@carlonicora/nextjs-jsonapi 4.0.7 → 4.1.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 (59) hide show
  1. package/dist/{BlockNoteEditor-J7EF7BRX.js → BlockNoteEditor-6CBMNJPU.js} +9 -9
  2. package/dist/{BlockNoteEditor-J7EF7BRX.js.map → BlockNoteEditor-6CBMNJPU.js.map} +1 -1
  3. package/dist/{BlockNoteEditor-KOHN3SSR.mjs → BlockNoteEditor-7N66JABP.mjs} +2 -2
  4. package/dist/billing/index.js +335 -335
  5. package/dist/billing/index.mjs +1 -1
  6. package/dist/{chunk-3W3VVX2N.mjs → chunk-HQKTQBUC.mjs} +89 -29
  7. package/dist/chunk-HQKTQBUC.mjs.map +1 -0
  8. package/dist/{chunk-3M7D6NXU.js → chunk-MJVEKDM4.js} +320 -260
  9. package/dist/chunk-MJVEKDM4.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/dist/next-config/index.d.mts +8 -1
  26. package/dist/next-config/index.d.ts +8 -1
  27. package/dist/next-config/index.js +31 -9
  28. package/dist/next-config/index.js.map +1 -1
  29. package/dist/next-config/index.mjs +31 -9
  30. package/dist/next-config/index.mjs.map +1 -1
  31. package/package.json +1 -1
  32. package/src/features/company/components/details/CompanyContent.tsx +9 -2
  33. package/src/features/company/components/forms/CompanyEditor.tsx +2 -0
  34. package/src/features/company/hooks/useSubscriptionStatus.test.ts +76 -10
  35. package/src/features/company/hooks/useSubscriptionStatus.ts +9 -9
  36. package/src/features/how-to/components/containers/HowToCommand.spec.tsx +79 -6
  37. package/src/features/how-to/components/containers/HowToCommand.tsx +12 -2
  38. package/src/features/how-to/components/forms/HowToSelector.spec.tsx +85 -0
  39. package/src/features/how-to/components/forms/HowToSelector.tsx +1 -0
  40. package/src/features/role/components/forms/UserRoleAdd.spec.tsx +71 -0
  41. package/src/features/role/components/forms/UserRoleAdd.tsx +1 -0
  42. package/src/features/user/components/forms/UserSelector.spec.tsx +85 -0
  43. package/src/features/user/components/forms/UserSelector.tsx +1 -0
  44. package/src/features/user/components/lists/UserListInAdd.spec.tsx +84 -0
  45. package/src/features/user/components/lists/UserListInAdd.tsx +1 -0
  46. package/src/features/user/components/widgets/UserSearchPopover.spec.tsx +68 -0
  47. package/src/features/user/components/widgets/UserSearchPopover.tsx +1 -0
  48. package/src/next-config/__tests__/createNextConfig.test.ts +45 -3
  49. package/src/next-config/csp.ts +25 -4
  50. package/src/next-config/index.ts +19 -12
  51. package/src/shadcnui/custom/multi-select.spec.tsx +70 -0
  52. package/src/shadcnui/custom/multi-select.tsx +26 -2
  53. package/src/shadcnui/custom/multiple-selector.spec.tsx +111 -0
  54. package/src/shadcnui/custom/multiple-selector.tsx +33 -10
  55. package/src/shadcnui/ui/command.spec.ts +26 -0
  56. package/src/shadcnui/ui/command.tsx +13 -1
  57. package/dist/chunk-3M7D6NXU.js.map +0 -1
  58. package/dist/chunk-3W3VVX2N.mjs.map +0 -1
  59. /package/dist/{BlockNoteEditor-KOHN3SSR.mjs.map → BlockNoteEditor-7N66JABP.mjs.map} +0 -0
@@ -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
  >
