1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-03-21 14:50:08 +03:00

F #5422: Add features to VM Template forms (#2118)

This commit is contained in:
Sergio Betanzos 2022-06-02 11:52:46 +02:00 committed by GitHub
parent 23a1de0162
commit 129e252c12
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 411 additions and 237 deletions

View File

@ -80,6 +80,7 @@ export const ENDPOINTS = [
*
* @typedef {object} ResourceView - Resource view file selected in redux (auth-reducer)
* @property {string} resource_name - Resource view name
* @property {object} features - Features about the resources
* @property {object} actions - Bulk actions, including dialogs
* Which buttons are visible to operate over the resources
* @property {object} filters - List of criteria to filter the resources

View File

@ -52,7 +52,9 @@ const AutocompleteController = memo(
onChange={(_, newValue) => {
const newValueToChange = multiple
? newValue?.map((value) =>
typeof value === 'string' ? value : { text: value, value }
['string', 'number'].includes(typeof value)
? value
: { text: value, value }
)
: newValue?.value

View File

@ -18,7 +18,7 @@ import PropTypes from 'prop-types'
import { styled, FormControl, FormHelperText } from '@mui/material'
import { Check as CheckIcon, Page as FileIcon } from 'iconoir-react'
import { useController } from 'react-hook-form'
import { useFormContext, useController } from 'react-hook-form'
import {
ErrorHelper,
@ -50,9 +50,8 @@ const FileController = memo(
transform,
fieldProps = {},
readOnly = false,
formContext = {},
}) => {
const { setValue, setError, clearErrors, watch } = formContext
const { setValue, setError, clearErrors, watch } = useFormContext()
const {
field: { ref, value, onChange, ...inputProps },
@ -166,13 +165,6 @@ FileController.propTypes = {
transform: PropTypes.func,
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
formContext: PropTypes.shape({
setValue: PropTypes.func,
setError: PropTypes.func,
clearErrors: PropTypes.func,
watch: PropTypes.func,
register: PropTypes.func,
}),
}
FileController.displayName = 'FileController'

View File

@ -17,11 +17,11 @@ import { memo, useMemo, useEffect } from 'react'
import PropTypes from 'prop-types'
import { TextField } from '@mui/material'
import { useController } from 'react-hook-form'
import { useController, useWatch } from 'react-hook-form'
import { ErrorHelper, Tooltip } from 'client/components/FormControl'
import { Tr, labelCanBeTranslated } from 'client/components/HOC'
import { generateKey } from 'client/utils'
import { generateKey, findClosestValue } from 'client/utils'
const SelectController = memo(
({
@ -33,9 +33,17 @@ const SelectController = memo(
values = [],
renderValue,
tooltip,
watcher,
dependencies,
fieldProps = {},
readOnly = false,
}) => {
const watch = useWatch({
name: dependencies,
disabled: dependencies == null,
defaultValue: Array.isArray(dependencies) ? [] : undefined,
})
const firstValue = values?.[0]?.value ?? ''
const defaultValue = multiple ? [firstValue] : firstValue
@ -46,7 +54,7 @@ const SelectController = memo(
const needShrink = useMemo(
() =>
multiple || values.find((v) => v.value === optionSelected)?.text !== '',
multiple || values.find((o) => o.value === optionSelected)?.text !== '',
[optionSelected]
)
@ -54,15 +62,29 @@ const SelectController = memo(
if (!optionSelected && !optionSelected.length) return
if (multiple) {
const exists = values.some((v) => optionSelected.includes(v.value))
const exists = values.some((o) => optionSelected.includes(o.value))
!exists && onChange([firstValue])
} else {
const exists = values.some((v) => `${v.value}` === `${optionSelected}`)
const exists = values.some((o) => `${o.value}` === `${optionSelected}`)
!exists && onChange(firstValue)
}
}, [multiple])
useEffect(() => {
if (!watcher || !dependencies) return
if (!watch) return onChange(defaultValue)
const watcherValue = watcher(watch)
const optionValues = values.map((o) => o.value)
const ensuredWatcherValue = isNaN(watcherValue)
? optionValues.find((o) => `${o}` === `${watcherValue}`)
: findClosestValue(watcherValue, optionValues)
onChange(ensuredWatcherValue ?? defaultValue)
}, [watch, watcher, dependencies])
return (
<TextField
{...inputProps}
@ -86,12 +108,12 @@ const SelectController = memo(
}
select
fullWidth
disabled={readOnly}
SelectProps={{ native: true, multiple }}
label={labelCanBeTranslated(label) ? Tr(label) : label}
InputLabelProps={{ shrink: needShrink }}
InputProps={{
...(multiple && { sx: { paddingTop: '0.5em' } }),
readOnly,
startAdornment:
(optionSelected && renderValue?.(optionSelected)) ||
(tooltip && <Tooltip title={tooltip} position="start" />),
@ -115,7 +137,8 @@ const SelectController = memo(
prev.values.length === next.values.length &&
prev.label === next.label &&
prev.tooltip === next.tooltip &&
prev.multiple === next.multiple
prev.multiple === next.multiple &&
prev.readOnly === next.readOnly
)
SelectController.propTypes = {
@ -127,6 +150,11 @@ SelectController.propTypes = {
multiple: PropTypes.bool,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
renderValue: PropTypes.func,
watcher: PropTypes.func,
dependencies: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]),
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
}

View File

@ -13,11 +13,11 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo } from 'react'
import { memo, useEffect, useCallback } from 'react'
import PropTypes from 'prop-types'
import { TextField, Slider, FormHelperText, Stack } from '@mui/material'
import { useController } from 'react-hook-form'
import { useController, useWatch } from 'react-hook-form'
import { ErrorHelper, Tooltip } from 'client/components/FormControl'
import { Tr, labelCanBeTranslated } from 'client/components/HOC'
@ -30,9 +30,17 @@ const SliderController = memo(
name = '',
label = '',
tooltip,
watcher,
dependencies,
fieldProps = {},
readOnly = false,
}) => {
const watch = useWatch({
name: dependencies,
disabled: dependencies == null,
defaultValue: Array.isArray(dependencies) ? [] : undefined,
})
const { min, max, step } = fieldProps ?? {}
const {
@ -40,12 +48,33 @@ const SliderController = memo(
fieldState: { error },
} = useController({ name, control })
const handleEnsuredChange = useCallback(
(newValue) => {
if (min && newValue < min) return onChange(min)
if (max && newValue > max) return onChange(max)
},
[onChange, min, max]
)
useEffect(() => {
if (!watcher || !dependencies || !watch) return
const watcherValue = watcher(watch)
watcherValue !== undefined && handleEnsuredChange(watcherValue)
}, [watch, watcher, dependencies])
const sliderId = `${cy}-slider`
const inputId = `${cy}-input`
return (
<>
<Stack direction="row" mt="0.5rem" spacing={2} alignItems="center">
<Stack
direction="row"
pl="1em"
mt="0.5rem"
spacing={2}
alignItems="center"
>
<Slider
color="secondary"
value={typeof value === 'number' ? value : 0}
@ -75,15 +104,11 @@ const SliderController = memo(
step,
}}
onChange={(evt) =>
onChange(!evt.target.value ? '0' : Number(evt.target.value))
handleEnsuredChange(
!evt.target.value ? '0' : Number(evt.target.value)
)
}
onBlur={() => {
if (min && value < min) {
onChange(min)
} else if (max && value > max) {
onChange(max)
}
}}
onBlur={() => handleEnsuredChange(value)}
/>
</Stack>
{Boolean(error) && (
@ -102,6 +127,11 @@ SliderController.propTypes = {
name: PropTypes.string.isRequired,
label: PropTypes.any,
tooltip: PropTypes.any,
watcher: PropTypes.func,
dependencies: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]),
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
}

View File

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { memo, useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import { useController } from 'react-hook-form'
import { useFormContext, useController } from 'react-hook-form'
import Legend from 'client/components/Forms/Legend'
import { ErrorHelper } from 'client/components/FormControl'
@ -42,11 +42,10 @@ const TableController = memo(
Table,
singleSelect = true,
getRowId = defaultGetRowId,
formContext = {},
readOnly = false,
fieldProps: { initialState, ...fieldProps } = {},
}) => {
const { clearErrors } = formContext
const { clearErrors } = useFormContext()
const {
field: { value, onChange },
@ -107,13 +106,6 @@ TableController.propTypes = {
tooltip: PropTypes.any,
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
formContext: PropTypes.shape({
setValue: PropTypes.func,
setError: PropTypes.func,
clearErrors: PropTypes.func,
watch: PropTypes.func,
register: PropTypes.func,
}),
}
TableController.displayName = 'TableController'

View File

@ -37,13 +37,11 @@ const TextController = memo(
fieldProps = {},
readOnly = false,
}) => {
const watch =
dependencies &&
useWatch({
control,
name: dependencies,
disabled: dependencies === null,
})
const watch = useWatch({
name: dependencies,
disabled: dependencies == null,
defaultValue: Array.isArray(dependencies) ? [] : undefined,
})
const {
field: { ref, value = '', onChange, ...inputProps },
@ -51,11 +49,11 @@ const TextController = memo(
} = useController({ name, control })
useEffect(() => {
if (watch && watcher) {
const watcherValue = watcher(watch)
watcherValue && onChange(watcherValue)
}
}, [watch])
if (!watcher || !dependencies || !watch) return
const watcherValue = watcher(watch)
watcherValue !== undefined && onChange(watcherValue)
}, [watch, watcher, dependencies])
return (
<TextField
@ -94,7 +92,8 @@ const TextController = memo(
prevProps.label === nextProps.label &&
prevProps.tooltip === nextProps.tooltip &&
prevProps.fieldProps?.value === nextProps.fieldProps?.value &&
prevProps.fieldProps?.helperText === nextProps.fieldProps?.helperText
prevProps.fieldProps?.helperText === nextProps.fieldProps?.helperText &&
prevProps.readOnly === nextProps.readOnly
)
TextController.propTypes = {

View File

@ -13,21 +13,23 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import {
ReactElement,
Fragment,
createElement,
memo,
useMemo,
useCallback,
isValidElement,
} from 'react'
import PropTypes from 'prop-types'
import { useFormContext } from 'react-hook-form'
import { useFormContext, useWatch } from 'react-hook-form'
import { FormControl, Accordion, AccordionSummary, Grid } from '@mui/material'
import * as FC from 'client/components/FormControl'
import Legend from 'client/components/Forms/Legend'
import { Field } from 'client/utils'
import { INPUT_TYPES } from 'client/constants'
const NOT_DEPEND_ATTRIBUTES = [
@ -51,19 +53,28 @@ const INPUT_CONTROLLER = {
[INPUT_TYPES.TOGGLE]: FC.ToggleController,
}
/**
* Renders a form with a schema and a legend for each section.
*
* @param {object} props - Component props
* @param {boolean} [props.accordion] - If true, the accordion will be rendered
* @param {string} [props.id] - The form id to be used as a prefix for the field name
* @param {string} [props.cy] - The id to be used on testing purposes
* @param {function():Field[]|Field[]} [props.fields] - The fields to be rendered
* @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
* @returns {ReactElement} - The form component
*/
const FormWithSchema = ({
accordion = false,
id,
cy,
fields,
rootProps,
className,
legend,
legendTooltip,
}) => {
const formContext = useFormContext()
const { control, watch } = formContext
const { sx: sxRoot, ...restOfRootProps } = rootProps ?? {}
const RootWrapper = useMemo(
@ -88,25 +99,14 @@ const FormWithSchema = ({
const getFields = useMemo(
() => (typeof fields === 'function' ? fields() : fields),
[fields?.length]
[fields]
)
if (!getFields || getFields?.length === 0) return null
const addIdToName = useCallback(
(name) =>
name.startsWith('$')
? name.slice(1) // removes character '$' and returns
: id
? `${id}.${name}` // concat form ID if exists
: name,
[id]
)
return (
<FormControl
component="fieldset"
className={className}
sx={{ width: '100%', ...sxRoot }}
{...restOfRootProps}
>
@ -122,56 +122,9 @@ const FormWithSchema = ({
)}
</LegendWrapper>
<Grid container spacing={1} alignContent="flex-start">
{getFields?.map?.(({ dependOf, ...attributes }) => {
let valueOfDependField = null
let nameOfDependField = null
if (dependOf) {
nameOfDependField = Array.isArray(dependOf)
? dependOf.map(addIdToName)
: addIdToName(dependOf)
valueOfDependField = watch(nameOfDependField)
}
const { name, type, htmlType, grid, ...fieldProps } =
Object.entries(attributes).reduce((field, attribute) => {
const [key, value] = attribute
const isNotDependAttribute = NOT_DEPEND_ATTRIBUTES.includes(key)
const finalValue =
typeof value === 'function' &&
!isNotDependAttribute &&
!isValidElement(value())
? value(valueOfDependField, formContext)
: value
return { ...field, [key]: finalValue }
}, {})
const dataCy = `${cy}-${name}`.replaceAll('.', '-')
const inputName = addIdToName(name)
const isHidden = htmlType === INPUT_TYPES.HIDDEN
if (isHidden) return null
return (
INPUT_CONTROLLER[type] && (
<Grid key={dataCy} item xs={12} md={6} {...grid}>
{createElement(INPUT_CONTROLLER[type], {
control,
cy: dataCy,
formContext,
dependencies: nameOfDependField,
name: inputName,
type: htmlType === false ? undefined : htmlType,
...fieldProps,
})}
</Grid>
)
)
})}
{getFields?.map?.((field) => (
<FieldComponent key={field?.name} cy={cy} id={id} {...field} />
))}
</Grid>
</RootWrapper>
</FormControl>
@ -189,7 +142,89 @@ FormWithSchema.propTypes = {
legend: PropTypes.any,
legendTooltip: PropTypes.string,
rootProps: PropTypes.object,
className: PropTypes.string,
}
const FieldComponent = memo(({ id, cy, dependOf, ...attributes }) => {
const formContext = useFormContext()
const addIdToName = useCallback(
(n) => {
// removes character '$' and returns
if (n.startsWith('$')) return n.slice(1)
// concat form ID if exists
return id ? `${id}.${n}` : n
},
[id]
)
const nameOfDependField = useMemo(() => {
if (!dependOf) return null
return Array.isArray(dependOf)
? dependOf.map(addIdToName)
: addIdToName(dependOf)
}, [dependOf, addIdToName])
const valueOfDependField = useWatch({
name: nameOfDependField,
disabled: dependOf === undefined,
defaultValue: Array.isArray(dependOf) ? [] : undefined,
})
/* const valueOfDependField = useMemo(() => {
if (!dependOf) return null
return watch(nameOfDependField)
}, [dependOf, watch, nameOfDependField]) */
const { name, type, htmlType, grid, ...fieldProps } = Object.entries(
attributes
).reduce((field, attribute) => {
const [key, value] = attribute
const isNotDependAttribute = NOT_DEPEND_ATTRIBUTES.includes(key)
const finalValue =
typeof value === 'function' &&
!isNotDependAttribute &&
!isValidElement(value())
? value(valueOfDependField, formContext)
: value
return { ...field, [key]: finalValue }
}, {})
const dataCy = useMemo(() => `${cy}-${name ?? ''}`.replaceAll('.', '-'), [cy])
const inputName = useMemo(() => addIdToName(name), [addIdToName, name])
const isHidden = useMemo(() => htmlType === INPUT_TYPES.HIDDEN, [htmlType])
if (isHidden) return null
return (
INPUT_CONTROLLER[type] && (
<Grid item xs={12} md={6} {...grid}>
{createElement(INPUT_CONTROLLER[type], {
control: formContext.control,
cy: dataCy,
dependencies: nameOfDependField,
name: inputName,
type: htmlType === false ? undefined : htmlType,
...fieldProps,
})}
</Grid>
)
)
})
FieldComponent.propTypes = {
id: PropTypes.string,
cy: PropTypes.string,
dependOf: PropTypes.oneOfType([
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]),
}
FieldComponent.displayName = 'FieldComponent'
export default FormWithSchema

View File

@ -24,7 +24,7 @@ import {
import { Translate } from 'client/components/HOC'
import { formatNumberByCurrency } from 'client/models/Helper'
import { Field } from 'client/utils'
import { T, HYPERVISORS } from 'client/constants'
import { T, HYPERVISORS, VmTemplateFeatures } from 'client/constants'
const commonValidation = number()
.positive()
@ -186,5 +186,9 @@ export const DISK_COST = generateCostCapacityInput({
},
})
/** @type {Field[]} List of showback fields */
export const SHOWBACK_FIELDS = [MEMORY_COST, CPU_COST, DISK_COST]
/**
* @param {VmTemplateFeatures} features - Features of the template
* @returns {Field[]} List of showback fields
*/
export const SHOWBACK_FIELDS = (features) =>
[MEMORY_COST, !features?.hide_cpu && CPU_COST, DISK_COST].filter(Boolean)

View File

@ -38,11 +38,12 @@ const Content = ({ isUpdate }) => {
const sections = useMemo(() => {
const resource = RESOURCE_NAMES.VM_TEMPLATE
const dialog = getResourceView(resource)?.dialogs?.create_dialog
const { features, dialogs } = getResourceView(resource)
const dialog = dialogs?.create_dialog
const sectionsAvailable = getSectionsAvailable(dialog, hypervisor)
return (
SECTIONS(hypervisor, isUpdate)
SECTIONS(hypervisor, isUpdate, features)
.filter(
({ id, required }) => required || sectionsAvailable.includes(id)
)
@ -57,8 +58,8 @@ const Content = ({ isUpdate }) => {
<FormWithSchema
key={key}
id={STEP_ID}
className={classes[id]}
cy={`${STEP_ID}-${id}`}
rootProps={{ className: classes[id] }}
{...section}
/>
))}

View File

@ -35,62 +35,64 @@ import {
filterFieldsByHypervisor,
getObjectSchemaFromFields,
} from 'client/utils'
import { T, HYPERVISORS } from 'client/constants'
import { T, HYPERVISORS, VmTemplateFeatures } from 'client/constants'
/**
* @param {HYPERVISORS} [hypervisor] - Template hypervisor
* @param {boolean} [isUpdate] - If `true`, the form is being updated
* @param {VmTemplateFeatures} [features] - Features
* @returns {Section[]} Fields
*/
const SECTIONS = (hypervisor, isUpdate) => [
{
id: 'information',
legend: T.Information,
required: true,
fields: INFORMATION_FIELDS(isUpdate),
},
{
id: 'hypervisor',
legend: T.Hypervisor,
required: true,
fields: [HYPERVISOR_FIELD, VROUTER_FIELD],
},
{
id: 'capacity',
legend: T.Memory,
fields: filterFieldsByHypervisor(MEMORY_FIELDS, hypervisor),
},
{
id: 'capacity',
legend: T.PhysicalCpu,
fields: filterFieldsByHypervisor(CPU_FIELDS, hypervisor),
},
{
id: 'capacity',
legend: T.VirtualCpu,
fields: filterFieldsByHypervisor(VCPU_FIELDS, hypervisor),
},
{
id: 'showback',
legend: T.Cost,
fields: filterFieldsByHypervisor(SHOWBACK_FIELDS, hypervisor),
},
{
id: 'ownership',
legend: T.Ownership,
fields: filterFieldsByHypervisor(OWNERSHIP_FIELDS, hypervisor),
},
{
id: 'vm_group',
legend: T.VMGroup,
fields: filterFieldsByHypervisor(VM_GROUP_FIELDS, hypervisor),
},
{
id: 'vcenter',
legend: T.vCenterDeployment,
fields: filterFieldsByHypervisor(VCENTER_FIELDS, hypervisor),
},
]
const SECTIONS = (hypervisor, isUpdate, features) =>
[
{
id: 'information',
legend: T.Information,
required: true,
fields: INFORMATION_FIELDS(isUpdate),
},
{
id: 'hypervisor',
legend: T.Hypervisor,
required: true,
fields: [HYPERVISOR_FIELD, VROUTER_FIELD],
},
{
id: 'capacity',
legend: T.Memory,
fields: filterFieldsByHypervisor(MEMORY_FIELDS, hypervisor),
},
!features?.hide_cpu && {
id: 'capacity',
legend: T.PhysicalCpu,
fields: filterFieldsByHypervisor(CPU_FIELDS, hypervisor),
},
{
id: 'capacity',
legend: T.VirtualCpu,
fields: filterFieldsByHypervisor(VCPU_FIELDS, hypervisor),
},
{
id: 'showback',
legend: T.Cost,
fields: filterFieldsByHypervisor(SHOWBACK_FIELDS(features), hypervisor),
},
{
id: 'ownership',
legend: T.Ownership,
fields: filterFieldsByHypervisor(OWNERSHIP_FIELDS, hypervisor),
},
{
id: 'vm_group',
legend: T.VMGroup,
fields: filterFieldsByHypervisor(VM_GROUP_FIELDS, hypervisor),
},
{
id: 'vcenter',
legend: T.vCenterDeployment,
fields: filterFieldsByHypervisor(VCENTER_FIELDS, hypervisor),
},
].filter(Boolean)
/**
* @param {HYPERVISORS} [hypervisor] - Template hypervisor

View File

@ -14,6 +14,8 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { NumberSchema } from 'yup'
import { scaleVcpuByCpuFactor } from 'client/models/VirtualMachine'
import { getUserInputParams } from 'client/models/Helper'
import {
Field,
@ -21,7 +23,13 @@ import {
prettyBytes,
isDivisibleBy,
} from 'client/utils'
import { T, HYPERVISORS, USER_INPUT_TYPES, VmTemplate } from 'client/constants'
import {
T,
HYPERVISORS,
USER_INPUT_TYPES,
VmTemplate,
VmTemplateFeatures,
} from 'client/constants'
const { number, numberFloat, range, rangeFloat } = USER_INPUT_TYPES
@ -35,9 +43,13 @@ const valueLabelFormat = (value) => prettyBytes(value, 'MB')
/**
* @param {VmTemplate} [vmTemplate] - VM Template
* @param {VmTemplateFeatures} [features] - Features
* @returns {Field[]} Basic configuration fields
*/
export const FIELDS = (vmTemplate) => {
export const FIELDS = (
vmTemplate,
{ hide_cpu: hideCpu, cpu_factor: cpuFactor } = {}
) => {
const {
HYPERVISOR,
USER_INPUTS = {},
@ -52,18 +64,21 @@ export const FIELDS = (vmTemplate) => {
VCPU: vcpuInput = `O|${number}|| |${VCPU}`,
} = USER_INPUTS
return [
const fields = [
{ name: 'MEMORY', ...getUserInputParams(memoryInput) },
{ name: 'CPU', ...getUserInputParams(cpuInput) },
!hideCpu && { name: 'CPU', ...getUserInputParams(cpuInput) },
{ name: 'VCPU', ...getUserInputParams(vcpuInput) },
].map(({ name, options, ...userInput }) => {
].filter(Boolean)
return fields.map(({ name, options, ...userInput }) => {
const isMemory = name === 'MEMORY'
const isCPU = name === 'CPU'
const isVCenter = HYPERVISOR === HYPERVISORS.vcenter
const divisibleBy4 = isVCenter && isMemory
const isRange = [range, rangeFloat].includes(userInput.type)
// set default type to number
userInput.type ??= name === 'CPU' ? numberFloat : number
userInput.type ??= isCPU ? numberFloat : number
const ensuredOptions = divisibleBy4
? options?.filter((value) => isDivisibleBy(+value, 4))
@ -85,6 +100,12 @@ export const FIELDS = (vmTemplate) => {
schemaUi.fieldProps = { ...schemaUi.fieldProps, step: 4 }
}
if (cpuFactor && isCPU) {
schemaUi.readOnly = true
schemaUi.dependOf = 'VCPU'
schemaUi.watcher = (vcpu) => scaleVcpuByCpuFactor(vcpu, cpuFactor)
}
return { ...TRANSLATES[name], ...schemaUi, grid: { md: 12 } }
})
}

View File

@ -36,11 +36,12 @@ const Content = ({ vmTemplate }) => {
const sections = useMemo(() => {
const hypervisor = vmTemplate?.TEMPLATE?.HYPERVISOR
const resource = RESOURCE_NAMES.VM_TEMPLATE
const dialog = getResourceView(resource)?.dialogs?.instantiate_dialog
const { features, dialogs } = getResourceView(resource)
const dialog = dialogs?.instantiate_dialog
const sectionsAvailable = getSectionsAvailable(dialog, hypervisor)
return SECTIONS(vmTemplate).filter(({ id }) =>
sectionsAvailable.includes(id)
return SECTIONS(vmTemplate, features).filter(
({ id, required }) => required || sectionsAvailable.includes(id)
)
}, [view])
@ -49,8 +50,8 @@ const Content = ({ vmTemplate }) => {
{sections.map(({ id, legend, fields }) => (
<FormWithSchema
key={id}
className={classes[id]}
cy={id}
rootProps={{ className: classes[id] }}
fields={fields}
legend={legend}
id={STEP_ID}

View File

@ -30,13 +30,14 @@ import {
Field,
Section,
} from 'client/utils'
import { T, VmTemplate } from 'client/constants'
import { T, VmTemplate, VmTemplateFeatures } from 'client/constants'
/**
* @param {VmTemplate} [vmTemplate] - VM Template
* @param {VmTemplateFeatures} [features] - Features
* @returns {Section[]} Sections
*/
const SECTIONS = (vmTemplate) => {
const SECTIONS = (vmTemplate, features) => {
const hypervisor = vmTemplate?.TEMPLATE?.HYPERVISOR
return [
@ -48,7 +49,10 @@ const SECTIONS = (vmTemplate) => {
{
id: 'capacity',
legend: T.Capacity,
fields: filterFieldsByHypervisor(CAPACITY_FIELDS(vmTemplate), hypervisor),
fields: filterFieldsByHypervisor(
CAPACITY_FIELDS(vmTemplate, features),
hypervisor
),
},
{
id: 'ownership',
@ -70,17 +74,20 @@ const SECTIONS = (vmTemplate) => {
/**
* @param {VmTemplate} [vmTemplate] - VM Template
* @param {boolean} [hideCpu] - If `true`, the CPU fields is hidden
* @returns {Field[]} Basic configuration fields
*/
const FIELDS = (vmTemplate) =>
SECTIONS(vmTemplate)
const FIELDS = (vmTemplate, hideCpu) =>
SECTIONS(vmTemplate, hideCpu)
.map(({ fields }) => fields)
.flat()
/**
* @param {VmTemplate} [vmTemplate] - VM Template
* @param {boolean} [hideCpu] - If `true`, the CPU fields is hidden
* @returns {BaseSchema} Step schema
*/
const SCHEMA = (vmTemplate) => getObjectSchemaFromFields(FIELDS(vmTemplate))
const SCHEMA = (vmTemplate, hideCpu) =>
getObjectSchemaFromFields(FIELDS(vmTemplate, hideCpu))
export { SECTIONS, FIELDS, SCHEMA }

View File

@ -18,7 +18,7 @@ import * as ACTIONS from 'client/constants/actions'
import { Permissions, LockInfo } from 'client/constants/common'
/**
* @typedef {object} VmTemplate
* @typedef VmTemplate
* @property {string|number} ID - Id
* @property {string} NAME - Name
* @property {string|number} UID - User id
@ -35,6 +35,15 @@ import { Permissions, LockInfo } from 'client/constants/common'
* @property {string} [TEMPLATE.VCENTER_TEMPLATE_REF] - vCenter information
*/
/**
* @typedef VmTemplateFeatures
* @property {boolean} hide_cpu - If `true`, the CPU fields is hidden
* @property {false|number} cpu_factor - Scales CPU by VCPU
* - ``1``: Set it to 1 to tie CPU and vCPU
* - ``{number}``: CPU = cpu_factor * VCPU
* - ``{false}``: False to not scale the CPU
*/
export const VM_TEMPLATE_ACTIONS = {
REFRESH: ACTIONS.REFRESH,
CREATE_DIALOG: 'create_dialog',

View File

@ -330,3 +330,18 @@ export const nicsIncludesTheConnectionType = (vm, type) => {
return getNics(vm).some((nic) => stringToBoolean(nic[ensuredConnection]))
}
/**
* Scales the VCPU value by CPU factor to get the real CPU value.
*
* @param {number} [vcpu] - VCPU value
* @param {number} cpuFactor - Factor CPU
* @returns {number|undefined} Real CPU value
*/
export const scaleVcpuByCpuFactor = (vcpu, cpuFactor) => {
if (!cpuFactor || isNaN(+vcpu) || +vcpu === 0) return
if (+cpuFactor === 1) return vcpu
// round 2 decimals to avoid floating point errors
return Math.round(+vcpu * +cpuFactor * 100) / 100
}

View File

@ -420,47 +420,6 @@ export const cleanEmpty = (variable) =>
? cleanEmptyArray(variable)
: cleanEmptyObject(variable)
/**
* Check if value is in base64.
*
* @param {string} stringToValidate - String to check
* @param {object} options - Options
* @param {boolean} options.exact - Only match and exact string
* @returns {boolean} Returns `true` if string is a base64
*/
export const isBase64 = (stringToValidate, options = {}) => {
if (stringToValidate === '') return false
const { exact = true } = options
const BASE64_REG =
/(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)/g
const EXACT_BASE64_REG =
/(?:^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$)/
const regex = exact ? EXACT_BASE64_REG : BASE64_REG
return regex.test(stringToValidate)
}
/**
* Check if value is divisible by another number.
*
* @param {string|number} number - Value to check
* @param {string|number} divisor - Divisor number
* @returns {boolean} Returns `true` if value is divisible by another
*/
export const isDivisibleBy = (number, divisor) => !(number % divisor)
/**
* Returns factors of a number.
*
* @param {number} value - Number
* @returns {number[]} Returns list of numbers
*/
export const getFactorsOfNumber = (value) =>
[...Array(+value + 1).keys()].filter((idx) => value % idx === 0)
/**
* Returns an array with the separator interspersed between elements of the given array.
*

View File

@ -22,4 +22,5 @@ export * from 'client/utils/rest'
export * from 'client/utils/schema'
export * from 'client/utils/storage'
export * from 'client/utils/string'
export * from 'client/utils/number'
export * from 'client/utils/translation'

View File

@ -0,0 +1,75 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2022, 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. *
* ------------------------------------------------------------------------- */
/**
* Check if value is in base64.
*
* @param {string} stringToValidate - String to check
* @param {object} options - Options
* @param {boolean} options.exact - Only match and exact string
* @returns {boolean} Returns `true` if string is a base64
*/
export const isBase64 = (stringToValidate, options = {}) => {
if (stringToValidate === '') return false
const { exact = true } = options
const BASE64_REG =
/(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)/g
const EXACT_BASE64_REG =
/(?:^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$)/
const regex = exact ? EXACT_BASE64_REG : BASE64_REG
return regex.test(stringToValidate)
}
/**
* Check if value is divisible by another number.
*
* @param {string|number} number - Value to check
* @param {string|number} divisor - Divisor number
* @returns {boolean} Returns `true` if value is divisible by another
*/
export const isDivisibleBy = (number, divisor) => !(number % divisor)
/**
* Returns factors of a number.
*
* @param {number} value - Number
* @returns {number[]} Returns list of numbers
*/
export const getFactorsOfNumber = (value) =>
[...Array(+value + 1).keys()].filter((idx) => value % idx === 0)
/**
* Finds the closest number in list.
*
* @param {number} value - The value to compare
* @param {number} list - List of numbers
* @returns {number} Closest number
*/
export const findClosestValue = (value, list) => {
const closestValue = list.reduce((closest, current) => {
if (closest === null) return current
return Math.abs(current - value) < Math.abs(closest - value)
? current
: closest
}, null)
return closestValue ?? value
}

View File

@ -224,7 +224,7 @@ const getValuesFromArray = (options, separator = SEMICOLON_CHAR) =>
options?.split(separator)
const getOptionsFromList = (options = []) =>
arrayToOptions([...new Set(options)])
arrayToOptions([...new Set(options)], { addEmpty: false })
const parseUserInputValue = (value) => {
if (value === true) {

View File

@ -26,7 +26,7 @@ import {
} from 'yup'
import { T } from 'client/constants'
import { isDivisibleBy, isBase64 } from 'client/utils/helpers'
import { isDivisibleBy, isBase64 } from 'client/utils/number'
const buildMethods = () => {
;[number, string, boolean, object, array, date].forEach((schemaType) => {