From 26f6e5d99506e75fabed453e33400da61a335d28 Mon Sep 17 00:00:00 2001 From: Keith Chong Date: Tue, 16 Apr 2024 17:40:01 -0400 Subject: [PATCH] feat: Add Sources tab to show all sources of a multi-source app (#17274) (#17275) * feat: Add Sources tab to show all sources of a multi-source app (#17274) Signed-off-by: Keith Chong * Update ui/src/app/applications/components/resource-details/resource-details.tsx Co-authored-by: Alexandre Gaudreault Signed-off-by: Keith Chong * Remove getAppSources Signed-off-by: Keith Chong * Add address Ishita's comments. Add missing Ref field Signed-off-by: Keith Chong * Use single quotes Signed-off-by: Keith Chong --------- Signed-off-by: Keith Chong Co-authored-by: Alexandre Gaudreault --- .../application-parameters.tsx | 394 +++++++++++++----- .../application-summary.tsx | 165 ++++---- .../resource-details/resource-details.tsx | 51 ++- .../editable-panel/editable-panel.scss | 20 + .../editable-panel/editable-panel.tsx | 205 +++++---- ui/src/app/shared/models.ts | 2 + 6 files changed, 562 insertions(+), 275 deletions(-) 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 38a6d151a90c2..3028c3a13725c 100644 --- a/ui/src/app/applications/components/application-parameters/application-parameters.tsx +++ b/ui/src/app/applications/components/application-parameters/application-parameters.tsx @@ -14,7 +14,11 @@ import { StringValueField, NameValue, TagsInputField, - ValueEditor + ValueEditor, + Paginate, + RevisionHelpIcon, + Revision, + Repo } from '../../../shared/components'; import * as models from '../../../shared/models'; import {ApplicationSourceDirectory, Plugin} from '../../../shared/models'; @@ -23,8 +27,9 @@ 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, helpTip} from '../utils'; import * as jsYaml from 'js-yaml'; +import {RevisionFormField} from '../revision-form-field/revision-form-field'; const TextWithMetadataField = ReactFormField((props: {metadata: {value: string}; fieldApi: FieldApi; className: string}) => { const { @@ -51,6 +56,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 +137,285 @@ 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; }) => { const app = cloneDeep(props.application); - const source = getAppDefaultSource(app); + const source = getAppDefaultSource(app); // For source field + const appSources = app?.spec.sources; const [removedOverrides, setRemovedOverrides] = React.useState(new Array()); let attributes: EditablePanelItem[] = []; - const isValuesObject = source?.helm?.valuesObject; - const helmValues = isValuesObject ? jsYaml.safeDump(source.helm.valuesObject) : source?.helm?.values; + const multipleAttributes = new Array(); + const [appParamsDeletedState, setAppParamsDeletedState] = React.useState([]); - if (props.details.type === 'Kustomize' && props.details.kustomize) { + 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 = []; + } + } else { + // For source field. Delete this when source field is removed + attributes = gatherDetails(props.details, attributes, source, app, setRemovedOverrides, removedOverrides, appParamsDeletedState, setAppParamsDeletedState); + } + + if (props.detailsList && props.detailsList.length > 1) { + return ( + { + props.setPageNumber(page); + }}> + {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; + }} + + ); + } 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; + 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 : ''); + } + } + 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={ + 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 => { + 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()} + titleCollapsed={src.repoURL} + floatingTitle={floatingTitle} + items={panel as EditablePanelItem[]} + noReadonlyMode={props.noReadonlyMode} + collapsible={sources.length > 1} + collapsed={true} + collapsedDescription={descriptionCollapsed} + hasMultipleSources={app.spec.sources && app.spec.sources.length > 0} + /> + ); + } +}; + +function gatherDetails( + repoDetails: models.RepoAppDetails, + attributes: EditablePanelItem[], + source: models.ApplicationSource, + app: models.Application, + setRemovedOverrides: any, + removedOverrides: any, + appParamsDeletedState: any[], + setAppParamsDeletedState: any +): EditablePanelItem[] { + const hasMultipleSources = app.spec.sources && app.spec.sources.length > 0; + const isHelm = source.hasOwnProperty('chart'); + 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.') + ) : ( + + ) + }); + if (isHelm) { + attributes.push({ + 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[]) => ( +
+ + +
+ )} +
+
+ )} +
+ ) + }); + } 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.') + ) : ( + + ) + }); + attributes.push({ + 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.') + ) : ( + + ) + }); + attributes.push({ + title: 'REF', + view: source.ref, + edit: (formApi: FormApi) => + }); + } + } + if (repoDetails.type === 'Kustomize' && repoDetails.kustomize) { attributes.push({ title: 'VERSION', view: (source.kustomize && source.kustomize.version) || default, @@ -168,7 +448,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) { @@ -199,7 +479,9 @@ 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', @@ -209,7 +491,7 @@ export const ApplicationParameters = (props: { field='spec.source.helm.valueFiles' component={TagsInputField} componentProps={{ - options: props.details.helm.valueFiles, + options: repoDetails.helm.valueFiles, noTagsLabel: 'No values files selected' }} /> @@ -238,7 +520,7 @@ export const ApplicationParameters = (props: { } }); 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( @@ -261,7 +543,7 @@ 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( @@ -283,7 +565,7 @@ 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)}
, @@ -309,8 +591,8 @@ export const ApplicationParameters = (props: { 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 +606,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.'; @@ -450,7 +732,7 @@ export const ApplicationParameters = (props: { }); } }); - } else if (props.details.type === 'Directory') { + } else if (repoDetails.type === 'Directory') { const directory = source.directory || ({} as ApplicationSourceDirectory); attributes.push({ title: 'DIRECTORY RECURSE', @@ -488,79 +770,5 @@ export const ApplicationParameters = (props: { edit: (formApi: FormApi) => }); } - - 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'; - } - - 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} - /> - ); -}; + return attributes; +} 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 f38a380b50ea8..83916a001860e 100644 --- a/ui/src/app/applications/components/application-summary/application-summary.tsx +++ b/ui/src/app/applications/components/application-summary/application-summary.tsx @@ -170,7 +170,7 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { title: 'CREATED AT', view: formatCreationTimestamp(app.metadata.creationTimestamp) }, - { + !hasMultipleSources && { title: 'REPO URL', view: , edit: (formApi: FormApi) => @@ -180,88 +180,89 @@ export const ApplicationSummary = (props: ApplicationSummaryProps) => { ) }, - ...(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, 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 52d2fef184703..1dfeb153d07ef 100644 --- a/ui/src/app/applications/components/resource-details/resource-details.tsx +++ b/ui/src/app/applications/components/resource-details/resource-details.tsx @@ -40,6 +40,7 @@ export const ResourceDetails = (props: ResourceDetailsProps) => { const tab = new URLSearchParams(appContext.history.location.search).get('tab'); const selectedNodeInfo = NodeInfo(new URLSearchParams(appContext.history.location.search).get('node')); const selectedNodeKey = selectedNodeInfo.key; + const [pageNumber, setPageNumber] = React.useState(0); const getResourceTabs = ( node: ResourceNode, @@ -161,23 +162,18 @@ export const ResourceDetails = (props: ResourceDetailsProps) => { content: updateApp(app, query)} /> }, { - title: 'PARAMETERS', - key: 'parameters', + title: 'SOURCES', + key: 'sources', content: ( - - services.repos.appDetails(AppUtils.getAppDefaultSource(app), app.metadata.name, app.spec.project).catch(() => ({ - type: 'Directory' as AppSourceType, - path: AppUtils.getAppDefaultSource(app).path - })) - }> - {(details: RepoAppDetails) => ( + getSources(app)}> + {(details: RepoAppDetails[]) => ( updateApp(app, query)} application={application} - details={details} + details={details[0]} + detailsList={details} + pageNumber={pageNumber} + setPageNumber={setPageNumber} /> )} @@ -368,3 +364,32 @@ export const ResourceDetails = (props: ResourceDetailsProps) => {
); }; + +// 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/shared/components/editable-panel/editable-panel.scss b/ui/src/app/shared/components/editable-panel/editable-panel.scss index 7da3f2c3fc55c..ee0d48e77d689 100644 --- a/ui/src/app/shared/components/editable-panel/editable-panel.scss +++ b/ui/src/app/shared/components/editable-panel/editable-panel.scss @@ -13,6 +13,26 @@ right: 3em; } + &__collapsible-button { + position: absolute; + top: 30px; + right: 30px; + } + + &__sticky-title { + z-index: 10; + opacity: 75%; + position: sticky; + padding-left: 15px; + padding-right: 15px; + margin-bottom: 5px; + text-align: center; + top: 0px; + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + } + .form-field__select { line-height: 15px; padding: 0; diff --git a/ui/src/app/shared/components/editable-panel/editable-panel.tsx b/ui/src/app/shared/components/editable-panel/editable-panel.tsx index 9e62711e0da7d..6567c7f4e3850 100644 --- a/ui/src/app/shared/components/editable-panel/editable-panel.tsx +++ b/ui/src/app/shared/components/editable-panel/editable-panel.tsx @@ -18,6 +18,8 @@ export interface EditablePanelItem { export interface EditablePanelProps { title?: string | React.ReactNode; + titleCollapsed?: string | React.ReactNode; + floatingTitle?: string | React.ReactNode; values: T; validate?: (values: T) => any; save?: (input: T, query: {validate?: boolean}) => Promise; @@ -27,11 +29,15 @@ export interface EditablePanelProps { view?: string | React.ReactNode; edit?: (formApi: FormApi) => React.ReactNode; hasMultipleSources?: boolean; + collapsible?: boolean; + collapsed?: boolean; + collapsedDescription?: string; } interface EditablePanelState { edit: boolean; saving: boolean; + collapsed: boolean; } require('./editable-panel.scss'); @@ -41,7 +47,7 @@ export class EditablePanel extends React.Component constructor(props: EditablePanelProps) { super(props); - this.state = {edit: !!props.noReadonlyMode, saving: false}; + this.state = {edit: !!props.noReadonlyMode, saving: false, collapsed: this.props.collapsed}; } public UNSAFE_componentWillReceiveProps(nextProps: EditablePanelProps) { @@ -55,105 +61,130 @@ export class EditablePanel extends React.Component public render() { return ( - {ctx => ( -
-
- {!this.props.noReadonlyMode && this.props.save && ( -
- {!this.state.edit && ( - - )} - {this.state.edit && ( - - {' '} + {ctx => + this.props.collapsible && this.state.collapsed ? ( +
this.setState({collapsed: !this.state.collapsed})}> +
+
{this.props.titleCollapsed ? this.props.titleCollapsed : this.props.title}
+
{this.props.collapsedDescription}
+
+
+ +
+
+ ) : ( +
+ {this.props.floatingTitle &&
{this.props.floatingTitle}
} +
+ {!this.props.noReadonlyMode && this.props.save && ( +
+ {!this.state.edit && ( - - )} -
- )} - {this.props.title &&

{this.props.title}

} - {(!this.state.edit && ( - - {this.props.view} - {this.props.items - .filter(item => item.view) - .map(item => ( - - {item.before} -
-
{item.customTitle || item.title}
-
{item.view}
-
+ )} + {this.state.edit && ( + + {' '} + - ))} -
- )) || ( -
(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({saving: true}); - await this.props.save(input as any, {}); - this.setState({edit: false, saving: false}); - this.onModeSwitch(); - } catch (e) { - ctx.notifications.show({ - content: , - type: NotificationType.Error - }); - } finally { - this.setState({saving: false}); - } - }} - defaultValues={this.props.values} - validateError={this.props.validate}> - {api => ( - - {this.props.edit && this.props.edit(api)} - {this.props.items.map(item => ( + )} +
+ )} + {this.props.collapsible && ( + +
+ { + this.setState({collapsed: !this.state.collapsed}); + }} + /> +
+
+ )} + {this.props.title &&

{this.props.title}

} + {(!this.state.edit && ( + + {this.props.view} + {this.props.items + .filter(item => item.view) + .map(item => ( {item.before}
-
{(item.titleEdit && item.titleEdit(api)) || item.customTitle || item.title}
-
{(item.edit && item.edit(api)) || item.view}
+
{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({saving: true}); + await this.props.save(input as any, {}); + this.setState({edit: false, saving: false}); + this.onModeSwitch(); + } catch (e) { + ctx.notifications.show({ + content: , + type: NotificationType.Error + }); + } finally { + this.setState({saving: 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 823c61c34dc9a..bb88dcf3cc443 100644 --- a/ui/src/app/shared/models.ts +++ b/ui/src/app/shared/models.ts @@ -197,6 +197,8 @@ export interface ApplicationSource { plugin?: ApplicationSourcePlugin; directory?: ApplicationSourceDirectory; + + ref?: string; } export interface ApplicationSourceHelm {