@griddo/ax 12.8.3 → 12.8.4

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.8.3",
4
+ "version": "12.8.4",
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": "2583a2f1211251425b5766dd3377e5ec38db7a89"
204
+ "gitHead": "3a207a967d89225272c5760824e1138d0b01b926"
205
205
  }
@@ -40,6 +40,7 @@ const GlobalStyle = createGlobalStyle`
40
40
  background: transparent;
41
41
  padding: 0;
42
42
  border: none;
43
+ color: inherit;
43
44
  }
44
45
 
45
46
  #root {
@@ -48,6 +48,30 @@ describe("VisualUniqueSelection component rendering", () => {
48
48
  expect(screen.getByTestId("imageSelectionComponent")).toBeTruthy();
49
49
  });
50
50
 
51
+ // Las opciones por tema son una lista de `{ theme, options }`; el campo tiene que
52
+ // quedarse con la entrada del tema que recibe, y con sus miniaturas.
53
+ test("should render only the options of the given theme", () => {
54
+ const { container } = render(
55
+ <ThemeProvider theme={parseTheme(globalTheme)}>
56
+ <VisualUniqueSelection
57
+ {...makeProps({
58
+ theme: "griddo-alt-theme",
59
+ options: [
60
+ { theme: "default-theme", options: [{ value: "square", img: "/img/default/square.png" }] },
61
+ { theme: "griddo-alt-theme", options: [{ value: "round", img: "/img/alt/round.png" }] },
62
+ ],
63
+ })}
64
+ />
65
+ </ThemeProvider>,
66
+ );
67
+
68
+ const values = screen.getAllByTestId("radioButtons").map((radio) => (radio as HTMLInputElement).value);
69
+ expect(values).toEqual(["round"]);
70
+
71
+ const sources = Array.from(container.querySelectorAll("img")).map((img) => img.getAttribute("src"));
72
+ expect(sources).toEqual(["/img/alt/round.png"]);
73
+ });
74
+
51
75
  test("should render the component with ScrollableSelection", () => {
52
76
  render(
53
77
  <ThemeProvider theme={parseTheme(globalTheme)}>
@@ -209,3 +209,50 @@ describe("Tabs component events", () => {
209
209
  expect(tabs[1]).toHaveStyleRule("cursor", "pointer");
210
210
  });
211
211
  });
212
+
213
+ describe("Tabs component auto selection", () => {
214
+ it("should select the only tab once, although the tabs array is new on each render", () => {
215
+ const setSelectedTabAction = vi.fn();
216
+
217
+ // A new array on each render, as the callers that build the tabs inline do.
218
+ const renderTabs = () => (
219
+ <ThemeProvider theme={parseTheme(globalTheme)}>
220
+ <Tabs {...makeProps({ tabs: ["content"], active: "content", setSelectedTab: setSelectedTabAction })} />
221
+ </ThemeProvider>
222
+ );
223
+
224
+ const { rerender } = render(renderTabs());
225
+ rerender(renderTabs());
226
+ rerender(renderTabs());
227
+
228
+ expect(setSelectedTabAction).toHaveBeenCalledTimes(1);
229
+ });
230
+
231
+ it("should select the new tab when the only tab changes", () => {
232
+ const setSelectedTabAction = vi.fn();
233
+
234
+ const renderTabs = (tab: string) => (
235
+ <ThemeProvider theme={parseTheme(globalTheme)}>
236
+ <Tabs {...makeProps({ tabs: [tab], active: tab, setSelectedTab: setSelectedTabAction })} />
237
+ </ThemeProvider>
238
+ );
239
+
240
+ const { rerender } = render(renderTabs("content"));
241
+ rerender(renderTabs("config"));
242
+
243
+ expect(setSelectedTabAction).toHaveBeenNthCalledWith(1, "content");
244
+ expect(setSelectedTabAction).toHaveBeenNthCalledWith(2, "config");
245
+ });
246
+
247
+ it("should not select any tab when there is more than one", () => {
248
+ const setSelectedTabAction = vi.fn();
249
+
250
+ render(
251
+ <ThemeProvider theme={parseTheme(globalTheme)}>
252
+ <Tabs {...makeProps({ tabs: ["tab1", "tab2"], active: "tab1", setSelectedTab: setSelectedTabAction })} />
253
+ </ThemeProvider>,
254
+ );
255
+
256
+ expect(setSelectedTabAction).not.toBeCalled();
257
+ });
258
+ });
@@ -0,0 +1,61 @@
1
+ import { pages } from "@ax/api";
2
+ import { pageEditorActions } from "@ax/containers/PageEditor";
3
+ import { SET_USER_EDITING } from "@ax/containers/PageEditor/constants";
4
+ import type { IUserEditing } from "@ax/types";
5
+
6
+ const PAGE = 213;
7
+ const OTHER_AUTHOR: IUserEditing = { id: 12, name: "Otra autora", username: "otra" };
8
+ const THIRD_AUTHOR: IUserEditing = { id: 13, name: "Tercera autora", username: "tercera" };
9
+
10
+ const lockedResponse = (user: IUserEditing) => ({ status: 400, data: { code: 400, user } });
11
+
12
+ describe("sendPagePing action", () => {
13
+ const runPing = async (params: { onStart: IUserEditing | null; onAnswer: IUserEditing | null; answer: any }) => {
14
+ const dispatch = vi.fn();
15
+ const getState = vi.fn().mockReturnValue({ pageEditor: { userEditing: params.onStart } } as any) as unknown as any;
16
+
17
+ vi.spyOn(pages, "sendPagePing").mockImplementation(async () => {
18
+ // El ping sale antes de que getPage responda: el lock del store cambia
19
+ // mientras la petición está en vuelo.
20
+ getState.mockReturnValue({ pageEditor: { userEditing: params.onAnswer } });
21
+ return params.answer;
22
+ });
23
+
24
+ await pageEditorActions.sendPagePing(PAGE)(dispatch, getState);
25
+
26
+ return dispatch;
27
+ };
28
+
29
+ it("does not dispatch the author the store already has", async () => {
30
+ const dispatch = await runPing({
31
+ onStart: null,
32
+ onAnswer: OTHER_AUTHOR,
33
+ answer: lockedResponse(OTHER_AUTHOR),
34
+ });
35
+
36
+ expect(dispatch).not.toHaveBeenCalled();
37
+ });
38
+
39
+ it("dispatches the author that took the page", async () => {
40
+ const dispatch = await runPing({
41
+ onStart: OTHER_AUTHOR,
42
+ onAnswer: OTHER_AUTHOR,
43
+ answer: lockedResponse(THIRD_AUTHOR),
44
+ });
45
+
46
+ expect(dispatch).toHaveBeenCalledWith({
47
+ type: SET_USER_EDITING,
48
+ payload: { userEditing: THIRD_AUTHOR },
49
+ });
50
+ });
51
+
52
+ it("dispatches nothing while nobody holds the page", async () => {
53
+ const dispatch = await runPing({
54
+ onStart: null,
55
+ onAnswer: null,
56
+ answer: { status: 200, data: {} },
57
+ });
58
+
59
+ expect(dispatch).not.toHaveBeenCalled();
60
+ });
61
+ });
@@ -0,0 +1,92 @@
1
+ import "@testing-library/jest-dom";
2
+
3
+ import { useEditingLock } from "@ax/hooks";
4
+ import type { IUserEditing } from "@ax/types";
5
+
6
+ import { renderHook } from "@testing-library/react";
7
+
8
+ const CURRENT_USER = 7;
9
+ const OTHER_AUTHOR: IUserEditing = { id: 12, name: "Otra autora", username: "otra" };
10
+ const THIRD_AUTHOR: IUserEditing = { id: 13, name: "Tercera autora", username: "tercera" };
11
+
12
+ describe("useEditingLock hook", () => {
13
+ const renderLock = (userEditing: IUserEditing | null, callbacks: { onLocked: () => void; onUnlocked: () => void }) =>
14
+ renderHook(
15
+ ({ userEditing }) =>
16
+ useEditingLock({
17
+ userEditing,
18
+ currentUserID: CURRENT_USER,
19
+ ...callbacks,
20
+ }),
21
+ { initialProps: { userEditing } },
22
+ );
23
+
24
+ const callbacks = () => ({ onLocked: vi.fn(), onUnlocked: vi.fn() });
25
+
26
+ it("locks the editor when another author holds the page", () => {
27
+ const spies = callbacks();
28
+
29
+ const { result } = renderLock(OTHER_AUTHOR, spies);
30
+
31
+ expect(result.current.isReadOnly).toBe(true);
32
+ expect(spies.onLocked).toHaveBeenCalledTimes(1);
33
+ expect(spies.onUnlocked).not.toHaveBeenCalled();
34
+ });
35
+
36
+ it("leaves the editor open when the page is free", () => {
37
+ const spies = callbacks();
38
+
39
+ const { result } = renderLock(null, spies);
40
+
41
+ expect(result.current.isReadOnly).toBe(false);
42
+ expect(spies.onUnlocked).toHaveBeenCalledTimes(1);
43
+ expect(spies.onLocked).not.toHaveBeenCalled();
44
+ });
45
+
46
+ it("leaves the editor open when the lock is your own", () => {
47
+ const spies = callbacks();
48
+
49
+ const { result } = renderLock({ id: CURRENT_USER, name: "Yo", username: "yo" }, spies);
50
+
51
+ expect(result.current.isReadOnly).toBe(false);
52
+ expect(spies.onLocked).not.toHaveBeenCalled();
53
+ });
54
+
55
+ // El motivo del bug: getPage y el ping despachan cada uno su propio objeto
56
+ // para el mismo autor, y reaccionar a la referencia reabría el modal que la
57
+ // autora acababa de cerrar con «Preview Page».
58
+ it("does not lock again when the same author is dispatched in a new object", () => {
59
+ const spies = callbacks();
60
+
61
+ const { rerender, result } = renderLock(OTHER_AUTHOR, spies);
62
+ expect(spies.onLocked).toHaveBeenCalledTimes(1);
63
+
64
+ rerender({ userEditing: { ...OTHER_AUTHOR } });
65
+
66
+ expect(spies.onLocked).toHaveBeenCalledTimes(1);
67
+ expect(spies.onUnlocked).not.toHaveBeenCalled();
68
+ expect(result.current.isReadOnly).toBe(true);
69
+ });
70
+
71
+ it("unlocks the editor when the author leaves the page", () => {
72
+ const spies = callbacks();
73
+
74
+ const { rerender, result } = renderLock(OTHER_AUTHOR, spies);
75
+
76
+ rerender({ userEditing: null });
77
+
78
+ expect(result.current.isReadOnly).toBe(false);
79
+ expect(spies.onUnlocked).toHaveBeenCalledTimes(1);
80
+ });
81
+
82
+ it("locks again when a different author takes the page", () => {
83
+ const spies = callbacks();
84
+
85
+ const { rerender, result } = renderLock(OTHER_AUTHOR, spies);
86
+
87
+ rerender({ userEditing: THIRD_AUTHOR });
88
+
89
+ expect(result.current.isReadOnly).toBe(true);
90
+ expect(spies.onLocked).toHaveBeenCalledTimes(2);
91
+ });
92
+ });
@@ -0,0 +1,88 @@
1
+ import { Route, Router } from "react-router-dom";
2
+
3
+ import { parseTheme } from "@ax/helpers";
4
+ import FormEditor from "@ax/modules/Forms/FormEditor";
5
+ import history from "@ax/routes/history";
6
+ import globalTheme from "@ax/themes/theme.json";
7
+
8
+ import configureStore from "redux-mock-store";
9
+ import thunk from "redux-thunk";
10
+ import { ThemeProvider } from "styled-components";
11
+
12
+ import { act, cleanup, render, screen } from "../../../../../config/tests/test-utils";
13
+
14
+ // El editor real monta el panel de configuración entero. Aquí solo interesa el
15
+ // tema que le entrega FormEditor, así que el doble lo expone en el DOM.
16
+ vi.mock("@ax/modules/Forms/FormEditor/Editor", () => ({
17
+ default: ({ theme }: { theme: string }) => <div data-testid="form-editor-editor" data-theme={theme} />,
18
+ }));
19
+
20
+ // `getForm` es un thunk que pide el formulario y luego selecciona su contenido.
21
+ // Sobre un mock store el estado nunca avanza, así que la cadena rompe y deja un
22
+ // rechazo sin capturar. El tema no depende de ella: se anula y ya.
23
+ vi.mock("@ax/containers/Forms", async (importOriginal) => {
24
+ const actual = await importOriginal<typeof import("@ax/containers/Forms")>();
25
+ return {
26
+ ...actual,
27
+ formsActions: { ...actual.formsActions, getForm: () => async () => undefined },
28
+ };
29
+ });
30
+
31
+ afterEach(cleanup);
32
+
33
+ // El componentsMock declara un único tema, `1`, y ninguno marcado por defecto:
34
+ // getDefaultTheme() cae al primero de la lista.
35
+ const DEFAULT_THEME = "1";
36
+ const SITE_THEME = "griddo-alt-theme";
37
+
38
+ const createMockStore = (currentSiteInfo: Record<string, unknown> | null) => {
39
+ const mockStore = configureStore([thunk] as any);
40
+ return mockStore({
41
+ app: {
42
+ isSaving: false,
43
+ isLoading: false,
44
+ lang: { locale: "en-US", id: 1 },
45
+ globalLangs: [],
46
+ globalSettings: {},
47
+ },
48
+ forms: {
49
+ currentFormID: null,
50
+ formContent: null,
51
+ isNewTranslation: false,
52
+ selectedEditorID: null,
53
+ selectedContent: {},
54
+ template: "",
55
+ moduleCopy: null,
56
+ },
57
+ sites: { currentSiteInfo, currentSiteLanguages: [], currentSitePages: [] },
58
+ users: { currentUser: null, currentPermissions: [], globalPermissions: [], roles: [] },
59
+ });
60
+ };
61
+
62
+ const renderEditor = async (currentSiteInfo: Record<string, unknown> | null) => {
63
+ const store = createMockStore(currentSiteInfo);
64
+
65
+ await act(async () => {
66
+ render(
67
+ <Router history={history}>
68
+ <ThemeProvider theme={parseTheme(globalTheme)}>
69
+ {/* El editor está conectado y espera las props de ruta; la Route se las da. */}
70
+ <Route component={FormEditor} />
71
+ </ThemeProvider>
72
+ </Router>,
73
+ { store },
74
+ );
75
+ });
76
+
77
+ return screen.getByTestId("form-editor-editor").getAttribute("data-theme");
78
+ };
79
+
80
+ describe("FormEditor theme", () => {
81
+ it("should use the theme of the current site", async () => {
82
+ expect(await renderEditor({ id: 1, theme: SITE_THEME })).toBe(SITE_THEME);
83
+ });
84
+
85
+ it("should fall back to the default theme on global forms", async () => {
86
+ expect(await renderEditor(null)).toBe(DEFAULT_THEME);
87
+ });
88
+ });
@@ -5,9 +5,10 @@ import type { IUserEditing } from "@ax/types";
5
5
 
6
6
  import * as S from "./style";
7
7
 
8
+ const tabs = ["content"];
9
+
8
10
  const PreviewForm = (props: IPreviewFormProps): JSX.Element => {
9
11
  const { selectedTab, setSelectedTab, userEditing } = props;
10
- const tabs = ["content"];
11
12
 
12
13
  const noteText = `${
13
14
  userEditing && userEditing.name
@@ -49,7 +49,7 @@ const Password = styled.div`
49
49
  width: 100%;
50
50
  text-align: right;
51
51
 
52
- span {
52
+ button {
53
53
  cursor: pointer;
54
54
  }
55
55
  `;
@@ -6,10 +6,14 @@ import * as S from "./style";
6
6
  const Tabs = (props: ITabsProps): JSX.Element => {
7
7
  const { tabs, icons, active, setSelectedTab, isInAppBar, noMargins, inversed, disabled } = props;
8
8
 
9
+ // Depend on the tab itself, not on the array: callers that build the array inline
10
+ // give a new reference on each render and would re-run the effect every time.
11
+ const onlyTab = tabs && tabs.length === 1 ? tabs[0] : undefined;
12
+
9
13
  // biome-ignore lint/correctness/useExhaustiveDependencies: setSelectedTab is stable and should not trigger re-renders
10
14
  useEffect(() => {
11
- tabs && tabs.length === 1 && setSelectedTab(tabs[0]);
12
- }, [tabs]);
15
+ onlyTab !== undefined && setSelectedTab(onlyTab);
16
+ }, [onlyTab]);
13
17
 
