@open-mercato/ui 0.7.1-develop.7153.1.7145d295e6 → 0.7.1-develop.7170.1.d95074d7ba
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/dist/backend/CrudForm.js +45 -21
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +7 -6
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/SectionHeader.js +17 -8
- package/dist/backend/SectionHeader.js.map +2 -2
- package/dist/backend/injection/InjectedField.js +55 -9
- package/dist/backend/injection/InjectedField.js.map +2 -2
- package/dist/backend/utils/nav.js +34 -4
- package/dist/backend/utils/nav.js.map +2 -2
- package/dist/primitives/date-format.js +68 -1
- package/dist/primitives/date-format.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +40 -9
- package/src/backend/DataTable.tsx +11 -15
- package/src/backend/SectionHeader.tsx +28 -5
- package/src/backend/__tests__/CrudForm.localeNumber.test.tsx +159 -0
- package/src/backend/__tests__/CrudForm.validation.test.tsx +1 -1
- package/src/backend/__tests__/DataTable.dateCells.test.tsx +87 -0
- package/src/backend/__tests__/SectionHeader.test.tsx +105 -0
- package/src/backend/__tests__/nav-utils.test.ts +180 -0
- package/src/backend/injection/InjectedField.tsx +69 -9
- package/src/backend/injection/__tests__/InjectedField.localeNumber.test.tsx +94 -0
- package/src/backend/utils/nav.ts +75 -5
- package/src/primitives/__tests__/date-format.test.ts +185 -0
- package/src/primitives/date-format.ts +145 -0
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -75,7 +75,7 @@ import { loadGeneratedFieldRegistrations } from './fields/registry'
|
|
|
75
75
|
import type { CustomFieldDefDto, CustomFieldDefinitionsPayload, CustomFieldsetDto } from './utils/customFieldDefs'
|
|
76
76
|
import { isDefVisible } from './utils/customFieldDefs'
|
|
77
77
|
import { buildFormFieldsFromCustomFields, buildFormFieldFromCustomFieldDef } from './utils/customFieldForms'
|
|
78
|
-
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
78
|
+
import { useT, useOptionalLocale } from '@open-mercato/shared/lib/i18n/context'
|
|
79
79
|
import { TagsInput } from './inputs/TagsInput'
|
|
80
80
|
import { ComboboxInput } from './inputs/ComboboxInput'
|
|
81
81
|
import { format } from 'date-fns/format'
|
|
@@ -96,6 +96,7 @@ import { useInjectionSpotEvents, InjectionSpot, useInjectionWidgets } from './in
|
|
|
96
96
|
import { dispatchBackendMutationError } from './injection/mutationEvents'
|
|
97
97
|
import { VersionHistoryAction } from './version-history/VersionHistoryAction'
|
|
98
98
|
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
99
|
+
import { parseLocaleNumber, resolveLocaleNumberSeparators } from '@open-mercato/shared/lib/number'
|
|
99
100
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
100
101
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
101
102
|
import { useInjectionDataWidgets } from './injection/useInjectionDataWidgets'
|
|
@@ -4036,14 +4037,23 @@ function NumberInput({
|
|
|
4036
4037
|
autoFocus?: boolean
|
|
4037
4038
|
onSubmit?: () => void
|
|
4038
4039
|
}) {
|
|
4040
|
+
const locale = useOptionalLocale()
|
|
4039
4041
|
const serializedValue = value !== undefined && value !== null ? String(value) : ''
|
|
4040
4042
|
const [local, setLocal] = React.useState<string>(serializedValue)
|
|
4041
4043
|
const isFocusedRef = React.useRef(false)
|
|
4044
|
+
// Users type the separator the surrounding UI displays, which follows the application
|
|
4045
|
+
// locale — `110,70` under Polish. `Number()` only ever accepted `.` (issue #5552).
|
|
4046
|
+
const parse = React.useCallback(
|
|
4047
|
+
(raw: string): number | undefined => {
|
|
4048
|
+
if (raw === '') return undefined
|
|
4049
|
+
return parseLocaleNumber(raw, locale) ?? undefined
|
|
4050
|
+
},
|
|
4051
|
+
[locale],
|
|
4052
|
+
)
|
|
4042
4053
|
const commitIfChanged = React.useCallback(() => {
|
|
4043
4054
|
if (local === serializedValue) return
|
|
4044
|
-
|
|
4045
|
-
|
|
4046
|
-
}, [local, onChange, serializedValue])
|
|
4055
|
+
onChange(parse(local))
|
|
4056
|
+
}, [local, onChange, parse, serializedValue])
|
|
4047
4057
|
|
|
4048
4058
|
React.useEffect(() => {
|
|
4049
4059
|
// Only sync from props when not focused to avoid caret jumps
|
|
@@ -4055,17 +4065,31 @@ function NumberInput({
|
|
|
4055
4065
|
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
|
4056
4066
|
const next = e.target.value
|
|
4057
4067
|
setLocal(next)
|
|
4058
|
-
|
|
4059
|
-
|
|
4060
|
-
}, [onChange])
|
|
4068
|
+
onChange(parse(next))
|
|
4069
|
+
}, [onChange, parse])
|
|
4061
4070
|
|
|
4062
4071
|
const handleKeyDown = React.useCallback((e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
4063
4072
|
if (e.key === 'Enter' && !e.shiftKey) {
|
|
4064
4073
|
e.preventDefault()
|
|
4065
4074
|
commitIfChanged()
|
|
4066
4075
|
onSubmit?.()
|
|
4076
|
+
return
|
|
4077
|
+
}
|
|
4078
|
+
// A text input has no native spinner, so keep the step affordance the previous
|
|
4079
|
+
// type="number" field offered.
|
|
4080
|
+
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
|
4081
|
+
e.preventDefault()
|
|
4082
|
+
// Binary floating point puts 8.2 - 1 at 7.199999999999999; the native spinner
|
|
4083
|
+
// re-serialized against the step rather than committing that to form state.
|
|
4084
|
+
const base = parse(local) ?? 0
|
|
4085
|
+
const stepped = Number((base + (e.key === 'ArrowUp' ? 1 : -1)).toFixed(10))
|
|
4086
|
+
// The box must keep showing the separator the rest of the UI displays, which is
|
|
4087
|
+
// the application locale's — not the dot String() always emits (issue #5552).
|
|
4088
|
+
const { decimal } = resolveLocaleNumberSeparators(locale)
|
|
4089
|
+
setLocal(decimal === '.' ? String(stepped) : String(stepped).replace('.', decimal))
|
|
4090
|
+
onChange(stepped)
|
|
4067
4091
|
}
|
|
4068
|
-
}, [commitIfChanged, onSubmit])
|
|
4092
|
+
}, [commitIfChanged, local, locale, onChange, onSubmit, parse])
|
|
4069
4093
|
|
|
4070
4094
|
const handleFocus = React.useCallback(() => {
|
|
4071
4095
|
isFocusedRef.current = true
|
|
@@ -4077,8 +4101,15 @@ function NumberInput({
|
|
|
4077
4101
|
}, [commitIfChanged])
|
|
4078
4102
|
|
|
4079
4103
|
return (
|
|
4104
|
+
// type="text" rather than type="number": the browser sanitizes a type="number" value
|
|
4105
|
+
// against the BROWSER locale, so it discards the separator the app locale displays
|
|
4106
|
+
// before React ever sees it (issue #5552). inputMode keeps the mobile numeric keypad.
|
|
4080
4107
|
<Input
|
|
4081
|
-
type="
|
|
4108
|
+
type="text"
|
|
4109
|
+
inputMode="decimal"
|
|
4110
|
+
// A text input is eligible for autofill and spellcheck; a number field is not.
|
|
4111
|
+
autoComplete="off"
|
|
4112
|
+
spellCheck={false}
|
|
4082
4113
|
placeholder={placeholder}
|
|
4083
4114
|
value={local}
|
|
4084
4115
|
onChange={handleChange}
|
|
@@ -40,10 +40,11 @@ import { raiseCrudError } from './utils/serverErrors'
|
|
|
40
40
|
import { computeMenuViewportShiftX } from './utils/viewport'
|
|
41
41
|
import { PerspectiveSidebar } from './PerspectiveSidebar'
|
|
42
42
|
import { Popover, PopoverTrigger, PopoverContent } from '../primitives/popover'
|
|
43
|
-
import {
|
|
43
|
+
import { parseISO } from 'date-fns/parseISO'
|
|
44
|
+
import { formatDisplayDateTime } from '../primitives/date-format'
|
|
44
45
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
45
46
|
import { readVersionedPreference, writeVersionedPreference, clearVersionedPreference } from '@open-mercato/shared/lib/browser/versionedPreference'
|
|
46
|
-
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
47
|
+
import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
|
|
47
48
|
import { flash } from './FlashMessages'
|
|
48
49
|
import { useConfirmDialog } from './confirm-dialog'
|
|
49
50
|
import { surfaceRecordConflict } from './conflicts'
|
|
@@ -1665,15 +1666,9 @@ export function DataTable<T extends RowData>({
|
|
|
1665
1666
|
return <RowActions items={injectedItems} />
|
|
1666
1667
|
}, [injectedRowActions, rowActions, router, t])
|
|
1667
1668
|
|
|
1668
|
-
//
|
|
1669
|
-
//
|
|
1670
|
-
const
|
|
1671
|
-
normalizeDateFormatPattern(process.env.NEXT_PUBLIC_OM_DATE_TIME_FORMAT)
|
|
1672
|
-
?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_DATE_TIME_FORMAT)
|
|
1673
|
-
?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_OM_DATE_FORMAT)
|
|
1674
|
-
?? normalizeDateFormatPattern(process.env.NEXT_PUBLIC_DATE_FORMAT)
|
|
1675
|
-
?? 'yyyy-MM-dd HH:mm'
|
|
1676
|
-
)
|
|
1669
|
+
// Locale-aware for the same reason the detail fields are: with no env override a table cell and
|
|
1670
|
+
// the field beside it must not disagree about the convention. An env override still wins.
|
|
1671
|
+
const dateLocale = useLocale()
|
|
1677
1672
|
|
|
1678
1673
|
const tryParseDate = (v: unknown): Date | null => {
|
|
1679
1674
|
if (v == null) return null
|
|
@@ -1685,9 +1680,10 @@ export function DataTable<T extends RowData>({
|
|
|
1685
1680
|
if (typeof v === 'string') {
|
|
1686
1681
|
const s = v.trim()
|
|
1687
1682
|
if (!s) return null
|
|
1688
|
-
// ISO-like detection (YYYY-MM-DD ...)
|
|
1683
|
+
// ISO-like detection (YYYY-MM-DD ...). `parseISO`, not `new Date`: the latter reads a bare
|
|
1684
|
+
// `yyyy-MM-dd` as UTC midnight, which renders as the previous day west of UTC.
|
|
1689
1685
|
if (/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?(?:\.\d+)?(?:Z|[+-]\d{2}:?\d{2})?)?$/.test(s)) {
|
|
1690
|
-
const d =
|
|
1686
|
+
const d = parseISO(s)
|
|
1691
1687
|
return isNaN(d.getTime()) ? null : d
|
|
1692
1688
|
}
|
|
1693
1689
|
// Fallback: Date.parse
|
|
@@ -3540,7 +3536,7 @@ export function DataTable<T extends RowData>({
|
|
|
3540
3536
|
if (isDateCol) {
|
|
3541
3537
|
const raw = cell.getValue() as any
|
|
3542
3538
|
const d = tryParseDate(raw)
|
|
3543
|
-
content = d ? (
|
|
3539
|
+
content = d ? (formatDisplayDateTime(d, dateLocale) ?? raw) : (raw as any)
|
|
3544
3540
|
} else {
|
|
3545
3541
|
content = flexRender(cell.column.columnDef.cell, cell.getContext())
|
|
3546
3542
|
}
|
|
@@ -3563,7 +3559,7 @@ export function DataTable<T extends RowData>({
|
|
|
3563
3559
|
tooltipText = metaTooltipContent(row.original)
|
|
3564
3560
|
} else if (isDateCol && cellValue != null) {
|
|
3565
3561
|
const parsedDate = tryParseDate(cellValue)
|
|
3566
|
-
tooltipText = parsedDate ? (
|
|
3562
|
+
tooltipText = parsedDate ? (formatDisplayDateTime(parsedDate, dateLocale) ?? String(cellValue)) : String(cellValue)
|
|
3567
3563
|
} else {
|
|
3568
3564
|
tooltipText = cellValue != null ? String(cellValue) : undefined
|
|
3569
3565
|
}
|
|
@@ -4,6 +4,7 @@ import * as React from 'react'
|
|
|
4
4
|
import { ChevronDown } from 'lucide-react'
|
|
5
5
|
import { Badge } from '../primitives/badge'
|
|
6
6
|
import { cn } from '@open-mercato/shared/lib/utils'
|
|
7
|
+
import { useOptionalT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
8
|
|
|
8
9
|
export type SectionHeaderProps = {
|
|
9
10
|
/** Section title */
|
|
@@ -14,6 +15,11 @@ export type SectionHeaderProps = {
|
|
|
14
15
|
action?: React.ReactNode
|
|
15
16
|
/** Additional className */
|
|
16
17
|
className?: string
|
|
18
|
+
/**
|
|
19
|
+
* Additional className on the title element. Supplying it also lets the title row
|
|
20
|
+
* shrink below its content width, so `truncate` takes effect in narrow containers.
|
|
21
|
+
*/
|
|
22
|
+
titleClassName?: string
|
|
17
23
|
}
|
|
18
24
|
|
|
19
25
|
export function SectionHeader({
|
|
@@ -21,11 +27,13 @@ export function SectionHeader({
|
|
|
21
27
|
count,
|
|
22
28
|
action,
|
|
23
29
|
className,
|
|
30
|
+
titleClassName,
|
|
24
31
|
}: SectionHeaderProps) {
|
|
32
|
+
const allowsTitleShrink = Boolean(titleClassName)
|
|
25
33
|
return (
|
|
26
34
|
<div className={cn('flex items-center justify-between', className)}>
|
|
27
|
-
<div className=
|
|
28
|
-
<h3 className=
|
|
35
|
+
<div className={cn('flex items-center gap-2', allowsTitleShrink && 'min-w-0')}>
|
|
36
|
+
<h3 className={cn('text-sm font-semibold', allowsTitleShrink && 'min-w-0', titleClassName)}>{title}</h3>
|
|
29
37
|
{count != null && (
|
|
30
38
|
<Badge variant="muted" className="text-xs tabular-nums">
|
|
31
39
|
{count}
|
|
@@ -56,6 +64,11 @@ export type CollapsibleSectionProps = {
|
|
|
56
64
|
className?: string
|
|
57
65
|
/** Additional className on content wrapper */
|
|
58
66
|
contentClassName?: string
|
|
67
|
+
/**
|
|
68
|
+
* Additional className on the title element. Supplying it also lets the header row
|
|
69
|
+
* shrink below its content width, so `truncate` takes effect in narrow containers.
|
|
70
|
+
*/
|
|
71
|
+
titleClassName?: string
|
|
59
72
|
}
|
|
60
73
|
|
|
61
74
|
export function CollapsibleSection({
|
|
@@ -68,6 +81,7 @@ export function CollapsibleSection({
|
|
|
68
81
|
children,
|
|
69
82
|
className,
|
|
70
83
|
contentClassName,
|
|
84
|
+
titleClassName,
|
|
71
85
|
}: CollapsibleSectionProps) {
|
|
72
86
|
const [internalCollapsed, setInternalCollapsed] = React.useState(defaultCollapsed)
|
|
73
87
|
const isControlled = controlledCollapsed !== undefined
|
|
@@ -79,23 +93,32 @@ export function CollapsibleSection({
|
|
|
79
93
|
onCollapsedChange?.(next)
|
|
80
94
|
}, [isCollapsed, isControlled, onCollapsedChange])
|
|
81
95
|
|
|
96
|
+
const contextT = useOptionalT()
|
|
97
|
+
const t = (key: string, fallback: string) =>
|
|
98
|
+
contextT ? contextT(key, fallback, { title }) : fallback.replace('{title}', title)
|
|
99
|
+
const toggleAriaLabel = isCollapsed
|
|
100
|
+
? t('ui.sectionHeader.expand', 'Expand {title} section')
|
|
101
|
+
: t('ui.sectionHeader.collapse', 'Collapse {title} section')
|
|
102
|
+
const allowsTitleShrink = Boolean(titleClassName)
|
|
103
|
+
|
|
82
104
|
return (
|
|
83
105
|
<div className={cn('space-y-3', className)}>
|
|
84
106
|
<div className="flex items-center justify-between">
|
|
85
107
|
<button
|
|
86
108
|
type="button"
|
|
87
109
|
onClick={toggle}
|
|
88
|
-
className=
|
|
110
|
+
className={cn('flex items-center gap-2 group', allowsTitleShrink && 'min-w-0')}
|
|
89
111
|
aria-expanded={!isCollapsed}
|
|
90
|
-
aria-label={
|
|
112
|
+
aria-label={toggleAriaLabel}
|
|
91
113
|
>
|
|
92
114
|
<ChevronDown
|
|
93
115
|
className={cn(
|
|
94
116
|
'h-4 w-4 text-muted-foreground transition-transform duration-200',
|
|
95
117
|
isCollapsed && '-rotate-90',
|
|
118
|
+
allowsTitleShrink && 'shrink-0',
|
|
96
119
|
)}
|
|
97
120
|
/>
|
|
98
|
-
<h3 className=
|
|
121
|
+
<h3 className={cn('text-sm font-semibold', allowsTitleShrink && 'min-w-0', titleClassName)}>{title}</h3>
|
|
99
122
|
{count != null && (
|
|
100
123
|
<Badge variant="muted" className="text-xs tabular-nums">
|
|
101
124
|
{count}
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
jest.setTimeout(15000)
|
|
3
|
+
|
|
4
|
+
jest.mock('next/navigation', () => ({
|
|
5
|
+
useRouter: () => ({ push: () => {} }),
|
|
6
|
+
usePathname: () => '/',
|
|
7
|
+
useSearchParams: () => new URLSearchParams(),
|
|
8
|
+
}))
|
|
9
|
+
jest.mock('remark-gfm', () => ({ __esModule: true, default: {} }))
|
|
10
|
+
jest.mock('../injection/InjectionSpot', () => ({
|
|
11
|
+
__esModule: true,
|
|
12
|
+
InjectionSpot: () => null,
|
|
13
|
+
useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
|
|
14
|
+
useInjectionSpotEvents: () => ({ triggerEvent: jest.fn() }),
|
|
15
|
+
}))
|
|
16
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
17
|
+
__esModule: true,
|
|
18
|
+
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false, error: null }),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
import * as React from 'react'
|
|
22
|
+
import { act, fireEvent, waitFor } from '@testing-library/react'
|
|
23
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
24
|
+
import { CrudForm, type CrudField } from '../CrudForm'
|
|
25
|
+
|
|
26
|
+
const dict = { 'ui.forms.actions.save': 'Save' }
|
|
27
|
+
const fields: CrudField[] = [{ id: 'amount', label: 'Amount', type: 'number' }]
|
|
28
|
+
|
|
29
|
+
// The locale is pinned to one that does NOT use a dot decimal separator, because an
|
|
30
|
+
// en-US pin passes on the buggy implementation too.
|
|
31
|
+
function renderNumberForm(locale: string, onSubmit: (values: Record<string, unknown>) => void) {
|
|
32
|
+
return renderWithProviders(
|
|
33
|
+
<CrudForm title="Form" fields={fields} onSubmit={onSubmit} />,
|
|
34
|
+
{ locale, dict },
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function amountInput(container: HTMLElement): HTMLInputElement {
|
|
39
|
+
const input = container.querySelector('[data-crud-field-id="amount"] input')
|
|
40
|
+
expect(input).not.toBeNull()
|
|
41
|
+
return input as HTMLInputElement
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function submitWith(container: HTMLElement, typed: string) {
|
|
45
|
+
const input = amountInput(container)
|
|
46
|
+
await act(async () => {
|
|
47
|
+
fireEvent.change(input, { target: { value: typed } })
|
|
48
|
+
})
|
|
49
|
+
await act(async () => {
|
|
50
|
+
fireEvent.blur(input)
|
|
51
|
+
})
|
|
52
|
+
await act(async () => {
|
|
53
|
+
fireEvent.submit(container.querySelector('form') as HTMLFormElement)
|
|
54
|
+
})
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe('CrudForm number fields accept the locale decimal separator (issue #5552)', () => {
|
|
58
|
+
it('does not render a type="number" input, whose value the browser sanitizes per BROWSER locale', () => {
|
|
59
|
+
const { container } = renderNumberForm('pl', () => {})
|
|
60
|
+
const input = amountInput(container)
|
|
61
|
+
// A type="number" input discards `110,70` before React sees it when the browser
|
|
62
|
+
// locale differs from the application locale — the original defect.
|
|
63
|
+
expect(input.getAttribute('type')).toBe('text')
|
|
64
|
+
expect(input.getAttribute('inputMode')).toBe('decimal')
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('commits a comma-typed value as the number the UI displays under a Polish locale', async () => {
|
|
68
|
+
const onSubmit = jest.fn()
|
|
69
|
+
const { container } = renderNumberForm('pl', onSubmit)
|
|
70
|
+
|
|
71
|
+
await submitWith(container, '110,70')
|
|
72
|
+
|
|
73
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
74
|
+
expect(onSubmit.mock.calls[0][0].amount).toBe(110.7)
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
it('still accepts a dot under a comma-decimal locale, so the existing input keeps working', async () => {
|
|
78
|
+
const onSubmit = jest.fn()
|
|
79
|
+
const { container } = renderNumberForm('pl', onSubmit)
|
|
80
|
+
|
|
81
|
+
await submitWith(container, '110.70')
|
|
82
|
+
|
|
83
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
84
|
+
expect(onSubmit.mock.calls[0][0].amount).toBe(110.7)
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('commits a dot-typed value under an English locale', async () => {
|
|
88
|
+
const onSubmit = jest.fn()
|
|
89
|
+
const { container } = renderNumberForm('en', onSubmit)
|
|
90
|
+
|
|
91
|
+
await submitWith(container, '2.5')
|
|
92
|
+
|
|
93
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
94
|
+
expect(onSubmit.mock.calls[0][0].amount).toBe(2.5)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('keeps a half-typed decimal in the box on the way to a complete one', async () => {
|
|
98
|
+
const { container } = renderNumberForm('pl', () => {})
|
|
99
|
+
const input = amountInput(container)
|
|
100
|
+
|
|
101
|
+
await act(async () => {
|
|
102
|
+
fireEvent.focus(input)
|
|
103
|
+
})
|
|
104
|
+
// `110,` is not yet the final value; the local buffer has to survive the round trip
|
|
105
|
+
// through the form state so the user can finish typing.
|
|
106
|
+
await act(async () => {
|
|
107
|
+
fireEvent.change(input, { target: { value: '110,' } })
|
|
108
|
+
})
|
|
109
|
+
expect(input.value).toBe('110,')
|
|
110
|
+
|
|
111
|
+
await act(async () => {
|
|
112
|
+
fireEvent.change(input, { target: { value: '110,70' } })
|
|
113
|
+
})
|
|
114
|
+
expect(input.value).toBe('110,70')
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('steps the value with the arrow keys, writing back the separator the locale displays', async () => {
|
|
118
|
+
const onSubmit = jest.fn()
|
|
119
|
+
const { container } = renderNumberForm('pl', onSubmit)
|
|
120
|
+
const input = amountInput(container)
|
|
121
|
+
|
|
122
|
+
await act(async () => {
|
|
123
|
+
fireEvent.change(input, { target: { value: '4,5' } })
|
|
124
|
+
})
|
|
125
|
+
await act(async () => {
|
|
126
|
+
fireEvent.keyDown(input, { key: 'ArrowUp' })
|
|
127
|
+
})
|
|
128
|
+
// A dot here would contradict the separator the rest of the Polish UI shows, which is
|
|
129
|
+
// the complaint issue #5552 opened with.
|
|
130
|
+
expect(input.value).toBe('5,5')
|
|
131
|
+
|
|
132
|
+
await act(async () => {
|
|
133
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
|
134
|
+
})
|
|
135
|
+
expect(input.value).toBe('4,5')
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('does not commit a binary floating point artifact when stepping', async () => {
|
|
139
|
+
const onSubmit = jest.fn()
|
|
140
|
+
const { container } = renderNumberForm('pl', onSubmit)
|
|
141
|
+
const input = amountInput(container)
|
|
142
|
+
|
|
143
|
+
await act(async () => {
|
|
144
|
+
fireEvent.change(input, { target: { value: '8,2' } })
|
|
145
|
+
})
|
|
146
|
+
await act(async () => {
|
|
147
|
+
fireEvent.keyDown(input, { key: 'ArrowDown' })
|
|
148
|
+
})
|
|
149
|
+
// 8.2 - 1 is 7.199999999999999 in binary floating point, and onChange puts whatever
|
|
150
|
+
// this commits straight into the submitted form value.
|
|
151
|
+
expect(input.value).toBe('7,2')
|
|
152
|
+
|
|
153
|
+
await act(async () => {
|
|
154
|
+
fireEvent.submit(container.querySelector('form') as HTMLFormElement)
|
|
155
|
+
})
|
|
156
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
157
|
+
expect(onSubmit.mock.calls[0][0].amount).toBe(7.2)
|
|
158
|
+
})
|
|
159
|
+
})
|
|
@@ -191,7 +191,7 @@ describe('CrudForm validation state', () => {
|
|
|
191
191
|
},
|
|
192
192
|
)
|
|
193
193
|
|
|
194
|
-
const priorityInput = container.querySelector('[data-crud-field-id="cf_priority"] input
|
|
194
|
+
const priorityInput = container.querySelector('[data-crud-field-id="cf_priority"] input')
|
|
195
195
|
expect(priorityInput).not.toBeNull()
|
|
196
196
|
|
|
197
197
|
// Blurring an untouched field must NOT flag the required error — tabbing
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { DataTable } from '../DataTable'
|
|
4
|
+
import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
|
|
5
|
+
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
6
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import { render, screen } from '@testing-library/react'
|
|
8
|
+
import { formatWithPublicDateFormat } from '../../primitives/date-format'
|
|
9
|
+
|
|
10
|
+
jest.mock('next/navigation', () => ({
|
|
11
|
+
useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
|
|
12
|
+
}))
|
|
13
|
+
|
|
14
|
+
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
15
|
+
useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
type Row = { id: string; created_at: string }
|
|
19
|
+
|
|
20
|
+
const ORIGINAL_ENV = { ...process.env }
|
|
21
|
+
|
|
22
|
+
afterEach(() => {
|
|
23
|
+
process.env = { ...ORIGINAL_ENV }
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
function renderTable(data: Row[], locale = 'pl') {
|
|
27
|
+
const columns: ColumnDef<Row>[] = [{ accessorKey: 'created_at', header: 'Created' }]
|
|
28
|
+
const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
|
|
29
|
+
return render(
|
|
30
|
+
React.createElement(
|
|
31
|
+
QueryClientProvider as any,
|
|
32
|
+
{ client: queryClient },
|
|
33
|
+
React.createElement(
|
|
34
|
+
I18nProvider as any,
|
|
35
|
+
{ locale, dict: {} },
|
|
36
|
+
React.createElement(DataTable as any, { columns, data, title: 'Test' }),
|
|
37
|
+
),
|
|
38
|
+
),
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
describe('DataTable date cells', () => {
|
|
43
|
+
// `new Date('2026-07-01')` reads a bare date as UTC midnight. Asserting the time too, not just
|
|
44
|
+
// the day, catches that in either direction: a UTC parse reads `02:00` in Europe/Warsaw and
|
|
45
|
+
// `2026-06-30 17:00` in America/Los_Angeles, while local midnight is `00:00` everywhere.
|
|
46
|
+
it('renders a date-only value as local midnight of the stored day', () => {
|
|
47
|
+
renderTable([{ id: '1', created_at: '2026-07-01' }])
|
|
48
|
+
|
|
49
|
+
// `pl`, not `en`: under `en` a component that never reads the locale renders the same string as
|
|
50
|
+
// one that does, so the assertion would hold either way — the corollary in
|
|
51
|
+
// `.ai/lessons/tests-asserting-intl-output-must-pin-locale-and-date.md`. Mutation-checked:
|
|
52
|
+
// dropping the locale from `DataTable`'s formatter call fails this case.
|
|
53
|
+
//
|
|
54
|
+
// The time is asserted too: a UTC parse reads `02:00` in Europe/Warsaw and 30 June further west,
|
|
55
|
+
// while local midnight is `00:00` everywhere.
|
|
56
|
+
expect(screen.getByText('1 lip 2026, 00:00')).toBeInTheDocument()
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
// The `locale` parameter exists for this case. `en` is `defaultLocale`, so a 12-hour clock in
|
|
60
|
+
// every backend table is what most deployments get from this change; asserting it here means the
|
|
61
|
+
// most-seen rendering in the product is guarded rather than inferred from the `pl` case above.
|
|
62
|
+
it('renders the default locale on a 12-hour clock', () => {
|
|
63
|
+
renderTable([{ id: '1', created_at: '2026-07-01' }], 'en')
|
|
64
|
+
|
|
65
|
+
expect(screen.getByText('Jul 1, 2026, 12:00 AM')).toBeInTheDocument()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
// One env chain with the detail-page helpers: a table cell and a detail field must not disagree.
|
|
69
|
+
it('honours the date-time env format', () => {
|
|
70
|
+
process.env.NEXT_PUBLIC_OM_DATE_TIME_FORMAT = 'dd.MM.yyyy HH:mm'
|
|
71
|
+
renderTable([{ id: '1', created_at: '2026-07-01T09:30:00Z' }])
|
|
72
|
+
|
|
73
|
+
// Same reason as the date-format suite: `09:30Z` is 30 June at UTC-10 and further west,
|
|
74
|
+
// so the expectation comes from the formatter rather than a literal.
|
|
75
|
+
const expected = formatWithPublicDateFormat(new Date('2026-07-01T09:30:00Z'), 'dd.MM.yyyy HH:mm')!
|
|
76
|
+
expect(screen.getByText(expected)).toBeInTheDocument()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
// The date vars stay in the chain and stay bare: appending a time to a caller's date-only
|
|
80
|
+
// pattern would change how every existing table renders.
|
|
81
|
+
it('uses a date-only env format bare, without inventing a time', () => {
|
|
82
|
+
process.env.NEXT_PUBLIC_OM_DATE_FORMAT = 'dd.MM.yyyy'
|
|
83
|
+
renderTable([{ id: '1', created_at: '2026-07-01' }])
|
|
84
|
+
|
|
85
|
+
expect(screen.getByText('01.07.2026')).toBeInTheDocument()
|
|
86
|
+
})
|
|
87
|
+
})
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
import * as React from 'react'
|
|
5
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
6
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
7
|
+
import { CollapsibleSection, SectionHeader } from '../SectionHeader'
|
|
8
|
+
|
|
9
|
+
const plDict = {
|
|
10
|
+
'ui.sectionHeader.collapse': 'Zwiń sekcję {title}',
|
|
11
|
+
'ui.sectionHeader.expand': 'Rozwiń sekcję {title}',
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('CollapsibleSection', () => {
|
|
15
|
+
it('translates the toggle aria-label for the active locale', () => {
|
|
16
|
+
renderWithProviders(
|
|
17
|
+
<CollapsibleSection title="Skrzynka">
|
|
18
|
+
<p>Treść</p>
|
|
19
|
+
</CollapsibleSection>,
|
|
20
|
+
{ locale: 'pl', dict: plDict },
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
expect(screen.getByRole('button', { name: 'Zwiń sekcję Skrzynka' })).toBeTruthy()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('switches to the expand key once collapsed', () => {
|
|
27
|
+
renderWithProviders(
|
|
28
|
+
<CollapsibleSection title="Skrzynka">
|
|
29
|
+
<p>Treść</p>
|
|
30
|
+
</CollapsibleSection>,
|
|
31
|
+
{ locale: 'pl', dict: plDict },
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
fireEvent.click(screen.getByRole('button', { name: 'Zwiń sekcję Skrzynka' }))
|
|
35
|
+
|
|
36
|
+
expect(screen.getByRole('button', { name: 'Rozwiń sekcję Skrzynka' })).toBeTruthy()
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('falls back to English when the locale has no translation', () => {
|
|
40
|
+
renderWithProviders(
|
|
41
|
+
<CollapsibleSection title="Billing details">
|
|
42
|
+
<p>Content</p>
|
|
43
|
+
</CollapsibleSection>,
|
|
44
|
+
{ locale: 'en', dict: {} },
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
expect(screen.getByRole('button', { name: 'Collapse Billing details section' })).toBeTruthy()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('renders outside an I18nProvider using the English fallback', () => {
|
|
51
|
+
render(
|
|
52
|
+
<CollapsibleSection title="Billing details">
|
|
53
|
+
<p>Content</p>
|
|
54
|
+
</CollapsibleSection>,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
expect(screen.getByRole('button', { name: 'Collapse Billing details section' })).toBeTruthy()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('applies titleClassName and lets the header row shrink so truncation works', () => {
|
|
61
|
+
renderWithProviders(
|
|
62
|
+
<CollapsibleSection title="a-fairly-long-identifier@example.com" titleClassName="truncate">
|
|
63
|
+
<p>Content</p>
|
|
64
|
+
</CollapsibleSection>,
|
|
65
|
+
{ locale: 'en', dict: {} },
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
const heading = screen.getByRole('heading', { level: 3 })
|
|
69
|
+
expect(heading.className).toContain('truncate')
|
|
70
|
+
expect(heading.className).toContain('min-w-0')
|
|
71
|
+
expect(heading.className).toContain('text-sm')
|
|
72
|
+
expect(screen.getByRole('button').className).toContain('min-w-0')
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('keeps the header row unconstrained when no titleClassName is supplied', () => {
|
|
76
|
+
renderWithProviders(
|
|
77
|
+
<CollapsibleSection title="Billing details">
|
|
78
|
+
<p>Content</p>
|
|
79
|
+
</CollapsibleSection>,
|
|
80
|
+
{ locale: 'en', dict: {} },
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
expect(screen.getByRole('heading', { level: 3 }).className).not.toContain('min-w-0')
|
|
84
|
+
expect(screen.getByRole('button').className).not.toContain('min-w-0')
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('SectionHeader', () => {
|
|
89
|
+
it('applies titleClassName to the title element', () => {
|
|
90
|
+
renderWithProviders(<SectionHeader title="a-fairly-long-identifier@example.com" titleClassName="truncate" />, {
|
|
91
|
+
locale: 'en',
|
|
92
|
+
dict: {},
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
const heading = screen.getByRole('heading', { level: 3 })
|
|
96
|
+
expect(heading.className).toContain('truncate')
|
|
97
|
+
expect(heading.className).toContain('min-w-0')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('keeps the title unconstrained when no titleClassName is supplied', () => {
|
|
101
|
+
renderWithProviders(<SectionHeader title="Billing details" />, { locale: 'en', dict: {} })
|
|
102
|
+
|
|
103
|
+
expect(screen.getByRole('heading', { level: 3 }).className).not.toContain('min-w-0')
|
|
104
|
+
})
|
|
105
|
+
})
|