@griddo/ax 12.6.0 → 12.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@griddo/ax",
3
3
  "description": "Griddo Author Experience",
4
- "version": "12.6.0",
4
+ "version": "12.6.2",
5
5
  "authors": [
6
6
  "Álvaro Sánchez' <alvaro.sanches@secuoyas.com>",
7
7
  "Diego M. Béjar <diego.bejar@secuoyas.com>",
@@ -201,5 +201,5 @@
201
201
  "publishConfig": {
202
202
  "access": "public"
203
203
  },
204
- "gitHead": "5b898e9cbe8af3916311b072f42b1014150c331f"
204
+ "gitHead": "1e6a3ad08eb17a4c57f80fffb65193a82a577969"
205
205
  }
@@ -0,0 +1,88 @@
1
+ import "@testing-library/jest-dom";
2
+
3
+ import { useEditingHeartbeat } from "@ax/hooks";
4
+
5
+ import { renderHook } from "@testing-library/react";
6
+
7
+ const HEARTBEAT = 30000;
8
+
9
+ const ORIGINAL = 213;
10
+ const TRANSLATION = 214;
11
+
12
+ describe("useEditingHeartbeat hook", () => {
13
+ beforeEach(() => {
14
+ vi.useFakeTimers();
15
+ });
16
+
17
+ afterEach(() => {
18
+ vi.useRealTimers();
19
+ });
20
+
21
+ const renderHeartbeat = (pageID: number | null, sendPagePing: (id: number) => void) =>
22
+ renderHook(({ pageID }) => useEditingHeartbeat(pageID, sendPagePing), { initialProps: { pageID } });
23
+
24
+ it("locks the page on mount, without waiting for the first interval", () => {
25
+ const sendPagePing = vi.fn();
26
+
27
+ renderHeartbeat(ORIGINAL, sendPagePing);
28
+
29
+ expect(sendPagePing).toHaveBeenCalledTimes(1);
30
+ expect(sendPagePing).toHaveBeenCalledWith(ORIGINAL);
31
+ });
32
+
33
+ it("renews the lock every 30 seconds", () => {
34
+ const sendPagePing = vi.fn();
35
+
36
+ renderHeartbeat(ORIGINAL, sendPagePing);
37
+ vi.advanceTimersByTime(HEARTBEAT * 2);
38
+
39
+ // t+0 (mount) + t+30s + t+60s
40
+ expect(sendPagePing).toHaveBeenCalledTimes(3);
41
+ expect(sendPagePing.mock.calls.every(([id]) => id === ORIGINAL)).toBe(true);
42
+ });
43
+
44
+ // sc-118275: creating a translation from inside the editor changes pageID in the store without
45
+ // remounting. The heartbeat used to be pinned to the id it captured on mount, so it kept
46
+ // renewing the lock of the original page and left the translation unlocked.
47
+ it("follows the page when its id changes under an editor that never remounts", () => {
48
+ const sendPagePing = vi.fn();
49
+
50
+ const { rerender } = renderHeartbeat(ORIGINAL, sendPagePing);
51
+ vi.advanceTimersByTime(HEARTBEAT);
52
+ sendPagePing.mockClear();
53
+
54
+ rerender({ pageID: TRANSLATION });
55
+
56
+ // No 30 second gap: the page actually being edited is locked as soon as it exists.
57
+ expect(sendPagePing).toHaveBeenCalledTimes(1);
58
+ expect(sendPagePing).toHaveBeenCalledWith(TRANSLATION);
59
+
60
+ vi.advanceTimersByTime(HEARTBEAT * 2);
61
+
62
+ expect(sendPagePing).toHaveBeenCalledTimes(3);
63
+ // The original is left alone so its lock expires; renewing it would block the rest of the
64
+ // team on a page nobody is touching.
65
+ expect(sendPagePing).not.toHaveBeenCalledWith(ORIGINAL);
66
+ });
67
+
68
+ it("does not ping while there is no page yet", () => {
69
+ const sendPagePing = vi.fn();
70
+
71
+ renderHeartbeat(null, sendPagePing);
72
+ vi.advanceTimersByTime(HEARTBEAT * 2);
73
+
74
+ expect(sendPagePing).not.toHaveBeenCalled();
75
+ });
76
+
77
+ it("stops renewing the lock once the editor is closed", () => {
78
+ const sendPagePing = vi.fn();
79
+
80
+ const { unmount } = renderHeartbeat(ORIGINAL, sendPagePing);
81
+ unmount();
82
+ sendPagePing.mockClear();
83
+
84
+ vi.advanceTimersByTime(HEARTBEAT * 2);
85
+
86
+ expect(sendPagePing).not.toHaveBeenCalled();
87
+ });
88
+ });
@@ -0,0 +1,32 @@
1
+ import { useEffect } from "react";
2
+
3
+ /**
4
+ * Keeps the edit lock of the page being edited alive, renewing it every 30 seconds.
5
+ *
6
+ * sc-118275: this used to live inside each editor's mount effect, whose dependency list is empty,
7
+ * so the interval kept pinging whichever id it captured on mount. Creating a translation from
8
+ * inside the editor moves the store to the new page but never remounts, so the lock stayed on the
9
+ * original page for as long as the editor was open — and the translation, the one actually being
10
+ * edited, was left unlocked. Keeping pageID in the dependency list is what makes the heartbeat
11
+ * follow the page.
12
+ *
13
+ * The leading ping is not redundant with the interval: on a page switch the new id would otherwise
14
+ * sit unlocked for 30 seconds, because savePage dispatches setUserEditing(page.editing) with a null
15
+ * payload when nobody else holds the page, and the editors' [userEditing] effect only pings when
16
+ * that reference changes.
17
+ *
18
+ * sendPagePing has to keep a stable reference across renders; both editors get it from an object
19
+ * literal mapDispatchToProps, so connect binds it once. If it ever started changing on every
20
+ * render the effect would re-run, the cleanup would clear the interval before it could fire and
21
+ * there would be no heartbeat at all.
22
+ */
23
+ const useEditingHeartbeat = (pageID: number | null, sendPagePing: (pageID: number) => void): void => {
24
+ useEffect(() => {
25
+ if (!pageID) return;
26
+ sendPagePing(pageID);
27
+ const interval = setInterval(() => sendPagePing(pageID), 30000);
28
+ return () => clearInterval(interval);
29
+ }, [pageID, sendPagePing]);
30
+ };
31
+
32
+ export { useEditingHeartbeat };
@@ -8,6 +8,7 @@ import {
8
8
  usePrevious,
9
9
  useShouldBeSaved,
10
10
  } from "./forms";
