@carlonicora/nextjs-jsonapi 3.23.0 → 3.24.1

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 (106) hide show
  1. package/dist/{AssistantMessageInterface-CuaMDs2i.d.ts → AssistantMessageInterface-1Kbz8RVs.d.ts} +34 -1
  2. package/dist/{AssistantMessageInterface-Du3ShThp.d.mts → AssistantMessageInterface-CoxJyuFM.d.mts} +34 -1
  3. package/dist/{BlockNoteEditor-BUSOUPQ7.js → BlockNoteEditor-7I7PL3KS.js} +19 -19
  4. package/dist/{BlockNoteEditor-BUSOUPQ7.js.map → BlockNoteEditor-7I7PL3KS.js.map} +1 -1
  5. package/dist/{BlockNoteEditor-W42IQTGL.mjs → BlockNoteEditor-BNS3U3CP.mjs} +4 -4
  6. package/dist/{BlockNoteViewer-FQ23TZ3J.mjs → BlockNoteViewer-THOJS3OG.mjs} +2 -2
  7. package/dist/{BlockNoteViewer-MCX53AR4.js → BlockNoteViewer-UCWWHBTZ.js} +3 -3
  8. package/dist/{BlockNoteViewer-MCX53AR4.js.map → BlockNoteViewer-UCWWHBTZ.js.map} +1 -1
  9. package/dist/billing/index.d.mts +57 -2
  10. package/dist/billing/index.d.ts +57 -2
  11. package/dist/billing/index.js +406 -401
  12. package/dist/billing/index.js.map +1 -1
  13. package/dist/billing/index.mjs +8 -3
  14. package/dist/{chunk-GFLEFIAW.mjs → chunk-6R45U3GH.mjs} +1401 -1178
  15. package/dist/chunk-6R45U3GH.mjs.map +1 -0
  16. package/dist/{chunk-FXWO5J2R.js → chunk-7EFTSFZO.js} +7 -7
  17. package/dist/{chunk-FXWO5J2R.js.map → chunk-7EFTSFZO.js.map} +1 -1
  18. package/dist/{chunk-VR65NRE4.js → chunk-7XD2BJN6.js} +1338 -1115
  19. package/dist/chunk-7XD2BJN6.js.map +1 -0
  20. package/dist/{chunk-YCHPWXHD.js → chunk-WRMTBKSN.js} +44 -40
  21. package/dist/chunk-WRMTBKSN.js.map +1 -0
  22. package/dist/{chunk-JZUBFAAY.mjs → chunk-Z7KPVKWV.mjs} +2 -2
  23. package/dist/{chunk-O5MKEU7G.mjs → chunk-ZE5ZUY6Y.mjs} +44 -40
  24. package/dist/chunk-ZE5ZUY6Y.mjs.map +1 -0
  25. package/dist/client/index.d.mts +13 -2
  26. package/dist/client/index.d.ts +13 -2
  27. package/dist/client/index.js +4 -4
  28. package/dist/client/index.mjs +3 -3
  29. package/dist/components/index.d.mts +87 -28
  30. package/dist/components/index.d.ts +87 -28
  31. package/dist/components/index.js +8 -4
  32. package/dist/components/index.js.map +1 -1
  33. package/dist/components/index.mjs +7 -3
  34. package/dist/contexts/index.d.mts +11 -2
  35. package/dist/contexts/index.d.ts +11 -2
  36. package/dist/contexts/index.js +4 -4
  37. package/dist/contexts/index.mjs +3 -3
  38. package/dist/core/index.d.mts +29 -7
  39. package/dist/core/index.d.ts +29 -7
  40. package/dist/core/index.js +2 -2
  41. package/dist/core/index.mjs +1 -1
  42. package/dist/features/help/index.js +37 -37
  43. package/dist/features/help/index.mjs +3 -3
  44. package/dist/features/tokenusage/index.js +101 -101
  45. package/dist/features/tokenusage/index.mjs +3 -3
  46. package/dist/index.d.mts +3 -4
  47. package/dist/index.d.ts +3 -4
  48. package/dist/index.js +3 -3
  49. package/dist/index.mjs +2 -2
  50. package/dist/{oauth.interface-CD90ycYz.d.ts → oauth.interface-BZKZAPRw.d.ts} +8 -1
  51. package/dist/{oauth.interface-DRE3NAKc.d.mts → oauth.interface-nVwnzxJw.d.mts} +8 -1
  52. package/dist/{s3.service-DJHqEZAp.d.ts → s3.service-Bqr-fQmi.d.ts} +1 -0
  53. package/dist/{s3.service-DaRFLnrX.d.mts → s3.service-Buiy1ncj.d.mts} +1 -0
  54. package/dist/server/index.d.mts +1 -1
  55. package/dist/server/index.d.ts +1 -1
  56. package/dist/server/index.js +3 -3
  57. package/dist/server/index.mjs +1 -1
  58. package/package.json +1 -1
  59. package/src/components/forms/__tests__/EditorSheet.test.tsx +5 -3
  60. package/src/core/utils/__tests__/translateResponse.test.ts +3 -1
  61. package/src/features/assistant/components/containers/AssistantContainer.tsx +105 -36
  62. package/src/features/assistant/components/containers/__tests__/AssistantContainer.spec.tsx +163 -1
  63. package/src/features/assistant/components/parts/AssistantComposer.tsx +38 -15
  64. package/src/features/assistant/components/parts/AssistantEmptyState.tsx +5 -3
  65. package/src/features/assistant/components/parts/AssistantSidebar.tsx +26 -7
  66. package/src/features/assistant/components/parts/__tests__/AssistantComposer.spec.tsx +8 -0
  67. package/src/features/assistant/components/parts/__tests__/AssistantSidebar.spec.tsx +7 -0
  68. package/src/features/assistant/contexts/AssistantContext.tsx +91 -7
  69. package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +196 -0
  70. package/src/features/assistant-action/components/AssistantContainerWithApprovals.tsx +23 -1
  71. package/src/features/assistant-message/components/EntityMentionLink.tsx +28 -0
  72. package/src/features/assistant-message/components/__tests__/EntityMentionLink.spec.tsx +58 -0
  73. package/src/features/assistant-message/components/index.ts +1 -0
  74. package/src/features/assistant-message/components/parts/tabs/ContentsTab.tsx +1 -1
  75. package/src/features/assistant-message/components/parts/tabs/ReferencesTab.tsx +2 -2
  76. package/src/features/auth/components/forms/__tests__/Logout.spec.tsx +3 -2
  77. package/src/features/billing/stripe-customer/data/stripe-customer.service.ts +5 -0
  78. package/src/features/billing/stripe-subscription/components/index.ts +1 -0
  79. package/src/features/notification/contexts/NotificationContext.tsx +5 -2
  80. package/src/features/notification/contexts/__tests__/NotificationContext.spec.tsx +32 -0
  81. package/src/features/notification/data/notification.service.ts +6 -1
  82. package/src/features/oauth/components/OAuthClientForm.tsx +21 -4
  83. package/src/features/oauth/components/__tests__/OAuthClientForm.spec.tsx +63 -0
  84. package/src/features/oauth/components/consent/OAuthCompanySelector.tsx +54 -0
  85. package/src/features/oauth/components/consent/OAuthConsentActions.tsx +5 -2
  86. package/src/features/oauth/components/consent/OAuthConsentHeader.tsx +10 -6
  87. package/src/features/oauth/components/consent/OAuthConsentScreen.tsx +33 -13
  88. package/src/features/oauth/components/consent/OAuthScopeList.tsx +4 -1
  89. package/src/features/oauth/components/consent/__tests__/OAuthConsentHeader.spec.tsx +25 -0
  90. package/src/features/oauth/components/consent/__tests__/OAuthConsentScreen.spec.tsx +126 -0
  91. package/src/features/oauth/components/consent/index.ts +1 -0
  92. package/src/features/oauth/data/__tests__/oauth.service.spec.ts +103 -0
  93. package/src/features/oauth/data/oauth.service.ts +3 -13
  94. package/src/features/oauth/data/oauth.ts +30 -1
  95. package/src/features/oauth/hooks/useOAuthConsent.ts +34 -3
  96. package/src/features/oauth/interfaces/oauth.interface.ts +11 -25
  97. package/src/hooks/__tests__/useDataListRetriever.test.ts +1 -1
  98. package/dist/AssistantInterface-RvZzlcA_.d.ts +0 -36
  99. package/dist/AssistantInterface-jL56dyNc.d.mts +0 -36
  100. package/dist/chunk-GFLEFIAW.mjs.map +0 -1
  101. package/dist/chunk-O5MKEU7G.mjs.map +0 -1
  102. package/dist/chunk-VR65NRE4.js.map +0 -1
  103. package/dist/chunk-YCHPWXHD.js.map +0 -1
  104. /package/dist/{BlockNoteEditor-W42IQTGL.mjs.map → BlockNoteEditor-BNS3U3CP.mjs.map} +0 -0
  105. /package/dist/{BlockNoteViewer-FQ23TZ3J.mjs.map → BlockNoteViewer-THOJS3OG.mjs.map} +0 -0
  106. /package/dist/{chunk-JZUBFAAY.mjs.map → chunk-Z7KPVKWV.mjs.map} +0 -0
