@btst/stack 4.0.0 → 4.1.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/README.md +2 -2
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.cjs +112 -103
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.mjs +112 -103
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +5 -2
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +5 -2
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.cjs +99 -88
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +99 -88
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-message.cjs +12 -3
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-message.mjs +12 -3
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.cjs +6 -2
- package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.mjs +6 -2
- package/dist/packages/stack/src/plugins/blog/client/components/shared/page-wrapper.cjs +3 -2
- package/dist/packages/stack/src/plugins/blog/client/components/shared/page-wrapper.mjs +3 -2
- package/dist/packages/stack/src/plugins/blog/client/components/shared/post-card.cjs +81 -52
- package/dist/packages/stack/src/plugins/blog/client/components/shared/post-card.mjs +82 -53
- package/dist/packages/stack/src/plugins/blog/client/components/shared/posts-list.cjs +3 -2
- package/dist/packages/stack/src/plugins/blog/client/components/shared/posts-list.mjs +3 -2
- package/dist/packages/stack/src/plugins/cms/client/components/pages/content-list-page.internal.cjs +95 -75
- package/dist/packages/stack/src/plugins/cms/client/components/pages/content-list-page.internal.mjs +95 -75
- package/dist/packages/stack/src/plugins/cms/client/components/pages/dashboard-page.internal.cjs +16 -3
- package/dist/packages/stack/src/plugins/cms/client/components/pages/dashboard-page.internal.mjs +16 -3
- package/dist/packages/stack/src/plugins/cms/client/components/shared/page-wrapper.cjs +3 -2
- package/dist/packages/stack/src/plugins/cms/client/components/shared/page-wrapper.mjs +3 -2
- package/dist/packages/stack/src/plugins/cms/utils.cjs +6 -2
- package/dist/packages/stack/src/plugins/cms/utils.mjs +6 -3
- package/dist/packages/stack/src/plugins/form-builder/client/components/forms/form-renderer.cjs +14 -8
- package/dist/packages/stack/src/plugins/form-builder/client/components/forms/form-renderer.mjs +14 -8
- package/dist/packages/stack/src/plugins/form-builder/client/components/pages/form-list-page.internal.cjs +94 -87
- package/dist/packages/stack/src/plugins/form-builder/client/components/pages/form-list-page.internal.mjs +94 -87
- package/dist/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.cjs +81 -74
- package/dist/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.mjs +81 -74
- package/dist/packages/stack/src/plugins/form-builder/client/components/shared/page-wrapper.cjs +3 -2
- package/dist/packages/stack/src/plugins/form-builder/client/components/shared/page-wrapper.mjs +3 -2
- package/dist/packages/stack/src/plugins/form-builder/client/components/shared/pagination.cjs +25 -16
- package/dist/packages/stack/src/plugins/form-builder/client/components/shared/pagination.mjs +25 -16
- package/dist/packages/stack/src/plugins/form-builder/utils.cjs +6 -2
- package/dist/packages/stack/src/plugins/form-builder/utils.mjs +6 -3
- package/dist/plugins/ai-chat/client/components/index.d.cts +1 -1
- package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
- package/dist/plugins/ai-chat/client/components/index.d.ts +1 -1
- package/dist/plugins/ai-chat/client/index.d.cts +2 -2
- package/dist/plugins/ai-chat/client/index.d.mts +2 -2
- package/dist/plugins/ai-chat/client/index.d.ts +2 -2
- package/dist/plugins/blog/api/index.d.cts +2 -2
- package/dist/plugins/blog/api/index.d.mts +2 -2
- package/dist/plugins/blog/api/index.d.ts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
- package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
- package/dist/plugins/blog/client/index.d.cts +20 -2
- package/dist/plugins/blog/client/index.d.mts +20 -2
- package/dist/plugins/blog/client/index.d.ts +20 -2
- package/dist/plugins/blog/query-keys.d.cts +2 -2
- package/dist/plugins/blog/query-keys.d.mts +2 -2
- package/dist/plugins/blog/query-keys.d.ts +2 -2
- package/dist/plugins/cms/api/index.d.cts +1 -1
- package/dist/plugins/cms/api/index.d.mts +1 -1
- package/dist/plugins/cms/api/index.d.ts +1 -1
- package/dist/plugins/cms/client/index.d.cts +18 -0
- package/dist/plugins/cms/client/index.d.mts +18 -0
- package/dist/plugins/cms/client/index.d.ts +18 -0
- package/dist/plugins/cms/query-keys.d.cts +1 -1
- package/dist/plugins/cms/query-keys.d.mts +1 -1
- package/dist/plugins/cms/query-keys.d.ts +1 -1
- package/dist/plugins/form-builder/client/index.d.cts +16 -0
- package/dist/plugins/form-builder/client/index.d.mts +16 -0
- package/dist/plugins/form-builder/client/index.d.ts +16 -0
- package/dist/plugins/ui-builder/client/hooks/index.d.cts +1 -1
- package/dist/plugins/ui-builder/client/hooks/index.d.mts +1 -1
- package/dist/plugins/ui-builder/client/hooks/index.d.ts +1 -1
- package/dist/plugins/ui-builder/client/index.d.cts +1 -1
- package/dist/plugins/ui-builder/client/index.d.mts +1 -1
- package/dist/plugins/ui-builder/client/index.d.ts +1 -1
- package/dist/plugins/ui-builder/query-keys.d.cts +1 -1
- package/dist/plugins/ui-builder/query-keys.d.mts +1 -1
- package/dist/plugins/ui-builder/query-keys.d.ts +1 -1
- package/dist/shared/{stack.CjCSDfWy.d.mts → stack.94kN3pEK.d.mts} +5 -5
- package/dist/shared/{stack.BGzgFx3R.d.cts → stack.BTStHVTy.d.cts} +5 -5
- package/dist/shared/{stack.Dvy7FmaA.d.mts → stack.BggTHKlo.d.mts} +35 -35
- package/dist/shared/{stack.BOZRaVpu.d.cts → stack.CtrnoQgH.d.cts} +35 -35
- package/dist/shared/{stack.Dpg_JjIo.d.ts → stack.DfrRYOIl.d.ts} +5 -5
- package/dist/shared/{stack.DlBoNqYu.d.ts → stack.lPKwd_v0.d.ts} +35 -35
- package/dist/shared/{stack.5_zzCejP.d.cts → stack.rVF0f509.d.cts} +20 -0
- package/dist/shared/{stack.5_zzCejP.d.mts → stack.rVF0f509.d.mts} +20 -0
- package/dist/shared/{stack.5_zzCejP.d.ts → stack.rVF0f509.d.ts} +20 -0
- package/package.json +1 -1
- package/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx +248 -0
- package/src/plugins/ai-chat/client/components/chat-input.tsx +6 -1
- package/src/plugins/ai-chat/client/components/chat-interface.tsx +3 -0
- package/src/plugins/ai-chat/client/components/chat-layout.tsx +9 -2
- package/src/plugins/ai-chat/client/components/chat-message.tsx +13 -5
- package/src/plugins/ai-chat/client/components/chat-sidebar.tsx +6 -2
- package/src/plugins/ai-chat/client/overrides.ts +21 -0
- package/src/plugins/blog/__tests__/classnames-overrides.test.tsx +206 -0
- package/src/plugins/blog/client/components/shared/page-wrapper.tsx +3 -2
- package/src/plugins/blog/client/components/shared/post-card.tsx +32 -12
- package/src/plugins/blog/client/components/shared/posts-list.tsx +3 -2
- package/src/plugins/blog/client/overrides.ts +18 -0
- package/src/plugins/cms/__tests__/classnames-overrides.test.tsx +200 -0
- package/src/plugins/cms/client/components/pages/content-list-page.internal.tsx +17 -4
- package/src/plugins/cms/client/components/pages/dashboard-page.internal.tsx +14 -3
- package/src/plugins/cms/client/components/shared/page-wrapper.tsx +3 -2
- package/src/plugins/cms/client/overrides.ts +19 -0
- package/src/plugins/form-builder/__tests__/classnames-overrides.test.tsx +312 -0
- package/src/plugins/form-builder/client/components/forms/form-renderer.tsx +14 -9
- package/src/plugins/form-builder/client/components/pages/form-list-page.internal.tsx +9 -5
- package/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx +9 -5
- package/src/plugins/form-builder/client/components/shared/page-wrapper.tsx +3 -2
- package/src/plugins/form-builder/client/components/shared/pagination.tsx +7 -1
- package/src/plugins/form-builder/client/overrides.ts +17 -0
- package/dist/shared/{stack.CyalJ0j6.d.ts → stack.BMlTZnZp.d.ts} +16 -16
- package/dist/shared/{stack.D6Pg-ufX.d.cts → stack.BV0uNV4f.d.cts} +16 -16
- package/dist/shared/{stack.Csi-4Mgz.d.cts → stack.CKc_Bais.d.cts} +7 -7
- package/dist/shared/{stack.Csi-4Mgz.d.mts → stack.CKc_Bais.d.mts} +7 -7
- package/dist/shared/{stack.Csi-4Mgz.d.ts → stack.CKc_Bais.d.ts} +7 -7
- package/dist/shared/{stack.B8PG21zX.d.mts → stack.lsbdKRj7.d.mts} +16 -16
|
@@ -204,6 +204,26 @@ interface AiChatPluginOverrides {
|
|
|
204
204
|
* ```
|
|
205
205
|
*/
|
|
206
206
|
toolRenderers?: Record<string, ToolCallRenderer>;
|
|
207
|
+
/**
|
|
208
|
+
* Optional class names merged onto specific UI elements.
|
|
209
|
+
* Use these for targeted styling without replacing whole components.
|
|
210
|
+
*/
|
|
211
|
+
classNames?: {
|
|
212
|
+
/** Root of `ChatLayout` (full-page or widget mode) */
|
|
213
|
+
container?: string;
|
|
214
|
+
/** Conversation sidebar root */
|
|
215
|
+
sidebar?: string;
|
|
216
|
+
/** Wrapper around the list of messages */
|
|
217
|
+
messageList?: string;
|
|
218
|
+
/** Each message row (user and assistant) */
|
|
219
|
+
message?: string;
|
|
220
|
+
/** The bubble of a user message */
|
|
221
|
+
userMessage?: string;
|
|
222
|
+
/** The bubble of an assistant message */
|
|
223
|
+
assistantMessage?: string;
|
|
224
|
+
/** The composer form at the bottom of the chat */
|
|
225
|
+
input?: string;
|
|
226
|
+
};
|
|
207
227
|
/**
|
|
208
228
|
* Called when a route is rendered
|
|
209
229
|
* @param routeName - Name of the route (e.g., 'chat', 'chatConversation')
|
package/package.json
CHANGED
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
3
|
+
import { act } from "react";
|
|
4
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
5
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
6
|
+
// Core primitives MUST be imported from the package entry (not relative src
|
|
7
|
+
// paths) so they share module identity — and React context — with the plugin
|
|
8
|
+
// components, which resolve `@btst/stack/*` via package self-reference.
|
|
9
|
+
import { StackProvider } from "@btst/stack/context";
|
|
10
|
+
import type { UIMessage } from "ai";
|
|
11
|
+
import { ChatInput } from "../client/components/chat-input";
|
|
12
|
+
import { ChatLayout } from "../client/components/chat-layout";
|
|
13
|
+
import { ChatMessage } from "../client/components/chat-message";
|
|
14
|
+
import { aiChatClientPlugin } from "../client/plugin";
|
|
15
|
+
import type { SerializedConversation } from "../types";
|
|
16
|
+
import { createTestClientStack } from "../../../__tests__/client-stack-test-utils";
|
|
17
|
+
|
|
18
|
+
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
19
|
+
(globalThis as any).ResizeObserver ??= class {
|
|
20
|
+
observe() {}
|
|
21
|
+
unobserve() {}
|
|
22
|
+
disconnect() {}
|
|
23
|
+
};
|
|
24
|
+
Element.prototype.scrollIntoView ??= () => {};
|
|
25
|
+
|
|
26
|
+
const mocks = vi.hoisted(() => ({
|
|
27
|
+
useChat: vi.fn(),
|
|
28
|
+
useConversation: vi.fn(),
|
|
29
|
+
useConversations: vi.fn(),
|
|
30
|
+
useRenameConversationForm: vi.fn(),
|
|
31
|
+
useDeleteConversation: vi.fn(),
|
|
32
|
+
useAiChatIdentityPartition: vi.fn(),
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
vi.mock("@ai-sdk/react", () => ({ useChat: mocks.useChat }));
|
|
36
|
+
vi.mock("../client/hooks/chat-hooks", () => ({
|
|
37
|
+
useAiChatIdentityPartition: mocks.useAiChatIdentityPartition,
|
|
38
|
+
useConversation: mocks.useConversation,
|
|
39
|
+
useConversations: mocks.useConversations,
|
|
40
|
+
useRenameConversationForm: mocks.useRenameConversationForm,
|
|
41
|
+
useDeleteConversation: mocks.useDeleteConversation,
|
|
42
|
+
}));
|
|
43
|
+
vi.mock("../client/context/page-ai-context", () => ({
|
|
44
|
+
usePageAIContext: () => undefined,
|
|
45
|
+
}));
|
|
46
|
+
// Markdown rendering is unrelated to class wiring and drags in highlight/katex.
|
|
47
|
+
vi.mock("@workspace/ui/components/markdown-content", () => ({
|
|
48
|
+
MarkdownContent: ({ content }: { content: string }) => <div>{content}</div>,
|
|
49
|
+
}));
|
|
50
|
+
|
|
51
|
+
const CLASS_NAMES = {
|
|
52
|
+
container: "bg-red-100",
|
|
53
|
+
sidebar: "bg-blue-100",
|
|
54
|
+
messageList: "bg-yellow-50",
|
|
55
|
+
message: "text-purple-700",
|
|
56
|
+
userMessage: "border-4 border-orange-500",
|
|
57
|
+
assistantMessage: "border-4 border-green-500",
|
|
58
|
+
input: "bg-pink-100",
|
|
59
|
+
} as const;
|
|
60
|
+
|
|
61
|
+
const conversation: SerializedConversation = {
|
|
62
|
+
id: "conv-1",
|
|
63
|
+
userId: "owner-1",
|
|
64
|
+
title: "First conversation",
|
|
65
|
+
createdAt: new Date("2024-01-01").toISOString(),
|
|
66
|
+
updatedAt: new Date("2024-01-02").toISOString(),
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
const userMessage: UIMessage = {
|
|
70
|
+
id: "user-1",
|
|
71
|
+
role: "user",
|
|
72
|
+
parts: [{ type: "text", text: "Hi there" }],
|
|
73
|
+
};
|
|
74
|
+
const assistantMessage: UIMessage = {
|
|
75
|
+
id: "assistant-1",
|
|
76
|
+
role: "assistant",
|
|
77
|
+
parts: [{ type: "text", text: "Hello back" }],
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
let container: HTMLDivElement;
|
|
81
|
+
let root: Root;
|
|
82
|
+
let queryClient: QueryClient;
|
|
83
|
+
|
|
84
|
+
beforeEach(() => {
|
|
85
|
+
container = document.createElement("div");
|
|
86
|
+
document.body.appendChild(container);
|
|
87
|
+
root = createRoot(container);
|
|
88
|
+
queryClient = new QueryClient({
|
|
89
|
+
defaultOptions: { queries: { retry: false } },
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
mocks.useAiChatIdentityPartition.mockReturnValue("anonymous");
|
|
93
|
+
mocks.useChat.mockReturnValue({
|
|
94
|
+
messages: [userMessage, assistantMessage],
|
|
95
|
+
sendMessage: vi.fn(),
|
|
96
|
+
status: "ready",
|
|
97
|
+
error: null,
|
|
98
|
+
setMessages: vi.fn(),
|
|
99
|
+
regenerate: vi.fn(),
|
|
100
|
+
addToolOutput: vi.fn(),
|
|
101
|
+
stop: vi.fn(),
|
|
102
|
+
});
|
|
103
|
+
mocks.useConversation.mockReturnValue({
|
|
104
|
+
conversation: null,
|
|
105
|
+
isLoading: false,
|
|
106
|
+
error: null,
|
|
107
|
+
refetch: vi.fn(),
|
|
108
|
+
});
|
|
109
|
+
mocks.useConversations.mockReturnValue({
|
|
110
|
+
conversations: [conversation],
|
|
111
|
+
isLoading: false,
|
|
112
|
+
error: null,
|
|
113
|
+
refetch: vi.fn(),
|
|
114
|
+
});
|
|
115
|
+
mocks.useRenameConversationForm.mockReturnValue({
|
|
116
|
+
submit: vi.fn(),
|
|
117
|
+
isSubmitting: false,
|
|
118
|
+
error: null,
|
|
119
|
+
fieldErrors: {},
|
|
120
|
+
clearErrors: vi.fn(),
|
|
121
|
+
});
|
|
122
|
+
mocks.useDeleteConversation.mockReturnValue({
|
|
123
|
+
mutateAsync: vi.fn(),
|
|
124
|
+
isPending: false,
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
afterEach(async () => {
|
|
129
|
+
await act(async () => {
|
|
130
|
+
root.unmount();
|
|
131
|
+
});
|
|
132
|
+
container.remove();
|
|
133
|
+
vi.clearAllMocks();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
async function render(ui: React.ReactElement) {
|
|
137
|
+
await act(async () => {
|
|
138
|
+
root.render(
|
|
139
|
+
<QueryClientProvider client={queryClient}>
|
|
140
|
+
<StackProvider
|
|
141
|
+
stack={createTestClientStack(
|
|
142
|
+
{ aiChat: aiChatClientPlugin() },
|
|
143
|
+
queryClient,
|
|
144
|
+
)}
|
|
145
|
+
overrides={{ aiChat: { classNames: CLASS_NAMES } }}
|
|
146
|
+
>
|
|
147
|
+
{ui}
|
|
148
|
+
</StackProvider>
|
|
149
|
+
</QueryClientProvider>,
|
|
150
|
+
);
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function query(selector: string): HTMLElement {
|
|
155
|
+
const el = container.querySelector<HTMLElement>(selector);
|
|
156
|
+
expect(el, `expected to find ${selector}`).toBeTruthy();
|
|
157
|
+
return el!;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
describe("ai-chat classNames overrides (issue #36)", () => {
|
|
161
|
+
it("applies message, userMessage and assistantMessage to a user message", async () => {
|
|
162
|
+
await render(<ChatMessage message={userMessage} />);
|
|
163
|
+
|
|
164
|
+
const row = query('[data-testid="chat-message"][data-role="user"]');
|
|
165
|
+
expect(row.className).toContain(CLASS_NAMES.message);
|
|
166
|
+
expect(row.className).toContain("justify-end");
|
|
167
|
+
|
|
168
|
+
const bubble = query('[data-testid="chat-message-bubble"]');
|
|
169
|
+
expect(bubble.className).toContain("border-orange-500");
|
|
170
|
+
expect(bubble.className).not.toContain("border-green-500");
|
|
171
|
+
// The role-specific base styling is preserved.
|
|
172
|
+
expect(bubble.className).toContain("bg-primary");
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
it("applies assistantMessage (not userMessage) to an assistant message", async () => {
|
|
176
|
+
await render(<ChatMessage message={assistantMessage} />);
|
|
177
|
+
|
|
178
|
+
const row = query('[data-testid="chat-message"][data-role="assistant"]');
|
|
179
|
+
expect(row.className).toContain(CLASS_NAMES.message);
|
|
180
|
+
expect(row.className).toContain("justify-start");
|
|
181
|
+
|
|
182
|
+
const bubble = query('[data-testid="chat-message-bubble"]');
|
|
183
|
+
expect(bubble.className).toContain("border-green-500");
|
|
184
|
+
expect(bubble.className).not.toContain("border-orange-500");
|
|
185
|
+
expect(bubble.className).toContain("bg-muted");
|
|
186
|
+
});
|
|
187
|
+
|
|
188
|
+
it("applies classNames.input to the composer form", async () => {
|
|
189
|
+
await render(
|
|
190
|
+
<ChatInput
|
|
191
|
+
handleInputChange={() => {}}
|
|
192
|
+
handleSubmit={() => {}}
|
|
193
|
+
isLoading={false}
|
|
194
|
+
/>,
|
|
195
|
+
);
|
|
196
|
+
|
|
197
|
+
const form = query('[data-testid="chat-input-form"]');
|
|
198
|
+
expect(form.tagName).toBe("FORM");
|
|
199
|
+
expect(form.className).toContain(CLASS_NAMES.input);
|
|
200
|
+
expect(form.className).toContain("space-y-2");
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("applies container, sidebar and messageList in the full layout", async () => {
|
|
204
|
+
await render(<ChatLayout conversationId={conversation.id} />);
|
|
205
|
+
|
|
206
|
+
const layout = query('[data-testid="chat-layout"]');
|
|
207
|
+
expect(layout.className).toContain(CLASS_NAMES.container);
|
|
208
|
+
expect(layout.className).toContain("overflow-hidden");
|
|
209
|
+
|
|
210
|
+
const sidebar = query('[data-testid="chat-sidebar"]');
|
|
211
|
+
expect(sidebar.className).toContain(CLASS_NAMES.sidebar);
|
|
212
|
+
expect(sidebar.className).toContain("border-r");
|
|
213
|
+
|
|
214
|
+
const list = query('[data-testid="chat-message-list"]');
|
|
215
|
+
expect(list.className).toContain(CLASS_NAMES.messageList);
|
|
216
|
+
expect(list.className).toContain("flex-col");
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("applies classNames.container to the widget root", async () => {
|
|
220
|
+
await render(<ChatLayout layout="widget" />);
|
|
221
|
+
|
|
222
|
+
const trigger = query('[data-testid="widget-trigger"]');
|
|
223
|
+
const widgetRoot = trigger.parentElement!;
|
|
224
|
+
expect(widgetRoot.className).toContain(CLASS_NAMES.container);
|
|
225
|
+
expect(widgetRoot.className).toContain("items-end");
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
it("leaves base classes untouched when no classNames override is supplied", async () => {
|
|
229
|
+
await act(async () => {
|
|
230
|
+
root.render(
|
|
231
|
+
<QueryClientProvider client={queryClient}>
|
|
232
|
+
<StackProvider
|
|
233
|
+
stack={createTestClientStack(
|
|
234
|
+
{ aiChat: aiChatClientPlugin() },
|
|
235
|
+
queryClient,
|
|
236
|
+
)}
|
|
237
|
+
>
|
|
238
|
+
<ChatMessage message={userMessage} />
|
|
239
|
+
</StackProvider>
|
|
240
|
+
</QueryClientProvider>,
|
|
241
|
+
);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
const bubble = query('[data-testid="chat-message-bubble"]');
|
|
245
|
+
expect(bubble.className).toContain("bg-primary");
|
|
246
|
+
expect(bubble.className).not.toContain("border-orange-500");
|
|
247
|
+
});
|
|
248
|
+
});
|
|
@@ -93,6 +93,7 @@ export function ChatInput({
|
|
|
93
93
|
uploadFile,
|
|
94
94
|
localization: customLocalization,
|
|
95
95
|
allowedFileTypes,
|
|
96
|
+
classNames,
|
|
96
97
|
} = usePluginOverrides<AiChatPluginOverrides, Partial<AiChatPluginOverrides>>(
|
|
97
98
|
"aiChat",
|
|
98
99
|
{},
|
|
@@ -314,7 +315,11 @@ export function ChatInput({
|
|
|
314
315
|
)}
|
|
315
316
|
</PermissionCheck>
|
|
316
317
|
)}
|
|
317
|
-
<form
|
|
318
|
+
<form
|
|
319
|
+
onSubmit={handleFormSubmit}
|
|
320
|
+
className={cn("space-y-2", classNames?.input)}
|
|
321
|
+
data-testid="chat-input-form"
|
|
322
|
+
>
|
|
318
323
|
{/* Attached Files Preview */}
|
|
319
324
|
{attachedFiles.length > 0 && (
|
|
320
325
|
<div className="flex flex-wrap gap-2">
|
|
@@ -263,6 +263,7 @@ export function ChatInterface({
|
|
|
263
263
|
localization: customLocalization,
|
|
264
264
|
showAttribution,
|
|
265
265
|
chatSuggestions,
|
|
266
|
+
classNames,
|
|
266
267
|
} = usePluginOverrides<AiChatPluginOverrides, Partial<AiChatPluginOverrides>>(
|
|
267
268
|
"aiChat",
|
|
268
269
|
{ showAttribution: true },
|
|
@@ -1139,7 +1140,9 @@ export function ChatInterface({
|
|
|
1139
1140
|
className={cn(
|
|
1140
1141
|
"flex flex-col p-4",
|
|
1141
1142
|
isWidget ? "max-w-full" : "max-w-3xl mx-auto w-full",
|
|
1143
|
+
classNames?.messageList,
|
|
1142
1144
|
)}
|
|
1145
|
+
data-testid="chat-message-list"
|
|
1143
1146
|
>
|
|
1144
1147
|
{messages.length === 0 ? (
|
|
1145
1148
|
<div className="flex flex-col h-full min-h-[300px]">
|
|
@@ -86,7 +86,7 @@ export function ChatLayout(props: ChatLayoutProps) {
|
|
|
86
86
|
onMessagesChange,
|
|
87
87
|
onClear,
|
|
88
88
|
} = props;
|
|
89
|
-
const { localization } = usePluginOverrides<
|
|
89
|
+
const { localization, classNames } = usePluginOverrides<
|
|
90
90
|
AiChatPluginOverrides,
|
|
91
91
|
Partial<AiChatPluginOverrides>
|
|
92
92
|
>("aiChat", {});
|
|
@@ -133,7 +133,13 @@ export function ChatLayout(props: ChatLayoutProps) {
|
|
|
133
133
|
|
|
134
134
|
if (layout === "widget") {
|
|
135
135
|
return (
|
|
136
|
-
<div
|
|
136
|
+
<div
|
|
137
|
+
className={cn(
|
|
138
|
+
"flex flex-col items-end gap-3",
|
|
139
|
+
className,
|
|
140
|
+
classNames?.container,
|
|
141
|
+
)}
|
|
142
|
+
>
|
|
137
143
|
{/* Chat panel — always mounted to preserve conversation state, hidden when closed */}
|
|
138
144
|
<div
|
|
139
145
|
className={cn(
|
|
@@ -238,6 +244,7 @@ export function ChatLayout(props: ChatLayoutProps) {
|
|
|
238
244
|
className={cn(
|
|
239
245
|
"flex h-[calc(100vh-4rem)] w-full overflow-hidden",
|
|
240
246
|
className,
|
|
247
|
+
classNames?.container,
|
|
241
248
|
)}
|
|
242
249
|
data-testid="chat-layout"
|
|
243
250
|
>
|
|
@@ -133,11 +133,14 @@ export function ChatMessage({
|
|
|
133
133
|
}: ChatMessageProps) {
|
|
134
134
|
const isUser = message.role === "user";
|
|
135
135
|
|
|
136
|
-
const {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
136
|
+
const {
|
|
137
|
+
localization: customLocalization,
|
|
138
|
+
toolRenderers,
|
|
139
|
+
classNames,
|
|
140
|
+
} = usePluginOverrides<AiChatPluginOverrides, Partial<AiChatPluginOverrides>>(
|
|
141
|
+
"aiChat",
|
|
142
|
+
{},
|
|
143
|
+
);
|
|
141
144
|
const { router } = useStack();
|
|
142
145
|
const Link = router?.Link;
|
|
143
146
|
const Image = router?.Image;
|
|
@@ -265,7 +268,10 @@ export function ChatMessage({
|
|
|
265
268
|
"flex gap-3 w-full",
|
|
266
269
|
isCompact ? "mb-3" : "mb-4",
|
|
267
270
|
isUser ? "justify-end" : "justify-start",
|
|
271
|
+
classNames?.message,
|
|
268
272
|
)}
|
|
273
|
+
data-testid="chat-message"
|
|
274
|
+
data-role={message.role}
|
|
269
275
|
aria-label={
|
|
270
276
|
isUser
|
|
271
277
|
? tr("A11Y_USER_MESSAGE", "aiChat.a11y.userMessage", "Your message")
|
|
@@ -295,7 +301,9 @@ export function ChatMessage({
|
|
|
295
301
|
"rounded-lg",
|
|
296
302
|
isCompact ? "px-3 py-2" : "px-4 py-3",
|
|
297
303
|
isUser ? "bg-primary text-primary-foreground" : "bg-muted",
|
|
304
|
+
isUser ? classNames?.userMessage : classNames?.assistantMessage,
|
|
298
305
|
)}
|
|
306
|
+
data-testid="chat-message-bubble"
|
|
299
307
|
>
|
|
300
308
|
{isUser ? (
|
|
301
309
|
// User messages: files + images + text (or edit mode)
|
|
@@ -67,7 +67,7 @@ export function ChatSidebar({
|
|
|
67
67
|
onNewChat,
|
|
68
68
|
className,
|
|
69
69
|
}: ChatSidebarProps) {
|
|
70
|
-
const { localization: customLocalization } = usePluginOverrides<
|
|
70
|
+
const { localization: customLocalization, classNames } = usePluginOverrides<
|
|
71
71
|
AiChatPluginOverrides,
|
|
72
72
|
Partial<AiChatPluginOverrides>
|
|
73
73
|
>("aiChat", {});
|
|
@@ -208,7 +208,11 @@ export function ChatSidebar({
|
|
|
208
208
|
return (
|
|
209
209
|
<div
|
|
210
210
|
data-testid="chat-sidebar"
|
|
211
|
-
className={cn(
|
|
211
|
+
className={cn(
|
|
212
|
+
"flex flex-col h-full bg-muted/30 border-r",
|
|
213
|
+
className,
|
|
214
|
+
classNames?.sidebar,
|
|
215
|
+
)}
|
|
212
216
|
>
|
|
213
217
|
{/* Header */}
|
|
214
218
|
<div className="p-4 border-b">
|
|
@@ -201,6 +201,27 @@ export interface AiChatPluginOverrides {
|
|
|
201
201
|
*/
|
|
202
202
|
toolRenderers?: Record<string, ToolCallRenderer>;
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* Optional class names merged onto specific UI elements.
|
|
206
|
+
* Use these for targeted styling without replacing whole components.
|
|
207
|
+
*/
|
|
208
|
+
classNames?: {
|
|
209
|
+
/** Root of `ChatLayout` (full-page or widget mode) */
|
|
210
|
+
container?: string;
|
|
211
|
+
/** Conversation sidebar root */
|
|
212
|
+
sidebar?: string;
|
|
213
|
+
/** Wrapper around the list of messages */
|
|
214
|
+
messageList?: string;
|
|
215
|
+
/** Each message row (user and assistant) */
|
|
216
|
+
message?: string;
|
|
217
|
+
/** The bubble of a user message */
|
|
218
|
+
userMessage?: string;
|
|
219
|
+
/** The bubble of an assistant message */
|
|
220
|
+
assistantMessage?: string;
|
|
221
|
+
/** The composer form at the bottom of the chat */
|
|
222
|
+
input?: string;
|
|
223
|
+
};
|
|
224
|
+
|
|
204
225
|
// ============== Lifecycle Hooks (optional) ==============
|
|
205
226
|
|
|
206
227
|
/**
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { createRoot, type Root } from "react-dom/client";
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
// Core primitives MUST be imported from the package entry (not relative src
|
|
6
|
+
// paths) so they share module identity — and React context — with the blog
|
|
7
|
+
// components, which resolve `@btst/stack/*` via package self-reference.
|
|
8
|
+
import { StackProvider } from "@btst/stack/context";
|
|
9
|
+
import { PageWrapper } from "../client/components/shared/page-wrapper";
|
|
10
|
+
import { PostsList } from "../client/components/shared/posts-list";
|
|
11
|
+
import type { SerializedPost } from "../types";
|
|
12
|
+
import { createTestClientStack } from "../../../__tests__/client-stack-test-utils";
|
|
13
|
+
import { blogClientPlugin } from "../client/plugin";
|
|
14
|
+
|
|
15
|
+
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
16
|
+
|
|
17
|
+
// jsdom lacks these APIs used by Radix / cmdk inside SearchInput
|
|
18
|
+
(globalThis as any).ResizeObserver ??= class {
|
|
19
|
+
observe() {}
|
|
20
|
+
unobserve() {}
|
|
21
|
+
disconnect() {}
|
|
22
|
+
};
|
|
23
|
+
Element.prototype.scrollIntoView ??= () => {};
|
|
24
|
+
|
|
25
|
+
const hooks = vi.hoisted(() => ({
|
|
26
|
+
useSuspensePost: vi.fn(),
|
|
27
|
+
useDeletePost: vi.fn(),
|
|
28
|
+
usePostForm: vi.fn(),
|
|
29
|
+
usePostSearch: vi.fn(),
|
|
30
|
+
useSuspensePosts: vi.fn(),
|
|
31
|
+
useTags: vi.fn(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock("../client/hooks/blog-hooks", () => hooks);
|
|
35
|
+
|
|
36
|
+
const CLASS_NAMES = {
|
|
37
|
+
container: "bg-red-100",
|
|
38
|
+
postCard: "bg-blue-100",
|
|
39
|
+
postTitle: "text-purple-700",
|
|
40
|
+
postMeta: "text-green-600",
|
|
41
|
+
tagsList: "bg-yellow-50",
|
|
42
|
+
pagination: "border-4 border-orange-500",
|
|
43
|
+
} as const;
|
|
44
|
+
|
|
45
|
+
const post: SerializedPost = {
|
|
46
|
+
id: "p1",
|
|
47
|
+
title: "Hello World",
|
|
48
|
+
content: "# Hello",
|
|
49
|
+
excerpt: "An excerpt",
|
|
50
|
+
slug: "hello-world",
|
|
51
|
+
published: true,
|
|
52
|
+
image: "",
|
|
53
|
+
tags: [{ id: "t1", name: "Release", slug: "release" }],
|
|
54
|
+
authorId: null,
|
|
55
|
+
publishedAt: new Date("2024-01-01").toISOString(),
|
|
56
|
+
createdAt: new Date("2024-01-01").toISOString(),
|
|
57
|
+
updatedAt: new Date("2024-01-01").toISOString(),
|
|
58
|
+
} as unknown as SerializedPost;
|
|
59
|
+
|
|
60
|
+
let container: HTMLDivElement;
|
|
61
|
+
let root: Root;
|
|
62
|
+
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
container = document.createElement("div");
|
|
65
|
+
document.body.appendChild(container);
|
|
66
|
+
root = createRoot(container);
|
|
67
|
+
|
|
68
|
+
hooks.usePostSearch.mockReturnValue({ data: [], isLoading: false });
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
afterEach(async () => {
|
|
72
|
+
await act(async () => {
|
|
73
|
+
root.unmount();
|
|
74
|
+
});
|
|
75
|
+
container.remove();
|
|
76
|
+
vi.clearAllMocks();
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
async function render(ui: React.ReactElement) {
|
|
80
|
+
await act(async () => {
|
|
81
|
+
root.render(ui);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function withBlogOverrides(children: React.ReactNode) {
|
|
86
|
+
return (
|
|
87
|
+
<StackProvider
|
|
88
|
+
stack={createTestClientStack({ blog: blogClientPlugin() })}
|
|
89
|
+
overrides={{
|
|
90
|
+
blog: {
|
|
91
|
+
uploadImage: async () => "",
|
|
92
|
+
classNames: CLASS_NAMES,
|
|
93
|
+
},
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{children}
|
|
97
|
+
</StackProvider>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function query(selector: string): HTMLElement {
|
|
102
|
+
const el = container.querySelector<HTMLElement>(selector);
|
|
103
|
+
expect(el, `expected to find ${selector}`).toBeTruthy();
|
|
104
|
+
return el!;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
describe("blog classNames overrides (issue #36)", () => {
|
|
108
|
+
it("applies classNames.container to the page container", async () => {
|
|
109
|
+
await render(
|
|
110
|
+
withBlogOverrides(
|
|
111
|
+
<PageWrapper testId="blog-container">
|
|
112
|
+
<span>content</span>
|
|
113
|
+
</PageWrapper>,
|
|
114
|
+
),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const el = query('[data-testid="blog-container"]');
|
|
118
|
+
expect(el.className).toContain(CLASS_NAMES.container);
|
|
119
|
+
// The override must merge with the layout's own classes, not replace them.
|
|
120
|
+
expect(el.className).toContain("container");
|
|
121
|
+
expect(el.className).toContain("mx-auto");
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
it("applies classNames.postCard, postTitle, postMeta and tagsList inside a post card", async () => {
|
|
125
|
+
await render(withBlogOverrides(<PostsList posts={[post]} />));
|
|
126
|
+
|
|
127
|
+
// postCard — anchored on the Card primitive's structural slot.
|
|
128
|
+
const card = query('[data-slot="card"]');
|
|
129
|
+
expect(card.className).toContain(CLASS_NAMES.postCard);
|
|
130
|
+
expect(card.className).toContain("group");
|
|
131
|
+
|
|
132
|
+
// postTitle — anchored on the CardTitle primitive's structural slot.
|
|
133
|
+
const title = query('[data-slot="card-title"]');
|
|
134
|
+
expect(title.className).toContain(CLASS_NAMES.postTitle);
|
|
135
|
+
expect(title.className).toContain("line-clamp-3");
|
|
136
|
+
expect(title.textContent).toBe(post.title);
|
|
137
|
+
|
|
138
|
+
// postMeta — the wrapper holding the published date.
|
|
139
|
+
const meta = query("time").parentElement!;
|
|
140
|
+
expect(meta.className).toContain(CLASS_NAMES.postMeta);
|
|
141
|
+
// `cn` runs tailwind-merge, so the conflicting `text-muted-foreground`
|
|
142
|
+
// is intentionally replaced by the override; non-conflicting base
|
|
143
|
+
// classes must survive.
|
|
144
|
+
expect(meta.className).toContain("items-center");
|
|
145
|
+
expect(meta.className).not.toContain("text-muted-foreground");
|
|
146
|
+
|
|
147
|
+
// tagsList — the wrapper immediately following the metadata block.
|
|
148
|
+
const tags = meta.nextElementSibling as HTMLElement;
|
|
149
|
+
expect(tags.className).toContain(CLASS_NAMES.tagsList);
|
|
150
|
+
expect(tags.className).toContain("flex-wrap");
|
|
151
|
+
expect(tags.textContent).toContain("Release");
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it("applies classNames.pagination to the load-more wrapper", async () => {
|
|
155
|
+
await render(
|
|
156
|
+
withBlogOverrides(
|
|
157
|
+
<PostsList posts={[post]} onLoadMore={() => {}} hasMore />,
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
const button = [...container.querySelectorAll("button")].find((b) =>
|
|
162
|
+
b.textContent?.includes("Load more posts"),
|
|
163
|
+
);
|
|
164
|
+
expect(button, "expected a load-more button").toBeTruthy();
|
|
165
|
+
|
|
166
|
+
const pagination = button!.parentElement!;
|
|
167
|
+
expect(pagination.className).toContain("border-4");
|
|
168
|
+
expect(pagination.className).toContain("border-orange-500");
|
|
169
|
+
expect(pagination.className).toContain("justify-center");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("leaves base classes untouched when no classNames override is supplied", async () => {
|
|
173
|
+
await render(
|
|
174
|
+
<StackProvider
|
|
175
|
+
stack={createTestClientStack({ blog: blogClientPlugin() })}
|
|
176
|
+
overrides={{ blog: { uploadImage: async () => "" } }}
|
|
177
|
+
>
|
|
178
|
+
<PostsList
|
|
179
|
+
posts={[
|
|
180
|
+
{
|
|
181
|
+
...post,
|
|
182
|
+
tags: [
|
|
183
|
+
...post.tags!,
|
|
184
|
+
{ ...post.tags![0]!, id: "t2", name: "News", slug: "news" },
|
|
185
|
+
],
|
|
186
|
+
},
|
|
187
|
+
]}
|
|
188
|
+
onLoadMore={() => {}}
|
|
189
|
+
hasMore
|
|
190
|
+
/>
|
|
191
|
+
</StackProvider>,
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const card = query('[data-slot="card"]');
|
|
195
|
+
expect(card.className).toContain("group");
|
|
196
|
+
expect(card.className).not.toContain(CLASS_NAMES.postCard);
|
|
197
|
+
const metadata = query("time").parentElement!;
|
|
198
|
+
const tags = metadata.parentElement!.querySelectorAll(
|
|
199
|
+
'[data-slot="badge"]',
|
|
200
|
+
);
|
|
201
|
+
expect(tags).toHaveLength(2);
|
|
202
|
+
for (const tag of tags) {
|
|
203
|
+
expect(tag.parentElement).toBe(metadata.parentElement);
|
|
204
|
+
}
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -4,6 +4,7 @@ import { usePluginOverrides } from "@btst/stack/context";
|
|
|
4
4
|
import { PageWrapper as SharedPageWrapper } from "@workspace/ui/components/page-wrapper";
|
|
5
5
|
import type { BlogPluginOverrides } from "../../overrides";
|
|
6
6
|
import { BLOG_PLUGIN_ID } from "../../constants";
|
|
7
|
+
import { cn } from "../../../utils";
|
|
7
8
|
|
|
8
9
|
export function PageWrapper({
|
|
9
10
|
children,
|
|
@@ -14,7 +15,7 @@ export function PageWrapper({
|
|
|
14
15
|
className?: string;
|
|
15
16
|
testId?: string;
|
|
16
17
|
}) {
|
|
17
|
-
const { showAttribution } = usePluginOverrides<
|
|
18
|
+
const { showAttribution, classNames } = usePluginOverrides<
|
|
18
19
|
BlogPluginOverrides,
|
|
19
20
|
Partial<BlogPluginOverrides>
|
|
20
21
|
>(BLOG_PLUGIN_ID, {
|
|
@@ -23,7 +24,7 @@ export function PageWrapper({
|
|
|
23
24
|
|
|
24
25
|
return (
|
|
25
26
|
<SharedPageWrapper
|
|
26
|
-
className={className}
|
|
27
|
+
className={cn(className, classNames?.container)}
|
|
27
28
|
testId={testId}
|
|
28
29
|
showAttribution={showAttribution}
|
|
29
30
|
>
|