@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.
Files changed (164) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/__integration__/TC-AI-UI-003-aichat-registry.spec.tsx +13 -0
  3. package/dist/ai/AiAssistantLauncher.js +8 -0
  4. package/dist/ai/AiAssistantLauncher.js.map +2 -2
  5. package/dist/ai/AiChat.js +226 -58
  6. package/dist/ai/AiChat.js.map +2 -2
  7. package/dist/ai/AiDock.js +3 -2
  8. package/dist/ai/AiDock.js.map +2 -2
  9. package/dist/ai/ModelPicker.js +186 -0
  10. package/dist/ai/ModelPicker.js.map +7 -0
  11. package/dist/ai/index.js +3 -1
  12. package/dist/ai/index.js.map +2 -2
  13. package/dist/ai/useAiChat.js +12 -16
  14. package/dist/ai/useAiChat.js.map +2 -2
  15. package/dist/backend/CrudForm.js +46 -129
  16. package/dist/backend/CrudForm.js.map +3 -3
  17. package/dist/backend/DataTable.js +10 -3
  18. package/dist/backend/DataTable.js.map +2 -2
  19. package/dist/backend/EmptyState.js +1 -47
  20. package/dist/backend/EmptyState.js.map +2 -2
  21. package/dist/backend/FilterOverlay.js +31 -26
  22. package/dist/backend/FilterOverlay.js.map +2 -2
  23. package/dist/backend/FlashMessages.js +23 -25
  24. package/dist/backend/FlashMessages.js.map +2 -2
  25. package/dist/backend/JsonBuilder.js +4 -2
  26. package/dist/backend/JsonBuilder.js.map +2 -2
  27. package/dist/backend/LogList.js +49 -0
  28. package/dist/backend/LogList.js.map +7 -0
  29. package/dist/backend/crud/CollapsibleGroup.js +13 -3
  30. package/dist/backend/crud/CollapsibleGroup.js.map +2 -2
  31. package/dist/backend/detail/InlineEditors.js +80 -32
  32. package/dist/backend/detail/InlineEditors.js.map +2 -2
  33. package/dist/backend/inputs/ComboboxInput.js +60 -6
  34. package/dist/backend/inputs/ComboboxInput.js.map +2 -2
  35. package/dist/backend/inputs/DatePicker.js +6 -142
  36. package/dist/backend/inputs/DatePicker.js.map +2 -2
  37. package/dist/backend/inputs/DateTimePicker.js +6 -182
  38. package/dist/backend/inputs/DateTimePicker.js.map +2 -2
  39. package/dist/backend/inputs/TagsInput.js +16 -19
  40. package/dist/backend/inputs/TagsInput.js.map +2 -2
  41. package/dist/backend/inputs/TimeInput.js +1 -1
  42. package/dist/backend/inputs/TimeInput.js.map +2 -2
  43. package/dist/backend/inputs/TimePicker.js +83 -72
  44. package/dist/backend/inputs/TimePicker.js.map +2 -2
  45. package/dist/backend/utils/customFieldForms.js +2 -2
  46. package/dist/backend/utils/customFieldForms.js.map +1 -1
  47. package/dist/portal/components/PortalEmptyState.js +13 -9
  48. package/dist/portal/components/PortalEmptyState.js.map +2 -2
  49. package/dist/primitives/accordion.js +137 -0
  50. package/dist/primitives/accordion.js.map +7 -0
  51. package/dist/primitives/alert.js +180 -12
  52. package/dist/primitives/alert.js.map +2 -2
  53. package/dist/primitives/calendar.js +122 -32
  54. package/dist/primitives/calendar.js.map +2 -2
  55. package/dist/primitives/compact-select.js +47 -0
  56. package/dist/primitives/compact-select.js.map +7 -0
  57. package/dist/primitives/counter-input.js +174 -0
  58. package/dist/primitives/counter-input.js.map +7 -0
  59. package/dist/primitives/date-picker-helpers.js +79 -0
  60. package/dist/primitives/date-picker-helpers.js.map +7 -0
  61. package/dist/primitives/date-picker.js +216 -0
  62. package/dist/primitives/date-picker.js.map +7 -0
  63. package/dist/primitives/date-range-picker.js +239 -0
  64. package/dist/primitives/date-range-picker.js.map +7 -0
  65. package/dist/primitives/digit-input.js +157 -0
  66. package/dist/primitives/digit-input.js.map +7 -0
  67. package/dist/primitives/empty-state.js +93 -0
  68. package/dist/primitives/empty-state.js.map +7 -0
  69. package/dist/primitives/inline-input.js +29 -0
  70. package/dist/primitives/inline-input.js.map +7 -0
  71. package/dist/primitives/inline-select.js +41 -0
  72. package/dist/primitives/inline-select.js.map +7 -0
  73. package/dist/primitives/notification-stack.js +116 -0
  74. package/dist/primitives/notification-stack.js.map +7 -0
  75. package/dist/primitives/notification.js +62 -0
  76. package/dist/primitives/notification.js.map +7 -0
  77. package/dist/primitives/rich-editor.js +1230 -0
  78. package/dist/primitives/rich-editor.js.map +7 -0
  79. package/dist/primitives/select.js +1 -0
  80. package/dist/primitives/select.js.map +2 -2
  81. package/dist/primitives/skeleton.js +55 -0
  82. package/dist/primitives/skeleton.js.map +7 -0
  83. package/dist/primitives/tag-input.js +186 -0
  84. package/dist/primitives/tag-input.js.map +7 -0
  85. package/dist/primitives/tag.js +66 -13
  86. package/dist/primitives/tag.js.map +2 -2
  87. package/dist/primitives/time-picker.js +793 -0
  88. package/dist/primitives/time-picker.js.map +7 -0
  89. package/package.json +4 -3
  90. package/src/ai/AiAssistantLauncher.tsx +8 -0
  91. package/src/ai/AiChat.tsx +229 -11
  92. package/src/ai/AiDock.tsx +3 -2
  93. package/src/ai/ModelPicker.tsx +248 -0
  94. package/src/ai/__tests__/AiAssistantLauncher.test.tsx +58 -0
  95. package/src/ai/__tests__/AiChat.conversation.test.tsx +16 -0
  96. package/src/ai/__tests__/AiChat.debug.test.tsx +16 -0
  97. package/src/ai/__tests__/AiChat.registry.test.tsx +16 -0
  98. package/src/ai/__tests__/AiChat.test.tsx +180 -0
  99. package/src/ai/__tests__/AiChat.thinking-unmount.test.tsx +16 -0
  100. package/src/ai/__tests__/AiDock.test.tsx +12 -1
  101. package/src/ai/__tests__/ModelPicker.test.tsx +252 -0
  102. package/src/ai/index.ts +1 -0
  103. package/src/ai/useAiChat.ts +29 -16
  104. package/src/backend/CrudForm.tsx +61 -113
  105. package/src/backend/DataTable.tsx +17 -5
  106. package/src/backend/EmptyState.tsx +13 -63
  107. package/src/backend/FilterOverlay.tsx +38 -22
  108. package/src/backend/FlashMessages.tsx +23 -24
  109. package/src/backend/JsonBuilder.tsx +4 -2
  110. package/src/backend/LogList.tsx +109 -0
  111. package/src/backend/__tests__/CrudForm.datetime.test.tsx +12 -7
  112. package/src/backend/__tests__/FilterOverlay.dateRange.test.tsx +89 -0
  113. package/src/backend/__tests__/LogList.test.tsx +163 -0
  114. package/src/backend/crud/CollapsibleGroup.tsx +17 -3
  115. package/src/backend/detail/InlineEditors.tsx +78 -25
  116. package/src/backend/detail/__tests__/InlineEditors.test.tsx +81 -1
  117. package/src/backend/inputs/ComboboxInput.tsx +81 -9
  118. package/src/backend/inputs/DatePicker.tsx +28 -155
  119. package/src/backend/inputs/DateTimePicker.tsx +25 -193
  120. package/src/backend/inputs/TagsInput.tsx +10 -13
  121. package/src/backend/inputs/TimeInput.tsx +2 -1
  122. package/src/backend/inputs/TimePicker.tsx +99 -70
  123. package/src/backend/inputs/__tests__/ComboboxInput.test.tsx +133 -0
  124. package/src/backend/inputs/__tests__/TimePicker.test.tsx +2 -2
  125. package/src/backend/utils/__tests__/richTextSanitizer.test.ts +5 -2
  126. package/src/backend/utils/customFieldForms.ts +2 -2
  127. package/src/portal/components/PortalEmptyState.tsx +27 -22
  128. package/src/primitives/__tests__/accordion.test.tsx +197 -0
  129. package/src/primitives/__tests__/alert.test.tsx +204 -0
  130. package/src/primitives/__tests__/compact-select.test.tsx +98 -0
  131. package/src/primitives/__tests__/counter-input.test.tsx +165 -0
  132. package/src/primitives/__tests__/date-picker.test.tsx +221 -0
  133. package/src/primitives/__tests__/date-range-picker.test.tsx +173 -0
  134. package/src/primitives/__tests__/digit-input.test.tsx +168 -0
  135. package/src/primitives/__tests__/empty-state.test.tsx +154 -0
  136. package/src/primitives/__tests__/inline-input.test.tsx +100 -0
  137. package/src/primitives/__tests__/inline-select.test.tsx +103 -0
  138. package/src/primitives/__tests__/notification-stack.test.tsx +174 -0
  139. package/src/primitives/__tests__/notification.test.tsx +119 -0
  140. package/src/primitives/__tests__/rich-editor.test.tsx +411 -0
  141. package/src/primitives/__tests__/skeleton.test.tsx +76 -0
  142. package/src/primitives/__tests__/tag-input.test.tsx +198 -0
  143. package/src/primitives/__tests__/tag.test.tsx +123 -0
  144. package/src/primitives/__tests__/time-picker.test.tsx +392 -0
  145. package/src/primitives/accordion.tsx +225 -0
  146. package/src/primitives/alert.tsx +234 -24
  147. package/src/primitives/calendar.tsx +146 -38
  148. package/src/primitives/compact-select.tsx +59 -0
  149. package/src/primitives/counter-input.tsx +211 -0
  150. package/src/primitives/date-picker-helpers.ts +96 -0
  151. package/src/primitives/date-picker.tsx +290 -0
  152. package/src/primitives/date-range-picker.tsx +281 -0
  153. package/src/primitives/digit-input.tsx +208 -0
  154. package/src/primitives/empty-state.tsx +140 -0
  155. package/src/primitives/inline-input.tsx +55 -0
  156. package/src/primitives/inline-select.tsx +74 -0
  157. package/src/primitives/notification-stack.tsx +185 -0
  158. package/src/primitives/notification.tsx +136 -0
  159. package/src/primitives/rich-editor.tsx +1578 -0
  160. package/src/primitives/select.tsx +1 -0
  161. package/src/primitives/skeleton.tsx +70 -0
  162. package/src/primitives/tag-input.tsx +223 -0
  163. package/src/primitives/tag.tsx +69 -10
  164. package/src/primitives/time-picker.tsx +1059 -0
