@carlonicora/nextjs-jsonapi 3.9.0 → 3.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{BlockNoteEditor-WY4LFMXQ.js → BlockNoteEditor-4UAIVF3G.js} +19 -19
- package/dist/{BlockNoteEditor-WY4LFMXQ.js.map → BlockNoteEditor-4UAIVF3G.js.map} +1 -1
- package/dist/BlockNoteEditor-K55A4FS2.css +30 -0
- package/dist/BlockNoteEditor-K55A4FS2.css.map +1 -0
- package/dist/{BlockNoteEditor-RGMU2O24.mjs → BlockNoteEditor-PFCHUFTD.mjs} +4 -4
- package/dist/{BlockNoteViewer-6N2GQWTI.js → BlockNoteViewer-C2UUOBAF.js} +17 -3
- package/dist/BlockNoteViewer-C2UUOBAF.js.map +1 -0
- package/dist/{BlockNoteViewer-IBQRJ3WM.mjs → BlockNoteViewer-TYLVTIUD.mjs} +17 -3
- package/dist/BlockNoteViewer-TYLVTIUD.mjs.map +1 -0
- package/dist/billing/index.css +22 -21
- package/dist/billing/index.css.map +1 -1
- package/dist/billing/index.js +398 -398
- package/dist/billing/index.mjs +3 -3
- package/dist/{chunk-DOLEXUO3.js → chunk-46CQ3RRQ.js} +1271 -1049
- package/dist/chunk-46CQ3RRQ.js.map +1 -0
- package/dist/{chunk-UYLS3LJW.js → chunk-6J2SQZ3G.js} +11 -1
- package/dist/chunk-6J2SQZ3G.js.map +1 -0
- package/dist/{chunk-2D4MFVDV.mjs → chunk-IMSIVIIC.mjs} +2 -2
- package/dist/{chunk-HDZ2BL6D.mjs → chunk-M5QAHSCY.mjs} +11 -1
- package/dist/{chunk-HDZ2BL6D.mjs.map → chunk-M5QAHSCY.mjs.map} +1 -1
- package/dist/{chunk-TULJCLZA.js → chunk-V6CGCARD.js} +7 -7
- package/dist/{chunk-TULJCLZA.js.map → chunk-V6CGCARD.js.map} +1 -1
- package/dist/{chunk-7PNGCZES.mjs → chunk-XZJJ4SZW.mjs} +382 -160
- package/dist/chunk-XZJJ4SZW.mjs.map +1 -0
- package/dist/client/index.css +22 -21
- package/dist/client/index.css.map +1 -1
- package/dist/client/index.js +4 -4
- package/dist/client/index.mjs +3 -3
- package/dist/components/index.css +22 -21
- package/dist/components/index.css.map +1 -1
- package/dist/components/index.d.mts +44 -13
- package/dist/components/index.d.ts +44 -13
- 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.css +22 -21
- package/dist/contexts/index.css.map +1 -1
- package/dist/contexts/index.d.mts +11 -4
- package/dist/contexts/index.d.ts +11 -4
- package/dist/contexts/index.js +6 -4
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +5 -3
- package/dist/core/index.d.mts +13 -0
- package/dist/core/index.d.ts +13 -0
- package/dist/core/index.js +2 -2
- package/dist/core/index.mjs +1 -1
- package/dist/features/help/index.css +22 -21
- package/dist/features/help/index.css.map +1 -1
- package/dist/features/help/index.d.mts +6 -1
- package/dist/features/help/index.d.ts +6 -1
- package/dist/features/help/index.js +39 -38
- package/dist/features/help/index.js.map +1 -1
- package/dist/features/help/index.mjs +4 -3
- package/dist/features/tokenusage/index.css +22 -21
- package/dist/features/tokenusage/index.css.map +1 -1
- package/dist/features/tokenusage/index.js +102 -102
- package/dist/features/tokenusage/index.js.map +1 -1
- package/dist/features/tokenusage/index.mjs +4 -4
- package/dist/features/tokenusage/index.mjs.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.mjs +2 -2
- package/dist/{onboarding.interface-Djyl9qYu.d.mts → onboarding.interface-DVUOUC4y.d.mts} +4 -1
- package/dist/{onboarding.interface-Djyl9qYu.d.ts → onboarding.interface-DVUOUC4y.d.ts} +4 -1
- package/dist/server/index.js +3 -3
- package/dist/server/index.mjs +1 -1
- package/package.json +2 -2
- package/src/components/containers/RoundPageContainer.tsx +2 -1
- package/src/components/containers/RoundPageContainerTitle.tsx +6 -1
- package/src/components/containers/TabsContainer.tsx +1 -0
- package/src/components/forms/EditorSheet.tsx +2 -1
- package/src/components/grids/ContentListGrid.tsx +1 -1
- package/src/components/navigations/MobileNavigationBar.tsx +1 -0
- package/src/components/tables/ContentListTable.tsx +1 -1
- package/src/components/tables/ContentTableSearch.tsx +1 -0
- package/src/components/viewers/BlockNoteViewer.tsx +13 -2
- package/src/components/viewers/BlockNoteViewerContainer.tsx +4 -1
- package/src/features/assistant/components/index.ts +2 -0
- package/src/features/assistant/components/parts/AssistantComposer.tsx +1 -1
- package/src/features/assistant/components/parts/__tests__/AssistantComposer.spec.tsx +2 -2
- package/src/features/assistant-action/components/ApprovalActionCard.tsx +325 -4
- package/src/features/assistant-action/components/__tests__/ApprovalActionCard.spec.tsx +268 -0
- package/src/features/assistant-action/data/AssistantAction.ts +12 -0
- package/src/features/assistant-action/data/AssistantActionInterface.ts +9 -0
- package/src/features/assistant-action/data/__tests__/AssistantAction.spec.ts +11 -0
- package/src/features/help/index.ts +1 -0
- package/src/features/onboarding/components/OnboardingCard.tsx +1 -2
- package/src/features/onboarding/contexts/OnboardingContext.spec.tsx +191 -0
- package/src/features/onboarding/contexts/OnboardingContext.tsx +176 -125
- package/src/features/onboarding/interfaces/onboarding.interface.ts +4 -0
- package/src/features/onboarding/styles/onboarding.css +25 -30
- package/src/features/tokenusage/components/TokenUsageReportFilterBar.tsx +1 -1
- package/dist/BlockNoteEditor-YOAJRPWU.css +0 -29
- package/dist/BlockNoteEditor-YOAJRPWU.css.map +0 -1
- package/dist/BlockNoteViewer-6N2GQWTI.js.map +0 -1
- package/dist/BlockNoteViewer-IBQRJ3WM.mjs.map +0 -1
- package/dist/chunk-7PNGCZES.mjs.map +0 -1
- package/dist/chunk-DOLEXUO3.js.map +0 -1
- package/dist/chunk-UYLS3LJW.js.map +0 -1
- /package/dist/{BlockNoteEditor-RGMU2O24.mjs.map → BlockNoteEditor-PFCHUFTD.mjs.map} +0 -0
- /package/dist/{chunk-2D4MFVDV.mjs.map → chunk-IMSIVIIC.mjs.map} +0 -0
|
@@ -18,6 +18,15 @@ export type AssistantActionInput = {
|
|
|
18
18
|
export interface AssistantActionInterface extends ApiDataInterface {
|
|
19
19
|
get status(): AssistantActionStatus;
|
|
20
20
|
get toolName(): string;
|
|
21
|
+
/** Raw JSON string of the frozen tool call's arguments. Audit copy — never rendered. */
|
|
22
|
+
get toolArgs(): string;
|
|
23
|
+
/**
|
|
24
|
+
* JSON string of the operator's presentation payload: the record being
|
|
25
|
+
* written with every referenced id already resolved to a display name.
|
|
26
|
+
* Empty when the action predates the payload (the card then falls back to
|
|
27
|
+
* the attributes in `toolArgs`).
|
|
28
|
+
*/
|
|
29
|
+
get proposal(): string;
|
|
21
30
|
get summary(): string;
|
|
22
31
|
get resolvedAt(): Date | undefined;
|
|
23
32
|
get expiresAt(): Date | undefined;
|
|
@@ -50,6 +50,7 @@ function makeHydratedData(overrides: Record<string, any> = {}) {
|
|
|
50
50
|
status: "pending",
|
|
51
51
|
toolName: "createNpc",
|
|
52
52
|
toolArgs: '{"name":"A"}',
|
|
53
|
+
proposal: '{"type":"npcs","attributes":{"name":"A"}}',
|
|
53
54
|
summary: "Create a new record",
|
|
54
55
|
threadId: "thread-1",
|
|
55
56
|
userModuleIds: ["module-1"],
|
|
@@ -74,6 +75,14 @@ describe("AssistantAction model", () => {
|
|
|
74
75
|
expect(action.summary).toBe("Create a new record");
|
|
75
76
|
});
|
|
76
77
|
|
|
78
|
+
it("rehydrates the resolved proposal payload", () => {
|
|
79
|
+
const action = new AssistantAction();
|
|
80
|
+
action.rehydrate(makeHydratedData() as any);
|
|
81
|
+
|
|
82
|
+
expect(action.toolArgs).toBe('{"name":"A"}');
|
|
83
|
+
expect(action.proposal).toBe('{"type":"npcs","attributes":{"name":"A"}}');
|
|
84
|
+
});
|
|
85
|
+
|
|
77
86
|
it("parses expiresAt into a Date", () => {
|
|
78
87
|
const action = new AssistantAction();
|
|
79
88
|
action.rehydrate(makeHydratedData() as any);
|
|
@@ -114,6 +123,8 @@ describe("AssistantAction model", () => {
|
|
|
114
123
|
// Missing status defaults to the fail-safe, non-actionable "expired" — never the actionable "pending".
|
|
115
124
|
expect(action.status).toBe("expired");
|
|
116
125
|
expect(action.toolName).toBe("");
|
|
126
|
+
// A pre-`proposal` action must degrade to the empty string, never to undefined.
|
|
127
|
+
expect(action.proposal).toBe("");
|
|
117
128
|
expect(action.summary).toBe("");
|
|
118
129
|
expect(action.resolvedAt).toBeUndefined();
|
|
119
130
|
expect(action.expiresAt).toBeUndefined();
|
|
@@ -15,6 +15,7 @@ export { HelpArticleBody } from "./components/HelpArticleBody";
|
|
|
15
15
|
export { HelpTOC } from "./components/HelpTOC";
|
|
16
16
|
export { HelpHint } from "./components/HelpHint";
|
|
17
17
|
export { HelpAskAi } from "./components/HelpAskAi";
|
|
18
|
+
export { HelpAssistantSheet } from "./components/HelpAssistantSheet";
|
|
18
19
|
export { HelpSearchResultRow } from "./components/HelpSearchResultRow";
|
|
19
20
|
export { useHelpFilter } from "./hooks/useHelpFilter";
|
|
20
21
|
export { articleUrl, modeUrl } from "./utils/articleUrl";
|
|
@@ -4,7 +4,6 @@ import { X } from "lucide-react";
|
|
|
4
4
|
import { Button, Card, CardContent, CardHeader, CardTitle } from "../../../shadcnui";
|
|
5
5
|
import { OnboardingCardRenderProps } from "../interfaces";
|
|
6
6
|
|
|
7
|
-
// Matches a360ai ShepherdCard design exactly
|
|
8
7
|
export function OnboardingCard({
|
|
9
8
|
step,
|
|
10
9
|
currentIndex,
|
|
@@ -17,7 +16,7 @@ export function OnboardingCard({
|
|
|
17
16
|
isLast,
|
|
18
17
|
}: OnboardingCardRenderProps) {
|
|
19
18
|
return (
|
|
20
|
-
<Card className="w-[320px] relative" data-
|
|
19
|
+
<Card className="w-[320px] relative" data-onboarding-card>
|
|
21
20
|
<Button variant="ghost" size="icon" className="absolute end-2 top-2" onClick={onClose} aria-label={labels.close}>
|
|
22
21
|
<X className="h-4 w-4" />
|
|
23
22
|
</Button>
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { act, renderHook } from "@testing-library/react";
|
|
3
|
+
import type { ReactNode } from "react";
|
|
4
|
+
|
|
5
|
+
type Hooks = {
|
|
6
|
+
onDestroyed?: () => void;
|
|
7
|
+
steps: Array<{
|
|
8
|
+
element?: () => Element | undefined;
|
|
9
|
+
disableActiveInteraction?: boolean;
|
|
10
|
+
popover: { side: string; align: string; onPopoverRender: (p: { wrapper: HTMLElement }) => void };
|
|
11
|
+
onHighlightStarted?: () => void;
|
|
12
|
+
onDeselected?: () => void;
|
|
13
|
+
}>;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
const driverMock = vi.hoisted(() => {
|
|
17
|
+
const instance = {
|
|
18
|
+
drive: vi.fn(),
|
|
19
|
+
moveNext: vi.fn(),
|
|
20
|
+
movePrevious: vi.fn(),
|
|
21
|
+
moveTo: vi.fn(),
|
|
22
|
+
destroy: vi.fn(),
|
|
23
|
+
};
|
|
24
|
+
const state: { config?: Hooks } = {};
|
|
25
|
+
const driver = vi.fn((config: Hooks) => {
|
|
26
|
+
state.config = config;
|
|
27
|
+
return instance;
|
|
28
|
+
});
|
|
29
|
+
return { driver, instance, state };
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
vi.mock("driver.js", () => ({ driver: driverMock.driver }));
|
|
33
|
+
vi.mock("driver.js/dist/driver.css", () => ({}));
|
|
34
|
+
vi.mock("../styles/onboarding.css", () => ({}));
|
|
35
|
+
|
|
36
|
+
import { OnboardingProvider, useOnboarding } from "./OnboardingContext";
|
|
37
|
+
import type { OnboardingStepConfig } from "../interfaces";
|
|
38
|
+
|
|
39
|
+
const steps: OnboardingStepConfig[] = [
|
|
40
|
+
{ id: "a", title: "First", content: "one", selector: "[data-help='x']", side: "bottom-start" },
|
|
41
|
+
{ id: "b", title: "Second", content: "two" },
|
|
42
|
+
];
|
|
43
|
+
|
|
44
|
+
function wrap(onTourEnd = vi.fn()) {
|
|
45
|
+
const wrapper = ({ children }: { children: ReactNode }) => (
|
|
46
|
+
<OnboardingProvider onTourEnd={onTourEnd}>{children}</OnboardingProvider>
|
|
47
|
+
);
|
|
48
|
+
return { wrapper, onTourEnd };
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe("OnboardingProvider on driver.js", () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
driverMock.driver.mockClear();
|
|
54
|
+
driverMock.instance.drive.mockClear();
|
|
55
|
+
driverMock.instance.destroy.mockClear();
|
|
56
|
+
driverMock.instance.moveNext.mockClear();
|
|
57
|
+
driverMock.state.config = undefined;
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("maps steps to driver steps and starts the tour", () => {
|
|
61
|
+
const { wrapper } = wrap();
|
|
62
|
+
const { result } = renderHook(() => useOnboarding(), { wrapper });
|
|
63
|
+
act(() => result.current.startTour("t", steps));
|
|
64
|
+
|
|
65
|
+
const config = driverMock.state.config!;
|
|
66
|
+
expect(driverMock.instance.drive).toHaveBeenCalledTimes(1);
|
|
67
|
+
expect(config.steps).toHaveLength(2);
|
|
68
|
+
const resolveElement = config.steps[0].element!;
|
|
69
|
+
expect(typeof resolveElement).toBe("function");
|
|
70
|
+
expect(resolveElement()).toBeUndefined();
|
|
71
|
+
const target = document.createElement("div");
|
|
72
|
+
target.setAttribute("data-help", "x");
|
|
73
|
+
document.body.appendChild(target);
|
|
74
|
+
// jsdom lays nothing out, so getClientRects() is always empty; the resolver
|
|
75
|
+
// reads it to catch a display:none ancestor, so give it one box here.
|
|
76
|
+
const rects = vi.spyOn(Element.prototype, "getClientRects").mockReturnValue([{}] as unknown as DOMRectList);
|
|
77
|
+
expect(resolveElement()).toBe(target);
|
|
78
|
+
rects.mockRestore();
|
|
79
|
+
target.remove();
|
|
80
|
+
expect(resolveElement()).toBeUndefined();
|
|
81
|
+
expect(config.steps[0].popover.side).toBe("bottom");
|
|
82
|
+
expect(config.steps[0].popover.align).toBe("start");
|
|
83
|
+
expect(config.steps[0].disableActiveInteraction).toBe(true);
|
|
84
|
+
expect(config.steps[1].element).toBeUndefined();
|
|
85
|
+
expect(config.steps[1].popover.align).toBe("center");
|
|
86
|
+
expect(result.current.totalSteps).toBe(2);
|
|
87
|
+
expect(result.current.activeTourId).toBe("t");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("renders the card into the popover wrapper and tracks the active step", () => {
|
|
91
|
+
const { wrapper } = wrap();
|
|
92
|
+
const { result } = renderHook(() => useOnboarding(), { wrapper });
|
|
93
|
+
act(() => result.current.startTour("t", steps));
|
|
94
|
+
const config = driverMock.state.config!;
|
|
95
|
+
|
|
96
|
+
const popoverWrapper = document.createElement("div");
|
|
97
|
+
act(() => {
|
|
98
|
+
config.steps[0].onHighlightStarted?.();
|
|
99
|
+
config.steps[0].popover.onPopoverRender({ wrapper: popoverWrapper });
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
expect(result.current.isTourActive).toBe(true);
|
|
103
|
+
expect(result.current.currentStepIndex).toBe(0);
|
|
104
|
+
expect(popoverWrapper.querySelector("[data-onboarding-card]")).not.toBeNull();
|
|
105
|
+
expect(popoverWrapper.textContent).toContain("First");
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it("mounts the card synchronously, before driver.js positions the popover", () => {
|
|
109
|
+
const { wrapper } = wrap();
|
|
110
|
+
const { result } = renderHook(() => useOnboarding(), { wrapper });
|
|
111
|
+
act(() => result.current.startTour("t", steps));
|
|
112
|
+
const config = driverMock.state.config!;
|
|
113
|
+
|
|
114
|
+
// driver.js calls onPopoverRender, then measures and positions the popover in the
|
|
115
|
+
// same synchronous pass; the card must already be there at full size.
|
|
116
|
+
const popoverWrapper = document.createElement("div");
|
|
117
|
+
config.steps[0].popover.onPopoverRender({ wrapper: popoverWrapper });
|
|
118
|
+
expect(popoverWrapper.querySelector("[data-onboarding-card]")).not.toBeNull();
|
|
119
|
+
expect(popoverWrapper.textContent).toContain("First");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("reports 'finished' when the last step's Finish is pressed", () => {
|
|
123
|
+
const { wrapper, onTourEnd } = wrap();
|
|
124
|
+
const { result } = renderHook(() => useOnboarding(), { wrapper });
|
|
125
|
+
act(() => result.current.startTour("t", steps));
|
|
126
|
+
const config = driverMock.state.config!;
|
|
127
|
+
const popoverWrapper = document.createElement("div");
|
|
128
|
+
act(() => {
|
|
129
|
+
config.steps[1].onHighlightStarted?.();
|
|
130
|
+
config.steps[1].popover.onPopoverRender({ wrapper: popoverWrapper });
|
|
131
|
+
});
|
|
132
|
+
const finish = Array.from(popoverWrapper.querySelectorAll("button")).find((b) => b.textContent === "Finish")!;
|
|
133
|
+
act(() => finish.click());
|
|
134
|
+
act(() => config.onDestroyed?.());
|
|
135
|
+
|
|
136
|
+
expect(driverMock.instance.destroy).toHaveBeenCalled();
|
|
137
|
+
expect(onTourEnd).toHaveBeenCalledWith("t", "finished");
|
|
138
|
+
expect(result.current.isTourActive).toBe(false);
|
|
139
|
+
expect(result.current.activeTourId).toBeNull();
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("ends the tour even when driver.js never fires onDestroyed", () => {
|
|
143
|
+
/* driver.js skips onDeselected/onDestroyed unless its 400ms transition callback has
|
|
144
|
+
set __activeElement and __activeStep, so a fast Finish click destroys the tour
|
|
145
|
+
without the hook ever running. The provider must still tear itself down. */
|
|
146
|
+
const { wrapper, onTourEnd } = wrap();
|
|
147
|
+
const { result } = renderHook(() => useOnboarding(), { wrapper });
|
|
148
|
+
act(() => result.current.startTour("t", steps));
|
|
149
|
+
const config = driverMock.state.config!;
|
|
150
|
+
const popoverWrapper = document.createElement("div");
|
|
151
|
+
act(() => {
|
|
152
|
+
config.steps[1].onHighlightStarted?.();
|
|
153
|
+
config.steps[1].popover.onPopoverRender({ wrapper: popoverWrapper });
|
|
154
|
+
});
|
|
155
|
+
expect(result.current.isTourActive).toBe(true);
|
|
156
|
+
|
|
157
|
+
const finish = Array.from(popoverWrapper.querySelectorAll("button")).find((b) => b.textContent === "Finish")!;
|
|
158
|
+
act(() => finish.click());
|
|
159
|
+
|
|
160
|
+
expect(driverMock.instance.destroy).toHaveBeenCalled();
|
|
161
|
+
expect(result.current.isTourActive).toBe(false);
|
|
162
|
+
expect(result.current.activeTourId).toBeNull();
|
|
163
|
+
expect(onTourEnd).toHaveBeenCalledWith("t", "finished");
|
|
164
|
+
|
|
165
|
+
// A late onDestroyed must not end the tour a second time.
|
|
166
|
+
act(() => config.onDestroyed?.());
|
|
167
|
+
expect(onTourEnd).toHaveBeenCalledTimes(1);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it("reports 'dismissed' when driver is destroyed without a card action", () => {
|
|
171
|
+
const { wrapper, onTourEnd } = wrap();
|
|
172
|
+
const { result } = renderHook(() => useOnboarding(), { wrapper });
|
|
173
|
+
act(() => result.current.startTour("t", steps));
|
|
174
|
+
act(() => driverMock.state.config!.onDestroyed?.());
|
|
175
|
+
expect(onTourEnd).toHaveBeenCalledWith("t", "dismissed");
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("warns and does nothing for an unknown tour id", () => {
|
|
179
|
+
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
180
|
+
const { wrapper } = wrap();
|
|
181
|
+
const { result } = renderHook(() => useOnboarding(), { wrapper });
|
|
182
|
+
act(() => result.current.startTour("missing"));
|
|
183
|
+
expect(driverMock.driver).not.toHaveBeenCalled();
|
|
184
|
+
expect(warn).toHaveBeenCalled();
|
|
185
|
+
warn.mockRestore();
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("throws when used outside the provider", () => {
|
|
189
|
+
expect(() => renderHook(() => useOnboarding())).toThrow(/OnboardingProvider/);
|
|
190
|
+
});
|
|
191
|
+
});
|
|
@@ -1,20 +1,61 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
3
|
import { createContext, useCallback, useContext, useEffect, useRef, useState } from "react";
|
|
4
|
+
import { flushSync } from "react-dom";
|
|
4
5
|
import { createRoot, Root } from "react-dom/client";
|
|
5
|
-
import
|
|
6
|
-
import "
|
|
6
|
+
import { driver, type Driver, type DriveStep, type PopoverDOM } from "driver.js";
|
|
7
|
+
import "driver.js/dist/driver.css";
|
|
7
8
|
import { OnboardingCard } from "../components/OnboardingCard";
|
|
8
9
|
import {
|
|
9
10
|
DEFAULT_ONBOARDING_LABELS,
|
|
10
11
|
OnboardingContextValue,
|
|
11
12
|
OnboardingProviderProps,
|
|
12
13
|
OnboardingStepConfig,
|
|
14
|
+
OnboardingStepSide,
|
|
15
|
+
OnboardingTourEndReason,
|
|
13
16
|
} from "../interfaces";
|
|
14
17
|
import "../styles/onboarding.css";
|
|
15
18
|
|
|
16
19
|
const OnboardingContext = createContext<OnboardingContextValue | null>(null);
|
|
17
20
|
|
|
21
|
+
type DriverSide = "top" | "right" | "bottom" | "left";
|
|
22
|
+
type DriverAlign = "start" | "center" | "end";
|
|
23
|
+
|
|
24
|
+
/** "bottom-start" → { side: "bottom", align: "start" }; bare side → align "center". */
|
|
25
|
+
export function mapOnboardingSide(side?: OnboardingStepSide): { side: DriverSide; align: DriverAlign } {
|
|
26
|
+
if (!side) return { side: "bottom", align: "center" };
|
|
27
|
+
const [s, a] = side.split("-") as [DriverSide, DriverAlign | undefined];
|
|
28
|
+
return { side: s, align: a ?? "center" };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolves a step's target, treating a hidden element as a missing one.
|
|
33
|
+
*
|
|
34
|
+
* driver.js only honours `skipMissingElement` when the element resolves to
|
|
35
|
+
* nothing. A selector string cannot express "present in the DOM but not
|
|
36
|
+
* displayed", so targets that exist yet are `display: none` (the section rail
|
|
37
|
+
* below `md`, for instance) would otherwise highlight an empty box. Returning
|
|
38
|
+
* `undefined` from the resolver takes the exact same path as an absent
|
|
39
|
+
* selector, so the step is skipped.
|
|
40
|
+
*
|
|
41
|
+
* The check is `getClientRects()`, not the element's own computed style: an
|
|
42
|
+
* element inherits invisibility from a `display: none` ancestor, whose own
|
|
43
|
+
* style says nothing. An element with no client rects generates no boxes, so
|
|
44
|
+
* it is not displayed, whatever its own `display` value is.
|
|
45
|
+
*
|
|
46
|
+
* The cast exists because driver.js types the resolver as `() => Element`,
|
|
47
|
+
* while its runtime (`typeof e === "function" ? e() : ...`) treats any falsy
|
|
48
|
+
* result as missing.
|
|
49
|
+
*/
|
|
50
|
+
function resolveVisibleElement(selector: string): DriveStep["element"] {
|
|
51
|
+
return (() => {
|
|
52
|
+
const element = document.querySelector(selector);
|
|
53
|
+
if (!element) return undefined;
|
|
54
|
+
if (element.getClientRects().length === 0) return undefined;
|
|
55
|
+
return element;
|
|
56
|
+
}) as DriveStep["element"];
|
|
57
|
+
}
|
|
58
|
+
|
|
18
59
|
export function OnboardingProvider({
|
|
19
60
|
children,
|
|
20
61
|
tours = [],
|
|
@@ -22,72 +63,78 @@ export function OnboardingProvider({
|
|
|
22
63
|
labels = DEFAULT_ONBOARDING_LABELS,
|
|
23
64
|
renderCard,
|
|
24
65
|
zIndex: _zIndex = 9999,
|
|
66
|
+
onTourEnd,
|
|
25
67
|
}: OnboardingProviderProps) {
|
|
26
68
|
const [isTourActive, setIsTourActive] = useState(false);
|
|
27
69
|
const [activeTourId, setActiveTourId] = useState<string | null>(null);
|
|
28
70
|
const [currentStepIndex, setCurrentStepIndex] = useState(0);
|
|
29
71
|
const [totalSteps, setTotalSteps] = useState(0);
|
|
30
72
|
|
|
31
|
-
const
|
|
32
|
-
const rootsRef = useRef<Map<
|
|
73
|
+
const driverRef = useRef<Driver | null>(null);
|
|
74
|
+
const rootsRef = useRef<Map<number, Root>>(new Map());
|
|
75
|
+
const endReasonRef = useRef<OnboardingTourEndReason>("dismissed");
|
|
76
|
+
const activeTourIdRef = useRef<string | null>(null);
|
|
77
|
+
|
|
78
|
+
// Unmount on the next tick: a root cannot unmount synchronously from inside
|
|
79
|
+
// an event handler it rendered (React warns and skips the unmount).
|
|
80
|
+
const unmountRoot = (root: Root) => setTimeout(() => root.unmount(), 0);
|
|
33
81
|
|
|
34
82
|
const cleanupRoots = useCallback(() => {
|
|
35
|
-
rootsRef.current.forEach((root) =>
|
|
36
|
-
try {
|
|
37
|
-
root.unmount();
|
|
38
|
-
} catch (_e) {
|
|
39
|
-
// Root may already be unmounted
|
|
40
|
-
}
|
|
41
|
-
});
|
|
83
|
+
rootsRef.current.forEach((root) => unmountRoot(root));
|
|
42
84
|
rootsRef.current.clear();
|
|
43
85
|
}, []);
|
|
44
86
|
|
|
45
|
-
const
|
|
46
|
-
if (tourRef.current) {
|
|
47
|
-
tourRef.current.cancel();
|
|
48
|
-
tourRef.current = null;
|
|
49
|
-
}
|
|
50
|
-
cleanupRoots();
|
|
87
|
+
const resetState = useCallback(() => {
|
|
51
88
|
setIsTourActive(false);
|
|
52
89
|
setActiveTourId(null);
|
|
53
90
|
setCurrentStepIndex(0);
|
|
54
91
|
setTotalSteps(0);
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const nextStep = useCallback(() => {
|
|
58
|
-
if (tourRef.current) {
|
|
59
|
-
tourRef.current.next();
|
|
60
|
-
}
|
|
92
|
+
driverRef.current = null;
|
|
93
|
+
activeTourIdRef.current = null;
|
|
61
94
|
}, []);
|
|
62
95
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
96
|
+
/**
|
|
97
|
+
* Ends the tour on our side: unmount the cards, reset the state, report the reason.
|
|
98
|
+
*
|
|
99
|
+
* It cannot be left to driver.js's `onDestroyed`: driver only fires that hook when
|
|
100
|
+
* it holds both an active element and an active step, and both are set by a step's
|
|
101
|
+
* 400ms transition callback. A Next/Finish click that lands before the animation
|
|
102
|
+
* settles therefore destroys the DOM without ever calling the hook, and the provider
|
|
103
|
+
* would stay `isTourActive` forever. Every path that calls `destroy()` calls this
|
|
104
|
+
* straight after; `resetState()` nulls `driverRef.current`, so a later `onDestroyed`
|
|
105
|
+
* is a no-op.
|
|
106
|
+
*/
|
|
107
|
+
const teardown = useCallback(
|
|
108
|
+
(reason: OnboardingTourEndReason) => {
|
|
109
|
+
if (!driverRef.current) return;
|
|
110
|
+
const id = activeTourIdRef.current;
|
|
111
|
+
cleanupRoots();
|
|
112
|
+
resetState();
|
|
113
|
+
if (id) onTourEnd?.(id, reason);
|
|
114
|
+
},
|
|
115
|
+
[cleanupRoots, resetState, onTourEnd],
|
|
116
|
+
);
|
|
68
117
|
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
|
|
118
|
+
const closeTour = useCallback(() => {
|
|
119
|
+
if (driverRef.current) {
|
|
120
|
+
driverRef.current.destroy();
|
|
121
|
+
teardown("dismissed");
|
|
122
|
+
return;
|
|
72
123
|
}
|
|
73
|
-
|
|
124
|
+
cleanupRoots();
|
|
125
|
+
resetState();
|
|
126
|
+
}, [cleanupRoots, resetState, teardown]);
|
|
127
|
+
|
|
128
|
+
const nextStep = useCallback(() => driverRef.current?.moveNext(), []);
|
|
129
|
+
const previousStep = useCallback(() => driverRef.current?.movePrevious(), []);
|
|
130
|
+
const goToStep = useCallback((index: number) => driverRef.current?.moveTo(index), []);
|
|
74
131
|
|
|
75
132
|
const startTour = useCallback(
|
|
76
133
|
(tourId: string, steps?: OnboardingStepConfig[]) => {
|
|
77
134
|
if (typeof window === "undefined") return;
|
|
135
|
+
if (driverRef.current) closeTour();
|
|
78
136
|
|
|
79
|
-
|
|
80
|
-
if (tourRef.current) {
|
|
81
|
-
closeTour();
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Get steps from provided steps or find tour by ID
|
|
85
|
-
let tourSteps = steps;
|
|
86
|
-
if (!tourSteps) {
|
|
87
|
-
const tour = tours.find((t) => t.id === tourId);
|
|
88
|
-
tourSteps = tour?.steps;
|
|
89
|
-
}
|
|
90
|
-
|
|
137
|
+
const tourSteps = steps ?? tours.find((t) => t.id === tourId)?.steps;
|
|
91
138
|
if (!tourSteps || tourSteps.length === 0) {
|
|
92
139
|
console.warn(`No steps found for tour: ${tourId}`);
|
|
93
140
|
return;
|
|
@@ -95,96 +142,102 @@ export function OnboardingProvider({
|
|
|
95
142
|
|
|
96
143
|
setTotalSteps(tourSteps.length);
|
|
97
144
|
setActiveTourId(tourId);
|
|
145
|
+
activeTourIdRef.current = tourId;
|
|
146
|
+
endReasonRef.current = "dismissed";
|
|
98
147
|
|
|
99
|
-
const
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
},
|
|
106
|
-
});
|
|
148
|
+
const move = (direction: "next" | "previous", from: number) => {
|
|
149
|
+
const target = tourSteps[direction === "next" ? from + 1 : from - 1];
|
|
150
|
+
const go = () => (direction === "next" ? driverRef.current?.moveNext() : driverRef.current?.movePrevious());
|
|
151
|
+
if (target?.showDelay) setTimeout(go, target.showDelay);
|
|
152
|
+
else go();
|
|
153
|
+
};
|
|
107
154
|
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
setActiveTourId(null);
|
|
114
|
-
setCurrentStepIndex(0);
|
|
115
|
-
setTotalSteps(0);
|
|
116
|
-
tourRef.current = null;
|
|
117
|
-
});
|
|
118
|
-
tour.on("complete", () => {
|
|
119
|
-
cleanupRoots();
|
|
120
|
-
setIsTourActive(false);
|
|
121
|
-
setActiveTourId(null);
|
|
122
|
-
setCurrentStepIndex(0);
|
|
123
|
-
setTotalSteps(0);
|
|
124
|
-
tourRef.current = null;
|
|
125
|
-
});
|
|
155
|
+
const end = (reason: OnboardingTourEndReason) => {
|
|
156
|
+
endReasonRef.current = reason;
|
|
157
|
+
driverRef.current?.destroy();
|
|
158
|
+
teardown(reason);
|
|
159
|
+
};
|
|
126
160
|
|
|
127
|
-
tourSteps.
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
stepConfig.onShow?.();
|
|
168
|
-
},
|
|
169
|
-
hide: () => {
|
|
170
|
-
stepConfig.onHide?.();
|
|
161
|
+
const driveSteps: DriveStep[] = tourSteps.map((stepConfig, index) => {
|
|
162
|
+
const { side, align } = mapOnboardingSide(stepConfig.side);
|
|
163
|
+
return {
|
|
164
|
+
element: stepConfig.selector ? resolveVisibleElement(stepConfig.selector) : undefined,
|
|
165
|
+
disableActiveInteraction: !(stepConfig.canClickTarget ?? false),
|
|
166
|
+
popover: {
|
|
167
|
+
side,
|
|
168
|
+
align,
|
|
169
|
+
showButtons: [],
|
|
170
|
+
popoverClass: ["onboarding-popover", stepConfig.className].filter(Boolean).join(" "),
|
|
171
|
+
onPopoverRender: (popover: PopoverDOM) => {
|
|
172
|
+
popover.wrapper.querySelectorAll("[data-onboarding-root]").forEach((n) => n.remove());
|
|
173
|
+
const container = document.createElement("div");
|
|
174
|
+
container.setAttribute("data-onboarding-root", "");
|
|
175
|
+
const root = createRoot(container);
|
|
176
|
+
/* driver.js re-renders the popover on refresh(), so the same step can
|
|
177
|
+
render twice; drop the root the previous render left behind. */
|
|
178
|
+
const prev = rootsRef.current.get(index);
|
|
179
|
+
if (prev) unmountRoot(prev);
|
|
180
|
+
rootsRef.current.set(index, root);
|
|
181
|
+
const cardProps = {
|
|
182
|
+
step: stepConfig,
|
|
183
|
+
currentIndex: index,
|
|
184
|
+
totalSteps: tourSteps.length,
|
|
185
|
+
labels,
|
|
186
|
+
onNext: () => move("next", index),
|
|
187
|
+
onPrevious: () => move("previous", index),
|
|
188
|
+
onClose: () => end(index === tourSteps.length - 1 ? "finished" : "skipped"),
|
|
189
|
+
onSkip: () => end("skipped"),
|
|
190
|
+
isFirst: index === 0,
|
|
191
|
+
isLast: index === tourSteps.length - 1,
|
|
192
|
+
};
|
|
193
|
+
/* driver.js positions the popover right after this hook returns (renderPopover
|
|
194
|
+
calls onRender, then repositionPopover), so the card has to be in the DOM at
|
|
195
|
+
its real size by then — an async render would be measured as an empty box and
|
|
196
|
+
the card would overflow the viewport near a page edge. */
|
|
197
|
+
flushSync(() => {
|
|
198
|
+
root.render(renderCard ? renderCard(cardProps) : <OnboardingCard {...cardProps} />);
|
|
199
|
+
});
|
|
200
|
+
popover.wrapper.appendChild(container);
|
|
171
201
|
},
|
|
172
202
|
},
|
|
173
|
-
|
|
203
|
+
onHighlightStarted: () => {
|
|
204
|
+
setIsTourActive(true);
|
|
205
|
+
setCurrentStepIndex(index);
|
|
206
|
+
stepConfig.onShow?.();
|
|
207
|
+
},
|
|
208
|
+
onDeselected: () => {
|
|
209
|
+
stepConfig.onHide?.();
|
|
210
|
+
const root = rootsRef.current.get(index);
|
|
211
|
+
if (root) {
|
|
212
|
+
rootsRef.current.delete(index);
|
|
213
|
+
unmountRoot(root);
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
const instance = driver({
|
|
220
|
+
steps: driveSteps,
|
|
221
|
+
animate: true,
|
|
222
|
+
allowClose: true,
|
|
223
|
+
overlayOpacity: 0.75,
|
|
224
|
+
stagePadding: 8,
|
|
225
|
+
stageRadius: 8,
|
|
226
|
+
smoothScroll: true,
|
|
227
|
+
skipMissingElement: true,
|
|
228
|
+
showButtons: [],
|
|
229
|
+
onDestroyed: () => teardown(endReasonRef.current),
|
|
174
230
|
});
|
|
175
231
|
|
|
176
|
-
|
|
177
|
-
|
|
232
|
+
driverRef.current = instance;
|
|
233
|
+
instance.drive();
|
|
178
234
|
},
|
|
179
|
-
[tours, labels, renderCard, closeTour,
|
|
235
|
+
[tours, labels, renderCard, closeTour, teardown],
|
|
180
236
|
);
|
|
181
237
|
|
|
182
|
-
// Cleanup on unmount
|
|
183
238
|
useEffect(() => {
|
|
184
239
|
return () => {
|
|
185
|
-
|
|
186
|
-
tourRef.current.cancel();
|
|
187
|
-
}
|
|
240
|
+
driverRef.current?.destroy();
|
|
188
241
|
cleanupRoots();
|
|
189
242
|
};
|
|
190
243
|
}, [cleanupRoots]);
|
|
@@ -206,8 +259,6 @@ export function OnboardingProvider({
|
|
|
206
259
|
|
|
207
260
|
export function useOnboarding(): OnboardingContextValue {
|
|
208
261
|
const context = useContext(OnboardingContext);
|
|
209
|
-
if (!context)
|
|
210
|
-
throw new Error("useOnboarding must be used within an OnboardingProvider");
|
|
211
|
-
}
|
|
262
|
+
if (!context) throw new Error("useOnboarding must be used within an OnboardingProvider");
|
|
212
263
|
return context;
|
|
213
264
|
}
|
|
@@ -70,6 +70,8 @@ export interface OnboardingContextValue {
|
|
|
70
70
|
totalSteps: number;
|
|
71
71
|
}
|
|
72
72
|
|
|
73
|
+
export type OnboardingTourEndReason = "finished" | "skipped" | "dismissed";
|
|
74
|
+
|
|
73
75
|
export interface OnboardingProviderProps {
|
|
74
76
|
children: React.ReactNode;
|
|
75
77
|
tours?: OnboardingTourConfig[];
|
|
@@ -77,6 +79,8 @@ export interface OnboardingProviderProps {
|
|
|
77
79
|
labels?: OnboardingLabels;
|
|
78
80
|
renderCard?: (props: OnboardingCardRenderProps) => ReactNode;
|
|
79
81
|
zIndex?: number;
|
|
82
|
+
/** Fires once when a tour ends. "finished" = last-step Finish, "skipped" = card Skip/close, "dismissed" = overlay click or Escape. */
|
|
83
|
+
onTourEnd?: (tourId: string, reason: OnboardingTourEndReason) => void;
|
|
80
84
|
}
|
|
81
85
|
|
|
82
86
|
export interface OnboardingCardRenderProps {
|