@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.
- package/dist/{AssistantMessageInterface-CuaMDs2i.d.ts → AssistantMessageInterface-1Kbz8RVs.d.ts} +34 -1
- package/dist/{AssistantMessageInterface-Du3ShThp.d.mts → AssistantMessageInterface-CoxJyuFM.d.mts} +34 -1
- package/dist/{BlockNoteEditor-BUSOUPQ7.js → BlockNoteEditor-7I7PL3KS.js} +19 -19
- package/dist/{BlockNoteEditor-BUSOUPQ7.js.map → BlockNoteEditor-7I7PL3KS.js.map} +1 -1
- package/dist/{BlockNoteEditor-W42IQTGL.mjs → BlockNoteEditor-BNS3U3CP.mjs} +4 -4
- package/dist/{BlockNoteViewer-FQ23TZ3J.mjs → BlockNoteViewer-THOJS3OG.mjs} +2 -2
- package/dist/{BlockNoteViewer-MCX53AR4.js → BlockNoteViewer-UCWWHBTZ.js} +3 -3
- package/dist/{BlockNoteViewer-MCX53AR4.js.map → BlockNoteViewer-UCWWHBTZ.js.map} +1 -1
- package/dist/billing/index.d.mts +57 -2
- package/dist/billing/index.d.ts +57 -2
- package/dist/billing/index.js +406 -401
- package/dist/billing/index.js.map +1 -1
- package/dist/billing/index.mjs +8 -3
- package/dist/{chunk-GFLEFIAW.mjs → chunk-6R45U3GH.mjs} +1401 -1178
- package/dist/chunk-6R45U3GH.mjs.map +1 -0
- package/dist/{chunk-FXWO5J2R.js → chunk-7EFTSFZO.js} +7 -7
- package/dist/{chunk-FXWO5J2R.js.map → chunk-7EFTSFZO.js.map} +1 -1
- package/dist/{chunk-VR65NRE4.js → chunk-7XD2BJN6.js} +1338 -1115
- package/dist/chunk-7XD2BJN6.js.map +1 -0
- package/dist/{chunk-YCHPWXHD.js → chunk-WRMTBKSN.js} +44 -40
- package/dist/chunk-WRMTBKSN.js.map +1 -0
- package/dist/{chunk-JZUBFAAY.mjs → chunk-Z7KPVKWV.mjs} +2 -2
- package/dist/{chunk-O5MKEU7G.mjs → chunk-ZE5ZUY6Y.mjs} +44 -40
- package/dist/chunk-ZE5ZUY6Y.mjs.map +1 -0
- package/dist/client/index.d.mts +13 -2
- package/dist/client/index.d.ts +13 -2
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.d.mts +87 -28
- package/dist/components/index.d.ts +87 -28
- package/dist/components/index.js +8 -4
- package/dist/components/index.js.map +1 -1
- package/dist/components/index.mjs +7 -3
- package/dist/contexts/index.d.mts +11 -2
- package/dist/contexts/index.d.ts +11 -2
- package/dist/contexts/index.js +4 -4
- package/dist/contexts/index.mjs +3 -3
- package/dist/core/index.d.mts +29 -7
- package/dist/core/index.d.ts +29 -7
- package/dist/core/index.js +2 -2
- package/dist/core/index.mjs +1 -1
- package/dist/features/help/index.js +37 -37
- package/dist/features/help/index.mjs +3 -3
- package/dist/features/tokenusage/index.js +101 -101
- package/dist/features/tokenusage/index.mjs +3 -3
- package/dist/index.d.mts +3 -4
- package/dist/index.d.ts +3 -4
- package/dist/index.js +3 -3
- package/dist/index.mjs +2 -2
- package/dist/{oauth.interface-CD90ycYz.d.ts → oauth.interface-BZKZAPRw.d.ts} +8 -1
- package/dist/{oauth.interface-DRE3NAKc.d.mts → oauth.interface-nVwnzxJw.d.mts} +8 -1
- package/dist/{s3.service-DJHqEZAp.d.ts → s3.service-Bqr-fQmi.d.ts} +1 -0
- package/dist/{s3.service-DaRFLnrX.d.mts → s3.service-Buiy1ncj.d.mts} +1 -0
- package/dist/server/index.d.mts +1 -1
- package/dist/server/index.d.ts +1 -1
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/package.json +1 -1
- package/src/components/forms/__tests__/EditorSheet.test.tsx +5 -3
- package/src/core/utils/__tests__/translateResponse.test.ts +3 -1
- package/src/features/assistant/components/containers/AssistantContainer.tsx +105 -36
- package/src/features/assistant/components/containers/__tests__/AssistantContainer.spec.tsx +163 -1
- package/src/features/assistant/components/parts/AssistantComposer.tsx +38 -15
- package/src/features/assistant/components/parts/AssistantEmptyState.tsx +5 -3
- package/src/features/assistant/components/parts/AssistantSidebar.tsx +26 -7
- package/src/features/assistant/components/parts/__tests__/AssistantComposer.spec.tsx +8 -0
- package/src/features/assistant/components/parts/__tests__/AssistantSidebar.spec.tsx +7 -0
- package/src/features/assistant/contexts/AssistantContext.tsx +91 -7
- package/src/features/assistant/contexts/__tests__/AssistantContext.spec.tsx +196 -0
- package/src/features/assistant-action/components/AssistantContainerWithApprovals.tsx +23 -1
- package/src/features/assistant-message/components/EntityMentionLink.tsx +28 -0
- package/src/features/assistant-message/components/__tests__/EntityMentionLink.spec.tsx +58 -0
- package/src/features/assistant-message/components/index.ts +1 -0
- package/src/features/assistant-message/components/parts/tabs/ContentsTab.tsx +1 -1
- package/src/features/assistant-message/components/parts/tabs/ReferencesTab.tsx +2 -2
- package/src/features/auth/components/forms/__tests__/Logout.spec.tsx +3 -2
- package/src/features/billing/stripe-customer/data/stripe-customer.service.ts +5 -0
- package/src/features/billing/stripe-subscription/components/index.ts +1 -0
- package/src/features/notification/contexts/NotificationContext.tsx +5 -2
- package/src/features/notification/contexts/__tests__/NotificationContext.spec.tsx +32 -0
- package/src/features/notification/data/notification.service.ts +6 -1
- package/src/features/oauth/components/OAuthClientForm.tsx +21 -4
- package/src/features/oauth/components/__tests__/OAuthClientForm.spec.tsx +63 -0
- package/src/features/oauth/components/consent/OAuthCompanySelector.tsx +54 -0
- package/src/features/oauth/components/consent/OAuthConsentActions.tsx +5 -2
- package/src/features/oauth/components/consent/OAuthConsentHeader.tsx +10 -6
- package/src/features/oauth/components/consent/OAuthConsentScreen.tsx +33 -13
- package/src/features/oauth/components/consent/OAuthScopeList.tsx +4 -1
- package/src/features/oauth/components/consent/__tests__/OAuthConsentHeader.spec.tsx +25 -0
- package/src/features/oauth/components/consent/__tests__/OAuthConsentScreen.spec.tsx +126 -0
- package/src/features/oauth/components/consent/index.ts +1 -0
- package/src/features/oauth/data/__tests__/oauth.service.spec.ts +103 -0
- package/src/features/oauth/data/oauth.service.ts +3 -13
- package/src/features/oauth/data/oauth.ts +30 -1
- package/src/features/oauth/hooks/useOAuthConsent.ts +34 -3
- package/src/features/oauth/interfaces/oauth.interface.ts +11 -25
- package/src/hooks/__tests__/useDataListRetriever.test.ts +1 -1
- package/dist/AssistantInterface-RvZzlcA_.d.ts +0 -36
- package/dist/AssistantInterface-jL56dyNc.d.mts +0 -36
- package/dist/chunk-GFLEFIAW.mjs.map +0 -1
- package/dist/chunk-O5MKEU7G.mjs.map +0 -1
- package/dist/chunk-VR65NRE4.js.map +0 -1
- package/dist/chunk-YCHPWXHD.js.map +0 -1
- /package/dist/{BlockNoteEditor-W42IQTGL.mjs.map → BlockNoteEditor-BNS3U3CP.mjs.map} +0 -0
- /package/dist/{BlockNoteViewer-FQ23TZ3J.mjs.map → BlockNoteViewer-THOJS3OG.mjs.map} +0 -0
- /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,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"
|
|
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
|
|
|
@@ -95,7 +95,10 @@ export const NotificationContextProvider = ({ children }: NotificationContextPro
|
|
|
95
95
|
setError(null);
|
|
96
96
|
|
|
97
97
|
try {
|
|
98
|
-
|
|
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: {
|
|
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 {
|
|
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({
|
|
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
|
|
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
|
|
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
|
+
});
|