@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.
- package/dist/packages/better-stack/src/plugins/cms/api/plugin.cjs +445 -16
- package/dist/packages/better-stack/src/plugins/cms/api/plugin.mjs +445 -16
- package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.cjs +24 -7
- package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.mjs +25 -8
- package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.cjs +224 -0
- package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.mjs +222 -0
- package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.cjs +243 -0
- package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.mjs +241 -0
- package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.cjs +56 -2
- package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.mjs +56 -2
- package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.cjs +190 -0
- package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.mjs +187 -1
- package/dist/packages/better-stack/src/plugins/cms/db.cjs +38 -0
- package/dist/packages/better-stack/src/plugins/cms/db.mjs +38 -0
- package/dist/packages/ui/src/components/auto-form/fields/object.cjs +81 -1
- package/dist/packages/ui/src/components/auto-form/fields/object.mjs +81 -1
- package/dist/packages/ui/src/components/dialog.cjs +6 -0
- package/dist/packages/ui/src/components/dialog.mjs +6 -1
- package/dist/plugins/cms/api/index.d.cts +67 -3
- package/dist/plugins/cms/api/index.d.mts +67 -3
- package/dist/plugins/cms/api/index.d.ts +67 -3
- package/dist/plugins/cms/client/hooks/index.cjs +4 -0
- package/dist/plugins/cms/client/hooks/index.d.cts +82 -3
- package/dist/plugins/cms/client/hooks/index.d.mts +82 -3
- package/dist/plugins/cms/client/hooks/index.d.ts +82 -3
- package/dist/plugins/cms/client/hooks/index.mjs +1 -1
- 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/api/index.d.cts +1 -1
- package/dist/plugins/form-builder/api/index.d.mts +1 -1
- package/dist/plugins/form-builder/api/index.d.ts +1 -1
- package/dist/shared/{stack.L-UFwz2G.d.cts → stack.oGOteE6g.d.cts} +27 -5
- package/dist/shared/{stack.L-UFwz2G.d.mts → stack.oGOteE6g.d.mts} +27 -5
- package/dist/shared/{stack.L-UFwz2G.d.ts → stack.oGOteE6g.d.ts} +27 -5
- package/package.json +1 -1
- package/src/plugins/cms/api/plugin.ts +667 -21
- package/src/plugins/cms/client/components/forms/content-form.tsx +60 -18
- package/src/plugins/cms/client/components/forms/relation-field.tsx +299 -0
- package/src/plugins/cms/client/components/inverse-relations-panel.tsx +329 -0
- package/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx +127 -1
- package/src/plugins/cms/client/hooks/cms-hooks.tsx +344 -0
- package/src/plugins/cms/db.ts +38 -0
- 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={
|
|
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
|
);
|