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.
- package/LICENSE +21 -0
- package/README.md +29 -0
- package/bin/index.js +229 -0
- package/bin/sync-templates.js +100 -0
- package/package.json +40 -0
- package/templates/default/.env.example +1 -0
- package/templates/default/index.html +13 -0
- package/templates/default/package.json +49 -0
- package/templates/default/postcss.config.cjs +6 -0
- package/templates/default/src/App.tsx +42 -0
- package/templates/default/src/components/AppShell.tsx +23 -0
- package/templates/default/src/components/BulkActionBar.tsx +47 -0
- package/templates/default/src/components/RelatedCreateModal.tsx +91 -0
- package/templates/default/src/components/RelatedList.tsx +70 -0
- package/templates/default/src/components/ResourceForm.tsx +311 -0
- package/templates/default/src/components/ResourceTable.tsx +475 -0
- package/templates/default/src/components/RowActions.tsx +54 -0
- package/templates/default/src/components/Sidebar.tsx +171 -0
- package/templates/default/src/components/ui/button.tsx +43 -0
- package/templates/default/src/components/ui/card.tsx +47 -0
- package/templates/default/src/components/ui/checkbox.tsx +24 -0
- package/templates/default/src/components/ui/command.tsx +117 -0
- package/templates/default/src/components/ui/dialog.tsx +95 -0
- package/templates/default/src/components/ui/dropdown-menu.tsx +78 -0
- package/templates/default/src/components/ui/input.tsx +18 -0
- package/templates/default/src/components/ui/label.tsx +17 -0
- package/templates/default/src/components/ui/popover.tsx +27 -0
- package/templates/default/src/components/ui/select.tsx +83 -0
- package/templates/default/src/components/ui/switch.tsx +21 -0
- package/templates/default/src/components/ui/table.tsx +66 -0
- package/templates/default/src/components/ui/tabs.tsx +53 -0
- package/templates/default/src/components/ui/textarea.tsx +17 -0
- package/templates/default/src/davepi-ui.config.ts +14 -0
- package/templates/default/src/index.css +55 -0
- package/templates/default/src/lib/utils.ts +10 -0
- package/templates/default/src/main.tsx +34 -0
- package/templates/default/src/pages/DashboardPage.tsx +42 -0
- package/templates/default/src/pages/LoginScreen.tsx +77 -0
- package/templates/default/src/pages/ResourceCreatePage.tsx +58 -0
- package/templates/default/src/pages/ResourceDetailPage.tsx +171 -0
- package/templates/default/src/pages/ResourceEditPage.tsx +52 -0
- package/templates/default/src/pages/ResourceListPage.tsx +8 -0
- package/templates/default/src/resourceOverrides.ts +34 -0
- package/templates/default/src/resources/account.ts +25 -0
- package/templates/default/src/resources/category.ts +7 -0
- package/templates/default/src/resources/contact.ts +40 -0
- package/templates/default/src/resources/product.ts +7 -0
- package/templates/default/src/resources/project.ts +7 -0
- package/templates/default/src/resources/quote.ts +12 -0
- package/templates/default/src/vite-env.d.ts +9 -0
- package/templates/default/src/widgets/CurrencyInput.tsx +44 -0
- package/templates/default/src/widgets/DateInput.tsx +36 -0
- package/templates/default/src/widgets/EmailInput.tsx +28 -0
- package/templates/default/src/widgets/EnumSelect.tsx +35 -0
- package/templates/default/src/widgets/FileUploaderStub.tsx +9 -0
- package/templates/default/src/widgets/JsonEditor.tsx +64 -0
- package/templates/default/src/widgets/NumberInput.tsx +36 -0
- package/templates/default/src/widgets/RelationPicker.tsx +349 -0
- package/templates/default/src/widgets/SwitchWidget.tsx +20 -0
- package/templates/default/src/widgets/TagInput.tsx +83 -0
- package/templates/default/src/widgets/TextAreaWidget.tsx +27 -0
- package/templates/default/src/widgets/TextInput.tsx +32 -0
- package/templates/default/src/widgets/UrlInput.tsx +27 -0
- package/templates/default/src/widgets/registry.ts +51 -0
- package/templates/default/src/widgets/types.ts +26 -0
- package/templates/default/tailwind.config.ts +54 -0
- package/templates/default/tsconfig.json +40 -0
- package/templates/default/vite.config.ts +16 -0
- 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,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,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
|
+
}
|