-
Notifications
You must be signed in to change notification settings - Fork 12
Improve editors by using a form
Currently editors are still really complicated, they require refs/state which is not really the best thing to do.
One known fact is that <form>
provide: access to all field data & validation functions. When the validation function of a <form>
is called, it may focus the invalid form and provide the reason (in user's language).
To achieve that goal we built two hooks:
-
useZodForm
give all the necessary handle function + validation function to turn a Editor into a validated form -
useInputAttrsWithLabel
give shortcut to simpler Input/Select element that guess all validation/defaults with name and let you give a label so the editor code is easier.
In order to make the <Editor />
work with a <form>
you should swap <InputContainer>
with <InputFormContainer ref={formRef}>
.
Combining the hooks with the new component can turn the <BreedingEditor />
from:
return (
<Editor type="edit" title={t('breeding')}>
<InputContainer>
<InputWithTopLabelContainer>
<Label htmlFor="baby">{t('baby')}</Label>
<SelectPokemon dbSymbol={baby} onChange={(value) => setBaby(value as DbSymbol)} noLabel />
</InputWithTopLabelContainer>
<InputWithTopLabelContainer>
<Label htmlFor="form">{t('form')}</Label>
<SelectPokemonForm dbSymbol={baby} form={babyForm} onChange={(value) => setBabyForm(Number(value))} noLabel />
</InputWithTopLabelContainer>
<InputWithTopLabelContainer>
<Label htmlFor="breed_group_1">{t('egg_group_1')}</Label>
<SelectCustomSimple
id="select-breed-group-1"
options={breedingGroupOptions}
onChange={(value) => setBreedGroup1(parseInt(value))}
value={breedGroup1.toString()}
noTooltip
/>
</InputWithTopLabelContainer>
<InputWithTopLabelContainer>
<Label htmlFor="breed_group_2">{t('egg_group_2')}</Label>
<SelectCustomSimple
id="select-breed-group-2"
options={breedingGroupOptions}
onChange={(value) => setBreedGroup2(parseInt(value))}
value={breedGroup2.toString()}
noTooltip
/>
</InputWithTopLabelContainer>
<InputWithLeftLabelContainer>
<Label htmlFor="hatch_steps">{t('hatch_steps')}</Label>
<Input name="hatch_steps" type="number" defaultValue={form.hatchSteps} min={0} max={99999} ref={hatchStepsRef} />
</InputWithLeftLabelContainer>
</InputContainer>
</Editor>
);
To:
return (
<Editor type="edit" title={t('breeding')}>
<InputFormContainer ref={formRef}>
<InputWithTopLabelContainer>
<Label htmlFor="baby">{t('baby')}</Label>
<SelectPokemon2 name="babyDbSymbol" defaultValue={defaults.babyDbSymbol as DbSymbol} onChange={setBaby} />
</InputWithTopLabelContainer>
<InputWithTopLabelContainer>
<Label htmlFor="form">{t('form')}</Label>
<SelectCreatureForm dbSymbol={baby} name="babyForm" defaultValue={defaults.babyForm} />
</InputWithTopLabelContainer>
<Select name="breedGroups.0" label={t('egg_group_1')} options={breedingGroupOptions} data-input-type="number" />
<Select name="breedGroups.1" label={t('egg_group_2')} options={breedingGroupOptions} data-input-type="number" />
<Input name="hatchSteps" label={t('hatch_steps')} labelLeft onInput={onInputTouched} />
</InputFormContainer>
</Editor>
);
As you can see the jsx is much simpler this way, only 1 ref and all the inputs are simplified to reflect what they are expected to edit in the jsx.
The onClose
event from the editor also becomes easier, from:
const onClose = () => {
if (!hatchStepsRef.current || !canClose()) return;
const hatchSteps = isNaN(hatchStepsRef.current.valueAsNumber) ? form.hatchSteps : hatchStepsRef.current.valueAsNumber;
updateForm({ breedGroups: [breedGroup1, breedGroup2], babyDbSymbol: baby, babyForm, hatchSteps });
};
To:
const onClose = () => {
const result = canClose() && getFormData();
if (result && result.success) updateForm(result.data);
};
Note
It can sometime be a bit more complex, it all depends on how complex the editor is, when it's just stupid select & input it's that easy, when you have specific business logic, it becomes more complex.
Inputs:
-
schema
:z.ZodObject
object describing exactly which part of the entity schema you're editing in the current form. -
defaults
: all the default values for this specific editor (as an record) -
fixturesBeforeValidation(input)
: an optional function allowing you to rework the object generated ingetRawFormData
before it's being returned bygetRawFormData
(and passed togetFormData
for validation).
Detail about fixturesBeforeValidation
: If you have special input names that do not match the schema but are kind of required for special logic (example, select+custom input for custom battle method) you can use this method to rework the output. The input is all the objects got from the form as if this function is not there, the output is your reworked output so it's complying with the schema.
Example:
const fixturesBeforeValidation = (input: Record<string, unknown>) => {
const { battleMethod, battleMethodCustom, ...rest } = input;
if (battleMethod === 'custom') return { battleMethod: battleMethodCustom, ...rest };
return { battleMethod, ...rest };
}
Outputs:
-
isValid
: Boolean describing if the form is valid judging all the touched inputs (ignoring selects most of the time) -
onTouched(inputName, isValid, value)
: function allowing you to update the isValid status when necessary (input value changed and validity changed). -
onInputTouched(event)
: function to provide to theonInput
functions of standardHTMLInputElement
s to asses validity of the input -
formRef
:HTMLFormElement
ref to provide to the<form>
element holding all the inputs of this editor. -
defaults
: Default values in flattened format (defaults['key.0.key2.3']
instead ofdefaults.key[0].key2[3]
). -
defaultValid
: Default validity status (in regard of default values) -
getFormData
: get the result ofschema.safeParse(getRawFormData)
to assess that the data is valid and only holds what's edited (ignore all the extra inputs for business logic in the form). -
getRawFormData
: get all theHTMLInputElement | HTMLTextAreaElement
values with conversion and potentialfixturesBeforeValidation
. -
canClose
: Function to pass touseEditorHandlingClose
(can also be used inonClose
for validity assessment).
Note
canClose
can turn invalid field to their default values so you can close the editor in case you just forgot to fill fields after erasing them.
Important
As per business requirements canClose
cannot set the default value if the input is invalid. A 0.1 precision number form should show invalidity if you entered 0.05 inside. (And prevent you from closing the editor.)
Conversions
- Type number /
data-input-type="number"
inputs get their non-empty values converted through theNumber
constructor. - Type checkbox inputs gets their values converted to boolean (checked).
- Empty values gets converted to:
-
null
fordata-input-empty-type="null"
inputs. -
data-input-empty-default-value
value for inputs havingdata-input-empty-default-value
(always string)
-
Inputs:
-
schema
:z.ZodObject
object describing exactly which part of the entity schema you're editing in the current form. It's being used to extract input attributes (such as pattern for .regexp, min, max, minLength, maxLength etc...) -
defaults
: defaults returned byuseZodForm
so input can show with those values on open.
Outputs:
-
<Input name, schemaKey, label, labelLeft, ...props />
: Regular input with label (props specify theHTMLInputElement
props) -
<EmbeddedUnitInput name, schemaKey, label, labelLeft, ...props />
: EmbeddedUnitInput with label (props specify theEmbeddedUnitInput
props). -
<Select name, schemaKey, label, labelLeft, ...props />
:@ds/Select
element with label (props specify the<Select />
props). -
<Toggle name, schemaKey, label, ...props />
: Toggle with label (props specify theHTMLInputElement
props)
Note
Most of the time schemaKey
is undefined as it's used to overwrite name
in case you have a different name for special logic (then the constraints will be guessed from schemaKey
instead of name
, defaults remains guessed from name
).
Tip
You can find plenty of examples in the code.