@truedat/auth 8.14.3 → 8.14.5

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 (78) hide show
  1. package/package.json +4 -4
  2. package/src/groups/components/GroupCard.js +2 -2
  3. package/src/groups/components/GroupCards.js +129 -37
  4. package/src/groups/components/GroupForm.js +13 -7
  5. package/src/groups/components/GroupFormModal.js +8 -7
  6. package/src/groups/components/GroupInfoModal.js +6 -6
  7. package/src/groups/components/GroupMemberPicker.js +2 -4
  8. package/src/groups/components/GroupRouteModal.js +15 -10
  9. package/src/groups/components/__tests__/GroupCards.spec.js +94 -0
  10. package/src/groups/components/__tests__/GroupForm.spec.js +2 -2
  11. package/src/groups/components/__tests__/GroupModals.spec.js +42 -16
  12. package/src/groups/components/__tests__/__snapshots__/GroupCards.spec.js.snap +14 -0
  13. package/src/messages/en.js +6 -5
  14. package/src/messages/es.js +8 -7
  15. package/src/styles/Groups.less +96 -131
  16. package/src/styles/UserCard.less +416 -0
  17. package/src/styles/UserModals.less +831 -0
  18. package/src/styles/Users.less +313 -0
  19. package/src/users/components/AdminUserRoutes.js +7 -20
  20. package/src/users/components/EditUser.js +7 -34
  21. package/src/users/components/EditUserModal.js +150 -0
  22. package/src/users/components/NewUser.js +128 -17
  23. package/src/users/components/Password.js +11 -189
  24. package/src/users/components/UserAcls.js +117 -6
  25. package/src/users/components/UserActions.js +99 -34
  26. package/src/users/components/UserCard.js +171 -22
  27. package/src/users/components/UserCards.js +188 -35
  28. package/src/users/components/UserFormFields.js +206 -0
  29. package/src/users/components/UserGroupAclRow.js +8 -2
  30. package/src/users/components/UserGroupAcls.js +2 -2
  31. package/src/users/components/UserGroupLink.js +21 -0
  32. package/src/users/components/UserInfoModal.js +228 -0
  33. package/src/users/components/UserPasswordField.js +66 -0
  34. package/src/users/components/UserPasswordModal.js +210 -0
  35. package/src/users/components/UserRoutes.js +4 -13
  36. package/src/users/components/UserTabs.js +5 -2
  37. package/src/users/components/UserTypeSelector.js +109 -0
  38. package/src/users/components/UsersAndGroups.js +39 -5
  39. package/src/users/components/UsersTable.js +234 -0
  40. package/src/users/components/__tests__/EditUser.spec.js +84 -10
  41. package/src/users/components/__tests__/NewUser.spec.js +34 -4
  42. package/src/users/components/__tests__/Password.spec.js +53 -146
  43. package/src/users/components/__tests__/UserAcls.spec.js +62 -2
  44. package/src/users/components/__tests__/UserCard.spec.js +111 -5
  45. package/src/users/components/__tests__/UserCards.spec.js +149 -0
  46. package/src/users/components/__tests__/UserGroupAclRow.spec.js +19 -1
  47. package/src/users/components/__tests__/UserModals.spec.js +554 -0
  48. package/src/users/components/__tests__/UserRoutes.spec.js +8 -3
  49. package/src/users/components/__tests__/UserTypeSelector.spec.js +32 -0
  50. package/src/users/components/__tests__/UsersAndGroups.spec.js +17 -0
  51. package/src/users/components/__tests__/__snapshots__/UserCard.spec.js.snap +66 -37
  52. package/src/users/components/__tests__/__snapshots__/UserCards.spec.js.snap +375 -75
  53. package/src/users/components/__tests__/__snapshots__/UserGroupAclRow.spec.js.snap +7 -3
  54. package/src/users/components/__tests__/__snapshots__/UserRoutes.spec.js.snap +4 -13
  55. package/src/users/hooks/useSortedGroups.js +13 -0
  56. package/src/users/hooks/useUserModalOperation.js +55 -0
  57. package/src/users/reducers/__tests__/userRedirect.spec.js +14 -3
  58. package/src/users/reducers/index.js +1 -0
  59. package/src/users/reducers/userModalOperation.js +29 -0
  60. package/src/users/reducers/userRedirect.js +5 -4
  61. package/src/users/sagas/__tests__/updateUser.spec.js +19 -1
  62. package/src/users/sagas/createUser.js +12 -5
  63. package/src/users/sagas/deleteUser.js +12 -5
  64. package/src/users/sagas/updateUser.js +19 -8
  65. package/src/users/selectors/__tests__/filterUsers.spec.js +11 -0
  66. package/src/users/selectors/__tests__/getUserAcls.spec.js +1 -0
  67. package/src/users/selectors/__tests__/getUserGroupAcls.spec.js +2 -0
  68. package/src/users/selectors/filterUsers.js +9 -1
  69. package/src/users/selectors/getUserGroupAcls.js +1 -0
  70. package/src/users/components/User.js +0 -68
  71. package/src/users/components/UserPassword.js +0 -24
  72. package/src/users/components/__tests__/User.spec.js +0 -20
  73. package/src/users/components/__tests__/UserPassword.spec.js +0 -24
  74. package/src/users/components/__tests__/__snapshots__/EditUser.spec.js.snap +0 -281
  75. package/src/users/components/__tests__/__snapshots__/NewUser.spec.js.snap +0 -321
  76. package/src/users/components/__tests__/__snapshots__/Password.spec.js.snap +0 -128
  77. package/src/users/components/__tests__/__snapshots__/User.spec.js.snap +0 -129
  78. package/src/users/components/__tests__/__snapshots__/UserPassword.spec.js.snap +0 -124
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@truedat/auth",
3
- "version": "8.14.3",
3
+ "version": "8.14.5",
4
4
  "description": "Truedat Web Auth",
