@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,110 +0,0 @@
1
- import { Router } from "react-router-dom";
2
-
3
- import { parseTheme } from "@ax/helpers";
4
- import Robots from "@ax/modules/GlobalSettings/Robots";
5
- import history from "@ax/routes/history";
6
- import globalTheme from "@ax/themes/theme.json";
7
- import type { IDomainRobot } from "@ax/types";
8
-
9
- import axios from "axios";
10
- import configureStore from "redux-mock-store";
11
- import thunk from "redux-thunk";
12
- import { ThemeProvider } from "styled-components";
13
- import type { MockedFunction } from "vitest";
14
-
15
- import { act, cleanup, fireEvent, render, screen } from "../../../../../config/tests/test-utils";
16
-
17
- vi.mock("axios");
18
- const mockedAxios = axios as MockedFunction<typeof axios>;
19
-
20
- const mockComponent = () => null;
21
- const navItems = [{ title: "Robots.txt", path: "/settings/robots", component: mockComponent }];
22
- const currentNavItem = navItems[0];
23
-
24
- const ROBOT: IDomainRobot = {
25
- id: 1,
26
- path: "/",
27
- fullUrl: "https://www.example.edu/",
28
- content: "User-agent: *",
29
- computedContent: "User-agent: *",
30
- };
31
- const OTHER: IDomainRobot = { ...ROBOT, id: 2, path: "/es", fullUrl: "https://www.example.edu/es" };
32
-
33
- const createMockStore = (overrides: Record<string, unknown> = {}) => {
34
- const defaultStore = {
35
- app: { isLoading: false, isSaving: false },
36
- domains: { robots: [ROBOT, OTHER] },
37
- ...overrides,
38
- };
39
- return configureStore([thunk] as never)(defaultStore);
40
- };
41
-
42
- const renderScreen = async (store: ReturnType<typeof createMockStore>) => {
43
- await act(async () => {
44
- render(
45
- <Router history={history}>
46
- <ThemeProvider theme={parseTheme(globalTheme)}>
47
- <Robots navItems={navItems} currentNavItem={currentNavItem} />
48
- </ThemeProvider>
49
- </Router>,
50
- { store },
51
- );
52
- });
53
- };
54
-
55
- // The screen is asserted through the requests it makes, not through the action
56
- // creators it calls: `connect` captures those by reference when the module is
57
- // evaluated, so spying on them is invisible here — and would break anyway the
58
- // moment the area moves to hooks.
59
- const requestsTo = (endpoint: string, method: string) =>
60
- mockedAxios.mock.calls
61
- .map(([config]) => config as { url?: string; method?: string; data?: unknown })
62
- .filter((config) => config?.url?.endsWith(endpoint) && config?.method === method);
63
-
64
- beforeEach(() => {
65
- mockedAxios.mockResolvedValue({ status: 200, data: [], statusText: "OK", headers: {}, config: {} as never });
66
- });
67
-
68
- afterEach(() => {
69
- vi.restoreAllMocks();
70
- cleanup();
71
- });
72
-
73
- describe("[UC-AX-configure-seo-analytics] robots.txt editor", () => {
74
- it("AC1 · loads the robots on mount", async () => {
75
- await renderScreen(createMockStore());
76
-
77
- expect(requestsTo("/domains/robots", "GET")).toHaveLength(1);
78
- });
79
-
80
- it("AC7 · shows one row per robot received", async () => {
81
- await renderScreen(createMockStore());
82
-
83
- expect(screen.getAllByText("Edit robots.txt")).toHaveLength(2);
84
- expect(screen.getAllByText(ROBOT.fullUrl, { exact: false }).length).toBeGreaterThan(0);
85
- expect(screen.getAllByText(OTHER.fullUrl, { exact: false }).length).toBeGreaterThan(0);
86
- });
87
-
88
- it("AC7 · shows the loading state instead of the rows while app.isLoading", async () => {
89
- await renderScreen(createMockStore({ app: { isLoading: true, isSaving: false } }));
90
-
91
- expect(screen.queryByText("Edit robots.txt")).toBeNull();
92
- });
93
-
94
- it("AC1 · saving sends the robots currently on screen to the bulk endpoint", async () => {
95
- await renderScreen(createMockStore());
96
- await act(async () => {
97
- fireEvent.click(screen.getByText("Save"));
98
- });
99
-
100
- const [save] = requestsTo("/domains/robots/bulk", "PUT");
101
- expect(save?.data).toEqual({ robots: [ROBOT, OTHER] });
102
- });
103
-
104
- it("AC7 · the save button reports progress while app.isSaving", async () => {
105
- await renderScreen(createMockStore({ app: { isLoading: false, isSaving: true } }));
106
-
107
- expect(screen.getByText("Saving")).toBeTruthy();
108
- expect(screen.queryByText("Save")).toBeNull();
109
- });
110
- });
@@ -1,125 +0,0 @@
1
- import { Router } from "react-router-dom";
2
-
3
- import { appReducer, type IAppState } from "@ax/containers/App/reducer";
4
- import { domainsReducer } from "@ax/containers/Domains";
5
- import { parseTheme } from "@ax/helpers";
6
- import Robots from "@ax/modules/GlobalSettings/Robots";
7
- import history from "@ax/routes/history";
8
- import globalTheme from "@ax/themes/theme.json";
9
- import type { IDomainRobot } from "@ax/types";
10
-
11
- import { configureStore, type Reducer, type UnknownAction } from "@reduxjs/toolkit";
12
- import axios from "axios";
13
- import { ThemeProvider } from "styled-components";
14
- import type { MockedFunction } from "vitest";
15
-
16
- import { act, cleanup, fireEvent, render, screen } from "../../../../../config/tests/test-utils";
17
-
18
- /**
19
- * End-to-end test of the use case slice: API → thunk → slice → selector → UI,
20
- * with only axios mocked. Named `.uc.` and not `.flow.` because "flow" is taken in
21
- * GAINUP: an `F-<slug>` is a multi-package spec that links UCs and has no tests of
22
- * its own.
23
- *
24
- * The sibling Robots.test.tsx mounts the same screen on a `redux-mock-store`,
25
- * which accepts dispatches without reducing them — so it can only assert on the
26
- * requests that leave. Here the store is the real one, so what the screen renders
27
- * IS the result of the reducer running on the server's response, which is what the
28
- * acceptance criteria actually claim.
29
- *
30
- * Only the slices this screen reads are mounted, not the whole root reducer: the
31
- * legacy areas still hold non-serialisable values (Dates, a function in
32
- * `app.error.btnAction`) that would trip RTK's dev checks for reasons that have
33
- * nothing to do with this use case.
34
- */
35
- vi.mock("axios");
36
- const mockedAxios = axios as MockedFunction<typeof axios>;
37
-
38
- const mockComponent = () => null;
39
- const navItems = [{ title: "Robots.txt", path: "/settings/robots", component: mockComponent }];
40
-
41
- const EMPTY: IDomainRobot = {
42
- id: 1,
43
- path: "/",
44
- fullUrl: "https://www.example.edu/",
45
- content: "",
46
- computedContent: "",
47
- };
48
-
49
- const ok = (data: unknown) => ({ status: 200, data, statusText: "OK", headers: {}, config: {} as never });
50
-
51
- const makeStore = () =>
52
- configureStore({
53
- // `app` is still a legacy reducer, so it needs the same cast GlobalStore uses:
54
- // RTK types reducers against redux 5's UnknownAction and the hand-written
55
- // legacy signatures are not assignable to it (docs/RTK-MIGRATION.md §8.3).
56
- reducer: { app: appReducer as Reducer<IAppState, UnknownAction>, domains: domainsReducer },
57
- middleware: (getDefault) => getDefault({ serializableCheck: false, immutableCheck: false }),
58
- });
59
-
60
- const renderScreen = async (store: ReturnType<typeof makeStore>) => {
61
- await act(async () => {
62
- render(
63
- <Router history={history}>
64
- <ThemeProvider theme={parseTheme(globalTheme)}>
65
- <Robots navItems={navItems} currentNavItem={navItems[0]} />
66
- </ThemeProvider>
67
- </Router>,
68
- { store },
69
- );
70
- });
71
- };
72
-
73
- afterEach(() => {
74
- vi.restoreAllMocks();
75
- cleanup();
76
- });
77
-
78
- describe("[UC-AX-configure-seo-analytics] robots.txt end to end", () => {
79
- it("AC1 · what the server returns after saving is what the screen shows", async () => {
80
- const store = makeStore();
81
- const filled = { ...EMPTY, content: "User-agent: *", computedContent: "User-agent: *" };
82
-
83
- // Mount: the domain has no robots.txt yet, so the row offers to add one.
84
- mockedAxios.mockResolvedValueOnce(ok([EMPTY]));
85
- await renderScreen(store);
86
-
87
- expect(screen.getByText("Add robots.txt")).toBeTruthy();
88
-
89
- // Saving: PUT, then the reload that the thunk chains, now with content.
90
- mockedAxios.mockResolvedValueOnce(ok({})).mockResolvedValueOnce(ok([filled]));
91
- await act(async () => {
92
- fireEvent.click(screen.getByText("Save"));
93
- });
94
-
95
- // The row flipped because the reducer ran on the reloaded response — this is
96
- // the assertion a mock store cannot make.
97
- expect(screen.getByText("Edit robots.txt")).toBeTruthy();
98
- expect(screen.queryByText("Add robots.txt")).toBeNull();
99
- expect(store.getState().domains.robots).toEqual([filled]);
100
- });
101
-
102
- it("AC7 · the screen shows the loading state while the load is in flight", async () => {
103
- const store = makeStore();
104
- // Held open on purpose: the loading state only exists while the request is in
105
- // flight, so the test needs to look at the screen before letting it resolve.
106
- let releaseLoad: (value: unknown) => void = () => undefined;
107
- const pending = new Promise((resolve) => {
108
- releaseLoad = resolve;
109
- });
110
- mockedAxios.mockReturnValueOnce(pending as never);
111
-
112
- await renderScreen(store);
113
-
114
- // setIsLoading(true) has gone through the real `app` reducer by now.
115
- expect(store.getState().app.isLoading).toBe(true);
116
- expect(screen.queryByText("Add robots.txt")).toBeNull();
117
-
118
- await act(async () => {
119
- releaseLoad(ok([EMPTY]));
120
- });
121
-
122
- expect(store.getState().app.isLoading).toBe(false);
123
- expect(screen.getByText("Add robots.txt")).toBeTruthy();
124
- });
125
- });
@@ -1,164 +0,0 @@
1
- import { Router } from "react-router-dom";
2
-
3
- import { appReducer, type IAppState } from "@ax/containers/App/reducer";
4
- import { redirectsReducer } from "@ax/containers/Redirects";
5
- import { type ISitesState, sitesReducer } from "@ax/containers/Sites/reducer";
6
- import { parseTheme } from "@ax/helpers";
7
- import Redirects from "@ax/modules/Redirects";
8
- import history from "@ax/routes/history";
9
- import globalTheme from "@ax/themes/theme.json";
10
- import type { INavItem, IRedirect } 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 redirect manager on the real store — view, thunks, api layer and
21
- * slice, with only axios mocked (see the `## Profile` section of docs/gainup/README.md for
22
- * why a `redux-mock-store` test does not count as AC coverage).
23
- *
24
- * The reason this file exists rather than leaving the area to its store tests: AC3 is the
25
- * flow the migration redesigned. A taken origin (409) used to travel out of the thunk
26
- * through an `errorAction` CALLBACK passed as an argument, which createAsyncThunk would
27
- * have parked in `meta.arg` and the serializableCheck would have flagged. It now comes back
28
- * as data and the SCREEN opens the modal — and the only way to know that seam still works
29
- * is to drive it from the button, through the store, to the retry with `?force=1`.
30
- */
31
- vi.mock("axios");
32
- const mockedAxios = axios as MockedFunction<typeof axios>;
33
-
34
- // The panel's "New URL" field mounts the CMS page finder, which indexes the instance's whole
35
- // page tree off `sites.sites[0]` and the active language. It is incidental to the seam under
36
- // test — 409 → overwrite → forced retry — and its fixtures would dominate this file, so the
37
- // component is stubbed. It has to be mocked at its own module, not through the `@ax/components`
38
- // barrel: `UrlField` reads PageFinder back OUT of that barrel, so a partial barrel mock loses
39
- // the race against the circular import and the real one renders anyway.
40
- vi.mock("@ax/components/PageFinder", () => ({ default: () => null }));
41
-
42
- const REDIRECT: IRedirect = {
43
- id: 1,
44
- from: "/old-page",
45
- to: { url: "/new-page" },
46
- date: "2026-08-10",
47
- site: { siteId: null, siteName: null, siteUrl: null },
48
- };
49
-
50
- const mockComponent = () => null;
51
- const NAV_ITEMS: INavItem[] = [
52
- { title: "Redirects", path: "/settings/redirects", component: mockComponent },
53
- { title: "Robots", path: "/settings/robots", component: mockComponent },
54
- ];
55
-
56
- const makeStore = () =>
57
- configureStore({
58
- // Only what this screen reads. `app` and `sites` are still legacy, so they need the
59
- // cast GlobalStore uses (docs/RTK-MIGRATION.md §8.3).
60
- reducer: {
61
- app: appReducer as Reducer<IAppState, UnknownAction>,
62
- redirects: redirectsReducer,
63
- sites: sitesReducer as Reducer<ISitesState, UnknownAction>,
64
- },
65
- middleware: (getDefault) => getDefault({ serializableCheck: false, immutableCheck: false }),
66
- // No preloadedState: each area's own initial state is the global scope this screen has
67
- // to work in — `currentSiteInfo` null, which is where the Site column and filter show.
68
- });
69
-
70
- const answer = (status: number, data: unknown) => ({ status, data, statusText: "", headers: {}, config: {} });
71
-
72
- const callsTo = (method: string, urlPart: string) =>
73
- mockedAxios.mock.calls
74
- .map(([config]) => config as { method?: string; url?: string; data?: unknown })
75
- .filter((config) => config?.method === method && (config?.url ?? "").includes(urlPart));
76
-
77
- const renderScreen = async (store: ReturnType<typeof makeStore>) => {
78
- await act(async () => {
79
- render(
80
- <Router history={history}>
81
- <ThemeProvider theme={parseTheme(globalTheme)}>
82
- <Redirects navItems={NAV_ITEMS} currentNavItem={NAV_ITEMS[0]} />
83
- </ThemeProvider>
84
- </Router>,
85
- { store },
86
- );
87
- });
88
- };
89
-
90
- const click = async (name: string | RegExp) => {
91
- await act(async () => {
92
- fireEvent.click(screen.getByRole("button", { name }));
93
- });
94
- };
95
-
96
- beforeAll(() => {
97
- // jsdom has no `Element.scrollTo`, and the screen scrolls the table back to the top on
98
- // every page or filter change. Without this the effect throws and takes the screen with it.
99
- Element.prototype.scrollTo = () => undefined;
100
- });
101
-
102
- beforeEach(() => {
103
- // A POST collides the first time and goes through once forced, which is the shape of the
104
- // overwrite flow: the backend answers 409 until `?force=1` says to replace.
105
- mockedAxios.mockImplementation((async (config: { method?: string; url?: string }) => {
106
- const { method = "", url = "" } = config ?? {};
107
-
108
- // The screen's chrome fetches its own option lists too (the Site filter asks
109
- // `/select/sites`), so only the list endpoint answers with redirects.
110
- if (method === "GET") {
111
- return url.includes("/redirects") ? answer(200, { items: [REDIRECT], totalItems: 1 }) : answer(200, []);
112
- }
113
- if (method === "POST") return url.includes("force=1") ? answer(200, {}) : answer(409, {});
114
- return answer(200, {});
115
- }) as never);
116
- });
117
-
118
- afterEach(() => {
119
- vi.restoreAllMocks();
120
- cleanup();
121
- });
122
-
123
- describe("[UC-AX-manage-redirects] the redirects screen end to end", () => {
124
- it("AC1 · mounting asks for the first page of 50 and paints the redirects it gets", async () => {
125
- const store = makeStore();
126
-
127
- await renderScreen(store);
128
-
129
- const [list] = callsTo("GET", "/redirects");
130
- expect(list.url).toContain("page=1");
131
- expect(list.url).toContain("itemsPerPage=50");
132
- expect(store.getState().redirects.totalItems).toBe(1);
133
- expect(screen.getByText(REDIRECT.from)).toBeTruthy();
134
- expect(screen.getByText("/new-page")).toBeTruthy();
135
- });
136
-
137
- it("AC3 · a taken origin opens the overwrite modal instead of the error toast", async () => {
138
- const store = makeStore();
139
- await renderScreen(store);
140
-
141
- await click("New Redirect");
142
- await click("Add Redirect");
143
-
144
- expect(callsTo("POST", "/redirect")).toHaveLength(1);
145
- expect(callsTo("POST", "/redirect")[0].url).not.toContain("force");
146
- // The 409 travelled back as data and the screen turned it into this modal. It is also
147
- // NOT an error: the error channel stayed empty, so no toast.
148
- expect(screen.getByText(/already created/i)).toBeTruthy();
149
- expect(store.getState().app.error.text).toBe("");
150
- });
151
-
152
- it("AC3 · confirming the overwrite retries the very same redirect with force", async () => {
153
- await renderScreen(makeStore());
154
-
155
- await click("New Redirect");
156
- await click("Add Redirect");
157
- await click(/Overwrite redirect/i);
158
-
159
- const posts = callsTo("POST", "/redirect");
160
- expect(posts).toHaveLength(2);
161
- expect(posts[1].url).toContain("force=1");
162
- expect(posts[1].data).toEqual(posts[0].data);
163
- });
164
- });
@@ -1,213 +0,0 @@
1
- import { Router } from "react-router-dom";
2
-
3
- import { appReducer, type IAppState } from "@ax/containers/App/reducer";
4
- import { languagesReducer } from "@ax/containers/Settings";
5
- import { type ISitesState, sitesInitialState, sitesReducer } from "@ax/containers/Sites/reducer";
6
- import { type IUsersState, usersInitialState, usersReducer } from "@ax/containers/Users/reducer";
7
- import { parseTheme } from "@ax/helpers";
8
- import Languages from "@ax/modules/Settings/Languages";
9
- import LanguagePanel from "@ax/modules/Settings/Languages/LanguagePanel";
10
- import Table from "@ax/modules/Settings/Languages/Table";
11
- import history from "@ax/routes/history";
12
- import globalTheme from "@ax/themes/theme.json";
13
- import type { INavItem, ISite, IUser } from "@ax/types";
14
-
15
- import { configureStore, type Reducer, type UnknownAction } from "@reduxjs/toolkit";
16
- import axios from "axios";
17
- import { ThemeProvider } from "styled-components";
18
- import type { MockedFunction } from "vitest";
19
-
20
- import { act, cleanup, fireEvent, render, screen } from "../../../../../config/tests/test-utils";
21
-
22
- /**
23
- * End-to-end tests of the languages screen on the real store — view, thunks, api layer
24
- * and slice, with only axios mocked (see the `## Profile` section of docs/gainup/README.md
25
- * for why a `redux-mock-store` test does not count as AC coverage).
26
- *
27
- * This file holds every AC of UC-AX-manage-languages that is verified at view level. AC1,
28
- * AC4 and AC7 were written on a mock store first, which made them the only evidence for a
29
- * UC promoted to `active` while resting on a store that never runs a reducer — so they were
30
- * moved here, where mounting really fetches and the state really reduces.
31
- *
32
- * The last two guard a cost rather than a use case: every panel mounts the Language and
33
- * Domain AsyncSelects and each fetches on mount, so with one panel per row plus one in the
34
- * header, mounting them eagerly meant N+1 requests for `/select/domains` just for landing.
35
- */
36
- vi.mock("axios");
37
- const mockedAxios = axios as MockedFunction<typeof axios>;
38
-
39
- const SITE_ID = 1;
40
- const EN = { id: 10, language: "English", locale: "en_GB", path: "/en", domain: 5, isDefault: true, label: "en" };
41
- const ES = { id: 20, language: "Spanish", locale: "es_ES", path: "/es", domain: 6, isDefault: false, label: "es" };
42
-
43
- const mockComponent = () => null;
44
- const NAV_ITEMS: INavItem[] = [{ title: "Languages", path: "/settings/languages", component: mockComponent }];
45
-
46
- const domainSelectCalls = () =>
47
- mockedAxios.mock.calls.filter(([config]) => (config as { url?: string })?.url?.endsWith("/select/domains")).length;
48
-
49
- const makeStore = ({ permissions = ["general.editLanguage", "general.createLanguages"] } = {}) =>
50
- configureStore({
51
- // The legacy reducers need the same cast GlobalStore uses: RTK types reducers
52
- // against redux 5's UnknownAction, which the hand-written legacy signatures are
53
- // not assignable to (docs/RTK-MIGRATION.md §8.3).
54
- reducer: {
55
- app: appReducer as Reducer<IAppState, UnknownAction>,
56
- languages: languagesReducer,
57
- sites: sitesReducer as Reducer<ISitesState, UnknownAction>,
58
- users: usersReducer as Reducer<IUsersState, UnknownAction>,
59
- },
60
- middleware: (getDefault) => getDefault({ serializableCheck: false, immutableCheck: false }),
61
- // Built off each area's own initialState so the preloaded shape stays a real
62
- // state and not a hand-made object the types have to be told to accept.
63
- preloadedState: {
64
- sites: { ...sitesInitialState, currentSiteInfo: { id: SITE_ID } as ISite, currentSiteLanguages: [EN, ES] },
65
- users: {
66
- ...usersInitialState,
67
- currentUser: { isSuperAdmin: false } as IUser,
68
- currentPermissions: permissions,
69
- globalPermissions: [],
70
- roles: [],
71
- },
72
- },
73
- });
74
-
75
- const renderScreen = async (store: ReturnType<typeof makeStore>, ui: JSX.Element) => {
76
- await act(async () => {
77
- render(
78
- <Router history={history}>
79
- <ThemeProvider theme={parseTheme(globalTheme)}>{ui}</ThemeProvider>
80
- </Router>,
81
- { store },
82
- );
83
- });
84
- };
85
-
86
- const renderTable = (store: ReturnType<typeof makeStore>) => renderScreen(store, <Table />);
87
-
88
- const languagesCalls = () =>
89
- mockedAxios.mock.calls.filter(([config]) =>
90
- (config as { url?: string })?.url?.endsWith(`/site/${SITE_ID}/languages`),
91
- );
92
-
93
- beforeEach(() => {
94
- // An ARRAY, not `{items}`: the AsyncSelect fields assign `result.data` straight into
95
- // their option list. The site's own languages are routed by url so the screen test can
96
- // assert the round trip instead of a spy — everything else answers an empty list.
97
- mockedAxios.mockImplementation((async (config: { url?: string }) => ({
98
- status: 200,
99
- data: config?.url?.endsWith(`/site/${SITE_ID}/languages`) ? { items: [EN, ES] } : [],
100
- statusText: "OK",
101
- headers: {},
102
- config: {},
103
- })) as never);
104
- });
105
-
106
- afterEach(() => {
107
- vi.restoreAllMocks();
108
- cleanup();
109
- });
110
-
111
- describe("[UC-AX-manage-languages] the languages screen end to end", () => {
112
- it("AC1 · mounting asks the current site for its languages and lists what came back", async () => {
113
- const store = makeStore();
114
-
115
- await renderScreen(store, <Languages navItems={NAV_ITEMS} currentNavItem={NAV_ITEMS[0]} />);
116
-
117
- expect(languagesCalls()).toHaveLength(1);
118
- // The round trip a mock store cannot see: the answer really reduced into `sites`.
119
- expect(store.getState().sites.currentSiteLanguages).toEqual([EN, ES]);
120
- expect(screen.getByText(EN.language)).toBeTruthy();
121
- });
122
-
123
- it("AC1 · shows the loading state instead of the list while the request is in flight", async () => {
124
- // `getSiteLanguages` raises and releases `app.isLoading` around its own request, so
125
- // the loading state only exists while it is pending: preloading the flag would be
126
- // cleared by the fetch itself. Holding the answer is what makes that window visible.
127
- let answerGet = () => {};
128
- mockedAxios.mockImplementation(
129
- (() =>
130
- new Promise((resolve) => {
131
- answerGet = () =>
132
- resolve({ status: 200, data: { items: [EN, ES] }, statusText: "OK", headers: {}, config: {} });
133
- })) as never,
134
- );
135
-
136
- await renderScreen(makeStore(), <Languages navItems={NAV_ITEMS} currentNavItem={NAV_ITEMS[0]} />);
137
-
138
- expect(screen.queryByText(EN.language)).toBeNull();
139
-
140
- await act(async () => {
141
- answerGet();
142
- });
143
-
144
- // And the list is what replaces it, so the absence above was the loading state and
145
- // not an empty screen.
146
- expect(screen.getByText(EN.language)).toBeTruthy();
147
- });
148
- });
149
-
150
- describe("[UC-AX-manage-languages] the language panel", () => {
151
- it("AC4 · editing shows the fixed language and no language selector", async () => {
152
- await renderScreen(makeStore(), <LanguagePanel isOpen={true} toggleModal={vi.fn()} item={EN as never} />);
153
-
154
- expect(screen.getByText("You are editing")).toBeTruthy();
155
- expect(screen.getByText("Update Language")).toBeTruthy();
156
- });
157
-
158
- it("AC4 · creating offers the language selector instead", async () => {
159
- await renderScreen(makeStore(), <LanguagePanel isOpen={true} toggleModal={vi.fn()} />);
160
-
161
- expect(screen.queryByText("You are editing")).toBeNull();
162
- expect(screen.getByText("New Language")).toBeTruthy();
163
- });
164
- });
165
-
166
- describe("[UC-AX-manage-languages] permissions", () => {
167
- it("AC7 · without general.createLanguages there is no add action", async () => {
168
- await renderTable(makeStore({ permissions: [] }));
169
-
170
- expect(screen.queryByTestId("icon-action-add")).toBeNull();
171
- });
172
-
173
- it("AC7 · with general.createLanguages the add action is offered", async () => {
174
- await renderTable(makeStore({ permissions: ["general.createLanguages"] }));
175
-
176
- expect(screen.getByTestId("icon-action-add")).toBeTruthy();
177
- });
178
-
179
- it("AC7 · the row count reflects the site languages", async () => {
180
- await renderTable(makeStore());
181
-
182
- expect(screen.getByText("2 items")).toBeTruthy();
183
- });
184
- });
185
-
186
- describe("languages table panel mounting", () => {
187
- it("does not fetch the domain options until a panel is opened", async () => {
188
- await renderTable(makeStore());
189
-
190
- // Two rows plus the header panel: this used to be 3 requests.
191
- expect(screen.getByText(EN.language)).toBeTruthy();
192
- expect(domainSelectCalls()).toBe(0);
193
- });
194
-
195
- it("fetches them once when a row opens its panel", async () => {
196
- const store = makeStore();
197
- await renderTable(store);
198
-
199
- await act(async () => {
200
- fireEvent.click(screen.getByText(ES.language));
201
- });
202
-
203
- expect(domainSelectCalls()).toBe(1);
204
- // Opening also seeds the form from the row, which is what lets the panel
205
- // mount late without losing anything.
206
- expect(store.getState().languages.form).toEqual({
207
- language: ES.id,
208
- path: ES.path,
209
- domain: ES.domain,
210
- isDefault: ES.isDefault,
211
- });
212
- });
213
- });