@griddo/ax 12.10.5 → 12.11.1-rc.0

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.
Files changed (33) hide show
  1. package/README.md +0 -2
  2. package/config/webpack.config.js +2 -1
  3. package/config/webpackSchemas.config.js +7 -4
  4. package/package.json +31 -56
  5. package/src/__tests__/components/Fields/ConditionalField/ConditionalField.test.tsx +17 -0
  6. package/src/__tests__/components/Fields/RadioField/RadioField.test.tsx +66 -0
  7. package/src/__tests__/components/Fields/RadioGroup/RadioGroup.test.tsx +76 -0
  8. package/src/__tests__/modules/Redirects/BulkHeader/TableHeader.test.tsx +123 -0
  9. package/src/__tests__/modules/Redirects/RedirectItem/RedirectItem.test.tsx +163 -0
  10. package/src/__tests__/modules/Redirects/RedirectPanel/RedirectPanel.test.tsx +134 -0
  11. package/src/__tests__/modules/Redirects/Redirects/Redirects.create.test.tsx +69 -0
  12. package/src/__tests__/modules/Redirects/Redirects/Redirects.edit.test.tsx +91 -0
  13. package/src/__tests__/modules/Redirects/Redirects/Redirects.filter.test.tsx +131 -0
  14. package/src/components/Fields/ConditionalField/style.tsx +1 -1
  15. package/src/components/Fields/RadioField/index.tsx +20 -3
  16. package/src/components/Fields/RadioField/style.tsx +18 -1
  17. package/src/components/Fields/RadioGroup/index.tsx +1 -0
  18. package/src/containers/Redirects/actions.tsx +15 -3
  19. package/src/global.d.ts +0 -1
  20. package/src/modules/ActivityLog/DownloadModal/index.tsx +2 -1
  21. package/src/modules/ActivityLog/index.tsx +2 -2
  22. package/src/modules/Redirects/BulkHeader/TableHeader/index.tsx +5 -0
  23. package/src/modules/Redirects/BulkHeader/TableHeader/style.tsx +5 -1
  24. package/src/modules/Redirects/RedirectItem/index.tsx +11 -1
  25. package/src/modules/Redirects/RedirectItem/style.tsx +29 -1
  26. package/src/modules/Redirects/RedirectPanel/index.tsx +40 -3
  27. package/src/modules/Redirects/TypeFilter/index.tsx +44 -0
  28. package/src/modules/Redirects/TypeFilter/style.tsx +27 -0
  29. package/src/modules/Redirects/hooks.tsx +12 -4
  30. package/src/modules/Redirects/index.tsx +22 -14
  31. package/src/modules/Redirects/utils.tsx +10 -1
  32. package/src/types/index.tsx +3 -0
  33. package/tsconfig.json +1 -9