5
5
  "sideEffects": false,
6
6
  "jsnext:main": "src/index.js",
@@ -54,14 +54,14 @@
54
54
  "@testing-library/jest-dom": "^6.6.3",
55
55
  "@testing-library/react": "^16.3.0",
56
56
  "@testing-library/user-event": "^14.6.1",
57
- "@truedat/test": "8.14.3",
57
+ "@truedat/test": "8.14.5",
58
58
  "identity-obj-proxy": "^3.0.0",
59
59
  "jest": "^29.7.0",
60
60
  "redux-saga-test-plan": "^4.0.6"
61
61
  },
62
62
  "dependencies": {
63
63
  "@apollo/client": "^3.13.8",
64
- "@truedat/core": "8.14.3",
64
+ "@truedat/core": "8.14.5",
65
65
  "auth0-js": "^10.2.0",
66
66
  "axios": "^1.20.0",
67
67
  "graphql": "^16.11.0",
@@ -90,5 +90,5 @@
90
90
  "swr": "^2.3.3"
91
91
  },
92
92
  "overrides": {},
93
- "gitHead": "ca5cc79e39ed78a1b5e317d825832e0676b011f6"
93
+ "gitHead": "a792103a77b3b2392be5777a8dc6675ac3882ffd"
94
94
  }
