WizardOptions.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528
  1. /*
  2. Copyright (C) 2017 Cloudbase Solutions SRL
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <http://www.gnu.org/licenses/>.
  13. */
  14. import * as React from 'react'
  15. import styled from 'styled-components'
  16. import { observer } from 'mobx-react'
  17. import { toJS } from 'mobx'
  18. import autobind from 'autobind-decorator'
  19. import { CSSTransitionGroup } from 'react-transition-group'
  20. import configLoader from '../../../../utils/Config'
  21. import ToggleButtonBar from '../../../ui/ToggleButtonBar/ToggleButtonBar'
  22. import FieldInput from '../../../ui/FieldInput/FieldInput'
  23. import StatusImage from '../../../ui/StatusComponents/StatusImage/StatusImage'
  24. import type { Field } from '../../../../@types/Field'
  25. import type { Instance } from '../../../../@types/Instance'
  26. import type { StorageBackend } from '../../../../@types/Endpoint'
  27. import { executionOptions, migrationFields } from '../../../../constants'
  28. import LabelDictionary from '../../../../utils/LabelDictionary'
  29. import { ThemePalette, ThemeProps } from '../../../Theme'
  30. import endpointImage from './images/endpoint.svg'
  31. import { MinionPool } from '../../../../@types/MinionPool'
  32. import { MinionPoolStoreUtils } from '../../../../stores/MinionPoolStore'
  33. export const INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS = 'instance_osmorphing_minion_pool_mappings'
  34. const Wrapper = styled.div<any>`
  35. display: flex;
  36. min-height: 0;
  37. flex-direction: column;
  38. width: 100%;
  39. `
  40. const Options = styled.div<any>`
  41. display: flex;
  42. flex-direction: column;
  43. min-height: 0;
  44. `
  45. const Fields = styled.div<any>`
  46. ${props => (props.padding ? `padding: ${props.padding}px;` : '')}
  47. display: flex;
  48. flex-direction: column;
  49. overflow: auto;
  50. `
  51. const Group = styled.div<any>`
  52. display: flex;
  53. flex-direction: column;
  54. flex-shrink: 0;
  55. &.field-group-transition-appear {
  56. opacity: 0.01;
  57. }
  58. &.field-group-transition-appear-active {
  59. opacity: 1;
  60. transition: opacity 250ms ease-out;
  61. }
  62. `
  63. const GroupName = styled.div<any>`
  64. display: flex;
  65. align-items: center;
  66. margin: 48px 0 24px 0;
  67. `
  68. const GroupNameText = styled.div<any>`
  69. margin: 0 32px;
  70. font-size: 16px;
  71. `
  72. const GroupNameBar = styled.div<any>`
  73. flex-grow: 1;
  74. background: ${ThemePalette.grayscale[3]};
  75. height: 1px;
  76. `
  77. const GroupFields = styled.div<any>`
  78. display: flex;
  79. justify-content: space-between;
  80. `
  81. const Column = styled.div<any>`
  82. margin-top: -16px;
  83. `
  84. const FieldInputStyled = styled(FieldInput)`
  85. width: ${props => props.width || ThemeProps.inputSizes.wizard.width}px;
  86. justify-content: space-between;
  87. margin-top: 16px;
  88. `
  89. const LoadingWrapper = styled.div<any>`
  90. margin-top: 32px;
  91. display: flex;
  92. flex-direction: column;
  93. align-items: center;
  94. `
  95. const LoadingText = styled.div<any>`
  96. margin-top: 38px;
  97. font-size: 18px;
  98. `
  99. const EndpointImage = styled.div<any>`
  100. ${ThemeProps.exactSize('96px')};
  101. background: url('${endpointImage}') center no-repeat;
  102. `
  103. const NoSourceFieldsWrapper = styled.div<any>`
  104. margin-top: 16px;
  105. display: flex;
  106. flex-direction: column;
  107. align-items: center;
  108. `
  109. const NoSourceFieldsMessage = styled.div<any>`
  110. font-size: 18px;
  111. margin-top: 16px;
  112. `
  113. const NoSourceFieldsSubMessage = styled.div<any>`
  114. margin-top: 16px;
  115. color: ${ThemePalette.grayscale[4]};
  116. `
  117. export const shouldRenderField = (field: Field) => (field.type !== 'array' || (field.enum && field.enum.length && field.enum.length > 0))
  118. && (field.type !== 'object' || field.properties)
  119. type FieldRender = {
  120. field: Field,
  121. component: React.ReactNode,
  122. column: number,
  123. }
  124. type Props = {
  125. fields: Field[],
  126. minionPools: MinionPool[]
  127. isSource?: boolean,
  128. selectedInstances?: Instance[] | null,
  129. showSeparatePerVm?: boolean
  130. data?: { [prop: string]: any } | null,
  131. getFieldValue?: (
  132. fieldName: string,
  133. defaultValue: any,
  134. parentFieldName: string | undefined
  135. ) => any,
  136. onChange: (field: Field, value: any, parentFieldName?: string) => void,
  137. useAdvancedOptions?: boolean,
  138. hasStorageMap: boolean,
  139. storageBackends?: StorageBackend[],
  140. onAdvancedOptionsToggle?: (showAdvanced: boolean) => void,
  141. wizardType: string,
  142. oneColumnStyle?: { [prop: string]: any },
  143. fieldWidth?: number,
  144. onScrollableRef?: (ref: HTMLElement) => void,
  145. availableHeight?: number,
  146. layout?: 'page' | 'modal',
  147. loading?: boolean,
  148. optionsLoading?: boolean,
  149. optionsLoadingSkipFields?: string[],
  150. dictionaryKey: string,
  151. }
  152. @observer
  153. class WizardOptions extends React.Component<Props> {
  154. componentDidMount() {
  155. window.addEventListener('resize', this.handleResize)
  156. }
  157. componentWillUnmount() {
  158. window.removeEventListener('resize', this.handleResize, false)
  159. }
  160. getFieldValue(fieldName: string, defaultValue: any, parentFieldName?: string) {
  161. if (this.props.getFieldValue) {
  162. return this.props.getFieldValue(fieldName, defaultValue, parentFieldName)
  163. }
  164. if (!this.props.data) {
  165. return defaultValue
  166. }
  167. if (parentFieldName) {
  168. if (this.props.data[parentFieldName]
  169. && this.props.data[parentFieldName][fieldName] !== undefined) {
  170. return this.props.data[parentFieldName][fieldName]
  171. }
  172. return defaultValue
  173. }
  174. if (!this.props.data || this.props.data[fieldName] === undefined) {
  175. return defaultValue
  176. }
  177. return this.props.data[fieldName]
  178. }
  179. getDefaultSimpleFieldsSchema() {
  180. let fieldsSchema: Field[] = []
  181. if (this.props.minionPools.length) {
  182. fieldsSchema.push({
  183. name: 'minion_pool_id',
  184. label: `${this.props.isSource ? 'Source' : 'Target'} Minion Pool`,
  185. type: 'string',
  186. enum: this.props.minionPools.map(minionPool => ({
  187. label: minionPool.name,
  188. value: minionPool.id,
  189. disabled: !MinionPoolStoreUtils.isActive(minionPool),
  190. subtitleLabel: !MinionPoolStoreUtils.isActive(minionPool) ? `Pool is in ${minionPool.status} status instead of being ALLOCATED.` : '',
  191. })),
  192. })
  193. }
  194. if (this.props.showSeparatePerVm) {
  195. const dictionaryLabel = LabelDictionary.get('separate_vm')
  196. const label = this.props.wizardType === 'migration' ? dictionaryLabel : dictionaryLabel.replace('Migration', 'Replica')
  197. fieldsSchema.push({
  198. name: 'separate_vm', label, type: 'boolean', default: true, nullableBoolean: false,
  199. })
  200. }
  201. if (this.props.wizardType === 'migration' || this.props.wizardType === 'migration-destination-options-edit') {
  202. fieldsSchema.push({
  203. name: 'skip_os_morphing', type: 'boolean', default: false, nullableBoolean: false,
  204. })
  205. }
  206. if (this.props.wizardType === 'migration' || this.props.wizardType === 'replica'
  207. || this.props.wizardType === 'migration-destination-options-edit' || this.props.wizardType === 'replica-destination-options-edit') {
  208. let titleFieldSchema: Field = { name: 'title', type: 'string' }
  209. if (this.props.showSeparatePerVm && this.getFieldValue('separate_vm', true)) {
  210. titleFieldSchema = {
  211. ...titleFieldSchema,
  212. disabled: true,
  213. description: 'When using \'Separate Migration/VM\', the title is automatically set based on the names of the selected instances',
  214. }
  215. }
  216. fieldsSchema.push(titleFieldSchema)
  217. }
  218. if (this.props.wizardType === 'replica') {
  219. fieldsSchema.push({
  220. name: 'execute_now', type: 'boolean', default: true, nullableBoolean: false,
  221. })
  222. const executeNowValue = this.getFieldValue('execute_now', true)
  223. fieldsSchema.push({
  224. name: 'execute_now_options',
  225. type: 'object',
  226. properties: executionOptions,
  227. disabled: !executeNowValue,
  228. description: !executeNowValue ? 'Enable \'Execute Now\' to set \'Execute Now Options\'' : `Set the options for ${this.props.wizardType} execution`,
  229. })
  230. } else if (this.props.wizardType === 'migration' || this.props.wizardType === 'migration-destination-options-edit') {
  231. fieldsSchema = [...fieldsSchema, ...migrationFields]
  232. }
  233. return fieldsSchema
  234. }
  235. getDefaultAdvancedFieldsSchema() {
  236. const fieldsSchema: Field[] = []
  237. if (this.props.minionPools.length && this.props.selectedInstances
  238. && this.props.selectedInstances.length) {
  239. const properties: Field[] = this.props.selectedInstances.map(instance => ({
  240. name: instance.instance_name || instance.id,
  241. label: instance.name,
  242. type: 'string',
  243. enum: this.props.minionPools.map(minionPool => ({
  244. name: minionPool.name,
  245. id: minionPool.id,
  246. })),
  247. }))
  248. fieldsSchema.push({
  249. name: INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS,
  250. label: 'Instance OSMorphing Minion Pool Mappings',
  251. type: 'object',
  252. properties,
  253. })
  254. }
  255. return fieldsSchema
  256. }
  257. isPassword(fieldName: string): boolean {
  258. return fieldName.indexOf('password') > -1 || Boolean(configLoader.config.passwordFields.find(f => f === fieldName))
  259. }
  260. @autobind
  261. handleResize() {
  262. this.setState({})
  263. }
  264. generateGroups(fields: FieldRender[]) {
  265. let groups: Array<{ fields: FieldRender[], name?: string }> = [{ fields }]
  266. const workerFields = fields.filter(f => f.field.name.indexOf('migr_') === 0)
  267. if (workerFields.length > 1) {
  268. groups = [
  269. { fields: fields.filter(f => f.field.name.indexOf('migr_') === -1) },
  270. { name: 'Temporary Migration Worker Options', fields: workerFields.map((f, i) => ({ ...f, column: i % 2 })) },
  271. ]
  272. }
  273. fields.forEach(f => {
  274. if (f.field.groupName) {
  275. groups[0].fields = groups[0].fields
  276. ? groups[0].fields.filter(gf => gf.field.name !== f.field.name) : []
  277. const group = groups.find(g => g.name && g.name === f.field.groupName)
  278. if (!group) {
  279. groups.push({
  280. name: f.field.groupName,
  281. fields: [f],
  282. })
  283. } else {
  284. group.fields.push(f)
  285. }
  286. }
  287. })
  288. return groups
  289. }
  290. renderOptionsField(field: Field) {
  291. let additionalProps
  292. if (field.type === 'object' && field.properties) {
  293. const renderOsMorphingLabels = (propName: string) => (
  294. propName.indexOf('/') > -1 ? propName.split('/')[propName.split('/').length - 1] : propName
  295. )
  296. additionalProps = {
  297. valueCallback: (f: any) => this.getFieldValue(f.name, f.default, field.name),
  298. onChange: (value: any, f: any) => {
  299. this.props.onChange(f, value, field.name)
  300. },
  301. labelRenderer: field.name === INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS
  302. ? renderOsMorphingLabels : null,
  303. properties: field.properties,
  304. }
  305. } else {
  306. additionalProps = {
  307. value: this.getFieldValue(field.name, field.default, field.groupName),
  308. onChange: (value: any) => { this.props.onChange(field, value) },
  309. }
  310. }
  311. const optionsLoadingReqFields = this.props.optionsLoadingSkipFields || []
  312. return (
  313. <FieldInputStyled
  314. layout={this.props.layout || 'page'}
  315. key={field.name}
  316. name={field.name}
  317. type={field.type}
  318. minimum={field.minimum}
  319. maximum={field.maximum}
  320. label={field.label || LabelDictionary.get(field.name, this.props.dictionaryKey)}
  321. description={field.description
  322. || LabelDictionary.getDescription(field.name, this.props.dictionaryKey)}
  323. password={this.isPassword(field.name)}
  324. enum={field.enum}
  325. addNullValue
  326. required={field.required}
  327. data-test-id={`wOptions-field-${field.name}`}
  328. width={this.props.fieldWidth || ThemeProps.inputSizes.wizard.width}
  329. nullableBoolean={field.nullableBoolean}
  330. disabled={field.disabled}
  331. disabledLoading={this.props.optionsLoading
  332. && !optionsLoadingReqFields.find(fn => fn === field.name)}
  333. // eslint-disable-next-line react/jsx-props-no-spreading
  334. {...additionalProps}
  335. />
  336. )
  337. }
  338. renderNoFieldsMessage() {
  339. return (
  340. <NoSourceFieldsWrapper>
  341. <EndpointImage />
  342. <NoSourceFieldsMessage>No Source Options</NoSourceFieldsMessage>
  343. <NoSourceFieldsSubMessage>
  344. There are no options for the specified source cloud provider.
  345. </NoSourceFieldsSubMessage>
  346. </NoSourceFieldsWrapper>
  347. )
  348. }
  349. renderOptionsFields() {
  350. if (this.props.fields.length === 0 && this.props.isSource) {
  351. return this.renderNoFieldsMessage()
  352. }
  353. let fieldsSchema: Field[] = this.getDefaultSimpleFieldsSchema()
  354. fieldsSchema = fieldsSchema.concat(this.props.fields.filter(f => f.required))
  355. if (this.props.useAdvancedOptions) {
  356. fieldsSchema = fieldsSchema.concat(this.getDefaultAdvancedFieldsSchema())
  357. fieldsSchema = fieldsSchema.concat(this.props.fields.filter(f => !f.required))
  358. }
  359. const nonNullableBooleans: string[] = fieldsSchema.filter(f => f.type === 'boolean' && f.nullableBoolean === false).map(f => f.name)
  360. // Add subfields for enums which have them
  361. let subFields: any[] = []
  362. fieldsSchema.forEach(f => {
  363. if (!f.subFields) {
  364. return
  365. }
  366. const value = this.getFieldValue(f.name, f.default)
  367. if (!f.subFields) {
  368. return
  369. }
  370. let subField: Field | undefined
  371. if (f.type === 'boolean') {
  372. subField = value ? f.subFields[1] : f.subFields[0]
  373. } else {
  374. subField = f.subFields.find(sf => sf.name === `${String(value)}_options`)
  375. }
  376. if (subField?.properties) {
  377. subFields = [...subFields, ...subField.properties]
  378. }
  379. })
  380. fieldsSchema = [...fieldsSchema, ...subFields]
  381. let executeNowColumn: number
  382. const fields: FieldRender[] = fieldsSchema.filter(f => shouldRenderField(f)).map((field, i) => {
  383. let column: number = i % 2
  384. if (field.name === 'execute_now') {
  385. executeNowColumn = column
  386. }
  387. if (field.name === 'execute_now_options') {
  388. column = executeNowColumn
  389. }
  390. const usableField = toJS(field)
  391. if (field.type === 'boolean' && !nonNullableBooleans.find(name => name === field.name)) {
  392. usableField.nullableBoolean = true
  393. }
  394. return {
  395. column,
  396. component: this.renderOptionsField(usableField),
  397. field: usableField,
  398. }
  399. })
  400. const groups = this.generateGroups(fields)
  401. return (
  402. <Fields ref={this.props.onScrollableRef} padding={this.props.layout === 'page' ? null : 32}>
  403. {groups.map((g, i) => {
  404. const getColumnInGroup = (field: any, fieldIndex: number) => (
  405. g.name ? fieldIndex % 2 : field.column
  406. )
  407. return (
  408. <CSSTransitionGroup
  409. key={g.name || 0}
  410. transitionName={i > 0 ? 'field-group-transition' : ''}
  411. transitionAppear
  412. transitionAppearTimeout={250}
  413. in={false}
  414. >
  415. <Group>
  416. {g.name ? (
  417. <GroupName>
  418. <GroupNameBar />
  419. <GroupNameText>{LabelDictionary.get(g.name)}</GroupNameText>
  420. <GroupNameBar />
  421. </GroupName>
  422. ) : null}
  423. <GroupFields>
  424. <Column left>
  425. {g.fields.map((f, j) => (getColumnInGroup(f, j) === 0 && f.component))}
  426. </Column>
  427. <Column right>
  428. {g.fields.map((f, j) => getColumnInGroup(f, j) === 1 && f.component)}
  429. </Column>
  430. </GroupFields>
  431. </Group>
  432. </CSSTransitionGroup>
  433. )
  434. })}
  435. </Fields>
  436. )
  437. }
  438. renderLoading() {
  439. if (!this.props.loading) {
  440. return null
  441. }
  442. return (
  443. <LoadingWrapper>
  444. <StatusImage loading />
  445. <LoadingText>Loading options...</LoadingText>
  446. </LoadingWrapper>
  447. )
  448. }
  449. renderOptions() {
  450. if (this.props.loading) {
  451. return null
  452. }
  453. const onAdvancedOptionsToggle = this.props.onAdvancedOptionsToggle
  454. return (
  455. <Options>
  456. {onAdvancedOptionsToggle ? (
  457. <ToggleButtonBar
  458. style={{ marginBottom: '46px' }}
  459. items={[{ label: 'Simple', value: 'simple' }, { label: 'Advanced', value: 'advanced' }]}
  460. selectedValue={this.props.useAdvancedOptions ? 'advanced' : 'simple'}
  461. onChange={item => { onAdvancedOptionsToggle(item.value === 'advanced') }}
  462. />
  463. ) : null}
  464. {this.renderOptionsFields()}
  465. </Options>
  466. )
  467. }
  468. render() {
  469. return (
  470. <Wrapper>
  471. <input type="password" style={{ position: 'absolute', top: '-99999px', left: '-99999px' }} />
  472. {this.renderOptions()}
  473. {this.renderLoading()}
  474. </Wrapper>
  475. )
  476. }
  477. }
  478. export default WizardOptions