11
+ import { useEditingHeartbeat } from "./heartbeat";
11
12
  import { useOnMessageReceivedFromIframe, useOnMessageReceivedFromOutside } from "./iframe";
12
13
  import { useURLSearchParam } from "./location";
13
14
  import { useContextMenu, useHandleClickOutside, useModal, useModals, useToast } from "./modals";
@@ -29,6 +30,7 @@ export {
29
30
  useContextMenu,
30
31
  useDebounce,
31
32
  useDebouncedCallback,
33
+ useEditingHeartbeat,
32
34
  useEmptyState,
33
35
  useEqualStructured,
34
36
  useFirefoxScrollLock,
@@ -26,7 +26,7 @@ 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 { useIsDirty, useModals, usePermissionsForPage } from "@ax/hooks";
29
+ import { useEditingHeartbeat, useIsDirty, useModals, usePermissionsForPage } from "@ax/hooks";
30
30
  import type {
31
31
  HeadingFilter,
32
32
  IErrorItem,
@@ -77,6 +77,7 @@ const GlobalEditor = (props: IProps) => {
77
77
  schemaVersion,
78
78
  updatePageAccessGrants,
79
79
  accessGrants,
80
+ sendPagePing,
80
81
  } = props;
81
82
 
82
83
  const isAllowedTo = usePermissionsForPage(
@@ -153,7 +154,7 @@ const GlobalEditor = (props: IProps) => {
153
154
 
154
155
  // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: fix this
155
156
  useEffect(() => {
156
- const { pageID, getPage, sendPagePing, setStructuredDataFilter } = props;
157
+ const { setStructuredDataFilter } = props;
157
158
 
158
159
  editorContent?.structuredData && setStructuredDataFilter(editorContent.structuredData);
159
160
  const handleGetPage = async () => await getPage(pageID, true);
@@ -163,13 +164,10 @@ const GlobalEditor = (props: IProps) => {
163
164
  if (!pageID) {
164
165
  setIsDirty(false);
165
166
  }
166
-
167
- const interval = setInterval(() => {
168
- pageID && sendPagePing(pageID);
169
- }, 30000);
170
- return () => clearInterval(interval);
171
167
  }, []);
172
168
 
169
+ useEditingHeartbeat(pageID, sendPagePing);
170
+
173
171
  useEffect(() => {
174
172
  setSelectedTab(defaultTab);
175
173
  }, [defaultTab]);
@@ -181,7 +179,7 @@ const GlobalEditor = (props: IProps) => {
181
179
 
182
180
  // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: fix this
183
181
  useEffect(() => {
184
- const { pageID, sendPagePing, currentUserID } = props;
182
+ const { currentUserID } = props;
185
183
  if (userEditing && userEditing.id !== currentUserID) {
186
184
  setIsReadOnly(true);
187
185
  !isOpen("userEditing") && toggleModal("userEditing");
@@ -24,7 +24,7 @@ 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 { useIsDirty, useModals, usePermissionsForPage } from "@ax/hooks";
27
+ import { useEditingHeartbeat, useIsDirty, useModals, usePermissionsForPage } from "@ax/hooks";
28
28
  import type {
29
29
  HeadingFilter,
30
30
  IErrorItem,
@@ -80,6 +80,7 @@ const PageEditor = (props: IProps) => {
80
80
  currentSiteInfo,
81
81
  updatePageAccessGrants,
82
82
  accessGrants,
83
+ sendPagePing,
83
84
  } = props;
84
85
 
85
86
  const isAllowedTo = usePermissionsForPage(
@@ -158,8 +159,6 @@ const PageEditor = (props: IProps) => {
158
159
 
159
160
  // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: fix this
160
161
  useEffect(() => {
161
- const { pageID, getPage, sendPagePing } = props;
162
-
163
162
  const handleGetPage = async () => {
164
163
  if (isNewTranslation) {
165
164
  await getDefaults();
@@ -175,20 +174,17 @@ const PageEditor = (props: IProps) => {
175
174
  if (!pageID) {
176
175
  setIsDirty(false);
177
176
  }
178
-
179
- const interval = setInterval(() => {
180
- pageID && sendPagePing(pageID);
181
- }, 30000);
182
- return () => clearInterval(interval);
183
177
  }, []);
184
178
 
179
+ useEditingHeartbeat(pageID, sendPagePing);
180
+
185
181
  useEffect(() => {
186
182
  setSelectedTab(defaultTab);
187
183
  }, [defaultTab]);
188
184
 
189
185
  // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: fix this
190
186
  useEffect(() => {
191
- const { pageID, sendPagePing, currentUserID } = props;
187
+ const { currentUserID } = props;
192
188
  if (userEditing && userEditing.id !== currentUserID) {
193
189
  setIsReadOnly(true);
194
190
  !isOpen("userEditing") && toggleModal("userEditing");