@griddo/ax 12.7.0 → 12.8.0

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 (52) 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/api/utils.tsx +7 -6
  11. package/src/components/Browser/index.tsx +10 -2
  12. package/src/components/ConfigPanel/GlobalPageForm/index.tsx +3 -4
  13. package/src/components/Fields/Wysiwyg/atoms.tsx +1 -1
  14. package/src/components/Fields/Wysiwyg/index.tsx +28 -0
  15. package/src/components/Fields/Wysiwyg/style.tsx +41 -1
  16. package/src/components/MainWrapper/index.tsx +14 -2
  17. package/src/components/Nav/index.tsx +21 -6
  18. package/src/components/ResizePanel/index.tsx +8 -3
  19. package/src/components/Toast/index.tsx +4 -1
  20. package/src/containers/ActivityLog/actions.tsx +4 -7
  21. package/src/containers/PageEditor/actions.tsx +14 -3
  22. package/src/helpers/containerEvaluations.tsx +8 -0
  23. package/src/hooks/broadcast.ts +160 -0
  24. package/src/hooks/index.tsx +5 -0
  25. package/src/modules/ActivityLog/index.tsx +1 -0
  26. package/src/modules/Analytics/index.tsx +1 -1
  27. package/src/modules/App/index.tsx +21 -6
  28. package/src/modules/Content/PageItem/index.tsx +36 -19
  29. package/src/modules/Content/index.tsx +5 -7
  30. package/src/modules/FileDrive/FileModal/DetailPanel/UsageContent/index.tsx +5 -14
  31. package/src/modules/FileDrive/index.tsx +1 -1
  32. package/src/modules/Forms/FormUseModal/index.tsx +3 -8
  33. package/src/modules/GlobalEditor/atoms.tsx +35 -1
  34. package/src/modules/GlobalEditor/index.tsx +169 -51
  35. package/src/modules/GlobalSettings/Robots/index.tsx +1 -1
  36. package/src/modules/MediaGallery/ImageModal/DetailPanel/UsageContent/index.tsx +6 -15
  37. package/src/modules/MediaGallery/index.tsx +1 -1
  38. package/src/modules/PageEditor/atoms.tsx +35 -1
  39. package/src/modules/PageEditor/index.tsx +168 -48
  40. package/src/modules/Redirects/index.tsx +1 -0
  41. package/src/modules/Settings/Languages/index.tsx +1 -1
  42. package/src/modules/Settings/SeoAnalyticsSettings/Analytics/index.tsx +1 -0
  43. package/src/modules/Settings/Social/index.tsx +1 -1
  44. package/src/modules/StructuredData/Form/index.tsx +108 -10
  45. package/src/modules/StructuredData/StructuredDataList/GlobalPageItem/index.tsx +31 -11
  46. package/src/modules/StructuredData/StructuredDataList/StructuredDataItem/index.tsx +17 -3
  47. package/src/modules/StructuredData/StructuredDataList/index.tsx +6 -4
  48. package/src/modules/StructuredData/atoms.tsx +35 -1
  49. package/src/routes/multisite.tsx +34 -18
  50. package/src/routes/site.tsx +40 -21
  51. package/src/storeRegistry.ts +5 -0
  52. package/src/modules/StructuredData/index.tsx +0 -18
@@ -24,7 +24,15 @@ import { pageStatus } from "@ax/containers/PageEditor/interfaces";
24
24
  import { dataPacksActions } from "@ax/containers/Settings/DataPacks";
25
25
  import { RouteLeavingGuard } from "@ax/guards";
26
26
  import { dateToString, getDeactivatedModules, isModuleDisabled } from "@ax/helpers";
