diff --git a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/CustomVariables/index.js b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/CustomVariables/index.js new file mode 100644 index 0000000000..00fa8fe7e2 --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/CustomVariables/index.js @@ -0,0 +1,72 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2021, OpenNebula Project, OpenNebula Systems * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); you may * + * not use this file except in compliance with the License. You may obtain * + * a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + * ------------------------------------------------------------------------- */ +/* eslint-disable jsdoc/require-jsdoc */ +import PropTypes from 'prop-types' +import { Box } from '@mui/material' +import { useFormContext, useWatch } from 'react-hook-form' + +import { AttributePanel } from 'client/components/Tabs/Common' +import { SCHEMA } from 'client/components/Forms/VmTemplate/CreateForm/Steps/CustomVariables/schema' +import { cleanEmpty, cloneObject, set } from 'client/utils' +import { T, ACTIONS } from 'client/constants' + +const ALL_ACTIONS = [ + ACTIONS.COPY_ATTRIBUTE, + ACTIONS.ADD_ATTRIBUTE, + ACTIONS.EDIT_ATTRIBUTE, + ACTIONS.DELETE_ATTRIBUTE +] + +export const STEP_ID = 'custom-variables' + +const Content = () => { + const { setValue } = useFormContext() + const customVars = useWatch({ name: STEP_ID }) + + const handleChangeAttribute = (path, newValue) => { + const newCustomVars = cloneObject(customVars) + + set(newCustomVars, path, newValue) + setValue(STEP_ID, cleanEmpty(newCustomVars)) + } + + return ( + + + + ) +} + +const CustomVariables = () => ({ + id: STEP_ID, + label: T.CustomVariables, + resolver: SCHEMA, + optionsValidate: { abortEarly: false }, + content: Content +}) + +Content.propTypes = { + data: PropTypes.any +} + +export default CustomVariables diff --git a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/CustomVariables/schema.js b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/CustomVariables/schema.js new file mode 100644 index 0000000000..417d968dac --- /dev/null +++ b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/CustomVariables/schema.js @@ -0,0 +1,24 @@ +/* ------------------------------------------------------------------------- * + * Copyright 2002-2021, OpenNebula Project, OpenNebula Systems * + * * + * Licensed under the Apache License, Version 2.0 (the "License"); you may * + * not use this file except in compliance with the License. You may obtain * + * a copy of the License at * + * * + * http://www.apache.org/licenses/LICENSE-2.0 * + * * + * Unless required by applicable law or agreed to in writing, software * + * distributed under the License is distributed on an "AS IS" BASIS, * + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * + * See the License for the specific language governing permissions and * + * limitations under the License. * + * ------------------------------------------------------------------------- */ +import { object, ObjectSchema } from 'yup' + +import { } from 'client/utils' +import { } from 'client/constants' + +/** @type {ObjectSchema} Step schema */ +const SCHEMA = object() + +export { SCHEMA } diff --git a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/configurationSchema.js b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/configurationSchema.js index fda6d05fda..9c07c3f86c 100644 --- a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/configurationSchema.js +++ b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/configurationSchema.js @@ -32,7 +32,8 @@ export const SSH_PUBLIC_KEY = { multiline: true, validation: string() .trim() - .notRequired(), + .notRequired() + .ensure(), grid: { md: 12 }, fieldProps: { rows: 4 } } @@ -81,6 +82,7 @@ export const START_SCRIPT = { validation: string() .trim() .notRequired() + .ensure() .when( '$extra.CONTEXT.START_SCRIPT_BASE64', (scriptEncoded, schema) => scriptEncoded diff --git a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/filesSchema.js b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/filesSchema.js index 0d2546f68b..5057fc18e0 100644 --- a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/filesSchema.js +++ b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/filesSchema.js @@ -29,7 +29,8 @@ export const FILES_DS = { type: INPUT_TYPES.TEXT, validation: string() .trim() - .notRequired(), + .notRequired() + .ensure(), grid: { md: 12 } } @@ -41,7 +42,8 @@ export const INIT_SCRIPTS = { type: INPUT_TYPES.TEXT, validation: string() .trim() - .notRequired(), + .notRequired() + .ensure(), grid: { md: 12 } } diff --git a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/index.js b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/index.js index 606784bbcd..cecc24f8fb 100644 --- a/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/index.js +++ b/src/fireedge/src/client/components/Forms/VmTemplate/CreateForm/Steps/index.js @@ -15,38 +15,46 @@ * ------------------------------------------------------------------------- */ import General, { STEP_ID as GENERAL_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General' import ExtraConfiguration, { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration' +import CustomVariables, { STEP_ID as CUSTOM_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/CustomVariables' import { jsonToXml, userInputsToArray } from 'client/models/Helper' import { createSteps, isBase64 } from 'client/utils' const Steps = createSteps( - [General, ExtraConfiguration], + [General, ExtraConfiguration, CustomVariables], { transformInitialValue: (vmTemplate, schema) => { - const generalStep = schema - .pick([GENERAL_ID]) - .cast( - { [GENERAL_ID]: { ...vmTemplate, ...vmTemplate?.TEMPLATE } }, - { stripUnknown: true } - ) - const inputsOrder = vmTemplate?.TEMPLATE?.INPUTS_ORDER?.split(',') ?? [] const userInputs = userInputsToArray(vmTemplate?.TEMPLATE?.USER_INPUTS) .sort((a, b) => inputsOrder.indexOf(a.name) - inputsOrder.indexOf(b.name)) - const configurationStep = schema - .pick([EXTRA_ID]) - .cast( - { [EXTRA_ID]: { ...vmTemplate?.TEMPLATE, USER_INPUTS: userInputs } }, - { stripUnknown: true, context: { [EXTRA_ID]: vmTemplate.TEMPLATE } } - ) + const knownTemplate = schema.cast( + { + [GENERAL_ID]: { ...vmTemplate, ...vmTemplate?.TEMPLATE }, + [EXTRA_ID]: { ...vmTemplate?.TEMPLATE, USER_INPUTS: userInputs } + }, + { stripUnknown: true, context: { [EXTRA_ID]: vmTemplate.TEMPLATE } } + ) - return { ...generalStep, ...configurationStep } + const customVars = {} + const knownAttributes = Object.getOwnPropertyNames({ + ...knownTemplate[GENERAL_ID], + ...knownTemplate[EXTRA_ID] + }) + + Object.entries(vmTemplate?.TEMPLATE) + .forEach(([key, value]) => { + if (!knownAttributes.includes(key) && value) { + customVars[key] = value + } + }) + + return { ...knownTemplate, [CUSTOM_ID]: customVars } }, transformBeforeSubmit: formData => { const { [GENERAL_ID]: general = {}, + [CUSTOM_ID]: customVariables = {}, [EXTRA_ID]: { - USER_INPUTS, CONTEXT: { START_SCRIPT, ENCODE_START_SCRIPT, ...restOfContext }, ...extraTemplate } = {} @@ -62,16 +70,17 @@ const Steps = createSteps( } // add user inputs to context - Object.keys(USER_INPUTS).forEach(name => { - const upperName = String(name).toUpperCase() - context[upperName] = `$${upperName}` - }) + Object.keys(extraTemplate?.USER_INPUTS ?? {}) + .forEach(name => { + const upperName = String(name).toUpperCase() + context[upperName] = `$${upperName}` + }) return jsonToXml({ + ...customVariables, ...extraTemplate, ...general, - CONTEXT: context, - USER_INPUTS: USER_INPUTS + CONTEXT: context }) } } diff --git a/src/fireedge/src/client/components/Tabs/Common/Attribute/Attribute.js b/src/fireedge/src/client/components/Tabs/Common/Attribute/Attribute.js index 1ef94d4a30..fad7eca4c6 100644 --- a/src/fireedge/src/client/components/Tabs/Common/Attribute/Attribute.js +++ b/src/fireedge/src/client/components/Tabs/Common/Attribute/Attribute.js @@ -137,10 +137,10 @@ const Attribute = memo(({ } - {canCopy && ( + {value && canCopy && ( )} - {canEdit && ( + {(value || numberOfParents > 0) && canEdit && ( )} {canDelete && ( diff --git a/src/fireedge/src/client/components/Tabs/Common/AttributePanel.js b/src/fireedge/src/client/components/Tabs/Common/AttributePanel.js index dc16ca049c..050662ef3c 100644 --- a/src/fireedge/src/client/components/Tabs/Common/AttributePanel.js +++ b/src/fireedge/src/client/components/Tabs/Common/AttributePanel.js @@ -28,7 +28,7 @@ * See the License for the specific language governing permissions and * * limitations under the License. * * ------------------------------------------------------------------------- */ -import { memo } from 'react' +import { memo, useCallback } from 'react' import PropTypes from 'prop-types' import makeStyles from '@mui/styles/makeStyles' @@ -57,7 +57,6 @@ const SPECIAL_ATTRIBUTES = { [DELETE]: false }, VCENTER_PASSWORD: { - [EDIT]: true, [DELETE]: false }, VCENTER_USER: { @@ -87,21 +86,24 @@ const AttributePanel = memo(({ handleEdit, handleDelete, handleAdd, - actions + actions = [], + filtersSpecialAttributes = true }) => { const classes = useStyles() + const canUseAction = useCallback((name, action) => ( + actions?.includes?.(action) && + (!filtersSpecialAttributes || SPECIAL_ATTRIBUTES[name]?.[action] === undefined) + ), [actions?.length]) + const formatAttributes = Object.entries(attributes) .map(([name, value]) => ({ name, value, showActionsOnHover: true, - canCopy: - actions?.includes?.(COPY) && !SPECIAL_ATTRIBUTES[name]?.[COPY], - canEdit: - actions?.includes?.(EDIT) && !SPECIAL_ATTRIBUTES[name]?.[EDIT], - canDelete: - actions?.includes?.(DELETE) && !SPECIAL_ATTRIBUTES[name]?.[DELETE], + canCopy: canUseAction(name, COPY), + canEdit: canUseAction(name, EDIT), + canDelete: canUseAction(name, DELETE), handleEdit, handleDelete })) @@ -124,7 +126,8 @@ AttributePanel.propTypes = { handleAdd: PropTypes.func, handleEdit: PropTypes.func, handleDelete: PropTypes.func, - title: PropTypes.string + title: PropTypes.string, + filtersSpecialAttributes: PropTypes.bool } AttributePanel.displayName = 'AttributePanel' diff --git a/src/fireedge/src/client/components/Tabs/Common/List.js b/src/fireedge/src/client/components/Tabs/Common/List.js index decc42a1c3..94d5d25c3f 100644 --- a/src/fireedge/src/client/components/Tabs/Common/List.js +++ b/src/fireedge/src/client/components/Tabs/Common/List.js @@ -36,7 +36,8 @@ const useStyles = makeStyles(theme => ({ gap: '1em', '& > *': { flex: '1 1 50%', - overflow: 'hidden' + overflow: 'hidden', + minHeight: '100%' }, '&:hover': { backgroundColor: alpha(theme.palette.text.primary, 0.05) diff --git a/src/fireedge/src/client/constants/translates.js b/src/fireedge/src/client/constants/translates.js index b922acf581..a60162eea8 100644 --- a/src/fireedge/src/client/constants/translates.js +++ b/src/fireedge/src/client/constants/translates.js @@ -328,7 +328,7 @@ module.exports = { UserTemplate: 'User Template', Template: 'Template', WhereIsRunning: - 'VM %1$s is currently running on Host %2$s and Datastore %3$s', + 'VM %1$s is currently running on Host %2$s and Datastore %3$s', /* VM schema - capacity */ Capacity: 'Capacity', PhysicalCpu: 'Physical CPU', @@ -357,11 +357,12 @@ module.exports = { ExternalConcept: 'The NIC will be attached as an external alias of the VM', /* VM Template schema */ - /* VM schema - general */ + /* VM Template schema - general */ Logo: 'Logo', Hypervisor: 'Hypervisor', TemplateName: 'Template name', MakeNewImagePersistent: 'Make the new images persistent', + CustomVariables: 'Custom Variables', /* VM schema - ownership */ InstantiateAsUser: 'Instantiate as different User', InstantiateAsGroup: 'Instantiate as different Group', diff --git a/src/fireedge/src/client/utils/helpers.js b/src/fireedge/src/client/utils/helpers.js index 27f24de7fa..9b222eb0fd 100644 --- a/src/fireedge/src/client/utils/helpers.js +++ b/src/fireedge/src/client/utils/helpers.js @@ -15,6 +15,7 @@ * ------------------------------------------------------------------------- */ import DOMPurify from 'dompurify' import { object, reach, ObjectSchema, BaseSchema } from 'yup' +import { isMergeableObject } from 'client/utils/merge' import { HYPERVISORS } from 'client/constants' /** @@ -266,6 +267,62 @@ export const groupBy = (list, key) => */ export const cloneObject = obj => JSON.parse(JSON.stringify(obj)) +/** + * Removes undefined and null values from object. + * + * @param {object} obj - Object value + * @returns {object} - Cleaned object + */ +export const cleanEmptyObject = obj => { + const entries = Object.entries(obj) + .filter(([_, value]) => + // filter object/array values without attributes + isMergeableObject(value) + ? Object.values(value).some(v => v != null) + : Array.isArray(value) ? value.length > 0 : true + ) + .map(([key, value]) => { + let cleanedValue = value + + if (isMergeableObject(value)) { + cleanedValue = cleanEmptyObject(value) + } else if (Array.isArray(value)) { + cleanedValue = cleanEmptyArray(value) + } + + return [key, cleanedValue] + }) + + return entries?.length > 0 + ? entries.reduce((cleanedObject, [key, value]) => { + // `value == null` checks against undefined and null + return value == null ? cleanedObject : { ...cleanedObject, [key]: value } + }, {}) + : undefined +} + +/** + * Removes undefined and null values from array. + * + * @param {Array} arr - Array value + * @returns {object} - Cleaned object + */ +export const cleanEmptyArray = arr => arr + .map(value => isMergeableObject(value) ? cleanEmpty(value) : value) + .filter(value => + !(value == null) || // `value == null` checks against undefined and null + (Array.isArray(value) && value.length > 0) + ) + +/** + * Removes undefined and null values from variable. + * + * @param {Array|object} variable - Variable + * @returns {Array|object} - Cleaned variable + */ +export const cleanEmpty = variable => + Array.isArray(variable) ? cleanEmptyArray(variable) : cleanEmptyObject(variable) + /** * Check if value is in base64. *