@griddo/ax 12.7.1 → 12.8.1

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 (53) 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/__tests__/modules/GlobalSettings/Robots/Robots.uc.test.tsx +401 -0
  11. package/src/api/utils.tsx +7 -6
  12. package/src/components/Browser/index.tsx +10 -2
  13. package/src/components/ConfigPanel/GlobalPageForm/index.tsx +3 -4
  14. package/src/components/Fields/Wysiwyg/atoms.tsx +1 -1
  15. package/src/components/Fields/Wysiwyg/index.tsx +28 -0
  16. package/src/components/Fields/Wysiwyg/style.tsx +41 -1
  17. package/src/components/MainWrapper/index.tsx +14 -2
  18. package/src/components/Nav/index.tsx +21 -6
  19. package/src/components/ResizePanel/index.tsx +8 -3
  20. package/src/components/Toast/index.tsx +4 -1
  21. package/src/containers/ActivityLog/actions.tsx +4 -7
  22. package/src/containers/PageEditor/actions.tsx +14 -3
  23. package/src/helpers/containerEvaluations.tsx +8 -0
  24. package/src/hooks/broadcast.ts +160 -0
  25. package/src/hooks/index.tsx +5 -0
  26. package/src/modules/ActivityLog/index.tsx +1 -0
  27. package/src/modules/Analytics/index.tsx +1 -1
  28. package/src/modules/App/index.tsx +21 -6
  29. package/src/modules/Content/PageItem/index.tsx +36 -19
  30. package/src/modules/Content/index.tsx +5 -7
  31. package/src/modules/FileDrive/FileModal/DetailPanel/UsageContent/index.tsx +5 -14
  32. package/src/modules/FileDrive/index.tsx +1 -1
  33. package/src/modules/Forms/FormUseModal/index.tsx +3 -8
  34. package/src/modules/GlobalEditor/atoms.tsx +35 -1
  35. package/src/modules/GlobalEditor/index.tsx +169 -51
  36. package/src/modules/GlobalSettings/Robots/index.tsx +52 -12
  37. package/src/modules/MediaGallery/ImageModal/DetailPanel/UsageContent/index.tsx +6 -15
  38. package/src/modules/MediaGallery/index.tsx +1 -1
  39. package/src/modules/PageEditor/atoms.tsx +35 -1
  40. package/src/modules/PageEditor/index.tsx +168 -48
  41. package/src/modules/Redirects/index.tsx +1 -0
  42. package/src/modules/Settings/Languages/index.tsx +1 -1
  43. package/src/modules/Settings/SeoAnalyticsSettings/Analytics/index.tsx +1 -0
  44. package/src/modules/Settings/Social/index.tsx +1 -1
  45. package/src/modules/StructuredData/Form/index.tsx +108 -10
  46. package/src/modules/StructuredData/StructuredDataList/GlobalPageItem/index.tsx +31 -11
  47. package/src/modules/StructuredData/StructuredDataList/StructuredDataItem/index.tsx +17 -3
  48. package/src/modules/StructuredData/StructuredDataList/index.tsx +6 -4
  49. package/src/modules/StructuredData/atoms.tsx +35 -1
  50. package/src/routes/multisite.tsx +34 -18
  51. package/src/routes/site.tsx +40 -21
  52. package/src/storeRegistry.ts +5 -0
  53. package/src/modules/StructuredData/index.tsx +0 -18
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@griddo/ax",
3
3
  "description": "Griddo Author Experience",
4
- "version": "12.7.1",
4
+ "version": "12.8.1",
5
5
  "authors": [
6
6
  "Álvaro Sánchez' <alvaro.sanches@secuoyas.com>",
7
7
  "Diego M. Béjar <diego.bejar@secuoyas.com>",
@@ -201,5 +201,5 @@
201
201
  "publishConfig": {
202
202
  "access": "public"
203
203
  },
204
- "gitHead": "1cb1a5a34c3445d33631135712a85e8dd80fa2be"
204
+ "gitHead": "f035f7339bf7550addec2f02e9c9257cd3de5ccc"
205
205
  }
@@ -1,7 +1,8 @@
1
1
  import { createStore, type Reducer, combineReducers, applyMiddleware, type Action, type StoreEnhancer } from "redux";
2
+ import { connectRouter, routerMiddleware } from "connected-react-router";
2
3
  import { persistReducer, persistStore, type PersistConfig, type Persistor } from "redux-persist";