27
- import { useEditingHeartbeat, useIsDirty, useModals, usePermissionsForPage } from "@ax/hooks";
27
+ import {
28
+ useBroadcastClipboard,
29
+ useBroadcastContentUpdate,
30
+ useEditingHeartbeat,
31
+ useIsDirty,
32
+ useModals,
33
+ usePermissionsForPage,
34
+ } from "@ax/hooks";
35
+ import history from "@ax/routes/history";
28
36
  import type {
29
37
  HeadingFilter,
30
38
  IErrorItem,
@@ -38,7 +46,7 @@ import type {
38
46
  PageAccessGrantsSummary,
39
47
  } from "@ax/types";
40
48
 
41
- import { DeleteModal, DirtyNavigateModal, UnpublishModal, UserEditingModal } from "./atoms";
49
+ import { ConflictSaveModal, DeleteModal, DirtyNavigateModal, UnpublishModal, UserEditingModal } from "./atoms";
42
50
  import Editor from "./Editor";
43
51
  import Preview from "./Preview";
44
52
 
@@ -81,6 +89,7 @@ const PageEditor = (props: IProps) => {
81
89
  updatePageAccessGrants,
82
90
  accessGrants,
83
91
  sendPagePing,
92
+ match,
84
93
  } = props;
85
94
 
86
95
  const isAllowedTo = usePermissionsForPage(
@@ -111,9 +120,14 @@ const PageEditor = (props: IProps) => {
111
120
  const [errorPagesChecked, setErrorPagesChecked] = useState(false);
112
121
  const [headingsFilter, setHeadingsFilter] = useState<HeadingFilter>("all");
113
122
  const [keywordsFilter, setKeywordsFilter] = useState<string[]>([]);
123
+ const [showPageUpdateNotification, setShowPageUpdateNotification] = useState(false);
124
+ const [pendingConflictAction, setPendingConflictAction] = useState<(() => Promise<void>) | null>(null);
114
125
  const browserRef = useRef<HTMLDivElement>(null);
115
126
 
116
- const { isOpen, toggleModal } = useModals([
127
+ const { broadcastPageSave, remotePageUpdate, clearRemoteUpdate } = useBroadcastContentUpdate(pageID);
128
+ useBroadcastClipboard();
129
+
130
+ const { isOpen, toggleModal, closeModal } = useModals([
117
131
  "userEditing",
118
132
  "unpublish",
119
133
  "delete",
@@ -124,6 +138,7 @@ const PageEditor = (props: IProps) => {
124
138
  "keywordsPreview",
125
139
  "dirtyNavigate",
126
140
  "userAccess",
141
+ "conflictSave",
127
142
  ]);
128
143
 
129
144
  const { isDirty, setIsDirty, resetDirty } = useIsDirty(editorContent, isNewTranslation);
@@ -159,11 +174,15 @@ const PageEditor = (props: IProps) => {
159
174
 
160
175
  // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: fix this
161
176
  useEffect(() => {
177
+ const urlPageID = match?.params?.pageID ? parseInt(match.params.pageID, 10) : undefined;
178
+ if (urlPageID) setCurrentPageID(urlPageID);
179
+ const resolvedPageID = urlPageID ?? pageID;
180
+
162
181
  const handleGetPage = async () => {
163
182
  if (isNewTranslation) {
164
183
  await getDefaults();
165
184
  }
166
- await getPage(pageID);
185
+ await getPage(resolvedPageID);
167
186
  };
168
187
 
169
188
  setTab(defaultContentTab);
@@ -171,13 +190,24 @@ const PageEditor = (props: IProps) => {
171
190
  setToastMsg(null);
172
191
  handleGetPage();
173
192
 
174
- if (!pageID) {
193
+ if (!resolvedPageID) {
175
194
  setIsDirty(false);
176
195
  }
177
196
  }, []);
178
197
 
179
198
  useEditingHeartbeat(pageID, sendPagePing);
180
199
 
200
+ // sc-111850: the route carries the page id and the mount effect above seeds
201
+ // currentPageID from it, so a URL still pointing at the previous page is not cosmetic —
202
+ // it re-enters on the next mount and drags the editor back to that page. `replace` and
203
+ // not `push`: for the author this is the same screen, not a step to go back to.
204
+ useEffect(() => {
205
+ const urlPageID = match?.params?.pageID ? parseInt(match.params.pageID, 10) : undefined;
206
+ if (pageID && urlPageID && pageID !== urlPageID) {
207
+ history.replace(`/sites/pages/editor/${pageID}`);
208
+ }
209
+ }, [pageID, match]);
210
+
181
211
  useEffect(() => {
182
212
  setSelectedTab(defaultTab);
183
213
  }, [defaultTab]);
@@ -205,6 +235,12 @@ const PageEditor = (props: IProps) => {
205
235
  }
206
236
  }, [editorContent]);
207
237
 
238
+ useEffect(() => {
239
+ if (remotePageUpdate) {
240
+ setShowPageUpdateNotification(true);
241
+ }
242
+ }, [remotePageUpdate]);
243
+
208
244
  const setRoute = (path: string) => props.setHistoryPush(path, true);
209
245
 
210
246
  const removePage = async () => {
@@ -222,31 +258,52 @@ const PageEditor = (props: IProps) => {
222
258
  }
223
259
  };
224
260
 
225
- const publishPage = async () => {
226
- const { updatePageStatus, savePage, pageID, validatePage, skipReviewOnPublish } = props;
261
+ // A warned tab must ask before ANY write, not only before the ones that go through
262
+ // savePage. Two traps here: these actions are wired straight to onClick, so their first
263
+ // argument can be a click event and only an explicit `true` may count as "already
264
+ // confirmed"; and the retry is stored as-is, because `() => retry()` would make React
265
+ // read it as a state updater and run the write before the author decides.
266
+ const blockedByConflict = (retry: () => Promise<void>, skipConflictCheck?: boolean) => {
267
+ if (skipConflictCheck === true || !isDirty || !remotePageUpdate) {
268
+ return false;
269
+ }
270
+ setPendingConflictAction(() => retry);
271
+ toggleModal("conflictSave");
272
+ return true;
273
+ };
274
+
275
+ const publishPage = async (skipConflictCheck?: boolean) => {
276
+ const { updatePageStatus, pageID, validatePage, skipReviewOnPublish } = props;
227
277
 
228
278
  const validated = skipReviewOnPublish ? true : await validatePage(true);
229
279
 
230
280
  if (validated) {
231
- const publishPage = {
232
- status: pageStatus.UPLOAD_PENDING,
233
- };
234
-
235
- const isSaved =
236
- pageID && !isNewTranslation
237
- ? await updatePageStatus([pageID], pageStatus.UPLOAD_PENDING)
238
- : await savePage({ publishPage });
281
+ if (pageID && !isNewTranslation) {
282
+ if (blockedByConflict(() => publishPage(true), skipConflictCheck)) {
283
+ return;
284
+ }
285
+
286
+ const isSaved = await updatePageStatus([pageID], pageStatus.UPLOAD_PENDING);
287
+ if (isSaved) {
288
+ resetDirty();
289
+ broadcastPageSave(pageID);
290
+ }
291
+ } else {
292
+ const publishPageData = {
293
+ status: pageStatus.UPLOAD_PENDING,
294
+ };
239
295
 
240
- if (isSaved) {
241
- resetDirty();
296
+ await handleSavePageCommon({ publishPage: publishPageData }, undefined, skipConflictCheck, () =>
297
+ publishPage(true),
298
+ );
242
299
  }
243
300
  } else {
244
301
  setNotification({ text: errorNotificationText, type: "error" });
245
302
  }
246
303
  };
247
304
 
248
- const publishChanges = async () => {
249
- const { savePage, validatePage, skipReviewOnPublish } = props;
305
+ const publishChanges = async (skipConflictCheck?: boolean) => {
306
+ const { validatePage, skipReviewOnPublish } = props;
250
307
 
251
308
  const validated = skipReviewOnPublish ? true : await validatePage(true);
252
309
 
@@ -255,21 +312,23 @@ const PageEditor = (props: IProps) => {
255
312
  status: pageStatus.UPLOAD_PENDING,
256
313
  };
257
314
 
258
- const isSaved = await savePage({ publishPage });
259
- if (isSaved) {
260
- resetDirty();
261
- }
315
+ await handleSavePageCommon({ publishPage }, undefined, skipConflictCheck, () => publishChanges(true));
262
316
  } else {
263
317
  setNotification({ text: errorNotificationText, type: "error" });
264
318
  }
265
319
  };
266
320
 
267
- const unpublishPage = async () => {
321
+ const unpublishPage = async (skipConflictCheck?: boolean) => {
268
322
  const { updatePageStatus, pageID } = props;
269
323
 
324
+ if (blockedByConflict(() => unpublishPage(true), skipConflictCheck)) {
325
+ return;
326
+ }
327
+
270
328
  const isSaved = await updatePageStatus([pageID], pageStatus.OFFLINE_PENDING);
271
329
  if (isSaved) {
272
330
  resetDirty();
331
+ broadcastPageSave(pageID);
273
332
  }
274
333
  };
275
334
 
@@ -283,14 +342,13 @@ const PageEditor = (props: IProps) => {
283
342
  }
284
343
  };
285
344
 
286
- const handlePublishDraft = async () => {
287
- const { savePage, validatePage, skipReviewOnPublish } = props;
345
+ const handlePublishDraft = async (skipConflictCheck?: boolean) => {
346
+ const { validatePage, skipReviewOnPublish } = props;
288
347
 
289
348
  const validated = skipReviewOnPublish ? true : await validatePage(true);
290
349
 
291
350
  if (validated) {
292
- const isSaved = await savePage({ publishDraft: true });
293
- if (isSaved) resetDirty();
351
+ await handleSavePageCommon({ publishDraft: true }, undefined, skipConflictCheck, () => handlePublishDraft(true));
294
352
  } else {
295
353
  setNotification({ text: errorNotificationText, type: "error" });
296
354
  }
@@ -309,29 +367,38 @@ const PageEditor = (props: IProps) => {
309
367
  getPage(pageID);
310
368
  };
311
369
 
312
- const handleCreateDraft = async () => {
313
- const { savePage } = props;
314
-
315
- const isSaved = await savePage({ createDraft: true });
316
- if (isSaved) resetDirty();
370
+ const handleCreateDraft = async (skipConflictCheck?: boolean) => {
371
+ await handleSavePageCommon({ createDraft: true }, undefined, skipConflictCheck, () => handleCreateDraft(true));
317
372
  };
318
373
 
319
- const handleSchedulePublication = async () => {
374
+ const handleSchedulePublication = async (skipConflictCheck?: boolean) => {
375
+ if (blockedByConflict(() => handleSchedulePublication(true), skipConflictCheck)) {
376
+ closeModal("schedule");
377
+ return;
378
+ }
379
+
320
380
  const date = new Date(`${scheduleDate.date} ${scheduleDate.time}`);
321
381
  const dateString = dateToString(date, "dd/MM/yyyy HH:mm:ss");
322
382
  const saved = await schedulePublication(dateString, isDraft);
323
383
  if (saved) {
324
384
  resetDirty();
325
- toggleModal("schedule");
385
+ broadcastPageSave(pageID);
386
+ closeModal("schedule");
326
387
  }
327
388
  };
328
389
 
329
- const handleCancelSchedulePublication = async () => {
390
+ const handleCancelSchedulePublication = async (skipConflictCheck?: boolean) => {
391
+ if (blockedByConflict(() => handleCancelSchedulePublication(true), skipConflictCheck)) {
392
+ closeModal("cancelSchedule");
393
+ return;
394
+ }
395
+
330
396
  const saved = await schedulePublication(null, isDraft);
331
397
  if (saved) {
332
398
  setScheduleDate({ date: "", time: "12:00 am" });
333
399
  resetDirty();
334
- toggleModal("cancelSchedule");
400
+ broadcastPageSave(pageID);
401
+ closeModal("cancelSchedule");
335
402
  }
336
403
  };
337
404
 
@@ -438,16 +505,38 @@ const PageEditor = (props: IProps) => {
438
505
  options: !isGlobal ? menuOptions : [],
439
506
  };
440
507
 
441
- const handleSavePage = async (setToast?: (msg: string) => void) => {
508
+ const handleSavePageCommon = async (
509
+ saveOptions: SavePageOptions,
510
+ onSuccess?: () => void,
511
+ skipConflictCheck?: boolean,
512
+ recursiveCall: () => Promise<void> = () => Promise.resolve(),
513
+ ) => {
514
+ if (blockedByConflict(recursiveCall, skipConflictCheck)) {
515
+ return;
516
+ }
517
+
442
518
  const { savePage } = props;
443
519
 
444
- const isSaved = await savePage({ setToast });
520
+ const isSaved = await savePage(saveOptions);
445
521
  if (isSaved) {
446
522
  resetDirty();
447
- setToastMsg(null);
523
+ clearRemoteUpdate();
524
+ if (pageID) {
525
+ broadcastPageSave(pageID);
526
+ }
527
+ onSuccess?.();
448
528
  }
449
529
  };
450
530
 
531
+ const handleSavePage = async (setToast?: (msg: string) => void, skipConflictCheck?: boolean) => {
532
+ await handleSavePageCommon(
533
+ { setToast },
534
+ () => setToastMsg(null),
535
+ skipConflictCheck,
536
+ () => handleSavePage(setToast, true),
537
+ );
538
+ };
539
+
451
540
  const goToPages = (path: string) => setRoute(path);
452
541
 
453
542
  const goToError = async (editorID: number | null, tab: string, template: boolean, parentEditorID?: number) => {
@@ -693,7 +782,15 @@ const PageEditor = (props: IProps) => {
693
782
  setPendingNavigateCallback(null);
694
783
  };
695
784
 
696
- return isLoading ? (
785
+ const handleConfirmConflictSave = async () => {
786
+ toggleModal("conflictSave");
787
+ await pendingConflictAction?.();
788
+ setPendingConflictAction(null);
789
+ };
790
+
791
+ const isContentReady = !!(editorContent as any)?.template;
792
+
793
+ return isLoading || !isContentReady ? (
697
794
  <Loading />
698
795
  ) : (
699
796
  <>
@@ -723,6 +820,22 @@ const PageEditor = (props: IProps) => {
723
820
  >
724
821
  {selectedTab === "edit" ? (
725
822
  <>
823
+ {showPageUpdateNotification && (
824
+ <S.NotificationWrapper data-testid="page-update-notification">
825
+ <Notification
826
+ type="info"
827
+ 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."
828
+ btnText="Reload Page"
829
+ onClick={() => {
830
+ window.location.reload();
831
+ }}
832
+ resetError={() => {
833
+ setShowPageUpdateNotification(false);
834
+ clearRemoteUpdate();
835
+ }}
836
+ />
837
+ </S.NotificationWrapper>
838
+ )}
726
839
  {(!isTemplateActivated || hasDeactivatedModules) && !isGlobal && (
727
840
  <S.NotificationWrapper>
728
841
  <Notification
@@ -855,6 +968,11 @@ const PageEditor = (props: IProps) => {
855
968
  onUpdateAccessGrants={handleUpdateAccessGrants}
856
969
  isSaving={isSaving}
857
970
  />
971
+ <ConflictSaveModal
972
+ isOpen={isOpen("conflictSave")}
973
+ toggleModal={() => toggleModal("conflictSave")}
974
+ onSaveAnyway={handleConfirmConflictSave}
975
+ />
858
976
  </MainWrapper>
859
977
  </>
860
978
  );
@@ -935,14 +1053,16 @@ const mapDispatchToProps = {
935
1053
  updatePageAccessGrants: pageEditorActions.updatePageAccessGrants,
936
1054
  };
937
1055
 
1056
+ type SavePageOptions = {
1057
+ createDraft?: boolean;
1058
+ publishPage?: { status: string };
1059
+ publishDraft?: boolean;
1060
+ setToast?: (msg: string) => void;
1061
+ };
1062
+
938
1063
  interface IPageEditorDispatchProps {
939
1064
  getPage(pageID?: number, global?: boolean, preferDraft?: boolean): Promise<void>;
940
- savePage(data?: {
941
- createDraft?: boolean;
942
- publishPage?: { status: string };
943
- publishDraft?: boolean;
944
- setToast?: (msg: string) => void;
945
- }): Promise<boolean>;
1065
+ savePage(data?: SavePageOptions): Promise<boolean>;
946
1066
  deletePage(params?: ISavePageParams): Promise<boolean>;
947
1067
  validatePage(publish?: boolean): Promise<boolean>;
948
1068
  updatePageStatus(id: number[], status: string): Promise<boolean>;
@@ -969,7 +1089,7 @@ interface IPageEditorDispatchProps {
969
1089
  }): Promise<boolean>;
970
1090
  }
971
1091
 
972
- type IProps = IPageEditorStateProps & IPageEditorDispatchProps & RouteComponentProps;
1092
+ type IProps = IPageEditorStateProps & IPageEditorDispatchProps & RouteComponentProps<{ pageID?: string }>;
973
1093
 
974
1094
  const PageEditorWithErrorBoundary = withErrorBoundary(PageEditor, { fallback: <ErrorPage type="wrongEditor" /> });
975
1095
 
@@ -253,6 +253,7 @@ const Redirects = (props: IProps): JSX.Element => {
253
253
  <MainWrapper
254
254
  backLink={false}
255
255
  title="SEO Settings"
256
+ screen="redirects"
256
257
  rightButton={rightButtonProps}
257
258
  rightLineButton={rightLineButtonProps}
258
259
  searchAction={handleSearch}
@@ -24,7 +24,7 @@ const Languages = (props: IProps): JSX.Element => {
24
24
  };
25
25
 
26
26
  return (
27
- <MainWrapper title={`Languages`}>
27
+ <MainWrapper title={`Languages`} screen="languages">
28
28
  <S.Wrapper>
29
29
  <Nav current={currentNavItem} items={navItems} onClick={handleMenuClick} />
30
30
  <S.ContentWrapper>
@@ -106,6 +106,7 @@ const Analytics = (props: IProps): JSX.Element => {
106
106
  <MainWrapper
107
107
  backLink={false}
108
108
  title="Analytics Settings"
109
+ screen="analytics-site"
109
110
  rightButton={rightButtonProps}
110
111
  rightLineButton={secondaryButtonProps}
111
112
  >
@@ -122,7 +122,7 @@ const Social = (props: IProps): JSX.Element => {
122
122
  return (
123
123
  <>
124
124
  <RouteLeavingGuard when={isDirty} action={setRoute} text={modalText} />
125
- <MainWrapper backLink={false} title="Social" rightButton={rightButtonProps}>
125
+ <MainWrapper backLink={false} title="Social" rightButton={rightButtonProps} screen="social">
126
126
  <S.Wrapper>
127
127
  <Nav current={currentNavItem} items={navItems} onClick={handleMenuClick} />
128
128
  <S.ContentWrapper>
@@ -16,7 +16,7 @@ import { dataPacksActions } from "@ax/containers/Settings/DataPacks";
16
16
  import { structuredDataActions } from "@ax/containers/StructuredData";
17
17
  import { RouteLeavingGuard } from "@ax/guards";
18
18
  import { dateToString, getActivatedDataPacksIds, getDefaultTheme } from "@ax/helpers";
19
- import { useIsDirty, useModals, usePermissionsForPage } from "@ax/hooks";
19
+ import { useBroadcastContentUpdate, useIsDirty, useModals, usePermissionsForPage } from "@ax/hooks";
20
20
  import type {
21
21
  IDataLanguage,
22
22
  IDataPack,
@@ -32,7 +32,7 @@ import type {
32
32
  PageAccessGrantsSummary,
33
33
  } from "@ax/types";
34
34
 
35
- import { DeleteModal } from "../atoms";
35
+ import { ConflictSaveModal, DeleteModal } from "../atoms";
36
36
  import { getPermission } from "../utils";
37
37
  import ConnectedField from "./ConnectedField";
38
38
 
@@ -86,15 +86,31 @@ const Form = (props: IProps) => {
86
86
  form?.dataLanguages,
87
87
  );
88
88
 
89
- const [isNewStructuredData, setIsNewStructuredData] = useState(!currentStructuredDataId);
89
+ const urlItemId = parseInt((props as any).match?.params?.contentId ?? "", 10);
90
+ const hasUrlItemId = !Number.isNaN(urlItemId);
91
+
92
+ const [isNewStructuredData, setIsNewStructuredData] = useState(!currentStructuredDataId && !hasUrlItemId);
93
+ const [isItemLoading, setIsItemLoading] = useState(hasUrlItemId);
90
94
  const [notification, setNotification] = useState<INotification | null>(null);
91
95
  const [scheduleDate, setScheduleDate] = useState({
92
96
  date: dateToString(new Date(), "yyy/MM/dd"),
93
97
  time: "12:00 am",
94
98
  });
95
- const { isOpen, toggleModal } = useModals(["schedule", "cancelSchedule", "restore", "userAccess", "delete"]);
99
+ const { isOpen, toggleModal } = useModals([
100
+ "schedule",
101
+ "cancelSchedule",
102
+ "restore",
103
+ "userAccess",
104
+ "delete",
105
+ "conflictSave",
106
+ ]);
107
+
108
+ const [showRemoteUpdateNotification, setShowRemoteUpdateNotification] = useState(false);
109
+
110
+ const { broadcastPageSave, remotePageUpdate, clearRemoteUpdate } = useBroadcastContentUpdate(currentStructuredDataId);
96
111
  const [isDeleting, setIsDeleting] = useState(false);
97
112
  const [deleteAllVersions, setDeleteAllVersions] = useState(false);
113
+ const [pendingConflictAction, setPendingConflictAction] = useState<(() => Promise<void>) | null>(null);
98
114
  const { isDirty, resetDirty, setIsDirty } = useIsDirty(form);
99
115
  const path = site ? "/sites/pages" : "/data";
100
116
 
@@ -139,13 +155,29 @@ const Form = (props: IProps) => {
139
155
 
140
156
  const theme = getDefaultTheme();
141
157
 
158
+ // Load item from URL param when opening via Ctrl+Click (new tab)
159
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentional one-time load
160
+ useEffect(() => {
161
+ if (hasUrlItemId) {
162
+ setCurrentDataID(urlItemId);
163
+ setIsNewStructuredData(false);
164
+ getDataContent(urlItemId).finally(() => setIsItemLoading(false));
165
+ }
166
+ }, []);
167
+
142
168
  // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: fix this
143
169
  useEffect(() => {
144
- if (!currentStructuredDataId) {
170
+ if (!currentStructuredDataId && !hasUrlItemId) {
145
171
  setIsDirty(true);
146
172
  }
147
173
  }, []);
148
174
 
175
+ useEffect(() => {
176
+ if (remotePageUpdate) {
177
+ setShowRemoteUpdateNotification(true);
178
+ }
179
+ }, [remotePageUpdate]);
180
+
149
181
  const Fields = fieldsTranslate
150
182
  ?.filter((field: ISchemaField) => !field.hidden)
151
183
  .map((field: ISchemaField, i: number) => {
@@ -166,7 +198,29 @@ const Form = (props: IProps) => {
166
198
  );
167
199
  });
168
200
 
169
- const handleSave = async (publish: boolean, scheduleDate?: string | null, formToSave?: any) => {
201
+ // Mismo guard que en los editores de página: solo un `true` explícito cuenta como
202
+ // confirmado (estas acciones cuelgan de un onClick y reciben el evento), y el reintento
203
+ // se guarda tal cual, porque `() => retry()` lo convertiría en un updater de React y
204
+ // ejecutaría la escritura antes de que el autor decida.
205
+ const blockedByConflict = (retry: () => Promise<void>, skipConflictCheck?: boolean) => {
206
+ if (skipConflictCheck === true || !isDirty || !remotePageUpdate) {
207
+ return false;
208
+ }
209
+ setPendingConflictAction(() => retry);
210
+ toggleModal("conflictSave");
211
+ return true;
212
+ };
213
+
214
+ const handleSave = async (
215
+ publish: boolean,
216
+ scheduleDate?: string | null,
217
+ formToSave?: any,
218
+ skipConflictCheck?: boolean,
219
+ ) => {
220
+ if (blockedByConflict(() => handleSave(publish, scheduleDate, formToSave, true), skipConflictCheck)) {
221
+ return;
222
+ }
223
+
170
224
  const currentForm = formToSave || form;
171
225
  const validated = publish && !skipReviewOnPublish ? await validateForm(true) : true;
172
226
 
@@ -188,7 +242,13 @@ const Form = (props: IProps) => {
188
242
  } else {
189
243
  saved = await updateStructuredDataContent(payload);
190
244
  }
191
- saved && resetDirty();
245
+ if (saved) {
246
+ resetDirty();
247
+ clearRemoteUpdate();
248
+ if (currentStructuredDataId) {
249
+ broadcastPageSave(currentStructuredDataId);
250
+ }
251
+ }
192
252
  }
193
253
  };
194
254
 
@@ -248,15 +308,20 @@ const Form = (props: IProps) => {
248
308
  }
249
309
  };
250
310
 
251
- const publishItem = async () => {
311
+ const publishItem = async (skipConflictCheck?: boolean) => {
252
312
  if (!form) return;
253
313
  const validated = skipReviewOnPublish ? true : await validateForm(true);
254
314
  if (!validated) return;
255
315
 
256
316
  if (!isNewStructuredData) {
317
+ if (blockedByConflict(() => publishItem(true), skipConflictCheck)) {
318
+ return;
319
+ }
320
+
257
321
  await setDataStatus(form.id, "undraft").then(async (updated: boolean) => {
258
322
  if (updated) {
259
323
  await getDataContent(form.id);
324
+ broadcastPageSave(form.id);
260
325
  }
261
326
  });
262
327
  } else {
@@ -264,11 +329,17 @@ const Form = (props: IProps) => {
264
329
  }
265
330
  };
266
331
 
267
- const unpublishItem = async () => {
332
+ const unpublishItem = async (skipConflictCheck?: boolean) => {
268
333
  if (!form) return;
334
+
335
+ if (blockedByConflict(() => unpublishItem(true), skipConflictCheck)) {
336
+ return;
337
+ }
338
+
269
339
  await setDataStatus(form.id, "draft").then(async (updated: boolean) => {
270
340
  if (updated) {
271
341
  await getDataContent(form.id);
342
+ broadcastPageSave(form.id);
272
343
  }
273
344
  });
274
345
  };
@@ -387,7 +458,13 @@ const Form = (props: IProps) => {
387
458
  toggleModal("userAccess");
388
459
  };
389
460
 
390
- return isLoading && !isDeleting ? (
461
+ const handleConfirmConflictSave = async () => {
462
+ toggleModal("conflictSave");
463
+ await pendingConflictAction?.();
464
+ setPendingConflictAction(null);
465
+ };
466
+
467
+ return (isLoading && !isDeleting) || isItemLoading ? (
391
468
  <Loading />
392
469
  ) : (
393
470
  <>
@@ -410,6 +487,22 @@ const Form = (props: IProps) => {
410
487
  scheduledPublication={form?.publicationScheduled}
411
488
  isSaving={isSaving}
412
489
  >
490
+ {showRemoteUpdateNotification && (
491
+ <S.NotificationWrapper data-testid="page-update-notification">
492
+ <Notification
493
+ type="info"
494
+ text="This content 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."
495
+ btnText="Reload Content"
496
+ onClick={() => {
497
+ window.location.reload();
498
+ }}
499
+ resetError={() => {
500
+ setShowRemoteUpdateNotification(false);
501
+ clearRemoteUpdate();
502
+ }}
503
+ />
504
+ </S.NotificationWrapper>
505
+ )}
413
506
  {disabledDatapack && (
414
507
  <S.NotificationWrapper>
415
508
  <Notification
@@ -492,6 +585,11 @@ const Form = (props: IProps) => {
492
585
  onUpdateAccessGrants={handleUpdateAccessGrants}
493
586
  isSaving={isSaving}
494
587
  />
588
+ <ConflictSaveModal
589
+ isOpen={isOpen("conflictSave")}
590
+ toggleModal={() => toggleModal("conflictSave")}
591
+ onSaveAnyway={handleConfirmConflictSave}
592
+ />
495
593
  </>
496
594
  );
497
595
  };