@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,530 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import Link from 'next/link'
5
+ import dynamic from 'next/dynamic'
6
+ import type { PluggableList } from 'unified'
7
+ import { Pencil, X } from 'lucide-react'
8
+ import { useQuery, useQueryClient } from '@tanstack/react-query'
9
+ import { Button } from '@open-mercato/ui/primitives/button'
10
+ import { DataLoader } from '@open-mercato/ui/primitives/DataLoader'
11
+ import type { CrudField } from '@open-mercato/ui/backend/CrudForm'
12
+ import { CrudForm } from '@open-mercato/ui/backend/CrudForm'
13
+ import { fetchCustomFieldFormFieldsWithDefinitions } from '@open-mercato/ui/backend/utils/customFieldForms'
14
+ import type { CustomFieldDefDto } from '@open-mercato/ui/backend/utils/customFieldDefs'
15
+ import {
16
+ DictionaryValue,
17
+ type DictionaryMap,
18
+ } from '@open-mercato/core/modules/dictionaries/components/dictionaryAppearance'
19
+ import { ensureDictionaryEntries } from '@open-mercato/core/modules/dictionaries/components/hooks/useDictionaryEntries'
20
+ import { useOrganizationScopeVersion } from '@open-mercato/shared/lib/frontend/useOrganizationScope'
21
+ import { cn } from '@open-mercato/shared/lib/utils'
22
+
23
+ type MarkdownPreviewProps = { children: string; className?: string; remarkPlugins?: PluggableList }
24
+
25
+ const isTestEnv = typeof process !== 'undefined' && process.env.NODE_ENV === 'test'
26
+
27
+ const MarkdownPreview: React.ComponentType<MarkdownPreviewProps> = isTestEnv
28
+ ? ({ children, className }) => <div className={className}>{children}</div>
29
+ : (dynamic(() => import('react-markdown').then((mod) => mod.default as React.ComponentType<MarkdownPreviewProps>), {
30
+ ssr: false,
31
+ loading: () => null,
32
+ }) as unknown as React.ComponentType<MarkdownPreviewProps>)
33
+
34
+ let markdownPluginsPromise: Promise<PluggableList> | null = null
35
+
36
+ async function loadMarkdownPlugins(): Promise<PluggableList> {
37
+ if (isTestEnv) return []
38
+ if (!markdownPluginsPromise) {
39
+ markdownPluginsPromise = import('remark-gfm')
40
+ .then((mod) => [mod.default ?? mod] as PluggableList)
41
+ .catch(() => [])
42
+ }
43
+ return markdownPluginsPromise
44
+ }
45
+
46
+ const MARKDOWN_FIELD_TYPES = new Set<CrudField['type']>(['text', 'textarea', 'richtext'])
47
+ const MARKDOWN_CLASSNAME =
48
+ 'text-sm text-foreground break-words [&>*]:mb-2 [&>*:last-child]:mb-0 [&_ul]:ml-4 [&_ul]:list-disc [&_ol]:ml-4 [&_ol]:list-decimal [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_pre]:text-xs'
49
+
50
+ function renderMarkdownValue(content: string, remarkPlugins: PluggableList) {
51
+ return (
52
+ <MarkdownPreview remarkPlugins={remarkPlugins} className={MARKDOWN_CLASSNAME}>
53
+ {content}
54
+ </MarkdownPreview>
55
+ )
56
+ }
57
+
58
+ function extractDictionaryValue(entry: unknown): string | null {
59
+ if (typeof entry === 'string') {
60
+ const trimmed = entry.trim()
61
+ return trimmed.length ? trimmed : null
62
+ }
63
+ if (!entry || typeof entry !== 'object') return null
64
+ const record = entry as Record<string, unknown>
65
+ const candidate = record.value ?? record.name ?? record.id ?? record.key ?? record.label
66
+ if (typeof candidate === 'string') {
67
+ const trimmed = candidate.trim()
68
+ return trimmed.length ? trimmed : null
69
+ }
70
+ return null
71
+ }
72
+
73
+ export type CustomDataLabels = {
74
+ loading: string
75
+ emptyValue: string
76
+ noFields: string
77
+ defineFields?: string
78
+ saveShortcut: string
79
+ edit?: string
80
+ cancel?: string
81
+ }
82
+
83
+ export type CustomDataSectionProps = {
84
+ entityId?: string
85
+ entityIds?: string[]
86
+ values: Record<string, unknown>
87
+ onSubmit: (values: Record<string, unknown>) => Promise<void>
88
+ title: string
89
+ scopeVersion?: string | number | null
90
+ loadFields?: (
91
+ entityIds: string[],
92
+ ) => Promise<{ fields: CrudField[]; definitions: CustomFieldDefDto[] }>
93
+ labels: CustomDataLabels
94
+ definitionHref?: string
95
+ }
96
+
97
+ function formatFieldValue(
98
+ field: CrudField,
99
+ value: unknown,
100
+ emptyLabel: string,
101
+ dictionaryMap?: DictionaryMap,
102
+ remarkPlugins: PluggableList = [],
103
+ ): React.ReactNode {
104
+ if (dictionaryMap) {
105
+ if (value === undefined || value === null || value === '') {
106
+ return <span className="text-muted-foreground">{emptyLabel}</span>
107
+ }
108
+
109
+ if (Array.isArray(value)) {
110
+ const normalizedValues = value
111
+ .map((entry) => extractDictionaryValue(entry))
112
+ .filter((entry): entry is string => typeof entry === 'string' && entry.length > 0)
113
+
114
+ if (!normalizedValues.length) {
115
+ return <span className="text-muted-foreground">{emptyLabel}</span>
116
+ }
117
+
118
+ return (
119
+ <div className="flex flex-wrap gap-1.5">
120
+ {normalizedValues.map((entry, index) => (
121
+ <DictionaryValue
122
+ key={`${field.id}-${entry}-${index}`}
123
+ value={entry}
124
+ map={dictionaryMap}
125
+ className="inline-flex items-center gap-1 rounded-full border border-border bg-card px-2 py-1 text-xs"
126
+ iconWrapperClassName="inline-flex h-4 w-4 items-center justify-center rounded-full border border-border bg-background"
127
+ iconClassName="h-3 w-3"
128
+ colorClassName="h-2.5 w-2.5 rounded-full"
129
+ />
130
+ ))}
131
+ </div>
132
+ )
133
+ }
134
+
135
+ const resolved = extractDictionaryValue(value)
136
+ if (!resolved) {
137
+ return <span className="text-muted-foreground">{emptyLabel}</span>
138
+ }
139
+
140
+ return (
141
+ <DictionaryValue
142
+ value={resolved}
143
+ map={dictionaryMap}
144
+ className="inline-flex items-center gap-2 text-sm"
145
+ fallback={<span className="text-muted-foreground">{emptyLabel}</span>}
146
+ iconWrapperClassName="inline-flex h-6 w-6 items-center justify-center rounded border border-border bg-card"
147
+ iconClassName="h-4 w-4"
148
+ colorClassName="h-3 w-3 rounded-full"
149
+ />
150
+ )
151
+ }
152
+
153
+ const optionMap =
154
+ 'options' in field && Array.isArray(field.options)
155
+ ? field.options.reduce<Map<string, string>>((acc, option) => {
156
+ acc.set(option.value, option.label)
157
+ return acc
158
+ }, new Map())
159
+ : null
160
+
161
+ const resolveOptionLabel = (entry: unknown): string => {
162
+ if (entry && typeof entry === 'object') {
163
+ const record = entry as { label?: unknown; value?: unknown; name?: unknown }
164
+ const candidateLabel = record.label
165
+ if (typeof candidateLabel === 'string' && candidateLabel.trim().length) {
166
+ return candidateLabel.trim()
167
+ }
168
+ const candidateValue = record.value ?? record.name
169
+ if (typeof candidateValue === 'string' && candidateValue.trim().length) {
170
+ const normalized = candidateValue.trim()
171
+ return optionMap?.get(normalized) ?? normalized
172
+ }
173
+ }
174
+ if (entry === undefined || entry === null) return ''
175
+ const normalized = String(entry)
176
+ if (!normalized.length) return ''
177
+ return optionMap?.get(normalized) ?? normalized
178
+ }
179
+
180
+ if (value === undefined || value === null || value === '') {
181
+ return <span className="text-muted-foreground">{emptyLabel}</span>
182
+ }
183
+
184
+ if (Array.isArray(value)) {
185
+ if (!value.length) return <span className="text-muted-foreground">{emptyLabel}</span>
186
+ return value.map((entry, index) => (
187
+ <span
188
+ key={`${field.id}-${index}`}
189
+ className="mr-1 inline-flex items-center rounded-full bg-muted px-2 py-0.5 text-xs"
190
+ >
191
+ {resolveOptionLabel(entry) || emptyLabel}
192
+ </span>
193
+ ))
194
+ }
195
+
196
+ if (typeof value === 'boolean') {
197
+ return value ? 'Yes' : 'No'
198
+ }
199
+
200
+ if (typeof value === 'object') {
201
+ try {
202
+ return JSON.stringify(value)
203
+ } catch {
204
+ return String(value)
205
+ }
206
+ }
207
+
208
+ const resolved = resolveOptionLabel(value)
209
+ if (typeof value === 'string' && MARKDOWN_FIELD_TYPES.has(field.type)) {
210
+ if (!resolved.trim().length) {
211
+ return <span className="text-muted-foreground">{emptyLabel}</span>
212
+ }
213
+ return renderMarkdownValue(value, remarkPlugins)
214
+ }
215
+ if (!resolved.length) return <span className="text-muted-foreground">{emptyLabel}</span>
216
+ return resolved
217
+ }
218
+
219
+ export function CustomDataSection({
220
+ entityId,
221
+ entityIds,
222
+ values,
223
+ onSubmit,
224
+ title,
225
+ scopeVersion: scopeVersionProp,
226
+ loadFields,
227
+ labels,
228
+ definitionHref: explicitDefinitionHref,
229
+ }: CustomDataSectionProps) {
230
+ const queryClient = useQueryClient()
231
+ const defaultScopeVersion = useOrganizationScopeVersion()
232
+ const scopeVersion = scopeVersionProp ?? defaultScopeVersion
233
+ const resolvedScopeVersion = React.useMemo(
234
+ () => (typeof scopeVersion === 'number' ? scopeVersion : Number(scopeVersion) || 0),
235
+ [scopeVersion],
236
+ )
237
+ const [dictionaryMapsByField, setDictionaryMapsByField] = React.useState<Record<string, DictionaryMap>>({})
238
+ const [editing, setEditing] = React.useState(false)
239
+ const sectionRef = React.useRef<HTMLDivElement | null>(null)
240
+ const [markdownPlugins, setMarkdownPlugins] = React.useState<PluggableList>([])
241
+ React.useEffect(() => {
242
+ if (isTestEnv) return
243
+ let mounted = true
244
+ void loadMarkdownPlugins().then((plugins) => {
245
+ if (!mounted) return
246
+ setMarkdownPlugins(plugins)
247
+ })
248
+ return () => {
249
+ mounted = false
250
+ }
251
+ }, [])
252
+ const resolvedEntityIds = React.useMemo(() => {
253
+ if (Array.isArray(entityIds) && entityIds.length) {
254
+ const dedup = new Set<string>()
255
+ const list: string[] = []
256
+ entityIds.forEach((id) => {
257
+ const trimmed = typeof id === 'string' ? id.trim() : ''
258
+ if (!trimmed || dedup.has(trimmed)) return
259
+ dedup.add(trimmed)
260
+ list.push(trimmed)
261
+ })
262
+ return list
263
+ }
264
+ if (typeof entityId === 'string' && entityId.trim().length > 0) {
265
+ return [entityId.trim()]
266
+ }
267
+ return []
268
+ }, [entityId, entityIds])
269
+ const primaryEntityId = resolvedEntityIds.length ? resolvedEntityIds[0] : undefined
270
+ const customFieldFormsQuery = useQuery({
271
+ queryKey: ['customFieldForms', resolvedScopeVersion, ...resolvedEntityIds],
272
+ enabled: resolvedEntityIds.length > 0,
273
+ staleTime: 5 * 60 * 1000,
274
+ gcTime: 30 * 60 * 1000,
275
+ queryFn: async () => {
276
+ const loader = loadFields ?? fetchCustomFieldFormFieldsWithDefinitions
277
+ return loader(resolvedEntityIds)
278
+ },
279
+ })
280
+ const fields = React.useMemo(() => customFieldFormsQuery.data?.fields ?? [], [customFieldFormsQuery.data])
281
+ const definitions = React.useMemo(
282
+ () => customFieldFormsQuery.data?.definitions ?? [],
283
+ [customFieldFormsQuery.data],
284
+ )
285
+ const [dictionaryLoading, setDictionaryLoading] = React.useState(false)
286
+ const loading = customFieldFormsQuery.isLoading || dictionaryLoading
287
+ const hasFields = fields.length > 0
288
+ const definitionHref = explicitDefinitionHref ?? (primaryEntityId
289
+ ? `/backend/entities/system/${encodeURIComponent(primaryEntityId)}`
290
+ : undefined)
291
+
292
+ React.useEffect(() => {
293
+ if (!hasFields && editing) {
294
+ setEditing(false)
295
+ }
296
+ }, [editing, hasFields])
297
+
298
+ const submitActiveForm = React.useCallback(() => {
299
+ const node = sectionRef.current?.querySelector('form')
300
+ if (!node) return
301
+ const form = node as HTMLFormElement
302
+ if (typeof form.requestSubmit === 'function') {
303
+ form.requestSubmit()
304
+ return
305
+ }
306
+ form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
307
+ }, [])
308
+
309
+ const handleEditingKeyDown = React.useCallback(
310
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
311
+ if (!editing) return
312
+ if (event.key === 'Escape') {
313
+ event.preventDefault()
314
+ setEditing(false)
315
+ return
316
+ }
317
+ if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
318
+ event.preventDefault()
319
+ submitActiveForm()
320
+ }
321
+ },
322
+ [editing, submitActiveForm],
323
+ )
324
+
325
+ const handleActivate = React.useCallback(() => {
326
+ if (loading || editing || !hasFields) return
327
+ setEditing(true)
328
+ }, [editing, hasFields, loading])
329
+
330
+ const handleReadOnlyKeyDown = React.useCallback(
331
+ (event: React.KeyboardEvent<HTMLDivElement>) => {
332
+ if (loading || editing || !hasFields) return
333
+ if (event.key === 'Enter' || event.key === ' ') {
334
+ event.preventDefault()
335
+ setEditing(true)
336
+ }
337
+ },
338
+ [editing, hasFields, loading],
339
+ )
340
+
341
+ React.useEffect(() => {
342
+ if (!resolvedEntityIds.length || !definitions.length) {
343
+ setDictionaryLoading((prev) => (prev ? false : prev))
344
+ setDictionaryMapsByField((prev) => (Object.keys(prev).length ? {} : prev))
345
+ return
346
+ }
347
+
348
+ let cancelled = false
349
+ const load = async () => {
350
+ setDictionaryLoading(true)
351
+ try {
352
+ const dictionaryDefs = definitions
353
+ .map((def) => {
354
+ const rawId = typeof def.dictionaryId === 'string' ? def.dictionaryId.trim() : ''
355
+ if (!rawId) return null
356
+ return { keyLower: def.key.toLowerCase(), dictionaryId: rawId }
357
+ })
358
+ .filter((entry): entry is { keyLower: string; dictionaryId: string } => !!entry)
359
+
360
+ if (!dictionaryDefs.length) {
361
+ if (!cancelled) {
362
+ setDictionaryMapsByField((prev) => (Object.keys(prev).length ? {} : prev))
363
+ }
364
+ return
365
+ }
366
+
367
+ const uniqueDictionaryIds = Array.from(new Set(dictionaryDefs.map((entry) => entry.dictionaryId)))
368
+ const mapsByDictionaryId: Record<string, DictionaryMap> = {}
369
+
370
+ await Promise.all(
371
+ uniqueDictionaryIds.map(async (dictionaryId) => {
372
+ try {
373
+ const data = await ensureDictionaryEntries(queryClient, dictionaryId, resolvedScopeVersion)
374
+ mapsByDictionaryId[dictionaryId] = data.map
375
+ } catch {
376
+ mapsByDictionaryId[dictionaryId] = {}
377
+ }
378
+ }),
379
+ )
380
+
381
+ const dictionaryByKey = dictionaryDefs.reduce<Map<string, string>>((acc, entry) => {
382
+ acc.set(entry.keyLower, entry.dictionaryId)
383
+ return acc
384
+ }, new Map())
385
+
386
+ const nextMaps: Record<string, DictionaryMap> = {}
387
+ fields.forEach((field) => {
388
+ const id = typeof field.id === 'string' ? field.id : ''
389
+ if (!id) return
390
+ const normalizedKey = id.startsWith('cf_') ? id.slice(3) : id
391
+ const keyLower = normalizedKey.toLowerCase()
392
+ if (!keyLower) return
393
+ const dictionaryId = dictionaryByKey.get(keyLower)
394
+ if (!dictionaryId) return
395
+ nextMaps[id] = mapsByDictionaryId[dictionaryId] ?? {}
396
+ })
397
+
398
+ if (!cancelled) {
399
+ setDictionaryMapsByField((prev) => {
400
+ const prevKeys = Object.keys(prev)
401
+ const nextKeys = Object.keys(nextMaps)
402
+ if (
403
+ prevKeys.length === nextKeys.length &&
404
+ prevKeys.every((key) => prev[key] === nextMaps[key])
405
+ ) {
406
+ return prev
407
+ }
408
+ return nextMaps
409
+ })
410
+ }
411
+ } catch {
412
+ if (!cancelled) {
413
+ setDictionaryMapsByField((prev) => (Object.keys(prev).length ? {} : prev))
414
+ }
415
+ } finally {
416
+ if (!cancelled) {
417
+ setDictionaryLoading(false)
418
+ }
419
+ }
420
+ }
421
+
422
+ load().catch(() => {})
423
+ return () => {
424
+ cancelled = true
425
+ }
426
+ }, [definitions, fields, queryClient, resolvedEntityIds, resolvedScopeVersion])
427
+
428
+ const handleSubmit = React.useCallback(
429
+ async (input: Record<string, unknown>) => {
430
+ await onSubmit(input)
431
+ setEditing(false)
432
+ },
433
+ [onSubmit],
434
+ )
435
+
436
+ return (
437
+ <div className="space-y-3">
438
+ <div className="flex items-center justify-between group">
439
+ <h2 className="text-sm font-semibold">{title}</h2>
440
+ <Button
441
+ type="button"
442
+ variant="ghost"
443
+ size="icon"
444
+ onClick={() => {
445
+ if (!hasFields || loading) return
446
+ setEditing((prev) => !prev)
447
+ }}
448
+ disabled={loading || !hasFields}
449
+ className={
450
+ editing
451
+ ? 'opacity-100 transition-opacity duration-150'
452
+ : 'opacity-0 transition-opacity duration-150 group-hover:opacity-100 focus-visible:opacity-100'
453
+ }
454
+ >
455
+ {editing ? <X className="h-4 w-4" /> : <Pencil className="h-4 w-4" />}
456
+ <span className="sr-only">{editing ? labels.cancel ?? 'Cancel' : labels.edit ?? 'Edit'}</span>
457
+ </Button>
458
+ </div>
459
+ <DataLoader
460
+ isLoading={loading}
461
+ loadingMessage={labels.loading}
462
+ spinnerSize="md"
463
+ className="min-h-[120px]"
464
+ >
465
+ {editing ? (
466
+ <div
467
+ ref={sectionRef}
468
+ className="rounded-lg border bg-card p-4"
469
+ onKeyDown={handleEditingKeyDown}
470
+ >
471
+ <CrudForm<Record<string, unknown>>
472
+ embedded
473
+ entityId={primaryEntityId}
474
+ entityIds={resolvedEntityIds}
475
+ fields={fields}
476
+ initialValues={values}
477
+ onSubmit={handleSubmit}
478
+ submitLabel={labels.saveShortcut}
479
+ isLoading={loading}
480
+ />
481
+ </div>
482
+ ) : (
483
+ <div
484
+ className={cn(
485
+ 'rounded-lg border bg-muted/20 p-4 space-y-3 transition hover:border-border/80 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
486
+ hasFields && !loading ? 'cursor-pointer' : 'cursor-default',
487
+ )}
488
+ role={hasFields && !loading ? 'button' : undefined}
489
+ tabIndex={hasFields && !loading ? 0 : -1}
490
+ onClick={hasFields && !loading ? handleActivate : undefined}
491
+ onKeyDown={hasFields && !loading ? handleReadOnlyKeyDown : undefined}
492
+ >
493
+ {!hasFields ? (
494
+ <p className="text-sm text-muted-foreground">
495
+ {labels.noFields}{' '}
496
+ {definitionHref && labels.defineFields ? (
497
+ <Link
498
+ href={definitionHref}
499
+ className="font-medium text-primary underline-offset-2 hover:underline focus-visible:underline"
500
+ >
501
+ {labels.defineFields}
502
+ </Link>
503
+ ) : null}
504
+ </p>
505
+ ) : (
506
+ fields.map((field) => (
507
+ <div key={field.id} className="space-y-1">
508
+ <p className="text-xs uppercase tracking-wide text-muted-foreground">
509
+ {field.label}
510
+ </p>
511
+ <div className="text-sm break-words">
512
+ {formatFieldValue(
513
+ field,
514
+ values?.[field.id],
515
+ labels.emptyValue,
516
+ dictionaryMapsByField[field.id],
517
+ markdownPlugins,
518
+ )}
519
+ </div>
520
+ </div>
521
+ ))
522
+ )}
523
+ </div>
524
+ )}
525
+ </DataLoader>
526
+ </div>
527
+ )
528
+ }
529
+
530
+ export default CustomDataSection
@@ -0,0 +1,147 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import {
5
+ InlineMultilineEditor,
6
+ InlineSelectEditor,
7
+ InlineTextEditor,
8
+ type InlineSelectOption,
9
+ type InlineTextEditorProps,
10
+ type InlineMultilineEditorProps,
11
+ } from './InlineEditors'
12
+
13
+ type EditorVariant = 'default' | 'muted' | 'plain'
14
+
15
+ type DetailFieldCommon = {
16
+ key: string
17
+ label: string
18
+ emptyLabel: string
19
+ gridClassName?: string
20
+ editorVariant?: EditorVariant
21
+ activateOnClick?: boolean
22
+ containerClassName?: string
23
+ triggerClassName?: string
24
+ }
25
+
26
+ export type DetailTextFieldConfig = DetailFieldCommon & {
27
+ kind: 'text'
28
+ value: string | null | undefined
29
+ placeholder?: string
30
+ onSave: (value: string | null) => Promise<void>
31
+ inputType?: React.HTMLInputTypeAttribute
32
+ validator?: (value: string) => string | null
33
+ hideLabel?: boolean
34
+ renderDisplay?: InlineTextEditorProps['renderDisplay']
35
+ }
36
+
37
+ export type DetailMultilineFieldConfig = DetailFieldCommon & {
38
+ kind: 'multiline'
39
+ value: string | null | undefined
40
+ placeholder?: string
41
+ onSave: (value: string | null) => Promise<void>
42
+ validator?: (value: string) => string | null
43
+ renderDisplay?: InlineMultilineEditorProps['renderDisplay']
44
+ }
45
+
46
+ export type DetailSelectFieldConfig = DetailFieldCommon & {
47
+ kind: 'select'
48
+ value: string | null | undefined
49
+ onSave: (value: string | null) => Promise<void>
50
+ options: InlineSelectOption[]
51
+ }
52
+
53
+ export type DetailCustomFieldConfig = DetailFieldCommon & {
54
+ kind: 'custom'
55
+ render: () => React.ReactNode
56
+ }
57
+
58
+ export type DetailFieldConfig =
59
+ | DetailTextFieldConfig
60
+ | DetailMultilineFieldConfig
61
+ | DetailSelectFieldConfig
62
+ | DetailCustomFieldConfig
63
+
64
+ export type DetailFieldsSectionProps = {
65
+ fields: DetailFieldConfig[]
66
+ className?: string
67
+ }
68
+
69
+ export function DetailFieldsSection({ fields, className }: DetailFieldsSectionProps) {
70
+ return (
71
+ <div className={['grid gap-4 sm:grid-cols-2 xl:grid-cols-3', className].filter(Boolean).join(' ')}>
72
+ {fields.map((field) => {
73
+ const variant = field.editorVariant ?? 'muted'
74
+ const activateOnClick = field.activateOnClick ?? true
75
+ const containerClassName = field.containerClassName ?? undefined
76
+ const triggerClassName = field.triggerClassName ?? undefined
77
+ const wrapperClassName = field.gridClassName ?? undefined
78
+
79
+ if (field.kind === 'text') {
80
+ return (
81
+ <div key={field.key} className={wrapperClassName}>
82
+ <InlineTextEditor
83
+ label={field.label}
84
+ value={field.value}
85
+ placeholder={field.placeholder}
86
+ emptyLabel={field.emptyLabel}
87
+ onSave={field.onSave}
88
+ inputType={field.inputType}
89
+ validator={field.validator}
90
+ variant={variant}
91
+ activateOnClick={activateOnClick}
92
+ containerClassName={containerClassName}
93
+ triggerClassName={triggerClassName}
94
+ hideLabel={field.hideLabel}
95
+ renderDisplay={field.renderDisplay}
96
+ />
97
+ </div>
98
+ )
99
+ }
100
+
101
+ if (field.kind === 'multiline') {
102
+ return (
103
+ <div key={field.key} className={wrapperClassName}>
104
+ <InlineMultilineEditor
105
+ label={field.label}
106
+ value={field.value}
107
+ placeholder={field.placeholder}
108
+ emptyLabel={field.emptyLabel}
109
+ onSave={field.onSave}
110
+ validator={field.validator}
111
+ variant={variant === 'plain' ? 'default' : variant}
112
+ activateOnClick={activateOnClick}
113
+ containerClassName={containerClassName}
114
+ triggerClassName={triggerClassName}
115
+ renderDisplay={field.renderDisplay}
116
+ />
117
+ </div>
118
+ )
119
+ }
120
+
121
+ if (field.kind === 'select') {
122
+ return (
123
+ <div key={field.key} className={wrapperClassName}>
124
+ <InlineSelectEditor
125
+ label={field.label}
126
+ value={field.value}
127
+ emptyLabel={field.emptyLabel}
128
+ onSave={field.onSave}
129
+ options={field.options}
130
+ variant={variant}
131
+ activateOnClick={activateOnClick}
132
+ containerClassName={containerClassName}
133
+ triggerClassName={triggerClassName}
134
+ />
135
+ </div>
136
+ )
137
+ }
138
+
139
+ return (
140
+ <div key={field.key} className={wrapperClassName}>
141
+ {field.render()}
142
+ </div>
143
+ )
144
+ })}
145
+ </div>
146
+ )
147
+ }