1
0
mirror of https://github.com/OpenNebula/one.git synced 2025-02-14 01:57:24 +03:00

F #5422: Implement capacity inputs (#1908)

This commit is contained in:
Sergio Betanzos 2022-04-06 11:21:19 +02:00 committed by GitHub
parent 5d1129910f
commit ec33af3a3c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
37 changed files with 751 additions and 321 deletions

View File

@ -26,7 +26,7 @@ import Image from 'client/components/Image'
import { timeFromMilliseconds } from 'client/models/Helper'
import { isExternalURL } from 'client/utils'
import { VM, LOGO_IMAGES_URL } from 'client/constants'
import { VM, STATIC_FILES_URL } from 'client/constants'
const VmTemplateCard = memo(
/**
@ -48,13 +48,10 @@ const VmTemplateCard = memo(
LOGO = '',
} = template
const [logoSource] = useMemo(() => {
const external = isExternalURL(LOGO)
const cleanLogoAttribute = String(LOGO).split('/').at(-1)
const src = external ? LOGO : `${LOGO_IMAGES_URL}/${cleanLogoAttribute}`
return [src, external]
}, [LOGO])
const logoSource = useMemo(
() => (isExternalURL(LOGO) ? LOGO : `${STATIC_FILES_URL}/${LOGO}`),
[LOGO]
)
const time = timeFromMilliseconds(+REGTIME)

View File

@ -33,6 +33,7 @@ const AutocompleteController = memo(
multiple = false,
values = [],
fieldProps: { separators, ...fieldProps } = {},
readOnly = false,
}) => {
const {
field: { value: renderValue, onBlur, onChange },
@ -78,6 +79,7 @@ const AutocompleteController = memo(
<TextField
label={labelCanBeTranslated(label) ? Tr(label) : label}
inputProps={{ ...inputProps, 'data-cy': cy }}
InputProps={{ readOnly }}
error={Boolean(error)}
helperText={
Boolean(error) && <ErrorHelper label={error?.message} />
@ -115,6 +117,7 @@ AutocompleteController.propTypes = {
multiple: PropTypes.bool,
values: PropTypes.arrayOf(PropTypes.object),
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
}
AutocompleteController.displayName = 'AutocompleteController'

View File

@ -43,6 +43,7 @@ const CheckboxController = memo(
label = '',
tooltip,
fieldProps = {},
readOnly = false,
}) => {
const {
field: { value = false, onChange },
@ -56,6 +57,7 @@ const CheckboxController = memo(
<Checkbox
onChange={(e) => onChange(e.target.checked)}
name={name}
readOnly={readOnly}
checked={Boolean(value)}
color="secondary"
inputProps={{ 'data-cy': cy }}
@ -87,6 +89,7 @@ CheckboxController.propTypes = {
label: PropTypes.any,
tooltip: PropTypes.any,
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
}
CheckboxController.displayName = 'CheckboxController'

View File

@ -54,6 +54,7 @@ ErrorHelper.propTypes = {
label: PropTypes.oneOfType([
PropTypes.string,
PropTypes.node,
PropTypes.array,
PropTypes.shape({
word: PropTypes.string,
values: PropTypes.oneOfType([

View File

@ -49,6 +49,7 @@ const FileController = memo(
validationBeforeTransform,
transform,
fieldProps = {},
readOnly = false,
formContext = {},
}) => {
const { setValue, setError, clearErrors, watch } = formContext
@ -115,12 +116,14 @@ const FileController = memo(
}
return (
<FormControl fullWidth margin="dense">
<FormControl margin="dense">
<HiddenInput
{...inputProps}
ref={ref}
id={cy}
type="file"
readOnly={readOnly}
disabled={readOnly}
onChange={handleChange}
{...fieldProps}
/>
@ -128,6 +131,7 @@ const FileController = memo(
<SubmitButton
color={success ? 'success' : 'secondary'}
component="span"
disabled={readOnly}
data-cy={`${cy}-button`}
isSubmitting={isLoading}
label={success ? <CheckIcon /> : <FileIcon />}
@ -161,6 +165,7 @@ FileController.propTypes = {
),
transform: PropTypes.func,
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
formContext: PropTypes.shape({
setValue: PropTypes.func,
setError: PropTypes.func,

View File

@ -34,6 +34,7 @@ const SelectController = memo(
renderValue,
tooltip,
fieldProps = {},
readOnly = false,
}) => {
const firstValue = values?.[0]?.value ?? ''
const defaultValue = multiple ? [firstValue] : firstValue
@ -50,8 +51,15 @@ const SelectController = memo(
)
useEffect(() => {
if (multiple && !Array.isArray(optionSelected)) {
onChange([optionSelected])
if (!optionSelected && !optionSelected.length) return
if (multiple) {
const exists = values.some((v) => optionSelected.includes(v.value))
!exists && onChange([firstValue])
} else {
const exists = values.some((v) => `${v.value}` === `${optionSelected}`)
!exists && onChange(firstValue)
}
}, [multiple])
@ -82,6 +90,7 @@ const SelectController = memo(
label={labelCanBeTranslated(label) ? Tr(label) : label}
InputLabelProps={{ shrink: needShrink }}
InputProps={{
readOnly,
startAdornment:
(optionSelected && renderValue?.(optionSelected)) ||
(tooltip && <Tooltip title={tooltip} position="start" />),
@ -118,6 +127,7 @@ SelectController.propTypes = {
values: PropTypes.arrayOf(PropTypes.object).isRequired,
renderValue: PropTypes.func,
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
}
SelectController.displayName = 'SelectController'

View File

@ -31,6 +31,7 @@ const SliderController = memo(
label = '',
tooltip,
fieldProps = {},
readOnly = false,
}) => {
const {
field: { value, onChange, ...inputProps },
@ -48,6 +49,7 @@ const SliderController = memo(
value={typeof value === 'number' ? value : 0}
aria-labelledby={sliderId}
valueLabelDisplay="auto"
disabled={readOnly}
data-cy={sliderId}
onChange={(_, val) => onChange(val)}
{...fieldProps}
@ -60,6 +62,7 @@ const SliderController = memo(
error={Boolean(error)}
label={labelCanBeTranslated(label) ? Tr(label) : label}
InputProps={{
readOnly,
endAdornment: tooltip && <Tooltip title={tooltip} />,
}}
inputProps={{
@ -98,6 +101,7 @@ SliderController.propTypes = {
label: PropTypes.any,
tooltip: PropTypes.any,
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
}
SliderController.displayName = 'SliderController'

View File

@ -43,6 +43,7 @@ const SwitchController = memo(
label = '',
tooltip,
fieldProps = {},
readOnly = false,
}) => {
const {
field: { value = false, onChange },
@ -50,10 +51,11 @@ const SwitchController = memo(
} = useController({ name, control })
return (
<FormControl fullWidth error={Boolean(error)} margin="dense">
<FormControl error={Boolean(error)} margin="dense">
<FormControlLabel
control={
<Switch
readOnly={readOnly}
onChange={(e) => onChange(e.target.checked)}
name={name}
checked={Boolean(value)}
@ -87,6 +89,7 @@ SwitchController.propTypes = {
label: PropTypes.any,
tooltip: PropTypes.any,
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
}
SwitchController.displayName = 'SwitchController'

View File

@ -43,6 +43,7 @@ const TableController = memo(
singleSelect = true,
getRowId = defaultGetRowId,
formContext = {},
readOnly = false,
fieldProps: { initialState, ...fieldProps } = {},
}) => {
const { clearErrors } = formContext
@ -74,7 +75,10 @@ const TableController = memo(
onlyGlobalSelectedRows
getRowId={getRowId}
initialState={{ ...initialState, selectedRowIds: initialRows }}
disableRowSelect={readOnly}
onSelectedRowsChange={(rows) => {
if (readOnly) return
const rowValues = rows?.map(({ original }) => getRowId(original))
onChange(singleSelect ? rowValues?.[0] : rowValues)
@ -102,6 +106,7 @@ TableController.propTypes = {
label: PropTypes.any,
tooltip: PropTypes.any,
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
formContext: PropTypes.shape({
setValue: PropTypes.func,
setError: PropTypes.func,

View File

@ -35,6 +35,7 @@ const TextController = memo(
watcher,
dependencies,
fieldProps = {},
readOnly = false,
}) => {
const watch =
dependencies &&
@ -68,9 +69,17 @@ const TextController = memo(
type={type}
label={labelCanBeTranslated(label) ? Tr(label) : label}
InputProps={{
readOnly,
endAdornment: tooltip && <Tooltip title={tooltip} />,
}}
inputProps={{ 'data-cy': cy }}
inputProps={{
'data-cy': cy,
...(type === 'number' && {
min: fieldProps.min,
max: fieldProps.max,
step: fieldProps.step,
}),
}}
error={Boolean(error)}
helperText={Boolean(error) && <ErrorHelper label={error?.message} />}
FormHelperTextProps={{ 'data-cy': `${cy}-error` }}
@ -99,6 +108,7 @@ TextController.propTypes = {
PropTypes.arrayOf(PropTypes.string),
]),
fieldProps: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]),
readOnly: PropTypes.bool,
}
TextController.displayName = 'TextController'

View File

@ -33,7 +33,7 @@ const TimeController = memo(
label = '',
tooltip,
fieldProps: { defaultValue, ...fieldProps } = {},
...props
readOnly = false,
}) => {
const {
field: { value, ...controllerProps },
@ -50,6 +50,7 @@ const TimeController = memo(
clearText={<Translate word={T.Clear} />}
todayText={<Translate word={T.Today} />}
InputProps={{
readOnly,
autoComplete: 'off',
startAdornment: tooltip && <Tooltip title={tooltip} />,
}}
@ -80,6 +81,7 @@ TimeController.propTypes = {
label: PropTypes.any,
tooltip: PropTypes.any,
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
}
TimeController.displayName = 'TimeController'

View File

@ -48,6 +48,7 @@ const ToggleController = memo(
values = [],
tooltip,
fieldProps = {},
readOnly = false,
}) => {
const defaultValue = multiple ? [values?.[0]?.value] : values?.[0]?.value
@ -72,17 +73,17 @@ const ToggleController = memo(
</Label>
)}
<ToggleButtonGroup
onChange={(_, newValues) => onChange(newValues)}
fullWidth
ref={ref}
id={cy}
onChange={(_, newValues) => !readOnly && onChange(newValues)}
value={optionSelected}
fullWidth
exclusive={!multiple}
data-cy={cy}
{...fieldProps}
>
{values?.map(({ text, value = '' }) => (
<ToggleButton key={`${name}-${value}`} value={value}>
<ToggleButton key={`${name}-${value}`} value={value} sx={{ p: 1 }}>
{text}
</ToggleButton>
))}
@ -111,6 +112,7 @@ ToggleController.propTypes = {
values: PropTypes.arrayOf(PropTypes.object).isRequired,
renderValue: PropTypes.func,
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
}
ToggleController.displayName = 'ToggleController'

View File

@ -15,7 +15,7 @@
* ------------------------------------------------------------------------- */
import { object, array, string, boolean, number, ref, ObjectSchema } from 'yup'
import { userInputsToObject } from 'client/models/Helper'
import { userInputsToObject, userInputsToArray } from 'client/models/Helper'
import {
UserInputType,
T,
@ -40,7 +40,7 @@ const {
boolean: uiBoolean,
} = USER_INPUT_TYPES
const { array: __, ...userInputTypes } = USER_INPUT_TYPES
const { array: _array, fixed: _fixed, ...userInputTypes } = USER_INPUT_TYPES
/** @type {UserInputType[]} User inputs types */
const valuesOfUITypes = Object.values(userInputTypes)
@ -90,7 +90,7 @@ const DESCRIPTION = {
const OPTIONS = {
name: 'options',
label: T.Options,
tooltip: 'Press ENTER key to add a value',
tooltip: [T.PressKeysToAddAValue, ['ENTER']],
dependOf: TYPE.name,
type: INPUT_TYPES.AUTOCOMPLETE,
multiple: true,
@ -100,7 +100,7 @@ const OPTIONS = {
.default(() => [])
.when(TYPE.name, (type, schema) =>
[uiList, uiListMultiple].includes(type)
? schema.required()
? schema.required().min(1)
: schema.strip().notRequired()
),
fieldProps: {
@ -211,10 +211,22 @@ export const USER_INPUT_SCHEMA = getObjectSchemaFromFields(USER_INPUT_FIELDS)
export const USER_INPUTS_SCHEMA = object({
USER_INPUTS: array(USER_INPUT_SCHEMA)
.ensure()
.afterSubmit((userInputs) => userInputsToObject(userInputs)),
.afterSubmit((userInputs, { context }) => {
const capacityInputs = userInputsToArray(context?.general?.MODIFICATION, {
filterCapacityInputs: false,
}).map(({ name, ...userInput }) => ({
name,
...userInput,
// set default value from MEMORY, CPU and VCPU fields
default: context.general?.[name],
...(['MEMORY', 'CPU'].includes(name) && { mandatory: true }),
}))
return userInputsToObject([...capacityInputs, ...userInputs])
}),
INPUTS_ORDER: string()
.trim()
.afterSubmit((_, { context }) => {
.afterSubmit((_inputsOrder_, { context }) => {
const userInputs = context?.extra?.USER_INPUTS
return userInputs?.map(({ name }) => String(name).toUpperCase()).join(',')

View File

@ -13,105 +13,95 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { number, boolean } from 'yup'
import { number } from 'yup'
import { T, INPUT_TYPES, HYPERVISORS } from 'client/constants'
import {
generateModificationInputs,
generateHotResizeInputs,
generateCapacityInput,
} from 'client/components/Forms/VmTemplate/CreateForm/Steps/General/capacityUtils'
import { Field } from 'client/utils'
import { T, HYPERVISORS } from 'client/constants'
const commonValidation = number()
.positive()
.default(() => undefined)
// --------------------------------------------------------
// MEMORY fields
// --------------------------------------------------------
/** @type {Field} Memory field */
export const MEMORY = {
export const MEMORY = generateCapacityInput({
name: 'MEMORY',
label: T.Memory,
tooltip: T.MemoryConcept,
type: INPUT_TYPES.TEXT,
htmlType: 'number',
validation: commonValidation
.required()
.when('HYPERVISOR', (hypervisor, schema) =>
hypervisor === HYPERVISORS.vcenter ? schema.isDivisibleBy(4) : schema
),
grid: { md: 12 },
}
})
/** @type {Field} Hot reloading on memory field */
export const ENABLE_HR_MEMORY = {
name: 'HOT_RESIZE.MEMORY_HOT_ADD_ENABLED',
label: T.EnableHotResize,
type: INPUT_TYPES.SWITCH,
validation: boolean().yesOrNo(),
grid: { xs: 4, md: 6 },
}
/** @type {Field[]} Hot resize on memory field */
export const HR_MEMORY_FIELDS = generateHotResizeInputs(
{ fieldName: MEMORY.name },
{
name: `${MEMORY.name}_MAX`,
label: T.MaxMemory,
tooltip: T.MaxMemoryConcept,
}
)
/** @type {Field} Maximum memory field */
export const MEMORY_MAX = {
name: 'MEMORY_MAX',
label: T.MaxMemory,
dependOf: ENABLE_HR_MEMORY.name,
type: INPUT_TYPES.TEXT,
htmlType: (enabledHr) => (enabledHr ? 'number' : INPUT_TYPES.HIDDEN),
validation: commonValidation.when(
ENABLE_HR_MEMORY.name,
(enabledHr, schema) =>
enabledHr ? schema.required() : schema.notRequired()
),
grid: { xs: 8, md: 6 },
}
/** @type {Field[]} Modification inputs on memory field */
export const MOD_MEMORY_FIELDS = generateModificationInputs(MEMORY.name)
/** @type {Field[]} List of memory fields */
export const MEMORY_FIELDS = [MEMORY, ...HR_MEMORY_FIELDS, ...MOD_MEMORY_FIELDS]
// --------------------------------------------------------
// CPU fields
// --------------------------------------------------------
/** @type {Field} Physical CPU field */
export const PHYSICAL_CPU = {
export const PHYSICAL_CPU = generateCapacityInput({
name: 'CPU',
label: T.PhysicalCpu,
tooltip: T.CpuConcept,
type: INPUT_TYPES.TEXT,
htmlType: 'number',
validation: commonValidation.required(),
grid: { md: 12 },
}
})
/** @type {Field[]} Modification inputs on CPU field */
export const MOD_CPU_FIELDS = generateModificationInputs(PHYSICAL_CPU.name)
/** @type {Field[]} List of CPU fields */
export const CPU_FIELDS = [PHYSICAL_CPU, ...MOD_CPU_FIELDS]
// --------------------------------------------------------
// Virtual CPU fields
// --------------------------------------------------------
/** @type {Field} Virtual CPU field */
export const VIRTUAL_CPU = {
export const VIRTUAL_CPU = generateCapacityInput({
name: 'VCPU',
label: T.VirtualCpu,
tooltip: T.VirtualCpuConcept,
type: INPUT_TYPES.TEXT,
htmlType: 'number',
validation: commonValidation,
grid: { md: 12 },
}
grid: { md: 3 },
})
/** @type {Field} Hot reloading on virtual CPU field */
export const ENABLE_HR_VCPU = {
name: 'HOT_RESIZE.CPU_HOT_ADD_ENABLED',
label: T.EnableHotResize,
type: INPUT_TYPES.SWITCH,
validation: boolean().yesOrNo(),
grid: { xs: 4, md: 6 },
}
/** @type {Field[]} Hot resize on memory field */
export const HR_CPU_FIELDS = generateHotResizeInputs(
{ name: PHYSICAL_CPU.name },
{
name: `${VIRTUAL_CPU.name}_MAX`,
label: T.MaxVirtualCpu,
tooltip: T.MaxVirtualCpuConcept,
}
)
/** @type {Field} Maximum virtual CPU field */
export const VCPU_MAX = {
name: 'VCPU_MAX',
label: T.MaxVirtualCpu,
dependOf: ENABLE_HR_VCPU.name,
type: INPUT_TYPES.TEXT,
htmlType: (enabledHr) => (enabledHr ? 'number' : INPUT_TYPES.HIDDEN),
validation: commonValidation.when(ENABLE_HR_VCPU.name, (enabledHr, schema) =>
enabledHr ? schema.required() : schema.notRequired()
),
grid: { xs: 8, md: 6 },
}
/** @type {Field[]} Modification inputs on Virtual CPU field */
export const MOD_VCPU_FIELDS = generateModificationInputs(VIRTUAL_CPU.name)
/** @type {Field[]} List of capacity fields */
export const FIELDS = [
MEMORY,
ENABLE_HR_MEMORY,
MEMORY_MAX,
PHYSICAL_CPU,
VIRTUAL_CPU,
ENABLE_HR_VCPU,
VCPU_MAX,
]
/** @type {Field[]} List of Virtual CPU fields */
export const VCPU_FIELDS = [VIRTUAL_CPU, ...HR_CPU_FIELDS, ...MOD_VCPU_FIELDS]

View File

@ -0,0 +1,266 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import {
boolean,
number,
lazy,
string,
array,
ref,
BaseSchema,
NumberSchema,
} from 'yup'
import { getUserInputParams } from 'client/models/Helper'
import { Field, arrayToOptions, sentenceCase } from 'client/utils'
import { T, INPUT_TYPES, HYPERVISORS, USER_INPUT_TYPES } from 'client/constants'
const { fixed, range, rangeFloat, list } = USER_INPUT_TYPES
const MODIFICATION_TRANSLATES = {
MEMORY: {
label: T.MemoryModification,
tooltip: T.AllowUsersToModifyMemory,
},
CPU: {
label: T.CpuModification,
tooltip: T.AllowUsersToModifyCpu,
},
VCPU: {
label: T.VirtualCpuModification,
tooltip: T.AllowUsersToModifyVirtualCpu,
},
}
const isRangeType = (type) => [range, rangeFloat].includes(type)
/** @typedef {'MEMORY'|'CPU'|'VCPU'} CapacityFieldName */
/** @typedef {{ type: string, min: string, max: string, options: string }} ModificationIds */
/**
* @param {CapacityFieldName} fieldName - Capacity field name
* @returns {ModificationIds} - Modification ids
*/
const getModificationIdsByFieldName = (fieldName) => ({
type: `MODIFICATION.${fieldName}.type`,
min: `MODIFICATION.${fieldName}.min`,
max: `MODIFICATION.${fieldName}.max`,
options: `MODIFICATION.${fieldName}.options`,
})
/**
* @param {CapacityFieldName} fieldName - Capacity field name
* @param {ModificationIds} [modificationIds] - Modification ids
* @returns {Field} - Type field for capacity modification
*/
const modificationTypeInput = (fieldName, { type: typeId }) => ({
name: typeId,
...MODIFICATION_TRANSLATES[fieldName],
type: INPUT_TYPES.SELECT,
values: arrayToOptions([fixed, range, list], {
addEmpty: 'Any value',
getValue: (type) =>
// allow float numbers on CPU and VCPU
['CPU', 'VCPU'].includes(fieldName) && type === range ? rangeFloat : type,
getText: (type) => sentenceCase(type),
}),
validation: lazy((_, { context }) =>
string().default(() => {
const capacityUserInput = context.extra?.USER_INPUTS?.[fieldName]
const { type } = getUserInputParams(capacityUserInput) ?? {}
return type
})
),
grid: { md: 3 },
})
/**
* @param {CapacityFieldName} fieldName - Capacity field name
* @param {ModificationIds} [modificationIds] - Modification ids
* @returns {Field[]} - Range fields for capacity modification
*/
const modificationRangeInputs = (fieldName, { type, min, max }) => {
// Common attributes for range inputs (min, max)
const commonRangeAttributes = {
type: INPUT_TYPES.TEXT,
dependOf: type,
htmlType: (modificationType) =>
isRangeType(modificationType) ? 'number' : INPUT_TYPES.HIDDEN,
grid: { md: 3 },
}
/**
* Generates validation for range inputs.
*
* @param {object} config - Validation config
* @param {NumberSchema} config.thenFn - Schema validation when the modification type is range
* @param {'min'|'max'} config.rangeParam - Range parameter: min or max
* @returns {BaseSchema} Schema validation for range inputs
*/
const commonRangeValidation = ({ thenFn, rangeParam }) =>
lazy((_, { context }) =>
number()
.positive()
.transform((value) => (!isNaN(value) ? value : null))
.when('HYPERVISOR', (hypervisor, schema) =>
hypervisor === HYPERVISORS.vcenter ? schema.isDivisibleBy(4) : schema
)
.when(`$general.${type}`, {
is: isRangeType,
then: thenFn,
otherwise: (schema) => schema.optional().nullable(),
})
.default(() => {
const capacityUserInput = context.extra?.USER_INPUTS?.[fieldName]
const params = getUserInputParams(capacityUserInput)
return params[rangeParam]
})
)
return [
{
name: min,
label: T.Min,
...commonRangeAttributes,
validation: commonRangeValidation({
thenFn: (schema) => schema.required().lessThan(ref(`$general.${max}`)),
rangeParam: 'min',
}),
},
{
name: max,
label: T.Max,
...commonRangeAttributes,
validation: commonRangeValidation({
thenFn: (schema) => schema.required().moreThan(ref(`$general.${min}`)),
rangeParam: 'max',
}),
},
]
}
/**
* @param {CapacityFieldName} fieldName - Capacity field name
* @param {ModificationIds} [modificationIds] - Modification ids
* @returns {Field} - Options field for capacity modification
*/
const modificationOptionsInput = (fieldName, { type, options: optionsId }) => ({
name: optionsId,
label: T.Options,
tooltip: [T.PressKeysToAddAValue, ['ENTER']],
type: INPUT_TYPES.AUTOCOMPLETE,
multiple: true,
dependOf: type,
htmlType: (modificationType) =>
modificationType !== list && INPUT_TYPES.HIDDEN,
validation: lazy((_, { context }) =>
array(number().positive())
.when(`$general.${type}`, {
is: (modificationType) => modificationType === list,
then: (schema) => schema.required().min(1),
otherwise: (schema) => schema.nullable(),
})
.default(() => {
const capacityUserInput = context.extra?.USER_INPUTS?.[fieldName]
const { options = [] } = getUserInputParams(capacityUserInput) ?? {}
return options
})
),
fieldProps: { freeSolo: true },
grid: fieldName === 'CPU' ? { md: 6 } : { md: 9 },
})
/**
* @param {CapacityFieldName} fieldName - Capacity field name
* @returns {Field[]} - Fields for capacity modification
*/
export const generateModificationInputs = (fieldName) => {
const modificationIds = getModificationIdsByFieldName(fieldName)
return [
modificationTypeInput(fieldName, modificationIds),
modificationOptionsInput(fieldName, modificationIds),
...modificationRangeInputs(fieldName, modificationIds),
]
}
/**
* @param {Field} hrField - Hot resize field
* @param {CapacityFieldName} hrField.fieldName - Capacity field name
* @param {Field} maxField - Max field
* @returns {Field[]} Hot resize fields
*/
export const generateHotResizeInputs = (
{ name: hrFieldName, ...hrField },
maxField
) => [
{
...hrField,
name: `HOT_RESIZE.${hrFieldName}_HOT_ADD_ENABLED`,
dependOf: `HOT_RESIZE.${hrFieldName}_HOT_ADD_ENABLED`,
label: T.EnableHotResize,
type: INPUT_TYPES.SWITCH,
validation: boolean().yesOrNo(),
grid: (enabledHr) =>
enabledHr ? { xs: 7, sm: 5, md: 3 } : { xs: 12, sm: 9, md: 9 },
},
{
...maxField,
dependOf: `HOT_RESIZE.${hrFieldName}_HOT_ADD_ENABLED`,
type: INPUT_TYPES.TEXT,
htmlType: (enabledHr) => (enabledHr ? 'number' : INPUT_TYPES.HIDDEN),
validation: number()
.positive()
.default(() => undefined)
.when(`HOT_RESIZE.${hrFieldName}_HOT_ADD_ENABLED`, (enabledHr, schema) =>
enabledHr ? schema.required() : schema.notRequired()
),
grid: { xs: 5, sm: 7, md: 6 },
},
]
/**
* @param {Field} config - Configuration
* @param {CapacityFieldName} config.name - Capacity field name
* @param {BaseSchema} config.validation - Validation schema
* @returns {Field} - Field with validation modification conditions
*/
export const generateCapacityInput = ({ validation, ...field }) => ({
...field,
type: INPUT_TYPES.TEXT,
htmlType: 'number',
validation: validation
.when(`$general.MODIFICATION.${field.name}.type`, {
is: (modificationType) => modificationType === range,
then: (schema) =>
schema
.max(ref(`$general.MODIFICATION.${field.name}.max`))
.min(ref(`$general.MODIFICATION.${field.name}.min`)),
otherwise: (schema) => schema,
})
.when(
[
`$general.MODIFICATION.${field.name}.type`,
`$general.MODIFICATION.${field.name}.options`,
],
(modificationType, options = [], schema) =>
modificationType === list ? schema.oneOf(options) : schema
),
grid: { md: 3 },
})

View File

@ -13,7 +13,6 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useMemo } from 'react'
import PropTypes from 'prop-types'
import { useWatch } from 'react-hook-form'
@ -22,13 +21,13 @@ import { useViews } from 'client/features/Auth'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import useStyles from 'client/components/Forms/VmTemplate/CreateForm/Steps/General/styles'
import { HYPERVISOR_FIELD } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General/informationSchema'
import {
SCHEMA,
SECTIONS,
} from 'client/components/Forms/VmTemplate/CreateForm/Steps/General/schema'
import { getActionsAvailable as getSectionsAvailable } from 'client/models/Helper'
import { T, RESOURCE_NAMES } from 'client/constants'
import { generateKey } from 'client/utils'
import { T, RESOURCE_NAMES, VmTemplate } from 'client/constants'
export const STEP_ID = 'general'
@ -42,22 +41,21 @@ const Content = ({ isUpdate }) => {
const dialog = getResourceView(resource)?.dialogs?.create_dialog
const sectionsAvailable = getSectionsAvailable(dialog, hypervisor)
return SECTIONS(hypervisor, isUpdate).filter(
({ id, required }) => required || sectionsAvailable.includes(id)
return (
SECTIONS(hypervisor, isUpdate)
.filter(
({ id, required }) => required || sectionsAvailable.includes(id)
)
// unique keys to avoid duplicates
.map((section) => ({ key: generateKey(), ...section }))
)
}, [view, hypervisor])
return (
<div className={classes.root}>
<FormWithSchema
cy={`${STEP_ID}-hypervisor`}
fields={[HYPERVISOR_FIELD]}
legend={T.Hypervisor}
id={STEP_ID}
/>
{sections.map(({ id, ...section }) => (
{sections.map(({ key, id, ...section }) => (
<FormWithSchema
key={id}
key={key}
id={STEP_ID}
className={classes[id]}
cy={`${STEP_ID}-${id}`}
@ -68,9 +66,15 @@ const Content = ({ isUpdate }) => {
)
}
const General = (initialValues) => {
const isUpdate = initialValues?.NAME
const initialHypervisor = initialValues?.TEMPLATE?.HYPERVISOR
/**
* General configuration about VM Template.
*
* @param {VmTemplate} vmTemplate - VM Template
* @returns {object} General configuration step
*/
const General = (vmTemplate) => {
const isUpdate = vmTemplate?.NAME
const initialHypervisor = vmTemplate?.TEMPLATE?.HYPERVISOR
return {
id: STEP_ID,

View File

@ -16,7 +16,7 @@
import { string } from 'yup'
import Image from 'client/components/Image'
import { T, LOGO_IMAGES_URL, INPUT_TYPES, HYPERVISORS } from 'client/constants'
import { T, STATIC_FILES_URL, INPUT_TYPES, HYPERVISORS } from 'client/constants'
import { Field, arrayToOptions } from 'client/utils'
/**
@ -31,7 +31,7 @@ export const NAME = (isUpdate) => ({
.trim()
.required()
.default(() => undefined),
grid: { sm: 6 },
grid: { md: 12 },
...(isUpdate && { fieldProps: { disabled: true } }),
})
@ -40,10 +40,12 @@ export const DESCRIPTION = {
name: 'DESCRIPTION',
label: T.Description,
type: INPUT_TYPES.TEXT,
multiline: true,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { md: 12 },
}
/** @type {Field} Hypervisor field */
@ -68,40 +70,38 @@ export const LOGO = {
type: INPUT_TYPES.SELECT,
values: [
{ text: '-', value: '' },
// client/assets/images/logos
{ text: 'Alpine Linux', value: 'alpine.png' },
{ text: 'ALT', value: 'alt.png' },
{ text: 'Arch', value: 'arch.png' },
{ text: 'CentOS', value: 'centos.png' },
{ text: 'Debian', value: 'debian.png' },
{ text: 'Devuan', value: 'devuan.png' },
{ text: 'Fedora', value: 'fedora.png' },
{ text: 'FreeBSD', value: 'freebsd.png' },
{ text: 'HardenedBSD', value: 'hardenedbsd.png' },
{ text: 'Knoppix', value: 'knoppix.png' },
{ text: 'Linux', value: 'linux.png' },
{ text: 'Oracle', value: 'oracle.png' },
{ text: 'RedHat', value: 'redhat.png' },
{ text: 'Suse', value: 'suse.png' },
{ text: 'Ubuntu', value: 'ubuntu.png' },
{ text: 'Windows xp', value: 'windowsxp.png' },
{ text: 'Windows 10', value: 'windows8.png' },
{ text: 'Alpine Linux', value: 'images/logos/alpine.png' },
{ text: 'ALT', value: 'images/logos/alt.png' },
{ text: 'Arch', value: 'images/logos/arch.png' },
{ text: 'CentOS', value: 'images/logos/centos.png' },
{ text: 'Debian', value: 'images/logos/debian.png' },
{ text: 'Devuan', value: 'images/logos/devuan.png' },
{ text: 'Fedora', value: 'images/logos/fedora.png' },
{ text: 'FreeBSD', value: 'images/logos/freebsd.png' },
{ text: 'HardenedBSD', value: 'images/logos/hardenedbsd.png' },
{ text: 'Knoppix', value: 'images/logos/knoppix.png' },
{ text: 'Linux', value: 'images/logos/linux.png' },
{ text: 'Oracle', value: 'images/logos/oracle.png' },
{ text: 'RedHat', value: 'images/logos/redhat.png' },
{ text: 'Suse', value: 'images/logos/suse.png' },
{ text: 'Ubuntu', value: 'images/logos/ubuntu.png' },
{ text: 'Windows xp', value: 'images/logos/windowsxp.png' },
{ text: 'Windows 10', value: 'images/logos/windows8.png' },
],
renderValue: (value) => (
<Image
alt="logo"
imgProps={{
height: 25,
width: 25,
style: { marginRight: 10 },
}}
src={`${LOGO_IMAGES_URL}/${value}`}
imgProps={{ height: 25, width: 25, style: { marginRight: 10 } }}
// expected url for Ruby Sunstone compatibility
// => client/assets/images/logos/{value}.png
src={`${STATIC_FILES_URL}/${value}`}
/>
),
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { md: 12 },
}
/**

View File

@ -29,6 +29,7 @@ export const UID_FIELD = {
const { data: users = [] } = useGetUsersQuery()
return arrayToOptions(users, {
addEmpty: false,
getText: ({ ID, NAME }) => `#${ID} ${NAME}`,
getValue: ({ ID }) => ID,
sorter: OPTION_SORTERS.numeric,
@ -50,6 +51,7 @@ export const GID_FIELD = {
const { data: groups = [] } = useGetGroupsQuery()
return arrayToOptions(groups, {
addEmpty: false,
getText: ({ ID, NAME }) => `#${ID} ${NAME}`,
getValue: ({ ID }) => ID,
sorter: OPTION_SORTERS.numeric,

View File

@ -19,7 +19,7 @@ import {
FIELDS as INFORMATION_FIELDS,
HYPERVISOR_FIELD,
} from './informationSchema'
import { FIELDS as CAPACITY_FIELDS } from './capacitySchema'
import { MEMORY_FIELDS, CPU_FIELDS, VCPU_FIELDS } from './capacitySchema'
import { FIELDS as VM_GROUP_FIELDS } from './vmGroupSchema'
import { FIELDS as OWNERSHIP_FIELDS } from './ownershipSchema'
import { FIELDS as VCENTER_FIELDS } from './vcenterSchema'
@ -41,12 +41,28 @@ const SECTIONS = (hypervisor, isUpdate) => [
id: 'information',
legend: T.Information,
required: true,
fields: filterFieldsByHypervisor(INFORMATION_FIELDS(isUpdate), hypervisor),
fields: INFORMATION_FIELDS(isUpdate),
},
{
id: 'hypervisor',
legend: T.Hypervisor,
required: true,
fields: [HYPERVISOR_FIELD],
},
{
id: 'capacity',
legend: T.Capacity,
fields: filterFieldsByHypervisor(CAPACITY_FIELDS, hypervisor),
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: 'ownership',
@ -70,11 +86,10 @@ const SECTIONS = (hypervisor, isUpdate) => [
* @returns {BaseSchema} Step schema
*/
const SCHEMA = (hypervisor) =>
getObjectSchemaFromFields([
HYPERVISOR_FIELD,
...SECTIONS(hypervisor)
getObjectSchemaFromFields(
SECTIONS(hypervisor)
.map(({ fields }) => fields)
.flat(),
])
.flat()
)
export { SECTIONS, SCHEMA }

View File

@ -20,11 +20,11 @@ export default makeStyles((theme) => ({
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: theme.spacing(1),
[theme.breakpoints.down('lg')]: {
[theme.breakpoints.down('md')]: {
gridTemplateColumns: '1fr',
},
},
information: {
capacity: {
gridColumn: '1 / -1',
},
}))

View File

@ -28,6 +28,7 @@ export const VM_GROUP_FIELD = {
const { data: vmGroups = [] } = useGetVMGroupsQuery()
return arrayToOptions(vmGroups, {
addEmpty: false,
getText: ({ ID, NAME }) => `#${ID} ${NAME}`,
getValue: ({ ID }) => ID,
sorter: OPTION_SORTERS.numeric,
@ -58,7 +59,7 @@ export const ROLE_FIELD = {
)
?.flat()
return arrayToOptions(roles)
return arrayToOptions(roles, { addEmpty: false })
},
grid: { md: 12 },
validation: string()

View File

@ -23,14 +23,13 @@ import CustomVariables, {
STEP_ID as CUSTOM_ID,
} from 'client/components/Forms/VmTemplate/CreateForm/Steps/CustomVariables'
import { jsonToXml, userInputsToArray } from 'client/models/Helper'
import { createSteps, isBase64 } from 'client/utils'
import { createSteps, isBase64, encodeBase64 } from 'client/utils'
const Steps = createSteps([General, ExtraConfiguration, CustomVariables], {
transformInitialValue: (vmTemplate, schema) => {
const userInputs = userInputsToArray(
vmTemplate?.TEMPLATE?.USER_INPUTS,
vmTemplate?.TEMPLATE?.INPUTS_ORDER
)
const userInputs = userInputsToArray(vmTemplate?.TEMPLATE?.USER_INPUTS, {
order: vmTemplate?.TEMPLATE?.INPUTS_ORDER,
})
const knownTemplate = schema.cast(
{
@ -56,7 +55,7 @@ const Steps = createSteps([General, ExtraConfiguration, CustomVariables], {
},
transformBeforeSubmit: (formData) => {
const {
[GENERAL_ID]: general = {},
[GENERAL_ID]: { MODIFICATION: _, ...general } = {},
[CUSTOM_ID]: customVariables = {},
[EXTRA_ID]: {
CONTEXT: { START_SCRIPT, ENCODE_START_SCRIPT, ...restOfContext },
@ -70,7 +69,7 @@ const Steps = createSteps([General, ExtraConfiguration, CustomVariables], {
// transform start script to base64 if needed
[ENCODE_START_SCRIPT ? 'START_SCRIPT_BASE64' : 'START_SCRIPT']:
ENCODE_START_SCRIPT && !isBase64(START_SCRIPT)
? btoa(unescape(encodeURIComponent(START_SCRIPT)))
? encodeBase64(START_SCRIPT)
: START_SCRIPT,
}
const topology = ENABLE_NUMA ? { TOPOLOGY: restOfTopology } : {}

View File

@ -13,54 +13,73 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { number } from 'yup'
import { NumberSchema } from 'yup'
import { getUserInputParams } from 'client/models/Helper'
import {
Field,
schemaUserInput,
prettyBytes,
isDivisibleBy,
} from 'client/utils'
import { T, HYPERVISORS, VmTemplate } from 'client/constants'
import { Field } from 'client/utils'
import { T, INPUT_TYPES, HYPERVISORS } from 'client/constants'
const commonValidation = number()
.positive()
.default(() => undefined)
/** @type {Field} Memory field */
const MEMORY = (hypervisor) => {
let validation = commonValidation.required()
if (hypervisor === HYPERVISORS.vcenter) {
validation = validation.isDivisibleBy(4)
}
return {
name: 'MEMORY',
label: T.Memory,
tooltip: T.MemoryConcept,
type: INPUT_TYPES.TEXT,
htmlType: 'number',
validation,
grid: { md: 12 },
}
const TRANSLATES = {
MEMORY: { name: 'MEMORY', label: T.Memory, tooltip: T.MemoryConcept },
CPU: { name: 'CPU', label: T.PhysicalCpu, tooltip: T.CpuConcept },
VCPU: { name: 'VCPU', label: T.VirtualCpu, tooltip: T.VirtualCpuConcept },
}
/** @type {Field} Physical CPU field */
const PHYSICAL_CPU = {
name: 'CPU',
label: T.PhysicalCpu,
tooltip: T.PhysicalCpuConcept,
type: INPUT_TYPES.TEXT,
htmlType: 'number',
validation: commonValidation.required(),
grid: { md: 12 },
}
const valueLabelFormat = (value) => prettyBytes(value, 'MB')
/** @type {Field} Virtual CPU field */
const VIRTUAL_CPU = {
name: 'VCPU',
label: T.VirtualCpu,
tooltip: T.VirtualCpuConcept,
type: INPUT_TYPES.TEXT,
htmlType: 'number',
validation: commonValidation.notRequired(),
grid: { md: 12 },
}
/**
* @param {VmTemplate} [vmTemplate] - VM Template
* @returns {Field[]} Basic configuration fields
*/
export const FIELDS = (vmTemplate) => {
const {
HYPERVISOR,
USER_INPUTS = {},
MEMORY = '',
CPU = '',
VCPU = '',
} = vmTemplate?.TEMPLATE || {}
export const FIELDS = [MEMORY, PHYSICAL_CPU, VIRTUAL_CPU]
const {
MEMORY: memoryInput = `M|number|||${MEMORY}`,
CPU: cpuInput = `M|number-float|||${CPU}`,
VCPU: vcpuInput = `O|number|||${VCPU}`,
} = USER_INPUTS
return [
{ name: 'MEMORY', ...getUserInputParams(memoryInput) },
{ name: 'CPU', ...getUserInputParams(cpuInput) },
{ name: 'VCPU', ...getUserInputParams(vcpuInput) },
].map(({ name, options, ...userInput }) => {
const isMemory = name === 'MEMORY'
const isVCenter = HYPERVISOR === HYPERVISORS.vcenter
const divisibleBy4 = isVCenter && isMemory
const ensuredOptions = divisibleBy4
? options?.filter((value) => isDivisibleBy(+value, 4))
: options
const schemaUi = schemaUserInput({ options: ensuredOptions, ...userInput })
const isNumber = schemaUi.validation instanceof NumberSchema
if (isNumber) {
// add positive number validator
isNumber && (schemaUi.validation &&= schemaUi.validation.positive())
// add label format on pretty bytes
isMemory &&
(schemaUi.fieldProps = { ...schemaUi.fieldProps, valueLabelFormat })
if (divisibleBy4) {
schemaUi.validation &&= schemaUi.validation.isDivisibleBy(4)
schemaUi.fieldProps = { ...schemaUi.fieldProps, step: 4 }
}
}
return { ...TRANSLATES[name], ...schemaUi, grid: { md: 12 } }
})
}

View File

@ -22,23 +22,26 @@ import useStyles from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/
import {
SCHEMA,
FIELDS,
SECTIONS,
} from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/BasicConfiguration/schema'
import { getActionsAvailable as getSectionsAvailable } from 'client/models/Helper'
import { T, RESOURCE_NAMES, VmTemplate } from 'client/constants'
export const STEP_ID = 'configuration'
const Content = ({ hypervisor }) => {
const Content = ({ vmTemplate }) => {
const classes = useStyles()
const { view, getResourceView } = useViews()
const sections = useMemo(() => {
const hypervisor = vmTemplate?.TEMPLATE?.HYPERVISOR
const resource = RESOURCE_NAMES.VM_TEMPLATE
const dialog = getResourceView(resource)?.dialogs?.instantiate_dialog
const sectionsAvailable = getSectionsAvailable(dialog, hypervisor)
return FIELDS(hypervisor).filter(({ id }) => sectionsAvailable.includes(id))
return SECTIONS(vmTemplate).filter(({ id }) =>
sectionsAvailable.includes(id)
)
}, [view])
return (
@ -57,26 +60,22 @@ const Content = ({ hypervisor }) => {
)
}
Content.propTypes = {
vmTemplate: PropTypes.object,
}
/**
* Basic configuration about VM Template.
*
* @param {VmTemplate} vmTemplate - VM Template
* @returns {object} Basic configuration step
*/
const BasicConfiguration = (vmTemplate) => {
const hypervisor = vmTemplate?.TEMPLATE?.HYPERVISOR
return {
id: STEP_ID,
label: T.Configuration,
resolver: () => SCHEMA(hypervisor),
optionsValidate: { abortEarly: false },
content: (props) => Content({ ...props, hypervisor }),
}
}
Content.propTypes = {
hypervisor: PropTypes.string,
}
const BasicConfiguration = (vmTemplate) => ({
id: STEP_ID,
label: T.Configuration,
resolver: () => SCHEMA(vmTemplate),
optionsValidate: { abortEarly: false },
content: (props) => Content({ ...props, vmTemplate }),
})
export default BasicConfiguration

View File

@ -28,50 +28,59 @@ import {
filterFieldsByHypervisor,
getObjectSchemaFromFields,
Field,
Section,
} from 'client/utils'
import { T, HYPERVISORS } from 'client/constants'
import { T, VmTemplate } from 'client/constants'
/**
* @param {HYPERVISORS} [hypervisor] - Template hypervisor
* @returns {function(string):{ id: string, legend: string, fields: Field[] }[]} Fields
* @param {VmTemplate} [vmTemplate] - VM Template
* @returns {Section[]} Sections
*/
const FIELDS = (hypervisor) => [
{
id: 'information',
legend: T.Information,
fields: filterFieldsByHypervisor(INFORMATION_FIELDS, hypervisor),
},
{
id: 'capacity',
legend: T.Capacity,
fields: filterFieldsByHypervisor(CAPACITY_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_FOLDER_FIELD], hypervisor),
},
]
const SECTIONS = (vmTemplate) => {
const hypervisor = vmTemplate?.TEMPLATE?.HYPERVISOR
return [
{
id: 'information',
legend: T.Information,
fields: filterFieldsByHypervisor(INFORMATION_FIELDS, hypervisor),
},
{
id: 'capacity',
legend: T.Capacity,
fields: filterFieldsByHypervisor(CAPACITY_FIELDS(vmTemplate), 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_FOLDER_FIELD], hypervisor),
},
]
}
/**
* @param {HYPERVISORS} [hypervisor] - Template hypervisor
* @param {VmTemplate} [vmTemplate] - VM Template
* @returns {Field[]} Basic configuration fields
*/
const FIELDS = (vmTemplate) =>
SECTIONS(vmTemplate)
.map(({ fields }) => fields)
.flat()
/**
* @param {VmTemplate} [vmTemplate] - VM Template
* @returns {BaseSchema} Step schema
*/
const SCHEMA = (hypervisor) =>
getObjectSchemaFromFields(
FIELDS(hypervisor)
.map(({ fields }) => fields)
.flat()
)
const SCHEMA = (vmTemplate) => getObjectSchemaFromFields(FIELDS(vmTemplate))
export { FIELDS, SCHEMA }
export { SECTIONS, FIELDS, SCHEMA }

View File

@ -20,8 +20,7 @@ import {
FIELDS,
SCHEMA,
} from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/UserInputs/schema'
import { userInputsToArray } from 'client/models/Helper'
import { T, VmTemplate } from 'client/constants'
import { T, UserInputObject } from 'client/constants'
import { FormWithSchema } from 'client/components/Forms'
export const STEP_ID = 'user_inputs'
@ -42,22 +41,15 @@ Content.propTypes = {
/**
* User inputs step.
*
* @param {VmTemplate} vmTemplate - VM Template
* @param {UserInputObject[]} userInputs - User inputs
* @returns {object} User inputs step
*/
const UserInputsStep = (vmTemplate) => {
const userInputs = userInputsToArray(
vmTemplate?.TEMPLATE?.USER_INPUTS,
vmTemplate?.TEMPLATE?.INPUTS_ORDER
)
return {
id: STEP_ID,
label: T.UserInputs,
optionsValidate: { abortEarly: false },
resolver: SCHEMA(userInputs),
content: (props) => Content({ ...props, userInputs }),
}
}
const UserInputsStep = (userInputs) => ({
id: STEP_ID,
label: T.UserInputs,
optionsValidate: { abortEarly: false },
resolver: SCHEMA(userInputs),
content: (props) => Content({ ...props, userInputs }),
})
export default UserInputsStep

View File

@ -22,16 +22,21 @@ import UserInputs, {
import ExtraConfiguration, {
STEP_ID as EXTRA_ID,
} from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration'
import { jsonToXml } from 'client/models/Helper'
import { jsonToXml, userInputsToArray } from 'client/models/Helper'
import { createSteps } from 'client/utils'
const Steps = createSteps(
(vmTemplate) =>
[
(vmTemplate) => {
const userInputs = userInputsToArray(vmTemplate?.TEMPLATE?.USER_INPUTS, {
order: vmTemplate?.TEMPLATE?.INPUTS_ORDER,
})
return [
BasicConfiguration,
vmTemplate?.TEMPLATE?.USER_INPUTS && UserInputs,
!!userInputs.length && (() => UserInputs(userInputs)),
ExtraConfiguration,
].filter(Boolean),
].filter(Boolean)
},
{
transformInitialValue: (vmTemplate, schema) => {
const initialValue = schema.cast(

View File

@ -124,4 +124,4 @@ AsyncLoadForm.propTypes = {
componentToLoad: PropTypes.string,
}
export default AsyncLoadForm
export { AsyncLoadForm }

View File

@ -16,9 +16,6 @@
export { default as AuthLayout } from 'client/components/HOC/AuthLayout'
export { default as ConditionalWrap } from 'client/components/HOC/ConditionalWrap'
export { default as InternalLayout } from 'client/components/HOC/InternalLayout'
export {
default as AsyncLoadForm,
ConfigurationProps,
} from 'client/components/HOC/AsyncLoadForm'
export * from 'client/components/HOC/AsyncLoadForm'
export * from 'client/components/HOC/Translate'

View File

@ -41,17 +41,17 @@ const VmTemplateInfoTab = ({ tabProps = {}, id }) => {
ownership_panel: ownershipPanel,
} = tabProps
const [changeOwnership] = useChangeTemplatePermissionsMutation()
const [changePermissions] = useChangeTemplateOwnershipMutation()
const [changePermissions] = useChangeTemplatePermissionsMutation()
const [changeOwnership] = useChangeTemplateOwnershipMutation()
const { data: template = {} } = useGetTemplateQuery({ id })
const { ID, UNAME, UID, GNAME, GID, PERMISSIONS } = template
const { UNAME, UID, GNAME, GID, PERMISSIONS } = template
const handleChangeOwnership = async (newOwnership) => {
await changeOwnership({ id: ID, ...newOwnership })
await changeOwnership({ id, ...newOwnership })
}
const handleChangePermission = async (newPermission) => {
await changePermissions({ id: ID, ...newPermission })
await changePermissions({ id, ...newPermission })
}
const getActions = (actions) => Helper.getActionsAvailable(actions)
@ -72,6 +72,7 @@ const VmTemplateInfoTab = ({ tabProps = {}, id }) => {
{permissionsPanel?.enabled && (
<Permissions
actions={getActions(permissionsPanel?.actions)}
handleEdit={handleChangePermission}
ownerUse={PERMISSIONS.OWNER_U}
ownerManage={PERMISSIONS.OWNER_M}
ownerAdmin={PERMISSIONS.OWNER_A}
@ -81,17 +82,16 @@ const VmTemplateInfoTab = ({ tabProps = {}, id }) => {
otherUse={PERMISSIONS.OTHER_U}
otherManage={PERMISSIONS.OTHER_M}
otherAdmin={PERMISSIONS.OTHER_A}
handleEdit={handleChangePermission}
/>
)}
{ownershipPanel?.enabled && (
<Ownership
actions={getActions(ownershipPanel?.actions)}
handleEdit={handleChangeOwnership}
userId={UID}
userName={UNAME}
groupId={GID}
groupName={GNAME}
handleEdit={handleChangeOwnership}
/>
)}
</Stack>

View File

@ -24,7 +24,7 @@ import { timeToString, levelLockToString } from 'client/models/Helper'
import {
T,
VM_TEMPLATE_ACTIONS,
LOGO_IMAGES_URL,
STATIC_FILES_URL,
VmTemplate,
} from 'client/constants'
@ -67,7 +67,7 @@ const InformationPanel = ({ template = {}, actions }) => {
},
LOGO && {
name: T.Logo,
value: <Image alt="logo" src={`${LOGO_IMAGES_URL}/${LOGO}`} />,
value: <Image alt="logo" src={`${STATIC_FILES_URL}/${LOGO}`} />,
dataCy: 'logo',
},
].filter(Boolean)

View File

@ -484,6 +484,9 @@ module.exports = {
DefaultNicFilter: 'Default network filtering rule for all NICs',
/* VM Template schema - capacity */
MaxMemory: 'Max memory',
MaxMemoryConcept: `
This value sets the maximum value of the MEMORY allowed to be modified
after instantiation, through the Capacity Resize options of instantiated VMs`,
MemoryModification: 'Memory modification',
AllowUsersToModifyMemory:
"Allow users to modify this template's default memory on instantiate",
@ -492,11 +495,15 @@ module.exports = {
Percentage of CPU divided by 100 required for the
Virtual Machine. Half a processor is written 0.5`,
MaxVirtualCpu: 'Max Virtual CPU',
MaxVirtualCpuConcept: `
This value sets the maximum value of the VCPU allowed to be modified
after instantiation, through the Capacity Resize options of instantiated VMs`,
CpuModification: 'CPU modification',
AllowUsersToModifyCpu:
"Allow users to modify this template's default CPU on instantiate",
VirtualCpuConcept: `
Number of virtual cpus. This value is optional, the default
hypervisor behavior is used, usually one virtual CPU`,
AllowUsersToModifyCpu:
"Allow users to modify this template's default CPU on instantiate",
VirtualCpuModification: 'Virtual CPU modification',
AllowUsersToModifyVirtualCpu:
"Allow users to modify this template's default Virtual CPU on instantiate",

View File

@ -16,6 +16,7 @@
/**
* @typedef {(
* 'fixed' |
* 'text' |
* 'text64' |
* 'password' |
@ -64,6 +65,7 @@
/** @enum {UserInputType} User input types */
export const USER_INPUT_TYPES = {
fixed: 'fixed',
text: 'text',
text64: 'text64',
password: 'password',

View File

@ -215,6 +215,34 @@ const vmTemplateApi = oneApi.injectEndpoints({
return { params, command }
},
invalidatesTags: (_, __, { id }) => [{ type: TEMPLATE, id }],
async onQueryStarted(
{ id, ...permissions },
{ dispatch, queryFulfilled }
) {
const patchResult = dispatch(
vmTemplateApi.util.updateQueryData('getTemplate', { id }, (draft) => {
Object.entries(permissions)
.filter(([_, value]) => value !== '-1')
.forEach(([name, value]) => {
const ensuredName = {
ownerUse: 'OWNER_U',
ownerManage: 'OWNER_M',
ownerAdmin: 'OWNER_A',
groupUse: 'GROUP_U',
groupManage: 'GROUP_M',
groupAdmin: 'GROUP_A',
otherUse: 'OTHER_U',
otherManage: 'OTHER_M',
otherAdmin: 'OTHER_A',
}[name]
draft.PERMISSIONS[ensuredName] = value
})
})
)
queryFulfilled.catch(patchResult.undo)
},
}),
changeTemplateOwnership: builder.mutation({
/**
@ -235,6 +263,19 @@ const vmTemplateApi = oneApi.injectEndpoints({
return { params, command }
},
invalidatesTags: (_, __, { id }) => [{ type: TEMPLATE, id }],
async onQueryStarted(
{ id, user, group },
{ dispatch, queryFulfilled, getState }
) {
const patchResult = dispatch(
vmTemplateApi.util.updateQueryData('getTemplate', id, (draft) => {
user > 0 && (draft.UID = user)
group > 0 && (draft.GID = group)
})
)
queryFulfilled.catch(patchResult.undo)
},
}),
renameTemplate: builder.mutation({
/**

View File

@ -339,7 +339,7 @@ export const getUserInputString = (userInput) => {
// mandatory|type|description|range/options/' '|defaultValue
const uiString = [mandatoryString, type, description]
range?.length > 0
;[USER_INPUT_TYPES.range, USER_INPUT_TYPES.rangeFloat].includes(type)
? uiString.push(range)
: options?.length > 0
? uiString.push(options.join(LIST_SEPARATOR))
@ -352,16 +352,19 @@ export const getUserInputString = (userInput) => {
* Get list of user inputs defined in OpenNebula template.
*
* @param {object} userInputs - List of user inputs in string format
* @param {string} [inputsOrder] - List separated by comma of input names
* @param {object} [options] - Options to filter user inputs
* @param {boolean} [options.filterCapacityInputs]
* - If false, will not filter capacity inputs: MEMORY, CPU, VCPU. By default `true`
* @param {string} [options.order] - List separated by comma of input names
* @example
* const userInputs = {
* "INPUT-1": "O|text|Description1| |text1",
* "INPUT-2": "M|text|Description2| |text2"
* }
*
* const inputsOrder = "INPUT-2,INPUT-1"
* const order = "INPUT-2,INPUT-1"
*
* => userInputsToArray(userInputs, inputsOrder) => [{
* => userInputsToArray(userInputs, { order }) => [{
* name: 'INPUT-1',
* mandatory: false,
* type: 'text',
@ -377,17 +380,33 @@ export const getUserInputString = (userInput) => {
* }]
* @returns {UserInputObject[]} User input object
*/
export const userInputsToArray = (userInputs = {}, inputsOrder) => {
const orderedList = inputsOrder?.split(',') ?? []
export const userInputsToArray = (
userInputs = {},
{ filterCapacityInputs = true, order } = {}
) => {
const orderedList = order?.split(',') ?? []
const userInputsArray = Object.entries(userInputs)
return Object.entries(userInputs)
.map(([name, ui]) => ({ name, ...getUserInputParams(ui) }))
.sort((a, b) => {
let list = userInputsArray.map(([name, ui]) => ({
name: `${name}`.toUpperCase(),
...(typeof ui === 'string' ? getUserInputParams(ui) : ui),
}))
if (filterCapacityInputs) {
const capacityInputs = ['MEMORY', 'CPU', 'VCPU']
list = list.filter((ui) => !capacityInputs.includes(ui.name))
}
if (orderedList.length) {
list = list.sort((a, b) => {
const upperAName = a.name?.toUpperCase?.()
const upperBName = b.name?.toUpperCase?.()
return orderedList.indexOf(upperAName) - orderedList.indexOf(upperBName)
})
}
return list
}
/**

View File

@ -13,6 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { v4 as uuidv4 } from 'uuid'
import DOMPurify from 'dompurify'
import { object, reach, ObjectSchema, BaseSchema } from 'yup'
import { isMergeableObject } from 'client/utils/merge'
@ -41,8 +42,7 @@ export const isExternalURL = (url) => /^(http|https):/g.test(url)
*
* @returns {string} Random key
*/
export const generateKey = () =>
String(new Date().getTime() + Math.random()).replace('.', '')
export const generateKey = () => uuidv4()
/**
* Sanitizes HTML and prevents XSS attacks.
@ -443,15 +443,6 @@ export const isBase64 = (stringToValidate, options = {}) => {
return regex.test(stringToValidate)
}
/**
* Check if value is divisible by 4.
*
* @param {string|number} value - Number to check
* @returns {boolean} Returns `true` if string is divisible by 4
*/
export const isDivisibleBy4 = (value) =>
/[048]|\d*([02468][048]|[13579][26])/g.test(value)
/**
* Check if value is divisible by another number.
*

View File

@ -115,6 +115,8 @@ import { stringToBoolean } from 'client/models/Helper'
* - Filters the field when the driver is not include on list
* @property {TextFieldProps|CheckboxProps|InputBaseComponentProps} [fieldProps]
* - Extra properties to material-ui field
* @property {boolean|DependOfCallback} [readOnly]
* - If `true`, the field is read only
* @property {function(string|number):any} [renderValue]
* - Render the current selected value inside selector input
* - **Only for select inputs.**
@ -218,12 +220,8 @@ const getRange = (options) => options?.split?.('..').map(parseFloat)
const getValuesFromArray = (options, separator = SEMICOLON_CHAR) =>
options?.split(separator)
const getOptionsFromList = (options) =>
options
?.map((option) =>
typeof option === 'string' ? { text: option, value: option } : option
)
?.filter(({ text, value } = {}) => text && value)
const getOptionsFromList = (options = []) =>
arrayToOptions([...new Set(options)])
const parseUserInputValue = (value) => {
if (value === true) {
@ -257,6 +255,22 @@ export const schemaUserInput = ({
default: defaultValue,
}) => {
switch (type) {
case USER_INPUT_TYPES.fixed: {
const isNumeric = !isNaN(defaultValue)
const ensuredValue = isNumeric ? parseFloat(defaultValue) : defaultValue
const validation = isNumeric ? number() : string().trim()
return {
type: INPUT_TYPES.TEXT,
htmlType: isNaN(+defaultValue) ? 'text' : 'number',
validation: validation
.default(ensuredValue)
// ensures to send the value
.afterSubmit(() => defaultValue),
fieldProps: { disabled: true },
readOnly: true,
}
}
case USER_INPUT_TYPES.text:
case USER_INPUT_TYPES.text64:
case USER_INPUT_TYPES.password:
@ -311,7 +325,8 @@ export const schemaUserInput = ({
}
case USER_INPUT_TYPES.list: {
const values = getOptionsFromList(options)
const firstOption = values?.[0]?.value ?? undefined
const optionValues = values.map(({ value }) => value).filter(Boolean)
const firstOption = optionValues[0] ?? undefined
return {
values,
@ -319,7 +334,7 @@ export const schemaUserInput = ({
validation: string()
.trim()
.concat(requiredSchema(mandatory, string()))
.oneOf(values.map(({ value }) => value))
.oneOf(optionValues)
.default(() => defaultValue || firstOption),
}
}