@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.
Files changed (26) hide show
  1. package/package.json +2 -2
  2. package/src/__tests__/components/Fields/ConditionalField/ConditionalField.test.tsx +17 -0
  3. package/src/__tests__/components/Fields/RadioField/RadioField.test.tsx +66 -0
  4. package/src/__tests__/components/Fields/RadioGroup/RadioGroup.test.tsx +76 -0
  5. package/src/__tests__/modules/Redirects/BulkHeader/TableHeader.test.tsx +123 -0
  6. package/src/__tests__/modules/Redirects/RedirectItem/RedirectItem.test.tsx +163 -0
  7. package/src/__tests__/modules/Redirects/RedirectPanel/RedirectPanel.test.tsx +134 -0
  8. package/src/__tests__/modules/Redirects/Redirects/Redirects.create.test.tsx +69 -0
  9. package/src/__tests__/modules/Redirects/Redirects/Redirects.edit.test.tsx +91 -0
  10. package/src/__tests__/modules/Redirects/Redirects/Redirects.filter.test.tsx +131 -0
  11. package/src/components/Fields/ConditionalField/style.tsx +1 -1
  12. package/src/components/Fields/RadioField/index.tsx +20 -3
  13. package/src/components/Fields/RadioField/style.tsx +18 -1
  14. package/src/components/Fields/RadioGroup/index.tsx +1 -0
  15. package/src/containers/Redirects/actions.tsx +15 -3
  16. package/src/modules/Redirects/BulkHeader/TableHeader/index.tsx +5 -0
  17. package/src/modules/Redirects/BulkHeader/TableHeader/style.tsx +5 -1
  18. package/src/modules/Redirects/RedirectItem/index.tsx +11 -1
  19. package/src/modules/Redirects/RedirectItem/style.tsx +29 -1
  20. package/src/modules/Redirects/RedirectPanel/index.tsx +40 -3
  21. package/src/modules/Redirects/TypeFilter/index.tsx +44 -0
  22. package/src/modules/Redirects/TypeFilter/style.tsx +27 -0
  23. package/src/modules/Redirects/hooks.tsx +12 -4
  24. package/src/modules/Redirects/index.tsx +22 -14
  25. package/src/modules/Redirects/utils.tsx +10 -1
  26. 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
+ });
@@ -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 }>`
@@ -28,6 +28,7 @@ const RadioGroup = (props: IRadioGroupProps): JSX.Element => {
28
28
  checked={checked}
29
29
  value={item.value}
30
30
  title={item.title}
31
+ description={item.description}
31
32
  name={name}
32
33
  error={item.error}
33
34
  disabled={item.disabled || disabled}
@@ -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 = { ...redirect, to: { ...redirect.to, linkTo: redirect.to.pageId, href: redirect.to.url } };
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 { CheckCell, SiteCell, ActionsCell, UrlCell, StyledActionMenu, ItemRow, ModalContent, DateCell };
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 && (await updateRedirect({ from: formValues.from, to: toPage }, formValues.id, currentFilter));
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(redirect: { from: string; to: string | number }, redirectID: number, filter?: string): Promise<void>;
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 };