3
4
  import storage from "redux-persist/lib/storage"; // defaults to localStorage for web
4
- import { connectRouter, routerMiddleware } from "connected-react-router";
5
+ import { setStore } from "./storeRegistry";
5
6
  import { composeWithDevTools } from "redux-devtools-extension";
6
7
  import thunk from "redux-thunk";
7
8
 
@@ -94,6 +95,7 @@ export class GlobalStore {
94
95
  middleware = composeWithDevTools(middleware);
95
96
  }
96
97
  const store = createStore(persistReducer(this.persistConfig, rootReducer), middleware);
98
+ setStore(store);
97
99
  const persistor: Persistor = persistStore(store);
98
100
  return { store, persistor };
99
101
  }
@@ -9,13 +9,13 @@ import GlobalPageForm, { type IGlobalPageFormProps } from "@ax/components/Config
9
9
  import { parseTheme } from "@ax/helpers";
10
10
  import globalTheme from "@ax/themes/theme.json";
11
11
 
12
- import thunk from "redux-thunk";
13
-
14
12
  vi.mock("@ax/hooks", async () => ({
15
13
  ...(await vi.importActual("@ax/hooks")),
16
14
  useGlobalPermission: vi.fn(() => true),
17
15
  }));
18
16
 
17
+ import thunk from "redux-thunk";
18
+
19
19
  beforeEach(() => {
20
20
  cleanup();
21
21
  });
