@griddo/ax 12.7.0 → 12.8.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/package.json +2 -2
- package/src/GlobalStore.tsx +3 -1
- package/src/__tests__/components/ConfigPanel/GlobalPageForm/GlobalPageForm.test.tsx +7 -3
- package/src/__tests__/components/Fields/Wysiwyg/Wysiwyg.style.test.tsx +44 -0
- package/src/__tests__/components/Fields/Wysiwyg/Wysiwyg.test.tsx +109 -0
- package/src/__tests__/components/Nav/Nav.test.tsx +75 -2
- package/src/__tests__/components/ResizePanel/ResizePanel.test.tsx +50 -21
- package/src/__tests__/components/Toast/Toast.test.tsx +37 -1
- package/src/__tests__/hooks/broadcast.test.tsx +263 -0
- package/src/api/utils.tsx +7 -6
- package/src/components/Browser/index.tsx +10 -2
- package/src/components/ConfigPanel/GlobalPageForm/index.tsx +3 -4
- package/src/components/Fields/Wysiwyg/atoms.tsx +1 -1
- package/src/components/Fields/Wysiwyg/index.tsx +28 -0
- package/src/components/Fields/Wysiwyg/style.tsx +41 -1
- package/src/components/MainWrapper/index.tsx +14 -2
- package/src/components/Nav/index.tsx +21 -6
- package/src/components/ResizePanel/index.tsx +8 -3
- package/src/components/Toast/index.tsx +4 -1
- package/src/containers/ActivityLog/actions.tsx +4 -7
- package/src/containers/PageEditor/actions.tsx +14 -3
- package/src/helpers/containerEvaluations.tsx +8 -0
- package/src/hooks/broadcast.ts +160 -0
- package/src/hooks/index.tsx +5 -0
- package/src/modules/ActivityLog/index.tsx +1 -0
- package/src/modules/Analytics/index.tsx +1 -1
- package/src/modules/App/index.tsx +21 -6
- package/src/modules/Content/PageItem/index.tsx +36 -19
- package/src/modules/Content/index.tsx +5 -7
- package/src/modules/FileDrive/FileModal/DetailPanel/UsageContent/index.tsx +5 -14
- package/src/modules/FileDrive/index.tsx +1 -1
- package/src/modules/Forms/FormUseModal/index.tsx +3 -8
- package/src/modules/GlobalEditor/atoms.tsx +35 -1
- package/src/modules/GlobalEditor/index.tsx +169 -51
- package/src/modules/GlobalSettings/Robots/index.tsx +1 -1
- package/src/modules/MediaGallery/ImageModal/DetailPanel/UsageContent/index.tsx +6 -15
- package/src/modules/MediaGallery/index.tsx +1 -1
- package/src/modules/PageEditor/atoms.tsx +35 -1
- package/src/modules/PageEditor/index.tsx +168 -48
- package/src/modules/Redirects/index.tsx +1 -0
- package/src/modules/Settings/Languages/index.tsx +1 -1
- package/src/modules/Settings/SeoAnalyticsSettings/Analytics/index.tsx +1 -0
- package/src/modules/Settings/Social/index.tsx +1 -1
- package/src/modules/StructuredData/Form/index.tsx +108 -10
- package/src/modules/StructuredData/StructuredDataList/GlobalPageItem/index.tsx +31 -11
- package/src/modules/StructuredData/StructuredDataList/StructuredDataItem/index.tsx +17 -3
- package/src/modules/StructuredData/StructuredDataList/index.tsx +6 -4
- package/src/modules/StructuredData/atoms.tsx +35 -1
- package/src/routes/multisite.tsx +34 -18
- package/src/routes/site.tsx +40 -21
- package/src/storeRegistry.ts +5 -0
- package/src/modules/StructuredData/index.tsx +0 -18
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
import { act, renderHook } from "@testing-library/react";
|
|
2
|
+
import "@testing-library/jest-dom";
|
|
3
|
+
|
|
4
|
+
import type * as React from "react";
|
|
5
|
+
import { Provider } from "react-redux";
|
|
6
|
+
|
|
7
|
+
import { SET_COPY_MODULE } from "@ax/containers/PageEditor/constants";
|
|
8
|
+
import { useBroadcastChannel, useBroadcastClipboard, useBroadcastContentUpdate, useBroadcastLogout } from "@ax/hooks";
|
|
9
|
+
|
|
10
|
+
import configureStore from "redux-mock-store";
|
|
11
|
+
|
|
12
|
+
// --- Controllable BroadcastChannel mock -------------------------------------
|
|
13
|
+
// jsdom has no BroadcastChannel and Node's implementation delivers asynchronously
|
|
14
|
+
// and keeps the event loop alive. This in-memory mock delivers synchronously and
|
|
15
|
+
// mirrors the real contract: a message reaches every OTHER open channel with the
|
|
16
|
+
// same name, never the sender itself.
|
|
17
|
+
type Listener = (event: { data: any }) => void;
|
|
18
|
+
|
|
19
|
+
class MockBroadcastChannel {
|
|
20
|
+
static registry = new Map<string, Set<MockBroadcastChannel>>();
|
|
21
|
+
name: string;
|
|
22
|
+
onmessage: Listener | null = null;
|
|
23
|
+
private listeners = new Set<Listener>();
|
|
24
|
+
private closed = false;
|
|
25
|
+
|
|
26
|
+
constructor(name: string) {
|
|
27
|
+
this.name = name;
|
|
28
|
+
const peers = MockBroadcastChannel.registry.get(name) ?? new Set<MockBroadcastChannel>();
|
|
29
|
+
peers.add(this);
|
|
30
|
+
MockBroadcastChannel.registry.set(name, peers);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
postMessage(data: any) {
|
|
34
|
+
const peers = MockBroadcastChannel.registry.get(this.name);
|
|
35
|
+
if (!peers) return;
|
|
36
|
+
for (const peer of peers) {
|
|
37
|
+
if (peer === this || peer.closed) continue;
|
|
38
|
+
const event = { data };
|
|
39
|
+
peer.onmessage?.(event);
|
|
40
|
+
peer.listeners.forEach((listener) => {
|
|
41
|
+
listener(event);
|
|
42
|
+
});
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
addEventListener(type: string, listener: Listener) {
|
|
47
|
+
if (type === "message") this.listeners.add(listener);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
removeEventListener(type: string, listener: Listener) {
|
|
51
|
+
if (type === "message") this.listeners.delete(listener);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
close() {
|
|
55
|
+
this.closed = true;
|
|
56
|
+
MockBroadcastChannel.registry.get(this.name)?.delete(this);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
static reset() {
|
|
60
|
+
MockBroadcastChannel.registry.clear();
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const originalBroadcastChannel = globalThis.BroadcastChannel;
|
|
65
|
+
|
|
66
|
+
beforeAll(() => {
|
|
67
|
+
globalThis.BroadcastChannel = MockBroadcastChannel as unknown as typeof BroadcastChannel;
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
afterAll(() => {
|
|
71
|
+
globalThis.BroadcastChannel = originalBroadcastChannel;
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
beforeEach(() => {
|
|
75
|
+
MockBroadcastChannel.reset();
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
describe("useBroadcastChannel", () => {
|
|
79
|
+
it("posts messages (stamped with a timestamp) to other channels of the same name", () => {
|
|
80
|
+
const received: any[] = [];
|
|
81
|
+
const peer = new BroadcastChannel("test-channel");
|
|
82
|
+
peer.addEventListener("message", (e) => received.push(e.data));
|
|
83
|
+
|
|
84
|
+
const { result } = renderHook(() => useBroadcastChannel("test-channel", vi.fn()));
|
|
85
|
+
|
|
86
|
+
act(() => {
|
|
87
|
+
result.current.postMessage({ type: "ping" });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(received).toHaveLength(1);
|
|
91
|
+
expect(received[0]).toMatchObject({ type: "ping", timestamp: expect.any(Number) });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("invokes onMessage when a peer posts to the channel", () => {
|
|
95
|
+
const onMessage = vi.fn();
|
|
96
|
+
renderHook(() => useBroadcastChannel("test-channel", onMessage));
|
|
97
|
+
|
|
98
|
+
const peer = new BroadcastChannel("test-channel");
|
|
99
|
+
act(() => {
|
|
100
|
+
peer.postMessage({ type: "pong" });
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
expect(onMessage).toHaveBeenCalledWith({ type: "pong" });
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("uses the latest onMessage after a re-render without re-subscribing", () => {
|
|
107
|
+
const first = vi.fn();
|
|
108
|
+
const second = vi.fn();
|
|
109
|
+
const { rerender } = renderHook(({ cb }) => useBroadcastChannel("test-channel", cb), {
|
|
110
|
+
initialProps: { cb: first },
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
rerender({ cb: second });
|
|
114
|
+
|
|
115
|
+
const peer = new BroadcastChannel("test-channel");
|
|
116
|
+
act(() => {
|
|
117
|
+
peer.postMessage({ type: "update" });
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
expect(first).not.toHaveBeenCalled();
|
|
121
|
+
expect(second).toHaveBeenCalledWith({ type: "update" });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("[UC-AX-work-across-tabs] ciclo de vida del canal", () => {
|
|
126
|
+
it("AC9 · una pestaña desmontada deja de recibir avisos", () => {
|
|
127
|
+
const onMessage = vi.fn();
|
|
128
|
+
const { unmount } = renderHook(() => useBroadcastChannel("test-channel", onMessage));
|
|
129
|
+
|
|
130
|
+
unmount();
|
|
131
|
+
|
|
132
|
+
const peer = new BroadcastChannel("test-channel");
|
|
133
|
+
act(() => {
|
|
134
|
+
peer.postMessage({ type: "late" });
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
expect(onMessage).not.toHaveBeenCalled();
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
describe("[UC-AX-work-across-tabs] aviso de contenido guardado", () => {
|
|
142
|
+
it("AC1 · guardar avisa a las demás pestañas con el identificador del contenido", () => {
|
|
143
|
+
const received: any[] = [];
|
|
144
|
+
const peer = new BroadcastChannel("griddo-page-updates");
|
|
145
|
+
peer.addEventListener("message", (e) => received.push(e.data));
|
|
146
|
+
|
|
147
|
+
const { result } = renderHook(() => useBroadcastContentUpdate(10));
|
|
148
|
+
|
|
149
|
+
act(() => {
|
|
150
|
+
result.current.broadcastPageSave(10);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(received[0]).toMatchObject({ type: "page-saved", payload: { contentID: 10 } });
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("AC2 · la pestaña con ese mismo contenido abierto anota el aviso y puede descartarlo", () => {
|
|
157
|
+
const { result } = renderHook(() => useBroadcastContentUpdate(10));
|
|
158
|
+
const peer = new BroadcastChannel("griddo-page-updates");
|
|
159
|
+
|
|
160
|
+
act(() => {
|
|
161
|
+
peer.postMessage({ type: "page-saved", payload: { contentID: 10 } });
|
|
162
|
+
});
|
|
163
|
+
expect(result.current.remotePageUpdate).toEqual({ contentID: 10 });
|
|
164
|
+
|
|
165
|
+
act(() => {
|
|
166
|
+
result.current.clearRemoteUpdate();
|
|
167
|
+
});
|
|
168
|
+
expect(result.current.remotePageUpdate).toBeNull();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("AC3 · un aviso sobre otro contenido distinto no anota nada", () => {
|
|
172
|
+
const { result } = renderHook(() => useBroadcastContentUpdate(10));
|
|
173
|
+
const peer = new BroadcastChannel("griddo-page-updates");
|
|
174
|
+
|
|
175
|
+
act(() => {
|
|
176
|
+
peer.postMessage({ type: "page-saved", payload: { contentID: 99 } });
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
expect(result.current.remotePageUpdate).toBeNull();
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
describe("[UC-AX-work-across-tabs] cierre de sesión entre pestañas", () => {
|
|
184
|
+
it("AC4 · cerrar sesión en una pestaña se anuncia a las demás", () => {
|
|
185
|
+
const received: any[] = [];
|
|
186
|
+
const peer = new BroadcastChannel("griddo-logout");
|
|
187
|
+
peer.addEventListener("message", (e) => received.push(e.data));
|
|
188
|
+
|
|
189
|
+
const { result } = renderHook(() => useBroadcastLogout(vi.fn()));
|
|
190
|
+
|
|
191
|
+
act(() => {
|
|
192
|
+
result.current.broadcastLogout();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
expect(received[0]).toMatchObject({ type: "user-logout" });
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it("AC5 · la pestaña que recibe el anuncio cierra su sesión", () => {
|
|
199
|
+
const onRemoteLogout = vi.fn();
|
|
200
|
+
renderHook(() => useBroadcastLogout(onRemoteLogout));
|
|
201
|
+
|
|
202
|
+
const peer = new BroadcastChannel("griddo-logout");
|
|
203
|
+
act(() => {
|
|
204
|
+
peer.postMessage({ type: "user-logout" });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
expect(onRemoteLogout).toHaveBeenCalledTimes(1);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
describe("[UC-AX-work-across-tabs] portapapeles compartido entre pestañas", () => {
|
|
212
|
+
const mockStore = configureStore([]);
|
|
213
|
+
const wrapperWith = (state: any) => {
|
|
214
|
+
const store = mockStore(state);
|
|
215
|
+
const wrapper = ({ children }: { children: React.ReactNode }) => <Provider store={store}>{children}</Provider>;
|
|
216
|
+
return { store, wrapper };
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
it("AC6 · un módulo copiado en otra pestaña queda disponible para pegar en esta", () => {
|
|
220
|
+
const { store, wrapper } = wrapperWith({ pageEditor: { moduleCopy: null } });
|
|
221
|
+
renderHook(() => useBroadcastClipboard(), { wrapper });
|
|
222
|
+
|
|
223
|
+
const peer = new BroadcastChannel("griddo-clipboard");
|
|
224
|
+
act(() => {
|
|
225
|
+
peer.postMessage({
|
|
226
|
+
type: "module-copied",
|
|
227
|
+
senderId: "another-tab",
|
|
228
|
+
payload: { id: 1, date: "2026-07-02T00:00:00.000Z" },
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
const actions = store.getActions();
|
|
233
|
+
expect(actions).toHaveLength(1);
|
|
234
|
+
expect(actions[0].type).toBe(SET_COPY_MODULE);
|
|
235
|
+
expect(actions[0].payload.moduleCopy).toMatchObject({ id: 1 });
|
|
236
|
+
expect(actions[0].payload.moduleCopy.date).toBeInstanceOf(Date);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
it("AC7 · un mensaje que no sea de copia de módulo se ignora", () => {
|
|
240
|
+
const { store, wrapper } = wrapperWith({ pageEditor: { moduleCopy: null } });
|
|
241
|
+
renderHook(() => useBroadcastClipboard(), { wrapper });
|
|
242
|
+
|
|
243
|
+
const peer = new BroadcastChannel("griddo-clipboard");
|
|
244
|
+
act(() => {
|
|
245
|
+
peer.postMessage({ type: "something-else", payload: {} });
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
expect(store.getActions()).toHaveLength(0);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("AC8 · al montarse, una pestaña anuncia lo que ya tiene en el portapapeles", () => {
|
|
252
|
+
const moduleCopy = { id: 7, date: new Date("2026-07-02T00:00:00.000Z") };
|
|
253
|
+
const received: any[] = [];
|
|
254
|
+
const peer = new BroadcastChannel("griddo-clipboard");
|
|
255
|
+
peer.addEventListener("message", (e) => received.push(e.data));
|
|
256
|
+
|
|
257
|
+
const { wrapper } = wrapperWith({ pageEditor: { moduleCopy } });
|
|
258
|
+
renderHook(() => useBroadcastClipboard(), { wrapper });
|
|
259
|
+
|
|
260
|
+
expect(received).toHaveLength(1);
|
|
261
|
+
expect(received[0]).toMatchObject({ type: "module-copied", senderId: expect.any(String) });
|
|
262
|
+
});
|
|
263
|
+
});
|
package/src/api/utils.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import axios, { type AxiosRequestConfig, type AxiosResponse, type Method, type ResponseType } from "axios";
|
|
2
2
|
import { SET_ERROR } from "./../containers/App/constants";
|
|
3
|
+
import { getStore } from "../storeRegistry";
|
|
3
4
|
|
|
4
5
|
export interface IServiceConfig {
|
|
5
6
|
host: string | undefined;
|
|
@@ -24,15 +25,15 @@ const getToken = (): Record<string, unknown> | null => {
|
|
|
24
25
|
};
|
|
25
26
|
|
|
26
27
|
const getLang = (): Record<string, unknown> => {
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
return { lang };
|
|
28
|
+
const store = getStore();
|
|
29
|
+
if (store) return { lang: store.getState()?.app?.lang?.id ?? null };
|
|
30
|
+
return { lang: JSON.parse(localStorage.getItem("langID") || "{}") };
|
|
30
31
|
};
|
|
31
32
|
|
|
32
33
|
const getSite = (): Record<string, unknown> => {
|
|
33
|
-
const
|
|
34
|
-
|
|
35
|
-
return { site };
|
|
34
|
+
const store = getStore();
|
|
35
|
+
if (store) return { site: store.getState()?.sites?.currentSiteInfo?.id ?? null };
|
|
36
|
+
return { site: JSON.parse(localStorage.getItem("siteID") || "{}") };
|
|
36
37
|
};
|
|
37
38
|
|
|
38
39
|
const getHeaders = (headers: Record<string, unknown>, hasToken: boolean) => {
|
|
@@ -81,6 +81,14 @@ const Browser = (props: IBrowserProps): JSX.Element => {
|
|
|
81
81
|
|
|
82
82
|
useOnMessageReceivedFromIframe(actions);
|
|
83
83
|
|
|
84
|
+
const handleIframeLoad = (e: React.SyntheticEvent<HTMLIFrameElement>) => {
|
|
85
|
+
setIsIframeLoading(false);
|
|
86
|
+
const iframeWindow = (e.target as HTMLIFrameElement)?.contentWindow;
|
|
87
|
+
if (iframeWindow && (content as any)?.template) {
|
|
88
|
+
iframeWindow.postMessage({ type: "content-update", message: content }, "*");
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
84
92
|
useEffect(() => {
|
|
85
93
|
localStorage.setItem("selectedID", "0");
|
|
86
94
|
(window as any).browserRef = null;
|
|
@@ -326,7 +334,7 @@ const Browser = (props: IBrowserProps): JSX.Element => {
|
|
|
326
334
|
src={urlPreview}
|
|
327
335
|
loading="lazy"
|
|
328
336
|
className="frame-content"
|
|
329
|
-
onLoad={
|
|
337
|
+
onLoad={handleIframeLoad}
|
|
330
338
|
style={{ visibility: isIframeLoading ? "hidden" : "visible" }}
|
|
331
339
|
/>
|
|
332
340
|
) : (
|
|
@@ -344,7 +352,7 @@ const Browser = (props: IBrowserProps): JSX.Element => {
|
|
|
344
352
|
src={urlPreview}
|
|
345
353
|
loading="lazy"
|
|
346
354
|
className="frame-content"
|
|
347
|
-
onLoad={
|
|
355
|
+
onLoad={handleIframeLoad}
|
|
348
356
|
style={{
|
|
349
357
|
display: "block",
|
|
350
358
|
transform: isZoomEditor ? `scale(${parseInt(dimensions.zoom) / 100})` : "scale(1)",
|
|
@@ -34,10 +34,9 @@ const GlobalPageForm = (props: IGlobalPageFormProps): JSX.Element => {
|
|
|
34
34
|
const handleClick = async () => {
|
|
35
35
|
if (isAllowedToEditGlobalData) {
|
|
36
36
|
const navigate = async () => {
|
|
37
|
-
await actions.getGlobalFromLocalPageAction();
|
|
38
|
-
const path = "/data/pages/editor";
|
|
39
|
-
setHistoryPush?.(path, true);
|
|
37
|
+
const pageID = await actions.getGlobalFromLocalPageAction();
|
|
40
38
|
actions.saveCurrentSiteInfoAction();
|
|
39
|
+
setHistoryPush?.(`/data/pages/editor/${pageID}`, true);
|
|
41
40
|
};
|
|
42
41
|
|
|
43
42
|
if (isDirty && onNavigateWithDirty) {
|
|
@@ -195,7 +194,7 @@ export interface IGlobalPageFormProps {
|
|
|
195
194
|
isDirty?: boolean;
|
|
196
195
|
onNavigateWithDirty?: (navigateCallback: () => void) => void;
|
|
197
196
|
actions: {
|
|
198
|
-
getGlobalFromLocalPageAction(): Promise<
|
|
197
|
+
getGlobalFromLocalPageAction(): Promise<number | null>;
|
|
199
198
|
saveCurrentSiteInfoAction(): Promise<void>;
|
|
200
199
|
restorePageNavigationAction(type: string): Promise<void>;
|
|
201
200
|
setNotificationAction(notification: INotification): void;
|
|
@@ -86,7 +86,7 @@ const LinkPopover = (props: ILinkPopoverProps) => {
|
|
|
86
86
|
return createPortal(
|
|
87
87
|
<>
|
|
88
88
|
<S.PopoverOverlay onClick={handleOverlayClick} />
|
|
89
|
-
<S.PopoverContainer ref={popoverRef} x={position.x} y={position.y}>
|
|
89
|
+
<S.PopoverContainer ref={popoverRef} x={position.x} y={position.y} data-testid="link-popover">
|
|
90
90
|
{!linkData?.isImageLink && (
|
|
91
91
|
<FieldsBehavior
|
|
92
92
|
title="Text"
|
|
@@ -286,6 +286,33 @@ const Wysiwyg = (props: IWysiwygProps) => {
|
|
|
286
286
|
});
|
|
287
287
|
}
|
|
288
288
|
},
|
|
289
|
+
// Froala's fullscreen stashes the editor's z-index in `opts.z_index` but
|
|
290
|
+
// reads the option back from `opts.zIndex`, so leaving it restores
|
|
291
|
+
// undefined. Any popup opened while in fullscreen pins the toolbar at
|
|
292
|
+
// Froala's own 2147483642, and with the option gone nothing ever clears
|
|
293
|
+
// it: the toolbar then paints over every modal the field opens. Seed the
|
|
294
|
+
// key the plugin reads, and drop what it left inline on the way out.
|
|
295
|
+
"commands.before": function (this: any, cmd: string) {
|
|
296
|
+
if (cmd === "fullscreen" && !this.fullscreen.isActive()) {
|
|
297
|
+
this.opts.z_index = this.opts.zIndex;
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
"commands.after": function (this: any, cmd: string) {
|
|
301
|
+
if (cmd === "fullscreen" && !this.fullscreen.isActive()) {
|
|
302
|
+
this.$tb.css("z-index", "");
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
// Froala's shortcut dispatcher calls plugin commands unbound, so its own
|
|
306
|
+
// Ctrl/Cmd+K throws instead of opening anything. Returning false cancels
|
|
307
|
+
// it; commands.exec runs ours with the editor bound.
|
|
308
|
+
shortcut: function (this: any, e: KeyboardEvent, cmd: string) {
|
|
309
|
+
if (cmd !== "insertLink") return;
|
|
310
|
+
e.preventDefault();
|
|
311
|
+
if (!inline) {
|
|
312
|
+
this.commands.exec("insertLinkGriddo");
|
|
313
|
+
}
|
|
314
|
+
return false;
|
|
315
|
+
},
|
|
289
316
|
blur: function (this: any) {
|
|
290
317
|
const html = this.html.get();
|
|
291
318
|
const stripedHtml = decodeEntities(html);
|
|
@@ -318,6 +345,7 @@ const Wysiwyg = (props: IWysiwygProps) => {
|
|
|
318
345
|
|
|
319
346
|
return (
|
|
320
347
|
<>
|
|
348
|
+
<S.FullscreenGlobalStyle />
|
|
321
349
|
<S.EditorWrapper error={error} disabled={disabled} data-testid="wysiwyg-wrapper">
|
|
322
350
|
<FroalaEditorComponent tag="textarea" model={value} config={config} onModelChange={handleChange} />
|
|
323
351
|
</S.EditorWrapper>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Wrapper } from "@ax/components/FieldsBehavior/style";
|
|
2
|
+
import { Wrapper as FloatingPanelWrapper } from "@ax/components/FloatingPanel/style";
|
|
2
3
|
|
|
3
|
-
import styled from "styled-components";
|
|
4
|
+
import styled, { createGlobalStyle } from "styled-components";
|
|
4
5
|
|
|
5
6
|
export const PopoverOverlay = styled.div`
|
|
6
7
|
position: fixed;
|
|
@@ -193,3 +194,42 @@ export const EditorWrapper = styled.div<{ error: boolean | undefined; disabled?:
|
|
|
193
194
|
}
|
|
194
195
|
}
|
|
195
196
|
`;
|
|
197
|
+
|
|
198
|
+
// Froala's fullscreen raises the editor and every ancestor up to <body> to
|
|
199
|
+
// z-index 2147483640, which buries everything the field portals into <body>.
|
|
200
|
+
// Bring the editor back into the app's scale and lift the overlays it can open
|
|
201
|
+
// over it. It has to be global because both the ancestors it marks and the
|
|
202
|
+
// portals live outside this file's tree, and scoped to `body.fr-fullscreen` so
|
|
203
|
+
// nothing moves while the editor is inline.
|
|
204
|
+
//
|
|
205
|
+
// The editor goes over the app chrome and under the overlay layer (Modal), and
|
|
206
|
+
// the overlays it can open ride over it: the link popover, and the page finder
|
|
207
|
+
// panel that the popover opens on top of itself. The galleries need nothing,
|
|
208
|
+
// Modal already sits above. Exported because these only hold as long as the
|
|
209
|
+
// components they stack against keep their own values.
|
|
210
|
+
export const FULLSCREEN_LAYER = {
|
|
211
|
+
editor: 1210,
|
|
212
|
+
popoverOverlay: 1220,
|
|
213
|
+
popover: 1230,
|
|
214
|
+
panel: 1235,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export const FullscreenGlobalStyle = createGlobalStyle`
|
|
218
|
+
body.fr-fullscreen {
|
|
219
|
+
/* An ancestor panel that *contains* the editor keeps the editor's own value
|
|
220
|
+
here, because Froala marks it as a wrapper too and !important wins. */
|
|
221
|
+
.fr-fullscreen-wrapper {
|
|
222
|
+
z-index: ${FULLSCREEN_LAYER.editor} !important;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
${PopoverOverlay} {
|
|
226
|
+
z-index: ${FULLSCREEN_LAYER.popoverOverlay};
|
|
227
|
+
}
|
|
228
|
+
${PopoverContainer} {
|
|
229
|
+
z-index: ${FULLSCREEN_LAYER.popover};
|
|
230
|
+
}
|
|
231
|
+
${FloatingPanelWrapper} {
|
|
232
|
+
z-index: ${FULLSCREEN_LAYER.panel};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
`;
|
|
@@ -6,12 +6,12 @@ import AppBar, { type IAppBarProps } from "./AppBar";
|
|
|
6
6
|
import * as S from "./style";
|
|
7
7
|
|
|
8
8
|
const MainWrapper = (props: IWrapperProps): JSX.Element => {
|
|
9
|
-
const { children, fixedAppBar, fullWidth, hasAnimation } = props;
|
|
9
|
+
const { children, fixedAppBar, fullWidth, hasAnimation, screen } = props;
|
|
10
10
|
|
|
11
11
|
const { isOnline } = useNetworkStatus();
|
|
12
12
|
|
|
13
13
|
return (
|
|
14
|
-
<S.Wrapper fixedAppBar={fixedAppBar} data-testid="main-wrapper">
|
|
14
|
+
<S.Wrapper fixedAppBar={fixedAppBar} data-testid="main-wrapper" data-screen={screen}>
|
|
15
15
|
{hasAnimation && <S.BackgroundAnimation />}
|
|
16
16
|
<AppBar {...props} />
|
|
17
17
|
<S.Main fullWidth={fullWidth} data-testid="main-component" id="main-component">
|
|
@@ -30,6 +30,18 @@ const MainWrapper = (props: IWrapperProps): JSX.Element => {
|
|
|
30
30
|
export interface IWrapperProps extends IAppBarProps {
|
|
31
31
|
children?: any[] | JSX.Element;
|
|
32
32
|
fullWidth?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Stable per-screen identifier for e2e, emitted as `data-screen`. Separate from
|
|
35
|
+
* `data-testid="main-wrapper"`, which is the same on all 32 screens and is already
|
|
36
|
+
* consumed by tests here and in griddo-qa's `identifiers.ts`.
|
|
37
|
+
*
|
|
38
|
+
* It exists because nothing else identifies a screen: `title` is display copy and
|
|
39
|
+
* is not even unique — Robots and Redirects are both "SEO Settings", and the global
|
|
40
|
+
* and the site Analytics screens are both "Analytics Settings". The e2e specs had
|
|
41
|
+
* to assert on `location.pathname` with exact equality, and `/settings/analytics`
|
|
42
|
+
* is a substring of `/sites/settings/analytics`.
|
|
43
|
+
*/
|
|
44
|
+
screen?: string;
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
export default MainWrapper;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { NavLink } from "react-router-dom";
|
|
3
2
|
|
|
4
3
|
import { SubNav, MenuItem } from "@ax/components";
|
|
5
4
|
import type { INavItem } from "@ax/types";
|
|
@@ -18,13 +17,29 @@ const Nav = (props: INavProps): JSX.Element => {
|
|
|
18
17
|
const selectedClass = isSelected ? "selected" : "";
|
|
19
18
|
const handleClick = () => onClick(item.path);
|
|
20
19
|
|
|
20
|
+
// No `NavLink` wrapping the label, on purpose (sc-118192). It used to be here with
|
|
21
|
+
// `to="#"`, which made every click navigate TWICE: the link's transition fires first —
|
|
22
|
+
// it is the inner element, so its handler runs first as the event bubbles, and by the
|
|
23
|
+
// time `MenuItem` calls `preventDefault` react-router has already read
|
|
24
|
+
// `defaultPrevented` as false and pushed — and then the real one from `onClick`.
|
|
25
|
+
//
|
|
26
|
+
// On a clean screen that left a spurious history entry before every real one. On a
|
|
27
|
+
// screen with unsaved changes it was worse: a `RouteLeavingGuard`'s `<Prompt>`
|
|
28
|
+
// intercepted both transitions and its handler toggles the modal
|
|
29
|
+
// (`hooks/modals.tsx:6-8`), so it opened and shut in the same tick — the click did
|
|
30
|
+
// nothing at all, no navigation and no warning, on the five settings screens that
|
|
31
|
+
// mount both.
|
|
32
|
+
//
|
|
33
|
+
// Nothing was lost by dropping it: `S.Link` is a `styled.div` that only reads `active`,
|
|
34
|
+
// no style hangs off the anchor, and `MenuItem` is already the focusable,
|
|
35
|
+
// keyboard-operable element (`role="button"`, `tabIndex={0}`, Enter handled) — so the
|
|
36
|
+
// `<a>` was a nested interactive element inside a button, which is worse semantics
|
|
37
|
+
// rather than better.
|
|
21
38
|
const menuItem = (
|
|
22
39
|
<MenuItem onClick={handleClick} key={key} className={selectedClass}>
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
</S.Link>
|
|
27
|
-
</NavLink>
|
|
40
|
+
<S.Link active={isSelected} data-testid="nav-link">
|
|
41
|
+
{item.title}
|
|
42
|
+
</S.Link>
|
|
28
43
|
</MenuItem>
|
|
29
44
|
);
|
|
30
45
|
|
|
@@ -31,9 +31,14 @@ const ResizePanel = (props: IResizePanelProps): JSX.Element => {
|
|
|
31
31
|
|
|
32
32
|
const updateWidth = () => {
|
|
33
33
|
frame = requestAnimationFrame(() => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
const panel = rightPanelRef.current;
|
|
35
|
+
// Froala's fullscreen tags every ancestor of the editor with
|
|
36
|
+
// .fr-fullscreen-wrapper, and its `width: 100% !important` lands on this
|
|
37
|
+
// panel too. Reading it while that class is on would store the width the
|
|
38
|
+
// editor borrowed as the panel's own, and the panel would keep it once
|
|
39
|
+
// fullscreen is closed.
|
|
40
|
+
if (!panel || panel.classList.contains("fr-fullscreen-wrapper")) return;
|
|
41
|
+
setRwidth(panel.offsetWidth);
|
|
37
42
|
});
|
|
38
43
|
};
|
|
39
44
|
|
|
@@ -11,7 +11,10 @@ const Toast = (props: IToastProps): JSX.Element => {
|
|
|
11
11
|
const toast = useRef<HTMLDivElement>(null);
|
|
12
12
|
|
|
13
13
|
const { pathname } = useLocation();
|
|
14
|
-
|
|
14
|
+
// Structured data items live under `/data/:id/content[/:contentId]`, page editors
|
|
15
|
+
// under `/editor`. Matched as a whole segment so `/settings/content-types` doesn't
|
|
16
|
+
// count as an editor.
|
|
17
|
+
const isEditor = /\/(editor|content)(\/|$)/.test(pathname);
|
|
15
18
|
|
|
16
19
|
let temp: NodeJS.Timeout;
|
|
17
20
|
const setTemp = (time: number) => {
|
|
@@ -13,7 +13,6 @@ import type {
|
|
|
13
13
|
import { logs } from "@ax/api";
|
|
14
14
|
import { appActions } from "@ax/containers/App";
|
|
15
15
|
import { usersActions } from "@ax/containers/Users";
|
|
16
|
-
import { pageEditorActions } from "@ax/containers/PageEditor";
|
|
17
16
|
import { sitesActions } from "@ax/containers/Sites";
|
|
18
17
|
import { integrationsActions } from "@ax/containers/Integrations";
|
|
19
18
|
import { formsActions } from "@ax/containers/Forms";
|
|
@@ -228,13 +227,11 @@ function goToLogContent(
|
|
|
228
227
|
case "template":
|
|
229
228
|
if (site) {
|
|
230
229
|
await sitesActions.getSite(site)(dispatch, getState);
|
|
231
|
-
dispatch(pageEditorActions.setCurrentPageID(contentType.id));
|
|
232
230
|
dispatch(appActions.setLanguage(language));
|
|
233
|
-
appActions.setHistoryPush(
|
|
231
|
+
appActions.setHistoryPush(`/sites/pages/editor/${contentType.id}`, true)(dispatch);
|
|
234
232
|
} else {
|
|
235
|
-
dispatch(pageEditorActions.setCurrentPageID(contentType.id));
|
|
236
233
|
dispatch(appActions.setLanguage(language));
|
|
237
|
-
appActions.setHistoryPush(
|
|
234
|
+
appActions.setHistoryPush(`/data/pages/editor/${contentType.id}`, true)(dispatch);
|
|
238
235
|
}
|
|
239
236
|
break;
|
|
240
237
|
case "structuredData":
|
|
@@ -245,13 +242,13 @@ function goToLogContent(
|
|
|
245
242
|
dispatch(structuredDataActions.setCurrentDataID(contentType.id));
|
|
246
243
|
await structuredDataActions.getDataContent(contentType.id)(dispatch);
|
|
247
244
|
dispatch(appActions.setLanguage(language));
|
|
248
|
-
appActions.setHistoryPush(`/sites/data/${contentType.content.id}/
|
|
245
|
+
appActions.setHistoryPush(`/sites/data/${contentType.content.id}/content`)(dispatch);
|
|
249
246
|
} else {
|
|
250
247
|
structuredDataActions.setSelectedStructuredData(contentType.content.id, "global")(dispatch, getState);
|
|
251
248
|
dispatch(structuredDataActions.setCurrentDataID(contentType.id));
|
|
252
249
|
await structuredDataActions.getDataContent(contentType.id)(dispatch);
|
|
253
250
|
dispatch(appActions.setLanguage(language));
|
|
254
|
-
appActions.setHistoryPush(`/data/${contentType.content.id}/
|
|
251
|
+
appActions.setHistoryPush(`/data/${contentType.content.id}/content`)(dispatch);
|
|
255
252
|
}
|
|
256
253
|
}
|
|
257
254
|
break;
|
|
@@ -338,6 +338,10 @@ function getPage(
|
|
|
338
338
|
return async (dispatch, getState) => {
|
|
339
339
|
try {
|
|
340
340
|
dispatch(setIsLoading(true));
|
|
341
|
+
|
|
342
|
+
const savedCopy = localStorage.getItem("pageCopyModule");
|
|
343
|
+
if (savedCopy) dispatch(setCopyModule(JSON.parse(savedCopy)));
|
|
344
|
+
|
|
341
345
|
const {
|
|
342
346
|
sites: { currentSiteInfo },
|
|
343
347
|
pageEditor: { isNewTranslation, template },
|
|
@@ -688,15 +692,18 @@ function getPageLanguages(
|
|
|
688
692
|
};
|
|
689
693
|
}
|
|
690
694
|
|
|
691
|
-
function duplicatePage(pageID: number, data: any, siteID?: number): (dispatch: Dispatch) => Promise<
|
|
695
|
+
function duplicatePage(pageID: number, data: any, siteID?: number): (dispatch: Dispatch) => Promise<number | false> {
|
|
692
696
|
return async (dispatch) => {
|
|
693
697
|
try {
|
|
698
|
+
let duplicatedId: number | null = null;
|
|
699
|
+
|
|
694
700
|
const responseActions = {
|
|
695
701
|
handleSuccess: (data: any) => {
|
|
696
702
|
if (!siteID) {
|
|
697
703
|
dispatch(setCurrentPageID(data.id));
|
|
698
704
|
dispatch(setCurrentPageStatus(pageStatus.OFFLINE));
|
|
699
705
|
}
|
|
706
|
+
duplicatedId = data.id;
|
|
700
707
|
return true;
|
|
701
708
|
},
|
|
702
709
|
handleError: (response: any) => {
|
|
@@ -706,8 +713,9 @@ function duplicatePage(pageID: number, data: any, siteID?: number): (dispatch: D
|
|
|
706
713
|
};
|
|
707
714
|
|
|
708
715
|
const callback = async () => pages.duplicatePage(pageID, data, siteID);
|
|
716
|
+
const success = await handleRequest(callback, responseActions, [appActions.setIsSaving])(dispatch);
|
|
709
717
|
|
|
710
|
-
return
|
|
718
|
+
return success && duplicatedId !== null ? duplicatedId : false;
|
|
711
719
|
} catch (e) {
|
|
712
720
|
console.log(e);
|
|
713
721
|
return false;
|
|
@@ -1037,6 +1045,7 @@ function copyModule(editorID: number[]): (dispatch: Dispatch, getState: () => IR
|
|
|
1037
1045
|
};
|
|
1038
1046
|
|
|
1039
1047
|
dispatch(setCopyModule(payload));
|
|
1048
|
+
localStorage.setItem("pageCopyModule", JSON.stringify(payload));
|
|
1040
1049
|
|
|
1041
1050
|
return payload.elements.length;
|
|
1042
1051
|
} else {
|
|
@@ -1441,7 +1450,7 @@ function deleteError(error: IErrorItem): (dispatch: Dispatch, getState: () => IR
|
|
|
1441
1450
|
};
|
|
1442
1451
|
}
|
|
1443
1452
|
|
|
1444
|
-
function getGlobalFromLocalPage(): (dispatch: Dispatch, getState: any) => Promise<
|
|
1453
|
+
function getGlobalFromLocalPage(): (dispatch: Dispatch, getState: any) => Promise<number | null> {
|
|
1445
1454
|
return async (dispatch, getState) => {
|
|
1446
1455
|
try {
|
|
1447
1456
|
dispatch(setIsLoading(true));
|
|
@@ -1457,8 +1466,10 @@ function getGlobalFromLocalPage(): (dispatch: Dispatch, getState: any) => Promis
|
|
|
1457
1466
|
structuredDataActions.setSelectedStructuredData(structuredData, "global")(dispatch, getState);
|
|
1458
1467
|
usersActions.getUserCurrentPermissions("global")(dispatch, getState);
|
|
1459
1468
|
dispatch(setIsLoading(false));
|
|
1469
|
+
return originalGlobalPage as number;
|
|
1460
1470
|
} catch (e) {
|
|
1461
1471
|
console.log(e);
|
|
1472
|
+
return null;
|
|
1462
1473
|
}
|
|
1463
1474
|
};
|
|
1464
1475
|
}
|