diff --git a/ui-test/src/application-create-panel/application-create-panel.ts b/ui-test/src/application-create-panel/application-create-panel.ts index 713845c2df..7fb904ab27 100644 --- a/ui-test/src/application-create-panel/application-create-panel.ts +++ b/ui-test/src/application-create-panel/application-create-panel.ts @@ -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 { + public async setSourceOneRepoUrl(sourceRepoUrl: string): Promise { 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 { + 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 { + public async setSourceOneRepoPath(sourceRepoPath: string): Promise { 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 { + 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 { @@ -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(); diff --git a/ui-test/src/test001.ts b/ui-test/src/test001.ts index 4c2fa604e3..399296d292 100644 --- a/ui-test/src/test001.ts +++ b/ui-test/src/test001.ts @@ -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(); diff --git a/ui/src/app/applications/components/application-create-panel/application-create-panel.scss b/ui/src/app/applications/components/application-create-panel/application-create-panel.scss index 86e239970b..404cac988b 100644 --- a/ui/src/app/applications/components/application-create-panel/application-create-panel.scss +++ b/ui/src/app/applications/components/application-create-panel/application-create-panel.scss @@ -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; + } + } } diff --git a/ui/src/app/applications/components/application-create-panel/application-create-panel.tsx b/ui/src/app/applications/components/application-create-panel/application-create-panel.tsx index f744ee6b75..046719a32f 100644 --- a/ui/src/app/applications/components/application-create-panel/application-create-panel.tsx +++ b/ui/src/app/applications/components/application-create-panel/application-create-panel.tsx @@ -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 = { 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 ( {({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 = { + '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 = () => (

GENERAL

@@ -316,59 +367,95 @@ export const ApplicationCreatePanel = (props: {
); - const sourcePanel = () => ( -
-

SOURCE

- {/* Only show hydrator checkbox if hydrator is enabled in auth settings */} - {authSettingsCtx?.hydratorEnabled && ( -
-
-
- { - 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' - /> - -
+ const sourcePanel = () => { + if (multiSourceMode) { + const count = formApp.spec.sources?.length ?? 0; + return ( +
+

SOURCES

+ {Array.from({length: count}, (_, i) => ( + = 2} + onRemove={() => handleRemoveSource(api, i)} + /> + ))} +
+
- )} - {isHydratorEnabled ? ( - - ) : ( - - )} -
- ); + ); + } + + return ( +
+

SOURCE

+ {authSettingsCtx?.hydratorEnabled && ( +
+
+
+ { + 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' + /> + +
+
+
+ )} + {isHydratorEnabled ? ( + + ) : ( + + +
+ +
+
+ )} +
+ ); + }; const destinationPanel = () => (

DESTINATION

@@ -437,81 +524,86 @@ export const ApplicationCreatePanel = (props: {
); - const typePanel = () => ( - { - 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 ( + { + 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 ( - - ( -

- {type} -

- )} - 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); - } - }))} - /> - { - api.setAllValues(updatedApp); - }} - /> -
- ); - }} -
- ); + return ( + + ( +

+ {type} +

+ )} + 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); + } + }))} + /> + { + api.setAllValues(updatedApp); + }} + /> +
+ ); + }} +
+ ); + }; return (
@@ -521,7 +613,7 @@ export const ApplicationCreatePanel = (props: { {destinationPanel()} - {typePanel()} + {!multiSourceMode && typePanel()}
); }} diff --git a/ui/src/app/applications/components/application-create-panel/collapsible-multi-source-section.tsx b/ui/src/app/applications/components/application-create-panel/collapsible-multi-source-section.tsx new file mode 100644 index 0000000000..12ece95120 --- /dev/null +++ b/ui/src/app/applications/components/application-create-panel/collapsible-multi-source-section.tsx @@ -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 ( +
setExpanded(true)}> +
+ +
+
+
{title}
+
{desc}
+
+
+ ); + } + + return ( +
+
+ Source {props.index + 1} +
+ {props.canRemove && props.onRemove && ( + + )} + +
+
+
+ +
+
+ +
+
+ ); +} diff --git a/ui/src/app/applications/components/application-create-panel/create-panel-source-type-parameters.tsx b/ui/src/app/applications/components/application-create-panel/create-panel-source-type-parameters.tsx new file mode 100644 index 0000000000..059f1e4efb --- /dev/null +++ b/ui/src/app/applications/components/application-create-panel/create-panel-source-type-parameters.tsx @@ -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 ( + { + 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 ( + + ( +

+ {type} +

+ )} + 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); + } + }))} + /> + { + props.formApi.setAllValues(updatedApp); + }} + /> +
+ ); + }} +
+ ); +}; diff --git a/ui/src/app/applications/components/application-create-panel/source-panel.tsx b/ui/src/app/applications/components/application-create-panel/source-panel.tsx index c02b759d2c..476732c8d5 100644 --- a/ui/src/app/applications/components/application-create-panel/source-panel.tsx +++ b/ui/src/app/applications/components/application-create-panel/source-panel.tsx @@ -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; - lastGitOrHelmUrl: React.MutableRefObject; - lastOciUrl: React.MutableRefObject; + sourceIndex?: number; + suppressMultiSourceHeading?: boolean; + currentRepoType?: React.MutableRefObject; + lastGitOrHelmUrl?: React.MutableRefObject; + lastOciUrl?: React.MutableRefObject; } export const SourcePanel = (props: SourcePanelProps) => { + const internalRepoType = React.useRef(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 ( + {isMulti && !props.suppressMultiSourceHeading && ( +

0 ? '1em' : 0}}> + SOURCE {idx + 1} +

+ )}
{ {repoType.toUpperCase()}

)} - 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' && ( - +
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) => { { )) || (repoType === 'git' && ( - +
(src.repoURL && services.repos @@ -140,8 +186,8 @@ export const SourcePanel = (props: SourcePanelProps) => { { )) || ( (src.repoURL && services.repos.charts(src.repoURL).catch(() => new Array())) || new Array()}> {(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 (
chart.name), @@ -175,7 +223,7 @@ export const SourcePanel = (props: SourcePanelProps) => {
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 (
{ - 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()); }) } - 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).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; diff --git a/ui/src/app/applications/components/application-parameters/source-panel.tsx b/ui/src/app/applications/components/application-parameters/source-panel.tsx index c3877b0c5d..398e9fea70 100644 --- a/ui/src/app/applications/components/application-parameters/source-panel.tsx +++ b/ui/src/app/applications/components/application-parameters/source-panel.tsx @@ -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 ( Promise.all([services.repos.list()]).then(([reposInfo]) => ({reposInfo}))}> {({reposInfo}) => { @@ -384,11 +366,11 @@ export const SourcePanel = (props: { {type}

)} - 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); } }))} /> diff --git a/ui/src/app/applications/components/revision-form-field/revision-form-field.scss b/ui/src/app/applications/components/revision-form-field/revision-form-field.scss index ff012b3590..3eebc286f1 100644 --- a/ui/src/app/applications/components/revision-form-field/revision-form-field.scss +++ b/ui/src/app/applications/components/revision-form-field/revision-form-field.scss @@ -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; } diff --git a/ui/src/app/applications/components/shared/app-source-edit.ts b/ui/src/app/applications/components/shared/app-source-edit.ts new file mode 100644 index 0000000000..8d5694f362 --- /dev/null +++ b/ui/src/app/applications/components/shared/app-source-edit.ts @@ -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; + 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; + for (const item of APP_SOURCE_TYPES) { + if (item.type !== type) { + delete srcRec[item.field]; + } + } + } + formApi.setAllValues(appToNormalize); +} diff --git a/ui/src/app/shared/models.ts b/ui/src/app/shared/models.ts index dcf87ce18e..469ff734bd 100644 --- a/ui/src/app/shared/models.ts +++ b/ui/src/app/shared/models.ts @@ -348,7 +348,7 @@ export interface Info { export interface ApplicationSpec { project: string; - source: ApplicationSource; + source?: ApplicationSource; sources: ApplicationSource[]; sourceHydrator?: SourceHydrator; destination: ApplicationDestination;