@griddo/ax 12.8.2 → 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/Content/Content.test.tsx +263 -0
- package/src/__tests__/modules/Content/hooks.test.tsx +42 -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/Content/hooks.tsx +16 -3
- package/src/modules/Content/index.tsx +10 -27
- 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,263 @@
|
|
|
1
|
+
import { Router } from "react-router-dom";
|
|
2
|
+
|
|
3
|
+
import { parseTheme } from "@ax/helpers";
|
|
4
|
+
import Content from "@ax/modules/Content";
|
|
5
|
+
import { history } from "@ax/routes";
|
|
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
|
+
import { mock } from "vitest-mock-extended";
|
|
12
|
+
|
|
13
|
+
import { act, cleanup, fireEvent, render, screen } from "./../../../../config/tests/test-utils";
|
|
14
|
+
|
|
15
|
+
const bulkDelete = vi.fn();
|
|
16
|
+
const getSitePages = vi.fn();
|
|
17
|
+
|
|
18
|
+
vi.mock("@ax/api", async (importOriginal) => {
|
|
19
|
+
const actual = (await importOriginal()) as any;
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
...actual,
|
|
23
|
+
pages: {
|
|
24
|
+
...actual.pages,
|
|
25
|
+
bulkDelete: (...args: any[]) => bulkDelete(...args),
|
|
26
|
+
getSitePages: (...args: any[]) => getSitePages(...args),
|
|
27
|
+
},
|
|
28
|
+
};
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
// El mock global de `components` (config/tests/componentsMock.cjs) trae una plantilla
|
|
32
|
+
// `default` sin `type` que no existe en el catálogo real. getTemplatesFilters lee
|
|
33
|
+
// `type.value` de cada plantilla, así que esa entrada rompe el montaje del listado.
|
|
34
|
+
vi.mock("components", async (importOriginal) => {
|
|
35
|
+
const actual = (await importOriginal()) as any;
|
|
36
|
+
const source = actual.config ? actual : actual.default;
|
|
37
|
+
const { default: _defaultTemplate, ...templates } = source.config.schemas.ui.templates;
|
|
38
|
+
|
|
39
|
+
return {
|
|
40
|
+
...source,
|
|
41
|
+
config: {
|
|
42
|
+
...source.config,
|
|
43
|
+
schemas: {
|
|
44
|
+
...source.config.schemas,
|
|
45
|
+
ui: { ...source.config.schemas.ui, templates },
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
afterEach(cleanup);
|
|
52
|
+
|
|
53
|
+
const buildPage = (id: number) => ({
|
|
54
|
+
id,
|
|
55
|
+
editorID: id,
|
|
56
|
+
title: `Page ${id}`,
|
|
57
|
+
slug: `/page-${id}`,
|
|
58
|
+
fullPath: { site: "/site-1", page: `/page-${id}` },
|
|
59
|
+
templateId: "BasicTemplate",
|
|
60
|
+
origin: "SITE",
|
|
61
|
+
isHome: false,
|
|
62
|
+
canBeUnpublished: true,
|
|
63
|
+
manuallyImported: false,
|
|
64
|
+
structuredDataContent: null,
|
|
65
|
+
publicationScheduled: null,
|
|
66
|
+
accessGrants: null,
|
|
67
|
+
haveDraftPage: null,
|
|
68
|
+
liveStatus: { status: "offline" },
|
|
69
|
+
pageLanguages: [
|
|
70
|
+
{ pageId: id, languageId: 4, locale: "en_GB" },
|
|
71
|
+
{ pageId: id + 100, languageId: 2, locale: "es_ES" },
|
|
72
|
+
],
|
|
73
|
+
modified: "2026-08-01T10:00:00.000Z",
|
|
74
|
+
categories: [],
|
|
75
|
+
isIndexed: true,
|
|
76
|
+
metaTitle: "",
|
|
77
|
+
metaDescription: "",
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const pages = [1, 2, 3, 4, 5].map(buildPage);
|
|
81
|
+
const language = { id: 4, locale: "en_GB", language: "English", label: "EN", isDefault: true };
|
|
82
|
+
|
|
83
|
+
const initialStore = {
|
|
84
|
+
app: {
|
|
85
|
+
isLoading: false,
|
|
86
|
+
isSaving: false,
|
|
87
|
+
lang: { id: 4, locale: "en_GB" },
|
|
88
|
+
globalLangs: [language],
|
|
89
|
+
globalSettings: { skipReviewOnPublish: false },
|
|
90
|
+
error: null,
|
|
91
|
+
token: "eyJhbGciOiJIUzI",
|
|
92
|
+
},
|
|
93
|
+
sites: {
|
|
94
|
+
currentSiteInfo: { id: 1, name: "Site 1", hidden: false },
|
|
95
|
+
currentSitePages: pages,
|
|
96
|
+
allSitePages: pages,
|
|
97
|
+
currentFilter: "unique-pages",
|
|
98
|
+
totalItems: pages.length,
|
|
99
|
+
currentSiteLanguages: [language],
|
|
100
|
+
currentSiteErrorPages: [],
|
|
101
|
+
sitesByLang: [],
|
|
102
|
+
contentFilters: null,
|
|
103
|
+
currentSearch: "",
|
|
104
|
+
},
|
|
105
|
+
pageEditor: { template: null, currentPageLanguages: [], errors: [] },
|
|
106
|
+
structuredData: {
|
|
107
|
+
structuredData: { global: [], site: [] },
|
|
108
|
+
currentDataContent: [],
|
|
109
|
+
currentStructuredData: null,
|
|
110
|
+
},
|
|
111
|
+
dataPacks: { activated: [], templates: [] },
|
|
112
|
+
users: {
|
|
113
|
+
currentUser: { id: 1, isSuperAdmin: true },
|
|
114
|
+
currentPermissions: [],
|
|
115
|
+
globalPermissions: [],
|
|
116
|
+
roles: [],
|
|
117
|
+
},
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
// El store se lee de una variable mutable para poder simular el paginador: cambiar
|
|
121
|
+
// de página sustituye `currentSitePages` sin desmontar el componente, que es lo que
|
|
122
|
+
// hace la app de verdad. `redux-mock-store` admite una función como estado.
|
|
123
|
+
const renderContent = async (visiblePages = pages) => {
|
|
124
|
+
let state = { ...initialStore, sites: { ...initialStore.sites, currentSitePages: visiblePages } };
|
|
125
|
+
const store = configureStore([thunk])(() => state);
|
|
126
|
+
|
|
127
|
+
await act(async () => {
|
|
128
|
+
render(
|
|
129
|
+
<Router history={history}>
|
|
130
|
+
<ThemeProvider theme={parseTheme(globalTheme)}>
|
|
131
|
+
<Content {...mock<any>()} />
|
|
132
|
+
</ThemeProvider>
|
|
133
|
+
</Router>,
|
|
134
|
+
{ store },
|
|
135
|
+
);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const goToPageWith = async (nextPages: ReturnType<typeof buildPage>[]) => {
|
|
139
|
+
state = { ...state, sites: { ...state.sites, currentSitePages: nextPages } };
|
|
140
|
+
await act(async () => {
|
|
141
|
+
store.dispatch({ type: "@@test/PAGINATE" });
|
|
142
|
+
});
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
return { store, goToPageWith };
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const checkboxOf = (pageId: number) =>
|
|
149
|
+
screen
|
|
150
|
+
.getAllByTestId("check-field-input")
|
|
151
|
+
.find((input) => input.getAttribute("value") === String(pageId)) as HTMLElement;
|
|
152
|
+
|
|
153
|
+
const selectPage = async (pageId: number) => {
|
|
154
|
+
await act(async () => {
|
|
155
|
+
fireEvent.click(checkboxOf(pageId));
|
|
156
|
+
});
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const selectRangeTo = async (pageId: number) => {
|
|
160
|
+
await act(async () => {
|
|
161
|
+
fireEvent.keyDown(window, { key: "Shift" });
|
|
162
|
+
});
|
|
163
|
+
await selectPage(pageId);
|
|
164
|
+
await act(async () => {
|
|
165
|
+
fireEvent.keyUp(window, { key: "Shift" });
|
|
166
|
+
});
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const confirmBulkDelete = async (allLanguages = false) => {
|
|
170
|
+
await act(async () => {
|
|
171
|
+
fireEvent.click(screen.getByText("delete"));
|
|
172
|
+
});
|
|
173
|
+
if (allLanguages) {
|
|
174
|
+
await act(async () => {
|
|
175
|
+
fireEvent.click(screen.getByText("Delete all languages versions"));
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
await act(async () => {
|
|
179
|
+
fireEvent.click(screen.getByText("Delete page"));
|
|
180
|
+
});
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
describe("[UC-AX-explore-manage-pages] Content — borrado en bulk", () => {
|
|
184
|
+
beforeEach(() => {
|
|
185
|
+
bulkDelete.mockResolvedValue({ status: 200, data: {} });
|
|
186
|
+
getSitePages.mockResolvedValue({ status: 200, data: { items: [], totalItems: 0 } });
|
|
187
|
+
window.HTMLElement.prototype.scrollTo = vi.fn();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("borra todas las páginas de un rango seleccionado con Shift+click", async () => {
|
|
191
|
+
await renderContent();
|
|
192
|
+
|
|
193
|
+
await selectPage(1);
|
|
194
|
+
await selectRangeTo(5);
|
|
195
|
+
|
|
196
|
+
// El contador de la barra y el borrado tienen que ver lo mismo.
|
|
197
|
+
expect(screen.getByText("5 selected")).toBeInTheDocument();
|
|
198
|
+
|
|
199
|
+
await confirmBulkDelete();
|
|
200
|
+
|
|
201
|
+
expect(bulkDelete).toHaveBeenCalledTimes(1);
|
|
202
|
+
expect(bulkDelete.mock.calls[0][0]).toEqual([1, 2, 3, 4, 5]);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
it("con 'all languages versions' borra las versiones de todo el rango", async () => {
|
|
206
|
+
await renderContent();
|
|
207
|
+
|
|
208
|
+
await selectPage(1);
|
|
209
|
+
await selectRangeTo(5);
|
|
210
|
+
|
|
211
|
+
await confirmBulkDelete(true);
|
|
212
|
+
|
|
213
|
+
expect(bulkDelete).toHaveBeenCalledTimes(1);
|
|
214
|
+
// Cada página lleva su versión en inglés (id) y en español (id + 100).
|
|
215
|
+
expect([...bulkDelete.mock.calls[0][0]].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5, 101, 102, 103, 104, 105]);
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it("borra las páginas seleccionadas en otra página del paginador", async () => {
|
|
219
|
+
const firstPage = pages.slice(0, 3);
|
|
220
|
+
const secondPage = [6, 7, 8].map(buildPage);
|
|
221
|
+
const { goToPageWith } = await renderContent(firstPage);
|
|
222
|
+
|
|
223
|
+
await selectPage(1);
|
|
224
|
+
await goToPageWith(secondPage);
|
|
225
|
+
await selectPage(7);
|
|
226
|
+
|
|
227
|
+
await confirmBulkDelete();
|
|
228
|
+
|
|
229
|
+
expect(bulkDelete).toHaveBeenCalledTimes(1);
|
|
230
|
+
expect(bulkDelete.mock.calls[0][0]).toEqual([1, 7]);
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it("con 'all languages versions' incluye las versiones de las páginas fuera de vista", async () => {
|
|
234
|
+
const firstPage = pages.slice(0, 3);
|
|
235
|
+
const secondPage = [6, 7, 8].map(buildPage);
|
|
236
|
+
const { goToPageWith } = await renderContent(firstPage);
|
|
237
|
+
|
|
238
|
+
await selectPage(1);
|
|
239
|
+
await goToPageWith(secondPage);
|
|
240
|
+
await selectPage(7);
|
|
241
|
+
|
|
242
|
+
await confirmBulkDelete(true);
|
|
243
|
+
|
|
244
|
+
expect(bulkDelete).toHaveBeenCalledTimes(1);
|
|
245
|
+
// La página 1 ya no está a la vista, pero sus dos versiones tienen que caer igual.
|
|
246
|
+
expect([...bulkDelete.mock.calls[0][0]].sort((a, b) => a - b)).toEqual([1, 7, 101, 107]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
it("borra todas las páginas seleccionadas con clics sueltos", async () => {
|
|
250
|
+
await renderContent();
|
|
251
|
+
|
|
252
|
+
await selectPage(1);
|
|
253
|
+
await selectPage(3);
|
|
254
|
+
await selectPage(5);
|
|
255
|
+
|
|
256
|
+
expect(screen.getByText("3 selected")).toBeInTheDocument();
|
|
257
|
+
|
|
258
|
+
await confirmBulkDelete();
|
|
259
|
+
|
|
260
|
+
expect(bulkDelete).toHaveBeenCalledTimes(1);
|
|
261
|
+
expect(bulkDelete.mock.calls[0][0]).toEqual([1, 3, 5]);
|
|
262
|
+
});
|
|
263
|
+
});
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { useSelectedPages } from "@ax/modules/Content/hooks";
|
|
2
|
+
import type { IPage } from "@ax/types";
|
|
3
|
+
|
|
4
|
+
import { renderHook } from "@testing-library/react";
|
|
5
|
+
|
|
6
|
+
const buildPage = (id: number, languages = 1) =>
|
|
7
|
+
({
|
|
8
|
+
id,
|
|
9
|
+
title: `Page ${id}`,
|
|
10
|
+
pageLanguages: Array.from({ length: languages }, (_, index) => ({ pageId: id + index * 100 })),
|
|
11
|
+
}) as unknown as IPage;
|
|
12
|
+
|
|
13
|
+
describe("[UC-AX-explore-manage-pages] useSelectedPages", () => {
|
|
14
|
+
it("resuelve una página por cada id seleccionado", () => {
|
|
15
|
+
const visiblePages = [buildPage(1), buildPage(2), buildPage(3)];
|
|
16
|
+
|
|
17
|
+
const { result } = renderHook(() => useSelectedPages([1, 3], visiblePages));
|
|
18
|
+
|
|
19
|
+
expect(result.current.map((page) => page.id)).toEqual([1, 3]);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("recuerda las páginas seleccionadas en otra página del paginador", () => {
|
|
23
|
+
const firstPage = [buildPage(1), buildPage(2)];
|
|
24
|
+
const secondPage = [buildPage(3), buildPage(4)];
|
|
25
|
+
|
|
26
|
+
const { result, rerender } = renderHook(
|
|
27
|
+
({ selectedIds, visiblePages }) => useSelectedPages(selectedIds, visiblePages),
|
|
28
|
+
{ initialProps: { selectedIds: [1], visiblePages: firstPage } },
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
// El usuario pasa a la siguiente página del paginador y sigue seleccionando.
|
|
32
|
+
rerender({ selectedIds: [1, 3], visiblePages: secondPage });
|
|
33
|
+
|
|
34
|
+
expect(result.current.map((page) => page.id)).toEqual([1, 3]);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
it("descarta los ids que no corresponden a ninguna página conocida", () => {
|
|
38
|
+
const { result } = renderHook(() => useSelectedPages([1, 999], [buildPage(1)]));
|
|
39
|
+
|
|
40
|
+
expect(result.current.map((page) => page.id)).toEqual([1]);
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -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,
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { useState } from "react";
|
|
2
|
-
|
|
1
|
+
import { useRef, useState } from "react";
|
|
2
|
+
|
|
3
|
+
import type { IPage, IQueryValue } from "@ax/types";
|
|
3
4
|
|
|
4
5
|
const useSortedListStatus = () => {
|
|
5
6
|
const sortedInitialState: {
|
|
@@ -106,4 +107,16 @@ const useFilterQuery = (values?: Record<string, IQueryValue[]> | null) => {
|
|
|
106
107
|
};
|
|
107
108
|
};
|
|
108
109
|
|
|
109
|
-
|
|
110
|
+
const useSelectedPages = (selectedIds: number[], visiblePages: IPage[]): IPage[] => {
|
|
111
|
+
// La selección sobrevive al cambio de página del paginador, así que hay que
|
|
112
|
+
// recordar las páginas que ya no están a la vista para poder resolverlas.
|
|
113
|
+
const knownPages = useRef(new Map<number, IPage>());
|
|
114
|
+
|
|
115
|
+
visiblePages.forEach((page) => {
|
|
116
|
+
knownPages.current.set(page.id, page);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
return selectedIds.map((id) => knownPages.current.get(id)).filter((page): page is IPage => !!page);
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
export { useSortedListStatus, useFilterQuery, useSelectedPages };
|
|
@@ -58,7 +58,7 @@ import StructuredDataItem from "./../StructuredData/StructuredDataList/Structure
|
|
|
58
58
|
import { DeleteModal } from "./atoms";
|
|
59
59
|
import BulkHeader from "./BulkHeader";
|
|
60
60
|
import ContentFilters from "./ContentFilters";
|
|
61
|
-
import { useFilterQuery, useSortedListStatus } from "./hooks";
|
|
61
|
+
import { useFilterQuery, useSelectedPages, useSortedListStatus } from "./hooks";
|
|
62
62
|
import NewContentModal from "./NewContentModal";
|
|
63
63
|
import PageItem from "./PageItem";
|
|
64
64
|
import {
|
|
@@ -194,7 +194,6 @@ const Content = (props: IProps): JSX.Element => {
|
|
|
194
194
|
const [deleteAllVersions, setDeleteAllVersions] = useState(false);
|
|
195
195
|
const [arePagesTranslated, setArePagesTranslated] = useState(false);
|
|
196
196
|
const [templateInstanceError, setTemplateInstanceError] = useState({ error: false, templateName: "" });
|
|
197
|
-
const [pagesSelected, setPagesSelected] = useState<any[]>([]);
|
|
198
197
|
const [selectedOption, setSelectedOption] = useState<string | null>(null);
|
|
199
198
|
const history = useHistory();
|
|
200
199
|
|
|
@@ -220,6 +219,8 @@ const Content = (props: IProps): JSX.Element => {
|
|
|
220
219
|
setHoverCheck,
|
|
221
220
|
} = useBulkSelection(contentIds);
|
|
222
221
|
|
|
222
|
+
const pagesSelected = useSelectedPages(selectedItems.all, currentSitePages);
|
|
223
|
+
|
|
223
224
|
const { isVisible, toggleToast, setIsVisible } = useToast();
|
|
224
225
|
const {
|
|
225
226
|
isVisible: isVisibleRemovedToast,
|
|
@@ -390,25 +391,11 @@ const Content = (props: IProps): JSX.Element => {
|
|
|
390
391
|
|
|
391
392
|
const bulkFilter = (bulkSelection: number[]) => filterByStatus(bulkSelection, currentSitePages);
|
|
392
393
|
|
|
393
|
-
const handleAddToBulk = (item: ICheck) =>
|
|
394
|
-
const page = currentSitePages.find((page: IPage) => page.id === item.value);
|
|
394
|
+
const handleAddToBulk = (item: ICheck) => addToBulkSelection(item, bulkFilter);
|
|
395
395
|
|
|
396
|
-
|
|
397
|
-
? setPagesSelected((pagesSelected) => [...pagesSelected, page])
|
|
398
|
-
: setPagesSelected(pagesSelected.filter((page: IPage) => page.id !== item.value));
|
|
396
|
+
const handleSelectAll = () => selectAllItems(bulkFilter);
|
|
399
397
|
|
|
400
|
-
|
|
401
|
-
};
|
|
402
|
-
|
|
403
|
-
const handleSelectAll = () => {
|
|
404
|
-
selectAllItems(bulkFilter);
|
|
405
|
-
setPagesSelected(isStructuredData ? currentDataContent : currentSitePages);
|
|
406
|
-
};
|
|
407
|
-
|
|
408
|
-
const unselectAllItems = () => {
|
|
409
|
-
resetBulkSelection();
|
|
410
|
-
setPagesSelected([]);
|
|
411
|
-
};
|
|
398
|
+
const unselectAllItems = () => resetBulkSelection();
|
|
412
399
|
|
|
413
400
|
const selectItems = () => (checkState.isAllSelected ? unselectAllItems() : handleSelectAll());
|
|
414
401
|
|
|
@@ -485,12 +472,9 @@ const Content = (props: IProps): JSX.Element => {
|
|
|
485
472
|
const bulkDelete = async () => {
|
|
486
473
|
let allPageVersions: number[] = [];
|
|
487
474
|
if (deleteAllVersions) {
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
const ids = element.map((lang: IPageLanguage) => lang.pageId);
|
|
492
|
-
allPageVersions = [...allPageVersions, ...ids];
|
|
493
|
-
});
|
|
475
|
+
allPageVersions = pagesSelected.flatMap((page: IPage) =>
|
|
476
|
+
page.pageLanguages.map((lang: IPageLanguage) => lang.pageId),
|
|
477
|
+
);
|
|
494
478
|
}
|
|
495
479
|
|
|
496
480
|
isStructuredData
|
|
@@ -511,8 +495,7 @@ const Content = (props: IProps): JSX.Element => {
|
|
|
511
495
|
};
|
|
512
496
|
|
|
513
497
|
const handleToggleDeleteModal = () => {
|
|
514
|
-
const
|
|
515
|
-
const hasTranslations = selectedPages.some((page) => page.pageLanguages.length > 1);
|
|
498
|
+
const hasTranslations = pagesSelected.some((page: IPage) => page.pageLanguages.length > 1);
|
|
516
499
|
setArePagesTranslated(hasTranslations);
|
|
517
500
|
toggleDeleteModal();
|
|
518
501
|
};
|
|
@@ -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) {
|