@btst/stack 1.8.0 → 1.9.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 (44) hide show
  1. package/dist/packages/better-stack/src/plugins/cms/api/plugin.cjs +445 -16
  2. package/dist/packages/better-stack/src/plugins/cms/api/plugin.mjs +445 -16
  3. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.cjs +24 -7
  4. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.mjs +25 -8
  5. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.cjs +224 -0
  6. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.mjs +222 -0
  7. package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.cjs +243 -0
  8. package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.mjs +241 -0
  9. package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.cjs +56 -2
  10. package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.mjs +56 -2
  11. package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.cjs +190 -0
  12. package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.mjs +187 -1
  13. package/dist/packages/better-stack/src/plugins/cms/db.cjs +38 -0
  14. package/dist/packages/better-stack/src/plugins/cms/db.mjs +38 -0
  15. package/dist/packages/ui/src/components/auto-form/fields/object.cjs +81 -1
  16. package/dist/packages/ui/src/components/auto-form/fields/object.mjs +81 -1
  17. package/dist/packages/ui/src/components/dialog.cjs +6 -0
  18. package/dist/packages/ui/src/components/dialog.mjs +6 -1
  19. package/dist/plugins/cms/api/index.d.cts +67 -3
  20. package/dist/plugins/cms/api/index.d.mts +67 -3
  21. package/dist/plugins/cms/api/index.d.ts +67 -3
  22. package/dist/plugins/cms/client/hooks/index.cjs +4 -0
  23. package/dist/plugins/cms/client/hooks/index.d.cts +82 -3
  24. package/dist/plugins/cms/client/hooks/index.d.mts +82 -3
  25. package/dist/plugins/cms/client/hooks/index.d.ts +82 -3
  26. package/dist/plugins/cms/client/hooks/index.mjs +1 -1
  27. package/dist/plugins/cms/query-keys.d.cts +1 -1
  28. package/dist/plugins/cms/query-keys.d.mts +1 -1
  29. package/dist/plugins/cms/query-keys.d.ts +1 -1
  30. package/dist/plugins/form-builder/api/index.d.cts +1 -1
  31. package/dist/plugins/form-builder/api/index.d.mts +1 -1
  32. package/dist/plugins/form-builder/api/index.d.ts +1 -1
  33. package/dist/shared/{stack.L-UFwz2G.d.cts → stack.oGOteE6g.d.cts} +27 -5
  34. package/dist/shared/{stack.L-UFwz2G.d.mts → stack.oGOteE6g.d.mts} +27 -5
  35. package/dist/shared/{stack.L-UFwz2G.d.ts → stack.oGOteE6g.d.ts} +27 -5
  36. package/package.json +1 -1
  37. package/src/plugins/cms/api/plugin.ts +667 -21
  38. package/src/plugins/cms/client/components/forms/content-form.tsx +60 -18
  39. package/src/plugins/cms/client/components/forms/relation-field.tsx +299 -0
  40. package/src/plugins/cms/client/components/inverse-relations-panel.tsx +329 -0
  41. package/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx +127 -1
  42. package/src/plugins/cms/client/hooks/cms-hooks.tsx +344 -0
  43. package/src/plugins/cms/db.ts +38 -0
  44. package/src/plugins/cms/types.ts +99 -10
