Improve editors by using a form - PokemonWorkshop/PokemonStudio GitHub Wiki
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:
-
useZodFormgive all the necessary handle function + validation function to turn a Editor into a validated form -
useInputAttrsWithLabelgive 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.
-
schema:z.ZodObjectobject 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 ingetRawFormDatabefore it's being returned bygetRawFormData(and passed togetFormDatafor 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.
const fixturesBeforeValidation = (input: Record<string, unknown>) => {
const { battleMethod, battleMethodCustom, ...rest } = input;
if (battleMethod === 'custom') return { battleMethod: battleMethodCustom, ...rest };
return { battleMethod, ...rest };
}-
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 theonInputfunctions of standardHTMLInputElements to asses validity of the input -
formRef:HTMLFormElementref 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 | HTMLTextAreaElementvalues with conversion and potentialfixturesBeforeValidation. -
canClose: Function to pass touseEditorHandlingClose(can also be used inonClosefor 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.)
- Type number /
data-input-type="number"inputs get their non-empty values converted through theNumberconstructor. - Type checkbox inputs gets their values converted to boolean (checked).
- Empty values gets converted to:
-
nullfordata-input-empty-type="null"inputs. -
data-input-empty-default-valuevalue for inputs havingdata-input-empty-default-value(always string)
-
-
schema:z.ZodObjectobject 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 byuseZodFormso input can show with those values on open.
-
<Input name, schemaKey, label, labelLeft, ...props />: Regular input with label (props specify theHTMLInputElementprops) -
<EmbeddedUnitInput name, schemaKey, label, labelLeft, ...props />: EmbeddedUnitInput with label (props specify theEmbeddedUnitInputprops). -
<Select name, schemaKey, label, labelLeft, ...props />:@ds/Selectelement with label (props specify the<Select />props). -
<Toggle name, schemaKey, label, ...props />: Toggle with label (props specify theHTMLInputElementprops)
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.