@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.
Files changed (110) hide show
  1. package/dist/api/index.d.cts +2 -2
  2. package/dist/api/index.d.mts +2 -2
  3. package/dist/api/index.d.ts +2 -2
  4. package/dist/client/index.cjs +6 -2
  5. package/dist/client/index.d.cts +2 -1
  6. package/dist/client/index.d.mts +2 -1
  7. package/dist/client/index.d.ts +2 -1
  8. package/dist/client/index.mjs +6 -2
  9. package/dist/index.d.cts +1 -1
  10. package/dist/index.d.mts +1 -1
  11. package/dist/index.d.ts +1 -1
  12. package/dist/packages/better-stack/src/plugins/cms/api/plugin.cjs +445 -16
  13. package/dist/packages/better-stack/src/plugins/cms/api/plugin.mjs +445 -16
  14. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.cjs +24 -7
  15. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/content-form.mjs +25 -8
  16. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.cjs +224 -0
  17. package/dist/packages/better-stack/src/plugins/cms/client/components/forms/relation-field.mjs +222 -0
  18. package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.cjs +243 -0
  19. package/dist/packages/better-stack/src/plugins/cms/client/components/inverse-relations-panel.mjs +241 -0
  20. package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.cjs +56 -2
  21. package/dist/packages/better-stack/src/plugins/cms/client/components/pages/content-editor-page.internal.mjs +56 -2
  22. package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.cjs +190 -0
  23. package/dist/packages/better-stack/src/plugins/cms/client/hooks/cms-hooks.mjs +187 -1
  24. package/dist/packages/better-stack/src/plugins/cms/db.cjs +38 -0
  25. package/dist/packages/better-stack/src/plugins/cms/db.mjs +38 -0
  26. package/dist/packages/better-stack/src/plugins/route-docs/client/components/loading/docs-skeleton.cjs +43 -0
  27. package/dist/packages/better-stack/src/plugins/route-docs/client/components/loading/docs-skeleton.mjs +41 -0
  28. package/dist/packages/better-stack/src/plugins/route-docs/client/components/pages/docs-page.cjs +794 -0
  29. package/dist/packages/better-stack/src/plugins/route-docs/client/components/pages/docs-page.mjs +788 -0
  30. package/dist/packages/better-stack/src/plugins/route-docs/client/plugin.cjs +111 -0
  31. package/dist/packages/better-stack/src/plugins/route-docs/client/plugin.mjs +106 -0
  32. package/dist/packages/better-stack/src/plugins/route-docs/generator.cjs +244 -0
  33. package/dist/packages/better-stack/src/plugins/route-docs/generator.mjs +227 -0
  34. package/dist/packages/ui/src/components/auto-form/fields/object.cjs +81 -1
  35. package/dist/packages/ui/src/components/auto-form/fields/object.mjs +81 -1
  36. package/dist/packages/ui/src/components/dialog.cjs +6 -0
  37. package/dist/packages/ui/src/components/dialog.mjs +6 -1
  38. package/dist/packages/ui/src/components/sheet.cjs +25 -0
  39. package/dist/packages/ui/src/components/sheet.mjs +24 -1
  40. package/dist/plugins/api/index.d.cts +2 -2
  41. package/dist/plugins/api/index.d.mts +2 -2
  42. package/dist/plugins/api/index.d.ts +2 -2
  43. package/dist/plugins/blog/api/index.d.cts +1 -1
  44. package/dist/plugins/blog/api/index.d.mts +1 -1
  45. package/dist/plugins/blog/api/index.d.ts +1 -1
  46. package/dist/plugins/blog/client/hooks/index.d.cts +2 -2
  47. package/dist/plugins/blog/client/hooks/index.d.mts +2 -2
  48. package/dist/plugins/blog/client/hooks/index.d.ts +2 -2
  49. package/dist/plugins/blog/client/index.d.cts +1 -1
  50. package/dist/plugins/blog/client/index.d.mts +1 -1
  51. package/dist/plugins/blog/client/index.d.ts +1 -1
  52. package/dist/plugins/blog/query-keys.d.cts +2 -2
  53. package/dist/plugins/blog/query-keys.d.mts +2 -2
  54. package/dist/plugins/blog/query-keys.d.ts +2 -2
  55. package/dist/plugins/client/index.d.cts +2 -2
  56. package/dist/plugins/client/index.d.mts +2 -2
  57. package/dist/plugins/client/index.d.ts +2 -2
  58. package/dist/plugins/cms/api/index.d.cts +67 -3
  59. package/dist/plugins/cms/api/index.d.mts +67 -3
  60. package/dist/plugins/cms/api/index.d.ts +67 -3
  61. package/dist/plugins/cms/client/hooks/index.cjs +4 -0
  62. package/dist/plugins/cms/client/hooks/index.d.cts +82 -3
  63. package/dist/plugins/cms/client/hooks/index.d.mts +82 -3
  64. package/dist/plugins/cms/client/hooks/index.d.ts +82 -3
  65. package/dist/plugins/cms/client/hooks/index.mjs +1 -1
  66. package/dist/plugins/cms/query-keys.d.cts +1 -1
  67. package/dist/plugins/cms/query-keys.d.mts +1 -1
  68. package/dist/plugins/cms/query-keys.d.ts +1 -1
  69. package/dist/plugins/form-builder/api/index.d.cts +1 -1
  70. package/dist/plugins/form-builder/api/index.d.mts +1 -1
  71. package/dist/plugins/form-builder/api/index.d.ts +1 -1
  72. package/dist/plugins/open-api/api/index.d.cts +1 -1
  73. package/dist/plugins/open-api/api/index.d.mts +1 -1
  74. package/dist/plugins/open-api/api/index.d.ts +1 -1
  75. package/dist/plugins/route-docs/client/index.cjs +10 -0
  76. package/dist/plugins/route-docs/client/index.d.cts +126 -0
  77. package/dist/plugins/route-docs/client/index.d.mts +126 -0
  78. package/dist/plugins/route-docs/client/index.d.ts +126 -0
  79. package/dist/plugins/route-docs/client/index.mjs +1 -0
  80. package/dist/plugins/route-docs/client.css +3 -0
  81. package/dist/plugins/route-docs/style.css +19 -0
  82. package/dist/shared/{stack.L-UFwz2G.d.mts → stack.oGOteE6g.d.cts} +27 -5
  83. package/dist/shared/{stack.L-UFwz2G.d.ts → stack.oGOteE6g.d.mts} +27 -5
  84. package/dist/shared/{stack.L-UFwz2G.d.cts → stack.oGOteE6g.d.ts} +27 -5
  85. package/dist/shared/{stack.CSce37mX.d.cts → stack.u9iYV6vt.d.cts} +14 -2
  86. package/dist/shared/{stack.CSce37mX.d.mts → stack.u9iYV6vt.d.mts} +14 -2
  87. package/dist/shared/{stack.CSce37mX.d.ts → stack.u9iYV6vt.d.ts} +14 -2
  88. package/package.json +15 -1
  89. package/src/client/index.ts +11 -4
  90. package/src/plugins/cms/api/plugin.ts +667 -21
  91. package/src/plugins/cms/client/components/forms/content-form.tsx +60 -18
  92. package/src/plugins/cms/client/components/forms/relation-field.tsx +299 -0
  93. package/src/plugins/cms/client/components/inverse-relations-panel.tsx +329 -0
  94. package/src/plugins/cms/client/components/pages/content-editor-page.internal.tsx +127 -1
  95. package/src/plugins/cms/client/hooks/cms-hooks.tsx +344 -0
  96. package/src/plugins/cms/db.ts +38 -0
  97. package/src/plugins/cms/types.ts +99 -10
  98. package/src/plugins/route-docs/client/components/loading/docs-skeleton.tsx +82 -0
  99. package/src/plugins/route-docs/client/components/loading/index.tsx +1 -0
  100. package/src/plugins/route-docs/client/components/pages/docs-page.tsx +1240 -0
  101. package/src/plugins/route-docs/client/index.ts +7 -0
  102. package/src/plugins/route-docs/client/plugin.tsx +187 -0
  103. package/src/plugins/route-docs/client.css +3 -0
  104. package/src/plugins/route-docs/generator.ts +385 -0
  105. package/src/plugins/route-docs/index.ts +12 -0
  106. package/src/plugins/route-docs/style.css +19 -0
  107. package/src/types.ts +19 -1
  108. package/dist/shared/{stack.CcI4sYJP.d.mts → stack.DLhzx1-D.d.cts} +1 -1
  109. package/dist/shared/{stack.CcI4sYJP.d.ts → stack.DLhzx1-D.d.mts} +1 -1
  110. 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 "file" fieldType ONLY if no custom component exists
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
- { fieldType?: string }
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
- // Only handle "file" fieldType when there's NO custom component for "file"
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 (e.g., when editing an existing item)
144
- // This is necessary because useState only uses the initial value once on mount
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
- // Only sync when we're in editing mode and initialData has content
147
- // This ensures we properly load existing item data into the form
148
- if (isEditing && Object.keys(initialData).length > 0) {
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
- toast.error("Slug is required");
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
- toast.error(message);
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
+ }