@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.
Files changed (86) hide show
  1. package/config/griddo-config/index.js +1 -0
  2. package/package.json +3 -4
  3. package/src/GlobalStore.tsx +91 -35
  4. package/src/__tests__/modules/ActivityLog/ActivityLog.uc.test.tsx +132 -0
  5. package/src/__tests__/modules/Analytics/Analytics.uc.test.tsx +168 -0
  6. package/src/__tests__/modules/GlobalSettings/Robots/Robots.test.tsx +110 -0
  7. package/src/__tests__/modules/GlobalSettings/Robots/Robots.uc.test.tsx +125 -0
  8. package/src/__tests__/modules/Redirects/Redirects.uc.test.tsx +164 -0
  9. package/src/__tests__/modules/Settings/Languages/Languages.uc.test.tsx +213 -0
  10. package/src/__tests__/modules/Settings/Social/Social.uc.test.tsx +171 -0
  11. package/src/__tests__/store/activityLog.test.ts +301 -0
  12. package/src/__tests__/store/analytics.test.ts +134 -0
  13. package/src/__tests__/store/domains.test.ts +107 -0
  14. package/src/__tests__/store/languages.test.ts +129 -0
  15. package/src/__tests__/store/persist.test.ts +47 -0
  16. package/src/__tests__/store/redirects.test.ts +313 -0
  17. package/src/__tests__/store/social.test.ts +165 -0
  18. package/src/components/MainWrapper/index.tsx +14 -2
  19. package/src/containers/ActivityLog/index.tsx +16 -3
  20. package/src/containers/ActivityLog/slice.ts +45 -0
  21. package/src/containers/ActivityLog/{actions.tsx → thunks.ts} +104 -89
  22. package/src/containers/Analytics/index.tsx +14 -3
  23. package/src/containers/Analytics/slice.ts +48 -0
  24. package/src/containers/Analytics/thunks.ts +103 -0
  25. package/src/containers/Domains/index.tsx +12 -3
  26. package/src/containers/Domains/slice.ts +37 -0
  27. package/src/containers/Domains/thunks.ts +60 -0
  28. package/src/containers/Redirects/constants.tsx +1 -17
  29. package/src/containers/Redirects/index.tsx +14 -3
  30. package/src/containers/Redirects/slice.ts +60 -0
  31. package/src/containers/Redirects/thunks.ts +196 -0
  32. package/src/containers/Settings/Languages/index.tsx +15 -3
  33. package/src/containers/Settings/Languages/slice.ts +48 -0
  34. package/src/containers/Settings/Languages/thunks.ts +83 -0
  35. package/src/containers/Settings/Social/index.tsx +11 -3
  36. package/src/containers/Settings/Social/slice.ts +40 -0
  37. package/src/containers/Settings/Social/thunks.ts +68 -0
  38. package/src/containers/Sites/actions.tsx +6 -2
  39. package/src/index.tsx +7 -6
  40. package/src/modules/ActivityLog/ItemLog/EventItem/index.tsx +13 -27
  41. package/src/modules/ActivityLog/ItemLogUser/UserItem/EventItem/index.tsx +13 -27
  42. package/src/modules/ActivityLog/index.tsx +22 -51
  43. package/src/modules/Analytics/index.tsx +33 -42
  44. package/src/modules/App/Routing/index.tsx +5 -4
  45. package/src/modules/FileDrive/index.tsx +1 -1
  46. package/src/modules/GlobalSettings/Robots/index.tsx +24 -47
  47. package/src/modules/MediaGallery/index.tsx +1 -1
  48. package/src/modules/Redirects/RedirectItem/index.tsx +9 -22
  49. package/src/modules/Redirects/RedirectPanel/index.tsx +13 -14
  50. package/src/modules/Redirects/index.tsx +26 -69
  51. package/src/modules/Settings/ContentTypes/DataPacks/index.tsx +4 -3
  52. package/src/modules/Settings/Languages/LanguagePanel/Form/ConnectedField/index.tsx +11 -18
  53. package/src/modules/Settings/Languages/LanguagePanel/Form/index.tsx +7 -9
  54. package/src/modules/Settings/Languages/LanguagePanel/index.tsx +38 -44
  55. package/src/modules/Settings/Languages/Table/Header/index.tsx +12 -19
  56. package/src/modules/Settings/Languages/Table/Item/index.tsx +21 -36
  57. package/src/modules/Settings/Languages/Table/index.tsx +6 -14
  58. package/src/modules/Settings/Languages/index.tsx +18 -30
  59. package/src/modules/Settings/SeoAnalyticsSettings/Analytics/index.tsx +42 -52
  60. package/src/modules/Settings/Social/index.tsx +17 -12
  61. package/src/modules/StructuredData/StructuredDataList/index.tsx +3 -2
  62. package/src/store/hooks.ts +37 -0
  63. package/src/store/index.ts +2 -0
  64. package/src/types/index.tsx +5 -6
  65. package/tsconfig.paths.json +1 -0
  66. package/src/containers/ActivityLog/constants.tsx +0 -6
  67. package/src/containers/ActivityLog/interfaces.tsx +0 -12
  68. package/src/containers/ActivityLog/reducer.tsx +0 -24
  69. package/src/containers/Analytics/actions.tsx +0 -109
  70. package/src/containers/Analytics/constants.tsx +0 -5
  71. package/src/containers/Analytics/interfaces.tsx +0 -9
  72. package/src/containers/Analytics/reducer.tsx +0 -28
  73. package/src/containers/Domains/actions.tsx +0 -58
  74. package/src/containers/Domains/constants.tsx +0 -5
  75. package/src/containers/Domains/interfaces.tsx +0 -9
  76. package/src/containers/Domains/reducer.tsx +0 -22
  77. package/src/containers/Redirects/actions.tsx +0 -190
  78. package/src/containers/Redirects/interfaces.tsx +0 -23
  79. package/src/containers/Redirects/reducer.tsx +0 -35
  80. package/src/containers/Settings/Languages/actions.tsx +0 -88
  81. package/src/containers/Settings/Languages/constants.tsx +0 -6
  82. package/src/containers/Settings/Languages/reducer.tsx +0 -31
  83. package/src/containers/Settings/Social/actions.tsx +0 -63
  84. package/src/containers/Settings/Social/constants.tsx +0 -3
  85. package/src/containers/Settings/Social/interfaces.tsx +0 -9
  86. package/src/containers/Settings/Social/reducer.tsx +0 -18
