@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.
- 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/__tests__/modules/GlobalSettings/Robots/Robots.uc.test.tsx +401 -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 +52 -12
- 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
|
@@ -81,6 +81,14 @@ const Browser = (props: IBrowserProps): JSX.Element => {
|
|
|
81
81
|
|
|
82
82
|
useOnMessageReceivedFromIframe(actions);
|
|
83
83
|
|
|
84
|
+
const handleIframeLoad = (e: React.SyntheticEvent<HTMLIFrameElement>) => {
|
|
85
|
+
setIsIframeLoading(false);
|
|
86
|
+
const iframeWindow = (e.target as HTMLIFrameElement)?.contentWindow;
|
|
87
|
+
if (iframeWindow && (content as any)?.template) {
|
|
88
|
+
iframeWindow.postMessage({ type: "content-update", message: content }, "*");
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
84
92
|
useEffect(() => {
|
|
85
93
|
localStorage.setItem("selectedID", "0");
|
|
86
94
|
(window as any).browserRef = null;
|
|
@@ -326,7 +334,7 @@ const Browser = (props: IBrowserProps): JSX.Element => {
|
|
|
326
334
|
src={urlPreview}
|
|
327
335
|
loading="lazy"
|
|
328
336
|
className="frame-content"
|
|
329
|
-
onLoad={
|
|
337
|
+
onLoad={handleIframeLoad}
|
|
330
338
|
style={{ visibility: isIframeLoading ? "hidden" : "visible" }}
|
|
331
339
|
/>
|
|
332
340
|
) : (
|
|
@@ -344,7 +352,7 @@ const Browser = (props: IBrowserProps): JSX.Element => {
|
|
|
344
352
|
src={urlPreview}
|
|
345
353
|
loading="lazy"
|
|
346
354
|
className="frame-content"
|
|
347
|
-
onLoad={
|
|
355
|
+
onLoad={handleIframeLoad}
|
|
348
356
|
style={{
|
|
349
357
|
display: "block",
|
|
350
358
|
transform: isZoomEditor ? `scale(${parseInt(dimensions.zoom) / 100})` : "scale(1)",
|
|
@@ -34,10 +34,9 @@ const GlobalPageForm = (props: IGlobalPageFormProps): JSX.Element => {
|
|
|
34
34
|
const handleClick = async () => {
|
|
35
35
|
if (isAllowedToEditGlobalData) {
|
|
36
36
|
const navigate = async () => {
|
|
37
|
-
await actions.getGlobalFromLocalPageAction();
|
|
38
|
-
const path = "/data/pages/editor";
|
|
39
|
-
setHistoryPush?.(path, true);
|
|
37
|
+
const pageID = await actions.getGlobalFromLocalPageAction();
|
|
40
38
|
actions.saveCurrentSiteInfoAction();
|
|
39
|
+
setHistoryPush?.(`/data/pages/editor/${pageID}`, true);
|
|
41
40
|
};
|
|
42
41
|
|
|
43
42
|
if (isDirty && onNavigateWithDirty) {
|
|
@@ -195,7 +194,7 @@ export interface IGlobalPageFormProps {
|
|
|
195
194
|
isDirty?: boolean;
|
|
196
195
|
onNavigateWithDirty?: (navigateCallback: () => void) => void;
|
|
197
196
|
actions: {
|
|
198
|
-
getGlobalFromLocalPageAction(): Promise<
|
|
197
|
+
getGlobalFromLocalPageAction(): Promise<number | null>;
|
|
199
198
|
saveCurrentSiteInfoAction(): Promise<void>;
|
|
200
199
|
restorePageNavigationAction(type: string): Promise<void>;
|
|
201
200
|
setNotificationAction(notification: INotification): void;
|
|
@@ -86,7 +86,7 @@ const LinkPopover = (props: ILinkPopoverProps) => {
|
|
|
86
86
|
return createPortal(
|
|
87
87
|
<>
|
|
88
88
|
<S.PopoverOverlay onClick={handleOverlayClick} />
|
|
89
|
-
<S.PopoverContainer ref={popoverRef} x={position.x} y={position.y}>
|
|
89
|
+
<S.PopoverContainer ref={popoverRef} x={position.x} y={position.y} data-testid="link-popover">
|
|
90
90
|
{!linkData?.isImageLink && (
|
|
91
91
|
<FieldsBehavior
|
|
92
92
|
title="Text"
|
|
@@ -286,6 +286,33 @@ const Wysiwyg = (props: IWysiwygProps) => {
|
|
|
286
286
|
});
|
|
287
287
|
}
|
|
288
288
|
},
|
|
289
|
+
// Froala's fullscreen stashes the editor's z-index in `opts.z_index` but
|
|
290
|
+
// reads the option back from `opts.zIndex`, so leaving it restores
|
|
291
|
+
// undefined. Any popup opened while in fullscreen pins the toolbar at
|
|
292
|
+
// Froala's own 2147483642, and with the option gone nothing ever clears
|
|
293
|
+
// it: the toolbar then paints over every modal the field opens. Seed the
|
|
294
|
+
// key the plugin reads, and drop what it left inline on the way out.
|
|
295
|
+
"commands.before": function (this: any, cmd: string) {
|
|
296
|
+
if (cmd === "fullscreen" && !this.fullscreen.isActive()) {
|
|
297
|
+
this.opts.z_index = this.opts.zIndex;
|
|
298
|
+
}
|
|
299
|
+
},
|
|
300
|
+
"commands.after": function (this: any, cmd: string) {
|
|
301
|
+
if (cmd === "fullscreen" && !this.fullscreen.isActive()) {
|
|
302
|
+
this.$tb.css("z-index", "");
|
|
303
|
+
}
|
|
304
|
+
},
|
|
305
|
+
// Froala's shortcut dispatcher calls plugin commands unbound, so its own
|
|
306
|
+
// Ctrl/Cmd+K throws instead of opening anything. Returning false cancels
|
|
307
|
+
// it; commands.exec runs ours with the editor bound.
|
|
308
|
+
shortcut: function (this: any, e: KeyboardEvent, cmd: string) {
|
|
309
|
+
if (cmd !== "insertLink") return;
|
|
310
|
+
e.preventDefault();
|
|
311
|
+
if (!inline) {
|
|
312
|
+
this.commands.exec("insertLinkGriddo");
|
|
313
|
+
}
|
|
314
|
+
return false;
|
|
315
|
+
},
|
|
289
316
|
blur: function (this: any) {
|
|
290
317
|
const html = this.html.get();
|
|
291
318
|
const stripedHtml = decodeEntities(html);
|
|
@@ -318,6 +345,7 @@ const Wysiwyg = (props: IWysiwygProps) => {
|
|
|
318
345
|
|
|
319
346
|
return (
|
|
320
347
|
<>
|
|
348
|
+
<S.FullscreenGlobalStyle />
|
|
321
349
|
<S.EditorWrapper error={error} disabled={disabled} data-testid="wysiwyg-wrapper">
|
|
322
350
|
<FroalaEditorComponent tag="textarea" model={value} config={config} onModelChange={handleChange} />
|
|
323
351
|
</S.EditorWrapper>
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Wrapper } from "@ax/components/FieldsBehavior/style";
|
|
2
|
+
import { Wrapper as FloatingPanelWrapper } from "@ax/components/FloatingPanel/style";
|
|
2
3
|
|
|
3
|
-
import styled from "styled-components";
|
|
4
|
+
import styled, { createGlobalStyle } from "styled-components";
|
|
4
5
|
|
|
5
6
|
export const PopoverOverlay = styled.div`
|
|
6
7
|
position: fixed;
|
|
@@ -193,3 +194,42 @@ export const EditorWrapper = styled.div<{ error: boolean | undefined; disabled?:
|
|
|
193
194
|
}
|
|
194
195
|
}
|
|
195
196
|
`;
|
|
197
|
+
|
|
198
|
+
// Froala's fullscreen raises the editor and every ancestor up to <body> to
|
|
199
|
+
// z-index 2147483640, which buries everything the field portals into <body>.
|
|
200
|
+
// Bring the editor back into the app's scale and lift the overlays it can open
|
|
201
|
+
// over it. It has to be global because both the ancestors it marks and the
|
|
202
|
+
// portals live outside this file's tree, and scoped to `body.fr-fullscreen` so
|
|
203
|
+
// nothing moves while the editor is inline.
|
|
204
|
+
//
|
|
205
|
+
// The editor goes over the app chrome and under the overlay layer (Modal), and
|
|
206
|
+
// the overlays it can open ride over it: the link popover, and the page finder
|
|
207
|
+
// panel that the popover opens on top of itself. The galleries need nothing,
|
|
208
|
+
// Modal already sits above. Exported because these only hold as long as the
|
|
209
|
+
// components they stack against keep their own values.
|
|
210
|
+
export const FULLSCREEN_LAYER = {
|
|
211
|
+
editor: 1210,
|
|
212
|
+
popoverOverlay: 1220,
|
|
213
|
+
popover: 1230,
|
|
214
|
+
panel: 1235,
|
|
215
|
+
};
|
|
216
|
+
|
|
217
|
+
export const FullscreenGlobalStyle = createGlobalStyle`
|
|
218
|
+
body.fr-fullscreen {
|
|
219
|
+
/* An ancestor panel that *contains* the editor keeps the editor's own value
|
|
220
|
+
here, because Froala marks it as a wrapper too and !important wins. */
|
|
221
|
+
.fr-fullscreen-wrapper {
|
|
222
|
+
z-index: ${FULLSCREEN_LAYER.editor} !important;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
${PopoverOverlay} {
|
|
226
|
+
z-index: ${FULLSCREEN_LAYER.popoverOverlay};
|
|
227
|
+
}
|
|
228
|
+
${PopoverContainer} {
|
|
229
|
+
z-index: ${FULLSCREEN_LAYER.popover};
|
|
230
|
+
}
|
|
231
|
+
${FloatingPanelWrapper} {
|
|
232
|
+
z-index: ${FULLSCREEN_LAYER.panel};
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
`;
|
|
@@ -6,12 +6,12 @@ import AppBar, { type IAppBarProps } from "./AppBar";
|
|
|
6
6
|
import * as S from "./style";
|
|
7
7
|
|
|
8
8
|
const MainWrapper = (props: IWrapperProps): JSX.Element => {
|
|
9
|
-
const { children, fixedAppBar, fullWidth, hasAnimation } = props;
|
|
9
|
+
const { children, fixedAppBar, fullWidth, hasAnimation, screen } = props;
|
|
10
10
|
|
|
11
11
|
const { isOnline } = useNetworkStatus();
|
|
12
12
|
|
|
13
13
|
return (
|
|
14
|
-
<S.Wrapper fixedAppBar={fixedAppBar} data-testid="main-wrapper">
|
|
14
|
+
<S.Wrapper fixedAppBar={fixedAppBar} data-testid="main-wrapper" data-screen={screen}>
|
|
15
15
|
{hasAnimation && <S.BackgroundAnimation />}
|
|
16
16
|
<AppBar {...props} />
|
|
17
17
|
<S.Main fullWidth={fullWidth} data-testid="main-component" id="main-component">
|
|
@@ -30,6 +30,18 @@ const MainWrapper = (props: IWrapperProps): JSX.Element => {
|
|
|
30
30
|
export interface IWrapperProps extends IAppBarProps {
|
|
31
31
|
children?: any[] | JSX.Element;
|
|
32
32
|
fullWidth?: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Stable per-screen identifier for e2e, emitted as `data-screen`. Separate from
|
|
35
|
+
* `data-testid="main-wrapper"`, which is the same on all 32 screens and is already
|
|
36
|
+
* consumed by tests here and in griddo-qa's `identifiers.ts`.
|
|
37
|
+
*
|
|
38
|
+
* It exists because nothing else identifies a screen: `title` is display copy and
|
|
39
|
+
* is not even unique — Robots and Redirects are both "SEO Settings", and the global
|
|
40
|
+
* and the site Analytics screens are both "Analytics Settings". The e2e specs had
|
|
41
|
+
* to assert on `location.pathname` with exact equality, and `/settings/analytics`
|
|
42
|
+
* is a substring of `/sites/settings/analytics`.
|
|
43
|
+
*/
|
|
44
|
+
screen?: string;
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
export default MainWrapper;
|
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { NavLink } from "react-router-dom";
|
|
3
2
|
|
|
4
3
|
import { SubNav, MenuItem } from "@ax/components";
|
|
5
4
|
import type { INavItem } from "@ax/types";
|
|
@@ -18,13 +17,29 @@ const Nav = (props: INavProps): JSX.Element => {
|
|
|
18
17
|
const selectedClass = isSelected ? "selected" : "";
|
|
19
18
|
const handleClick = () => onClick(item.path);
|
|
20
19
|
|
|
20
|
+
// No `NavLink` wrapping the label, on purpose (sc-118192). It used to be here with
|
|
21
|
+
// `to="#"`, which made every click navigate TWICE: the link's transition fires first —
|
|
22
|
+
// it is the inner element, so its handler runs first as the event bubbles, and by the
|
|
23
|
+
// time `MenuItem` calls `preventDefault` react-router has already read
|
|
24
|
+
// `defaultPrevented` as false and pushed — and then the real one from `onClick`.
|
|
25
|
+
//
|
|
26
|
+
// On a clean screen that left a spurious history entry before every real one. On a
|
|
27
|
+
// screen with unsaved changes it was worse: a `RouteLeavingGuard`'s `<Prompt>`
|
|
28
|
+
// intercepted both transitions and its handler toggles the modal
|
|
29
|
+
// (`hooks/modals.tsx:6-8`), so it opened and shut in the same tick — the click did
|
|
30
|
+
// nothing at all, no navigation and no warning, on the five settings screens that
|
|
31
|
+
// mount both.
|
|
32
|
+
//
|
|
33
|
+
// Nothing was lost by dropping it: `S.Link` is a `styled.div` that only reads `active`,
|
|
34
|
+
// no style hangs off the anchor, and `MenuItem` is already the focusable,
|
|
35
|
+
// keyboard-operable element (`role="button"`, `tabIndex={0}`, Enter handled) — so the
|
|
36
|
+
// `<a>` was a nested interactive element inside a button, which is worse semantics
|
|
37
|
+
// rather than better.
|
|
21
38
|
const menuItem = (
|
|
22
39
|
<MenuItem onClick={handleClick} key={key} className={selectedClass}>
|
|
23
|
-
<
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
</S.Link>
|
|
27
|
-
</NavLink>
|
|
40
|
+
<S.Link active={isSelected} data-testid="nav-link">
|
|
41
|
+
{item.title}
|
|
42
|
+
</S.Link>
|
|
28
43
|
</MenuItem>
|
|
29
44
|
);
|
|
30
45
|
|
|
@@ -31,9 +31,14 @@ const ResizePanel = (props: IResizePanelProps): JSX.Element => {
|
|
|
31
31
|
|
|
32
32
|
const updateWidth = () => {
|
|
33
33
|
frame = requestAnimationFrame(() => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
34
|
+
const panel = rightPanelRef.current;
|
|
35
|
+
// Froala's fullscreen tags every ancestor of the editor with
|
|
36
|
+
// .fr-fullscreen-wrapper, and its `width: 100% !important` lands on this
|
|
37
|
+
// panel too. Reading it while that class is on would store the width the
|
|
38
|
+
// editor borrowed as the panel's own, and the panel would keep it once
|
|
39
|
+
// fullscreen is closed.
|
|
40
|
+
if (!panel || panel.classList.contains("fr-fullscreen-wrapper")) return;
|
|
41
|
+
setRwidth(panel.offsetWidth);
|
|
37
42
|
});
|
|
38
43
|
};
|
|
39
44
|
|
|
@@ -11,7 +11,10 @@ const Toast = (props: IToastProps): JSX.Element => {
|
|
|
11
11
|
const toast = useRef<HTMLDivElement>(null);
|
|
12
12
|
|
|
13
13
|
const { pathname } = useLocation();
|
|
14
|
-
|
|
14
|
+
// Structured data items live under `/data/:id/content[/:contentId]`, page editors
|
|
15
|
+
// under `/editor`. Matched as a whole segment so `/settings/content-types` doesn't
|
|
16
|
+
// count as an editor.
|
|
17
|
+
const isEditor = /\/(editor|content)(\/|$)/.test(pathname);
|
|
15
18
|
|
|
16
19
|
let temp: NodeJS.Timeout;
|
|
17
20
|
const setTemp = (time: number) => {
|
|
@@ -13,7 +13,6 @@ import type {
|
|
|
13
13
|
import { logs } from "@ax/api";
|
|
14
14
|
import { appActions } from "@ax/containers/App";
|
|
15
15
|
import { usersActions } from "@ax/containers/Users";
|
|
16
|
-
import { pageEditorActions } from "@ax/containers/PageEditor";
|
|
17
16
|
import { sitesActions } from "@ax/containers/Sites";
|
|
18
17
|
import { integrationsActions } from "@ax/containers/Integrations";
|
|
19
18
|
import { formsActions } from "@ax/containers/Forms";
|
|
@@ -228,13 +227,11 @@ function goToLogContent(
|
|
|
228
227
|
case "template":
|
|
229
228
|
if (site) {
|
|
230
229
|
await sitesActions.getSite(site)(dispatch, getState);
|
|
231
|
-
dispatch(pageEditorActions.setCurrentPageID(contentType.id));
|
|
232
230
|
dispatch(appActions.setLanguage(language));
|
|
233
|
-
appActions.setHistoryPush(
|
|
231
|
+
appActions.setHistoryPush(`/sites/pages/editor/${contentType.id}`, true)(dispatch);
|
|
234
232
|
} else {
|
|
235
|
-
dispatch(pageEditorActions.setCurrentPageID(contentType.id));
|
|
236
233
|
dispatch(appActions.setLanguage(language));
|
|
237
|
-
appActions.setHistoryPush(
|
|
234
|
+
appActions.setHistoryPush(`/data/pages/editor/${contentType.id}`, true)(dispatch);
|
|
238
235
|
}
|
|
239
236
|
break;
|
|
240
237
|
case "structuredData":
|
|
@@ -245,13 +242,13 @@ function goToLogContent(
|
|
|
245
242
|
dispatch(structuredDataActions.setCurrentDataID(contentType.id));
|
|
246
243
|
await structuredDataActions.getDataContent(contentType.id)(dispatch);
|
|
247
244
|
dispatch(appActions.setLanguage(language));
|
|
248
|
-
appActions.setHistoryPush(`/sites/data/${contentType.content.id}/
|
|
245
|
+
appActions.setHistoryPush(`/sites/data/${contentType.content.id}/content`)(dispatch);
|
|
249
246
|
} else {
|
|
250
247
|
structuredDataActions.setSelectedStructuredData(contentType.content.id, "global")(dispatch, getState);
|
|
251
248
|
dispatch(structuredDataActions.setCurrentDataID(contentType.id));
|
|
252
249
|
await structuredDataActions.getDataContent(contentType.id)(dispatch);
|
|
253
250
|
dispatch(appActions.setLanguage(language));
|
|
254
|
-
appActions.setHistoryPush(`/data/${contentType.content.id}/
|
|
251
|
+
appActions.setHistoryPush(`/data/${contentType.content.id}/content`)(dispatch);
|
|
255
252
|
}
|
|
256
253
|
}
|
|
257
254
|
break;
|
|
@@ -338,6 +338,10 @@ function getPage(
|
|
|
338
338
|
return async (dispatch, getState) => {
|
|
339
339
|
try {
|
|
340
340
|
dispatch(setIsLoading(true));
|
|
341
|
+
|
|
342
|
+
const savedCopy = localStorage.getItem("pageCopyModule");
|
|
343
|
+
if (savedCopy) dispatch(setCopyModule(JSON.parse(savedCopy)));
|
|
344
|
+
|
|
341
345
|
const {
|
|
342
346
|
sites: { currentSiteInfo },
|
|
343
347
|
pageEditor: { isNewTranslation, template },
|
|
@@ -688,15 +692,18 @@ function getPageLanguages(
|
|
|
688
692
|
};
|
|
689
693
|
}
|
|
690
694
|
|
|
691
|
-
function duplicatePage(pageID: number, data: any, siteID?: number): (dispatch: Dispatch) => Promise<
|
|
695
|
+
function duplicatePage(pageID: number, data: any, siteID?: number): (dispatch: Dispatch) => Promise<number | false> {
|
|
692
696
|
return async (dispatch) => {
|
|
693
697
|
try {
|
|
698
|
+
let duplicatedId: number | null = null;
|
|
699
|
+
|
|
694
700
|
const responseActions = {
|
|
695
701
|
handleSuccess: (data: any) => {
|
|
696
702
|
if (!siteID) {
|
|
697
703
|
dispatch(setCurrentPageID(data.id));
|
|
698
704
|
dispatch(setCurrentPageStatus(pageStatus.OFFLINE));
|
|
699
705
|
}
|
|
706
|
+
duplicatedId = data.id;
|
|
700
707
|
return true;
|
|
701
708
|
},
|
|
702
709
|
handleError: (response: any) => {
|
|
@@ -706,8 +713,9 @@ function duplicatePage(pageID: number, data: any, siteID?: number): (dispatch: D
|
|
|
706
713
|
};
|
|
707
714
|
|
|
708
715
|
const callback = async () => pages.duplicatePage(pageID, data, siteID);
|
|
716
|
+
const success = await handleRequest(callback, responseActions, [appActions.setIsSaving])(dispatch);
|
|
709
717
|
|
|
710
|
-
return
|
|
718
|
+
return success && duplicatedId !== null ? duplicatedId : false;
|
|
711
719
|
} catch (e) {
|
|
712
720
|
console.log(e);
|
|
713
721
|
return false;
|
|
@@ -1037,6 +1045,7 @@ function copyModule(editorID: number[]): (dispatch: Dispatch, getState: () => IR
|
|
|
1037
1045
|
};
|
|
1038
1046
|
|
|
1039
1047
|
dispatch(setCopyModule(payload));
|
|
1048
|
+
localStorage.setItem("pageCopyModule", JSON.stringify(payload));
|
|
1040
1049
|
|
|
1041
1050
|
return payload.elements.length;
|
|
1042
1051
|
} else {
|
|
@@ -1441,7 +1450,7 @@ function deleteError(error: IErrorItem): (dispatch: Dispatch, getState: () => IR
|
|
|
1441
1450
|
};
|
|
1442
1451
|
}
|
|
1443
1452
|
|
|
1444
|
-
function getGlobalFromLocalPage(): (dispatch: Dispatch, getState: any) => Promise<
|
|
1453
|
+
function getGlobalFromLocalPage(): (dispatch: Dispatch, getState: any) => Promise<number | null> {
|
|
1445
1454
|
return async (dispatch, getState) => {
|
|
1446
1455
|
try {
|
|
1447
1456
|
dispatch(setIsLoading(true));
|
|
@@ -1457,8 +1466,10 @@ function getGlobalFromLocalPage(): (dispatch: Dispatch, getState: any) => Promis
|
|
|
1457
1466
|
structuredDataActions.setSelectedStructuredData(structuredData, "global")(dispatch, getState);
|
|
1458
1467
|
usersActions.getUserCurrentPermissions("global")(dispatch, getState);
|
|
1459
1468
|
dispatch(setIsLoading(false));
|
|
1469
|
+
return originalGlobalPage as number;
|
|
1460
1470
|
} catch (e) {
|
|
1461
1471
|
console.log(e);
|
|
1472
|
+
return null;
|
|
1462
1473
|
}
|
|
1463
1474
|
};
|
|
1464
1475
|
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { IComponent, IContainedComponent, IContainerEvaluation } from "@ax/types";
|
|
2
|
+
|
|
2
3
|
import { getDisplayName } from "./schemas";
|
|
3
4
|
|
|
4
5
|
const isComponentEmpty = (component: IComponent): boolean => {
|
|
@@ -58,6 +59,13 @@ const areEqual = (prevProps: any, newProps: any): boolean => {
|
|
|
58
59
|
return false;
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
// Re-render si moduleCopy cambia (e.g. cross-tab clipboard broadcast)
|
|
63
|
+
const prevModuleCopyDate = prevProps.moduleCopy?.date?.getTime?.();
|
|
64
|
+
const newModuleCopyDate = newProps.moduleCopy?.date?.getTime?.();
|
|
65
|
+
if (prevModuleCopyDate !== newModuleCopyDate) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
|
|
61
69
|
const {
|
|
62
70
|
selectedContent: { type },
|
|
63
71
|
} = prevProps;
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
|
+
import { useDispatch, useSelector } from "react-redux";
|
|
3
|
+
|
|
4
|
+
import { SET_COPY_MODULE } from "@ax/containers/PageEditor/constants";
|
|
5
|
+
import type { IRootState } from "@ax/types";
|
|
6
|
+
|
|
7
|
+
// Types
|
|
8
|
+
interface BroadcastMessage {
|
|
9
|
+
type: string;
|
|
10
|
+
payload?: any;
|
|
11
|
+
timestamp?: number;
|
|
12
|
+
senderId?: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
interface ContentUpdateMessage {
|
|
16
|
+
contentID: number;
|
|
17
|
+
timestamp: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// Unique ID for this tab instance to filter own broadcasts
|
|
21
|
+
const TAB_ID = Math.random().toString(36).substring(2, 11);
|
|
22
|
+
|
|
23
|
+
// Generic Broadcast Channel wrapper
|
|
24
|
+
const useBroadcastChannel = (channelName: string, onMessage: (message: BroadcastMessage) => void) => {
|
|
25
|
+
const channelRef = useRef<BroadcastChannel | null>(null);
|
|
26
|
+
const onMessageRef = useRef(onMessage);
|
|
27
|
+
|
|
28
|
+
useEffect(() => {
|
|
29
|
+
onMessageRef.current = onMessage;
|
|
30
|
+
}, [onMessage]);
|
|
31
|
+
|
|
32
|
+
useEffect(() => {
|
|
33
|
+
const channel = new BroadcastChannel(channelName);
|
|
34
|
+
channelRef.current = channel;
|
|
35
|
+
|
|
36
|
+
const handleMessage = (event: MessageEvent<BroadcastMessage>) => {
|
|
37
|
+
onMessageRef.current(event.data);
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
channel.addEventListener("message", handleMessage);
|
|
41
|
+
|
|
42
|
+
return () => {
|
|
43
|
+
channel.removeEventListener("message", handleMessage);
|
|
44
|
+
channel.close();
|
|
45
|
+
};
|
|
46
|
+
}, [channelName]);
|
|
47
|
+
|
|
48
|
+
const postMessage = useCallback((message: BroadcastMessage) => {
|
|
49
|
+
if (channelRef.current) {
|
|
50
|
+
channelRef.current.postMessage({
|
|
51
|
+
...message,
|
|
52
|
+
timestamp: Date.now(),
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}, []);
|
|
56
|
+
|
|
57
|
+
return { postMessage };
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
// Broadcast clipboard (module copy) across tabs
|
|
61
|
+
const useBroadcastClipboard = () => {
|
|
62
|
+
const dispatch = useDispatch();
|
|
63
|
+
const channelName = "griddo-clipboard";
|
|
64
|
+
const moduleCopy = useSelector((state: IRootState) => state.pageEditor.moduleCopy);
|
|
65
|
+
const isFromBroadcastRef = useRef(false);
|
|
66
|
+
|
|
67
|
+
const handleMessage = useCallback(
|
|
68
|
+
(message: any) => {
|
|
69
|
+
if (message.senderId === TAB_ID) {
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (message.type === "module-copied" && message.payload) {
|
|
73
|
+
isFromBroadcastRef.current = true;
|
|
74
|
+
const moduleDataWithDate = {
|
|
75
|
+
...message.payload,
|
|
76
|
+
date: new Date(message.payload.date),
|
|
77
|
+
};
|
|
78
|
+
dispatch({
|
|
79
|
+
type: SET_COPY_MODULE,
|
|
80
|
+
payload: { moduleCopy: moduleDataWithDate },
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
},
|
|
84
|
+
[dispatch],
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
const { postMessage } = useBroadcastChannel(channelName, handleMessage);
|
|
88
|
+
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (moduleCopy) {
|
|
91
|
+
if (isFromBroadcastRef.current) {
|
|
92
|
+
isFromBroadcastRef.current = false;
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
postMessage({
|
|
96
|
+
type: "module-copied",
|
|
97
|
+
payload: moduleCopy,
|
|
98
|
+
senderId: TAB_ID,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
}, [moduleCopy, postMessage]);
|
|
102
|
+
|
|
103
|
+
return {};
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
// Broadcast content updates (page/data saves) across tabs
|
|
107
|
+
const useBroadcastContentUpdate = (contentID: number | null) => {
|
|
108
|
+
const [remotePageUpdate, setRemotePageUpdate] = useState<ContentUpdateMessage | null>(null);
|
|
109
|
+
|
|
110
|
+
const handleMessage = useCallback(
|
|
111
|
+
(message: any) => {
|
|
112
|
+
if (message.type === "page-saved" && message.payload?.contentID === contentID) {
|
|
113
|
+
setRemotePageUpdate(message.payload);
|
|
114
|
+
}
|
|
115
|
+
},
|
|
116
|
+
[contentID],
|
|
117
|
+
);
|
|
118
|
+
|
|
119
|
+
const { postMessage } = useBroadcastChannel("griddo-page-updates", handleMessage);
|
|
120
|
+
|
|
121
|
+
const broadcastPageSave = useCallback(
|
|
122
|
+
(id: number) => {
|
|
123
|
+
postMessage({
|
|
124
|
+
type: "page-saved",
|
|
125
|
+
payload: { contentID: id },
|
|
126
|
+
});
|
|
127
|
+
},
|
|
128
|
+
[postMessage],
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
return { broadcastPageSave, remotePageUpdate, clearRemoteUpdate: () => setRemotePageUpdate(null) };
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
// Broadcast logout across tabs.
|
|
135
|
+
// The logout action is injected by the consumer so this generic hook does not depend on
|
|
136
|
+
// the App container (which would create a load-time circular dependency: hooks → App → … → hooks).
|
|
137
|
+
const useBroadcastLogout = (onRemoteLogout?: () => void) => {
|
|
138
|
+
const channelName = "griddo-logout";
|
|
139
|
+
|
|
140
|
+
const handleMessage = useCallback(
|
|
141
|
+
(message: any) => {
|
|
142
|
+
if (message.type === "user-logout") {
|
|
143
|
+
onRemoteLogout?.();
|
|
144
|
+
}
|
|
145
|
+
},
|
|
146
|
+
[onRemoteLogout],
|
|
147
|
+
);
|
|
148
|
+
|
|
149
|
+
const { postMessage } = useBroadcastChannel(channelName, handleMessage);
|
|
150
|
+
|
|
151
|
+
const broadcastLogout = useCallback(() => {
|
|
152
|
+
postMessage({
|
|
153
|
+
type: "user-logout",
|
|
154
|
+
});
|
|
155
|
+
}, [postMessage]);
|
|
156
|
+
|
|
157
|
+
return { broadcastLogout };
|
|
158
|
+
};
|
|
159
|
+
|
|
160
|
+
export { useBroadcastChannel, useBroadcastClipboard, useBroadcastContentUpdate, useBroadcastLogout };
|
package/src/hooks/index.tsx
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { useBroadcastChannel, useBroadcastClipboard, useBroadcastContentUpdate, useBroadcastLogout } from "./broadcast";
|
|
1
2
|
import { type IBulkSelectedItems, useBulkSelection } from "./bulk";
|
|
2
3
|
import { useAdaptiveText, useCategoryColors, useEmptyState } from "./content";
|
|
3
4
|
import {
|
|
@@ -25,6 +26,10 @@ import { useFirefoxScrollLock, useWindowSize } from "./window";
|
|
|
25
26
|
|
|
26
27
|
export {
|
|
27
28
|
useAdaptiveText,
|
|
29
|
+
useBroadcastChannel,
|
|
30
|
+
useBroadcastClipboard,
|
|
31
|
+
useBroadcastLogout,
|
|
32
|
+
useBroadcastContentUpdate,
|
|
28
33
|
useBulkSelection,
|
|
29
34
|
useCategoryColors,
|
|
30
35
|
useContextMenu,
|
|
@@ -123,7 +123,7 @@ const Analytics = (props: IProps): JSX.Element => {
|
|
|
123
123
|
return (
|
|
124
124
|
<>
|
|
125
125
|
<RouteLeavingGuard when={isDirty} action={setRoute} text={modalText} />
|
|
126
|
-
<MainWrapper backLink={false} title="Analytics Settings" rightButton={rightButtonProps}>
|
|
126
|
+
<MainWrapper backLink={false} title="Analytics Settings" rightButton={rightButtonProps} screen="analytics-global">
|
|
127
127
|
<ErrorToast />
|
|
128
128
|
<S.Wrapper>
|
|
129
129
|
<S.FormWrapper>
|
|
@@ -1,15 +1,25 @@
|
|
|
1
|
-
import
|
|
2
|
-
import { connect } from "react-redux";
|
|
1
|
+
import { useEffect } from "react";
|
|
3
2
|
import { ErrorBoundary } from "react-error-boundary";
|
|
3
|
+
import { connect, useSelector } from "react-redux";
|
|
4
4
|
|
|
5
|
-
import type { IRootState } from "@ax/types";
|
|
6
5
|
import { ErrorPage, Loading } from "@ax/components";
|
|
6
|
+
import { appActions } from "@ax/containers/App";
|
|
7
|
+
import { useBroadcastLogout } from "@ax/hooks";
|
|
8
|
+
import type { IRootState } from "@ax/types";
|
|
7
9
|
|
|
8
|
-
import Routing from "./Routing";
|
|
9
10
|
import Style from "../../Style";
|
|
11
|
+
import Routing from "./Routing";
|
|
10
12
|
|
|
11
13
|
const App = (props: IProps) => {
|
|
12
|
-
const { isRehydrated } = props;
|
|
14
|
+
const { isRehydrated, logoutAndNavigate } = props;
|
|
15
|
+
const token = useSelector((state: IRootState) => state.app.token);
|
|
16
|
+
const { broadcastLogout } = useBroadcastLogout(logoutAndNavigate);
|
|
17
|
+
|
|
18
|
+
useEffect(() => {
|
|
19
|
+
if (token === "") {
|
|
20
|
+
broadcastLogout();
|
|
21
|
+
}
|
|
22
|
+
}, [token, broadcastLogout]);
|
|
13
23
|
|
|
14
24
|
return (
|
|
15
25
|
<Style>
|
|
@@ -32,8 +42,13 @@ function mapStateToProps(state: IRootState) {
|
|
|
32
42
|
};
|
|
33
43
|
}
|
|
34
44
|
|
|
45
|
+
const mapDispatchToProps = {
|
|
46
|
+
logoutAndNavigate: appActions.logoutAndNavigate,
|
|
47
|
+
};
|
|
48
|
+
|
|
35
49
|
interface IProps {
|
|
36
50
|
isRehydrated: boolean;
|
|
51
|
+
logoutAndNavigate: () => void;
|
|
37
52
|
}
|
|
38
53
|
|
|
39
|
-
export default connect(mapStateToProps)(App);
|
|
54
|
+
export default connect(mapStateToProps, mapDispatchToProps)(App);
|