@griddo/ax 12.6.1 → 12.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (91) hide show
  1. package/config/griddo-config/index.js +0 -1
  2. package/package.json +4 -3
  3. package/src/GlobalStore.tsx +35 -91
  4. package/src/__tests__/hooks/heartbeat.test.tsx +88 -0
  5. package/src/components/MainWrapper/index.tsx +2 -14
  6. package/src/containers/ActivityLog/{thunks.ts → actions.tsx} +89 -104
  7. package/src/containers/ActivityLog/constants.tsx +6 -0
  8. package/src/containers/ActivityLog/index.tsx +3 -16
  9. package/src/containers/ActivityLog/interfaces.tsx +12 -0
  10. package/src/containers/ActivityLog/reducer.tsx +24 -0
  11. package/src/containers/Analytics/actions.tsx +109 -0
  12. package/src/containers/Analytics/constants.tsx +5 -0
  13. package/src/containers/Analytics/index.tsx +3 -14
  14. package/src/containers/Analytics/interfaces.tsx +9 -0
  15. package/src/containers/Analytics/reducer.tsx +28 -0
  16. package/src/containers/Domains/actions.tsx +58 -0
  17. package/src/containers/Domains/constants.tsx +5 -0
  18. package/src/containers/Domains/index.tsx +3 -12
  19. package/src/containers/Domains/interfaces.tsx +9 -0
  20. package/src/containers/Domains/reducer.tsx +22 -0
  21. package/src/containers/Redirects/actions.tsx +190 -0
  22. package/src/containers/Redirects/constants.tsx +17 -1
  23. package/src/containers/Redirects/index.tsx +3 -14
  24. package/src/containers/Redirects/interfaces.tsx +23 -0
  25. package/src/containers/Redirects/reducer.tsx +35 -0
  26. package/src/containers/Settings/Languages/actions.tsx +88 -0
  27. package/src/containers/Settings/Languages/constants.tsx +6 -0
  28. package/src/containers/Settings/Languages/index.tsx +3 -15
  29. package/src/containers/Settings/Languages/reducer.tsx +31 -0
  30. package/src/containers/Settings/Social/actions.tsx +63 -0
  31. package/src/containers/Settings/Social/constants.tsx +3 -0
  32. package/src/containers/Settings/Social/index.tsx +3 -11
  33. package/src/containers/Settings/Social/interfaces.tsx +9 -0
  34. package/src/containers/Settings/Social/reducer.tsx +18 -0
  35. package/src/containers/Sites/actions.tsx +2 -6
  36. package/src/hooks/heartbeat.ts +32 -0
  37. package/src/hooks/index.tsx +2 -0
  38. package/src/index.tsx +6 -7
  39. package/src/modules/ActivityLog/ItemLog/EventItem/index.tsx +27 -13
  40. package/src/modules/ActivityLog/ItemLogUser/UserItem/EventItem/index.tsx +27 -13
  41. package/src/modules/ActivityLog/index.tsx +51 -22
  42. package/src/modules/Analytics/index.tsx +42 -33
  43. package/src/modules/App/Routing/index.tsx +4 -5
  44. package/src/modules/FileDrive/index.tsx +1 -1
  45. package/src/modules/GlobalEditor/index.tsx +6 -8
  46. package/src/modules/GlobalSettings/Robots/index.tsx +47 -24
  47. package/src/modules/MediaGallery/index.tsx +1 -1
  48. package/src/modules/PageEditor/index.tsx +5 -9
  49. package/src/modules/Redirects/RedirectItem/index.tsx +22 -9
  50. package/src/modules/Redirects/RedirectPanel/index.tsx +14 -13
  51. package/src/modules/Redirects/index.tsx +69 -26
  52. package/src/modules/Settings/ContentTypes/DataPacks/index.tsx +3 -4
  53. package/src/modules/Settings/Languages/LanguagePanel/Form/ConnectedField/index.tsx +18 -11
  54. package/src/modules/Settings/Languages/LanguagePanel/Form/index.tsx +9 -7
  55. package/src/modules/Settings/Languages/LanguagePanel/index.tsx +44 -38
  56. package/src/modules/Settings/Languages/Table/Header/index.tsx +19 -12
  57. package/src/modules/Settings/Languages/Table/Item/index.tsx +36 -21
  58. package/src/modules/Settings/Languages/Table/index.tsx +14 -6
  59. package/src/modules/Settings/Languages/index.tsx +30 -18
  60. package/src/modules/Settings/SeoAnalyticsSettings/Analytics/index.tsx +52 -42
  61. package/src/modules/Settings/Social/index.tsx +12 -17
  62. package/src/modules/StructuredData/StructuredDataList/index.tsx +2 -3
  63. package/src/types/index.tsx +6 -5
  64. package/tsconfig.paths.json +0 -1
  65. package/src/__tests__/modules/ActivityLog/ActivityLog.uc.test.tsx +0 -132
  66. package/src/__tests__/modules/Analytics/Analytics.uc.test.tsx +0 -168
  67. package/src/__tests__/modules/GlobalSettings/Robots/Robots.test.tsx +0 -110
  68. package/src/__tests__/modules/GlobalSettings/Robots/Robots.uc.test.tsx +0 -125
  69. package/src/__tests__/modules/Redirects/Redirects.uc.test.tsx +0 -164
  70. package/src/__tests__/modules/Settings/Languages/Languages.uc.test.tsx +0 -213
  71. package/src/__tests__/modules/Settings/Social/Social.uc.test.tsx +0 -171
  72. package/src/__tests__/store/activityLog.test.ts +0 -301
  73. package/src/__tests__/store/analytics.test.ts +0 -134
  74. package/src/__tests__/store/domains.test.ts +0 -107
  75. package/src/__tests__/store/languages.test.ts +0 -129
  76. package/src/__tests__/store/persist.test.ts +0 -47
  77. package/src/__tests__/store/redirects.test.ts +0 -313
  78. package/src/__tests__/store/social.test.ts +0 -165
  79. package/src/containers/ActivityLog/slice.ts +0 -45
  80. package/src/containers/Analytics/slice.ts +0 -48
  81. package/src/containers/Analytics/thunks.ts +0 -103
  82. package/src/containers/Domains/slice.ts +0 -37
  83. package/src/containers/Domains/thunks.ts +0 -60
  84. package/src/containers/Redirects/slice.ts +0 -60
  85. package/src/containers/Redirects/thunks.ts +0 -196
  86. package/src/containers/Settings/Languages/slice.ts +0 -48
  87. package/src/containers/Settings/Languages/thunks.ts +0 -83
  88. package/src/containers/Settings/Social/slice.ts +0 -40
  89. package/src/containers/Settings/Social/thunks.ts +0 -68
  90. package/src/store/hooks.ts +0 -37
  91. package/src/store/index.ts +0 -2