@@ -45,8 +45,8 @@ export const GroupCard = ({ group }) => {
45
45
  <span className="gc-count-label">
46
46
  {formatMessage(
47
47
  isEmpty
48
- ? { id: "group.members.none", defaultMessage: "Sin miembros" }
49
- : { id: "navigation.members", defaultMessage: "miembros" },
48
+ ? { id: "group.members.none", defaultMessage: "No members" }
49
+ : { id: "navigation.members", defaultMessage: "members" },
50
50
  )}
51
51
  </span>
52
52
  </span>
@@ -1,42 +1,137 @@
1
+ import { useEffect, useState } from "react";
1
2
  import PropTypes from "prop-types";
2
3
  import { Link } from "react-router";
3
- import { Button, Icon } from "semantic-ui-react";
4
- import { FormattedMessage } from "react-intl";
4
+ import { Button, Icon, Input, Message, Popup } from "semantic-ui-react";
5
+ import { FormattedMessage, useIntl } from "react-intl";
6
+ import { lowerDeburrTrim } from "@truedat/core/services/sort";
5
7
  import { GROUP_CREATE } from "@truedat/core/routes";
6
8
  import { useGroups } from "../hooks/useGroups";
7
9
  import GroupCard from "./GroupCard";
8
10
 
9
- export const GroupCards = ({ groups = [] }) => (
10
- <>
11
- <div className="groups-toolbar">
12
- <Button
13
- primary
14
- as={Link}
15
- to={GROUP_CREATE}
16
- className="group-create-button"
17
- >
18
- <span className="group-create-icon" aria-hidden="true">
19
- <Icon name="users" />
20
- <svg viewBox="0 0 16 16" focusable="false">
21
- <path
22
- d="M8 3v10M3 8h10"
23
- fill="none"
24
- stroke="currentColor"
25
- strokeWidth="2"
26
- strokeLinecap="round"
11
+ export const GroupCards = ({ groups = [] }) => {
12
+ const [search, setSearch] = useState("");
13
+ const [hintOpen, setHintOpen] = useState(false);
14
+ useEffect(() => {
15
+ try {
16
+ setHintOpen(
17
+ localStorage.getItem("truedat.groups.search-hint.dismissed") !== "true",
18
+ );
19
+ } catch {
20
+ setHintOpen(true);
21
+ }
22
+ }, []);
23
+ const { formatMessage } = useIntl();
24
+ const query = lowerDeburrTrim(search);
25
+ const filteredGroups = query
26
+ ? groups.filter((group) =>
27
+ [
28
+ group.name,
29
+ group.alias,
30
+ group.description,
31
+ ...(group.users || []).flatMap((user) => [
32
+ user.user_name,
33
+ user.full_name,
34
+ ]),
35
+ ].some((value) => lowerDeburrTrim(value).includes(query)),
36
+ )
37
+ : groups;
38
+ const searchLabel = formatMessage({
39
+ id: "groups.search.placeholder",
40
+ defaultMessage: "Search groups or users…",
41
+ });
42
+ return (
43
+ <>
44
+ <div className="groups-toolbar">
45
+ <Popup
46
+ className="groups-search-hint-popup"
47
+ content={
48
+ <div className="groups-search-hint">
49
+ <FormattedMessage
50
+ id="groups.search.hint"
51
+ defaultMessage="You can now <emphasis>also</emphasis> search for users within groups"
52
+ values={{
53
+ emphasis: (text) => (
54
+ <strong>
55
+ <em>{text}</em>
56
+ </strong>
57
+ ),
58
+ }}
59
+ />
60
+ <Button
61
+ icon
62
+ basic
63
+ className="groups-search-hint-close"
64
+ aria-label={formatMessage({
65
+ id: "actions.close",
66
+ defaultMessage: "Close",
67
+ })}
68
+ onClick={() => {
69
+ setHintOpen(false);
70
+ try {
71
+ localStorage.setItem(
72
+ "truedat.groups.search-hint.dismissed",
73
+ "true",
74
+ );
75
+ } catch {
76
+ // Keep the hint dismissed for this render if storage is unavailable.
77
+ }
78
+ }}
79
+ >
80
+ <Icon name="close" />
81
+ </Button>
82
+ </div>
83
+ }
84
+ open={hintOpen}
85
+ on={[]}
86
+ position="top left"
87
+ trigger={
88
+ <Input
89
+ icon="search"
90
+ iconPosition="left"
91
+ value={search}
92
+ onChange={(_event, { value }) => setSearch(value)}
93
+ placeholder={searchLabel}
94
+ aria-label={searchLabel}
27
95
  />
28
- </svg>
29
- </span>
30
- <FormattedMessage id="groups.actions.create" />
31
- </Button>
32
- </div>
33
- <div className="groups-card-list">
34
- {groups.map((group) => (
35
- <GroupCard key={group.id} group={group} />
36
- ))}
37
- </div>
38
- </>
39
- );
96
+ }
97
+ />
98
+ <Button
99
+ primary
100
+ as={Link}
101
+ to={GROUP_CREATE}
102
+ className="group-create-button"
103
+ >
104
+ <span className="group-create-icon" aria-hidden="true">
105
+ <Icon name="users" />
106
+ <svg viewBox="0 0 16 16" focusable="false">
107
+ <path
108
+ d="M8 3v10M3 8h10"
109
+ fill="none"
110
+ stroke="currentColor"
111
+ strokeWidth="2"
112
+ strokeLinecap="round"
113
+ />
114
+ </svg>
115
+ </span>
116
+ <FormattedMessage id="groups.actions.create" />
117
+ </Button>
118
+ </div>
119
+ <div className="groups-card-list">
120
+ {filteredGroups.map((group) => (
121
+ <GroupCard key={group.id} group={group} />
122
+ ))}
123
+ </div>
124
+ {query && filteredGroups.length === 0 && (
125
+ <Message role="status">
126
+ <FormattedMessage
127
+ id="groups.search.no_results"
128
+ defaultMessage="No groups found for this search."
129
+ />
130
+ </Message>
131
+ )}
132
+ </>
133
+ );
134
+ };
40
135
  GroupCards.propTypes = { groups: PropTypes.array };
41
136
  export const GroupsList = () => {
42
137
  const { groups, error, loading, retry } = useGroups();
@@ -46,7 +141,7 @@ export const GroupsList = () => {
46
141
  <p role="status">
47
142
  <FormattedMessage
48
143
  id="group.loading"
49
- defaultMessage="Cargando grupo…"
144
+ defaultMessage="Loading groups…"
50
145
  />
51
146
  </p>
52
147
  )}
@@ -54,10 +149,7 @@ export const GroupsList = () => {
54
149
  <div role="alert">
55
150
  <FormattedMessage id="group.unavailable" />
56
151
  <Button onClick={() => retry().catch(() => {})}>
57
- <FormattedMessage
58
- id="group.members.retry"
59
- defaultMessage="Reintentar"
60
- />
152
+ <FormattedMessage id="group.members.retry" defaultMessage="Retry" />
61
153
  </Button>
62
154
  </div>
63
155
  )}
