@asteby/metacore-runtime-react 23.1.0 → 23.3.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 +56 -0
- package/dist/action-modal-dispatcher.js +4 -4
- package/dist/dialogs/dynamic-record.d.ts +5 -0
- package/dist/dialogs/dynamic-record.d.ts.map +1 -1
- package/dist/dialogs/dynamic-record.js +146 -31
- package/dist/display-value.d.ts +56 -0
- package/dist/display-value.d.ts.map +1 -0
- package/dist/display-value.js +74 -0
- package/dist/dynamic-columns.d.ts.map +1 -1
- package/dist/dynamic-columns.js +2 -53
- package/dist/dynamic-kanban.d.ts +19 -3
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +114 -8
- package/dist/dynamic-row-actions.d.ts.map +1 -1
- package/dist/dynamic-row-actions.js +5 -3
- package/dist/dynamic-table.d.ts +9 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +132 -32
- package/dist/use-infinite-scroll.d.ts +27 -0
- package/dist/use-infinite-scroll.d.ts.map +1 -0
- package/dist/use-infinite-scroll.js +60 -0
- package/package.json +3 -3
- package/src/__tests__/dynamic-kanban-infinite-scroll.test.tsx +210 -0
- package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +196 -0
- package/src/__tests__/readonly-fields.test.tsx +97 -0
- package/src/__tests__/record-detail-display.test.tsx +113 -0
- package/src/__tests__/use-infinite-scroll.test.tsx +147 -0
- package/src/action-modal-dispatcher.tsx +4 -4
- package/src/dialogs/dynamic-record.tsx +217 -40
- package/src/display-value.tsx +134 -0
- package/src/dynamic-columns.tsx +6 -102
- package/src/dynamic-kanban.tsx +168 -8
- package/src/dynamic-row-actions.tsx +5 -3
- package/src/dynamic-table.tsx +149 -10
- package/src/use-infinite-scroll.ts +94 -0
package/src/dynamic-kanban.tsx
CHANGED
|
@@ -85,6 +85,7 @@ import {
|
|
|
85
85
|
summarizeFilterValues,
|
|
86
86
|
translateOptionLabels,
|
|
87
87
|
} from './filter-chips'
|
|
88
|
+
import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll'
|
|
88
89
|
import { useMetadataCache } from './metadata-cache'
|
|
89
90
|
import { ActivityValueRenderer } from './activity-value-renderer'
|
|
90
91
|
import { DynamicIcon } from './dynamic-icon'
|
|
@@ -213,6 +214,30 @@ export function applyOptimisticMove(
|
|
|
213
214
|
return next
|
|
214
215
|
}
|
|
215
216
|
|
|
217
|
+
/**
|
|
218
|
+
* Returns a NEW per-lane pagination map with the server totals adjusted for a
|
|
219
|
+
* card moving `fromStage` → `toStage`: the source loses one, the destination
|
|
220
|
+
* gains one. Lanes whose `total` is still unknown (`null`, not yet topped up)
|
|
221
|
+
* are left alone. Pure — backs the optimistic drag so a partial lane's
|
|
222
|
+
* `count/total` header stays truthful, and can be restored on PUT failure.
|
|
223
|
+
*/
|
|
224
|
+
export function applyLaneTotalsOnMove<T extends { total: number | null }>(
|
|
225
|
+
pagination: Record<string, T>,
|
|
226
|
+
fromStage: string,
|
|
227
|
+
toStage: string,
|
|
228
|
+
): Record<string, T> {
|
|
229
|
+
const next = { ...pagination }
|
|
230
|
+
const bump = (key: string, delta: number) => {
|
|
231
|
+
const st = next[key]
|
|
232
|
+
if (st && st.total != null) {
|
|
233
|
+
next[key] = { ...st, total: Math.max(0, st.total + delta) }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
bump(fromStage, -1)
|
|
237
|
+
bump(toStage, +1)
|
|
238
|
+
return next
|
|
239
|
+
}
|
|
240
|
+
|
|
216
241
|
/**
|
|
217
242
|
* Picks the columns shown on a card: a `title` column (first searchable column,
|
|
218
243
|
* else first text-ish column) and up to `maxFields` secondary columns. Excludes
|
|
@@ -337,6 +362,18 @@ interface LaneFilterState {
|
|
|
337
362
|
query?: string
|
|
338
363
|
}
|
|
339
364
|
|
|
365
|
+
/** Incremental pagination bookkeeping for one lane/stage. */
|
|
366
|
+
interface LanePageState {
|
|
367
|
+
/** Next stage-scoped page to request. */
|
|
368
|
+
nextPage: number
|
|
369
|
+
/** Server total for the stage (from response meta), or null if unknown. */
|
|
370
|
+
total: number | null
|
|
371
|
+
/** A top-up request is in flight. */
|
|
372
|
+
loading: boolean
|
|
373
|
+
/** No more pages for this stage. */
|
|
374
|
+
done: boolean
|
|
375
|
+
}
|
|
376
|
+
|
|
340
377
|
export interface DynamicKanbanProps {
|
|
341
378
|
/** Model key as registered on the backend (e.g. "issue"). */
|
|
342
379
|
model: string
|
|
@@ -357,10 +394,16 @@ export interface DynamicKanbanProps {
|
|
|
357
394
|
*/
|
|
358
395
|
onAction?: (action: string, row: any) => void
|
|
359
396
|
/**
|
|
360
|
-
*
|
|
361
|
-
*
|
|
397
|
+
* Size of the INITIAL board page (one request, grouped into lanes). Each
|
|
398
|
+
* lane then tops up incrementally on scroll (see `lanePageSize`). Defaults
|
|
399
|
+
* to 50 — enough to fill the visible lanes without loading the whole board.
|
|
362
400
|
*/
|
|
363
401
|
pageSize?: number
|
|
402
|
+
/**
|
|
403
|
+
* Page size for a lane's incremental top-up fetch (scoped by
|
|
404
|
+
* `f_<group_by>=<stage>`). Defaults to 25.
|
|
405
|
+
*/
|
|
406
|
+
lanePageSize?: number
|
|
364
407
|
/** IANA timezone for datetime card fields (org config). */
|
|
365
408
|
timeZone?: string
|
|
366
409
|
/** ISO 4217 currency for money card fields (org config). */
|
|
@@ -378,7 +421,8 @@ export function DynamicKanban({
|
|
|
378
421
|
refreshTrigger,
|
|
379
422
|
onCardClick,
|
|
380
423
|
onAction,
|
|
381
|
-
pageSize =
|
|
424
|
+
pageSize = 50,
|
|
425
|
+
lanePageSize = 25,
|
|
382
426
|
timeZone,
|
|
383
427
|
currency,
|
|
384
428
|
defaultFilters,
|
|
@@ -394,6 +438,14 @@ export function DynamicKanban({
|
|
|
394
438
|
const [records, setRecords] = useState<any[]>([])
|
|
395
439
|
const [loading, setLoading] = useState(!cachedMeta)
|
|
396
440
|
const [loadingData, setLoadingData] = useState(true)
|
|
441
|
+
// Per-stage incremental pagination for infinite scroll. The initial board
|
|
442
|
+
// page (grouped into lanes) is fetched once; each lane then tops up its OWN
|
|
443
|
+
// stage via `f_<group_by>=<stage>&page=n`, appended (deduped by id) into the
|
|
444
|
+
// shared `records`. `total` is the stage's server count when the response
|
|
445
|
+
// meta carries it. Reset whenever the filters/search change.
|
|
446
|
+
const [lanePagination, setLanePagination] = useState<
|
|
447
|
+
Record<string, LanePageState>
|
|
448
|
+
>({})
|
|
397
449
|
|
|
398
450
|
// Active drag card id (for the DragOverlay + drop-zone highlighting).
|
|
399
451
|
const [activeId, setActiveId] = useState<string | null>(null)
|
|
@@ -449,7 +501,10 @@ export function DynamicKanban({
|
|
|
449
501
|
clearAll,
|
|
450
502
|
} = useDynamicFilters(metadata, { defaultFilters, model, endpoint })
|
|
451
503
|
|
|
452
|
-
// ----
|
|
504
|
+
// ---- initial board page (one request, grouped into lanes) ----
|
|
505
|
+
// Resets the per-lane pagination so every lane restarts its incremental
|
|
506
|
+
// top-up from scratch — called on mount, refresh, and any filter/search
|
|
507
|
+
// change (fetchData's identity changes with filterParams).
|
|
453
508
|
const fetchData = useCallback(async () => {
|
|
454
509
|
if (!metadata) return
|
|
455
510
|
setLoadingData(true)
|
|
@@ -463,8 +518,67 @@ export function DynamicKanban({
|
|
|
463
518
|
} finally {
|
|
464
519
|
setLoadingData(false)
|
|
465
520
|
}
|
|
521
|
+
setLanePagination({})
|
|
466
522
|
}, [api, endpoint, model, metadata, pageSize, filterParams])
|
|
467
523
|
|
|
524
|
+
// Load the next page for ONE lane/stage and append it (deduped by id) into
|
|
525
|
+
// the shared records. Scoped by `f_<group_by>=<stage>` on top of the active
|
|
526
|
+
// filterParams (the stage scope wins over any global group_by filter).
|
|
527
|
+
const groupByKey = metadata?.group_by || ''
|
|
528
|
+
const loadMoreLane = useCallback(
|
|
529
|
+
async (stageKey: string) => {
|
|
530
|
+
if (!metadata || !groupByKey) return
|
|
531
|
+
const current = lanePagination[stageKey]
|
|
532
|
+
if (current?.loading || current?.done) return
|
|
533
|
+
const nextPage = current?.nextPage ?? 1
|
|
534
|
+
setLanePagination((p) => ({
|
|
535
|
+
...p,
|
|
536
|
+
[stageKey]: {
|
|
537
|
+
nextPage,
|
|
538
|
+
total: current?.total ?? null,
|
|
539
|
+
loading: true,
|
|
540
|
+
done: false,
|
|
541
|
+
},
|
|
542
|
+
}))
|
|
543
|
+
try {
|
|
544
|
+
const res = (await api.get(endpoint || `/data/${model}`, {
|
|
545
|
+
params: {
|
|
546
|
+
...filterParams,
|
|
547
|
+
page: nextPage,
|
|
548
|
+
per_page: lanePageSize,
|
|
549
|
+
[`f_${groupByKey}`]: stageKey,
|
|
550
|
+
},
|
|
551
|
+
})) as { data: ApiResponse<any[]> & { meta?: any } }
|
|
552
|
+
const rows = res.data.success ? res.data.data || [] : []
|
|
553
|
+
const total =
|
|
554
|
+
res.data.meta?.total ?? res.data.meta?.count ?? null
|
|
555
|
+
setRecords((prev) => dedupeById(prev, rows))
|
|
556
|
+
setLanePagination((p) => ({
|
|
557
|
+
...p,
|
|
558
|
+
[stageKey]: {
|
|
559
|
+
nextPage: nextPage + 1,
|
|
560
|
+
total,
|
|
561
|
+
loading: false,
|
|
562
|
+
// Exhausted when the server returned a short page.
|
|
563
|
+
done: rows.length < lanePageSize,
|
|
564
|
+
},
|
|
565
|
+
}))
|
|
566
|
+
} catch (err) {
|
|
567
|
+
console.error(`Error al cargar más tarjetas de ${stageKey}`, err)
|
|
568
|
+
setLanePagination((p) => ({
|
|
569
|
+
...p,
|
|
570
|
+
[stageKey]: {
|
|
571
|
+
nextPage,
|
|
572
|
+
total: current?.total ?? null,
|
|
573
|
+
loading: false,
|
|
574
|
+
done: false,
|
|
575
|
+
},
|
|
576
|
+
}))
|
|
577
|
+
}
|
|
578
|
+
},
|
|
579
|
+
[api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination],
|
|
580
|
+
)
|
|
581
|
+
|
|
468
582
|
// Refetch when metadata resolves, on an explicit refresh, or when the
|
|
469
583
|
// filters change. `fetchData` is stable while `filterParams` is unchanged
|
|
470
584
|
// (both memoized), so this only re-runs on real input changes. Debounced so
|
|
@@ -566,7 +680,6 @@ export function DynamicKanban({
|
|
|
566
680
|
() => (metadata ? deriveStages(metadata) : []),
|
|
567
681
|
[metadata],
|
|
568
682
|
)
|
|
569
|
-
const groupByKey = metadata?.group_by || ''
|
|
570
683
|
const transitions = metadata?.transitions
|
|
571
684
|
|
|
572
685
|
const grouped = useMemo(
|
|
@@ -651,11 +764,16 @@ export function DynamicKanban({
|
|
|
651
764
|
|
|
652
765
|
// OPTIMISTIC: move the card in local state immediately.
|
|
653
766
|
const prevRecords = records
|
|
767
|
+
const prevPagination = lanePagination
|
|
654
768
|
setRecords((rs) =>
|
|
655
769
|
rs.map((r) =>
|
|
656
770
|
String(r.id) === cardId ? { ...r, [groupByKey]: destStage } : r,
|
|
657
771
|
),
|
|
658
772
|
)
|
|
773
|
+
// Keep the server totals consistent with the moved card so a lane's
|
|
774
|
+
// `count/total` header stays truthful with partial lanes: one leaves
|
|
775
|
+
// the source stage, one joins the destination.
|
|
776
|
+
setLanePagination((p) => applyLaneTotalsOnMove(p, srcStage, destStage))
|
|
659
777
|
|
|
660
778
|
try {
|
|
661
779
|
const base = endpoint || `/data/${model}`
|
|
@@ -672,6 +790,7 @@ export function DynamicKanban({
|
|
|
672
790
|
} catch (err: any) {
|
|
673
791
|
// REVERT + toast on failure.
|
|
674
792
|
setRecords(prevRecords)
|
|
793
|
+
setLanePagination(prevPagination)
|
|
675
794
|
toast.error(
|
|
676
795
|
t('kanban.moveFailed', {
|
|
677
796
|
defaultValue: 'No se pudo mover la tarjeta',
|
|
@@ -682,7 +801,7 @@ export function DynamicKanban({
|
|
|
682
801
|
)
|
|
683
802
|
}
|
|
684
803
|
},
|
|
685
|
-
[api, endpoint, groupByKey, model, records, stageOfCard, t, transitions],
|
|
804
|
+
[api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions],
|
|
686
805
|
)
|
|
687
806
|
|
|
688
807
|
if (loading) {
|
|
@@ -855,12 +974,21 @@ export function DynamicKanban({
|
|
|
855
974
|
!activeId ||
|
|
856
975
|
stage.key === activeStage ||
|
|
857
976
|
isTransitionAllowed(transitions, activeStage, stage.key)
|
|
977
|
+
// Infinite scroll is per declared stage; the synthetic
|
|
978
|
+
// "unassigned" lane can't be stage-scoped, so it never tops up.
|
|
979
|
+
const laneState = lanePagination[stage.key]
|
|
980
|
+
const isUnassigned = stage.key === UNASSIGNED_LANE
|
|
981
|
+
const laneHasMore = !isUnassigned && !laneState?.done
|
|
858
982
|
return (
|
|
859
983
|
<KanbanLane
|
|
860
984
|
key={stage.key}
|
|
861
985
|
stage={stage}
|
|
862
986
|
count={cards.length}
|
|
863
987
|
totalCount={allCards.length}
|
|
988
|
+
serverTotal={laneState?.total ?? null}
|
|
989
|
+
hasMore={laneHasMore}
|
|
990
|
+
loadingMore={!!laneState?.loading}
|
|
991
|
+
onLoadMore={() => loadMoreLane(stage.key)}
|
|
864
992
|
filterFields={filterFields}
|
|
865
993
|
laneFilter={laneFilter}
|
|
866
994
|
onFunnelChange={(f) =>
|
|
@@ -1029,6 +1157,14 @@ interface KanbanLaneProps {
|
|
|
1029
1157
|
stage: StageMeta
|
|
1030
1158
|
count: number
|
|
1031
1159
|
totalCount: number
|
|
1160
|
+
/** Server-reported total for the stage (from response meta), or null. */
|
|
1161
|
+
serverTotal: number | null
|
|
1162
|
+
/** More server pages available for this stage. */
|
|
1163
|
+
hasMore: boolean
|
|
1164
|
+
/** A top-up request for this stage is in flight. */
|
|
1165
|
+
loadingMore: boolean
|
|
1166
|
+
/** Request the next page for this stage. */
|
|
1167
|
+
onLoadMore: () => void
|
|
1032
1168
|
filterFields: LaneFilterField[]
|
|
1033
1169
|
laneFilter: LaneFilterState | undefined
|
|
1034
1170
|
onFunnelChange: (filter: LaneFunnelValue | null) => void
|
|
@@ -1043,6 +1179,10 @@ function KanbanLane({
|
|
|
1043
1179
|
stage,
|
|
1044
1180
|
count,
|
|
1045
1181
|
totalCount,
|
|
1182
|
+
serverTotal,
|
|
1183
|
+
hasMore,
|
|
1184
|
+
loadingMore,
|
|
1185
|
+
onLoadMore,
|
|
1046
1186
|
filterFields,
|
|
1047
1187
|
laneFilter,
|
|
1048
1188
|
onFunnelChange,
|
|
@@ -1054,6 +1194,12 @@ function KanbanLane({
|
|
|
1054
1194
|
}: KanbanLaneProps) {
|
|
1055
1195
|
const { t } = useTranslation()
|
|
1056
1196
|
const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled })
|
|
1197
|
+
// Infinite scroll: the sentinel lives at the bottom of the lane's own scroll
|
|
1198
|
+
// container; a load in flight or an exhausted stage disables it.
|
|
1199
|
+
const { rootRef, sentinelRef } = useInfiniteScrollSentinel({
|
|
1200
|
+
onLoadMore,
|
|
1201
|
+
disabled: !hasMore || loadingMore,
|
|
1202
|
+
})
|
|
1057
1203
|
const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
|
|
1058
1204
|
isDark,
|
|
1059
1205
|
})
|
|
@@ -1111,7 +1257,11 @@ function KanbanLane({
|
|
|
1111
1257
|
{t(stage.label, { defaultValue: stage.label })}
|
|
1112
1258
|
</Badge>
|
|
1113
1259
|
<span className="text-xs font-medium tabular-nums text-muted-foreground">
|
|
1114
|
-
{laneActive
|
|
1260
|
+
{laneActive
|
|
1261
|
+
? `${count}/${totalCount}`
|
|
1262
|
+
: serverTotal != null
|
|
1263
|
+
? `${count}/${serverTotal}`
|
|
1264
|
+
: count}
|
|
1115
1265
|
</span>
|
|
1116
1266
|
</div>
|
|
1117
1267
|
{/* Lane actions — always visible in muted (a hidden hover-reveal
|
|
@@ -1189,8 +1339,18 @@ function KanbanLane({
|
|
|
1189
1339
|
text wraps freely (no line-clamp) the cards grew past the lane
|
|
1190
1340
|
and spilled out of the stage. A normal `overflow-y-auto` block
|
|
1191
1341
|
constrains every card to the lane width so text wraps inside it. */}
|
|
1192
|
-
<div
|
|
1342
|
+
<div
|
|
1343
|
+
ref={rootRef}
|
|
1344
|
+
className="flex min-h-[55vh] max-h-[70vh] min-w-0 flex-col gap-2 overflow-y-auto px-2 pb-3"
|
|
1345
|
+
>
|
|
1193
1346
|
{children}
|
|
1347
|
+
{loadingMore && (
|
|
1348
|
+
<Skeleton className="h-16 w-full shrink-0" data-testid="lane-loading-more" />
|
|
1349
|
+
)}
|
|
1350
|
+
{/* Sentinel: entering view triggers the next stage page. */}
|
|
1351
|
+
{hasMore && (
|
|
1352
|
+
<div ref={sentinelRef} className="h-1 w-full shrink-0" aria-hidden />
|
|
1353
|
+
)}
|
|
1194
1354
|
</div>
|
|
1195
1355
|
</div>
|
|
1196
1356
|
)
|
|
@@ -139,11 +139,13 @@ export function useDynamicRowActions({
|
|
|
139
139
|
try {
|
|
140
140
|
const deleteEndpoint = endpoint ? `${endpoint}/${rowToDelete.id}` : `/data/${model}/${rowToDelete.id}`
|
|
141
141
|
const res = await api.delete(deleteEndpoint)
|
|
142
|
-
|
|
143
|
-
|
|
142
|
+
// CRUD estándar: no usar res.data.message (el endpoint dinámico
|
|
143
|
+
// devuelve texto en inglés que se filtraría al toast). String localizado.
|
|
144
|
+
if (res.data.success) { toast.success(t('dynamic.delete_success', { defaultValue: 'Registro eliminado correctamente' })); onRefresh() }
|
|
145
|
+
else toast.error(t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' }))
|
|
144
146
|
} catch (error) {
|
|
145
147
|
console.error('Error al eliminar', error)
|
|
146
|
-
toast.error('
|
|
148
|
+
toast.error(t('dynamic.delete_error', { defaultValue: 'No se pudo eliminar el registro' }))
|
|
147
149
|
} finally {
|
|
148
150
|
setIsDeleting(false)
|
|
149
151
|
setRowToDelete(null)
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -68,6 +68,7 @@ import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-sh
|
|
|
68
68
|
import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns'
|
|
69
69
|
import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
|
|
70
70
|
import { FilterChipsRow, translateOptionLabels, type FilterChipField } from './filter-chips'
|
|
71
|
+
import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll'
|
|
71
72
|
import { OptionsContext } from './options-context'
|
|
72
73
|
import type { TableMetadata, ApiResponse } from './types'
|
|
73
74
|
import { getSearchableColumnKeys } from './column-visibility'
|
|
@@ -112,6 +113,14 @@ export interface DynamicTableProps {
|
|
|
112
113
|
* an explicit per-column currency. Optional — defaults to 'USD'.
|
|
113
114
|
*/
|
|
114
115
|
currency?: string
|
|
116
|
+
/**
|
|
117
|
+
* Opt into infinite scroll: instead of the classic pager, rows accumulate as
|
|
118
|
+
* the user scrolls (a sentinel at the bottom fetches + appends the next
|
|
119
|
+
* page, deduped by id, respecting the active filters/search). Changing any
|
|
120
|
+
* filter/sort/search resets to page 1. Default false — existing hosts keep
|
|
121
|
+
* the classic pagination untouched.
|
|
122
|
+
*/
|
|
123
|
+
infiniteScroll?: boolean
|
|
115
124
|
}
|
|
116
125
|
|
|
117
126
|
export function DynamicTable({
|
|
@@ -127,6 +136,7 @@ export function DynamicTable({
|
|
|
127
136
|
getDynamicColumns = defaultGetDynamicColumns,
|
|
128
137
|
timeZone,
|
|
129
138
|
currency,
|
|
139
|
+
infiniteScroll = false,
|
|
130
140
|
}: DynamicTableProps) {
|
|
131
141
|
const { t, i18n } = useTranslation()
|
|
132
142
|
const api = useApi()
|
|
@@ -144,6 +154,10 @@ export function DynamicTable({
|
|
|
144
154
|
const [footerTotals, setFooterTotals] = useState<Record<string, any>>({})
|
|
145
155
|
const [loading, setLoading] = useState(!cachedMeta)
|
|
146
156
|
const [loadingData, setLoadingData] = useState(true)
|
|
157
|
+
// Infinite-scroll: a top-up page is in flight (distinct from the initial
|
|
158
|
+
// page load so only a small bottom spinner shows, not the whole-table one).
|
|
159
|
+
const [loadingMore, setLoadingMore] = useState(false)
|
|
160
|
+
const infPageRef = useRef(1)
|
|
147
161
|
const [optionsMap, setOptionsMap] = useState<Map<string, any[]>>(new Map())
|
|
148
162
|
|
|
149
163
|
const [exportOpen, setExportOpen] = useState(false)
|
|
@@ -472,9 +486,69 @@ export function DynamicTable({
|
|
|
472
486
|
}
|
|
473
487
|
}, [model, metadata, aggregateColumns, buildFilterParams, endpoint, currentBranch?.id, api])
|
|
474
488
|
|
|
489
|
+
// ---- infinite scroll: page fetch that REPLACES (page 1) or APPENDS ----
|
|
490
|
+
const infPageSize = 30
|
|
491
|
+
const fetchPage = useCallback(
|
|
492
|
+
async (page: number, append: boolean) => {
|
|
493
|
+
if (!metadata) return
|
|
494
|
+
if (append) setLoadingMore(true)
|
|
495
|
+
else setLoadingData(true)
|
|
496
|
+
try {
|
|
497
|
+
const params: Record<string, any> = {
|
|
498
|
+
page,
|
|
499
|
+
per_page: infPageSize,
|
|
500
|
+
...buildFilterParams(),
|
|
501
|
+
}
|
|
502
|
+
const res = (await api.get(endpoint || `/data/${model}`, {
|
|
503
|
+
params,
|
|
504
|
+
})) as { data: ApiResponse<any[]> }
|
|
505
|
+
if (res.data.success) {
|
|
506
|
+
const rows = res.data.data || []
|
|
507
|
+
setData((prev) => (append ? dedupeById(prev, rows) : rows))
|
|
508
|
+
if (res.data.meta) setRowCount(res.data.meta.total)
|
|
509
|
+
}
|
|
510
|
+
} catch (error) {
|
|
511
|
+
console.error('Error al cargar los datos', error)
|
|
512
|
+
} finally {
|
|
513
|
+
if (append) setLoadingMore(false)
|
|
514
|
+
else setLoadingData(false)
|
|
515
|
+
}
|
|
516
|
+
},
|
|
517
|
+
[metadata, buildFilterParams, endpoint, model, api, currentBranch?.id],
|
|
518
|
+
)
|
|
519
|
+
|
|
520
|
+
// Signature of everything that must reset the incremental list to page 1:
|
|
521
|
+
// the filters/search AND the sort (both live in buildFilterParams).
|
|
522
|
+
const filterSignature = useMemo(
|
|
523
|
+
() => JSON.stringify(buildFilterParams()),
|
|
524
|
+
[buildFilterParams],
|
|
525
|
+
)
|
|
526
|
+
|
|
527
|
+
const loadNextPage = useCallback(() => {
|
|
528
|
+
if (loadingMore || loadingData) return
|
|
529
|
+
if (data.length >= rowCount) return
|
|
530
|
+
infPageRef.current += 1
|
|
531
|
+
void fetchPage(infPageRef.current, true)
|
|
532
|
+
}, [loadingMore, loadingData, data.length, rowCount, fetchPage])
|
|
533
|
+
|
|
534
|
+
// Infinite-scroll sentinels. There are two scroll containers (desktop
|
|
535
|
+
// table + mobile card list) but only one is laid out at a time — the CSS
|
|
536
|
+
// `hidden`/`sm:hidden` container has no box, so its observer never fires.
|
|
537
|
+
// Each sentinel drives the SAME `loadNextPage`; its internal guards + the
|
|
538
|
+
// `disabled` flag keep concurrent/exhausted fetches from doubling up.
|
|
539
|
+
const infScrollDisabled =
|
|
540
|
+
!infiniteScroll || loadingMore || loadingData || data.length >= rowCount
|
|
541
|
+
const { rootRef: infDesktopRoot, sentinelRef: infDesktopSentinel } =
|
|
542
|
+
useInfiniteScrollSentinel({ onLoadMore: loadNextPage, disabled: infScrollDisabled })
|
|
543
|
+
const { rootRef: infMobileRoot, sentinelRef: infMobileSentinel } =
|
|
544
|
+
useInfiniteScrollSentinel({ onLoadMore: loadNextPage, disabled: infScrollDisabled })
|
|
545
|
+
|
|
475
546
|
const initialFetchDone = useRef(false)
|
|
476
547
|
useEffect(() => {
|
|
477
548
|
if (!metadata) return
|
|
549
|
+
// Infinite mode owns its own fetching (reset-to-page-1 effect below);
|
|
550
|
+
// the classic pagination-driven path is skipped entirely.
|
|
551
|
+
if (infiniteScroll) return
|
|
478
552
|
if (!initialFetchDone.current) {
|
|
479
553
|
initialFetchDone.current = true
|
|
480
554
|
fetchData()
|
|
@@ -486,9 +560,41 @@ export function DynamicTable({
|
|
|
486
560
|
fetchAggregates()
|
|
487
561
|
}, 300)
|
|
488
562
|
return () => clearTimeout(timeoutId)
|
|
489
|
-
}, [fetchData, fetchAggregates, metadata])
|
|
563
|
+
}, [fetchData, fetchAggregates, metadata, infiniteScroll])
|
|
490
564
|
|
|
491
|
-
|
|
565
|
+
// Infinite mode: (re)load page 1 on mount and whenever the filters/sort/
|
|
566
|
+
// search change (or an explicit refreshTrigger) — replacing the accumulated
|
|
567
|
+
// rows and resetting the cursor.
|
|
568
|
+
useEffect(() => {
|
|
569
|
+
if (!infiniteScroll || !metadata) return
|
|
570
|
+
infPageRef.current = 1
|
|
571
|
+
const first = !initialFetchDone.current
|
|
572
|
+
initialFetchDone.current = true
|
|
573
|
+
if (first) {
|
|
574
|
+
void fetchPage(1, false)
|
|
575
|
+
void fetchAggregates()
|
|
576
|
+
return
|
|
577
|
+
}
|
|
578
|
+
const timeoutId = setTimeout(() => {
|
|
579
|
+
void fetchPage(1, false)
|
|
580
|
+
void fetchAggregates()
|
|
581
|
+
}, 300)
|
|
582
|
+
return () => clearTimeout(timeoutId)
|
|
583
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
584
|
+
}, [infiniteScroll, metadata, filterSignature])
|
|
585
|
+
|
|
586
|
+
const handleRefresh = useCallback(() => {
|
|
587
|
+
// Infinite mode owns its own list: refresh reloads page 1 and drops the
|
|
588
|
+
// accumulated pages (a classic fetchData would collapse it to one small
|
|
589
|
+
// pagination page). Classic mode keeps the pagination-driven refetch.
|
|
590
|
+
if (infiniteScroll) {
|
|
591
|
+
infPageRef.current = 1
|
|
592
|
+
void fetchPage(1, false)
|
|
593
|
+
} else {
|
|
594
|
+
fetchData()
|
|
595
|
+
}
|
|
596
|
+
fetchAggregates()
|
|
597
|
+
}, [infiniteScroll, fetchPage, fetchData, fetchAggregates])
|
|
492
598
|
|
|
493
599
|
// Per-row action dispatch (view/edit/delete/link/custom) + its dialogs live
|
|
494
600
|
// in the shared hook so DynamicKanban's card menu behaves identically.
|
|
@@ -522,8 +628,8 @@ export function DynamicTable({
|
|
|
522
628
|
setBulkDeleteProgress(0)
|
|
523
629
|
setBulkDeleteTotal(0)
|
|
524
630
|
setRowSelection({})
|
|
525
|
-
if (successCount > 0) toast.success(
|
|
526
|
-
if (errorCount > 0) toast.error(
|
|
631
|
+
if (successCount > 0) toast.success(t('dynamic.bulk_delete_success', { count: successCount, defaultValue: '{{count}} registro(s) eliminado(s) correctamente' }))
|
|
632
|
+
if (errorCount > 0) toast.error(t('dynamic.bulk_delete_error', { count: errorCount, defaultValue: '{{count}} registro(s) no pudieron ser eliminados' }))
|
|
527
633
|
handleRefresh()
|
|
528
634
|
}
|
|
529
635
|
|
|
@@ -838,7 +944,7 @@ export function DynamicTable({
|
|
|
838
944
|
{/* Desktop: classic horizontal-scroll table. Hidden on phones —
|
|
839
945
|
a 7-column table forces a wide horizontal scroll there, so we
|
|
840
946
|
render a card-per-row list instead (see MobileCards below). */}
|
|
841
|
-
<div className='hidden sm:block flex-1 min-h-0 overflow-auto border rounded-md bg-card'>
|
|
947
|
+
<div ref={infDesktopRoot} className='hidden sm:block flex-1 min-h-0 overflow-auto border rounded-md bg-card'>
|
|
842
948
|
<Table noWrapper className={cn('min-w-max w-full', aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && 'h-full')}>
|
|
843
949
|
<TableHeader className='sticky top-0 z-10'>
|
|
844
950
|
{table.getHeaderGroups().map((headerGroup: HeaderGroup<any>) => (
|
|
@@ -954,12 +1060,25 @@ export function DynamicTable({
|
|
|
954
1060
|
</TableFooter>
|
|
955
1061
|
)}
|
|
956
1062
|
</Table>
|
|
1063
|
+
{infiniteScroll && (
|
|
1064
|
+
<>
|
|
1065
|
+
{loadingMore && (
|
|
1066
|
+
<div className='p-2'>
|
|
1067
|
+
<Skeleton className='h-8 w-full' data-testid='table-loading-more' />
|
|
1068
|
+
</div>
|
|
1069
|
+
)}
|
|
1070
|
+
{/* Sentinel stays mounted for the whole infinite-scroll
|
|
1071
|
+
session so its observer attaches once; `disabled`
|
|
1072
|
+
(no more pages / load in flight) gates the fetch. */}
|
|
1073
|
+
<div ref={infDesktopSentinel} className='h-1 w-full' aria-hidden />
|
|
1074
|
+
</>
|
|
1075
|
+
)}
|
|
957
1076
|
</div>
|
|
958
1077
|
|
|
959
1078
|
{/* Mobile: one card per row — no horizontal scroll. Each card
|
|
960
1079
|
stacks its columns as label : value pairs with the row actions
|
|
961
1080
|
pinned at the bottom. */}
|
|
962
|
-
<div className='flex flex-1 min-h-0 flex-col gap-2 overflow-y-auto sm:hidden'>
|
|
1081
|
+
<div ref={infMobileRoot} className='flex flex-1 min-h-0 flex-col gap-2 overflow-y-auto sm:hidden'>
|
|
963
1082
|
{loadingData && data.length === 0 ? (
|
|
964
1083
|
Array.from({ length: 5 }).map((_, i) => (
|
|
965
1084
|
<div key={i} className='rounded-lg border bg-card p-3'>
|
|
@@ -1014,13 +1133,33 @@ export function DynamicTable({
|
|
|
1014
1133
|
<p className='text-sm text-muted-foreground'>No hay datos para mostrar en este momento.</p>
|
|
1015
1134
|
</div>
|
|
1016
1135
|
)}
|
|
1136
|
+
{infiniteScroll && (
|
|
1137
|
+
<>
|
|
1138
|
+
{loadingMore && (
|
|
1139
|
+
<Skeleton className='h-16 w-full shrink-0' data-testid='table-loading-more-mobile' />
|
|
1140
|
+
)}
|
|
1141
|
+
<div ref={infMobileSentinel} className='h-1 w-full shrink-0' aria-hidden />
|
|
1142
|
+
</>
|
|
1143
|
+
)}
|
|
1017
1144
|
</div>
|
|
1018
1145
|
|
|
1019
1146
|
<div className='shrink-0 pt-4'>
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1147
|
+
{infiniteScroll ? (
|
|
1148
|
+
data.length > 0 && (
|
|
1149
|
+
<p className='text-center text-xs text-muted-foreground tabular-nums'>
|
|
1150
|
+
{t('common.showingCount', {
|
|
1151
|
+
defaultValue: '{{count}} de {{total}}',
|
|
1152
|
+
count: data.length,
|
|
1153
|
+
total: rowCount,
|
|
1154
|
+
})}
|
|
1155
|
+
</p>
|
|
1156
|
+
)
|
|
1157
|
+
) : (
|
|
1158
|
+
<DataTablePagination
|
|
1159
|
+
table={table}
|
|
1160
|
+
pageSizeOptions={metadata.perPageOptions}
|
|
1161
|
+
/>
|
|
1162
|
+
)}
|
|
1024
1163
|
</div>
|
|
1025
1164
|
</div>
|
|
1026
1165
|
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Shared incremental-loading primitives for DynamicTable and DynamicKanban.
|
|
2
|
+
// - `dedupeById` appends a page of rows to the accumulated set, dropping any
|
|
3
|
+
// id already present (the server may re-paginate / overlap between a global
|
|
4
|
+
// page and a scoped top-up).
|
|
5
|
+
// - `useInfiniteScrollSentinel` wires an IntersectionObserver: attach
|
|
6
|
+
// `rootRef` to the scroll container and `sentinelRef` to a sentinel at its
|
|
7
|
+
// bottom; when the sentinel enters view (and it's enabled) `onLoadMore`
|
|
8
|
+
// fires. Degrades to a no-op where IntersectionObserver is unavailable
|
|
9
|
+
// (older/SSR/happy-dom) so callers still render.
|
|
10
|
+
import { useEffect, useRef } from 'react'
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Returns a NEW array = `existing` followed by every row in `incoming` whose
|
|
14
|
+
* `id` is not already present. Stable on identity/order of `existing`. Pure.
|
|
15
|
+
*/
|
|
16
|
+
export function dedupeById<T extends { id?: any }>(
|
|
17
|
+
existing: T[],
|
|
18
|
+
incoming: T[],
|
|
19
|
+
): T[] {
|
|
20
|
+
if (incoming.length === 0) return existing
|
|
21
|
+
const seen = new Set(existing.map((r) => String(r?.id)))
|
|
22
|
+
const additions: T[] = []
|
|
23
|
+
for (const row of incoming) {
|
|
24
|
+
const key = String(row?.id)
|
|
25
|
+
if (seen.has(key)) continue
|
|
26
|
+
seen.add(key)
|
|
27
|
+
additions.push(row)
|
|
28
|
+
}
|
|
29
|
+
return additions.length === 0 ? existing : [...existing, ...additions]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface UseInfiniteScrollOptions {
|
|
33
|
+
/** Fired when the sentinel scrolls into view and loading is enabled. */
|
|
34
|
+
onLoadMore: () => void
|
|
35
|
+
/** When true, the observer is inert (no more pages, or a load in flight). */
|
|
36
|
+
disabled?: boolean
|
|
37
|
+
/** Pixels of pre-fetch margin below the viewport. Default 200. */
|
|
38
|
+
rootMargin?: number
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface InfiniteScrollRefs<
|
|
42
|
+
R extends HTMLElement = HTMLDivElement,
|
|
43
|
+
S extends HTMLElement = HTMLDivElement,
|
|
44
|
+
> {
|
|
45
|
+
rootRef: React.RefObject<R | null>
|
|
46
|
+
sentinelRef: React.RefObject<S | null>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* IntersectionObserver-backed infinite scroll. Attach `rootRef` to the
|
|
51
|
+
* scrollable container and `sentinelRef` to a small element at its bottom. The
|
|
52
|
+
* latest `onLoadMore`/`disabled` are read through a ref so the observer isn't
|
|
53
|
+
* torn down and rebuilt on every render.
|
|
54
|
+
*/
|
|
55
|
+
export function useInfiniteScrollSentinel<
|
|
56
|
+
R extends HTMLElement = HTMLDivElement,
|
|
57
|
+
S extends HTMLElement = HTMLDivElement,
|
|
58
|
+
>({
|
|
59
|
+
onLoadMore,
|
|
60
|
+
disabled = false,
|
|
61
|
+
rootMargin = 200,
|
|
62
|
+
}: UseInfiniteScrollOptions): InfiniteScrollRefs<R, S> {
|
|
63
|
+
const rootRef = useRef<R | null>(null)
|
|
64
|
+
const sentinelRef = useRef<S | null>(null)
|
|
65
|
+
const cb = useRef(onLoadMore)
|
|
66
|
+
const disabledRef = useRef(disabled)
|
|
67
|
+
cb.current = onLoadMore
|
|
68
|
+
disabledRef.current = disabled
|
|
69
|
+
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
const sentinel = sentinelRef.current
|
|
72
|
+
if (
|
|
73
|
+
!sentinel ||
|
|
74
|
+
typeof IntersectionObserver === 'undefined'
|
|
75
|
+
) {
|
|
76
|
+
return
|
|
77
|
+
}
|
|
78
|
+
const observer = new IntersectionObserver(
|
|
79
|
+
(entries) => {
|
|
80
|
+
for (const entry of entries) {
|
|
81
|
+
if (entry.isIntersecting && !disabledRef.current) {
|
|
82
|
+
cb.current()
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
},
|
|
86
|
+
{ root: rootRef.current ?? null, rootMargin: `0px 0px ${rootMargin}px 0px` },
|
|
87
|
+
)
|
|
88
|
+
observer.observe(sentinel)
|
|
89
|
+
return () => observer.disconnect()
|
|
90
|
+
// Re-create only when the margin changes; onLoadMore/disabled are read live.
|
|
91
|
+
}, [rootMargin])
|
|
92
|
+
|
|
93
|
+
return { rootRef, sentinelRef }
|
|
94
|
+
}
|