MultiTableStepFormWizard
A multi-step form wizard where each step persists its data to its own table. Every step in the same run shares one workflow caseID, so a single submission can spread rows across many tables (a header row, a details row, an uploads step, and so on) and still be stitched back together later.
It is built on top of OnboardingUI / OnboardingLogic, which own the shell and the Back / Continue chrome. The wizard intercepts navigation to add validation, dirty-checked saving, prefill, a standalone Save button, and workflow bookkeeping.
This is the multi-table counterpart to StepWizard (single table). If your form only ever writes to one table, use StepWizard instead.
Importing
import { MultiTableStepFormWizard } from "./sp-multi-table-step-form-wizard.js";
Place the file in the same core folder as sp-step-wizard.js. Its own imports (ApiLogic, FileUploadBoard, DomBuilder, OnboardingUI) should resolve from that folder, with ApiConfig coming from ../utils/sp-config.js.
Quick start
const wizard = new MultiTableStepFormWizard(steps, {
serviceID: "SchoolEnrollment", // drives the caseID prefix
loadOnStart: {}, // optional dropdown/topic preloads
});
// init() must run before render() for prefill + upload topics to be ready.
await wizard.init();
wizard.onFinish = (e, formData, caseID) => {
DomBuilder.showNotification?.(`Workflow ${caseID} complete`, "success");
};
container.appendChild(wizard.render());
The two-call shape (await init() then render()) matters: init() loads option data and any existing records for the case, and render() returns the DOM element synchronously.
Constructor
new MultiTableStepFormWizard(steps, options)
steps is an array of step definitions (see Step configuration below).
options accepts:
serviceID— a short identifier for the workflow. Its first three characters (upper-cased) become thecaseIDprefix. Required if you want case tracking; without it nocaseIDis generated and step records are not pre-fetched.caseID— pass an existing case to edit a previously started workflow. Omit it to start a fresh one (a new id is generated).headerTable— the workflow header table. Defaults totsp_sys_workflows.loadOnStart— a map of dropdown/upload option loaders (see loadOnStart).
The caseID
When you don't pass a caseID, one is generated in the form PREFIX-YYMMDD-XXXX, for example SCH-260801-4K9A. The random suffix uses crypto when available and drops ambiguous characters (no O/0/I/1).
The caseID is written into every saved row via the workflow meta fields, so all rows from one run share it.
The lifecycle: init() then render()
init() performs, in order: load loadOnStart option data, pre-fetch existing records for the header and every step table matching the caseID, then mount any upload boards. Call it once and await it.
render() returns the wizard's root element. It is safe to call without init() (uploads render empty and nothing is pre-filled), but for real use always init() first.
destroy() tears down uploaders and clears internal state when you close the window.
Step configuration
Each entry in steps is a plain object. Only title is strictly required; the rest depend on what the step does.
title— the step heading shown in the wizard.type— set to"upload"for a file-upload step. Omit for a normal form step.tableToSendData— the table this step saves to. Omit for upload or display-only steps.uniqueField— the primary-key column oftableToSendData. Used to decide insert vs update and to target updates. See How saving works.fields— an object keyed by field id, each value a field config (see Fields and grouping). Omitted for upload steps.groupFields— an array describing how to group fields visually within the step.onConfirm— an async gate run before advancing (see The onConfirm hook).selectRelations— dependent-dropdown wiring (see Dependent dropdowns).topicKey,topics,maxFiles— upload-step options (see Upload steps).
A minimal saving step looks like this:
{
title: "Student details",
tableToSendData: "tsp_app_students",
uniqueField: "sp_student_id",
fields: {
first_name: { type: "text", label: "First name", required: true },
last_name: { type: "text", label: "Last name", required: true },
dob: { type: "date", label: "Date of birth" },
},
}
Fields and grouping
fields is an object whose keys are field ids and whose values are configs passed to DomBuilder.createSmartInput. The key is important: it is the id the wizard reads on save, so each key must equal the destination column name in tableToSendData. A first_name key writes to a first_name column.
Each field config supports (all optional except type):
type—text,email,number,password,date,datetime-local,checkbox,select,textarea, or any valid input type.label— the visible label.value— an initial value. Usually left empty; prefill fills it at runtime.placeholder,required,readonly,disabled.description— help text shown under the control.options— forselect: an array of{ value, label }, an array of scalars, or a plain object. A PHP associative array (which encodes as an object) is handled.searchable— force a select's search box on or off (defaults to on when there are more than eight options).min,max,step— numeric and date bounds.
Grouping fields
groupFields lays several fields out together. Each group is an object:
groupFields: [
{ label: "Name", fields: ["first_name", "last_name"] },
]
The fields array holds field keys from the fields object. A group renders as a grid by default (two columns). You can override with type: "flex" for a stacked column, and columnCount for a different grid width:
{ label: "Address", type: "grid", columnCount: 3, fields: ["street", "city", "zip"] }
Fields not named in any group render normally, in their object order.
How saving works
Workflow meta on every row
Before each save the wizard injects three meta fields into the payload:
sp_workflow_case— thecaseID.sp_workflow_folder— theserviceID.sp_workflow_user— the current API user key.
Every table you save to (each step table and the header) must have these three columns. They are what let you re-assemble a workflow later by querying on sp_workflow_case.
The header row
On the first step, the wizard writes one row to headerTable (tsp_sys_workflows by default) carrying just the workflow meta. It is written once per workflow — if a header already exists for the case (edit mode), it is not re-inserted.
Insert once, update thereafter
A step inserts at most once. The first successful save inserts; every save after that updates. The decision uses three signals: a prior successful save for the step, a known primary-key value, and the presence of uniqueField in the values. Once a row exists, the server-assigned key is carried forward into later update payloads so the update targets the right row.
Change detection
A network write only happens when the step is dirty — its current inputs differ from the last saved or loaded snapshot. If nothing changed, no request is sent. This applies on Next, Prev, and Save. The Save button reports "No changes to save" when the step is clean.
Duplicate-submit protection
A wizard-wide in-flight lock wraps every Next / Prev / Save, so a rapid double-click cannot fire two writes or cause a double insert.
Two server expectations
Updates target the row by uniqueField, so uniqueField should be the step table's primary key. And the save endpoint should echo the persisted row back in resp.data[0]; the wizard reads that to capture a server-assigned key and to sync computed values back into the inputs. If your endpoint doesn't echo the row, make uniqueField one of the step's own input fields so the key is always present in the payload.
Navigation and the Save button
Next validates the step, collects inputs, injects workflow meta, runs onConfirm, saves the header on step one, saves the step if dirty, then advances (or finishes on the last step).
Prev saves the step if it is dirty and valid, then goes back. Going back is never blocked — if the step is invalid, Prev simply declines to persist bad data and still navigates.
Save persists the current step in place without navigating. It is injected next to Back / Continue and only appears on steps that have a tableToSendData and are not upload steps.
Prefill and edit mode
To edit an existing workflow, pass its caseID:
const wizard = new MultiTableStepFormWizard(steps, {
serviceID: "SchoolEnrollment",
caseID: "SCH-260801-4K9A",
});
await wizard.init();
init() pre-fetches the matching row for each step table. As you land on a step its inputs are filled from those rows (and from anything already entered in this run), dependent dropdowns are restored to their saved selections, and the step is snapshotted as "clean" so it won't re-save unless you actually change something.
Validation
Steps use native HTML5 validation. On Next and Save, an invalid step is blocked and the offending fields are highlighted with an inline message (falling back to a notification when a field has no error slot). On Prev, validation does not block navigation; it only prevents saving invalid data. Upload steps have no inputs to validate and always pass.
The onConfirm hook
Attach onConfirm to gate forward progression or run side effects before a step advances. It runs on Next only.
{
title: "Payment",
tableToSendData: "tsp_app_payments",
uniqueField: "sp_payment_id",
fields: { amount: { type: "number", label: "Amount", required: true } },
onConfirm: async (values, formData, inputRefs, ctx) => {
if (Number(values.amount) <= 0) {
DomBuilder.showNotification?.("Amount must be positive", "error");
return false; // blocks Next
}
return true;
},
}
The arguments are the collected step values (including workflow meta), the accumulated formData, the step's inputRefs, and a ctx object of { caseID, uploader, stepConfig, index }. On an upload step, ctx.uploader is the live FileUploadBoard so you can validate uploads before continuing. Return false to stop; any other return value proceeds.
onFinish
Set wizard.onFinish to run when the last step's Next (now labelled Finish) is pressed:
wizard.onFinish = (e, formData, caseID) => {
// formData holds every field collected across all steps
// caseID identifies the workflow you just completed
};
Upload steps
An upload step mounts a FileUploadBoard instead of form fields:
{
title: "Documents",
type: "upload",
topicKey: "documentTopics", // resolves against loadOnStart data
maxFiles: 5,
}
Topics come from topicKey (a key in the loadOnStart option data) with a static topics array as a fallback, plus maxFiles (defaults to five). The board manages its own uploads, so an upload step is not saved to a table by the wizard — validate or read it through ctx.uploader in onConfirm if you need to gate on it.
Dependent dropdowns (selectRelations)
selectRelations wires a parent select to a child select: when the parent changes, the child's options reload.
{
title: "Location",
tableToSendData: "tsp_app_addresses",
uniqueField: "sp_address_id",
fields: {
country: { type: "select", label: "Country", options: [/* ... */] },
city: { type: "select", label: "City" },
},
selectRelations: [
{
parent: "country",
child: "city",
app: "AddressApp",
method: "getCities",
filterKey: "country_id",
},
],
}
The parent and child values are field keys; the wizard finds the actual <select> elements by matching select[id="..."], so the rendered select's id must equal the field key (which it does when the key is the field's data-model).
Parameter shaping for the reload call:
- Default (object): sends
[{ [filterKey]: value }], withfilterKeydefaulting toid. type: "query": sends[queryName, { [filterKey]: value }].paramType: "plain": sends[value].localSource: skip the server entirely and filter a local array byitem.parentId === value.
In edit mode, when a saved parent value exists the child is reloaded automatically and its stored value re-selected.
loadOnStart
loadOnStart pre-fetches option data before render. It is a map of a name to a loader:
loadOnStart: {
documentTopics: { app: "DocsApp", method: "getUploadTopics", params: [] },
regions: { app: "GeoApp", method: "getRegions", params: [] },
}
Each loader calls spFetch({ app, method, params, nocache: true }) and stores the result under its name in the wizard's option data. Upload steps read topics from there via topicKey. Reads use a separate engine, so they never disturb the API URL used by saves.
Generating the config from the Note editor
The visual form builder can emit this structure. On the Form Designer tab, switch the form type selector to Multi-Table Step Wizard. The generator then produces, per step, { title, tableToSendData, uniqueField, type?, fields, groupFields }, where fields is the name-to-config object described above.
selectRelations, upload topics, and loadOnStart are deliberately not generated by the scan — you edit those through the project's JsonBuilder fields (sp_select_relations, sp_file_upload_config, sp_load_on_start) and attach them at runtime.
The form type is saved as its own formType column alongside the JSON and HTML snapshots, so when you read a saved form you know whether to instantiate StepWizard or MultiTableStepFormWizard.
Method and property reference
Public methods:
async init()— load options, pre-fetch records, mount uploads. Call beforerender().render()— returns the wizard's root DOM element.destroy()— tear down uploaders and clear state._swapWindowView(windowId, nextViewElement)— swap the host window's body to another view (delegates toOnboardingUI).
Useful properties:
caseID— the active workflow case.formData— every field collected so far across all steps, merged with saved records.loadedData— pre-fetched and post-save records keyed by table name.stepHistory— per-step record of the last save (action, status, timestamp).onFinish— assignable finish callback.
Requirements and gotchas
- The header table (
tsp_sys_workflowsby default) must exist, and every step table must havesp_workflow_case,sp_workflow_folder, andsp_workflow_usercolumns in addition to its own fields and primary key. uniqueFieldshould be the step table's primary key; updates match on it, and the wizard carries a server-returned key forward into later updates.- The save endpoint should echo the saved row in
resp.data[0]; otherwise makeuniqueFieldone of the step's input fields. - A field's object key equals its
data-modelequals its destination column name — keep those three in sync. fieldsis an object (name to config), not an array.- A dependent select's rendered id must equal its field key for
selectRelationsto find it.
Full example
import { MultiTableStepFormWizard } from "./sp-multi-table-step-form-wizard.js";
const steps = [
{
title: "Student",
tableToSendData: "tsp_app_students",
uniqueField: "sp_student_id",
fields: {
first_name: { type: "text", label: "First name", required: true },
last_name: { type: "text", label: "Last name", required: true },
email: { type: "email", label: "Email" },
},
groupFields: [{ label: "Name", fields: ["first_name", "last_name"] }],
},
{
title: "Enrollment",
tableToSendData: "tsp_app_enrollments",
uniqueField: "sp_enrollment_id",
fields: {
country: { type: "select", label: "Country", options: [/* ... */] },
program: { type: "select", label: "Program" },
start_on: { type: "date", label: "Start date", required: true },
},
selectRelations: [
{ parent: "country", child: "program", app: "SchoolApp", method: "getPrograms", filterKey: "country_id" },
],
onConfirm: async (values) => {
if (!values.program) {
DomBuilder.showNotification?.("Choose a program", "error");
return false;
}
return true;
},
},
{
title: "Documents",
type: "upload",
topicKey: "enrollmentDocs",
maxFiles: 4,
},
];
const wizard = new MultiTableStepFormWizard(steps, {
serviceID: "SchoolEnrollment",
loadOnStart: {
enrollmentDocs: { app: "SchoolApp", method: "getDocTopics", params: [] },
},
});
await wizard.init();
wizard.onFinish = (e, formData, caseID) => {
DomBuilder.showNotification?.(`Enrollment ${caseID} submitted`, "success");
};
document.querySelector("#host").appendChild(wizard.render());
To reopen and edit that same enrollment later, pass its caseID in the options and everything pre-fills.