@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.
- package/config/griddo-config/index.js +0 -1
- package/package.json +4 -3
- package/src/GlobalStore.tsx +35 -91
- package/src/__tests__/hooks/heartbeat.test.tsx +88 -0
- package/src/components/MainWrapper/index.tsx +2 -14
- package/src/containers/ActivityLog/{thunks.ts → actions.tsx} +89 -104
- package/src/containers/ActivityLog/constants.tsx +6 -0
- package/src/containers/ActivityLog/index.tsx +3 -16
- package/src/containers/ActivityLog/interfaces.tsx +12 -0
- package/src/containers/ActivityLog/reducer.tsx +24 -0
- package/src/containers/Analytics/actions.tsx +109 -0
- package/src/containers/Analytics/constants.tsx +5 -0
- package/src/containers/Analytics/index.tsx +3 -14
- package/src/containers/Analytics/interfaces.tsx +9 -0
- package/src/containers/Analytics/reducer.tsx +28 -0
- package/src/containers/Domains/actions.tsx +58 -0
- package/src/containers/Domains/constants.tsx +5 -0
- package/src/containers/Domains/index.tsx +3 -12
- package/src/containers/Domains/interfaces.tsx +9 -0
- package/src/containers/Domains/reducer.tsx +22 -0
- package/src/containers/Redirects/actions.tsx +190 -0
- package/src/containers/Redirects/constants.tsx +17 -1
- package/src/containers/Redirects/index.tsx +3 -14
- package/src/containers/Redirects/interfaces.tsx +23 -0
- package/src/containers/Redirects/reducer.tsx +35 -0
- package/src/containers/Settings/Languages/actions.tsx +88 -0
- package/src/containers/Settings/Languages/constants.tsx +6 -0
- package/src/containers/Settings/Languages/index.tsx +3 -15
- package/src/containers/Settings/Languages/reducer.tsx +31 -0
- package/src/containers/Settings/Social/actions.tsx +63 -0
- package/src/containers/Settings/Social/constants.tsx +3 -0
- package/src/containers/Settings/Social/index.tsx +3 -11
- package/src/containers/Settings/Social/interfaces.tsx +9 -0
- package/src/containers/Settings/Social/reducer.tsx +18 -0
- package/src/containers/Sites/actions.tsx +2 -6
- package/src/hooks/heartbeat.ts +32 -0
- package/src/hooks/index.tsx +2 -0
- package/src/index.tsx +6 -7
- package/src/modules/ActivityLog/ItemLog/EventItem/index.tsx +27 -13
- package/src/modules/ActivityLog/ItemLogUser/UserItem/EventItem/index.tsx +27 -13
- package/src/modules/ActivityLog/index.tsx +51 -22
- package/src/modules/Analytics/index.tsx +42 -33
- package/src/modules/App/Routing/index.tsx +4 -5
- package/src/modules/FileDrive/index.tsx +1 -1
- package/src/modules/GlobalEditor/index.tsx +6 -8
- package/src/modules/GlobalSettings/Robots/index.tsx +47 -24
- package/src/modules/MediaGallery/index.tsx +1 -1
- package/src/modules/PageEditor/index.tsx +5 -9
- package/src/modules/Redirects/RedirectItem/index.tsx +22 -9
- package/src/modules/Redirects/RedirectPanel/index.tsx +14 -13
- package/src/modules/Redirects/index.tsx +69 -26
- package/src/modules/Settings/ContentTypes/DataPacks/index.tsx +3 -4
- package/src/modules/Settings/Languages/LanguagePanel/Form/ConnectedField/index.tsx +18 -11
- package/src/modules/Settings/Languages/LanguagePanel/Form/index.tsx +9 -7
- package/src/modules/Settings/Languages/LanguagePanel/index.tsx +44 -38
- package/src/modules/Settings/Languages/Table/Header/index.tsx +19 -12
- package/src/modules/Settings/Languages/Table/Item/index.tsx +36 -21
- package/src/modules/Settings/Languages/Table/index.tsx +14 -6
- package/src/modules/Settings/Languages/index.tsx +30 -18
- package/src/modules/Settings/SeoAnalyticsSettings/Analytics/index.tsx +52 -42
- package/src/modules/Settings/Social/index.tsx +12 -17
- package/src/modules/StructuredData/StructuredDataList/index.tsx +2 -3
- package/src/types/index.tsx +6 -5
- package/tsconfig.paths.json +0 -1
- package/src/__tests__/modules/ActivityLog/ActivityLog.uc.test.tsx +0 -132
- package/src/__tests__/modules/Analytics/Analytics.uc.test.tsx +0 -168
- package/src/__tests__/modules/GlobalSettings/Robots/Robots.test.tsx +0 -110
- package/src/__tests__/modules/GlobalSettings/Robots/Robots.uc.test.tsx +0 -125
- package/src/__tests__/modules/Redirects/Redirects.uc.test.tsx +0 -164
- package/src/__tests__/modules/Settings/Languages/Languages.uc.test.tsx +0 -213
- package/src/__tests__/modules/Settings/Social/Social.uc.test.tsx +0 -171
- package/src/__tests__/store/activityLog.test.ts +0 -301
- package/src/__tests__/store/analytics.test.ts +0 -134
- package/src/__tests__/store/domains.test.ts +0 -107
- package/src/__tests__/store/languages.test.ts +0 -129
- package/src/__tests__/store/persist.test.ts +0 -47
- package/src/__tests__/store/redirects.test.ts +0 -313
- package/src/__tests__/store/social.test.ts +0 -165
- package/src/containers/ActivityLog/slice.ts +0 -45
- package/src/containers/Analytics/slice.ts +0 -48
- package/src/containers/Analytics/thunks.ts +0 -103
- package/src/containers/Domains/slice.ts +0 -37
- package/src/containers/Domains/thunks.ts +0 -60
- package/src/containers/Redirects/slice.ts +0 -60
- package/src/containers/Redirects/thunks.ts +0 -196
- package/src/containers/Settings/Languages/slice.ts +0 -48
- package/src/containers/Settings/Languages/thunks.ts +0 -83
- package/src/containers/Settings/Social/slice.ts +0 -40
- package/src/containers/Settings/Social/thunks.ts +0 -68
- package/src/store/hooks.ts +0 -37
- 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
|
-
});
|