@@ -30,6 +30,7 @@ export const GroupForm = ({
30
30
  onCancel,
31
31
  onDelete,
32
32
  memberFilters = false,
33
+ editingModal = false,
33
34
  }) => {
34
35
  const { formatMessage } = useIntl();
35
36
  const nameFieldId = useId();
@@ -74,7 +75,7 @@ export const GroupForm = ({
74
75
  memberFilters
75
76
  ? {
76
77
  id: "group.form.name.label",
77
- defaultMessage: "Nombre del grupo",
78
+ defaultMessage: "Group name",
78
79
  }
79
80
  : { id: "group.props.name" },
80
81
  )}
@@ -115,7 +116,7 @@ export const GroupForm = ({
115
116
  message: formatMessage({
116
117
  id: "group.description.maxLength",
117
118
  defaultMessage:
118
- "La descripción no puede superar los 55 caracteres.",
119
+ "The description cannot exceed 55 characters.",
119
120
  }),
120
121
  },
121
122
  }
@@ -131,14 +132,14 @@ export const GroupForm = ({
131
132
  ? "group.description.short"
132
133
  : "group.props.description",
133
134
  defaultMessage: memberFilters
134
- ? "Descripción breve"
135
- : "Descripción",
135
+ ? "Short description"
136
+ : "Description",
136
137
  })}
137
138
  onBlur={onBlur}
138
139
  onChange={(_e, { value }) => onChange(value)}
139
140
  placeholder={formatMessage({
140
141
  id: "group.form.description.placeholder",
141
- defaultMessage: "Descripción del grupo",
142
+ defaultMessage: "Group description",
142
143
  })}
143
144
  value={value || ""}
144
145
  />
