@asteby/metacore-runtime-react 23.9.1 → 23.9.3

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,20 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 23.9.3
4
+
5
+ ### Patch Changes
6
+
7
+ - e67e403: fix(kanban): las etiquetas de lane dejan de mostrar la clave i18n cruda cuando el bundle del addon llega tarde. Un hook (useI18nResourceVersion) re-resuelve las etiquetas al mergearse el bundle async (addResourceBundle), sin depender del bindI18nStore del host.
8
+
9
+ ## 23.9.2
10
+
11
+ ### Patch Changes
12
+
13
+ - 38856fc: fix(data-table/kanban): filtros con un solo indicador y scroll infinito sin atascos
14
+ - La tabla ya no renderiza la fila de chips de filtros activos: el indicador del header de columna es la única señal (los chips seguían duplicando la info).
15
+ - La tabla re-sincroniza sus filtros cuando el router del host reescribe la query string sin remontar (p. ej. entradas hermanas del sidebar que deep-linkean distintos `f_` sobre la misma ruta); antes quedaba mostrando el filtro anterior.
16
+ - El sentinel de scroll infinito encadena la siguiente página cuando una carga termina y el sentinel sigue visible (pantallas pequeñas / páginas cortas); antes se atascaba hasta que el usuario movía el scroll.
17
+
3
18
  ## 23.9.1
4
19
 
5
20
  ### Patch Changes
@@ -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;AAyF9B,OAAO,EAEH,qBAAqB,EACrB,qBAAqB,EACxB,MAAM,gBAAgB,CAAA;AAUvB,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;;;;;;;;GAQG;AACH,wBAAgB,eAAe,CAC3B,KAAK,EAAE,MAAM,EACb,MAAM,EAAE,MAAM,EACd,WAAW,EAAE,MAAM,GAAG,IAAI,EAC1B,UAAU,EAAE,OAAO,GACpB,MAAM,CAMR;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;AAKD;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,GAAG,EAAE,GAAG,EAAE,gBAAgB,GAAG,OAAO,CASvE;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,qBAsmCpB"}
1
+ {"version":3,"file":"dynamic-kanban.d.ts","sourceRoot":"","sources":["../src/dynamic-kanban.tsx"],"names":[],"mappings":"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"}
@@ -1,6 +1,7 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
3
3
  import { useTranslation } from 'react-i18next';
4
+ import { useI18nResourceVersion } from './use-i18n-resource-version';
4
5
  import { DndContext, DragOverlay, PointerSensor, useSensor, useSensors, useDraggable, useDroppable, } from '@dnd-kit/core';
5
6
  import { SortableContext, horizontalListSortingStrategy, useSortable, } from '@dnd-kit/sortable';
6
7
  import { CSS } from '@dnd-kit/utilities';
@@ -1082,6 +1083,11 @@ function SortableSmartLane({ droppableDisabled, smartProps, }) {
1082
1083
  }
