@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,128 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import dynamic from 'next/dynamic'
5
+ import type { PluggableList } from 'unified'
6
+ import { LoadingMessage } from '../detail/LoadingMessage'
7
+
8
+ export type SwitchableMarkdownInputProps = {
9
+ value: string
10
+ onChange: (value: string) => void
11
+ isMarkdownEnabled: boolean
12
+ disableMarkdown?: boolean
13
+ height?: number
14
+ placeholder?: string
15
+ rows?: number
16
+ textareaRef?: React.Ref<HTMLTextAreaElement>
17
+ onTextareaInput?: React.FormEventHandler<HTMLTextAreaElement>
18
+ textareaClassName?: string
19
+ editorWrapperClassName?: string
20
+ editorClassName?: string
21
+ disabled?: boolean
22
+ remarkPlugins?: PluggableList
23
+ }
24
+
25
+ type UiMarkdownEditorProps = {
26
+ value?: string
27
+ height?: number
28
+ onChange?: (value?: string) => void
29
+ previewOptions?: { remarkPlugins?: unknown[] }
30
+ }
31
+
32
+ const isTestEnv = typeof process !== 'undefined' && process.env.NODE_ENV === 'test'
33
+
34
+ const MarkdownEditorTestStub: React.ComponentType<UiMarkdownEditorProps> = ({ value, onChange }) => (
35
+ <textarea
36
+ className="min-h-[160px] w-full rounded border px-3 py-2 text-sm"
37
+ value={value ?? ''}
38
+ onChange={(event) => onChange?.(event.target.value)}
39
+ />
40
+ )
41
+
42
+ const UiMarkdownEditor = isTestEnv
43
+ ? MarkdownEditorTestStub
44
+ : (dynamic(() => import('@uiw/react-md-editor'), {
45
+ ssr: false,
46
+ loading: () => (
47
+ <LoadingMessage
48
+ label="Loading editor..."
49
+ className="min-h-[220px] justify-center"
50
+ />
51
+ ),
52
+ }) as unknown as React.ComponentType<UiMarkdownEditorProps>)
53
+
54
+ let markdownPluginsPromise: Promise<PluggableList> | null = null
55
+
56
+ async function loadMarkdownPlugins(): Promise<PluggableList> {
57
+ if (isTestEnv) return []
58
+ if (!markdownPluginsPromise) {
59
+ markdownPluginsPromise = import('remark-gfm')
60
+ .then((mod) => [mod.default ?? mod] as PluggableList)
61
+ .catch(() => [])
62
+ }
63
+ return markdownPluginsPromise
64
+ }
65
+
66
+ export function SwitchableMarkdownInput({
67
+ value,
68
+ onChange,
69
+ isMarkdownEnabled,
70
+ disableMarkdown,
71
+ height = 220,
72
+ placeholder,
73
+ rows = 3,
74
+ textareaRef,
75
+ onTextareaInput,
76
+ textareaClassName,
77
+ editorWrapperClassName,
78
+ editorClassName,
79
+ disabled,
80
+ remarkPlugins,
81
+ }: SwitchableMarkdownInputProps) {
82
+ const [localPlugins, setLocalPlugins] = React.useState<PluggableList>([])
83
+
84
+ React.useEffect(() => {
85
+ if (remarkPlugins) return
86
+ let active = true
87
+ void loadMarkdownPlugins().then((plugins) => {
88
+ if (active) setLocalPlugins(plugins)
89
+ })
90
+ return () => { active = false }
91
+ }, [remarkPlugins])
92
+
93
+ const resolvedPlugins = remarkPlugins ?? localPlugins
94
+ const editorWrapperClasses =
95
+ editorWrapperClassName ?? 'w-full rounded-lg border border-muted-foreground/20 bg-background p-2'
96
+ const editorClasses = editorClassName ?? 'w-full'
97
+ const textareaClasses =
98
+ textareaClassName
99
+ ?? 'w-full resize-none overflow-hidden rounded-lg border border-muted-foreground/20 bg-background px-3 py-2 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary'
100
+
101
+ if (isMarkdownEnabled && !disableMarkdown) {
102
+ return (
103
+ <div className={editorWrapperClasses}>
104
+ <div data-color-mode="light" className={editorClasses}>
105
+ <UiMarkdownEditor
106
+ value={value}
107
+ height={height}
108
+ onChange={(nextValue) => onChange(typeof nextValue === 'string' ? nextValue : '')}
109
+ previewOptions={resolvedPlugins.length ? { remarkPlugins: resolvedPlugins } : undefined}
110
+ />
111
+ </div>
112
+ </div>
113
+ )
114
+ }
115
+
116
+ return (
117
+ <textarea
118
+ ref={textareaRef}
119
+ rows={rows}
120
+ className={textareaClasses}
121
+ placeholder={placeholder}
122
+ value={value}
123
+ onChange={(event) => onChange(event.target.value)}
124
+ onInput={onTextareaInput}
125
+ disabled={disabled}
126
+ />
127
+ )
128
+ }
@@ -0,0 +1,259 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+
6
+ export type TagsInputOption = {
7
+ value: string
8
+ label: string
9
+ description?: string | null
10
+ }
11
+
12
+ export type TagsInputProps = {
13
+ value: string[]
14
+ onChange: (next: string[]) => void
15
+ placeholder?: string
16
+ suggestions?: Array<string | TagsInputOption>
17
+ loadSuggestions?: (query?: string) => Promise<Array<string | TagsInputOption>>
18
+ selectedOptions?: TagsInputOption[]
19
+ resolveLabel?: (value: string) => string
20
+ resolveDescription?: (value: string) => string | null | undefined
21
+ autoFocus?: boolean
22
+ disabled?: boolean
23
+ allowCustomValues?: boolean
24
+ }
25
+
26
+ function normalizeOptions(input?: Array<string | TagsInputOption>): TagsInputOption[] {
27
+ if (!Array.isArray(input)) return []
28
+ return input
29
+ .map((option) => {
30
+ if (typeof option === 'string') {
31
+ const trimmed = option.trim()
32
+ if (!trimmed) return null
33
+ return { value: trimmed, label: trimmed }
34
+ }
35
+ const value = typeof option.value === 'string' ? option.value.trim() : ''
36
+ if (!value) return null
37
+ return {
38
+ value,
39
+ label: option.label?.trim() || value,
40
+ description: option.description ?? null,
41
+ }
42
+ })
43
+ .filter((option): option is TagsInputOption => !!option)
44
+ }
45
+
46
+ export function TagsInput({
47
+ value,
48
+ onChange,
49
+ placeholder,
50
+ suggestions,
51
+ loadSuggestions,
52
+ selectedOptions,
53
+ resolveLabel,
54
+ resolveDescription,
55
+ autoFocus,
56
+ disabled = false,
57
+ allowCustomValues = true,
58
+ }: TagsInputProps) {
59
+ const t = useT()
60
+ const [input, setInput] = React.useState('')
61
+ const [asyncOptions, setAsyncOptions] = React.useState<TagsInputOption[]>([])
62
+ const [loading, setLoading] = React.useState(false)
63
+ const [touched, setTouched] = React.useState(false)
64
+
65
+ const staticOptions = React.useMemo(() => normalizeOptions(suggestions), [suggestions])
66
+ const selectedOptionList = React.useMemo(
67
+ () => normalizeOptions(selectedOptions),
68
+ [selectedOptions]
69
+ )
70
+
71
+ const optionMap = React.useMemo(() => {
72
+ const map = new Map<string, TagsInputOption>()
73
+ const register = (option: TagsInputOption) => {
74
+ if (!map.has(option.value)) {
75
+ map.set(option.value, option)
76
+ }
77
+ }
78
+ staticOptions.forEach(register)
79
+ asyncOptions.forEach(register)
80
+ selectedOptionList.forEach(register)
81
+ value.forEach((val) => {
82
+ if (map.has(val)) return
83
+ map.set(val, {
84
+ value: val,
85
+ label: resolveLabel?.(val) ?? val,
86
+ description: resolveDescription?.(val) ?? null,
87
+ })
88
+ })
89
+ return map
90
+ }, [asyncOptions, resolveDescription, resolveLabel, selectedOptionList, staticOptions, value])
91
+
92
+ const availableOptions = React.useMemo(() => {
93
+ return Array.from(optionMap.values()).filter((option) => !value.includes(option.value))
94
+ }, [optionMap, value])
95
+
96
+ const filteredSuggestions = React.useMemo(() => {
97
+ const query = input.toLowerCase().trim()
98
+ if (!query) return availableOptions.slice(0, 8)
99
+ return availableOptions.filter((option) => {
100
+ const labelMatch = option.label.toLowerCase().includes(query)
101
+ const descMatch = option.description?.toLowerCase().includes(query)
102
+ return labelMatch || Boolean(descMatch)
103
+ })
104
+ }, [availableOptions, input])
105
+
106
+ React.useEffect(() => {
107
+ if (!loadSuggestions || !touched || disabled) return
108
+ const query = input.trim()
109
+ let cancelled = false
110
+ const handle = window.setTimeout(async () => {
111
+ setLoading(true)
112
+ try {
113
+ const items = await loadSuggestions(query)
114
+ if (!cancelled) {
115
+ setAsyncOptions(normalizeOptions(items))
116
+ }
117
+ } finally {
118
+ if (!cancelled) setLoading(false)
119
+ }
120
+ }, 200)
121
+ return () => {
122
+ cancelled = true
123
+ window.clearTimeout(handle)
124
+ }
125
+ }, [disabled, input, loadSuggestions, touched])
126
+
127
+ const addValue = React.useCallback(
128
+ (nextValue: string) => {
129
+ if (disabled) return
130
+ const trimmed = nextValue.trim()
131
+ if (!trimmed) return
132
+ if (value.includes(trimmed)) return
133
+ onChange([...value, trimmed])
134
+ },
135
+ [disabled, onChange, value]
136
+ )
137
+
138
+ const findOptionForInput = React.useCallback(
139
+ (raw: string): TagsInputOption | null => {
140
+ const query = raw.trim().toLowerCase()
141
+ if (!query) return null
142
+ for (const option of optionMap.values()) {
143
+ if (option.value === raw.trim()) return option
144
+ if (option.label.toLowerCase() === query) return option
145
+ }
146
+ return null
147
+ },
148
+ [optionMap]
149
+ )
150
+
151
+ const addTag = React.useCallback(
152
+ (raw: string) => {
153
+ if (disabled) return
154
+ const option = findOptionForInput(raw)
155
+ if (option) {
156
+ addValue(option.value)
157
+ return
158
+ }
159
+ if (!allowCustomValues) return
160
+ addValue(raw)
161
+ },
162
+ [addValue, allowCustomValues, disabled, findOptionForInput]
163
+ )
164
+
165
+ const removeTag = React.useCallback(
166
+ (tag: string) => {
167
+ if (disabled) return
168
+ onChange(value.filter((candidate) => candidate !== tag))
169
+ },
170
+ [disabled, onChange, value]
171
+ )
172
+
173
+ return (
174
+ <div
175
+ className={[
176
+ 'w-full rounded border px-2 py-1',
177
+ disabled ? 'bg-muted text-muted-foreground/80 cursor-not-allowed' : '',
178
+ ]
179
+ .filter(Boolean)
180
+ .join(' ')}
181
+ aria-disabled={disabled || undefined}
182
+ >
183
+ <div className="flex flex-wrap gap-1">
184
+ {value.map((tag) => {
185
+ const option = optionMap.get(tag)
186
+ const label = option?.label ?? tag
187
+ const description = option?.description
188
+ return (
189
+ <span key={tag} className="inline-flex items-center gap-2 rounded-sm bg-muted px-2 py-0.5 text-xs">
190
+ <span className="flex flex-col items-start leading-tight">
191
+ <span className="whitespace-nowrap">{label}</span>
192
+ {description ? (
193
+ <span className="text-[10px] text-muted-foreground">{description}</span>
194
+ ) : null}
195
+ </span>
196
+ <button
197
+ type="button"
198
+ className="opacity-60 transition-opacity hover:opacity-100"
199
+ onClick={() => removeTag(tag)}
200
+ disabled={disabled}
201
+ >
202
+ Ă—
203
+ </button>
204
+ </span>
205
+ )
206
+ })}
207
+ <input
208
+ className="flex-1 min-w-[120px] border-0 py-1 text-sm outline-none disabled:bg-transparent"
209
+ value={input}
210
+ placeholder={placeholder || t('ui.inputs.tagsInput.placeholder', 'Add tag and press Enter')}
211
+ autoFocus={autoFocus}
212
+ data-crud-focus-target=""
213
+ disabled={disabled}
214
+ onFocus={() => setTouched(true)}
215
+ onChange={(event) => {
216
+ setTouched(true)
217
+ setInput(event.target.value)
218
+ }}
219
+ onKeyDown={(event) => {
220
+ if (disabled) return
221
+ if (event.key === 'Enter' || event.key === ',') {
222
+ event.preventDefault()
223
+ addTag(input)
224
+ setInput('')
225
+ } else if (event.key === 'Backspace' && input === '' && value.length > 0) {
226
+ removeTag(value[value.length - 1])
227
+ }
228
+ }}
229
+ onBlur={() => {
230
+ if (disabled) return
231
+ addTag(input)
232
+ setInput('')
233
+ }}
234
+ />
235
+ {loading && touched ? (
236
+ <div className="basis-full mt-1 text-xs text-muted-foreground">Loading suggestions…</div>
237
+ ) : null}
238
+ {!loading && filteredSuggestions.length ? (
239
+ <div className="basis-full mt-1 flex flex-col gap-1">
240
+ {filteredSuggestions.map((option) => (
241
+ <button
242
+ key={option.value}
243
+ type="button"
244
+ className="flex flex-col items-start rounded border px-1.5 py-1 text-xs transition hover:bg-muted"
245
+ onMouseDown={(event) => event.preventDefault()}
246
+ onClick={() => addValue(option.value)}
247
+ >
248
+ <span>{option.label}</span>
249
+ {option.description ? (
250
+ <span className="text-[10px] text-muted-foreground">{option.description}</span>
251
+ ) : null}
252
+ </button>
253
+ ))}
254
+ </div>
255
+ ) : null}
256
+ </div>
257
+ </div>
258
+ )
259
+ }
@@ -0,0 +1,5 @@
1
+ export * from './PhoneNumberField'
2
+ export * from './TagsInput'
3
+ export * from './LookupSelect'
4
+ export * from './ComboboxInput'
5
+ export * from './SwitchableMarkdownInput'
@@ -0,0 +1,85 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { Undo2 } from 'lucide-react'
4
+ import { useRouter } from 'next/navigation'
5
+ import { Button } from '../../primitives/button'
6
+ import { apiCall } from '../utils/apiCall'
7
+ import { flash } from '../FlashMessages'
8
+ import { useLastOperation, markUndoSuccess } from './store'
9
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
10
+
11
+ export function LastOperationBanner() {
12
+ const t = useT()
13
+ const operation = useLastOperation()
14
+ const [pendingToken, setPendingToken] = React.useState<string | null>(null)
15
+ const router = useRouter()
16
+
17
+ if (!operation) return null
18
+
19
+ const rawLabel = operation.actionLabel ?? operation.commandId
20
+ const translatedLabel = t(rawLabel)
21
+ const label = translatedLabel === rawLabel ? rawLabel : translatedLabel
22
+ const isPending = pendingToken === operation.undoToken
23
+
24
+ async function handleUndo() {
25
+ const undoToken = operation?.undoToken
26
+ if (!undoToken || isPending) return
27
+ setPendingToken(undoToken)
28
+ try {
29
+ const call = await apiCall<Record<string, unknown>>('/api/audit_logs/audit-logs/actions/undo', {
30
+ method: 'POST',
31
+ headers: { 'Content-Type': 'application/json' },
32
+ body: JSON.stringify({ undoToken }),
33
+ })
34
+ if (!call.ok) {
35
+ const message =
36
+ (call.result && typeof call.result.error === 'string' && call.result.error) ||
37
+ ''
38
+ throw new Error(message || t('audit_logs.banner.undo_failed', 'Failed to undo'))
39
+ }
40
+ markUndoSuccess(undoToken)
41
+ flash(t('audit_logs.banner.undo_success'), 'success')
42
+ router.refresh()
43
+ if (typeof window !== 'undefined') {
44
+ try {
45
+ const isJSDOM = typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string'
46
+ ? navigator.userAgent.toLowerCase().includes('jsdom')
47
+ : false
48
+ if (!isJSDOM && typeof window.location?.reload === 'function') {
49
+ window.location.reload()
50
+ }
51
+ } catch {
52
+ // noop in non-browser or jsdom environments
53
+ }
54
+ }
55
+ } catch (err) {
56
+ const message = err instanceof Error && err.message ? err.message : t('audit_logs.banner.undo_error')
57
+ flash(message, 'error')
58
+ } finally {
59
+ setPendingToken(null)
60
+ }
61
+ }
62
+
63
+ return (
64
+ <div className="mb-4 flex items-center justify-between gap-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-2 text-sm text-amber-900">
65
+ <div className="min-w-0 truncate">
66
+ <span className="font-medium text-amber-950">
67
+ {t('audit_logs.banner.last_operation')}
68
+ </span>
69
+ <span className="ml-2 truncate text-amber-900">
70
+ {label}
71
+ </span>
72
+ </div>
73
+ <Button
74
+ variant="outline"
75
+ size="sm"
76
+ onClick={() => { void handleUndo() }}
77
+ disabled={isPending}
78
+ className="border-amber-300 text-amber-900 hover:bg-amber-100"
79
+ >
80
+ <Undo2 className="mr-1 size-4" aria-hidden="true" />
81
+ {isPending ? t('audit_logs.actions.undoing') : t('audit_logs.banner.undo')}
82
+ </Button>
83
+ </div>
84
+ )
85
+ }
@@ -0,0 +1,99 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { fireEvent, screen, waitFor } from '@testing-library/react'
7
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
8
+ import { LastOperationBanner } from '../LastOperationBanner'
9
+ import { apiCall } from '../../utils/apiCall'
10
+ import { flash } from '../../FlashMessages'
11
+ import { markUndoSuccess, useLastOperation } from '../store'
12
+
13
+ jest.mock('../../utils/apiCall', () => ({
14
+ apiCall: jest.fn(),
15
+ }))
16
+
17
+ jest.mock('../../FlashMessages', () => ({
18
+ flash: jest.fn(),
19
+ }))
20
+
21
+ jest.mock('../store', () => ({
22
+ useLastOperation: jest.fn(),
23
+ markUndoSuccess: jest.fn(),
24
+ }))
25
+
26
+ jest.mock('next/navigation', () => ({
27
+ useRouter: () => ({
28
+ refresh: jest.fn(),
29
+ }),
30
+ }))
31
+
32
+ const mockOperation = {
33
+ actionLabel: 'audit_logs.actions.create',
34
+ commandId: 'users.create',
35
+ undoToken: 'token-123',
36
+ }
37
+
38
+ const createMockResponse = (status: number): Response => ({ status } as Response)
39
+
40
+ const dict = {
41
+ 'audit_logs.banner.last_operation': 'Last operation',
42
+ 'audit_logs.banner.undo': 'Undo',
43
+ 'audit_logs.actions.undoing': 'Undoing…',
44
+ 'audit_logs.banner.undo_success': 'Undo completed',
45
+ 'audit_logs.banner.undo_error': 'Undo failed',
46
+ 'audit_logs.actions.create': 'Created user',
47
+ }
48
+
49
+ describe('LastOperationBanner', () => {
50
+ beforeEach(() => {
51
+ jest.resetAllMocks()
52
+ ;(useLastOperation as jest.Mock).mockReturnValue(mockOperation)
53
+ })
54
+
55
+ it('renders nothing when there is no operation', () => {
56
+ ;(useLastOperation as jest.Mock).mockReturnValue(null)
57
+ const { container } = renderWithProviders(<LastOperationBanner />, { dict })
58
+ expect(container).toBeEmptyDOMElement()
59
+ })
60
+
61
+ it('shows translated label and handles successful undo', async () => {
62
+ ;(apiCall as jest.Mock).mockResolvedValue({
63
+ ok: true,
64
+ status: 200,
65
+ result: {},
66
+ response: createMockResponse(200),
67
+ })
68
+
69
+ renderWithProviders(<LastOperationBanner />, { dict })
70
+
71
+ expect(screen.getByText('Last operation')).toBeInTheDocument()
72
+ fireEvent.click(screen.getByRole('button', { name: /undo/i }))
73
+
74
+ await waitFor(() => {
75
+ expect(apiCall).toHaveBeenCalledWith('/api/audit_logs/audit-logs/actions/undo', expect.objectContaining({
76
+ method: 'POST',
77
+ }))
78
+ })
79
+ expect(markUndoSuccess).toHaveBeenCalledWith('token-123')
80
+ expect(flash).toHaveBeenCalledWith('Undo completed', 'success')
81
+ })
82
+
83
+ it('surfaces undo errors from the API', async () => {
84
+ ;(apiCall as jest.Mock).mockResolvedValue({
85
+ ok: false,
86
+ status: 500,
87
+ result: { error: 'failed' },
88
+ response: createMockResponse(500),
89
+ })
90
+
91
+ renderWithProviders(<LastOperationBanner />, { dict })
92
+ fireEvent.click(screen.getByRole('button', { name: /undo/i }))
93
+
94
+ await waitFor(() => {
95
+ expect(flash).toHaveBeenCalledWith('failed', 'error')
96
+ })
97
+ expect(markUndoSuccess).not.toHaveBeenCalled()
98
+ })
99
+ })