WizardOptions.tsx 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675
  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 autobind from "autobind-decorator";
  15. import { toJS } from "mobx";
  16. import { observer } from "mobx-react";
  17. import * as React from "react";
  18. import { CSSTransitionGroup } from "react-transition-group";
  19. import styled from "styled-components";
  20. import { MinionPool } from "@src/@types/MinionPool";
  21. import { ThemePalette, ThemeProps } from "@src/components/Theme";
  22. import FieldInput from "@src/components/ui/FieldInput";
  23. import StatusImage from "@src/components/ui/StatusComponents/StatusImage";
  24. import ToggleButtonBar from "@src/components/ui/ToggleButtonBar";
  25. import { executionOptions } from "@src/constants";
  26. import { MinionPoolStoreUtils } from "@src/stores/MinionPoolStore";
  27. import configLoader from "@src/utils/Config";
  28. import LabelDictionary from "@src/utils/LabelDictionary";
  29. import endpointImage from "./images/endpoint.svg";
  30. import type { Field } from "@src/@types/Field";
  31. import type { Instance } from "@src/@types/Instance";
  32. import type { StorageBackend } from "@src/@types/Endpoint";
  33. export const INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS =
  34. "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. height: 100%;
  46. `;
  47. const Fields = styled.div<any>`
  48. ${props => (props.layout === "page" ? "" : "padding: 32px;")}
  49. display: flex;
  50. flex-direction: column;
  51. overflow: auto;
  52. padding-right: ${props => (props.layout === "page" ? 4 : 24)}px;
  53. flex-grow: 1;
  54. `;
  55. const Group = styled.div<any>`
  56. display: flex;
  57. flex-direction: column;
  58. flex-shrink: 0;
  59. &.field-group-transition-appear {
  60. opacity: 0.01;
  61. }
  62. &.field-group-transition-appear-active {
  63. opacity: 1;
  64. transition: opacity 250ms ease-out;
  65. }
  66. `;
  67. const GroupName = styled.div<any>`
  68. display: flex;
  69. align-items: center;
  70. margin: 48px 0 24px 0;
  71. `;
  72. const GroupNameText = styled.div<any>`
  73. margin: 0 32px;
  74. font-size: 16px;
  75. `;
  76. const GroupNameBar = styled.div<any>`
  77. flex-grow: 1;
  78. background: ${ThemePalette.grayscale[3]};
  79. height: 1px;
  80. `;
  81. const GroupFields = styled.div<any>`
  82. display: flex;
  83. justify-content: space-between;
  84. `;
  85. const Column = styled.div<any>`
  86. margin-top: -16px;
  87. `;
  88. const FieldInputStyled = styled(FieldInput)`
  89. width: ${props => props.width || ThemeProps.inputSizes.wizard.width}px;
  90. justify-content: space-between;
  91. margin-top: 16px;
  92. `;
  93. const LoadingWrapper = styled.div<any>`
  94. margin-top: 32px;
  95. display: flex;
  96. flex-direction: column;
  97. align-items: center;
  98. `;
  99. const LoadingText = styled.div<any>`
  100. margin-top: 38px;
  101. font-size: 18px;
  102. `;
  103. const EndpointImage = styled.div<any>`
  104. ${ThemeProps.exactSize("96px")};
  105. background: url("${endpointImage}") center no-repeat;
  106. `;
  107. const NoSourceFieldsWrapper = styled.div<any>`
  108. margin-top: 16px;
  109. display: flex;
  110. flex-direction: column;
  111. align-items: center;
  112. `;
  113. const NoSourceFieldsMessage = styled.div<any>`
  114. font-size: 18px;
  115. margin-top: 16px;
  116. `;
  117. const NoSourceFieldsSubMessage = styled.div<any>`
  118. margin-top: 16px;
  119. color: ${ThemePalette.grayscale[4]};
  120. `;
  121. export const shouldRenderField = (field: Field) =>
  122. (field.type !== "array" ||
  123. (field.enum && field.enum.length && field.enum.length > 0)) &&
  124. (field.type !== "object" || field.properties);
  125. export const findInvalidFields = (data: any, schema: Field[]): Field[] => {
  126. const isInvalid = (field: Field): boolean => {
  127. if (field.groupName && data[field.groupName]?.[field.name] !== undefined) {
  128. return !data[field.groupName][field.name];
  129. } else if (data[field.name] !== undefined) {
  130. return !data[field.name];
  131. } else {
  132. return !field.default;
  133. }
  134. };
  135. if (!schema || schema.length === 0) {
  136. return [];
  137. }
  138. let required = schema.filter(f => f.required && f.type !== "object");
  139. schema.forEach(f => {
  140. if (f.type === "object" && f.properties) {
  141. required = required.concat(
  142. f.properties
  143. ?.filter(p => p.required)
  144. .map(p => ({ ...p, groupName: f.name }))
  145. );
  146. }
  147. if (f.subFields) {
  148. if (f.enum) {
  149. const value = data && data[f.name];
  150. const subField = f.subFields.find(
  151. sf => sf.name === `${String(value)}_options`
  152. );
  153. if (subField?.properties) {
  154. required = required.concat(
  155. subField.properties.filter(p => p.required)
  156. );
  157. }
  158. } else if (f.type === "boolean") {
  159. const subField = data?.[f.name] ? f.subFields[1] : f.subFields[0];
  160. if (subField.properties) {
  161. required = required.concat(
  162. subField.properties.filter(p => p.required)
  163. );
  164. }
  165. }
  166. }
  167. });
  168. return required.filter(isInvalid);
  169. };
  170. type FieldRender = {
  171. field: Field;
  172. component: React.ReactNode;
  173. column: number;
  174. };
  175. type Props = {
  176. fields: Field[];
  177. minionPools: MinionPool[];
  178. isSource?: boolean;
  179. selectedInstances?: Instance[] | null;
  180. showSeparatePerVm?: boolean;
  181. data?: { [prop: string]: any } | null;
  182. executeNowOptionsDisabled?: boolean;
  183. getFieldValue?: (
  184. fieldName: string,
  185. defaultValue: any,
  186. parentFieldName: string | undefined
  187. ) => any;
  188. onChange: (field: Field, value: any, parentFieldName?: string) => void;
  189. useAdvancedOptions?: boolean;
  190. hasStorageMap: boolean;
  191. storageBackends?: StorageBackend[];
  192. onAdvancedOptionsToggle?: (showAdvanced: boolean) => void;
  193. wizardType: string;
  194. oneColumnStyle?: { [prop: string]: any };
  195. fieldWidth?: number;
  196. onScrollableRef?: (ref: HTMLElement) => void;
  197. availableHeight?: number;
  198. layout?: "page" | "modal";
  199. loading?: boolean;
  200. optionsLoading?: boolean;
  201. optionsLoadingSkipFields?: string[];
  202. dictionaryKey: string;
  203. };
  204. type State = {
  205. highlightedFields: Field[];
  206. };
  207. @observer
  208. class WizardOptions extends React.Component<Props> {
  209. state: State = {
  210. highlightedFields: [],
  211. };
  212. componentDidMount() {
  213. window.addEventListener("resize", this.handleResize);
  214. }
  215. componentWillUnmount() {
  216. window.removeEventListener("resize", this.handleResize, false);
  217. }
  218. getFieldValue(
  219. fieldName: string,
  220. defaultValue: any,
  221. parentFieldName?: string
  222. ) {
  223. if (this.props.getFieldValue) {
  224. return this.props.getFieldValue(fieldName, defaultValue, parentFieldName);
  225. }
  226. if (!this.props.data) {
  227. return defaultValue;
  228. }
  229. if (parentFieldName) {
  230. if (
  231. this.props.data[parentFieldName] &&
  232. this.props.data[parentFieldName][fieldName] !== undefined
  233. ) {
  234. return this.props.data[parentFieldName][fieldName];
  235. }
  236. return defaultValue;
  237. }
  238. if (!this.props.data || this.props.data[fieldName] === undefined) {
  239. return defaultValue;
  240. }
  241. return this.props.data[fieldName];
  242. }
  243. getDefaultSimpleFieldsSchema() {
  244. const fieldsSchema: Field[] = [];
  245. if (this.props.minionPools.length) {
  246. fieldsSchema.push({
  247. name: "minion_pool_id",
  248. label: `${this.props.isSource ? "Source" : "Target"} Minion Pool`,
  249. type: "string",
  250. enum: this.props.minionPools.map(minionPool => ({
  251. label: minionPool.name,
  252. value: minionPool.id,
  253. disabled: !MinionPoolStoreUtils.isActive(minionPool),
  254. subtitleLabel: !MinionPoolStoreUtils.isActive(minionPool)
  255. ? `Pool is in ${minionPool.status} status instead of being ALLOCATED.`
  256. : "",
  257. })),
  258. });
  259. }
  260. if (this.props.showSeparatePerVm) {
  261. const dictionaryLabel = LabelDictionary.get("separate_vm");
  262. const label =
  263. this.props.wizardType === "migration"
  264. ? dictionaryLabel
  265. : dictionaryLabel.replace("Migration", "Replica");
  266. fieldsSchema.push({
  267. name: "separate_vm",
  268. label,
  269. type: "boolean",
  270. default: true,
  271. nullableBoolean: false,
  272. description: `Whether or not to create a separate ${this.props.wizardType} for each selected VM`,
  273. });
  274. }
  275. if (this.props.wizardType === "migration-destination-options-edit") {
  276. fieldsSchema.push({
  277. name: "skip_os_morphing",
  278. type: "boolean",
  279. default: false,
  280. nullableBoolean: false,
  281. });
  282. }
  283. if (
  284. this.props.wizardType === "migration" ||
  285. this.props.wizardType === "replica" ||
  286. this.props.wizardType === "migration-destination-options-edit" ||
  287. this.props.wizardType === "replica-destination-options-edit"
  288. ) {
  289. let titleFieldSchema: Field = { name: "title", type: "string" };
  290. if (
  291. this.props.showSeparatePerVm &&
  292. this.getFieldValue("separate_vm", true)
  293. ) {
  294. titleFieldSchema = {
  295. ...titleFieldSchema,
  296. disabled: true,
  297. description:
  298. "When using 'Separate Migration/VM', the title is automatically set based on the names of the selected instances",
  299. };
  300. }
  301. fieldsSchema.push(titleFieldSchema);
  302. }
  303. return fieldsSchema;
  304. }
  305. getDefaultAdvancedFieldsSchema() {
  306. const fieldsSchema: Field[] = [];
  307. if (
  308. this.props.minionPools.length &&
  309. this.props.selectedInstances &&
  310. this.props.selectedInstances.length
  311. ) {
  312. const properties: Field[] = this.props.selectedInstances.map(
  313. instance => ({
  314. name: instance.instance_name || instance.id,
  315. label: instance.name,
  316. type: "string",
  317. enum: this.props.minionPools.map(minionPool => ({
  318. name: minionPool.name,
  319. id: minionPool.id,
  320. })),
  321. })
  322. );
  323. fieldsSchema.push({
  324. name: INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS,
  325. label: "Instance OSMorphing Minion Pool Mappings",
  326. type: "object",
  327. properties,
  328. });
  329. }
  330. return fieldsSchema;
  331. }
  332. isPassword(fieldName: string): boolean {
  333. return (
  334. fieldName.indexOf("password") > -1 ||
  335. Boolean(configLoader.config.passwordFields.find(f => f === fieldName))
  336. );
  337. }
  338. @autobind
  339. handleResize() {
  340. this.setState({});
  341. }
  342. // Called only by parent components
  343. // eslint-disable-next-line
  344. highlightFields(): boolean {
  345. const highlightedFields: Field[] = findInvalidFields(
  346. this.props.data,
  347. this.props.fields
  348. );
  349. this.setState({ highlightedFields });
  350. return highlightedFields.length > 0;
  351. }
  352. generateGroups(fields: FieldRender[]) {
  353. let groups: Array<{ fields: FieldRender[]; name?: string }> = [{ fields }];
  354. const workerFields = fields.filter(
  355. f => f.field.name.indexOf("migr_") === 0
  356. );
  357. if (workerFields.length > 1) {
  358. groups = [
  359. { fields: fields.filter(f => f.field.name.indexOf("migr_") === -1) },
  360. {
  361. name: "Temporary Migration Worker Options",
  362. fields: workerFields.map((f, i) => ({ ...f, column: i % 2 })),
  363. },
  364. ];
  365. }
  366. fields.forEach(f => {
  367. if (f.field.groupName) {
  368. groups[0].fields = groups[0].fields
  369. ? groups[0].fields.filter(gf => gf.field.name !== f.field.name)
  370. : [];
  371. const group = groups.find(g => g.name && g.name === f.field.groupName);
  372. if (!group) {
  373. groups.push({
  374. name: f.field.groupName,
  375. fields: [f],
  376. });
  377. } else {
  378. group.fields.push(f);
  379. }
  380. }
  381. });
  382. return groups;
  383. }
  384. renderOptionsField(field: Field) {
  385. let additionalProps;
  386. if (field.type === "object" && field.properties) {
  387. const renderOsMorphingLabels = (propName: string) =>
  388. propName.indexOf("/") > -1
  389. ? propName.split("/")[propName.split("/").length - 1]
  390. : propName;
  391. additionalProps = {
  392. valueCallback: (f: any) =>
  393. this.getFieldValue(f.name, f.default, field.name),
  394. onChange: (value: any, f: any) => {
  395. this.props.onChange(f, value, field.name);
  396. },
  397. labelRenderer:
  398. field.name === INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS
  399. ? renderOsMorphingLabels
  400. : null,
  401. properties: field.properties,
  402. };
  403. } else {
  404. additionalProps = {
  405. value: this.getFieldValue(field.name, field.default, field.groupName),
  406. onChange: (value: any) => {
  407. this.props.onChange(field, value);
  408. },
  409. };
  410. }
  411. const optionsLoadingReqFields = this.props.optionsLoadingSkipFields || [];
  412. return (
  413. <FieldInputStyled
  414. layout={this.props.layout || "page"}
  415. key={field.name}
  416. name={field.name}
  417. type={field.type}
  418. minimum={field.minimum}
  419. maximum={field.maximum}
  420. label={
  421. field.label ||
  422. LabelDictionary.get(field.name, this.props.dictionaryKey)
  423. }
  424. warning={field.warning}
  425. description={
  426. field.description ||
  427. LabelDictionary.getDescription(field.name, this.props.dictionaryKey)
  428. }
  429. password={this.isPassword(field.name)}
  430. enum={field.enum}
  431. addNullValue
  432. required={field.required}
  433. width={this.props.fieldWidth || ThemeProps.inputSizes.wizard.width}
  434. nullableBoolean={field.nullableBoolean}
  435. disabled={field.disabled}
  436. highlight={Boolean(
  437. this.state.highlightedFields.find(
  438. f => f.name === field.name || f.groupName === field.name
  439. )
  440. )}
  441. disabledLoading={
  442. this.props.optionsLoading &&
  443. !optionsLoadingReqFields.find(fn => fn === field.name)
  444. }
  445. // eslint-disable-next-line react/jsx-props-no-spreading
  446. {...additionalProps}
  447. />
  448. );
  449. }
  450. renderNoFieldsMessage() {
  451. return (
  452. <NoSourceFieldsWrapper>
  453. <EndpointImage />
  454. <NoSourceFieldsMessage>No Source Options</NoSourceFieldsMessage>
  455. <NoSourceFieldsSubMessage>
  456. There are no options for the specified source cloud provider.
  457. </NoSourceFieldsSubMessage>
  458. </NoSourceFieldsWrapper>
  459. );
  460. }
  461. renderOptionsFields() {
  462. if (this.props.fields.length === 0 && this.props.isSource) {
  463. return this.renderNoFieldsMessage();
  464. }
  465. let fieldsSchema: Field[] = this.getDefaultSimpleFieldsSchema();
  466. const isRequired = (f: Field) =>
  467. f.required || f.properties?.some(p => p.required);
  468. fieldsSchema = fieldsSchema.concat(this.props.fields.filter(isRequired));
  469. if (this.props.useAdvancedOptions) {
  470. fieldsSchema = fieldsSchema.concat(this.getDefaultAdvancedFieldsSchema());
  471. fieldsSchema = fieldsSchema.concat(
  472. this.props.fields.filter(f => !isRequired(f))
  473. );
  474. }
  475. const nonNullableBooleans: string[] = fieldsSchema
  476. .filter(f => f.type === "boolean" && f.nullableBoolean === false)
  477. .map(f => f.name);
  478. // Add subfields for enums which have them
  479. let subFields: any[] = [];
  480. fieldsSchema.forEach(f => {
  481. if (!f.subFields) {
  482. return;
  483. }
  484. const value = this.getFieldValue(f.name, f.default);
  485. if (!f.subFields) {
  486. return;
  487. }
  488. let subField: Field | undefined;
  489. if (f.type === "boolean") {
  490. subField = value ? f.subFields[1] : f.subFields[0];
  491. } else {
  492. subField = f.subFields.find(
  493. sf => sf.name === `${String(value)}_options`
  494. );
  495. }
  496. if (subField?.properties) {
  497. subFields = [...subFields, ...subField.properties];
  498. }
  499. });
  500. fieldsSchema = [...fieldsSchema, ...subFields];
  501. let executeNowColumn: number;
  502. const fields: FieldRender[] = fieldsSchema
  503. .filter(f => shouldRenderField(f))
  504. .map((field, i) => {
  505. let column: number = i % 2;
  506. if (field.name === "execute_now") {
  507. executeNowColumn = column;
  508. }
  509. const usableField = toJS(field);
  510. if (
  511. field.type === "boolean" &&
  512. !nonNullableBooleans.find(name => name === field.name)
  513. ) {
  514. usableField.nullableBoolean = true;
  515. }
  516. return {
  517. column,
  518. component: this.renderOptionsField(usableField),
  519. field: usableField,
  520. };
  521. });
  522. const groups = this.generateGroups(fields);
  523. return (
  524. <Fields ref={this.props.onScrollableRef} layout={this.props.layout}>
  525. {groups.map((g, i) => {
  526. const getColumnInGroup = (field: any, fieldIndex: number) =>
  527. g.name ? fieldIndex % 2 : field.column;
  528. return (
  529. <CSSTransitionGroup
  530. key={g.name || 0}
  531. transitionName={i > 0 ? "field-group-transition" : ""}
  532. transitionAppear
  533. transitionEnterTimeout={250}
  534. transitionAppearTimeout={250}
  535. transitionLeaveTimeout={250}
  536. in={false}
  537. >
  538. <Group>
  539. {g.name ? (
  540. <GroupName>
  541. <GroupNameBar />
  542. <GroupNameText>{LabelDictionary.get(g.name)}</GroupNameText>
  543. <GroupNameBar />
  544. </GroupName>
  545. ) : null}
  546. <GroupFields>
  547. <Column left>
  548. {g.fields.map(
  549. (f, j) => getColumnInGroup(f, j) === 0 && f.component
  550. )}
  551. </Column>
  552. <Column right>
  553. {g.fields.map(
  554. (f, j) => getColumnInGroup(f, j) === 1 && f.component
  555. )}
  556. </Column>
  557. </GroupFields>
  558. </Group>
  559. </CSSTransitionGroup>
  560. );
  561. })}
  562. </Fields>
  563. );
  564. }
  565. renderLoading() {
  566. if (!this.props.loading) {
  567. return null;
  568. }
  569. return (
  570. <LoadingWrapper>
  571. <StatusImage loading />
  572. <LoadingText>Loading options...</LoadingText>
  573. </LoadingWrapper>
  574. );
  575. }
  576. renderOptions() {
  577. if (this.props.loading) {
  578. return null;
  579. }
  580. const onAdvancedOptionsToggle = this.props.onAdvancedOptionsToggle;
  581. return (
  582. <Options>
  583. {onAdvancedOptionsToggle ? (
  584. <ToggleButtonBar
  585. style={{ marginBottom: "46px" }}
  586. items={[
  587. { label: "Simple", value: "simple" },
  588. { label: "Advanced", value: "advanced" },
  589. ]}
  590. selectedValue={
  591. this.props.useAdvancedOptions ? "advanced" : "simple"
  592. }
  593. onChange={item => {
  594. onAdvancedOptionsToggle(item.value === "advanced");
  595. }}
  596. />
  597. ) : null}
  598. {this.renderOptionsFields()}
  599. </Options>
  600. );
  601. }
  602. render() {
  603. return (
  604. <Wrapper>
  605. <input
  606. type="password"
  607. style={{ position: "absolute", top: "-99999px", left: "-99999px" }}
  608. />
  609. {this.renderOptions()}
  610. {this.renderLoading()}
  611. </Wrapper>
  612. );
  613. }
  614. }
  615. export default WizardOptions;