@capillarytech/creatives-library 9.0.13 → 9.0.14-beta.0

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 (46) hide show
  1. package/constants/unified.js +3 -0
  2. package/package.json +1 -1
  3. package/utils/common.js +8 -0
  4. package/v2Components/FormBuilder/Classic.js +4487 -0
  5. package/v2Components/FormBuilder/Functional/FormBuilderShell.js +371 -0
  6. package/v2Components/FormBuilder/Functional/channels/registry.js +17 -0
  7. package/v2Components/FormBuilder/Functional/channels/sms/buildSubmitPayload.js +9 -0
  8. package/v2Components/FormBuilder/Functional/channels/sms/config.js +30 -0
  9. package/v2Components/FormBuilder/Functional/channels/sms/getEditorErrorDescriptor.js +46 -0
  10. package/v2Components/FormBuilder/Functional/channels/sms/getLiquidContent.js +13 -0
  11. package/v2Components/FormBuilder/Functional/channels/sms/index.js +22 -0
  12. package/v2Components/FormBuilder/Functional/channels/sms/tests/getEditorErrorDescriptor.test.js +52 -0
  13. package/v2Components/FormBuilder/Functional/channels/sms/tests/getLiquidContent.test.js +25 -0
  14. package/v2Components/FormBuilder/Functional/channels/sms/tests/validate.test.js +87 -0
  15. package/v2Components/FormBuilder/Functional/channels/sms/validate.js +89 -0
  16. package/v2Components/FormBuilder/Functional/constants.js +42 -0
  17. package/v2Components/FormBuilder/Functional/core/schema/fieldRegistry.js +38 -0
  18. package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +85 -0
  19. package/v2Components/FormBuilder/Functional/core/store/formReducer.js +81 -0
  20. package/v2Components/FormBuilder/Functional/core/store/selectors.js +30 -0
  21. package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +91 -0
  22. package/v2Components/FormBuilder/Functional/index.js +26 -0
  23. package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +59 -0
  24. package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +31 -0
  25. package/v2Components/FormBuilder/Functional/layout/Section.js +116 -0
  26. package/v2Components/FormBuilder/Functional/renderers/smsRenderers.js +258 -0
  27. package/v2Components/FormBuilder/Functional/tests/channelRegistry.test.js +21 -0
  28. package/v2Components/FormBuilder/Functional/tests/fieldRegistry.test.js +65 -0
  29. package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +97 -0
  30. package/v2Components/FormBuilder/Functional/tests/fixtures/smsParityCases.js +192 -0
  31. package/v2Components/FormBuilder/Functional/tests/formReducer.test.js +129 -0
  32. package/v2Components/FormBuilder/Functional/tests/initializeFormState.test.js +132 -0
  33. package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +40 -0
  34. package/v2Components/FormBuilder/Functional/tests/section.test.js +99 -0
  35. package/v2Components/FormBuilder/Functional/tests/selectors.test.js +67 -0
  36. package/v2Components/FormBuilder/Functional/tests/sms.crossFlowParity.test.js +155 -0
  37. package/v2Components/FormBuilder/Functional/tests/sms.liquid.test.js +172 -0
  38. package/v2Components/FormBuilder/Functional/tests/sms.rollout.test.js +122 -0
  39. package/v2Components/FormBuilder/Functional/tests/sms.shell.parity.test.js +329 -0
  40. package/v2Components/FormBuilder/Functional/tests/smsRenderers.test.js +160 -0
  41. package/v2Components/FormBuilder/Functional/tests/toLegacyFormData.test.js +95 -0
  42. package/v2Components/FormBuilder/_formBuilder.scss +5 -0
  43. package/v2Components/FormBuilder/index.js +41 -4479
  44. package/v2Components/FormBuilder/tests/__snapshots__/sms.characterization.test.js.snap +114 -0
  45. package/v2Components/FormBuilder/tests/entryGate.test.js +106 -0
  46. package/v2Components/FormBuilder/tests/sms.characterization.test.js +336 -0
