feat(ui): support creating multi-source applications in New App panel [CONTINUED..] (#27095)

Signed-off-by: Dave Canton <dvcanton7@gmail.com>
Signed-off-by: Atif Ali <atali@redhat.com>
Co-authored-by: Dave Canton <dvcanton7@gmail.com>
This commit is contained in:
Atif Ali 2026-04-15 10:47:43 -04:00 committed by GitHub
parent ecc178f03e
commit 706a0370c2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 788 additions and 238 deletions

View file

@ -8,8 +8,10 @@ const CREATE_APPLICATION_BUTTON_CANCEL: By = By.xpath('.//button[@qe-id="applica
const CREATE_APPLICATION_FIELD_APP_NAME: By = By.xpath('.//input[@qeid="application-create-field-app-name"]');
const CREATE_APPLICATION_FIELD_PROJECT: By = By.xpath('.//input[@qe-id="application-create-field-project"]');
const CREATE_APPLICATION_FIELD_REPOSITORY_URL: By = By.xpath('.//input[@qe-id="application-create-field-repository-url"]');
const CREATE_APPLICATION_FIELD_REPOSITORY_PATH: By = By.xpath('.//input[@qe-id="application-create-field-path"]');
const CREATE_APPLICATION_SOURCE_1_FIELD_REPOSITORY_URL: By = By.xpath('.//input[@qe-id="application-create-source-1-field-repository-url"]');
const CREATE_APPLICATION_SOURCE_1_FIELD_REPOSITORY_PATH: By = By.xpath('.//input[@qe-id="application-create-source-1-field-path"]');
const CREATE_APPLICATION_SOURCE_2_FIELD_REPOSITORY_PATH: By = By.xpath('.//input[@qe-id="application-create-source-2-field-path"]');
const CREATE_APPLICATION_SOURCE_2_FIELD_REPOSITORY_URL: By = By.xpath('.//input[@qe-id="application-create-source-2-field-repository-url"]');
const CREATE_APPLICATION_DROPDOWN_DESTINATION: By = By.xpath('.//div[@qe-id="application-create-dropdown-destination"]');
const CREATE_APPLICATION_DROPDOWN_MENU_URL: By = By.xpath('.//li[@qe-id="application-create-dropdown-destination-URL"]');
@ -47,9 +49,17 @@ export class ApplicationCreatePanel extends Base {
}
}
public async setSourceRepoUrl(sourceRepoUrl: string): Promise<void> {
public async setSourceOneRepoUrl(sourceRepoUrl: string): Promise<void> {
try {
const reposUrl = await UiTestUtilities.findUiElement(this.driver, CREATE_APPLICATION_FIELD_REPOSITORY_URL);
const reposUrl = await UiTestUtilities.findUiElement(this.driver, CREATE_APPLICATION_SOURCE_1_FIELD_REPOSITORY_URL);
await reposUrl.sendKeys(sourceRepoUrl);
} catch (err) {
throw new Error(err);
}
}
public async setSourceTwoRepoUrl(sourceRepoUrl: string): Promise<void> {
try {
const reposUrl = await UiTestUtilities.findUiElement(this.driver, CREATE_APPLICATION_SOURCE_2_FIELD_REPOSITORY_URL);
await reposUrl.sendKeys(sourceRepoUrl);
} catch (err: any) {
UiTestUtilities.log('Error caught while setting source repo URL: ' + err);
@ -57,9 +67,18 @@ export class ApplicationCreatePanel extends Base {
}
}
public async setSourceRepoPath(sourceRepoPath: string): Promise<void> {
public async setSourceOneRepoPath(sourceRepoPath: string): Promise<void> {
try {
const path = await UiTestUtilities.findUiElement(this.driver, CREATE_APPLICATION_FIELD_REPOSITORY_PATH);
const path = await UiTestUtilities.findUiElement(this.driver, CREATE_APPLICATION_SOURCE_1_FIELD_REPOSITORY_PATH);
await path.sendKeys(sourceRepoPath);
} catch (err) {
throw new Error(err);
}
}
public async setSourceTwoRepoPath(sourceRepoPath: string): Promise<void> {
try {
const path = await UiTestUtilities.findUiElement(this.driver, CREATE_APPLICATION_SOURCE_2_FIELD_REPOSITORY_PATH);
await path.sendKeys(sourceRepoPath);
} catch (err: any) {
UiTestUtilities.log('Error caught while setting source repo path: ' + err);
@ -195,8 +214,10 @@ export class ApplicationCreatePanel extends Base {
public async createApplication(
appName: string,
projectName: string,
sourceRepoUrl: string,
sourceRepoPath: string,
sourceOneRepoUrl: string,
sourceOneRepoPath: string,
sourceTwoRepoUrl: string,
sourceTwoRepoPath: string,
destinationClusterName: string,
destinationNamespace: string
): Promise<void> {
@ -204,8 +225,10 @@ export class ApplicationCreatePanel extends Base {
try {
await this.setAppName(appName);
await this.setProjectName(projectName);
await this.setSourceRepoUrl(sourceRepoUrl);
await this.setSourceRepoPath(sourceRepoPath);
await this.setSourceOneRepoUrl(sourceOneRepoUrl);
await this.setSourceOneRepoPath(sourceOneRepoPath);
await this.setSourceTwoRepoUrl(sourceTwoRepoUrl);
await this.setSourceTwoRepoPath(sourceTwoRepoPath);
await this.selectDestinationClusterNameMenu(destinationClusterName);
await this.setDestinationNamespace(destinationNamespace);
await this.clickCreateButton();

View file

@ -29,8 +29,10 @@ async function doTest() {
UiTestUtilities.log('About to create application');
await applicationCreatePanel.setAppName(Configuration.APP_NAME);
await applicationCreatePanel.setProjectName(Configuration.APP_PROJECT);
await applicationCreatePanel.setSourceRepoUrl(Configuration.GIT_REPO);
await applicationCreatePanel.setSourceRepoPath(Configuration.SOURCE_REPO_PATH);
await applicationCreatePanel.setSourceOneRepoUrl(Configuration.GIT_REPO);
await applicationCreatePanel.setSourceOneRepoPath(Configuration.SOURCE_REPO_PATH);
await applicationCreatePanel.setSourceTwoRepoUrl(Configuration.GIT_REPO);
await applicationCreatePanel.setSourceTwoRepoPath(Configuration.SOURCE_REPO_PATH);
await applicationCreatePanel.selectDestinationClusterNameMenu(Configuration.DESTINATION_CLUSTER_NAME);
await applicationCreatePanel.setDestinationNamespace(Configuration.DESTINATION_NAMESPACE);
await applicationCreatePanel.clickCreateButton();

View file

@ -1,4 +1,5 @@
@import 'node_modules/argo-ui/src/styles/config';
@import 'node_modules/argo-ui/src/styles/theme';
.application-create-panel {
@ -25,4 +26,132 @@
.row.argo-form-row .columns {
padding-left: 0;
}
&__add-source {
margin-top: 0.75em;
}
&__multi-source-block {
margin-bottom: 0.5em;
.argo-field-container {
display: flex;
flex-direction: column-reverse;
align-items: stretch;
padding-top: 0;
min-height: 0;
}
.argo-field-container > .argo-field,
.argo-field-container > .argo-textarea {
grid-area: unset;
}
.argo-field-container > label.argo-label-placeholder {
position: static;
grid-area: unset;
align-self: flex-start;
transform: none !important;
font-size: 12px;
line-height: 1.25;
margin: 0 0 2px;
padding: 0;
pointer-events: none;
@include themify($themes) {
color: themed('text-2');
}
}
.argo-field-container > .argo-field:focus + label.argo-label-placeholder,
.argo-field-container > .argo-textarea:focus + label.argo-label-placeholder {
font-size: 12px;
transform: none !important;
@include themify($themes) {
color: themed('light-argo-teal-7');
}
}
}
&__multi-source-title {
font-weight: 600;
}
&__multi-source-section.white-box {
margin-bottom: 1rem;
@include themify($themes) {
border: 1px solid themed('light-argo-gray-4');
background-color: themed('background-2');
}
box-shadow: 1px 2px 3px rgba(0, 0, 0, 0.12);
&:last-child {
margin-bottom: 0;
}
}
&__multi-source-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
&__multi-source-header-actions {
display: flex;
align-items: center;
gap: 0.5rem;
}
&__multi-source-label {
font-weight: 600;
font-size: 13px;
text-transform: uppercase;
@include themify($themes) {
color: themed('text-2');
}
}
&__multi-source-remove-btn {
white-space: nowrap;
}
&__multi-source-collapse-btn {
background: transparent;
border: none;
cursor: pointer;
padding: 4px 8px;
line-height: 1;
@include themify($themes) {
color: themed('light-argo-teal-7');
}
}
&__multi-source-section .revision-form-field {
width: 100%;
.revision-form-field__main {
flex: 1 1 auto;
min-width: 0;
}
}
&__multi-source-collapsed {
margin-bottom: 12px;
cursor: pointer;
}
&__multi-source-params {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid $argo-color-gray-4;
.application-parameters > .white-box.editable-panel {
box-shadow: none;
margin-bottom: 0;
}
.white-box__details {
padding-top: 0;
}
}
}

View file

@ -3,6 +3,7 @@ import {AutocompleteField, Checkbox, DataLoader, DropDownMenu, FormField, HelpIc
import * as deepMerge from 'deepmerge';
import * as React from 'react';
import {FieldApi, Form, FormApi, FormField as ReactFormField, Text} from 'react-form';
import {cloneDeep, debounce} from 'lodash-es';
import {YamlEditor} from '../../../shared/components';
import * as models from '../../../shared/models';
import {services} from '../../../shared/services';
@ -12,20 +13,14 @@ import {ApplicationRetryOptions} from '../application-retry-options/application-
import {ApplicationSyncOptionsField} from '../application-sync-options/application-sync-options';
import {SetFinalizerOnApplication} from './set-finalizer-on-application';
import {HydratorSourcePanel} from './hydrator-source-panel';
import {CollapsibleMultiSourceSection} from './collapsible-multi-source-section';
import {SourcePanel} from './source-panel';
import './application-create-panel.scss';
import {getAppDefaultSource} from '../utils';
import {debounce} from 'lodash-es';
import {APP_SOURCE_TYPES, normalizeTypeFieldsForSource} from '../shared/app-source-edit';
const jsonMergePatch = require('json-merge-patch');
const appTypes = new Array<{field: string; type: models.AppSourceType}>(
{type: 'Helm', field: 'helm'},
{type: 'Kustomize', field: 'kustomize'},
{type: 'Directory', field: 'directory'},
{type: 'Plugin', field: 'plugin'}
);
const DEFAULT_APP: Partial<models.Application> = {
apiVersion: 'argoproj.io/v1alpha1',
kind: 'Application',
@ -127,6 +122,8 @@ export const ApplicationCreatePanel = (props: {
let destinationComboValue = destinationFieldChanges.destFormat;
const authSettingsCtx = React.useContext(AuthSettingsCtx);
const [multiSourceMode, setMultiSourceMode] = React.useState(() => (app.spec?.sources?.length ?? 0) >= 2);
React.useEffect(() => {
comboSwitchedFromPanel.current = false;
}, []);
@ -137,16 +134,6 @@ export const ApplicationCreatePanel = (props: {
};
}, [debouncedOnAppChanged]);
function normalizeTypeFields(formApi: FormApi, type: models.AppSourceType) {
const appToNormalize = formApi.getFormState().values;
for (const item of appTypes) {
if (item.type !== type) {
delete appToNormalize.spec.source[item.field];
}
}
formApi.setAllValues(appToNormalize);
}
const currentName = app.spec.destination.name;
const currentServer = app.spec.destination.server;
if (destinationFieldChanges.destFormatChanged !== null) {
@ -193,9 +180,49 @@ export const ApplicationCreatePanel = (props: {
delete data.spec.sourceHydrator.hydrateTo;
}
if (multiSourceMode && data.spec.sources && data.spec.sources.length > 0) {
delete data.spec.source;
}
props.createApp(data);
};
function handleAddSource(api: FormApi) {
const updated = cloneDeep(api.getFormState().values) as models.Application;
if (!multiSourceMode) {
updated.spec.sources = [{...(updated.spec.source || {path: '', repoURL: '', targetRevision: 'HEAD'})}, {path: '', repoURL: '', targetRevision: 'HEAD'}];
delete updated.spec.source;
delete updated.spec.sourceHydrator;
setIsHydratorEnabled(false);
setMultiSourceMode(true);
} else {
if (!updated.spec.sources) {
updated.spec.sources = [];
}
updated.spec.sources.push({path: '', repoURL: '', targetRevision: 'HEAD'});
}
api.setAllValues(updated);
}
function handleRemoveSource(api: FormApi, index: number) {
const updated = cloneDeep(api.getFormState().values) as models.Application;
const sources = updated.spec.sources;
if (!sources || index < 0 || index >= sources.length) {
return;
}
sources.splice(index, 1);
if (sources.length === 0) {
updated.spec.source = {path: '', repoURL: '', targetRevision: 'HEAD'};
delete updated.spec.sources;
setMultiSourceMode(false);
} else if (sources.length === 1) {
updated.spec.source = sources[0];
delete updated.spec.sources;
setMultiSourceMode(false);
}
api.setAllValues(updated);
}
return (
<DataLoader
key='creation-deps'
@ -208,7 +235,7 @@ export const ApplicationCreatePanel = (props: {
}>
{({projects, clusters, reposInfo}) => {
const repos = reposInfo.map(info => info.repo).sort();
const repoInfo = reposInfo.find(info => info.repo === app.spec.source.repoURL);
const repoInfo = reposInfo.find(info => info.repo === app.spec.source?.repoURL);
if (repoInfo) {
normalizeAppSource(app, repoInfo.type || currentRepoType.current || 'git');
}
@ -221,8 +248,10 @@ export const ApplicationCreatePanel = (props: {
input={app}
onCancel={() => setYamlMode(false)}
onSave={async patch => {
props.onAppChanged(jsonMergePatch.apply(app, JSON.parse(patch)));
const next = jsonMergePatch.apply(app, JSON.parse(patch)) as models.Application;
props.onAppChanged(next);
setYamlMode(false);
setMultiSourceMode((next.spec?.sources?.length ?? 0) >= 2);
return true;
}}
/>
@ -232,23 +261,43 @@ export const ApplicationCreatePanel = (props: {
const hasHydrator = !!a.spec.sourceHydrator;
const source = a.spec.source;
return {
'metadata.name': !a.metadata.name && 'Application Name is required',
'spec.project': !a.spec.project && 'Project Name is required',
'spec.source.repoURL': !hasHydrator && !source?.repoURL && 'Repository URL is required',
'spec.source.targetRevision': !hasHydrator && !source?.targetRevision && source?.hasOwnProperty('chart') && 'Version is required',
'spec.source.path': !hasHydrator && !source?.path && !source?.chart && 'Path is required',
'spec.source.chart': !hasHydrator && !source?.path && !source?.chart && 'Chart is required',
// Verify cluster URL when there is no cluster name field or the name value is empty
const destinationErrors = {
'spec.destination.server':
!a.spec.destination.server &&
(!a.spec.destination.hasOwnProperty('name') || a.spec.destination.name === '') &&
'Cluster URL is required',
// Verify cluster name when there is no cluster URL field or the URL value is empty
!a.spec.destination.server && (!a.spec.destination.hasOwnProperty('name') || a.spec.destination.name === '')
? 'Cluster URL is required'
: undefined,
'spec.destination.name':
!a.spec.destination.name &&
(!a.spec.destination.hasOwnProperty('server') || a.spec.destination.server === '') &&
'Cluster name is required'
!a.spec.destination.name && (!a.spec.destination.hasOwnProperty('server') || a.spec.destination.server === '')
? 'Cluster name is required'
: undefined
};
if (multiSourceMode && !hasHydrator) {
const errs: Record<string, string | undefined> = {
'metadata.name': !a.metadata.name ? 'Application Name is required' : undefined,
'spec.project': !a.spec.project ? 'Project Name is required' : undefined,
...destinationErrors
};
const sources = a.spec.sources || [];
for (let i = 0; i < sources.length; i++) {
const s = sources[i];
errs[`spec.sources[${i}].repoURL`] = !s?.repoURL ? 'Repository URL is required' : undefined;
errs[`spec.sources[${i}].targetRevision`] = !s?.targetRevision && s?.hasOwnProperty('chart') ? 'Version is required' : undefined;
errs[`spec.sources[${i}].path`] = !s?.path && !s?.chart ? 'Path is required' : undefined;
errs[`spec.sources[${i}].chart`] = !s?.path && !s?.chart ? 'Chart is required' : undefined;
}
return errs;
}
return {
'metadata.name': !a.metadata.name ? 'Application Name is required' : undefined,
'spec.project': !a.spec.project ? 'Project Name is required' : undefined,
'spec.source.repoURL': !hasHydrator && !source?.repoURL ? 'Repository URL is required' : undefined,
'spec.source.targetRevision':
!hasHydrator && !source?.targetRevision && source?.hasOwnProperty('chart') ? 'Version is required' : undefined,
'spec.source.path': !hasHydrator && !source?.path && !source?.chart ? 'Path is required' : undefined,
'spec.source.chart': !hasHydrator && !source?.path && !source?.chart ? 'Chart is required' : undefined,
...destinationErrors
};
}}
defaultValues={app}
@ -256,6 +305,8 @@ export const ApplicationCreatePanel = (props: {
onSubmit={onCreateApp}
getApi={props.getFormApi}>
{api => {
const formApp = api.getFormState().values as models.Application;
const generalPanel = () => (
<div className='white-box'>
<p>GENERAL</p>
@ -316,59 +367,95 @@ export const ApplicationCreatePanel = (props: {
</div>
);
const sourcePanel = () => (
<div className='white-box'>
<p>SOURCE</p>
{/* Only show hydrator checkbox if hydrator is enabled in auth settings */}
{authSettingsCtx?.hydratorEnabled && (
<div className='row argo-form-row'>
<div className='columns small-12'>
<div className='checkbox-container'>
<Checkbox
onChange={(val: boolean) => {
const updatedApp = api.getFormState().values as models.Application;
if (val) {
if (!updatedApp.spec.sourceHydrator) {
updatedApp.spec.sourceHydrator = {
drySource: {
repoURL: updatedApp.spec.source.repoURL,
targetRevision: updatedApp.spec.source.targetRevision,
path: updatedApp.spec.source.path
},
syncSource: savedSyncSource
};
delete updatedApp.spec.source;
}
} else if (updatedApp.spec.sourceHydrator) {
setSavedSyncSource(updatedApp.spec.sourceHydrator.syncSource);
updatedApp.spec.source = updatedApp.spec.sourceHydrator.drySource;
delete updatedApp.spec.sourceHydrator;
}
api.setAllValues(updatedApp);
setIsHydratorEnabled(val);
}}
checked={!!(api.getFormState().values as models.Application).spec.sourceHydrator}
id='enable-source-hydrator'
/>
<label htmlFor='enable-source-hydrator'>enable source hydrator</label>
</div>
const sourcePanel = () => {
if (multiSourceMode) {
const count = formApp.spec.sources?.length ?? 0;
return (
<div className='white-box'>
<p>SOURCES</p>
{Array.from({length: count}, (_, i) => (
<CollapsibleMultiSourceSection
key={`msrc-${i}`}
index={i}
formApi={api}
repos={repos}
reposInfo={reposInfo}
formApp={formApp}
canRemove={count >= 2}
onRemove={() => handleRemoveSource(api, i)}
/>
))}
<div className='application-create-panel__add-source'>
<button type='button' className='argo-button argo-button--base' onClick={() => handleAddSource(api)}>
<i className='fa fa-plus' style={{marginLeft: '-5px', marginRight: '5px'}} />
Add Source
</button>
</div>
</div>
)}
{isHydratorEnabled ? (
<HydratorSourcePanel formApi={api} repos={repos} />
) : (
<SourcePanel
formApi={api}
repos={repos}
repoInfo={repoInfo}
currentRepoType={currentRepoType}
lastGitOrHelmUrl={lastGitOrHelmUrl}
lastOciUrl={lastOciUrl}
/>
)}
</div>
);
);
}
return (
<div className='white-box'>
<p>SOURCE</p>
{authSettingsCtx?.hydratorEnabled && (
<div className='row argo-form-row'>
<div className='columns small-12'>
<div className='checkbox-container'>
<Checkbox
onChange={(val: boolean) => {
const updatedApp = api.getFormState().values as models.Application;
if (val) {
if (!updatedApp.spec.sourceHydrator) {
updatedApp.spec.sourceHydrator = {
drySource: {
repoURL: updatedApp.spec.source.repoURL,
targetRevision: updatedApp.spec.source.targetRevision,
path: updatedApp.spec.source.path
},
syncSource: savedSyncSource
};
delete updatedApp.spec.source;
}
} else if (updatedApp.spec.sourceHydrator) {
setSavedSyncSource(updatedApp.spec.sourceHydrator.syncSource);
updatedApp.spec.source = updatedApp.spec.sourceHydrator.drySource;
delete updatedApp.spec.sourceHydrator;
}
api.setAllValues(updatedApp);
setIsHydratorEnabled(val);
}}
checked={!!(api.getFormState().values as models.Application).spec.sourceHydrator}
id='enable-source-hydrator'
/>
<label htmlFor='enable-source-hydrator'>enable source hydrator</label>
</div>
</div>
</div>
)}
{isHydratorEnabled ? (
<HydratorSourcePanel formApi={api} repos={repos} />
) : (
<React.Fragment>
<SourcePanel
formApi={api}
repos={repos}
repoInfo={repoInfo}
currentRepoType={currentRepoType}
lastGitOrHelmUrl={lastGitOrHelmUrl}
lastOciUrl={lastOciUrl}
/>
<div className='application-create-panel__add-source'>
<button type='button' className='argo-button argo-button--base' onClick={() => handleAddSource(api)}>
<i className='fa fa-plus' style={{marginLeft: '-5px', marginRight: '5px'}} />
Add Source
</button>
</div>
</React.Fragment>
)}
</div>
);
};
const destinationPanel = () => (
<div className='white-box'>
<p>DESTINATION</p>
@ -437,81 +524,86 @@ export const ApplicationCreatePanel = (props: {
</div>
);
const typePanel = () => (
<DataLoader
input={{
repoURL: app.spec.source.repoURL,
path: app.spec.source.path,
chart: app.spec.source.chart,
targetRevision: app.spec.source.targetRevision,
appName: app.metadata.name
}}
load={async src => {
if (src.repoURL && src.targetRevision && (src.path || src.chart)) {
return services.repos.appDetails(src, src.appName, app.spec.project, 0, 0).catch(() => ({
type: 'Directory',
details: {}
}));
} else {
const typePanel = () => {
const liveApp = api.getFormState().values as models.Application;
const liveSrc = liveApp.spec.source;
return (
<DataLoader
input={{
repoURL: liveSrc?.repoURL,
path: liveSrc?.path,
chart: liveSrc?.chart,
targetRevision: liveSrc?.targetRevision,
appName: liveApp.metadata.name,
project: liveApp.spec.project
}}
load={async src => {
if (src.repoURL && src.targetRevision && (src.path || src.chart)) {
return services.repos.appDetails(src, src.appName, src.project, 0, 0).catch(() => ({
type: 'Directory',
details: {}
}));
}
return {
type: 'Directory',
details: {}
};
}
}}>
{(details: models.RepoAppDetails) => {
const type = (explicitPathType && explicitPathType.path === app.spec.source.path && explicitPathType.type) || details.type;
if (details.type !== type) {
switch (type) {
case 'Helm':
details = {
type,
path: details.path,
helm: {name: '', valueFiles: [], path: '', parameters: [], fileParameters: []}
};
break;
case 'Kustomize':
details = {type, path: details.path, kustomize: {path: ''}};
break;
case 'Plugin':
details = {type, path: details.path, plugin: {name: '', env: []}};
break;
// Directory
default:
details = {type, path: details.path, directory: {}};
break;
}}>
{(details: models.RepoAppDetails) => {
const pathKey = (liveSrc?.chart || liveSrc?.path || '') as string;
const type = (explicitPathType && explicitPathType.path === pathKey && explicitPathType.type) || details.type;
let d = details;
if (d.type !== type) {
switch (type) {
case 'Helm':
d = {
type,
path: d.path,
helm: {name: '', valueFiles: [], path: '', parameters: [], fileParameters: []}
};
break;
case 'Kustomize':
d = {type, path: d.path, kustomize: {path: ''}};
break;
case 'Plugin':
d = {type, path: d.path, plugin: {name: '', env: []}};
break;
default:
d = {type, path: d.path, directory: {}};
break;
}
}
}
return (
<React.Fragment>
<DropDownMenu
anchor={() => (
<p>
{type} <i className='fa fa-caret-down' />
</p>
)}
qeId='application-create-dropdown-source'
items={appTypes.map(item => ({
title: item.type,
action: () => {
setExplicitPathType({type: item.type, path: app.spec.source.path});
normalizeTypeFields(api, item.type);
}
}))}
/>
<ApplicationParameters
noReadonlyMode={true}
application={app}
details={details}
save={async updatedApp => {
api.setAllValues(updatedApp);
}}
/>
</React.Fragment>
);
}}
</DataLoader>
);
return (
<React.Fragment>
<DropDownMenu
anchor={() => (
<p>
{type} <i className='fa fa-caret-down' />
</p>
)}
qeId='application-create-dropdown-source'
items={APP_SOURCE_TYPES.map(item => ({
title: item.type,
action: () => {
setExplicitPathType({type: item.type, path: pathKey});
normalizeTypeFieldsForSource(api, item.type, undefined);
}
}))}
/>
<ApplicationParameters
noReadonlyMode={true}
application={liveApp}
details={d}
save={async updatedApp => {
api.setAllValues(updatedApp);
}}
/>
</React.Fragment>
);
}}
</DataLoader>
);
};
return (
<form onSubmit={api.submitForm} role='form' className='width-control'>
@ -521,7 +613,7 @@ export const ApplicationCreatePanel = (props: {
{destinationPanel()}
{typePanel()}
{!multiSourceMode && typePanel()}
</form>
);
}}

View file

@ -0,0 +1,72 @@
import * as React from 'react';
import {FormApi} from 'react-form';
import * as models from '../../../shared/models';
import {CreatePanelSourceTypeParameters} from './create-panel-source-type-parameters';
import {SourcePanel} from './source-panel';
export function CollapsibleMultiSourceSection(props: {
index: number;
formApi: FormApi;
repos: string[];
reposInfo: models.Repository[];
formApp: models.Application;
canRemove?: boolean;
onRemove?: () => void;
}) {
const [expanded, setExpanded] = React.useState(true);
const src = props.formApp.spec.sources?.[props.index];
const repoInfoFor = props.reposInfo.find(r => r.repo === src?.repoURL);
const title = `Source ${props.index + 1}${src?.name ? `${src.name}` : ''}: ${src?.repoURL || ''}`;
const desc = [src?.path && `PATH=${src.path}`, src?.chart && `CHART=${src.chart}`, src?.targetRevision && `REVISION=${src.targetRevision}`].filter(Boolean).join(', ');
if (!expanded) {
return (
<div className='settings-overview__redirect-panel application-create-panel__multi-source-collapsed' onClick={() => setExpanded(true)}>
<div className='editable-panel__collapsible-button'>
<i className='fa fa-angle-down filter__collapse editable-panel__collapsible-button__override' />
</div>
<div className='settings-overview__redirect-panel__content'>
<div className='settings-overview__redirect-panel__title'>{title}</div>
<div className='settings-overview__redirect-panel__description'>{desc}</div>
</div>
</div>
);
}
return (
<div className='white-box application-create-panel__multi-source-section'>
<div className='application-create-panel__multi-source-header'>
<span className='application-create-panel__multi-source-label'>Source {props.index + 1}</span>
<div className='application-create-panel__multi-source-header-actions'>
{props.canRemove && props.onRemove && (
<button
type='button'
className='argo-button argo-button--base application-create-panel__multi-source-remove-btn'
title='Remove this source'
onClick={e => {
e.stopPropagation();
props.onRemove?.();
}}>
<i className='fa fa-minus' style={{marginLeft: '-5px', marginRight: '5px'}} />
Remove source
</button>
)}
<button
type='button'
className='application-create-panel__multi-source-collapse-btn'
title='Collapse source'
aria-label='Collapse source section'
onClick={() => setExpanded(false)}>
<i className='fa fa-angle-up' />
</button>
</div>
</div>
<div className='application-create-panel__multi-source-block'>
<SourcePanel formApi={props.formApi} repos={props.repos} repoInfo={repoInfoFor} sourceIndex={props.index} suppressMultiSourceHeading={true} />
</div>
<div className='application-create-panel__multi-source-params'>
<CreatePanelSourceTypeParameters formApi={props.formApi} sourceIndex={props.index} />
</div>
</div>
);
}

View file

@ -0,0 +1,100 @@
import {DataLoader, DropDownMenu} from 'argo-ui';
import * as React from 'react';
import {FormApi} from 'react-form';
import * as models from '../../../shared/models';
import {services} from '../../../shared/services';
import {ApplicationParameters} from '../application-parameters/application-parameters';
import {APP_SOURCE_TYPES, normalizeTypeFieldsForSource} from '../shared/app-source-edit';
function pathKeyForSource(src: models.ApplicationSource | undefined): string {
if (!src) {
return '';
}
return (src.chart || src.path || '') as string;
}
export const CreatePanelSourceTypeParameters = (props: {formApi: FormApi; sourceIndex: number}) => {
const [explicitPathType, setExplicitPathType] = React.useState<{path: string; type: models.AppSourceType}>(null);
const formApp = props.formApi.getFormState().values as models.Application;
const src = formApp.spec.sources?.[props.sourceIndex];
const qeN = props.sourceIndex + 1;
return (
<DataLoader
input={{
repoURL: src?.repoURL,
path: src?.path,
chart: src?.chart,
targetRevision: src?.targetRevision,
appName: formApp.metadata.name,
project: formApp.spec.project,
pathKey: pathKeyForSource(src)
}}
load={async input => {
if (input.repoURL && input.targetRevision && (input.path || input.chart)) {
return services.repos.appDetails(input, input.appName, input.project, 0, 0).catch(() => ({
type: 'Directory' as const,
details: {}
}));
}
return {
type: 'Directory' as const,
details: {}
};
}}>
{(details: models.RepoAppDetails) => {
const key = pathKeyForSource(src);
const type = (explicitPathType && explicitPathType.path === key && explicitPathType.type) || details.type;
let d = details;
if (d.type !== type) {
switch (type) {
case 'Helm':
d = {
type,
path: d.path,
helm: {name: '', valueFiles: [], path: '', parameters: [], fileParameters: []}
};
break;
case 'Kustomize':
d = {type, path: d.path, kustomize: {path: ''}};
break;
case 'Plugin':
d = {type, path: d.path, plugin: {name: '', env: []}};
break;
default:
d = {type, path: d.path, directory: {}};
break;
}
}
return (
<React.Fragment>
<DropDownMenu
anchor={() => (
<p>
{type} <i className='fa fa-caret-down' />
</p>
)}
qeId={`application-create-dropdown-source-${qeN}`}
items={APP_SOURCE_TYPES.map(item => ({
title: item.type,
action: () => {
setExplicitPathType({type: item.type, path: key});
normalizeTypeFieldsForSource(props.formApi, item.type, props.sourceIndex);
}
}))}
/>
<ApplicationParameters
noReadonlyMode={true}
application={formApp}
details={d}
multiSourceIndex={props.sourceIndex}
save={async updatedApp => {
props.formApi.setAllValues(updatedApp);
}}
/>
</React.Fragment>
);
}}
</DataLoader>
);
};

View file

@ -7,29 +7,62 @@ import {services} from '../../../shared/services';
import {RevisionFormField} from '../revision-form-field/revision-form-field';
import {getAppDefaultSource} from '../utils';
interface SourcePanelProps {
function getSourceForPanel(app: models.Application, sourceIndex?: number): models.ApplicationSource | null {
if (sourceIndex !== undefined) {
return app.spec.sources?.[sourceIndex] ?? null;
}
return getAppDefaultSource(app);
}
function fieldPath(sourceIndex: number | undefined, field: string): string {
if (sourceIndex !== undefined) {
return `spec.sources[${sourceIndex}].${field}`;
}
return `spec.source.${field}`;
}
export interface SourcePanelProps {
formApi: FormApi;
repos: string[];
repoInfo?: models.Repository;
currentRepoType: React.MutableRefObject<string>;
lastGitOrHelmUrl: React.MutableRefObject<string>;
lastOciUrl: React.MutableRefObject<string>;
sourceIndex?: number;
suppressMultiSourceHeading?: boolean;
currentRepoType?: React.MutableRefObject<string | undefined>;
lastGitOrHelmUrl?: React.MutableRefObject<string>;
lastOciUrl?: React.MutableRefObject<string>;
}
export const SourcePanel = (props: SourcePanelProps) => {
const internalRepoType = React.useRef<string | undefined>(undefined);
const internalLastGit = React.useRef('');
const internalLastOci = React.useRef('');
const isMulti = props.sourceIndex !== undefined;
const lastGitOrHelmUrl = isMulti ? internalLastGit : props.lastGitOrHelmUrl;
const lastOciUrl = isMulti ? internalLastOci : props.lastOciUrl;
const currentRepoType = isMulti ? internalRepoType : props.currentRepoType;
const currentApp = props.formApi.getFormState().values as models.Application;
const currentSource = getAppDefaultSource(currentApp);
const currentSource = getSourceForPanel(currentApp, props.sourceIndex);
const repoType = currentSource?.repoURL?.startsWith('oci://') ? 'oci' : (currentSource && Object.prototype.hasOwnProperty.call(currentSource, 'chart') && 'helm') || 'git';
const idx = props.sourceIndex;
const qeSourceN = isMulti && idx !== undefined ? idx + 1 : 0;
const specSourceForRevision = isMulti ? currentApp.spec.sources?.[props.sourceIndex] : currentApp.spec.source;
return (
<React.Fragment>
{isMulti && !props.suppressMultiSourceHeading && (
<p className='application-create-panel__multi-source-title' style={{marginTop: idx > 0 ? '1em' : 0}}>
SOURCE {idx + 1}
</p>
)}
<div style={{display: 'flex', alignItems: 'flex-start'}}>
<div style={{flex: '1 1 auto', minWidth: 0}}>
<FormField
formApi={props.formApi}
label='Repository URL'
qeId='application-create-field-repository-url'
field='spec.source.repoURL'
qeId={isMulti ? `application-create-source-${qeSourceN}-field-repository-url` : 'application-create-field-repository-url'}
field={fieldPath(idx, 'repoURL')}
component={AutocompleteField}
componentProps={{
items: props.repos,
@ -50,20 +83,22 @@ export const SourcePanel = (props: SourcePanelProps) => {
{repoType.toUpperCase()} <i className='fa fa-caret-down' />
</p>
)}
qeId='application-create-dropdown-source-repository'
qeId={isMulti ? `application-create-dropdown-source-repository-${qeSourceN}` : 'application-create-dropdown-source-repository'}
items={['git', 'helm', 'oci'].map((type: 'git' | 'helm' | 'oci') => ({
title: type.toUpperCase(),
action: () => {
if (repoType !== type) {
const updatedApp = props.formApi.getFormState().values as models.Application;
const source = getAppDefaultSource(updatedApp);
// Save the previous URL value for later use
if (repoType === 'git' || repoType === 'helm') {
props.lastGitOrHelmUrl.current = source.repoURL;
} else {
props.lastOciUrl.current = source.repoURL;
const source = getSourceForPanel(updatedApp, props.sourceIndex);
if (!source) {
return;
}
props.currentRepoType.current = type;
if (repoType === 'git' || repoType === 'helm') {
lastGitOrHelmUrl.current = source.repoURL;
} else {
lastOciUrl.current = source.repoURL;
}
currentRepoType.current = type;
switch (type) {
case 'git':
case 'oci':
@ -72,8 +107,7 @@ export const SourcePanel = (props: SourcePanelProps) => {
delete source.chart;
}
source.targetRevision = 'HEAD';
source.repoURL =
type === 'git' ? props.lastGitOrHelmUrl.current : props.lastOciUrl.current === '' ? 'oci://' : props.lastOciUrl.current;
source.repoURL = type === 'git' ? lastGitOrHelmUrl.current : lastOciUrl.current === '' ? 'oci://' : lastOciUrl.current;
break;
case 'helm':
if (Object.prototype.hasOwnProperty.call(source, 'path')) {
@ -81,7 +115,7 @@ export const SourcePanel = (props: SourcePanelProps) => {
delete source.path;
}
source.targetRevision = '';
source.repoURL = props.lastGitOrHelmUrl.current;
source.repoURL = lastGitOrHelmUrl.current;
break;
}
props.formApi.setAllValues(updatedApp);
@ -96,10 +130,16 @@ export const SourcePanel = (props: SourcePanelProps) => {
{(repoType === 'oci' && (
<React.Fragment>
<RevisionFormField formApi={props.formApi} helpIconTop={'2.5em'} repoURL={currentApp.spec.source.repoURL} repoType={repoType} />
<RevisionFormField
formApi={props.formApi}
helpIconTop={'2.5em'}
repoURL={specSourceForRevision?.repoURL || ''}
repoType={repoType}
fieldValue={fieldPath(idx, 'targetRevision')}
/>
<div className='argo-form-row'>
<DataLoader
input={{repoURL: currentApp.spec.source.repoURL, revision: currentApp.spec.source.targetRevision}}
input={{repoURL: specSourceForRevision?.repoURL, revision: specSourceForRevision?.targetRevision}}
load={async src =>
src.repoURL &&
// TODO: for autocomplete we need to fetch paths that are used by other apps within the same project making use of the same OCI repo
@ -109,8 +149,8 @@ export const SourcePanel = (props: SourcePanelProps) => {
<FormField
formApi={props.formApi}
label='Path'
qeId='application-create-field-path'
field='spec.source.path'
qeId={isMulti ? `application-create-source-${qeSourceN}-field-path` : 'application-create-field-path'}
field={fieldPath(idx, 'path')}
component={AutocompleteField}
componentProps={{
items: paths,
@ -124,10 +164,16 @@ export const SourcePanel = (props: SourcePanelProps) => {
)) ||
(repoType === 'git' && (
<React.Fragment>
<RevisionFormField formApi={props.formApi} helpIconTop={'2.5em'} repoURL={currentApp.spec.source.repoURL} repoType={repoType} />
<RevisionFormField
formApi={props.formApi}
helpIconTop={'2.5em'}
repoURL={specSourceForRevision?.repoURL || ''}
repoType={repoType}
fieldValue={fieldPath(idx, 'targetRevision')}
/>
<div className='argo-form-row'>
<DataLoader
input={{repoURL: currentApp.spec.source.repoURL, revision: currentApp.spec.source.targetRevision}}
input={{repoURL: specSourceForRevision?.repoURL, revision: specSourceForRevision?.targetRevision}}
load={async src =>
(src.repoURL &&
services.repos
@ -140,8 +186,8 @@ export const SourcePanel = (props: SourcePanelProps) => {
<FormField
formApi={props.formApi}
label='Path'
qeId='application-create-field-path'
field='spec.source.path'
qeId={isMulti ? `application-create-source-${qeSourceN}-field-path` : 'application-create-field-path'}
field={fieldPath(idx, 'path')}
component={AutocompleteField}
componentProps={{
items: apps,
@ -154,17 +200,19 @@ export const SourcePanel = (props: SourcePanelProps) => {
</React.Fragment>
)) || (
<DataLoader
input={{repoURL: currentApp.spec.source.repoURL}}
input={{repoURL: specSourceForRevision?.repoURL}}
load={async src => (src.repoURL && services.repos.charts(src.repoURL).catch(() => new Array<models.HelmChart>())) || new Array<models.HelmChart>()}>
{(charts: models.HelmChart[]) => {
const selectedChart = charts.find(chart => chart.name === props.formApi.getFormState().values.spec.source.chart);
const spec = props.formApi.getFormState().values.spec;
const chartName = isMulti ? spec.sources?.[props.sourceIndex as number]?.chart : spec.source?.chart;
const selectedChart = charts.find(chart => chart.name === chartName);
return (
<div className='row argo-form-row'>
<div className='columns small-10'>
<FormField
formApi={props.formApi}
label='Chart'
field='spec.source.chart'
field={fieldPath(idx, 'chart')}
component={AutocompleteField}
componentProps={{
items: charts.map(chart => chart.name),
@ -175,7 +223,7 @@ export const SourcePanel = (props: SourcePanelProps) => {
<div className='columns small-2'>
<FormField
formApi={props.formApi}
field='spec.source.targetRevision'
field={fieldPath(idx, 'targetRevision')}
component={AutocompleteField}
componentProps={{
items: (selectedChart && selectedChart.versions) || [],

View file

@ -153,6 +153,8 @@ export const ApplicationParameters = (props: {
handleCollapse?: (i: number, isCollapsed: boolean) => void;
appContext?: AppContext;
tempSource?: models.ApplicationSource;
/** When set with `details`, render parameters for `spec.sources[index]` (multi-source create / edit). */
multiSourceIndex?: number;
}) => {
const app = cloneDeep(props.application);
const source = getAppDefaultSource(app); // For source field
@ -252,20 +254,27 @@ export const ApplicationParameters = (props: {
// Create App, Add source, Rollback and History
let attributes: EditablePanelItem[] = [];
if (props.details) {
const ind = props.multiSourceIndex;
const isMulti = ind !== undefined;
const attrSource = isMulti ? app.spec.sources[ind] : props.tempSource ? props.tempSource : source;
if (isMulti && !attrSource) {
return null;
}
return getEditablePanel(
gatherDetails(
0,
isMulti ? ind : 0,
props.details,
attributes,
props.tempSource ? props.tempSource : source,
attrSource,
app,
setRemovedOverrides,
removedOverrides,
appParamsDeletedState,
setAppParamsDeletedState,
false
isMulti
),
props.details
props.details,
isMulti ? ind : undefined
);
} else {
// For single source field, details page where we have to do the load to retrieve repo details
@ -338,14 +347,20 @@ export const ApplicationParameters = (props: {
);
}
function getEditablePanel(items: EditablePanelItem[], repoAppDetails: models.RepoAppDetails): any {
function getEditablePanel(items: EditablePanelItem[], repoAppDetails: models.RepoAppDetails, multiSourceIndex?: number): any {
const ind = multiSourceIndex;
const isMulti = ind !== undefined;
const jsonnetTlas = isMulti ? `spec.sources[${ind}].directory.jsonnet.tlas` : 'spec.source.directory.jsonnet.tlas';
const jsonnetExtVars = isMulti ? `spec.sources[${ind}].directory.jsonnet.extVars` : 'spec.source.directory.jsonnet.extVars';
const helmValuesPath = isMulti ? `spec.sources[${ind}].helm.values` : 'spec.source.helm.values';
return (
<div className='application-parameters'>
<EditablePanel
save={
props.save &&
(async (input: models.Application) => {
const updatedSrc = input.spec.source;
const updatedSrc = isMulti ? input.spec.sources[ind] : input.spec.source;
function isDefined(item: any) {
return item !== null && item !== undefined;
@ -360,7 +375,7 @@ export const ApplicationParameters = (props: {
updatedSrc.kustomize.images = updatedSrc.kustomize.images.filter(isDefinedWithVersion);
}
let params = input.spec?.source?.plugin?.parameters;
let params = isMulti ? input.spec?.sources[ind]?.plugin?.parameters : input.spec?.source?.plugin?.parameters;
if (params) {
for (const param of params) {
if (param.map && param.array) {
@ -376,28 +391,37 @@ export const ApplicationParameters = (props: {
}
}
params = params.filter(param => !appParamsDeletedState.includes(param.name));
input.spec.source.plugin.parameters = params;
if (isMulti) {
const ms = input.spec.sources[ind];
if (!ms.plugin) {
ms.plugin = {name: '', env: [], parameters: []};
}
ms.plugin.parameters = params;
} else {
input.spec.source.plugin.parameters = params;
}
}
if (input.spec.source && input.spec.source.helm?.valuesObject) {
input.spec.source.helm.valuesObject = jsYaml.load(input.spec.source.helm.values); // Deserialize json
input.spec.source.helm.values = '';
if (updatedSrc && updatedSrc.helm?.valuesObject) {
updatedSrc.helm.valuesObject = jsYaml.load(updatedSrc.helm.values); // Deserialize json
updatedSrc.helm.values = '';
}
await props.save(input, {});
setRemovedOverrides(new Array<boolean>());
})
}
values={((repoAppDetails?.plugin || app?.spec?.source?.plugin) && cloneDeep(app)) || app}
values={((repoAppDetails?.plugin || (isMulti ? app?.spec?.sources[ind]?.plugin : app?.spec?.source?.plugin)) && cloneDeep(app)) || app}
validate={updatedApp => {
const errors = {} as any;
for (const fieldPath of ['spec.source.directory.jsonnet.tlas', 'spec.source.directory.jsonnet.extVars']) {
for (const fieldPath of [jsonnetTlas, jsonnetExtVars]) {
const invalid = ((getNestedField(updatedApp, fieldPath) || []) as Array<models.JsonnetVar>).filter(item => !item.name && !item.code);
errors[fieldPath] = invalid.length > 0 ? 'All fields must have name' : null;
}
if (updatedApp.spec.source && updatedApp.spec.source.helm?.values) {
const parsedValues = jsYaml.load(updatedApp.spec.source.helm.values);
errors['spec.source.helm.values'] = typeof parsedValues === 'object' ? null : 'Values must be a map';
const helmSrc = isMulti ? updatedApp.spec.sources[ind] : updatedApp.spec.source;
if (helmSrc?.helm?.values) {
const parsedValues = jsYaml.load(helmSrc.helm.values);
errors[helmValuesPath] = typeof parsedValues === 'object' ? null : 'Values must be a map';
}
return errors;

View file

@ -7,17 +7,9 @@ import {RevisionFormField} from '../../../applications/components/revision-form-
import {RevisionHelpIcon} from '../../../shared/components';
import * as models from '../../../shared/models';
import {services} from '../../../shared/services';
import {APP_SOURCE_TYPES, normalizeTypeFieldsForSource} from '../shared/app-source-edit';
import './source-panel.scss';
// This is similar to what is in application-create-panel.tsx. If the create panel
// is modified to support multi-source apps, then we should refactor and common these up
const appTypes = new Array<{field: string; type: models.AppSourceType}>(
{type: 'Helm', field: 'helm'},
{type: 'Kustomize', field: 'kustomize'},
{type: 'Directory', field: 'directory'},
{type: 'Plugin', field: 'plugin'}
);
// This is similar to the same function in application-create-panel.tsx. If the create panel
// is modified to support multi-source apps, then we should refactor and common these up
function normalizeAppSource(app: models.Application, type: string): boolean {
@ -75,16 +67,6 @@ export const SourcePanel = (props: {
const [explicitPathType, setExplicitPathType] = React.useState<{path: string; type: models.AppSourceType}>(null);
const appInEdit = deepMerge(DEFAULT_APP, {});
function normalizeTypeFields(formApi: FormApi, type: models.AppSourceType) {
const appToNormalize = formApi.getFormState().values;
for (const item of appTypes) {
if (item.type !== type) {
delete appToNormalize.spec.source[item.field];
}
}
formApi.setAllValues(appToNormalize);
}
return (
<DataLoader key='add-new-source' load={() => Promise.all([services.repos.list()]).then(([reposInfo]) => ({reposInfo}))}>
{({reposInfo}) => {
@ -384,11 +366,11 @@ export const SourcePanel = (props: {
{type} <i className='fa fa-caret-down' />
</p>
)}
items={appTypes.map(item => ({
items={APP_SOURCE_TYPES.map(item => ({
title: item.type,
action: () => {
setExplicitPathType({type: item.type, path: appInEdit.spec?.source?.path});
normalizeTypeFields(api, item.type);
normalizeTypeFieldsForSource(api, item.type, undefined);
}
}))}
/>

View file

@ -1,3 +1,5 @@
@import 'node_modules/argo-ui/src/styles/theme';
.revision-form-field {
display: flex;
align-items: flex-start;
@ -9,8 +11,46 @@
min-width: 0;
}
.revision-form-field .argo-field-container {
display: flex;
flex-direction: column-reverse;
align-items: stretch;
padding-top: 0;
min-height: 0;
}
.revision-form-field .argo-field-container > .argo-field,
.revision-form-field .argo-field-container > .argo-textarea {
grid-area: unset;
}
.revision-form-field .argo-field-container > label.argo-label-placeholder {
position: static;
grid-area: unset;
align-self: flex-start;
transform: none !important;
font-size: 12px;
line-height: 1.25;
margin: 0 0 2px;
padding: 0;
pointer-events: none;
@include themify($themes) {
color: themed('text-2');
}
}
.revision-form-field .argo-field-container > .argo-field:focus + label.argo-label-placeholder,
.revision-form-field .argo-field-container > .argo-textarea:focus + label.argo-label-placeholder {
font-size: 12px;
transform: none !important;
@include themify($themes) {
color: themed('light-argo-teal-7');
}
}
.revision-form-field__dropdown {
align-self: center;
align-self: flex-end;
padding-bottom: 2px;
display: flex;
align-items: center;
}

View file

@ -0,0 +1,38 @@
import {FormApi} from 'react-form';
import * as models from '../../../shared/models';
/** Shared with application-create-panel and application-parameters/source-panel. */
export const APP_SOURCE_TYPES = new Array<{field: string; type: models.AppSourceType}>(
{type: 'Helm', field: 'helm'},
{type: 'Kustomize', field: 'kustomize'},
{type: 'Directory', field: 'directory'},
{type: 'Plugin', field: 'plugin'}
);
/**
* Clears sibling source-type blocks (helm/kustomize/directory/plugin) when the user picks a type.
* @param sourceIndex when set, edits `spec.sources[index]`; otherwise `spec.source`.
*/
export function normalizeTypeFieldsForSource(formApi: FormApi, type: models.AppSourceType, sourceIndex?: number): void {
const appToNormalize = formApi.getFormState().values as models.Application;
if (sourceIndex === undefined) {
const single = appToNormalize.spec.source as unknown as Record<string, unknown>;
for (const item of APP_SOURCE_TYPES) {
if (item.type !== type) {
delete single[item.field];
}
}
} else {
const src = appToNormalize.spec.sources[sourceIndex];
if (!src) {
return;
}
const srcRec = src as unknown as Record<string, unknown>;
for (const item of APP_SOURCE_TYPES) {
if (item.type !== type) {
delete srcRec[item.field];
}
}
}
formApi.setAllValues(appToNormalize);
}

View file

@ -348,7 +348,7 @@ export interface Info {
export interface ApplicationSpec {
project: string;
source: ApplicationSource;
source?: ApplicationSource;
sources: ApplicationSource[];
sourceHydrator?: SourceHydrator;
destination: ApplicationDestination;