@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.
- package/dist/{BlockNoteEditor-J7EF7BRX.js → BlockNoteEditor-6CBMNJPU.js} +9 -9
- package/dist/{BlockNoteEditor-J7EF7BRX.js.map → BlockNoteEditor-6CBMNJPU.js.map} +1 -1
- package/dist/{BlockNoteEditor-KOHN3SSR.mjs → BlockNoteEditor-7N66JABP.mjs} +2 -2
- package/dist/billing/index.js +335 -335
- package/dist/billing/index.mjs +1 -1
- package/dist/{chunk-3W3VVX2N.mjs → chunk-HQKTQBUC.mjs} +89 -29
- package/dist/chunk-HQKTQBUC.mjs.map +1 -0
- package/dist/{chunk-3M7D6NXU.js → chunk-MJVEKDM4.js} +320 -260
- package/dist/chunk-MJVEKDM4.js.map +1 -0
- package/dist/client/index.js +2 -2
- package/dist/client/index.mjs +1 -1
- package/dist/components/index.d.mts +10 -1
- package/dist/components/index.d.ts +10 -1
- package/dist/components/index.js +4 -2
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +3 -1
- package/dist/contexts/index.js +2 -2
- package/dist/contexts/index.mjs +1 -1
- package/dist/features/analytics/index.js +88 -88
- package/dist/features/analytics/index.mjs +1 -1
- package/dist/features/help/index.js +31 -31
- package/dist/features/help/index.mjs +1 -1
- package/dist/features/tokenusage/index.js +75 -75
- package/dist/features/tokenusage/index.mjs +1 -1
- package/dist/next-config/index.d.mts +8 -1
- package/dist/next-config/index.d.ts +8 -1
- package/dist/next-config/index.js +31 -9
- package/dist/next-config/index.js.map +1 -1
- package/dist/next-config/index.mjs +31 -9
- package/dist/next-config/index.mjs.map +1 -1
- package/package.json +1 -1
- package/src/features/company/components/details/CompanyContent.tsx +9 -2
- package/src/features/company/components/forms/CompanyEditor.tsx +2 -0
- package/src/features/company/hooks/useSubscriptionStatus.test.ts +76 -10
- package/src/features/company/hooks/useSubscriptionStatus.ts +9 -9
- package/src/features/how-to/components/containers/HowToCommand.spec.tsx +79 -6
- package/src/features/how-to/components/containers/HowToCommand.tsx +12 -2
- package/src/features/how-to/components/forms/HowToSelector.spec.tsx +85 -0
- package/src/features/how-to/components/forms/HowToSelector.tsx +1 -0
- package/src/features/role/components/forms/UserRoleAdd.spec.tsx +71 -0
- package/src/features/role/components/forms/UserRoleAdd.tsx +1 -0
- package/src/features/user/components/forms/UserSelector.spec.tsx +85 -0
- package/src/features/user/components/forms/UserSelector.tsx +1 -0
- package/src/features/user/components/lists/UserListInAdd.spec.tsx +84 -0
- package/src/features/user/components/lists/UserListInAdd.tsx +1 -0
- package/src/features/user/components/widgets/UserSearchPopover.spec.tsx +68 -0
- package/src/features/user/components/widgets/UserSearchPopover.tsx +1 -0
- package/src/next-config/__tests__/createNextConfig.test.ts +45 -3
- package/src/next-config/csp.ts +25 -4
- package/src/next-config/index.ts +19 -12
- package/src/shadcnui/custom/multi-select.spec.tsx +70 -0
- package/src/shadcnui/custom/multi-select.tsx +26 -2
- package/src/shadcnui/custom/multiple-selector.spec.tsx +111 -0
- package/src/shadcnui/custom/multiple-selector.tsx +33 -10
- package/src/shadcnui/ui/command.spec.ts +26 -0
- package/src/shadcnui/ui/command.tsx +13 -1
- package/dist/chunk-3M7D6NXU.js.map +0 -1
- package/dist/chunk-3W3VVX2N.mjs.map +0 -1
- /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
|
|
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
|
|
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: [
|
|
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,}/);
|
package/src/next-config/csp.ts
CHANGED
|
@@ -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,
|
|
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
|
};
|