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.
- package/dist/interfaces/Form/formSubmissionHandleInterface.d.ts +6 -0
- package/dist/interfaces/FormBuilder/DefaultEelement.js +76 -0
- package/dist/interfaces/FormBuilder/DefaultInputConfigInterface.d.ts +13 -0
- package/dist/interfaces/FormBuilder/FormInputKeys.d.ts +6 -0
- package/dist/interfaces/FormBuilder/FormInputKeys.js +6 -0
- package/dist/interfaces/FormBuilder/inputConfig/ElementEditConfig.js +188 -3
- package/dist/interfaces/Import/ImportIdentity.d.ts +32 -0
- package/dist/interfaces/Import/ImportIdentity.js +1 -0
- package/dist/interfaces/Import/ImportProgress.d.ts +12 -1
- package/dist/interfaces/Import/ImportRowState.d.ts +52 -1
- package/dist/interfaces/Import/index.d.ts +2 -1
- package/dist/interfaces/formInput/APIDataFetchingConfigurationInterface.d.ts +34 -0
- package/dist/interfaces/formInput/BasicFormInputInterface.d.ts +1 -0
- package/dist/interfaces/formInput/BasicFormInputInterface.js +1 -0
- package/dist/interfaces/formInput/IMscoaAccount.d.ts +19 -3
- package/dist/interfaces/formInput/ISelectInputInterface.d.ts +12 -0
- package/dist/interfaces/formInput/MultipleInterface.d.ts +10 -0
- package/dist/interfaces/formInput/WorkflowDocumentPicker.d.ts +2 -0
- package/dist/schemas/FormInputSchema.js +41 -2
- package/dist/schemas/MatOptionsSchema.js +7 -0
- package/dist/schemas/MscoaConfigSchema.js +1 -0
- package/dist/schemas/index.d.ts +2 -1
- package/dist/schemas/index.js +2 -1
- package/dist/skillet/authoring-rules.d.ts +41 -0
- package/dist/skillet/authoring-rules.js +61 -0
- package/dist/skillet/extra-members.d.ts +143 -0
- package/dist/skillet/extra-members.js +79 -0
- package/dist/skillet/form.d.ts +170 -0
- package/dist/skillet/form.js +139 -0
- package/dist/skillet/index.d.ts +43 -0
- package/dist/skillet/index.js +46 -0
- package/dist/skillet/input-members.d.ts +375 -0
- package/dist/skillet/input-members.js +186 -0
- package/dist/skillet/internal/coupling.d.ts +63 -0
- package/dist/skillet/internal/coupling.js +69 -0
- package/dist/skillet/internal/editor-guidance.d.ts +64 -0
- package/dist/skillet/internal/editor-guidance.js +291 -0
- package/dist/skillet/members/calculated-field.d.ts +155 -0
- package/dist/skillet/members/calculated-field.js +105 -0
- package/dist/skillet/members/conditional.d.ts +69 -0
- package/dist/skillet/members/conditional.js +16 -0
- package/dist/skillet/members/document-picker.d.ts +182 -0
- package/dist/skillet/members/document-picker.js +111 -0
- package/dist/skillet/members/mat-options.d.ts +187 -0
- package/dist/skillet/members/mat-options.js +92 -0
- package/dist/skillet/members/mscoa.d.ts +182 -0
- package/dist/skillet/members/mscoa.js +129 -0
- package/dist/skillet/members/pagination.d.ts +59 -0
- package/dist/skillet/members/pagination.js +16 -0
- package/dist/skillet/members/table.d.ts +178 -0
- package/dist/skillet/members/table.js +105 -0
- package/dist/skillet/members/validators.d.ts +197 -0
- package/dist/skillet/members/validators.js +74 -0
- package/dist/skillet/members/value.d.ts +154 -0
- package/dist/skillet/members/value.js +97 -0
- package/dist/skillet/shared-types.d.ts +57 -0
- package/dist/skillet/shared-types.js +69 -0
- package/dist/skillet/tests/house-rules.spec.d.ts +1 -0
- package/dist/skillet/tests/house-rules.spec.js +162 -0
- package/dist/skillet/tests/skillet-coupling.spec.d.ts +1 -0
- package/dist/skillet/tests/skillet-coupling.spec.js +191 -0
- package/dist/skillet/tests/variant-assembly.spec.d.ts +1 -0
- package/dist/skillet/tests/variant-assembly.spec.js +643 -0
- package/dist/skillet/variants.d.ts +138 -0
- package/dist/skillet/variants.js +536 -0
- package/dist/skillet/workflow-context.d.ts +95 -0
- package/dist/skillet/workflow-context.js +128 -0
- 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
|
+
});
|