@capillarytech/creatives-library 9.0.12 → 9.0.13-alpha.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.
- package/constants/unified.js +3 -0
- package/package.json +1 -1
- package/utils/common.js +8 -0
- package/v2Components/FormBuilder/Classic.js +4487 -0
- package/v2Components/FormBuilder/Functional/FormBuilderShell.js +369 -0
- package/v2Components/FormBuilder/Functional/channels/registry.js +17 -0
- package/v2Components/FormBuilder/Functional/channels/sms/buildSubmitPayload.js +9 -0
- package/v2Components/FormBuilder/Functional/channels/sms/config.js +30 -0
- package/v2Components/FormBuilder/Functional/channels/sms/getEditorErrorDescriptor.js +46 -0
- package/v2Components/FormBuilder/Functional/channels/sms/getLiquidContent.js +13 -0
- package/v2Components/FormBuilder/Functional/channels/sms/index.js +22 -0
- package/v2Components/FormBuilder/Functional/channels/sms/tests/getEditorErrorDescriptor.test.js +52 -0
- package/v2Components/FormBuilder/Functional/channels/sms/tests/getLiquidContent.test.js +25 -0
- package/v2Components/FormBuilder/Functional/channels/sms/tests/validate.test.js +87 -0
- package/v2Components/FormBuilder/Functional/channels/sms/validate.js +89 -0
- package/v2Components/FormBuilder/Functional/constants.js +39 -0
- package/v2Components/FormBuilder/Functional/core/schema/fieldRegistry.js +38 -0
- package/v2Components/FormBuilder/Functional/core/schema/initializeFormState.js +85 -0
- package/v2Components/FormBuilder/Functional/core/store/formReducer.js +81 -0
- package/v2Components/FormBuilder/Functional/core/store/selectors.js +30 -0
- package/v2Components/FormBuilder/Functional/core/store/toLegacyFormData.js +91 -0
- package/v2Components/FormBuilder/Functional/index.js +26 -0
- package/v2Components/FormBuilder/Functional/layout/FieldSlot.js +59 -0
- package/v2Components/FormBuilder/Functional/layout/SchemaForm.js +32 -0
- package/v2Components/FormBuilder/Functional/layout/Section.js +118 -0
- package/v2Components/FormBuilder/Functional/renderers/smsRenderers.js +265 -0
- package/v2Components/FormBuilder/Functional/tests/channelRegistry.test.js +21 -0
- package/v2Components/FormBuilder/Functional/tests/fieldRegistry.test.js +65 -0
- package/v2Components/FormBuilder/Functional/tests/fieldSlot.test.js +97 -0
- package/v2Components/FormBuilder/Functional/tests/fixtures/smsParityCases.js +192 -0
- package/v2Components/FormBuilder/Functional/tests/formReducer.test.js +129 -0
- package/v2Components/FormBuilder/Functional/tests/initializeFormState.test.js +132 -0
- package/v2Components/FormBuilder/Functional/tests/schemaForm.test.js +40 -0
- package/v2Components/FormBuilder/Functional/tests/section.test.js +99 -0
- package/v2Components/FormBuilder/Functional/tests/selectors.test.js +67 -0
- package/v2Components/FormBuilder/Functional/tests/sms.crossFlowParity.test.js +155 -0
- package/v2Components/FormBuilder/Functional/tests/sms.liquid.test.js +172 -0
- package/v2Components/FormBuilder/Functional/tests/sms.rollout.test.js +122 -0
- package/v2Components/FormBuilder/Functional/tests/sms.shell.parity.test.js +329 -0
- package/v2Components/FormBuilder/Functional/tests/smsRenderers.test.js +162 -0
- package/v2Components/FormBuilder/Functional/tests/toLegacyFormData.test.js +95 -0
- package/v2Components/FormBuilder/_formBuilder.scss +8 -0
- package/v2Components/FormBuilder/index.js +41 -4479
- package/v2Components/FormBuilder/tests/__snapshots__/sms.characterization.test.js.snap +114 -0
- package/v2Components/FormBuilder/tests/entryGate.test.js +106 -0
- package/v2Components/FormBuilder/tests/sms.characterization.test.js +336 -0
- package/v2Components/TemplatePreview/coderabbits_comments +171 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SMS Shell PARITY tests — the same behavioral contracts the PR0 characterization
|
|
3
|
+
* suite pins against the Classic monolith, asserted here against the NEW functional
|
|
4
|
+
* Shell. These two suites together are the Stage A gate: the Shell must reproduce
|
|
5
|
+
* Classic's SMS behavior (render, onChange shape, validation, save flow, parent
|
|
6
|
+
* bridge, edit hydration) exactly.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import React from 'react';
|
|
10
|
+
import '@testing-library/jest-dom';
|
|
11
|
+
import _ from 'lodash';
|
|
12
|
+
import { render, screen, fireEvent, act } from '../../../../utils/test-utils';
|
|
13
|
+
import { response as smsSchemaResponse } from '../../../../v2Containers/Sms/initialSchema';
|
|
14
|
+
|
|
15
|
+
// TagList (rendered for the tag-list field) is an auth-guarded container; neutralize
|
|
16
|
+
// the route guard the same way the container tests do.
|
|
17
|
+
jest.mock('redux-auth-wrapper/history4/redirect', () => ({
|
|
18
|
+
connectedRouterRedirect: jest.fn(() => (Component) => Component),
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// eslint-disable-next-line import/first
|
|
22
|
+
import FunctionalFormBuilder from '../index';
|
|
23
|
+
|
|
24
|
+
// Attach capture handlers onto the schema nodes, exactly as the container's
|
|
25
|
+
// injectEvents step does (records args + `this` binding for the parent bridge).
|
|
26
|
+
const injectHandlers = (schema) => {
|
|
27
|
+
const registry = {};
|
|
28
|
+
const attach = (field) => {
|
|
29
|
+
if (!field || !field.id || !field.supportedEvents) return;
|
|
30
|
+
field.injectedEvents = {};
|
|
31
|
+
field.supportedEvents.forEach((event) => {
|
|
32
|
+
const record = { calls: [], contexts: [] };
|
|
33
|
+
registry[`${field.id}:${event}`] = record;
|
|
34
|
+
field.injectedEvents[event] = function capture(...args) {
|
|
35
|
+
record.calls.push(args);
|
|
36
|
+
record.contexts.push(this);
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
};
|
|
40
|
+
const walk = (section) => {
|
|
41
|
+
if (!section) return;
|
|
42
|
+
if (section.type === 'parent') (section.childSections || []).forEach(walk);
|
|
43
|
+
else if (section.type === 'multicols') {
|
|
44
|
+
(section.inputFields || []).forEach((r) => (r.cols || []).forEach(attach));
|
|
45
|
+
(section.actionFields || []).forEach((r) => (r.cols || []).forEach(attach));
|
|
46
|
+
} else if (section.type === 'col-label') {
|
|
47
|
+
(section.inputFields || []).forEach(attach);
|
|
48
|
+
(section.actionFields || []).forEach(attach);
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
((schema.standalone && schema.standalone.sections) || []).forEach(walk);
|
|
52
|
+
return registry;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const buildSmsSchema = () => {
|
|
56
|
+
const schema = _.cloneDeep(smsSchemaResponse.metaEntities[0].definition);
|
|
57
|
+
const registry = injectHandlers(schema);
|
|
58
|
+
return { schema, registry };
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
const buildProps = (overrides = {}) => ({
|
|
62
|
+
formData: {},
|
|
63
|
+
channel: 'SMS',
|
|
64
|
+
location: { pathname: '/sms/create', query: { type: false, module: 'default' } },
|
|
65
|
+
usingTabContainer: true,
|
|
66
|
+
isFullMode: true,
|
|
67
|
+
isEdit: false,
|
|
68
|
+
startValidation: false,
|
|
69
|
+
checkValidation: false,
|
|
70
|
+
currentTab: 1,
|
|
71
|
+
tabCount: 1,
|
|
72
|
+
tags: [],
|
|
73
|
+
injectedTags: {},
|
|
74
|
+
onChange: jest.fn(),
|
|
75
|
+
onSubmit: jest.fn(),
|
|
76
|
+
onFormValidityChange: jest.fn(),
|
|
77
|
+
stopValidation: jest.fn(),
|
|
78
|
+
...overrides,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
const renderShell = (props) => {
|
|
82
|
+
const utils = render(<FunctionalFormBuilder {...props} />);
|
|
83
|
+
return { ...utils, rerenderWith: (next) => utils.rerender(<FunctionalFormBuilder {...next} />) };
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const lastCall = (fn) => fn.mock.calls[fn.mock.calls.length - 1];
|
|
87
|
+
|
|
88
|
+
describe('SMS Shell parity — rendering', () => {
|
|
89
|
+
it('renders every active SMS field with the same labels/placeholders as Classic', () => {
|
|
90
|
+
const { schema } = buildSmsSchema();
|
|
91
|
+
renderShell(buildProps({ schema }));
|
|
92
|
+
expect(screen.getByPlaceholderText('Enter template name')).toBeInTheDocument();
|
|
93
|
+
expect(screen.getByPlaceholderText('Please input sms template content.')).toBeInTheDocument();
|
|
94
|
+
expect(screen.getByLabelText('Allow Unicode characters')).toBeInTheDocument();
|
|
95
|
+
expect(screen.getByText('Save')).toBeInTheDocument();
|
|
96
|
+
expect(screen.getByText('Preview and Test')).toBeInTheDocument();
|
|
97
|
+
});
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
describe('SMS Shell parity — mount validity', () => {
|
|
101
|
+
it('emits onFormValidityChange(false, errorData) with template-name flagged at ROOT and sms-editor under tab 0', () => {
|
|
102
|
+
const { schema } = buildSmsSchema();
|
|
103
|
+
const props = buildProps({ schema });
|
|
104
|
+
renderShell(props);
|
|
105
|
+
expect(props.onFormValidityChange).toHaveBeenCalled();
|
|
106
|
+
const [isValid, errorData] = lastCall(props.onFormValidityChange);
|
|
107
|
+
expect(isValid).toBe(false);
|
|
108
|
+
expect(errorData['template-name']).toBe(true);
|
|
109
|
+
expect(errorData[0]['sms-editor']).toBeTruthy();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('SMS Shell parity — onChange contract', () => {
|
|
114
|
+
it('typing in sms-editor writes formData[0] + mirrors base, emits onChange(formData, tabCount, currentTab, fieldSchema)', () => {
|
|
115
|
+
const { schema } = buildSmsSchema();
|
|
116
|
+
const props = buildProps({ schema });
|
|
117
|
+
renderShell(props);
|
|
118
|
+
|
|
119
|
+
fireEvent.change(screen.getByPlaceholderText('Please input sms template content.'), {
|
|
120
|
+
target: { value: 'hello world' },
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
expect(props.onChange).toHaveBeenCalled();
|
|
124
|
+
const [formData, tabCount, currentTab, fieldSchema] = lastCall(props.onChange);
|
|
125
|
+
expect(tabCount).toBe(1);
|
|
126
|
+
expect(currentTab).toBe(1);
|
|
127
|
+
expect(fieldSchema.id).toBe('sms-editor');
|
|
128
|
+
expect(formData[0]['sms-editor']).toBe('hello world');
|
|
129
|
+
expect(formData[0].base).toBe(true);
|
|
130
|
+
expect(formData.base['sms-editor']).toBe('hello world');
|
|
131
|
+
expect(formData[0]['unicode-validity']).toBe(false);
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('template-name is high-frequency: DOM updates immediately, onChange debounced (~300ms), written at ROOT', () => {
|
|
135
|
+
jest.useFakeTimers();
|
|
136
|
+
try {
|
|
137
|
+
const { schema } = buildSmsSchema();
|
|
138
|
+
const props = buildProps({ schema });
|
|
139
|
+
renderShell(props);
|
|
140
|
+
props.onChange.mockClear();
|
|
141
|
+
|
|
142
|
+
const nameInput = screen.getByPlaceholderText('Enter template name');
|
|
143
|
+
fireEvent.change(nameInput, { target: { value: 'My SMS' } });
|
|
144
|
+
|
|
145
|
+
expect(nameInput).toHaveValue('My SMS');
|
|
146
|
+
expect(props.onChange).not.toHaveBeenCalled();
|
|
147
|
+
|
|
148
|
+
act(() => {
|
|
149
|
+
jest.advanceTimersByTime(350);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
expect(props.onChange).toHaveBeenCalled();
|
|
153
|
+
expect(lastCall(props.onChange)[0]['template-name']).toBe('My SMS');
|
|
154
|
+
} finally {
|
|
155
|
+
jest.useRealTimers();
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
describe('SMS Shell parity — validation & save (startValidation rising edge)', () => {
|
|
161
|
+
const typeNameAndContent = (content) => {
|
|
162
|
+
jest.useFakeTimers();
|
|
163
|
+
fireEvent.change(screen.getByPlaceholderText('Enter template name'), { target: { value: 'My SMS' } });
|
|
164
|
+
act(() => {
|
|
165
|
+
jest.advanceTimersByTime(350);
|
|
166
|
+
});
|
|
167
|
+
jest.useRealTimers();
|
|
168
|
+
fireEvent.change(screen.getByPlaceholderText('Please input sms template content.'), { target: { value: content } });
|
|
169
|
+
};
|
|
170
|
+
|
|
171
|
+
it('unicode content + checkbox OFF blocks save: invalid, unicode error, stopValidation, no onSubmit', () => {
|
|
172
|
+
const { schema } = buildSmsSchema();
|
|
173
|
+
const props = buildProps({ schema });
|
|
174
|
+
const { rerenderWith } = renderShell(props);
|
|
175
|
+
|
|
176
|
+
typeNameAndContent('नमस्ते');
|
|
177
|
+
rerenderWith({ ...props, startValidation: true });
|
|
178
|
+
|
|
179
|
+
const [isValid, errorData] = lastCall(props.onFormValidityChange);
|
|
180
|
+
expect(isValid).toBe(false);
|
|
181
|
+
expect(errorData[0]['unicode-validity']).toBe(true);
|
|
182
|
+
expect(props.stopValidation).toHaveBeenCalled();
|
|
183
|
+
expect(props.onSubmit).not.toHaveBeenCalled();
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
it('unicode content + checkbox ON is valid and submits (isFullMode skips liquid)', () => {
|
|
187
|
+
const { schema } = buildSmsSchema();
|
|
188
|
+
const props = buildProps({ schema });
|
|
189
|
+
const { rerenderWith } = renderShell(props);
|
|
190
|
+
|
|
191
|
+
typeNameAndContent('नमस्ते');
|
|
192
|
+
fireEvent.click(screen.getByLabelText('Allow Unicode characters'));
|
|
193
|
+
rerenderWith({ ...props, startValidation: true });
|
|
194
|
+
|
|
195
|
+
const [isValid] = lastCall(props.onFormValidityChange);
|
|
196
|
+
expect(isValid).toBe(true);
|
|
197
|
+
expect(props.onSubmit).toHaveBeenCalledTimes(1);
|
|
198
|
+
const [submitted] = lastCall(props.onSubmit);
|
|
199
|
+
expect(submitted['template-name']).toBe('My SMS');
|
|
200
|
+
expect(submitted[0]['sms-editor']).toBe('नमस्ते');
|
|
201
|
+
expect(submitted[0]['unicode-validity']).toBe(true);
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it('plain ASCII content with a name is valid and submits', () => {
|
|
205
|
+
const { schema } = buildSmsSchema();
|
|
206
|
+
const props = buildProps({ schema });
|
|
207
|
+
const { rerenderWith } = renderShell(props);
|
|
208
|
+
|
|
209
|
+
typeNameAndContent('hello world');
|
|
210
|
+
rerenderWith({ ...props, startValidation: true });
|
|
211
|
+
|
|
212
|
+
expect(lastCall(props.onFormValidityChange)[0]).toBe(true);
|
|
213
|
+
expect(props.onSubmit).toHaveBeenCalledTimes(1);
|
|
214
|
+
expect(lastCall(props.onSubmit)[0][0]['sms-editor']).toBe('hello world');
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
describe('SMS Shell parity — error display gating (checkValidation)', () => {
|
|
219
|
+
it('does NOT show the template-name error on mount (create) — matches Classic', () => {
|
|
220
|
+
const { schema } = buildSmsSchema();
|
|
221
|
+
renderShell(buildProps({ schema }));
|
|
222
|
+
// invalid on mount, but the message must not be displayed until Save
|
|
223
|
+
expect(screen.queryByText('Template name cannot be empty.')).toBeNull();
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
it('does NOT show errors on edit-load of an existing template', () => {
|
|
227
|
+
const { schema } = buildSmsSchema();
|
|
228
|
+
const tab0 = { 'sms-editor': 'Body', 'unicode-validity': false, base: true, tabKey: 'k1' };
|
|
229
|
+
renderShell(buildProps({ schema, isEdit: true, formData: { 'template-name': 'Name', 0: tab0, base: tab0 } }));
|
|
230
|
+
expect(screen.queryByText('Template name cannot be empty.')).toBeNull();
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('reveals the template-name error after validation is triggered (Save)', () => {
|
|
234
|
+
const { schema } = buildSmsSchema();
|
|
235
|
+
const props = buildProps({ schema });
|
|
236
|
+
const { rerenderWith } = renderShell(props);
|
|
237
|
+
rerenderWith({ ...props, startValidation: true }); // empty name -> reveal error
|
|
238
|
+
expect(screen.getByText('Template name cannot be empty.')).toBeInTheDocument();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it('reveals the template-name error after Save and CLEARS it live as the user types a valid name (no blur needed)', () => {
|
|
242
|
+
const { schema } = buildSmsSchema();
|
|
243
|
+
const props = buildProps({ schema });
|
|
244
|
+
const { rerenderWith } = renderShell(props);
|
|
245
|
+
|
|
246
|
+
// Save with an empty name -> error revealed.
|
|
247
|
+
rerenderWith({ ...props, startValidation: true });
|
|
248
|
+
expect(screen.getByText('Template name cannot be empty.')).toBeInTheDocument();
|
|
249
|
+
|
|
250
|
+
// The container resets startValidation after the failed save (stopValidation).
|
|
251
|
+
rerenderWith({ ...props, startValidation: false });
|
|
252
|
+
|
|
253
|
+
// Typing a valid name re-validates while validation is active and clears the
|
|
254
|
+
// error immediately — no blur required. (The input has no antd suffix, so this
|
|
255
|
+
// toggle does not remount it; focus is retained in the browser.)
|
|
256
|
+
const nameInput = screen.getByPlaceholderText('Enter template name');
|
|
257
|
+
fireEvent.change(nameInput, { target: { value: 'My SMS' } });
|
|
258
|
+
expect(nameInput).toHaveValue('My SMS');
|
|
259
|
+
expect(screen.queryByText('Template name cannot be empty.')).toBeNull();
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
it('does not feed errorMessage into CapInput (no antd suffix -> no remount-on-toggle); border via status', () => {
|
|
263
|
+
const { schema } = buildSmsSchema();
|
|
264
|
+
const props = buildProps({ schema });
|
|
265
|
+
const { rerenderWith } = renderShell(props);
|
|
266
|
+
rerenderWith({ ...props, startValidation: true });
|
|
267
|
+
|
|
268
|
+
// The message is rendered in the cap-ui label error span, not as an input suffix.
|
|
269
|
+
const message = screen.getByText('Template name cannot be empty.');
|
|
270
|
+
expect(message).toHaveClass('component-with-label-error-message');
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('empty SMS body after Save shows the red border ONLY — no inline message (matches Classic)', () => {
|
|
274
|
+
const { schema } = buildSmsSchema();
|
|
275
|
+
const props = buildProps({ schema });
|
|
276
|
+
const { rerenderWith } = renderShell(props);
|
|
277
|
+
rerenderWith({ ...props, startValidation: true });
|
|
278
|
+
// template-name (a plain input) shows its inline error.
|
|
279
|
+
expect(screen.getByText('Template name cannot be empty.')).toBeInTheDocument();
|
|
280
|
+
// The SMS body shows the error BORDER only — Classic never shows a message for
|
|
281
|
+
// an empty/generic body (neither inline nor footer). The generic body text and
|
|
282
|
+
// the schema's raw string must NOT appear.
|
|
283
|
+
expect(screen.queryByText('Please check the message content for unsupported/missing tags')).toBeNull();
|
|
284
|
+
expect(screen.queryByText('Template content has unsupported/missing tags!')).toBeNull();
|
|
285
|
+
const textarea = screen.getByPlaceholderText('Please input sms template content.');
|
|
286
|
+
expect(textarea.closest('.error-form-builder')).not.toBeNull();
|
|
287
|
+
});
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
describe('SMS Shell parity — parent bridge', () => {
|
|
291
|
+
it('clicking Save invokes the injected saveFormData handler with `this`=parent and val.id as 2nd arg', () => {
|
|
292
|
+
const { schema, registry } = buildSmsSchema();
|
|
293
|
+
const parentSentinel = { iAmTheContainer: true };
|
|
294
|
+
renderShell(buildProps({ schema, parent: parentSentinel }));
|
|
295
|
+
|
|
296
|
+
fireEvent.click(screen.getByText('Save'));
|
|
297
|
+
|
|
298
|
+
const record = registry['save-button:saveFormData'];
|
|
299
|
+
expect(record.calls).toHaveLength(1);
|
|
300
|
+
expect(record.calls[0][1]).toBe('save-button');
|
|
301
|
+
expect(record.contexts[0]).toBe(parentSentinel);
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('blurring the template-name (a high-frequency field) re-validates — mirrors Classic handleFieldBlur', () => {
|
|
305
|
+
const { schema } = buildSmsSchema();
|
|
306
|
+
const props = buildProps({ schema });
|
|
307
|
+
renderShell(props);
|
|
308
|
+
const nameInput = screen.getByPlaceholderText('Enter template name');
|
|
309
|
+
fireEvent.change(nameInput, { target: { value: 'My SMS' } });
|
|
310
|
+
props.onFormValidityChange.mockClear();
|
|
311
|
+
fireEvent.blur(nameInput);
|
|
312
|
+
// onFieldBlur flushes + re-validates -> emits a fresh validity result
|
|
313
|
+
expect(props.onFormValidityChange).toHaveBeenCalled();
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
describe('SMS Shell parity — edit hydration', () => {
|
|
318
|
+
it('renders saved template data passed via the formData prop', () => {
|
|
319
|
+
const { schema } = buildSmsSchema();
|
|
320
|
+
const tab0 = { 'sms-editor': 'Existing body', 'unicode-validity': false, base: true, tabKey: 'k1' };
|
|
321
|
+
renderShell(buildProps({
|
|
322
|
+
schema,
|
|
323
|
+
isEdit: true,
|
|
324
|
+
formData: { 'template-name': 'Existing name', 0: tab0, base: tab0 },
|
|
325
|
+
}));
|
|
326
|
+
expect(screen.getByPlaceholderText('Enter template name')).toHaveValue('Existing name');
|
|
327
|
+
expect(screen.getByPlaceholderText('Please input sms template content.')).toHaveValue('Existing body');
|
|
328
|
+
});
|
|
329
|
+
});
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the SMS field renderers. Each renderer is invoked as a plain
|
|
3
|
+
* function and its returned element tree is inspected — this exercises the
|
|
4
|
+
* conditional branches (showError, senderId, embedded filter, submit-button
|
|
5
|
+
* guard, refs) without mounting the heavy UnifiedPreview / TagList subtrees.
|
|
6
|
+
*/
|
|
7
|
+
import {
|
|
8
|
+
InputField, TextAreaField, CheckboxField, ButtonField, TagListField, SmsPreviewField, DivField,
|
|
9
|
+
} from '../renderers/smsRenderers';
|
|
10
|
+
import { isAiContentBotDisabled } from '../../../../utils/common';
|
|
11
|
+
|
|
12
|
+
jest.mock('../../../../utils/common', () => ({ isAiContentBotDisabled: jest.fn(() => true) }));
|
|
13
|
+
|
|
14
|
+
// CapColumn children: either a single element or an array — normalize to an array.
|
|
15
|
+
const kids = (el) => {
|
|
16
|
+
const c = el.props.children;
|
|
17
|
+
return Array.isArray(c) ? c.filter(Boolean) : [c].filter(Boolean);
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
describe('InputField', () => {
|
|
21
|
+
const field = {
|
|
22
|
+
id: 'template-name', type: 'input', width: 18, errorMessage: 'Name required',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
it('shows the error span + status=error when validation triggered and error present', () => {
|
|
26
|
+
const el = InputField({
|
|
27
|
+
field, value: '', error: true, onChange: () => {}, onBlur: () => {}, renderContext: { checkValidation: true },
|
|
28
|
+
});
|
|
29
|
+
const [input, span] = kids(el);
|
|
30
|
+
expect(input.props.status).toBe('error');
|
|
31
|
+
expect(span.props.children).toBe('Name required');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('no error span when validation not triggered', () => {
|
|
35
|
+
const el = InputField({
|
|
36
|
+
field, value: 'x', error: true, onChange: () => {}, onBlur: () => {}, renderContext: { checkValidation: false },
|
|
37
|
+
});
|
|
38
|
+
const [input, span] = kids(el);
|
|
39
|
+
expect(input.props.status).toBeUndefined();
|
|
40
|
+
expect(span).toBeUndefined();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
describe('TextAreaField', () => {
|
|
45
|
+
const field = { id: 'sms-editor', type: 'textarea', width: 18 };
|
|
46
|
+
|
|
47
|
+
it('surfaces the resolved (brace) message inline when an error is present', () => {
|
|
48
|
+
const el = TextAreaField({
|
|
49
|
+
field, value: 'abc {{x', error: 'tagBracketCountMismatchError', resolvedError: 'Invalid label', onChange: () => {}, renderContext: { checkValidation: true, channel: 'SMS' },
|
|
50
|
+
});
|
|
51
|
+
const [textarea] = kids(el);
|
|
52
|
+
expect(textarea.props.errorMessage).toBe('Invalid label');
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('empty-content error only shows after validation is triggered', () => {
|
|
56
|
+
const noTrigger = TextAreaField({
|
|
57
|
+
field, value: '', error: 'genericValidationError', resolvedError: '', onChange: () => {}, renderContext: { checkValidation: false, channel: 'SMS' },
|
|
58
|
+
});
|
|
59
|
+
expect(kids(noTrigger)[0].props.className).toBe('');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('renders the AskAira content bot for SMS when AI content is enabled, and wires setText', () => {
|
|
63
|
+
isAiContentBotDisabled.mockReturnValueOnce(false);
|
|
64
|
+
const onChange = jest.fn();
|
|
65
|
+
const el = TextAreaField({
|
|
66
|
+
field, value: 'hi', error: false, resolvedError: '', onChange, renderContext: { checkValidation: false, channel: 'SMS' },
|
|
67
|
+
});
|
|
68
|
+
const askAira = kids(el).find((c) => c && c.props && typeof c.props.setText === 'function');
|
|
69
|
+
expect(askAira).toBeTruthy();
|
|
70
|
+
askAira.props.setText('generated');
|
|
71
|
+
expect(onChange).toHaveBeenCalledWith('generated');
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe('CheckboxField', () => {
|
|
76
|
+
const field = {
|
|
77
|
+
id: 'unicode-validity', type: 'checkbox', width: 18, errorMessage: 'Unicode detected',
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
it('passes errorMessage to the checkbox when validation triggered + error', () => {
|
|
81
|
+
const el = CheckboxField({
|
|
82
|
+
field, value: false, error: true, onChange: () => {}, renderContext: { checkValidation: true },
|
|
83
|
+
});
|
|
84
|
+
const [checkbox] = kids(el);
|
|
85
|
+
expect(checkbox.props.errorMessage).toBe('Unicode detected');
|
|
86
|
+
expect(checkbox.props.className).toBe('error');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('no errorMessage when not triggered', () => {
|
|
90
|
+
const el = CheckboxField({
|
|
91
|
+
field, value: true, error: false, onChange: () => {}, renderContext: { checkValidation: false },
|
|
92
|
+
});
|
|
93
|
+
expect(kids(el)[0].props.errorMessage).toBe('');
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
describe('ButtonField', () => {
|
|
98
|
+
it('renders a button for metaType=submit-button and wires onEvent', () => {
|
|
99
|
+
const onEvent = jest.fn();
|
|
100
|
+
const el = ButtonField({ field: { id: 'save', metaType: 'submit-button', submitAction: 'saveFormData', value: 'Save' }, onEvent });
|
|
101
|
+
const [button] = kids(el);
|
|
102
|
+
button.props.onClick({ foo: 1 });
|
|
103
|
+
expect(onEvent).toHaveBeenCalledWith('saveFormData', { foo: 1 });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('renders nothing for a non-submit button', () => {
|
|
107
|
+
expect(ButtonField({ field: { id: 'x', metaType: 'other' }, onEvent: () => {} })).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
describe('TagListField', () => {
|
|
112
|
+
it('disables module filter in embedded mode', () => {
|
|
113
|
+
const embedded = TagListField({ field: { id: 'tagList' }, onEvent: () => {}, renderContext: { location: { query: { type: 'embedded' } } } });
|
|
114
|
+
expect(kids(embedded)[0].props.moduleFilterEnabled).toBe(false);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('enables module filter outside embedded mode', () => {
|
|
118
|
+
const full = TagListField({ field: { id: 'tagList' }, onEvent: () => {}, renderContext: { location: { query: { type: 'full' } } } });
|
|
119
|
+
expect(kids(full)[0].props.moduleFilterEnabled).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('forwards a tag selection through onEvent', () => {
|
|
123
|
+
const onEvent = jest.fn();
|
|
124
|
+
const el = TagListField({ field: { id: 'tagList' }, onEvent, renderContext: {} });
|
|
125
|
+
kids(el)[0].props.onTagSelect({ tag: 'name' });
|
|
126
|
+
expect(onEvent).toHaveBeenCalledWith('onTagSelect', { tag: 'name' });
|
|
127
|
+
});
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
describe('SmsPreviewField', () => {
|
|
131
|
+
const field = { id: 'sms-preview', channel: 'SMS' };
|
|
132
|
+
|
|
133
|
+
it('uses the Unicode senderId when the unicode checkbox is on', () => {
|
|
134
|
+
const el = SmsPreviewField({ field, renderContext: { legacyFormData: { 0: { 'sms-editor': 'hi', 'unicode-validity': true } } } });
|
|
135
|
+
expect(kids(el)[0].props.senderId).toBe('Unicode');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('uses the ASCII senderId when off, and tolerates a missing legacyFormData', () => {
|
|
139
|
+
const el = SmsPreviewField({ field, renderContext: {} });
|
|
140
|
+
expect(kids(el)[0].props.senderId).toBe('ASCII');
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('falls back to the SMS channel when the field has none', () => {
|
|
144
|
+
const el = SmsPreviewField({ field: { id: 'sms-preview' }, renderContext: {} });
|
|
145
|
+
expect(kids(el)[0].props.channel).toBe('SMS');
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe('DivField', () => {
|
|
150
|
+
it('renders the derived text and forwards the ref for its id', () => {
|
|
151
|
+
const ref = { current: null };
|
|
152
|
+
const el = DivField({ field: { id: 'sms-count', width: 18 }, renderContext: { legacyFormData: { 0: { 'sms-count': '2 SMS' } }, refs: { 'sms-count': ref } } });
|
|
153
|
+
const [div] = kids(el);
|
|
154
|
+
expect(div.props.children).toBe('2 SMS');
|
|
155
|
+
expect(div.ref).toBe(ref); // React exposes ref on element.ref, not props.ref
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('falls back to field.value and tolerates missing refs', () => {
|
|
159
|
+
const el = DivField({ field: { id: 'x', value: 'fallback', width: 18 }, renderContext: {} });
|
|
160
|
+
expect(kids(el)[0].props.children).toBe('fallback');
|
|
161
|
+
});
|
|
162
|
+
});
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unit tests for the legacy-formData codec.
|
|
3
|
+
* The headline contract is the round-trip law: toLegacy(fromLegacy(x)) ≡ x.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { fromLegacy, toLegacy } from '../core/store/toLegacyFormData';
|
|
7
|
+
|
|
8
|
+
describe('toLegacyFormData codec', () => {
|
|
9
|
+
describe('round-trip law: toLegacy(fromLegacy(x)) deep-equals x', () => {
|
|
10
|
+
it('SMS single-tab shape', () => {
|
|
11
|
+
const tab0 = { 'sms-editor': 'hello', 'unicode-validity': false, base: true, tabKey: 'k0' };
|
|
12
|
+
const legacy = { 'template-name': 'My SMS', 0: tab0, base: tab0 };
|
|
13
|
+
expect(toLegacy(fromLegacy(legacy, { channel: 'SMS' }))).toEqual(legacy);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('empty/new SMS shape (single base tab, blank fields)', () => {
|
|
17
|
+
const tab0 = { 'sms-editor': '', 'unicode-validity': false, base: true, tabKey: 'k0' };
|
|
18
|
+
const legacy = { 'template-name': '', 0: tab0, base: tab0 };
|
|
19
|
+
expect(toLegacy(fromLegacy(legacy))).toEqual(legacy);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('multi-tab shape with a named non-base tab (future channels)', () => {
|
|
23
|
+
const t0 = { 'sms-editor': 'a', base: true, tabKey: 'k0' };
|
|
24
|
+
const t1 = { 'sms-editor2': 'b', name: 'Version 2', tabKey: 'k1' };
|
|
25
|
+
const legacy = { 'template-name': 'n', 0: t0, 1: t1, base: t0 };
|
|
26
|
+
expect(toLegacy(fromLegacy(legacy))).toEqual(legacy);
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('base tab that is not index 0', () => {
|
|
30
|
+
const t0 = { x: 'a', tabKey: 'k0' };
|
|
31
|
+
const t1 = { x: 'b', base: true, tabKey: 'k1' };
|
|
32
|
+
const legacy = { 0: t0, 1: t1, base: t1 };
|
|
33
|
+
const round = toLegacy(fromLegacy(legacy));
|
|
34
|
+
expect(round).toEqual(legacy);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
describe('fromLegacy parsing', () => {
|
|
39
|
+
it('with no args returns an empty normalized state (default params)', () => {
|
|
40
|
+
const state = fromLegacy();
|
|
41
|
+
expect(state.root).toEqual({});
|
|
42
|
+
expect(state.tabs).toEqual([]);
|
|
43
|
+
expect(state.meta.channel).toBeNull();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('tolerates a numeric key whose value is missing', () => {
|
|
47
|
+
const state = fromLegacy({ 0: undefined });
|
|
48
|
+
expect(state.tabs).toHaveLength(1);
|
|
49
|
+
expect(state.tabs[0].fields).toEqual({});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('separates root (standalone) fields from numeric tabs and drops the alias', () => {
|
|
53
|
+
const tab0 = { 'sms-editor': 'hi', base: true, tabKey: 'k0' };
|
|
54
|
+
const state = fromLegacy({ 'template-name': 'X', 0: tab0, base: tab0 }, { channel: 'SMS' });
|
|
55
|
+
expect(state.meta.channel).toBe('SMS');
|
|
56
|
+
expect(state.root).toEqual({ 'template-name': 'X' });
|
|
57
|
+
expect(state.tabs).toHaveLength(1);
|
|
58
|
+
expect(state.tabs[0].fields).toEqual({ 'sms-editor': 'hi' });
|
|
59
|
+
expect(state.tabs[0].base).toBe(true);
|
|
60
|
+
expect(state.tabs[0].tabKey).toBe('k0');
|
|
61
|
+
expect(state.meta.baseTabIndex).toBe(0);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('computes baseTabIndex from the base flag', () => {
|
|
65
|
+
const state = fromLegacy({ 0: { x: 'a' }, 1: { x: 'b', base: true } });
|
|
66
|
+
expect(state.meta.baseTabIndex).toBe(1);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('defaults baseTabIndex to 0 when no tab is flagged base', () => {
|
|
70
|
+
const state = fromLegacy({ 0: { x: 'a' } });
|
|
71
|
+
expect(state.meta.baseTabIndex).toBe(0);
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('does not mutate the input tab objects', () => {
|
|
75
|
+
const tab0 = { 'sms-editor': 'hi', base: true };
|
|
76
|
+
fromLegacy({ 0: tab0, base: tab0 });
|
|
77
|
+
expect(tab0).toEqual({ 'sms-editor': 'hi', base: true });
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
describe('toLegacy serialization', () => {
|
|
82
|
+
it('makes formData.base the SAME reference as the base tab', () => {
|
|
83
|
+
const state = fromLegacy({ 'template-name': 'X', 0: { 'sms-editor': 'hi', base: true, tabKey: 'k0' } });
|
|
84
|
+
const legacy = toLegacy(state);
|
|
85
|
+
expect(legacy.base).toBe(legacy[0]); // alias identity, not a copy
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('omits the base key on non-base tabs (matches legacy)', () => {
|
|
89
|
+
const state = fromLegacy({ 0: { x: 'a', base: true, tabKey: 'k0' }, 1: { x: 'b', tabKey: 'k1' } });
|
|
90
|
+
const legacy = toLegacy(state);
|
|
91
|
+
expect('base' in legacy[1]).toBe(false);
|
|
92
|
+
expect(legacy[0].base).toBe(true);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -46,6 +46,14 @@
|
|
|
46
46
|
.error .error-message{
|
|
47
47
|
top: revert;
|
|
48
48
|
}
|
|
49
|
+
// SMS message-body error ("Invalid label, please close all curly braces"):
|
|
50
|
+
// drop the default top gap and add breathing room below the message box.
|
|
51
|
+
.sms-message-content {
|
|
52
|
+
.component-with-label-error-message {
|
|
53
|
+
margin-top: 0 !important;
|
|
54
|
+
margin-bottom: 8px !important;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
49
57
|
.tabs-error {
|
|
50
58
|
.ant-tabs-ink-bar .ant-tabs-ink-bar-animated {
|
|
51
59
|
background-color: #F34F56;
|