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 new file mode 100644 index 000000000000..4acbcdd82fcf --- /dev/null +++ b/ui/src/app/applications/components/application-parameters/application-parameters-source.tsx @@ -0,0 +1,112 @@ +import * as classNames from 'classnames'; +import * as React from 'react'; +import {FormApi} from 'react-form'; +import {EditablePanelItem} from '../../../shared/components'; +import {EditableSection} from '../../../shared/components/editable-panel/editable-section'; +import {Consumer} from '../../../shared/context'; +import '../../../shared/components/editable-panel/editable-panel.scss'; + +export interface ApplicationParametersPanelProps { + floatingTitle?: string | React.ReactNode; + titleTop?: string | React.ReactNode; + titleBottom?: string | React.ReactNode; + index: number; + valuesTop?: T; + valuesBottom?: T; + validateTop?: (values: T) => any; + validateBottom?: (values: T) => any; + saveTop?: (input: T, query: {validate?: boolean}) => Promise; + saveBottom?: (input: T, query: {validate?: boolean}) => Promise; + itemsTop?: EditablePanelItem[]; + itemsBottom?: EditablePanelItem[]; + onModeSwitch?: () => any; + viewTop?: string | React.ReactNode; + viewBottom?: string | React.ReactNode; + editTop?: (formApi: FormApi) => React.ReactNode; + editBottom?: (formApi: FormApi) => React.ReactNode; + noReadonlyMode?: boolean; + collapsible?: boolean; +} + +interface ApplicationParametersPanelState { + editTop: boolean; + editBottom: boolean; + savingTop: boolean; + savingBottom: boolean; +} + +// Currently two editable sections, but can be modified to support N panels in general. This should be part of a white-box, editable-panel. +export class ApplicationParametersSource extends React.Component, ApplicationParametersPanelState> { + constructor(props: ApplicationParametersPanelProps) { + super(props); + this.state = {editTop: !!props.noReadonlyMode, editBottom: !!props.noReadonlyMode, savingTop: false, savingBottom: false}; + } + + public render() { + return ( + + {ctx => ( +
+ {this.props.floatingTitle &&
{this.props.floatingTitle}
} + + this.onModeSwitch()} + noReadonlyMode={this.props.noReadonlyMode} + edit={this.props.editTop} + collapsible={this.props.collapsible} + ctx={ctx} + isTopSection={true} + disabledState={this.state.editTop || this.state.editTop === null} + updateButtons={editClicked => { + this.setState({editBottom: editClicked}); + }} + /> + + {this.props.itemsTop && ( + +
+

 

+
+
+ + )} + + this.onModeSwitch()} + noReadonlyMode={this.props.noReadonlyMode} + edit={this.props.editBottom} + collapsible={this.props.collapsible} + ctx={ctx} + isTopSection={false} + disabledState={this.state.editBottom || this.state.editBottom === null} + updateButtons={editClicked => { + this.setState({editTop: editClicked}); + }} + /> + +
+ )} + + ); + } + + private onModeSwitch() { + if (this.props.onModeSwitch) { + this.props.onModeSwitch(); + } + } +} diff --git a/ui/src/app/applications/components/application-parameters/application-parameters.scss b/ui/src/app/applications/components/application-parameters/application-parameters.scss new file mode 100644 index 000000000000..e49945dc8532 --- /dev/null +++ b/ui/src/app/applications/components/application-parameters/application-parameters.scss @@ -0,0 +1,80 @@ +@import 'node_modules/argo-ui/src/styles/config'; +@import 'node_modules/argo-ui/src/styles/theme'; + +.application-parameters { + &__labels { + line-height: 28px; + display: flex; + align-items: center; + height: 100%; + flex-wrap: wrap; + padding-top: 0.5em; + } + + &__label { + background-color: $argo-color-gray-5; + color: white; + border-radius: 5px; + padding: 4px; + line-height: 14px; + margin: 0.3em 0; + margin-right: 2px; + } + + &__sort-icon { + cursor: pointer; + position: absolute; + font-size: 1.3em; + left: -1em; + + &.fa-sort-up { + top: 10px; + } + + &.fa-sort-down { + bottom: 10px; + } + } + &__remove-icon { + cursor: pointer; + position: absolute; + top: 1em; + right: 1em; + } + + .argo-field { + line-height: 1.15; + } + + .white-box__details p { + font-weight: 500; + @include themify($themes) { + color: themed('text-1'); + } + } + + .white-box__details-row .row { + padding-left: 1em; + padding-right: 1em; + } + + .white-box__details-row .row .columns:last-child { + padding-left: 1em; + } + + .select { + padding-bottom: 0; + } + + .row.application-retry-options { + .columns.application-retry-options__item{ + padding-left: 0; + padding-right: 10px; + } + + .argo-form-row__error-msg { + position: static; + line-height: 1; + } + } +} 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 3028c3a13725..0080aa8822f5 100644 --- a/ui/src/app/applications/components/application-parameters/application-parameters.tsx +++ b/ui/src/app/applications/components/application-parameters/application-parameters.tsx @@ -6,8 +6,6 @@ import { ArrayInputField, ArrayValueField, CheckboxField, - EditablePanel, - EditablePanelItem, Expandable, MapValueField, NameValueEditor, @@ -18,7 +16,9 @@ import { Paginate, RevisionHelpIcon, Revision, - Repo + Repo, + EditablePanel, + EditablePanelItem } from '../../../shared/components'; import * as models from '../../../shared/models'; import {ApplicationSourceDirectory, Plugin} from '../../../shared/models'; @@ -27,9 +27,13 @@ import {ImageTagFieldEditor} from './kustomize'; import * as kustomize from './kustomize-image'; import {VarsInputField} from './vars-input-field'; import {concatMaps} from '../../../shared/utils'; -import {getAppDefaultSource, helpTip} from '../utils'; +import {getAppDefaultSource} 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'; const TextWithMetadataField = ReactFormField((props: {metadata: {value: string}; fieldApi: FieldApi; className: string}) => { const { @@ -143,108 +147,260 @@ export const ApplicationParameters = (props: { noReadonlyMode?: boolean; pageNumber?: number; setPageNumber?: (x: number) => any; + collapsedSources?: boolean[]; + handleCollapse?: (i: number, isCollapsed: boolean) => void; }) => { 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; - let attributes: EditablePanelItem[] = []; - const multipleAttributes = new Array(); + const attributes: EditablePanelItem[] = []; const [appParamsDeletedState, setAppParamsDeletedState] = React.useState([]); - if (appSources && props.detailsList && props.detailsList.length > 1) { - for (let i: number = 0; i < props.detailsList.length; i++) { - multipleAttributes.push( - gatherDetails(props.detailsList[i], attributes, appSources[i], app, setRemovedOverrides, removedOverrides, appParamsDeletedState, setAppParamsDeletedState) - ); - attributes = []; - } + if (app.spec.sources?.length > 0) { + return ( +
+ { + props.setPageNumber(page); + }}> + {data => { + const listOfPanels: JSX.Element[] = []; + data.forEach(appSource => { + const i = app.spec.sources.indexOf(appSource); + listOfPanels.push(getEditablePanelForSources(i, appSource)); + }); + return listOfPanels; + }} + +
+ ); } else { - // For source field. Delete this when source field is removed - attributes = gatherDetails(props.details, attributes, source, app, setRemovedOverrides, removedOverrides, appParamsDeletedState, setAppParamsDeletedState); + // Delete when source field is removed + return ( + getSingleSource(appSource)}> + {(details: models.RepoAppDetails) => + getEditablePanel( + gatherDetails(0, details, attributes, source, app, setRemovedOverrides, removedOverrides, appParamsDeletedState, setAppParamsDeletedState, false), + details, + app.spec.source + ) + } + + ); } - if (props.detailsList && props.detailsList.length > 1) { - return ( - { - props.setPageNumber(page); + // Collapse button is separate + function getEditablePanelForSources(index: number, appSource: models.ApplicationSource): JSX.Element { + return (collapsible && props.collapsedSources[index] === undefined) || props.collapsedSources[index] ? ( +
{ + const currentState = props.collapsedSources[index] !== undefined ? props.collapsedSources[index] : true; + props.handleCollapse(index, !currentState); }}> - {data => { - const listOfPanels: any[] = []; - data.forEach(attr => { - const repoAppDetails = props.detailsList[multipleAttributes.indexOf(attr)]; - listOfPanels.push(getEditablePanel(attr, repoAppDetails, multipleAttributes.indexOf(attr), app.spec.sources)); - }); - return listOfPanels; - }} - +
+ +
+
+
Source {index + 1 + ': ' + appSource.repoURL}
+
+ {(appSource.path ? 'PATH=' + appSource.path : '') + (appSource.targetRevision ? (appSource.path ? ', ' : '') + 'REVISION=' + appSource.targetRevision : '')} +
+
+
+ ) : ( +
+
+ {collapsible && ( + +
+ { + props.handleCollapse(index, !props.collapsedSources[index]); + }} + /> +
+
+ )} + getSourceFromSources(application, index)}> + {(details: models.RepoAppDetails) => getEditablePanelForOneSource(details, index, source)} + +
+
); - } else { - const v: models.ApplicationSource[] = new Array(); - v.push(app.spec.source); - return getEditablePanel(attributes, props.details, 0, v); } - function getEditablePanel(panel: EditablePanelItem[], repoAppDetails: models.RepoAppDetails, ind: number, sources: models.ApplicationSource[]): any { - const src: models.ApplicationSource = sources[ind]; - let descriptionCollapsed: string; + function getEditablePanel(items: EditablePanelItem[], repoAppDetails: models.RepoAppDetails, src: models.ApplicationSource): any { + return ( +
+ { + function isDefined(item: any) { + return item !== null && item !== undefined; + } + function isDefinedWithVersion(item: any) { + return item !== null && item !== undefined && item.match(/:/); + } + if (src.helm && src.helm.parameters) { + src.helm.parameters = src.helm.parameters.filter(isDefined); + } + if (src.kustomize && src.kustomize.images) { + src.kustomize.images = src.kustomize.images.filter(isDefinedWithVersion); + } + + let params = input.spec?.source?.plugin?.parameters; + if (params) { + for (const param of params) { + if (param.map && param.array) { + // @ts-ignore + param.map = param.array.reduce((acc, {name, value}) => { + // @ts-ignore + acc[name] = value; + return acc; + }, {}); + delete param.array; + } + } + params = params.filter(param => !appParamsDeletedState.includes(param.name)); + input.spec.source.plugin.parameters = params; + } + if (input.spec.source.helm && input.spec.source.helm.valuesObject) { + input.spec.source.helm.valuesObject = jsYaml.safeLoad(input.spec.source.helm.values); // Deserialize json + input.spec.source.helm.values = ''; + } + await props.save(input, {}); + setRemovedOverrides(new Array()); + }) + } + values={((repoAppDetails.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']) { + 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.helm && updatedApp.spec.source.helm.values) { + const parsedValues = jsYaml.safeLoad(updatedApp.spec.source.helm.values); + errors['spec.source.helm.values'] = typeof parsedValues === 'object' ? null : 'Values must be a map'; + } + + return errors; + }} + onModeSwitch={ + repoAppDetails.plugin && + (() => { + setAppParamsDeletedState([]); + }) + } + title={repoAppDetails.type.toLocaleUpperCase()} + items={items as EditablePanelItem[]} + noReadonlyMode={props.noReadonlyMode} + hasMultipleSources={false} + /> +
+ ); + } + + function getEditablePanelForOneSource(repoAppDetails: models.RepoAppDetails, ind: number, src: models.ApplicationSource): any { let floatingTitle: string; - if (sources.length > 1) { - if (repoAppDetails.type === 'Directory') { - floatingTitle = 'TYPE=' + repoAppDetails.type + ', URL=' + src.repoURL; - descriptionCollapsed = - 'TYPE=' + repoAppDetails.type + (src.path ? ', PATH=' + src.path : '' + (src.targetRevision ? ', TARGET REVISION=' + src.targetRevision : '')); - } else if (repoAppDetails.type === 'Helm') { - floatingTitle = 'TYPE=' + repoAppDetails.type + ', URL=' + src.repoURL + (src.chart ? ', CHART=' + src.chart + ':' + src.targetRevision : ''); - descriptionCollapsed = - 'TYPE=' + - repoAppDetails.type + - (src.chart ? ', CHART=' + src.chart + ':' + src.targetRevision : '') + - (src.path ? ', PATH=' + src.path : '') + - (src.helm && src.helm.valueFiles ? ', VALUES=' + src.helm.valueFiles[0] : ''); - } else if (repoAppDetails.type === 'Kustomize') { - floatingTitle = 'TYPE=' + repoAppDetails.type + ', URL=' + src.repoURL; - descriptionCollapsed = 'TYPE=' + repoAppDetails.type + ', VERSION=' + src.kustomize.version + (src.path ? ', PATH=' + src.path : ''); - } else if (repoAppDetails.type === 'Plugin') { - floatingTitle = - 'TYPE=' + - repoAppDetails.type + - ', URL=' + - src.repoURL + - (src.path ? ', PATH=' + src.path : '') + - (src.targetRevision ? ', TARGET REVISION=' + src.targetRevision : ''); - descriptionCollapsed = - 'TYPE=' + repoAppDetails.type + '' + (src.path ? ', PATH=' + src.path : '') + (src.targetRevision ? ', TARGET REVISION=' + src.targetRevision : ''); - } + const lowerPanelAttributes: EditablePanelItem[] = []; + const upperPanelAttributes: EditablePanelItem[] = []; + + const upperPanel = gatherCoreSourceDetails(ind, upperPanelAttributes, appSources[ind], app); + const lowerPanel = gatherDetails( + ind, + repoAppDetails, + lowerPanelAttributes, + appSources[ind], + app, + setRemovedOverrides, + removedOverrides, + appParamsDeletedState, + setAppParamsDeletedState, + true + ); + + if (repoAppDetails.type === 'Directory') { + floatingTitle = + 'Source ' + + (ind + 1) + + ': TYPE=' + + repoAppDetails.type + + ', URL=' + + src.repoURL + + (repoAppDetails.path ? ', PATH=' + repoAppDetails.path : '') + + (src.targetRevision ? ', TARGET REVISION=' + src.targetRevision : ''); + } else if (repoAppDetails.type === 'Helm') { + floatingTitle = + 'Source ' + + (ind + 1) + + ': TYPE=' + + repoAppDetails.type + + ', URL=' + + src.repoURL + + (src.chart ? ', CHART=' + src.chart + ':' + src.targetRevision : '') + + (src.path ? ', PATH=' + src.path : '') + + (src.targetRevision ? ', REVISION=' + src.targetRevision : ''); + } else if (repoAppDetails.type === 'Kustomize') { + floatingTitle = + 'Source ' + + (ind + 1) + + ': TYPE=' + + repoAppDetails.type + + ', URL=' + + src.repoURL + + (repoAppDetails.path ? ', PATH=' + repoAppDetails.path : '') + + (src.targetRevision ? ', TARGET REVISION=' + src.targetRevision : ''); + } else if (repoAppDetails.type === 'Plugin') { + floatingTitle = + 'Source ' + + (ind + 1) + + ': TYPE=' + + repoAppDetails.type + + ', URL=' + + src.repoURL + + (repoAppDetails.path ? ', PATH=' + repoAppDetails.path : '') + + (src.targetRevision ? ', TARGET REVISION=' + src.targetRevision : ''); } return ( - { + const appSrc = input.spec.sources[ind]; + function isDefined(item: any) { return item !== null && item !== undefined; } function isDefinedWithVersion(item: any) { return item !== null && item !== undefined && item.match(/:/); } - - if (src.helm && src.helm.parameters) { - src.helm.parameters = src.helm.parameters.filter(isDefined); + if (appSrc.helm && appSrc.helm.parameters) { + appSrc.helm.parameters = appSrc.helm.parameters.filter(isDefined); } - if (src.kustomize && src.kustomize.images) { - src.kustomize.images = src.kustomize.images.filter(isDefinedWithVersion); + + if (appSrc.kustomize && appSrc.kustomize.images) { + appSrc.kustomize.images = appSrc.kustomize.images.filter(isDefinedWithVersion); } - let params = input.spec?.source?.plugin?.parameters; + let params = input.spec?.sources[ind]?.plugin?.parameters; if (params) { for (const param of params) { if (param.map && param.array) { @@ -259,32 +415,40 @@ export const ApplicationParameters = (props: { } params = params.filter(param => !appParamsDeletedState.includes(param.name)); - input.spec.source.plugin.parameters = params; + input.spec.sources[ind].plugin.parameters = params; } - if (input.spec.source.helm && input.spec.source.helm.valuesObject) { - input.spec.source.helm.valuesObject = jsYaml.safeLoad(input.spec.source.helm.values); // Deserialize json - input.spec.source.helm.values = ''; + if (input.spec.sources[ind].helm && input.spec.sources[ind].helm.valuesObject) { + input.spec.sources[ind].helm.valuesObject = jsYaml.safeLoad(input.spec.sources[ind].helm.values); // Deserialize json + input.spec.sources[ind].helm.values = ''; } + await props.save(input, {}); setRemovedOverrides(new Array()); }) } - values={ - app?.spec?.source - ? ((props.details.plugin || app?.spec?.source?.plugin) && cloneDeep(app)) || app - : ((repoAppDetails.plugin || app?.spec?.sources[ind]?.plugin) && cloneDeep(app)) || app - } - validate={updatedApp => { + valuesTop={(app?.spec?.sources && (repoAppDetails.plugin || app?.spec?.sources[ind]?.plugin) && cloneDeep(app)) || app} + valuesBottom={(app?.spec?.sources && (repoAppDetails.plugin || app?.spec?.sources[ind]?.plugin) && cloneDeep(app)) || app} + validateTop={updatedApp => { + const errors = [] as any; + const repoURL = updatedApp.spec.sources[ind].repoURL; + if (repoURL === null || repoURL.length === 0) { + errors['spec.sources[' + ind + '].repoURL'] = 'The source repo URL cannot be empty'; + } else { + errors['spec.sources[' + ind + '].repoURL'] = null; + } + return errors; + }} + validateBottom={updatedApp => { const errors = {} as any; - for (const fieldPath of ['spec.source.directory.jsonnet.tlas', 'spec.source.directory.jsonnet.extVars']) { + for (const fieldPath of ['spec.sources[' + ind + '].directory.jsonnet.tlas', 'spec.sources[' + ind + '].directory.jsonnet.extVars']) { 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.helm && updatedApp.spec.source.helm.values) { - const parsedValues = jsYaml.safeLoad(updatedApp.spec.source.helm.values); - errors['spec.source.helm.values'] = typeof parsedValues === 'object' ? null : 'Values must be a map'; + if (updatedApp.spec.sources[ind].helm?.values) { + const parsedValues = jsYaml.safeLoad(updatedApp.spec.sources[ind].helm.values); + errors['spec.sources[' + ind + '].helm.values'] = typeof parsedValues === 'object' ? null : 'Values must be a map'; } return errors; @@ -295,42 +459,31 @@ export const ApplicationParameters = (props: { setAppParamsDeletedState([]); }) } - title={repoAppDetails.type.toLocaleUpperCase()} - titleCollapsed={src.repoURL} - floatingTitle={floatingTitle} - items={panel as EditablePanelItem[]} + titleBottom={repoAppDetails.type.toLocaleUpperCase()} + titleTop={'SOURCE ' + (ind + 1)} + floatingTitle={floatingTitle ? floatingTitle : null} + itemsBottom={lowerPanel as EditablePanelItem[]} + itemsTop={upperPanel as EditablePanelItem[]} noReadonlyMode={props.noReadonlyMode} - collapsible={sources.length > 1} - collapsed={true} - collapsedDescription={descriptionCollapsed} - hasMultipleSources={app.spec.sources && app.spec.sources.length > 0} + collapsible={collapsible} /> ); } }; -function gatherDetails( - repoDetails: models.RepoAppDetails, - attributes: EditablePanelItem[], - source: models.ApplicationSource, - app: models.Application, - setRemovedOverrides: any, - removedOverrides: any, - appParamsDeletedState: any[], - setAppParamsDeletedState: any -): EditablePanelItem[] { +function gatherCoreSourceDetails(i: number, attributes: EditablePanelItem[], source: models.ApplicationSource, app: models.Application): EditablePanelItem[] { const hasMultipleSources = app.spec.sources && app.spec.sources.length > 0; const isHelm = source.hasOwnProperty('chart'); + const repoUrlField = 'spec.sources[' + i + '].repoURL'; + const sourcesPathField = 'spec.sources[' + i + '].path'; + const chartField = 'spec.sources[' + i + '].chart'; + const revisionField = 'spec.sources[' + i + '].targetRevision'; + // For single source apps using the source field, these fields are shown in the Summary tab. if (hasMultipleSources) { attributes.push({ title: 'REPO URL', view: , - edit: (formApi: FormApi) => - hasMultipleSources ? ( - helpTip('REPO URL is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.') - ) : ( - - ) + edit: (formApi: FormApi) => }); if (isHelm) { attributes.push({ @@ -340,59 +493,51 @@ function gatherDetails( {source.chart}:{source.targetRevision} ), - edit: (formApi: FormApi) => - hasMultipleSources ? ( - helpTip('CHART is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.') - ) : ( - services.repos.charts(src.repoURL).catch(() => new Array())}> - {(charts: models.HelmChart[]) => ( -
-
- chart.name), - filterSuggestions: true - }} - /> -
- { - const chartInfo = data.charts.find(chart => chart.name === data.chart); - return (chartInfo && chartInfo.versions) || new Array(); - }}> - {(versions: string[]) => ( -
- - -
- )} -
+ edit: (formApi: FormApi) => ( + services.repos.charts(src.repoURL).catch(() => new Array())}> + {(charts: models.HelmChart[]) => ( +
+
+ chart.name), + filterSuggestions: true + }} + />
- )} - - ) + { + const chartInfo = data.charts.find(chart => chart.name === data.chart); + return (chartInfo && chartInfo.versions) || new Array(); + }}> + {(versions: string[]) => ( +
+ + +
+ )} +
+
+ )} +
+ ) }); } else { attributes.push({ title: 'TARGET REVISION', view: , - edit: (formApi: FormApi) => - hasMultipleSources ? ( - helpTip('TARGET REVISION is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.') - ) : ( - - ) + edit: (formApi: FormApi) => }); attributes.push({ title: 'PATH', @@ -401,20 +546,25 @@ function gatherDetails( {processPath(source.path)} ), - edit: (formApi: FormApi) => - hasMultipleSources ? ( - helpTip('PATH is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.') - ) : ( - - ) - }); - attributes.push({ - title: 'REF', - view: source.ref, - edit: (formApi: FormApi) => + edit: (formApi: FormApi) => }); } } + return attributes; +} + +function gatherDetails( + ind: number, + repoDetails: models.RepoAppDetails, + attributes: EditablePanelItem[], + source: models.ApplicationSource, + app: models.Application, + setRemovedOverrides: any, + removedOverrides: any, + appParamsDeletedState: any[], + setAppParamsDeletedState: any, + isMultiSource: boolean +): EditablePanelItem[] { if (repoDetails.type === 'Kustomize' && repoDetails.kustomize) { attributes.push({ title: 'VERSION', @@ -462,7 +612,7 @@ function gatherDetails( getParamsEditableItems( app, 'IMAGES', - 'spec.source.kustomize.images', + isMultiSource ? 'spec.sources[' + ind + '].kustomize.images' : 'spec.source.kustomize.images', removedOverrides, setRemovedOverrides, distinct(imagesByName.keys(), overridesByName.keys()).map(name => { @@ -488,7 +638,7 @@ function gatherDetails( edit: (formApi: FormApi) => (
-                            
+                            
                         
); @@ -527,7 +677,7 @@ function gatherDetails( getParamsEditableItems( app, 'PARAMETERS', - 'spec.source.helm.parameters', + isMultiSource ? 'spec.sources[' + ind + '].helm.parameters' : 'spec.source.helm.parameters', removedOverrides, setRemovedOverrides, distinct(paramsByName.keys(), overridesByName.keys()).map(name => { @@ -550,7 +700,7 @@ function gatherDetails( getParamsEditableItems( app, 'PARAMETERS', - 'spec.source.helm.parameters', + isMultiSource ? 'spec.sources[' + ind + '].helm.parameters' : 'spec.source.helm.parameters', removedOverrides, setRemovedOverrides, distinct(fileParamsByName.keys(), fileOverridesByName.keys()).map(name => { @@ -572,7 +722,12 @@ function gatherDetails( edit: (formApi: FormApi) => ( services.authService.plugins()}> {(plugins: Plugin[]) => ( - p.name)}} /> + p.name)}} + /> )} ) @@ -588,7 +743,9 @@ function gatherDetails( ))}
), - edit: (formApi: FormApi) => + edit: (formApi: FormApi) => ( + + ) }); const parametersSet = new Set(); if (repoDetails?.plugin?.parametersAnnouncement) { @@ -640,7 +797,7 @@ function gatherDetails( ), edit: (formApi: FormApi) => ( ( ( + edit: (formApi: FormApi) => }); attributes.push({ title: 'TOP-LEVEL ARGUMENTS', @@ -772,3 +930,29 @@ function gatherDetails( } return attributes; } + +// For Sources field. Get one source with index i from the list +async function getSourceFromSources(app: models.Application, i: number) { + const sources: models.ApplicationSource[] = app.spec.sources; + if (sources && i < sources.length) { + const aSource = sources[i]; + const repoDetail = await services.repos.appDetails(aSource, app.metadata.name, app.spec.project).catch(e => ({ + type: 'Directory' as models.AppSourceType, + path: aSource.path + })); + return repoDetail; + } + return null; +} + +// Delete when source field is removed +async function getSingleSource(app: models.Application) { + if (app.spec.source) { + const repoDetail = await services.repos.appDetails(getAppDefaultSource(app), app.metadata.name, app.spec.project).catch(() => ({ + type: 'Directory' as models.AppSourceType, + path: getAppDefaultSource(app).path + })); + return repoDetail; + } + return null; +} diff --git a/ui/src/app/applications/components/application-summary/application-summary.tsx b/ui/src/app/applications/components/application-summary/application-summary.tsx index 83916a001860..d11a2e28620b 100644 --- a/ui/src/app/applications/components/application-summary/application-summary.tsx +++ b/ui/src/app/applications/components/application-summary/application-summary.tsx @@ -30,7 +30,6 @@ import {EditAnnotations} from './edit-annotations'; import './application-summary.scss'; import {DeepLinks} from '../../../shared/components/deep-links'; -import {ExternalLinks} from '../application-urls'; function swap(array: any[], a: number, b: number) { array = array.slice(); @@ -173,12 +172,7 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { !hasMultipleSources && { title: 'REPO URL', view: , - edit: (formApi: FormApi) => - hasMultipleSources ? ( - helpTip('REPO URL is not editable for applications with multiple sources. You can edit them in the "Manifest" tab.') - ) : ( - - ) + edit: (formApi: FormApi) => }, ...(!hasMultipleSources ? isHelm @@ -268,12 +262,7 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { view: app.spec.revisionHistoryLimit, edit: (formApi: FormApi) => (
- +
- {urls.map((url, i) => { - return ( - - {url.title}   + {urls + .map(item => item.split('|')) + .map((parts, i) => ( + 1 ? parts[1] : parts[0]} target='__blank'> + {parts[0]}   - ); - })} + ))} ) }); @@ -494,6 +484,7 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => {
This is a multi-source app, see the Sources tab for repository URLs and source-related information. : <>} validate={input => ({ 'spec.project': !input.spec.project && 'Project name is required', 'spec.destination.server': !input.spec.destination.server && input.spec.destination.hasOwnProperty('server') && 'Cluster server is required', @@ -510,7 +501,7 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => {

SYNC POLICY

-
{(app.spec.syncPolicy && app.spec.syncPolicy.automated && AUTOMATED) || MANUAL}
+
{(app.spec.syncPolicy && app.spec.syncPolicy.automated && AUTOMATED) || NONE}
{(app.spec.syncPolicy && app.spec.syncPolicy.automated && (
); }; - -// Maintain compatibility with single source field. Remove else block when source field is removed -async function getSources(app: models.Application) { - const listOfDetails = new Array(); - const sources: models.ApplicationSource[] = app.spec.sources; - if (sources) { - const length = sources.length; - for (let i = 0; i < length; i++) { - const aSource = sources[i]; - const repoDetail = await services.repos.appDetails(aSource, app.metadata.name, app.spec.project).catch(e => ({ - type: 'Directory' as AppSourceType, - path: aSource.path - })); - if (repoDetail) { - listOfDetails.push(repoDetail); - } - } - return listOfDetails; - } else { - const repoDetail = await services.repos.appDetails(AppUtils.getAppDefaultSource(app), app.metadata.name, app.spec.project).catch(() => ({ - type: 'Directory' as AppSourceType, - path: AppUtils.getAppDefaultSource(app).path - })); - if (repoDetail) { - listOfDetails.push(repoDetail); - } - return listOfDetails; - } -} diff --git a/ui/src/app/applications/components/revision-form-field/revision-form-field.tsx b/ui/src/app/applications/components/revision-form-field/revision-form-field.tsx index 8174896d3e64..8064e0a8f5ba 100644 --- a/ui/src/app/applications/components/revision-form-field/revision-form-field.tsx +++ b/ui/src/app/applications/components/revision-form-field/revision-form-field.tsx @@ -10,6 +10,7 @@ interface RevisionFormFieldProps { helpIconTop?: string; hideLabel?: boolean; repoURL: string; + fieldValue?: string; } export class RevisionFormField extends React.PureComponent { @@ -49,7 +50,7 @@ export class RevisionFormField extends React.PureComponent { + title?: string | React.ReactNode; + uniqueId: string; + values: T; + validate?: (values: T) => any; + save?: (input: T, query: {validate?: boolean}) => Promise; + items: EditablePanelItem[]; + onModeSwitch?: () => any; + noReadonlyMode?: boolean; + view?: string | React.ReactNode; + edit?: (formApi: FormApi) => React.ReactNode; + collapsible?: boolean; + ctx: ContextApis; + isTopSection?: boolean; + disabledState?: boolean; + updateButtons?: (pressed: boolean) => void; +} + +interface EditableSectionState { + isEditing: boolean; + isSaving: boolean; +} + +// Similar to editable-panel but it should be part of a white-box, editable-panel HOC and it can be reused one after another +export class EditableSection extends React.Component, EditableSectionState> { + private formApi: FormApi; + + constructor(props: EditableSectionProps) { + super(props); + this.state = {isEditing: !!props.noReadonlyMode, isSaving: false}; + } + + public UNSAFE_componentWillReceiveProps(nextProps: EditableSectionProps) { + if (this.formApi && JSON.stringify(this.props.values) !== JSON.stringify(nextProps.values)) { + if (!!nextProps.noReadonlyMode) { + this.formApi.setAllValues(nextProps.values); + } + } + } + + public render() { + return ( +
+ {!this.props.noReadonlyMode && this.props.save && ( +
+ {!this.state.isEditing && ( + + )} + {this.state.isEditing && ( +
+ + {' '} + + +
+ )} +
+ )} + + {this.props.title && ( +
+

{this.props.title}

+
+ )} + + {(!this.state.isEditing && ( + + {this.props.view} + {this.props.items + .filter(item => item.view) + .map(item => ( + + {item.before} +
+
{item.customTitle || item.title}
+
{item.view}
+
+
+ ))} +
+ )) || ( +
(this.formApi = api)} + formDidUpdate={async form => { + if (this.props.noReadonlyMode && this.props.save) { + await this.props.save(form.values as any, {}); + } + }} + onSubmit={async input => { + try { + this.setState({isSaving: true}); + await this.props.save(input as any, {}); + this.setState({isEditing: false, isSaving: false}); + this.props.onModeSwitch(); + } catch (e) { + this.props.ctx.notifications.show({ + content: , + type: NotificationType.Error + }); + } finally { + this.setState({isSaving: false}); + } + }} + defaultValues={this.props.values} + validateError={this.props.validate}> + {api => ( + + {this.props.edit && this.props.edit(api)} + {this.props.items?.map(item => ( + + {item.before} +
+
{(item.titleEdit && item.titleEdit(api)) || item.customTitle || item.title}
+
{(item.edit && item.edit(api)) || item.view}
+
+
+ ))} +
+ )} + + )} +
+ ); + } +}