@@ -4,6 +4,8 @@ import { AssistantProvider, useAssistantContext } from "../AssistantContext";
4
4
  import { AssistantService } from "../../data/AssistantService";
5
5
  import { AssistantMessageService } from "../../../assistant-message/data/AssistantMessageService";
6
6
  import { useSocketContext } from "../../../../contexts/SocketContext";
7
+ import { useSharedContext } from "../../../../contexts";
8
+ import { render, screen, fireEvent } from "@testing-library/react";
7
9
  import type { JsonApiHydratedDataInterface } from "../../../../core";
8
10
  import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
9
11
  import { DataClassRegistry } from "../../../../core/registry/DataClassRegistry";
@@ -636,4 +638,198 @@ describe("AssistantContext", () => {
636
638
 
637
639
  replaceState.mockRestore();
638
640
  });
641
+
642
+ it("setScope binds the first message's thread", async () => {
643
+ const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
644
+ const create = vi.fn().mockResolvedValue(buildAssistantStub({ id: "a-scoped" }));
645
+ AssistantService.create = create;
646
+ AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]);
647
+
648
+ const { result } = renderHook(() => useAssistantContext(), {
649
+ wrapper: ({ children }) => <AssistantProvider>{children}</AssistantProvider>,
650
+ });
651
+ expect(result.current.scope).toBeUndefined();
652
+
653
+ act(() => {
654
+ result.current.setScope({ type: "proceedings", id: "p1" });
655
+ });
656
+ expect(result.current.scope).toEqual({ type: "proceedings", id: "p1" });
657
+
658
+ await act(async () => {
659
+ await result.current.sendMessage("hello");
660
+ });
661
+
662
+ expect(create).toHaveBeenCalledWith(expect.objectContaining({ boundContent: { type: "proceedings", id: "p1" } }));
663
+
664
+ // Once a thread exists its scope is fixed.
665
+ act(() => {
666
+ result.current.setScope({ type: "proceedings", id: "p2" });
667
+ });
668
+ expect(result.current.scope).toEqual({ type: "proceedings", id: "p1" });
669
+ replaceState.mockRestore();
670
+ });
671
+
672
+ it("the provider scope prop still filters the thread list", async () => {
673
+ const findMany = vi.fn().mockResolvedValue([]);
674
+ AssistantService.findMany = findMany;
675
+
676
+ const scope = { type: "campaigns", id: "camp-1" };
677
+ const { result } = renderHook(() => useAssistantContext(), {
678
+ wrapper: ({ children }) => <AssistantProvider scope={scope}>{children}</AssistantProvider>,
679
+ });
680
+
681
+ await waitFor(() => expect(findMany).toHaveBeenCalledWith({ boundType: "campaigns", boundId: "camp-1" }));
682
+ expect(result.current.scope).toEqual(scope);
683
+
684
+ act(() => {
685
+ result.current.setScope({ type: "proceedings", id: "p1" });
686
+ });
687
+
688
+ expect(findMany).not.toHaveBeenCalledWith({ boundType: "proceedings", boundId: "p1" });
689
+ expect(findMany).toHaveBeenLastCalledWith({ boundType: "campaigns", boundId: "camp-1" });
690
+ });
691
+
692
+ it("page heading: thread title as element and a new-thread button that resets the thread", async () => {
693
+ const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
694
+ const existing = buildAssistantDehydrated({ id: "a-h", title: "A thread title longer than 25 chars" });
695
+ let ctx: ReturnType<typeof useAssistantContext> | undefined;
696
+ function Probe() {
697
+ ctx = useAssistantContext();
698
+ const { title } = useSharedContext();
699
+ return (
700
+ <div>
701
+ <span data-testid="element">{title.element}</span>
702
+ {title.functions}
703
+ </div>
704
+ );
705
+ }
706
+ render(
707
+ <AssistantProvider dehydratedAssistant={existing}>
708
+ <Probe />
709
+ </AssistantProvider>,
710
+ );
711
+ expect(screen.getByTestId("element")).toHaveTextContent("A thread title longer tha...");
712
+ fireEvent.click(screen.getByTestId("assistant-new-button"));
713
+ await waitFor(() => expect(ctx?.assistant).toBeUndefined());
714
+ expect(screen.getByTestId("element")).toBeEmptyDOMElement();
715
+ expect(replaceState).toHaveBeenCalledWith(null, "", "/assistants");
716
+ });
717
+
718
+ describe("assistant:created", () => {
719
+ function fakeSocket() {
720
+ const handlers: Record<string, (payload: any) => void> = {};
721
+ const socket = {
722
+ on: vi.fn((evt: string, h: any) => {
723
+ handlers[evt] = h;
724
+ }),
725
+ off: vi.fn((evt: string) => {
726
+ delete handlers[evt];
727
+ }),
728
+ };
729
+ vi.mocked(useSocketContext).mockReturnValue({ socket, isConnected: true } as any);
730
+ return { socket, handlers };
731
+ }
732
+
733
+ function createdEvent(id: string, title: string) {
734
+ return {
735
+ assistant: {
736
+ data: { type: "assistants", id, attributes: { title, messageCount: 0 } },
737
+ included: [],
738
+ },
739
+ };
740
+ }
741
+
742
+ it("names the thread, lists it and swaps the URL before the create request returns", async () => {
743
+ const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
744
+ const { socket, handlers } = fakeSocket();
745
+ let resolveCreate!: (value: unknown) => void;
746
+ AssistantService.create = vi.fn().mockImplementation(() => new Promise((resolve) => (resolveCreate = resolve)));
747
+ AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([buildMessageStub({ role: "user" })]);
748
+
749
+ const { result } = renderHook(() => useAssistantContext(), {
750
+ wrapper: ({ children }) => <AssistantProvider>{children}</AssistantProvider>,
751
+ });
752
+ await waitFor(() => expect(result.current.threadsLoading).toBe(false));
753
+
754
+ let sendPromise: Promise<void>;
755
+ await act(async () => {
756
+ sendPromise = result.current.sendMessage("first question");
757
+ await Promise.resolve();
758
+ });
759
+ expect(socket.on).toHaveBeenCalledWith("assistant:created", expect.any(Function));
760
+
761
+ await act(async () => {
762
+ handlers["assistant:created"]({ assistant: { data: null } });
763
+ handlers["assistant:created"](createdEvent("a-new", "Named thread"));
764
+ handlers["assistant:created"](createdEvent("a-other", "Second event"));
765
+ });
766
+
767
+ expect(result.current.assistant?.id).toBe("a-new");
768
+ expect(result.current.assistant?.title).toBe("Named thread");
769
+ expect(result.current.threads.map((t) => t.id)).toEqual(["a-new"]);
770
+ expect(replaceState).toHaveBeenCalledWith(null, "", "/assistants/a-new");
771
+ expect(result.current.sending).toBe(true);
772
+ expect(result.current.messages.map((m) => m.content)).toEqual(["first question"]);
773
+
774
+ await act(async () => {
775
+ resolveCreate(buildAssistantStub({ id: "a-new", title: "Named thread" }));
776
+ await sendPromise!;
777
+ });
778
+
779
+ expect(result.current.threads.filter((t) => t.id === "a-new")).toHaveLength(1);
780
+ expect(result.current.threads).toHaveLength(1);
781
+ expect(result.current.assistant?.id).toBe("a-new");
782
+ expect(socket.off).toHaveBeenCalledWith("assistant:created", expect.any(Function));
783
+ replaceState.mockRestore();
784
+ });
785
+
786
+ it("does not touch the URL from the event when manageUrl is false", async () => {
787
+ const replaceState = vi.spyOn(window.history, "replaceState").mockImplementation(() => {});
788
+ const { handlers } = fakeSocket();
789
+ let resolveCreate!: (value: unknown) => void;
790
+ AssistantService.create = vi.fn().mockImplementation(() => new Promise((resolve) => (resolveCreate = resolve)));
791
+ AssistantMessageService.findByAssistant = vi.fn().mockResolvedValue([]);
792
+
793
+ const { result } = renderHook(() => useAssistantContext(), {
794
+ wrapper: ({ children }) => <AssistantProvider manageUrl={false}>{children}</AssistantProvider>,
795
+ });
796
+ await waitFor(() => expect(result.current.threadsLoading).toBe(false));
797
+
798
+ let sendPromise: Promise<void>;
799
+ await act(async () => {
800
+ sendPromise = result.current.sendMessage("hello");
801
+ await Promise.resolve();
802
+ });
803
+ await act(async () => {
804
+ handlers["assistant:created"](createdEvent("a-sheet", "Sheet thread"));
805
+ });
806
+
807
+ expect(result.current.assistant?.id).toBe("a-sheet");
808
+ expect(result.current.threads.map((t) => t.id)).toEqual(["a-sheet"]);
809
+ expect(replaceState).not.toHaveBeenCalled();
810
+
811
+ await act(async () => {
812
+ resolveCreate(buildAssistantStub({ id: "a-sheet", title: "Sheet thread" }));
813
+ await sendPromise!;
814
+ });
815
+ expect(replaceState).not.toHaveBeenCalled();
816
+ replaceState.mockRestore();
817
+ });
818
+
819
+ it("does not subscribe to assistant:created when appending to an existing thread", async () => {
820
+ const { socket } = fakeSocket();
821
+ AssistantService.appendMessage = vi.fn().mockResolvedValue([]);
822
+ const { result } = renderHook(() => useAssistantContext(), {
823
+ wrapper: ({ children }) => (
824
+ <AssistantProvider dehydratedAssistant={buildAssistantDehydrated({ id: "a-ex" })}>
825
+ {children}
826
+ </AssistantProvider>
827
+ ),
828
+ });
829
+ await act(async () => {
830
+ await result.current.sendMessage("follow-up");
831
+ });
832
+ expect(socket.on).not.toHaveBeenCalledWith("assistant:created", expect.any(Function));
833
+ });
834
+ });
639
835
  });