@@ -173,10 +173,12 @@ describe("GlobalPageForm component rendering", () => {
173
173
  describe("GlobalPageForm component rendering", () => {
174
174
  it("should trigger handleClick", async () => {
175
175
  defaultProps.selectedTab = "content";
176
- const getGlobalFromLocalPageActionMock = vi.fn(() => Promise.resolve());
176
+ const getGlobalFromLocalPageActionMock = vi.fn(() => Promise.resolve(42));
177
177
  const saveCurrentSiteInfoActionMock = vi.fn();
178
+ const setHistoryPushMock = vi.fn();
178
179
  defaultProps.actions.saveCurrentSiteInfoAction = saveCurrentSiteInfoActionMock;
179
180
  defaultProps.actions.getGlobalFromLocalPageAction = getGlobalFromLocalPageActionMock;
181
+ defaultProps.setHistoryPush = setHistoryPushMock;
180
182
 
181
183
  render(
182
184
  <ThemeProvider theme={parseTheme(globalTheme)}>
@@ -194,6 +196,8 @@ describe("GlobalPageForm component rendering", () => {
194
196
  expect(getGlobalFromLocalPageActionMock).toBeCalled();
195
197
  expect(saveCurrentSiteInfoActionMock).toBeCalled();
196
198
  });
199
+ // sc-111850: navigates to the page the action returns, not to the shared editor path
200
+ expect(setHistoryPushMock).toHaveBeenCalledWith("/data/pages/editor/42", true);
197
201
  });
198
202
 
199
203
  it("should trigger handleRestoreHeader", () => {
@@ -0,0 +1,44 @@
1
+ import { FULLSCREEN_LAYER } from "@ax/components/Fields/Wysiwyg/style";
2
+ import { Wrapper as FloatingPanelWrapper } from "@ax/components/FloatingPanel/style";
3
+ import { ModalOverlay } from "@ax/components/Modal/style";
4
+ import { parseTheme } from "@ax/helpers";
5
+ import globalTheme from "@ax/themes/theme.json";
6
+
7
+ import { render } from "@testing-library/react";
8
+ import { ThemeProvider } from "styled-components";
9
+
10
+ // Froala's fullscreen buries everything the field portals into <body>, so the
11
+ // field re-stacks that layer by hand (FullscreenGlobalStyle). Those numbers only
12
+ // hold as long as the components it stacks against keep their own, and nothing
13
+ // else would catch that drift: the rule lives in a createGlobalStyle, which
14
+ // injects nothing under vitest, and jsdom models no stacking contexts either.
15
+ // So this reads the real z-index out of those components and checks the layer
16
+ // still lines up. The behaviour itself is covered by the e2e spec.
17
+ const zIndexOf = (Component: any, props: Record<string, unknown> = {}) => {
18
+ const { container } = render(
19
+ <ThemeProvider theme={parseTheme(globalTheme)}>
20
+ <Component {...props} />
21
+ </ThemeProvider>,
22
+ );
23
+ const element = container.firstElementChild as HTMLElement;
24
+
25
+ return Number(getComputedStyle(element).zIndex);
26
+ };
27
+
28
+ describe("Wysiwyg fullscreen layer", () => {
29
+ it("should keep the whole layer under the modals the field opens", () => {
30
+ const layer = Object.values(FULLSCREEN_LAYER);
31
+
32
+ expect(Math.max(...layer)).toBeLessThan(zIndexOf(ModalOverlay));
33
+ });
34
+
35
+ it("should lift the link popover and the page finder panel over the editor", () => {
36
+ expect(FULLSCREEN_LAYER.popoverOverlay).toBeGreaterThan(FULLSCREEN_LAYER.editor);
37
+ expect(FULLSCREEN_LAYER.popover).toBeGreaterThan(FULLSCREEN_LAYER.popoverOverlay);
38
+ expect(FULLSCREEN_LAYER.panel).toBeGreaterThan(FULLSCREEN_LAYER.popover);
39
+ });
40
+
41
+ it("should need the panel override, whose own z-index sits under the editor", () => {
42
+ expect(zIndexOf(FloatingPanelWrapper, { isOpen: true })).toBeLessThan(FULLSCREEN_LAYER.editor);
43
+ });
44
+ });
@@ -19,6 +19,10 @@ vi.mock("react-froala-wysiwyg", () => ({
19
19
  $el: { get: vi.fn(() => editorElRef.current!) },
20
20
  el: editorElRef.current,
21
21
  edit: { on: vi.fn(), off: vi.fn() },
22
+ commands: { exec: vi.fn() },
23
+ opts: { zIndex: 1 },
24
+ $tb: { css: vi.fn() },
25
+ fullscreen: { isActive: vi.fn(() => false) },
22
26
  link: { insert: vi.fn() },
23
27
  image: { insert: vi.fn(), get: vi.fn(() => null) },
24
28
  html: { get: vi.fn(() => props.model) },
@@ -32,6 +36,7 @@ vi.mock("react-froala-wysiwyg", () => ({
32
36
  };
33
37
  props.config.events.initialized.call(mockEditor);
34
38
  (window as any).__froalaEditorInstance = mockEditor;
39
+ (window as any).__froalaEditorConfig = props.config;
35
40
  }
36
41
  }, [props]);
37
42
 
@@ -51,6 +56,7 @@ afterEach(() => {
51
56
  cleanup();
52
57
  vi.resetAllMocks();
53
58
  delete (window as any).__froalaEditorInstance;
59
+ delete (window as any).__froalaEditorConfig;
54
60
  });
55
61
 
56
62
  const initialStore = {
@@ -843,3 +849,106 @@ describe("Wysiwyg event listeners - link and gallery operations", () => {
843
849
  // linkData can have: isImageLink, isEditing, url, newTab, noFollow, linkPageId
844
850
  });
845
851
  });
852
+
853
+ describe("Wysiwyg keyboard shortcuts", () => {
854
+ const renderWysiwyg = (overrides?: Partial<IWysiwygProps>) =>
855
+ render(
856
+ <ThemeProvider theme={parseTheme(globalTheme)}>
857
+ <Wysiwyg {...createMockProps(overrides)} />
858
+ </ThemeProvider>,
859
+ { store },
860
+ );
861
+
862
+ const triggerShortcut = (cmd: string) => {
863
+ const editor = (window as any).__froalaEditorInstance;
864
+ const event = { preventDefault: vi.fn() } as unknown as KeyboardEvent;
865
+ const cancelled = (window as any).__froalaEditorConfig.events.shortcut.call(editor, event, cmd) === false;
866
+
867
+ return { editor, event, cancelled };
868
+ };
869
+
870
+ it("should open the Griddo link popover on Ctrl/Cmd+K instead of Froala's own command", () => {
871
+ renderWysiwyg({ full: true });
872
+
873
+ const { editor, event, cancelled } = triggerShortcut("insertLink");
874
+
875
+ expect(editor.commands.exec).toHaveBeenCalledWith("insertLinkGriddo");
876
+ expect(event.preventDefault).toHaveBeenCalled();
877
+ // Froala calls plugin commands unbound, so its own insertLink has to be cancelled
878
+ expect(cancelled).toBe(true);
879
+ });
880
+
881
+ it("should leave the rest of the shortcuts to Froala", () => {
882
+ renderWysiwyg({ full: true });
883
+
884
+ const { editor, event, cancelled } = triggerShortcut("bold");
885
+
886
+ expect(editor.commands.exec).not.toHaveBeenCalled();
887
+ expect(event.preventDefault).not.toHaveBeenCalled();
888
+ expect(cancelled).toBe(false);
889
+ });
890
+
891
+ it("should not insert links in inline fields, whose toolbar has no link button", () => {
892
+ renderWysiwyg({ inline: true });
893
+
894
+ const { editor, cancelled } = triggerShortcut("insertLink");
895
+
896
+ expect(editor.commands.exec).not.toHaveBeenCalled();
897
+ expect(cancelled).toBe(true);
898
+ });
899
+ });
900
+
901
+ describe("Wysiwyg fullscreen", () => {
902
+ const renderWysiwyg = (overrides?: Partial<IWysiwygProps>) =>
903
+ render(
904
+ <ThemeProvider theme={parseTheme(globalTheme)}>
905
+ <Wysiwyg {...createMockProps(overrides)} />
906
+ </ThemeProvider>,
907
+ { store },
908
+ );
909
+
910
+ const events = () => (window as any).__froalaEditorConfig.events;
911
+
912
+ it("should hand Froala the key it restores the editor z-index from", () => {
913
+ renderWysiwyg({ full: true });
914
+ const editor = (window as any).__froalaEditorInstance;
915
+
916
+ events()["commands.before"].call(editor, "fullscreen");
917
+
918
+ expect(editor.opts.z_index).toBe(editor.opts.zIndex);
919
+ });
920
+
921
+ it("should drop the z-index Froala pinned on the toolbar when leaving fullscreen", () => {
922
+ renderWysiwyg({ full: true });
923
+ const editor = (window as any).__froalaEditorInstance;
924
+
925
+ events()["commands.after"].call(editor, "fullscreen");
926
+
927
+ expect(editor.$tb.css).toHaveBeenCalledWith("z-index", "");
928
+ });
929
+
930
+ it("should keep its hands off while fullscreen is on", () => {
931
+ renderWysiwyg({ full: true });
932
+ const editor = (window as any).__froalaEditorInstance;
933
+ editor.fullscreen.isActive.mockReturnValue(true);
934
+
935
+ // Entering: the toolbar has to stay over the editor. Leaving: seeding here
936
+ // would save Froala's own 2147483641 as the value to restore.
937
+ events()["commands.after"].call(editor, "fullscreen");
938
+ events()["commands.before"].call(editor, "fullscreen");
939
+
940
+ expect(editor.opts.z_index).toBeUndefined();
941
+ expect(editor.$tb.css).not.toHaveBeenCalled();
942
+ });
943
+
944
+ it("should leave the rest of the commands alone", () => {
945
+ renderWysiwyg({ full: true });
946
+ const editor = (window as any).__froalaEditorInstance;
947
+
948
+ events()["commands.before"].call(editor, "bold");
949
+ events()["commands.after"].call(editor, "bold");
950
+
951
+ expect(editor.opts.z_index).toBeUndefined();
952
+ expect(editor.$tb.css).not.toHaveBeenCalled();
953
+ });
954
+ });
@@ -1,8 +1,9 @@
1
1
  import * as React from "react";
2
- import { BrowserRouter } from "react-router-dom";
2
+ import { BrowserRouter, Prompt, Router } from "react-router-dom";
3
3
 
4
4
  import { ThemeProvider } from "styled-components";
5
- import { render, cleanup, screen } from "@testing-library/react";
5
+ import { render, cleanup, fireEvent, screen } from "@testing-library/react";
6
+ import { createMemoryHistory } from "history";
6
7
  import { parseTheme } from "@ax/helpers";
7
8
  import "@testing-library/jest-dom";
8
9
 
@@ -54,3 +55,75 @@ describe("Nav component rendering", () => {
54
55
  expect(links).toHaveLength(0);
55
56
  });
56
57
  });
58
+
59
+ /**
60
+ * How many times ONE click navigates (sc-118192).
61
+ *
62
+ * The nav used to wrap its label in a `<NavLink to="#">` inside the `<MenuItem onClick>`, so a
63
+ * single click fired two transitions: the link's first — it is the inner element, so its
64
+ * handler runs first as the event bubbles — and then the real one from `onClick`. `MenuItem`
65
+ * does call `preventDefault`, but by then react-router's Link has already read
66
+ * `defaultPrevented` as false and pushed.
67
+ *
68
+ * Two symptoms, and the second is the one a user reports:
69
+ *
70
+ * - on a clean screen both transitions run, so every click leaves a spurious history entry
71
+ * before the real one and the browser's Back button misbehaves;
72
+ * - on a screen with unsaved changes, a `RouteLeavingGuard`'s `<Prompt>` intercepts BOTH,
73
+ * and its handler calls a real toggle (`hooks/modals.tsx:6-8`), so the modal opens and
74
+ * closes in the same tick. Nothing navigates and nothing warns: the click does nothing at
75
+ * all.
76
+ *
77
+ * Found by running the QA suite's robots spec, whose positive control exercises exactly this
78
+ * (the jsdom tests of the screens never saw it — they drive navigation directly rather than
79
+ * through the nav).
80
+ */
81
+ describe("Nav navigation", () => {
82
+ const items = [
83
+ { title: "Item 1", path: "/item1", component: "Item1" },
84
+ { title: "Item 2", path: "/item2", component: "Item2" },
85
+ ];
86
+
87
+ const renderNav = (history: ReturnType<typeof createMemoryHistory>, guard = false) =>
88
+ render(
89
+ <Router history={history}>
90
+ <ThemeProvider theme={parseTheme(globalTheme)}>
91
+ {/* Stands in for a screen with unsaved changes: `RouteLeavingGuard` blocks the
92
+ same way, by returning false from the Prompt's message handler. */}
93
+ {guard && <Prompt when={true} message={() => false} />}
94
+ <Nav items={items} current={items[0]} onClick={(path: string) => history.push(path)} />
95
+ </ThemeProvider>
96
+ </Router>,
97
+ );
98
+
99
+ it("navigates once per click, leaving no spurious history entry", () => {
100
+ const history = createMemoryHistory({ initialEntries: ["/item1"] });
101
+ renderNav(history);
102
+
103
+ fireEvent.click(screen.getByText("Item 2"));
104
+
105
+ expect(history.location.pathname).toBe("/item2");
106
+ // The whole point: 2 would mean the extra `to="#"` transition went out first.
107
+ expect(history.entries).toHaveLength(2);
108
+ });
109
+
110
+ it("fires a single blocked transition when the screen has unsaved changes", () => {
111
+ const history = createMemoryHistory({ initialEntries: ["/item1"] });
112
+ const blocked = vi.fn(() => false);
113
+ render(
114
+ <Router history={history}>
115
+ <ThemeProvider theme={parseTheme(globalTheme)}>
116
+ <Prompt when={true} message={blocked as never} />
117
+ <Nav items={items} current={items[0]} onClick={(path: string) => history.push(path)} />
118
+ </ThemeProvider>
119
+ </Router>,
120
+ );
121
+
122
+ fireEvent.click(screen.getByText("Item 2"));
123
+
124
+ // Called twice is what made the guard's modal toggle open and straight back shut, so the
125
+ // user got no warning and no navigation.
126
+ expect(blocked).toHaveBeenCalledTimes(1);
127
+ expect(history.location.pathname).toBe("/item1");
128
+ });
129
+ });
@@ -1,5 +1,4 @@
1
- import type { MockedFunction } from "vitest";
2
- import React, { useRef } from "react";
1
+ import React from "react";
3
2
 
4
3
  import { ThemeProvider } from "styled-components";
5
4
  import { render, cleanup, screen, fireEvent, act } from "@testing-library/react";
@@ -18,25 +17,6 @@ beforeEach(() => {
18
17
  });
19
18
  });
20
19
 
21
- vi.mock("react", async () => {
22
- const originReact = await vi.importActual("react");
23
- const mUseRef = vi.fn();
24
- return {
25
- ...originReact,
26
- useRef: mUseRef,
27
- };
28
- });
29
-
30
- const useMockRef = useRef as MockedFunction<typeof useRef>;
31
-
32
- // `useRef` está mockeado a nivel de módulo y sin implementación devuelve
33
- // undefined. El primer test dejaba puesto un `mockReturnValue` que `clearMocks`
34
- // no borra —solo limpia el historial de llamadas— y los dos últimos dependían
35
- // de él: al reordenarlos petaban con `... reading 'current'`.
36
- beforeEach(() => {
37
- useMockRef.mockReturnValue({ current: {} });
38
- });
39
-
40
20
  describe("ResizePanel component rendering", () => {
41
21
  it("should render the component with fixed panel", () => {
42
22
  const defaultProps: IResizePanelProps = {
@@ -164,3 +144,52 @@ describe("ResizePanel mouse events", () => {
164
144
  expect(width).toBe("368px");
165
145
  });
166
146
  });
147
+
148
+ describe("ResizePanel while Froala is in fullscreen", () => {
149
+ const renderPanel = () => {
150
+ Object.defineProperty(document.body, "offsetWidth", { value: 1440, configurable: true });
151
+
152
+ render(
153
+ <ThemeProvider theme={parseTheme(globalTheme)}>
154
+ <ResizePanel leftPanel={<div>Left Panel</div>} rightPanel={<div>Right Panel</div>} fixed={true} />
155
+ </ThemeProvider>,
156
+ );
157
+
158
+ const rightPanel = screen.getByTestId("right-panel");
159
+
160
+ // The width the user dragged the panel to
161
+ fireEvent.mouseDown(screen.getByTestId("handler"));
162
+ fireEvent.mouseMove(window, { clientX: 1000, clientY: 100 });
163
+ fireEvent.mouseUp(window);
164
+
165
+ return rightPanel;
166
+ };
167
+
168
+ const resizeWindow = async () => {
169
+ await act(async () => {
170
+ fireEvent(window, new Event("resize"));
171
+ await new Promise((resolve) => setTimeout(resolve, 32));
172
+ });
173
+ };
174
+
175
+ it("should not adopt the width Froala's fullscreen forces on the panel", async () => {
176
+ const rightPanel = renderPanel();
177
+ // Froala tags every ancestor of the editor, this panel included, and its
178
+ // rule widens them to 100%
179
+ rightPanel.classList.add("fr-fullscreen-wrapper");
180
+ vi.spyOn(rightPanel, "offsetWidth", "get").mockReturnValue(1200);
181
+
182
+ await resizeWindow();
183
+
184
+ expect(getComputedStyle(rightPanel).width).toBe("440px");
185
+ });
186
+
187
+ it("should still follow the panel width on a plain window resize", async () => {
188
+ const rightPanel = renderPanel();
189
+ vi.spyOn(rightPanel, "offsetWidth", "get").mockReturnValue(1200);
190
+
191
+ await resizeWindow();
192
+
193
+ expect(getComputedStyle(rightPanel).width).toBe("1200px");
194
+ });
195
+ });
@@ -10,10 +10,12 @@ import globalTheme from "@ax/themes/theme.json";
10
10
 
11
11
  afterEach(cleanup);
12
12
 
13
+ const routerMock = vi.hoisted(() => ({ pathname: "localhost:3000/example/path" }));
14
+
13
15
  vi.mock("react-router-dom", async () => ({
14
16
  ...(await vi.importActual("react-router-dom")),
15
17
  useLocation: () => ({
16
- pathname: "localhost:3000/example/path",
18
+ pathname: routerMock.pathname,
17
19
  }),
18
20
  }));
19
21
 
@@ -105,3 +107,37 @@ describe("Toast component events", () => {
105
107
  expect(toastWrapper.classList.contains("close-animation")).toBeTruthy();
106
108
  });
107
109
  });
110
+
111
+ describe("Toast positioning by route", () => {
112
+ // Editors have no side nav, so the toast slides left. `spacing.m` is 24px.
113
+ const AT_EDGE = "calc(24px * 1)";
114
+ const CLEARS_NAV = "calc(24px * 4)";
115
+
116
+ afterEach(() => {
117
+ routerMock.pathname = "localhost:3000/example/path";
118
+ });
119
+
120
+ const renderAt = (pathname: string) => {
121
+ routerMock.pathname = pathname;
122
+
123
+ render(
124
+ <ThemeProvider theme={parseTheme(globalTheme)}>
125
+ <Toast {...makeProps({ message: "1 module copied to clipboard" })} />
126
+ </ThemeProvider>,
127
+ );
128
+
129
+ return screen.getByTestId("toast-wrapper");
130
+ };
131
+
132
+ it("sits at the edge in the page editor", () => {
133
+ expect(renderAt("/sites/pages/editor/42")).toHaveStyleRule("left", AT_EDGE);
134
+ });
135
+
136
+ it("sits at the edge in a structured data item", () => {
137
+ expect(renderAt("/sites/data/5/content/12")).toHaveStyleRule("left", AT_EDGE);
138
+ });
139
+
140
+ it("clears the side nav on the content types settings", () => {
141
+ expect(renderAt("/sites/settings/content-types")).toHaveStyleRule("left", CLEARS_NAV);
142
+ });
143
+ });