@asteby/metacore-runtime-react 23.12.2 → 24.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +84 -8
- package/package.json +3 -3
- package/src/dynamic-table.tsx +107 -8
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 24.0.0
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- 165f25c: feat(dynamic-table): cache first-page rows for instant reload paint
|
|
8
|
+
|
|
9
|
+
The table fetched rows into local state, so a full reload showed a full-table
|
|
10
|
+
skeleton until `/data/:model` resolved — even for the view just seen. Stash the
|
|
11
|
+
last first-page result in sessionStorage (org/user-scoped → must not outlive the
|
|
12
|
+
tab session) keyed by model+endpoint+branch+URL params, and seed the initial
|
|
13
|
+
rows from it so a reload paints instantly and revalidates in the background
|
|
14
|
+
(stale-while-revalidate).
|
|
15
|
+
|
|
16
|
+
- Updated dependencies [0704d54]
|
|
17
|
+
- @asteby/metacore-ui@2.10.0
|
|
18
|
+
|
|
19
|
+
## 23.12.3
|
|
20
|
+
|
|
21
|
+
### Patch Changes
|
|
22
|
+
|
|
23
|
+
- 5e46e61: fix(dynamic-table): don't strip a deep-linked filter on first mount (URL race)
|
|
24
|
+
|
|
25
|
+
The URL-write effect ran on the very first commit — when `initializedFromUrl`
|
|
26
|
+
(a ref) is already true but the init effect's `setDynamicFilters` hasn't
|
|
27
|
+
re-rendered yet — so it wrote a URL WITHOUT `f_status`, stripping a
|
|
28
|
+
deep-linked/reloaded filter and flickering the address bar until it settled
|
|
29
|
+
without the filter. Gate the write on a STATE flag (`urlSynced`) set once the
|
|
30
|
+
URL has been adopted, so the first write only happens on the render where
|
|
31
|
+
`dynamicFilters`/`pagination` already mirror the URL (a true no-op).
|
|
32
|
+
|
|
3
33
|
## 23.12.2
|
|
4
34
|
|
|
5
35
|
### Patch Changes
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAgBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAgC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAiEnF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;;;OAKG;IACH,UAAU,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC/B,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;IACpC,YAAY,CAAC,EAAE,SAAS,CAAC,GAAG,CAAC,EAAE,CAAA;IAC/B;;;;;OAKG;IACH,iBAAiB,CAAC,EAAE,iBAAiB,CAAA;IACrC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;;;OAMG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;CAC3B;AAED,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,aAAoB,EACpB,aAAkB,EAClB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EACR,QAAQ,EACR,cAAsB,GACzB,EAAE,iBAAiB,+BAupCnB"}
|
package/dist/dynamic-table.js
CHANGED
|
@@ -33,6 +33,48 @@ import { useCan, usePermissionsActive, gateTableMetadata } from './permissions-c
|
|
|
33
33
|
import { useDynamicRowActions } from './dynamic-row-actions';
|
|
34
34
|
import { ExportDialog } from './dialogs/export';
|
|
35
35
|
import { ImportDialog } from './dialogs/import';
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Row-data cache (perceived performance).
|
|
38
|
+
//
|
|
39
|
+
// The table fetches rows into local state, so a full page reload starts empty
|
|
40
|
+
// and shows a full skeleton until `/data/:model` resolves — even for a view the
|
|
41
|
+
// user just looked at. We stash the last first-page result and seed the initial
|
|
42
|
+
// state from it so a reload paints the previous rows instantly and the fetch
|
|
43
|
+
// below revalidates in the background (stale-while-revalidate).
|
|
44
|
+
//
|
|
45
|
+
// Deliberately `sessionStorage`, NOT localStorage: row data is org/user-scoped,
|
|
46
|
+
// so it must not outlive the tab session (a browser restart or a different login
|
|
47
|
+
// starts clean). The key includes model+endpoint+branch+URL params so a
|
|
48
|
+
// different filter / sort / page never paints the wrong rows. Only the first
|
|
49
|
+
// page is cached (capped) — infinite-scroll top-ups re-fetch on scroll.
|
|
50
|
+
const TBL_DATA_CACHE_PREFIX = 'mc:tbl:data:v1';
|
|
51
|
+
function tableDataCacheKey(model, endpoint, branchId, search) {
|
|
52
|
+
const p = new URLSearchParams(search);
|
|
53
|
+
const parts = [];
|
|
54
|
+
p.forEach((v, k) => parts.push(`${k}=${v}`));
|
|
55
|
+
parts.sort();
|
|
56
|
+
return `${TBL_DATA_CACHE_PREFIX}|${model || ''}|${endpoint || ''}|${branchId || ''}|${parts.join('&')}`;
|
|
57
|
+
}
|
|
58
|
+
function readTableDataCache(key) {
|
|
59
|
+
try {
|
|
60
|
+
const raw = sessionStorage.getItem(key);
|
|
61
|
+
if (!raw)
|
|
62
|
+
return null;
|
|
63
|
+
const parsed = JSON.parse(raw);
|
|
64
|
+
return parsed && Array.isArray(parsed.rows) ? parsed : null;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
function writeTableDataCache(key, rows, rowCount) {
|
|
71
|
+
try {
|
|
72
|
+
sessionStorage.setItem(key, JSON.stringify({ rows: rows.slice(0, 50), rowCount, ts: Date.now() }));
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
// quota / private mode — the cache is a nicety, never fatal
|
|
76
|
+
}
|
|
77
|
+
}
|
|
36
78
|
export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColumns = [], onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns = [], getDynamicColumns = defaultGetDynamicColumns, timeZone, currency, infiniteScroll = false, }) {
|
|
37
79
|
const { t, i18n } = useTranslation();
|
|
38
80
|
const api = useApi();
|
|
@@ -41,12 +83,22 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
41
83
|
const { getMetadata, setMetadata: cacheMetadata } = useMetadataCache();
|
|
42
84
|
const cachedMeta = getMetadata(model);
|
|
43
85
|
const [metadata, setMetadata] = useState(cachedMeta || null);
|
|
44
|
-
|
|
86
|
+
// Read the row-data cache ONCE at mount (before the fetch effects run) so the
|
|
87
|
+
// first paint uses the previous rows for this exact view instead of skeletons.
|
|
88
|
+
const bootDataKey = tableDataCacheKey(model, endpoint, currentBranch?.id, typeof window !== 'undefined' ? window.location.search : '');
|
|
89
|
+
const bootDataRef = useRef(undefined);
|
|
90
|
+
if (bootDataRef.current === undefined) {
|
|
91
|
+
bootDataRef.current = enableUrlSync ? readTableDataCache(bootDataKey) : null;
|
|
92
|
+
}
|
|
93
|
+
const bootData = bootDataRef.current;
|
|
94
|
+
const [data, setData] = useState(bootData?.rows ?? []);
|
|
45
95
|
// Footer totals: per-column SUM over the FILTERED set, fetched from a
|
|
46
96
|
// separate /aggregate endpoint (NOT summed from the visible page).
|
|
47
97
|
const [footerTotals, setFooterTotals] = useState({});
|
|
48
98
|
const [loading, setLoading] = useState(!cachedMeta);
|
|
49
|
-
|
|
99
|
+
// Cached rows → no full-table skeleton on reload; the background fetch still
|
|
100
|
+
// runs and swaps in fresh data.
|
|
101
|
+
const [loadingData, setLoadingData] = useState(!(bootData?.rows?.length));
|
|
50
102
|
// Infinite-scroll: a top-up page is in flight (distinct from the initial
|
|
51
103
|
// page load so only a small bottom spinner shows, not the whole-table one).
|
|
52
104
|
const [loadingMore, setLoadingMore] = useState(false);
|
|
@@ -72,7 +124,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
72
124
|
const [columnFilters, setColumnFilters] = useState([]);
|
|
73
125
|
const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 10 });
|
|
74
126
|
const [globalFilter, setGlobalFilter] = useState('');
|
|
75
|
-
const [rowCount, setRowCount] = useState(0);
|
|
127
|
+
const [rowCount, setRowCount] = useState(bootData?.rowCount ?? 0);
|
|
76
128
|
const [dateRange, setDateRange] = useState(undefined);
|
|
77
129
|
const [dynamicFilters, setDynamicFilters] = useState({});
|
|
78
130
|
const [filterOptionsMap, setFilterOptionsMap] = useState(new Map());
|
|
@@ -85,6 +137,17 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
85
137
|
// which reads as a URL flicker and fights the router's spelling. Starts at 10
|
|
86
138
|
// to match the initial pagination state until metadata arrives.
|
|
87
139
|
const defaultPerPage = useRef(10);
|
|
140
|
+
// Has the table finished adopting its state FROM the URL yet? The write
|
|
141
|
+
// effect must not run before this flips true. `initializedFromUrl` is a ref
|
|
142
|
+
// set synchronously at the top of the init effect, so on the very first
|
|
143
|
+
// commit the write effect (which runs right after, in the SAME commit) sees
|
|
144
|
+
// `initialized=true` but `dynamicFilters` still EMPTY — the init effect's
|
|
145
|
+
// setState hasn't re-rendered yet — and writes a URL WITHOUT `f_status`,
|
|
146
|
+
// stripping a deep-linked filter (the "recarga quita el filtro y parpadea"
|
|
147
|
+
// bug). A STATE flag defers the first write to the render AFTER the URL has
|
|
148
|
+
// been adopted, where `dynamicFilters`/`pagination` already mirror the URL,
|
|
149
|
+
// so the write is a no-op and the filter never gets stripped.
|
|
150
|
+
const [urlSynced, setUrlSynced] = useState(false);
|
|
88
151
|
useEffect(() => {
|
|
89
152
|
if (prevBranchId.current !== currentBranch?.id) {
|
|
90
153
|
prevBranchId.current = currentBranch?.id;
|
|
@@ -181,13 +244,16 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
181
244
|
});
|
|
182
245
|
if (Object.keys(filters).length > 0)
|
|
183
246
|
setDynamicFilters(filters);
|
|
247
|
+
// Adopted everything the URL carries; the write effect may now run on the
|
|
248
|
+
// NEXT render (where the setState above has landed) without clobbering it.
|
|
249
|
+
setUrlSynced(true);
|
|
184
250
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
|
185
251
|
// The exact query string this table last wrote to the URL — lets the
|
|
186
252
|
// resync effect below tell "our own replaceState" apart from an external
|
|
187
253
|
// rewrite by the host router.
|
|
188
254
|
const lastSelfSearch = useRef(null);
|
|
189
255
|
useEffect(() => {
|
|
190
|
-
if (!enableUrlSync || !
|
|
256
|
+
if (!enableUrlSync || !urlSynced)
|
|
191
257
|
return;
|
|
192
258
|
const params = new URLSearchParams();
|
|
193
259
|
// Preserve the route-owned view params. The table rebuilds the query
|
|
@@ -241,7 +307,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
241
307
|
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
|
|
242
308
|
lastSelfSearch.current = search ? `?${search}` : '';
|
|
243
309
|
window.history.replaceState(null, '', newUrl);
|
|
244
|
-
}, [enableUrlSync, pagination, sorting, globalFilter, dynamicFilters, defaultFilters]);
|
|
310
|
+
}, [enableUrlSync, urlSynced, pagination, sorting, globalFilter, dynamicFilters, defaultFilters]);
|
|
245
311
|
// The host router can rewrite the query string WITHOUT remounting the
|
|
246
312
|
// table — e.g. sidebar sibling entries deep-link different `f_` filters
|
|
247
313
|
// into the same model route. The mount-time init above never re-runs, so
|
|
@@ -461,9 +527,15 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
461
527
|
};
|
|
462
528
|
const res = await api.get(endpoint || `/data/${model}`, { params });
|
|
463
529
|
if (res.data.success) {
|
|
464
|
-
|
|
530
|
+
const rows = res.data.data || [];
|
|
531
|
+
setData(rows);
|
|
465
532
|
if (res.data.meta)
|
|
466
533
|
setRowCount(res.data.meta.total);
|
|
534
|
+
// Cache the first page for an instant reload paint (see the cache
|
|
535
|
+
// helpers). Keyed off the live URL so it matches the next mount.
|
|
536
|
+
if (enableUrlSync && pagination.pageIndex === 0) {
|
|
537
|
+
writeTableDataCache(tableDataCacheKey(model, endpoint, currentBranch?.id, window.location.search), rows, res.data.meta?.total ?? rows.length);
|
|
538
|
+
}
|
|
467
539
|
}
|
|
468
540
|
}
|
|
469
541
|
catch (error) {
|
|
@@ -472,7 +544,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
472
544
|
finally {
|
|
473
545
|
setLoadingData(false);
|
|
474
546
|
}
|
|
475
|
-
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api]);
|
|
547
|
+
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync]);
|
|
476
548
|
// Columns whose metadata opts into a footer total (display_config.aggregate
|
|
477
549
|
// → styleConfig.aggregate). When empty, no footer row is rendered and no
|
|
478
550
|
// aggregate request is made.
|
|
@@ -518,6 +590,10 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
518
590
|
setData((prev) => (append ? dedupeById(prev, rows) : rows));
|
|
519
591
|
if (res.data.meta)
|
|
520
592
|
setRowCount(res.data.meta.total);
|
|
593
|
+
// Cache the first (replace) page for an instant reload paint.
|
|
594
|
+
if (!append && enableUrlSync) {
|
|
595
|
+
writeTableDataCache(tableDataCacheKey(model, endpoint, currentBranch?.id, window.location.search), rows, res.data.meta?.total ?? rows.length);
|
|
596
|
+
}
|
|
521
597
|
// A short page means the backend has no more rows, even if
|
|
522
598
|
// meta.total disagrees with the visible count (count query
|
|
523
599
|
// vs list query drift, dedupe). Without this the sentinel
|
|
@@ -534,7 +610,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
|
|
|
534
610
|
else
|
|
535
611
|
setLoadingData(false);
|
|
536
612
|
}
|
|
537
|
-
}, [metadata, buildFilterParams, endpoint, model, api, currentBranch?.id]);
|
|
613
|
+
}, [metadata, buildFilterParams, endpoint, model, api, currentBranch?.id, enableUrlSync]);
|
|
538
614
|
// Signature of everything that must reset the incremental list to page 1:
|
|
539
615
|
// the filters/search AND the sort (both live in buildFilterParams).
|
|
540
616
|
const filterSignature = useMemo(() => JSON.stringify(buildFilterParams()), [buildFilterParams]);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asteby/metacore-runtime-react",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "24.0.0",
|
|
4
4
|
"description": "React runtime for metacore hosts — renders addon contributions dynamically",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"sonner": ">=1.7",
|
|
39
39
|
"zustand": ">=5",
|
|
40
40
|
"@asteby/metacore-sdk": "^3.2.0",
|
|
41
|
-
"@asteby/metacore-ui": "^2.
|
|
41
|
+
"@asteby/metacore-ui": "^2.10.0"
|
|
42
42
|
},
|
|
43
43
|
"peerDependenciesMeta": {
|
|
44
44
|
"@tanstack/react-router": {
|
|
@@ -68,7 +68,7 @@
|
|
|
68
68
|
"vitest": "^4.0.0",
|
|
69
69
|
"zustand": "^5.0.0",
|
|
70
70
|
"@asteby/metacore-sdk": "3.2.0",
|
|
71
|
-
"@asteby/metacore-ui": "2.
|
|
71
|
+
"@asteby/metacore-ui": "2.10.0"
|
|
72
72
|
},
|
|
73
73
|
"scripts": {
|
|
74
74
|
"build": "tsc -p tsconfig.json",
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -77,6 +77,58 @@ import { useDynamicRowActions } from './dynamic-row-actions'
|
|
|
77
77
|
import { ExportDialog } from './dialogs/export'
|
|
78
78
|
import { ImportDialog } from './dialogs/import'
|
|
79
79
|
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// Row-data cache (perceived performance).
|
|
82
|
+
//
|
|
83
|
+
// The table fetches rows into local state, so a full page reload starts empty
|
|
84
|
+
// and shows a full skeleton until `/data/:model` resolves — even for a view the
|
|
85
|
+
// user just looked at. We stash the last first-page result and seed the initial
|
|
86
|
+
// state from it so a reload paints the previous rows instantly and the fetch
|
|
87
|
+
// below revalidates in the background (stale-while-revalidate).
|
|
88
|
+
//
|
|
89
|
+
// Deliberately `sessionStorage`, NOT localStorage: row data is org/user-scoped,
|
|
90
|
+
// so it must not outlive the tab session (a browser restart or a different login
|
|
91
|
+
// starts clean). The key includes model+endpoint+branch+URL params so a
|
|
92
|
+
// different filter / sort / page never paints the wrong rows. Only the first
|
|
93
|
+
// page is cached (capped) — infinite-scroll top-ups re-fetch on scroll.
|
|
94
|
+
const TBL_DATA_CACHE_PREFIX = 'mc:tbl:data:v1'
|
|
95
|
+
interface TableDataCacheEntry { rows: any[]; rowCount: number; ts: number }
|
|
96
|
+
|
|
97
|
+
function tableDataCacheKey(
|
|
98
|
+
model: string | undefined,
|
|
99
|
+
endpoint: string | undefined,
|
|
100
|
+
branchId: string | number | null | undefined,
|
|
101
|
+
search: string,
|
|
102
|
+
): string {
|
|
103
|
+
const p = new URLSearchParams(search)
|
|
104
|
+
const parts: string[] = []
|
|
105
|
+
p.forEach((v, k) => parts.push(`${k}=${v}`))
|
|
106
|
+
parts.sort()
|
|
107
|
+
return `${TBL_DATA_CACHE_PREFIX}|${model || ''}|${endpoint || ''}|${branchId || ''}|${parts.join('&')}`
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function readTableDataCache(key: string): TableDataCacheEntry | null {
|
|
111
|
+
try {
|
|
112
|
+
const raw = sessionStorage.getItem(key)
|
|
113
|
+
if (!raw) return null
|
|
114
|
+
const parsed = JSON.parse(raw)
|
|
115
|
+
return parsed && Array.isArray(parsed.rows) ? parsed : null
|
|
116
|
+
} catch {
|
|
117
|
+
return null
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function writeTableDataCache(key: string, rows: any[], rowCount: number): void {
|
|
122
|
+
try {
|
|
123
|
+
sessionStorage.setItem(
|
|
124
|
+
key,
|
|
125
|
+
JSON.stringify({ rows: rows.slice(0, 50), rowCount, ts: Date.now() }),
|
|
126
|
+
)
|
|
127
|
+
} catch {
|
|
128
|
+
// quota / private mode — the cache is a nicety, never fatal
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
80
132
|
export interface DynamicTableProps {
|
|
81
133
|
model: string
|
|
82
134
|
endpoint?: string
|
|
@@ -148,12 +200,27 @@ export function DynamicTable({
|
|
|
148
200
|
const cachedMeta = getMetadata(model)
|
|
149
201
|
|
|
150
202
|
const [metadata, setMetadata] = useState<TableMetadata | null>(cachedMeta || null)
|
|
151
|
-
|
|
203
|
+
// Read the row-data cache ONCE at mount (before the fetch effects run) so the
|
|
204
|
+
// first paint uses the previous rows for this exact view instead of skeletons.
|
|
205
|
+
const bootDataKey = tableDataCacheKey(
|
|
206
|
+
model,
|
|
207
|
+
endpoint,
|
|
208
|
+
currentBranch?.id,
|
|
209
|
+
typeof window !== 'undefined' ? window.location.search : '',
|
|
210
|
+
)
|
|
211
|
+
const bootDataRef = useRef<TableDataCacheEntry | null | undefined>(undefined)
|
|
212
|
+
if (bootDataRef.current === undefined) {
|
|
213
|
+
bootDataRef.current = enableUrlSync ? readTableDataCache(bootDataKey) : null
|
|
214
|
+
}
|
|
215
|
+
const bootData = bootDataRef.current
|
|
216
|
+
const [data, setData] = useState<any[]>(bootData?.rows ?? [])
|
|
152
217
|
// Footer totals: per-column SUM over the FILTERED set, fetched from a
|
|
153
218
|
// separate /aggregate endpoint (NOT summed from the visible page).
|
|
154
219
|
const [footerTotals, setFooterTotals] = useState<Record<string, any>>({})
|
|
155
220
|
const [loading, setLoading] = useState(!cachedMeta)
|
|
156
|
-
|
|
221
|
+
// Cached rows → no full-table skeleton on reload; the background fetch still
|
|
222
|
+
// runs and swaps in fresh data.
|
|
223
|
+
const [loadingData, setLoadingData] = useState(!(bootData?.rows?.length))
|
|
157
224
|
// Infinite-scroll: a top-up page is in flight (distinct from the initial
|
|
158
225
|
// page load so only a small bottom spinner shows, not the whole-table one).
|
|
159
226
|
const [loadingMore, setLoadingMore] = useState(false)
|
|
@@ -182,7 +249,7 @@ export function DynamicTable({
|
|
|
182
249
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([])
|
|
183
250
|
const [pagination, setPagination] = useState<PaginationState>({ pageIndex: 0, pageSize: 10 })
|
|
184
251
|
const [globalFilter, setGlobalFilter] = useState('')
|
|
185
|
-
const [rowCount, setRowCount] = useState(0)
|
|
252
|
+
const [rowCount, setRowCount] = useState(bootData?.rowCount ?? 0)
|
|
186
253
|
|
|
187
254
|
const [dateRange, setDateRange] = useState<DateRange | undefined>(undefined)
|
|
188
255
|
const [dynamicFilters, setDynamicFilters] = useState<Record<string, string[]>>({})
|
|
@@ -197,6 +264,17 @@ export function DynamicTable({
|
|
|
197
264
|
// which reads as a URL flicker and fights the router's spelling. Starts at 10
|
|
198
265
|
// to match the initial pagination state until metadata arrives.
|
|
199
266
|
const defaultPerPage = useRef(10)
|
|
267
|
+
// Has the table finished adopting its state FROM the URL yet? The write
|
|
268
|
+
// effect must not run before this flips true. `initializedFromUrl` is a ref
|
|
269
|
+
// set synchronously at the top of the init effect, so on the very first
|
|
270
|
+
// commit the write effect (which runs right after, in the SAME commit) sees
|
|
271
|
+
// `initialized=true` but `dynamicFilters` still EMPTY — the init effect's
|
|
272
|
+
// setState hasn't re-rendered yet — and writes a URL WITHOUT `f_status`,
|
|
273
|
+
// stripping a deep-linked filter (the "recarga quita el filtro y parpadea"
|
|
274
|
+
// bug). A STATE flag defers the first write to the render AFTER the URL has
|
|
275
|
+
// been adopted, where `dynamicFilters`/`pagination` already mirror the URL,
|
|
276
|
+
// so the write is a no-op and the filter never gets stripped.
|
|
277
|
+
const [urlSynced, setUrlSynced] = useState(false)
|
|
200
278
|
|
|
201
279
|
useEffect(() => {
|
|
202
280
|
if (prevBranchId.current !== currentBranch?.id) {
|
|
@@ -289,6 +367,9 @@ export function DynamicTable({
|
|
|
289
367
|
}
|
|
290
368
|
})
|
|
291
369
|
if (Object.keys(filters).length > 0) setDynamicFilters(filters)
|
|
370
|
+
// Adopted everything the URL carries; the write effect may now run on the
|
|
371
|
+
// NEXT render (where the setState above has landed) without clobbering it.
|
|
372
|
+
setUrlSynced(true)
|
|
292
373
|
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
293
374
|
|
|
294
375
|
// The exact query string this table last wrote to the URL — lets the
|
|
@@ -297,7 +378,7 @@ export function DynamicTable({
|
|
|
297
378
|
const lastSelfSearch = useRef<string | null>(null)
|
|
298
379
|
|
|
299
380
|
useEffect(() => {
|
|
300
|
-
if (!enableUrlSync || !
|
|
381
|
+
if (!enableUrlSync || !urlSynced) return
|
|
301
382
|
const params = new URLSearchParams()
|
|
302
383
|
// Preserve the route-owned view params. The table rebuilds the query
|
|
303
384
|
// string from scratch (it only knows its own page/sort/filter keys), but
|
|
@@ -343,7 +424,7 @@ export function DynamicTable({
|
|
|
343
424
|
const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname
|
|
344
425
|
lastSelfSearch.current = search ? `?${search}` : ''
|
|
345
426
|
window.history.replaceState(null, '', newUrl)
|
|
346
|
-
}, [enableUrlSync, pagination, sorting, globalFilter, dynamicFilters, defaultFilters])
|
|
427
|
+
}, [enableUrlSync, urlSynced, pagination, sorting, globalFilter, dynamicFilters, defaultFilters])
|
|
347
428
|
|
|
348
429
|
// The host router can rewrite the query string WITHOUT remounting the
|
|
349
430
|
// table — e.g. sidebar sibling entries deep-link different `f_` filters
|
|
@@ -543,15 +624,25 @@ export function DynamicTable({
|
|
|
543
624
|
}
|
|
544
625
|
const res = await api.get(endpoint || `/data/${model}`, { params }) as { data: ApiResponse<any[]> }
|
|
545
626
|
if (res.data.success) {
|
|
546
|
-
|
|
627
|
+
const rows = res.data.data || []
|
|
628
|
+
setData(rows)
|
|
547
629
|
if (res.data.meta) setRowCount(res.data.meta.total)
|
|
630
|
+
// Cache the first page for an instant reload paint (see the cache
|
|
631
|
+
// helpers). Keyed off the live URL so it matches the next mount.
|
|
632
|
+
if (enableUrlSync && pagination.pageIndex === 0) {
|
|
633
|
+
writeTableDataCache(
|
|
634
|
+
tableDataCacheKey(model, endpoint, currentBranch?.id, window.location.search),
|
|
635
|
+
rows,
|
|
636
|
+
res.data.meta?.total ?? rows.length,
|
|
637
|
+
)
|
|
638
|
+
}
|
|
548
639
|
}
|
|
549
640
|
} catch (error) {
|
|
550
641
|
console.error('Error al cargar los datos', error)
|
|
551
642
|
} finally {
|
|
552
643
|
setLoadingData(false)
|
|
553
644
|
}
|
|
554
|
-
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api])
|
|
645
|
+
}, [model, metadata, pagination, buildFilterParams, refreshTrigger, endpoint, currentBranch?.id, api, enableUrlSync])
|
|
555
646
|
|
|
556
647
|
// Columns whose metadata opts into a footer total (display_config.aggregate
|
|
557
648
|
// → styleConfig.aggregate). When empty, no footer row is rendered and no
|
|
@@ -599,6 +690,14 @@ export function DynamicTable({
|
|
|
599
690
|
const rows = res.data.data || []
|
|
600
691
|
setData((prev) => (append ? dedupeById(prev, rows) : rows))
|
|
601
692
|
if (res.data.meta) setRowCount(res.data.meta.total)
|
|
693
|
+
// Cache the first (replace) page for an instant reload paint.
|
|
694
|
+
if (!append && enableUrlSync) {
|
|
695
|
+
writeTableDataCache(
|
|
696
|
+
tableDataCacheKey(model, endpoint, currentBranch?.id, window.location.search),
|
|
697
|
+
rows,
|
|
698
|
+
res.data.meta?.total ?? rows.length,
|
|
699
|
+
)
|
|
700
|
+
}
|
|
602
701
|
// A short page means the backend has no more rows, even if
|
|
603
702
|
// meta.total disagrees with the visible count (count query
|
|
604
703
|
// vs list query drift, dedupe). Without this the sentinel
|
|
@@ -612,7 +711,7 @@ export function DynamicTable({
|
|
|
612
711
|
else setLoadingData(false)
|
|
613
712
|
}
|
|
614
713
|
},
|
|
615
|
-
[metadata, buildFilterParams, endpoint, model, api, currentBranch?.id],
|
|
714
|
+
[metadata, buildFilterParams, endpoint, model, api, currentBranch?.id, enableUrlSync],
|
|
616
715
|
)
|
|
617
716
|
|
|
618
717
|
// Signature of everything that must reset the incremental list to page 1:
|