@griddo/ax 12.7.1 → 12.8.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 (53) hide show
  1. package/package.json +2 -2
  2. package/src/GlobalStore.tsx +3 -1
  3. package/src/__tests__/components/ConfigPanel/GlobalPageForm/GlobalPageForm.test.tsx +7 -3
  4. package/src/__tests__/components/Fields/Wysiwyg/Wysiwyg.style.test.tsx +44 -0
  5. package/src/__tests__/components/Fields/Wysiwyg/Wysiwyg.test.tsx +109 -0
  6. package/src/__tests__/components/Nav/Nav.test.tsx +75 -2
  7. package/src/__tests__/components/ResizePanel/ResizePanel.test.tsx +50 -21
  8. package/src/__tests__/components/Toast/Toast.test.tsx +37 -1
  9. package/src/__tests__/hooks/broadcast.test.tsx +263 -0
  10. package/src/__tests__/modules/GlobalSettings/Robots/Robots.uc.test.tsx +401 -0
  11. package/src/api/utils.tsx +7 -6
  12. package/src/components/Browser/index.tsx +10 -2
  13. package/src/components/ConfigPanel/GlobalPageForm/index.tsx +3 -4
  14. package/src/components/Fields/Wysiwyg/atoms.tsx +1 -1
  15. package/src/components/Fields/Wysiwyg/index.tsx +28 -0
  16. package/src/components/Fields/Wysiwyg/style.tsx +41 -1
  17. package/src/components/MainWrapper/index.tsx +14 -2
  18. package/src/components/Nav/index.tsx +21 -6
  19. package/src/components/ResizePanel/index.tsx +8 -3
  20. package/src/components/Toast/index.tsx +4 -1
  21. package/src/containers/ActivityLog/actions.tsx +4 -7
  22. package/src/containers/PageEditor/actions.tsx +14 -3
  23. package/src/helpers/containerEvaluations.tsx +8 -0
  24. package/src/hooks/broadcast.ts +160 -0
  25. package/src/hooks/index.tsx +5 -0
  26. package/src/modules/ActivityLog/index.tsx +1 -0
  27. package/src/modules/Analytics/index.tsx +1 -1
  28. package/src/modules/App/index.tsx +21 -6
  29. package/src/modules/Content/PageItem/index.tsx +36 -19
  30. package/src/modules/Content/index.tsx +5 -7
  31. package/src/modules/FileDrive/FileModal/DetailPanel/UsageContent/index.tsx +5 -14
  32. package/src/modules/FileDrive/index.tsx +1 -1
  33. package/src/modules/Forms/FormUseModal/index.tsx +3 -8
  34. package/src/modules/GlobalEditor/atoms.tsx +35 -1
  35. package/src/modules/GlobalEditor/index.tsx +169 -51
  36. package/src/modules/GlobalSettings/Robots/index.tsx +52 -12
  37. package/src/modules/MediaGallery/ImageModal/DetailPanel/UsageContent/index.tsx +6 -15
  38. package/src/modules/MediaGallery/index.tsx +1 -1
  39. package/src/modules/PageEditor/atoms.tsx +35 -1
  40. package/src/modules/PageEditor/index.tsx +168 -48
  41. package/src/modules/Redirects/index.tsx +1 -0
  42. package/src/modules/Settings/Languages/index.tsx +1 -1
  43. package/src/modules/Settings/SeoAnalyticsSettings/Analytics/index.tsx +1 -0
  44. package/src/modules/Settings/Social/index.tsx +1 -1
  45. package/src/modules/StructuredData/Form/index.tsx +108 -10
  46. package/src/modules/StructuredData/StructuredDataList/GlobalPageItem/index.tsx +31 -11
  47. package/src/modules/StructuredData/StructuredDataList/StructuredDataItem/index.tsx +17 -3
  48. package/src/modules/StructuredData/StructuredDataList/index.tsx +6 -4
  49. package/src/modules/StructuredData/atoms.tsx +35 -1
  50. package/src/routes/multisite.tsx +34 -18
  51. package/src/routes/site.tsx +40 -21
  52. package/src/storeRegistry.ts +5 -0
  53. package/src/modules/StructuredData/index.tsx +0 -18
