@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.
Files changed (50) hide show
  1. package/dist/{BlockNoteEditor-6CTF5DEV.mjs → BlockNoteEditor-A2E4VUYB.mjs} +2 -2
  2. package/dist/{BlockNoteEditor-JJ3S5COJ.js → BlockNoteEditor-MCYQBKT5.js} +9 -9
  3. package/dist/{BlockNoteEditor-JJ3S5COJ.js.map → BlockNoteEditor-MCYQBKT5.js.map} +1 -1
  4. package/dist/billing/index.js +335 -335
  5. package/dist/billing/index.mjs +1 -1
  6. package/dist/{chunk-Y2AWOWWH.mjs → chunk-DJRTJBSZ.mjs} +384 -330
  7. package/dist/chunk-DJRTJBSZ.mjs.map +1 -0
  8. package/dist/{chunk-XUUHPYMM.js → chunk-PL2FHXHR.js} +82 -28
  9. package/dist/chunk-PL2FHXHR.js.map +1 -0
  10. package/dist/client/index.js +2 -2
  11. package/dist/client/index.mjs +1 -1
  12. package/dist/components/index.d.mts +10 -1
  13. package/dist/components/index.d.ts +10 -1
  14. package/dist/components/index.js +4 -2
  15. package/dist/components/index.js.map +1 -1
  16. package/dist/components/index.mjs +3 -1
  17. package/dist/contexts/index.js +2 -2
  18. package/dist/contexts/index.mjs +1 -1
  19. package/dist/features/analytics/index.js +88 -88
  20. package/dist/features/analytics/index.mjs +1 -1
  21. package/dist/features/help/index.js +31 -31
  22. package/dist/features/help/index.mjs +1 -1
  23. package/dist/features/tokenusage/index.js +75 -75
  24. package/dist/features/tokenusage/index.mjs +1 -1
  25. package/package.json +1 -1
  26. package/src/components/navigations/RecentPagesNavigator.tsx +11 -1
  27. package/src/components/navigations/__tests__/RecentPagesNavigator.spec.tsx +19 -0
  28. package/src/features/company/hooks/useSubscriptionStatus.test.ts +76 -10
  29. package/src/features/company/hooks/useSubscriptionStatus.ts +9 -9
  30. package/src/features/how-to/components/containers/HowToCommand.spec.tsx +79 -6
  31. package/src/features/how-to/components/containers/HowToCommand.tsx +12 -2
  32. package/src/features/how-to/components/forms/HowToSelector.spec.tsx +85 -0
  33. package/src/features/how-to/components/forms/HowToSelector.tsx +1 -0
  34. package/src/features/role/components/forms/UserRoleAdd.spec.tsx +71 -0
  35. package/src/features/role/components/forms/UserRoleAdd.tsx +1 -0
  36. package/src/features/user/components/forms/UserSelector.spec.tsx +85 -0
  37. package/src/features/user/components/forms/UserSelector.tsx +1 -0
  38. package/src/features/user/components/lists/UserListInAdd.spec.tsx +84 -0
  39. package/src/features/user/components/lists/UserListInAdd.tsx +1 -0
  40. package/src/features/user/components/widgets/UserSearchPopover.spec.tsx +68 -0
  41. package/src/features/user/components/widgets/UserSearchPopover.tsx +1 -0
  42. package/src/shadcnui/custom/multi-select.spec.tsx +70 -0
  43. package/src/shadcnui/custom/multi-select.tsx +26 -2
  44. package/src/shadcnui/custom/multiple-selector.spec.tsx +111 -0
  45. package/src/shadcnui/custom/multiple-selector.tsx +33 -10
  46. package/src/shadcnui/ui/command.spec.ts +26 -0
  47. package/src/shadcnui/ui/command.tsx +13 -1
  48. package/dist/chunk-XUUHPYMM.js.map +0 -1
  49. package/dist/chunk-Y2AWOWWH.mjs.map +0 -1
  50. /package/dist/{BlockNoteEditor-6CTF5DEV.mjs.map → BlockNoteEditor-A2E4VUYB.mjs.map} +0 -0