@@ -0,0 +1,163 @@
1
+ import { parseTheme } from "@ax/helpers";
2
+ import RedirectItem from "@ax/modules/Redirects/RedirectItem";
3
+ import * as S from "@ax/modules/Redirects/RedirectItem/style";
4
+ import { REDIRECT_TYPE_COLORS } from "@ax/modules/Redirects/utils";
5
+ import globalTheme from "@ax/themes/theme.json";
6
+ import type { IRedirect, RedirectType } from "@ax/types";
7
+
8
+ import configureStore from "redux-mock-store";
9
+ import thunk from "redux-thunk";
10
+ import { ThemeProvider } from "styled-components";
11
+
12
+ import { cleanup, fireEvent, render, screen } from "../../../../../config/tests/test-utils";
13
+
14
+ afterEach(cleanup);
15
+
16
+ const mockStore = configureStore([thunk]);
17
+ const store = mockStore({
18
+ app: { isSaving: false, lang: { locale: "en_EN", id: 1 }, globalLangs: [] },
19
+ sites: { currentSiteInfo: null, sites: [{ id: 1, name: "UST School" }] },
20
+ });
21
+
22
+ const redirect: IRedirect = {
23
+ id: 1,
24
+ from: "/about/team",
25
+ to: { pageId: 7, url: "/about/people" },
26
+ site: { siteId: 2, siteName: "UST School", siteUrl: "/ust" },
27
+ domain: "https://ust.edu",
28
+ date: "2021-10-15T00:00:00.000Z",
29
+ };
30
+
31
+ const renderItem = (overrides: Partial<IRedirect> = {}, isSiteItem = false, setFormValues = vi.fn()) =>
32
+ render(
33
+ <ThemeProvider theme={parseTheme(globalTheme)}>
34
+ <RedirectItem
35
+ redirect={{ ...redirect, ...overrides }}
36
+ isSelected={false}
37
+ onChange={vi.fn()}
38
+ toggleToast={vi.fn()}
39
+ setFormValues={setFormValues}
40
+ formValues={redirect}
41
+ addRedirect={vi.fn()}
42
+ currentFilter=""
43
+ isSiteItem={isSiteItem}
44
+ hoverCheck={false}
45
+ />
46
+ </ThemeProvider>,
47
+ { store },
48
+ );
49
+
50
+ describe("[UC-AX-manage-redirects] RedirectItem redirection type tag", () => {
51
+ it("AC8 · paints the redirect type on the row", () => {
52
+ renderItem({ type: "301" });
53
+
54
+ expect(screen.getByTestId("tag-square").textContent).toEqual("301");
55
+ });
56
+
57
+ it("AC8 · leaves the cell empty, not absent, when the redirect has no type", () => {
58
+ renderItem();
59
+
60
+ expect(screen.queryByTestId("tag-square")).toBeNull();
61
+ // La celda tiene que seguir ocupando su hueco o la fila se descuadra
62
+ // respecto a la cabecera y a las filas que sí traen tipo.
63
+ expect(screen.getAllByRole("cell")[1].textContent).toEqual("");
64
+ });
65
+
66
+ it("AC8 · keeps the column in place whether or not there is a type", () => {
67
+ // El recuento por sí solo no basta: borrar la columna baja los dos lados
68
+ // por igual. Hay que fijar también cuántas celdas tiene la fila.
69
+ const cellsInGlobalScope = 7;
70
+
71
+ const { unmount } = renderItem({ type: "301" });
72
+ expect(screen.getAllByRole("cell")).toHaveLength(cellsInGlobalScope);
73
+ unmount();
74
+
75
+ renderItem();
76
+
77
+ expect(screen.getAllByRole("cell")).toHaveLength(cellsInGlobalScope);
78
+ });
79
+
80
+ it("AC8 · keeps the type column in site scope, without the site one", () => {
81
+ renderItem({ type: "301" }, true);
82
+
83
+ expect(screen.getByTestId("tag-square")).toBeTruthy();
84
+ expect(screen.queryByText("UST School")).toBeNull();
85
+ });
86
+ });
87
+
88
+ describe("[UC-AX-manage-redirects] TypeTag colours", () => {
89
+ const renderTag = (type: RedirectType) =>
90
+ render(
91
+ <ThemeProvider theme={parseTheme(globalTheme)}>
92
+ <S.TypeTag type="square" text={type} color={REDIRECT_TYPE_COLORS[type]} />
93
+ </ThemeProvider>,
94
+ { store },
95
+ );
96
+
97
+ it("AC8 · paints 301 with the permanent colour", () => {
98
+ renderTag("301");
99
+
100
+ expect(screen.getByTestId("tag-square")).toHaveStyleRule("background-color", "#C9EFFF");
101
+ });
102
+
103
+ it("AC8 · paints 302 with the temporary colour", () => {
104
+ renderTag("302");
105
+
106
+ expect(screen.getByTestId("tag-square")).toHaveStyleRule("background-color", "#DBB9FF");
107
+ });
108
+
109
+ it("AC8 · keeps the text readable over the pastel background", () => {
110
+ // Tag paints a near-white text whenever `color` is set, unreadable on these
111
+ // backgrounds. TypeTag lo revierte con un `&&`, que duplica especificidad;
112
+ // hay que pedirle al matcher ese mismo selector, porque jsdom no aplica la
113
+ // cascada y sin `modifier` devuelve la regla plana de TagSquare.
114
+ renderTag("301");
115
+
116
+ expect(screen.getByTestId("tag-square")).toHaveStyleRule(
117
+ "color",
118
+ parseTheme(globalTheme).color.textMediumEmphasis,
119
+ { modifier: "&&" },
120
+ );
121
+ });
122
+ });
123
+
124
+ describe("[UC-AX-manage-redirects] RedirectItem editing", () => {
125
+ it("AC7 · seeds the form with 301 when the row carries no type", () => {
126
+ const setFormValues = vi.fn();
127
+ renderItem({}, false, setFormValues);
128
+
129
+ fireEvent.click(screen.getByText("/about/team"));
130
+
131
+ expect(setFormValues).toHaveBeenCalledWith(expect.objectContaining({ type: "301" }));
132
+ });
133
+
134
+ it("should open the row from the type cell too", () => {
135
+ // Todas las celdas de la fila abren la edición; si la del tipo se queda sin
136
+ // onClick, clicar el tag no hace nada y las demás sí.
137
+ const setFormValues = vi.fn();
138
+ renderItem({ type: "301" }, false, setFormValues);
139
+
140
+ fireEvent.click(screen.getByTestId("tag-square"));
141
+
142
+ expect(setFormValues).toHaveBeenCalledWith(expect.objectContaining({ type: "301" }));
143
+ });
144
+
145
+ it("AC7 · keeps the type the row already has", () => {
146
+ const setFormValues = vi.fn();
147
+ renderItem({ type: "302" }, false, setFormValues);
148
+
149
+ fireEvent.click(screen.getByText("/about/team"));
150
+
151
+ expect(setFormValues).toHaveBeenCalledWith(expect.objectContaining({ type: "302" }));
152
+ });
153
+ });
154
+
155
+ describe("[UC-AX-manage-redirects] RedirectItem type normalisation", () => {
156
+ it("should paint the tag when the API sends the type as a number", () => {
157
+ // El store normaliza a string (containers/Redirects/actions), pero la fila
158
+ // no debe depender de ello para pintar.
159
+ renderItem({ type: 301 as never });
160
+
161
+ expect(screen.getByTestId("tag-square").textContent).toEqual("301");
162
+ });
163
+ });
@@ -0,0 +1,134 @@
1
+ // `connect` captura el action creator al cargar el módulo, así que hay que
2
+ // sustituirlo antes de que se importe el panel (vi.mock se iza).
3
+ const updateRedirectSpy = vi.hoisted(() => vi.fn());
4
+ vi.mock("@ax/containers/Redirects", () => ({
5
+ redirectsActions: {
6
+ updateRedirect: (...args: unknown[]) => {
7
+ updateRedirectSpy(...args);
8
+ return () => Promise.resolve();
9
+ },
10
+ },
11
+ }));
12
+
13
+ import { parseTheme } from "@ax/helpers";
14
+ import RedirectPanel from "@ax/modules/Redirects/RedirectPanel";
15
+ import globalTheme from "@ax/themes/theme.json";
16
+ import type { IRedirect } from "@ax/types";
17
+
18
+ import configureStore from "redux-mock-store";
19
+ import thunk from "redux-thunk";
20
+ import { ThemeProvider } from "styled-components";
21
+
22
+ import { cleanup, fireEvent, render, screen } from "../../../../../config/tests/test-utils";
23
+
24
+ afterEach(cleanup);
25
+
26
+ const mockStore = configureStore([thunk]);
27
+ // El panel monta el UrlField, que arrastra el PageFinder conectado:
28
+ // necesita sites.currentSiteInfo/sites y app.lang/globalLangs.
29
+ const store = mockStore({
30
+ app: { isSaving: false, lang: { locale: "en_EN", id: 1 }, globalLangs: [] },
31
+ sites: { currentSiteInfo: null, sites: [{ id: 1, name: "UST School" }] },
32
+ });
33
+
34
+ const formValues: IRedirect = {
35
+ from: "/about/team",
36
+ to: { pageId: undefined, url: "/about/people" },
37
+ type: "301",
38
+ };
39
+
40
+ const renderPanel = (overrides: Partial<IRedirect> = {}, setFormValues = vi.fn()) => {
41
+ render(
42
+ <ThemeProvider theme={parseTheme(globalTheme)}>
43
+ <RedirectPanel
44
+ isOpen={true}
45
+ isOpenedSecond={false}
46
+ toggleModal={vi.fn()}
47
+ toggleSecondaryPanel={vi.fn()}
48
+ formValues={{ ...formValues, ...overrides }}
49
+ setFormValues={setFormValues}
50
+ addRedirect={vi.fn()}
51
+ currentFilter=""
52
+ />
53
+ </ThemeProvider>,
54
+ { store },
55
+ );
56
+
57
+ return { setFormValues };
58
+ };
59
+
60
+ describe("[UC-AX-manage-redirects] RedirectPanel redirection type", () => {
61
+ it("AC7 · offers both types with their descriptions", () => {
62
+ renderPanel();
63
+
64
+ expect(screen.getByText("301 - Permanent")).toBeTruthy();
65
+ expect(screen.getByText("302 - Temporary")).toBeTruthy();
66
+ expect(screen.getByText("Keeps SEO. Use for definitive redirects.")).toBeTruthy();
67
+ expect(screen.getByText("Doesn’t keep SEO. Use for short-lived changes.")).toBeTruthy();
68
+ });
69
+
70
+ it("AC7 · preselects the type held in the form", () => {
71
+ renderPanel();
72
+
73
+ const [permanent, temporary] = screen.getAllByTestId<HTMLInputElement>("radio-field-input");
74
+ expect(permanent.checked).toBe(true);
75
+ expect(temporary.checked).toBe(false);
76
+ });
77
+
78
+ it("should not preselect anything when the type is not a string", () => {
79
+ // Documenta por qué el store normaliza: RadioGroup compara con ===, así que
80
+ // un 301 numérico no casa con la opción "301" y el panel sale en blanco.
81
+ renderPanel({ type: 301 as never });
82
+
83
+ const [permanent, temporary] = screen.getAllByTestId<HTMLInputElement>("radio-field-input");
84
+ expect(permanent.checked).toBe(false);
85
+ expect(temporary.checked).toBe(false);
86
+ });
87
+
88
+ it("AC7 · preselects 302 when the form holds it", () => {
89
+ renderPanel({ type: "302" });
90
+
91
+ const [permanent, temporary] = screen.getAllByTestId<HTMLInputElement>("radio-field-input");
92
+ expect(permanent.checked).toBe(false);
93
+ expect(temporary.checked).toBe(true);
94
+ });
95
+
96
+ it("AC7 · reports the chosen type without touching the rest of the form", () => {
97
+ const { setFormValues } = renderPanel();
98
+
99
+ const [, temporary] = screen.getAllByTestId("radio-field-input");
100
+ fireEvent.click(temporary);
101
+
102
+ expect(setFormValues).toHaveBeenCalledWith({ ...formValues, type: "302" });
103
+ });
104
+ });
105
+
106
+ describe("[UC-AX-manage-redirects] RedirectPanel update payload", () => {
107
+ it("AC7 · sends the chosen type on update (PUT)", async () => {
108
+ render(
109
+ <ThemeProvider theme={parseTheme(globalTheme)}>
110
+ <RedirectPanel
111
+ isOpen={true}
112
+ isOpenedSecond={false}
113
+ toggleModal={vi.fn()}
114
+ toggleSecondaryPanel={vi.fn()}
115
+ redirect={{ ...formValues, id: 7 }}
116
+ formValues={{ ...formValues, id: 7, type: "302" }}
117
+ setFormValues={vi.fn()}
118
+ addRedirect={vi.fn()}
119
+ currentFilter="&sites=1"
120
+ />
121
+ </ThemeProvider>,
122
+ { store },
123
+ );
124
+
125
+ // "Update Redirect" es también el título del panel; hay que ir al botón.
126
+ fireEvent.click(screen.getByRole("button", { name: "Update Redirect" }));
127
+
128
+ expect(updateRedirectSpy).toHaveBeenCalledWith(
129
+ { from: "/about/team", to: "/about/people", type: "302" },
130
+ 7,
131
+ "&sites=1",
132
+ );
133
+ });
134
+ });
@@ -0,0 +1,69 @@
1
+ // `connect` captura los action creators al cargar el módulo, así que hay que
2
+ // sustituirlos antes de que se importe la pantalla (vi.mock se iza).
3
+ const addRedirectSpy = vi.hoisted(() => vi.fn());
4
+ vi.mock("@ax/containers/Redirects", () => ({
5
+ redirectsActions: {
6
+ getRedirects: () => () => Promise.resolve(),
7
+ deleteRedirect: () => () => Promise.resolve(true),
8
+ importRedirects: () => () => Promise.resolve(true),
9
+ updateRedirect: () => () => Promise.resolve(),
10
+ addRedirect: (...args: unknown[]) => {
11
+ addRedirectSpy(...args);
12
+ return () => Promise.resolve();
13
+ },
14
+ },
15
+ }));
16
+
17
+ import { MemoryRouter } from "react-router-dom";
18
+
19
+ import { parseTheme } from "@ax/helpers";
20
+ import Redirects from "@ax/modules/Redirects";
21
+ import globalTheme from "@ax/themes/theme.json";
22
+
23
+ import configureStore from "redux-mock-store";
24
+ import thunk from "redux-thunk";
25
+ import { ThemeProvider } from "styled-components";
26
+
27
+ import { cleanup, fireEvent, render, screen } from "../../../../../config/tests/test-utils";
28
+
29
+ afterEach(cleanup);
30
+
31
+ // jsdom no implementa Element.scrollTo y el módulo lo llama sobre el tableRef.
32
+ beforeEach(() => {
33
+ Element.prototype.scrollTo = vi.fn();
34
+ });
35
+
36
+ const store = configureStore([thunk])({
37
+ redirects: { redirects: [], totalItems: 0, totalImports: 0, imports: null },
38
+ sites: { currentSiteInfo: null, sites: [{ id: 1, name: "UST School" }] },
39
+ app: { isLoading: false, isSaving: false, lang: { locale: "en_EN", id: 1 }, globalLangs: [] },
40
+ });
41
+
42
+ const navItems = [{ title: "Redirects", path: "/settings/redirects" }];
43
+
44
+ describe("[UC-AX-manage-redirects] Redirects — crear con tipo", () => {
45
+ it("AC7 · creates as 301 when the author does not touch the type (POST)", () => {
46
+ render(
47
+ <MemoryRouter>
48
+ <ThemeProvider theme={parseTheme(globalTheme)}>
49
+ <Redirects navItems={navItems as never} currentNavItem={navItems[0] as never} />
50
+ </ThemeProvider>
51
+ </MemoryRouter>,
52
+ { store },
53
+ );
54
+
55
+ fireEvent.click(screen.getByText("New Redirect"));
56
+ // Hay varios inputs en pantalla (el buscador del MainWrapper incluido);
57
+ // el autoComplete del panel es el que identifica el campo "Old URL".
58
+ const oldUrl = document.querySelector('input[autocomplete="redirect-old"]') as HTMLInputElement;
59
+ fireEvent.change(oldUrl, { target: { value: "/old" } });
60
+ fireEvent.click(screen.getByRole("button", { name: "Add Redirect" }));
61
+
62
+ expect(addRedirectSpy).toHaveBeenCalledWith(
63
+ expect.objectContaining({ type: "301" }),
64
+ expect.any(Function),
65
+ undefined,
66
+ expect.any(String),
67
+ );
68
+ });
69
+ });
@@ -0,0 +1,91 @@
1
+ // `connect` captura los action creators al cargar el módulo, así que hay que
2
+ // sustituirlos antes de que se importe la pantalla (vi.mock se iza).
3
+ vi.mock("@ax/containers/Redirects", () => ({
4
+ redirectsActions: {
5
+ getRedirects: () => () => Promise.resolve(),
6
+ deleteRedirect: () => () => Promise.resolve(true),
7
+ importRedirects: () => () => Promise.resolve(true),
8
+ updateRedirect: () => () => Promise.resolve(),
9
+ addRedirect: () => () => Promise.resolve(),
10
+ },
11
+ }));
12
+
13
+ import { MemoryRouter } from "react-router-dom";
14
+
15
+ import { parseTheme } from "@ax/helpers";
16
+ import Redirects from "@ax/modules/Redirects";
17
+ import globalTheme from "@ax/themes/theme.json";
18
+ import type { IRedirect } from "@ax/types";
19
+
20
+ import configureStore from "redux-mock-store";
21
+ import thunk from "redux-thunk";
22
+ import { ThemeProvider } from "styled-components";
23
+
24
+ import { cleanup, fireEvent, render, screen, within } from "../../../../../config/tests/test-utils";
25
+
26
+ afterEach(cleanup);
27
+
28
+ // jsdom no implementa Element.scrollTo y el módulo lo llama sobre el tableRef.
29
+ beforeEach(() => {
30
+ Element.prototype.scrollTo = vi.fn();
31
+ });
32
+
33
+ const redirect: IRedirect = {
34
+ id: 1,
35
+ from: "/about/team",
36
+ to: { url: "https://example.com/team" },
37
+ type: "302",
38
+ site: { siteId: 2, siteName: "UST School", siteUrl: "/ust" },
39
+ date: "2021-10-15T00:00:00.000Z",
40
+ };
41
+
42
+ const store = configureStore([thunk])({
43
+ redirects: { redirects: [redirect], totalItems: 1, totalImports: 0, imports: null },
44
+ sites: { currentSiteInfo: null, sites: [{ id: 2, name: "UST School" }] },
45
+ app: { isLoading: false, isSaving: false, lang: { locale: "en_EN", id: 1 }, globalLangs: [] },
46
+ });
47
+
48
+ const navItems = [{ title: "Redirects", path: "/settings/redirects" }];
49
+
50
+ // El panel de alta sigue montado, cerrado, mientras se edita una fila, y pinta
51
+ // los mismos radios: el aserto se acota al único panel abierto.
52
+ const OPEN_PANEL = "[data-testid='floating-panel'][data-is-open='true']";
53
+
54
+ const openPanel = () => {
55
+ expect(document.querySelectorAll(OPEN_PANEL)).toHaveLength(1);
56
+ return document.querySelector(OPEN_PANEL) as HTMLElement;
57
+ };
58
+
59
+ // El grupo de un radio, como lo define el HTML: mismo `name` y mismo form owner.
60
+ const radioGroupOf = (radio: HTMLInputElement) =>
61
+ Array.from(document.querySelectorAll<HTMLInputElement>('input[type="radio"]')).filter(
62
+ (other) => other.name === radio.name && other.form === radio.form,
63
+ );
64
+
65
+ describe("[UC-AX-manage-redirects] Redirects — editar el tipo", () => {
66
+ it("AC10 · checks the row's type in the open panel while the add panel stays mounted", () => {
67
+ render(
68
+ <MemoryRouter>
69
+ <ThemeProvider theme={parseTheme(globalTheme)}>
70
+ <Redirects navItems={navItems as never} currentNavItem={navItems[0] as never} />
71
+ </ThemeProvider>
72
+ </MemoryRouter>,
73
+ { store },
74
+ );
75
+
76
+ fireEvent.click(screen.getByTestId("redirect-type"));
77
+
78
+ // Primero la causa: si el grupo incluyera los radios del panel cerrado, el
79
+ // `checked` sería del panel que se re-renderice el último, y en el
80
+ // navegador eso cambia según el orden en que lleguen las peticiones del
81
+ // panel (medido en el e2e de QA `sites/edit/011`).
82
+ const radio302 = within(openPanel()).getByRole("radio", { name: "302 - Temporary" }) as HTMLInputElement;
83
+ expect(radioGroupOf(radio302)).toHaveLength(2);
84
+
85
+ expect(radio302).toBeChecked();
86
+
87
+ fireEvent.click(within(openPanel()).getByText("301 - Permanent"));
88
+
89
+ expect(within(openPanel()).getByRole("radio", { name: "301 - Permanent" })).toBeChecked();
90
+ });
91
+ });
@@ -0,0 +1,131 @@
1
+ // `connect` captura los action creators al cargar el módulo, así que hay que
2
+ // sustituirlos antes de que se importe la pantalla (vi.mock se iza).
3
+ const getRedirectsSpy = vi.hoisted(() => vi.fn());
4
+ vi.mock("@ax/containers/Redirects", () => ({
5
+ redirectsActions: {
6
+ getRedirects: (...args: unknown[]) => {
7
+ getRedirectsSpy(...args);
8
+ return () => Promise.resolve();
9
+ },
10
+ deleteRedirect: () => () => Promise.resolve(true),
11
+ importRedirects: () => () => Promise.resolve(true),
12
+ updateRedirect: () => () => Promise.resolve(),
13
+ addRedirect: () => () => Promise.resolve(),
14
+ },
15
+ }));
16
+
17
+ import { MemoryRouter } from "react-router-dom";
18
+
19
+ import { parseTheme } from "@ax/helpers";
20
+ import Redirects from "@ax/modules/Redirects";
21
+ import globalTheme from "@ax/themes/theme.json";
22
+
23
+ import configureStore from "redux-mock-store";
24
+ import thunk from "redux-thunk";
25
+ import { ThemeProvider } from "styled-components";
26
+
27
+ import { cleanup, fireEvent, render, screen } from "../../../../../config/tests/test-utils";
28
+
29
+ afterEach(cleanup);
30
+
31
+ // jsdom no implementa Element.scrollTo y el módulo lo llama sobre el tableRef.
32
+ beforeEach(() => {
33
+ getRedirectsSpy.mockClear();
34
+ Element.prototype.scrollTo = vi.fn();
35
+ });
36
+
37
+ const navItems = [{ title: "Redirects", path: "/settings/redirects" }];
38
+
39
+ // `totalItems` por encima de los 50 por página para que el paginador tenga más
40
+ // de una página y se pueda comprobar el reseteo.
41
+ const renderScreen = (currentSiteInfo: { id: number; name: string } | null) => {
42
+ const store = configureStore([thunk])({
43
+ redirects: { redirects: [], totalItems: 120, totalImports: 0, imports: null },
44
+ sites: { currentSiteInfo, sites: [{ id: 5, name: "UST School" }] },
45
+ app: { isLoading: false, isSaving: false, lang: { locale: "en_EN", id: 1 }, globalLangs: [] },
46
+ });
47
+
48
+ return render(
49
+ <MemoryRouter>
50
+ <ThemeProvider theme={parseTheme(globalTheme)}>
51
+ <Redirects navItems={navItems as never} currentNavItem={navItems[0] as never} />
52
+ </ThemeProvider>
53
+ </MemoryRouter>,
54
+ { store },
55
+ );
56
+ };
57
+
58
+ const chooseType = (type: string) => {
59
+ fireEvent.click(screen.getByTestId("redirect-type-filter"));
60
+
61
+ const option = screen.getAllByTestId("list-item").find((item) => item.textContent === type);
62
+ fireEvent.click(option as HTMLElement);
63
+ };
64
+
65
+ const lastCall = () => getRedirectsSpy.mock.lastCall as [{ page: number }, string];
66
+
67
+ describe("[UC-AX-manage-redirects] Redirects — filtrar por tipo", () => {
68
+ it("AC9 · sends the chosen type in the listing query", () => {
69
+ renderScreen(null);
70
+
71
+ chooseType("302");
72
+
73
+ expect(lastCall()[1]).toBe("&type=302");
74
+ });
75
+
76
+ it("AC9 · keeps the site scope when filtering by type", () => {
77
+ renderScreen({ id: 5, name: "UST School" });
78
+
79
+ chooseType("302");
80
+
81
+ expect(lastCall()[1]).toBe("&sites=5&type=302");
82
+ });
83
+
84
+ it("AC9 · goes back to the first page", () => {
85
+ renderScreen(null);
86
+
87
+ const pageInput = screen.getByTestId("pagination-text-input");
88
+ fireEvent.change(pageInput, { target: { value: "2" } });
89
+ fireEvent.blur(pageInput);
90
+ expect(lastCall()[0].page).toBe(2);
91
+
92
+ chooseType("302");
93
+
94
+ expect(lastCall()[0].page).toBe(1);
95
+ });
96
+
97
+ it("AC9 · tags the chosen type, in site scope too", () => {
98
+ renderScreen({ id: 5, name: "UST School" });
99
+
100
+ chooseType("302");
101
+
102
+ expect(screen.getByText("Type: 302")).toBeTruthy();
103
+ });
104
+
105
+ it("AC9 · replaces the type instead of adding to it — the filter is single choice", () => {
106
+ renderScreen(null);
107
+
108
+ chooseType("302");
109
+ chooseType("301");
110
+
111
+ expect(lastCall()[1]).toBe("&type=301");
112
+ });
113
+
114
+ it("AC9 · clears the type when the chosen one is picked again", () => {
115
+ renderScreen(null);
116
+
117
+ chooseType("302");
118
+ chooseType("302");
119
+
120
+ expect(lastCall()[1]).toBe("");
121
+ });
122
+
123
+ it("AC9 · Clear All returns the listing to its site, not to the global catalogue", () => {
124
+ renderScreen({ id: 5, name: "UST School" });
125
+
126
+ chooseType("302");
127
+ fireEvent.click(screen.getByText("Clear All"));
128
+
129
+ expect(lastCall()[1]).toBe("&sites=5");
130
+ });
131
+ });
@@ -7,7 +7,7 @@ export const Wrapper = styled.div`
7
7
  export const Content = styled.div`
