@griddo/ax 12.10.5 → 12.11.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.
- package/package.json +2 -2
- package/src/__tests__/components/Fields/ConditionalField/ConditionalField.test.tsx +17 -0
- package/src/__tests__/components/Fields/RadioField/RadioField.test.tsx +66 -0
- package/src/__tests__/components/Fields/RadioGroup/RadioGroup.test.tsx +76 -0
- package/src/__tests__/modules/Redirects/BulkHeader/TableHeader.test.tsx +123 -0
- package/src/__tests__/modules/Redirects/RedirectItem/RedirectItem.test.tsx +163 -0
- package/src/__tests__/modules/Redirects/RedirectPanel/RedirectPanel.test.tsx +134 -0
- package/src/__tests__/modules/Redirects/Redirects/Redirects.create.test.tsx +69 -0
- package/src/__tests__/modules/Redirects/Redirects/Redirects.edit.test.tsx +91 -0
- package/src/__tests__/modules/Redirects/Redirects/Redirects.filter.test.tsx +131 -0
- package/src/components/Fields/ConditionalField/style.tsx +1 -1
- package/src/components/Fields/RadioField/index.tsx +20 -3
- package/src/components/Fields/RadioField/style.tsx +18 -1
- package/src/components/Fields/RadioGroup/index.tsx +1 -0
- package/src/containers/Redirects/actions.tsx +15 -3
- package/src/modules/Redirects/BulkHeader/TableHeader/index.tsx +5 -0
- package/src/modules/Redirects/BulkHeader/TableHeader/style.tsx +5 -1
- package/src/modules/Redirects/RedirectItem/index.tsx +11 -1
- package/src/modules/Redirects/RedirectItem/style.tsx +29 -1
- package/src/modules/Redirects/RedirectPanel/index.tsx +40 -3
- package/src/modules/Redirects/TypeFilter/index.tsx +44 -0
- package/src/modules/Redirects/TypeFilter/style.tsx +27 -0
- package/src/modules/Redirects/hooks.tsx +12 -4
- package/src/modules/Redirects/index.tsx +22 -14
- package/src/modules/Redirects/utils.tsx +10 -1
- package/src/types/index.tsx +3 -0
|
@@ -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
|
+
});
|
|
@@ -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 }>`
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { redirects } from "@ax/api";
|
|
2
2
|
import { appActions } from "@ax/containers/App";
|
|
3
3
|
import { handleRequest } from "@ax/helpers";
|
|
4
|
+
import type { RedirectType } from "@ax/types";
|
|
5
|
+
|
|
4
6
|
import {
|
|
5
7
|
DEFAULT_PARAMS,
|
|
6
8
|
ERROR_CODE,
|
|
@@ -11,8 +13,18 @@ import {
|
|
|
11
13
|
} from "./constants";
|
|
12
14
|
import type { ISetImports, ISetRedirects, ISetTotalImports, ISetTotalItems } from "./interfaces";
|
|
13
15
|
|
|
16
|
+
// El contrato del tipo de redirección con la API no está cerrado (sc-62031) y
|
|
17
|
+
// un código HTTP en BD es naturalmente un entero. `RadioGroup` compara con
|
|
18
|
+
// `===`, así que un 301 numérico no casaría con la opción "301" y el panel se
|
|
19
|
+
// abriría sin nada marcado. Se normaliza aquí, en la única puerta de entrada de
|
|
20
|
+
// las redirecciones al store, para que listado y panel vean siempre lo mismo.
|
|
21
|
+
const normalizeRedirectType = (redirect: any) =>
|
|
22
|
+
redirect?.type === null || redirect?.type === undefined || redirect?.type === ""
|
|
23
|
+
? redirect
|
|
24
|
+
: { ...redirect, type: String(redirect.type) };
|
|
25
|
+
|
|
14
26
|
function setRedirects(redirects: any[]): ISetRedirects {
|
|
15
|
-
return { type: SET_REDIRECTS, payload: { redirects } };
|
|
27
|
+
return { type: SET_REDIRECTS, payload: { redirects: redirects.map(normalizeRedirectType) } };
|
|
16
28
|
}
|
|
17
29
|
|
|
18
30
|
function setTotalItems(totalItems: number): ISetTotalItems {
|
|
@@ -50,7 +62,7 @@ function getRedirects(params: any, filters?: string): (dispatch: any) => Promise
|
|
|
50
62
|
}
|
|
51
63
|
|
|
52
64
|
function addRedirect(
|
|
53
|
-
redirect: { from: string; to: string | number },
|
|
65
|
+
redirect: { from: string; to: string | number; type?: RedirectType },
|
|
54
66
|
errorAction: () => void,
|
|
55
67
|
force?: boolean,
|
|
56
68
|
filter?: string,
|
|
@@ -84,7 +96,7 @@ function addRedirect(
|
|
|
84
96
|
}
|
|
85
97
|
|
|
86
98
|
function updateRedirect(
|
|
87
|
-
redirect: { from: string; to: string | number },
|
|
99
|
+
redirect: { from: string; to: string | number; type?: RedirectType },
|
|
88
100
|
redirectID: number,
|
|
89
101
|
filter?: string,
|
|
90
102
|
): (dispatch: any, getState: any) => Promise<void> {
|
|
@@ -3,6 +3,8 @@ import React from "react";
|
|
|
3
3
|
import { CheckField, SiteFilter, TableCounter, DateFilter, Tooltip } from "@ax/components";
|
|
4
4
|
import type { IQueryValue } from "@ax/types";
|
|
5
5
|
|
|
6
|
+
import TypeFilter from "../../TypeFilter";
|
|
7
|
+
|
|
6
8
|
import * as S from "./style";
|
|
7
9
|
|
|
8
10
|
const TableHeader = (props: IProps): JSX.Element => {
|
|
@@ -35,6 +37,9 @@ const TableHeader = (props: IProps): JSX.Element => {
|
|
|
35
37
|
/>
|
|
36
38
|
</Tooltip>
|
|
37
39
|
</S.CheckHeader>
|
|
40
|
+
<S.TypeHeader>
|
|
41
|
+
<TypeFilter filterItems={filterItems} value={filterValues.type} />
|
|
42
|
+
</S.TypeHeader>
|
|
38
43
|
{!isSiteItem && (
|
|
39
44
|
<S.SiteHeader>
|
|
40
45
|
<SiteFilter filterItems={filterItems} value={filterValues} pointer="sites" center={false} />
|
|
@@ -16,6 +16,10 @@ const CheckHeader = styled(Header)`
|
|
|
16
16
|
width: 32px;
|
|
17
17
|
`;
|
|
18
18
|
|
|
19
|
+
const TypeHeader = styled(Header)`
|
|
20
|
+
width: 96px;
|
|
21
|
+
`;
|
|
22
|
+
|
|
19
23
|
const SiteHeader = styled(Header)`
|
|
20
24
|
width: 200px;
|
|
21
25
|
`;
|
|
@@ -30,4 +34,4 @@ const ActionsHeader = styled(Header)`
|
|
|
30
34
|
justify-content: flex-end;
|
|
31
35
|
`;
|
|
32
36
|
|
|
33
|
-
export { TableHeader, CheckHeader, SiteHeader, UrlHeader, ActionsHeader };
|
|
37
|
+
export { TableHeader, CheckHeader, TypeHeader, SiteHeader, UrlHeader, ActionsHeader };
|
|
@@ -10,6 +10,7 @@ import { format } from "date-fns";
|
|
|
10
10
|
|
|
11
11
|
import { DeleteModal } from "../atoms";
|
|
12
12
|
import RedirectPanel from "../RedirectPanel";
|
|
13
|
+
import { DEFAULT_REDIRECT_TYPE, REDIRECT_TYPE_COLORS } from "../utils";
|
|
13
14
|
|
|
14
15
|
import * as S from "./style";
|
|
15
16
|
|
|
@@ -36,7 +37,11 @@ const RedirectItem = (props: IRedirectItemProps): JSX.Element => {
|
|
|
36
37
|
const toggleSecondaryPanel = () => setIsOpenedSecond(!isOpenedSecond);
|
|
37
38
|
|
|
38
39
|
const handleClick = () => {
|
|
39
|
-
const mappedRedirect = {
|
|
40
|
+
const mappedRedirect = {
|
|
41
|
+
...redirect,
|
|
42
|
+
type: redirect.type ?? DEFAULT_REDIRECT_TYPE,
|
|
43
|
+
to: { ...redirect.to, linkTo: redirect.to.pageId, href: redirect.to.url },
|
|
44
|
+
};
|
|
40
45
|
setFormValues(mappedRedirect);
|
|
41
46
|
setIsOpenedSecond(false);
|
|
42
47
|
toggleModal("edit");
|
|
@@ -94,6 +99,11 @@ const RedirectItem = (props: IRedirectItemProps): JSX.Element => {
|
|
|
94
99
|
onChange={handleOnChange}
|
|
95
100
|
/>
|
|
96
101
|
</S.CheckCell>
|
|
102
|
+
<S.TypeCell role="cell" onClick={handleClick} data-testid="redirect-type">
|
|
103
|
+
{redirect.type && (
|
|
104
|
+
<S.TypeTag type="square" text={redirect.type} color={REDIRECT_TYPE_COLORS[redirect.type]} />
|
|
105
|
+
)}
|
|
106
|
+
</S.TypeCell>
|
|
97
107
|
{!isSiteItem && (
|
|
98
108
|
<S.SiteCell role="cell" onClick={handleClick}>
|
|
99
109
|
{redirect.site?.siteName && <ElementsTooltip elements={[redirect.site.siteName]} maxChar={20} />}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import styled from "styled-components";
|
|
2
2
|
|
|
3
3
|
import { Cell, Row } from "@ax/components/TableList/TableItem/style";
|
|
4
|
+
import Tag from "@ax/components/Tag";
|
|
4
5
|
import { ActionMenu } from "@ax/components";
|
|
5
6
|
|
|
6
7
|
const CheckCell = styled(Cell)`
|
|
@@ -11,6 +12,22 @@ const CheckCell = styled(Cell)`
|
|
|
11
12
|
}
|
|
12
13
|
`;
|
|
13
14
|
|
|
15
|
+
// `Cell` es `flex-flow: column wrap`, que por defecto estira al hijo al ancho
|
|
16
|
+
// del content box; el chip debe ceñirse a su texto.
|
|
17
|
+
const TypeCell = styled(Cell)`
|
|
18
|
+
width: 96px;
|
|
19
|
+
align-items: flex-start;
|
|
20
|
+
`;
|
|
21
|
+
|
|
22
|
+
// `Tag` pinta el texto casi blanco en cuanto recibe `color`, ilegible sobre
|
|
23
|
+
// estos pasteles. El `&&` duplica la especificidad para ganarle a la regla de
|
|
24
|
+
// TagSquare siempre, y no solo cuando el orden de inyección acompaña.
|
|
25
|
+
const TypeTag = styled(Tag)`
|
|
26
|
+
&& {
|
|
27
|
+
color: ${(p) => p.theme.color.textMediumEmphasis};
|
|
28
|
+
}
|
|
29
|
+
`;
|
|
30
|
+
|
|
14
31
|
const SiteCell = styled(Cell)`
|
|
15
32
|
width: 200px;
|
|
16
33
|
`;
|
|
@@ -54,4 +71,15 @@ const ModalContent = styled.div`
|
|
|
54
71
|
}
|
|
55
72
|
`;
|
|
56
73
|
|
|
57
|
-
export {
|
|
74
|
+
export {
|
|
75
|
+
CheckCell,
|
|
76
|
+
TypeCell,
|
|
77
|
+
TypeTag,
|
|
78
|
+
SiteCell,
|
|
79
|
+
ActionsCell,
|
|
80
|
+
UrlCell,
|
|
81
|
+
StyledActionMenu,
|
|
82
|
+
ItemRow,
|
|
83
|
+
ModalContent,
|
|
84
|
+
DateCell,
|
|
85
|
+
};
|
|
@@ -1,12 +1,27 @@
|
|
|
1
1
|
import React from "react";
|
|
2
2
|
import { connect } from "react-redux";
|
|
3
3
|
|
|
4
|
-
import type { IRedirect } from "@ax/types";
|
|
4
|
+
import type { IRedirect, RedirectType } from "@ax/types";
|
|
5
5
|
import { Button, FieldsBehavior, FloatingPanel } from "@ax/components";
|
|
6
6
|
import { redirectsActions } from "@ax/containers/Redirects";
|
|
7
7
|
|
|
8
8
|
import * as S from "./style";
|
|
9
9
|
|
|
10
|
+
const REDIRECTION_TYPE_OPTIONS = [
|
|
11
|
+
{
|
|
12
|
+
name: "permanent",
|
|
13
|
+
value: "301",
|
|
14
|
+
title: "301 - Permanent",
|
|
15
|
+
description: "Keeps SEO. Use for definitive redirects.",
|
|
16
|
+
},
|
|
17
|
+
{
|
|
18
|
+
name: "temporary",
|
|
19
|
+
value: "302",
|
|
20
|
+
title: "302 - Temporary",
|
|
21
|
+
description: "Doesn’t keep SEO. Use for short-lived changes.",
|
|
22
|
+
},
|
|
23
|
+
];
|
|
24
|
+
|
|
10
25
|
const RedirectPanel = (props: IProps): JSX.Element => {
|
|
11
26
|
const {
|
|
12
27
|
isOpen,
|
|
@@ -30,11 +45,18 @@ const RedirectPanel = (props: IProps): JSX.Element => {
|
|
|
30
45
|
setFormValues({ ...formValues, to: toValue });
|
|
31
46
|
};
|
|
32
47
|
|
|
48
|
+
const handleType = (newValue: RedirectType) => setFormValues({ ...formValues, type: newValue });
|
|
49
|
+
|
|
33
50
|
const addItemAction = () => addRedirect();
|
|
34
51
|
|
|
35
52
|
const editItemAction = async () => {
|
|
36
53
|
const toPage = formValues.to.pageId ? formValues.to.pageId : formValues.to.url;
|
|
37
|
-
formValues.id &&
|
|
54
|
+
formValues.id &&
|
|
55
|
+
(await updateRedirect(
|
|
56
|
+
{ from: formValues.from, to: toPage, type: formValues.type },
|
|
57
|
+
formValues.id,
|
|
58
|
+
currentFilter,
|
|
59
|
+
));
|
|
38
60
|
toggleModal();
|
|
39
61
|
};
|
|
40
62
|
|
|
@@ -81,6 +103,17 @@ const RedirectPanel = (props: IProps): JSX.Element => {
|
|
|
81
103
|
autoComplete="redirect-new"
|
|
82
104
|
helptext="Select an internal page or paste an external url."
|
|
83
105
|
/>
|
|
106
|
+
{/* El panel de alta sigue montado mientras se edita una fila. Con el mismo
|
|
107
|
+
`name` y sin <form>, el navegador trata los radios de los dos paneles
|
|
108
|
+
como un solo grupo y el `checked` se lo queda el panel cerrado. */}
|
|
109
|
+
<FieldsBehavior
|
|
110
|
+
title="Redirection type"
|
|
111
|
+
name={redirect ? `redirectionType-${redirect.id}` : "redirectionType"}
|
|
112
|
+
fieldType="RadioGroup"
|
|
113
|
+
value={formValues.type}
|
|
114
|
+
options={REDIRECTION_TYPE_OPTIONS}
|
|
115
|
+
onChange={handleType}
|
|
116
|
+
/>
|
|
84
117
|
<S.Footer>
|
|
85
118
|
{redirect ? (
|
|
86
119
|
<Button className="button" type="button" onClick={editButton.action} disabled={editButton.disabled}>
|
|
@@ -113,7 +146,11 @@ interface ICategoryPanelProps {
|
|
|
113
146
|
|
|
114
147
|
interface IDispatchProps {
|
|
115
148
|
addRedirect(force?: boolean): void;
|
|
116
|
-
updateRedirect(
|
|
149
|
+
updateRedirect(
|
|
150
|
+
redirect: { from: string; to: string | number; type?: RedirectType },
|
|
151
|
+
redirectID: number,
|
|
152
|
+
filter?: string,
|
|
153
|
+
): Promise<void>;
|
|
117
154
|
}
|
|
118
155
|
|
|
119
156
|
type IProps = ICategoryPanelProps & IDispatchProps;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { FloatingMenu, Icon, ListItem, ListTitle } from "@ax/components";
|
|
2
|
+
import type { IQueryValue, RedirectType } from "@ax/types";
|
|
3
|
+
|
|
4
|
+
import * as S from "./style";
|
|
5
|
+
|
|
6
|
+
const REDIRECT_TYPES: RedirectType[] = ["301", "302"];
|
|
7
|
+
|
|
8
|
+
const TypeFilter = (props: ITypeFilterProps): JSX.Element => {
|
|
9
|
+
const { filterItems, value } = props;
|
|
10
|
+
|
|
11
|
+
const selected = value?.length ? value[0].value : undefined;
|
|
12
|
+
|
|
13
|
+
// El menú no ofrece un "All": volver a marcar el tipo activo es lo que lo
|
|
14
|
+
// quita, y la barra de filtros seleccionados conserva su "Clear All".
|
|
15
|
+
const chooseType = (type: RedirectType) =>
|
|
16
|
+
filterItems("type", selected === type ? [] : [{ value: type, label: type }]);
|
|
17
|
+
|
|
18
|
+
const Header = () => (
|
|
19
|
+
<S.Type isActive={!!selected} data-testid="redirect-type-filter">
|
|
20
|
+
Type
|
|
21
|
+
<S.IconsWrapper>
|
|
22
|
+
{selected ? <Icon name="Filter" size="16" /> : <Icon name="DownArrow" size="16" />}
|
|
23
|
+
</S.IconsWrapper>
|
|
24
|
+
</S.Type>
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
return (
|
|
28
|
+
<FloatingMenu Button={Header} position="left">
|
|
29
|
+
<ListTitle>Filter by type</ListTitle>
|
|
30
|
+
{REDIRECT_TYPES.map((type) => (
|
|
31
|
+
<ListItem key={type} isSelected={selected === type} onClick={() => chooseType(type)}>
|
|
32
|
+
{type}
|
|
33
|
+
</ListItem>
|
|
34
|
+
))}
|
|
35
|
+
</FloatingMenu>
|
|
36
|
+
);
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
export interface ITypeFilterProps {
|
|
40
|
+
filterItems(pointer: string, filter: IQueryValue[]): void;
|
|
41
|
+
value?: IQueryValue[];
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default TypeFilter;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import styled from "styled-components";
|
|
2
|
+
|
|
3
|
+
// Va dentro de la celda `TypeHeader`, que ya pone el ancho y el padding de la
|
|
4
|
+
// columna: aquí solo el estado del filtro.
|
|
5
|
+
const Type = styled.div<{ isActive: boolean }>`
|
|
6
|
+
${(p) => p.theme.textStyle.uiS};
|
|
7
|
+
box-sizing: border-box;
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: row;
|
|
10
|
+
width: 100%;
|
|
11
|
+
position: relative;
|
|
12
|
+
color: ${(p) => (p.isActive ? p.theme.color.textHighEmphasis : p.theme.color.textMediumEmphasis)};
|
|
13
|
+
&:hover {
|
|
14
|
+
color: ${(p) => p.theme.color.interactive01};
|
|
15
|
+
}
|
|
16
|
+
`;
|
|
17
|
+
|
|
18
|
+
const IconsWrapper = styled.div`
|
|
19
|
+
display: flex;
|
|
20
|
+
align-items: center;
|
|
21
|
+
flex-direction: row;
|
|
22
|
+
svg {
|
|
23
|
+
margin-left: 4px;
|
|
24
|
+
}
|
|
25
|
+
`;
|
|
26
|
+
|
|
27
|
+
export { Type, IconsWrapper };
|