@capillarytech/creatives-library 9.0.57-alpha.0 → 9.0.57-alpha.2
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/package.json
CHANGED
|
@@ -275,8 +275,14 @@ const FormBuilderShell = (props) => {
|
|
|
275
275
|
const isEcho = isEqual(parentFormData, lastEmittedRef.current) || isEqual(parentFormData, current);
|
|
276
276
|
if (!isEcho) {
|
|
277
277
|
dispatch(hydrate(fromLegacy(parentFormData, { channel })));
|
|
278
|
-
// Classic mirrors the tabCount prop
|
|
279
|
-
|
|
278
|
+
// Classic mirrors the tabCount prop on a formData sync only in EDIT mode
|
|
279
|
+
// (Classic.js:446-447); its generic mirror (415) needs the prop VALUE to
|
|
280
|
+
// change, which never happens with the containers' hardcoded tabCount={2}.
|
|
281
|
+
// Adopting 2 in the create flow makes the containers' content-emptiness
|
|
282
|
+
// check (CAP-189913) scan the untouched iOS tab -> Done/Preview stuck disabled.
|
|
283
|
+
if (propsRef.current.isEdit) {
|
|
284
|
+
emittedTabCountRef.current = propsRef.current.tabCount || emittedTabCountRef.current;
|
|
285
|
+
}
|
|
280
286
|
// Classic validates on every deep-unequal parent push (Classic.js:516-537) —
|
|
281
287
|
// this is what turns the container's isFormValid true before Create/Done.
|
|
282
288
|
if (validateOnExternalChange) {
|
|
@@ -439,7 +445,12 @@ const FormBuilderShell = (props) => {
|
|
|
439
445
|
// MPUSH opts out of live validation via features.liveValidation:false, SMS keeps it.
|
|
440
446
|
const validateLive = liquidEnabled && !isFullMode
|
|
441
447
|
&& adapter.config?.features?.liveValidation !== false;
|
|
442
|
-
|
|
448
|
+
// MPUSH: Classic still re-validates EVERY change via the container round-trip
|
|
449
|
+
// (onChange -> container setState -> CWRP deep-unequal formData push -> validateForm,
|
|
450
|
+
// Classic.js:516-537) — the container's isFormValid must track typing or the
|
|
451
|
+
// library-mode Done gate (Create/index.js:117) stays stuck. Display remains
|
|
452
|
+
// gated on checkValidation, so this emission is invisible pre-save.
|
|
453
|
+
if (validationActive || validateLive || validateOnExternalChange) {
|
|
443
454
|
emitValidity(runValidate(legacy), legacy);
|
|
444
455
|
}
|
|
445
456
|
};
|
|
@@ -621,6 +632,7 @@ FormBuilderShell.propTypes = {
|
|
|
621
632
|
tagModule: PropTypes.string,
|
|
622
633
|
tags: PropTypes.array,
|
|
623
634
|
checkValidation: PropTypes.bool,
|
|
635
|
+
isEdit: PropTypes.bool,
|
|
624
636
|
injectedTags: PropTypes.object,
|
|
625
637
|
onContextChange: PropTypes.func,
|
|
626
638
|
// Shapes match TagList's own contract (array of offer objects / keyed-by-blockId map).
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Library/embedded-mode validity parity — the campaigns-host Done/Preview gate.
|
|
3
|
+
*
|
|
4
|
+
* The container's isFormValid must track typing: Classic re-validates every change
|
|
5
|
+
* via the container round-trip (onChange -> container setState -> CWRP deep-unequal
|
|
6
|
+
* formData push -> validateForm -> onFormValidityChange, Classic.js:516-537), and the
|
|
7
|
+
* library-mode Done click early-returns on !state.isFormValid (Create/index.js:117).
|
|
8
|
+
* The shell mirrors this with a background validity emission on every field change
|
|
9
|
+
* under features.validateOnExternalChange (display stays gated on checkValidation).
|
|
10
|
+
*
|
|
11
|
+
* Also pins onContentValidityChange ({isContentEmpty}) — the footer's live
|
|
12
|
+
* Done/Preview-and-Test disable signal (CreativesContainer handleContentValidityChange).
|
|
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, fireEvent } from '../../../../utils/test-utils';
|
|
19
|
+
import history from '../../../../utils/history';
|
|
20
|
+
import { response as mpushSchemaResponse } from '../../../../v2Containers/MobilePush/initialSchema';
|
|
21
|
+
import { Create } from '../../../../v2Containers/MobilePush/Create';
|
|
22
|
+
|
|
23
|
+
jest.mock('redux-auth-wrapper/history4/redirect', () => ({
|
|
24
|
+
connectedRouterRedirect: jest.fn(() => (Component) => Component),
|
|
25
|
+
}));
|
|
26
|
+
jest.mock('../../../../services/api', () => ({
|
|
27
|
+
...jest.requireActual('../../../../services/api'),
|
|
28
|
+
getUnsubscribeUrl: () => Promise.resolve({ response: { response: '' } }),
|
|
29
|
+
}));
|
|
30
|
+
|
|
31
|
+
const fullDefinition = () => _.cloneDeep(mpushSchemaResponse.metaEntities[0].definition);
|
|
32
|
+
|
|
33
|
+
const intl = {
|
|
34
|
+
formatMessage: (d) => (d && (d.defaultMessage || d.id)) || '',
|
|
35
|
+
locale: 'en',
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const selectedAccount = {
|
|
39
|
+
id: 'acc-1',
|
|
40
|
+
name: 'Acc',
|
|
41
|
+
sourceTypeName: 'SOME_SDK',
|
|
42
|
+
sourceAccountIdentifier: 'lic-1',
|
|
43
|
+
configs: { android: '1', ios: '1', deeplink: '[]' },
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const buildProps = () => ({
|
|
47
|
+
intl,
|
|
48
|
+
params: { mode: 'text' },
|
|
49
|
+
route: { name: 'create' },
|
|
50
|
+
location: { pathname: '/mobilepush/create/text', query: { module: 'default', type: 'embedded' } },
|
|
51
|
+
router: { push: jest.fn() },
|
|
52
|
+
isFullMode: false,
|
|
53
|
+
isGetFormData: false,
|
|
54
|
+
isLoadingMetaEntities: false,
|
|
55
|
+
metaEntities: {},
|
|
56
|
+
Create: { createTemplateInProgress: false },
|
|
57
|
+
Edit: {},
|
|
58
|
+
Templates: { selectedWeChatAccount: selectedAccount },
|
|
59
|
+
actions: new Proxy({}, { get: (t, n) => { if (!t[n]) t[n] = jest.fn(); return t[n]; } }), // eslint-disable-line no-param-reassign
|
|
60
|
+
globalActions: {
|
|
61
|
+
fetchSchemaForEntity: jest.fn(),
|
|
62
|
+
addMessageToQueue: jest.fn(),
|
|
63
|
+
setInjectedTags: jest.fn(),
|
|
64
|
+
},
|
|
65
|
+
getFormLibraryData: jest.fn(),
|
|
66
|
+
onValidationFail: jest.fn(),
|
|
67
|
+
showLiquidErrorInFooter: jest.fn(),
|
|
68
|
+
onPersonalizationTokensChange: jest.fn(),
|
|
69
|
+
onContentValidityChange: jest.fn(),
|
|
70
|
+
getLiquidTags: jest.fn(),
|
|
71
|
+
injectedTags: {},
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const setNativeValue = (el, value) => {
|
|
75
|
+
const proto = Object.getPrototypeOf(el);
|
|
76
|
+
Object.getOwnPropertyDescriptor(proto, 'value').set.call(el, value);
|
|
77
|
+
};
|
|
78
|
+
const typeInto = (id, value) => {
|
|
79
|
+
const el = document.getElementById(id);
|
|
80
|
+
if (!el) throw new Error(`no #${id} in DOM`);
|
|
81
|
+
setNativeValue(el, value);
|
|
82
|
+
fireEvent.change(el, { target: { value } });
|
|
83
|
+
fireEvent.blur(el);
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
describe.each([
|
|
87
|
+
['Classic', 'false'],
|
|
88
|
+
['Functional', 'true'],
|
|
89
|
+
])('library-mode validity parity — %s', (flow, flagValue) => {
|
|
90
|
+
beforeEach(() => {
|
|
91
|
+
window.localStorage.setItem('ENABLE_NEW_FORMBUILDER_MPUSH', flagValue);
|
|
92
|
+
jest.useFakeTimers();
|
|
93
|
+
});
|
|
94
|
+
afterEach(() => {
|
|
95
|
+
jest.useRealTimers();
|
|
96
|
+
window.localStorage.removeItem('ENABLE_NEW_FORMBUILDER_MPUSH');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('isFormValid flips true after typing valid content (Done gate) and content-emptiness reports live', () => {
|
|
100
|
+
let containerRef = null;
|
|
101
|
+
const props = buildProps();
|
|
102
|
+
const ui = (p) => (
|
|
103
|
+
<Router history={history}>
|
|
104
|
+
<Create ref={(r) => { if (r) containerRef = r; }} {...p} />
|
|
105
|
+
</Router>
|
|
106
|
+
);
|
|
107
|
+
const utils = render(ui(props));
|
|
108
|
+
const meta = { layouts: [{ definition: fullDefinition() }], tags: { standard: [] } };
|
|
109
|
+
act(() => { utils.rerender(ui({ ...props, metaEntities: meta })); });
|
|
110
|
+
act(() => { jest.runOnlyPendingTimers(); });
|
|
111
|
+
|
|
112
|
+
// mount: empty form — invalid, content empty
|
|
113
|
+
expect(containerRef.state.isFormValid).toBe(false);
|
|
114
|
+
expect(props.onContentValidityChange).toHaveBeenCalledWith({ isContentEmpty: true });
|
|
115
|
+
|
|
116
|
+
act(() => { typeInto('message-title', 'Hello title'); });
|
|
117
|
+
act(() => { typeInto('message-editor', 'Hello body'); });
|
|
118
|
+
act(() => { jest.advanceTimersByTime(1000); }); // flush debounced emissions
|
|
119
|
+
|
|
120
|
+
// the Done gate must see the typed content: valid + non-empty
|
|
121
|
+
expect(containerRef.state.isFormValid).toBe(true);
|
|
122
|
+
const contentCalls = props.onContentValidityChange.mock.calls.map((c) => c[0]);
|
|
123
|
+
expect(contentCalls[contentCalls.length - 1]).toEqual({ isContentEmpty: false });
|
|
124
|
+
|
|
125
|
+
// user repro: image-upload write-back (a genuine parent push) then CTA check/uncheck.
|
|
126
|
+
// The shell must NOT adopt the hardcoded tabCount={2} prop in the create flow
|
|
127
|
+
// (Classic.js:415/446-447), or the content check scans the empty iOS tab and
|
|
128
|
+
// Done/Preview stick disabled.
|
|
129
|
+
act(() => {
|
|
130
|
+
containerRef.setState((prev) => ({
|
|
131
|
+
formData: { ...prev.formData, 0: { ...prev.formData[0], image: 'blob:uploaded' } },
|
|
132
|
+
}));
|
|
133
|
+
});
|
|
134
|
+
act(() => { jest.advanceTimersByTime(1000); });
|
|
135
|
+
const pane = document.querySelectorAll('.ant-tabs-tabpane')[0] || document;
|
|
136
|
+
const label = Array.from(pane.querySelectorAll('label')).find((l) => /action link/i.test(l.textContent || ''));
|
|
137
|
+
const cta = (label && label.querySelector('input[type="checkbox"]')) || pane.querySelector('input[type="checkbox"]');
|
|
138
|
+
act(() => { fireEvent.click(cta); });
|
|
139
|
+
act(() => { jest.advanceTimersByTime(1000); });
|
|
140
|
+
act(() => { fireEvent.click(cta); });
|
|
141
|
+
act(() => { jest.advanceTimersByTime(1000); });
|
|
142
|
+
|
|
143
|
+
expect(containerRef.state.tabCount).toBe(1); // create flow never adopts the prop
|
|
144
|
+
const finalCalls = props.onContentValidityChange.mock.calls.map((c) => c[0]);
|
|
145
|
+
expect(finalCalls[finalCalls.length - 1]).toEqual({ isContentEmpty: false }); // buttons stay enabled
|
|
146
|
+
|
|
147
|
+
utils.unmount();
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -161,4 +161,43 @@ describe('MPUSH shell event bridge', () => {
|
|
|
161
161
|
expect(legacy[0]).toEqual(expect.objectContaining({ 'message-title': '' }));
|
|
162
162
|
expect(legacy[1]).toEqual(expect.objectContaining({ 'message-title2': '' }));
|
|
163
163
|
});
|
|
164
|
+
|
|
165
|
+
// Classic mirrors the tabCount prop on a formData push only in EDIT mode
|
|
166
|
+
// (Classic.js:446-447; the generic mirror at 415 needs the prop VALUE to change,
|
|
167
|
+
// never true with the containers' hardcoded tabCount={2}). Adopting 2 in the
|
|
168
|
+
// create flow made getMobilePushEmbeddedContentValidity (CAP-189913) scan the
|
|
169
|
+
// untouched iOS tab after an image-upload write-back push -> Done/Preview stuck.
|
|
170
|
+
it('create flow keeps emitting tabCount 1 after a genuine parent push (image write-back); edit adopts the prop', () => {
|
|
171
|
+
const writeBack = (base) => ({
|
|
172
|
+
...base,
|
|
173
|
+
0: { ...base[0], image: 'blob:uploaded' }, // container's in-place upload write-back
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
const typeTitle = (value) => {
|
|
177
|
+
const el = document.getElementById('message-title');
|
|
178
|
+
fireEvent.change(el, { target: { value } });
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
// CREATE (no isEdit): tabCount must stay 1
|
|
182
|
+
const createProps = buildProps(buildSchema(), { formData: {} });
|
|
183
|
+
const createMount = mount(createProps);
|
|
184
|
+
act(() => {
|
|
185
|
+
createMount.rerender(createMount.ui({ ...createProps, formData: writeBack(buildFormData()) }));
|
|
186
|
+
});
|
|
187
|
+
act(() => { typeTitle('after push'); }); // next emission carries the tabCount
|
|
188
|
+
const createEmit = createProps.onChange.mock.calls[createProps.onChange.mock.calls.length - 1];
|
|
189
|
+
expect(createEmit[1]).toBe(1);
|
|
190
|
+
createMount.unmount();
|
|
191
|
+
|
|
192
|
+
// EDIT: Classic adopts the prop on the data sync (Classic.js:447)
|
|
193
|
+
const editProps = buildProps(buildSchema(), { formData: {}, isEdit: true });
|
|
194
|
+
const editMount = mount(editProps);
|
|
195
|
+
act(() => {
|
|
196
|
+
editMount.rerender(editMount.ui({ ...editProps, formData: writeBack(buildFormData()) }));
|
|
197
|
+
});
|
|
198
|
+
act(() => { typeTitle('after push'); });
|
|
199
|
+
const editEmit = editProps.onChange.mock.calls[editProps.onChange.mock.calls.length - 1];
|
|
200
|
+
expect(editEmit[1]).toBe(2);
|
|
201
|
+
editMount.unmount();
|
|
202
|
+
});
|
|
164
203
|
});
|