@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,118 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { User, LogOut } from 'lucide-react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+
6
+ export function UserMenu({ email }: { email?: string }) {
7
+ const t = useT()
8
+ const [open, setOpen] = React.useState(false)
9
+ const buttonRef = React.useRef<HTMLButtonElement>(null)
10
+ const menuRef = React.useRef<HTMLDivElement>(null)
11
+ const logoutButtonRef = React.useRef<HTMLButtonElement>(null)
12
+
13
+ // Toggle menu open/close
14
+ const toggle = () => setOpen((v) => !v)
15
+
16
+ // Open on hover, close when mouse leaves the menu area
17
+ const onMouseEnter = () => setOpen(true)
18
+ const onMouseLeave = () => setOpen(false)
19
+
20
+ // Close menu when clicking outside
21
+ React.useEffect(() => {
22
+ if (!open) return
23
+ function handleClick(event: MouseEvent) {
24
+ if (
25
+ menuRef.current &&
26
+ !menuRef.current.contains(event.target as Node) &&
27
+ buttonRef.current &&
28
+ !buttonRef.current.contains(event.target as Node)
29
+ ) {
30
+ setOpen(false)
31
+ }
32
+ }
33
+ document.addEventListener('mousedown', handleClick)
34
+ return () => document.removeEventListener('mousedown', handleClick)
35
+ }, [open])
36
+
37
+ // Keyboard navigation
38
+ React.useEffect(() => {
39
+ if (!open) return
40
+ function handleKeyDown(event: KeyboardEvent) {
41
+ if (event.key === 'Escape') {
42
+ setOpen(false)
43
+ buttonRef.current?.focus()
44
+ } else if (event.key === 'ArrowDown' || event.key === 'Tab') {
45
+ event.preventDefault()
46
+ logoutButtonRef.current?.focus()
47
+ } else if (event.key === 'ArrowUp') {
48
+ event.preventDefault()
49
+ logoutButtonRef.current?.focus()
50
+ }
51
+ }
52
+ document.addEventListener('keydown', handleKeyDown)
53
+ return () => document.removeEventListener('keydown', handleKeyDown)
54
+ }, [open])
55
+
56
+ // Focus the first menu item when menu opens
57
+ React.useEffect(() => {
58
+ if (open) {
59
+ setTimeout(() => {
60
+ logoutButtonRef.current?.focus()
61
+ }, 0)
62
+ }
63
+ }, [open])
64
+
65
+ return (
66
+ <div className="relative" onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}>
67
+ <button
68
+ ref={buttonRef}
69
+ className="text-sm px-2 py-1 rounded hover:bg-accent inline-flex items-center gap-2"
70
+ onClick={() => setOpen(true)}
71
+ aria-expanded={open}
72
+ aria-haspopup="menu"
73
+ aria-controls="user-menu-dropdown"
74
+ id="user-menu-button"
75
+ type="button"
76
+ title={email || t('ui.userMenu.userFallback', 'User')}
77
+ >
78
+ <User className="size-4" />
79
+ </button>
80
+ {open && (
81
+ <div
82
+ ref={menuRef}
83
+ id="user-menu-dropdown"
84
+ className="absolute right-0 top-full mt-0 w-56 rounded-md border bg-background p-1 shadow z-50"
85
+ role="menu"
86
+ aria-labelledby="user-menu-button"
87
+ tabIndex={-1}
88
+ >
89
+ {email && (
90
+ <div className="px-2 py-2 text-xs text-muted-foreground border-b mb-1">
91
+ <div className="font-medium">{t('ui.userMenu.loggedInAs', 'Logged in as:')}</div>
92
+ <div className="truncate">{email}</div>
93
+ </div>
94
+ )}
95
+ <form action="/api/auth/logout" method="POST">
96
+ <button
97
+ ref={logoutButtonRef}
98
+ className="w-full text-left text-sm px-2 py-1 rounded hover:bg-accent inline-flex items-center gap-2 outline-none focus:outline-none focus-visible:outline-none ring-0 focus:ring-0 focus-visible:ring-0"
99
+ type="submit"
100
+ role="menuitem"
101
+ tabIndex={0}
102
+ onKeyDown={(e) => {
103
+ if (e.key === 'Escape') {
104
+ setOpen(false)
105
+ buttonRef.current?.focus()
106
+ }
107
+ }}
108
+ >
109
+ <LogOut className="size-4" />
110
+ <span>{t('ui.userMenu.logout', 'Logout')}</span>
111
+ </button>
112
+ </form>
113
+ </div>
114
+ )}
115
+ </div>
116
+ )
117
+ }
118
+
@@ -0,0 +1,48 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { Check, X, AlertTriangle, Minus, Circle } from 'lucide-react'
4
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
5
+
6
+ export function BooleanIcon({ value, trueLabel, falseLabel, className }: {
7
+ value?: boolean | null
8
+ trueLabel?: string
9
+ falseLabel?: string
10
+ className?: string
11
+ }) {
12
+ const v = !!value
13
+ return (
14
+ <span className={`inline-flex items-center gap-1 ${className ?? ''}`}>
15
+ {v ? (
16
+ <Check className="size-4 text-emerald-600" />
17
+ ) : (
18
+ <X className="size-4 text-muted-foreground" />
19
+ )}
20
+ {v && trueLabel ? <span className="text-xs">{trueLabel}</span> : null}
21
+ {!v && falseLabel ? <span className="text-xs">{falseLabel}</span> : null}
22
+ </span>
23
+ )
24
+ }
25
+
26
+ export type EnumBadgeMap = Record<string, { label: string; className?: string; icon?: React.ReactNode }>
27
+
28
+ export function EnumBadge({ value, map, fallback }: { value?: string | null; map: EnumBadgeMap; fallback?: string }) {
29
+ if (!value) return <span className="text-muted-foreground text-xs">{fallback ?? '—'}</span>
30
+ const cfg = map[value] || { label: String(value) }
31
+ return (
32
+ <span className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs border ${cfg.className ?? ''}`}>
33
+ {cfg.icon}
34
+ <span>{cfg.label}</span>
35
+ </span>
36
+ )
37
+ }
38
+
39
+ // Presets for common enums (optional helper)
40
+ export function useSeverityPreset(): EnumBadgeMap {
41
+ const t = useT()
42
+ return {
43
+ low: { label: t('ui.badges.severity.low', 'Low'), className: 'border-amber-200 text-amber-700 bg-amber-50', icon: <Circle className="size-3" /> },
44
+ medium: { label: t('ui.badges.severity.medium', 'Medium'), className: 'border-yellow-200 text-yellow-800 bg-yellow-50', icon: <Minus className="size-3" /> },
45
+ high: { label: t('ui.badges.severity.high', 'High'), className: 'border-red-200 text-red-700 bg-red-50', icon: <AlertTriangle className="size-3" /> },
46
+ }
47
+ }
48
+
@@ -0,0 +1,115 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { screen, waitFor } from '@testing-library/react'
7
+ import { AppShell, ApplyBreadcrumb } from '../AppShell'
8
+ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
9
+
10
+ jest.mock('next/link', () => {
11
+ const React = require('react')
12
+ return React.forwardRef(({ children, href, ...rest }: any, ref: React.ForwardedRef<HTMLAnchorElement>) => (
13
+ <a href={typeof href === 'string' ? href : href?.toString?.()} ref={ref} {...rest}>
14
+ {children}
15
+ </a>
16
+ ))
17
+ })
18
+
19
+ jest.mock('next/image', () => (props: any) => <img alt={props.alt} {...props} />)
20
+
21
+ jest.mock('next/navigation', () => ({
22
+ usePathname: () => '/backend/users',
23
+ useRouter: () => ({
24
+ refresh: jest.fn(),
25
+ push: jest.fn(),
26
+ }),
27
+ }))
28
+
29
+ jest.mock('../operations/LastOperationBanner', () => ({
30
+ LastOperationBanner: () => <div data-testid="last-operation-banner" />,
31
+ }))
32
+
33
+ jest.mock('../indexes/PartialIndexBanner', () => ({
34
+ PartialIndexBanner: () => <div data-testid="partial-index-banner" />,
35
+ }))
36
+
37
+ jest.mock('../FlashMessages', () => ({
38
+ FlashMessages: () => <div data-testid="flash-messages" />,
39
+ }))
40
+
41
+ jest.mock('../../frontend/LanguageSwitcher', () => ({
42
+ LanguageSwitcher: () => <div data-testid="language-switcher" />,
43
+ }))
44
+
45
+ jest.mock('../upgrades/UpgradeActionBanner', () => ({
46
+ UpgradeActionBanner: () => <div data-testid="upgrade-action-banner" />,
47
+ }))
48
+
49
+ const dict = {
50
+ 'appShell.productName': 'Mercato',
51
+ 'appShell.menu': 'Menu',
52
+ 'appShell.toggleSidebar': 'Toggle sidebar',
53
+ 'appShell.collapseSidebar': 'Collapse',
54
+ 'appShell.expandSidebar': 'Expand',
55
+ 'appShell.userFallback': 'User',
56
+ 'appShell.goToDashboard': 'Go to dashboard',
57
+ 'appShell.closeMenu': 'Close',
58
+ 'common.terms': 'Terms',
59
+ 'common.privacy': 'Privacy',
60
+ 'dashboard.title': 'Dashboard',
61
+ 'custom.page.title': 'Custom Page',
62
+ 'custom.page.breadcrumb': 'Custom Trail',
63
+ }
64
+
65
+ const groups = [
66
+ {
67
+ id: 'core',
68
+ name: 'Core',
69
+ items: [
70
+ { href: '/backend/users', title: 'Users List' },
71
+ { href: '/backend/roles', title: 'Roles' },
72
+ ],
73
+ },
74
+ ]
75
+
76
+ describe('AppShell', () => {
77
+ beforeAll(() => {
78
+ const storage: Record<string, string> = {}
79
+ Object.defineProperty(window, 'localStorage', {
80
+ value: {
81
+ getItem: (key: string) => storage[key] ?? null,
82
+ setItem: (key: string, value: string) => {
83
+ storage[key] = value
84
+ },
85
+ removeItem: (key: string) => {
86
+ delete storage[key]
87
+ },
88
+ },
89
+ configurable: true,
90
+ })
91
+ })
92
+
93
+ it('renders navigation and breadcrumbs with translations applied via ApplyBreadcrumb', async () => {
94
+ renderWithProviders(
95
+ <AppShell
96
+ email="demo@example.com"
97
+ groups={groups}
98
+ breadcrumb={[{ label: 'Initial' }]}
99
+ currentTitle="Initial"
100
+ >
101
+ <ApplyBreadcrumb
102
+ titleKey="custom.page.title"
103
+ breadcrumb={[{ label: 'Custom Trail', labelKey: 'custom.page.breadcrumb', href: '/custom' }]}
104
+ />
105
+ <div>Child content</div>
106
+ </AppShell>,
107
+ { dict },
108
+ )
109
+
110
+ expect(screen.getByText('Users List')).toBeInTheDocument()
111
+ expect(screen.getAllByText('Terms')[0]).toBeInTheDocument()
112
+ expect(screen.getByTestId('flash-messages')).toBeInTheDocument()
113
+ expect(screen.getByText('Child content')).toBeInTheDocument()
114
+ })
115
+ })
@@ -0,0 +1,30 @@
1
+ jest.mock('next/navigation', () => ({ useRouter: () => ({ push: () => {} }) }))
2
+ jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
3
+ jest.mock('@uiw/react-md-editor', () => ({ __esModule: true, default: () => null }))
4
+
5
+ import * as React from 'react'
6
+ import { renderToString } from 'react-dom/server'
7
+ import { CrudForm, type CrudField } from '../CrudForm'
8
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
9
+
10
+ describe('CrudForm SSR render', () => {
11
+ it('renders base fields', () => {
12
+ const fields: CrudField[] = [
13
+ { id: 'title', label: 'Title', type: 'text' },
14
+ { id: 'is_done', label: 'Done', type: 'checkbox' },
15
+ ]
16
+ const html = renderToString(
17
+ React.createElement(
18
+ I18nProvider as any,
19
+ { locale: 'en', dict: {} },
20
+ React.createElement(CrudForm as any, {
21
+ title: 'Form',
22
+ fields,
23
+ onSubmit: () => {},
24
+ })
25
+ )
26
+ )
27
+ expect(html).toContain('Title')
28
+ expect(html).toContain('Done')
29
+ })
30
+ })
@@ -0,0 +1,48 @@
1
+ import * as React from 'react'
2
+ import { renderToString } from 'react-dom/server'
3
+ import { DataTable } from '../DataTable'
4
+ import type { ColumnDef } from '@tanstack/react-table'
5
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
6
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
7
+
8
+ // Mock next/navigation for SSR compatibility of client components
9
+ jest.mock('next/navigation', () => ({
10
+ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
11
+ }))
12
+
13
+ type Row = { id: string; name: string }
14
+
15
+ describe('DataTable SSR render', () => {
16
+ it('renders built-in FilterBar when search/filters provided', () => {
17
+ const columns: ColumnDef<Row>[] = [
18
+ { accessorKey: 'name', header: 'Name' },
19
+ ]
20
+ const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
21
+ try {
22
+ const html = renderToString(
23
+ React.createElement(
24
+ QueryClientProvider as any,
25
+ { client: queryClient },
26
+ React.createElement(
27
+ I18nProvider as any,
28
+ { locale: 'en', dict: {} },
29
+ React.createElement(DataTable as any, {
30
+ columns,
31
+ data: [],
32
+ title: 'Test',
33
+ searchValue: 'abc',
34
+ onSearchChange: () => {},
35
+ filters: [{ id: 'created_at', label: 'Created', type: 'dateRange' }],
36
+ filterValues: {},
37
+ onFiltersApply: () => {},
38
+ }),
39
+ ),
40
+ )
41
+ )
42
+ expect(html).toContain('Filters')
43
+ expect(html).toContain('Name')
44
+ } finally {
45
+ queryClient.clear()
46
+ }
47
+ })
48
+ })
@@ -0,0 +1,72 @@
1
+ import { buildFilterDefsFromCustomFields, type CustomFieldDefDto } from '../utils/customFieldFilters'
2
+
3
+ describe('buildFilterDefsFromCustomFields', () => {
4
+ it('maps boolean/select/text and respects filterable/multi', () => {
5
+ const defs: CustomFieldDefDto[] = [
6
+ { key: 'blocked', kind: 'boolean', filterable: true },
7
+ {
8
+ key: 'severity',
9
+ kind: 'select',
10
+ filterable: true,
11
+ options: [
12
+ { value: 'low', label: 'low' },
13
+ { value: 'medium', label: 'medium' },
14
+ { value: 'high', label: 'high' },
15
+ ],
16
+ },
17
+ {
18
+ key: 'labels',
19
+ kind: 'select',
20
+ filterable: true,
21
+ options: [
22
+ { value: 'bug', label: 'bug' },
23
+ { value: 'feature', label: 'feature' },
24
+ ],
25
+ multi: true,
26
+ },
27
+ { key: 'notes', kind: 'multiline', filterable: true },
28
+ { key: 'hidden', kind: 'text', filterable: false },
29
+ ]
30
+
31
+ const out = buildFilterDefsFromCustomFields(defs)
32
+
33
+ // boolean => checkbox
34
+ expect(out.find(f => f.id === 'cf_blocked')!.type).toBe('checkbox')
35
+ // select single => select with options and multiple false
36
+ const sev = out.find(f => f.id === 'cf_severity')!
37
+ expect(sev.type).toBe('select')
38
+ if (sev.type !== 'select') throw new Error('expected select')
39
+ expect(sev.multiple).toBeFalsy()
40
+ expect((sev.options || []).map((o) => o.value)).toEqual(['low','medium','high'])
41
+ // select multi => select with multiple true and id with In suffix
42
+ const labels = out.find(f => f.id === 'cf_labelsIn')!
43
+ expect(labels.type).toBe('select')
44
+ if (labels.type !== 'select') throw new Error('expected select')
45
+ expect(labels.multiple).toBe(true)
46
+ expect((labels.options || []).map((o) => o.value)).toEqual(['bug','feature'])
47
+ // text-like (multiline) => text
48
+ expect(out.find(f => f.id === 'cf_notes')!.type).toBe('text')
49
+ // non-filterable omitted
50
+ expect(out.some(f => f.id === 'cf_hidden')).toBe(false)
51
+ })
52
+
53
+ it('maps multi text to tags with async suggestions support', () => {
54
+ const defs: CustomFieldDefDto[] = [
55
+ {
56
+ key: 'labels',
57
+ kind: 'text',
58
+ filterable: true,
59
+ multi: true,
60
+ options: [
61
+ { value: 'bug', label: 'bug' },
62
+ { value: 'feature', label: 'feature' },
63
+ ],
64
+ },
65
+ ]
66
+ const out = buildFilterDefsFromCustomFields(defs)
67
+ const labels = out.find(f => f.id === 'cf_labelsIn')!
68
+ expect(labels.type).toBe('tags')
69
+ if (labels.type !== 'tags') throw new Error('expected tags')
70
+ expect((labels.options || []).map((o) => o.value)).toEqual(['bug','feature'])
71
+ })
72
+ })
@@ -0,0 +1,54 @@
1
+ import { buildFormFieldsFromCustomFields } from '../utils/customFieldForms'
2
+ import type { CustomFieldDefDto } from '../utils/customFieldFilters'
3
+
4
+ describe('buildFormFieldsFromCustomFields', () => {
5
+ it('maps kinds to CrudField and filters by formEditable', () => {
6
+ const defs: CustomFieldDefDto[] = [
7
+ { key: 'blocked', kind: 'boolean', filterable: true, formEditable: true },
8
+ { key: 'priority', kind: 'integer', filterable: true, formEditable: true },
9
+ {
10
+ key: 'severity',
11
+ kind: 'select',
12
+ options: [
13
+ { value: 'low', label: 'low' },
14
+ { value: 'high', label: 'high' },
15
+ ],
16
+ multi: false,
17
+ filterable: true,
18
+ formEditable: true,
19
+ },
20
+ {
21
+ key: 'labels',
22
+ kind: 'select',
23
+ options: [
24
+ { value: 'bug', label: 'bug' },
25
+ { value: 'feature', label: 'feature' },
26
+ ],
27
+ multi: true,
28
+ filterable: true,
29
+ formEditable: true,
30
+ },
31
+ { key: 'notes', kind: 'multiline', filterable: false, formEditable: true },
32
+ // text with editor hint should render richtext
33
+ { key: 'desc', kind: 'text', filterable: false, formEditable: true, editor: 'htmlRichText' },
34
+ { key: 'hidden', kind: 'text', filterable: true, formEditable: false },
35
+ ]
36
+
37
+ const fields = buildFormFieldsFromCustomFields(defs)
38
+ const byId: Record<string, any> = Object.fromEntries(fields.map(f => [f.id, f]))
39
+ expect(byId['cf_blocked']?.type).toBe('checkbox')
40
+ expect(byId['cf_priority']?.type).toBe('number')
41
+ expect(byId['cf_severity']?.type).toBe('select')
42
+ expect(byId['cf_labels']?.type).toBe('select')
43
+ if (byId['cf_labels']?.type === 'select') {
44
+ expect(byId['cf_labels'].multiple).toBe(true)
45
+ }
46
+ // Multiline now defaults to richtext (markdown editor)
47
+ expect(byId['cf_notes']?.type).toBe('richtext')
48
+ expect(byId['cf_desc']?.type).toBe('richtext')
49
+ if (byId['cf_desc']?.type === 'richtext') {
50
+ expect(byId['cf_desc'].editor).toBe('html')
51
+ }
52
+ expect(byId['cf_hidden']).toBeUndefined()
53
+ })
54
+ })
@@ -0,0 +1,83 @@
1
+ import { mapCrudServerErrorToFormErrors, raiseCrudError, readJsonSafe } from '../utils/serverErrors'
2
+
3
+ describe('serverErrors helpers', () => {
4
+ it('maps details array into field errors and message', () => {
5
+ const error = {
6
+ error: 'Invalid input',
7
+ details: [
8
+ {
9
+ origin: 'string',
10
+ code: 'too_small',
11
+ minimum: 6,
12
+ inclusive: true,
13
+ path: ['password'],
14
+ message: 'Too small: expected string to have >=6 characters',
15
+ },
16
+ ],
17
+ }
18
+
19
+ const result = mapCrudServerErrorToFormErrors(error, { customEntity: false })
20
+ expect(result.fieldErrors).toEqual({
21
+ password: 'Too small: expected string to have >=6 characters',
22
+ })
23
+ expect(result.message).toBe('Too small: expected string to have >=6 characters')
24
+ })
25
+
26
+ it('keeps provided fieldErrors when available', () => {
27
+ const error = {
28
+ message: 'Invalid input',
29
+ fieldErrors: {
30
+ cf_notes: 'Notes are required',
31
+ },
32
+ }
33
+
34
+ const result = mapCrudServerErrorToFormErrors(error, { customEntity: false })
35
+ expect(result.fieldErrors).toEqual({ cf_notes: 'Notes are required' })
36
+ expect(result.message).toBe('Notes are required')
37
+ })
38
+
39
+ it('raiseCrudError throws structured object with parsed body', async () => {
40
+ expect.assertions(2)
41
+ const response = new Response(
42
+ JSON.stringify({
43
+ error: 'Invalid input',
44
+ details: [
45
+ {
46
+ path: ['password'],
47
+ message: 'Too small: expected string to have >=6 characters',
48
+ },
49
+ ],
50
+ }),
51
+ { status: 400, headers: { 'content-type': 'application/json' } },
52
+ )
53
+
54
+ try {
55
+ await raiseCrudError(response, 'Fallback message')
56
+ } catch (err) {
57
+ expect(err).toMatchObject({ status: 400, message: 'Invalid input' })
58
+ expect(err).toHaveProperty('details')
59
+ }
60
+ })
61
+
62
+ it('raiseCrudError falls back to message when body is plain text', async () => {
63
+ expect.assertions(1)
64
+ const response = new Response('Something went wrong', { status: 500 })
65
+
66
+ await expect(raiseCrudError(response, 'Fallback message')).rejects.toMatchObject({
67
+ status: 500,
68
+ message: 'Fallback message',
69
+ })
70
+ })
71
+
72
+ it('readJsonSafe returns fallback when body empty', async () => {
73
+ const response = new Response('', { status: 200 })
74
+ const result = await readJsonSafe<{ ok: boolean }>(response, { ok: false })
75
+ expect(result).toEqual({ ok: false })
76
+ })
77
+
78
+ it('readJsonSafe returns fallback when parsing fails', async () => {
79
+ const response = new Response('not json', { status: 200 })
80
+ const result = await readJsonSafe<{ ok: boolean }>(response, { ok: true })
81
+ expect(result).toEqual({ ok: true })
82
+ })
83
+ })