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

F OpenNebula/one#5637: Stepper configurable by prev. choices (#2663)

This commit is contained in:
vichansson 2023-07-10 11:36:49 +03:00 committed by GitHub
parent 00e5419209
commit 76653e2142
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
12 changed files with 360 additions and 161 deletions

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo } from 'react'
import { memo, useCallback } from 'react'
import PropTypes from 'prop-types'
import { TextField, Chip, Autocomplete } from '@mui/material'
@ -34,6 +34,7 @@ const AutocompleteController = memo(
values = [],
fieldProps: { separators, ...fieldProps } = {},
readOnly = false,
onConditionChange,
}) => {
const {
field: { value: renderValue, onBlur, onChange },
@ -44,22 +45,30 @@ const AutocompleteController = memo(
? renderValue ?? []
: values.find(({ value }) => value === renderValue) ?? null
const handleChange = useCallback(
(_, newValue) => {
const newValueToChange = multiple
? newValue?.map((value) =>
['string', 'number'].includes(typeof value)
? value
: { text: value, value }
)
: newValue?.value
onChange(newValueToChange ?? '')
if (typeof onConditionChange === 'function') {
onConditionChange(newValueToChange ?? '')
}
},
[onChange, onConditionChange, multiple]
)
return (
<Autocomplete
fullWidth
color="secondary"
onBlur={onBlur}
onChange={(_, newValue) => {
const newValueToChange = multiple
? newValue?.map((value) =>
['string', 'number'].includes(typeof value)
? value
: { text: value, value }
)
: newValue?.value
return onChange(newValueToChange ?? '')
}}
onChange={handleChange}
options={values}
value={selected}
multiple={multiple}
@ -126,6 +135,7 @@ AutocompleteController.propTypes = {
values: PropTypes.arrayOf(PropTypes.object),
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
onConditionChange: PropTypes.func,
}
AutocompleteController.displayName = 'AutocompleteController'

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo } from 'react'
import { memo, useCallback } from 'react'
import PropTypes from 'prop-types'
import {
@ -44,18 +44,30 @@ const CheckboxController = memo(
tooltip,
fieldProps = {},
readOnly = false,
onConditionChange,
}) => {
const {
field: { value = false, onChange },
fieldState: { error },
} = useController({ name, control })
const handleChange = useCallback(
(e) => {
const condition = e.target.checked
onChange(condition)
if (typeof onConditionChange === 'function') {
onConditionChange(condition)
}
},
[onChange, onConditionChange]
)
return (
<FormControl error={Boolean(error)} margin="dense">
<FormControlLabel
control={
<Checkbox
onChange={(e) => onChange(e.target.checked)}
onChange={handleChange}
name={name}
readOnly={readOnly}
checked={Boolean(value)}
@ -90,6 +102,7 @@ CheckboxController.propTypes = {
tooltip: PropTypes.any,
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
onConditionChange: PropTypes.func,
}
CheckboxController.displayName = 'CheckboxController'

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo, useEffect, useState } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import { useFormContext } from 'react-hook-form'
import { ErrorHelper } from 'client/components/FormControl'
@ -22,7 +22,12 @@ import { generateKey } from 'client/utils'
import InputCode from 'client/components/FormControl/InputCode'
const DockerfileController = memo(
({ control, cy = `input-${generateKey()}`, name = '' }) => {
({
control,
cy = `input-${generateKey()}`,
name = '',
onConditionChange,
}) => {
const {
getValues,
setValue,
@ -38,15 +43,23 @@ const DockerfileController = memo(
setInternalError(messageError)
}, [messageError])
const handleChange = useCallback(
(value) => {
setValue(name, value)
if (typeof onConditionChange === 'function') {
onConditionChange(value)
}
},
[setValue, onConditionChange, name]
)
return (
<div data-cy={cy}>
<InputCode
mode="dockerfile"
height="600px"
value={getValues(name)}
onChange={(value) => {
setValue(name, value)
}}
onChange={handleChange}
onFocus={(e) => {
setInternalError()
}}
@ -62,6 +75,7 @@ DockerfileController.propTypes = {
control: PropTypes.object,
cy: PropTypes.string,
name: PropTypes.string.isRequired,
onConditionChange: PropTypes.func,
}
DockerfileController.displayName = 'DockerfileController'

View File

@ -50,6 +50,7 @@ const FileController = memo(
transform,
fieldProps = {},
readOnly = false,
onConditionChange,
}) => {
const { setValue, setError, clearErrors, watch } = useFormContext()
@ -108,6 +109,10 @@ const FileController = memo(
const parsedValue = transform ? await transform(file) : file
setValue(name, parsedValue)
handleDelayState()
if (typeof onConditionChange === 'function') {
onConditionChange(parsedValue)
}
} catch (err) {
setValue(name, undefined)
handleDelayState(err?.message ?? err)
@ -165,6 +170,7 @@ FileController.propTypes = {
transform: PropTypes.func,
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
onConditionChange: PropTypes.func,
}
FileController.displayName = 'FileController'

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo, useMemo, useEffect } from 'react'
import { memo, useMemo, useEffect, useCallback } from 'react'
import PropTypes from 'prop-types'
import { TextField } from '@mui/material'
@ -37,6 +37,7 @@ const SelectController = memo(
dependencies,
fieldProps = {},
readOnly = false,
onConditionChange,
}) => {
const watch = useWatch({
name: dependencies,
@ -85,27 +86,38 @@ const SelectController = memo(
onChange(ensuredWatcherValue ?? defaultValue)
}, [watch, watcher, dependencies])
const handleChange = useCallback(
(evt) => {
if (!multiple) {
onChange(evt)
if (typeof onConditionChange === 'function') {
onConditionChange(evt)
}
} else {
const {
target: { options },
} = evt
const newValue = []
for (const option of options) {
option.selected && newValue.push(option.value)
}
onChange(newValue)
if (typeof onConditionChange === 'function') {
onConditionChange(newValue)
}
}
},
[onChange, onConditionChange, multiple]
)
return (
<TextField
{...inputProps}
inputRef={ref}
value={optionSelected}
onChange={
!multiple
? onChange
: (evt) => {
const {
target: { options },
} = evt
const newValue = []
for (const option of options) {
option.selected && newValue.push(option.value)
}
onChange(newValue)
}
}
onChange={handleChange}
select
fullWidth
disabled={readOnly}
@ -157,6 +169,7 @@ SelectController.propTypes = {
]),
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
onConditionChange: PropTypes.func,
}
SelectController.displayName = 'SelectController'

View File

@ -34,6 +34,7 @@ const SliderController = memo(
dependencies,
fieldProps = {},
readOnly = false,
onConditionChange,
}) => {
const watch = useWatch({
name: dependencies,
@ -66,6 +67,18 @@ const SliderController = memo(
const sliderId = `${cy}-slider`
const inputId = `${cy}-input`
const handleChange = useCallback(
(_, newValue) => {
if (!readOnly) {
onChange(newValue)
if (typeof onConditionChange === 'function') {
onConditionChange(newValue)
}
}
},
[onChange, onConditionChange, readOnly]
)
return (
<>
<Stack
@ -82,7 +95,7 @@ const SliderController = memo(
valueLabelDisplay="auto"
disabled={readOnly}
data-cy={sliderId}
onChange={(_, val) => onChange(val)}
onChange={handleChange}
{...fieldProps}
/>
<TextField
@ -134,6 +147,7 @@ SliderController.propTypes = {
]),
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
onConditionChange: PropTypes.func,
}
SliderController.displayName = 'SliderController'

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo } from 'react'
import { memo, useCallback } from 'react'
import PropTypes from 'prop-types'
import {
@ -44,19 +44,31 @@ const SwitchController = memo(
tooltip,
fieldProps = {},
readOnly = false,
onConditionChange,
}) => {
const {
field: { value = false, onChange },
fieldState: { error },
} = useController({ name, control })
const handleChange = useCallback(
(e) => {
const condition = e.target.checked
onChange(condition)
if (typeof onConditionChange === 'function') {
onConditionChange(condition)
}
},
[onChange, onConditionChange]
)
return (
<FormControl error={Boolean(error)} margin="dense">
<FormControlLabel
control={
<Switch
readOnly={readOnly}
onChange={(e) => onChange(e.target.checked)}
onChange={handleChange}
name={name}
checked={Boolean(value)}
color="secondary"
@ -90,6 +102,7 @@ SwitchController.propTypes = {
tooltip: PropTypes.any,
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
onConditionChange: PropTypes.func,
}
SwitchController.displayName = 'SwitchController'

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo, useEffect, useState } from 'react'
import { memo, useCallback, useEffect, useState } from 'react'
import PropTypes from 'prop-types'
import { useFormContext, useController } from 'react-hook-form'
@ -43,6 +43,7 @@ const TableController = memo(
singleSelect = true,
getRowId = defaultGetRowId,
readOnly = false,
onConditionChange,
fieldProps: { initialState, ...fieldProps } = {},
}) => {
const { clearErrors } = useFormContext()
@ -61,6 +62,30 @@ const TableController = memo(
setInitialRows({})
}, [Table])
const handleSelectedRowsChange = useCallback(
(rows) => {
if (readOnly) return
const rowValues = rows?.map(({ original }) => getRowId(original))
onChange(singleSelect ? rowValues?.[0] : rowValues)
clearErrors(name)
if (typeof onConditionChange === 'function') {
onConditionChange(singleSelect ? rowValues?.[0] : rowValues)
}
},
[
onChange,
clearErrors,
name,
onConditionChange,
readOnly,
getRowId,
singleSelect,
]
)
return (
<>
<Legend title={label} tooltip={tooltip} />
@ -75,14 +100,7 @@ const TableController = memo(
singleSelect={singleSelect}
getRowId={getRowId}
initialState={{ ...initialState, selectedRowIds: initialRows }}
onSelectedRowsChange={(rows) => {
if (readOnly) return
const rowValues = rows?.map(({ original }) => getRowId(original))
onChange(singleSelect ? rowValues?.[0] : rowValues)
clearErrors(name)
}}
onSelectedRowsChange={handleSelectedRowsChange}
{...fieldProps}
/>
</>
@ -106,6 +124,7 @@ TableController.propTypes = {
tooltip: PropTypes.any,
fieldProps: PropTypes.object,
readOnly: PropTypes.bool,
onConditionChange: PropTypes.func,
}
TableController.displayName = 'TableController'

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo, useEffect } from 'react'
import { memo, useCallback, useEffect } from 'react'
import PropTypes from 'prop-types'
import { TextField } from '@mui/material'
@ -36,6 +36,7 @@ const TextController = memo(
dependencies,
fieldProps = {},
readOnly = false,
onConditionChange,
}) => {
const watch = useWatch({
name: dependencies,
@ -55,13 +56,24 @@ const TextController = memo(
watcherValue !== undefined && onChange(watcherValue)
}, [watch, watcher, dependencies])
const handleChange = useCallback(
(e) => {
const condition = e.target.value
onChange(condition)
if (typeof onConditionChange === 'function') {
onConditionChange(condition)
}
},
[onChange, onConditionChange]
)
return (
<TextField
{...inputProps}
fullWidth
inputRef={ref}
value={value}
onChange={onChange}
onChange={handleChange}
multiline={multiline}
rows={3}
type={type}
@ -111,6 +123,7 @@ TextController.propTypes = {
]),
fieldProps: PropTypes.oneOfType([PropTypes.object, PropTypes.bool]),
readOnly: PropTypes.bool,
onConditionChange: PropTypes.func,
}
TextController.displayName = 'TextController'

View File

@ -13,7 +13,7 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { memo, useEffect } from 'react'
import { memo, useCallback, useEffect } from 'react'
import PropTypes from 'prop-types'
import {
@ -50,6 +50,7 @@ const ToggleController = memo(
fieldProps = {},
notNull = false,
readOnly = false,
onConditionChange,
}) => {
const {
field: { ref, value: optionSelected, onChange },
@ -62,6 +63,17 @@ const ToggleController = memo(
!exists && onChange()
}
}, [])
const handleChange = useCallback(
(_, newValues) => {
if (!readOnly && (!notNull || newValues)) {
onChange(newValues)
if (typeof onConditionChange === 'function') {
onConditionChange(newValues)
}
}
},
[onChange, onConditionChange, readOnly, notNull]
)
return (
<FormControl fullWidth margin="dense">
@ -75,9 +87,7 @@ const ToggleController = memo(
fullWidth
ref={ref}
id={cy}
onChange={(_, newValues) =>
!readOnly && (!notNull || newValues) && onChange(newValues)
}
onChange={handleChange}
value={optionSelected}
exclusive={!multiple}
data-cy={cy}
@ -115,6 +125,7 @@ ToggleController.propTypes = {
fieldProps: PropTypes.object,
notNull: PropTypes.bool,
readOnly: PropTypes.bool,
onConditionChange: PropTypes.func,
}
ToggleController.displayName = 'ToggleController'

View File

@ -13,7 +13,15 @@
* See the License for the specific language governing permissions and *
* limitations under the License. *
* ------------------------------------------------------------------------- */
import { useState, useMemo, useCallback, useEffect, ReactElement } from 'react'
import {
createContext,
useContext,
useState,
useMemo,
useCallback,
useEffect,
ReactElement,
} from 'react'
import PropTypes from 'prop-types'
import { BaseSchema } from 'yup'
@ -68,6 +76,18 @@ DefaultFormStepper.propTypes = {
resolver: PropTypes.func,
}
const DisableStepContext = createContext(() => {})
/**
* Hook that can be used to enable/disable steps in the stepper dialog.
*
* @returns {Function} A function that is currently provided by the DisableStepContext.
* The function takes a stepId or an array of stepIds and a condition to disable or enable the steps.
* @example
* const disableStep = useDisableStep();
* disableStep('step1', true); // This will disable 'step1'
*/
export const useDisableStep = () => useContext(DisableStepContext)
/**
* Represents a form with one or more steps.
* Finally, it submit the result.
@ -78,7 +98,7 @@ DefaultFormStepper.propTypes = {
* @param {Function} props.onSubmit - Submit function
* @returns {ReactElement} Stepper form component
*/
const FormStepper = ({ steps = [], schema, onSubmit }) => {
const FormStepper = ({ steps: initialSteps = [], schema, onSubmit }) => {
const isMobile = useMediaQuery((theme) => theme.breakpoints.only('xs'))
const {
watch,
@ -87,6 +107,31 @@ const FormStepper = ({ steps = [], schema, onSubmit }) => {
setError,
} = useFormContext()
const { isLoading } = useGeneral()
const [steps, setSteps] = useState(initialSteps)
const [disabledSteps, setDisabledSteps] = useState({})
const disableStep = useCallback((stepIds, shouldDisable) => {
const ids = Array.isArray(stepIds) ? stepIds : [stepIds]
setDisabledSteps((prev) => {
let newDisabledSteps = { ...prev }
// eslint-disable-next-line no-shadow
ids.forEach((stepId) => {
newDisabledSteps = shouldDisable
? { ...newDisabledSteps, [stepId]: true }
: (({ [stepId]: _, ...rest }) => rest)(newDisabledSteps)
})
return newDisabledSteps
})
}, [])
useEffect(() => {
// filter out disabled steps
const enabledSteps = initialSteps.filter((step) => !disabledSteps[step.id])
setSteps(enabledSteps)
}, [disabledSteps, initialSteps])
const [formData, setFormData] = useState(() => watch())
const [activeStep, setActiveStep] = useState(FIRST_STEP)
@ -183,45 +228,49 @@ const FormStepper = ({ steps = [], schema, onSubmit }) => {
)
const { id: stepId, content: Content } = useMemo(
() => steps[activeStep],
() => steps[activeStep] || { id: null, content: null },
[formData, activeStep]
)
return (
<>
{/* STEPPER */}
{useMemo(
() =>
isMobile ? (
<CustomMobileStepper
steps={steps}
totalSteps={totalSteps}
activeStep={activeStep}
lastStep={lastStep}
disabledBack={disabledBack}
isSubmitting={isLoading}
handleNext={handleNext}
handleBack={handleBack}
errors={errors}
/>
) : (
<CustomStepper
steps={steps}
activeStep={activeStep}
lastStep={lastStep}
disabledBack={disabledBack}
isSubmitting={isLoading}
handleStep={handleStep}
handleNext={handleNext}
handleBack={handleBack}
errors={errors}
/>
),
[isLoading, isMobile, activeStep, errors[stepId]]
)}
{/* FORM CONTENT */}
{Content && <Content data={formData[stepId]} setFormData={setFormData} />}
</>
<DisableStepContext.Provider value={disableStep}>
<>
{/* STEPPER */}
{useMemo(
() =>
isMobile ? (
<CustomMobileStepper
steps={steps}
totalSteps={totalSteps}
activeStep={activeStep}
lastStep={lastStep}
disabledBack={disabledBack}
isSubmitting={isLoading}
handleNext={handleNext}
handleBack={handleBack}
errors={errors}
/>
) : (
<CustomStepper
steps={steps}
activeStep={activeStep}
lastStep={lastStep}
disabledBack={disabledBack}
isSubmitting={isLoading}
handleStep={handleStep}
handleNext={handleNext}
handleBack={handleBack}
errors={errors}
/>
),
[isLoading, isMobile, activeStep, errors[stepId], steps]
)}
{/* FORM CONTENT */}
{Content && (
<Content data={formData[stepId]} setFormData={setFormData} />
)}
</>
</DisableStepContext.Provider>
)
}

View File

@ -31,6 +31,7 @@ import * as FC from 'client/components/FormControl'
import Legend from 'client/components/Forms/Legend'
import { INPUT_TYPES } from 'client/constants'
import { Field } from 'client/utils'
import { useDisableStep } from 'client/components/FormStepper'
const NOT_DEPEND_ATTRIBUTES = [
'watcher',
@ -145,79 +146,98 @@ FormWithSchema.propTypes = {
rootProps: PropTypes.object,
}
const FieldComponent = memo(({ id, cy, dependOf, ...attributes }) => {
const formContext = useFormContext()
const FieldComponent = memo(
({ id, cy, dependOf, stepControl, ...attributes }) => {
const formContext = useFormContext()
const disableSteps = useDisableStep()
const addIdToName = useCallback(
(n) => {
// removes character '$' and returns
if (n.startsWith('$')) return n.slice(1)
const addIdToName = useCallback(
(n) => {
// removes character '$' and returns
if (n.startsWith('$')) return n.slice(1)
// concat form ID if exists
return id ? `${id}.${n}` : n
},
[id]
)
const nameOfDependField = useMemo(() => {
if (!dependOf) return null
return Array.isArray(dependOf)
? dependOf.map(addIdToName)
: addIdToName(dependOf)
}, [dependOf, addIdToName])
const valueOfDependField = useWatch({
name: nameOfDependField,
disabled: dependOf === undefined,
defaultValue: Array.isArray(dependOf) ? [] : undefined,
})
const { name, type, htmlType, grid, ...fieldProps } = Object.entries(
attributes
).reduce((field, attribute) => {
const [attrKey, value] = attribute
const isNotDependAttribute = NOT_DEPEND_ATTRIBUTES.includes(attrKey)
const finalValue =
typeof value === 'function' &&
!isNotDependAttribute &&
!isValidElement(value())
? value(valueOfDependField, formContext)
: value
return { ...field, [attrKey]: finalValue }
}, {})
const dataCy = useMemo(() => `${cy}-${name ?? ''}`.replaceAll('.', '-'), [cy])
const inputName = useMemo(() => addIdToName(name), [addIdToName, name])
const isHidden = useMemo(() => htmlType === INPUT_TYPES.HIDDEN, [htmlType])
const key = useMemo(
() =>
fieldProps?.values
? `${name}-${JSON.stringify(fieldProps.values)}`
: undefined,
[fieldProps]
)
if (isHidden) return null
return (
INPUT_CONTROLLER[type] && (
<Grid item xs={12} md={6} {...grid}>
{createElement(INPUT_CONTROLLER[type], {
key,
control: formContext.control,
cy: dataCy,
dependencies: nameOfDependField,
name: inputName,
type: htmlType === false ? undefined : htmlType,
...fieldProps,
})}
</Grid>
// concat form ID if exists
return id ? `${id}.${n}` : n
},
[id]
)
)
})
const nameOfDependField = useMemo(() => {
if (!dependOf) return null
return Array.isArray(dependOf)
? dependOf.map(addIdToName)
: addIdToName(dependOf)
}, [dependOf, addIdToName])
const valueOfDependField = useWatch({
name: nameOfDependField,
disabled: dependOf === undefined,
defaultValue: Array.isArray(dependOf) ? [] : undefined,
})
const handleConditionChange = useCallback(
(value) => {
if (stepControl?.condition) {
if (stepControl.condition(value)) {
disableSteps && disableSteps(stepControl.steps, true)
} else {
disableSteps && disableSteps(stepControl.steps, false)
}
}
},
[stepControl, disableSteps]
)
const { name, type, htmlType, grid, condition, ...fieldProps } =
Object.entries(attributes).reduce((field, attribute) => {
const [attrKey, value] = attribute
const isNotDependAttribute = NOT_DEPEND_ATTRIBUTES.includes(attrKey)
const finalValue =
typeof value === 'function' &&
!isNotDependAttribute &&
!isValidElement(value())
? value(valueOfDependField, formContext)
: value
return { ...field, [attrKey]: finalValue }
}, {})
const dataCy = useMemo(
() => `${cy}-${name ?? ''}`.replaceAll('.', '-'),
[cy]
)
const inputName = useMemo(() => addIdToName(name), [addIdToName, name])
const isHidden = useMemo(() => htmlType === INPUT_TYPES.HIDDEN, [htmlType])
const key = useMemo(
() =>
fieldProps?.values
? `${name}-${JSON.stringify(fieldProps.values)}`
: undefined,
[fieldProps]
)
if (isHidden) return null
return (
INPUT_CONTROLLER[type] && (
<Grid item xs={12} md={6} {...grid}>
{createElement(INPUT_CONTROLLER[type], {
key,
control: formContext.control,
cy: dataCy,
dependencies: nameOfDependField,
name: inputName,
type: htmlType === false ? undefined : htmlType,
onConditionChange: handleConditionChange,
...fieldProps,
})}
</Grid>
)
)
}
)
FieldComponent.propTypes = {
id: PropTypes.string,
@ -226,6 +246,10 @@ FieldComponent.propTypes = {
PropTypes.string,
PropTypes.arrayOf(PropTypes.string),
]),
stepControl: PropTypes.shape({
condition: PropTypes.func,
steps: PropTypes.arrayOf(PropTypes.string),
}),
}
FieldComponent.displayName = 'FieldComponent'