github.com/argoproj/argo-cd/v3@v3.2.1/ui/src/app/applications/components/application-create-panel/application-create-panel.tsx (about) 1 /* eslint-disable no-prototype-builtins */ 2 import {AutocompleteField, Checkbox, DataLoader, DropDownMenu, FormField, HelpIcon, Select} from 'argo-ui'; 3 import * as deepMerge from 'deepmerge'; 4 import * as React from 'react'; 5 import {FieldApi, Form, FormApi, FormField as ReactFormField, Text} from 'react-form'; 6 import {RevisionHelpIcon, YamlEditor} from '../../../shared/components'; 7 import * as models from '../../../shared/models'; 8 import {services} from '../../../shared/services'; 9 import {ApplicationParameters} from '../application-parameters/application-parameters'; 10 import {ApplicationRetryOptions} from '../application-retry-options/application-retry-options'; 11 import {ApplicationSyncOptionsField} from '../application-sync-options/application-sync-options'; 12 import {RevisionFormField} from '../revision-form-field/revision-form-field'; 13 import {SetFinalizerOnApplication} from './set-finalizer-on-application'; 14 import './application-create-panel.scss'; 15 import {getAppDefaultSource} from '../utils'; 16 import {debounce} from 'lodash-es'; 17 18 const jsonMergePatch = require('json-merge-patch'); 19 20 const appTypes = new Array<{field: string; type: models.AppSourceType}>( 21 {type: 'Helm', field: 'helm'}, 22 {type: 'Kustomize', field: 'kustomize'}, 23 {type: 'Directory', field: 'directory'}, 24 {type: 'Plugin', field: 'plugin'} 25 ); 26 27 const DEFAULT_APP: Partial<models.Application> = { 28 apiVersion: 'argoproj.io/v1alpha1', 29 kind: 'Application', 30 metadata: { 31 name: '' 32 }, 33 spec: { 34 destination: { 35 name: undefined, 36 namespace: '', 37 server: undefined 38 }, 39 source: { 40 path: '', 41 repoURL: '', 42 targetRevision: 'HEAD' 43 }, 44 sources: [], 45 project: '' 46 } 47 }; 48 49 const AutoSyncFormField = ReactFormField((props: {fieldApi: FieldApi; className: string}) => { 50 const manual = 'Manual'; 51 const auto = 'Automatic'; 52 const { 53 fieldApi: {getValue, setValue} 54 } = props; 55 const automated = getValue() as models.Automated; 56 return ( 57 <React.Fragment> 58 <label>Sync Policy</label> 59 <Select 60 value={automated ? auto : manual} 61 options={[manual, auto]} 62 onChange={opt => { 63 setValue(opt.value === auto ? {prune: false, selfHeal: false, enabled: true} : null); 64 }} 65 /> 66 {automated && ( 67 <div className='application-create-panel__sync-params'> 68 <div className='checkbox-container'> 69 <Checkbox onChange={val => setValue({...automated, enabled: val})} checked={automated.enabled === undefined ? true : automated.enabled} id='policyEnable' /> 70 <label htmlFor='policyEnable'>Enable Auto-Sync</label> 71 <HelpIcon title='If checked, application will automatically sync when changes are detected' /> 72 </div> 73 <div className='checkbox-container'> 74 <Checkbox onChange={val => setValue({...automated, prune: val})} checked={!!automated.prune} id='policyPrune' /> 75 <label htmlFor='policyPrune'>Prune Resources</label> 76 <HelpIcon title='If checked, Argo will delete resources if they are no longer defined in Git' /> 77 </div> 78 <div className='checkbox-container'> 79 <Checkbox onChange={val => setValue({...automated, selfHeal: val})} checked={!!automated.selfHeal} id='policySelfHeal' /> 80 <label htmlFor='policySelfHeal'>Self Heal</label> 81 <HelpIcon title='If checked, Argo will force the state defined in Git into the cluster when a deviation in the cluster is detected' /> 82 </div> 83 </div> 84 )} 85 </React.Fragment> 86 ); 87 }); 88 89 function normalizeAppSource(app: models.Application, type: string): boolean { 90 const source = getAppDefaultSource(app); 91 const repoType = source.repoURL.startsWith('oci://') ? 'oci' : (source.hasOwnProperty('chart') && 'helm') || 'git'; 92 if (repoType !== type) { 93 if (type === 'git' || type === 'oci') { 94 source.path = source.chart; 95 delete source.chart; 96 source.targetRevision = 'HEAD'; 97 } else { 98 source.chart = source.path; 99 delete source.path; 100 source.targetRevision = ''; 101 } 102 return true; 103 } 104 return false; 105 } 106 107 export const ApplicationCreatePanel = (props: { 108 app: models.Application; 109 onAppChanged: (app: models.Application) => any; 110 createApp: (app: models.Application) => any; 111 getFormApi: (api: FormApi) => any; 112 }) => { 113 const [yamlMode, setYamlMode] = React.useState(false); 114 const [explicitPathType, setExplicitPathType] = React.useState<{path: string; type: models.AppSourceType}>(null); 115 const [retry, setRetry] = React.useState(false); 116 const app = deepMerge(DEFAULT_APP, props.app || {}); 117 const debouncedOnAppChanged = debounce(props.onAppChanged, 800); 118 const [destinationFieldChanges, setDestinationFieldChanges] = React.useState({destFormat: 'URL', destFormatChanged: null}); 119 const comboSwitchedFromPanel = React.useRef(false); 120 const currentRepoType = React.useRef(undefined); 121 const lastGitOrHelmUrl = React.useRef(''); 122 const lastOciUrl = React.useRef(''); 123 let destinationComboValue = destinationFieldChanges.destFormat; 124 125 React.useEffect(() => { 126 comboSwitchedFromPanel.current = false; 127 }, []); 128 129 React.useEffect(() => { 130 return () => { 131 debouncedOnAppChanged.cancel(); 132 }; 133 }, [debouncedOnAppChanged]); 134 135 function normalizeTypeFields(formApi: FormApi, type: models.AppSourceType) { 136 const appToNormalize = formApi.getFormState().values; 137 for (const item of appTypes) { 138 if (item.type !== type) { 139 delete appToNormalize.spec.source[item.field]; 140 } 141 } 142 formApi.setAllValues(appToNormalize); 143 } 144 145 const currentName = app.spec.destination.name; 146 const currentServer = app.spec.destination.server; 147 if (destinationFieldChanges.destFormatChanged !== null) { 148 if (destinationComboValue == 'NAME') { 149 if (currentName === undefined && currentServer !== undefined && comboSwitchedFromPanel.current === false) { 150 destinationComboValue = 'URL'; 151 } else { 152 delete app.spec.destination.server; 153 if (currentName === undefined) { 154 app.spec.destination.name = ''; 155 } 156 } 157 } else { 158 if (currentServer === undefined && currentName !== undefined && comboSwitchedFromPanel.current === false) { 159 destinationComboValue = 'NAME'; 160 } else { 161 delete app.spec.destination.name; 162 if (currentServer === undefined) { 163 app.spec.destination.server = ''; 164 } 165 } 166 } 167 } else { 168 if (currentName === undefined && currentServer === undefined) { 169 destinationComboValue = destinationFieldChanges.destFormat; 170 app.spec.destination.server = ''; 171 } else { 172 if (currentName != undefined) { 173 destinationComboValue = 'NAME'; 174 } else { 175 destinationComboValue = 'URL'; 176 } 177 } 178 } 179 180 const onCreateApp = (data: models.Application) => { 181 if (destinationComboValue === 'URL') { 182 delete data.spec.destination.name; 183 } else { 184 delete data.spec.destination.server; 185 } 186 187 props.createApp(data); 188 }; 189 190 return ( 191 <DataLoader 192 key='creation-deps' 193 load={() => 194 Promise.all([ 195 services.projects.list('items.metadata.name').then(projects => projects.map(proj => proj.metadata.name).sort()), 196 services.clusters.list().then(clusters => clusters.sort()), 197 services.repos.list() 198 ]).then(([projects, clusters, reposInfo]) => ({projects, clusters, reposInfo})) 199 }> 200 {({projects, clusters, reposInfo}) => { 201 const repos = reposInfo.map(info => info.repo).sort(); 202 const repoInfo = reposInfo.find(info => info.repo === app.spec.source.repoURL); 203 if (repoInfo) { 204 normalizeAppSource(app, repoInfo.type || currentRepoType.current || 'git'); 205 } 206 return ( 207 <div className='application-create-panel'> 208 {(yamlMode && ( 209 <YamlEditor 210 minHeight={800} 211 initialEditMode={true} 212 input={app} 213 onCancel={() => setYamlMode(false)} 214 onSave={async patch => { 215 props.onAppChanged(jsonMergePatch.apply(app, JSON.parse(patch))); 216 setYamlMode(false); 217 return true; 218 }} 219 /> 220 )) || ( 221 <Form 222 validateError={(a: models.Application) => ({ 223 'metadata.name': !a.metadata.name && 'Application Name is required', 224 'spec.project': !a.spec.project && 'Project Name is required', 225 'spec.source.repoURL': !a.spec.source.repoURL && 'Repository URL is required', 226 'spec.source.targetRevision': !a.spec.source.targetRevision && a.spec.source.hasOwnProperty('chart') && 'Version is required', 227 'spec.source.path': !a.spec.source.path && !a.spec.source.chart && 'Path is required', 228 'spec.source.chart': !a.spec.source.path && !a.spec.source.chart && 'Chart is required', 229 // Verify cluster URL when there is no cluster name field or the name value is empty 230 'spec.destination.server': 231 !a.spec.destination.server && (!a.spec.destination.hasOwnProperty('name') || a.spec.destination.name === '') && 'Cluster URL is required', 232 // Verify cluster name when there is no cluster URL field or the URL value is empty 233 'spec.destination.name': 234 !a.spec.destination.name && (!a.spec.destination.hasOwnProperty('server') || a.spec.destination.server === '') && 'Cluster name is required' 235 })} 236 defaultValues={app} 237 formDidUpdate={state => debouncedOnAppChanged(state.values as any)} 238 onSubmit={onCreateApp} 239 getApi={props.getFormApi}> 240 {api => { 241 const generalPanel = () => ( 242 <div className='white-box'> 243 <p>GENERAL</p> 244 {/* 245 Need to specify "type='button'" because the default type 'submit' 246 will activate yaml mode whenever enter is pressed while in the panel. 247 This causes problems with some entry fields that require enter to be 248 pressed for the value to be accepted. 249 250 See https://github.com/argoproj/argo-cd/issues/4576 251 */} 252 {!yamlMode && ( 253 <button 254 type='button' 255 className='argo-button argo-button--base application-create-panel__yaml-button' 256 onClick={() => setYamlMode(true)}> 257 Edit as YAML 258 </button> 259 )} 260 <div className='argo-form-row'> 261 <FormField formApi={api} label='Application Name' qeId='application-create-field-app-name' field='metadata.name' component={Text} /> 262 </div> 263 <div className='argo-form-row'> 264 <FormField 265 formApi={api} 266 label='Project Name' 267 qeId='application-create-field-project' 268 field='spec.project' 269 component={AutocompleteField} 270 componentProps={{ 271 items: projects, 272 filterSuggestions: true 273 }} 274 /> 275 </div> 276 <div className='argo-form-row'> 277 <FormField 278 formApi={api} 279 field='spec.syncPolicy.automated' 280 qeId='application-create-field-sync-policy' 281 component={AutoSyncFormField} 282 /> 283 </div> 284 <div className='argo-form-row'> 285 <FormField formApi={api} field='metadata.finalizers' component={SetFinalizerOnApplication} /> 286 </div> 287 <div className='argo-form-row'> 288 <label>Sync Options</label> 289 <FormField formApi={api} field='spec.syncPolicy.syncOptions' component={ApplicationSyncOptionsField} /> 290 <ApplicationRetryOptions 291 formApi={api} 292 field='spec.syncPolicy.retry' 293 retry={retry || (api.getFormState().values.spec.syncPolicy && api.getFormState().values.spec.syncPolicy.retry)} 294 setRetry={setRetry} 295 initValues={api.getFormState().values.spec.syncPolicy ? api.getFormState().values.spec.syncPolicy.retry : null} 296 /> 297 </div> 298 </div> 299 ); 300 301 const repoType = api.getFormState().values.spec.source.repoURL.startsWith('oci://') 302 ? 'oci' 303 : (api.getFormState().values.spec.source.hasOwnProperty('chart') && 'helm') || 'git'; 304 const sourcePanel = () => ( 305 <div className='white-box'> 306 <p>SOURCE</p> 307 <div className='row argo-form-row'> 308 <div className='columns small-10'> 309 <FormField 310 formApi={api} 311 label='Repository URL' 312 qeId='application-create-field-repository-url' 313 field='spec.source.repoURL' 314 component={AutocompleteField} 315 componentProps={{ 316 items: repos, 317 filterSuggestions: true 318 }} 319 /> 320 </div> 321 <div className='columns small-2'> 322 <div style={{paddingTop: '1.5em'}}> 323 {(repoInfo && ( 324 <React.Fragment> 325 <span>{(repoInfo.type || 'git').toUpperCase()}</span> <i className='fa fa-check' /> 326 </React.Fragment> 327 )) || ( 328 <DropDownMenu 329 anchor={() => ( 330 <p> 331 {repoType.toUpperCase()} <i className='fa fa-caret-down' /> 332 </p> 333 )} 334 qeId='application-create-dropdown-source-repository' 335 items={['git', 'helm', 'oci'].map((type: 'git' | 'helm' | 'oci') => ({ 336 title: type.toUpperCase(), 337 action: () => { 338 if (repoType !== type) { 339 const updatedApp = api.getFormState().values as models.Application; 340 const source = getAppDefaultSource(updatedApp); 341 // Save the previous URL value for later use 342 if (repoType === 'git' || repoType === 'helm') { 343 lastGitOrHelmUrl.current = source.repoURL; 344 } else { 345 lastOciUrl.current = source.repoURL; 346 } 347 currentRepoType.current = type; 348 switch (type) { 349 case 'git': 350 case 'oci': 351 if (source.hasOwnProperty('chart')) { 352 source.path = source.chart; 353 delete source.chart; 354 } 355 source.targetRevision = 'HEAD'; 356 source.repoURL = 357 type === 'git' 358 ? lastGitOrHelmUrl.current 359 : lastOciUrl.current === '' 360 ? 'oci://' 361 : lastOciUrl.current; 362 break; 363 case 'helm': 364 if (source.hasOwnProperty('path')) { 365 source.chart = source.path; 366 delete source.path; 367 } 368 source.targetRevision = ''; 369 source.repoURL = lastGitOrHelmUrl.current; 370 break; 371 } 372 api.setAllValues(updatedApp); 373 } 374 } 375 }))} 376 /> 377 )} 378 </div> 379 </div> 380 </div> 381 {(repoType === 'oci' && ( 382 <React.Fragment> 383 <RevisionFormField formApi={api} helpIconTop={'2.5em'} repoURL={app.spec.source.repoURL} repoType={repoType} /> 384 <div className='argo-form-row'> 385 <DataLoader 386 input={{repoURL: app.spec.source.repoURL, revision: app.spec.source.targetRevision}} 387 load={async src => 388 src.repoURL && 389 // 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 390 new Array<string>() 391 }> 392 {(paths: string[]) => ( 393 <FormField 394 formApi={api} 395 label='Path' 396 qeId='application-create-field-path' 397 field='spec.source.path' 398 component={AutocompleteField} 399 componentProps={{ 400 items: paths, 401 filterSuggestions: true 402 }} 403 /> 404 )} 405 </DataLoader> 406 </div> 407 </React.Fragment> 408 )) || 409 (repoType === 'git' && ( 410 <React.Fragment> 411 <RevisionFormField formApi={api} helpIconTop={'2.5em'} repoURL={app.spec.source.repoURL} repoType={repoType} /> 412 <div className='argo-form-row'> 413 <DataLoader 414 input={{repoURL: app.spec.source.repoURL, revision: app.spec.source.targetRevision}} 415 load={async src => 416 (src.repoURL && 417 services.repos 418 .apps(src.repoURL, src.revision, app.metadata.name, app.spec.project) 419 .then(apps => Array.from(new Set(apps.map(item => item.path))).sort()) 420 .catch(() => new Array<string>())) || 421 new Array<string>() 422 }> 423 {(apps: string[]) => ( 424 <FormField 425 formApi={api} 426 label='Path' 427 qeId='application-create-field-path' 428 field='spec.source.path' 429 component={AutocompleteField} 430 componentProps={{ 431 items: apps, 432 filterSuggestions: true 433 }} 434 /> 435 )} 436 </DataLoader> 437 </div> 438 </React.Fragment> 439 )) || ( 440 <DataLoader 441 input={{repoURL: app.spec.source.repoURL}} 442 load={async src => 443 (src.repoURL && services.repos.charts(src.repoURL).catch(() => new Array<models.HelmChart>())) || 444 new Array<models.HelmChart>() 445 }> 446 {(charts: models.HelmChart[]) => { 447 const selectedChart = charts.find(chart => chart.name === api.getFormState().values.spec.source.chart); 448 return ( 449 <div className='row argo-form-row'> 450 <div className='columns small-10'> 451 <FormField 452 formApi={api} 453 label='Chart' 454 field='spec.source.chart' 455 component={AutocompleteField} 456 componentProps={{ 457 items: charts.map(chart => chart.name), 458 filterSuggestions: true 459 }} 460 /> 461 </div> 462 <div className='columns small-2'> 463 <FormField 464 formApi={api} 465 field='spec.source.targetRevision' 466 component={AutocompleteField} 467 componentProps={{ 468 items: (selectedChart && selectedChart.versions) || [], 469 filterSuggestions: true 470 }} 471 /> 472 <RevisionHelpIcon type='helm' /> 473 </div> 474 </div> 475 ); 476 }} 477 </DataLoader> 478 )} 479 </div> 480 ); 481 const destinationPanel = () => ( 482 <div className='white-box'> 483 <p>DESTINATION</p> 484 <div className='row argo-form-row'> 485 {(destinationComboValue.toUpperCase() === 'URL' && ( 486 <div className='columns small-10'> 487 <FormField 488 formApi={api} 489 label='Cluster URL' 490 qeId='application-create-field-cluster-url' 491 field='spec.destination.server' 492 componentProps={{ 493 items: clusters.map(cluster => cluster.server), 494 filterSuggestions: true 495 }} 496 component={AutocompleteField} 497 /> 498 </div> 499 )) || ( 500 <div className='columns small-10'> 501 <FormField 502 formApi={api} 503 label='Cluster Name' 504 qeId='application-create-field-cluster-name' 505 field='spec.destination.name' 506 componentProps={{ 507 items: clusters.map(cluster => cluster.name), 508 filterSuggestions: true 509 }} 510 component={AutocompleteField} 511 /> 512 </div> 513 )} 514 <div className='columns small-2'> 515 <div style={{paddingTop: '1.5em'}}> 516 <DropDownMenu 517 anchor={() => ( 518 <p> 519 {destinationComboValue} <i className='fa fa-caret-down' /> 520 </p> 521 )} 522 qeId='application-create-dropdown-destination' 523 items={['URL', 'NAME'].map((type: 'URL' | 'NAME') => ({ 524 title: type, 525 action: () => { 526 if (destinationComboValue !== type) { 527 destinationComboValue = type; 528 comboSwitchedFromPanel.current = true; 529 setDestinationFieldChanges({destFormat: type, destFormatChanged: 'changed'}); 530 } 531 } 532 }))} 533 /> 534 </div> 535 </div> 536 </div> 537 <div className='argo-form-row'> 538 <FormField 539 qeId='application-create-field-namespace' 540 formApi={api} 541 label='Namespace' 542 field='spec.destination.namespace' 543 component={Text} 544 /> 545 </div> 546 </div> 547 ); 548 549 const typePanel = () => ( 550 <DataLoader 551 input={{ 552 repoURL: app.spec.source.repoURL, 553 path: app.spec.source.path, 554 chart: app.spec.source.chart, 555 targetRevision: app.spec.source.targetRevision, 556 appName: app.metadata.name 557 }} 558 load={async src => { 559 if (src.repoURL && src.targetRevision && (src.path || src.chart)) { 560 return services.repos.appDetails(src, src.appName, app.spec.project, 0, 0).catch(() => ({ 561 type: 'Directory', 562 details: {} 563 })); 564 } else { 565 return { 566 type: 'Directory', 567 details: {} 568 }; 569 } 570 }}> 571 {(details: models.RepoAppDetails) => { 572 const type = (explicitPathType && explicitPathType.path === app.spec.source.path && explicitPathType.type) || details.type; 573 if (details.type !== type) { 574 switch (type) { 575 case 'Helm': 576 details = { 577 type, 578 path: details.path, 579 helm: {name: '', valueFiles: [], path: '', parameters: [], fileParameters: []} 580 }; 581 break; 582 case 'Kustomize': 583 details = {type, path: details.path, kustomize: {path: ''}}; 584 break; 585 case 'Plugin': 586 details = {type, path: details.path, plugin: {name: '', env: []}}; 587 break; 588 // Directory 589 default: 590 details = {type, path: details.path, directory: {}}; 591 break; 592 } 593 } 594 return ( 595 <React.Fragment> 596 <DropDownMenu 597 anchor={() => ( 598 <p> 599 {type} <i className='fa fa-caret-down' /> 600 </p> 601 )} 602 qeId='application-create-dropdown-source' 603 items={appTypes.map(item => ({ 604 title: item.type, 605 action: () => { 606 setExplicitPathType({type: item.type, path: app.spec.source.path}); 607 normalizeTypeFields(api, item.type); 608 } 609 }))} 610 /> 611 <ApplicationParameters 612 noReadonlyMode={true} 613 application={app} 614 details={details} 615 save={async updatedApp => { 616 api.setAllValues(updatedApp); 617 }} 618 /> 619 </React.Fragment> 620 ); 621 }} 622 </DataLoader> 623 ); 624 625 return ( 626 <form onSubmit={api.submitForm} role='form' className='width-control'> 627 {generalPanel()} 628 629 {sourcePanel()} 630 631 {destinationPanel()} 632 633 {typePanel()} 634 </form> 635 ); 636 }} 637 </Form> 638 )} 639 </div> 640 ); 641 }} 642 </DataLoader> 643 ); 644 };