@moatamed-sce/form-engine 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,427 @@
1
+ import { FormGroup, NonNullableFormBuilder, AbstractControl, ValidatorFn } from '@angular/forms';
2
+ import { FormDefinition, FormArrayNode, FormFieldType, FormField, Condition, I18nText, DataSourceRef, FormPage, NamedValidator, NamedValidatorName } from '@moatamed-sce/form-schema';
3
+ import { InjectionToken } from '@angular/core';
4
+ import { Observable } from 'rxjs';
5
+
6
+ /**
7
+ * Growing and shrinking a repeater.
8
+ *
9
+ * `buildFormGroup` already produces a `FormArray` for an `array` node and seeds it to `minItems`,
10
+ * and there it stopped: nothing could add a row or take one away, so a repeater rendered a fixed
11
+ * number of rows forever and `maxItems` described a limit nothing enforced. The capability was
12
+ * half-built, which is worse than absent — a definition could declare `minItems: 1, maxItems: 5`
13
+ * and get neither.
14
+ *
15
+ * Both bounds are enforced here rather than as validators on the array. A validator would let the
16
+ * user delete the last row and *then* tell them the form is invalid; refusing the removal keeps
17
+ * the form in a state the definition allows at every moment, and lets the renderer disable the
18
+ * button instead of rendering an error. {@link canAddArrayRow} and {@link canRemoveArrayRow} are
19
+ * that button's enabled state.
20
+ *
21
+ * Nothing throws. A path that names no repeater is an authoring mistake in the caller, and it is
22
+ * reported the way the rest of the engine reports one — in the result, with the form untouched.
23
+ *
24
+ * **Run `applyRuleState` after any change here.** Row state is keyed by index, so removing row 0
25
+ * makes what was row 1 into row 0 and every state below the cut is now describing its neighbour
26
+ * — a hidden field reappears one row up. A new row likewise has only its static state until a
27
+ * rule pass reads the values it was seeded with. Callers driven by a value-change effect get
28
+ * this for free, because both operations move `getRawValue()`.
29
+ */
30
+ /** Why a row could not be added or removed. */
31
+ type ArrayRowRefusal =
32
+ /** The path names no `array` node, or the group holds no `FormArray` there. */
33
+ 'no-such-array'
34
+ /** Already at `maxItems`. */
35
+ | 'at-max'
36
+ /** Already at `minItems`. */
37
+ | 'at-min'
38
+ /** The index is outside the rows that exist. */
39
+ | 'no-such-row';
40
+ /** What happened to the repeater. */
41
+ interface ArrayRowResult {
42
+ readonly changed: boolean;
43
+ /** Row count after the attempt — unchanged when `changed` is false. */
44
+ readonly rows: number;
45
+ /** Set when `changed` is false. */
46
+ readonly refused?: ArrayRowRefusal;
47
+ }
48
+ /**
49
+ * The `array` node a control path names.
50
+ *
51
+ * Arrays nested inside arrays are not walked: their controls are indexed (`partners.0.phones`),
52
+ * and v1 has no way to address one. A definition that nests them still builds — only this lookup
53
+ * declines to find the inner one.
54
+ */
55
+ declare function arrayNodeAt(definition: FormDefinition, path: string): FormArrayNode | undefined;
56
+ /** True when the repeater has room for another row. An absent `maxItems` means no ceiling. */
57
+ declare function canAddArrayRow(definition: FormDefinition, group: FormGroup, path: string): boolean;
58
+ /** True when a row can be taken away without dropping below `minItems`. */
59
+ declare function canRemoveArrayRow(definition: FormDefinition, group: FormGroup, path: string): boolean;
60
+ /**
61
+ * Appends a row, seeded from `initial` where it has values for the row's fields.
62
+ *
63
+ * The row is built by the same code that built the ones already there, so a field's coercion and
64
+ * validators cannot drift between "was in the initial data" and "was added by the user".
65
+ */
66
+ declare function addArrayRow(definition: FormDefinition, group: FormGroup, path: string, fb: NonNullableFormBuilder, initial?: Readonly<Record<string, unknown>>): ArrayRowResult;
67
+ /**
68
+ * Removes one row.
69
+ *
70
+ * A missing row is reported before the `minItems` check, so a stale index from a double click
71
+ * does not come back as "you cannot remove any more rows" — which would be a lie the user would
72
+ * act on.
73
+ */
74
+ declare function removeArrayRow(definition: FormDefinition, group: FormGroup, path: string, index: number): ArrayRowResult;
75
+
76
+ /**
77
+ * The value shape each control type holds.
78
+ *
79
+ * A `FormGroup` built from a definition has to be typed before any data arrives, and getting
80
+ * this wrong is subtle: a multi-select seeded with `''` instead of `[]` makes `value.length`
81
+ * read 0 either way, then throws the first time something calls `.map`. A currency field seeded
82
+ * with `''` submits an empty string where the API expects a number or null.
83
+ */
84
+ /** Types whose value is an array rather than a scalar. */
85
+ declare const MULTI_VALUE_TYPES: ReadonlySet<FormFieldType>;
86
+ /** The empty value a type holds before any input. */
87
+ declare function typeFallback(type: FormFieldType): unknown;
88
+ /** Coerces a provided value into the shape the type expects. */
89
+ declare function coerceValue(type: FormFieldType, provided: unknown): unknown;
90
+
91
+ /**
92
+ * Rule evaluation.
93
+ *
94
+ * No `eval`, no `new Function`, no expression parser — a closed operator set walked recursively.
95
+ * A definition may arrive from an API, so the evaluator has to be safe against a hostile
96
+ * document by construction rather than by sanitising one.
97
+ */
98
+ /** What the rules currently say about one field. */
99
+ interface FieldRuleState {
100
+ readonly hidden: boolean;
101
+ readonly disabled: boolean;
102
+ readonly readonly: boolean;
103
+ /** `true` when a rule added a requirement, `false` when one removed it, `undefined` when rules are silent. */
104
+ readonly required?: boolean;
105
+ }
106
+ /**
107
+ * Rule state for every field, keyed by the **control path** that addresses it.
108
+ *
109
+ * For a flat definition a path is just the field's key, so nothing changes. Inside a group it is
110
+ * dotted — `riyadh.q-5` — which is what `FormGroup.get` needs. Keying by the bare key instead
111
+ * meant a rule silently did nothing the moment a definition used groups: `group.get('q-5')`
112
+ * returns null when the control lives at `riyadh.q-5`, and the follow-up it was meant to reveal
113
+ * stayed hidden with nothing to say so.
114
+ */
115
+ type RuleState = ReadonlyMap<string, FieldRuleState>;
116
+ /**
117
+ * The repeater row a field lives in.
118
+ *
119
+ * Rules are authored against a definition, which knows the *shape* of a row and never how many
120
+ * of them exist — so an effect naming `crNumber` has to mean "that field, in whichever row is
121
+ * being resolved". The prefix is what turns it into a control path, and `keys` is what decides
122
+ * whether a condition is asking about this row or about the form.
123
+ */
124
+ interface RowScope {
125
+ /** Control path of the row group — `partners.0`. */
126
+ readonly prefix: string;
127
+ /** Field paths declared inside one row, relative to {@link prefix}. */
128
+ readonly keys: ReadonlySet<string>;
129
+ }
130
+ /** A field and the control path that reaches it. */
131
+ interface FieldPath {
132
+ readonly field: FormField;
133
+ readonly path: string;
134
+ /** Set when the field is inside a repeater row, which is what scopes a rule to that row. */
135
+ readonly row?: RowScope;
136
+ }
137
+ /**
138
+ * Every field a rule can address, with its control path.
139
+ *
140
+ * Repeater rows are walked only when `values` are supplied, because how many rows exist is a
141
+ * fact about the data and never about the definition — the same reason `buildFormGroup` seeds
142
+ * `max(rows.length, minItems)` rather than a number the schema could state. Called without
143
+ * values this returns the static tree, which is what a caller wants when it needs the fields a
144
+ * definition declares rather than the controls a form currently holds.
145
+ */
146
+ declare function fieldPaths(definition: FormDefinition, values?: Readonly<Record<string, unknown>>): FieldPath[];
147
+ /**
148
+ * Evaluates a condition against the live form values.
149
+ *
150
+ * `scope` is the row a rule is being resolved for. Inside one, a condition naming a field the
151
+ * row declares reads *that row's* answer — which is the only reading that lets "when this
152
+ * partner is a company, require this partner's CR number" be written at all. Every other field
153
+ * it names is read from the form, so a row can still depend on a question asked once above it.
154
+ */
155
+ declare function evaluateCondition(condition: Condition, values: Readonly<Record<string, unknown>>, scope?: RowScope): boolean;
156
+ /**
157
+ * Resolves every field's visibility, enablement and requirement for the current values.
158
+ *
159
+ * Starts from the definition's static state — `hiddenByDefault` — then applies each matching
160
+ * rule in document order, so a later rule wins over an earlier one. That ordering is the reason
161
+ * rules live on the definition rather than the field: it is deterministic and reviewable in one
162
+ * place, where a per-field predicate is neither.
163
+ *
164
+ * A rule that reaches into a repeater is resolved **once per row**, against that row's answers.
165
+ * Evaluating the condition once for the whole form instead would make every row share one
166
+ * verdict — the first partner declared a company would require a CR number from all of them.
167
+ */
168
+ declare function evaluateRules(definition: FormDefinition, values: Readonly<Record<string, unknown>>): RuleState;
169
+ /** Convenience default for a field the rules never mention. */
170
+ declare const DEFAULT_FIELD_RULE_STATE: FieldRuleState;
171
+
172
+ /**
173
+ * Where a select's options come from at runtime.
174
+ *
175
+ * A definition can already say `{ type: 'lookup', lookupKey: 'cities' }`, and until now nothing
176
+ * turned that into options — `formDefinitionToConfig` renders an empty list and says so in a
177
+ * comment. This is the seam that closes it, and the shape of the seam is the whole point:
178
+ *
179
+ * - **The engine never fetches.** It is `type:core` and must not know `HttpClient` exists. A
180
+ * feature implements this interface over whatever it already uses — `LookupApiService` here
181
+ * returns promises, so `from()` is the adapter — and provides it at the route.
182
+ * - **A lookup is a key, never a URL.** Enforced at the schema boundary already; restated here
183
+ * because the port is the place an endpoint would otherwise sneak back in. A host maps keys
184
+ * it recognises and refuses the rest, so a definition from an API cannot make the app fetch
185
+ * from an arbitrary origin.
186
+ * - **Only the declared dependency is passed.** A dependent dropdown needs its parent's value
187
+ * and nothing else. Handing the resolver the whole value record would send every answer the
188
+ * user has typed — a national ID, a salary — to whichever service happens to serve city
189
+ * names.
190
+ * - **Static sources never reach the port.** They are data on the document; routing them
191
+ * through an injected service would make a form that renders perfectly offline fail because
192
+ * a provider was missing.
193
+ */
194
+ /** One option a select-like field offers. Mirrors the shape a static `DataSourceRef` carries. */
195
+ interface FormOption {
196
+ readonly value: string;
197
+ readonly label: I18nText;
198
+ }
199
+ /** The control a dependent source reads, and what it currently holds. */
200
+ interface FormDataSourceDependency {
201
+ /** Control key named by `DataSourceRef.dependsOn`. */
202
+ readonly field: string;
203
+ readonly value: unknown;
204
+ }
205
+ /** What the host is asked to resolve. */
206
+ interface FormDataSourceRequest {
207
+ /** The definition's `lookupKey` — a key the host maps to an endpoint, never a URL. */
208
+ readonly lookupKey: string;
209
+ /** The definition asking, so a host serving several forms can scope caching or permissions. */
210
+ readonly definitionId: string;
211
+ /** Present only when the source declares `dependsOn`. */
212
+ readonly dependsOn?: FormDataSourceDependency;
213
+ }
214
+ /** The port a feature implements so the engine never has to know about HTTP. */
215
+ interface FormDataSource {
216
+ options(request: FormDataSourceRequest): Observable<readonly FormOption[]>;
217
+ }
218
+ /**
219
+ * Provided in **features**, never in `ui-components`.
220
+ *
221
+ * The renderer injects it `{ optional: true }`: a form built entirely from static sources has
222
+ * no reason to force every host that renders one to supply a resolver.
223
+ */
224
+ declare const FORM_DATA_SOURCE: InjectionToken<FormDataSource>;
225
+ /** The outcome of resolving one field's data source. */
226
+ interface ResolvedDataSource {
227
+ /** Options as they arrive. Emits once and completes for a static source. */
228
+ readonly options$: Observable<readonly FormOption[]>;
229
+ /**
230
+ * Why the list is empty, when it is empty for a reason worth surfacing.
231
+ *
232
+ * Follows `BuiltForm.unresolvedValidators`: the engine reports what it could not do and
233
+ * renders the rest, rather than throwing and blanking a page over one missing dropdown.
234
+ */
235
+ readonly issue?: string;
236
+ }
237
+ /** The source a field's `dataSourceId` names, if the definition declares one. */
238
+ declare function dataSourceById(definition: FormDefinition, id: string | undefined): DataSourceRef | undefined;
239
+ /**
240
+ * Resolves the options for one data source id.
241
+ *
242
+ * `source` is the injected port, or `null` when the host provided none. Passing it in rather
243
+ * than injecting it here keeps this function callable from a test — and from a mapper — without
244
+ * an injection context.
245
+ */
246
+ declare function resolveDataSource(definition: FormDefinition, dataSourceId: string | undefined, values: Readonly<Record<string, unknown>>, source: FormDataSource | null): ResolvedDataSource;
247
+ /**
248
+ * The value that decides whether a source must be re-fetched.
249
+ *
250
+ * A renderer re-resolves a dependent source when this changes and leaves every other source
251
+ * alone — refetching all of them on each keystroke is how a form ends up firing a request per
252
+ * character typed into an unrelated field.
253
+ */
254
+ declare function dataSourceDependencyValue(definition: FormDefinition, dataSourceId: string | undefined, values: Readonly<Record<string, unknown>>): unknown;
255
+
256
+ /**
257
+ * Wizard steps.
258
+ *
259
+ * `FormDefinition.steps` groups pages under a label, and until now nothing read it: a definition
260
+ * could describe a six-step service journey and the renderer drew all six pages stacked in one
261
+ * scroll. The schema said wizard and the runtime said long form.
262
+ *
263
+ * What lives here is the arithmetic a wizard needs and nothing else — which controls belong to a
264
+ * step, whether that step is answered, how far the user may go. Deliberately no chrome and no
265
+ * current-step state: the stepper is `sce-stepper`, and which step is showing is the host's, so
266
+ * that a wizard driven by a route parameter and one driven by a signal share one notion of
267
+ * "step 3 is not finished".
268
+ */
269
+ /** Why a definition's steps could not be taken at face value. */
270
+ interface FormStepIssue {
271
+ /** The step or page the problem is about. */
272
+ readonly id: string;
273
+ readonly reason: string;
274
+ }
275
+ /** One step, resolved against the pages and controls it actually covers. */
276
+ interface FormStepView {
277
+ readonly id: string;
278
+ readonly index: number;
279
+ readonly title: I18nText;
280
+ readonly pages: readonly FormPage[];
281
+ /**
282
+ * Control paths this step holds, expanded against the current values.
283
+ *
284
+ * Repeater rows are included — `partners.0.name` — because a step containing a repeater is
285
+ * finished only when the rows the user actually added are.
286
+ */
287
+ readonly paths: readonly string[];
288
+ }
289
+ /** Steps as the runtime sees them, with whatever the definition got wrong. */
290
+ interface ResolvedSteps {
291
+ readonly steps: readonly FormStepView[];
292
+ readonly issues: readonly FormStepIssue[];
293
+ }
294
+ /**
295
+ * Resolves a definition's steps, or falls back to one step per page.
296
+ *
297
+ * The fallback is what makes a step-aware host safe to point at any definition: a form that
298
+ * declares no steps is a one-page-per-step wizard, which for the single-page case is exactly the
299
+ * flat form it already was. A host that had to branch on `definition.steps` being present would
300
+ * end up with two layouts to keep in step.
301
+ *
302
+ * A page no step claims is reported rather than appended. Appending it would invent a step the
303
+ * author never wrote; dropping it in silence would lose the questions on it. The caller has an
304
+ * issues list to render, which is how the rest of this platform reports a document it cannot
305
+ * take literally.
306
+ */
307
+ declare function resolveSteps(definition: FormDefinition, values?: Readonly<Record<string, unknown>>): ResolvedSteps;
308
+ /**
309
+ * The controls a step holds, skipping paths the group does not have.
310
+ *
311
+ * A missing control is not an error here: a renderer that flattens groups holds `q-5` where the
312
+ * definition says `riyadh.q-5`, and a step asserting on every path would call such a form
313
+ * permanently incomplete.
314
+ */
315
+ declare function stepControls(step: FormStepView, group: FormGroup): AbstractControl[];
316
+ /**
317
+ * True when nothing in the step is blocking.
318
+ *
319
+ * Disabled controls are skipped rather than counted valid, because Angular reports a disabled
320
+ * control's `valid` as **false** — a step whose conditional half is currently hidden would never
321
+ * pass, and the Next button would sit dead with no error to explain why.
322
+ *
323
+ * A step holding no controls at all is complete: a page of `display` copy is something the user
324
+ * reads, not something they answer.
325
+ */
326
+ declare function isStepComplete(step: FormStepView, group: FormGroup): boolean;
327
+ /** Marks everything in the step touched, so `Next` reveals the errors instead of doing nothing. */
328
+ declare function markStepTouched(step: FormStepView, group: FormGroup): void;
329
+ /**
330
+ * Whether the user may move on from `index`.
331
+ *
332
+ * False on the last step: advancing past the end is a submit, and a wizard that treats it as a
333
+ * step change is how a form gets a seventh page nobody wrote.
334
+ */
335
+ declare function canAdvance(steps: readonly FormStepView[], group: FormGroup, index: number): boolean;
336
+ /**
337
+ * The furthest step the user has earned.
338
+ *
339
+ * Every step up to it is complete. This is what a stepper binds its clickable steps to — letting
340
+ * someone jump to step 5 from an unfinished step 2 produces a review page summarising answers
341
+ * that were never given.
342
+ */
343
+ declare function furthestReachableStep(steps: readonly FormStepView[], group: FormGroup): number;
344
+ /**
345
+ * The first step that is not complete, or `-1` when all of them are.
346
+ *
347
+ * A submit that fails should land the user on the question that failed it, not on step 1 with a
348
+ * toast — this is the index to navigate to.
349
+ */
350
+ declare function firstIncompleteStep(steps: readonly FormStepView[], group: FormGroup): number;
351
+
352
+ /**
353
+ * Resolves one named validator, or `null` when its parameters are missing or unusable.
354
+ *
355
+ * Returning `null` rather than throwing is deliberate: a definition with a malformed `min` should
356
+ * render the form without that one check, not blank the screen. Callers that need to know use
357
+ * {@link resolveNamedValidators}, which reports what it could not build.
358
+ */
359
+ declare function resolveNamedValidator(validator: NamedValidator): ValidatorFn | null;
360
+ /** Resolves a list of named validators, reporting the names that could not be built. */
361
+ declare function resolveNamedValidators(validators?: readonly NamedValidator[]): {
362
+ readonly fns: ValidatorFn[];
363
+ readonly unresolved: NamedValidatorName[];
364
+ };
365
+
366
+ /**
367
+ * Builds the `FormGroup` a definition describes.
368
+ *
369
+ * Three decisions here are the ones that bite if they are made the other way:
370
+ *
371
+ * - **Hidden fields still get a control.** They are `disable()`d rather than omitted, so
372
+ * `getRawValue()` still carries their key and a rule can reveal one without rebuilding the
373
+ * group and losing everything the user has typed. A hidden control that is merely absent is
374
+ * how a conditional field ends up blocking submit with an error nobody can see.
375
+ * - **`emitEvent: false` on every disable.** Toggling enablement during a `valueChanges`
376
+ * handler otherwise re-enters the rule pass, and the second pass sees a half-applied state.
377
+ * - **Requirement is reconciled, never toggled.** Each pass recomputes it from the rules and
378
+ * the definition together, so a field a rule un-required becomes required again the moment
379
+ * the condition stops matching.
380
+ */
381
+ /** Everything the renderer needs after a build: the group, and what could not be resolved. */
382
+ interface BuiltForm {
383
+ readonly group: FormGroup;
384
+ /** Validator names present in the definition that the engine could not resolve. Empty on a clean build. */
385
+ readonly unresolvedValidators: readonly string[];
386
+ }
387
+ /**
388
+ * Builds a row for an existing repeater, so a consumer can append one.
389
+ *
390
+ * Exported because `addArrayRow` has to produce a row indistinguishable from the ones the
391
+ * initial build produced — a second implementation would drift the moment a field type gained a
392
+ * coercion rule.
393
+ *
394
+ * A fresh row carries the definition's static state only. Rule state cannot be known here: the
395
+ * row does not exist in the values yet, so there is nothing for a condition to read. The caller
396
+ * settles it by running {@link applyRuleState} once the row is in the array, which
397
+ * {@link addArrayRow} documents as the step that must follow.
398
+ */
399
+ declare function buildArrayRow(node: FormArrayNode, initial: Readonly<Record<string, unknown>>, fb: NonNullableFormBuilder, rowPath?: string): FormGroup;
400
+ /** Builds a typed, non-nullable `FormGroup` from a definition and its initial values. */
401
+ declare function buildFormGroup(definition: FormDefinition, initial: Readonly<Record<string, unknown>>, fb: NonNullableFormBuilder): BuiltForm;
402
+ /**
403
+ * Re-applies rule state to an existing group after a value change.
404
+ *
405
+ * Rebuilding the group instead would discard `touched`, `dirty` and focus — which is exactly
406
+ * what users report as "the form reset itself while I was typing".
407
+ */
408
+ declare function applyRuleState(definition: FormDefinition, group: FormGroup): RuleState;
409
+ /**
410
+ * The values a submit should carry.
411
+ *
412
+ * Hidden fields are omitted rather than sent as empty: a conditional branch the user never saw
413
+ * should not appear in the payload at all, which is different from appearing as `""`.
414
+ */
415
+ declare function pickActiveValues(definition: FormDefinition, group: FormGroup, state: RuleState): Record<string, unknown>;
416
+ /**
417
+ * A signature of everything about a definition that requires a *rebuild* rather than a patch.
418
+ *
419
+ * Options are deliberately excluded. Including them meant every lookup fetch changed the
420
+ * signature, rebuilt the group and wiped whatever the user had already typed — options change
421
+ * what a select offers, never the shape of the control holding it. Keys are sorted so that two
422
+ * definitions differing only in property order produce the same signature.
423
+ */
424
+ declare function structureSignature(definition: FormDefinition): string;
425
+
426
+ export { DEFAULT_FIELD_RULE_STATE, FORM_DATA_SOURCE, MULTI_VALUE_TYPES, addArrayRow, applyRuleState, arrayNodeAt, buildArrayRow, buildFormGroup, canAddArrayRow, canAdvance, canRemoveArrayRow, coerceValue, dataSourceById, dataSourceDependencyValue, evaluateCondition, evaluateRules, fieldPaths, firstIncompleteStep, furthestReachableStep, isStepComplete, markStepTouched, pickActiveValues, removeArrayRow, resolveDataSource, resolveNamedValidator, resolveNamedValidators, resolveSteps, stepControls, structureSignature, typeFallback };
427
+ export type { ArrayRowRefusal, ArrayRowResult, BuiltForm, FieldPath, FieldRuleState, FormDataSource, FormDataSourceDependency, FormDataSourceRequest, FormOption, FormStepIssue, FormStepView, ResolvedDataSource, ResolvedSteps, RowScope, RuleState };