Skip to content

Forms

function

declare function Form({ row, layout, children }: FormProps): JSX.Element;

A form editing one row: the context of its fields, and what renders them.

<Form row={ ws.edit(bar) }/> // the layout derived from the row
<Form row={ ws.edit(bar) } layout={ barLayout }/> // a layout
<Form row={ ws.edit(bar) } layout="barEdit"/> // a layout by name, from config().layouts
<Form row={ ws.edit(bar) }> // the application's own fields
<Field path="name"/>
<Field path="owner"/>
</Form>

One form edits one row: its type and merge state are the working set’s for the draft. A screen editing several rows has several forms.

Without children, the form renders a layout, the one given or the one derived from the row (deriveLayout()), and wraps every widget in the conflict buttons (ConflictResolution). With children, it only provides the context, and the application’s fields and components render inside it as they are; useFormContext() reads it.

Either way the form has a live region telling a screen reader what somebody else changed (FormStatus): at its top, unless a FormStatus inside it places it. That region is the one markup a form with children adds of its own.

Throws Error for a form given both a layout and children, for a layout name config().layouts doesn’t have, and for a layout of another type than the row’s

interface

interface FormProps

Props of Form.

row: object;

the draft the form edits, what ws.edit(row) or ws.create(type) returned

layout?: Layout | string;

what the form renders, or the name of one in config().layouts (namedLayout()); derived from the row without either

children?: ReactNode;

the application’s own fields, in place of a layout

function

declare function FormStatus(): JSX.Element;

Where the form’s live region goes: what a screen reader is told when somebody else changed a field on screen, or a save left a conflict – once per such event, not once per field. Each field is called by what its label says, and the row by its name fields, so that a page of several forms says which one; a field the form doesn’t show is not mentioned, and what the user changes is not announced. A field is on screen while a Field or useField() renders it.

Every form has the region, at its top unless a FormStatus inside it places it elsewhere, at any depth. A layout places it with a {status: true} node. The region is there from the start and empty while there is nothing to say, since a screen reader only announces changes to a region it already knows. It is visually hidden by default; its class is qlive-form-status.

One FormStatus per form: a second one warns in development and announces everything twice.

Throws Error outside a Form

function

declare function useFormContext(): FormContext;

The context of the form the calling component is in: the row, its merge state, the text fields hold that didn’t parse, and the fields’ modes. What an application’s own component in a form reads it all through.

Rules of hooks apply.

Throws Error outside a Form

interface

interface FormContext

What a form holds for every field in it, read with useFormContext(). The fields themselves read it through useField().

row: object;

the draft the form edits

domainType: string;

its type

layoutName: string | null;

the name of the layout the form renders, null for a derived layout and a form with children

merge: MergeAccessor;

its merge state

invalid: ReadonlyMap<string, string>;

The fields whose text didn’t parse, by path, holding the text. That text never reaches the draft, which holds values, and lasts as long as the form, so a field that unmounts and comes back still shows it.

modes: ReadonlyMap<string, FieldMode>;

The modes the layout gave fields and setMode() changed them to, by path. A field not in it has the mode its Field gives, or the default (defaultFieldMode()).

setMode(path: string, mode: FieldMode): void;

changes a field’s mode

function

declare function Field(props: {
path: string;
} & FieldOptions): JSX.Element;

One field of the form it is in: a label, the widget, the error, with the merge mark on the control.

The widget is the one given, else the most specific override in config().widgets (WidgetOverride), else QLive’s own for the type: a text input, a number input, a date or date-time picker, a checkbox, a select for a nullable Boolean, a RowPicker for an enum or a to-one relation, a RowSet for a many-to-many field. A field QLive has none for, a scalar without input text, shows its value and says so.

A field in text mode renders no widget at all, but the value as text in a qlive-field-text span.

The control, widget or text, carries the merge mark; a decorator goes around the control, so what it adds sits beside the marked box rather than in it. Before the control sits the mark’s icon, so the states differ by shape and not by color alone; after it a visually hidden text naming the state, which the widget’s input is described by (field.descriptionId, see Widget).

Parameters

props the field path, and the options of useField()

function

declare function useField(path: string, options?: FieldOptions): FieldState;

One field of the form the calling component is in: what it is, the mode it is in, its value, and its text.

A path is a field of the form’s row. A to-one relation is named by its object field, owner, and edits the foreign key behind it; a many-to-many field holds the related rows. A field of a related row, owner.login, is not a form field.

A widget editing text calls setText(): text that parses (parseInput()) sets the value, text that doesn’t stays as it was typed, with an error, and is listed in the form’s invalid. A widget editing a value, a checkbox or a picker, calls setValue() and never has an error.

