@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
|
@@ -8,6 +8,7 @@ import { surfaceRecordConflict } from '../conflicts'
|
|
|
8
8
|
import type {
|
|
9
9
|
AttachmentListResponse,
|
|
10
10
|
MessageComposerProps,
|
|
11
|
+
MessageSenderOption,
|
|
11
12
|
MessageTypeItem,
|
|
12
13
|
UserListItem,
|
|
13
14
|
} from './message-composer.types'
|
|
@@ -45,6 +46,20 @@ function toErrorMessage(payload: unknown): string | null {
|
|
|
45
46
|
return null
|
|
46
47
|
}
|
|
47
48
|
|
|
49
|
+
/**
|
|
50
|
+
* Field-level errors a route returns alongside `error`, e.g. the send-as-user
|
|
51
|
+
* facade's 422 for a mailbox that needs reconnecting. Read defensively: the
|
|
52
|
+
* key is optional and any non-string value is dropped rather than rendered.
|
|
53
|
+
*/
|
|
54
|
+
function readFieldErrors(payload: unknown): Record<string, string> | null {
|
|
55
|
+
if (!payload || typeof payload !== 'object') return null
|
|
56
|
+
const raw = (payload as Record<string, unknown>).fieldErrors
|
|
57
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
58
|
+
const entries = Object.entries(raw as Record<string, unknown>)
|
|
59
|
+
.filter((entry): entry is [string, string] => typeof entry[1] === 'string')
|
|
60
|
+
return entries.length ? Object.fromEntries(entries) : null
|
|
61
|
+
}
|
|
62
|
+
|
|
48
63
|
function createTemporaryAttachmentRecordId(): string {
|
|
49
64
|
const randomPart =
|
|
50
65
|
typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
@@ -87,6 +102,9 @@ export type UseMessageComposeResult = {
|
|
|
87
102
|
setVisibility: React.Dispatch<React.SetStateAction<'public' | 'internal'>>
|
|
88
103
|
externalEmail: string
|
|
89
104
|
setExternalEmail: React.Dispatch<React.SetStateAction<string>>
|
|
105
|
+
senderOptions: MessageSenderOption[]
|
|
106
|
+
senderChannelId: string
|
|
107
|
+
setSenderChannelId: React.Dispatch<React.SetStateAction<string>>
|
|
90
108
|
sendViaEmail: boolean
|
|
91
109
|
setSendViaEmail: React.Dispatch<React.SetStateAction<boolean>>
|
|
92
110
|
contextActionRequired: boolean
|
|
@@ -100,6 +118,7 @@ export type UseMessageComposeResult = {
|
|
|
100
118
|
submitting: boolean
|
|
101
119
|
submitMode: 'send' | 'draft'
|
|
102
120
|
submitError: string | null
|
|
121
|
+
submitFieldErrors: Record<string, string> | null
|
|
103
122
|
composerTitle: string
|
|
104
123
|
submitLabel: string
|
|
105
124
|
selectedRecipientOptions: TagsInputOption[]
|
|
@@ -128,6 +147,7 @@ export function useMessageCompose({
|
|
|
128
147
|
contextObject = null,
|
|
129
148
|
requiredActionConfig = null,
|
|
130
149
|
contextPreview = null,
|
|
150
|
+
senderOptions,
|
|
131
151
|
defaultValues,
|
|
132
152
|
expectedUpdatedAt = null,
|
|
133
153
|
onSuccess,
|
|
@@ -159,6 +179,9 @@ export function useMessageCompose({
|
|
|
159
179
|
const [submitting, setSubmitting] = React.useState(false)
|
|
160
180
|
const [submitMode, setSubmitMode] = React.useState<'send' | 'draft'>('send')
|
|
161
181
|
const [submitError, setSubmitError] = React.useState<string | null>(null)
|
|
182
|
+
const [submitFieldErrors, setSubmitFieldErrors] = React.useState<Record<string, string> | null>(null)
|
|
183
|
+
// Empty means the platform sender: the default the composer has always used.
|
|
184
|
+
const [senderChannelId, setSenderChannelId] = React.useState('')
|
|
162
185
|
// Tracks whether the composer is currently in the "open" lifecycle so the init
|
|
163
186
|
// effect below only runs on the closed → open transition, not on every parent
|
|
164
187
|
// re-render that produces a new `defaultValues` / `contextObject` reference
|
|
@@ -473,7 +496,19 @@ export function useMessageCompose({
|
|
|
473
496
|
onCancel?.()
|
|
474
497
|
}, [inline, onCancel, onOpenChange, submitting])
|
|
475
498
|
|
|
499
|
+
// The "Send from" control only exists for a compose addressing an external
|
|
500
|
+
// recipient, so a selection made before the mode changed must not survive into
|
|
501
|
+
// a payload the control is no longer shown for.
|
|
502
|
+
const resolvedSenderOptions = React.useMemo(
|
|
503
|
+
() => (isComposePublicVisibility ? senderOptions ?? [] : []),
|
|
504
|
+
[isComposePublicVisibility, senderOptions],
|
|
505
|
+
)
|
|
506
|
+
const effectiveSenderChannelId = resolvedSenderOptions.some((option) => option.id === senderChannelId)
|
|
507
|
+
? senderChannelId
|
|
508
|
+
: ''
|
|
509
|
+
|
|
476
510
|
const composeSendOperation = useComposeSendOperation({
|
|
511
|
+
senderChannelId: effectiveSenderChannelId,
|
|
477
512
|
t,
|
|
478
513
|
messageType,
|
|
479
514
|
createableMessageTypes,
|
|
@@ -496,6 +531,7 @@ export function useMessageCompose({
|
|
|
496
531
|
|
|
497
532
|
const composeDraftOperation = useComposeDraftOperation({
|
|
498
533
|
t,
|
|
534
|
+
senderChannelId: effectiveSenderChannelId,
|
|
499
535
|
messageId,
|
|
500
536
|
messageType,
|
|
501
537
|
priority,
|
|
@@ -536,6 +572,7 @@ export function useMessageCompose({
|
|
|
536
572
|
|
|
537
573
|
const sendDraftOperation = useSendDraftOperation({
|
|
538
574
|
t,
|
|
575
|
+
senderChannelId: effectiveSenderChannelId,
|
|
539
576
|
messageId: messageId ?? '',
|
|
540
577
|
messageType,
|
|
541
578
|
priority,
|
|
@@ -557,6 +594,7 @@ export function useMessageCompose({
|
|
|
557
594
|
|
|
558
595
|
const updateDraftOperation = useUpdateDraftOperation({
|
|
559
596
|
t,
|
|
597
|
+
senderChannelId: effectiveSenderChannelId,
|
|
560
598
|
messageId: messageId ?? '',
|
|
561
599
|
messageType,
|
|
562
600
|
priority,
|
|
@@ -580,6 +618,7 @@ export function useMessageCompose({
|
|
|
580
618
|
if (submitting) return false
|
|
581
619
|
|
|
582
620
|
setSubmitError(null)
|
|
621
|
+
setSubmitFieldErrors(null)
|
|
583
622
|
|
|
584
623
|
const isEditingExistingDraft = variant === 'compose' && Boolean(messageId)
|
|
585
624
|
const isComposeDraftSubmit = saveAsDraft && variant === 'compose'
|
|
@@ -650,6 +689,7 @@ export function useMessageCompose({
|
|
|
650
689
|
} else {
|
|
651
690
|
const message = toErrorMessage(call.result) ?? t('messages.errors.sendFailed', 'Failed to send message.')
|
|
652
691
|
setSubmitError(message)
|
|
692
|
+
setSubmitFieldErrors(readFieldErrors(call.result))
|
|
653
693
|
flash(message, 'error')
|
|
654
694
|
}
|
|
655
695
|
shouldReturnFalse = true
|
|
@@ -734,6 +774,8 @@ export function useMessageCompose({
|
|
|
734
774
|
}
|
|
735
775
|
|
|
736
776
|
if (event.key === 'Escape') {
|
|
777
|
+
if (event.defaultPrevented) return
|
|
778
|
+
if (event.target instanceof Node && !event.currentTarget.contains(event.target)) return
|
|
737
779
|
event.preventDefault()
|
|
738
780
|
handleCancel()
|
|
739
781
|
}
|
|
@@ -787,6 +829,9 @@ export function useMessageCompose({
|
|
|
787
829
|
setVisibility,
|
|
788
830
|
externalEmail,
|
|
789
831
|
setExternalEmail,
|
|
832
|
+
senderOptions: resolvedSenderOptions,
|
|
833
|
+
senderChannelId: effectiveSenderChannelId,
|
|
834
|
+
setSenderChannelId,
|
|
790
835
|
sendViaEmail,
|
|
791
836
|
setSendViaEmail,
|
|
792
837
|
contextActionRequired,
|
|
@@ -800,6 +845,7 @@ export function useMessageCompose({
|
|
|
800
845
|
submitting,
|
|
801
846
|
submitMode,
|
|
802
847
|
submitError,
|
|
848
|
+
submitFieldErrors,
|
|
803
849
|
composerTitle,
|
|
804
850
|
submitLabel,
|
|
805
851
|
selectedRecipientOptions,
|
|
@@ -39,6 +39,11 @@ type ComposeOperationParams = {
|
|
|
39
39
|
body: string
|
|
40
40
|
bodyFormat: 'text' | 'markdown'
|
|
41
41
|
sendViaEmail: boolean
|
|
42
|
+
/**
|
|
43
|
+
* Chosen alternative sender, or empty for the platform sender. Sent only on a
|
|
44
|
+
* real send: a draft is not delivered, so routing it is meaningless.
|
|
45
|
+
*/
|
|
46
|
+
senderChannelId: string
|
|
42
47
|
contextObject: MessageComposerContextObject | null
|
|
43
48
|
defaultValues: MessageComposerProps['defaultValues']
|
|
44
49
|
contextActionOptions: Array<{ id: string; label: string }>
|
|
@@ -143,6 +148,9 @@ function buildComposePayload(
|
|
|
143
148
|
objects: contextObjects,
|
|
144
149
|
attachmentIds: input.attachmentIds.length > 0 ? input.attachmentIds : undefined,
|
|
145
150
|
sendViaEmail: params.sendViaEmail,
|
|
151
|
+
senderChannelId: !input.isDraft && publicMessage && params.senderChannelId
|
|
152
|
+
? params.senderChannelId
|
|
153
|
+
: undefined,
|
|
146
154
|
isDraft: input.isDraft,
|
|
147
155
|
}
|
|
148
156
|
}
|
|
@@ -108,6 +108,12 @@ export function NotificationItem({
|
|
|
108
108
|
|
|
109
109
|
const handleAction = async (actionId: string, event?: React.MouseEvent) => {
|
|
110
110
|
event?.stopPropagation()
|
|
111
|
+
if (notification.status === 'actioned') {
|
|
112
|
+
const actionHref = notification.actions.find((action) => action.id === actionId)?.href
|
|
113
|
+
const fallbackHref = actionHref ?? notification.linkHref
|
|
114
|
+
if (fallbackHref) router.push(fallbackHref)
|
|
115
|
+
return
|
|
116
|
+
}
|
|
111
117
|
setExecuting(actionId)
|
|
112
118
|
try {
|
|
113
119
|
const result = await onExecuteAction(actionId)
|
|
@@ -127,6 +133,7 @@ export function NotificationItem({
|
|
|
127
133
|
labelKey: action.labelKey ?? action.label,
|
|
128
134
|
variant: action.variant as NotificationTypeAction['variant'],
|
|
129
135
|
icon: action.icon,
|
|
136
|
+
href: action.href,
|
|
130
137
|
}))
|
|
131
138
|
|
|
132
139
|
if (CustomRenderer) {
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react'
|
|
6
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
|
|
7
|
+
import { NotificationItem } from '../NotificationItem'
|
|
8
|
+
import type {
|
|
9
|
+
NotificationDto,
|
|
10
|
+
NotificationRendererProps,
|
|
11
|
+
} from '@open-mercato/shared/modules/notifications/types'
|
|
12
|
+
import type { TranslateFn } from '@open-mercato/shared/lib/i18n/context'
|
|
13
|
+
|
|
14
|
+
const push = jest.fn()
|
|
15
|
+
|
|
16
|
+
jest.mock('next/navigation', () => ({
|
|
17
|
+
useRouter: () => ({
|
|
18
|
+
push,
|
|
19
|
+
replace: jest.fn(),
|
|
20
|
+
prefetch: jest.fn(),
|
|
21
|
+
refresh: jest.fn(),
|
|
22
|
+
back: jest.fn(),
|
|
23
|
+
forward: jest.fn(),
|
|
24
|
+
}),
|
|
25
|
+
}))
|
|
26
|
+
|
|
27
|
+
const t = ((key: string, fallback?: unknown) =>
|
|
28
|
+
typeof fallback === 'string' ? fallback : key) as TranslateFn
|
|
29
|
+
|
|
30
|
+
function ViewRenderer({ onAction, actions = [] }: NotificationRendererProps) {
|
|
31
|
+
return (
|
|
32
|
+
<button type="button" onClick={() => void onAction(actions[0]?.id ?? 'view')}>
|
|
33
|
+
View Quote
|
|
34
|
+
</button>
|
|
35
|
+
)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function TwoActionRenderer({ onAction }: NotificationRendererProps) {
|
|
39
|
+
return (
|
|
40
|
+
<>
|
|
41
|
+
<button type="button" onClick={() => void onAction('view-order')}>
|
|
42
|
+
View order
|
|
43
|
+
</button>
|
|
44
|
+
<button type="button" onClick={() => void onAction('view-inventory')}>
|
|
45
|
+
View inventory
|
|
46
|
+
</button>
|
|
47
|
+
</>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function buildNotification(overrides: Partial<NotificationDto>): NotificationDto {
|
|
52
|
+
return {
|
|
53
|
+
id: 'n-1',
|
|
54
|
+
type: 'sales.quote.created',
|
|
55
|
+
title: 'Quote created',
|
|
56
|
+
severity: 'info',
|
|
57
|
+
status: 'read',
|
|
58
|
+
actions: [{ id: 'view', label: 'View' }],
|
|
59
|
+
linkHref: '/backend/sales/quotes/q-1',
|
|
60
|
+
createdAt: '2026-09-25T00:00:00.000Z',
|
|
61
|
+
...overrides,
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function renderItem(
|
|
66
|
+
notification: NotificationDto,
|
|
67
|
+
onExecuteAction: jest.Mock,
|
|
68
|
+
customRenderer: React.ComponentType<NotificationRendererProps> = ViewRenderer,
|
|
69
|
+
) {
|
|
70
|
+
render(
|
|
71
|
+
<NotificationItem
|
|
72
|
+
notification={notification}
|
|
73
|
+
onMarkAsRead={jest.fn().mockResolvedValue(undefined)}
|
|
74
|
+
onExecuteAction={onExecuteAction}
|
|
75
|
+
onDismiss={jest.fn().mockResolvedValue(undefined)}
|
|
76
|
+
t={t}
|
|
77
|
+
customRenderer={customRenderer}
|
|
78
|
+
/>,
|
|
79
|
+
)
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
describe('NotificationItem custom renderer actions', () => {
|
|
83
|
+
beforeEach(() => {
|
|
84
|
+
push.mockReset()
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('executes the action and follows the returned href for a pending notification', async () => {
|
|
88
|
+
const onExecuteAction = jest.fn().mockResolvedValue({ href: '/backend/sales/quotes/q-1' })
|
|
89
|
+
renderItem(buildNotification({ status: 'read' }), onExecuteAction)
|
|
90
|
+
|
|
91
|
+
fireEvent.click(screen.getByRole('button', { name: 'View Quote' }))
|
|
92
|
+
|
|
93
|
+
await waitFor(() => expect(push).toHaveBeenCalledWith('/backend/sales/quotes/q-1'))
|
|
94
|
+
expect(onExecuteAction).toHaveBeenCalledWith('view')
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('navigates to linkHref without re-executing an already actioned notification', async () => {
|
|
98
|
+
const onExecuteAction = jest.fn().mockResolvedValue({})
|
|
99
|
+
renderItem(buildNotification({ status: 'actioned', actionTaken: 'view' }), onExecuteAction)
|
|
100
|
+
|
|
101
|
+
fireEvent.click(screen.getByRole('button', { name: 'View Quote' }))
|
|
102
|
+
|
|
103
|
+
await waitFor(() => expect(push).toHaveBeenCalledWith('/backend/sales/quotes/q-1'))
|
|
104
|
+
expect(onExecuteAction).not.toHaveBeenCalled()
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does nothing for an actioned notification without a link', async () => {
|
|
108
|
+
const onExecuteAction = jest.fn().mockResolvedValue({})
|
|
109
|
+
renderItem(buildNotification({ status: 'actioned', linkHref: null }), onExecuteAction)
|
|
110
|
+
|
|
111
|
+
fireEvent.click(screen.getByRole('button', { name: 'View Quote' }))
|
|
112
|
+
|
|
113
|
+
await waitFor(() => expect(onExecuteAction).not.toHaveBeenCalled())
|
|
114
|
+
expect(push).not.toHaveBeenCalled()
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('navigates to the clicked action href, not linkHref, on a multi-action actioned notification', async () => {
|
|
118
|
+
const onExecuteAction = jest.fn().mockResolvedValue({})
|
|
119
|
+
renderItem(
|
|
120
|
+
buildNotification({
|
|
121
|
+
status: 'actioned',
|
|
122
|
+
actionTaken: 'view-order',
|
|
123
|
+
actions: [
|
|
124
|
+
{ id: 'view-order', label: 'View order', href: '/backend/sales/orders/o-1' },
|
|
125
|
+
{ id: 'view-inventory', label: 'View inventory', href: '/backend/wms/inventory' },
|
|
126
|
+
],
|
|
127
|
+
linkHref: '/backend/sales/orders/o-1',
|
|
128
|
+
}),
|
|
129
|
+
onExecuteAction,
|
|
130
|
+
TwoActionRenderer,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
fireEvent.click(screen.getByRole('button', { name: 'View inventory' }))
|
|
134
|
+
|
|
135
|
+
await waitFor(() => expect(push).toHaveBeenCalledWith('/backend/wms/inventory'))
|
|
136
|
+
expect(push).not.toHaveBeenCalledWith('/backend/sales/orders/o-1')
|
|
137
|
+
expect(onExecuteAction).not.toHaveBeenCalled()
|
|
138
|
+
})
|
|
139
|
+
})
|
|
@@ -270,10 +270,13 @@ export function PortalShell({
|
|
|
270
270
|
<div className="flex min-h-svh flex-col bg-background" data-portal-handle={PORTAL_SHELL_HANDLE}>
|
|
271
271
|
<header className="sticky top-0 z-sticky border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80" data-portal-handle={PORTAL_HEADER_HANDLE}>
|
|
272
272
|
<div className="mx-auto flex h-16 w-full max-w-screen-lg items-center justify-between px-6">
|
|
273
|
-
|
|
273
|
+
{/* A plain anchor, not a client-side Link: for a signed-in customer on a public
|
|
274
|
+
auth route the root is across the public/authenticated boundary, and only a
|
|
275
|
+
full page load re-runs the layout that hands the session back (#6362). */}
|
|
276
|
+
<a href={portalHome} className="flex items-center gap-2.5 text-foreground transition hover:opacity-80" aria-label={headerTitle}>
|
|
274
277
|
<Image src={logo?.src ?? "/open-mercato.svg"} alt={logo?.alt ?? ""} width={28} height={28} className="" priority />
|
|
275
278
|
<span className="text-base font-semibold tracking-tight">{headerTitle}</span>
|
|
276
|
-
</
|
|
279
|
+
</a>
|
|
277
280
|
<nav aria-label="Primary" className="flex items-center gap-1">
|
|
278
281
|
<Button asChild variant="ghost" size="sm" className="text-sm">
|
|
279
282
|
<Link href={loginHref}>{t('portal.nav.login', 'Log In')}</Link>
|
|
@@ -293,10 +296,10 @@ export function PortalShell({
|
|
|
293
296
|
|
|
294
297
|
<footer className="border-t" data-portal-handle={PORTAL_FOOTER_HANDLE}>
|
|
295
298
|
<div className="mx-auto flex w-full max-w-screen-lg items-center justify-between px-6 py-6">
|
|
296
|
-
<
|
|
299
|
+
<a href={portalHome} className="flex items-center gap-2 text-muted-foreground transition hover:text-foreground">
|
|
297
300
|
<Image src={logo?.src ?? "/open-mercato.svg"} alt={logo?.alt ?? ""} width={20} height={20} className="" />
|
|
298
301
|
<span className="text-sm font-medium text-foreground">{headerTitle}</span>
|
|
299
|
-
</
|
|
302
|
+
</a>
|
|
300
303
|
<p className="text-xs text-muted-foreground/60">
|
|
301
304
|
{t('portal.footer.copyright', '\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}
|
|
302
305
|
</p>
|
|
@@ -31,7 +31,7 @@ jest.mock('@open-mercato/shared/lib/i18n/context', () => ({
|
|
|
31
31
|
jest.mock('next/link', () => {
|
|
32
32
|
const React = require('react')
|
|
33
33
|
return React.forwardRef(({ children, href, ...rest }: any, ref: React.ForwardedRef<HTMLAnchorElement>) => (
|
|
34
|
-
<a href={typeof href === 'string' ? href : href?.toString?.()} ref={ref} {...rest}>
|
|
34
|
+
<a href={typeof href === 'string' ? href : href?.toString?.()} ref={ref} data-client-link="true" {...rest}>
|
|
35
35
|
{children}
|
|
36
36
|
</a>
|
|
37
37
|
))
|
|
@@ -264,6 +264,34 @@ describe('PortalShell', () => {
|
|
|
264
264
|
})
|
|
265
265
|
})
|
|
266
266
|
|
|
267
|
+
// The (frontend) layout sits above [...slug] and does not re-run on a client-side
|
|
268
|
+
// navigation, so a signed-in customer hopping from a public auth route (session
|
|
269
|
+
// withheld) to the portal root would keep an empty context and see the logged-out
|
|
270
|
+
// landing page. The public chrome's home links must be full page loads (#6362).
|
|
271
|
+
it('links the public chrome back to the portal root with a full page load', () => {
|
|
272
|
+
render(
|
|
273
|
+
<PortalLayoutShell
|
|
274
|
+
orgSlug="acme"
|
|
275
|
+
organizationName="Acme"
|
|
276
|
+
tenantId="tenant-1"
|
|
277
|
+
organizationId="org-1"
|
|
278
|
+
authenticated={false}
|
|
279
|
+
userName={null}
|
|
280
|
+
userEmail={null}
|
|
281
|
+
customerAuth={null}
|
|
282
|
+
>
|
|
283
|
+
<div>Login form</div>
|
|
284
|
+
</PortalLayoutShell>,
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
const homeLinks = screen.getAllByRole('link').filter((link) => link.getAttribute('href') === '/acme/portal')
|
|
288
|
+
expect(homeLinks).toHaveLength(2)
|
|
289
|
+
for (const link of homeLinks) {
|
|
290
|
+
expect(link).not.toHaveAttribute('data-client-link')
|
|
291
|
+
}
|
|
292
|
+
expect(screen.getByRole('link', { name: 'Log In' })).toHaveAttribute('data-client-link', 'true')
|
|
293
|
+
})
|
|
294
|
+
|
|
267
295
|
it('renders the authenticated chrome for the props the layout passes on a signed-in route', async () => {
|
|
268
296
|
apiCallMock.mockResolvedValue({ ok: true, status: 200, result: { ok: true, groups: [] } })
|
|
269
297
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import * as React from 'react'
|
|
2
|
-
import { fireEvent, render, screen } from '@testing-library/react'
|
|
2
|
+
import { fireEvent, render, screen, within } from '@testing-library/react'
|
|
3
|
+
import userEvent from '@testing-library/user-event'
|
|
3
4
|
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
4
5
|
import { Calendar, CalendarMonthSelector } from '../calendar'
|
|
6
|
+
import { DatePicker } from '../date-picker'
|
|
5
7
|
|
|
6
8
|
describe('Calendar source sizes and month selector', () => {
|
|
7
9
|
it('preserves 36px days by default and supports optional 40px days', () => {
|
|
@@ -31,4 +33,175 @@ describe('Calendar source sizes and month selector', () => {
|
|
|
31
33
|
fireEvent.click(previous)
|
|
32
34
|
expect(onPreviousMonth).not.toHaveBeenCalled()
|
|
33
35
|
})
|
|
36
|
+
|
|
37
|
+
it('translates the caption and month grid accessible names from the app locale', () => {
|
|
38
|
+
const dict = {
|
|
39
|
+
'ui.calendar.goToPreviousMonth': 'Poprzedni: {month}',
|
|
40
|
+
'ui.calendar.goToNextMonth': 'Następny: {month}',
|
|
41
|
+
'ui.calendar.openMonthYearNavigation': '{month} – otwórz nawigację',
|
|
42
|
+
'ui.calendar.selectMonthAndYear': 'Wybierz miesiąc i rok',
|
|
43
|
+
'ui.calendar.goToPreviousYear': 'Poprzedni rok: {year}',
|
|
44
|
+
'ui.calendar.goToNextYear': 'Następny rok: {year}',
|
|
45
|
+
'ui.calendar.backToDaySelection': '{year} – powrót do dni',
|
|
46
|
+
}
|
|
47
|
+
render(<I18nProvider locale="pl" dict={dict}><Calendar mode="single" defaultMonth={new Date(2026, 5, 1)} /></I18nProvider>)
|
|
48
|
+
expect(screen.getByRole('button', { name: 'Poprzedni: May 2026' })).toBeInTheDocument()
|
|
49
|
+
expect(screen.getByRole('button', { name: 'Następny: July 2026' })).toBeInTheDocument()
|
|
50
|
+
fireEvent.click(screen.getByRole('button', { name: 'June 2026 – otwórz nawigację' }))
|
|
51
|
+
expect(screen.getByRole('dialog', { name: 'Wybierz miesiąc i rok' })).toBeInTheDocument()
|
|
52
|
+
expect(screen.getByRole('button', { name: 'Poprzedni rok: 2025' })).toBeInTheDocument()
|
|
53
|
+
expect(screen.getByRole('button', { name: 'Następny rok: 2027' })).toBeInTheDocument()
|
|
54
|
+
expect(screen.getByRole('button', { name: '2026 – powrót do dni' })).toBeInTheDocument()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('keeps the English accessible names without an I18nProvider', () => {
|
|
58
|
+
render(<Calendar mode="single" defaultMonth={new Date(2026, 5, 1)} />)
|
|
59
|
+
expect(screen.getByRole('button', { name: 'Go to previous month: May 2026' })).toBeInTheDocument()
|
|
60
|
+
fireEvent.click(screen.getByRole('button', { name: 'June 2026 – open month and year navigation' }))
|
|
61
|
+
expect(screen.getByRole('dialog', { name: 'Select month and year' })).toBeInTheDocument()
|
|
62
|
+
expect(screen.getByRole('button', { name: 'Go to previous year: 2025' })).toBeInTheDocument()
|
|
63
|
+
expect(screen.getByRole('button', { name: 'Go to next year: 2027' })).toBeInTheDocument()
|
|
64
|
+
expect(screen.getByRole('button', { name: '2026 – back to day selection' })).toBeInTheDocument()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('honors caller-supplied labels for the visible month arrows', () => {
|
|
68
|
+
const labelPrevious = jest.fn((month?: Date) => `Back to ${month?.getMonth()}`)
|
|
69
|
+
const labelNext = jest.fn((month?: Date) => `Forward to ${month?.getMonth()}`)
|
|
70
|
+
render(<I18nProvider locale="en" dict={{}}><Calendar mode="single" defaultMonth={new Date(2026, 5, 1)} labels={{ labelPrevious, labelNext }} /></I18nProvider>)
|
|
71
|
+
fireEvent.click(screen.getByRole('button', { name: 'Forward to 6' }))
|
|
72
|
+
expect(screen.getByRole('button', { name: 'Back to 5' })).toBeInTheDocument()
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it('does not render the hidden built-in navigation with its untranslated names', () => {
|
|
76
|
+
render(<I18nProvider locale="pl" dict={{}}><Calendar mode="single" defaultMonth={new Date(2026, 5, 1)} /></I18nProvider>)
|
|
77
|
+
expect(screen.queryByRole('navigation')).not.toBeInTheDocument()
|
|
78
|
+
expect(screen.queryByRole('button', { name: /Go to the (Previous|Next) Month/ })).not.toBeInTheDocument()
|
|
79
|
+
})
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
describe('Calendar month grid keyboard navigation', () => {
|
|
83
|
+
it('focuses the displayed month and makes the covered day view inactive', async () => {
|
|
84
|
+
const user = userEvent.setup()
|
|
85
|
+
render(<Calendar mode="single" defaultMonth={new Date(2026, 5, 1)} />)
|
|
86
|
+
const day = screen.getByRole('button', { name: /Friday, June 12/ })
|
|
87
|
+
const caption = screen.getByRole('button', { name: /June 2026 – open/ })
|
|
88
|
+
caption.focus()
|
|
89
|
+
|
|
90
|
+
await user.keyboard('{Enter}')
|
|
91
|
+
|
|
92
|
+
const grid = screen.getByRole('dialog', { name: 'Select month and year' })
|
|
93
|
+
expect(within(grid).getByRole('button', { name: 'Jun' })).toHaveFocus()
|
|
94
|
+
expect(day.closest('[aria-hidden="true"]')).toHaveAttribute('inert')
|
|
95
|
+
expect(day.closest('[aria-hidden="true"]')).toHaveClass('invisible')
|
|
96
|
+
await user.tab()
|
|
97
|
+
await user.tab()
|
|
98
|
+
expect(within(grid).getByRole('button', { name: 'Aug' })).toHaveFocus()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('cycles Tab and Shift+Tab through every grid control', async () => {
|
|
102
|
+
const user = userEvent.setup()
|
|
103
|
+
render(<Calendar mode="single" defaultMonth={new Date(2026, 5, 1)} />)
|
|
104
|
+
await user.click(screen.getByRole('button', { name: /June 2026 – open/ }))
|
|
105
|
+
const grid = screen.getByRole('dialog')
|
|
106
|
+
const previousYear = within(grid).getByRole('button', { name: 'Go to previous year: 2025' })
|
|
107
|
+
const december = within(grid).getByRole('button', { name: 'Dec' })
|
|
108
|
+
december.focus()
|
|
109
|
+
|
|
110
|
+
await user.tab()
|
|
111
|
+
expect(previousYear).toHaveFocus()
|
|
112
|
+
await user.tab({ shift: true })
|
|
113
|
+
expect(december).toHaveFocus()
|
|
114
|
+
|
|
115
|
+
for (const shift of [false, true]) {
|
|
116
|
+
for (let tabIndex = 0; tabIndex < within(grid).getAllByRole('button').length; tabIndex++) {
|
|
117
|
+
await user.tab({ shift })
|
|
118
|
+
expect(grid).toContainElement(document.activeElement as HTMLElement)
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
it('restores caption focus after Escape without changing the date or month', async () => {
|
|
124
|
+
const user = userEvent.setup()
|
|
125
|
+
const onSelect = jest.fn()
|
|
126
|
+
const onMonthChange = jest.fn()
|
|
127
|
+
render(<Calendar mode="single" selected={new Date(2026, 5, 12)} defaultMonth={new Date(2026, 5, 1)} onSelect={onSelect} onMonthChange={onMonthChange} />)
|
|
128
|
+
await user.click(screen.getByRole('button', { name: /June 2026 – open/ }))
|
|
129
|
+
await user.click(screen.getByRole('button', { name: 'Go to next year: 2027' }))
|
|
130
|
+
expect(screen.getByRole('button', { name: 'Go to next year: 2028' })).toHaveFocus()
|
|
131
|
+
|
|
132
|
+
await user.keyboard('{Escape}')
|
|
133
|
+
|
|
134
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
135
|
+
expect(screen.getByRole('button', { name: /June 2026 – open/ })).toHaveFocus()
|
|
136
|
+
expect(screen.getByRole('button', { name: /Friday, June 12/ })).toBeVisible()
|
|
137
|
+
expect(onSelect).not.toHaveBeenCalled()
|
|
138
|
+
expect(onMonthChange).not.toHaveBeenCalled()
|
|
139
|
+
})
|
|
140
|
+
|
|
141
|
+
it('returns to the day view when the year button is activated', async () => {
|
|
142
|
+
const user = userEvent.setup()
|
|
143
|
+
render(<Calendar mode="single" defaultMonth={new Date(2026, 5, 1)} />)
|
|
144
|
+
await user.click(screen.getByRole('button', { name: /June 2026 – open/ }))
|
|
145
|
+
await user.click(screen.getByRole('button', { name: '2026 – back to day selection' }))
|
|
146
|
+
|
|
147
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
148
|
+
const caption = screen.getByRole('button', { name: /June 2026 – open/ })
|
|
149
|
+
expect(caption).toHaveFocus()
|
|
150
|
+
expect(caption.closest('[inert]')).toBeNull()
|
|
151
|
+
})
|
|
152
|
+
|
|
153
|
+
it('restores focus to the new caption after selecting a different month and year', async () => {
|
|
154
|
+
const user = userEvent.setup()
|
|
155
|
+
const onMonthChange = jest.fn()
|
|
156
|
+
const onSelect = jest.fn()
|
|
157
|
+
render(<React.StrictMode><Calendar mode="single" defaultMonth={new Date(2026, 5, 1)} onMonthChange={onMonthChange} onSelect={onSelect} /></React.StrictMode>)
|
|
158
|
+
await user.click(screen.getByRole('button', { name: /June 2026 – open/ }))
|
|
159
|
+
await user.click(screen.getByRole('button', { name: 'Go to next year: 2027' }))
|
|
160
|
+
expect(screen.getByRole('button', { name: 'Go to next year: 2028' })).toHaveFocus()
|
|
161
|
+
await user.click(screen.getByRole('button', { name: 'Jul' }))
|
|
162
|
+
|
|
163
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
164
|
+
expect(screen.getByRole('button', { name: /July 2027 – open/ })).toHaveFocus()
|
|
165
|
+
expect(onMonthChange).toHaveBeenCalledWith(new Date(2027, 6, 1))
|
|
166
|
+
expect(onSelect).not.toHaveBeenCalled()
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
it('keeps the date picker open when Escape dismisses the nested month grid', async () => {
|
|
170
|
+
const user = userEvent.setup()
|
|
171
|
+
render(<I18nProvider locale="en" dict={{}}><DatePicker value={new Date(2026, 5, 12)} onChange={jest.fn()} aria-label="Closing date" /></I18nProvider>)
|
|
172
|
+
await user.click(screen.getByRole('button', { name: 'Closing date' }))
|
|
173
|
+
await user.click(screen.getByRole('button', { name: /June 2026 – open/ }))
|
|
174
|
+
await user.keyboard('{Escape}')
|
|
175
|
+
|
|
176
|
+
expect(screen.queryByRole('dialog', { name: 'Select month and year' })).not.toBeInTheDocument()
|
|
177
|
+
expect(screen.getByRole('button', { name: /June 2026 – open/ })).toHaveFocus()
|
|
178
|
+
expect(screen.getByRole('button', { name: 'Apply' })).toBeVisible()
|
|
179
|
+
|
|
180
|
+
await user.keyboard('{Escape}')
|
|
181
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
182
|
+
expect(screen.getByRole('button', { name: 'Closing date' })).toHaveFocus()
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
it('removes the Escape listener when the calendar unmounts with its grid open', async () => {
|
|
186
|
+
const user = userEvent.setup()
|
|
187
|
+
const { unmount } = render(<Calendar mode="single" defaultMonth={new Date(2026, 5, 1)} />)
|
|
188
|
+
await user.click(screen.getByRole('button', { name: /June 2026 – open/ }))
|
|
189
|
+
unmount()
|
|
190
|
+
render(<button type="button">Outside calendar</button>)
|
|
191
|
+
const outsideButton = screen.getByRole('button', { name: 'Outside calendar' })
|
|
192
|
+
outsideButton.focus()
|
|
193
|
+
const escape = new KeyboardEvent('keydown', { key: 'Escape', bubbles: true, cancelable: true })
|
|
194
|
+
|
|
195
|
+
expect(outsideButton.dispatchEvent(escape)).toBe(true)
|
|
196
|
+
expect(outsideButton).toHaveFocus()
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
it('keeps multi-month calendars in the day view', () => {
|
|
200
|
+
render(<Calendar mode="range" defaultMonth={new Date(2026, 5, 1)} numberOfMonths={2} />)
|
|
201
|
+
|
|
202
|
+
expect(screen.queryByRole('button', { name: /open month and year/ })).not.toBeInTheDocument()
|
|
203
|
+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument()
|
|
204
|
+
expect(screen.getByRole('button', { name: /Friday, June 12/ })).toBeVisible()
|
|
205
|
+
expect(screen.getByRole('button', { name: /Friday, June 12/ }).closest('[inert]')).toBeNull()
|
|
206
|
+
})
|
|
34
207
|
})
|