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