github.com/argoproj/argo-cd/v3@v3.2.1/ui/src/app/applications/components/application-parameters/application-parameters.tsx (about)

     1  import {AutocompleteField, DataLoader, ErrorNotification, FormField, FormSelect, getNestedField, NotificationType, SlidingPanel} from 'argo-ui';
     2  import * as React from 'react';
     3  import {FieldApi, FormApi, FormField as ReactFormField, Text, TextArea} from 'react-form';
     4  import {cloneDeep} from 'lodash-es';
     5  import {
     6      ArrayInputField,
     7      ArrayValueField,
     8      CheckboxField,
     9      Expandable,
    10      MapValueField,
    11      NameValueEditor,
    12      StringValueField,
    13      NameValue,
    14      TagsInputField,
    15      ValueEditor,
    16      Paginate,
    17      RevisionHelpIcon,
    18      Revision,
    19      Repo,
    20      EditablePanel,
    21      EditablePanelItem,
    22      Spinner
    23  } from '../../../shared/components';
    24  import * as models from '../../../shared/models';
    25  import {ApplicationSourceDirectory, Plugin} from '../../../shared/models';
    26  import {services} from '../../../shared/services';
    27  import {ImageTagFieldEditor} from './kustomize';
    28  import * as kustomize from './kustomize-image';
    29  import {VarsInputField} from './vars-input-field';
    30  import {concatMaps} from '../../../shared/utils';
    31  import {deleteSourceAction, getAppDefaultSource, helpTip} from '../utils';
    32  import * as jsYaml from 'js-yaml';
    33  import {RevisionFormField} from '../revision-form-field/revision-form-field';
    34  import classNames from 'classnames';
    35  import {ApplicationParametersSource} from './application-parameters-source';
    36  
    37  import './application-parameters.scss';
    38  import {AppContext} from '../../../shared/context';
    39  import {SourcePanel} from './source-panel';
    40  
    41  const TextWithMetadataField = ReactFormField((props: {metadata: {value: string}; fieldApi: FieldApi; className: string}) => {
    42      const {
    43          fieldApi: {getValue, setValue}
    44      } = props;
    45      const metadata = getValue() || props.metadata;
    46  
    47      return <input className={props.className} value={metadata.value} onChange={el => setValue({...metadata, value: el.target.value})} />;
    48  });
    49  
    50  function distinct<T>(first: IterableIterator<T>, second: IterableIterator<T>) {
    51      return Array.from(new Set(Array.from(first).concat(Array.from(second))));
    52  }
    53  
    54  function overridesFirst(first: {overrideIndex: number; metadata: {name: string}}, second: {overrideIndex: number; metadata: {name: string}}) {
    55      if (first.overrideIndex === second.overrideIndex) {
    56          return first.metadata.name.localeCompare(second.metadata.name);
    57      }
    58      if (first.overrideIndex < 0) {
    59          return 1;
    60      } else if (second.overrideIndex < 0) {
    61          return -1;
    62      }
    63      return first.overrideIndex - second.overrideIndex;
    64  }
    65  
    66  function processPath(path: string) {
    67      if (path !== null && path !== undefined) {
    68          if (path === '.') {
    69              return '(root)';
    70          }
    71          return path;
    72      }
    73      return '';
    74  }
    75  
    76  function getParamsEditableItems(
    77      app: models.Application,
    78      title: string,
    79      fieldsPath: string,
    80      removedOverrides: boolean[],
    81      setRemovedOverrides: React.Dispatch<boolean[]>,
    82      params: {
    83          key?: string;
    84          overrideIndex: number;
    85          original: string;
    86          metadata: {name: string; value: string};
    87      }[],
    88      component: React.ComponentType = TextWithMetadataField
    89  ) {
    90      return params
    91          .sort(overridesFirst)
    92          .map((param, i) => ({
    93              key: param.key,
    94              title: param.metadata.name,
    95              view: (
    96                  <span title={param.metadata.value}>
    97                      {param.overrideIndex > -1 && <span className='fa fa-gavel' title={`Original value: ${param.original}`} />} {param.metadata.value}
    98                  </span>
    99              ),
   100              edit: (formApi: FormApi) => {
   101                  const labelStyle = {position: 'absolute', right: 0, top: 0, zIndex: 11} as any;
   102                  const overrideRemoved = removedOverrides[i];
   103                  const fieldItemPath = `${fieldsPath}[${i}]`;
   104                  return (
   105                      <React.Fragment>
   106                          {(overrideRemoved && <span>{param.original}</span>) || (
   107                              <FormField
   108                                  formApi={formApi}
   109                                  field={fieldItemPath}
   110                                  component={component}
   111                                  componentProps={{
   112                                      metadata: param.metadata
   113                                  }}
   114                              />
   115                          )}
   116                          {param.metadata.value !== param.original && !overrideRemoved && (
   117                              <a
   118                                  onClick={() => {
   119                                      formApi.setValue(fieldItemPath, null);
   120                                      removedOverrides[i] = true;
   121                                      setRemovedOverrides(removedOverrides);
   122                                  }}
   123                                  style={labelStyle}>
   124                                  Remove override
   125                              </a>
   126                          )}
   127                          {overrideRemoved && (
   128                              <a
   129                                  onClick={() => {
   130                                      formApi.setValue(fieldItemPath, getNestedField(app, fieldsPath)[i]);
   131                                      removedOverrides[i] = false;
   132                                      setRemovedOverrides(removedOverrides);
   133                                  }}
   134                                  style={labelStyle}>
   135                                  Keep override
   136                              </a>
   137                          )}
   138                      </React.Fragment>
   139                  );
   140              }
   141          }))
   142          .map((item, i) => ({...item, before: (i === 0 && <p style={{marginTop: '1em'}}>{title}</p>) || null}));
   143  }
   144  
   145  export const ApplicationParameters = (props: {
   146      application: models.Application;
   147      details?: models.RepoAppDetails;
   148      save?: (application: models.Application, query: {validate?: boolean}) => Promise<any>;
   149      noReadonlyMode?: boolean;
   150      pageNumber?: number;
   151      setPageNumber?: (x: number) => any;
   152      collapsedSources?: boolean[];
   153      handleCollapse?: (i: number, isCollapsed: boolean) => void;
   154      appContext?: AppContext;
   155      tempSource?: models.ApplicationSource;
   156  }) => {
   157      const app = cloneDeep(props.application);
   158      const source = getAppDefaultSource(app); // For source field
   159      const appSources = app?.spec.sources;
   160      const [removedOverrides, setRemovedOverrides] = React.useState(new Array<boolean>());
   161      const collapsible = props.collapsedSources !== undefined && props.handleCollapse !== undefined;
   162      const [createApi, setCreateApi] = React.useState(null);
   163      const [isAddingSource, setIsAddingSource] = React.useState(false);
   164      const [isSavingSource, setIsSavingSource] = React.useState(false);
   165      const [appParamsDeletedState, setAppParamsDeletedState] = React.useState([]);
   166  
   167      if (app.spec.sources?.length > 0 && !props.details) {
   168          // For multi-source case only
   169          return (
   170              <div className='application-parameters'>
   171                  <div className='source-panel-buttons'>
   172                      <button key={'add_source_button'} onClick={() => setIsAddingSource(true)} disabled={false} className='argo-button argo-button--base'>
   173                          {helpTip('Add a new source and append it to the sources field')}
   174                          <span style={{marginRight: '8px'}} />
   175                          Add Source
   176                      </button>
   177                  </div>
   178                  <Paginate
   179                      showHeader={false}
   180                      data={app.spec.sources}
   181                      page={props.pageNumber}
   182                      preferencesKey={'5'}
   183                      onPageChange={page => {
   184                          props.setPageNumber(page);
   185                      }}>
   186                      {data => {
   187                          const listOfPanels: JSX.Element[] = [];
   188                          data.forEach(appSource => {
   189                              const i = app.spec.sources.indexOf(appSource);
   190                              listOfPanels.push(getEditablePanelForSources(i, appSource));
   191                          });
   192                          return listOfPanels;
   193                      }}
   194                  </Paginate>
   195                  <SlidingPanel
   196                      isShown={isAddingSource}
   197                      onClose={() => setIsAddingSource(false)}
   198                      header={
   199                          <div>
   200                              <button
   201                                  key={'source_panel_save_button'}
   202                                  className='argo-button argo-button--base'
   203                                  disabled={isSavingSource}
   204                                  onClick={() => createApi && createApi.submitForm(null)}>
   205                                  <Spinner show={isSavingSource} style={{marginRight: '5px'}} />
   206                                  Save
   207                              </button>{' '}
   208                              <button
   209                                  key={'source_panel_cancel_button_'}
   210                                  onClick={() => {
   211                                      setIsAddingSource(false);
   212                                      setIsSavingSource(false);
   213                                  }}
   214                                  className='argo-button argo-button--base-o'>
   215                                  Cancel
   216                              </button>
   217                          </div>
   218                      }>
   219                      <SourcePanel
   220                          appCurrent={props.application}
   221                          getFormApi={api => {
   222                              setCreateApi(api);
   223                          }}
   224                          onSubmitFailure={errors => {
   225                              props.appContext.apis.notifications.show({
   226                                  content: 'Cannot add source: ' + errors.toString(),
   227                                  type: NotificationType.Warning
   228                              });
   229                          }}
   230                          updateApp={async updatedAppSource => {
   231                              setIsSavingSource(true);
   232                              props.application.spec.sources.push(updatedAppSource.spec.source);
   233                              try {
   234                                  await services.applications.update(props.application);
   235                                  setIsAddingSource(false);
   236                              } catch (e) {
   237                                  props.application.spec.sources.pop();
   238                                  props.appContext.apis.notifications.show({
   239                                      content: <ErrorNotification title='Unable to create source' e={e} />,
   240                                      type: NotificationType.Error
   241                                  });
   242                              } finally {
   243                                  setIsSavingSource(false);
   244                              }
   245                          }}
   246                      />
   247                  </SlidingPanel>
   248              </div>
   249          );
   250      } else {
   251          // For the three other references of ApplicationParameters. They are single source.
   252          // Create App, Add source, Rollback and History
   253          let attributes: EditablePanelItem[] = [];
   254          if (props.details) {
   255              return getEditablePanel(
   256                  gatherDetails(
   257                      0,
   258                      props.details,
   259                      attributes,
   260                      props.tempSource ? props.tempSource : source,
   261                      app,
   262                      setRemovedOverrides,
   263                      removedOverrides,
   264                      appParamsDeletedState,
   265                      setAppParamsDeletedState,
   266                      false
   267                  ),
   268                  props.details
   269              );
   270          } else {
   271              // For single source field, details page where we have to do the load to retrieve repo details
   272              // Input changes frequently due to updates higher in the tree, do not show loading state when reloading
   273              return (
   274                  <DataLoader noLoaderOnInputChange={true} input={app} load={application => getSingleSource(application)}>
   275                      {(details: models.RepoAppDetails) => {
   276                          attributes = [];
   277                          const attr = gatherDetails(
   278                              0,
   279                              details,
   280                              attributes,
   281                              source,
   282                              app,
   283                              setRemovedOverrides,
   284                              removedOverrides,
   285                              appParamsDeletedState,
   286                              setAppParamsDeletedState,
   287                              false
   288                          );
   289                          return getEditablePanel(attr, details);
   290                      }}
   291                  </DataLoader>
   292              );
   293          }
   294      }
   295  
   296      // Collapse button is separate
   297      function getEditablePanelForSources(index: number, appSource: models.ApplicationSource): JSX.Element {
   298          return (collapsible && props.collapsedSources[index] === undefined) || props.collapsedSources[index] ? (
   299              <div
   300                  key={'app_params_collapsed_' + index}
   301                  className='settings-overview__redirect-panel'
   302                  style={{marginTop: 0}}
   303                  onClick={() => {
   304                      const currentState = props.collapsedSources[index] !== undefined ? props.collapsedSources[index] : true;
   305                      props.handleCollapse(index, !currentState);
   306                  }}>
   307                  <div className='editable-panel__collapsible-button'>
   308                      <i className={`fa fa-angle-down filter__collapse editable-panel__collapsible-button__override`} />
   309                  </div>
   310                  <div className='settings-overview__redirect-panel__content'>
   311                      <div className='settings-overview__redirect-panel__title'>Source {index + 1 + (appSource.name ? ' - ' + appSource.name : '') + ': ' + appSource.repoURL}</div>
   312                      <div className='settings-overview__redirect-panel__description'>
   313                          {(appSource.path ? 'PATH=' + appSource.path : '') + (appSource.targetRevision ? (appSource.path ? ', ' : '') + 'REVISION=' + appSource.targetRevision : '')}
   314                      </div>
   315                  </div>
   316              </div>
   317          ) : (
   318              <div key={'app_params_expanded_' + index} className={classNames('white-box', 'editable-panel')} style={{marginBottom: '18px', paddingBottom: '20px'}}>
   319                  <div key={'app_params_panel_' + index} className='white-box__details'>
   320                      {collapsible && (
   321                          <div className='editable-panel__collapsible-button'>
   322                              <i
   323                                  className={`fa fa-angle-up filter__collapse editable-panel__collapsible-button__override`}
   324                                  onClick={() => {
   325                                      props.handleCollapse(index, !props.collapsedSources[index]);
   326                                  }}
   327                              />
   328                          </div>
   329                      )}
   330                      <DataLoader
   331                          key={'app_params_source_' + index}
   332                          input={app.spec.sources[index]}
   333                          load={src => getSourceFromAppSources(src, app.metadata.name, app.spec.project, index, 0)}>
   334                          {(details: models.RepoAppDetails) => getEditablePanelForOneSource(details, index, app.spec.sources[index])}
   335                      </DataLoader>
   336                  </div>
   337              </div>
   338          );
   339      }
   340  
   341      function getEditablePanel(items: EditablePanelItem[], repoAppDetails: models.RepoAppDetails): any {
   342          return (
   343              <div className='application-parameters'>
   344                  <EditablePanel
   345                      save={
   346                          props.save &&
   347                          (async (input: models.Application) => {
   348                              const updatedSrc = input.spec.source;
   349  
   350                              function isDefined(item: any) {
   351                                  return item !== null && item !== undefined;
   352                              }
   353                              function isDefinedWithVersion(item: any) {
   354                                  return item !== null && item !== undefined && item.match(/:/);
   355                              }
   356                              if (updatedSrc && updatedSrc.helm?.parameters) {
   357                                  updatedSrc.helm.parameters = updatedSrc.helm.parameters.filter(isDefined);
   358                              }
   359                              if (updatedSrc && updatedSrc.kustomize?.images) {
   360                                  updatedSrc.kustomize.images = updatedSrc.kustomize.images.filter(isDefinedWithVersion);
   361                              }
   362  
   363                              let params = input.spec?.source?.plugin?.parameters;
   364                              if (params) {
   365                                  for (const param of params) {
   366                                      if (param.map && param.array) {
   367                                          // eslint-disable-next-line @typescript-eslint/ban-ts-comment
   368                                          // @ts-ignore
   369                                          param.map = param.array.reduce((acc, {name, value}) => {
   370                                              // eslint-disable-next-line @typescript-eslint/ban-ts-comment
   371                                              // @ts-ignore
   372                                              acc[name] = value;
   373                                              return acc;
   374                                          }, {});
   375                                          delete param.array;
   376                                      }
   377                                  }
   378                                  params = params.filter(param => !appParamsDeletedState.includes(param.name));
   379                                  input.spec.source.plugin.parameters = params;
   380                              }
   381                              if (input.spec.source && input.spec.source.helm?.valuesObject) {
   382                                  input.spec.source.helm.valuesObject = jsYaml.load(input.spec.source.helm.values); // Deserialize json
   383                                  input.spec.source.helm.values = '';
   384                              }
   385                              await props.save(input, {});
   386                              setRemovedOverrides(new Array<boolean>());
   387                          })
   388                      }
   389                      values={((repoAppDetails?.plugin || app?.spec?.source?.plugin) && cloneDeep(app)) || app}
   390                      validate={updatedApp => {
   391                          const errors = {} as any;
   392  
   393                          for (const fieldPath of ['spec.source.directory.jsonnet.tlas', 'spec.source.directory.jsonnet.extVars']) {
   394                              const invalid = ((getNestedField(updatedApp, fieldPath) || []) as Array<models.JsonnetVar>).filter(item => !item.name && !item.code);
   395                              errors[fieldPath] = invalid.length > 0 ? 'All fields must have name' : null;
   396                          }
   397  
   398                          if (updatedApp.spec.source && updatedApp.spec.source.helm?.values) {
   399                              const parsedValues = jsYaml.load(updatedApp.spec.source.helm.values);
   400                              errors['spec.source.helm.values'] = typeof parsedValues === 'object' ? null : 'Values must be a map';
   401                          }
   402  
   403                          return errors;
   404                      }}
   405                      onModeSwitch={
   406                          repoAppDetails?.plugin &&
   407                          (() => {
   408                              setAppParamsDeletedState([]);
   409                          })
   410                      }
   411                      title={repoAppDetails?.type?.toLocaleUpperCase()}
   412                      items={items as EditablePanelItem[]}
   413                      noReadonlyMode={props.noReadonlyMode}
   414                      hasMultipleSources={false}
   415                  />
   416              </div>
   417          );
   418      }
   419  
   420      function getEditablePanelForOneSource(repoAppDetails: models.RepoAppDetails, ind: number, src: models.ApplicationSource): any {
   421          let floatingTitle: string;
   422          const lowerPanelAttributes: EditablePanelItem[] = [];
   423          const upperPanelAttributes: EditablePanelItem[] = [];
   424  
   425          const upperPanel = gatherCoreSourceDetails(ind, upperPanelAttributes, appSources[ind], app);
   426          const lowerPanel = gatherDetails(
   427              ind,
   428              repoAppDetails,
   429              lowerPanelAttributes,
   430              appSources[ind],
   431              app,
   432              setRemovedOverrides,
   433              removedOverrides,
   434              appParamsDeletedState,
   435              setAppParamsDeletedState,
   436              true
   437          );
   438  
   439          if (repoAppDetails.type === 'Directory') {
   440              floatingTitle =
   441                  'Source ' +
   442                  (ind + 1) +
   443                  ': TYPE=' +
   444                  repoAppDetails.type +
   445                  ', URL=' +
   446                  src.repoURL +
   447                  (repoAppDetails.path ? ', PATH=' + repoAppDetails.path : '') +
   448                  (src.targetRevision ? ', TARGET REVISION=' + src.targetRevision : '');
   449          } else if (repoAppDetails.type === 'Helm') {
   450              floatingTitle =
   451                  'Source ' +
   452                  (ind + 1) +
   453                  ': TYPE=' +
   454                  repoAppDetails.type +
   455                  ', URL=' +
   456                  src.repoURL +
   457                  (src.chart ? ', CHART=' + src.chart + ':' + src.targetRevision : '') +
   458                  (src.path ? ', PATH=' + src.path : '') +
   459                  (src.targetRevision ? ', REVISION=' + src.targetRevision : '');
   460          } else if (repoAppDetails.type === 'Kustomize') {
   461              floatingTitle =
   462                  'Source ' +
   463                  (ind + 1) +
   464                  ': TYPE=' +
   465                  repoAppDetails.type +
   466                  ', URL=' +
   467                  src.repoURL +
   468                  (repoAppDetails.path ? ', PATH=' + repoAppDetails.path : '') +
   469                  (src.targetRevision ? ', TARGET REVISION=' + src.targetRevision : '');
   470          } else if (repoAppDetails.type === 'Plugin') {
   471              floatingTitle =
   472                  'Source ' +
   473                  (ind + 1) +
   474                  ': TYPE=' +
   475                  repoAppDetails.type +
   476                  ', URL=' +
   477                  src.repoURL +
   478                  (repoAppDetails.path ? ', PATH=' + repoAppDetails.path : '') +
   479                  (src.targetRevision ? ', TARGET REVISION=' + src.targetRevision : '');
   480          }
   481          return (
   482              <ApplicationParametersSource
   483                  index={ind}
   484                  saveTop={props.save}
   485                  saveBottom={
   486                      props.save &&
   487                      (async (input: models.Application) => {
   488                          const appSrc = input.spec.sources[ind];
   489  
   490                          function isDefined(item: any) {
   491                              return item !== null && item !== undefined;
   492                          }
   493                          function isDefinedWithVersion(item: any) {
   494                              return item !== null && item !== undefined && item.match(/:/);
   495                          }
   496  
   497                          if (appSrc.helm && appSrc.helm.parameters) {
   498                              appSrc.helm.parameters = appSrc.helm.parameters.filter(isDefined);
   499                          }
   500                          if (appSrc.kustomize && appSrc.kustomize.images) {
   501                              appSrc.kustomize.images = appSrc.kustomize.images.filter(isDefinedWithVersion);
   502                          }
   503  
   504                          let params = input.spec?.sources[ind]?.plugin?.parameters;
   505                          if (params) {
   506                              for (const param of params) {
   507                                  if (param.map && param.array) {
   508                                      // eslint-disable-next-line @typescript-eslint/ban-ts-comment
   509                                      // @ts-ignore
   510                                      param.map = param.array.reduce((acc, {name, value}) => {
   511                                          // eslint-disable-next-line @typescript-eslint/ban-ts-comment
   512                                          // @ts-ignore
   513                                          acc[name] = value;
   514                                          return acc;
   515                                      }, {});
   516                                      delete param.array;
   517                                  }
   518                              }
   519  
   520                              params = params.filter(param => !appParamsDeletedState.includes(param.name));
   521                              appSrc.plugin.parameters = params;
   522                          }
   523                          if (appSrc.helm && appSrc.helm.valuesObject) {
   524                              appSrc.helm.valuesObject = jsYaml.load(appSrc.helm.values); // Deserialize json
   525                              appSrc.helm.values = '';
   526                          }
   527  
   528                          await props.save(input, {});
   529                          setRemovedOverrides(new Array<boolean>());
   530                      })
   531                  }
   532                  valuesTop={(app?.spec?.sources && (repoAppDetails.plugin || app?.spec?.sources[ind]?.plugin) && cloneDeep(app)) || app}
   533                  valuesBottom={(app?.spec?.sources && (repoAppDetails.plugin || app?.spec?.sources[ind]?.plugin) && cloneDeep(app)) || app}
   534                  validateTop={updatedApp => {
   535                      const errors = [] as any;
   536                      const repoURL = updatedApp.spec.sources[ind].repoURL;
   537                      if (repoURL === null || repoURL.length === 0) {
   538                          errors['spec.sources[' + ind + '].repoURL'] = 'The source repo URL cannot be empty';
   539                      } else {
   540                          errors['spec.sources[' + ind + '].repoURL'] = null;
   541                      }
   542                      return errors;
   543                  }}
   544                  validateBottom={updatedApp => {
   545                      const errors = {} as any;
   546  
   547                      for (const fieldPath of ['spec.sources[' + ind + '].directory.jsonnet.tlas', 'spec.sources[' + ind + '].directory.jsonnet.extVars']) {
   548                          const invalid = ((getNestedField(updatedApp, fieldPath) || []) as Array<models.JsonnetVar>).filter(item => !item.name && !item.code);
   549                          errors[fieldPath] = invalid.length > 0 ? 'All fields must have name' : null;
   550                      }
   551  
   552                      if (updatedApp.spec.sources[ind].helm?.values) {
   553                          const parsedValues = jsYaml.load(updatedApp.spec.sources[ind].helm.values);
   554                          errors['spec.sources[' + ind + '].helm.values'] = typeof parsedValues === 'object' ? null : 'Values must be a map';
   555                      }
   556  
   557                      return errors;
   558                  }}
   559                  onModeSwitch={
   560                      repoAppDetails.plugin &&
   561                      (() => {
   562                          setAppParamsDeletedState([]);
   563                      })
   564                  }
   565                  titleBottom={repoAppDetails.type.toLocaleUpperCase()}
   566                  titleTop={'SOURCE ' + (ind + 1)}
   567                  floatingTitle={floatingTitle ? floatingTitle : null}
   568                  itemsBottom={lowerPanel as EditablePanelItem[]}
   569                  itemsTop={upperPanel as EditablePanelItem[]}
   570                  noReadonlyMode={props.noReadonlyMode}
   571                  collapsible={collapsible}
   572                  numberOfSources={app?.spec?.sources.length}
   573                  deleteSource={() => {
   574                      deleteSourceAction(app, app.spec.sources.at(ind), props.appContext);
   575                  }}
   576              />
   577          );
   578      }
   579  };
   580  
   581  function gatherCoreSourceDetails(i: number, attributes: EditablePanelItem[], source: models.ApplicationSource, app: models.Application): EditablePanelItem[] {
   582      const hasMultipleSources = app.spec.sources && app.spec.sources.length > 0;
   583      // eslint-disable-next-line no-prototype-builtins
   584      const isHelm = source.hasOwnProperty('chart');
   585      const repoUrlField = 'spec.sources[' + i + '].repoURL';
   586      const sourcesPathField = 'spec.sources[' + i + '].path';
   587      const refField = 'spec.sources[' + i + '].ref';
   588      const nameField = 'spec.sources[' + i + '].name';
   589      const chartField = 'spec.sources[' + i + '].chart';
   590      const revisionField = 'spec.sources[' + i + '].targetRevision';
   591      // For single source apps using the source field, these fields are shown in the Summary tab.
   592      if (hasMultipleSources) {
   593          attributes.push({
   594              title: 'REPO URL',
   595              view: <Repo url={source.repoURL} />,
   596              edit: (formApi: FormApi) => <FormField formApi={formApi} field={repoUrlField} component={Text} />
   597          });
   598          attributes.push({
   599              title: 'NAME',
   600              view: <span>{source?.name}</span>,
   601              edit: (formApi: FormApi) => <FormField formApi={formApi} field={nameField} component={Text} />
   602          });
   603          if (isHelm) {
   604              attributes.push({
   605                  title: 'CHART',
   606                  view: (
   607                      <span>
   608                          {source.chart}:{source.targetRevision}
   609                      </span>
   610                  ),
   611                  edit: (formApi: FormApi) => (
   612                      <DataLoader input={{repoURL: source.repoURL}} load={src => services.repos.charts(src.repoURL).catch(() => new Array<models.HelmChart>())}>
   613                          {(charts: models.HelmChart[]) => (
   614                              <div className='row'>
   615                                  <div className='columns small-8'>
   616                                      <FormField
   617                                          formApi={formApi}
   618                                          field={chartField}
   619                                          component={AutocompleteField}
   620                                          componentProps={{
   621                                              items: charts.map(chart => chart.name),
   622                                              filterSuggestions: true
   623                                          }}
   624                                      />
   625                                  </div>
   626                                  <DataLoader
   627                                      input={{charts, chart: source.chart}}
   628                                      load={async data => {
   629                                          const chartInfo = data.charts.find(chart => chart.name === data.chart);
   630                                          return (chartInfo && chartInfo.versions) || new Array<string>();
   631                                      }}>
   632                                      {(versions: string[]) => (
   633                                          <div className='columns small-4'>
   634                                              <FormField
   635                                                  formApi={formApi}
   636                                                  field={revisionField}
   637                                                  component={AutocompleteField}
   638                                                  componentProps={{
   639                                                      items: versions
   640                                                  }}
   641                                              />
   642                                              <RevisionHelpIcon type='helm' top='0' />
   643                                          </div>
   644                                      )}
   645                                  </DataLoader>
   646                              </div>
   647                          )}
   648                      </DataLoader>
   649                  )
   650              });
   651          } else {
   652              const targetRevision = source ? source.targetRevision || 'HEAD' : 'Unknown';
   653              attributes.push({
   654                  title: 'TARGET REVISION',
   655                  view: <Revision repoUrl={source?.repoURL} revision={targetRevision} />,
   656                  edit: (formApi: FormApi) => <RevisionFormField helpIconTop={'0'} hideLabel={true} formApi={formApi} repoURL={source?.repoURL} fieldValue={revisionField} />
   657              });
   658              attributes.push({
   659                  title: 'PATH',
   660                  view: (
   661                      <Revision repoUrl={source?.repoURL} revision={targetRevision} path={source?.path} isForPath={true}>
   662                          {processPath(source?.path)}
   663                      </Revision>
   664                  ),
   665                  edit: (formApi: FormApi) => <FormField formApi={formApi} field={sourcesPathField} component={Text} />
   666              });
   667              attributes.push({
   668                  title: 'REF',
   669                  view: <span>{source?.ref}</span>,
   670                  edit: (formApi: FormApi) => <FormField formApi={formApi} field={refField} component={Text} />
   671              });
   672          }
   673      }
   674      return attributes;
   675  }
   676  
   677  function gatherDetails(
   678      ind: number,
   679      repoDetails: models.RepoAppDetails,
   680      attributes: EditablePanelItem[],
   681      source: models.ApplicationSource,
   682      app: models.Application,
   683      setRemovedOverrides: any,
   684      removedOverrides: any,
   685      appParamsDeletedState: any[],
   686      setAppParamsDeletedState: any,
   687      isMultiSource: boolean
   688  ): EditablePanelItem[] {
   689      if (repoDetails.type === 'Kustomize' && repoDetails.kustomize) {
   690          attributes.push({
   691              title: 'VERSION',
   692              view: (source.kustomize && source.kustomize.version) || <span>default</span>,
   693              edit: (formApi: FormApi) => (
   694                  <DataLoader load={() => services.authService.settings()}>
   695                      {settings =>
   696                          ((settings.kustomizeVersions || []).length > 0 && (
   697                              <FormField
   698                                  formApi={formApi}
   699                                  field={isMultiSource ? 'spec.sources[' + ind + '].kustomize.version' : 'spec.source.kustomize.version'}
   700                                  component={AutocompleteField}
   701                                  componentProps={{items: settings.kustomizeVersions}}
   702                              />
   703                          )) || <span>default</span>
   704                      }
   705                  </DataLoader>
   706              )
   707          });
   708  
   709          attributes.push({
   710              title: 'NAME PREFIX',
   711              view: source.kustomize && source.kustomize.namePrefix,
   712              edit: (formApi: FormApi) => (
   713                  <FormField formApi={formApi} field={isMultiSource ? 'spec.sources[' + ind + '].kustomize.namePrefix' : 'spec.source.kustomize.namePrefix'} component={Text} />
   714              )
   715          });
   716  
   717          attributes.push({
   718              title: 'NAME SUFFIX',
   719              view: source.kustomize && source.kustomize.nameSuffix,
   720              edit: (formApi: FormApi) => (
   721                  <FormField formApi={formApi} field={isMultiSource ? 'spec.sources[' + ind + '].kustomize.nameSuffix' : 'spec.source.kustomize.nameSuffix'} component={Text} />
   722              )
   723          });
   724  
   725          attributes.push({
   726              title: 'NAMESPACE',
   727              view: source.kustomize && source.kustomize.namespace,
   728              edit: (formApi: FormApi) => (
   729                  <FormField formApi={formApi} field={isMultiSource ? 'spec.sources[' + ind + '].kustomize.namespace' : 'spec.source.kustomize.namespace'} component={Text} />
   730              )
   731          });
   732  
   733          const srcImages = ((repoDetails && repoDetails.kustomize && repoDetails.kustomize.images) || []).map(val => kustomize.parse(val));
   734          const images = ((source.kustomize && source.kustomize.images) || []).map(val => kustomize.parse(val));
   735  
   736          if (srcImages.length > 0) {
   737              const imagesByName = new Map<string, kustomize.Image>();
   738              srcImages.forEach(img => imagesByName.set(img.name, img));
   739  
   740              const overridesByName = new Map<string, number>();
   741              images.forEach((override, i) => overridesByName.set(override.name, i));
   742  
   743              attributes = attributes.concat(
   744                  getParamsEditableItems(
   745                      app,
   746                      'IMAGES',
   747                      isMultiSource ? 'spec.sources[' + ind + '].kustomize.images' : 'spec.source.kustomize.images',
   748                      removedOverrides,
   749                      setRemovedOverrides,
   750                      distinct(imagesByName.keys(), overridesByName.keys()).map(name => {
   751                          const param = imagesByName.get(name);
   752                          const original = param && kustomize.format(param);
   753                          let overrideIndex = overridesByName.get(name);
   754                          if (overrideIndex === undefined) {
   755                              overrideIndex = -1;
   756                          }
   757                          const value = (overrideIndex > -1 && kustomize.format(images[overrideIndex])) || original;
   758                          return {overrideIndex, original, metadata: {name, value}};
   759                      }),
   760                      ImageTagFieldEditor
   761                  )
   762              );
   763          }
   764      } else if (repoDetails.type === 'Helm' && repoDetails.helm) {
   765          const isValuesObject = source?.helm?.valuesObject;
   766          const helmValues = isValuesObject ? jsYaml.dump(source.helm.valuesObject) : source?.helm?.values;
   767          attributes.push({
   768              title: 'VALUES FILES',
   769              view: (source.helm && (source.helm.valueFiles || []).join(', ')) || 'No values files selected',
   770              edit: (formApi: FormApi) => (
   771                  <FormField
   772                      formApi={formApi}
   773                      field={isMultiSource ? 'spec.sources[' + ind + '].helm.valueFiles' : 'spec.source.helm.valueFiles'}
   774                      component={TagsInputField}
   775                      componentProps={{
   776                          options: repoDetails.helm.valueFiles,
   777                          noTagsLabel: 'No values files selected'
   778                      }}
   779                  />
   780              )
   781          });
   782          attributes.push({
   783              title: 'VALUES',
   784              view: source.helm && (
   785                  <Expandable>
   786                      <pre>{helmValues}</pre>
   787                  </Expandable>
   788              ),
   789              edit: (formApi: FormApi) => {
   790                  // In case source.helm.valuesObject is set, set source.helm.values to its value
   791                  if (source.helm) {
   792                      source.helm.values = helmValues;
   793                  }
   794  
   795                  return (
   796                      <div>
   797                          <pre>
   798                              <FormField formApi={formApi} field={isMultiSource ? 'spec.sources[' + ind + '].helm.values' : 'spec.source.helm.values'} component={TextArea} />
   799                          </pre>
   800                      </div>
   801                  );
   802              }
   803          });
   804          const paramsByName = new Map<string, models.HelmParameter>();
   805          (repoDetails.helm.parameters || []).forEach(param => paramsByName.set(param.name, param));
   806          const overridesByName = new Map<string, number>();
   807          ((source.helm && source.helm.parameters) || []).forEach((override, i) => overridesByName.set(override.name, i));
   808          attributes = attributes.concat(
   809              getParamsEditableItems(
   810                  app,
   811                  'PARAMETERS',
   812                  isMultiSource ? 'spec.sources[' + ind + '].helm.parameters' : 'spec.source.helm.parameters',
   813                  removedOverrides,
   814                  setRemovedOverrides,
   815                  distinct(paramsByName.keys(), overridesByName.keys()).map(name => {
   816                      const param = paramsByName.get(name);
   817                      const original = (param && param.value) || '';
   818                      let overrideIndex = overridesByName.get(name);
   819                      if (overrideIndex === undefined) {
   820                          overrideIndex = -1;
   821                      }
   822                      const value = (overrideIndex > -1 && source.helm.parameters[overrideIndex].value) || original;
   823                      return {overrideIndex, original, metadata: {name, value}};
   824                  })
   825              )
   826          );
   827          const fileParamsByName = new Map<string, models.HelmFileParameter>();
   828          (repoDetails.helm.fileParameters || []).forEach(param => fileParamsByName.set(param.name, param));
   829          const fileOverridesByName = new Map<string, number>();
   830          ((source.helm && source.helm.fileParameters) || []).forEach((override, i) => fileOverridesByName.set(override.name, i));
   831          attributes = attributes.concat(
   832              getParamsEditableItems(
   833                  app,
   834                  'PARAMETERS',
   835                  isMultiSource ? 'spec.sources[' + ind + '].helm.parameters' : 'spec.source.helm.parameters',
   836                  removedOverrides,
   837                  setRemovedOverrides,
   838                  distinct(fileParamsByName.keys(), fileOverridesByName.keys()).map(name => {
   839                      const param = fileParamsByName.get(name);
   840                      const original = (param && param.path) || '';
   841                      let overrideIndex = fileOverridesByName.get(name);
   842                      if (overrideIndex === undefined) {
   843                          overrideIndex = -1;
   844                      }
   845                      const value = (overrideIndex > -1 && source.helm.fileParameters[overrideIndex].path) || original;
   846                      return {overrideIndex, original, metadata: {name, value}};
   847                  })
   848              )
   849          );
   850      } else if (repoDetails.type === 'Plugin') {
   851          attributes.push({
   852              title: 'NAME',
   853              view: <div style={{marginTop: 15, marginBottom: 5}}>{ValueEditor(app.spec.source?.plugin?.name, null)}</div>,
   854              edit: (formApi: FormApi) => (
   855                  <DataLoader load={() => services.authService.plugins()}>
   856                      {(plugins: Plugin[]) => (
   857                          <FormField
   858                              formApi={formApi}
   859                              field={isMultiSource ? 'spec.sources[' + ind + '].plugin.name' : 'spec.source.plugin.name'}
   860                              component={FormSelect}
   861                              componentProps={{options: plugins.map(p => p.name)}}
   862                          />
   863                      )}
   864                  </DataLoader>
   865              )
   866          });
   867          attributes.push({
   868              title: 'ENV',
   869              view: (
   870                  <div style={{marginTop: 15}}>
   871                      {(app.spec.source?.plugin?.env || []).map(val => (
   872                          <span key={val.name} style={{display: 'block', marginBottom: 5}}>
   873                              {NameValueEditor(val, null)}
   874                          </span>
   875                      ))}
   876                  </div>
   877              ),
   878              edit: (formApi: FormApi) => (
   879                  <FormField field={isMultiSource ? 'spec.sources[' + ind + '].plugin.env' : 'spec.source.plugin.env'} formApi={formApi} component={ArrayInputField} />
   880              )
   881          });
   882          const parametersSet = new Set<string>();
   883          if (repoDetails?.plugin?.parametersAnnouncement) {
   884              for (const announcement of repoDetails.plugin.parametersAnnouncement) {
   885                  parametersSet.add(announcement.name);
   886              }
   887          }
   888          if (app.spec.source?.plugin?.parameters) {
   889              for (const appParameter of app.spec.source.plugin.parameters) {
   890                  parametersSet.add(appParameter.name);
   891              }
   892          }
   893  
   894          for (const key of appParamsDeletedState) {
   895              parametersSet.delete(key);
   896          }
   897          parametersSet.forEach(name => {
   898              const announcement = repoDetails.plugin.parametersAnnouncement?.find(param => param.name === name);
   899              const liveParam = app.spec.source?.plugin?.parameters?.find(param => param.name === name);
   900              const pluginIcon =
   901                  announcement && liveParam ? 'This parameter has been provided by plugin, but is overridden in application manifest.' : 'This parameter is provided by the plugin.';
   902              const isPluginPar = !!announcement;
   903              if ((announcement?.collectionType === undefined && liveParam?.map) || announcement?.collectionType === 'map') {
   904                  let liveParamMap;
   905                  if (liveParam) {
   906                      liveParamMap = liveParam.map ?? new Map<string, string>();
   907                  }
   908                  const map = concatMaps(liveParamMap ?? announcement?.map, new Map<string, string>());
   909                  const entries = map.entries();
   910                  const items = new Array<NameValue>();
   911                  Array.from(entries).forEach(([key, value]) => items.push({name: key, value: `${value}`}));
   912                  attributes.push({
   913                      title: announcement?.title ?? announcement?.name ?? name,
   914                      customTitle: (
   915                          <span>
   916                              {isPluginPar && <i className='fa solid fa-puzzle-piece' title={pluginIcon} style={{marginRight: 5}} />}
   917                              {announcement?.title ?? announcement?.name ?? name}
   918                          </span>
   919                      ),
   920                      view: (
   921                          <div style={{marginTop: 15, marginBottom: 5}}>
   922                              {items.length === 0 && <span style={{color: 'dimgray'}}>-- NO ITEMS --</span>}
   923                              {items.map(val => (
   924                                  <span key={val.name} style={{display: 'block', marginBottom: 5}}>
   925                                      {NameValueEditor(val)}
   926                                  </span>
   927                              ))}
   928                          </div>
   929                      ),
   930                      edit: (formApi: FormApi) => (
   931                          <FormField
   932                              field={isMultiSource ? 'spec.sources[' + ind + '].plugin.parameters' : 'spec.source.plugin.parameters'}
   933                              componentProps={{
   934                                  name: announcement?.name ?? name,
   935                                  defaultVal: announcement?.map,
   936                                  isPluginPar,
   937                                  setAppParamsDeletedState
   938                              }}
   939                              formApi={formApi}
   940                              component={MapValueField}
   941                          />
   942                      )
   943                  });
   944              } else if ((announcement?.collectionType === undefined && liveParam?.array) || announcement?.collectionType === 'array') {
   945                  let liveParamArray;
   946                  if (liveParam) {
   947                      liveParamArray = liveParam?.array ?? [];
   948                  }
   949                  attributes.push({
   950                      title: announcement?.title ?? announcement?.name ?? name,
   951                      customTitle: (
   952                          <span>
   953                              {isPluginPar && <i className='fa-solid fa-puzzle-piece' title={pluginIcon} style={{marginRight: 5}} />}
   954                              {announcement?.title ?? announcement?.name ?? name}
   955                          </span>
   956                      ),
   957                      view: (
   958                          <div style={{marginTop: 15, marginBottom: 5}}>
   959                              {(liveParamArray ?? announcement?.array ?? []).length === 0 && <span style={{color: 'dimgray'}}>-- NO ITEMS --</span>}
   960                              {(liveParamArray ?? announcement?.array ?? []).map((val, index) => (
   961                                  <span key={index} style={{display: 'block', marginBottom: 5}}>
   962                                      {ValueEditor(val, null)}
   963                                  </span>
   964                              ))}
   965                          </div>
   966                      ),
   967                      edit: (formApi: FormApi) => (
   968                          <FormField
   969                              field={isMultiSource ? 'spec.sources[' + ind + '].plugin.parameters' : 'spec.source.plugin.parameters'}
   970                              componentProps={{
   971                                  name: announcement?.name ?? name,
   972                                  defaultVal: announcement?.array,
   973                                  isPluginPar,
   974                                  setAppParamsDeletedState
   975                              }}
   976                              formApi={formApi}
   977                              component={ArrayValueField}
   978                          />
   979                      )
   980                  });
   981              } else if (
   982                  (announcement?.collectionType === undefined && liveParam?.string) ||
   983                  announcement?.collectionType === '' ||
   984                  announcement?.collectionType === 'string' ||
   985                  announcement?.collectionType === undefined
   986              ) {
   987                  let liveParamString;
   988                  if (liveParam) {
   989                      liveParamString = liveParam?.string ?? '';
   990                  }
   991                  attributes.push({
   992                      title: announcement?.title ?? announcement?.name ?? name,
   993                      customTitle: (
   994                          <span>
   995                              {isPluginPar && <i className='fa-solid fa-puzzle-piece' title={pluginIcon} style={{marginRight: 5}} />}
   996                              {announcement?.title ?? announcement?.name ?? name}
   997                          </span>
   998                      ),
   999                      view: (
  1000                          <div
  1001                              style={{
  1002                                  marginTop: 15,
  1003                                  marginBottom: 5
  1004                              }}>
  1005                              {ValueEditor(liveParamString ?? announcement?.string, null)}
  1006                          </div>
  1007                      ),
  1008                      edit: (formApi: FormApi) => (
  1009                          <FormField
  1010                              field={isMultiSource ? 'spec.sources[' + ind + '].plugin.parameters' : 'spec.source.plugin.parameters'}
  1011                              componentProps={{
  1012                                  name: announcement?.name ?? name,
  1013                                  defaultVal: announcement?.string,
  1014                                  isPluginPar,
  1015                                  setAppParamsDeletedState
  1016                              }}
  1017                              formApi={formApi}
  1018                              component={StringValueField}
  1019                          />
  1020                      )
  1021                  });
  1022              }
  1023          });
  1024      } else if (repoDetails.type === 'Directory') {
  1025          const directory = source.directory || ({} as ApplicationSourceDirectory);
  1026          const fieldValue = isMultiSource ? 'spec.sources[' + ind + '].directory.recurse' : 'spec.source.directory.recurse';
  1027          attributes.push({
  1028              title: 'DIRECTORY RECURSE',
  1029              view: (!!directory.recurse).toString(),
  1030              edit: (formApi: FormApi) => <FormField formApi={formApi} field={fieldValue} component={CheckboxField} />
  1031          });
  1032          attributes.push({
  1033              title: 'TOP-LEVEL ARGUMENTS',
  1034              view: ((directory?.jsonnet && directory?.jsonnet.tlas) || []).map((i, j) => (
  1035                  <label key={j}>
  1036                      {i.name}='{i.value}' {i.code && 'code'}
  1037                  </label>
  1038              )),
  1039              edit: (formApi: FormApi) => (
  1040                  <FormField
  1041                      field={isMultiSource ? 'spec.sources[' + ind + '].directory.jsonnet.tlas' : 'spec.source.directory.jsonnet.tlas'}
  1042                      formApi={formApi}
  1043                      component={VarsInputField}
  1044                  />
  1045              )
  1046          });
  1047          attributes.push({
  1048              title: 'EXTERNAL VARIABLES',
  1049              view: ((directory.jsonnet && directory.jsonnet.extVars) || []).map((i, j) => (
  1050                  <label key={j}>
  1051                      {i.name}='{i.value}' {i.code && 'code'}
  1052                  </label>
  1053              )),
  1054              edit: (formApi: FormApi) => (
  1055                  <FormField
  1056                      field={isMultiSource ? 'spec.sources[' + ind + '].directory.jsonnet.extVars' : 'spec.source.directory.jsonnet.extVars'}
  1057                      formApi={formApi}
  1058                      component={VarsInputField}
  1059                  />
  1060              )
  1061          });
  1062  
  1063          attributes.push({
  1064              title: 'INCLUDE',
  1065              view: directory && directory.include,
  1066              edit: (formApi: FormApi) => (
  1067                  <FormField formApi={formApi} field={isMultiSource ? 'spec.sources[' + ind + '].directory.include' : 'spec.source.directory.include'} component={Text} />
  1068              )
  1069          });
  1070  
  1071          attributes.push({
  1072              title: 'EXCLUDE',
  1073              view: directory && directory.exclude,
  1074              edit: (formApi: FormApi) => (
  1075                  <FormField formApi={formApi} field={isMultiSource ? 'spec.sources[' + ind + '].directory.exclude' : 'spec.source.directory.exclude'} component={Text} />
  1076              )
  1077          });
  1078      }
  1079      return attributes;
  1080  }
  1081  
  1082  // For Sources field. Get one source with index i from the list
  1083  async function getSourceFromAppSources(aSource: models.ApplicationSource, name: string, project: string, index: number, version: number) {
  1084      const repoDetail = await services.repos.appDetails(aSource, name, project, index, version).catch(() => ({
  1085          type: 'Directory' as models.AppSourceType,
  1086          path: aSource.path
  1087      }));
  1088      return repoDetail;
  1089  }
  1090  
  1091  // Delete when source field is removed
  1092  async function getSingleSource(app: models.Application) {
  1093      if (app.spec.source || app.spec.sourceHydrator) {
  1094          const repoDetail = await services.repos.appDetails(getAppDefaultSource(app), app.metadata.name, app.spec.project, 0, 0).catch(() => ({
  1095              type: 'Directory' as models.AppSourceType,
  1096              path: getAppDefaultSource(app).path
  1097          }));
  1098          return repoDetail;
  1099      }
  1100      return null;
  1101  }