@open-mercato/ui 0.4.2-canary-c02407ff85

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 (319) hide show
  1. package/build.mjs +62 -0
  2. package/dist/backend/AppShell.js +902 -0
  3. package/dist/backend/AppShell.js.map +7 -0
  4. package/dist/backend/ConfirmDialog.js +17 -0
  5. package/dist/backend/ConfirmDialog.js.map +7 -0
  6. package/dist/backend/ContextHelp.js +31 -0
  7. package/dist/backend/ContextHelp.js.map +7 -0
  8. package/dist/backend/CrudForm.js +2028 -0
  9. package/dist/backend/CrudForm.js.map +7 -0
  10. package/dist/backend/DataTable.js +1363 -0
  11. package/dist/backend/DataTable.js.map +7 -0
  12. package/dist/backend/EmptyState.js +52 -0
  13. package/dist/backend/EmptyState.js.map +7 -0
  14. package/dist/backend/FilterBar.js +140 -0
  15. package/dist/backend/FilterBar.js.map +7 -0
  16. package/dist/backend/FilterOverlay.js +279 -0
  17. package/dist/backend/FilterOverlay.js.map +7 -0
  18. package/dist/backend/FlashMessages.js +66 -0
  19. package/dist/backend/FlashMessages.js.map +7 -0
  20. package/dist/backend/JsonBuilder.js +322 -0
  21. package/dist/backend/JsonBuilder.js.map +7 -0
  22. package/dist/backend/JsonDisplay.js +203 -0
  23. package/dist/backend/JsonDisplay.js.map +7 -0
  24. package/dist/backend/Page.js +27 -0
  25. package/dist/backend/Page.js.map +7 -0
  26. package/dist/backend/PerspectiveSidebar.js +282 -0
  27. package/dist/backend/PerspectiveSidebar.js.map +7 -0
  28. package/dist/backend/RowActions.js +148 -0
  29. package/dist/backend/RowActions.js.map +7 -0
  30. package/dist/backend/TruncatedCell.js +92 -0
  31. package/dist/backend/TruncatedCell.js.map +7 -0
  32. package/dist/backend/UserMenu.js +107 -0
  33. package/dist/backend/UserMenu.js.map +7 -0
  34. package/dist/backend/ValueIcons.js +34 -0
  35. package/dist/backend/ValueIcons.js.map +7 -0
  36. package/dist/backend/custom-fields/FieldDefinitionsEditor.js +1264 -0
  37. package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +7 -0
  38. package/dist/backend/custom-fields/FieldDefinitionsManager.js +332 -0
  39. package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +7 -0
  40. package/dist/backend/dashboard/DashboardScreen.js +578 -0
  41. package/dist/backend/dashboard/DashboardScreen.js.map +7 -0
  42. package/dist/backend/dashboard/index.js +5 -0
  43. package/dist/backend/dashboard/index.js.map +7 -0
  44. package/dist/backend/dashboard/widgetRegistry.js +55 -0
  45. package/dist/backend/dashboard/widgetRegistry.js.map +7 -0
  46. package/dist/backend/detail/ActivitiesSection.js +962 -0
  47. package/dist/backend/detail/ActivitiesSection.js.map +7 -0
  48. package/dist/backend/detail/AddressEditor.js +413 -0
  49. package/dist/backend/detail/AddressEditor.js.map +7 -0
  50. package/dist/backend/detail/AddressTiles.js +437 -0
  51. package/dist/backend/detail/AddressTiles.js.map +7 -0
  52. package/dist/backend/detail/AddressesSection.js +264 -0
  53. package/dist/backend/detail/AddressesSection.js.map +7 -0
  54. package/dist/backend/detail/AttachmentDeleteDialog.js +41 -0
  55. package/dist/backend/detail/AttachmentDeleteDialog.js.map +7 -0
  56. package/dist/backend/detail/AttachmentMetadataDialog.js +517 -0
  57. package/dist/backend/detail/AttachmentMetadataDialog.js.map +7 -0
  58. package/dist/backend/detail/AttachmentsSection.js +367 -0
  59. package/dist/backend/detail/AttachmentsSection.js.map +7 -0
  60. package/dist/backend/detail/CustomDataSection.js +433 -0
  61. package/dist/backend/detail/CustomDataSection.js.map +7 -0
  62. package/dist/backend/detail/DetailFieldsSection.js +75 -0
  63. package/dist/backend/detail/DetailFieldsSection.js.map +7 -0
  64. package/dist/backend/detail/ErrorMessage.js +28 -0
  65. package/dist/backend/detail/ErrorMessage.js.map +7 -0
  66. package/dist/backend/detail/InlineEditors.js +681 -0
  67. package/dist/backend/detail/InlineEditors.js.map +7 -0
  68. package/dist/backend/detail/LoadingMessage.js +14 -0
  69. package/dist/backend/detail/LoadingMessage.js.map +7 -0
  70. package/dist/backend/detail/NotesSection.js +1032 -0
  71. package/dist/backend/detail/NotesSection.js.map +7 -0
  72. package/dist/backend/detail/TabEmptyState.js +25 -0
  73. package/dist/backend/detail/TabEmptyState.js.map +7 -0
  74. package/dist/backend/detail/TagsSection.js +254 -0
  75. package/dist/backend/detail/TagsSection.js.map +7 -0
  76. package/dist/backend/detail/addressFormat.js +77 -0
  77. package/dist/backend/detail/addressFormat.js.map +7 -0
  78. package/dist/backend/detail/index.js +34 -0
  79. package/dist/backend/detail/index.js.map +7 -0
  80. package/dist/backend/fields/registry.generated.js +8 -0
  81. package/dist/backend/fields/registry.generated.js.map +7 -0
  82. package/dist/backend/fields/registry.js +29 -0
  83. package/dist/backend/fields/registry.js.map +7 -0
  84. package/dist/backend/indexes/PartialIndexBanner.js +58 -0
  85. package/dist/backend/indexes/PartialIndexBanner.js.map +7 -0
  86. package/dist/backend/indexes/store.js +62 -0
  87. package/dist/backend/indexes/store.js.map +7 -0
  88. package/dist/backend/injection/InjectionSpot.js +179 -0
  89. package/dist/backend/injection/InjectionSpot.js.map +7 -0
  90. package/dist/backend/injection/PageInjectionBoundary.js +26 -0
  91. package/dist/backend/injection/PageInjectionBoundary.js.map +7 -0
  92. package/dist/backend/injection/helpers.js +26 -0
  93. package/dist/backend/injection/helpers.js.map +7 -0
  94. package/dist/backend/injection/widgetRegistry.js +55 -0
  95. package/dist/backend/injection/widgetRegistry.js.map +7 -0
  96. package/dist/backend/inputs/ComboboxInput.js +225 -0
  97. package/dist/backend/inputs/ComboboxInput.js.map +7 -0
  98. package/dist/backend/inputs/LookupSelect.js +191 -0
  99. package/dist/backend/inputs/LookupSelect.js.map +7 -0
  100. package/dist/backend/inputs/PhoneNumberField.js +100 -0
  101. package/dist/backend/inputs/PhoneNumberField.js.map +7 -0
  102. package/dist/backend/inputs/SwitchableMarkdownInput.js +92 -0
  103. package/dist/backend/inputs/SwitchableMarkdownInput.js.map +7 -0
  104. package/dist/backend/inputs/TagsInput.js +222 -0
  105. package/dist/backend/inputs/TagsInput.js.map +7 -0
  106. package/dist/backend/inputs/index.js +6 -0
  107. package/dist/backend/inputs/index.js.map +7 -0
  108. package/dist/backend/operations/LastOperationBanner.js +80 -0
  109. package/dist/backend/operations/LastOperationBanner.js.map +7 -0
  110. package/dist/backend/operations/store.js +183 -0
  111. package/dist/backend/operations/store.js.map +7 -0
  112. package/dist/backend/schedule/ScheduleAgenda.js +107 -0
  113. package/dist/backend/schedule/ScheduleAgenda.js.map +7 -0
  114. package/dist/backend/schedule/ScheduleGrid.js +107 -0
  115. package/dist/backend/schedule/ScheduleGrid.js.map +7 -0
  116. package/dist/backend/schedule/ScheduleToolbar.js +166 -0
  117. package/dist/backend/schedule/ScheduleToolbar.js.map +7 -0
  118. package/dist/backend/schedule/ScheduleView.js +165 -0
  119. package/dist/backend/schedule/ScheduleView.js.map +7 -0
  120. package/dist/backend/schedule/index.js +6 -0
  121. package/dist/backend/schedule/index.js.map +7 -0
  122. package/dist/backend/schedule/recurrence.js +83 -0
  123. package/dist/backend/schedule/recurrence.js.map +7 -0
  124. package/dist/backend/schedule/types.js +1 -0
  125. package/dist/backend/schedule/types.js.map +7 -0
  126. package/dist/backend/upgrades/UpgradeActionBanner.js +91 -0
  127. package/dist/backend/upgrades/UpgradeActionBanner.js.map +7 -0
  128. package/dist/backend/utils/api.js +127 -0
  129. package/dist/backend/utils/api.js.map +7 -0
  130. package/dist/backend/utils/apiCall.js +48 -0
  131. package/dist/backend/utils/apiCall.js.map +7 -0
  132. package/dist/backend/utils/crud.js +126 -0
  133. package/dist/backend/utils/crud.js.map +7 -0
  134. package/dist/backend/utils/customFieldColumns.js +56 -0
  135. package/dist/backend/utils/customFieldColumns.js.map +7 -0
  136. package/dist/backend/utils/customFieldDefs.js +143 -0
  137. package/dist/backend/utils/customFieldDefs.js.map +7 -0
  138. package/dist/backend/utils/customFieldFilters.js +126 -0
  139. package/dist/backend/utils/customFieldFilters.js.map +7 -0
  140. package/dist/backend/utils/customFieldForms.js +162 -0
  141. package/dist/backend/utils/customFieldForms.js.map +7 -0
  142. package/dist/backend/utils/customFieldValues.js +26 -0
  143. package/dist/backend/utils/customFieldValues.js.map +7 -0
  144. package/dist/backend/utils/flash.js +16 -0
  145. package/dist/backend/utils/flash.js.map +7 -0
  146. package/dist/backend/utils/nav.js +185 -0
  147. package/dist/backend/utils/nav.js.map +7 -0
  148. package/dist/backend/utils/serverErrors.js +230 -0
  149. package/dist/backend/utils/serverErrors.js.map +7 -0
  150. package/dist/frontend/AuthFooter.js +23 -0
  151. package/dist/frontend/AuthFooter.js.map +7 -0
  152. package/dist/frontend/LanguageSwitcher.js +57 -0
  153. package/dist/frontend/LanguageSwitcher.js.map +7 -0
  154. package/dist/frontend/Layout.js +14 -0
  155. package/dist/frontend/Layout.js.map +7 -0
  156. package/dist/index.js +32 -0
  157. package/dist/index.js.map +7 -0
  158. package/dist/primitives/DataLoader.js +67 -0
  159. package/dist/primitives/DataLoader.js.map +7 -0
  160. package/dist/primitives/ErrorNotice.js +20 -0
  161. package/dist/primitives/ErrorNotice.js.map +7 -0
  162. package/dist/primitives/alert.js +38 -0
  163. package/dist/primitives/alert.js.map +7 -0
  164. package/dist/primitives/badge.js +28 -0
  165. package/dist/primitives/badge.js.map +7 -0
  166. package/dist/primitives/button.js +44 -0
  167. package/dist/primitives/button.js.map +7 -0
  168. package/dist/primitives/card.js +91 -0
  169. package/dist/primitives/card.js.map +7 -0
  170. package/dist/primitives/checkbox.js +28 -0
  171. package/dist/primitives/checkbox.js.map +7 -0
  172. package/dist/primitives/dialog.js +90 -0
  173. package/dist/primitives/dialog.js.map +7 -0
  174. package/dist/primitives/input.js +22 -0
  175. package/dist/primitives/input.js.map +7 -0
  176. package/dist/primitives/label.js +21 -0
  177. package/dist/primitives/label.js.map +7 -0
  178. package/dist/primitives/separator.js +9 -0
  179. package/dist/primitives/separator.js.map +7 -0
  180. package/dist/primitives/spinner.js +24 -0
  181. package/dist/primitives/spinner.js.map +7 -0
  182. package/dist/primitives/switch.js +80 -0
  183. package/dist/primitives/switch.js.map +7 -0
  184. package/dist/primitives/table.js +29 -0
  185. package/dist/primitives/table.js.map +7 -0
  186. package/dist/primitives/tabs.js +87 -0
  187. package/dist/primitives/tabs.js.map +7 -0
  188. package/dist/primitives/textarea.js +21 -0
  189. package/dist/primitives/textarea.js.map +7 -0
  190. package/dist/primitives/tooltip.js +60 -0
  191. package/dist/primitives/tooltip.js.map +7 -0
  192. package/dist/theme/QueryProvider.js +44 -0
  193. package/dist/theme/QueryProvider.js.map +7 -0
  194. package/dist/theme/ThemeProvider.js +95 -0
  195. package/dist/theme/ThemeProvider.js.map +7 -0
  196. package/dist/theme/ThemeToggle.js +88 -0
  197. package/dist/theme/ThemeToggle.js.map +7 -0
  198. package/dist/theme/index.js +10 -0
  199. package/dist/theme/index.js.map +7 -0
  200. package/dist/types/react-big-calendar.d.js +1 -0
  201. package/dist/types/react-big-calendar.d.js.map +7 -0
  202. package/jest.config.cjs +23 -0
  203. package/jest.setup.ts +55 -0
  204. package/package.json +105 -0
  205. package/src/backend/AppShell.tsx +1096 -0
  206. package/src/backend/ConfirmDialog.tsx +19 -0
  207. package/src/backend/ContextHelp.tsx +38 -0
  208. package/src/backend/CrudForm.tsx +2503 -0
  209. package/src/backend/DataTable.tsx +1730 -0
  210. package/src/backend/EmptyState.tsx +65 -0
  211. package/src/backend/FilterBar.tsx +161 -0
  212. package/src/backend/FilterOverlay.tsx +328 -0
  213. package/src/backend/FlashMessages.tsx +82 -0
  214. package/src/backend/JsonBuilder.tsx +362 -0
  215. package/src/backend/JsonDisplay.tsx +254 -0
  216. package/src/backend/Page.tsx +30 -0
  217. package/src/backend/PerspectiveSidebar.tsx +337 -0
  218. package/src/backend/RowActions.tsx +151 -0
  219. package/src/backend/TruncatedCell.tsx +133 -0
  220. package/src/backend/UserMenu.tsx +118 -0
  221. package/src/backend/ValueIcons.tsx +48 -0
  222. package/src/backend/__tests__/AppShell.test.tsx +115 -0
  223. package/src/backend/__tests__/CrudForm.render.test.tsx +30 -0
  224. package/src/backend/__tests__/DataTable.render.test.tsx +48 -0
  225. package/src/backend/__tests__/custom-field-filters.test.ts +72 -0
  226. package/src/backend/__tests__/custom-field-forms.test.ts +54 -0
  227. package/src/backend/__tests__/serverErrors.test.ts +83 -0
  228. package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +1292 -0
  229. package/src/backend/custom-fields/FieldDefinitionsManager.tsx +381 -0
  230. package/src/backend/dashboard/DashboardScreen.tsx +684 -0
  231. package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +112 -0
  232. package/src/backend/dashboard/index.ts +1 -0
  233. package/src/backend/dashboard/widgetRegistry.ts +68 -0
  234. package/src/backend/detail/ActivitiesSection.tsx +1284 -0
  235. package/src/backend/detail/AddressEditor.tsx +472 -0
  236. package/src/backend/detail/AddressTiles.tsx +587 -0
  237. package/src/backend/detail/AddressesSection.tsx +346 -0
  238. package/src/backend/detail/AttachmentDeleteDialog.tsx +56 -0
  239. package/src/backend/detail/AttachmentMetadataDialog.tsx +672 -0
  240. package/src/backend/detail/AttachmentsSection.tsx +414 -0
  241. package/src/backend/detail/CustomDataSection.tsx +530 -0
  242. package/src/backend/detail/DetailFieldsSection.tsx +147 -0
  243. package/src/backend/detail/ErrorMessage.tsx +32 -0
  244. package/src/backend/detail/InlineEditors.tsx +877 -0
  245. package/src/backend/detail/LoadingMessage.tsx +14 -0
  246. package/src/backend/detail/NotesSection.tsx +1275 -0
  247. package/src/backend/detail/TabEmptyState.tsx +48 -0
  248. package/src/backend/detail/TagsSection.tsx +314 -0
  249. package/src/backend/detail/addressFormat.tsx +121 -0
  250. package/src/backend/detail/index.ts +44 -0
  251. package/src/backend/fields/registry.generated.ts +8 -0
  252. package/src/backend/fields/registry.ts +38 -0
  253. package/src/backend/indexes/PartialIndexBanner.tsx +68 -0
  254. package/src/backend/indexes/store.ts +88 -0
  255. package/src/backend/injection/InjectionSpot.tsx +236 -0
  256. package/src/backend/injection/PageInjectionBoundary.tsx +31 -0
  257. package/src/backend/injection/helpers.ts +35 -0
  258. package/src/backend/injection/widgetRegistry.ts +68 -0
  259. package/src/backend/inputs/ComboboxInput.tsx +269 -0
  260. package/src/backend/inputs/LookupSelect.tsx +247 -0
  261. package/src/backend/inputs/PhoneNumberField.tsx +129 -0
  262. package/src/backend/inputs/SwitchableMarkdownInput.tsx +128 -0
  263. package/src/backend/inputs/TagsInput.tsx +259 -0
  264. package/src/backend/inputs/index.ts +5 -0
  265. package/src/backend/operations/LastOperationBanner.tsx +85 -0
  266. package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +99 -0
  267. package/src/backend/operations/store.ts +230 -0
  268. package/src/backend/schedule/ScheduleAgenda.tsx +136 -0
  269. package/src/backend/schedule/ScheduleGrid.tsx +136 -0
  270. package/src/backend/schedule/ScheduleToolbar.tsx +178 -0
  271. package/src/backend/schedule/ScheduleView.tsx +198 -0
  272. package/src/backend/schedule/index.ts +5 -0
  273. package/src/backend/schedule/recurrence.ts +99 -0
  274. package/src/backend/schedule/types.ts +26 -0
  275. package/src/backend/upgrades/UpgradeActionBanner.tsx +128 -0
  276. package/src/backend/utils/__tests__/apiCall.test.ts +109 -0
  277. package/src/backend/utils/__tests__/crud.test.ts +87 -0
  278. package/src/backend/utils/__tests__/customFieldDefs.test.ts +25 -0
  279. package/src/backend/utils/__tests__/customFieldValues.test.ts +35 -0
  280. package/src/backend/utils/api.ts +149 -0
  281. package/src/backend/utils/apiCall.ts +96 -0
  282. package/src/backend/utils/crud.ts +174 -0
  283. package/src/backend/utils/customFieldColumns.ts +71 -0
  284. package/src/backend/utils/customFieldDefs.ts +245 -0
  285. package/src/backend/utils/customFieldFilters.ts +145 -0
  286. package/src/backend/utils/customFieldForms.ts +196 -0
  287. package/src/backend/utils/customFieldValues.ts +41 -0
  288. package/src/backend/utils/flash.ts +17 -0
  289. package/src/backend/utils/nav.ts +238 -0
  290. package/src/backend/utils/serverErrors.ts +302 -0
  291. package/src/frontend/AuthFooter.tsx +29 -0
  292. package/src/frontend/LanguageSwitcher.tsx +66 -0
  293. package/src/frontend/Layout.tsx +13 -0
  294. package/src/index.ts +32 -0
  295. package/src/primitives/DataLoader.tsx +92 -0
  296. package/src/primitives/ErrorNotice.tsx +26 -0
  297. package/src/primitives/alert.tsx +52 -0
  298. package/src/primitives/badge.tsx +31 -0
  299. package/src/primitives/button.tsx +47 -0
  300. package/src/primitives/card.tsx +92 -0
  301. package/src/primitives/checkbox.tsx +28 -0
  302. package/src/primitives/dialog.tsx +110 -0
  303. package/src/primitives/input.tsx +20 -0
  304. package/src/primitives/label.tsx +18 -0
  305. package/src/primitives/separator.tsx +7 -0
  306. package/src/primitives/spinner.tsx +27 -0
  307. package/src/primitives/switch.tsx +86 -0
  308. package/src/primitives/table.tsx +27 -0
  309. package/src/primitives/tabs.tsx +128 -0
  310. package/src/primitives/textarea.tsx +20 -0
  311. package/src/primitives/tooltip.tsx +85 -0
  312. package/src/theme/QueryProvider.tsx +46 -0
  313. package/src/theme/ThemeProvider.tsx +120 -0
  314. package/src/theme/ThemeToggle.tsx +88 -0
  315. package/src/theme/index.ts +3 -0
  316. package/src/types/react-big-calendar.d.ts +16 -0
  317. package/tsconfig.build.json +11 -0
  318. package/tsconfig.json +9 -0
  319. package/watch.mjs +6 -0
