@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.
Files changed (85) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/CrudForm.js +19 -6
  3. package/dist/backend/CrudForm.js.map +2 -2
  4. package/dist/backend/DataTable.js +62 -10
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/RowActions.js +54 -5
  7. package/dist/backend/RowActions.js.map +2 -2
  8. package/dist/backend/charts/KpiCard.js +2 -1
  9. package/dist/backend/charts/KpiCard.js.map +2 -2
  10. package/dist/backend/detail/AttachmentsSection.js +17 -5
  11. package/dist/backend/detail/AttachmentsSection.js.map +2 -2
  12. package/dist/backend/detail/NotesSection.js +38 -20
  13. package/dist/backend/detail/NotesSection.js.map +2 -2
  14. package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js +11 -1
  15. package/dist/backend/dev/DevRuntimeDiagnosticsBanner.js.map +2 -2
  16. package/dist/backend/icons/lucideRegistry.generated.js +26 -0
  17. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  18. package/dist/backend/injection/eventBridge.js +33 -0
  19. package/dist/backend/injection/eventBridge.js.map +2 -2
  20. package/dist/backend/inputs/LookupSelect.js +20 -3
  21. package/dist/backend/inputs/LookupSelect.js.map +2 -2
  22. package/dist/backend/inputs/PhoneNumberField.js +1 -1
  23. package/dist/backend/inputs/PhoneNumberField.js.map +2 -2
  24. package/dist/backend/inputs/TagsInput.js +20 -2
  25. package/dist/backend/inputs/TagsInput.js.map +2 -2
  26. package/dist/backend/messages/EmailThreadsPanel.js +2 -1
  27. package/dist/backend/messages/EmailThreadsPanel.js.map +2 -2
  28. package/dist/backend/messages/MessageComposer.js.map +2 -2
  29. package/dist/backend/messages/index.js.map +2 -2
  30. package/dist/backend/messages/message-compose-form-groups.js +24 -0
  31. package/dist/backend/messages/message-compose-form-groups.js.map +2 -2
  32. package/dist/backend/messages/useMessageCompose.js +27 -0
  33. package/dist/backend/messages/useMessageCompose.js.map +2 -2
  34. package/dist/backend/messages/useMessageComposeOperations.js +1 -0
  35. package/dist/backend/messages/useMessageComposeOperations.js.map +2 -2
  36. package/dist/backend/notifications/NotificationItem.js +8 -1
  37. package/dist/backend/notifications/NotificationItem.js.map +2 -2
  38. package/dist/portal/PortalShell.js +2 -2
  39. package/dist/portal/PortalShell.js.map +2 -2
  40. package/dist/primitives/calendar.js +69 -14
  41. package/dist/primitives/calendar.js.map +2 -2
  42. package/package.json +4 -5
  43. package/src/backend/CrudForm.tsx +30 -8
  44. package/src/backend/DataTable.tsx +95 -20
  45. package/src/backend/RowActions.tsx +114 -6
  46. package/src/backend/__tests__/AttachmentsSection.upload.test.tsx +183 -0
  47. package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +139 -2
  48. package/src/backend/__tests__/CrudForm.hiddenGroups.test.tsx +1 -1
  49. package/src/backend/__tests__/DataTable.extensions.test.tsx +30 -0
  50. package/src/backend/__tests__/DataTable.perspectiveReconcile.test.tsx +320 -2
  51. package/src/backend/__tests__/DataTable.stickyResponsive.test.tsx +19 -0
  52. package/src/backend/__tests__/DevRuntimeDiagnosticsBanner.test.tsx +117 -0
  53. package/src/backend/__tests__/NotesSection.test.tsx +138 -0
  54. package/src/backend/__tests__/RowActions.test.tsx +202 -0
  55. package/src/backend/charts/KpiCard.tsx +1 -0
  56. package/src/backend/charts/__tests__/KpiCard.test.tsx +20 -0
  57. package/src/backend/detail/AttachmentsSection.tsx +17 -5
  58. package/src/backend/detail/NotesSection.tsx +122 -66
  59. package/src/backend/dev/DevRuntimeDiagnosticsBanner.tsx +24 -1
  60. package/src/backend/icons/lucideRegistry.generated.tsx +26 -0
  61. package/src/backend/injection/__tests__/eventBridge.test.tsx +47 -1
  62. package/src/backend/injection/eventBridge.ts +35 -0
  63. package/src/backend/inputs/LookupSelect.tsx +32 -3
  64. package/src/backend/inputs/PhoneNumberField.tsx +1 -1
  65. package/src/backend/inputs/TagsInput.tsx +24 -3
  66. package/src/backend/inputs/__tests__/LookupSelect.test.tsx +166 -1
  67. package/src/backend/inputs/__tests__/PhoneNumberField.typeahead.test.tsx +43 -0
  68. package/src/backend/inputs/__tests__/TagsInput.test.tsx +81 -0
  69. package/src/backend/messages/EmailThreadsPanel.tsx +21 -1
  70. package/src/backend/messages/MessageComposer.tsx +1 -0
  71. package/src/backend/messages/__tests__/EmailThreadsPanel.senderLabel.test.tsx +99 -0
  72. package/src/backend/messages/__tests__/MessageComposer.test.tsx +44 -0
  73. package/src/backend/messages/__tests__/useMessageCompose.test.tsx +69 -0
  74. package/src/backend/messages/__tests__/useMessageComposeSender.test.tsx +208 -0
  75. package/src/backend/messages/index.ts +1 -0
  76. package/src/backend/messages/message-compose-form-groups.tsx +41 -0
  77. package/src/backend/messages/message-composer.types.ts +26 -0
  78. package/src/backend/messages/useMessageCompose.ts +46 -0
  79. package/src/backend/messages/useMessageComposeOperations.ts +8 -0
  80. package/src/backend/notifications/NotificationItem.tsx +7 -0
  81. package/src/backend/notifications/__tests__/NotificationItem.customRenderer.test.tsx +139 -0
  82. package/src/portal/PortalShell.tsx +7 -4
  83. package/src/portal/__tests__/PortalShell.test.tsx +29 -1
  84. package/src/primitives/__tests__/calendar.test.tsx +174 -1
  85. 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
- <Link href={portalHome} className="flex items-center gap-2.5 text-foreground transition hover:opacity-80" aria-label={headerTitle}>
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
- </Link>
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
- <Link href={portalHome} className="flex items-center gap-2 text-muted-foreground transition hover:text-foreground">
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
- </Link>
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
  })