@griddo/ax 12.6.0 → 12.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/griddo-config/index.js +1 -0
- package/package.json +3 -4
- package/src/GlobalStore.tsx +91 -35
- package/src/__tests__/modules/ActivityLog/ActivityLog.uc.test.tsx +132 -0
- package/src/__tests__/modules/Analytics/Analytics.uc.test.tsx +168 -0
- package/src/__tests__/modules/GlobalSettings/Robots/Robots.test.tsx +110 -0
- package/src/__tests__/modules/GlobalSettings/Robots/Robots.uc.test.tsx +125 -0
- package/src/__tests__/modules/Redirects/Redirects.uc.test.tsx +164 -0
- package/src/__tests__/modules/Settings/Languages/Languages.uc.test.tsx +213 -0
- package/src/__tests__/modules/Settings/Social/Social.uc.test.tsx +171 -0
- package/src/__tests__/store/activityLog.test.ts +301 -0
- package/src/__tests__/store/analytics.test.ts +134 -0
- package/src/__tests__/store/domains.test.ts +107 -0
- package/src/__tests__/store/languages.test.ts +129 -0
- package/src/__tests__/store/persist.test.ts +47 -0
- package/src/__tests__/store/redirects.test.ts +313 -0
- package/src/__tests__/store/social.test.ts +165 -0
- package/src/components/MainWrapper/index.tsx +14 -2
- package/src/containers/ActivityLog/index.tsx +16 -3
- package/src/containers/ActivityLog/slice.ts +45 -0
- package/src/containers/ActivityLog/{actions.tsx → thunks.ts} +104 -89
- package/src/containers/Analytics/index.tsx +14 -3
- package/src/containers/Analytics/slice.ts +48 -0
- package/src/containers/Analytics/thunks.ts +103 -0
- package/src/containers/Domains/index.tsx +12 -3
- package/src/containers/Domains/slice.ts +37 -0
- package/src/containers/Domains/thunks.ts +60 -0
- package/src/containers/Redirects/constants.tsx +1 -17
- package/src/containers/Redirects/index.tsx +14 -3
- package/src/containers/Redirects/slice.ts +60 -0
- package/src/containers/Redirects/thunks.ts +196 -0
- package/src/containers/Settings/Languages/index.tsx +15 -3
- package/src/containers/Settings/Languages/slice.ts +48 -0
- package/src/containers/Settings/Languages/thunks.ts +83 -0
- package/src/containers/Settings/Social/index.tsx +11 -3
- package/src/containers/Settings/Social/slice.ts +40 -0
- package/src/containers/Settings/Social/thunks.ts +68 -0
- package/src/containers/Sites/actions.tsx +6 -2
- package/src/index.tsx +7 -6
- package/src/modules/ActivityLog/ItemLog/EventItem/index.tsx +13 -27
- package/src/modules/ActivityLog/ItemLogUser/UserItem/EventItem/index.tsx +13 -27
- package/src/modules/ActivityLog/index.tsx +22 -51
- package/src/modules/Analytics/index.tsx +33 -42
- package/src/modules/App/Routing/index.tsx +5 -4
- package/src/modules/FileDrive/index.tsx +1 -1
- package/src/modules/GlobalSettings/Robots/index.tsx +24 -47
- package/src/modules/MediaGallery/index.tsx +1 -1
- package/src/modules/Redirects/RedirectItem/index.tsx +9 -22
- package/src/modules/Redirects/RedirectPanel/index.tsx +13 -14
- package/src/modules/Redirects/index.tsx +26 -69
- package/src/modules/Settings/ContentTypes/DataPacks/index.tsx +4 -3
- package/src/modules/Settings/Languages/LanguagePanel/Form/ConnectedField/index.tsx +11 -18
- package/src/modules/Settings/Languages/LanguagePanel/Form/index.tsx +7 -9
- package/src/modules/Settings/Languages/LanguagePanel/index.tsx +38 -44
- package/src/modules/Settings/Languages/Table/Header/index.tsx +12 -19
- package/src/modules/Settings/Languages/Table/Item/index.tsx +21 -36
- package/src/modules/Settings/Languages/Table/index.tsx +6 -14
- package/src/modules/Settings/Languages/index.tsx +18 -30
- package/src/modules/Settings/SeoAnalyticsSettings/Analytics/index.tsx +42 -52
- package/src/modules/Settings/Social/index.tsx +17 -12
- package/src/modules/StructuredData/StructuredDataList/index.tsx +3 -2
- package/src/store/hooks.ts +37 -0
- package/src/store/index.ts +2 -0
- package/src/types/index.tsx +5 -6
- package/tsconfig.paths.json +1 -0
- package/src/containers/ActivityLog/constants.tsx +0 -6
- package/src/containers/ActivityLog/interfaces.tsx +0 -12
- package/src/containers/ActivityLog/reducer.tsx +0 -24
- package/src/containers/Analytics/actions.tsx +0 -109
- package/src/containers/Analytics/constants.tsx +0 -5
- package/src/containers/Analytics/interfaces.tsx +0 -9
- package/src/containers/Analytics/reducer.tsx +0 -28
- package/src/containers/Domains/actions.tsx +0 -58
- package/src/containers/Domains/constants.tsx +0 -5
- package/src/containers/Domains/interfaces.tsx +0 -9
- package/src/containers/Domains/reducer.tsx +0 -22
- package/src/containers/Redirects/actions.tsx +0 -190
- package/src/containers/Redirects/interfaces.tsx +0 -23
- package/src/containers/Redirects/reducer.tsx +0 -35
- package/src/containers/Settings/Languages/actions.tsx +0 -88
- package/src/containers/Settings/Languages/constants.tsx +0 -6
- package/src/containers/Settings/Languages/reducer.tsx +0 -31
- package/src/containers/Settings/Social/actions.tsx +0 -63
- package/src/containers/Settings/Social/constants.tsx +0 -3
- package/src/containers/Settings/Social/interfaces.tsx +0 -9
- package/src/containers/Settings/Social/reducer.tsx +0 -18
|
@@ -0,0 +1,47 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,313 @@
|
|
|
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
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { social } from "@ax/api";
|
|
2
|
+
import { appActions } from "@ax/containers/App";
|
|
3
|
+
import { socialActions, socialReducer } from "@ax/containers/Settings";
|
|
4
|
+
import { type ISitesState, sitesInitialState, sitesReducer } from "@ax/containers/Sites/reducer";
|
|
5
|
+
import type { ISite, ISocialState } 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 `social` area — the per-site social profile URLs
|
|
12
|
+
* behind `/sites/settings/social` (UC-AX-configure-social-networks).
|
|
13
|
+
*
|
|
14
|
+
* Written against the PUBLIC surface (the store and the exported action creators),
|
|
15
|
+
* never against reducer internals or action type constants, so this file must keep
|
|
16
|
+
* passing unchanged once the area becomes a slice. If it needs an edit, the shape
|
|
17
|
+
* changed and that is a finding, not an adjustment.
|
|
18
|
+
*
|
|
19
|
+
* Two behaviours here are load-bearing and easy to lose in a rewrite:
|
|
20
|
+
*
|
|
21
|
+
* - `setSocial` REPLACES the map, it does not merge — the opposite of its
|
|
22
|
+
* `analytics` sibling. The legacy reducer returned `{ ...payload }`, and the
|
|
23
|
+
* screen depends on it: "removing" a network means emptying its field and
|
|
24
|
+
* saving, so a key the server drops has to drop out of the store too.
|
|
25
|
+
* - the site is read from `sites.currentSiteInfo`, not passed in, which is why
|
|
26
|
+
* that reducer is mounted below. It is the seam this area has with `sites`.
|
|
27
|
+
*
|
|
28
|
+
* Going to the slice needed exactly two edits, and both are type-level or known:
|
|
29
|
+
*
|
|
30
|
+
* - the two `expect(saved)` assertions gained `.unwrap()`. `dispatch(thunk())` used
|
|
31
|
+
* to resolve to `handleRequest`'s boolean; a createAsyncThunk resolves to the
|
|
32
|
+
* fulfilled action and hands the value over through `.unwrap()`. Same finding as
|
|
33
|
+
* Domains — docs/RTK-MIGRATION.md §5.
|
|
34
|
+
* - the casts that let a redux 4 reducer and action creator into an RTK store went
|
|
35
|
+
* away on their own (§8.3). `sites` still needs its own, being still legacy.
|
|
36
|
+
*
|
|
37
|
+
* Every request, resulting state and error-channel assertion below came over from the
|
|
38
|
+
* legacy run untouched. No argument churn: both thunks already took exactly one
|
|
39
|
+
* argument, so createAsyncThunk's single-argument rule cost nothing here.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
const SITE_ID = 42;
|
|
43
|
+
|
|
44
|
+
const SOCIALS: ISocialState = {
|
|
45
|
+
instagram: "https://instagram.com/griddo",
|
|
46
|
+
twitter: "https://x.com/griddo",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// Mirrors how GlobalStore mounts this area, minus persistence. `sites` is still
|
|
50
|
+
// legacy, so it needs the cast GlobalStore uses — RTK types reducers against redux 5's
|
|
51
|
+
// UnknownAction (docs/RTK-MIGRATION.md §8.3).
|
|
52
|
+
const makeStore = () =>
|
|
53
|
+
configureStore({
|
|
54
|
+
reducer: {
|
|
55
|
+
social: socialReducer,
|
|
56
|
+
sites: sitesReducer as Reducer<ISitesState, UnknownAction>,
|
|
57
|
+
},
|
|
58
|
+
preloadedState: {
|
|
59
|
+
sites: { ...sitesInitialState, currentSiteInfo: { id: SITE_ID } as ISite },
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
type Store = ReturnType<typeof makeStore>;
|
|
64
|
+
const socialsOf = (store: Store) => store.getState().social;
|
|
65
|
+
|
|
66
|
+
const ok = (data: ISocialState) => ({ status: 200, data });
|
|
67
|
+
|
|
68
|
+
describe("[UC-AX-configure-social-networks] social state", () => {
|
|
69
|
+
let store: Store;
|
|
70
|
+
let handleError: MockInstance;
|
|
71
|
+
|
|
72
|
+
beforeEach(() => {
|
|
73
|
+
store = makeStore();
|
|
74
|
+
handleError = vi.spyOn(appActions, "handleError").mockReturnValue((() => undefined) as never);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
vi.restoreAllMocks();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("AC2 · starts with no networks configured", () => {
|
|
82
|
+
expect(socialsOf(store)).toEqual({});
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("AC2 · setSocial replaces the whole map instead of merging onto it", () => {
|
|
86
|
+
store.dispatch(socialActions.setSocial(SOCIALS));
|
|
87
|
+
|
|
88
|
+
store.dispatch(socialActions.setSocial({ instagram: SOCIALS.instagram }));
|
|
89
|
+
|
|
90
|
+
// `twitter` is gone, not kept: the payload is the server's whole map.
|
|
91
|
+
expect(socialsOf(store)).toEqual({ instagram: SOCIALS.instagram });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
it("AC2 · loading asks the current site for its networks and stores them", async () => {
|
|
95
|
+
const get = vi.spyOn(social, "getSocial").mockResolvedValue(ok(SOCIALS) as never);
|
|
96
|
+
|
|
97
|
+
await store.dispatch(socialActions.getSocial());
|
|
98
|
+
|
|
99
|
+
expect(get).toHaveBeenCalledWith(SITE_ID);
|
|
100
|
+
expect(socialsOf(store)).toEqual(SOCIALS);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("AC2 · a failed load leaves the map untouched and logs instead of showing a toast", async () => {
|
|
104
|
+
store.dispatch(socialActions.setSocial(SOCIALS));
|
|
105
|
+
vi.spyOn(social, "getSocial").mockResolvedValue({ status: 500, data: {} } as never);
|
|
106
|
+
// Asserted rather than merely allowed: the read side's ONLY error channel is this
|
|
107
|
+
// console.log, so spying on it both pins the wart and keeps it out of the suite output.
|
|
108
|
+
const log = vi.spyOn(console, "log").mockImplementation(() => undefined);
|
|
109
|
+
|
|
110
|
+
await store.dispatch(socialActions.getSocial());
|
|
111
|
+
|
|
112
|
+
// The failure reaches the console and nothing else, so the screen keeps whatever it had.
|
|
113
|
+
// Documented as a wart in the UC: a failed load is invisible to the user.
|
|
114
|
+
expect(log).toHaveBeenCalledWith("Error en getSocial");
|
|
115
|
+
expect(handleError).not.toHaveBeenCalled();
|
|
116
|
+
expect(socialsOf(store)).toEqual(SOCIALS);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
describe("[UC-AX-configure-social-networks] social writes", () => {
|
|
121
|
+
let store: Store;
|
|
122
|
+
let handleError: MockInstance;
|
|
123
|
+
|
|
124
|
+
beforeEach(() => {
|
|
125
|
+
store = makeStore();
|
|
126
|
+
handleError = vi.spyOn(appActions, "handleError").mockReturnValue((() => undefined) as never);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
afterEach(() => {
|
|
130
|
+
vi.restoreAllMocks();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("AC6 · saving puts the whole form to the current site and takes the response as the new state", async () => {
|
|
134
|
+
const update = vi.spyOn(social, "updateSocial").mockResolvedValue(ok(SOCIALS) as never);
|
|
135
|
+
|
|
136
|
+
const saved = await store.dispatch(socialActions.saveSocial(SOCIALS)).unwrap();
|
|
137
|
+
|
|
138
|
+
expect(update).toHaveBeenCalledWith(SITE_ID, SOCIALS);
|
|
139
|
+
expect(saved).toBe(true);
|
|
140
|
+
expect(socialsOf(store)).toEqual(SOCIALS);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("AC6 · an emptied network goes out as an empty string — that is how one is removed", async () => {
|
|
144
|
+
const form = { ...SOCIALS, twitter: "" };
|
|
145
|
+
const update = vi.spyOn(social, "updateSocial").mockResolvedValue(ok(form) as never);
|
|
146
|
+
|
|
147
|
+
await store.dispatch(socialActions.saveSocial(form));
|
|
148
|
+
|
|
149
|
+
expect(update).toHaveBeenCalledWith(SITE_ID, form);
|
|
150
|
+
expect(socialsOf(store)).toEqual(form);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it("AC6 · a failed save routes through the error channel and reports it did not save", async () => {
|
|
154
|
+
const response = { status: 400, data: { message: "Invalid URL" } };
|
|
155
|
+
vi.spyOn(social, "updateSocial").mockResolvedValue(response as never);
|
|
156
|
+
|
|
157
|
+
const saved = await store.dispatch(socialActions.saveSocial(SOCIALS)).unwrap();
|
|
158
|
+
|
|
159
|
+
expect(handleError).toHaveBeenCalledWith(response);
|
|
160
|
+
// The screen keys `resetDirty()` off this boolean, so losing it means the form
|
|
161
|
+
// silently forgets unsaved changes.
|
|
162
|
+
expect(saved).toBe(false);
|
|
163
|
+
expect(socialsOf(store)).toEqual({});
|
|
164
|
+
});
|
|
165
|
+
});
|
|
@@ -6,12 +6,12 @@ import AppBar, { type IAppBarProps } from "./AppBar";
|
|
|
6
6
|
import * as S from "./style";
|
|
7
7
|
|
|
8
8
|
const MainWrapper = (props: IWrapperProps): JSX.Element => {
|
|
9
|
-
const { children, fixedAppBar, fullWidth, hasAnimation } = props;
|
|
9
|
+
const { children, fixedAppBar, fullWidth, hasAnimation, screen } = props;
|
|
10
10
|
|
|
11
11
|
const { isOnline } = useNetworkStatus();
|
|
12
12
|
|
|
13
13
|
return (
|
|
14
|
-
<S.Wrapper fixedAppBar={fixedAppBar} data-testid="main-wrapper">
|
|
14
|
+
<S.Wrapper fixedAppBar={fixedAppBar} data-testid="main-wrapper" data-screen={screen}>
|
|
15
15
|
{hasAnimation && <S.BackgroundAnimation />}
|
|
16
16
|
<AppBar {...props} />
|
|
17
17
|
<S.Main fullWidth={fullWidth} data-testid="main-component" id="main-component">
|
|
@@ -30,6 +30,18 @@ const MainWrapper = (props: IWrapperProps): JSX.Element => {
|
|
|
30
30
|
export interface IWrapperProps extends IAppBarProps {
|
|
31
31
|
children?: any[] | JSX.Element;
|
|
32
32
|
fullWidth?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Stable per-screen identifier for e2e, emitted as `data-screen`. Separate from
|
|
35
|
+
* `data-testid="main-wrapper"`, which is the same on all 32 screens and is already
|
|
36
|
+
* consumed by tests here and in griddo-qa's `identifiers.ts`.
|
|
37
|
+
*
|
|
38
|
+
* It exists because nothing else identifies a screen: `title` is display copy and
|
|
39
|
+
* is not even unique — Robots and Redirects are both "SEO Settings", and the global
|
|
40
|
+
* and the site Analytics screens are both "Analytics Settings". The e2e specs had
|
|
41
|
+
* to assert on `location.pathname` with exact equality, and `/settings/analytics`
|
|
42
|
+
* is a substring of `/sites/settings/analytics`.
|
|
43
|
+
*/
|
|
44
|
+
screen?: string;
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
export default MainWrapper;
|
|
@@ -1,4 +1,17 @@
|
|
|
1
|
-
import
|
|
2
|
-
import {
|
|
1
|
+
import { setListMode } from "./slice";
|
|
2
|
+
import { downloadActivityLog, getActivityLog, getActivityLogByUser, goToLogContent } from "./thunks";
|
|
3
3
|
|
|
4
|
-
|
|
4
|
+
// `setActivityLog` stays private, as it was: the legacy `actions.tsx` did not export it
|
|
5
|
+
// either — only the thunks write the page.
|
|
6
|
+
const activityLogActions = {
|
|
7
|
+
getActivityLog,
|
|
8
|
+
getActivityLogByUser,
|
|
9
|
+
setListMode,
|
|
10
|
+
downloadActivityLog,
|
|
11
|
+
goToLogContent,
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export { activityLogActions };
|
|
15
|
+
|
|
16
|
+
export type { IActivityLogState } from "./slice";
|
|
17
|
+
export { activityLogInitialState, activityLogReducer } from "./slice";
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { ILogItemsByDay, ILogItemsByUser } from "@ax/types";
|
|
2
|
+
|
|
3
|
+
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The `activityLog` area holds ONE page of the audit trail plus how it is grouped
|
|
7
|
+
* (UC-AX-view-activity-log). There is a single slot for the page: switching between the
|
|
8
|
+
* timeline and the grouped-by-user view overwrites it, which is why the screen refetches
|
|
9
|
+
* whenever the mode changes.
|
|
10
|
+
*
|
|
11
|
+
* `activityLog` is a union because the two endpoints answer with differently shaped groups —
|
|
12
|
+
* by day or by user — and the view picks its renderer off `listMode`, not off the data.
|
|
13
|
+
*
|
|
14
|
+
* Out of redux-persist since this migration (ADR 0002).
|
|
15
|
+
*/
|
|
16
|
+
export interface IActivityLogState {
|
|
17
|
+
activityLog: ILogItemsByDay | ILogItemsByUser;
|
|
18
|
+
listMode: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export const initialState: IActivityLogState = {
|
|
22
|
+
activityLog: { totalItems: 0, items: [] },
|
|
23
|
+
listMode: "default",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const activityLogSlice = createSlice({
|
|
27
|
+
name: "activityLog",
|
|
28
|
+
initialState,
|
|
29
|
+
// The legacy reducer had both action types share one `return { ...state, ...payload }`
|
|
30
|
+
// branch, which worked because each payload carried exactly one key. One reducer per field
|
|
31
|
+
// says the same thing without the indirection.
|
|
32
|
+
reducers: {
|
|
33
|
+
setActivityLog(state, action: PayloadAction<ILogItemsByDay | ILogItemsByUser>) {
|
|
34
|
+
state.activityLog = action.payload;
|
|
35
|
+
},
|
|
36
|
+
setListMode(state, action: PayloadAction<string>) {
|
|
37
|
+
state.listMode = action.payload;
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
export const { setActivityLog, setListMode } = activityLogSlice.actions;
|
|
43
|
+
|
|
44
|
+
export { initialState as activityLogInitialState };
|
|
45
|
+
export const activityLogReducer = activityLogSlice.reducer;
|