@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,76 +1,61 @@
1
- import { memo, useEffect, useState } from "react";
1
+ import { useEffect, useState } from "react";
2
+ import { connect } from "react-redux";
2
3
 
3
4
  import { Loading, MainWrapper, Nav } from "@ax/components";
4
5
  import { analyticsActions } from "@ax/containers/Analytics";
5
6
  import { appActions } from "@ax/containers/App";
6
7
  import { RouteLeavingGuard } from "@ax/guards";
7
8
  import { useModal } from "@ax/hooks";
8
- import { useAppDispatch, useAppSelector } from "@ax/store";
9
- import type { INavItem } from "@ax/types";
9
+ import type { IAnalytics, INavItem, IRootState, ISite } from "@ax/types";
10
10
 
11
11
  import { Dimensions, Groups, ResetModal, ScriptCode, Warning } from "./atoms";
12
12
 
13
13
  import * as S from "./style";
14
14
 
15
15
  const Analytics = (props: IProps): JSX.Element => {
16
- const { navItems, currentNavItem } = props;
17
-
18
- const dispatch = useAppDispatch();
19
- const isSaving = useAppSelector((state) => state.app.isSaving);
20
- const isLoading = useAppSelector((state) => state.app.isLoading);
21
- const analytics = useAppSelector((state) => state.analytics);
22
- const site = useAppSelector((state) => state.sites.currentSiteInfo);
16
+ const {
17
+ navItems,
18
+ currentNavItem,
19
+ isSaving,
20
+ isLoading,
21
+ analytics,
22
+ getAnalytics,
23
+ setHistoryPush,
24
+ site,
25
+ updateScriptCode,
26
+ deleteScriptCode,
27
+ } = props;
23
28
 
24
29
  const [showWarning, setShowWarning] = useState(false);
25
30
  const [scriptCode, setScriptCode] = useState(analytics.scriptCode);
26
31
  const [isDirty, setIsDirty] = useState(false);
27
- const [hasLoaded, setHasLoaded] = useState(false);
28
32
  const { isOpen, toggleModal } = useModal();
29
33
 
30
- // biome-ignore lint/correctness/useExhaustiveDependencies: only on mount, as before
34
+ // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: fix this
31
35
  useEffect(() => {
32
- const load = async () => {
33
- try {
34
- setHasLoaded(await dispatch(analyticsActions.getAnalytics(site?.id ?? null)).unwrap());
35
- } catch {
36
- setHasLoaded(false);
37
- }
38
- };
39
- load();
36
+ const handleGetAnalytics = async () => await getAnalytics(site?.id);
37
+ handleGetAnalytics();
40
38
  }, []);
41
39
 
42
- // The Warning ("this site has no script code of its own, edit the global one") is a
43
- // claim about the server, and `siteScriptCodeExists` is `false` both when the site has
44
- // no override and when nothing has been fetched yet. Making the claim before the GET
45
- // answers told a site that HAS its own script that it had none — and `getAnalytics`
46
- // sets no loading flag, so nothing covered the gap. Hence the guard: until the GET
47
- // succeeds the editor is the neutral state, and on failure the error modal is already up.
48
- useEffect(() => {
49
- if (!hasLoaded) return;
50
- setShowWarning(!analytics.siteScriptCodeExists);
51
- }, [hasLoaded, analytics.siteScriptCodeExists]);
52
-
53
40
  useEffect(() => {
54
41
  setScriptCode(analytics.scriptCode);
42
+ setShowWarning(!analytics.siteScriptCodeExists);
55
43
  setIsDirty(false);
56
- }, [analytics.scriptCode]);
44
+ }, [analytics.scriptCode, analytics.siteScriptCodeExists]);
57
45
 
58
46
  useEffect(() => {
59
47
  setIsDirty(scriptCode !== analytics.scriptCode);
60
48
  }, [scriptCode, analytics.scriptCode]);
61
49
 
