@modern-admin/react 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/dist/action-guard.d.ts +13 -0
- package/dist/action-guard.d.ts.map +1 -0
- package/dist/action-guard.js +15 -0
- package/dist/action-guard.js.map +1 -0
- package/dist/action-menu.d.ts +17 -0
- package/dist/action-menu.d.ts.map +1 -0
- package/dist/action-menu.jsx +80 -0
- package/dist/action-menu.jsx.map +1 -0
- package/dist/admin-app.d.ts +23 -0
- package/dist/admin-app.d.ts.map +1 -0
- package/dist/admin-app.jsx +407 -0
- package/dist/admin-app.jsx.map +1 -0
- package/dist/admin-router.d.ts +29 -0
- package/dist/admin-router.d.ts.map +1 -0
- package/dist/admin-router.jsx +215 -0
- package/dist/admin-router.jsx.map +1 -0
- package/dist/breadcrumbs.d.ts +17 -0
- package/dist/breadcrumbs.d.ts.map +1 -0
- package/dist/breadcrumbs.jsx +40 -0
- package/dist/breadcrumbs.jsx.map +1 -0
- package/dist/client.d.ts +526 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +582 -0
- package/dist/client.js.map +1 -0
- package/dist/component-loader.d.ts +10 -0
- package/dist/component-loader.d.ts.map +1 -0
- package/dist/component-loader.js +23 -0
- package/dist/component-loader.js.map +1 -0
- package/dist/components/ai-assistant-widget.d.ts +3 -0
- package/dist/components/ai-assistant-widget.d.ts.map +1 -0
- package/dist/components/ai-assistant-widget.jsx +390 -0
- package/dist/components/ai-assistant-widget.jsx.map +1 -0
- package/dist/components/ai-fill-dialog.d.ts +9 -0
- package/dist/components/ai-fill-dialog.d.ts.map +1 -0
- package/dist/components/ai-fill-dialog.jsx +105 -0
- package/dist/components/ai-fill-dialog.jsx.map +1 -0
- package/dist/components/chart-builder-dialog.d.ts +10 -0
- package/dist/components/chart-builder-dialog.d.ts.map +1 -0
- package/dist/components/chart-builder-dialog.jsx +433 -0
- package/dist/components/chart-builder-dialog.jsx.map +1 -0
- package/dist/components/chart-widget.d.ts +12 -0
- package/dist/components/chart-widget.d.ts.map +1 -0
- package/dist/components/chart-widget.jsx +365 -0
- package/dist/components/chart-widget.jsx.map +1 -0
- package/dist/components/global-search-dialog.d.ts +7 -0
- package/dist/components/global-search-dialog.d.ts.map +1 -0
- package/dist/components/global-search-dialog.jsx +187 -0
- package/dist/components/global-search-dialog.jsx.map +1 -0
- package/dist/components/group-settings-dialog.d.ts +13 -0
- package/dist/components/group-settings-dialog.d.ts.map +1 -0
- package/dist/components/group-settings-dialog.jsx +53 -0
- package/dist/components/group-settings-dialog.jsx.map +1 -0
- package/dist/components/move-chart-dialog.d.ts +18 -0
- package/dist/components/move-chart-dialog.d.ts.map +1 -0
- package/dist/components/move-chart-dialog.jsx +68 -0
- package/dist/components/move-chart-dialog.jsx.map +1 -0
- package/dist/components/reference-multi-table-dialog.d.ts +12 -0
- package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
- package/dist/components/reference-multi-table-dialog.jsx +126 -0
- package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
- package/dist/components/related-records-tabs.d.ts +8 -0
- package/dist/components/related-records-tabs.d.ts.map +1 -0
- package/dist/components/related-records-tabs.jsx +75 -0
- package/dist/components/related-records-tabs.jsx.map +1 -0
- package/dist/components/revisions-button.d.ts +7 -0
- package/dist/components/revisions-button.d.ts.map +1 -0
- package/dist/components/revisions-button.jsx +152 -0
- package/dist/components/revisions-button.jsx.map +1 -0
- package/dist/components/wizard-form.d.ts +43 -0
- package/dist/components/wizard-form.d.ts.map +1 -0
- package/dist/components/wizard-form.jsx +136 -0
- package/dist/components/wizard-form.jsx.map +1 -0
- package/dist/dashboard/time-series.d.ts +20 -0
- package/dist/dashboard/time-series.d.ts.map +1 -0
- package/dist/dashboard/time-series.js +108 -0
- package/dist/dashboard/time-series.js.map +1 -0
- package/dist/dialogs.d.ts +35 -0
- package/dist/dialogs.d.ts.map +1 -0
- package/dist/dialogs.jsx +152 -0
- package/dist/dialogs.jsx.map +1 -0
- package/dist/export.d.ts +39 -0
- package/dist/export.d.ts.map +1 -0
- package/dist/export.js +114 -0
- package/dist/export.js.map +1 -0
- package/dist/extension-registry.d.ts +122 -0
- package/dist/extension-registry.d.ts.map +1 -0
- package/dist/extension-registry.js +93 -0
- package/dist/extension-registry.js.map +1 -0
- package/dist/header-controls.d.ts +4 -0
- package/dist/header-controls.d.ts.map +1 -0
- package/dist/header-controls.jsx +70 -0
- package/dist/header-controls.jsx.map +1 -0
- package/dist/hooks.d.ts +104 -0
- package/dist/hooks.d.ts.map +1 -0
- package/dist/hooks.js +374 -0
- package/dist/hooks.js.map +1 -0
- package/dist/hotkey-help.d.ts +3 -0
- package/dist/hotkey-help.d.ts.map +1 -0
- package/dist/hotkey-help.jsx +32 -0
- package/dist/hotkey-help.jsx.map +1 -0
- package/dist/hotkey-registry.d.ts +18 -0
- package/dist/hotkey-registry.d.ts.map +1 -0
- package/dist/hotkey-registry.jsx +34 -0
- package/dist/hotkey-registry.jsx.map +1 -0
- package/dist/i18n.d.ts +74 -0
- package/dist/i18n.d.ts.map +1 -0
- package/dist/i18n.jsx +127 -0
- package/dist/i18n.jsx.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +36 -0
- package/dist/index.js.map +1 -0
- package/dist/notify.d.ts +41 -0
- package/dist/notify.d.ts.map +1 -0
- package/dist/notify.jsx +58 -0
- package/dist/notify.jsx.map +1 -0
- package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
- package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
- package/dist/pages/ai-assistant-settings-section.jsx +126 -0
- package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
- package/dist/pages/audit-log-page.d.ts +3 -0
- package/dist/pages/audit-log-page.d.ts.map +1 -0
- package/dist/pages/audit-log-page.jsx +354 -0
- package/dist/pages/audit-log-page.jsx.map +1 -0
- package/dist/pages/edit-page.d.ts +7 -0
- package/dist/pages/edit-page.d.ts.map +1 -0
- package/dist/pages/edit-page.jsx +614 -0
- package/dist/pages/edit-page.jsx.map +1 -0
- package/dist/pages/export-dialog.d.ts +11 -0
- package/dist/pages/export-dialog.d.ts.map +1 -0
- package/dist/pages/export-dialog.jsx +102 -0
- package/dist/pages/export-dialog.jsx.map +1 -0
- package/dist/pages/home-page.d.ts +3 -0
- package/dist/pages/home-page.d.ts.map +1 -0
- package/dist/pages/home-page.jsx +211 -0
- package/dist/pages/home-page.jsx.map +1 -0
- package/dist/pages/list-page.d.ts +42 -0
- package/dist/pages/list-page.d.ts.map +1 -0
- package/dist/pages/list-page.jsx +1596 -0
- package/dist/pages/list-page.jsx.map +1 -0
- package/dist/pages/login-page.d.ts +11 -0
- package/dist/pages/login-page.d.ts.map +1 -0
- package/dist/pages/login-page.jsx +157 -0
- package/dist/pages/login-page.jsx.map +1 -0
- package/dist/pages/settings-page.d.ts +5 -0
- package/dist/pages/settings-page.d.ts.map +1 -0
- package/dist/pages/settings-page.jsx +787 -0
- package/dist/pages/settings-page.jsx.map +1 -0
- package/dist/pages/settings-shared.d.ts +51 -0
- package/dist/pages/settings-shared.d.ts.map +1 -0
- package/dist/pages/settings-shared.jsx +66 -0
- package/dist/pages/settings-shared.jsx.map +1 -0
- package/dist/pages/show-page.d.ts +7 -0
- package/dist/pages/show-page.d.ts.map +1 -0
- package/dist/pages/show-page.jsx +147 -0
- package/dist/pages/show-page.jsx.map +1 -0
- package/dist/pages/wizard-create-page.d.ts +14 -0
- package/dist/pages/wizard-create-page.d.ts.map +1 -0
- package/dist/pages/wizard-create-page.jsx +106 -0
- package/dist/pages/wizard-create-page.jsx.map +1 -0
- package/dist/property-renderer.d.ts +8 -0
- package/dist/property-renderer.d.ts.map +1 -0
- package/dist/property-renderer.jsx +690 -0
- package/dist/property-renderer.jsx.map +1 -0
- package/dist/provider.d.ts +20 -0
- package/dist/provider.d.ts.map +1 -0
- package/dist/provider.jsx +32 -0
- package/dist/provider.jsx.map +1 -0
- package/dist/realtime.d.ts +22 -0
- package/dist/realtime.d.ts.map +1 -0
- package/dist/realtime.js +38 -0
- package/dist/realtime.js.map +1 -0
- package/dist/reference.d.ts +52 -0
- package/dist/reference.d.ts.map +1 -0
- package/dist/reference.jsx +224 -0
- package/dist/reference.jsx.map +1 -0
- package/dist/relations.d.ts +11 -0
- package/dist/relations.d.ts.map +1 -0
- package/dist/relations.js +36 -0
- package/dist/relations.js.map +1 -0
- package/dist/router.d.ts +82 -0
- package/dist/router.d.ts.map +1 -0
- package/dist/router.jsx +187 -0
- package/dist/router.jsx.map +1 -0
- package/dist/show-when.d.ts +7 -0
- package/dist/show-when.d.ts.map +1 -0
- package/dist/show-when.js +77 -0
- package/dist/show-when.js.map +1 -0
- package/dist/types.d.ts +194 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +18 -0
- package/dist/types.js.map +1 -0
- package/dist/use-dashboard-charts.d.ts +93 -0
- package/dist/use-dashboard-charts.d.ts.map +1 -0
- package/dist/use-dashboard-charts.js +263 -0
- package/dist/use-dashboard-charts.js.map +1 -0
- package/dist/use-hotkey.d.ts +17 -0
- package/dist/use-hotkey.d.ts.map +1 -0
- package/dist/use-hotkey.js +103 -0
- package/dist/use-hotkey.js.map +1 -0
- package/dist/user-directory.d.ts +18 -0
- package/dist/user-directory.d.ts.map +1 -0
- package/dist/user-directory.js +51 -0
- package/dist/user-directory.js.map +1 -0
- package/dist/validation.d.ts +22 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +338 -0
- package/dist/validation.js.map +1 -0
- package/package.json +59 -0
- package/src/action-guard.ts +20 -0
- package/src/action-menu.tsx +161 -0
- package/src/admin-app.tsx +630 -0
- package/src/admin-router.tsx +273 -0
- package/src/breadcrumbs.tsx +75 -0
- package/src/client.ts +1093 -0
- package/src/component-loader.ts +33 -0
- package/src/components/ai-assistant-widget.tsx +565 -0
- package/src/components/ai-fill-dialog.tsx +143 -0
- package/src/components/chart-builder-dialog.tsx +618 -0
- package/src/components/chart-widget.tsx +654 -0
- package/src/components/global-search-dialog.tsx +272 -0
- package/src/components/group-settings-dialog.tsx +93 -0
- package/src/components/move-chart-dialog.tsx +130 -0
- package/src/components/reference-multi-table-dialog.tsx +196 -0
- package/src/components/related-records-tabs.tsx +130 -0
- package/src/components/revisions-button.tsx +237 -0
- package/src/components/wizard-form.tsx +302 -0
- package/src/dashboard/time-series.ts +125 -0
- package/src/dialogs.tsx +265 -0
- package/src/export.ts +140 -0
- package/src/extension-registry.ts +195 -0
- package/src/header-controls.tsx +125 -0
- package/src/hooks.ts +509 -0
- package/src/hotkey-help.tsx +56 -0
- package/src/hotkey-registry.tsx +60 -0
- package/src/i18n.tsx +267 -0
- package/src/index.ts +192 -0
- package/src/notify.tsx +94 -0
- package/src/pages/ai-assistant-settings-section.tsx +167 -0
- package/src/pages/audit-log-page.tsx +580 -0
- package/src/pages/edit-page.tsx +743 -0
- package/src/pages/export-dialog.tsx +154 -0
- package/src/pages/home-page.tsx +318 -0
- package/src/pages/list-page.tsx +2645 -0
- package/src/pages/login-page.tsx +242 -0
- package/src/pages/settings-page.tsx +1143 -0
- package/src/pages/settings-shared.tsx +134 -0
- package/src/pages/show-page.tsx +223 -0
- package/src/pages/wizard-create-page.tsx +164 -0
- package/src/property-renderer.tsx +1143 -0
- package/src/provider.tsx +70 -0
- package/src/realtime.ts +55 -0
- package/src/reference.tsx +386 -0
- package/src/relations.ts +55 -0
- package/src/router.tsx +211 -0
- package/src/show-when.ts +76 -0
- package/src/types.ts +198 -0
- package/src/use-dashboard-charts.ts +362 -0
- package/src/use-hotkey.ts +128 -0
- package/src/user-directory.ts +56 -0
- package/src/validation.ts +361 -0
|
@@ -0,0 +1,1143 @@
|
|
|
1
|
+
// Property-type rendering: maps a PropertyJSON.type to display + form widgets.
|
|
2
|
+
// Custom components registered via ComponentLoader take precedence.
|
|
3
|
+
|
|
4
|
+
import * as React from 'react'
|
|
5
|
+
import {
|
|
6
|
+
Button,
|
|
7
|
+
Input,
|
|
8
|
+
PasswordInput,
|
|
9
|
+
Textarea,
|
|
10
|
+
Badge,
|
|
11
|
+
FileInput,
|
|
12
|
+
MultiFileInput,
|
|
13
|
+
type MultiFileInputPendingItem,
|
|
14
|
+
Switch,
|
|
15
|
+
DatePicker,
|
|
16
|
+
JsonEditor,
|
|
17
|
+
JsonView,
|
|
18
|
+
KeyValueEditor,
|
|
19
|
+
KeyValueView,
|
|
20
|
+
MediaPreview,
|
|
21
|
+
RichtextEditor,
|
|
22
|
+
RichtextRender,
|
|
23
|
+
Select,
|
|
24
|
+
SelectContent,
|
|
25
|
+
SelectItem,
|
|
26
|
+
SelectTrigger,
|
|
27
|
+
SelectValue,
|
|
28
|
+
Tooltip,
|
|
29
|
+
TooltipContent,
|
|
30
|
+
TooltipTrigger,
|
|
31
|
+
} from '@modern-admin/ui'
|
|
32
|
+
import { Check, Copy } from 'lucide-react'
|
|
33
|
+
import { uuidv7 } from '@modern-admin/core'
|
|
34
|
+
import { useQueries } from '@tanstack/react-query'
|
|
35
|
+
import type {
|
|
36
|
+
KeyValueFieldSpec,
|
|
37
|
+
PropertyDisplayProps,
|
|
38
|
+
PropertyEditorProps,
|
|
39
|
+
PropertyJSON,
|
|
40
|
+
} from './types.js'
|
|
41
|
+
import { getPropertyExtension } from './extension-registry.js'
|
|
42
|
+
import { useAdminContext, useAdminClient } from './provider.js'
|
|
43
|
+
import { useI18n } from './i18n.js'
|
|
44
|
+
import { useNotify } from './notify.js'
|
|
45
|
+
import {
|
|
46
|
+
ReferenceCombobox,
|
|
47
|
+
ReferenceLink,
|
|
48
|
+
ReferenceLinkList,
|
|
49
|
+
ReferenceMultiCombobox,
|
|
50
|
+
} from './reference.js'
|
|
51
|
+
import { ReferenceMultiTableDialog } from './components/reference-multi-table-dialog.js'
|
|
52
|
+
import { useResource } from './hooks.js'
|
|
53
|
+
|
|
54
|
+
const formatDate = (value: unknown): string => {
|
|
55
|
+
if (value == null) return ''
|
|
56
|
+
if (value instanceof Date) return value.toISOString().slice(0, 10)
|
|
57
|
+
const d = new Date(String(value))
|
|
58
|
+
if (Number.isNaN(d.getTime())) return String(value)
|
|
59
|
+
return d.toISOString().slice(0, 10)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const formatMoneyValue = (
|
|
63
|
+
value: unknown,
|
|
64
|
+
currency?: string,
|
|
65
|
+
locale?: string,
|
|
66
|
+
): string => {
|
|
67
|
+
const amount = typeof value === 'number' ? value : Number(value)
|
|
68
|
+
if (!Number.isFinite(amount)) return String(value ?? '')
|
|
69
|
+
try {
|
|
70
|
+
if (!currency) {
|
|
71
|
+
return new Intl.NumberFormat(locale, {
|
|
72
|
+
minimumFractionDigits: 2,
|
|
73
|
+
maximumFractionDigits: 2,
|
|
74
|
+
}).format(amount)
|
|
75
|
+
}
|
|
76
|
+
return new Intl.NumberFormat(locale, {
|
|
77
|
+
style: 'currency',
|
|
78
|
+
currency,
|
|
79
|
+
minimumFractionDigits: 2,
|
|
80
|
+
maximumFractionDigits: 2,
|
|
81
|
+
}).format(amount)
|
|
82
|
+
} catch {
|
|
83
|
+
return amount.toFixed(2)
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const normalizeHexColor = (value: unknown): string | null => {
|
|
88
|
+
if (typeof value !== 'string') return null
|
|
89
|
+
const trimmed = value.trim()
|
|
90
|
+
return /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/.test(trimmed) ? trimmed : null
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function CopiableDisplay({
|
|
94
|
+
text,
|
|
95
|
+
children,
|
|
96
|
+
}: {
|
|
97
|
+
text: string
|
|
98
|
+
children: React.ReactNode
|
|
99
|
+
}): React.ReactElement {
|
|
100
|
+
const { t } = useI18n()
|
|
101
|
+
const notify = useNotify()
|
|
102
|
+
const [copied, setCopied] = React.useState(false)
|
|
103
|
+
|
|
104
|
+
React.useEffect(() => {
|
|
105
|
+
if (!copied) return
|
|
106
|
+
const timer = window.setTimeout(() => setCopied(false), 3_000)
|
|
107
|
+
return () => window.clearTimeout(timer)
|
|
108
|
+
}, [copied])
|
|
109
|
+
|
|
110
|
+
const onCopy = async (): Promise<void> => {
|
|
111
|
+
try {
|
|
112
|
+
await navigator.clipboard.writeText(text)
|
|
113
|
+
setCopied(true)
|
|
114
|
+
} catch {
|
|
115
|
+
notify.error({ key: 'settings:apiKeys.notice.copyFailed' })
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return (
|
|
120
|
+
<span className="inline-flex max-w-full items-center gap-2 align-middle">
|
|
121
|
+
<span className="min-w-0">{children}</span>
|
|
122
|
+
<Tooltip>
|
|
123
|
+
<TooltipTrigger asChild>
|
|
124
|
+
<Button
|
|
125
|
+
type="button"
|
|
126
|
+
variant="ghost"
|
|
127
|
+
size="icon"
|
|
128
|
+
className="h-7 w-7 shrink-0"
|
|
129
|
+
onClick={() => void onCopy()}
|
|
130
|
+
aria-label={copied ? t('settings:apiKeys.created.copied') : t('settings:apiKeys.created.copy')}
|
|
131
|
+
>
|
|
132
|
+
{copied ? <Check className="size-4 text-emerald-600" /> : <Copy className="size-4" />}
|
|
133
|
+
</Button>
|
|
134
|
+
</TooltipTrigger>
|
|
135
|
+
<TooltipContent>
|
|
136
|
+
{copied ? t('settings:apiKeys.created.copied') : t('settings:apiKeys.created.copy')}
|
|
137
|
+
</TooltipContent>
|
|
138
|
+
</Tooltip>
|
|
139
|
+
</span>
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// PropertyDisplayProps is defined in types.ts (shared with extension-registry).
|
|
144
|
+
// Re-exported here for backwards compat.
|
|
145
|
+
export type { PropertyDisplayProps } from './types.js'
|
|
146
|
+
|
|
147
|
+
function ListCellText({ children }: { children: React.ReactNode }): React.ReactElement {
|
|
148
|
+
return (
|
|
149
|
+
<span
|
|
150
|
+
className="max-w-full overflow-hidden break-words text-foreground"
|
|
151
|
+
style={{
|
|
152
|
+
display: '-webkit-box',
|
|
153
|
+
WebkitBoxOrient: 'vertical',
|
|
154
|
+
WebkitLineClamp: 5,
|
|
155
|
+
whiteSpace: 'pre-wrap',
|
|
156
|
+
}}
|
|
157
|
+
>
|
|
158
|
+
{children}
|
|
159
|
+
</span>
|
|
160
|
+
)
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
export function PropertyDisplay({ property, value, view = 'list', populated }: PropertyDisplayProps): React.ReactElement | null {
|
|
164
|
+
const { components } = useAdminContext()
|
|
165
|
+
const { t, locale } = useI18n()
|
|
166
|
+
const copiable = view === 'show' && (property.isId === true || property.custom?.copiable === true)
|
|
167
|
+
const withCopy = (content: React.ReactElement): React.ReactElement =>
|
|
168
|
+
copiable ? <CopiableDisplay text={String(value)}>{content}</CopiableDisplay> : content
|
|
169
|
+
const componentName = property.components?.[view]
|
|
170
|
+
if (componentName && components?.has(componentName)) {
|
|
171
|
+
const Custom = components.get(componentName)!
|
|
172
|
+
return <Custom property={property} value={value} view={view} />
|
|
173
|
+
}
|
|
174
|
+
if (value == null || value === '') return <span className="text-muted-foreground">—</span>
|
|
175
|
+
switch (property.type) {
|
|
176
|
+
case 'boolean':
|
|
177
|
+
return <Badge variant={value ? 'default' : 'outline'}>{value ? 'true' : 'false'}</Badge>
|
|
178
|
+
case 'date':
|
|
179
|
+
case 'datetime':
|
|
180
|
+
return withCopy(view === 'list' ? <ListCellText>{formatDate(value)}</ListCellText> : <span>{formatDate(value)}</span>)
|
|
181
|
+
case 'money': {
|
|
182
|
+
const currency = typeof property.custom?.currency === 'string'
|
|
183
|
+
? property.custom.currency
|
|
184
|
+
: undefined
|
|
185
|
+
return withCopy(
|
|
186
|
+
view === 'list'
|
|
187
|
+
? <ListCellText>{formatMoneyValue(value, currency, locale)}</ListCellText>
|
|
188
|
+
: <span>{formatMoneyValue(value, currency, locale)}</span>,
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
case 'json':
|
|
192
|
+
case 'mixed':
|
|
193
|
+
case 'key-value':
|
|
194
|
+
if (property.keyValueFields?.length) {
|
|
195
|
+
return (
|
|
196
|
+
<KeyValueView
|
|
197
|
+
fields={property.keyValueFields}
|
|
198
|
+
value={value}
|
|
199
|
+
variant={view === 'list' ? 'inline' : 'block'}
|
|
200
|
+
labels={{
|
|
201
|
+
emptyValue: '—',
|
|
202
|
+
trueLabel: t('common:yes'),
|
|
203
|
+
falseLabel: t('common:no'),
|
|
204
|
+
}}
|
|
205
|
+
/>
|
|
206
|
+
)
|
|
207
|
+
}
|
|
208
|
+
return <JsonView value={value} inline={view === 'list'} />
|
|
209
|
+
|
|
210
|
+
case 'reference':
|
|
211
|
+
if (property.reference) {
|
|
212
|
+
if (property.isArray) {
|
|
213
|
+
const ids = Array.isArray(value)
|
|
214
|
+
? (value as Array<string | number>)
|
|
215
|
+
: []
|
|
216
|
+
return (
|
|
217
|
+
<ReferenceLinkList
|
|
218
|
+
resourceId={property.reference}
|
|
219
|
+
recordIds={ids}
|
|
220
|
+
populated={populated}
|
|
221
|
+
populatedKeyPrefix={property.path}
|
|
222
|
+
/>
|
|
223
|
+
)
|
|
224
|
+
}
|
|
225
|
+
const populatedRecord = populated?.[property.path] as
|
|
226
|
+
| { id?: string; title?: string }
|
|
227
|
+
| undefined
|
|
228
|
+
return (
|
|
229
|
+
<ReferenceLink
|
|
230
|
+
resourceId={property.reference}
|
|
231
|
+
recordId={value as string | number}
|
|
232
|
+
showIcon={view === 'show'}
|
|
233
|
+
populated={populatedRecord}
|
|
234
|
+
/>
|
|
235
|
+
)
|
|
236
|
+
}
|
|
237
|
+
return <Badge variant="secondary">{String(value)}</Badge>
|
|
238
|
+
case 'm2m': {
|
|
239
|
+
const items = Array.isArray(value) ? (value as Array<Record<string, unknown>>) : []
|
|
240
|
+
const m2m = property.custom?.m2m as
|
|
241
|
+
| { reference: string; extraFields?: string[] }
|
|
242
|
+
| undefined
|
|
243
|
+
const reference = m2m?.reference ?? property.reference
|
|
244
|
+
const ids = items.map((i) => String(i.id ?? ''))
|
|
245
|
+
if (!reference) return <span className="text-muted-foreground">—</span>
|
|
246
|
+
if (items.length === 0) return <span className="text-muted-foreground">—</span>
|
|
247
|
+
const extras = m2m?.extraFields ?? []
|
|
248
|
+
if (view === 'list' || extras.length === 0) {
|
|
249
|
+
return (
|
|
250
|
+
<ReferenceLinkList
|
|
251
|
+
resourceId={reference}
|
|
252
|
+
recordIds={ids}
|
|
253
|
+
populated={populated}
|
|
254
|
+
populatedKeyPrefix={property.path}
|
|
255
|
+
/>
|
|
256
|
+
)
|
|
257
|
+
}
|
|
258
|
+
return (
|
|
259
|
+
<div className="space-y-1">
|
|
260
|
+
{items.map((it) => {
|
|
261
|
+
const populatedRef = populated?.[`${property.path}.${it.id}`] as
|
|
262
|
+
| { id?: string; title?: string }
|
|
263
|
+
| undefined
|
|
264
|
+
return (
|
|
265
|
+
<div key={String(it.id)} className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
|
266
|
+
<ReferenceLink
|
|
267
|
+
resourceId={reference}
|
|
268
|
+
recordId={String(it.id)}
|
|
269
|
+
populated={populatedRef}
|
|
270
|
+
/>
|
|
271
|
+
{extras.map((f) =>
|
|
272
|
+
it[f] != null && it[f] !== '' ? (
|
|
273
|
+
<span key={f} className="text-xs text-muted-foreground">
|
|
274
|
+
{f}:{' '}
|
|
275
|
+
<span className="text-foreground">{String(it[f])}</span>
|
|
276
|
+
</span>
|
|
277
|
+
) : null,
|
|
278
|
+
)}
|
|
279
|
+
</div>
|
|
280
|
+
)
|
|
281
|
+
})}
|
|
282
|
+
</div>
|
|
283
|
+
)
|
|
284
|
+
}
|
|
285
|
+
case 'richtext':
|
|
286
|
+
if (view === 'show') {
|
|
287
|
+
return <RichtextRender value={String(value)} format="html" />
|
|
288
|
+
}
|
|
289
|
+
// List view: strip HTML tags for a compact preview.
|
|
290
|
+
return (
|
|
291
|
+
<ListCellText>
|
|
292
|
+
{String(value).replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' ').trim()}
|
|
293
|
+
</ListCellText>
|
|
294
|
+
)
|
|
295
|
+
case 'markdown':
|
|
296
|
+
if (view === 'show') {
|
|
297
|
+
return <RichtextRender value={String(value)} format="markdown" />
|
|
298
|
+
}
|
|
299
|
+
return (
|
|
300
|
+
<ListCellText>
|
|
301
|
+
{String(value).replace(/[#>*_`~-]/g, '').replace(/\s+/g, ' ').trim()}
|
|
302
|
+
</ListCellText>
|
|
303
|
+
)
|
|
304
|
+
case 'textarea':
|
|
305
|
+
return withCopy(
|
|
306
|
+
view === 'show'
|
|
307
|
+
? <span className="whitespace-pre-wrap text-foreground">{String(value)}</span>
|
|
308
|
+
: <ListCellText>{String(value)}</ListCellText>,
|
|
309
|
+
)
|
|
310
|
+
case 'color': {
|
|
311
|
+
const color = normalizeHexColor(value)
|
|
312
|
+
if (!color) {
|
|
313
|
+
return withCopy(view === 'list' ? <ListCellText>{String(value)}</ListCellText> : <span>{String(value)}</span>)
|
|
314
|
+
}
|
|
315
|
+
return withCopy(
|
|
316
|
+
<span className="inline-flex items-center gap-2">
|
|
317
|
+
<span className="size-3 rounded-full border border-border" style={{ backgroundColor: color }} />
|
|
318
|
+
<span>{color.toUpperCase()}</span>
|
|
319
|
+
</span>,
|
|
320
|
+
)
|
|
321
|
+
}
|
|
322
|
+
case 'previewMedia': {
|
|
323
|
+
const url = String(value)
|
|
324
|
+
const labels = {
|
|
325
|
+
preview: t('common:preview'),
|
|
326
|
+
download: t('common:download'),
|
|
327
|
+
openInNewTab: t('common:openInNewTab'),
|
|
328
|
+
title: property.label || t('common:preview'),
|
|
329
|
+
}
|
|
330
|
+
return (
|
|
331
|
+
<MediaPreview
|
|
332
|
+
url={url}
|
|
333
|
+
labels={labels}
|
|
334
|
+
showUrl={view === 'show'}
|
|
335
|
+
triggerSize="sm"
|
|
336
|
+
triggerVariant="outline"
|
|
337
|
+
/>
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
case 'file': {
|
|
341
|
+
const template = property.custom?.uploadUrlTemplate as string | undefined
|
|
342
|
+
const renderOne = (rawKey: string, idx?: number): React.ReactElement => {
|
|
343
|
+
const url = template
|
|
344
|
+
? template.replace('{key}', rawKey)
|
|
345
|
+
: rawKey.startsWith('http')
|
|
346
|
+
? rawKey
|
|
347
|
+
: null
|
|
348
|
+
const filename = rawKey.split('/').pop() ?? rawKey
|
|
349
|
+
if (url) {
|
|
350
|
+
const labels = {
|
|
351
|
+
preview: t('common:preview'),
|
|
352
|
+
download: t('common:download'),
|
|
353
|
+
openInNewTab: t('common:openInNewTab'),
|
|
354
|
+
title: filename,
|
|
355
|
+
}
|
|
356
|
+
return (
|
|
357
|
+
<MediaPreview
|
|
358
|
+
key={idx ?? rawKey}
|
|
359
|
+
url={url}
|
|
360
|
+
downloadName={filename}
|
|
361
|
+
labels={labels}
|
|
362
|
+
showUrl={view === 'show'}
|
|
363
|
+
triggerSize="sm"
|
|
364
|
+
triggerVariant="outline"
|
|
365
|
+
/>
|
|
366
|
+
)
|
|
367
|
+
}
|
|
368
|
+
return (
|
|
369
|
+
<span key={idx ?? rawKey} className="text-sm text-muted-foreground">
|
|
370
|
+
{filename}
|
|
371
|
+
</span>
|
|
372
|
+
)
|
|
373
|
+
}
|
|
374
|
+
if (Array.isArray(value)) {
|
|
375
|
+
const arr = value as Array<unknown>
|
|
376
|
+
if (arr.length === 0) return <span className="text-muted-foreground">—</span>
|
|
377
|
+
return (
|
|
378
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
379
|
+
{arr.map((v, i) => renderOne(String(v), i))}
|
|
380
|
+
</div>
|
|
381
|
+
)
|
|
382
|
+
}
|
|
383
|
+
return renderOne(String(value))
|
|
384
|
+
}
|
|
385
|
+
default: {
|
|
386
|
+
// Check the extension registry for a custom type before falling back to plain text.
|
|
387
|
+
const ext = getPropertyExtension(property.type)
|
|
388
|
+
if (ext) return <ext.display property={property} value={value} view={view} populated={populated} />
|
|
389
|
+
return withCopy(view === 'list' ? <ListCellText>{String(value)}</ListCellText> : <span>{String(value)}</span>)
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// PropertyEditorProps is defined in types.ts (shared with extension-registry).
|
|
395
|
+
// Re-exported here for backwards compat.
|
|
396
|
+
export type { PropertyEditorProps } from './types.js'
|
|
397
|
+
|
|
398
|
+
// ─── File upload editor ───────────────────────────────────────────────────────
|
|
399
|
+
|
|
400
|
+
interface FilePropertyEditorProps {
|
|
401
|
+
property: PropertyJSON
|
|
402
|
+
value: unknown
|
|
403
|
+
onChange(next: unknown): void
|
|
404
|
+
disabled?: boolean
|
|
405
|
+
resourceId?: string
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Build the public URL for a stored key, using `{key}` substitution. */
|
|
409
|
+
const urlForKey = (key: string, template: string | undefined): string | null => {
|
|
410
|
+
if (template) return template.replace('{key}', key)
|
|
411
|
+
if (key.startsWith('http')) return key
|
|
412
|
+
return null
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/**
|
|
416
|
+
* Local pending state for one in-flight upload. A small `id` (set on first
|
|
417
|
+
* insertion and never re-used) keys the React row so progress updates do not
|
|
418
|
+
* disturb the list ordering.
|
|
419
|
+
*/
|
|
420
|
+
interface PendingUpload {
|
|
421
|
+
id: string
|
|
422
|
+
name: string
|
|
423
|
+
progress: number
|
|
424
|
+
status: 'queued' | 'uploading' | 'error'
|
|
425
|
+
error?: string
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
const newPendingId = (): string => uuidv7()
|
|
429
|
+
|
|
430
|
+
function FilePropertyEditor({
|
|
431
|
+
property,
|
|
432
|
+
value,
|
|
433
|
+
onChange,
|
|
434
|
+
disabled,
|
|
435
|
+
resourceId,
|
|
436
|
+
}: FilePropertyEditorProps): React.ReactElement {
|
|
437
|
+
const client = useAdminClient()
|
|
438
|
+
const { t } = useI18n()
|
|
439
|
+
const isArray = Boolean(property.isArray)
|
|
440
|
+
const [pending, setPending] = React.useState<PendingUpload[]>([])
|
|
441
|
+
const [uploadError, setUploadError] = React.useState<string | null>(null)
|
|
442
|
+
|
|
443
|
+
// Map of key → freshly-uploaded URL (so we can render previews without
|
|
444
|
+
// waiting for the form to re-fetch), and the set of keys that were uploaded
|
|
445
|
+
// in this editing session and have not yet been "saved" by submitting the
|
|
446
|
+
// form. The latter is used to fire `cancelUpload` when the user removes a
|
|
447
|
+
// pending file before saving.
|
|
448
|
+
const [uploadedUrls, setUploadedUrls] = React.useState<Record<string, string>>({})
|
|
449
|
+
const pendingKeysRef = React.useRef<Set<string>>(new Set())
|
|
450
|
+
|
|
451
|
+
const template = property.custom?.uploadUrlTemplate as string | undefined
|
|
452
|
+
const accept =
|
|
453
|
+
(property.custom?.uploadMimeTypes as string[] | null | undefined)?.join(',') ?? undefined
|
|
454
|
+
|
|
455
|
+
// Normalise current value into an array of keys for uniform handling.
|
|
456
|
+
const currentKeys: string[] = React.useMemo(() => {
|
|
457
|
+
if (isArray) {
|
|
458
|
+
return Array.isArray(value)
|
|
459
|
+
? (value as unknown[]).flatMap((v) => (v == null || v === '' ? [] : [String(v)]))
|
|
460
|
+
: []
|
|
461
|
+
}
|
|
462
|
+
return value == null || value === '' ? [] : [String(value)]
|
|
463
|
+
}, [value, isArray])
|
|
464
|
+
|
|
465
|
+
const currentKeysRef = React.useRef(currentKeys)
|
|
466
|
+
React.useEffect(() => {
|
|
467
|
+
currentKeysRef.current = currentKeys
|
|
468
|
+
}, [currentKeys])
|
|
469
|
+
|
|
470
|
+
const cancelIfPending = React.useCallback(
|
|
471
|
+
(key: string): void => {
|
|
472
|
+
if (!resourceId) return
|
|
473
|
+
if (!pendingKeysRef.current.has(key)) return
|
|
474
|
+
pendingKeysRef.current.delete(key)
|
|
475
|
+
void client.cancelUpload(resourceId, property.path, key).catch(() => {
|
|
476
|
+
// Best-effort — server-side TTL sweeper handles missed cancellations.
|
|
477
|
+
})
|
|
478
|
+
},
|
|
479
|
+
[client, resourceId, property.path],
|
|
480
|
+
)
|
|
481
|
+
|
|
482
|
+
const startUploads = async (files: File[]): Promise<void> => {
|
|
483
|
+
if (!resourceId) {
|
|
484
|
+
setUploadError('resourceId is required for file upload')
|
|
485
|
+
return
|
|
486
|
+
}
|
|
487
|
+
setUploadError(null)
|
|
488
|
+
// For single-value fields, only the first file matters; the rest are
|
|
489
|
+
// dropped before they ever hit the network.
|
|
490
|
+
const accepted = isArray ? files : files.slice(0, 1)
|
|
491
|
+
if (accepted.length === 0) return
|
|
492
|
+
// Pre-allocate one pending row per file; the index correlates with the
|
|
493
|
+
// upload index used by per-item callbacks.
|
|
494
|
+
const ids = accepted.map(() => newPendingId())
|
|
495
|
+
setPending((prev) => [
|
|
496
|
+
...prev,
|
|
497
|
+
...accepted.map((f, i) => ({
|
|
498
|
+
id: ids[i]!,
|
|
499
|
+
name: f.name,
|
|
500
|
+
progress: 0,
|
|
501
|
+
status: 'queued' as const,
|
|
502
|
+
})),
|
|
503
|
+
])
|
|
504
|
+
|
|
505
|
+
await client.uploadFiles(resourceId, property.path, accepted, {
|
|
506
|
+
concurrency: 3,
|
|
507
|
+
onItemStart: (i) => {
|
|
508
|
+
setPending((prev) =>
|
|
509
|
+
prev.map((p) => (p.id === ids[i] ? { ...p, status: 'uploading' } : p)),
|
|
510
|
+
)
|
|
511
|
+
},
|
|
512
|
+
onItemProgress: (i, _f, p) => {
|
|
513
|
+
setPending((prev) =>
|
|
514
|
+
prev.map((row) => (row.id === ids[i] ? { ...row, progress: p.percent } : row)),
|
|
515
|
+
)
|
|
516
|
+
},
|
|
517
|
+
onItemComplete: (i, _f, info) => {
|
|
518
|
+
setPending((prev) => prev.filter((p) => p.id !== ids[i]))
|
|
519
|
+
setUploadedUrls((u) => ({ ...u, [info.key]: info.url }))
|
|
520
|
+
pendingKeysRef.current.add(info.key)
|
|
521
|
+
if (isArray) {
|
|
522
|
+
const next = [...currentKeysRef.current, info.key]
|
|
523
|
+
currentKeysRef.current = next
|
|
524
|
+
onChange(next)
|
|
525
|
+
} else {
|
|
526
|
+
// Single value: cancel any previously-staged key being replaced.
|
|
527
|
+
for (const old of currentKeysRef.current) cancelIfPending(old)
|
|
528
|
+
currentKeysRef.current = [info.key]
|
|
529
|
+
onChange(info.key)
|
|
530
|
+
}
|
|
531
|
+
},
|
|
532
|
+
onItemError: (i, _f, err) => {
|
|
533
|
+
setPending((prev) =>
|
|
534
|
+
prev.map((p) =>
|
|
535
|
+
p.id === ids[i] ? { ...p, status: 'error', error: err.message } : p,
|
|
536
|
+
),
|
|
537
|
+
)
|
|
538
|
+
},
|
|
539
|
+
})
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
const dismissPending = (id: string): void => {
|
|
543
|
+
setPending((prev) => prev.filter((p) => p.id !== id))
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const removeAt = (index: number): void => {
|
|
547
|
+
const key = currentKeys[index]
|
|
548
|
+
if (key) cancelIfPending(key)
|
|
549
|
+
if (isArray) {
|
|
550
|
+
const next = currentKeys.filter((_, i) => i !== index)
|
|
551
|
+
onChange(next)
|
|
552
|
+
} else {
|
|
553
|
+
onChange(null)
|
|
554
|
+
}
|
|
555
|
+
setUploadError(null)
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const stillUploading = pending.some((p) => p.status !== 'error')
|
|
559
|
+
|
|
560
|
+
if (isArray) {
|
|
561
|
+
const items = currentKeys.map((key) => ({
|
|
562
|
+
value: key,
|
|
563
|
+
previewUrl: uploadedUrls[key] ?? urlForKey(key, template),
|
|
564
|
+
}))
|
|
565
|
+
const pendingItems: MultiFileInputPendingItem[] = pending.map((p) => ({
|
|
566
|
+
id: p.id,
|
|
567
|
+
name: p.name,
|
|
568
|
+
progress: p.status === 'error' ? undefined : p.progress,
|
|
569
|
+
status: p.status,
|
|
570
|
+
error: p.error,
|
|
571
|
+
}))
|
|
572
|
+
return (
|
|
573
|
+
<MultiFileInput
|
|
574
|
+
items={items}
|
|
575
|
+
pendingItems={pendingItems}
|
|
576
|
+
accept={accept}
|
|
577
|
+
error={uploadError ?? undefined}
|
|
578
|
+
disabled={disabled}
|
|
579
|
+
labels={{
|
|
580
|
+
chooseFiles: t('common:chooseFiles'),
|
|
581
|
+
dragAndDrop: t('common:dragAndDrop'),
|
|
582
|
+
chooseLink: t('common:chooseAFile'),
|
|
583
|
+
addMoreLink: t('common:addMoreFiles'),
|
|
584
|
+
uploading: t('common:uploading'),
|
|
585
|
+
removeFile: t('common:removeFile'),
|
|
586
|
+
uploadFailed: t('common:uploadFailed'),
|
|
587
|
+
dismiss: t('common:dismiss'),
|
|
588
|
+
}}
|
|
589
|
+
onFilesSelect={(files) => {
|
|
590
|
+
void startUploads(files)
|
|
591
|
+
}}
|
|
592
|
+
onRemove={removeAt}
|
|
593
|
+
onPendingDismiss={dismissPending}
|
|
594
|
+
/>
|
|
595
|
+
)
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
const storedKey = currentKeys[0] ?? null
|
|
599
|
+
const previewUrl = storedKey ? (uploadedUrls[storedKey] ?? urlForKey(storedKey, template)) : null
|
|
600
|
+
// For single-value fields we surface the latest in-flight upload's progress
|
|
601
|
+
// through the simple FileInput's `uploading` flag. The detailed progress UI
|
|
602
|
+
// lives in MultiFileInput.
|
|
603
|
+
const activePending = pending.find((p) => p.status === 'uploading') ?? pending.find((p) => p.status === 'queued')
|
|
604
|
+
const erroredPending = pending.find((p) => p.status === 'error')
|
|
605
|
+
return (
|
|
606
|
+
<FileInput
|
|
607
|
+
value={storedKey}
|
|
608
|
+
previewUrl={previewUrl}
|
|
609
|
+
accept={accept}
|
|
610
|
+
uploading={stillUploading}
|
|
611
|
+
uploadProgress={activePending?.progress}
|
|
612
|
+
uploadingName={activePending?.name}
|
|
613
|
+
error={uploadError ?? erroredPending?.error ?? undefined}
|
|
614
|
+
disabled={disabled}
|
|
615
|
+
labels={{
|
|
616
|
+
chooseFile: t('common:chooseFile'),
|
|
617
|
+
dragAndDrop: t('common:dragAndDrop'),
|
|
618
|
+
chooseAFile: t('common:chooseAFile'),
|
|
619
|
+
uploading: t('common:uploading'),
|
|
620
|
+
uploadingFile: t('common:uploadingFile'),
|
|
621
|
+
removeFile: t('common:removeFile'),
|
|
622
|
+
}}
|
|
623
|
+
onFileSelect={(f) => {
|
|
624
|
+
void startUploads([f])
|
|
625
|
+
}}
|
|
626
|
+
onRemove={() => removeAt(0)}
|
|
627
|
+
/>
|
|
628
|
+
)
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// ─── M2M editor ───────────────────────────────────────────────────────────────
|
|
632
|
+
|
|
633
|
+
interface M2MItemValue extends Record<string, unknown> {
|
|
634
|
+
id: string
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/**
|
|
638
|
+
* Editor for many-to-many properties registered by `m2mFeature`. Wraps the
|
|
639
|
+
* existing `ReferenceMultiCombobox` for picking referenced records, then
|
|
640
|
+
* (when the relation has extra junction columns) renders a per-item row of
|
|
641
|
+
* nested `PropertyEditor`s — one per extra field, typed from the junction
|
|
642
|
+
* resource's own property declarations.
|
|
643
|
+
*/
|
|
644
|
+
function M2MPropertyEditor({
|
|
645
|
+
property,
|
|
646
|
+
value,
|
|
647
|
+
onChange,
|
|
648
|
+
disabled,
|
|
649
|
+
}: PropertyEditorProps): React.ReactElement {
|
|
650
|
+
const m2m = property.custom?.m2m as
|
|
651
|
+
| {
|
|
652
|
+
reference: string
|
|
653
|
+
through: string
|
|
654
|
+
extraFields?: string[]
|
|
655
|
+
}
|
|
656
|
+
| undefined
|
|
657
|
+
const junction = useResource(m2m?.through)
|
|
658
|
+
if (!m2m?.reference) return <span className="text-muted-foreground">—</span>
|
|
659
|
+
const items: M2MItemValue[] = Array.isArray(value)
|
|
660
|
+
? (value as Array<Record<string, unknown>>).flatMap((entry) => {
|
|
661
|
+
if (entry == null) return []
|
|
662
|
+
if (typeof entry === 'string' || typeof entry === 'number') {
|
|
663
|
+
return [{ id: String(entry) }]
|
|
664
|
+
}
|
|
665
|
+
if (typeof entry === 'object' && entry.id != null) {
|
|
666
|
+
return [{ ...entry, id: String((entry as { id: unknown }).id) }]
|
|
667
|
+
}
|
|
668
|
+
return []
|
|
669
|
+
})
|
|
670
|
+
: []
|
|
671
|
+
const ids = items.map((i) => String(i.id))
|
|
672
|
+
const extras = m2m.extraFields ?? []
|
|
673
|
+
|
|
674
|
+
const setIds = (nextIds: Array<string | number>): void => {
|
|
675
|
+
const byId = new Map<string, M2MItemValue>(items.map((i) => [String(i.id), i]))
|
|
676
|
+
const next = nextIds.map((rawId) => {
|
|
677
|
+
const id = String(rawId)
|
|
678
|
+
return byId.get(id) ?? ({ id } as M2MItemValue)
|
|
679
|
+
})
|
|
680
|
+
onChange(next)
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
const updateItem = (id: string, field: string, val: unknown): void => {
|
|
684
|
+
onChange(items.map((it) => (String(it.id) === id ? { ...it, [field]: val } : it)))
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// m2m relations are typically large tables, so default to the table-driven
|
|
688
|
+
// dialog picker. Opt back into the combobox via `m2m.picker = 'combobox'`.
|
|
689
|
+
const Picker =
|
|
690
|
+
(m2m as { picker?: string } | undefined)?.picker === 'combobox'
|
|
691
|
+
? ReferenceMultiCombobox
|
|
692
|
+
: ReferenceMultiTableDialog
|
|
693
|
+
return (
|
|
694
|
+
<div className="space-y-3">
|
|
695
|
+
<Picker
|
|
696
|
+
referenceResourceId={m2m.reference}
|
|
697
|
+
value={ids}
|
|
698
|
+
onChange={setIds}
|
|
699
|
+
disabled={disabled}
|
|
700
|
+
/>
|
|
701
|
+
{extras.length > 0 && items.length > 0 ? (
|
|
702
|
+
<div className="space-y-1.5">
|
|
703
|
+
{items.map((item) => (
|
|
704
|
+
<div
|
|
705
|
+
key={item.id}
|
|
706
|
+
className="rounded-md border border-border bg-muted/30 px-2.5 py-2"
|
|
707
|
+
>
|
|
708
|
+
{/* Mobile: stack reference link above the extras row.
|
|
709
|
+
≥sm: reference link gets a fixed-width slot on the left,
|
|
710
|
+
extras flow inline on the right so we don't waste space. */}
|
|
711
|
+
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:gap-3">
|
|
712
|
+
<div className="min-w-0 sm:w-32 sm:shrink-0 sm:pt-1.5">
|
|
713
|
+
<ReferenceLink resourceId={m2m.reference} recordId={item.id} showIcon />
|
|
714
|
+
</div>
|
|
715
|
+
<div
|
|
716
|
+
className={
|
|
717
|
+
'grid min-w-0 flex-1 gap-2 ' +
|
|
718
|
+
(extras.length > 1 ? 'sm:grid-cols-2' : '')
|
|
719
|
+
}
|
|
720
|
+
>
|
|
721
|
+
{extras.map((f) => {
|
|
722
|
+
const junctionProp = junction?.properties.find((p) => p.path === f)
|
|
723
|
+
const synthetic: PropertyJSON = junctionProp ?? {
|
|
724
|
+
path: f,
|
|
725
|
+
label: f,
|
|
726
|
+
type: 'string',
|
|
727
|
+
isId: false,
|
|
728
|
+
isSortable: false,
|
|
729
|
+
isRequired: false,
|
|
730
|
+
isDisabled: false,
|
|
731
|
+
isArray: false,
|
|
732
|
+
reference: null,
|
|
733
|
+
availableValues: null,
|
|
734
|
+
components: {},
|
|
735
|
+
visibility: { list: false, show: true, edit: true, filter: false },
|
|
736
|
+
position: 1,
|
|
737
|
+
custom: {},
|
|
738
|
+
}
|
|
739
|
+
return (
|
|
740
|
+
<div key={f} className="flex items-center gap-2">
|
|
741
|
+
<label className="w-16 shrink-0 text-xs font-medium text-muted-foreground sm:w-auto">
|
|
742
|
+
{synthetic.label}
|
|
743
|
+
</label>
|
|
744
|
+
<div className="min-w-0 flex-1">
|
|
745
|
+
<PropertyEditor
|
|
746
|
+
property={synthetic}
|
|
747
|
+
value={item[f]}
|
|
748
|
+
onChange={(v) => updateItem(String(item.id), f, v)}
|
|
749
|
+
disabled={disabled}
|
|
750
|
+
/>
|
|
751
|
+
</div>
|
|
752
|
+
</div>
|
|
753
|
+
)
|
|
754
|
+
})}
|
|
755
|
+
</div>
|
|
756
|
+
</div>
|
|
757
|
+
</div>
|
|
758
|
+
))}
|
|
759
|
+
</div>
|
|
760
|
+
) : null}
|
|
761
|
+
</div>
|
|
762
|
+
)
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ─── KeyValue editor wrapper that resolves DB-bound autocomplete sources ────
|
|
766
|
+
|
|
767
|
+
/**
|
|
768
|
+
* Loads dynamic autocomplete suggestions for `keyValueFields[i].type ===
|
|
769
|
+
* 'autocomplete'` fields that declare a `suggestionsResource` +
|
|
770
|
+
* `suggestionsField` binding, then forwards everything to KeyValueEditor.
|
|
771
|
+
*
|
|
772
|
+
* Static suggestions (declared via `availableValues` on the field) are
|
|
773
|
+
* already supported inside the editor itself — this wrapper only handles
|
|
774
|
+
* the network-fetch side, so the UI primitive stays i18n- and
|
|
775
|
+
* client-unaware.
|
|
776
|
+
*/
|
|
777
|
+
function KeyValueEditorWithSuggestions({
|
|
778
|
+
fields,
|
|
779
|
+
value,
|
|
780
|
+
onChange,
|
|
781
|
+
disabled,
|
|
782
|
+
}: {
|
|
783
|
+
fields: ReadonlyArray<KeyValueFieldSpec>
|
|
784
|
+
value: unknown
|
|
785
|
+
onChange(next: Record<string, unknown>): void
|
|
786
|
+
disabled?: boolean
|
|
787
|
+
}): React.ReactElement {
|
|
788
|
+
const client = useAdminClient()
|
|
789
|
+
const { t } = useI18n()
|
|
790
|
+
|
|
791
|
+
// Identify just the fields that need a network fetch. The order is
|
|
792
|
+
// stable across renders (driven by the `fields` prop array) so the
|
|
793
|
+
// `useQueries` array length is stable too.
|
|
794
|
+
const dynamic = React.useMemo(
|
|
795
|
+
() =>
|
|
796
|
+
fields.filter(
|
|
797
|
+
(f): f is KeyValueFieldSpec & {
|
|
798
|
+
suggestionsResource: string
|
|
799
|
+
suggestionsField: string
|
|
800
|
+
} =>
|
|
801
|
+
f.type === 'autocomplete' &&
|
|
802
|
+
!!f.suggestionsResource &&
|
|
803
|
+
!!f.suggestionsField,
|
|
804
|
+
),
|
|
805
|
+
[fields],
|
|
806
|
+
)
|
|
807
|
+
|
|
808
|
+
const queries = useQueries({
|
|
809
|
+
queries: dynamic.map((f) => ({
|
|
810
|
+
queryKey: [
|
|
811
|
+
'modern-admin',
|
|
812
|
+
'fieldSuggestions',
|
|
813
|
+
f.suggestionsResource,
|
|
814
|
+
f.suggestionsField,
|
|
815
|
+
200,
|
|
816
|
+
] as const,
|
|
817
|
+
queryFn: async (): Promise<string[]> => {
|
|
818
|
+
const res = await client.list(f.suggestionsResource, { perPage: 200 })
|
|
819
|
+
const seen = new Set<string>()
|
|
820
|
+
const out: string[] = []
|
|
821
|
+
for (const r of res.records) {
|
|
822
|
+
const raw = r.params?.[f.suggestionsField]
|
|
823
|
+
if (raw == null || raw === '') continue
|
|
824
|
+
const v = String(raw)
|
|
825
|
+
if (seen.has(v)) continue
|
|
826
|
+
seen.add(v)
|
|
827
|
+
out.push(v)
|
|
828
|
+
}
|
|
829
|
+
out.sort((a, b) => a.localeCompare(b))
|
|
830
|
+
return out
|
|
831
|
+
},
|
|
832
|
+
staleTime: 60_000,
|
|
833
|
+
})),
|
|
834
|
+
})
|
|
835
|
+
|
|
836
|
+
const suggestionsByKey: Record<string, string[]> = {}
|
|
837
|
+
const suggestionsLoadingByKey: Record<string, boolean> = {}
|
|
838
|
+
dynamic.forEach((f, i) => {
|
|
839
|
+
suggestionsByKey[f.key] = queries[i]?.data ?? []
|
|
840
|
+
suggestionsLoadingByKey[f.key] = queries[i]?.isLoading ?? false
|
|
841
|
+
})
|
|
842
|
+
|
|
843
|
+
return (
|
|
844
|
+
<KeyValueEditor
|
|
845
|
+
fields={fields}
|
|
846
|
+
value={value}
|
|
847
|
+
onChange={onChange}
|
|
848
|
+
disabled={disabled}
|
|
849
|
+
suggestionsByKey={suggestionsByKey}
|
|
850
|
+
suggestionsLoadingByKey={suggestionsLoadingByKey}
|
|
851
|
+
labels={{
|
|
852
|
+
combobox: {
|
|
853
|
+
loading: t('common:loading'),
|
|
854
|
+
// KeyValueEditor's combobox label inherits the field label; this
|
|
855
|
+
// is the empty-state message inside the dropdown.
|
|
856
|
+
noMatches: t('keyValue:noMatches'),
|
|
857
|
+
},
|
|
858
|
+
}}
|
|
859
|
+
/>
|
|
860
|
+
)
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
// ─── Generic property editor ──────────────────────────────────────────────────
|
|
864
|
+
|
|
865
|
+
export function PropertyEditor({
|
|
866
|
+
property,
|
|
867
|
+
value,
|
|
868
|
+
onChange,
|
|
869
|
+
disabled,
|
|
870
|
+
resourceId,
|
|
871
|
+
}: PropertyEditorProps): React.ReactElement {
|
|
872
|
+
const { components } = useAdminContext()
|
|
873
|
+
const { t } = useI18n()
|
|
874
|
+
const componentName = property.components?.edit
|
|
875
|
+
if (componentName && components?.has(componentName)) {
|
|
876
|
+
const Custom = components.get(componentName)!
|
|
877
|
+
return <Custom property={property} value={value} onChange={onChange} disabled={disabled} />
|
|
878
|
+
}
|
|
879
|
+
const stringValue = value == null ? '' : String(value)
|
|
880
|
+
if (property.type === 'm2m') {
|
|
881
|
+
return (
|
|
882
|
+
<M2MPropertyEditor
|
|
883
|
+
property={property}
|
|
884
|
+
value={value}
|
|
885
|
+
onChange={onChange}
|
|
886
|
+
disabled={disabled}
|
|
887
|
+
resourceId={resourceId}
|
|
888
|
+
/>
|
|
889
|
+
)
|
|
890
|
+
}
|
|
891
|
+
if (property.reference) {
|
|
892
|
+
if (property.isArray) {
|
|
893
|
+
const arr = Array.isArray(value)
|
|
894
|
+
? (value as Array<string | number>)
|
|
895
|
+
: []
|
|
896
|
+
// Opt into the table-driven dialog picker via `custom.picker = 'dialog'`;
|
|
897
|
+
// default stays as the compact combobox for plain reference arrays.
|
|
898
|
+
const pickerKind = (property.custom as { picker?: string } | undefined)?.picker
|
|
899
|
+
const ArrayPicker =
|
|
900
|
+
pickerKind === 'dialog' ? ReferenceMultiTableDialog : ReferenceMultiCombobox
|
|
901
|
+
return (
|
|
902
|
+
<ArrayPicker
|
|
903
|
+
referenceResourceId={property.reference}
|
|
904
|
+
value={arr}
|
|
905
|
+
onChange={(next) => onChange(next)}
|
|
906
|
+
disabled={disabled}
|
|
907
|
+
/>
|
|
908
|
+
)
|
|
909
|
+
}
|
|
910
|
+
return (
|
|
911
|
+
<ReferenceCombobox
|
|
912
|
+
referenceResourceId={property.reference}
|
|
913
|
+
value={value as string | number | null | undefined}
|
|
914
|
+
onChange={(next) => onChange(next)}
|
|
915
|
+
disabled={disabled}
|
|
916
|
+
/>
|
|
917
|
+
)
|
|
918
|
+
}
|
|
919
|
+
if (property.availableValues?.length) {
|
|
920
|
+
return (
|
|
921
|
+
<Select
|
|
922
|
+
value={stringValue}
|
|
923
|
+
onValueChange={(v) => onChange(v === '_empty_' ? '' : v)}
|
|
924
|
+
disabled={disabled}
|
|
925
|
+
>
|
|
926
|
+
<SelectTrigger>
|
|
927
|
+
<SelectValue placeholder="—" />
|
|
928
|
+
</SelectTrigger>
|
|
929
|
+
<SelectContent>
|
|
930
|
+
<SelectItem value="_empty_">—</SelectItem>
|
|
931
|
+
{property.availableValues.map((opt) => (
|
|
932
|
+
<SelectItem key={opt.value} value={opt.value}>
|
|
933
|
+
{opt.label}
|
|
934
|
+
</SelectItem>
|
|
935
|
+
))}
|
|
936
|
+
</SelectContent>
|
|
937
|
+
</Select>
|
|
938
|
+
)
|
|
939
|
+
}
|
|
940
|
+
switch (property.type) {
|
|
941
|
+
case 'boolean':
|
|
942
|
+
return (
|
|
943
|
+
<Switch
|
|
944
|
+
checked={Boolean(value)}
|
|
945
|
+
onCheckedChange={(v) => onChange(Boolean(v))}
|
|
946
|
+
disabled={disabled}
|
|
947
|
+
/>
|
|
948
|
+
)
|
|
949
|
+
case 'json':
|
|
950
|
+
case 'mixed':
|
|
951
|
+
case 'key-value':
|
|
952
|
+
if (property.keyValueFields?.length) {
|
|
953
|
+
return (
|
|
954
|
+
<KeyValueEditorWithSuggestions
|
|
955
|
+
fields={property.keyValueFields}
|
|
956
|
+
value={value}
|
|
957
|
+
onChange={(next) => onChange(next)}
|
|
958
|
+
disabled={disabled}
|
|
959
|
+
/>
|
|
960
|
+
)
|
|
961
|
+
}
|
|
962
|
+
return (
|
|
963
|
+
<JsonEditor
|
|
964
|
+
value={value}
|
|
965
|
+
onChange={onChange}
|
|
966
|
+
disabled={disabled}
|
|
967
|
+
formatLabel={t('common:format')}
|
|
968
|
+
invalidLabel={t('common:invalidJson')}
|
|
969
|
+
/>
|
|
970
|
+
)
|
|
971
|
+
case 'number':
|
|
972
|
+
case 'float':
|
|
973
|
+
case 'currency':
|
|
974
|
+
case 'money':
|
|
975
|
+
return (
|
|
976
|
+
<Input
|
|
977
|
+
type="number"
|
|
978
|
+
inputMode="decimal"
|
|
979
|
+
step="0.01"
|
|
980
|
+
value={stringValue}
|
|
981
|
+
onChange={(e) => onChange(e.target.value === '' ? null : Number(e.target.value))}
|
|
982
|
+
disabled={disabled}
|
|
983
|
+
/>
|
|
984
|
+
)
|
|
985
|
+
case 'date':
|
|
986
|
+
return (
|
|
987
|
+
<DatePicker
|
|
988
|
+
mode="date"
|
|
989
|
+
value={value == null ? '' : String(value)}
|
|
990
|
+
onChange={(v) => onChange(v === '' ? null : v)}
|
|
991
|
+
disabled={disabled}
|
|
992
|
+
ariaLabel={property.label}
|
|
993
|
+
openCalendarLabel={t('common:openCalendar')}
|
|
994
|
+
timeLabel={t('common:time')}
|
|
995
|
+
/>
|
|
996
|
+
)
|
|
997
|
+
case 'datetime':
|
|
998
|
+
case 'datetime-local':
|
|
999
|
+
return (
|
|
1000
|
+
<DatePicker
|
|
1001
|
+
mode="datetime"
|
|
1002
|
+
value={value == null ? '' : String(value)}
|
|
1003
|
+
onChange={(v) => onChange(v === '' ? null : v)}
|
|
1004
|
+
disabled={disabled}
|
|
1005
|
+
ariaLabel={property.label}
|
|
1006
|
+
openCalendarLabel={t('common:openCalendar')}
|
|
1007
|
+
timeLabel={t('common:time')}
|
|
1008
|
+
/>
|
|
1009
|
+
)
|
|
1010
|
+
case 'richtext':
|
|
1011
|
+
return (
|
|
1012
|
+
<RichtextEditor
|
|
1013
|
+
value={stringValue}
|
|
1014
|
+
onChange={(v) => onChange(v)}
|
|
1015
|
+
format="html"
|
|
1016
|
+
disabled={disabled}
|
|
1017
|
+
ariaLabelledBy={property.label}
|
|
1018
|
+
labels={{
|
|
1019
|
+
bold: t('richtext:bold'),
|
|
1020
|
+
italic: t('richtext:italic'),
|
|
1021
|
+
strikethrough: t('richtext:strikethrough'),
|
|
1022
|
+
inlineCode: t('richtext:inlineCode'),
|
|
1023
|
+
heading: t('richtext:heading'),
|
|
1024
|
+
bulletList: t('richtext:bulletList'),
|
|
1025
|
+
numberedList: t('richtext:numberedList'),
|
|
1026
|
+
blockquote: t('richtext:blockquote'),
|
|
1027
|
+
horizontalRule: t('richtext:horizontalRule'),
|
|
1028
|
+
insertLink: t('richtext:insertLink'),
|
|
1029
|
+
undo: t('richtext:undo'),
|
|
1030
|
+
redo: t('richtext:redo'),
|
|
1031
|
+
source: t('richtext:source'),
|
|
1032
|
+
splitView: t('richtext:splitView'),
|
|
1033
|
+
visualEditor: t('richtext:visualEditor'),
|
|
1034
|
+
fullscreen: t('richtext:fullscreen'),
|
|
1035
|
+
exitFullscreen: t('richtext:exitFullscreen'),
|
|
1036
|
+
urlPrompt: t('richtext:urlPrompt'),
|
|
1037
|
+
}}
|
|
1038
|
+
/>
|
|
1039
|
+
)
|
|
1040
|
+
case 'markdown':
|
|
1041
|
+
return (
|
|
1042
|
+
<RichtextEditor
|
|
1043
|
+
value={stringValue}
|
|
1044
|
+
onChange={(v) => onChange(v)}
|
|
1045
|
+
format="markdown"
|
|
1046
|
+
disabled={disabled}
|
|
1047
|
+
ariaLabelledBy={property.label}
|
|
1048
|
+
labels={{
|
|
1049
|
+
bold: t('richtext:bold'),
|
|
1050
|
+
italic: t('richtext:italic'),
|
|
1051
|
+
strikethrough: t('richtext:strikethrough'),
|
|
1052
|
+
inlineCode: t('richtext:inlineCode'),
|
|
1053
|
+
heading: t('richtext:heading'),
|
|
1054
|
+
bulletList: t('richtext:bulletList'),
|
|
1055
|
+
numberedList: t('richtext:numberedList'),
|
|
1056
|
+
blockquote: t('richtext:blockquote'),
|
|
1057
|
+
horizontalRule: t('richtext:horizontalRule'),
|
|
1058
|
+
insertLink: t('richtext:insertLink'),
|
|
1059
|
+
undo: t('richtext:undo'),
|
|
1060
|
+
redo: t('richtext:redo'),
|
|
1061
|
+
source: t('richtext:source'),
|
|
1062
|
+
splitView: t('richtext:splitView'),
|
|
1063
|
+
visualEditor: t('richtext:visualEditor'),
|
|
1064
|
+
fullscreen: t('richtext:fullscreen'),
|
|
1065
|
+
exitFullscreen: t('richtext:exitFullscreen'),
|
|
1066
|
+
urlPrompt: t('richtext:urlPrompt'),
|
|
1067
|
+
}}
|
|
1068
|
+
/>
|
|
1069
|
+
)
|
|
1070
|
+
case 'textarea':
|
|
1071
|
+
return (
|
|
1072
|
+
<Textarea
|
|
1073
|
+
value={stringValue}
|
|
1074
|
+
onChange={(e) => onChange(e.target.value)}
|
|
1075
|
+
disabled={disabled}
|
|
1076
|
+
rows={5}
|
|
1077
|
+
/>
|
|
1078
|
+
)
|
|
1079
|
+
case 'password':
|
|
1080
|
+
return (
|
|
1081
|
+
<PasswordInput
|
|
1082
|
+
value={stringValue}
|
|
1083
|
+
onChange={(e) => onChange(e.target.value)}
|
|
1084
|
+
disabled={disabled}
|
|
1085
|
+
toggleLabel={{
|
|
1086
|
+
show: t('common:showPassword'),
|
|
1087
|
+
hide: t('common:hidePassword'),
|
|
1088
|
+
}}
|
|
1089
|
+
/>
|
|
1090
|
+
)
|
|
1091
|
+
case 'file':
|
|
1092
|
+
return (
|
|
1093
|
+
<FilePropertyEditor
|
|
1094
|
+
property={property}
|
|
1095
|
+
value={value}
|
|
1096
|
+
onChange={onChange}
|
|
1097
|
+
disabled={disabled}
|
|
1098
|
+
resourceId={resourceId}
|
|
1099
|
+
/>
|
|
1100
|
+
)
|
|
1101
|
+
case 'previewMedia':
|
|
1102
|
+
return (
|
|
1103
|
+
<Input
|
|
1104
|
+
type="url"
|
|
1105
|
+
inputMode="url"
|
|
1106
|
+
placeholder="https://…"
|
|
1107
|
+
value={stringValue}
|
|
1108
|
+
onChange={(e) => onChange(e.target.value)}
|
|
1109
|
+
disabled={disabled}
|
|
1110
|
+
/>
|
|
1111
|
+
)
|
|
1112
|
+
case 'color':
|
|
1113
|
+
return (
|
|
1114
|
+
<div className="flex items-center gap-3">
|
|
1115
|
+
<Input
|
|
1116
|
+
type="color"
|
|
1117
|
+
className="h-10 w-14 rounded-md p-1"
|
|
1118
|
+
value={normalizeHexColor(value) ?? '#000000'}
|
|
1119
|
+
onChange={(e) => onChange(e.target.value)}
|
|
1120
|
+
disabled={disabled}
|
|
1121
|
+
/>
|
|
1122
|
+
<Input
|
|
1123
|
+
value={stringValue}
|
|
1124
|
+
placeholder="#000000"
|
|
1125
|
+
onChange={(e) => onChange(e.target.value)}
|
|
1126
|
+
disabled={disabled}
|
|
1127
|
+
/>
|
|
1128
|
+
</div>
|
|
1129
|
+
)
|
|
1130
|
+
default: {
|
|
1131
|
+
// Check the extension registry for a custom type before falling back to a plain text input.
|
|
1132
|
+
const ext = getPropertyExtension(property.type)
|
|
1133
|
+
if (ext) return <ext.editor property={property} value={value} onChange={onChange} disabled={disabled} resourceId={resourceId} />
|
|
1134
|
+
return (
|
|
1135
|
+
<Input
|
|
1136
|
+
value={stringValue}
|
|
1137
|
+
onChange={(e) => onChange(e.target.value)}
|
|
1138
|
+
disabled={disabled}
|
|
1139
|
+
/>
|
|
1140
|
+
)
|
|
1141
|
+
}
|
|
1142
|
+
}
|
|
1143
|
+
}
|