@carlonicora/nextjs-jsonapi 4.0.7 → 4.0.8

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 (48) hide show
  1. package/dist/{BlockNoteEditor-KOHN3SSR.mjs → BlockNoteEditor-A2E4VUYB.mjs} +2 -2
  2. package/dist/{BlockNoteEditor-J7EF7BRX.js → BlockNoteEditor-MCYQBKT5.js} +9 -9
  3. package/dist/{BlockNoteEditor-J7EF7BRX.js.map → BlockNoteEditor-MCYQBKT5.js.map} +1 -1
  4. package/dist/billing/index.js +335 -335
  5. package/dist/billing/index.mjs +1 -1
  6. package/dist/{chunk-3W3VVX2N.mjs → chunk-DJRTJBSZ.mjs} +76 -27
  7. package/dist/chunk-DJRTJBSZ.mjs.map +1 -0
  8. package/dist/{chunk-3M7D6NXU.js → chunk-PL2FHXHR.js} +76 -27
  9. package/dist/chunk-PL2FHXHR.js.map +1 -0
  10. package/dist/client/index.js +2 -2
  11. package/dist/client/index.mjs +1 -1
  12. package/dist/components/index.d.mts +10 -1
  13. package/dist/components/index.d.ts +10 -1
  14. package/dist/components/index.js +4 -2
  15. package/dist/components/index.js.map +1 -1
  16. package/dist/components/index.mjs +3 -1
  17. package/dist/contexts/index.js +2 -2
  18. package/dist/contexts/index.mjs +1 -1
  19. package/dist/features/analytics/index.js +88 -88
  20. package/dist/features/analytics/index.mjs +1 -1
  21. package/dist/features/help/index.js +31 -31
  22. package/dist/features/help/index.mjs +1 -1
  23. package/dist/features/tokenusage/index.js +75 -75
  24. package/dist/features/tokenusage/index.mjs +1 -1
  25. package/package.json +1 -1
  26. package/src/features/company/hooks/useSubscriptionStatus.test.ts +76 -10
  27. package/src/features/company/hooks/useSubscriptionStatus.ts +9 -9
  28. package/src/features/how-to/components/containers/HowToCommand.spec.tsx +79 -6
  29. package/src/features/how-to/components/containers/HowToCommand.tsx +12 -2
  30. package/src/features/how-to/components/forms/HowToSelector.spec.tsx +85 -0
  31. package/src/features/how-to/components/forms/HowToSelector.tsx +1 -0
  32. package/src/features/role/components/forms/UserRoleAdd.spec.tsx +71 -0
  33. package/src/features/role/components/forms/UserRoleAdd.tsx +1 -0
  34. package/src/features/user/components/forms/UserSelector.spec.tsx +85 -0
  35. package/src/features/user/components/forms/UserSelector.tsx +1 -0
  36. package/src/features/user/components/lists/UserListInAdd.spec.tsx +84 -0
  37. package/src/features/user/components/lists/UserListInAdd.tsx +1 -0
  38. package/src/features/user/components/widgets/UserSearchPopover.spec.tsx +68 -0
  39. package/src/features/user/components/widgets/UserSearchPopover.tsx +1 -0
  40. package/src/shadcnui/custom/multi-select.spec.tsx +70 -0
  41. package/src/shadcnui/custom/multi-select.tsx +26 -2
  42. package/src/shadcnui/custom/multiple-selector.spec.tsx +111 -0
  43. package/src/shadcnui/custom/multiple-selector.tsx +33 -10
  44. package/src/shadcnui/ui/command.spec.ts +26 -0
  45. package/src/shadcnui/ui/command.tsx +13 -1
  46. package/dist/chunk-3M7D6NXU.js.map +0 -1
  47. package/dist/chunk-3W3VVX2N.mjs.map +0 -1
  48. /package/dist/{BlockNoteEditor-KOHN3SSR.mjs.map → BlockNoteEditor-A2E4VUYB.mjs.map} +0 -0
@@ -1,7 +1,7 @@
1
- import { render, screen } from "@testing-library/react";
1
+ import { render, screen, waitFor, within } from "@testing-library/react";
2
2
  import userEvent from "@testing-library/user-event";
3
- import { ReactElement } from "react";
4
- import { beforeAll, describe, expect, it, vi } from "vitest";
3
+ import { ReactElement, useCallback, useMemo, useState } from "react";
4
+ import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
5
5
 
