@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 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
@@ -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
- * Translates option labels through the app translator (manifest i18n keys
66
- * localized text). A raw value with no matching key falls through to itself via
67
- * `defaultValue`. Pure — exported for unit tests.
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 translateOptionLabels<T extends {
70
- label: string;
71
- }>(options: T[], translate: (key: string) => string): T[];
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
- * Max cards fetched per lane render. Kanban shows all cards at once (no
99
- * pagination UI), so it requests a single large page. Defaults to 200.
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":"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"}
1
+ {"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"AA0BA,OAAO,KAAK,KAAK,MAAM,OAAO,CAAA;AAwD9B,OAAO,EAEH,qBAAqB,EACrB,qBAAqB,EACxB,MAAM,gBAAgB,CAAA;AASvB,OAAO,KAAK,EACR,aAAa,EACb,gBAAgB,EAGhB,SAAS,EACT,eAAe,EAClB,MAAM,SAAS,CAAA;AAIhB,OAAO,EAAE,qBAAqB,EAAE,qBAAqB,EAAE,CAAA;AAMvD;;;;;;GAMG;AACH,wBAAgB,YAAY,CAAC,QAAQ,EAAE,aAAa,GAAG,SAAS,EAAE,CAejE;AAQD;;;;;GAKG;AACH,eAAO,MAAM,eAAe,mBAAmB,CAAA;AAE/C,wBAAgB,YAAY,CACxB,OAAO,EAAE,GAAG,EAAE,EACd,UAAU,EAAE,MAAM,EAClB,MAAM,EAAE,SAAS,EAAE,GACpB,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,CAepB;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAC/B,WAAW,EAAE,eAAe,EAAE,GAAG,SAAS,EAC1C,IAAI,EAAE,MAAM,EACZ,EAAE,EAAE,MAAM,GACX,OAAO,CAOT;AAED;;;;;GAKG;AACH,wBAAgB,mBAAmB,CAC/B,OAAO,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,EAC3B,MAAM,EAAE,MAAM,GAAG,MAAM,EACvB,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,EACf,UAAU,EAAE,MAAM,GACnB,GAAG,CAAC,MAAM,EAAE,GAAG,EAAE,CAAC,CAYpB;AAED;;;;;;GAMG;AACH,wBAAgB,qBAAqB,CAAC,CAAC,SAAS;IAAE,KAAK,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,EACpE,UAAU,EAAE,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,EAC7B,SAAS,EAAE,MAAM,EACjB,OAAO,EAAE,MAAM,GAChB,MAAM,CAAC,MAAM,EAAE,CAAC,CAAC,CAWnB;AAED;;;;;GAKG;AACH,wBAAgB,iBAAiB,CAC7B,QAAQ,EAAE,aAAa,EACvB,SAAS,SAAI,GACd;IAAE,KAAK,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAAC,MAAM,EAAE,gBAAgB,EAAE,CAAA;CAAE,CAkBhE;AAED;;;;;GAKG;AACH,wBAAgB,qBAAqB,CACjC,IAAI,EAAE,GAAG,EACT,MAAM,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,GACzE,OAAO,CAUT;AAED;;;;GAIG;AACH,wBAAgB,eAAe,CAC3B,KAAK,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,EAAE,CAAC;IAAC,IAAI,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,SAAS,GACxD,MAAM,CAIR;AAED;;;;GAIG;AACH,wBAAgB,oBAAoB,CAChC,IAAI,EAAE,GAAG,EACT,IAAI,EAAE,gBAAgB,EAAE,EACxB,KAAK,EAAE,MAAM,GACd,OAAO,CAQT;AAwDD,MAAM,WAAW,kBAAkB;IAC/B,6DAA6D;IAC7D,KAAK,EAAE,MAAM,CAAA;IACb;;;OAGG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,kFAAkF;IAClF,cAAc,CAAC,EAAE,GAAG,CAAA;IACpB,+DAA+D;IAC/D,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAChC;;;;;OAKG;IACH,QAAQ,CAAC,EAAE,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG,EAAE,GAAG,KAAK,IAAI,CAAA;IAC7C;;;;OAIG;IACH,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,YAAY,CAAC,EAAE,MAAM,CAAA;IACrB,2DAA2D;IAC3D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB,4DAA4D;IAC5D,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;OAGG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,GAAG,CAAC,CAAA;CACvC;AAED,wBAAgB,aAAa,CAAC,EAC1B,KAAK,EACL,QAAQ,EACR,cAAc,EACd,WAAW,EACX,QAAQ,EACR,QAAa,EACb,YAAiB,EACjB,QAAQ,EACR,QAAQ,EACR,cAAc,GACjB,EAAE,kBAAkB,qBAknBpB"}
@@ -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, resolveColorCss } from '@asteby/metacore-ui/lib';
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
- * Translates option labels through the app translator (manifest i18n keys
210
- * localized text). A raw value with no matching key falls through to itself via
211
- * `defaultValue`. Pure — exported for unit tests.
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 translateOptionLabels(options, translate) {
214
- return options.map((o) => ({ ...o, label: translate(o.label) }));
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 = 200, timeZone, currency, defaultFilters, }) {
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
- // ---- records fetch (same path as DynamicTable, single large page) ----
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.length > 0 && (_jsxs("div", { className: "flex flex-wrap items-center gap-1.5", "data-testid": "kanban-filter-chips", children: [activeFields.map((field) => {
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
- return (_jsx(KanbanLane, { stage: stage, count: cards.length, totalCount: allCards.length, filterFields: filterFields, laneFilter: laneFilter, onFunnelChange: (f) => updateLaneFilter(stage.key, {
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 ? `${count}/${totalCount}` : count })] }), _jsxs("div", { className: `flex items-center gap-0.5 transition-opacity focus-within:opacity-100 group-hover/lane:opacity-100 ${laneActive || searchOpen ? 'opacity-100' : 'opacity-0'}`, children: [_jsx("button", { type: "button", onClick: () => setSearchOpen((o) => !o), className: `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', {
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" }) })] })), _jsx("div", { className: "flex min-h-[55vh] max-h-[70vh] min-w-0 flex-col gap-2 overflow-y-auto px-2 pb-3", children: children })] }));
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: _jsx("button", { type: "button", className: `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', {
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.
@@ -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;AAWnF,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;CACpB;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,GACX,EAAE,iBAAiB,+BA02BnB"}
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"}