1083
1084
  function KanbanLane({ stage, count, totalCount, serverTotal, hasMore, loadingMore, onLoadMore, filterFields, laneFilter, onFunnelChange, onQueryChange, isDark, dimmed, dnd, model, columns, automationsAvailable, automationRules, onAutomationCreate, onAutomationUpdate, onAutomationRemove, extraFilters, configurable, onConfigure, children, }) {
1084
1085
  const { t } = useTranslation();
1086
+ // Re-resolve the lane label when an addon i18n bundle lands after the board
1087
+ // painted (async addResourceBundle) — otherwise a manifest-key label like
1088
+ // "integration_github.stage.in_progress" stays raw until an unrelated
1089
+ // re-render. Independent of the host's react-i18next bindI18nStore config.
1090
+ useI18nResourceVersion();
1085
1091
  // Infinite scroll: the sentinel lives at the bottom of the lane's own scroll
1086
1092
  // container; a load in flight or an exhausted stage disables it.
1087
1093
  const { rootRef, sentinelRef } = useInfiniteScrollSentinel({
@@ -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;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,+BA6iCnB"}
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,+BA8iCnB"}
@@ -25,7 +25,7 @@ import { useMetadataCache } from './metadata-cache';
25
25
  import { useApi, useCurrentBranch } from './api-context';
26
26
  import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns';
27
27
  import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders';
28
- import { FilterChipsRow, translateOptionLabels } from './filter-chips';
28
+ import { translateOptionLabels } from './filter-chips';
29
29
  import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll';
30
30
  import { OptionsContext } from './options-context';
31
31
  import { getSearchableColumnKeys } from './column-visibility';
@@ -148,6 +148,10 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
148
148
  if (Object.keys(filters).length > 0)
149
149
  setDynamicFilters(filters);
150
150
  }, []); // eslint-disable-line react-hooks/exhaustive-deps
151
+ // The exact query string this table last wrote to the URL — lets the
152
+ // resync effect below tell "our own replaceState" apart from an external
153
+ // rewrite by the host router.
154
+ const lastSelfSearch = useRef(null);
151
155
  useEffect(() => {
152
156
  if (!enableUrlSync || !initializedFromUrl.current)
153
157
  return;
@@ -188,8 +192,46 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
188
192
  });
189
193
  const search = params.toString();
190
194
  const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname;
195
+ lastSelfSearch.current = search ? `?${search}` : '';
191
196
  window.history.replaceState(null, '', newUrl);
192
197
  }, [enableUrlSync, pagination, sorting, globalFilter, dynamicFilters, defaultFilters]);
198
+ // The host router can rewrite the query string WITHOUT remounting the
199
+ // table — e.g. sidebar sibling entries deep-link different `f_` filters
200
+ // into the same model route. The mount-time init above never re-runs, so
201
+ // the table kept showing the previous filter. On every render where the
202
+ // location's search differs from the last URL we ourselves wrote,
203
+ // re-parse the `f_` params and adopt them.
204
+ const locationSearch = typeof window !== 'undefined' ? window.location.search : '';
205
+ useEffect(() => {
206
+ if (!enableUrlSync || !initializedFromUrl.current)
207
+ return;
208
+ if (locationSearch === lastSelfSearch.current)
209
+ return;
210
+ lastSelfSearch.current = locationSearch;
211
+ const params = new URLSearchParams(locationSearch);
212
+ const filters = {};
213
+ params.forEach((rawValue, key) => {
214
+ if (!key.startsWith('f_'))
215
+ return;
216
+ const filterKey = key.substring(2);
217
+ if (defaultFilters && filterKey in defaultFilters)
218
+ return;
219
+ const value = urlValueToInternal(rawValue);
220
+ if (value.startsWith('IN:'))
221
+ filters[filterKey] = value.substring(3).split(',');
222
+ else
223
+ filters[filterKey] = [value];
224
+ });
225
+ setDynamicFilters((prev) => {
226
+ if (JSON.stringify(prev) === JSON.stringify(filters))
227
+ return prev;
228
+ setPagination((p) => ({ ...p, pageIndex: 0 }));
229
+ return filters;
230
+ });
231
+ const search = params.get('search');
232
+ if (search !== null)
233
+ setGlobalFilter(search);
234
+ });
193
235
  const prefetchOptions = useCallback(async (endpoints) => {
194
236
  if (endpoints.length === 0)
195
237
  return new Map();
@@ -714,30 +756,6 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
714
756
  return;
715
757
  prefetchFacets(facetFieldsSig.split('|'));
716
758
  }, [facetFieldsSig, prefetchFacets]);