Rules of hooks apply.

Parameters

path field path
options what is given in place of what is derived

Throws Error outside a Form, and for a path the form’s type has no field for

interface

interface FieldOptions

What a field is given where it is written, in place of what is derived.

mode?: FieldMode;

the mode the field starts in, see FormContext.modes

widget?: Widget | string;

the widget, or the name it is registered under (registerWidget())

decorator?: FieldDecorator | null;

the component around the widget, null for none, see WidgetOverride

label?: ReactNode;

the label, in place of the i18n() of Type.field

condition?: FilterExpression | null;

For a to-one relation, which related rows it offers: a FilterDSL condition written against the form’s row, which reaches the related row through the relation. A related row is offered where the condition holds for the form’s row with that row in the relation’s place.

<Field path="owner" condition={ field("owner.login").ne(field("name")) }/>

Since it reads the form’s row, the rows on offer follow it as it is edited.

interface

interface FieldState

One field of a form, as of this render: what it is, the mode it is in, its value and its text.

path: string;

the path the field was asked for by

domainType: string;

the form’s type

name: string;

the field’s name in it

fieldType: string;

the field’s type: a scalar or enum type, or the related type for a relation

kind: "scalar" | "toOne" | "manyToMany";
  • scalar: a scalar or enum field
  • toOne: a to-one relation, named by its object field and edited through its foreign key
  • manyToMany: a many-to-many field, holding the related rows
label: ReactNode;
id: string;

the input’s element id, for the label’s htmlFor

labelId: string;

the label’s element id, for a widget’s aria-labelledby where the label can’t name the input by htmlFor

descriptionId: string;

The id of the text saying what the merge did to the field, which a screen reader reads in place of the mark’s color. A widget puts it in its input’s aria-describedby, as it puts id on the input: one that doesn’t leaves a screen reader without the field’s merge state, and nothing looks wrong. Field renders the text; it is empty while nothing happened to the field.

mode: FieldMode;
required: boolean;

non-null in the schema

meta: DomainFieldMeta;

the field’s meta data, maxLength and the like

condition: FilterExpression | null;

which related rows a to-one relation offers, see FieldOptions.condition; null for all

value: unknown;

the value, read through the draft: the foreign key for a to-one relation

setValue(value: unknown): void;

writes a value to the draft, and drops text the field held

text: string;

what a text input shows: the text being typed, else the input text of the value

setText(text: string): void;

takes what the user typed: text that parses sets the value, text that doesn’t stays, with an error

error: string | null;

why the text didn’t parse, a message for the user; null where it did

merge: MergeField;

the merge state of the field the value is in

function

declare function fieldText(field: FieldState, row: object, value?: unknown): string;

The value of a field as text, what text mode shows: the formatted value, an enum value’s label, the related row’s name for a relation.

Parameters

field the field
row the row it is a field of
value the value to show, the field’s own by default

type

type FieldMode = "normal" | "disabled" | "readOnly" | "text";

How a form renders one field.

  • normal – an input the user edits
  • disabled – a disabled input: not focusable, and dimmed
  • readOnly – a read-only input where the widget has one: focusable and selectable, but not editable
  • text – the value as text in a span, lined up with the inputs around it

readOnly and text both show a value the user can’t change. readOnly keeps the look of an input, text reads as what it is, which is why it is what a field no form edits gets.

function

declare function defaultFieldMode(type: string, field: string): FieldMode;

The mode a field of a type has where nothing says otherwise: text for a field no form edits, normal for every other.

No form edits

  • a field of the primary key, which identifies the row rather than describing it,
  • a computed field, which nothing stores,
  • the version field, which only the merge writes,
  • a field whose meta data says editable: false.

A foreign key is edited, through the picker of the relation it is the key of.

Parameters

type GraphQL type name
field name of a field of it

type

type Widget = ComponentType<WidgetProps>;

The input of one field. It gets the whole field state and renders the input only: label, error and merge marks are Field’s. It only ever sees the modes normal, disabled and readOnly, since a field in text mode renders no widget.

Two ids tie the input to what Field renders, and a widget must apply both: field.id as the input’s id, which the label names it by, and field.descriptionId in the input’s aria-describedby, the text saying what the merge did to the field. A widget that skips the second shows the mark to the eye and hides it from a screen reader.

<input id={ field.id } aria-describedby={ field.descriptionId } ... />

interface

interface WidgetProps

Props of a Widget.

field: FieldState;

type

type FieldDecorator = ComponentType<FieldDecoratorProps>;

