diff --git a/ui/src/app/applications/components/application-details/application-details.tsx b/ui/src/app/applications/components/application-details/application-details.tsx index c300a2bc24a2e..56e45df46764b 100644 --- a/ui/src/app/applications/components/application-details/application-details.tsx +++ b/ui/src/app/applications/components/application-details/application-details.tsx @@ -825,6 +825,7 @@ export class ApplicationDetails extends React.Component this.updateApp(app, query)} selectedNode={selectedNode} + appCxt={this.context} tab={tab} /> diff --git a/ui/src/app/applications/components/application-parameters/application-parameters-source.tsx b/ui/src/app/applications/components/application-parameters/application-parameters-source.tsx index 4acbcdd82fcf6..2d494af941d3d 100644 --- a/ui/src/app/applications/components/application-parameters/application-parameters-source.tsx +++ b/ui/src/app/applications/components/application-parameters/application-parameters-source.tsx @@ -24,8 +24,10 @@ export interface ApplicationParametersPanelProps { viewBottom?: string | React.ReactNode; editTop?: (formApi: FormApi) => React.ReactNode; editBottom?: (formApi: FormApi) => React.ReactNode; + numberOfSources?: number; noReadonlyMode?: boolean; collapsible?: boolean; + deleteSource: () => void; } interface ApplicationParametersPanelState { @@ -64,9 +66,11 @@ export class ApplicationParametersSource extends React.Component { this.setState({editBottom: editClicked}); }} + deleteSource={this.props.deleteSource} /> {this.props.itemsTop && ( diff --git a/ui/src/app/applications/components/application-parameters/application-parameters.scss b/ui/src/app/applications/components/application-parameters/application-parameters.scss index 86a3af27e24f5..d40c88ac05340 100644 --- a/ui/src/app/applications/components/application-parameters/application-parameters.scss +++ b/ui/src/app/applications/components/application-parameters/application-parameters.scss @@ -42,6 +42,10 @@ right: 1em; } + .source-panel-buttons { + margin-bottom: 10px; + } + .argo-field { line-height: 1.15; } diff --git a/ui/src/app/applications/components/application-parameters/application-parameters.tsx b/ui/src/app/applications/components/application-parameters/application-parameters.tsx index 427be982b05f6..ec6d67dae46a1 100644 --- a/ui/src/app/applications/components/application-parameters/application-parameters.tsx +++ b/ui/src/app/applications/components/application-parameters/application-parameters.tsx @@ -1,4 +1,4 @@ -import {AutocompleteField, DataLoader, FormField, FormSelect, getNestedField} from 'argo-ui'; +import {AutocompleteField, DataLoader, ErrorNotification, FormField, FormSelect, getNestedField, NotificationType, SlidingPanel} from 'argo-ui'; import * as React from 'react'; import {FieldApi, FormApi, FormField as ReactFormField, Text, TextArea} from 'react-form'; import {cloneDeep} from 'lodash-es'; @@ -18,7 +18,8 @@ import { Revision, Repo, EditablePanel, - EditablePanelItem + EditablePanelItem, + Spinner } from '../../../shared/components'; import * as models from '../../../shared/models'; import {ApplicationSourceDirectory, Plugin} from '../../../shared/models'; @@ -27,13 +28,15 @@ import {ImageTagFieldEditor} from './kustomize'; import * as kustomize from './kustomize-image'; import {VarsInputField} from './vars-input-field'; import {concatMaps} from '../../../shared/utils'; -import {getAppDefaultSource} from '../utils'; +import {deleteSourceAction, getAppDefaultSource, helpTip} from '../utils'; import * as jsYaml from 'js-yaml'; import {RevisionFormField} from '../revision-form-field/revision-form-field'; import classNames from 'classnames'; import {ApplicationParametersSource} from './application-parameters-source'; import './application-parameters.scss'; +import {AppContext} from '../../../shared/context'; +import {SourcePanel} from './source-panel'; const TextWithMetadataField = ReactFormField((props: {metadata: {value: string}; fieldApi: FieldApi; className: string}) => { const { @@ -148,17 +151,30 @@ export const ApplicationParameters = (props: { setPageNumber?: (x: number) => any; collapsedSources?: boolean[]; handleCollapse?: (i: number, isCollapsed: boolean) => void; + appContext?: AppContext; + tempSource?: models.ApplicationSource; }) => { const app = cloneDeep(props.application); const source = getAppDefaultSource(app); // For source field const appSources = app?.spec.sources; const [removedOverrides, setRemovedOverrides] = React.useState(new Array()); const collapsible = props.collapsedSources !== undefined && props.handleCollapse !== undefined; + const [createApi, setCreateApi] = React.useState(null); + const [isAddingSource, setIsAddingSource] = React.useState(false); + const [isSavingSource, setIsSavingSource] = React.useState(false); const [appParamsDeletedState, setAppParamsDeletedState] = React.useState([]); if (app.spec.sources?.length > 0 && !props.details) { + // For multi-source case only return (
+
+ +
+ setIsAddingSource(false)} + header={ +
+ {' '} + +
+ }> + { + setCreateApi(api); + }} + onSubmitFailure={errors => { + props.appContext.apis.notifications.show({ + content: 'Cannot add source: ' + errors.toString(), + type: NotificationType.Warning + }); + }} + updateApp={async updatedAppSource => { + setIsSavingSource(true); + props.application.spec.sources.push(updatedAppSource.spec.source); + try { + await services.applications.update(props.application); + setIsAddingSource(false); + } catch (e) { + props.application.spec.sources.pop(); + props.appContext.apis.notifications.show({ + content: , + type: NotificationType.Error + }); + } finally { + setIsSavingSource(false); + } + }} + /> +
); } else { - // For the other old/existings references of ApplicationParameters that have details already loaded. They are single source + // For the three other references of ApplicationParameters. They are single source. + // Create App, Add source, Rollback and History let attributes: EditablePanelItem[] = []; if (props.details) { return getEditablePanel( - gatherDetails(0, props.details, attributes, source, app, setRemovedOverrides, removedOverrides, appParamsDeletedState, setAppParamsDeletedState, false), + gatherDetails( + 0, + props.details, + attributes, + props.tempSource ? props.tempSource : source, + app, + setRemovedOverrides, + removedOverrides, + appParamsDeletedState, + setAppParamsDeletedState, + false + ), props.details ); } else { - // For single source field, for resource details where we have to do the load. + // For single source field, details page where we have to do the load to retrieve repo details return ( getSingleSource(application)}> {(details: models.RepoAppDetails) => { @@ -247,7 +328,10 @@ export const ApplicationParameters = (props: { )} - getSourceFromAppSources(src, app.metadata.name, app.spec.project, index, 0)}> + getSourceFromAppSources(src, app.metadata.name, app.spec.project, index, 0)}> {(details: models.RepoAppDetails) => getEditablePanelForOneSource(details, index, app.spec.sources[index])} @@ -270,10 +354,10 @@ export const ApplicationParameters = (props: { function isDefinedWithVersion(item: any) { return item !== null && item !== undefined && item.match(/:/); } - if (updatedSrc.helm && updatedSrc.helm.parameters) { + if (updatedSrc && updatedSrc.helm?.parameters) { updatedSrc.helm.parameters = updatedSrc.helm.parameters.filter(isDefined); } - if (updatedSrc.kustomize && updatedSrc.kustomize.images) { + if (updatedSrc && updatedSrc.kustomize?.images) { updatedSrc.kustomize.images = updatedSrc.kustomize.images.filter(isDefinedWithVersion); } @@ -295,7 +379,7 @@ export const ApplicationParameters = (props: { params = params.filter(param => !appParamsDeletedState.includes(param.name)); input.spec.source.plugin.parameters = params; } - if (input.spec.source.helm && input.spec.source.helm.valuesObject) { + 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 = ''; } @@ -303,7 +387,7 @@ export const ApplicationParameters = (props: { setRemovedOverrides(new Array()); }) } - values={((repoAppDetails.plugin || app?.spec?.source?.plugin) && cloneDeep(app)) || app} + values={((repoAppDetails?.plugin || app?.spec?.source?.plugin) && cloneDeep(app)) || app} validate={updatedApp => { const errors = {} as any; @@ -312,7 +396,7 @@ export const ApplicationParameters = (props: { errors[fieldPath] = invalid.length > 0 ? 'All fields must have name' : null; } - if (updatedApp.spec.source.helm && updatedApp.spec.source.helm.values) { + 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'; } @@ -320,12 +404,12 @@ export const ApplicationParameters = (props: { return errors; }} onModeSwitch={ - repoAppDetails.plugin && + repoAppDetails?.plugin && (() => { setAppParamsDeletedState([]); }) } - title={repoAppDetails.type.toLocaleUpperCase()} + title={repoAppDetails?.type?.toLocaleUpperCase()} items={items as EditablePanelItem[]} noReadonlyMode={props.noReadonlyMode} hasMultipleSources={false} @@ -402,7 +486,7 @@ export const ApplicationParameters = (props: { saveBottom={ props.save && (async (input: models.Application) => { - const updatedSrc = input.spec.sources[ind]; + const appSrc = input.spec.sources[ind]; function isDefined(item: any) { return item !== null && item !== undefined; @@ -411,11 +495,11 @@ export const ApplicationParameters = (props: { return item !== null && item !== undefined && item.match(/:/); } - if (updatedSrc.helm && updatedSrc.helm.parameters) { - updatedSrc.helm.parameters = updatedSrc.helm.parameters.filter(isDefined); + if (appSrc.helm && appSrc.helm.parameters) { + appSrc.helm.parameters = appSrc.helm.parameters.filter(isDefined); } - if (updatedSrc.kustomize && updatedSrc.kustomize.images) { - updatedSrc.kustomize.images = updatedSrc.kustomize.images.filter(isDefinedWithVersion); + if (appSrc.kustomize && appSrc.kustomize.images) { + appSrc.kustomize.images = appSrc.kustomize.images.filter(isDefinedWithVersion); } let params = input.spec?.sources[ind]?.plugin?.parameters; @@ -435,11 +519,11 @@ export const ApplicationParameters = (props: { } params = params.filter(param => !appParamsDeletedState.includes(param.name)); - updatedSrc.plugin.parameters = params; + appSrc.plugin.parameters = params; } - if (updatedSrc.helm && updatedSrc.helm.valuesObject) { - updatedSrc.helm.valuesObject = jsYaml.load(updatedSrc.helm.values); // Deserialize json - updatedSrc.helm.values = ''; + if (appSrc.helm && appSrc.helm.valuesObject) { + appSrc.helm.valuesObject = jsYaml.load(appSrc.helm.values); // Deserialize json + appSrc.helm.values = ''; } await props.save(input, {}); @@ -486,6 +570,10 @@ export const ApplicationParameters = (props: { itemsTop={upperPanel as EditablePanelItem[]} noReadonlyMode={props.noReadonlyMode} collapsible={collapsible} + numberOfSources={app?.spec?.sources.length} + deleteSource={() => { + deleteSourceAction(app, app.spec.sources.at(ind), props.appContext); + }} /> ); } diff --git a/ui/src/app/applications/components/application-parameters/source-panel.scss b/ui/src/app/applications/components/application-parameters/source-panel.scss new file mode 100644 index 0000000000000..9ee0b7c0aa785 --- /dev/null +++ b/ui/src/app/applications/components/application-parameters/source-panel.scss @@ -0,0 +1,18 @@ +@import 'node_modules/argo-ui/src/styles/config'; + +.new-source-panel { + + .checkbox-container { + margin: 0.5em ; + } + + pre { + font-family: monospace; + line-height: normal; + white-space: pre; + } + + .row.argo-form-row .columns { + padding-left: 0; + } +} diff --git a/ui/src/app/applications/components/application-parameters/source-panel.tsx b/ui/src/app/applications/components/application-parameters/source-panel.tsx new file mode 100644 index 0000000000000..8e750b6e4a9b9 --- /dev/null +++ b/ui/src/app/applications/components/application-parameters/source-panel.tsx @@ -0,0 +1,375 @@ +import {AutocompleteField, DataLoader, DropDownMenu, FormField} from 'argo-ui'; +import * as deepMerge from 'deepmerge'; +import * as React from 'react'; +import {Form, FormApi, FormErrors, Text} from 'react-form'; +import {ApplicationParameters} from '../../../applications/components/application-parameters/application-parameters'; +import {RevisionFormField} from '../../../applications/components/revision-form-field/revision-form-field'; +import {RevisionHelpIcon} from '../../../shared/components'; +import * as models from '../../../shared/models'; +import {services} from '../../../shared/services'; +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 { + const source = app.spec.source; + // eslint-disable-next-line no-prototype-builtins + const repoType = (source.hasOwnProperty('chart') && 'helm') || 'git'; + if (repoType !== type) { + if (type === 'git') { + source.path = source.chart; + delete source.chart; + source.targetRevision = 'HEAD'; + } else { + source.chart = source.path; + delete source.path; + source.targetRevision = ''; + } + return true; + } + return false; +} + +// Use a single source app to represent the 'new source'. This panel will make use of the source field only. +// However, we need to use a template based on an Application so that we can reuse the application-parameters code +const DEFAULT_APP: Partial = { + apiVersion: 'argoproj.io/v1alpha1', + kind: 'Application', + metadata: { + name: '' + }, + spec: { + destination: { + name: '', + namespace: '', + server: '' + }, + source: { + path: '', + repoURL: '', + ref: '', + targetRevision: 'HEAD' + }, + sources: [], + project: '' + } +}; + +export const SourcePanel = (props: { + appCurrent: models.Application; + onSubmitFailure: (error: string) => any; + updateApp: (app: models.Application) => any; + getFormApi: (api: FormApi) => any; +}) => { + 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}) => { + const repos = reposInfo.map(info => info.repo).sort(); + return ( +
+
{ + let samePath = false; + let sameChartVersion = false; + let pathError = null; + let chartError = null; + if (a.spec.source.repoURL && a.spec.source.path) { + props.appCurrent.spec.sources.forEach(source => { + if (source.repoURL === a.spec.source.repoURL && source.path === a.spec.source.path) { + samePath = true; + pathError = 'Provided path in the selected repository URL was already added to this multi-source application'; + } + }); + } + if (a.spec.source.repoURL && a.spec.source.chart) { + props.appCurrent.spec.sources.forEach(source => { + if ( + source.repoURL === a.spec.source.repoURL && + source.chart === a.spec.source.chart && + source.targetRevision === a.spec.source.targetRevision + ) { + sameChartVersion = true; + chartError = + 'Version ' + + source.targetRevision + + ' of chart ' + + source.chart + + ' from the selected repository was already added to this multi-source application'; + } + }); + } + if (!samePath) { + if (!a.spec.source.path && !a.spec.source.chart && !a.spec.source.ref) { + pathError = 'Path or Ref is required'; + } + } + if (!sameChartVersion) { + if (!a.spec.source.chart && !a.spec.source.path && !a.spec.source.ref) { + chartError = 'Chart is required'; + } + } + return { + 'spec.source.repoURL': !a.spec.source.repoURL && 'Repository URL is required', + // eslint-disable-next-line no-prototype-builtins + 'spec.source.targetRevision': !a.spec.source.targetRevision && a.spec.source.hasOwnProperty('chart') && 'Version is required', + 'spec.source.path': pathError, + 'spec.source.chart': chartError + }; + }} + defaultValues={appInEdit} + onSubmitFailure={(errors: FormErrors) => { + let errorString: string = ''; + let i = 0; + for (const key in errors) { + if (errors[key]) { + i++; + errorString = errorString.concat(i + '. ' + errors[key] + ' '); + } + } + props.onSubmitFailure(errorString); + }} + onSubmit={values => { + props.updateApp(values as models.Application); + }} + getApi={props.getFormApi}> + {api => { + // eslint-disable-next-line no-prototype-builtins + const repoType = (api.getFormState().values.spec.source.hasOwnProperty('chart') && 'helm') || 'git'; + const repoInfo = reposInfo.find(info => info.repo === api.getFormState().values.spec.source.repoURL); + if (repoInfo) { + normalizeAppSource(appInEdit, repoInfo.type || 'git'); + } + const sourcePanel = () => ( +
+

SOURCE

+
+
+ +
+
+
+ {(repoInfo && ( + + {(repoInfo.type || 'git').toUpperCase()} + + )) || ( + ( +

+ {repoType.toUpperCase()} +

+ )} + items={['git', 'helm'].map((type: 'git' | 'helm') => ({ + title: type.toUpperCase(), + action: () => { + if (repoType !== type) { + const updatedApp = api.getFormState().values as models.Application; + if (normalizeAppSource(updatedApp, type)) { + api.setAllValues(updatedApp); + } + } + } + }))} + /> + )} +
+
+
+ {(repoType === 'git' && ( + + +
+ + (src.repoURL && + (await services.repos + .apps(src.repoURL, src.revision, appInEdit.metadata.name, props.appCurrent.spec.project) + .then(apps => Array.from(new Set(apps.map(item => item.path))).sort()) + .catch(() => new Array()))) || + new Array() + }> + {(apps: string[]) => ( + + )} + +
+
+ +
+
+ )) || ( + + (src.repoURL && services.repos.charts(src.repoURL).catch(() => new Array())) || + new Array() + }> + {(charts: models.HelmChart[]) => { + const selectedChart = charts.find(chart => chart.name === api.getFormState().values.spec.source.chart); + return ( +
+
+ chart.name), + filterSuggestions: true + }} + /> +
+
+ + +
+
+ ); + }} +
+ )} +
+ ); + + const typePanel = () => ( + { + if (src.repoURL && src.targetRevision && (src.path || src.chart)) { + return services.repos.appDetails(src, src.appName, props.appCurrent.spec.project, 0, 0).catch(() => ({ + type: 'Directory', + details: {} + })); + } else { + return { + type: 'Directory', + details: {} + }; + } + }}> + {(details: models.RepoAppDetails) => { + const type = (explicitPathType && explicitPathType.path === appInEdit.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; + } + } + return ( + + ( +

+ {type} +

+ )} + items={appTypes.map(item => ({ + title: item.type, + action: () => { + setExplicitPathType({type: item.type, path: appInEdit.spec.source.path}); + normalizeTypeFields(api, item.type); + } + }))} + /> + { + api.setAllValues(updatedApp); + }} + /> +
+ ); + }} +
+ ); + + return ( + + {sourcePanel()} + + {typePanel()} + + ); + }} + +
+ ); + }} +
+
+ ); +}; diff --git a/ui/src/app/applications/components/resource-details/resource-details.tsx b/ui/src/app/applications/components/resource-details/resource-details.tsx index 7b973f89fc8c9..59774fa459872 100644 --- a/ui/src/app/applications/components/resource-details/resource-details.tsx +++ b/ui/src/app/applications/components/resource-details/resource-details.tsx @@ -4,7 +4,7 @@ import {useState} from 'react'; import {EventsList, YamlEditor} from '../../../shared/components'; import * as models from '../../../shared/models'; import {ErrorBoundary} from '../../../shared/components/error-boundary/error-boundary'; -import {Context} from '../../../shared/context'; +import {AppContext, Context} from '../../../shared/context'; import {Application, ApplicationTree, Event, ResourceNode, State, SyncStatuses} from '../../../shared/models'; import {services} from '../../../shared/services'; import {ResourceTabExtension} from '../../../shared/services/extensions-service'; @@ -31,6 +31,7 @@ interface ResourceDetailsProps { isAppSelected: boolean; tree: ApplicationTree; tab?: string; + appCxt: AppContext; } export const ResourceDetails = (props: ResourceDetailsProps) => { @@ -178,6 +179,7 @@ export const ResourceDetails = (props: ResourceDetailsProps) => { setPageNumber={setPageNumber} collapsedSources={collapsedSources} handleCollapse={handleCollapse} + appContext={props.appCxt} /> ) }, diff --git a/ui/src/app/applications/components/utils.tsx b/ui/src/app/applications/components/utils.tsx index 844426d9ee074..1a06400522c50 100644 --- a/ui/src/app/applications/components/utils.tsx +++ b/ui/src/app/applications/components/utils.tsx @@ -7,7 +7,7 @@ import {FormApi, Text} from 'react-form'; import * as moment from 'moment'; import {BehaviorSubject, combineLatest, concat, from, fromEvent, Observable, Observer, Subscription} from 'rxjs'; import {debounceTime, map} from 'rxjs/operators'; -import {Context, ContextApis} from '../../shared/context'; +import {AppContext, Context, ContextApis} from '../../shared/context'; import {ResourceTreeNode} from './application-resource-tree/application-resource-tree'; import {CheckboxField, COLORS, ErrorNotification, Revision} from '../../shared/components'; @@ -346,6 +346,36 @@ const deletePodAction = async (ctx: ContextApis, pod: appModels.ResourceNode, ap ); }; +export const deleteSourceAction = (app: appModels.Application, source: appModels.ApplicationSource, appContext: AppContext) => { + appContext.apis.popup.prompt( + 'Delete source', + () => ( +
+

+ Are you sure you want to delete the source with URL: {source.repoURL}? +

+
+ ), + { + submit: async (vals, _, close) => { + try { + const i = app.spec.sources.indexOf(source); + app.spec.sources.splice(i, 1); + await services.applications.update(app); + close(); + } catch (e) { + appContext.apis.notifications.show({ + content: , + type: NotificationType.Error + }); + } + } + }, + {name: 'argo-icon-warning', color: 'warning'}, + 'yellow' + ); +}; + export const deletePopup = async ( ctx: ContextApis, resource: ResourceTreeNode, diff --git a/ui/src/app/shared/components/editable-panel/editable-section.tsx b/ui/src/app/shared/components/editable-panel/editable-section.tsx index 7a5ff63aed988..790390b6a6a24 100644 --- a/ui/src/app/shared/components/editable-panel/editable-section.tsx +++ b/ui/src/app/shared/components/editable-panel/editable-section.tsx @@ -4,6 +4,7 @@ import {Form, FormApi} from 'react-form'; import {ContextApis} from '../../context'; import {EditablePanelItem} from './editable-panel'; import {Spinner} from '../spinner'; +import {helpTip} from '../../../applications/components/utils'; export interface EditableSectionProps { title?: string | React.ReactNode; @@ -20,7 +21,9 @@ export interface EditableSectionProps { ctx: ContextApis; isTopSection?: boolean; disabledState?: boolean; + disabledDelete?: boolean; updateButtons?: (pressed: boolean) => void; + deleteSource?: () => void; } interface EditableSectionState { @@ -57,17 +60,32 @@ export class EditableSection extends React.Component {!this.state.isEditing && ( - +
+ {' '} + {this.props.isTopSection && this.props.deleteSource && ( + + )} +
)} {this.state.isEditing && (