717
- // Active-filter chips (shared row with the kanban). One chip per field with a
718
- // selection; the label is the translated filter/column label.
719
- const activeFilterChips = useMemo(() => {
720
- if (!metadata)
721
- return [];
722
- const out = [];
723
- for (const [key, config] of columnFilterConfigs) {
724
- if ((config.selectedValues?.length ?? 0) === 0)
725
- continue;
726
- const f = metadata.filters?.find((x) => x.key === key);
727
- const c = metadata.columns?.find((x) => x.key === key);
728
- const rawLabel = f?.label || c?.label || key;
729
- out.push({
730
- key,
731
- label: t(rawLabel, { defaultValue: rawLabel }),
732
- config,
733
- });
734
- }
735
- return out;
736
- }, [metadata, columnFilterConfigs, t]);
737
- const clearAllDynamicFilters = useCallback(() => {
738
- setDynamicFilters({});
739
- setPagination((prev) => ({ ...prev, pageIndex: 0 }));
740
- }, []);
741
759
  const columns = useMemo(() => {
742
760
  if (!viewMetadata)
743
761
  return [];
@@ -783,7 +801,7 @@ export function DynamicTable({ model, endpoint, enableUrlSync = true, hiddenColu
783
801
  if (!metadata) {
784
802
  return _jsx("div", { className: "text-center text-muted-foreground py-8", children: "Error al cargar la configuraci\u00F3n de la tabla." });
785
803
  }
786
- return (_jsxs(OptionsContext.Provider, { value: { optionsMap }, children: [_jsxs("div", { className: 'flex flex-col h-full min-h-0 w-full', children: [_jsxs("div", { className: 'pb-4 shrink-0', children: [_jsx(DataTableToolbar, { table: table, searchPlaceholder: metadata.searchPlaceholder || 'Buscar...', filters: filters, activeFilters: dynamicFilters, onDynamicFilterChange: handleDynamicFilterChange, dateFilter: { value: dateRange, onChange: setDateRange, placeholder: 'Filtrar por fecha' }, perPageOptions: metadata.perPageOptions, onRefresh: handleRefresh, isLoading: loadingData, selectedCount: Object.keys(rowSelection).length, onBulkDelete: () => setShowBulkDeleteConfirm(true), extraActions: _jsxs(_Fragment, { children: [viewMetadata?.canExport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setExportOpen(true), children: [_jsx(Download, { className: "h-4 w-4 mr-1" }), " Exportar"] })), viewMetadata?.canImport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setImportOpen(true), children: [_jsx(Upload, { className: "h-4 w-4 mr-1" }), " Importar"] }))] }) }), activeFilterChips.length > 0 && (_jsx("div", { className: 'pt-2', children: _jsx(FilterChipsRow, { fields: activeFilterChips, onClearAll: clearAllDynamicFilters, "data-testid": 'table-filter-chips' }) }))] }), _jsxs("div", { ref: infDesktopRoot, className: 'hidden sm:block flex-1 min-h-0 overflow-auto border rounded-md bg-card', children: [_jsxs(Table, { noWrapper: true, className: cn('min-w-max w-full', aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && 'h-full'), children: [_jsx(TableHeader, { className: 'sticky top-0 z-10', children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: headerGroup.headers.map((header) => {
804
+ return (_jsxs(OptionsContext.Provider, { value: { optionsMap }, children: [_jsxs("div", { className: 'flex flex-col h-full min-h-0 w-full', children: [_jsx("div", { className: 'pb-4 shrink-0', children: _jsx(DataTableToolbar, { table: table, searchPlaceholder: metadata.searchPlaceholder || 'Buscar...', filters: filters, activeFilters: dynamicFilters, onDynamicFilterChange: handleDynamicFilterChange, dateFilter: { value: dateRange, onChange: setDateRange, placeholder: 'Filtrar por fecha' }, perPageOptions: metadata.perPageOptions, onRefresh: handleRefresh, isLoading: loadingData, selectedCount: Object.keys(rowSelection).length, onBulkDelete: () => setShowBulkDeleteConfirm(true), extraActions: _jsxs(_Fragment, { children: [viewMetadata?.canExport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setExportOpen(true), children: [_jsx(Download, { className: "h-4 w-4 mr-1" }), " Exportar"] })), viewMetadata?.canImport && (_jsxs(Button, { variant: "outline", size: "sm", className: "h-8", onClick: () => setImportOpen(true), children: [_jsx(Upload, { className: "h-4 w-4 mr-1" }), " Importar"] }))] }) }) }), _jsxs("div", { ref: infDesktopRoot, className: 'hidden sm:block flex-1 min-h-0 overflow-auto border rounded-md bg-card', children: [_jsxs(Table, { noWrapper: true, className: cn('min-w-max w-full', aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && 'h-full'), children: [_jsx(TableHeader, { className: 'sticky top-0 z-10', children: table.getHeaderGroups().map((headerGroup) => (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: headerGroup.headers.map((header) => {
787
805
  const isActionsColumn = header.id === 'actions';
788
806
  return (_jsx(TableHead, { colSpan: header.colSpan, style: header.column.columnDef.size ? { width: header.column.columnDef.size } : undefined, className: cn('bg-card border-b h-10', isActionsColumn && 'sticky right-0 z-20 bg-card shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]'), children: header.isPlaceholder ? null : flexRender(header.column.columnDef.header, header.getContext()) }, header.id));
789
807
  }) }, headerGroup.id))) }), _jsx(TableBody, { children: loadingData && data.length === 0 ? (_jsx(TableSkeleton, {})) : table.getRowModel().rows?.length ? (_jsxs(_Fragment, { children: [table.getRowModel().rows.map((row) => (_jsx(TableRow, { "data-state": row.getIsSelected() && 'selected', className: cn(onRowClick && 'cursor-pointer'), onClick: onRowClick ? () => onRowClick(row.original) : undefined, children: row.getVisibleCells().map((cell) => {
@@ -0,0 +1,2 @@
1
+ export declare function useI18nResourceVersion(): number;
2
+ //# sourceMappingURL=use-i18n-resource-version.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"use-i18n-resource-version.d.ts","sourceRoot":"","sources":["../src/use-i18n-resource-version.ts"],"names":[],"mappings":"AAmBA,wBAAgB,sBAAsB,IAAI,MAAM,CAsB/C"}
@@ -0,0 +1,42 @@
1
+ // A tiny hook that returns a version counter which bumps every time i18next's
2
+ // resource store changes (a bundle is `added`, a language `loaded`, or a key
3
+ // `removed`).
4
+ //
5
+ // Why this exists: addon i18n bundles are fetched and merged asynchronously
6
+ // (addResourceBundle) AFTER the board first paints. react-i18next only
7
+ // re-renders a `useTranslation()` consumer on a store mutation when the host
8
+ // configured `bindI18nStore` to include those events — a host-level setting the
9
+ // SDK cannot assume. Without it a lane whose label is a manifest i18n key
10
+ // (e.g. "integration_github.stage.in_progress") renders the RAW key until an
11
+ // unrelated re-render happens to re-run `t()`.
12
+ //
13
+ // Depending on this version inside a memo/render makes the component re-resolve
14
+ // its labels the moment the bundle lands, regardless of the host's
15
+ // react-i18next binding config. Self-contained and cheap: one listener per
16
+ // mounting component, torn down on unmount.
17
+ import { useEffect, useState } from 'react';
18
+ import { useTranslation } from 'react-i18next';
19
+ export function useI18nResourceVersion() {
20
+ const { i18n } = useTranslation();
21
+ const [version, setVersion] = useState(0);
22
+ useEffect(() => {
23
+ if (!i18n)
24
+ return;
25
+ const bump = () => setVersion((v) => v + 1);
26
+ // `added`/`removed` fire on the resource STORE (addResourceBundle);
27
+ // `loaded` fires when a backend finishes a language; `languageChanged`
28
+ // covers a runtime locale flip. Listening to all keeps a label correct
29
+ // through every path a translation can arrive.
30
+ i18n.store?.on?.('added', bump);
31
+ i18n.store?.on?.('removed', bump);
32
+ i18n.on?.('loaded', bump);
33
+ i18n.on?.('languageChanged', bump);
34
+ return () => {
35
+ i18n.store?.off?.('added', bump);
36
+ i18n.store?.off?.('removed', bump);
37
+ i18n.off?.('loaded', bump);
38
+ i18n.off?.('languageChanged', bump);
39
+ };
40
+ }, [i18n]);
41
+ return version;
42
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"use-infinite-scroll.d.ts","sourceRoot":"","sources":["../src/use-infinite-scroll.ts"],"names":[],"mappings":"AAWA;;;GAGG;AACH,wBAAgB,UAAU,CAAC,CAAC,SAAS;IAAE,EAAE,CAAC,EAAE,GAAG,CAAA;CAAE,EAC/C,QAAQ,EAAE,CAAC,EAAE,EACb,QAAQ,EAAE,CAAC,EAAE,GACZ,CAAC,EAAE,CAWL;AAED,MAAM,WAAW,wBAAwB;IACvC,wEAAwE;IACxE,UAAU,EAAE,MAAM,IAAI,CAAA;IACtB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,kBAAkB,CACjC,CAAC,SAAS,WAAW,GAAG,cAAc,EACtC,CAAC,SAAS,WAAW,GAAG,cAAc;IAEtC,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;IAClC,WAAW,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;CACvC;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CACvC,CAAC,SAAS,WAAW,GAAG,cAAc,EACtC,CAAC,SAAS,WAAW,GAAG,cAAc,EACtC,EACA,UAAU,EACV,QAAgB,EAChB,UAAgB,GACjB,EAAE,wBAAwB,GAAG,kBAAkB,CAAC,CAAC,EAAE,CAAC,CAAC,CAgCrD"}
1
+ {"version":3,"file":"use-infinite-scroll.d.ts","sourceRoot":"","sources":["../src/use-infinite-scroll.ts"],"names":[],"mappings":"AAWA;;;GAGG;AACH,wBAAgB,UAAU,CAAC,CAAC,SAAS;IAAE,EAAE,CAAC,EAAE,GAAG,CAAA;CAAE,EAC/C,QAAQ,EAAE,CAAC,EAAE,EACb,QAAQ,EAAE,CAAC,EAAE,GACZ,CAAC,EAAE,CAWL;AAED,MAAM,WAAW,wBAAwB;IACvC,wEAAwE;IACxE,UAAU,EAAE,MAAM,IAAI,CAAA;IACtB,6EAA6E;IAC7E,QAAQ,CAAC,EAAE,OAAO,CAAA;IAClB,kEAAkE;IAClE,UAAU,CAAC,EAAE,MAAM,CAAA;CACpB;AAED,MAAM,WAAW,kBAAkB,CACjC,CAAC,SAAS,WAAW,GAAG,cAAc,EACtC,CAAC,SAAS,WAAW,GAAG,cAAc;IAEtC,OAAO,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;IAClC,WAAW,EAAE,KAAK,CAAC,SAAS,CAAC,CAAC,GAAG,IAAI,CAAC,CAAA;CACvC;AAED;;;;;GAKG;AACH,wBAAgB,yBAAyB,CACvC,CAAC,SAAS,WAAW,GAAG,cAAc,EACtC,CAAC,SAAS,WAAW,GAAG,cAAc,EACtC,EACA,UAAU,EACV,QAAgB,EAChB,UAAgB,GACjB,EAAE,wBAAwB,GAAG,kBAAkB,CAAC,CAAC,EAAE,CAAC,CAAC,CA4CrD"}
@@ -37,6 +37,7 @@ export function useInfiniteScrollSentinel({ onLoadMore, disabled = false, rootMa
37
37
  const sentinelRef = useRef(null);
38
38
  const cb = useRef(onLoadMore);
39
39
  const disabledRef = useRef(disabled);
40
+ const intersectingRef = useRef(false);
40
41
  cb.current = onLoadMore;
41
42
  disabledRef.current = disabled;
42
43
  useEffect(() => {
@@ -47,6 +48,7 @@ export function useInfiniteScrollSentinel({ onLoadMore, disabled = false, rootMa
47
48
  }
48
49
  const observer = new IntersectionObserver((entries) => {
49
50
  for (const entry of entries) {
51
+ intersectingRef.current = entry.isIntersecting;
50
52
  if (entry.isIntersecting && !disabledRef.current) {
51
53
  cb.current();
52
54
  }
@@ -56,5 +58,15 @@ export function useInfiniteScrollSentinel({ onLoadMore, disabled = false, rootMa
56
58
  return () => observer.disconnect();
57
59
  // Re-create only when the margin changes; onLoadMore/disabled are read live.
58
60
  }, [rootMargin]);
61
+ // IntersectionObserver only fires on intersection CHANGES. If a loaded page
62
+ // is too short to push the sentinel out of view (small screens, short
63
+ // lanes), the sentinel stays intersecting and no further event ever comes —
64
+ // the list stalls until the user jiggles the scroll. When a load finishes
65
+ // (disabled flips back to false) and the sentinel is still in view, chain
66
+ // the next page.
67
+ useEffect(() => {
68
+ if (!disabled && intersectingRef.current)
69
+ cb.current();
70
+ }, [disabled]);
59
71
  return { rootRef, sentinelRef };
60
72
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "23.9.1",
3
+ "version": "23.9.3",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,7 +38,7 @@
38
38
  "sonner": ">=1.7",
39
39
  "zustand": ">=5",
40
40
  "@asteby/metacore-sdk": "^3.2.0",
41
- "@asteby/metacore-ui": "^2.9.2"
41
+ "@asteby/metacore-ui": "^2.9.3"
42
42
  },
43
43
  "peerDependenciesMeta": {
44
44
  "@tanstack/react-router": {
@@ -68,7 +68,7 @@
68
68
  "vitest": "^4.0.0",
69
69
  "zustand": "^5.0.0",
70
70
  "@asteby/metacore-sdk": "3.2.0",
71
- "@asteby/metacore-ui": "2.9.2"
71
+ "@asteby/metacore-ui": "2.9.3"
72
72
  },
73
73
  "scripts": {
74
74
  "build": "tsc -p tsconfig.json",
@@ -27,6 +27,7 @@
27
27
  import * as React from 'react'
28
28
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
29
29
  import { useTranslation } from 'react-i18next'
30
+ import { useI18nResourceVersion } from './use-i18n-resource-version'
30
31
  import {
31
32
  DndContext,
32
33
  DragOverlay,
@@ -1919,6 +1920,11 @@ function KanbanLane({
1919
1920
  children,
1920
1921
  }: KanbanLaneProps) {
1921
1922
  const { t } = useTranslation()
1923
+ // Re-resolve the lane label when an addon i18n bundle lands after the board
1924
+ // painted (async addResourceBundle) — otherwise a manifest-key label like
1925
+ // "integration_github.stage.in_progress" stays raw until an unrelated
1926
+ // re-render. Independent of the host's react-i18next bindI18nStore config.
1927
+ useI18nResourceVersion()
1922
1928
  // Infinite scroll: the sentinel lives at the bottom of the lane's own scroll
1923
1929
  // container; a load in flight or an exhausted stage disables it.
1924
1930
  const { rootRef, sentinelRef } = useInfiniteScrollSentinel({
@@ -67,7 +67,7 @@ import { useApi, useCurrentBranch } from './api-context'
67
67
  import type { ColumnFilterConfig, GetDynamicColumns } from './dynamic-columns-shim'
68
68
  import { defaultGetDynamicColumns, DATE_CELL_TYPES, aggregateOf, formatAggregateTotal } from './dynamic-columns'
69
69
  import { useFacetLoaders, isLongTextColumn } from './use-facet-loaders'
70
- import { FilterChipsRow, translateOptionLabels, type FilterChipField } from './filter-chips'
70
+ import { translateOptionLabels } from './filter-chips'
71
71
  import { dedupeById, useInfiniteScrollSentinel } from './use-infinite-scroll'
72
72
  import { OptionsContext } from './options-context'
73
73
  import type { TableMetadata, ApiResponse } from './types'
@@ -256,6 +256,11 @@ export function DynamicTable({
256
256
  if (Object.keys(filters).length > 0) setDynamicFilters(filters)
257
257
  }, []) // eslint-disable-line react-hooks/exhaustive-deps
258
258
 
259
+ // The exact query string this table last wrote to the URL — lets the
260
+ // resync effect below tell "our own replaceState" apart from an external
261
+ // rewrite by the host router.
262
+ const lastSelfSearch = useRef<string | null>(null)
263
+
259
264
  useEffect(() => {
260
265
  if (!enableUrlSync || !initializedFromUrl.current) return
261
266
  const params = new URLSearchParams()
@@ -287,9 +292,40 @@ export function DynamicTable({
287
292
  })
288
293
  const search = params.toString()
289
294
  const newUrl = search ? `${window.location.pathname}?${search}` : window.location.pathname
295
+ lastSelfSearch.current = search ? `?${search}` : ''
290
296
  window.history.replaceState(null, '', newUrl)
291
297
  }, [enableUrlSync, pagination, sorting, globalFilter, dynamicFilters, defaultFilters])
292
298
 
299
+ // The host router can rewrite the query string WITHOUT remounting the
300
+ // table — e.g. sidebar sibling entries deep-link different `f_` filters
301
+ // into the same model route. The mount-time init above never re-runs, so
302
+ // the table kept showing the previous filter. On every render where the
303
+ // location's search differs from the last URL we ourselves wrote,
304
+ // re-parse the `f_` params and adopt them.
305
+ const locationSearch = typeof window !== 'undefined' ? window.location.search : ''
306
+ useEffect(() => {
307
+ if (!enableUrlSync || !initializedFromUrl.current) return
308
+ if (locationSearch === lastSelfSearch.current) return
309
+ lastSelfSearch.current = locationSearch
310
+ const params = new URLSearchParams(locationSearch)
311
+ const filters: Record<string, string[]> = {}
312
+ params.forEach((rawValue, key) => {
313
+ if (!key.startsWith('f_')) return
314
+ const filterKey = key.substring(2)
315
+ if (defaultFilters && filterKey in defaultFilters) return
316
+ const value = urlValueToInternal(rawValue)
317
+ if (value.startsWith('IN:')) filters[filterKey] = value.substring(3).split(',')
318
+ else filters[filterKey] = [value]
319
+ })
320
+ setDynamicFilters((prev: Record<string, string[]>) => {
321
+ if (JSON.stringify(prev) === JSON.stringify(filters)) return prev
322
+ setPagination((p: PaginationState) => ({ ...p, pageIndex: 0 }))
323
+ return filters
324
+ })
325
+ const search = params.get('search')
326
+ if (search !== null) setGlobalFilter(search)
327
+ })
328
+
293
329
  const prefetchOptions = useCallback(async (endpoints: string[]) => {
294
330
  if (endpoints.length === 0) return new Map<string, any[]>()
295
331
  const uniqueEndpoints = Array.from(new Set(endpoints))
@@ -790,30 +826,6 @@ export function DynamicTable({
790
826
  prefetchFacets(facetFieldsSig.split('|'))
791
827
  }, [facetFieldsSig, prefetchFacets])
792
828
 
793
- // Active-filter chips (shared row with the kanban). One chip per field with a
794
- // selection; the label is the translated filter/column label.
795
- const activeFilterChips = useMemo<FilterChipField[]>(() => {
796
- if (!metadata) return []
797
- const out: FilterChipField[] = []
798
- for (const [key, config] of columnFilterConfigs) {
799
- if ((config.selectedValues?.length ?? 0) === 0) continue
800
- const f = metadata.filters?.find((x) => x.key === key)
801
- const c = metadata.columns?.find((x) => x.key === key)
802
- const rawLabel = f?.label || c?.label || key
803
- out.push({
804
- key,
805
- label: t(rawLabel, { defaultValue: rawLabel }),
806
- config,
807
- })
808
- }
809
- return out
810
- }, [metadata, columnFilterConfigs, t])
811
-
812
- const clearAllDynamicFilters = useCallback(() => {
813
- setDynamicFilters({})
814
- setPagination((prev: PaginationState) => ({ ...prev, pageIndex: 0 }))
815
- }, [])
816
-
817
829
  const columns = useMemo(() => {
818
830
  if (!viewMetadata) return []
819
831
  // Row-action column only renders per-row actions. Table-level placements
@@ -933,17 +945,6 @@ export function DynamicTable({
933
945
  </>
934
946
  }
935
947
  />
936
- {/* Active-filter chips — shared with the kanban. Quick
937
- visibility + one-click removal of the header filters. */}
938
- {activeFilterChips.length > 0 && (
939
- <div className='pt-2'>
940
- <FilterChipsRow
941
- fields={activeFilterChips}
942
- onClearAll={clearAllDynamicFilters}
943
- data-testid='table-filter-chips'
944
- />
945
- </div>
946
- )}
947
948
  </div>
948
949
  {/* Desktop: classic horizontal-scroll table. Hidden on phones —
949
950
  a 7-column table forces a wide horizontal scroll there, so we
@@ -0,0 +1,42 @@
1
+ // A tiny hook that returns a version counter which bumps every time i18next's
2
+ // resource store changes (a bundle is `added`, a language `loaded`, or a key
3
+ // `removed`).
4
+ //
5
+ // Why this exists: addon i18n bundles are fetched and merged asynchronously
6
+ // (addResourceBundle) AFTER the board first paints. react-i18next only
7
+ // re-renders a `useTranslation()` consumer on a store mutation when the host
8
+ // configured `bindI18nStore` to include those events — a host-level setting the
9
+ // SDK cannot assume. Without it a lane whose label is a manifest i18n key
10
+ // (e.g. "integration_github.stage.in_progress") renders the RAW key until an
11
+ // unrelated re-render happens to re-run `t()`.
12
+ //
13
+ // Depending on this version inside a memo/render makes the component re-resolve
14
+ // its labels the moment the bundle lands, regardless of the host's
15
+ // react-i18next binding config. Self-contained and cheap: one listener per
16
+ // mounting component, torn down on unmount.
17
+ import { useEffect, useState } from 'react'
18
+ import { useTranslation } from 'react-i18next'
19
+
20
+ export function useI18nResourceVersion(): number {
21
+ const { i18n } = useTranslation()
22
+ const [version, setVersion] = useState(0)
23
+ useEffect(() => {
24
+ if (!i18n) return
25
+ const bump = () => setVersion((v) => v + 1)
26
+ // `added`/`removed` fire on the resource STORE (addResourceBundle);
27
+ // `loaded` fires when a backend finishes a language; `languageChanged`
28
+ // covers a runtime locale flip. Listening to all keeps a label correct
29
+ // through every path a translation can arrive.
30
+ i18n.store?.on?.('added', bump)
31
+ i18n.store?.on?.('removed', bump)
32
+ i18n.on?.('loaded', bump)
33
+ i18n.on?.('languageChanged', bump)
34
+ return () => {
35
+ i18n.store?.off?.('added', bump)
36
+ i18n.store?.off?.('removed', bump)
37
+ i18n.off?.('loaded', bump)
38
+ i18n.off?.('languageChanged', bump)
39
+ }
40
+ }, [i18n])
41
+ return version
42
+ }
@@ -64,6 +64,7 @@ export function useInfiniteScrollSentinel<
64
64
  const sentinelRef = useRef<S | null>(null)
65
65
  const cb = useRef(onLoadMore)
66
66
  const disabledRef = useRef(disabled)
67
+ const intersectingRef = useRef(false)
67
68
  cb.current = onLoadMore
68
69
  disabledRef.current = disabled
69
70
 
@@ -78,6 +79,7 @@ export function useInfiniteScrollSentinel<
78
79
  const observer = new IntersectionObserver(
79
80
  (entries) => {
80
81
  for (const entry of entries) {
82
+ intersectingRef.current = entry.isIntersecting
81
83
  if (entry.isIntersecting && !disabledRef.current) {
82
84
  cb.current()
83
85
  }
@@ -90,5 +92,15 @@ export function useInfiniteScrollSentinel<
90
92
  // Re-create only when the margin changes; onLoadMore/disabled are read live.
91
93
  }, [rootMargin])
92
94
 
95
+ // IntersectionObserver only fires on intersection CHANGES. If a loaded page
96
+ // is too short to push the sentinel out of view (small screens, short
97
+ // lanes), the sentinel stays intersecting and no further event ever comes —
98
+ // the list stalls until the user jiggles the scroll. When a load finishes
99
+ // (disabled flips back to false) and the sentinel is still in view, chain
100
+ // the next page.
101
+ useEffect(() => {
102
+ if (!disabled && intersectingRef.current) cb.current()
103
+ }, [disabled])
104
+
93
105
  return { rootRef, sentinelRef }
94
106
  }