@griddo/ax 12.7.1 → 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.
- package/package.json +2 -2
- package/src/GlobalStore.tsx +3 -1
- package/src/__tests__/components/ConfigPanel/GlobalPageForm/GlobalPageForm.test.tsx +7 -3
- package/src/__tests__/components/Fields/Wysiwyg/Wysiwyg.style.test.tsx +44 -0
- package/src/__tests__/components/Fields/Wysiwyg/Wysiwyg.test.tsx +109 -0
- package/src/__tests__/components/Nav/Nav.test.tsx +75 -2
- package/src/__tests__/components/ResizePanel/ResizePanel.test.tsx +50 -21
- package/src/__tests__/components/Toast/Toast.test.tsx +37 -1
- package/src/__tests__/hooks/broadcast.test.tsx +263 -0
- package/src/api/utils.tsx +7 -6
- package/src/components/Browser/index.tsx +10 -2
- package/src/components/ConfigPanel/GlobalPageForm/index.tsx +3 -4
- package/src/components/Fields/Wysiwyg/atoms.tsx +1 -1
- package/src/components/Fields/Wysiwyg/index.tsx +28 -0
- package/src/components/Fields/Wysiwyg/style.tsx +41 -1
- package/src/components/MainWrapper/index.tsx +14 -2
- package/src/components/Nav/index.tsx +21 -6
- package/src/components/ResizePanel/index.tsx +8 -3
- package/src/components/Toast/index.tsx +4 -1
- package/src/containers/ActivityLog/actions.tsx +4 -7
- package/src/containers/PageEditor/actions.tsx +14 -3
- package/src/helpers/containerEvaluations.tsx +8 -0
- package/src/hooks/broadcast.ts +160 -0
- package/src/hooks/index.tsx +5 -0
- package/src/modules/ActivityLog/index.tsx +1 -0
- package/src/modules/Analytics/index.tsx +1 -1
- package/src/modules/App/index.tsx +21 -6
- package/src/modules/Content/PageItem/index.tsx +36 -19
- package/src/modules/Content/index.tsx +5 -7
- package/src/modules/FileDrive/FileModal/DetailPanel/UsageContent/index.tsx +5 -14
- package/src/modules/FileDrive/index.tsx +1 -1
- package/src/modules/Forms/FormUseModal/index.tsx +3 -8
- package/src/modules/GlobalEditor/atoms.tsx +35 -1
- package/src/modules/GlobalEditor/index.tsx +169 -51
- package/src/modules/GlobalSettings/Robots/index.tsx +1 -1
- package/src/modules/MediaGallery/ImageModal/DetailPanel/UsageContent/index.tsx +6 -15
- package/src/modules/MediaGallery/index.tsx +1 -1
- package/src/modules/PageEditor/atoms.tsx +35 -1
- package/src/modules/PageEditor/index.tsx +168 -48
- package/src/modules/Redirects/index.tsx +1 -0
- package/src/modules/Settings/Languages/index.tsx +1 -1
- package/src/modules/Settings/SeoAnalyticsSettings/Analytics/index.tsx +1 -0
- package/src/modules/Settings/Social/index.tsx +1 -1
- package/src/modules/StructuredData/Form/index.tsx +108 -10
- package/src/modules/StructuredData/StructuredDataList/GlobalPageItem/index.tsx +31 -11
- package/src/modules/StructuredData/StructuredDataList/StructuredDataItem/index.tsx +17 -3
- package/src/modules/StructuredData/StructuredDataList/index.tsx +6 -4
- package/src/modules/StructuredData/atoms.tsx +35 -1
- package/src/routes/multisite.tsx +34 -18
- package/src/routes/site.tsx +40 -21
- package/src/storeRegistry.ts +5 -0
- 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 {
|
|
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 {
|
|
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(
|
|
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 (!
|
|
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
|
-
|
|
226
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
pageID
|
|
237
|
-
|
|
238
|
-
|
|
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
|
-
|
|
241
|
-
|
|
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 {
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
520
|
+
const isSaved = await savePage(saveOptions);
|
|
445
521
|
if (isSaved) {
|
|
446
522
|
resetDirty();
|
|
447
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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>
|
|
@@ -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
|
|
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([
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|