@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.
- package/build.mjs +62 -0
- package/dist/backend/AppShell.js +902 -0
- package/dist/backend/AppShell.js.map +7 -0
- package/dist/backend/ConfirmDialog.js +17 -0
- package/dist/backend/ConfirmDialog.js.map +7 -0
- package/dist/backend/ContextHelp.js +31 -0
- package/dist/backend/ContextHelp.js.map +7 -0
- package/dist/backend/CrudForm.js +2028 -0
- package/dist/backend/CrudForm.js.map +7 -0
- package/dist/backend/DataTable.js +1363 -0
- package/dist/backend/DataTable.js.map +7 -0
- package/dist/backend/EmptyState.js +52 -0
- package/dist/backend/EmptyState.js.map +7 -0
- package/dist/backend/FilterBar.js +140 -0
- package/dist/backend/FilterBar.js.map +7 -0
- package/dist/backend/FilterOverlay.js +279 -0
- package/dist/backend/FilterOverlay.js.map +7 -0
- package/dist/backend/FlashMessages.js +66 -0
- package/dist/backend/FlashMessages.js.map +7 -0
- package/dist/backend/JsonBuilder.js +322 -0
- package/dist/backend/JsonBuilder.js.map +7 -0
- package/dist/backend/JsonDisplay.js +203 -0
- package/dist/backend/JsonDisplay.js.map +7 -0
- package/dist/backend/Page.js +27 -0
- package/dist/backend/Page.js.map +7 -0
- package/dist/backend/PerspectiveSidebar.js +282 -0
- package/dist/backend/PerspectiveSidebar.js.map +7 -0
- package/dist/backend/RowActions.js +148 -0
- package/dist/backend/RowActions.js.map +7 -0
- package/dist/backend/TruncatedCell.js +92 -0
- package/dist/backend/TruncatedCell.js.map +7 -0
- package/dist/backend/UserMenu.js +107 -0
- package/dist/backend/UserMenu.js.map +7 -0
- package/dist/backend/ValueIcons.js +34 -0
- package/dist/backend/ValueIcons.js.map +7 -0
- package/dist/backend/custom-fields/FieldDefinitionsEditor.js +1264 -0
- package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +7 -0
- package/dist/backend/custom-fields/FieldDefinitionsManager.js +332 -0
- package/dist/backend/custom-fields/FieldDefinitionsManager.js.map +7 -0
- package/dist/backend/dashboard/DashboardScreen.js +578 -0
- package/dist/backend/dashboard/DashboardScreen.js.map +7 -0
- package/dist/backend/dashboard/index.js +5 -0
- package/dist/backend/dashboard/index.js.map +7 -0
- package/dist/backend/dashboard/widgetRegistry.js +55 -0
- package/dist/backend/dashboard/widgetRegistry.js.map +7 -0
- package/dist/backend/detail/ActivitiesSection.js +962 -0
- package/dist/backend/detail/ActivitiesSection.js.map +7 -0
- package/dist/backend/detail/AddressEditor.js +413 -0
- package/dist/backend/detail/AddressEditor.js.map +7 -0
- package/dist/backend/detail/AddressTiles.js +437 -0
- package/dist/backend/detail/AddressTiles.js.map +7 -0
- package/dist/backend/detail/AddressesSection.js +264 -0
- package/dist/backend/detail/AddressesSection.js.map +7 -0
- package/dist/backend/detail/AttachmentDeleteDialog.js +41 -0
- package/dist/backend/detail/AttachmentDeleteDialog.js.map +7 -0
- package/dist/backend/detail/AttachmentMetadataDialog.js +517 -0
- package/dist/backend/detail/AttachmentMetadataDialog.js.map +7 -0
- package/dist/backend/detail/AttachmentsSection.js +367 -0
- package/dist/backend/detail/AttachmentsSection.js.map +7 -0
- package/dist/backend/detail/CustomDataSection.js +433 -0
- package/dist/backend/detail/CustomDataSection.js.map +7 -0
- package/dist/backend/detail/DetailFieldsSection.js +75 -0
- package/dist/backend/detail/DetailFieldsSection.js.map +7 -0
- package/dist/backend/detail/ErrorMessage.js +28 -0
- package/dist/backend/detail/ErrorMessage.js.map +7 -0
- package/dist/backend/detail/InlineEditors.js +681 -0
- package/dist/backend/detail/InlineEditors.js.map +7 -0
- package/dist/backend/detail/LoadingMessage.js +14 -0
- package/dist/backend/detail/LoadingMessage.js.map +7 -0
- package/dist/backend/detail/NotesSection.js +1032 -0
- package/dist/backend/detail/NotesSection.js.map +7 -0
- package/dist/backend/detail/TabEmptyState.js +25 -0
- package/dist/backend/detail/TabEmptyState.js.map +7 -0
- package/dist/backend/detail/TagsSection.js +254 -0
- package/dist/backend/detail/TagsSection.js.map +7 -0
- package/dist/backend/detail/addressFormat.js +77 -0
- package/dist/backend/detail/addressFormat.js.map +7 -0
- package/dist/backend/detail/index.js +34 -0
- package/dist/backend/detail/index.js.map +7 -0
- package/dist/backend/fields/registry.generated.js +8 -0
- package/dist/backend/fields/registry.generated.js.map +7 -0
- package/dist/backend/fields/registry.js +29 -0
- package/dist/backend/fields/registry.js.map +7 -0
- package/dist/backend/indexes/PartialIndexBanner.js +58 -0
- package/dist/backend/indexes/PartialIndexBanner.js.map +7 -0
- package/dist/backend/indexes/store.js +62 -0
- package/dist/backend/indexes/store.js.map +7 -0
- package/dist/backend/injection/InjectionSpot.js +179 -0
- package/dist/backend/injection/InjectionSpot.js.map +7 -0
- package/dist/backend/injection/PageInjectionBoundary.js +26 -0
- package/dist/backend/injection/PageInjectionBoundary.js.map +7 -0
- package/dist/backend/injection/helpers.js +26 -0
- package/dist/backend/injection/helpers.js.map +7 -0
- package/dist/backend/injection/widgetRegistry.js +55 -0
- package/dist/backend/injection/widgetRegistry.js.map +7 -0
- package/dist/backend/inputs/ComboboxInput.js +225 -0
- package/dist/backend/inputs/ComboboxInput.js.map +7 -0
- package/dist/backend/inputs/LookupSelect.js +191 -0
- package/dist/backend/inputs/LookupSelect.js.map +7 -0
- package/dist/backend/inputs/PhoneNumberField.js +100 -0
- package/dist/backend/inputs/PhoneNumberField.js.map +7 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js +92 -0
- package/dist/backend/inputs/SwitchableMarkdownInput.js.map +7 -0
- package/dist/backend/inputs/TagsInput.js +222 -0
- package/dist/backend/inputs/TagsInput.js.map +7 -0
- package/dist/backend/inputs/index.js +6 -0
- package/dist/backend/inputs/index.js.map +7 -0
- package/dist/backend/operations/LastOperationBanner.js +80 -0
- package/dist/backend/operations/LastOperationBanner.js.map +7 -0
- package/dist/backend/operations/store.js +183 -0
- package/dist/backend/operations/store.js.map +7 -0
- package/dist/backend/schedule/ScheduleAgenda.js +107 -0
- package/dist/backend/schedule/ScheduleAgenda.js.map +7 -0
- package/dist/backend/schedule/ScheduleGrid.js +107 -0
- package/dist/backend/schedule/ScheduleGrid.js.map +7 -0
- package/dist/backend/schedule/ScheduleToolbar.js +166 -0
- package/dist/backend/schedule/ScheduleToolbar.js.map +7 -0
- package/dist/backend/schedule/ScheduleView.js +165 -0
- package/dist/backend/schedule/ScheduleView.js.map +7 -0
- package/dist/backend/schedule/index.js +6 -0
- package/dist/backend/schedule/index.js.map +7 -0
- package/dist/backend/schedule/recurrence.js +83 -0
- package/dist/backend/schedule/recurrence.js.map +7 -0
- package/dist/backend/schedule/types.js +1 -0
- package/dist/backend/schedule/types.js.map +7 -0
- package/dist/backend/upgrades/UpgradeActionBanner.js +91 -0
- package/dist/backend/upgrades/UpgradeActionBanner.js.map +7 -0
- package/dist/backend/utils/api.js +127 -0
- package/dist/backend/utils/api.js.map +7 -0
- package/dist/backend/utils/apiCall.js +48 -0
- package/dist/backend/utils/apiCall.js.map +7 -0
- package/dist/backend/utils/crud.js +126 -0
- package/dist/backend/utils/crud.js.map +7 -0
- package/dist/backend/utils/customFieldColumns.js +56 -0
- package/dist/backend/utils/customFieldColumns.js.map +7 -0
- package/dist/backend/utils/customFieldDefs.js +143 -0
- package/dist/backend/utils/customFieldDefs.js.map +7 -0
- package/dist/backend/utils/customFieldFilters.js +126 -0
- package/dist/backend/utils/customFieldFilters.js.map +7 -0
- package/dist/backend/utils/customFieldForms.js +162 -0
- package/dist/backend/utils/customFieldForms.js.map +7 -0
- package/dist/backend/utils/customFieldValues.js +26 -0
- package/dist/backend/utils/customFieldValues.js.map +7 -0
- package/dist/backend/utils/flash.js +16 -0
- package/dist/backend/utils/flash.js.map +7 -0
- package/dist/backend/utils/nav.js +185 -0
- package/dist/backend/utils/nav.js.map +7 -0
- package/dist/backend/utils/serverErrors.js +230 -0
- package/dist/backend/utils/serverErrors.js.map +7 -0
- package/dist/frontend/AuthFooter.js +23 -0
- package/dist/frontend/AuthFooter.js.map +7 -0
- package/dist/frontend/LanguageSwitcher.js +57 -0
- package/dist/frontend/LanguageSwitcher.js.map +7 -0
- package/dist/frontend/Layout.js +14 -0
- package/dist/frontend/Layout.js.map +7 -0
- package/dist/index.js +32 -0
- package/dist/index.js.map +7 -0
- package/dist/primitives/DataLoader.js +67 -0
- package/dist/primitives/DataLoader.js.map +7 -0
- package/dist/primitives/ErrorNotice.js +20 -0
- package/dist/primitives/ErrorNotice.js.map +7 -0
- package/dist/primitives/alert.js +38 -0
- package/dist/primitives/alert.js.map +7 -0
- package/dist/primitives/badge.js +28 -0
- package/dist/primitives/badge.js.map +7 -0
- package/dist/primitives/button.js +44 -0
- package/dist/primitives/button.js.map +7 -0
- package/dist/primitives/card.js +91 -0
- package/dist/primitives/card.js.map +7 -0
- package/dist/primitives/checkbox.js +28 -0
- package/dist/primitives/checkbox.js.map +7 -0
- package/dist/primitives/dialog.js +90 -0
- package/dist/primitives/dialog.js.map +7 -0
- package/dist/primitives/input.js +22 -0
- package/dist/primitives/input.js.map +7 -0
- package/dist/primitives/label.js +21 -0
- package/dist/primitives/label.js.map +7 -0
- package/dist/primitives/separator.js +9 -0
- package/dist/primitives/separator.js.map +7 -0
- package/dist/primitives/spinner.js +24 -0
- package/dist/primitives/spinner.js.map +7 -0
- package/dist/primitives/switch.js +80 -0
- package/dist/primitives/switch.js.map +7 -0
- package/dist/primitives/table.js +29 -0
- package/dist/primitives/table.js.map +7 -0
- package/dist/primitives/tabs.js +87 -0
- package/dist/primitives/tabs.js.map +7 -0
- package/dist/primitives/textarea.js +21 -0
- package/dist/primitives/textarea.js.map +7 -0
- package/dist/primitives/tooltip.js +60 -0
- package/dist/primitives/tooltip.js.map +7 -0
- package/dist/theme/QueryProvider.js +44 -0
- package/dist/theme/QueryProvider.js.map +7 -0
- package/dist/theme/ThemeProvider.js +95 -0
- package/dist/theme/ThemeProvider.js.map +7 -0
- package/dist/theme/ThemeToggle.js +88 -0
- package/dist/theme/ThemeToggle.js.map +7 -0
- package/dist/theme/index.js +10 -0
- package/dist/theme/index.js.map +7 -0
- package/dist/types/react-big-calendar.d.js +1 -0
- package/dist/types/react-big-calendar.d.js.map +7 -0
- package/jest.config.cjs +23 -0
- package/jest.setup.ts +55 -0
- package/package.json +105 -0
- package/src/backend/AppShell.tsx +1096 -0
- package/src/backend/ConfirmDialog.tsx +19 -0
- package/src/backend/ContextHelp.tsx +38 -0
- package/src/backend/CrudForm.tsx +2503 -0
- package/src/backend/DataTable.tsx +1730 -0
- package/src/backend/EmptyState.tsx +65 -0
- package/src/backend/FilterBar.tsx +161 -0
- package/src/backend/FilterOverlay.tsx +328 -0
- package/src/backend/FlashMessages.tsx +82 -0
- package/src/backend/JsonBuilder.tsx +362 -0
- package/src/backend/JsonDisplay.tsx +254 -0
- package/src/backend/Page.tsx +30 -0
- package/src/backend/PerspectiveSidebar.tsx +337 -0
- package/src/backend/RowActions.tsx +151 -0
- package/src/backend/TruncatedCell.tsx +133 -0
- package/src/backend/UserMenu.tsx +118 -0
- package/src/backend/ValueIcons.tsx +48 -0
- package/src/backend/__tests__/AppShell.test.tsx +115 -0
- package/src/backend/__tests__/CrudForm.render.test.tsx +30 -0
- package/src/backend/__tests__/DataTable.render.test.tsx +48 -0
- package/src/backend/__tests__/custom-field-filters.test.ts +72 -0
- package/src/backend/__tests__/custom-field-forms.test.ts +54 -0
- package/src/backend/__tests__/serverErrors.test.ts +83 -0
- package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +1292 -0
- package/src/backend/custom-fields/FieldDefinitionsManager.tsx +381 -0
- package/src/backend/dashboard/DashboardScreen.tsx +684 -0
- package/src/backend/dashboard/__tests__/DashboardScreen.test.tsx +112 -0
- package/src/backend/dashboard/index.ts +1 -0
- package/src/backend/dashboard/widgetRegistry.ts +68 -0
- package/src/backend/detail/ActivitiesSection.tsx +1284 -0
- package/src/backend/detail/AddressEditor.tsx +472 -0
- package/src/backend/detail/AddressTiles.tsx +587 -0
- package/src/backend/detail/AddressesSection.tsx +346 -0
- package/src/backend/detail/AttachmentDeleteDialog.tsx +56 -0
- package/src/backend/detail/AttachmentMetadataDialog.tsx +672 -0
- package/src/backend/detail/AttachmentsSection.tsx +414 -0
- package/src/backend/detail/CustomDataSection.tsx +530 -0
- package/src/backend/detail/DetailFieldsSection.tsx +147 -0
- package/src/backend/detail/ErrorMessage.tsx +32 -0
- package/src/backend/detail/InlineEditors.tsx +877 -0
- package/src/backend/detail/LoadingMessage.tsx +14 -0
- package/src/backend/detail/NotesSection.tsx +1275 -0
- package/src/backend/detail/TabEmptyState.tsx +48 -0
- package/src/backend/detail/TagsSection.tsx +314 -0
- package/src/backend/detail/addressFormat.tsx +121 -0
- package/src/backend/detail/index.ts +44 -0
- package/src/backend/fields/registry.generated.ts +8 -0
- package/src/backend/fields/registry.ts +38 -0
- package/src/backend/indexes/PartialIndexBanner.tsx +68 -0
- package/src/backend/indexes/store.ts +88 -0
- package/src/backend/injection/InjectionSpot.tsx +236 -0
- package/src/backend/injection/PageInjectionBoundary.tsx +31 -0
- package/src/backend/injection/helpers.ts +35 -0
- package/src/backend/injection/widgetRegistry.ts +68 -0
- package/src/backend/inputs/ComboboxInput.tsx +269 -0
- package/src/backend/inputs/LookupSelect.tsx +247 -0
- package/src/backend/inputs/PhoneNumberField.tsx +129 -0
- package/src/backend/inputs/SwitchableMarkdownInput.tsx +128 -0
- package/src/backend/inputs/TagsInput.tsx +259 -0
- package/src/backend/inputs/index.ts +5 -0
- package/src/backend/operations/LastOperationBanner.tsx +85 -0
- package/src/backend/operations/__tests__/LastOperationBanner.test.tsx +99 -0
- package/src/backend/operations/store.ts +230 -0
- package/src/backend/schedule/ScheduleAgenda.tsx +136 -0
- package/src/backend/schedule/ScheduleGrid.tsx +136 -0
- package/src/backend/schedule/ScheduleToolbar.tsx +178 -0
- package/src/backend/schedule/ScheduleView.tsx +198 -0
- package/src/backend/schedule/index.ts +5 -0
- package/src/backend/schedule/recurrence.ts +99 -0
- package/src/backend/schedule/types.ts +26 -0
- package/src/backend/upgrades/UpgradeActionBanner.tsx +128 -0
- package/src/backend/utils/__tests__/apiCall.test.ts +109 -0
- package/src/backend/utils/__tests__/crud.test.ts +87 -0
- package/src/backend/utils/__tests__/customFieldDefs.test.ts +25 -0
- package/src/backend/utils/__tests__/customFieldValues.test.ts +35 -0
- package/src/backend/utils/api.ts +149 -0
- package/src/backend/utils/apiCall.ts +96 -0
- package/src/backend/utils/crud.ts +174 -0
- package/src/backend/utils/customFieldColumns.ts +71 -0
- package/src/backend/utils/customFieldDefs.ts +245 -0
- package/src/backend/utils/customFieldFilters.ts +145 -0
- package/src/backend/utils/customFieldForms.ts +196 -0
- package/src/backend/utils/customFieldValues.ts +41 -0
- package/src/backend/utils/flash.ts +17 -0
- package/src/backend/utils/nav.ts +238 -0
- package/src/backend/utils/serverErrors.ts +302 -0
- package/src/frontend/AuthFooter.tsx +29 -0
- package/src/frontend/LanguageSwitcher.tsx +66 -0
- package/src/frontend/Layout.tsx +13 -0
- package/src/index.ts +32 -0
- package/src/primitives/DataLoader.tsx +92 -0
- package/src/primitives/ErrorNotice.tsx +26 -0
- package/src/primitives/alert.tsx +52 -0
- package/src/primitives/badge.tsx +31 -0
- package/src/primitives/button.tsx +47 -0
- package/src/primitives/card.tsx +92 -0
- package/src/primitives/checkbox.tsx +28 -0
- package/src/primitives/dialog.tsx +110 -0
- package/src/primitives/input.tsx +20 -0
- package/src/primitives/label.tsx +18 -0
- package/src/primitives/separator.tsx +7 -0
- package/src/primitives/spinner.tsx +27 -0
- package/src/primitives/switch.tsx +86 -0
- package/src/primitives/table.tsx +27 -0
- package/src/primitives/tabs.tsx +128 -0
- package/src/primitives/textarea.tsx +20 -0
- package/src/primitives/tooltip.tsx +85 -0
- package/src/theme/QueryProvider.tsx +46 -0
- package/src/theme/ThemeProvider.tsx +120 -0
- package/src/theme/ThemeToggle.tsx +88 -0
- package/src/theme/index.ts +3 -0
- package/src/types/react-big-calendar.d.ts +16 -0
- package/tsconfig.build.json +11 -0
- package/tsconfig.json +9 -0
- 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,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
|
+
})
|