14
18
  if (tabs) {
15
19
  return (
@@ -1477,14 +1477,18 @@ function getGlobalFromLocalPage(): (dispatch: Dispatch, getState: any) => Promis
1477
1477
  function sendPagePing(pageID: number): (dispatch: Dispatch, getState: () => IRootState) => Promise<boolean> {
1478
1478
  return async (dispatch, getState) => {
1479
1479
  try {
1480
- const {
1481
- pageEditor: { userEditing },
1482
- } = getState();
1483
-
1484
1480
  const responseActions = {
1485
1481
  handleSuccess: () => null,
1482
+ // sc-119017: the state is read when the answer lands, not when the ping leaves:
1483
+ // the first ping of the editor starts before getPage has resolved, so
1484
+ // a lock read here would still be null and this would dispatch a
1485
+ // second object for the same author. That dispatch is what reopened
1486
+ // the "page being edited" modal after the author dismissed it.
1486
1487
  handleError: (response: any) => {
1487
1488
  const { data } = response;
1489
+ const {
1490
+ pageEditor: { userEditing },
1491
+ } = getState();
1488
1492
  if (data.code === 400 && (!userEditing || userEditing.id !== data.user.id)) {
1489
1493
  dispatch(setUserEditing(data.user));
1490
1494
  }
@@ -0,0 +1,45 @@
1
+ import { useEffect } from "react";
2
+
3
+ import type { IUserEditing } from "@ax/types";
4
+
5
+ /**
6
+ * Puts the editor in read only while another author holds the page, and tells
7
+ * the caller when to open and to close the modal that names that author.
8
+ *
9
+ * sc-119017: the effect watches the author's **id**, not the object. The
10
+ * store gets a new object for the same author more than once per page: getPage
11
+ * dispatches `setUserEditing(page.editing)` and the lock ping dispatches
12
+ * `setUserEditing(data.user)` on its 400, and both are in flight together when
13
+ * the editor mounts. Watching the reference reopened the modal that the author
14
+ * had already dismissed, because the guard of the effect read a stale `isOpen`
15
+ * from the render that opened it — the second dispatch looked like a new lock.
16
+ * With the id as the dependency, a repeated dispatch for the same author is a
17
+ * no-op and the modal stays closed.
18
+ *
19
+ * `onLocked` and `onUnlocked` are not dependencies on purpose: React keeps the
20
+ * closure of the last render, so both callbacks are always the current ones,
21
+ * and adding them would run the effect on every render.
22
+ */
23
+ const useEditingLock = (params: {
24
+ userEditing: IUserEditing | null;
25
+ currentUserID: number | null;
26
+ onLocked: () => void;
27
+ onUnlocked: () => void;
28
+ }): { isReadOnly: boolean } => {
29
+ const { userEditing, currentUserID, onLocked, onUnlocked } = params;
30
+
31
+ const lockedByOtherUserID = userEditing && userEditing.id !== currentUserID ? userEditing.id : null;
32
+
33
+ // biome-ignore lint/correctness/useExhaustiveDependencies: see the note above
34
+ useEffect(() => {
35
+ if (lockedByOtherUserID) {
36
+ onLocked();
37
+ } else {
38
+ onUnlocked();
39
+ }
40
+ }, [lockedByOtherUserID]);
41
+
42
+ return { isReadOnly: lockedByOtherUserID !== null };
43
+ };
44
+
45
+ export { useEditingLock };
@@ -1,6 +1,7 @@
1
1
  import { useBroadcastChannel, useBroadcastClipboard, useBroadcastContentUpdate, useBroadcastLogout } from "./broadcast";
2
2
  import { type IBulkSelectedItems, useBulkSelection } from "./bulk";
3
3
  import { useAdaptiveText, useCategoryColors, useEmptyState } from "./content";
4
+ import { useEditingLock } from "./editingLock";
4
5
  import {
5
6
  useDebounce,
6
7
  useDebouncedCallback,
@@ -36,6 +37,7 @@ export {
36
37
  useDebounce,
37
38
  useDebouncedCallback,
38
39
  useEditingHeartbeat,
40
+ useEditingLock,
39
41
  useEmptyState,
40
42
  useEqualStructured,
41
43
  useFirefoxScrollLock,
@@ -98,7 +98,9 @@ const FormEditor = (props: IProps) => {
98
98
  );
99
99
 
100
100
  const backLinkRoute = isSiteView ? "/sites/forms" : "/forms";
101
- const theme = getDefaultTheme();
101
+ // El tema manda en los campos temáticos (VisualUniqueSelection, ColorPicker) y en las
102
+ // miniaturas: en un site vale el suyo, y solo los formularios globales caen al principal.
103
+ const theme = currentSiteInfo?.theme || getDefaultTheme();
102
104
 
103
105
  // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: fix this
104
106
  useEffect(() => {
@@ -30,6 +30,7 @@ import {
30
30
  useBroadcastClipboard,
31
31
  useBroadcastContentUpdate,
32
32
  useEditingHeartbeat,
33
+ useEditingLock,
33
34
  useIsDirty,
34
35
  useModals,
35
36
  usePermissionsForPage,
@@ -106,7 +107,6 @@ const GlobalEditor = (props: IProps) => {
106
107
  const defaultTab = isAllowedTo.editContentPages ? "edit" : "view";
107
108
 
108
109
  const [deleteAllVersions, setDeleteAllVersions] = useState(false);
109
- const [isReadOnly, setIsReadOnly] = useState(false);
110
110
  const [selectedTab, setSelectedTab] = useState(defaultTab);
111
111
  const [notification, setNotification] = useState<INotification | null>(null);
112
112
  const [toastMsg, setToastMsg] = useState<string | null>(null);
@@ -125,7 +125,7 @@ const GlobalEditor = (props: IProps) => {
125
125
  const { broadcastPageSave, remotePageUpdate, clearRemoteUpdate } = useBroadcastContentUpdate(pageID);
126
126
  useBroadcastClipboard();
127
127
 
128
- const { isOpen, toggleModal, closeModal } = useModals([
128
+ const { isOpen, toggleModal, openModal, closeModal } = useModals([
129
129
  "userEditing",
130
130
  "unpublish",
131
131
  "schedule",
@@ -139,6 +139,16 @@ const GlobalEditor = (props: IProps) => {
139
139
  "conflictSave",
140
140
  ]);
141
141
 
142
+ const { isReadOnly } = useEditingLock({
143
+ userEditing,
144
+ currentUserID: props.currentUserID,
145
+ onLocked: () => openModal("userEditing"),
146
+ onUnlocked: () => {
147
+ pageID && sendPagePing(pageID);
148
+ closeModal("userEditing");
149
+ },
150
+ });
151
+
142
152
  const { isDirty, setIsDirty, resetDirty } = useIsDirty(editorContent, isNewTranslation);
143
153
 
144
154
  const isPublished = props.pageStatus === pageStatus.PUBLISHED || props.pageStatus === pageStatus.UPLOAD_PENDING;
@@ -207,19 +217,6 @@ const GlobalEditor = (props: IProps) => {
207
217
  resetDirty();
208
218
  }, [lang]);
209
219
 
210
- // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: fix this
211
- useEffect(() => {
212
- const { currentUserID } = props;
213
- if (userEditing && userEditing.id !== currentUserID) {
214
- setIsReadOnly(true);
215
- !isOpen("userEditing") && toggleModal("userEditing");
216
- } else {
217
- setIsReadOnly(false);
218
- pageID && sendPagePing(pageID);
219
- isOpen("userEditing") && toggleModal("userEditing");
220
- }
221
- }, [userEditing]);
222
-
223
220
  // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: fix this
224
221
  useEffect(() => {
225
222
  if (!errorPagesChecked && !isLoading && editorContent) {
@@ -28,6 +28,7 @@ import {
28
28
  useBroadcastClipboard,
29
29
  useBroadcastContentUpdate,
30
30
  useEditingHeartbeat,
31
+ useEditingLock,
31
32
  useIsDirty,
32
33
  useModals,
33
34
  usePermissionsForPage,
@@ -111,7 +112,6 @@ const PageEditor = (props: IProps) => {
111
112
  const defaultTab = isAllowedTo.editContentPages ? "edit" : "view";
112
113
 
113
114
  const [deleteAllVersions, setDeleteAllVersions] = useState(false);
114
- const [isReadOnly, setIsReadOnly] = useState(false);
115
115
  const [selectedTab, setSelectedTab] = useState(defaultTab);
116
116
  const [notification, setNotification] = useState<INotification | null>(null);
117
117
  const [toastMsg, setToastMsg] = useState<string | null>(null);
@@ -127,7 +127,7 @@ const PageEditor = (props: IProps) => {
127
127
  const { broadcastPageSave, remotePageUpdate, clearRemoteUpdate } = useBroadcastContentUpdate(pageID);
128
128
  useBroadcastClipboard();
129
129
 
130
- const { isOpen, toggleModal, closeModal } = useModals([
130
+ const { isOpen, toggleModal, openModal, closeModal } = useModals([
131
131
  "userEditing",
132
132
  "unpublish",
133
133
  "delete",
@@ -141,6 +141,16 @@ const PageEditor = (props: IProps) => {
141
141
  "conflictSave",
142
142
  ]);
143
143
 
144
+ const { isReadOnly } = useEditingLock({
145
+ userEditing,
146
+ currentUserID: props.currentUserID,
147
+ onLocked: () => openModal("userEditing"),
148
+ onUnlocked: () => {
149
+ pageID && sendPagePing(pageID);
150
+ closeModal("userEditing");
151
+ },
152
+ });
153
+
144
154
  const { isDirty, setIsDirty, resetDirty } = useIsDirty(editorContent, isNewTranslation);
145
155
 
146
156
  const isGlobal = editorContent && editorContent.origin === "GLOBAL";
@@ -212,19 +222,6 @@ const PageEditor = (props: IProps) => {
212
222
  setSelectedTab(defaultTab);
213
223
  }, [defaultTab]);
214
224
 
215
- // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: fix this
216
- useEffect(() => {
217
- const { currentUserID } = props;
218
- if (userEditing && userEditing.id !== currentUserID) {
219
- setIsReadOnly(true);
220
- !isOpen("userEditing") && toggleModal("userEditing");
221
- } else {
222
- setIsReadOnly(false);
223
- pageID && sendPagePing(pageID);
224
- isOpen("userEditing") && toggleModal("userEditing");
225
- }
226
- }, [userEditing]);
227
-
228
225
  // biome-ignore lint/correctness/useExhaustiveDependencies: TODO: fix this
229
226
  useEffect(() => {
230
227
  if (!errorPagesChecked && !isLoading && editorContent) {