@griddo/ax 12.6.0 → 12.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/config/griddo-config/index.js +1 -0
- package/package.json +3 -4
- package/src/GlobalStore.tsx +91 -35
- package/src/__tests__/modules/ActivityLog/ActivityLog.uc.test.tsx +132 -0
- package/src/__tests__/modules/Analytics/Analytics.uc.test.tsx +168 -0
- package/src/__tests__/modules/GlobalSettings/Robots/Robots.test.tsx +110 -0
- package/src/__tests__/modules/GlobalSettings/Robots/Robots.uc.test.tsx +125 -0
- package/src/__tests__/modules/Redirects/Redirects.uc.test.tsx +164 -0
- package/src/__tests__/modules/Settings/Languages/Languages.uc.test.tsx +213 -0
- package/src/__tests__/modules/Settings/Social/Social.uc.test.tsx +171 -0
- package/src/__tests__/store/activityLog.test.ts +301 -0
- package/src/__tests__/store/analytics.test.ts +134 -0
- package/src/__tests__/store/domains.test.ts +107 -0
- package/src/__tests__/store/languages.test.ts +129 -0
- package/src/__tests__/store/persist.test.ts +47 -0
- package/src/__tests__/store/redirects.test.ts +313 -0
- package/src/__tests__/store/social.test.ts +165 -0
- package/src/components/MainWrapper/index.tsx +14 -2
- package/src/containers/ActivityLog/index.tsx +16 -3
- package/src/containers/ActivityLog/slice.ts +45 -0
- package/src/containers/ActivityLog/{actions.tsx → thunks.ts} +104 -89
- package/src/containers/Analytics/index.tsx +14 -3
- package/src/containers/Analytics/slice.ts +48 -0
- package/src/containers/Analytics/thunks.ts +103 -0
- package/src/containers/Domains/index.tsx +12 -3
- package/src/containers/Domains/slice.ts +37 -0
- package/src/containers/Domains/thunks.ts +60 -0
- package/src/containers/Redirects/constants.tsx +1 -17
- package/src/containers/Redirects/index.tsx +14 -3
- package/src/containers/Redirects/slice.ts +60 -0
- package/src/containers/Redirects/thunks.ts +196 -0
- package/src/containers/Settings/Languages/index.tsx +15 -3
- package/src/containers/Settings/Languages/slice.ts +48 -0
- package/src/containers/Settings/Languages/thunks.ts +83 -0
- package/src/containers/Settings/Social/index.tsx +11 -3
- package/src/containers/Settings/Social/slice.ts +40 -0
- package/src/containers/Settings/Social/thunks.ts +68 -0
- package/src/containers/Sites/actions.tsx +6 -2
- package/src/index.tsx +7 -6
- package/src/modules/ActivityLog/ItemLog/EventItem/index.tsx +13 -27
- package/src/modules/ActivityLog/ItemLogUser/UserItem/EventItem/index.tsx +13 -27
- package/src/modules/ActivityLog/index.tsx +22 -51
- package/src/modules/Analytics/index.tsx +33 -42
- package/src/modules/App/Routing/index.tsx +5 -4
- package/src/modules/FileDrive/index.tsx +1 -1
- package/src/modules/GlobalSettings/Robots/index.tsx +24 -47
- package/src/modules/MediaGallery/index.tsx +1 -1
- package/src/modules/Redirects/RedirectItem/index.tsx +9 -22
- package/src/modules/Redirects/RedirectPanel/index.tsx +13 -14
- package/src/modules/Redirects/index.tsx +26 -69
- package/src/modules/Settings/ContentTypes/DataPacks/index.tsx +4 -3
- package/src/modules/Settings/Languages/LanguagePanel/Form/ConnectedField/index.tsx +11 -18
- package/src/modules/Settings/Languages/LanguagePanel/Form/index.tsx +7 -9
- package/src/modules/Settings/Languages/LanguagePanel/index.tsx +38 -44
- package/src/modules/Settings/Languages/Table/Header/index.tsx +12 -19
- package/src/modules/Settings/Languages/Table/Item/index.tsx +21 -36
- package/src/modules/Settings/Languages/Table/index.tsx +6 -14
- package/src/modules/Settings/Languages/index.tsx +18 -30
- package/src/modules/Settings/SeoAnalyticsSettings/Analytics/index.tsx +42 -52
- package/src/modules/Settings/Social/index.tsx +17 -12
- package/src/modules/StructuredData/StructuredDataList/index.tsx +3 -2
- package/src/store/hooks.ts +37 -0
- package/src/store/index.ts +2 -0
- package/src/types/index.tsx +5 -6
- package/tsconfig.paths.json +1 -0
- package/src/containers/ActivityLog/constants.tsx +0 -6
- package/src/containers/ActivityLog/interfaces.tsx +0 -12
- package/src/containers/ActivityLog/reducer.tsx +0 -24
- package/src/containers/Analytics/actions.tsx +0 -109
- package/src/containers/Analytics/constants.tsx +0 -5
- package/src/containers/Analytics/interfaces.tsx +0 -9
- package/src/containers/Analytics/reducer.tsx +0 -28
- package/src/containers/Domains/actions.tsx +0 -58
- package/src/containers/Domains/constants.tsx +0 -5
- package/src/containers/Domains/interfaces.tsx +0 -9
- package/src/containers/Domains/reducer.tsx +0 -22
- package/src/containers/Redirects/actions.tsx +0 -190
- package/src/containers/Redirects/interfaces.tsx +0 -23
- package/src/containers/Redirects/reducer.tsx +0 -35
- package/src/containers/Settings/Languages/actions.tsx +0 -88
- package/src/containers/Settings/Languages/constants.tsx +0 -6
- package/src/containers/Settings/Languages/reducer.tsx +0 -31
- package/src/containers/Settings/Social/actions.tsx +0 -63
- package/src/containers/Settings/Social/constants.tsx +0 -3
- package/src/containers/Settings/Social/interfaces.tsx +0 -9
- package/src/containers/Settings/Social/reducer.tsx +0 -18
|
@@ -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.
|
|
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": "
|
|
203
|
+
"gitHead": "e0eec02188240c74c8fdacfa7a04fbafdc38c597"
|
|
205
204
|
}
|
package/src/GlobalStore.tsx
CHANGED
|
@@ -1,35 +1,78 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
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 {
|
|
9
|
-
import {
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
+
});
|