@@ -0,0 +1,70 @@
1
+ import { render, screen, waitFor, within } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { MultiSelect } from "./multi-select";
6
+
7
+ // Two options with the SAME label: cmdk derives an item's value from its text when no
8
+ // `value` is given, so without a unique value both rows highlight together.
9
+ const options = [
10
+ { label: "Mario Rossi", value: "u1" },
11
+ { label: "Mario Rossi", value: "u2" },
12
+ { label: "Anna Bianchi", value: "u3" },
13
+ ];
14
+
15
+ async function openList(onValueChange = vi.fn()) {
16
+ render(<MultiSelect options={options} onValueChange={onValueChange} placeholder="pick" />);
17
+ await userEvent.click(screen.getByText("pick"));
18
+ return screen.findByRole("listbox");
19
+ }
20
+
21
+ const highlightedRows = (list: HTMLElement) =>
22
+ within(list)
23
+ .getAllByRole("option")
24
+ .filter((o) => o.getAttribute("aria-selected") === "true");
25
+
26
+ describe("MultiSelect", () => {
27
+ beforeEach(() => {
28
+ Element.prototype.scrollIntoView = vi.fn();
29
+ });
30
+
31
+ it("highlights only one of two same-labelled options and toggles the one clicked", async () => {
32
+ const onValueChange = vi.fn();
33
+ const list = await openList(onValueChange);
34
+
35
+ // "(Select All)" starts highlighted; one step down lands on the first "Mario Rossi".
36
+ await userEvent.keyboard("{ArrowDown}");
37
+ const highlighted = highlightedRows(list);
38
+ expect(highlighted).toHaveLength(1);
39
+ expect(highlighted[0]).toHaveTextContent("Mario Rossi");
40
+
41
+ const sameLabelled = within(list).getAllByRole("option", { name: "Mario Rossi" });
42
+ expect(sameLabelled).toHaveLength(2);
43
+ await userEvent.click(sameLabelled[1]);
44
+ expect(onValueChange).toHaveBeenLastCalledWith(["u2"]);
45
+ });
46
+
47
+ it("still filters by the option label when the user types", async () => {
48
+ const list = await openList();
49
+ await userEvent.type(screen.getByPlaceholderText("Search..."), "anna");
50
+
51
+ await waitFor(() => expect(within(list).queryAllByRole("option", { name: "Mario Rossi" })).toHaveLength(0));
52
+ expect(within(list).getByRole("option", { name: "Anna Bianchi" })).toBeInTheDocument();
53
+ expect(within(list).queryByRole("option", { name: "(Select All)" })).toBeNull();
54
+ });
55
+
56
+ it("does not match an option by its raw value, only by its label (as before)", async () => {
57
+ const list = await openList();
58
+ await userEvent.type(screen.getByPlaceholderText("Search..."), "u3");
59
+
60
+ await waitFor(() => expect(within(list).queryByRole("option", { name: "Anna Bianchi" })).toBeNull());
61
+ });
62
+
63
+ it("keeps the static rows searchable by their text", async () => {
64
+ const list = await openList();
65
+ await userEvent.type(screen.getByPlaceholderText("Search..."), "select all");
66
+
67
+ await waitFor(() => expect(within(list).queryAllByRole("option", { name: "Mario Rossi" })).toHaveLength(0));
68
+ expect(within(list).getByRole("option", { name: "(Select All)" })).toBeInTheDocument();
69
+ });
70
+ });
@@ -14,6 +14,7 @@ import {
14
14
  CommandItem,
15
15
  CommandList,
16
16
  CommandSeparator,
17
+ commandFilterByKeywords,
17
18
  } from "../ui/command";
18
19
  import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
19
20
  import { Separator } from "../ui/separator";
@@ -39,6 +40,18 @@ const multiSelectVariants = cva(
39
40
  },
40
41
  );
41
42
 