@@ -0,0 +1,2503 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import Link from 'next/link'
4
+ import { z } from 'zod'
5
+ import { useRouter } from 'next/navigation'
6
+ import { Button } from '../primitives/button'
7
+ import { DataLoader } from '../primitives/DataLoader'
8
+ import { flash } from './FlashMessages'
9
+ import dynamic from 'next/dynamic'
10
+ import remarkGfm from 'remark-gfm'
11
+ import {
12
+ Trash2,
13
+ Save,
14
+ Settings,
15
+ Layers,
16
+ Tag,
17
+ Sparkles,
18
+ Package,
19
+ Shirt,
20
+ Grid,
21
+ ShoppingBag,
22
+ ShoppingCart,
23
+ Store,
24
+ Users,
25
+ Briefcase,
26
+ Building,
27
+ BookOpen,
28
+ Bookmark,
29
+ Camera,
30
+ Car,
31
+ Clock,
32
+ Cloud,
33
+ Compass,
34
+ CreditCard,
35
+ Database,
36
+ Flame,
37
+ Gift,
38
+ Globe,
39
+ Heart,
40
+ Key,
41
+ Map as MapIcon,
42
+ Palette,
43
+ Shield,
44
+ Star,
45
+ Truck,
46
+ Zap,
47
+ Coins,
48
+ } from 'lucide-react'
49
+ import { loadGeneratedFieldRegistrations } from './fields/registry'
50
+ import type { CustomFieldDefDto, CustomFieldDefinitionsPayload, CustomFieldsetDto } from './utils/customFieldDefs'
51
+ import { buildFormFieldsFromCustomFields, buildFormFieldFromCustomFieldDef } from './utils/customFieldForms'
52
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
53
+ import { TagsInput } from './inputs/TagsInput'
54
+ import { ComboboxInput } from './inputs/ComboboxInput'
55
+ import { mapCrudServerErrorToFormErrors, parseServerMessage } from './utils/serverErrors'
56
+ import type { CustomFieldDefLike } from '@open-mercato/shared/modules/entities/validation'
57
+ import type { MDEditorProps as UiWMDEditorProps } from '@uiw/react-md-editor'
58
+ import { Dialog, DialogContent, DialogHeader, DialogTitle } from '../primitives/dialog'
59
+ import { FieldDefinitionsManager, type FieldDefinitionsManagerHandle } from './custom-fields/FieldDefinitionsManager'
60
+ import { useInjectionSpotEvents, InjectionSpot, useInjectionWidgets } from './injection/InjectionSpot'
61
+
62
+ // Stable empty options array to avoid creating a new [] every render
63
+ const EMPTY_OPTIONS: CrudFieldOption[] = []
64
+ const FOCUSABLE_SELECTOR =
65
+ '[data-crud-focus-target], input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled]), [tabindex]:not([tabindex="-1"])'
66
+
67
+ export type CrudFieldBase = {
68
+ id: string
69
+ label: string
70
+ placeholder?: string
71
+ description?: string // inline field-level help
72
+ required?: boolean
73
+ layout?: 'full' | 'half' | 'third'
74
+ disabled?: boolean
75
+ }
76
+
77
+ export type CrudFieldOption = { value: string; label: string }
78
+
79
+ export type CrudBuiltinField = CrudFieldBase & {
80
+ type:
81
+ | 'text'
82
+ | 'textarea'
83
+ | 'checkbox'
84
+ | 'select'
85
+ | 'number'
86
+ | 'date'
87
+ | 'datetime-local'
88
+ | 'tags'
89
+ | 'richtext'
90
+ | 'relation'
91
+ | 'combobox'
92
+ placeholder?: string
93
+ options?: CrudFieldOption[]
94
+ multiple?: boolean
95
+ listbox?: boolean
96
+ // for relation/select style fields; if provided, options are loaded on mount
97
+ loadOptions?: (query?: string) => Promise<CrudFieldOption[]>
98
+ // when type === 'richtext', choose editor implementation
99
+ editor?: 'simple' | 'uiw' | 'html'
100
+ // for text fields; provides datalist suggestions while allowing free-text input
101
+ suggestions?: string[]
102
+ // for combobox fields; allow custom values or restrict to suggestions only
103
+ allowCustomValues?: boolean
104
+ }
105
+
106
+ export type CrudCustomFieldRenderProps = {
107
+ id: string
108
+ value: unknown
109
+ error?: string
110
+ autoFocus?: boolean
111
+ disabled?: boolean
112
+ values?: Record<string, unknown>
113
+ setValue: (value: unknown) => void
114
+ // Optional helper to update other form values from within a custom field
115
+ setFormValue?: (id: string, value: unknown) => void
116
+ // Optional context for advanced custom inputs
117
+ entityId?: string
118
+ recordId?: string
119
+ }
120
+
121
+ export type CrudCustomField = CrudFieldBase & {
122
+ type: 'custom'
123
+ component: (props: CrudCustomFieldRenderProps) => React.ReactNode
124
+ }
125
+
126
+ export type CrudField = CrudBuiltinField | CrudCustomField
127
+
128
+ type CrudFormValues<TValues extends Record<string, unknown>> = Partial<TValues> & Record<string, unknown>
129
+
130
+ export type CrudFormProps<TValues extends Record<string, unknown>> = {
131
+ schema?: z.ZodType<TValues>
132
+ fields: CrudField[]
133
+ initialValues?: Partial<TValues>
134
+ submitLabel?: string
135
+ customFieldsLoadingMessage?: string
136
+ cancelHref?: string
137
+ successRedirect?: string
138
+ deleteRedirect?: string
139
+ onSubmit?: (values: TValues) => Promise<void> | void
140
+ onDelete?: () => Promise<void> | void
141
+ // When true, shows Delete button whenever onDelete is provided, even without an id
142
+ deleteVisible?: boolean
143
+ // Legacy field-only grid toggle. Use `groups` for advanced layout.
144
+ twoColumn?: boolean
145
+ title?: string
146
+ backHref?: string
147
+ // Optional extra action buttons rendered next to Delete/Cancel/Save
148
+ // Useful for custom links like "Show Records" etc.
149
+ extraActions?: React.ReactNode
150
+ // When provided, CrudForm will fetch custom field definitions and append
151
+ // form-editable custom fields automatically to the provided `fields`.
152
+ entityId?: string
153
+ entityIds?: string[]
154
+ // Optional grouped layout rendered in two responsive columns (1 on mobile).
155
+ groups?: CrudFormGroup[]
156
+ // Loading state for the entire form (e.g., when loading record data)
157
+ isLoading?: boolean
158
+ loadingMessage?: string
159
+ // User-defined entity mode: all fields are custom, use bare keys (no cf_)
160
+ customEntity?: boolean
161
+ // Embedded mode hides outer chrome; useful for inline sections
162
+ embedded?: boolean
163
+ // Optional custom content injected between the header actions and the form body
164
+ contentHeader?: React.ReactNode
165
+ // Optional mapping of entityId -> form value key storing the selected fieldset code
166
+ customFieldsetBindings?: Record<string, { valueKey: string }>
167
+ // Optional injection spot ID for widget injection
168
+ injectionSpotId?: string
169
+ }
170
+
171
+ // Group-level custom component context
172
+ export type CrudFormGroupComponentProps = {
173
+ values: Record<string, unknown>
174
+ setValue: (id: string, v: unknown) => void
175
+ errors: Record<string, string>
176
+ }
177
+
178
+ // Special group kind for automatic Custom Fields section
179
+ export type CrudFormGroup = {
180
+ id: string
181
+ title?: string
182
+ column?: 1 | 2
183
+ description?: string
184
+ // Either list field ids, inline field configs, or mix of both
185
+ fields?: (CrudField | string)[]
186
+ // Inject a custom component into the group card
187
+ component?: (ctx: CrudFormGroupComponentProps) => React.ReactNode
188
+ // When kind === 'customFields', the group renders form-editable custom fields
189
+ kind?: 'customFields'
190
+ // When true, render component output inline without wrapping group chrome
191
+ bare?: boolean
192
+ }
193
+
194
+ const FIELDSET_ICON_COMPONENTS: Record<string, React.ComponentType<{ className?: string }>> = {
195
+ layers: Layers,
196
+ tag: Tag,
197
+ sparkles: Sparkles,
198
+ package: Package,
199
+ shirt: Shirt,
200
+ grid: Grid,
201
+ shoppingBag: ShoppingBag,
202
+ shoppingCart: ShoppingCart,
203
+ store: Store,
204
+ users: Users,
205
+ briefcase: Briefcase,
206
+ building: Building,
207
+ bookOpen: BookOpen,
208
+ bookmark: Bookmark,
209
+ camera: Camera,
210
+ car: Car,
211
+ clock: Clock,
212
+ cloud: Cloud,
213
+ compass: Compass,
214
+ creditCard: CreditCard,
215
+ database: Database,
216
+ flame: Flame,
217
+ gift: Gift,
218
+ globe: Globe,
219
+ heart: Heart,
220
+ key: Key,
221
+ map: MapIcon,
222
+ palette: Palette,
223
+ shield: Shield,
224
+ star: Star,
225
+ truck: Truck,
226
+ zap: Zap,
227
+ coins: Coins,
228
+ }
229
+
230
+ type CustomFieldGroupLayout = {
231
+ code: string | null
232
+ label?: string
233
+ hint?: string
234
+ fields: CrudField[]
235
+ }
236
+
237
+ type CustomFieldSectionLayout = {
238
+ entityId: string
239
+ fieldsetCode: string | null
240
+ fieldset?: CustomFieldsetDto
241
+ title: string
242
+ description?: string
243
+ groups: CustomFieldGroupLayout[]
244
+ }
245
+
246
+ type CustomFieldEntityLayout = {
247
+ entityId: string
248
+ sections: CustomFieldSectionLayout[]
249
+ availableFieldsets: CustomFieldsetDto[]
250
+ singleFieldsetPerRecord: boolean
251
+ hasFieldsets: boolean
252
+ activeFieldset: string | null
253
+ }
254
+
255
+ export function CrudForm<TValues extends Record<string, unknown>>({
256
+ schema,
257
+ fields,
258
+ initialValues,
259
+ submitLabel,
260
+ customFieldsLoadingMessage,
261
+ cancelHref,
262
+ successRedirect,
263
+ deleteRedirect,
264
+ onSubmit,
265
+ onDelete,
266
+ deleteVisible,
267
+ twoColumn = false,
268
+ title,
269
+ backHref,
270
+ entityId,
271
+ entityIds,
272
+ groups,
273
+ isLoading = false,
274
+ loadingMessage,
275
+ customEntity = false,
276
+ embedded = false,
277
+ extraActions,
278
+ contentHeader,
279
+ customFieldsetBindings,
280
+ injectionSpotId,
281
+ }: CrudFormProps<TValues>) {
282
+ // Ensure module field components are registered (client-side)
283
+ React.useEffect(() => { loadGeneratedFieldRegistrations().catch(() => {}) }, [])
284
+ const router = useRouter()
285
+ const t = useT()
286
+ const resolvedSubmitLabel = submitLabel ?? t('ui.forms.actions.save')
287
+ const resolvedLoadingMessage = loadingMessage ?? t('ui.forms.loading')
288
+ const resolvedCustomFieldsLoadingMessage = customFieldsLoadingMessage ?? resolvedLoadingMessage
289
+ const cancelLabel = t('ui.forms.actions.cancel')
290
+ const deleteLabel = t('ui.forms.actions.delete')
291
+ const savingLabel = t('ui.forms.status.saving')
292
+ const backLabel = t('ui.navigation.back')
293
+ const customFieldsLabel = t('entities.customFields.title')
294
+ const fieldsetSelectorLabel = t('entities.customFields.fieldsetSelectorLabel', 'Fieldset')
295
+ const emptyFieldsetMessage = t('entities.customFields.emptyFieldset', 'No fields defined for this fieldset.')
296
+ const defaultFieldsetLabel = t('entities.customFields.defaultFieldset', 'Default')
297
+ const manageFieldsetLabel = t('entities.customFields.manageFieldset', 'Manage fields')
298
+ const fieldsetDialogTitle = t('entities.customFields.manageDialogTitle', 'Edit custom fields')
299
+ const fieldsetDialogUnavailable = t('entities.customFields.manageDialogUnavailable', 'Field definitions page is unavailable.')
300
+ const deleteConfirmMessage = t('ui.forms.confirmDelete')
301
+ const deleteSuccessMessage = t('ui.forms.flash.deleteSuccess')
302
+ const deleteErrorMessage = t('ui.forms.flash.deleteError')
303
+ const saveErrorMessage = t('ui.forms.flash.saveError')
304
+ const formId = React.useId()
305
+ const [values, setValues] = React.useState<CrudFormValues<TValues>>(
306
+ () => ({ ...(initialValues ?? {}) } as CrudFormValues<TValues>)
307
+ )
308
+ const [errors, setErrors] = React.useState<Record<string, string>>({})
309
+ const [pending, setPending] = React.useState(false)
310
+ const [formError, setFormError] = React.useState<string | null>(null)
311
+ const [dynamicOptions, setDynamicOptions] = React.useState<Record<string, CrudFieldOption[]>>({})
312
+ const [cfDefinitions, setCfDefinitions] = React.useState<CustomFieldDefDto[]>([])
313
+ const [cfMetadata, setCfMetadata] = React.useState<CustomFieldDefinitionsPayload | null>(null)
314
+ const [cfFieldsetSelections, setCfFieldsetSelections] = React.useState<Record<string, string | null>>({})
315
+ const [isLoadingCustomFields, setIsLoadingCustomFields] = React.useState(false)
316
+ const [customFieldDefsVersion, setCustomFieldDefsVersion] = React.useState(0)
317
+ const [fieldsetEditorTarget, setFieldsetEditorTarget] = React.useState<{ entityId: string; fieldsetCode: string | null; view: 'entity' | 'fieldset' } | null>(null)
318
+ const [isInDialog, setIsInDialog] = React.useState(false)
319
+ const rootRef = React.useRef<HTMLDivElement | null>(null)
320
+ const fieldsetManagerRef = React.useRef<FieldDefinitionsManagerHandle | null>(null)
321
+ const resolvedEntityIds = React.useMemo(() => {
322
+ if (Array.isArray(entityIds) && entityIds.length) {
323
+ const dedup = new Set<string>()
324
+ const list: string[] = []
325
+ entityIds.forEach((id) => {
326
+ const trimmed = typeof id === 'string' ? id.trim() : ''
327
+ if (!trimmed || dedup.has(trimmed)) return
328
+ dedup.add(trimmed)
329
+ list.push(trimmed)
330
+ })
331
+ return list
332
+ }
333
+ if (typeof entityId === 'string' && entityId.trim().length > 0) {
334
+ return [entityId.trim()]
335
+ }
336
+ return []
337
+ }, [entityId, entityIds])
338
+ const primaryEntityId = resolvedEntityIds.length ? resolvedEntityIds[0] : null
339
+
340
+ // Injection spot events for widget lifecycle management
341
+ const resolvedInjectionSpotId = React.useMemo(() => {
342
+ if (injectionSpotId) return injectionSpotId
343
+ if (resolvedEntityIds.length) {
344
+ const normalized = resolvedEntityIds[0].replace(/[:]+/g, '.')
345
+ return `crud-form:${normalized}`
346
+ }
347
+ return undefined
348
+ }, [injectionSpotId, resolvedEntityIds])
349
+
350
+ const injectionContext = React.useMemo(() => ({
351
+ formId,
352
+ entityId: primaryEntityId,
353
+ isLoading,
354
+ pending,
355
+ }), [formId, primaryEntityId, isLoading, pending])
356
+
357
+ const { widgets: injectionWidgets } = useInjectionWidgets(resolvedInjectionSpotId, {
358
+ context: injectionContext,
359
+ triggerOnLoad: true,
360
+ })
361
+
362
+ const { triggerEvent: triggerInjectionEvent } = useInjectionSpotEvents(resolvedInjectionSpotId ?? '', injectionWidgets)
363
+
364
+ React.useEffect(() => {
365
+ const root = rootRef.current
366
+ if (!root) return
367
+ setIsInDialog(Boolean(root.closest('[data-dialog-content]')))
368
+ }, [])
369
+ const dialogFooterClass = isInDialog
370
+ ? 'sticky bottom-0 left-0 right-0 z-20 -mx-6 px-6 bg-card border-t border-border/70 py-2 sm:-mx-6 sm:px-6'
371
+ : ''
372
+ const dialogFormPadding = isInDialog ? 'pb-4' : ''
373
+
374
+ const buildCustomFieldsManageHref = React.useCallback(
375
+ (targetEntityId: string | null) => {
376
+ if (!targetEntityId) return null
377
+ try {
378
+ const encoded = encodeURIComponent(targetEntityId)
379
+ return customEntity ? `/backend/entities/user/${encoded}` : `/backend/entities/system/${encoded}`
380
+ } catch {
381
+ return null
382
+ }
383
+ },
384
+ [customEntity],
385
+ )
386
+
387
+ const refreshCustomFieldDefinitions = React.useCallback(() => {
388
+ setCustomFieldDefsVersion((prev) => prev + 1)
389
+ }, [])
390
+
391
+ const recordId = React.useMemo(() => {
392
+ const raw = values.id
393
+ if (typeof raw === 'string') return raw
394
+ if (typeof raw === 'number') return String(raw)
395
+ return undefined
396
+ }, [values])
397
+ // Unified delete handler with confirmation
398
+ const handleDelete = React.useCallback(async () => {
399
+ if (!onDelete) return
400
+ try {
401
+ const ok = typeof window !== 'undefined' ? window.confirm(deleteConfirmMessage) : true
402
+ if (!ok) return
403
+ await onDelete()
404
+ try { flash(deleteSuccessMessage, 'success') } catch {}
405
+ // Redirect if requested by caller
406
+ if (typeof deleteRedirect === 'string' && deleteRedirect) {
407
+ router.push(deleteRedirect)
408
+ }
409
+ } catch (err) {
410
+ const message = err instanceof Error && err.message ? err.message : deleteErrorMessage
411
+ try { flash(message, 'error') } catch {}
412
+ }
413
+ }, [onDelete, deleteRedirect, router, deleteConfirmMessage, deleteSuccessMessage, deleteErrorMessage])
414
+
415
+ // Determine whether this form is creating a new record (no `id` yet)
416
+ const isNewRecord = React.useMemo(() => {
417
+ const rawId = values.id
418
+ if (rawId === undefined || rawId === null) return true
419
+ return typeof rawId === 'string' ? rawId.trim().length === 0 : false
420
+ }, [values])
421
+ const showDelete = Boolean(onDelete) && (typeof deleteVisible === 'boolean' ? deleteVisible : !isNewRecord)
422
+
423
+ // Auto-append custom fields for this entityId
424
+ React.useEffect(() => {
425
+ let cancelled = false
426
+ async function load() {
427
+ if (!resolvedEntityIds.length) {
428
+ setCfDefinitions([])
429
+ setCfMetadata(null)
430
+ setIsLoadingCustomFields(false)
431
+ return
432
+ }
433
+
434
+ setIsLoadingCustomFields(true)
435
+ try {
436
+ const mod = await import('./utils/customFieldForms')
437
+ const { definitions, metadata } = await mod.fetchCustomFieldFormStructure(resolvedEntityIds, undefined, { bareIds: customEntity })
438
+ if (!cancelled) {
439
+ setCfDefinitions(definitions)
440
+ setCfMetadata(metadata)
441
+ setCfFieldsetSelections((prev) => {
442
+ const next: Record<string, string | null> = {}
443
+ let changed = false
444
+ resolvedEntityIds.forEach((entityId) => {
445
+ const existing = prev[entityId]
446
+ const fieldsets = metadata.fieldsetsByEntity?.[entityId] ?? []
447
+ const defaultSelection = fieldsets[0]?.code ?? null
448
+ const value = existing !== undefined ? existing : defaultSelection
449
+ next[entityId] = value
450
+ if (existing !== value) changed = true
451
+ })
452
+ if (Object.keys(prev).length !== Object.keys(next).length) changed = true
453
+ return changed ? next : prev
454
+ })
455
+ setIsLoadingCustomFields(false)
456
+ }
457
+ } catch {
458
+ if (!cancelled) {
459
+ setCfDefinitions([])
460
+ setCfMetadata(null)
461
+ setIsLoadingCustomFields(false)
462
+ }
463
+ }
464
+ }
465
+ load()
466
+ return () => { cancelled = true }
467
+ }, [resolvedEntityIds, customEntity, customFieldDefsVersion])
468
+
469
+ React.useEffect(() => {
470
+ if (!customFieldsetBindings) return
471
+ setCfFieldsetSelections((prev) => {
472
+ let changed = false
473
+ const next = { ...prev }
474
+ resolvedEntityIds.forEach((entityId) => {
475
+ const binding = customFieldsetBindings[entityId]
476
+ if (!binding) return
477
+ const raw = values[binding.valueKey]
478
+ if (typeof raw === 'string' && raw.trim().length > 0) {
479
+ const normalized = raw.trim()
480
+ if (next[entityId] !== normalized) {
481
+ next[entityId] = normalized
482
+ changed = true
483
+ }
484
+ }
485
+ })
486
+ return changed ? next : prev
487
+ })
488
+ }, [customFieldsetBindings, resolvedEntityIds, values])
489
+
490
+ const fieldsetsByEntity = cfMetadata?.fieldsetsByEntity ?? {}
491
+ const entitySettings = cfMetadata?.entitySettings ?? {}
492
+
493
+ const { cfFields, customFieldLayout } = React.useMemo(() => {
494
+ if (!cfDefinitions.length) return { cfFields: [], customFieldLayout: [] as CustomFieldEntityLayout[] }
495
+ const aggregated: CrudField[] = []
496
+ const layout: CustomFieldEntityLayout[] = []
497
+ const defsByEntity = new globalThis.Map<string, CustomFieldDefDto[]>()
498
+ cfDefinitions.forEach((def) => {
499
+ const entityId = typeof def.entityId === 'string' && def.entityId.trim().length
500
+ ? def.entityId.trim()
501
+ : resolvedEntityIds[0]
502
+ if (!entityId) return
503
+ const bucket = defsByEntity.get(entityId) ?? []
504
+ bucket.push(def)
505
+ defsByEntity.set(entityId, bucket)
506
+ })
507
+
508
+ const buildSection = (
509
+ entityId: string,
510
+ fieldsetCode: string | null,
511
+ defList: CustomFieldDefDto[],
512
+ fieldset?: CustomFieldsetDto,
513
+ ): CustomFieldSectionLayout | null => {
514
+ if (!defList.length) return null
515
+ const groupsMap = new globalThis.Map<string, CustomFieldGroupLayout>()
516
+ const order: string[] = []
517
+ const fieldsetGroupMap = new globalThis.Map<string, { title?: string; hint?: string; code: string }>()
518
+ if (Array.isArray(fieldset?.groups)) {
519
+ fieldset.groups.forEach((group) => {
520
+ if (!group?.code) return
521
+ fieldsetGroupMap.set(group.code, { code: group.code, title: group.title, hint: group.hint })
522
+ })
523
+ }
524
+ const sortedDefs = [...defList].sort((a, b) => (a.priority ?? 0) - (b.priority ?? 0))
525
+ const ensureBucket = (code: string | null, def: CustomFieldDefDto): CustomFieldGroupLayout => {
526
+ const key = code ?? '__default__'
527
+ let bucket = groupsMap.get(key)
528
+ if (!bucket) {
529
+ const fallbackMeta = code ? fieldsetGroupMap.get(code) : undefined
530
+ const directMeta = code ? def.group : undefined
531
+ const label =
532
+ code === null
533
+ ? undefined
534
+ : directMeta?.title || fallbackMeta?.title || directMeta?.code || fallbackMeta?.code || code
535
+ const hint = directMeta?.hint || fallbackMeta?.hint
536
+ bucket = { code, label, hint, fields: [] }
537
+ groupsMap.set(key, bucket)
538
+ order.push(key)
539
+ } else if (code && !bucket.label) {
540
+ const fallbackMeta = fieldsetGroupMap.get(code)
541
+ const directMeta = def.group ?? undefined
542
+ bucket.label = directMeta?.title || fallbackMeta?.title || directMeta?.code || fallbackMeta?.code || bucket.label
543
+ bucket.hint = directMeta?.hint || fallbackMeta?.hint || bucket.hint
544
+ }
545
+ return bucket
546
+ }
547
+ sortedDefs.forEach((definition) => {
548
+ const field = buildFormFieldFromCustomFieldDef(definition, { bareIds: customEntity })
549
+ if (!field) return
550
+ aggregated.push(field)
551
+ const bucket = ensureBucket(definition.group?.code ?? null, definition)
552
+ bucket.fields.push(field)
553
+ })
554
+ const groups = order
555
+ .map((key) => groupsMap.get(key)!)
556
+ .filter((group) => group.fields.length > 0)
557
+ if (!groups.length) return null
558
+ return {
559
+ entityId,
560
+ fieldsetCode,
561
+ fieldset,
562
+ title: fieldset?.label ?? customFieldsLabel,
563
+ description: fieldset?.description,
564
+ groups,
565
+ }
566
+ }
567
+
568
+ const entityIds = resolvedEntityIds.length ? resolvedEntityIds : Array.from(defsByEntity.keys())
569
+ entityIds.forEach((entityId) => {
570
+ const defsForEntity = defsByEntity.get(entityId) ?? []
571
+ if (!defsForEntity.length) return
572
+ const availableFieldsets = fieldsetsByEntity[entityId] ?? []
573
+ const hasFieldsets = availableFieldsets.length > 0
574
+ const singleFieldsetPerRecord =
575
+ entitySettings[entityId]?.singleFieldsetPerRecord !== false
576
+ const defsByFieldset = new globalThis.Map<string | null, CustomFieldDefDto[]>()
577
+ defsForEntity.forEach((def) => {
578
+ const code = typeof def.fieldset === 'string' && def.fieldset.trim().length > 0 ? def.fieldset.trim() : null
579
+ const bucket = defsByFieldset.get(code) ?? []
580
+ bucket.push(def)
581
+ defsByFieldset.set(code, bucket)
582
+ })
583
+ const sections: CustomFieldSectionLayout[] = []
584
+
585
+ const createEmptySection = (code: string | null): CustomFieldSectionLayout => {
586
+ const fieldset = code ? availableFieldsets.find((fs) => fs.code === code) : undefined
587
+ return {
588
+ entityId,
589
+ fieldsetCode: code,
590
+ fieldset,
591
+ title: fieldset?.label ?? customFieldsLabel,
592
+ description: fieldset?.description,
593
+ groups: [],
594
+ }
595
+ }
596
+
597
+ if (!hasFieldsets) {
598
+ const fallbackDefs =
599
+ defsByFieldset.get(null) ?? Array.from(defsByFieldset.values()).flat()
600
+ const section = buildSection(entityId, null, fallbackDefs, undefined)
601
+ if (section) sections.push(section)
602
+ } else if (singleFieldsetPerRecord) {
603
+ const availableCodes = availableFieldsets.map((fs) => fs.code)
604
+ const activeFieldset =
605
+ cfFieldsetSelections[entityId] && availableCodes.includes(cfFieldsetSelections[entityId]!)
606
+ ? cfFieldsetSelections[entityId]
607
+ : availableFieldsets[0]?.code ?? null
608
+ const targetDefs = activeFieldset ? defsByFieldset.get(activeFieldset) ?? [] : defsByFieldset.get(null) ?? []
609
+ const targetSection = activeFieldset
610
+ ? buildSection(
611
+ entityId,
612
+ activeFieldset,
613
+ targetDefs,
614
+ availableFieldsets.find((fs) => fs.code === activeFieldset),
615
+ )
616
+ : buildSection(entityId, null, targetDefs, undefined)
617
+ if (targetSection) {
618
+ sections.push(targetSection)
619
+ } else if (activeFieldset) {
620
+ sections.push(createEmptySection(activeFieldset))
621
+ }
622
+ const unassigned = defsByFieldset.get(null)
623
+ if (unassigned?.length && activeFieldset) {
624
+ const generalSection = buildSection(entityId, null, unassigned, undefined)
625
+ if (generalSection) sections.push(generalSection)
626
+ }
627
+ } else {
628
+ availableFieldsets.forEach((fieldset) => {
629
+ const list = defsByFieldset.get(fieldset.code) ?? []
630
+ const section = buildSection(entityId, fieldset.code, list, fieldset)
631
+ if (section) sections.push(section)
632
+ })
633
+ const unassigned = defsByFieldset.get(null)
634
+ if (unassigned?.length) {
635
+ const section = buildSection(entityId, null, unassigned, undefined)
636
+ if (section) sections.push(section)
637
+ }
638
+ }
639
+
640
+ if (!sections.length && hasFieldsets) {
641
+ const fallbackCode = availableFieldsets[0]?.code ?? null
642
+ sections.push(createEmptySection(fallbackCode))
643
+ }
644
+
645
+ layout.push({
646
+ entityId,
647
+ sections,
648
+ availableFieldsets,
649
+ singleFieldsetPerRecord,
650
+ hasFieldsets,
651
+ activeFieldset: cfFieldsetSelections[entityId] ?? availableFieldsets[0]?.code ?? null,
652
+ })
653
+ })
654
+
655
+ return { cfFields: aggregated, customFieldLayout: layout }
656
+ }, [
657
+ cfDefinitions,
658
+ cfFieldsetSelections,
659
+ customEntity,
660
+ customFieldsLabel,
661
+ entitySettings,
662
+ fieldsetsByEntity,
663
+ resolvedEntityIds,
664
+ ])
665
+
666
+ const allFields = React.useMemo(() => {
667
+ if (!cfFields.length) return fields
668
+ const provided = new Set(fields.map(f => f.id))
669
+ const extras = cfFields.filter(f => !provided.has(f.id))
670
+ return [...fields, ...extras]
671
+ }, [fields, cfFields])
672
+
673
+ const fieldById = React.useMemo(() => {
674
+ return new globalThis.Map(allFields.map((f) => [f.id, f]))
675
+ }, [allFields])
676
+
677
+ const injectionGroupCards = React.useMemo<CrudFormGroup[]>(() => {
678
+ if (!injectionWidgets || injectionWidgets.length === 0) return []
679
+ const pairs = injectionWidgets
680
+ .filter((widget) => (widget.placement?.kind ?? 'stack') === 'group')
681
+ .map((widget) => {
682
+ const priority = typeof widget.placement?.priority === 'number' ? widget.placement.priority : 0
683
+ const group: CrudFormGroup = {
684
+ id: `widget:${widget.widgetId}`,
685
+ title: widget.placement?.groupLabel ?? widget.module.metadata.title,
686
+ description: widget.placement?.groupDescription ?? widget.module.metadata.description,
687
+ column: widget.placement?.column === 2 ? 2 : 1,
688
+ component: () => (
689
+ <widget.module.Widget
690
+ context={injectionContext}
691
+ data={values as unknown as CrudFormValues<TValues>}
692
+ onDataChange={(next) => setValues(next as CrudFormValues<TValues>)}
693
+ disabled={pending}
694
+ />
695
+ ),
696
+ }
697
+ return { group, priority }
698
+ })
699
+ pairs.sort((a, b) => b.priority - a.priority)
700
+ return pairs.map((p) => p.group)
701
+ }, [injectionWidgets, injectionContext, pending, setValues, values])
702
+
703
+ const shouldAutoGroup = (!groups || groups.length === 0) && injectionGroupCards.length > 0
704
+ const resolvedGroupsForLayout = React.useMemo(() => {
705
+ const baseGroups = groups && groups.length ? groups : []
706
+ const autoGroup = shouldAutoGroup ? [{ id: '__auto-fields__', fields: allFields }] as CrudFormGroup[] : []
707
+ return [...(baseGroups.length ? baseGroups : autoGroup), ...injectionGroupCards]
708
+ }, [allFields, groups, injectionGroupCards, shouldAutoGroup])
709
+ const useGroupedLayout = resolvedGroupsForLayout.length > 0
710
+ const stackedInjectionWidgets = React.useMemo(
711
+ () => (injectionWidgets ?? []).filter((widget) => (widget.placement?.kind ?? 'stack') === 'stack'),
712
+ [injectionWidgets],
713
+ )
714
+
715
+ const resolveGroupFields = React.useCallback((g: CrudFormGroup): CrudField[] => {
716
+ if (g.kind === 'customFields') {
717
+ return cfFields
718
+ }
719
+
720
+ const src = g.fields || []
721
+ const result: CrudField[] = []
722
+
723
+ for (const item of src) {
724
+ if (typeof item === 'string') {
725
+ const found = fieldById.get(item)
726
+ if (found) result.push(found)
727
+ } else if (item) {
728
+ result.push(item as CrudField)
729
+ }
730
+ }
731
+
732
+ return result
733
+ }, [cfFields, fieldById])
734
+
735
+ const customFieldsManageHref = React.useMemo(() => buildCustomFieldsManageHref(primaryEntityId), [buildCustomFieldsManageHref, primaryEntityId])
736
+
737
+ const customFieldsEmptyState = React.useMemo(() => {
738
+ const text = t('entities.customFields.empty')
739
+ const action = t('entities.customFields.addFirst')
740
+ return (
741
+ <div className="rounded-md border border-dashed border-muted-foreground/50 bg-muted/10 px-3 py-4 text-sm text-muted-foreground">
742
+ <span>{text} </span>
743
+ {customFieldsManageHref ? (
744
+ <Link href={customFieldsManageHref} className="font-medium text-primary hover:underline">
745
+ {action}
746
+ </Link>
747
+ ) : (
748
+ <span className="font-medium text-foreground">{action}</span>
749
+ )}
750
+ </div>
751
+ )
752
+ }, [customFieldsManageHref, t])
753
+
754
+ const firstFieldId = React.useMemo(() => {
755
+ if (useGroupedLayout) {
756
+ const col1: CrudFormGroup[] = []
757
+ const col2: CrudFormGroup[] = []
758
+
759
+ for (const g of resolvedGroupsForLayout) {
760
+ if ((g.column ?? 1) === 2) col2.push(g)
761
+ else col1.push(g)
762
+ }
763
+
764
+ const scan = (list: CrudFormGroup[]) => {
765
+ for (const group of list) {
766
+ const resolved = resolveGroupFields(group)
767
+ for (const field of resolved) {
768
+ if (field?.id && !field.disabled) return field.id
769
+ }
770
+ }
771
+ return null as string | null
772
+ }
773
+
774
+ const fromCol1 = scan(col1)
775
+ if (fromCol1) return fromCol1
776
+ const fromCol2 = scan(col2)
777
+ if (fromCol2) return fromCol2
778
+ }
779
+
780
+ for (const field of allFields) {
781
+ if (field?.id && !field.disabled) return field.id
782
+ }
783
+ return null
784
+ }, [allFields, resolveGroupFields, resolvedGroupsForLayout, useGroupedLayout])
785
+
786
+ const requestSubmit = React.useCallback(() => {
787
+ if (typeof document === 'undefined') return
788
+ const form = document.getElementById(formId) as HTMLFormElement | null
789
+ form?.requestSubmit()
790
+ }, [formId])
791
+
792
+ const lastFocusedFieldRef = React.useRef<string | null>(null)
793
+ const lastErrorFieldRef = React.useRef<string | null>(null)
794
+
795
+ React.useEffect(() => {
796
+ if (typeof window === 'undefined' || typeof document === 'undefined') return
797
+
798
+ if (isLoading || isLoadingCustomFields) {
799
+ lastFocusedFieldRef.current = null
800
+ return
801
+ }
802
+
803
+ if (!firstFieldId) return
804
+ if (lastFocusedFieldRef.current === firstFieldId) return
805
+
806
+ const run = () => {
807
+ const form = document.getElementById(formId)
808
+ if (!form) return
809
+
810
+ // Do not steal focus if the user is already interacting with any element inside the form
811
+ const active = typeof document !== 'undefined' ? (document.activeElement as HTMLElement | null) : null
812
+ if (active && form.contains(active)) {
813
+ return
814
+ }
815
+
816
+ const container = form.querySelector<HTMLElement>(`[data-crud-field-id="${firstFieldId}"]`)
817
+ const target =
818
+ container?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ??
819
+ form.querySelector<HTMLElement>(FOCUSABLE_SELECTOR)
820
+
821
+ if (target && typeof target.focus === 'function') {
822
+ target.focus()
823
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
824
+ try {
825
+ target.select()
826
+ } catch {}
827
+ }
828
+ lastFocusedFieldRef.current = firstFieldId
829
+ }
830
+ }
831
+
832
+ const frame =
833
+ typeof window.requestAnimationFrame === 'function'
834
+ ? window.requestAnimationFrame(run)
835
+ : window.setTimeout(run, 0)
836
+
837
+ return () => {
838
+ if (typeof window === 'undefined') return
839
+ if (typeof window.cancelAnimationFrame === 'function') {
840
+ window.cancelAnimationFrame(frame as number)
841
+ } else {
842
+ window.clearTimeout(frame as number)
843
+ }
844
+ }
845
+ }, [firstFieldId, formId, isLoading, isLoadingCustomFields])
846
+
847
+ React.useEffect(() => {
848
+ if (typeof window === 'undefined' || typeof document === 'undefined') return
849
+ const entries = Object.entries(errors)
850
+ if (!entries.length) {
851
+ lastErrorFieldRef.current = null
852
+ return
853
+ }
854
+ const [fieldId] = entries[0]
855
+ if (!fieldId || lastErrorFieldRef.current === fieldId) return
856
+
857
+ const form = document.getElementById(formId)
858
+ if (!form) return
859
+ const container = form.querySelector<HTMLElement>(`[data-crud-field-id="${fieldId}"]`)
860
+ const target =
861
+ container?.querySelector<HTMLElement>(FOCUSABLE_SELECTOR) ??
862
+ form.querySelector<HTMLElement>(`[name="${fieldId}"]`) ??
863
+ container ??
864
+ null
865
+
866
+ if (target && typeof target.focus === 'function') {
867
+ target.focus()
868
+ if (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement) {
869
+ try {
870
+ target.select()
871
+ } catch {}
872
+ }
873
+ lastErrorFieldRef.current = fieldId
874
+ }
875
+ }, [errors, formId])
876
+
877
+ const setValue = React.useCallback((id: string, nextValue: unknown) => {
878
+ setValues((prev) => {
879
+ if (Object.is(prev[id], nextValue)) return prev
880
+ return { ...prev, [id]: nextValue } as CrudFormValues<TValues>
881
+ })
882
+ }, [])
883
+
884
+ const handleFieldsetSelectionChange = React.useCallback(
885
+ (entityId: string, nextCode: string | null) => {
886
+ setCfFieldsetSelections((prev) => ({ ...prev, [entityId]: nextCode }))
887
+ const bindingKey = customFieldsetBindings?.[entityId]?.valueKey
888
+ if (bindingKey) {
889
+ setValue(bindingKey, nextCode ?? undefined)
890
+ }
891
+ },
892
+ [customFieldsetBindings, setValue],
893
+ )
894
+
895
+ const handleOpenFieldsetEditor = React.useCallback(
896
+ (entityId: string, fieldsetCode: string | null, view: 'entity' | 'fieldset' = 'entity') => {
897
+ const href = buildCustomFieldsManageHref(entityId)
898
+ if (!href) return
899
+ setFieldsetEditorTarget({ entityId, fieldsetCode, view })
900
+ },
901
+ [buildCustomFieldsManageHref],
902
+ )
903
+
904
+ // Apply initialValues when provided (reapply when initialValues change for edit forms)
905
+ React.useEffect(() => {
906
+ if (!initialValues) return
907
+ setValues((prev) => ({ ...prev, ...initialValues } as CrudFormValues<TValues>))
908
+ }, [initialValues])
909
+
910
+ const buildFieldsetEditorHref = React.useCallback(
911
+ (includeViewParam: boolean) => {
912
+ if (!fieldsetEditorTarget) return null
913
+ const base = buildCustomFieldsManageHref(fieldsetEditorTarget.entityId)
914
+ if (!base) return null
915
+ const params: string[] = []
916
+ if (fieldsetEditorTarget.fieldsetCode) {
917
+ params.push(`fieldset=${encodeURIComponent(fieldsetEditorTarget.fieldsetCode)}`)
918
+ }
919
+ if (includeViewParam && fieldsetEditorTarget.view === 'fieldset') {
920
+ params.push('view=fieldset')
921
+ }
922
+ if (!params.length) return base
923
+ const connector = base.includes('?') ? '&' : '?'
924
+ return `${base}${connector}${params.join('&')}`
925
+ },
926
+ [buildCustomFieldsManageHref, fieldsetEditorTarget],
927
+ )
928
+
929
+ const fieldsetEditorFullHref = React.useMemo(() => buildFieldsetEditorHref(false), [buildFieldsetEditorHref])
930
+
931
+ const handleFieldsetDialogSave = React.useCallback(() => {
932
+ if (!fieldsetManagerRef.current) return
933
+ void fieldsetManagerRef.current.submit()
934
+ }, [])
935
+
936
+ const handleSubmit = async (e: React.FormEvent) => {
937
+ e.preventDefault()
938
+ setFormError(null)
939
+ setErrors({})
940
+
941
+ const requiredMessage = t('ui.forms.errors.required')
942
+ const highlightedMessage = t('ui.forms.errors.highlighted')
943
+
944
+ // Make sure inputs that commit on blur flush their local state before submit.
945
+ try {
946
+ if (typeof document !== 'undefined') {
947
+ const activeElement = document.activeElement
948
+ if (activeElement instanceof HTMLElement) {
949
+ activeElement.blur()
950
+ await new Promise<void>((resolve) => setTimeout(resolve, 0))
951
+ }
952
+ }
953
+ } catch {
954
+ // ignore focus cleanup errors
955
+ }
956
+
957
+ // Basic required-field validation when no zod schema is provided
958
+ const requiredErrors: Record<string, string> = {}
959
+ for (const field of allFields) {
960
+ if (!field.required) continue
961
+ if (field.disabled) continue
962
+ const v = values[field.id]
963
+ const isArray = Array.isArray(v)
964
+ const isString = typeof v === 'string'
965
+ const empty =
966
+ v === undefined ||
967
+ v === null ||
968
+ (isString && v.trim() === '') ||
969
+ (isArray && v.length === 0) ||
970
+ (field.type === 'checkbox' && v !== true)
971
+ if (empty) requiredErrors[field.id] = requiredMessage
972
+ }
973
+ if (Object.keys(requiredErrors).length) {
974
+ if (process.env.NODE_ENV !== 'production') {
975
+ console.debug('[crud-form] Required field errors prevented submit', requiredErrors)
976
+ }
977
+ setErrors(requiredErrors)
978
+ flash(highlightedMessage, 'error')
979
+ return
980
+ }
981
+
982
+ // Custom fields validation via definitions (rules)
983
+ if (resolvedEntityIds.length) {
984
+ try {
985
+ const mod = await import('./utils/customFieldDefs')
986
+ const defs = await mod.fetchCustomFieldDefs(resolvedEntityIds)
987
+ const { validateValuesAgainstDefs } = await import('@open-mercato/shared/modules/entities/validation')
988
+ // Build values keyed by def.key for validation
989
+ const cfValues: Record<string, unknown> = {}
990
+ if (customEntity) {
991
+ for (const def of defs) {
992
+ if (Object.prototype.hasOwnProperty.call(values, def.key)) {
993
+ cfValues[def.key] = values[def.key]
994
+ }
995
+ }
996
+ } else {
997
+ for (const [k, v] of Object.entries(values)) {
998
+ if (k.startsWith('cf_')) cfValues[k.replace(/^cf_/, '')] = v
999
+ }
1000
+ }
1001
+ const defsForValidation = defs as unknown as CustomFieldDefLike[]
1002
+ const result = validateValuesAgainstDefs(cfValues, defsForValidation)
1003
+ if (!result.ok) {
1004
+ if (customEntity) {
1005
+ const mapped: Record<string, string> = {}
1006
+ for (const [ek, ev] of Object.entries(result.fieldErrors)) mapped[ek.replace(/^cf_/, '')] = String(ev)
1007
+ setErrors((prev) => ({ ...prev, ...mapped }))
1008
+ } else {
1009
+ setErrors((prev) => ({ ...prev, ...result.fieldErrors }))
1010
+ }
1011
+ flash(highlightedMessage, 'error')
1012
+ return
1013
+ }
1014
+ } catch {
1015
+ // ignore validation errors if helper not available
1016
+ }
1017
+ }
1018
+
1019
+ let parsedValues: TValues
1020
+ if (schema) {
1021
+ const res = schema.safeParse(values)
1022
+ if (!res.success) {
1023
+ const fieldErrors: Record<string, string> = {}
1024
+ res.error.issues.forEach((issue) => {
1025
+ if (issue.path && issue.path.length) fieldErrors[String(issue.path[0])] = issue.message
1026
+ })
1027
+ if (process.env.NODE_ENV !== 'production') {
1028
+ console.debug('[crud-form] Schema validation failed', res.error.issues)
1029
+ }
1030
+ setErrors(fieldErrors)
1031
+ flash(highlightedMessage, 'error')
1032
+ return
1033
+ }
1034
+ parsedValues = res.data
1035
+ } else {
1036
+ parsedValues = values as TValues
1037
+ }
1038
+
1039
+ // Trigger onBeforeSave event for injection widgets
1040
+ if (resolvedInjectionSpotId) {
1041
+ try {
1042
+ const result = await triggerInjectionEvent('onBeforeSave', parsedValues, injectionContext)
1043
+ if (!result.ok) {
1044
+ if (result.fieldErrors && Object.keys(result.fieldErrors).length) {
1045
+ setErrors(result.fieldErrors)
1046
+ }
1047
+ const message = result.message || t('ui.forms.flash.saveBlocked', 'Save blocked by validation')
1048
+ flash(message, 'error')
1049
+ setPending(false)
1050
+ return
1051
+ }
1052
+ } catch (err) {
1053
+ console.error('[CrudForm] Error in onBeforeSave:', err)
1054
+ flash(t('ui.forms.flash.saveBlocked', 'Save blocked by validation'), 'error')
1055
+ setPending(false)
1056
+ return
1057
+ }
1058
+ }
1059
+
1060
+ setPending(true)
1061
+
1062
+ // Trigger onSave event for injection widgets
1063
+ if (resolvedInjectionSpotId) {
1064
+ try {
1065
+ await triggerInjectionEvent('onSave', parsedValues, injectionContext)
1066
+ } catch (err) {
1067
+ console.error('[CrudForm] Error in onSave:', err)
1068
+ flash(t('ui.forms.flash.saveBlocked', 'Save blocked by validation'), 'error')
1069
+ setPending(false)
1070
+ return
1071
+ }
1072
+ }
1073
+
1074
+ try {
1075
+ await onSubmit?.(parsedValues)
1076
+
1077
+ // Trigger onAfterSave event for injection widgets
1078
+ if (resolvedInjectionSpotId) {
1079
+ try {
1080
+ await triggerInjectionEvent('onAfterSave', parsedValues, injectionContext)
1081
+ } catch (err) {
1082
+ console.error('[CrudForm] Error in onAfterSave:', err)
1083
+ }
1084
+ }
1085
+
1086
+ if (successRedirect) router.push(successRedirect)
1087
+ } catch (err: unknown) {
1088
+ const { message: helperMessage, fieldErrors: serverFieldErrors } = mapCrudServerErrorToFormErrors(err, { customEntity })
1089
+ const combinedFieldErrors = serverFieldErrors ?? {}
1090
+ const hasFieldErrors = Object.keys(combinedFieldErrors).length > 0
1091
+ const firstFieldMessage = hasFieldErrors
1092
+ ? (() => {
1093
+ const firstKey = Object.keys(combinedFieldErrors)[0]
1094
+ if (!firstKey) return null
1095
+ const value = combinedFieldErrors[firstKey]
1096
+ return typeof value === 'string' && value.trim().length ? value.trim() : null
1097
+ })()
1098
+ : null
1099
+ if (hasFieldErrors) {
1100
+ setErrors(combinedFieldErrors)
1101
+ if (process.env.NODE_ENV !== 'production') {
1102
+ console.debug('[crud-form] Submission failed with field errors', combinedFieldErrors)
1103
+ }
1104
+ }
1105
+
1106
+ let displayMessage = typeof helperMessage === 'string' && helperMessage.trim() ? helperMessage.trim() : ''
1107
+ if (hasFieldErrors) {
1108
+ const lowered = displayMessage.toLowerCase()
1109
+ const highlightedLower = highlightedMessage.toLowerCase()
1110
+ if (!displayMessage || lowered === 'invalid input' || lowered === highlightedLower) {
1111
+ displayMessage = firstFieldMessage ?? highlightedMessage
1112
+ }
1113
+ }
1114
+ if (!displayMessage && err instanceof Error && typeof err.message === 'string' && err.message.trim()) {
1115
+ displayMessage = err.message.trim()
1116
+ }
1117
+ if (!displayMessage) {
1118
+ displayMessage = hasFieldErrors ? highlightedMessage : saveErrorMessage
1119
+ }
1120
+ displayMessage = parseServerMessage(displayMessage)
1121
+ flash(displayMessage, 'error')
1122
+ setFormError(displayMessage)
1123
+ } finally {
1124
+ setPending(false)
1125
+ }
1126
+ }
1127
+ // Load dynamic options for fields that require it
1128
+ React.useEffect(() => {
1129
+ let cancelled = false
1130
+ const loadAll = async () => {
1131
+ const loaders = allFields
1132
+ .filter(
1133
+ (f): f is CrudBuiltinField & { loadOptions: NonNullable<CrudBuiltinField['loadOptions']> } =>
1134
+ f.type !== 'custom' && typeof f.loadOptions === 'function'
1135
+ )
1136
+ .map(async (f) => {
1137
+ try {
1138
+ const opts = await f.loadOptions()
1139
+ if (!cancelled) setDynamicOptions((prev) => ({ ...prev, [f.id]: opts }))
1140
+ } catch {
1141
+ // ignore
1142
+ }
1143
+ })
1144
+ await Promise.all(loaders)
1145
+ }
1146
+ loadAll()
1147
+ return () => {
1148
+ cancelled = true
1149
+ }
1150
+ }, [allFields])
1151
+
1152
+ const loadFieldOptions = React.useCallback(async (field: CrudField, query?: string): Promise<CrudFieldOption[]> => {
1153
+ if (!('type' in field) || field.type === 'custom') return EMPTY_OPTIONS
1154
+ const builtin = field as CrudBuiltinField
1155
+ const loader = builtin.loadOptions
1156
+ if (typeof loader === 'function') {
1157
+ if (query === undefined && Array.isArray(dynamicOptions[field.id])) return dynamicOptions[field.id]
1158
+ try {
1159
+ const fetched = await loader(query)
1160
+ if (query === undefined) {
1161
+ setDynamicOptions((prev) => ({
1162
+ ...prev,
1163
+ [field.id]: fetched,
1164
+ }))
1165
+ }
1166
+ return fetched
1167
+ } catch {
1168
+ return builtin.options ?? EMPTY_OPTIONS
1169
+ }
1170
+ }
1171
+ return dynamicOptions[field.id] || builtin.options || EMPTY_OPTIONS
1172
+ }, [dynamicOptions])
1173
+
1174
+ const fieldOptionsById = React.useMemo(() => {
1175
+ const map = new globalThis.Map<string, CrudFieldOption[]>()
1176
+ for (const f of allFields) {
1177
+ if (!('type' in f) || f.type === 'custom') continue
1178
+ const builtin = f as CrudBuiltinField
1179
+ const staticOptions = builtin.options ?? EMPTY_OPTIONS
1180
+ const dynamic = dynamicOptions[f.id]
1181
+ if (dynamic && dynamic.length) {
1182
+ const merged: CrudFieldOption[] = []
1183
+ const seen = new Set<string>()
1184
+ for (const opt of staticOptions) {
1185
+ if (seen.has(opt.value)) continue
1186
+ seen.add(opt.value)
1187
+ merged.push(opt)
1188
+ }
1189
+ for (const opt of dynamic) {
1190
+ if (seen.has(opt.value)) continue
1191
+ seen.add(opt.value)
1192
+ merged.push(opt)
1193
+ }
1194
+ map.set(f.id, merged)
1195
+ } else if (staticOptions.length) {
1196
+ map.set(f.id, staticOptions)
1197
+ } else if (dynamic) {
1198
+ map.set(f.id, dynamic)
1199
+ }
1200
+ }
1201
+ return map
1202
+ }, [allFields, dynamicOptions])
1203
+
1204
+ // no auto-focus; let the browser/user manage focus
1205
+
1206
+ const usesResponsiveLayout = allFields.some(
1207
+ (field) => field.layout === 'half' || field.layout === 'third'
1208
+ )
1209
+ const grid = twoColumn
1210
+ ? 'grid grid-cols-1 lg:grid-cols-[7fr_3fr] gap-4'
1211
+ : usesResponsiveLayout
1212
+ ? 'grid grid-cols-1 gap-4 md:grid-cols-6'
1213
+ : 'grid grid-cols-1 gap-4'
1214
+
1215
+ // Helper to render a list of field configs
1216
+ const resolveLayoutClass = (layout?: CrudFieldBase['layout']) => {
1217
+ switch (layout) {
1218
+ case 'half':
1219
+ return 'md:col-span-3'
1220
+ case 'third':
1221
+ return 'md:col-span-2'
1222
+ default:
1223
+ return 'md:col-span-6'
1224
+ }
1225
+ }
1226
+
1227
+ const renderFields = (fieldList: CrudField[]) => {
1228
+ const usesResponsive = fieldList.some(
1229
+ (field) => field.layout === 'half' || field.layout === 'third'
1230
+ )
1231
+ const gridClass = usesResponsive ? 'grid grid-cols-1 gap-4 md:grid-cols-6' : 'grid grid-cols-1 gap-4'
1232
+ return (
1233
+ <div className={gridClass}>
1234
+ {fieldList.map((f) => {
1235
+ const layout = f.layout ?? 'full'
1236
+ const wrapperClassName = usesResponsive ? resolveLayoutClass(layout) : undefined
1237
+ return (
1238
+ <FieldControl
1239
+ key={f.id}
1240
+ field={f}
1241
+ value={values[f.id]}
1242
+ error={errors[f.id]}
1243
+ options={fieldOptionsById.get(f.id) || EMPTY_OPTIONS}
1244
+ setValue={setValue}
1245
+ values={values}
1246
+ loadFieldOptions={loadFieldOptions}
1247
+ autoFocus={Boolean(firstFieldId && f.id === firstFieldId)}
1248
+ onSubmitRequest={requestSubmit}
1249
+ wrapperClassName={wrapperClassName}
1250
+ entityIdForField={primaryEntityId ?? undefined}
1251
+ recordId={recordId}
1252
+ />
1253
+ )
1254
+ })}
1255
+ </div>
1256
+ )
1257
+ }
1258
+
1259
+ const renderCustomFieldsContent = React.useCallback((): React.ReactNode[] => {
1260
+ if (!customFieldLayout.length) {
1261
+ return [
1262
+ <div key="custom-fields-empty" className="rounded-lg border bg-card p-4">
1263
+ {customFieldsEmptyState}
1264
+ </div>,
1265
+ ]
1266
+ }
1267
+
1268
+ const nodes: React.ReactNode[] = []
1269
+ const multipleEntities = customFieldLayout.length > 1
1270
+
1271
+ customFieldLayout.forEach((entityLayout) => {
1272
+ const manageHref = buildCustomFieldsManageHref(entityLayout.entityId)
1273
+ const showSelector =
1274
+ entityLayout.hasFieldsets &&
1275
+ entityLayout.singleFieldsetPerRecord &&
1276
+ entityLayout.availableFieldsets.length > 0
1277
+
1278
+ if (multipleEntities) {
1279
+ nodes.push(
1280
+ <div
1281
+ key={`custom-fields-entity-${entityLayout.entityId}`}
1282
+ className="px-1 text-xs font-semibold uppercase tracking-wide text-muted-foreground"
1283
+ >
1284
+ {entityLayout.entityId}
1285
+ </div>,
1286
+ )
1287
+ }
1288
+
1289
+ if (showSelector) {
1290
+ nodes.push(
1291
+ <div key={`custom-fields-selector-${entityLayout.entityId}`} className="rounded-lg border bg-card p-4">
1292
+ <div className="flex flex-wrap items-center gap-2 text-sm">
1293
+ <label className="text-xs uppercase tracking-wide text-muted-foreground">
1294
+ {fieldsetSelectorLabel}
1295
+ </label>
1296
+ <select
1297
+ className="h-9 rounded border px-2 text-sm"
1298
+ value={entityLayout.activeFieldset ?? ''}
1299
+ onChange={(event) =>
1300
+ handleFieldsetSelectionChange(
1301
+ entityLayout.entityId,
1302
+ event.target.value || null,
1303
+ )}
1304
+ >
1305
+ <option value="">{defaultFieldsetLabel}</option>
1306
+ {entityLayout.availableFieldsets.map((fs) => (
1307
+ <option key={fs.code} value={fs.code}>
1308
+ {fs.label}
1309
+ </option>
1310
+ ))}
1311
+ </select>
1312
+ <button
1313
+ type="button"
1314
+ className="inline-flex h-8 w-8 items-center justify-center rounded border text-muted-foreground hover:text-foreground"
1315
+ onClick={() =>
1316
+ handleOpenFieldsetEditor(entityLayout.entityId, entityLayout.activeFieldset ?? null, 'fieldset')}
1317
+ disabled={!manageHref}
1318
+ title={manageFieldsetLabel}
1319
+ >
1320
+ <Settings className="size-4" />
1321
+ <span className="sr-only">{manageFieldsetLabel}</span>
1322
+ </button>
1323
+ </div>
1324
+ </div>,
1325
+ )
1326
+ }
1327
+
1328
+ if (entityLayout.sections.length) {
1329
+ entityLayout.sections.forEach((section) => {
1330
+ const FieldsetIcon = section.fieldset?.icon
1331
+ ? FIELDSET_ICON_COMPONENTS[section.fieldset.icon]
1332
+ : null
1333
+ const sectionKey = `${entityLayout.entityId}:${section.fieldsetCode ?? 'default'}`
1334
+ const manageDisabled = !manageHref
1335
+ nodes.push(
1336
+ <div key={sectionKey} className="rounded-lg border bg-card p-4 space-y-4">
1337
+ <div className="flex items-start justify-between gap-3">
1338
+ <div className="flex items-start gap-2">
1339
+ {FieldsetIcon ? (
1340
+ <FieldsetIcon className="size-5 text-muted-foreground" />
1341
+ ) : null}
1342
+ <div>
1343
+ <div className="text-sm font-medium">{section.title}</div>
1344
+ {section.description ? (
1345
+ <div className="text-xs text-muted-foreground">
1346
+ {section.description}
1347
+ </div>
1348
+ ) : null}
1349
+ </div>
1350
+ </div>
1351
+ <button
1352
+ type="button"
1353
+ className="inline-flex items-center gap-1 rounded border px-2 py-1 text-xs text-muted-foreground hover:text-foreground disabled:opacity-50"
1354
+ onClick={() => handleOpenFieldsetEditor(entityLayout.entityId, section.fieldsetCode, 'fieldset')}
1355
+ disabled={manageDisabled}
1356
+ >
1357
+ <Settings className="size-4" />
1358
+ {manageFieldsetLabel}
1359
+ </button>
1360
+ </div>
1361
+ {section.groups.map((group) => {
1362
+ const groupKey = `${section.fieldsetCode ?? 'default'}:${group.code ?? 'default'}`
1363
+ return (
1364
+ <div key={groupKey} className="space-y-2">
1365
+ {group.label ? (
1366
+ <div>
1367
+ <div className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
1368
+ {group.label}
1369
+ </div>
1370
+ {group.hint ? (
1371
+ <div className="text-xs text-muted-foreground">{group.hint}</div>
1372
+ ) : null}
1373
+ </div>
1374
+ ) : null}
1375
+ {renderFields(group.fields)}
1376
+ </div>
1377
+ )
1378
+ })}
1379
+ {!section.groups.length ? (
1380
+ <div className="text-xs text-muted-foreground">{emptyFieldsetMessage}</div>
1381
+ ) : null}
1382
+ </div>,
1383
+ )
1384
+ })
1385
+ } else {
1386
+ nodes.push(
1387
+ <div key={`custom-fields-empty-${entityLayout.entityId}`} className="rounded-lg border bg-card p-4">
1388
+ {customFieldsEmptyState}
1389
+ </div>,
1390
+ )
1391
+ }
1392
+ })
1393
+
1394
+ return nodes
1395
+ }, [
1396
+ buildCustomFieldsManageHref,
1397
+ customFieldLayout,
1398
+ customFieldsEmptyState,
1399
+ defaultFieldsetLabel,
1400
+ emptyFieldsetMessage,
1401
+ fieldsetSelectorLabel,
1402
+ handleFieldsetSelectionChange,
1403
+ handleOpenFieldsetEditor,
1404
+ manageFieldsetLabel,
1405
+ renderFields,
1406
+ ])
1407
+
1408
+ const fieldsetManagerDialog = (
1409
+ <Dialog open={fieldsetEditorTarget !== null} onOpenChange={(open) => { if (!open) setFieldsetEditorTarget(null) }}>
1410
+ <DialogContent
1411
+ className="max-w-5xl w-full"
1412
+ onKeyDown={(event) => {
1413
+ if ((event.metaKey || event.ctrlKey) && event.key === 'Enter') {
1414
+ event.preventDefault()
1415
+ handleFieldsetDialogSave()
1416
+ }
1417
+ }}
1418
+ >
1419
+ <DialogHeader>
1420
+ <DialogTitle>{fieldsetDialogTitle}</DialogTitle>
1421
+ </DialogHeader>
1422
+ {fieldsetEditorTarget ? (
1423
+ <FieldDefinitionsManager
1424
+ ref={fieldsetManagerRef}
1425
+ entityId={fieldsetEditorTarget.entityId}
1426
+ initialFieldset={fieldsetEditorTarget.fieldsetCode}
1427
+ fullEditorHref={fieldsetEditorFullHref ?? undefined}
1428
+ onSaved={refreshCustomFieldDefinitions}
1429
+ onClose={() => setFieldsetEditorTarget(null)}
1430
+ />
1431
+ ) : (
1432
+ <div className="flex h-full items-center justify-center text-sm text-muted-foreground px-4 text-center">
1433
+ {fieldsetDialogUnavailable}
1434
+ </div>
1435
+ )}
1436
+ </DialogContent>
1437
+ </Dialog>
1438
+ )
1439
+
1440
+ // If groups are provided, render the two-column grouped layout
1441
+ if (useGroupedLayout) {
1442
+
1443
+ const col1: CrudFormGroup[] = []
1444
+ const col2: CrudFormGroup[] = []
1445
+ for (const g of resolvedGroupsForLayout) {
1446
+ if ((g.column ?? 1) === 2) col2.push(g)
1447
+ else col1.push(g)
1448
+ }
1449
+
1450
+ const renderGroupedCards = (items: CrudFormGroup[]) => {
1451
+ const nodes: React.ReactNode[] = []
1452
+ for (const g of items) {
1453
+ const isCustomFieldsGroup = g.kind === 'customFields'
1454
+ if (isCustomFieldsGroup) {
1455
+ if (isLoadingCustomFields) {
1456
+ nodes.push(
1457
+ <div key={`${g.id}-loading`} className="rounded-lg border bg-card p-4">
1458
+ <DataLoader
1459
+ isLoading
1460
+ loadingMessage={resolvedCustomFieldsLoadingMessage}
1461
+ spinnerSize="md"
1462
+ className="min-h-[1px]"
1463
+ >
1464
+ <div />
1465
+ </DataLoader>
1466
+ </div>,
1467
+ )
1468
+ continue
1469
+ }
1470
+ if (g.component) {
1471
+ nodes.push(
1472
+ <div key={`${g.id}-component`} className="rounded-lg border bg-card px-4 py-3">
1473
+ {g.component({ values, setValue, errors })}
1474
+ </div>,
1475
+ )
1476
+ }
1477
+ const renderedSections = renderCustomFieldsContent()
1478
+ if (renderedSections.length) nodes.push(...renderedSections)
1479
+ continue
1480
+ }
1481
+
1482
+ const componentNode = g.component ? g.component({ values, setValue, errors }) : null
1483
+ if (g.bare) {
1484
+ if (componentNode) {
1485
+ nodes.push(<React.Fragment key={g.id}>{componentNode}</React.Fragment>)
1486
+ }
1487
+ continue
1488
+ }
1489
+ const groupFields = resolveGroupFields(g)
1490
+ nodes.push(
1491
+ <div key={g.id} className="rounded-lg border bg-card px-4 py-3 space-y-3">
1492
+ {g.title ? (
1493
+ <div className="text-sm font-medium">{t(g.title, g.title)}</div>
1494
+ ) : null}
1495
+ {g.description ? <div className="text-xs text-muted-foreground">{t(g.description, g.description)}</div> : null}
1496
+ {componentNode ? (
1497
+ <div>{componentNode}</div>
1498
+ ) : null}
1499
+ <DataLoader
1500
+ isLoading={false}
1501
+ loadingMessage={resolvedLoadingMessage}
1502
+ spinnerSize="md"
1503
+ className="min-h-[1px]"
1504
+ >
1505
+ {groupFields.length > 0 ? renderFields(groupFields) : <div className="min-h-[1px]" />}
1506
+ </DataLoader>
1507
+ </div>,
1508
+ )
1509
+ }
1510
+ return nodes
1511
+ }
1512
+
1513
+ const col1Content = renderGroupedCards(col1)
1514
+ const col2Content = renderGroupedCards(col2)
1515
+ const hasSecondaryColumn = col2Content.length > 0
1516
+
1517
+ return (
1518
+ <div className="space-y-4" ref={rootRef}>
1519
+ {!embedded ? (
1520
+ <div className="flex items-center justify-between gap-3">
1521
+ <div className="flex items-center gap-3">
1522
+ {backHref ? (
1523
+ <Link href={backHref} className="text-sm text-muted-foreground hover:text-foreground">
1524
+ ← {backLabel}
1525
+ </Link>
1526
+ ) : null}
1527
+ {title ? <div className="text-base font-medium">{title}</div> : null}
1528
+ </div>
1529
+ <div className="flex items-center gap-2">
1530
+ {extraActions}
1531
+ {showDelete ? (
1532
+ <Button type="button" variant="outline" onClick={handleDelete} className="text-red-600 border-red-200 hover:bg-red-50 rounded">
1533
+ <Trash2 className="size-4 mr-2" />
1534
+ {deleteLabel}
1535
+ </Button>
1536
+ ) : null}
1537
+ {cancelHref ? (
1538
+ <Link href={cancelHref} className="h-9 inline-flex items-center rounded border px-3 text-sm">
1539
+ {cancelLabel}
1540
+ </Link>
1541
+ ) : null}
1542
+ <Button type="submit" form={formId} disabled={pending}>
1543
+ <Save className="size-4 mr-2" />
1544
+ {pending ? savingLabel : resolvedSubmitLabel}
1545
+ </Button>
1546
+ </div>
1547
+ </div>
1548
+ ) : null}
1549
+ {contentHeader}
1550
+ <DataLoader
1551
+ isLoading={isLoading}
1552
+ loadingMessage={resolvedLoadingMessage}
1553
+ spinnerSize="md"
1554
+ className="min-h-[400px]"
1555
+ >
1556
+ <form id={formId} onSubmit={handleSubmit} className={`space-y-4 ${dialogFormPadding}`}>
1557
+ {resolvedInjectionSpotId ? (
1558
+ <InjectionSpot
1559
+ spotId={resolvedInjectionSpotId}
1560
+ context={injectionContext}
1561
+ data={values}
1562
+ onDataChange={(newData) => setValues(newData as CrudFormValues<TValues>)}
1563
+ disabled={pending}
1564
+ widgetsOverride={stackedInjectionWidgets}
1565
+ />
1566
+ ) : null}
1567
+ <div
1568
+ className={hasSecondaryColumn
1569
+ ? 'grid grid-cols-1 lg:grid-cols-[7fr_3fr] gap-4'
1570
+ : 'grid grid-cols-1 gap-4'}
1571
+ >
1572
+ <div className="space-y-3">{col1Content}</div>
1573
+ {hasSecondaryColumn ? <div className="space-y-3">{col2Content}</div> : null}
1574
+ </div>
1575
+ {formError ? <div className="text-sm text-red-600">{formError}</div> : null}
1576
+ <div className={`flex items-center ${embedded ? 'justify-end' : 'justify-between'} gap-2 ${dialogFooterClass}`}>
1577
+ {embedded ? null : <div />}
1578
+ <div className="flex items-center gap-2">
1579
+ {extraActions}
1580
+ {!embedded && showDelete ? (
1581
+ <Button type="button" variant="outline" onClick={handleDelete} className="text-red-600 border-red-200 hover:bg-red-50 rounded">
1582
+ <Trash2 className="size-4 mr-2" />
1583
+ {deleteLabel}
1584
+ </Button>
1585
+ ) : null}
1586
+ {!embedded && cancelHref ? (
1587
+ <Link href={cancelHref} className="h-9 inline-flex items-center rounded border px-3 text-sm">
1588
+ {cancelLabel}
1589
+ </Link>
1590
+ ) : null}
1591
+ <Button type="submit" disabled={pending}>
1592
+ <Save className="size-4 mr-2" />
1593
+ {pending ? savingLabel : resolvedSubmitLabel}
1594
+ </Button>
1595
+ </div>
1596
+ </div>
1597
+ </form>
1598
+ </DataLoader>
1599
+ {fieldsetManagerDialog}
1600
+ </div>
1601
+ )
1602
+ }
1603
+
1604
+ // Default single-card layout (compatible with previous API)
1605
+ return (
1606
+ <div className="space-y-4" ref={rootRef}>
1607
+ {!embedded ? (
1608
+ <div className="flex items-center justify-between gap-3">
1609
+ <div className="flex items-center gap-3">
1610
+ {backHref ? (
1611
+ <Link href={backHref} className="text-sm text-muted-foreground hover:text-foreground">
1612
+ ← {backLabel}
1613
+ </Link>
1614
+ ) : null}
1615
+ {title ? <div className="text-base font-medium">{title}</div> : null}
1616
+ </div>
1617
+ <div className="flex items-center gap-2">
1618
+ {extraActions}
1619
+ {showDelete ? (
1620
+ <Button type="button" variant="outline" onClick={handleDelete} className="text-red-600 border-red-200 hover:bg-red-50 rounded">
1621
+ <Trash2 className="size-4 mr-2" />
1622
+ {deleteLabel}
1623
+ </Button>
1624
+ ) : null}
1625
+ {cancelHref ? (
1626
+ <Link href={cancelHref} className="h-9 inline-flex items-center rounded border px-3 text-sm">
1627
+ {cancelLabel}
1628
+ </Link>
1629
+ ) : null}
1630
+ <Button type="submit" form={formId} disabled={pending}>
1631
+ <Save className="size-4 mr-2" />
1632
+ {pending ? savingLabel : resolvedSubmitLabel}
1633
+ </Button>
1634
+ </div>
1635
+ </div>
1636
+ ) : null}
1637
+ {contentHeader}
1638
+ <DataLoader
1639
+ isLoading={isLoading}
1640
+ loadingMessage={resolvedLoadingMessage}
1641
+ spinnerSize="md"
1642
+ className="min-h-[400px]"
1643
+ >
1644
+ <div>
1645
+ <form
1646
+ id={formId}
1647
+ onSubmit={handleSubmit}
1648
+ className={`${embedded ? 'space-y-4' : 'rounded-lg border bg-card p-4 space-y-4'} ${dialogFormPadding}`}
1649
+ >
1650
+ {resolvedInjectionSpotId ? (
1651
+ <InjectionSpot
1652
+ spotId={resolvedInjectionSpotId}
1653
+ context={injectionContext}
1654
+ data={values}
1655
+ onDataChange={(newData) => setValues(newData as CrudFormValues<TValues>)}
1656
+ disabled={pending}
1657
+ widgetsOverride={stackedInjectionWidgets}
1658
+ />
1659
+ ) : null}
1660
+ <div className={grid}>
1661
+ {allFields.map((f) => {
1662
+ const layout = f.layout ?? 'full'
1663
+ const wrapperClassName = usesResponsiveLayout ? resolveLayoutClass(layout) : undefined
1664
+ return (
1665
+ <FieldControl
1666
+ key={f.id}
1667
+ field={f}
1668
+ value={values[f.id]}
1669
+ error={errors[f.id]}
1670
+ options={fieldOptionsById.get(f.id) || EMPTY_OPTIONS}
1671
+ setValue={setValue}
1672
+ values={values}
1673
+ loadFieldOptions={loadFieldOptions}
1674
+ autoFocus={Boolean(firstFieldId && f.id === firstFieldId)}
1675
+ onSubmitRequest={requestSubmit}
1676
+ wrapperClassName={wrapperClassName}
1677
+ entityIdForField={primaryEntityId ?? undefined}
1678
+ recordId={recordId}
1679
+ />
1680
+ )
1681
+ })}
1682
+ </div>
1683
+ {formError ? <div className="text-sm text-red-600">{formError}</div> : null}
1684
+ <div className={`flex items-center ${embedded ? 'justify-end' : 'justify-end'} gap-2 ${dialogFooterClass}`}>
1685
+ {extraActions}
1686
+ {!embedded && showDelete ? (
1687
+ <Button type="button" variant="outline" onClick={handleDelete} className="text-red-600 border-red-200 hover:bg-red-50">
1688
+ <Trash2 className="size-4 mr-2" />
1689
+ {deleteLabel}
1690
+ </Button>
1691
+ ) : null}
1692
+ {!embedded && cancelHref ? (
1693
+ <Link href={cancelHref} className="h-9 inline-flex items-center rounded border px-3 text-sm">
1694
+ {cancelLabel}
1695
+ </Link>
1696
+ ) : null}
1697
+ <Button type="submit" disabled={pending}>
1698
+ <Save className="size-4 mr-2" />
1699
+ {pending ? savingLabel : resolvedSubmitLabel}
1700
+ </Button>
1701
+ </div>
1702
+ </form>
1703
+ </div>
1704
+ </DataLoader>
1705
+ {fieldsetManagerDialog}
1706
+ </div>
1707
+ )
1708
+ }
1709
+
1710
+ function RelationSelect({
1711
+ value,
1712
+ onChange,
1713
+ options,
1714
+ placeholder,
1715
+ autoFocus,
1716
+ }: {
1717
+ value: string
1718
+ onChange: (v: string) => void
1719
+ options: CrudFieldOption[]
1720
+ placeholder?: string
1721
+ autoFocus?: boolean
1722
+ }) {
1723
+ const t = useT()
1724
+ const [query, setQuery] = React.useState('')
1725
+ const inputRef = React.useRef<HTMLInputElement | null>(null)
1726
+
1727
+ const filtered = React.useMemo(() => {
1728
+ const q = query.toLowerCase().trim()
1729
+ if (!q) return options
1730
+ return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
1731
+ }, [query, options])
1732
+
1733
+ return (
1734
+ <div className="space-y-1">
1735
+ <input
1736
+ ref={inputRef}
1737
+ className="w-full h-9 rounded border px-2 text-sm"
1738
+ placeholder={placeholder || t('ui.forms.listbox.searchPlaceholder', 'Search...')}
1739
+ value={query}
1740
+ onChange={(e) => setQuery(e.target.value)}
1741
+ autoFocus={autoFocus}
1742
+ data-crud-focus-target=""
1743
+ />
1744
+ <div className="max-h-40 overflow-auto rounded border">
1745
+ <button
1746
+ type="button"
1747
+ className="block w-full text-left px-2 py-1 text-sm hover:bg-muted"
1748
+ onClick={() => onChange('')}
1749
+ >
1750
+
1751
+ </button>
1752
+ {filtered.map((opt) => (
1753
+ <button
1754
+ key={opt.value}
1755
+ type="button"
1756
+ className={`block w-full text-left px-2 py-1 text-sm hover:bg-muted ${
1757
+ value === opt.value ? 'bg-muted' : ''
1758
+ }`}
1759
+ onClick={() => onChange(opt.value)}
1760
+ >
1761
+ {opt.label}
1762
+ </button>
1763
+ ))}
1764
+ </div>
1765
+ </div>
1766
+ )
1767
+ }
1768
+ // Local-buffer text input to avoid focus loss when parent re-renders
1769
+ function TextInput({
1770
+ value,
1771
+ onChange,
1772
+ placeholder,
1773
+ autoFocus,
1774
+ onSubmit,
1775
+ disabled,
1776
+ suggestions,
1777
+ }: {
1778
+ value: string
1779
+ onChange: (v: string) => void
1780
+ placeholder?: string
1781
+ autoFocus?: boolean
1782
+ onSubmit?: () => void
1783
+ disabled?: boolean
1784
+ suggestions?: string[]
1785
+ }) {
1786
+ const [local, setLocal] = React.useState<string>(value)
1787
+ const isFocusedRef = React.useRef(false)
1788
+ const userTypingRef = React.useRef(false)
1789
+ const datalistId = React.useId()
1790
+
1791
+ React.useEffect(() => {
1792
+ // Sync from props whenever the input is unfocused or the user hasn't typed yet.
1793
+ if (!isFocusedRef.current || !userTypingRef.current) {
1794
+ setLocal(value)
1795
+ }
1796
+ }, [value])
1797
+
1798
+ const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
1799
+ if (disabled) return
1800
+ const next = e.target.value
1801
+ userTypingRef.current = true
1802
+ setLocal(next)
1803
+ onChange(next)
1804
+ }, [disabled, onChange])
1805
+
1806
+ const handleKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
1807
+ if (disabled) return
1808
+ if (e.key === 'Enter' && !e.shiftKey) {
1809
+ e.preventDefault()
1810
+ onChange(local)
1811
+ onSubmit?.()
1812
+ }
1813
+ }, [disabled, local, onChange, onSubmit])
1814
+
1815
+ const handleFocus = React.useCallback(() => {
1816
+ isFocusedRef.current = true
1817
+ }, [])
1818
+
1819
+ const handleBlur = React.useCallback(() => {
1820
+ isFocusedRef.current = false
1821
+ userTypingRef.current = false
1822
+ onChange(local)
1823
+ }, [local, onChange])
1824
+
1825
+ return (
1826
+ <>
1827
+ <input
1828
+ type="text"
1829
+ className="w-full h-9 rounded border px-2 text-sm"
1830
+ placeholder={placeholder}
1831
+ value={local}
1832
+ onChange={handleChange}
1833
+ onKeyDown={handleKeyDown}
1834
+ onFocus={handleFocus}
1835
+ onBlur={handleBlur}
1836
+ spellCheck={false}
1837
+ autoFocus={autoFocus}
1838
+ data-crud-focus-target=""
1839
+ disabled={disabled}
1840
+ list={suggestions && suggestions.length > 0 ? datalistId : undefined}
1841
+ />
1842
+ {suggestions && suggestions.length > 0 && (
1843
+ <datalist id={datalistId}>
1844
+ {suggestions.map((suggestion) => (
1845
+ <option key={suggestion} value={suggestion} />
1846
+ ))}
1847
+ </datalist>
1848
+ )}
1849
+ </>
1850
+ )
1851
+ }
1852
+
1853
+ // Local-buffer number input to avoid focus loss when parent re-renders
1854
+ function NumberInput({
1855
+ value,
1856
+ onChange,
1857
+ placeholder,
1858
+ autoFocus,
1859
+ onSubmit,
1860
+ }: {
1861
+ value: number | string | null | undefined
1862
+ onChange: (v: number | undefined) => void
1863
+ placeholder?: string
1864
+ autoFocus?: boolean
1865
+ onSubmit?: () => void
1866
+ }) {
1867
+ const [local, setLocal] = React.useState<string>(value !== undefined && value !== null ? String(value) : '')
1868
+ const isFocusedRef = React.useRef(false)
1869
+
1870
+ React.useEffect(() => {
1871
+ // Only sync from props when not focused to avoid caret jumps
1872
+ if (!isFocusedRef.current) {
1873
+ setLocal(value !== undefined && value !== null ? String(value) : '')
1874
+ }
1875
+ }, [value])
1876
+
1877
+ const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
1878
+ const next = e.target.value
1879
+ setLocal(next)
1880
+ const numValue = next === '' ? undefined : Number(next)
1881
+ onChange(numValue)
1882
+ }, [onChange])
1883
+
1884
+ const handleKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
1885
+ if (e.key === 'Enter' && !e.shiftKey) {
1886
+ e.preventDefault()
1887
+ const numValue = local === '' ? undefined : Number(local)
1888
+ onChange(numValue)
1889
+ onSubmit?.()
1890
+ }
1891
+ }, [local, onChange, onSubmit])
1892
+
1893
+ const handleFocus = React.useCallback(() => {
1894
+ isFocusedRef.current = true
1895
+ }, [])
1896
+
1897
+ const handleBlur = React.useCallback(() => {
1898
+ isFocusedRef.current = false
1899
+ const numValue = local === '' ? undefined : Number(local)
1900
+ onChange(numValue)
1901
+ }, [local, onChange])
1902
+
1903
+ return (
1904
+ <input
1905
+ type="number"
1906
+ className="w-full h-9 rounded border px-2 text-sm"
1907
+ placeholder={placeholder}
1908
+ value={local}
1909
+ onChange={handleChange}
1910
+ onKeyDown={handleKeyDown}
1911
+ onFocus={handleFocus}
1912
+ onBlur={handleBlur}
1913
+ autoFocus={autoFocus}
1914
+ data-crud-focus-target=""
1915
+ />
1916
+ )
1917
+ }
1918
+
1919
+ // Local-buffer textarea to avoid form-wide re-renders while typing
1920
+ function TextAreaInput({
1921
+ value,
1922
+ onChange,
1923
+ placeholder,
1924
+ autoFocus,
1925
+ }: {
1926
+ value: string
1927
+ onChange: (v: string) => void
1928
+ placeholder?: string
1929
+ autoFocus?: boolean
1930
+ }) {
1931
+ const [local, setLocal] = React.useState<string>(value)
1932
+ const isFocusedRef = React.useRef(false)
1933
+
1934
+ React.useEffect(() => {
1935
+ if (!isFocusedRef.current) setLocal(value)
1936
+ }, [value])
1937
+
1938
+ const handleChange = React.useCallback((e: React.ChangeEvent<HTMLTextAreaElement>) => {
1939
+ const next = e.target.value
1940
+ setLocal(next)
1941
+ onChange(next)
1942
+ }, [onChange])
1943
+
1944
+ const handleFocus = React.useCallback(() => { isFocusedRef.current = true }, [])
1945
+ const handleBlur = React.useCallback(() => { isFocusedRef.current = false; onChange(local) }, [local, onChange])
1946
+
1947
+ return (
1948
+ <textarea
1949
+ className="w-full rounded border px-2 py-2 min-h-[120px] text-sm"
1950
+ placeholder={placeholder}
1951
+ value={local}
1952
+ onChange={handleChange}
1953
+ onFocus={handleFocus}
1954
+ onBlur={handleBlur}
1955
+ autoFocus={autoFocus}
1956
+ data-crud-focus-target=""
1957
+ />
1958
+ )
1959
+ }
1960
+
1961
+ // Markdown editor using @uiw/react-md-editor (client-only)
1962
+ type MDProps = { value?: string; onChange: (md: string) => void }
1963
+ const MDEditor = dynamic(async () => {
1964
+ const mod = await import('@uiw/react-md-editor')
1965
+ return mod.default
1966
+ }, { ssr: false }) as React.ComponentType<UiWMDEditorProps>
1967
+ const MarkdownEditor = React.memo(function MarkdownEditor({ value = '', onChange }: MDProps) {
1968
+ const containerRef = React.useRef<HTMLDivElement | null>(null)
1969
+ const [local, setLocal] = React.useState<string>(value)
1970
+ const typingRef = React.useRef(false)
1971
+
1972
+ React.useEffect(() => {
1973
+ if (!typingRef.current) setLocal(value)
1974
+ }, [value])
1975
+
1976
+ const handleChange = React.useCallback((v?: string) => {
1977
+ typingRef.current = true
1978
+ setLocal(v ?? '')
1979
+ }, [])
1980
+
1981
+ const commit = React.useCallback(() => {
1982
+ if (!typingRef.current) return
1983
+ typingRef.current = false
1984
+ onChange(local)
1985
+ requestAnimationFrame(() => {
1986
+ const ta = containerRef.current?.querySelector('textarea') as HTMLTextAreaElement | null
1987
+ ta?.focus()
1988
+ })
1989
+ }, [local, onChange])
1990
+
1991
+ return (
1992
+ <div ref={containerRef} data-color-mode="light" className="w-full" onBlur={() => commit()}>
1993
+ <MDEditor
1994
+ value={local}
1995
+ height={220}
1996
+ onChange={handleChange}
1997
+ previewOptions={{ remarkPlugins: [remarkGfm] }}
1998
+ />
1999
+ </div>
2000
+ )
2001
+ }, (prev, next) => prev.value === next.value)
2002
+
2003
+ // HTML Rich Text editor (contentEditable) with shortcuts; returns HTML string
2004
+ type HtmlRTProps = { value?: string; onChange: (html: string) => void }
2005
+ const HtmlRichTextEditor = React.memo(function HtmlRichTextEditor({ value = '', onChange }: HtmlRTProps) {
2006
+ const t = useT()
2007
+ const boldLabel = t('ui.forms.richtext.bold')
2008
+ const italicLabel = t('ui.forms.richtext.italic')
2009
+ const underlineLabel = t('ui.forms.richtext.underline')
2010
+ const listLabel = t('ui.forms.richtext.list')
2011
+ const heading3Label = t('ui.forms.richtext.heading3')
2012
+ const linkLabel = t('ui.forms.richtext.link')
2013
+ const linkUrlPrompt = t('ui.forms.richtext.linkUrlPrompt')
2014
+ const ref = React.useRef<HTMLDivElement | null>(null)
2015
+ const applyingExternal = React.useRef(false)
2016
+ const typingRef = React.useRef(false)
2017
+
2018
+ React.useEffect(() => {
2019
+ const el = ref.current
2020
+ if (!el) return
2021
+ const current = el.innerHTML
2022
+ if (!typingRef.current && current !== value) {
2023
+ applyingExternal.current = true
2024
+ el.innerHTML = value || ''
2025
+ requestAnimationFrame(() => { applyingExternal.current = false })
2026
+ }
2027
+ }, [value])
2028
+
2029
+ const exec = (cmd: string, arg?: string) => {
2030
+ const el = ref.current
2031
+ if (!el) return
2032
+ el.focus()
2033
+ try {
2034
+ document.execCommand(cmd, false, arg)
2035
+ } catch {
2036
+ // ignore execCommand failures
2037
+ }
2038
+ }
2039
+
2040
+ const onKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
2041
+ const isMod = e.metaKey || e.ctrlKey
2042
+ if (!isMod) return
2043
+ const k = e.key.toLowerCase()
2044
+ if (k === 'b') { e.preventDefault(); exec('bold') }
2045
+ if (k === 'i') { e.preventDefault(); exec('italic') }
2046
+ if (k === 'u') { e.preventDefault(); exec('underline') }
2047
+ }
2048
+
2049
+ return (
2050
+ <div className="w-full rounded border">
2051
+ <div className="flex items-center gap-1 px-2 py-1 border-b">
2052
+ <button type="button" className="px-2 py-0.5 text-xs rounded hover:bg-muted" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('bold')}>{boldLabel}</button>
2053
+ <button type="button" className="px-2 py-0.5 text-xs rounded hover:bg-muted" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('italic')}>{italicLabel}</button>
2054
+ <button type="button" className="px-2 py-0.5 text-xs rounded hover:bg-muted" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('underline')}>{underlineLabel}</button>
2055
+ <span className="mx-2 text-muted-foreground">|</span>
2056
+ <button type="button" className="px-2 py-0.5 text-xs rounded hover:bg-muted" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('insertUnorderedList')}>• {listLabel}</button>
2057
+ <button type="button" className="px-2 py-0.5 text-xs rounded hover:bg-muted" onMouseDown={(e) => e.preventDefault()} onClick={() => exec('formatBlock', '<h3>')}>{heading3Label}</button>
2058
+ <button
2059
+ type="button"
2060
+ className="px-2 py-0.5 text-xs rounded hover:bg-muted"
2061
+ onMouseDown={(e) => e.preventDefault()}
2062
+ onClick={() => {
2063
+ const url = window.prompt(linkUrlPrompt)?.trim()
2064
+ if (url) exec('createLink', url)
2065
+ }}
2066
+ >{linkLabel}</button>
2067
+ </div>
2068
+ <div
2069
+ ref={ref}
2070
+ className="w-full px-2 py-2 min-h-[160px] focus:outline-none prose prose-sm max-w-none"
2071
+ contentEditable
2072
+ suppressContentEditableWarning
2073
+ onKeyDown={onKeyDown}
2074
+ onInput={() => { if (!applyingExternal.current) typingRef.current = true }}
2075
+ onBlur={() => {
2076
+ const el = ref.current
2077
+ if (!el) return
2078
+ typingRef.current = false
2079
+ onChange(el.innerHTML)
2080
+ }}
2081
+ />
2082
+ </div>
2083
+ )
2084
+ }, (prev, next) => prev.value === next.value)
2085
+
2086
+ // Very simple markdown editor with Bold/Italic/Underline + shortcuts.
2087
+ type SimpleMDProps = { value?: string; onChange: (md: string) => void }
2088
+ const SimpleMarkdownEditor = React.memo(function SimpleMarkdownEditor({ value = '', onChange }: SimpleMDProps) {
2089
+ const t = useT()
2090
+ const boldLabel = t('ui.forms.richtext.bold')
2091
+ const italicLabel = t('ui.forms.richtext.italic')
2092
+ const underlineLabel = t('ui.forms.richtext.underline')
2093
+ const markdownPlaceholder = t('ui.forms.richtext.placeholder')
2094
+ const sampleText = t('ui.forms.richtext.sampleText')
2095
+ const taRef = React.useRef<HTMLTextAreaElement | null>(null)
2096
+ const [local, setLocal] = React.useState<string>(value)
2097
+ const typingRef = React.useRef(false)
2098
+
2099
+ React.useEffect(() => {
2100
+ if (!typingRef.current) setLocal(value)
2101
+ }, [value])
2102
+
2103
+ const wrap = (before: string, after: string = before) => {
2104
+ const el = taRef.current
2105
+ if (!el) return
2106
+ const start = el.selectionStart ?? 0
2107
+ const end = el.selectionEnd ?? 0
2108
+ const sel = value.slice(start, end) || sampleText
2109
+ const next = value.slice(0, start) + before + sel + after + value.slice(end)
2110
+ onChange(next)
2111
+ queueMicrotask(() => {
2112
+ const caret = start + before.length + sel.length + after.length
2113
+ el.focus()
2114
+ el.setSelectionRange(caret, caret)
2115
+ })
2116
+ }
2117
+
2118
+ const onKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
2119
+ const isMod = e.metaKey || e.ctrlKey
2120
+ if (!isMod) return
2121
+ const key = e.key.toLowerCase()
2122
+ if (key === 'b') { e.preventDefault(); wrap('**') }
2123
+ if (key === 'i') { e.preventDefault(); wrap('_') }
2124
+ if (key === 'u') { e.preventDefault(); wrap('__') }
2125
+ }
2126
+
2127
+ return (
2128
+ <div className="w-full rounded border">
2129
+ <div className="flex items-center gap-1 px-2 py-1 border-b">
2130
+ <button type="button" className="px-2 py-0.5 text-xs rounded hover:bg-muted" onMouseDown={(e) => e.preventDefault()} onClick={() => wrap('**')}>{boldLabel}</button>
2131
+ <button type="button" className="px-2 py-0.5 text-xs rounded hover:bg-muted" onMouseDown={(e) => e.preventDefault()} onClick={() => wrap('_')}>{italicLabel}</button>
2132
+ <button type="button" className="px-2 py-0.5 text-xs rounded hover:bg-muted" onMouseDown={(e) => e.preventDefault()} onClick={() => wrap('__')}>{underlineLabel}</button>
2133
+ </div>
2134
+ <textarea
2135
+ ref={taRef}
2136
+ className="w-full min-h-[160px] resize-y px-2 py-2 font-mono text-sm outline-none"
2137
+ spellCheck={false}
2138
+ value={local}
2139
+ onChange={(e) => { typingRef.current = true; setLocal(e.target.value) }}
2140
+ onBlur={() => { if (typingRef.current) { typingRef.current = false; onChange(local) } }}
2141
+ onKeyDown={onKeyDown}
2142
+ placeholder={markdownPlaceholder}
2143
+ />
2144
+ </div>
2145
+ )
2146
+ }, (prev, next) => prev.value === next.value)
2147
+
2148
+ type FieldControlProps = {
2149
+ field: CrudField
2150
+ value: unknown
2151
+ error?: string
2152
+ options: CrudFieldOption[]
2153
+ setValue: (id: string, v: unknown) => void
2154
+ values: Record<string, unknown>
2155
+ loadFieldOptions: (field: CrudField, query?: string) => Promise<CrudFieldOption[]>
2156
+ autoFocus: boolean
2157
+ onSubmitRequest: () => void
2158
+ wrapperClassName?: string
2159
+ entityIdForField?: string
2160
+ recordId?: string
2161
+ }
2162
+
2163
+ type ListboxMultiSelectProps = {
2164
+ options: CrudFieldOption[]
2165
+ placeholder?: string
2166
+ value: string[]
2167
+ onChange: (vals: string[]) => void
2168
+ autoFocus?: boolean
2169
+ }
2170
+
2171
+ const ListboxMultiSelect = React.memo(function ListboxMultiSelect({
2172
+ options,
2173
+ placeholder,
2174
+ value,
2175
+ onChange,
2176
+ autoFocus,
2177
+ }: ListboxMultiSelectProps) {
2178
+ const t = useT()
2179
+ const searchPlaceholder = placeholder || t('ui.forms.listbox.searchPlaceholder')
2180
+ const noMatchesLabel = t('ui.forms.listbox.noMatches')
2181
+ const [query, setQuery] = React.useState('')
2182
+ const filtered = React.useMemo(() => {
2183
+ const q = query.toLowerCase().trim()
2184
+ if (!q) return options
2185
+ return options.filter((o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().includes(q))
2186
+ }, [options, query])
2187
+ const toggle = React.useCallback(
2188
+ (val: string) => {
2189
+ const set = new Set(value)
2190
+ if (set.has(val)) set.delete(val)
2191
+ else set.add(val)
2192
+ onChange(Array.from(set))
2193
+ },
2194
+ [value, onChange]
2195
+ )
2196
+ return (
2197
+ <div className="w-full">
2198
+ <input
2199
+ className="mb-2 w-full h-8 rounded border px-2 text-sm"
2200
+ placeholder={searchPlaceholder}
2201
+ value={query}
2202
+ onChange={(e) => setQuery(e.target.value)}
2203
+ autoFocus={autoFocus}
2204
+ data-crud-focus-target=""
2205
+ />
2206
+ <div className="rounded border max-h-48 overflow-auto divide-y">
2207
+ {filtered.map((opt) => {
2208
+ const isSel = value.includes(opt.value)
2209
+ return (
2210
+ <button
2211
+ key={opt.value}
2212
+ type="button"
2213
+ onClick={() => toggle(opt.value)}
2214
+ className={`w-full text-left px-3 py-2 text-sm hover:bg-muted ${isSel ? 'bg-muted' : ''}`}
2215
+ >
2216
+ <span className="inline-flex items-center gap-2">
2217
+ <input type="checkbox" className="size-4" readOnly checked={isSel} />
2218
+ <span>{opt.label}</span>
2219
+ </span>
2220
+ </button>
2221
+ )
2222
+ })}
2223
+ {!filtered.length ? (
2224
+ <div className="px-3 py-2 text-sm text-muted-foreground">{noMatchesLabel}</div>
2225
+ ) : null}
2226
+ </div>
2227
+ </div>
2228
+ )
2229
+ })
2230
+
2231
+ const FieldControl = React.memo(function FieldControlImpl({
2232
+ field,
2233
+ value,
2234
+ error,
2235
+ options,
2236
+ setValue,
2237
+ values,
2238
+ loadFieldOptions,
2239
+ autoFocus,
2240
+ onSubmitRequest,
2241
+ wrapperClassName,
2242
+ entityIdForField,
2243
+ recordId,
2244
+ }: FieldControlProps) {
2245
+ const t = useT()
2246
+ const fieldSetValue = React.useCallback(
2247
+ (nextValue: unknown) => setValue(field.id, nextValue),
2248
+ [setValue, field.id]
2249
+ )
2250
+ const setFormValue = React.useCallback(
2251
+ (targetId: string, nextValue: unknown) => setValue(targetId, nextValue),
2252
+ [setValue],
2253
+ )
2254
+ const builtin = field.type === 'custom' ? null : field
2255
+ const hasLoader = typeof builtin?.loadOptions === 'function'
2256
+ const disabled = Boolean(field.disabled)
2257
+ const autoFocusField = autoFocus && !disabled
2258
+
2259
+ React.useEffect(() => {
2260
+ if (!hasLoader || field.type === 'custom') return
2261
+ loadFieldOptions(field).catch(() => {})
2262
+ }, [field, hasLoader, loadFieldOptions])
2263
+
2264
+ const placeholder = builtin?.placeholder
2265
+ const rootClassName = wrapperClassName ? `space-y-1 ${wrapperClassName}` : 'space-y-1'
2266
+
2267
+ return (
2268
+ <div className={rootClassName} data-crud-field-id={field.id}>
2269
+ {field.type !== 'checkbox' && field.label.trim().length > 0 ? (
2270
+ <label className="block text-sm font-medium">
2271
+ {field.label}
2272
+ {field.required ? <span className="text-red-600"> *</span> : null}
2273
+ </label>
2274
+ ) : null}
2275
+ {field.type === 'text' && (
2276
+ <TextInput
2277
+ value={value == null ? '' : String(value)}
2278
+ placeholder={placeholder}
2279
+ onChange={(next) => fieldSetValue(next)}
2280
+ autoFocus={autoFocusField}
2281
+ onSubmit={onSubmitRequest}
2282
+ disabled={disabled}
2283
+ suggestions={field.type === 'text' ? field.suggestions : undefined}
2284
+ />
2285
+ )}
2286
+ {field.type === 'number' && (
2287
+ <NumberInput
2288
+ value={typeof value === 'number' || typeof value === 'string' ? value : null}
2289
+ placeholder={placeholder}
2290
+ onChange={fieldSetValue}
2291
+ autoFocus={autoFocusField}
2292
+ onSubmit={onSubmitRequest}
2293
+ />
2294
+ )}
2295
+ {field.type === 'date' && (
2296
+ <input
2297
+ type="date"
2298
+ className="w-full h-9 rounded border px-2 text-sm"
2299
+ value={typeof value === 'string' ? value : ''}
2300
+ onChange={(e) => setValue(field.id, e.target.value || undefined)}
2301
+ autoFocus={autoFocusField}
2302
+ data-crud-focus-target=""
2303
+ disabled={disabled}
2304
+ />
2305
+ )}
2306
+ {field.type === 'datetime-local' && (
2307
+ <input
2308
+ type="datetime-local"
2309
+ className="w-full h-9 rounded border px-2 text-sm"
2310
+ value={typeof value === 'string' ? value : ''}
2311
+ onChange={(e) => setValue(field.id, e.target.value || undefined)}
2312
+ autoFocus={autoFocusField}
2313
+ data-crud-focus-target=""
2314
+ disabled={disabled}
2315
+ />
2316
+ )}
2317
+ {field.type === 'textarea' && (
2318
+ <TextAreaInput
2319
+ value={value == null ? '' : String(value)}
2320
+ placeholder={placeholder}
2321
+ onChange={(next) => fieldSetValue(next)}
2322
+ autoFocus={autoFocusField}
2323
+ />
2324
+ )}
2325
+ {field.type === 'richtext' && builtin?.editor === 'simple' && (
2326
+ <SimpleMarkdownEditor value={String(value ?? '')} onChange={fieldSetValue} />
2327
+ )}
2328
+ {field.type === 'richtext' && builtin?.editor === 'html' && (
2329
+ <HtmlRichTextEditor value={String(value ?? '')} onChange={fieldSetValue} />
2330
+ )}
2331
+ {field.type === 'richtext' && (!builtin?.editor || (builtin.editor !== 'simple' && builtin.editor !== 'html')) && (
2332
+ <MarkdownEditor value={String(value ?? '')} onChange={fieldSetValue} />
2333
+ )}
2334
+ {field.type === 'tags' && (
2335
+ <TagsInput
2336
+ value={Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []}
2337
+ onChange={(next) => fieldSetValue(next)}
2338
+ placeholder={placeholder}
2339
+ autoFocus={autoFocusField}
2340
+ suggestions={options.map((opt) => opt.label)}
2341
+ loadSuggestions={
2342
+ typeof builtin?.loadOptions === 'function'
2343
+ ? async (query?: string) => {
2344
+ const opts = await loadFieldOptions(field, query)
2345
+ return opts.map((opt) => opt.label)
2346
+ }
2347
+ : undefined
2348
+ }
2349
+ />
2350
+ )}
2351
+ {field.type === 'combobox' && (
2352
+ <ComboboxInput
2353
+ value={typeof value === 'string' ? value : String(value ?? '')}
2354
+ onChange={(next) => fieldSetValue(next)}
2355
+ placeholder={placeholder}
2356
+ autoFocus={autoFocusField}
2357
+ suggestions={
2358
+ builtin?.suggestions
2359
+ ? builtin.suggestions
2360
+ : options.map((opt) => ({ value: opt.value, label: opt.label }))
2361
+ }
2362
+ loadSuggestions={
2363
+ typeof builtin?.loadOptions === 'function'
2364
+ ? async (query?: string) => {
2365
+ const opts = await loadFieldOptions(field, query)
2366
+ return opts.map((opt) => ({ value: opt.value, label: opt.label }))
2367
+ }
2368
+ : undefined
2369
+ }
2370
+ allowCustomValues={builtin?.allowCustomValues ?? true}
2371
+ disabled={disabled}
2372
+ />
2373
+ )}
2374
+ {field.type === 'checkbox' && (
2375
+ <label className="inline-flex items-center gap-2">
2376
+ <input
2377
+ type="checkbox"
2378
+ className="size-4"
2379
+ checked={value === true}
2380
+ onChange={(e) => setValue(field.id, e.target.checked)}
2381
+ data-crud-focus-target=""
2382
+ disabled={disabled}
2383
+ />
2384
+ <span className="text-sm">{field.label}</span>
2385
+ </label>
2386
+ )}
2387
+ {field.type === 'select' && !builtin?.multiple && (
2388
+ <select
2389
+ className="w-full h-9 rounded border px-2 text-sm"
2390
+ value={
2391
+ Array.isArray(value)
2392
+ ? String(value[0] ?? '')
2393
+ : value == null
2394
+ ? ''
2395
+ : String(value)
2396
+ }
2397
+ onChange={(e) => setValue(field.id, e.target.value || undefined)}
2398
+ data-crud-focus-target=""
2399
+ disabled={disabled}
2400
+ >
2401
+ <option value="">{t('ui.forms.select.emptyOption', '—')}</option>
2402
+ {options.map((opt) => (
2403
+ <option key={opt.value} value={opt.value}>
2404
+ {opt.label}
2405
+ </option>
2406
+ ))}
2407
+ </select>
2408
+ )}
2409
+ {field.type === 'select' && builtin?.multiple && builtin.listbox === true && (
2410
+ <ListboxMultiSelect
2411
+ options={options}
2412
+ placeholder={placeholder}
2413
+ value={Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []}
2414
+ onChange={(vals) => setValue(field.id, vals)}
2415
+ autoFocus={autoFocusField}
2416
+ />
2417
+ )}
2418
+ {field.type === 'select' && builtin?.multiple && builtin.listbox !== true && (
2419
+ <div className="flex flex-wrap gap-3">
2420
+ {options.map((opt) => {
2421
+ const arr = Array.isArray(value)
2422
+ ? value.filter((item): item is string => typeof item === 'string')
2423
+ : []
2424
+ const checked = arr.includes(opt.value)
2425
+ return (
2426
+ <label key={opt.value} className="inline-flex items-center gap-2">
2427
+ <input
2428
+ type="checkbox"
2429
+ className="size-4"
2430
+ checked={checked}
2431
+ onChange={(e) => {
2432
+ const next = new Set(arr)
2433
+ if (e.target.checked) {
2434
+ next.add(opt.value)
2435
+ } else {
2436
+ next.delete(opt.value)
2437
+ }
2438
+ setValue(field.id, Array.from(next))
2439
+ }}
2440
+ disabled={disabled}
2441
+ />
2442
+ <span className="text-sm">{opt.label}</span>
2443
+ </label>
2444
+ )
2445
+ })}
2446
+ </div>
2447
+ )}
2448
+ {field.type === 'relation' && (
2449
+ <RelationSelect
2450
+ options={options}
2451
+ placeholder={placeholder}
2452
+ value={
2453
+ Array.isArray(value)
2454
+ ? String(value[0] ?? '')
2455
+ : value == null
2456
+ ? ''
2457
+ : String(value)
2458
+ }
2459
+ onChange={(selected) => setValue(field.id, selected)}
2460
+ autoFocus={autoFocusField}
2461
+ />
2462
+ )}
2463
+ {field.type === 'custom' && (
2464
+ <>
2465
+ {field.component({
2466
+ id: field.id,
2467
+ value,
2468
+ error,
2469
+ setValue: fieldSetValue,
2470
+ setFormValue,
2471
+ values,
2472
+ entityId: entityIdForField,
2473
+ recordId,
2474
+ autoFocus,
2475
+ disabled,
2476
+ })}
2477
+ </>
2478
+ )}
2479
+ {field.description ? (
2480
+ <div className="text-xs text-muted-foreground">{field.description}</div>
2481
+ ) : null}
2482
+ {error ? <div className="text-xs text-red-600">{error}</div> : null}
2483
+ </div>
2484
+ )
2485
+ },
2486
+ (prev, next) =>
2487
+ prev.field.id === next.field.id &&
2488
+ prev.field.type === next.field.type &&
2489
+ prev.field.label === next.field.label &&
2490
+ prev.field.required === next.field.required &&
2491
+ prev.value === next.value &&
2492
+ prev.error === next.error &&
2493
+ prev.options === next.options &&
2494
+ prev.loadFieldOptions === next.loadFieldOptions &&
2495
+ prev.autoFocus === next.autoFocus &&
2496
+ prev.onSubmitRequest === next.onSubmitRequest &&
2497
+ prev.wrapperClassName === next.wrapperClassName &&
2498
+ prev.entityIdForField === next.entityIdForField &&
2499
+ prev.recordId === next.recordId &&
2500
+ (prev.field.type !== 'custom' ||
2501
+ (prev.values === next.values &&
2502
+ prev.field.component === (next.field as CrudCustomField).component))
2503
+ )