@btst/stack 4.0.0 → 4.1.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.
Files changed (92) hide show
  1. package/README.md +2 -2
  2. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.cjs +112 -103
  3. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-input.mjs +112 -103
  4. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.cjs +5 -2
  5. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-interface.mjs +5 -2
  6. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.cjs +99 -88
  7. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-layout.mjs +99 -88
  8. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-message.cjs +12 -3
  9. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-message.mjs +12 -3
  10. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.cjs +6 -2
  11. package/dist/packages/stack/src/plugins/ai-chat/client/components/chat-sidebar.mjs +6 -2
  12. package/dist/packages/stack/src/plugins/blog/client/components/shared/page-wrapper.cjs +3 -2
  13. package/dist/packages/stack/src/plugins/blog/client/components/shared/page-wrapper.mjs +3 -2
  14. package/dist/packages/stack/src/plugins/blog/client/components/shared/post-card.cjs +81 -52
  15. package/dist/packages/stack/src/plugins/blog/client/components/shared/post-card.mjs +82 -53
  16. package/dist/packages/stack/src/plugins/blog/client/components/shared/posts-list.cjs +3 -2
  17. package/dist/packages/stack/src/plugins/blog/client/components/shared/posts-list.mjs +3 -2
  18. package/dist/packages/stack/src/plugins/cms/client/components/pages/content-list-page.internal.cjs +95 -75
  19. package/dist/packages/stack/src/plugins/cms/client/components/pages/content-list-page.internal.mjs +95 -75
  20. package/dist/packages/stack/src/plugins/cms/client/components/pages/dashboard-page.internal.cjs +16 -3
  21. package/dist/packages/stack/src/plugins/cms/client/components/pages/dashboard-page.internal.mjs +16 -3
  22. package/dist/packages/stack/src/plugins/cms/client/components/shared/page-wrapper.cjs +3 -2
  23. package/dist/packages/stack/src/plugins/cms/client/components/shared/page-wrapper.mjs +3 -2
  24. package/dist/packages/stack/src/plugins/cms/utils.cjs +6 -2
  25. package/dist/packages/stack/src/plugins/cms/utils.mjs +6 -3
  26. package/dist/packages/stack/src/plugins/form-builder/client/components/forms/form-renderer.cjs +14 -8
  27. package/dist/packages/stack/src/plugins/form-builder/client/components/forms/form-renderer.mjs +14 -8
  28. package/dist/packages/stack/src/plugins/form-builder/client/components/pages/form-list-page.internal.cjs +94 -87
  29. package/dist/packages/stack/src/plugins/form-builder/client/components/pages/form-list-page.internal.mjs +94 -87
  30. package/dist/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.cjs +81 -74
  31. package/dist/packages/stack/src/plugins/form-builder/client/components/pages/submissions-page.internal.mjs +81 -74
  32. package/dist/packages/stack/src/plugins/form-builder/client/components/shared/page-wrapper.cjs +3 -2
  33. package/dist/packages/stack/src/plugins/form-builder/client/components/shared/page-wrapper.mjs +3 -2
  34. package/dist/packages/stack/src/plugins/form-builder/client/components/shared/pagination.cjs +25 -16
  35. package/dist/packages/stack/src/plugins/form-builder/client/components/shared/pagination.mjs +25 -16
  36. package/dist/packages/stack/src/plugins/form-builder/utils.cjs +6 -2
  37. package/dist/packages/stack/src/plugins/form-builder/utils.mjs +6 -3
  38. package/dist/plugins/ai-chat/client/components/index.d.cts +1 -1
  39. package/dist/plugins/ai-chat/client/components/index.d.mts +1 -1
  40. package/dist/plugins/ai-chat/client/components/index.d.ts +1 -1
  41. package/dist/plugins/ai-chat/client/index.d.cts +2 -2
  42. package/dist/plugins/ai-chat/client/index.d.mts +2 -2
  43. package/dist/plugins/ai-chat/client/index.d.ts +2 -2
  44. package/dist/plugins/blog/client/index.d.cts +18 -0
  45. package/dist/plugins/blog/client/index.d.mts +18 -0
  46. package/dist/plugins/blog/client/index.d.ts +18 -0
  47. package/dist/plugins/cms/client/index.d.cts +18 -0
  48. package/dist/plugins/cms/client/index.d.mts +18 -0
  49. package/dist/plugins/cms/client/index.d.ts +18 -0
  50. package/dist/plugins/form-builder/api/index.d.cts +1 -1
  51. package/dist/plugins/form-builder/api/index.d.mts +1 -1
  52. package/dist/plugins/form-builder/api/index.d.ts +1 -1
  53. package/dist/plugins/form-builder/client/hooks/index.d.cts +1 -1
  54. package/dist/plugins/form-builder/client/hooks/index.d.mts +1 -1
  55. package/dist/plugins/form-builder/client/hooks/index.d.ts +1 -1
  56. package/dist/plugins/form-builder/client/index.d.cts +16 -0
  57. package/dist/plugins/form-builder/client/index.d.mts +16 -0
  58. package/dist/plugins/form-builder/client/index.d.ts +16 -0
  59. package/dist/plugins/form-builder/query-keys.d.cts +1 -1
  60. package/dist/plugins/form-builder/query-keys.d.mts +1 -1
  61. package/dist/plugins/form-builder/query-keys.d.ts +1 -1
  62. package/dist/shared/{stack.5_zzCejP.d.cts → stack.rVF0f509.d.cts} +20 -0
  63. package/dist/shared/{stack.5_zzCejP.d.mts → stack.rVF0f509.d.mts} +20 -0
  64. package/dist/shared/{stack.5_zzCejP.d.ts → stack.rVF0f509.d.ts} +20 -0
  65. package/package.json +1 -1
  66. package/src/plugins/ai-chat/__tests__/classnames-overrides.test.tsx +248 -0
  67. package/src/plugins/ai-chat/client/components/chat-input.tsx +6 -1
  68. package/src/plugins/ai-chat/client/components/chat-interface.tsx +3 -0
  69. package/src/plugins/ai-chat/client/components/chat-layout.tsx +9 -2
  70. package/src/plugins/ai-chat/client/components/chat-message.tsx +13 -5
  71. package/src/plugins/ai-chat/client/components/chat-sidebar.tsx +6 -2
  72. package/src/plugins/ai-chat/client/overrides.ts +21 -0
  73. package/src/plugins/blog/__tests__/classnames-overrides.test.tsx +206 -0
  74. package/src/plugins/blog/client/components/shared/page-wrapper.tsx +3 -2
  75. package/src/plugins/blog/client/components/shared/post-card.tsx +32 -12
  76. package/src/plugins/blog/client/components/shared/posts-list.tsx +3 -2
  77. package/src/plugins/blog/client/overrides.ts +18 -0
  78. package/src/plugins/cms/__tests__/classnames-overrides.test.tsx +200 -0
  79. package/src/plugins/cms/client/components/pages/content-list-page.internal.tsx +17 -4
  80. package/src/plugins/cms/client/components/pages/dashboard-page.internal.tsx +14 -3
  81. package/src/plugins/cms/client/components/shared/page-wrapper.tsx +3 -2
  82. package/src/plugins/cms/client/overrides.ts +19 -0
  83. package/src/plugins/form-builder/__tests__/classnames-overrides.test.tsx +312 -0
  84. package/src/plugins/form-builder/client/components/forms/form-renderer.tsx +14 -9
  85. package/src/plugins/form-builder/client/components/pages/form-list-page.internal.tsx +9 -5
  86. package/src/plugins/form-builder/client/components/pages/submissions-page.internal.tsx +9 -5
  87. package/src/plugins/form-builder/client/components/shared/page-wrapper.tsx +3 -2
  88. package/src/plugins/form-builder/client/components/shared/pagination.tsx +7 -1
  89. package/src/plugins/form-builder/client/overrides.ts +17 -0
  90. package/dist/shared/{stack.BuxUQqL8.d.ts → stack.CGmh-Lpe.d.ts} +12 -12
  91. package/dist/shared/{stack.Egr3RWhA.d.mts → stack.Doy2_wgl.d.mts} +12 -12
  92. package/dist/shared/{stack.CX83YNuS.d.cts → stack.zch70Loj.d.cts} +12 -12
