@carlonicora/nextjs-jsonapi 3.8.2 → 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-KRGDA5RX.js → BlockNoteEditor-4UAIVF3G.js} +19 -19
- package/dist/{BlockNoteEditor-KRGDA5RX.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-34AZITDR.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-DGAVM2LZ.js → chunk-46CQ3RRQ.js} +1315 -1069
- 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-GXSXD4OR.mjs → chunk-XZJJ4SZW.mjs} +2073 -1827
- 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 +77 -15
- package/dist/components/index.d.ts +77 -15
- 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 +17 -4
- package/dist/contexts/index.d.ts +17 -4
- package/dist/contexts/index.js +10 -4
- package/dist/contexts/index.js.map +1 -1
- package/dist/contexts/index.mjs +9 -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 +38 -16
- package/src/components/containers/RoundPageContainerTitle.tsx +11 -3
- package/src/components/containers/TabsContainer.tsx +11 -0
- package/src/components/forms/CommonDeleter.tsx +14 -1
- package/src/components/forms/EditorSheet.tsx +66 -47
- 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/contexts/ActionBarContext.tsx +23 -0
- package/src/contexts/index.ts +1 -0
- 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/src/shadcnui/ui/command.tsx +9 -0
- 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-DGAVM2LZ.js.map +0 -1
- package/dist/chunk-GXSXD4OR.mjs.map +0 -1
- package/dist/chunk-UYLS3LJW.js.map +0 -1
- /package/dist/{BlockNoteEditor-34AZITDR.mjs.map → BlockNoteEditor-PFCHUFTD.mjs.map} +0 -0
- /package/dist/{chunk-2D4MFVDV.mjs.map → chunk-IMSIVIIC.mjs.map} +0 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
import { render, screen } from "@testing-library/react";
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import { ApprovalActionCard } from "../ApprovalActionCard";
|
|
4
|
+
|
|
5
|
+
const findOne = vi.fn();
|
|
6
|
+
const approve = vi.fn();
|
|
7
|
+
const deny = vi.fn();
|
|
8
|
+
|
|
9
|
+
vi.mock("../../data/AssistantActionService", () => ({
|
|
10
|
+
AssistantActionService: {
|
|
11
|
+
findOne: (params: { id: string }) => findOne(params),
|
|
12
|
+
approve: (params: { id: string }) => approve(params),
|
|
13
|
+
deny: (params: { id: string }) => deny(params),
|
|
14
|
+
},
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
/** Any uuid-ish run of hex — no id may ever reach the screen. */
|
|
18
|
+
const UUID_LIKE = /[0-9a-f]{8}-[0-9a-f]{4}-/;
|
|
19
|
+
|
|
20
|
+
const CREATE_TOOL_ARGS = JSON.stringify({
|
|
21
|
+
type: "npcs",
|
|
22
|
+
attributes: { name: "Marcus", tldr: "A nerd with bouts of rage" },
|
|
23
|
+
relationships: { related: { data: [{ type: "npcs", id: "042ec319-9d3c-4f0a-9a6c-1a2b3c4d5e6f" }] } },
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const CREATE_PROPOSAL = JSON.stringify({
|
|
27
|
+
type: "npcs",
|
|
28
|
+
attributes: { name: "Marcus", tldr: "A nerd with bouts of rage" },
|
|
29
|
+
relationships: {
|
|
30
|
+
related: [{ id: "042ec319-9d3c-4f0a-9a6c-1a2b3c4d5e6f", type: "npcs", label: "Zoe" }],
|
|
31
|
+
},
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
function makeAction(overrides: Record<string, unknown> = {}) {
|
|
35
|
+
return {
|
|
36
|
+
id: "act-1",
|
|
37
|
+
status: "pending",
|
|
38
|
+
toolName: "create_entity",
|
|
39
|
+
toolArgs: CREATE_TOOL_ARGS,
|
|
40
|
+
proposal: CREATE_PROPOSAL,
|
|
41
|
+
summary: 'Create a new npcs record named "Marcus".',
|
|
42
|
+
...overrides,
|
|
43
|
+
} as any;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
describe("ApprovalActionCard", () => {
|
|
47
|
+
beforeEach(() => {
|
|
48
|
+
findOne.mockReset();
|
|
49
|
+
approve.mockReset();
|
|
50
|
+
deny.mockReset();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("renders the proposed record: the attributes and the related records by name", async () => {
|
|
54
|
+
findOne.mockResolvedValue(makeAction());
|
|
55
|
+
|
|
56
|
+
render(<ApprovalActionCard actionId="act-1" />);
|
|
57
|
+
|
|
58
|
+
// summary still renders
|
|
59
|
+
expect(await screen.findByText('Create a new npcs record named "Marcus".')).toBeInTheDocument();
|
|
60
|
+
// attributes
|
|
61
|
+
expect(await screen.findByText("Marcus")).toBeInTheDocument();
|
|
62
|
+
expect(screen.getByText("A nerd with bouts of rage")).toBeInTheDocument();
|
|
63
|
+
expect(screen.getByText("Name")).toBeInTheDocument();
|
|
64
|
+
// relationships: humanised key + the resolved display names, never ids
|
|
65
|
+
expect(screen.getByText("Related")).toBeInTheDocument();
|
|
66
|
+
expect(screen.getByText("Zoe")).toBeInTheDocument();
|
|
67
|
+
expect(screen.queryByText(UUID_LIKE)).toBeNull();
|
|
68
|
+
// buttons are actionable while the action is pending
|
|
69
|
+
expect(screen.getByRole("button", { name: "features.assistant.approval.approve" })).toBeEnabled();
|
|
70
|
+
expect(screen.getByRole("button", { name: "features.assistant.approval.deny" })).toBeEnabled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it("renders booleans as Yes/No and numbers as-is", async () => {
|
|
74
|
+
findOne.mockResolvedValue(
|
|
75
|
+
makeAction({
|
|
76
|
+
proposal: JSON.stringify({
|
|
77
|
+
type: "factions",
|
|
78
|
+
attributes: { name: "The Cinder Pact", secret: true, rank: 3 },
|
|
79
|
+
}),
|
|
80
|
+
}),
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
render(<ApprovalActionCard actionId="act-1" />);
|
|
84
|
+
|
|
85
|
+
expect(await screen.findByText("The Cinder Pact")).toBeInTheDocument();
|
|
86
|
+
expect(screen.getByText("features.assistant.approval.proposal.yes")).toBeInTheDocument();
|
|
87
|
+
expect(screen.getByText("3")).toBeInTheDocument();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("renders the plain text of a BlockNote document value", async () => {
|
|
91
|
+
const document = JSON.stringify([
|
|
92
|
+
{ id: "1", type: "paragraph", content: [{ type: "text", text: "First line" }], children: [] },
|
|
93
|
+
{
|
|
94
|
+
id: "2",
|
|
95
|
+
type: "paragraph",
|
|
96
|
+
content: [
|
|
97
|
+
{ type: "text", text: "Second " },
|
|
98
|
+
{ type: "text", text: "line" },
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
findOne.mockResolvedValue(
|
|
103
|
+
makeAction({ proposal: JSON.stringify({ type: "npcs", attributes: { description: document } }) }),
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
render(<ApprovalActionCard actionId="act-1" />);
|
|
107
|
+
|
|
108
|
+
// identity normalizer: the default one collapses the newline the reducer emits
|
|
109
|
+
expect(await screen.findByText("First line\nSecond line", { normalizer: (text) => text })).toBeInTheDocument();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it("skips null, undefined and empty attribute rows", async () => {
|
|
113
|
+
findOne.mockResolvedValue(
|
|
114
|
+
makeAction({
|
|
115
|
+
proposal: JSON.stringify({
|
|
116
|
+
type: "npcs",
|
|
117
|
+
attributes: { name: "Marcus", tldr: null, notes: "", tags: [] },
|
|
118
|
+
}),
|
|
119
|
+
}),
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
render(<ApprovalActionCard actionId="act-1" />);
|
|
123
|
+
|
|
124
|
+
expect(await screen.findByText("Marcus")).toBeInTheDocument();
|
|
125
|
+
expect(screen.queryByText("Tldr")).not.toBeInTheDocument();
|
|
126
|
+
expect(screen.queryByText("Notes")).not.toBeInTheDocument();
|
|
127
|
+
expect(screen.queryByText("Tags")).not.toBeInTheDocument();
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it("shows the target record's name — never its id — for an update_entity proposal", async () => {
|
|
131
|
+
findOne.mockResolvedValue(
|
|
132
|
+
makeAction({
|
|
133
|
+
toolName: "update_entity",
|
|
134
|
+
summary: 'Update the npcs record "Marcus" (name).',
|
|
135
|
+
toolArgs: JSON.stringify({
|
|
136
|
+
type: "npcs",
|
|
137
|
+
id: "npc-9d3c4f0a-1a2b-4c4d-9a6c-042ec3199d3c",
|
|
138
|
+
attributes: { name: "Marcus the Calm" },
|
|
139
|
+
}),
|
|
140
|
+
proposal: JSON.stringify({
|
|
141
|
+
type: "npcs",
|
|
142
|
+
target: { id: "9d3c4f0a-1a2b-4c4d-9a6c-042ec3199d3c", type: "npcs", label: "Marcus" },
|
|
143
|
+
attributes: { name: "Marcus the Calm" },
|
|
144
|
+
}),
|
|
145
|
+
}),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
render(<ApprovalActionCard actionId="act-1" />);
|
|
149
|
+
|
|
150
|
+
expect(await screen.findByText("features.assistant.approval.proposal.updateHeading")).toBeInTheDocument();
|
|
151
|
+
expect(screen.getByText("Marcus")).toBeInTheDocument();
|
|
152
|
+
expect(screen.getByText("Marcus the Calm")).toBeInTheDocument();
|
|
153
|
+
expect(screen.queryByText(UUID_LIKE)).toBeNull();
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("shows the relationship name and the linked records for a link proposal", async () => {
|
|
157
|
+
findOne.mockResolvedValue(
|
|
158
|
+
makeAction({
|
|
159
|
+
toolName: "link_entities",
|
|
160
|
+
summary: 'Link "Zoe", "Bob" to the "related" relationship of the npcs record "Marcus".',
|
|
161
|
+
proposal: JSON.stringify({
|
|
162
|
+
type: "npcs",
|
|
163
|
+
target: { id: "9d3c4f0a-1a2b-4c4d-9a6c-042ec3199d3c", type: "npcs", label: "Marcus" },
|
|
164
|
+
relationship: "related",
|
|
165
|
+
targets: [
|
|
166
|
+
{ id: "042ec319-9d3c-4f0a-9a6c-1a2b3c4d5e6f", type: "npcs", label: "Zoe" },
|
|
167
|
+
{ id: "c81d6ac7-1a2b-4c4d-9a6c-042ec3199d3c", type: "npcs", label: "Bob" },
|
|
168
|
+
],
|
|
169
|
+
}),
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
render(<ApprovalActionCard actionId="act-1" />);
|
|
174
|
+
|
|
175
|
+
expect(await screen.findByText("features.assistant.approval.proposal.relationship")).toBeInTheDocument();
|
|
176
|
+
expect(screen.getByText("Related")).toBeInTheDocument();
|
|
177
|
+
expect(screen.getByText("features.assistant.approval.proposal.records")).toBeInTheDocument();
|
|
178
|
+
expect(screen.getByText("Zoe, Bob")).toBeInTheDocument();
|
|
179
|
+
expect(screen.getByText("Marcus")).toBeInTheDocument();
|
|
180
|
+
expect(screen.queryByText(UUID_LIKE)).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("shows the unlinked records by name for an unlink proposal", async () => {
|
|
184
|
+
findOne.mockResolvedValue(
|
|
185
|
+
makeAction({
|
|
186
|
+
toolName: "unlink_entities",
|
|
187
|
+
summary: 'Unlink "Zoe" from the "related" relationship of the npcs record "Marcus".',
|
|
188
|
+
proposal: JSON.stringify({
|
|
189
|
+
type: "npcs",
|
|
190
|
+
target: { id: "9d3c4f0a-1a2b-4c4d-9a6c-042ec3199d3c", type: "npcs", label: "Marcus" },
|
|
191
|
+
relationship: "related",
|
|
192
|
+
targets: [{ id: "042ec319-9d3c-4f0a-9a6c-1a2b3c4d5e6f", type: "npcs", label: "Zoe" }],
|
|
193
|
+
}),
|
|
194
|
+
}),
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
render(<ApprovalActionCard actionId="act-1" />);
|
|
198
|
+
|
|
199
|
+
expect(await screen.findByText("Zoe")).toBeInTheDocument();
|
|
200
|
+
expect(screen.getByText("features.assistant.approval.proposal.records")).toBeInTheDocument();
|
|
201
|
+
expect(screen.queryByText(UUID_LIKE)).toBeNull();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
it("hides the relationship rows named by hiddenRelationshipKeys, and shows them when it does not", async () => {
|
|
205
|
+
// The write tools force the run's own scope and discard the model's, so
|
|
206
|
+
// the host app hides that relationship rather than showing a record the
|
|
207
|
+
// write may not even end up using.
|
|
208
|
+
const proposal = JSON.stringify({
|
|
209
|
+
type: "npcs",
|
|
210
|
+
attributes: { name: "Marcus" },
|
|
211
|
+
relationships: {
|
|
212
|
+
campaign: [{ id: "c81d6ac7-1a2b-4c4d-9a6c-042ec3199d3c", type: "campaigns", label: "The Ashen Crown" }],
|
|
213
|
+
related: [{ id: "042ec319-9d3c-4f0a-9a6c-1a2b3c4d5e6f", type: "npcs", label: "Zoe" }],
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
findOne.mockResolvedValue(makeAction({ proposal }));
|
|
217
|
+
|
|
218
|
+
const hidden = render(<ApprovalActionCard actionId="act-1" hiddenRelationshipKeys={["campaign"]} />);
|
|
219
|
+
|
|
220
|
+
expect(await screen.findByText("Marcus")).toBeInTheDocument();
|
|
221
|
+
expect(screen.queryByText("Campaign")).not.toBeInTheDocument();
|
|
222
|
+
expect(screen.queryByText("The Ashen Crown")).not.toBeInTheDocument();
|
|
223
|
+
// the relationships that are not listed keep rendering
|
|
224
|
+
expect(screen.getByText("Related")).toBeInTheDocument();
|
|
225
|
+
expect(screen.getByText("Zoe")).toBeInTheDocument();
|
|
226
|
+
|
|
227
|
+
hidden.unmount();
|
|
228
|
+
render(<ApprovalActionCard actionId="act-1" />);
|
|
229
|
+
|
|
230
|
+
expect(await screen.findByText("Campaign")).toBeInTheDocument();
|
|
231
|
+
expect(screen.getByText("The Ashen Crown")).toBeInTheDocument();
|
|
232
|
+
expect(screen.queryByText(UUID_LIKE)).toBeNull();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it("falls back to the toolArgs attributes — and to no relationship rows — for a legacy action", async () => {
|
|
236
|
+
findOne.mockResolvedValue(
|
|
237
|
+
makeAction({
|
|
238
|
+
proposal: "",
|
|
239
|
+
toolArgs: JSON.stringify({
|
|
240
|
+
type: "npcs",
|
|
241
|
+
fields: { name: "Marcus", tldr: "A nerd with bouts of rage" },
|
|
242
|
+
relationships: { related: { data: [{ type: "npcs", id: "042ec319-9d3c-4f0a-9a6c-1a2b3c4d5e6f" }] } },
|
|
243
|
+
}),
|
|
244
|
+
}),
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
render(<ApprovalActionCard actionId="act-1" />);
|
|
248
|
+
|
|
249
|
+
// attributes still render (`fields` is the key the create_entity tool emits)
|
|
250
|
+
expect(await screen.findByText("Marcus")).toBeInTheDocument();
|
|
251
|
+
expect(screen.getByText("A nerd with bouts of rage")).toBeInTheDocument();
|
|
252
|
+
// relationships from toolArgs carry ids only — no row, and no id on screen
|
|
253
|
+
expect(screen.queryByText("Related")).not.toBeInTheDocument();
|
|
254
|
+
expect(screen.queryByText(UUID_LIKE)).toBeNull();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("still renders the summary and the buttons when neither payload parses", async () => {
|
|
258
|
+
findOne.mockResolvedValue(makeAction({ proposal: "{not-json", toolArgs: "{not-json" }));
|
|
259
|
+
|
|
260
|
+
render(<ApprovalActionCard actionId="act-1" />);
|
|
261
|
+
|
|
262
|
+
expect(await screen.findByText('Create a new npcs record named "Marcus".')).toBeInTheDocument();
|
|
263
|
+
expect(screen.getByRole("button", { name: "features.assistant.approval.approve" })).toBeEnabled();
|
|
264
|
+
expect(screen.getByRole("button", { name: "features.assistant.approval.deny" })).toBeEnabled();
|
|
265
|
+
expect(screen.queryByText("features.assistant.approval.proposal.heading")).not.toBeInTheDocument();
|
|
266
|
+
expect(screen.queryByText(UUID_LIKE)).toBeNull();
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -4,6 +4,8 @@ import { AssistantActionInput, AssistantActionInterface, AssistantActionStatus }
|
|
|
4
4
|
export class AssistantAction extends AbstractApiData implements AssistantActionInterface {
|
|
5
5
|
private _status?: AssistantActionStatus;
|
|
6
6
|
private _toolName?: string;
|
|
7
|
+
private _toolArgs?: string;
|
|
8
|
+
private _proposal?: string;
|
|
7
9
|
private _summary?: string;
|
|
8
10
|
private _resolvedAt?: Date;
|
|
9
11
|
private _expiresAt?: Date;
|
|
@@ -17,6 +19,14 @@ export class AssistantAction extends AbstractApiData implements AssistantActionI
|
|
|
17
19
|
return this._toolName ?? "";
|
|
18
20
|
}
|
|
19
21
|
|
|
22
|
+
get toolArgs(): string {
|
|
23
|
+
return this._toolArgs ?? "";
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
get proposal(): string {
|
|
27
|
+
return this._proposal ?? "";
|
|
28
|
+
}
|
|
29
|
+
|
|
20
30
|
get summary(): string {
|
|
21
31
|
return this._summary ?? "";
|
|
22
32
|
}
|
|
@@ -34,6 +44,8 @@ export class AssistantAction extends AbstractApiData implements AssistantActionI
|
|
|
34
44
|
|
|
35
45
|
this._status = data.jsonApi.attributes.status;
|
|
36
46
|
this._toolName = data.jsonApi.attributes.toolName;
|
|
47
|
+
this._toolArgs = data.jsonApi.attributes.toolArgs;
|
|
48
|
+
this._proposal = data.jsonApi.attributes.proposal;
|
|
37
49
|
this._summary = data.jsonApi.attributes.summary;
|
|
38
50
|
this._resolvedAt = data.jsonApi.attributes.resolvedAt ? new Date(data.jsonApi.attributes.resolvedAt) : undefined;
|
|
39
51
|
this._expiresAt = data.jsonApi.attributes.expiresAt ? new Date(data.jsonApi.attributes.expiresAt) : undefined;
|
|
@@ -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
|
+
});
|