github.com/argoproj/argo-cd/v3@v3.2.1/ui/src/app/applications/components/application-parameters/source-panel.tsx (about) 1 import {AutocompleteField, DataLoader, DropDownMenu, FormField} from 'argo-ui'; 2 import * as deepMerge from 'deepmerge'; 3 import * as React from 'react'; 4 import {Form, FormApi, FormErrors, Text} from 'react-form'; 5 import {ApplicationParameters} from '../../../applications/components/application-parameters/application-parameters'; 6 import {RevisionFormField} from '../../../applications/components/revision-form-field/revision-form-field'; 7 import {RevisionHelpIcon} from '../../../shared/components'; 8 import * as models from '../../../shared/models'; 9 import {services} from '../../../shared/services'; 10 import './source-panel.scss'; 11 12 // This is similar to what is in application-create-panel.tsx. If the create panel 13 // is modified to support multi-source apps, then we should refactor and common these up 14 const appTypes = new Array<{field: string; type: models.AppSourceType}>( 15 {type: 'Helm', field: 'helm'}, 16 {type: 'Kustomize', field: 'kustomize'}, 17 {type: 'Directory', field: 'directory'}, 18 {type: 'Plugin', field: 'plugin'} 19 ); 20 21 // This is similar to the same function in application-create-panel.tsx. If the create panel 22 // is modified to support multi-source apps, then we should refactor and common these up 23 function normalizeAppSource(app: models.Application, type: string): boolean { 24 const source = app.spec.source; 25 // eslint-disable-next-line no-prototype-builtins 26 const repoType = source.repoURL.startsWith('oci://') ? 'oci' : (source.hasOwnProperty('chart') && 'helm') || 'git'; 27 28 if (repoType !== type) { 29 if (type === 'git' || type === 'oci') { 30 source.path = source.chart; 31 delete source.chart; 32 source.targetRevision = 'HEAD'; 33 } else { 34 source.chart = source.path; 35 delete source.path; 36 source.targetRevision = ''; 37 } 38 return true; 39 } 40 return false; 41 } 42 43 // Use a single source app to represent the 'new source'. This panel will make use of the source field only. 44 // However, we need to use a template based on an Application so that we can reuse the application-parameters code 45 const DEFAULT_APP: Partial<models.Application> = { 46 apiVersion: 'argoproj.io/v1alpha1', 47 kind: 'Application', 48 metadata: { 49 name: '' 50 }, 51 spec: { 52 destination: { 53 name: '', 54 namespace: '', 55 server: '' 56 }, 57 source: { 58 path: '', 59 repoURL: '', 60 ref: '', 61 name: '', 62 targetRevision: 'HEAD' 63 }, 64 sources: [], 65 project: '' 66 } 67 }; 68 69 export const SourcePanel = (props: { 70 appCurrent: models.Application; 71 onSubmitFailure: (error: string) => any; 72 updateApp: (app: models.Application) => any; 73 getFormApi: (api: FormApi) => any; 74 }) => { 75 const [explicitPathType, setExplicitPathType] = React.useState<{path: string; type: models.AppSourceType}>(null); 76 const appInEdit = deepMerge(DEFAULT_APP, {}); 77 78 function normalizeTypeFields(formApi: FormApi, type: models.AppSourceType) { 79 const appToNormalize = formApi.getFormState().values; 80 for (const item of appTypes) { 81 if (item.type !== type) { 82 delete appToNormalize.spec.source[item.field]; 83 } 84 } 85 formApi.setAllValues(appToNormalize); 86 } 87 88 return ( 89 <DataLoader key='add-new-source' load={() => Promise.all([services.repos.list()]).then(([reposInfo]) => ({reposInfo}))}> 90 {({reposInfo}) => { 91 const repos = reposInfo.map(info => info.repo).sort(); 92 return ( 93 <div className='new-source-panel'> 94 <Form 95 validateError={(a: models.Application) => { 96 let samePath = false; 97 let sameChartVersion = false; 98 let pathError = null; 99 let chartError = null; 100 if (a.spec.source.repoURL && a.spec.source.path) { 101 props.appCurrent.spec.sources.forEach(source => { 102 if (source.repoURL === a.spec.source.repoURL && source.path === a.spec.source.path) { 103 samePath = true; 104 pathError = 'Provided path in the selected repository URL was already added to this multi-source application'; 105 } 106 }); 107 } 108 if (a.spec?.source?.repoURL && a.spec?.source?.chart) { 109 props.appCurrent.spec.sources.forEach(source => { 110 if ( 111 source?.repoURL === a.spec?.source?.repoURL && 112 source?.chart === a.spec?.source?.chart && 113 source?.targetRevision === a.spec?.source?.targetRevision 114 ) { 115 sameChartVersion = true; 116 chartError = 117 'Version ' + 118 source?.targetRevision + 119 ' of chart ' + 120 source?.chart + 121 ' from the selected repository was already added to this multi-source application'; 122 } 123 }); 124 } 125 if (!samePath) { 126 if (!a.spec?.source?.path && !a.spec?.source?.chart && !a.spec?.source?.ref) { 127 pathError = 'Path or Ref is required'; 128 } 129 } 130 if (!sameChartVersion) { 131 if (!a.spec?.source?.chart && !a.spec?.source?.path && !a.spec?.source?.ref) { 132 chartError = 'Chart is required'; 133 } 134 } 135 return { 136 'spec.source.repoURL': !a.spec?.source?.repoURL && 'Repository URL is required', 137 // eslint-disable-next-line no-prototype-builtins 138 'spec.source.targetRevision': !a.spec?.source?.targetRevision && a.spec?.source?.hasOwnProperty('chart') && 'Version is required', 139 'spec.source.path': pathError, 140 'spec.source.chart': chartError 141 }; 142 }} 143 defaultValues={appInEdit} 144 onSubmitFailure={(errors: FormErrors) => { 145 let errorString: string = ''; 146 let i = 0; 147 for (const key in errors) { 148 if (errors[key]) { 149 i++; 150 errorString = errorString.concat(i + '. ' + errors[key] + ' '); 151 } 152 } 153 props.onSubmitFailure(errorString); 154 }} 155 onSubmit={values => { 156 props.updateApp(values as models.Application); 157 }} 158 getApi={props.getFormApi}> 159 {api => { 160 const repoType = api.getFormState().values.spec?.source?.repoURL.startsWith('oci://') 161 ? 'oci' 162 : (api.getFormState().values.spec?.source?.chart && 'helm') || 'git'; 163 const repoInfo = reposInfo.find(info => info.repo === api.getFormState().values.spec?.source?.repoURL); 164 if (repoInfo) { 165 normalizeAppSource(appInEdit, repoInfo.type || 'git'); 166 } 167 const sourcePanel = () => ( 168 <div className='white-box'> 169 <p>SOURCE</p> 170 <div className='row argo-form-row'> 171 <div className='columns small-10'> 172 <FormField 173 formApi={api} 174 label='Repository URL' 175 field='spec.source.repoURL' 176 component={AutocompleteField} 177 componentProps={{items: repos}} 178 /> 179 </div> 180 <div className='columns small-2'> 181 <div style={{paddingTop: '1.5em'}}> 182 {(repoInfo && ( 183 <React.Fragment> 184 <span>{(repoInfo.type || 'git').toUpperCase()}</span> <i className='fa fa-check' /> 185 </React.Fragment> 186 )) || ( 187 <DropDownMenu 188 anchor={() => ( 189 <p> 190 {repoType.toUpperCase()} <i className='fa fa-caret-down' /> 191 </p> 192 )} 193 items={['git', 'helm', 'oci'].map((type: 'git' | 'helm' | 'oci') => ({ 194 title: type.toUpperCase(), 195 action: () => { 196 if (repoType !== type) { 197 const updatedApp = api.getFormState().values as models.Application; 198 if (normalizeAppSource(updatedApp, type)) { 199 api.setAllValues(updatedApp); 200 } 201 } 202 } 203 }))} 204 /> 205 )} 206 </div> 207 </div> 208 </div> 209 <div className='row argo-form-row'> 210 <div className='columns small-10'> 211 <FormField formApi={api} label='Name' field={'spec.source.name'} component={Text}></FormField> 212 </div> 213 </div> 214 {(repoType === 'oci' && ( 215 <React.Fragment> 216 <RevisionFormField 217 formApi={api} 218 helpIconTop={'2.5em'} 219 repoURL={api.getFormState().values.spec?.source?.repoURL} 220 repoType={repoType} 221 /> 222 <div className='argo-form-row'> 223 <DataLoader 224 input={{ 225 repoURL: api.getFormState().values.spec?.source?.repoURL, 226 revision: api.getFormState().values.spec?.source?.targetRevision 227 }} 228 load={async src => 229 src.repoURL && 230 // 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 231 new Array<string>() 232 }> 233 {(paths: string[]) => ( 234 <FormField 235 formApi={api} 236 label='Path' 237 field='spec.source.path' 238 component={AutocompleteField} 239 componentProps={{ 240 items: paths, 241 filterSuggestions: true 242 }} 243 /> 244 )} 245 </DataLoader> 246 </div> 247 <div className='argo-form-row'> 248 <FormField formApi={api} label='Ref' field={'spec.source.ref'} component={Text}></FormField> 249 </div> 250 </React.Fragment> 251 )) || 252 (repoType === 'git' && ( 253 <React.Fragment> 254 <RevisionFormField 255 formApi={api} 256 helpIconTop={'2.5em'} 257 repoURL={api.getFormState().values.spec?.source?.repoURL} 258 repoType={repoType} 259 /> 260 <div className='argo-form-row'> 261 <DataLoader 262 input={{ 263 repoURL: api.getFormState().values.spec?.source?.repoURL, 264 revision: api.getFormState().values.spec?.source?.targetRevision 265 }} 266 load={async src => 267 (src.repoURL && 268 (await services.repos 269 .apps(src.repoURL, src.revision, appInEdit.metadata.name, props.appCurrent.spec.project) 270 .then(apps => Array.from(new Set(apps.map(item => item.path))).sort()) 271 .catch(() => new Array<string>()))) || 272 new Array<string>() 273 }> 274 {(apps: string[]) => ( 275 <FormField 276 formApi={api} 277 label='Path' 278 field='spec.source.path' 279 component={AutocompleteField} 280 componentProps={{ 281 items: apps, 282 filterSuggestions: true 283 }} 284 /> 285 )} 286 </DataLoader> 287 </div> 288 <div className='argo-form-row'> 289 <FormField formApi={api} label='Ref' field={'spec.source.ref'} component={Text}></FormField> 290 </div> 291 </React.Fragment> 292 )) || ( 293 <DataLoader 294 input={{repoURL: api.getFormState().values.spec.source.repoURL}} 295 load={async src => 296 (src.repoURL && services.repos.charts(src.repoURL).catch(() => new Array<models.HelmChart>())) || 297 new Array<models.HelmChart>() 298 }> 299 {(charts: models.HelmChart[]) => { 300 const selectedChart = charts.find(chart => chart.name === api.getFormState().values.spec?.source?.chart); 301 return ( 302 <div className='row argo-form-row'> 303 <div className='columns small-10'> 304 <FormField 305 formApi={api} 306 label='Chart' 307 field='spec.source.chart' 308 component={AutocompleteField} 309 componentProps={{ 310 items: charts.map(chart => chart.name), 311 filterSuggestions: true 312 }} 313 /> 314 </div> 315 <div className='columns small-2'> 316 <FormField 317 formApi={api} 318 field='spec.source.targetRevision' 319 component={AutocompleteField} 320 componentProps={{ 321 items: (selectedChart && selectedChart.versions) || [] 322 }} 323 /> 324 <RevisionHelpIcon type='helm' /> 325 </div> 326 </div> 327 ); 328 }} 329 </DataLoader> 330 )} 331 </div> 332 ); 333 334 const typePanel = () => ( 335 <DataLoader 336 input={{ 337 repoURL: appInEdit.spec?.source?.repoURL, 338 path: appInEdit.spec?.source?.path, 339 chart: appInEdit.spec?.source?.chart, 340 targetRevision: appInEdit.spec?.source?.targetRevision, 341 appName: appInEdit.metadata.name 342 }} 343 load={async src => { 344 if (src?.repoURL && src?.targetRevision && (src?.path || src?.chart)) { 345 return services.repos.appDetails(src, src?.appName, props.appCurrent.spec?.project, 0, 0).catch(() => ({ 346 type: 'Directory', 347 details: {} 348 })); 349 } else { 350 return { 351 type: 'Directory', 352 details: {} 353 }; 354 } 355 }}> 356 {(details: models.RepoAppDetails) => { 357 const type = (explicitPathType && explicitPathType.path === appInEdit.spec?.source?.path && explicitPathType.type) || details.type; 358 if (details.type !== type) { 359 switch (type) { 360 case 'Helm': 361 details = { 362 type, 363 path: details.path, 364 helm: {name: '', valueFiles: [], path: '', parameters: [], fileParameters: []} 365 }; 366 break; 367 case 'Kustomize': 368 details = {type, path: details.path, kustomize: {path: ''}}; 369 break; 370 case 'Plugin': 371 details = {type, path: details.path, plugin: {name: '', env: []}}; 372 break; 373 // Directory 374 default: 375 details = {type, path: details.path, directory: {}}; 376 break; 377 } 378 } 379 return ( 380 <React.Fragment> 381 <DropDownMenu 382 anchor={() => ( 383 <p> 384 {type} <i className='fa fa-caret-down' /> 385 </p> 386 )} 387 items={appTypes.map(item => ({ 388 title: item.type, 389 action: () => { 390 setExplicitPathType({type: item.type, path: appInEdit.spec?.source?.path}); 391 normalizeTypeFields(api, item.type); 392 } 393 }))} 394 /> 395 <ApplicationParameters 396 noReadonlyMode={true} 397 application={api.getFormState().values as models.Application} 398 details={details} 399 tempSource={appInEdit.spec.source} 400 save={async updatedApp => { 401 api.setAllValues(updatedApp); 402 }} 403 /> 404 </React.Fragment> 405 ); 406 }} 407 </DataLoader> 408 ); 409 410 return ( 411 <form onSubmit={api.submitForm} role='form' className='width-control'> 412 {sourcePanel()} 413 414 {typePanel()} 415 </form> 416 ); 417 }} 418 </Form> 419 </div> 420 ); 421 }} 422 </DataLoader> 423 ); 424 };