@truedat/auth 8.14.2 → 8.14.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/package.json +4 -4
  2. package/src/messages/en.js +6 -5
  3. package/src/messages/es.js +8 -7
  4. package/src/styles/UserCard.less +582 -0
  5. package/src/styles/UserModals.less +826 -0
  6. package/src/styles/Users.less +280 -0
  7. package/src/users/components/AdminUserRoutes.js +7 -20
  8. package/src/users/components/EditUser.js +7 -34
  9. package/src/users/components/EditUserModal.js +150 -0
  10. package/src/users/components/NewUser.js +128 -17
  11. package/src/users/components/Password.js +11 -189
  12. package/src/users/components/UserAcls.js +56 -6
  13. package/src/users/components/UserActions.js +99 -34
  14. package/src/users/components/UserCard.js +168 -22
  15. package/src/users/components/UserCards.js +273 -35
  16. package/src/users/components/UserFormFields.js +206 -0
  17. package/src/users/components/UserGroupAclRow.js +8 -2
  18. package/src/users/components/UserGroupAcls.js +2 -2
  19. package/src/users/components/UserGroupLink.js +21 -0
  20. package/src/users/components/UserInfoModal.js +268 -0
  21. package/src/users/components/UserPasswordField.js +66 -0
  22. package/src/users/components/UserPasswordModal.js +210 -0
  23. package/src/users/components/UserRoutes.js +4 -13
  24. package/src/users/components/UserTabs.js +5 -2
  25. package/src/users/components/UserTypeSelector.js +109 -0
  26. package/src/users/components/UsersAndGroups.js +39 -5
  27. package/src/users/components/UsersTable.js +234 -0
  28. package/src/users/components/__tests__/EditUser.spec.js +84 -10
  29. package/src/users/components/__tests__/NewUser.spec.js +34 -4
  30. package/src/users/components/__tests__/Password.spec.js +53 -146
  31. package/src/users/components/__tests__/UserAcls.spec.js +23 -2
  32. package/src/users/components/__tests__/UserCard.spec.js +111 -5
  33. package/src/users/components/__tests__/UserCards.spec.js +194 -0
  34. package/src/users/components/__tests__/UserGroupAclRow.spec.js +19 -1
  35. package/src/users/components/__tests__/UserModals.spec.js +507 -0
  36. package/src/users/components/__tests__/UserRoutes.spec.js +8 -3
  37. package/src/users/components/__tests__/UserTypeSelector.spec.js +32 -0
  38. package/src/users/components/__tests__/UsersAndGroups.spec.js +17 -0
  39. package/src/users/components/__tests__/__snapshots__/UserCard.spec.js.snap +64 -37
  40. package/src/users/components/__tests__/__snapshots__/UserCards.spec.js.snap +424 -75
  41. package/src/users/components/__tests__/__snapshots__/UserGroupAclRow.spec.js.snap +7 -3
  42. package/src/users/components/__tests__/__snapshots__/UserRoutes.spec.js.snap +4 -13
  43. package/src/users/hooks/useSortedGroups.js +13 -0
  44. package/src/users/hooks/useUserModalOperation.js +55 -0
  45. package/src/users/reducers/__tests__/userRedirect.spec.js +14 -3
  46. package/src/users/reducers/index.js +1 -0
  47. package/src/users/reducers/userModalOperation.js +29 -0
  48. package/src/users/reducers/userRedirect.js +5 -4
  49. package/src/users/sagas/__tests__/updateUser.spec.js +19 -1
  50. package/src/users/sagas/createUser.js +12 -5
  51. package/src/users/sagas/deleteUser.js +12 -5
  52. package/src/users/sagas/updateUser.js +19 -8
  53. package/src/users/selectors/__tests__/filterUsers.spec.js +11 -0
  54. package/src/users/selectors/__tests__/getUserAcls.spec.js +1 -0
  55. package/src/users/selectors/__tests__/getUserGroupAcls.spec.js +2 -0
  56. package/src/users/selectors/filterUsers.js +9 -1
  57. package/src/users/selectors/getUserGroupAcls.js +1 -0
  58. package/src/users/components/User.js +0 -68
  59. package/src/users/components/UserPassword.js +0 -24
  60. package/src/users/components/__tests__/User.spec.js +0 -20
  61. package/src/users/components/__tests__/UserPassword.spec.js +0 -24
  62. package/src/users/components/__tests__/__snapshots__/EditUser.spec.js.snap +0 -281
  63. package/src/users/components/__tests__/__snapshots__/NewUser.spec.js.snap +0 -321
  64. package/src/users/components/__tests__/__snapshots__/Password.spec.js.snap +0 -128
  65. package/src/users/components/__tests__/__snapshots__/User.spec.js.snap +0 -129
  66. package/src/users/components/__tests__/__snapshots__/UserPassword.spec.js.snap +0 -124
