ngx-t-forms-types 0.0.30 → 0.0.34

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.
Files changed (68) hide show
  1. package/dist/interfaces/Form/formSubmissionHandleInterface.d.ts +6 -0
  2. package/dist/interfaces/FormBuilder/DefaultEelement.js +76 -0
  3. package/dist/interfaces/FormBuilder/DefaultInputConfigInterface.d.ts +13 -0
  4. package/dist/interfaces/FormBuilder/FormInputKeys.d.ts +6 -0
  5. package/dist/interfaces/FormBuilder/FormInputKeys.js +6 -0
  6. package/dist/interfaces/FormBuilder/inputConfig/ElementEditConfig.js +188 -3
  7. package/dist/interfaces/Import/ImportIdentity.d.ts +32 -0
  8. package/dist/interfaces/Import/ImportIdentity.js +1 -0
  9. package/dist/interfaces/Import/ImportProgress.d.ts +12 -1
  10. package/dist/interfaces/Import/ImportRowState.d.ts +52 -1
  11. package/dist/interfaces/Import/index.d.ts +2 -1
  12. package/dist/interfaces/formInput/APIDataFetchingConfigurationInterface.d.ts +34 -0
  13. package/dist/interfaces/formInput/BasicFormInputInterface.d.ts +1 -0
  14. package/dist/interfaces/formInput/BasicFormInputInterface.js +1 -0
  15. package/dist/interfaces/formInput/IMscoaAccount.d.ts +19 -3
  16. package/dist/interfaces/formInput/ISelectInputInterface.d.ts +12 -0
  17. package/dist/interfaces/formInput/MultipleInterface.d.ts +10 -0
  18. package/dist/interfaces/formInput/WorkflowDocumentPicker.d.ts +2 -0
  19. package/dist/schemas/FormInputSchema.js +41 -2
  20. package/dist/schemas/MatOptionsSchema.js +7 -0
  21. package/dist/schemas/MscoaConfigSchema.js +1 -0
  22. package/dist/schemas/index.d.ts +2 -1
  23. package/dist/schemas/index.js +2 -1
  24. package/dist/skillet/authoring-rules.d.ts +41 -0
  25. package/dist/skillet/authoring-rules.js +61 -0
  26. package/dist/skillet/extra-members.d.ts +143 -0
  27. package/dist/skillet/extra-members.js +79 -0
  28. package/dist/skillet/form.d.ts +170 -0
  29. package/dist/skillet/form.js +139 -0
  30. package/dist/skillet/index.d.ts +43 -0
  31. package/dist/skillet/index.js +46 -0
  32. package/dist/skillet/input-members.d.ts +375 -0
  33. package/dist/skillet/input-members.js +186 -0
  34. package/dist/skillet/internal/coupling.d.ts +63 -0
  35. package/dist/skillet/internal/coupling.js +69 -0
  36. package/dist/skillet/internal/editor-guidance.d.ts +64 -0
  37. package/dist/skillet/internal/editor-guidance.js +291 -0
  38. package/dist/skillet/members/calculated-field.d.ts +155 -0
  39. package/dist/skillet/members/calculated-field.js +105 -0
  40. package/dist/skillet/members/conditional.d.ts +69 -0
  41. package/dist/skillet/members/conditional.js +16 -0
  42. package/dist/skillet/members/document-picker.d.ts +182 -0
  43. package/dist/skillet/members/document-picker.js +111 -0
  44. package/dist/skillet/members/mat-options.d.ts +187 -0
  45. package/dist/skillet/members/mat-options.js +92 -0
  46. package/dist/skillet/members/mscoa.d.ts +182 -0
  47. package/dist/skillet/members/mscoa.js +129 -0
  48. package/dist/skillet/members/pagination.d.ts +59 -0
  49. package/dist/skillet/members/pagination.js +16 -0
  50. package/dist/skillet/members/table.d.ts +178 -0
  51. package/dist/skillet/members/table.js +105 -0
  52. package/dist/skillet/members/validators.d.ts +197 -0
  53. package/dist/skillet/members/validators.js +74 -0
  54. package/dist/skillet/members/value.d.ts +154 -0
  55. package/dist/skillet/members/value.js +97 -0
  56. package/dist/skillet/shared-types.d.ts +57 -0
  57. package/dist/skillet/shared-types.js +69 -0
  58. package/dist/skillet/tests/house-rules.spec.d.ts +1 -0
  59. package/dist/skillet/tests/house-rules.spec.js +162 -0
  60. package/dist/skillet/tests/skillet-coupling.spec.d.ts +1 -0
  61. package/dist/skillet/tests/skillet-coupling.spec.js +191 -0
  62. package/dist/skillet/tests/variant-assembly.spec.d.ts +1 -0
  63. package/dist/skillet/tests/variant-assembly.spec.js +643 -0
  64. package/dist/skillet/variants.d.ts +138 -0
  65. package/dist/skillet/variants.js +536 -0
  66. package/dist/skillet/workflow-context.d.ts +95 -0
  67. package/dist/skillet/workflow-context.js +128 -0
  68. package/package.json +78 -60
