@questpie/admin 0.0.1

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 (203) hide show
  1. package/.turbo/turbo-build.log +108 -0
  2. package/CHANGELOG.md +10 -0
  3. package/README.md +556 -0
  4. package/STATUS.md +917 -0
  5. package/VALIDATION.md +602 -0
  6. package/components.json +24 -0
  7. package/dist/__tests__/setup.mjs +38 -0
  8. package/dist/__tests__/test-utils.mjs +45 -0
  9. package/dist/__tests__/vitest.d.mjs +3 -0
  10. package/dist/components/admin-app.mjs +69 -0
  11. package/dist/components/fields/array-field.mjs +190 -0
  12. package/dist/components/fields/checkbox-field.mjs +34 -0
  13. package/dist/components/fields/custom-field.mjs +32 -0
  14. package/dist/components/fields/date-field.mjs +41 -0
  15. package/dist/components/fields/datetime-field.mjs +42 -0
  16. package/dist/components/fields/email-field.mjs +37 -0
  17. package/dist/components/fields/embedded-collection.mjs +253 -0
  18. package/dist/components/fields/field-types.mjs +1 -0
  19. package/dist/components/fields/field-utils.mjs +10 -0
  20. package/dist/components/fields/field-wrapper.mjs +34 -0
  21. package/dist/components/fields/index.mjs +23 -0
  22. package/dist/components/fields/json-field.mjs +243 -0
  23. package/dist/components/fields/locale-badge.mjs +16 -0
  24. package/dist/components/fields/number-field.mjs +39 -0
  25. package/dist/components/fields/password-field.mjs +37 -0
  26. package/dist/components/fields/relation-field.mjs +104 -0
  27. package/dist/components/fields/relation-picker.mjs +229 -0
  28. package/dist/components/fields/relation-select.mjs +188 -0
  29. package/dist/components/fields/rich-text-editor/index.mjs +897 -0
  30. package/dist/components/fields/select-field.mjs +41 -0
  31. package/dist/components/fields/switch-field.mjs +34 -0
  32. package/dist/components/fields/text-field.mjs +38 -0
  33. package/dist/components/fields/textarea-field.mjs +38 -0
  34. package/dist/components/index.mjs +59 -0
  35. package/dist/components/primitives/checkbox-input.mjs +127 -0
  36. package/dist/components/primitives/date-input.mjs +303 -0
  37. package/dist/components/primitives/index.mjs +12 -0
  38. package/dist/components/primitives/number-input.mjs +104 -0
  39. package/dist/components/primitives/select-input.mjs +177 -0
  40. package/dist/components/primitives/tag-input.mjs +135 -0
  41. package/dist/components/primitives/text-input.mjs +39 -0
  42. package/dist/components/primitives/textarea-input.mjs +37 -0
  43. package/dist/components/primitives/toggle-input.mjs +31 -0
  44. package/dist/components/primitives/types.mjs +12 -0
  45. package/dist/components/ui/accordion.mjs +55 -0
  46. package/dist/components/ui/avatar.mjs +54 -0
  47. package/dist/components/ui/badge.mjs +34 -0
  48. package/dist/components/ui/button.mjs +48 -0
  49. package/dist/components/ui/card.mjs +58 -0
  50. package/dist/components/ui/checkbox.mjs +21 -0
  51. package/dist/components/ui/combobox.mjs +163 -0
  52. package/dist/components/ui/dialog.mjs +95 -0
  53. package/dist/components/ui/dropdown-menu.mjs +138 -0
  54. package/dist/components/ui/field.mjs +113 -0
  55. package/dist/components/ui/input-group.mjs +82 -0
  56. package/dist/components/ui/input.mjs +17 -0
  57. package/dist/components/ui/label.mjs +15 -0
  58. package/dist/components/ui/popover.mjs +56 -0
  59. package/dist/components/ui/scroll-area.mjs +38 -0
  60. package/dist/components/ui/select.mjs +100 -0
  61. package/dist/components/ui/separator.mjs +16 -0
  62. package/dist/components/ui/sheet.mjs +90 -0
  63. package/dist/components/ui/sidebar.mjs +387 -0
  64. package/dist/components/ui/skeleton.mjs +14 -0
  65. package/dist/components/ui/spinner.mjs +16 -0
  66. package/dist/components/ui/switch.mjs +22 -0
  67. package/dist/components/ui/table.mjs +68 -0
  68. package/dist/components/ui/tabs.mjs +48 -0
  69. package/dist/components/ui/textarea.mjs +15 -0
  70. package/dist/components/ui/tooltip.mjs +44 -0
  71. package/dist/config/component-registry.mjs +38 -0
  72. package/dist/config/index.mjs +129 -0
  73. package/dist/hooks/admin-provider.mjs +70 -0
  74. package/dist/hooks/index.mjs +7 -0
  75. package/dist/hooks/store.mjs +178 -0
  76. package/dist/hooks/use-auth.mjs +76 -0
  77. package/dist/hooks/use-collection-db.mjs +146 -0
  78. package/dist/hooks/use-collection.mjs +112 -0
  79. package/dist/hooks/use-global.mjs +46 -0
  80. package/dist/hooks/use-mobile.mjs +20 -0
  81. package/dist/lib/utils.mjs +10 -0
  82. package/dist/styles/index.css +336 -0
  83. package/dist/styles/index.mjs +1 -0
  84. package/dist/utils/index.mjs +9 -0
  85. package/dist/views/auth/auth-layout.mjs +52 -0
  86. package/dist/views/auth/forgot-password-form.mjs +148 -0
  87. package/dist/views/auth/index.mjs +6 -0
  88. package/dist/views/auth/login-form.mjs +156 -0
  89. package/dist/views/auth/reset-password-form.mjs +184 -0
  90. package/dist/views/collection/auto-form-fields.mjs +525 -0
  91. package/dist/views/collection/collection-form.mjs +91 -0
  92. package/dist/views/collection/collection-list.mjs +76 -0
  93. package/dist/views/collection/form-field.mjs +42 -0
  94. package/dist/views/collection/index.mjs +6 -0
  95. package/dist/views/common/index.mjs +4 -0
  96. package/dist/views/common/locale-switcher.mjs +39 -0
  97. package/dist/views/common/version-history.mjs +272 -0
  98. package/dist/views/index.mjs +9 -0
  99. package/dist/views/layout/admin-layout.mjs +40 -0
  100. package/dist/views/layout/admin-router.mjs +95 -0
  101. package/dist/views/layout/admin-sidebar.mjs +63 -0
  102. package/dist/views/layout/index.mjs +5 -0
  103. package/package.json +276 -0
  104. package/src/__tests__/setup.ts +44 -0
  105. package/src/__tests__/test-utils.tsx +49 -0
  106. package/src/__tests__/vitest.d.ts +9 -0
  107. package/src/components/admin-app.tsx +221 -0
  108. package/src/components/fields/array-field.tsx +237 -0
  109. package/src/components/fields/checkbox-field.tsx +47 -0
  110. package/src/components/fields/custom-field.tsx +50 -0
  111. package/src/components/fields/date-field.tsx +65 -0
  112. package/src/components/fields/datetime-field.tsx +67 -0
  113. package/src/components/fields/email-field.tsx +51 -0
  114. package/src/components/fields/embedded-collection.tsx +315 -0
  115. package/src/components/fields/field-types.ts +162 -0
  116. package/src/components/fields/field-utils.ts +6 -0
  117. package/src/components/fields/field-wrapper.tsx +52 -0
  118. package/src/components/fields/index.ts +66 -0
  119. package/src/components/fields/json-field.tsx +440 -0
  120. package/src/components/fields/locale-badge.tsx +15 -0
  121. package/src/components/fields/number-field.tsx +57 -0
  122. package/src/components/fields/password-field.tsx +51 -0
  123. package/src/components/fields/relation-field.tsx +243 -0
  124. package/src/components/fields/relation-picker.tsx +402 -0
  125. package/src/components/fields/relation-select.tsx +327 -0
  126. package/src/components/fields/rich-text-editor/index.tsx +1337 -0
  127. package/src/components/fields/select-field.tsx +61 -0
  128. package/src/components/fields/switch-field.tsx +47 -0
  129. package/src/components/fields/text-field.tsx +55 -0
  130. package/src/components/fields/textarea-field.tsx +55 -0
  131. package/src/components/index.ts +40 -0
  132. package/src/components/primitives/checkbox-input.tsx +193 -0
  133. package/src/components/primitives/date-input.tsx +401 -0
  134. package/src/components/primitives/index.ts +24 -0
  135. package/src/components/primitives/number-input.tsx +132 -0
  136. package/src/components/primitives/select-input.tsx +296 -0
  137. package/src/components/primitives/tag-input.tsx +200 -0
  138. package/src/components/primitives/text-input.tsx +49 -0
  139. package/src/components/primitives/textarea-input.tsx +46 -0
  140. package/src/components/primitives/toggle-input.tsx +36 -0
  141. package/src/components/primitives/types.ts +235 -0
  142. package/src/components/ui/accordion.tsx +72 -0
  143. package/src/components/ui/avatar.tsx +106 -0
  144. package/src/components/ui/badge.tsx +48 -0
  145. package/src/components/ui/button.tsx +53 -0
  146. package/src/components/ui/card.tsx +94 -0
  147. package/src/components/ui/checkbox.tsx +27 -0
  148. package/src/components/ui/combobox.tsx +290 -0
  149. package/src/components/ui/dialog.tsx +151 -0
  150. package/src/components/ui/dropdown-menu.tsx +254 -0
  151. package/src/components/ui/field.tsx +227 -0
  152. package/src/components/ui/input-group.tsx +149 -0
  153. package/src/components/ui/input.tsx +20 -0
  154. package/src/components/ui/label.tsx +18 -0
  155. package/src/components/ui/popover.tsx +88 -0
  156. package/src/components/ui/scroll-area.tsx +53 -0
  157. package/src/components/ui/select.tsx +192 -0
  158. package/src/components/ui/separator.tsx +23 -0
  159. package/src/components/ui/sheet.tsx +127 -0
  160. package/src/components/ui/sidebar.tsx +723 -0
  161. package/src/components/ui/skeleton.tsx +13 -0
  162. package/src/components/ui/spinner.tsx +10 -0
  163. package/src/components/ui/switch.tsx +32 -0
  164. package/src/components/ui/table.tsx +99 -0
  165. package/src/components/ui/tabs.tsx +82 -0
  166. package/src/components/ui/textarea.tsx +18 -0
  167. package/src/components/ui/tooltip.tsx +70 -0
  168. package/src/config/component-registry.ts +190 -0
  169. package/src/config/index.ts +1099 -0
  170. package/src/hooks/README.md +269 -0
  171. package/src/hooks/admin-provider.tsx +110 -0
  172. package/src/hooks/index.ts +41 -0
  173. package/src/hooks/store.ts +248 -0
  174. package/src/hooks/use-auth.ts +168 -0
  175. package/src/hooks/use-collection-db.ts +209 -0
  176. package/src/hooks/use-collection.ts +156 -0
  177. package/src/hooks/use-global.ts +69 -0
  178. package/src/hooks/use-mobile.ts +21 -0
  179. package/src/lib/utils.ts +6 -0
  180. package/src/styles/index.css +340 -0
  181. package/src/utils/index.ts +6 -0
  182. package/src/views/auth/auth-layout.tsx +77 -0
  183. package/src/views/auth/forgot-password-form.tsx +192 -0
  184. package/src/views/auth/index.ts +21 -0
  185. package/src/views/auth/login-form.tsx +229 -0
  186. package/src/views/auth/reset-password-form.tsx +232 -0
  187. package/src/views/collection/auto-form-fields.tsx +982 -0
  188. package/src/views/collection/collection-form.tsx +186 -0
  189. package/src/views/collection/collection-list.tsx +223 -0
  190. package/src/views/collection/form-field.tsx +52 -0
  191. package/src/views/collection/index.ts +15 -0
  192. package/src/views/common/index.ts +8 -0
  193. package/src/views/common/locale-switcher.tsx +45 -0
  194. package/src/views/common/version-history.tsx +406 -0
  195. package/src/views/index.ts +25 -0
  196. package/src/views/layout/admin-layout.tsx +117 -0
  197. package/src/views/layout/admin-router.tsx +206 -0
  198. package/src/views/layout/admin-sidebar.tsx +185 -0
  199. package/src/views/layout/index.ts +12 -0
  200. package/tsconfig.json +13 -0
  201. package/tsconfig.tsbuildinfo +1 -0
  202. package/tsdown.config.ts +13 -0
  203. package/vitest.config.ts +29 -0
