@open-mercato/ui 0.8.1-develop.7296.1.2111d779db → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.turbo/turbo-build.log +1 -1
- package/dist/backend/CrudForm.js +19 -6
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +62 -10
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/RowActions.js +54 -5
- package/dist/backend/RowActions.js.map +2 -2
- package/dist/backend/charts/KpiCard.js +2 -1
- package/dist/backend/charts/KpiCard.js.map +2 -2
- package/dist/backend/detail/AttachmentsSection.js +17 -5
- package/dist/backend/detail/AttachmentsSection.js.map +2 -2
- package/dist/backend/detail/NotesSection.js +38 -20
- package/dist/backend/detail/NotesSection.js.map +2 -2
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +11 -1
- package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +2 -2
- package/dist/backend/icons/lucideRegistry.generated.js +26 -0
- package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
- package/dist/backend/injection/eventBridge.js +33 -0
- package/dist/backend/injection/eventBridge.js.map +2 -2
- package/dist/backend/inputs/LookupSelect.js +20 -3
- package/dist/backend/inputs/LookupSelect.js.map +2 -2
- package/dist/backend/inputs/PhoneNumberField.js +1 -1
- package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
- package/dist/backend/inputs/TagsInput.js +20 -2
- package/dist/backend/inputs/TagsInput.js.map +2 -2
- package/dist/backend/messages/EmailThreadsPanel.js +2 -1
- package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
- package/dist/backend/messages/MessageComposer.js.map +2 -2
- package/dist/backend/messages/index.js.map +2 -2
- package/dist/backend/messages/message-compose-form-groups.js +24 -0
- package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
- package/dist/backend/messages/useMessageCompose.js +27 -0
- package/dist/backend/messages/useMessageCompose.js.map +2 -2
- package/dist/backend/messages/useMessageComposeOperations.js +1 -0
- package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
- package/dist/backend/notifications/NotificationItem.js +8 -1
- package/dist/backend/notifications/NotificationItem.js.map +2 -2
- package/dist/portal/PortalShell.js +2 -2
- package/dist/portal/PortalShell.js.map +2 -2
- package/dist/primitives/calendar.js +69 -14
- package/dist/primitives/calendar.js.map +2 -2
- package/package.json +4 -5
- package/src/backend/CrudForm.tsx +30 -8
- package/src/backend/DataTable.tsx +95 -20
- package/src/backend/RowActions.tsx +114 -6
- package/src/backend/__tests__/AttachmentsSection.upload.test.tsx +183 -0
- package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +139 -2
- package/src/backend/__tests__/CrudForm.hiddenGroups.test.tsx +1 -1
- package/src/backend/__tests__/DataTable.extensions.test.tsx +30 -0
- package/src/backend/__tests__/DataTable.perspectiveReconcile.test.tsx +320 -2
- package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +19 -0
- package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +117 -0
- package/src/backend/__tests__/NotesSection.test.tsx +138 -0
- package/src/backend/__tests__/RowActions.test.tsx +202 -0
- package/src/backend/charts/KpiCard.tsx +1 -0
- package/src/backend/charts/__tests__/KpiCard.test.tsx +20 -0
- package/src/backend/detail/AttachmentsSection.tsx +17 -5
- package/src/backend/detail/NotesSection.tsx +122 -66
- package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +24 -1
- package/src/backend/icons/lucideRegistry.generated.tsx +26 -0
- package/src/backend/injection/__tests__/eventBridge.test.tsx +47 -1
- package/src/backend/injection/eventBridge.ts +35 -0
- package/src/backend/inputs/LookupSelect.tsx +32 -3
- package/src/backend/inputs/PhoneNumberField.tsx +1 -1
- package/src/backend/inputs/TagsInput.tsx +24 -3
- package/src/backend/inputs/__tests__/LookupSelect.test.tsx +166 -1
- package/src/backend/inputs/__tests__/PhoneNumberField.typeahead.test.tsx +43 -0
- package/src/backend/inputs/__tests__/TagsInput.test.tsx +81 -0
- package/src/backend/messages/EmailThreadsPanel.tsx +21 -1
- package/src/backend/messages/MessageComposer.tsx +1 -0
- package/src/backend/messages/__tests__/EmailThreadsPanel.senderLabel.test.tsx +99 -0
- package/src/backend/messages/__tests__/MessageComposer.test.tsx +44 -0
- package/src/backend/messages/__tests__/useMessageCompose.test.tsx +69 -0
- package/src/backend/messages/__tests__/useMessageComposeSender.test.tsx +208 -0
- package/src/backend/messages/index.ts +1 -0
- package/src/backend/messages/message-compose-form-groups.tsx +41 -0
- package/src/backend/messages/message-composer.types.ts +26 -0
- package/src/backend/messages/useMessageCompose.ts +46 -0
- package/src/backend/messages/useMessageComposeOperations.ts +8 -0
- package/src/backend/notifications/NotificationItem.tsx +7 -0
- package/src/backend/notifications/__tests__/NotificationItem.customRenderer.test.tsx +139 -0
- package/src/portal/PortalShell.tsx +7 -4
- package/src/portal/__tests__/PortalShell.test.tsx +29 -1
- package/src/primitives/__tests__/calendar.test.tsx +174 -1
- package/src/primitives/calendar.tsx +168 -96
|
@@ -19,9 +19,26 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
|
|
|
19
19
|
const btnRef = React.useRef<HTMLButtonElement>(null)
|
|
20
20
|
const menuRef = React.useRef<HTMLDivElement>(null)
|
|
21
21
|
const hoverTimeoutRef = React.useRef<NodeJS.Timeout | null>(null)
|
|
22
|
+
/**
|
|
23
|
+
* Whether this open should pull focus into the menu.
|
|
24
|
+
*
|
|
25
|
+
* Only a deliberate open does — a click, or Enter/Space on the trigger, which
|
|
26
|
+
* the browser also reports as a click. The menu opens on hover too, and taking
|
|
27
|
+
* focus on a hover open meant brushing the pointer over ⋯ while typing in a
|
|
28
|
+
* list's search field pulled the caret out of the field (#6771).
|
|
29
|
+
*
|
|
30
|
+
* Consumed on use, so repositioning the panel mid-scroll cannot re-trigger it.
|
|
31
|
+
*/
|
|
32
|
+
const focusOnOpenRef = React.useRef(false)
|
|
22
33
|
const [anchorRect, setAnchorRect] = React.useState<DOMRect | null>(null)
|
|
23
34
|
const [direction, setDirection] = React.useState<'down' | 'up'>('down')
|
|
24
35
|
|
|
36
|
+
/** The menu's items in DOM order, as focusable elements. */
|
|
37
|
+
const getFocusableItems = React.useCallback((): HTMLElement[] => {
|
|
38
|
+
if (!menuRef.current) return []
|
|
39
|
+
return Array.from(menuRef.current.querySelectorAll<HTMLElement>('[role="menuitem"]'))
|
|
40
|
+
}, [])
|
|
41
|
+
|
|
25
42
|
const updatePosition = React.useCallback(() => {
|
|
26
43
|
if (!btnRef.current) return
|
|
27
44
|
const rect = btnRef.current.getBoundingClientRect()
|
|
@@ -42,10 +59,60 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
|
|
|
42
59
|
}
|
|
43
60
|
}
|
|
44
61
|
function onKey(e: KeyboardEvent) {
|
|
62
|
+
// This listener is on `document`, and the menu can be open while the user
|
|
63
|
+
// is working somewhere else entirely — it opens on hover. So every key
|
|
64
|
+
// below is scoped to "focus is actually in this menu", otherwise a
|
|
65
|
+
// hover-opened menu would swallow the arrows and Home/End of someone
|
|
66
|
+
// typing in the list's search field (#6771). The trigger counts, so
|
|
67
|
+
// ArrowDown from it still walks into the menu.
|
|
68
|
+
const active = document.activeElement
|
|
69
|
+
const focusWithin = Boolean(
|
|
70
|
+
(menuRef.current && active && menuRef.current.contains(active)) || (active && active === btnRef.current),
|
|
71
|
+
)
|
|
45
72
|
if (e.key === 'Escape') {
|
|
46
73
|
setOpen(false)
|
|
47
|
-
|
|
74
|
+
// Only pull focus back if it was ours to begin with.
|
|
75
|
+
if (focusWithin) btnRef.current?.focus()
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
if (!focusWithin) return
|
|
79
|
+
// Arrow keys move between items instead of doing nothing (#6718). Without
|
|
80
|
+
// this the only way to reach an item was to Tab through it — and since
|
|
81
|
+
// every row's trigger sits in the tab order, reaching the first item of a
|
|
82
|
+
// six-row table took eleven Tab presses.
|
|
83
|
+
const focusables = getFocusableItems()
|
|
84
|
+
if (!focusables.length) return
|
|
85
|
+
const activeIndex = focusables.indexOf(document.activeElement as HTMLElement)
|
|
86
|
+
let nextIndex: number | null = null
|
|
87
|
+
switch (e.key) {
|
|
88
|
+
case 'ArrowDown':
|
|
89
|
+
nextIndex = activeIndex < 0 ? 0 : (activeIndex + 1) % focusables.length
|
|
90
|
+
break
|
|
91
|
+
case 'ArrowUp':
|
|
92
|
+
nextIndex = activeIndex < 0
|
|
93
|
+
? focusables.length - 1
|
|
94
|
+
: (activeIndex - 1 + focusables.length) % focusables.length
|
|
95
|
+
break
|
|
96
|
+
case 'Home':
|
|
97
|
+
nextIndex = 0
|
|
98
|
+
break
|
|
99
|
+
case 'End':
|
|
100
|
+
nextIndex = focusables.length - 1
|
|
101
|
+
break
|
|
102
|
+
case 'Tab':
|
|
103
|
+
// A menu is modal for the keyboard: Tab must not escape it into the
|
|
104
|
+
// next row's trigger, which is what made the menu feel unreachable.
|
|
105
|
+
e.preventDefault()
|
|
106
|
+
nextIndex = e.shiftKey
|
|
107
|
+
? (activeIndex <= 0 ? focusables.length - 1 : activeIndex - 1)
|
|
108
|
+
: (activeIndex < 0 || activeIndex === focusables.length - 1 ? 0 : activeIndex + 1)
|
|
109
|
+
break
|
|
110
|
+
default:
|
|
111
|
+
return
|
|
48
112
|
}
|
|
113
|
+
if (nextIndex === null) return
|
|
114
|
+
e.preventDefault()
|
|
115
|
+
focusables[nextIndex]?.focus()
|
|
49
116
|
}
|
|
50
117
|
function onScrollOrResize() {
|
|
51
118
|
updatePosition()
|
|
@@ -60,7 +127,28 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
|
|
|
60
127
|
window.removeEventListener('scroll', onScrollOrResize, true)
|
|
61
128
|
window.removeEventListener('resize', onScrollOrResize)
|
|
62
129
|
}
|
|
63
|
-
}, [open, updatePosition])
|
|
130
|
+
}, [open, updatePosition, getFocusableItems])
|
|
131
|
+
|
|
132
|
+
// Move focus into the menu so the arrow keys have somewhere to start, and so a
|
|
133
|
+
// screen reader announces the item rather than the still-focused trigger.
|
|
134
|
+
//
|
|
135
|
+
// Keyed on `anchorRect` rather than deferred with a timer: the panel only
|
|
136
|
+
// renders once `updatePosition` has measured the trigger, which is a second
|
|
137
|
+
// render, so on the first pass there is nothing to focus yet. This effect
|
|
138
|
+
// re-runs when that measurement lands, by which point the portal has
|
|
139
|
+
// committed. A `requestAnimationFrame` would also have waited for it, but
|
|
140
|
+
// browsers throttle rAF in a background tab, so the focus could silently never
|
|
141
|
+
// happen — a timer-free dependency cannot be throttled.
|
|
142
|
+
React.useEffect(() => {
|
|
143
|
+
if (!open) {
|
|
144
|
+
focusOnOpenRef.current = false
|
|
145
|
+
return
|
|
146
|
+
}
|
|
147
|
+
if (!anchorRect || !focusOnOpenRef.current) return
|
|
148
|
+
focusOnOpenRef.current = false
|
|
149
|
+
const [first] = getFocusableItems()
|
|
150
|
+
first?.focus()
|
|
151
|
+
}, [open, anchorRect, getFocusableItems])
|
|
64
152
|
|
|
65
153
|
// Cleanup timeout on unmount
|
|
66
154
|
React.useEffect(() => {
|
|
@@ -100,7 +188,9 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
|
|
|
100
188
|
variant="ghost"
|
|
101
189
|
aria-haspopup="menu"
|
|
102
190
|
aria-expanded={open}
|
|
103
|
-
|
|
191
|
+
// A click — and Enter/Space on the trigger, which the browser reports as
|
|
192
|
+
// one — is a deliberate open, so it takes focus. Hover does not (#6771).
|
|
193
|
+
onClick={(e) => { e.stopPropagation(); focusOnOpenRef.current = true; setOpen(true); requestAnimationFrame(updatePosition) }}
|
|
104
194
|
>
|
|
105
195
|
<span aria-hidden="true">⋯</span>
|
|
106
196
|
<span className="sr-only">{t('ui.rowActions.openActions', 'Open actions')}</span>
|
|
@@ -109,7 +199,22 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
|
|
|
109
199
|
<div
|
|
110
200
|
ref={menuRef}
|
|
111
201
|
role="menu"
|
|
112
|
-
|
|
202
|
+
// Sized to its longest item instead of a fixed 11rem: the items are
|
|
203
|
+
// Buttons, which are `whitespace-nowrap`, so a label wider than the box
|
|
204
|
+
// overflowed its right border — "Zarejestruj push ponownie" in Polish
|
|
205
|
+
// overhung by ~10px (#6719). `min-w-44` keeps the old width as a floor;
|
|
206
|
+
// past `max-w-xs` the items wrap instead of growing further.
|
|
207
|
+
//
|
|
208
|
+
// `flex flex-col` is load-bearing for that, not cosmetic. Button is
|
|
209
|
+
// `inline-flex`, so under a block panel the items are inline-level and
|
|
210
|
+
// `max-content` is the width of them all laid end to end — 523px here,
|
|
211
|
+
// past the cap, so the panel was pinned at `max-w-xs` whatever the
|
|
212
|
+
// labels said. They only looked stacked because `w-full` forced each
|
|
213
|
+
// onto its own line. Blockifying them as flex items makes `max-content`
|
|
214
|
+
// the widest item, which is what `w-max` was meant to measure.
|
|
215
|
+
//
|
|
216
|
+
// Same fix, same shape as ActionsDropdown (#3580).
|
|
217
|
+
className="fixed flex flex-col w-max min-w-44 max-w-xs rounded-md border bg-background p-1 shadow focus-visible:outline-none z-dropdown"
|
|
113
218
|
style={{
|
|
114
219
|
top: direction === 'down' ? anchorRect.bottom + 8 : anchorRect.top - 8,
|
|
115
220
|
left: Math.min(anchorRect.right, window.innerWidth - 8),
|
|
@@ -123,7 +228,7 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
|
|
|
123
228
|
<a
|
|
124
229
|
key={idx}
|
|
125
230
|
href={it.href}
|
|
126
|
-
className={`block w-full text-left px-2 py-1 text-sm rounded hover:bg-accent ${it.destructive ? 'text-destructive' : ''}`}
|
|
231
|
+
className={`block w-full text-left px-2 py-1 text-sm rounded hover:bg-accent whitespace-normal ${it.destructive ? 'text-destructive' : ''}`}
|
|
127
232
|
role="menuitem"
|
|
128
233
|
onClick={(event) => {
|
|
129
234
|
event.stopPropagation()
|
|
@@ -138,7 +243,10 @@ export function RowActions({ items = [] }: { items?: RowActionItem[] }) {
|
|
|
138
243
|
type="button"
|
|
139
244
|
variant="ghost"
|
|
140
245
|
size="sm"
|
|
141
|
-
|
|
246
|
+
// `whitespace-normal h-auto` so a label that reaches the panel's
|
|
247
|
+
// max width wraps to a second line rather than overflowing: the
|
|
248
|
+
// Button primitive is `whitespace-nowrap` by default (#6719).
|
|
249
|
+
className={`w-full justify-start rounded-none font-normal whitespace-normal h-auto py-1.5 text-left ${it.destructive ? 'text-destructive' : ''}`}
|
|
142
250
|
role="menuitem"
|
|
143
251
|
onClick={(event) => {
|
|
144
252
|
event.stopPropagation()
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
|
|
3
|
+
import * as React from 'react'
|
|
4
|
+
import { act, fireEvent, screen, waitFor } from '@testing-library/react'
|
|
5
|
+
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
6
|
+
import { AttachmentsSection } from '../detail/AttachmentsSection'
|
|
7
|
+
import { apiCall } from '@open-mercato/ui/backend/utils/apiCall'
|
|
8
|
+
|
|
9
|
+
jest.mock('@open-mercato/ui/backend/utils/apiCall', () => ({
|
|
10
|
+
apiCall: jest.fn(),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
jest.mock('../injection/useRegisteredComponent', () => ({
|
|
14
|
+
useRegisteredComponent: <T,>(_handle: string, fallback?: React.ComponentType<T>) =>
|
|
15
|
+
fallback ?? ((() => null) as React.ComponentType<T>),
|
|
16
|
+
}))
|
|
17
|
+
|
|
18
|
+
jest.mock('../detail/AttachmentMetadataDialog', () => ({
|
|
19
|
+
AttachmentMetadataDialog: () => null,
|
|
20
|
+
}))
|
|
21
|
+
|
|
22
|
+
jest.mock('../detail/AttachmentDeleteDialog', () => ({
|
|
23
|
+
AttachmentDeleteDialog: () => null,
|
|
24
|
+
}))
|
|
25
|
+
|
|
26
|
+
const REJECTION = 'Executable file types are not allowed as attachments.'
|
|
27
|
+
|
|
28
|
+
type StoredItem = { id: string; fileName: string; fileSize: number; mimeType: string }
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Emulates `/api/attachments` for one record: POST persists the file unless its
|
|
32
|
+
* name ends in `.exe` (the route's dangerous-executable 400), GET lists what was
|
|
33
|
+
* persisted. `holdUploads` parks each POST until `releaseUpload` is called so a
|
|
34
|
+
* test can interact with the section while an upload is in flight.
|
|
35
|
+
*/
|
|
36
|
+
function createServer({ holdUploads = false } = {}) {
|
|
37
|
+
const stored: StoredItem[] = []
|
|
38
|
+
const posted: string[] = []
|
|
39
|
+
const pending: Array<() => void> = []
|
|
40
|
+
let holding = holdUploads
|
|
41
|
+
let listCalls = 0
|
|
42
|
+
;(apiCall as jest.Mock).mockImplementation(async (url: string, init?: { method?: string; body?: FormData }) => {
|
|
43
|
+
if (init?.method === 'POST') {
|
|
44
|
+
const file = init.body?.get('file') as File
|
|
45
|
+
posted.push(file.name)
|
|
46
|
+
if (holding) await new Promise<void>((resolve) => pending.push(resolve))
|
|
47
|
+
if (file.name.endsWith('.exe')) {
|
|
48
|
+
return { ok: false, status: 400, result: { error: REJECTION }, response: { status: 400 } }
|
|
49
|
+
}
|
|
50
|
+
const item = { id: `att-${stored.length + 1}`, fileName: file.name, fileSize: file.size, mimeType: 'text/plain' }
|
|
51
|
+
stored.push(item)
|
|
52
|
+
return { ok: true, status: 200, result: { ok: true, item }, response: { status: 200 } }
|
|
53
|
+
}
|
|
54
|
+
if (url.startsWith('/api/attachments?')) {
|
|
55
|
+
listCalls += 1
|
|
56
|
+
return { ok: true, status: 200, result: { items: [...stored], page: 1, pageSize: 24 }, response: { status: 200 } }
|
|
57
|
+
}
|
|
58
|
+
return { ok: true, status: 200, result: {}, response: { status: 200 } }
|
|
59
|
+
})
|
|
60
|
+
return {
|
|
61
|
+
stored,
|
|
62
|
+
posted,
|
|
63
|
+
listCalls: () => listCalls,
|
|
64
|
+
pendingUploads: () => pending.length,
|
|
65
|
+
releaseAll: () => {
|
|
66
|
+
holding = false
|
|
67
|
+
pending.splice(0).forEach((resolve) => resolve())
|
|
68
|
+
},
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function textFile(name: string): File {
|
|
73
|
+
return new File([`harmless text for ${name}`], name, { type: 'text/plain' })
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function fileInput(container: HTMLElement): HTMLInputElement {
|
|
77
|
+
const input = container.querySelector('input[type="file"]')
|
|
78
|
+
if (!input) throw new Error('file input not rendered')
|
|
79
|
+
return input as HTMLInputElement
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function selectFiles(container: HTMLElement, names: string[]) {
|
|
83
|
+
fireEvent.change(fileInput(container), { target: { files: names.map(textFile) } })
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async function renderSection(onChanged = jest.fn()) {
|
|
87
|
+
const view = renderWithProviders(
|
|
88
|
+
<AttachmentsSection entityId="customers:customer_entity" recordId="record-1" onChanged={onChanged} />,
|
|
89
|
+
{ dict: {} },
|
|
90
|
+
)
|
|
91
|
+
await screen.findByText('No attachments found.')
|
|
92
|
+
return { ...view, onChanged }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
async function waitForIdle() {
|
|
96
|
+
await waitFor(() => expect(screen.getByRole('button', { name: 'Choose files' })).toBeEnabled())
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
describe('AttachmentsSection multi-file upload (#6397)', () => {
|
|
100
|
+
beforeEach(() => {
|
|
101
|
+
jest.resetAllMocks()
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
it('uploads every file and refreshes the list when all succeed', async () => {
|
|
105
|
+
const server = createServer()
|
|
106
|
+
const { container, onChanged } = await renderSection()
|
|
107
|
+
|
|
108
|
+
selectFiles(container, ['ok-a.txt', 'ok-b.txt', 'ok-c.txt'])
|
|
109
|
+
|
|
110
|
+
expect(await screen.findByText('ok-c.txt')).toBeInTheDocument()
|
|
111
|
+
await waitForIdle()
|
|
112
|
+
expect(server.posted).toEqual(['ok-a.txt', 'ok-b.txt', 'ok-c.txt'])
|
|
113
|
+
expect(screen.getByText('ok-a.txt')).toBeInTheDocument()
|
|
114
|
+
expect(screen.getByText('ok-b.txt')).toBeInTheDocument()
|
|
115
|
+
expect(screen.queryByRole('alert')).toBeNull()
|
|
116
|
+
expect(onChanged).toHaveBeenCalledTimes(1)
|
|
117
|
+
})
|
|
118
|
+
|
|
119
|
+
it('stops at a rejected first file, announces the error and changes nothing', async () => {
|
|
120
|
+
const server = createServer()
|
|
121
|
+
const { container, onChanged } = await renderSection()
|
|
122
|
+
|
|
123
|
+
selectFiles(container, ['blocked.exe', 'ok-b.txt', 'ok-c.txt'])
|
|
124
|
+
|
|
125
|
+
expect(await screen.findByRole('alert')).toHaveTextContent(REJECTION)
|
|
126
|
+
await waitForIdle()
|
|
127
|
+
expect(server.posted).toEqual(['blocked.exe'])
|
|
128
|
+
expect(server.stored).toHaveLength(0)
|
|
129
|
+
expect(screen.getByText('No attachments found.')).toBeInTheDocument()
|
|
130
|
+
expect(onChanged).not.toHaveBeenCalled()
|
|
131
|
+
})
|
|
132
|
+
|
|
133
|
+
it('shows the files saved before a rejected middle file without a reload', async () => {
|
|
134
|
+
const server = createServer()
|
|
135
|
+
const { container, onChanged } = await renderSection()
|
|
136
|
+
const listCallsBefore = server.listCalls()
|
|
137
|
+
|
|
138
|
+
selectFiles(container, ['scout-first.txt', 'scout-blocked.exe', 'scout-last.txt'])
|
|
139
|
+
|
|
140
|
+
expect(await screen.findByText('scout-first.txt')).toBeInTheDocument()
|
|
141
|
+
await waitForIdle()
|
|
142
|
+
expect(server.posted).toEqual(['scout-first.txt', 'scout-blocked.exe'])
|
|
143
|
+
expect(server.listCalls()).toBeGreaterThan(listCallsBefore)
|
|
144
|
+
expect(screen.getByRole('alert')).toHaveTextContent(REJECTION)
|
|
145
|
+
expect(screen.queryByText('No attachments found.')).toBeNull()
|
|
146
|
+
expect(screen.queryByText('scout-last.txt')).toBeNull()
|
|
147
|
+
expect(onChanged).toHaveBeenCalledTimes(1)
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
it('shows every file saved before a rejected last file', async () => {
|
|
151
|
+
const server = createServer()
|
|
152
|
+
const { container } = await renderSection()
|
|
153
|
+
|
|
154
|
+
selectFiles(container, ['last-a.txt', 'last-b.txt', 'last-blocked.exe'])
|
|
155
|
+
|
|
156
|
+
expect(await screen.findByText('last-b.txt')).toBeInTheDocument()
|
|
157
|
+
await waitForIdle()
|
|
158
|
+
expect(server.posted).toEqual(['last-a.txt', 'last-b.txt', 'last-blocked.exe'])
|
|
159
|
+
expect(screen.getByText('last-a.txt')).toBeInTheDocument()
|
|
160
|
+
expect(screen.getByRole('alert')).toHaveTextContent(REJECTION)
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('ignores a second selection or drop while a batch is uploading', async () => {
|
|
164
|
+
const server = createServer({ holdUploads: true })
|
|
165
|
+
const { container } = await renderSection()
|
|
166
|
+
|
|
167
|
+
selectFiles(container, ['drop-a.txt', 'drop-b.txt'])
|
|
168
|
+
await waitFor(() => expect(server.pendingUploads()).toBe(1))
|
|
169
|
+
|
|
170
|
+
selectFiles(container, ['late-select.txt'])
|
|
171
|
+
const dropZone = container.querySelector('[role="presentation"]') as HTMLElement
|
|
172
|
+
fireEvent.drop(dropZone, { dataTransfer: { files: [textFile('late-drop.txt')] } })
|
|
173
|
+
|
|
174
|
+
await act(async () => {
|
|
175
|
+
server.releaseAll()
|
|
176
|
+
})
|
|
177
|
+
await waitForIdle()
|
|
178
|
+
await waitFor(() => expect(screen.getByText('drop-b.txt')).toBeInTheDocument())
|
|
179
|
+
|
|
180
|
+
expect(server.posted).toEqual(['drop-a.txt', 'drop-b.txt'])
|
|
181
|
+
expect(server.stored.map((item) => item.fileName)).toEqual(['drop-a.txt', 'drop-b.txt'])
|
|
182
|
+
})
|
|
183
|
+
})
|
|
@@ -16,7 +16,11 @@ jest.mock('../injection/InjectionSpot', () => ({
|
|
|
16
16
|
__esModule: true,
|
|
17
17
|
InjectionSpot: () => null,
|
|
18
18
|
useInjectionWidgets: () => ({ widgets: [], loading: false, error: null }),
|
|
19
|
-
|
|
19
|
+
// Echo the payload so `transformFormData` is a no-op and submit assertions see
|
|
20
|
+
// exactly what CrudForm forwards to the host `onSubmit`.
|
|
21
|
+
useInjectionSpotEvents: () => ({
|
|
22
|
+
triggerEvent: jest.fn(async (_event: string, data: unknown) => ({ ok: true, data })),
|
|
23
|
+
}),
|
|
20
24
|
}))
|
|
21
25
|
jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
22
26
|
__esModule: true,
|
|
@@ -31,7 +35,8 @@ jest.mock('../injection/useInjectionDataWidgets', () => ({
|
|
|
31
35
|
}))
|
|
32
36
|
|
|
33
37
|
import * as React from 'react'
|
|
34
|
-
import { waitFor } from '@testing-library/react'
|
|
38
|
+
import { fireEvent, waitFor } from '@testing-library/react'
|
|
39
|
+
import { z } from 'zod'
|
|
35
40
|
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
36
41
|
import { CrudForm, type CrudField, type CrudFormGroup } from '../CrudForm'
|
|
37
42
|
import { InjectionPosition } from '@open-mercato/shared/modules/widgets/injection-position'
|
|
@@ -139,3 +144,135 @@ describe('CrudForm group field injection (#3047)', () => {
|
|
|
139
144
|
expect(labelMatches).toHaveLength(1)
|
|
140
145
|
})
|
|
141
146
|
})
|
|
147
|
+
|
|
148
|
+
describe('CrudForm injected field id collisions', () => {
|
|
149
|
+
afterEach(() => {
|
|
150
|
+
injectedFieldWidgets = []
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
function renderCollisionForm(options: {
|
|
154
|
+
fields: CrudField[]
|
|
155
|
+
initialValues: Record<string, unknown>
|
|
156
|
+
onSubmit: (values: unknown) => void
|
|
157
|
+
schema?: z.ZodTypeAny
|
|
158
|
+
}) {
|
|
159
|
+
return renderWithProviders(
|
|
160
|
+
React.createElement(CrudForm as any, {
|
|
161
|
+
title: 'Form',
|
|
162
|
+
entityId: 'catalog:catalog_product_price',
|
|
163
|
+
fields: options.fields,
|
|
164
|
+
groups: [{ id: 'scope', title: 'Scope', fields: options.fields.map((field) => field.id) }],
|
|
165
|
+
initialValues: options.initialValues,
|
|
166
|
+
schema: options.schema,
|
|
167
|
+
onSubmit: options.onSubmit,
|
|
168
|
+
}),
|
|
169
|
+
)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
it('submits the value of an injected field that reuses a host field id', async () => {
|
|
173
|
+
injectedFieldWidgets = [
|
|
174
|
+
{ fields: [{ id: 'customerGroupId', label: 'Customer group', type: 'text', group: 'scope' }] },
|
|
175
|
+
]
|
|
176
|
+
const onSubmit = jest.fn()
|
|
177
|
+
const { container } = renderCollisionForm({
|
|
178
|
+
fields: [
|
|
179
|
+
{ id: 'name', label: 'Name', type: 'text' },
|
|
180
|
+
{ id: 'customerGroupId', label: 'Customer group id', type: 'text' },
|
|
181
|
+
],
|
|
182
|
+
initialValues: { name: 'Retail', customerGroupId: 'group-1' },
|
|
183
|
+
onSubmit,
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
fireEvent.submit(container.querySelector('form') as HTMLFormElement)
|
|
187
|
+
|
|
188
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
189
|
+
expect(onSubmit.mock.calls[0][0]).toEqual(
|
|
190
|
+
expect.objectContaining({ name: 'Retail', customerGroupId: 'group-1' }),
|
|
191
|
+
)
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
it('submits the value of an injected field that reuses a group-inline host field id', async () => {
|
|
195
|
+
injectedFieldWidgets = [
|
|
196
|
+
{ fields: [{ id: 'customerGroupId', label: 'Customer group', type: 'text', group: 'scope' }] },
|
|
197
|
+
]
|
|
198
|
+
const onSubmit = jest.fn()
|
|
199
|
+
const { container } = renderWithProviders(
|
|
200
|
+
React.createElement(CrudForm as any, {
|
|
201
|
+
title: 'Form',
|
|
202
|
+
entityId: 'catalog:catalog_product_price',
|
|
203
|
+
fields: [{ id: 'name', label: 'Name', type: 'text' }],
|
|
204
|
+
groups: [
|
|
205
|
+
{
|
|
206
|
+
id: 'scope',
|
|
207
|
+
title: 'Scope',
|
|
208
|
+
fields: ['name', { id: 'customerGroupId', label: 'Customer group id', type: 'text' }],
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
initialValues: { name: 'Retail', customerGroupId: 'group-1' },
|
|
212
|
+
onSubmit,
|
|
213
|
+
}),
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
await waitFor(() => {
|
|
217
|
+
expect(container.querySelector('[data-crud-field-id="customerGroupId"]')).toBeTruthy()
|
|
218
|
+
})
|
|
219
|
+
fireEvent.submit(container.querySelector('form') as HTMLFormElement)
|
|
220
|
+
|
|
221
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
222
|
+
expect(onSubmit.mock.calls[0][0]).toEqual(
|
|
223
|
+
expect.objectContaining({ name: 'Retail', customerGroupId: 'group-1' }),
|
|
224
|
+
)
|
|
225
|
+
})
|
|
226
|
+
|
|
227
|
+
it('still strips an injected-only field from the host payload', async () => {
|
|
228
|
+
injectedFieldWidgets = [
|
|
229
|
+
{ fields: [{ id: 'widgetOnlyNote', label: 'Widget note', type: 'text', group: 'scope' }] },
|
|
230
|
+
]
|
|
231
|
+
const onSubmit = jest.fn()
|
|
232
|
+
const { container } = renderCollisionForm({
|
|
233
|
+
fields: [{ id: 'name', label: 'Name', type: 'text' }],
|
|
234
|
+
initialValues: { name: 'Retail', widgetOnlyNote: 'extra' },
|
|
235
|
+
onSubmit,
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
await waitFor(() => {
|
|
239
|
+
expect(container.querySelector('[data-crud-field-id="widgetOnlyNote"]')).toBeTruthy()
|
|
240
|
+
})
|
|
241
|
+
fireEvent.submit(container.querySelector('form') as HTMLFormElement)
|
|
242
|
+
|
|
243
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
244
|
+
const submitted = onSubmit.mock.calls[0][0] as Record<string, unknown>
|
|
245
|
+
expect(submitted.name).toBe('Retail')
|
|
246
|
+
expect(submitted).not.toHaveProperty('widgetOnlyNote')
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('collapses a dot-path host field that an injected field reuses into its nested shape', async () => {
|
|
250
|
+
injectedFieldWidgets = [
|
|
251
|
+
{ fields: [{ id: 'metadata.channel', label: 'Channel', type: 'text', group: 'scope' }] },
|
|
252
|
+
]
|
|
253
|
+
const onSubmit = jest.fn()
|
|
254
|
+
const { container } = renderCollisionForm({
|
|
255
|
+
fields: [
|
|
256
|
+
{ id: 'name', label: 'Name', type: 'text' },
|
|
257
|
+
{ id: 'metadata.channel', label: 'Channel', type: 'text' },
|
|
258
|
+
],
|
|
259
|
+
initialValues: { name: 'Retail', metadata: { channel: 'web' } },
|
|
260
|
+
schema: z.object({
|
|
261
|
+
name: z.string(),
|
|
262
|
+
metadata: z.object({ channel: z.string() }).optional(),
|
|
263
|
+
}),
|
|
264
|
+
onSubmit,
|
|
265
|
+
})
|
|
266
|
+
|
|
267
|
+
await waitFor(() => {
|
|
268
|
+
expect(container.querySelector('[data-crud-field-id="metadata.channel"] input')).toBeTruthy()
|
|
269
|
+
})
|
|
270
|
+
const channelInput = container.querySelector('[data-crud-field-id="metadata.channel"] input') as HTMLInputElement
|
|
271
|
+
await waitFor(() => expect(channelInput.value).toBe('web'))
|
|
272
|
+
fireEvent.change(channelInput, { target: { value: 'store' } })
|
|
273
|
+
fireEvent.submit(container.querySelector('form') as HTMLFormElement)
|
|
274
|
+
|
|
275
|
+
await waitFor(() => expect(onSubmit).toHaveBeenCalled())
|
|
276
|
+
expect(onSubmit.mock.calls[0][0]).toEqual({ name: 'Retail', metadata: { channel: 'store' } })
|
|
277
|
+
})
|
|
278
|
+
})
|
|
@@ -5,7 +5,7 @@ jest.setTimeout(15000)
|
|
|
5
5
|
// stable id. Hiding is presentation-only — the fields of a hidden group keep their
|
|
6
6
|
// values and are still submitted, so a smaller form can never silently clear data.
|
|
7
7
|
let injectedGroupWidgets: unknown[] = []
|
|
8
|
-
let injectedFieldWidgets: Array<{ fields: unknown[] }> = []
|
|
8
|
+
let injectedFieldWidgets: Array<{ metadata: { id: string }; fields: unknown[] }> = []
|
|
9
9
|
const fetchCustomFieldFormStructureMock = jest.fn()
|
|
10
10
|
const buildFormFieldFromCustomFieldDefMock = jest.fn()
|
|
11
11
|
|
|
@@ -9,6 +9,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
|
9
9
|
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
10
10
|
import { DataTable } from '../DataTable'
|
|
11
11
|
import { APP_EVENT_DOM_NAME } from '../injection/useAppEvent'
|
|
12
|
+
import { registerComponentOverrides } from '@open-mercato/shared/modules/widgets/component-registry'
|
|
12
13
|
|
|
13
14
|
const mockRouterRefresh = jest.fn()
|
|
14
15
|
|
|
@@ -72,12 +73,41 @@ function renderTable(elementProps: Record<string, unknown>) {
|
|
|
72
73
|
|
|
73
74
|
describe('DataTable extensions', () => {
|
|
74
75
|
beforeEach(() => {
|
|
76
|
+
registerComponentOverrides([])
|
|
75
77
|
;(globalThis as typeof globalThis & { ResizeObserver?: typeof ResizeObserverMock }).ResizeObserver = ResizeObserverMock
|
|
76
78
|
useInjectionDataWidgetsMock.mockImplementation(() => ({ widgets: [], isLoading: false, error: null }))
|
|
77
79
|
flashMock.mockReset()
|
|
78
80
|
mockRouterRefresh.mockReset()
|
|
79
81
|
})
|
|
80
82
|
|
|
83
|
+
afterEach(() => {
|
|
84
|
+
registerComponentOverrides([])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('executes transforms and wrappers at the derived DataTable handle', () => {
|
|
88
|
+
registerComponentOverrides([
|
|
89
|
+
{
|
|
90
|
+
target: { componentId: 'data-table:catalog.products.list' },
|
|
91
|
+
priority: 10,
|
|
92
|
+
propsTransform: (props: Record<string, unknown>) => ({ ...props, title: 'Transformed title' }),
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
target: { componentId: 'data-table:catalog.products.list' },
|
|
96
|
+
priority: 20,
|
|
97
|
+
wrapper: (Original: React.ComponentType<Record<string, unknown>>) => function Wrapped(props) {
|
|
98
|
+
return <section data-testid="registered-table-wrapper"><Original {...props} /></section>
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
])
|
|
102
|
+
renderTable({
|
|
103
|
+
columns: [{ accessorKey: 'name', header: 'Name' }],
|
|
104
|
+
data: [{ id: 'r1', name: 'Product' }],
|
|
105
|
+
extensionTableId: 'catalog.products.list',
|
|
106
|
+
})
|
|
107
|
+
expect(screen.getByTestId('registered-table-wrapper')).toBeInTheDocument()
|
|
108
|
+
expect(screen.getByText('Transformed title')).toBeInTheDocument()
|
|
109
|
+
})
|
|
110
|
+
|
|
81
111
|
it('renders injected columns from data-table extension surface', () => {
|
|
82
112
|
useInjectionDataWidgetsMock.mockImplementation((spotId: string) => {
|
|
83
113
|
if (spotId === 'data-table:customers.people:columns') {
|