@@ -0,0 +1,192 @@
1
+ /**
2
+ * SMS cross-flow parity fixtures.
3
+ *
4
+ * A single source of truth describing, for a range of SMS content combinations,
5
+ * the result the FormBuilder must produce — validity, per-field error type, and
6
+ * the legacy formData payload the container hands to getChannelData(SMS) on save.
7
+ *
8
+ * The same array is fed to BOTH the legacy (Classic) and the new (Functional)
9
+ * FormBuilder in sms.crossFlowParity.test.js, in BOTH full mode and library mode.
10
+ * Each case asserts Classic ≡ Functional ≡ `expected`, so any divergence between
11
+ * the two flows (or from the documented contract) fails the suite.
12
+ *
13
+ * Field-error vocabulary (legacy errorData values, identical in both flows):
14
+ * editorError -> errorData[0]['sms-editor']:
15
+ * false | 'genericValidationError' | 'tagBracketCountMismatchError'
16
+ * unicodeError -> errorData[0]['unicode-validity']: boolean
17
+ * nameError -> errorData['template-name']: boolean
18
+ *
19
+ * Payload = getChannelData('SMS', formData) = `${base['sms-editor']} ${template-name}`.
20
+ * (Note the documented quirk: it is a template literal, so empty parts render as
21
+ * the literal text — e.g. an empty name yields a trailing space, not "".)
22
+ *
23
+ * `modes` lists the modes a case runs in. `embedded: true` sets query.type
24
+ * 'embedded' (the engine then drops the template-name requirement, mirroring the
25
+ * container's removeStandAlone trim).
26
+ */
27
+
28
+ export const SMS_PARITY_CASES = [
29
+ {
30
+ id: 'plain-ascii',
31
+ description: 'Plain GSM-7 content + name → valid, submits',
32
+ content: 'Hello world',
33
+ name: 'Promo',
34
+ allowUnicode: false,
35
+ modes: ['full', 'library'],
36
+ expected: {
37
+ isValid: true,
38
+ editorError: false,
39
+ unicodeError: false,
40
+ nameError: false,
41
+ submits: true,
42
+ payload: 'Hello world Promo',
43
+ },
44
+ },
45
+ {
46
+ id: 'gsm-extended-symbols',
47
+ description: 'GSM-7 extended symbols (£, @) are NOT unicode → valid without the checkbox',
48
+ content: 'Cost £5 @home',
49
+ name: 'Promo',
50
+ allowUnicode: false,
51
+ modes: ['full', 'library'],
52
+ expected: {
53
+ isValid: true,
54
+ editorError: false,
55
+ unicodeError: false,
56
+ nameError: false,
57
+ submits: true,
58
+ payload: 'Cost £5 @home Promo',
59
+ },
60
+ },
61
+ {
62
+ id: 'leading-trailing-spaces',
63
+ description: 'Spacing in content is preserved verbatim in the payload',
64
+ content: ' hi ',
65
+ name: 'Promo',
66
+ allowUnicode: false,
67
+ modes: ['full', 'library'],
68
+ expected: {
69
+ isValid: true,
70
+ editorError: false,
71
+ unicodeError: false,
72
+ nameError: false,
73
+ submits: true,
74
+ payload: ' hi Promo',
75
+ },
76
+ },
77
+ {
78
+ id: 'long-multisegment',
79
+ description: 'Long (multi-segment) content does not block save; payload preserved',
80
+ content: 'A'.repeat(200),
81
+ name: 'Bulk',
82
+ allowUnicode: false,
83
+ modes: ['full', 'library'],
84
+ expected: {
85
+ isValid: true,
86
+ editorError: false,
87
+ unicodeError: false,
88
+ nameError: false,
89
+ submits: true,
90
+ payload: `${'A'.repeat(200)} Bulk`,
91
+ },
92
+ },
93
+ {
94
+ id: 'empty-content',
95
+ description: 'Empty content → generic editor error, blocks save',
96
+ content: '',
97
+ name: 'Promo',
98
+ allowUnicode: false,
99
+ modes: ['full', 'library'],
100
+ expected: {
101
+ isValid: false,
102
+ editorError: 'genericValidationError',
103
+ unicodeError: false,
104
+ nameError: false,
105
+ submits: false,
106
+ payload: null,
107
+ },
108
+ },
109
+ {
110
+ id: 'empty-name-full',
111
+ description: 'Empty name (non-embedded) → template-name error, blocks save',
112
+ content: 'Hello',
113
+ name: '',
114
+ allowUnicode: false,
115
+ modes: ['full', 'library'],
116
+ expected: {
117
+ isValid: false,
118
+ editorError: false,
119
+ unicodeError: false,
120
+ nameError: true,
121
+ submits: false,
122
+ payload: null,
123
+ },
124
+ },
125
+ {
126
+ id: 'empty-name-embedded',
127
+ description: 'Empty name in embedded mode → template-name NOT required, submits',
128
+ content: 'Hello',
129
+ name: '',
130
+ allowUnicode: false,
131
+ embedded: true,
132
+ modes: ['full', 'library'],
133
+ expected: {
134
+ isValid: true,
135
+ editorError: false,
136
+ unicodeError: false,
137
+ nameError: false,
138
+ submits: true,
139
+ payload: 'Hello ',
140
+ },
141
+ },
142
+ {
143
+ id: 'unicode-unchecked',
144
+ description: 'Unicode content with the checkbox OFF → unicode error, blocks save',
145
+ content: 'नमस्ते',
146
+ name: 'Diwali',
147
+ allowUnicode: false,
148
+ modes: ['full', 'library'],
149
+ expected: {
150
+ isValid: false,
151
+ editorError: false,
152
+ unicodeError: true,
153
+ nameError: false,
154
+ submits: false,
155
+ payload: null,
156
+ },
157
+ },
158
+ {
159
+ id: 'unicode-checked',
160
+ description: 'Unicode content with the checkbox ON → valid, submits',
161
+ content: 'नमस्ते',
162
+ name: 'Diwali',
163
+ allowUnicode: true,
164
+ modes: ['full', 'library'],
165
+ expected: {
166
+ isValid: true,
167
+ editorError: false,
168
+ unicodeError: false,
169
+ nameError: false,
170
+ submits: true,
171
+ payload: 'नमस्ते Diwali',
172
+ },
173
+ },
174
+ {
175
+ id: 'unbalanced-braces',
176
+ description: 'Unbalanced {{ → bracket error, blocks save (the "close all curly braces" case)',
177
+ content: 'Hi {{name',
178
+ name: 'Promo',
179
+ allowUnicode: false,
180
+ modes: ['full', 'library'],
181
+ expected: {
182
+ isValid: false,
183
+ editorError: 'tagBracketCountMismatchError',
184
+ unicodeError: false,
185
+ nameError: false,
186
+ submits: false,
187
+ payload: null,
188
+ },
189
+ },
190
+ ];
191
+
192
+ export default SMS_PARITY_CASES;
@@ -0,0 +1,129 @@
1
+ /**
2
+ * Unit tests for the V3 form reducer + selectors.
3
+ * Pins: action behavior, structural sharing (untouched slices keep identity),
4
+ * and no-op identity preservation.
5
+ */
6
+
7
+ import formReducer, {
8
+ createInitialState,
9
+ hydrate,
10
+ fieldChanged,
11
+ validationResult,
12
+ } from '../core/store/formReducer';
13
+ import {
14
+ selectFieldValue,
15
+ selectFieldError,
16
+ selectIsFormValid,
17
+ selectBaseTabIndex,
18
+ } from '../core/store/selectors';
19
+
20
+ const seededState = () => ({
21
+ ...createInitialState('SMS'),
22
+ root: { 'template-name': '' },
23
+ tabs: [{ fields: { 'sms-editor': '', 'unicode-validity': false }, base: true, tabKey: 'k0' }],
24
+ errors: { root: {}, tabs: [{}] },
25
+ });
26
+
27
+ describe('formReducer', () => {
28
+ it('returns the same state for an unknown action', () => {
29
+ const s = seededState();
30
+ expect(formReducer(s, { type: 'nope' })).toBe(s);
31
+ });
32
+
33
+ it('HYDRATE replaces state', () => {
34
+ const next = seededState();
35
+ expect(formReducer(createInitialState('SMS'), hydrate(next))).toBe(next);
36
+ });
37
+
38
+ it('HYDRATE with a falsy payload keeps the current state', () => {
39
+ const s = seededState();
40
+ expect(formReducer(s, { type: 'fb/HYDRATE', state: null })).toBe(s);
41
+ });
42
+
43
+ it('createInitialState defaults the channel to null', () => {
44
+ expect(createInitialState().meta.channel).toBeNull();
45
+ });
46
+
47
+ describe('FIELD_CHANGED — tab field', () => {
48
+ it('updates the targeted tab field', () => {
49
+ const s = seededState();
50
+ const next = formReducer(s, fieldChanged({ fieldId: 'sms-editor', value: 'hi', tabIndex: 0 }));
51
+ expect(selectFieldValue(next, { fieldId: 'sms-editor', tabIndex: 0 })).toBe('hi');
52
+ });
53
+
54
+ it('replaces the changed tab object but keeps root identity (structural sharing)', () => {
55
+ const s = seededState();
56
+ const next = formReducer(s, fieldChanged({ fieldId: 'sms-editor', value: 'hi', tabIndex: 0 }));
57
+ expect(next).not.toBe(s);
58
+ expect(next.tabs[0]).not.toBe(s.tabs[0]); // changed slice gets a new ref
59
+ expect(next.root).toBe(s.root); // untouched slice keeps identity
60
+ });
61
+
62
+ it('is a no-op (same ref) when the value is unchanged', () => {
63
+ const s = seededState();
64
+ const next = formReducer(s, fieldChanged({ fieldId: 'sms-editor', value: '', tabIndex: 0 }));
65
+ expect(next).toBe(s);
66
+ });
67
+
68
+ it('ignores an out-of-range tabIndex', () => {
69
+ const s = seededState();
70
+ expect(formReducer(s, fieldChanged({ fieldId: 'x', value: 'y', tabIndex: 5 }))).toBe(s);
71
+ });
72
+
73
+ it('keeps sibling tabs by reference when one tab changes (structural sharing)', () => {
74
+ const s = { ...seededState(), tabs: [{ fields: { a: '' } }, { fields: { b: '' } }] };
75
+ const next = formReducer(s, fieldChanged({ fieldId: 'a', value: 'A', tabIndex: 0 }));
76
+ expect(next.tabs[0]).not.toBe(s.tabs[0]); // changed tab -> new ref
77
+ expect(next.tabs[1]).toBe(s.tabs[1]); // sibling tab -> same ref
78
+ });
79
+ });
80
+
81
+ describe('FIELD_CHANGED — root (standalone) field', () => {
82
+ it('updates a root field and keeps tabs identity', () => {
83
+ const s = seededState();
84
+ const next = formReducer(s, fieldChanged({ fieldId: 'template-name', value: 'My SMS' }));
85
+ expect(selectFieldValue(next, { fieldId: 'template-name' })).toBe('My SMS');
86
+ expect(next.tabs).toBe(s.tabs); // untouched
87
+ expect(next.root).not.toBe(s.root);
88
+ });
89
+
90
+ it('is a no-op (same ref) when the root value is unchanged', () => {
91
+ const s = seededState();
92
+ const next = formReducer(s, fieldChanged({ fieldId: 'template-name', value: '' }));
93
+ expect(next).toBe(s);
94
+ });
95
+ });
96
+
97
+ describe('VALIDATION_RESULT', () => {
98
+ it('merges errors + validity', () => {
99
+ const s = seededState();
100
+ const errors = { root: { 'template-name': true }, tabs: [{ 'sms-editor': 'bad' }] };
101
+ const next = formReducer(s, validationResult({ errors, isFormValid: false }));
102
+ expect(selectIsFormValid(next)).toBe(false);
103
+ expect(selectFieldError(next, { fieldId: 'template-name' })).toBe(true);
104
+ expect(selectFieldError(next, { fieldId: 'sms-editor', tabIndex: 0 })).toBe('bad');
105
+ });
106
+
107
+ it('coerces isFormValid to boolean', () => {
108
+ const s = seededState();
109
+ const next = formReducer(s, validationResult({ errors: s.errors, isFormValid: 1 }));
110
+ expect(selectIsFormValid(next)).toBe(true);
111
+ });
112
+
113
+ it('falls back to existing errors + liquidErrorMessage when omitted', () => {
114
+ const s = seededState();
115
+ const next = formReducer(s, { type: 'fb/VALIDATION_RESULT', isFormValid: true });
116
+ expect(next.errors).toBe(s.errors); // fallback to prior errors
117
+ expect(next.validity.liquidErrorMessage).toBe(s.validity.liquidErrorMessage);
118
+ });
119
+ });
120
+
121
+ describe('selectors', () => {
122
+ it('selectBaseTabIndex reads meta', () => {
123
+ expect(selectBaseTabIndex(seededState())).toBe(0);
124
+ });
125
+ it('selectFieldValue returns undefined for a missing tab', () => {
126
+ expect(selectFieldValue(seededState(), { fieldId: 'x', tabIndex: 9 })).toBeUndefined();
127
+ });
128
+ });
129
+ });
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Unit tests for initializeFormState, seeded from the REAL bundled SMS schema.
3
+ * Pins: standalone field -> root, data fields -> tab 0, onlyDisplay fields not
4
+ * seeded, correct defaults, single base tab with a tabKey.
5
+ */
6
+
7
+ import initializeFormState from '../core/schema/initializeFormState';
8
+ import { response as smsSchemaResponse } from '../../../../v2Containers/Sms/initialSchema';
9
+
10
+ const smsDefinition = smsSchemaResponse.metaEntities[0].definition;
11
+
12
+ describe('initializeFormState — SMS', () => {
13
+ const state = initializeFormState(smsDefinition, { generateId: () => 'k0' });
14
+
15
+ it('records the channel', () => {
16
+ expect(state.meta.channel).toBe('SMS');
17
+ });
18
+
19
+ it('creates exactly one base tab with a tabKey', () => {
20
+ expect(state.tabs).toHaveLength(1);
21
+ expect(state.tabs[0].base).toBe(true);
22
+ expect(state.tabs[0].tabKey).toBe('k0');
23
+ expect(state.meta.baseTabIndex).toBe(0);
24
+ });
25
+
26
+ it('seeds the standalone field (template-name) into root, not into the tab', () => {
27
+ expect(state.root['template-name']).toBe('');
28
+ expect(state.tabs[0].fields['template-name']).toBeUndefined();
29
+ });
30
+
31
+ it('seeds tab data fields with correct defaults', () => {
32
+ expect(state.tabs[0].fields['sms-editor']).toBe(''); // textarea -> ''
33
+ expect(state.tabs[0].fields['unicode-validity']).toBe(false); // checkbox -> false
34
+ });
35
+
36
+ it('does NOT seed onlyDisplay / UI-control fields', () => {
37
+ const allSeeded = { ...state.root, ...state.tabs[0].fields };
38
+ ['tagList', 'sms-count', 'sms-preview', 'save-button', 'test-and-preview-button'].forEach((id) => {
39
+ expect(allSeeded[id]).toBeUndefined();
40
+ });
41
+ });
42
+
43
+ it('produces parallel empty error buckets', () => {
44
+ expect(state.errors).toEqual({ root: {}, tabs: [{}] });
45
+ expect(state.validity.isFormValid).toBe(false);
46
+ });
47
+
48
+ it('uses an injectable id generator (deterministic in tests)', () => {
49
+ const s = initializeFormState(smsDefinition, { generateId: () => 'XYZ' });
50
+ expect(s.tabs[0].tabKey).toBe('XYZ');
51
+ });
52
+
53
+ it('uses the default id generator when none is injected', () => {
54
+ const s = initializeFormState(smsDefinition);
55
+ expect(typeof s.tabs[0].tabKey).toBe('string');
56
+ expect(s.tabs[0].tabKey).toMatch(/^fb-\d+$/);
57
+ });
58
+ });
59
+
60
+ // A synthetic schema exercising the traversal branches the bundled SMS schema
61
+ // does not contain: col-label sections, actionFields, an unknown section type,
62
+ // a null section, missing child arrays, and a field carrying an explicit value.
63
+ describe('initializeFormState — traversal branch coverage', () => {
64
+ const synthetic = {
65
+ channel: 'SMS',
66
+ standalone: {
67
+ sections: [
68
+ null, // null-section guard
69
+ { type: 'unknownType' }, // switch default
70
+ { type: 'parent' }, // parent with no childSections (|| [])
71
+ {
72
+ type: 'parent',
73
+ childSections: [
74
+ {
75
+ type: 'multicols',
76
+ inputFields: [
77
+ { cols: [{ id: 'name', type: 'input', standalone: true, value: 'seed' }] },
78
+ { rowClassName: 'row-without-cols' },
79
+ ],
80
+ actionFields: [{ cols: [{ id: 'save', type: 'button', onlyDisplay: true }] }],
81
+ },
82
+ {
83
+ type: 'col-label',
84
+ inputFields: [{ id: 'chk', type: 'checkbox' }, { id: 'body', type: 'textarea' }],
85
+ actionFields: [{ id: 'lbl', type: 'div', onlyDisplay: true }],
86
+ },
87
+ { type: 'multicols' }, // multicols with no input/action fields
88
+ ],
89
+ },
90
+ ],
91
+ },
92
+ };
93
+
94
+ const state = initializeFormState(synthetic, { generateId: () => 'k0' });
95
+
96
+ it('seeds a standalone field carrying an explicit value into root', () => {
97
+ expect(state.root.name).toBe('seed');
98
+ });
99
+
100
+ it('seeds col-label data fields into tab 0 with correct defaults', () => {
101
+ expect(state.tabs[0].fields.chk).toBe(false); // checkbox default
102
+ expect(state.tabs[0].fields.body).toBe(''); // textarea default
103
+ });
104
+
105
+ it('skips onlyDisplay fields (button / div label)', () => {
106
+ const seeded = { ...state.root, ...state.tabs[0].fields };
107
+ expect(seeded.save).toBeUndefined();
108
+ expect(seeded.lbl).toBeUndefined();
109
+ });
110
+
111
+ it('returns a valid state for an empty / missing schema', () => {
112
+ expect(initializeFormState().tabs).toHaveLength(1);
113
+ expect(initializeFormState({}).root).toEqual({});
114
+ });
115
+
116
+ it('does not overwrite an already-seeded field id (first occurrence wins)', () => {
117
+ const schema = {
118
+ channel: 'SMS',
119
+ standalone: {
120
+ sections: [{
121
+ type: 'col-label',
122
+ inputFields: [
123
+ { id: 'dup', type: 'input', value: 'first' },
124
+ { id: 'dup', type: 'input', value: 'second' },
125
+ ],
126
+ }],
127
+ },
128
+ };
129
+ const s = initializeFormState(schema, { generateId: () => 'k0' });
130
+ expect(s.tabs[0].fields.dup).toBe('first');
131
+ });
132
+ });
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Unit tests for SchemaForm — the standalone-sections walker + its guards.
3
+ */
4
+ import React from 'react';
5
+ import { render } from '@testing-library/react';
6
+ import SchemaForm from '../layout/SchemaForm';
7
+
8
+ const Stub = () => <div data-testid="stub">r</div>;
9
+ const registry = { resolve: () => Stub };
10
+ const renderContext = {
11
+ registry,
12
+ state: { root: {}, tabs: [{ fields: {} }] },
13
+ errorData: {},
14
+ activeTabIndex: 0,
15
+ onFieldChange: () => {},
16
+ onFieldBlur: () => {},
17
+ onEvent: () => {},
18
+ };
19
+
20
+ describe('SchemaForm', () => {
21
+ it('renders nothing when schema is missing or has no standalone', () => {
22
+ const { container: c1 } = render(<SchemaForm schema={null} renderContext={renderContext} />);
23
+ expect(c1.textContent).toBe('');
24
+ const { container: c2 } = render(<SchemaForm schema={{}} renderContext={renderContext} />);
25
+ expect(c2.textContent).toBe('');
26
+ });
27
+
28
+ it('walks standalone.sections and renders each section', () => {
29
+ const schema = {
30
+ standalone: {
31
+ sections: [
32
+ { type: 'col-label', inputFields: [{ id: 'a', type: 'div' }] },
33
+ { type: 'col-label', inputFields: [{ id: 'b', type: 'div' }] },
34
+ ],
35
+ },
36
+ };
37
+ const { getAllByTestId } = render(<SchemaForm schema={schema} renderContext={renderContext} />);
38
+ expect(getAllByTestId('stub').length).toBe(2);
39
+ });
40
+ });
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Unit tests for Section + the exported renderField helper. Covers the three
3
+ * structural section types, the memoized vs direct render paths, the handler
4
+ * closures on the direct path, and the unknown-type / unknown-renderer guards.
5
+ */
6
+ import React from 'react';
7
+ import { render } from '@testing-library/react';
8
+ import Section, { renderField } from '../layout/Section';
9
+
10
+ const Stub = () => <div data-testid="stub">r</div>;
11
+ // `none` resolves to no renderer; everything else resolves to the stub.
12
+ const registry = { resolve: (type) => (type === 'none' ? null : Stub) };
13
+
14
+ const ctx = (over = {}) => ({
15
+ registry,
16
+ state: { root: { name: 'v' }, tabs: [{ fields: { body: 'b' } }] },
17
+ errorData: { name: true, 0: { body: 'err' } },
18
+ activeTabIndex: 0,
19
+ checkValidation: true,
20
+ channel: 'SMS',
21
+ resolvedFieldErrors: { body: 'msg' },
22
+ onFieldChange: jest.fn(),
23
+ onFieldBlur: jest.fn(),
24
+ onEvent: jest.fn(),
25
+ ...over,
26
+ });
27
+
28
+ describe('renderField', () => {
29
+ it('returns null for a field without a type or a null field', () => {
30
+ expect(renderField(null, ctx())).toBeNull();
31
+ expect(renderField({ id: 'x' }, ctx())).toBeNull();
32
+ });
33
+
34
+ it('returns null when the registry has no renderer for the type', () => {
35
+ expect(renderField({ id: 'x', type: 'none' }, ctx())).toBeNull();
36
+ });
37
+
38
+ it('memoized type (input) renders through FieldSlot', () => {
39
+ const el = renderField({ id: 'name', type: 'input', standalone: true }, ctx());
40
+ expect(el).toBeTruthy();
41
+ expect(el.key).toBe('name');
42
+ });
43
+
44
+ it('direct (derived) type wires onChange/onBlur/onEvent to the context handlers', () => {
45
+ const renderContext = ctx();
46
+ const field = { id: 'body', type: 'div' }; // not in MEMOIZED_TYPES -> direct path, tab field
47
+ const el = renderField(field, renderContext);
48
+ el.props.onChange('x');
49
+ el.props.onBlur();
50
+ el.props.onEvent('evt', { a: 1 });
51
+ expect(renderContext.onFieldChange).toHaveBeenCalledWith(field, 'x');
52
+ expect(renderContext.onFieldBlur).toHaveBeenCalledWith(field);
53
+ expect(renderContext.onEvent).toHaveBeenCalledWith(field, 'evt', { a: 1 });
54
+ });
55
+
56
+ it('reads the root error for a standalone field and the tab error otherwise', () => {
57
+ const standalone = renderField({ id: 'name', type: 'div', standalone: true }, ctx());
58
+ expect(standalone.props.error).toBe(true); // errorData.name
59
+ const tabField = renderField({ id: 'body', type: 'div' }, ctx());
60
+ expect(tabField.props.error).toBe('err'); // errorData[0].body
61
+ });
62
+
63
+ it('error is undefined when the context has no errorData', () => {
64
+ const el = renderField({ id: 'body', type: 'div' }, ctx({ errorData: undefined }));
65
+ expect(el.props.error).toBeUndefined();
66
+ });
67
+ });
68
+
69
+ describe('Section', () => {
70
+ it('renders nothing for a null section or an unknown type', () => {
71
+ const { container: c1 } = render(<Section section={null} renderContext={ctx()} />);
72
+ expect(c1.textContent).toBe('');
73
+ const { container: c2 } = render(<Section section={{ type: 'mystery' }} renderContext={ctx()} />);
74
+ expect(c2.textContent).toBe('');
75
+ });
76
+
77
+ it('renders a parent -> multicols (inputFields + actionFields)', () => {
78
+ const section = {
79
+ type: 'parent',
80
+ childSections: [{
81
+ type: 'multicols',
82
+ inputFields: [{ cols: [{ id: 'body', type: 'div' }] }],
83
+ actionFields: [{ cols: [{ id: 'save', type: 'div' }] }],
84
+ }],
85
+ };
86
+ const { getAllByTestId } = render(<Section section={section} renderContext={ctx()} />);
87
+ expect(getAllByTestId('stub').length).toBe(2);
88
+ });
89
+
90
+ it('renders a col-label section (inputFields + actionFields)', () => {
91
+ const section = {
92
+ type: 'col-label',
93
+ inputFields: [{ id: 'body', type: 'div' }],
94
+ actionFields: [{ id: 'save', type: 'div' }],
95
+ };
96
+ const { getAllByTestId } = render(<Section section={section} renderContext={ctx()} />);
97
+ expect(getAllByTestId('stub').length).toBe(2);
98
+ });
99
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Unit tests for the state selectors — covers root vs tab paths and the
3
+ * defensive branches (undefined state / missing tab / missing errors).
4
+ */
5
+ import {
6
+ selectFieldValue,
7
+ selectFieldError,
8
+ selectIsFormValid,
9
+ selectLiquidErrorMessage,
10
+ selectBaseTabIndex,
11
+ selectChannel,
12
+ } from '../core/store/selectors';
13
+
14
+ const state = {
15
+ meta: { channel: 'SMS', baseTabIndex: 0 },
16
+ root: { 'template-name': 'My SMS' },
17
+ tabs: [{ fields: { 'sms-editor': 'hi' } }],
18
+ errors: { root: { 'template-name': true }, tabs: [{ 'sms-editor': 'genericValidationError' }] },
19
+ validity: { isFormValid: true, liquidErrorMessage: { STANDARD_ERROR_MSG: ['x'], LIQUID_ERROR_MSG: [] } },
20
+ };
21
+
22
+ describe('selectFieldValue', () => {
23
+ it('returns a root field value when tabIndex is null', () => {
24
+ expect(selectFieldValue(state, { fieldId: 'template-name', tabIndex: null })).toBe('My SMS');
25
+ });
26
+
27
+ it('returns a tab field value when tabIndex is set', () => {
28
+ expect(selectFieldValue(state, { fieldId: 'sms-editor', tabIndex: 0 })).toBe('hi');
29
+ });
30
+
31
+ it('returns undefined for a missing tab', () => {
32
+ expect(selectFieldValue(state, { fieldId: 'sms-editor', tabIndex: 5 })).toBeUndefined();
33
+ });
34
+
35
+ it('is defensive when state is undefined', () => {
36
+ expect(selectFieldValue(undefined, { fieldId: 'sms-editor', tabIndex: 0 })).toBeUndefined();
37
+ expect(selectFieldValue(undefined, { fieldId: 'template-name', tabIndex: null })).toBeUndefined();
38
+ });
39
+ });
40
+
41
+ describe('selectFieldError', () => {
42
+ it('returns a root error when tabIndex is null', () => {
43
+ expect(selectFieldError(state, { fieldId: 'template-name', tabIndex: null })).toBe(true);
44
+ });
45
+
46
+ it('returns a tab error when tabIndex is set', () => {
47
+ expect(selectFieldError(state, { fieldId: 'sms-editor', tabIndex: 0 })).toBe('genericValidationError');
48
+ });
49
+
50
+ it('returns undefined for a missing tab', () => {
51
+ expect(selectFieldError(state, { fieldId: 'sms-editor', tabIndex: 9 })).toBeUndefined();
52
+ });
53
+
54
+ it('is defensive when state/errors are undefined', () => {
55
+ expect(selectFieldError(undefined, { fieldId: 'sms-editor', tabIndex: 0 })).toBeUndefined();
56
+ expect(selectFieldError({}, { fieldId: 'template-name', tabIndex: null })).toBeUndefined();
57
+ });
58
+ });
59
+
60
+ describe('scalar selectors', () => {
61
+ it('selectIsFormValid / selectLiquidErrorMessage / selectBaseTabIndex / selectChannel', () => {
62
+ expect(selectIsFormValid(state)).toBe(true);
63
+ expect(selectLiquidErrorMessage(state).STANDARD_ERROR_MSG).toEqual(['x']);
64
+ expect(selectBaseTabIndex(state)).toBe(0);
65
+ expect(selectChannel(state)).toBe('SMS');
66
+ });
67
+ });