@@ -0,0 +1,329 @@
1
+ "use client";
2
+
3
+ import { useState } from "react";
4
+ import { useQuery } from "@tanstack/react-query";
5
+ import {
6
+ ChevronDown,
7
+ ChevronRight,
8
+ ExternalLink,
9
+ Plus,
10
+ Trash2,
11
+ } from "lucide-react";
12
+ import { Button } from "@workspace/ui/components/button";
13
+ import {
14
+ Card,
15
+ CardContent,
16
+ CardHeader,
17
+ CardTitle,
18
+ } from "@workspace/ui/components/card";
19
+ import { createApiClient } from "@btst/stack/plugins/client";
20
+ import { usePluginOverrides, useBasePath } from "@btst/stack/context";
21
+ import { useDeleteContent } from "../hooks";
22
+ import type { CMSPluginOverrides } from "../overrides";
23
+ import type { CMSApiRouter } from "../../api";
24
+ import type { SerializedContentItemWithType } from "../../types";
25
+ import {
26
+ AlertDialog,
27
+ AlertDialogAction,
28
+ AlertDialogCancel,
29
+ AlertDialogContent,
30
+ AlertDialogDescription,
31
+ AlertDialogFooter,
32
+ AlertDialogHeader,
33
+ AlertDialogTitle,
34
+ } from "@workspace/ui/components/alert-dialog";
35
+
36
+ interface InverseRelation {
37
+ sourceType: string;
38
+ sourceTypeName: string;
39
+ fieldName: string;
40
+ count: number;
41
+ }
42
+
43
+ interface InverseRelationsPanelProps {
44
+ contentTypeSlug: string;
45
+ itemId: string;
46
+ }
47
+
48
+ /**
49
+ * Panel that shows content items that reference this item via belongsTo relations.
50
+ * For example, when editing a Resource, this shows all Comments that belong to it.
51
+ */
52
+ export function InverseRelationsPanel({
53
+ contentTypeSlug,
54
+ itemId,
55
+ }: InverseRelationsPanelProps) {
56
+ const { apiBaseURL, apiBasePath, headers, navigate, Link } =
57
+ usePluginOverrides<CMSPluginOverrides>("cms");
58
+ const basePath = useBasePath();
59
+ const client = createApiClient<CMSApiRouter>({
60
+ baseURL: apiBaseURL,
61
+ basePath: apiBasePath,
62
+ });
63
+
64
+ // Fetch inverse relations metadata
65
+ const { data: inverseRelationsData, isLoading } = useQuery({
66
+ queryKey: ["cmsInverseRelations", contentTypeSlug, itemId],
67
+ queryFn: async () => {
68
+ const response = await client("/content-types/:slug/inverse-relations", {
69
+ method: "GET",
70
+ params: { slug: contentTypeSlug },
71
+ query: { itemId },
72
+ headers,
73
+ });
74
+ return (
75
+ (response as { data?: { inverseRelations: InverseRelation[] } }).data
76
+ ?.inverseRelations ?? []
77
+ );
78
+ },
79
+ staleTime: 1000 * 60 * 5,
80
+ });
81
+
82
+ if (isLoading) {
83
+ return (
84
+ <Card className="animate-pulse">
85
+ <CardHeader>
86
+ <div className="h-5 w-32 bg-muted rounded" />
87
+ </CardHeader>
88
+ </Card>
89
+ );
90
+ }
91
+
92
+ const inverseRelations = inverseRelationsData ?? [];
93
+
94
+ if (inverseRelations.length === 0) {
95
+ return null;
96
+ }
97
+
98
+ return (
99
+ <div className="space-y-4">
100
+ <h3 className="text-lg font-semibold">Related Items</h3>
101
+ {inverseRelations.map((relation) => (
102
+ <InverseRelationSection
103
+ key={`${relation.sourceType}-${relation.fieldName}`}
104
+ relation={relation}
105
+ contentTypeSlug={contentTypeSlug}
106
+ itemId={itemId}
107
+ basePath={basePath}
108
+ navigate={navigate}
109
+ Link={Link}
110
+ client={client}
111
+ headers={headers}
112
+ />
113
+ ))}
114
+ </div>
115
+ );
116
+ }
117
+
118
+ interface InverseRelationSectionProps {
119
+ relation: InverseRelation;
120
+ contentTypeSlug: string;
121
+ itemId: string;
122
+ basePath: string;
123
+ navigate: (path: string) => void;
124
+ Link?: React.ComponentType<{
125
+ href?: string;
126
+ children?: React.ReactNode;
127
+ className?: string;
128
+ }>;
129
+ client: ReturnType<typeof createApiClient<CMSApiRouter>>;
130
+ headers?: HeadersInit;
131
+ }
132
+
133
+ function InverseRelationSection({
134
+ relation,
135
+ contentTypeSlug,
136
+ itemId,
137
+ basePath,
138
+ navigate,
139
+ Link,
140
+ client,
141
+ headers,
142
+ }: InverseRelationSectionProps) {
143
+ const [isExpanded, setIsExpanded] = useState(true);
144
+ const [deleteItemId, setDeleteItemId] = useState<string | null>(null);
145
+ const [deleteError, setDeleteError] = useState<string | null>(null);
146
+ const deleteContent = useDeleteContent(relation.sourceType);
147
+
148
+ // Fetch items for this inverse relation
149
+ const { data: itemsData, refetch } = useQuery({
150
+ queryKey: [
151
+ "cmsInverseRelationItems",
152
+ contentTypeSlug,
153
+ relation.sourceType,
154
+ itemId,
155
+ relation.fieldName,
156
+ ],
157
+ queryFn: async () => {
158
+ const response = await client(
159
+ "/content-types/:slug/inverse-relations/:sourceType",
160
+ {
161
+ method: "GET",
162
+ params: { slug: contentTypeSlug, sourceType: relation.sourceType },
163
+ query: { itemId, fieldName: relation.fieldName },
164
+ headers,
165
+ },
166
+ );
167
+ return (
168
+ (
169
+ response as {
170
+ data?: { items: SerializedContentItemWithType[]; total: number };
171
+ }
172
+ ).data ?? { items: [], total: 0 }
173
+ );
174
+ },
175
+ staleTime: 1000 * 60 * 5,
176
+ enabled: isExpanded,
177
+ });
178
+
179
+ const items = itemsData?.items ?? [];
180
+ const total = itemsData?.total ?? relation.count;
181
+
182
+ const handleDelete = async () => {
183
+ if (deleteItemId) {
184
+ setDeleteError(null);
185
+ try {
186
+ await deleteContent.mutateAsync(deleteItemId);
187
+ setDeleteItemId(null);
188
+ refetch();
189
+ } catch (error) {
190
+ const message =
191
+ error instanceof Error
192
+ ? error.message
193
+ : "Failed to delete item. Please try again.";
194
+ setDeleteError(message);
195
+ }
196
+ }
197
+ };
198
+
199
+ // Create new item with pre-filled belongsTo field
200
+ const handleAddNew = () => {
201
+ // Navigate to create page with query param to pre-fill the relation.
202
+ // ContentEditorPage reads prefill_* query params and passes them to ContentForm as initialData.
203
+ const createUrl = `${basePath}/cms/${relation.sourceType}/new?prefill_${relation.fieldName}=${itemId}`;
204
+ navigate(createUrl);
205
+ };
206
+
207
+ const LinkComponent = Link ?? "a";
208
+
209
+ return (
210
+ <Card>
211
+ <CardHeader className="py-3">
212
+ <button
213
+ type="button"
214
+ onClick={() => setIsExpanded(!isExpanded)}
215
+ className="flex items-center justify-between w-full text-left"
216
+ >
217
+ <CardTitle className="text-base flex items-center gap-2">
218
+ {isExpanded ? (
219
+ <ChevronDown className="h-4 w-4" />
220
+ ) : (
221
+ <ChevronRight className="h-4 w-4" />
222
+ )}
223
+ {relation.sourceTypeName} ({total})
224
+ </CardTitle>
225
+ </button>
226
+ </CardHeader>
227
+ {isExpanded && (
228
+ <CardContent className="pt-0">
229
+ {items.length === 0 ? (
230
+ <p className="text-sm text-muted-foreground py-2">
231
+ No {relation.sourceTypeName.toLowerCase()} items yet.
232
+ </p>
233
+ ) : (
234
+ <ul className="space-y-2">
235
+ {items.map((item) => {
236
+ const displayValue = getDisplayValue(item);
237
+ const editUrl = `${basePath}/cms/${relation.sourceType}/${item.id}`;
238
+ return (
239
+ <li
240
+ key={item.id}
241
+ className="flex items-center justify-between py-2 px-3 rounded-md bg-muted/50 hover:bg-muted transition-colors"
242
+ >
243
+ <LinkComponent
244
+ href={editUrl}
245
+ className="flex-1 text-sm hover:underline flex items-center gap-2"
246
+ >
247
+ <span className="truncate">{displayValue}</span>
248
+ <ExternalLink className="h-3 w-3 opacity-50" />
249
+ </LinkComponent>
250
+ <Button
251
+ variant="ghost"
252
+ size="icon"
253
+ className="h-7 w-7 text-muted-foreground hover:text-destructive"
254
+ onClick={() => setDeleteItemId(item.id)}
255
+ >
256
+ <Trash2 className="h-3.5 w-3.5" />
257
+ </Button>
258
+ </li>
259
+ );
260
+ })}
261
+ </ul>
262
+ )}
263
+ <div className="mt-3 pt-3 border-t">
264
+ <Button
265
+ variant="outline"
266
+ size="sm"
267
+ onClick={handleAddNew}
268
+ className="w-full"
269
+ >
270
+ <Plus className="h-4 w-4 mr-2" />
271
+ Add {relation.sourceTypeName}
272
+ </Button>
273
+ </div>
274
+ </CardContent>
275
+ )}
276
+
277
+ {/* Delete confirmation dialog */}
278
+ <AlertDialog
279
+ open={!!deleteItemId}
280
+ onOpenChange={(open) => {
281
+ if (!open) {
282
+ setDeleteItemId(null);
283
+ setDeleteError(null);
284
+ }
285
+ }}
286
+ >
287
+ <AlertDialogContent>
288
+ <AlertDialogHeader>
289
+ <AlertDialogTitle>
290
+ Delete {relation.sourceTypeName}?
291
+ </AlertDialogTitle>
292
+ <AlertDialogDescription>
293
+ This action cannot be undone. This will permanently delete this{" "}
294
+ {relation.sourceTypeName.toLowerCase()}.
295
+ </AlertDialogDescription>
296
+ </AlertDialogHeader>
297
+ {deleteError && (
298
+ <p className="text-sm text-destructive">{deleteError}</p>
299
+ )}
300
+ <AlertDialogFooter>
301
+ <AlertDialogCancel>Cancel</AlertDialogCancel>
302
+ <AlertDialogAction
303
+ onClick={handleDelete}
304
+ className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
305
+ >
306
+ Delete
307
+ </AlertDialogAction>
308
+ </AlertDialogFooter>
309
+ </AlertDialogContent>
310
+ </AlertDialog>
311
+ </Card>
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Get a display value from an item's parsedData
317
+ */
318
+ function getDisplayValue(item: SerializedContentItemWithType): string {
319
+ const data = item.parsedData as Record<string, unknown>;
320
+ // Try common display fields
321
+ const displayFields = ["name", "title", "label", "content", "author", "slug"];
322
+ for (const field of displayFields) {
323
+ if (typeof data[field] === "string" && data[field]) {
324
+ const value = data[field] as string;
325
+ return value.length > 50 ? `${value.slice(0, 50)}...` : value;
326
+ }
327
+ }
328
+ return item.slug;
329
+ }
@@ -1,5 +1,6 @@
1
1
  "use client";
2
2
 
3
+ import { useState, useEffect } from "react";
3
4
  import { ArrowLeft } from "lucide-react";
4
5
  import { Button } from "@workspace/ui/components/button";
5
6
  import { usePluginOverrides, useBasePath } from "@btst/stack/context";
@@ -11,12 +12,119 @@ import {
11
12
  useUpdateContent,
12
13
  } from "../../hooks";
13
14
  import { ContentForm } from "../forms/content-form";
15
+ import { InverseRelationsPanel } from "../inverse-relations-panel";
14
16
  import { EmptyState } from "../shared/empty-state";
15
17
  import { PageWrapper } from "../shared/page-wrapper";
16
18
  import { EditorSkeleton } from "../loading/editor-skeleton";
17
19
  import { CMS_LOCALIZATION } from "../../localization";
18
20
  import { useRouteLifecycle } from "@workspace/ui/hooks/use-route-lifecycle";
19
21
 
22
+ /**
23
+ * Parse prefill query parameters from the URL.
24
+ * Looks for query params with the format `prefill_<fieldName>=<value>`
25
+ * and returns a record of field names to values.
26
+ *
27
+ * Uses useState + useEffect pattern to work correctly with SSR/hydration.
28
+ * During SSR, returns empty object. After hydration, parses URL params.
29
+ * Also listens for popstate events to handle browser back/forward navigation.
30
+ *
31
+ * @example
32
+ * URL: /cms/comment/new?prefill_resourceId=123&prefill_author=John
33
+ * Returns: { resourceId: "123", author: "John" }
34
+ */
35
+ function usePrefillParams(): Record<string, string> {
36
+ const [prefillData, setPrefillData] = useState<Record<string, string>>({});
37
+
38
+ useEffect(() => {
39
+ if (typeof window === "undefined") {
40
+ return;
41
+ }
42
+
43
+ const parseAndSetPrefillData = () => {
44
+ const params = new URLSearchParams(window.location.search);
45
+ const data: Record<string, string> = {};
46
+
47
+ for (const [key, value] of params.entries()) {
48
+ if (key.startsWith("prefill_")) {
49
+ const fieldName = key.slice("prefill_".length);
50
+ if (fieldName) {
51
+ data[fieldName] = value;
52
+ }
53
+ }
54
+ }
55
+
56
+ // Always update state to ensure stale data is cleared when navigating
57
+ // to a URL without prefill params (e.g., via browser back/forward)
58
+ setPrefillData(data);
59
+ };
60
+
61
+ // Parse on mount
62
+ parseAndSetPrefillData();
63
+
64
+ // Listen for popstate events (browser back/forward navigation)
65
+ window.addEventListener("popstate", parseAndSetPrefillData);
66
+
67
+ return () => {
68
+ window.removeEventListener("popstate", parseAndSetPrefillData);
69
+ };
70
+ }, []);
71
+
72
+ return prefillData;
73
+ }
74
+
75
+ interface JsonSchemaProperty {
76
+ fieldType?: string;
77
+ relation?: {
78
+ type: "belongsTo" | "hasMany" | "manyToMany";
79
+ targetType: string;
80
+ };
81
+ }
82
+
83
+ /**
84
+ * Convert prefill params to the correct format for the form.
85
+ * Relation fields need special handling:
86
+ * - belongsTo: value should be { id: "uuid" }
87
+ * - hasMany/manyToMany: value should be [{ id: "uuid" }]
88
+ *
89
+ * @param prefillParams - Raw prefill params from URL
90
+ * @param jsonSchema - The content type's JSON schema
91
+ * @returns Converted data suitable for initialData
92
+ */
93
+ function convertPrefillToFormData(
94
+ prefillParams: Record<string, string>,
95
+ jsonSchema: Record<string, unknown>,
96
+ ): Record<string, unknown> {
97
+ const properties = jsonSchema.properties as
98
+ | Record<string, JsonSchemaProperty>
99
+ | undefined;
100
+
101
+ if (!properties) {
102
+ return prefillParams;
103
+ }
104
+
105
+ const result: Record<string, unknown> = {};
106
+
107
+ for (const [fieldName, value] of Object.entries(prefillParams)) {
108
+ const fieldSchema = properties[fieldName];
109
+
110
+ if (fieldSchema?.fieldType === "relation" && fieldSchema.relation) {
111
+ // Convert relation field value to the correct format
112
+ if (fieldSchema.relation.type === "belongsTo") {
113
+ // belongsTo expects { id: "uuid" }
114
+ result[fieldName] = { id: value };
115
+ } else {
116
+ // hasMany/manyToMany expect [{ id: "uuid" }]
117
+ result[fieldName] = [{ id: value }];
118
+ }
119
+ } else {
120
+ // Non-relation fields: pass through as-is
121
+ result[fieldName] = value;
122
+ }
123
+ }
124
+
125
+ return result;
126
+ }
127
+
20
128
  interface ContentEditorPageProps {
21
129
  typeSlug: string;
22
130
  id?: string;
@@ -28,6 +136,10 @@ export function ContentEditorPage({ typeSlug, id }: ContentEditorPageProps) {
28
136
  const localization = { ...CMS_LOCALIZATION, ...overrides.localization };
29
137
  const basePath = useBasePath();
30
138
 
139
+ // Parse prefill query parameters for pre-populating fields when creating new items
140
+ // This is used by the inverse relations panel to pre-fill the parent relation
141
+ const prefillParams = usePrefillParams();
142
+
31
143
  // Call lifecycle hooks for authorization
32
144
  useRouteLifecycle({
33
145
  routeName: "contentEditor",
@@ -127,12 +239,26 @@ export function ContentEditorPage({ typeSlug, id }: ContentEditorPageProps) {
127
239
  <ContentForm
128
240
  key={isEditing ? `edit-${id}` : "create"}
129
241
  contentType={contentType}
130
- initialData={item?.parsedData}
242
+ initialData={
243
+ isEditing
244
+ ? item?.parsedData
245
+ : Object.keys(prefillParams).length > 0
246
+ ? convertPrefillToFormData(
247
+ prefillParams,
248
+ JSON.parse(contentType.jsonSchema),
249
+ )
250
+ : undefined
251
+ }
131
252
  initialSlug={item?.slug}
132
253
  isEditing={isEditing}
133
254
  onSubmit={handleSubmit}
134
255
  onCancel={() => navigate(`${basePath}/cms/${typeSlug}`)}
135
256
  />
257
+
258
+ {/* Show inverse relations panel when editing (not creating) */}
259
+ {isEditing && id && (
260
+ <InverseRelationsPanel contentTypeSlug={typeSlug} itemId={id} />
261
+ )}
136
262
  </div>
137
263
  </PageWrapper>
138
264
  );