@carlonicora/nextjs-jsonapi 4.0.6 → 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.
- package/dist/{BlockNoteEditor-6CTF5DEV.mjs → BlockNoteEditor-A2E4VUYB.mjs} +2 -2
- package/dist/{BlockNoteEditor-JJ3S5COJ.js → BlockNoteEditor-MCYQBKT5.js} +9 -9
- package/dist/{BlockNoteEditor-JJ3S5COJ.js.map → BlockNoteEditor-MCYQBKT5.js.map} +1 -1
- package/dist/billing/index.js +335 -335
- package/dist/billing/index.mjs +1 -1
- package/dist/{chunk-Y2AWOWWH.mjs → chunk-DJRTJBSZ.mjs} +384 -330
- package/dist/chunk-DJRTJBSZ.mjs.map +1 -0
- package/dist/{chunk-XUUHPYMM.js → chunk-PL2FHXHR.js} +82 -28
- package/dist/chunk-PL2FHXHR.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/package.json +1 -1
- package/src/components/navigations/RecentPagesNavigator.tsx +11 -1
- package/src/components/navigations/__tests__/RecentPagesNavigator.spec.tsx +19 -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/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-XUUHPYMM.js.map +0 -1
- package/dist/chunk-Y2AWOWWH.mjs.map +0 -1
- /package/dist/{BlockNoteEditor-6CTF5DEV.mjs.map → BlockNoteEditor-A2E4VUYB.mjs.map} +0 -0
|
@@ -15,6 +15,7 @@ export interface TrialSubscriptionStatus {
|
|
|
15
15
|
|
|
16
16
|
const TRIAL_DAYS = 14;
|
|
17
17
|
const GRACE_DAYS = 3;
|
|
18
|
+
const DAY_MS = 1000 * 60 * 60 * 24;
|
|
18
19
|
|
|
19
20
|
const isAdministrator = (currentUser: any): boolean => {
|
|
20
21
|
if (!currentUser || !isRolesConfigured()) return false;
|
|
@@ -93,18 +94,17 @@ export function useSubscriptionStatus(): TrialSubscriptionStatus {
|
|
|
93
94
|
// trialing/active, and that is not guaranteed — a real signup produced a
|
|
94
95
|
// StripeSubscription stuck at status "incomplete", leaving the flag NULL and
|
|
95
96
|
// a zero-day-old company blocked out of its own trial.
|
|
97
|
+
//
|
|
98
|
+
// The trial is a fixed duration (Stripe's trial_period_days is 14 x 24h),
|
|
99
|
+
// so add it in milliseconds. Adding calendar days with setDate() follows the
|
|
100
|
+
// viewer's local clock: across a DST change the window grows or shrinks by
|
|
101
|
+
// an hour, and dividing that by a fixed 24h day then shows one day too many.
|
|
96
102
|
const createdAt = new Date(company.createdAt);
|
|
97
|
-
const trialEndsAt = new Date(createdAt);
|
|
98
|
-
trialEndsAt.setDate(trialEndsAt.getDate() + TRIAL_DAYS);
|
|
103
|
+
const trialEndsAt = new Date(createdAt.getTime() + TRIAL_DAYS * DAY_MS);
|
|
99
104
|
|
|
100
105
|
const now = new Date();
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
// day count plus or minus an hour, and ceil turned 13 days + 1h into 14.
|
|
104
|
-
// Correcting by the offset difference removes exactly that hour.
|
|
105
|
-
const dstShiftMs = (now.getTimezoneOffset() - trialEndsAt.getTimezoneOffset()) * 60 * 1000;
|
|
106
|
-
const msRemaining = trialEndsAt.getTime() - now.getTime() + dstShiftMs;
|
|
107
|
-
const daysRemaining = Math.ceil(msRemaining / (1000 * 60 * 60 * 24));
|
|
106
|
+
const msRemaining = trialEndsAt.getTime() - now.getTime();
|
|
107
|
+
const daysRemaining = Math.ceil(msRemaining / DAY_MS);
|
|
108
108
|
const trialElapsed = daysRemaining <= 0;
|
|
109
109
|
|
|
110
110
|
return {
|
|
@@ -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
|
-
|
|
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
|
|
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
|
>
|