@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,198 @@
1
+ "use client"
2
+
3
+ import * as React from 'react'
4
+ import { Calendar, dateFnsLocalizer, type View, type SlotInfo } from 'react-big-calendar'
5
+ import { addDays, differenceInCalendarDays, endOfDay, endOfMonth, endOfWeek, format, getDay, parse, startOfDay, startOfMonth, startOfWeek } from 'date-fns'
6
+ import { enUS } from 'date-fns/locale/en-US'
7
+ import type { ScheduleItem, ScheduleRange, ScheduleSlot, ScheduleViewMode } from './types'
8
+ import { ScheduleToolbar } from './ScheduleToolbar'
9
+ import { expandRecurringItems } from './recurrence'
10
+
11
+ type CalendarEvent = {
12
+ id: string
13
+ title: string
14
+ start: Date
15
+ end: Date
16
+ resource: ScheduleItem
17
+ }
18
+
19
+ const localizer = dateFnsLocalizer({
20
+ format,
21
+ parse,
22
+ startOfWeek,
23
+ getDay,
24
+ locales: { 'en-US': enUS },
25
+ })
26
+
27
+ const VIEW_MAP: Record<ScheduleViewMode, View> = {
28
+ day: 'day',
29
+ week: 'week',
30
+ month: 'month',
31
+ agenda: 'agenda',
32
+ }
33
+
34
+ function deriveRange(date: Date, view: ScheduleViewMode, agendaLength: number): ScheduleRange {
35
+ if (view === 'day') {
36
+ return { start: startOfDay(date), end: endOfDay(date) }
37
+ }
38
+ if (view === 'week') {
39
+ return { start: startOfWeek(date, { locale: enUS }), end: endOfWeek(date, { locale: enUS }) }
40
+ }
41
+ if (view === 'month') {
42
+ return { start: startOfMonth(date), end: endOfMonth(date) }
43
+ }
44
+ const length = Math.max(1, agendaLength)
45
+ return { start: startOfDay(date), end: endOfDay(addDays(date, length - 1)) }
46
+ }
47
+
48
+ function normalizeRange(
49
+ nextRange: Date[] | { start: Date; end: Date } | null | undefined,
50
+ view: ScheduleViewMode,
51
+ agendaLength: number,
52
+ ): ScheduleRange | null {
53
+ if (!nextRange) return null
54
+ if (Array.isArray(nextRange)) {
55
+ if (nextRange.length === 0) return null
56
+ if (view === 'agenda') {
57
+ return { start: nextRange[0], end: nextRange[nextRange.length - 1] }
58
+ }
59
+ return deriveRange(nextRange[0], view, agendaLength)
60
+ }
61
+ if (nextRange.start && nextRange.end) return { start: nextRange.start, end: nextRange.end }
62
+ return deriveRange(new Date(), view, agendaLength)
63
+ }
64
+
65
+ function getEventStyles(item: ScheduleItem): React.CSSProperties {
66
+ if (item.kind === 'event') {
67
+ return { backgroundColor: 'rgba(59, 130, 246, 0.15)', border: '1px solid rgba(59, 130, 246, 0.5)', color: '#1e3a8a' }
68
+ }
69
+ if (item.kind === 'exception') {
70
+ return { backgroundColor: 'rgba(148, 163, 184, 0.2)', border: '1px solid rgba(100, 116, 139, 0.6)', color: '#334155' }
71
+ }
72
+ return { backgroundColor: 'rgba(16, 185, 129, 0.15)', border: '1px solid rgba(16, 185, 129, 0.5)', color: '#064e3b' }
73
+ }
74
+
75
+ export type ScheduleViewProps = {
76
+ items: ScheduleItem[]
77
+ view: ScheduleViewMode
78
+ range: ScheduleRange
79
+ timezone?: string
80
+ onRangeChange: (range: ScheduleRange) => void
81
+ onViewChange: (view: ScheduleViewMode) => void
82
+ onItemClick?: (item: ScheduleItem) => void
83
+ onSlotClick?: (slot: ScheduleSlot) => void
84
+ onTimezoneChange?: (timezone: string) => void
85
+ className?: string
86
+ }
87
+
88
+ export function ScheduleView({
89
+ items,
90
+ view,
91
+ range,
92
+ timezone,
93
+ onRangeChange,
94
+ onViewChange,
95
+ onItemClick,
96
+ onSlotClick,
97
+ onTimezoneChange,
98
+ className,
99
+ }: ScheduleViewProps) {
100
+ const agendaLength = React.useMemo(
101
+ () => Math.max(1, differenceInCalendarDays(range.end, range.start) + 1),
102
+ [range.end, range.start],
103
+ )
104
+ const currentView = VIEW_MAP[view]
105
+ const expandedItems = React.useMemo(() => expandRecurringItems(items, range), [items, range])
106
+ const events = React.useMemo<CalendarEvent[]>(
107
+ () => expandedItems.map((item) => ({
108
+ id: item.id,
109
+ title: item.title,
110
+ start: item.startsAt,
111
+ end: item.endsAt,
112
+ resource: item,
113
+ })),
114
+ [expandedItems],
115
+ )
116
+
117
+ const handleNavigate = React.useCallback((date: Date, nextView?: View) => {
118
+ const resolvedView = (nextView ?? currentView) as ScheduleViewMode
119
+ onRangeChange(deriveRange(date, resolvedView, agendaLength))
120
+ }, [agendaLength, currentView, onRangeChange])
121
+
122
+ const handleRangeChange = React.useCallback((nextRange: Date[] | { start: Date; end: Date }, nextView?: View) => {
123
+ const resolvedView = (nextView ?? currentView) as ScheduleViewMode
124
+ const normalized = normalizeRange(nextRange, resolvedView, agendaLength)
125
+ if (normalized) onRangeChange(normalized)
126
+ }, [agendaLength, currentView, onRangeChange])
127
+
128
+ const handleViewChange = React.useCallback((nextView: View) => {
129
+ const resolved = nextView as ScheduleViewMode
130
+ if (resolved !== view) {
131
+ onViewChange(resolved)
132
+ onRangeChange(deriveRange(new Date(), resolved, agendaLength))
133
+ }
134
+ }, [agendaLength, onRangeChange, onViewChange, view])
135
+
136
+ const rootClassName = ['schedule-view', className].filter(Boolean).join(' ')
137
+
138
+ return (
139
+ <div className={rootClassName}>
140
+ <ScheduleToolbar
141
+ view={view}
142
+ range={range}
143
+ timezone={timezone}
144
+ onRangeChange={onRangeChange}
145
+ onViewChange={onViewChange}
146
+ onTimezoneChange={onTimezoneChange}
147
+ />
148
+ <div className="schedule-calendar mt-4 rounded-xl border bg-card p-3">
149
+ <Calendar
150
+ localizer={localizer}
151
+ culture="en-US"
152
+ events={events}
153
+ view={currentView}
154
+ date={range.start}
155
+ toolbar={false}
156
+ selectable={Boolean(onSlotClick)}
157
+ popup
158
+ length={agendaLength}
159
+ onView={handleViewChange}
160
+ onNavigate={handleNavigate}
161
+ onRangeChange={handleRangeChange}
162
+ onSelectEvent={(event: CalendarEvent) => onItemClick?.(event.resource)}
163
+ onSelectSlot={(slot: SlotInfo) => {
164
+ if (!onSlotClick) return
165
+ onSlotClick({ start: slot.start, end: slot.end })
166
+ }}
167
+ eventPropGetter={(event: CalendarEvent) => ({
168
+ style: getEventStyles(event.resource),
169
+ })}
170
+ components={{
171
+ event: ({ event }: { event: CalendarEvent }) => {
172
+ const resource = event.resource
173
+ const hasLink = Boolean(resource.linkLabel) && typeof onItemClick === 'function'
174
+ return (
175
+ <div className="flex items-center justify-between gap-2">
176
+ <span className="truncate text-xs font-medium">{resource.title}</span>
177
+ {hasLink ? (
178
+ <button
179
+ type="button"
180
+ className="text-[11px] font-medium underline-offset-2 hover:underline"
181
+ onClick={(clickEvent) => {
182
+ clickEvent.stopPropagation()
183
+ onItemClick?.(resource)
184
+ }}
185
+ >
186
+ {resource.linkLabel}
187
+ </button>
188
+ ) : null}
189
+ </div>
190
+ )
191
+ },
192
+ }}
193
+ style={{ height: 640 }}
194
+ />
195
+ </div>
196
+ </div>
197
+ )
198
+ }
@@ -0,0 +1,5 @@
1
+ export * from './types'
2
+ export * from './ScheduleToolbar'
3
+ export * from './ScheduleGrid'
4
+ export * from './ScheduleAgenda'
5
+ export * from './ScheduleView'
@@ -0,0 +1,99 @@
1
+ import type { ScheduleItem, ScheduleRange } from './types'
2
+
3
+ type RuleMetadata = {
4
+ rrule?: string
5
+ exdates?: unknown
6
+ }
7
+
8
+ const DAY_MS = 24 * 60 * 60 * 1000
9
+
10
+ function startOfDay(value: Date): Date {
11
+ return new Date(value.getFullYear(), value.getMonth(), value.getDate())
12
+ }
13
+
14
+ function toDateKey(value: Date): string {
15
+ const year = value.getFullYear()
16
+ const month = String(value.getMonth() + 1).padStart(2, '0')
17
+ const day = String(value.getDate()).padStart(2, '0')
18
+ return `${year}-${month}-${day}`
19
+ }
20
+
21
+ function parseRepeat(rrule: string): 'once' | 'daily' | 'weekly' {
22
+ const freqMatch = rrule.match(/FREQ=([A-Z]+)/)
23
+ const countMatch = rrule.match(/COUNT=(\d+)/)
24
+ const freq = freqMatch?.[1]
25
+ const count = countMatch?.[1] ? Number(countMatch[1]) : null
26
+ if (freq === 'WEEKLY') return 'weekly'
27
+ if (freq === 'DAILY') {
28
+ if (count === 1) return 'once'
29
+ return 'daily'
30
+ }
31
+ return 'once'
32
+ }
33
+
34
+ function parseRuleMetadata(item: ScheduleItem): RuleMetadata | null {
35
+ if (!item.metadata || typeof item.metadata !== 'object') return null
36
+ const metadata = item.metadata as { rule?: unknown }
37
+ if (!metadata.rule || typeof metadata.rule !== 'object') return null
38
+ const rule = metadata.rule as RuleMetadata
39
+ if (typeof rule.rrule !== 'string') return null
40
+ return rule
41
+ }
42
+
43
+ function normalizeExdates(exdates: unknown): Set<string> {
44
+ if (!Array.isArray(exdates)) return new Set()
45
+ const keys = exdates
46
+ .map((value) => {
47
+ if (typeof value !== 'string') return null
48
+ const parsed = new Date(value)
49
+ if (Number.isNaN(parsed.getTime())) return null
50
+ return toDateKey(parsed)
51
+ })
52
+ .filter((value): value is string => value !== null)
53
+ return new Set(keys)
54
+ }
55
+
56
+ export function expandRecurringItems(items: ScheduleItem[], range: ScheduleRange): ScheduleItem[] {
57
+ const expanded: ScheduleItem[] = []
58
+ const rangeStart = startOfDay(range.start)
59
+ const rangeEnd = startOfDay(range.end)
60
+
61
+ items.forEach((item) => {
62
+ const rule = parseRuleMetadata(item)
63
+ if (!rule) {
64
+ expanded.push(item)
65
+ return
66
+ }
67
+
68
+ const repeat = parseRepeat(rule.rrule ?? '')
69
+ if (repeat === 'once') {
70
+ expanded.push(item)
71
+ return
72
+ }
73
+
74
+ const durationMs = Math.max(0, item.endsAt.getTime() - item.startsAt.getTime())
75
+ const startHours = item.startsAt.getHours()
76
+ const startMinutes = item.startsAt.getMinutes()
77
+ const startSeconds = item.startsAt.getSeconds()
78
+ const startMs = item.startsAt.getMilliseconds()
79
+ const itemStartDay = startOfDay(item.startsAt)
80
+ const exdates = normalizeExdates(rule.exdates)
81
+
82
+ for (let cursor = new Date(rangeStart); cursor <= rangeEnd; cursor = new Date(cursor.getTime() + DAY_MS)) {
83
+ if (cursor < itemStartDay) continue
84
+ if (repeat === 'weekly' && cursor.getDay() !== item.startsAt.getDay()) continue
85
+ const dateKey = toDateKey(cursor)
86
+ if (exdates.has(dateKey)) continue
87
+ const start = new Date(cursor.getFullYear(), cursor.getMonth(), cursor.getDate(), startHours, startMinutes, startSeconds, startMs)
88
+ const end = new Date(start.getTime() + durationMs)
89
+ expanded.push({
90
+ ...item,
91
+ id: `${item.id}:${dateKey}`,
92
+ startsAt: start,
93
+ endsAt: end,
94
+ })
95
+ }
96
+ })
97
+
98
+ return expanded
99
+ }
@@ -0,0 +1,26 @@
1
+ export type ScheduleViewMode = 'day' | 'week' | 'month' | 'agenda'
2
+
3
+ export type ScheduleRange = {
4
+ start: Date
5
+ end: Date
6
+ }
7
+
8
+ export type ScheduleSlot = {
9
+ start: Date
10
+ end: Date
11
+ }
12
+
13
+ export type ScheduleItem = {
14
+ id: string
15
+ kind: 'availability' | 'event' | 'exception'
16
+ title: string
17
+ startsAt: Date
18
+ endsAt: Date
19
+ status?: 'draft' | 'negotiation' | 'confirmed' | 'cancelled'
20
+ subjectType?: 'member' | 'resource'
21
+ subjectId?: string
22
+ color?: string
23
+ linkLabel?: string
24
+ linkHref?: string
25
+ metadata?: Record<string, unknown>
26
+ }
@@ -0,0 +1,128 @@
1
+ "use client"
2
+ import * as React from 'react'
3
+ import { Sparkles } from 'lucide-react'
4
+ import { Button } from '../../primitives/button'
5
+ import { apiCall } from '../utils/apiCall'
6
+ import { flash } from '../FlashMessages'
7
+ import { useT } from '@open-mercato/shared/lib/i18n/context'
8
+
9
+ const upgradeActionsEnabled =
10
+ process.env.NEXT_PUBLIC_UPGRADE_ACTIONS_ENABLED === 'true' ||
11
+ process.env.UPGRADE_ACTIONS_ENABLED === 'true'
12
+
13
+ type UpgradeActionPayload = {
14
+ id: string
15
+ version: string
16
+ message: string
17
+ ctaLabel: string
18
+ successMessage?: string
19
+ loadingLabel?: string
20
+ }
21
+
22
+ type UpgradeActionResponse = {
23
+ version: string
24
+ actions?: UpgradeActionPayload[]
25
+ error?: string
26
+ }
27
+
28
+ type RunActionResponse = {
29
+ status?: 'completed' | 'already_completed'
30
+ message?: string
31
+ error?: string
32
+ }
33
+
34
+ export function UpgradeActionBanner() {
35
+ const t = useT()
36
+ const [action, setAction] = React.useState<UpgradeActionPayload | null>(null)
37
+ const [loading, setLoading] = React.useState(false)
38
+ const cancelledRef = React.useRef(false)
39
+
40
+ const loadNextAction = React.useCallback(async () => {
41
+ if (!upgradeActionsEnabled) return
42
+ if (typeof window === 'undefined' || typeof fetch === 'undefined') return
43
+ const call = await apiCall<UpgradeActionResponse>('/api/configs/upgrade-actions')
44
+ if (cancelledRef.current) return
45
+ if (!call.ok || !call.result || !Array.isArray(call.result.actions) || !call.result.actions.length) {
46
+ setAction(null)
47
+ return
48
+ }
49
+ setAction(call.result.actions[0]!)
50
+ }, [])
51
+
52
+ React.useEffect(() => {
53
+ cancelledRef.current = false
54
+ void loadNextAction()
55
+ return () => {
56
+ cancelledRef.current = true
57
+ }
58
+ }, [loadNextAction])
59
+
60
+ if (!upgradeActionsEnabled || !action) return null
61
+
62
+ async function handleRun() {
63
+ if (!upgradeActionsEnabled || !action || loading) return
64
+ setLoading(true)
65
+ try {
66
+ const response = await apiCall<RunActionResponse>('/api/configs/upgrade-actions', {
67
+ method: 'POST',
68
+ headers: { 'Content-Type': 'application/json' },
69
+ body: JSON.stringify({ actionId: action.id }),
70
+ })
71
+ if (!response.ok) {
72
+ const baseError =
73
+ (response.result && typeof response.result.error === 'string' && response.result.error) ||
74
+ t('upgrades.runFailed', 'We could not run this upgrade action.')
75
+ const detail = response.result && typeof (response.result as any).details === 'string' ? (response.result as any).details : null
76
+ const errorMessage = detail ? `${baseError} (${detail})` : baseError
77
+ flash(errorMessage, 'error')
78
+ return
79
+ }
80
+ const message =
81
+ response.result?.message ||
82
+ action.successMessage ||
83
+ t('upgrades.v034.success', 'Example catalog products and categories installed.')
84
+ flash(message, 'success')
85
+ setAction(null)
86
+ await loadNextAction()
87
+ } catch (error) {
88
+ const message = error instanceof Error ? error.message : t('upgrades.runFailed', 'We could not run this upgrade action.')
89
+ flash(message, 'error')
90
+ } finally {
91
+ setLoading(false)
92
+ }
93
+ }
94
+
95
+ const loadingLabel = action.loadingLabel || t('upgrades.v034.loading', 'Installing…')
96
+ const title = action.ctaLabel || action.message
97
+ const description = action.message && action.message !== title ? action.message : null
98
+
99
+ return (
100
+ <div className="mb-4 flex flex-col gap-3 rounded-md border border-amber-300 bg-amber-50 px-3 py-3 text-sm text-amber-900 md:flex-row md:items-center md:justify-between">
101
+ <div className="flex items-start gap-2 text-sm">
102
+ <Sparkles className="mt-0.5 size-4 text-amber-700" aria-hidden="true" />
103
+ <div className="flex flex-col gap-1">
104
+ <div className="font-medium text-amber-950">
105
+ {title}
106
+ </div>
107
+ {description ? (
108
+ <div className="text-xs text-amber-900/80">
109
+ {description}
110
+ </div>
111
+ ) : null}
112
+ <div className="text-xs text-amber-900/80">{t('upgrades.versionLabel', { version: action.version })}</div>
113
+ </div>
114
+ </div>
115
+ <div className="flex flex-wrap items-center gap-2">
116
+ <Button
117
+ variant="outline"
118
+ size="sm"
119
+ onClick={() => { void handleRun() }}
120
+ disabled={loading}
121
+ className="border-amber-300 text-amber-900 hover:bg-amber-100"
122
+ >
123
+ {loading ? loadingLabel : action.ctaLabel}
124
+ </Button>
125
+ </div>
126
+ </div>
127
+ )
128
+ }
@@ -0,0 +1,109 @@
1
+ jest.mock('../../utils/api', () => ({
2
+ apiFetch: jest.fn(),
3
+ }))
4
+ jest.mock('../../utils/serverErrors', () => {
5
+ const actual = jest.requireActual('../../utils/serverErrors')
6
+ return {
7
+ ...actual,
8
+ raiseCrudError: jest.fn(),
9
+ }
10
+ })
11
+
12
+ import { apiFetch } from '../../utils/api'
13
+ import { raiseCrudError } from '../../utils/serverErrors'
14
+ import { apiCall, apiCallOrThrow, readApiResultOrThrow } from '../../utils/apiCall'
15
+
16
+ function createMockResponse(body: string, init?: { status?: number }): Response {
17
+ const status = init?.status ?? 200
18
+ const ok = status >= 200 && status < 300
19
+ const responseBody = body
20
+ return {
21
+ ok,
22
+ status,
23
+ headers: new Map<string, string>(),
24
+ text: jest.fn(async () => responseBody),
25
+ clone: () => createMockResponse(responseBody, { status }),
26
+ } as unknown as Response
27
+ }
28
+
29
+ describe('apiCall', () => {
30
+ beforeEach(() => {
31
+ jest.resetAllMocks()
32
+ })
33
+
34
+ it('returns parsed JSON result by default', async () => {
35
+ const payload = { ok: true }
36
+ const response = createMockResponse(JSON.stringify(payload), { status: 200 })
37
+ ;(apiFetch as jest.Mock).mockResolvedValue(response)
38
+ const result = await apiCall<{ ok: boolean }>('/api/test')
39
+ expect(result.ok).toBe(true)
40
+ expect(result.status).toBe(200)
41
+ expect(result.result).toEqual(payload)
42
+ expect(result.response).toBe(response)
43
+ })
44
+
45
+ it('uses fallback when parsing fails', async () => {
46
+ ;(apiFetch as jest.Mock).mockResolvedValue(new Response('not json', { status: 200 }))
47
+ const result = await apiCall<{ ok: boolean }>('/api/test', undefined, { fallback: { ok: false } })
48
+ expect(result.result).toEqual({ ok: false })
49
+ })
50
+
51
+ it('supports custom parser', async () => {
52
+ const response = new Response('data', { status: 202 })
53
+ ;(apiFetch as jest.Mock).mockResolvedValue(response)
54
+ const parser = jest.fn(async () => ({ parsed: true }))
55
+ const result = await apiCall<{ parsed: boolean }>('/api/custom', undefined, { parse: parser })
56
+ expect(parser).toHaveBeenCalled()
57
+ expect(result.result).toEqual({ parsed: true })
58
+ })
59
+ })
60
+
61
+ describe('apiCallOrThrow', () => {
62
+ beforeEach(() => {
63
+ jest.resetAllMocks()
64
+ })
65
+
66
+ it('returns call result when successful', async () => {
67
+ const payload = { ok: true }
68
+ const response = createMockResponse(JSON.stringify(payload), { status: 200 })
69
+ ;(apiFetch as jest.Mock).mockResolvedValue(response)
70
+ const call = await apiCallOrThrow<{ ok: boolean }>('/api/success', undefined, { errorMessage: 'failed' })
71
+ expect(call.ok).toBe(true)
72
+ expect(call.result).toEqual(payload)
73
+ expect(raiseCrudError).not.toHaveBeenCalled()
74
+ })
75
+
76
+ it('delegates to raiseCrudError when response is not ok', async () => {
77
+ const response = createMockResponse(JSON.stringify({ error: 'nope' }), { status: 500 })
78
+ ;(apiFetch as jest.Mock).mockResolvedValue(response)
79
+ ;(raiseCrudError as jest.Mock).mockRejectedValue(new Error('nope'))
80
+ await expect(apiCallOrThrow('/api/fail', undefined, { errorMessage: 'failed' })).rejects.toThrow('nope')
81
+ expect(raiseCrudError).toHaveBeenCalledWith(response, 'failed')
82
+ })
83
+ })
84
+
85
+ describe('readApiResultOrThrow', () => {
86
+ beforeEach(() => {
87
+ jest.resetAllMocks()
88
+ })
89
+
90
+ it('returns parsed result when present', async () => {
91
+ const payload = { ok: true }
92
+ ;(apiFetch as jest.Mock).mockResolvedValue(createMockResponse(JSON.stringify(payload), { status: 200 }))
93
+ const result = await readApiResultOrThrow<{ ok: boolean }>('/api/result')
94
+ expect(result).toEqual(payload)
95
+ })
96
+
97
+ it('throws when result is null and not allowed', async () => {
98
+ ;(apiFetch as jest.Mock).mockResolvedValue(createMockResponse('', { status: 200 }))
99
+ await expect(
100
+ readApiResultOrThrow('/api/empty', undefined, { errorMessage: 'failed', emptyResultMessage: 'missing' }),
101
+ ).rejects.toThrow('missing')
102
+ })
103
+
104
+ it('allows null result when configured', async () => {
105
+ ;(apiFetch as jest.Mock).mockResolvedValue(createMockResponse('', { status: 200 }))
106
+ const result = await readApiResultOrThrow('/api/empty-ok', undefined, { allowNullResult: true })
107
+ expect(result).toBeNull()
108
+ })
109
+ })
@@ -0,0 +1,87 @@
1
+ jest.mock('../../utils/apiCall', () => ({
2
+ apiCall: jest.fn(),
3
+ }))
4
+ jest.mock('../../utils/serverErrors', () => ({
5
+ raiseCrudError: jest.fn().mockResolvedValue(undefined),
6
+ }))
7
+
8
+ import { apiCall } from '../../utils/apiCall'
9
+ import { raiseCrudError } from '../../utils/serverErrors'
10
+ import { createCrud, deleteCrud, updateCrud } from '../crud'
11
+
12
+ const response = new Response('ok', { status: 200 })
13
+
14
+ describe('crud helpers', () => {
15
+ beforeEach(() => {
16
+ jest.resetAllMocks()
17
+ })
18
+
19
+ it('createCrud resolves with parsed result', async () => {
20
+ const payload = { id: '123' }
21
+ ;(apiCall as jest.Mock).mockResolvedValue({
22
+ ok: true,
23
+ status: 201,
24
+ result: payload,
25
+ response,
26
+ })
27
+
28
+ const result = await createCrud<{ id: string }>('example/todos', { title: 'Test' })
29
+ expect(result.result).toEqual(payload)
30
+ expect(apiCall).toHaveBeenCalledWith(
31
+ '/api/example/todos',
32
+ expect.objectContaining({ method: 'POST' }),
33
+ expect.any(Object),
34
+ )
35
+ })
36
+
37
+ it('createCrud delegates error handling when request fails', async () => {
38
+ ;(apiCall as jest.Mock).mockResolvedValue({
39
+ ok: false,
40
+ status: 400,
41
+ result: null,
42
+ response,
43
+ })
44
+ const rejection = new Error('fail')
45
+ ;(raiseCrudError as jest.Mock).mockRejectedValue(rejection)
46
+ await expect(createCrud('example/todos', { title: 'Test' })).rejects.toThrow('fail')
47
+ expect(raiseCrudError).toHaveBeenCalledWith(response, 'Failed to create')
48
+ })
49
+
50
+ it('updateCrud uses PUT and returns ApiCallResult', async () => {
51
+ const callResult = { ok: true, status: 200, result: { updated: true }, response }
52
+ ;(apiCall as jest.Mock).mockResolvedValue(callResult)
53
+ const result = await updateCrud<{ updated: boolean }>('example/todos', { id: '1' })
54
+ expect(result).toBe(callResult)
55
+ expect(apiCall).toHaveBeenLastCalledWith(
56
+ '/api/example/todos',
57
+ expect.objectContaining({ method: 'PUT' }),
58
+ expect.any(Object),
59
+ )
60
+ })
61
+
62
+ it('deleteCrud supports id parameter', async () => {
63
+ const callResult = { ok: true, status: 200, result: null, response }
64
+ ;(apiCall as jest.Mock).mockResolvedValue(callResult)
65
+ const result = await deleteCrud('example/todos', '123')
66
+ expect(result).toBe(callResult)
67
+ expect(apiCall).toHaveBeenCalledWith(
68
+ '/api/example/todos?id=123',
69
+ expect.objectContaining({ method: 'DELETE' }),
70
+ expect.any(Object),
71
+ )
72
+ })
73
+
74
+ it('deleteCrud supports JSON body payload', async () => {
75
+ const callResult = { ok: true, status: 200, result: null, response }
76
+ ;(apiCall as jest.Mock).mockResolvedValue(callResult)
77
+ await deleteCrud('example/todos', { body: { id: 'abc' } })
78
+ expect(apiCall).toHaveBeenCalledWith(
79
+ '/api/example/todos',
80
+ expect.objectContaining({
81
+ method: 'DELETE',
82
+ body: JSON.stringify({ id: 'abc' }),
83
+ }),
84
+ expect.any(Object),
85
+ )
86
+ })
87
+ })
@@ -0,0 +1,25 @@
1
+ import { fetchCustomFieldDefs, normalizeEntityIds } from '../customFieldDefs'
2
+
3
+ const createFetchStub = (payload: unknown) => {
4
+ const json = jest.fn().mockResolvedValue(payload)
5
+ return Object.assign(jest.fn().mockResolvedValue({ json }), { json })
6
+ }
7
+
8
+ describe('customFieldDefs utilities', () => {
9
+ it('normalizes entity ids and removes duplicates', () => {
10
+ expect(normalizeEntityIds([' alpha ', 'ALPHA', 'beta', null as any])).toEqual(['alpha', 'ALPHA', 'beta'])
11
+ })
12
+
13
+ it('fetches definitions via provided fetch implementation and sorts by priority', async () => {
14
+ const stub = createFetchStub({
15
+ items: [
16
+ { key: 'b', priority: 5 },
17
+ { key: 'a', priority: 1 },
18
+ { key: 'c' },
19
+ ],
20
+ })
21
+ const defs = await fetchCustomFieldDefs(['entity.one'], stub as unknown as typeof fetch)
22
+ expect(stub).toHaveBeenCalledWith('/api/entities/definitions?entityId=entity.one', expect.any(Object))
23
+ expect(defs.map((d) => d.key)).toEqual(['c', 'a', 'b'])
24
+ })
25
+ })