@open-mercato/ui 0.6.6 → 0.6.7-develop.6553.1.2ec7246e04

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 (29) hide show
  1. package/AGENTS.md +1 -1
  2. package/dist/ai/ChatPaneTabs.js +2 -2
  3. package/dist/ai/ChatPaneTabs.js.map +2 -2
  4. package/dist/backend/AppShell.js +2 -2
  5. package/dist/backend/AppShell.js.map +1 -1
  6. package/dist/backend/custom-fields/FieldDefinitionsEditor.js +37 -20
  7. package/dist/backend/custom-fields/FieldDefinitionsEditor.js.map +2 -2
  8. package/dist/backend/detail/AttachmentMetadataDialog.js +9 -17
  9. package/dist/backend/detail/AttachmentMetadataDialog.js.map +2 -2
  10. package/dist/backend/filters/AdvancedFilterBuilder.js +1 -1
  11. package/dist/backend/filters/AdvancedFilterBuilder.js.map +1 -1
  12. package/dist/backend/messages/useMessageCompose.js +1 -0
  13. package/dist/backend/messages/useMessageCompose.js.map +2 -2
  14. package/dist/primitives/drawer.js +6 -3
  15. package/dist/primitives/drawer.js.map +2 -2
  16. package/dist/primitives/tabs.js +2 -1
  17. package/dist/primitives/tabs.js.map +2 -2
  18. package/package.json +5 -4
  19. package/src/ai/ChatPaneTabs.tsx +2 -2
  20. package/src/backend/AppShell.tsx +2 -2
  21. package/src/backend/__tests__/DataTable.extensions.test.tsx +26 -10
  22. package/src/backend/custom-fields/FieldDefinitionsEditor.tsx +52 -24
  23. package/src/backend/detail/AttachmentMetadataDialog.tsx +16 -23
  24. package/src/backend/filters/AdvancedFilterBuilder.tsx +1 -1
  25. package/src/backend/messages/__tests__/useMessageCompose.test.tsx +60 -0
  26. package/src/backend/messages/useMessageCompose.ts +4 -0
  27. package/src/primitives/__tests__/drawer.test.tsx +2 -1
  28. package/src/primitives/drawer.tsx +4 -3
  29. package/src/primitives/tabs.tsx +6 -1
@@ -15,7 +15,7 @@ import {
15
15
  SelectValue,
16
16
  } from '../../primitives/select'
17
17
  import { CUSTOM_FIELD_KINDS } from '@open-mercato/shared/modules/entities/kinds'
18
- import { useOptionalT } from '@open-mercato/shared/lib/i18n/context'
18
+ import { useOptionalT, type TranslateFn } from '@open-mercato/shared/lib/i18n/context'
19
19
  import { FieldRegistry } from '../fields/registry'
20
20
  import { slugify } from '@open-mercato/shared/lib/slugify'
21
21
  import { useConfirmDialog } from '../confirm-dialog'