@@ -1,65 +1,15 @@
1
- "use client"
2
-
3
- import * as React from 'react'
4
- import { cn } from '@open-mercato/shared/lib/utils'
5
- import { Button } from '@open-mercato/ui/primitives/button'
6
- import { Plus } from 'lucide-react'
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
- type EmptyStateAction = {
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 function EmptyState({
28
- title,
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
- <div className="grid grid-cols-1 gap-2">
232
- <div>
233
- <div className="text-xs text-muted-foreground mb-1">{t('ui.filters.dateRange.from', 'From')}</div>
234
- <input
235
- type="date"
236
- className="w-full h-11 rounded border px-2 text-sm"
237
- value={values[f.id]?.from ?? ''}
238
- onChange={(e) => setValue(f.id, { ...(values[f.id] ?? {}), from: e.target.value || undefined })}
239
- />
240
- </div>
241
- <div>
242
- <div className="text-xs text-muted-foreground mb-1">{t('ui.filters.dateRange.to', 'To')}</div>
243
- <input
244
- type="date"
245
- className="w-full h-11 rounded border px-2 text-sm"
246
- value={values[f.id]?.to ?? ''}
247
- onChange={(e) => setValue(f.id, { ...(values[f.id] ?? {}), to: e.target.value || undefined })}
248
- />
249
- </div>
250
- </div>
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 { X } from 'lucide-react'
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
- if (!msg) return null
150
+ const handleDismiss = React.useCallback(() => {
151
+ clearDismissTimer()
152
+ setMsg(null)
153
+ }, [clearDismissTimer])
145
154
 
146
- const colorMap: Record<FlashKind, string> = {
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={`pointer-events-auto rounded px-3 py-2 text-white shadow-md ${color}`}>
157
- <div className="flex items-center justify-between gap-2">
158
- <div className="text-sm">{msg}</div>
159
- <IconButton
160
- type="button"
161
- variant="ghost"
162
- size="sm"
163
- className="text-white/90 hover:text-white hover:bg-white/10"
164
- onClick={() => setMsg(null)}
165
- aria-label="Dismiss"
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
- <input
331
- className="w-full sm:w-[100px] text-xs font-mono border-b border-transparent hover:border-gray-300 focus-visible:border-ring bg-transparent focus-visible:outline-none text-right pr-1"
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 raw <input> (backward compatibility)', () => {
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('type="datetime-local"')
67
- expect(html).not.toContain('Pick date and time')
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 raw <input type="date"> (backward compatibility)', () => {
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('type="date"')
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 HH:MM in trigger when initialValues provided', () => {
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
- expect(html).toContain('14:30')
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
+ })