6
6
  import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
7
7
  import { SidebarProvider } from "../../../../shadcnui";
@@ -25,9 +25,10 @@ const guides = [
25
25
  },
26
26
  ];
27
27
 
28
- const { useDataListRetriever } = vi.hoisted(() => ({
29
- useDataListRetriever: vi.fn(() => ({ data: guides, search: vi.fn(), refresh: vi.fn() })),
30
- }));
28
+ const { useDataListRetriever, defaultRetriever } = vi.hoisted(() => {
29
+ const defaultRetriever = () => ({ data: guides, search: () => {}, refresh: () => {} });
30
+ return { useDataListRetriever: vi.fn(defaultRetriever), defaultRetriever };
31
+ });
31
32
 
32
33
  vi.mock("../../../../hooks", async (importOriginal) => ({
33
34
  ...(await importOriginal<typeof import("../../../../hooks")>()),
@@ -36,6 +37,14 @@ vi.mock("../../../../hooks", async (importOriginal) => ({
36
37
  }));
37
38
  vi.mock("next-intl", () => ({ useTranslations: () => (key: string) => key }));
38
39
 
40
+ const { viewerSpy } = vi.hoisted(() => ({ viewerSpy: vi.fn() }));
41
+ vi.mock("./HowToCommandViewer", () => ({
42
+ default: ({ howTo }: { howTo: { id: string } }) => {
43
+ viewerSpy(howTo);
44
+ return <div data-testid="howto-viewer" />;
45
+ },
46
+ }));
47
+
39
48
  beforeAll(() => {
40
49
  ModuleRegistry.register("HowTo" as any, { name: "howtos", model: HowTo } as any);
41
50
  });
@@ -70,3 +79,67 @@ describe("HowToCommand relevance", () => {
70
79
  expect(relevant).not.toHaveTextContent("Guida contatti");
71
80
  });
72
81
  });
82
+
83
+ // Two guides with the SAME title: cmdk derives an item's value from its text when no
84
+ // `value` is given, so without a unique value both rows highlight together.
85
+ describe("HowToCommand same-titled guides", () => {
86
+ const sameTitled = [
87
+ { id: "s1", name: "Come creare una pratica", pages: "[]", contextualKeys: [], draft: false },
88
+ { id: "s2", name: "Come creare una pratica", pages: "[]", contextualKeys: [], draft: false },
89
+ { id: "s3", name: "Gestire i contatti", pages: "[]", contextualKeys: [], draft: false },
90
+ ];
91
+ const searchSpy = vi.fn();
92
+
93
+ beforeAll(() => {
94
+ Element.prototype.scrollIntoView = vi.fn();
95
+ });
96
+
97
+ afterEach(() => {
98
+ useDataListRetriever.mockImplementation(defaultRetriever);
99
+ searchSpy.mockReset();
100
+ });
101
+
102
+ function useFakeRetriever() {
103
+ const [data, setData] = useState<any[]>(sameTitled);
104
+ const search = useCallback(async (term: string) => {
105
+ searchSpy(term);
106
+ setData(sameTitled.filter((g) => g.name.toLowerCase().includes(term.toLowerCase())));
107
+ }, []);
108
+ return useMemo(() => ({ data, search, refresh: () => {} }), [data, search]);
109
+ }
110
+
111
+ it("highlights only one of two same-titled guides and opens the one clicked", async () => {
112
+ useDataListRetriever.mockImplementation(useFakeRetriever as any);
113
+ renderCommand(<HowToCommand pathname="/anything" />);
114
+ await open();
115
+ const list = await screen.findByRole("listbox");
116
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(3));
117
+
118
+ const highlighted = within(list)
119
+ .getAllByRole("option")
120
+ .filter((o) => o.getAttribute("aria-selected") === "true");
121
+ expect(highlighted).toHaveLength(1);
122
+
123
+ const rows = within(list).getAllByRole("option", { name: "Come creare una pratica" });
124
+ expect(rows).toHaveLength(2);
125
+ await userEvent.click(rows[1]);
126
+ // The viewer opens on the clicked guide: the dialog title becomes its name, and
127
+ // HowToCommandViewer receives guide s2.
128
+ expect(await screen.findByRole("heading", { name: "Come creare una pratica" })).toBeInTheDocument();
129
+ expect(viewerSpy).toHaveBeenLastCalledWith(expect.objectContaining({ id: "s2" }));
130
+ });
131
+
132
+ it("still filters the list when the user types", async () => {
133
+ useDataListRetriever.mockImplementation(useFakeRetriever as any);
134
+ renderCommand(<HowToCommand pathname="/anything" />);
135
+ await open();
136
+ const list = await screen.findByRole("listbox");
137
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(3));
138
+
139
+ await userEvent.type(screen.getByRole("combobox"), "contatti");
140
+
141
+ await waitFor(() => expect(searchSpy).toHaveBeenCalledWith("contatti"));
142
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(1));
143
+ expect(within(list).getByRole("option", { name: "Gestire i contatti" })).toBeInTheDocument();
144
+ });
145
+ });
@@ -189,7 +189,12 @@ export default function HowToCommand({ pathname, contextKeys, extraGroups, onSta
189
189
  {relevantHowTos.length > 0 && (
190
190
  <CommandGroup heading={t(`howto.command.relevant`)}>
191
191
  {relevantHowTos.map((howTo: HowToInterface) => (
192
- <CommandItem key={howTo.id} onSelect={() => setSelectedHowTo(howTo)} className="cursor-pointer">
192
+ <CommandItem
193
+ key={howTo.id}
194
+ value={howTo.id}
195
+ onSelect={() => setSelectedHowTo(howTo)}
196
+ className="cursor-pointer"
197
+ >
193
198
  <ArrowRight className="h-4 w-4 rtl:rotate-180" />
194
199
  <span>{howTo.name}</span>
195
200
  </CommandItem>
@@ -201,7 +206,12 @@ export default function HowToCommand({ pathname, contextKeys, extraGroups, onSta
201
206
  {otherHowTos.length > 0 && (
202
207
  <CommandGroup heading={searchTerm ? undefined : t(`howto.command.all`)}>
203
208
  {otherHowTos.map((howTo: HowToInterface) => (
204
- <CommandItem key={howTo.id} onSelect={() => setSelectedHowTo(howTo)} className="cursor-pointer">
209
+ <CommandItem
210
+ key={howTo.id}
211
+ value={howTo.id}
212
+ onSelect={() => setSelectedHowTo(howTo)}
213
+ className="cursor-pointer"
214
+ >
205
215
  <ArrowRight className="h-4 w-4 rtl:rotate-180" />
206
216
  <span>{howTo.name}</span>
207
217
  </CommandItem>
@@ -0,0 +1,85 @@
1
+ import { render, screen, waitFor, within } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import { useForm } from "react-hook-form";
5
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ // Two guides with the SAME title: cmdk derives an item's value from its text when no
8
+ // `value` is given, so without a unique value both rows highlight together.
9
+ const guides = [
10
+ { id: "h1", name: "Come creare una pratica" },
11
+ { id: "h2", name: "Come creare una pratica" },
12
+ { id: "h3", name: "Gestire i contatti" },
13
+ ];
14
+
15
+ const { searchSpy } = vi.hoisted(() => ({ searchSpy: vi.fn() }));
16
+
17
+ vi.mock("../../../../hooks", async (importOriginal) => ({
18
+ ...(await importOriginal<typeof import("../../../../hooks")>()),
19
+ // Stands in for the server-side search: `search(term)` narrows the list by title.
20
+ useDataListRetriever: () => {
21
+ const [data, setData] = useState<any[]>(guides);
22
+ const search = useCallback(async (term: string) => {
23
+ searchSpy(term);
24
+ setData(guides.filter((g) => g.name.toLowerCase().includes(term.toLowerCase())));
25
+ }, []);
26
+ return useMemo(() => ({ data, search }), [data, search]);
27
+ },
28
+ useDebounce: (fn: any) => fn,
29
+ }));
30
+
31
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
32
+ import { HowTo } from "../../data/HowTo";
33
+ import HowToSelector from "./HowToSelector";
34
+
35
+ beforeAll(() => {
36
+ ModuleRegistry.register("HowTo" as any, { name: "howtos", model: HowTo } as any);
37
+ });
38
+
39
+ let formRef: any;
40
+ function Host() {
41
+ const form = useForm<{ howTo?: { id: string } }>();
42
+ useEffect(() => {
43
+ formRef = form;
44
+ });
45
+ return <HowToSelector id="howTo" form={form} placeholder="pick" />;
46
+ }
47
+
48
+ async function openList() {
49
+ render(<Host />);
50
+ await userEvent.click(screen.getByText("pick"));
51
+ return screen.findByRole("listbox");
52
+ }
53
+
54
+ describe("HowToSelector", () => {
55
+ beforeEach(() => {
56
+ Element.prototype.scrollIntoView = vi.fn();
57
+ searchSpy.mockReset();
58
+ });
59
+
60
+ it("highlights only one of two same-titled guides and selects the one clicked", async () => {
61
+ const list = await openList();
62
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(3));
63
+
64
+ const highlighted = within(list)
65
+ .getAllByRole("option")
66
+ .filter((o) => o.getAttribute("aria-selected") === "true");
67
+ expect(highlighted).toHaveLength(1);
68
+
69
+ const sameTitled = within(list).getAllByRole("option", { name: "Come creare una pratica" });
70
+ expect(sameTitled).toHaveLength(2);
71
+ await userEvent.click(sameTitled[1]);
72
+ expect(formRef.getValues("howTo")).toEqual({ id: "h2", name: "Come creare una pratica" });
73
+ });
74
+
75
+ it("still filters the list when the user types", async () => {
76
+ const list = await openList();
77
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(3));
78
+
79
+ await userEvent.type(screen.getByRole("textbox"), "contatti");
80
+
81
+ await waitFor(() => expect(searchSpy).toHaveBeenCalledWith("contatti"));
82
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(1));
83
+ expect(within(list).getByRole("option", { name: "Gestire i contatti" })).toBeInTheDocument();
84
+ });
85
+ });
@@ -148,6 +148,7 @@ export default function HowToSelector({
148
148
  <CommandItem
149
149
  className="cursor-pointer hover:bg-muted data-selected:hover:bg-muted bg-transparent data-selected:bg-transparent"
150
150
  key={howTo.id}
151
+ value={howTo.id}
151
152
  onSelect={() => setHowTo(howTo)}
152
153
  >
153
154
  {howTo.name}
@@ -0,0 +1,71 @@
1
+ import { render, screen, waitFor, within } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ // Every row's text is `t("role.roles", { role })`. A role id the translation does not
6
+ // know renders the same fallback text, so two roles can share a row text — and cmdk,
7
+ // without a `value`, derives the value from that text and highlights both rows.
8
+ // (The test translator returns the key, so every row reads "role.roles".)
9
+ const roles = [
10
+ { id: "role-a", name: "Role A" },
11
+ { id: "role-b", name: "Role B" },
12
+ { id: "role-c", name: "Role C" },
13
+ ];
14
+
15
+ const { findAllRolesUserNotIn, addUserToRole } = vi.hoisted(() => ({
16
+ findAllRolesUserNotIn: vi.fn(),
17
+ addUserToRole: vi.fn(),
18
+ }));
19
+
20
+ vi.mock("../../data/role.service", () => ({ RoleService: { findAllRolesUserNotIn, addUserToRole } }));
21
+ vi.mock("../../../../utils/toast", () => ({ showToast: vi.fn() }));
22
+ vi.mock("../../../../hooks", async (importOriginal) => ({
23
+ ...(await importOriginal<typeof import("../../../../hooks")>()),
24
+ useDebounce: (fn: any) => fn,
25
+ }));
26
+
27
+ import { UserRoleAdd } from "./UserRoleAdd";
28
+
29
+ const user = { id: "user-1", name: "Mario Rossi" } as any;
30
+
31
+ async function openList() {
32
+ render(<UserRoleAdd user={user} refresh={vi.fn(async () => {})} />);
33
+ await userEvent.click(screen.getByRole("button", { name: /common.association.label/ }));
34
+ return screen.findByRole("listbox");
35
+ }
36
+
37
+ describe("UserRoleAdd", () => {
38
+ beforeEach(() => {
39
+ Element.prototype.scrollIntoView = vi.fn();
40
+ findAllRolesUserNotIn.mockReset();
41
+ addUserToRole.mockReset();
42
+ findAllRolesUserNotIn.mockImplementation(async (params: { search: string }) =>
43
+ roles.filter((r) => r.id.includes(params.search)),
44
+ );
45
+ addUserToRole.mockResolvedValue(undefined);
46
+ });
47
+
48
+ it("highlights only one of two same-text roles and adds the one clicked", async () => {
49
+ const list = await openList();
50
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(3));
51
+
52
+ const highlighted = within(list)
53
+ .getAllByRole("option")
54
+ .filter((o) => o.getAttribute("aria-selected") === "true");
55
+ expect(highlighted).toHaveLength(1);
56
+
57
+ await userEvent.click(within(list).getAllByRole("option")[1]);
58
+ await waitFor(() => expect(addUserToRole).toHaveBeenCalled());
59
+ expect(addUserToRole.mock.calls.every(([p]) => p.roleId === "role-b" && p.userId === "user-1")).toBe(true);
60
+ });
61
+
62
+ it("still filters the list when the user types", async () => {
63
+ const list = await openList();
64
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(3));
65
+
66
+ await userEvent.type(screen.getByRole("combobox"), "role-c");
67
+
68
+ await waitFor(() => expect(findAllRolesUserNotIn).toHaveBeenCalledWith({ search: "role-c", userId: "user-1" }));
69
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(1));
70
+ });
71
+ });
@@ -115,6 +115,7 @@ export function UserRoleAdd({ user, refresh }: UserRoleAddProps) {
115
115
  <CommandItem
116
116
  className="cursor-pointer hover:bg-muted data-selected:hover:bg-muted bg-transparent data-selected:bg-transparent"
117
117
  key={role.id}
118
+ value={role.id}
118
119
  onSelect={() => addUserToRole(role)}
119
120
  onClick={() => addUserToRole(role)}
120
121
  >
@@ -0,0 +1,85 @@
1
+ import { render, screen, waitFor, within } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { useCallback, useEffect, useMemo, useState } from "react";
4
+ import { useForm } from "react-hook-form";
5
+ import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ // Two users with the SAME name: cmdk derives an item's value from its text when no
8
+ // `value` is given, so without a unique value both rows highlight together.
9
+ const users = [
10
+ { id: "u1", name: "Mario Rossi", avatar: "a1" },
11
+ { id: "u2", name: "Mario Rossi", avatar: "a2" },
12
+ { id: "u3", name: "Anna Bianchi", avatar: "a3" },
13
+ ];
14
+
15
+ const { searchSpy } = vi.hoisted(() => ({ searchSpy: vi.fn() }));
16
+
17
+ vi.mock("../../../../hooks", async (importOriginal) => ({
18
+ ...(await importOriginal<typeof import("../../../../hooks")>()),
19
+ // Stands in for the server-side search: `search(term)` narrows the list by name.
20
+ useDataListRetriever: () => {
21
+ const [data, setData] = useState<any[]>(users);
22
+ const search = useCallback(async (term: string) => {
23
+ searchSpy(term);
24
+ setData(users.filter((u) => u.name.toLowerCase().includes(term.toLowerCase())));
25
+ }, []);
26
+ return useMemo(() => ({ data, search }), [data, search]);
27
+ },
28
+ useDebounce: (fn: any) => fn,
29
+ }));
30
+ vi.mock("../widgets", () => ({ UserAvatar: () => null }));
31
+
32
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
33
+ import { UserSelector } from "./UserSelector";
34
+
35
+ beforeAll(() => {
36
+ ModuleRegistry.register("User" as any, { name: "users" } as any);
37
+ });
38
+
39
+ let formRef: any;
40
+ function Host() {
41
+ const form = useForm<{ user?: { id: string } }>();
42
+ useEffect(() => {
43
+ formRef = form;
44
+ });
45
+ return <UserSelector id="user" form={form} placeholder="pick" />;
46
+ }
47
+
48
+ async function openList() {
49
+ render(<Host />);
50
+ await userEvent.click(screen.getByText("pick"));
51
+ return screen.findByRole("listbox");
52
+ }
53
+
54
+ describe("UserSelector", () => {
55
+ beforeEach(() => {
56
+ Element.prototype.scrollIntoView = vi.fn();
57
+ searchSpy.mockReset();
58
+ });
59
+
60
+ it("highlights only one of two same-named users and selects the one clicked", async () => {
61
+ const list = await openList();
62
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(3));
63
+
64
+ const highlighted = within(list)
65
+ .getAllByRole("option")
66
+ .filter((o) => o.getAttribute("aria-selected") === "true");
67
+ expect(highlighted).toHaveLength(1);
68
+
69
+ const sameNamed = within(list).getAllByRole("option", { name: "Mario Rossi" });
70
+ expect(sameNamed).toHaveLength(2);
71
+ await userEvent.click(sameNamed[1]);
72
+ expect(formRef.getValues("user")).toEqual({ id: "u2", name: "Mario Rossi", avatar: "a2" });
73
+ });
74
+
75
+ it("still filters the list when the user types", async () => {
76
+ const list = await openList();
77
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(3));
78
+
79
+ await userEvent.type(screen.getByRole("textbox"), "anna");
80
+
81
+ await waitFor(() => expect(searchSpy).toHaveBeenCalledWith("anna"));
82
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(1));
83
+ expect(within(list).getByRole("option", { name: "Anna Bianchi" })).toBeInTheDocument();
84
+ });
85
+ });
@@ -154,6 +154,7 @@ export function UserSelector({ id, form, label, placeholder, onChange, isRequire
154
154
  <CommandItem
155
155
  className="cursor-pointer hover:bg-muted data-selected:hover:bg-muted bg-transparent data-selected:bg-transparent"
156
156
  key={user.id}
157
+ value={user.id}
157
158
  onSelect={() => setUser(user)}
158
159
  >
159
160
  <UserAvatar user={user} className={`me-2 h-4 w-4`} />
@@ -0,0 +1,84 @@
1
+ import { render, screen, waitFor, within } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { useCallback, useMemo, useState } from "react";
4
+ import { beforeEach, describe, expect, it, vi } from "vitest";
5
+
6
+ // Two users whose row text is identical (same name, same e-mail — e.g. a re-invited
7
+ // duplicate): cmdk derives an item's value from its text when no `value` is given, so
8
+ // without a unique value both rows highlight together.
9
+ const users = [
10
+ { id: "u1", name: "Mario Rossi", email: "mario@example.com" },
11
+ { id: "u2", name: "Mario Rossi", email: "mario@example.com" },
12
+ { id: "u3", name: "Anna Bianchi", email: "anna@example.com" },
13
+ ];
14
+
15
+ const { searchSpy } = vi.hoisted(() => ({ searchSpy: vi.fn() }));
16
+
17
+ vi.mock("../../../../hooks", async (importOriginal) => ({
18
+ ...(await importOriginal<typeof import("../../../../hooks")>()),
19
+ useDebounce: (fn: any) => fn,
20
+ }));
21
+ vi.mock("../widgets", () => ({ UserAvatar: () => null }));
22
+
23
+ import { CommonAssociationCommandDialog } from "../../../../components/forms/CommonAssociationForm";
24
+ import { UserListInAdd } from "./UserListInAdd";
25
+
26
+ // The list is always rendered inside CommonAssociationCommandDialog (see RoleUserAdd),
27
+ // which owns the search input; `search(term)` stands in for the server-side search.
28
+ function Host({ setSelectedUser }: { setSelectedUser: (user: any) => void }) {
29
+ const [list, setList] = useState<any[]>(users);
30
+ const search = useCallback(async (term: string) => {
31
+ searchSpy(term);
32
+ setList(users.filter((u) => u.name.toLowerCase().includes(term.toLowerCase())));
33
+ }, []);
34
+ const data = useMemo(() => ({ data: list, search }) as any, [list, search]);
35
+ return (
36
+ <CommonAssociationCommandDialog
37
+ show={true}
38
+ setShow={vi.fn()}
39
+ data={data}
40
+ source="users"
41
+ destination="roles"
42
+ destinationName="Admin"
43
+ >
44
+ <UserListInAdd data={data} existingUsers={[]} setSelectedUser={setSelectedUser} />
45
+ </CommonAssociationCommandDialog>
46
+ );
47
+ }
48
+
49
+ describe("UserListInAdd", () => {
50
+ beforeEach(() => {
51
+ Element.prototype.scrollIntoView = vi.fn();
52
+ searchSpy.mockReset();
53
+ });
54
+
55
+ it("highlights only one of two same-text users and selects the one clicked", async () => {
56
+ const setSelectedUser = vi.fn();
57
+ render(<Host setSelectedUser={setSelectedUser} />);
58
+ const list = await screen.findByRole("listbox");
59
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(3));
60
+
61
+ const highlighted = within(list)
62
+ .getAllByRole("option")
63
+ .filter((o) => o.getAttribute("aria-selected") === "true");
64
+ expect(highlighted).toHaveLength(1);
65
+
66
+ const sameText = within(list).getAllByRole("option", { name: /Mario Rossi/ });
67
+ expect(sameText).toHaveLength(2);
68
+ await userEvent.click(sameText[1]);
69
+ expect(setSelectedUser).toHaveBeenCalled();
70
+ expect(setSelectedUser.mock.calls.every(([u]) => u.id === "u2")).toBe(true);
71
+ });
72
+
73
+ it("still filters the list when the user types", async () => {
74
+ render(<Host setSelectedUser={vi.fn()} />);
75
+ const list = await screen.findByRole("listbox");
76
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(3));
77
+
78
+ await userEvent.type(screen.getByRole("combobox"), "anna");
79
+
80
+ await waitFor(() => expect(searchSpy).toHaveBeenCalledWith("anna"));
81
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(1));
82
+ expect(within(list).getByRole("option", { name: /Anna Bianchi/ })).toBeInTheDocument();
83
+ });
84
+ });
@@ -28,6 +28,7 @@ export function UserListInAdd({ data, existingUsers, setSelectedUser, setLevelOp
28
28
  <CommandItem
29
29
  className="cursor-pointer hover:bg-muted data-selected:hover:bg-muted bg-transparent data-selected:bg-transparent"
30
30
  key={user.id}
31
+ value={user.id}
31
32
  onClick={(_e) => {
32
33
  setSelectedUser(user);
33
34
  setLevelOpen?.(true);
@@ -0,0 +1,68 @@
1
+ import { render, screen, waitFor, within } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ // Two users with the SAME name: cmdk derives an item's value from its text when no
6
+ // `value` is given, so without a unique value both rows highlight together.
7
+ const users = [
8
+ { id: "u1", name: "Mario Rossi" },
9
+ { id: "u2", name: "Mario Rossi" },
10
+ { id: "u3", name: "Anna Bianchi" },
11
+ ];
12
+
13
+ const { findMany } = vi.hoisted(() => ({ findMany: vi.fn() }));
14
+
15
+ // The real useUserSearch runs; only the API call underneath it is stubbed.
16
+ vi.mock("../../data/user.service", () => ({ UserService: { findMany } }));
17
+ vi.mock("./UserAvatar", () => ({ UserAvatar: () => null }));
18
+
19
+ import { UserSearchPopover } from "./UserSearchPopover";
20
+
21
+ async function openList(onSelect = vi.fn()) {
22
+ render(
23
+ <UserSearchPopover onSelect={onSelect}>
24
+ <span>open</span>
25
+ </UserSearchPopover>,
26
+ );
27
+ await userEvent.click(screen.getByText("open"));
28
+ return screen.findByRole("listbox");
29
+ }
30
+
31
+ describe("UserSearchPopover", () => {
32
+ beforeEach(() => {
33
+ Element.prototype.scrollIntoView = vi.fn();
34
+ findMany.mockReset();
35
+ findMany.mockImplementation(async (params: { search?: string }) =>
36
+ users.filter((u) => u.name.toLowerCase().includes((params.search ?? "").toLowerCase())),
37
+ );
38
+ });
39
+
40
+ it("highlights only one of two same-named users and selects the one clicked", async () => {
41
+ const onSelect = vi.fn();
42
+ const list = await openList(onSelect);
43
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(3));
44
+
45
+ const highlighted = within(list)
46
+ .getAllByRole("option")
47
+ .filter((o) => o.getAttribute("aria-selected") === "true");
48
+ expect(highlighted).toHaveLength(1);
49
+
50
+ const sameNamed = within(list).getAllByRole("option", { name: "Mario Rossi" });
51
+ expect(sameNamed).toHaveLength(2);
52
+ await userEvent.click(sameNamed[1]);
53
+ expect(onSelect).toHaveBeenCalledWith("u2");
54
+ });
55
+
56
+ it("still filters the list when the user types", async () => {
57
+ const list = await openList();
58
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(3));
59
+
60
+ await userEvent.type(screen.getByRole("textbox"), "anna");
61
+
62
+ await waitFor(() => expect(findMany).toHaveBeenCalledWith(expect.objectContaining({ search: "anna" })), {
63
+ timeout: 3000,
64
+ });
65
+ await waitFor(() => expect(within(list).getAllByRole("option")).toHaveLength(1), { timeout: 3000 });
66
+ expect(within(list).getByRole("option", { name: "Anna Bianchi" })).toBeInTheDocument();
67
+ });
68
+ });
@@ -71,6 +71,7 @@ export const UserSearchPopover = ({ children, onSelect, align = "start", classNa
71
71
  users.map((user) => (
72
72
  <CommandItem
73
73
  key={user.id}
74
+ value={user.id}
74
75
  className="cursor-pointer hover:bg-muted data-selected:hover:bg-muted bg-transparent data-selected:bg-transparent"
75
76
  onSelect={() => handleSelectUser(user.id)}
76
77
  >
@@ -0,0 +1,70 @@
1
+ import { render, screen, waitFor, within } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { MultiSelect } from "./multi-select";
6
+
7
+ // Two options with the SAME label: cmdk derives an item's value from its text when no
8
+ // `value` is given, so without a unique value both rows highlight together.
9
+ const options = [
10
+ { label: "Mario Rossi", value: "u1" },
11
+ { label: "Mario Rossi", value: "u2" },
12
+ { label: "Anna Bianchi", value: "u3" },
13
+ ];
14
+
15
+ async function openList(onValueChange = vi.fn()) {
16
+ render(<MultiSelect options={options} onValueChange={onValueChange} placeholder="pick" />);
17
+ await userEvent.click(screen.getByText("pick"));
18
+ return screen.findByRole("listbox");
19
+ }
20
+
21
+ const highlightedRows = (list: HTMLElement) =>
22
+ within(list)
23
+ .getAllByRole("option")
24
+ .filter((o) => o.getAttribute("aria-selected") === "true");
25
+
26
+ describe("MultiSelect", () => {
27
+ beforeEach(() => {
28
+ Element.prototype.scrollIntoView = vi.fn();
29
+ });
30
+
31
+ it("highlights only one of two same-labelled options and toggles the one clicked", async () => {
32
+ const onValueChange = vi.fn();
33
+ const list = await openList(onValueChange);
34
+
35
+ // "(Select All)" starts highlighted; one step down lands on the first "Mario Rossi".
36
+ await userEvent.keyboard("{ArrowDown}");
37
+ const highlighted = highlightedRows(list);
38
+ expect(highlighted).toHaveLength(1);
39
+ expect(highlighted[0]).toHaveTextContent("Mario Rossi");
40
+
41
+ const sameLabelled = within(list).getAllByRole("option", { name: "Mario Rossi" });
42
+ expect(sameLabelled).toHaveLength(2);
43
+ await userEvent.click(sameLabelled[1]);
44
+ expect(onValueChange).toHaveBeenLastCalledWith(["u2"]);
45
+ });
46
+
47
+ it("still filters by the option label when the user types", async () => {
48
+ const list = await openList();
49
+ await userEvent.type(screen.getByPlaceholderText("Search..."), "anna");
50
+
51
+ await waitFor(() => expect(within(list).queryAllByRole("option", { name: "Mario Rossi" })).toHaveLength(0));
52
+ expect(within(list).getByRole("option", { name: "Anna Bianchi" })).toBeInTheDocument();
53
+ expect(within(list).queryByRole("option", { name: "(Select All)" })).toBeNull();
54
+ });
55
+
56
+ it("does not match an option by its raw value, only by its label (as before)", async () => {
57
+ const list = await openList();
58
+ await userEvent.type(screen.getByPlaceholderText("Search..."), "u3");
59
+
60
+ await waitFor(() => expect(within(list).queryByRole("option", { name: "Anna Bianchi" })).toBeNull());
61
+ });
62
+
63
+ it("keeps the static rows searchable by their text", async () => {
64
+ const list = await openList();
65
+ await userEvent.type(screen.getByPlaceholderText("Search..."), "select all");
66
+
67
+ await waitFor(() => expect(within(list).queryAllByRole("option", { name: "Mario Rossi" })).toHaveLength(0));
68
+ expect(within(list).getByRole("option", { name: "(Select All)" })).toBeInTheDocument();
69
+ });
70
+ });