Skip to content

Improve editors by using a form

Palbolsky edited this page Jun 18, 2024 · 4 revisions

Currently editors are still really complicated, they require refs/state which is not really the best thing to do.

Demo

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.

Documentation

useZodForm

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 in getRawFormData before it's being returned by getRawFormData (and passed to getFormData 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 the onInput functions of standard HTMLInputElements 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 of defaults.key[0].key2[3]).
  • defaultValid: Default validity status (in regard of default values)
  • getFormData: get the result of schema.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 the HTMLInputElement | HTMLTextAreaElement values with conversion and potential fixturesBeforeValidation.
  • canClose: Function to pass to useEditorHandlingClose (can also be used in onClose 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 the Number constructor.
  • Type checkbox inputs gets their values converted to boolean (checked).
  • Empty values gets converted to:
    • null for data-input-empty-type="null" inputs.
    • data-input-empty-default-value value for inputs having data-input-empty-default-value (always string)

useInputAttrsWithLabel

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 by useZodForm so input can show with those values on open.

Outputs:

  • <Input name, schemaKey, label, labelLeft, ...props />: Regular input with label (props specify the HTMLInputElement props)
  • <EmbeddedUnitInput name, schemaKey, label, labelLeft, ...props />: EmbeddedUnitInput with label (props specify the EmbeddedUnitInput 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 the HTMLInputElement 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.