43
+ /**
44
+ * Every row carries a unique `value` (an option's own value, or a reserved
45
+ * sentinel for the static rows) so that two options with the same label do not
46
+ * share cmdk's text-derived value and highlight/select together. The searchable
47
+ * text lives in `keywords`, and the filter scores the keywords alone: that keeps
48
+ * typing-to-search matching exactly what it matched when the value was the
49
+ * row's text, instead of also matching the option's raw value.
50
+ */
51
+ const SELECT_ALL_VALUE = "__multi-select:select-all__";
52
+ const CLEAR_VALUE = "__multi-select:clear__";
53
+ const CLOSE_VALUE = "__multi-select:close__";
54
+
42
55
  /**
43
56
  * Props for MultiSelect component
44
57
  */
@@ -365,7 +378,7 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
365
378
  style={{ minWidth: triggerWidth > 0 ? triggerWidth : undefined }}
366
379
  align="start"
367
380
  >
368
- <Command>
381
+ <Command filter={commandFilterByKeywords}>
369
382
  <CommandInput
370
383
  autoFocus
371
384
  placeholder="Search..."
@@ -383,6 +396,8 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
383
396
  {!hideSelectAll && (
384
397
  <CommandItem
385
398
  key="all"
399
+ value={SELECT_ALL_VALUE}
400
+ keywords={["(Select All)"]}
386
401
  onSelect={toggleAll}
387
402
  className="cursor-pointer hover:bg-muted data-selected:hover:bg-muted bg-transparent data-selected:bg-transparent"
388
403
  >
@@ -406,6 +421,8 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
406
421
  return (
407
422
  <CommandItem
408
423
  key={option.value}
424
+ value={option.value}
425
+ keywords={[option.label]}
409
426
  onSelect={() => toggleOption(option.value)}
410
427
  className="cursor-pointer hover:bg-muted data-selected:hover:bg-muted bg-transparent data-selected:bg-transparent"
411
428
  >
@@ -430,13 +447,20 @@ export const MultiSelect = React.forwardRef<HTMLButtonElement, MultiSelectProps>
430
447
  <div className="flex items-center justify-between">
431
448
  {selectedValues.length > 0 && (
432
449
  <>
433
- <CommandItem onSelect={handleClear} className="flex-1 cursor-pointer justify-center">
450
+ <CommandItem
451
+ value={CLEAR_VALUE}
452
+ keywords={["Clear"]}
453
+ onSelect={handleClear}
454
+ className="flex-1 cursor-pointer justify-center"
455
+ >
434
456
  Clear
435
457
  </CommandItem>
436
458
  <Separator orientation="vertical" className="flex h-full min-h-6" />
437
459
  </>
438
460
  )}
439
461
  <CommandItem
462
+ value={CLOSE_VALUE}
463
+ keywords={["Close"]}
440
464
  onSelect={() => setIsPopoverOpen(false)}
441
465
  className="max-w-full flex-1 cursor-pointer justify-center hover:bg-muted data-selected:hover:bg-muted bg-transparent data-selected:bg-transparent"
442
466
  >
@@ -0,0 +1,111 @@
1
+ import { render, screen, waitFor, within } from "@testing-library/react";
2
+ import userEvent from "@testing-library/user-event";
3
+ import { beforeEach, describe, expect, it, vi } from "vitest";
4
+
5
+ import { MultipleSelector, type MultipleSelectorProps, type Option } from "./multiple-selector";
6
+
7
+ // Two options with the SAME label: when the row value was the label, both rows shared
8
+ // one cmdk value and highlighted/selected together.
9
+ const options: Option[] = [
10
+ { label: "Mario Rossi", value: "u1" },
11
+ { label: "Mario Rossi", value: "u2" },
12
+ { label: "Anna Bianchi", value: "u3" },
13
+ ];
14
+
15
+ async function openList(props: Partial<MultipleSelectorProps> = {}) {
16
+ render(<MultipleSelector defaultOptions={options} placeholder="pick" {...props} />);
17
+ await userEvent.click(screen.getByPlaceholderText("pick"));
18
+ return screen.findByRole("listbox");
19
+ }
20
+
21
+ const highlightedRows = (list: HTMLElement) =>
22
+ within(list)
23
+ .getAllByRole("option")
24
+ .filter((o) => o.getAttribute("aria-selected") === "true");
25
+
26
+ const optionNames = (list: HTMLElement) =>
27
+ within(list)
28
+ .getAllByRole("option")
29
+ .map((o) => o.textContent);
30
+
31
+ describe("MultipleSelector", () => {
32
+ beforeEach(() => {
33
+ Element.prototype.scrollIntoView = vi.fn();
34
+ });
35
+
36
+ it("highlights only one of two same-labelled options and selects the one clicked", async () => {
37
+ const onChange = vi.fn();
38
+ const list = await openList({ onChange });
39
+
40
+ const highlighted = highlightedRows(list);
41
+ expect(highlighted).toHaveLength(1);
42
+ expect(highlighted[0]).toHaveTextContent("Mario Rossi");
43
+
44
+ await userEvent.keyboard("{ArrowDown}");
45
+ expect(highlightedRows(list)).toHaveLength(1);
46
+
47
+ const sameLabelled = within(list).getAllByRole("option", { name: "Mario Rossi" });
48
+ expect(sameLabelled).toHaveLength(2);
49
+ await userEvent.click(sameLabelled[1]);
50
+ expect(onChange).toHaveBeenLastCalledWith([options[1]]);
51
+ });
52
+
53
+ it("still filters by the option label when the user types", async () => {
54
+ const list = await openList();
55
+ await userEvent.type(screen.getByPlaceholderText("pick"), "anna");
56
+
57
+ await waitFor(() => expect(within(list).queryAllByRole("option", { name: "Mario Rossi" })).toHaveLength(0));
58
+ expect(within(list).getByRole("option", { name: "Anna Bianchi" })).toBeInTheDocument();
59
+ });
60
+
61
+ it("does not match an option by its raw value, only by its label (as before)", async () => {
62
+ const list = await openList();
63
+ await userEvent.type(screen.getByPlaceholderText("pick"), "u3");
64
+
65
+ await waitFor(() => expect(within(list).queryByRole("option", { name: "Anna Bianchi" })).toBeNull());
66
+ });
67
+
68
+ it("ranks matches by label score as before", async () => {
69
+ const list = await openList({
70
+ defaultOptions: [
71
+ { label: "Cabana", value: "o1" },
72
+ { label: "Ananas", value: "o2" },
73
+ ],
74
+ });
75
+ expect(optionNames(list)).toEqual(["Cabana", "Ananas"]);
76
+
77
+ await userEvent.type(screen.getByPlaceholderText("pick"), "ana");
78
+ await waitFor(() => expect(optionNames(list)).toEqual(["Ananas", "Cabana"]));
79
+ });
80
+
81
+ it("keeps the create row findable and creates the typed text", async () => {
82
+ const onChange = vi.fn();
83
+ const list = await openList({ onChange, creatable: true });
84
+ await userEvent.type(screen.getByPlaceholderText("pick"), "Zed");
85
+
86
+ const createRow = await within(list).findByRole("option", { name: 'Create "Zed"' });
87
+ expect(within(list).queryAllByRole("option", { name: "Mario Rossi" })).toHaveLength(0);
88
+ await userEvent.click(createRow);
89
+ expect(onChange).toHaveBeenLastCalledWith([{ value: "Zed", label: "Zed" }]);
90
+ });
91
+
92
+ it("filters by substring of the label when creatable, as before", async () => {
93
+ const list = await openList({ creatable: true });
94
+ await userEvent.type(screen.getByPlaceholderText("pick"), "bianchi");
95
+
96
+ await waitFor(() => expect(within(list).queryAllByRole("option", { name: "Mario Rossi" })).toHaveLength(0));
97
+ expect(within(list).getByRole("option", { name: "Anna Bianchi" })).toBeInTheDocument();
98
+ });
99
+
100
+ it("hands a caller-supplied filter the row text, never the raw option value", async () => {
101
+ const filter = vi.fn((value: string, search: string) => (value.toLowerCase().includes(search) ? 1 : 0));
102
+ const list = await openList({ commandProps: { filter } });
103
+ await userEvent.type(screen.getByPlaceholderText("pick"), "anna");
104
+
105
+ await waitFor(() => expect(within(list).queryAllByRole("option", { name: "Mario Rossi" })).toHaveLength(0));
106
+ const seenValues = filter.mock.calls.map((c) => c[0]);
107
+ expect(seenValues).toContain("Anna Bianchi");
108
+ expect(seenValues).not.toContain("u1");
109
+ expect(seenValues).not.toContain("u3");
110
+ });
111
+ });
@@ -8,7 +8,7 @@ import { forwardRef, useEffect } from "react";
8
8
 
9
9
  import { cn } from "../../utils/cn";
10
10
  import { Badge } from "../ui/badge";
11
- import { Command, CommandGroup, CommandItem, CommandList } from "../ui/command";
11
+ import { Command, CommandGroup, CommandItem, CommandList, commandFilterByKeywords } from "../ui/command";
12
12
 
13
13
  export interface Option {
14
14
  value: string;
@@ -140,6 +140,19 @@ function removePickedOption(groupOption: GroupOption, picked: Option[]) {
140
140
  return cloneOption;
141
141
  }
142
142
 
143
+ /**
144
+ * Every option row carries its own unique `value` (the option's `value`), and
145
+ * the "create" row a reserved one, so two options with the same label do not
146
+ * share cmdk's value and highlight/select together. The row's searchable text
147
+ * lives in `keywords`, and every filter below is given that text where it used
148
+ * to receive the value, so typing matches and ranks exactly as it did when the
149
+ * value was the label — and never matches an option's raw value.
150
+ */
151
+ const CREATE_VALUE = "__multiple-selector:create__";
152
+
153
+ /** The text a row is searched by: its keywords, or its value when it has none (the "-" placeholders). */
154
+ const rowText = (value: string, keywords?: string[]) => (keywords && keywords.length > 0 ? keywords.join(" ") : value);
155
+
143
156
  function isOptionsExist(groupOption: GroupOption, targetOption: Option[]) {
144
157
  for (const [, value] of Object.entries(groupOption)) {
145
158
  if (value.some((option) => targetOption.find((p) => p.value === option.value))) {
@@ -396,17 +409,22 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
396
409
 
397
410
  const Item = (
398
411
  <CommandItem
399
- value={inputValue}
412
+ // cmdk only re-reads an item's keywords when its value changes, so the
413
+ // typed text is part of the reserved value to keep the keywords current.
414
+ value={`${CREATE_VALUE}${inputValue}`}
415
+ keywords={[inputValue]}
400
416
  className="cursor-pointer"
401
417
  onMouseDown={(e) => {
402
418
  e.preventDefault();
403
419
  e.stopPropagation();
404
420
  }}
405
- onSelect={(value: string) => {
421
+ onSelect={() => {
406
422
  if (selected.length >= maxSelected) {
407
423
  onMaxSelected?.(selected.length);
408
424
  return;
409
425
  }
426
+ // cmdk used to hand back the row's value, which was the typed text trimmed.
427
+ const value = inputValue.trim();
410
428
  setInputValue("");
411
429
  const newOptions = [...selected, { value, label: value }];
412
430
  setSelected(newOptions);
@@ -445,16 +463,19 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
445
463
  const selectables = React.useMemo<GroupOption>(() => removePickedOption(options, selected), [options, selected]);
446
464
 
447
465
  const commandFilter = React.useCallback(() => {
448
- if (commandProps?.filter) {
449
- return commandProps.filter;
466
+ const customFilter = commandProps?.filter;
467
+ if (customFilter) {
468
+ // A caller's filter keeps receiving the row text as `value` (no keywords), as before.
469
+ return (value: string, search: string, keywords?: string[]) =>
470
+ customFilter(rowText(value, keywords), search, []);
450
471
  }
451
472
 
452
473
  if (creatable) {
453
- return (value: string, search: string) => {
454
- return value.toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
474
+ return (value: string, search: string, keywords?: string[]) => {
475
+ return rowText(value, keywords).toLowerCase().includes(search.toLowerCase()) ? 1 : -1;
455
476
  };
456
477
  }
457
- return undefined;
478
+ return commandFilterByKeywords;
458
479
  }, [creatable, commandProps?.filter]);
459
480
 
460
481
  // Determine which badges to display
@@ -613,7 +634,8 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
613
634
  return (
614
635
  <CommandItem
615
636
  key={option.value}
616
- value={option.label}
637
+ value={option.value}
638
+ keywords={[option.label]}
617
639
  disabled={option.disable}
618
640
  onMouseDown={(e) => {
619
641
  e.preventDefault();
@@ -688,7 +710,8 @@ const MultipleSelector = React.forwardRef<MultipleSelectorRef, MultipleSelectorP
688
710
  return (
689
711
  <CommandItem
690
712
  key={option.value}
691
- value={option.label}
713
+ value={option.value}
714
+ keywords={[option.label]}
692
715
  disabled={option.disable}
693
716
  onMouseDown={(e) => {
694
717
  e.preventDefault();
@@ -0,0 +1,26 @@
1
+ import { defaultFilter } from "cmdk";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { commandFilterByKeywords } from "./command";
5
+
6
+ describe("commandFilterByKeywords", () => {
7
+ it("scores the keywords exactly as cmdk scores the same text used as a value", () => {
8
+ expect(commandFilterByKeywords("u1", "mar", ["Mario Rossi"])).toBe(defaultFilter("Mario Rossi", "mar"));
9
+ expect(commandFilterByKeywords("u1", "mar", ["Mario Rossi"])).toBeGreaterThan(0);
10
+ });
11
+
12
+ it("does not match an id-like value when the keywords do not match", () => {
13
+ expect(commandFilterByKeywords("9f3c-uuid-1234", "1234", ["Mario Rossi"])).toBe(0);
14
+ expect(commandFilterByKeywords("u3", "u3", ["Anna Bianchi"])).toBe(0);
15
+ });
16
+
17
+ it("joins several keywords into one searchable text", () => {
18
+ expect(commandFilterByKeywords("u1", "rossi", ["Mario", "Rossi"])).toBeGreaterThan(0);
19
+ });
20
+
21
+ it("falls back to the value when there are no keywords", () => {
22
+ expect(commandFilterByKeywords("Mario Rossi", "mar")).toBe(defaultFilter("Mario Rossi", "mar"));
23
+ expect(commandFilterByKeywords("Mario Rossi", "mar", [])).toBeGreaterThan(0);
24
+ expect(commandFilterByKeywords("Mario Rossi", "zzz", [])).toBe(0);
25
+ });
26
+ });
@@ -1,7 +1,7 @@
1
1
  "use client";
2
2
 
3
3
  import * as React from "react";
4
- import { Command as CommandPrimitive } from "cmdk";
4
+ import { Command as CommandPrimitive, defaultFilter } from "cmdk";
5
5
 
6
6
  import { cn } from "@/lib/utils";
7
7
  import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@@ -167,6 +167,17 @@ function CommandShortcut({ className, ...props }: React.ComponentProps<"span">)
167
167
  );
168
168
  }
169
169
 
170
+ /**
171
+ * A `filter` for a `<Command>` whose items carry a unique `value` (an id) and
172
+ * their searchable text in `keywords`. cmdk's default filter scores the value
173
+ * AND the keywords, so an id value would make its own characters searchable;
174
+ * this one scores the keywords alone, and falls back to the value only for an
175
+ * item that has no keywords. Usage: `<Command filter={commandFilterByKeywords}>`
176
+ * with `<CommandItem value={id} keywords={[label]}>`.
177
+ */
178
+ const commandFilterByKeywords = (value: string, search: string, keywords?: string[]) =>
179
+ defaultFilter(keywords && keywords.length > 0 ? keywords.join(" ") : value, search);
180
+
170
181
  export {
171
182
  Command,
172
183
  CommandDialog,
@@ -177,4 +188,5 @@ export {
177
188
  CommandItem,
178
189
  CommandShortcut,
179
190
  CommandSeparator,
191
+ commandFilterByKeywords,
180
192
  };