Forms & Validation

The standard way to build a form in the App - useModelState, bound fields and validation rules.

Every form in the App uses one pattern: a useModelState per form, every input bound through modelState.bind, and validation rules declared in that bind. There is no other way to attach validation to an input.

A form

type FieldName = "name" | "energyType_UID";
 
export function AddTariffPopupContent() {
  const [name, setName] = useState("");
  const [energyType_UID, setEnergyTypeUID] = useState<Uid | undefined>(undefined);
  const modelState = useModelState<FieldName>();
 
  async function save() {
    // only called once every bound field passes its rules
  }
 
  return (
    <TemplateDataEntry modelState={modelState} onSave={save}>
      <Card>
        <FormInputString
          {...modelState.bind("name", { label: "Name", value: name, onChange: setName, rules: [required(), maxLength(100)] })}
          maxLength={100}
          size="full"
        />
        <FormInputSelect
          {...modelState.bind("energyType_UID", {
            label: "Energy type",
            value: energyType_UID,
            onChange: setEnergyTypeUID,
            rules: [nonEmptyGuid()],
          })}
          options={energyTypeOptions}
        />
      </Card>
    </TemplateDataEntry>
  );
}
  • useModelState<FieldName>() holds the form's errors. List every bound field in FieldName so field keys are checked by the compiler.
  • modelState.bind(field, { label, value, onChange, rules }) returns the label, value, error, validation and onChange props for the input. Spread it first, then add the input's own props (size, options, maxLength and so on). validation can only come from bind, so rules can't be attached to an input any other way.
  • TemplateDataEntry takes modelState as a required prop. Save runs modelState.validate() and only calls onSave when every bound field passes.
  • Typing in a field clears that field's error.

Rules

Rules live in @metrd/ui (packages/ui/src/forms/validation). A rule receives the field's value, the values of every other bound field, and the field's label, and returns an error message or undefined. The first failing rule for a field is the one shown.

RuleChecks
required()The value isn't empty. Marks the input with *.
requiredIf(condition)The value isn't empty while condition(values) is true. Marks the input with * only while the condition holds.
nonEmptyGuid()A selected id isn't missing or the empty GUID. Marks the input with *.
maxLength(n)Text is at most n characters.
greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqualNumeric bounds. The bound can be a number or another field, e.g. greaterThan({ field: "minimum", label: "the minimum" }).
greaterThanIf(condition, bound)greaterThan, only when condition(values) is true.
after({ field, label })A date is after another field's date.
regex, oneOf, nonDefaultValue, nonPastDatePattern, allowed values, changed from a default, not in the past.
and(...), or(...)Combine rules. and is required if any of its rules is, or only if all of them are.

Because rules receive every bound value, a rule that compares two fields belongs on the field that shows the error. For example, a tariff period's expiry has after({ field: "start", label: "start" }).

Required fields

Whether a field is required belongs to its rules, not to the input. A rule marks itself with isRequired(values), and the input shows * when any of its rules is required for the form's current values. Inputs render after the form has bound every field, so a requiredIf that depends on another field updates its * as soon as that field changes:

<FormInputSelect
  {...modelState.bind("unit_UID", {
    label: "Unit",
    value: unit_UID,
    onChange: setUnitUid,
    rules: [requiredIf((values) => values.rateType === "Consumption")],
  })}
  options={unitOptions}
/>

The same condition decides whether the empty check runs on Save, so the * and the validation always agree. To write a new conditional rule, set rule.isRequired to a function of the form's values.

Server-defined fields

Integration configuration fields come from the API with their own validation rules (StaticFieldValidation*). buildConfigurationFieldRules turns those into the same client rules, so they are never written by hand in the App. Render them with ConfigurationFieldGroups and pass the form's modelState. Each field is bound under configurationFieldKey(field_UID), so include ConfigurationFieldKey in the form's FieldName.

type FieldName = "name" | ConfigurationFieldKey;
 
<ConfigurationFieldGroups fieldGroups={fieldGroups} values={fieldValues.values} modelState={modelState} onChange={fieldValues.setValue} />

Errors from the API

When the API rejects a value the client couldn't check, such as a duplicate name, put the message on the field with modelState.setError(field, message) rather than in a page message:

if (result.status === 409) {
  modelState.setError("name", "Another tariff already has this name.");
  return;
}

Use the template's messages only for problems that don't belong to a single field.

Things to avoid

  • Don't validate inside save. If a check can be a rule, make it a rule so it runs with the others and shows on the right field.
  • Don't build inputs with label, value and onChange by hand in a form. Bind them, even when the field has no rules, so every field goes through the same model state.
  • Bind fields in the same render as the form. bind records the field during render, so a field rendered by a memoised child that doesn't re-render with the form won't be validated.