@open-mercato/ui 0.7.1-develop.7186.1.6e080a5017 → 0.7.1-develop.7193.1.910a5b0a1e

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.
@@ -44,7 +44,7 @@ import { parseISO } from 'date-fns/parseISO'
44
44
  import { formatDisplayDateTime } from '../primitives/date-format'
45
45
  import { cn } from '@open-mercato/shared/lib/utils'
46
46
  import { readVersionedPreference, writeVersionedPreference, clearVersionedPreference } from '@open-mercato/shared/lib/browser/versionedPreference'
47
- import { useT, useLocale } from '@open-mercato/shared/lib/i18n/context'
47
+ import { useT, useLocale, type TranslateFn } from '@open-mercato/shared/lib/i18n/context'
48
48
  import { flash } from './FlashMessages'
49
49
  import { useConfirmDialog } from './confirm-dialog'
50
50
  import { surfaceRecordConflict } from './conflicts'
@@ -510,9 +510,16 @@ function collectUniqueById<T extends { id: string }>(
510
510
  return Array.from(byId.values())
511
511
  }
512
512
 
513
+ const DEFAULT_VIEW_EXPORT_TITLE = 'Export what you view'
514
+ const DEFAULT_FULL_EXPORT_TITLE = 'Full data export'
515
+
513
516
  type ResolvedExportSection = {
514
517
  key: string
515
518
  title: string
519
+ // Seeds the default download filename. Kept separate from `title` because
520
+ // `defaultExportFilename` strips every non-ASCII character, so a translated
521
+ // title would collapse to underscores in locales like ko.
522
+ filenameBase: string
516
523
  description?: string
517
524
  formats: DataTableExportFormat[]
518
525
  getUrl?: (format: DataTableExportFormat) => string
@@ -521,13 +528,20 @@ type ResolvedExportSection = {
521
528
  disabled: boolean
522
529
  }
523
530
 
524
- function resolveExportSections(config: DataTableExportConfig | null | undefined): ResolvedExportSection[] {
531
+ function resolveExportSections(config: DataTableExportConfig | null | undefined, t: TranslateFn): ResolvedExportSection[] {
525
532
  if (!config) return []
526
533
  const sections: ResolvedExportSection[] = []
527
534
  const baseFormats = config.formats && config.formats.length > 0 ? config.formats : DEFAULT_EXPORT_FORMATS
528
- const addSection = (key: string, section: DataTableExportSectionConfig | undefined | null, fallbackTitle: string) => {
535
+ const addSection = (
536
+ key: string,
537
+ section: DataTableExportSectionConfig | undefined | null,
538
+ fallbackTitle: string,
539
+ fallbackFilenameBase: string,
540
+ ) => {
529
541
  if (!section || (!section.getUrl && !section.prepare)) return
530
- const title = section.title?.trim().length ? section.title!.trim() : fallbackTitle
542
+ const explicitTitle = section.title?.trim().length ? section.title!.trim() : null
543
+ const title = explicitTitle ?? fallbackTitle
544
+ const filenameBase = explicitTitle ?? fallbackFilenameBase
531
545
  const seen = new Set<DataTableExportFormat>()
532
546
  const formatsSource = section.formats && section.formats.length > 0 ? section.formats : baseFormats
533
547
  const formats = formatsSource.filter((format) => {
@@ -539,6 +553,7 @@ function resolveExportSections(config: DataTableExportConfig | null | undefined)
539
553
  sections.push({
540
554
  key,
541
555
  title,
556
+ filenameBase,
542
557
  description: section.description,
543
558
  formats,
544
559
  getUrl: section.getUrl,
@@ -557,19 +572,21 @@ function resolveExportSections(config: DataTableExportConfig | null | undefined)
557
572
 
558
573
  // Allow legacy config (getUrl without sections/view)
559
574
  const hasExplicitSections = Array.isArray(config.sections) && config.sections.length > 0
575
+ const viewTitle = t('ui.dataTable.export.viewTitle', DEFAULT_VIEW_EXPORT_TITLE)
560
576
  if (!config.view && !config.full && !hasExplicitSections && config.getUrl) {
561
- addSection('view', { getUrl: config.getUrl, formats: config.formats }, 'Export what you view')
577
+ addSection('view', { getUrl: config.getUrl, formats: config.formats }, viewTitle, DEFAULT_VIEW_EXPORT_TITLE)
562
578
  } else {
563
- addSection('view', config.view, 'Export what you view')
579
+ addSection('view', config.view, viewTitle, DEFAULT_VIEW_EXPORT_TITLE)
564
580
  }
565
581
 
566
582
  if (hasExplicitSections) {
567
583
  config.sections!.forEach((section, idx) => {
568
- addSection(`section-${idx}`, section, section.title?.trim().length ? section.title! : `Export ${idx + 1}`)
584
+ const numberedTitle = t('ui.dataTable.export.sectionTitle', 'Export {index}', { index: idx + 1 })
585
+ addSection(`section-${idx}`, section, numberedTitle, `Export ${idx + 1}`)
569
586
  })
570
587
  }
571
588
 
572
- addSection('full', config.full, 'Full data export')
589
+ addSection('full', config.full, t('ui.dataTable.export.fullTitle', DEFAULT_FULL_EXPORT_TITLE), DEFAULT_FULL_EXPORT_TITLE)
573
590
  return sections
574
591
  }
575
592
 
@@ -872,7 +889,7 @@ function ExportMenu({ config, sections }: { config: DataTableExportConfig; secti
872
889
  preparedResult.filename
873
890
  ?? section.filename?.(format)
874
891
  ?? config.filename?.(format)
875
- ?? defaultExportFilename(section.title, format)
892
+ ?? defaultExportFilename(section.filenameBase, format)
876
893
  if (typeof window !== 'undefined') {
877
894
  const blob = new Blob([serialized.body], { type: serialized.contentType })
878
895
  const href = URL.createObjectURL(blob)
@@ -1341,6 +1358,14 @@ export function DataTable<T extends RowData>({
1341
1358
  // hydration mismatch. Initial render uses only props-derived state (identical on both sides).
1342
1359
  const initialSnapshotRef = React.useRef<PerspectiveSnapshot | null>(null)
1343
1360
  const snapshotHydratedTableRef = React.useRef<string | null>(null)
1361
+ // Tracks the table whose locally-restored state has already been reconciled
1362
+ // against the server response, so reconciliation happens once per table
1363
+ // rather than on every refetch (#5113).
1364
+ const serverReconciledTableRef = React.useRef<string | null>(null)
1365
+ // The snapshot exactly as it came out of localStorage. `initialSnapshotRef`
1366
+ // cannot serve here: applying a snapshot rewrites the stored copy with a
1367
+ // fresh `Date.now()`, which would make every server row look older than it is.
1368
+ const hydratedSnapshotRef = React.useRef<PerspectiveSnapshot | null>(null)
1344
1369
  const initialSettingsSource = perspectiveConfig?.initialState?.initialSettings ?? null
1345
1370
  // Memoized on the host's own object: `sanitizePerspectiveSettings` returns a
1346
1371
  // fresh result on every call, so without this every effect keyed on the
@@ -2042,6 +2067,7 @@ export function DataTable<T extends RowData>({
2042
2067
  const snapshot = readPerspectiveSnapshot(perspectiveTableId)
2043
2068
  if (!snapshot) return
2044
2069
  initialSnapshotRef.current = snapshot
2070
+ hydratedSnapshotRef.current = snapshot
2045
2071
  // When the host page wired an advanced-filter tree (`advancedFilter.onApplyTree`),
2046
2072
  // the host owns filter persistence — typically by hydrating from / writing to the
2047
2073
  // URL (see CRM People/Companies/Deals lazy useState initializers + URL writer
@@ -2579,18 +2605,74 @@ export function DataTable<T extends RowData>({
2579
2605
  React.useLayoutEffect(() => {
2580
2606
  if (!canUsePerspectives) return
2581
2607
  if (!perspectiveTableId) return
2582
- if (initialSnapshotRef.current) return
2583
- if (initialPerspectiveAppliedRef.current) return
2584
2608
 
2585
2609
  const source = perspectiveData ?? perspectiveConfig?.initialState?.response
2586
2610
  if (!source) return
2587
2611
 
2612
+ let orphanedSnapshotDropped = false
2613
+
2588
2614
  const tryResolve = (id: string | null | undefined): PerspectiveDto | RolePerspectiveDto | undefined => {
2589
2615
  if (!id) return undefined
2590
2616
  return source.perspectives.find((p) => p.id === id)
2591
2617
  ?? source.rolePerspectives.find((p) => p.id === id)
2592
2618
  }
2593
2619
 
2620
+ // Whatever was painted at mount — the server's initial settings or the
2621
+ // localStorage snapshot — is a paint-flash optimisation, not a source of
2622
+ // truth. Reconcile it against the server response exactly once per table:
2623
+ // the guard used to be permanent, which pinned a browser to a stale layout
2624
+ // (or to a perspective deleted elsewhere) for the lifetime of its
2625
+ // localStorage entry (#5113). One-shot matters as much as reconciling at
2626
+ // all — a later refetch must not clobber edits made after mount.
2627
+ if (initialSnapshotRef.current || initialPerspectiveAppliedRef.current) {
2628
+ if (serverReconciledTableRef.current === perspectiveTableId) return
2629
+ serverReconciledTableRef.current = perspectiveTableId
2630
+ // A snapshot only speaks for the active perspective: once the user has
2631
+ // picked a different view, reconciling the mount-time one would undo that
2632
+ // choice, so fall back to identity-only resolution.
2633
+ const hydrated = hydratedSnapshotRef.current
2634
+ const snapshot = hydrated && (!activePerspectiveId || activePerspectiveId === hydrated.perspectiveId)
2635
+ ? hydrated
2636
+ : null
2637
+ const localId = snapshot?.perspectiveId ?? activePerspectiveId
2638
+ // "No view" and the widths-only snapshot (#1835) carry no perspective;
2639
+ // resolving a server default over them would override an explicit choice.
2640
+ if (!localId) return
2641
+ const local = tryResolve(localId)
2642
+ if (local) {
2643
+ const serverUpdatedAt = local.updatedAt ? Date.parse(local.updatedAt) : NaN
2644
+ const serverIsNewer = snapshot != null
2645
+ && Number.isFinite(serverUpdatedAt)
2646
+ && serverUpdatedAt > snapshot.updatedAt
2647
+ // `snapshot.updatedAt` is a browser clock reading and `local.updatedAt`
2648
+ // a database one, so clock skew alone must never re-apply a view — the
2649
+ // settings have to differ materially too.
2650
+ const settingsDiffer = snapshot != null && diffPerspectiveSettings(
2651
+ sanitizePerspectiveSettings(local.settings) ?? {},
2652
+ sanitizePerspectiveSettings(snapshot.settings) ?? {},
2653
+ ).length > 0
2654
+ if (serverIsNewer && settingsDiffer) {
2655
+ // Reconciliation is a background correction the user did not ask for,
2656
+ // so it follows the mount-time restore rather than an explicit view
2657
+ // selection: on a host that owns filter persistence through the URL,
2658
+ // it must not overwrite the filter currently on screen.
2659
+ applyPerspectiveSettings(local.settings, local.id, {
2660
+ preserveAdvancedFilter: !!advancedFilter?.onApplyTree,
2661
+ })
2662
+ initialPerspectiveAppliedRef.current = true
2663
+ }
2664
+ return
2665
+ }
2666
+ // The snapshot points at a perspective that no longer exists — deleted,
2667
+ // unshared, or reassigned in another session. Drop it and fall through to
2668
+ // normal resolution instead of staying pinned to orphaned settings.
2669
+ writePerspectiveSnapshot(perspectiveTableId, null)
2670
+ initialSnapshotRef.current = null
2671
+ hydratedSnapshotRef.current = null
2672
+ initialPerspectiveAppliedRef.current = false
2673
+ orphanedSnapshotDropped = true
2674
+ }
2675
+
2594
2676
  let target: PerspectiveDto | RolePerspectiveDto | undefined
2595
2677
  if (activePerspectiveId) {
2596
2678
  target = tryResolve(activePerspectiveId)
@@ -2607,10 +2689,31 @@ export function DataTable<T extends RowData>({
2607
2689
  target = source.perspectives[0]
2608
2690
  }
2609
2691
  if (target) {
2610
- applyPerspectiveSettings(target.settings, target.id)
2692
+ // Falling through to normal resolution after `orphanedSnapshotDropped`
2693
+ // is the same background correction handled below when nothing is left
2694
+ // at all — the active view was deleted, unshared, or reassigned in
2695
+ // another session — so it must not clobber a host-owned advanced filter
2696
+ // either. A fresh mount with no snapshot keeps applying normally.
2697
+ applyPerspectiveSettings(
2698
+ target.settings,
2699
+ target.id,
2700
+ orphanedSnapshotDropped ? { preserveAdvancedFilter: !!advancedFilter?.onApplyTree } : undefined,
2701
+ )
2702
+ } else if (orphanedSnapshotDropped) {
2703
+ // Nothing is left to fall back to — the deleted view was the only one. The
2704
+ // orphaned columns/sorting/search are still painted from the mount-time
2705
+ // restore and `activePerspectiveId` still names a row the server no longer
2706
+ // has, so clear explicitly rather than leaving a dead view on screen until
2707
+ // the next reload (#5113). Like the reconciling apply above, this is a
2708
+ // background correction the user never asked for — the view was deleted in
2709
+ // another session — so it must not clear the filter a host that owns
2710
+ // filter persistence through the URL currently has on screen.
2711
+ applyPerspectiveSettings({}, null, {
2712
+ preserveAdvancedFilter: !!advancedFilter?.onApplyTree,
2713
+ })
2611
2714
  }
2612
2715
  initialPerspectiveAppliedRef.current = true
2613
- }, [canUsePerspectives, perspectiveData, perspectiveTableId, perspectiveConfig, applyPerspectiveSettings, activePerspectiveId])
2716
+ }, [canUsePerspectives, perspectiveData, perspectiveTableId, perspectiveConfig, applyPerspectiveSettings, activePerspectiveId, advancedFilter?.onApplyTree])
2614
2717
 
2615
2718
  const scrollTableIntoView = React.useCallback(() => {
2616
2719
  const rect = containerRef.current?.getBoundingClientRect()
@@ -3214,7 +3317,7 @@ export function DataTable<T extends RowData>({
3214
3317
  const hasActions = actions !== undefined && actions !== null && actions !== false
3215
3318
  const shouldReserveActionsSpace = actions === null || actions === false
3216
3319
  const exportConfig = exporter === false ? null : exporter || null
3217
- const resolvedExportSections = React.useMemo(() => resolveExportSections(exportConfig), [exportConfig])
3320
+ const resolvedExportSections = React.useMemo(() => resolveExportSections(exportConfig, t), [exportConfig, t])
3218
3321
  const hasExport = resolvedExportSections.length > 0
3219
3322
  const refreshButtonConfig = refreshButton
3220
3323
  const hasRefreshButton = Boolean(refreshButtonConfig)
@@ -99,6 +99,7 @@ export function PerspectiveSidebar({
99
99
  const [pendingCloneBaselineIds, setPendingCloneBaselineIds] = React.useState<Set<string> | null>(null)
100
100
 
101
101
  const autosaveRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
102
+ const pendingAutosaveActionRef = React.useRef<(() => void) | null>(null)
102
103
  const isDefaultUserChangeRef = React.useRef(false)
103
104
  const isDefaultRef = React.useRef(isDefault)
104
105
  isDefaultRef.current = isDefault
@@ -109,41 +110,93 @@ export function PerspectiveSidebar({
109
110
  const perspectivesRef = React.useRef(perspectives)
110
111
  perspectivesRef.current = perspectives
111
112
 
113
+ const rolePerspectivesRef = React.useRef(rolePerspectives)
114
+ rolePerspectivesRef.current = rolePerspectives
115
+
116
+ const activePerspectiveIdRef = React.useRef(activePerspectiveId)
117
+ activePerspectiveIdRef.current = activePerspectiveId
118
+
112
119
  const flushAutosave = React.useCallback(() => {
113
120
  if (autosaveRef.current) {
114
121
  clearTimeout(autosaveRef.current)
115
122
  autosaveRef.current = null
116
123
  }
124
+ pendingAutosaveActionRef.current = null
125
+ }, [])
126
+
127
+ // Closing the panel or unmounting inside the debounce window must not drop
128
+ // a pending autosave save or shared-view warning in silence — that is the
129
+ // same discarded-edit bug (#5113) the debounce itself was added to fix.
130
+ // Run whatever is pending immediately instead of just cancelling it.
131
+ const runPendingAutosave = React.useCallback(() => {
132
+ if (!autosaveRef.current) return
133
+ clearTimeout(autosaveRef.current)
134
+ autosaveRef.current = null
135
+ const pending = pendingAutosaveActionRef.current
136
+ pendingAutosaveActionRef.current = null
137
+ pending?.()
117
138
  }, [])
118
139
 
119
140
  const scheduleAutosave = React.useCallback(() => {
120
141
  if (!activePerspectiveId || mode.type === 'new') return
121
142
  const activePersonal = perspectivesRef.current.find((p) => p.id === activePerspectiveId)
122
- if (!activePersonal) return
143
+ if (!activePersonal) {
144
+ // Role perspectives live in their own collection and are shared, so they
145
+ // are never autosaved into. Say so instead of dropping the edit in
146
+ // silence — the old early return made a discarded change look saved
147
+ // (#5113). The timer is reused purely to debounce the hint across a burst
148
+ // of column toggles.
149
+ const activeRole = rolePerspectivesRef.current.find((p) => p.id === activePerspectiveId)
150
+ if (!activeRole) return
151
+ flushAutosave()
152
+ const warnedId = activeRole.id
153
+ const emitWarning = () => {
154
+ autosaveRef.current = null
155
+ pendingAutosaveActionRef.current = null
156
+ // Switching away within the debounce window makes the warning obsolete —
157
+ // it would name a shared view the user is no longer editing.
158
+ if (activePerspectiveIdRef.current !== warnedId) return
159
+ flash(
160
+ t(
161
+ 'ui.perspectives.autosave.sharedView',
162
+ 'Shared views do not save automatically. Use Clone to keep these changes in your own view.',
163
+ ),
164
+ 'warning',
165
+ )
166
+ }
167
+ pendingAutosaveActionRef.current = emitWarning
168
+ autosaveRef.current = setTimeout(emitWarning, 400)
169
+ return
170
+ }
123
171
  flushAutosave()
124
172
  const targetId = activePersonal.id
125
173
  const targetName = activePersonal.name
126
- autosaveRef.current = setTimeout(async () => {
174
+ const runSave = () => {
127
175
  autosaveRef.current = null
128
- try {
129
- await onSaveRef.current({
130
- name: targetName,
131
- isDefault: isDefaultRef.current,
132
- applyToRoles: [],
133
- setRoleDefault: false,
134
- perspectiveId: targetId,
135
- })
136
- flash(t('ui.perspectives.autosave.success', 'View saved'), 'success')
137
- } catch (err: unknown) {
138
- if (surfaceRecordConflict(err, t)) return
139
- flash(t('ui.perspectives.autosave.error', 'Failed to save view'), 'error')
140
- }
141
- }, 400)
176
+ pendingAutosaveActionRef.current = null
177
+ void (async () => {
178
+ try {
179
+ await onSaveRef.current({
180
+ name: targetName,
181
+ isDefault: isDefaultRef.current,
182
+ applyToRoles: [],
183
+ setRoleDefault: false,
184
+ perspectiveId: targetId,
185
+ })
186
+ flash(t('ui.perspectives.autosave.success', 'View saved'), 'success')
187
+ } catch (err: unknown) {
188
+ if (surfaceRecordConflict(err, t)) return
189
+ flash(t('ui.perspectives.autosave.error', 'Failed to save view'), 'error')
190
+ }
191
+ })()
192
+ }
193
+ pendingAutosaveActionRef.current = runSave
194
+ autosaveRef.current = setTimeout(runSave, 400)
142
195
  }, [activePerspectiveId, mode.type, flushAutosave, t])
143
196
 
144
197
  React.useEffect(() => {
145
- return () => { flushAutosave() }
146
- }, [flushAutosave])
198
+ return () => { runPendingAutosave() }
199
+ }, [runPendingAutosave])
147
200
 
148
201
  const resetMode = () => {
149
202
  setMode({ type: 'idle' })
@@ -171,7 +224,7 @@ export function PerspectiveSidebar({
171
224
 
172
225
  React.useEffect(() => {
173
226
  if (!open) {
174
- flushAutosave()
227
+ runPendingAutosave()
175
228
  setError(null)
176
229
  resetMode()
177
230
  setRenamingId(null)
@@ -393,7 +446,10 @@ export function PerspectiveSidebar({
393
446
  {t('ui.perspectives.savedViews.new', 'New')}
394
447
  </Button>
395
448
  {(perspectives ?? emptyArray).map((p) => {
396
- const isActive = activePerspectiveId === p.id
449
+ // While the new-view form is open the sidebar is creating, not
450
+ // editing: keeping a chip lit reads as "I am editing the active
451
+ // view" and hides the mode switch (#5113).
452
+ const isActive = !isNew && activePerspectiveId === p.id
397
453
  const deleting = deletingIds.includes(p.id)
398
454
  const isShared = sharedIds.has(p.id)
399
455
  return (
@@ -426,7 +482,10 @@ export function PerspectiveSidebar({
426
482
  return !perspectives.some((pp) => pp.name.trim() === rpName)
427
483
  })
428
484
  .map((p) => {
429
- const isActive = activePerspectiveId === p.id
485
+ // While the new-view form is open the sidebar is creating, not
486
+ // editing: keeping a chip lit reads as "I am editing the active
487
+ // view" and hides the mode switch (#5113).
488
+ const isActive = !isNew && activePerspectiveId === p.id
430
489
  const clearing = roleClearingIds.includes(p.roleId)
431
490
  return (
432
491
  <ViewChip
@@ -0,0 +1,134 @@
1
+ /** @jest-environment jsdom */
2
+ import * as React from 'react'
3
+ import { DataTable } from '../DataTable'
4
+ import type { DataTableExportConfig } from '../DataTable'
5
+ import type { LegacyColumnDef as ColumnDef } from '@tanstack/react-table/legacy'
6
+ import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
7
+ import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
8
+ import { fireEvent, render, screen, waitFor } from '@testing-library/react'
9
+
10
+ jest.mock('next/navigation', () => ({
11
+ useRouter: () => ({ push: jest.fn(), replace: jest.fn(), prefetch: jest.fn() }),
12
+ }))
13
+
14
+ jest.mock('../injection/useInjectionDataWidgets', () => ({
15
+ useInjectionDataWidgets: () => ({ widgets: [], isLoading: false }),
16
+ }))
17
+
18
+ type Row = { id: string; name: string }
19
+
20
+ const columns: ColumnDef<Row>[] = [{ accessorKey: 'name', header: 'Name' }]
21
+
22
+ const exporter: DataTableExportConfig = {
23
+ view: { getUrl: () => '/api/example/export?scope=view' },
24
+ full: { getUrl: () => '/api/example/export?scope=full' },
25
+ }
26
+
27
+ function renderExportMenu(dict: Record<string, string>, exportConfig: DataTableExportConfig = exporter) {
28
+ const queryClient = new QueryClient({ defaultOptions: { queries: { gcTime: 0 } } })
29
+ const view = render(
30
+ React.createElement(
31
+ QueryClientProvider as any,
32
+ { client: queryClient },
33
+ React.createElement(
34
+ I18nProvider as any,
35
+ { locale: 'en', dict },
36
+ React.createElement(DataTable as any, {
37
+ columns,
38
+ data: [],
39
+ title: 'Test',
40
+ exporter: exportConfig,
41
+ }),
42
+ ),
43
+ )
44
+ )
45
+ fireEvent.click(screen.getByRole('button', { name: dict['ui.dataTable.export.label'] ?? 'Export' }))
46
+ return { view, queryClient }
47
+ }
48
+
49
+ describe('DataTable export section titles', () => {
50
+ it('falls back to the English defaults when the host supplies no translations', () => {
51
+ const { view, queryClient } = renderExportMenu({})
52
+ try {
53
+ expect(screen.getByText('Export what you view')).toBeTruthy()
54
+ expect(screen.getByText('Full data export')).toBeTruthy()
55
+ } finally {
56
+ view.unmount()
57
+ queryClient.clear()
58
+ }
59
+ })
60
+
61
+ it('uses the host dictionary for the default section titles without any page-level title', () => {
62
+ const { view, queryClient } = renderExportMenu({
63
+ 'ui.dataTable.export.label': 'Eksportuj',
64
+ 'ui.dataTable.export.viewTitle': 'Eksport tego, co widzisz',
65
+ 'ui.dataTable.export.fullTitle': 'Eksport pełnych danych',
66
+ })
67
+ try {
68
+ expect(screen.getByText('Eksport tego, co widzisz')).toBeTruthy()
69
+ expect(screen.getByText('Eksport pełnych danych')).toBeTruthy()
70
+ expect(screen.queryByText('Export what you view')).toBeNull()
71
+ expect(screen.queryByText('Full data export')).toBeNull()
72
+ } finally {
73
+ view.unmount()
74
+ queryClient.clear()
75
+ }
76
+ })
77
+
78
+ it('translates the numbered fallback for explicit sections that omit a title', () => {
79
+ const { view, queryClient } = renderExportMenu(
80
+ { 'ui.dataTable.export.sectionTitle': 'Eksport {index}' },
81
+ { sections: [{ getUrl: () => '/api/example/export?scope=first' }] },
82
+ )
83
+ try {
84
+ expect(screen.getByText('Eksport 1')).toBeTruthy()
85
+ } finally {
86
+ view.unmount()
87
+ queryClient.clear()
88
+ }
89
+ })
90
+
91
+ it('keeps the default download filename ASCII when the title is translated', async () => {
92
+ const anchorClicks: HTMLAnchorElement[] = []
93
+ const clickSpy = jest
94
+ .spyOn(HTMLAnchorElement.prototype, 'click')
95
+ .mockImplementation(function mockClick(this: HTMLAnchorElement) {
96
+ anchorClicks.push(this)
97
+ })
98
+ const originalCreateObjectURL = URL.createObjectURL
99
+ const originalRevokeObjectURL = URL.revokeObjectURL
100
+ URL.createObjectURL = jest.fn(() => 'blob:mock')
101
+ URL.revokeObjectURL = jest.fn()
102
+
103
+ const { view, queryClient } = renderExportMenu(
104
+ { 'ui.dataTable.export.fullTitle': '전체 데이터 내보내기' },
105
+ { full: { prepare: () => ({ prepared: { columns: [{ field: 'id', header: 'Id' }], rows: [{ id: '1' }] } }) } },
106
+ )
107
+ try {
108
+ expect(screen.getByText('전체 데이터 내보내기')).toBeTruthy()
109
+ fireEvent.click(screen.getByRole('button', { name: 'CSV' }))
110
+ await waitFor(() => expect(anchorClicks.length).toBe(1))
111
+ expect(anchorClicks[0].download).toBe('Full_data_export.csv')
112
+ } finally {
113
+ clickSpy.mockRestore()
114
+ URL.createObjectURL = originalCreateObjectURL
115
+ URL.revokeObjectURL = originalRevokeObjectURL
116
+ view.unmount()
117
+ queryClient.clear()
118
+ }
119
+ })
120
+
121
+ it('keeps an explicitly supplied section title verbatim', () => {
122
+ const { view, queryClient } = renderExportMenu(
123
+ { 'ui.dataTable.export.viewTitle': 'Eksport tego, co widzisz' },
124
+ { view: { title: 'Host supplied title', getUrl: () => '/api/example/export?scope=view' } },
125
+ )
126
+ try {
127
+ expect(screen.getByText('Host supplied title')).toBeTruthy()
128
+ expect(screen.queryByText('Eksport tego, co widzisz')).toBeNull()
129
+ } finally {
130
+ view.unmount()
131
+ queryClient.clear()
132
+ }
133
+ })
134
+ })