@open-mercato/ui 0.9.1-develop.7499.1.fcec456e4e → 0.9.1-develop.7513.1.ae2715553f

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 (33) hide show
  1. package/.turbo/turbo-build.log +1 -1
  2. package/dist/backend/CrudForm.js +56 -36
  3. package/dist/backend/CrudForm.js.map +2 -2
  4. package/dist/backend/DataTable.js +40 -14
  5. package/dist/backend/DataTable.js.map +2 -2
  6. package/dist/backend/columns/ColumnChooserPanel.js +4 -3
  7. package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
  8. package/dist/backend/crud/injectedFieldGroups.js +50 -0
  9. package/dist/backend/crud/injectedFieldGroups.js.map +7 -0
  10. package/dist/backend/filters/FilterFieldPicker.js +3 -2
  11. package/dist/backend/filters/FilterFieldPicker.js.map +2 -2
  12. package/dist/backend/utils/api.js +25 -4
  13. package/dist/backend/utils/api.js.map +2 -2
  14. package/dist/backend/utils/groupLabels.js +16 -0
  15. package/dist/backend/utils/groupLabels.js.map +7 -0
  16. package/dist/theme/QueryProvider.js +7 -1
  17. package/dist/theme/QueryProvider.js.map +2 -2
  18. package/package.json +3 -3
  19. package/src/backend/CrudForm.tsx +68 -45
  20. package/src/backend/DataTable.tsx +54 -14
  21. package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +85 -4
  22. package/src/backend/__tests__/DataTable.injectedColumnPlacement.test.tsx +146 -0
  23. package/src/backend/columns/ColumnChooserPanel.tsx +4 -3
  24. package/src/backend/columns/__tests__/ColumnChooserPanel.groupLabels.test.tsx +72 -0
  25. package/src/backend/crud/__tests__/injectedFieldGroups.test.ts +90 -0
  26. package/src/backend/crud/injectedFieldGroups.ts +75 -0
  27. package/src/backend/filters/FilterFieldPicker.tsx +3 -2
  28. package/src/backend/filters/__tests__/FilterFieldPicker.groupLabels.test.tsx +77 -0
  29. package/src/backend/utils/__tests__/api.test.ts +67 -0
  30. package/src/backend/utils/api.ts +33 -4
  31. package/src/backend/utils/groupLabels.ts +26 -0
  32. package/src/theme/QueryProvider.tsx +7 -1
  33. package/src/theme/__tests__/QueryProvider.i18n.test.tsx +40 -0