62
50
  const handleSave = async () => {
63
- // `.unwrap()` is what hands over handleRequest's boolean: the dispatch itself
64
- // resolves to the fulfilled action, which would be truthy even on failure.
65
- const isSaved =
66
- !!site && (await dispatch(analyticsActions.updateScriptCode({ scriptCode, siteId: site.id })).unwrap());
51
+ const isSaved = !!site && (await updateScriptCode(scriptCode, site.id));
67
52
  if (isSaved) setIsDirty(false);
68
53
  };
69
54
 
70
- const setRoute = (path: string) => dispatch(appActions.setHistoryPush(path));
55
+ const setRoute = (path: string) => setHistoryPush(path);
71
56
 
72
57
  const handleReset = async () => {
73
- await dispatch(analyticsActions.deleteScriptCode(site?.id ?? null));
58
+ await deleteScriptCode(site?.id);
74
59
  isOpen && toggleModal();
75
60
  setShowWarning(true);
76
61
  setScriptCode(analytics.scriptCode);
@@ -121,7 +106,6 @@ const Analytics = (props: IProps): JSX.Element => {
121
106
  <MainWrapper
122
107
  backLink={false}
123
108
  title="Analytics Settings"
124
- screen="analytics-site"
125
109
  rightButton={rightButtonProps}
126
110
  rightLineButton={secondaryButtonProps}
127
111
  >
@@ -135,10 +119,36 @@ const Analytics = (props: IProps): JSX.Element => {
135
119
  );
136
120
  };
137
121
 
138
- interface IProps {
122
+ const mapStateToProps = (state: IRootState) => ({
123
+ isSaving: state.app.isSaving,
124
+ isLoading: state.app.isLoading,
125
+ analytics: state.analytics,
126
+ site: state.sites.currentSiteInfo,
127
+ });
128
+
129
+ const mapDispatchToProps = {
130
+ setHistoryPush: appActions.setHistoryPush,
131
+ getAnalytics: analyticsActions.getAnalytics,
132
+ updateScriptCode: analyticsActions.updateScriptCode,
133
+ deleteScriptCode: analyticsActions.deleteScriptCode,
134
+ };
135
+
136
+ interface IAnalyticsProps {
139
137
  navItems: INavItem[];
140
138
  currentNavItem: INavItem;
139
+ isSaving: boolean;
140
+ isLoading: boolean;
141
+ analytics: IAnalytics;
142
+ site: ISite | null;
141
143
  }
142
144
 
143
- // `connect` memoised this screen implicitly; `memo` keeps that now it is gone.
144
- export default memo(Analytics);
145
+ interface IDispatchProps {
146
+ getAnalytics(siteId?: number | null): Promise<void>;
147
+ setHistoryPush(path: string, isEditor?: boolean): void;
148
+ updateScriptCode(scriptCode: string, siteId?: number | null): Promise<boolean>;
149
+ deleteScriptCode(siteId?: number | null): Promise<boolean>;
150
+ }
151
+
152
+ type IProps = IAnalyticsProps & IDispatchProps;
153
+
154
+ export default connect(mapStateToProps, mapDispatchToProps)(Analytics);
@@ -27,6 +27,7 @@
27
27
  //
28
28
 
29
29
  import { useEffect, useState } from "react";
30
+ import { useDispatch, useSelector } from "react-redux";
30
31
 
31
32
  import { ErrorToast, FieldsBehavior, MainWrapper, Nav } from "@ax/components";
32
33
  import { appActions } from "@ax/containers/App";
@@ -34,10 +35,11 @@ import { socialActions } from "@ax/containers/Settings/Social";
34
35
  import { RouteLeavingGuard } from "@ax/guards";
35
36
  import { capitalize } from "@ax/helpers";
36
37
  import { useIsDirty } from "@ax/hooks";
37
- import { useAppDispatch, useAppSelector } from "@ax/store";
38
- import type { INavItem } from "@ax/types";
38
+ import type { INavItem, IRootState } from "@ax/types";
39
39
 
40
40
  import { config } from "components";
41
+ import type { AnyAction } from "redux";
42
+ import type { ThunkDispatch } from "redux-thunk";
41
43
 
42
44
  import * as S from "./style";
43
45
 
@@ -62,12 +64,12 @@ const hardcodedSocials = [
62
64
 
63
65
  const Social = (props: IProps): JSX.Element => {
64
66
  const { navItems, currentNavItem } = props;
65
- const dispatch = useAppDispatch();
66
- const isSaving = useAppSelector((state) => state.app.isSaving);
67
- const socialInfo = useAppSelector((state) => state.social);
67
+ const dispatch = useDispatch<ThunkDispatch<IRootState, undefined, AnyAction>>();
68
+ const isSaving = useSelector((state: IRootState) => state.app.isSaving);
69
+ const socialInfo = useSelector((state: IRootState) => state.social);
68
70
  const [socialForm, setSocialForm] = useState<Record<string, string>>({});
69
71
  const [serverFields, setServerFields] = useState<Set<string>>(new Set());
70
- const { isDirty, resetDirty } = useIsDirty(socialForm);
72
+ const { isDirty, resetDirty, setIsDirty } = useIsDirty(socialForm);
71
73
 
72
74
  const socialFields = [
73
75
  ...instanceSocials.map((name) => ({ name, title: `${capitalize(name)} URL` })),
@@ -86,21 +88,14 @@ const Social = (props: IProps): JSX.Element => {
86
88
  resetDirty();
87
89
  }, [dispatch]);
88
90
 
89
- // `resetDirty()`, not `setIsDirty(false)`: it also moves useIsDirty's baseline to the
90
- // form being hydrated here. Leaving the redux-persist (ADR 0002) means `social` is
91
- // empty on the first render and arrives with the GET, and useIsDirty snapshots the
92
- // baseline during render — so the empty→loaded hop read as an edit and the screen
93
- // landed dirty, arming the leave guard before the user touched anything.
94
91
  useEffect(() => {
95
92
  setSocialForm({ ...socialInfo });
96
93
  setServerFields(new Set(Object.keys(socialInfo).filter((k) => !!socialInfo[k])));
97
- resetDirty();
98
- }, [socialInfo, resetDirty]);
94
+ setIsDirty(false);
95
+ }, [socialInfo, setIsDirty]);
99
96
 
100
97
  const saveForm = async () => {
101
- // `.unwrap()` is what hands over handleRequest's boolean: the dispatch itself
102
- // resolves to the fulfilled action, which would be truthy even on failure.
103
- const isSaved = await dispatch(socialActions.saveSocial(socialForm)).unwrap();
98
+ const isSaved = await dispatch(socialActions.saveSocial(socialForm));
104
99
  if (isSaved) resetDirty();
105
100
  };
106
101
 
@@ -127,7 +122,7 @@ const Social = (props: IProps): JSX.Element => {
127
122
  return (
128
123
  <>
129
124
  <RouteLeavingGuard when={isDirty} action={setRoute} text={modalText} />
130
- <MainWrapper backLink={false} title="Social" rightButton={rightButtonProps} screen="social">
125
+ <MainWrapper backLink={false} title="Social" rightButton={rightButtonProps}>
131
126
  <S.Wrapper>
132
127
  <Nav current={currentNavItem} items={navItems} onClick={handleMenuClick} />
133
128
  <S.ContentWrapper>
@@ -315,8 +315,7 @@ const StructuredDataList = (props: IProps): JSX.Element => {
315
315
  // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: fix this
316
316
  useEffect(() => {
317
317
  checkUserSession();
318
- // `null` is the global scope: the RTK thunk takes exactly one argument.
319
- getAnalytics(null);
318
+ getAnalytics();
320
319
  }, []);
321
320
 
322
321
  // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: fix this
@@ -812,7 +811,7 @@ interface IDispatchProps {
812
811
  deleteBulk(ids: number[]): Promise<boolean>;
813
812
  restorePage(id: number | number[]): Promise<boolean>;
814
813
  resetPageEditor(): Promise<void>;
815
- getAnalytics(siteId: number | null): unknown;
814
+ getAnalytics(siteId?: number | null): Promise<void>;
816
815
  setCurrentDataID(id: number | null): void;
817
816
  resetCurrentSiteErrorPages: () => Promise<void>;
818
817
  setContentFilters(contentFilters: Record<string, Record<string, IQueryValue[]>> | null): void;
@@ -1,7 +1,7 @@
1
- import type { IActivityLogState } from "@ax/containers/ActivityLog/slice";
2
- import type { IAnalyticsState } from "@ax/containers/Analytics/slice";
1
+ import type { IActivityLogState } from "@ax/containers/ActivityLog/reducer";
2
+ import type { IAnalyticsState } from "@ax/containers/Analytics/reducer";
3
3
  import type { IAppState } from "@ax/containers/App/reducer";
4
- import type { IDomainsState } from "@ax/containers/Domains/slice";
4
+ import type { IDomainsState } from "@ax/containers/Domains/reducer";
5
5
  import type { IFileDriveState } from "@ax/containers/FileDrive/reducer";
6
6
  import type { IFormsState } from "@ax/containers/Forms/reducer";
7
7
  import type { IGalleryState } from "@ax/containers/Gallery/reducer";
@@ -9,9 +9,9 @@ import type { IIntegrationsState } from "@ax/containers/Integrations/reducer";
9
9
  import type { INavigationState } from "@ax/containers/Navigation/Defaults/reducer";
10
10
  import type { IMenuState } from "@ax/containers/Navigation/Menu/reducer";
11
11
  import type { IPageEditorState } from "@ax/containers/PageEditor/reducer";
12
- import type { IRedirectsState } from "@ax/containers/Redirects/slice";
12
+ import type { IRedirectsState } from "@ax/containers/Redirects/reducer";
13
13
  import type { IDataPacksState } from "@ax/containers/Settings/DataPacks/reducer";
14
- import type { ILanguageState } from "@ax/containers/Settings/Languages/slice";
14
+ import type { ILanguageState } from "@ax/containers/Settings/Languages/reducer";
15
15
  import type { ISitesState } from "@ax/containers/Sites/reducer";
16
16
  import type { IStructuredDataState } from "@ax/containers/StructuredData/reducer";
17
17
  import type { IUsersState } from "@ax/containers/Users/reducer";
@@ -261,6 +261,7 @@ export interface IHeadingField {
261
261
  }
262
262
 
263
263
  export interface IRootState {
264
+ router: any;
264
265
  app: IAppState;
265
266
  sites: ISitesState;
266
267
  pageEditor: IPageEditorState;
@@ -17,7 +17,6 @@
17
17
  "@ax/routes/*": ["./src/routes/*"],
18
18
  "@ax/types": ["./src/types"],
19
19
  "@ax/schemas": ["./src/schemas"],
20
- "@ax/store": ["./src/store"],
21
20
  "@ax/themes/*": ["./src/themes/*"],
22
21
  "@ax/constants": ["./src/constants"],
23
22
  "@ax/constants/*": ["./src/constants/*"]
@@ -1,132 +0,0 @@
1
- import { activityLogReducer } from "@ax/containers/ActivityLog";
2
- import { appReducer, type IAppState } from "@ax/containers/App/reducer";
3
- import {
4
- type IStructuredDataState,
5
- structuredDataReducer,
6
- } from "@ax/containers/StructuredData/reducer";
7
- import { parseTheme } from "@ax/helpers";
8
- import ActivityLog from "@ax/modules/ActivityLog";
9
- import history from "@ax/routes/history";
10
- import globalTheme from "@ax/themes/theme.json";
11
-
12
- import { configureStore, type Reducer, type UnknownAction } from "@reduxjs/toolkit";
13
- import axios from "axios";
14
- import { Router } from "react-router-dom";
15
- import { ThemeProvider } from "styled-components";
16
- import type { MockedFunction } from "vitest";
17
-
18
- import { act, cleanup, render, screen } from "../../../../config/tests/test-utils";
19
-
20
- /**
21
- * End-to-end test of the activity log on the real store — view, thunks, api layer and slice,
22
- * with only axios mocked (see the `## Profile` section of docs/gainup/README.md for why a
23
- * `redux-mock-store` test does not count as AC coverage).
24
- *
25
- * It covers the two things the store tests cannot see: that landing on the screen really does
26
- * ask the grouped-by-day endpoint for a hundred rows and paint them (AC1), and the empty state
27
- * (AC5), which is view logic living nowhere else.
28
- *
29
- * AC2 — switching to "Grouped by user" — is verified in `src/__tests__/store/activityLog.test.ts`
30
- * instead: the mode selector is a react-select, and driving its menu open in jsdom would test
31
- * the dropdown rather than the use case. The endpoint switch and the persisted `listMode` are
32
- * the parts that matter and they are pinned there.
33
- */
34
- vi.mock("axios");
35
- const mockedAxios = axios as MockedFunction<typeof axios>;
36
-
37
- const LOG_PAGE = {
38
- totalItems: 1,
39
- items: [
40
- {
41
- date: "10 Aug 2026",
42
- items: [
43
- {
44
- id: 1,
45
- user: { id: 3, name: "Ada Lovelace", image: null, email: "ada@example.edu", role: "admin" },
46
- created: { hour: "09:41", date: "10 Aug 2026" },
47
- site: null,
48
- content: null,
49
- contentType: null,
50
- eventType: { id: 1, name: "Created page", internal_key: "CREATED_PAGE" },
51
- ip: "10.0.0.1",
52
- },
53
- ],
54
- },
55
- ],
56
- };
57
-
58
- const makeStore = () =>
59
- configureStore({
60
- // Only what this screen reads. `app` and `structuredData` are still legacy, so they need
61
- // the cast GlobalStore uses (docs/RTK-MIGRATION.md §8.3).
62
- reducer: {
63
- activityLog: activityLogReducer,
64
- app: appReducer as Reducer<IAppState, UnknownAction>,
65
- structuredData: structuredDataReducer as Reducer<IStructuredDataState, UnknownAction>,
66
- },
67
- middleware: (getDefault) => getDefault({ serializableCheck: false, immutableCheck: false }),
68
- });
69
-
70
- const answer = (data: unknown) => ({ status: 200, data, statusText: "OK", headers: {}, config: {} });
71
-
72
- const logCalls = () =>
73
- mockedAxios.mock.calls
74
- .map(([config]) => config as { url?: string; method?: string })
75
- .filter((config) => (config?.url ?? "").includes("/logs/activity-grouped-day"));
76
-
77
- const renderScreen = async (store: ReturnType<typeof makeStore>) => {
78
- await act(async () => {
79
- render(
80
- <Router history={history}>
81
- <ThemeProvider theme={parseTheme(globalTheme)}>
82
- <ActivityLog />
83
- </ThemeProvider>
84
- </Router>,
85
- { store },
86
- );
87
- });
88
- };
89
-
90
- // The screen's filters and the structured-data refresh fetch their own lists on mount, so only
91
- // the log endpoint answers with a log page.
92
- const respondWith = (page: unknown) => {
93
- mockedAxios.mockImplementation((async (config: { url?: string }) => {
94
- const url = config?.url ?? "";
95
- if (url.includes("/logs/activity-grouped-day")) return answer(page);
96
- // The filter option lists want a bare array, not a paginated envelope.
97
- return answer([]);
98
- }) as never);
99
- };
100
-
101
- afterEach(() => {
102
- vi.restoreAllMocks();
103
- cleanup();
104
- });
105
-
106
- describe("[UC-AX-view-activity-log] the activity log screen end to end", () => {
107
- it("AC1 · landing on the screen asks the grouped-by-day endpoint for a hundred rows and paints them", async () => {
108
- respondWith(LOG_PAGE);
109
- const store = makeStore();
110
-
111
- await renderScreen(store);
112
-
113
- const [request] = logCalls();
114
- expect(request.url).toContain("page=1");
115
- expect(request.url).toContain("itemsPerPage=100");
116
- // Default ordering is DESC, which the endpoint expresses by the absence of `order`.
117
- expect(request.url).not.toContain("order=");
118
- expect(store.getState().activityLog.activityLog.totalItems).toBe(1);
119
- expect(screen.getByText("Ada Lovelace")).toBeTruthy();
120
- expect(screen.getByText("09:41")).toBeTruthy();
121
- });
122
-
123
- it("AC5 · an empty log shows the default empty state instead of a table", async () => {
124
- respondWith({ totalItems: 0, items: [] });
125
-
126
- await renderScreen(makeStore());
127
-
128
- expect(logCalls()).toHaveLength(1);
129
- expect(screen.getByText(/You will start to see the User Activity Log/)).toBeTruthy();
130
- expect(screen.queryByText("Ada Lovelace")).toBeNull();
131
- });
132
- });
@@ -1,168 +0,0 @@
1
- import { Router } from "react-router-dom";
2
-
3
- // From the slice, not the `@ax/containers/Analytics` barrel: `Sites/actions` destructures
4
- // `analyticsActions` at module scope, so pulling the barrel in first from a test that also
5
- // mounts a screen loses the race with that cycle and the destructure sees `undefined`.
6
- import { analyticsReducer } from "@ax/containers/Analytics/slice";
7
- import { appInitialState, appReducer, type IAppState } from "@ax/containers/App/reducer";
8
- import { type ISitesState, sitesInitialState, sitesReducer } from "@ax/containers/Sites/reducer";
9
- import { parseTheme } from "@ax/helpers";
10
- import GlobalAnalytics from "@ax/modules/Analytics";
11
- import SiteAnalytics from "@ax/modules/Settings/SeoAnalyticsSettings/Analytics";
12
- import history from "@ax/routes/history";
13
- import globalTheme from "@ax/themes/theme.json";
14
- import type { IAnalytics, INavItem, ISite } from "@ax/types";
15
-
16
- import { configureStore, type Reducer, type UnknownAction } from "@reduxjs/toolkit";
17
- import axios from "axios";
18
- import { ThemeProvider } from "styled-components";
19
- import type { MockedFunction } from "vitest";
20
-
21
- import { act, cleanup, render, screen } from "../../../../config/tests/test-utils";
22
-
23
- /**
24
- * End-to-end tests of the two screens of the `analytics` area on the real store — view,
25
- * thunks, api layer and slice, with only axios mocked (see the `## Profile` section of
26
- * docs/gainup/README.md for why a `redux-mock-store` test does not count as AC coverage).
27
- *
28
- * They cover the half of AC5 and AC6 that lives only in the view, and both were written
29
- * against bugs the review of this migration found: the global screen landed dirty, so Save
30
- * was enabled and the leave guard armed on a screen the user had only looked at; and the
31
- * site screen claimed "this site has no script code of its own" for as long as the GET was
32
- * in flight, which is a statement about the server made before asking it.
33
- *
34
- * The in-flight window is the whole point of the second one, so its mock hands back a
35
- * promise this file resolves by hand: awaiting the render would skip the state under test.
36
- */
37
- vi.mock("axios");
38
- const mockedAxios = axios as MockedFunction<typeof axios>;
39
-
40
- const SITE_ID = 7;
41
- const ANALYTICS: IAnalytics = {
42
- scriptCode: "<script>site-ga</script>",
43
- siteScriptCodeExists: true,
44
- dimensions: [],
45
- groups: [],
46
- };
47
-
48
- const mockComponent = () => null;
49
- const NAV_ITEMS: INavItem[] = [{ title: "Analytics", path: "/sites/settings/analytics", component: mockComponent }];
50
-
51
- const okResponse = (data: unknown) => ({ status: 200, data, statusText: "OK", headers: {}, config: {} });
52
-
53
- const makeStore = () =>
54
- configureStore({
55
- // Only what these screens read. `app` and `sites` are still legacy, so they need the
56
- // cast GlobalStore uses (docs/RTK-MIGRATION.md §8.3).
57
- reducer: {
58
- app: appReducer as Reducer<IAppState, UnknownAction>,
59
- analytics: analyticsReducer,
60
- sites: sitesReducer as Reducer<ISitesState, UnknownAction>,
61
- },
62
- middleware: (getDefault) => getDefault({ serializableCheck: false, immutableCheck: false }),
63
- preloadedState: {
64
- // `appInitialState.isLoading` is `true` and `getAnalytics` passes no loading flag,
65
- // so a bare store would spin forever. `false` is the state any screen is actually
66
- // reached in: the login and the boot fetches have already released the counter.
67
- app: { ...appInitialState, isLoading: false },
68
- sites: { ...sitesInitialState, currentSiteInfo: { id: SITE_ID } as ISite },
69
- },
70
- });
71
-
72
- const metricsCalls = (method: string) =>
73
- mockedAxios.mock.calls
74
- .map(([config]) => config as { url?: string; method?: string })
75
- .filter((config) => config?.method === method && !!config?.url?.includes("/metrics"));
76
-
77
- const renderScreen = async (store: ReturnType<typeof makeStore>, ui: JSX.Element) => {
78
- await act(async () => {
79
- render(
80
- <Router history={history}>
81
- <ThemeProvider theme={parseTheme(globalTheme)}>{ui}</ThemeProvider>
82
- </Router>,
83
- { store },
84
- );
85
- });
86
- };
87
-
88
- const siteScreen = <SiteAnalytics navItems={NAV_ITEMS} currentNavItem={NAV_ITEMS[0]} />;
89
-
90
- const button = (name: string) => screen.getByRole("button", { name });
91
- // The Warning panel's own button — it is what tells the site it has no override.
92
- const warning = () => screen.queryByRole("button", { name: "Define Site Analytics" });
93
-
94
- beforeEach(() => {
95
- mockedAxios.mockImplementation((async () => okResponse(ANALYTICS)) as never);
96
- });
97
-
98
- afterEach(() => {
99
- vi.restoreAllMocks();
100
- cleanup();
101
- });
102
-
103
- describe("[UC-AX-configure-seo-analytics] the global Analytics screen end to end", () => {
104
- it("AC6 · lands clean on the data it just fetched, so Save stays disabled", async () => {
105
- const store = makeStore();
106
-
107
- await renderScreen(store, <GlobalAnalytics />);
108
-
109
- expect(metricsCalls("GET")).toHaveLength(1);
110
- expect(store.getState().analytics.scriptCode).toBe(ANALYTICS.scriptCode);
111
- // The empty→loaded hop is not an edit. useIsDirty snapshots its baseline during
112
- // render, so without the resetDirty() in the hydration effect this reads "Save"
113
- // enabled — and the leave guard fires on the way out of a screen nobody touched.
114
- expect(button("Saved").hasAttribute("disabled")).toBe(true);
115
- });
116
- });
117
-
118
- describe("[UC-AX-configure-seo-analytics] the site Analytics screen end to end", () => {
119
- it("AC5 · does not claim the site has no script code of its own until the GET answers", async () => {
120
- const store = makeStore();
121
- let answerGet = () => {};
122
- mockedAxios.mockImplementation(
123
- (() =>
124
- new Promise((resolve) => {
125
- answerGet = () => resolve(okResponse(ANALYTICS));
126
- })) as never,
127
- );
128
-
129
- await renderScreen(store, siteScreen);
130
-
131
- // In flight: `siteScriptCodeExists` is still its initial `false`, which means "not
132
- // asked yet", not "no override". Deciding here is what showed the Warning to a site
133
- // that does have its own script.
134
- expect(warning()).toBeNull();
135
-
136
- await act(async () => {
137
- answerGet();
138
- });
139
-
140
- expect(warning()).toBeNull();
141
- expect(screen.getByDisplayValue(ANALYTICS.scriptCode)).toBeTruthy();
142
- });
143
-
144
- it("AC5 · shows the Warning once the server answers that the site has no script code", async () => {
145
- const store = makeStore();
146
- mockedAxios.mockImplementation((async () =>
147
- okResponse({ ...ANALYTICS, siteScriptCodeExists: false, scriptCode: "" })) as never);
148
-
149
- await renderScreen(store, siteScreen);
150
-
151
- expect(warning()).toBeTruthy();
152
- });
153
-
154
- it("AC5 · a failed GET leaves the editor, not a claim about the site's script code", async () => {
155
- const store = makeStore();
156
- mockedAxios.mockImplementation((async () => ({
157
- status: 500,
158
- data: { message: "Server error" },
159
- statusText: "Server Error",
160
- headers: {},
161
- config: {},
162
- })) as never);
163
-
164
- await renderScreen(store, siteScreen);
165
-
166
- expect(warning()).toBeNull();
167
- });
168
- });