@@ -0,0 +1,643 @@
1
+ import { s } from '@hashbrownai/core';
2
+ import { FormInputKeys } from '../../interfaces/FormBuilder/FormInputKeys.js';
3
+ import { ElementTypes } from '../../interfaces/formInput/index.js';
4
+ import { extraInputMemberSchemas } from '../extra-members.js';
5
+ import { validateFormColumnInputs } from '../../schemas/FormInputSchema.js';
6
+ import { validateForm } from '../../schemas/FormSchema.js';
7
+ import { createFormDraftSchema } from '../form.js';
8
+ import { COMMON_ELEMENTS, JOI_MANDATORY_COLUMN_KEYS, JOI_UNKNOWN_COLUMN_KEYS, UNRESOLVED_VARIANT_KEYS, createColumnSchema, } from '../variants.js';
9
+ function columnJson(options = {}) {
10
+ return s.toJsonSchema(createColumnSchema(options));
11
+ }
12
+ /**
13
+ * Resolves a `$ref` against the schema's `$defs`.
14
+ *
15
+ * Skillet hoists any node instance it sees more than once, and the member
16
+ * registries exist precisely so that a property is ONE node reused everywhere —
17
+ * so most interesting members arrive as references rather than inline. A test
18
+ * that reads them without resolving is testing the reference, not the schema.
19
+ */
20
+ function deref(json, node) {
21
+ const ref = node?.$ref;
22
+ if (!ref)
23
+ return node;
24
+ return json.$defs?.[ref.replace('#/$defs/', '')];
25
+ }
26
+ /**
27
+ * Every branch of the column union, grouped by the element it discriminates on.
28
+ *
29
+ * Grouped rather than keyed one-to-one because an element may contribute more
30
+ * than one branch — see the calculated-field split.
31
+ */
32
+ function branches(options = {}) {
33
+ const json = columnJson(options);
34
+ const grouped = {};
35
+ for (const branch of json.anyOf) {
36
+ const element = branch.properties?.element?.const;
37
+ if (!element)
38
+ throw new Error('a column branch has no element discriminator');
39
+ (grouped[element] ??= []).push(branch);
40
+ }
41
+ return grouped;
42
+ }
43
+ /** The single branch for an element that contributes exactly one. */
44
+ function only(grouped, element) {
45
+ const found = grouped[element];
46
+ if (found?.length !== 1) {
47
+ throw new Error(`expected exactly one ${element} branch, got ${found?.length}`);
48
+ }
49
+ return found[0];
50
+ }
51
+ const WORKFLOWS = [{ id: 'wf-1', name: 'Purchase requisition' }];
52
+ describe('variant assembly', () => {
53
+ it('resolves every property listed by DefaultInputConfig', () => {
54
+ // A key that resolves to no node and is not a declared omission would be
55
+ // silently missing from every generated form of that element.
56
+ createColumnSchema({ workflows: WORKFLOWS });
57
+ expect([...new Set(UNRESOLVED_VARIANT_KEYS)]).toEqual([]);
58
+ });
59
+ it('builds a variant for every element it can complete', () => {
60
+ // `workflowDocumentPicker` REQUIRES `workflowPickerConfig`, which cannot be
61
+ // built without a workflow to point at. Offering it anyway would emit a
62
+ // picker missing its only required member, so the element is withdrawn
63
+ // instead — and comes back the moment a workflow is supplied.
64
+ const withoutWorkflows = Object.keys(branches());
65
+ expect(withoutWorkflows).not.toContain(ElementTypes.WorkflowDocumentPicker);
66
+ const withWorkflows = Object.keys(branches({ workflows: WORKFLOWS }));
67
+ expect(withWorkflows.sort()).toEqual([...Object.values(ElementTypes)].sort());
68
+ });
69
+ it('gives every variant the keys Joi requires of a column', () => {
70
+ const all = branches({ workflows: WORKFLOWS });
71
+ for (const [element, elementBranches] of Object.entries(all)) {
72
+ // validateForm() strips section titles before validating, so Joi never
73
+ // sees one and the mandatory base does not apply.
74
+ if (element === ElementTypes.SectionTitle)
75
+ continue;
76
+ for (const branch of elementBranches) {
77
+ for (const key of JOI_MANDATORY_COLUMN_KEYS) {
78
+ expect(Object.keys(branch.properties))
79
+ .withContext(`${element} is missing Joi-required '${key}'`)
80
+ .toContain(key);
81
+ }
82
+ }
83
+ }
84
+ });
85
+ it('keeps element-specific properties out of other elements', () => {
86
+ const all = branches({ workflows: WORKFLOWS });
87
+ // The reason for discriminating at all. A flat schema would offer all of
88
+ // these on every element and the model would scatter them.
89
+ expect(Object.keys(only(all, ElementTypes.Toggle).properties)).not.toContain('fileType');
90
+ expect(Object.keys(only(all, ElementTypes.FileUpload).properties)).not.toContain('trueLabel');
91
+ expect(Object.keys(all[ElementTypes.Input][0].properties)).not.toContain('labelPosition');
92
+ expect(Object.keys(only(all, ElementTypes.SectionTitle).properties)).not.toContain('formControlName');
93
+ });
94
+ it('marks non-required properties as nullable and required ones as not', () => {
95
+ const toggle = only(branches(), ElementTypes.Toggle);
96
+ const nullable = (key) => JSON.stringify(toggle.properties[key]).includes('"null"');
97
+ expect(nullable('label')).toBe(false);
98
+ expect(nullable('formControlName')).toBe(false);
99
+ expect(nullable('trueLabel')).toBe(true);
100
+ });
101
+ describe('agreement with the validator', () => {
102
+ it('offers nothing the validator would reject as unknown', () => {
103
+ // Eleven members used to fail here, each one failing the WHOLE form with
104
+ // '"Slides > Columns > Placeholder" is not allowed'. They are declared in
105
+ // `baseFormColumnInputsSchema` now, so the set is empty — and this is the
106
+ // guard that keeps it empty when the next member is modelled.
107
+ expect([...JOI_UNKNOWN_COLUMN_KEYS]).toEqual([]);
108
+ });
109
+ it('does not mistake a member only the nested schema declares', () => {
110
+ // `formInputs` is appended by `formColumnInputsSchema`, not present on
111
+ // the base. Probing the base instead would report it unknown and quietly
112
+ // withdraw every item list — so this pins which schema is probed.
113
+ expect(JOI_UNKNOWN_COLUMN_KEYS.has('formInputs')).toBe(false);
114
+ const list = only(branches(), ElementTypes.MultipleInput);
115
+ expect(Object.keys(list.properties)).toContain('formInputs');
116
+ });
117
+ });
118
+ describe('presentation members', () => {
119
+ const offers = (element, key) => branches({ workflows: WORKFLOWS })[element].every((branch) => Object.keys(branch.properties).includes(key));
120
+ it('offers a placeholder only where text is actually typed', () => {
121
+ // The point of scoping through `properties` rather than offering these
122
+ // everywhere: a placeholder on a toggle or a date picker is meaningless,
123
+ // and a model handed the key will fill it in regardless.
124
+ expect(offers(ElementTypes.Input, 'placeholder')).toBe(true);
125
+ expect(offers(ElementTypes.Textarea, 'placeholder')).toBe(true);
126
+ expect(offers(ElementTypes.AutoCompleteInput, 'placeholder')).toBe(true);
127
+ expect(offers(ElementTypes.Toggle, 'placeholder')).toBe(false);
128
+ expect(offers(ElementTypes.DatePicker, 'placeholder')).toBe(false);
129
+ expect(offers(ElementTypes.FileUpload, 'placeholder')).toBe(false);
130
+ expect(offers(ElementTypes.SectionTitle, 'placeholder')).toBe(false);
131
+ });
132
+ it('offers affixes on the controls drawn as a form field', () => {
133
+ // `input` was the notable omission — a currency prefix is the canonical
134
+ // use of the member and the one element that could not have it.
135
+ for (const element of [
136
+ ElementTypes.Input,
137
+ ElementTypes.AutoCompleteInput,
138
+ ElementTypes.Textarea,
139
+ ElementTypes.Select,
140
+ ElementTypes.DatePicker,
141
+ ]) {
142
+ expect(offers(element, 'prefixText'))
143
+ .withContext(`${element} should offer prefixText`)
144
+ .toBe(true);
145
+ }
146
+ // Controls that draw their own UI have nowhere to put an affix.
147
+ expect(offers(ElementTypes.Signature, 'prefixText')).toBe(false);
148
+ expect(offers(ElementTypes.SectionTitle, 'prefixText')).toBe(false);
149
+ });
150
+ it('offers a selection cap only to the multi-select controls', () => {
151
+ expect(offers(ElementTypes.Select, 'selectUpto')).toBe(true);
152
+ expect(offers(ElementTypes.PaginatedSelectionTable, 'selectUpto')).toBe(true);
153
+ expect(offers(ElementTypes.WorkflowDocumentPicker, 'selectUpto')).toBe(true);
154
+ expect(offers(ElementTypes.Input, 'selectUpto')).toBe(false);
155
+ expect(offers(ElementTypes.Toggle, 'selectUpto')).toBe(false);
156
+ });
157
+ it('carries the rule that a cap needs multiple selection', () => {
158
+ // Skillet cannot make `selectUpto` conditional on another member's VALUE
159
+ // the way the calculated-field split makes it conditional on shape. The
160
+ // editor row states the rule, and it must survive into the description or
161
+ // a model will cap a single-selection control at three.
162
+ const description = s.getDescription(extraInputMemberSchemas[FormInputKeys.SelectUpto]);
163
+ expect(description).toContain('allowMultipleSelection is true');
164
+ });
165
+ it('offers row totals only to the item list', () => {
166
+ expect(offers(ElementTypes.MultipleInput, 'showTotalsOf')).toBe(true);
167
+ expect(offers(ElementTypes.Select, 'showTotalsOf')).toBe(false);
168
+ });
169
+ it('offers disabled everywhere a value is captured, and nowhere else', () => {
170
+ // A section title renders no control, so there is nothing to disable.
171
+ // Everything that captures a value can be.
172
+ const all = branches({ workflows: WORKFLOWS });
173
+ for (const element of Object.keys(all)) {
174
+ expect(offers(element, 'disabled'))
175
+ .withContext(`${element} disabled`)
176
+ .toBe(element !== ElementTypes.SectionTitle);
177
+ }
178
+ });
179
+ it('does not tell the model to autofill with a token it cannot use', () => {
180
+ // `AutocompleteOptions` is on/off only. The description used to suggest
181
+ // 'email' or 'tel' — values the enumeration rejects, so the instruction
182
+ // could not be obeyed.
183
+ const description = s.getDescription(extraInputMemberSchemas[FormInputKeys.Autocomplete]);
184
+ expect(description).not.toContain('email');
185
+ expect(description).not.toContain('tel ');
186
+ });
187
+ });
188
+ // --- the calculated-field coupling ----------------------------------------
189
+ describe('calculated fields', () => {
190
+ it('splits only the elements that can actually be numeric', () => {
191
+ const all = branches({ workflows: WORKFLOWS });
192
+ // `input` and `autoCompleteInput` let an author set both `type` and
193
+ // `dataType`, so either can be made numeric. `datePicker` offers `type`
194
+ // but pins `dataType` to date, and must not be split.
195
+ expect(all[ElementTypes.Input].length).toBe(2);
196
+ expect(all[ElementTypes.AutoCompleteInput].length).toBe(2);
197
+ expect(all[ElementTypes.DatePicker].length).toBe(1);
198
+ expect(all[ElementTypes.Toggle].length).toBe(1);
199
+ });
200
+ it('makes the rules unstateable without the calculation, and vice versa', () => {
201
+ const [plain, calculated] = branches()[ElementTypes.Input];
202
+ // The whole point: a plain field has nowhere to put rules, and a
203
+ // calculated one cannot omit them.
204
+ expect(Object.keys(plain.properties)).not.toContain('calculatedFieldRules');
205
+ expect(Object.keys(calculated.properties)).toContain('calculatedFieldRules');
206
+ expect(calculated.required).toContain('calculatedFieldRules');
207
+ });
208
+ it('pins a calculated field to number on both type and dataType', () => {
209
+ const [, calculated] = branches()[ElementTypes.Input];
210
+ expect(calculated.properties['type'].const).toBe('number');
211
+ expect(calculated.properties['dataType'].const).toBe('number');
212
+ });
213
+ it('never points the model at a member it cannot see', () => {
214
+ // The editor's applicability text for this row reads "Only applies when
215
+ // isCalculatedField is true". Composed in, it would name a member the
216
+ // schema deliberately omits — and a dangling reference is exactly how a
217
+ // model talks itself into inventing a key.
218
+ const json = JSON.stringify(s.toJsonSchema(createColumnSchema({ elements: [ElementTypes.Input] })));
219
+ expect(json).not.toContain('isCalculatedField');
220
+ });
221
+ it('does not ask the model for isCalculatedField at all', () => {
222
+ // It is derivable from whether the rules are present, and a value a model
223
+ // supplies is a value it can contradict. Skillet also has no boolean
224
+ // literal to pin it with. The application sets it on expansion.
225
+ for (const branch of branches()[ElementTypes.Input]) {
226
+ expect(Object.keys(branch.properties)).not.toContain('isCalculatedField');
227
+ }
228
+ });
229
+ it('binds calculation variables by control name, never by id', () => {
230
+ const json = columnJson();
231
+ const [, calculated] = branches()[ElementTypes.Input];
232
+ const rules = JSON.stringify(deref(json, calculated.properties['calculatedFieldRules']));
233
+ expect(rules).toContain('formControlName');
234
+ // `inputId` is stamped by the application. Asking a model for it yields a
235
+ // plausible string pointing at nothing, and a formula that silently
236
+ // computes nothing.
237
+ expect(rules).not.toContain('inputId');
238
+ });
239
+ });
240
+ // --- nested item lists ----------------------------------------------------
241
+ describe('item lists', () => {
242
+ it('gives a list its contents', () => {
243
+ const list = only(branches(), ElementTypes.MultipleInput);
244
+ expect(Object.keys(list.properties)).toContain('formInputs');
245
+ // `formInputs` is one of the element's `requiredProperties`; a list with
246
+ // no contents is not a list.
247
+ expect(list.required).toContain('formInputs');
248
+ });
249
+ it('withdraws the list entirely once nesting is exhausted', () => {
250
+ // The element cannot be offered without its required contents, so at
251
+ // depth 0 it goes rather than being emitted incomplete.
252
+ const shallow = branches({ depth: 0 });
253
+ expect(Object.keys(shallow)).not.toContain(ElementTypes.MultipleInput);
254
+ });
255
+ it('does not put a section heading inside a list', () => {
256
+ // `validateForm()` strips section titles from a SLIDE, not from
257
+ // `formInputs`, which Joi validates through `Joi.link` against the full
258
+ // column schema — so a nested heading would be required to carry a
259
+ // formControlName it is deliberately built without.
260
+ const list = only(branches(), ElementTypes.MultipleInput);
261
+ const nested = JSON.stringify(list.properties['formInputs']);
262
+ expect(nested).toContain(ElementTypes.Input);
263
+ expect(nested).not.toContain(`"const":"${ElementTypes.SectionTitle}"`);
264
+ });
265
+ it('stops recursing rather than nesting forever', () => {
266
+ // Skillet has no recursive node, so the nesting is unrolled to a fixed
267
+ // depth. One level deep, the list's contents must contain no further list.
268
+ const list = only(branches(), ElementTypes.MultipleInput);
269
+ const nested = JSON.stringify(list.properties['formInputs']);
270
+ expect(nested).not.toContain(`"const":"${ElementTypes.MultipleInput}"`);
271
+ });
272
+ });
273
+ // --- end to end -----------------------------------------------------------
274
+ it('produces a draft that passes validateForm once expanded', () => {
275
+ // The contract this whole module exists to satisfy: what a model emits,
276
+ // put through the expansion the application owes it, must satisfy the
277
+ // existing validator. This covers the two hardest shapes — a calculated
278
+ // field and a nested item list — alongside both validator branches.
279
+ const draft = {
280
+ formTitle: 'Supplier registration',
281
+ slides: [
282
+ {
283
+ label: 'Company details',
284
+ columns: [
285
+ {
286
+ element: 'sectionTitle',
287
+ label: 'Company details',
288
+ appearance: 'outline',
289
+ hintLabel: null,
290
+ },
291
+ {
292
+ element: 'input',
293
+ label: 'Company name',
294
+ formControlName: 'companyName',
295
+ appearance: 'outline',
296
+ required: true,
297
+ dataType: 'string',
298
+ type: 'text',
299
+ colSize: 12,
300
+ hintLabel: null,
301
+ matOptions: null,
302
+ pattern: null,
303
+ validators: [
304
+ 'email',
305
+ {
306
+ message: 'Company name must differ from the trading name.',
307
+ expression: 'companyName !== tradingName',
308
+ canOverride: true,
309
+ inputsObservedForChanges: [
310
+ {
311
+ formControlName: 'tradingName',
312
+ variable: 'tradingName',
313
+ parentInputFormControl: null,
314
+ function: null,
315
+ },
316
+ ],
317
+ },
318
+ ],
319
+ },
320
+ {
321
+ element: 'multipleInput',
322
+ label: 'Directors',
323
+ formControlName: 'directors',
324
+ appearance: 'outline',
325
+ handleLabel: 'Add director',
326
+ colSize: 12,
327
+ dataType: 'array',
328
+ required: true,
329
+ formInputs: [
330
+ {
331
+ element: 'input',
332
+ label: 'Director name',
333
+ formControlName: 'directorName',
334
+ appearance: 'outline',
335
+ required: true,
336
+ dataType: 'string',
337
+ type: 'text',
338
+ colSize: 6,
339
+ },
340
+ {
341
+ element: 'input',
342
+ label: 'Shareholding',
343
+ formControlName: 'shareholding',
344
+ appearance: 'outline',
345
+ required: true,
346
+ dataType: 'number',
347
+ type: 'number',
348
+ colSize: 6,
349
+ },
350
+ ],
351
+ },
352
+ {
353
+ element: 'input',
354
+ label: 'Total shareholding',
355
+ formControlName: 'totalShareholding',
356
+ appearance: 'outline',
357
+ required: true,
358
+ type: 'number',
359
+ dataType: 'number',
360
+ colSize: 6,
361
+ calculatedFieldRules: {
362
+ formula: 'total',
363
+ variables: [
364
+ {
365
+ bindingType: 'listAggregate',
366
+ variable: 'total',
367
+ label: 'Total shareholding',
368
+ formControlName: 'shareholding',
369
+ parentInputFormControl: 'directors',
370
+ function: 'sum',
371
+ applyFunctionToCol: 'shareholding',
372
+ applyFunctionToLabel: null,
373
+ filterValuesByCol: null,
374
+ filterValuesByThisColLabel: null,
375
+ tableCell: null,
376
+ },
377
+ ],
378
+ decimalPlaces: 2,
379
+ roundingMode: 'ROUND',
380
+ getFormulaFromAFormInput: null,
381
+ formControlWithFormula: null,
382
+ },
383
+ },
384
+ ],
385
+ },
386
+ ],
387
+ };
388
+ /**
389
+ * The expansion the application owes a draft, written out so the test
390
+ * exercises the real obligation rather than a convenient subset of it.
391
+ */
392
+ let seq = 0;
393
+ const nextId = () => `id-${++seq}`;
394
+ const stripNulls = (value) => {
395
+ if (Array.isArray(value))
396
+ return value.map(stripNulls);
397
+ if (value && typeof value === 'object') {
398
+ return Object.fromEntries(Object.entries(value)
399
+ .filter(([, v]) => v !== null)
400
+ .map(([k, v]) => [k, stripNulls(v)]));
401
+ }
402
+ return value;
403
+ };
404
+ const expandValidators = (validators) => (validators ?? []).map((entry) => typeof entry === 'string'
405
+ ? entry
406
+ : {
407
+ ...entry,
408
+ id: nextId(),
409
+ inputsObservedForChanges: (entry
410
+ .inputsObservedForChanges ?? []).map((observed) => {
411
+ const { formControlName, parentInputFormControl, ...rest } = observed;
412
+ return {
413
+ ...rest,
414
+ inputId: `input-${String(formControlName)}`,
415
+ ...(parentInputFormControl
416
+ ? { parentInputId: `input-${String(parentInputFormControl)}` }
417
+ : {}),
418
+ };
419
+ }),
420
+ });
421
+ const expandRules = (rules) => rules && {
422
+ ...rules,
423
+ variables: rules['variables'].map(({ bindingType: _dropped, ...variable }) => ({
424
+ ...variable,
425
+ id: nextId(),
426
+ inputId: `input-${String(variable['formControlName'])}`,
427
+ })),
428
+ };
429
+ const expandColumn = (column, sectionId) => {
430
+ const rules = expandRules(column['calculatedFieldRules']);
431
+ return {
432
+ ...column,
433
+ id: nextId(),
434
+ sectionId,
435
+ // Derived, never authored — see `calculatedFieldSplit` in variants.ts.
436
+ isCalculatedField: rules != null,
437
+ onlySetTempErrorOnTouch: false,
438
+ validators: expandValidators(column['validators']),
439
+ ...(rules ? { calculatedFieldRules: rules } : {}),
440
+ ...(column['formInputs']
441
+ ? {
442
+ formInputs: column['formInputs'].map((nested) => expandColumn(nested, sectionId)),
443
+ }
444
+ : {}),
445
+ };
446
+ };
447
+ const cleaned = stripNulls(draft);
448
+ const expanded = {
449
+ formTitle: cleaned.formTitle,
450
+ slides: cleaned.slides.map((slide, i) => ({
451
+ sectionId: `sec-${i}`,
452
+ label: slide.label,
453
+ columns: slide.columns.map((column) => expandColumn(column, `sec-${i}`)),
454
+ })),
455
+ submissionHandle: {
456
+ submissionMessage: 'Registration received.',
457
+ submissionAPI: [
458
+ {
459
+ _id: '65f0a1',
460
+ name: 'Register supplier',
461
+ httpEndPoint: '/api/suppliers',
462
+ httpMethod: 'POST',
463
+ postFormData: true,
464
+ source: 'api',
465
+ backEndConfig: { minimumInputRequired: [] },
466
+ },
467
+ ],
468
+ },
469
+ };
470
+ expect(validateForm(expanded)).toEqual([]);
471
+ });
472
+ it('lets every branch produce a column the validator accepts', () => {
473
+ // The broadest guarantee available, and the one that generalises: rather
474
+ // than hand-writing a form and checking it, this walks EVERY branch of the
475
+ // union, builds a column populating EVERY member that branch offers,
476
+ // applies exactly the expansion `DRAFT_COMPLETION_REQUIRED` declares, and
477
+ // validates it.
478
+ //
479
+ // Populating every member, not just the required ones, is what makes this
480
+ // catch the class of bug that motivated it: `placeholder` was offered by
481
+ // three elements and rejected outright by the validator, and a column built
482
+ // from required keys alone would never have carried one. Any member the
483
+ // schema offers is a member some generated form will contain.
484
+ //
485
+ // So it proves three things at once — that no element can be asked for
486
+ // something it cannot produce validly, that every offered member is
487
+ // acceptable to the validator, and that the declared completion list is
488
+ // SUFFICIENT rather than merely descriptive. A new element, or a member
489
+ // that quietly starts needing expansion, fails here without anyone
490
+ // remembering to write a case for it.
491
+ const json = columnJson({
492
+ workflows: WORKFLOWS,
493
+ apiEndpoints: [{ id: 'ep-1', name: 'Departments' }],
494
+ });
495
+ /** A value a node accepts, filling every member it declares. */
496
+ const generate = (node, key) => {
497
+ const n = deref(json, node);
498
+ if (n.anyOf) {
499
+ const branch = n.anyOf.find((b) => deref(json, b)?.type !== 'null') ?? n.anyOf[0];
500
+ return generate(branch, key);
501
+ }
502
+ if (n.const !== undefined)
503
+ return n.const;
504
+ if (n.enum)
505
+ return n.enum[0];
506
+ switch (n.type) {
507
+ case 'string':
508
+ return key === 'formControlName' ? 'aField' : 'x';
509
+ case 'number':
510
+ case 'integer':
511
+ return n.minimum ?? 1;
512
+ case 'boolean':
513
+ return true;
514
+ case 'null':
515
+ return null;
516
+ case 'array':
517
+ return [generate(n.items, key)];
518
+ case 'object': {
519
+ const out = {};
520
+ for (const k of Object.keys(n.properties ?? {})) {
521
+ out[k] = generate(n.properties[k], k);
522
+ }
523
+ return out;
524
+ }
525
+ default:
526
+ return 'x';
527
+ }
528
+ };
529
+ /** Drops the nulls an unset member arrives as; Joi rejects them. */
530
+ const stripNulls = (value) => {
531
+ if (Array.isArray(value))
532
+ return value.map(stripNulls);
533
+ if (value && typeof value === 'object') {
534
+ return Object.fromEntries(Object.entries(value)
535
+ .filter(([, v]) => v !== null)
536
+ .map(([k, v]) => [k, stripNulls(v)]));
537
+ }
538
+ return value;
539
+ };
540
+ let seq = 0;
541
+ const nextId = () => `x${++seq}`;
542
+ const stamp = (rows) => (rows ?? []).map((row) => ({ ...row, id: nextId() }));
543
+ const expand = (input) => {
544
+ const out = {
545
+ ...input,
546
+ id: nextId(),
547
+ sectionId: 's1',
548
+ isCalculatedField: input['calculatedFieldRules'] != null,
549
+ onlySetTempErrorOnTouch: false,
550
+ validators: input['validators'] ?? [],
551
+ };
552
+ if (out['calculatedFieldRules']) {
553
+ out['calculatedFieldRules'] = {
554
+ ...out['calculatedFieldRules'],
555
+ variables: out['calculatedFieldRules'].variables.map(({ bindingType: _drop, ...variable }) => ({
556
+ ...variable,
557
+ id: nextId(),
558
+ inputId: `i-${String(variable['formControlName'])}`,
559
+ })),
560
+ };
561
+ }
562
+ out['validators'] = out['validators'].map((entry) => typeof entry === 'string'
563
+ ? entry
564
+ : {
565
+ ...entry,
566
+ id: nextId(),
567
+ inputsObservedForChanges: (entry
568
+ .inputsObservedForChanges ?? []).map(({ formControlName, parentInputFormControl: _p, ...rest }) => ({
569
+ ...rest,
570
+ inputId: `i-${String(formControlName)}`,
571
+ })),
572
+ });
573
+ if (out['mscoaConfig']) {
574
+ out['mscoaConfig'] = {
575
+ ...out['mscoaConfig'],
576
+ inputs: [],
577
+ segments: stamp(out['mscoaConfig'].segments),
578
+ ...(out['mscoaConfig'].cashSegments
579
+ ? { cashSegments: stamp(out['mscoaConfig'].cashSegments) }
580
+ : {}),
581
+ };
582
+ }
583
+ if (out['paginationSelectionConfig']) {
584
+ out['paginationSelectionConfig'] = {
585
+ ...out['paginationSelectionConfig'],
586
+ columns: stamp(out['paginationSelectionConfig'].columns),
587
+ };
588
+ }
589
+ // `CALCULATE_LIST_DRAFT_COMPLETION`: the roll-up names the column it
590
+ // totals by `formControlName`, which is NOT a member of
591
+ // `IMultipleInputCal` — the application stamps an `id`, resolves the name
592
+ // to the aggregated column's `inputId`, and drops the name.
593
+ if (out['calculateListFunctions']) {
594
+ out['calculateListFunctions'] = out['calculateListFunctions'].map(({ formControlName, ...rest }) => ({
595
+ ...rest,
596
+ id: nextId(),
597
+ inputId: `i-${String(formControlName)}`,
598
+ }));
599
+ }
600
+ // `endpointId` and `workflowId` are draft-only and are expanded away.
601
+ if (out['matOptions']?.fetch) {
602
+ const shed = (side) => {
603
+ if (!side)
604
+ return side;
605
+ const { endpointId: _e, workflowId: _w, ...rest } = side;
606
+ return rest;
607
+ };
608
+ out['matOptions'] = {
609
+ ...out['matOptions'],
610
+ fetch: {
611
+ value: shed(out['matOptions'].fetch.value),
612
+ options: shed(out['matOptions'].fetch.options),
613
+ },
614
+ };
615
+ }
616
+ if (out['formInputs']) {
617
+ out['formInputs'] = out['formInputs'].map(expand);
618
+ }
619
+ return out;
620
+ };
621
+ const failures = json.anyOf
622
+ .map((branch) => ({
623
+ element: branch.properties.element.const,
624
+ errors: validateFormColumnInputs(expand(stripNulls(generate(branch)))),
625
+ }))
626
+ .filter((result) => result.errors.length);
627
+ expect(failures).toEqual([]);
628
+ });
629
+ it('shrinks materially when narrowed to the common elements', () => {
630
+ const full = JSON.stringify(s.toJsonSchema(createColumnSchema())).length;
631
+ const common = JSON.stringify(s.toJsonSchema(createColumnSchema({ elements: COMMON_ELEMENTS }))).length;
632
+ expect(common).toBeLessThan(full);
633
+ });
634
+ it('never offers the deprecated submit-button flag', () => {
635
+ const json = JSON.stringify(s.toJsonSchema(createFormDraftSchema({ elements: COMMON_ELEMENTS })));
636
+ expect(json).not.toContain('innerComponentShowSubmitButton');
637
+ });
638
+ it('offers no submission endpoint when none were supplied', () => {
639
+ const json = JSON.stringify(s.toJsonSchema(createFormDraftSchema({ elements: COMMON_ELEMENTS })));
640
+ expect(json).toContain('Leave empty');
641
+ expect(json).not.toContain('httpEndPoint');
642
+ });
643
+ });