@@ -87,6 +87,7 @@ const griddoAxAliases = {
87
87
  "@ax/routes": path.resolve(__dirname, "../../src/routes"),
88
88
  "@ax/types": path.resolve(__dirname, "../../src/types"),
89
89
  "@ax/schemas": path.resolve(__dirname, "../../src/schemas"),
90
+ "@ax/store": path.resolve(__dirname, "../../src/store"),
90
91
  "@ax/locales": path.resolve(__dirname, "../../src/locales"),
91
92
  "@ax/constants": path.resolve(__dirname, "../../src/constants"),
92
93
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@griddo/ax",
3
3
  "description": "Griddo Author Experience",
4
- "version": "12.6.0",
4
+ "version": "12.6.1",
5
5
  "authors": [
6
6
  "Álvaro Sánchez' <alvaro.sanches@secuoyas.com>",
7
7
  "Diego M. Béjar <diego.bejar@secuoyas.com>",
@@ -46,6 +46,7 @@
46
46
  "@dnd-kit/sortable": "10.0.0",
47
47
  "@griddo/api-types": "^1.0.12",
48
48
  "@pmmmwh/react-refresh-webpack-plugin": "0.5.16",
49
+ "@reduxjs/toolkit": "2.9.0",
49
50
  "@styled-system/prop-types": "5.1.5",
50
51
  "@styled-system/theme-get": "5.1.2",
51
52
  "@svgr/webpack": "8.1.0",
@@ -77,7 +78,6 @@
77
78
  "babel-preset-react-app": "10.1.0",
78
79
  "case-sensitive-paths-webpack-plugin": "2.4.0",
79
80
  "compress.js": "1.2.2",
80
- "connected-react-router": "6.9.3",
81
81
  "css-loader": "6.11.0",
82
82
  "css-minimizer-webpack-plugin": "5.0.1",
83
83
  "date-fns": "2.30.0",
@@ -126,7 +126,6 @@
126
126
  "react-textarea-autosize": "8.5.9",
127
127
  "react-timezone-map-select": "1.2.25",
128
128
  "redux": "4.2.1",
129
- "redux-devtools-extension": "2.13.9",
130
129
  "redux-persist": "6.0.0",
131
130
  "reflect-metadata": "0.1.13",
132
131
  "resolve": "1.22.10",
@@ -201,5 +200,5 @@
201
200
  "publishConfig": {
202
201
  "access": "public"
203
202
  },
204
- "gitHead": "5b898e9cbe8af3916311b072f42b1014150c331f"
203
+ "gitHead": "e0eec02188240c74c8fdacfa7a04fbafdc38c597"
205
204
  }
@@ -1,35 +1,78 @@
1
- import { createStore, type Reducer, combineReducers, applyMiddleware, type Action, type StoreEnhancer } from "redux";
2
- import { persistReducer, persistStore, type PersistConfig, type Persistor } from "redux-persist";
1
+ import type { IRootState } from "@ax/types";
2
+
3
+ import { configureStore } from "@reduxjs/toolkit";
4
+ import { type Action, combineReducers, type Reducer } from "redux";
5
+ import {
6
+ FLUSH,
7
+ PAUSE,
8
+ PERSIST,
9
+ type PersistConfig,
10
+ type PersistedState,
11
+ type Persistor,
12
+ PURGE,
13
+ persistReducer,
14
+ persistStore,
15
+ REGISTER,
16
+ REHYDRATE,
17
+ } from "redux-persist";
3
18
  import storage from "redux-persist/lib/storage"; // defaults to localStorage for web
4
- import { connectRouter, routerMiddleware } from "connected-react-router";
5
- import { composeWithDevTools } from "redux-devtools-extension";
6
- import thunk from "redux-thunk";
7
19
 
8
- import { appReducer, appInitialState } from "./containers/App/reducer";
9
- import { sitesReducer, sitesInitialState } from "./containers/Sites/reducer";
10
- import { pageEditorReducer, pageEditorInitialState } from "./containers/PageEditor/reducer";
11
- import { menuReducer, menuInitialState } from "./containers/Navigation/Menu/reducer";
12
- import { navigationReducer, navigationInitialState } from "./containers/Navigation/Defaults/reducer";
13
- import { structuredDataReducer, structuredDataInitialState } from "./containers/StructuredData/reducer";
14
- import { languagesReducer, languagesInitialState } from "./containers/Settings/Languages/reducer";
15
- import { dataPacksReducer, dataPacksInitialState } from "./containers/Settings/DataPacks/reducer";
16
- import { socialReducer, socialInitialState } from "./containers/Settings/Social/reducer";
17
- import { usersReducer, usersInitialState } from "./containers/Users/reducer";
18
- import { galleryReducer, galleryInitialState } from "./containers/Gallery/reducer";
19
- import { domainsReducer, domainsInitialState } from "./containers/Domains/reducer";
20
- import { redirectsReducer, redirectsInitialState } from "./containers/Redirects/reducer";
21
- import { analyticsReducer, analyticsInitialState } from "./containers/Analytics/reducer";
22
- import { integrationsReducer, integrationsInitialState } from "./containers/Integrations/reducer";
23
- import { fileDriveReducer, fileDriveInitialState } from "./containers/FileDrive/reducer";
24
- import { formsReducer, formsInitialState } from "./containers/Forms/reducer";
25
- import { activityLogReducer, activityLogInitialState } from "./containers/ActivityLog/reducer";
20
+ import { activityLogInitialState, activityLogReducer } from "./containers/ActivityLog/slice";
21
+ import { analyticsInitialState, analyticsReducer } from "./containers/Analytics/slice";
26
22
  import { LOGOUT } from "./containers/App/constants";
23
+ import { appInitialState, appReducer } from "./containers/App/reducer";
24
+ import { domainsInitialState, domainsReducer } from "./containers/Domains/slice";
25
+ import { fileDriveInitialState, fileDriveReducer } from "./containers/FileDrive/reducer";
26
+ import { formsInitialState, formsReducer } from "./containers/Forms/reducer";
27
+ import { galleryInitialState, galleryReducer } from "./containers/Gallery/reducer";
28
+ import { integrationsInitialState, integrationsReducer } from "./containers/Integrations/reducer";
29
+ import { navigationInitialState, navigationReducer } from "./containers/Navigation/Defaults/reducer";
30
+ import { menuInitialState, menuReducer } from "./containers/Navigation/Menu/reducer";
31
+ import { pageEditorInitialState, pageEditorReducer } from "./containers/PageEditor/reducer";
32
+ import { redirectsInitialState, redirectsReducer } from "./containers/Redirects/slice";
33
+ import { dataPacksInitialState, dataPacksReducer } from "./containers/Settings/DataPacks/reducer";
34
+ import { languagesInitialState, languagesReducer } from "./containers/Settings/Languages/slice";
35
+ import { socialInitialState, socialReducer } from "./containers/Settings/Social/slice";
36
+ import { sitesInitialState, sitesReducer } from "./containers/Sites/reducer";
37
+ import { structuredDataInitialState, structuredDataReducer } from "./containers/StructuredData/reducer";
38
+ import { usersInitialState, usersReducer } from "./containers/Users/reducer";
27
39
 
28
- import type { IRootState } from "@ax/types";
40
+ // Every area migrated to RTK leaves redux-persist (docs/adr/0002): its state is a
41
+ // copy of the server's that the screen reloads on mount, so persisting it only
42
+ // meant starting up with stale data. `app` stays in — it carries the session.
43
+ //
44
+ // The test is that EVERY consumer reloads it, not just its settings screen, so
45
+ // `social` and `analytics` stay in for now: six page-editor screens read `state.social`
46
+ // and `AnalyticsField` reads `state.analytics`, and none of them fetches — the persist
47
+ // was their only loader on a hard refresh. They leave in waves 2-4 with those consumers
48
+ // (docs/RTK-MIGRATION.md §5).
49
+ const blacklist = ["menu", "app", "languages", "domains", "redirects", "activityLog"];
50
+
51
+ /**
52
+ * redux-persist applies the blacklist only when WRITING: `createPersistoid` filters the
53
+ * keys it stages, but `getStoredState` returns every key the previous releases wrote and
54
+ * `autoMergeLevel1` hard-sets them all (`newState[key] = inboundState[key]`). So adding
55
+ * an area to the blacklist does not stop it from being rehydrated once — it only stops
56
+ * the next write.
57
+ *
58
+ * Without this, the first boot after the deploy restores the areas that just left the
59
+ * persist from a stale blob, which is exactly what ADR 0002 removes: state from another
60
+ * site, in a screen that then PUTs it back. Deleting the keys from the inbound state is
61
+ * enough (the first write afterwards replaces the whole blob), and it needs no `version`
62
+ * bump — `persistReducer` calls `migrate` on every rehydrate.
63
+ */
64
+ export const dropBlacklistedAreas = async (state: PersistedState): Promise<PersistedState> => {
65
+ if (!state) return state;
66
+
67
+ const kept = { ...state } as Record<string, unknown>;
68
+ for (const area of blacklist) {
69
+ delete kept[area];
70
+ }
71
+ return kept as PersistedState;
72
+ };
29
73
 
30
- const blacklist = ["router", "menu", "app", "languages"];
31
74
  export class GlobalStore {
32
- private persistConfig: PersistConfig<any> = { key: "root", storage, blacklist };
75
+ private persistConfig: PersistConfig<any> = { key: "root", storage, blacklist, migrate: dropBlacklistedAreas };
33
76
 
34
77
  private authPersistConfig = {
35
78
  key: "app",
@@ -37,11 +80,8 @@ export class GlobalStore {
37
80
  blacklist: ["error", "isLoggingIn", "isRehydrated", "isSaving", "loadingCount", "user"],
38
81
  };
39
82
 
40
- public configureStore(history: History) {
41
- let middleware: StoreEnhancer = applyMiddleware(thunk, routerMiddleware(history));
42
-
83
+ public configureStore() {
43
84
  const globalReducer: Reducer<any> = combineReducers({
44
- router: connectRouter(history),
45
85
  app: persistReducer(this.authPersistConfig, appReducer as Reducer<any, Action<any>>),
46
86
  sites: sitesReducer as Reducer<any, Action<any>>,
47
87
  pageEditor: pageEditorReducer as Reducer<any, Action<any>>,
@@ -65,7 +105,6 @@ export class GlobalStore {
65
105
  const rootReducer = (state: IRootState | undefined, action: any) => {
66
106
  if (action.type === LOGOUT) {
67
107
  state = {
68
- router: state?.router,
69
108
  app: state?.app || appInitialState,
70
109
  sites: sitesInitialState,
71
110
  pageEditor: pageEditorInitialState,
@@ -90,10 +129,27 @@ export class GlobalStore {
90
129
  return globalReducer(state, action);
91
130
  };
92
131
 
93
- if (process.env.NODE_ENV !== "production") {
94
- middleware = composeWithDevTools(middleware);
95
- }
96
- const store = createStore(persistReducer(this.persistConfig, rootReducer), middleware);
132
+ // configureStore already wires redux-thunk and the devtools extension (in
133
+ // non-production), which is why redux-devtools-extension is gone from the deps.
134
+ const store = configureStore({
135
+ reducer: persistReducer(this.persistConfig, rootReducer),
136
+ middleware: (getDefaultMiddleware) =>
137
+ getDefaultMiddleware({
138
+ // redux-persist dispatches these with non-serializable payloads by design.
139
+ // Kept ON otherwise: it only logs, and its warnings are the worklist for
140
+ // the offenders documented in docs/RTK-MIGRATION.md §5 (5 Date instances in
141
+ // state, app.error.btnAction holding a function).
142
+ serializableCheck: {
143
+ ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
144
+ },
145
+ // OFF on purpose. The three editors (PageEditor, Forms, Navigation/Defaults)
146
+ // mutate live store references and re-dispatch the regenerated tree
147
+ // (docs/RTK-MIGRATION.md §2.1), so this would log on every edit — and it
148
+ // deep-scans the whole tree per action, which is costly on a state this big.
149
+ // Re-enable per area as each editor is sanitised (waves 2-4).
150
+ immutableCheck: false,
151
+ }),
152
+ });
97
153
  const persistor: Persistor = persistStore(store);
98
154
  return { store, persistor };
99
155
  }
@@ -0,0 +1,132 @@
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
+ });
@@ -0,0 +1,168 @@
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
+ });
@@ -0,0 +1,110 @@
1
+ import { Router } from "react-router-dom";
2
+
3
+ import { parseTheme } from "@ax/helpers";
4
+ import Robots from "@ax/modules/GlobalSettings/Robots";
5
+ import history from "@ax/routes/history";
6
+ import globalTheme from "@ax/themes/theme.json";
7
+ import type { IDomainRobot } from "@ax/types";
8
+
9
+ import axios from "axios";
10
+ import configureStore from "redux-mock-store";
11
+ import thunk from "redux-thunk";
12
+ import { ThemeProvider } from "styled-components";
13
+ import type { MockedFunction } from "vitest";
14
+
15
+ import { act, cleanup, fireEvent, render, screen } from "../../../../../config/tests/test-utils";
16
+
17
+ vi.mock("axios");
18
+ const mockedAxios = axios as MockedFunction<typeof axios>;
19
+
20
+ const mockComponent = () => null;
21
+ const navItems = [{ title: "Robots.txt", path: "/settings/robots", component: mockComponent }];
22
+ const currentNavItem = navItems[0];
23
+
24
+ const ROBOT: IDomainRobot = {
25
+ id: 1,
26
+ path: "/",
27
+ fullUrl: "https://www.example.edu/",
28
+ content: "User-agent: *",
29
+ computedContent: "User-agent: *",
30
+ };
31
+ const OTHER: IDomainRobot = { ...ROBOT, id: 2, path: "/es", fullUrl: "https://www.example.edu/es" };
32
+
33
+ const createMockStore = (overrides: Record<string, unknown> = {}) => {
34
+ const defaultStore = {
35
+ app: { isLoading: false, isSaving: false },
36
+ domains: { robots: [ROBOT, OTHER] },
37
+ ...overrides,
38
+ };
39
+ return configureStore([thunk] as never)(defaultStore);
40
+ };
41
+
42
+ const renderScreen = async (store: ReturnType<typeof createMockStore>) => {
43
+ await act(async () => {
44
+ render(
45
+ <Router history={history}>
46
+ <ThemeProvider theme={parseTheme(globalTheme)}>
47
+ <Robots navItems={navItems} currentNavItem={currentNavItem} />
48
+ </ThemeProvider>
49
+ </Router>,
50
+ { store },
51
+ );
52
+ });
53
+ };
54
+
55
+ // The screen is asserted through the requests it makes, not through the action
56
+ // creators it calls: `connect` captures those by reference when the module is
57
+ // evaluated, so spying on them is invisible here — and would break anyway the
58
+ // moment the area moves to hooks.
59
+ const requestsTo = (endpoint: string, method: string) =>
60
+ mockedAxios.mock.calls
61
+ .map(([config]) => config as { url?: string; method?: string; data?: unknown })
62
+ .filter((config) => config?.url?.endsWith(endpoint) && config?.method === method);
63
+
64
+ beforeEach(() => {
65
+ mockedAxios.mockResolvedValue({ status: 200, data: [], statusText: "OK", headers: {}, config: {} as never });
66
+ });
67
+
68
+ afterEach(() => {
69
+ vi.restoreAllMocks();
70
+ cleanup();
71
+ });
72
+
73
+ describe("[UC-AX-configure-seo-analytics] robots.txt editor", () => {
74
+ it("AC1 · loads the robots on mount", async () => {
75
+ await renderScreen(createMockStore());
76
+
77
+ expect(requestsTo("/domains/robots", "GET")).toHaveLength(1);
78
+ });
79
+
80
+ it("AC7 · shows one row per robot received", async () => {
81
+ await renderScreen(createMockStore());
82
+
83
+ expect(screen.getAllByText("Edit robots.txt")).toHaveLength(2);
84
+ expect(screen.getAllByText(ROBOT.fullUrl, { exact: false }).length).toBeGreaterThan(0);
85
+ expect(screen.getAllByText(OTHER.fullUrl, { exact: false }).length).toBeGreaterThan(0);
86
+ });
87
+
88
+ it("AC7 · shows the loading state instead of the rows while app.isLoading", async () => {
89
+ await renderScreen(createMockStore({ app: { isLoading: true, isSaving: false } }));
90
+
91
+ expect(screen.queryByText("Edit robots.txt")).toBeNull();
92
+ });
93
+
94
+ it("AC1 · saving sends the robots currently on screen to the bulk endpoint", async () => {
95
+ await renderScreen(createMockStore());
96
+ await act(async () => {
97
+ fireEvent.click(screen.getByText("Save"));
98
+ });
99
+
100
+ const [save] = requestsTo("/domains/robots/bulk", "PUT");
101
+ expect(save?.data).toEqual({ robots: [ROBOT, OTHER] });
102
+ });
103
+
104
+ it("AC7 · the save button reports progress while app.isSaving", async () => {
105
+ await renderScreen(createMockStore({ app: { isLoading: false, isSaving: true } }));
106
+
107
+ expect(screen.getByText("Saving")).toBeTruthy();
108
+ expect(screen.queryByText("Save")).toBeNull();
109
+ });
110
+ });