@@ -9,12 +9,12 @@ const FIXED_ENV = vi.hoisted(() => {
9
9
  NEXT_PUBLIC_API_URL: "https://api.test",
10
10
  NEXT_PUBLIC_ADDRESS: "https://app.test",
11
11
  IMAGE_SOURCES: "https://img.test",
12
+ NEXT_PUBLIC_CORPUS_URL: "https://corpus.test",
12
13
  APP_URL: "https://app.test",
13
14
  };
14
15
  for (const key of [
15
16
  "NEXT_PUBLIC_VAPID_PUBLIC_KEY",
16
17
  "NEXT_PUBLIC_GOOGLE_MAPS_API_KEY",
17
- "NEXT_PUBLIC_CORPUS_URL",
18
18
  "NEXT_PUBLIC_GTM_ID",
19
19
  "E2E_BUILD",
20
20
  ])
@@ -139,7 +139,11 @@ describe("createNextConfig: behaviour", () => {
139
139
  expect(plain.allowedDevOrigins).toEqual(["example.test"]);
140
140
  expect(plain.images).toEqual({
141
141
  minimumCacheTTL: 60,
142
- remotePatterns: [{ protocol: "https", hostname: "img.test" }],
142
+ remotePatterns: [
143
+ { protocol: "https", hostname: "img.test" },
144
+ { protocol: "https", hostname: "api.test" },
145
+ { protocol: "https", hostname: "corpus.test" },
146
+ ],
143
147
  dangerouslyAllowLocalIP: true,
144
148
  });
145
149
 
@@ -166,6 +170,16 @@ describe("createNextConfig: behaviour", () => {
166
170
  expect(production.distDir).toBeUndefined();
167
171
  });
168
172
 
173
+ it("lets the optimizer fetch from the api and corpus origins, skipping unset and duplicate ones", () => {
174
+ vi.stubEnv("NEXT_PUBLIC_CORPUS_URL", undefined);
175
+ vi.stubEnv("IMAGE_SOURCES", "https://img.test,https://api.test");
176
+ const config = createNextConfig(base) as { images: { remotePatterns: unknown[] } };
177
+ expect(config.images.remotePatterns).toEqual([
178
+ { protocol: "https", hostname: "img.test" },
179
+ { protocol: "https", hostname: "api.test" },
180
+ ]);
181
+ });
182
+
169
183
  it("reactCompiler option sets both reactCompiler and turbopackRustReactCompiler", () => {
170
184
  const on = createNextConfig({ ...base, reactCompiler: true });
171
185
  expect(on.reactCompiler).toBe(true);
@@ -197,7 +211,7 @@ describe("buildCsp", () => {
197
211
  expect(csp["script-src"]).toEqual(["'self'", "'unsafe-eval'", "'unsafe-inline'"]);
198
212
  expect(csp["style-src"]).toEqual(["'self'", "'unsafe-inline'"]);
199
213
  expect(csp["font-src"]).toEqual(["'self'"]);
200
- expect(csp["frame-src"]).toEqual(["'self'"]);
214
+ expect(csp["frame-src"]).toEqual(["'self'", "https://api.test", "https://corpus.test"]);
201
215
  expect(csp["worker-src"]).toEqual(["'self'", "blob:"]);
202
216
  expect(buildCsp({}, env)).not.toMatch(/stripe|google|cloudflare|discord/);
203
217
  });
@@ -246,6 +260,8 @@ describe("buildCsp", () => {
246
260
  const csp = parseCsp(buildCsp({ services: ["stripe"], csp: { "frame-src": ["https://docs.google.com/"] } }, env));
247
261
  expect(csp["frame-src"]).toEqual([
248
262
  "'self'",
263
+ "https://api.test",
264
+ "https://corpus.test",
249
265
  "https://docs.google.com/",
250
266
  "https://hooks.stripe.com",
251
267
  "https://js.stripe.com",
@@ -260,6 +276,32 @@ describe("buildCsp", () => {
260
276
  expect(csp["img-src"]).toContain("https://img.test");
261
277
  });
262
278
 
279
+ it("allows images and frames from the api and corpus origins", () => {
280
+ const csp = parseCsp(buildCsp({}, env));
281
+ for (const directive of ["img-src", "frame-src"]) {
282
+ expect(csp[directive]).toEqual(expect.arrayContaining(["https://api.test", "https://corpus.test"]));
283
+ }
284
+ });
285
+
286
+ it("lists the api and corpus origins once, and nothing for an unset corpus", () => {
287
+ const value = buildCsp({}, {
288
+ NODE_ENV: "production",
289
+ NEXT_PUBLIC_API_URL: "https://api.test/",
290
+ IMAGE_SOURCES: "https://api.test",
291
+ } as unknown as NodeJS.ProcessEnv);
292
+ for (const directive of ["img-src", "frame-src"]) {
293
+ const sources = value
294
+ .split(";")
295
+ .map((part) => part.trim().split(/\s+/))
296
+ .find(([name]) => name === directive)!
297
+ .slice(1);
298
+ expect(sources.filter((source) => source === "https://api.test")).toHaveLength(1);
299
+ expect(sources.join(" ")).not.toContain("corpus");
300
+ expect(sources).not.toContain("");
301
+ }
302
+ expect(value).not.toMatch(/\s{2,}/);
303
+ });
304
+
263
305
  it("drops empty sources when env values are missing", () => {
264
306
  const value = buildCsp({ csp: { "img-src": [""] } }, { NODE_ENV: "development" } as unknown as NodeJS.ProcessEnv);
265
307
  expect(value).not.toMatch(/\s{2,}/);
@@ -52,12 +52,33 @@ function listImageSources(env: NodeJS.ProcessEnv): string[] {
52
52
  return env.IMAGE_SOURCES ? env.IMAGE_SOURCES.split(",").map((s) => s.trim()) : [];
53
53
  }
54
54
 
55
+ /**
56
+ * Origins of the app's own servers, the API and the corpus, from
57
+ * `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_CORPUS_URL`. They serve every stored
58
+ * object link, so images and frames must be allowed from them. Unset or invalid
59
+ * URLs are skipped; the result has no duplicates.
60
+ */
61
+ export function listAppOrigins(env: NodeJS.ProcessEnv): string[] {
62
+ const origins: string[] = [];
63
+ for (const url of [env.NEXT_PUBLIC_API_URL, env.NEXT_PUBLIC_CORPUS_URL]) {
64
+ if (!url) continue;
65
+ try {
66
+ const { origin } = new URL(url.trim());
67
+ if (origin !== "null" && !origins.includes(origin)) origins.push(origin);
68
+ } catch {
69
+ // Not a URL: nothing to allow.
70
+ }
71
+ }
72
+ return origins;
73
+ }
74
+
55
75
  // Build CSP directives
56
76
  // Note: 'unsafe-inline' and 'unsafe-eval' needed for Next.js dev mode and some runtime features
57
77
  // In production, consider using nonces for stricter CSP
58
78
  export function buildCsp(options: CspOptions, env: NodeJS.ProcessEnv): string {
59
79
  const apiUrl = env.NEXT_PUBLIC_API_URL || "";
60
80
  const imageSources = listImageSources(env);
81
+ const appOrigins = listAppOrigins(env);
61
82
  const services = options.services ?? [];
62
83
 
63
84
  // Each fetch directive: what Next itself needs, then the named services, then
@@ -67,8 +88,8 @@ export function buildCsp(options: CspOptions, env: NodeJS.ProcessEnv): string {
67
88
  "script-src": { base: ["'self'", "'unsafe-inline'", "'unsafe-eval'"], fromEnv: [] },
68
89
  // Styles: self and unsafe-inline for styled-components/emotion
69
90
  "style-src": { base: ["'self'", "'unsafe-inline'"], fromEnv: [] },
70
- // Images: self, data URIs, blobs, and configured image sources
71
- "img-src": { base: ["'self'", "data:", "blob:"], fromEnv: imageSources },
91
+ // Images: self, data URIs, blobs, configured image sources, and the api/corpus origins
92
+ "img-src": { base: ["'self'", "data:", "blob:"], fromEnv: [...imageSources, ...appOrigins] },
72
93
  // Fonts: self
73
94
  "font-src": { base: ["'self'"], fromEnv: [] },
74
95
  // Connect: self, API (http + ws for socket.io), and storage (for uploads)
@@ -82,8 +103,8 @@ export function buildCsp(options: CspOptions, env: NodeJS.ProcessEnv): string {
82
103
  ...imageSources,
83
104
  ],
84
105
  },
85
- // Frames: self
86
- "frame-src": { base: ["'self'"], fromEnv: [] },
106
+ // Frames: self, and the api/corpus origins (inline previews of stored objects)
107
+ "frame-src": { base: ["'self'"], fromEnv: appOrigins },
87
108
  // Worker sources: self and blob (for Web Workers with dynamic imports)
88
109
  "worker-src": { base: ["'self'", "blob:"], fromEnv: [] },
89
110
  };