1
0
mirror of https://github.com/OpenNebula/one.git synced 2024-12-22 13:33:52 +03:00

B OpenNebula/one#6376: Fix for VM template create/update (#2962)

so it doesn't adds unnecessary parameters.

Signed-off-by: Victor Hansson <vhansson@opennebula.io>
Signed-off-by: David Carracedo <dcarracedo@opennebula.io>
Co-authored-by: Victor Hansson <vhansson@opennebula.io>
Co-authored-by: Tino Vázquez <cvazquez@opennebula.io>
This commit is contained in:
David 2024-02-28 19:06:41 +01:00 committed by GitHub
parent 0e1c0ecc49
commit 57f256606e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
83 changed files with 2462 additions and 501 deletions

View File

@ -46,7 +46,7 @@ const DialogForm = ({
dialogProps.fixedHeight ??= true
const methods = useForm({
mode: 'onBlur',
mode: 'onChange',
reValidateMode: 'onSubmit',
defaultValues: values,
resolver: yupResolver(resolver()),

View File

@ -47,12 +47,13 @@ const CheckboxController = memo(
onConditionChange,
}) => {
const {
field: { value = false, onChange },
field: { value = false, onChange, onBlur },
fieldState: { error },
} = useController({ name, control })
const handleChange = useCallback(
(e) => {
onBlur()
const condition = e.target.checked
onChange(condition)
if (typeof onConditionChange === 'function') {

View File

@ -55,7 +55,7 @@ const FileController = memo(
const { setValue, setError, clearErrors, watch } = useFormContext()
const {
field: { ref, value, onChange, ...inputProps },
field: { ref, value, onChange, onBlur, ...inputProps },
fieldState: { error },
} = useController({ name, control })
@ -92,6 +92,7 @@ const FileController = memo(
*/
const handleChange = async (event) => {
try {
onBlur()
const file = event.target.files?.[0]
if (!file) return

View File

@ -60,7 +60,7 @@ const SelectController = memo(
: firstValue
const {
field: { ref, value: optionSelected, onChange, ...inputProps },
field: { ref, value: optionSelected, onChange, onBlur, ...inputProps },
fieldState: { error },
} = useController({ name, control, defaultValue })
@ -100,6 +100,7 @@ const SelectController = memo(
const handleChange = useCallback(
(evt) => {
onBlur()
if (!multiple) {
onChange(evt)
if (typeof onConditionChange === 'function') {

View File

@ -45,7 +45,7 @@ const SliderController = memo(
const { min, max, step } = fieldProps ?? {}
const {
field: { value, onChange, ...inputProps },
field: { value, onChange, onBlur, ...inputProps },
fieldState: { error },
} = useController({ name, control })
@ -71,6 +71,7 @@ const SliderController = memo(
const handleChange = useCallback(
(_, newValue) => {
onBlur()
if (!readOnly) {
onChange(newValue)
if (typeof onConditionChange === 'function') {

View File

@ -49,12 +49,13 @@ const SwitchController = memo(
dependencies,
}) => {
const {
field: { value = false, onChange },
field: { value = false, onChange, onBlur },
fieldState: { error },
} = useController({ name, control })
const handleChange = useCallback(
(e) => {
onBlur()
const condition = e.target.checked
onChange(condition)
if (typeof onConditionChange === 'function') {

View File

@ -45,7 +45,7 @@ const TextController = memo(
})
const {
field: { ref, value = '', onChange, ...inputProps },
field: { ref, value = '', onChange, onBlur, ...inputProps },
fieldState: { error },
} = useController({ name, control })
@ -58,6 +58,7 @@ const TextController = memo(
const handleChange = useCallback(
(e) => {
onBlur()
const condition = e.target.value
onChange(condition)
if (typeof onConditionChange === 'function') {

View File

@ -53,7 +53,7 @@ const ToggleController = memo(
onConditionChange,
}) => {
const {
field: { ref, value: optionSelected, onChange },
field: { ref, value: optionSelected, onChange, onBlur },
fieldState: { error: { message } = {} },
} = useController({ name, control })
@ -65,6 +65,7 @@ const ToggleController = memo(
}, [])
const handleChange = useCallback(
(_, newValues) => {
onBlur()
if (!readOnly && (!notNull || newValues)) {
onChange(newValues)
if (typeof onConditionChange === 'function') {

View File

@ -116,6 +116,7 @@ const CustomStepper = ({
</Typography>
)
}
data-cy={`step-${id}`}
>
<StepLabel
StepIconComponent={StepIconStyled}

View File

@ -22,10 +22,11 @@ import {
memo,
useCallback,
useMemo,
useEffect,
} from 'react'
import { Accordion, AccordionSummary, FormControl, Grid } from '@mui/material'
import { useFormContext, useWatch } from 'react-hook-form'
import { useFormContext, useWatch, useFormState } from 'react-hook-form'
import * as FC from 'client/components/FormControl'
import { useDisableStep } from 'client/components/FormStepper'
@ -33,8 +34,10 @@ import Legend from 'client/components/Forms/Legend'
import { INPUT_TYPES } from 'client/constants'
import { Field, deepStringify, simpleHash } from 'client/utils'
import get from 'lodash.get'
import { get, set, merge, startsWith } from 'lodash'
import { useSelector } from 'react-redux'
import { parseTouchedDirty, isDeeplyEmpty } from 'client/utils/parser'
import { useGeneralApi } from 'client/features/General'
const NOT_DEPEND_ATTRIBUTES = [
'watcher',
@ -70,6 +73,9 @@ const INPUT_CONTROLLER = {
* @param {object} props.rootProps - The props to be passed to the root element
* @param {*} props.legend - The legend
* @param {string} props.legendTooltip - The legend tooltip
* @param {boolean} props.saveState - Save form state to redux
* @param {string} props.fieldPath - Field path to set after touched or dirty fields change
* @param {boolean} props.hiddenLegend - Hide the legend of the form
* @returns {ReactElement} - The form component
*/
const FormWithSchema = ({
@ -79,9 +85,135 @@ const FormWithSchema = ({
fields,
rootProps,
legend,
hiddenLegend = false,
legendTooltip,
saveState,
fieldPath,
}) => {
const { setModifiedFields, setFieldPath } = useGeneralApi()
const { sx: sxRoot, ...restOfRootProps } = rootProps ?? {}
const formContext = useFormContext()
const { touchedFields, dirtyFields } = useFormState({
control: formContext.control,
})
useEffect(
() => () => {
if (saveState) {
// Fields to add to the modifiedFields
let fieldsToMerge = {}
// Get the fields that are touched and dirty
const touchedDirtyFields = parseTouchedDirty(touchedFields, dirtyFields)
// Add to the fieldsToMerge
if (!isDeeplyEmpty(touchedDirtyFields)) {
fieldsToMerge = touchedDirtyFields
}
// Check hidden fields that have a dependOf that is a field touched or dirty so the hidden field has to be add to the modifiedFields
const fieldsHiddenMerge = {}
// Fields that have a value on dependOf attribute (if depend is in a different schema, the name of the field will contain the step id and starts with $)
const fieldWithDepend = fields.filter((item) =>
item.dependOf && Array.isArray(item.dependOf)
? item.dependOf.some((dependItem) =>
get(
id ? fieldsToMerge[id] : fieldsToMerge,
startsWith(dependItem, '$' + id)
? dependItem.substring(id.length + 2)
: dependItem
)
)
: get(
id ? fieldsToMerge[id] : fieldsToMerge,
startsWith(item.dependOf, '$' + id)
? item.dependOf.substring(id.length + 2)
: item.dependOf
)
)
// The fields that has a dependOf and has htmlType hidden has to be deleted
fieldWithDepend
.filter((field) => {
const htmlTypeFunction = typeof field.htmlType === 'function'
const valueDependOf = Array.isArray(field.dependOf)
? field.dependOf.map((depend) =>
formContext?.getValues(
`${id}.` +
(startsWith(depend, '$' + id)
? depend.substring(id.length + 2)
: depend)
)
)
: formContext?.getValues(
`${id}.` +
(startsWith(field.dependOf, '$' + id)
? field.dependOf.substring(id.length + 2)
: field.dependOf)
)
const hidden =
(htmlTypeFunction &&
field.htmlType(valueDependOf, formContext) === 'hidden') ||
(!htmlTypeFunction && field.htmlType === 'hidden')
return field.htmlType && hidden
})
.map((item) => item.name)
.forEach((element) => {
set(fieldsHiddenMerge, id ? `${id}.${element}` : `${element}`, {
__delete__: true,
})
})
// The fields that has a dependOf and has htmlType different that hidden has to be added
fieldWithDepend
.filter((field) => {
const htmlTypeFunction = typeof field.htmlType === 'function'
const valueDependOf = Array.isArray(field.dependOf)
? field.dependOf.map((depend) =>
formContext?.getValues(
`${id}.` +
(startsWith(depend, '$' + id)
? depend.substring(id.length + 2)
: depend)
)
)
: formContext?.getValues(
`${id}.` +
(startsWith(field.dependOf, '$' + id)
? field.dependOf.substring(id.length + 2)
: field.dependOf)
)
const notHidden =
(htmlTypeFunction &&
field.htmlType(valueDependOf, formContext) !== 'hidden') ||
(!htmlTypeFunction && field.htmlType !== 'hidden')
// return field.htmlType && notHidden
return notHidden
})
.map((item) => item.name)
.forEach((element) => {
set(fieldsHiddenMerge, id ? `${id}.${element}` : `${element}`, true)
})
// Set modified fields
const mix = merge({}, fieldsToMerge, fieldsHiddenMerge)
setModifiedFields(mix)
// If fieldPath exists, set in the store
if (fieldPath) {
setFieldPath(fieldPath)
}
}
},
[touchedFields, dirtyFields]
)
const RootWrapper = useMemo(
() =>
@ -95,12 +227,12 @@ const FormWithSchema = ({
</Accordion>
)
: Fragment,
[accordion, legend]
[accordion, legend, hiddenLegend]
)
const LegendWrapper = useMemo(
() => (accordion && legend ? AccordionSummary : Fragment),
[accordion, legend]
[accordion, legend, hiddenLegend]
)
const getFields = useMemo(
@ -118,12 +250,13 @@ const FormWithSchema = ({
>
<RootWrapper>
<LegendWrapper>
{legend && (
{legend && !hiddenLegend && (
<Legend
data-cy={`legend-${cy}`}
title={legend}
tooltip={legendTooltip}
disableGutters={accordion}
hiddenLegend={hiddenLegend}
/>
)}
</LegendWrapper>
@ -148,11 +281,15 @@ FormWithSchema.propTypes = {
legend: PropTypes.any,
legendTooltip: PropTypes.string,
rootProps: PropTypes.object,
saveState: PropTypes.bool,
fieldPath: PropTypes.string,
hiddenLegend: PropTypes.bool,
}
const FieldComponent = memo(
({ id, cy, dependOf, stepControl, ...attributes }) => {
const formContext = useFormContext()
const disableSteps = useDisableStep()
const currentState = useSelector((state) => state)
@ -179,7 +316,6 @@ const FieldComponent = memo(
const valueOfDependField = useWatch({
name: nameOfDependField,
disabled: dependOf === undefined,
defaultValue: Array.isArray(dependOf) ? [] : undefined,
})
const handleConditionChange = useCallback(

View File

@ -47,6 +47,7 @@ const Content = ({ hypervisor, oneConfig, adminGroup }) => {
fields={fields}
legend={legend}
id={STEP_ID}
saveState={true}
/>
))}
</Box>

View File

@ -29,6 +29,8 @@ const Content = ({ data, setFormData }) => {
const { handleSelect, handleClear } = useListForm({
key: STEP_ID,
setList: setFormData,
modifiedFields: ['IMAGE', 'IMAGE_UNAME'],
fieldKey: 'general',
})
const handleSelectedRows = (rows) => {
@ -45,6 +47,7 @@ const Content = ({ data, setFormData }) => {
<ImagesTable
singleSelect
disableGlobalSort
displaySelectedRows
pageSize={5}
initialState={{ selectedRowIds: { [ID]: true } }}
onSelectedRowsChange={handleSelectedRows}

View File

@ -20,6 +20,11 @@ import ImagesTable, {
STEP_ID as STEP_IMAGE,
} from 'client/components/Forms/Vm/AttachDiskForm/ImageSteps/ImagesTable'
import { createSteps, mapUserInputs } from 'client/utils'
import { store } from 'client/sunstone'
import {
setModifiedFields,
setFieldPath,
} from 'client/features/General/actions'
const Steps = createSteps([ImagesTable, AdvancedOptions], {
transformInitialValue: (initialValue) => {
@ -32,6 +37,8 @@ const Steps = createSteps([ImagesTable, AdvancedOptions], {
...diskProps
} = initialValue ?? {}
store.dispatch(setFieldPath(`extra.Storage.${diskProps?.DISK_ID}`))
return {
[STEP_IMAGE]: [
{
@ -50,6 +57,9 @@ const Steps = createSteps([ImagesTable, AdvancedOptions], {
const { [STEP_IMAGE]: [image] = [], [STEP_ADVANCED]: advanced } = formData
const { ID, NAME, UID, UNAME, STATE, SIZE, ...imageProps } = image ?? {}
imageProps?.DATASTORE_ID &&
store.dispatch(setModifiedFields({}, { batch: false }))
return {
...imageProps,
...mapUserInputs(advanced),

View File

@ -47,6 +47,7 @@ const Content = ({ hypervisor, oneConfig, adminGroup }) => {
fields={fields}
legend={legend}
id={STEP_ID}
saveState={true}
/>
))}
</Box>

View File

@ -32,7 +32,14 @@ const Content = ({ hypervisor, oneConfig, adminGroup }) => {
[]
)
return <FormWithSchema cy="attach-disk" fields={memoFields} id={STEP_ID} />
return (
<FormWithSchema
cy="attach-disk"
fields={memoFields}
id={STEP_ID}
saveState={true}
/>
)
}
/**

View File

@ -74,12 +74,16 @@ const TYPE = (hypervisor) => ({
type: INPUT_TYPES.SELECT,
values:
hypervisor === vcenter
? [{ text: 'FS', value: 'fs' }]
? [
{ text: '-', value: undefined },
{ text: 'FS', value: 'fs' },
]
: [
{ text: '-', value: undefined },
{ text: 'FS', value: 'fs' },
{ text: 'Swap', value: 'swap' },
],
validation: string().trim().notRequired().default('fs'),
validation: string().trim().required().default(undefined),
})
/**
@ -94,17 +98,22 @@ const FORMAT = (hypervisor) => ({
htmlType: (type) => type === 'swap' && INPUT_TYPES.HIDDEN,
values:
hypervisor === vcenter
? [{ text: 'Raw', value: 'raw' }]
? [
{ text: '-', value: undefined },
{ text: 'Raw', value: 'raw' },
]
: [
{ text: '-', value: undefined },
{ text: 'Raw', value: 'raw' },
{ text: 'qcow2', value: 'qcow2' },
],
validation: string()
.trim()
.when('TYPE', (type, schema) =>
type === 'swap' ? schema.notRequired() : schema.required()
)
.default('raw'),
type === 'swap'
? schema.notRequired().default(undefined)
: schema.required().default(undefined)
),
})
/** @type {Field} Filesystem field */

View File

@ -20,6 +20,11 @@ import AdvancedOptions, {
STEP_ID as ADVANCED_ID,
} from 'client/components/Forms/Vm/AttachDiskForm/VolatileSteps/AdvancedOptions'
import { mapUserInputs, createSteps, convertToMB } from 'client/utils'
import { store } from 'client/sunstone'
import {
setModifiedFields,
setFieldPath,
} from 'client/features/General/actions'
const Steps = createSteps([BasicConfiguration, AdvancedOptions], {
transformInitialValue: (disk = {}, schema) => {
@ -31,8 +36,7 @@ const Steps = createSteps([BasicConfiguration, AdvancedOptions], {
{ stripUnknown: true }
)
// #6154: Add temp id to propagate it
schemaCast[ADVANCED_ID].TEMP_ID = disk.TEMP_ID
store.dispatch(setFieldPath(`extra.Storage.${disk?.DISK_ID}`))
return schemaCast
},
@ -40,6 +44,8 @@ const Steps = createSteps([BasicConfiguration, AdvancedOptions], {
const { [BASIC_ID]: configuration = {}, [ADVANCED_ID]: advanced = {} } =
formData ?? {}
store.dispatch(setModifiedFields({}, { batch: false }))
// ISSUE#6136: Convert size to MB (because XML API uses only MB) and delete sizeunit field (no needed on XML API)
configuration.SIZE = convertToMB(configuration.SIZE, configuration.SIZEUNIT)
delete configuration.SIZEUNIT

View File

@ -45,6 +45,7 @@ const Content = (props) => {
},
}}
cy={id}
saveState={true}
fields={fields}
legend={legend}
id={STEP_ID}

View File

@ -33,6 +33,8 @@ import {
NIC_HARDWARE,
NIC_HARDWARE_STR,
PCI_TYPES,
IPV4_METHODS,
IPV6_METHODS,
} from 'client/constants'
import { useGetHostsQuery } from 'client/features/OneApi/host'
import { getPciDevices } from 'client/models/Host'
@ -64,18 +66,22 @@ const fillPCIAtributes =
/**
* @param {object} [data] - VM or VM Template data
* @param {Nic[]} [data.nics] - Current NICs
* @param {boolean} [data.hasAlias] - If it's an alias
* @param {boolean} [data.isPci] - If it's a PCI
* @returns {Field[]} List of general fields
*/
const GENERAL_FIELDS = ({ nics = [] } = {}) =>
const GENERAL_FIELDS = ({ nics = [], hasAlias = false, isPci = false } = {}) =>
[
!!nics?.length && {
name: 'PARENT',
label: T.AsAnAlias,
dependOf: 'NAME',
type: (name) => {
const hasAlias = nics?.some((nic) => nic.PARENT === name)
const hasAliasExisting = nics?.some((nic) => nic.PARENT === name)
return name && hasAlias ? INPUT_TYPES.HIDDEN : INPUT_TYPES.SELECT
return name && hasAliasExisting
? INPUT_TYPES.HIDDEN
: INPUT_TYPES.SELECT
},
values: (name) => [
{ text: '', value: '' },
@ -91,6 +97,9 @@ const GENERAL_FIELDS = ({ nics = [] } = {}) =>
return { text, value: NAME }
}),
],
fieldProps: {
disabled: hasAlias || isPci,
},
validation: string()
.trim()
.notRequired()
@ -150,10 +159,11 @@ const GUACAMOLE_CONNECTIONS = [
type: INPUT_TYPES.SELECT,
dependOf: 'RDP',
values: [
{ text: '-', value: undefined },
{ text: T.DisplayUpdate, value: 'display-update' },
{ text: T.Reconnect, value: 'reconnect' },
],
validation: string().trim().notRequired().default('display-update'),
validation: string().trim().notRequired().default(undefined),
htmlType: (noneType) => !noneType && INPUT_TYPES.HIDDEN,
grid: { sm: 6 },
},
@ -331,12 +341,12 @@ const OVERRIDE_IPV4_FIELDS = [
label: T.NetworkMethod,
tooltip: T.NetworkMethod4Concept,
type: INPUT_TYPES.SELECT,
values: [
{ text: 'static (Based on context)', value: 'static' },
{ text: 'dhcp (DHCPv4)', value: 'dhcp' },
{ text: 'skip (Do not configure IPv4)', value: 'skip' },
],
validation: string().trim().notRequired().default('static'),
values: arrayToOptions(Object.keys(IPV4_METHODS), {
getText: (key) => key,
getValue: (key) => IPV4_METHODS[key],
addEmpty: true,
}),
validation: string().trim().notRequired().default(undefined),
},
]
@ -367,19 +377,21 @@ const OVERRIDE_IPV6_FIELDS = [
label: T.NetworkMethod,
tooltip: T.NetworkMethod6Concept,
type: INPUT_TYPES.SELECT,
values: [
{ text: 'static (Based on context)', value: 'static' },
{ text: 'auto (SLAAC)', value: 'auto' },
{ text: 'dhcp (SLAAC and DHCPv6)', value: 'dhcp' },
{ text: 'disable (Do not use IPv6)', value: 'disable' },
{ text: 'skip (Do not configure IPv4)', value: 'skip' },
],
validation: string().trim().notRequired().default('static'),
values: arrayToOptions(Object.keys(IPV6_METHODS), {
getText: (key) => key,
getValue: (key) => IPV6_METHODS[key],
addEmpty: true,
}),
validation: string().trim().notRequired().default(undefined),
},
]
/** @type {Field[]} List of hardware fields */
const HARDWARE_FIELDS = (defaultData = {}) => [
const HARDWARE_FIELDS = (
defaultData = {},
hasAlias = false,
isAlias = false
) => [
{
name: PCI_TYPE_NAME,
label: T.VirtualNicHardwareMode,
@ -389,6 +401,9 @@ const HARDWARE_FIELDS = (defaultData = {}) => [
getText: (key) => NIC_HARDWARE_STR[key],
getValue: (type) => type,
}),
fieldProps: {
disabled: hasAlias || isAlias,
},
validation: string()
.trim()
.default(() => {
@ -411,6 +426,9 @@ const HARDWARE_FIELDS = (defaultData = {}) => [
htmlType: (value) => value !== NIC_HARDWARE.EMULATED && INPUT_TYPES.HIDDEN,
type: INPUT_TYPES.TEXT,
notOnHypervisors: [firecracker],
fieldProps: {
disabled: hasAlias || isAlias,
},
validation: string()
.trim()
.notRequired()
@ -422,6 +440,9 @@ const HARDWARE_FIELDS = (defaultData = {}) => [
tooltip: T.OnlySupportedForVirtioDriver,
type: INPUT_TYPES.TEXT,
notOnHypervisors: [firecracker],
fieldProps: {
disabled: hasAlias || isAlias,
},
dependOf: PCI_TYPE_NAME,
htmlType: (value) =>
value !== NIC_HARDWARE.EMULATED ? INPUT_TYPES.HIDDEN : 'number',
@ -492,13 +513,19 @@ const HARDWARE_FIELDS = (defaultData = {}) => [
)
},
validation: string()
.notRequired()
.when('PCI_TYPE', (type, schema) =>
type === NIC_HARDWARE.PCI_PASSTHROUGH_AUTOMATIC
? schema.required()
: schema.notRequired()
)
.default(() => undefined)
.afterSubmit((value, { context }) =>
context?.advanced?.PCI_TYPE === PCI_TYPES.AUTOMATIC ? value : undefined
),
grid: { md: 3 },
readOnly: true,
fieldProps: {
disabled: true,
},
},
{
name: DEVICE,
@ -516,13 +543,19 @@ const HARDWARE_FIELDS = (defaultData = {}) => [
)
},
validation: string()
.notRequired()
.when('PCI_TYPE', (type, schema) =>
type === NIC_HARDWARE.PCI_PASSTHROUGH_AUTOMATIC
? schema.required()
: schema.notRequired()
)
.default(() => undefined)
.afterSubmit((value, { context }) =>
context?.advanced?.PCI_TYPE === PCI_TYPES.AUTOMATIC ? value : undefined
),
grid: { md: 3 },
readOnly: true,
fieldProps: {
disabled: true,
},
},
{
name: CLASS,
@ -540,13 +573,19 @@ const HARDWARE_FIELDS = (defaultData = {}) => [
)
},
validation: string()
.notRequired()
.when('PCI_TYPE', (type, schema) =>
type === NIC_HARDWARE.PCI_PASSTHROUGH_AUTOMATIC
? schema.required()
: schema.notRequired()
)
.default(() => undefined)
.afterSubmit((value, { context }) =>
context?.advanced?.PCI_TYPE === PCI_TYPES.AUTOMATIC ? value : undefined
),
grid: { md: 3 },
readOnly: true,
fieldProps: {
disabled: true,
},
},
// PCI Passthrough Manual mode fields
{
@ -558,7 +597,11 @@ const HARDWARE_FIELDS = (defaultData = {}) => [
htmlType: (value) =>
value !== NIC_HARDWARE.PCI_PASSTHROUGH_MANUAL && INPUT_TYPES.HIDDEN,
validation: string()
.notRequired()
.when('PCI_TYPE', (type, schema) =>
type === NIC_HARDWARE.PCI_PASSTHROUGH_MANUAL
? schema.required()
: schema.notRequired()
)
.default(() => defaultData?.SHORT_ADDRESS)
.afterSubmit((value, { context }) =>
context?.advanced?.PCI_TYPE === PCI_TYPES.MANUAL ? value : undefined
@ -589,6 +632,9 @@ const GUEST_FIELDS = [
* @param {object} data.defaultData - VM or VM Template data
* @param {object} data.oneConfig - Config of oned.conf
* @param {boolean} data.adminGroup - User is admin or not
* @param {boolean} [data.hasAlias] - If has an alias
* @param {boolean} [data.isPci] - If it's a PCI
* @param {boolean} [data.isAlias] - If it's an alias
* @returns {Section[]} Sections
*/
const SECTIONS = ({
@ -598,6 +644,9 @@ const SECTIONS = ({
defaultData,
oneConfig,
adminGroup,
hasAlias,
isPci,
isAlias,
} = {}) => {
const filters = { driver, hypervisor }
@ -609,7 +658,10 @@ const SECTIONS = ({
id: 'general',
legend: T.General,
fields: disableFields(
filterByHypAndDriver(GENERAL_FIELDS({ nics }), filters),
filterByHypAndDriver(
GENERAL_FIELDS({ nics, hasAlias, isPci }),
filters
),
'NIC',
oneConfig,
adminGroup
@ -653,7 +705,10 @@ const SECTIONS = ({
id: 'hardware',
legend: T.Hardware,
fields: disableFields(
filterByHypAndDriver(HARDWARE_FIELDS(defaultData), filters),
filterByHypAndDriver(
HARDWARE_FIELDS(defaultData, hasAlias, isAlias),
filters
),
'NIC',
oneConfig,
adminGroup

View File

@ -25,11 +25,13 @@ import { T } from 'client/constants'
export const STEP_ID = 'network'
const Content = ({ data, setFormData }) => {
const { NAME } = data?.[0] ?? {}
const { ID } = data?.[0] ?? {}
const { handleSelect, handleClear } = useListForm({
key: STEP_ID,
setList: setFormData,
modifiedFields: ['NETWORK', 'NETWORK_UNAME'],
fieldKey: 'general',
})
const handleSelectedRows = (rows) => {
@ -44,8 +46,8 @@ const Content = ({ data, setFormData }) => {
disableGlobalSort
displaySelectedRows
pageSize={5}
getRowId={(row) => String(row.NAME)}
initialState={{ selectedRowIds: { [NAME]: true } }}
getRowId={(row) => String(row.ID)}
initialState={{ selectedRowIds: { [ID]: true } }}
onSelectedRowsChange={handleSelectedRows}
/>
)

View File

@ -44,6 +44,7 @@ const Content = (props) => {
fields={fields}
legend={legend}
id={STEP_ID}
saveState={true}
/>
))}
</Box>

View File

@ -60,9 +60,6 @@ const Steps = createSteps([NetworksTable, AdvancedOptions, QOSOptions], {
{ stripUnknown: true }
)
// #6154: Add temp id to propagate it
if (rest.TEMP_ID) castedValue[ADVANCED_ID].TEMP_ID = rest.TEMP_ID
return {
[NETWORK_ID]: [
{

View File

@ -37,7 +37,7 @@ const Backup = ({ oneConfig, adminGroup }) => (
{SECTIONS(oneConfig, adminGroup).map(({ id, ...section }) => (
<FormWithSchema
key={id}
cy="backups-conf"
cy="backup-configuration"
legend={T.Backup}
{...section}
/>

View File

@ -86,7 +86,7 @@ const Content = ({ hypervisor, oneConfig, adminGroup }) => {
error: !!errors?.CONTEXT,
},
{
id: 'backup_config',
id: 'backup',
icon: BackupIcon,
label: <Translate word={T.Backup} />,
renderContent: () => (

View File

@ -27,4 +27,4 @@ import { HYPERVISORS } from 'client/constants'
* @returns {ObjectSchema} Context schema
*/
export const SCHEMA = ({ hypervisor }) =>
object().concat(CONFIGURATION_SCHEMA).concat(FILES_SCHEMA(hypervisor))
object().concat(CONFIGURATION_SCHEMA(true)).concat(FILES_SCHEMA(hypervisor))

View File

@ -15,6 +15,7 @@
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { RefreshDouble as BackupIcon } from 'iconoir-react'
import { useEffect } from 'react'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
@ -27,19 +28,28 @@ import {
FIELDS,
} from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/backup/schema'
import { T } from 'client/constants'
import { useGeneralApi } from 'client/features/General'
const Backup = ({ oneConfig, adminGroup }) => (
<>
{SECTIONS(oneConfig, adminGroup).map(({ id, ...section }) => (
<FormWithSchema
key={id}
id={EXTRA_ID}
cy={`${EXTRA_ID}-${id}`}
{...section}
/>
))}
</>
)
const Backup = ({ oneConfig, adminGroup }) => {
const { setFieldPath } = useGeneralApi()
useEffect(() => {
setFieldPath(`extra.Backup`)
}, [])
return (
<>
{SECTIONS(oneConfig, adminGroup).map(({ id, ...section }) => (
<FormWithSchema
key={id}
id={EXTRA_ID}
saveState={true}
cy={`${EXTRA_ID}-${id}`}
{...section}
/>
))}
</>
)
}
Backup.propTypes = {
data: PropTypes.any,

View File

@ -61,8 +61,7 @@ const KEEP_LAST_FIELD = {
validation: number()
.notRequired()
.nullable(true)
.default(() => undefined)
.transform((_, val) => (val !== '' ? parseInt(val) : null)),
.default(() => undefined),
grid: { xs: 12, md: 6 },
}

View File

@ -152,8 +152,9 @@ export const FEATURE_CUSTOM_ENABLED = {
notOnHypervisors: [vcenter, firecracker, lxc],
type: INPUT_TYPES.SWITCH,
validation: boolean()
.strip()
.default(() => false),
.yesOrNo()
.default(() => false)
.afterSubmit((value) => undefined),
grid: { md: 12 },
}
@ -163,7 +164,11 @@ export const FIRMWARE = {
label: T.Firmware,
tooltip: T.FirmwareConcept,
notOnHypervisors: [firecracker, lxc],
type: ([, , custom] = []) => (custom ? INPUT_TYPES.TEXT : INPUT_TYPES.SELECT),
type: ([, , custom] = [], formContext) => {
const enabled = formContext?.getValues('extra.OS.FEATURE_CUSTOM_ENABLED')
return custom || enabled ? INPUT_TYPES.TEXT : INPUT_TYPES.SELECT
},
validation: string()
.trim()
.notRequired()

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useMemo } from 'react'
import { useEffect, useMemo } from 'react'
import PropTypes from 'prop-types'
import { Stack, FormControl } from '@mui/material'
import { SystemShut as OsIcon } from 'iconoir-react'
@ -34,15 +34,27 @@ import { TAB_ID as NIC_ID } from 'client/components/Forms/VmTemplate/CreateForm/
import { SECTIONS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting/schema'
import { T } from 'client/constants'
import { useGeneralApi } from 'client/features/General'
import { useWatch } from 'react-hook-form'
export const TAB_ID = 'OS'
const Booting = ({ hypervisor, oneConfig, adminGroup, ...props }) => {
const { setFieldPath } = useGeneralApi()
useEffect(() => {
setFieldPath(`extra.OsCpu`)
}, [])
const sections = useMemo(
() => SECTIONS(hypervisor, oneConfig, adminGroup),
[hypervisor]
)
const kernelWatch = useWatch({ name: `${EXTRA_ID}.OS.KERNEL` })
const kernelDsWatch = useWatch({ name: `${EXTRA_ID}.OS.KERNEL_DS` })
useEffect(() => {}, [kernelWatch, kernelDsWatch])
return (
<Stack
display="grid"
@ -64,7 +76,9 @@ const Booting = ({ hypervisor, oneConfig, adminGroup, ...props }) => {
<FormWithSchema
key={id}
id={EXTRA_ID}
saveState={true}
cy={`${EXTRA_ID}-${id}`}
hiddenLegend={id === 'os-ramdisk' && !kernelWatch && !kernelDsWatch}
{...section}
/>
))}

View File

@ -19,7 +19,11 @@ import { HYPERVISORS, IMAGE_TYPES_STR, INPUT_TYPES, T } from 'client/constants'
import { useGetAllImagesQuery } from 'client/features/OneApi/image'
import { getType } from 'client/models/Image'
import { Field, clearNames } from 'client/utils'
import { KERNEL_DS_NAME, KERNEL_NAME } from './kernelSchema'
import {
KERNEL_DS_NAME,
KERNEL_NAME,
KERNEL_PATH_ENABLED_NAME,
} from './kernelSchema'
const { vcenter, lxc } = HYPERVISORS
@ -39,6 +43,7 @@ export const RAMDISK_PATH_ENABLED = {
notOnHypervisors: [vcenter, lxc],
type: INPUT_TYPES.SWITCH,
dependOf: [`$extra.${KERNEL_DS_NAME}`, `$extra.${KERNEL_NAME}`],
htmlType: ([ds, path] = []) => !(ds || path) && INPUT_TYPES.HIDDEN,
fieldProps: (_, form) => {
const ds = form?.getValues(`extra.${KERNEL_DS_NAME}`)
const path = form?.getValues(`extra.${KERNEL_NAME}`)
@ -64,9 +69,11 @@ export const RAMDISK_DS = {
dependOf: [
RAMDISK_PATH_ENABLED.name,
`$extra.${KERNEL_DS_NAME}`,
`$extra${KERNEL_NAME}`,
`$extra.${KERNEL_NAME}`,
`$extra.${KERNEL_PATH_ENABLED_NAME}`,
],
htmlType: ([enabled = false] = []) => enabled && INPUT_TYPES.HIDDEN,
htmlType: ([enabled = false, ds, path] = []) =>
(enabled || !(ds || path)) && INPUT_TYPES.HIDDEN,
fieldProps: (_, form) => {
const ds = form?.getValues(`extra.${KERNEL_DS_NAME}`)
const path = form?.getValues(`extra.${KERNEL_NAME}`)
@ -117,7 +124,8 @@ export const RAMDISK = {
dependOf: [
RAMDISK_PATH_ENABLED.name,
`$extra.${KERNEL_DS_NAME}`,
`$extra${KERNEL_NAME}`,
`$extra.${KERNEL_NAME}`,
`$extra.${KERNEL_PATH_ENABLED_NAME}`,
],
fieldProps: (_, form) => {
const ds = form?.getValues(`extra.${KERNEL_DS_NAME}`)
@ -135,7 +143,8 @@ export const RAMDISK = {
return ds || path ? {} : { disabled: true }
},
htmlType: ([enabled = false] = []) => !enabled && INPUT_TYPES.HIDDEN,
htmlType: ([enabled = false, ds, path] = []) =>
(!enabled || !(ds || path)) && INPUT_TYPES.HIDDEN,
validation: ramdiskValidation,
}

View File

@ -44,7 +44,10 @@ const DATA = {
type: INPUT_TYPES.TEXT,
multiline: true,
notOnHypervisors: [lxc, vcenter, firecracker],
validation: string().trim().notRequired(),
validation: string()
.trim()
.notRequired()
.afterSubmit((value) => (value === '' ? undefined : value)),
grid: { md: 12 },
}
@ -55,7 +58,9 @@ const VALIDATE = {
tooltip: T.RawValidateConcept,
type: INPUT_TYPES.CHECKBOX,
notOnHypervisors: [lxc, vcenter, firecracker],
validation: boolean().yesOrNo(),
dependOf: DATA.name,
htmlType: (data) => !data && INPUT_TYPES.HIDDEN,
validation: boolean().yesOrNo().default(false),
grid: { md: 12 },
}

View File

@ -25,22 +25,30 @@ const switchField = {
}
/** @type {Field} SSH public key field */
export const SSH_PUBLIC_KEY = {
export const SSH_PUBLIC_KEY = (isUpdate) => ({
name: 'CONTEXT.SSH_PUBLIC_KEY',
label: T.SshPublicKey,
type: INPUT_TYPES.TEXT,
multiline: true,
validation: string().trim().notRequired().ensure(),
validation: string()
.trim()
.notRequired()
.ensure()
.default(() => (isUpdate ? undefined : '$USER[SSH_PUBLIC_KEY]')),
grid: { md: 12 },
fieldProps: { rows: 4 },
}
})
/** @type {Field} Network context field */
const NETWORK = {
name: 'CONTEXT.NETWORK',
label: T.AddNetworkContextualization,
tooltip: T.AddNetworkContextualizationConcept,
...switchField,
type: INPUT_TYPES.SWITCH,
validation: boolean()
.yesOrNo()
.default(() => true),
grid: { md: 12 },
}
/** @type {Field} Token OneGate token field */
@ -107,9 +115,10 @@ export const SCRIPT_FIELDS = [START_SCRIPT, ENCODE_START_SCRIPT]
export const OTHER_FIELDS = [NETWORK, TOKEN, REPORT_READY]
/** @type {ObjectSchema} User context configuration schema */
export const CONFIGURATION_SCHEMA = getObjectSchemaFromFields([
SSH_PUBLIC_KEY,
START_SCRIPT_BASE64,
...SCRIPT_FIELDS,
...OTHER_FIELDS,
])
export const CONFIGURATION_SCHEMA = (isUpdate) =>
getObjectSchemaFromFields([
SSH_PUBLIC_KEY(isUpdate),
START_SCRIPT_BASE64,
...SCRIPT_FIELDS,
...OTHER_FIELDS,
])

View File

@ -17,6 +17,7 @@ import { ReactElement, useCallback, useMemo } from 'react'
import PropTypes from 'prop-types'
import { Stack, FormControl, Button } from '@mui/material'
import { useFormContext } from 'react-hook-form'
import { useGeneralApi } from 'client/features/General'
import { FormWithSchema, Legend } from 'client/components/Forms'
import { SSH_PUBLIC_KEY, SCRIPT_FIELDS, OTHER_FIELDS } from './schema'
@ -33,12 +34,14 @@ const SSH_KEY_USER = '$USER[SSH_PUBLIC_KEY]'
* @param {string} [props.stepId] - ID of the step the section belongs to
* @param {object} props.oneConfig - Config of oned.conf
* @param {boolean} props.adminGroup - User is admin or not
* @param {boolean} props.isUpdate - If it's an update of the form
* @returns {ReactElement} - Configuration section
*/
const ConfigurationSection = ({ stepId, oneConfig, adminGroup }) => {
const ConfigurationSection = ({ stepId, oneConfig, adminGroup, isUpdate }) => {
const { setValue, getValues } = useFormContext()
const { setModifiedFields, setFieldPath } = useGeneralApi()
const SSH_PUBLIC_KEY_PATH = useMemo(
() => [stepId, SSH_PUBLIC_KEY.name].filter(Boolean).join('.'),
() => [stepId, SSH_PUBLIC_KEY(isUpdate).name].filter(Boolean).join('.'),
[stepId]
)
@ -47,16 +50,35 @@ const ConfigurationSection = ({ stepId, oneConfig, adminGroup }) => {
[stepId]
)
const handleClearKey = useCallback(
() => setValue(SSH_PUBLIC_KEY_PATH),
[setValue, SSH_PUBLIC_KEY_PATH]
)
const handleClearKey = useCallback(() => {
setValue(SSH_PUBLIC_KEY_PATH, '')
// Set as delete
setFieldPath('extra.Context')
setModifiedFields({
extra: {
CONTEXT: {
SSH_PUBLIC_KEY: { __delete__: true },
},
},
})
}, [setValue, SSH_PUBLIC_KEY_PATH])
const handleAddUserKey = useCallback(() => {
let currentKey = getValues(SSH_PUBLIC_KEY_PATH)
currentKey &&= currentKey + '\n'
setValue(SSH_PUBLIC_KEY_PATH, `${currentKey ?? ''}${SSH_KEY_USER}`)
// Set as update
setFieldPath('extra.Context')
setModifiedFields({
extra: {
CONTEXT: {
SSH_PUBLIC_KEY: true,
},
},
})
}, [getValues, setValue, SSH_PUBLIC_KEY_PATH])
return (
@ -69,15 +91,17 @@ const ConfigurationSection = ({ stepId, oneConfig, adminGroup }) => {
>
<FormWithSchema
id={stepId}
saveState={true}
cy={getCyPath('context-configuration-others')}
fields={disableFields(OTHER_FIELDS, 'CONTEXT', oneConfig, adminGroup)}
/>
<section>
<FormWithSchema
id={stepId}
saveState={true}
cy={getCyPath('context-ssh-public-key')}
fields={disableFields(
[SSH_PUBLIC_KEY],
[SSH_PUBLIC_KEY(isUpdate)],
'CONTEXT',
oneConfig,
adminGroup
@ -96,6 +120,7 @@ const ConfigurationSection = ({ stepId, oneConfig, adminGroup }) => {
onClick={handleClearKey}
color="secondary"
variant="outlined"
data-cy={getCyPath('delete-context-ssh-public-key')}
>
{T.Clear}
</Button>
@ -103,6 +128,7 @@ const ConfigurationSection = ({ stepId, oneConfig, adminGroup }) => {
</section>
<FormWithSchema
id={stepId}
saveState={true}
cy={getCyPath('context-script')}
fields={disableFields(
SCRIPT_FIELDS,
@ -122,6 +148,7 @@ ConfigurationSection.propTypes = {
hypervisor: PropTypes.string,
oneConfig: PropTypes.object,
adminGroup: PropTypes.bool,
isUpdate: PropTypes.bool,
}
export default ConfigurationSection

View File

@ -38,8 +38,9 @@ export const SECTION_ID = 'CONTEXT'
* @returns {ReactElement} - Context vars section
*/
const ContextVarsSection = ({ stepId, hypervisor }) => {
const { enqueueError } = useGeneralApi()
const { enqueueError, setModifiedFields, setFieldPath } = useGeneralApi()
const { setValue } = useFormContext()
const customVars = useWatch({
name: [stepId, SECTION_ID].filter(Boolean).join('.'),
})
@ -68,6 +69,17 @@ const ContextVarsSection = ({ stepId, hypervisor }) => {
// When the path is not found, it means that
// the attribute is correct and we can set it
setValue(formPath, newValue)
// Set as update if the newValue is not undefined and delete if the newValue is undefined
// Set as delete
setFieldPath('extra.Context')
setModifiedFields({
extra: {
CONTEXT: {
[path]: newValue ? true : { __delete__: true },
},
},
})
}
},
[hypervisor]

View File

@ -15,6 +15,7 @@
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { Folder as ContextIcon } from 'iconoir-react'
import { useEffect } from 'react'
import {
TabType,
@ -28,17 +29,25 @@ import FilesSection from './filesSection'
import ContextVarsSection from './contextVarsSection'
import { T } from 'client/constants'
import { useGeneralApi } from 'client/features/General'
export const TAB_ID = ['CONTEXT', USER_INPUTS_ID]
const Context = (props) => (
<>
<ConfigurationSection stepId={EXTRA_ID} {...props} />
<UserInputsSection {...props} />
<FilesSection stepId={EXTRA_ID} {...props} />
<ContextVarsSection stepId={EXTRA_ID} />
</>
)
const Context = (props) => {
const { setFieldPath } = useGeneralApi()
useEffect(() => {
setFieldPath(`extra.Context`)
}, [])
return (
<>
<ConfigurationSection stepId={EXTRA_ID} {...props} />
<UserInputsSection {...props} />
<FilesSection stepId={EXTRA_ID} {...props} />
<ContextVarsSection stepId={EXTRA_ID} />
</>
)
}
Context.propTypes = {
data: PropTypes.any,

View File

@ -21,11 +21,12 @@ import { FILES_SCHEMA } from './filesSchema'
/**
* @param {string} [hypervisor] - VM hypervisor
* @param {boolean} isUpdate - If it's an update of the form
* @returns {ObjectSchema} Context schema
*/
export const SCHEMA = (hypervisor) =>
export const SCHEMA = (hypervisor, isUpdate) =>
object()
.concat(CONFIGURATION_SCHEMA)
.concat(CONFIGURATION_SCHEMA(isUpdate))
.concat(USER_INPUTS_SCHEMA)
.concat(FILES_SCHEMA(hypervisor))

View File

@ -13,7 +13,16 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ObjectSchema, array, boolean, number, object, ref, string } from 'yup'
import {
object,
array,
string,
boolean,
number,
ref,
ObjectSchema,
mixed,
} from 'yup'
import {
INPUT_TYPES,
@ -197,15 +206,13 @@ const DEFAULT_VALUE = {
type === uiBoolean
? arrayToOptions(['NO', 'YES'])
: arrayToOptions(options),
validation: string()
.trim()
validation: mixed()
.default(() => undefined)
.when(
[TYPE.name, OPTIONS.name],
(type, options = [], schema) =>
({
[uiList]: schema.oneOf(options).notRequired(),
[uiListMultiple]: schema.includesInOptions(options),
[uiRange]: number().min(ref(MIN.name)).max(ref(MAX.name)).integer(),
[uiRangeFloat]: number().min(ref(MIN.name)).max(ref(MAX.name)),
[uiPassword]: schema.strip().notRequired(),

View File

@ -78,11 +78,17 @@ const UserItemDraggable = styled(ListItem)(({ theme }) => ({
}))
const UserInputItem = forwardRef(
({ removeAction, error, userInput: { name, ...ui } = {}, ...props }, ref) => (
(
{ removeAction, error, userInput: { name, ...ui } = {}, index, ...props },
ref
) => (
<UserItemDraggable
ref={ref}
secondaryAction={
<IconButton onClick={removeAction}>
<IconButton
onClick={removeAction}
data-cy={`delete-userInput-${index}`}
>
<DeleteCircledOutline />
</IconButton>
}
@ -110,6 +116,7 @@ UserInputItem.propTypes = {
removeAction: PropTypes.func,
error: PropTypes.object,
userInput: PropTypes.object,
index: PropTypes.string,
}
UserInputItem.displayName = 'UserInputItem'
@ -143,6 +150,10 @@ const UserInputsSection = ({ oneConfig, adminGroup }) => {
methods.reset()
}
const onDelete = (index) => {
remove(index)
}
/** @param {DropResult} result - Drop result */
const onDragEnd = (result) => {
const { destination, source } = result ?? {}
@ -165,6 +176,7 @@ const UserInputsSection = ({ oneConfig, adminGroup }) => {
>
<FormWithSchema
cy={`${EXTRA_ID}-context-user-input`}
saveState={true}
fields={disableFields(
USER_INPUT_FIELDS,
'USER_INPUTS',
@ -172,6 +184,7 @@ const UserInputsSection = ({ oneConfig, adminGroup }) => {
adminGroup
)}
rootProps={{ sx: { m: 0 } }}
fieldPath={`${EXTRA_ID}.Context`}
/>
<Button
variant="contained"
@ -199,10 +212,11 @@ const UserInputsSection = ({ oneConfig, adminGroup }) => {
{({ draggableProps, dragHandleProps, innerRef }) => (
<UserInputItem
key={id}
index={index}
ref={innerRef}
userInput={userInput}
error={get(errors, `${EXTRA_ID}.${SECTION_ID}.${index}`)}
removeAction={() => remove(index)}
removeAction={() => onDelete(index)}
{...draggableProps}
{...dragHandleProps}
/>

View File

@ -62,7 +62,7 @@ export const TABS = [
Backup,
]
const Content = ({ data, setFormData, oneConfig, adminGroup }) => {
const Content = ({ data, setFormData, oneConfig, adminGroup, isUpdate }) => {
const {
watch,
formState: { errors },
@ -97,6 +97,7 @@ const Content = ({ data, setFormData, oneConfig, adminGroup }) => {
control,
oneConfig,
adminGroup,
isUpdate,
}}
/>
),
@ -116,11 +117,12 @@ const Content = ({ data, setFormData, oneConfig, adminGroup }) => {
* @returns {object} Optional configuration step
*/
const ExtraConfiguration = ({
dataTemplateExtended: vmTemplate,
apiTemplateDataExtended: vmTemplate,
oneConfig,
adminGroup,
}) => {
const initialHypervisor = vmTemplate?.TEMPLATE?.HYPERVISOR
const isUpdate = vmTemplate?.NAME
return {
id: STEP_ID,
@ -128,10 +130,10 @@ const ExtraConfiguration = ({
resolver: (formData) => {
const hypervisor = formData?.[GENERAL_ID]?.HYPERVISOR ?? initialHypervisor
return SCHEMA(hypervisor)
return SCHEMA(hypervisor, isUpdate)
},
optionsValidate: { abortEarly: false },
content: (props) => Content({ ...props, oneConfig, adminGroup }),
content: (props) => Content({ ...props, oneConfig, adminGroup, isUpdate }),
}
}
@ -140,6 +142,7 @@ Content.propTypes = {
setFormData: PropTypes.func,
oneConfig: PropTypes.object,
adminGroup: PropTypes.bool,
isUpdate: PropTypes.bool,
}
export default ExtraConfiguration

View File

@ -18,6 +18,7 @@ import { Stack } from '@mui/material'
import { DataTransferBoth as IOIcon } from 'iconoir-react'
import { FormWithSchema } from 'client/components/Forms'
import { useEffect } from 'react'
import {
STEP_ID as EXTRA_ID,
@ -28,41 +29,50 @@ import PciDevicesSection, { SECTION_ID as PCI_ID } from './pciDevicesSection'
import VideoSection, { SECTION_ID as VIDEO_ID } from './videoSection'
import { GRAPHICS_FIELDS } from './schema'
import { T } from 'client/constants'
import { useGeneralApi } from 'client/features/General'
export const TAB_ID = ['GRAPHICS', INPUT_ID, PCI_ID, VIDEO_ID]
const InputOutput = ({ hypervisor, oneConfig, adminGroup }) => (
<Stack
display="grid"
gap="1em"
sx={{ gridTemplateColumns: { sm: '1fr', md: '1fr 1fr' } }}
>
<FormWithSchema
cy={`${EXTRA_ID}-io-graphics`}
fields={GRAPHICS_FIELDS(hypervisor, oneConfig, adminGroup)}
legend={T.Graphics}
id={EXTRA_ID}
/>
<InputsSection
stepId={EXTRA_ID}
hypervisor={hypervisor}
oneConfig={oneConfig}
adminGroup={adminGroup}
/>
<PciDevicesSection
stepId={EXTRA_ID}
hypervisor={hypervisor}
oneConfig={oneConfig}
adminGroup={adminGroup}
/>
<VideoSection
stepId={EXTRA_ID}
hypervisor={hypervisor}
oneConfig={oneConfig}
adminGroup={adminGroup}
/>
</Stack>
)
const InputOutput = ({ hypervisor, oneConfig, adminGroup }) => {
const { setFieldPath } = useGeneralApi()
useEffect(() => {
setFieldPath(`extra.InputOutput`)
}, [])
return (
<Stack
display="grid"
gap="1em"
sx={{ gridTemplateColumns: { sm: '1fr', md: '1fr 1fr' } }}
>
<FormWithSchema
cy={`${EXTRA_ID}-io-graphics`}
fields={GRAPHICS_FIELDS(hypervisor, oneConfig, adminGroup)}
legend={T.Graphics}
id={EXTRA_ID}
saveState={true}
/>
<InputsSection
stepId={EXTRA_ID}
hypervisor={hypervisor}
oneConfig={oneConfig}
adminGroup={adminGroup}
/>
<PciDevicesSection
stepId={EXTRA_ID}
hypervisor={hypervisor}
oneConfig={oneConfig}
adminGroup={adminGroup}
/>
<VideoSection
stepId={EXTRA_ID}
hypervisor={hypervisor}
oneConfig={oneConfig}
adminGroup={adminGroup}
/>
</Stack>
)
}
InputOutput.propTypes = {
data: PropTypes.any,

View File

@ -22,7 +22,6 @@ import ListItemText from '@mui/material/ListItemText'
import { DeleteCircledOutline, AddCircledOutline } from 'iconoir-react'
import { useFieldArray, useForm, FormProvider } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import { FormWithSchema, Legend } from 'client/components/Forms'
import { Tr, Translate } from 'client/components/HOC'
@ -36,6 +35,7 @@ import { T, HYPERVISORS } from 'client/constants'
import SubmitButton from 'client/components/FormControl/SubmitButton'
import { hasRestrictedAttributes } from 'client/utils'
export const SECTION_ID = 'INPUT'
const InputsSection = memo(
@ -79,6 +79,10 @@ const InputsSection = memo(
methods.reset()
}
const onDelete = (index) => {
remove(index)
}
if (fields.length === 0) {
return null
}
@ -98,6 +102,8 @@ const InputsSection = memo(
cy={getCyPath('io-inputs')}
fields={fields}
rootProps={{ sx: { m: 0 } }}
saveState={true}
fieldPath={`${stepId}.InputOutput`}
/>
<Button
variant="contained"
@ -119,7 +125,7 @@ const InputsSection = memo(
const busIcon = busTypeIcons[BUS]
const busInfo = `${BUS}`
// Disable action if the nic has a restricted attribute on the template
// Disable action if the input has a restricted attribute on the template
const disabledAction =
!adminGroup &&
hasRestrictedAttributes(
@ -134,10 +140,11 @@ const InputsSection = memo(
key={id}
secondaryAction={
<SubmitButton
onClick={() => remove(index)}
onClick={() => onDelete(index)}
icon=<DeleteCircledOutline />
disabled={disabledAction}
tooltip={tooltip}
data-cy={`input-delete-${index}`}
/>
}
sx={{ '&:hover': { bgcolor: 'action.hover' } }}

View File

@ -129,13 +129,28 @@ const commonHiddenFieldProps = (name) => ({
})
/** @type {Field} PCI device field */
const DEVICE_FIELD = { label: T.Device, ...commonFieldProps('DEVICE') }
const DEVICE_FIELD = {
label: T.Device,
fieldProps: { disabled: true },
tooltip: T.DeviceTooltip,
...commonFieldProps('DEVICE'),
}
/** @type {Field} PCI device field */
const VENDOR_FIELD = { label: T.Vendor, ...commonFieldProps('VENDOR') }
const VENDOR_FIELD = {
label: T.Vendor,
fieldProps: { disabled: true },
tooltip: T.VendorTooltip,
...commonFieldProps('VENDOR'),
}
/** @type {Field} PCI device field */
const CLASS_FIELD = { label: T.Class, ...commonFieldProps('CLASS') }
const CLASS_FIELD = {
label: T.Class,
fieldProps: { disabled: true },
tooltip: T.ClassTooltip,
...commonFieldProps('CLASS'),
}
/** @type {Field} PCI device field */
const SHORT_ADDRESS_FIELD = { ...commonHiddenFieldProps('SHORT_ADDRESS') }
@ -184,5 +199,5 @@ export const PCI_SCHEMA = getObjectSchemaFromFields([
/** @type {ObjectSchema} PCI devices schema */
export const PCI_DEVICES_SCHEMA = object({
PCI: array(PCI_SCHEMA).ensure(),
PCI: array().ensure(),
})

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement, useMemo } from 'react'
import { ReactElement, useMemo, useEffect } from 'react'
import PropTypes from 'prop-types'
import { Stack, FormControl, Divider, Button } from '@mui/material'
import List from '@mui/material/List'
@ -37,6 +37,8 @@ import { T, HYPERVISORS } from 'client/constants'
import { hasRestrictedAttributes } from 'client/utils'
import SubmitButton from 'client/components/FormControl/SubmitButton'
import { useGeneralApi } from 'client/features/General'
export const SECTION_ID = 'PCI'
/**
@ -64,6 +66,17 @@ const PciDevicesSection = ({ stepId, hypervisor, oneConfig, adminGroup }) => {
name: [stepId, SECTION_ID].filter(Boolean).join('.'),
})
const { setModifiedFields, setFieldPath, initModifiedFields } =
useGeneralApi()
useEffect(() => {
// Init pci devices modified fields
setFieldPath(`extra.InputOutput.PCI`)
initModifiedFields([
...pciDevices.map((element, index) => ({ __aliasPci__: index })),
])
}, [])
const methods = useForm({
defaultValues: PCI_SCHEMA.default(),
resolver: yupResolver(PCI_SCHEMA),
@ -71,10 +84,18 @@ const PciDevicesSection = ({ stepId, hypervisor, oneConfig, adminGroup }) => {
const onSubmit = (newInput) => {
delete newInput.DEVICE_NAME
setFieldPath(`${stepId}.InputOutput.PCI.${pciDevices.length}`)
append(newInput)
methods.reset()
}
const onDelete = (index) => {
setFieldPath(`${stepId}.InputOutput.PCI.${index}`)
setModifiedFields({ __delete__: true })
remove(index)
}
return (
<FormControl
component="fieldset"
@ -93,6 +114,8 @@ const PciDevicesSection = ({ stepId, hypervisor, oneConfig, adminGroup }) => {
cy={[stepId, 'io-pci-devices'].filter(Boolean).join('.')}
fields={fields}
rootProps={{ sx: { m: 0 } }}
saveState={true}
fieldPath={`${stepId}.InputOutput`}
/>
<Button
variant="contained"
@ -100,6 +123,7 @@ const PciDevicesSection = ({ stepId, hypervisor, oneConfig, adminGroup }) => {
color="secondary"
startIcon={<AddCircledOutline />}
sx={{ mt: '1em' }}
data-cy={[stepId, 'add-io-pci-devices'].filter(Boolean).join('-')}
>
<Translate word={T.Add} />
</Button>
@ -141,10 +165,11 @@ const PciDevicesSection = ({ stepId, hypervisor, oneConfig, adminGroup }) => {
key={id}
secondaryAction={
<SubmitButton
onClick={() => remove(index)}
onClick={() => onDelete(index)}
icon=<DeleteCircledOutline />
disabled={disabledAction}
tooltip={tooltip}
data-cy={`pci-delete-${index}`}
/>
}
sx={{ '&:hover': { bgcolor: 'action.hover' } }}

View File

@ -62,7 +62,7 @@ export const VIDEO_TYPE = {
!context?.general?.HYPERVISOR
// Not send to the request the value if it's not a valid hypervisor
return validHypervisor && value !== VIDEO_TYPES.auto ? value : undefined
return validHypervisor && value !== VIDEO_TYPES.auto ? value : ''
}),
grid: { sm: 3, md: 3 },
}

View File

@ -62,6 +62,7 @@ const VideoSection = ({ stepId, hypervisor, oneConfig, adminGroup }) => {
fields={fields}
legend={T.Video}
rootProps={{ sx: { gridColumn: '1 / -1' } }}
saveState={true}
id={stepId}
/>
)

View File

@ -17,7 +17,7 @@ import PropTypes from 'prop-types'
import { Stack } from '@mui/material'
import { ServerConnection as NetworkIcon } from 'iconoir-react'
import { useFormContext, useFieldArray } from 'react-hook-form'
import { useEffect } from 'react'
import { FormWithSchema } from 'client/components/Forms'
import NicCard from 'client/components/Cards/NicCard'
import {
@ -36,6 +36,7 @@ import {
} from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting'
import { FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/networking/schema'
import { T, PCI_TYPES } from 'client/constants'
import { useGeneralApi } from 'client/features/General'
export const TAB_ID = ['NIC', 'NIC_ALIAS', 'PCI']
@ -44,6 +45,35 @@ const mapAliasNameFunction = mapNameByIndex(TAB_ID[1])
const mapPCINameFunction = mapNameByIndex(TAB_ID[2])
const Networking = ({ hypervisor, oneConfig, adminGroup }) => {
const {
setModifiedFields,
setFieldPath,
initModifiedFields,
changePositionModifiedFields,
} = useGeneralApi()
useEffect(() => {
// Init nic modified fields
setFieldPath(`extra.Network.NIC`)
initModifiedFields([
...nics.map((element, index) => ({ __nicIndex__: index })),
])
// Init alias modified fields
setFieldPath(`extra.Network.NIC_ALIAS`)
initModifiedFields([
...alias.map((element, index) => ({ __aliasIndex__: index })),
])
// Init pci modified fields
setFieldPath(`extra.InputOutput.PCI`)
initModifiedFields([
...pcis.map((element, index) => ({ __aliasPci__: index })),
])
// Set field to network
setFieldPath(`extra.Network`)
}, [])
const { setValue, getValues } = useFormContext()
const {
@ -73,7 +103,20 @@ const Networking = ({ hypervisor, oneConfig, adminGroup }) => {
name: `${EXTRA_ID}.${TAB_ID[2]}`,
})
const removeAndReorder = (nic) => {
// Delay execution until next event loop tick to ensure state updates
useEffect(() => {
setFieldPath(`extra.Network`)
}, [])
/**
* Remove a nic and reorder the array of nics, alias or pcis. Also, update boot order.
*
* @param {object} nic - Nic to delete
* @param {string} idNic - If of the nic in the array
* @param {object} updatedNic - Nic to update
*/
const removeAndReorder = (nic, idNic, updatedNic) => {
// Get nic name and if it is alias or pci
const nicName = nic?.NAME
const isAlias = !!nic?.PARENT?.length
const isPCI = nic?.TYPE === 'NIC'
@ -81,21 +124,47 @@ const Networking = ({ hypervisor, oneConfig, adminGroup }) => {
let list
let mapFunction
if (isAlias) {
list = alias
mapFunction = mapAliasNameFunction
} else if (isPCI) {
if (isPCI) {
// Select list and map name function with pci type
list = pcis
mapFunction = mapPCINameFunction
// Find the id of the nic in pci array
const indexRemove = list.findIndex((nicPci) => nicPci.id === idNic)
// Set field path on the index of pci array to set delete flag in this element
setFieldPath(`extra.InputOutput.PCI.${indexRemove}`)
} else if (isAlias) {
// Select list and map name function with alias type
list = alias
mapFunction = mapAliasNameFunction
// Find the id of the nic in alias array
const indexRemove = list.findIndex((nicAlias) => nicAlias.id === idNic)
// Set field path on the index of alias array to set delete flag in this element
setFieldPath(`extra.Network.NIC_ALIAS.${indexRemove}`)
} else {
// Select list and map name function with nic type
list = nics
mapFunction = mapNicNameFunction
// Find the id of the nic in nics array
const indexRemove = list.findIndex(
(nicNetwork) => nicNetwork.id === idNic
)
// Set field path on the index of nics array to set delete flag in this element
setFieldPath(`extra.Network.NIC.${indexRemove}`)
}
const updatedList = list
.filter(({ NAME }) => NAME !== nicName)
.map(mapFunction)
// Set delete flag in modified fields
setModifiedFields({ __flag__: 'DELETE' })
// Update list selected (nics, alias or pcis) names. Names are based on index, so the names of the list elements are calculated and updated
const updatedList = list.filter(({ id }) => idNic !== id).map(mapFunction)
// Update boot order of booting section (boot order has disks and nics, so if we delete a nic, we need to update it)
const currentBootOrder = getValues(BOOT_ORDER_NAME())
const updatedBootOrder = reorderBootAfterRemove(
nicName,
@ -103,6 +172,31 @@ const Networking = ({ hypervisor, oneConfig, adminGroup }) => {
currentBootOrder
)
// Set modifiedFields with boout order to update it
setValue(BOOT_ORDER_NAME(), updatedBootOrder)
// Update refernces to NAME in alias items
if (!isAlias) {
alias.forEach((aliasItem) => {
const oldParent = list.find((item) => item.NAME === aliasItem.PARENT)
if (oldParent) {
const newParent = updatedList.find((item) => item.id === oldParent.id)
aliasItem.PARENT = newParent.NAME
}
})
if (updatedNic) {
const oldParent = list.find((item) => item.NAME === updatedNic.PARENT)
if (oldParent) {
const newParent = updatedList.find((item) => item.id === oldParent.id)
updatedNic.PARENT = newParent.NAME
}
}
replaceAlias(alias)
}
// Replace the list (nics, alias or pcis) with the new values after delete the element
if (isAlias) {
replaceAlias(updatedList)
} else if (isPCI) {
@ -110,57 +204,237 @@ const Networking = ({ hypervisor, oneConfig, adminGroup }) => {
} else {
replaceNic(updatedList)
}
setValue(BOOT_ORDER_NAME(), updatedBootOrder)
}
/**
* Update a nic with the different cases that could be because nics, alias and pcis are on different array forms.
*
* @param {object} nic - The nic to update
* @param {string} nic.NAME - Name of the nic
* @param {string} id - The id of the nic in the array form
* @param {object} nicForDelete - Nic to delete if user changes nic, alias or pci
* @returns {void} - Void value
*/
const handleUpdate = ({ NAME: _, ...updatedNic }, id, nicForDelete) => {
// Check if the nic is alias or pci
const isAlias = !!updatedNic?.PARENT?.length
const isPCI = Object.values(PCI_TYPES).includes(updatedNic?.PCI_TYPE)
if (isAlias) {
const index = alias.findIndex((nic) => nic.id === id)
if (index === -1) {
removeAndReorder(nicForDelete)
handleAppend(updatedNic)
if (isPCI) {
// Get the index of the pci in the pci array
const indexPci = pcis.findIndex((nic) => nic.id === id)
// If the index is equal to -1, that's mean that it's an element that before is not a pci, but in this update, user change this element from nic or alias type to pci.
// In this case, we need to delete the old element (that is on nics or alias array) and add to the pci arrays.
if (indexPci === -1) {
// Check if the old element is in nic or alias array and get the index
const indexNic = nics.findIndex((nic) => nic.id === id)
const indexAlias = alias.findIndex((nic) => nic.id === id)
// If the old element it's on nics array, we need to get the state (if it was deleted or updated) of the element from Network.NIC of modifiedFields and set on Network.PCI of modifiedFields
if (indexNic !== -1) {
changePositionModifiedFields({
sourcePath: 'extra.Network.NIC',
sourcePosition: indexNic,
targetPath: 'extra.InputOutput.PCI',
targetPosition: pcis.length,
sourceDelete: false,
emptyObjectContent: true,
})
}
// If the old element it's on alias array, we need to get the state (if it was deleted or updated) of the element from Network.NIC_ALIAS of moodifiedFields and set on Network.PCI of modifiedFields
if (indexAlias !== -1) {
changePositionModifiedFields({
sourcePath: 'extra.Network.NIC_ALIAS',
sourcePosition: indexAlias,
targetPath: 'extra.InputOutput.PCI',
targetPosition: pcis.length,
sourceDelete: false,
emptyObjectContent: true,
})
}
// Remove the old element
removeAndReorder(nicForDelete, id)
// Add the new element
handleAppend(updatedNic, true)
return
}
updateAlias(index, mapAliasNameFunction(updatedNic, index))
} else if (isPCI) {
const index = pcis.findIndex((nic) => nic.id === id)
if (index === -1) {
removeAndReorder(nicForDelete)
handleAppend(updatedNic)
return
}
// Update if the pci exists on pcis array
updatedNic.TYPE = 'NIC'
delete updatedNic.PCI_TYPE
updatePCI(index, mapPCINameFunction(updatedNic, index))
} else {
const index = nics.findIndex((nic) => nic.id === id)
if (index === -1) {
removeAndReorder(nicForDelete)
handleAppend(updatedNic)
updatePCI(indexPci, mapPCINameFunction(updatedNic, indexPci))
} else if (isAlias) {
// Get the index of the alias in the alias array
const indexAlias = alias.findIndex((nic) => nic.id === id)
// If the index is equal to -1, that's mean that it's an element that before is not an alias, but in this update, user change this element from nic or pci type to alias.
// In this case, we need to delete the old element (that is on nics or pcis array) and add to the alias arrays.
if (indexAlias === -1) {
// Check if the old element is in nic or pcis array and get the index
const indexNic = nics.findIndex((nic) => nic.id === id)
const indexPci = pcis.findIndex((nic) => nic.id === id)
// If the old element it's on nics array, we need to get the state (if it was deleted or updated) of the element from Network.NIC of moodifiedFields and set on Network.NIC_ALIAS of modifiedFields
if (indexNic !== -1) {
changePositionModifiedFields({
sourcePath: 'extra.Network.NIC',
sourcePosition: indexNic,
targetPath: 'extra.Network.NIC_ALIAS',
targetPosition: alias.length,
sourceDelete: false,
emptyObjectContent: true,
})
}
// If the old element it's on pcis array, we need to get the state (if it was deleted or updated) of the element from Network.PCI of moodifiedFields and set on Network.NIC_ALIAS of modifiedFields
if (indexPci !== -1) {
changePositionModifiedFields({
sourcePath: 'extra.InputOutput.PCI',
sourcePosition: indexPci,
targetPath: 'extra.Network.NIC_ALIAS',
targetPosition: alias.length,
sourceDelete: false,
emptyObjectContent: true,
})
// // If the element was pci, delete the pci fields
setFieldPath(`extra.Network.NIC_ALIAS.${alias.length}.advanced`)
setModifiedFields({
advanced: { TYPE: { __delete__: true } },
})
}
// Remove the old element
removeAndReorder(nicForDelete, id, updatedNic)
// Add the new element
handleAppend(updatedNic, true)
return
}
updateNic(index, mapNicNameFunction(updatedNic, index))
// Update if the alias exists on alias array
updateAlias(indexAlias, mapAliasNameFunction(updatedNic, indexAlias))
} else {
// Get the index of the nic in the nics array
const indexNic = nics.findIndex((nic) => nic.id === id)
// If the index is equal to -1, that's mean that it's an element that before is not a nic, but in this update, user change this element from alias or pci type to nic.
// In this case, we need to delete the old element (that is on nics or pcis array) and add to the alias arrays.
if (indexNic === -1) {
// Check if the old element is in alias or pcis array and get the index
const indexAlias = alias.findIndex((nic) => nic.id === id)
const indexPci = pcis.findIndex((nic) => nic.id === id)
// If the old element it's on alias array, we need to get the state (if it was deleted or updated) of the element from Network.NIC_ALIAS of moodifiedFields and set on Network.NIC of modifiedFields
if (indexAlias !== -1) {
changePositionModifiedFields({
sourcePath: 'extra.Network.NIC_ALIAS',
sourcePosition: indexAlias,
targetPath: 'extra.Network.NIC',
targetPosition: nics.length,
sourceDelete: false,
emptyObjectContent: true,
})
}
// If the old element it's on pcis array, we need to get the state (if it was deleted or updated) of the element from Network.PCI of moodifiedFields and set on Network.NIC_ALIAS of modifiedFields
if (indexPci !== -1) {
changePositionModifiedFields({
sourcePath: 'extra.InputOutput.PCI',
sourcePosition: indexPci,
targetPath: 'extra.Network.NIC',
targetPosition: nics.length,
sourceDelete: false,
emptyObjectContent: true,
})
// If the element was pci, delete the pci fields
setFieldPath(`extra.Network.NIC.${nics.length}`)
setModifiedFields({
advanced: { TYPE: { __delete__: true } },
})
}
// Remove the old element
removeAndReorder(nicForDelete, id)
// Add the new element
handleAppend(updatedNic, true)
return
}
// In case that the element has not changed from nic to alias or pci, update on nics array
updateNic(indexNic, mapNicNameFunction(updatedNic, indexNic))
}
// Always set field path on the length of nics
setFieldPath(`extra.Network`)
}
const handleAppend = (newNic) => {
/**
* Append a nic to the corresponding array (nics, alias or pcis).
*
* @param {object} newNic - The nic to append
* @param {boolean} update - If the append it's when user are updating a nic
*/
const handleAppend = (newNic, update) => {
// Check if nic is alias or pci
const isAlias = !!newNic?.PARENT?.length
const isPCI = Object.values(PCI_TYPES).includes(newNic?.PCI_TYPE)
if (isAlias) {
appendAlias(mapAliasNameFunction(newNic, alias.length))
} else if (isPCI) {
if (isPCI) {
// Set pci type as pci attribute
newNic.TYPE = 'NIC'
delete newNic.PCI_TYPE
// Add the nic to the pci section in modified fields
!update &&
changePositionModifiedFields({
sourcePath: 'extra.Network.NIC',
sourcePosition: nics.length,
targetPath: 'extra.InputOutput.PCI',
targetPosition: pcis.length,
sourceDelete: true,
})
setFieldPath(`extra.InputOutput.PCI.${pcis.length}`)
setModifiedFields({
advanced: { PCI_TYPE: { __delete__: true } },
})
// Append to form array of pci
appendPCI(mapPCINameFunction(newNic, pcis.length))
} else if (isAlias) {
// Add the nic to the alias section in modified fields
!update &&
changePositionModifiedFields({
sourcePath: 'extra.Network.NIC',
sourcePosition: nics.length,
targetPath: 'extra.Network.NIC_ALIAS',
targetPosition: alias.length,
sourceDelete: true,
})
setFieldPath(`extra.Network.NIC_ALIAS.${alias.length}`)
setModifiedFields({
advanced: { PCI_TYPE: { __delete__: true } },
})
// Append to form array of alias
appendAlias(mapAliasNameFunction(newNic, alias.length))
} else {
// Set field path to last position
setFieldPath(`extra.Network.NIC.${nics.length}`)
setModifiedFields({
advanced: { PCI_TYPE: { __delete__: true } },
})
// Append to form array of nics
appendNic(mapNicNameFunction(newNic, nics.length))
}
}
@ -189,6 +463,8 @@ const Networking = ({ hypervisor, oneConfig, adminGroup }) => {
{[...nics, ...alias, ...pcis.filter((pci) => pci?.TYPE === 'NIC')]?.map(
({ id, ...item }, index) => {
const hasAlias = alias?.some((nic) => nic.PARENT === item.NAME)
const isPci = item.TYPE === 'NIC'
const isAlias = Object.prototype.hasOwnProperty.call(item, 'PARENT')
item.NIC_ID = index
return (
@ -202,7 +478,7 @@ const Networking = ({ hypervisor, oneConfig, adminGroup }) => {
{!hasAlias && (
<DetachAction
nic={item}
onSubmit={() => removeAndReorder(item)}
onSubmit={() => removeAndReorder(item, id)}
oneConfig={oneConfig}
adminGroup={adminGroup}
/>
@ -216,6 +492,12 @@ const Networking = ({ hypervisor, oneConfig, adminGroup }) => {
onSubmit={(updatedNic) =>
handleUpdate(updatedNic, id, item)
}
indexNic={nics.findIndex((nic) => nic.id === id)}
indexAlias={alias.findIndex((nic) => nic.id === id)}
indexPci={pcis.findIndex((nic) => nic.id === id)}
hasAlias={hasAlias}
isPci={isPci}
isAlias={isAlias}
/>
</>
}
@ -230,6 +512,7 @@ const Networking = ({ hypervisor, oneConfig, adminGroup }) => {
legend={T.NetworkDefaults}
legendTooltip={T.NetworkDefaultsConcept}
id={EXTRA_ID}
saveState={true}
/>
</div>
)

View File

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { ElectronicsChip as NumaIcon } from 'iconoir-react'
import { useWatch } from 'react-hook-form'
import { useEffect } from 'react'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import { STEP_ID as GENERAL_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General'
@ -24,18 +24,19 @@ import {
TabType,
} from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { VIRTUAL_CPU as VCPU_FIELD } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General/capacitySchema'
import {
NUMA_FIELDS,
ENABLE_NUMA,
} from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/numa/schema'
import { NUMA_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/numa/schema'
import { T } from 'client/constants'
import { useGeneralApi } from 'client/features/General'
import { disableFields } from 'client/utils'
export const TAB_ID = 'NUMA'
const Numa = ({ hypervisor, oneConfig, adminGroup }) => {
const enableNuma = useWatch({ name: `${EXTRA_ID}.${ENABLE_NUMA.name}` })
const { setFieldPath } = useGeneralApi()
useEffect(() => {
setFieldPath(`extra.NUMA`)
}, [])
return (
<>
@ -43,24 +44,19 @@ const Numa = ({ hypervisor, oneConfig, adminGroup }) => {
cy={`${EXTRA_ID}-vcpu`}
fields={disableFields([VCPU_FIELD], 'TOPOLOGY', oneConfig, adminGroup)}
id={GENERAL_ID}
saveState={true}
/>
<FormWithSchema
cy={`${EXTRA_ID}-numa-enable`}
fields={disableFields([ENABLE_NUMA], 'TOPOLOGY', oneConfig, adminGroup)}
cy={`${EXTRA_ID}-numa`}
fields={disableFields(
NUMA_FIELDS(hypervisor),
'TOPOLOGY',
oneConfig,
adminGroup
)}
saveState={true}
id={EXTRA_ID}
/>
{enableNuma && (
<FormWithSchema
cy={`${EXTRA_ID}-numa`}
fields={disableFields(
NUMA_FIELDS(hypervisor),
'TOPOLOGY',
oneConfig,
adminGroup
)}
id={EXTRA_ID}
/>
)}
</>
)
}

View File

@ -58,7 +58,9 @@ const PIN_POLICY = {
addEmpty: false,
getText: sentenceCase,
}),
dependOf: '$general.HYPERVISOR',
dependOf: ENABLE_NUMA.name,
htmlType: (enableNuma) => !enableNuma && INPUT_TYPES.HIDDEN,
notOnHypervisors: [vcenter, firecracker],
validation: lazy((_, { context }) =>
string()
.trim()
@ -74,9 +76,6 @@ const PIN_POLICY = {
: NUMA_PIN_POLICIES.NONE
})
),
fieldProps: (hypervisor) => ({
disabled: [vcenter, firecracker].includes(hypervisor),
}),
}
/** @type {Field} NODE_AFFINITY field */
@ -84,17 +83,12 @@ const NODE_AFFINITY = {
name: 'TOPOLOGY.NODE_AFFINITY',
label: T.NodeAffinity,
tooltip: T.NodeAffinityConcept,
dependOf: ['$general.HYPERVISOR', PIN_POLICY.name],
dependOf: [PIN_POLICY.name, ENABLE_NUMA.name],
htmlType: ([pinPolicy, enableNuma] = []) =>
(!enableNuma || pinPolicy !== NUMA_PIN_POLICIES.NODE_AFFINITY) &&
INPUT_TYPES.HIDDEN,
notOnHypervisors: [vcenter, firecracker],
type: INPUT_TYPES.TEXT,
htmlType: (_, context) => {
const values = context?.getValues() || {}
const { general, extra } = values || {}
return ![vcenter, firecracker].includes(general?.HYPERVISOR) &&
extra?.TOPOLOGY?.PIN_POLICY === NUMA_PIN_POLICIES.NODE_AFFINITY
? 'number'
: INPUT_TYPES.HIDDEN
},
validation: lazy((_, { context }) => {
const { general, extra } = context || {}
@ -110,11 +104,15 @@ const CORES = {
name: 'TOPOLOGY.CORES',
label: T.Cores,
tooltip: T.NumaCoresConcept,
dependOf: ['$general.VCPU', '$general.HYPERVISOR'],
dependOf: ['$general.VCPU', '$general.HYPERVISOR', ENABLE_NUMA.name],
type: ([, hypervisor] = []) =>
hypervisor === vcenter ? INPUT_TYPES.SELECT : INPUT_TYPES.TEXT,
htmlType: 'number',
values: ([vcpu] = []) => arrayToOptions(getFactorsOfNumber(vcpu ?? 0)),
htmlType: ([, , enableNuma] = []) =>
!enableNuma ? INPUT_TYPES.HIDDEN : 'number',
values: ([vcpu, hypervisor] = []) => {
if (hypervisor === vcenter)
return arrayToOptions(getFactorsOfNumber(vcpu ?? 0))
},
validation: number()
.notRequired()
.integer()
@ -127,15 +125,19 @@ const SOCKETS = {
label: T.Sockets,
tooltip: T.NumaSocketsConcept,
type: INPUT_TYPES.TEXT,
htmlType: 'number',
dependOf: ['$general.HYPERVISOR', '$general.VCPU', 'TOPOLOGY.CORES'],
dependOf: [
'$general.HYPERVISOR',
'$general.VCPU',
'TOPOLOGY.CORES',
ENABLE_NUMA.name,
],
htmlType: ([, , , enableNuma] = []) =>
!enableNuma ? INPUT_TYPES.HIDDEN : 'number',
notOnHypervisors: [vcenter, firecracker],
validation: number()
.notRequired()
.integer()
.default(() => 1),
fieldProps: (hypervisor) => ({
disabled: [vcenter, firecracker].includes(hypervisor),
}),
watcher: ([hypervisor, vcpu, cores] = []) => {
if (hypervisor === vcenter) return
@ -158,9 +160,10 @@ const THREADS = {
name: 'TOPOLOGY.THREADS',
label: T.Threads,
tooltip: T.ThreadsConcept,
htmlType: 'number',
dependOf: '$general.HYPERVISOR',
type: (hypervisor) =>
htmlType: ([, enableNuma] = []) =>
!enableNuma ? INPUT_TYPES.HIDDEN : 'number',
dependOf: ['$general.HYPERVISOR', ENABLE_NUMA.name],
type: ([hypervisor] = []) =>
[firecracker, vcenter].includes(hypervisor)
? INPUT_TYPES.SELECT
: INPUT_TYPES.TEXT,
@ -184,6 +187,8 @@ const HUGEPAGES = {
label: T.HugepagesSize,
tooltip: T.HugepagesSizeConcept,
notOnHypervisors: [vcenter, firecracker],
dependOf: ENABLE_NUMA.name,
htmlType: (enableNuma) => !enableNuma && INPUT_TYPES.HIDDEN,
type: INPUT_TYPES.SELECT,
values: () => {
const { data: hosts = [] } = useGetHostsQuery()
@ -208,6 +213,8 @@ const MEMORY_ACCESS = {
tooltip: [T.MemoryAccessConcept, NUMA_MEMORY_ACCESS.join(', ')],
notOnHypervisors: [vcenter, firecracker],
type: INPUT_TYPES.SELECT,
dependOf: ENABLE_NUMA.name,
htmlType: (enableNuma) => !enableNuma && INPUT_TYPES.HIDDEN,
values: arrayToOptions(NUMA_MEMORY_ACCESS, { getText: sentenceCase }),
validation: string()
.trim()
@ -222,6 +229,7 @@ const MEMORY_ACCESS = {
const NUMA_FIELDS = (hypervisor) =>
filterFieldsByHypervisor(
[
ENABLE_NUMA,
PIN_POLICY,
NODE_AFFINITY,
CORES,
@ -237,7 +245,7 @@ const NUMA_FIELDS = (hypervisor) =>
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} List of NUMA fields
*/
const SCHEMA_FIELDS = (hypervisor) => [ENABLE_NUMA, ...NUMA_FIELDS(hypervisor)]
const SCHEMA_FIELDS = (hypervisor) => NUMA_FIELDS(hypervisor)
/**
* @param {string} [hypervisor] - VM hypervisor
@ -266,9 +274,4 @@ const NUMA_SCHEMA = (hypervisor) =>
}
)
export {
ENABLE_NUMA,
SCHEMA_FIELDS as FIELDS,
NUMA_FIELDS,
NUMA_SCHEMA as SCHEMA,
}
export { SCHEMA_FIELDS as FIELDS, NUMA_FIELDS, NUMA_SCHEMA as SCHEMA }

View File

@ -15,6 +15,7 @@
* ------------------------------------------------------------------------- */
import PropTypes from 'prop-types'
import { NetworkAlt as PlacementIcon } from 'iconoir-react'
import { useEffect } from 'react'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
@ -27,25 +28,34 @@ import {
FIELDS,
} from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/placement/schema'
import { T } from 'client/constants'
import { useGeneralApi } from 'client/features/General'
const Placement = ({ oneConfig, adminGroup }) => (
// TODO - Host requirements: add button to select HOST in list => ID="<id>"
// TODO - Host policy options: Packing|Stripping|Load-aware
const Placement = ({ oneConfig, adminGroup }) => {
const { setFieldPath } = useGeneralApi()
useEffect(() => {
setFieldPath(`extra.Placement`)
}, [])
// TODO - DS requirements: add button to select DATASTORE in list => ID="<id>"
// TODO - DS policy options: Packing|Stripping
return (
// TODO - Host requirements: add button to select HOST in list => ID="<id>"
// TODO - Host policy options: Packing|Stripping|Load-aware
<>
{SECTIONS(oneConfig, adminGroup).map(({ id, ...section }) => (
<FormWithSchema
key={id}
id={EXTRA_ID}
cy={`${EXTRA_ID}-${id}`}
{...section}
/>
))}
</>
)
// TODO - DS requirements: add button to select DATASTORE in list => ID="<id>"
// TODO - DS policy options: Packing|Stripping
<>
{SECTIONS(oneConfig, adminGroup).map(({ id, ...section }) => (
<FormWithSchema
key={id}
id={EXTRA_ID}
cy={`${EXTRA_ID}-${id}`}
saveState={true}
{...section}
/>
))}
</>
)
}
Placement.propTypes = {
data: PropTypes.any,

View File

@ -38,7 +38,7 @@ const HOST_RANK_FIELD = {
/** @type {Field} Datastore requirement field */
const DS_REQ_FIELD = {
name: 'DS_SCHED_REQUIREMENTS',
name: 'SCHED_DS_REQUIREMENTS',
label: T.DatastoreReqExpression,
tooltip: T.DatastoreReqExpressionConcept,
type: INPUT_TYPES.TEXT,
@ -47,7 +47,7 @@ const DS_REQ_FIELD = {
/** @type {Field} Datastore rank requirement field */
const DS_RANK_FIELD = {
name: 'DS_SCHED_RANK',
name: 'SCHED_DS_RANK',
label: T.DatastorePolicyExpression,
tooltip: T.DatastorePolicyExpressionConcept,
type: INPUT_TYPES.TEXT,

View File

@ -16,6 +16,7 @@
import { Stack } from '@mui/material'
import { Calendar as ActionIcon } from 'iconoir-react'
import { useFieldArray } from 'react-hook-form'
import { useEffect, useMemo } from 'react'
import { ScheduleActionCard } from 'client/components/Cards'
import {
@ -32,6 +33,7 @@ import {
import { mapNameByIndex } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema'
import { T } from 'client/constants'
import { useGeneralApi } from 'client/features/General'
import PropTypes from 'prop-types'
export const TAB_ID = 'SCHED_ACTION'
@ -39,6 +41,12 @@ export const TAB_ID = 'SCHED_ACTION'
const mapNameFunction = mapNameByIndex('SCHED_ACTION')
const ScheduleAction = ({ oneConfig, adminGroup }) => {
const { setModifiedFields, setFieldPath, initModifiedFields } =
useGeneralApi()
useEffect(() => {
setFieldPath(`extra.ScheduleAction`)
initModifiedFields([...scheduleActions.map(() => ({}))])
}, [])
const {
fields: scheduleActions,
remove,
@ -49,8 +57,23 @@ const ScheduleAction = ({ oneConfig, adminGroup }) => {
keyName: 'ID',
})
const totalFieldsCount = useMemo(
() => scheduleActions?.length,
[scheduleActions]
)
// Delay execution until next event loop tick to ensure state updates
useEffect(() => {
const timeoutId = setTimeout(() => {
setFieldPath(`extra.ScheduleAction.${totalFieldsCount}`)
}, 0)
return () => clearTimeout(timeoutId)
}, [totalFieldsCount])
const handleCreateAction = (action) => {
append(mapNameFunction(action, scheduleActions.length))
setModifiedFields(action)
}
const handleCreateCharter = (actions) => {
@ -62,10 +85,12 @@ const ScheduleAction = ({ oneConfig, adminGroup }) => {
}
const handleUpdate = (action, index) => {
setModifiedFields(action)
update(index, mapNameFunction(action, index))
}
const handleRemove = (index) => {
setModifiedFields({ __flag__: 'DELETE' })
remove(index)
}

View File

@ -48,14 +48,15 @@ const SCHED_ACTION_SCHEMA = object({
/**
* @param {HYPERVISORS} hypervisor - VM hypervisor
* @param {boolean} isUpdate - If it's an update of the form
* @returns {ObjectSchema} Extra configuration schema
*/
export const SCHEMA = (hypervisor) =>
export const SCHEMA = (hypervisor, isUpdate) =>
object()
.concat(SCHED_ACTION_SCHEMA)
.concat(NETWORK_SCHEMA)
.concat(STORAGE_SCHEMA)
.concat(CONTEXT_SCHEMA(hypervisor))
.concat(CONTEXT_SCHEMA(hypervisor, isUpdate))
.concat(IO_SCHEMA(hypervisor))
.concat(
getObjectSchemaFromFields([...PLACEMENT_FIELDS, ...OS_FIELDS(hypervisor)])

View File

@ -17,6 +17,7 @@ import PropTypes from 'prop-types'
import { Stack } from '@mui/material'
import { Db as DatastoreIcon } from 'iconoir-react'
import { useFieldArray, useFormContext } from 'react-hook-form'
import { useEffect, useMemo } from 'react'
import { FormWithSchema } from 'client/components/Forms'
import DiskCard from 'client/components/Cards/DiskCard'
@ -37,15 +38,24 @@ import {
import { FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/storage/schema'
import { getDiskName } from 'client/models/Image'
import { T } from 'client/constants'
import { useGeneralApi } from 'client/features/General'
export const TAB_ID = 'DISK'
const mapNameFunction = mapNameByIndex('DISK')
const Storage = ({ hypervisor, oneConfig, adminGroup }) => {
const { setModifiedFields, setFieldPath, initModifiedFields } =
useGeneralApi()
useEffect(() => {
setFieldPath(`extra.Storage`)
initModifiedFields([...disks.map(() => ({}))])
}, [])
const { getValues, setValue } = useFormContext()
const {
fields: disks,
fields: disks = [],
append,
update,
replace,
@ -53,23 +63,40 @@ const Storage = ({ hypervisor, oneConfig, adminGroup }) => {
name: `${EXTRA_ID}.${TAB_ID}`,
})
const removeAndReorder = (diskName) => {
const totalFieldsCount = useMemo(() => disks?.length, [disks])
// Delay execution until next event loop tick to ensure state updates
useEffect(() => {
const timeoutId = setTimeout(() => {
setFieldPath(`extra.Storage.${totalFieldsCount}`)
}, 0)
return () => clearTimeout(timeoutId)
}, [totalFieldsCount])
const removeAndReorder = (disk) => {
/** MARK FOR DELETION */
setFieldPath(`extra.Storage.${disk?.DISK_ID}`)
setModifiedFields({ __flag__: 'DELETE' })
const updatedDisks = disks
.filter(({ NAME }) => NAME !== diskName)
.filter(({ NAME }) => NAME !== disk?.NAME)
.map(mapNameFunction)
const currentBootOrder = getValues(BOOT_ORDER_NAME())
const updatedBootOrder = reorderBootAfterRemove(
diskName,
disk?.NAME,
disks,
currentBootOrder
)
replace(updatedDisks)
setValue(BOOT_ORDER_NAME(), updatedBootOrder)
setFieldPath(`extra.OsCpu`)
setModifiedFields({ OS: { BOOT: true } })
}
const handleUpdate = (updatedDisk, index) => {
update(index, mapNameFunction(updatedDisk, index))
setFieldPath(`extra.Storage.${totalFieldsCount}`)
}
return (
@ -93,7 +120,7 @@ const Storage = ({ hypervisor, oneConfig, adminGroup }) => {
}}
>
{disks?.map(({ id, ...item }, index) => {
item.DISK_ID ??= index
item.DISK_ID = index
return (
<DiskCard
@ -106,7 +133,7 @@ const Storage = ({ hypervisor, oneConfig, adminGroup }) => {
name={getDiskName(item)}
oneConfig={oneConfig}
adminGroup={adminGroup}
onSubmit={() => removeAndReorder(item?.NAME)}
onSubmit={() => removeAndReorder(item)}
/>
<AttachAction
disk={item}

View File

@ -235,11 +235,11 @@ export const MEMORY_RESIZE_MODE_FIELD = {
notOnHypervisors: [lxc, firecracker, vcenter],
dependOf: ['HYPERVISOR', '$general.HYPERVISOR'],
values: arrayToOptions(Object.keys(MEMORY_RESIZE_OPTIONS), {
addEmpty: false,
addEmpty: true,
getText: (option) => option,
getValue: (option) => MEMORY_RESIZE_OPTIONS[option],
}),
validation: string().default(() => MEMORY_RESIZE_OPTIONS[T.Ballooning]),
validation: string().default(() => undefined),
grid: { md: 6 },
}

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useMemo } from 'react'
import { useMemo, useEffect } from 'react'
import PropTypes from 'prop-types'
import { useWatch } from 'react-hook-form'
@ -28,6 +28,7 @@ import {
import { getActionsAvailable as getSectionsAvailable } from 'client/models/Helper'
import { generateKey } from 'client/utils'
import { T, RESOURCE_NAMES, VmTemplate } from 'client/constants'
import { useGeneralApi } from 'client/features/General'
let generalFeatures
@ -38,6 +39,11 @@ const Content = ({ isUpdate, oneConfig, adminGroup }) => {
const { view, getResourceView } = useViews()
const hypervisor = useWatch({ name: `${STEP_ID}.HYPERVISOR` })
const { setFieldPath } = useGeneralApi()
useEffect(() => {
setFieldPath(`general`)
}, [])
const sections = useMemo(() => {
const resource = RESOURCE_NAMES.VM_TEMPLATE
const { features, dialogs } = getResourceView(resource)
@ -64,6 +70,7 @@ const Content = ({ isUpdate, oneConfig, adminGroup }) => {
id={STEP_ID}
cy={`${STEP_ID}-${id}`}
rootProps={{ className: classes[id] }}
saveState={true}
{...section}
/>
))}
@ -78,11 +85,11 @@ const Content = ({ isUpdate, oneConfig, adminGroup }) => {
* @returns {object} General configuration step
*/
const General = ({
dataTemplateExtended: vmTemplate,
apiTemplateDataExtended: vmTemplate,
oneConfig,
adminGroup,
}) => {
const isUpdate = vmTemplate?.NAME
const isUpdate = !!vmTemplate?.NAME
const initialHypervisor = vmTemplate?.TEMPLATE?.HYPERVISOR
return {

View File

@ -25,20 +25,16 @@ import General, {
STEP_ID as GENERAL_ID,
} from 'client/components/Forms/VmTemplate/CreateForm/Steps/General'
import { MEMORY_RESIZE_OPTIONS, T } from 'client/constants'
import { userInputsToArray } from 'client/models/Helper'
import {
jsonToXml,
userInputsToArray,
transformXmlString,
} from 'client/models/Helper'
import {
convertToMB,
createSteps,
encodeBase64,
getUnknownAttributes,
isBase64,
} from 'client/utils'
import { KVM_FIRMWARE_TYPES, VCENTER_FIRMWARE_TYPES } from 'client/constants'
/**
* Encodes the start script value to base64 if it is not already encoded.
*
@ -85,6 +81,19 @@ const Steps = createSteps([General, ExtraConfiguration, CustomVariables], {
}
}
// cast FIRMWARE
const firmware = vmTemplate?.TEMPLATE?.OS?.FIRMWARE
if (firmware) {
const firmwareOption =
KVM_FIRMWARE_TYPES.includes(firmware) ||
VCENTER_FIRMWARE_TYPES.includes(firmware)
objectSchema[EXTRA_ID].OS = {
...vmTemplate?.TEMPLATE?.OS,
FEATURE_CUSTOM_ENABLED: !firmwareOption ? 'YES' : 'NO',
}
}
const knownTemplate = schema.cast(objectSchema, {
stripUnknown: true,
context: { ...vmTemplate, [EXTRA_ID]: vmTemplate.TEMPLATE },
@ -121,54 +130,9 @@ const Steps = createSteps([General, ExtraConfiguration, CustomVariables], {
return knownTemplate
},
transformBeforeSubmit: (formData) => {
const {
[GENERAL_ID]: general = {},
[CUSTOM_ID]: customVariables = {},
[EXTRA_ID]: extraTemplate = {},
} = formData ?? {}
ensureContextWithScript(extraTemplate)
// add user inputs to context
Object.keys(extraTemplate?.USER_INPUTS ?? {}).forEach((name) => {
const isCapacity = ['MEMORY', 'CPU', 'VCPU'].includes(name)
const upperName = String(name).toUpperCase()
!isCapacity && (extraTemplate.CONTEXT[upperName] = `$${upperName}`)
})
if (
general?.MEMORY_RESIZE_MODE === MEMORY_RESIZE_OPTIONS[T.Ballooning] &&
general?.MEMORY_SLOTS
) {
delete general.MEMORY_SLOTS
}
// ISSUE#6136: Convert size to MB (because XML API uses only MB) and delete sizeunit field (no needed on XML API)
general.MEMORY = convertToMB(general.MEMORY, general.MEMORYUNIT)
delete general.MEMORYUNIT
// cast CPU_MODEL/FEATURES
if (Array.isArray(extraTemplate?.CPU_MODEL?.FEATURES)) {
extraTemplate.CPU_MODEL.FEATURES =
extraTemplate.CPU_MODEL.FEATURES.join(', ')
}
;['NIC', 'NIC_ALIAS'].forEach((nicKey) =>
extraTemplate?.[nicKey]?.forEach((NIC) => delete NIC?.NAME)
)
// ISSUE #6418: Raw data is in XML format, so it needs to be transform before sennding it to the API (otherwise the value of RAW.DATA will be treat as part of the XML template)
extraTemplate?.RAW?.DATA &&
(extraTemplate.RAW.DATA = transformXmlString(extraTemplate.RAW.DATA))
return jsonToXml({
...customVariables,
...extraTemplate,
...general,
})
},
transformBeforeSubmit: (formData) =>
// All formatting and parsing is taken care of in the VmTemplate container
formData,
})
export default Steps

View File

@ -30,7 +30,7 @@ import { useFormContext } from 'react-hook-form'
let generalFeatures
export const STEP_ID = 'configuration'
export const STEP_ID = 'general'
const Content = ({ vmTemplate, oneConfig, adminGroup }) => {
const classes = useStyles()
@ -57,7 +57,7 @@ const Content = ({ vmTemplate, oneConfig, adminGroup }) => {
const oldValues = {
...getValues(),
}
oldValues.configuration.CPU = `${scaleVcpuByCpuFactor(
oldValues.general.CPU = `${scaleVcpuByCpuFactor(
vmTemplate.TEMPLATE.VCPU,
features.cpu_factor
)}`
@ -75,6 +75,7 @@ const Content = ({ vmTemplate, oneConfig, adminGroup }) => {
rootProps={{ className: classes[id] }}
fields={fields}
legend={legend}
saveState={true}
id={STEP_ID}
/>
))}

View File

@ -30,6 +30,7 @@ const Content = ({ userInputs }) => (
cy="user-inputs"
id={STEP_ID}
fields={useMemo(() => FIELDS(userInputs), [])}
saveState={true}
/>
)

View File

@ -19,12 +19,9 @@ import BasicConfiguration, {
import ExtraConfiguration, {
STEP_ID as EXTRA_ID,
} from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration'
import UserInputs, {
STEP_ID as USER_INPUTS_ID,
} from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/UserInputs'
import UserInputs from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/UserInputs'
import {
getUserInputParams,
jsonToXml,
parseRangeToArray,
userInputsToArray,
} from 'client/models/Helper'
@ -81,36 +78,17 @@ const Steps = createSteps(
{ stripUnknown: true }
)
},
transformBeforeSubmit: (formData, vmTemplate, _, adminGroup, oneConfig) => {
const {
[BASIC_ID]: { name, instances, hold, persistent, ...restOfConfig } = {},
[USER_INPUTS_ID]: userInputs,
[EXTRA_ID]: extraTemplate = {},
} = formData ?? {}
vmTemplate?.TEMPLATE?.OS &&
extraTemplate?.OS &&
(extraTemplate.OS = {
...vmTemplate?.TEMPLATE?.OS,
...extraTemplate?.OS,
})
;['NIC', 'NIC_ALIAS'].forEach((nicKey) =>
extraTemplate?.[nicKey]?.forEach((NIC) => delete NIC?.NAME)
)
// merge with template disks to get TYPE attribute
const templateXML = jsonToXml({
...userInputs,
...extraTemplate,
...restOfConfig,
})
const data = { instances, hold, persistent, template: templateXML }
transformBeforeSubmit: (formData, vmTemplate) => {
const { [BASIC_ID]: { name, instances, hold, persistent } = {} } =
formData ?? {}
const templates = [...new Array(instances)].map((__, idx) => ({
id: vmTemplate.ID,
name: name?.replace(/%idx/gi, idx),
...data,
instances: instances,
hold: hold,
persistent: persistent,
...formData,
}))
return templates

View File

@ -109,6 +109,14 @@ const EnhancedTable = ({
return updatedState
}
case 'toggleAllRowsSelected': {
// If the action is to deselect all the rows, the selectRowIds has to be an empory objet
if (singleSelect && !action.value) {
newState.selectedRowIds = {}
}
return newState
}
default:
return newState
}

View File

@ -35,6 +35,7 @@ import {
} from 'client/components/Tabs/Common/Attribute'
import AttributeCreateForm from 'client/components/Tabs/Common/AttributeCreateForm'
import { Tr } from 'client/components/HOC'
import { camelCase } from 'client/utils'
const Title = styled(ListItem)(({ theme }) => ({
fontWeight: theme.typography.fontWeightBold,
@ -92,6 +93,7 @@ const AttributeList = ({
>
<Attribute
path={parentPath || name}
dataCy={'attribute-' + camelCase(name)}
{...attribute}
{...(isParent && { canEdit: false, value: undefined })}
/>

View File

@ -48,7 +48,15 @@ const AttachAction = memo(
sx,
oneConfig,
adminGroup,
indexNic,
indexAlias,
indexPci,
hasAlias,
isPci,
isAlias,
}) => {
const { setFieldPath } = useGeneralApi()
const [attachNic] = useAttachNicMutation()
const handleAttachNic = async (formData) => {
@ -56,7 +64,6 @@ const AttachAction = memo(
return await onSubmit(formData)
}
const isAlias = !!formData?.PARENT?.length
const key = isAlias ? 'NIC_ALIAS' : 'NIC'
const data = { [key]: formData }
@ -85,17 +92,34 @@ const AttachAction = memo(
options={[
{
dialogProps: { title: T.AttachNic, dataCy: 'modal-attach-nic' },
form: () =>
AttachNicForm({
form: () => {
// Set field path
if (nic) {
if (isPci) {
setFieldPath(`extra.InputOutput.PCI.${indexPci}`)
} else if (isAlias) {
setFieldPath(`extra.Network.NIC_ALIAS.${indexAlias}`)
} else {
setFieldPath(`extra.Network.NIC.${indexNic}`)
}
} else {
setFieldPath(`extra.Network.NIC.${currentNics.length}`)
}
return AttachNicForm({
stepProps: {
hypervisor,
nics: currentNics,
defaultData: nic,
oneConfig,
adminGroup,
hasAlias,
isPci,
isAlias,
},
initialValues: nic,
}),
})
},
onSubmit: handleAttachNic,
},
]}
@ -273,6 +297,12 @@ const ActionPropTypes = {
sx: PropTypes.object,
oneConfig: PropTypes.object,
adminGroup: PropTypes.bool,
indexNic: PropTypes.string,
indexAlias: PropTypes.string,
indexPci: PropTypes.string,
hasAlias: PropTypes.bool,
isPci: PropTypes.bool,
isAlias: PropTypes.bool,
}
AttachAction.propTypes = ActionPropTypes

View File

@ -46,8 +46,6 @@ import { jsonToXml } from 'client/models/Helper'
import { Tr, Translate } from 'client/components/HOC'
import { T, VM_ACTIONS } from 'client/constants'
import { hasRestrictedAttributes } from 'client/utils'
const AttachAction = memo(
({ vmId, disk, hypervisor, onSubmit, sx, oneConfig, adminGroup }) => {
const [attachDisk] = useAttachDiskMutation()
@ -91,6 +89,7 @@ const AttachAction = memo(
title: (
<Translate word={T.EditSomething} values={[disk?.NAME]} />
),
dataCy: 'modal-edit-disk',
},
form: () =>
!disk?.IMAGE && !disk?.IMAGE_ID // is volatile
@ -137,14 +136,7 @@ const DetachAction = memo(
await handleDetachDisk({ id: vmId, disk: DISK_ID })
}
// Disable action if the disk has a restricted attribute on the template
const disabledAction =
!adminGroup &&
hasRestrictedAttributes(
disk.ORIGINAL,
'DISK',
oneConfig?.VM_RESTRICTED_ATTR
)
const disabledAction = !adminGroup
return (
<ButtonToTriggerForm

View File

@ -963,6 +963,14 @@ module.exports = {
Final users may not be aware of this`,
DefaultNicModel: 'Default hardware model to emulate for all NICs',
DefaultNicFilter: 'Default network filtering rule for all NICs',
Ipv4Static: 'Static (Based on context)',
Ipv4Dhcp: 'DHCP (DHCPv4)',
Ipv4Skip: 'Skip (Do not configure IPv4)',
Ipv6Static: 'Static (Based on context)',
Ipv6Auto: 'Auto (SLAAC)',
Ipv6Dhcp: 'DHCP (SLAAC and DHCPv6)',
Ipv6Disable: 'Disable (Do not use IPv6)',
Ipv6Skip: 'Skip (Do not configure IPv6)',
/* VM Template schema - capacity */
MaxMemory: 'Max memory',
MaxMemoryConcept: `
@ -1157,8 +1165,14 @@ module.exports = {
Profile: 'Profile',
DeviceName: 'Device name',
Device: 'Device',
DeviceTooltip:
'Select one device of the Device name list to complete this field please.',
Vendor: 'Vendor',
VendorTooltip:
'Select one vendor of the Device name list to complete this field please.',
Class: 'Class',
ClassTooltip:
'Select one class of the Device name list to complete this field please.',
Video: 'Video',
VideoType: 'Video device type',
VideoTypeConcept:

View File

@ -133,7 +133,7 @@ export const TEMPLATE_LOGOS = {
/** @enum {string} FS freeze options type */
export const FS_FREEZE_OPTIONS = {
[T.None]: 'NONE',
[T.QEMUAgent]: 'QEMU-AGENT',
[T.QEMUAgent]: 'AGENT',
[T.Suspend]: 'SUSPEND',
}
@ -174,3 +174,36 @@ export const MEMORY_RESIZE_OPTIONS = {
[T.Ballooning]: 'BALLOONING',
[T.Hotplug]: 'HOTPLUG',
}
export const TAB_FORM_MAP = {
Storage: ['DISK', 'TM_MAD_SYSTEM'],
Network: ['NIC', 'NIC_ALIAS', 'PCI', 'NIC_DEFAULT'],
OsCpu: ['OS', 'CPU_MODEL', 'FEATURES', 'RAW'],
InputOutput: ['INPUT', 'GRAPHICS', 'VIDEO', 'PCI'],
Context: ['CONTEXT', 'USER_INPUTS', 'INPUTS_ORDER'],
ScheduleAction: ['SCHED_ACTION'],
Placement: [
'SCHED_DS_RANK',
'SCHED_DS_REQUIREMENTS',
'SCHED_RANK',
'SCHED_REQUIREMENTS',
],
NUMA: ['TOPOLOGY'],
Backup: ['BACKUP_CONFIG'],
}
/** @enum {string} Methods on IP v4 options type */
export const IPV4_METHODS = {
[T.Ipv4Static]: 'static',
[T.Ipv4Dhcp]: 'dhcp',
[T.Ipv4Skip]: 'skip',
}
/** @enum {string} Methods on IP v6 options type */
export const IPV6_METHODS = {
[T.Ipv4Static]: 'static',
[T.Ipv6Auto]: 'auto',
[T.Ipv4Dhcp]: 'dhcp',
[T.Ipv6Disable]: 'disable',
[T.Ipv4Skip]: 'skip',
}

View File

@ -13,7 +13,8 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement } from 'react'
import { ReactElement, useEffect } from 'react'
import { useStore } from 'react-redux'
import { useHistory, useLocation } from 'react-router'
import { useGeneralApi } from 'client/features/General'
@ -35,16 +36,12 @@ import {
import { CreateForm } from 'client/components/Forms/VmTemplate'
import { PATH } from 'client/apps/sunstone/routesOne'
import { jsonToXml, xmlToJson } from 'client/models/Helper'
import { jsonToXml } from 'client/models/Helper'
import { filterTemplateData, transformActionsCreate } from 'client/utils/parser'
import { TAB_FORM_MAP } from 'client/constants'
import { useSystemData } from 'client/features/Auth'
import {
addTempInfo,
deleteTempInfo,
deleteRestrictedAttributes,
} from 'client/utils'
const _ = require('lodash')
/**
@ -53,10 +50,19 @@ const _ = require('lodash')
* @returns {ReactElement} VM Template form
*/
function CreateVmTemplate() {
// Reset modified fields + path on mount
useEffect(() => {
resetFieldPath()
resetModifiedFields()
}, [])
const store = useStore()
const history = useHistory()
const { state: { ID: templateId, NAME } = {} } = useLocation()
const { enqueueSuccess } = useGeneralApi()
const { enqueueSuccess, resetFieldPath, resetModifiedFields } =
useGeneralApi()
const [update] = useUpdateTemplateMutation()
const [allocate] = useAllocateTemplateMutation()
@ -72,70 +78,43 @@ function CreateVmTemplate() {
{ skip: templateId === undefined }
)
const dataTemplateExtended = _.cloneDeep(apiTemplateDataExtended)
const dataTemplate = _.cloneDeep(apiTemplateData)
// #6154: Add an unique identifier to compare on submit items that exists at the beginning of the update
if (!adminGroup) addTempInfo(dataTemplate, dataTemplateExtended)
useGetVMGroupsQuery(undefined, { refetchOnMountOrArgChange: false })
useGetHostsQuery(undefined, { refetchOnMountOrArgChange: false })
useGetImagesQuery(undefined, { refetchOnMountOrArgChange: false })
useGetUsersQuery(undefined, { refetchOnMountOrArgChange: false })
useGetDatastoresQuery(undefined, { refetchOnMountOrArgChange: false })
const onSubmit = async (xmlTemplate) => {
const onSubmit = async (rawTemplate) => {
try {
const currentState = store.getState()
const modifiedFields = currentState.general?.modifiedFields
const existingTemplate = {
...apiTemplateData?.TEMPLATE,
}
const filteredTemplate = filterTemplateData(
rawTemplate,
modifiedFields,
existingTemplate,
TAB_FORM_MAP
)
// Every action that is not an human action
transformActionsCreate(filteredTemplate)
const xmlTemplate = jsonToXml(filteredTemplate)
if (!templateId) {
const newTemplateId = await allocate({ template: xmlTemplate }).unwrap()
const newTemplateId = await allocate({
template: xmlTemplate,
}).unwrap()
resetFieldPath()
resetModifiedFields()
history.push(PATH.TEMPLATE.VMS.LIST)
enqueueSuccess(`VM Template created - #${newTemplateId}`)
} else {
/**
* #6154: Consideration about resolving this issue:
*
* When the user is a non admin user, we have to delete the restricted attributes of the DISK to avoid errors.
*
* Core behaviour: The core will fail if in the template there is a restricted attribute that has a different value before modify it.
* EXAMPLES:
* - If you add a restricted attribute on the template
* - If you remove a restricted attribute on the template
* - If you modify the value of a restricted attribute on the template
*
* Core will not fail if you send a restricted attribute in the template without modify him.
* EXAMPLES:
* - If your template has a restricted attribute with value 1024 and you send the same attribute with 1024 value, core will not fail
*
* Fireedge Sunstone behaviour: The app has a different behaviour between the DISK attribute of a template and another attributes like NIC, CONTEXT, GRAPHICS,... The sequence when you're updating a template is the following:
* 1. Get the info of the template from the core with extended value false. That returns only what the template has on the core.
* 2. Get the info of the template from the core with extended value true. That returns the info of the template plus the info of the disk (only disk, not NIC or another attributes).
* 3. When the template is update, DISK could have some attributes that are not in the template, so this could cause a failure.
*
* To resolve the issue we delete restricted attributes if there are not in template when the user is non admin . This can be done because the user can modify the restricted attributes (as part of this issue, the schemas has a read only attribute if the field is restricted)
*
* We delete this info onto onSubmit function becasue we need to get the original tempalte without modify. So there is need a hook that we can't do on tranformBeforeSubmit.
* Also, the data that is an input parameter of the CreateForm is the data with extended values, so it's no possible to to that using initualValues on transformBeforeSubmit.
*
*/
// #6154: Delete restricted attributes (if there are not on the original template)
const jsonFinal = adminGroup
? xmlToJson(xmlTemplate)
: deleteRestrictedAttributes(
xmlToJson(xmlTemplate),
dataTemplate?.TEMPLATE,
oneConfig?.VM_RESTRICTED_ATTR
)
// #6154: Delete unique identifier to compare on submit items that exists at the beginning of the update
if (!adminGroup) {
deleteTempInfo(jsonFinal)
}
// Transform json to xml
const xmlFinal = jsonToXml(jsonFinal)
await update({ id: templateId, template: xmlFinal }).unwrap()
await update({ id: templateId, template: xmlTemplate }).unwrap()
resetFieldPath()
resetModifiedFields()
history.push(PATH.TEMPLATE.VMS.LIST)
enqueueSuccess(`VM Template updated - #${templateId} ${NAME}`)
}
@ -143,13 +122,13 @@ function CreateVmTemplate() {
}
return templateId &&
(!dataTemplateExtended || !dataTemplate || _.isEmpty(oneConfig)) ? (
(!apiTemplateDataExtended || !apiTemplateData || _.isEmpty(oneConfig)) ? (
<SkeletonStepsForm />
) : (
<CreateForm
initialValues={dataTemplateExtended}
initialValues={apiTemplateDataExtended}
stepProps={{
dataTemplateExtended,
apiTemplateDataExtended,
oneConfig,
adminGroup,
}}

View File

@ -14,6 +14,7 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { ReactElement } from 'react'
import { useStore } from 'react-redux'
import { Redirect, useHistory, useLocation } from 'react-router'
import { useGeneralApi } from 'client/features/General'
@ -32,12 +33,12 @@ import {
import { InstantiateForm } from 'client/components/Forms/VmTemplate'
import { useSystemData } from 'client/features/Auth'
import { jsonToXml, xmlToJson } from 'client/models/Helper'
import { jsonToXml } from 'client/models/Helper'
import {
addTempInfo,
deleteRestrictedAttributes,
deleteTempInfo,
} from 'client/utils'
filterTemplateData,
transformActionsInstantiate,
} from 'client/utils/parser'
import { TAB_FORM_MAP } from 'client/constants'
const _ = require('lodash')
@ -47,10 +48,11 @@ const _ = require('lodash')
* @returns {ReactElement} Instantiation form
*/
function InstantiateVmTemplate() {
const store = useStore()
const history = useHistory()
const { state: { ID: templateId, NAME: templateName } = {} } = useLocation()
const { enqueueInfo } = useGeneralApi()
const { enqueueInfo, resetFieldPath, resetModifiedFields } = useGeneralApi()
const [instantiate] = useInstantiateTemplateMutation()
const { adminGroup, oneConfig } = useSystemData()
@ -66,44 +68,42 @@ function InstantiateVmTemplate() {
)
const dataTemplateExtended = _.cloneDeep(apiTemplateDataExtended)
const dataTemplate = _.cloneDeep(apiTemplateData)
// #6154: Add an unique identifier to compare on submit items that exists at the beginning of the update
if (!adminGroup) addTempInfo(dataTemplate, dataTemplateExtended)
useGetUsersQuery(undefined, { refetchOnMountOrArgChange: false })
useGetGroupsQuery(undefined, { refetchOnMountOrArgChange: false })
const onSubmit = async (templates) => {
try {
const currentState = store.getState()
const modifiedFields = currentState.general?.modifiedFields
await Promise.all(
templates.map((t) => {
// #6154: Consideration about resolving this issue -> Read comment on src/client/containers/VmTemplates/Create.js
// #6154: Delete restricted attributes (if there are not on the original template)
const jsonFinal = adminGroup
? xmlToJson(t.template)
: deleteRestrictedAttributes(
xmlToJson(t?.template),
dataTemplate?.TEMPLATE,
oneConfig?.VM_RESTRICTED_ATTR
)
// #6154: Delete unique identifier to compare on submit items that exists at the beginning of the update
if (!adminGroup) {
deleteTempInfo(jsonFinal)
templates.map((rawTemplate) => {
const existingTemplate = {
...apiTemplateData?.TEMPLATE,
}
// Transform json to xml
const xmlFinal = jsonToXml(jsonFinal)
const filteredTemplate = filterTemplateData(
rawTemplate,
modifiedFields,
existingTemplate,
TAB_FORM_MAP
)
// Every action that is not an human action
transformActionsInstantiate(filteredTemplate, apiTemplateData)
const xmlFinal = jsonToXml(filteredTemplate)
// Modify template
t.template = xmlFinal
rawTemplate.template = xmlFinal
return instantiate(t).unwrap()
return instantiate(rawTemplate).unwrap()
})
)
resetFieldPath()
resetModifiedFields()
history.push(PATH.INSTANCE.VMS.LIST)
const total = templates.length
@ -116,7 +116,7 @@ function InstantiateVmTemplate() {
return <Redirect to={PATH.TEMPLATE.VMS.LIST} />
}
return !dataTemplateExtended || !dataTemplate || _.isEmpty(oneConfig) ? (
return !dataTemplateExtended || !apiTemplateData || _.isEmpty(oneConfig) ? (
<SkeletonStepsForm />
) : (
<InstantiateForm

View File

@ -26,6 +26,20 @@ export const updateDisabledSteps = createAction('Set disabled steps')
export const dismissSnackbar = createAction('Dismiss snackbar')
export const deleteSnackbar = createAction('Delete snackbar')
export const setUploadSnackbar = createAction('Change upload snackbar')
export const setFieldPath = createAction('Set dynamic field path')
export const resetFieldPath = createAction('Reset field path')
export const initModifiedFields = createAction('Init modified fields')
export const changePositionModifiedFields = createAction(
'Change position of two array elements in modified fields'
)
export const setModifiedFields = createAction(
'Set modified fields',
(fields, options = {}) => ({
payload: fields,
meta: { batch: options.batch || false },
})
)
export const resetModifiedFields = createAction('Reset modified fields')
export const enqueueSnackbar = createAction(
'Enqueue snackbar',

View File

@ -35,6 +35,18 @@ export const useGeneralApi = () => {
setUpdateDialog: (updateDialog) =>
dispatch(actions.setUpdateDialog(updateDialog)),
// modified fields
setFieldPath: (path) => dispatch(actions.setFieldPath(path)),
resetFieldPath: () => dispatch(actions.resetFieldPath()),
initModifiedFields: (fields) =>
dispatch(actions.initModifiedFields(fields)),
changePositionModifiedFields: (fields) =>
dispatch(actions.changePositionModifiedFields(fields)),
setModifiedFields: (fields, options = {}) => {
dispatch(actions.setModifiedFields(fields, options))
},
resetModifiedFields: () => dispatch(actions.resetModifiedFields()),
// dismiss all if no key has been defined
dismissSnackbar: (key) =>
dispatch(actions.dismissSnackbar({ key, dismissAll: !key })),

View File

@ -18,7 +18,9 @@ import { createSlice } from '@reduxjs/toolkit'
import { APPS_IN_BETA, APPS_WITH_SWITCHER } from 'client/constants'
import { logout } from 'client/features/Auth/slice'
import * as actions from 'client/features/General/actions'
import { generateKey } from 'client/utils'
import { generateKey, calculateIndex } from 'client/utils'
import { merge, cloneDeep, set, get, pullAt, pickBy } from 'lodash'
import { parsePayload } from 'client/utils/parser'
const initial = {
zone: 0,
@ -32,6 +34,8 @@ const initial = {
notifications: [],
selectedIds: [],
disabledSteps: [],
fieldPath: '',
modifiedFields: {},
}
const slice = createSlice({
@ -78,6 +82,144 @@ const slice = createSlice({
state.isUpdateDialog = !!payload
})
/* FIELD MODIFICATIONS */
.addCase(actions.setFieldPath, (state, { payload }) => {
state.fieldPath = payload
})
.addCase(actions.resetFieldPath, (state) => {
state.fieldPath = ''
})
.addCase(actions.initModifiedFields, (state, { payload }) => {
// Get field path and check if there is something in this path
const fieldPath = state?.fieldPath || ''
const exists = get(state.modifiedFields, fieldPath, false)
// If the path has content, do nothing
if (!exists) {
set(state.modifiedFields, fieldPath, payload)
}
})
.addCase(actions.changePositionModifiedFields, (state, { payload }) => {
// Get from payload the path to the arrays and the number of the element
const sourcePath = payload?.sourcePath
const sourcePosition = payload?.sourcePosition
const targetPath = payload?.targetPath
const sourceDelete = payload?.sourceDelete
const emptyObjectContent = payload?.emptyObjectContent
const targetPosition = payload?.targetPosition
// Get source element and change to target array and delete on source array
const sourceArray = get(state.modifiedFields, sourcePath)
const targetArray = get(state.modifiedFields, targetPath)
const sourceIndex = calculateIndex(sourceArray, sourcePosition)
const targetIndex = calculateIndex(targetArray, targetPosition)
const sourceFinalPosition =
sourceIndex === -1 ? sourceArray.length : sourceIndex
const targetFinalPosition =
targetIndex === -1 ? targetArray.length : targetIndex
targetArray[targetFinalPosition] = sourceArray[sourceFinalPosition]
sourceDelete && pullAt(sourceArray, sourceFinalPosition)
emptyObjectContent &&
(sourceArray[sourceFinalPosition] = pickBy(
sourceArray[sourceFinalPosition],
(value, key) => key.startsWith('__')
))
set(state.modifiedFields, sourcePath, sourceArray)
set(state.modifiedFields, targetPath, targetArray)
})
.addCase(actions.setModifiedFields, (state, { payload, meta }) => {
// Get field path
const fieldPath = state?.fieldPath || ''
// Removes references
const mergedPayload = cloneDeep(payload)
if (fieldPath.length) {
const pathSegments = fieldPath.split('.')
const lastSegment = pathSegments.slice(-1)[0]
const isArrayIndex = /^\d+$/.test(lastSegment)
if (isArrayIndex) {
const pathWithoutIndex = pathSegments.slice(0, -1).join('.')
const arrayAtPath = get(state.modifiedFields, pathWithoutIndex, [])
const index = parseInt(lastSegment, 10)
while (arrayAtPath.length <= index) {
arrayAtPath.push({})
}
if (payload?.__flag__ === 'DELETE') {
// Filter the array to mark the correct position with delete
arrayAtPath.filter((item) => !item.__delete__)[
index
].__delete__ = true
} else {
// In array cases, we need to calculate the correction index (could exists elements that were deleted)
const arrayWithoutDeleteItems = arrayAtPath.filter(
(item) => !item.__delete__
)
const arrayWithoutDeleteItemsLength =
arrayWithoutDeleteItems.length
// Update original item
if (index < arrayWithoutDeleteItemsLength) {
arrayWithoutDeleteItems[index] = merge(
arrayWithoutDeleteItems[index] || {},
mergedPayload
)
} else {
// Create new item
arrayAtPath[arrayAtPath.length] = mergedPayload
}
}
set(state.modifiedFields, pathWithoutIndex, arrayAtPath)
} else {
const nested = pathSegments?.length > 2
const nestedBasePath = nested
? pathSegments.slice(0, -1).join('.')
: pathSegments.join('.')
const nestedKey = pathSegments.slice(-1)[0]
let existingNestedValue = get(
state.modifiedFields,
nestedBasePath,
{}
)
if (nested) {
existingNestedValue[nestedKey] = merge(
{},
existingNestedValue || {},
get(mergedPayload, pathSegments[0], {})
)
} else {
existingNestedValue = merge(
{},
existingNestedValue || {},
get(mergedPayload, pathSegments[0], {})
)
}
const parsedExisting = parsePayload(existingNestedValue, fieldPath)
set(state.modifiedFields, nestedBasePath, parsedExisting)
}
} else {
state.modifiedFields = merge({}, state.modifiedFields, mergedPayload)
}
if (payload?.setPath) {
state.fieldPath = payload.setPath
}
})
.addCase(actions.resetModifiedFields, (state) => {
state.modifiedFields = {}
})
/* UPLOAD NOTIFICATION */
.addCase(actions.setUploadSnackbar, (state, { payload }) => ({
...state,

View File

@ -13,7 +13,8 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useCallback, useState, SetStateAction } from 'react'
import { useCallback, useState, SetStateAction, useRef, useEffect } from 'react'
import { useGeneralApi } from 'client/features/General'
import { v4 as uuidv4 } from 'uuid'
import { set } from 'client/utils'
@ -94,6 +95,8 @@ const defaultAddItemId = (item, id) => ({ ...item, id })
* @param {object} props.defaultValue - Default value of element
* @param {function(object, number):string|number} props.getItemId - Function to change how detects unique item
* @param {function(object, string|number, number):object} props.addItemId - Function to add ID
* @param {object} props.modifiedFields - Array of field names to set as modified on select/unselect
* @param {string} props.fieldKey - Key under which to save modified fields.
* @example <caption>Example usage.</caption>
* // const INITIAL_STATE = { listKey: [{ id: 'item1' }, { id: 'item2' }] }
* // const [formData, setFormData] = useState(INITIAL_STATE)
@ -115,7 +118,12 @@ const useListForm = ({
defaultValue,
getItemId = defaultGetItemId,
addItemId = defaultAddItemId,
modifiedFields,
fieldKey,
}) => {
const { setModifiedFields } = useGeneralApi()
const selectedRef = useRef(false)
const [editingData, setEditingData] = useState(() => undefined)
const getIndexById = useCallback(
@ -135,15 +143,13 @@ const useListForm = ({
[key, parent, setList]
)
const handleSelect = useCallback(
(id) => {
setList((prevList) => ({
...prevList,
[key]: multiple ? [...(prevList[key] ?? []), id] : [id],
}))
},
[key, setList, multiple]
)
const handleSelect = useCallback((id) => {
setList((prevList) => ({
...prevList,
[key]: multiple ? [...(prevList[key] ?? []), id] : [id],
}))
selectedRef.current = true
})
const handleUnselect = useCallback(
(id) => {
@ -152,6 +158,7 @@ const useListForm = ({
)
handleSetList(newList)
selectedRef.current = false
},
[list, setList]
)
@ -199,6 +206,24 @@ const useListForm = ({
[list, defaultValue]
)
useEffect(
() => () => {
if (selectedRef?.current && !!modifiedFields?.length) {
const mergedFields = modifiedFields.reduce((acc, field) => {
if (fieldKey) {
acc[fieldKey] = { ...acc[fieldKey], [field]: true }
} else {
acc[field] = true
}
return acc
}, {})
setModifiedFields(mergedFields, { batch: true })
}
},
[]
)
return {
list,
editingData,

View File

@ -522,10 +522,8 @@ export const userInputsToArray = (
if (orderedList.length) {
list = list.sort((a, b) => {
const valueA = parseInt(a.name, 10)
const valueB = parseInt(b.name, 10)
const upperAName = isNaN(valueA) ? valueA : a.name?.toUpperCase?.()
const upperBName = isNaN(valueB) ? valueB : b.name?.toUpperCase?.()
const upperAName = a.name?.toUpperCase?.()
const upperBName = b.name?.toUpperCase?.()
return orderedList.indexOf(upperAName) - orderedList.indexOf(upperBName)
})

View File

@ -98,7 +98,9 @@ export const getAllocatedInfo = (host) => {
*/
export const getHugepageSizes = (host) => {
const wrapHost = Array.isArray(host) ? host : [host]
const numaNodes = [wrapHost?.HOST_SHARE?.NUMA_NODES?.NODE ?? []].flat()
const numaNodes = wrapHost
?.map((item) => item?.HOST_SHARE?.NUMA_NODES?.NODE)
.flat()
return numaNodes
.filter((node) => node?.NODE_ID && node?.HUGEPAGE)

View File

@ -18,7 +18,7 @@ import { hydrate, render } from 'react-dom'
import { createStore } from 'client/store'
import App from 'client/apps/sunstone'
const { store } = createStore({ initState: window.__PRELOADED_STATE__ })
export const { store } = createStore({ initState: window.__PRELOADED_STATE__ })
delete window.__PRELOADED_STATE__

View File

@ -591,3 +591,83 @@ export const generateDocLink = (version, path) => {
// Return link
return DOCS_BASE_PATH + '/' + versionDoc + '/' + path
}
/**
* Extract TAB id from field path.
*
* @param {string} str - Field path
* @returns {string} - Tab ID/NAME
*/
export const extractTab = (str) => {
const parts = str?.split('.')
return /^\d+$/.test(parts[parts.length - 1])
? parts[parts.length - 2]
: parts.pop()
}
export const findKeyWithPath = (() => {
const cache = new Map()
/**
* @param {object} root0 - Params
* @param {object} root0.obj - Object to search
* @param {string} root0.keyToFind - Key to find
* @param {Array} root0.path - Path to key
* @param {boolean} root0.findAll - Return first or all
* @returns {object} - Found key(s) + path(s)
*/
const search = ({ obj, keyToFind, path = [], findAll = false }) => {
const cacheKey = JSON.stringify({ obj, keyToFind, path, findAll })
if (cache.has(cacheKey)) {
return cache.get(cacheKey)
}
if (typeof obj !== 'object' || obj === null) {
return { found: false, paths: [] }
}
if (keyToFind in obj) {
const newPath = [...path, keyToFind]
const searchResult = { found: true, paths: [newPath] }
if (!findAll) {
cache.set(cacheKey, searchResult)
return searchResult
}
return searchResult
}
let accumulatedResults = []
const entries = Array.isArray(obj) ? obj.entries() : Object.entries(obj)
for (const [key, value] of entries) {
if (typeof value === 'object') {
const nextPath = Array.isArray(obj)
? [...path, `[${key}]`]
: [...path, key]
const subSearchResult = search({
obj: value,
keyToFind,
path: nextPath,
findAll,
})
if (subSearchResult.found) {
accumulatedResults = [...accumulatedResults, ...subSearchResult.paths]
if (!findAll) {
break
}
}
}
}
const found = accumulatedResults.length > 0
const finalResult = { found, paths: accumulatedResults }
cache.set(cacheKey, finalResult)
return finalResult
}
return search
})()

View File

@ -17,12 +17,20 @@ import templateToObject from 'client/utils/parser/templateToObject'
import parseApplicationToForm from 'client/utils/parser/parseApplicationToForm'
import parseFormToApplication from 'client/utils/parser/parseFormToApplication'
import parseFormToDeployApplication from 'client/utils/parser/parseFormToDeployApplication'
import { parseAcl } from 'client/utils/parser/parseACL'
import {
parseNetworkString,
parseCustomInputString,
} from 'client/utils/parser/parseServiceTemplate'
import parseVmTemplateContents from 'client/utils/parser/parseVmTemplateContents'
import { parseAcl } from 'client/utils/parser/parseACL'
import parseTouchedDirty from 'client/utils/parser/parseTouchedDirty'
import isDeeplyEmpty from 'client/utils/parser/isDeeplyEmpty'
import {
filterTemplateData,
transformActionsCreate,
transformActionsInstantiate,
} from 'client/utils/parser/vmTemplateFilter'
import parsePayload from 'client/utils/parser/parseTemplatePayload'
export {
templateToObject,
@ -33,4 +41,10 @@ export {
parseNetworkString,
parseCustomInputString,
parseVmTemplateContents,
parseTouchedDirty,
isDeeplyEmpty,
filterTemplateData,
parsePayload,
transformActionsCreate,
transformActionsInstantiate,
}

View File

@ -0,0 +1,34 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, 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. *
* ------------------------------------------------------------------------- */
/**
* @param {object} obj - Deeply nested object
* @returns {boolean} - Empty
*/
const isDeeplyEmpty = (obj) => {
if (obj == null || typeof obj !== 'object') return true
return Object.entries(obj).every(([_key, value]) => {
if (Array.isArray(value)) {
return value.every(isDeeplyEmpty)
} else if (value !== null && typeof value === 'object') {
return isDeeplyEmpty(value)
}
return !value
})
}
export default isDeeplyEmpty

View File

@ -25,7 +25,7 @@ import { ACL_ID, ACL_RESOURCES, ACL_RIGHTS } from 'client/constants'
* @param {string} rule - The ACL rule
* @returns {number} - The hex value for the four components of a rule (user, resources, rights and zone)
*/
export const parseAcl = (rule) => {
const parseAcl = (rule) => {
// Get each component
const ruleComponents = rule.split(' ')
@ -140,3 +140,5 @@ const calculateIds = (id) => {
// Return the integer id value
return idValue
}
export { parseAcl }

View File

@ -0,0 +1,54 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, 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 { get } from 'lodash'
import { findKeyWithPath, extractTab } from 'client/utils'
import { TAB_FORM_MAP } from 'client/constants'
/**
* @param {object} payload - Payload.
* @param {string} fieldPath - Field path.
* @returns {object} - Parsed payload.
*/
const parsePayload = (payload, fieldPath) => {
const TAB = extractTab(fieldPath)
if (payload === undefined || !fieldPath?.includes('extra')) {
return payload // only parses the extra step
}
const relevantFields = TAB_FORM_MAP[TAB]
if (!relevantFields) {
return {}
}
return relevantFields.reduce((parsedPayload, key) => {
const searchResult = findKeyWithPath({
obj: payload,
keyToFind: key,
})
if (searchResult.found) {
const value = get(payload, searchResult.paths[0].join('.'), {})
if (value !== undefined) {
parsedPayload[key] = value
}
}
return parsedPayload
}, {})
}
export default parsePayload

View File

@ -0,0 +1,44 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, 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. *
* ------------------------------------------------------------------------- */
/**
* @param {object} touchedFields - RHF touched fields object.
* @param {object} dirtyFields - RHF dirty fields object.
* @returns {object} - Touched dirty object.
*/
const parseTouchedDirty = (touchedFields, dirtyFields) => {
const mergeRecursively = (touched, dirty) =>
Object.keys(dirty).reduce((acc, key) => {
if (touched?.[key]) {
if (
typeof dirty[key] === 'object' &&
dirty[key] !== null &&
typeof touched[key] === 'object' &&
touched[key] !== null
) {
acc[key] = mergeRecursively(touched[key], dirty[key])
} else {
acc[key] = dirty[key]
}
}
return acc
}, {})
return mergeRecursively(touchedFields, dirtyFields)
}
export default parseTouchedDirty

View File

@ -0,0 +1,739 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2023, 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 _, { cloneDeep, merge, get } from 'lodash'
import { convertToMB } from 'client/utils'
import { MEMORY_RESIZE_OPTIONS, T } from 'client/constants'
import { transformXmlString } from 'client/models/Helper'
// Attributes that will be always modify with the value of the form (except Storage, Network and PCI sections)
const alwaysIncludeAttributes = {
general: {
HYPERVISOR: true,
},
extra: {
OsCpu: {
OS: {
BOOT: true,
FIRMWARE: true,
},
},
InputOutput: {
INPUT: true,
},
Context: {
INPUTS_ORDER: true,
USER_INPUTS: true,
CONTEXT: {
SSH_PUBLIC_KEY: true,
NETWORK: true,
},
},
},
}
// Attributes that will be always modify with the value of the form in the Pci section
const alwaysIncludePci = {
TYPE: true,
}
// Attributes that will be always modify with the value of the form in the Nic section
const alwaysIncludeNic = {
NAME: true,
}
// Attributes that will be always modify with the value of the form in the Nic alias section
const alwaysIncludeNicAlias = {
PARENT: true,
NAME: true,
}
/**
* Filter the data of the form data with the values that were modified by the user and not adding the ones that could be added by default. The goal is to create the most simplify template that we can.
*
* @param {object} formData - VM Template form data
* @param {object} modifiedFields - Touched/Dirty fields object
* @param {object} existingTemplate - Existing data
* @param {object} tabFormMap - Maps formData fields to tabs
* @returns {object} - Filtered template data
*/
const filterTemplateData = (
formData,
modifiedFields,
existingTemplate,
tabFormMap
) => {
// Generate a form from the original data
const normalizedTemplate = normalizeTemplate(existingTemplate, tabFormMap)
// Filter data of formData.general
const newGeneral = reduceGeneral(
formData?.general,
modifiedFields?.general,
normalizedTemplate?.general,
alwaysIncludeAttributes
)
// Filter data of formData.extra
const newExtra = reduceExtra(
formData,
modifiedFields,
normalizedTemplate,
tabFormMap,
alwaysIncludeAttributes
)
// Add custom variables
const newCustomVariables = {
...normalizedTemplate['custom-variables'],
...formData['custom-variables'],
}
const result = {
...newGeneral,
...newExtra,
...newCustomVariables,
}
// Instantiate form could have another step called user_inputs
if (formData.user_inputs) {
result.CONTEXT = {
...result.CONTEXT,
...formData.user_inputs,
}
}
// Return object with all sections
return result
}
/**
* Generates a formData format object with the original data before the user modifies anything.
*
* @param {object} existingTemplate - Original data before user's modifications in the same format as formData
* @param {string} tabFormMap - Map with the attributes (for the XML request) that will contain the section
* @returns {object} - A form data object with the original data
*/
const normalizeTemplate = (existingTemplate, tabFormMap) => {
const categoryLookup = Object.entries(tabFormMap).reduce(
(acc, [category, keys]) => {
keys.forEach((key) => (acc[key] = category))
return acc
},
{}
)
const normalized = { general: {}, extra: {} }
Object.entries(existingTemplate).forEach(([key, value]) => {
if (categoryLookup[key]) {
normalized.extra[key] = value
} else {
normalized.general[key] = value
}
})
if ('custom-variables' in existingTemplate) {
normalized['custom-variables'] = existingTemplate['custom-variables']
}
return normalized
}
/**
* Filter the formData.general section to only add modifications of the user.
*
* @param {object} formData - The data from the form (the one that the user could be modify)
* @param {object} itemModifications - Map with the fields that will be touched and modified by the user or deleted (in array case) by the user
* @param {object} existingTemplate - Original data before user's modifications in the same format as formData
* @param {object} alwaysInclude - Include this field always as modified
* @returns {object} - The general section with the final data to submit
*/
const reduceGeneral = (
formData,
itemModifications,
existingTemplate,
alwaysInclude
) => {
const newGeneral = { ...existingTemplate }
// Add always include
const correctionMap = merge({}, itemModifications, alwaysInclude?.general)
Object.entries(correctionMap || {}).forEach(([key, correction]) => {
if (correction && typeof correction === 'object' && correction.__delete__) {
delete newGeneral[key]
} else if (
correction &&
key in formData &&
(!_.isEmpty(formData[key]) || formData[key] !== null)
) {
// If the correction is boolean, means that the user changed this value that is a simple value
// If the correction is an object with an attribute delete means that is a hidden field that was deleted because the user change the value of its parent
// If the correction is an object without an attribute delete means that is a field with an object instead a simple value and the user changed its value
if (typeof correction === 'boolean' && correction) {
newGeneral[key] = formData[key]
} else if (typeof correction === 'object') {
// In object case, call the same function to iterate over each key of the object
const newChildren = reduceGeneral(
formData[key],
correction,
_.get(existingTemplate, key)
)
newGeneral[key] = newChildren
}
}
})
return newGeneral
}
/**
* Filter the formData.extra section to only add modifications of the user.
*
* @param {object} formData - The data from the form (the one that the user could be modify)
* @param {object} itemModifications - Map with the fields that will be touched and modified by the user or deleted (in array case) by the user
* @param {object} existingTemplate - Original data before user's modifications in the same format as formData
* @param {string} tabFormMap - Map with the attributes (for the XML request) that will contain the section
* @param {object} alwaysInclude - Include this field always as modified
* @returns {object} - The extra section with the final data to submit
*/
const reduceExtra = (
formData,
itemModifications,
existingTemplate,
tabFormMap,
alwaysInclude
) => {
const newExtra = { ...existingTemplate.extra }
// Ensure NIC array
if (newExtra?.NIC)
newExtra.NIC = Array.isArray(newExtra.NIC) ? newExtra.NIC : [newExtra.NIC]
if (newExtra?.NIC_ALIAS)
newExtra.NIC_ALIAS = Array.isArray(newExtra.NIC_ALIAS)
? newExtra.NIC_ALIAS
: [newExtra.NIC_ALIAS]
if (newExtra?.PCI)
newExtra.PCI = Array.isArray(newExtra.PCI) ? newExtra.PCI : [newExtra.PCI]
const originalData = cloneDeep(newExtra)
// Add always include
const correctionMap = merge({}, itemModifications, alwaysInclude)
Object.entries(correctionMap.extra || {}).forEach(
([section, sectionData]) => {
if (section === 'Network') {
Object.entries(correctionMap.extra[section]).forEach(([subSection]) => {
if (subSection === 'NIC') {
handleNetwork(
formData,
correctionMap?.extra?.Network,
newExtra,
subSection,
'NIC',
originalData,
alwaysIncludeNic
)
} else if (subSection === 'NIC_ALIAS') {
handleNetwork(
formData,
correctionMap?.extra?.Network,
newExtra,
subSection,
'NIC_ALIAS',
originalData,
alwaysIncludeNicAlias
)
} else if (subSection === 'NIC_DEFAULT') {
filterSingleSection(
formData,
correctionMap,
'Network',
newExtra,
'NIC_DEFAULT'
)
}
})
} else if (section === 'Storage') {
handleStorage(formData, correctionMap, newExtra, section, 'DISK')
} else {
handleOtherSections(
formData,
correctionMap,
section,
newExtra,
tabFormMap,
originalData
)
}
}
)
// Omitting values that are empty
return _.omitBy(newExtra, _.isEmpty)
}
/**
* Delete the items that were deleted by the user in the original data.
*
* @param {object} existingData - The original data
* @param {object} correctionMapSection - Map to get the delete items
* @returns {object} - An array with the original data but without the deleted items
*/
const deleteItemsOnExistingData = (
existingData = {},
correctionMapSection = []
) =>
// The items will be deleted if the section exists on the correction map and the index exists and has the delete flag
existingData.filter(
(data, index) =>
!correctionMapSection ||
index >= correctionMapSection.length ||
!correctionMapSection[index].__delete__
)
/**
* Handle the network section to add modified fields.
*
* @param {object} formData - The data from the form (the one that the user could be modify)
* @param {object} correctionMap - Map with the fields that will be touched and modified by the user
* @param {object} newExtra - The extra section of the form.
* @param {string} section - Section of the form (this function will have always Network)
* @param {string} type - Section type inside network section
* @param {object} originalData - Form data before the user makes any changes
* @param {object} alwaysInclude - Include this field always as modified
*/
const handleNetwork = (
formData,
correctionMap,
newExtra,
section,
type,
originalData,
alwaysInclude = {}
) => {
if (!formData.extra[type]) return
const existingData = _.cloneDeep(newExtra[type])
// Create an array if there is only one element and delete the items that were deleted by the user.
const wrappedExistingData = existingData
? deleteItemsOnExistingData(
Array.isArray(existingData) ? existingData : [existingData],
get(correctionMap, section, [])
)
: {}
// Delete the items that were deleted by the user to get the correct indexes.
const sectionModifications = deleteItemsOnExistingData(
get(correctionMap, section, []),
get(correctionMap, section, [])
)
if (type === 'NIC') {
sectionModifications.forEach((value, index) => {
const indexAlias = value.__aliasIndex__
if (indexAlias !== undefined)
wrappedExistingData[index] = { ...originalData.NIC_ALIAS[indexAlias] }
const indexPci = value.__aliasPci__
if (indexPci !== undefined)
wrappedExistingData[index] = { ...originalData.PCI[indexPci] }
})
} else if (type === 'NIC_ALIAS') {
sectionModifications.forEach((value, index) => {
const indexNic = value.__nicIndex__
if (indexNic !== undefined)
wrappedExistingData[index] = { ...originalData.NIC[indexNic] }
const indexPci = value.__aliasPci__
if (indexPci !== undefined)
wrappedExistingData[index] = { ...originalData.PCI[indexPci] }
})
} else if (type === 'PCI') {
sectionModifications.forEach((value, index) => {
const indexNic = value.__nicIndex__
if (indexNic !== undefined)
wrappedExistingData[index] = { ...originalData.NIC[indexNic] }
const indexAlias = value.__aliasIndex__
if (indexAlias !== undefined)
wrappedExistingData[index] = { ...originalData.NIC_ALIAS[indexAlias] }
})
}
// Iterate over the final data
const modifiedData = formData.extra[type].map((item, index) => {
// Check if the index of the item it's on the modifications map and has value
if (
index < sectionModifications.length &&
sectionModifications[index] !== null
) {
let itemModifications = {}
// If the type is PCI and not has a TYPE attribute, the pci is from the inputOutput section. In other case, is from network section. So in the second case, we have to get the attribute from advanced step, and not in the first case.
if (type === 'PCI' && !item?.TYPE) {
// Get the fields where the modifications were done
itemModifications = {
...sectionModifications[index],
...alwaysInclude,
}
} else {
// Get the fields where the modifications were done
itemModifications = Object.keys(sectionModifications[index])?.reduce(
(acc, key) => ({
...acc,
...sectionModifications[index][key],
...alwaysInclude,
}),
{}
)
}
// Delete keys on existing data that are marked to delete and not exists on formData
if (wrappedExistingData && wrappedExistingData[index])
Object.entries(itemModifications)
.filter(([key, value]) => value.__delete__ && !item[key])
.forEach(
([key, value]) =>
wrappedExistingData[index][key] &&
delete wrappedExistingData[index][key]
)
// Iterate over each field of the item and, if it is one of the field that was modified, add the modification to the new data
return Object.keys(item).reduce((acc, key) => {
if (
typeof itemModifications[key] === 'boolean' &&
itemModifications[key]
) {
acc[key] = item[key]
} else if (
typeof itemModifications[key] === 'object' &&
itemModifications[key].__delete__
) {
delete acc[key]
}
return acc
}, wrappedExistingData?.[index] || {})
}
return item
})
newExtra[type] = modifiedData
}
/**
* Handle the storage section to add modified fields.
*
* @param {object} formData - The data from the form (the one that the user could be modify)
* @param {object} correctionMap - Map with the fields that will be touched and modified by the user
* @param {object} newExtra - The extra section of the form.
* @param {string} section - Section of the form (this function will have always Storage)
* @param {string} type - Section type inside network section
*/
const handleStorage = (formData, correctionMap, newExtra, section, type) => {
if (!formData.extra[type]) return
// const sectionModifications = correctionMap.extra[section] || []
const existingData = _.cloneDeep(newExtra[type])
// Delete the items that were deleted by the user to get the correct indexes.
const wrappedExistingData = deleteItemsOnExistingData(
Array.isArray(existingData) ? existingData : [existingData],
correctionMap.extra[section]
)
// Delete the items that were deleted by the user to get the correct indexes.
const sectionModifications = deleteItemsOnExistingData(
correctionMap.extra[section],
correctionMap.extra[section]
)
// Iterate over the final data
const modifiedData = formData.extra[type].map((disk, index) => {
// Check if the index of the item it's on the modifications map and has value
if (
index < sectionModifications.length &&
sectionModifications[index] !== null
) {
// Get the fields where the modifications were done
const diskModifications = Object.keys(
sectionModifications[index]
)?.reduce(
(acc, key) => ({ ...acc, ...sectionModifications[index][key] }),
{}
)
// Iterate over each field of the item and, if it is one of the field that was modified, add the modification to the new data
return Object.keys(disk).reduce((acc, key) => {
if (
typeof diskModifications[key] === 'boolean' &&
diskModifications[key]
) {
acc[key] = disk[key]
} else if (
typeof diskModifications[key] === 'object' &&
diskModifications[key].__delete__
) {
delete acc[key]
} else if (key === 'SIZE' && diskModifications.SIZEUNIT) {
acc[key] = disk[key]
}
return acc
}, wrappedExistingData?.[index] || {})
}
return disk
})
newExtra[type] = modifiedData
}
/**
* Handle others section (that not are Storage or Network) to add modified fields.
*
* @param {object} formData - The data from the form (the one that the user could be modify)
* @param {object} correctionMap - Map with the fields that will be touched and modified by the user
* @param {string} section - Section of the form
* @param {object} newExtra - The extra section of the form.
* @param {string} tabFormMap - Map with the attributes (for the XML request) that will contain the section
* @param {object} originalData - Form data before the user makes any changes
*/
const handleOtherSections = (
formData,
correctionMap,
section,
newExtra,
tabFormMap,
originalData
) => {
// Check each section of the templates form
if (tabFormMap[section]) {
tabFormMap[section].forEach((key) => {
// Scheduled actions special case
if (key === 'SCHED_ACTION') {
newExtra[key] = formData?.extra[key]
} else if (key === 'PCI') {
handleNetwork(
formData,
correctionMap?.extra?.InputOutput,
newExtra,
'PCI',
'PCI',
originalData,
alwaysIncludePci
)
} else {
filterSingleSection(formData, correctionMap, section, newExtra, key)
// Special cases for Context.USER_INPUTS
if (
section === 'Context' &&
key === 'USER_INPUTS' &&
correctionMap.extra.Context.USER_INPUTS
) {
// Keep CONTEXT.INPUTS_ORDER because it's not a form by himself, depends on CONTEXT.USER_INPUTS
newExtra.INPUTS_ORDER = formData.extra.INPUTS_ORDER
}
}
})
}
}
/**
* Filter one section that is not Storage, Network or Schedule Actions.
*
* @param {object} formData - The data from the form (the one that the user could be modify)
* @param {object} itemsModifications - Map with the fields that will be touched and modified by the user
* @param {string} section - Section of the form
* @param {object} newExtra - The extra section of the form.
* @param {string} key - Section on the formData
*/
const filterSingleSection = (
formData,
itemsModifications,
section,
newExtra,
key
) => {
// Check if attribute has changes on the correction map
if (
(key in formData.extra || key in itemsModifications.extra[section]) &&
itemsModifications.extra[section]?.[key]
) {
// Get value and copy the section
const value = formData.extra[key]
const newOtherSection = { ...newExtra[key] }
// Arrays and single values replace the whole value
if (
Array.isArray(value) ||
key === 'USER_INPUTS' ||
!(typeof value === 'object')
) {
newExtra[key] = value
} else {
// Objects iterate over each key to check if the key was changed by the user
Object.entries(itemsModifications.extra[section]?.[key] || {}).forEach(
([childrenKey, correction]) => {
// If the correction is boolean, means that the user changed this value that is a simple value
// If the correction is an object with an attribute delete means that is a hidden field that was deleted because the user change the value of its parent
// If the correction is an object without an attribute delete means that is a field with an object instead a simple value and the user changed its value
if (
correction &&
typeof correction === 'object' &&
correction.__delete__
) {
delete newOtherSection[childrenKey]
} else if (
correction &&
typeof correction === 'boolean' &&
childrenKey in formData.extra[key] &&
(!_.isEmpty(formData.extra[key][childrenKey]) ||
formData.extra[key][childrenKey] !== null)
) {
newOtherSection[childrenKey] = formData.extra[key][childrenKey]
} else if (
correction &&
typeof correction === 'boolean' &&
!(childrenKey in formData.extra[key])
) {
delete newOtherSection[childrenKey]
}
}
)
// Add section with changes
newExtra[key] = newOtherSection
}
}
}
/**
* Execute any needed action on create or update template after filter the template.
*
* @param {object} template - Template with data
*/
const transformActionsCreate = (template) => {
transformActionsCommon(template)
}
/**
* Execute any needed action on instantiate template after filter the template.
*
* @param {object} template - Template with data
* @param {object} original - Initial values of the template
*/
const transformActionsInstantiate = (template, original) => {
transformActionsCommon(template)
original?.TEMPLATE?.OS &&
template?.OS &&
(template.OS = {
...original?.TEMPLATE?.OS,
...template?.OS,
})
}
/**
* Execute actions on the filtered template (common to instantiate and create/update).
*
* @param {object} template - Template with data
*/
const transformActionsCommon = (template) => {
const newContext = template.CONTEXT ? { ...template.CONTEXT } : {}
// Reset user inputs in context object
Object.keys(newContext).forEach((key) => {
if (newContext[key] === '$' + key) {
delete newContext[key]
}
})
// Add user inputs to context
if (template?.USER_INPUTS) {
Object.keys(template?.USER_INPUTS).forEach((name) => {
const isCapacity = ['MEMORY', 'CPU', 'VCPU'].includes(name)
const upperName = String(name).toUpperCase()
!isCapacity && (newContext[upperName] = `$${upperName}`)
if (!template.CONTEXT) template.CONTEXT = {}
})
}
template.CONTEXT = newContext
// Delete MEMORY_SLOTS if the MEMORY_RESIZE_MODE is not Hotplug
if (template?.MEMORY_RESIZE_MODE !== MEMORY_RESIZE_OPTIONS[T.Hotplug]) {
delete template.MEMORY_SLOTS
}
// ISSUE#6136: Convert size to MB (because XML API uses only MB) and delete sizeunit field (no needed on XML API)
template.MEMORY = convertToMB(template.MEMORY, template.MEMORYUNIT)
delete template.MEMORYUNIT
// cast CPU_MODEL/FEATURES
if (Array.isArray(template?.CPU_MODEL?.FEATURES)) {
template.CPU_MODEL.FEATURES = template.CPU_MODEL.FEATURES.join(', ')
}
// Delete NIC name
;['PCI', 'NIC_ALIAS'].forEach((nicKey) => {
const section = template?.[nicKey]
? Array.isArray(template?.[nicKey])
? template?.[nicKey]
: [template?.[nicKey]]
: []
section.forEach((NIC) => delete NIC?.NAME)
})
// Delete Schedule action NAME
if (template.SCHED_ACTION) {
const ensuredSched = template.SCHED_ACTION
? Array.isArray(template.SCHED_ACTION)
? template.SCHED_ACTION
: [template.SCHED_ACTION]
: []
template.SCHED_ACTION = ensuredSched.map((action) => {
delete action.NAME
return action
})
}
// If template has RAW attribute
if (template.RAW) {
// // Add type (hypervisor) on RAW data if exists data, if not, delete RAW section.
if (template.RAW?.DATA) template.RAW.TYPE = template.HYPERVISOR
else delete template.RAW
// ISSUE #6418: Raw data is in XML format, so it needs to be transform before sennding it to the API (otherwise the value of RAW.DATA will be treat as part of the XML template)
template?.RAW?.DATA &&
(template.RAW.DATA = transformXmlString(template.RAW.DATA))
}
}
export {
filterTemplateData,
transformActionsCreate,
transformActionsInstantiate,
}

View File

@ -608,3 +608,27 @@ export const disableFields = (
return field
})
}
/**
* Calculate the index array without objects that have delete attribute.
*
* @param {Array} list - Array to filter
* @param {number} position - Position to look for
* @returns {number} - The index in the array
*/
export const calculateIndex = (list, position) => {
let filteredIndex = -1
let acc = 0
for (let i = 0; i < list.length; i++) {
if (!list[i].__delete__) {
if (acc === position) {
filteredIndex = i
break
}
acc++
}
}
return filteredIndex
}

View File

@ -106,6 +106,10 @@ module.exports = {
from: resource,
default: 0,
},
force: {
from: postBody,
default: false,
},
},
},
[IMAGE_ENABLE]: {