@btst/stack 1.7.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/api/index.d.cts +2 -2
- package/dist/api/index.d.mts +2 -2
- package/dist/api/index.d.ts +2 -2
- package/dist/client/index.cjs +6 -2
- package/dist/client/index.d.cts +2 -1
- package/dist/client/index.d.mts +2 -1
- package/dist/client/index.d.ts +2 -1
- package/dist/client/index.mjs +6 -2
- package/dist/index.d.cts +1 -1
- package/dist/index.d.mts +1 -1
- package/dist/index.d.ts +1 -1
- 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/better-stack/src/plugins/route-docs/client/components/loading/docs-skeleton.cjs +43 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/components/loading/docs-skeleton.mjs +41 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/components/pages/docs-page.cjs +794 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/components/pages/docs-page.mjs +788 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/plugin.cjs +111 -0
- package/dist/packages/better-stack/src/plugins/route-docs/client/plugin.mjs +106 -0
- package/dist/packages/better-stack/src/plugins/route-docs/generator.cjs +244 -0
- package/dist/packages/better-stack/src/plugins/route-docs/generator.mjs +227 -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/packages/ui/src/components/sheet.cjs +25 -0
- package/dist/packages/ui/src/components/sheet.mjs +24 -1
- package/dist/plugins/api/index.d.cts +2 -2
- package/dist/plugins/api/index.d.mts +2 -2
- package/dist/plugins/api/index.d.ts +2 -2
- package/dist/plugins/blog/api/index.d.cts +1 -1
- package/dist/plugins/blog/api/index.d.mts +1 -1
- package/dist/plugins/blog/api/index.d.ts +1 -1
- 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 +1 -1
- package/dist/plugins/blog/client/index.d.mts +1 -1
- package/dist/plugins/blog/client/index.d.ts +1 -1
- 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/client/index.d.cts +2 -2
- package/dist/plugins/client/index.d.mts +2 -2
- package/dist/plugins/client/index.d.ts +2 -2
- 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/plugins/open-api/api/index.d.cts +1 -1
- package/dist/plugins/open-api/api/index.d.mts +1 -1
- package/dist/plugins/open-api/api/index.d.ts +1 -1
- package/dist/plugins/route-docs/client/index.cjs +10 -0
- package/dist/plugins/route-docs/client/index.d.cts +126 -0
- package/dist/plugins/route-docs/client/index.d.mts +126 -0
- package/dist/plugins/route-docs/client/index.d.ts +126 -0
- package/dist/plugins/route-docs/client/index.mjs +1 -0
- package/dist/plugins/route-docs/client.css +3 -0
- package/dist/plugins/route-docs/style.css +19 -0
- package/dist/shared/{stack.L-UFwz2G.d.mts → stack.oGOteE6g.d.cts} +27 -5
- package/dist/shared/{stack.L-UFwz2G.d.ts → stack.oGOteE6g.d.mts} +27 -5
- package/dist/shared/{stack.L-UFwz2G.d.cts → stack.oGOteE6g.d.ts} +27 -5
- package/dist/shared/{stack.CSce37mX.d.cts → stack.u9iYV6vt.d.cts} +14 -2
- package/dist/shared/{stack.CSce37mX.d.mts → stack.u9iYV6vt.d.mts} +14 -2
- package/dist/shared/{stack.CSce37mX.d.ts → stack.u9iYV6vt.d.ts} +14 -2
- package/package.json +15 -1
- package/src/client/index.ts +11 -4
- 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
- package/src/plugins/route-docs/client/components/loading/docs-skeleton.tsx +82 -0
- package/src/plugins/route-docs/client/components/loading/index.tsx +1 -0
- package/src/plugins/route-docs/client/components/pages/docs-page.tsx +1240 -0
- package/src/plugins/route-docs/client/index.ts +7 -0
- package/src/plugins/route-docs/client/plugin.tsx +187 -0
- package/src/plugins/route-docs/client.css +3 -0
- package/src/plugins/route-docs/generator.ts +385 -0
- package/src/plugins/route-docs/index.ts +12 -0
- package/src/plugins/route-docs/style.css +19 -0
- package/src/types.ts +19 -1
- package/dist/shared/{stack.CcI4sYJP.d.mts → stack.DLhzx1-D.d.cts} +1 -1
- package/dist/shared/{stack.CcI4sYJP.d.ts → stack.DLhzx1-D.d.mts} +1 -1
- package/dist/shared/{stack.CcI4sYJP.d.cts → stack.DLhzx1-D.d.ts} +1 -1
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
|
|
3
|
-
import { useState, useMemo, useEffect } from "react";
|
|
3
|
+
import { useState, useMemo, useEffect, useRef } from "react";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { toast } from "sonner";
|
|
6
5
|
import { SteppedAutoForm } from "@workspace/ui/components/auto-form/stepped-auto-form";
|
|
7
6
|
import type {
|
|
8
7
|
FieldConfig,
|
|
@@ -15,10 +14,11 @@ import { Label } from "@workspace/ui/components/label";
|
|
|
15
14
|
import { Badge } from "@workspace/ui/components/badge";
|
|
16
15
|
import { usePluginOverrides } from "@btst/stack/context";
|
|
17
16
|
import type { CMSPluginOverrides } from "../../overrides";
|
|
18
|
-
import type { SerializedContentType } from "../../../types";
|
|
17
|
+
import type { SerializedContentType, RelationConfig } from "../../../types";
|
|
19
18
|
import { slugify } from "../../../utils";
|
|
20
19
|
import { CMS_LOCALIZATION } from "../../localization";
|
|
21
20
|
import { CMSFileUpload } from "./file-upload";
|
|
21
|
+
import { RelationField } from "./relation-field";
|
|
22
22
|
|
|
23
23
|
interface ContentFormProps {
|
|
24
24
|
contentType: SerializedContentType;
|
|
@@ -43,6 +43,12 @@ interface ContentFormProps {
|
|
|
43
43
|
* @param uploadImage - The uploadImage function from overrides (for file fields)
|
|
44
44
|
* @param fieldComponents - Custom field components from overrides
|
|
45
45
|
*/
|
|
46
|
+
interface JsonSchemaProperty {
|
|
47
|
+
fieldType?: string;
|
|
48
|
+
relation?: RelationConfig;
|
|
49
|
+
[key: string]: unknown;
|
|
50
|
+
}
|
|
51
|
+
|
|
46
52
|
function buildFieldConfigFromJsonSchema(
|
|
47
53
|
jsonSchema: Record<string, unknown>,
|
|
48
54
|
uploadImage?: (file: File) => Promise<string>,
|
|
@@ -54,17 +60,17 @@ function buildFieldConfigFromJsonSchema(
|
|
|
54
60
|
// Get base config from shared utility (handles fieldType from JSON Schema)
|
|
55
61
|
const baseConfig = buildFieldConfigBase(jsonSchema, fieldComponents);
|
|
56
62
|
|
|
57
|
-
// Apply CMS-specific handling for
|
|
63
|
+
// Apply CMS-specific handling for special fieldTypes ONLY if no custom component exists
|
|
58
64
|
// Custom fieldComponents take priority - don't override if user provided one
|
|
59
65
|
const properties = jsonSchema.properties as Record<
|
|
60
66
|
string,
|
|
61
|
-
|
|
67
|
+
JsonSchemaProperty
|
|
62
68
|
>;
|
|
63
69
|
|
|
64
70
|
if (!properties) return baseConfig;
|
|
65
71
|
|
|
66
72
|
for (const [key, prop] of Object.entries(properties)) {
|
|
67
|
-
//
|
|
73
|
+
// Handle "file" fieldType when there's NO custom component for "file"
|
|
68
74
|
if (prop.fieldType === "file" && !fieldComponents?.["file"]) {
|
|
69
75
|
// Use CMSFileUpload as the default file component
|
|
70
76
|
if (!uploadImage) {
|
|
@@ -87,6 +93,21 @@ function buildFieldConfigFromJsonSchema(
|
|
|
87
93
|
};
|
|
88
94
|
}
|
|
89
95
|
}
|
|
96
|
+
|
|
97
|
+
// Handle "relation" fieldType when there's NO custom component for "relation"
|
|
98
|
+
if (
|
|
99
|
+
prop.fieldType === "relation" &&
|
|
100
|
+
prop.relation &&
|
|
101
|
+
!fieldComponents?.["relation"]
|
|
102
|
+
) {
|
|
103
|
+
const relationConfig = prop.relation;
|
|
104
|
+
baseConfig[key] = {
|
|
105
|
+
...baseConfig[key],
|
|
106
|
+
fieldType: (props: AutoFormInputComponentProps) => (
|
|
107
|
+
<RelationField {...props} relation={relationConfig} />
|
|
108
|
+
),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
90
111
|
}
|
|
91
112
|
|
|
92
113
|
return baseConfig;
|
|
@@ -139,14 +160,28 @@ export function ContentForm({
|
|
|
139
160
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
140
161
|
const [formData, setFormData] =
|
|
141
162
|
useState<Record<string, unknown>>(initialData);
|
|
163
|
+
const [slugError, setSlugError] = useState<string | null>(null);
|
|
164
|
+
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
165
|
+
|
|
166
|
+
// Track if we've already synced prefill data to avoid overwriting user input
|
|
167
|
+
const hasSyncedPrefillRef = useRef(false);
|
|
142
168
|
|
|
143
|
-
// Sync formData with initialData when it changes
|
|
144
|
-
// This
|
|
169
|
+
// Sync formData with initialData when it changes
|
|
170
|
+
// This handles both:
|
|
171
|
+
// 1. Editing mode: always sync when item data is loaded (isEditing=true)
|
|
172
|
+
// 2. Create mode: only sync prefill data ONCE to avoid overwriting user input
|
|
173
|
+
// useState only uses the initial value on mount, so we need this effect for updates
|
|
145
174
|
useEffect(() => {
|
|
146
|
-
|
|
147
|
-
//
|
|
148
|
-
|
|
175
|
+
const hasData = Object.keys(initialData).length > 0;
|
|
176
|
+
// In edit mode, always sync (user is loading existing data)
|
|
177
|
+
// In create mode, only sync prefill data once
|
|
178
|
+
const shouldSync = hasData && (isEditing || !hasSyncedPrefillRef.current);
|
|
179
|
+
|
|
180
|
+
if (shouldSync) {
|
|
149
181
|
setFormData(initialData);
|
|
182
|
+
if (!isEditing) {
|
|
183
|
+
hasSyncedPrefillRef.current = true;
|
|
184
|
+
}
|
|
150
185
|
}
|
|
151
186
|
}, [initialData, isEditing]);
|
|
152
187
|
|
|
@@ -204,23 +239,21 @@ export function ContentForm({
|
|
|
204
239
|
|
|
205
240
|
// Handle form submission
|
|
206
241
|
const handleSubmit = async (data: Record<string, unknown>) => {
|
|
242
|
+
setSlugError(null);
|
|
243
|
+
setSubmitError(null);
|
|
244
|
+
|
|
207
245
|
if (!slug.trim()) {
|
|
208
|
-
|
|
246
|
+
setSlugError("Slug is required");
|
|
209
247
|
return;
|
|
210
248
|
}
|
|
211
249
|
|
|
212
250
|
setIsSubmitting(true);
|
|
213
251
|
try {
|
|
214
252
|
await onSubmit({ slug, data });
|
|
215
|
-
toast.success(
|
|
216
|
-
isEditing
|
|
217
|
-
? localization.CMS_TOAST_UPDATE_SUCCESS
|
|
218
|
-
: localization.CMS_TOAST_CREATE_SUCCESS,
|
|
219
|
-
);
|
|
220
253
|
} catch (error) {
|
|
221
254
|
const message =
|
|
222
255
|
error instanceof Error ? error.message : localization.CMS_TOAST_ERROR;
|
|
223
|
-
|
|
256
|
+
setSubmitError(message);
|
|
224
257
|
} finally {
|
|
225
258
|
setIsSubmitting(false);
|
|
226
259
|
}
|
|
@@ -245,6 +278,7 @@ export function ContentForm({
|
|
|
245
278
|
value={slug}
|
|
246
279
|
onChange={(e) => {
|
|
247
280
|
setSlug(e.target.value);
|
|
281
|
+
setSlugError(null);
|
|
248
282
|
if (!isEditing) {
|
|
249
283
|
setSlugManuallyEdited(true);
|
|
250
284
|
}
|
|
@@ -256,11 +290,19 @@ export function ContentForm({
|
|
|
256
290
|
: "Enter slug..."
|
|
257
291
|
}
|
|
258
292
|
/>
|
|
293
|
+
{slugError && <p className="text-sm text-destructive">{slugError}</p>}
|
|
259
294
|
<p className="text-sm text-muted-foreground">
|
|
260
295
|
{localization.CMS_LABEL_SLUG_DESCRIPTION}
|
|
261
296
|
</p>
|
|
262
297
|
</div>
|
|
263
298
|
|
|
299
|
+
{/* Submit error message */}
|
|
300
|
+
{submitError && (
|
|
301
|
+
<div className="rounded-md border border-destructive/50 bg-destructive/10 p-3">
|
|
302
|
+
<p className="text-sm text-destructive">{submitError}</p>
|
|
303
|
+
</div>
|
|
304
|
+
)}
|
|
305
|
+
|
|
264
306
|
{/* Dynamic form from Zod schema */}
|
|
265
307
|
{/* Uses SteppedAutoForm which automatically handles both single-step and multi-step content types */}
|
|
266
308
|
<SteppedAutoForm
|
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState, useCallback, useMemo } from "react";
|
|
4
|
+
import { useContent, useCreateContent } from "../../hooks";
|
|
5
|
+
import MultipleSelector from "@workspace/ui/components/multi-select";
|
|
6
|
+
import type { Option } from "@workspace/ui/components/multi-select";
|
|
7
|
+
import { Button } from "@workspace/ui/components/button";
|
|
8
|
+
import { Plus, X } from "lucide-react";
|
|
9
|
+
import {
|
|
10
|
+
Dialog,
|
|
11
|
+
DialogContent,
|
|
12
|
+
DialogHeader,
|
|
13
|
+
DialogTitle,
|
|
14
|
+
DialogTrigger,
|
|
15
|
+
} from "@workspace/ui/components/dialog";
|
|
16
|
+
import { Input } from "@workspace/ui/components/input";
|
|
17
|
+
import { Label } from "@workspace/ui/components/label";
|
|
18
|
+
import { Textarea } from "@workspace/ui/components/textarea";
|
|
19
|
+
import type { AutoFormInputComponentProps } from "@workspace/ui/components/auto-form/types";
|
|
20
|
+
import type { RelationConfig } from "../../../types";
|
|
21
|
+
|
|
22
|
+
interface RelationFieldProps extends AutoFormInputComponentProps {
|
|
23
|
+
relation: RelationConfig;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* A form field component for handling CMS content relationships.
|
|
28
|
+
* Supports selecting existing items and optionally creating new items inline.
|
|
29
|
+
*
|
|
30
|
+
* Handles two value formats:
|
|
31
|
+
* - belongsTo: single object { id: string } or undefined
|
|
32
|
+
* - hasMany/manyToMany: array of { id: string }
|
|
33
|
+
*/
|
|
34
|
+
export function RelationField({
|
|
35
|
+
field,
|
|
36
|
+
fieldConfigItem,
|
|
37
|
+
label,
|
|
38
|
+
isRequired,
|
|
39
|
+
relation,
|
|
40
|
+
}: RelationFieldProps) {
|
|
41
|
+
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false);
|
|
42
|
+
const [newItemName, setNewItemName] = useState("");
|
|
43
|
+
const [newItemDescription, setNewItemDescription] = useState("");
|
|
44
|
+
const [createError, setCreateError] = useState<string | null>(null);
|
|
45
|
+
|
|
46
|
+
// For belongsTo (single relation), we only allow one selection
|
|
47
|
+
const isSingleSelect = relation.type === "belongsTo";
|
|
48
|
+
|
|
49
|
+
// Fetch available items from the target content type
|
|
50
|
+
const { items: availableItems, isLoading } = useContent(relation.targetType, {
|
|
51
|
+
limit: 100, // Load a good chunk for the dropdown
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
// Mutation for creating new items
|
|
55
|
+
const createMutation = useCreateContent(relation.targetType);
|
|
56
|
+
|
|
57
|
+
// Normalize the field value to an array for internal use
|
|
58
|
+
// belongsTo stores as single object { id }, hasMany/manyToMany store as array
|
|
59
|
+
const normalizedValue = useMemo((): Array<{ id: string }> => {
|
|
60
|
+
if (!field.value) return [];
|
|
61
|
+
|
|
62
|
+
if (isSingleSelect) {
|
|
63
|
+
// belongsTo: value is { id: string } or undefined
|
|
64
|
+
const singleValue = field.value as { id?: string } | undefined;
|
|
65
|
+
if (singleValue && singleValue.id) {
|
|
66
|
+
return [{ id: singleValue.id }];
|
|
67
|
+
}
|
|
68
|
+
return [];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// hasMany/manyToMany: value is array
|
|
72
|
+
return (field.value as Array<{ id: string }>) || [];
|
|
73
|
+
}, [field.value, isSingleSelect]);
|
|
74
|
+
|
|
75
|
+
// Convert normalized value to Option[] for MultipleSelector
|
|
76
|
+
const selectedOptions: Option[] = normalizedValue
|
|
77
|
+
.map((v) => {
|
|
78
|
+
const item = availableItems.find((item) => item.id === v.id);
|
|
79
|
+
if (item) {
|
|
80
|
+
const displayValue =
|
|
81
|
+
(item.parsedData as Record<string, unknown>)?.[
|
|
82
|
+
relation.displayField
|
|
83
|
+
] || item.slug;
|
|
84
|
+
return {
|
|
85
|
+
value: item.id,
|
|
86
|
+
label: String(displayValue),
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
// Item not found in loaded items - show ID
|
|
90
|
+
return { value: v.id, label: `ID: ${v.id.slice(0, 8)}...` };
|
|
91
|
+
})
|
|
92
|
+
.filter(Boolean);
|
|
93
|
+
|
|
94
|
+
// Convert available items to options
|
|
95
|
+
const options: Option[] = availableItems.map((item) => {
|
|
96
|
+
const displayValue =
|
|
97
|
+
(item.parsedData as Record<string, unknown>)?.[relation.displayField] ||
|
|
98
|
+
item.slug;
|
|
99
|
+
return {
|
|
100
|
+
value: item.id,
|
|
101
|
+
label: String(displayValue),
|
|
102
|
+
};
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
// Handle selection change - convert back to appropriate format
|
|
106
|
+
const handleChange = useCallback(
|
|
107
|
+
(newOptions: Option[]) => {
|
|
108
|
+
if (isSingleSelect) {
|
|
109
|
+
// belongsTo: store as single object or undefined
|
|
110
|
+
if (newOptions.length > 0) {
|
|
111
|
+
field.onChange({ id: newOptions[0]!.value });
|
|
112
|
+
} else {
|
|
113
|
+
field.onChange(undefined);
|
|
114
|
+
}
|
|
115
|
+
} else {
|
|
116
|
+
// hasMany/manyToMany: store as array
|
|
117
|
+
const newValue = newOptions.map((opt) => ({ id: opt.value }));
|
|
118
|
+
field.onChange(newValue);
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
[field, isSingleSelect],
|
|
122
|
+
);
|
|
123
|
+
|
|
124
|
+
// Handle creating a new item
|
|
125
|
+
const handleCreateItem = async () => {
|
|
126
|
+
if (!newItemName.trim()) return;
|
|
127
|
+
|
|
128
|
+
setCreateError(null);
|
|
129
|
+
try {
|
|
130
|
+
const result = await createMutation.mutateAsync({
|
|
131
|
+
slug: newItemName.toLowerCase().replace(/\s+/g, "-"),
|
|
132
|
+
data: {
|
|
133
|
+
[relation.displayField]: newItemName,
|
|
134
|
+
description: newItemDescription || undefined,
|
|
135
|
+
} as Record<string, unknown>,
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
// Add the new item to the selection
|
|
139
|
+
if (isSingleSelect) {
|
|
140
|
+
// belongsTo: replace with new item
|
|
141
|
+
field.onChange({ id: result.id });
|
|
142
|
+
} else {
|
|
143
|
+
// hasMany/manyToMany: append to array
|
|
144
|
+
const newValue = [...normalizedValue, { id: result.id }];
|
|
145
|
+
field.onChange(newValue);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Reset and close dialog
|
|
149
|
+
setNewItemName("");
|
|
150
|
+
setNewItemDescription("");
|
|
151
|
+
setIsCreateDialogOpen(false);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
const message =
|
|
154
|
+
error instanceof Error
|
|
155
|
+
? error.message
|
|
156
|
+
: "Failed to create item. Please try again.";
|
|
157
|
+
setCreateError(message);
|
|
158
|
+
}
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// Handle removing an item
|
|
162
|
+
const handleRemove = useCallback(
|
|
163
|
+
(idToRemove: string) => {
|
|
164
|
+
if (isSingleSelect) {
|
|
165
|
+
// belongsTo: clear the value
|
|
166
|
+
field.onChange(undefined);
|
|
167
|
+
} else {
|
|
168
|
+
// hasMany/manyToMany: filter out the item
|
|
169
|
+
const newValue = normalizedValue.filter((v) => v.id !== idToRemove);
|
|
170
|
+
field.onChange(newValue);
|
|
171
|
+
}
|
|
172
|
+
},
|
|
173
|
+
[normalizedValue, field, isSingleSelect],
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
return (
|
|
177
|
+
<div className="space-y-2">
|
|
178
|
+
<Label>
|
|
179
|
+
{label}
|
|
180
|
+
{isRequired && <span className="text-destructive ml-1">*</span>}
|
|
181
|
+
</Label>
|
|
182
|
+
|
|
183
|
+
<div className="flex gap-2">
|
|
184
|
+
<div className="flex-1">
|
|
185
|
+
<MultipleSelector
|
|
186
|
+
value={selectedOptions}
|
|
187
|
+
onChange={handleChange}
|
|
188
|
+
options={options}
|
|
189
|
+
placeholder={
|
|
190
|
+
isLoading
|
|
191
|
+
? "Loading..."
|
|
192
|
+
: `Select ${relation.targetType}${isSingleSelect ? "" : "(s)"}...`
|
|
193
|
+
}
|
|
194
|
+
disabled={isLoading}
|
|
195
|
+
hidePlaceholderWhenSelected
|
|
196
|
+
emptyIndicator={
|
|
197
|
+
<p className="text-center text-sm text-muted-foreground py-4">
|
|
198
|
+
No {relation.targetType} items found
|
|
199
|
+
</p>
|
|
200
|
+
}
|
|
201
|
+
maxSelected={isSingleSelect ? 1 : undefined}
|
|
202
|
+
className="min-h-10"
|
|
203
|
+
/>
|
|
204
|
+
</div>
|
|
205
|
+
|
|
206
|
+
{/* Create new item button/dialog */}
|
|
207
|
+
{relation.creatable && (
|
|
208
|
+
<Dialog
|
|
209
|
+
open={isCreateDialogOpen}
|
|
210
|
+
onOpenChange={setIsCreateDialogOpen}
|
|
211
|
+
>
|
|
212
|
+
<DialogTrigger asChild>
|
|
213
|
+
<Button type="button" variant="outline" size="icon">
|
|
214
|
+
<Plus className="h-4 w-4" />
|
|
215
|
+
</Button>
|
|
216
|
+
</DialogTrigger>
|
|
217
|
+
<DialogContent>
|
|
218
|
+
<DialogHeader>
|
|
219
|
+
<DialogTitle>Create New {relation.targetType}</DialogTitle>
|
|
220
|
+
</DialogHeader>
|
|
221
|
+
<div className="space-y-4 py-4">
|
|
222
|
+
{createError && (
|
|
223
|
+
<p className="text-sm text-destructive">{createError}</p>
|
|
224
|
+
)}
|
|
225
|
+
<div className="space-y-2">
|
|
226
|
+
<Label htmlFor="newItemName">
|
|
227
|
+
{relation.displayField.charAt(0).toUpperCase() +
|
|
228
|
+
relation.displayField.slice(1)}
|
|
229
|
+
</Label>
|
|
230
|
+
<Input
|
|
231
|
+
id="newItemName"
|
|
232
|
+
value={newItemName}
|
|
233
|
+
onChange={(e) => setNewItemName(e.target.value)}
|
|
234
|
+
placeholder={`Enter ${relation.displayField}...`}
|
|
235
|
+
/>
|
|
236
|
+
</div>
|
|
237
|
+
<div className="space-y-2">
|
|
238
|
+
<Label htmlFor="newItemDescription">
|
|
239
|
+
Description (optional)
|
|
240
|
+
</Label>
|
|
241
|
+
<Textarea
|
|
242
|
+
id="newItemDescription"
|
|
243
|
+
value={newItemDescription}
|
|
244
|
+
onChange={(e) => setNewItemDescription(e.target.value)}
|
|
245
|
+
placeholder="Enter description..."
|
|
246
|
+
rows={3}
|
|
247
|
+
/>
|
|
248
|
+
</div>
|
|
249
|
+
<div className="flex justify-end gap-2">
|
|
250
|
+
<Button
|
|
251
|
+
type="button"
|
|
252
|
+
variant="outline"
|
|
253
|
+
onClick={() => setIsCreateDialogOpen(false)}
|
|
254
|
+
>
|
|
255
|
+
Cancel
|
|
256
|
+
</Button>
|
|
257
|
+
<Button
|
|
258
|
+
type="button"
|
|
259
|
+
onClick={handleCreateItem}
|
|
260
|
+
disabled={!newItemName.trim() || createMutation.isPending}
|
|
261
|
+
>
|
|
262
|
+
{createMutation.isPending ? "Creating..." : "Create"}
|
|
263
|
+
</Button>
|
|
264
|
+
</div>
|
|
265
|
+
</div>
|
|
266
|
+
</DialogContent>
|
|
267
|
+
</Dialog>
|
|
268
|
+
)}
|
|
269
|
+
</div>
|
|
270
|
+
|
|
271
|
+
{/* Show selected items as removable badges */}
|
|
272
|
+
{selectedOptions.length > 0 && (
|
|
273
|
+
<div className="flex flex-wrap gap-1 mt-2">
|
|
274
|
+
{selectedOptions.map((opt) => (
|
|
275
|
+
<div
|
|
276
|
+
key={opt.value}
|
|
277
|
+
className="inline-flex items-center gap-1 px-2 py-1 text-xs rounded-md bg-secondary text-secondary-foreground"
|
|
278
|
+
>
|
|
279
|
+
<span>{opt.label}</span>
|
|
280
|
+
<button
|
|
281
|
+
type="button"
|
|
282
|
+
onClick={() => handleRemove(opt.value)}
|
|
283
|
+
className="hover:text-destructive"
|
|
284
|
+
>
|
|
285
|
+
<X className="h-3 w-3" />
|
|
286
|
+
</button>
|
|
287
|
+
</div>
|
|
288
|
+
))}
|
|
289
|
+
</div>
|
|
290
|
+
)}
|
|
291
|
+
|
|
292
|
+
{fieldConfigItem?.description && (
|
|
293
|
+
<p className="text-sm text-muted-foreground">
|
|
294
|
+
{fieldConfigItem.description}
|
|
295
|
+
</p>
|
|
296
|
+
)}
|
|
297
|
+
</div>
|
|
298
|
+
);
|
|
299
|
+
}
|