@asteby/metacore-runtime-react 23.0.0 → 23.2.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 +64 -0
- package/dist/dynamic-kanban.d.ts +28 -19
- package/dist/dynamic-kanban.d.ts.map +1 -1
- package/dist/dynamic-kanban.js +134 -82
- package/dist/dynamic-table.d.ts +9 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +189 -36
- package/dist/filter-chips.d.ts +60 -0
- package/dist/filter-chips.d.ts.map +1 -0
- package/dist/filter-chips.js +93 -0
- 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 +4 -4
- package/src/__tests__/dynamic-kanban-infinite-scroll.test.tsx +210 -0
- package/src/__tests__/dynamic-kanban.test.tsx +40 -0
- package/src/__tests__/dynamic-table-filters.test.tsx +102 -0
- package/src/__tests__/dynamic-table-infinite-scroll.test.tsx +196 -0
- package/src/__tests__/filter-chips.test.tsx +119 -0
- package/src/__tests__/use-infinite-scroll.test.tsx +147 -0
- package/src/dynamic-kanban.tsx +210 -149
- package/src/dynamic-table.tsx +216 -14
- package/src/filter-chips.tsx +175 -0
- package/src/use-infinite-scroll.ts +94 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,69 @@
|
|
|
1
1
|
# @asteby/metacore-runtime-react
|
|
2
2
|
|
|
3
|
+
## 23.2.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 68a6844: Scroll infinito con carga incremental en DynamicKanban y DynamicTable,
|
|
8
|
+
respetando la búsqueda y los filtros activos. Cero cambios de backend: se apoya
|
|
9
|
+
en el `page`/`per_page` que ya expone `/data/:model`.
|
|
10
|
+
- **Primitivos compartidos (`use-infinite-scroll`):** `dedupeById` (append puro
|
|
11
|
+
que descarta ids ya presentes, estable en identidad cuando no hay altas) y
|
|
12
|
+
`useInfiniteScrollSentinel` (IntersectionObserver sobre un sentinel; lee
|
|
13
|
+
`onLoadMore`/`disabled` por ref para no recrear el observer en cada render;
|
|
14
|
+
degrada a no-op donde no hay IntersectionObserver). Los usan ambas vistas.
|
|
15
|
+
- **Kanban incremental por lane:** una página global inicial (`pageSize`, 50 por
|
|
16
|
+
defecto) pinta el tablero agrupado —y captura naturalmente la lane
|
|
17
|
+
"sin asignar"— y luego cada lane rellena SU propia etapa al acercarse el
|
|
18
|
+
scroll al fondo (`f_<group_by>=<stage>&page=n&per_page=lanePageSize`, 25 por
|
|
19
|
+
defecto, sobre los filtros activos), con dedup por id y un skeleton chico al
|
|
20
|
+
fondo. El contador del header muestra el total real de la etapa cuando la
|
|
21
|
+
respuesta trae `meta.total` (`count/total`), si no el cargado. Cambiar
|
|
22
|
+
filtros/búsqueda resetea la paginación de todas las lanes. El drag&drop
|
|
23
|
+
optimista ajusta los totales de origen y destino (`applyLaneTotalsOnMove`)
|
|
24
|
+
para que las lanes parciales sigan mostrando un `count/total` veraz, y los
|
|
25
|
+
revierte si el PUT falla.
|
|
26
|
+
- **Tabla opt-in (`infiniteScroll?: boolean`, default `false`):** la paginación
|
|
27
|
+
clásica queda intacta salvo que se active. Con el flag, un sentinel al fondo
|
|
28
|
+
del contenedor de scroll pide la siguiente página y APPENDEA filas (dedup por
|
|
29
|
+
id); el pager clásico se reemplaza por un indicador "N de total". Cambiar
|
|
30
|
+
filtros/orden/búsqueda resetea a la página 1 y limpia el acumulado. El footer
|
|
31
|
+
de totales (`/aggregate`) no cambia.
|
|
32
|
+
|
|
33
|
+
### Patch Changes
|
|
34
|
+
|
|
35
|
+
- Updated dependencies [68a6844]
|
|
36
|
+
- @asteby/metacore-ui@2.9.1
|
|
37
|
+
|
|
38
|
+
## 23.1.0
|
|
39
|
+
|
|
40
|
+
### Minor Changes
|
|
41
|
+
|
|
42
|
+
- 08e18bf: Paridad de la experiencia pro de filtros en DynamicTable (vista tabla de los
|
|
43
|
+
módulos dinámicos), igualando al kanban.
|
|
44
|
+
- **Prefetch de facetas en la tabla:** al resolver la metadata se precargan en
|
|
45
|
+
paralelo (dedup por firma, `allSettled`) las facetas de todos los campos facet,
|
|
46
|
+
sembrando sus `options`. Abrir el filtro de una columna de texto en el header
|
|
47
|
+
ya muestra el combobox con valores + counts al instante, sin "Cargando…"; el
|
|
48
|
+
spinner queda solo para el refetch con búsqueda.
|
|
49
|
+
- **i18n de opciones en la tabla:** las opciones (stages y cualquier key i18n del
|
|
50
|
+
manifest) se traducen con `t(label, {defaultValue})` en runtime-react antes de
|
|
51
|
+
pasarlas al ui package, tanto en los filtros de header como en los chips y
|
|
52
|
+
resúmenes de valor.
|
|
53
|
+
- **Fila de chips de filtros activos sobre la tabla:** debajo de la toolbar,
|
|
54
|
+
removibles ("Campo: valor(es) ×" + "Limpiar todo"), con el color del valor
|
|
55
|
+
cuando aplica (p.ej. la etapa). Extraído a un componente compartido
|
|
56
|
+
`FilterChipsRow` (con `summarizeFilterValues`/`chipValueColor`/
|
|
57
|
+
`translateOptionLabels`) reusado por el kanban y la tabla — sin duplicar.
|
|
58
|
+
- **Stage-select en la tabla:** la columna `group_by`/stage sin opciones propias
|
|
59
|
+
ofrece el select con las etapas del pipeline (traducidas, con color), no un
|
|
60
|
+
cuadro de texto — sale del motor compartido; confirmado con test.
|
|
61
|
+
- **Header de lane del kanban:** los botones de búsqueda y embudo se ven SIEMPRE
|
|
62
|
+
(se quitó el hover-reveal, que nadie descubría) en muted con hover a foreground.
|
|
63
|
+
El embudo muestra un badge numérico con la cantidad de valores/filtros
|
|
64
|
+
aplicados en esa lane (como el badge del botón Filtros de la toolbar); la lupa
|
|
65
|
+
tiñe a primary + dot cuando hay búsqueda activa.
|
|
66
|
+
|
|
3
67
|
## 23.0.0
|
|
4
68
|
|
|
5
69
|
### Patch Changes
|
package/dist/dynamic-kanban.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import * as React from 'react';
|
|
2
|
+
import { summarizeFilterValues, translateOptionLabels } from './filter-chips';
|
|
2
3
|
import type { TableMetadata, ColumnDefinition, StageMeta, StageTransition } from './types';
|
|
4
|
+
export { summarizeFilterValues, translateOptionLabels };
|
|
3
5
|
/**
|
|
4
6
|
* Resolves the board lanes for a kanban view. Prefers the model-level
|
|
5
7
|
* `metadata.stages` (the kernel's `stages[]`); falls back to the `group_by`
|
|
@@ -30,6 +32,16 @@ export declare function isTransitionAllowed(transitions: StageTransition[] | und
|
|
|
30
32
|
* resolves, and so the previous grouping can be restored on failure.
|
|
31
33
|
*/
|
|
32
34
|
export declare function applyOptimisticMove(grouped: Map<string, any[]>, cardId: string | number, fromStage: string, toStage: string, groupByKey: string): Map<string, any[]>;
|
|
35
|
+
/**
|
|
36
|
+
* Returns a NEW per-lane pagination map with the server totals adjusted for a
|
|
37
|
+
* card moving `fromStage` → `toStage`: the source loses one, the destination
|
|
38
|
+
* gains one. Lanes whose `total` is still unknown (`null`, not yet topped up)
|
|
39
|
+
* are left alone. Pure — backs the optimistic drag so a partial lane's
|
|
40
|
+
* `count/total` header stays truthful, and can be restored on PUT failure.
|
|
41
|
+
*/
|
|
42
|
+
export declare function applyLaneTotalsOnMove<T extends {
|
|
43
|
+
total: number | null;
|
|
44
|
+
}>(pagination: Record<string, T>, fromStage: string, toStage: string): Record<string, T>;
|
|
33
45
|
/**
|
|
34
46
|
* Picks the columns shown on a card: a `title` column (first searchable column,
|
|
35
47
|
* else first text-ish column) and up to `maxFields` secondary columns. Excludes
|
|
@@ -40,16 +52,6 @@ export declare function selectCardColumns(metadata: TableMetadata, maxFields?: n
|
|
|
40
52
|
title: ColumnDefinition | null;
|
|
41
53
|
fields: ColumnDefinition[];
|
|
42
54
|
};
|
|
43
|
-
/**
|
|
44
|
-
* Human-readable summary of a field's selected filter values for the removable
|
|
45
|
-
* chip row. Resolves option labels, unwraps the wire operators
|
|
46
|
-
* (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
|
|
47
|
-
* `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
|
|
48
|
-
*/
|
|
49
|
-
export declare function summarizeFilterValues(values: string[] | undefined, options: {
|
|
50
|
-
label: string;
|
|
51
|
-
value: string;
|
|
52
|
-
}[] | undefined, maxShown?: number): string;
|
|
53
55
|
/**
|
|
54
56
|
* Whether a card passes a lane funnel. Picked select/facet `values` match by
|
|
55
57
|
* equality (IN — the card's field value must be one of them); a free-text
|
|
@@ -62,13 +64,14 @@ export declare function cardMatchesLaneFunnel(card: any, filter: {
|
|
|
62
64
|
text?: string;
|
|
63
65
|
} | undefined): boolean;
|
|
64
66
|
/**
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
67
|
+
* Count of applied criteria on a lane funnel: the number of picked select/facet
|
|
68
|
+
* `values`, else 1 for a free-text `text`, else 0. Drives the funnel's count
|
|
69
|
+
* badge. Pure — exported for unit tests.
|
|
68
70
|
*/
|
|
69
|
-
export declare function
|
|
70
|
-
|
|
71
|
-
|
|
71
|
+
export declare function laneFunnelCount(value: {
|
|
72
|
+
values?: string[];
|
|
73
|
+
text?: string;
|
|
74
|
+
} | undefined): number;
|
|
72
75
|
/**
|
|
73
76
|
* Whether a card matches a free-text lane search: a case-insensitive substring
|
|
74
77
|
* over the card's title + every visible field value (`String(v)`). Empty query
|
|
@@ -95,10 +98,16 @@ export interface DynamicKanbanProps {
|
|
|
95
98
|
*/
|
|
96
99
|
onAction?: (action: string, row: any) => void;
|
|
97
100
|
/**
|
|
98
|
-
*
|
|
99
|
-
*
|
|
101
|
+
* Size of the INITIAL board page (one request, grouped into lanes). Each
|
|
102
|
+
* lane then tops up incrementally on scroll (see `lanePageSize`). Defaults
|
|
103
|
+
* to 50 — enough to fill the visible lanes without loading the whole board.
|
|
100
104
|
*/
|
|
101
105
|
pageSize?: number;
|
|
106
|
+
/**
|
|
107
|
+
* Page size for a lane's incremental top-up fetch (scoped by
|
|
108
|
+
* `f_<group_by>=<stage>`). Defaults to 25.
|
|
109
|
+
*/
|
|
110
|
+
lanePageSize?: number;
|
|
102
111
|
/** IANA timezone for datetime card fields (org config). */
|
|
103
112
|
timeZone?: string;
|
|
104
113
|
/** ISO 4217 currency for money card fields (org config). */
|
|
@@ -109,5 +118,5 @@ export interface DynamicKanbanProps {
|
|
|
109
118
|
*/
|
|
110
119
|
defaultFilters?: Record<string, any>;
|
|
111
120
|
}
|
|
112
|
-
export declare function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, onAction, pageSize, timeZone, currency, defaultFilters, }: DynamicKanbanProps): React.JSX.Element;
|
|
121
|
+
export declare function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, onAction, pageSize, lanePageSize, timeZone, currency, defaultFilters, }: DynamicKanbanProps): React.JSX.Element;
|
|
113
122
|
//# sourceMappingURL=dynamic-kanban.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"AA0BA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;
|
|
1
|
+
{"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"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"}
|
package/dist/dynamic-kanban.js
CHANGED
|
@@ -6,9 +6,11 @@ import { Calendar, CircleDot, Hash, ListFilter, MoreHorizontal, Search, Tag, Tog
|
|
|
6
6
|
import { toast } from 'sonner';
|
|
7
7
|
import { Badge, Button, Card, CardContent, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, Input, Popover, PopoverContent, PopoverTrigger, Select, SelectContent, SelectItem, SelectTrigger, SelectValue, Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger, Skeleton, } from '@asteby/metacore-ui/primitives';
|
|
8
8
|
import { ColumnFilterControl, FilterValueCombobox } from '@asteby/metacore-ui/data-table';
|
|
9
|
-
import { generateBadgeStyles, optionColor
|
|
9
|
+
import { generateBadgeStyles, optionColor } from '@asteby/metacore-ui/lib';
|
|
10
10
|
import { useApi } from './api-context';
|
|
11
11
|
import { useDynamicFilters } from './use-dynamic-filters';
|
|
12
|
+
import { FilterChipsRow, summarizeFilterValues, translateOptionLabels, } from './filter-chips';
|
|
13
|
+
import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll';
|
|
12
14
|
import { useMetadataCache } from './metadata-cache';
|
|
13
15
|
import { ActivityValueRenderer } from './activity-value-renderer';
|
|
14
16
|
import { DynamicIcon } from './dynamic-icon';
|
|
@@ -16,6 +18,9 @@ import { isColumnVisibleInTable } from './column-visibility';
|
|
|
16
18
|
import { isRowActionVisible } from './dynamic-columns';
|
|
17
19
|
import { useCan, usePermissionsActive, resolveRowActions } from './permissions-context';
|
|
18
20
|
import { useDynamicRowActions } from './dynamic-row-actions';
|
|
21
|
+
// Re-exported for tests + backward-compat: these live in ./filter-chips now
|
|
22
|
+
// (shared with DynamicTable) but were historically imported from here.
|
|
23
|
+
export { summarizeFilterValues, translateOptionLabels };
|
|
19
24
|
// ---------------------------------------------------------------------------
|
|
20
25
|
// Pure helpers (exported for unit tests — no React, no transport)
|
|
21
26
|
// ---------------------------------------------------------------------------
|
|
@@ -108,6 +113,25 @@ export function applyOptimisticMove(grouped, cardId, fromStage, toStage, groupBy
|
|
|
108
113
|
next.set(toStage, toRows);
|
|
109
114
|
return next;
|
|
110
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Returns a NEW per-lane pagination map with the server totals adjusted for a
|
|
118
|
+
* card moving `fromStage` → `toStage`: the source loses one, the destination
|
|
119
|
+
* gains one. Lanes whose `total` is still unknown (`null`, not yet topped up)
|
|
120
|
+
* are left alone. Pure — backs the optimistic drag so a partial lane's
|
|
121
|
+
* `count/total` header stays truthful, and can be restored on PUT failure.
|
|
122
|
+
*/
|
|
123
|
+
export function applyLaneTotalsOnMove(pagination, fromStage, toStage) {
|
|
124
|
+
const next = { ...pagination };
|
|
125
|
+
const bump = (key, delta) => {
|
|
126
|
+
const st = next[key];
|
|
127
|
+
if (st && st.total != null) {
|
|
128
|
+
next[key] = { ...st, total: Math.max(0, st.total + delta) };
|
|
129
|
+
}
|
|
130
|
+
};
|
|
131
|
+
bump(fromStage, -1);
|
|
132
|
+
bump(toStage, +1);
|
|
133
|
+
return next;
|
|
134
|
+
}
|
|
111
135
|
/**
|
|
112
136
|
* Picks the columns shown on a card: a `title` column (first searchable column,
|
|
113
137
|
* else first text-ish column) and up to `maxFields` secondary columns. Excludes
|
|
@@ -129,64 +153,6 @@ export function selectCardColumns(metadata, maxFields = 3) {
|
|
|
129
153
|
.slice(0, maxFields);
|
|
130
154
|
return { title, fields };
|
|
131
155
|
}
|
|
132
|
-
/**
|
|
133
|
-
* Human-readable summary of a field's selected filter values for the removable
|
|
134
|
-
* chip row. Resolves option labels, unwraps the wire operators
|
|
135
|
-
* (`IN:`/`ILIKE:`/`RANGE:`/`GTE:`/`LTE:`/date `from_to`), and caps the list at
|
|
136
|
-
* `maxShown` values with a `+n` overflow. Pure — exported for unit tests.
|
|
137
|
-
*/
|
|
138
|
-
export function summarizeFilterValues(values, options, maxShown = 2) {
|
|
139
|
-
if (!values || values.length === 0)
|
|
140
|
-
return '';
|
|
141
|
-
const opts = options ?? [];
|
|
142
|
-
const labelFor = (v) => opts.find((o) => o.value === v)?.label ?? v;
|
|
143
|
-
const first = values[0];
|
|
144
|
-
if (values.length === 1) {
|
|
145
|
-
if (first.startsWith('ILIKE:'))
|
|
146
|
-
return `"${first.slice(6)}"`;
|
|
147
|
-
if (first.startsWith('IN:')) {
|
|
148
|
-
return summarizeList(first.slice(3).split(','), labelFor, maxShown);
|
|
149
|
-
}
|
|
150
|
-
if (first.startsWith('RANGE:')) {
|
|
151
|
-
const [min, max] = first.slice(6).split(',');
|
|
152
|
-
return `${min || '…'} – ${max || '…'}`;
|
|
153
|
-
}
|
|
154
|
-
if (/^\d{4}-\d{2}-\d{2}_/.test(first))
|
|
155
|
-
return first.replace('_', ' – ');
|
|
156
|
-
}
|
|
157
|
-
if (first.startsWith('GTE:') || first.startsWith('LTE:')) {
|
|
158
|
-
const min = values.find((v) => v.startsWith('GTE:'))?.slice(4) ?? '';
|
|
159
|
-
const max = values.find((v) => v.startsWith('LTE:'))?.slice(4) ?? '';
|
|
160
|
-
return `${min || '…'} – ${max || '…'}`;
|
|
161
|
-
}
|
|
162
|
-
return summarizeList(values, labelFor, maxShown);
|
|
163
|
-
}
|
|
164
|
-
function summarizeList(items, labelFor, maxShown) {
|
|
165
|
-
const labels = items.map(labelFor);
|
|
166
|
-
if (labels.length <= maxShown)
|
|
167
|
-
return labels.join(', ');
|
|
168
|
-
return `${labels.slice(0, maxShown).join(', ')} +${labels.length - maxShown}`;
|
|
169
|
-
}
|
|
170
|
-
/**
|
|
171
|
-
* The color of a filter's first selected value (for the chip's dot) — e.g. a
|
|
172
|
-
* stage's palette color. Returns a resolved CSS color, or undefined for
|
|
173
|
-
* operator/range/free-text values that carry no option color.
|
|
174
|
-
*/
|
|
175
|
-
function chipValueColor(config) {
|
|
176
|
-
const sel = config.selectedValues;
|
|
177
|
-
if (!sel || sel.length === 0)
|
|
178
|
-
return undefined;
|
|
179
|
-
const first = sel[0];
|
|
180
|
-
let value = first;
|
|
181
|
-
if (first.startsWith('IN:'))
|
|
182
|
-
value = first.slice(3).split(',')[0];
|
|
183
|
-
else if (/^(ILIKE|RANGE|GTE|LTE):/.test(first))
|
|
184
|
-
return undefined;
|
|
185
|
-
else if (/^\d{4}-\d{2}-\d{2}_/.test(first))
|
|
186
|
-
return undefined;
|
|
187
|
-
const opt = config.options.find((o) => o.value === value);
|
|
188
|
-
return opt?.color ? resolveColorCss(opt.color) : undefined;
|
|
189
|
-
}
|
|
190
156
|
/**
|
|
191
157
|
* Whether a card passes a lane funnel. Picked select/facet `values` match by
|
|
192
158
|
* equality (IN — the card's field value must be one of them); a free-text
|
|
@@ -206,12 +172,16 @@ export function cardMatchesLaneFunnel(card, filter) {
|
|
|
206
172
|
return true;
|
|
207
173
|
}
|
|
208
174
|
/**
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
*
|
|
175
|
+
* Count of applied criteria on a lane funnel: the number of picked select/facet
|
|
176
|
+
* `values`, else 1 for a free-text `text`, else 0. Drives the funnel's count
|
|
177
|
+
* badge. Pure — exported for unit tests.
|
|
212
178
|
*/
|
|
213
|
-
export function
|
|
214
|
-
|
|
179
|
+
export function laneFunnelCount(value) {
|
|
180
|
+
if (value?.values?.length)
|
|
181
|
+
return value.values.length;
|
|
182
|
+
if (value?.text?.trim())
|
|
183
|
+
return 1;
|
|
184
|
+
return 0;
|
|
215
185
|
}
|
|
216
186
|
/**
|
|
217
187
|
* Whether a card matches a free-text lane search: a case-insensitive substring
|
|
@@ -245,7 +215,7 @@ function useIsDarkTheme() {
|
|
|
245
215
|
}, []);
|
|
246
216
|
return isDark;
|
|
247
217
|
}
|
|
248
|
-
export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, onAction, pageSize =
|
|
218
|
+
export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, onAction, pageSize = 50, lanePageSize = 25, timeZone, currency, defaultFilters, }) {
|
|
249
219
|
const { t, i18n } = useTranslation();
|
|
250
220
|
const api = useApi();
|
|
251
221
|
const isDark = useIsDarkTheme();
|
|
@@ -255,6 +225,12 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
255
225
|
const [records, setRecords] = useState([]);
|
|
256
226
|
const [loading, setLoading] = useState(!cachedMeta);
|
|
257
227
|
const [loadingData, setLoadingData] = useState(true);
|
|
228
|
+
// Per-stage incremental pagination for infinite scroll. The initial board
|
|
229
|
+
// page (grouped into lanes) is fetched once; each lane then tops up its OWN
|
|
230
|
+
// stage via `f_<group_by>=<stage>&page=n`, appended (deduped by id) into the
|
|
231
|
+
// shared `records`. `total` is the stage's server count when the response
|
|
232
|
+
// meta carries it. Reset whenever the filters/search change.
|
|
233
|
+
const [lanePagination, setLanePagination] = useState({});
|
|
258
234
|
// Active drag card id (for the DragOverlay + drop-zone highlighting).
|
|
259
235
|
const [activeId, setActiveId] = useState(null);
|
|
260
236
|
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 5 } }));
|
|
@@ -297,7 +273,10 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
297
273
|
// and `f_<key>` serialization DynamicTable uses, so the board filters
|
|
298
274
|
// identically to its table sibling.
|
|
299
275
|
const { dynamicFilters, globalFilter, setGlobalFilter, columnFilterConfigs, filterParams, activeFilterCount, handleDynamicFilterChange, clearAll, } = useDynamicFilters(metadata, { defaultFilters, model, endpoint });
|
|
300
|
-
// ----
|
|
276
|
+
// ---- initial board page (one request, grouped into lanes) ----
|
|
277
|
+
// Resets the per-lane pagination so every lane restarts its incremental
|
|
278
|
+
// top-up from scratch — called on mount, refresh, and any filter/search
|
|
279
|
+
// change (fetchData's identity changes with filterParams).
|
|
301
280
|
const fetchData = useCallback(async () => {
|
|
302
281
|
if (!metadata)
|
|
303
282
|
return;
|
|
@@ -315,7 +294,64 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
315
294
|
finally {
|
|
316
295
|
setLoadingData(false);
|
|
317
296
|
}
|
|
297
|
+
setLanePagination({});
|
|
318
298
|
}, [api, endpoint, model, metadata, pageSize, filterParams]);
|
|
299
|
+
// Load the next page for ONE lane/stage and append it (deduped by id) into
|
|
300
|
+
// the shared records. Scoped by `f_<group_by>=<stage>` on top of the active
|
|
301
|
+
// filterParams (the stage scope wins over any global group_by filter).
|
|
302
|
+
const groupByKey = metadata?.group_by || '';
|
|
303
|
+
const loadMoreLane = useCallback(async (stageKey) => {
|
|
304
|
+
if (!metadata || !groupByKey)
|
|
305
|
+
return;
|
|
306
|
+
const current = lanePagination[stageKey];
|
|
307
|
+
if (current?.loading || current?.done)
|
|
308
|
+
return;
|
|
309
|
+
const nextPage = current?.nextPage ?? 1;
|
|
310
|
+
setLanePagination((p) => ({
|
|
311
|
+
...p,
|
|
312
|
+
[stageKey]: {
|
|
313
|
+
nextPage,
|
|
314
|
+
total: current?.total ?? null,
|
|
315
|
+
loading: true,
|
|
316
|
+
done: false,
|
|
317
|
+
},
|
|
318
|
+
}));
|
|
319
|
+
try {
|
|
320
|
+
const res = (await api.get(endpoint || `/data/${model}`, {
|
|
321
|
+
params: {
|
|
322
|
+
...filterParams,
|
|
323
|
+
page: nextPage,
|
|
324
|
+
per_page: lanePageSize,
|
|
325
|
+
[`f_${groupByKey}`]: stageKey,
|
|
326
|
+
},
|
|
327
|
+
}));
|
|
328
|
+
const rows = res.data.success ? res.data.data || [] : [];
|
|
329
|
+
const total = res.data.meta?.total ?? res.data.meta?.count ?? null;
|
|
330
|
+
setRecords((prev) => dedupeById(prev, rows));
|
|
331
|
+
setLanePagination((p) => ({
|
|
332
|
+
...p,
|
|
333
|
+
[stageKey]: {
|
|
334
|
+
nextPage: nextPage + 1,
|
|
335
|
+
total,
|
|
336
|
+
loading: false,
|
|
337
|
+
// Exhausted when the server returned a short page.
|
|
338
|
+
done: rows.length < lanePageSize,
|
|
339
|
+
},
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
342
|
+
catch (err) {
|
|
343
|
+
console.error(`Error al cargar más tarjetas de ${stageKey}`, err);
|
|
344
|
+
setLanePagination((p) => ({
|
|
345
|
+
...p,
|
|
346
|
+
[stageKey]: {
|
|
347
|
+
nextPage,
|
|
348
|
+
total: current?.total ?? null,
|
|
349
|
+
loading: false,
|
|
350
|
+
done: false,
|
|
351
|
+
},
|
|
352
|
+
}));
|
|
353
|
+
}
|
|
354
|
+
}, [api, endpoint, model, metadata, groupByKey, filterParams, lanePageSize, lanePagination]);
|
|
319
355
|
// Refetch when metadata resolves, on an explicit refresh, or when the
|
|
320
356
|
// filters change. `fetchData` is stable while `filterParams` is unchanged
|
|
321
357
|
// (both memoized), so this only re-runs on real input changes. Debounced so
|
|
@@ -398,7 +434,6 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
398
434
|
});
|
|
399
435
|
}, []);
|
|
400
436
|
const stages = useMemo(() => (metadata ? deriveStages(metadata) : []), [metadata]);
|
|
401
|
-
const groupByKey = metadata?.group_by || '';
|
|
402
437
|
const transitions = metadata?.transitions;
|
|
403
438
|
const grouped = useMemo(() => groupByStage(records, groupByKey, stages), [records, groupByKey, stages]);
|
|
404
439
|
const { title: titleCol, fields: fieldCols } = useMemo(() => (metadata ? selectCardColumns(metadata) : { title: null, fields: [] }), [metadata]);
|
|
@@ -455,7 +490,12 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
455
490
|
}
|
|
456
491
|
// OPTIMISTIC: move the card in local state immediately.
|
|
457
492
|
const prevRecords = records;
|
|
493
|
+
const prevPagination = lanePagination;
|
|
458
494
|
setRecords((rs) => rs.map((r) => String(r.id) === cardId ? { ...r, [groupByKey]: destStage } : r));
|
|
495
|
+
// Keep the server totals consistent with the moved card so a lane's
|
|
496
|
+
// `count/total` header stays truthful with partial lanes: one leaves
|
|
497
|
+
// the source stage, one joins the destination.
|
|
498
|
+
setLanePagination((p) => applyLaneTotalsOnMove(p, srcStage, destStage));
|
|
459
499
|
try {
|
|
460
500
|
const base = endpoint || `/data/${model}`;
|
|
461
501
|
// `base` is the org-scoped list endpoint (e.g. `/data/<model>/me`),
|
|
@@ -472,6 +512,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
472
512
|
catch (err) {
|
|
473
513
|
// REVERT + toast on failure.
|
|
474
514
|
setRecords(prevRecords);
|
|
515
|
+
setLanePagination(prevPagination);
|
|
475
516
|
toast.error(t('kanban.moveFailed', {
|
|
476
517
|
defaultValue: 'No se pudo mover la tarjeta',
|
|
477
518
|
}) +
|
|
@@ -479,7 +520,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
479
520
|
? `: ${err.response.data.message}`
|
|
480
521
|
: ''));
|
|
481
522
|
}
|
|
482
|
-
}, [api, endpoint, groupByKey, model, records, stageOfCard, t, transitions]);
|
|
523
|
+
}, [api, endpoint, groupByKey, lanePagination, model, records, stageOfCard, t, transitions]);
|
|
483
524
|
if (loading) {
|
|
484
525
|
return (_jsx("div", { className: "flex gap-4 overflow-x-auto p-1", children: [0, 1, 2, 3].map((i) => (_jsxs("div", { className: "w-[300px] shrink-0 space-y-3", children: [_jsx(Skeleton, { className: "h-8 w-full" }), _jsx(Skeleton, { className: "h-24 w-full" }), _jsx(Skeleton, { className: "h-24 w-full" })] }, i))) }));
|
|
485
526
|
}
|
|
@@ -508,13 +549,7 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
508
549
|
})
|
|
509
550
|
: t('kanban.noActiveFilters', {
|
|
510
551
|
defaultValue: 'Sin filtros',
|
|
511
|
-
}) }), _jsxs(Button, { variant: "ghost", size: "sm", className: "h-7 gap-1 text-xs", onClick: clearAll, disabled: activeFilterCount === 0, children: [_jsx(X, { className: "h-3.5 w-3.5" }), t('kanban.clearAll', { defaultValue: 'Limpiar todo' }), activeFilterCount > 0 ? ` (${activeFilterCount})` : ''] })] })] })] }))] }), activeFields
|
|
512
|
-
const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
|
|
513
|
-
const dot = chipValueColor(field.config);
|
|
514
|
-
return (_jsxs(Badge, { variant: "secondary", className: "h-6 gap-1.5 rounded-md pl-2 pr-1 text-xs font-normal", children: [dot && (_jsx("span", { className: "size-2 shrink-0 rounded-full", style: { backgroundColor: dot } })), _jsxs("span", { className: "font-medium", children: [field.label, ":"] }), _jsx("span", { className: "max-w-[180px] truncate text-muted-foreground", children: summary }), _jsx("button", { type: "button", onClick: () => field.config.onFilterChange(field.config.filterKey, []), className: "ml-0.5 rounded-sm p-0.5 transition-colors hover:bg-muted-foreground/20", "aria-label": t('kanban.removeFilter', {
|
|
515
|
-
defaultValue: 'Quitar filtro',
|
|
516
|
-
}), children: _jsx(X, { className: "h-3 w-3" }) })] }, field.key));
|
|
517
|
-
}), _jsx(Button, { variant: "ghost", size: "sm", className: "h-6 gap-1 px-2 text-xs text-muted-foreground", onClick: clearAll, children: t('kanban.clearAll', { defaultValue: 'Limpiar todo' }) })] })), _jsxs(DndContext, { sensors: sensors, onDragStart: onDragStart, onDragEnd: onDragEnd, children: [_jsx("div", { className: "flex min-w-0 gap-4 overflow-x-auto p-1", "data-testid": "kanban-board", children: lanes.map((stage) => {
|
|
552
|
+
}) }), _jsxs(Button, { variant: "ghost", size: "sm", className: "h-7 gap-1 text-xs", onClick: clearAll, disabled: activeFilterCount === 0, children: [_jsx(X, { className: "h-3.5 w-3.5" }), t('kanban.clearAll', { defaultValue: 'Limpiar todo' }), activeFilterCount > 0 ? ` (${activeFilterCount})` : ''] })] })] })] }))] }), _jsx(FilterChipsRow, { fields: activeFields, onClearAll: clearAll, "data-testid": "kanban-filter-chips" }), _jsxs(DndContext, { sensors: sensors, onDragStart: onDragStart, onDragEnd: onDragEnd, children: [_jsx("div", { className: "flex min-w-0 gap-4 overflow-x-auto p-1", "data-testid": "kanban-board", children: lanes.map((stage) => {
|
|
518
553
|
const allCards = grouped.get(stage.key) ?? [];
|
|
519
554
|
// Per-lane client-side narrowing (instant, scoped to this
|
|
520
555
|
// stage). The funnel (field/value) and the lane search
|
|
@@ -530,7 +565,12 @@ export function DynamicKanban({ model, endpoint, refreshTrigger, onCardClick, on
|
|
|
530
565
|
const droppableAllowed = !activeId ||
|
|
531
566
|
stage.key === activeStage ||
|
|
532
567
|
isTransitionAllowed(transitions, activeStage, stage.key);
|
|
533
|
-
|
|
568
|
+
// Infinite scroll is per declared stage; the synthetic
|
|
569
|
+
// "unassigned" lane can't be stage-scoped, so it never tops up.
|
|
570
|
+
const laneState = lanePagination[stage.key];
|
|
571
|
+
const isUnassigned = stage.key === UNASSIGNED_LANE;
|
|
572
|
+
const laneHasMore = !isUnassigned && !laneState?.done;
|
|
573
|
+
return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, serverTotal: laneState?.total ?? null, hasMore: laneHasMore, loadingMore: !!laneState?.loading, onLoadMore: () => loadMoreLane(stage.key), filterFields: filterFields, laneFilter: laneFilter, onFunnelChange: (f) => updateLaneFilter(stage.key, {
|
|
534
574
|
field: f?.field,
|
|
535
575
|
values: f?.values,
|
|
536
576
|
text: f?.text,
|
|
@@ -564,9 +604,15 @@ function SheetFilterRow({ field, isStage, }) {
|
|
|
564
604
|
const summary = summarizeFilterValues(field.config.selectedValues, field.config.options);
|
|
565
605
|
return (_jsx(ColumnFilterControl, { variant: "row", align: "end", icon: filterTypeIcon(field.config.filterType, isStage), label: field.label, valueSummary: summary, filterKey: field.config.filterKey, filterType: field.config.filterType, filterOptions: field.config.options, filterLoading: field.config.loading, filterSearchEndpoint: field.config.searchEndpoint, selectedValues: field.config.selectedValues, onFilterChange: field.config.onFilterChange, loadOptions: field.config.loadOptions }));
|
|
566
606
|
}
|
|
567
|
-
function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, disabled, children, }) {
|
|
607
|
+
function KanbanLane({ stage, count, totalCount, serverTotal, hasMore, loadingMore, onLoadMore, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, disabled, children, }) {
|
|
568
608
|
const { t } = useTranslation();
|
|
569
609
|
const { setNodeRef, isOver } = useDroppable({ id: stage.key, disabled });
|
|
610
|
+
// Infinite scroll: the sentinel lives at the bottom of the lane's own scroll
|
|
611
|
+
// container; a load in flight or an exhausted stage disables it.
|
|
612
|
+
const { rootRef, sentinelRef } = useInfiniteScrollSentinel({
|
|
613
|
+
onLoadMore,
|
|
614
|
+
disabled: !hasMore || loadingMore,
|
|
615
|
+
});
|
|
570
616
|
const headerStyle = generateBadgeStyles(stage.color || optionColor(stage.key), {
|
|
571
617
|
isDark,
|
|
572
618
|
});
|
|
@@ -602,9 +648,13 @@ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunn
|
|
|
602
648
|
opacity: dimmed ? 0.45 : 1,
|
|
603
649
|
outline: isOver && !disabled ? '2px solid var(--ring, #3b82f6)' : 'none',
|
|
604
650
|
outlineOffset: 2,
|
|
605
|
-
}, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: laneActive
|
|
651
|
+
}, "data-stage": stage.key, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "flex items-center justify-between gap-2 px-3 py-2.5", children: [_jsxs("div", { className: "flex min-w-0 items-center gap-2", children: [_jsx(Badge, { variant: "outline", className: "border-0 text-xs font-semibold", style: headerStyle, children: t(stage.label, { defaultValue: stage.label }) }), _jsx("span", { className: "text-xs font-medium tabular-nums text-muted-foreground", children: laneActive
|
|
652
|
+
? `${count}/${totalCount}`
|
|
653
|
+
: serverTotal != null
|
|
654
|
+
? `${count}/${serverTotal}`
|
|
655
|
+
: count })] }), _jsxs("div", { className: "flex items-center gap-0.5", children: [_jsxs("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${queryActive ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.searchLane', {
|
|
606
656
|
defaultValue: 'Buscar en la columna',
|
|
607
|
-
}), children: _jsx(Search, { className: "h-3.5 w-3.5" }) }), _jsx(LaneFilterButton, { fields: filterFields, value: funnelValue, onChange: onFunnelChange })] })] }), searchOpen && (_jsx("div", { className: "px-3 pb-1.5", children: _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { ref: searchRef, value: laneFilter?.query ?? '', onChange: (e) => onQueryChange(e.target.value), onKeyDown: (e) => {
|
|
657
|
+
}), children: [_jsx(Search, { className: "h-3.5 w-3.5" }), queryActive && (_jsx("span", { className: "absolute -right-0.5 -top-0.5 size-1.5 rounded-full bg-primary" }))] }), _jsx(LaneFilterButton, { fields: filterFields, value: funnelValue, onChange: onFunnelChange })] })] }), searchOpen && (_jsx("div", { className: "px-3 pb-1.5", children: _jsxs("div", { className: "relative", children: [_jsx(Search, { className: "pointer-events-none absolute left-2 top-1/2 h-3 w-3 -translate-y-1/2 text-muted-foreground" }), _jsx(Input, { ref: searchRef, value: laneFilter?.query ?? '', onChange: (e) => onQueryChange(e.target.value), onKeyDown: (e) => {
|
|
608
658
|
if (e.key === 'Escape') {
|
|
609
659
|
onQueryChange('');
|
|
610
660
|
setSearchOpen(false);
|
|
@@ -616,7 +666,7 @@ function KanbanLane({ stage, count, totalCount, filterFields, laneFilter, onFunn
|
|
|
616
666
|
defaultValue: 'Buscar tarjetas...',
|
|
617
667
|
}), className: "h-7 pl-7 text-xs" })] }) })), funnelActive && (_jsxs("div", { className: "flex items-center gap-1 px-3 pb-1.5 text-[11px] text-muted-foreground", children: [_jsx(ListFilter, { className: "h-3 w-3 shrink-0" }), _jsxs("span", { className: "truncate", children: [activeFieldLabel, ": ", funnelSummary] }), _jsx("button", { type: "button", onClick: () => onFunnelChange(null), className: "ml-auto rounded p-0.5 hover:bg-muted", "aria-label": t('kanban.clearFilters', {
|
|
618
668
|
defaultValue: 'Limpiar',
|
|
619
|
-
}), children: _jsx(X, { className: "h-3 w-3" }) })] })),
|
|
669
|
+
}), children: _jsx(X, { className: "h-3 w-3" }) })] })), _jsxs("div", { ref: rootRef, className: "flex min-h-[55vh] max-h-[70vh] min-w-0 flex-col gap-2 overflow-y-auto px-2 pb-3", children: [children, loadingMore && (_jsx(Skeleton, { className: "h-16 w-full shrink-0", "data-testid": "lane-loading-more" })), hasMore && (_jsx("div", { ref: sentinelRef, className: "h-1 w-full shrink-0", "aria-hidden": true }))] })] }));
|
|
620
670
|
}
|
|
621
671
|
// LaneFilterButton — the per-column funnel. Picks a field + a value and narrows
|
|
622
672
|
// ONLY this lane's cards (client-side, in the parent). Draft state lives here so
|
|
@@ -639,6 +689,8 @@ function LaneFilterButton({ fields, value, onChange, }) {
|
|
|
639
689
|
return null;
|
|
640
690
|
const active = !!(value &&
|
|
641
691
|
((value.values && value.values.length > 0) || value.text?.trim()));
|
|
692
|
+
// Number of applied criteria on this lane's funnel (drives the count badge).
|
|
693
|
+
const activeCount = laneFunnelCount(value);
|
|
642
694
|
// The value step mirrors the sheet: when the chosen field is a select or a
|
|
643
695
|
// facet (static options OR a lazy loader), render the SAME pro combobox —
|
|
644
696
|
// multi-select, searchable, with counts. Only a genuinely free-text field
|
|
@@ -659,9 +711,9 @@ function LaneFilterButton({ fields, value, onChange, }) {
|
|
|
659
711
|
onChange(null);
|
|
660
712
|
setOpen(false);
|
|
661
713
|
};
|
|
662
|
-
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children:
|
|
714
|
+
return (_jsxs(Popover, { open: open, onOpenChange: setOpen, children: [_jsx(PopoverTrigger, { asChild: true, children: _jsxs("button", { type: "button", className: `relative flex size-6 items-center justify-center rounded-md transition-colors hover:bg-accent hover:text-foreground ${active ? 'text-primary' : 'text-muted-foreground'}`, "aria-label": t('kanban.filterLane', {
|
|
663
715
|
defaultValue: 'Filtrar columna',
|
|
664
|
-
}), children: _jsx(ListFilter, { className: "h-3.5 w-3.5" }) }) }), _jsxs(PopoverContent, { align: "end", className: "w-72 space-y-2.5 rounded-xl p-2.5 shadow-lg", children: [_jsxs(Select, { value: field, onValueChange: (f) => {
|
|
716
|
+
}), children: [_jsx(ListFilter, { className: "h-3.5 w-3.5" }), activeCount > 0 && (_jsx("span", { className: "absolute -right-1 -top-1 flex h-3.5 min-w-3.5 items-center justify-center rounded-full bg-primary px-0.5 text-[9px] font-bold leading-none text-primary-foreground tabular-nums", children: activeCount }))] }) }), _jsxs(PopoverContent, { align: "end", className: "w-72 space-y-2.5 rounded-xl p-2.5 shadow-lg", children: [_jsxs(Select, { value: field, onValueChange: (f) => {
|
|
665
717
|
setField(f);
|
|
666
718
|
// Reset the value when switching fields — a value picked
|
|
667
719
|
// for one field is meaningless for another.
|
package/dist/dynamic-table.d.ts
CHANGED
|
@@ -36,6 +36,14 @@ export interface DynamicTableProps {
|
|
|
36
36
|
* an explicit per-column currency. Optional — defaults to 'USD'.
|
|
37
37
|
*/
|
|
38
38
|
currency?: string;
|
|
39
|
+
/**
|
|
40
|
+
* Opt into infinite scroll: instead of the classic pager, rows accumulate as
|
|
41
|
+
* the user scrolls (a sentinel at the bottom fetches + appends the next
|
|
42
|
+
* page, deduped by id, respecting the active filters/search). Changing any
|
|
43
|
+
* filter/sort/search resets to page 1. Default false — existing hosts keep
|
|
44
|
+
* the classic pagination untouched.
|
|
45
|
+
*/
|
|
46
|
+
infiniteScroll?: boolean;
|
|
39
47
|
}
|
|
40
|
-
export declare function DynamicTable({ model, endpoint, enableUrlSync, hiddenColumns, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns, getDynamicColumns, timeZone, currency, }: DynamicTableProps): import("react").JSX.Element;
|
|
48
|
+
export declare function DynamicTable({ model, endpoint, enableUrlSync, hiddenColumns, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns, getDynamicColumns, timeZone, currency, infiniteScroll, }: DynamicTableProps): import("react").JSX.Element;
|
|
41
49
|
//# sourceMappingURL=dynamic-table.d.ts.map
|
|
@@ -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;AAanF,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,+BAyiCnB"}
|