@open-mercato/ui 0.6.1-develop.3246.1.dbef9d7392 → 0.6.1-develop.3260.1.dc719cac84
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/.turbo/turbo-build.log +1 -1
- package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +13 -0
- package/dist/ai/AiAssistantLauncher.js +8 -0
- package/dist/ai/AiAssistantLauncher.js.map +2 -2
- package/dist/ai/AiChat.js +226 -58
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/ai/AiDock.js +3 -2
- package/dist/ai/AiDock.js.map +2 -2
- package/dist/ai/ModelPicker.js +186 -0
- package/dist/ai/ModelPicker.js.map +7 -0
- package/dist/ai/index.js +3 -1
- package/dist/ai/index.js.map +2 -2
- package/dist/ai/useAiChat.js +12 -16
- package/dist/ai/useAiChat.js.map +2 -2
- package/dist/backend/CrudForm.js +46 -129
- package/dist/backend/CrudForm.js.map +3 -3
- package/dist/backend/DataTable.js +10 -3
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/EmptyState.js +1 -47
- package/dist/backend/EmptyState.js.map +2 -2
- package/dist/backend/FilterOverlay.js +31 -26
- package/dist/backend/FilterOverlay.js.map +2 -2
- package/dist/backend/FlashMessages.js +23 -25
- package/dist/backend/FlashMessages.js.map +2 -2
- package/dist/backend/JsonBuilder.js +4 -2
- package/dist/backend/JsonBuilder.js.map +2 -2
- package/dist/backend/LogList.js +49 -0
- package/dist/backend/LogList.js.map +7 -0
- package/dist/backend/crud/CollapsibleGroup.js +13 -3
- package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
- package/dist/backend/detail/InlineEditors.js +80 -32
- package/dist/backend/detail/InlineEditors.js.map +2 -2
- package/dist/backend/inputs/ComboboxInput.js +60 -6
- package/dist/backend/inputs/ComboboxInput.js.map +2 -2
- package/dist/backend/inputs/DatePicker.js +6 -142
- package/dist/backend/inputs/DatePicker.js.map +2 -2
- package/dist/backend/inputs/DateTimePicker.js +6 -182
- package/dist/backend/inputs/DateTimePicker.js.map +2 -2
- package/dist/backend/inputs/TagsInput.js +16 -19
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/inputs/TimeInput.js +1 -1
- package/dist/backend/inputs/TimeInput.js.map +2 -2
- package/dist/backend/inputs/TimePicker.js +83 -72
- package/dist/backend/inputs/TimePicker.js.map +2 -2
- package/dist/backend/utils/customFieldForms.js +2 -2
- package/dist/backend/utils/customFieldForms.js.map +1 -1
- package/dist/portal/components/PortalEmptyState.js +13 -9
- package/dist/portal/components/PortalEmptyState.js.map +2 -2
- package/dist/primitives/accordion.js +137 -0
- package/dist/primitives/accordion.js.map +7 -0
- package/dist/primitives/alert.js +180 -12
- package/dist/primitives/alert.js.map +2 -2
- package/dist/primitives/calendar.js +122 -32
- package/dist/primitives/calendar.js.map +2 -2
- package/dist/primitives/compact-select.js +47 -0
- package/dist/primitives/compact-select.js.map +7 -0
- package/dist/primitives/counter-input.js +174 -0
- package/dist/primitives/counter-input.js.map +7 -0
- package/dist/primitives/date-picker-helpers.js +79 -0
- package/dist/primitives/date-picker-helpers.js.map +7 -0
- package/dist/primitives/date-picker.js +216 -0
- package/dist/primitives/date-picker.js.map +7 -0
- package/dist/primitives/date-range-picker.js +239 -0
- package/dist/primitives/date-range-picker.js.map +7 -0
- package/dist/primitives/digit-input.js +157 -0
- package/dist/primitives/digit-input.js.map +7 -0
- package/dist/primitives/empty-state.js +93 -0
- package/dist/primitives/empty-state.js.map +7 -0
- package/dist/primitives/inline-input.js +29 -0
- package/dist/primitives/inline-input.js.map +7 -0
- package/dist/primitives/inline-select.js +41 -0
- package/dist/primitives/inline-select.js.map +7 -0
- package/dist/primitives/notification-stack.js +116 -0
- package/dist/primitives/notification-stack.js.map +7 -0
- package/dist/primitives/notification.js +62 -0
- package/dist/primitives/notification.js.map +7 -0
- package/dist/primitives/rich-editor.js +1230 -0
- package/dist/primitives/rich-editor.js.map +7 -0
- package/dist/primitives/select.js +1 -0
- package/dist/primitives/select.js.map +2 -2
- package/dist/primitives/skeleton.js +55 -0
- package/dist/primitives/skeleton.js.map +7 -0
- package/dist/primitives/tag-input.js +186 -0
- package/dist/primitives/tag-input.js.map +7 -0
- package/dist/primitives/tag.js +66 -13
- package/dist/primitives/tag.js.map +2 -2
- package/dist/primitives/time-picker.js +793 -0
- package/dist/primitives/time-picker.js.map +7 -0
- package/package.json +4 -3
- package/src/ai/AiAssistantLauncher.tsx +8 -0
- package/src/ai/AiChat.tsx +229 -11
- package/src/ai/AiDock.tsx +3 -2
- package/src/ai/ModelPicker.tsx +248 -0
- package/src/ai/__tests__/AiAssistantLauncher.test.tsx +58 -0
- package/src/ai/__tests__/AiChat.conversation.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.debug.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.registry.test.tsx +16 -0
- package/src/ai/__tests__/AiChat.test.tsx +180 -0
- package/src/ai/__tests__/AiChat.thinking-unmount.test.tsx +16 -0
- package/src/ai/__tests__/AiDock.test.tsx +12 -1
- package/src/ai/__tests__/ModelPicker.test.tsx +252 -0
- package/src/ai/index.ts +1 -0
- package/src/ai/useAiChat.ts +29 -16
- package/src/backend/CrudForm.tsx +61 -113
- package/src/backend/DataTable.tsx +17 -5
- package/src/backend/EmptyState.tsx +13 -63
- package/src/backend/FilterOverlay.tsx +38 -22
- package/src/backend/FlashMessages.tsx +23 -24
- package/src/backend/JsonBuilder.tsx +4 -2
- package/src/backend/LogList.tsx +109 -0
- package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
- package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
- package/src/backend/__tests__/LogList.test.tsx +163 -0
- package/src/backend/crud/CollapsibleGroup.tsx +17 -3
- package/src/backend/detail/InlineEditors.tsx +78 -25
- package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
- package/src/backend/inputs/ComboboxInput.tsx +81 -9
- package/src/backend/inputs/DatePicker.tsx +28 -155
- package/src/backend/inputs/DateTimePicker.tsx +25 -193
- package/src/backend/inputs/TagsInput.tsx +10 -13
- package/src/backend/inputs/TimeInput.tsx +2 -1
- package/src/backend/inputs/TimePicker.tsx +99 -70
- package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
- package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
- package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
- package/src/backend/utils/customFieldForms.ts +2 -2
- package/src/portal/components/PortalEmptyState.tsx +27 -22
- package/src/primitives/__tests__/accordion.test.tsx +197 -0
- package/src/primitives/__tests__/alert.test.tsx +204 -0
- package/src/primitives/__tests__/compact-select.test.tsx +98 -0
- package/src/primitives/__tests__/counter-input.test.tsx +165 -0
- package/src/primitives/__tests__/date-picker.test.tsx +221 -0
- package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
- package/src/primitives/__tests__/digit-input.test.tsx +168 -0
- package/src/primitives/__tests__/empty-state.test.tsx +154 -0
- package/src/primitives/__tests__/inline-input.test.tsx +100 -0
- package/src/primitives/__tests__/inline-select.test.tsx +103 -0
- package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
- package/src/primitives/__tests__/notification.test.tsx +119 -0
- package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
- package/src/primitives/__tests__/skeleton.test.tsx +76 -0
- package/src/primitives/__tests__/tag-input.test.tsx +198 -0
- package/src/primitives/__tests__/tag.test.tsx +123 -0
- package/src/primitives/__tests__/time-picker.test.tsx +392 -0
- package/src/primitives/accordion.tsx +225 -0
- package/src/primitives/alert.tsx +234 -24
- package/src/primitives/calendar.tsx +146 -38
- package/src/primitives/compact-select.tsx +59 -0
- package/src/primitives/counter-input.tsx +211 -0
- package/src/primitives/date-picker-helpers.ts +96 -0
- package/src/primitives/date-picker.tsx +290 -0
- package/src/primitives/date-range-picker.tsx +281 -0
- package/src/primitives/digit-input.tsx +208 -0
- package/src/primitives/empty-state.tsx +140 -0
- package/src/primitives/inline-input.tsx +55 -0
- package/src/primitives/inline-select.tsx +74 -0
- package/src/primitives/notification-stack.tsx +185 -0
- package/src/primitives/notification.tsx +136 -0
- package/src/primitives/rich-editor.tsx +1578 -0
- package/src/primitives/select.tsx +1 -0
- package/src/primitives/skeleton.tsx +70 -0
- package/src/primitives/tag-input.tsx +223 -0
- package/src/primitives/tag.tsx +69 -10
- package/src/primitives/time-picker.tsx +1059 -0
|
@@ -1,65 +1,15 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
1
|
+
/**
|
|
2
|
+
* @deprecated Import `EmptyState` from `@open-mercato/ui/primitives/empty-state` instead.
|
|
3
|
+
*
|
|
4
|
+
* This shim preserves the legacy import path
|
|
5
|
+
* `@open-mercato/ui/backend/EmptyState` for existing consumers
|
|
6
|
+
* (TabEmptyState, customers/auth/sales backend pages, …) while the
|
|
7
|
+
* EmptyState is promoted to a primitive in DS Foundation v3. Public API
|
|
8
|
+
* is preserved 1:1 — same props, same default outline button render
|
|
9
|
+
* for the legacy `action` / `actionLabel + onAction` shapes.
|
|
10
|
+
*/
|
|
7
11
|
|
|
8
|
-
|
|
9
|
-
label: string
|
|
10
|
-
onClick?: () => void
|
|
11
|
-
icon?: React.ReactNode
|
|
12
|
-
disabled?: boolean
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
type EmptyStateProps = {
|
|
16
|
-
title: string
|
|
17
|
-
description?: string
|
|
18
|
-
action?: EmptyStateAction
|
|
19
|
-
actionLabel?: string
|
|
20
|
-
onAction?: () => void
|
|
21
|
-
icon?: React.ReactNode
|
|
22
|
-
className?: string
|
|
23
|
-
actionLabelClassName?: string
|
|
24
|
-
children?: React.ReactNode
|
|
25
|
-
}
|
|
12
|
+
"use client"
|
|
26
13
|
|
|
27
|
-
export
|
|
28
|
-
|
|
29
|
-
description,
|
|
30
|
-
action,
|
|
31
|
-
actionLabel,
|
|
32
|
-
onAction,
|
|
33
|
-
icon,
|
|
34
|
-
className,
|
|
35
|
-
actionLabelClassName,
|
|
36
|
-
children,
|
|
37
|
-
}: EmptyStateProps) {
|
|
38
|
-
const resolvedAction = action ?? (actionLabel ? { label: actionLabel, onClick: onAction } : undefined)
|
|
39
|
-
return (
|
|
40
|
-
<div
|
|
41
|
-
className={cn(
|
|
42
|
-
'flex flex-col items-center justify-center rounded-lg border border-dashed border-muted-foreground/40 bg-muted/30 px-6 py-10 text-center',
|
|
43
|
-
className
|
|
44
|
-
)}
|
|
45
|
-
>
|
|
46
|
-
{icon ? <div className="mb-3 text-muted-foreground">{icon}</div> : null}
|
|
47
|
-
<p className="text-sm font-medium text-foreground">{title}</p>
|
|
48
|
-
{description ? <p className="mt-2 text-sm text-muted-foreground">{description}</p> : null}
|
|
49
|
-
{children}
|
|
50
|
-
{resolvedAction ? (
|
|
51
|
-
<Button
|
|
52
|
-
type="button"
|
|
53
|
-
variant="outline"
|
|
54
|
-
size="sm"
|
|
55
|
-
onClick={resolvedAction.onClick}
|
|
56
|
-
className={cn('mt-4 inline-flex items-center gap-2 text-foreground', actionLabelClassName)}
|
|
57
|
-
disabled={resolvedAction.disabled}
|
|
58
|
-
>
|
|
59
|
-
{(resolvedAction.icon ?? <Plus className="h-4 w-4" aria-hidden />)}
|
|
60
|
-
<span>{resolvedAction.label}</span>
|
|
61
|
-
</Button>
|
|
62
|
-
) : null}
|
|
63
|
-
</div>
|
|
64
|
-
)
|
|
65
|
-
}
|
|
14
|
+
export { EmptyState } from '../primitives/empty-state'
|
|
15
|
+
export type { EmptyStateProps } from '../primitives/empty-state'
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
|
+
import { format } from 'date-fns'
|
|
3
4
|
import { Button } from '../primitives/button'
|
|
4
5
|
import { Checkbox } from '../primitives/checkbox'
|
|
6
|
+
import { DateRangePicker } from '../primitives/date-range-picker'
|
|
7
|
+
import type { DateRange } from './date-range/dateRanges'
|
|
5
8
|
import {
|
|
6
9
|
Select,
|
|
7
10
|
SelectContent,
|
|
@@ -227,28 +230,41 @@ export function FilterOverlay({
|
|
|
227
230
|
onChange={(e) => setValue(f.id, e.target.value || undefined)}
|
|
228
231
|
/>
|
|
229
232
|
)}
|
|
230
|
-
{f.type === 'dateRange' && (
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
233
|
+
{f.type === 'dateRange' && (() => {
|
|
234
|
+
const fromStr = values[f.id]?.from
|
|
235
|
+
const toStr = values[f.id]?.to
|
|
236
|
+
const parseISODate = (input: unknown): Date | null => {
|
|
237
|
+
if (typeof input !== 'string' || !input.length) return null
|
|
238
|
+
const candidate = new Date(input)
|
|
239
|
+
return Number.isNaN(candidate.getTime()) ? null : candidate
|
|
240
|
+
}
|
|
241
|
+
const fromDate = parseISODate(fromStr)
|
|
242
|
+
const toDate = parseISODate(toStr)
|
|
243
|
+
const rangeValue: DateRange | null =
|
|
244
|
+
fromDate && toDate
|
|
245
|
+
? { start: fromDate, end: toDate }
|
|
246
|
+
: fromDate
|
|
247
|
+
? { start: fromDate, end: fromDate }
|
|
248
|
+
: toDate
|
|
249
|
+
? { start: toDate, end: toDate }
|
|
250
|
+
: null
|
|
251
|
+
return (
|
|
252
|
+
<DateRangePicker
|
|
253
|
+
value={rangeValue}
|
|
254
|
+
onChange={(next) => {
|
|
255
|
+
if (!next) {
|
|
256
|
+
setValue(f.id, { from: undefined, to: undefined })
|
|
257
|
+
return
|
|
258
|
+
}
|
|
259
|
+
setValue(f.id, {
|
|
260
|
+
from: format(next.start, 'yyyy-MM-dd'),
|
|
261
|
+
to: format(next.end, 'yyyy-MM-dd'),
|
|
262
|
+
})
|
|
263
|
+
}}
|
|
264
|
+
placeholder={t('ui.filters.dateRange.placeholder', 'Pick a date range')}
|
|
265
|
+
/>
|
|
266
|
+
)
|
|
267
|
+
})()}
|
|
252
268
|
{f.type === 'select' && (
|
|
253
269
|
<div className="space-y-1">
|
|
254
270
|
{f.multiple ? (
|
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
"use client"
|
|
2
2
|
import * as React from 'react'
|
|
3
|
-
import {
|
|
4
|
-
import { IconButton } from '../primitives/icon-button'
|
|
3
|
+
import { Alert, type AlertStatus } from '../primitives/alert'
|
|
5
4
|
|
|
6
5
|
export type FlashKind = 'success' | 'error' | 'warning' | 'info'
|
|
7
6
|
|
|
7
|
+
const flashKindToAlertStatus: Record<FlashKind, AlertStatus> = {
|
|
8
|
+
success: 'success',
|
|
9
|
+
error: 'error',
|
|
10
|
+
warning: 'warning',
|
|
11
|
+
info: 'information',
|
|
12
|
+
}
|
|
13
|
+
|
|
8
14
|
// Programmatic API to show a flash message without navigation.
|
|
9
15
|
// Consumers can import { flash } and call flash('text', 'error').
|
|
10
16
|
export function flash(message: string, type: FlashKind = 'info') {
|
|
@@ -141,32 +147,25 @@ function FlashMessagesInner() {
|
|
|
141
147
|
return () => window.removeEventListener('flash', handler as EventListener)
|
|
142
148
|
}, [showFlash])
|
|
143
149
|
|
|
144
|
-
|
|
150
|
+
const handleDismiss = React.useCallback(() => {
|
|
151
|
+
clearDismissTimer()
|
|
152
|
+
setMsg(null)
|
|
153
|
+
}, [clearDismissTimer])
|
|
145
154
|
|
|
146
|
-
|
|
147
|
-
success: 'bg-status-success-icon',
|
|
148
|
-
error: 'bg-status-error-icon',
|
|
149
|
-
warning: 'bg-status-warning-icon',
|
|
150
|
-
info: 'bg-status-info-icon',
|
|
151
|
-
}
|
|
152
|
-
const color = colorMap[kind]
|
|
155
|
+
if (!msg) return null
|
|
153
156
|
|
|
154
157
|
return (
|
|
155
158
|
<div className="pointer-events-none fixed left-3 right-3 top-3 z-toast sm:left-auto sm:right-4 sm:w-[380px]">
|
|
156
|
-
<div className=
|
|
157
|
-
<
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
>
|
|
167
|
-
<X size={16} />
|
|
168
|
-
</IconButton>
|
|
169
|
-
</div>
|
|
159
|
+
<div className="pointer-events-auto">
|
|
160
|
+
<Alert
|
|
161
|
+
status={flashKindToAlertStatus[kind]}
|
|
162
|
+
size="sm"
|
|
163
|
+
dismissible
|
|
164
|
+
onDismiss={handleDismiss}
|
|
165
|
+
className="shadow-md"
|
|
166
|
+
>
|
|
167
|
+
{msg}
|
|
168
|
+
</Alert>
|
|
170
169
|
</div>
|
|
171
170
|
</div>
|
|
172
171
|
)
|
|
@@ -4,6 +4,7 @@ import * as React from 'react'
|
|
|
4
4
|
import { Button } from '../primitives/button'
|
|
5
5
|
import { IconButton } from '../primitives/icon-button'
|
|
6
6
|
import { Input } from '../primitives/input'
|
|
7
|
+
import { InlineInput } from '../primitives/inline-input'
|
|
7
8
|
import {
|
|
8
9
|
Select,
|
|
9
10
|
SelectContent,
|
|
@@ -327,8 +328,9 @@ function JsonNode({ data, onChange, onDelete, readOnly, label, isRoot }: JsonNod
|
|
|
327
328
|
<div key={idx} className="flex">
|
|
328
329
|
<div className="pt-2">
|
|
329
330
|
{/* Key Renamer */}
|
|
330
|
-
<
|
|
331
|
-
className="w-full sm:w-[100px]
|
|
331
|
+
<InlineInput
|
|
332
|
+
className="w-full sm:w-[100px]"
|
|
333
|
+
inputClassName="text-right text-xs font-mono pr-1"
|
|
332
334
|
value={key}
|
|
333
335
|
onChange={(e) => handleKeyRename(key, e.target.value)}
|
|
334
336
|
disabled={readOnly}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use client"
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { cn } from '@open-mercato/shared/lib/utils'
|
|
5
|
+
import {
|
|
6
|
+
Accordion,
|
|
7
|
+
AccordionContent,
|
|
8
|
+
AccordionItem,
|
|
9
|
+
AccordionTrigger,
|
|
10
|
+
} from '@open-mercato/ui/primitives/accordion'
|
|
11
|
+
import { Badge, type BadgeProps } from '@open-mercato/ui/primitives/badge'
|
|
12
|
+
|
|
13
|
+
export type LogListLevel = 'info' | 'warn' | 'warning' | 'error' | 'debug' | string
|
|
14
|
+
|
|
15
|
+
export type LogListEntry = {
|
|
16
|
+
id: string
|
|
17
|
+
/** Pre-formatted timestamp (consumer formats with `formatDateTime()` / locale). */
|
|
18
|
+
time: React.ReactNode
|
|
19
|
+
/** Raw level value — drives the badge color via the built-in map. */
|
|
20
|
+
level: LogListLevel
|
|
21
|
+
/** Optional translated label override; falls back to `level` verbatim. */
|
|
22
|
+
levelLabel?: React.ReactNode
|
|
23
|
+
/** Trigger message (one-liner — truncates in the trigger row, full text available inside `body`). */
|
|
24
|
+
message: React.ReactNode
|
|
25
|
+
/** Body content rendered inside `AccordionContent` when expanded — typically a metadata grid + JSON payload. */
|
|
26
|
+
body: React.ReactNode
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
type LogLevelBadgeVariant = NonNullable<BadgeProps['variant']>
|
|
30
|
+
|
|
31
|
+
const LOG_LEVEL_BADGE_VARIANT: Record<string, LogLevelBadgeVariant> = {
|
|
32
|
+
info: 'info',
|
|
33
|
+
warn: 'warning',
|
|
34
|
+
warning: 'warning',
|
|
35
|
+
error: 'error',
|
|
36
|
+
debug: 'neutral',
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type LogLevelBadgeProps = {
|
|
40
|
+
level: LogListLevel
|
|
41
|
+
label?: React.ReactNode
|
|
42
|
+
className?: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function LogLevelBadge({ level, label, className }: LogLevelBadgeProps) {
|
|
46
|
+
const key = typeof level === 'string' ? level.toLowerCase() : ''
|
|
47
|
+
const variant = LOG_LEVEL_BADGE_VARIANT[key] ?? 'secondary'
|
|
48
|
+
return (
|
|
49
|
+
<Badge variant={variant} className={className} data-log-level={key || undefined}>
|
|
50
|
+
{label ?? level}
|
|
51
|
+
</Badge>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export type LogListProps = {
|
|
56
|
+
entries: LogListEntry[]
|
|
57
|
+
/** Optional message rendered when the list is empty. Pass through `useT()` for i18n. */
|
|
58
|
+
emptyMessage?: React.ReactNode
|
|
59
|
+
className?: string
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Unified `Accordion`-driven log list for admin "logs" tabs (integrations,
|
|
64
|
+
* data sync runs, payment gateway transactions, …). Replaces the per-module
|
|
65
|
+
* `<table>` + `expandedLogId` row-expand pattern with a Figma-aligned card
|
|
66
|
+
* list that uses the DS `Accordion` primitive under the hood. Each row
|
|
67
|
+
* shows time + level badge + message in the trigger; the consumer controls
|
|
68
|
+
* the expanded body content (metadata grid, JSON payload, etc.).
|
|
69
|
+
*
|
|
70
|
+
* `type='single' collapsible` matches the previous "one expanded row at a
|
|
71
|
+
* time" behaviour. Switch to `Accordion` directly if you need multi-open.
|
|
72
|
+
*/
|
|
73
|
+
export function LogList({ entries, emptyMessage, className }: LogListProps) {
|
|
74
|
+
if (entries.length === 0) {
|
|
75
|
+
return emptyMessage ? (
|
|
76
|
+
<p className={cn('py-4 text-sm text-muted-foreground', className)} data-slot="log-list-empty">
|
|
77
|
+
{emptyMessage}
|
|
78
|
+
</p>
|
|
79
|
+
) : null
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<Accordion
|
|
84
|
+
type="single"
|
|
85
|
+
collapsible
|
|
86
|
+
className={cn('space-y-2', className)}
|
|
87
|
+
data-slot="log-list"
|
|
88
|
+
>
|
|
89
|
+
{entries.map((entry) => (
|
|
90
|
+
<AccordionItem key={entry.id} value={entry.id} data-log-entry-id={entry.id}>
|
|
91
|
+
<AccordionTrigger triggerIcon="chevron">
|
|
92
|
+
<span className="grid w-full items-center gap-3 sm:grid-cols-[10rem_5rem_minmax(0,1fr)]">
|
|
93
|
+
<span className="truncate whitespace-nowrap text-xs text-muted-foreground sm:text-sm">
|
|
94
|
+
{entry.time}
|
|
95
|
+
</span>
|
|
96
|
+
<span className="justify-self-start">
|
|
97
|
+
<LogLevelBadge level={entry.level} label={entry.levelLabel} />
|
|
98
|
+
</span>
|
|
99
|
+
<span className="min-w-0 truncate text-left text-sm font-normal">
|
|
100
|
+
{entry.message}
|
|
101
|
+
</span>
|
|
102
|
+
</span>
|
|
103
|
+
</AccordionTrigger>
|
|
104
|
+
<AccordionContent>{entry.body}</AccordionContent>
|
|
105
|
+
</AccordionItem>
|
|
106
|
+
))}
|
|
107
|
+
</Accordion>
|
|
108
|
+
)
|
|
109
|
+
}
|
|
@@ -60,17 +60,20 @@ describe('CrudForm — datetime field types render correct picker component', ()
|
|
|
60
60
|
expect(html).toContain('Pick a time')
|
|
61
61
|
})
|
|
62
62
|
|
|
63
|
-
it('type: datetime-local renders
|
|
63
|
+
it('type: datetime-local renders DateTimePicker trigger (DS unification — no native input)', () => {
|
|
64
64
|
const fields: CrudField[] = [{ id: 'at', label: 'At', type: 'datetime-local' }]
|
|
65
65
|
const html = renderForm(fields)
|
|
66
|
-
expect(html).toContain('
|
|
67
|
-
expect(html).
|
|
66
|
+
expect(html).toContain('aria-haspopup="dialog"')
|
|
67
|
+
expect(html).toContain('data-slot="date-picker-trigger"')
|
|
68
|
+
expect(html).not.toContain('type="datetime-local"')
|
|
68
69
|
})
|
|
69
70
|
|
|
70
|
-
it('type: date renders
|
|
71
|
+
it('type: date renders DatePicker trigger (DS unification — no native input)', () => {
|
|
71
72
|
const fields: CrudField[] = [{ id: 'dt', label: 'Date', type: 'date' }]
|
|
72
73
|
const html = renderForm(fields)
|
|
73
|
-
expect(html).toContain('
|
|
74
|
+
expect(html).toContain('aria-haspopup="dialog"')
|
|
75
|
+
expect(html).toContain('data-slot="date-picker-trigger"')
|
|
76
|
+
expect(html).not.toContain('type="date"')
|
|
74
77
|
})
|
|
75
78
|
|
|
76
79
|
it('datepicker shows formatted date in trigger when initialValues provided', () => {
|
|
@@ -87,9 +90,11 @@ describe('CrudForm — datetime field types render correct picker component', ()
|
|
|
87
90
|
expect(html).toContain('2026')
|
|
88
91
|
})
|
|
89
92
|
|
|
90
|
-
it('time shows
|
|
93
|
+
it('time shows 12h-formatted value in trigger when initialValues provided', () => {
|
|
91
94
|
const fields: CrudField[] = [{ id: 'sync_time', label: 'Sync Time', type: 'time' }]
|
|
92
95
|
const html = renderForm(fields, { sync_time: '14:30' })
|
|
93
|
-
|
|
96
|
+
// Legacy TimePicker shim renders the trigger in 12h "HH:MM AM/PM" format to
|
|
97
|
+
// match the slot list inside the popover.
|
|
98
|
+
expect(html).toContain('02:30 PM')
|
|
94
99
|
})
|
|
95
100
|
})
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { act, fireEvent, render, screen } from '@testing-library/react'
|
|
7
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
8
|
+
import { FilterOverlay, type FilterDef, type FilterValues } from '../FilterOverlay'
|
|
9
|
+
|
|
10
|
+
function renderOverlay({
|
|
11
|
+
filters,
|
|
12
|
+
initialValues = {},
|
|
13
|
+
onApply = jest.fn(),
|
|
14
|
+
onClear = jest.fn(),
|
|
15
|
+
}: {
|
|
16
|
+
filters: FilterDef[]
|
|
17
|
+
initialValues?: FilterValues
|
|
18
|
+
onApply?: (v: FilterValues) => void
|
|
19
|
+
onClear?: () => void
|
|
20
|
+
}) {
|
|
21
|
+
return render(
|
|
22
|
+
<I18nProvider locale="en" dict={{}}>
|
|
23
|
+
<FilterOverlay
|
|
24
|
+
open={true}
|
|
25
|
+
onOpenChange={() => {}}
|
|
26
|
+
filters={filters}
|
|
27
|
+
initialValues={initialValues}
|
|
28
|
+
onApply={onApply}
|
|
29
|
+
onClear={onClear}
|
|
30
|
+
/>
|
|
31
|
+
</I18nProvider>,
|
|
32
|
+
)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
describe('FilterOverlay dateRange — DS unification (DateRangePicker swap)', () => {
|
|
36
|
+
const dateRangeFilter: FilterDef = {
|
|
37
|
+
id: 'createdRange',
|
|
38
|
+
label: 'Created',
|
|
39
|
+
type: 'dateRange',
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
it('renders the DateRangePicker primitive trigger (not raw <input type="date">)', () => {
|
|
43
|
+
renderOverlay({ filters: [dateRangeFilter] })
|
|
44
|
+
expect(document.querySelector('[data-slot="date-range-picker-trigger"]')).toBeInTheDocument()
|
|
45
|
+
expect(document.querySelector('input[type="date"]')).toBeNull()
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('preserves the existing { from, to } string output shape on Apply (BC for consumers)', async () => {
|
|
49
|
+
const onApply = jest.fn()
|
|
50
|
+
renderOverlay({
|
|
51
|
+
filters: [dateRangeFilter],
|
|
52
|
+
initialValues: { createdRange: { from: '2026-05-01', to: '2026-05-09' } },
|
|
53
|
+
onApply,
|
|
54
|
+
})
|
|
55
|
+
// Apply existing values — output shape stays as { from: 'YYYY-MM-DD', to: 'YYYY-MM-DD' }.
|
|
56
|
+
// Multiple Apply buttons may exist (FilterOverlay top + DateRangePicker popover);
|
|
57
|
+
// pick the FilterOverlay top one (always rendered, popover may or may not be open).
|
|
58
|
+
const applyBtns = screen.getAllByRole('button', { name: /^apply$/i })
|
|
59
|
+
const overlayApply = applyBtns[0]
|
|
60
|
+
await act(async () => {
|
|
61
|
+
fireEvent.click(overlayApply)
|
|
62
|
+
})
|
|
63
|
+
expect(onApply).toHaveBeenCalledTimes(1)
|
|
64
|
+
const applied = onApply.mock.calls[0][0]
|
|
65
|
+
expect(applied.createdRange).toBeDefined()
|
|
66
|
+
expect(typeof applied.createdRange.from).toBe('string')
|
|
67
|
+
expect(typeof applied.createdRange.to).toBe('string')
|
|
68
|
+
expect(applied.createdRange.from).toBe('2026-05-01')
|
|
69
|
+
expect(applied.createdRange.to).toBe('2026-05-09')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('renders the formatted range in the trigger when initial { from, to } is provided', () => {
|
|
73
|
+
renderOverlay({
|
|
74
|
+
filters: [dateRangeFilter],
|
|
75
|
+
initialValues: { createdRange: { from: '2026-05-01', to: '2026-05-09' } },
|
|
76
|
+
})
|
|
77
|
+
const trigger = document.querySelector('[data-slot="date-range-picker-trigger"]')
|
|
78
|
+
expect(trigger).toBeInTheDocument()
|
|
79
|
+
expect(trigger!.textContent).toContain('May')
|
|
80
|
+
expect(trigger!.textContent).toContain('2026')
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
it('handles empty / undefined values gracefully (no crash)', () => {
|
|
84
|
+
expect(() => {
|
|
85
|
+
renderOverlay({ filters: [dateRangeFilter], initialValues: {} })
|
|
86
|
+
}).not.toThrow()
|
|
87
|
+
expect(document.querySelector('[data-slot="date-range-picker-trigger"]')).toBeInTheDocument()
|
|
88
|
+
})
|
|
89
|
+
})
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
7
|
+
import { LogLevelBadge, LogList, type LogListEntry } from '../LogList'
|
|
8
|
+
|
|
9
|
+
function makeEntry(over: Partial<LogListEntry> = {}): LogListEntry {
|
|
10
|
+
return {
|
|
11
|
+
id: 'log-1',
|
|
12
|
+
time: '2026-05-12 10:00',
|
|
13
|
+
level: 'info',
|
|
14
|
+
message: 'Sample log message',
|
|
15
|
+
body: <div data-testid="body-1">Body of log-1</div>,
|
|
16
|
+
...over,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('LogLevelBadge', () => {
|
|
21
|
+
it('applies the info palette by default (semantic status tokens)', () => {
|
|
22
|
+
const { container } = render(<LogLevelBadge level="info" />)
|
|
23
|
+
const badge = container.firstElementChild as HTMLElement
|
|
24
|
+
expect(badge).toHaveAttribute('data-log-level', 'info')
|
|
25
|
+
expect(badge.className).toContain('bg-status-info-bg')
|
|
26
|
+
expect(badge.className).toContain('text-status-info-text')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('maps warn / warning / error / debug to the matching semantic palette', () => {
|
|
30
|
+
const { rerender, container } = render(<LogLevelBadge level="warn" />)
|
|
31
|
+
expect(container.firstElementChild!).toHaveAttribute('data-log-level', 'warn')
|
|
32
|
+
expect(container.firstElementChild!.className).toContain('bg-status-warning-bg')
|
|
33
|
+
|
|
34
|
+
rerender(<LogLevelBadge level="warning" />)
|
|
35
|
+
expect(container.firstElementChild!).toHaveAttribute('data-log-level', 'warning')
|
|
36
|
+
expect(container.firstElementChild!.className).toContain('bg-status-warning-bg')
|
|
37
|
+
|
|
38
|
+
rerender(<LogLevelBadge level="error" />)
|
|
39
|
+
expect(container.firstElementChild!).toHaveAttribute('data-log-level', 'error')
|
|
40
|
+
expect(container.firstElementChild!.className).toContain('bg-status-error-bg')
|
|
41
|
+
|
|
42
|
+
rerender(<LogLevelBadge level="debug" />)
|
|
43
|
+
expect(container.firstElementChild!).toHaveAttribute('data-log-level', 'debug')
|
|
44
|
+
expect(container.firstElementChild!.className).toContain('bg-status-neutral-bg')
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('renders an unrecognized level without status palette classes (falls back to Badge secondary)', () => {
|
|
48
|
+
const { container } = render(<LogLevelBadge level="custom-level" />)
|
|
49
|
+
const badge = container.firstElementChild as HTMLElement
|
|
50
|
+
expect(badge).toHaveAttribute('data-log-level', 'custom-level')
|
|
51
|
+
expect(badge.className).not.toContain('bg-status-info-bg')
|
|
52
|
+
expect(badge.className).not.toContain('bg-status-warning-bg')
|
|
53
|
+
expect(badge.className).not.toContain('bg-status-error-bg')
|
|
54
|
+
expect(badge.className).not.toContain('bg-status-neutral-bg')
|
|
55
|
+
expect(badge.className).toContain('bg-secondary')
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('honors a custom label and falls back to level when label is omitted', () => {
|
|
59
|
+
const { rerender } = render(<LogLevelBadge level="info" label="Information" />)
|
|
60
|
+
expect(screen.getByText('Information')).toBeInTheDocument()
|
|
61
|
+
|
|
62
|
+
rerender(<LogLevelBadge level="error" />)
|
|
63
|
+
expect(screen.getByText('error')).toBeInTheDocument()
|
|
64
|
+
})
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
describe('LogList', () => {
|
|
68
|
+
it('renders the empty message when entries are empty', () => {
|
|
69
|
+
render(<LogList entries={[]} emptyMessage="No log entries" />)
|
|
70
|
+
expect(screen.getByText('No log entries')).toBeInTheDocument()
|
|
71
|
+
expect(screen.getByText('No log entries').getAttribute('data-slot')).toBe('log-list-empty')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
it('renders nothing when entries are empty and no empty message is provided', () => {
|
|
75
|
+
const { container } = render(<LogList entries={[]} />)
|
|
76
|
+
expect(container.firstChild).toBeNull()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('renders one accordion item per entry with time / level / message in the trigger', () => {
|
|
80
|
+
render(
|
|
81
|
+
<LogList
|
|
82
|
+
entries={[
|
|
83
|
+
makeEntry({ id: 'a', time: '10:00', level: 'info', message: 'Connected' }),
|
|
84
|
+
makeEntry({ id: 'b', time: '10:01', level: 'error', message: 'Timeout' }),
|
|
85
|
+
]}
|
|
86
|
+
/>,
|
|
87
|
+
)
|
|
88
|
+
expect(screen.getByText('Connected')).toBeInTheDocument()
|
|
89
|
+
expect(screen.getByText('Timeout')).toBeInTheDocument()
|
|
90
|
+
expect(screen.getByText('10:00')).toBeInTheDocument()
|
|
91
|
+
expect(screen.getByText('10:01')).toBeInTheDocument()
|
|
92
|
+
expect(screen.getByText('info')).toBeInTheDocument()
|
|
93
|
+
expect(screen.getByText('error')).toBeInTheDocument()
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
it('forwards the entry id as data-log-entry-id on the AccordionItem', () => {
|
|
97
|
+
const { container } = render(
|
|
98
|
+
<LogList entries={[makeEntry({ id: 'log-42' })]} />,
|
|
99
|
+
)
|
|
100
|
+
expect(container.querySelector('[data-log-entry-id="log-42"]')).not.toBeNull()
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('hides each entry body until the trigger is clicked, then reveals it (single + collapsible)', () => {
|
|
104
|
+
const { container } = render(
|
|
105
|
+
<LogList
|
|
106
|
+
entries={[
|
|
107
|
+
makeEntry({ id: 'a', message: 'A' }),
|
|
108
|
+
makeEntry({ id: 'b', message: 'B' }),
|
|
109
|
+
]}
|
|
110
|
+
/>,
|
|
111
|
+
)
|
|
112
|
+
const items = container.querySelectorAll('[data-slot="accordion-item"]')
|
|
113
|
+
expect(items[0]).toHaveAttribute('data-state', 'closed')
|
|
114
|
+
expect(items[1]).toHaveAttribute('data-state', 'closed')
|
|
115
|
+
|
|
116
|
+
fireEvent.click(screen.getByRole('button', { name: /A/ }))
|
|
117
|
+
expect(items[0]).toHaveAttribute('data-state', 'open')
|
|
118
|
+
expect(items[1]).toHaveAttribute('data-state', 'closed')
|
|
119
|
+
|
|
120
|
+
fireEvent.click(screen.getByRole('button', { name: /B/ }))
|
|
121
|
+
expect(items[0]).toHaveAttribute('data-state', 'closed')
|
|
122
|
+
expect(items[1]).toHaveAttribute('data-state', 'open')
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('uses the levelLabel override when provided (e.g. translated level text)', () => {
|
|
126
|
+
render(
|
|
127
|
+
<LogList
|
|
128
|
+
entries={[
|
|
129
|
+
makeEntry({ level: 'warn', levelLabel: 'Ostrzeżenie' }),
|
|
130
|
+
]}
|
|
131
|
+
/>,
|
|
132
|
+
)
|
|
133
|
+
expect(screen.getByText('Ostrzeżenie')).toBeInTheDocument()
|
|
134
|
+
expect(screen.queryByText('warn')).toBeNull()
|
|
135
|
+
})
|
|
136
|
+
|
|
137
|
+
it('supports ReactNode message + body slots (not just strings); body mounts on expand', () => {
|
|
138
|
+
render(
|
|
139
|
+
<LogList
|
|
140
|
+
entries={[
|
|
141
|
+
{
|
|
142
|
+
id: 'rich',
|
|
143
|
+
time: <span data-testid="time">10:00:01</span>,
|
|
144
|
+
level: 'info',
|
|
145
|
+
message: <span data-testid="message">Detailed event</span>,
|
|
146
|
+
body: <pre data-testid="body">JSON payload</pre>,
|
|
147
|
+
},
|
|
148
|
+
]}
|
|
149
|
+
/>,
|
|
150
|
+
)
|
|
151
|
+
expect(screen.getByTestId('time')).toBeInTheDocument()
|
|
152
|
+
expect(screen.getByTestId('message')).toBeInTheDocument()
|
|
153
|
+
// Radix lazy-mounts AccordionContent children — body appears only after the trigger is opened.
|
|
154
|
+
expect(screen.queryByTestId('body')).toBeNull()
|
|
155
|
+
fireEvent.click(screen.getByRole('button', { name: /Detailed event/ }))
|
|
156
|
+
expect(screen.getByTestId('body')).toBeInTheDocument()
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it('marks the root with data-slot="log-list"', () => {
|
|
160
|
+
const { container } = render(<LogList entries={[makeEntry()]} />)
|
|
161
|
+
expect(container.querySelector('[data-slot="log-list"]')).not.toBeNull()
|
|
162
|
+
})
|
|
163
|
+
})
|