create-davepi-ui 0.1.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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +29 -0
  3. package/bin/index.js +229 -0
  4. package/bin/sync-templates.js +100 -0
  5. package/package.json +40 -0
  6. package/templates/default/.env.example +1 -0
  7. package/templates/default/index.html +13 -0
  8. package/templates/default/package.json +49 -0
  9. package/templates/default/postcss.config.cjs +6 -0
  10. package/templates/default/src/App.tsx +42 -0
  11. package/templates/default/src/components/AppShell.tsx +23 -0
  12. package/templates/default/src/components/BulkActionBar.tsx +47 -0
  13. package/templates/default/src/components/RelatedCreateModal.tsx +91 -0
  14. package/templates/default/src/components/RelatedList.tsx +70 -0
  15. package/templates/default/src/components/ResourceForm.tsx +311 -0
  16. package/templates/default/src/components/ResourceTable.tsx +475 -0
  17. package/templates/default/src/components/RowActions.tsx +54 -0
  18. package/templates/default/src/components/Sidebar.tsx +171 -0
  19. package/templates/default/src/components/ui/button.tsx +43 -0
  20. package/templates/default/src/components/ui/card.tsx +47 -0
  21. package/templates/default/src/components/ui/checkbox.tsx +24 -0
  22. package/templates/default/src/components/ui/command.tsx +117 -0
  23. package/templates/default/src/components/ui/dialog.tsx +95 -0
  24. package/templates/default/src/components/ui/dropdown-menu.tsx +78 -0
  25. package/templates/default/src/components/ui/input.tsx +18 -0
  26. package/templates/default/src/components/ui/label.tsx +17 -0
  27. package/templates/default/src/components/ui/popover.tsx +27 -0
  28. package/templates/default/src/components/ui/select.tsx +83 -0
  29. package/templates/default/src/components/ui/switch.tsx +21 -0
  30. package/templates/default/src/components/ui/table.tsx +66 -0
  31. package/templates/default/src/components/ui/tabs.tsx +53 -0
  32. package/templates/default/src/components/ui/textarea.tsx +17 -0
  33. package/templates/default/src/davepi-ui.config.ts +14 -0
  34. package/templates/default/src/index.css +55 -0
  35. package/templates/default/src/lib/utils.ts +10 -0
  36. package/templates/default/src/main.tsx +34 -0
  37. package/templates/default/src/pages/DashboardPage.tsx +42 -0
  38. package/templates/default/src/pages/LoginScreen.tsx +77 -0
  39. package/templates/default/src/pages/ResourceCreatePage.tsx +58 -0
  40. package/templates/default/src/pages/ResourceDetailPage.tsx +171 -0
  41. package/templates/default/src/pages/ResourceEditPage.tsx +52 -0
  42. package/templates/default/src/pages/ResourceListPage.tsx +8 -0
  43. package/templates/default/src/resourceOverrides.ts +34 -0
  44. package/templates/default/src/resources/account.ts +25 -0
  45. package/templates/default/src/resources/category.ts +7 -0
  46. package/templates/default/src/resources/contact.ts +40 -0
  47. package/templates/default/src/resources/product.ts +7 -0
  48. package/templates/default/src/resources/project.ts +7 -0
  49. package/templates/default/src/resources/quote.ts +12 -0
  50. package/templates/default/src/vite-env.d.ts +9 -0
  51. package/templates/default/src/widgets/CurrencyInput.tsx +44 -0
  52. package/templates/default/src/widgets/DateInput.tsx +36 -0
  53. package/templates/default/src/widgets/EmailInput.tsx +28 -0
  54. package/templates/default/src/widgets/EnumSelect.tsx +35 -0
  55. package/templates/default/src/widgets/FileUploaderStub.tsx +9 -0
  56. package/templates/default/src/widgets/JsonEditor.tsx +64 -0
  57. package/templates/default/src/widgets/NumberInput.tsx +36 -0
  58. package/templates/default/src/widgets/RelationPicker.tsx +349 -0
  59. package/templates/default/src/widgets/SwitchWidget.tsx +20 -0
  60. package/templates/default/src/widgets/TagInput.tsx +83 -0
  61. package/templates/default/src/widgets/TextAreaWidget.tsx +27 -0
  62. package/templates/default/src/widgets/TextInput.tsx +32 -0
  63. package/templates/default/src/widgets/UrlInput.tsx +27 -0
  64. package/templates/default/src/widgets/registry.ts +51 -0
  65. package/templates/default/src/widgets/types.ts +26 -0
  66. package/templates/default/tailwind.config.ts +54 -0
  67. package/templates/default/tsconfig.json +40 -0
  68. package/templates/default/vite.config.ts +16 -0
  69. package/templates/pinned-versions.json +5 -0
