feat(ui): enable hydrator support in app create panel (#26485)

Signed-off-by: Jonathan Winters <wintersjonathan0@gmail.com>
Co-authored-by: Alexandre Gaudreault <alexandre_gaudreault@intuit.com>
This commit is contained in:
jwinters01 2026-02-25 12:54:43 -08:00 committed by GitHub
parent 8c1a815b78
commit ff45972ec8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 393 additions and 187 deletions

View file

@ -3,14 +3,16 @@ import {AutocompleteField, Checkbox, DataLoader, DropDownMenu, FormField, HelpIc
import * as deepMerge from 'deepmerge';
import * as React from 'react';
import {FieldApi, Form, FormApi, FormField as ReactFormField, Text} from 'react-form';
import {RevisionHelpIcon, YamlEditor} from '../../../shared/components';
import {YamlEditor} from '../../../shared/components';
import * as models from '../../../shared/models';
import {services} from '../../../shared/services';
import {AuthSettingsCtx} from '../../../shared/context';
import {ApplicationParameters} from '../application-parameters/application-parameters';
import {ApplicationRetryOptions} from '../application-retry-options/application-retry-options';
import {ApplicationSyncOptionsField} from '../application-sync-options/application-sync-options';
import {RevisionFormField} from '../revision-form-field/revision-form-field';
import {SetFinalizerOnApplication} from './set-finalizer-on-application';
import {HydratorSourcePanel} from './hydrator-source-panel';
import {SourcePanel} from './source-panel';
import './application-create-panel.scss';
import {getAppDefaultSource} from '../utils';
import {debounce} from 'lodash-es';
@ -120,7 +122,10 @@ export const ApplicationCreatePanel = (props: {
const currentRepoType = React.useRef(undefined);
const lastGitOrHelmUrl = React.useRef('');
const lastOciUrl = React.useRef('');
const [isHydratorEnabled, setIsHydratorEnabled] = React.useState(!!app.spec.sourceHydrator);
const [savedSyncSource, setSavedSyncSource] = React.useState(app.spec.sourceHydrator?.syncSource || {targetBranch: '', path: ''});
let destinationComboValue = destinationFieldChanges.destFormat;
const authSettingsCtx = React.useContext(AuthSettingsCtx);
React.useEffect(() => {
comboSwitchedFromPanel.current = false;
@ -184,6 +189,10 @@ export const ApplicationCreatePanel = (props: {
delete data.spec.destination.server;
}
if (data.spec.sourceHydrator && !data.spec.sourceHydrator.hydrateTo?.targetBranch) {
delete data.spec.sourceHydrator.hydrateTo;
}
props.createApp(data);
};
@ -219,20 +228,29 @@ export const ApplicationCreatePanel = (props: {
/>
)) || (
<Form
validateError={(a: models.Application) => ({
'metadata.name': !a.metadata.name && 'Application Name is required',
'spec.project': !a.spec.project && 'Project Name is required',
'spec.source.repoURL': !a.spec.source.repoURL && 'Repository URL is required',
'spec.source.targetRevision': !a.spec.source.targetRevision && a.spec.source.hasOwnProperty('chart') && 'Version is required',
'spec.source.path': !a.spec.source.path && !a.spec.source.chart && 'Path is required',
'spec.source.chart': !a.spec.source.path && !a.spec.source.chart && 'Chart is required',
// Verify cluster URL when there is no cluster name field or the name value is empty
'spec.destination.server':
!a.spec.destination.server && (!a.spec.destination.hasOwnProperty('name') || a.spec.destination.name === '') && 'Cluster URL is required',
// Verify cluster name when there is no cluster URL field or the URL value is empty
'spec.destination.name':
!a.spec.destination.name && (!a.spec.destination.hasOwnProperty('server') || a.spec.destination.server === '') && 'Cluster name is required'
})}
validateError={(a: models.Application) => {
const hasHydrator = !!a.spec.sourceHydrator;
const source = a.spec.source;
return {
'metadata.name': !a.metadata.name && 'Application Name is required',
'spec.project': !a.spec.project && 'Project Name is required',
'spec.source.repoURL': !hasHydrator && !source?.repoURL && 'Repository URL is required',
'spec.source.targetRevision': !hasHydrator && !source?.targetRevision && source?.hasOwnProperty('chart') && 'Version is required',
'spec.source.path': !hasHydrator && !source?.path && !source?.chart && 'Path is required',
'spec.source.chart': !hasHydrator && !source?.path && !source?.chart && 'Chart is required',
// Verify cluster URL when there is no cluster name field or the name value is empty
'spec.destination.server':
!a.spec.destination.server &&
(!a.spec.destination.hasOwnProperty('name') || a.spec.destination.name === '') &&
'Cluster URL is required',
// Verify cluster name when there is no cluster URL field or the URL value is empty
'spec.destination.name':
!a.spec.destination.name &&
(!a.spec.destination.hasOwnProperty('server') || a.spec.destination.server === '') &&
'Cluster name is required'
};
}}
defaultValues={app}
formDidUpdate={state => debouncedOnAppChanged(state.values as any)}
onSubmit={onCreateApp}
@ -298,184 +316,57 @@ export const ApplicationCreatePanel = (props: {
</div>
);
const repoType = api.getFormState().values.spec.source.repoURL.startsWith('oci://')
? 'oci'
: (api.getFormState().values.spec.source.hasOwnProperty('chart') && 'helm') || 'git';
const sourcePanel = () => (
<div className='white-box'>
<p>SOURCE</p>
<div className='row argo-form-row'>
<div className='columns small-10'>
<FormField
formApi={api}
label='Repository URL'
qeId='application-create-field-repository-url'
field='spec.source.repoURL'
component={AutocompleteField}
componentProps={{
items: repos,
filterSuggestions: true
}}
/>
</div>
<div className='columns small-2'>
<div style={{paddingTop: '1.5em'}}>
{(repoInfo && (
<React.Fragment>
<span>{(repoInfo.type || 'git').toUpperCase()}</span> <i className='fa fa-check' />
</React.Fragment>
)) || (
<DropDownMenu
anchor={() => (
<p>
{repoType.toUpperCase()} <i className='fa fa-caret-down' />
</p>
)}
qeId='application-create-dropdown-source-repository'
items={['git', 'helm', 'oci'].map((type: 'git' | 'helm' | 'oci') => ({
title: type.toUpperCase(),
action: () => {
if (repoType !== type) {
const updatedApp = api.getFormState().values as models.Application;
const source = getAppDefaultSource(updatedApp);
// Save the previous URL value for later use
if (repoType === 'git' || repoType === 'helm') {
lastGitOrHelmUrl.current = source.repoURL;
} else {
lastOciUrl.current = source.repoURL;
}
currentRepoType.current = type;
switch (type) {
case 'git':
case 'oci':
if (source.hasOwnProperty('chart')) {
source.path = source.chart;
delete source.chart;
}
source.targetRevision = 'HEAD';
source.repoURL =
type === 'git'
? lastGitOrHelmUrl.current
: lastOciUrl.current === ''
? 'oci://'
: lastOciUrl.current;
break;
case 'helm':
if (source.hasOwnProperty('path')) {
source.chart = source.path;
delete source.path;
}
source.targetRevision = '';
source.repoURL = lastGitOrHelmUrl.current;
break;
}
api.setAllValues(updatedApp);
{/* Only show hydrator checkbox if hydrator is enabled in auth settings */}
{authSettingsCtx?.hydratorEnabled && (
<div className='row argo-form-row'>
<div className='columns small-12'>
<div className='checkbox-container'>
<Checkbox
onChange={(val: boolean) => {
const updatedApp = api.getFormState().values as models.Application;
if (val) {
if (!updatedApp.spec.sourceHydrator) {
updatedApp.spec.sourceHydrator = {
drySource: {
repoURL: updatedApp.spec.source.repoURL,
targetRevision: updatedApp.spec.source.targetRevision,
path: updatedApp.spec.source.path
},
syncSource: savedSyncSource
};
delete updatedApp.spec.source;
}
} else if (updatedApp.spec.sourceHydrator) {
setSavedSyncSource(updatedApp.spec.sourceHydrator.syncSource);
updatedApp.spec.source = updatedApp.spec.sourceHydrator.drySource;
delete updatedApp.spec.sourceHydrator;
}
}))}
api.setAllValues(updatedApp);
setIsHydratorEnabled(val);
}}
checked={!!(api.getFormState().values as models.Application).spec.sourceHydrator}
id='enable-source-hydrator'
/>
)}
<label htmlFor='enable-source-hydrator'>enable source hydrator</label>
</div>
</div>
</div>
</div>
{(repoType === 'oci' && (
<React.Fragment>
<RevisionFormField formApi={api} helpIconTop={'2.5em'} repoURL={app.spec.source.repoURL} repoType={repoType} />
<div className='argo-form-row'>
<DataLoader
input={{repoURL: app.spec.source.repoURL, revision: app.spec.source.targetRevision}}
load={async src =>
src.repoURL &&
// TODO: for autocomplete we need to fetch paths that are used by other apps within the same project making use of the same OCI repo
new Array<string>()
}>
{(paths: string[]) => (
<FormField
formApi={api}
label='Path'
qeId='application-create-field-path'
field='spec.source.path'
component={AutocompleteField}
componentProps={{
items: paths,
filterSuggestions: true
}}
/>
)}
</DataLoader>
</div>
</React.Fragment>
)) ||
(repoType === 'git' && (
<React.Fragment>
<RevisionFormField formApi={api} helpIconTop={'2.5em'} repoURL={app.spec.source.repoURL} repoType={repoType} />
<div className='argo-form-row'>
<DataLoader
input={{repoURL: app.spec.source.repoURL, revision: app.spec.source.targetRevision}}
load={async src =>
(src.repoURL &&
services.repos
.apps(src.repoURL, src.revision, app.metadata.name, app.spec.project)
.then(apps => Array.from(new Set(apps.map(item => item.path))).sort())
.catch(() => new Array<string>())) ||
new Array<string>()
}>
{(apps: string[]) => (
<FormField
formApi={api}
label='Path'
qeId='application-create-field-path'
field='spec.source.path'
component={AutocompleteField}
componentProps={{
items: apps,
filterSuggestions: true
}}
/>
)}
</DataLoader>
</div>
</React.Fragment>
)) || (
<DataLoader
input={{repoURL: app.spec.source.repoURL}}
load={async src =>
(src.repoURL && services.repos.charts(src.repoURL).catch(() => new Array<models.HelmChart>())) ||
new Array<models.HelmChart>()
}>
{(charts: models.HelmChart[]) => {
const selectedChart = charts.find(chart => chart.name === api.getFormState().values.spec.source.chart);
return (
<div className='row argo-form-row'>
<div className='columns small-10'>
<FormField
formApi={api}
label='Chart'
field='spec.source.chart'
component={AutocompleteField}
componentProps={{
items: charts.map(chart => chart.name),
filterSuggestions: true
}}
/>
</div>
<div className='columns small-2'>
<FormField
formApi={api}
field='spec.source.targetRevision'
component={AutocompleteField}
componentProps={{
items: (selectedChart && selectedChart.versions) || [],
filterSuggestions: true
}}
/>
<RevisionHelpIcon type='helm' />
</div>
</div>
);
}}
</DataLoader>
)}
)}
{isHydratorEnabled ? (
<HydratorSourcePanel formApi={api} repos={repos} />
) : (
<SourcePanel
formApi={api}
repos={repos}
repoInfo={repoInfo}
currentRepoType={currentRepoType}
lastGitOrHelmUrl={lastGitOrHelmUrl}
lastOciUrl={lastOciUrl}
/>
)}
</div>
);
const destinationPanel = () => (

View file

@ -0,0 +1,115 @@
import * as React from 'react';
import {FormApi, Text} from 'react-form';
import {AutocompleteField, FormField} from 'argo-ui';
import * as models from '../../../shared/models';
import {RevisionFormField} from '../revision-form-field/revision-form-field';
interface HydratorSourcePanelProps {
formApi: FormApi;
repos: string[];
}
interface LabeledRevisionFieldProps {
formApi: FormApi;
repoURL: string;
fieldValue: string;
repoType?: string;
revisionType?: 'Branches' | 'Tags';
helpIconTop?: string;
compact?: boolean;
}
const LabeledRevisionField = (props: LabeledRevisionFieldProps) => (
<div style={{display: 'flex', width: '100%'}}>
<div style={{flex: 1, minWidth: 0}}>
<RevisionFormField
formApi={props.formApi}
helpIconTop={props.helpIconTop}
repoURL={props.repoURL}
repoType={props.repoType}
fieldValue={props.fieldValue}
revisionType={props.revisionType}
compact={props.compact !== false}
/>
</div>
</div>
);
const subsectionBodyStyle: React.CSSProperties = {
display: 'flex',
flexDirection: 'column',
gap: '2.5rem',
paddingLeft: '0.75rem',
borderLeft: '2px solid #e2e5e9'
};
export const HydratorSourcePanel = (props: HydratorSourcePanelProps) => {
const app = props.formApi.getFormState().values as models.Application;
const drySourceRepoURL = app.spec.sourceHydrator?.drySource?.repoURL || '';
return (
<div style={{display: 'flex', flexDirection: 'column', gap: '1rem'}}>
<div style={{display: 'flex', flexDirection: 'column'}}>
<p style={{marginBottom: 0, fontWeight: 600}}>DRY SOURCE</p>
<div style={subsectionBodyStyle}>
<div style={{display: 'flex', width: '100%'}}>
<div style={{flex: 1, minWidth: 0}}>
<FormField
formApi={props.formApi}
label='Repository URL'
field='spec.sourceHydrator.drySource.repoURL'
component={AutocompleteField}
componentProps={{
items: props.repos,
filterSuggestions: true
}}
/>
</div>
</div>
<LabeledRevisionField
formApi={props.formApi}
repoURL={drySourceRepoURL}
repoType='git'
fieldValue='spec.sourceHydrator.drySource.targetRevision'
helpIconTop='2.5em'
/>
<div style={{display: 'flex', width: '100%'}}>
<div style={{flex: 1, minWidth: 0}}>
<FormField formApi={props.formApi} label='Path' field='spec.sourceHydrator.drySource.path' component={Text} />
</div>
</div>
</div>
</div>
<div style={{display: 'flex', flexDirection: 'column'}}>
<p style={{marginBottom: 0, fontWeight: 600}}>SYNC SOURCE</p>
<div style={subsectionBodyStyle}>
<LabeledRevisionField
formApi={props.formApi}
repoURL={drySourceRepoURL}
repoType='git'
fieldValue='spec.sourceHydrator.syncSource.targetBranch'
revisionType='Branches'
/>
<div style={{display: 'flex', width: '100%'}}>
<div style={{flex: 1, minWidth: 0}}>
<FormField formApi={props.formApi} label='Path' field='spec.sourceHydrator.syncSource.path' component={Text} />
</div>
</div>
</div>
</div>
<div style={{display: 'flex', flexDirection: 'column', gap: '0.5rem'}}>
<p style={{fontWeight: 600}}>HYDRATE TO</p>
<div style={subsectionBodyStyle}>
<LabeledRevisionField
formApi={props.formApi}
repoURL={drySourceRepoURL}
repoType='git'
fieldValue='spec.sourceHydrator.hydrateTo.targetBranch'
revisionType='Branches'
/>
</div>
</div>
</div>
);
};

View file

@ -0,0 +1,194 @@
import {AutocompleteField, DataLoader, DropDownMenu, FormField} from 'argo-ui';
import * as React from 'react';
import {FormApi} from 'react-form';
import {RevisionHelpIcon} from '../../../shared/components';
import * as models from '../../../shared/models';
import {services} from '../../../shared/services';
import {RevisionFormField} from '../revision-form-field/revision-form-field';
import {getAppDefaultSource} from '../utils';
interface SourcePanelProps {
formApi: FormApi;
repos: string[];
repoInfo?: models.Repository;
currentRepoType: React.MutableRefObject<string>;
lastGitOrHelmUrl: React.MutableRefObject<string>;
lastOciUrl: React.MutableRefObject<string>;
}
export const SourcePanel = (props: SourcePanelProps) => {
const currentApp = props.formApi.getFormState().values as models.Application;
const currentSource = getAppDefaultSource(currentApp);
const repoType = currentSource?.repoURL?.startsWith('oci://') ? 'oci' : (currentSource && Object.prototype.hasOwnProperty.call(currentSource, 'chart') && 'helm') || 'git';
return (
<React.Fragment>
<div style={{display: 'flex', alignItems: 'flex-start'}}>
<div style={{flex: '1 1 auto', minWidth: 0}}>
<FormField
formApi={props.formApi}
label='Repository URL'
qeId='application-create-field-repository-url'
field='spec.source.repoURL'
component={AutocompleteField}
componentProps={{
items: props.repos,
filterSuggestions: true
}}
/>
</div>
<div style={{flex: '0 0 auto', minWidth: '7rem'}}>
<div style={{paddingTop: '1.5em'}}>
{(props.repoInfo && (
<React.Fragment>
<span>{(props.repoInfo.type || 'git').toUpperCase()}</span> <i className='fa fa-check' />
</React.Fragment>
)) || (
<DropDownMenu
anchor={() => (
<p>
{repoType.toUpperCase()} <i className='fa fa-caret-down' />
</p>
)}
qeId='application-create-dropdown-source-repository'
items={['git', 'helm', 'oci'].map((type: 'git' | 'helm' | 'oci') => ({
title: type.toUpperCase(),
action: () => {
if (repoType !== type) {
const updatedApp = props.formApi.getFormState().values as models.Application;
const source = getAppDefaultSource(updatedApp);
// Save the previous URL value for later use
if (repoType === 'git' || repoType === 'helm') {
props.lastGitOrHelmUrl.current = source.repoURL;
} else {
props.lastOciUrl.current = source.repoURL;
}
props.currentRepoType.current = type;
switch (type) {
case 'git':
case 'oci':
if (Object.prototype.hasOwnProperty.call(source, 'chart')) {
source.path = source.chart;
delete source.chart;
}
source.targetRevision = 'HEAD';
source.repoURL =
type === 'git' ? props.lastGitOrHelmUrl.current : props.lastOciUrl.current === '' ? 'oci://' : props.lastOciUrl.current;
break;
case 'helm':
if (Object.prototype.hasOwnProperty.call(source, 'path')) {
source.chart = source.path;
delete source.path;
}
source.targetRevision = '';
source.repoURL = props.lastGitOrHelmUrl.current;
break;
}
props.formApi.setAllValues(updatedApp);
}
}
}))}
/>
)}
</div>
</div>
</div>
{(repoType === 'oci' && (
<React.Fragment>
<RevisionFormField formApi={props.formApi} helpIconTop={'2.5em'} repoURL={currentApp.spec.source.repoURL} repoType={repoType} />
<div className='argo-form-row'>
<DataLoader
input={{repoURL: currentApp.spec.source.repoURL, revision: currentApp.spec.source.targetRevision}}
load={async src =>
src.repoURL &&
// TODO: for autocomplete we need to fetch paths that are used by other apps within the same project making use of the same OCI repo
new Array<string>()
}>
{(paths: string[]) => (
<FormField
formApi={props.formApi}
label='Path'
qeId='application-create-field-path'
field='spec.source.path'
component={AutocompleteField}
componentProps={{
items: paths,
filterSuggestions: true
}}
/>
)}
</DataLoader>
</div>
</React.Fragment>
)) ||
(repoType === 'git' && (
<React.Fragment>
<RevisionFormField formApi={props.formApi} helpIconTop={'2.5em'} repoURL={currentApp.spec.source.repoURL} repoType={repoType} />
<div className='argo-form-row'>
<DataLoader
input={{repoURL: currentApp.spec.source.repoURL, revision: currentApp.spec.source.targetRevision}}
load={async src =>
(src.repoURL &&
services.repos
.apps(src.repoURL, src.revision, currentApp.metadata.name, currentApp.spec.project)
.then(apps => Array.from(new Set(apps.map(item => item.path))).sort())
.catch(() => new Array<string>())) ||
new Array<string>()
}>
{(apps: string[]) => (
<FormField
formApi={props.formApi}
label='Path'
qeId='application-create-field-path'
field='spec.source.path'
component={AutocompleteField}
componentProps={{
items: apps,
filterSuggestions: true
}}
/>
)}
</DataLoader>
</div>
</React.Fragment>
)) || (
<DataLoader
input={{repoURL: currentApp.spec.source.repoURL}}
load={async src => (src.repoURL && services.repos.charts(src.repoURL).catch(() => new Array<models.HelmChart>())) || new Array<models.HelmChart>()}>
{(charts: models.HelmChart[]) => {
const selectedChart = charts.find(chart => chart.name === props.formApi.getFormState().values.spec.source.chart);
return (
<div className='row argo-form-row'>
<div className='columns small-10'>
<FormField
formApi={props.formApi}
label='Chart'
field='spec.source.chart'
component={AutocompleteField}
componentProps={{
items: charts.map(chart => chart.name),
filterSuggestions: true
}}
/>
</div>
<div className='columns small-2'>
<FormField
formApi={props.formApi}
field='spec.source.targetRevision'
component={AutocompleteField}
componentProps={{
items: (selectedChart && selectedChart.versions) || [],
filterSuggestions: true
}}
/>
<RevisionHelpIcon type='helm' />
</div>
</div>
);
}}
</DataLoader>
)}
</React.Fragment>
);
};

View file

@ -11,6 +11,7 @@ interface RevisionFormFieldProps {
formApi: FormApi;
helpIconTop?: string;
hideLabel?: boolean;
compact?: boolean;
repoURL: string;
fieldValue?: string;
repoType?: string;
@ -27,8 +28,13 @@ export function RevisionFormField(props: RevisionFormFieldProps) {
const selectedFilter = props.revisionType || filterType;
const rowClass = props.hideLabel ? '' : ' argo-form-row';
const rowPaddingRight = !props.revisionType ? '45px' : undefined;
const wrapperClassName = [props.compact ? '' : 'row' + rowClass, 'revision-form-field'].filter(Boolean).join(' ');
const wrapperStyle: React.CSSProperties = {
paddingRight: rowPaddingRight,
...(props.compact ? {marginTop: 0, marginBottom: 0} : {})
};
return (
<div className={'row' + rowClass + ' revision-form-field'} style={{paddingRight: rowPaddingRight}}>
<div className={wrapperClassName} style={wrapperStyle}>
<div className='revision-form-field__main'>
<DataLoader
input={{repoURL: props.repoURL, filterType: selectedFilter}}