@open-mercato/ui 0.6.5-develop.5337.1.534b781eac → 0.6.5-develop.5382.1.f542de69af
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/ai/AiChat.js.map +1 -1
- package/dist/ai/records/ActivityCard.js +1 -6
- package/dist/ai/records/ActivityCard.js.map +2 -2
- package/dist/ai/records/DealCard.js +2 -23
- package/dist/ai/records/DealCard.js.map +2 -2
- package/dist/ai/records/ProductCard.js +2 -17
- package/dist/ai/records/ProductCard.js.map +2 -2
- package/dist/ai/useAiChat.js.map +1 -1
- package/dist/backend/CrudForm.js.map +1 -1
- package/dist/backend/DataTable.js.map +1 -1
- package/dist/backend/ProfileDropdown.js +62 -33
- package/dist/backend/ProfileDropdown.js.map +2 -2
- package/dist/backend/injection/useGuardedMutation.js.map +1 -1
- package/dist/backend/utils/optimisticLock.js.map +1 -1
- package/dist/utils/format.js +27 -0
- package/dist/utils/format.js.map +7 -0
- package/package.json +7 -3
- package/src/ai/AiChat.tsx +1 -1
- package/src/ai/__tests__/AiChat.task-plan.test.tsx +1 -1
- package/src/ai/records/ActivityCard.tsx +1 -7
- package/src/ai/records/DealCard.tsx +2 -26
- package/src/ai/records/ProductCard.tsx +2 -19
- package/src/ai/useAiChat.ts +1 -1
- package/src/backend/CrudForm.tsx +1 -1
- package/src/backend/DataTable.tsx +2 -2
- package/src/backend/ProfileDropdown.tsx +35 -3
- package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +52 -0
- package/src/backend/__tests__/ProfileDropdown.test.tsx +59 -0
- package/src/backend/__tests__/lazy-heavy-libraries.test.ts +8 -0
- package/src/backend/injection/useGuardedMutation.ts +1 -1
- package/src/backend/utils/optimisticLock.ts +1 -1
- package/src/utils/__tests__/format.test.ts +58 -0
- package/src/utils/format.ts +40 -0
|
@@ -58,6 +58,9 @@ import { dismissRecordConflict, getRecordConflictForTest } from '../conflicts'
|
|
|
58
58
|
|
|
59
59
|
const dict = {
|
|
60
60
|
'ui.forms.actions.save': 'Save',
|
|
61
|
+
'ui.forms.actions.delete': 'Delete',
|
|
62
|
+
'ui.forms.flash.deleteSuccess': 'Record deleted.',
|
|
63
|
+
'ui.forms.flash.deleteError': 'Failed to delete record.',
|
|
61
64
|
'ui.forms.flash.recordModified': 'This record was modified by someone else. Refresh and try again.',
|
|
62
65
|
}
|
|
63
66
|
|
|
@@ -171,6 +174,55 @@ describe('CrudForm — optimisticLockUpdatedAt prop wiring', () => {
|
|
|
171
174
|
'error',
|
|
172
175
|
)
|
|
173
176
|
})
|
|
177
|
+
|
|
178
|
+
it('delete: a 409 conflict surfaces the conflict bar and never shows a success toast (regression #2409)', async () => {
|
|
179
|
+
// Regression guard for #2409: when an optimistic-lock conflict (409) is
|
|
180
|
+
// thrown from onDelete, CrudForm must route it to the unified conflict bar
|
|
181
|
+
// and MUST NOT flash a "Record deleted." success toast. A previous version
|
|
182
|
+
// of the planner Availability Schedule page swallowed the delete error, so
|
|
183
|
+
// CrudForm treated the failed delete as a success and showed a false toast.
|
|
184
|
+
dismissRecordConflict()
|
|
185
|
+
const conflict = Object.assign(new Error('record_modified'), {
|
|
186
|
+
status: 409,
|
|
187
|
+
error: 'record_modified',
|
|
188
|
+
code: OPTIMISTIC_LOCK_CONFLICT_CODE,
|
|
189
|
+
currentUpdatedAt: '2026-05-25T08:42:19.000Z',
|
|
190
|
+
expectedUpdatedAt: '2026-05-25T08:42:18.000Z',
|
|
191
|
+
})
|
|
192
|
+
const onSubmit = jest.fn(async () => undefined)
|
|
193
|
+
const onDelete = jest.fn(async () => {
|
|
194
|
+
throw conflict
|
|
195
|
+
})
|
|
196
|
+
const { container } = renderWithProviders(
|
|
197
|
+
<CrudForm<{ displayName: string; id?: string }>
|
|
198
|
+
fields={fields}
|
|
199
|
+
initialValues={{ id: 'rec-1', displayName: 'old' }}
|
|
200
|
+
onSubmit={onSubmit}
|
|
201
|
+
onDelete={onDelete}
|
|
202
|
+
optimisticLockUpdatedAt="2026-05-25T08:42:18.000Z"
|
|
203
|
+
/>,
|
|
204
|
+
{ dict },
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
const deleteButton = Array.from(container.querySelectorAll('button')).find(
|
|
208
|
+
(button) => button.textContent?.trim() === 'Delete',
|
|
209
|
+
) as HTMLButtonElement
|
|
210
|
+
expect(deleteButton).toBeTruthy()
|
|
211
|
+
|
|
212
|
+
await act(async () => {
|
|
213
|
+
fireEvent.click(deleteButton)
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
await waitFor(() => expect(onDelete).toHaveBeenCalledTimes(1))
|
|
217
|
+
await waitFor(() => {
|
|
218
|
+
const entry = getRecordConflictForTest()
|
|
219
|
+
expect(entry).not.toBeNull()
|
|
220
|
+
expect(entry?.message).toBe('This record was modified by someone else. Refresh and try again.')
|
|
221
|
+
})
|
|
222
|
+
// The false success toast is the actual bug — it must never fire on a 409.
|
|
223
|
+
expect(flashMock).not.toHaveBeenCalledWith('Record deleted.', 'success')
|
|
224
|
+
expect(flashMock).not.toHaveBeenCalledWith(expect.anything(), 'success')
|
|
225
|
+
})
|
|
174
226
|
})
|
|
175
227
|
|
|
176
228
|
type AutoFormValues = { displayName: string; id?: string; updatedAt?: string | null; updated_at?: string | null }
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { fireEvent, screen } from '@testing-library/react'
|
|
3
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
4
|
+
|
|
5
|
+
jest.mock('next/link', () => {
|
|
6
|
+
const MockLink = ({ children, href, ...rest }: { children: React.ReactNode; href: string }) => (
|
|
7
|
+
<a href={href} {...rest}>{children}</a>
|
|
8
|
+
)
|
|
9
|
+
MockLink.displayName = 'MockLink'
|
|
10
|
+
return MockLink
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
jest.mock('../injection/InjectionSpot', () => ({
|
|
14
|
+
InjectionSpot: () => null,
|
|
15
|
+
}))
|
|
16
|
+
|
|
17
|
+
jest.mock('../injection/useInjectedMenuItems', () => ({
|
|
18
|
+
useInjectedMenuItems: () => ({ items: [], isLoading: false }),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
jest.mock('@open-mercato/ui/theme', () => ({
|
|
22
|
+
useTheme: () => ({ theme: 'light', resolvedTheme: 'light', setTheme: jest.fn() }),
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
import { ProfileDropdown } from '../ProfileDropdown'
|
|
26
|
+
|
|
27
|
+
describe('ProfileDropdown', () => {
|
|
28
|
+
it('does not render the menu until the trigger is clicked', () => {
|
|
29
|
+
renderWithProviders(<ProfileDropdown email="user@example.com" />)
|
|
30
|
+
expect(screen.queryByTestId('profile-dropdown')).not.toBeInTheDocument()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders the open menu in a body portal so it escapes the header stacking context', () => {
|
|
34
|
+
renderWithProviders(<ProfileDropdown email="user@example.com" />)
|
|
35
|
+
|
|
36
|
+
fireEvent.click(screen.getByTestId('profile-dropdown-trigger'))
|
|
37
|
+
|
|
38
|
+
const menu = screen.getByTestId('profile-dropdown')
|
|
39
|
+
expect(menu).toBeInTheDocument()
|
|
40
|
+
// createPortal mounts the menu directly under document.body, outside the
|
|
41
|
+
// sticky header's backdrop-blur stacking context (regression: issue #2941).
|
|
42
|
+
expect(menu.parentElement).toBe(document.body)
|
|
43
|
+
// It must use fixed positioning + the popover layer rather than being
|
|
44
|
+
// absolutely positioned inside the header.
|
|
45
|
+
expect(menu.className).toContain('fixed')
|
|
46
|
+
expect(menu.className).toContain('z-popover')
|
|
47
|
+
expect(menu.className).not.toContain('absolute')
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('closes the menu (unmounting the portal) on Escape', () => {
|
|
51
|
+
renderWithProviders(<ProfileDropdown email="user@example.com" />)
|
|
52
|
+
|
|
53
|
+
fireEvent.click(screen.getByTestId('profile-dropdown-trigger'))
|
|
54
|
+
expect(screen.getByTestId('profile-dropdown')).toBeInTheDocument()
|
|
55
|
+
|
|
56
|
+
fireEvent.keyDown(document, { key: 'Escape' })
|
|
57
|
+
expect(screen.queryByTestId('profile-dropdown')).not.toBeInTheDocument()
|
|
58
|
+
})
|
|
59
|
+
})
|
|
@@ -123,4 +123,12 @@ describe('heavy libraries are lazy-loaded', () => {
|
|
|
123
123
|
const source = read('packages/ui/src/backend/schedule/ScheduleCalendar.tsx')
|
|
124
124
|
expect(source).toMatch(/import\s+['"]react-big-calendar\/lib\/css\/react-big-calendar\.css['"]/)
|
|
125
125
|
})
|
|
126
|
+
|
|
127
|
+
it('AttachmentContentPreview does not statically import react-markdown or remark-gfm', () => {
|
|
128
|
+
const source = read(
|
|
129
|
+
'packages/core/src/modules/attachments/components/AttachmentContentPreview.tsx',
|
|
130
|
+
)
|
|
131
|
+
expect(source).not.toMatch(/from\s+['"]react-markdown['"]/)
|
|
132
|
+
expect(source).not.toMatch(/from\s+['"]remark-gfm['"]/)
|
|
133
|
+
})
|
|
126
134
|
})
|
|
@@ -43,7 +43,7 @@ export function useGuardedMutation<TContext extends GuardedMutationContext>({
|
|
|
43
43
|
error,
|
|
44
44
|
})
|
|
45
45
|
// Default UX for OSS optimistic-lock conflicts (spec
|
|
46
|
-
// .ai/specs/2026-05-25-oss-optimistic-locking.md §3.6): when the
|
|
46
|
+
// .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md §3.6): when the
|
|
47
47
|
// server returns 409 with `code: 'optimistic_lock_conflict'`,
|
|
48
48
|
// surface the conflict on the unified, persistent, error-styled
|
|
49
49
|
// RecordConflictBanner (rendered in AppShell) instead of a transient
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Client-side helpers for the OSS opt-in optimistic-locking guard
|
|
3
|
-
* (spec: .ai/specs/2026-05-25-oss-optimistic-locking.md).
|
|
3
|
+
* (spec: .ai/specs/implemented/2026-05-25-oss-optimistic-locking.md).
|
|
4
4
|
*
|
|
5
5
|
* These are deliberately small and dependency-light so they can be wired
|
|
6
6
|
* into any backend page without touching the shared CrudForm / useGuardedMutation
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { formatCurrency, formatDate } from '../format'
|
|
2
|
+
|
|
3
|
+
describe('formatCurrency', () => {
|
|
4
|
+
it('returns null for empty input', () => {
|
|
5
|
+
expect(formatCurrency(null)).toBeNull()
|
|
6
|
+
expect(formatCurrency(undefined)).toBeNull()
|
|
7
|
+
expect(formatCurrency('')).toBeNull()
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('echoes back a non-numeric string', () => {
|
|
11
|
+
expect(formatCurrency('n/a')).toBe('n/a')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('returns null for a non-finite number', () => {
|
|
15
|
+
expect(formatCurrency(Number.NaN)).toBeNull()
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('formats a numeric value with an ISO currency code', () => {
|
|
19
|
+
const result = formatCurrency(1234.5, 'usd')
|
|
20
|
+
expect(result).toMatch(/1,234\.5/)
|
|
21
|
+
expect(result).toMatch(/\$|USD/)
|
|
22
|
+
})
|
|
23
|
+
|
|
24
|
+
it('formats a numeric string the same as a number', () => {
|
|
25
|
+
expect(formatCurrency('1234.5', 'USD')).toBe(formatCurrency(1234.5, 'USD'))
|
|
26
|
+
})
|
|
27
|
+
|
|
28
|
+
it('falls back to a plain number without a currency code', () => {
|
|
29
|
+
expect(formatCurrency(1000)).toBe(new Intl.NumberFormat().format(1000))
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('ignores currency codes that are not three characters', () => {
|
|
33
|
+
expect(formatCurrency(1000, 'US')).toBe(new Intl.NumberFormat().format(1000))
|
|
34
|
+
})
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('formatDate', () => {
|
|
38
|
+
it('returns null for empty input', () => {
|
|
39
|
+
expect(formatDate(null)).toBeNull()
|
|
40
|
+
expect(formatDate(undefined)).toBeNull()
|
|
41
|
+
expect(formatDate('')).toBeNull()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('echoes back an unparseable date', () => {
|
|
45
|
+
expect(formatDate('not-a-date')).toBe('not-a-date')
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
it('formats a valid ISO date as a localized short date', () => {
|
|
49
|
+
const result = formatDate('2026-06-09T00:00:00.000Z')
|
|
50
|
+
expect(result).toEqual(
|
|
51
|
+
new Date('2026-06-09T00:00:00.000Z').toLocaleDateString(undefined, {
|
|
52
|
+
year: 'numeric',
|
|
53
|
+
month: 'short',
|
|
54
|
+
day: 'numeric',
|
|
55
|
+
}),
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
})
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Format a monetary value with an optional ISO-4217 currency code.
|
|
3
|
+
*
|
|
4
|
+
* Returns `null` for empty input, the original string for non-numeric input,
|
|
5
|
+
* and falls back to a plain number format (optionally suffixed with the code)
|
|
6
|
+
* when the currency code is missing or rejected by `Intl.NumberFormat`.
|
|
7
|
+
*/
|
|
8
|
+
export function formatCurrency(
|
|
9
|
+
value: string | number | null | undefined,
|
|
10
|
+
currency?: string | null,
|
|
11
|
+
): string | null {
|
|
12
|
+
if (value === null || value === undefined || value === '') return null
|
|
13
|
+
const numeric = typeof value === 'number' ? value : Number(value)
|
|
14
|
+
if (!Number.isFinite(numeric)) {
|
|
15
|
+
return typeof value === 'string' ? value : null
|
|
16
|
+
}
|
|
17
|
+
const code = currency && currency.length === 3 ? currency.toUpperCase() : undefined
|
|
18
|
+
try {
|
|
19
|
+
if (code) {
|
|
20
|
+
return new Intl.NumberFormat(undefined, { style: 'currency', currency: code }).format(numeric)
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// fall through to plain number formatting
|
|
24
|
+
}
|
|
25
|
+
const formatted = new Intl.NumberFormat().format(numeric)
|
|
26
|
+
return code ? `${formatted} ${code}` : formatted
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Format an ISO date string as a localized short date (e.g. `Jun 9, 2026`).
|
|
31
|
+
*
|
|
32
|
+
* Returns `null` for empty input and echoes the original value back when it is
|
|
33
|
+
* not a parseable date.
|
|
34
|
+
*/
|
|
35
|
+
export function formatDate(value: string | null | undefined): string | null {
|
|
36
|
+
if (!value) return null
|
|
37
|
+
const date = new Date(value)
|
|
38
|
+
if (Number.isNaN(date.getTime())) return value
|
|
39
|
+
return date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' })
|
|
40
|
+
}
|