@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.
Files changed (34) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/ai/AiChat.js.map +1 -1
  3. package/dist/ai/records/ActivityCard.js +1 -6
  4. package/dist/ai/records/ActivityCard.js.map +2 -2
  5. package/dist/ai/records/DealCard.js +2 -23
  6. package/dist/ai/records/DealCard.js.map +2 -2
  7. package/dist/ai/records/ProductCard.js +2 -17
  8. package/dist/ai/records/ProductCard.js.map +2 -2
  9. package/dist/ai/useAiChat.js.map +1 -1
  10. package/dist/backend/CrudForm.js.map +1 -1
  11. package/dist/backend/DataTable.js.map +1 -1
  12. package/dist/backend/ProfileDropdown.js +62 -33
  13. package/dist/backend/ProfileDropdown.js.map +2 -2
  14. package/dist/backend/injection/useGuardedMutation.js.map +1 -1
  15. package/dist/backend/utils/optimisticLock.js.map +1 -1
  16. package/dist/utils/format.js +27 -0
  17. package/dist/utils/format.js.map +7 -0
  18. package/package.json +7 -3
  19. package/src/ai/AiChat.tsx +1 -1
  20. package/src/ai/__tests__/AiChat.task-plan.test.tsx +1 -1
  21. package/src/ai/records/ActivityCard.tsx +1 -7
  22. package/src/ai/records/DealCard.tsx +2 -26
  23. package/src/ai/records/ProductCard.tsx +2 -19
  24. package/src/ai/useAiChat.ts +1 -1
  25. package/src/backend/CrudForm.tsx +1 -1
  26. package/src/backend/DataTable.tsx +2 -2
  27. package/src/backend/ProfileDropdown.tsx +35 -3
  28. package/src/backend/__tests__/CrudForm.optimisticLock.test.tsx +52 -0
  29. package/src/backend/__tests__/ProfileDropdown.test.tsx +59 -0
  30. package/src/backend/__tests__/lazy-heavy-libraries.test.ts +8 -0
  31. package/src/backend/injection/useGuardedMutation.ts +1 -1
  32. package/src/backend/utils/optimisticLock.ts +1 -1
  33. package/src/utils/__tests__/format.test.ts +58 -0
  34. 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
+ }