@@ -0,0 +1,243 @@
1
+ /**
2
+ * RelationField Component
3
+ *
4
+ * Unified relation field that automatically chooses between:
5
+ * - RelationSelect (single relation / one-to-one)
6
+ * - RelationPicker (multiple relations / one-to-many, many-to-many)
7
+ *
8
+ * Integrates with react-hook-form via Controller.
9
+ */
10
+
11
+ import * as React from "react";
12
+ import { Controller, useFormContext, type Control } from "react-hook-form";
13
+ import { RelationSelect, type RelationSelectProps } from "./relation-select";
14
+ import { RelationPicker, type RelationPickerProps } from "./relation-picker";
15
+ import type { Questpie } from "questpie";
16
+
17
+ export type RelationFieldProps<T extends Questpie<any>> = {
18
+ /**
19
+ * Field name (for react-hook-form)
20
+ */
21
+ name: string;
22
+
23
+ /**
24
+ * Target collection name
25
+ */
26
+ targetCollection: string;
27
+
28
+ /**
29
+ * Relation type:
30
+ * - "single" (one-to-one) - uses RelationSelect
31
+ * - "multiple" (one-to-many, many-to-many) - uses RelationPicker
32
+ */
33
+ type: "single" | "multiple";
34
+
35
+ /**
36
+ * Label for the field
37
+ */
38
+ label?: string;
39
+
40
+ /**
41
+ * Description/help text
42
+ */
43
+ description?: string;
44
+
45
+ /**
46
+ * Localized field
47
+ */
48
+ localized?: boolean;
49
+
50
+ /**
51
+ * Active locale
52
+ */
53
+ locale?: string;
54
+
55
+ /**
56
+ * Filter options based on form values
57
+ */
58
+ filter?: (formValues: any) => any;
59
+
60
+ /**
61
+ * Is the field required
62
+ */
63
+ required?: boolean;
64
+
65
+ /**
66
+ * Is the field disabled
67
+ */
68
+ disabled?: boolean;
69
+
70
+ /**
71
+ * Is the field readonly
72
+ */
73
+ readOnly?: boolean;
74
+
75
+ /**
76
+ * Placeholder text
77
+ */
78
+ placeholder?: string;
79
+
80
+ /**
81
+ * Enable drag-and-drop reordering (only for multiple type)
82
+ */
83
+ orderable?: boolean;
84
+
85
+ /**
86
+ * Maximum number of items (only for multiple type)
87
+ */
88
+ maxItems?: number;
89
+
90
+ /**
91
+ * Custom render function for dropdown options
92
+ */
93
+ renderOption?: (item: any) => React.ReactNode;
94
+
95
+ /**
96
+ * Custom render function for selected value (single) or items (multiple)
97
+ */
98
+ renderValue?: (item: any) => React.ReactNode;
99
+
100
+ /**
101
+ * Custom render function for selected items (only for multiple type)
102
+ */
103
+ renderItem?: (item: any, index: number) => React.ReactNode;
104
+
105
+ /**
106
+ * Form fields to render in create/edit sheet
107
+ */
108
+ renderFormFields?: (collection: string, itemId?: string) => React.ReactNode;
109
+
110
+ /**
111
+ * Form control (optional, will use useFormContext if not provided)
112
+ */
113
+ control?: Control<any>;
114
+ };
115
+
116
+ /**
117
+ * Unified relation field component that integrates with react-hook-form.
118
+ *
119
+ * Automatically chooses between RelationSelect (single) and RelationPicker (multiple)
120
+ * based on the `type` prop.
121
+ *
122
+ * @example
123
+ * ```tsx
124
+ * // Single relation (one-to-one)
125
+ * <RelationField
126
+ * name="author"
127
+ * targetCollection="users"
128
+ * type="single"
129
+ * label="Author"
130
+ * required
131
+ * />
132
+ *
133
+ * // Multiple relations (many-to-many)
134
+ * <RelationField
135
+ * name="tags"
136
+ * targetCollection="tags"
137
+ * type="multiple"
138
+ * label="Tags"
139
+ * maxItems={5}
140
+ * orderable
141
+ * />
142
+ * ```
143
+ */
144
+ export function RelationField<T extends Questpie<any>>({
145
+ name,
146
+ targetCollection,
147
+ type,
148
+ label,
149
+ description,
150
+ localized,
151
+ locale,
152
+ filter,
153
+ required,
154
+ disabled,
155
+ readOnly,
156
+ placeholder,
157
+ orderable,
158
+ maxItems,
159
+ renderOption,
160
+ renderValue,
161
+ renderItem,
162
+ renderFormFields,
163
+ control: controlProp,
164
+ }: RelationFieldProps<T>) {
165
+ const formContext = useFormContext();
166
+ const control = controlProp ?? formContext?.control;
167
+
168
+ if (!control) {
169
+ console.warn(
170
+ "RelationField: No form control found. Make sure to use within FormProvider or pass control prop.",
171
+ );
172
+ return null;
173
+ }
174
+
175
+ return (
176
+ <Controller
177
+ name={name}
178
+ control={control}
179
+ rules={{
180
+ required: required ? `${label || name} is required` : undefined,
181
+ }}
182
+ render={({ field, fieldState }) => {
183
+ const error = fieldState.error?.message;
184
+
185
+ if (type === "single") {
186
+ return (
187
+ <div className="space-y-1">
188
+ <RelationSelect<T>
189
+ name={name}
190
+ value={field.value as string | null}
191
+ onChange={field.onChange}
192
+ targetCollection={targetCollection}
193
+ label={label}
194
+ localized={localized}
195
+ locale={locale}
196
+ filter={filter}
197
+ required={required}
198
+ disabled={disabled}
199
+ readOnly={readOnly}
200
+ placeholder={placeholder}
201
+ error={error}
202
+ renderOption={renderOption}
203
+ renderValue={renderValue}
204
+ renderFormFields={renderFormFields}
205
+ />
206
+ {description && !error && (
207
+ <p className="text-muted-foreground text-xs">{description}</p>
208
+ )}
209
+ </div>
210
+ );
211
+ }
212
+
213
+ return (
214
+ <div className="space-y-1">
215
+ <RelationPicker<T>
216
+ name={name}
217
+ value={field.value as string[] | null}
218
+ onChange={field.onChange}
219
+ targetCollection={targetCollection}
220
+ label={label}
221
+ localized={localized}
222
+ locale={locale}
223
+ filter={filter}
224
+ required={required}
225
+ disabled={disabled}
226
+ readOnly={readOnly}
227
+ placeholder={placeholder}
228
+ error={error}
229
+ orderable={orderable}
230
+ maxItems={maxItems}
231
+ renderOption={renderOption}
232
+ renderItem={renderItem}
233
+ renderFormFields={renderFormFields}
234
+ />
235
+ {description && !error && (
236
+ <p className="text-muted-foreground text-xs">{description}</p>
237
+ )}
238
+ </div>
239
+ );
240
+ }}
241
+ />
242
+ );
243
+ }
@@ -0,0 +1,402 @@
1
+ /**
2
+ * RelationPicker Component
3
+ *
4
+ * Multiple relation field (one-to-many, many-to-many) with:
5
+ * - Multi-select picker to choose existing items
6
+ * - Plus button to create new related item (opens side sheet)
7
+ * - Edit button on each selected item (opens side sheet)
8
+ * - Remove button on each selected item
9
+ * - Optional drag-and-drop reordering
10
+ */
11
+
12
+ import * as React from "react";
13
+ import { useQuery } from "@tanstack/react-query";
14
+ import { Plus, Pencil, X, DotsSixVertical } from "@phosphor-icons/react";
15
+ import { Button } from "../ui/button";
16
+ import {
17
+ Sheet,
18
+ SheetContent,
19
+ SheetDescription,
20
+ SheetHeader,
21
+ SheetTitle,
22
+ } from "../ui/sheet";
23
+ import { Spinner } from "../ui/spinner";
24
+ import { LocaleBadge } from "./locale-badge";
25
+ import {
26
+ Combobox,
27
+ ComboboxInput,
28
+ ComboboxContent,
29
+ ComboboxList,
30
+ ComboboxItem,
31
+ ComboboxEmpty,
32
+ } from "../ui/combobox";
33
+ import { useAdminContext } from "../../hooks/admin-provider";
34
+ import type { Questpie } from "questpie";
35
+
36
+ export interface RelationPickerProps<T extends Questpie<any>> {
37
+ /**
38
+ * Field name
39
+ */
40
+ name: string;
41
+
42
+ /**
43
+ * Current value (array of IDs of related items)
44
+ */
45
+ value?: string[] | null;
46
+
47
+ /**
48
+ * Change handler
49
+ */
50
+ onChange: (value: string[]) => void;
51
+
52
+ /**
53
+ * Target collection name
54
+ */
55
+ targetCollection: string;
56
+
57
+ /**
58
+ * Label for the field
59
+ */
60
+ label?: string;
61
+
62
+ /**
63
+ * Localized field
64
+ */
65
+ localized?: boolean;
66
+
67
+ /**
68
+ * Active locale
69
+ */
70
+ locale?: string;
71
+
72
+ /**
73
+ * Filter options based on form values
74
+ */
75
+ filter?: (formValues: any) => any;
76
+
77
+ /**
78
+ * Is the field required
79
+ */
80
+ required?: boolean;
81
+
82
+ /**
83
+ * Is the field disabled
84
+ */
85
+ disabled?: boolean;
86
+
87
+ /**
88
+ * Is the field readonly
89
+ */
90
+ readOnly?: boolean;
91
+
92
+ /**
93
+ * Placeholder text
94
+ */
95
+ placeholder?: string;
96
+
97
+ /**
98
+ * Error message
99
+ */
100
+ error?: string;
101
+
102
+ /**
103
+ * Enable drag-and-drop reordering
104
+ */
105
+ orderable?: boolean;
106
+
107
+ /**
108
+ * Maximum number of items
109
+ */
110
+ maxItems?: number;
111
+
112
+ /**
113
+ * Custom render function for selected items
114
+ */
115
+ renderItem?: (item: any, index: number) => React.ReactNode;
116
+
117
+ /**
118
+ * Custom render function for dropdown options
119
+ */
120
+ renderOption?: (item: any) => React.ReactNode;
121
+
122
+ /**
123
+ * Form fields to render in create/edit sheet
124
+ */
125
+ renderFormFields?: (collection: string, itemId?: string) => React.ReactNode;
126
+ }
127
+
128
+ export function RelationPicker<T extends Questpie<any>>({
129
+ name,
130
+ value = [],
131
+ onChange,
132
+ targetCollection,
133
+ label,
134
+ filter,
135
+ required,
136
+ disabled,
137
+ readOnly,
138
+ placeholder,
139
+ error,
140
+ localized,
141
+ locale: localeProp,
142
+ orderable = false,
143
+ maxItems,
144
+ renderItem,
145
+ renderOption,
146
+ renderFormFields,
147
+ }: RelationPickerProps<T>) {
148
+ const { client, locale: contextLocale } = useAdminContext<T>();
149
+ const locale = localeProp ?? contextLocale;
150
+ const localeKey = locale ?? "default";
151
+ const [isSheetOpen, setIsSheetOpen] = React.useState(false);
152
+ const [sheetMode, setSheetMode] = React.useState<"create" | "edit">("create");
153
+ const [editingItemId, setEditingItemId] = React.useState<
154
+ string | undefined
155
+ >();
156
+
157
+ const selectedIds = value || [];
158
+
159
+ // Fetch all available options
160
+ const { data: allOptions, isLoading } = useQuery({
161
+ queryKey: ["relation", targetCollection, localeKey, filter],
162
+ queryFn: async () => {
163
+ const api = (client as any).collections?.[targetCollection];
164
+ if (!api?.list) return [];
165
+ const result = await api.list({
166
+ ...(filter ? filter({}) : {}),
167
+ });
168
+ return result.data || [];
169
+ },
170
+ });
171
+
172
+ // Fetch selected items details
173
+ const { data: selectedItems } = useQuery({
174
+ queryKey: [
175
+ "relation",
176
+ targetCollection,
177
+ localeKey,
178
+ "selected",
179
+ selectedIds,
180
+ ],
181
+ queryFn: async () => {
182
+ if (!selectedIds.length) return [];
183
+ const api = (client as any).collections?.[targetCollection];
184
+ if (!api?.get) return [];
185
+ const items = await Promise.all(selectedIds.map((id) => api.get(id)));
186
+ return items;
187
+ },
188
+ enabled: selectedIds.length > 0,
189
+ });
190
+
191
+ const handleAdd = (itemId: string) => {
192
+ if (selectedIds.includes(itemId)) return;
193
+ if (maxItems && selectedIds.length >= maxItems) return;
194
+ onChange([...selectedIds, itemId]);
195
+ };
196
+
197
+ const handleRemove = (itemId: string) => {
198
+ onChange(selectedIds.filter((id) => id !== itemId));
199
+ };
200
+
201
+ const handleOpenCreate = () => {
202
+ setSheetMode("create");
203
+ setEditingItemId(undefined);
204
+ setIsSheetOpen(true);
205
+ };
206
+
207
+ const handleOpenEdit = (itemId: string) => {
208
+ setSheetMode("edit");
209
+ setEditingItemId(itemId);
210
+ setIsSheetOpen(true);
211
+ };
212
+
213
+ const getDisplayValue = (item: any) => {
214
+ return item?._title || item?.id || "";
215
+ };
216
+
217
+ const getOptionDisplay = (item: any) => {
218
+ if (renderOption) return renderOption(item);
219
+ return item?._title || item?.id || "";
220
+ };
221
+
222
+ // Filter out already selected items from dropdown
223
+ const availableOptions =
224
+ allOptions?.filter((opt: any) => !selectedIds.includes(opt.id)) || [];
225
+
226
+ const canAddMore = !maxItems || selectedIds.length < maxItems;
227
+
228
+ return (
229
+ <div className="space-y-2">
230
+ {label && (
231
+ <div className="flex items-center gap-2">
232
+ <label htmlFor={name} className="text-sm font-medium">
233
+ {label}
234
+ {required && <span className="text-destructive">*</span>}
235
+ {maxItems && (
236
+ <span className="ml-2 text-xs text-muted-foreground">
237
+ ({selectedIds.length}/{maxItems})
238
+ </span>
239
+ )}
240
+ </label>
241
+ {localized && <LocaleBadge locale={locale || "i18n"} />}
242
+ </div>
243
+ )}
244
+
245
+ {/* Selected Items List */}
246
+ {selectedItems && selectedItems.length > 0 && (
247
+ <div className="space-y-2 rounded-lg border p-3">
248
+ {selectedItems.map((item: any, index: number) => (
249
+ <div
250
+ key={item.id}
251
+ className="flex items-center gap-2 rounded-md border bg-card p-2"
252
+ >
253
+ {/* Drag Handle (if orderable) */}
254
+ {orderable && !readOnly && (
255
+ <button
256
+ type="button"
257
+ className="cursor-grab text-muted-foreground hover:text-foreground"
258
+ disabled={disabled}
259
+ >
260
+ <DotsSixVertical className="h-4 w-4" />
261
+ </button>
262
+ )}
263
+
264
+ {/* Item Display */}
265
+ <div className="flex-1">
266
+ {renderItem ? (
267
+ renderItem(item, index)
268
+ ) : (
269
+ <span className="text-sm">{getDisplayValue(item)}</span>
270
+ )}
271
+ </div>
272
+
273
+ {/* Edit Button */}
274
+ {!readOnly && (
275
+ <Button
276
+ type="button"
277
+ variant="ghost"
278
+ size="icon"
279
+ className="h-7 w-7"
280
+ onClick={() => handleOpenEdit(item.id)}
281
+ disabled={disabled}
282
+ title="Edit"
283
+ >
284
+ <Pencil className="h-3 w-3" />
285
+ </Button>
286
+ )}
287
+
288
+ {/* Remove Button */}
289
+ {!readOnly && (!required || selectedIds.length > 1) && (
290
+ <Button
291
+ type="button"
292
+ variant="ghost"
293
+ size="icon"
294
+ className="h-7 w-7"
295
+ onClick={() => handleRemove(item.id)}
296
+ disabled={disabled}
297
+ title="Remove"
298
+ >
299
+ <X className="h-3 w-3" />
300
+ </Button>
301
+ )}
302
+ </div>
303
+ ))}
304
+ </div>
305
+ )}
306
+
307
+ {/* Add More */}
308
+ {!readOnly && canAddMore && (
309
+ <div className="flex gap-2">
310
+ {/* Combobox to add existing items */}
311
+ <div className="flex-1">
312
+ <Combobox
313
+ value=""
314
+ onValueChange={(value) => {
315
+ if (value) handleAdd(value);
316
+ }}
317
+ disabled={disabled || isLoading}
318
+ >
319
+ <ComboboxInput
320
+ placeholder={
321
+ placeholder || `Add ${label || targetCollection}...`
322
+ }
323
+ disabled={disabled || isLoading}
324
+ />
325
+ <ComboboxContent>
326
+ <ComboboxList>
327
+ {availableOptions.map((opt: any) => (
328
+ <ComboboxItem key={opt.id} value={opt.id}>
329
+ {getOptionDisplay(opt)}
330
+ </ComboboxItem>
331
+ ))}
332
+ </ComboboxList>
333
+ <ComboboxEmpty>
334
+ {isLoading ? "Loading..." : "No more options available"}
335
+ </ComboboxEmpty>
336
+ </ComboboxContent>
337
+ </Combobox>
338
+ </div>
339
+
340
+ {/* Create Button */}
341
+ <Button
342
+ type="button"
343
+ variant="outline"
344
+ size="icon"
345
+ onClick={handleOpenCreate}
346
+ disabled={disabled}
347
+ title={`Create new ${label || targetCollection}`}
348
+ >
349
+ <Plus className="h-4 w-4" />
350
+ </Button>
351
+ </div>
352
+ )}
353
+
354
+ {/* Empty State */}
355
+ {selectedIds.length === 0 && (
356
+ <div className="rounded-lg border border-dashed p-4 text-center">
357
+ <p className="text-sm text-muted-foreground">
358
+ {placeholder || `No ${label || targetCollection} selected`}
359
+ </p>
360
+ </div>
361
+ )}
362
+
363
+ {/* Error message */}
364
+ {error && <p className="text-sm text-destructive">{error}</p>}
365
+
366
+ {/* Side Sheet for Create/Edit */}
367
+ <Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
368
+ <SheetContent
369
+ side="right"
370
+ className="w-full sm:max-w-lg overflow-y-auto"
371
+ >
372
+ <SheetHeader>
373
+ <SheetTitle>
374
+ {sheetMode === "create" ? "Create" : "Edit"}{" "}
375
+ {label || targetCollection}
376
+ </SheetTitle>
377
+ <SheetDescription>
378
+ {sheetMode === "create"
379
+ ? `Fill in the details to create a new ${label || targetCollection}`
380
+ : `Update the details of this ${label || targetCollection}`}
381
+ </SheetDescription>
382
+ </SheetHeader>
383
+
384
+ <div className="mt-6">
385
+ {renderFormFields ? (
386
+ renderFormFields(targetCollection, editingItemId)
387
+ ) : (
388
+ <div className="rounded-lg border border-dashed p-8 text-center">
389
+ <p className="text-sm text-muted-foreground">
390
+ Form fields not configured.
391
+ <br />
392
+ Pass <code className="text-xs">renderFormFields</code> prop to
393
+ enable create/edit.
394
+ </p>
395
+ </div>
396
+ )}
397
+ </div>
398
+ </SheetContent>
399
+ </Sheet>
400
+ </div>
401
+ );
402
+ }