@@ -1,171 +0,0 @@
1
- import { Router } from "react-router-dom";
2
-
3
- import { appReducer, type IAppState } from "@ax/containers/App/reducer";
4
- import { socialReducer } from "@ax/containers/Settings";
5
- import { type ISitesState, sitesInitialState, sitesReducer } from "@ax/containers/Sites/reducer";
6
- import { parseTheme } from "@ax/helpers";
7
- import Social from "@ax/modules/Settings/Social";
8
- import history from "@ax/routes/history";
9
- import globalTheme from "@ax/themes/theme.json";
10
- import type { INavItem, ISite, ISocialState } from "@ax/types";
11
-
12
- import { configureStore, type Reducer, type UnknownAction } from "@reduxjs/toolkit";
13
- import axios from "axios";
14
- import { ThemeProvider } from "styled-components";
15
- import type { MockedFunction } from "vitest";
16
-
17
- import { act, cleanup, fireEvent, render, screen } from "../../../../../config/tests/test-utils";
18
-
19
- /**
20
- * End-to-end test of the Social screen on the real store — view, thunk, api layer and
21
- * slice, with only axios mocked (see the `## Profile` section of docs/gainup/README.md
22
- * for why a `redux-mock-store` test does not count as AC coverage).
23
- *
24
- * The sibling Social.test.tsx keeps covering the render rules — which networks show up,
25
- * the dedup between the instance set and the hardcoded one, the button labels — on a
26
- * mock store. What it cannot see, and what this file is for, is the round trip: that
27
- * mounting fetches, that saving PUTs the whole map, and that the form goes clean off
28
- * the server's answer rather than off the click.
29
- *
30
- * The mock echoes a PUT's body back as the response, which is what the API does: the
31
- * screen rehydrates from that answer, so a mock returning something else would test a
32
- * server that does not exist.
33
- */
34
- vi.mock("axios");
35
- const mockedAxios = axios as MockedFunction<typeof axios>;
36
-
37
- const SITE_ID = 42;
38
- const SOCIALS: ISocialState = {
39
- instagram: "https://instagram.com/griddo",
40
- twitter: "https://x.com/griddo",
41
- };
42
- const EDITED = "https://instagram.com/griddo-cms";
43
-
44
- const mockComponent = () => null;
45
- const NAV_ITEMS: INavItem[] = [
46
- { title: "Social", path: "/settings/social", component: mockComponent },
47
- { title: "Other", path: "/settings/other", component: mockComponent },
48
- ];
49
-
50
- const makeStore = () =>
51
- configureStore({
52
- // Only what this screen reads. `app` and `sites` are still legacy, so they need
53
- // the cast GlobalStore uses (docs/RTK-MIGRATION.md §8.3).
54
- reducer: {
55
- app: appReducer as Reducer<IAppState, UnknownAction>,
56
- social: socialReducer,
57
- sites: sitesReducer as Reducer<ISitesState, UnknownAction>,
58
- },
59
- middleware: (getDefault) => getDefault({ serializableCheck: false, immutableCheck: false }),
60
- preloadedState: {
61
- sites: { ...sitesInitialState, currentSiteInfo: { id: SITE_ID } as ISite },
62
- },
63
- });
64
-
65
- const socialsCalls = (method: string) =>
66
- mockedAxios.mock.calls
67
- .map(([config]) => config as { url?: string; method?: string; data?: ISocialState })
68
- .filter((config) => config?.method === method && config?.url?.endsWith(`/site/${SITE_ID}/socials`));
69
-
70
- const renderScreen = async (store: ReturnType<typeof makeStore>) => {
71
- await act(async () => {
72
- render(
73
- <Router history={history}>
74
- <ThemeProvider theme={parseTheme(globalTheme)}>
75
- <Social navItems={NAV_ITEMS} currentNavItem={NAV_ITEMS[0]} />
76
- </ThemeProvider>
77
- </Router>,
78
- { store },
79
- );
80
- });
81
- };
82
-
83
- const button = (name: string) => screen.getByRole("button", { name });
84
-
85
- // TextField debounces its onChange by 300ms, so an edit does not reach `socialForm`
86
- // until that timer fires. Driving the clock instead of sleeping keeps it deterministic —
87
- // and without it the test asserts on a form that never changed.
88
- const DEBOUNCE_MS = 300;
89
- const typeInto = async (currentValue: string, newValue: string) => {
90
- fireEvent.change(screen.getByDisplayValue(currentValue), { target: { value: newValue } });
91
- await act(async () => {
92
- vi.advanceTimersByTime(DEBOUNCE_MS);
93
- });
94
- };
95
-
96
- beforeEach(() => {
97
- vi.useFakeTimers();
98
- mockedAxios.mockImplementation((async (config: { method?: string; data?: ISocialState }) => ({
99
- status: 200,
100
- data: config?.method === "PUT" ? config.data : SOCIALS,
101
- statusText: "OK",
102
- headers: {},
103
- config: {},
104
- })) as never);
105
- });
106
-
107
- afterEach(() => {
108
- vi.useRealTimers();
109
- vi.restoreAllMocks();
110
- cleanup();
111
- });
112
-
113
- describe("[UC-AX-configure-social-networks] the Social screen end to end", () => {
114
- it("AC2 · mounting asks the current site for its networks and prefills each one", async () => {
115
- const store = makeStore();
116
-
117
- await renderScreen(store);
118
-
119
- expect(socialsCalls("GET")).toHaveLength(1);
120
- expect(store.getState().social).toEqual(SOCIALS);
121
- expect(screen.getByDisplayValue(SOCIALS.instagram)).toBeTruthy();
122
- expect(screen.getByDisplayValue(SOCIALS.twitter)).toBeTruthy();
123
- });
124
-
125
- it("AC6 · editing a network enables Save, and saving PUTs the whole map and leaves the form clean", async () => {
126
- const store = makeStore();
127
- await renderScreen(store);
128
-
129
- // Pristine off what the server answered — there is nothing to save yet.
130
- expect(button("Saved").hasAttribute("disabled")).toBe(true);
131
-
132
- await typeInto(SOCIALS.instagram, EDITED);
133
-
134
- expect(button("Save").hasAttribute("disabled")).toBe(false);
135
-
136
- await act(async () => {
137
- fireEvent.click(button("Save"));
138
- });
139
-
140
- // The whole map, not just the edited field — the endpoint takes it as a unit.
141
- expect(socialsCalls("PUT")).toHaveLength(1);
142
- expect(socialsCalls("PUT")[0].data).toEqual({ ...SOCIALS, instagram: EDITED });
143
- expect(store.getState().social).toEqual({ ...SOCIALS, instagram: EDITED });
144
- // Back to pristine, and off the server's answer: this is the assertion that the
145
- // `.unwrap()` in the view protects. Without it the dispatch resolves to the
146
- // fulfilled action, truthy even on a failed save.
147
- expect(button("Saved").hasAttribute("disabled")).toBe(true);
148
- });
149
-
150
- it("AC6 · a failed save keeps the form dirty so the changes are not silently dropped", async () => {
151
- const store = makeStore();
152
- await renderScreen(store);
153
-
154
- await typeInto(SOCIALS.instagram, EDITED);
155
-
156
- mockedAxios.mockImplementation((async () => ({
157
- status: 400,
158
- data: { message: "Invalid URL" },
159
- statusText: "Bad Request",
160
- headers: {},
161
- config: {},
162
- })) as never);
163
-
164
- await act(async () => {
165
- fireEvent.click(button("Save"));
166
- });
167
-
168
- expect(button("Save").hasAttribute("disabled")).toBe(false);
169
- expect(store.getState().social).toEqual(SOCIALS);
170
- });
171
- });
@@ -1,301 +0,0 @@
1
- import { logs } from "@ax/api";
2
- import { activityLogActions, activityLogReducer } from "@ax/containers/ActivityLog";
3
- import { appActions } from "@ax/containers/App";
4
- import { appInitialState, appReducer, type IAppState } from "@ax/containers/App/reducer";
5
- import { sitesActions } from "@ax/containers/Sites";
6
- import { usersActions } from "@ax/containers/Users";
7
- import type { AppDispatch } from "@ax/store";
8
- import type { ILanguage } from "@ax/types";
9
-
10
- import type { LogActivityPaginationRequest, LogContentDTO, LogContentTypeDTO } from "@griddo/api-types";
11
- import { configureStore, type Reducer, type UnknownAction } from "@reduxjs/toolkit";
12
- import type { MockInstance } from "vitest";
13
-
14
- /**
15
- * Characterisation tests for the `activityLog` area — the audit trail behind `/log`
16
- * (UC-AX-view-activity-log). The area had **zero** tests, so this file is written against
17
- * the legacy `actions.tsx` + `reducer.tsx` first and must keep passing against the slice.
18
- *
19
- * Thunks are dispatched through the one-line adapters below: createAsyncThunk takes exactly
20
- * ONE argument, so the calls change shape when the area migrates and the adapters keep every
21
- * assertion untouched (docs/RTK-MIGRATION.md §5). Same trick as `redirects.test.ts`.
22
- *
23
- * On scope: the `goToLogContent` block below is tagged with no AC on purpose. The use case
24
- * files deep-linking under step 9 as an *extension* whose navigation belongs to the target
25
- * screens' own use cases, so these are **mechanics** tests, not AC coverage — they exist
26
- * because the migration rewrites how that 160-line navigation switch is invoked, and it is
27
- * the one thing in this wave too big to convert unpinned. They cover the branch SHAPES (site
28
- * vs global, content vs contentType, language resolution), not all seventeen content types.
29
- */
30
-
31
- const PARAMS: LogActivityPaginationRequest = { page: 1, itemsPerPage: 100 };
32
-
33
- const LOG_PAGE = {
34
- totalItems: 2,
35
- items: [
36
- { date: "2026-08-10", items: [{ id: 1 }] },
37
- { date: "2026-08-09", items: [{ id: 2 }] },
38
- ],
39
- };
40
-
41
- const EN: ILanguage = { id: 1, locale: "en_GB", label: "en", language: "English", isDefault: true };
42
- const ES: ILanguage = { id: 2, locale: "es_ES", label: "es", language: "Spanish", isDefault: false };
43
-
44
- const SITE_ID = 7;
45
-
46
- // `ReturnType<…>` rather than naming the state type: it lives in `reducer.tsx` today and in
47
- // `slice.ts` afterwards, and this file must not have to follow it. The `app` cast is the one
48
- // GlobalStore uses — RTK types reducers against redux 5's UnknownAction (§8.3).
49
- type ActivityLogState = ReturnType<typeof activityLogReducer>;
50
-
51
- const makeStore = () =>
52
- configureStore({
53
- reducer: {
54
- activityLog: activityLogReducer as unknown as Reducer<ActivityLogState, UnknownAction>,
55
- app: appReducer as Reducer<IAppState, UnknownAction>,
56
- },
57
- middleware: (getDefault) => getDefault({ serializableCheck: false, immutableCheck: false }),
58
- // `goToLogContent` resolves the target language against these two.
59
- preloadedState: { app: { ...appInitialState, lang: { locale: EN.locale, id: EN.id }, globalLangs: [EN, ES] } },
60
- });
61
-
62
- type Store = ReturnType<typeof makeStore>;
63
- const stateOf = (store: Store) => store.getState().activityLog;
64
-
65
- // The adapters. One line each; the migration rewrote these and nothing else.
66
- //
67
- // Only `goToLogContent` changed shape — three positional arguments into one object, the
68
- // single-argument rule of docs/RTK-MIGRATION.md §5. The other three already took one argument,
69
- // and `setListMode` shed the cast a redux 4 action creator needed to enter an RTK store (§8.3).
70
- const loadByDay = (store: Store, params = PARAMS) => store.dispatch(activityLogActions.getActivityLog(params));
71
- const loadByUser = (store: Store, params = PARAMS) => store.dispatch(activityLogActions.getActivityLogByUser(params));
72
- const setMode = (store: Store, mode: string) => store.dispatch(activityLogActions.setListMode(mode));
73
- const download = (store: Store, data: { format: string[]; dateRange?: string }) =>
74
- store.dispatch(activityLogActions.downloadActivityLog(data as never));
75
- const goTo = (
76
- store: Store,
77
- contentType: LogContentTypeDTO | null,
78
- content: LogContentDTO | null,
79
- site: number | null,
80
- // `goToLogContent` is declared with the store's real `AppDispatch` (see thunks.ts, it has to
81
- // be, or the nineteen legacy actions it dispatches stop type-checking), and this store mounts
82
- // two slices instead of all eighteen — so its dispatch gets widened to the real one. The
83
- // thunk only ever reads `app`.
84
- ) => (store.dispatch as AppDispatch)(activityLogActions.goToLogContent({ contentType, content, site }));
85
-
86
- const ok = (data: unknown) => ({ status: 200, data });
87
-
88
- let handleError: MockInstance;
89
-
90
- beforeEach(() => {
91
- handleError = vi.spyOn(appActions, "handleError").mockReturnValue((() => undefined) as never);
92
- });
93
-
94
- afterEach(() => {
95
- vi.restoreAllMocks();
96
- });
97
-
98
- describe("[UC-AX-view-activity-log] loading the log", () => {
99
- it("AC1 · starts empty, in the timeline mode", () => {
100
- expect(stateOf(makeStore())).toEqual({ activityLog: { totalItems: 0, items: [] }, listMode: "default" });
101
- });
102
-
103
- it("AC1 · loading asks the grouped-by-day endpoint and stores the page it gets", async () => {
104
- const store = makeStore();
105
- const get = vi.spyOn(logs, "getLogActivityTimelineByDay").mockResolvedValue(ok(LOG_PAGE) as never);
106
-
107
- await loadByDay(store);
108
-
109
- expect(get).toHaveBeenCalledWith(PARAMS);
110
- expect(stateOf(store).activityLog).toEqual(LOG_PAGE);
111
- });
112
-
113
- it("AC4 · the params travel through untouched, so ordering and search reach the endpoint", async () => {
114
- const store = makeStore();
115
- const get = vi.spyOn(logs, "getLogActivityTimelineByDay").mockResolvedValue(ok(LOG_PAGE) as never);
116
- const sorted = { ...PARAMS, order: "ASC" as const, search: "deleted" };
117
-
118
- await loadByDay(store, sorted);
119
-
120
- expect(get).toHaveBeenCalledWith(sorted);
121
- });
122
-
123
- it("AC2 · the grouped-by-user mode asks a different endpoint and lands in the same slot", async () => {
124
- const store = makeStore();
125
- const byUser = vi.spyOn(logs, "getLogActivityTimelineByUser").mockResolvedValue(ok(LOG_PAGE) as never);
126
- const byDay = vi.spyOn(logs, "getLogActivityTimelineByDay").mockResolvedValue(ok(LOG_PAGE) as never);
127
-
128
- await loadByUser(store);
129
-
130
- expect(byUser).toHaveBeenCalledWith(PARAMS);
131
- expect(byDay).not.toHaveBeenCalled();
132
- expect(stateOf(store).activityLog).toEqual(LOG_PAGE);
133
- });
134
-
135
- it("AC2 · the list mode is a separate flag the endpoints do not touch", async () => {
136
- const store = makeStore();
137
- vi.spyOn(logs, "getLogActivityTimelineByUser").mockResolvedValue(ok(LOG_PAGE) as never);
138
-
139
- setMode(store, "user");
140
- await loadByUser(store);
141
-
142
- expect(stateOf(store).listMode).toBe("user");
143
- expect(stateOf(store).activityLog).toEqual(LOG_PAGE);
144
- });
145
-
146
- it("AC1 · a failed load routes through the error channel and leaves the page as it was", async () => {
147
- const store = makeStore();
148
- vi.spyOn(logs, "getLogActivityTimelineByDay").mockResolvedValue({ status: 500, data: {} } as never);
149
-
150
- await loadByDay(store);
151
-
152
- expect(handleError).toHaveBeenCalled();
153
- expect(stateOf(store).activityLog).toEqual({ totalItems: 0, items: [] });
154
- });
155
- });
156
-
157
- describe("[UC-AX-view-activity-log] exporting the log", () => {
158
- let click: MockInstance;
159
- let createObjectURL: MockInstance;
160
-
161
- beforeEach(() => {
162
- // jsdom implements neither of these, and clicking a real <a href> would try to navigate.
163
- createObjectURL = vi.fn(() => "blob:activity-log") as unknown as MockInstance;
164
- Object.assign(window.URL, { createObjectURL, revokeObjectURL: vi.fn() });
165
- click = vi.spyOn(HTMLAnchorElement.prototype, "click").mockImplementation(() => undefined);
166
- });
167
-
168
- it("AC6 · exporting posts the request and hands the browser a download named after today", async () => {
169
- const store = makeStore();
170
- const exportLog = vi.spyOn(logs, "logExport").mockResolvedValue(ok("id;event\n1;CREATED") as never);
171
- const request = { dateRange: "7", format: ["csv"] };
172
-
173
- await download(store, request);
174
-
175
- expect(exportLog).toHaveBeenCalledWith(request);
176
- expect(createObjectURL).toHaveBeenCalled();
177
- expect(click).toHaveBeenCalled();
178
- // The extension comes from the requested format, the stem from the date.
179
- const link = click.mock.instances[0] as HTMLAnchorElement;
180
- expect(link.download).toMatch(/^activity-log-\d{4}-\d{2}-\d{2}\.csv$/);
181
- });
182
-
183
- it("AC6 · the requested format decides the extension", async () => {
184
- const store = makeStore();
185
- vi.spyOn(logs, "logExport").mockResolvedValue(ok("") as never);
186
-
187
- await download(store, { dateRange: "30", format: ["xlsx"] });
188
-
189
- const link = click.mock.instances[0] as HTMLAnchorElement;
190
- expect(link.download.endsWith(".xlsx")).toBe(true);
191
- });
192
-
193
- it("AC6 · a failed export reports the error and downloads nothing", async () => {
194
- const store = makeStore();
195
- vi.spyOn(logs, "logExport").mockResolvedValue({ status: 500, data: {} } as never);
196
-
197
- await download(store, { dateRange: "7", format: ["csv"] });
198
-
199
- expect(handleError).toHaveBeenCalled();
200
- expect(click).not.toHaveBeenCalled();
201
- });
202
- });
203
-
204
- describe("goToLogContent — deep-link mechanics", () => {
205
- let push: MockInstance;
206
- let getSite: MockInstance;
207
- let setLanguage: MockInstance;
208
-
209
- const noopThunk = (() => () => Promise.resolve()) as never;
210
-
211
- beforeEach(() => {
212
- push = vi.spyOn(appActions, "setHistoryPush").mockReturnValue((() => undefined) as never);
213
- getSite = vi.spyOn(sitesActions, "getSite").mockImplementation(noopThunk);
214
- setLanguage = vi.spyOn(appActions, "setLanguage");
215
- });
216
-
217
- const content = (over: Partial<LogContentDTO>) =>
218
- ({ id: 9, type: "site", language: EN.id, ...over }) as LogContentDTO;
219
-
220
- it("a global target navigates without loading any site", async () => {
221
- const store = makeStore();
222
- const getUser = vi.spyOn(usersActions, "getUser").mockImplementation(noopThunk);
223
-
224
- await goTo(store, null, content({ type: "user" }), null);
225
-
226
- expect(getUser).toHaveBeenCalledWith(9);
227
- expect(push).toHaveBeenCalledWith("/users/edit");
228
- expect(getSite).not.toHaveBeenCalled();
229
- });
230
-
231
- it("a site-scoped target loads the site FIRST and only then navigates", async () => {
232
- const store = makeStore();
233
- const order: string[] = [];
234
- getSite.mockImplementation(((id: number) => {
235
- order.push(`getSite:${id}`);
236
- return () => Promise.resolve();
237
- }) as never);
238
- push.mockImplementation(((path: string) => {
239
- order.push(`push:${path}`);
240
- return () => undefined;
241
- }) as never);
242
-
243
- await goTo(store, null, content({ type: "site" }), SITE_ID);
244
-
245
- // The editor reads the loaded site, so the order is the behaviour.
246
- expect(order).toEqual([`getSite:${SITE_ID}`, "push:/sites/pages"]);
247
- });
248
-
249
- it("the same content type falls back to its global route when there is no site", async () => {
250
- const store = makeStore();
251
-
252
- await goTo(store, null, content({ type: "file" }), null);
253
-
254
- expect(getSite).not.toHaveBeenCalled();
255
- expect(push).toHaveBeenCalledWith("/media-assets/files");
256
- });
257
-
258
- it("a site-only content type does nothing at all without a site", async () => {
259
- const store = makeStore();
260
-
261
- await goTo(store, null, content({ type: "menu" }), null);
262
-
263
- expect(getSite).not.toHaveBeenCalled();
264
- expect(push).not.toHaveBeenCalled();
265
- });
266
-
267
- it("switches the editor language when the content is in another one", async () => {
268
- const store = makeStore();
269
-
270
- await goTo(store, null, content({ type: "menu", language: ES.id }), SITE_ID);
271
-
272
- expect(setLanguage).toHaveBeenCalledWith({ locale: ES.locale, id: ES.id });
273
- });
274
-
275
- it("keeps the current language when the content is already in it", async () => {
276
- const store = makeStore();
277
-
278
- await goTo(store, null, content({ type: "menu", language: EN.id }), SITE_ID);
279
-
280
- expect(setLanguage).toHaveBeenCalledWith({ locale: EN.locale, id: EN.id });
281
- });
282
-
283
- it("a content TYPE target is a separate branch: a template opens the page editor", async () => {
284
- const store = makeStore();
285
- const contentType = { id: 42, type: "template", languageId: EN.id } as LogContentTypeDTO;
286
-
287
- await goTo(store, contentType, null, SITE_ID);
288
-
289
- expect(getSite).toHaveBeenCalledWith(SITE_ID);
290
- expect(push).toHaveBeenCalledWith("/sites/pages/editor", true);
291
- });
292
-
293
- it("does nothing when neither a content nor a content type is given", async () => {
294
- const store = makeStore();
295
-
296
- await goTo(store, null, null, SITE_ID);
297
-
298
- expect(getSite).not.toHaveBeenCalled();
299
- expect(push).not.toHaveBeenCalled();
300
- });
301
- });
@@ -1,134 +0,0 @@
1
- import { analytics } from "@ax/api";
2
- import { analyticsActions, analyticsReducer } from "@ax/containers/Analytics";
3
- import { appActions } from "@ax/containers/App";
4
- import type { IAnalytics } from "@ax/types";
5
-
6
- import { configureStore } from "@reduxjs/toolkit";
7
- import type { MockInstance } from "vitest";
8
-
9
- /**
10
- * Characterisation tests for the `analytics` area, written against the store and
11
- * the exported action creators before the area became a slice — see the sibling
12
- * domains.test.ts for why that order is the point.
13
- *
14
- * Two behaviours here are load-bearing and easy to lose in a rewrite:
15
- * `setAnalytics` MERGES onto the state (the legacy reducer spread the payload, and
16
- * `Sites/actions.tsx` dispatches it with whatever the site's metrics endpoint
17
- * returned), and the global save fires its three requests in PARALLEL. Both
18
- * assertions below are the ones that came over from the legacy run untouched.
19
- *
20
- * What did have to change are the CALLS, and that is the second finding of this
21
- * migration: a createAsyncThunk takes exactly ONE argument, so the two-parameter
22
- * writes now take an object (`{ scriptCode, siteId }`), and the optional `siteId`
23
- * became explicit (`null` = global, which the API spells "global"). Every call site
24
- * of a multi-argument thunk pays this — see docs/RTK-MIGRATION.md §5.
25
- */
26
-
27
- const ANALYTICS: IAnalytics = {
28
- scriptCode: "<script>ga</script>",
29
- siteScriptCodeExists: true,
30
- dimensions: [{ id: 1, name: "campus", type: "onlyDimensions", values: [] } as never],
31
- groups: [],
32
- };
33
-
34
- const SITE_ID = 7;
35
-
36
- const makeStore = () => configureStore({ reducer: { analytics: analyticsReducer } });
37
- type Store = ReturnType<typeof makeStore>;
38
- const stateOf = (store: Store) => (store.getState() as { analytics: IAnalytics }).analytics;
39
-
40
- const ok = { status: 200, data: {} };
41
-
42
- describe("[UC-AX-configure-seo-analytics] analytics state", () => {
43
- let store: Store;
44
- let handleError: MockInstance;
45
-
46
- beforeEach(() => {
47
- store = makeStore();
48
- handleError = vi.spyOn(appActions, "handleError").mockReturnValue((() => undefined) as never);
49
- });
50
-
51
- afterEach(() => {
52
- vi.restoreAllMocks();
53
- });
54
-
55
- it("AC2 · starts with an empty script and no dimensions", () => {
56
- expect(stateOf(store)).toEqual({ scriptCode: "", siteScriptCodeExists: false, dimensions: [], groups: [] });
57
- });
58
-
59
- it("AC2 · setAnalytics merges onto the state instead of replacing it", () => {
60
- store.dispatch(analyticsActions.setAnalytics(ANALYTICS));
61
-
62
- // A partial payload — which is what the site metrics endpoint can return —
63
- // must leave the untouched fields alone.
64
- store.dispatch(analyticsActions.setAnalytics({ scriptCode: "<script>gtm</script>" } as IAnalytics));
65
-
66
- expect(stateOf(store)).toEqual({ ...ANALYTICS, scriptCode: "<script>gtm</script>" });
67
- });
68
-
69
- it("AC2 · loading the global analytics puts them in the store", async () => {
70
- vi.spyOn(analytics, "getAnalytics").mockResolvedValue({ status: 200, data: ANALYTICS } as never);
71
-
72
- await store.dispatch(analyticsActions.getAnalytics(null));
73
-
74
- expect(analytics.getAnalytics).toHaveBeenCalledWith("global");
75
- expect(stateOf(store)).toEqual(ANALYTICS);
76
- });
77
-
78
- it("AC2 · a site load asks for that site, not for global", async () => {
79
- vi.spyOn(analytics, "getAnalytics").mockResolvedValue({ status: 200, data: ANALYTICS } as never);
80
-
81
- await store.dispatch(analyticsActions.getAnalytics(SITE_ID));
82
-
83
- expect(analytics.getAnalytics).toHaveBeenCalledWith(SITE_ID);
84
- });
85
-
86
- it("AC2 · saving the global analytics fires exactly the three writes and reloads", async () => {
87
- const code = vi.spyOn(analytics, "updateScriptCode").mockResolvedValue(ok as never);
88
- const dimensions = vi.spyOn(analytics, "createDimensions").mockResolvedValue(ok as never);
89
- const groups = vi.spyOn(analytics, "createDimensionsGroups").mockResolvedValue(ok as never);
90
- const reload = vi.spyOn(analytics, "getAnalytics").mockResolvedValue({ status: 200, data: ANALYTICS } as never);
91
-
92
- await store.dispatch(analyticsActions.updateAnalytics({ analytics: ANALYTICS, siteId: null }));
93
-
94
- expect(code).toHaveBeenCalledWith("global", { scriptCode: ANALYTICS.scriptCode });
95
- expect(dimensions).toHaveBeenCalledWith("global", { dimensions: ANALYTICS.dimensions });
96
- expect(groups).toHaveBeenCalledWith("global", { groups: ANALYTICS.groups });
97
- expect(reload).toHaveBeenCalled();
98
- });
99
-
100
- it("AC2 · one failing write among the three reports the count and skips the reload", async () => {
101
- const failure = { status: 400, data: { message: ["bad dimension", "bad group"] } };
102
- vi.spyOn(analytics, "updateScriptCode").mockResolvedValue(ok as never);
103
- vi.spyOn(analytics, "createDimensions").mockResolvedValue(failure as never);
104
- vi.spyOn(analytics, "createDimensionsGroups").mockResolvedValue(ok as never);
105
- const reload = vi.spyOn(analytics, "getAnalytics").mockResolvedValue({ status: 200, data: ANALYTICS } as never);
106
-
107
- await store.dispatch(analyticsActions.updateAnalytics({ analytics: ANALYTICS, siteId: null }));
108
-
109
- expect(handleError).toHaveBeenCalledWith(failure, true, "The action failed due to 2 errors.");
110
- expect(reload).not.toHaveBeenCalled();
111
- });
112
-
113
- it("AC5 · a site only writes its script code, and reloads that site", async () => {
114
- const code = vi.spyOn(analytics, "updateScriptCode").mockResolvedValue(ok as never);
115
- const dimensions = vi.spyOn(analytics, "createDimensions").mockResolvedValue(ok as never);
116
- const reload = vi.spyOn(analytics, "getAnalytics").mockResolvedValue({ status: 200, data: ANALYTICS } as never);
117
-
118
- await store.dispatch(analyticsActions.updateScriptCode({ scriptCode: "<script>site</script>", siteId: SITE_ID }));
119
-
120
- expect(code).toHaveBeenCalledWith(SITE_ID, { scriptCode: "<script>site</script>" });
121
- expect(dimensions).not.toHaveBeenCalled();
122
- expect(reload).toHaveBeenCalledWith(SITE_ID);
123
- });
124
-
125
- it("AC5 · resetting a site to the global defaults deletes its script code and reloads", async () => {
126
- const remove = vi.spyOn(analytics, "deleteScriptCode").mockResolvedValue(ok as never);
127
- const reload = vi.spyOn(analytics, "getAnalytics").mockResolvedValue({ status: 200, data: ANALYTICS } as never);
128
-
129
- await store.dispatch(analyticsActions.deleteScriptCode(SITE_ID));
130
-
131
- expect(remove).toHaveBeenCalledWith(SITE_ID);
132
- expect(reload).toHaveBeenCalledWith(SITE_ID);
133
- });
134
- });