A component given a field’s control as children, rendering it with whatever it adds around it. What QLive’s conflict buttons are (ConflictResolution), and where an application puts UI about a field that no widget should have to know about.

interface

interface FieldDecoratorProps

Props of a FieldDecorator.

field: FieldState;
children: ReactNode;

the field’s control, widget and merge mark

interface

interface WidgetOverride

A widget or decorator for the fields an application doesn’t name one for, listed in config().widgets. It matches on where the field is and on what it is, each by one key at most:

  • where: the layout the field is in, by name, or the domainType of the row the form edits
  • what: the field’s path, or its fieldType

A key left out matches anything, and an override giving both keys of one kind is an error.

config.widgets = [
{fieldType: "Money", widget: MoneyInput},
{fieldType: "AppUser", widget: UserPicker},
{domainType: "Bar", path: "num", widget: Slider}
]

Of the overrides that match, the most specific wins: what the field is counts first, a path over a field type over neither, then where it is, a layout over a domain type over neither. Of two equally specific ones the later wins. Widgets and decorators are matched separately, so a decorator override doesn’t hide a widget override that is less specific.

layout?: string;

the name of the layout the field is in

domainType?: string;

the type of the row the form edits

path?: string;

the field’s path from that type

fieldType?: string;

the field’s type: a scalar or enum type, or the related type for a relation

widget?: Widget;
decorator?: FieldDecorator;

function

declare function registerWidget(name: string, widget: Widget): void;

Gives a widget the name a layout refers to it by. A layout written in TSX holds the component itself and needs no name; a layout the server delivers can’t hold one.

Parameters

name widget name
widget the widget

function

declare function ConflictResolution({ field, children }: FieldDecoratorProps): JSX.Element;

The buttons that resolve a field both writes changed, around its widget: one keeping the user’s value, one taking the saved one. Where the saved value isn’t known yet – a push message says which fields changed, not to what – a note instead, until a save brings both.

What a form rendering a layout decorates every control with, unless an override gives a decorator (WidgetOverride). A hand-written form gives it where it wants it, <Field path="name" decorator={ ConflictResolution }/>.

function

declare function TextInput({ field, type, inputMode }: {
field: FieldState;
type?: string;
inputMode?: "decimal" | "numeric";
}): JSX.Element;

Edits a field as text: String, ID, and every type with an input text (see inputText()).

function

declare function NumberInput({ field }: WidgetProps): JSX.Element;

Edits a number as text. Not type="number": a browser hands that over as empty while it can’t parse it, which would hide what the user typed, and it has no idea of a bigint.

function

declare function DateInput({ field }: WidgetProps): JSX.Element;

a date picker

function

declare function DateTimeInput({ field }: WidgetProps): JSX.Element;

a date and time picker in the user’s time zone

function

declare function Checkbox({ field }: WidgetProps): JSX.Element;

Edits a non-null Boolean. A checkbox has no read-only state, so readOnly disables it.

function

declare function BooleanSelect({ field }: WidgetProps): JSX.Element;

Edits a nullable Boolean: yes, no, or nothing.

function

declare function MissingWidget({ field }: WidgetProps): JSX.Element;

What a field gets where QLive has no widget for it, which a form shows rather than failing: a scalar without an input text.

function

declare function RowPicker({ field }: WidgetProps): JSX.Element;

Picks a value of a closed set: a text input searching the set by what its values are called, and the list of the values found, the first page of them. Choosing one sets the field; a nullable field is cleared by emptying the input. The input shows the chosen value’s name while it isn’t being typed into, and what the user typed while it is; typed text that chooses nothing is dropped when the input loses focus.

What the set is depends on the field:

  • a to-one relation picks a related row and sets the foreign key. The rows come from relatedRows(), the server’s related-rows endpoint, found by their name fields and sorted by them.
  • a to-one relation to a catalog type picks one of its rows, which the bootstrap brought (see catalogRows()). The set is known in the browser and searched there, by the rows’ names, in the order the server read them.
  • an enum field picks one of the type’s values, but the deprecated ones, each called by its Type.VALUE translation. The set is known in the browser too.

A to-one field’s condition (FieldOptions.condition) narrows the rows: tested on each catalog row in the browser, sent to the endpoint otherwise, where the rows are asked for again when a value of the form’s row it reads changes.

QLive’s widget for all of them, so a form edits a closed set without being told how, and the same way however large the set grows.

function

declare function RowSet({ field }: WidgetProps): JSX.Element;

Edits a many-to-many field: a picker adding one of the rows not yet associated, and after it the associated rows, each named and with a button removing it. The picker comes first so it stays in place while rows come and go. It searches as RowPicker searches a to-one relation’s rows: through the related-rows endpoint, or in the browser for a catalog type. The field is set to the new list of rows, and the merge writes the difference as associations gained and lost.

