@archduck/gst-forms
v0.1.1
Published
Form plugin for gst-core: field definitions, actions, validation, and lifecycle hooks
Maintainers
Readme
gst-forms
Form plugin for gst-core. Adds field definitions, buttons, actions, validation, lifecycle hooks, and dirty tracking on top of the generic rendering engine.
gst stands for Grand Schema Things -- a play on "grand scheme of things." Forms are defined as schemas (JSON configurations) rather than code, and this library handles the form-specific concerns that sit on top of that config-driven foundation.
npm install @archduck/gst-formsimport { mountForm, loadJsonConfig } from '@archduck/gst-forms'
import '@archduck/gst-forms/style.css' // structural layout (not theming)The stylesheet uses em units throughout, so the entire form scales proportionally when you set font-size on the form container.
Why forms need their own layer
Forms look simple. A few inputs, a submit button, done. Then you need to know whether the user changed anything. Then you need to validate before saving. Then you need different behavior for creating a new record vs updating an existing one. Then you need confirmation dialogs when the user navigates away with unsaved changes. Then delete needs its own confirmation. Then you want hooks that fire before and after each action so you can log, audit, or transform data on the way through.
gst-forms encodes that state machine as a plugin for gst-core. You declare fields, buttons, and hooks in config. The engine manages dirty tracking, validation, action lifecycles, confirmation dialogs, and state transitions. Your application code handles one thing: what happens when the user clicks save.
mountForm(document.getElementById('app'), form, {
onAction: async ({ action, record }) => {
if (action === 'save') await api.save(record)
}
})Everything between the button click and that callback -- validation, before/after hooks, dirty state reset, error handling -- is the engine's job.
Quick start
Install gst-forms (pulls in gst-core and gst-compose automatically):
npm install @archduck/gst-formsCreate a form from three JSON files and a few lines of JS:
my-app/
public/
forms/
UserView/
fields.json
layout.json
buttons.json
src/
main.js
index.htmlfields.json -- define your fields:
{
"username": { "label": "Username", "required": true },
"email": { "type": "email", "label": "Email" }
}layout.json -- arrange fields into rows:
[["username"], ["email"]]buttons.json -- pick from built-in buttons:
["save", "cancel"]main.js -- load the config and mount:
import { mountForm, loadJsonConfig } from '@archduck/gst-forms'
const form = await loadJsonConfig('/forms/UserView')
mountForm(document.getElementById('app'), form, {
onAction: async ({ action, record }) => {
if (action === 'save') {
await fetch('/api/users', { method: 'POST', body: JSON.stringify(record.data) })
}
}
})That's it. The form renders with two-way data binding, dirty tracking, validation, and lifecycle hooks. No framework required.
Fields use standard HTML input types by default. Set type to email, number, date, textarea, checkbox, etc. For dropdowns, set component to select and provide options. For any other HTML element, use its tag name as the component value.
Actions and lifecycle
Built-in actions
Five actions handle common form operations:
save -- Validates required fields, fires beforeSave -> beforeCreate/beforeUpdate -> your onAction callback -> afterCreate/afterUpdate -> afterSave.
cancel -- Confirms if dirty (configurable via meta.confirmCancel), then resets to baseline.
delete -- Confirms (configurable via meta.confirmDelete), marks as deleted. Save label changes to "Undo Delete."
new -- Confirms if dirty, clears to a blank record (or meta.defaultRecord).
duplicate -- Confirms if dirty, clones current record with key cleared.
Action resolution
When a button fires, the engine resolves the handler in priority order:
- Default actions (highest) -- built-in save, cancel, reset, delete, duplicate, new
- Specific prop handlers --
onSave,onDelete, etc. passed tomountForm - Generic
onActionprop -- single handler for all actions - Registry functions (lowest) -- custom actions defined in the functions registry
Lifecycle hooks
Every action gets before* and after* hooks automatically. The hook name is derived from the action: before${capitalize(actionName)} / after${capitalize(actionName)}. Define a custom action named publish and beforePublish/afterPublish fire around it with no extra wiring.
Return false from any before* hook to cancel the action.
{
"actions": {
"hooks": {
"beforeSave": "{{validateForm}}",
"afterSave": ["{{logSave}}", "{{showToast}}"],
"onDirty": "{{enableAutoSave}}"
}
}
}Hooks for built-in actions:
beforeSave/afterSave,beforeCreate/afterCreate,beforeUpdate/afterUpdatebeforeCancel/afterCancel,beforeDelete/afterDeletebeforeNew/afterNew,beforeDuplicate/afterDuplicate
State-transition hooks
These fire on form state changes, not around actions:
onChange-- any field value changesonDirty/onClean-- dirty state transitionsonError/onRecover-- validation error state transitionsonLoad/onUnload-- form mount/unmountbeforeUnload-- browser navigation when the form is dirty
Note: hooks are NOT a registry. They don't use spread syntax. They're plain objects nested under actions.hooks.
Form state
The form tracks five states derived from three flags (hasKey, isDirty, isDeleted):
| State | hasKey | isDirty | isDeleted | |-------|--------|---------|-----------| | Loaded | yes | no | no | | Editing | yes | yes | no | | New | no | no | no | | Creating | no | yes | no | | Deleted | yes | no | yes |
Button visibility and the save button's label adapt to the current state automatically.
Built-in buttons
| Button | Shows when | Label | |--------|-----------|-------| | save | Always | "Save", "Create", or "Undo Delete" | | cancel | Dirty and not deleted | "Cancel" | | delete | Has key, not deleted | "Delete" | | new | Has key or dirty, not deleted | "New" | | duplicate | Has key, not deleted | "Duplicate" |
Override any button by defining it in your buttons.json. Reference defaults by name in a buttonSet array.
Navigation guards
mountForm() automatically registers a dirty-form guard and wires the browser's beforeunload event. When the form has unsaved changes:
- Browser navigation triggers a native confirmation dialog
- In-app navigation can be guarded by calling
controller.checkGuards()before transitioning
const ctrl = mountForm(container, form, options)
function navigate(url) {
const result = ctrl.checkGuards()
if (result !== true) {
if (!confirm(result)) return // user chose to stay
}
// proceed with navigation
}Disable the built-in guard with meta.preventNavigationWhenDirty: false.
Functions
Functions in registries.functions receive (context, event) with this bound to a stage object. Use regular functions (not arrow functions) to access this.
Context
{
action, // current action name
record, // { key, data }
meta, // form metadata
registries, // all registries
form: {
isDirty, isSubmitting, isDeleted,
getValue(name), setValue(name, value),
setFieldError(name, msg), clearFieldError(name),
reset(), loadRecord(record),
isRequired(name), isReadonly(name)
}
}Stage (this)
For field functions:
this.value,this.initialValuethis.setValue(val),this.clearValue()this.setError(msg),this.clearErrors()this.focus(),this.blur()this.def,this.path
For button functions:
this.isLoadingthis.executeAction()this.def,this.path
The onChange timing trap
Inside an onChange handler, context.form.getValue(fieldName) may return the old value. The reactive store hasn't flushed yet when your handler runs.
Read from event.target.value instead:
// Wrong -- may return stale value
export function onRoleChange(context, event) {
const role = context.form.getValue('role') // old value!
context.form.setValue('accessLevel', getMinLevel(role))
}
// Right -- read the new value from the event
export function onRoleChange(context, event) {
const role = event.target.value // current value
context.form.setValue('accessLevel', getMinLevel(role))
}Dynamic properties ({{functionName}}) are not affected -- they re-evaluate after the store updates, so they always see current values. The timing trap only applies to imperative onChange/onBlur handlers.
Meta configuration
Control form behavior through meta.json:
{
"confirmCancel": true,
"confirmDelete": true,
"confirmDuplicate": true,
"confirmNew": true,
"clearHiddenFields": true,
"preventNavigationWhenDirty": true,
"submitOnEnter": true,
"debug": false,
"recordKeyPath": "key",
"recordDataPath": "data",
"readonly": [],
"systemFields": []
}All values shown are defaults.
Custom components
Register your own components with a _adapter object in the component registry:
const form = await loadJsonConfig('/forms/UserView')
form.registries.components = {
...form.registries.components,
DatePicker: {
_adapter: {
mount(container, { def, store }) {
const picker = document.createElement('my-date-picker')
picker.value = store.record.data[def.name]
picker.addEventListener('change', (e) => {
store.record.data[def.name] = e.target.value
})
container.appendChild(picker)
return { unmount() { container.innerHTML = '' } }
}
}
}
}
mountForm(document.getElementById('app'), form, { /* ... */ })Reference by name in field definitions:
{
"startDate": { "component": "DatePicker", "label": "Start Date" }
}Config loading
import { loadJsonConfig, createLiveConfig } from '@archduck/gst-forms'
// Load from files (form schema pre-applied)
const form = await loadJsonConfig('./UserView')
// With variant overlays
const form = await loadJsonConfig('./UserView', { patches: ['uchealth', 'admin'] })
// With translation map
const form = await loadJsonConfig('./UserView', { patches: ['uchealth'], map: 'es' })File naming: property[-variant][~map].{json|js}. Variants stack left to right. Maps are applied after all variants have merged.
Runtime patching
const live = createLiveConfig()
await live.load('./UserView')
live.patch('fields', { email: { required: true } })
const form = live.get()Patches cascade through dependencies. If workEmail spreads from email, patching email re-resolves workEmail too.
Update without unmounting
const ctrl = mountForm(container, form, options)
// Later, load a different record or a different config
ctrl.update(newRecord)This preserves DOM state, scroll position, and focus while swapping the underlying data.
Debug API
When mounted, the form exposes window.gst:
window.gst.record // current record
window.gst.data // shorthand for record.data
window.gst.isDirty // dirty state
window.gst.errors // field errors
window.gst.getValue('email')
window.gst.setValue('email', '[email protected]')
window.gst.executeAction('save')
window.gst.getField('email')
window.gst.listFields()
window.gst.listButtons()
window.gst.listFunctions()API reference
Functions
mountForm(container, config, options)-- Mount a form. Returns controller withupdate,executeAction,checkGuards,unmount,store.loadJsonConfig(path, options?)-- Load form config with form schema. Options includepatches(variant array) andmapfor translations.createLiveConfig(options?)-- Create LiveConfig with form schema.
Defaults
defaultActions-- Built-in action handlers (save,cancel,delete,new,duplicate).defaultButtons-- Built-in button definitions with dynamic visibility.coreButtonFunctions-- Functions used by default buttons (showSave,saveLabel, etc.).META_DEFAULTS-- Default meta configuration values.
Schema
FORM_TLP_DECLARATIONS-- Raw TLP declarations for form properties.formSchema-- Pre-built schema from declarations (resolutionOrder,dependencies,registryMapping,validRegistries,getDefault).
Utilities
getRecordData(record, meta)/setRecordData(record, meta, data)-- Access record data respectingmeta.recordDataPath.getRecordKey(record, meta)-- Access record key respectingmeta.recordKeyPath.executeHooks(hookName, context, data)-- Run hook functions.validateConfig(config)-- Validate form config completeness.sanitizeHTML(html)/escapeHTML(str)/sanitizeInput(value, type)-- Security utilities.
