@moatamed-sce/form-engine
v0.1.5
Published
FormGroup construction, named Saudi validators and rule evaluation for SCE form definitions.
Maintainers
Readme
@moatamed-sce/form-engine
The runtime half of the SCE form platform. @moatamed-sce/form-schema says what a form is — a versioned JSON document; this package turns that document into a working Angular form: a typed FormGroup, resolved validators, evaluated rules, repeating rows, and lookup-backed options.
It renders nothing. Rendering is sce-dynamic-form in @moatamed-sce/ui-components. The split is deliberate: the engine is type:core, so it may use @angular/forms but never HttpClient, NgRx, or the design system — which is what lets the same document be built into a form inside a designer preview, a unit test, and the app.
Status — not published
This package is not currently on npmjs.org, and neither is @moatamed-sce/form-schema, which it declares as a peer. Inside the SCE monorepo both resolve through TypeScript path aliases.
What's in it
buildFormGroup(definition, initialValue, fb) — the entry point. Walks the definition's pages depth-first and returns a non-nullable FormGroup: fields become FormControls, group nodes become nested FormGroups, and array nodes become a FormArray of row groups. A field's initial value is coerced to its type, so a number field handed "5" does not poison the form value with a string.
Named validators — resolveNamedValidator / resolveNamedValidators turn a NamedValidator ({ name: 'saudiId' }) into a real ValidatorFn, resolved against the implementations in @moatamed-sce/core. This is the half of the schema's central constraint that makes it work: the document carries a name because a function is not serializable, and this is where the name becomes a function again.
Rule evaluation — applyRuleState reads the definition's rules against the current form value and produces the per-field state the renderer needs: hidden, disabled, required, readonly. Rules are the closed operator set the schema defines; there is no eval and no expression parser.
Repeating rows — addArrayRow, removeArrayRow, canAddArrayRow, canRemoveArrayRow, buildArrayRow and arrayNodeAt, which honour the node's minItems / maxItems so the UI never has to re-derive the bounds.
Wizard steps — resolveSteps turns a definition's steps into a FormStepView per step, reporting a FormStepIssue for anything that does not line up.
Data sources — the FORM_DATA_SOURCE injection token and resolveDataSource. The engine never fetches: the host provides a FormDataSource, and a DataSourceRef names a lookupKey the host resolves. A dependsOn source is re-requested with the value of the control it depends on.
pickActiveValues — the submit-time value with hidden and disabled controls removed, so a conditional branch the user never saw does not travel to the API.
structureSignature — a cheap string that changes only when a definition's shape changes, which is what lets the renderer rebuild the FormGroup on a real structural change and leave it alone otherwise.
Peer dependencies
Angular 21 (@angular/core, @angular/forms), RxJS 7.8, @moatamed-sce/core (the Saudi validators live there) and @moatamed-sce/form-schema (the contract).
Quick start
import { inject } from '@angular/core';
import { NonNullableFormBuilder } from '@angular/forms';
import { parseFormDefinition } from '@moatamed-sce/form-schema';
import { buildFormGroup, applyRuleState, pickActiveValues } from '@moatamed-sce/form-engine';
const fb = inject(NonNullableFormBuilder);
// Parse first — buildFormGroup trusts the definition completely.
const { definition, problems } = parseFormDefinition(stored);
if (!definition) {
return this.error.set(problems);
}
const { group } = buildFormGroup(definition, { nationalId: '1234567890' }, fb);
// Which fields are hidden / required / readonly right now:
const state = applyRuleState(definition, group);
// On submit — hidden and disabled controls are dropped:
const payload = pickActiveValues(definition, group, state);Provide a data source when the definition uses lookup options:
import {
FORM_DATA_SOURCE,
type FormDataSource,
type FormDataSourceRequest,
type FormOption,
} from '@moatamed-sce/form-engine';
@Injectable({ providedIn: 'root' })
export class LookupFormDataSource implements FormDataSource {
private readonly lookups = inject(LookupsApiService);
// `lookupKey` is a key, never a URL — the document cannot choose an endpoint.
// Returns an Observable; the engine subscribes, it never fetches.
options(request: FormDataSourceRequest): Observable<readonly FormOption[]> {
return this.lookups.byKey(request.lookupKey, request.dependsOn?.value);
}
}
providers: [{ provide: FORM_DATA_SOURCE, useExisting: LookupFormDataSource }];The request also carries definitionId, so a host serving several forms can scope caching or
permissions. The renderer injects the token { optional: true } — a form built entirely from
static sources should not force every host that renders one to supply a resolver.
A caveat worth knowing
buildFormGroup trusts the definition it is handed. Run it through parseFormDefinition first for anything that did not come from a TypeScript literal in this repo — a stored draft, an API response, an uploaded file. The parser is where prototype-pollution paths, non-JSON values and unbounded row counts are refused; the engine assumes that already happened.
Docs
- ADR 0002 — a form is a versioned JSON definition
- Form definition platform — full plan
- The JSON contract
License
MIT
