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