@griddo/ax 12.8.0 → 12.8.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@griddo/ax",
3
3
  "description": "Griddo Author Experience",
4
- "version": "12.8.0",
4
+ "version": "12.8.2",
5
5
  "authors": [
6
6
  "Álvaro Sánchez' <alvaro.sanches@secuoyas.com>",
7
7
  "Diego M. Béjar <diego.bejar@secuoyas.com>",
@@ -44,36 +44,36 @@
44
44
  "dependencies": {
45
45
  "@dnd-kit/core": "6.3.1",
46
46
  "@dnd-kit/sortable": "10.0.0",
47
- "@griddo/api-types": "^1.0.12",
48
- "@pmmmwh/react-refresh-webpack-plugin": "0.5.16",
47
+ "@griddo/api-types": "1.1.3",
48
+ "@pmmmwh/react-refresh-webpack-plugin": "0.5.17",
49
49
  "@styled-system/prop-types": "5.1.5",
50
50
  "@styled-system/theme-get": "5.1.2",
51
51
  "@svgr/webpack": "8.1.0",
52
- "@testing-library/jest-dom": "5.16.5",
52
+ "@testing-library/jest-dom": "5.17.0",
53
53
  "@testing-library/react": "13.4.0",
54
- "@testing-library/user-event": "14.6.1",
54
+ "@testing-library/user-event": "14.6.6",
55
55
  "@types/compress.js": "1.1.3",
56
- "@types/draft-js": "0.11.18",
56
+ "@types/draft-js": "0.11.20",
57
57
  "@types/lodash.isequal": "4.5.8",
58
58
  "@types/markdown-draft-js": "2.2.7",
59
- "@types/node": "20.17.10",
59
+ "@types/node": "20.19.43",
60
60
  "@types/react": "18.2.79",
61
- "@types/react-beautiful-dnd": "13.1.3",
61
+ "@types/react-beautiful-dnd": "13.1.8",
62
62
  "@types/react-datepicker": "4.19.6",
63
63
  "@types/react-dom": "18.2.25",
64
- "@types/react-draft-wysiwyg": "1.13.8",
65
- "@types/react-redux": "7.1.25",
64
+ "@types/react-draft-wysiwyg": "1.13.9",
65
+ "@types/react-redux": "7.1.34",
66
66
  "@types/react-router-dom": "5.3.3",
67
67
  "@types/react-slick": "0.23.13",
68
- "@types/react-test-renderer": "17.0.1",
69
- "@types/styled-components": "5.1.26",
68
+ "@types/react-test-renderer": "17.0.9",
69
+ "@types/styled-components": "5.1.36",
70
70
  "@types/uuid": "8.3.4",
71
- "@types/webpack-env": "1.18.0",
71
+ "@types/webpack-env": "1.18.8",
72
72
  "axios": "0.19.0",
73
73
  "babel-loader": "9.2.1",
74
74
  "babel-plugin-named-asset-import": "0.3.8",
75
75
  "babel-plugin-root-import": "6.6.0",
76
- "babel-plugin-styled-components": "2.1.4",
76
+ "babel-plugin-styled-components": "2.3.0",
77
77
  "babel-preset-react-app": "10.1.0",
78
78
  "case-sensitive-paths-webpack-plugin": "2.4.0",
79
79
  "compress.js": "1.2.2",
@@ -102,12 +102,12 @@
102
102
  "mini-css-extract-plugin": "2.10.2",
103
103
  "node-polyfill-webpack-plugin": "3.0.0",
104
104
  "pkg-dir": "5.0.0",
105
- "polished": "3.4.1",
106
- "postcss": "8.5.3",
105
+ "polished": "3.7.2",
106
+ "postcss": "8.5.26",
107
107
  "postcss-flexbugs-fixes": "5.0.2",
108
108
  "postcss-loader": "7.3.4",
109
109
  "postcss-normalize": "7.0.1",
110
- "postcss-preset-env": "6.7.0",
110
+ "postcss-preset-env": "6.7.2",
111
111
  "react": "18.2.0",
112
112
  "react-app-polyfill": "1.0.6",
113
113
  "react-datepicker": "4.25.0",
@@ -115,13 +115,13 @@
115
115
  "react-dom": "18.2.0",
116
116
  "react-draft-wysiwyg": "1.15.0",
117
117
  "react-easy-crop": "5.5.7",
118
- "react-error-boundary": "4.0.13",
118
+ "react-error-boundary": "4.1.2",
119
119
  "react-froala-wysiwyg": "4.0.4",
120
- "react-qr-code": "2.0.18",
120
+ "react-qr-code": "2.2.0",
121
121
  "react-redux": "7.2.9",
122
122
  "react-refresh": "0.14.2",
123
- "react-router-dom": "5.1.2",
124
- "react-select": "5.10.1",
123
+ "react-router-dom": "5.3.4",
124
+ "react-select": "5.10.2",
125
125
  "react-slick": "0.30.3",
126
126
  "react-textarea-autosize": "8.5.9",
127
127
  "react-timezone-map-select": "1.2.25",
@@ -129,11 +129,11 @@
129
129
  "redux-devtools-extension": "2.13.9",
130
130
  "redux-persist": "6.0.0",
131
131
  "reflect-metadata": "0.1.13",
132
- "resolve": "1.22.10",
132
+ "resolve": "1.22.12",
133
133
  "resolve-url-loader": "4.0.0",
134
134
  "sass-alias": "1.0.5",
135
135
  "sass-loader": "16.0.8",
136
- "semver": "7.6.3",
136
+ "semver": "7.8.5",
137
137
  "slick-carousel": "1.8.1",
138
138
  "source-map-loader": "1.1.3",
139
139
  "string-replace-loader": "3.1.0",
@@ -149,18 +149,18 @@
149
149
  "webpack-manifest-plugin": "5.0.1"
150
150
  },
151
151
  "devDependencies": {
152
- "@babel/core": "7.26.10",
152
+ "@babel/core": "7.29.7",
153
153
  "@babel/helper-environment-visitor": "7.24.7",
154
154
  "@babel/helper-function-name": "7.24.7",
155
155
  "@babel/helper-hoist-variables": "7.24.7",
156
156
  "@babel/helper-split-export-declaration": "7.24.7",
157
- "@babel/plugin-transform-optional-chaining": "7.25.9",
158
- "@babel/preset-env": "7.26.9",
159
- "@babel/preset-react": "7.26.3",
160
- "@babel/preset-typescript": "7.26.0",
157
+ "@babel/plugin-transform-optional-chaining": "7.29.7",
158
+ "@babel/preset-env": "7.29.7",
159
+ "@babel/preset-react": "7.29.7",
160
+ "@babel/preset-typescript": "7.29.7",
161
161
  "@biomejs/biome": "2.3.8",
162
- "@types/redux-mock-store": "1.0.3",
163
- "@vitest/coverage-v8": "4.1.6",
162
+ "@types/redux-mock-store": "1.5.0",
163
+ "@vitest/coverage-v8": "4.1.10",
164
164
  "axios-mock-adapter": "1.22.0",
165
165
  "prettier": "3.4.2",
166
166
  "react-test-renderer": "18.2.0",
@@ -201,5 +201,5 @@
201
201
  "publishConfig": {
202
202
  "access": "public"
203
203
  },
204
- "gitHead": "c9c202ecd4e133b8a599e0439cff1aaf93e30bc1"
204
+ "gitHead": "00a702fad2cd346b673d8454d818d9e0e9d470f3"
205
205
  }
@@ -0,0 +1,401 @@
1
+ import { Router } from "react-router-dom";
2
+
3
+ import { appReducer, type IAppState } from "@ax/containers/App/reducer";
4
+ import { SET_DOMAINS_ROBOTS } from "@ax/containers/Domains/constants";
5
+ import { domainsReducer, type IDomainsState } from "@ax/containers/Domains/reducer";
6
+ import { parseTheme } from "@ax/helpers";
7
+ import Robots from "@ax/modules/GlobalSettings/Robots";
8
+ import history from "@ax/routes/history";
9
+ import globalTheme from "@ax/themes/theme.json";
10
+ import type { IDomainRobot } from "@ax/types";
11
+
12
+ import axios from "axios";
13
+ import { applyMiddleware, combineReducers, createStore, type Reducer } from "redux";
14
+ import thunk from "redux-thunk";
15
+ import { ThemeProvider } from "styled-components";
16
+ import type { MockedFunction } from "vitest";
17
+
18
+ import { act, cleanup, fireEvent, render, screen, within } from "../../../../../config/tests/test-utils";
19
+
20
+ /**
21
+ * End-to-end test of the use case slice: API → action → reducer → selector → UI,
22
+ * with only axios mocked. Named `.uc.` and not `.flow.` because "flow" is taken in
23
+ * GAINUP: an `F-<slug>` is a multi-package spec that links UCs and has no tests of
24
+ * its own.
25
+ *
26
+ * The house pattern for these screens (e.g. Social.test.tsx) mounts them on a
27
+ * `redux-mock-store`, which accepts dispatches without reducing them — so it can
28
+ * only assert on the requests that leave. Here the store is the real one, so what
29
+ * the screen renders IS the result of the reducer running on the server's
30
+ * response, which is what the acceptance criteria actually claim.
31
+ *
32
+ * Only the reducers this screen reads are mounted, not the whole root reducer:
33
+ * the rest would only add setup noise to a test that is about `domains` + `app`.
34
+ */
35
+ vi.mock("axios");
36
+ const mockedAxios = axios as MockedFunction<typeof axios>;
37
+
38
+ const mockComponent = () => null;
39
+ const navItems = [{ title: "Robots.txt", path: "/settings/robots", component: mockComponent }];
40
+
41
+ const EMPTY: IDomainRobot = {
42
+ id: 1,
43
+ path: "/",
44
+ fullUrl: "https://www.example.edu/",
45
+ content: "",
46
+ computedContent: "",
47
+ };
48
+
49
+ /**
50
+ * `structuredClone` is load-bearing: the screen still edits by mutating in place
51
+ * (`handleSetItem` writes `newItems[i].content` into the very objects the store holds —
52
+ * the live-mutation debt of RTK-MIGRATION.md §2.1, one more site for its list). Handing
53
+ * the same `EMPTY` module constant to two tests couples them through that mutation, and
54
+ * the suite runs shuffled (`sequence.shuffle`), so the coupling surfaces as seed-dependent
55
+ * failures in whichever test draws `EMPTY` after an editing test ran.
56
+ */
57
+ const ok = (data: unknown) => ({
58
+ status: 200,
59
+ data: structuredClone(data),
60
+ statusText: "OK",
61
+ headers: {},
62
+ config: {} as never,
63
+ });
64
+
65
+ // The casts are the redux-4 spelling of the cast GlobalStore needs too (RTK-MIGRATION.md
66
+ // §8.3): each legacy reducer types its `state` parameter by the literal inferred from its
67
+ // own `initialState`, which is narrower than the interface it returns, so an unannotated
68
+ // `combineReducers` collapses the store type and `getState()` becomes unusable.
69
+ const makeStore = () =>
70
+ createStore(
71
+ combineReducers({
72
+ app: appReducer as Reducer<IAppState>,
73
+ domains: domainsReducer as Reducer<IDomainsState>,
74
+ }),
75
+ applyMiddleware(thunk),
76
+ );
77
+
78
+ const renderScreen = async (store: ReturnType<typeof makeStore>) => {
79
+ await act(async () => {
80
+ render(
81
+ <Router history={history}>
82
+ <ThemeProvider theme={parseTheme(globalTheme)}>
83
+ <Robots navItems={navItems} currentNavItem={navItems[0]} />
84
+ </ThemeProvider>
85
+ </Router>,
86
+ { store },
87
+ );
88
+ });
89
+ };
90
+
91
+ /**
92
+ * Two pieces of state outlive a test here, and the file's tests run shuffled
93
+ * (`sequence.shuffle.tests`), so leaving either behind makes failures move around between
94
+ * runs — which is exactly what happened while writing these:
95
+ *
96
+ * - **`history` is a module singleton.** A test that navigates away leaves the next one
97
+ * starting somewhere else, and one that gets *blocked* by the guard leaves a pending
98
+ * transition behind.
99
+ * - **the axios mock is a queue.** `mockResolvedValueOnce` chains are positional, so a
100
+ * response left unconsumed by one test is handed to the next one's first request.
101
+ */
102
+ beforeEach(() => {
103
+ history.replace("/settings/robots");
104
+ mockedAxios.mockReset();
105
+ });
106
+
107
+ afterEach(() => {
108
+ // Only the dirty-tracking tests fake the clock; this is a no-op for the others.
109
+ vi.useRealTimers();
110
+ vi.restoreAllMocks();
111
+ cleanup();
112
+ });
113
+
114
+ describe("[UC-AX-configure-seo-analytics] robots.txt end to end", () => {
115
+ it("AC1 · what the server returns after saving is what the screen shows", async () => {
116
+ vi.useFakeTimers();
117
+ const store = makeStore();
118
+
119
+ // Mount: the domain has no robots.txt yet, so the row offers to add one.
120
+ mockedAxios.mockResolvedValueOnce(ok([EMPTY]));
121
+ await renderScreen(store);
122
+
123
+ expect(screen.getByText("Add robots.txt")).toBeTruthy();
124
+
125
+ // An edit is now a precondition for saving at all: since sc-118157 the button is
126
+ // disabled while the screen is clean, so this test used to click a Save it should never
127
+ // have been offered.
128
+ await editContent("Add robots.txt", "typed by the user");
129
+
130
+ // Saving: PUT, then the reload the thunk chains. The server deliberately answers with
131
+ // something DIFFERENT from what was typed — that is what makes the last assertion prove
132
+ // the reducer ran on the reloaded response rather than on the local edit, which is the
133
+ // assertion a mock store cannot make.
134
+ const fromServer = { ...EMPTY, content: "User-agent: *", computedContent: "User-agent: *" };
135
+ mockedAxios.mockResolvedValueOnce(ok({})).mockResolvedValueOnce(ok([fromServer]));
136
+ await act(async () => {
137
+ fireEvent.click(screen.getByText("Save"));
138
+ });
139
+
140
+ expect(screen.getByText("Edit robots.txt")).toBeTruthy();
141
+ expect(screen.queryByText("Add robots.txt")).toBeNull();
142
+ expect(store.getState().domains.robots).toEqual([fromServer]);
143
+ });
144
+
145
+ /**
146
+ * The dirty tracking of this screen (sc-118183). Two things make it awkward to assert on,
147
+ * and both are why it had no test:
148
+ *
149
+ * 1. **`isDirty` is invisible here.** Unlike Social or the global Analytics, this Save
150
+ * button is `disabled={isSaving}` and never looks at `isDirty`, so the button cannot
151
+ * tell you anything. The only window onto it is the `RouteLeavingGuard`, so these
152
+ * navigate and look for its modal.
153
+ * 2. **Asserting "clean after saving" does not discriminate.** It passes just as well
154
+ * with a stale comparison reference as with a correct one. What separates them is
155
+ * **reverting**: going back to the value the field had *before* the edit may only read
156
+ * clean if the reference is still the pre-edit content, i.e. if it never moved.
157
+ *
158
+ * So the second test is the one that matters, and under the bug the two are inverted:
159
+ * saving read dirty and reverting read clean, which is exactly backwards.
160
+ */
161
+ /**
162
+ * `TextArea` debounces its `onChange` by 300 ms (`components/Fields/TextArea/index.tsx:13-20`),
163
+ * so without driving the clock the typed value never reaches `robotItems` and every
164
+ * assertion below would be about a screen that was never edited. This is the trap that
165
+ * made the first draft of these tests pass for the wrong reason — same one wave 1 hit on
166
+ * Social (docs/RTK-MIGRATION.md §7).
167
+ */
168
+ const DEBOUNCE_MS = 300;
169
+
170
+ const typeInto = async (text: string) => {
171
+ fireEvent.change(screen.getByTestId("text-area-component"), { target: { value: text } });
172
+ await act(async () => {
173
+ vi.advanceTimersByTime(DEBOUNCE_MS);
174
+ });
175
+ };
176
+
177
+ const openRow = async (label: "Add robots.txt" | "Edit robots.txt") => {
178
+ await act(async () => {
179
+ fireEvent.click(screen.getByText(label));
180
+ });
181
+ };
182
+
183
+ const editContent = async (label: "Add robots.txt" | "Edit robots.txt", text: string) => {
184
+ await openRow(label);
185
+ await typeInto(text);
186
+ await applyPanel();
187
+ };
188
+
189
+ /**
190
+ * Clicks the panel's footer button, which is what hands the typed value up to the screen.
191
+ *
192
+ * Scoped **inside the panel**, and that is the whole point: the button's label lives in a
193
+ * `<span>` (so `{ selector: "button" }` finds nothing), the panel *title* repeats those
194
+ * same words, and `className="button"` is shared with `MainWrapper`'s Save — which sits
195
+ * earlier in the DOM, so a bare `querySelector("button.button")` silently clicked **Save**
196
+ * and the edit never reached the screen at all.
197
+ */
198
+ const applyPanel = async () => {
199
+ const apply = within(screen.getByTestId("floating-panel")).getByRole("button", { name: /Robots\.txt$/ });
200
+ await act(async () => {
201
+ fireEvent.click(apply);
202
+ });
203
+ };
204
+
205
+ const navigateAway = async () => {
206
+ await act(async () => {
207
+ history.push("/settings/analytics");
208
+ });
209
+ };
210
+
211
+ /** The guard's modal, which `RouteLeavingGuard` only opens when `when={isDirty}` blocks. */
212
+ const guardIsShowing = () => screen.queryByText("Unsaved changes") !== null;
213
+
214
+ it("AC6 · a successful save leaves the screen clean, so leaving does not warn", async () => {
215
+ vi.useFakeTimers();
216
+ const store = makeStore();
217
+ mockedAxios.mockResolvedValueOnce(ok([EMPTY]));
218
+ await renderScreen(store);
219
+
220
+ await editContent("Add robots.txt", "User-agent: *");
221
+ // Dirty now, and the guard proves it — without this the next assertion could pass
222
+ // simply because the guard never works in this harness.
223
+ await navigateAway();
224
+ expect(guardIsShowing()).toBe(true);
225
+ await act(async () => {
226
+ fireEvent.click(screen.getByText("Cancel"));
227
+ });
228
+
229
+ // PUT, then the reload the thunk chains. The server echoes the saved content back,
230
+ // and `computedContent` comes back filled — the reload exists for exactly that.
231
+ const saved = { ...EMPTY, content: "User-agent: *", computedContent: "User-agent: *" };
232
+ mockedAxios.mockResolvedValueOnce(ok({})).mockResolvedValueOnce(ok([saved]));
233
+ await act(async () => {
234
+ fireEvent.click(screen.getByText("Save"));
235
+ });
236
+
237
+ await navigateAway();
238
+ expect(guardIsShowing()).toBe(false);
239
+ });
240
+
241
+ it("AC6 · after saving, the reference is what was saved: reverting reads dirty again", async () => {
242
+ vi.useFakeTimers();
243
+ const store = makeStore();
244
+ mockedAxios.mockResolvedValueOnce(ok([{ ...EMPTY, content: "Allow: /" }]));
245
+ await renderScreen(store);
246
+
247
+ await editContent("Edit robots.txt", "Disallow: /");
248
+
249
+ // `computedContent` deliberately comes back UNCHANGED here. It is what makes this test
250
+ // discriminate: if the reload also moved that field, the reverted state would differ
251
+ // from the stale reference on `computedContent` alone, so the assertion below would go
252
+ // green with a broken reference too. Holding it still leaves `content` as the only
253
+ // difference, which is exactly the thing under test.
254
+ const saved = { ...EMPTY, content: "Disallow: /" };
255
+ mockedAxios.mockResolvedValueOnce(ok({})).mockResolvedValueOnce(ok([saved]));
256
+ await act(async () => {
257
+ fireEvent.click(screen.getByText("Save"));
258
+ });
259
+
260
+ // Back to what the field held BEFORE the edit. That is a change with respect to what
261
+ // was just saved, so it has to read dirty. If the reference never moved off the
262
+ // pre-edit content, this reads clean and the user loses the change on navigating.
263
+ await editContent("Edit robots.txt", "Allow: /");
264
+
265
+ await navigateAway();
266
+ expect(guardIsShowing()).toBe(true);
267
+ });
268
+
269
+ /**
270
+ * The other half of sc-118183, and the one nothing covered: the screen used to arm a
271
+ * skip-a-turn token in its mount effect, betting that a hydration would arrive to burn it.
272
+ * When the load fails none does — silently, since a network failure never reaches the error
273
+ * channel (docs/RTK-MIGRATION.md §4.10) — so the token stayed armed and swallowed the
274
+ * user's first edit: they type, the screen says nothing changed, and the leaving guard lets
275
+ * them walk away from it.
276
+ *
277
+ * Reachable because the store survives SPA navigation: coming back to a screen that loaded
278
+ * fine earlier renders its rows from the store while the fresh GET is still in flight, so
279
+ * there is something to edit even when that GET fails.
280
+ */
281
+ it("AC6 · an edit still registers when the reload behind it failed", async () => {
282
+ vi.useFakeTimers();
283
+ const store = makeStore();
284
+ // The state a previous visit left behind, so the rows are on screen…
285
+ store.dispatch({ type: SET_DOMAINS_ROBOTS, payload: { robots: [{ ...EMPTY, content: "Allow: /" }] } });
286
+ // …and this visit's GET fails, so nothing re-hydrates.
287
+ mockedAxios.mockResolvedValueOnce({ status: 500, data: {}, statusText: "err", headers: {}, config: {} as never });
288
+ await renderScreen(store);
289
+
290
+ await editContent("Edit robots.txt", "Disallow: /");
291
+
292
+ await navigateAway();
293
+ expect(guardIsShowing()).toBe(true);
294
+ });
295
+
296
+ // The other side of the contract, and the one that would break if the reset were moved back
297
+ // onto the save path without checking the result: a save that fails must leave the edit
298
+ // pending, or the user walks away believing it went through.
299
+ it("AC8 · a failed save keeps the screen dirty", async () => {
300
+ vi.useFakeTimers();
301
+ const store = makeStore();
302
+ mockedAxios.mockResolvedValueOnce(ok([{ ...EMPTY, content: "Allow: /" }]));
303
+ await renderScreen(store);
304
+
305
+ await editContent("Edit robots.txt", "Disallow: /");
306
+
307
+ // The PUT fails, so the thunk never chains its reload.
308
+ mockedAxios.mockResolvedValueOnce({ status: 400, data: {}, statusText: "e", headers: {}, config: {} as never });
309
+ await act(async () => {
310
+ fireEvent.click(screen.getByText("Save"));
311
+ });
312
+
313
+ await navigateAway();
314
+ expect(guardIsShowing()).toBe(true);
315
+ });
316
+
317
+ /**
318
+ * The Save button as a dirty indicator (sc-118157). Until now this screen's button was
319
+ * `disabled={isSaving}` and always read "Save", so it said nothing about whether there was
320
+ * anything to save — while its sibling settings screens (`Settings/Social/index.tsx:103-104`
321
+ * and `Analytics/index.tsx:48-49`, identical to each other) show "Saved" disabled when clean.
322
+ *
323
+ * These tests only became writable once the bounce of sc-118183 was fixed: with that bug in
324
+ * place the screen went dirty again right after saving, so the button would have flipped
325
+ * back to an enabled "Save" and the third assertion below could not pass. That is why this
326
+ * branch is stacked on `bugfix/ax-118183` rather than cut from main.
327
+ */
328
+ const saveButton = () => screen.getByTestId("main-wrapper").querySelector("button") as HTMLButtonElement;
329
+
330
+ it("AC6 · the button reads Saved and is disabled on a screen with nothing to save", async () => {
331
+ vi.useFakeTimers();
332
+ const store = makeStore();
333
+ mockedAxios.mockResolvedValueOnce(ok([{ ...EMPTY, content: "Allow: /" }]));
334
+ await renderScreen(store);
335
+
336
+ expect(saveButton().textContent).toBe("Saved");
337
+ expect(saveButton().disabled).toBe(true);
338
+ });
339
+
340
+ it("AC6 · editing turns it into an enabled Save, and saving turns it back", async () => {
341
+ vi.useFakeTimers();
342
+ const store = makeStore();
343
+ mockedAxios.mockResolvedValueOnce(ok([{ ...EMPTY, content: "Allow: /" }]));
344
+ await renderScreen(store);
345
+
346
+ await editContent("Edit robots.txt", "Disallow: /");
347
+ expect(saveButton().textContent).toBe("Save");
348
+ expect(saveButton().disabled).toBe(false);
349
+
350
+ mockedAxios.mockResolvedValueOnce(ok({})).mockResolvedValueOnce(ok([{ ...EMPTY, content: "Disallow: /" }]));
351
+ await act(async () => {
352
+ fireEvent.click(saveButton());
353
+ });
354
+
355
+ // Back to clean, which is the assertion that needs sc-118183's fix underneath: with the
356
+ // bounce still there the screen re-dirtied itself and this read "Save".
357
+ expect(saveButton().textContent).toBe("Saved");
358
+ expect(saveButton().disabled).toBe(true);
359
+ });
360
+
361
+ it("AC8 · a failed save leaves the button offering to save again", async () => {
362
+ vi.useFakeTimers();
363
+ const store = makeStore();
364
+ mockedAxios.mockResolvedValueOnce(ok([{ ...EMPTY, content: "Allow: /" }]));
365
+ await renderScreen(store);
366
+
367
+ await editContent("Edit robots.txt", "Disallow: /");
368
+ mockedAxios.mockResolvedValueOnce({ status: 400, data: {}, statusText: "e", headers: {}, config: {} as never });
369
+ await act(async () => {
370
+ fireEvent.click(saveButton());
371
+ });
372
+
373
+ // The edit is still pending, so the button must not claim it is saved.
374
+ expect(saveButton().textContent).toBe("Save");
375
+ expect(saveButton().disabled).toBe(false);
376
+ });
377
+
378
+ it("AC7 · the screen shows the loading state while the load is in flight", async () => {
379
+ const store = makeStore();
380
+ // Held open on purpose: the loading state only exists while the request is in
381
+ // flight, so the test needs to look at the screen before letting it resolve.
382
+ let releaseLoad: (value: unknown) => void = () => undefined;
383
+ const pending = new Promise((resolve) => {
384
+ releaseLoad = resolve;
385
+ });
386
+ mockedAxios.mockReturnValueOnce(pending as never);
387
+
388
+ await renderScreen(store);
389
+
390
+ // setIsLoading(true) has gone through the real `app` reducer by now.
391
+ expect(store.getState().app.isLoading).toBe(true);
392
+ expect(screen.queryByText("Add robots.txt")).toBeNull();
393
+
394
+ await act(async () => {
395
+ releaseLoad(ok([EMPTY]));
396
+ });
397
+
398
+ expect(store.getState().app.isLoading).toBe(false);
399
+ expect(screen.getByText("Add robots.txt")).toBeTruthy();
400
+ });
401
+ });
@@ -1,4 +1,4 @@
1
- import { useEffect, useState } from "react";
1
+ import { useEffect, useRef, useState } from "react";
2
2
  import { connect } from "react-redux";
3
3
 
4
4
  import { ErrorToast, Loading, MainWrapper, Nav } from "@ax/components";
@@ -25,28 +25,68 @@ const Robots = (props: IProps): JSX.Element => {
25
25
  } = props;
26
26
 
27
27
  const [robotItems, setRobotItems] = useState(robots);
28
- const { isDirty, setIsDirty, resetDirty } = useIsDirty(robotItems);
28
+ const { isDirty, resetDirty } = useIsDirty(robotItems);
29
29
 
30
30
  // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: fix this
31
31
  useEffect(() => {
32
32
  getDomainsRobots();
33
- resetDirty();
34
33
  }, []);
35
34
 
35
+ /**
36
+ * Hydration, and the ONLY place this screen resets its dirty state (sc-118183).
37
+ *
38
+ * Whatever the server last sent is by definition the saved state, and `state.domains.robots`
39
+ * only ever moves for that reason — the mount load and the reload the save chains
40
+ * (`containers/Domains/actions.tsx:38`). So resetting here covers both, and covers them with
41
+ * the right reference.
42
+ *
43
+ * Doing it on the save path instead does NOT work, and that is worth spelling out because a
44
+ * one-line `resetDirty()` in `handleSave` looks like it should: the reload exists precisely
45
+ * to bring back a `computedContent` the client could not compute, so what the server returns
46
+ * is **not** what was on screen when the save resolved. Adopting the on-screen version left
47
+ * the reference disagreeing with the reloaded content by that field, and the screen bounced
48
+ * back to dirty anyway.
49
+ *
50
+ * The skip-a-turn token (the argument) is load-bearing, and measured to be: the deciding
51
+ * effect inside `useIsDirty` compares against the reference **as read during render**, not
52
+ * against the ref this call just moved, so there is a render of lag
53
+ * (docs/RTK-MIGRATION.md §4.2). Without the token, the commit that brings the new values
54
+ * reads them as an edit whatever the reference says. It is safe to arm here — unlike at
55
+ * mount — because a hydration really is arriving to consume it.
56
+ *
57
+ * No `force`: it was in the first version of this fix and the tests say it changes nothing,
58
+ * because an edit sets `isSaved` back to false and this call flipping it to true is already
59
+ * enough to move the reference. Left out rather than kept "just in case".
60
+ *
61
+ * And the reset does NOT run on mount, which is the other half of the fix. The old code
62
+ * armed a token in the mount effect on the assumption that a hydration would come to burn
63
+ * it; if the initial GET fails, none does — and that failure is silent (§4.10) — so the token
64
+ * sat there and ate the user's first edit. At mount there is nothing to hydrate anyway: the
65
+ * form is initialised from the store, so the two already agree.
66
+ */
67
+ const hasHydrated = useRef(false);
36
68
  useEffect(() => {
37
69
  setRobotItems(robots);
38
- }, [robots]);
39
-
70
+ if (hasHydrated.current) resetDirty();
71
+ hasHydrated.current = true;
72
+ }, [robots, resetDirty]);
73
+
74
+ // Deliberately does not touch the dirty state: the save reloads, and the hydration effect
75
+ // above owns it. What used to be here — `if (isSaved) setIsDirty(false)` — was the bug: the
76
+ // raw setter lowers the flag without ever moving the comparison reference (sc-118183). A
77
+ // failed save reloads nothing, so the form correctly stays dirty and the guard still warns.
40
78
  const handleSave = async () => {
41
- const isSaved = await updateDomainRobots(robotItems);
42
- if (isSaved) {
43
- setIsDirty(false);
44
- }
79
+ await updateDomainRobots(robotItems);
45
80
  };
46
81
 
82
+ // Governed by `isDirty`, like every sibling settings screen — `Settings/Social/index.tsx:103`
83
+ // and `Analytics/index.tsx:48` are identical to this (sc-118157). The screen already knew
84
+ // whether it was dirty and fed that same flag to the `RouteLeavingGuard` below; the button
85
+ // simply never read it, so the guard warned about unsaved changes while the button claimed
86
+ // nothing was pending — and a click launched a save of nothing.
47
87
  const rightButtonProps = {
48
- label: isSaving ? "Saving" : "Save",
49
- disabled: isSaving,
88
+ label: isSaving ? "Saving" : isDirty ? "Save" : "Saved",
89
+ disabled: isSaving || !isDirty,
50
90
  action: () => handleSave(),
51
91
  };
52
92