mirror of
https://github.com/argoproj/argo-cd
synced 2026-04-21 08:57:17 +00:00
feat(ui): support creating multi-source applications in New App panel [CONTINUED..] (#27095)
Signed-off-by: Dave Canton <dvcanton7@gmail.com> Signed-off-by: Atif Ali <atali@redhat.com> Co-authored-by: Dave Canton <dvcanton7@gmail.com>
This commit is contained in:
parent
ecc178f03e
commit
706a0370c2
12 changed files with 788 additions and 238 deletions
|
|
@ -8,8 +8,10 @@ const CREATE_APPLICATION_BUTTON_CANCEL: By = By.xpath('.//button[@qe-id="applica
|
|||
|
||||
const CREATE_APPLICATION_FIELD_APP_NAME: By = By.xpath('.//input[@qeid="application-create-field-app-name"]');
|
||||
const CREATE_APPLICATION_FIELD_PROJECT: By = By.xpath('.//input[@qe-id="application-create-field-project"]');
|
||||
const CREATE_APPLICATION_FIELD_REPOSITORY_URL: By = By.xpath('.//input[@qe-id="application-create-field-repository-url"]');
|
||||
const CREATE_APPLICATION_FIELD_REPOSITORY_PATH: By = By.xpath('.//input[@qe-id="application-create-field-path"]');
|
||||
const CREATE_APPLICATION_SOURCE_1_FIELD_REPOSITORY_URL: By = By.xpath('.//input[@qe-id="application-create-source-1-field-repository-url"]');
|
||||
const CREATE_APPLICATION_SOURCE_1_FIELD_REPOSITORY_PATH: By = By.xpath('.//input[@qe-id="application-create-source-1-field-path"]');
|
||||
const CREATE_APPLICATION_SOURCE_2_FIELD_REPOSITORY_PATH: By = By.xpath('.//input[@qe-id="application-create-source-2-field-path"]');
|
||||
const CREATE_APPLICATION_SOURCE_2_FIELD_REPOSITORY_URL: By = By.xpath('.//input[@qe-id="application-create-source-2-field-repository-url"]');
|
||||
|
||||
const CREATE_APPLICATION_DROPDOWN_DESTINATION: By = By.xpath('.//div[@qe-id="application-create-dropdown-destination"]');
|
||||
const CREATE_APPLICATION_DROPDOWN_MENU_URL: By = By.xpath('.//li[@qe-id="application-create-dropdown-destination-URL"]');
|
||||
|
|
@ -47,9 +49,17 @@ export class ApplicationCreatePanel extends Base {
|
|||
}
|
||||
}
|
||||
|
||||
public async setSourceRepoUrl(sourceRepoUrl: string): Promise<void> {
|
||||
public async setSourceOneRepoUrl(sourceRepoUrl: string): Promise<void> {
|
||||
try {
|
||||
const reposUrl = await UiTestUtilities.findUiElement(this.driver, CREATE_APPLICATION_FIELD_REPOSITORY_URL);
|
||||
const reposUrl = await UiTestUtilities.findUiElement(this.driver, CREATE_APPLICATION_SOURCE_1_FIELD_REPOSITORY_URL);
|
||||
await reposUrl.sendKeys(sourceRepoUrl);
|
||||
} catch (err) {
|
||||
throw new Error(err);
|
||||
}
|
||||
}
|
||||
public async setSourceTwoRepoUrl(sourceRepoUrl: string): Promise<void> {
|
||||
try {
|
||||
const reposUrl = await UiTestUtilities.findUiElement(this.driver, CREATE_APPLICATION_SOURCE_2_FIELD_REPOSITORY_URL);
|
||||
await reposUrl.sendKeys(sourceRepoUrl);
|
||||
} catch (err: any) {
|
||||
UiTestUtilities.log('Error caught while setting source repo URL: ' + err);
|
||||
|
|
@ -57,9 +67,18 @@ export class ApplicationCreatePanel extends Base {
|
|||
}
|
||||
}
|
||||
|
||||
public async setSourceRepoPath(sourceRepoPath: string): Promise<void> {
|
||||
public async setSourceOneRepoPath(sourceRepoPath: string): Promise<void> {
|
||||
try {
|
||||
const path = await UiTestUtilities.findUiElement(this.driver, CREATE_APPLICATION_FIELD_REPOSITORY_PATH);
|
||||
const path = await UiTestUtilities.findUiElement(this.driver, CREATE_APPLICATION_SOURCE_1_FIELD_REPOSITORY_PATH);
|
||||
await path.sendKeys(sourceRepoPath);
|
||||
} catch (err) {
|
||||
throw new Error(err);
|
||||
}
|
||||
}
|
||||
|
||||
public async setSourceTwoRepoPath(sourceRepoPath: string): Promise<void> {
|
||||
try {
|
||||
const path = await UiTestUtilities.findUiElement(this.driver, CREATE_APPLICATION_SOURCE_2_FIELD_REPOSITORY_PATH);
|
||||
await path.sendKeys(sourceRepoPath);
|
||||
} catch (err: any) {
|
||||
UiTestUtilities.log('Error caught while setting source repo path: ' + err);
|
||||
|
|
@ -195,8 +214,10 @@ export class ApplicationCreatePanel extends Base {
|
|||
public async createApplication(
|
||||
appName: string,
|
||||
projectName: string,
|
||||
sourceRepoUrl: string,
|
||||
sourceRepoPath: string,
|
||||
sourceOneRepoUrl: string,
|
||||
sourceOneRepoPath: string,
|
||||
sourceTwoRepoUrl: string,
|
||||
sourceTwoRepoPath: string,
|
||||
destinationClusterName: string,
|
||||
destinationNamespace: string
|
||||
): Promise<void> {
|
||||
|
|
@ -204,8 +225,10 @@ export class ApplicationCreatePanel extends Base {
|
|||
try {
|
||||
await this.setAppName(appName);
|
||||
await this.setProjectName(projectName);
|
||||
await this.setSourceRepoUrl(sourceRepoUrl);
|
||||
await this.setSourceRepoPath(sourceRepoPath);
|
||||
await this.setSourceOneRepoUrl(sourceOneRepoUrl);
|
||||
await this.setSourceOneRepoPath(sourceOneRepoPath);
|
||||
await this.setSourceTwoRepoUrl(sourceTwoRepoUrl);
|
||||
await this.setSourceTwoRepoPath(sourceTwoRepoPath);
|
||||
await this.selectDestinationClusterNameMenu(destinationClusterName);
|
||||
await this.setDestinationNamespace(destinationNamespace);
|
||||
await this.clickCreateButton();
|
||||
|
|
|
|||
|
|
@ -29,8 +29,10 @@ async function doTest() {
|
|||
UiTestUtilities.log('About to create application');
|
||||
await applicationCreatePanel.setAppName(Configuration.APP_NAME);
|
||||
await applicationCreatePanel.setProjectName(Configuration.APP_PROJECT);
|
||||
await applicationCreatePanel.setSourceRepoUrl(Configuration.GIT_REPO);
|
||||
await applicationCreatePanel.setSourceRepoPath(Configuration.SOURCE_REPO_PATH);
|
||||
await applicationCreatePanel.setSourceOneRepoUrl(Configuration.GIT_REPO);
|
||||
await applicationCreatePanel.setSourceOneRepoPath(Configuration.SOURCE_REPO_PATH);
|
||||
await applicationCreatePanel.setSourceTwoRepoUrl(Configuration.GIT_REPO);
|
||||
await applicationCreatePanel.setSourceTwoRepoPath(Configuration.SOURCE_REPO_PATH);
|
||||
await applicationCreatePanel.selectDestinationClusterNameMenu(Configuration.DESTINATION_CLUSTER_NAME);
|
||||
await applicationCreatePanel.setDestinationNamespace(Configuration.DESTINATION_NAMESPACE);
|
||||
await applicationCreatePanel.clickCreateButton();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
@import 'node_modules/argo-ui/src/styles/config';
|
||||
@import 'node_modules/argo-ui/src/styles/theme';
|
||||
|
||||
.application-create-panel {
|
||||
|
||||
|
|
@ -25,4 +26,132 @@
|
|||
.row.argo-form-row .columns {
|
||||
padding-left: 0;
|
||||
}
|
||||
|
||||
&__add-source {
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
|
||||
&__multi-source-block {
|
||||
margin-bottom: 0.5em;
|
||||
|
||||
.argo-field-container {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: stretch;
|
||||
padding-top: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.argo-field-container > .argo-field,
|
||||
.argo-field-container > .argo-textarea {
|
||||
grid-area: unset;
|
||||
}
|
||||
|
||||
.argo-field-container > label.argo-label-placeholder {
|
||||
position: static;
|
||||
grid-area: unset;
|
||||
align-self: flex-start;
|
||||
transform: none !important;
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
margin: 0 0 2px;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
@include themify($themes) {
|
||||
color: themed('text-2');
|
||||
}
|
||||
}
|
||||
|
||||
.argo-field-container > .argo-field:focus + label.argo-label-placeholder,
|
||||
.argo-field-container > .argo-textarea:focus + label.argo-label-placeholder {
|
||||
font-size: 12px;
|
||||
transform: none !important;
|
||||
@include themify($themes) {
|
||||
color: themed('light-argo-teal-7');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&__multi-source-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&__multi-source-section.white-box {
|
||||
margin-bottom: 1rem;
|
||||
@include themify($themes) {
|
||||
border: 1px solid themed('light-argo-gray-4');
|
||||
background-color: themed('background-2');
|
||||
}
|
||||
box-shadow: 1px 2px 3px rgba(0, 0, 0, 0.12);
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__multi-source-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
&__multi-source-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
&__multi-source-label {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
text-transform: uppercase;
|
||||
@include themify($themes) {
|
||||
color: themed('text-2');
|
||||
}
|
||||
}
|
||||
|
||||
&__multi-source-remove-btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&__multi-source-collapse-btn {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
line-height: 1;
|
||||
@include themify($themes) {
|
||||
color: themed('light-argo-teal-7');
|
||||
}
|
||||
}
|
||||
|
||||
&__multi-source-section .revision-form-field {
|
||||
width: 100%;
|
||||
|
||||
.revision-form-field__main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&__multi-source-collapsed {
|
||||
margin-bottom: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&__multi-source-params {
|
||||
margin-top: 0.75rem;
|
||||
padding-top: 0.75rem;
|
||||
border-top: 1px solid $argo-color-gray-4;
|
||||
|
||||
.application-parameters > .white-box.editable-panel {
|
||||
box-shadow: none;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.white-box__details {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ 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 {cloneDeep, debounce} from 'lodash-es';
|
||||
import {YamlEditor} from '../../../shared/components';
|
||||
import * as models from '../../../shared/models';
|
||||
import {services} from '../../../shared/services';
|
||||
|
|
@ -12,20 +13,14 @@ import {ApplicationRetryOptions} from '../application-retry-options/application-
|
|||
import {ApplicationSyncOptionsField} from '../application-sync-options/application-sync-options';
|
||||
import {SetFinalizerOnApplication} from './set-finalizer-on-application';
|
||||
import {HydratorSourcePanel} from './hydrator-source-panel';
|
||||
import {CollapsibleMultiSourceSection} from './collapsible-multi-source-section';
|
||||
import {SourcePanel} from './source-panel';
|
||||
import './application-create-panel.scss';
|
||||
import {getAppDefaultSource} from '../utils';
|
||||
import {debounce} from 'lodash-es';
|
||||
import {APP_SOURCE_TYPES, normalizeTypeFieldsForSource} from '../shared/app-source-edit';
|
||||
|
||||
const jsonMergePatch = require('json-merge-patch');
|
||||
|
||||
const appTypes = new Array<{field: string; type: models.AppSourceType}>(
|
||||
{type: 'Helm', field: 'helm'},
|
||||
{type: 'Kustomize', field: 'kustomize'},
|
||||
{type: 'Directory', field: 'directory'},
|
||||
{type: 'Plugin', field: 'plugin'}
|
||||
);
|
||||
|
||||
const DEFAULT_APP: Partial<models.Application> = {
|
||||
apiVersion: 'argoproj.io/v1alpha1',
|
||||
kind: 'Application',
|
||||
|
|
@ -127,6 +122,8 @@ export const ApplicationCreatePanel = (props: {
|
|||
let destinationComboValue = destinationFieldChanges.destFormat;
|
||||
const authSettingsCtx = React.useContext(AuthSettingsCtx);
|
||||
|
||||
const [multiSourceMode, setMultiSourceMode] = React.useState(() => (app.spec?.sources?.length ?? 0) >= 2);
|
||||
|
||||
React.useEffect(() => {
|
||||
comboSwitchedFromPanel.current = false;
|
||||
}, []);
|
||||
|
|
@ -137,16 +134,6 @@ export const ApplicationCreatePanel = (props: {
|
|||
};
|
||||
}, [debouncedOnAppChanged]);
|
||||
|
||||
function normalizeTypeFields(formApi: FormApi, type: models.AppSourceType) {
|
||||
const appToNormalize = formApi.getFormState().values;
|
||||
for (const item of appTypes) {
|
||||
if (item.type !== type) {
|
||||
delete appToNormalize.spec.source[item.field];
|
||||
}
|
||||
}
|
||||
formApi.setAllValues(appToNormalize);
|
||||
}
|
||||
|
||||
const currentName = app.spec.destination.name;
|
||||
const currentServer = app.spec.destination.server;
|
||||
if (destinationFieldChanges.destFormatChanged !== null) {
|
||||
|
|
@ -193,9 +180,49 @@ export const ApplicationCreatePanel = (props: {
|
|||
delete data.spec.sourceHydrator.hydrateTo;
|
||||
}
|
||||
|
||||
if (multiSourceMode && data.spec.sources && data.spec.sources.length > 0) {
|
||||
delete data.spec.source;
|
||||
}
|
||||
|
||||
props.createApp(data);
|
||||
};
|
||||
|
||||
function handleAddSource(api: FormApi) {
|
||||
const updated = cloneDeep(api.getFormState().values) as models.Application;
|
||||
if (!multiSourceMode) {
|
||||
updated.spec.sources = [{...(updated.spec.source || {path: '', repoURL: '', targetRevision: 'HEAD'})}, {path: '', repoURL: '', targetRevision: 'HEAD'}];
|
||||
delete updated.spec.source;
|
||||
delete updated.spec.sourceHydrator;
|
||||
setIsHydratorEnabled(false);
|
||||
setMultiSourceMode(true);
|
||||
} else {
|
||||
if (!updated.spec.sources) {
|
||||
updated.spec.sources = [];
|
||||
}
|
||||
updated.spec.sources.push({path: '', repoURL: '', targetRevision: 'HEAD'});
|
||||
}
|
||||
api.setAllValues(updated);
|
||||
}
|
||||
|
||||
function handleRemoveSource(api: FormApi, index: number) {
|
||||
const updated = cloneDeep(api.getFormState().values) as models.Application;
|
||||
const sources = updated.spec.sources;
|
||||
if (!sources || index < 0 || index >= sources.length) {
|
||||
return;
|
||||
}
|
||||
sources.splice(index, 1);
|
||||
if (sources.length === 0) {
|
||||
updated.spec.source = {path: '', repoURL: '', targetRevision: 'HEAD'};
|
||||
delete updated.spec.sources;
|
||||
setMultiSourceMode(false);
|
||||
} else if (sources.length === 1) {
|
||||
updated.spec.source = sources[0];
|
||||
delete updated.spec.sources;
|
||||
setMultiSourceMode(false);
|
||||
}
|
||||
api.setAllValues(updated);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataLoader
|
||||
key='creation-deps'
|
||||
|
|
@ -208,7 +235,7 @@ export const ApplicationCreatePanel = (props: {
|
|||
}>
|
||||
{({projects, clusters, reposInfo}) => {
|
||||
const repos = reposInfo.map(info => info.repo).sort();
|
||||
const repoInfo = reposInfo.find(info => info.repo === app.spec.source.repoURL);
|
||||
const repoInfo = reposInfo.find(info => info.repo === app.spec.source?.repoURL);
|
||||
if (repoInfo) {
|
||||
normalizeAppSource(app, repoInfo.type || currentRepoType.current || 'git');
|
||||
}
|
||||
|
|
@ -221,8 +248,10 @@ export const ApplicationCreatePanel = (props: {
|
|||
input={app}
|
||||
onCancel={() => setYamlMode(false)}
|
||||
onSave={async patch => {
|
||||
props.onAppChanged(jsonMergePatch.apply(app, JSON.parse(patch)));
|
||||
const next = jsonMergePatch.apply(app, JSON.parse(patch)) as models.Application;
|
||||
props.onAppChanged(next);
|
||||
setYamlMode(false);
|
||||
setMultiSourceMode((next.spec?.sources?.length ?? 0) >= 2);
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
|
|
@ -232,23 +261,43 @@ export const ApplicationCreatePanel = (props: {
|
|||
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
|
||||
const destinationErrors = {
|
||||
'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
|
||||
!a.spec.destination.server && (!a.spec.destination.hasOwnProperty('name') || a.spec.destination.name === '')
|
||||
? 'Cluster URL is required'
|
||||
: undefined,
|
||||
'spec.destination.name':
|
||||
!a.spec.destination.name &&
|
||||
(!a.spec.destination.hasOwnProperty('server') || a.spec.destination.server === '') &&
|
||||
'Cluster name is required'
|
||||
!a.spec.destination.name && (!a.spec.destination.hasOwnProperty('server') || a.spec.destination.server === '')
|
||||
? 'Cluster name is required'
|
||||
: undefined
|
||||
};
|
||||
|
||||
if (multiSourceMode && !hasHydrator) {
|
||||
const errs: Record<string, string | undefined> = {
|
||||
'metadata.name': !a.metadata.name ? 'Application Name is required' : undefined,
|
||||
'spec.project': !a.spec.project ? 'Project Name is required' : undefined,
|
||||
...destinationErrors
|
||||
};
|
||||
const sources = a.spec.sources || [];
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
const s = sources[i];
|
||||
errs[`spec.sources[${i}].repoURL`] = !s?.repoURL ? 'Repository URL is required' : undefined;
|
||||
errs[`spec.sources[${i}].targetRevision`] = !s?.targetRevision && s?.hasOwnProperty('chart') ? 'Version is required' : undefined;
|
||||
errs[`spec.sources[${i}].path`] = !s?.path && !s?.chart ? 'Path is required' : undefined;
|
||||
errs[`spec.sources[${i}].chart`] = !s?.path && !s?.chart ? 'Chart is required' : undefined;
|
||||
}
|
||||
return errs;
|
||||
}
|
||||
|
||||
return {
|
||||
'metadata.name': !a.metadata.name ? 'Application Name is required' : undefined,
|
||||
'spec.project': !a.spec.project ? 'Project Name is required' : undefined,
|
||||
'spec.source.repoURL': !hasHydrator && !source?.repoURL ? 'Repository URL is required' : undefined,
|
||||
'spec.source.targetRevision':
|
||||
!hasHydrator && !source?.targetRevision && source?.hasOwnProperty('chart') ? 'Version is required' : undefined,
|
||||
'spec.source.path': !hasHydrator && !source?.path && !source?.chart ? 'Path is required' : undefined,
|
||||
'spec.source.chart': !hasHydrator && !source?.path && !source?.chart ? 'Chart is required' : undefined,
|
||||
...destinationErrors
|
||||
};
|
||||
}}
|
||||
defaultValues={app}
|
||||
|
|
@ -256,6 +305,8 @@ export const ApplicationCreatePanel = (props: {
|
|||
onSubmit={onCreateApp}
|
||||
getApi={props.getFormApi}>
|
||||
{api => {
|
||||
const formApp = api.getFormState().values as models.Application;
|
||||
|
||||
const generalPanel = () => (
|
||||
<div className='white-box'>
|
||||
<p>GENERAL</p>
|
||||
|
|
@ -316,59 +367,95 @@ export const ApplicationCreatePanel = (props: {
|
|||
</div>
|
||||
);
|
||||
|
||||
const sourcePanel = () => (
|
||||
<div className='white-box'>
|
||||
<p>SOURCE</p>
|
||||
{/* 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>
|
||||
const sourcePanel = () => {
|
||||
if (multiSourceMode) {
|
||||
const count = formApp.spec.sources?.length ?? 0;
|
||||
return (
|
||||
<div className='white-box'>
|
||||
<p>SOURCES</p>
|
||||
{Array.from({length: count}, (_, i) => (
|
||||
<CollapsibleMultiSourceSection
|
||||
key={`msrc-${i}`}
|
||||
index={i}
|
||||
formApi={api}
|
||||
repos={repos}
|
||||
reposInfo={reposInfo}
|
||||
formApp={formApp}
|
||||
canRemove={count >= 2}
|
||||
onRemove={() => handleRemoveSource(api, i)}
|
||||
/>
|
||||
))}
|
||||
<div className='application-create-panel__add-source'>
|
||||
<button type='button' className='argo-button argo-button--base' onClick={() => handleAddSource(api)}>
|
||||
<i className='fa fa-plus' style={{marginLeft: '-5px', marginRight: '5px'}} />
|
||||
Add Source
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{isHydratorEnabled ? (
|
||||
<HydratorSourcePanel formApi={api} repos={repos} />
|
||||
) : (
|
||||
<SourcePanel
|
||||
formApi={api}
|
||||
repos={repos}
|
||||
repoInfo={repoInfo}
|
||||
currentRepoType={currentRepoType}
|
||||
lastGitOrHelmUrl={lastGitOrHelmUrl}
|
||||
lastOciUrl={lastOciUrl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='white-box'>
|
||||
<p>SOURCE</p>
|
||||
{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>
|
||||
)}
|
||||
{isHydratorEnabled ? (
|
||||
<HydratorSourcePanel formApi={api} repos={repos} />
|
||||
) : (
|
||||
<React.Fragment>
|
||||
<SourcePanel
|
||||
formApi={api}
|
||||
repos={repos}
|
||||
repoInfo={repoInfo}
|
||||
currentRepoType={currentRepoType}
|
||||
lastGitOrHelmUrl={lastGitOrHelmUrl}
|
||||
lastOciUrl={lastOciUrl}
|
||||
/>
|
||||
<div className='application-create-panel__add-source'>
|
||||
<button type='button' className='argo-button argo-button--base' onClick={() => handleAddSource(api)}>
|
||||
<i className='fa fa-plus' style={{marginLeft: '-5px', marginRight: '5px'}} />
|
||||
Add Source
|
||||
</button>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
const destinationPanel = () => (
|
||||
<div className='white-box'>
|
||||
<p>DESTINATION</p>
|
||||
|
|
@ -437,81 +524,86 @@ export const ApplicationCreatePanel = (props: {
|
|||
</div>
|
||||
);
|
||||
|
||||
const typePanel = () => (
|
||||
<DataLoader
|
||||
input={{
|
||||
repoURL: app.spec.source.repoURL,
|
||||
path: app.spec.source.path,
|
||||
chart: app.spec.source.chart,
|
||||
targetRevision: app.spec.source.targetRevision,
|
||||
appName: app.metadata.name
|
||||
}}
|
||||
load={async src => {
|
||||
if (src.repoURL && src.targetRevision && (src.path || src.chart)) {
|
||||
return services.repos.appDetails(src, src.appName, app.spec.project, 0, 0).catch(() => ({
|
||||
type: 'Directory',
|
||||
details: {}
|
||||
}));
|
||||
} else {
|
||||
const typePanel = () => {
|
||||
const liveApp = api.getFormState().values as models.Application;
|
||||
const liveSrc = liveApp.spec.source;
|
||||
return (
|
||||
<DataLoader
|
||||
input={{
|
||||
repoURL: liveSrc?.repoURL,
|
||||
path: liveSrc?.path,
|
||||
chart: liveSrc?.chart,
|
||||
targetRevision: liveSrc?.targetRevision,
|
||||
appName: liveApp.metadata.name,
|
||||
project: liveApp.spec.project
|
||||
}}
|
||||
load={async src => {
|
||||
if (src.repoURL && src.targetRevision && (src.path || src.chart)) {
|
||||
return services.repos.appDetails(src, src.appName, src.project, 0, 0).catch(() => ({
|
||||
type: 'Directory',
|
||||
details: {}
|
||||
}));
|
||||
}
|
||||
return {
|
||||
type: 'Directory',
|
||||
details: {}
|
||||
};
|
||||
}
|
||||
}}>
|
||||
{(details: models.RepoAppDetails) => {
|
||||
const type = (explicitPathType && explicitPathType.path === app.spec.source.path && explicitPathType.type) || details.type;
|
||||
if (details.type !== type) {
|
||||
switch (type) {
|
||||
case 'Helm':
|
||||
details = {
|
||||
type,
|
||||
path: details.path,
|
||||
helm: {name: '', valueFiles: [], path: '', parameters: [], fileParameters: []}
|
||||
};
|
||||
break;
|
||||
case 'Kustomize':
|
||||
details = {type, path: details.path, kustomize: {path: ''}};
|
||||
break;
|
||||
case 'Plugin':
|
||||
details = {type, path: details.path, plugin: {name: '', env: []}};
|
||||
break;
|
||||
// Directory
|
||||
default:
|
||||
details = {type, path: details.path, directory: {}};
|
||||
break;
|
||||
}}>
|
||||
{(details: models.RepoAppDetails) => {
|
||||
const pathKey = (liveSrc?.chart || liveSrc?.path || '') as string;
|
||||
const type = (explicitPathType && explicitPathType.path === pathKey && explicitPathType.type) || details.type;
|
||||
let d = details;
|
||||
if (d.type !== type) {
|
||||
switch (type) {
|
||||
case 'Helm':
|
||||
d = {
|
||||
type,
|
||||
path: d.path,
|
||||
helm: {name: '', valueFiles: [], path: '', parameters: [], fileParameters: []}
|
||||
};
|
||||
break;
|
||||
case 'Kustomize':
|
||||
d = {type, path: d.path, kustomize: {path: ''}};
|
||||
break;
|
||||
case 'Plugin':
|
||||
d = {type, path: d.path, plugin: {name: '', env: []}};
|
||||
break;
|
||||
default:
|
||||
d = {type, path: d.path, directory: {}};
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DropDownMenu
|
||||
anchor={() => (
|
||||
<p>
|
||||
{type} <i className='fa fa-caret-down' />
|
||||
</p>
|
||||
)}
|
||||
qeId='application-create-dropdown-source'
|
||||
items={appTypes.map(item => ({
|
||||
title: item.type,
|
||||
action: () => {
|
||||
setExplicitPathType({type: item.type, path: app.spec.source.path});
|
||||
normalizeTypeFields(api, item.type);
|
||||
}
|
||||
}))}
|
||||
/>
|
||||
<ApplicationParameters
|
||||
noReadonlyMode={true}
|
||||
application={app}
|
||||
details={details}
|
||||
save={async updatedApp => {
|
||||
api.setAllValues(updatedApp);
|
||||
}}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}}
|
||||
</DataLoader>
|
||||
);
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DropDownMenu
|
||||
anchor={() => (
|
||||
<p>
|
||||
{type} <i className='fa fa-caret-down' />
|
||||
</p>
|
||||
)}
|
||||
qeId='application-create-dropdown-source'
|
||||
items={APP_SOURCE_TYPES.map(item => ({
|
||||
title: item.type,
|
||||
action: () => {
|
||||
setExplicitPathType({type: item.type, path: pathKey});
|
||||
normalizeTypeFieldsForSource(api, item.type, undefined);
|
||||
}
|
||||
}))}
|
||||
/>
|
||||
<ApplicationParameters
|
||||
noReadonlyMode={true}
|
||||
application={liveApp}
|
||||
details={d}
|
||||
save={async updatedApp => {
|
||||
api.setAllValues(updatedApp);
|
||||
}}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}}
|
||||
</DataLoader>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<form onSubmit={api.submitForm} role='form' className='width-control'>
|
||||
|
|
@ -521,7 +613,7 @@ export const ApplicationCreatePanel = (props: {
|
|||
|
||||
{destinationPanel()}
|
||||
|
||||
{typePanel()}
|
||||
{!multiSourceMode && typePanel()}
|
||||
</form>
|
||||
);
|
||||
}}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,72 @@
|
|||
import * as React from 'react';
|
||||
import {FormApi} from 'react-form';
|
||||
import * as models from '../../../shared/models';
|
||||
import {CreatePanelSourceTypeParameters} from './create-panel-source-type-parameters';
|
||||
import {SourcePanel} from './source-panel';
|
||||
|
||||
export function CollapsibleMultiSourceSection(props: {
|
||||
index: number;
|
||||
formApi: FormApi;
|
||||
repos: string[];
|
||||
reposInfo: models.Repository[];
|
||||
formApp: models.Application;
|
||||
canRemove?: boolean;
|
||||
onRemove?: () => void;
|
||||
}) {
|
||||
const [expanded, setExpanded] = React.useState(true);
|
||||
const src = props.formApp.spec.sources?.[props.index];
|
||||
const repoInfoFor = props.reposInfo.find(r => r.repo === src?.repoURL);
|
||||
const title = `Source ${props.index + 1}${src?.name ? ` — ${src.name}` : ''}: ${src?.repoURL || ''}`;
|
||||
const desc = [src?.path && `PATH=${src.path}`, src?.chart && `CHART=${src.chart}`, src?.targetRevision && `REVISION=${src.targetRevision}`].filter(Boolean).join(', ');
|
||||
|
||||
if (!expanded) {
|
||||
return (
|
||||
<div className='settings-overview__redirect-panel application-create-panel__multi-source-collapsed' onClick={() => setExpanded(true)}>
|
||||
<div className='editable-panel__collapsible-button'>
|
||||
<i className='fa fa-angle-down filter__collapse editable-panel__collapsible-button__override' />
|
||||
</div>
|
||||
<div className='settings-overview__redirect-panel__content'>
|
||||
<div className='settings-overview__redirect-panel__title'>{title}</div>
|
||||
<div className='settings-overview__redirect-panel__description'>{desc}</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='white-box application-create-panel__multi-source-section'>
|
||||
<div className='application-create-panel__multi-source-header'>
|
||||
<span className='application-create-panel__multi-source-label'>Source {props.index + 1}</span>
|
||||
<div className='application-create-panel__multi-source-header-actions'>
|
||||
{props.canRemove && props.onRemove && (
|
||||
<button
|
||||
type='button'
|
||||
className='argo-button argo-button--base application-create-panel__multi-source-remove-btn'
|
||||
title='Remove this source'
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
props.onRemove?.();
|
||||
}}>
|
||||
<i className='fa fa-minus' style={{marginLeft: '-5px', marginRight: '5px'}} />
|
||||
Remove source
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type='button'
|
||||
className='application-create-panel__multi-source-collapse-btn'
|
||||
title='Collapse source'
|
||||
aria-label='Collapse source section'
|
||||
onClick={() => setExpanded(false)}>
|
||||
<i className='fa fa-angle-up' />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className='application-create-panel__multi-source-block'>
|
||||
<SourcePanel formApi={props.formApi} repos={props.repos} repoInfo={repoInfoFor} sourceIndex={props.index} suppressMultiSourceHeading={true} />
|
||||
</div>
|
||||
<div className='application-create-panel__multi-source-params'>
|
||||
<CreatePanelSourceTypeParameters formApi={props.formApi} sourceIndex={props.index} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,100 @@
|
|||
import {DataLoader, DropDownMenu} from 'argo-ui';
|
||||
import * as React from 'react';
|
||||
import {FormApi} from 'react-form';
|
||||
import * as models from '../../../shared/models';
|
||||
import {services} from '../../../shared/services';
|
||||
import {ApplicationParameters} from '../application-parameters/application-parameters';
|
||||
import {APP_SOURCE_TYPES, normalizeTypeFieldsForSource} from '../shared/app-source-edit';
|
||||
|
||||
function pathKeyForSource(src: models.ApplicationSource | undefined): string {
|
||||
if (!src) {
|
||||
return '';
|
||||
}
|
||||
return (src.chart || src.path || '') as string;
|
||||
}
|
||||
|
||||
export const CreatePanelSourceTypeParameters = (props: {formApi: FormApi; sourceIndex: number}) => {
|
||||
const [explicitPathType, setExplicitPathType] = React.useState<{path: string; type: models.AppSourceType}>(null);
|
||||
const formApp = props.formApi.getFormState().values as models.Application;
|
||||
const src = formApp.spec.sources?.[props.sourceIndex];
|
||||
const qeN = props.sourceIndex + 1;
|
||||
|
||||
return (
|
||||
<DataLoader
|
||||
input={{
|
||||
repoURL: src?.repoURL,
|
||||
path: src?.path,
|
||||
chart: src?.chart,
|
||||
targetRevision: src?.targetRevision,
|
||||
appName: formApp.metadata.name,
|
||||
project: formApp.spec.project,
|
||||
pathKey: pathKeyForSource(src)
|
||||
}}
|
||||
load={async input => {
|
||||
if (input.repoURL && input.targetRevision && (input.path || input.chart)) {
|
||||
return services.repos.appDetails(input, input.appName, input.project, 0, 0).catch(() => ({
|
||||
type: 'Directory' as const,
|
||||
details: {}
|
||||
}));
|
||||
}
|
||||
return {
|
||||
type: 'Directory' as const,
|
||||
details: {}
|
||||
};
|
||||
}}>
|
||||
{(details: models.RepoAppDetails) => {
|
||||
const key = pathKeyForSource(src);
|
||||
const type = (explicitPathType && explicitPathType.path === key && explicitPathType.type) || details.type;
|
||||
let d = details;
|
||||
if (d.type !== type) {
|
||||
switch (type) {
|
||||
case 'Helm':
|
||||
d = {
|
||||
type,
|
||||
path: d.path,
|
||||
helm: {name: '', valueFiles: [], path: '', parameters: [], fileParameters: []}
|
||||
};
|
||||
break;
|
||||
case 'Kustomize':
|
||||
d = {type, path: d.path, kustomize: {path: ''}};
|
||||
break;
|
||||
case 'Plugin':
|
||||
d = {type, path: d.path, plugin: {name: '', env: []}};
|
||||
break;
|
||||
default:
|
||||
d = {type, path: d.path, directory: {}};
|
||||
break;
|
||||
}
|
||||
}
|
||||
return (
|
||||
<React.Fragment>
|
||||
<DropDownMenu
|
||||
anchor={() => (
|
||||
<p>
|
||||
{type} <i className='fa fa-caret-down' />
|
||||
</p>
|
||||
)}
|
||||
qeId={`application-create-dropdown-source-${qeN}`}
|
||||
items={APP_SOURCE_TYPES.map(item => ({
|
||||
title: item.type,
|
||||
action: () => {
|
||||
setExplicitPathType({type: item.type, path: key});
|
||||
normalizeTypeFieldsForSource(props.formApi, item.type, props.sourceIndex);
|
||||
}
|
||||
}))}
|
||||
/>
|
||||
<ApplicationParameters
|
||||
noReadonlyMode={true}
|
||||
application={formApp}
|
||||
details={d}
|
||||
multiSourceIndex={props.sourceIndex}
|
||||
save={async updatedApp => {
|
||||
props.formApi.setAllValues(updatedApp);
|
||||
}}
|
||||
/>
|
||||
</React.Fragment>
|
||||
);
|
||||
}}
|
||||
</DataLoader>
|
||||
);
|
||||
};
|
||||
|
|
@ -7,29 +7,62 @@ import {services} from '../../../shared/services';
|
|||
import {RevisionFormField} from '../revision-form-field/revision-form-field';
|
||||
import {getAppDefaultSource} from '../utils';
|
||||
|
||||
interface SourcePanelProps {
|
||||
function getSourceForPanel(app: models.Application, sourceIndex?: number): models.ApplicationSource | null {
|
||||
if (sourceIndex !== undefined) {
|
||||
return app.spec.sources?.[sourceIndex] ?? null;
|
||||
}
|
||||
return getAppDefaultSource(app);
|
||||
}
|
||||
|
||||
function fieldPath(sourceIndex: number | undefined, field: string): string {
|
||||
if (sourceIndex !== undefined) {
|
||||
return `spec.sources[${sourceIndex}].${field}`;
|
||||
}
|
||||
return `spec.source.${field}`;
|
||||
}
|
||||
|
||||
export interface SourcePanelProps {
|
||||
formApi: FormApi;
|
||||
repos: string[];
|
||||
repoInfo?: models.Repository;
|
||||
currentRepoType: React.MutableRefObject<string>;
|
||||
lastGitOrHelmUrl: React.MutableRefObject<string>;
|
||||
lastOciUrl: React.MutableRefObject<string>;
|
||||
sourceIndex?: number;
|
||||
suppressMultiSourceHeading?: boolean;
|
||||
currentRepoType?: React.MutableRefObject<string | undefined>;
|
||||
lastGitOrHelmUrl?: React.MutableRefObject<string>;
|
||||
lastOciUrl?: React.MutableRefObject<string>;
|
||||
}
|
||||
|
||||
export const SourcePanel = (props: SourcePanelProps) => {
|
||||
const internalRepoType = React.useRef<string | undefined>(undefined);
|
||||
const internalLastGit = React.useRef('');
|
||||
const internalLastOci = React.useRef('');
|
||||
const isMulti = props.sourceIndex !== undefined;
|
||||
const lastGitOrHelmUrl = isMulti ? internalLastGit : props.lastGitOrHelmUrl;
|
||||
const lastOciUrl = isMulti ? internalLastOci : props.lastOciUrl;
|
||||
const currentRepoType = isMulti ? internalRepoType : props.currentRepoType;
|
||||
|
||||
const currentApp = props.formApi.getFormState().values as models.Application;
|
||||
const currentSource = getAppDefaultSource(currentApp);
|
||||
const currentSource = getSourceForPanel(currentApp, props.sourceIndex);
|
||||
const repoType = currentSource?.repoURL?.startsWith('oci://') ? 'oci' : (currentSource && Object.prototype.hasOwnProperty.call(currentSource, 'chart') && 'helm') || 'git';
|
||||
|
||||
const idx = props.sourceIndex;
|
||||
const qeSourceN = isMulti && idx !== undefined ? idx + 1 : 0;
|
||||
const specSourceForRevision = isMulti ? currentApp.spec.sources?.[props.sourceIndex] : currentApp.spec.source;
|
||||
|
||||
return (
|
||||
<React.Fragment>
|
||||
{isMulti && !props.suppressMultiSourceHeading && (
|
||||
<p className='application-create-panel__multi-source-title' style={{marginTop: idx > 0 ? '1em' : 0}}>
|
||||
SOURCE {idx + 1}
|
||||
</p>
|
||||
)}
|
||||
<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'
|
||||
qeId={isMulti ? `application-create-source-${qeSourceN}-field-repository-url` : 'application-create-field-repository-url'}
|
||||
field={fieldPath(idx, 'repoURL')}
|
||||
component={AutocompleteField}
|
||||
componentProps={{
|
||||
items: props.repos,
|
||||
|
|
@ -50,20 +83,22 @@ export const SourcePanel = (props: SourcePanelProps) => {
|
|||
{repoType.toUpperCase()} <i className='fa fa-caret-down' />
|
||||
</p>
|
||||
)}
|
||||
qeId='application-create-dropdown-source-repository'
|
||||
qeId={isMulti ? `application-create-dropdown-source-repository-${qeSourceN}` : '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;
|
||||
const source = getSourceForPanel(updatedApp, props.sourceIndex);
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
props.currentRepoType.current = type;
|
||||
if (repoType === 'git' || repoType === 'helm') {
|
||||
lastGitOrHelmUrl.current = source.repoURL;
|
||||
} else {
|
||||
lastOciUrl.current = source.repoURL;
|
||||
}
|
||||
currentRepoType.current = type;
|
||||
switch (type) {
|
||||
case 'git':
|
||||
case 'oci':
|
||||
|
|
@ -72,8 +107,7 @@ export const SourcePanel = (props: SourcePanelProps) => {
|
|||
delete source.chart;
|
||||
}
|
||||
source.targetRevision = 'HEAD';
|
||||
source.repoURL =
|
||||
type === 'git' ? props.lastGitOrHelmUrl.current : props.lastOciUrl.current === '' ? 'oci://' : props.lastOciUrl.current;
|
||||
source.repoURL = type === 'git' ? lastGitOrHelmUrl.current : lastOciUrl.current === '' ? 'oci://' : lastOciUrl.current;
|
||||
break;
|
||||
case 'helm':
|
||||
if (Object.prototype.hasOwnProperty.call(source, 'path')) {
|
||||
|
|
@ -81,7 +115,7 @@ export const SourcePanel = (props: SourcePanelProps) => {
|
|||
delete source.path;
|
||||
}
|
||||
source.targetRevision = '';
|
||||
source.repoURL = props.lastGitOrHelmUrl.current;
|
||||
source.repoURL = lastGitOrHelmUrl.current;
|
||||
break;
|
||||
}
|
||||
props.formApi.setAllValues(updatedApp);
|
||||
|
|
@ -96,10 +130,16 @@ export const SourcePanel = (props: SourcePanelProps) => {
|
|||
|
||||
{(repoType === 'oci' && (
|
||||
<React.Fragment>
|
||||
<RevisionFormField formApi={props.formApi} helpIconTop={'2.5em'} repoURL={currentApp.spec.source.repoURL} repoType={repoType} />
|
||||
<RevisionFormField
|
||||
formApi={props.formApi}
|
||||
helpIconTop={'2.5em'}
|
||||
repoURL={specSourceForRevision?.repoURL || ''}
|
||||
repoType={repoType}
|
||||
fieldValue={fieldPath(idx, 'targetRevision')}
|
||||
/>
|
||||
<div className='argo-form-row'>
|
||||
<DataLoader
|
||||
input={{repoURL: currentApp.spec.source.repoURL, revision: currentApp.spec.source.targetRevision}}
|
||||
input={{repoURL: specSourceForRevision?.repoURL, revision: specSourceForRevision?.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
|
||||
|
|
@ -109,8 +149,8 @@ export const SourcePanel = (props: SourcePanelProps) => {
|
|||
<FormField
|
||||
formApi={props.formApi}
|
||||
label='Path'
|
||||
qeId='application-create-field-path'
|
||||
field='spec.source.path'
|
||||
qeId={isMulti ? `application-create-source-${qeSourceN}-field-path` : 'application-create-field-path'}
|
||||
field={fieldPath(idx, 'path')}
|
||||
component={AutocompleteField}
|
||||
componentProps={{
|
||||
items: paths,
|
||||
|
|
@ -124,10 +164,16 @@ export const SourcePanel = (props: SourcePanelProps) => {
|
|||
)) ||
|
||||
(repoType === 'git' && (
|
||||
<React.Fragment>
|
||||
<RevisionFormField formApi={props.formApi} helpIconTop={'2.5em'} repoURL={currentApp.spec.source.repoURL} repoType={repoType} />
|
||||
<RevisionFormField
|
||||
formApi={props.formApi}
|
||||
helpIconTop={'2.5em'}
|
||||
repoURL={specSourceForRevision?.repoURL || ''}
|
||||
repoType={repoType}
|
||||
fieldValue={fieldPath(idx, 'targetRevision')}
|
||||
/>
|
||||
<div className='argo-form-row'>
|
||||
<DataLoader
|
||||
input={{repoURL: currentApp.spec.source.repoURL, revision: currentApp.spec.source.targetRevision}}
|
||||
input={{repoURL: specSourceForRevision?.repoURL, revision: specSourceForRevision?.targetRevision}}
|
||||
load={async src =>
|
||||
(src.repoURL &&
|
||||
services.repos
|
||||
|
|
@ -140,8 +186,8 @@ export const SourcePanel = (props: SourcePanelProps) => {
|
|||
<FormField
|
||||
formApi={props.formApi}
|
||||
label='Path'
|
||||
qeId='application-create-field-path'
|
||||
field='spec.source.path'
|
||||
qeId={isMulti ? `application-create-source-${qeSourceN}-field-path` : 'application-create-field-path'}
|
||||
field={fieldPath(idx, 'path')}
|
||||
component={AutocompleteField}
|
||||
componentProps={{
|
||||
items: apps,
|
||||
|
|
@ -154,17 +200,19 @@ export const SourcePanel = (props: SourcePanelProps) => {
|
|||
</React.Fragment>
|
||||
)) || (
|
||||
<DataLoader
|
||||
input={{repoURL: currentApp.spec.source.repoURL}}
|
||||
input={{repoURL: specSourceForRevision?.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);
|
||||
const spec = props.formApi.getFormState().values.spec;
|
||||
const chartName = isMulti ? spec.sources?.[props.sourceIndex as number]?.chart : spec.source?.chart;
|
||||
const selectedChart = charts.find(chart => chart.name === chartName);
|
||||
return (
|
||||
<div className='row argo-form-row'>
|
||||
<div className='columns small-10'>
|
||||
<FormField
|
||||
formApi={props.formApi}
|
||||
label='Chart'
|
||||
field='spec.source.chart'
|
||||
field={fieldPath(idx, 'chart')}
|
||||
component={AutocompleteField}
|
||||
componentProps={{
|
||||
items: charts.map(chart => chart.name),
|
||||
|
|
@ -175,7 +223,7 @@ export const SourcePanel = (props: SourcePanelProps) => {
|
|||
<div className='columns small-2'>
|
||||
<FormField
|
||||
formApi={props.formApi}
|
||||
field='spec.source.targetRevision'
|
||||
field={fieldPath(idx, 'targetRevision')}
|
||||
component={AutocompleteField}
|
||||
componentProps={{
|
||||
items: (selectedChart && selectedChart.versions) || [],
|
||||
|
|
|
|||
|
|
@ -153,6 +153,8 @@ export const ApplicationParameters = (props: {
|
|||
handleCollapse?: (i: number, isCollapsed: boolean) => void;
|
||||
appContext?: AppContext;
|
||||
tempSource?: models.ApplicationSource;
|
||||
/** When set with `details`, render parameters for `spec.sources[index]` (multi-source create / edit). */
|
||||
multiSourceIndex?: number;
|
||||
}) => {
|
||||
const app = cloneDeep(props.application);
|
||||
const source = getAppDefaultSource(app); // For source field
|
||||
|
|
@ -252,20 +254,27 @@ export const ApplicationParameters = (props: {
|
|||
// Create App, Add source, Rollback and History
|
||||
let attributes: EditablePanelItem[] = [];
|
||||
if (props.details) {
|
||||
const ind = props.multiSourceIndex;
|
||||
const isMulti = ind !== undefined;
|
||||
const attrSource = isMulti ? app.spec.sources[ind] : props.tempSource ? props.tempSource : source;
|
||||
if (isMulti && !attrSource) {
|
||||
return null;
|
||||
}
|
||||
return getEditablePanel(
|
||||
gatherDetails(
|
||||
0,
|
||||
isMulti ? ind : 0,
|
||||
props.details,
|
||||
attributes,
|
||||
props.tempSource ? props.tempSource : source,
|
||||
attrSource,
|
||||
app,
|
||||
setRemovedOverrides,
|
||||
removedOverrides,
|
||||
appParamsDeletedState,
|
||||
setAppParamsDeletedState,
|
||||
false
|
||||
isMulti
|
||||
),
|
||||
props.details
|
||||
props.details,
|
||||
isMulti ? ind : undefined
|
||||
);
|
||||
} else {
|
||||
// For single source field, details page where we have to do the load to retrieve repo details
|
||||
|
|
@ -338,14 +347,20 @@ export const ApplicationParameters = (props: {
|
|||
);
|
||||
}
|
||||
|
||||
function getEditablePanel(items: EditablePanelItem[], repoAppDetails: models.RepoAppDetails): any {
|
||||
function getEditablePanel(items: EditablePanelItem[], repoAppDetails: models.RepoAppDetails, multiSourceIndex?: number): any {
|
||||
const ind = multiSourceIndex;
|
||||
const isMulti = ind !== undefined;
|
||||
const jsonnetTlas = isMulti ? `spec.sources[${ind}].directory.jsonnet.tlas` : 'spec.source.directory.jsonnet.tlas';
|
||||
const jsonnetExtVars = isMulti ? `spec.sources[${ind}].directory.jsonnet.extVars` : 'spec.source.directory.jsonnet.extVars';
|
||||
const helmValuesPath = isMulti ? `spec.sources[${ind}].helm.values` : 'spec.source.helm.values';
|
||||
|
||||
return (
|
||||
<div className='application-parameters'>
|
||||
<EditablePanel
|
||||
save={
|
||||
props.save &&
|
||||
(async (input: models.Application) => {
|
||||
const updatedSrc = input.spec.source;
|
||||
const updatedSrc = isMulti ? input.spec.sources[ind] : input.spec.source;
|
||||
|
||||
function isDefined(item: any) {
|
||||
return item !== null && item !== undefined;
|
||||
|
|
@ -360,7 +375,7 @@ export const ApplicationParameters = (props: {
|
|||
updatedSrc.kustomize.images = updatedSrc.kustomize.images.filter(isDefinedWithVersion);
|
||||
}
|
||||
|
||||
let params = input.spec?.source?.plugin?.parameters;
|
||||
let params = isMulti ? input.spec?.sources[ind]?.plugin?.parameters : input.spec?.source?.plugin?.parameters;
|
||||
if (params) {
|
||||
for (const param of params) {
|
||||
if (param.map && param.array) {
|
||||
|
|
@ -376,28 +391,37 @@ export const ApplicationParameters = (props: {
|
|||
}
|
||||
}
|
||||
params = params.filter(param => !appParamsDeletedState.includes(param.name));
|
||||
input.spec.source.plugin.parameters = params;
|
||||
if (isMulti) {
|
||||
const ms = input.spec.sources[ind];
|
||||
if (!ms.plugin) {
|
||||
ms.plugin = {name: '', env: [], parameters: []};
|
||||
}
|
||||
ms.plugin.parameters = params;
|
||||
} else {
|
||||
input.spec.source.plugin.parameters = params;
|
||||
}
|
||||
}
|
||||
if (input.spec.source && input.spec.source.helm?.valuesObject) {
|
||||
input.spec.source.helm.valuesObject = jsYaml.load(input.spec.source.helm.values); // Deserialize json
|
||||
input.spec.source.helm.values = '';
|
||||
if (updatedSrc && updatedSrc.helm?.valuesObject) {
|
||||
updatedSrc.helm.valuesObject = jsYaml.load(updatedSrc.helm.values); // Deserialize json
|
||||
updatedSrc.helm.values = '';
|
||||
}
|
||||
await props.save(input, {});
|
||||
setRemovedOverrides(new Array<boolean>());
|
||||
})
|
||||
}
|
||||
values={((repoAppDetails?.plugin || app?.spec?.source?.plugin) && cloneDeep(app)) || app}
|
||||
values={((repoAppDetails?.plugin || (isMulti ? app?.spec?.sources[ind]?.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']) {
|
||||
for (const fieldPath of [jsonnetTlas, jsonnetExtVars]) {
|
||||
const invalid = ((getNestedField(updatedApp, fieldPath) || []) as Array<models.JsonnetVar>).filter(item => !item.name && !item.code);
|
||||
errors[fieldPath] = invalid.length > 0 ? 'All fields must have name' : null;
|
||||
}
|
||||
|
||||
if (updatedApp.spec.source && updatedApp.spec.source.helm?.values) {
|
||||
const parsedValues = jsYaml.load(updatedApp.spec.source.helm.values);
|
||||
errors['spec.source.helm.values'] = typeof parsedValues === 'object' ? null : 'Values must be a map';
|
||||
const helmSrc = isMulti ? updatedApp.spec.sources[ind] : updatedApp.spec.source;
|
||||
if (helmSrc?.helm?.values) {
|
||||
const parsedValues = jsYaml.load(helmSrc.helm.values);
|
||||
errors[helmValuesPath] = typeof parsedValues === 'object' ? null : 'Values must be a map';
|
||||
}
|
||||
|
||||
return errors;
|
||||
|
|
|
|||
|
|
@ -7,17 +7,9 @@ import {RevisionFormField} from '../../../applications/components/revision-form-
|
|||
import {RevisionHelpIcon} from '../../../shared/components';
|
||||
import * as models from '../../../shared/models';
|
||||
import {services} from '../../../shared/services';
|
||||
import {APP_SOURCE_TYPES, normalizeTypeFieldsForSource} from '../shared/app-source-edit';
|
||||
import './source-panel.scss';
|
||||
|
||||
// This is similar to what is in application-create-panel.tsx. If the create panel
|
||||
// is modified to support multi-source apps, then we should refactor and common these up
|
||||
const appTypes = new Array<{field: string; type: models.AppSourceType}>(
|
||||
{type: 'Helm', field: 'helm'},
|
||||
{type: 'Kustomize', field: 'kustomize'},
|
||||
{type: 'Directory', field: 'directory'},
|
||||
{type: 'Plugin', field: 'plugin'}
|
||||
);
|
||||
|
||||
// This is similar to the same function in application-create-panel.tsx. If the create panel
|
||||
// is modified to support multi-source apps, then we should refactor and common these up
|
||||
function normalizeAppSource(app: models.Application, type: string): boolean {
|
||||
|
|
@ -75,16 +67,6 @@ export const SourcePanel = (props: {
|
|||
const [explicitPathType, setExplicitPathType] = React.useState<{path: string; type: models.AppSourceType}>(null);
|
||||
const appInEdit = deepMerge(DEFAULT_APP, {});
|
||||
|
||||
function normalizeTypeFields(formApi: FormApi, type: models.AppSourceType) {
|
||||
const appToNormalize = formApi.getFormState().values;
|
||||
for (const item of appTypes) {
|
||||
if (item.type !== type) {
|
||||
delete appToNormalize.spec.source[item.field];
|
||||
}
|
||||
}
|
||||
formApi.setAllValues(appToNormalize);
|
||||
}
|
||||
|
||||
return (
|
||||
<DataLoader key='add-new-source' load={() => Promise.all([services.repos.list()]).then(([reposInfo]) => ({reposInfo}))}>
|
||||
{({reposInfo}) => {
|
||||
|
|
@ -384,11 +366,11 @@ export const SourcePanel = (props: {
|
|||
{type} <i className='fa fa-caret-down' />
|
||||
</p>
|
||||
)}
|
||||
items={appTypes.map(item => ({
|
||||
items={APP_SOURCE_TYPES.map(item => ({
|
||||
title: item.type,
|
||||
action: () => {
|
||||
setExplicitPathType({type: item.type, path: appInEdit.spec?.source?.path});
|
||||
normalizeTypeFields(api, item.type);
|
||||
normalizeTypeFieldsForSource(api, item.type, undefined);
|
||||
}
|
||||
}))}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
@import 'node_modules/argo-ui/src/styles/theme';
|
||||
|
||||
.revision-form-field {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
@ -9,8 +11,46 @@
|
|||
min-width: 0;
|
||||
}
|
||||
|
||||
.revision-form-field .argo-field-container {
|
||||
display: flex;
|
||||
flex-direction: column-reverse;
|
||||
align-items: stretch;
|
||||
padding-top: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.revision-form-field .argo-field-container > .argo-field,
|
||||
.revision-form-field .argo-field-container > .argo-textarea {
|
||||
grid-area: unset;
|
||||
}
|
||||
|
||||
.revision-form-field .argo-field-container > label.argo-label-placeholder {
|
||||
position: static;
|
||||
grid-area: unset;
|
||||
align-self: flex-start;
|
||||
transform: none !important;
|
||||
font-size: 12px;
|
||||
line-height: 1.25;
|
||||
margin: 0 0 2px;
|
||||
padding: 0;
|
||||
pointer-events: none;
|
||||
@include themify($themes) {
|
||||
color: themed('text-2');
|
||||
}
|
||||
}
|
||||
|
||||
.revision-form-field .argo-field-container > .argo-field:focus + label.argo-label-placeholder,
|
||||
.revision-form-field .argo-field-container > .argo-textarea:focus + label.argo-label-placeholder {
|
||||
font-size: 12px;
|
||||
transform: none !important;
|
||||
@include themify($themes) {
|
||||
color: themed('light-argo-teal-7');
|
||||
}
|
||||
}
|
||||
|
||||
.revision-form-field__dropdown {
|
||||
align-self: center;
|
||||
align-self: flex-end;
|
||||
padding-bottom: 2px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
|
|||
38
ui/src/app/applications/components/shared/app-source-edit.ts
Normal file
38
ui/src/app/applications/components/shared/app-source-edit.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
import {FormApi} from 'react-form';
|
||||
import * as models from '../../../shared/models';
|
||||
|
||||
/** Shared with application-create-panel and application-parameters/source-panel. */
|
||||
export const APP_SOURCE_TYPES = new Array<{field: string; type: models.AppSourceType}>(
|
||||
{type: 'Helm', field: 'helm'},
|
||||
{type: 'Kustomize', field: 'kustomize'},
|
||||
{type: 'Directory', field: 'directory'},
|
||||
{type: 'Plugin', field: 'plugin'}
|
||||
);
|
||||
|
||||
/**
|
||||
* Clears sibling source-type blocks (helm/kustomize/directory/plugin) when the user picks a type.
|
||||
* @param sourceIndex — when set, edits `spec.sources[index]`; otherwise `spec.source`.
|
||||
*/
|
||||
export function normalizeTypeFieldsForSource(formApi: FormApi, type: models.AppSourceType, sourceIndex?: number): void {
|
||||
const appToNormalize = formApi.getFormState().values as models.Application;
|
||||
if (sourceIndex === undefined) {
|
||||
const single = appToNormalize.spec.source as unknown as Record<string, unknown>;
|
||||
for (const item of APP_SOURCE_TYPES) {
|
||||
if (item.type !== type) {
|
||||
delete single[item.field];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const src = appToNormalize.spec.sources[sourceIndex];
|
||||
if (!src) {
|
||||
return;
|
||||
}
|
||||
const srcRec = src as unknown as Record<string, unknown>;
|
||||
for (const item of APP_SOURCE_TYPES) {
|
||||
if (item.type !== type) {
|
||||
delete srcRec[item.field];
|
||||
}
|
||||
}
|
||||
}
|
||||
formApi.setAllValues(appToNormalize);
|
||||
}
|
||||
|
|
@ -348,7 +348,7 @@ export interface Info {
|
|||
|
||||
export interface ApplicationSpec {
|
||||
project: string;
|
||||
source: ApplicationSource;
|
||||
source?: ApplicationSource;
|
||||
sources: ApplicationSource[];
|
||||
sourceHydrator?: SourceHydrator;
|
||||
destination: ApplicationDestination;
|
||||
|
|
|
|||
Loading…
Reference in a new issue