@@ -0,0 +1,90 @@
1
+ import type { InjectionFieldDefinition } from '@open-mercato/shared/modules/widgets/injection'
2
+ import { InjectionPosition } from '@open-mercato/shared/modules/widgets/injection-position'
3
+ import type { CrudFormGroup } from '../../CrudForm'
4
+ import {
5
+ INJECTED_FIELDS_FALLBACK_GROUP_ID,
6
+ placeInjectedFieldsInGroups,
7
+ type InjectedFieldWidgetPlacement,
8
+ } from '../injectedFieldGroups'
9
+
10
+ function field(id: string, group?: string, extra: Partial<InjectionFieldDefinition> = {}): InjectionFieldDefinition {
11
+ return { id, label: id, type: 'text', group, ...extra } as InjectionFieldDefinition
12
+ }
13
+
14
+ function fieldIds(group: CrudFormGroup | undefined): string[] {
15
+ return (group?.fields ?? []).map((entry) => (typeof entry === 'string' ? entry : entry.id))
16
+ }
17
+
18
+ const groupPlacement: InjectedFieldWidgetPlacement = {
19
+ kind: 'group',
20
+ column: 2,
21
+ groupLabel: 'wms.widgets.catalog.inventoryProfile.groupLabel',
22
+ groupDescription: 'wms.widgets.catalog.inventoryProfile.groupDescription',
23
+ }
24
+
25
+ describe('placeInjectedFieldsInGroups (#6142)', () => {
26
+ it('inserts a field into the declared group it targets, honoring placement', () => {
27
+ const groups: CrudFormGroup[] = [{ id: 'general', fields: ['title', 'sku'] }]
28
+ const placed = placeInjectedFieldsInGroups(
29
+ groups,
30
+ [field('subtitle', 'general', { placement: { position: InjectionPosition.After, relativeTo: 'title' } })],
31
+ new Map(),
32
+ )
33
+ expect(fieldIds(placed[0])).toEqual(['title', 'subtitle', 'sku'])
34
+ expect(groups[0].fields).toEqual(['title', 'sku'])
35
+ })
36
+
37
+ it('renders fields of an undeclared group-kind injection as their own card', () => {
38
+ const groups: CrudFormGroup[] = [
39
+ { id: 'general', fields: ['title'] },
40
+ { id: 'custom', kind: 'customFields', title: 'Custom attributes' },
41
+ ]
42
+ const definitions = [field('wms.manageInventory', 'wms.inventoryProfile'), field('wms.safetyStock', 'wms.inventoryProfile')]
43
+ const placements = new Map(definitions.map((definition) => [definition.id, groupPlacement]))
44
+
45
+ const placed = placeInjectedFieldsInGroups(groups, definitions, placements)
46
+
47
+ expect(placed.map((group) => group.id)).toEqual(['general', 'custom', 'wms.inventoryProfile'])
48
+ const card = placed[2]
49
+ expect(card.title).toBe(groupPlacement.groupLabel)
50
+ expect(card.description).toBe(groupPlacement.groupDescription)
51
+ expect(card.column).toBe(2)
52
+ expect(fieldIds(card)).toEqual(['wms.manageInventory', 'wms.safetyStock'])
53
+ expect(fieldIds(placed[0])).toEqual(['title'])
54
+ })
55
+
56
+ it('never falls back into a customFields group, which ignores group.fields', () => {
57
+ const fallbacks: string[] = []
58
+ const placed = placeInjectedFieldsInGroups(
59
+ [
60
+ { id: 'general', fields: ['title'] },
61
+ { id: 'custom', kind: 'customFields' },
62
+ ],
63
+ [field('example.priority', 'missing')],
64
+ new Map(),
65
+ (definition) => fallbacks.push(definition.id),
66
+ )
67
+ expect(fieldIds(placed[0])).toEqual(['title', 'example.priority'])
68
+ expect(fieldIds(placed[1])).toEqual([])
69
+ expect(fallbacks).toEqual(['example.priority'])
70
+ })
71
+
72
+ it('synthesizes a host group when no declared group renders plain fields', () => {
73
+ const placed = placeInjectedFieldsInGroups(
74
+ [{ id: 'custom', kind: 'customFields' }],
75
+ [field('example.priority', 'missing')],
76
+ new Map(),
77
+ )
78
+ expect(placed.map((group) => group.id)).toEqual(['custom', INJECTED_FIELDS_FALLBACK_GROUP_ID])
79
+ expect(fieldIds(placed[1])).toEqual(['example.priority'])
80
+ })
81
+
82
+ it('does not duplicate a field the host already declares', () => {
83
+ const placed = placeInjectedFieldsInGroups(
84
+ [{ id: 'general', fields: ['title', 'subtitle'] }],
85
+ [field('subtitle', 'general')],
86
+ new Map(),
87
+ )
88
+ expect(fieldIds(placed[0])).toEqual(['title', 'subtitle'])
89
+ })
90
+ })
@@ -0,0 +1,75 @@
1
+ import type { InjectionFieldDefinition } from '@open-mercato/shared/modules/widgets/injection'
2
+ import { insertByInjectionPlacement } from '@open-mercato/shared/modules/widgets/injection-position'
3
+ import type { CrudFormGroup } from '../CrudForm'
4
+
5
+ export type InjectedFieldWidgetPlacement = {
6
+ kind?: 'tab' | 'group' | 'stack'
7
+ groupLabel?: string
8
+ groupDescription?: string
9
+ column?: 1 | 2
10
+ }
11
+
12
+ export const INJECTED_FIELDS_FALLBACK_GROUP_ID = '__injected-fields__'
13
+
14
+ function hostsPlainFields(group: CrudFormGroup): boolean {
15
+ return group.kind !== 'customFields' && !group.bare
16
+ }
17
+
18
+ function entryId(entry: NonNullable<CrudFormGroup['fields']>[number]): string {
19
+ return typeof entry === 'string' ? entry : entry.id
20
+ }
21
+
22
+ /**
23
+ * Places injected field definitions into the host form's groups.
24
+ *
25
+ * - A field whose `group` matches a declared group is inserted there (honoring `placement`).
26
+ * - A field whose `group` is not declared, injected by a widget mapped with `kind: 'group'`,
27
+ * gets its own card built from the injection-table `groupLabel`/`groupDescription`/`column`.
28
+ * - Any other field falls back to the last group that renders plain fields — never into a
29
+ * `customFields` or `bare` group, which do not render `group.fields` and would drop it.
30
+ * A field that explicitly names a `customFields` or `bare` group is still placed there and,
31
+ * like any non-field entry of such a group, is not rendered.
32
+ */
33
+ export function placeInjectedFieldsInGroups(
34
+ groups: CrudFormGroup[],
35
+ definitions: InjectionFieldDefinition[],
36
+ placementByFieldId: ReadonlyMap<string, InjectedFieldWidgetPlacement | undefined>,
37
+ onFallback?: (definition: InjectionFieldDefinition) => void,
38
+ ): CrudFormGroup[] {
39
+ const placed: CrudFormGroup[] = groups.map((group) => ({ ...group, fields: [...(group.fields ?? [])] }))
40
+ let fallbackIndex = -1
41
+ for (let index = placed.length - 1; index >= 0; index -= 1) {
42
+ if (hostsPlainFields(placed[index])) {
43
+ fallbackIndex = index
44
+ break
45
+ }
46
+ }
47
+
48
+ for (const definition of definitions) {
49
+ let index = definition.group ? placed.findIndex((group) => group.id === definition.group) : -1
50
+ if (index < 0) {
51
+ const placement = placementByFieldId.get(definition.id)
52
+ if (definition.group && placement?.kind === 'group') {
53
+ placed.push({
54
+ id: definition.group,
55
+ title: placement.groupLabel,
56
+ description: placement.groupDescription,
57
+ column: placement.column === 2 ? 2 : 1,
58
+ fields: [],
59
+ })
60
+ index = placed.length - 1
61
+ } else {
62
+ onFallback?.(definition)
63
+ if (fallbackIndex < 0) {
64
+ placed.push({ id: INJECTED_FIELDS_FALLBACK_GROUP_ID, fields: [] })
65
+ fallbackIndex = placed.length - 1
66
+ }
67
+ index = fallbackIndex
68
+ }
69
+ }
70
+ const fieldEntries = placed[index].fields ?? []
71
+ if (fieldEntries.some((entry) => entryId(entry) === definition.id)) continue
72
+ placed[index].fields = insertByInjectionPlacement(fieldEntries, definition.id, definition.placement, entryId)
73
+ }
74
+ return placed
75
+ }
@@ -4,6 +4,7 @@ import * as React from 'react'
4
4
  import { Search, Activity, Calendar, Hash, Tag, ArrowRight, ALargeSmall, UserRound, Mail, Phone, Filter, type LucideIcon } from 'lucide-react'
