@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.
Files changed (52) hide show
  1. package/package.json +2 -2
  2. package/src/GlobalStore.tsx +3 -1
  3. package/src/__tests__/components/ConfigPanel/GlobalPageForm/GlobalPageForm.test.tsx +7 -3
  4. package/src/__tests__/components/Fields/Wysiwyg/Wysiwyg.style.test.tsx +44 -0
  5. package/src/__tests__/components/Fields/Wysiwyg/Wysiwyg.test.tsx +109 -0
  6. package/src/__tests__/components/Nav/Nav.test.tsx +75 -2
  7. package/src/__tests__/components/ResizePanel/ResizePanel.test.tsx +50 -21
  8. package/src/__tests__/components/Toast/Toast.test.tsx +37 -1
  9. package/src/__tests__/hooks/broadcast.test.tsx +263 -0
  10. package/src/api/utils.tsx +7 -6
  11. package/src/components/Browser/index.tsx +10 -2
  12. package/src/components/ConfigPanel/GlobalPageForm/index.tsx +3 -4
  13. package/src/components/Fields/Wysiwyg/atoms.tsx +1 -1
  14. package/src/components/Fields/Wysiwyg/index.tsx +28 -0
  15. package/src/components/Fields/Wysiwyg/style.tsx +41 -1
  16. package/src/components/MainWrapper/index.tsx +14 -2
  17. package/src/components/Nav/index.tsx +21 -6
  18. package/src/components/ResizePanel/index.tsx +8 -3
  19. package/src/components/Toast/index.tsx +4 -1
  20. package/src/containers/ActivityLog/actions.tsx +4 -7
  21. package/src/containers/PageEditor/actions.tsx +14 -3
  22. package/src/helpers/containerEvaluations.tsx +8 -0
  23. package/src/hooks/broadcast.ts +160 -0
  24. package/src/hooks/index.tsx +5 -0
  25. package/src/modules/ActivityLog/index.tsx +1 -0
  26. package/src/modules/Analytics/index.tsx +1 -1
  27. package/src/modules/App/index.tsx +21 -6
  28. package/src/modules/Content/PageItem/index.tsx +36 -19
  29. package/src/modules/Content/index.tsx +5 -7
  30. package/src/modules/FileDrive/FileModal/DetailPanel/UsageContent/index.tsx +5 -14
  31. package/src/modules/FileDrive/index.tsx +1 -1
  32. package/src/modules/Forms/FormUseModal/index.tsx +3 -8
  33. package/src/modules/GlobalEditor/atoms.tsx +35 -1
  34. package/src/modules/GlobalEditor/index.tsx +169 -51
  35. package/src/modules/GlobalSettings/Robots/index.tsx +1 -1
  36. package/src/modules/MediaGallery/ImageModal/DetailPanel/UsageContent/index.tsx +6 -15
  37. package/src/modules/MediaGallery/index.tsx +1 -1
  38. package/src/modules/PageEditor/atoms.tsx +35 -1
  39. package/src/modules/PageEditor/index.tsx +168 -48
  40. package/src/modules/Redirects/index.tsx +1 -0
  41. package/src/modules/Settings/Languages/index.tsx +1 -1
  42. package/src/modules/Settings/SeoAnalyticsSettings/Analytics/index.tsx +1 -0
  43. package/src/modules/Settings/Social/index.tsx +1 -1
  44. package/src/modules/StructuredData/Form/index.tsx +108 -10
  45. package/src/modules/StructuredData/StructuredDataList/GlobalPageItem/index.tsx +31 -11
  46. package/src/modules/StructuredData/StructuredDataList/StructuredDataItem/index.tsx +17 -3
  47. package/src/modules/StructuredData/StructuredDataList/index.tsx +6 -4
  48. package/src/modules/StructuredData/atoms.tsx +35 -1
  49. package/src/routes/multisite.tsx +34 -18
  50. package/src/routes/site.tsx +40 -21
  51. package/src/storeRegistry.ts +5 -0
  52. 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 lang = JSON.parse(localStorage.getItem("langID") || "{}");
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 site = JSON.parse(localStorage.getItem("siteID") || "{}");
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={() => setIsIframeLoading(false)}
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={() => setIsIframeLoading(false)}
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<void>;
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
- <NavLink to="#">
24
- <S.Link active={isSelected} data-testid="nav-link">
25
- {item.title}
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
- if (rightPanelRef.current) {
35
- setRwidth(rightPanelRef.current.offsetWidth);
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
- const isEditor = pathname.includes("/editor");
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("/sites/pages/editor", true)(dispatch);
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("/data/pages/editor", true)(dispatch);
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}/editor`)(dispatch);
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}/editor`)(dispatch);
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<boolean> {
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 await handleRequest(callback, responseActions, [appActions.setIsSaving])(dispatch);
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<void> {
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
  }