A Lit 3.x web component for creating dynamic forms with configurable fields. Part of the IronFlock widget ecosystem.
npm i @ironflock/widget-form<script type="module">
import '@ironflock/widget-form'
</script>
<widget-form-1.0.9></widget-form-1.0.9>Note: The version number is part of the custom element tag name.
| Property | Type | Description |
|---|---|---|
inputData |
FormConfiguration |
Form configuration and fields |
theme |
Theme |
Theme object for styling |
route |
string |
The board page's path with its ?query, set by IronFlock Studio on boards |
| Event | Detail | Description |
|---|---|---|
data-submit |
Array of field/value mappings | Fired when the form is submitted |
file-data-submit |
{ items, waitUntil } (see File Fields) |
Fired instead of data-submit when the save includes a new file |
nav-submit |
{ path } |
After Delete, with the resolved deleteNavigationRoute |
param-submit |
{ params: { [openParameter]: undefined } } |
When a dialog opened by a URL parameter closes (see Open on URL Parameter) |
The form is configured via the inputData property:
interface FormConfiguration {
title?: string
subTitle?: string
formButton?: boolean // Show button to open form dialog
autoSubmit?: boolean // Save on Change: no Reset or Submit, inline forms only
formButtonStyle?: FormButtonStyle // How that button looks
openParameter?: string // The URL parameter that opens that dialog instead of the button
formDialogSize?: FormDialogSize // Minimum size of that dialog
fieldLayout?: FieldLayout // How the fields share rows (see Layout)
formFields?: FormField[]
}With formButton: true the tile shows only a button that opens the form in a dialog. formButtonStyle sets how it looks:
interface FormButtonStyle {
fab?: boolean // Floating action button (default); false for a regular button
label?: string // Label Text of the regular button
size?: 'small' | 'medium' | 'large'
cornerRadius?: number // Corner radius in px, for both kinds of button
bgColor?: string // Background colour
color?: string // Icon colour, and label colour on the regular button
iconName?: string // Material icon name, 'add' by default
}By default (fab unset or true) it is a round Material floating action button that shows only the icon, 40, 56 or 96px by size. With fab: false it is a Material filled button with the icon and label (only the icon when label is empty), 32, 40 or 56px tall by size; a long label is cut with an ellipsis at the tile's edge. Unset, its label and icon are black or white, whichever reads on its background.
cornerRadius rounds the corners of either button, in px: 0 is square, and half the button's height or more is fully round (a circle for the floating action button, a pill for the regular button). Unset, the floating action button keeps Material's 12, 16 or 28px corners by size, and the regular button is a pill.
Every field except fab can be bound to a column, so the button can follow data: a fault state can turn it red, swap its icon to warning and relabel it "Report fault". A bound value that does not fit falls back to the default: a size other than small, medium or large gives medium, an invalid colour the theme's, a corner radius that is not a number of 0 or more (numeric text counts) Material's corners, and an empty icon name add. A numeric label is shown as it is.
With autoSubmit: true (and no formButton) the form has no Reset or Submit button: every change saves the whole form, as Submit would. A checkbox, dropdown or picked colour saves at once; text, numbers and dates save when the field loses focus or on Enter. An invalid field blocks the save and shows its error, as Submit does. After a save the fields keep what was entered rather than going back to their pre-filled values, so bind those to the stored row and they follow it. A Delete button stays, and "Saving…" shows while a file is stored. For a single on/off setting, widget-switch with a targetColumn is the better fit: it shows the stored position and writes a row per click.
With formButton: true the form opens in a dialog that is 480px wide (narrower on small screens) and as tall as its fields, up to 80% of the window height. formDialogSize sets the dialog's minimum width and height:
interface FormDialogSize {
minWidth?: string // e.g. '800px', '60%', '50vw'
minHeight?: string // e.g. '600px', '70%', '90vh'
}Each value is a number with an optional unit: px (assumed when there is none), % or vw/vh for a share of the window, rem or em. Empty keeps the default. A value replaces Material's own 280×140px minimum and is capped at the window less 24px on each side, so the dialog and its Submit button always stay on screen; a form taller than that scrolls inside the dialog. On an ordinary window a value below the default size therefore changes nothing, while on a window narrower than 328px (or a very short one) any value, even a large one, lets the dialog shrink a little below Material's minimum to keep those margins. Anything else, such as auto, calc() or a negative number, is ignored with a console warning. Fields that take part of a row (see Layout) fit more of them on a row in a wider dialog.
With formButton: true and openParameter set to a parameter name, such as edit, the form is an edit dialog that another widget opens: on a board the tile shows no button, and the dialog is open while the page URL carries the parameter (?edit=press-1). A widget-table column of type edit sets it to the clicked row's key; so can a link. Closing the dialog (Cancel, Submit, Delete) removes the parameter again, so the same row can open it again, and the browser's Back button closes it as well.
The record comes through the fields' pre-filled values, bound in Studio to the record's latest row filtered by the same parameter: a Parameter filter named edit plus the Latest switch, on each bound field, and the record's key column as a hidden field with the same binding, so a save writes the same entity. While Studio re-queries those bindings after the parameter changed, the dialog shows a progress bar and its Save, Delete and Reset buttons are disabled; the fields then rebuild from the record.
# board-template.yml (Studio stores the same)
openParameter: $str:edit
formFields:
- label: $str:Machine # the entity key, written back unchanged
hiddenField: $bool:true
targetColumn: { tablename: machines, column: machine_id, dataType: string }
preFilledValue:
valueRef:
tablename: machines
column: machine_id
queryParams:
limit: 1
filterAnd:
- { latest: true, column: '', operator: '=', value: '', dataType: boolean }
- { column: machine_id, operator: '=', value: edit, dataType: string, useFilter: true }
- label: $str:Name # every edited field: the same filter, its own column
type: $str:textfield
targetColumn: { tablename: machines, column: name, dataType: string }
preFilledValue: { valueRef: { tablename: machines, column: name, queryParams: { limit: 1, filterAnd: [...] } } }Keep a "New" form as a tile of its own: with the parameter absent, Studio drops the filter and the bindings would deliver the newest record. A Delete with deleteNavigationRoute drops the parameter in the same navigation. Outside a board (the Widget Editor's preview, where there is no route) the button stays, so the form can be tried; a bookmarked URL with the parameter opens the dialog on load.
The dialog and its dropdown menus take the tile's colour as it is displayed (the tile over the dashboard background), so the text colour reads on them. On a dark tile they are lightened one step and the backdrop behind the dialog darkens more (60% instead of 32%), so the dialog stands out from the page; on a light tile they keep the tile's colour.
Each form field supports the following types:
| Type | Description |
|---|---|
textfield |
Single-line text input |
numberfield |
Numeric input with min/max |
checkbox |
Boolean checkbox |
textarea |
Multi-line text input |
dropdown |
Select from predefined options |
datetime |
Date/time picker |
file |
Upload one file (FleetFiles) |
secret |
Masked password or token |
color |
Color picker, stored as hex |
section |
Heading over a group of fields |
interface FormField {
label: string
type: 'dropdown' | 'textfield' | 'numberfield' | 'checkbox' | 'textarea' | 'datetime' | 'file' | 'secret' | 'color' | 'section'
width?: FieldWidth // Share of the row; fieldLayout.defaultWidth when unset
newRow?: boolean // Start a new row even if the field would fit on the current one
groupWidth?: FieldWidth // For a section: the width of its group's fields
hiddenField?: boolean // Hide field but still submit value
required?: boolean // Field must be filled
description?: string // Hint text shown below field
targetColumn?: TargetColumn // Database column mapping
preFilledValue?: string // For a file: the record's current key; not for a secret
defaultValue?: string
preFilledValueColor?: string // For color, in place of preFilledValue: a color picker in the editor
defaultValueColor?: string // For color, in place of defaultValue
min?: number // For numberfield
max?: number // For numberfield
validation?: string // Regex for textfield and secret (a secret's minimum requirements)
validationMessage?: string // For textfield and secret: shown when the regex fails
browserPasswordManager?: boolean // For secret: let the browser save and fill it (default off)
values?: DropdownValue[] // For dropdown type
fileNamespace?: string // For file: the app's FleetFiles namespace (required)
acceptedFileTypes?: string // For file: 'application/pdf, image/*'
maxFileSizeMB?: number // For file: 1 MB = 1,048,576 bytes
fileNameColumn?: TargetColumn // For file: gets the original file name
fileSizeColumn?: TargetColumn // For file: gets the size in bytes
fileTypeColumn?: TargetColumn // For file: gets the content type
fileUrlColumn?: TargetColumn // For file: gets the file's permanent URL (Studio writes it)
}Fields fill the form left to right in rows, in the order of formFields. By default every field takes a full row at Material's standard size, which is the single column of earlier versions.
interface FieldLayout {
defaultWidth?: FieldWidth // For fields without a width of their own or their section's; 'full' when unset
minFieldWidth?: number // In px; 180 when unset, 0 never widens a field
density?: 'comfortable' | 'compact' // 'comfortable' when unset; new forms start 'compact'
}
type FieldWidth = 'full' | 'three-quarters' | 'two-thirds' | 'half' | 'third' | 'quarter'- A field's
width, else its section'sgroupWidth, elsefieldLayout.defaultWidth, sets its share of the row. A field that does not fit on the current row starts the next one, andnewRow: truestarts a new row even when it would fit. - In a form too narrow for its fields, they widen so that none is narrower than
minFieldWidth. Below room for four, quarters become thirds and three quarters become two thirds. Below room for three, every field takes half a row, and below room for two, a full row. A three-quarters and a quarter field, or a two-thirds and a third field, therefore stay on one row while there are two columns. The form measures its own width, so this follows the tile and the dialog; the default 480px dialog fits two fields on a row. - Number, date and color fields on a full row keep their 200px. On part of a row they fill it.
- A dropdown's label sits inside its outline, as on the text fields, so the two line up side by side. A file field's caption sits above its box, so put a file field on a row of its own or beside another file field.
- A checkbox on part of a row is centered in the height of the text fields beside it.
density: 'compact' makes the form smaller for desktop boards used with a mouse, and is what a form dropped on a board starts with (the default config sets it). Unset, a form is comfortable, Material's size, which is how forms made before stay. Fields are 40px high instead of Material's 56px, with 14px text, the buttons are 32px high, and the gaps between fields and rows shrink. The Form Open Button keeps its own size.
In a tile (without formButton), the Delete, Reset and Submit buttons stay at the bottom while the fields scroll above them, with a thin line between the two while more fields are hidden below. A short form keeps its buttons right under its last field. In a tile with less than about 160px left under its title, the fields and the buttons scroll together.
An entry of type section is a heading, not an input. It shows its label, with its description under it, across a full row, and a line above it unless it comes first. It groups the fields after it, up to the next section, and stores nothing. Its groupWidth sets the width of the fields in its group that have no width of their own.
A section's conditionalDisplay shows or hides the heading and its whole group. The fields of a hidden group store NULL, as any field hidden by Conditional Display does, while a hiddenField in it keeps sending its value. Keep the controlling field outside the group it controls: once the group is hidden, a field inside it reads as its default value. A section never controls a condition, so it may share its label with a field.
A file field stores one file in the app's file storage (FleetFiles) and writes the stored object's key into its target column, a string column, in the same save as the rest of the form.
- The field is a drop zone: click it to pick a file or drop one on it. It shows the file's name and size, and a button to remove it. Reset clears it.
acceptedFileTypes(MIME types,type/*allowed) filters the picker and refuses other files on the spot; so doesmaxFileSizeMB. The browser's type is used when it fits, otherwise the one the file's extension stands for, so a CSV that Windows callsapplication/vnd.ms-excelis stilltext/csv.fileNamespacenames a namespace from thefilessection of the app's data template. Its owncontentTypesandmaxObjectBytesapply as well. Files up to 6 MB upload on every installation; larger ones only where the platform can issue upload links (the cloud, not an appliance).fileUrlColumngets the stored file's permanent platform URL, so other widgets can show or link the file: an embed of a PDF, an image. Opening it needs a signed-in IronFlock session. Studio builds it after the upload; it stays empty where the installation serves no file links, and with a Studio version that does not write it.- In an edit form, bind
preFilledValueto the record's key column. The field then shows that file (the key's last segment is the file name), and a save without a new file writes the key back unchanged. Without a pre-filled key and without a new file, the column is left out of the row, never set to null, so a table with entity keys keeps its previous value. The name, size, type and URL columns are only written with a new file. A file field hidden by Conditional Display submits null, like every other field.
The widget never uploads anything itself. A save with a new file dispatches file-data-submit instead of data-submit:
detail: {
items: Array<
| { swarm_app_databackend_key, table_name, column_name, value } // as in data-submit
| {
swarm_app_databackend_key, table_name, column_name, file: File, namespace: string, contentType: string
urlColumn?: { swarm_app_databackend_key, table_name, column_name } // the field's fileUrlColumn
}
>
waitUntil(outcome: Promise<unknown>): void
}The host claims the event by calling waitUntil() while it is dispatched. It uploads each file under a key of its choosing, puts the key in as that item's value, writes the object's permanent URL into the item's urlColumn (null where it cannot build one), and writes the rows only when every upload succeeded. A null item for the URL column comes ahead of the file item, and a later item for the same column wins: a host that writes no URL leaves the column empty instead of keeping another file's URL. The form shows "Saving…" until the promise settles. If it resolves, the form resets and the dialog closes. If it rejects, the form keeps its values and shows the error's message. A host that does not know the event (Studio before file support) never claims it, and the form says that it cannot upload and saves nothing, rather than sending a File through data-submit, which would write garbage into the column.
A color field takes a color for a string column, such as the color a chart or a status lamp shows for a category. It is a text field with a swatch: click the swatch, or tab to it and press Enter or Space, for the browser's color picker, or type or paste any CSS color (#1e88e5, 1e88e5, rgb(30, 136, 229), teal).
- It stores lowercase hex,
#rrggbb, or#rrggbbaafor a translucent color, which every widget and chart library reads. A typed color is shown as that hex once the field loses focus. The browser's picker has no transparency, so picking keeps the alpha of a translucent color typed before. - Text that is no color marks the field invalid, and the form cannot be saved until it is fixed. A pre-filled value that is no color is shown as it is and, unless the user changes it, written back unchanged.
- Left empty, the field stores its default value, which its swatch shows, or else NULL. Required means it cannot be left empty when there is no default or pre-filled value, as for the other fields.
- Set
preFilledValueColoranddefaultValueColor, for which the editor shows a color picker;preFilledValueanddefaultValueare read when they are empty.
A secret field takes a password, token or key without showing it: the input is masked, and an eye button shows what was typed. It is write-only: a saved secret never comes back to the widget, so the field has no pre-filled value and always starts empty.
- Point it at a string column declared
secret: truein the app's data template. FleetDB encrypts that column and never returns the value to a board (Studio shows••••••••); only the app's own containers can read it, with the SDK'srevealSecrets(). Any other column stores the value as plain text. - Left empty, the field leaves its column out of the save. A table that keeps the latest row per entity (
maintainLatestFlagFor) then carries the stored secret over; any other table stores NULL. The widget does not send FleetDB's keep sentinel (__secret__): on a table without entity keys FleetDB rejects the whole row, and Studio's publish does not report that, and a column that is notsecret: truewould store it literally. browserPasswordManagerdecides whether the browser treats the field as a password. Off (the default), the field is a text input drawn as dots (-webkit-text-security), so neither the browser nor a password manager extension offers to save, fill or generate anything, which suits a PIN;autocomplete="off"keeps the value out of the browser's form history, phones do not capitalise or correct it, and copying is blocked while it is masked. On, it is a password input markedautocomplete="new-password": the browser may offer a strong password and to save it, but does not fill in the viewer's IronFlock login.requiredmeans a new value must be typed on every save, so leave it off in a form that edits existing records.validationsets the minimum requirements for a newly typed value, such as^.{12,}$for at least 12 characters or(?=.*[0-9])(?=.*[A-Za-z]).{10,}for 10 with a letter and a digit; the whole value must match. SetvalidationMessageto say what is required ("At least 10 characters, with a letter and a digit"): it replaces the browser's generic "Please match the requested format", which tells nothing about a value one cannot see. Repeat it indescriptionto show it before anyone types.- The value is sent exactly as typed, spaces included.
# Start dev server
npm run start
# Build for production
npm run build
# Generate types from schema
npm run types
# A release pull request; its merge publishes to npm
just release-patchMIT