@@ -1,21 +1,43 @@
1
1
  "use client";
2
2
 
3
+ import type { ReactNode } from "react";
4
+ import type { MentionRenderer } from "../../assistant-message/components/MessageItem";
3
5
  import type { AssistantMessageInterface } from "../../assistant-message/data/AssistantMessageInterface";
4
6
  import { AssistantContainer } from "../../assistant/components/containers/AssistantContainer";
7
+ import type { AssistantInterface } from "../../assistant/data/AssistantInterface";
5
8
  import { useAssistantContext } from "../../assistant/contexts/AssistantContext";
6
9
  import { ApprovalActionCard } from "./ApprovalActionCard";
7
10
 
11
+ interface Props {
12
+ renderMention?: MentionRenderer;
13
+ embedded?: boolean;
14
+ renderScopePicker?: () => ReactNode;
15
+ renderThreadBadge?: (thread: AssistantInterface) => ReactNode;
16
+ showOperatorToggle?: boolean;
17
+ }
18
+
8
19
  /**
9
20
  * AssistantContainer with the operator approval card wired in. The container
10
21
  * renders `approval-request` messages through the `renderApprovalAction` slot,
11
22
  * which stays a slot so the plain chat container has no dependency on the
12
23
  * AssistantAction module. Must be rendered inside an `AssistantProvider`.
13
24
  */