@@ -26,7 +26,15 @@ import { structuredDataActions } from "@ax/containers/StructuredData";
26
26
  import { usersActions } from "@ax/containers/Users";
27
27
  import { RouteLeavingGuard } from "@ax/guards";
28
28
  import { dateToString, getDefaultTheme } from "@ax/helpers";
29
- import { useEditingHeartbeat, useIsDirty, useModals, usePermissionsForPage } from "@ax/hooks";
29
+ import {
30
+ useBroadcastClipboard,
31
+ useBroadcastContentUpdate,
32
+ useEditingHeartbeat,
33
+ useIsDirty,
34
+ useModals,
35
+ usePermissionsForPage,
36
+ } from "@ax/hooks";
37
+ import history from "@ax/routes/history";
30
38
  import type {
31
39
  HeadingFilter,
32
40
  IErrorItem,
@@ -40,7 +48,7 @@ import type {
40
48
  PageAccessGrantsSummary,
41
49
  } from "@ax/types";
42
50
 
43
- import { DeleteModal } from "./atoms";
51
+ import { ConflictSaveModal, DeleteModal } from "./atoms";
44
52
  import Editor from "./Editor";
45
53
  import Preview from "./Preview";
46
54
 
@@ -78,6 +86,7 @@ const GlobalEditor = (props: IProps) => {
78
86
  updatePageAccessGrants,
79
87
  accessGrants,
80
88
  sendPagePing,
89
+ match,
81
90
  } = props;
82
91
 
83
92
  const isAllowedTo = usePermissionsForPage(
@@ -109,9 +118,14 @@ const GlobalEditor = (props: IProps) => {
109
118
  time: "12:00 am",
110
119
  });
111
120
  const [pendingNavigateCallback, setPendingNavigateCallback] = useState<(() => void) | null>(null);
121
+ const [showPageUpdateNotification, setShowPageUpdateNotification] = useState(false);
122
+ const [pendingConflictAction, setPendingConflictAction] = useState<(() => Promise<void>) | null>(null);
112
123
  const browserRef = useRef<HTMLDivElement>(null);
113
124
 
114
- const { isOpen, toggleModal } = useModals([
125
+ const { broadcastPageSave, remotePageUpdate, clearRemoteUpdate } = useBroadcastContentUpdate(pageID);
126
+ useBroadcastClipboard();
127
+
128
+ const { isOpen, toggleModal, closeModal } = useModals([
115
129
  "userEditing",
116
130
  "unpublish",
117
131
  "schedule",
@@ -122,6 +136,7 @@ const GlobalEditor = (props: IProps) => {
122
136
  "dirtyNavigate",
123
137
  "userAccess",
124
138
  "delete",
139
+ "conflictSave",
125
140
  ]);
126
141
 
127
142
  const { isDirty, setIsDirty, resetDirty } = useIsDirty(editorContent, isNewTranslation);
@@ -155,19 +170,34 @@ const GlobalEditor = (props: IProps) => {
155
170
  // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: fix this
156
171
  useEffect(() => {
157
172
  const { setStructuredDataFilter } = props;
173
+ const urlPageID = match?.params?.pageID ? parseInt(match.params.pageID, 10) : undefined;
174
+ if (urlPageID) setCurrentPageID(urlPageID);
175
+ const resolvedPageID = urlPageID ?? pageID;
158
176
 
159
177
  editorContent?.structuredData && setStructuredDataFilter(editorContent.structuredData);
160
- const handleGetPage = async () => await getPage(pageID, true);
178
+ const handleGetPage = async () => await getPage(resolvedPageID, true);
161
179
  setTab(defaultContentTab);
162
180
  setToastMsg(null);
163
181
  handleGetPage();
164
- if (!pageID) {
182
+ if (!resolvedPageID) {
165
183
  setIsDirty(false);
166
184
  }
167
185
  }, []);
168
186
 
169
187
  useEditingHeartbeat(pageID, sendPagePing);
170
188
 
189
+ // sc-111850: the route carries the page id and the mount effect above seeds
190
+ // currentPageID from it, so a URL still pointing at the previous page is not cosmetic —
191
+ // it re-enters on the next mount and drags the editor back to that page. The global
192
+ // editor lives under /data, not /sites: this module is the one routes/multisite.tsx
193
+ // mounts, and routes/site.tsx mounts PageEditor instead.
194
+ useEffect(() => {
195
+ const urlPageID = match?.params?.pageID ? parseInt(match.params.pageID, 10) : undefined;
196
+ if (pageID && urlPageID && pageID !== urlPageID) {
197
+ history.replace(`/data/pages/editor/${pageID}`);
198
+ }
199
+ }, [pageID, match]);
200
+
171
201
  useEffect(() => {
172
202
  setSelectedTab(defaultTab);
173
203
  }, [defaultTab]);
@@ -200,6 +230,12 @@ const GlobalEditor = (props: IProps) => {
200
230
  }
201
231
  }, [editorContent]);
202
232
 
233
+ useEffect(() => {
234
+ if (remotePageUpdate) {
235
+ setShowPageUpdateNotification(true);
236
+ }
237
+ }, [remotePageUpdate]);
238
+
203
239
  const setRoute = (path: string) => props.setHistoryPush(path, true);
204
240
 
205
241
  const removePage = async () => {
@@ -217,31 +253,52 @@ const GlobalEditor = (props: IProps) => {
217
253
  }
218
254
  };
219
255
 
220
- const publishPage = async () => {
221
- const { updatePageStatus, savePage, pageID, validatePage, skipReviewOnPublish } = props;
256
+ // A warned tab must ask before ANY write, not only before the ones that go through
257
+ // savePage. Two traps here: these actions are wired straight to onClick, so their first
258
+ // argument can be a click event and only an explicit `true` may count as "already
259
+ // confirmed"; and the retry is stored as-is, because `() => retry()` would make React
260
+ // read it as a state updater and run the write before the author decides.
261
+ const blockedByConflict = (retry: () => Promise<void>, skipConflictCheck?: boolean) => {
262
+ if (skipConflictCheck === true || !isDirty || !remotePageUpdate) {
263
+ return false;
264
+ }
265
+ setPendingConflictAction(() => retry);
266
+ toggleModal("conflictSave");
267
+ return true;
268
+ };
269
+
270
+ const publishPage = async (skipConflictCheck?: boolean) => {
271
+ const { updatePageStatus, pageID, validatePage, skipReviewOnPublish } = props;
222
272
 
223
273
  const validated = skipReviewOnPublish ? true : await validatePage(true);
224
274
 
225
275
  if (validated) {
226
- const publishPage = {
227
- status: pageStatus.UPLOAD_PENDING,
228
- };
229
-
230
- const isSaved =
231
- pageID && !isNewTranslation
232
- ? await updatePageStatus([pageID], pageStatus.UPLOAD_PENDING)
233
- : await savePage({ publishPage });
276
+ if (pageID && !isNewTranslation) {
277
+ if (blockedByConflict(() => publishPage(true), skipConflictCheck)) {
278
+ return;
279
+ }
280
+
281
+ const isSaved = await updatePageStatus([pageID], pageStatus.UPLOAD_PENDING);
282
+ if (isSaved) {
283
+ resetDirty();
284
+ broadcastPageSave(pageID);
285
+ }
286
+ } else {
287
+ const publishPageData = {
288
+ status: pageStatus.UPLOAD_PENDING,
289
+ };
234
290
 
235
- if (isSaved) {
236
- resetDirty();
291
+ await handleSavePageCommon({ publishPage: publishPageData }, undefined, skipConflictCheck, () =>
292
+ publishPage(true),
293
+ );
237
294
  }
238
295
  } else {
239
296
  setNotification({ text: errorNotificationText, type: "error" });
240
297
  }
241
298
  };
242
299
 
243
- const publishChanges = async () => {
244
- const { savePage, validatePage, skipReviewOnPublish } = props;
300
+ const publishChanges = async (skipConflictCheck?: boolean) => {
301
+ const { validatePage, skipReviewOnPublish } = props;
245
302
 
246
303
  const validated = skipReviewOnPublish ? true : await validatePage(true);
247
304
 
@@ -250,21 +307,23 @@ const GlobalEditor = (props: IProps) => {
250
307
  status: pageStatus.UPLOAD_PENDING,
251
308
  };
252
309
 
253
- const isSaved = await savePage({ publishPage });
254
- if (isSaved) {
255
- resetDirty();
256
- }
310
+ await handleSavePageCommon({ publishPage }, undefined, skipConflictCheck, () => publishChanges(true));
257
311
  } else {
258
312
  setNotification({ text: errorNotificationText, type: "error" });
259
313
  }
260
314
  };
261
315
 
262
- const unpublishPage = async () => {
316
+ const unpublishPage = async (skipConflictCheck?: boolean) => {
263
317
  const { updatePageStatus, pageID } = props;
264
318
 
319
+ if (blockedByConflict(() => unpublishPage(true), skipConflictCheck)) {
320
+ return;
321
+ }
322
+
265
323
  const isSaved = await updatePageStatus([pageID], pageStatus.OFFLINE_PENDING);
266
324
  if (isSaved) {
267
325
  resetDirty();
326
+ broadcastPageSave(pageID);
268
327
  }
269
328
  };
270
329
 
@@ -278,14 +337,13 @@ const GlobalEditor = (props: IProps) => {
278
337
  }
279
338
  };
280
339
 
281
- const handlePublishDraft = async () => {
282
- const { savePage, validatePage, skipReviewOnPublish } = props;
340
+ const handlePublishDraft = async (skipConflictCheck?: boolean) => {
341
+ const { validatePage, skipReviewOnPublish } = props;
283
342
 
284
343
  const validated = skipReviewOnPublish ? true : await validatePage(true);
285
344
 
286
345
  if (validated) {
287
- const isSaved = await savePage({ publishDraft: true });
288
- if (isSaved) resetDirty();
346
+ await handleSavePageCommon({ publishDraft: true }, undefined, skipConflictCheck, () => handlePublishDraft(true));
289
347
  } else {
290
348
  setNotification({ text: errorNotificationText, type: "error" });
291
349
  }
@@ -303,29 +361,38 @@ const GlobalEditor = (props: IProps) => {
303
361
  getPage(pageID, true);
304
362
  };
305
363
 
306
- const handleCreateDraft = async () => {
307
- const { savePage } = props;
308
-
309
- const isSaved = await savePage({ createDraft: true });
310
- if (isSaved) resetDirty();
364
+ const handleCreateDraft = async (skipConflictCheck?: boolean) => {
365
+ await handleSavePageCommon({ createDraft: true }, undefined, skipConflictCheck, () => handleCreateDraft(true));
311
366
  };
312
367
 
313
- const handleSchedulePublication = async () => {
368
+ const handleSchedulePublication = async (skipConflictCheck?: boolean) => {
369
+ if (blockedByConflict(() => handleSchedulePublication(true), skipConflictCheck)) {
370
+ closeModal("schedule");
371
+ return;
372
+ }
373
+
314
374
  const date = new Date(`${scheduleDate.date} ${scheduleDate.time}`);
315
375
  const dateString = dateToString(date, "dd/MM/yyyy HH:mm:ss");
316
376
  const saved = await schedulePublication(dateString, isDraft);
317
377
  if (saved) {
318
378
  resetDirty();
319
- toggleModal("schedule");
379
+ broadcastPageSave(pageID);
380
+ closeModal("schedule");
320
381
  }
321
382
  };
322
383
 
323
- const handleCancelSchedulePublication = async () => {
384
+ const handleCancelSchedulePublication = async (skipConflictCheck?: boolean) => {
385
+ if (blockedByConflict(() => handleCancelSchedulePublication(true), skipConflictCheck)) {
386
+ closeModal("cancelSchedule");
387
+ return;
388
+ }
389
+
324
390
  const saved = await schedulePublication(null, isDraft);
325
391
  if (saved) {
326
392
  setScheduleDate({ date: "", time: "12:00 am" });
327
393
  resetDirty();
328
- toggleModal("cancelSchedule");
394
+ broadcastPageSave(pageID);
395
+ closeModal("cancelSchedule");
329
396
  }
330
397
  };
331
398
 
@@ -433,16 +500,38 @@ const GlobalEditor = (props: IProps) => {
433
500
  const isEditable = editorContent?.editable;
434
501
  const isRestricted = !!(accessGrants.users.length || accessGrants.roles.length);
435
502
 
436
- const handleSavePage = async (setToast?: (msg: string) => void) => {
503
+ const handleSavePageCommon = async (
504
+ saveOptions: SavePageOptions,
505
+ onSuccess?: () => void,
506
+ skipConflictCheck?: boolean,
507
+ recursiveCall: () => Promise<void> = () => Promise.resolve(),
508
+ ) => {
509
+ if (blockedByConflict(recursiveCall, skipConflictCheck)) {
510
+ return;
511
+ }
512
+
437
513
  const { savePage } = props;
438
514
 
439
- const isSaved = await savePage({ setToast });
515
+ const isSaved = await savePage(saveOptions);
440
516
  if (isSaved) {
441
517
  resetDirty();
442
- setToastMsg(null);
518
+ clearRemoteUpdate();
519
+ if (pageID) {
520
+ broadcastPageSave(pageID);
521
+ }
522
+ onSuccess?.();
443
523
  }
444
524
  };
445
525
 
526
+ const handleSavePage = async (setToast?: (msg: string) => void, skipConflictCheck?: boolean) => {
527
+ await handleSavePageCommon(
528
+ { setToast },
529
+ () => setToastMsg(null),
530
+ skipConflictCheck,
531
+ () => handleSavePage(setToast, true),
532
+ );
533
+ };
534
+
446
535
  const goToPages = (path: string) => setRoute(path);
447
536
 
448
537
  const goToError = async (editorID: number | null, tab: string, template: boolean, parentEditorID?: number) => {
@@ -523,6 +612,12 @@ const GlobalEditor = (props: IProps) => {
523
612
  setPendingNavigateCallback(null);
524
613
  };
525
614
 
615
+ const handleConfirmConflictSave = async () => {
616
+ toggleModal("conflictSave");
617
+ await pendingConflictAction?.();
618
+ setPendingConflictAction(null);
619
+ };
620
+
526
621
  let pageStatusActions =
527
622
  props.pageStatus === pageStatus.PUBLISHED || isDraft
528
623
  ? [
@@ -549,10 +644,8 @@ const GlobalEditor = (props: IProps) => {
549
644
  const goBackToSite = () => {
550
645
  if (sitePageID) {
551
646
  setCurrentSiteInfo(savedSiteInfo);
552
- setCurrentPageID(sitePageID);
553
647
  getUserCurrentPermissions();
554
- const path = "/sites/pages/editor";
555
- setHistoryPush(path, true);
648
+ setHistoryPush(`/sites/pages/editor/${sitePageID}`, true);
556
649
  }
557
650
  };
558
651
 
@@ -656,7 +749,9 @@ const GlobalEditor = (props: IProps) => {
656
749
  }
657
750
  };
658
751
 
659
- return isLoading ? (
752
+ const isContentReady = !!(editorContent as any)?.template;
753
+
754
+ return isLoading || !isContentReady ? (
660
755
  <Loading />
661
756
  ) : (
662
757
  <>
@@ -687,6 +782,22 @@ const GlobalEditor = (props: IProps) => {
687
782
  >
688
783
  {selectedTab === "edit" ? (
689
784
  <>
785
+ {showPageUpdateNotification && (
786
+ <S.NotificationWrapper data-testid="page-update-notification">
787
+ <Notification
788
+ type="info"
789
+ text="This page was edited in another tab and you're viewing an older version. Reload to see the latest changes. Any unsaved edits here will be lost."
790
+ btnText="Reload Page"
791
+ onClick={() => {
792
+ window.location.reload();
793
+ }}
794
+ resetError={() => {
795
+ setShowPageUpdateNotification(false);
796
+ clearRemoteUpdate();
797
+ }}
798
+ />
799
+ </S.NotificationWrapper>
800
+ )}
690
801
  {isLivePageChanged && (
691
802
  <S.NotificationWrapper>
692
803
  <Notification type="warning" text={modifiedNotificationText} />
@@ -844,6 +955,11 @@ const GlobalEditor = (props: IProps) => {
844
955
  you want to discard your changes?
845
956
  </S.ModalContent>
846
957
  </Modal>
958
+ <ConflictSaveModal
959
+ isOpen={isOpen("conflictSave")}
960
+ toggleModal={() => toggleModal("conflictSave")}
961
+ onSaveAnyway={handleConfirmConflictSave}
962
+ />
847
963
  </MainWrapper>
848
964
  </>
849
965
  );
@@ -917,14 +1033,16 @@ const mapDispatchToProps = {
917
1033
  updatePageAccessGrants: pageEditorActions.updatePageAccessGrants,
918
1034
  };
919
1035
 
1036
+ type SavePageOptions = {
1037
+ createDraft?: boolean;
1038
+ publishPage?: { status: string };
1039
+ publishDraft?: boolean;
1040
+ setToast?: (msg: string) => void;
1041
+ };
1042
+
920
1043
  interface IPageEditorDispatchProps {
921
1044
  getPage(pageID?: number, global?: boolean, preferDraft?: boolean): Promise<void>;
922
- savePage(data?: {
923
- createDraft?: boolean;
924
- publishPage?: { status: string };
925
- publishDraft?: boolean;
926
- setToast?: (msg: string) => void;
927
- }): Promise<boolean>;
1045
+ savePage(data?: SavePageOptions): Promise<boolean>;
928
1046
  deletePage(params?: ISavePageParams): Promise<boolean>;
929
1047
  validatePage(publish?: boolean): Promise<boolean>;
930
1048
  updatePageStatus(id: number[], status: string): Promise<boolean>;
@@ -950,7 +1068,7 @@ interface IPageEditorDispatchProps {
950
1068
  }): Promise<boolean>;
951
1069
  }
952
1070
 
953
- type IProps = IPageEditorStateProps & IPageEditorDispatchProps & RouteComponentProps;
1071
+ type IProps = IPageEditorStateProps & IPageEditorDispatchProps & RouteComponentProps<{ pageID?: string }>;
954
1072
 
955
1073
  const GlobalEditorWithErrorBoundary = withErrorBoundary(GlobalEditor, {
956
1074
  fallback: <ErrorPage type="wrongEditor" />,
@@ -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
 
@@ -76,7 +116,7 @@ const Robots = (props: IProps): JSX.Element => {
76
116
  return (
77
117
  <>
78
118
  <RouteLeavingGuard when={isDirty} action={setRoute} text={modalText} />
79
- <MainWrapper backLink={false} title="SEO Settings" rightButton={rightButtonProps}>
119
+ <MainWrapper backLink={false} title="SEO Settings" rightButton={rightButtonProps} screen="robots">
80
120
  <S.Wrapper>
81
121
  <Nav current={currentNavItem} items={navItems} onClick={handleMenuClick} />
82
122
  <S.ContentWrapper>
@@ -1,11 +1,9 @@
1
1
  import React, { useEffect, useState } from "react";
2
2
  import { connect } from "react-redux";
3
3
  import { appActions } from "@ax/containers/App";
4
- import { pageEditorActions } from "@ax/containers/PageEditor";
5
4
  import { sitesActions } from "@ax/containers/Sites";
6
5
  import { structuredDataActions } from "@ax/containers/StructuredData";
7
6
  import { usersActions } from "@ax/containers/Users";
8
- import type { ISetCurrentPageIDAction } from "@ax/containers/PageEditor/interfaces";
9
7
  import type { IImage, IRootState, ISite, IUser } from "@ax/types";
10
8
  import { images } from "@ax/api";
11
9
  import { isReqOk } from "@ax/helpers";
@@ -23,7 +21,6 @@ const UsageContent = (props: IProps) => {
23
21
  selectedTab,
24
22
  currentUser,
25
23
  setHistoryPush,
26
- setCurrentPageID,
27
24
  getSite,
28
25
  setCurrentDataID,
29
26
  setSelectedStructuredData,
@@ -71,8 +68,7 @@ const UsageContent = (props: IProps) => {
71
68
  const data = items.sites.find((site) => site.id === currentSiteID)?.simpleContent || [];
72
69
  const pageItems = pages.map((item: IImageUseItem) => {
73
70
  const handleClick = () => {
74
- setCurrentPageID(item.id);
75
- setHistoryPush("/sites/pages/editor", true);
71
+ setHistoryPush(`/sites/pages/editor/${item.id}`, true);
76
72
  };
77
73
 
78
74
  return <Item title={item.title} type="Page" date={item.date} key={item.id} onClick={handleClick} />;
@@ -84,7 +80,7 @@ const UsageContent = (props: IProps) => {
84
80
  setSelectedStructuredData(item.structuredData.id, "site");
85
81
  setCurrentDataID(item.id);
86
82
  await getDataContent(item.id);
87
- setHistoryPush(`/sites/data/${item.structuredData.id}/editor`);
83
+ setHistoryPush(`/sites/data/${item.structuredData.id}/content`);
88
84
  };
89
85
 
90
86
  return (
@@ -104,13 +100,12 @@ const UsageContent = (props: IProps) => {
104
100
  const handleClick = async (page: IImageUseItem) => {
105
101
  await getSite(item.id);
106
102
  if (!page.structuredData) {
107
- setCurrentPageID(page.id);
108
- setHistoryPush("/sites/pages/editor", true);
103
+ setHistoryPush(`/sites/pages/editor/${page.id}`, true);
109
104
  } else {
110
105
  setSelectedStructuredData(page.structuredData.id, "site");
111
106
  setCurrentDataID(page.id);
112
107
  await getDataContent(page.id);
113
- setHistoryPush(`/sites/data/${page.structuredData.id}/editor`);
108
+ setHistoryPush(`/sites/data/${page.structuredData.id}/content`);
114
109
  }
115
110
  };
116
111
  return (
@@ -132,13 +127,11 @@ const UsageContent = (props: IProps) => {
132
127
  }
133
128
  const hasGlobal = currentUser?.isSuperAdmin || !!currentUser?.roles.find((rol) => rol.siteId === "global");
134
129
  const handleClick = (item: IImageUseItem) => {
135
- setCurrentPageID(item.id);
136
130
  if (isSiteView) {
137
131
  setCurrentSiteInfo(null);
138
132
  getUserCurrentPermissions();
139
133
  }
140
- const path = "/data/pages/editor";
141
- setHistoryPush(path, true);
134
+ setHistoryPush(`/data/pages/editor/${item.id}`, true);
142
135
  };
143
136
  return <ItemGroup title="Global Pages" items={items.globalPages} onClick={handleClick} disabled={!hasGlobal} />;
144
137
  };
@@ -157,7 +150,7 @@ const UsageContent = (props: IProps) => {
157
150
  setCurrentSiteInfo(null);
158
151
  getUserCurrentPermissions();
159
152
  }
160
- setHistoryPush(`/data/${item.structuredData.id}/editor`);
153
+ setHistoryPush(`/data/${item.structuredData.id}/content`);
161
154
  };
162
155
  return (
163
156
  <ItemGroup title="Global Simple Content" items={items.simpleData} onClick={handleClick} disabled={!hasGlobal} />
@@ -196,7 +189,6 @@ interface IProps {
196
189
  selectedTab: "site" | "global";
197
190
  currentUser: IUser | null;
198
191
  setHistoryPush(page: string, isEditor?: boolean): Promise<void>;
199
- setCurrentPageID(currentPageID: number | null): ISetCurrentPageIDAction;
200
192
  getSite(siteID: number): Promise<void>;
201
193
  setCurrentDataID(id: number | null): void;
202
194
  setSelectedStructuredData(id: string, scope: string): void;
@@ -220,7 +212,6 @@ export interface IImageUseItem {
220
212
 
221
213
  const mapDispatchToProps = {
222
214
  setHistoryPush: appActions.setHistoryPush,
223
- setCurrentPageID: pageEditorActions.setCurrentPageID,
224
215
  getSite: sitesActions.getSite,
225
216
  setCurrentSiteInfo: sitesActions.setCurrentSiteInfo,
226
217
  setCurrentDataID: structuredDataActions.setCurrentDataID,
@@ -623,7 +623,7 @@ const MediaGallery = (props: IProps) => {
623
623
  );
624
624
 
625
625
  return (
626
- <MainWrapper backLink={false} title="Media Gallery" rightButton={rightButtonProps}>
626
+ <MainWrapper backLink={false} title="Media Gallery" rightButton={rightButtonProps} screen="gallery">
627
627
  <S.Wrapper ref={wrapperRef}>
628
628
  <S.FolderPanel
629
629
  isOpen={isPanelOpen}
@@ -154,6 +154,40 @@ interface IUserEditingModal {
154
154
  onBack: () => void;
155
155
  }
156
156
 
157
+ const ConflictSaveModal = (props: IConflictSaveModal): JSX.Element => {
158
+ const { isOpen, toggleModal, onSaveAnyway } = props;
159
+
160
+ const mainAction = {
161
+ title: "Continue anyway",
162
+ onClick: onSaveAnyway,
163
+ };
164
+
165
+ const secondaryAction = { title: "Cancel", onClick: toggleModal };
166
+
167
+ return (
168
+ <Modal
169
+ isOpen={isOpen}
170
+ hide={toggleModal}
171
+ size="S"
172
+ height="auto"
173
+ title="Continue with an outdated version?"
174
+ mainAction={mainAction}
175
+ secondaryAction={secondaryAction}
176
+ >
177
+ <S.ModalContent>
178
+ <p>
179
+ This page <strong>was updated in another tab</strong> after you opened it, so what you see here{" "}
180
+ <strong>is out of date</strong>. Reload to get the latest version first.
181
+ </p>
182
+ </S.ModalContent>
183
+ </Modal>
184
+ );
185
+ };
186
+
187
+ interface IConflictSaveModal extends IModal {
188
+ onSaveAnyway: () => void;
189
+ }
190
+
157
191
  const UnpublishModal = (props: IUnpublishModal): JSX.Element => {
158
192
  const { isOpen, toggleModal } = props;
159
193
 
@@ -181,4 +215,4 @@ interface IUnpublishModal {
181
215
  toggleModal: () => void;
182
216
  }
183
217
 
184
- export { UnpublishModal, UserEditingModal, DeleteModal, DirtyNavigateModal };
218
+ export { UnpublishModal, UserEditingModal, DeleteModal, DirtyNavigateModal, ConflictSaveModal };