@@ -196,7 +197,9 @@ export const GroupForm = ({
196
197
  secondary
197
198
  disabled={isSubmitting}
198
199
  onClick={onCancel}
199
- content={formatMessage({ id: "actions.cancel" })}
200
+ content={formatMessage({
201
+ id: editingModal ? "actions.close" : "actions.cancel",
202
+ })}
200
203
  />
201
204
  ) : (
202
205
  <HistoryBackButton
@@ -209,7 +212,9 @@ export const GroupForm = ({
209
212
  primary
210
213
  loading={isSubmitting}
211
214
  disabled={isSubmitting || !isDirty || !isValid}
212
- content={formatMessage({ id: "actions.save" })}
215
+ content={formatMessage({
216
+ id: editingModal ? "actions.edit" : "actions.save",
217
+ })}
213
218
  />
214
219
  </div>
215
220
  </Form>
@@ -219,6 +224,7 @@ export const GroupForm = ({
219
224
  GroupForm.propTypes = {
220
225
  users: PropTypes.array,
221
226
  memberFilters: PropTypes.bool,
227
+ editingModal: PropTypes.bool,
222
228
  group: PropTypes.object,
223
229
  groupUsers: PropTypes.array,
224
230
  onSubmit: PropTypes.func,
@@ -46,7 +46,7 @@ const GroupFormModal = ({ editing = false, onClose }) => {
46
46
  ? formatMessage(
47
47
  {
48
48
  id: "group.edit.title",
49
- defaultMessage: "Editar grupo {name}",
49
+ defaultMessage: "Edit group {name}",
50
50
  },
51
51
  { name: group?.name || "" },
52
52
  )
@@ -62,7 +62,7 @@ const GroupFormModal = ({ editing = false, onClose }) => {
62
62
  ? formatMessage({
63
63
  id: "group.operation.error",
64
64
  defaultMessage:
65
- "No se ha podido completar la operación. Inténtalo de nuevo.",
65
+ "The operation could not be completed. Please try again.",
66
66
  })
67
67
  : null
68
68
  }
@@ -72,6 +72,7 @@ const GroupFormModal = ({ editing = false, onClose }) => {
72
72
  <GroupForm
73
73
  key={editing ? group.id : "new"}
74
74
  memberFilters
75
+ editingModal={editing}
75
76
  group={editing ? group : {}}
76
77
  groupUsers={editing ? groupUsers : []}
77
78
  users={users}
@@ -89,8 +90,8 @@ const GroupFormModal = ({ editing = false, onClose }) => {
89
90
  loading || usersLoading ? "group.loading" : "group.unavailable",
90
91
  defaultMessage:
91
92
  loading || usersLoading
92
- ? "Cargando grupo…"
93
- : "El grupo no está disponible.",
93
+ ? "Loading group…"
94
+ : "The group is unavailable.",
94
95
  })}
95
96
  </p>
96
97
  )}
@@ -98,12 +99,12 @@ const GroupFormModal = ({ editing = false, onClose }) => {
98
99
  <div role="alert">
99
100
  {formatMessage({
100
101
  id: "group.members.error",
101
- defaultMessage: "No se han podido cargar los usuarios.",
102
+ defaultMessage: "Could not load users.",
102
103
  })}
103
104
  <button type="button" onClick={() => retryUsers().catch(() => {})}>
104
105
  {formatMessage({
105
106
  id: "group.members.retry",
106
- defaultMessage: "Reintentar",
107
+ defaultMessage: "Retry",
107
108
  })}
108
109
  </button>
109
110
  </div>
@@ -114,7 +115,7 @@ const GroupFormModal = ({ editing = false, onClose }) => {
114
115
  <button type="button" onClick={() => retry().catch(() => {})}>
115
116
  {formatMessage({
116
117
  id: "group.members.retry",
117
- defaultMessage: "Reintentar",
118
+ defaultMessage: "Retry",
118
119
  })}
119
120
  </button>
120
121
  </div>
@@ -29,7 +29,7 @@ const GroupInfoModal = ({ onClose }) => {
29
29
  ? group.name
30
30
  : formatMessage({
31
31
  id: "group.loading",
32
- defaultMessage: "Cargando grupo…",
32
+ defaultMessage: "Loading group…",
33
33
  })
34
34
  }
35
35
  subtitle={ready ? group.description || "" : ""}
@@ -60,7 +60,7 @@ const GroupInfoModal = ({ onClose }) => {
60
60
  <Button type="button" onClick={() => retry().catch(() => {})}>
61
61
  {formatMessage({
62
62
  id: "group.members.retry",
63
- defaultMessage: "Reintentar",
63
+ defaultMessage: "Retry",
64
64
  })}
65
65
  </Button>
66
66
  </div>
@@ -69,8 +69,8 @@ const GroupInfoModal = ({ onClose }) => {
69
69
  {formatMessage({
70
70
  id: loading ? "group.loading" : "group.unavailable",
71
71
  defaultMessage: loading
72
- ? "Cargando grupo…"
73
- : "El grupo no está disponible.",
72
+ ? "Loading group…"
73
+ : "The group is unavailable.",
74
74
  })}
75
75
  </p>
76
76
  ) : empty ? (
@@ -79,13 +79,13 @@ const GroupInfoModal = ({ onClose }) => {
79
79
  <h3>
80
80
  {formatMessage({
81
81
  id: "group.members.empty.title",
82
- defaultMessage: "Este grupo no tiene miembros",
82
+ defaultMessage: "This group has no members",
83
83
  })}
84
84
  </h3>
85
85
  <p>
86
86
  {formatMessage({
87
87
  id: "group.members.empty.description",
88
- defaultMessage: "Edita el grupo para añadir miembros.",
88
+ defaultMessage: "Edit the group to add members.",
89
89
  })}
90
90
  </p>
91
91
  <Button
@@ -310,7 +310,7 @@ const GroupMemberPicker = ({
310
310
  aria-label={formatMessage(
311
311
  {
312
312
  id: "group.members.remove",
313
- defaultMessage: "Eliminar a {name} del grupo",
313
+ defaultMessage: "Remove {name} from the group",
314
314
  },
315
315
  { name },
316
316
  )}
@@ -325,9 +325,7 @@ const GroupMemberPicker = ({
325
325
  })}
326
326
  </ul>
327
327
  ) : (
328
- <p>
329
- {message("group.members.empty", "No hay miembros seleccionados")}
330
- </p>
328
+ <p>{message("group.members.empty", "No members selected")}</p>
331
329
  )}
332
330
  </section>
333
331
  {showFilters && failed && (
@@ -1,5 +1,5 @@
1
1
  import PropTypes from "prop-types";
2
- import { useCallback, useEffect } from "react";
2
+ import { useCallback, useEffect, useRef } from "react";
3
3
  import { useNavigate, useLocation, useParams } from "react-router";
4
4
  import { GROUPS } from "@truedat/core/routes";
5
5
  import GroupFormModal from "./GroupFormModal";
@@ -9,11 +9,14 @@ const GroupRouteModal = ({ mode }) => {
9
9
  const navigate = useNavigate();
10
10
  const { pathname } = useLocation();
11
11
  const { id } = useParams();
12
+ const keyboardClose = useRef(false);
12
13
  useEffect(() => {
13
14
  const opener = document.activeElement;
15
+ const keyboardFocus = opener?.matches(":focus-visible");
14
16
  return () => {
17
+ const restoreFocus = keyboardFocus || keyboardClose.current;
15
18
  requestAnimationFrame(() => {
16
- if (document.querySelector(".group-modal")) return;
19
+ if (!restoreFocus || document.querySelector(".group-modal")) return;
17
20
  if (opener?.isConnected && opener !== document.body) opener.focus();
18
21
  else
19
22
  document
@@ -22,14 +25,16 @@ const GroupRouteModal = ({ mode }) => {
22
25
  });
23
26
  };
24
27
  }, [pathname]);
25
- const onClose = useCallback(() => {
26
- navigate(GROUPS);
27
- requestAnimationFrame(() => {
28
- document
29
- .querySelector(`a[href="${pathname.replace(/\/edit$/, "")}"]`)
30
- ?.focus();
31
- });
32
- }, [navigate, pathname]);
28
+ const onClose = useCallback(
29
+ (event) => {
30
+ keyboardClose.current =
31
+ event?.key === "Escape" ||
32
+ event?.detail === 0 ||
33
+ event?.currentTarget?.matches?.(":focus-visible");
34
+ navigate(GROUPS);
35
+ },
36
+ [navigate],
37
+ );
33
38
  return mode === "info" ? (
34
39
  <GroupInfoModal key={id} onClose={onClose} />
35
40
  ) : (
@@ -1,3 +1,6 @@
1
+ import { StrictMode } from "react";
2
+ import { screen } from "@testing-library/react";
3
+ import userEvent from "@testing-library/user-event";
1
4
  import { render, waitForLoad } from "@truedat/test/render";
2
5
  import { GroupCards } from "../GroupCards";
3
6
 
@@ -15,3 +18,94 @@ describe("<GroupCards />", () => {
15
18
  expect(rendered.container).toMatchSnapshot();
16
19
  });
17
20
  });
21
+
22
+ const searchableGroups = [
23
+ {
24
+ id: 1,
25
+ name: "finance",
26
+ alias: "Finanzas",
27
+ description: "Gestión de presupuestos",
28
+ users: [{ user_name: "jgarcia", full_name: "José García" }],
29
+ },
30
+ { id: 2, name: "quality", users: [{ user_name: "jgarcia" }] },
31
+ { id: 3, name: "empty", users: [] },
32
+ { id: 4, name: "other" },
33
+ ];
34
+
35
+ it.each([
36
+ ["FINANCE", 1],
37
+ ["finanzas", 1],
38
+ ["gestion", 1],
39
+ [" JGARC ", 2],
40
+ ["jose garcia", 1],
41
+ [" ", 4],
42
+ ])("matches groups and their members for %s", async (search, count) => {
43
+ const user = userEvent.setup({ delay: null });
44
+ render(<GroupCards groups={searchableGroups} />);
45
+ await user.type(screen.getByRole("textbox"), search);
46
+ expect(screen.getAllByRole("article")).toHaveLength(count);
47
+ });
48
+
49
+ it("shows no results and restores all groups when the search is cleared", async () => {
50
+ const user = userEvent.setup({ delay: null });
51
+ render(<GroupCards groups={searchableGroups} />);
52
+ const search = screen.getByRole("textbox");
53
+ await user.type(search, "unmatched");
54
+ expect(screen.queryAllByRole("article")).toHaveLength(0);
55
+ expect(screen.getByRole("status")).toBeInTheDocument();
56
+ await user.clear(search);
57
+ expect(screen.getAllByRole("article")).toHaveLength(4);
58
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
59
+ });
60
+
61
+ describe("group search introduction", () => {
62
+ const key = "truedat.groups.search-hint.dismissed";
63
+ const hint = "also";
64
+ const options = { state: { authentication: { user_name: "alice" } } };
65
+ beforeEach(() => localStorage.clear());
66
+ afterEach(() => jest.restoreAllMocks());
67
+
68
+ it("keeps the hint visible until dismissed and remembers the dismissal", async () => {
69
+ const user = userEvent.setup({ delay: null });
70
+ const rendered = render(
71
+ <StrictMode>
72
+ <GroupCards />
73
+ </StrictMode>,
74
+ options,
75
+ );
76
+ expect(screen.getByText(hint).tagName).toBe("EM");
77
+ expect(screen.getByText(hint).parentElement.tagName).toBe("STRONG");
78
+ expect(localStorage.getItem(key)).toBeNull();
79
+ await user.click(screen.getByRole("textbox"));
80
+ expect(screen.getByText(hint)).toBeInTheDocument();
81
+ await user.click(screen.getByRole("button", { name: "Close" }));
82
+ expect(screen.queryByText(hint)).not.toBeInTheDocument();
83
+ expect(localStorage.getItem(key)).toBe("true");
84
+ rendered.rerender(
85
+ <StrictMode>
86
+ <GroupCards groups={searchableGroups} />
87
+ </StrictMode>,
88
+ );
89
+ rendered.unmount();
90
+ render(<GroupCards />, options);
91
+ expect(screen.queryByText(hint)).not.toBeInTheDocument();
92
+ });
93
+
94
+ it("remembers dismissal across users in the same browser", () => {
95
+ localStorage.setItem(key, "true");
96
+ render(<GroupCards />, { state: { authentication: { user_name: "bob" } } });
97
+ expect(screen.queryByText(hint)).not.toBeInTheDocument();
98
+ expect(localStorage.length).toBe(1);
99
+ });
100
+
101
+ it("keeps search usable when storage is unavailable", async () => {
102
+ jest.spyOn(Storage.prototype, "getItem").mockImplementation(() => {
103
+ throw new Error("unavailable");
104
+ });
105
+ const user = userEvent.setup({ delay: null });
106
+ render(<GroupCards groups={searchableGroups} />, options);
107
+ expect(screen.getByText(hint)).toBeInTheDocument();
108
+ await user.type(screen.getByRole("textbox"), "finance");
109
+ expect(screen.getAllByRole("article")).toHaveLength(1);
110
+ });
111
+ });
@@ -78,7 +78,7 @@ describe("<GroupForm />", () => {
78
78
  "Team Alpha",
79
79
  );
80
80
  await user.type(
81
- rendered.getByPlaceholderText(/Descripción del grupo/i),
81
+ rendered.getByPlaceholderText(/Group description/i),
82
82
  "Alpha description",
83
83
  );
84
84
  await user.type(
@@ -94,7 +94,7 @@ describe("<GroupForm />", () => {
94
94
  rendered.getByPlaceholderText(/group.form.name.placeholder/i),
95
95
  ).toHaveValue("Team Alpha"),
96
96
  );
97
- expect(rendered.getByPlaceholderText(/Descripción del grupo/i)).toHaveValue(
97
+ expect(rendered.getByPlaceholderText(/Group description/i)).toHaveValue(
98
98
  "Alpha description",
99
99
  );
100
100
  expect(