14
- export function AssistantContainerWithApprovals() {
25
+ export function AssistantContainerWithApprovals({
26
+ renderMention,
27
+ embedded,
28
+ renderScopePicker,
29
+ renderThreadBadge,
30
+ showOperatorToggle,
31
+ }: Props = {}) {
15
32
  const ctx = useAssistantContext();
16
33
 
17
34
  return (
18
35
  <AssistantContainer
36
+ renderMention={renderMention}
37
+ embedded={embedded}
38
+ renderScopePicker={renderScopePicker}
39
+ renderThreadBadge={renderThreadBadge}
40
+ showOperatorToggle={showOperatorToggle}
19
41
  renderApprovalAction={(message: AssistantMessageInterface) =>
20
42
  message.actionId ? (
21
43
  <ApprovalActionCard
@@ -0,0 +1,28 @@
1
+ "use client";
2
+
3
+ import { ModuleRegistry } from "../../../core/registry/ModuleRegistry";
4
+ import { usePageUrlGenerator } from "../../../hooks";
5
+ import { Link } from "../../../shadcnui";
6
+ import type { MentionRenderer } from "./MessageItem";
7
+
8
+ function EntityMentionLinkView({ type, id, alias }: { type: string; id: string; alias: string }) {
9
+ const generate = usePageUrlGenerator();
10
+
11
+ let module;
12
+ try {
13
+ module = ModuleRegistry.findByName(type);
14
+ } catch {
15
+ return <>{alias}</>;
16
+ }
17
+ if (!module?.pageUrl) return <>{alias}</>;
18
+
19
+ return <Link href={generate({ page: module, id })}>{alias}</Link>;
20
+ }
21
+
22
+ /**
23
+ * Default mention renderer for assistant answers: a `mention://<type>/<id>`
24
+ * link becomes a link to the entity's page when `<type>` is a registered
25
+ * module with a `pageUrl`, and plain text otherwise. Rendered as a component
26
+ * so the URL hook runs inside React's component tree.
27
+ */
28
+ export const EntityMentionLink: MentionRenderer = (p) => <EntityMentionLinkView {...p} />;
@@ -0,0 +1,58 @@
1
+ import { describe, it, expect, vi, beforeAll } from "vitest";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { ModuleRegistry } from "../../../../core/registry/ModuleRegistry";
4
+ import { configureI18n } from "../../../../i18n";
5
+ import { EntityMentionLink } from "../EntityMentionLink";
6
+
7
+ beforeAll(() => {
8
+ // The package Link resolves its inner component at runtime and throws if i18n
9
+ // was never configured (src/i18n/config.ts).
10
+ configureI18n({
11
+ useRouter: () => ({
12
+ push: vi.fn(),
13
+ replace: vi.fn(),
14
+ back: vi.fn(),
15
+ forward: vi.fn(),
16
+ refresh: vi.fn(),
17
+ prefetch: vi.fn(),
18
+ }),
19
+ useTranslations: () => (key: string) => key,
20
+ usePathname: () => "/",
21
+ Link: ({
22
+ href,
23
+ children,
24
+ prefetch: _prefetch,
25
+ ...rest
26
+ }: {
27
+ href: string;
28
+ children: React.ReactNode;
29
+ [key: string]: any;
30
+ }) => (
31
+ <a href={href} {...rest}>
32
+ {children}
33
+ </a>
34
+ ),
35
+ });
36
+ ModuleRegistry.register("Account" as any, { name: "accounts", pageUrl: "/accounts" } as any);
37
+ });
38
+
39
+ describe("EntityMentionLink", () => {
40
+ it("renders a link to the entity page for a registered module", () => {
41
+ render(<>{EntityMentionLink({ type: "accounts", id: "a1", alias: "Rossi" })}</>);
42
+ const link = screen.getByRole("link", { name: "Rossi" });
43
+ expect(link).toHaveAttribute("href", "/accounts/a1");
44
+ expect(link).toHaveTextContent("Rossi");
45
+ });
46
+
47
+ it("renders through the package Link (locale-aware, link typography role)", () => {
48
+ render(<>{EntityMentionLink({ type: "accounts", id: "a1", alias: "Rossi" })}</>);
49
+ const link = screen.getByRole("link", { name: "Rossi" });
50
+ expect(link.className).toContain("text-primary font-medium");
51
+ });
52
+
53
+ it("renders plain text with no link for an unknown type", () => {
54
+ render(<>{EntityMentionLink({ type: "unknown-things", id: "x1", alias: "Mystery" })}</>);
55
+ expect(screen.getByText("Mystery")).toBeInTheDocument();
56
+ expect(screen.queryByRole("link")).not.toBeInTheDocument();
57
+ });
58
+ });
@@ -1,3 +1,4 @@
1
1
  export * from "./MessageItem";
2
2
  export * from "./MessageList";
3
3
  export { MessageSourcesPanel } from "./parts/MessageSourcesPanel";
4
+ export * from "./EntityMentionLink";
@@ -1,13 +1,13 @@
1
1
  "use client";
2
2
 
3
3
  import { useTranslations } from "next-intl";
4
- import Link from "next/link";
5
4
  import type { ApiDataInterface } from "../../../../../core";
6
5
  import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
7
6
  import { usePageUrlGenerator } from "../../../../../hooks";
8
7
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../../../shadcnui/ui/table";
9
8
  import type { ChunkInterface, ChunkRelationshipMeta } from "../../../../chunk/data/ChunkInterface";
10
9
  import { useEntityLabel } from "./useEntityLabel";
10
+ import { Link } from "../../../../../shadcnui/custom/link";
11
11
 
12
12
  interface Props {
13
13
  citations: (ChunkInterface & ChunkRelationshipMeta)[];
@@ -1,12 +1,12 @@
1
1
  "use client";
2
2
 
3
3
  import { useTranslations } from "next-intl";
4
- import Link from "next/link";
5
4
  import type { ApiDataInterface } from "../../../../../core";
6
5
  import { ModuleRegistry } from "../../../../../core/registry/ModuleRegistry";
7
6
  import { usePageUrlGenerator } from "../../../../../hooks";
8
7
  import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "../../../../../shadcnui/ui/table";
9
8
  import { useEntityLabel } from "./useEntityLabel";
9
+ import { Link } from "../../../../../shadcnui/custom/link";
10
10
 
11
11
  interface Props {
12
12
  references: ApiDataInterface[];
@@ -42,7 +42,7 @@ export function ReferencesTab({ references }: Props) {
42
42
  return (
43
43
  <TableRow key={`${ref.type}/${ref.id}`}>
44
44
  <TableCell>
45
- <Link href={href} target="_blank" rel="noopener noreferrer" className="font-medium">
45
+ <Link href={href} target="_blank" rel="noopener noreferrer">
46
46
  {ref.identifier}
47
47
  </Link>
48
48
  </TableCell>
@@ -37,7 +37,8 @@ describe("Logout", () => {
37
37
  // We need to delete the original location first
38
38
  // @ts-expect-error - deleting window.location for mocking purposes
39
39
  delete window.location;
40
- window.location = {
40
+ // The DOM lib types `window.location` as accessor-only; jsdom lets the test replace it.
41
+ (window as unknown as { location: Location }).location = {
41
42
  ...originalLocation,
42
43
  get href() {
43
44
  return windowLocationHref;
@@ -50,7 +51,7 @@ describe("Logout", () => {
50
51
 
51
52
  afterEach(() => {
52
53
  // Restore original location
53
- window.location = originalLocation;
54
+ (window as unknown as { location: Location }).location = originalLocation;
54
55
  });
55
56
 
56
57
  describe("Scenario: Clears storage when storageKeys provided", () => {
@@ -22,6 +22,11 @@ export class StripeCustomerService extends AbstractService {
22
22
  type: Modules.StripeCustomer,
23
23
  method: HttpMethod.GET,
24
24
  endpoint: endpoint.generate(),
25
+ // A 404 here means the company has no billing customer yet — an expected
26
+ // state that BillingDashboardContainer turns into a setup prompt. Without
27
+ // this, the global error handler swallows it and replaces the page with a
28
+ // 404 screen.
29
+ suppressGlobalError: true,
25
30
  });
26
31
  }
27
32
 
@@ -3,3 +3,4 @@ export * from "./details";
3
3
  export * from "./forms";
4
4
  export * from "./lists";
5
5
  export * from "./widgets";
6
+ export * from "./wizards";
@@ -95,7 +95,10 @@ export const NotificationContextProvider = ({ children }: NotificationContextPro
95
95
  setError(null);
96
96
 
97
97
  try {
98
- const fetchedNotifications = await NotificationService.findMany({});
98
+ // Background load: a failure must throw into the catch below. Routed
99
+ // through the global error handler, callApi resolves to undefined
100
+ // instead, which lands in state and crashes every consumer.
101
+ const fetchedNotifications = await NotificationService.findMany({ suppressGlobalError: true });
99
102
  setNotifications(fetchedNotifications);
100
103
  setLastLoaded(Date.now());
101
104
  } catch (error) {
@@ -146,7 +149,7 @@ export const NotificationContextProvider = ({ children }: NotificationContextPro
146
149
  };
147
150
 
148
151
  await NotificationService.markAsRead({ data: data });
149
- const allNotifications = await NotificationService.findMany({});
152
+ const allNotifications = await NotificationService.findMany({ suppressGlobalError: true });
150
153
  setNotifications(allNotifications);
151
154
  setLastLoaded(Date.now());
152
155
  } catch (error) {
@@ -94,3 +94,35 @@ describe("NotificationContextProvider mount load", () => {
94
94
  expect(NotificationService.findMany).toHaveBeenCalledTimes(1);
95
95
  });
96
96
  });
97
+
98
+ describe("NotificationContextProvider failed load", () => {
99
+ beforeEach(() => {
100
+ userMock.currentUser = undefined;
101
+ vi.mocked(NotificationService.findMany).mockReset();
102
+ });
103
+
104
+ it("asks the service to throw instead of routing to the global error handler", async () => {
105
+ vi.mocked(NotificationService.findMany).mockResolvedValue([] as never);
106
+
107
+ const { result } = renderHook(() => useNotificationContext(), { wrapper });
108
+
109
+ await act(async () => {
110
+ await result.current.loadNotifications();
111
+ });
112
+
113
+ expect(NotificationService.findMany).toHaveBeenCalledWith({ suppressGlobalError: true });
114
+ });
115
+
116
+ it("keeps notifications an array and records the error when the API is unreachable", async () => {
117
+ vi.mocked(NotificationService.findMany).mockRejectedValue(new Error("500:"));
118
+
119
+ const { result } = renderHook(() => useNotificationContext(), { wrapper });
120
+
121
+ await act(async () => {
122
+ await result.current.loadNotifications();
123
+ });
124
+
125
+ expect(result.current.notifications).toEqual([]);
126
+ expect(result.current.error).toBe("500:");
127
+ });
128
+ });
@@ -2,7 +2,11 @@ import { AbstractService, EndpointCreator, HttpMethod, Modules, NextRef } from "
2
2
  import { NotificationInterface } from "./notification.interface";
3
3
 
4
4
  export class NotificationService extends AbstractService {
5
- static async findMany(params: { isArchived?: boolean; next?: NextRef }): Promise<NotificationInterface[]> {
5
+ static async findMany(params: {
6
+ isArchived?: boolean;
7
+ next?: NextRef;
8
+ suppressGlobalError?: boolean;
9
+ }): Promise<NotificationInterface[]> {
6
10
  const endpoint = new EndpointCreator({ endpoint: Modules.Notification });
7
11
 
8
12
  if (params.isArchived) endpoint.addAdditionalParam("isArchived", "true");
@@ -12,6 +16,7 @@ export class NotificationService extends AbstractService {
12
16
  method: HttpMethod.GET,
13
17
  endpoint: endpoint.generate(),
14
18
  next: params.next,
19
+ suppressGlobalError: params.suppressGlobalError,
15
20
  });
16
21
  }
17
22
 
@@ -17,7 +17,12 @@ import {
17
17
  } from "../../../shadcnui";
18
18
  import { OAuthRedirectUriInput } from "./OAuthRedirectUriInput";
19
19
  import { OAuthScopeSelector } from "./OAuthScopeSelector";
20
- import { OAuthClientCreateRequest, OAuthClientInterface, DEFAULT_GRANT_TYPES } from "../interfaces/oauth.interface";
20
+ import {
21
+ OAuthClientCreateRequest,
22
+ OAuthClientInterface,
23
+ OAuthScopeInfo,
24
+ DEFAULT_GRANT_TYPES,
25
+ } from "../interfaces/oauth.interface";
21
26
 
22
27
  export interface OAuthClientFormProps {
23
28
  /** Existing client for edit mode (undefined = create mode) */
@@ -28,6 +33,10 @@ export interface OAuthClientFormProps {
28
33
  onCancel: () => void;
29
34
  /** Whether form is submitting */
30
35
  isLoading?: boolean;
36
+ /** Scopes the app offers in the selector (defaults to AVAILABLE_OAUTH_SCOPES) */
37
+ availableScopes?: OAuthScopeInfo[];
38
+ /** Scopes preselected in create mode (edit mode uses client.allowedScopes) */
39
+ defaultScopes?: string[];
31
40
  }
32
41
 
33
42
  interface FormState {
@@ -47,14 +56,21 @@ interface FormErrors {
47
56
  /**
48
57
  * Form for creating or editing an OAuth client
49
58
  */
50
- export function OAuthClientForm({ client, onSubmit, onCancel, isLoading = false }: OAuthClientFormProps) {
59
+ export function OAuthClientForm({
60
+ client,
61
+ onSubmit,
62
+ onCancel,
63
+ isLoading = false,
64
+ availableScopes,
65
+ defaultScopes = [],
66
+ }: OAuthClientFormProps) {
51
67
  const isEditMode = !!client;
52
68
 
53
69
  const [formState, setFormState] = useState<FormState>({
54
70
  name: client?.name || "",
55
71
  description: client?.description || "",
56
72
  redirectUris: client?.redirectUris?.length ? client.redirectUris : [""],
57
- allowedScopes: client?.allowedScopes || [],
73
+ allowedScopes: client ? client.allowedScopes || [] : defaultScopes,
58
74
  isConfidential: client?.isConfidential ?? true,
59
75
  });
60
76
 
@@ -117,7 +133,7 @@ export function OAuthClientForm({ client, onSubmit, onCancel, isLoading = false
117
133
  id="name"
118
134
  value={formState.name}
119
135
  onChange={(e) => setFormState((s) => ({ ...s, name: e.target.value }))}
120
- placeholder="My Lightroom Plugin"
136
+ placeholder="My Application"
121
137
  disabled={isLoading}
122
138
  className={errors.name ? "border-destructive" : ""}
123
139
  />
@@ -149,6 +165,7 @@ export function OAuthClientForm({ client, onSubmit, onCancel, isLoading = false
149
165
  <OAuthScopeSelector
150
166
  value={formState.allowedScopes}
151
167
  onChange={(scopes) => setFormState((s) => ({ ...s, allowedScopes: scopes }))}
168
+ availableScopes={availableScopes}
152
169
  error={errors.allowedScopes}
153
170
  disabled={isLoading}
154
171
  />
@@ -0,0 +1,63 @@
1
+ import { render, screen } from "@testing-library/react";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import { OAuthClientForm } from "../OAuthClientForm";
4
+ import { OAUTH_SCOPE_DISPLAY, OAuthClientInterface } from "../../interfaces/oauth.interface";
5
+
6
+ const scopeCheckbox = (name: string) => screen.getByRole("checkbox", { name: new RegExp(name) });
7
+
8
+ const makeClient = (allowedScopes: string[]): OAuthClientInterface =>
9
+ ({
10
+ name: "Existing app",
11
+ description: undefined,
12
+ redirectUris: ["https://example.com/callback"],
13
+ allowedScopes,
14
+ isConfidential: true,
15
+ }) as unknown as OAuthClientInterface;
16
+
17
+ describe("OAuthClientForm", () => {
18
+ it("renders only the scopes passed in availableScopes", () => {
19
+ render(<OAuthClientForm onSubmit={vi.fn()} onCancel={vi.fn()} availableScopes={[OAUTH_SCOPE_DISPLAY.mcp]} />);
20
+
21
+ expect(screen.getByText("MCP Server Access")).toBeTruthy();
22
+ expect(screen.queryByText("Read Access")).toBeNull();
23
+ expect(screen.queryByText("Write Access")).toBeNull();
24
+ });
25
+
26
+ it("preselects defaultScopes in create mode", () => {
27
+ render(
28
+ <OAuthClientForm
29
+ onSubmit={vi.fn()}
30
+ onCancel={vi.fn()}
31
+ availableScopes={[OAUTH_SCOPE_DISPLAY.read, OAUTH_SCOPE_DISPLAY.mcp]}
32
+ defaultScopes={["mcp"]}
33
+ />,
34
+ );
35
+
36
+ expect(scopeCheckbox("MCP Server Access").getAttribute("aria-checked")).toBe("true");
37
+ expect(scopeCheckbox("Read Access").getAttribute("aria-checked")).toBe("false");
38
+ });
39
+
40
+ it("uses client.allowedScopes instead of defaultScopes in edit mode", () => {
41
+ render(
42
+ <OAuthClientForm
43
+ client={makeClient(["read"])}
44
+ onSubmit={vi.fn()}
45
+ onCancel={vi.fn()}
46
+ availableScopes={[OAUTH_SCOPE_DISPLAY.read, OAUTH_SCOPE_DISPLAY.mcp]}
47
+ defaultScopes={["mcp"]}
48
+ />,
49
+ );
50
+
51
+ expect(scopeCheckbox("Read Access").getAttribute("aria-checked")).toBe("true");
52
+ expect(scopeCheckbox("MCP Server Access").getAttribute("aria-checked")).toBe("false");
53
+ });
54
+
55
+ it("offers the generic scope list by default, without Only35 scopes", () => {
56
+ render(<OAuthClientForm onSubmit={vi.fn()} onCancel={vi.fn()} />);
57
+
58
+ expect(screen.getByText("MCP Server Access")).toBeTruthy();
59
+ expect(screen.getByText("Read Access")).toBeTruthy();
60
+ expect(screen.queryByText("View Photographs")).toBeNull();
61
+ expect(screen.queryByText("View Rolls")).toBeNull();
62
+ });
63
+ });