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