@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.
- package/dist/backend/DataTable.js +59 -13
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/PerspectiveSidebar.js +60 -21
- package/dist/backend/PerspectiveSidebar.js.map +2 -2
- package/dist/backend/views/NewViewForm.js +46 -38
- package/dist/backend/views/NewViewForm.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/DataTable.tsx +117 -14
- package/src/backend/PerspectiveSidebar.tsx +80 -21
- package/src/backend/__tests__/DataTable.exportSectionTitles.test.tsx +134 -0
- package/src/backend/__tests__/DataTable.perspectiveReconcile.test.tsx +312 -0
- package/src/backend/__tests__/PerspectiveSidebar.roleAutosave.test.tsx +252 -0
- package/src/backend/views/NewViewForm.tsx +46 -32
|
@@ -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 = (
|
|
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
|
|
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 },
|
|
577
|
+
addSection('view', { getUrl: config.getUrl, formats: config.formats }, viewTitle, DEFAULT_VIEW_EXPORT_TITLE)
|
|
562
578
|
} else {
|
|
563
|
-
addSection('view', config.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
|
-
|
|
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, '
|
|
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.
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
174
|
+
const runSave = () => {
|
|
127
175
|
autosaveRef.current = null
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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 () => {
|
|
146
|
-
}, [
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
})
|