@designcrowd/library.brand-page 6.0.6-undo-5 → 6.0.6-undo-7
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/lib/src/{CreatedOnBrandCrowd-de-de-BRaWY6nF.js → CreatedOnBrandCrowd-de-de-BDxea4Se.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-en-us-D0DuxW2v.js → CreatedOnBrandCrowd-en-us-BvCtPc-H.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-es-es-Du0Wjx0E.js → CreatedOnBrandCrowd-es-es-BFQR8krQ.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-fr-Cz1OGFZT.js → CreatedOnBrandCrowd-fr-ca-Dy0Xb5GE.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-fr-ca-Cz1OGFZT.js → CreatedOnBrandCrowd-fr-fr-Dy0Xb5GE.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-pt-TfMgQ231.js → CreatedOnBrandCrowd-pt-br-BSGwZq7a.js} +1 -1
- package/lib/src/{CreatedOnBrandCrowd-pt-br-TfMgQ231.js → CreatedOnBrandCrowd-pt-pt-BSGwZq7a.js} +1 -1
- package/lib/src/{CreatedOnDcom-de-de-CIxWfXsX.js → CreatedOnDcom-de-de-DU42Z-z_.js} +1 -1
- package/lib/src/{CreatedOnDcom-en-us-BQeFEYIF.js → CreatedOnDcom-en-us-BR_2u6_i.js} +1 -1
- package/lib/src/{CreatedOnDcom-es-es-Bu9Nr8t-.js → CreatedOnDcom-es-es-Dts49b6I.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-ca-Dpsu-wxQ.js → CreatedOnDcom-fr-ca-C7Lo2AbX.js} +1 -1
- package/lib/src/{CreatedOnDcom-fr-fr-CWGZAKa0.js → CreatedOnDcom-fr-fr-ZpEbqlkx.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-br-Ds8H3Kiq.js → CreatedOnDcom-pt-br-CrUIv-Ye.js} +1 -1
- package/lib/src/{CreatedOnDcom-pt-pt-kDx8VWIo.js → CreatedOnDcom-pt-pt-JlbQbaR1.js} +1 -1
- package/lib/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.js +65 -54
- package/lib/src/css/library.brand-page-brandCrowd.min.css +2 -2
- package/lib/src/css/library.brand-page-brandPage.min.css +2 -2
- package/lib/src/css/library.brand-page-designCom.min.css +2 -2
- package/lib/src/css/library.brand-page-preview.min.css +2 -2
- package/lib/src/index.mjs +1 -1
- package/lib/src/{lib-BJdIOaxe.js → lib-6j91ExXu.js} +10679 -10663
- package/lib/src/shared/content-settings/store/content-settings-store.js +17 -0
- package/package.json +1 -1
- package/src/brand-page-maker/components/quick-link-bar/RedoButton.vue +9 -1
- package/src/brand-page-maker/components/quick-link-bar/UndoButton.vue +9 -1
- package/src/brand-page-maker/components/quick-link-bar/undo-redo-buttons.test.ts +29 -2
- package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.test.ts +130 -0
- package/src/brand-page-maker/stores/brandpage-maker/brandpage-maker-store.ts +71 -62
- package/src/shared/content-settings/store/content-settings-store.test.ts +103 -0
- package/src/shared/content-settings/store/content-settings-store.ts +28 -0
|
@@ -14,6 +14,17 @@ const DEFAULT_TEXT_COLOR_DEPENDENT_TYPES = [
|
|
|
14
14
|
'profile',
|
|
15
15
|
'text',
|
|
16
16
|
];
|
|
17
|
+
/**
|
|
18
|
+
* True when the incoming value is byte-identical (as JSON, matching how
|
|
19
|
+
* history snapshots the model) to what the model already holds. Such
|
|
20
|
+
* re-writes must be dropped whole: the assignment would be a reactive
|
|
21
|
+
* no-op, but onContentChanged() still flips isModifiedAfterTemplateApplied
|
|
22
|
+
* — and when that flag was false, its flip is itself a model mutation, so
|
|
23
|
+
* undo history commits an entry in which nothing user-visible changed
|
|
24
|
+
* (phantom undo state, BP-5772: re-clicking the logo panel's Upload/Logo
|
|
25
|
+
* option, re-clicking an already-active display style).
|
|
26
|
+
*/
|
|
27
|
+
const isSameValue = (a, b) => a === b || JSON.stringify(a) === JSON.stringify(b);
|
|
17
28
|
export const CONTENT_SETTINGS_MODES = {
|
|
18
29
|
edit: 'edit',
|
|
19
30
|
add: 'add',
|
|
@@ -211,6 +222,9 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
|
|
|
211
222
|
if (!item) {
|
|
212
223
|
return;
|
|
213
224
|
}
|
|
225
|
+
if (isSameValue(item.settings.content[payload.name], payload.value)) {
|
|
226
|
+
return; // no-op write: don't validate, don't flip the modified flag
|
|
227
|
+
}
|
|
214
228
|
// Hydration write-backs (RTE canonical HTML on mount, lazy config
|
|
215
229
|
// hydration, stale-id clears) fold into the current undo head instead
|
|
216
230
|
// of pushing an entry. Arming here — same tick as the write — is what
|
|
@@ -232,6 +246,9 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
|
|
|
232
246
|
if (!item) {
|
|
233
247
|
return;
|
|
234
248
|
}
|
|
249
|
+
if (Object.keys(value ?? {}).every((k) => isSameValue(item.settings.content[k], value[k]))) {
|
|
250
|
+
return; // every field a no-op — see isSameValue
|
|
251
|
+
}
|
|
235
252
|
// Same hydration-write contract as updateItemValue.
|
|
236
253
|
if (isHydrationWrite) {
|
|
237
254
|
useHistoryStore().mergeNextMutation();
|
package/package.json
CHANGED
|
@@ -39,17 +39,25 @@ import { Icon, Tooltip } from '@designcrowd/fe-shared-lib';
|
|
|
39
39
|
import { useHistoryStore } from '@brand-page-maker/stores/history/history-store';
|
|
40
40
|
import { useAiWebsiteBuilderThreadStore } from '@brand-page-maker/stores/ai-thread/ai-website-builder-thread-store';
|
|
41
41
|
import { useBrandPageContainerStore } from '@brand-page-maker/stores/brandpage-container';
|
|
42
|
+
import { useUiTemplateSettingsStore } from '@shared/template-settings/store';
|
|
42
43
|
import { quickLinkBarTr } from '@src/composables/useTranslate';
|
|
43
44
|
|
|
44
45
|
const historyStore = useHistoryStore();
|
|
45
46
|
const aiThreadStore = useAiWebsiteBuilderThreadStore();
|
|
46
47
|
const containerStore = useBrandPageContainerStore();
|
|
48
|
+
const templateSettingsStore = useUiTemplateSettingsStore();
|
|
47
49
|
|
|
48
50
|
const isDisabled = computed(
|
|
49
51
|
() =>
|
|
50
52
|
!historyStore.canRedo ||
|
|
51
53
|
aiThreadStore.isProcessing ||
|
|
52
|
-
containerStore.isSaving
|
|
54
|
+
containerStore.isSaving ||
|
|
55
|
+
// Locked while a restore is applying (the apply awaits the designed-logo
|
|
56
|
+
// re-render) or the template previews are refetching: re-entering
|
|
57
|
+
// mid-regeneration overlapped two reloads and left the sidebar keyed to
|
|
58
|
+
// the wrong logo (fast undo→redo desync).
|
|
59
|
+
historyStore.isApplyingSnapshot ||
|
|
60
|
+
templateSettingsStore.isBusy,
|
|
53
61
|
);
|
|
54
62
|
|
|
55
63
|
const onClick = () => {
|
|
@@ -39,17 +39,25 @@ import { Icon, Tooltip } from '@designcrowd/fe-shared-lib';
|
|
|
39
39
|
import { useHistoryStore } from '@brand-page-maker/stores/history/history-store';
|
|
40
40
|
import { useAiWebsiteBuilderThreadStore } from '@brand-page-maker/stores/ai-thread/ai-website-builder-thread-store';
|
|
41
41
|
import { useBrandPageContainerStore } from '@brand-page-maker/stores/brandpage-container';
|
|
42
|
+
import { useUiTemplateSettingsStore } from '@shared/template-settings/store';
|
|
42
43
|
import { quickLinkBarTr } from '@src/composables/useTranslate';
|
|
43
44
|
|
|
44
45
|
const historyStore = useHistoryStore();
|
|
45
46
|
const aiThreadStore = useAiWebsiteBuilderThreadStore();
|
|
46
47
|
const containerStore = useBrandPageContainerStore();
|
|
48
|
+
const templateSettingsStore = useUiTemplateSettingsStore();
|
|
47
49
|
|
|
48
50
|
const isDisabled = computed(
|
|
49
51
|
() =>
|
|
50
52
|
!historyStore.canUndo ||
|
|
51
53
|
aiThreadStore.isProcessing ||
|
|
52
|
-
containerStore.isSaving
|
|
54
|
+
containerStore.isSaving ||
|
|
55
|
+
// Locked while a restore is applying (the apply awaits the designed-logo
|
|
56
|
+
// re-render) or the template previews are refetching: re-entering
|
|
57
|
+
// mid-regeneration overlapped two reloads and left the sidebar keyed to
|
|
58
|
+
// the wrong logo (fast undo→redo desync).
|
|
59
|
+
historyStore.isApplyingSnapshot ||
|
|
60
|
+
templateSettingsStore.isBusy,
|
|
53
61
|
);
|
|
54
62
|
|
|
55
63
|
const onClick = () => {
|
|
@@ -6,8 +6,10 @@ import RedoButton from './RedoButton.vue';
|
|
|
6
6
|
|
|
7
7
|
// These tests cover the click-dispatch contract for the undo/redo buttons:
|
|
8
8
|
// - Click → historyStore.undo()/redo() ONLY when enabled.
|
|
9
|
-
// - Disabled when any of
|
|
10
|
-
// aiThread.isProcessing, container.isSaving.
|
|
9
|
+
// - Disabled when any of five guards is true: canUndo/canRedo,
|
|
10
|
+
// aiThread.isProcessing, container.isSaving, history.isApplyingSnapshot
|
|
11
|
+
// (a restore is applying — spans the awaited logo re-render), or
|
|
12
|
+
// templateSettings.isBusy (template previews are refetching).
|
|
11
13
|
//
|
|
12
14
|
// Markup, class names, and i18n strings are intentionally not asserted —
|
|
13
15
|
// they're covered by e2e tests and the data-component / data-test-*
|
|
@@ -34,6 +36,7 @@ vi.mock('@designcrowd/fe-shared-lib', () => ({
|
|
|
34
36
|
const historyStoreMock = {
|
|
35
37
|
canUndo: true,
|
|
36
38
|
canRedo: true,
|
|
39
|
+
isApplyingSnapshot: false,
|
|
37
40
|
undo: vi.fn(),
|
|
38
41
|
redo: vi.fn(),
|
|
39
42
|
};
|
|
@@ -57,14 +60,22 @@ vi.mock('@brand-page-maker/stores/brandpage-container', () => ({
|
|
|
57
60
|
useBrandPageContainerStore: vi.fn(() => containerStoreMock),
|
|
58
61
|
}));
|
|
59
62
|
|
|
63
|
+
const templateSettingsStoreMock = { isBusy: false };
|
|
64
|
+
|
|
65
|
+
vi.mock('@shared/template-settings/store', () => ({
|
|
66
|
+
useUiTemplateSettingsStore: vi.fn(() => templateSettingsStoreMock),
|
|
67
|
+
}));
|
|
68
|
+
|
|
60
69
|
beforeEach(() => {
|
|
61
70
|
setActivePinia(createPinia());
|
|
62
71
|
historyStoreMock.canUndo = true;
|
|
63
72
|
historyStoreMock.canRedo = true;
|
|
73
|
+
historyStoreMock.isApplyingSnapshot = false;
|
|
64
74
|
historyStoreMock.undo = vi.fn();
|
|
65
75
|
historyStoreMock.redo = vi.fn();
|
|
66
76
|
aiStoreMock.isProcessing = false;
|
|
67
77
|
containerStoreMock.isSaving = false;
|
|
78
|
+
templateSettingsStoreMock.isBusy = false;
|
|
68
79
|
});
|
|
69
80
|
|
|
70
81
|
describe('UndoButton', () => {
|
|
@@ -80,6 +91,14 @@ describe('UndoButton', () => {
|
|
|
80
91
|
['canUndo is false', () => (historyStoreMock.canUndo = false)],
|
|
81
92
|
['AI is processing', () => (aiStoreMock.isProcessing = true)],
|
|
82
93
|
['save is in flight', () => (containerStoreMock.isSaving = true)],
|
|
94
|
+
[
|
|
95
|
+
'a restore is applying',
|
|
96
|
+
() => (historyStoreMock.isApplyingSnapshot = true),
|
|
97
|
+
],
|
|
98
|
+
[
|
|
99
|
+
'template previews are refetching',
|
|
100
|
+
() => (templateSettingsStoreMock.isBusy = true),
|
|
101
|
+
],
|
|
83
102
|
])('does not dispatch when %s', async (_label, applyGuard) => {
|
|
84
103
|
applyGuard();
|
|
85
104
|
|
|
@@ -104,6 +123,14 @@ describe('RedoButton', () => {
|
|
|
104
123
|
['canRedo is false', () => (historyStoreMock.canRedo = false)],
|
|
105
124
|
['AI is processing', () => (aiStoreMock.isProcessing = true)],
|
|
106
125
|
['save is in flight', () => (containerStoreMock.isSaving = true)],
|
|
126
|
+
[
|
|
127
|
+
'a restore is applying',
|
|
128
|
+
() => (historyStoreMock.isApplyingSnapshot = true),
|
|
129
|
+
],
|
|
130
|
+
[
|
|
131
|
+
'template previews are refetching',
|
|
132
|
+
() => (templateSettingsStoreMock.isBusy = true),
|
|
133
|
+
],
|
|
107
134
|
])('does not dispatch when %s', async (_label, applyGuard) => {
|
|
108
135
|
applyGuard();
|
|
109
136
|
|
|
@@ -50,6 +50,9 @@ import {
|
|
|
50
50
|
} from '@shared/content-settings/store';
|
|
51
51
|
import { useUiSideBarStore } from '@shared/side-bar/store';
|
|
52
52
|
import { useUiEditableLogoStore } from '@shared/logo-settings/store/editable-logo-store';
|
|
53
|
+
import { useUiLogoSettingsStore } from '@shared/logo-settings/store';
|
|
54
|
+
import { useLogoStore } from '@brand-page-maker/stores';
|
|
55
|
+
import { getLogoImageFromModel } from '@brand-page/utils/logo.util';
|
|
53
56
|
import { SIDE_BAR_TAB_NAMES } from '@brand-page-maker/constants';
|
|
54
57
|
import { PAGE_SECTIONS } from '@shared/page-settings/models';
|
|
55
58
|
import { FabricRenderClient } from '../../../clients/fabric-render-client';
|
|
@@ -988,4 +991,131 @@ describe('brand page maker store module', () => {
|
|
|
988
991
|
// Still 'Acme' — no restore attempted.
|
|
989
992
|
expect(editableLogoStore.logoEdits.textObjects[0].text).toBe('Acme');
|
|
990
993
|
});
|
|
994
|
+
|
|
995
|
+
// The template sidebar regenerates off the selectedLogoImage watcher, so
|
|
996
|
+
// the restore must hand it a READY preview key. For a designed logo
|
|
997
|
+
// (pre-purchase) that key is the s3 render token minted by the awaited
|
|
998
|
+
// fabric re-render — exposing selectedLogoImage BEFORE the render made the
|
|
999
|
+
// sidebar fetch a list keyed to the previous render, which repainted
|
|
1000
|
+
// seconds later (the undo "flash"). Same precondition that makes the BYO
|
|
1001
|
+
// upload flow work: the key exists before the watcher fires.
|
|
1002
|
+
test('_syncLogoSettingsFromSnapshot re-renders the designed logo BEFORE exposing selectedLogoImage', async () => {
|
|
1003
|
+
const makerStore = useBrandPageMakerStore();
|
|
1004
|
+
const containerStore = useBrandPageContainerStore();
|
|
1005
|
+
const editableLogoStore = useUiEditableLogoStore();
|
|
1006
|
+
const logoSettingsStore = useUiLogoSettingsStore();
|
|
1007
|
+
const logoStore = useLogoStore();
|
|
1008
|
+
|
|
1009
|
+
await makerStore.initialise(INITIALISE_PAYLOAD);
|
|
1010
|
+
|
|
1011
|
+
const originalStamp = editableLogoStore.stampLogoBase64IntoBrandPageData;
|
|
1012
|
+
const originalRender = editableLogoStore.updateEditableLogoImageUrlAsync;
|
|
1013
|
+
const originalSetSelected = logoSettingsStore.setSelectedLogoImage;
|
|
1014
|
+
|
|
1015
|
+
try {
|
|
1016
|
+
containerStore.isPrePurchase = true;
|
|
1017
|
+
logoStore.setLogoTemplateAsset({
|
|
1018
|
+
token: 'designed-template',
|
|
1019
|
+
isByo: false,
|
|
1020
|
+
} as never);
|
|
1021
|
+
logoStore.addLogoDraftAsset({
|
|
1022
|
+
logoDraftAsset: { token: 'designed-2', tick: 1, isByo: false },
|
|
1023
|
+
} as never);
|
|
1024
|
+
// Previous token differs from the snapshot's → not the
|
|
1025
|
+
// logoUnchanged short-circuit.
|
|
1026
|
+
editableLogoStore.setLogoDraftToken('designed-1');
|
|
1027
|
+
|
|
1028
|
+
const snapshot = JSON.parse(
|
|
1029
|
+
JSON.stringify(makerStore.brandPageData),
|
|
1030
|
+
) as BrandPageModel;
|
|
1031
|
+
const snapshotLogoImage = getLogoImageFromModel(snapshot);
|
|
1032
|
+
|
|
1033
|
+
expect(snapshotLogoImage).toBeTruthy();
|
|
1034
|
+
snapshotLogoImage!.logoDraftToken = 'designed-2';
|
|
1035
|
+
|
|
1036
|
+
const callOrder: string[] = [];
|
|
1037
|
+
|
|
1038
|
+
editableLogoStore.stampLogoBase64IntoBrandPageData = vi.fn();
|
|
1039
|
+
editableLogoStore.updateEditableLogoImageUrlAsync = vi.fn(async () => {
|
|
1040
|
+
// Async like the real fabric render — the order assertion below
|
|
1041
|
+
// fails if the sync doesn't await it before exposing the logo.
|
|
1042
|
+
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
1043
|
+
callOrder.push('render');
|
|
1044
|
+
});
|
|
1045
|
+
logoSettingsStore.setSelectedLogoImage = vi.fn(() => {
|
|
1046
|
+
callOrder.push('selectedLogoImage');
|
|
1047
|
+
});
|
|
1048
|
+
|
|
1049
|
+
await makerStore._syncLogoSettingsFromSnapshot(
|
|
1050
|
+
snapshot,
|
|
1051
|
+
'{"colors":["#123456"]}',
|
|
1052
|
+
);
|
|
1053
|
+
|
|
1054
|
+
expect(callOrder).toEqual(['render', 'selectedLogoImage']);
|
|
1055
|
+
expect(logoSettingsStore.setSelectedLogoImage).toHaveBeenCalledWith(
|
|
1056
|
+
expect.objectContaining({ logoDraftToken: 'designed-2' }),
|
|
1057
|
+
);
|
|
1058
|
+
} finally {
|
|
1059
|
+
containerStore.isPrePurchase = false;
|
|
1060
|
+
editableLogoStore.stampLogoBase64IntoBrandPageData = originalStamp;
|
|
1061
|
+
editableLogoStore.updateEditableLogoImageUrlAsync = originalRender;
|
|
1062
|
+
logoSettingsStore.setSelectedLogoImage = originalSetSelected;
|
|
1063
|
+
}
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
test('_syncLogoSettingsFromSnapshot still exposes selectedLogoImage on the BYO and token-less paths', async () => {
|
|
1067
|
+
const makerStore = useBrandPageMakerStore();
|
|
1068
|
+
const editableLogoStore = useUiEditableLogoStore();
|
|
1069
|
+
const logoSettingsStore = useUiLogoSettingsStore();
|
|
1070
|
+
const logoStore = useLogoStore();
|
|
1071
|
+
|
|
1072
|
+
await makerStore.initialise(INITIALISE_PAYLOAD);
|
|
1073
|
+
|
|
1074
|
+
const originalStamp = editableLogoStore.stampLogoBase64IntoBrandPageData;
|
|
1075
|
+
const originalSetSelected = logoSettingsStore.setSelectedLogoImage;
|
|
1076
|
+
|
|
1077
|
+
try {
|
|
1078
|
+
editableLogoStore.stampLogoBase64IntoBrandPageData = vi.fn();
|
|
1079
|
+
|
|
1080
|
+
const setSelectedSpy = vi.fn();
|
|
1081
|
+
|
|
1082
|
+
logoSettingsStore.setSelectedLogoImage = setSelectedSpy;
|
|
1083
|
+
|
|
1084
|
+
// BYO path: registered byo draft, differing previous token.
|
|
1085
|
+
logoStore.addLogoDraftAsset({
|
|
1086
|
+
logoDraftAsset: { token: 'byo-1', tick: 2, isByo: true },
|
|
1087
|
+
} as never);
|
|
1088
|
+
editableLogoStore.setLogoDraftToken('designed-1');
|
|
1089
|
+
|
|
1090
|
+
const byoSnapshot = JSON.parse(
|
|
1091
|
+
JSON.stringify(makerStore.brandPageData),
|
|
1092
|
+
) as BrandPageModel;
|
|
1093
|
+
const byoLogoImage = getLogoImageFromModel(byoSnapshot);
|
|
1094
|
+
|
|
1095
|
+
byoLogoImage!.logoDraftToken = 'byo-1';
|
|
1096
|
+
|
|
1097
|
+
await makerStore._syncLogoSettingsFromSnapshot(byoSnapshot);
|
|
1098
|
+
|
|
1099
|
+
expect(setSelectedSpy).toHaveBeenCalledWith(
|
|
1100
|
+
expect.objectContaining({ logoDraftToken: 'byo-1' }),
|
|
1101
|
+
);
|
|
1102
|
+
|
|
1103
|
+
// Token-less path: the logoImage exists but has no draft token.
|
|
1104
|
+
setSelectedSpy.mockClear();
|
|
1105
|
+
|
|
1106
|
+
const tokenlessSnapshot = JSON.parse(
|
|
1107
|
+
JSON.stringify(makerStore.brandPageData),
|
|
1108
|
+
) as BrandPageModel;
|
|
1109
|
+
const tokenlessLogoImage = getLogoImageFromModel(tokenlessSnapshot);
|
|
1110
|
+
|
|
1111
|
+
tokenlessLogoImage!.logoDraftToken = '';
|
|
1112
|
+
|
|
1113
|
+
await makerStore._syncLogoSettingsFromSnapshot(tokenlessSnapshot);
|
|
1114
|
+
|
|
1115
|
+
expect(setSelectedSpy).toHaveBeenCalledTimes(1);
|
|
1116
|
+
} finally {
|
|
1117
|
+
editableLogoStore.stampLogoBase64IntoBrandPageData = originalStamp;
|
|
1118
|
+
logoSettingsStore.setSelectedLogoImage = originalSetSelected;
|
|
1119
|
+
}
|
|
1120
|
+
});
|
|
991
1121
|
});
|
|
@@ -960,83 +960,92 @@ export const useBrandPageMakerStore = defineStore('brandPageMaker', {
|
|
|
960
960
|
|
|
961
961
|
if (!logoImage) return;
|
|
962
962
|
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
// existing object, so the templates store's deep watcher receives
|
|
966
|
-
// old === new (same reference), sees no logoDraftToken change, and
|
|
967
|
-
// never refetches the template previews. Post-purchase that watcher
|
|
968
|
-
// is the ONLY refetch path (previews are keyed by logoDraftToken and
|
|
969
|
-
// the designed-logo re-render below is pre-purchase-only), which left
|
|
970
|
-
// the sidebar stuck on the undone logo (BP-5814).
|
|
971
|
-
logoSettingsStore.setSelectedLogoImage({ ...logoImage });
|
|
972
|
-
|
|
973
|
-
const draftToken: string | undefined = logoImage.logoDraftToken;
|
|
963
|
+
try {
|
|
964
|
+
const draftToken: string | undefined = logoImage.logoDraftToken;
|
|
974
965
|
|
|
975
|
-
|
|
966
|
+
if (!draftToken) return;
|
|
976
967
|
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
968
|
+
// Re-derive enableEditMode + templateAsset from the draft registry
|
|
969
|
+
// (append-only for the session) instead of stashing on every entry.
|
|
970
|
+
const draft = logoStore.getLogoDraftAssetByToken(draftToken);
|
|
980
971
|
|
|
981
|
-
|
|
982
|
-
|
|
972
|
+
if (draft) {
|
|
973
|
+
editableLogoStore.setEnableEditMode(!draft.isByo);
|
|
983
974
|
|
|
984
|
-
|
|
985
|
-
|
|
975
|
+
if (draft.templateAsset) {
|
|
976
|
+
logoStore.setLogoTemplateAsset(draft.templateAsset);
|
|
977
|
+
}
|
|
986
978
|
}
|
|
987
|
-
}
|
|
988
979
|
|
|
989
|
-
|
|
980
|
+
editableLogoStore.setLogoDraftToken(draftToken);
|
|
990
981
|
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
982
|
+
// No logo change → cache is still correct; just stamp base64 back
|
|
983
|
+
// (snapshot doesn't carry logoImageBase64Url).
|
|
984
|
+
const logoUnchanged =
|
|
985
|
+
prevDraftToken === draftToken &&
|
|
986
|
+
(logoEditsStr === undefined || logoEditsStr === prevLogoEditsStr);
|
|
996
987
|
|
|
997
|
-
|
|
998
|
-
|
|
988
|
+
if (logoUnchanged) {
|
|
989
|
+
editableLogoStore.stampLogoBase64IntoBrandPageData();
|
|
999
990
|
|
|
1000
|
-
|
|
1001
|
-
|
|
991
|
+
return;
|
|
992
|
+
}
|
|
1002
993
|
|
|
1003
|
-
|
|
994
|
+
const isByo = !!draft?.isByo;
|
|
1004
995
|
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
996
|
+
if (isByo) {
|
|
997
|
+
// BYO mints a fresh draftToken per upload so the per-token
|
|
998
|
+
// cache always matches; no re-render needed.
|
|
999
|
+
if (editableLogoStore.restoreVariationsForDraftToken(draftToken)) {
|
|
1000
|
+
editableLogoStore.stampLogoBase64IntoBrandPageData();
|
|
1001
|
+
}
|
|
1011
1002
|
|
|
1012
|
-
|
|
1013
|
-
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1014
1005
|
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1006
|
+
// Designed: draft token is reused across edits, so the cache
|
|
1007
|
+
// only holds the latest render. Re-issue fabric-render — same
|
|
1008
|
+
// call as the live edit path. Awaited so concurrent undos
|
|
1009
|
+
// serialize and the call sits inside the isApplyingSnapshot
|
|
1010
|
+
// window (watcher guard suppresses the writeback).
|
|
1011
|
+
if (
|
|
1012
|
+
containerStore.isPrePurchase &&
|
|
1013
|
+
logoEditsStr &&
|
|
1014
|
+
logoStore.logoTemplateAsset
|
|
1015
|
+
) {
|
|
1016
|
+
// Stamp the cached (pre-undo) base64 onto brandPageData first
|
|
1017
|
+
// so the canvas keeps showing the previous logo while the
|
|
1018
|
+
// re-render is in flight. Without this the logoImageBase64Url
|
|
1019
|
+
// fields are undefined (snapshot is stripped pre-purchase) and
|
|
1020
|
+
// the canvas flickers blank for ~500-1500ms.
|
|
1021
|
+
editableLogoStore.stampLogoBase64IntoBrandPageData();
|
|
1031
1022
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1023
|
+
try {
|
|
1024
|
+
await editableLogoStore.updateEditableLogoImageUrlAsync();
|
|
1025
|
+
} catch (error) {
|
|
1026
|
+
console.error(
|
|
1027
|
+
'brandpage-maker-store: failed to re-render logo on history apply',
|
|
1028
|
+
error,
|
|
1029
|
+
);
|
|
1030
|
+
}
|
|
1039
1031
|
}
|
|
1032
|
+
} finally {
|
|
1033
|
+
// Assign a FRESH object — the same way the upload/selection flows
|
|
1034
|
+
// write selectedLogoImage. A setState/$patch here deep-merges into
|
|
1035
|
+
// the existing object, so the templates store's deep watcher would
|
|
1036
|
+
// receive old === new (same reference), see no logoDraftToken
|
|
1037
|
+
// change, and never refetch the template previews (BP-5814).
|
|
1038
|
+
//
|
|
1039
|
+
// Set LAST — after the branch above has prepared the preview key
|
|
1040
|
+
// (BYO cache restore, or the awaited designed re-render that mints
|
|
1041
|
+
// the s3 token) — so the watcher always fires with a fetchable key,
|
|
1042
|
+
// the same precondition that makes the upload flow work. Firing it
|
|
1043
|
+
// before the re-render fetched the template list with the previous
|
|
1044
|
+
// render's s3 token: an obsolete list that repainted seconds later
|
|
1045
|
+
// (the undo "flash"). The sidebar instead holds the previous
|
|
1046
|
+
// previews while the render is in flight, matching the canvas
|
|
1047
|
+
// policy above.
|
|
1048
|
+
logoSettingsStore.setSelectedLogoImage({ ...logoImage });
|
|
1040
1049
|
}
|
|
1041
1050
|
},
|
|
1042
1051
|
|
|
@@ -206,3 +206,106 @@ describe('content settings store — hydration writes arm the history merge', ()
|
|
|
206
206
|
expect(mergeNextMutationMock).not.toHaveBeenCalled();
|
|
207
207
|
});
|
|
208
208
|
});
|
|
209
|
+
|
|
210
|
+
// BP-5772: a value-identical re-write must be dropped whole. The assignment
|
|
211
|
+
// would be a reactive no-op, but onContentChanged() still flips
|
|
212
|
+
// isModifiedAfterTemplateApplied — and when that flag was false, its flip is
|
|
213
|
+
// itself a model mutation, so history commits an entry in which nothing
|
|
214
|
+
// user-visible changed. Live repro: with the logo visible, clicking the logo
|
|
215
|
+
// panel's "Upload" option re-emits { logoImage, logoTemplateImage,
|
|
216
|
+
// logoImageBase64Url, isLogoVisible } deep-equal to the model under fresh
|
|
217
|
+
// references (LogoSelectionField.onLogoVisibleChanged). Same class:
|
|
218
|
+
// re-clicking the already-active display style.
|
|
219
|
+
describe('content settings store — value-identical writes are dropped', () => {
|
|
220
|
+
const logoImage = () => ({
|
|
221
|
+
logoDraftToken: 'dae5cda0-602d-4c69-a26e-000000000001',
|
|
222
|
+
logoVariation: 0,
|
|
223
|
+
logoLastUpdate: '638000000000000000',
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
const makeItem = () => ({
|
|
227
|
+
id: 'item-1',
|
|
228
|
+
type: 'logo',
|
|
229
|
+
settings: {
|
|
230
|
+
content: {
|
|
231
|
+
title: '<p>old</p>',
|
|
232
|
+
isLogoVisible: true,
|
|
233
|
+
logoImage: logoImage(),
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
});
|
|
237
|
+
|
|
238
|
+
const setupStore = () => {
|
|
239
|
+
setActivePinia(createPinia());
|
|
240
|
+
|
|
241
|
+
const store = useUiContentSettingsStore();
|
|
242
|
+
|
|
243
|
+
store.items.push(makeItem() as never);
|
|
244
|
+
|
|
245
|
+
store.validateItem = vi.fn();
|
|
246
|
+
store.onContentChanged = vi.fn();
|
|
247
|
+
|
|
248
|
+
return store;
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
beforeEach(() => {
|
|
252
|
+
mergeNextMutationMock.mockClear();
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('updateMultipleItemValues drops the logo-panel Upload re-click payload', () => {
|
|
256
|
+
const store = setupStore();
|
|
257
|
+
|
|
258
|
+
// Deep-equal to the model but every reference is fresh — exactly what
|
|
259
|
+
// the Upload option click emits when visibility didn't change.
|
|
260
|
+
store.updateMultipleItemValues({
|
|
261
|
+
id: 'item-1',
|
|
262
|
+
name: 'logoImage',
|
|
263
|
+
value: { logoImage: logoImage(), isLogoVisible: true },
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
expect(store.onContentChanged).not.toHaveBeenCalled();
|
|
267
|
+
expect(store.validateItem).not.toHaveBeenCalled();
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('updateItemValue drops an identical primitive', () => {
|
|
271
|
+
const store = setupStore();
|
|
272
|
+
|
|
273
|
+
store.updateItemValue({
|
|
274
|
+
id: 'item-1',
|
|
275
|
+
name: 'title',
|
|
276
|
+
value: '<p>old</p>',
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
expect(store.onContentChanged).not.toHaveBeenCalled();
|
|
280
|
+
expect(store.validateItem).not.toHaveBeenCalled();
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('updateMultipleItemValues writes when any one field differs', () => {
|
|
284
|
+
const store = setupStore();
|
|
285
|
+
|
|
286
|
+
store.updateMultipleItemValues({
|
|
287
|
+
id: 'item-1',
|
|
288
|
+
name: 'logoImage',
|
|
289
|
+
value: { logoImage: logoImage(), isLogoVisible: false },
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
expect(store.onContentChanged).toHaveBeenCalledTimes(1);
|
|
293
|
+
expect(
|
|
294
|
+
(store.items[0].settings.content as { isLogoVisible?: boolean })
|
|
295
|
+
.isLogoVisible,
|
|
296
|
+
).toBe(false);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('a dropped write never arms the history merge, even when hydration-flagged', () => {
|
|
300
|
+
const store = setupStore();
|
|
301
|
+
|
|
302
|
+
store.updateItemValue({
|
|
303
|
+
id: 'item-1',
|
|
304
|
+
name: 'title',
|
|
305
|
+
value: '<p>old</p>',
|
|
306
|
+
isHydrationWrite: true,
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
expect(mergeNextMutationMock).not.toHaveBeenCalled();
|
|
310
|
+
});
|
|
311
|
+
});
|
|
@@ -60,6 +60,19 @@ const DEFAULT_TEXT_COLOR_DEPENDENT_TYPES: readonly ContentType[] = [
|
|
|
60
60
|
'text',
|
|
61
61
|
] as const;
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* True when the incoming value is byte-identical (as JSON, matching how
|
|
65
|
+
* history snapshots the model) to what the model already holds. Such
|
|
66
|
+
* re-writes must be dropped whole: the assignment would be a reactive
|
|
67
|
+
* no-op, but onContentChanged() still flips isModifiedAfterTemplateApplied
|
|
68
|
+
* — and when that flag was false, its flip is itself a model mutation, so
|
|
69
|
+
* undo history commits an entry in which nothing user-visible changed
|
|
70
|
+
* (phantom undo state, BP-5772: re-clicking the logo panel's Upload/Logo
|
|
71
|
+
* option, re-clicking an already-active display style).
|
|
72
|
+
*/
|
|
73
|
+
const isSameValue = (a: unknown, b: unknown): boolean =>
|
|
74
|
+
a === b || JSON.stringify(a) === JSON.stringify(b);
|
|
75
|
+
|
|
63
76
|
export const CONTENT_SETTINGS_MODES = {
|
|
64
77
|
edit: 'edit',
|
|
65
78
|
add: 'add',
|
|
@@ -390,6 +403,10 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
|
|
|
390
403
|
return;
|
|
391
404
|
}
|
|
392
405
|
|
|
406
|
+
if (isSameValue(item.settings.content[payload.name], payload.value)) {
|
|
407
|
+
return; // no-op write: don't validate, don't flip the modified flag
|
|
408
|
+
}
|
|
409
|
+
|
|
393
410
|
// Hydration write-backs (RTE canonical HTML on mount, lazy config
|
|
394
411
|
// hydration, stale-id clears) fold into the current undo head instead
|
|
395
412
|
// of pushing an entry. Arming here — same tick as the write — is what
|
|
@@ -421,6 +438,17 @@ export const useUiContentSettingsStore = defineStore('uiContentSettings', {
|
|
|
421
438
|
return;
|
|
422
439
|
}
|
|
423
440
|
|
|
441
|
+
if (
|
|
442
|
+
Object.keys(value ?? {}).every((k) =>
|
|
443
|
+
isSameValue(
|
|
444
|
+
item.settings.content[k],
|
|
445
|
+
(value as Record<string, unknown>)[k],
|
|
446
|
+
),
|
|
447
|
+
)
|
|
448
|
+
) {
|
|
449
|
+
return; // every field a no-op — see isSameValue
|
|
450
|
+
}
|
|
451
|
+
|
|
424
452
|
// Same hydration-write contract as updateItemValue.
|
|
425
453
|
if (isHydrationWrite) {
|
|
426
454
|
useHistoryStore().mergeNextMutation();
|