@@ -0,0 +1,40 @@
1
+ import type { ResourceConfig } from '@davepi/ui-core';
2
+
3
+ /**
4
+ * Contact override. Form sections and explicit list columns are the
5
+ * UI-only bits — labels / pluralLabel / displayField come straight
6
+ * from the backend manifest.
7
+ */
8
+ const config: ResourceConfig = {
9
+ category: 'CRM',
10
+ listColumns: [
11
+ { field: 'first_name' },
12
+ { field: 'last_name' },
13
+ { field: 'email' },
14
+ { field: 'phone' },
15
+ ],
16
+ formSections: [
17
+ {
18
+ title: 'Identity',
19
+ fields: [{ field: 'first_name' }, { field: 'last_name' }, { field: 'email' }],
20
+ },
21
+ {
22
+ title: 'Contact',
23
+ fields: [{ field: 'phone' }, { field: 'mobile' }, { field: 'company' }],
24
+ },
25
+ {
26
+ title: 'Address',
27
+ description: 'Postal address used for invoices and correspondence.',
28
+ fields: [
29
+ { field: 'address1' },
30
+ { field: 'address2' },
31
+ { field: 'suburb' },
32
+ { field: 'state' },
33
+ { field: 'postcode' },
34
+ { field: 'country' },
35
+ ],
36
+ },
37
+ ],
38
+ };
39
+
40
+ export default config;
@@ -0,0 +1,7 @@
1
+ import type { ResourceConfig } from '@davepi/ui-core';
2
+
3
+ const config: ResourceConfig = {
4
+ category: 'Catalogue',
5
+ };
6
+
7
+ export default config;
@@ -0,0 +1,7 @@
1
+ import type { ResourceConfig } from '@davepi/ui-core';
2
+
3
+ const config: ResourceConfig = {
4
+ category: 'Delivery',
5
+ };
6
+
7
+ export default config;
@@ -0,0 +1,12 @@
1
+ import type { ResourceConfig } from '@davepi/ui-core';
2
+
3
+ /**
4
+ * Quote override. Only the sidebar category is consumer-supplied —
5
+ * everything else (labels, displayField, contactId relation, etc.)
6
+ * comes from the backend manifest.
7
+ */
8
+ const config: ResourceConfig = {
9
+ category: 'CRM',
10
+ };
11
+
12
+ export default config;
@@ -0,0 +1,9 @@
1
+ /// <reference types="vite/client" />
2
+
3
+ interface ImportMetaEnv {
4
+ readonly VITE_API_URL?: string;
5
+ }
6
+
7
+ interface ImportMeta {
8
+ readonly env: ImportMetaEnv;
9
+ }
@@ -0,0 +1,44 @@
1
+ import { Input } from '@/components/ui/input';
2
+ import type { WidgetComponentProps } from './types';
3
+
4
+ /**
5
+ * Currency = NumberInput + leading currency code. The currency value is
6
+ * read from `spec.currency` (parsed from the backend's `format: 'currency:USD'`
7
+ * hint). Falls back to 'USD' when unset.
8
+ */
9
+ export function CurrencyInput({
10
+ spec,
11
+ value,
12
+ onChange,
13
+ onBlur,
14
+ disabled,
15
+ readOnly,
16
+ id,
17
+ name,
18
+ }: WidgetComponentProps<number | string | undefined>) {
19
+ const currency = spec.currency ?? 'USD';
20
+ return (
21
+ <div className="flex items-center gap-2">
22
+ <span className="text-sm text-muted-foreground">{currency}</span>
23
+ <Input
24
+ id={id}
25
+ name={name}
26
+ type="number"
27
+ step="0.01"
28
+ value={value == null ? '' : String(value)}
29
+ onChange={(e) => {
30
+ const next = e.target.value;
31
+ if (next === '') {
32
+ onChange(undefined);
33
+ return;
34
+ }
35
+ const parsed = Number(next);
36
+ onChange(Number.isFinite(parsed) ? parsed : next);
37
+ }}
38
+ onBlur={onBlur}
39
+ disabled={disabled}
40
+ readOnly={readOnly}
41
+ />
42
+ </div>
43
+ );
44
+ }
@@ -0,0 +1,36 @@
1
+ import { Input } from '@/components/ui/input';
2
+ import type { WidgetComponentProps } from './types';
3
+
4
+ /**
5
+ * Native HTML date input. A richer date-picker (Radix popover + calendar)
6
+ * lands in M2; today's input is simple, accessible, and accepts ISO
7
+ * strings either from form state or paste.
8
+ */
9
+ export function DateInput({
10
+ value,
11
+ onChange,
12
+ onBlur,
13
+ disabled,
14
+ readOnly,
15
+ id,
16
+ name,
17
+ }: WidgetComponentProps<string | Date | undefined>) {
18
+ const stringValue =
19
+ value instanceof Date
20
+ ? value.toISOString().slice(0, 10)
21
+ : typeof value === 'string'
22
+ ? value.slice(0, 10)
23
+ : '';
24
+ return (
25
+ <Input
26
+ id={id}
27
+ name={name}
28
+ type="date"
29
+ value={stringValue}
30
+ onChange={(e) => onChange(e.target.value || undefined)}
31
+ onBlur={onBlur}
32
+ disabled={disabled}
33
+ readOnly={readOnly}
34
+ />
35
+ );
36
+ }
@@ -0,0 +1,28 @@
1
+ import { Input } from '@/components/ui/input';
2
+ import type { WidgetComponentProps } from './types';
3
+
4
+ export function EmailInput({
5
+ value,
6
+ onChange,
7
+ onBlur,
8
+ disabled,
9
+ readOnly,
10
+ id,
11
+ name,
12
+ placeholder,
13
+ }: WidgetComponentProps<string | undefined>) {
14
+ return (
15
+ <Input
16
+ id={id}
17
+ name={name}
18
+ type="email"
19
+ autoComplete="email"
20
+ value={value ?? ''}
21
+ onChange={(e) => onChange(e.target.value || undefined)}
22
+ onBlur={onBlur}
23
+ disabled={disabled}
24
+ readOnly={readOnly}
25
+ placeholder={placeholder ?? 'name@example.com'}
26
+ />
27
+ );
28
+ }
@@ -0,0 +1,35 @@
1
+ import {
2
+ Select,
3
+ SelectContent,
4
+ SelectItem,
5
+ SelectTrigger,
6
+ SelectValue,
7
+ } from '@/components/ui/select';
8
+ import { labelize } from '@davepi/ui-core';
9
+ import type { WidgetComponentProps } from './types';
10
+
11
+ export function EnumSelect({
12
+ spec,
13
+ value,
14
+ onChange,
15
+ disabled,
16
+ id,
17
+ name,
18
+ placeholder,
19
+ }: WidgetComponentProps<string | undefined>) {
20
+ const options = spec.options ?? [];
21
+ return (
22
+ <Select value={value ?? ''} onValueChange={(next) => onChange(next || undefined)} disabled={disabled}>
23
+ <SelectTrigger id={id} aria-label={name}>
24
+ <SelectValue placeholder={placeholder ?? 'Select…'} />
25
+ </SelectTrigger>
26
+ <SelectContent>
27
+ {options.map((opt) => (
28
+ <SelectItem key={opt} value={opt}>
29
+ {labelize(opt)}
30
+ </SelectItem>
31
+ ))}
32
+ </SelectContent>
33
+ </Select>
34
+ );
35
+ }
@@ -0,0 +1,9 @@
1
+ import type { WidgetComponentProps } from './types';
2
+
3
+ export function FileUploaderStub({ spec }: WidgetComponentProps<unknown>) {
4
+ return (
5
+ <div className="rounded-md border border-dashed border-input p-3 text-xs text-muted-foreground">
6
+ File widget for <code>{spec.field.name}</code> coming in M2.
7
+ </div>
8
+ );
9
+ }
@@ -0,0 +1,64 @@
1
+ import { useEffect, useState } from 'react';
2
+ import { Textarea } from '@/components/ui/textarea';
3
+ import type { WidgetComponentProps } from './types';
4
+
5
+ /**
6
+ * JSON editor backed by a textarea. Parses on blur — invalid JSON shows
7
+ * inline and keeps the prior value. A real Monaco-based editor lands
8
+ * later as an optional add-on.
9
+ */
10
+ export function JsonEditor({
11
+ value,
12
+ onChange,
13
+ onBlur,
14
+ disabled,
15
+ readOnly,
16
+ id,
17
+ name,
18
+ }: WidgetComponentProps<unknown>) {
19
+ const [text, setText] = useState(() => stringify(value));
20
+ const [error, setError] = useState<string | null>(null);
21
+
22
+ useEffect(() => {
23
+ setText(stringify(value));
24
+ }, [value]);
25
+
26
+ return (
27
+ <div>
28
+ <Textarea
29
+ id={id}
30
+ name={name}
31
+ rows={6}
32
+ value={text}
33
+ onChange={(e) => setText(e.target.value)}
34
+ disabled={disabled}
35
+ readOnly={readOnly}
36
+ className="font-mono text-xs"
37
+ onBlur={() => {
38
+ if (!text.trim()) {
39
+ onChange(undefined);
40
+ setError(null);
41
+ } else {
42
+ try {
43
+ onChange(JSON.parse(text));
44
+ setError(null);
45
+ } catch (err) {
46
+ setError(err instanceof Error ? err.message : 'Invalid JSON');
47
+ }
48
+ }
49
+ onBlur?.();
50
+ }}
51
+ />
52
+ {error ? <p className="mt-1 text-xs text-destructive">{error}</p> : null}
53
+ </div>
54
+ );
55
+ }
56
+
57
+ function stringify(value: unknown): string {
58
+ if (value == null) return '';
59
+ try {
60
+ return JSON.stringify(value, null, 2);
61
+ } catch {
62
+ return '';
63
+ }
64
+ }
@@ -0,0 +1,36 @@
1
+ import { Input } from '@/components/ui/input';
2
+ import type { WidgetComponentProps } from './types';
3
+
4
+ export function NumberInput({
5
+ value,
6
+ onChange,
7
+ onBlur,
8
+ disabled,
9
+ readOnly,
10
+ id,
11
+ name,
12
+ placeholder,
13
+ }: WidgetComponentProps<number | string | undefined>) {
14
+ return (
15
+ <Input
16
+ id={id}
17
+ name={name}
18
+ type="number"
19
+ value={value == null ? '' : String(value)}
20
+ onChange={(e) => {
21
+ const next = e.target.value;
22
+ if (next === '') {
23
+ onChange(undefined);
24
+ } else {
25
+ const parsed = Number(next);
26
+ onChange(Number.isFinite(parsed) ? parsed : next);
27
+ }
28
+ }}
29
+ onBlur={onBlur}
30
+ disabled={disabled}
31
+ readOnly={readOnly}
32
+ placeholder={placeholder}
33
+ step="any"
34
+ />
35
+ );
36
+ }
@@ -0,0 +1,349 @@
1
+ import { useEffect, useMemo, useState } from 'react';
2
+ import { useQuery } from '@tanstack/react-query';
3
+ import { Check, ChevronDown, Plus, X } from 'lucide-react';
4
+ import { useAuth, useDescribe, useResource, useResourceList } from '@davepi/ui-react';
5
+ import {
6
+ Command,
7
+ CommandEmpty,
8
+ CommandInput,
9
+ CommandItem,
10
+ CommandList,
11
+ CommandLoading,
12
+ CommandSeparator,
13
+ } from '@/components/ui/command';
14
+ import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
15
+ import { Button } from '@/components/ui/button';
16
+ import { cn } from '@/lib/utils';
17
+ import { RelatedCreateModal } from '@/components/RelatedCreateModal';
18
+ import type { WidgetComponentProps } from './types';
19
+
20
+ /**
21
+ * Schema-driven relation picker (M2 flagship).
22
+ *
23
+ * Renders as a Popover-backed combobox over the target resource list.
24
+ * Typing triggers `__q` search (debounced 250ms) when the target schema
25
+ * declares searchable fields; otherwise client-side filter on the
26
+ * `displayField` value. Selecting a result writes the record id back
27
+ * to the form. An inline "+ Create new <Target>" item opens a
28
+ * `RelatedCreateModal` so a missing referent doesn't break the flow.
29
+ *
30
+ * The currently selected id is resolved via `useResource` so the trigger
31
+ * shows the human-readable label (not the raw id) even on reload.
32
+ *
33
+ * @example
34
+ * <Controller name="accountId" render={({ field }) => (
35
+ * <RelationPicker
36
+ * spec={{ kind: 'RelationPicker', field, target: 'account', searchField: 'accountName' }}
37
+ * value={field.value}
38
+ * onChange={field.onChange}
39
+ * />
40
+ * )} />
41
+ */
42
+ export function RelationPicker({
43
+ spec,
44
+ value,
45
+ onChange,
46
+ disabled,
47
+ readOnly,
48
+ id,
49
+ placeholder,
50
+ }: WidgetComponentProps<string | undefined>) {
51
+ const target = spec.target;
52
+ const { data: describe } = useDescribe();
53
+ const [open, setOpen] = useState(false);
54
+ const [search, setSearch] = useState('');
55
+ const debouncedSearch = useDebounced(search, 250);
56
+ const [creating, setCreating] = useState(false);
57
+
58
+ const selectedRecord = useResource<Record<string, unknown>>(target ?? '', value, {
59
+ enabled: !!value && !!target,
60
+ });
61
+
62
+ const list = useResourceList<Record<string, unknown>>(target ?? '', {
63
+ params: { page: 1, q: debouncedSearch || undefined },
64
+ enabled: !!target && open,
65
+ });
66
+
67
+ if (!target || !describe) {
68
+ return (
69
+ <div className="text-xs text-muted-foreground">
70
+ Unknown relation target. Did the backend register the schema?
71
+ </div>
72
+ );
73
+ }
74
+
75
+ const display = describe.registry.display(target);
76
+ const triggerLabel = value
77
+ ? selectedRecord.data
78
+ ? describe.registry.preview(target, selectedRecord.data)
79
+ : selectedRecord.isPending
80
+ ? 'Loading…'
81
+ : value
82
+ : (placeholder ?? `Select ${display.label.toLowerCase()}…`);
83
+
84
+ return (
85
+ <div className="flex flex-col gap-1">
86
+ <div className="flex items-center gap-1">
87
+ <Popover open={open} onOpenChange={setOpen}>
88
+ <PopoverTrigger asChild>
89
+ <Button
90
+ type="button"
91
+ role="combobox"
92
+ variant="outline"
93
+ aria-expanded={open}
94
+ aria-controls={`${id ?? target}-listbox`}
95
+ disabled={disabled || readOnly}
96
+ className={cn('w-full justify-between font-normal', !value && 'text-muted-foreground')}
97
+ id={id}
98
+ >
99
+ <span className="truncate text-left">{triggerLabel}</span>
100
+ <ChevronDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
101
+ </Button>
102
+ </PopoverTrigger>
103
+ <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
104
+ <Command shouldFilter={false} id={`${id ?? target}-listbox`}>
105
+ <CommandInput
106
+ placeholder={`Search ${display.pluralLabel.toLowerCase()}…`}
107
+ value={search}
108
+ onValueChange={setSearch}
109
+ />
110
+ <CommandList>
111
+ {list.isPending ? <CommandLoading>Searching…</CommandLoading> : null}
112
+ {list.data?.results.length === 0 ? (
113
+ <CommandEmpty>
114
+ No {display.pluralLabel.toLowerCase()} match {search ? `"${search}"` : 'your search'}.
115
+ </CommandEmpty>
116
+ ) : null}
117
+ {list.data?.results.map((record) => {
118
+ const recordId = String(record._id ?? '');
119
+ const label = describe.registry.preview(target, record);
120
+ const selected = recordId === value;
121
+ return (
122
+ <CommandItem
123
+ key={recordId}
124
+ value={recordId}
125
+ onSelect={() => {
126
+ onChange(recordId);
127
+ setOpen(false);
128
+ }}
129
+ >
130
+ <Check
131
+ className={cn('mr-2 h-4 w-4', selected ? 'opacity-100' : 'opacity-0')}
132
+ />
133
+ <span className="truncate">{label}</span>
134
+ </CommandItem>
135
+ );
136
+ })}
137
+ <CommandSeparator />
138
+ <CommandItem
139
+ onSelect={() => {
140
+ setOpen(false);
141
+ setCreating(true);
142
+ }}
143
+ className="text-primary"
144
+ >
145
+ <Plus className="mr-2 h-4 w-4" />
146
+ Create new {display.label.toLowerCase()}
147
+ </CommandItem>
148
+ </CommandList>
149
+ </Command>
150
+ </PopoverContent>
151
+ </Popover>
152
+ {value && !disabled && !readOnly ? (
153
+ <Button
154
+ type="button"
155
+ variant="ghost"
156
+ size="icon"
157
+ aria-label={`Clear ${display.label}`}
158
+ onClick={() => onChange(undefined)}
159
+ >
160
+ <X className="h-4 w-4" />
161
+ </Button>
162
+ ) : null}
163
+ </div>
164
+ <RelatedCreateModal
165
+ open={creating}
166
+ onOpenChange={setCreating}
167
+ resource={target}
168
+ prefill={search ? guessPrefill(spec.searchField, search) : undefined}
169
+ onCreated={(record) => {
170
+ const newId = (record as { _id?: string })._id;
171
+ if (newId) onChange(newId);
172
+ }}
173
+ />
174
+ </div>
175
+ );
176
+ }
177
+
178
+ /**
179
+ * Multi-select variant on top of the same combobox. Backing value is a
180
+ * string array; selections render as chips with X-to-remove.
181
+ */
182
+ export function MultiRelationPicker({
183
+ spec,
184
+ value,
185
+ onChange,
186
+ disabled,
187
+ readOnly,
188
+ id,
189
+ placeholder,
190
+ }: WidgetComponentProps<string[] | undefined>) {
191
+ const target = spec.target;
192
+ const { data: describe } = useDescribe();
193
+ const [open, setOpen] = useState(false);
194
+ const [search, setSearch] = useState('');
195
+ const debouncedSearch = useDebounced(search, 250);
196
+ const [creating, setCreating] = useState(false);
197
+
198
+ const selected = value ?? [];
199
+ const list = useResourceList<Record<string, unknown>>(target ?? '', {
200
+ params: { page: 1, q: debouncedSearch || undefined },
201
+ enabled: !!target && open,
202
+ });
203
+
204
+ if (!target || !describe) {
205
+ return <div className="text-xs text-muted-foreground">Unknown relation target.</div>;
206
+ }
207
+ const display = describe.registry.display(target);
208
+
209
+ function toggle(recordId: string) {
210
+ if (selected.includes(recordId)) {
211
+ onChange(selected.filter((x) => x !== recordId));
212
+ } else {
213
+ onChange([...selected, recordId]);
214
+ }
215
+ }
216
+
217
+ return (
218
+ <div className="flex flex-col gap-2">
219
+ {selected.length ? (
220
+ <div className="flex flex-wrap gap-1">
221
+ {selected.map((sid) => (
222
+ <SelectedChip
223
+ key={sid}
224
+ target={target}
225
+ id={sid}
226
+ onRemove={readOnly || disabled ? undefined : () => toggle(sid)}
227
+ />
228
+ ))}
229
+ </div>
230
+ ) : null}
231
+ <Popover open={open} onOpenChange={setOpen}>
232
+ <PopoverTrigger asChild>
233
+ <Button
234
+ type="button"
235
+ variant="outline"
236
+ disabled={disabled || readOnly}
237
+ className="w-full justify-between font-normal text-muted-foreground"
238
+ id={id}
239
+ >
240
+ <span>{placeholder ?? `Add ${display.label.toLowerCase()}…`}</span>
241
+ <ChevronDown className="ml-2 h-4 w-4 opacity-50" />
242
+ </Button>
243
+ </PopoverTrigger>
244
+ <PopoverContent className="w-[var(--radix-popover-trigger-width)] p-0" align="start">
245
+ <Command shouldFilter={false}>
246
+ <CommandInput
247
+ placeholder={`Search ${display.pluralLabel.toLowerCase()}…`}
248
+ value={search}
249
+ onValueChange={setSearch}
250
+ />
251
+ <CommandList>
252
+ {list.isPending ? <CommandLoading>Searching…</CommandLoading> : null}
253
+ {list.data?.results.length === 0 ? (
254
+ <CommandEmpty>No results.</CommandEmpty>
255
+ ) : null}
256
+ {list.data?.results.map((record) => {
257
+ const recordId = String(record._id ?? '');
258
+ const isSelected = selected.includes(recordId);
259
+ return (
260
+ <CommandItem
261
+ key={recordId}
262
+ value={recordId}
263
+ onSelect={() => toggle(recordId)}
264
+ >
265
+ <Check
266
+ className={cn('mr-2 h-4 w-4', isSelected ? 'opacity-100' : 'opacity-0')}
267
+ />
268
+ {describe.registry.preview(target, record)}
269
+ </CommandItem>
270
+ );
271
+ })}
272
+ <CommandSeparator />
273
+ <CommandItem
274
+ onSelect={() => {
275
+ setOpen(false);
276
+ setCreating(true);
277
+ }}
278
+ className="text-primary"
279
+ >
280
+ <Plus className="mr-2 h-4 w-4" />
281
+ Create new {display.label.toLowerCase()}
282
+ </CommandItem>
283
+ </CommandList>
284
+ </Command>
285
+ </PopoverContent>
286
+ </Popover>
287
+ <RelatedCreateModal
288
+ open={creating}
289
+ onOpenChange={setCreating}
290
+ resource={target}
291
+ onCreated={(record) => {
292
+ const newId = (record as { _id?: string })._id;
293
+ if (newId) onChange([...selected, newId]);
294
+ }}
295
+ />
296
+ </div>
297
+ );
298
+ }
299
+
300
+ function SelectedChip({
301
+ target,
302
+ id,
303
+ onRemove,
304
+ }: {
305
+ target: string;
306
+ id: string;
307
+ onRemove?: () => void;
308
+ }) {
309
+ const { client, status } = useAuth();
310
+ const { data: describe } = useDescribe();
311
+ const record = useQuery({
312
+ queryKey: ['davepi', 'resource', target, 'v1', 'preview', id],
313
+ enabled: status === 'authenticated' && !!id,
314
+ staleTime: 60_000,
315
+ queryFn: () => client.get<Record<string, unknown>>(`/api/v1/${target}`, id),
316
+ });
317
+ const label = record.data && describe ? describe.registry.preview(target, record.data) : id;
318
+ return (
319
+ <span className="inline-flex items-center gap-1 rounded-sm bg-secondary px-2 py-0.5 text-xs text-secondary-foreground">
320
+ {label}
321
+ {onRemove ? (
322
+ <button
323
+ type="button"
324
+ onClick={onRemove}
325
+ aria-label={`Remove ${label}`}
326
+ className="rounded-sm hover:bg-muted"
327
+ >
328
+ <X className="h-3 w-3" />
329
+ </button>
330
+ ) : null}
331
+ </span>
332
+ );
333
+ }
334
+
335
+ function guessPrefill(searchField: string | undefined, search: string): Record<string, unknown> | undefined {
336
+ if (!searchField) return undefined;
337
+ // Stamp the user-typed search term into the most-likely display field of
338
+ // the new record so the modal feels like a continuation of the search.
339
+ return { [searchField]: search };
340
+ }
341
+
342
+ function useDebounced<T>(value: T, ms: number): T {
343
+ const [debounced, setDebounced] = useState(value);
344
+ useEffect(() => {
345
+ const t = setTimeout(() => setDebounced(value), ms);
346
+ return () => clearTimeout(t);
347
+ }, [value, ms]);
348
+ return useMemo(() => debounced, [debounced]);
349
+ }