@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.
Files changed (261) hide show
  1. package/dist/action-guard.d.ts +13 -0
  2. package/dist/action-guard.d.ts.map +1 -0
  3. package/dist/action-guard.js +15 -0
  4. package/dist/action-guard.js.map +1 -0
  5. package/dist/action-menu.d.ts +17 -0
  6. package/dist/action-menu.d.ts.map +1 -0
  7. package/dist/action-menu.jsx +80 -0
  8. package/dist/action-menu.jsx.map +1 -0
  9. package/dist/admin-app.d.ts +23 -0
  10. package/dist/admin-app.d.ts.map +1 -0
  11. package/dist/admin-app.jsx +407 -0
  12. package/dist/admin-app.jsx.map +1 -0
  13. package/dist/admin-router.d.ts +29 -0
  14. package/dist/admin-router.d.ts.map +1 -0
  15. package/dist/admin-router.jsx +215 -0
  16. package/dist/admin-router.jsx.map +1 -0
  17. package/dist/breadcrumbs.d.ts +17 -0
  18. package/dist/breadcrumbs.d.ts.map +1 -0
  19. package/dist/breadcrumbs.jsx +40 -0
  20. package/dist/breadcrumbs.jsx.map +1 -0
  21. package/dist/client.d.ts +526 -0
  22. package/dist/client.d.ts.map +1 -0
  23. package/dist/client.js +582 -0
  24. package/dist/client.js.map +1 -0
  25. package/dist/component-loader.d.ts +10 -0
  26. package/dist/component-loader.d.ts.map +1 -0
  27. package/dist/component-loader.js +23 -0
  28. package/dist/component-loader.js.map +1 -0
  29. package/dist/components/ai-assistant-widget.d.ts +3 -0
  30. package/dist/components/ai-assistant-widget.d.ts.map +1 -0
  31. package/dist/components/ai-assistant-widget.jsx +390 -0
  32. package/dist/components/ai-assistant-widget.jsx.map +1 -0
  33. package/dist/components/ai-fill-dialog.d.ts +9 -0
  34. package/dist/components/ai-fill-dialog.d.ts.map +1 -0
  35. package/dist/components/ai-fill-dialog.jsx +105 -0
  36. package/dist/components/ai-fill-dialog.jsx.map +1 -0
  37. package/dist/components/chart-builder-dialog.d.ts +10 -0
  38. package/dist/components/chart-builder-dialog.d.ts.map +1 -0
  39. package/dist/components/chart-builder-dialog.jsx +433 -0
  40. package/dist/components/chart-builder-dialog.jsx.map +1 -0
  41. package/dist/components/chart-widget.d.ts +12 -0
  42. package/dist/components/chart-widget.d.ts.map +1 -0
  43. package/dist/components/chart-widget.jsx +365 -0
  44. package/dist/components/chart-widget.jsx.map +1 -0
  45. package/dist/components/global-search-dialog.d.ts +7 -0
  46. package/dist/components/global-search-dialog.d.ts.map +1 -0
  47. package/dist/components/global-search-dialog.jsx +187 -0
  48. package/dist/components/global-search-dialog.jsx.map +1 -0
  49. package/dist/components/group-settings-dialog.d.ts +13 -0
  50. package/dist/components/group-settings-dialog.d.ts.map +1 -0
  51. package/dist/components/group-settings-dialog.jsx +53 -0
  52. package/dist/components/group-settings-dialog.jsx.map +1 -0
  53. package/dist/components/move-chart-dialog.d.ts +18 -0
  54. package/dist/components/move-chart-dialog.d.ts.map +1 -0
  55. package/dist/components/move-chart-dialog.jsx +68 -0
  56. package/dist/components/move-chart-dialog.jsx.map +1 -0
  57. package/dist/components/reference-multi-table-dialog.d.ts +12 -0
  58. package/dist/components/reference-multi-table-dialog.d.ts.map +1 -0
  59. package/dist/components/reference-multi-table-dialog.jsx +126 -0
  60. package/dist/components/reference-multi-table-dialog.jsx.map +1 -0
  61. package/dist/components/related-records-tabs.d.ts +8 -0
  62. package/dist/components/related-records-tabs.d.ts.map +1 -0
  63. package/dist/components/related-records-tabs.jsx +75 -0
  64. package/dist/components/related-records-tabs.jsx.map +1 -0
  65. package/dist/components/revisions-button.d.ts +7 -0
  66. package/dist/components/revisions-button.d.ts.map +1 -0
  67. package/dist/components/revisions-button.jsx +152 -0
  68. package/dist/components/revisions-button.jsx.map +1 -0
  69. package/dist/components/wizard-form.d.ts +43 -0
  70. package/dist/components/wizard-form.d.ts.map +1 -0
  71. package/dist/components/wizard-form.jsx +136 -0
  72. package/dist/components/wizard-form.jsx.map +1 -0
  73. package/dist/dashboard/time-series.d.ts +20 -0
  74. package/dist/dashboard/time-series.d.ts.map +1 -0
  75. package/dist/dashboard/time-series.js +108 -0
  76. package/dist/dashboard/time-series.js.map +1 -0
  77. package/dist/dialogs.d.ts +35 -0
  78. package/dist/dialogs.d.ts.map +1 -0
  79. package/dist/dialogs.jsx +152 -0
  80. package/dist/dialogs.jsx.map +1 -0
  81. package/dist/export.d.ts +39 -0
  82. package/dist/export.d.ts.map +1 -0
  83. package/dist/export.js +114 -0
  84. package/dist/export.js.map +1 -0
  85. package/dist/extension-registry.d.ts +122 -0
  86. package/dist/extension-registry.d.ts.map +1 -0
  87. package/dist/extension-registry.js +93 -0
  88. package/dist/extension-registry.js.map +1 -0
  89. package/dist/header-controls.d.ts +4 -0
  90. package/dist/header-controls.d.ts.map +1 -0
  91. package/dist/header-controls.jsx +70 -0
  92. package/dist/header-controls.jsx.map +1 -0
  93. package/dist/hooks.d.ts +104 -0
  94. package/dist/hooks.d.ts.map +1 -0
  95. package/dist/hooks.js +374 -0
  96. package/dist/hooks.js.map +1 -0
  97. package/dist/hotkey-help.d.ts +3 -0
  98. package/dist/hotkey-help.d.ts.map +1 -0
  99. package/dist/hotkey-help.jsx +32 -0
  100. package/dist/hotkey-help.jsx.map +1 -0
  101. package/dist/hotkey-registry.d.ts +18 -0
  102. package/dist/hotkey-registry.d.ts.map +1 -0
  103. package/dist/hotkey-registry.jsx +34 -0
  104. package/dist/hotkey-registry.jsx.map +1 -0
  105. package/dist/i18n.d.ts +74 -0
  106. package/dist/i18n.d.ts.map +1 -0
  107. package/dist/i18n.jsx +127 -0
  108. package/dist/i18n.jsx.map +1 -0
  109. package/dist/index.d.ts +35 -0
  110. package/dist/index.d.ts.map +1 -0
  111. package/dist/index.js +36 -0
  112. package/dist/index.js.map +1 -0
  113. package/dist/notify.d.ts +41 -0
  114. package/dist/notify.d.ts.map +1 -0
  115. package/dist/notify.jsx +58 -0
  116. package/dist/notify.jsx.map +1 -0
  117. package/dist/pages/ai-assistant-settings-section.d.ts +3 -0
  118. package/dist/pages/ai-assistant-settings-section.d.ts.map +1 -0
  119. package/dist/pages/ai-assistant-settings-section.jsx +126 -0
  120. package/dist/pages/ai-assistant-settings-section.jsx.map +1 -0
  121. package/dist/pages/audit-log-page.d.ts +3 -0
  122. package/dist/pages/audit-log-page.d.ts.map +1 -0
  123. package/dist/pages/audit-log-page.jsx +354 -0
  124. package/dist/pages/audit-log-page.jsx.map +1 -0
  125. package/dist/pages/edit-page.d.ts +7 -0
  126. package/dist/pages/edit-page.d.ts.map +1 -0
  127. package/dist/pages/edit-page.jsx +614 -0
  128. package/dist/pages/edit-page.jsx.map +1 -0
  129. package/dist/pages/export-dialog.d.ts +11 -0
  130. package/dist/pages/export-dialog.d.ts.map +1 -0
  131. package/dist/pages/export-dialog.jsx +102 -0
  132. package/dist/pages/export-dialog.jsx.map +1 -0
  133. package/dist/pages/home-page.d.ts +3 -0
  134. package/dist/pages/home-page.d.ts.map +1 -0
  135. package/dist/pages/home-page.jsx +211 -0
  136. package/dist/pages/home-page.jsx.map +1 -0
  137. package/dist/pages/list-page.d.ts +42 -0
  138. package/dist/pages/list-page.d.ts.map +1 -0
  139. package/dist/pages/list-page.jsx +1596 -0
  140. package/dist/pages/list-page.jsx.map +1 -0
  141. package/dist/pages/login-page.d.ts +11 -0
  142. package/dist/pages/login-page.d.ts.map +1 -0
  143. package/dist/pages/login-page.jsx +157 -0
  144. package/dist/pages/login-page.jsx.map +1 -0
  145. package/dist/pages/settings-page.d.ts +5 -0
  146. package/dist/pages/settings-page.d.ts.map +1 -0
  147. package/dist/pages/settings-page.jsx +787 -0
  148. package/dist/pages/settings-page.jsx.map +1 -0
  149. package/dist/pages/settings-shared.d.ts +51 -0
  150. package/dist/pages/settings-shared.d.ts.map +1 -0
  151. package/dist/pages/settings-shared.jsx +66 -0
  152. package/dist/pages/settings-shared.jsx.map +1 -0
  153. package/dist/pages/show-page.d.ts +7 -0
  154. package/dist/pages/show-page.d.ts.map +1 -0
  155. package/dist/pages/show-page.jsx +147 -0
  156. package/dist/pages/show-page.jsx.map +1 -0
  157. package/dist/pages/wizard-create-page.d.ts +14 -0
  158. package/dist/pages/wizard-create-page.d.ts.map +1 -0
  159. package/dist/pages/wizard-create-page.jsx +106 -0
  160. package/dist/pages/wizard-create-page.jsx.map +1 -0
  161. package/dist/property-renderer.d.ts +8 -0
  162. package/dist/property-renderer.d.ts.map +1 -0
  163. package/dist/property-renderer.jsx +690 -0
  164. package/dist/property-renderer.jsx.map +1 -0
  165. package/dist/provider.d.ts +20 -0
  166. package/dist/provider.d.ts.map +1 -0
  167. package/dist/provider.jsx +32 -0
  168. package/dist/provider.jsx.map +1 -0
  169. package/dist/realtime.d.ts +22 -0
  170. package/dist/realtime.d.ts.map +1 -0
  171. package/dist/realtime.js +38 -0
  172. package/dist/realtime.js.map +1 -0
  173. package/dist/reference.d.ts +52 -0
  174. package/dist/reference.d.ts.map +1 -0
  175. package/dist/reference.jsx +224 -0
  176. package/dist/reference.jsx.map +1 -0
  177. package/dist/relations.d.ts +11 -0
  178. package/dist/relations.d.ts.map +1 -0
  179. package/dist/relations.js +36 -0
  180. package/dist/relations.js.map +1 -0
  181. package/dist/router.d.ts +82 -0
  182. package/dist/router.d.ts.map +1 -0
  183. package/dist/router.jsx +187 -0
  184. package/dist/router.jsx.map +1 -0
  185. package/dist/show-when.d.ts +7 -0
  186. package/dist/show-when.d.ts.map +1 -0
  187. package/dist/show-when.js +77 -0
  188. package/dist/show-when.js.map +1 -0
  189. package/dist/types.d.ts +194 -0
  190. package/dist/types.d.ts.map +1 -0
  191. package/dist/types.js +18 -0
  192. package/dist/types.js.map +1 -0
  193. package/dist/use-dashboard-charts.d.ts +93 -0
  194. package/dist/use-dashboard-charts.d.ts.map +1 -0
  195. package/dist/use-dashboard-charts.js +263 -0
  196. package/dist/use-dashboard-charts.js.map +1 -0
  197. package/dist/use-hotkey.d.ts +17 -0
  198. package/dist/use-hotkey.d.ts.map +1 -0
  199. package/dist/use-hotkey.js +103 -0
  200. package/dist/use-hotkey.js.map +1 -0
  201. package/dist/user-directory.d.ts +18 -0
  202. package/dist/user-directory.d.ts.map +1 -0
  203. package/dist/user-directory.js +51 -0
  204. package/dist/user-directory.js.map +1 -0
  205. package/dist/validation.d.ts +22 -0
  206. package/dist/validation.d.ts.map +1 -0
  207. package/dist/validation.js +338 -0
  208. package/dist/validation.js.map +1 -0
  209. package/package.json +59 -0
  210. package/src/action-guard.ts +20 -0
  211. package/src/action-menu.tsx +161 -0
  212. package/src/admin-app.tsx +630 -0
  213. package/src/admin-router.tsx +273 -0
  214. package/src/breadcrumbs.tsx +75 -0
  215. package/src/client.ts +1093 -0
  216. package/src/component-loader.ts +33 -0
  217. package/src/components/ai-assistant-widget.tsx +565 -0
  218. package/src/components/ai-fill-dialog.tsx +143 -0
  219. package/src/components/chart-builder-dialog.tsx +618 -0
  220. package/src/components/chart-widget.tsx +654 -0
  221. package/src/components/global-search-dialog.tsx +272 -0
  222. package/src/components/group-settings-dialog.tsx +93 -0
  223. package/src/components/move-chart-dialog.tsx +130 -0
  224. package/src/components/reference-multi-table-dialog.tsx +196 -0
  225. package/src/components/related-records-tabs.tsx +130 -0
  226. package/src/components/revisions-button.tsx +237 -0
  227. package/src/components/wizard-form.tsx +302 -0
  228. package/src/dashboard/time-series.ts +125 -0
  229. package/src/dialogs.tsx +265 -0
  230. package/src/export.ts +140 -0
  231. package/src/extension-registry.ts +195 -0
  232. package/src/header-controls.tsx +125 -0
  233. package/src/hooks.ts +509 -0
  234. package/src/hotkey-help.tsx +56 -0
  235. package/src/hotkey-registry.tsx +60 -0
  236. package/src/i18n.tsx +267 -0
  237. package/src/index.ts +192 -0
  238. package/src/notify.tsx +94 -0
  239. package/src/pages/ai-assistant-settings-section.tsx +167 -0
  240. package/src/pages/audit-log-page.tsx +580 -0
  241. package/src/pages/edit-page.tsx +743 -0
  242. package/src/pages/export-dialog.tsx +154 -0
  243. package/src/pages/home-page.tsx +318 -0
  244. package/src/pages/list-page.tsx +2645 -0
  245. package/src/pages/login-page.tsx +242 -0
  246. package/src/pages/settings-page.tsx +1143 -0
  247. package/src/pages/settings-shared.tsx +134 -0
  248. package/src/pages/show-page.tsx +223 -0
  249. package/src/pages/wizard-create-page.tsx +164 -0
  250. package/src/property-renderer.tsx +1143 -0
  251. package/src/provider.tsx +70 -0
  252. package/src/realtime.ts +55 -0
  253. package/src/reference.tsx +386 -0
  254. package/src/relations.ts +55 -0
  255. package/src/router.tsx +211 -0
  256. package/src/show-when.ts +76 -0
  257. package/src/types.ts +198 -0
  258. package/src/use-dashboard-charts.ts +362 -0
  259. package/src/use-hotkey.ts +128 -0
  260. package/src/user-directory.ts +56 -0
  261. 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
+ }