@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,107 +0,0 @@
1
- import { domains } from "@ax/api";
2
- import { appActions } from "@ax/containers/App";
3
- import { domainsActions, domainsReducer } from "@ax/containers/Domains";
4
- import type { IDomainRobot } from "@ax/types";
5
-
6
- import { configureStore } from "@reduxjs/toolkit";
7
- import type { MockInstance } from "vitest";
8
-
9
- /**
10
- * Characterisation tests for the `domains` area — the robots.txt editor behind
11
- * `/settings/robots`, migrated to Redux Toolkit in wave 1.
12
- *
13
- * Written against the PUBLIC surface (the store and the two exported thunks),
14
- * never against reducer internals or action type constants, so this file must
15
- * keep passing unchanged once the area becomes a slice. If it needs an edit,
16
- * the shape changed and that is a finding, not an adjustment.
17
- *
18
- * It needed exactly one, and it is the finding: `dispatch(thunk())` used to
19
- * resolve to `handleRequest`'s boolean, while a createAsyncThunk resolves to the
20
- * fulfilled action and hands the value over through `.unwrap()`. Nothing about the
21
- * behaviour moved — the requests, the resulting state and the error channel below
22
- * are untouched — but every caller that reads the result of a write has to unwrap
23
- * it. That is the 457-call-site item in docs/RTK-MIGRATION.md §5.
24
- */
25
-
26
- const ROBOT: IDomainRobot = {
27
- id: 1,
28
- path: "/",
29
- fullUrl: "https://www.example.edu/",
30
- content: "User-agent: *",
31
- computedContent: "User-agent: *\nSitemap: /sitemap.xml",
32
- };
33
- const OTHER: IDomainRobot = { ...ROBOT, id: 2, path: "/es", fullUrl: "https://www.example.edu/es" };
34
-
35
- // Mirrors how GlobalStore mounts this area, minus persistence.
36
- const makeStore = () => configureStore({ reducer: { domains: domainsReducer } });
37
- type Store = ReturnType<typeof makeStore>;
38
- const robotsOf = (store: Store) => (store.getState() as { domains: { robots: IDomainRobot[] } }).domains.robots;
39
-
40
- describe("[UC-AX-configure-seo-analytics] domains state", () => {
41
- let store: Store;
42
- let handleError: MockInstance;
43
-
44
- beforeEach(() => {
45
- store = makeStore();
46
- handleError = vi.spyOn(appActions, "handleError").mockReturnValue((() => undefined) as never);
47
- });
48
-
49
- afterEach(() => {
50
- vi.restoreAllMocks();
51
- });
52
-
53
- it("AC7 · starts with no robots", () => {
54
- expect(robotsOf(store)).toEqual([]);
55
- });
56
-
57
- it("AC7 · loading keeps one entry per robot received", async () => {
58
- vi.spyOn(domains, "getDomainsRobots").mockResolvedValue({ status: 200, data: [ROBOT, OTHER] } as never);
59
-
60
- await store.dispatch(domainsActions.getDomainsRobots());
61
-
62
- expect(robotsOf(store)).toEqual([ROBOT, OTHER]);
63
- });
64
-
65
- it("AC7 · a failed load routes through the error channel and leaves the list untouched", async () => {
66
- vi.spyOn(domains, "getDomainsRobots").mockResolvedValue({ status: 500, data: {} } as never);
67
-
68
- await store.dispatch(domainsActions.getDomainsRobots());
69
-
70
- expect(handleError).toHaveBeenCalled();
71
- expect(robotsOf(store)).toEqual([]);
72
- });
73
-
74
- it("AC1 · saving puts the edited robots and reloads the list from the server", async () => {
75
- const edited = { ...ROBOT, content: "User-agent: *\nDisallow: /admin" };
76
- const update = vi.spyOn(domains, "updateDomainsRobots").mockResolvedValue({ status: 200, data: {} } as never);
77
- const reload = vi.spyOn(domains, "getDomainsRobots").mockResolvedValue({ status: 200, data: [edited] } as never);
78
-
79
- const saved = await store.dispatch(domainsActions.updateDomainsRobots([edited])).unwrap();
80
-
81
- expect(update).toHaveBeenCalledWith([edited]);
82
- expect(reload).toHaveBeenCalled();
83
- expect(saved).toBe(true);
84
- expect(robotsOf(store)).toEqual([edited]);
85
- });
86
-
87
- it("AC8 · a save failing with several messages reports the count and does not reload", async () => {
88
- const response = { status: 400, data: { message: ["bad path", "bad content", "bad domain"] } };
89
- vi.spyOn(domains, "updateDomainsRobots").mockResolvedValue(response as never);
90
- const reload = vi.spyOn(domains, "getDomainsRobots").mockResolvedValue({ status: 200, data: [] } as never);
91
-
92
- const saved = await store.dispatch(domainsActions.updateDomainsRobots([ROBOT])).unwrap();
93
-
94
- expect(handleError).toHaveBeenCalledWith(response, true, "The action failed due to 3 errors.");
95
- expect(reload).not.toHaveBeenCalled();
96
- expect(saved).toBe(false);
97
- });
98
-
99
- it("AC8 · a save failing with a single message goes through the standard error channel", async () => {
100
- const response = { status: 400, data: { message: ["bad path"] } };
101
- vi.spyOn(domains, "updateDomainsRobots").mockResolvedValue(response as never);
102
-
103
- await store.dispatch(domainsActions.updateDomainsRobots([ROBOT]));
104
-
105
- expect(handleError).toHaveBeenCalledWith(response, false, undefined);
106
- });
107
- });
@@ -1,129 +0,0 @@
1
- import { languages } from "@ax/api";
2
- import { appActions } from "@ax/containers/App";
3
- import { languagesActions, languagesReducer } from "@ax/containers/Settings";
4
- import { sitesActions } from "@ax/containers/Sites";
5
- import type { ISiteLanguage } from "@ax/types";
6
-
7
- import { configureStore } from "@reduxjs/toolkit";
8
- import type { MockInstance } from "vitest";
9
-
10
- /**
11
- * Characterisation tests for the `languages` area — the pilot of the Redux
12
- * Toolkit migration.
13
- *
14
- * The state ones are written against the PUBLIC surface (the store and the
15
- * exported action creators), never against reducer internals or action type
16
- * constants, so this exact file kept passing unchanged when the area went from
17
- * `actions.tsx + reducer.tsx + constants.tsx` to `createSlice`. That is the
18
- * point: it is the regression net for the migration, not a description of the
19
- * current implementation.
20
- */
21
-
22
- const ES: ISiteLanguage = { language: 3, path: "/es", domain: 7, isDefault: true };
23
- const SITE_ID = 42;
24
-
25
- // Mirrors how GlobalStore mounts this area, minus persistence.
26
- const makeStore = () => configureStore({ reducer: { languages: languagesReducer } });
27
- type Store = ReturnType<typeof makeStore>;
28
- const formOf = (store: Store) => (store.getState() as { languages: { form: ISiteLanguage } }).languages.form;
29
-
30
- describe("[UC-AX-manage-languages] languages state", () => {
31
- let store: Store;
32
-
33
- beforeEach(() => {
34
- store = makeStore();
35
- });
36
-
37
- it("AC2 · starts with an empty form", () => {
38
- expect(formOf(store)).toEqual({ language: null, path: "", domain: null, isDefault: false });
39
- });
40
-
41
- it("AC3 · setForm replaces the whole form with the given language", () => {
42
- store.dispatch(languagesActions.setForm(ES));
43
-
44
- expect(formOf(store)).toEqual(ES);
45
- });
46
-
47
- it("AC2 · updateFormValue merges one field and keeps the rest", () => {
48
- store.dispatch(languagesActions.setForm(ES));
49
-
50
- store.dispatch(languagesActions.updateFormValue({ path: "/es-ES" }));
51
-
52
- expect(formOf(store)).toEqual({ ...ES, path: "/es-ES" });
53
- });
54
-
55
- it("AC2 · updateFormValue applied twice accumulates both fields", () => {
56
- store.dispatch(languagesActions.updateFormValue({ language: 3 }));
57
- store.dispatch(languagesActions.updateFormValue({ path: "/es" }));
58
-
59
- expect(formOf(store)).toEqual({ language: 3, path: "/es", domain: null, isDefault: false });
60
- });
61
-
62
- it("AC3 · resetForm brings the form back to its initial state", () => {
63
- store.dispatch(languagesActions.setForm(ES));
64
-
65
- store.dispatch(languagesActions.resetForm());
66
-
67
- expect(formOf(store)).toEqual({ language: null, path: "", domain: null, isDefault: false });
68
- });
69
-
70
- it("AC2 · keeps isDefault false unless it is set explicitly", () => {
71
- store.dispatch(languagesActions.updateFormValue({ path: "/en" }));
72
-
73
- expect(formOf(store).isDefault).toBe(false);
74
- });
75
- });
76
-
77
- describe("[UC-AX-manage-languages] languages writes", () => {
78
- let store: Store;
79
- let refresh: MockInstance;
80
-
81
- const ok = { status: 200, data: {} };
82
-
83
- beforeEach(() => {
84
- store = makeStore();
85
- // The list belongs to `sites`, so every write refreshes through this thunk.
86
- refresh = vi.spyOn(sitesActions, "getSiteLanguages").mockReturnValue((() => Promise.resolve()) as never);
87
- vi.spyOn(appActions, "handleError").mockReturnValue((() => undefined) as never);
88
- });
89
-
90
- afterEach(() => {
91
- vi.restoreAllMocks();
92
- });
93
-
94
- it("AC5 · creating posts the form and reloads the list", async () => {
95
- const create = vi.spyOn(languages, "createSiteLanguage").mockResolvedValue(ok as never);
96
-
97
- await store.dispatch(languagesActions.createSiteLanguage({ siteID: SITE_ID, data: ES }));
98
-
99
- expect(create).toHaveBeenCalledWith(SITE_ID, ES);
100
- expect(refresh).toHaveBeenCalledWith(SITE_ID);
101
- });
102
-
103
- it("AC5 · updating puts the form and reloads the list", async () => {
104
- const update = vi.spyOn(languages, "updateSiteLanguage").mockResolvedValue(ok as never);
105
-
106
- await store.dispatch(languagesActions.updateSiteLanguage({ siteID: SITE_ID, data: ES }));
107
-
108
- expect(update).toHaveBeenCalledWith(SITE_ID, ES);
109
- expect(refresh).toHaveBeenCalledWith(SITE_ID);
110
- });
111
-
112
- it("AC6 · deleting removes the language and reloads the list", async () => {
113
- const remove = vi.spyOn(languages, "deleteSiteLanguage").mockResolvedValue(ok as never);
114
-
115
- await store.dispatch(languagesActions.deleteSiteLanguage({ siteID: SITE_ID, languageID: 3 }));
116
-
117
- expect(remove).toHaveBeenCalledWith(SITE_ID, 3);
118
- expect(refresh).toHaveBeenCalledWith(SITE_ID);
119
- });
120
-
121
- it("AC5 · a failed write routes through the error channel and does not reload", async () => {
122
- vi.spyOn(languages, "createSiteLanguage").mockResolvedValue({ status: 500, data: {} } as never);
123
-
124
- await store.dispatch(languagesActions.createSiteLanguage({ siteID: SITE_ID, data: ES }));
125
-
126
- expect(appActions.handleError).toHaveBeenCalled();
127
- expect(refresh).not.toHaveBeenCalled();
128
- });
129
- });
@@ -1,47 +0,0 @@
1
- import { dropBlacklistedAreas } from "../../GlobalStore";
2
-
3
- /**
4
- * The blacklist alone does not stop a rehydration, and that asymmetry is the whole
5
- * reason `dropBlacklistedAreas` exists: redux-persist filters keys when it WRITES
6
- * (`createPersistoid`), but `getStoredState` returns every key an earlier release
7
- * stored and `autoMergeLevel1` hard-sets them all back into the tree.
8
- *
9
- * So on the first boot after the deploy — before any write has replaced the blob —
10
- * the areas that just left the persist (ADR 0002) would come back from it, which is
11
- * exactly the stale state the ADR removes: another site's data, in a screen that then
12
- * PUTs it back to the server.
13
- */
14
- const persistedBlob = (areas: Record<string, unknown>) =>
15
- ({ _persist: { version: -1, rehydrated: true }, ...areas }) as never;
16
-
17
- describe("dropping the de-persisted areas on rehydrate", () => {
18
- it("drops the areas that left the persist from a blob written by an earlier release", async () => {
19
- const stored = persistedBlob({
20
- sites: { currentSiteInfo: { id: 3 } },
21
- domains: { robots: [{ id: 1, content: "stale" }] },
22
- redirects: { items: [{ id: 9 }] },
23
- activityLog: { activityLog: { totalItems: 4, items: [] } },
24
- });
25
-
26
- const migrated = (await dropBlacklistedAreas(stored)) as unknown as Record<string, unknown>;
27
-
28
- expect(Object.keys(migrated).sort()).toEqual(["_persist", "sites"]);
29
- // The areas still in the persist are untouched: dropping them would be the
30
- // opposite bug, since their consumers have no other loader.
31
- expect(migrated.sites).toEqual({ currentSiteInfo: { id: 3 } });
32
- });
33
-
34
- it("keeps the areas that are still persisted, `social` and `analytics` among them", async () => {
35
- const stored = persistedBlob({
36
- social: { twitter: "https://x.com/griddo" },
37
- analytics: { scriptCode: "<script>ga</script>", groups: [] },
38
- pageEditor: { editorContent: {} },
39
- });
40
-
41
- expect(await dropBlacklistedAreas(stored)).toEqual(stored);
42
- });
43
-
44
- it("passes an empty storage through, so a first-ever visit rehydrates nothing", async () => {
45
- expect(await dropBlacklistedAreas(undefined)).toBeUndefined();
46
- });
47
- });
@@ -1,313 +0,0 @@
1
- import { redirects } from "@ax/api";
2
- import { appActions } from "@ax/containers/App";
3
- import { redirectsActions, redirectsReducer } from "@ax/containers/Redirects";
4
- import { type ISitesState, sitesInitialState, sitesReducer } from "@ax/containers/Sites/reducer";
5
- import type { IRedirect, ISite } from "@ax/types";
6
-
7
- import { configureStore, type Reducer, type UnknownAction } from "@reduxjs/toolkit";
8
- import type { MockInstance } from "vitest";
9
-
10
- /**
11
- * Characterisation tests for the `redirects` area — the URL redirect manager behind
12
- * `/settings/redirects` (UC-AX-manage-redirects). The area had **zero** tests, so this
13
- * file is written against the legacy `actions.tsx` + `reducer.tsx` first and must keep
14
- * passing against the slice.
15
- *
16
- * The five thunks are dispatched through the one-line adapters below instead of inline.
17
- * createAsyncThunk takes exactly ONE argument, so all five change call shape when the
18
- * area migrates (docs/RTK-MIGRATION.md §5) — routing them through adapters means the
19
- * migration edits five lines and **not a single assertion**, which is what makes the
20
- * "passes unchanged" claim checkable rather than a slogan.
21
- *
22
- * Behaviours here that a rewrite loses quietly:
23
- *
24
- * - `getRedirects` **empties the list before asking**, so the table blanks instead of
25
- * showing the previous page under a spinner.
26
- * - every write reloads through `getRedirects`, and the filter it reloads with depends
27
- * on scope: `&sites={id}` when a site is loaded, otherwise whatever filter the caller
28
- * passed. Import is the exception — it reloads unfiltered.
29
- * - a **409 on create is not an error**: it means "this `from` already exists" and drives
30
- * the overwrite flow, so it must NOT reach the error toast.
31
- */
32
-
33
- const SITE_ID = 7;
34
-
35
- const PARAMS = { page: 1, itemsPerPage: 50, pagination: true, query: "", filterBy: "" };
36
- // What every write reloads with — `DEFAULT_PARAMS` in the area's constants.
37
- const RELOAD_PARAMS = { page: 1, itemsPerPage: 50, pagination: true };
38
- const SITE_FILTER = `&sites=${SITE_ID}`;
39
-
40
- const REDIRECT: IRedirect = { id: 1, from: "/old", to: { url: "/new" }, date: "2026-08-10" };
41
- const OTHER: IRedirect = { id: 2, from: "/older", to: { url: "/newer" }, date: "2026-08-09" };
42
- const FORM = { from: "/old", to: "/new" };
43
-
44
- // `ReturnType<…>` rather than naming the state type: it lives in `reducer.tsx` today and in
45
- // `slice.ts` afterwards, and this file must not have to follow it. The casts are the ones
46
- // GlobalStore uses — RTK types reducers against redux 5's UnknownAction (§8.3).
47
- type RedirectsState = ReturnType<typeof redirectsReducer>;
48
-
49
- const makeStore = (currentSiteInfo: ISite | null = null) =>
50
- configureStore({
51
- reducer: {
52
- redirects: redirectsReducer as unknown as Reducer<RedirectsState, UnknownAction>,
53
- sites: sitesReducer as Reducer<ISitesState, UnknownAction>,
54
- },
55
- preloadedState: { sites: { ...sitesInitialState, currentSiteInfo } },
56
- });
57
-
58
- type Store = ReturnType<typeof makeStore>;
59
- const stateOf = (store: Store) => store.getState().redirects;
60
-
61
- // The adapters. One line each; the migration rewrote these and nothing else.
62
- //
63
- // Two things changed here and nowhere else in this file: the single-argument rule turned
64
- // every call into an object, and `addRedirect`'s 409 callback became part of the RESULT
65
- // (`isOriginTaken`) instead of an argument — a function in `meta.arg` would trip the
66
- // serializableCheck (docs/RTK-MIGRATION.md §5). So `create` unwraps and calls the hook
67
- // itself, which keeps every assertion below untouched.
68
- const load = (store: Store, filters?: string) =>
69
- store.dispatch(redirectsActions.getRedirects({ params: PARAMS, filters }));
70
- const create = async (store: Store, onIsBeingUsed: () => void, force?: boolean, filter?: string) => {
71
- const { isOriginTaken } = await store
72
- .dispatch(redirectsActions.addRedirect({ redirect: FORM, force, filter }))
73
- .unwrap();
74
- if (isOriginTaken) onIsBeingUsed();
75
- };
76
- const edit = (store: Store, redirectID: number, filter?: string) =>
77
- store.dispatch(redirectsActions.updateRedirect({ redirect: FORM, redirectID, filter }));
78
- const remove = (store: Store, redirectID: number | number[], filter?: string) =>
79
- store.dispatch(redirectsActions.deleteRedirect({ redirectID, filter })).unwrap();
80
- const bulkImport = (store: Store, rows: { from: string; to: string }[], check: boolean) =>
81
- store.dispatch(redirectsActions.importRedirects({ rows, check })).unwrap();
82
-
83
- const listOk = (items: IRedirect[]) => ({ status: 200, data: { items, totalItems: items.length } });
84
- const ok = { status: 200, data: {} };
85
-
86
- let handleError: MockInstance;
87
-
88
- beforeEach(() => {
89
- handleError = vi.spyOn(appActions, "handleError").mockReturnValue((() => undefined) as never);
90
- });
91
-
92
- afterEach(() => {
93
- vi.restoreAllMocks();
94
- });
95
-
96
- describe("[UC-AX-manage-redirects] the redirect list", () => {
97
- it("AC1 · starts empty, with nothing imported", () => {
98
- expect(stateOf(makeStore())).toEqual({ redirects: [], totalItems: 0, totalImports: 0, imports: null });
99
- });
100
-
101
- it("AC1 · loading asks for the requested page and stores the items with the total", async () => {
102
- const store = makeStore();
103
- const get = vi.spyOn(redirects, "getRedirects").mockResolvedValue(listOk([REDIRECT, OTHER]) as never);
104
-
105
- await load(store, SITE_FILTER);
106
-
107
- expect(get).toHaveBeenCalledWith(PARAMS, SITE_FILTER);
108
- expect(stateOf(store).redirects).toEqual([REDIRECT, OTHER]);
109
- expect(stateOf(store).totalItems).toBe(2);
110
- });
111
-
112
- it("AC1 · empties the list before asking, so the table blanks instead of showing the previous page", async () => {
113
- const store = makeStore();
114
- vi.spyOn(redirects, "getRedirects").mockResolvedValue(listOk([REDIRECT, OTHER]) as never);
115
- await load(store);
116
- expect(stateOf(store).redirects).toHaveLength(2);
117
-
118
- let listWhenAsked: IRedirect[] | undefined;
119
- vi.spyOn(redirects, "getRedirects").mockImplementation((async () => {
120
- listWhenAsked = stateOf(store).redirects;
121
- return listOk([REDIRECT]);
122
- }) as never);
123
-
124
- await load(store);
125
-
126
- expect(listWhenAsked).toEqual([]);
127
- expect(stateOf(store).redirects).toEqual([REDIRECT]);
128
- });
129
-
130
- it("AC1 · a failed load routes through the error channel and leaves the list empty", async () => {
131
- const store = makeStore();
132
- vi.spyOn(redirects, "getRedirects").mockResolvedValue({ status: 500, data: {} } as never);
133
-
134
- await load(store);
135
-
136
- expect(handleError).toHaveBeenCalled();
137
- expect(stateOf(store).redirects).toEqual([]);
138
- });
139
- });
140
-
141
- describe("[UC-AX-manage-redirects] creating a redirect", () => {
142
- const onIsBeingUsed = vi.fn();
143
-
144
- beforeEach(() => {
145
- onIsBeingUsed.mockClear();
146
- });
147
-
148
- it("AC2 · posts the redirect and reloads the list filtered by the current site", async () => {
149
- const store = makeStore({ id: SITE_ID } as ISite);
150
- const post = vi.spyOn(redirects, "createRedirect").mockResolvedValue(ok as never);
151
- const reload = vi.spyOn(redirects, "getRedirects").mockResolvedValue(listOk([REDIRECT]) as never);
152
-
153
- await create(store, onIsBeingUsed);
154
-
155
- expect(post).toHaveBeenCalledWith(FORM, undefined);
156
- expect(reload).toHaveBeenCalledWith(RELOAD_PARAMS, SITE_FILTER);
157
- });
158
-
159
- it("AC2 · in global scope it reloads with the filter it was given instead of a site", async () => {
160
- const store = makeStore();
161
- vi.spyOn(redirects, "createRedirect").mockResolvedValue(ok as never);
162
- const reload = vi.spyOn(redirects, "getRedirects").mockResolvedValue(listOk([]) as never);
163
-
164
- await create(store, onIsBeingUsed, undefined, "&sites=global");
165
-
166
- expect(reload).toHaveBeenCalledWith(RELOAD_PARAMS, "&sites=global");
167
- });
168
-
169
- it("AC3 · a 409 means the origin is taken: it drives the overwrite flow, not the error toast", async () => {
170
- const store = makeStore();
171
- vi.spyOn(redirects, "createRedirect").mockResolvedValue({ status: 409, data: {} } as never);
172
- const reload = vi.spyOn(redirects, "getRedirects").mockResolvedValue(listOk([]) as never);
173
-
174
- await create(store, onIsBeingUsed);
175
-
176
- expect(onIsBeingUsed).toHaveBeenCalled();
177
- expect(handleError).not.toHaveBeenCalled();
178
- expect(reload).not.toHaveBeenCalled();
179
- });
180
-
181
- it("AC3 · confirming the overwrite posts again with force", async () => {
182
- const store = makeStore();
183
- const post = vi.spyOn(redirects, "createRedirect").mockResolvedValue(ok as never);
184
- vi.spyOn(redirects, "getRedirects").mockResolvedValue(listOk([REDIRECT]) as never);
185
-
186
- await create(store, onIsBeingUsed, true);
187
-
188
- expect(post).toHaveBeenCalledWith(FORM, true);
189
- });
190
-
191
- it("AC2 · any other failure goes through the error channel and leaves the overwrite flow alone", async () => {
192
- const store = makeStore();
193
- const response = { status: 400, data: { message: "Invalid url" } };
194
- vi.spyOn(redirects, "createRedirect").mockResolvedValue(response as never);
195
-
196
- await create(store, onIsBeingUsed);
197
-
198
- expect(handleError).toHaveBeenCalledWith(response);
199
- expect(onIsBeingUsed).not.toHaveBeenCalled();
200
- });
201
- });
202
-
203
- describe("[UC-AX-manage-redirects] editing and deleting", () => {
204
- it("AC4 · editing puts the redirect and reloads the list", async () => {
205
- const store = makeStore({ id: SITE_ID } as ISite);
206
- const put = vi.spyOn(redirects, "updateRedirect").mockResolvedValue(ok as never);
207
- const reload = vi.spyOn(redirects, "getRedirects").mockResolvedValue(listOk([REDIRECT]) as never);
208
-
209
- await edit(store, 1);
210
-
211
- expect(put).toHaveBeenCalledWith(FORM, 1);
212
- expect(reload).toHaveBeenCalledWith(RELOAD_PARAMS, SITE_FILTER);
213
- });
214
-
215
- it("AC4 · deleting one goes to the single endpoint, reloads, and reports that it deleted", async () => {
216
- const store = makeStore({ id: SITE_ID } as ISite);
217
- const single = vi.spyOn(redirects, "deleteRedirect").mockResolvedValue(ok as never);
218
- const bulk = vi.spyOn(redirects, "deleteRedirectsBulk").mockResolvedValue(ok as never);
219
- const reload = vi.spyOn(redirects, "getRedirects").mockResolvedValue(listOk([]) as never);
220
-
221
- const deleted = await remove(store, 1);
222
-
223
- expect(single).toHaveBeenCalledWith(1);
224
- expect(bulk).not.toHaveBeenCalled();
225
- expect(reload).toHaveBeenCalledWith(RELOAD_PARAMS, SITE_FILTER);
226
- // The screen only shows the "Redirect deleted" toast off this boolean.
227
- expect(deleted).toBe(true);
228
- });
229
-
230
- it("AC4 · deleting several goes to the bulk endpoint with the ids", async () => {
231
- const store = makeStore();
232
- const bulk = vi.spyOn(redirects, "deleteRedirectsBulk").mockResolvedValue(ok as never);
233
- const single = vi.spyOn(redirects, "deleteRedirect").mockResolvedValue(ok as never);
234
- vi.spyOn(redirects, "getRedirects").mockResolvedValue(listOk([]) as never);
235
-
236
- await remove(store, [1, 2]);
237
-
238
- expect(bulk).toHaveBeenCalledWith([1, 2]);
239
- expect(single).not.toHaveBeenCalled();
240
- });
241
-
242
- it("AC4 · a bulk delete failing with several messages reports the count and does not reload", async () => {
243
- const store = makeStore();
244
- const response = { status: 400, data: { message: ["in use", "not found", "forbidden"] } };
245
- vi.spyOn(redirects, "deleteRedirectsBulk").mockResolvedValue(response as never);
246
- const reload = vi.spyOn(redirects, "getRedirects").mockResolvedValue(listOk([]) as never);
247
-
248
- const deleted = await remove(store, [1, 2]);
249
-
250
- expect(handleError).toHaveBeenCalledWith(response, true, "The delete action failed due to 3 errors.");
251
- expect(reload).not.toHaveBeenCalled();
252
- expect(deleted).toBe(false);
253
- });
254
-
255
- it("AC4 · a delete failing with a single message goes through the standard error channel", async () => {
256
- const store = makeStore();
257
- const response = { status: 400, data: { message: ["in use"] } };
258
- vi.spyOn(redirects, "deleteRedirect").mockResolvedValue(response as never);
259
-
260
- await remove(store, 1);
261
-
262
- expect(handleError).toHaveBeenCalledWith(response, false, undefined);
263
- });
264
- });
265
-
266
- describe("[UC-AX-manage-redirects] importing from CSV", () => {
267
- const ROWS = [
268
- { from: "/a", to: "/b" },
269
- { from: "/c", to: "/d" },
270
- ];
271
-
272
- it("AC5 · the check phase stores the three buckets and the total, without reloading the list", async () => {
273
- const store = makeStore();
274
- const buckets = { total: 2, error: [], existing: [OTHER], ok: [REDIRECT] };
275
- const check = vi.spyOn(redirects, "createRedirectsBulkCheck").mockResolvedValue({
276
- status: 200,
277
- data: buckets,
278
- } as never);
279
- const reload = vi.spyOn(redirects, "getRedirects").mockResolvedValue(listOk([]) as never);
280
-
281
- const checked = await bulkImport(store, ROWS, true);
282
-
283
- expect(check).toHaveBeenCalledWith(ROWS);
284
- expect(stateOf(store).imports).toEqual({ error: [], existing: [OTHER], ok: [REDIRECT] });
285
- expect(stateOf(store).totalImports).toBe(2);
286
- expect(reload).not.toHaveBeenCalled();
287
- expect(checked).toBe(true);
288
- });
289
-
290
- it("AC5 · confirming posts the bulk and reloads the list unfiltered", async () => {
291
- const store = makeStore({ id: SITE_ID } as ISite);
292
- const post = vi.spyOn(redirects, "createRedirectsBulk").mockResolvedValue(ok as never);
293
- const reload = vi.spyOn(redirects, "getRedirects").mockResolvedValue(listOk([REDIRECT]) as never);
294
-
295
- await bulkImport(store, ROWS, false);
296
-
297
- expect(post).toHaveBeenCalledWith(ROWS);
298
- // Unfiltered even with a site loaded — the only write that ignores the scope.
299
- expect(reload).toHaveBeenCalledWith(RELOAD_PARAMS, undefined);
300
- });
301
-
302
- it("AC5 · an import failing with several messages reports the count", async () => {
303
- const store = makeStore();
304
- const response = { status: 400, data: { message: ["row 1 invalid", "row 2 invalid"] } };
305
- vi.spyOn(redirects, "createRedirectsBulkCheck").mockResolvedValue(response as never);
306
-
307
- const checked = await bulkImport(store, ROWS, true);
308
-
309
- expect(handleError).toHaveBeenCalledWith(response, true, "The import action failed due to 2 errors.");
310
- expect(stateOf(store).imports).toBeNull();
311
- expect(checked).toBe(false);
312
- });
313
- });