@@ -1,158 +1,65 @@
1
- import { waitFor } from "@testing-library/react";
2
- import userEvent from "@testing-library/user-event";
3
- import { MemoryRouter } from "react-router";
4
- import { renderWithIntl } from "@truedat/test/renderWithIntl";
5
- import { Password } from "../Password";
6
-
7
- const render = (component) =>
8
- renderWithIntl(<MemoryRouter>{component}</MemoryRouter>);
1
+ import { fireEvent, waitFor } from "@testing-library/react";
2
+ import { Routes, Route } from "react-router";
3
+ import { render } from "@truedat/test/render";
4
+ import Password from "../Password";
5
+ import { updatePassword } from "../../routines";
9
6
 
10
7
  describe("<Password />", () => {
11
- beforeEach(() => jest.useRealTimers());
12
-
13
- const updatePassword = jest.fn();
14
-
15
- it("matches the latest snapshot", async () => {
16
- const props = {};
17
- const rendered = render(<Password {...props} />);
18
- await waitFor(() => expect(rendered.container).toMatchSnapshot());
19
- });
20
-
21
- it("calls updatePassword for non admin user", async () => {
22
- const props = { userName: "joe", authUserName: "joe", updatePassword };
23
- const rendered = render(<Password {...props} />);
24
- const user = userEvent.setup({ delay: null });
25
-
26
- // Submit button should initially be disabled
27
- await waitFor(() =>
28
- expect(rendered.getByRole("button", { name: /save/i })).toBeDisabled(),
29
- );
30
-
31
- await user.type(
32
- rendered.getByLabelText(/user.password.current/i, { selector: "input" }),
33
- "oldPassword",
34
- );
35
- await user.type(
36
- rendered.getByLabelText(/user.password.new_password/i, {
37
- selector: "input",
38
- }),
39
- "Va1id.pa$$w0rd!",
40
- );
41
- await user.type(
42
- rendered.getByLabelText(/login.form.re_password.label/i, {
43
- selector: "input",
44
- }),
45
- "Va1id.pa$$w0rd!",
46
- );
47
-
48
- await waitFor(() =>
49
- expect(
50
- rendered.getByRole("button", { name: /save/i }),
51
- ).not.toBeDisabled(),
52
- );
53
-
54
- // Submit
55
- await user.click(await rendered.findByRole("button", { name: /save/i }));
56
-
57
- await waitFor(() =>
58
- expect(updatePassword).toHaveBeenCalledWith({
59
- user: {
60
- old_password: "oldPassword",
61
- new_password: "Va1id.pa$$w0rd!",
62
- },
63
- }),
64
- );
65
- });
66
-
67
- it("calls updatePassword for admin user", async () => {
68
- const props = {
69
- id: 42,
70
- userName: "joe",
71
- authUserName: "bob",
72
- updatePassword,
73
- };
74
- const rendered = render(<Password {...props} />);
75
- const user = userEvent.setup({ delay: null });
76
-
77
- // Submit button should initially be disabled
78
- await waitFor(() =>
79
- expect(rendered.getByRole("button", { name: /save/i })).toBeDisabled(),
80
- );
81
-
82
- // No current password id needed for admin users
83
- expect(
84
- await rendered.queryByLabelText(/user.password.current/i, {
85
- selector: "input",
86
- }),
87
- ).toBeNull();
88
-
89
- await user.type(
90
- rendered.getByLabelText(/user.password.new_password/i, {
91
- selector: "input",
92
- }),
93
- "Va1id.pa$$w0rd!",
8
+ it("uses the shared modal for the authenticated user without an account ID", async () => {
9
+ const dispatch = jest.fn();
10
+ const rendered = render(<Password />, {
11
+ state: { authentication: { user_name: "test.user" } },
12
+ dispatch,
13
+ });
14
+ expect(rendered.getByRole("dialog")).toHaveClass("user-modal--password");
15
+ expect(document.getElementById("currentPassword")).toHaveAttribute(
16
+ "autocomplete",
17
+ "current-password",
94
18
  );
95
- await user.type(
96
- rendered.getByLabelText(/login.form.re_password.label/i, {
97
- selector: "input",
98
- }),
99
- "Va1id.pa$$w0rd!",
19
+ Object.entries({
20
+ currentPassword: "old-example123",
21
+ password: "new-example123",
22
+ passwordConfirmation: "new-example123",
23
+ }).forEach(([id, value]) =>
24
+ fireEvent.change(document.getElementById(id), { target: { value } }),
100
25
  );
101
-
26
+ fireEvent.submit(document.getElementById("password-modal-form"));
102
27
  await waitFor(() =>
103
- expect(
104
- rendered.getByRole("button", { name: /save/i }),
105
- ).not.toBeDisabled(),
106
- );
107
-
108
- // Submit
109
- await user.click(await rendered.findByRole("button", { name: /save/i }));
110
-
111
- await waitFor(() =>
112
- expect(updatePassword).toHaveBeenCalledWith({
113
- user: {
114
- id: 42,
115
- new_password: "Va1id.pa$$w0rd!",
116
- },
117
- }),
28
+ expect(dispatch).toHaveBeenCalledWith(
29
+ expect.objectContaining({
30
+ type: updatePassword.TRIGGER,
31
+ payload: {
32
+ user: {
33
+ old_password: "old-example123",
34
+ new_password: "new-example123",
35
+ },
36
+ },
37
+ }),
38
+ ),
118
39
  );
119
40
  });
120
41
 
121
- it("requires current password to have min length", async () => {
122
- const props = { userName: "joe", authUserName: "joe", updatePassword };
123
- const rendered = render(<Password {...props} />);
124
- const user = userEvent.setup({ delay: null });
125
-
126
- await user.type(
127
- rendered.getByLabelText(/user.password.current/i, { selector: "input" }),
128
- "short",
42
+ it("returns to the previous page on cancel without submitting", async () => {
43
+ const dispatch = jest.fn();
44
+ const rendered = render(
45
+ <Routes>
46
+ <Route path="/previous" element={<div>Previous page</div>} />
47
+ <Route path="/password" element={<Password />} />
48
+ </Routes>,
49
+ {
50
+ routes: ["/previous", "/password"],
51
+ routeIndex: 1,
52
+ state: { authentication: { user_name: "test.user" } },
53
+ dispatch,
54
+ },
129
55
  );
130
-
131
- expect(
132
- await rendered.findByText(/form.validation.minLength/i),
133
- ).not.toBeNull();
134
- });
135
-
136
- it("requires new password to password confirmation to be the same", async () => {
137
- const props = { userName: "joe", authUserName: "joe", updatePassword };
138
- const rendered = render(<Password {...props} />);
139
- const user = userEvent.setup({ delay: null });
140
-
141
- await user.type(
142
- rendered.getByLabelText(/user.password.new_password/i, {
143
- selector: "input",
144
- }),
145
- "Va1id.pa$$w0rd!",
56
+ fireEvent.change(document.getElementById("password"), {
57
+ target: { value: "discard-example" },
58
+ });
59
+ fireEvent.click(rendered.getByRole("button", { name: "actions.cancel" }));
60
+ expect(await rendered.findByText("Previous page")).toBeVisible();
61
+ expect(dispatch).not.toHaveBeenCalledWith(
62
+ expect.objectContaining({ type: updatePassword.TRIGGER }),
146
63
  );
147
- await user.type(
148
- rendered.getByLabelText(/login.form.re_password.label/i, {
149
- selector: "input",
150
- }),
151
- "different",
152
- );
153
-
154
- expect(
155
- await rendered.findByText(/user.password.must.match/i),
156
- ).not.toBeNull();
157
64
  });
158
65
  });
@@ -1,6 +1,6 @@
1
- import { waitFor } from "@testing-library/react";
1
+ import { screen, waitFor, within } from "@testing-library/react";
2
2
  import { render } from "@truedat/test/render";
3
- import UserAcls from "../UserAcls";
3
+ import UserAcls, { UserAcls as UserAclsTable } from "../UserAcls";
4
4
 
5
5
  const renderOpts = {
6
6
  messages: {
@@ -32,3 +32,24 @@ describe("<UserAcls />", () => {
32
32
  await waitFor(() => expect(container).toMatchSnapshot());
33
33
  });
34
34
  });
35
+
36
+ it("groups roles by domain identity and deduplicates repeated assignments", () => {
37
+ const first = { id: 1, name: "Domain", type: "domain" };
38
+ const second = { id: 2, name: "Domain", type: "domain" };
39
+ const acls = [
40
+ { resource: first, role: "Reader" },
41
+ { resource: first, role: "Editor" },
42
+ { resource: first, role: "Reader" },
43
+ { resource: second, role: "Reader" },
44
+ ];
45
+ const rendered = render(<UserAclsTable acls={acls} groupBy="domain" />);
46
+ expect(screen.getAllByRole("row")).toHaveLength(3);
47
+ expect(
48
+ within(screen.getAllByRole("row")[1]).getAllByRole("listitem"),
49
+ ).toHaveLength(2);
50
+ rendered.rerender(<UserAclsTable acls={acls} groupBy="role" />);
51
+ expect(screen.getAllByRole("row")).toHaveLength(3);
52
+ expect(
53
+ within(screen.getAllByRole("row")[2]).getAllByRole("listitem"),
54
+ ).toHaveLength(2);
55
+ });
@@ -1,6 +1,11 @@
1
1
  import { render } from "@truedat/test/render";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { fireEvent, waitFor } from "@testing-library/react";
4
+ import { useLocation } from "react-router";
2
5
  import { UserCard } from "../UserCard";
3
6
 
7
+ const CurrentRoute = () => <output>{useLocation().pathname}</output>;
8
+
4
9
  describe("<UserCard />", () => {
5
10
  const deleteUser = jest.fn();
6
11
  const user = {
@@ -12,15 +17,116 @@ describe("<UserCard />", () => {
12
17
  uid: "uID00001",
13
18
  };
14
19
 
15
- it("matches the latest snapshot when showing full name", () => {
16
- const props = { user, deleteUser, showFullname: true };
20
+ it("matches the latest snapshot", () => {
21
+ const props = { user, deleteUser };
17
22
  const rendered = render(<UserCard {...props} />);
18
23
  expect(rendered.container).toMatchSnapshot();
19
24
  });
20
25
 
21
- it("matches the latest snapshot when showing UID", () => {
22
- const props = { user, deleteUser, showFullname: false };
26
+ it("shows the trimmed external ID without replacing user metadata", () => {
27
+ const rendered = render(
28
+ <UserCard user={{ ...user, external_id: " employee-123 " }} />,
29
+ );
30
+ expect(rendered.getByText("employee-123")).toHaveAttribute(
31
+ "title",
32
+ "ID externo: employee-123",
33
+ );
34
+ expect(rendered.getByText(user.user_name)).toBeInTheDocument();
35
+ expect(rendered.getByText(user.email)).toBeInTheDocument();
36
+ });
37
+
38
+ it("shows groups in the badge popup", async () => {
39
+ const props = {
40
+ user: { ...user, groups: ["admins", { alias: "Quality" }] },
41
+ deleteUser,
42
+ };
23
43
  const rendered = render(<UserCard {...props} />);
24
- expect(rendered.container).toMatchSnapshot();
44
+
45
+ expect(rendered.container.querySelector(".mail.icon")).toBeInTheDocument();
46
+ fireEvent.mouseOver(rendered.getByText("2"));
47
+
48
+ await waitFor(() => expect(rendered.getByRole("list")).toBeInTheDocument());
49
+ expect(rendered.getByText("admins")).toBeInTheDocument();
50
+ expect(rendered.getByText("Quality")).toBeInTheDocument();
51
+ });
52
+
53
+ it("omits rows with missing or blank optional data", () => {
54
+ const props = {
55
+ user: {
56
+ id: "2",
57
+ user_name: "jhon.doe",
58
+ email: " ",
59
+ external_id: " ",
60
+ groups: [],
61
+ },
62
+ deleteUser,
63
+ };
64
+ const rendered = render(<UserCard {...props} />);
65
+
66
+ expect(rendered.getByText("jhon.doe")).toBeInTheDocument();
67
+ expect(
68
+ rendered.container.querySelector(".user.icon"),
69
+ ).not.toBeInTheDocument();
70
+ expect(
71
+ rendered.container.querySelector(".mail.icon"),
72
+ ).not.toBeInTheDocument();
73
+ expect(
74
+ rendered.container.querySelector(".groups-badge"),
75
+ ).not.toBeInTheDocument();
76
+ expect(
77
+ rendered.container.querySelector(".uc-meta-grid"),
78
+ ).toBeEmptyDOMElement();
79
+ });
80
+
81
+ it("does not render meta tooltips on hover", () => {
82
+ const longUserName = "very.long.user.name";
83
+ const props = {
84
+ user: {
85
+ ...user,
86
+ user_name: longUserName,
87
+ full_name: "Very Long User",
88
+ },
89
+ deleteUser,
90
+ };
91
+ const rendered = render(<UserCard {...props} />);
92
+
93
+ fireEvent.mouseOver(rendered.getByText(longUserName));
94
+
95
+ expect(rendered.getAllByText(longUserName)).toHaveLength(1);
96
+ expect(rendered.container.querySelector(".inverted.popup")).toBeNull();
97
+ });
98
+
99
+ it("keeps card navigation separate from edit and password actions", async () => {
100
+ const rendered = render(
101
+ <>
102
+ <UserCard user={{ ...user, groups: ["Data"] }} />
103
+ <CurrentRoute />
104
+ </>,
105
+ { routes: ["/users"] },
106
+ );
107
+ const keyboard = userEvent.setup({ delay: null });
108
+ await keyboard.tab();
109
+ expect(
110
+ rendered.getByRole("link", { name: "user.info.open" }),
111
+ ).toHaveFocus();
112
+ await keyboard.keyboard("{Enter}");
113
+ expect(rendered.getByRole("status")).toHaveTextContent(/^\/users\/1$/);
114
+ await keyboard.tab();
115
+ expect(
116
+ rendered.getByRole("link", { name: "users.actions.edit" }),
117
+ ).toHaveFocus();
118
+ await keyboard.keyboard("{Enter}");
119
+ expect(rendered.getByRole("status")).toHaveTextContent("/users/1/edit");
120
+ await keyboard.click(
121
+ rendered.getByRole("link", { name: "user.actions.edit_password" }),
122
+ );
123
+ expect(rendered.getByRole("status")).toHaveTextContent("/users/1/password");
124
+ expect(
125
+ rendered.queryByRole("button", {
126
+ name: "user.actions.delete.confirmation.header",
127
+ }),
128
+ ).not.toBeInTheDocument();
129
+ await keyboard.click(rendered.getByRole("link", { name: "1" }));
130
+ expect(rendered.getByRole("status")).toHaveTextContent(/^\/users\/1$/);
25
131
  });
26
132
  });
@@ -1,8 +1,11 @@
1
+ import { screen, within } from "@testing-library/react";
1
2
  import userEvent from "@testing-library/user-event";
2
3
  import { render, waitForLoad } from "@truedat/test/render";
3
4
  import { UserCards } from "../UserCards";
4
5
 
5
6
  describe("<UserCards />", () => {
7
+ beforeEach(() => localStorage.clear());
8
+ afterEach(() => jest.restoreAllMocks());
6
9
  const users = [
7
10
  {
8
11
  id: "1",
@@ -68,3 +71,194 @@ describe("<UserCards />", () => {
68
71
  expect(rendered.getByText(/user.search.no_results/i)).toBeInTheDocument();
69
72
  });
70
73
  });
74
+
75
+ it("retains search when switching views and remembers the selected view", async () => {
76
+ const user = userEvent.setup({ delay: null });
77
+ const rendered = render(
78
+ <UserCards
79
+ users={[
80
+ {
81
+ id: 1,
82
+ full_name: "Alpha",
83
+ user_name: "a",
84
+ role: "user",
85
+ external_id: "EXT-1",
86
+ },
87
+ { id: 2, full_name: "Beta", user_name: "b", role: "admin" },
88
+ ]}
89
+ />,
90
+ );
91
+ await user.type(
92
+ screen.getByPlaceholderText(/user.search.placeholder/),
93
+ "ext-1",
94
+ );
95
+ await user.click(screen.getByRole("button", { name: "users.view.table" }));
96
+ expect(screen.getAllByRole("row")).toHaveLength(2);
97
+ expect(screen.getByRole("link", { name: "Alpha" })).toHaveAttribute(
98
+ "href",
99
+ "/users/1",
100
+ );
101
+ expect(localStorage.getItem("truedat.users.view")).toBe("table");
102
+ expect(
103
+ screen.getByRole("button", { name: "users.view.table" }),
104
+ ).toHaveAttribute("aria-pressed", "true");
105
+ await user.click(screen.getByRole("button", { name: "users.view.cards" }));
106
+ expect(rendered.container.querySelectorAll(".card")).toHaveLength(1);
107
+ await user.click(screen.getByRole("button", { name: "users.view.table" }));
108
+ await user.click(screen.getByRole("button", { name: "users.filter.clear" }));
109
+ expect(screen.getAllByRole("row")).toHaveLength(3);
110
+ await user.click(screen.getByRole("button", { name: /users.table.name/ }));
111
+ expect(
112
+ within(screen.getAllByRole("row")[1]).getByRole("link", { name: "Beta" }),
113
+ ).toBeInTheDocument();
114
+ rendered.unmount();
115
+ render(<UserCards users={[]} />);
116
+ expect(screen.getByRole("table")).toBeInTheDocument();
117
+ localStorage.clear();
118
+ });
119
+
120
+ it("works when storage is unavailable and reuses the delete confirmation", async () => {
121
+ const get = jest
122
+ .spyOn(Storage.prototype, "getItem")
123
+ .mockImplementation(() => {
124
+ throw new Error("unavailable");
125
+ });
126
+ const set = jest
127
+ .spyOn(Storage.prototype, "setItem")
128
+ .mockImplementation(() => {
129
+ throw new Error("unavailable");
130
+ });
131
+ const user = userEvent.setup({ delay: null });
132
+ render(
133
+ <UserCards
134
+ users={[
135
+ {
136
+ id: 1,
137
+ user_name: "Alpha",
138
+ role: "user",
139
+ groups: ["Data"],
140
+ acls: [
141
+ { role: { id: 7, name: "Reader" } },
142
+ { role: { id: 7, name: "Reader" }, group: { name: "Data" } },
143
+ ],
144
+ },
145
+ ]}
146
+ />,
147
+ );
148
+ await user.click(screen.getByRole("button", { name: "users.view.table" }));
149
+ expect(screen.getByLabelText("users.table.roles: 1")).toBeInTheDocument();
150
+ expect(
151
+ screen.getByRole("link", { name: "users.table.edit" }),
152
+ ).toHaveAttribute("href", "/users/1/edit");
153
+ await user.click(screen.getByRole("button", { name: "users.table.delete" }));
154
+ expect(screen.getByRole("dialog")).toBeInTheDocument();
155
+ await user.click(screen.getByRole("button", { name: "actions.cancel" }));
156
+ expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
157
+ expect(screen.getByRole("table")).toBeInTheDocument();
158
+ get.mockRestore();
159
+ set.mockRestore();
160
+ });
161
+
162
+ it("combines type and group filters and keeps all group options available", async () => {
163
+ localStorage.clear();
164
+ const user = userEvent.setup({ delay: null });
165
+ render(
166
+ <UserCards
167
+ users={[
168
+ { id: 1, user_name: "Alpha", role: "user", groups: ["Data"], acls: [] },
169
+ {
170
+ id: 2,
171
+ user_name: "Beta",
172
+ role: "admin",
173
+ groups: [{ name: "quality", alias: "Quality" }],
174
+ },
175
+ {
176
+ id: 3,
177
+ user_name: "Gamma",
178
+ role: "user",
179
+ groups: ["Data"],
180
+ acls: [{ role: {} }],
181
+ },
182
+ ]}
183
+ />,
184
+ );
185
+ await user.click(screen.getByRole("button", { name: "users.view.table" }));
186
+ await user.click(screen.getByText("Filters", { exact: true }));
187
+ await user.click(screen.getByRole("option", { name: "type", exact: true }));
188
+ await user.click(screen.getByRole("option", { name: "user.type.user" }));
189
+ await user.keyboard("{Escape}");
190
+ await user.click(screen.getByText("Filters", { exact: true }));
191
+ await user.click(screen.getByRole("option", { name: "groups", exact: true }));
192
+ expect(screen.getByRole("option", { name: "Quality" })).toBeInTheDocument();
193
+ await user.click(screen.getByRole("option", { name: "Data" }));
194
+ await user.keyboard("{Escape}");
195
+ expect(screen.getAllByRole("row")).toHaveLength(3);
196
+ expect(screen.getAllByText("users.table.unknown")).toHaveLength(1);
197
+ await user.type(
198
+ screen.getByPlaceholderText(/user.search.placeholder/),
199
+ "alpha",
200
+ );
201
+ expect(screen.getAllByRole("row")).toHaveLength(2);
202
+ await user.click(screen.getByRole("button", { name: "users.filter.clear" }));
203
+ expect(screen.getAllByRole("row")).toHaveLength(4);
204
+ localStorage.clear();
205
+ });
206
+
207
+ describe("<UserCards /> role sections", () => {
208
+ beforeEach(() => localStorage.clear());
209
+ const users = [
210
+ { id: 1, user_name: "alpha", full_name: "Alpha", role: "admin" },
211
+ { id: 2, user_name: "beta", full_name: "Beta", role: "user" },
212
+ ];
213
+
214
+ it("exposes each role section as a button with its expanded state", async () => {
215
+ const rendered = render(<UserCards users={users} />);
216
+ await waitForLoad(rendered);
217
+
218
+ const adminToggle = screen.getByRole("button", { name: /user.type.admin/ });
219
+ expect(adminToggle).toHaveAttribute("aria-expanded", "true");
220
+ const panelId = adminToggle.getAttribute("aria-controls");
221
+ expect(panelId).toBeTruthy();
222
+ expect(document.getElementById(panelId)).toHaveClass("users-role-content");
223
+ });
224
+
225
+ it.each([["{Enter}"], [" "]])(
226
+ "collapses and expands a role section with the keyboard (%j)",
227
+ async (key) => {
228
+ const user = userEvent.setup({ delay: null });
229
+ const rendered = render(<UserCards users={users} />);
230
+ await waitForLoad(rendered);
231
+
232
+ await user.tab();
233
+ const toggles = screen
234
+ .getAllByRole("button")
235
+ .filter((button) => button.hasAttribute("aria-expanded"));
236
+ const adminToggle = toggles.find((button) =>
237
+ /user.type.admin/.test(button.textContent),
238
+ );
239
+ adminToggle.focus();
240
+ expect(adminToggle).toHaveFocus();
241
+
242
+ await user.keyboard(key);
243
+ expect(adminToggle).toHaveAttribute("aria-expanded", "false");
244
+ expect(adminToggle).toHaveFocus();
245
+
246
+ await user.keyboard(key);
247
+ expect(adminToggle).toHaveAttribute("aria-expanded", "true");
248
+ },
249
+ );
250
+
251
+ it("keeps the other role sections open when one is collapsed", async () => {
252
+ const user = userEvent.setup({ delay: null });
253
+ const rendered = render(<UserCards users={users} />);
254
+ await waitForLoad(rendered);
255
+
256
+ await user.click(screen.getByRole("button", { name: /user.type.admin/ }));
257
+ expect(
258
+ screen.getByRole("button", { name: /user.type.admin/ }),
259
+ ).toHaveAttribute("aria-expanded", "false");
260
+ expect(
261
+ screen.getByRole("button", { name: /user.type.user/ }),
262
+ ).toHaveAttribute("aria-expanded", "true");
263
+ });
264
+ });
@@ -1,5 +1,5 @@
1
1
  import { waitFor } from "@testing-library/react";
2
- import { renderWithIntl as render } from "@truedat/test/renderWithIntl";
2
+ import { render } from "@truedat/test/render";
3
3
  import UserGroupAclRow from "../UserGroupAclRow";
4
4
 
5
5
  describe("<UserGroupAclRow />", () => {
@@ -20,4 +20,22 @@ describe("<UserGroupAclRow />", () => {
20
20
  );
21
21
  await waitFor(() => expect(container).toMatchSnapshot());
22
22
  });
23
+
24
+ it("renders group, domain and role in order with a new-tab group link", () => {
25
+ const rendered = render(
26
+ <table>
27
+ <tbody>
28
+ <UserGroupAclRow {...props} groupId={6} />
29
+ </tbody>
30
+ </table>,
31
+ {},
32
+ );
33
+ expect(
34
+ rendered.getAllByRole("cell").map((cell) => cell.textContent),
35
+ ).toEqual(["grupo1", "resource1", "role1"]);
36
+ const link = rendered.getByRole("link", { name: "grupo1" });
37
+ expect(link).toHaveAttribute("href", "/groups/6");
38
+ expect(link).toHaveAttribute("target", "_blank");
39
+ expect(link).toHaveAttribute("rel", "noopener noreferrer");
40
+ });
23
41
  });