@open-mercato/ui 0.6.1-develop.3256.1.fe3dec2464 → 0.6.1-develop.3276.1.1fcd145078
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/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/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,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
|
+
})
|
|
@@ -75,11 +75,21 @@ export const CollapsibleGroup = React.forwardRef<CollapsibleGroupHandle, Collaps
|
|
|
75
75
|
<div
|
|
76
76
|
id={`collapsible-group-wrapper-${groupId}`}
|
|
77
77
|
className={cn(
|
|
78
|
-
|
|
78
|
+
// Aligned with Figma Accordion `210:4022`: rounded-10, three visual
|
|
79
|
+
// states (closed = white card + soft border + x-small shadow,
|
|
80
|
+
// hover-while-closed = bg-muted + no border + no shadow,
|
|
81
|
+
// open = bg-muted + no border + no shadow). Destructive border
|
|
82
|
+
// wins over the Figma states when the group has validation errors.
|
|
83
|
+
'rounded-[10px] border bg-card transition-colors',
|
|
79
84
|
!isHydrated && 'invisible',
|
|
80
|
-
errorCount > 0
|
|
85
|
+
errorCount > 0
|
|
86
|
+
? 'border-destructive'
|
|
87
|
+
: expanded
|
|
88
|
+
? 'border-transparent bg-muted shadow-none'
|
|
89
|
+
: 'border-border shadow-xs hover:border-transparent hover:bg-muted hover:shadow-none',
|
|
81
90
|
)}
|
|
82
91
|
data-collapsible-group-id={groupId}
|
|
92
|
+
data-state={expanded ? 'open' : 'closed'}
|
|
83
93
|
data-persistence-hydrated={isHydrated ? 'true' : 'false'}
|
|
84
94
|
aria-hidden={isHydrated ? undefined : true}
|
|
85
95
|
>
|
|
@@ -96,7 +106,11 @@ export const CollapsibleGroup = React.forwardRef<CollapsibleGroupHandle, Collaps
|
|
|
96
106
|
variant="muted"
|
|
97
107
|
onClick={toggle}
|
|
98
108
|
className={cn(
|
|
99
|
-
|
|
109
|
+
// Defer hover styling to the surrounding wrapper so the whole
|
|
110
|
+
// row toggles to `bg-muted` together (Figma Accordion behaviour)
|
|
111
|
+
// instead of stacking a second hover layer over the trigger
|
|
112
|
+
// button.
|
|
113
|
+
'flex-1 rounded-md px-2 py-1 text-sm font-medium hover:bg-transparent dark:hover:bg-transparent',
|
|
100
114
|
chevronPosition === 'left' ? 'justify-start gap-2' : 'justify-between',
|
|
101
115
|
)}
|
|
102
116
|
aria-expanded={expanded}
|
|
@@ -14,8 +14,10 @@ import {
|
|
|
14
14
|
SelectValue,
|
|
15
15
|
} from '@open-mercato/ui/primitives/select'
|
|
16
16
|
import { Textarea } from '@open-mercato/ui/primitives/textarea'
|
|
17
|
+
import { DatePicker } from '@open-mercato/ui/primitives/date-picker'
|
|
17
18
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
18
19
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
20
|
+
import { format } from 'date-fns'
|
|
19
21
|
import { LoadingMessage } from './LoadingMessage'
|
|
20
22
|
import { mapCrudServerErrorToFormErrors } from '../utils/serverErrors'
|
|
21
23
|
import { MarkdownPreview } from '../markdown'
|
|
@@ -317,33 +319,82 @@ export function InlineTextEditor({
|
|
|
317
319
|
}
|
|
318
320
|
}}
|
|
319
321
|
>
|
|
320
|
-
{
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
322
|
+
{(() => {
|
|
323
|
+
const lowerType = (inputType ?? resolvedType ?? '').toLowerCase()
|
|
324
|
+
const isDate = lowerType === 'date'
|
|
325
|
+
const isDateTime = lowerType === 'datetime-local' || lowerType === 'datetime'
|
|
326
|
+
if (resolvedType === 'tel') {
|
|
327
|
+
return (
|
|
328
|
+
<PhoneNumberField
|
|
329
|
+
value={draft.length ? draft : undefined}
|
|
330
|
+
onValueChange={(next) => {
|
|
331
|
+
if (error) setError(null)
|
|
332
|
+
setDraft(next ?? '')
|
|
333
|
+
}}
|
|
334
|
+
placeholder={placeholder}
|
|
335
|
+
autoFocus
|
|
336
|
+
disabled={saving}
|
|
337
|
+
minDigits={7}
|
|
338
|
+
/>
|
|
339
|
+
)
|
|
340
|
+
}
|
|
341
|
+
if (isDate || isDateTime) {
|
|
342
|
+
let parsed: Date | null = null
|
|
343
|
+
if (draft && draft.length) {
|
|
344
|
+
const candidate = new Date(draft)
|
|
345
|
+
if (!Number.isNaN(candidate.getTime())) parsed = candidate
|
|
346
|
+
}
|
|
347
|
+
return (
|
|
348
|
+
<DatePicker
|
|
349
|
+
value={parsed}
|
|
350
|
+
onChange={(date) => {
|
|
351
|
+
if (error) setError(null)
|
|
352
|
+
const formatted = !date
|
|
353
|
+
? ''
|
|
354
|
+
: isDateTime
|
|
355
|
+
? format(date, "yyyy-MM-dd'T'HH:mm")
|
|
356
|
+
: format(date, 'yyyy-MM-dd')
|
|
357
|
+
setDraft(formatted)
|
|
358
|
+
setSaving(true)
|
|
359
|
+
;(async () => {
|
|
360
|
+
try {
|
|
361
|
+
await onSave(formatted.length ? formatted : null)
|
|
362
|
+
setEditingSafe(false)
|
|
363
|
+
} catch (err) {
|
|
364
|
+
setError(resolveInlineErrorMessage(err, fallbackError))
|
|
365
|
+
} finally {
|
|
366
|
+
setSaving(false)
|
|
367
|
+
}
|
|
368
|
+
})()
|
|
369
|
+
}}
|
|
370
|
+
withTime={isDateTime}
|
|
371
|
+
footer="apply-cancel"
|
|
372
|
+
placeholder={placeholder}
|
|
373
|
+
disabled={saving}
|
|
374
|
+
/>
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
return (
|
|
378
|
+
<input
|
|
379
|
+
className="w-full rounded-md border px-3 py-2 text-sm focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
|
|
380
|
+
value={draft}
|
|
381
|
+
onChange={(event) => {
|
|
382
|
+
if (error) setError(null)
|
|
383
|
+
setDraft(event.target.value)
|
|
384
|
+
}}
|
|
385
|
+
placeholder={placeholder}
|
|
386
|
+
type={inputType ?? resolvedType}
|
|
387
|
+
autoFocus
|
|
388
|
+
/>
|
|
389
|
+
)
|
|
390
|
+
})()}
|
|
345
391
|
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
|
346
392
|
{renderBelowInput ? renderBelowInput({ draft, resolvedType, error, saving }) : null}
|
|
393
|
+
{(() => {
|
|
394
|
+
const lowerType = (inputType ?? resolvedType ?? '').toLowerCase()
|
|
395
|
+
const isDateLike = lowerType === 'date' || lowerType === 'datetime-local' || lowerType === 'datetime'
|
|
396
|
+
if (isDateLike) return null
|
|
397
|
+
return (
|
|
347
398
|
<div className="flex items-center gap-2">
|
|
348
399
|
<Button type="submit" size="sm" disabled={saving}>
|
|
349
400
|
{saving ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : null}
|
|
@@ -353,6 +404,8 @@ export function InlineTextEditor({
|
|
|
353
404
|
{t('ui.detail.inline.cancel', 'Cancel')}
|
|
354
405
|
</Button>
|
|
355
406
|
</div>
|
|
407
|
+
)
|
|
408
|
+
})()}
|
|
356
409
|
</form>
|
|
357
410
|
) : (
|
|
358
411
|
<div className={variant === 'plain' ? '' : 'mt-1'}>{displayContent}</div>
|