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

F #5422: Add new tabs to create template form (#1534)

This commit is contained in:
Sergio Betanzos 2021-10-20 15:51:03 +02:00 committed by GitHub
parent 9e6a155a5d
commit 62afe391b2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
96 changed files with 5932 additions and 4682 deletions

View File

@ -87,13 +87,22 @@ dialogs:
sched_action: true
booting: true
create_dialog:
general: true
capacity: true
ownership: true
vm_group: true
vcenter:
enabled: true
not_on:
- vcenter
storage:
enabled: true
- kvm
- lxc
- firecracker
network: true
storage: true
placement: true
input_output: true
sched_action: true
context: true
booting: true
numa:
enabled: true
no_on:

View File

@ -87,15 +87,25 @@ dialogs:
sched_action: true
booting: true
create_dialog:
general: true
information: true
capacity: true
ownership: true
vm_group: true
vcenter:
enabled: true
hypervisor:
- vcenter
storage:
enabled: true
not_on:
- kvm
- lxc
- firecracker
network: true
storage: true
placement: true
input_output: true
sched_action: true
context: true
booting: true
numa:
enabled: true
hypervisors:
no_on:
- vcenter
- kvm

File diff suppressed because it is too large Load Diff

View File

@ -30,7 +30,7 @@
"vms"
],
"devDependencies": {
"@pmmmwh/react-refresh-webpack-plugin": "0.5.0-rc.3",
"@pmmmwh/react-refresh-webpack-plugin": "0.5.1",
"cross-env": "7.0.2",
"eslint": "7.11.0",
"eslint-config-prettier": "6.11.0",
@ -48,22 +48,23 @@
"opennebula-generatepotfile": "1.0.0",
"opennebula-potojson": "1.0.0",
"react-refresh": "0.10.0",
"webpack-dev-middleware": "5.0.0",
"webpack-hot-middleware": "2.25.0"
"webpack-dev-middleware": "5.2.1",
"webpack-hot-middleware": "2.25.1"
},
"dependencies": {
"@babel/cli": "7.14.5",
"@babel/core": "7.12.13",
"@babel/node": "7.12.13",
"@babel/plugin-proposal-class-properties": "7.12.13",
"@babel/plugin-proposal-nullish-coalescing-operator": "7.12.13",
"@babel/plugin-proposal-object-rest-spread": "7.12.13",
"@babel/plugin-proposal-optional-chaining": "7.12.13",
"@babel/preset-env": "7.12.13",
"@babel/preset-react": "7.12.13",
"@babel/cli": "7.15.7",
"@babel/core": "7.15.8",
"@babel/node": "7.15.8",
"@babel/plugin-proposal-class-properties": "7.14.5",
"@babel/plugin-proposal-nullish-coalescing-operator": "7.14.5",
"@babel/plugin-proposal-object-rest-spread": "7.15.6",
"@babel/plugin-proposal-optional-chaining": "7.14.5",
"@babel/preset-env": "7.15.8",
"@babel/preset-react": "7.14.5",
"@emotion/react": "11.4.1",
"@emotion/styled": "11.3.0",
"@hookform/resolvers": "1.3.7",
"@hookform/devtools": "4.0.1",
"@hookform/resolvers": "2.8.2",
"@loadable/babel-plugin": "5.13.2",
"@loadable/component": "5.15.0",
"@loadable/server": "5.15.1",
@ -72,9 +73,9 @@
"@mui/material": "5.0.2",
"@mui/styles": "5.0.1",
"@mui/system": "5.0.2",
"@reduxjs/toolkit": "1.5.1",
"@reduxjs/toolkit": "1.6.2",
"atob": "2.1.2",
"axios": "0.21.1",
"axios": "0.23.0",
"babel-eslint": "10.1.0",
"babel-loader": "8.2.1",
"babel-plugin-module-resolver": "4.0.0",
@ -117,17 +118,17 @@
"react-dom": "17.0.2",
"react-flatpickr": "3.10.7",
"react-flow-renderer": "9.6.0",
"react-hook-form": "6.12.0",
"react-hook-form": "7.17.4",
"react-json-pretty": "2.2.0",
"react-minimal-pie-chart": "8.2.0",
"react-opennebula-ace": "1.0.1",
"react-redux": "7.2.4",
"react-redux": "7.2.5",
"react-router": "5.2.1",
"react-router-dom": "5.3.0",
"react-table": "7.7.0",
"react-transition-group": "4.4.1",
"react-virtual": "2.7.1",
"redux": "4.1.0",
"redux": "4.1.1",
"redux-thunk": "2.3.0",
"rimraf": "3.0.2",
"socket.io": "4.1.2",

View File

@ -29,6 +29,9 @@ import { TranslateProvider } from 'client/components/HOC'
import App, { APP_NAME as ProvisionAppName } from 'client/apps/provision/_app'
import theme from 'client/apps/provision/theme'
import { APP_URL } from 'client/constants'
import { buildTranslationLocale } from 'client/utils'
buildTranslationLocale()
/**
* @param {object} props - Props

View File

@ -28,6 +28,9 @@ import { TranslateProvider } from 'client/components/HOC'
import App, { APP_NAME as SunstoneAppName } from 'client/apps/sunstone/_app'
import theme from 'client/apps/sunstone/theme'
import { APP_URL } from 'client/constants'
import { buildTranslationLocale } from 'client/utils'
buildTranslationLocale()
/**
* @param {object} props - Props

View File

@ -49,8 +49,9 @@ const VirtualMachineDetail = loadable(() => import('client/containers/VirtualMac
const VirtualRouters = loadable(() => import('client/containers/VirtualRouters'), { ssr: false })
const VmTemplates = loadable(() => import('client/containers/VmTemplates'), { ssr: false })
const InstantiateVmTemplates =
const InstantiateVmTemplate =
loadable(() => import('client/containers/VmTemplates/Instantiate'), { ssr: false })
const CreateVmTemplate = loadable(() => import('client/containers/VmTemplates/Create'), { ssr: false })
// const VrTemplates = loadable(() => import('client/containers/VrTemplates'), { ssr: false })
// const VmGroups = loadable(() => import('client/containers/VmGroups'), { ssr: false })
@ -92,7 +93,8 @@ export const PATH = {
VMS: {
LIST: '/vm-template',
DETAIL: '/vm-template/:id',
INSTANTIATE: '/vm-template/instantiate'
INSTANTIATE: '/vm-template/instantiate',
CREATE: '/vm-template/create'
}
},
STORAGE: {
@ -199,7 +201,12 @@ const ENDPOINTS = [
{
label: 'Instantiate VM Template',
path: PATH.TEMPLATE.VMS.INSTANTIATE,
Component: InstantiateVmTemplates
Component: InstantiateVmTemplate
},
{
label: 'Create VM Template',
path: PATH.TEMPLATE.VMS.CREATE,
Component: CreateVmTemplate
}
]
},

View File

@ -19,7 +19,7 @@
export const _regANSI = /(?:(?:\u001b\[)|\u009b)(?:(?:[0-9]{1,3})?(?:(?:;[0-9]{0,3})*)?[A-M|f-m])|\u001b[A-M]/
const _defColors = {
reset: ['fff', '000'], // [FOREGROUD_COLOR, BACKGROUND_COLOR]
reset: ['fff', '000'], // [FOREGROUND_COLOR, BACKGROUND_COLOR]
black: '000',
red: 'ff0000',
green: '209805',

View File

@ -17,7 +17,9 @@ import { memo, useEffect } from 'react'
import { styled, Link, Typography } from '@mui/material'
import { useFetch } from 'client/hooks'
import { useSystem, useSystemApi } from 'client/features/One'
import { StatusChip } from 'client/components/Status'
import { BY } from 'client/constants'
const FooterBox = styled('footer')(({ theme }) => ({
@ -44,9 +46,10 @@ const HeartIcon = styled('span')(({ theme }) => ({
const Footer = memo(() => {
const { version } = useSystem()
const { getOneVersion } = useSystemApi()
const { fetchRequest } = useFetch(getOneVersion)
useEffect(() => {
!version && getOneVersion()
!version && fetchRequest()
}, [])
return (
@ -56,8 +59,10 @@ const Footer = memo(() => {
<HeartIcon role='img' aria-label='heart-emoji' />
<Link href={BY.url} color='primary.contrastText'>
{BY.text}
{version}
</Link>
{version && (
<StatusChip stateColor='secondary' text={version} mx={1} />
)}
</Typography>
</FooterBox>
)

View File

@ -17,70 +17,76 @@ import { memo } from 'react'
import PropTypes from 'prop-types'
import { TextField, Chip, Autocomplete } from '@mui/material'
import { Controller } from 'react-hook-form'
import { useController } from 'react-hook-form'
import { ErrorHelper } from 'client/components/FormControl'
import { Tr } from 'client/components/HOC'
import { Tr, labelCanBeTranslated } from 'client/components/HOC'
import { generateKey } from 'client/utils'
const AutocompleteController = memo(
({ control, cy, name, label, multiple, values, error, fieldProps }) => (
<Controller
render={({ value: renderValue, onBlur, onChange }) => {
const selected = multiple
? renderValue ?? []
: values.find(({ value }) => value === renderValue) ?? null
({
control,
cy = `autocomplete-${generateKey()}`,
name = '',
label = '',
multiple = false,
values = [],
fieldProps = {}
}) => {
const {
field: { value: renderValue, onBlur, onChange },
fieldState: { error }
} = useController({ name, control })
return (
<Autocomplete
fullWidth
color='secondary'
onBlur={onBlur}
onChange={(_, newValue) => {
const newValueToChange = multiple
? newValue?.map(value =>
typeof value === 'string' ? value : ({ text: value, value })
)
: newValue?.value
const selected = multiple
? renderValue ?? []
: values.find(({ value }) => value === renderValue) ?? null
return onChange(newValueToChange ?? '')
}}
options={values}
value={selected}
multiple={multiple}
renderTags={(tags, getTagProps) =>
// render when freesolo prop
tags.map((tag, index) => (
<Chip
key={tag}
size='small'
variant='outlined'
label={tag}
{...getTagProps({ index })}
/>
))
}
getOptionLabel={option => option.text}
isOptionEqualToValue={option => option.value === renderValue}
renderInput={({ inputProps, ...inputParams }) => (
<TextField
label={Tr(label)}
inputProps={{ ...inputProps, 'data-cy': cy }}
error={Boolean(error)}
helperText={
Boolean(error) && <ErrorHelper label={error?.message} />
}
FormHelperTextProps={{ 'data-cy': `${cy}-error` }}
{...inputParams}
/>
)}
{...fieldProps}
return (
<Autocomplete
fullWidth
color='secondary'
onBlur={onBlur}
onChange={(_, newValue) => {
const newValueToChange = multiple
? newValue?.map(value =>
typeof value === 'string' ? value : ({ text: value, value })
)
: newValue?.value
return onChange(newValueToChange ?? '')
}}
options={values}
value={selected}
multiple={multiple}
renderTags={(tags, getTagProps) =>
// render when freesolo prop
tags.map((tag, index) => (
<Chip
key={tag}
size='small'
variant='outlined'
label={tag}
{...getTagProps({ index })}
/>
))
}
getOptionLabel={option => option.text}
isOptionEqualToValue={option => option.value === renderValue}
renderInput={({ inputProps, ...inputParams }) => (
<TextField
label={labelCanBeTranslated(label) ? Tr(label) : label}
inputProps={{ ...inputProps, 'data-cy': cy }}
error={Boolean(error)}
helperText={Boolean(error) && <ErrorHelper label={error?.message} />}
FormHelperTextProps={{ 'data-cy': `${cy}-error` }}
{...inputParams}
/>
)
}}
name={name}
control={control}
/>
),
)}
{...fieldProps}
/>
)
},
(prevProps, nextProps) => (
prevProps.error === nextProps.error &&
prevProps.values === nextProps.values
@ -90,27 +96,12 @@ AutocompleteController.propTypes = {
control: PropTypes.object,
cy: PropTypes.string,
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
label: PropTypes.any,
multiple: PropTypes.bool,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
error: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.objectOf(PropTypes.any)
]),
fieldProps: PropTypes.object
}
AutocompleteController.defaultProps = {
control: {},
cy: 'cy',
name: '',
label: '',
multiple: false,
values: [],
error: false,
fieldProps: undefined
}
AutocompleteController.displayName = 'AutocompleteController'
export default AutocompleteController

View File

@ -16,43 +16,62 @@
import { memo } from 'react'
import PropTypes from 'prop-types'
import { FormControl, FormControlLabel, Checkbox } from '@mui/material'
import { Controller } from 'react-hook-form'
import { styled, FormControl, FormControlLabel, FormHelperText, Checkbox } from '@mui/material'
import { useController } from 'react-hook-form'
import { ErrorHelper, Tooltip } from 'client/components/FormControl'
import { Tr } from 'client/components/HOC'
import { Tr, labelCanBeTranslated } from 'client/components/HOC'
import { generateKey } from 'client/utils'
const Label = styled('span')({
display: 'flex',
alignItems: 'center',
gap: '0.5em'
})
const CheckboxController = memo(
({ control, cy, name, label, tooltip, error, fieldProps }) => (
<Controller
render={({ onChange, value = false }) => (
<FormControl error={Boolean(error)} margin='dense'>
<FormControlLabel
control={
<Checkbox
onChange={e => onChange(e.target.checked)}
name={name}
checked={Boolean(value)}
color='secondary'
inputProps={{ 'data-cy': cy }}
{...fieldProps}
/>
}
label={
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5em' }}>
{Tr(label)}
{tooltip && <Tooltip title={tooltip} />}
</span>
}
labelPlacement='end'
/>
{Boolean(error) && <ErrorHelper label={error?.message} />}
</FormControl>
)}
name={name}
control={control}
/>
),
({
control,
cy = `checkbox-${generateKey()}`,
name = '',
label = '',
tooltip,
fieldProps = {}
}) => {
const {
field: { value = false, onChange },
fieldState: { error }
} = useController({ name, control })
return (
<FormControl fullWidth error={Boolean(error)} margin='dense'>
<FormControlLabel
control={
<Checkbox
onChange={e => onChange(e.target.checked)}
name={name}
checked={Boolean(value)}
color='secondary'
inputProps={{ 'data-cy': cy }}
{...fieldProps}
/>
}
label={
<Label>
{labelCanBeTranslated(label) ? Tr(label) : label}
{tooltip && <Tooltip title={tooltip} />}
</Label>
}
labelPlacement='end'
/>
{Boolean(error) && (
<FormHelperText data-cy={`${cy}-error`}>
<ErrorHelper label={error?.message} />
</FormHelperText>
)}
</FormControl>
)
},
(prevProps, nextProps) => prevProps.error === nextProps.error
)
@ -60,24 +79,11 @@ CheckboxController.propTypes = {
control: PropTypes.object,
cy: PropTypes.string,
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
tooltip: PropTypes.string,
error: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.objectOf(PropTypes.any)
]),
label: PropTypes.any,
tooltip: PropTypes.any,
fieldProps: PropTypes.object
}
CheckboxController.defaultProps = {
control: {},
cy: 'cy',
name: '',
label: '',
values: [],
error: false
}
CheckboxController.displayName = 'CheckboxController'
export default CheckboxController

View File

@ -14,46 +14,30 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo } from 'react'
import { string } from 'prop-types'
import { oneOfType, string, node } from 'prop-types'
import { Box, Typography } from '@mui/material'
import makeStyles from '@mui/styles/makeStyles'
import { Stack, Typography, styled } from '@mui/material'
import { WarningCircledOutline as WarningIcon } from 'iconoir-react'
import { Tr } from 'client/components/HOC'
import { Tr, labelCanBeTranslated } from 'client/components/HOC'
const useStyles = makeStyles(theme => ({
root: {
color: theme.palette.error.dark,
display: 'flex',
alignItems: 'center'
},
text: {
...theme.typography.body1,
paddingLeft: theme.spacing(1),
overflowWrap: 'anywhere'
}
const ErrorTypo = styled(Typography)(({ theme }) => ({
...theme.typography.body1,
paddingLeft: theme.spacing(1),
overflowWrap: 'anywhere'
}))
const ErrorHelper = memo(({ label, ...rest }) => {
const classes = useStyles()
return (
<Box component='span' className={classes.root} {...rest}>
<WarningIcon />
<Typography className={classes.text} component='span' data-cy='error-text'>
{Tr(label)}
</Typography>
</Box>
)
})
const ErrorHelper = memo(({ label, ...rest }) => (
<Stack component='span' color='error.dark' direction='row' alignItems='center' {...rest}>
<WarningIcon />
<ErrorTypo component='span' data-cy='error-text'>
{labelCanBeTranslated(label) ? Tr(label) : label}
</ErrorTypo>
</Stack>
))
ErrorHelper.propTypes = {
label: string
}
ErrorHelper.defaultProps = {
label: 'Error'
label: oneOfType([string, node])
}
ErrorHelper.displayName = 'ErrorHelper'

View File

@ -15,44 +15,45 @@
* ------------------------------------------------------------------------- */
import { memo, useState, useRef, useEffect, ChangeEvent } from 'react'
import PropTypes from 'prop-types'
import clsx from 'clsx'
import { FormControl, FormHelperText } from '@mui/material'
import makeStyles from '@mui/styles/makeStyles'
import { styled, FormControl, FormHelperText } from '@mui/material'
import { Check as CheckIcon, Page as FileIcon } from 'iconoir-react'
import { Controller } from 'react-hook-form'
import { useController } from 'react-hook-form'
import { ErrorHelper, SubmitButton } from 'client/components/FormControl'
import { ErrorHelper, Tooltip, SubmitButton } from 'client/components/FormControl'
import { Tr, labelCanBeTranslated } from 'client/components/HOC'
import { generateKey } from 'client/utils'
const useStyles = makeStyles(theme => ({
hide: {
display: 'none'
},
label: {
display: 'flex',
alignItems: 'center',
gap: '1em',
padding: '0.5em',
borderBottom: `1px solid ${theme.palette.text.secondary}`
},
button: {
'&:hover': {
backgroundColor: theme.palette.secondary.dark
}
},
buttonSuccess: {
backgroundColor: theme.palette.success.main,
'&:hover': {
backgroundColor: theme.palette.success.dark
}
}
const HiddenInput = styled('input')({ display: 'none' })
const Label = styled('label')(({ theme, error }) => ({
display: 'flex',
alignItems: 'center',
gap: '1em',
...(error && {
color: theme.palette.error.main
})
}))
const FileController = memo(
({ control, cy, name, label, error, fieldProps, validationBeforeTransform, transform, formContext }) => {
({
control,
cy = `input-file-${generateKey()}`,
name = '',
label = '',
tooltip = '',
validationBeforeTransform,
transform,
fieldProps = {},
formContext = {}
}) => {
const { setValue, setError, clearErrors, watch } = formContext
const classes = useStyles()
const {
field: { ref, value, onChange, ...inputProps },
fieldState: { error }
} = useController({ name, control })
const [isLoading, setLoading] = useState(() => false)
const [success, setSuccess] = useState(() => !error && !!watch(name))
const timer = useRef()
@ -107,33 +108,26 @@ const FileController = memo(
}
return (
<FormControl fullWidth>
<Controller
render={() => (
<input
className={classes.hide}
id={cy}
type='file'
onChange={handleChange}
{...fieldProps}
/>
)}
name={name}
control={control}
<FormControl fullWidth margin='dense'>
<HiddenInput
{...inputProps}
ref={ref}
id={cy}
type='file'
onChange={handleChange}
{...fieldProps}
/>
<label htmlFor={cy} className={classes.label}>
<Label htmlFor={cy} error={error ? 'error' : undefined}>
<SubmitButton
color='secondary'
color={success ? 'success' : 'secondary'}
component='span'
data-cy={`${cy}-button`}
isSubmitting={isLoading}
label={success ? <CheckIcon /> : <FileIcon />}
className={clsx({
[classes.buttonSuccess]: success
})}
/>
{label}
</label>
{labelCanBeTranslated(label) ? Tr(label) : label}
{tooltip && <Tooltip title={tooltip} />}
</Label>
{Boolean(error) && (
<FormHelperText data-cy={`${cy}-error`}>
<ErrorHelper label={error?.message} />
@ -143,18 +137,17 @@ const FileController = memo(
)
},
(prevProps, nextProps) =>
prevProps.error === nextProps.error && prevProps.type === nextProps.type
prevProps.error === nextProps.error &&
prevProps.type === nextProps.type &&
prevProps.label === nextProps.label
)
FileController.propTypes = {
control: PropTypes.object,
cy: PropTypes.string,
name: PropTypes.string.isRequired,
label: PropTypes.string,
error: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.objectOf(PropTypes.any)
]),
label: PropTypes.any,
tooltip: PropTypes.any,
validationBeforeTransform: PropTypes.arrayOf(
PropTypes.shape({
message: PropTypes.string,
@ -172,17 +165,6 @@ FileController.propTypes = {
})
}
FileController.defaultProps = {
control: {},
cy: 'cy',
name: '',
label: '',
error: false,
validationBeforeTransform: undefined,
transform: undefined,
fieldProps: undefined
}
FileController.displayName = 'FileController'
export default FileController

View File

@ -38,7 +38,7 @@ const PasswordController = memo(({ fieldProps, ...props }) => {
<IconButton
aria-label='toggle password visibility'
onClick={handleClickShowPassword}
size='large'>
>
{showPassword ? <Visibility /> : <VisibilityOff />}
</IconButton>
</InputAdornment>
@ -49,7 +49,8 @@ const PasswordController = memo(({ fieldProps, ...props }) => {
)
},
(prevProps, nextProps) =>
prevProps.error === nextProps.error && prevProps.type === nextProps.type
prevProps.error === nextProps.error &&
prevProps.type === nextProps.type
)
PasswordController.propTypes = {

View File

@ -13,67 +13,79 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo } from 'react'
import { memo, useMemo } from 'react'
import PropTypes from 'prop-types'
import { TextField } from '@mui/material'
import { Controller } from 'react-hook-form'
import { useController } from 'react-hook-form'
import { ErrorHelper, Tooltip } from 'client/components/FormControl'
import { Tr } from 'client/components/HOC'
import { Tr, labelCanBeTranslated } from 'client/components/HOC'
import { generateKey } from 'client/utils'
const SelectController = memo(
({ control, cy, name, label, multiple, values, tooltip, error, fieldProps }) => {
({
control,
cy = `select-${generateKey()}`,
name = '',
label = '',
multiple = false,
values = [],
renderValue,
tooltip,
fieldProps = {}
}) => {
const defaultValue = multiple ? [values?.[0]?.value] : values?.[0]?.value
const {
field: { ref, value: optionSelected = defaultValue, onChange, ...inputProps },
fieldState: { error }
} = useController({ name, control })
const needShrink = useMemo(
() => values.find(v => v.value === optionSelected)?.text !== '',
[optionSelected]
)
return (
<Controller
render={({ value: optionSelected, onChange, onBlur }) => (
<TextField
value={optionSelected ?? defaultValue}
onBlur={onBlur}
onChange={
multiple
? event => {
const { options } = event.target
const newValue = []
<TextField
{...inputProps}
inputRef={ref}
value={optionSelected}
onChange={
multiple
? event => {
const { options = [] } = event.target
const newValue = options
.filter(option => option.selected)
.map(option => option.value)
for (let i = 0, l = options.length; i < l; i += 1) {
if (options[i].selected) {
newValue.push(options[i].value)
}
}
onChange(newValue)
}
: onChange
onChange(newValue)
}
select
fullWidth
SelectProps={{ native: true, multiple }}
label={Tr(label)}
InputProps={{
startAdornment: tooltip && (
<Tooltip title={tooltip} position='start' />
)
}}
inputProps={{ 'data-cy': cy }}
error={Boolean(error)}
helperText={Boolean(error) && <ErrorHelper label={error?.message} />}
FormHelperTextProps={{ 'data-cy': `${cy}-error` }}
{...fieldProps}
>
{values?.map(({ text, value = '' }) =>
<option key={`${name}-${value}`} value={value}>
{text}
</option>
)}
</TextField>
: onChange
}
select
fullWidth
SelectProps={{ native: true, multiple }}
label={labelCanBeTranslated(label) ? Tr(label) : label}
InputLabelProps={{ shrink: needShrink }}
InputProps={{
startAdornment:
(optionSelected && renderValue?.(optionSelected)) ||
(tooltip && <Tooltip title={tooltip} position='start' />)
}}
inputProps={{ 'data-cy': cy }}
error={Boolean(error)}
helperText={Boolean(error) && <ErrorHelper label={error?.message} />}
FormHelperTextProps={{ 'data-cy': `${cy}-error` }}
{...fieldProps}
>
{values?.map(({ text, value = '' }) =>
<option key={`${name}-${value}`} value={value}>
{text}
</option>
)}
name={name}
control={control}
multiple={multiple}
/>
</TextField>
)
},
(prevProps, nextProps) =>
@ -87,28 +99,14 @@ SelectController.propTypes = {
control: PropTypes.object,
cy: PropTypes.string,
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
label: PropTypes.any,
tooltip: PropTypes.any,
multiple: PropTypes.bool,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
tooltip: PropTypes.string,
error: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.objectOf(PropTypes.any)
]),
renderValue: PropTypes.func,
fieldProps: PropTypes.object
}
SelectController.defaultProps = {
control: {},
cy: 'cy',
name: '',
label: '',
multiple: false,
values: [],
error: false,
fieldProps: undefined
}
SelectController.displayName = 'SelectController'
export default SelectController

View File

@ -17,60 +17,71 @@ import { memo } from 'react'
import PropTypes from 'prop-types'
import { Typography, TextField, Slider, FormHelperText, Grid } from '@mui/material'
import { Controller } from 'react-hook-form'
import { useController } from 'react-hook-form'
import { ErrorHelper } from 'client/components/FormControl'
import { Tr } from 'client/components/HOC'
import { Tr, labelCanBeTranslated } from 'client/components/HOC'
import { generateKey } from 'client/utils'
const SliderController = memo(
({ control, cy, name, label, error, fieldProps }) => (
<>
<Typography id={`slider-${name}`} gutterBottom>
{Tr(label)}
</Typography>
<Controller
render={({ value, onChange, onBlur }) =>
<Grid container spacing={2} alignItems='center'>
<Grid item xs>
<Slider
color='secondary'
value={typeof value === 'number' ? value : 0}
aria-labelledby={`slider-${name}`}
valueLabelDisplay='auto'
data-cy={`${cy}-slider`}
{...fieldProps}
onChange={(_, val) => onChange(val)}
/>
</Grid>
<Grid item>
<TextField
fullWidth
value={value ?? ''}
error={Boolean(error)}
type='number'
inputProps={{
'data-cy': `${cy}-input`,
'aria-labelledby': `slider-${name}`,
...fieldProps
}}
onChange={evt => onChange(
evt.target.value === '' ? '0' : Number(evt.target.value)
)}
onBlur={onBlur}
/>
</Grid>
({
control,
cy = `slider-${generateKey()}`,
name = '',
label = '',
fieldProps = {}
}) => {
const {
field: { value, onChange, ...inputProps },
fieldState: { error }
} = useController({ name, control })
const sliderId = `${cy}-slider`
const inputId = `${cy}-input`
return (
<>
<Typography id={sliderId} gutterBottom>
{labelCanBeTranslated(label) ? Tr(label) : label}
</Typography>
<Grid container spacing={2} alignItems='center'>
<Grid item xs>
<Slider
color='secondary'
value={typeof value === 'number' ? value : 0}
aria-labelledby={sliderId}
valueLabelDisplay='auto'
data-cy={sliderId}
onChange={(_, val) => onChange(val)}
{...fieldProps}
/>
</Grid>
}
name={name}
control={control}
/>
{Boolean(error) && (
<FormHelperText data-cy={`${cy}-error`}>
<ErrorHelper label={error?.message} />
</FormHelperText>
)}
</>
),
<Grid item>
<TextField
{...inputProps}
fullWidth
value={value}
error={Boolean(error)}
type='number'
inputProps={{
'data-cy': inputId,
'aria-labelledby': sliderId,
...fieldProps
}}
onChange={evt => onChange(
evt.target.value === '' ? '0' : Number(evt.target.value)
)}
/>
</Grid>
</Grid>
{Boolean(error) && (
<FormHelperText data-cy={`${cy}-error`}>
<ErrorHelper label={error?.message} />
</FormHelperText>
)}
</>
)
},
(prevProps, nextProps) => prevProps.error === nextProps.error
)
@ -78,27 +89,13 @@ SliderController.propTypes = {
control: PropTypes.object,
cy: PropTypes.string,
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
label: PropTypes.any,
tooltip: PropTypes.any,
multiple: PropTypes.bool,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
error: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.objectOf(PropTypes.any)
]),
fieldProps: PropTypes.object
}
SliderController.defaultProps = {
control: {},
cy: 'cy',
name: '',
label: '',
multiple: false,
values: [],
error: false,
fieldProps: undefined
}
SliderController.displayName = 'SliderController'
export default SliderController

View File

@ -16,43 +16,62 @@
import { memo } from 'react'
import PropTypes from 'prop-types'
import { FormControl, FormControlLabel, Switch } from '@mui/material'
import { Controller } from 'react-hook-form'
import { styled, FormControl, FormControlLabel, FormHelperText, Switch } from '@mui/material'
import { useController } from 'react-hook-form'
import { ErrorHelper, Tooltip } from 'client/components/FormControl'
import { Tr } from 'client/components/HOC'
import { Tr, labelCanBeTranslated } from 'client/components/HOC'
import { generateKey } from 'client/utils'
const Label = styled('span')({
display: 'flex',
alignItems: 'center',
gap: '0.5em'
})
const SwitchController = memo(
({ control, cy, name, label, tooltip, error, fieldProps }) => (
<Controller
render={({ onChange, value = false }) => (
<FormControl error={Boolean(error)} margin='dense'>
<FormControlLabel
control={
<Switch
onChange={e => onChange(e.target.checked)}
name={name}
checked={Boolean(value)}
color='secondary'
inputProps={{ 'data-cy': cy }}
{...fieldProps}
/>
}
label={
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5em' }}>
{Tr(label)}
{tooltip && <Tooltip title={tooltip} />}
</span>
}
labelPlacement='end'
/>
{Boolean(error) && <ErrorHelper label={error?.message} />}
</FormControl>
)}
name={name}
control={control}
/>
),
({
control,
cy = `switch-${generateKey()}`,
name = '',
label = '',
tooltip,
fieldProps = {}
}) => {
const {
field: { value = false, onChange },
fieldState: { error }
} = useController({ name, control })
return (
<FormControl fullWidth error={Boolean(error)} margin='dense'>
<FormControlLabel
control={
<Switch
onChange={e => onChange(e.target.checked)}
name={name}
checked={Boolean(value)}
color='secondary'
inputProps={{ 'data-cy': cy }}
{...fieldProps}
/>
}
label={
<Label>
{labelCanBeTranslated(label) ? Tr(label) : label}
{tooltip && <Tooltip title={tooltip} />}
</Label>
}
labelPlacement='end'
/>
{Boolean(error) && (
<FormHelperText data-cy={`${cy}-error`}>
<ErrorHelper label={error?.message} />
</FormHelperText>
)}
</FormControl>
)
},
(prevProps, nextProps) => prevProps.error === nextProps.error
)
@ -60,24 +79,11 @@ SwitchController.propTypes = {
control: PropTypes.object,
cy: PropTypes.string,
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([PropTypes.string, PropTypes.node]),
tooltip: PropTypes.string,
error: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.objectOf(PropTypes.any)
]),
label: PropTypes.any,
tooltip: PropTypes.any,
fieldProps: PropTypes.object
}
SwitchController.defaultProps = {
control: {},
cy: 'cy',
name: '',
label: '',
values: [],
error: false
}
SwitchController.displayName = 'SwitchController'
export default SwitchController

View File

@ -17,47 +17,61 @@ import { memo } from 'react'
import PropTypes from 'prop-types'
import { Typography } from '@mui/material'
import { Controller } from 'react-hook-form'
import { useController } from 'react-hook-form'
import { ErrorHelper, Tooltip } from 'client/components/FormControl'
import { Tr } from 'client/components/HOC'
import { Tr, labelCanBeTranslated } from 'client/components/HOC'
import { generateKey } from 'client/utils'
const defaultGetRowId = item => typeof item === 'object' ? item?.id ?? item?.ID : item
const TableController = memo(
({ control, cy, name, Table, singleSelect = true, getRowId = defaultGetRowId, label, tooltip, error, formContext }) => {
({
control,
cy = `table-${generateKey()}`,
name = '',
label = '',
tooltip,
Table,
singleSelect = true,
getRowId = defaultGetRowId,
formContext = {}
}) => {
const { clearErrors } = formContext
const {
field: { onChange },
fieldState: { error }
} = useController({ name, control })
return (
<>
{error ? (
<ErrorHelper data-cy={`${cy}-error`} label={error?.message} mb={2} />
<ErrorHelper
data-cy={`${cy}-error`}
label={error?.message}
mb={2}
/>
) : (
label && (
<Typography variant='body1' mb={2}>
{tooltip && <Tooltip title={tooltip} position='start' />}
{typeof label === 'string' ? Tr(label) : label}
{labelCanBeTranslated(label) ? Tr(label) : label}
</Typography>
)
)}
<Controller
render={({ onChange }) => (
<Table
pageSize={4}
singleSelect={singleSelect}
onlyGlobalSearch
onlyGlobalSelectedRows
getRowId={getRowId}
onSelectedRowsChange={rows => {
const rowValues = rows?.map(({ original }) => getRowId(original))
<Table
pageSize={4}
singleSelect={singleSelect}
onlyGlobalSearch
onlyGlobalSelectedRows
getRowId={getRowId}
onSelectedRowsChange={rows => {
const rowValues = rows?.map(({ original }) => getRowId(original))
onChange(singleSelect ? rowValues?.[0] : rowValues)
clearErrors(name)
}}
/>
)}
name={name}
control={control}
onChange(singleSelect ? rowValues?.[0] : rowValues)
clearErrors(name)
}}
/>
</>
)
@ -76,15 +90,8 @@ TableController.propTypes = {
Table: PropTypes.any,
getRowId: PropTypes.func,
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([
PropTypes.string,
PropTypes.node
]),
tooltip: PropTypes.string,
error: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.objectOf(PropTypes.any)
]),
label: PropTypes.any,
tooltip: PropTypes.any,
fieldProps: PropTypes.object,
formContext: PropTypes.shape({
setValue: PropTypes.func,

View File

@ -13,45 +13,74 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo } from 'react'
import { memo, useEffect } from 'react'
import PropTypes from 'prop-types'
import { TextField } from '@mui/material'
import { Controller } from 'react-hook-form'
import { useController, useWatch } from 'react-hook-form'
import { ErrorHelper, Tooltip } from 'client/components/FormControl'
import { Tr } from 'client/components/HOC'
import { Tr, labelCanBeTranslated } from 'client/components/HOC'
import { generateKey } from 'client/utils'
const TextController = memo(
({ control, cy, type, multiline, name, label, tooltip, error, fieldProps }) => (
<Controller
render={({ value, ...controllerProps }) =>
<TextField
fullWidth
multiline={multiline}
value={value ?? ''}
type={type}
label={typeof label === 'string' ? Tr(label) : label}
InputProps={{
endAdornment: tooltip && <Tooltip title={tooltip} />
}}
inputProps={{ 'data-cy': cy, ...fieldProps }}
error={Boolean(error)}
helperText={Boolean(error) && <ErrorHelper label={error?.message} />}
FormHelperTextProps={{ 'data-cy': `${cy}-error` }}
{...controllerProps}
{...fieldProps}
/>
({
control,
cy = `input-${generateKey()}`,
name = '',
label = '',
type = 'text',
multiline = false,
tooltip,
watcher,
dependencies,
fieldProps = {}
}) => {
const watch = dependencies && useWatch({
control,
name: dependencies,
disabled: dependencies === null
})
const {
field: { ref, value = '', onChange, ...inputProps },
fieldState: { error }
} = useController({ name, control })
useEffect(() => {
if (watch && watcher) {
const watcherValue = watcher(watch)
watcherValue && onChange(watcherValue)
}
name={name}
control={control}
/>
),
}, [watch])
return (
<TextField
{...inputProps}
fullWidth
inputRef={ref}
value={value}
onChange={onChange}
multiline={multiline}
type={type}
label={labelCanBeTranslated(label) ? Tr(label) : label}
InputProps={{
endAdornment: tooltip && <Tooltip title={tooltip} />
}}
inputProps={{ 'data-cy': cy, ...fieldProps }}
error={Boolean(error)}
helperText={Boolean(error) && <ErrorHelper label={error?.message} />}
FormHelperTextProps={{ 'data-cy': `${cy}-error` }}
{...fieldProps}
/>
)
},
(prevProps, nextProps) =>
prevProps.error === nextProps.error &&
prevProps.type === nextProps.type &&
prevProps.label === nextProps.label &&
prevProps.tooltip === nextProps.tooltip
prevProps.tooltip === nextProps.tooltip &&
prevProps.fieldProps?.value === nextProps.fieldProps?.value
)
TextController.propTypes = {
@ -60,14 +89,12 @@ TextController.propTypes = {
type: PropTypes.string,
multiline: PropTypes.bool,
name: PropTypes.string.isRequired,
label: PropTypes.oneOfType([
PropTypes.string,
PropTypes.node
]),
tooltip: PropTypes.string,
error: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.objectOf(PropTypes.any)
label: PropTypes.any,
tooltip: PropTypes.any,
watcher: PropTypes.func,
dependencies: PropTypes.oneOfType([
PropTypes.strin,
PropTypes.arrayOf(PropTypes.string)
]),
fieldProps: PropTypes.object,
formContext: PropTypes.shape({
@ -79,16 +106,6 @@ TextController.propTypes = {
})
}
TextController.defaultProps = {
control: {},
cy: 'cy',
type: 'text',
multiline: false,
name: '',
label: '',
error: false
}
TextController.displayName = 'TextController'
export default TextController

View File

@ -73,7 +73,7 @@ const TimeController = memo(
inputProps={{ 'data-cy': cy }}
inputRef={ref}
error={Boolean(error)}
helperText={Boolean(error) && <ErrorHelper label={error?.message} />}
helperText={Boolean(error) && <ErrorHelper label={error} />}
FormHelperTextProps={{ 'data-cy': `${cy}-error` }}
{...fieldProps}
/>
@ -94,11 +94,9 @@ TimeController.propTypes = {
cy: PropTypes.string,
multiline: PropTypes.bool,
name: PropTypes.string.isRequired,
label: PropTypes.string,
error: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.objectOf(PropTypes.any)
]),
label: PropTypes.any,
tooltip: PropTypes.any,
error: PropTypes.any,
fieldProps: PropTypes.object,
formContext: PropTypes.shape({
setValue: PropTypes.func,

View File

@ -17,32 +17,41 @@ import { memo } from 'react'
import PropTypes from 'prop-types'
import { TextField } from '@mui/material'
import { Controller } from 'react-hook-form'
import { useController } from 'react-hook-form'
import { Tr } from 'client/components/HOC'
import { ErrorHelper } from 'client/components/FormControl'
import { Tr, labelCanBeTranslated } from 'client/components/HOC'
import { generateKey } from 'client/utils'
const TimeController = memo(
({ control, cy, name, type, label, error, fieldProps }) => (
<Controller
render={({ value, ...props }) =>
<TextField
{...props}
fullWidth
value={value}
{...(label && { label: Tr(label) })}
type={type}
inputProps={{ 'data-cy': cy, ...fieldProps }}
InputLabelProps={{ shrink: true }}
error={Boolean(error)}
helperText={Boolean(error) && <ErrorHelper label={error?.message} />}
FormHelperTextProps={{ 'data-cy': `${cy}-error` }}
/>
}
name={name}
control={control}
/>
),
({
control,
cy = `datetime-${generateKey()}`,
name = '',
label = '',
type = 'datetime-local',
fieldProps = {}
}) => {
const {
field: { ref, value, ...inputProps },
fieldState: { error }
} = useController({ name, control })
return (
<TextField
{...inputProps}
fullWidth
label={labelCanBeTranslated(label) ? Tr(label) : label}
inputRef={ref}
type={type}
inputProps={{ 'data-cy': cy, ...fieldProps }}
InputLabelProps={{ shrink: true }}
error={Boolean(error)}
helperText={Boolean(error) && <ErrorHelper label={error?.message} />}
FormHelperTextProps={{ 'data-cy': `${cy}-error` }}
/>
)
},
(prevProps, nextProps) =>
prevProps.error === nextProps.error &&
prevProps.label === nextProps.label
@ -53,12 +62,9 @@ TimeController.propTypes = {
cy: PropTypes.string,
multiline: PropTypes.bool,
name: PropTypes.string.isRequired,
label: PropTypes.string,
label: PropTypes.any,
tooltip: PropTypes.any,
type: PropTypes.string,
error: PropTypes.oneOfType([
PropTypes.bool,
PropTypes.objectOf(PropTypes.any)
]),
fieldProps: PropTypes.object,
formContext: PropTypes.shape({
setValue: PropTypes.func,
@ -69,16 +75,6 @@ TimeController.propTypes = {
})
}
TimeController.defaultProps = {
control: {},
cy: 'cy',
name: '',
label: '',
type: 'datetime-local',
error: false,
fieldProps: undefined
}
TimeController.displayName = 'TimeController'
export default TimeController

View File

@ -0,0 +1,120 @@
/* ------------------------------------------------------------------------- *
* 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 { memo, useEffect } from 'react'
import PropTypes from 'prop-types'
import {
styled,
FormControl,
ToggleButtonGroup,
ToggleButton,
FormHelperText
} from '@mui/material'
import { useController } from 'react-hook-form'
import { ErrorHelper, Tooltip } from 'client/components/FormControl'
import { Tr, labelCanBeTranslated } from 'client/components/HOC'
import { generateKey } from 'client/utils'
const Label = styled('label')(({ theme, error }) => ({
display: 'flex',
alignItems: 'center',
gap: '1em',
...(error && {
color: theme.palette.error.main
})
}))
const ToggleController = memo(
({
control,
cy = `toggle-${generateKey()}`,
name = '',
label = '',
multiple = false,
values = [],
tooltip,
fieldProps = {}
}) => {
const defaultValue = multiple ? [values?.[0]?.value] : values?.[0]?.value
const {
field: { ref, value: optionSelected = defaultValue, onChange, ...inputProps },
fieldState: { error }
} = useController({ name, control })
useEffect(() => {
if (optionSelected) {
const exists = values?.find(option => option.value === optionSelected)
!exists && onChange()
}
}, [])
return (
<FormControl fullWidth margin='dense'>
{label && (
<Label htmlFor={cy} error={error ? 'error' : undefined}>
{labelCanBeTranslated(label) ? Tr(label) : label}
{tooltip && <Tooltip title={tooltip} />}
</Label>
)}
<ToggleButtonGroup
{...inputProps}
onChange={(_, newValues) => onChange(newValues)}
ref={ref}
id={cy}
value={optionSelected}
fullWidth
exclusive={!multiple}
data-cy={cy}
{...fieldProps}
>
{values?.map(({ text, value = '' }) =>
<ToggleButton key={`${name}-${value}`} value={value}>
{text}
</ToggleButton>
)}
</ToggleButtonGroup>
{Boolean(error) && (
<FormHelperText data-cy={`${cy}-error`}>
<ErrorHelper label={error?.message} />
</FormHelperText>
)}
</FormControl>
)
},
(prevProps, nextProps) =>
prevProps.error === nextProps.error &&
prevProps.values.length === nextProps.values.length &&
prevProps.label === nextProps.label &&
prevProps.tooltip === nextProps.tooltip
)
ToggleController.propTypes = {
control: PropTypes.object,
cy: PropTypes.string,
name: PropTypes.string.isRequired,
label: PropTypes.any,
tooltip: PropTypes.any,
multiple: PropTypes.bool,
values: PropTypes.arrayOf(PropTypes.object).isRequired,
renderValue: PropTypes.func,
fieldProps: PropTypes.object
}
ToggleController.displayName = 'ToggleController'
export default ToggleController

View File

@ -18,25 +18,40 @@ import PropTypes from 'prop-types'
import { QuestionMarkCircle } from 'iconoir-react'
import { InputAdornment, Typography, Tooltip } from '@mui/material'
import { Tr, labelCanBeTranslated } from 'client/components/HOC'
const AdornmentWithTooltip = memo(({ title, position = 'end', children }) => (
<Tooltip
arrow
placement='bottom'
title={
<Typography variant='subtitle2'>
{title}
</Typography>
}
>
<InputAdornment position={position} style={{ cursor: 'help' }}>
{children ?? <QuestionMarkCircle />}
</InputAdornment>
</Tooltip>
), (prevProps, nextProps) => prevProps.title === nextProps.title)
const AdornmentWithTooltip = memo(({ title, position = 'end', children }) => {
if (!title || title === '' || (Array.isArray(title) && title.length === 0)) {
return null
}
return (
<Tooltip
arrow
placement='bottom'
title={
<Typography variant='subtitle2'>
{labelCanBeTranslated(title) ? Tr(title) : title}
</Typography>
}
>
<InputAdornment position={position} style={{ cursor: 'help' }}>
{children ?? <QuestionMarkCircle />}
</InputAdornment>
</Tooltip>
)
}, (prevProps, nextProps) =>
Array.isArray(nextProps.title)
? prevProps.title?.[0] === nextProps.title?.[0] || prevProps.title === nextProps.title?.[0]
: prevProps.title === nextProps.title
)
AdornmentWithTooltip.propTypes = {
title: PropTypes.string,
title: PropTypes.oneOfType([
PropTypes.string,
PropTypes.node,
PropTypes.object
]),
children: PropTypes.any,
position: PropTypes.oneOf(['start', 'end'])
}

View File

@ -23,6 +23,7 @@ import SwitchController from 'client/components/FormControl/SwitchController'
import TableController from 'client/components/FormControl/TableController'
import TextController from 'client/components/FormControl/TextController'
import TimeController from 'client/components/FormControl/TimeController'
import ToggleController from 'client/components/FormControl/ToggleController'
import SubmitButton, { SubmitButtonPropTypes } from 'client/components/FormControl/SubmitButton'
import InputCode from 'client/components/FormControl/InputCode'
@ -40,6 +41,7 @@ export {
TableController,
TextController,
TimeController,
ToggleController,
SubmitButton,
SubmitButtonPropTypes,

View File

@ -20,7 +20,7 @@ import { Button, MobileStepper, Typography, Box, alpha } from '@mui/material'
import makeStyles from '@mui/styles/makeStyles'
import { NavArrowLeft as PreviousIcon, NavArrowRight as NextIcon } from 'iconoir-react'
import { Tr } from 'client/components/HOC'
import { Tr, Translate, labelCanBeTranslated } from 'client/components/HOC'
import { T } from 'client/constants'
const useStyles = makeStyles(theme => ({
@ -57,7 +57,7 @@ const CustomMobileStepper = ({
<Box className={classes.root}>
<Box minHeight={60}>
<Typography className={classes.title}>
{typeof label === 'string' ? Tr(label) : label}
{labelCanBeTranslated(label) ? Tr(label) : label}
</Typography>
{Boolean(errors[id]) && (
<Typography className={classes.error} variant='caption' color='error'>
@ -79,12 +79,16 @@ const CustomMobileStepper = ({
onClick={handleBack}
disabled={disabledBack}
>
<PreviousIcon /> {Tr(T.Back)}
<PreviousIcon />
<Translate word={T.Back} />
</Button>
}
nextButton={
<Button className={classes.button} size='small' onClick={handleNext}>
{activeStep === lastStep ? Tr(T.Finish) : Tr(T.Next)}
{activeStep === lastStep
? <Translate word={T.Finish} />
: <Translate word={T.Next} />
}
<NextIcon />
</Button>
}

View File

@ -26,7 +26,7 @@ import StepConnector, { stepConnectorClasses } from '@mui/material/StepConnector
import { styled } from '@mui/styles'
import { SubmitButton } from 'client/components/FormControl'
import { Tr, Translate } from 'client/components/HOC'
import { Tr, Translate, labelCanBeTranslated } from 'client/components/HOC'
import { T, SCHEMES } from 'client/constants'
const StepperStyled = styled(Stepper)(({ theme }) => ({
@ -65,10 +65,7 @@ const ConnectorStyled = styled(StepConnector)(({ theme }) => ({
const StepIconStyled = styled(StepIcon)(({ theme }) => ({
color: theme.palette.text.hint,
display: 'block',
[`&.${stepIconClasses.completed}`]: {
color: theme.palette.secondary[700]
},
[`&.${stepIconClasses.active}`]: {
[`&.${stepIconClasses.completed}, &.${stepIconClasses.active}`]: {
color: theme.palette.secondary[700]
},
[`&.${stepIconClasses.error}`]: {
@ -104,7 +101,7 @@ const CustomStepper = ({
StepIconComponent={StepIconStyled}
error={Boolean(errors[id]?.message)}
>
{typeof label === 'string' ? Tr(label) : label}
{labelCanBeTranslated(label) ? Tr(label) : label}
</StepLabel>
</StepButton>
</Step>

View File

@ -18,13 +18,14 @@ import PropTypes from 'prop-types'
import { BaseSchema } from 'yup'
import { useFormContext } from 'react-hook-form'
import { DevTool } from '@hookform/devtools'
import { useMediaQuery } from '@mui/material'
import { useGeneral } from 'client/features/General'
import CustomMobileStepper from 'client/components/FormStepper/MobileStepper'
import CustomStepper from 'client/components/FormStepper/Stepper'
import SkeletonStepsForm from 'client/components/FormStepper/Skeleton'
import { groupBy, Step } from 'client/utils'
import { groupBy, Step, isDevelopment } from 'client/utils'
const FIRST_STEP = 0
@ -40,7 +41,7 @@ const FIRST_STEP = 0
*/
const FormStepper = ({ steps = [], schema, onSubmit }) => {
const isMobile = useMediaQuery(theme => theme.breakpoints.only('xs'))
const { watch, reset, errors, setError } = useFormContext()
const { control, watch, reset, formState: { errors }, setError } = useFormContext()
const { isLoading } = useGeneral()
const [formData, setFormData] = useState(() => watch())
@ -51,7 +52,7 @@ const FormStepper = ({ steps = [], schema, onSubmit }) => {
const disabledBack = useMemo(() => activeStep === FIRST_STEP, [activeStep])
useEffect(() => {
reset({ ...formData }, { errors: false })
reset({ ...formData }, { keepErrors: false })
}, [formData])
const validateSchema = async stepIdx => {
@ -164,6 +165,8 @@ const FormStepper = ({ steps = [], schema, onSubmit }) => {
), [isLoading, isMobile, activeStep, errors[id]])}
{/* FORM CONTENT */}
{Content && <Content data={formData[id]} setFormData={setFormData} />}
{isDevelopment() && <DevTool control={control} placement='top-left' />}
</>
)
}

View File

@ -17,23 +17,14 @@
import { createElement, useMemo } from 'react'
import PropTypes from 'prop-types'
import { styled, Grid } from '@mui/material'
import { Grid, Typography } from '@mui/material'
import { useFormContext } from 'react-hook-form'
import * as FC from 'client/components/FormControl'
import { Tr } from 'client/components/HOC'
import { INPUT_TYPES } from 'client/constants'
import { get } from 'client/utils'
const Fieldset = styled('fieldset')({ border: 'none' })
const Legend = styled('legend')(({ theme }) => ({
...theme.typography.subtitle1,
marginBottom: '1em',
padding: '0em 1em 0.2em 0.5em',
borderBottom: `2px solid ${theme.palette.secondary.main}`
}))
const NOT_DEPEND_ATTRIBUTES = ['transform', 'Table']
const NOT_DEPEND_ATTRIBUTES = ['watcher', 'transform', 'Table', 'renderValue']
const INPUT_CONTROLLER = {
[INPUT_TYPES.TEXT]: FC.TextController,
@ -45,28 +36,39 @@ const INPUT_CONTROLLER = {
[INPUT_TYPES.AUTOCOMPLETE]: FC.AutocompleteController,
[INPUT_TYPES.FILE]: FC.FileController,
[INPUT_TYPES.TIME]: FC.TimeController,
[INPUT_TYPES.TABLE]: FC.TableController
[INPUT_TYPES.TABLE]: FC.TableController,
[INPUT_TYPES.TOGGLE]: FC.ToggleController
}
const FormWithSchema = ({ id, cy, fields, className, legend }) => {
const formContext = useFormContext()
const { control, errors, watch } = formContext
const { control, watch } = formContext
const getFields = useMemo(() => typeof fields === 'function' ? fields() : fields, [])
if (getFields.length === 0) return null
const addIdToName = name => name.startsWith('$')
? name.slice(1) // removes character '$' and returns
: id ? `${id}.${name}` : name // concat form ID if exists
return (
<Fieldset className={className}>
{legend && <Legend>{legend}</Legend>}
<fieldset className={className}>
{legend && (
<Typography variant='subtitle1' component='legend'>
{Tr(legend)}
</Typography>
)}
<Grid container spacing={1} alignContent='flex-start'>
{getFields?.map?.(
({ dependOf, ...attributes }) => {
let valueOfDependField = null
let nameOfDependField = null
if (dependOf) {
const nameOfDependField = id
? Array.isArray(dependOf) ? dependOf.map(d => `${id}.${d}`) : `${id}.${dependOf}`
: dependOf
nameOfDependField = Array.isArray(dependOf)
? dependOf.map(addIdToName)
: addIdToName(dependOf)
valueOfDependField = watch(nameOfDependField)
}
@ -78,16 +80,14 @@ const FormWithSchema = ({ id, cy, fields, className, legend }) => {
const isNotDependAttribute = NOT_DEPEND_ATTRIBUTES.includes(key)
const finalValue = typeof value === 'function' && !isNotDependAttribute
? value(valueOfDependField)
? value(valueOfDependField, formContext)
: value
return { ...field, [key]: finalValue }
}, {})
const dataCy = `${cy}-${name}`
const inputName = id ? `${id}.${name}` : name
const inputError = get(errors, inputName) ?? false
const inputName = addIdToName(name)
const isHidden = htmlType === INPUT_TYPES.HIDDEN
@ -99,8 +99,8 @@ const FormWithSchema = ({ id, cy, fields, className, legend }) => {
{createElement(INPUT_CONTROLLER[type], {
control,
cy: dataCy,
error: inputError,
formContext,
dependencies: nameOfDependField,
name: inputName,
type: htmlType === false ? undefined : htmlType,
...fieldProps
@ -111,7 +111,7 @@ const FormWithSchema = ({ id, cy, fields, className, legend }) => {
}
)}
</Grid>
</Fieldset>
</fieldset>
)
}

View File

@ -16,7 +16,7 @@
/* eslint-disable jsdoc/require-jsdoc */
import * as yup from 'yup'
import { INPUT_TYPES } from 'client/constants'
import { getValidationFromFields, isBase64, prettyBytes } from 'client/utils'
import { getValidationFromFields, prettyBytes } from 'client/utils'
const MAX_SIZE_JSON = 102_400
const JSON_FORMAT = 'application/json'
@ -29,11 +29,11 @@ export const FORM_FIELDS = ({ connection, fileCredentials }) =>
let validation = yup
.string()
.trim()
.required(`${name} field is required`)
.required()
.default(undefined)
if (isInputFile) {
validation = validation.test('is-base64', 'File has invalid format', isBase64)
validation = validation.isBase64()
}
return {

View File

@ -0,0 +1,228 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { SetStateAction } from 'react'
import PropTypes from 'prop-types'
import { useWatch } from 'react-hook-form'
import { NetworkAlt as NetworkIcon, BoxIso as ImageIcon } from 'iconoir-react'
import { Stack, Checkbox, styled } from '@mui/material'
import { DragDropContext, Draggable, Droppable, DropResult } from 'react-beautiful-dnd'
import { Translate } from 'client/components/HOC'
import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { TAB_ID as BOOTING_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting'
import { TAB_ID as STORAGE_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/storage'
import { TAB_ID as NIC_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/networking'
import { T } from 'client/constants'
export const BOOT_ORDER_ID = 'BOOT'
const BootItem = styled('div')(({ theme }) => ({
display: 'flex',
alignItems: 'center',
gap: '0.5em',
border: `1px solid ${theme.palette.divider}`,
borderRadius: '0.5em',
padding: '1em',
marginBottom: '1em',
backgroundColor: theme.palette.background.default
}))
const BootItemDraggable = styled(BootItem)(({ theme }) => ({
'&:before': {
content: "''",
display: 'block',
width: 16,
height: 10,
backgroundImage: `linear-gradient(
to bottom,
${theme.palette.action.active} 4px,
transparent 4px,
transparent 6px,
${theme.palette.action.active} 6px
)`
}
}))
/**
* @param {string} id - Resource id: 'NIC<index>' or 'DISK<index>'
* @param {Array} list - List of resources
* @param {object} formData - Form data
* @param {SetStateAction} setFormData - React set state action
*/
export const reorderBootAfterRemove = (id, list, formData, setFormData) => {
const type = String(id).toLowerCase().replace(/\d+/g, '') // nic | disk
const getIndexFromId = id => String(id).toLowerCase().replace(type, '')
const idxToRemove = getIndexFromId(id)
const ids = list
.filter(resource => resource.NAME !== id)
.map(resource => String(resource.NAME).toLowerCase())
const newBootOrder = [...formData?.[BOOTING_ID]?.[BOOT_ORDER_ID]?.split(',').filter(Boolean)]
.filter(bootId => !bootId.startsWith(type) || ids.includes(bootId))
.map(bootId => {
if (!bootId.startsWith(type)) return bootId
const resourceId = getIndexFromId(bootId)
return resourceId < idxToRemove
? bootId
: `${type}${resourceId - 1}`
})
reorder(newBootOrder, setFormData)
}
/**
* @param {string[]} newBootOrder - New boot order
* @param {SetStateAction} setFormData - React set state action
*/
const reorder = (newBootOrder, setFormData) => {
setFormData(prev => ({
...prev,
[EXTRA_ID]: {
...prev[EXTRA_ID],
[BOOTING_ID]: {
...prev[EXTRA_ID]?.[BOOTING_ID],
[BOOT_ORDER_ID]: newBootOrder.join(',')
}
}
}))
}
const BootOrder = ({ data, setFormData, control }) => {
const booting = useWatch({ name: `${EXTRA_ID}.${BOOTING_ID}.${BOOT_ORDER_ID}`, control })
const bootOrder = booting?.split(',').filter(Boolean) ?? []
const disks = data?.[STORAGE_ID]
?.map((disk, idx) => {
const isVolatile = !disk?.IMAGE && !disk?.IMAGE_ID
return {
ID: `disk${idx}`,
NAME: (
<>
<ImageIcon />
{isVolatile
? <>{`${disk?.NAME}: `}<Translate word={T.VolatileDisk} /></>
: [disk?.NAME, disk?.IMAGE].filter(Boolean).join(': ')}
</>
)
}
}) ?? []
const nics = data?.[NIC_ID]
?.map((nic, idx) => ({
ID: `nic${idx}`,
NAME: (
<>
<NetworkIcon />
{[nic?.NAME, nic.NETWORK].filter(Boolean).join(': ')}
</>
)
})) ?? []
const enabledItems = [...disks, ...nics]
.filter(item => bootOrder.includes(item.ID))
.sort((a, b) => bootOrder.indexOf(a.ID) - bootOrder.indexOf(b.ID))
const restOfItems = [...disks, ...nics]
.filter(item => !bootOrder.includes(item.ID))
/** @param {DropResult} result - Drop result */
const onDragEnd = result => {
const { destination, source, draggableId } = result
const newBootOrder = [...bootOrder]
if (
destination &&
destination.index !== source.index &&
newBootOrder.includes(draggableId)
) {
newBootOrder.splice(source.index, 1) // remove current position
newBootOrder.splice(destination.index, 0, draggableId) // set in new position
reorder(newBootOrder, setFormData)
}
}
const handleEnable = itemId => {
const newBootOrder = [...bootOrder]
const itemIndex = bootOrder.indexOf(itemId)
itemIndex >= 0
? newBootOrder.splice(itemIndex, 1)
: newBootOrder.push(itemId)
reorder(newBootOrder, setFormData)
}
return (
<DragDropContext onDragEnd={onDragEnd}>
<Stack>
<Droppable droppableId='booting'>
{({ droppableProps, innerRef, placeholder }) => (
<Stack {...droppableProps} ref={innerRef} m={2}>
{enabledItems.map(({ ID, NAME }, idx) => (
<Draggable key={ID} draggableId={ID} index={idx}>
{({ draggableProps, dragHandleProps, innerRef }) => (
<BootItemDraggable
{...draggableProps}
{...dragHandleProps}
ref={innerRef}
>
<Checkbox
checked
color='secondary'
data-cy={ID}
onChange={() => handleEnable(ID)}
/>
{NAME}
</BootItemDraggable>
)}
</Draggable>
))}
{placeholder}
</Stack>
)}
</Droppable>
{restOfItems.map(({ ID, NAME }) => (
<BootItem key={ID}>
<Checkbox
color='secondary'
data-cy={ID}
onChange={() => handleEnable(ID)}
/>
{NAME}
</BootItem>
))}
</Stack>
</DragDropContext>
)
}
BootOrder.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
hypervisor: PropTypes.string,
control: PropTypes.object
}
BootOrder.displayName = 'BootOrder'
export default BootOrder

View File

@ -0,0 +1,219 @@
/* ------------------------------------------------------------------------- *
* 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 { string, boolean } from 'yup'
import { useHost } from 'client/features/One'
import { getKvmMachines, getKvmCpuModels } from 'client/models/Host'
import { Field, arrayToOptions, filterFieldsByHypervisor } from 'client/utils'
import {
T,
INPUT_TYPES,
CPU_ARCHITECTURES,
SD_DISK_BUSES,
FIRMWARE_TYPES,
KVM_FIRMWARE_TYPES,
VCENTER_FIRMWARE_TYPES,
HYPERVISORS
} from 'client/constants'
const { vcenter, firecracker, lxc, kvm } = HYPERVISORS
/** @type {Field} CPU architecture field */
export const ARCH = {
name: 'OS.ARCH',
label: T.CpuArchitecture,
notOnHypervisors: [vcenter, firecracker, lxc],
type: INPUT_TYPES.SELECT,
values: () => arrayToOptions(CPU_ARCHITECTURES),
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} Bus for SD disks field */
export const SD_DISK_BUS = {
name: 'OS.SD_DISK_BUS',
label: T.BusForSdDisks,
notOnHypervisors: [vcenter, firecracker, lxc],
type: INPUT_TYPES.SELECT,
values: arrayToOptions(SD_DISK_BUSES, { getText: o => o.toUpperCase() }),
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} Machine type field */
export const MACHINE_TYPES = {
name: 'OS.MACHINE',
label: T.MachineType,
notOnHypervisors: [vcenter, firecracker, lxc],
type: INPUT_TYPES.SELECT,
values: () => {
const hosts = useHost()
const kvmMachines = getKvmMachines(hosts)
return arrayToOptions(kvmMachines)
},
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} CPU Model field */
export const CPU_MODEL = {
name: 'OS.MODEL',
label: T.CpuModel,
notOnHypervisors: [vcenter, firecracker, lxc],
type: INPUT_TYPES.SELECT,
values: () => {
const hosts = useHost()
const kvmCpuModels = getKvmCpuModels(hosts)
return arrayToOptions(kvmCpuModels)
},
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} Root device field */
export const ROOT_DEVICE = {
name: 'OS.ROOT',
label: T.RootDevice,
notOnHypervisors: [vcenter, firecracker, lxc],
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} Kernel CMD field */
export const KERNEL_CMD = {
name: 'OS.KERNEL_CMD',
label: T.KernelBootParameters,
notOnHypervisors: [vcenter, lxc],
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
fieldProps: { placeholder: 'ro console=tty1' }
}
/** @type {Field} Path bootloader field */
export const BOOTLOADER = {
name: 'OS.BOOTLOADER',
label: T.PathBootloader,
notOnHypervisors: [vcenter, lxc],
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { md: 12 }
}
/** @type {Field} UUID field */
export const UUID = {
name: 'OS.UUID',
label: T.UniqueIdOfTheVm,
tooltip: T.UniqueIdOfTheVmConcept,
notOnHypervisors: [firecracker, lxc],
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { md: 12 }
}
/** @type {Field} Feature custom field */
export const FEATURE_CUSTOM_ENABLED = {
name: 'OS.FEATURE_CUSTOM_ENABLED',
label: T.CustomPath,
notOnHypervisors: [vcenter, firecracker, lxc],
type: INPUT_TYPES.SWITCH,
validation: boolean().strip().default(() => false),
grid: { md: 12 }
}
/** @type {Field} Firmware field */
export const FIRMWARE = {
name: 'OS.FIRMWARE',
label: T.Firmware,
tooltip: T.FirmwareConcept,
notOnHypervisors: [firecracker, lxc],
type: ([_, custom]) => custom ? INPUT_TYPES.TEXT : INPUT_TYPES.SELECT,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
dependOf: ['$general.HYPERVISOR', FEATURE_CUSTOM_ENABLED.name],
values: ([hypervisor] = []) => {
const types = {
[vcenter]: VCENTER_FIRMWARE_TYPES,
[kvm]: KVM_FIRMWARE_TYPES
}[hypervisor] ?? FIRMWARE_TYPES
return arrayToOptions(types)
},
grid: { md: 12 }
}
/** @type {Field} Feature secure field */
export const FIRMWARE_SECURE = {
name: 'OS.FIRMWARE_SECURE',
label: T.FirmwareSecure,
notOnHypervisors: [vcenter, firecracker, lxc],
type: INPUT_TYPES.CHECKBOX,
dependOf: FEATURE_CUSTOM_ENABLED.name,
htmlType: custom => !custom && INPUT_TYPES.HIDDEN,
validation: boolean()
.default(() => false)
.transform(value => {
if (typeof value === 'boolean') return value
return String(value).toUpperCase() === 'YES'
}),
grid: { md: 12 }
}
/**
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} List of Boot fields
*/
export const BOOT_FIELDS = hypervisor =>
filterFieldsByHypervisor(
[
ARCH,
SD_DISK_BUS,
MACHINE_TYPES,
CPU_MODEL,
ROOT_DEVICE,
KERNEL_CMD,
BOOTLOADER,
UUID,
FEATURE_CUSTOM_ENABLED,
FIRMWARE,
FIRMWARE_SECURE
],
hypervisor
)

View File

@ -0,0 +1,150 @@
/* ------------------------------------------------------------------------- *
* 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 { string, number } from 'yup'
import { Field, arrayToOptions, filterFieldsByHypervisor } from 'client/utils'
import { T, INPUT_TYPES, HYPERVISORS } from 'client/constants'
const { vcenter, lxc, firecracker } = HYPERVISORS
/** @type {Field} ACPI field */
export const ACPI = {
name: 'OS.ACPI',
label: T.Acpi,
tooltip: T.AcpiConcept,
notOnHypervisors: [vcenter, lxc, firecracker],
type: INPUT_TYPES.SELECT,
values: arrayToOptions([T.Yes, T.No]),
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} PAE field */
export const PAE = {
name: 'OS.PAE',
label: T.Pae,
tooltip: T.PaeConcept,
notOnHypervisors: [vcenter, lxc, firecracker],
type: INPUT_TYPES.SELECT,
values: arrayToOptions([T.Yes, T.No]),
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} APIC field */
export const APIC = {
name: 'OS.APIC',
label: T.Apic,
tooltip: T.ApicConcept,
notOnHypervisors: [vcenter, lxc, firecracker],
type: INPUT_TYPES.SELECT,
values: arrayToOptions([T.Yes, T.No]),
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} HYPER-V field */
export const HYPERV = {
name: 'OS.HYPERV',
label: T.Hyperv,
tooltip: T.HypervConcept,
notOnHypervisors: [vcenter, lxc, firecracker],
type: INPUT_TYPES.SELECT,
values: arrayToOptions([T.Yes, T.No]),
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} Local time field */
export const LOCALTIME = {
name: 'OS.LOCALTIME',
label: T.Localtime,
tooltip: T.LocaltimeConcept,
notOnHypervisors: [vcenter, lxc, firecracker],
type: INPUT_TYPES.SELECT,
values: arrayToOptions([T.Yes, T.No]),
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} Guest agent field */
export const GUEST_AGENT = {
name: 'OS.GUEST_AGENT',
label: T.GuestAgent,
tooltip: T.GuestAgentConcept,
notOnHypervisors: [vcenter, lxc, firecracker],
type: INPUT_TYPES.SELECT,
values: arrayToOptions([T.Yes, T.No]),
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} Virtio-SCSI queues field */
export const VIRTIO_SCSI_QUEUES = {
name: 'OS.VIRTIO_SCSI_QUEUES',
label: T.VirtioQueues,
tooltip: T.VirtioQueuesConcept,
notOnHypervisors: [vcenter, lxc, firecracker],
type: INPUT_TYPES.SELECT,
values: arrayToOptions(Array.from({ length: 16 }, (_, i) => i + 1)),
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} IO threads field */
export const IO_THREADS = {
name: 'OS.IOTHREADS',
label: T.IoThreads,
tooltip: T.IoThreadsConcept,
notOnHypervisors: [vcenter, lxc, firecracker],
type: INPUT_TYPES.TEXT,
htmlType: 'number',
validation: number()
.default(() => undefined)
}
/**
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} List of Features fields
*/
export const FEATURES_FIELDS = hypervisor =>
filterFieldsByHypervisor(
[
ACPI,
PAE,
APIC,
HYPERV,
LOCALTIME,
GUEST_AGENT,
VIRTIO_SCSI_QUEUES,
IO_THREADS
],
hypervisor
)

View File

@ -0,0 +1,102 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
import { Stack, Typography } from '@mui/material'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import { STEP_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import BootOrder, { reorderBootAfterRemove } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting/bootOrder'
import { TAB_ID as STORAGE_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/storage'
import { TAB_ID as NIC_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/networking'
import { BOOT_FIELDS, KERNEL_FIELDS, RAMDISK_FIELDS, FEATURES_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting/schema'
import { Translate } from 'client/components/HOC'
import { T } from 'client/constants'
import AdornmentWithTooltip from 'client/components/FormControl/Tooltip'
export const TAB_ID = 'OS'
const Booting = props => {
const { hypervisor } = props
return (
<>
{(
!!props.data?.[STORAGE_ID]?.length ||
!!props.data?.[NIC_ID]?.length
) && (
<fieldset>
<Typography
variant='subtitle1'
component='legend'
sx={{
display: 'inline-flex',
alignItems: 'center'
}}
>
<Translate word={T.BootOrder} />
<AdornmentWithTooltip title={T.BootOrderConcept} />
</Typography>
<BootOrder {...props} />
</fieldset>
)}
<Stack
display='grid'
gap='2em'
sx={{ gridTemplateColumns: { sm: '1fr', md: '1fr 1fr' } }}
>
<FormWithSchema
cy='create-vm-template-extra.os-boot'
fields={BOOT_FIELDS(hypervisor)}
legend={T.Boot}
id={STEP_ID}
/>
<FormWithSchema
cy='create-vm-template-extra.os-features'
fields={FEATURES_FIELDS(hypervisor)}
legend={T.Features}
id={STEP_ID}
/>
<FormWithSchema
cy='create-vm-template-extra.os-kernel'
fields={KERNEL_FIELDS(hypervisor)}
legend={T.Kernel}
id={STEP_ID}
/>
<FormWithSchema
cy='create-vm-template-extra.os-ramdisk'
fields={RAMDISK_FIELDS(hypervisor)}
legend={T.Ramdisk}
id={STEP_ID}
/>
</Stack>
</>
)
}
Booting.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
hypervisor: PropTypes.string,
control: PropTypes.object
}
Booting.displayName = 'Booting'
export default Booting
export { reorderBootAfterRemove }

View File

@ -0,0 +1,84 @@
/* ------------------------------------------------------------------------- *
* 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, string } from 'yup'
import { useImage } from 'client/features/One'
import { getType } from 'client/models/Image'
import { Field, clearNames, filterFieldsByHypervisor } from 'client/utils'
import { T, INPUT_TYPES, HYPERVISORS, IMAGE_TYPES_STR } from 'client/constants'
const { vcenter, lxc } = HYPERVISORS
const kernelValidation = string().trim().notRequired().default(() => undefined)
/** @type {Field} Kernel path field */
export const KERNEL_PATH_ENABLED = {
name: 'OS.KERNEL_PATH_ENABLED',
label: T.CustomPath,
notOnHypervisors: [vcenter, lxc],
type: INPUT_TYPES.SWITCH,
validation: boolean().strip().default(() => false)
}
/** @type {Field} Kernel DS field */
export const KERNEL_DS = {
name: 'OS.KERNEL_DS',
label: T.Kernel,
notOnHypervisors: [vcenter, lxc],
type: INPUT_TYPES.AUTOCOMPLETE,
dependOf: KERNEL_PATH_ENABLED.name,
htmlType: enabled => enabled && INPUT_TYPES.HIDDEN,
values: () => {
const images = useImage()
return images
?.filter(image => getType(image) === IMAGE_TYPES_STR.KERNEL)
?.map(({ ID, NAME }) => ({ text: `#${ID} ${NAME}`, value: `$FILE[IMAGE_ID=${ID}]` }))
?.sort((a, b) => {
const compareOptions = { numeric: true, ignorePunctuation: true }
return a.value.localeCompare(b.value, undefined, compareOptions)
})
},
validation: kernelValidation.when(
clearNames(KERNEL_PATH_ENABLED.name),
(enabled, schema) => enabled ? schema.strip() : schema
)
}
/** @type {Field} Kernel path field */
export const KERNEL = {
name: 'OS.KERNEL',
label: T.KernelPath,
notOnHypervisors: [vcenter, lxc],
type: INPUT_TYPES.TEXT,
dependOf: KERNEL_PATH_ENABLED.name,
htmlType: enabled => !enabled && INPUT_TYPES.HIDDEN,
validation: kernelValidation.when(
clearNames(KERNEL_PATH_ENABLED.name),
(enabled, schema) => enabled ? schema : schema.strip()
)
}
/**
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} List of Kernel fields
*/
export const KERNEL_FIELDS = hypervisor =>
filterFieldsByHypervisor(
[KERNEL_PATH_ENABLED, KERNEL, KERNEL_DS],
hypervisor
)

View File

@ -0,0 +1,84 @@
/* ------------------------------------------------------------------------- *
* 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 { string, boolean } from 'yup'
import { useImage } from 'client/features/One'
import { getType } from 'client/models/Image'
import { Field, clearNames, filterFieldsByHypervisor } from 'client/utils'
import { T, INPUT_TYPES, HYPERVISORS, IMAGE_TYPES_STR } from 'client/constants'
const { vcenter, lxc, firecracker } = HYPERVISORS
const ramdiskValidation = string().trim().notRequired().default(() => undefined)
/** @type {Field} Ramdisk path field */
export const RAMDISK_PATH_ENABLED = {
name: 'OS.RAMDISK_PATH_ENABLED',
label: T.CustomPath,
notOnHypervisors: [vcenter, lxc, firecracker],
type: INPUT_TYPES.SWITCH,
validation: boolean().strip().default(() => false)
}
/** @type {Field} Ramdisk DS field */
export const RAMDISK_DS = {
name: 'OS.INITRD_DS',
label: T.Ramdisk,
notOnHypervisors: [vcenter, lxc, firecracker],
type: INPUT_TYPES.AUTOCOMPLETE,
dependOf: RAMDISK_PATH_ENABLED.name,
htmlType: enabled => enabled && INPUT_TYPES.HIDDEN,
values: () => {
const images = useImage()
return images
?.filter(image => getType(image) === IMAGE_TYPES_STR.RAMDISK)
?.map(({ ID, NAME }) => ({ text: `#${ID} ${NAME}`, value: `$FILE[IMAGE_ID=${ID}]` }))
?.sort((a, b) => {
const compareOptions = { numeric: true, ignorePunctuation: true }
return a.value.localeCompare(b.value, undefined, compareOptions)
})
},
validation: ramdiskValidation.when(
clearNames(RAMDISK_PATH_ENABLED.name),
(enabled, schema) => enabled ? schema.strip() : schema
)
}
/** @type {Field} Ramdisk path field */
export const RAMDISK = {
name: 'OS.INITRD',
label: T.RamdiskPath,
notOnHypervisors: [vcenter, lxc, firecracker],
type: INPUT_TYPES.TEXT,
dependOf: RAMDISK_PATH_ENABLED.name,
htmlType: enabled => !enabled && INPUT_TYPES.HIDDEN,
validation: ramdiskValidation.when(
clearNames(RAMDISK_PATH_ENABLED.name),
(enabled, schema) => enabled ? schema : schema.strip()
)
}
/**
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} List of Ramdisk fields
*/
export const RAMDISK_FIELDS = hypervisor =>
filterFieldsByHypervisor(
[RAMDISK_PATH_ENABLED, RAMDISK, RAMDISK_DS],
hypervisor
)

View File

@ -0,0 +1,50 @@
/* ------------------------------------------------------------------------- *
* 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 { string } from 'yup'
import { Field } from 'client/utils'
import { BOOT_FIELDS } from './bootSchema'
import { KERNEL_FIELDS } from './kernelSchema'
import { RAMDISK_FIELDS } from './ramdiskSchema'
import { FEATURES_FIELDS } from './featuresSchema'
export {
BOOT_FIELDS,
KERNEL_FIELDS,
RAMDISK_FIELDS,
FEATURES_FIELDS
}
/** @type {Field} Boot order field */
export const BOOT_ORDER = {
name: 'OS.BOOT',
validation: string()
.trim()
.notRequired()
.default(() => '')
}
/**
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} All 'OS & CPU' fields
*/
export const OS_FIELDS = hypervisor => [
...BOOT_FIELDS(hypervisor),
...KERNEL_FIELDS(hypervisor),
...RAMDISK_FIELDS(hypervisor),
...FEATURES_FIELDS(hypervisor)
]

View File

@ -0,0 +1,40 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
// import FormWithSchema from 'client/components/Forms/FormWithSchema'
// import { STEP_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
// import {} from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context/schema'
// import { T } from 'client/constants'
const Context = () => {
return (
<>
{'Context'}
</>
)
}
Context.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func
}
Context.displayName = 'Context'
export default Context

View File

@ -0,0 +1,29 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { string } from 'yup'
import { INPUT_TYPES } from 'client/constants'
const FIELD = {
name: '',
label: '',
tooltip: '',
type: INPUT_TYPES.TEXT,
validation: string().trim().notRequired()
}
export const CONTEXT_FIELDS = [FIELD]

View File

@ -0,0 +1,111 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useMemo } from 'react'
import PropTypes from 'prop-types'
import { useFormContext } from 'react-hook-form'
import { useTheme } from '@mui/material'
import {
WarningCircledOutline as WarningIcon,
Db as DatastoreIcon,
ServerConnection as NetworkIcon,
SystemShut as OsIcon,
DataTransferBoth as IOIcon,
Calendar as ActionIcon,
NetworkAlt as PlacementIcon,
Folder as ContextIcon,
ElectronicsChip as NumaIcon
} from 'iconoir-react'
import { useAuth } from 'client/features/Auth'
import { Tr, Translate } from 'client/components/HOC'
import Tabs from 'client/components/Tabs'
import Storage from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/storage'
import Networking from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/networking'
import Placement from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/placement'
import ScheduleAction from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/scheduleAction'
import Booting from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting'
import Context from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/context'
import InputOutput from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/inputOutput'
import Numa from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/numa'
import { STEP_ID as GENERAL_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General'
import { SCHEMA } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema'
import { getActionsAvailable as getSectionsAvailable } from 'client/models/Helper'
import { T } from 'client/constants'
export const STEP_ID = 'extra'
export const STEP_SECTION = [
{ id: 'storage', name: T.Storage, icon: DatastoreIcon, Content: Storage },
{ id: 'network', name: T.Network, icon: NetworkIcon, Content: Networking },
{ id: 'booting', name: T.OSBooting, icon: OsIcon, Content: Booting },
{ id: 'input_output', name: T.InputOrOutput, icon: IOIcon, Content: InputOutput },
{ id: 'context', name: T.Context, icon: ContextIcon, Content: Context },
{ id: 'sched_action', name: T.ScheduledAction, icon: ActionIcon, Content: ScheduleAction },
{ id: 'placement', name: T.Placement, icon: PlacementIcon, Content: Placement },
{ id: 'numa', name: T.Numa, icon: NumaIcon, Content: Numa }
]
const Content = ({ data, setFormData }) => {
const theme = useTheme()
const { watch, formState: { errors }, control } = useFormContext()
const { view, getResourceView } = useAuth()
const tabs = useMemo(() => {
const hypervisor = watch(`${GENERAL_ID}.HYPERVISOR`)
const dialog = getResourceView('VM-TEMPLATE')?.dialogs?.create_dialog
const sectionsAvailable = getSectionsAvailable(dialog, hypervisor)
return STEP_SECTION
.filter(({ id }) => sectionsAvailable.includes(id))
.map(({ Content, name, icon, ...section }, idx) => ({
...section,
name,
label: <Translate word={name} />,
renderContent: <Content {...{ data, setFormData, hypervisor, control }} />,
icon: errors[STEP_ID]?.[idx] ? (
<WarningIcon color={theme.palette.error.main} />
) : icon
}))
}, [errors[STEP_ID], view, control])
return tabs.length > 0 ? (
<Tabs tabs={tabs} />
) : (
<span>{Tr(T.Empty)}</span>
)
}
const ExtraConfiguration = () => ({
id: STEP_ID,
label: T.AdvancedOptions,
resolver: formData => {
const hypervisor = formData?.[GENERAL_ID]?.HYPERVISOR
return SCHEMA(hypervisor)
},
optionsValidate: { abortEarly: false },
content: Content
})
Content.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func
}
export default ExtraConfiguration

View File

@ -0,0 +1,52 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
import { Stack } from '@mui/material'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import { STEP_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { INPUT_OUTPUT_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/inputOutput/schema'
import { T } from 'client/constants'
const InputOutput = ({ hypervisor }) => {
return (
<Stack
display='grid'
gap='2em'
sx={{ gridTemplateColumns: { sm: '1fr', md: '1fr 1fr' } }}
>
<FormWithSchema
cy='create-vm-template-extra.io-graphics'
fields={INPUT_OUTPUT_FIELDS(hypervisor)}
legend={T.Graphics}
id={STEP_ID}
/>
</Stack>
)
}
InputOutput.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
hypervisor: PropTypes.string,
control: PropTypes.object
}
InputOutput.displayName = 'InputOutput'
export default InputOutput

View File

@ -0,0 +1,143 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { string, boolean } from 'yup'
import { Field, arrayToOptions, filterFieldsByHypervisor } from 'client/utils'
import { T, INPUT_TYPES, HYPERVISORS } from 'client/constants'
const { vcenter, lxc, kvm } = HYPERVISORS
/** @type {Field} Type field */
export const TYPE = {
name: 'GRAPHICS.TYPE',
label: T.Type,
type: INPUT_TYPES.TOGGLE,
dependOf: '$general.HYPERVISOR',
values: (hypervisor = kvm) => {
const types = {
[vcenter]: [T.VMRC],
[lxc]: [T.VNC]
}[hypervisor] ?? [T.VNC, T.SDL, T.SPICE]
return arrayToOptions(types)
},
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { md: 12 }
}
/** @type {Field} Listen field */
export const LISTEN = {
name: 'GRAPHICS.LISTEN',
label: T.ListenOnIp,
type: INPUT_TYPES.TEXT,
dependOf: TYPE.name,
htmlType: noneType => !noneType && INPUT_TYPES.HIDDEN,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
fieldProps: { placeholder: '0.0.0.0' },
grid: { md: 12 }
}
/** @type {Field} Port field */
export const PORT = {
name: 'GRAPHICS.PORT',
label: T.ServerPort,
tooltip: T.ServerPortConcept,
type: INPUT_TYPES.TEXT,
dependOf: TYPE.name,
htmlType: noneType => !noneType && INPUT_TYPES.HIDDEN,
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} Keymap field */
export const KEYMAP = {
name: 'GRAPHICS.KEYMAP',
label: T.Keymap,
type: INPUT_TYPES.TEXT,
dependOf: TYPE.name,
htmlType: noneType => !noneType && INPUT_TYPES.HIDDEN,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
fieldProps: { placeholder: 'en-us' }
}
/** @type {Field} Password random field */
export const RANDOM_PASSWD = {
name: 'OS.RANDOM_PASSWD',
label: T.GenerateRandomPassword,
type: INPUT_TYPES.CHECKBOX,
dependOf: TYPE.name,
htmlType: noneType => !noneType && INPUT_TYPES.HIDDEN,
validation: boolean()
.default(() => false)
.transform(value => {
if (typeof value === 'boolean') return value
return String(value).toUpperCase() === 'YES'
}),
grid: { md: 12 }
}
/** @type {Field} Password field */
export const PASSWD = {
name: 'GRAPHICS.PASSWD',
label: T.Password,
type: INPUT_TYPES.PASSWORD,
dependOf: [TYPE.name, RANDOM_PASSWD.name],
htmlType: ([noneType, random] = []) =>
(!noneType || random) && INPUT_TYPES.HIDDEN,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { md: 12 }
}
/** @type {Field} Command field */
export const COMMAND = {
name: 'GRAPHICS.COMMAND',
label: T.Command,
notOnHypervisors: [lxc],
type: INPUT_TYPES.TEXT,
dependOf: TYPE.name,
htmlType: noneType => !noneType && INPUT_TYPES.HIDDEN,
validation: string()
.trim()
.notRequired()
.default(() => undefined),
grid: { md: 12 }
}
/**
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} List of I/O fields
*/
export const INPUT_OUTPUT_FIELDS = hypervisor =>
filterFieldsByHypervisor(
[TYPE, LISTEN, PORT, KEYMAP, PASSWD, RANDOM_PASSWD, COMMAND],
hypervisor
)

View File

@ -0,0 +1,148 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
import makeStyles from '@mui/styles/makeStyles'
import { Edit, Trash } from 'iconoir-react'
import { useWatch } from 'react-hook-form'
import { useListForm } from 'client/hooks'
import ButtonToTriggerForm from 'client/components/Forms/ButtonToTriggerForm'
import SelectCard, { Action } from 'client/components/Cards/SelectCard'
import { AttachNicForm } from 'client/components/Forms/Vm'
import { Translate } from 'client/components/HOC'
import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { SCHEMA as EXTRA_SCHEMA } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema'
import { reorderBootAfterRemove } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting'
import { stringToBoolean } from 'client/models/Helper'
import { T } from 'client/constants'
const useStyles = makeStyles({
root: {
paddingBlock: '1em',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, auto))',
gap: '1em'
}
})
export const TAB_ID = 'NIC'
const Networking = ({ data, setFormData, control }) => {
const classes = useStyles()
const nics = useWatch({ name: `${EXTRA_ID}.${TAB_ID}`, control })
const { handleSetList, handleRemove, handleSave } = useListForm({
parent: EXTRA_ID,
key: TAB_ID,
list: nics,
setList: setFormData,
getItemId: item => item.NAME,
addItemId: (item, _, itemIndex) => ({ ...item, NAME: `${TAB_ID}${itemIndex}` })
})
const reorderNics = () => {
const diskSchema = EXTRA_SCHEMA.pick([TAB_ID])
const { [TAB_ID]: newList } = diskSchema.cast({ [TAB_ID]: data?.[TAB_ID] })
handleSetList(newList)
}
return (
<>
<ButtonToTriggerForm
buttonProps={{
color: 'secondary',
'data-cy': 'add-nic',
label: T.AttachNic,
variant: 'outlined'
}}
options={[{
dialogProps: { title: T.AttachNic },
form: () => AttachNicForm({ nics }),
onSubmit: handleSave
}]}
/>
<div className={classes.root}>
{nics?.map(item => {
const { NAME, RDP, SSH, NETWORK, PARENT, EXTERNAL } = item
const hasAlias = nics?.some(nic => nic.PARENT === NAME)
return (
<SelectCard
key={NAME}
title={[NAME, NETWORK].filter(Boolean).join(' - ')}
subheader={<>
{Object
.entries({
RDP: stringToBoolean(RDP),
SSH: stringToBoolean(SSH),
EXTERNAL: stringToBoolean(EXTERNAL),
ALIAS: PARENT
})
.map(([k, v]) => v ? `${k}` : '')
.filter(Boolean)
.join(' | ')
}
</>}
action={
<>
{!hasAlias &&
<Action
data-cy={`remove-${NAME}`}
handleClick={() => {
handleRemove(NAME)
reorderNics()
reorderBootAfterRemove(NAME, nics, data, setFormData)
}}
icon={<Trash />}
/>
}
<ButtonToTriggerForm
buttonProps={{
'data-cy': `edit-${NAME}`,
icon: <Edit />,
tooltip: <Translate word={T.Edit} />
}}
options={[{
dialogProps: {
title: <Translate word={T.EditSomething} values={[`${NAME} - ${NETWORK}`]} />
},
form: () => AttachNicForm({ nics }, item),
onSubmit: newValues => handleSave(newValues, NAME)
}]}
/>
</>
}
/>
)
})}
</div>
</>
)
}
Networking.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
hypervisor: PropTypes.string,
control: PropTypes.object
}
Networking.displayName = 'Networking'
export default Networking

View File

@ -0,0 +1,51 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import { STEP_ID as GENERAL_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General'
import { VIRTUAL_CPU } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General/capacitySchema'
import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { NUMA_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/numa/schema'
const Placement = ({ hypervisor }) => {
return (
<>
<FormWithSchema
cy='create-vm-template-extra.vcpu'
fields={[VIRTUAL_CPU]}
id={GENERAL_ID}
/>
<FormWithSchema
cy='create-vm-template-extra.numa'
fields={NUMA_FIELDS(hypervisor)}
id={EXTRA_ID}
/>
</>
)
}
Placement.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
hypervisor: PropTypes.string,
control: PropTypes.object
}
Placement.displayName = 'Placement'
export default Placement

View File

@ -0,0 +1,178 @@
/* ------------------------------------------------------------------------- *
* 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 { string, number } from 'yup'
import { useHost } from 'client/features/One'
import { getHugepageSizes } from 'client/models/Host'
import { T, INPUT_TYPES, NUMA_PIN_POLICIES, NUMA_MEMORY_ACCESS, HYPERVISORS } from 'client/constants'
import {
Field,
filterFieldsByHypervisor,
getFactorsOfNumber,
sentenceCase,
prettyBytes,
arrayToOptions
} from 'client/utils'
const { vcenter, firecracker } = HYPERVISORS
const threadsValidation = number()
.nullable()
.notRequired()
.integer()
/**
* @param {HYPERVISORS} hypervisor - VM hypervisor
* @returns {Field} Pin policy field
*/
const PIN_POLICY = hypervisor => {
const isVCenter = hypervisor === vcenter
const isFirecracker = hypervisor === firecracker
return {
name: 'TOPOLOGY.PIN_POLICY',
label: T.PinPolicy,
tooltip: [T.PinPolicyConcept, NUMA_PIN_POLICIES.join(', ')],
type: INPUT_TYPES.SELECT,
values: arrayToOptions(NUMA_PIN_POLICIES, {
addEmpty: false,
getText: sentenceCase
}),
validation: string()
.trim()
.notRequired()
.default(() => isFirecracker
? NUMA_PIN_POLICIES[2] // SHARED
: NUMA_PIN_POLICIES[0] // NONE
),
fieldProps: { disabled: isVCenter || isFirecracker }
}
}
/**
* @param {HYPERVISORS} hypervisor - VM hypervisor
* @returns {Field} Cores field
*/
const CORES = hypervisor => ({
name: 'TOPOLOGY.CORES',
label: T.Cores,
tooltip: T.NumaCoresConcept,
dependOf: '$general.VCPU',
type: hypervisor === vcenter ? INPUT_TYPES.SELECT : INPUT_TYPES.TEXT,
htmlType: 'number',
values: vcpu => arrayToOptions(getFactorsOfNumber(vcpu ?? 0)),
validation: number()
.notRequired()
.integer()
.default(() => undefined)
})
/**
* @param {HYPERVISORS} hypervisor - VM hypervisor
* @returns {Field} Sockets field
*/
const SOCKETS = hypervisor => ({
name: 'TOPOLOGY.SOCKETS',
label: T.Sockets,
tooltip: T.NumaSocketsConcept,
type: INPUT_TYPES.TEXT,
htmlType: 'number',
validation: number()
.notRequired()
.integer()
.default(() => 1),
fieldProps: {
disabled: hypervisor === firecracker
},
...(hypervisor === vcenter && {
fieldProps: { disabled: true },
dependOf: ['$general.VCPU', 'TOPOLOGY.CORES'],
watcher: ([vcpu, cores] = []) => {
if (!isNaN(+vcpu) && !isNaN(+cores) && +cores !== 0) {
return vcpu / cores
}
}
})
})
/**
* @param {HYPERVISORS} hypervisor - VM hypervisor
* @returns {Field} Threads field
*/
const THREADS = hypervisor => ({
name: 'TOPOLOGY.THREADS',
label: T.Threads,
tooltip: T.ThreadsConcept,
type: INPUT_TYPES.TEXT,
htmlType: 'number',
validation: threadsValidation,
...(hypervisor === firecracker && {
type: INPUT_TYPES.SELECT,
values: arrayToOptions([1, 2]),
validation: threadsValidation.min(1).max(2)
}),
...(hypervisor === vcenter && {
type: INPUT_TYPES.SELECT,
values: arrayToOptions([1]),
validation: threadsValidation.min(1).max(1)
})
})
/** @type {Field} Hugepage size field */
const HUGEPAGES = {
name: 'TOPOLOGY.HUGEPAGE_SIZE',
label: T.HugepagesSize,
tooltip: T.HugepagesSizeConcept,
notOnHypervisors: [vcenter, firecracker],
type: INPUT_TYPES.SELECT,
values: () => {
const hosts = useHost()
const sizes = hosts
.reduce((res, host) => res.concat(getHugepageSizes(host)), [])
return arrayToOptions([...new Set(sizes)], {
getText: size => prettyBytes(+size, 'MB')
})
},
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @returns {Field} Memory access field */
const MEMORY_ACCESS = {
name: 'TOPOLOGY.MEMORY_ACCESS',
label: T.MemoryAccess,
tooltip: [T.MemoryAccessConcept, NUMA_MEMORY_ACCESS.join(', ')],
notOnHypervisors: [vcenter, firecracker],
type: INPUT_TYPES.SELECT,
values: arrayToOptions(NUMA_MEMORY_ACCESS, { getText: sentenceCase }),
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/**
* @param {string} [hypervisor] - VM hypervisor
* @returns {Field[]} List of NUMA fields
*/
export const NUMA_FIELDS = hypervisor =>
filterFieldsByHypervisor(
[PIN_POLICY, CORES, SOCKETS, THREADS, HUGEPAGES, MEMORY_ACCESS],
hypervisor
)

View File

@ -0,0 +1,60 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import { STEP_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import {
PLACEMENT_HOST_FIELDS,
PLACEMENT_DS_FIELDS
} from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/placement/schema'
import { T } from 'client/constants'
const Placement = () => {
// TODO - Host requirements: add button to select HOST in list => ID="<id>"
// TODO - Host policy options: Packing|Stripping|Load-aware
// TODO - DS requirements: add button to select DATASTORE in list => ID="<id>"
// TODO - DS policy options: Packing|Stripping
return (
<>
<FormWithSchema
cy='create-vm-template-extra.host-placement'
fields={PLACEMENT_HOST_FIELDS}
legend={T.Host}
id={STEP_ID}
/>
<FormWithSchema
cy='create-vm-template-extra.ds-placement'
fields={PLACEMENT_DS_FIELDS}
legend={T.Datastore}
id={STEP_ID}
/>
</>
)
}
Placement.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func
}
Placement.displayName = 'Placement'
export default Placement

View File

@ -0,0 +1,57 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { string } from 'yup'
import { T, INPUT_TYPES } from 'client/constants'
const HOST_REQ_FIELD = {
name: 'SCHED_REQUIREMENTS',
label: T.HostReqExpression,
tooltip: T.HostReqExpressionConcept,
type: INPUT_TYPES.TEXT,
validation: string().trim().notRequired()
}
const HOST_RANK_FIELD = {
name: 'SCHED_RANK',
label: T.HostPolicyExpression,
tooltip: T.HostPolicyExpressionConcept,
type: INPUT_TYPES.TEXT,
validation: string().trim().notRequired()
}
const DS_REQ_FIELD = {
name: 'DS_SCHED_REQUIREMENTS',
label: T.DatastoreReqExpression,
tooltip: T.DatastoreReqExpressionConcept,
type: INPUT_TYPES.TEXT,
validation: string().trim().notRequired()
}
const DS_RANK_FIELD = {
name: 'DS_SCHED_RANK',
label: T.DatastorePolicyExpression,
tooltip: T.DatastorePolicyExpressionConcept,
type: INPUT_TYPES.TEXT,
validation: string().trim().notRequired()
}
export const PLACEMENT_HOST_FIELDS = [HOST_REQ_FIELD, HOST_RANK_FIELD]
export const PLACEMENT_DS_FIELDS = [DS_REQ_FIELD, DS_RANK_FIELD]
export const PLACEMENT_FIELDS = [PLACEMENT_HOST_FIELDS, PLACEMENT_DS_FIELDS]

View File

@ -0,0 +1,130 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
import makeStyles from '@mui/styles/makeStyles'
import { Edit, Trash } from 'iconoir-react'
import { useWatch } from 'react-hook-form'
import { useListForm } from 'client/hooks'
import ButtonToTriggerForm from 'client/components/Forms/ButtonToTriggerForm'
import SelectCard, { Action } from 'client/components/Cards/SelectCard'
import { PunctualForm, RelativeForm } from 'client/components/Forms/Vm'
import { Translate } from 'client/components/HOC'
import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { T } from 'client/constants'
const useStyles = makeStyles({
root: {
paddingBlock: '1em',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, auto))',
gap: '1em'
}
})
export const TAB_ID = 'SCHED_ACTION'
const ScheduleAction = ({ setFormData, control }) => {
const classes = useStyles()
const scheduleActions = useWatch({ name: `${EXTRA_ID}.${TAB_ID}`, control })
const { handleRemove, handleSave } = useListForm({
parent: EXTRA_ID,
key: TAB_ID,
list: scheduleActions,
setList: setFormData,
getItemId: item => item.NAME,
addItemId: (item, _, itemIndex) => ({ ...item, NAME: `${TAB_ID}${itemIndex}` })
})
return (
<>
<ButtonToTriggerForm
buttonProps={{
color: 'secondary',
'data-cy': 'add-sched-action',
label: T.AddAction,
variant: 'outlined'
}}
options={[{
cy: 'add-sched-action-punctual',
name: 'Punctual action',
dialogProps: { title: T.ScheduledAction },
form: () => PunctualForm(),
onSubmit: handleSave
},
{
cy: 'add-sched-action-relative',
name: 'Relative action',
dialogProps: { title: T.ScheduledAction },
form: () => RelativeForm(),
onSubmit: handleSave
}]}
/>
<div className={classes.root}>
{scheduleActions?.map(item => {
const { NAME, ACTION, TIME } = item
const isRelative = String(TIME).includes('+')
return (
<SelectCard
key={NAME}
title={`${NAME} - ${ACTION}`}
action={
<>
<Action
data-cy={`remove-${NAME}`}
handleClick={() => handleRemove(NAME)}
icon={<Trash />}
/>
<ButtonToTriggerForm
buttonProps={{
'data-cy': `edit-${NAME}`,
icon: <Edit />,
tooltip: <Translate word={T.Edit} />
}}
options={[{
dialogProps: {
title: <><Translate word={T.Edit} />{`: ${NAME}`}</>
},
form: () => isRelative
? RelativeForm(undefined, item)
: PunctualForm(undefined, item),
onSubmit: newValues => handleSave(newValues, NAME)
}]}
/>
</>
}
/>
)
})}
</div>
</>
)
}
ScheduleAction.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
hypervisor: PropTypes.string,
control: PropTypes.object
}
ScheduleAction.displayName = 'ScheduleAction'
export default ScheduleAction

View File

@ -0,0 +1,55 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { array, object } from 'yup'
import { PLACEMENT_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/placement/schema'
import { OS_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting/schema'
import { NUMA_FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/numa/schema'
import { getObjectSchemaFromFields } from 'client/utils'
export const SCHEMA = hypervisor => object({
DISK: array()
.ensure()
.transform(disks => disks?.map((disk, idx) => ({
...disk,
NAME: disk?.NAME?.startsWith('DISK') || !disk?.NAME
? `DISK${idx}`
: disk?.NAME
}))),
NIC: array()
.ensure()
.transform(nics => nics?.map((nic, idx) => ({
...nic,
NAME: nic?.NAME?.startsWith('NIC') || !nic?.NAME
? `NIC${idx}`
: nic?.NAME
}))),
SCHED_ACTION: array()
.ensure()
.transform(actions => actions?.map((action, idx) => ({
...action,
NAME: action?.NAME?.startsWith('SCHED_ACTION') || !action?.NAME
? `SCHED_ACTION${idx}`
: action?.NAME
})))
})
.concat(getObjectSchemaFromFields([
...PLACEMENT_FIELDS,
...OS_FIELDS(hypervisor),
...NUMA_FIELDS(hypervisor)
]))
.noUnknown(false)

View File

@ -0,0 +1,192 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import PropTypes from 'prop-types'
import makeStyles from '@mui/styles/makeStyles'
import { Edit, Trash } from 'iconoir-react'
import { useWatch } from 'react-hook-form'
import { useListForm } from 'client/hooks'
import ButtonToTriggerForm from 'client/components/Forms/ButtonToTriggerForm'
import SelectCard, { Action } from 'client/components/Cards/SelectCard'
import { ImageSteps, VolatileSteps } from 'client/components/Forms/Vm'
import { StatusCircle, StatusChip } from 'client/components/Status'
import { Translate } from 'client/components/HOC'
import { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { SCHEMA as EXTRA_SCHEMA } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/schema'
import { reorderBootAfterRemove } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration/booting'
import { getState, getDiskType } from 'client/models/Image'
import { stringToBoolean } from 'client/models/Helper'
import { prettyBytes } from 'client/utils'
import { T } from 'client/constants'
const useStyles = makeStyles({
root: {
paddingBlock: '1em',
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(300px, auto))',
gap: '1em'
}
})
export const TAB_ID = 'DISK'
const Storage = ({ data, setFormData, hypervisor, control }) => {
const classes = useStyles()
const disks = useWatch({ name: `${EXTRA_ID}.${TAB_ID}`, control })
const { handleSetList, handleRemove, handleSave } = useListForm({
parent: EXTRA_ID,
key: TAB_ID,
list: disks,
setList: setFormData,
getItemId: item => item.NAME,
addItemId: (item, _, itemIndex) => ({ ...item, NAME: `${TAB_ID}${itemIndex}` })
})
const reorderDisks = () => {
const diskSchema = EXTRA_SCHEMA.pick([TAB_ID])
const { [TAB_ID]: newList } = diskSchema.cast({ [TAB_ID]: data?.[TAB_ID] })
handleSetList(newList)
}
return (
<>
<ButtonToTriggerForm
buttonProps={{
color: 'secondary',
'data-cy': 'add-disk',
label: T.AttachDisk,
variant: 'outlined'
}}
options={[
{
cy: 'attach-image-disk',
name: T.Image,
dialogProps: { title: T.AttachImage },
form: () => ImageSteps({ hypervisor }),
onSubmit: handleSave
},
{
cy: 'attach-volatile-disk',
name: T.Volatile,
dialogProps: { title: T.AttachVolatile },
form: () => VolatileSteps({ hypervisor }),
onSubmit: handleSave
}
]}
/>
<div className={classes.root}>
{disks?.map(item => {
const {
NAME,
TYPE,
IMAGE,
IMAGE_ID,
IMAGE_STATE,
ORIGINAL_SIZE,
SIZE = ORIGINAL_SIZE,
READONLY,
DATASTORE,
PERSISTENT
} = item
const isVolatile = !IMAGE && !IMAGE_ID
const isPersistent = stringToBoolean(PERSISTENT)
const state = !isVolatile && getState({ STATE: IMAGE_STATE })
const type = isVolatile ? TYPE : getDiskType(item)
const originalSize = +ORIGINAL_SIZE ? prettyBytes(+ORIGINAL_SIZE, 'MB') : '-'
const size = prettyBytes(+SIZE, 'MB')
return (
<SelectCard
key={NAME}
title={isVolatile ? (
<>
{`${NAME} - `}
<Translate word={T.VolatileDisk} />
</>
) : (
<span style={{ display: 'flex', alignItems: 'center', gap: '0.5em' }}>
<StatusCircle color={state?.color} tooltip={state?.name} />
{`${NAME}: ${IMAGE}`}
{isPersistent && <StatusChip text='PERSISTENT' />}
</span>
)}
subheader={<>
{Object
.entries({
[DATASTORE]: DATASTORE,
READONLY: stringToBoolean(READONLY),
PERSISTENT: stringToBoolean(PERSISTENT),
[isVolatile || ORIGINAL_SIZE === SIZE ? size : `${originalSize}/${size}`]: true,
[type]: type
})
.map(([k, v]) => v ? `${k}` : '')
.filter(Boolean)
.join(' | ')
}
</>}
action={
<>
<Action
data-cy={`remove-${NAME}`}
tooltip={<Translate word={T.Remove} />}
handleClick={() => {
handleRemove(NAME)
reorderDisks()
reorderBootAfterRemove(NAME, disks, data, setFormData)
}}
icon={<Trash />}
/>
<ButtonToTriggerForm
buttonProps={{
'data-cy': `edit-${NAME}`,
icon: <Edit />,
tooltip: <Translate word={T.Edit} />
}}
options={[{
dialogProps: {
title: <Translate word={T.EditSomething} values={[NAME]} />
},
form: () => isVolatile
? VolatileSteps({ hypervisor }, item)
: ImageSteps({ hypervisor }, item),
onSubmit: newValues => handleSave(newValues, NAME)
}]}
/>
</>
}
/>
)
})}
</div>
</>
)
}
Storage.propTypes = {
data: PropTypes.any,
setFormData: PropTypes.func,
hypervisor: PropTypes.string,
control: PropTypes.object
}
Storage.displayName = 'Storage'
export default Storage

View File

@ -0,0 +1,139 @@
/* ------------------------------------------------------------------------- *
* 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 { number, boolean } from 'yup'
import { T, INPUT_TYPES, HYPERVISORS } from 'client/constants'
import { Field } from 'client/utils'
/**
* @param {Field} field - Field params
* @param {boolean} field.required - If `true`, add to validation if it's required
* @param {boolean} field.divBy4 - If `true`, add to validation if it's divisible by 4
* @returns {Field} Capacity field params
*/
const CAPACITY_FIELD = ({ dependOf, required, divBy4, ...field }) => {
let validation = number()
.integer('Should be integer number')
.positive('Should be positive number')
.typeError('Must be a number')
.default(() => undefined)
if (required) {
validation = validation.required()
}
if (dependOf) {
validation = validation.when(
dependOf,
(enabledHr, schema) => enabledHr
? schema.required()
: schema.strip().notRequired()
)
}
if (divBy4) {
validation = validation.when(
'HYPERVISOR',
(hypervisor, schema) => hypervisor === HYPERVISORS.vcenter
? schema.isDivisibleBy(4)
: schema
)
}
return {
...field,
dependOf,
type: INPUT_TYPES.TEXT,
htmlType: 'number',
...(dependOf && {
htmlType: dependValue =>
dependValue ? 'number' : INPUT_TYPES.HIDDEN
}),
validation
}
}
/** @type {Field} Memory field */
export const MEMORY = CAPACITY_FIELD({
name: 'MEMORY',
label: T.Memory,
tooltip: T.MemoryConcept,
divBy4: true,
required: true,
grid: { md: 12 }
})
/** @type {Field} Hot reloading on memory field */
export const ENABLE_HR_MEMORY = {
name: 'ENABLE_HR_MEMORY',
label: T.EnableHotResize,
type: INPUT_TYPES.SWITCH,
validation: boolean().default(() => false),
grid: { xs: 4, md: 6 }
}
/** @type {Field} Maximum memory field */
export const MEMORY_MAX = CAPACITY_FIELD({
name: 'MEMORY_MAX',
label: T.MaxMemory,
dependOf: ENABLE_HR_MEMORY.name,
grid: { xs: 8, md: 6 }
})
/** @type {Field} Physical CPU field */
export const PHYSICAL_CPU = CAPACITY_FIELD({
name: 'CPU',
label: T.PhysicalCpu,
tooltip: T.CpuConcept,
required: true,
grid: { md: 12 }
})
/** @type {Field} Virtual CPU field */
export const VIRTUAL_CPU = CAPACITY_FIELD({
name: 'VCPU',
label: T.VirtualCpu,
tooltip: T.VirtualCpuConcept,
grid: { md: 12 }
})
/** @type {Field} Hot reloading on virtual CPU field */
export const ENABLE_HR_VCPU = {
name: 'ENABLE_HR_VCPU',
label: T.EnableHotResize,
type: INPUT_TYPES.SWITCH,
validation: boolean().default(() => false),
grid: { xs: 4, md: 6 }
}
/** @type {Field} Maximum virtual CPU field */
export const VCPU_MAX = CAPACITY_FIELD({
name: 'VCPU_MAX',
label: T.MaxVirtualCpu,
dependOf: ENABLE_HR_VCPU.name,
grid: { xs: 8, md: 6 }
})
/** @type {Field[]} List of capacity fields */
export const FIELDS = [
MEMORY,
ENABLE_HR_MEMORY,
MEMORY_MAX,
PHYSICAL_CPU,
VIRTUAL_CPU,
ENABLE_HR_VCPU,
VCPU_MAX
]

View File

@ -0,0 +1,77 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useMemo } from 'react'
import { useWatch } from 'react-hook-form'
import { useAuth } 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, FIELDS } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General/schema'
import { getActionsAvailable as getSectionsAvailable } from 'client/models/Helper'
import { T } from 'client/constants'
export const STEP_ID = 'general'
const Content = () => {
const classes = useStyles()
const { view, getResourceView } = useAuth()
const hypervisor = useWatch({ name: `${STEP_ID}.HYPERVISOR` })
const groups = useMemo(() => {
const dialog = getResourceView('VM-TEMPLATE')?.dialogs?.create_dialog
const sectionsAvailable = getSectionsAvailable(dialog, hypervisor)
return FIELDS(hypervisor)
.filter(({ id, required }) => required || sectionsAvailable.includes(id))
}, [view, hypervisor])
return (
<div className={classes.root}>
<FormWithSchema
cy={'create-vm-template-general.hypervisor'}
fields={[HYPERVISOR_FIELD]}
legend={T.Hypervisor}
id={STEP_ID}
/>
{groups.map(({ id, legend, fields }) => (
<FormWithSchema
key={id}
className={classes[id]}
cy={`create-vm-template-general.${id}`}
fields={fields}
legend={legend}
id={STEP_ID}
/>
))}
</div>
)
}
const General = () => ({
id: STEP_ID,
label: T.General,
resolver: formData => {
const hypervisor = formData?.[STEP_ID]?.HYPERVISOR
return SCHEMA(hypervisor)
},
optionsValidate: { abortEarly: false },
content: Content
})
export default General

View File

@ -0,0 +1,108 @@
/* ------------------------------------------------------------------------- *
* 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 { string } from 'yup'
import Image from 'client/components/Image'
import { T, LOGO_IMAGES_URL, INPUT_TYPES, HYPERVISORS } from 'client/constants'
import { Field, arrayToOptions } from 'client/utils'
/** @type {Field} Name field */
export const NAME = {
name: 'NAME',
label: T.Name,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.required()
.default(() => undefined),
grid: { sm: 6 }
}
/** @type {Field} Description field */
export const DESCRIPTION = {
name: 'DESCRIPTION',
label: T.Description,
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} Hypervisor field */
export const HYPERVISOR_FIELD = {
name: 'HYPERVISOR',
type: INPUT_TYPES.TOGGLE,
values: arrayToOptions(Object.values(HYPERVISORS), {
addEmpty: false,
getText: hypervisor => hypervisor.toUpperCase()
}),
validation: string()
.trim()
.required()
.default(() => HYPERVISORS.kvm),
grid: { md: 12 }
}
/** @type {Field} Logo field */
export const LOGO = {
name: 'LOGO',
label: T.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' }
],
// eslint-disable-next-line react/display-name
renderValue: value => (
<Image
imgProps={{
height: 25,
width: 25,
style: { marginRight: 10 }
}}
src={`${LOGO_IMAGES_URL}/${value}`}
/>
),
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field[]} List of information fields */
export const FIELDS = [
NAME,
DESCRIPTION,
LOGO
]

View File

@ -0,0 +1,64 @@
/* ------------------------------------------------------------------------- *
* 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 { string } from 'yup'
import { useGroup, useUser } from 'client/features/One'
import { T, INPUT_TYPES } from 'client/constants'
import { Field } from 'client/utils'
/** @type {Field} User id field */
export const UID_FIELD = {
name: 'AS_UID',
label: T.InstantiateAsUser,
type: INPUT_TYPES.AUTOCOMPLETE,
values: () => {
const users = useUser()
return users
.map(({ ID: value, NAME: text }) => ({ text, value }))
.sort((a, b) => a.value - b.value)
},
validation: string()
.trim()
.notRequired()
.default(undefined),
grid: { md: 12 }
}
/** @type {Field} Group id field */
export const GID_FIELD = {
name: 'AS_GID',
label: T.InstantiateAsGroup,
type: INPUT_TYPES.AUTOCOMPLETE,
values: () => {
const groups = useGroup()
return groups
.map(({ ID: value, NAME: text }) => ({ text, value }))
.sort((a, b) => a.value - b.value)
},
validation: string()
.trim()
.notRequired()
.default(undefined),
grid: { md: 12 }
}
/** @type {Field[]} List of ownership fields */
export const FIELDS = [
UID_FIELD,
GID_FIELD
]

View File

@ -0,0 +1,69 @@
/* ------------------------------------------------------------------------- *
* 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 { BaseSchema } from 'yup'
import { FIELDS as INFORMATION_FIELDS, HYPERVISOR_FIELD } from './informationSchema'
import { FIELDS as CAPACITY_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'
import { filterFieldsByHypervisor, getObjectSchemaFromFields, Field } from 'client/utils'
import { T, HYPERVISORS } from 'client/constants'
/**
* @param {HYPERVISORS} [hypervisor] - Template hypervisor
* @returns {{ id: string, legend: string, fields: Field[] }[]} Fields
*/
const FIELDS = hypervisor => [
{
id: 'information',
legend: T.Information,
required: true,
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_FIELDS, hypervisor)
}
]
/**
* @param {HYPERVISORS} [hypervisor] - Template hypervisor
* @returns {BaseSchema} Step schema
*/
const SCHEMA = hypervisor => getObjectSchemaFromFields([
HYPERVISOR_FIELD,
...FIELDS(hypervisor).map(({ fields }) => fields).flat()
])
export { FIELDS, SCHEMA }

View File

@ -0,0 +1,30 @@
/* ------------------------------------------------------------------------- *
* 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 makeStyles from '@mui/styles/makeStyles'
export default makeStyles(theme => ({
root: {
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '2em',
[theme.breakpoints.down('lg')]: {
gridTemplateColumns: '1fr'
}
},
information: {
gridColumn: '1 / -1'
}
}))

View File

@ -0,0 +1,73 @@
/* ------------------------------------------------------------------------- *
* 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 { string } from 'yup'
import { T, INPUT_TYPES, HYPERVISORS } from 'client/constants'
import { Field } from 'client/utils'
const { vcenter, ...hypervisors } = HYPERVISORS
/** @type {Field} Common field attributes */
const commonAttributes = {
notOnHypervisors: Object.values(hypervisors),
type: INPUT_TYPES.TEXT,
validation: string()
.trim()
.required()
.default(() => undefined),
grid: { md: 12 }
}
/** @type {Field} vCenter template reference field */
const VCENTER_TEMPLATE_FIELD = {
...commonAttributes,
name: 'VCENTER_TEMPLATE_REF',
label: T.vCenterTemplateRef
}
/** @type {Field} vCenter cluster reference field */
const VCENTER_CCR_FIELD = {
...commonAttributes,
name: 'VCENTER_CCR_REF',
label: T.vCenterClusterRef
}
/** @type {Field} vCenter instance id field */
const VCENTER_INSTANCE_ID = {
...commonAttributes,
name: 'VCENTER_INSTANCE_ID',
label: T.vCenterInstanceId
}
/** @type {Field} vCenter VM folder field */
const VCENTER_FOLDER_FIELD = {
...commonAttributes,
name: 'VCENTER_VM_FOLDER',
label: T.vCenterVmFolder,
tooltip: T.vCenterVmFolderConcept,
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field[]} List of vCenter fields */
export const FIELDS = [
VCENTER_TEMPLATE_FIELD,
VCENTER_CCR_FIELD,
VCENTER_INSTANCE_ID,
VCENTER_FOLDER_FIELD
]

View File

@ -0,0 +1,78 @@
/* ------------------------------------------------------------------------- *
* 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 { string } from 'yup'
import { useVmGroup } from 'client/features/One'
import { T, INPUT_TYPES } from 'client/constants'
import { Field } from 'client/utils'
/** @type {Field} VM Group field */
export const VM_GROUP_FIELD = {
name: 'VMGROUP.VMGROUP_ID',
label: T.AssociateToVMGroup,
type: INPUT_TYPES.AUTOCOMPLETE,
values: () => {
const vmGroups = useVmGroup()
return vmGroups
?.map(({ ID, NAME }) => ({ text: `#${ID} ${NAME}`, value: ID }))
?.sort((a, b) => {
const compareOptions = { numeric: true, ignorePunctuation: true }
return a.value.localeCompare(b.value, undefined, compareOptions)
})
},
grid: { md: 12 },
validation: string()
.trim()
.notRequired()
.default(() => undefined)
}
/** @type {Field} Role field */
export const ROLE_FIELD = {
name: 'VMGROUP.ROLE',
label: T.Role,
type: INPUT_TYPES.AUTOCOMPLETE,
dependOf: VM_GROUP_FIELD.name,
htmlType: vmGroup => vmGroup && vmGroup !== '' ? undefined : INPUT_TYPES.HIDDEN,
values: vmGroupSelected => {
const vmGroups = useVmGroup()
const roles = vmGroups
?.filter(({ ID }) => ID === vmGroupSelected)
?.map(({ ROLES }) =>
[ROLES?.ROLE ?? []].flat().map(({ NAME: ROLE_NAME }) => ROLE_NAME)
)
?.flat()
return roles.map(role => ({ text: role, value: role }))
},
grid: { md: 12 },
validation: string()
.trim()
.default(() => undefined)
.when(
'VMGROUP_ID',
(vmGroup, schema) => vmGroup ? schema.required() : schema
)
}
/** @type {Field[]} List of VM Group fields */
export const FIELDS = [
VM_GROUP_FIELD,
ROLE_FIELD
]

View File

@ -0,0 +1,37 @@
/* ------------------------------------------------------------------------- *
* 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 General, { STEP_ID as GENERAL_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/General'
import ExtraConfiguration, { STEP_ID as EXTRA_ID } from 'client/components/Forms/VmTemplate/CreateForm/Steps/ExtraConfiguration'
import { jsonToXml } from 'client/models/Helper'
import { createSteps } from 'client/utils'
const Steps = createSteps(
[General, ExtraConfiguration],
{
// transformInitialValue: (vmTemplate, schema) =>,
transformBeforeSubmit: formData => {
const {
[GENERAL_ID]: general = {},
[EXTRA_ID]: extraTemplate = {}
} = formData ?? {}
const templateXML = jsonToXml({ ...general, ...extraTemplate })
return { template: templateXML }
}
}
)
export default Steps

View File

@ -0,0 +1,83 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { useEffect, useMemo } from 'react'
import PropTypes from 'prop-types'
import { useForm, FormProvider } from 'react-hook-form'
import { yupResolver } from '@hookform/resolvers/yup'
import { useFetch } from 'client/hooks'
// import { useUserApi, useVmGroupApi, useVmTemplateApi } from 'client/features/One'
import { useVmTemplateApi, useHostApi, useImageApi } from 'client/features/One'
import FormStepper, { SkeletonStepsForm } from 'client/components/FormStepper'
import Steps from 'client/components/Forms/VmTemplate/CreateForm/Steps'
const CreateForm = ({ template, onSubmit }) => {
const stepsForm = useMemo(() => Steps(template, {}), [])
const { steps, defaultValues, resolver, transformBeforeSubmit } = stepsForm
const methods = useForm({
mode: 'onSubmit',
defaultValues,
resolver: yupResolver(resolver?.())
})
return (
<FormProvider {...methods}>
<FormStepper
steps={steps}
schema={resolver}
onSubmit={data => onSubmit(transformBeforeSubmit?.(data) ?? data)}
/>
</FormProvider>
)
}
const PreFetchingForm = ({ templateId, onSubmit }) => {
// const { getUsers } = useUserApi()
// const { getVmGroups } = useVmGroupApi()
const { getHosts } = useHostApi()
const { getImages } = useImageApi()
const { getVmTemplate } = useVmTemplateApi()
const { fetchRequest, data } = useFetch(
() => getVmTemplate(templateId, { extended: true })
)
useEffect(() => {
templateId && fetchRequest()
getHosts()
getImages()
// getUsers()
// getVmGroups()
}, [])
return (templateId && !data)
? <SkeletonStepsForm />
: <CreateForm template={data} onSubmit={onSubmit} />
}
PreFetchingForm.propTypes = {
templateId: PropTypes.string,
onSubmit: PropTypes.func
}
CreateForm.propTypes = {
template: PropTypes.object,
onSubmit: PropTypes.func
}
export default PreFetchingForm

View File

@ -17,7 +17,6 @@
import { number } from 'yup'
import { T, INPUT_TYPES, HYPERVISORS } from 'client/constants'
import { isDivisibleBy4 } from 'client/utils'
const MEMORY = hypervisor => {
let validation = number()
@ -28,8 +27,7 @@ const MEMORY = hypervisor => {
.default(() => undefined)
if (hypervisor === HYPERVISORS.vcenter) {
validation = validation
.test('is-divisible-by-4', 'Memory should be divisible by 4', isDivisibleBy4)
validation = validation.isDivisibleBy(4)
}
return {

View File

@ -20,11 +20,10 @@ import { useFormContext } from 'react-hook-form'
import { useAuth } from 'client/features/Auth'
import FormWithSchema from 'client/components/Forms/FormWithSchema'
import useStyles from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/BasicConfiguration/styles'
import { Tr } from 'client/components/HOC'
import { STEP_ID as TEMPLATE_ID } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/VmTemplatesTable'
import { SCHEMA, FIELDS } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/BasicConfiguration/schema'
import { getActionsAvailable } from 'client/models/Helper'
import { getActionsAvailable as getSectionsAvailable } from 'client/models/Helper'
import { T } from 'client/constants'
export const STEP_ID = 'configuration'
@ -37,9 +36,9 @@ const Content = () => {
const groups = useMemo(() => {
const hypervisor = watch(`${TEMPLATE_ID}[0].TEMPLATE.HYPERVISOR`)
const dialog = getResourceView('VM-TEMPLATE')?.dialogs?.instantiate_dialog
const groupsAvailable = getActionsAvailable(dialog, hypervisor)
const sectionsAvailable = getSectionsAvailable(dialog, hypervisor)
return FIELDS(hypervisor).filter(({ id }) => groupsAvailable.includes(id))
return FIELDS(hypervisor).filter(({ id }) => sectionsAvailable.includes(id))
}, [view])
return (
@ -50,7 +49,7 @@ const Content = () => {
className={classes[id]}
cy={`instantiate-vm-template-configuration.${id}`}
fields={fields}
legend={Tr(legend)}
legend={legend}
id={STEP_ID}
/>
))}

View File

@ -27,7 +27,7 @@ import { T, HYPERVISORS } from 'client/constants'
/**
* @param {HYPERVISORS} [hypervisor] - Template hypervisor
* @returns {{ id: string, legend: string, fields: Field[] }[]} Fields
* @returns {function(string):{ id: string, legend: string, fields: Field[] }[]} Fields
*/
const FIELDS = hypervisor => [
{

View File

@ -33,20 +33,20 @@ import Booting from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/Ex
import { STEP_ID as TEMPLATE_ID } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/VmTemplatesTable'
import { SCHEMA } from 'client/components/Forms/VmTemplate/InstantiateForm/Steps/ExtraConfiguration/schema'
import { getActionsAvailable } from 'client/models/Helper'
import { getActionsAvailable as getSectionsAvailable } from 'client/models/Helper'
import { T } from 'client/constants'
export const STEP_ID = 'extra'
const Content = ({ data, setFormData }) => {
const theme = useTheme()
const { watch, errors, control } = useFormContext()
const { watch, formState: { errors }, control } = useFormContext()
const { view, getResourceView } = useAuth()
const tabs = useMemo(() => {
const hypervisor = watch(`${TEMPLATE_ID}[0].TEMPLATE.HYPERVISOR`)
const dialog = getResourceView('VM-TEMPLATE')?.dialogs?.instantiate_dialog
const groupsAvailable = getActionsAvailable(dialog, hypervisor)
const sectionsAvailable = getSectionsAvailable(dialog, hypervisor)
return [
{
@ -89,7 +89,7 @@ const Content = ({ data, setFormData }) => {
<WarningIcon color={theme.palette.error.main} />
)
}
].filter(({ id }) => groupsAvailable.includes(id))
].filter(({ id }) => sectionsAvailable.includes(id))
}, [errors[STEP_ID], view, control])
return (

View File

@ -14,9 +14,11 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import CloneForm from 'client/components/Forms/VmTemplate/CloneForm'
import CreateForm from 'client/components/Forms/VmTemplate/CreateForm'
import InstantiateForm from 'client/components/Forms/VmTemplate/InstantiateForm'
export {
CloneForm,
CreateForm,
InstantiateForm
}

View File

@ -26,6 +26,9 @@ import { isDevelopment } from 'client/utils'
const TranslateContext = createContext()
let languageScript = root.document?.createElement('script')
const labelCanBeTranslated = val =>
typeof val === 'string' || (Array.isArray(val) && val.length === 2)
const GenerateScript = (
language = DEFAULT_LANGUAGE,
setHash = () => undefined
@ -48,7 +51,7 @@ const RemoveScript = () => {
root.document.body.removeChild(languageScript)
}
const TranslateProvider = ({ children }) => {
const TranslateProvider = ({ children = [] }) => {
const [hash, setHash] = useState({})
const { settings: { lang } = {} } = useAuth()
@ -104,7 +107,7 @@ const Tr = (str = '') => {
return translateString(key, valuesTr)
}
const Translate = ({ word = '', values }) => {
const Translate = ({ word = '', values = [] }) => {
const valuesTr = !Array.isArray(values) ? [values] : values
return translateString(word, valuesTr)
}
@ -116,18 +119,20 @@ TranslateProvider.propTypes = {
])
}
TranslateProvider.defaultProps = {
children: []
}
Translate.propTypes = {
word: PropTypes.string,
values: PropTypes.oneOfType([PropTypes.string, PropTypes.array])
values: PropTypes.oneOfType([
PropTypes.string,
PropTypes.number,
PropTypes.bool,
PropTypes.array
])
}
Translate.defaultProps = {
word: '',
values: ''
export {
TranslateContext,
TranslateProvider,
Translate,
Tr,
labelCanBeTranslated
}
export { TranslateContext, TranslateProvider, Translate, Tr }

View File

@ -15,9 +15,10 @@
* ------------------------------------------------------------------------- */
import { useEffect, JSXElementConstructor } from 'react'
import { MenuList, MenuItem } from '@mui/material'
import { MenuList, MenuItem, LinearProgress } from '@mui/material'
import { Language as ZoneIcon } from 'iconoir-react'
import { useFetch } from 'client/hooks'
import { useZone, useZoneApi } from 'client/features/One'
import HeaderPopover from 'client/components/Header/Popover'
import { Translate } from 'client/components/HOC'
@ -31,10 +32,7 @@ import { T } from 'client/constants'
const Zone = () => {
const zones = useZone()
const { getZones } = useZoneApi()
useEffect(() => {
!zones?.length && getZones()
}, [])
const { fetchRequest, loading } = useFetch(getZones)
return (
<HeaderPopover
@ -44,18 +42,31 @@ const Zone = () => {
buttonProps={{ 'data-cy': 'header-zone-button' }}
headerTitle={<Translate word={T.Zones} />}
>
{({ handleClose }) => (
<MenuList>
{zones?.length
? zones?.map(({ ID, NAME }) => (
<MenuItem key={`zone-${ID}`} onClick={handleClose}>
{NAME}
</MenuItem>
))
: <MenuItem disabled>{'Not zones found'}</MenuItem>
}
</MenuList>
)}
{({ handleClose }) => {
useEffect(() => {
fetchRequest()
}, [])
return (
<>
{loading && <LinearProgress color='secondary' />}
<MenuList>
{zones?.length
? zones?.map(({ ID, NAME }) => (
<MenuItem key={`zone-${ID}`} onClick={handleClose}>
{NAME}
</MenuItem>
))
: (
<MenuItem disabled>
<Translate word={T.NotFound} />
</MenuItem>
)
}
</MenuList>
</>
)
}}
</HeaderPopover>
)
}

View File

@ -93,7 +93,11 @@ const Header = () => {
{title}
</Typography>
</Box>
<Stack direction='row' flexGrow={1} justifyContent='end'>
<Stack
direction='row'
justifyContent='end'
sx={{ flexGrow: { xs: 1, sm: 0 } }}
>
<User />
<View />
{!isOneAdmin && <Group />}

View File

@ -18,7 +18,7 @@ import { number, string, oneOfType } from 'prop-types'
const DockerLogo = memo(({ viewBox, width, height, color, ...props }) => {
return (
<svg viewBox={viewBox} width={width} height={height} {...props} {...props}>
<svg viewBox={viewBox} width={width} height={height} {...props}>
<path
fill={color}
d="M296 245h42v-38h-42zm-50 0h42v-38h-42zm-49 0h42v-38h-42zm-49 0h41v-38h-41zm-50 0h42v-38H98zm50-46h41v-38h-41zm49 0h42v-38h-42zm49 0h42v-38h-42zm0-46h42v-38h-42zm226 75s-18-17-55-11c-4-29-35-46-35-46s-29 35-8 74c-6 3-16 7-31 7H68c-5 19-5 145 133 145 99 0 173-46 208-130 52 4 63-39 63-39z"

View File

@ -13,8 +13,8 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import DockerLogo from 'client/components/Icons/docker'
import OpenNebulaLogo from 'client/components/Icons/opennebula'
import DockerLogo from 'client/components/Icons/DockerIcon'
import OpenNebulaLogo from 'client/components/Icons/OpenNebulaIcon'
export {
DockerLogo,

View File

@ -29,65 +29,67 @@ const INITIAL_STATE = { fail: false, retries: 0 }
* @param {string} props.imgProps - Properties to image element
* @returns {JSXElementConstructor} Picture with all images format
*/
const Image = memo(({ src, imageInError, withSources, imgProps }) => {
const [error, setError] = useState(INITIAL_STATE)
const Image = memo(
({
src,
imageInError = DEFAULT_IMAGE,
withSources = false,
pictureProps = {},
imgProps = {}
}) => {
const [error, setError] = useState(INITIAL_STATE)
/** Increment retries by one in error state. */
const addRetry = () => {
setError(prev => ({ ...prev, retries: prev.retries + 1 }))
}
const imageProps = {
decoding: 'async',
draggable: false,
loading: 'lazy',
...imgProps
}
/** Set failed state. */
const onImageFail = () => {
setError(prev => ({ fail: true, retries: prev.retries + 1 }))
}
/** Increment retries by one in error state. */
const addRetry = () => {
setError(prev => ({ ...prev, retries: prev.retries + 1 }))
}
if (error.retries >= MAX_RETRIES) {
return null
}
/** Set failed state. */
const onImageFail = () => {
setError(prev => ({ fail: true, retries: prev.retries + 1 }))
}
if (error.fail) {
return <img
{...imgProps}
src={imageInError}
draggable={false}
onError={addRetry}
/>
}
if (error.retries >= MAX_RETRIES) {
return null
}
return (
<picture>
{withSources && IMAGE_FORMATS.map(format => (
<source key={format}
srcSet={`${src}.${format}`}
type={`image/${format}`}
/>
))}
<img {...imgProps} src={src} onError={onImageFail} />
</picture>
)
}, (prev, next) => prev.src === next.src)
if (error.fail) {
return <img
{...imageProps}
src={imageInError}
draggable={false}
onError={addRetry}
/>
}
return withSources ? (
<picture {...pictureProps}>
{withSources && IMAGE_FORMATS.map(format => (
<source key={format}
srcSet={`${src}.${format}`}
type={`image/${format}`}
/>
))}
<img {...imageProps} src={src} onError={onImageFail} />
</picture>
) : (
<img {...imageProps} src={src} onError={onImageFail} />
)
}, (prev, next) => prev.src === next.src)
Image.propTypes = {
src: PropTypes.string,
imageInError: PropTypes.string,
withSources: PropTypes.bool,
imgProps: PropTypes.shape({
decoding: PropTypes.oneOf(['sync', 'async', 'auto']),
draggable: PropTypes.bool,
loading: PropTypes.oneOf(['eager', 'lazy'])
})
}
Image.defaultProps = {
src: undefined,
imageInError: DEFAULT_IMAGE,
withSources: false,
imgProps: {
decoding: 'async',
draggable: false,
loading: 'lazy'
}
pictureProps: PropTypes.object,
imgProps: PropTypes.object
}
Image.displayName = 'Image'

View File

@ -27,17 +27,24 @@ const useStyles = makeStyles(({ spacing, palette, typography }) => {
const defaultStateColor = palette.grey[palette.mode === SCHEMES.DARK ? 300 : 700]
return {
root: ({ stateColor = defaultStateColor }) => ({
color: getBackgroundColor(stateColor, 0.75),
backgroundColor: addOpacityToColor(stateColor, 0.2),
cursor: 'default',
padding: spacing('0.25rem', '0.5rem'),
borderRadius: 2,
textTransform: 'uppercase',
fontSize: typography.overline.fontSize,
fontWeight: 500,
lineHeight: 'normal'
})
root: ({ stateColor = defaultStateColor }) => {
const paletteColor = palette[stateColor]
const color = paletteColor?.contrastText ?? getBackgroundColor(stateColor, 0.75)
const bgColor = paletteColor?.dark ?? stateColor
return {
color,
backgroundColor: addOpacityToColor(bgColor, 0.2),
cursor: 'default',
padding: spacing('0.25rem', '0.5rem'),
borderRadius: 2,
textTransform: 'uppercase',
fontSize: typography.overline.fontSize,
fontWeight: 500,
lineHeight: 'normal'
}
}
}
})

View File

@ -97,6 +97,6 @@ GlobalActions.propTypes = {
useTableProps: PropTypes.object
}
export { Action, ActionPropTypes, GlobalAction }
export { Action, ActionPropTypes }
export default GlobalActions

View File

@ -14,7 +14,7 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import CategoryFilter from 'client/components/Tables/Enhanced/Utils/CategoryFilter'
import GlobalActions, { Action, ActionPropTypes, GlobalAction } from 'client/components/Tables/Enhanced/Utils/GlobalActions'
import GlobalActions, { Action, ActionPropTypes } from 'client/components/Tables/Enhanced/Utils/GlobalActions'
import GlobalFilter from 'client/components/Tables/Enhanced/Utils/GlobalFilter'
import GlobalSelectedRows from 'client/components/Tables/Enhanced/Utils/GlobalSelectedRows'
import GlobalSort from 'client/components/Tables/Enhanced/Utils/GlobalSort'
@ -26,7 +26,6 @@ export {
Action,
ActionPropTypes,
CategoryFilter,
GlobalAction,
GlobalActions,
GlobalFilter,
GlobalSelectedRows,

View File

@ -81,13 +81,10 @@ const Actions = () => {
accessor: VM_TEMPLATE_ACTIONS.CREATE_DIALOG,
tooltip: T.Create,
icon: AddSquare,
disabled: true,
action: rows => {
// TODO: go to CREATE form
// const { ID } = rows?.[0]?.original ?? {}
// const path = generatePath(PATH.TEMPLATE.VMS.CREATE, { id: ID })
action: () => {
const path = PATH.TEMPLATE.VMS.CREATE
// history.push(path)
history.push(path)
}
},
{
@ -174,25 +171,25 @@ const Actions = () => {
selected: true,
color: 'secondary',
options: [{
cy: `action.${VM_TEMPLATE_ACTIONS.CHANGE_OWNER}`,
accessor: VM_TEMPLATE_ACTIONS.CHANGE_OWNER,
name: T.ChangeOwner,
disabled: true,
isConfirmDialog: true,
onSubmit: () => undefined
}, {
cy: `action.${VM_TEMPLATE_ACTIONS.CHANGE_GROUP}`,
accessor: VM_TEMPLATE_ACTIONS.CHANGE_GROUP,
name: T.ChangeGroup,
disabled: true,
isConfirmDialog: true,
onSubmit: () => undefined
}, {
cy: `action.${VM_TEMPLATE_ACTIONS.SHARE}`,
accessor: VM_TEMPLATE_ACTIONS.SHARE,
disabled: true,
name: T.Share,
isConfirmDialog: true,
onSubmit: () => undefined
}, {
cy: `action.${VM_TEMPLATE_ACTIONS.UNSHARE}`,
accessor: VM_TEMPLATE_ACTIONS.UNSHARE,
disabled: true,
name: T.Unshare,
isConfirmDialog: true,
@ -203,8 +200,9 @@ const Actions = () => {
tooltip: T.Lock,
icon: Lock,
selected: true,
color: 'secondary',
options: [{
cy: `action.${VM_TEMPLATE_ACTIONS.LOCK}`,
accessor: VM_TEMPLATE_ACTIONS.LOCK,
name: T.Lock,
isConfirmDialog: true,
dialogProps: {
@ -217,7 +215,7 @@ const Actions = () => {
await Promise.all(ids.map(id => getVmTemplate(id)))
}
}, {
cy: `action.${VM_TEMPLATE_ACTIONS.UNLOCK}`,
accessor: VM_TEMPLATE_ACTIONS.UNLOCK,
name: T.Unlock,
isConfirmDialog: true,
dialogProps: {

View File

@ -26,7 +26,6 @@ const Content = ({ name, renderContent: Content, hidden }) => (
sx={{
p: theme => theme.spacing(2, 1),
height: '100%',
overflow: 'auto',
display: hidden ? 'none' : 'block'
}}
>
@ -45,13 +44,13 @@ const Tabs = ({ tabs = [], renderHiddenTabs = false }) => {
scrollButtons='auto'
onChange={(_, tab) => setTab(tab)}
>
{tabs.map(({ value, name, icon: Icon }, idx) =>
{tabs.map(({ value, name, label, icon: Icon }, idx) =>
<MTab
key={`tab-${name}`}
id={`tab-${name}`}
icon={Icon && <Icon />}
value={value ?? idx}
label={name}
label={label ?? name}
/>
)}
</MTabs>

View File

@ -83,7 +83,10 @@ const TotalProviders = ({ isLoading }) => {
return useMemo(() => (
!totalProviders && isLoading ? (
<Skeleton variant='rectangular' height={350} />
<Skeleton
variant='rectangular'
sx={{ height: { xs: 210, sm: 350 } }}
/>
) : (
<Paper
data-cy='dashboard-widget-total-providers-by-type'

View File

@ -55,7 +55,7 @@ const TotalProvisionsByState = ({ isLoading }) => {
!totalProvisions && isLoading ? (
<Skeleton
variant='rectangular'
sx={{ height: { xs: 210, sm: 210, md: 380 } }}
sx={{ height: { xs: 210, sm: 350 } }}
/>
) : (
<Paper

View File

@ -69,7 +69,8 @@ export const INPUT_TYPES = {
SELECT: 'select',
SLIDER: 'slider',
TEXT: 'text',
TABLE: 'table'
TABLE: 'table',
TOGGLE: 'toggle'
}
export const DEBUG_LEVEL = {

View File

@ -271,6 +271,7 @@ module.exports = {
Provisions: 'Provisions',
/* tabs */
General: 'General',
Information: 'Information',
Placement: 'Placement',
@ -314,7 +315,8 @@ module.exports = {
/* VM schema - info */
UserTemplate: 'User Template',
Template: 'Template',
WhereIsRunning: 'VM %1$s is currently running on Host %2$s and Datastore %3$s',
WhereIsRunning:
'VM %1$s is currently running on Host %2$s and Datastore %3$s',
/* VM schema - capacity */
Capacity: 'Capacity',
PhysicalCpu: 'Physical CPU',
@ -338,9 +340,154 @@ module.exports = {
Alias: 'Alias',
/* VM Template schema */
/* VM Template schema - booting */
/* VM schema - general */
Logo: 'Logo',
Hypervisor: 'Hypervisor',
/* VM schema - ownership */
InstantiateAsUser: 'Instantiate as different User',
InstantiateAsGroup: 'Instantiate as different Group',
/* VM Template schema - capacity */
MaxMemory: 'Max memory',
MemoryModification: 'Memory modification',
AllowUsersToModifyMemory:
"Allow users to modify this template's default memory on instantiate",
MemoryConcept: 'Amount of RAM required for the VM',
CpuConcept: `
Percentage of CPU divided by 100 required for the
Virtual Machine. Half a processor is written 0.5`,
MaxVirtualCpu: 'Max Virtual CPU',
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",
EnableHotResize: 'Enable hot resize',
/* VM schema - VM Group */
AssociateToVMGroup: 'Associate VM to a VM Group',
Role: 'Role',
/* VM Template schema - vCenter */
vCenterTemplateRef: 'vCenter Template reference',
vCenterClusterRef: 'vCenter Cluster reference',
vCenterInstanceId: 'vCenter instance ID',
vCenterVmFolder: 'vCenter VM folder',
vCenterVmFolderConcept: `
If specified, the the VMs and Template folder path where
the VM will be created inside the data center.
The path is delimited by slashes (e.g /Management/VMs).
If no path is set the VM will be placed in the same folder where
the template is located.
`,
/* VM Template schema - placement */
HostReqExpression: 'Host requirements expression',
HostReqExpressionConcept: `
Boolean expression that rules out provisioning hosts
from list of machines suitable to run this VM`,
HostPolicyExpression: 'Host policy expression',
HostPolicyExpressionConcept: `
This field sets which attribute will be used
to sort the suitable hosts for this VM`,
DatastoreReqExpression: 'Datastore requirements expression',
DatastoreReqExpressionConcept: `
Boolean expression that rules out entries from
the pool of datastores suitable to run this VM`,
DatastorePolicyExpression: 'Datastore policy expression',
DatastorePolicyExpressionConcept: `
This field sets which attribute will be used to
sort the suitable datastores for this VM`,
/* VM Template schema - OS & CPU */
/* VM Template schema - OS & CPU - boot */
Boot: 'Boot',
OSAndCpu: 'OS & CPU',
OSBooting: 'OS Booting',
BootOrder: 'Boot order',
BootOrderConcept: 'Select the devices to boot from, and their order',
CpuArchitecture: 'CPU Architecture',
BusForSdDisks: 'Bus for SD disks',
MachineType: 'Machine type',
RootDevice: 'Root devide',
KernelBootParameters: 'Kernel boot parameters',
PathBootloader: 'Path to the bootloader executable',
UniqueIdOfTheVm: 'Unique ID of the VM',
UniqueIdOfTheVmConcept: `
Its referenced as machine ID inside the VM.
Could be used to force ID for licensing purposes`,
Firmware: 'Firmware',
FirmwareConcept:
'This attribute allows to define the type of firmware used to boot the VM',
FirmwareSecure: 'Firmware secure',
CpuModel: 'CPU Model',
CustomPath: 'Customize with path',
/* VM Template schema - OS & CPU - kernel */
Kernel: 'Kernel',
KernelExpression: 'Kernel expression',
KernelPath: 'Path to the OS kernel to boot the image',
/* VM Template schema - OS & CPU - ramdisk */
Ramdisk: 'Ramdisk',
RamdiskExpression: 'Ramdisk expression',
RamdiskPath: 'Path to the initrd image',
/* VM Template schema - OS & CPU - features */
Features: 'Features',
Acpi: 'ACPI',
AcpiConcept:
'Add support in the VM for Advanced Configuration and Power Interface (ACPI)',
Pae: 'PAE',
PaeConcept: 'Add support in the VM for Physical Address Extension (PAE)',
Apic: 'APIC',
ApicConcept: 'Enables the advanced programmable IRQ management',
Hyperv: 'Hyper-v',
HypervConcept: 'Add support in the VM for hyper-v features (HYPERV)',
Localtime: 'Localtime',
LocaltimeConcept:
'The guest clock will be synchronized to the hosts configured timezone when booted',
GuestAgent: 'QEMU Guest Agent',
GuestAgentConcept:
`Enables the QEMU Guest Agent communication.
This does not start the Guest Agent inside the VM`,
VirtioQueues: 'Virtio-scsi Queues',
VirtioQueuesConcept:
`Number of vCPU queues to use in the virtio-scsi controller.
Leave blank to use the default value`,
IoThreads: 'Iothreads',
IoThreadsConcept:
`Number of iothreads for virtio disks.
By default threads will be assign to disk by round robin algorithm.
Disk thread id can be forced by disk IOTHREAD attribute`,
/* VM Template schema - context */
Context: 'Context',
/* VM Template schema - Input/Output */
InputOrOutput: 'Input / Output',
/* VM Template schema - Input/Output - graphics */
Graphics: 'Graphics',
VMRC: 'VMRC',
VNC: 'VNC',
SDL: 'SDL',
SPICE: 'SPICE',
Type: 'Type',
ListenOnIp: 'Listen on IP',
ServerPort: 'Server port',
ServerPortConcept: 'Port for the VNC/SPICE server',
Keymap: 'Keymap',
GenerateRandomPassword: 'Generate random password',
Command: 'Command',
/* VM Template schema - NUMA */
PinPolicy: 'Pin Policy',
PinPolicyConcept: 'Virtual CPU pinning preference: %s',
NumaSocketsConcept: 'Number of sockets or NUMA nodes',
NumaCoresConcept: 'Number of cores per node',
Threads: 'Threads',
ThreadsConcept: 'Number of threads per core',
HugepagesSize: 'Hugepages size',
HugepagesSizeConcept:
'Size of hugepages (MB). If not defined no hugepages will be used',
MemoryAccess: 'Memory Access',
MemoryAccessConcept: 'Control if the memory is to be mapped: %s',
VirtualCpuSelected: 'Virtual Cpu selected',
VirtualCpuSelectedConcept: `
Number of virtual CPUs. This value is optional, the default
hypervisor behavior is used, usually one virtual CPU`,
/* security group schema */
TCP: 'TCP',
@ -356,7 +503,7 @@ module.exports = {
VM_MAD: 'VM MAD',
Wilds: 'Wilds',
Zombies: 'Zombies',
Numa: 'Numa',
Numa: 'NUMA',
/* Host schema - capacity */
AllocatedMemory: 'Allocated Memory',
AllocatedCpu: 'Allocated CPU',
@ -367,5 +514,58 @@ module.exports = {
/* Cluster schema */
/* Cluster schema - capacity */
ReservedMemory: 'Allocated Memory',
ReservedCpu: 'Allocated CPU'
ReservedCpu: 'Allocated CPU',
/* User inputs */
Fixed: 'Fixed',
Range: 'Range',
List: 'List',
AnyValue: 'Any value',
/* Validation */
/* Validation - mixed */
'validation.mixed.default': 'Is invalid',
'validation.mixed.required': 'Is a required field',
'validation.mixed.oneOf': 'Must be one of the following values: %s',
'validation.mixed.notOneOf': 'Must not be one of the following values: %s',
'validation.mixed.notType': 'Invalid type',
'validation.mixed.notType.string': 'Must be a string type',
'validation.mixed.notType.number': 'Must be a number type',
'validation.mixed.notType.date': 'Must be a date type',
'validation.mixed.notType.boolean': 'Must be a boolean type',
'validation.mixed.notType.object': 'Must be an object type',
'validation.mixed.notType.array': 'Must be an array type',
'validation.mixed.defined': 'Must be defined',
/* Validation - string */
'validation.string.length': 'Must be exactly %s characters',
'validation.string.min': 'Must be at least %s characters',
'validation.string.max': 'Must be at most %s characters',
'validation.string.matches': 'Must match the following: "%s"',
'validation.string.email': 'Must be a valid email',
'validation.string.url': 'Must be a valid URL',
'validation.string.uuid': 'Must be a valid UUID',
'validation.string.trim': 'Must be a trimmed string',
'validation.string.lowercase': 'Must be a lowercase string',
'validation.string.uppercase': 'Must be a upper case string',
'validation.string.invalidFormat': 'File has invalid format',
/* Validation - number */
'validation.number.min': 'Must be greater than or equal to %s',
'validation.number.max': 'Must be less than or equal to %s',
'validation.number.lessThan': 'Must be less than %s',
'validation.number.moreThan': 'Must be greater than %s',
'validation.number.positive': 'Must be a positive number',
'validation.number.negative': 'Must be a negative number',
'validation.number.integer': 'Must be an integer',
'validation.number.isDivisible': 'Should be divisible by %s',
/* Validation - date */
'validation.date.min': 'Must be later than %s',
'validation.date.max': 'Must be at earlier than %s',
/* Validation - boolean */
'validation.boolean.isValue': 'Must be %s',
/* Validation - object */
'validation.object.noUnknown': 'Has unspecified keys: %s',
/* Validation - array */
'validation.array.min': 'Must have at least %s items',
'validation.array.max': 'Must have less than or equal to %s items',
'validation.array.length': 'Must have %s items'
}

View File

@ -32,3 +32,22 @@ export const VM_TEMPLATE_ACTIONS = {
CHANGE_OWNER: ACTIONS.CHANGE_OWNER,
CHANGE_GROUP: ACTIONS.CHANGE_GROUP
}
export const NUMA_PIN_POLICIES = ['NONE', 'THREAD', 'SHARED', 'CORE']
export const NUMA_MEMORY_ACCESS = ['shared', 'private']
export const CPU_ARCHITECTURES = ['i686', 'x86_64']
export const DEFAULT_CPU_MODELS = ['host-passthrough']
export const SD_DISK_BUSES = ['scsi', 'sata']
export const FIRMWARE_TYPES = ['BIOS']
export const KVM_FIRMWARE_TYPES = FIRMWARE_TYPES.concat([
'/usr/share/OVMF/OVMF_CODE.fd',
'/usr/share/OVMF/OVMF_CODE.secboot.fd'
])
export const VCENTER_FIRMWARE_TYPES = FIRMWARE_TYPES.concat(['uefi'])

View File

@ -69,7 +69,7 @@ const Policies = () => ({
const [tabSelected, setTab] = useState(TABS.elasticity.name)
const theme = useTheme()
const { watch, errors } = useFormContext()
const { watch, formState: { errors } } = useFormContext()
const { handleSetList } = useListForm({
key: STEP_ID,
setList: setFormData

View File

@ -69,7 +69,7 @@ function ApplicationsTemplatesCreateForm () {
useEffect(() => {
const formData = data ? parseApplicationToForm(data) : {}
methods.reset(resolvers().cast(formData), { errors: false })
methods.reset(resolvers().cast(formData), { keepErrors: false })
}, [data])
if (error) {

View File

@ -43,7 +43,7 @@ const Tiers = ({ tiers, vmTemplates }) => {
const [tabSelected, setTab] = useState(tiers?.[0]?.id)
const theme = useTheme()
const { errors } = useFormContext()
const { formState: { errors } } = useFormContext()
return (
<>

View File

@ -17,7 +17,7 @@
import * as yup from 'yup'
import { useAuth } from 'client/features/Auth'
import { getValidationFromFields } from 'client/utils'
import { getValidationFromFields, arrayToOptions } from 'client/utils'
import { Tr } from 'client/components/HOC'
import { T, INPUT_TYPES, FILTER_POOL } from 'client/constants'
@ -29,7 +29,7 @@ export const USERNAME = {
.string()
.trim()
.required('Username is a required field')
.default(null),
.default(() => ''),
grid: { md: 12 },
fieldProps: {
autoFocus: true,
@ -47,7 +47,7 @@ export const PASSWORD = {
.string()
.trim()
.required('Password is a required field')
.default(null),
.default(() => ''),
grid: { md: 12 },
fieldProps: {
required: true,
@ -62,7 +62,7 @@ export const REMEMBER = {
type: INPUT_TYPES.CHECKBOX,
validation: yup
.boolean()
.default(false),
.default(() => false),
grid: { md: 12 }
}
@ -74,7 +74,7 @@ export const TOKEN = {
.string()
.trim()
.required('Authenticator is a required field')
.default(null),
.default(() => ''),
grid: { md: 12 },
fieldProps: {
autoFocus: true,
@ -92,13 +92,13 @@ export const GROUP = {
const sortedGroupsById = groups?.sort((a, b) => a.ID - b.ID)
const formatGroups = sortedGroupsById.map(({ ID, NAME }) => {
const isPrimary = user?.GID === ID ? `(${Tr(T.Primary)})` : ''
return {
text: `${ID} - ${NAME} ${isPrimary}`,
value: String(ID)
}
const formatGroups = arrayToOptions(sortedGroupsById, {
addEmpty: false,
getText: ({ ID, NAME }) => {
const isPrimary = user?.GID === ID ? `(${Tr(T.Primary)})` : ''
return `${ID} - ${NAME} ${isPrimary}`
},
getValue: ({ ID }) => String(ID)
})
return [{ text: T.ShowAll, value: FILTER_POOL.ALL_RESOURCES }]

View File

@ -74,7 +74,7 @@ const Settings = () => {
useEffect(() => {
reset(
FORM_SCHEMA.cast(settings),
{ isSubmitted: false, error: false }
{ keepIsSubmitted: false, keepErrors: false }
)
}, [settings])

View File

@ -0,0 +1,54 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable jsdoc/require-jsdoc */
import { /* useHistory, */ useLocation } from 'react-router'
import { Container } from '@mui/material'
// import { useGeneralApi } from 'client/features/General'
// import { useVmTemplateApi } from 'client/features/One'
import { CreateForm } from 'client/components/Forms/VmTemplate'
// import { PATH } from 'client/apps/sunstone/routesOne'
import { isDevelopment } from 'client/utils'
function CreateVmTemplate () {
// const history = useHistory()
const { state: { ID: templateId } = {} } = useLocation()
// const { enqueueInfo } = useGeneralApi()
// const { instantiate } = useVmTemplateApi()
const onSubmit = async formData => {
try {
console.log({ formData })
/* const { ID, NAME } = templateSelected
await Promise.all(templates.map(template => instantiate(ID, template)))
history.push(templateId ? PATH.TEMPLATE.VMS.LIST : PATH.INSTANCE.VMS.LIST)
enqueueInfo(`VM Template instantiated x${templates.length} - #${ID} ${NAME}`) */
} catch (err) {
isDevelopment() && console.error(err)
}
}
return (
<Container style={{ display: 'flex', flexFlow: 'column' }} disableGutters>
<CreateForm templateId={templateId} onSubmit={onSubmit} />
</Container>
)
}
export default CreateVmTemplate

View File

@ -89,6 +89,10 @@ const { name, actions, reducer } = createSlice({
extraReducers: builder => {
builder
.addMatcher(({ type }) => type === logout.type, () => initial)
.addMatcher(
({ type }) => type.startsWith(RESOURCES.system) && type.endsWith('/fulfilled'),
(state, { payload }) => ({ ...state, ...payload })
)
.addMatcher(
({ type }) =>
type === updateResourceFromFetch.type ||

View File

@ -104,8 +104,12 @@ const useFetch = (request, socket) => {
}
}, [isFetched])
useEffect(() => () => {
cancelRequest.current = true
useEffect(() => {
cancelRequest.current = false
return () => {
cancelRequest.current = true
}
}, [])
const doFetch = useCallback(async (payload, reload = false) => {

View File

@ -118,7 +118,7 @@ const useListForm = ({
const getIndexById = useCallback(
(listToFind, searchId = -1) =>
listToFind.findIndex((item, idx) => getItemId(item, idx) === searchId),
listToFind?.findIndex((item, idx) => getItemId(item, idx) === searchId),
[]
)

View File

@ -14,7 +14,7 @@
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { prettyBytes } from 'client/utils'
import { HOST_STATES, StateInfo } from 'client/constants'
import { DEFAULT_CPU_MODELS, HOST_STATES, HYPERVISORS, StateInfo } from 'client/constants'
/**
* Returns information about the host state.
@ -63,3 +63,43 @@ export const getAllocatedInfo = host => {
percentMemLabel
}
}
/**
* Returns list of hugepage sizes from the host numa nodes.
*
* @param {object} host - Host
* @returns {Array} List of hugepages sizes from resource
*/
export const getHugepageSizes = host => {
const numaNodes = [host?.HOST_SHARE?.NUMA_NODES?.NODE ?? []].flat()
return numaNodes.filter(node => node?.NODE_ID &&
[node?.HUGEPAGE?.SIZE ?? []].flat().map(size => +size)
)
}
/**
* Returns list of KVM CPU Models available from the host pool.
*
* @param {object[]} hosts - Hosts
* @returns {Array} List of KVM CPU Models from the pool
*/
export const getKvmCpuModels = (hosts = []) => hosts
.filter(host => host?.TEMPLATE?.HYPERVISOR === HYPERVISORS.kvm)
.map(host => host.TEMPLATE?.KVM_CPU_MODELS.split(' '))
.flat()
/**
* Returns list of KVM Machines available from the host pool.
*
* @param {object[]} hosts - Hosts
* @returns {Array} List of KVM Machines from the pool
*/
export const getKvmMachines = (hosts = []) => {
const machineTypes = hosts
.filter(host => host?.TEMPLATE?.HYPERVISOR === HYPERVISORS.kvm)
.map(host => host.TEMPLATE?.KVM_MACHINES.split(' '))
.flat()
return [DEFAULT_CPU_MODELS, ...machineTypes]
}

View File

@ -83,8 +83,8 @@ export default (appTheme, mode = SCHEMES.DARK) => {
white
},
background: {
paper: isDarkMode ? '#2a2d3d' : white,
default: isDarkMode ? '#222431' : '#f2f4f8'
paper: isDarkMode ? primary.light : white,
default: isDarkMode ? primary.main : '#f2f4f8'
},
error: {
100: '#fdeae7',
@ -244,9 +244,20 @@ export default (appTheme, mode = SCHEMES.DARK) => {
'&:hover': {
textDecoration: 'underline'
}
}
},
fieldset: { border: 'none' }
}
},
MuiTypography: {
variants: [{
props: { component: 'legend' },
style: {
marginBottom: '1em',
padding: '0em 1em 0.2em 0.5em',
borderBottom: `2px solid ${secondary.main}`
}
}]
},
MuiPaper: {
styleOverrides: {
root: { backgroundImage: 'unset' }
@ -386,6 +397,31 @@ export default (appTheme, mode = SCHEMES.DARK) => {
}
}
},
MuiToggleButtonGroup: {
styleOverrides: {
root: {
backgroundColor: isDarkMode ? primary.main : '#f2f4f8'
}
},
defaultProps: {
color: 'secondary'
}
},
MuiToggleButton: {
styleOverrides: {
root: {
textTransform: 'none',
fontWeight: 700,
color: isDarkMode ? grey[300] : grey[700],
borderColor: isDarkMode ? secondary[500] : grey[400],
'&.Mui-selected': {
borderColor: `${secondary[500]} !important`,
color: isDarkMode ? white : secondary[800],
backgroundColor: isDarkMode ? alpha(secondary[800], 0.2) : secondary[100]
}
}
}
},
MuiList: {
defaultProps: {
dense: true,

View File

@ -290,7 +290,25 @@ export const isBase64 = (stringToValidate, options = {}) => {
/**
* Check if value is divisible by 4.
*
* @param {number} value - Number to check
* @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.
*
* @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)

View File

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

View File

@ -49,10 +49,16 @@ import { INPUT_TYPES } from 'client/constants'
* Callback of field parameter when depend of another field.
*
* @callback DependOfCallback
* @param {string|string[]} value - Value
* @param {any|any[]} value - Value
* @returns {any|any[]}
*/
/**
* @typedef {object} SelectOption - Option of select field
* @property {string|JSXElementConstructor} text - Text to display on select list
* @property {any} value - Value to option
*/
/**
* @typedef {object} Field
* @property {string|DependOfCallback} name
@ -69,13 +75,15 @@ import { INPUT_TYPES } from 'client/constants'
* @property {string|DependOfCallback} [tooltip]
* - Text description
* @property {INPUT_TYPES|DependOfCallback} type
* - Field type to draw
* - Field type to draw: text, select, autocomplete, etc
* @property {string|DependOfCallback} [htmlType]
* - Type of the input element. It should be a valid HTML5 input type
* @property {{text: string|JSXElementConstructor, value: any}[]|DependOfCallback} [values]
* @property {function(any|any[]):any} [watcher]
* - Function to watch other values
* @property {SelectOption[]|DependOfCallback} [values]
* - Type of the input element. It should be a valid HTML5 input type
* @property {boolean|DependOfCallback} [multiline]
* - If `true`, a textarea element will be rendered instead of an input.
* - If `true`, a textarea element will be rendered instead of an input
* @property {GridProps|DependOfCallback} [grid]
* - Grid properties to override in the wrapper element
* - Default: { xs: 12, md: 6 }
@ -299,6 +307,36 @@ export const mapUserInputs = (userInputs = {}) =>
...res, [key]: parseUserInputValue(value)
}), {})
/**
* Converts a list of values to usable options.
*
* @param {any[]} array - List of option values
* @param {object} [options] - Options to conversion
* @param {boolean} [options.addEmpty] - If `true`, add an empty option
* @param {function(any):any} [options.getText] - Function to get the text option
* @param {function(any):any} [options.getValue] - Function to get the value option
* @returns {SelectOption} Options
*/
export const arrayToOptions = (array = [], options = {}) => {
const { addEmpty = true, getText = o => o, getValue = o => o } = options
const values = array.map(item => ({ text: getText(item), value: getValue(item) }))
addEmpty && values.unshift({ text: '-', value: '' })
return values
}
/**
* Sanitizes the names from object.
*
* @param {string|string[]} names - List of names
* @returns {string|string[]} Sanitized names
* @example 'TEST.NAME' => 'NAME'
*/
export const clearNames = (names = '') =>
Array.isArray(names) ? names.map(clearNames) : names.split(/[,[\].]+?/).at(-1)
/**
* Returns parameters needed to create stepper form.
*

View File

@ -0,0 +1,96 @@
/* ------------------------------------------------------------------------- *
* Copyright 2002-2021, OpenNebula Project, OpenNebula Systems *
* *
* Licensed under the Apache License, Version 2.0 (the "License"); you may *
* not use this file except in compliance with the License. You may obtain *
* a copy of the License at *
* *
* http://www.apache.org/licenses/LICENSE-2.0 *
* *
* Unless required by applicable law or agreed to in writing, software *
* distributed under the License is distributed on an "AS IS" BASIS, *
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
/* eslint-disable react/display-name */
/* eslint-disable react/prop-types */
import { setLocale, addMethod, number, string } from 'yup'
import { T } from 'client/constants'
import { isDivisibleBy, isBase64 } from 'client/utils/helpers'
const buildMethods = () => {
addMethod(number, 'isDivisibleBy', function (divisor) {
return this.test(
'is-divisible',
[T['validation.number.isDivisible'], divisor],
value => isDivisibleBy(value, divisor)
)
})
addMethod(string, 'isBase64', function () {
return this.test(
'is-base64',
T['validation.string.invalidFormat'],
value => isBase64(value)
)
})
}
/**
* Function that runs the yup.setLocale().
*/
const buildTranslationLocale = () => {
buildMethods()
setLocale({
mixed: {
default: () => T['validation.mixed.default'],
required: () => T['validation.mixed.required'],
defined: () => T['validation.mixed.defined'],
oneOf: ({ values }) => [T['validation.mixed.oneOf'], values],
notOneOf: ({ values }) => [T['validation.mixed.notOneOf'], values],
notType: ({ type }) =>
T[`validation.mixed.notType.${type}`] ?? T['validation.mixed.notType']
},
string: {
length: ({ length }) => [T['validation.string.length'], length],
min: ({ min }) => [T['validation.string.min'], min],
max: ({ max }) => [T['validation.string.max'], max],
matches: ({ matches }) => [T['validation.string.matches'], matches],
email: () => T['validation.string.email'],
url: () => T['validation.string.url'],
uuid: () => T['validation.string.uuid'],
trim: () => T['validation.string.trim'],
lowercase: () => T['validation.string.lowercase'],
uppercase: () => T['validation.string.uppercase']
},
number: {
min: ({ min }) => [T['validation.number.min'], min],
max: ({ max }) => [T['validation.number.max'], max],
lessThan: ({ less }) => [T['validation.number.lessThan'], less],
moreThan: ({ more }) => [T['validation.number.moreThan'], more],
positive: () => T['validation.number.positive'],
negative: () => T['validation.number.negative'],
integer: () => T['validation.number.integer']
},
boolean: {
isValue: ({ value }) => [T['validation.boolean.isValue'], value]
},
date: {
min: ({ min }) => [T['validation.date.min'], min],
max: ({ max }) => [T['validation.date.max'], max]
},
object: {
noUnknown: ({ nounknown }) => [T['validation.object.noUnknown'], nounknown]
},
array: {
min: ({ min }) => [T['validation.array.min'], min],
max: ({ max }) => [T['validation.array.max'], max],
length: ({ length }) => [T['validation.array.length'], length]
}
})
}
export { buildTranslationLocale }