@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,155 @@
1
+ /**
2
+ * SMS cross-flow parity — the legacy (Classic) and new (Functional) FormBuilders
3
+ * must produce the SAME response for the same SMS content, in BOTH full mode and
4
+ * library mode. Each fixture (./fixtures/smsParityCases) is rendered through both
5
+ * flows and we assert Classic ≡ Functional ≡ the documented `expected`.
6
+ *
7
+ * Both flows are driven identically via the `startValidation` rising edge (the
8
+ * exact mechanism the container uses on Save). The legacy liquid API is mocked to
9
+ * resolve successfully so library-mode submits complete synchronously — this
10
+ * isolates FORM parity (validity + payload) from the external liquid service,
11
+ * which has its own coverage in sms.liquid.test.js.
12
+ */
13
+
14
+ import React from 'react';
15
+ import '@testing-library/jest-dom';
16
+ import _ from 'lodash';
17
+ import { Router } from 'react-router-dom';
18
+ import { render, act } from '../../../../utils/test-utils';
19
+ import history from '../../../../utils/history';
20
+ import { response as smsSchemaResponse } from '../../../../v2Containers/Sms/initialSchema';
21
+ import { getChannelData } from '../../../../utils/commonUtils';
22
+ import { SMS_PARITY_CASES } from './fixtures/smsParityCases';
23
+
24
+ import ClassicFormBuilder from '../../Classic';
25
+ import FunctionalFormBuilder from '../index';
26
+
27
+ // TagList (rendered for the tag-list field) is auth-guarded; neutralize the route guard.
28
+ jest.mock('redux-auth-wrapper/history4/redirect', () => ({
29
+ connectedRouterRedirect: jest.fn(() => (Component) => Component),
30
+ }));
31
+
32
+ // updateCharCount fires Api.getUnsubscribeUrl on first use; stub it (plain fn so
33
+ // resetMocks doesn't wipe the implementation).
34
+ jest.mock('../../../../services/api', () => ({
35
+ ...jest.requireActual('../../../../services/api'),
36
+ getUnsubscribeUrl: () => Promise.resolve({ response: { response: '' } }),
37
+ }));
38
+
39
+ // Make the (async) liquid validation resolve to success synchronously so a valid
40
+ // library-mode save submits deterministically in BOTH flows. getChannelData stays real.
41
+ jest.mock('../../../../utils/commonUtils', () => ({
42
+ ...jest.requireActual('../../../../utils/commonUtils'),
43
+ validateLiquidTemplateContent: (content, options) => {
44
+ if (options && typeof options.onSuccess === 'function') options.onSuccess(content);
45
+ return Promise.resolve(true);
46
+ },
47
+ }));
48
+
49
+ const baseDefinition = () => _.cloneDeep(smsSchemaResponse.metaEntities[0].definition);
50
+
51
+ const buildFormData = ({ content, name, allowUnicode }) => {
52
+ const tab = { 'sms-editor': content, 'unicode-validity': allowUnicode, base: true, tabKey: 'k0' };
53
+ return { 'template-name': name, 0: tab, base: tab };
54
+ };
55
+
56
+ const buildProps = (flowCase, mode) => {
57
+ const onSubmit = jest.fn();
58
+ const onFormValidityChange = jest.fn();
59
+ return {
60
+ schema: baseDefinition(),
61
+ channel: 'SMS',
62
+ location: {
63
+ pathname: '/sms',
64
+ query: {
65
+ type: flowCase.embedded ? 'embedded' : false,
66
+ module: mode === 'library' ? 'library' : 'default',
67
+ },
68
+ },
69
+ usingTabContainer: true,
70
+ isFullMode: mode === 'full',
71
+ isEdit: true,
72
+ currentTab: 1,
73
+ tabCount: 1,
74
+ tabKey: '',
75
+ formData: buildFormData(flowCase),
76
+ startValidation: false,
77
+ checkValidation: false,
78
+ tags: [],
79
+ injectedTags: {},
80
+ onChange: jest.fn(),
81
+ onSubmit,
82
+ onFormValidityChange,
83
+ stopValidation: jest.fn(),
84
+ showLiquidErrorInFooter: jest.fn(),
85
+ setModalContent: jest.fn(),
86
+ handleCancelModal: jest.fn(),
87
+ parent: {},
88
+ _onSubmit: onSubmit,
89
+ _onFormValidityChange: onFormValidityChange,
90
+ };
91
+ };
92
+
93
+ // Render a flow, fire the Save (startValidation rising edge), and read back the
94
+ // validity + submitted formData → normalized result for comparison.
95
+ const runFlow = (FlowComponent, flowCase, mode) => {
96
+ const props = buildProps(flowCase, mode);
97
+ const ui = (p) => (
98
+ <Router history={history}>
99
+ <FlowComponent {...p} />
100
+ </Router>
101
+ );
102
+ const utils = render(ui(props));
103
+ act(() => {
104
+ utils.rerender(ui({ ...props, startValidation: true }));
105
+ });
106
+ utils.unmount();
107
+
108
+ const vCalls = props._onFormValidityChange.mock.calls;
109
+ const lastValidity = vCalls[vCalls.length - 1] || [];
110
+ const isValid = lastValidity[0];
111
+ const errorData = lastValidity[1] || {};
112
+ const submitted = props._onSubmit.mock.calls[0] ? props._onSubmit.mock.calls[0][0] : null;
113
+ const tabErr = errorData[0] || {};
114
+ return {
115
+ isValid: Boolean(isValid),
116
+ editorError: tabErr['sms-editor'] === undefined ? false : tabErr['sms-editor'],
117
+ unicodeError: Boolean(tabErr['unicode-validity']),
118
+ nameError: Boolean(errorData['template-name']),
119
+ submits: Boolean(submitted),
120
+ payload: submitted ? getChannelData('SMS', submitted, 'en') : null,
121
+ };
122
+ };
123
+
124
+ const normalize = (r) => ({
125
+ isValid: r.isValid,
126
+ editorError: r.editorError || false,
127
+ unicodeError: r.unicodeError,
128
+ nameError: r.nameError,
129
+ submits: r.submits,
130
+ payload: r.payload,
131
+ });
132
+
133
+ describe('SMS cross-flow parity (Classic ≡ Functional ≡ expected)', () => {
134
+ SMS_PARITY_CASES.forEach((flowCase) => {
135
+ flowCase.modes.forEach((mode) => {
136
+ it(`[${mode}] ${flowCase.id}: ${flowCase.description}`, () => {
137
+ const classic = normalize(runFlow(ClassicFormBuilder, flowCase, mode));
138
+ const functional = normalize(runFlow(FunctionalFormBuilder, flowCase, mode));
139
+
140
+ // 1) The two flows must agree with each other.
141
+ expect(functional).toEqual(classic);
142
+
143
+ // 2) ...and both must match the documented contract.
144
+ expect(functional).toEqual({
145
+ isValid: flowCase.expected.isValid,
146
+ editorError: flowCase.expected.editorError,
147
+ unicodeError: flowCase.expected.unicodeError,
148
+ nameError: flowCase.expected.nameError,
149
+ submits: flowCase.expected.submits,
150
+ payload: flowCase.expected.payload,
151
+ });
152
+ });
153
+ });
154
+ });
155
+ });
@@ -0,0 +1,172 @@
1
+ /**
2
+ * SMS liquid-pipeline parity (non-isFullMode / library + embedded mode).
3
+ *
4
+ * Mirrors Classic.onSubmitWrapper's `runLiquidValidation` branch: outside
5
+ * isFullMode, a liquid-supported channel runs liquid-tag extraction/validation
6
+ * before submitting. A clean result submits; a liquid/standard error is surfaced
7
+ * in the footer (showLiquidErrorInFooter) + stopValidation and blocks the submit.
8
+ *
9
+ * These exercise the bare FormBuilderShell (wrapped in injectIntl) so a mock
10
+ * getLiquidTags action can be injected as a prop — the connected default export
11
+ * would override it from Redux dispatch.
12
+ */
13
+
14
+ import React from 'react';
15
+ import '@testing-library/jest-dom';
16
+ import { waitFor } from '@testing-library/react';
17
+ import _ from 'lodash';
18
+ import { injectIntl } from 'react-intl';
19
+ import { render, screen, fireEvent } from '../../../../utils/test-utils';
20
+ import { response as smsSchemaResponse } from '../../../../v2Containers/Sms/initialSchema';
21
+ import FormBuilderShell from '../FormBuilderShell';
22
+
23
+ // TagList (the tag-list field) is an auth-guarded container; neutralize the guard
24
+ // the same way the container/characterization tests do.
25
+ jest.mock('redux-auth-wrapper/history4/redirect', () => ({
26
+ connectedRouterRedirect: jest.fn(() => (Component) => Component),
27
+ }));
28
+
29
+ const Wrapped = injectIntl(FormBuilderShell);
30
+
31
+ const buildSchema = () => _.cloneDeep(smsSchemaResponse.metaEntities[0].definition);
32
+
33
+ const buildProps = (overrides = {}) => ({
34
+ formData: {},
35
+ channel: 'SMS',
36
+ location: { pathname: '/sms/create', query: { type: false, module: 'library' } },
37
+ usingTabContainer: true,
38
+ isFullMode: false, // <-- library/embedded: the liquid path is active
39
+ isEdit: false,
40
+ startValidation: false,
41
+ checkValidation: false,
42
+ tags: [],
43
+ injectedTags: {},
44
+ baseLanguage: 'en',
45
+ onChange: jest.fn(),
46
+ onSubmit: jest.fn(),
47
+ onFormValidityChange: jest.fn(),
48
+ stopValidation: jest.fn(),
49
+ showLiquidErrorInFooter: jest.fn(),
50
+ ...overrides,
51
+ });
52
+
53
+ const renderShell = (props) => {
54
+ const utils = render(<Wrapped {...props} />);
55
+ return { ...utils, rerenderWith: (next) => utils.rerender(<Wrapped {...next} />) };
56
+ };
57
+
58
+ const typeNameAndContent = () => {
59
+ fireEvent.change(screen.getByPlaceholderText('Enter template name'), { target: { value: 'My SMS' } });
60
+ fireEvent.change(screen.getByPlaceholderText('Please input sms template content.'), { target: { value: 'hello world' } });
61
+ };
62
+
63
+ describe('SMS liquid pipeline (non-isFullMode)', () => {
64
+ it('clean liquid result submits: getLiquidTags called with channel content, then onSubmit', async () => {
65
+ let liquidContent = null;
66
+ const getLiquidTags = jest.fn((content, cb) => {
67
+ liquidContent = content;
68
+ cb({ askAiraResponse: { errors: [] }, isError: false });
69
+ });
70
+ const props = buildProps({ schema: buildSchema(), actions: { getLiquidTags } });
71
+ const { rerenderWith } = renderShell(props);
72
+
73
+ typeNameAndContent();
74
+ rerenderWith({ ...props, startValidation: true });
75
+
76
+ await waitFor(() => expect(props.onSubmit).toHaveBeenCalledTimes(1));
77
+ // content fed to the liquid API == getChannelData('SMS', formData): `${body} ${name}`
78
+ expect(getLiquidTags).toHaveBeenCalledTimes(1);
79
+ expect(liquidContent).toBe('hello world My SMS');
80
+ // The footer is touched (Classic calls showLiquidErrorInFooter on every validate to
81
+ // clear standard errors when valid), but no actual error is ever surfaced.
82
+ props.showLiquidErrorInFooter.mock.calls.forEach(([arg]) => {
83
+ expect(arg.STANDARD_ERROR_MSG).toEqual([]);
84
+ expect(arg.LIQUID_ERROR_MSG || []).toEqual([]);
85
+ });
86
+ });
87
+
88
+ it('liquid error blocks submit: footer errors + stopValidation, no onSubmit', async () => {
89
+ const getLiquidTags = jest.fn((content, cb) => {
90
+ cb({ askAiraResponse: { errors: [{ message: 'Invalid liquid tag' }] }, isError: false });
91
+ });
92
+ const props = buildProps({ schema: buildSchema(), actions: { getLiquidTags } });
93
+ const { rerenderWith } = renderShell(props);
94
+
95
+ typeNameAndContent();
96
+ rerenderWith({ ...props, startValidation: true });
97
+
98
+ await waitFor(() => expect(props.showLiquidErrorInFooter).toHaveBeenCalled());
99
+ const footerArg = props.showLiquidErrorInFooter.mock.calls[props.showLiquidErrorInFooter.mock.calls.length - 1][0];
100
+ expect(footerArg.LIQUID_ERROR_MSG).toContain('Invalid liquid tag');
101
+ expect(props.stopValidation).toHaveBeenCalled();
102
+ expect(props.onSubmit).not.toHaveBeenCalled();
103
+ });
104
+
105
+ it('does NOT run the liquid pipeline when sync validation already failed (no getLiquidTags call)', async () => {
106
+ const getLiquidTags = jest.fn((content, cb) => cb({ askAiraResponse: { errors: [] }, isError: false }));
107
+ // Empty name + empty content -> sync-invalid; liquid must not run.
108
+ const props = buildProps({ schema: buildSchema(), actions: { getLiquidTags } });
109
+ const { rerenderWith } = renderShell(props);
110
+
111
+ rerenderWith({ ...props, startValidation: true });
112
+
113
+ await waitFor(() => expect(props.stopValidation).toHaveBeenCalled());
114
+ expect(getLiquidTags).not.toHaveBeenCalled();
115
+ expect(props.onSubmit).not.toHaveBeenCalled();
116
+ });
117
+
118
+ it('LIBRARY mode: brace error goes to the FOOTER only (unchanged old flow — no inline message)', async () => {
119
+ const getLiquidTags = jest.fn((content, cb) => cb({ askAiraResponse: { errors: [] }, isError: false }));
120
+ const props = buildProps({ schema: buildSchema(), actions: { getLiquidTags } }); // isFullMode: false
121
+ const { rerenderWith } = renderShell(props);
122
+
123
+ // Unbalanced braces in the SMS body.
124
+ fireEvent.change(screen.getByPlaceholderText('Enter template name'), { target: { value: 'My SMS' } });
125
+ fireEvent.change(screen.getByPlaceholderText('Please input sms template content.'), { target: { value: 'abc {{full_name' } });
126
+
127
+ // Trigger validation (Save/Done) — sync validation fails on the brace error.
128
+ rerenderWith({ ...props, startValidation: true });
129
+
130
+ // Library mode is unchanged: the brace message goes to the FOOTER...
131
+ await waitFor(() => {
132
+ const braceCall = props.showLiquidErrorInFooter.mock.calls.find(
133
+ ([arg]) => (arg.STANDARD_ERROR_MSG || []).includes('Invalid label, please close all curly braces'),
134
+ );
135
+ expect(braceCall).toBeTruthy();
136
+ });
137
+ // ...and NOT inline below the box.
138
+ expect(screen.queryByText('Invalid label, please close all curly braces')).toBeNull();
139
+ // sync-invalid -> the liquid API is never called, and nothing is submitted.
140
+ expect(getLiquidTags).not.toHaveBeenCalled();
141
+ expect(props.onSubmit).not.toHaveBeenCalled();
142
+ });
143
+
144
+ it('FULL mode: brace error shows INLINE below the box and NOT in the footer', async () => {
145
+ const props = buildProps({ schema: buildSchema(), isFullMode: true, actions: { getLiquidTags: jest.fn() } });
146
+ const { rerenderWith } = renderShell(props);
147
+ fireEvent.change(screen.getByPlaceholderText('Enter template name'), { target: { value: 'My SMS' } });
148
+ fireEvent.change(screen.getByPlaceholderText('Please input sms template content.'), { target: { value: 'abc {{full_name' } });
149
+ rerenderWith({ ...props, startValidation: true });
150
+
151
+ // Full mode: the brace message is shown INLINE below the box...
152
+ await waitFor(() => expect(screen.getByText('Invalid label, please close all curly braces')).toBeInTheDocument());
153
+ // ...and is never pushed to the footer (no showLiquidErrorInFooter call carries it).
154
+ const braceCall = props.showLiquidErrorInFooter.mock.calls.find(
155
+ ([arg]) => (arg.STANDARD_ERROR_MSG || []).includes('Invalid label, please close all curly braces'),
156
+ );
157
+ expect(braceCall).toBeUndefined();
158
+ expect(props.onSubmit).not.toHaveBeenCalled();
159
+ });
160
+
161
+ it('isFullMode bypasses the liquid pipeline entirely (direct submit, getLiquidTags untouched)', async () => {
162
+ const getLiquidTags = jest.fn();
163
+ const props = buildProps({ schema: buildSchema(), isFullMode: true, actions: { getLiquidTags } });
164
+ const { rerenderWith } = renderShell(props);
165
+
166
+ typeNameAndContent();
167
+ rerenderWith({ ...props, startValidation: true });
168
+
169
+ await waitFor(() => expect(props.onSubmit).toHaveBeenCalledTimes(1));
170
+ expect(getLiquidTags).not.toHaveBeenCalled();
171
+ });
172
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * SMS rollout-readiness parity tests — locks the remaining pre-rollout behaviors:
3
+ * 1. Embedded/library mode: template-name is not required (isEmbedded), and the
4
+ * Shell renders whatever (container-trimmed) schema it is handed.
5
+ * 2. sms-count ref: the DivField forwards the container's ref so the container's
6
+ * onTemplateContentChange can imperatively write the "N SMS (M characters)" text.
7
+ * 3. Parent-sync: a GENUINE external formData push (edit load / discard) re-hydrates,
8
+ * while an echo of our own emitted data does not clobber what the user typed.
9
+ */
10
+
11
+ import React from 'react';
12
+ import '@testing-library/jest-dom';
13
+ import _ from 'lodash';
14
+ import { render, screen, fireEvent } from '../../../../utils/test-utils';
15
+ import { response as smsSchemaResponse } from '../../../../v2Containers/Sms/initialSchema';
16
+
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
+ const buildSchema = () => _.cloneDeep(smsSchemaResponse.metaEntities[0].definition);
25
+
26
+ const buildProps = (overrides = {}) => ({
27
+ formData: {},
28
+ channel: 'SMS',
29
+ location: { pathname: '/sms', query: { type: false, module: 'default' } },
30
+ usingTabContainer: true,
31
+ isFullMode: true,
32
+ isEdit: false,
33
+ startValidation: false,
34
+ checkValidation: false,
35
+ tags: [],
36
+ injectedTags: {},
37
+ onChange: jest.fn(),
38
+ onSubmit: jest.fn(),
39
+ onFormValidityChange: jest.fn(),
40
+ stopValidation: jest.fn(),
41
+ ...overrides,
42
+ });
43
+
44
+ const renderShell = (props) => {
45
+ const utils = render(<FunctionalFormBuilder {...props} />);
46
+ return { ...utils, rerenderWith: (next) => utils.rerender(<FunctionalFormBuilder {...next} />) };
47
+ };
48
+
49
+ const lastCall = (fn) => fn.mock.calls[fn.mock.calls.length - 1];
50
+
51
+ describe('SMS rollout — embedded/library mode', () => {
52
+ it('is VALID with content but no template-name when embedded (matches Classic isEmbedded skip)', () => {
53
+ const props = buildProps({
54
+ schema: buildSchema(),
55
+ location: { pathname: '/sms', query: { type: 'embedded', module: 'library' } },
56
+ formData: { 'template-name': '', 0: { 'sms-editor': 'hello world', 'unicode-validity': false, base: true } },
57
+ isEdit: true,
58
+ });
59
+ renderShell(props);
60
+ const [isValid, errorData] = lastCall(props.onFormValidityChange);
61
+ expect(errorData['template-name']).toBe(false); // not required when embedded
62
+ expect(isValid).toBe(true);
63
+ });
64
+
65
+ it('renders only the fields present in a container-trimmed schema (no template-name input)', () => {
66
+ // Simulate the container's removeStandAlone having dropped the template-name field.
67
+ const schema = buildSchema();
68
+ const nameSection = schema.standalone.sections[0].childSections[1].childSections[0].childSections;
69
+ nameSection.splice(0, 1); // remove the template-name multicols row
70
+ renderShell(buildProps({
71
+ schema,
72
+ location: { pathname: '/sms', query: { type: 'embedded', module: 'library' } },
73
+ }));
74
+ expect(screen.queryByPlaceholderText('Enter template name')).toBeNull();
75
+ // the message editor still renders
76
+ expect(screen.getByPlaceholderText('Please input sms template content.')).toBeInTheDocument();
77
+ });
78
+ });
79
+
80
+ describe('SMS rollout — sms-count ref forwarding', () => {
81
+ it('forwards the container ref to the sms-count div so the counter text can be written imperatively', () => {
82
+ const smsCountRef = React.createRef();
83
+ renderShell(buildProps({ schema: buildSchema(), refs: { 'sms-count': smsCountRef } }));
84
+ expect(smsCountRef.current).toBeTruthy();
85
+ expect(smsCountRef.current.id).toBe('sms-count');
86
+ // The container writes the count via this ref (as onTemplateContentChange does);
87
+ // React must not clobber it because the div's React children are always ''.
88
+ smsCountRef.current.innerText = '1 SMS (11 characters)';
89
+ fireEvent.change(screen.getByPlaceholderText('Please input sms template content.'), { target: { value: 'hello world' } });
90
+ expect(smsCountRef.current.innerText).toBe('1 SMS (11 characters)');
91
+ });
92
+ });
93
+
94
+ describe('SMS rollout — parent-sync', () => {
95
+ it('re-hydrates on a GENUINE external formData push (edit load / discard)', () => {
96
+ const props = buildProps({ schema: buildSchema() });
97
+ const { rerenderWith } = renderShell(props);
98
+ expect(screen.getByPlaceholderText('Please input sms template content.')).toHaveValue('');
99
+
100
+ // Parent pushes saved data (e.g. edit load resolves, or Discard restores).
101
+ const tab0 = { 'sms-editor': 'Restored body', 'unicode-validity': false, base: true, tabKey: 'k1' };
102
+ rerenderWith({ ...props, isEdit: true, formData: { 'template-name': 'Restored', 0: tab0, base: tab0 } });
103
+
104
+ expect(screen.getByPlaceholderText('Enter template name')).toHaveValue('Restored');
105
+ expect(screen.getByPlaceholderText('Please input sms template content.')).toHaveValue('Restored body');
106
+ });
107
+
108
+ it('does NOT clobber user input when the parent echoes back our own emitted formData', () => {
109
+ const props = buildProps({ schema: buildSchema() });
110
+ const { rerenderWith } = renderShell(props);
111
+
112
+ const editor = screen.getByPlaceholderText('Please input sms template content.');
113
+ fireEvent.change(editor, { target: { value: 'typed by user' } });
114
+
115
+ // The container stores what we emitted and passes it straight back as formData.
116
+ const echoed = lastCall(props.onChange)[0];
117
+ rerenderWith({ ...props, formData: echoed });
118
+
119
+ // Still shows the user's text — the echo must be detected and ignored.
120
+ expect(screen.getByPlaceholderText('Please input sms template content.')).toHaveValue('typed by user');
121
+ });
122
+ });