@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 +2 -2
- package/src/Style/index.tsx +1 -0
- package/src/__tests__/components/Fields/VisualUniqueSelection/VisualUniqueSelection.test.tsx +24 -0
- package/src/__tests__/components/Tabs/Tabs.test.tsx +47 -0
- package/src/__tests__/containers/PageEditor/sendPagePing.test.ts +61 -0
- package/src/__tests__/hooks/editingLock.test.tsx +92 -0
- package/src/__tests__/modules/Forms/FormEditor/FormEditor.test.tsx +88 -0
- package/src/components/ConfigPanel/PreviewForm/index.tsx +2 -1
- package/src/components/Login/LoginForm/style.tsx +1 -1
- package/src/components/Tabs/index.tsx +6 -2
- package/src/containers/PageEditor/actions.tsx +8 -4
- package/src/hooks/editingLock.ts +45 -0
- package/src/hooks/index.tsx +2 -0
- package/src/modules/Forms/FormEditor/index.tsx +3 -1
- package/src/modules/GlobalEditor/index.tsx +12 -15
- package/src/modules/PageEditor/index.tsx +12 -15
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.
|
|
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": "
|
|
204
|
+
"gitHead": "3a207a967d89225272c5760824e1138d0b01b926"
|
|
205
205
|
}
|
package/src/Style/index.tsx
CHANGED
package/src/__tests__/components/Fields/VisualUniqueSelection/VisualUniqueSelection.test.tsx
CHANGED
|
@@ -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
|
|
@@ -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
|
-
|
|
12
|
-
}, [
|
|
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 };
|
package/src/hooks/index.tsx
CHANGED
|
@@ -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
|
-
|
|
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) {
|