From 422c6f60e20711680b5eb518e1cb6a09284a2ffb Mon Sep 17 00:00:00 2001 From: Keith Chong Date: Thu, 21 Mar 2024 18:11:47 -0400 Subject: [PATCH] feat: Provide Edit support in Sources tab for multi-source app (#17588) Signed-off-by: Keith Chong --- .../application-parameters-source.tsx | 112 ++++ .../application-parameters.scss | 80 +++ .../application-parameters.tsx | 602 +++++++++++++++--- .../application-summary.tsx | 198 +++--- .../resource-details/resource-details.tsx | 36 +- .../revision-form-field.tsx | 3 +- ui/src/app/applications/components/utils.tsx | 7 + .../editable-panel/editable-panel.scss | 35 + .../editable-panel/editable-section.tsx | 162 +++++ ui/src/app/shared/models.ts | 7 + 10 files changed, 1014 insertions(+), 228 deletions(-) create mode 100644 ui/src/app/applications/components/application-parameters/application-parameters-source.tsx create mode 100644 ui/src/app/applications/components/application-parameters/application-parameters.scss create mode 100644 ui/src/app/shared/components/editable-panel/editable-section.tsx 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 38a6d151a90c..81b42668ae66 100644 --- a/ui/src/app/applications/components/application-parameters/application-parameters.tsx +++ b/ui/src/app/applications/components/application-parameters/application-parameters.tsx @@ -6,15 +6,19 @@ import { ArrayInputField, ArrayValueField, CheckboxField, - EditablePanel, - EditablePanelItem, Expandable, MapValueField, NameValueEditor, StringValueField, NameValue, TagsInputField, - ValueEditor + ValueEditor, + Paginate, + RevisionHelpIcon, + Revision, + Repo, + EditablePanel, + EditablePanelItem } from '../../../shared/components'; import * as models from '../../../shared/models'; import {ApplicationSourceDirectory, Plugin} from '../../../shared/models'; @@ -23,8 +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} from '../utils'; +import {getAppDefaultSource, getAppSources} 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 { @@ -51,6 +60,16 @@ function overridesFirst(first: {overrideIndex: number; metadata: {name: string}} return first.overrideIndex - second.overrideIndex; } +function processPath(path: string) { + if (path !== null && path !== undefined) { + if (path === '.') { + return '(root)'; + } + return path; + } + return ''; +} + function getParamsEditableItems( app: models.Application, title: string, @@ -122,20 +141,431 @@ function getParamsEditableItems( export const ApplicationParameters = (props: { application: models.Application; - details: models.RepoAppDetails; + details?: models.RepoAppDetails; + detailsList?: models.RepoAppDetails[]; save?: (application: models.Application, query: {validate?: boolean}) => Promise; 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); + const source = getAppDefaultSource(app); // For source field + const appSources = getAppSources(app); const [removedOverrides, setRemovedOverrides] = React.useState(new Array()); + const collapsible = props.collapsedSources !== undefined && props.handleCollapse !== undefined; + + const attributes: EditablePanelItem[] = []; - let attributes: EditablePanelItem[] = []; - const isValuesObject = source?.helm?.valuesObject; - const helmValues = isValuesObject ? jsYaml.safeDump(source.helm.valuesObject) : source?.helm?.values; const [appParamsDeletedState, setAppParamsDeletedState] = React.useState([]); - if (props.details.type === 'Kustomize' && props.details.kustomize) { + 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 { + // 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 + ) + } + + ); + } + + // 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); + }}> +
+ +
+
+
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)} + +
+
+ ); + } + + 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; + 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 === models.AppSource.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 === models.AppSource.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 === models.AppSource.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 === models.AppSource.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 = getAppSources(input)[ind]; + + function isDefined(item: any) { + return item !== null && item !== undefined; + } + function isDefinedWithVersion(item: any) { + return item !== null && item !== undefined && item.match(/:/); + } + if (appSrc.helm && appSrc.helm.parameters) { + appSrc.helm.parameters = appSrc.helm.parameters.filter(isDefined); + } + + if (appSrc.kustomize && appSrc.kustomize.images) { + appSrc.kustomize.images = appSrc.kustomize.images.filter(isDefinedWithVersion); + } + + let params = input.spec?.sources[ind]?.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.sources[ind].plugin.parameters = params; + } + 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()); + }) + } + 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.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.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; + }} + onModeSwitch={ + repoAppDetails.plugin && + (() => { + setAppParamsDeletedState([]); + }) + } + titleBottom={repoAppDetails.type.toLocaleUpperCase()} + titleTop={'SOURCE ' + (ind + 1)} + floatingTitle={floatingTitle ? floatingTitle : null} + itemsBottom={lowerPanel as EditablePanelItem[]} + itemsTop={upperPanel as EditablePanelItem[]} + noReadonlyMode={props.noReadonlyMode} + collapsible={collapsible} + /> + ); + } +}; + +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) => + }); + if (isHelm) { + attributes.push({ + title: 'CHART', + view: ( + + {source.chart}:{source.targetRevision} + + ), + 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) => + }); + attributes.push({ + title: 'PATH', + view: ( + + {processPath(source.path)} + + ), + 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', view: (source.kustomize && source.kustomize.version) || default, @@ -168,7 +598,7 @@ export const ApplicationParameters = (props: { edit: (formApi: FormApi) => }); - const srcImages = ((props.details && props.details.kustomize && props.details.kustomize.images) || []).map(val => kustomize.parse(val)); + const srcImages = ((repoDetails && repoDetails.kustomize && repoDetails.kustomize.images) || []).map(val => kustomize.parse(val)); const images = ((source.kustomize && source.kustomize.images) || []).map(val => kustomize.parse(val)); if (srcImages.length > 0) { @@ -182,7 +612,7 @@ export const ApplicationParameters = (props: { 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 => { @@ -199,17 +629,19 @@ export const ApplicationParameters = (props: { ) ); } - } else if (props.details.type === 'Helm' && props.details.helm) { + } else if (repoDetails.type === 'Helm' && repoDetails.helm) { + const isValuesObject = source?.helm?.valuesObject; + const helmValues = isValuesObject ? jsYaml.safeDump(source.helm.valuesObject) : source?.helm?.values; attributes.push({ title: 'VALUES FILES', view: (source.helm && (source.helm.valueFiles || []).join(', ')) || 'No values files selected', edit: (formApi: FormApi) => ( @@ -231,21 +663,21 @@ export const ApplicationParameters = (props: { return (
-                            
+                            
                         
); } }); const paramsByName = new Map(); - (props.details.helm.parameters || []).forEach(param => paramsByName.set(param.name, param)); + (repoDetails.helm.parameters || []).forEach(param => paramsByName.set(param.name, param)); const overridesByName = new Map(); ((source.helm && source.helm.parameters) || []).forEach((override, i) => overridesByName.set(override.name, i)); attributes = attributes.concat( 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 => { @@ -261,14 +693,14 @@ export const ApplicationParameters = (props: { ) ); const fileParamsByName = new Map(); - (props.details.helm.fileParameters || []).forEach(param => fileParamsByName.set(param.name, param)); + (repoDetails.helm.fileParameters || []).forEach(param => fileParamsByName.set(param.name, param)); const fileOverridesByName = new Map(); ((source.helm && source.helm.fileParameters) || []).forEach((override, i) => fileOverridesByName.set(override.name, i)); attributes = attributes.concat( 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 => { @@ -283,14 +715,19 @@ export const ApplicationParameters = (props: { }) ) ); - } else if (props.details.type === 'Plugin') { + } else if (repoDetails.type === 'Plugin') { attributes.push({ title: 'NAME', view:
{ValueEditor(app.spec.source?.plugin?.name, null)}
, edit: (formApi: FormApi) => ( services.authService.plugins()}> {(plugins: Plugin[]) => ( - p.name)}} /> + p.name)}} + /> )} ) @@ -306,11 +743,13 @@ export const ApplicationParameters = (props: { ))}
), - edit: (formApi: FormApi) => + edit: (formApi: FormApi) => ( + + ) }); const parametersSet = new Set(); - if (props.details?.plugin?.parametersAnnouncement) { - for (const announcement of props.details.plugin.parametersAnnouncement) { + if (repoDetails?.plugin?.parametersAnnouncement) { + for (const announcement of repoDetails.plugin.parametersAnnouncement) { parametersSet.add(announcement.name); } } @@ -324,7 +763,7 @@ export const ApplicationParameters = (props: { parametersSet.delete(key); } parametersSet.forEach(name => { - const announcement = props.details.plugin.parametersAnnouncement?.find(param => param.name === name); + const announcement = repoDetails.plugin.parametersAnnouncement?.find(param => param.name === name); const liveParam = app.spec.source?.plugin?.parameters?.find(param => param.name === name); const pluginIcon = announcement && liveParam ? 'This parameter has been provided by plugin, but is overridden in application manifest.' : 'This parameter is provided by the plugin.'; @@ -358,7 +797,7 @@ export const ApplicationParameters = (props: { ), edit: (formApi: FormApi) => ( ( ( + edit: (formApi: FormApi) => }); attributes.push({ title: 'TOP-LEVEL ARGUMENTS', @@ -488,79 +928,31 @@ export const ApplicationParameters = (props: { edit: (formApi: FormApi) => }); } + return attributes; +} - return ( - { - const src = getAppDefaultSource(input); - - 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={((props.details.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'; - } +// For Sources field. Get one source with index i from the list +async function getSourceFromSources(app: models.Application, i: number) { + const sources: models.ApplicationSource[] = getAppSources(app); + if (sources) { + 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; +} - return errors; - }} - onModeSwitch={ - props.details.plugin && - (() => { - setAppParamsDeletedState([]); - }) - } - title={props.details.type.toLocaleUpperCase()} - items={attributes} - noReadonlyMode={props.noReadonlyMode} - hasMultipleSources={app.spec.sources && app.spec.sources.length > 0} - /> - ); -}; +// 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 f38a380b50ea..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(); @@ -170,109 +169,100 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { title: 'CREATED AT', view: formatCreationTimestamp(app.metadata.creationTimestamp) }, - { + !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) => }, - ...(isHelm - ? [ - { - title: 'CHART', - view: ( - - {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 - }} - /> + ...(!hasMultipleSources + ? isHelm + ? [ + { + title: 'CHART', + view: ( + + {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[]) => ( +
+ + +
+ )} +
- { - const chartInfo = data.charts.find(chart => chart.name === data.chart); - return (chartInfo && chartInfo.versions) || new Array(); - }}> - {(versions: string[]) => ( -
- - -
- )} -
-
- )} - - ) - } - ] - : [ - { - 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.') - ) : ( - - ) - }, - { - title: 'PATH', - view: ( - - {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.') - ) : ( - - ) - } - ]), - + )} + + ) + } + ] + : [ + { + 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.') + ) : ( + + ) + }, + { + title: 'PATH', + view: ( + + {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.') + ) : ( + + ) + } + ] + : []), { title: 'REVISION HISTORY LIMIT', 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]}   - ); - })} + ))} ) }); @@ -493,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', @@ -509,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 && ( + )} + {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}
+
+
+ ))} +
+ )} + + )} +
+ ); + } +} diff --git a/ui/src/app/shared/models.ts b/ui/src/app/shared/models.ts index 823c61c34dc9..230e15127fa0 100644 --- a/ui/src/app/shared/models.ts +++ b/ui/src/app/shared/models.ts @@ -586,6 +586,13 @@ export interface HelmChart { export type AppSourceType = 'Helm' | 'Kustomize' | 'Directory' | 'Plugin'; +export enum AppSource { + Helm = 'Helm', + Kustomize = 'Kustomize', + Directory = 'Directory', + Plugin = 'Plugin' +} + export interface RepoAppDetails { type: AppSourceType; path: string;