npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@archduck/gst-forms

v0.1.1

Published

Form plugin for gst-core: field definitions, actions, validation, and lifecycle hooks

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-forms
import { 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-forms

Create 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.html

fields.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:

  1. Default actions (highest) -- built-in save, cancel, reset, delete, duplicate, new
  2. Specific prop handlers -- onSave, onDelete, etc. passed to mountForm
  3. Generic onAction prop -- single handler for all actions
  4. 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 / afterUpdate
  • beforeCancel / afterCancel, beforeDelete / afterDelete
  • beforeNew / afterNew, beforeDuplicate / afterDuplicate

State-transition hooks

These fire on form state changes, not around actions:

  • onChange -- any field value changes
  • onDirty / onClean -- dirty state transitions
  • onError / onRecover -- validation error state transitions
  • onLoad / onUnload -- form mount/unmount
  • beforeUnload -- 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.initialValue
  • this.setValue(val), this.clearValue()
  • this.setError(msg), this.clearErrors()
  • this.focus(), this.blur()
  • this.def, this.path

For button functions:

  • this.isLoading
  • this.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 with update, executeAction, checkGuards, unmount, store.
  • loadJsonConfig(path, options?) -- Load form config with form schema. Options include patches (variant array) and map for 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 respecting meta.recordDataPath.
  • getRecordKey(record, meta) -- Access record key respecting meta.recordKeyPath.
  • executeHooks(hookName, context, data) -- Run hook functions.
  • validateConfig(config) -- Validate form config completeness.
  • sanitizeHTML(html) / escapeHTML(str) / sanitizeInput(value, type) -- Security utilities.