@@ -18,7 +18,7 @@ import { CalendarIcon } from "lucide-react";
18
18
  import type { BlogPluginOverrides } from "../../overrides";
19
19
  import { BLOG_PLUGIN_ID } from "../../constants";
20
20
  import { DefaultLink, DefaultImage } from "./defaults";
21
-
21
+ import { cn } from "../../../utils";
22
22
  // Beautiful gradient color combinations
23
23
  const GRADIENT_PALETTES = [
24
24
  {
@@ -222,7 +222,7 @@ function getGradientFromTitle(title: string) {
222
222
 
223
223
  export function PostCard({ post }: { post: SerializedPost }) {
224
224
  const t = useTranslate();
225
- const { localization } = usePluginOverrides<
225
+ const { localization, classNames } = usePluginOverrides<
226
226
  BlogPluginOverrides,
227
227
  Partial<BlogPluginOverrides>
228
228
  >(BLOG_PLUGIN_ID, {});
@@ -234,9 +234,19 @@ export function PostCard({ post }: { post: SerializedPost }) {
234
234
  const timestamp = post.publishedAt || post.createdAt;
235
235
  const postDate = formatPostDate(timestamp);
236
236
  const gradient = post.image ? null : getGradientFromTitle(post.title);
237
+ const tags = post.tags?.map((tag) => (
238
+ <Badge key={tag.id} variant="secondary" className="text-xs">
239
+ {tag.name}
240
+ </Badge>
241
+ ));
237
242
 
238
243
  return (
239
- <Card className="group relative flex h-full flex-col gap-4 pt-0! pb-4! transition-shadow duration-200 hover:shadow-lg">
244
+ <Card
245
+ className={cn(
246
+ "group relative flex h-full flex-col gap-4 pt-0! pb-4! transition-shadow duration-200 hover:shadow-lg",
247
+ classNames?.postCard,
248
+ )}
249
+ >
240
250
  {/* Image or Placeholder */}
241
251
  <Link
242
252
  href={blogPath}
@@ -281,24 +291,34 @@ export function PostCard({ post }: { post: SerializedPost }) {
281
291
  className="block focus:outline-none focus-visible:ring-2 focus-visible:ring-ring/50 grow"
282
292
  >
283
293
  <CardHeader className="flex-1">
284
- <CardTitle className="line-clamp-3 text-lg leading-tight transition-colors group-hover:underline">
294
+ <CardTitle
295
+ className={cn(
296
+ "line-clamp-3 text-lg leading-tight transition-colors group-hover:underline",
297
+ classNames?.postTitle,
298
+ )}
299
+ >
285
300
  {post.title}
286
301
  </CardTitle>
287
302
  </CardHeader>
288
303
 
289
304
  <CardContent className="flex flex-1 flex-col gap-2">
290
305
  <div className="flex flex-wrap gap-2">
291
- <div className="flex items-center gap-2 text-muted-foreground text-xs">
306
+ <div
307
+ className={cn(
308
+ "flex items-center gap-2 text-muted-foreground text-xs",
309
+ classNames?.postMeta,
310
+ )}
311
+ >
292
312
  <CalendarIcon className="h-3 w-3" />
293
313
  <time dateTime={timestamp}>{postDate}</time>
294
314
  </div>
295
- {post.tags &&
296
- post.tags.length > 0 &&
297
- post.tags.map((tag) => (
298
- <Badge key={tag.id} variant="secondary" className="text-xs">
299
- {tag.name}
300
- </Badge>
301
- ))}
315
+ {classNames?.tagsList ? (
316
+ <div className={cn("flex flex-wrap gap-2", classNames.tagsList)}>
317
+ {tags}
318
+ </div>
319
+ ) : (
320
+ tags
321
+ )}
302
322
  </div>
303
323
  </CardContent>
304
324
  </Link>
@@ -6,6 +6,7 @@ import { SearchInput } from "./search-input";
6
6
  import type { BlogPluginOverrides } from "../../overrides";
7
7
  import { BLOG_PLUGIN_ID } from "../../constants";
8
8
  import { PostCard as DefaultPostCard } from "./post-card";
9
+ import { cn } from "../../../utils";
9
10
 
10
11
  interface PostsListProps {
11
12
  posts: SerializedPost[];
@@ -21,7 +22,7 @@ export function PostsList({
21
22
  isLoadingMore,
22
23
  }: PostsListProps) {
23
24
  const t = useTranslate();
24
- const { localization, PostCard } =
25
+ const { localization, PostCard, classNames } =
25
26
  usePluginOverrides<BlogPluginOverrides>(BLOG_PLUGIN_ID);
26
27
 
27
28
  const PostCardComponent = PostCard || DefaultPostCard;
@@ -61,7 +62,7 @@ export function PostsList({
61
62
  </div>
62
63
 
63
64
  {onLoadMore && hasMore && (
64
- <div className="flex justify-center">
65
+ <div className={cn("flex justify-center", classNames?.pagination)}>
65
66
  <Button
66
67
  onClick={onLoadMore}
67
68
  disabled={isLoadingMore}
@@ -41,6 +41,24 @@ export interface BlogPluginOverrides {
41
41
  PostCard?: ComponentType<{
42
42
  post: SerializedPost;
43
43
  }>;
44
+ /**
45
+ * Optional class names merged into the blog UI's structural elements,
46
+ * allowing styling without replacing whole components.
47
+ */
48
+ classNames?: {
49
+ /** Page container shared by blog pages. */
50
+ container?: string;
51
+ /** Each built-in post card. */
52
+ postCard?: string;
53
+ /** Title inside a post card. */
54
+ postTitle?: string;
55
+ /** Date metadata inside a post card. */
56
+ postMeta?: string;
57
+ /** Optional flex wrapper around post-card tags; omitted when unset. */
58
+ tagsList?: string;
59
+ /** Wrapper around the load-more control. */
60
+ pagination?: string;
61
+ };
44
62
  /**
45
63
  * Function used to upload a new image file and return its URL.
46
64
  * This is separate from `imagePicker`, which selects an existing asset URL.
@@ -0,0 +1,200 @@
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 cms
7
+ // components, which resolve `@btst/stack/*` via package self-reference.
8
+ import { StackProvider } from "@btst/stack/context";
9
+ import { createTestClientStack } from "../../../__tests__/client-stack-test-utils";
10
+ import { cmsClientPlugin } from "../client";
11
+ import { DashboardPage } from "../client/components/pages/dashboard-page.internal";
12
+ import { ContentListPage } from "../client/components/pages/content-list-page.internal";
13
+ import type {
14
+ SerializedContentItemWithType,
15
+ SerializedContentType,
16
+ } from "../types";
17
+
18
+ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
19
+
20
+ // jsdom lacks these APIs used by Radix / cmdk
21
+ (globalThis as any).ResizeObserver ??= class {
22
+ observe() {}
23
+ unobserve() {}
24
+ disconnect() {}
25
+ };
26
+ Element.prototype.scrollIntoView ??= () => {};
27
+
28
+ const hooks = vi.hoisted(() => ({
29
+ useContentTypes: vi.fn(),
30
+ useSuspenseContentTypes: vi.fn(),
31
+ useContent: vi.fn(),
32
+ useSuspenseContent: vi.fn(),
33
+ useDeleteContent: vi.fn(),
34
+ }));
35
+
36
+ vi.mock("../client/hooks", () => hooks);
37
+
38
+ const CLASS_NAMES = {
39
+ container: "bg-red-100",
40
+ contentTypeCard: "bg-blue-100",
41
+ contentTypeTitle: "text-purple-700",
42
+ table: "bg-yellow-50",
43
+ tableRow: "text-green-600",
44
+ pagination: "border-4 border-orange-500",
45
+ } as const;
46
+
47
+ const SIMPLE_JSON_SCHEMA = JSON.stringify({
48
+ type: "object",
49
+ properties: { title: { type: "string" } },
50
+ required: ["title"],
51
+ autoFormVersion: 2,
52
+ });
53
+
54
+ const contentType: SerializedContentType & { itemCount: number } = {
55
+ id: "ct1",
56
+ name: "Post",
57
+ slug: "post",
58
+ description: "",
59
+ jsonSchema: SIMPLE_JSON_SCHEMA,
60
+ autoFormVersion: 2,
61
+ itemCount: 1,
62
+ createdAt: new Date("2024-01-01").toISOString(),
63
+ updatedAt: new Date("2024-01-01").toISOString(),
64
+ } as unknown as SerializedContentType & { itemCount: number };
65
+
66
+ const item: SerializedContentItemWithType = {
67
+ id: "i1",
68
+ slug: "hello-world",
69
+ contentTypeId: "ct1",
70
+ data: JSON.stringify({ title: "Hello" }),
71
+ parsedData: { title: "Hello" },
72
+ contentType: { id: "ct1", name: "Post", slug: "post" },
73
+ createdAt: new Date("2024-01-01").toISOString(),
74
+ updatedAt: new Date("2024-01-01").toISOString(),
75
+ authorId: "user-1",
76
+ } as unknown as SerializedContentItemWithType;
77
+
78
+ let container: HTMLDivElement;
79
+ let root: Root;
80
+
81
+ beforeEach(() => {
82
+ container = document.createElement("div");
83
+ document.body.appendChild(container);
84
+ root = createRoot(container);
85
+
86
+ hooks.useSuspenseContentTypes.mockReturnValue({
87
+ contentTypes: [contentType],
88
+ refetch: vi.fn(),
89
+ });
90
+ hooks.useSuspenseContent.mockReturnValue({
91
+ items: [item],
92
+ total: 1,
93
+ loadMore: vi.fn(),
94
+ hasMore: false,
95
+ isLoadingMore: false,
96
+ refetch: vi.fn(),
97
+ });
98
+ hooks.useContent.mockReturnValue({
99
+ items: [],
100
+ total: 0,
101
+ isLoading: false,
102
+ error: null,
103
+ loadMore: vi.fn(),
104
+ hasMore: false,
105
+ isLoadingMore: false,
106
+ });
107
+ hooks.useDeleteContent.mockReturnValue({
108
+ mutateAsync: vi.fn(),
109
+ isPending: false,
110
+ });
111
+ });
112
+
113
+ afterEach(async () => {
114
+ await act(async () => {
115
+ root.unmount();
116
+ });
117
+ container.remove();
118
+ vi.clearAllMocks();
119
+ });
120
+
121
+ function createMockRouter() {
122
+ let params = new URLSearchParams();
123
+ return {
124
+ navigate: vi.fn(),
125
+ getSearchParams: () => new URLSearchParams(params.toString()),
126
+ setSearchParams: vi.fn((next: URLSearchParams) => {
127
+ params = new URLSearchParams(next.toString());
128
+ }),
129
+ };
130
+ }
131
+
132
+ async function render(ui: React.ReactElement, withOverrides = true) {
133
+ await act(async () => {
134
+ root.render(
135
+ <StackProvider
136
+ stack={createTestClientStack({ cms: cmsClientPlugin() })}
137
+ router={createMockRouter()}
138
+ overrides={withOverrides ? { cms: { classNames: CLASS_NAMES } } : {}}
139
+ >
140
+ {ui}
141
+ </StackProvider>,
142
+ );
143
+ });
144
+ }
145
+
146
+ function query(selector: string): HTMLElement {
147
+ const el = container.querySelector<HTMLElement>(selector);
148
+ expect(el, `expected to find ${selector}`).toBeTruthy();
149
+ return el!;
150
+ }
151
+
152
+ describe("cms classNames overrides (issue #36)", () => {
153
+ it("applies container, contentTypeCard and contentTypeTitle on the dashboard", async () => {
154
+ await render(<DashboardPage />);
155
+
156
+ const page = query('[data-testid="cms-dashboard-page"]');
157
+ expect(page.className).toContain(CLASS_NAMES.container);
158
+ // The override must merge with the layout's own classes, not replace them.
159
+ expect(page.className).toContain("mx-auto");
160
+
161
+ const card = query('[data-slot="card"]');
162
+ expect(card.className).toContain(CLASS_NAMES.contentTypeCard);
163
+ expect(card.className).toContain("cursor-pointer");
164
+
165
+ const title = query('[data-slot="card-title"]');
166
+ expect(title.className).toContain(CLASS_NAMES.contentTypeTitle);
167
+ expect(title.className).toContain("font-medium");
168
+ expect(title.textContent).toBe(contentType.name);
169
+ });
170
+
171
+ it("applies table, tableRow and pagination on the content list", async () => {
172
+ await render(<ContentListPage typeSlug="post" />);
173
+
174
+ const table = query('[data-testid="cms-content-table"]');
175
+ expect(table.className).toContain(CLASS_NAMES.table);
176
+ expect(table.className).toContain("rounded-lg");
177
+
178
+ const row = table.querySelector<HTMLElement>("tbody tr")!;
179
+ expect(row, "expected a body row").toBeTruthy();
180
+ expect(row.className).toContain(CLASS_NAMES.tableRow);
181
+ expect(row.textContent).toContain(item.slug);
182
+
183
+ const pagination = query('[data-testid="cms-content-pagination"]');
184
+ expect(pagination.className).toContain("border-4");
185
+ expect(pagination.className).toContain("border-orange-500");
186
+ // Non-conflicting base classes survive the merge. (`border-t` is
187
+ // intentionally dropped by tailwind-merge since `border-4` wins the
188
+ // border-width group — that is the override taking precedence.)
189
+ expect(pagination.className).toContain("justify-between");
190
+ expect(pagination.className).toContain("px-4");
191
+ });
192
+
193
+ it("leaves base classes untouched when no classNames override is supplied", async () => {
194
+ await render(<DashboardPage />, false);
195
+
196
+ const card = query('[data-slot="card"]');
197
+ expect(card.className).toContain("cursor-pointer");
198
+ expect(card.className).not.toContain(CLASS_NAMES.contentTypeCard);
199
+ });
200
+ });
@@ -25,6 +25,7 @@ import { cmsPermissions } from "../../../permissions";
25
25
  import { useListState, type ListStateSchema } from "@btst/stack/client/hooks";
26
26
  import { orderCMSContentTypes, type CMSPluginOverrides } from "../../overrides";
27
27
  import { CMS_PLUGIN_ID } from "../../constants";
28
+ import { cn } from "../../../utils";
28
29
  import type { SerializedContentItemWithType } from "../../../types";
29
30
  import {
30
31
  useContent,
@@ -52,7 +53,7 @@ export function ContentListPage({ typeSlug }: ContentListPageProps) {
52
53
  const t = useTranslate();
53
54
  const notify = useNotify();
54
55
  const overrides = usePluginOverrides<CMSPluginOverrides>(CMS_PLUGIN_ID);
55
- const { localization } = overrides;
56
+ const { localization, classNames } = overrides;
56
57
  const { router, plugins, basePath: stackBasePath } = useStack();
57
58
  const navigate = router?.navigate;
58
59
  const Link = router?.Link;
@@ -287,6 +288,7 @@ export function ContentListPage({ typeSlug }: ContentListPageProps) {
287
288
  hasMore={hasMore}
288
289
  isLoadingMore={isLoadingMore}
289
290
  localization={localization}
291
+ classNames={classNames}
290
292
  t={t}
291
293
  />
292
294
  )}
@@ -309,6 +311,7 @@ function ContentTable({
309
311
  hasMore,
310
312
  isLoadingMore,
311
313
  localization,
314
+ classNames,
312
315
  t,
313
316
  }: {
314
317
  items: SerializedContentItemWithType[];
@@ -324,10 +327,14 @@ function ContentTable({
324
327
  hasMore: boolean;
325
328
  isLoadingMore: boolean;
326
329
  localization: CMSPluginOverrides["localization"];
330
+ classNames: CMSPluginOverrides["classNames"];
327
331
  t: TranslateFn;
328
332
  }) {
329
333
  return (
330
- <div className="border rounded-lg">
334
+ <div
335
+ className={cn("border rounded-lg", classNames?.table)}
336
+ data-testid="cms-content-table"
337
+ >
331
338
  <Table>
332
339
  <TableHeader>
333
340
  <TableRow>
@@ -351,7 +358,7 @@ function ContentTable({
351
358
  </TableHeader>
352
359
  <TableBody>
353
360
  {items.map((item) => (
354
- <TableRow key={item.id}>
361
+ <TableRow key={item.id} className={classNames?.tableRow}>
355
362
  <TableCell className="font-medium">
356
363
  <LinkComponent
357
364
  href={joinBasePath(basePath, `/cms/${typeSlug}/${item.id}`)}
@@ -406,7 +413,13 @@ function ContentTable({
406
413
  </TableBody>
407
414
  </Table>
408
415
  {/* Load More and pagination info */}
409
- <div className="flex items-center justify-between px-4 py-3 border-t">
416
+ <div
417
+ className={cn(
418
+ "flex items-center justify-between px-4 py-3 border-t",
419
+ classNames?.pagination,
420
+ )}
421
+ data-testid="cms-content-pagination"
422
+ >
410
423
  <p className="text-sm text-muted-foreground">
411
424
  {(
412
425
  localization?.CMS_LIST_PAGINATION_SHOWING ??
@@ -19,11 +19,12 @@ import { useSuspenseContentTypes } from "../../hooks";
19
19
  import { EmptyState } from "../shared/empty-state";
20
20
  import { PageWrapper } from "../shared/page-wrapper";
21
21
  import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle";
22
+ import { cn } from "../../../utils";
22
23
 
23
24
  export function DashboardPage() {
24
25
  const t = useTranslate();
25
26
  const overrides = usePluginOverrides<CMSPluginOverrides>(CMS_PLUGIN_ID);
26
- const { localization } = overrides;
27
+ const { localization, classNames } = overrides;
27
28
  const { router, plugins, basePath: stackBasePath } = useStack();
28
29
  const navigate = router?.navigate;
29
30
  const basePath = plugins?.[CMS_PLUGIN_ID]?.site.basePath ?? stackBasePath;
@@ -104,13 +105,23 @@ export function DashboardPage() {
104
105
  {contentTypes.map((ct) => (
105
106
  <Card
106
107
  key={ct.id}
107
- className="hover:border-primary/50 transition-colors cursor-pointer"
108
+ className={cn(
109
+ "hover:border-primary/50 transition-colors cursor-pointer",
110
+ classNames?.contentTypeCard,
111
+ )}
108
112
  onClick={() =>
109
113
  void navigate?.(joinBasePath(basePath, `/cms/${ct.slug}`))
110
114
  }
111
115
  >
112
116
  <CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
113
- <CardTitle className="text-lg font-medium">{ct.name}</CardTitle>
117
+ <CardTitle
118
+ className={cn(
119
+ "text-lg font-medium",
120
+ classNames?.contentTypeTitle,
121
+ )}
122
+ >
123
+ {ct.name}
124
+ </CardTitle>
114
125
  <FileText className="h-5 w-5 text-muted-foreground" />
115
126
  </CardHeader>
116
127
  <CardContent>
@@ -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 { CMSPluginOverrides } from "../../overrides";
6
6
  import { CMS_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
  CMSPluginOverrides,
19
20
  Partial<CMSPluginOverrides>
20
21
  >(CMS_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
  >
@@ -129,6 +129,25 @@ export interface CMSPluginOverrides {
129
129
  */
130
130
  imagePicker?: ComponentType<{ onSelect: (url: string) => void }>;
131
131
 
132
+ /**
133
+ * Optional class names merged onto specific UI elements.
134
+ * Use these for targeted styling without replacing whole components.
135
+ */
136
+ classNames?: {
137
+ /** Page container shared by every CMS page */
138
+ container?: string;
139
+ /** Each content-type card on the dashboard */
140
+ contentTypeCard?: string;
141
+ /** Title inside a content-type card */
142
+ contentTypeTitle?: string;
143
+ /** Wrapper around the content entries table */
144
+ table?: string;
145
+ /** Each row in the content entries table */
146
+ tableRow?: string;
147
+ /** Pagination footer under the content entries table */
148
+ pagination?: string;
149
+ };
150
+
132
151
  /**
133
152
  * Custom field components for AutoForm fields.
134
153
  *