2
0

WizardOptions.tsx 17 KB

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