@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.
- package/.turbo/turbo-build.log +108 -0
- package/CHANGELOG.md +10 -0
- package/README.md +556 -0
- package/STATUS.md +917 -0
- package/VALIDATION.md +602 -0
- package/components.json +24 -0
- package/dist/__tests__/setup.mjs +38 -0
- package/dist/__tests__/test-utils.mjs +45 -0
- package/dist/__tests__/vitest.d.mjs +3 -0
- package/dist/components/admin-app.mjs +69 -0
- package/dist/components/fields/array-field.mjs +190 -0
- package/dist/components/fields/checkbox-field.mjs +34 -0
- package/dist/components/fields/custom-field.mjs +32 -0
- package/dist/components/fields/date-field.mjs +41 -0
- package/dist/components/fields/datetime-field.mjs +42 -0
- package/dist/components/fields/email-field.mjs +37 -0
- package/dist/components/fields/embedded-collection.mjs +253 -0
- package/dist/components/fields/field-types.mjs +1 -0
- package/dist/components/fields/field-utils.mjs +10 -0
- package/dist/components/fields/field-wrapper.mjs +34 -0
- package/dist/components/fields/index.mjs +23 -0
- package/dist/components/fields/json-field.mjs +243 -0
- package/dist/components/fields/locale-badge.mjs +16 -0
- package/dist/components/fields/number-field.mjs +39 -0
- package/dist/components/fields/password-field.mjs +37 -0
- package/dist/components/fields/relation-field.mjs +104 -0
- package/dist/components/fields/relation-picker.mjs +229 -0
- package/dist/components/fields/relation-select.mjs +188 -0
- package/dist/components/fields/rich-text-editor/index.mjs +897 -0
- package/dist/components/fields/select-field.mjs +41 -0
- package/dist/components/fields/switch-field.mjs +34 -0
- package/dist/components/fields/text-field.mjs +38 -0
- package/dist/components/fields/textarea-field.mjs +38 -0
- package/dist/components/index.mjs +59 -0
- package/dist/components/primitives/checkbox-input.mjs +127 -0
- package/dist/components/primitives/date-input.mjs +303 -0
- package/dist/components/primitives/index.mjs +12 -0
- package/dist/components/primitives/number-input.mjs +104 -0
- package/dist/components/primitives/select-input.mjs +177 -0
- package/dist/components/primitives/tag-input.mjs +135 -0
- package/dist/components/primitives/text-input.mjs +39 -0
- package/dist/components/primitives/textarea-input.mjs +37 -0
- package/dist/components/primitives/toggle-input.mjs +31 -0
- package/dist/components/primitives/types.mjs +12 -0
- package/dist/components/ui/accordion.mjs +55 -0
- package/dist/components/ui/avatar.mjs +54 -0
- package/dist/components/ui/badge.mjs +34 -0
- package/dist/components/ui/button.mjs +48 -0
- package/dist/components/ui/card.mjs +58 -0
- package/dist/components/ui/checkbox.mjs +21 -0
- package/dist/components/ui/combobox.mjs +163 -0
- package/dist/components/ui/dialog.mjs +95 -0
- package/dist/components/ui/dropdown-menu.mjs +138 -0
- package/dist/components/ui/field.mjs +113 -0
- package/dist/components/ui/input-group.mjs +82 -0
- package/dist/components/ui/input.mjs +17 -0
- package/dist/components/ui/label.mjs +15 -0
- package/dist/components/ui/popover.mjs +56 -0
- package/dist/components/ui/scroll-area.mjs +38 -0
- package/dist/components/ui/select.mjs +100 -0
- package/dist/components/ui/separator.mjs +16 -0
- package/dist/components/ui/sheet.mjs +90 -0
- package/dist/components/ui/sidebar.mjs +387 -0
- package/dist/components/ui/skeleton.mjs +14 -0
- package/dist/components/ui/spinner.mjs +16 -0
- package/dist/components/ui/switch.mjs +22 -0
- package/dist/components/ui/table.mjs +68 -0
- package/dist/components/ui/tabs.mjs +48 -0
- package/dist/components/ui/textarea.mjs +15 -0
- package/dist/components/ui/tooltip.mjs +44 -0
- package/dist/config/component-registry.mjs +38 -0
- package/dist/config/index.mjs +129 -0
- package/dist/hooks/admin-provider.mjs +70 -0
- package/dist/hooks/index.mjs +7 -0
- package/dist/hooks/store.mjs +178 -0
- package/dist/hooks/use-auth.mjs +76 -0
- package/dist/hooks/use-collection-db.mjs +146 -0
- package/dist/hooks/use-collection.mjs +112 -0
- package/dist/hooks/use-global.mjs +46 -0
- package/dist/hooks/use-mobile.mjs +20 -0
- package/dist/lib/utils.mjs +10 -0
- package/dist/styles/index.css +336 -0
- package/dist/styles/index.mjs +1 -0
- package/dist/utils/index.mjs +9 -0
- package/dist/views/auth/auth-layout.mjs +52 -0
- package/dist/views/auth/forgot-password-form.mjs +148 -0
- package/dist/views/auth/index.mjs +6 -0
- package/dist/views/auth/login-form.mjs +156 -0
- package/dist/views/auth/reset-password-form.mjs +184 -0
- package/dist/views/collection/auto-form-fields.mjs +525 -0
- package/dist/views/collection/collection-form.mjs +91 -0
- package/dist/views/collection/collection-list.mjs +76 -0
- package/dist/views/collection/form-field.mjs +42 -0
- package/dist/views/collection/index.mjs +6 -0
- package/dist/views/common/index.mjs +4 -0
- package/dist/views/common/locale-switcher.mjs +39 -0
- package/dist/views/common/version-history.mjs +272 -0
- package/dist/views/index.mjs +9 -0
- package/dist/views/layout/admin-layout.mjs +40 -0
- package/dist/views/layout/admin-router.mjs +95 -0
- package/dist/views/layout/admin-sidebar.mjs +63 -0
- package/dist/views/layout/index.mjs +5 -0
- package/package.json +276 -0
- package/src/__tests__/setup.ts +44 -0
- package/src/__tests__/test-utils.tsx +49 -0
- package/src/__tests__/vitest.d.ts +9 -0
- package/src/components/admin-app.tsx +221 -0
- package/src/components/fields/array-field.tsx +237 -0
- package/src/components/fields/checkbox-field.tsx +47 -0
- package/src/components/fields/custom-field.tsx +50 -0
- package/src/components/fields/date-field.tsx +65 -0
- package/src/components/fields/datetime-field.tsx +67 -0
- package/src/components/fields/email-field.tsx +51 -0
- package/src/components/fields/embedded-collection.tsx +315 -0
- package/src/components/fields/field-types.ts +162 -0
- package/src/components/fields/field-utils.ts +6 -0
- package/src/components/fields/field-wrapper.tsx +52 -0
- package/src/components/fields/index.ts +66 -0
- package/src/components/fields/json-field.tsx +440 -0
- package/src/components/fields/locale-badge.tsx +15 -0
- package/src/components/fields/number-field.tsx +57 -0
- package/src/components/fields/password-field.tsx +51 -0
- package/src/components/fields/relation-field.tsx +243 -0
- package/src/components/fields/relation-picker.tsx +402 -0
- package/src/components/fields/relation-select.tsx +327 -0
- package/src/components/fields/rich-text-editor/index.tsx +1337 -0
- package/src/components/fields/select-field.tsx +61 -0
- package/src/components/fields/switch-field.tsx +47 -0
- package/src/components/fields/text-field.tsx +55 -0
- package/src/components/fields/textarea-field.tsx +55 -0
- package/src/components/index.ts +40 -0
- package/src/components/primitives/checkbox-input.tsx +193 -0
- package/src/components/primitives/date-input.tsx +401 -0
- package/src/components/primitives/index.ts +24 -0
- package/src/components/primitives/number-input.tsx +132 -0
- package/src/components/primitives/select-input.tsx +296 -0
- package/src/components/primitives/tag-input.tsx +200 -0
- package/src/components/primitives/text-input.tsx +49 -0
- package/src/components/primitives/textarea-input.tsx +46 -0
- package/src/components/primitives/toggle-input.tsx +36 -0
- package/src/components/primitives/types.ts +235 -0
- package/src/components/ui/accordion.tsx +72 -0
- package/src/components/ui/avatar.tsx +106 -0
- package/src/components/ui/badge.tsx +48 -0
- package/src/components/ui/button.tsx +53 -0
- package/src/components/ui/card.tsx +94 -0
- package/src/components/ui/checkbox.tsx +27 -0
- package/src/components/ui/combobox.tsx +290 -0
- package/src/components/ui/dialog.tsx +151 -0
- package/src/components/ui/dropdown-menu.tsx +254 -0
- package/src/components/ui/field.tsx +227 -0
- package/src/components/ui/input-group.tsx +149 -0
- package/src/components/ui/input.tsx +20 -0
- package/src/components/ui/label.tsx +18 -0
- package/src/components/ui/popover.tsx +88 -0
- package/src/components/ui/scroll-area.tsx +53 -0
- package/src/components/ui/select.tsx +192 -0
- package/src/components/ui/separator.tsx +23 -0
- package/src/components/ui/sheet.tsx +127 -0
- package/src/components/ui/sidebar.tsx +723 -0
- package/src/components/ui/skeleton.tsx +13 -0
- package/src/components/ui/spinner.tsx +10 -0
- package/src/components/ui/switch.tsx +32 -0
- package/src/components/ui/table.tsx +99 -0
- package/src/components/ui/tabs.tsx +82 -0
- package/src/components/ui/textarea.tsx +18 -0
- package/src/components/ui/tooltip.tsx +70 -0
- package/src/config/component-registry.ts +190 -0
- package/src/config/index.ts +1099 -0
- package/src/hooks/README.md +269 -0
- package/src/hooks/admin-provider.tsx +110 -0
- package/src/hooks/index.ts +41 -0
- package/src/hooks/store.ts +248 -0
- package/src/hooks/use-auth.ts +168 -0
- package/src/hooks/use-collection-db.ts +209 -0
- package/src/hooks/use-collection.ts +156 -0
- package/src/hooks/use-global.ts +69 -0
- package/src/hooks/use-mobile.ts +21 -0
- package/src/lib/utils.ts +6 -0
- package/src/styles/index.css +340 -0
- package/src/utils/index.ts +6 -0
- package/src/views/auth/auth-layout.tsx +77 -0
- package/src/views/auth/forgot-password-form.tsx +192 -0
- package/src/views/auth/index.ts +21 -0
- package/src/views/auth/login-form.tsx +229 -0
- package/src/views/auth/reset-password-form.tsx +232 -0
- package/src/views/collection/auto-form-fields.tsx +982 -0
- package/src/views/collection/collection-form.tsx +186 -0
- package/src/views/collection/collection-list.tsx +223 -0
- package/src/views/collection/form-field.tsx +52 -0
- package/src/views/collection/index.ts +15 -0
- package/src/views/common/index.ts +8 -0
- package/src/views/common/locale-switcher.tsx +45 -0
- package/src/views/common/version-history.tsx +406 -0
- package/src/views/index.ts +25 -0
- package/src/views/layout/admin-layout.tsx +117 -0
- package/src/views/layout/admin-router.tsx +206 -0
- package/src/views/layout/admin-sidebar.tsx +185 -0
- package/src/views/layout/index.ts +12 -0
- package/tsconfig.json +13 -0
- package/tsconfig.tsbuildinfo +1 -0
- package/tsdown.config.ts +13 -0
- 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
|
+
}
|