/*
Copyright (C) 2017 Cloudbase Solutions SRL
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see .
*/
import autobind from "autobind-decorator";
import { toJS } from "mobx";
import { observer } from "mobx-react";
import * as React from "react";
import { CSSTransitionGroup } from "react-transition-group";
import styled from "styled-components";
import { MinionPool } from "@src/@types/MinionPool";
import { ThemePalette, ThemeProps } from "@src/components/Theme";
import FieldInput from "@src/components/ui/FieldInput";
import StatusImage from "@src/components/ui/StatusComponents/StatusImage";
import ToggleButtonBar from "@src/components/ui/ToggleButtonBar";
import { executionOptions } from "@src/constants";
import { MinionPoolStoreUtils } from "@src/stores/MinionPoolStore";
import configLoader from "@src/utils/Config";
import LabelDictionary from "@src/utils/LabelDictionary";
import endpointImage from "./images/endpoint.svg";
import type { Field } from "@src/@types/Field";
import type { Instance } from "@src/@types/Instance";
import type { StorageBackend } from "@src/@types/Endpoint";
export const INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS =
"instance_osmorphing_minion_pool_mappings";
const Wrapper = styled.div`
display: flex;
min-height: 0;
flex-direction: column;
width: 100%;
`;
const Options = styled.div`
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
`;
const Fields = styled.div`
${props => (props.layout === "page" ? "" : "padding: 32px;")}
display: flex;
flex-direction: column;
overflow: auto;
padding-right: ${props => (props.layout === "page" ? 4 : 24)}px;
flex-grow: 1;
`;
const Group = styled.div`
display: flex;
flex-direction: column;
flex-shrink: 0;
&.field-group-transition-appear {
opacity: 0.01;
}
&.field-group-transition-appear-active {
opacity: 1;
transition: opacity 250ms ease-out;
}
`;
const GroupName = styled.div`
display: flex;
align-items: center;
margin: 48px 0 24px 0;
`;
const GroupNameText = styled.div`
margin: 0 32px;
font-size: 16px;
`;
const GroupNameBar = styled.div`
flex-grow: 1;
background: ${ThemePalette.grayscale[3]};
height: 1px;
`;
const GroupFields = styled.div`
display: flex;
justify-content: space-between;
`;
const Column = styled.div`
margin-top: -16px;
`;
const FieldInputStyled = styled(FieldInput)`
width: ${props => props.width || ThemeProps.inputSizes.wizard.width}px;
justify-content: space-between;
margin-top: 16px;
`;
const LoadingWrapper = styled.div`
margin-top: 32px;
display: flex;
flex-direction: column;
align-items: center;
`;
const LoadingText = styled.div`
margin-top: 38px;
font-size: 18px;
`;
const EndpointImage = styled.div`
${ThemeProps.exactSize("96px")};
background: url("${endpointImage}") center no-repeat;
`;
const NoSourceFieldsWrapper = styled.div`
margin-top: 16px;
display: flex;
flex-direction: column;
align-items: center;
`;
const NoSourceFieldsMessage = styled.div`
font-size: 18px;
margin-top: 16px;
`;
const NoSourceFieldsSubMessage = styled.div`
margin-top: 16px;
color: ${ThemePalette.grayscale[4]};
`;
export const shouldRenderField = (field: Field) =>
(field.type !== "array" ||
(field.enum && field.enum.length && field.enum.length > 0)) &&
(field.type !== "object" || field.properties);
export const findInvalidFields = (data: any, schema: Field[]): Field[] => {
const isInvalid = (field: Field): boolean => {
if (field.groupName && data[field.groupName]?.[field.name] !== undefined) {
return !data[field.groupName][field.name];
} else if (data[field.name] !== undefined) {
return !data[field.name];
} else {
return !field.default;
}
};
if (!schema || schema.length === 0) {
return [];
}
let required = schema.filter(f => f.required && f.type !== "object");
schema.forEach(f => {
if (f.type === "object" && f.properties) {
required = required.concat(
f.properties
?.filter(p => p.required)
.map(p => ({ ...p, groupName: f.name }))
);
}
if (f.subFields) {
if (f.enum) {
const value = data && data[f.name];
const subField = f.subFields.find(
sf => sf.name === `${String(value)}_options`
);
if (subField?.properties) {
required = required.concat(
subField.properties.filter(p => p.required)
);
}
} else if (f.type === "boolean") {
const subField = data?.[f.name] ? f.subFields[1] : f.subFields[0];
if (subField.properties) {
required = required.concat(
subField.properties.filter(p => p.required)
);
}
}
}
});
return required.filter(isInvalid);
};
type FieldRender = {
field: Field;
component: React.ReactNode;
column: number;
};
type Props = {
fields: Field[];
minionPools: MinionPool[];
isSource?: boolean;
selectedInstances?: Instance[] | null;
showSeparatePerVm?: boolean;
data?: { [prop: string]: any } | null;
executeNowOptionsDisabled?: boolean;
getFieldValue?: (
fieldName: string,
defaultValue: any,
parentFieldName: string | undefined
) => any;
onChange: (field: Field, value: any, parentFieldName?: string) => void;
useAdvancedOptions?: boolean;
hasStorageMap: boolean;
storageBackends?: StorageBackend[];
onAdvancedOptionsToggle?: (showAdvanced: boolean) => void;
wizardType: string;
oneColumnStyle?: { [prop: string]: any };
fieldWidth?: number;
onScrollableRef?: (ref: HTMLElement) => void;
availableHeight?: number;
layout?: "page" | "modal";
loading?: boolean;
optionsLoading?: boolean;
optionsLoadingSkipFields?: string[];
dictionaryKey: string;
};
type State = {
highlightedFields: Field[];
};
@observer
class WizardOptions extends React.Component {
state: State = {
highlightedFields: [],
};
componentDidMount() {
window.addEventListener("resize", this.handleResize);
}
componentWillUnmount() {
window.removeEventListener("resize", this.handleResize, false);
}
getFieldValue(
fieldName: string,
defaultValue: any,
parentFieldName?: string
) {
if (this.props.getFieldValue) {
return this.props.getFieldValue(fieldName, defaultValue, parentFieldName);
}
if (!this.props.data) {
return defaultValue;
}
if (parentFieldName) {
if (
this.props.data[parentFieldName] &&
this.props.data[parentFieldName][fieldName] !== undefined
) {
return this.props.data[parentFieldName][fieldName];
}
return defaultValue;
}
if (!this.props.data || this.props.data[fieldName] === undefined) {
return defaultValue;
}
return this.props.data[fieldName];
}
getDefaultSimpleFieldsSchema() {
const fieldsSchema: Field[] = [];
if (this.props.minionPools.length) {
fieldsSchema.push({
name: "minion_pool_id",
label: `${this.props.isSource ? "Source" : "Target"} Minion Pool`,
type: "string",
enum: this.props.minionPools.map(minionPool => ({
label: minionPool.name,
value: minionPool.id,
disabled: !MinionPoolStoreUtils.isActive(minionPool),
subtitleLabel: !MinionPoolStoreUtils.isActive(minionPool)
? `Pool is in ${minionPool.status} status instead of being ALLOCATED.`
: "",
})),
});
}
if (this.props.showSeparatePerVm) {
const dictionaryLabel = LabelDictionary.get("separate_vm");
const label =
this.props.wizardType === "migration"
? dictionaryLabel
: dictionaryLabel.replace("Migration", "Replica");
fieldsSchema.push({
name: "separate_vm",
label,
type: "boolean",
default: true,
nullableBoolean: false,
description: `Whether or not to create a separate ${this.props.wizardType} for each selected VM`,
});
}
if (this.props.wizardType === "migration-destination-options-edit") {
fieldsSchema.push({
name: "skip_os_morphing",
type: "boolean",
default: false,
nullableBoolean: false,
});
}
if (
this.props.wizardType === "migration" ||
this.props.wizardType === "replica" ||
this.props.wizardType === "migration-destination-options-edit" ||
this.props.wizardType === "replica-destination-options-edit"
) {
let titleFieldSchema: Field = { name: "title", type: "string" };
if (
this.props.showSeparatePerVm &&
this.getFieldValue("separate_vm", true)
) {
titleFieldSchema = {
...titleFieldSchema,
disabled: true,
description:
"When using 'Separate Migration/VM', the title is automatically set based on the names of the selected instances",
};
}
fieldsSchema.push(titleFieldSchema);
}
return fieldsSchema;
}
getDefaultAdvancedFieldsSchema() {
const fieldsSchema: Field[] = [];
if (
this.props.minionPools.length &&
this.props.selectedInstances &&
this.props.selectedInstances.length
) {
const properties: Field[] = this.props.selectedInstances.map(
instance => ({
name: instance.instance_name || instance.id,
label: instance.name,
type: "string",
enum: this.props.minionPools.map(minionPool => ({
name: minionPool.name,
id: minionPool.id,
})),
})
);
fieldsSchema.push({
name: INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS,
label: "Instance OSMorphing Minion Pool Mappings",
type: "object",
properties,
});
}
return fieldsSchema;
}
isPassword(fieldName: string): boolean {
return (
fieldName.indexOf("password") > -1 ||
Boolean(configLoader.config.passwordFields.find(f => f === fieldName))
);
}
@autobind
handleResize() {
this.setState({});
}
// Called only by parent components
// eslint-disable-next-line
highlightFields(): boolean {
const highlightedFields: Field[] = findInvalidFields(
this.props.data,
this.props.fields
);
this.setState({ highlightedFields });
return highlightedFields.length > 0;
}
generateGroups(fields: FieldRender[]) {
let groups: Array<{ fields: FieldRender[]; name?: string }> = [{ fields }];
const workerFields = fields.filter(
f => f.field.name.indexOf("migr_") === 0
);
if (workerFields.length > 1) {
groups = [
{ fields: fields.filter(f => f.field.name.indexOf("migr_") === -1) },
{
name: "Temporary Migration Worker Options",
fields: workerFields.map((f, i) => ({ ...f, column: i % 2 })),
},
];
}
fields.forEach(f => {
if (f.field.groupName) {
groups[0].fields = groups[0].fields
? groups[0].fields.filter(gf => gf.field.name !== f.field.name)
: [];
const group = groups.find(g => g.name && g.name === f.field.groupName);
if (!group) {
groups.push({
name: f.field.groupName,
fields: [f],
});
} else {
group.fields.push(f);
}
}
});
return groups;
}
renderOptionsField(field: Field) {
let additionalProps;
if (field.type === "object" && field.properties) {
const renderOsMorphingLabels = (propName: string) =>
propName.indexOf("/") > -1
? propName.split("/")[propName.split("/").length - 1]
: propName;
additionalProps = {
valueCallback: (f: any) =>
this.getFieldValue(f.name, f.default, field.name),
onChange: (value: any, f: any) => {
this.props.onChange(f, value, field.name);
},
labelRenderer:
field.name === INSTANCE_OSMORPHING_MINION_POOL_MAPPINGS
? renderOsMorphingLabels
: null,
properties: field.properties,
};
} else {
additionalProps = {
value: this.getFieldValue(field.name, field.default, field.groupName),
onChange: (value: any) => {
this.props.onChange(field, value);
},
};
}
const optionsLoadingReqFields = this.props.optionsLoadingSkipFields || [];
return (
f.name === field.name || f.groupName === field.name
)
)}
disabledLoading={
this.props.optionsLoading &&
!optionsLoadingReqFields.find(fn => fn === field.name)
}
// eslint-disable-next-line react/jsx-props-no-spreading
{...additionalProps}
/>
);
}
renderNoFieldsMessage() {
return (
No Source Options
There are no options for the specified source cloud provider.
);
}
renderOptionsFields() {
if (this.props.fields.length === 0 && this.props.isSource) {
return this.renderNoFieldsMessage();
}
let fieldsSchema: Field[] = this.getDefaultSimpleFieldsSchema();
const isRequired = (f: Field) =>
f.required || f.properties?.some(p => p.required);
fieldsSchema = fieldsSchema.concat(this.props.fields.filter(isRequired));
if (this.props.useAdvancedOptions) {
fieldsSchema = fieldsSchema.concat(this.getDefaultAdvancedFieldsSchema());
fieldsSchema = fieldsSchema.concat(
this.props.fields.filter(f => !isRequired(f))
);
}
const nonNullableBooleans: string[] = fieldsSchema
.filter(f => f.type === "boolean" && f.nullableBoolean === false)
.map(f => f.name);
// Add subfields for enums which have them
let subFields: any[] = [];
fieldsSchema.forEach(f => {
if (!f.subFields) {
return;
}
const value = this.getFieldValue(f.name, f.default);
if (!f.subFields) {
return;
}
let subField: Field | undefined;
if (f.type === "boolean") {
subField = value ? f.subFields[1] : f.subFields[0];
} else {
subField = f.subFields.find(
sf => sf.name === `${String(value)}_options`
);
}
if (subField?.properties) {
subFields = [...subFields, ...subField.properties];
}
});
fieldsSchema = [...fieldsSchema, ...subFields];
let executeNowColumn: number;
const fields: FieldRender[] = fieldsSchema
.filter(f => shouldRenderField(f))
.map((field, i) => {
let column: number = i % 2;
if (field.name === "execute_now") {
executeNowColumn = column;
}
const usableField = toJS(field);
if (
field.type === "boolean" &&
!nonNullableBooleans.find(name => name === field.name)
) {
usableField.nullableBoolean = true;
}
return {
column,
component: this.renderOptionsField(usableField),
field: usableField,
};
});
const groups = this.generateGroups(fields);
return (
{groups.map((g, i) => {
const getColumnInGroup = (field: any, fieldIndex: number) =>
g.name ? fieldIndex % 2 : field.column;
return (
0 ? "field-group-transition" : ""}
transitionAppear
transitionEnterTimeout={250}
transitionAppearTimeout={250}
transitionLeaveTimeout={250}
in={false}
>
{g.name ? (
{LabelDictionary.get(g.name)}
) : null}
{g.fields.map(
(f, j) => getColumnInGroup(f, j) === 0 && f.component
)}
{g.fields.map(
(f, j) => getColumnInGroup(f, j) === 1 && f.component
)}
);
})}
);
}
renderLoading() {
if (!this.props.loading) {
return null;
}
return (
Loading options...
);
}
renderOptions() {
if (this.props.loading) {
return null;
}
const onAdvancedOptionsToggle = this.props.onAdvancedOptionsToggle;
return (
{onAdvancedOptionsToggle ? (
{
onAdvancedOptionsToggle(item.value === "advanced");
}}
/>
) : null}
{this.renderOptionsFields()}
);
}
render() {
return (
{this.renderOptions()}
{this.renderLoading()}
);
}
}
export default WizardOptions;