Parsware
All articles

Parsware Platform

Build a custom control

A field sometimes needs an editor the platform doesn't ship. A custom control is one React component that stands in for it: it shows the field's value, reports every edit, and leaves saving to the form. We built a badge-number field for the front desk outside the repository, packed it into one file and installed it.

The badge field running on its own: a text box holding B-2077 next to a teal badge that reads VISITOR B-2077, next to the title "Build a custom control"

Reception types each visitor's badge number into the Badge number column, a plain text field. The badges are printed as B- and four digits, and typos are easy: b 2077, B2077 . What the front desk wants is a field that tidies what's typed and shows the badge it will print.

That's a custom control: one React component that replaces a field's editor on a form. It's built and packed like the custom app from the last article; only the contract is different.

The contract

The form hands a control four things and expects one thing back:

interface ParswareControlProps {
  value: unknown;                    // the field's current value
  onChange: (next: unknown) => void; // report an edit; the form decides when to save
  fieldMetadata: FieldMetadata;      // the field's name, label, type, required flag, length
  context: ParswareRuntime;          // the same runtime an app gets
}

The control is controlled: it draws value and reports edits through onChange. It never keeps its own copy of the value, and it never saves the record. That's what lets it take part in the form like any built-in field: Save, Cancel, required checks and business rules all work on what the control reported.

The control

import type { ParswareControlProps } from '@parsware/runtime-sdk';
import './badge.css';

function tidy(text: string): string {
  return text.toUpperCase().replace(/\s+/g, '');
}

export default function Control({ value, onChange, fieldMetadata }: ParswareControlProps) {
  const badge = typeof value === 'string' ? value : '';

  return (
    <div className="badge-field">
      <input
        aria-label={fieldMetadata.displayName}
        value={badge}
        maxLength={fieldMetadata.maxLength ?? undefined}
        placeholder="B-1042"
        onChange={(e) => onChange(tidy(e.target.value))}
      />
      <div className="badge-field__badge" aria-hidden="true">
        <span>VISITOR</span>
        <strong>{badge || '—'}</strong>
      </div>
      {fieldMetadata.isRequired && !badge && <span className="badge-field__hint">Required</span>}
    </div>
  );
}
  • The label, the length and the required flag come from fieldMetadata. Nothing about the Badge number column is written into the control, so it works on any text column.
  • An empty or unexpected value draws an empty field, not an error. A control lives inside somebody else's form, and an exception there takes the whole record with it.

See it before you pack it

A control can't open itself in the Shell, so the project has a small development page that holds the value the way a form would. npm run dev, and type into it:

The badge field on its development page: Badge number, a text box holding B-2077, a teal badge reading VISITOR B-2077, and below it The form would save: "B-2077"

b-2077 went in; B-2077 is what the form would save.

Say what it stands in for

The project is set up exactly like the app's, from the help site's downloads, with two differences: Module Federation exposes ./Control rather than ./App, and the parsware block says which kinds of field the control can replace:

"parsware": {
  "logicalName": "nw_badge_field",
  "displayName": "Badge field",
  "description": "Types a visitor badge number and shows the badge it prints.",
  "substitutesFor": ["Text"]
}

substitutesFor is what the form designer reads: it offers this control for text fields and for nothing else, so nobody can put a badge editor on a date.

Pack it and install it

$ npm run pack

Packaged nw_badge_field 1.0.0
  scope   nw_badge_field
  module  ./Control
  entry   remoteEntry.js
  files   23
  sha256  b44d7dd286d6f67ca498f062d9b388396ee31ede3eeb16a07403aa2e813019f0

  …adge-fieldpackage
w_badge_field-1.0.0.zip  (91.9 KiB)

Upload it in the Admin Control Center: open the solution, then Custom Controls → New.

In the Visitors solution, Custom Controls, New, choose the file:

The New custom control panel in the Visitors solution with nw_badge_field-1.0.0.zip chosen

Register custom control, and it's installed:

The Custom Controls list of the Visitors solution: Badge field, nw_badge_field, version 1.0.0, substitutes for Text

The archive carries the code, so the solution carries the control: export the Visitors solution, import it into Test, and the badge field is there, with nothing outside to keep running.

What we fixed

Following the help page for custom controls in an empty folder stopped four times: its packages only resolved inside our own repository, its Module Federation settings left out what the packer needs, its test used a helper it never defined, and it told makers to type in a module name that the packer has read from the build for months. It's been rewritten and checked by doing exactly what it says, in all seven languages.

Good to know

  • The calls a control makes go out as the person using the form. context is the same runtime an app gets, with the same rules.
  • A control is per environment. It's installed with its solution and travels with it.
  • Raise version before you pack again. Uploading the same logical name replaces the control, and the version is how you can tell which build is installed.

Try it

Build a control for a text field you know, pack it, and install it in a solution. Then type something wrong into its development page and see what your control does with it.

Next: Put a custom control on a field.