A row added comes from the picker, with its key and name fields only: other fields of it read as missing until the row’s query is read again.

In disabled and readOnly the rows are listed without the buttons and without the picker.

The whole is a group named by the field’s label, which names the picker too; a hidden text describes what the picker is for. After a row is removed, focus moves to the button of the row after it, else the one before, else the picker.

function

declare function relatedRows<R = any>(type: string, delta?: QueryConfigDelta): Promise<RelatedRows<R>>;

The rows of a type a foreign key references, as the given config narrows and pages them: what a picker offers.

The server answers for the targets of foreign keys only, with the fields they reference and the type’s name fields, and the config filters and sorts by those only. The type’s own query config applies under the one given, and the page is held to the type’s maximum page size.

Running on a fixture, there is no server to ask, and this rejects without trying.

Parameters

type the related type, e.g. “AppUser”
delta the config, over the type’s own and the defaults

interface

interface RelatedRows<R = any>

What relatedRows() resolves with.

type: string;

the type the rows are of

rows: R[];

the rows of the page, with the key fields foreign keys reference and the type’s name fields

rowCount: number;

how many rows the condition finds, the page aside

function

declare function catalogRows<R = any>(type: string): R[];

Every row of a catalog type, with every scalar and enum field, in the order the server read them: the type’s sort fields, else its primary key.

A catalog type is an enum bound at application start: a table whose rows don’t change while the application runs, declared with the server’s CatalogMetadataProvider. Its rows come with the bootstrap, so reading them takes no request, and a picker of a relation to it filters them in the browser.

const fooTypes = catalogRows<FooType>("FooType")

The rows are shared: treat them as read-only.

Parameters

type the catalog type

Throws Error for a type that is no catalog type, or a page whose config came without its rows – a reduced one, for a view that declared noSchema()

function

declare function isCatalog(type: string): boolean;

Whether the type is a catalog type, see catalogRows().

Parameters

type a type name, e.g. “FooType”

interface

interface Layout

What a form renders for one entity: its fields, grouped and arranged by the nodes.

name?: string;

the name overrides match it by (WidgetOverride), if it has one

domainType: string;

the type of the row the form edits

nodes: LayoutNode[];

type

type LayoutNode = LayoutFieldNode | LayoutSectionNode | LayoutComponentNode | LayoutStatusNode;

One node of a layout.

interface

interface LayoutFieldNode

One field, rendered by Field with these options.

field: string;

field path, see useField()

widget?: Widget | string;

the widget, or the name it is registered under (registerWidget())

mode?: FieldMode;

the mode the field starts in

label?: ReactNode;
condition?: FilterExpression | null;

which related rows a to-one relation offers, see FieldOptions.condition

interface

interface LayoutSectionNode

A labeled group of nodes, a <fieldset> whose legend is the section, as given. A section that should be translated is written as an i18n() call, section: i18n("Foo.details"), which keeps its tag where the analysis finds it.

section: string;
nodes: LayoutNode[];

interface

interface LayoutComponentNode

A component of the application’s, with the nodes as its children.

component: ComponentType<any> | string;

the component, or the name it is registered under (registerComponent())

props?: Record<string, unknown>;
nodes?: LayoutNode[];

interface

interface LayoutStatusNode

Where the form’s live region goes, see FormStatus. Without one it is at the top of the form.

status: true;

function

declare function deriveLayout(type: string, row: object): Layout;

The layout of a form nobody wrote one for: a field node for every field the row has, in the order it has them, which is the order the query selected them in. A new row has every field, in schema order after those set.

  • A foreign key and the relation object it is the key of become one node, the relation’s, where the first of the two is: ownerId and owner are edited as owner.
  • A list is dropped unless it is a many-to-many field.
  • A field the schema doesn’t have is dropped.

Every node is a plain field node, so the modes are the defaults (defaultFieldMode()).

Parameters

type the row’s type
row a row, or a draft of one

function

declare function namedLayout(name: string): Layout;

The layout of the given name, from config().layouts: one the server sent with the bootstrap (a LayoutProvider’s), or one the application put there in startup()’s init hook. The layout carries the name it was found by, which is what overrides match (WidgetOverride), whatever its own name says.

Throws Error for a name no layout has

function

declare function registerComponent(name: string, component: ComponentType<any>): void;

Gives a component the name a layout refers to it by. A layout written in TSX holds the component itself and needs no name.

Parameters

name component name
component the component, rendered with the node’s props and its nodes as children