@@ -35,6 +35,16 @@ import {
35
35
  type FieldsetGroup = { code: string; title?: string; hint?: string }
36
36
  type FieldsetConfig = { code: string; label: string; icon?: string; description?: string; groups?: FieldsetGroup[] }
37
37
 
38
+ function formatFallback(template: string, params?: Record<string, string | number>) {
39
+ if (!params) return template
40
+ return template.replace(/\{\{(\w+)\}\}|\{(\w+)\}/g, (match, doubleKey, singleKey) => {
41
+ const key = doubleKey ?? singleKey
42
+ if (!key) return match
43
+ const value = params[key]
44
+ return value === undefined ? match : String(value)
45
+ })
46
+ }
47
+
38
48
  export type FieldDefinition = {
39
49
  key: string
40
50
  kind: string
@@ -68,7 +78,7 @@ export type FieldDefinitionsEditorProps = {
68
78
  listProps?: React.HTMLAttributes<HTMLDivElement>
69
79
  singleFieldsetPerRecord?: boolean
70
80
  onSingleFieldsetPerRecordChange?: (value: boolean) => void
71
- translate?: (key: string, fallback: string) => string
81
+ translate?: TranslateFn
72
82
  }
73
83
 
74
84
  const DEFAULT_VALUE_NONE = '__open_mercato_no_default__'
@@ -180,11 +190,13 @@ export function FieldDefinitionsEditor({
180
190
  const dragIndex = React.useRef<number | null>(null)
181
191
  const hasFieldsets = fieldsets.length > 0
182
192
  const contextT = useOptionalT()
183
- const t = React.useCallback(
184
- (key: string, fallback: string) => {
185
- if (translate) return translate(key, fallback)
186
- if (contextT) return contextT(key, fallback)
187
- return fallback
193
+ const t = React.useCallback<TranslateFn>(
194
+ (key, fallbackOrParams, params) => {
195
+ if (translate) return translate(key, fallbackOrParams, params)
196
+ if (contextT) return contextT(key, fallbackOrParams, params)
197
+ return typeof fallbackOrParams === 'string'
198
+ ? formatFallback(fallbackOrParams, params)
199
+ : key
188
200
  },
189
201
  [translate, contextT],
190
202
  )
@@ -264,7 +276,9 @@ export function FieldDefinitionsEditor({
264
276
  if (!onFieldsetsChange) return
265
277
  if (!resolvedActiveFieldset) return
266
278
  const confirmed = await confirm({
267
- title: t('entities.customFields.editor.deleteFieldsetTitle', 'Delete fieldset "{code}"?').replace('{code}', resolvedActiveFieldset),
279
+ title: t('entities.customFields.editor.deleteFieldsetTitle', 'Delete fieldset "{code}"?', {
280
+ code: resolvedActiveFieldset,
281
+ }),
268
282
  text: t('entities.customFields.editor.deleteFieldsetText', 'This will move its fields to Unassigned.'),
269
283
  variant: 'destructive',
270
284
  })
@@ -541,7 +555,7 @@ type FieldDefinitionCardProps = {
541
555
  onRegisterGroup?: (fieldsetCode: string, group: FieldsetGroup) => void
542
556
  onRemoveGroup?: (fieldsetCode: string, groupCode: string) => void
543
557
  onTranslate?: () => void
544
- translate?: (key: string, fallback: string) => string
558
+ translate?: TranslateFn
545
559
  }
546
560
 
547
561
  const FieldDefinitionCard = React.memo(function FieldDefinitionCard({
@@ -573,7 +587,15 @@ const FieldDefinitionCard = React.memo(function FieldDefinitionCard({
573
587
  () => (typeof local.configJson?.fieldset === 'string' ? local.configJson.fieldset : ''),
574
588
  [local.configJson?.fieldset],
575
589
  )
576
- const t = React.useCallback((key: string, fallback: string) => (translate ? translate(key, fallback) : fallback), [translate])
590
+ const t = React.useCallback<TranslateFn>(
591
+ (key, fallbackOrParams, params) => {
592
+ if (translate) return translate(key, fallbackOrParams, params)
593
+ return typeof fallbackOrParams === 'string'
594
+ ? formatFallback(fallbackOrParams, params)
595
+ : key
596
+ },
597
+ [translate],
598
+ )
577
599
  React.useEffect(() => {
578
600
  localRef.current = definition
579
601
  setLocal(definition)
@@ -1093,23 +1115,29 @@ const FieldDefinitionCard = React.memo(function FieldDefinitionCard({
1093
1115
  <SelectValue />
1094
1116
  </SelectTrigger>
1095
1117
  <SelectContent>
1096
- <SelectItem value="required">required</SelectItem>
1097
- <SelectItem value="date">date</SelectItem>
1098
- <SelectItem value="integer">integer</SelectItem>
1099
- <SelectItem value="float">float</SelectItem>
1100
- <SelectItem value="lt">lt</SelectItem>
1101
- <SelectItem value="lte">lte</SelectItem>
1102
- <SelectItem value="gt">gt</SelectItem>
1103
- <SelectItem value="gte">gte</SelectItem>
1104
- <SelectItem value="eq">eq</SelectItem>
1105
- <SelectItem value="ne">ne</SelectItem>
1106
- <SelectItem value="regex">regex</SelectItem>
1118
+ <SelectItem value="required">{t('entities.customFields.editor.validation.required', 'required')}</SelectItem>
1119
+ <SelectItem value="date">{t('entities.customFields.editor.validation.date', 'date')}</SelectItem>
1120
+ <SelectItem value="integer">{t('entities.customFields.editor.validation.integer', 'integer')}</SelectItem>
1121
+ <SelectItem value="float">{t('entities.customFields.editor.validation.float', 'float')}</SelectItem>
1122
+ <SelectItem value="lt">{t('entities.customFields.editor.validation.lt', 'lt')}</SelectItem>
1123
+ <SelectItem value="lte">{t('entities.customFields.editor.validation.lte', 'lte')}</SelectItem>
1124
+ <SelectItem value="gt">{t('entities.customFields.editor.validation.gt', 'gt')}</SelectItem>
1125
+ <SelectItem value="gte">{t('entities.customFields.editor.validation.gte', 'gte')}</SelectItem>
1126
+ <SelectItem value="eq">{t('entities.customFields.editor.validation.eq', 'eq')}</SelectItem>
1127
+ <SelectItem value="ne">{t('entities.customFields.editor.validation.ne', 'ne')}</SelectItem>
1128
+ <SelectItem value="regex">{t('entities.customFields.editor.validation.regex', 'regex')}</SelectItem>
1107
1129
  </SelectContent>
1108
1130
  </Select>
1109
1131
  </div>
1110
1132
  <div className="md:col-span-4">
1111
1133
  <Input
1112
- placeholder={rule?.rule === 'regex' ? t('entities.customFields.editor.rulePatternPlaceholder', 'Pattern (e.g. ^[a-z]+$)') : (['lt','lte','gt','gte'].includes(rule?.rule) ? t('entities.customFields.editor.ruleNumberPlaceholder', 'Number') : '—')}
1134
+ placeholder={
1135
+ rule?.rule === 'regex'
1136
+ ? t('entities.customFields.editor.rulePatternPlaceholder', 'Pattern (e.g. ^[a-z]+$)')
1137
+ : (['lt','lte','gt','gte'].includes(rule?.rule)
1138
+ ? t('entities.customFields.editor.ruleNumberPlaceholder', 'Number')
1139
+ : t('entities.customFields.editor.noParameterPlaceholder', '—'))
1140
+ }
1113
1141
  value={rule?.param ?? ''}
1114
1142
  onChange={(event) => {
1115
1143
  const value = ['lt','lte','gt','gte'].includes(rule?.rule) ? Number(event.target.value) : event.target.value
@@ -1426,7 +1454,7 @@ const FieldDefinitionCard = React.memo(function FieldDefinitionCard({
1426
1454
  size="xs"
1427
1455
  className="text-muted-foreground hover:text-foreground"
1428
1456
  onClick={() => handleEditGroupEntry(group)}
1429
- aria-label={t('entities.customFields.editor.editGroupAria', 'Edit {code}').replace('{code}', group.code)}
1457
+ aria-label={t('entities.customFields.editor.editGroupAria', 'Edit {code}', { code: group.code })}
1430
1458
  >
1431
1459
  <Pencil className="h-4 w-4" />
1432
1460
  </IconButton>
@@ -1435,7 +1463,7 @@ const FieldDefinitionCard = React.memo(function FieldDefinitionCard({
1435
1463
  size="xs"
1436
1464
  className="text-destructive hover:text-destructive/80"
1437
1465
  onClick={() => handleRemoveGroupEntry(group.code)}
1438
- aria-label={t('entities.customFields.editor.deleteGroupAria', 'Delete {code}').replace('{code}', group.code)}
1466
+ aria-label={t('entities.customFields.editor.deleteGroupAria', 'Delete {code}', { code: group.code })}
1439
1467
  >
1440
1468
  <Trash2 className="h-4 w-4" />
1441
1469
  </IconButton>
@@ -6,6 +6,7 @@ import { Button } from '../../primitives/button'
6
6
  import { IconButton } from '../../primitives/icon-button'
7
7
  import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@open-mercato/ui/primitives/dialog'
8
8
  import { Input } from '@open-mercato/ui/primitives/input'
9
+ import { Tabs, TabsList, TabsTrigger } from '@open-mercato/ui/primitives/tabs'
9
10
  import { TagsInput } from '@open-mercato/ui/backend/inputs/TagsInput'
10
11
  import { CrudForm, type CrudField, type CrudFormGroup } from '@open-mercato/ui/backend/CrudForm'
11
12
  import { collectCustomFieldValues } from '@open-mercato/ui/backend/utils/customFieldValues'
@@ -576,29 +577,21 @@ export function AttachmentMetadataDialog({ open, onOpenChange, item, availableTa
576
577
  </div>
577
578
  {isImage ? (
578
579
  <div className="rounded border">
579
- <div className="flex flex-wrap gap-4 border-b px-3 py-2 text-sm font-medium" role="tablist">
580
- {(['preview', 'resize'] as const).map((tab) => (
581
- <Button
582
- key={tab}
583
- type="button"
584
- variant="ghost"
585
- size="sm"
586
- role="tab"
587
- aria-selected={imageTab === tab}
588
- onClick={() => setImageTab(tab)}
589
- className={cn(
590
- 'h-auto -mb-px rounded-none border-b-2 border-transparent px-0 py-1',
591
- imageTab === tab
592
- ? 'border-accent-indigo text-foreground'
593
- : 'text-muted-foreground hover:text-foreground',
594
- )}
595
- >
596
- {tab === 'preview'
597
- ? t('attachments.library.metadata.preview', 'Preview')
598
- : t('attachments.library.metadata.resizeTool.title', 'Generate resized URL')}
599
- </Button>
600
- ))}
601
- </div>
580
+ <Tabs
581
+ value={imageTab}
582
+ onValueChange={(value) => setImageTab(value as 'preview' | 'resize')}
583
+ variant="underline"
584
+ >
585
+ <TabsList className="w-full flex-wrap px-3">
586
+ {(['preview', 'resize'] as const).map((tab) => (
587
+ <TabsTrigger key={tab} value={tab}>
588
+ {tab === 'preview'
589
+ ? t('attachments.library.metadata.preview', 'Preview')
590
+ : t('attachments.library.metadata.resizeTool.title', 'Generate resized URL')}
591
+ </TabsTrigger>
592
+ ))}
593
+ </TabsList>
594
+ </Tabs>
602
595
  <div className="space-y-3 p-3">
603
596
  {imageTab === 'preview' ? (
604
597
  previewUrl ? (
@@ -332,7 +332,7 @@ function AndOrToggle({
332
332
  const andLabel = t('ui.advancedFilter.combinator.and', 'And')
333
333
  const orLabel = t('ui.advancedFilter.combinator.or', 'Or')
334
334
  const baseBtn = 'h-8 px-3 text-sm font-medium outline-none focus-visible:shadow-focus disabled:cursor-not-allowed'
335
- const selBtn = 'bg-brand-violet text-white hover:bg-brand-violet/90'
335
+ const selBtn = 'bg-brand-violet text-brand-violet-foreground hover:bg-brand-violet/90'
336
336
  const unselBtn = 'bg-background text-foreground hover:bg-accent'
337
337
  return (
338
338
  <div
@@ -0,0 +1,60 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+
5
+ import * as React from 'react'
6
+ import { renderHook } from '@testing-library/react'
7
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
8
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
9
+ import { useMessageCompose } from '../useMessageCompose'
10
+ import { apiCall } from '../../utils/apiCall'
11
+
12
+ jest.mock('../../utils/apiCall', () => ({
13
+ apiCall: jest.fn(),
14
+ }))
15
+
16
+ jest.mock('../../FlashMessages', () => ({
17
+ flash: jest.fn(),
18
+ }))
19
+
20
+ function createWrapper() {
21
+ const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } })
22
+ return function Wrapper({ children }: { children: React.ReactNode }) {
23
+ return (
24
+ <QueryClientProvider client={queryClient}>
25
+ {/* @ts-expect-error shared provider accepts a loose dict shape */}
26
+ <I18nProvider locale="en" dict={{}}>
27
+ {children}
28
+ </I18nProvider>
29
+ </QueryClientProvider>
30
+ )
31
+ }
32
+ }
33
+
34
+ describe('useMessageCompose recipient suggestions', () => {
35
+ beforeEach(() => {
36
+ jest.resetAllMocks()
37
+ ;(apiCall as jest.Mock).mockResolvedValue({
38
+ ok: true,
39
+ status: 200,
40
+ result: { items: [] },
41
+ response: { status: 200 },
42
+ })
43
+ })
44
+
45
+ it('scopes recipient suggestions to the composer active organization', async () => {
46
+ const { result } = renderHook(() => useMessageCompose({ variant: 'compose' }), {
47
+ wrapper: createWrapper(),
48
+ })
49
+
50
+ await result.current.loadRecipientSuggestions()
51
+
52
+ const authUsersCall = (apiCall as jest.Mock).mock.calls.find(
53
+ ([url]) => typeof url === 'string' && url.startsWith('/api/auth/users'),
54
+ )
55
+ expect(authUsersCall).toBeDefined()
56
+
57
+ const requestedUrl = new URL(`http://localhost${authUsersCall[0]}`)
58
+ expect(requestedUrl.searchParams.get('scopeToActiveOrganization')).toBe('1')
59
+ })
60
+ })
@@ -413,6 +413,10 @@ export function useMessageCompose({
413
413
  const params = new URLSearchParams()
414
414
  params.set('page', '1')
415
415
  params.set('pageSize', '100')
416
+ // Scope suggestions to the composer's active organization: a message is stamped with
417
+ // that org and its detail endpoint denies cross-org recipients, so a recipient from
418
+ // another org could never open what they were sent.
419
+ params.set('scopeToActiveOrganization', '1')
416
420
  // Recipient lookup is filtered in TagsInput because incremental auth user search is unreliable.
417
421
 
418
422
  const call = await apiCall<{ items?: UserListItem[] }>(
@@ -86,7 +86,8 @@ describe('Drawer', () => {
86
86
  const content = document.querySelector('[data-slot="drawer-content"]') as HTMLElement
87
87
  expect(content.className).toContain('right-0')
88
88
  expect(content.className).toContain('inset-y-0')
89
- expect(content.className).toContain('max-w-md')
89
+ // Figma Drawer [1.1] width contract: 400px
90
+ expect(content.className).toContain('max-w-[400px]')
90
91
  // Per Figma: inner edge rounded, no panel border on the seam.
91
92
  expect(content.className).toContain('rounded-l-xl')
92
93
  expect(content.className).not.toMatch(/\bborder-l\b/)
@@ -94,10 +94,11 @@ const drawerContentVariants = cva(
94
94
  variants: {
95
95
  side: {
96
96
  right:
97
- 'inset-y-0 right-0 h-full w-full max-w-md rounded-l-xl ' +
97
+ // Figma Drawer [1.1] width: 400px (not the Tailwind max-w-md 448px)
98
+ 'inset-y-0 right-0 h-full w-full max-w-[400px] rounded-l-xl ' +
98
99
  'data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right',
99
100
  left:
100
- 'inset-y-0 left-0 h-full w-full max-w-md rounded-r-xl ' +
101
+ 'inset-y-0 left-0 h-full w-full max-w-[400px] rounded-r-xl ' +
101
102
  'data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left',
102
103
  top:
103
104
  'inset-x-0 top-0 max-h-[80vh] w-full rounded-b-xl ' +
@@ -303,7 +304,7 @@ const DrawerTitle = React.forwardRef<
303
304
  <DialogPrimitive.Title
304
305
  ref={ref}
305
306
  data-slot="drawer-title"
306
- className={cn('text-base font-semibold leading-tight text-foreground', className)}
307
+ className={cn('text-lg font-semibold leading-tight text-foreground', className)}
307
308
  {...props}
308
309
  />
309
310
  ))
@@ -108,9 +108,13 @@ export function Tabs({
108
108
  export type TabsListProps = {
109
109
  children: React.ReactNode
110
110
  className?: string
111
+ /** Accessible name for the `role="tablist"` element. Set it when the
112
+ * surrounding context does not already label the strip (e.g. a bare
113
+ * detail-page section switcher). */
114
+ 'aria-label'?: string
111
115
  }
112
116
 
113
- export function TabsList({ children, className }: TabsListProps) {
117
+ export function TabsList({ children, className, 'aria-label': ariaLabel }: TabsListProps) {
114
118
  const { variant, orientation } = useTabsContext()
115
119
 
116
120
  const baseClasses =
@@ -130,6 +134,7 @@ export function TabsList({ children, className }: TabsListProps) {
130
134
  data-slot="tabs-list"
131
135
  className={cn(baseClasses, className)}
132
136
  role="tablist"
137
+ aria-label={ariaLabel}
133
138
  aria-orientation={orientation}
134
139
  >
135
140
  {children}