8
8
  transition: all 0.5s ease-in-out;
9
9
  margin-top: ${(p) => p.theme.spacing.m};
10
- div:last-child {
10
+ > div:last-child {
11
11
  margin-bottom: 0;
12
12
  }
13
13
  `;
@@ -1,7 +1,7 @@
1
1
  import * as S from "./style";
2
2
 
3
3
  const RadioField = (props: IRadioFieldProps): JSX.Element => {
4
- const { name, title, value, checked, error, disabled, tabIndex, onChange } = props;
4
+ const { name, title, description, value, checked, error, disabled, tabIndex, onChange } = props;
5
5
 
6
6
  const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
7
7
  if (onChange) {
@@ -9,10 +9,24 @@ const RadioField = (props: IRadioFieldProps): JSX.Element => {
9
9
  }
10
10
  };
11
11
 
12
+ // Con descripción, el título va en su propio nodo y el input apunta a él con
13
+ // aria-labelledby: si la descripción cuelga del <label>, el nombre accesible
14
+ // concatena las dos cadenas sin separador ("301 - PermanentKeeps SEO."). La
15
+ // descripción se queda dentro del <label> para que clicarla siga marcando la
16
+ // opción, y se anuncia aparte con aria-describedby. Sin descripción no se
17
+ // envuelve nada, para no alterar el DOM de los radios que ya existen.
18
+ const titleId = `${name}-${value}-title`;
19
+ const descriptionId = `${name}-${value}-description`;
20
+
12
21
  return (
13
22
  <S.Wrapper data-testid="radio-field-wrapper">
14
- <S.Label>
15
- {title}
23
+ <S.Label hasDescription={!!description}>
24
+ {description ? <S.Title id={titleId}>{title}</S.Title> : title}
25
+ {description && (
26
+ <S.Description id={descriptionId} data-testid="radio-field-description">
27
+ {description}
28
+ </S.Description>
29
+ )}
16
30
  <S.Input
17
31
  type="radio"
18
32
  name={name}
@@ -22,6 +36,8 @@ const RadioField = (props: IRadioFieldProps): JSX.Element => {
22
36
  onChange={handleChange}
23
37
  data-testid="radio-field-input"
24
38
  tabIndex={tabIndex}
39
+ aria-labelledby={description ? titleId : undefined}
40
+ aria-describedby={description ? descriptionId : undefined}
25
41
  />
26
42
  <S.CheckMark checked={checked} error={error} />
27
43
  </S.Label>
@@ -32,6 +48,7 @@ const RadioField = (props: IRadioFieldProps): JSX.Element => {
32
48
  export interface IRadioFieldProps {
33
49
  name: string;
34
50
  title?: string;
51
+ description?: string;
35
52
  value: string;
36
53
  checked?: boolean;
37
54
  error?: boolean;
@@ -2,7 +2,7 @@ import styled from "styled-components";
2
2
 
3
3
  export const Wrapper = styled.div``;
4
4
 
5
- export const Label = styled.label`
5
+ export const Label = styled.label<{ hasDescription?: boolean }>`
6
6
  ${(p) => p.theme.textStyle.fieldContent};
7
7
  position: relative;
8
8
  padding-left: calc(${(p) => p.theme.spacing.s} + ${(p) => p.theme.spacing.xs});
@@ -10,6 +10,23 @@ export const Label = styled.label`
10
10
  cursor: pointer;
11
11
  user-select: none;
12
12
  min-height: 16px;
13
+ ${(p) =>
14
+ p.hasDescription &&
15
+ `
16
+ display: block;
17
+ font-weight: ${p.theme.fontWeight.semibold};
18
+ `}
19
+ `;
20
+
21
+ export const Title = styled.span``;
22
+
23
+ // Mismo par de tokens que el HelpText de FieldsBehavior, que es el texto
24
+ // descriptivo que ya usa el CMS bajo un campo.
25
+ export const Description = styled.span`
26
+ ${(p) => p.theme.textStyle.uiXS};
27
+ display: block;
28
+ margin-top: ${(p) => p.theme.spacing.xxs};
29
+ color: ${(p) => p.theme.color.textMediumEmphasis};
13
30
  `;
14
31
 
15
32
  export const CheckMark = styled.span<{ checked: boolean | undefined; error: boolean | undefined }>`