@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@griddo/ax",
3
3
  "description": "Griddo Author Experience",
4
- "version": "12.8.2",
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": "00a702fad2cd346b673d8454d818d9e0e9d470f3"
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,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
@@ -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,
@@ -1,5 +1,6 @@
1
- import { useState } from "react";
2
- import type { IQueryValue } from "@ax/types";
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
- export { useSortedListStatus, useFilterQuery };
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
- item.isChecked && !pagesSelected.includes(item)
397
- ? setPagesSelected((pagesSelected) => [...pagesSelected, page])
398
- : setPagesSelected(pagesSelected.filter((page: IPage) => page.id !== item.value));
396
+ const handleSelectAll = () => selectAllItems(bulkFilter);
399
397
 
400
- addToBulkSelection(item, bulkFilter);
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
- const selectedPages = currentSitePages.filter((page) => selectedItems.all.includes(page.id));
489
- const pageLanguages = selectedPages.map((element) => element.pageLanguages);
490
- pageLanguages.forEach((element) => {
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 selectedPages = currentSitePages.filter((page) => selectedItems.all.includes(page.id));
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
- 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) {