5
5
  import { Popover, PopoverContent, PopoverAnchor } from '../../primitives/popover'
6
6
  import { useT } from '@open-mercato/shared/lib/i18n/context'
7
+ import { UNGROUPED_GROUP_ID, resolveGroupLabel } from '../utils/groupLabels'
7
8
  import type { FilterFieldDef, FilterFieldType } from '@open-mercato/shared/lib/query/advanced-filter'
8
9
 
9
10
  const TYPE_ICON: Record<FilterFieldType, LucideIcon> = {
@@ -54,7 +55,7 @@ export function FilterFieldPicker({ fields, open, onOpenChange, onSelect, trigge
54
55
  const map = new Map<string, FilterFieldDef[]>()
55
56
  const order: string[] = []
56
57
  for (const f of filtered) {
57
- const g = f.group ?? 'More'
58
+ const g = f.group ?? UNGROUPED_GROUP_ID
58
59
  if (!map.has(g)) { map.set(g, []); order.push(g) }
59
60
  map.get(g)!.push(f)
60
61
  }
@@ -103,7 +104,7 @@ export function FilterFieldPicker({ fields, open, onOpenChange, onSelect, trigge
103
104
  <div className="max-h-[400px] overflow-y-auto py-1" role="listbox">
104
105
  {grouped.map(({ group, items }) => (
105
106
  <div key={group}>
106
- <div className="px-3 pt-2 pb-1 text-overline font-semibold uppercase tracking-widest text-muted-foreground">{group}</div>
107
+ <div className="px-3 pt-2 pb-1 text-overline font-semibold uppercase tracking-widest text-muted-foreground">{resolveGroupLabel(t, group, 'ui.advancedFilter.fieldPicker.ungrouped', 'More')}</div>
107
108
  {items.map((f) => {
108
109
  const flatIdx = flatVisible.indexOf(f)
109
110
  const Icon = resolveIcon(f)
@@ -0,0 +1,77 @@
1
+ // packages/ui/src/backend/filters/__tests__/FilterFieldPicker.groupLabels.test.tsx
2
+ import * as React from 'react'
3
+ import { render, screen } from '@testing-library/react'
4
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
5
+ import type { Locale } from '@open-mercato/shared/lib/i18n/config'
6
+ import { FilterFieldPicker } from '../FilterFieldPicker'
7
+ import type { FilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter'
8
+
9
+ const fields: FilterFieldDef[] = [
10
+ { key: 'owner', label: 'Owner', type: 'text', group: 'customers.columnGroups.crm' },
11
+ { key: 'email', label: 'Email', type: 'text', group: 'Contact' },
12
+ { key: 'ungrouped', label: 'Ungrouped', type: 'text' },
13
+ ]
14
+
15
+ function renderPicker(locale: Locale, dict: Record<string, string>, pickerFields = fields) {
16
+ return render(
17
+ <I18nProvider locale={locale} dict={dict}>
18
+ <FilterFieldPicker fields={pickerFields} open onSelect={() => {}} onOpenChange={() => {}} triggerRef={{ current: null }} />
19
+ </I18nProvider>,
20
+ )
21
+ }
22
+
23
+ describe('FilterFieldPicker group labels', () => {
24
+ it('translates group labels a host registered in its locale file', () => {
25
+ renderPicker('pl', {
26
+ 'customers.columnGroups.crm': 'CRM PL',
27
+ 'ui.advancedFilter.fieldPicker.ungrouped': 'Więcej',
28
+ })
29
+ expect(screen.getByText('CRM PL')).toBeInTheDocument()
30
+ expect(screen.queryByText('customers.columnGroups.crm')).not.toBeInTheDocument()
31
+ })
32
+
33
+ it('renders unrecognized group strings verbatim', () => {
34
+ renderPicker('pl', { 'customers.columnGroups.crm': 'CRM PL' })
35
+ expect(screen.getByText('Contact')).toBeInTheDocument()
36
+ })
37
+
38
+ it('translates the fallback group label for fields without a group', () => {
39
+ renderPicker('pl', { 'ui.advancedFilter.fieldPicker.ungrouped': 'Więcej' })
40
+ expect(screen.getByText('Więcej')).toBeInTheDocument()
41
+ expect(screen.queryByText('More')).not.toBeInTheDocument()
42
+ })
43
+
44
+ it('falls back to the English label when no translation is registered', () => {
45
+ renderPicker('en', {})
46
+ expect(screen.getByText('More')).toBeInTheDocument()
47
+ expect(screen.getByText('Contact')).toBeInTheDocument()
48
+ })
49
+
50
+ it('never resolves tenant-authored group titles as translation keys', () => {
51
+ renderPicker('pl', { 'Deal pipeline': 'Lejek sprzedaży' }, [
52
+ { key: 'stage', label: 'Stage', type: 'text', group: 'Deal pipeline' },
53
+ ])
54
+ expect(screen.getByText('Deal pipeline')).toBeInTheDocument()
55
+ expect(screen.queryByText('Lejek sprzedaży')).not.toBeInTheDocument()
56
+ })
57
+
58
+ it('keeps ungrouped identity locale-independent instead of splitting on the translated label', () => {
59
+ renderPicker('pl', { 'ui.advancedFilter.fieldPicker.ungrouped': 'Więcej' }, [
60
+ { key: 'ungrouped', label: 'Ungrouped', type: 'text' },
61
+ { key: 'more', label: 'More field', type: 'text', group: 'More' },
62
+ ])
63
+ expect(screen.getByText('Więcej')).toBeInTheDocument()
64
+ expect(screen.getByText('More')).toBeInTheDocument()
65
+ })
66
+
67
+ it('keys grouping on the raw group string so two groups sharing a translation stay apart', () => {
68
+ renderPicker('pl', {
69
+ 'customers.columnGroups.crm': 'CRM',
70
+ 'customers.columnGroups.pipeline': 'CRM',
71
+ }, [
72
+ { key: 'owner', label: 'Owner', type: 'text', group: 'customers.columnGroups.crm' },
73
+ { key: 'stage', label: 'Stage', type: 'text', group: 'customers.columnGroups.pipeline' },
74
+ ])
75
+ expect(screen.getAllByText('CRM')).toHaveLength(2)
76
+ })
77
+ })
@@ -10,7 +10,11 @@ import {
10
10
  ForbiddenError,
11
11
  UnauthorizedError,
12
12
  apiFetch,
13
+ notifyForbiddenAccess,
14
+ redirectToSessionRefresh,
15
+ registerAuthNoticeTranslator,
13
16
  } from '../../utils/api'
17
+ import { createTranslator } from '@open-mercato/shared/lib/i18n/translate'
14
18
 
15
19
  function createMockResponse(
16
20
  status: number,
@@ -227,3 +231,66 @@ describe('apiFetch', () => {
227
231
  expect(flash).toHaveBeenCalledTimes(1)
228
232
  })
229
233
  })
234
+
235
+ describe('auth notice banners i18n (GH #7381)', () => {
236
+ const polishTranslator = createTranslator({
237
+ 'ui.auth.sessionExpired': 'Sesja wygasła. Przekierowanie do logowania…',
238
+ 'ui.auth.accessDenied.missingFeatures': 'Odmowa dostępu: brakuje uprawnienia „{features}”.',
239
+ 'ui.auth.accessDenied.requiredRoles': 'Odmowa dostępu: wymagana rola „{roles}”.',
240
+ 'ui.auth.accessDenied.generic': 'Odmowa dostępu.',
241
+ })
242
+ let unregister: (() => void) | null = null
243
+
244
+ beforeEach(() => {
245
+ jest.resetAllMocks()
246
+ jest.useFakeTimers()
247
+ window.history.pushState({}, '', '/backend/customers/people')
248
+ window.sessionStorage.clear()
249
+ })
250
+
251
+ afterEach(() => {
252
+ unregister?.()
253
+ unregister = null
254
+ jest.clearAllTimers()
255
+ jest.useRealTimers()
256
+ })
257
+
258
+ it('translates the session-expired banner with the registered translator', () => {
259
+ unregister = registerAuthNoticeTranslator(polishTranslator)
260
+
261
+ redirectToSessionRefresh()
262
+
263
+ expect(flash).toHaveBeenCalledWith('Sesja wygasła. Przekierowanie do logowania…', 'warning')
264
+ })
265
+
266
+ it('translates the access-denied banners and interpolates features and roles', () => {
267
+ unregister = registerAuthNoticeTranslator(polishTranslator)
268
+
269
+ notifyForbiddenAccess({ requiredFeatures: ['dashboards.view'] })
270
+ notifyForbiddenAccess({ requiredRoles: ['admin'] })
271
+
272
+ expect(flash).toHaveBeenNthCalledWith(1, 'Odmowa dostępu: brakuje uprawnienia „dashboards.view”.', 'warning')
273
+ expect(flash).toHaveBeenNthCalledWith(2, 'Odmowa dostępu: wymagana rola „admin”.', 'warning')
274
+ })
275
+
276
+ it('falls back to English copy once the translator is unregistered', () => {
277
+ registerAuthNoticeTranslator(polishTranslator)()
278
+
279
+ notifyForbiddenAccess({ requiredFeatures: ['dashboards.view'] })
280
+
281
+ expect(flash).toHaveBeenCalledWith(
282
+ 'Access denied: you are missing the required permission "dashboards.view". Contact your administrator.',
283
+ 'warning',
284
+ )
285
+ })
286
+
287
+ it('keeps a newer translator when an older registration is cleaned up', () => {
288
+ const releaseOlder = registerAuthNoticeTranslator(createTranslator({}))
289
+ unregister = registerAuthNoticeTranslator(polishTranslator)
290
+ releaseOlder()
291
+
292
+ redirectToSessionRefresh()
293
+
294
+ expect(flash).toHaveBeenCalledWith('Sesja wygasła. Przekierowanie do logowania…', 'warning')
295
+ })
296
+ })
@@ -8,9 +8,30 @@ import { pushOperation } from '../operations/store'
8
8
  import { pushPartialIndexWarning } from '../indexes/store'
9
9
  import { createScopedHeaderStack } from './scopedHeaderStack'
10
10
  import { createLogger } from '@open-mercato/shared/lib/logger'
11
+ import type { TranslateFn, TranslateParams } from '@open-mercato/shared/lib/i18n/context'
12
+ import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
11
13
 
12
14
  const logger = createLogger('ui').child({ component: 'apiFetch' })
13
15
 
16
+ const fallbackAuthNoticeTranslator: TranslateFn = (key) => key
17
+ let authNoticeTranslator: TranslateFn | null = null
18
+
19
+ /**
20
+ * Register the active i18n translator for the session-expired and
21
+ * access-denied flash banners, which fire outside React. Returns a cleanup
22
+ * that unregisters it unless another translator replaced it meanwhile.
23
+ */
24
+ export function registerAuthNoticeTranslator(translate: TranslateFn): () => void {
25
+ authNoticeTranslator = translate
26
+ return () => {
27
+ if (authNoticeTranslator === translate) authNoticeTranslator = null
28
+ }
29
+ }
30
+
31
+ function translateAuthNotice(key: string, fallback: string, params?: TranslateParams): string {
32
+ return translateWithFallback(authNoticeTranslator ?? fallbackAuthNoticeTranslator, key, fallback, params)
33
+ }
34
+
14
35
  const scopedHeaders = createScopedHeaderStack()
15
36
 
16
37
  function mergeHeaders(base: HeadersInit | undefined, scoped: Record<string, string>): Headers {
@@ -106,7 +127,7 @@ export function redirectToSessionRefresh() {
106
127
  if (recentlyAttemptedSessionRefresh(current)) return
107
128
  recordSessionRefreshAttempt(current)
108
129
  try {
109
- flash('Session expired. Redirecting to sign in…', 'warning')
130
+ flash(translateAuthNotice('ui.auth.sessionExpired', 'Session expired. Redirecting to sign in…'), 'warning')
110
131
  setTimeout(() => {
111
132
  window.location.href = `/api/auth/session/refresh?redirect=${encodeURIComponent(current)}`
112
133
  }, 20)
@@ -144,12 +165,20 @@ function formatForbiddenAccessMessage(options?: { requiredRoles?: string[] | nul
144
165
  const roles = options?.requiredRoles?.filter(Boolean) ?? []
145
166
  const effectiveRoles = roles.length ? roles : DEFAULT_FORBIDDEN_ROLES.filter(Boolean)
146
167
  if (features.length) {
147
- return `Access denied: you are missing the required permission "${features.join(', ')}". Contact your administrator.`
168
+ return translateAuthNotice(
169
+ 'ui.auth.accessDenied.missingFeatures',
170
+ 'Access denied: you are missing the required permission "{features}". Contact your administrator.',
171
+ { features: features.join(', ') },
172
+ )
148
173
  }
149
174
  if (effectiveRoles.length) {
150
- return `Access denied: this area requires the role "${effectiveRoles.join(', ')}". Contact your administrator.`
175
+ return translateAuthNotice(
176
+ 'ui.auth.accessDenied.requiredRoles',
177
+ 'Access denied: this area requires the role "{roles}". Contact your administrator.',
178
+ { roles: effectiveRoles.join(', ') },
179
+ )
151
180
  }
152
- return 'Access denied: you do not have permission to perform this action.'
181
+ return translateAuthNotice('ui.auth.accessDenied.generic', 'Access denied: you do not have permission to perform this action.')
153
182
  }
154
183
 
155
184
  /**
@@ -0,0 +1,26 @@
1
+ // packages/ui/src/backend/utils/groupLabels.ts
2
+
3
+ // Stable, locale-independent identity for the "no declared group" bucket. Grouping and
4
+ // collapse state key on this sentinel so two locales never split one bucket in two.
5
+ export const UNGROUPED_GROUP_ID = '__om_ungrouped__'
6
+
7
+ const TRANSLATION_KEY_PATTERN = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)+$/
8
+
9
+ // Group strings reach these components from two very different places: code literals a
10
+ // module author controls, and free text a tenant types into the custom-field fieldset
11
+ // editor. Only the former may be resolved as a translation key, so tenant data is never
12
+ // silently reinterpreted as a dictionary lookup.
13
+ export function isTranslationKeyShaped(group: string): boolean {
14
+ return TRANSLATION_KEY_PATTERN.test(group)
15
+ }
16
+
17
+ export function resolveGroupLabel(
18
+ translate: (key: string, fallback?: string) => string,
19
+ group: string,
20
+ ungroupedKey: string,
21
+ ungroupedFallback: string,
22
+ ): string {
23
+ if (group === UNGROUPED_GROUP_ID) return translate(ungroupedKey, ungroupedFallback)
24
+ if (!isTranslationKeyShaped(group)) return group
25
+ return translate(group, group)
26
+ }
@@ -2,7 +2,8 @@
2
2
  import * as React from 'react'
3
3
  import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'
4
4
  import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
5
- import { redirectToSessionRefresh, notifyForbiddenAccess, UnauthorizedError, ForbiddenError, apiFetch, setAuthRedirectConfig } from '../backend/utils/api'
5
+ import { useOptionalT } from '@open-mercato/shared/lib/i18n/context'
6
+ import { redirectToSessionRefresh, notifyForbiddenAccess, UnauthorizedError, ForbiddenError, apiFetch, setAuthRedirectConfig, registerAuthNoticeTranslator } from '../backend/utils/api'
6
7
 
7
8
  // Ensure global fetch calls also flow through apiFetch so 401 session-refresh
8
9
  // and 403 access-denied flash banners fire consistently.
@@ -68,6 +69,11 @@ const client = new QueryClient({
68
69
  type QueryProviderProps = { children: React.ReactNode; defaultForbiddenRoles?: string[] }
69
70
 
70
71
  export function QueryProvider({ children, defaultForbiddenRoles }: QueryProviderProps) {
72
+ const t = useOptionalT()
73
+ React.useEffect(() => {
74
+ if (!t) return
75
+ return registerAuthNoticeTranslator(t)
76
+ }, [t])
71
77
  React.useEffect(() => {
72
78
  ensureGlobalFetchInterception()
73
79
  if (defaultForbiddenRoles && defaultForbiddenRoles.length) {
@@ -0,0 +1,40 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ jest.mock('../../backend/FlashMessages', () => ({
5
+ flash: jest.fn(),
6
+ }))
7
+
8
+ import { render } from '@testing-library/react'
9
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
10
+ import { flash } from '../../backend/FlashMessages'
11
+ import { redirectToSessionRefresh } from '../../backend/utils/api'
12
+ import { QueryProvider } from '../QueryProvider'
13
+
14
+ describe('QueryProvider auth notice translator (GH #7381)', () => {
15
+ beforeEach(() => {
16
+ jest.useFakeTimers()
17
+ window.history.pushState({}, '', '/backend/customers/people')
18
+ window.sessionStorage.clear()
19
+ })
20
+
21
+ afterEach(() => {
22
+ jest.clearAllTimers()
23
+ jest.useRealTimers()
24
+ })
25
+
26
+ it('shows the session-expired banner in the active locale', () => {
27
+ const view = render(
28
+ <I18nProvider locale="pl" dict={{ 'ui.auth.sessionExpired': 'Sesja wygasła. Przekierowanie do logowania…' }}>
29
+ <QueryProvider>
30
+ <div />
31
+ </QueryProvider>
32
+ </I18nProvider>,
33
+ )
34
+
35
+ redirectToSessionRefresh()
36
+ view.unmount()
37
+
38
+ expect(flash).toHaveBeenCalledWith('Sesja wygasła. Przekierowanie do logowania…', 'warning')
39
+ })
40
+ })