@asteby/metacore-runtime-react 49.4.0 → 49.5.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,13 @@
1
1
  # @asteby/metacore-runtime-react
2
2
 
3
+ ## 49.5.0
4
+
5
+ ### Minor Changes
6
+
7
+ - 2fca21f: `ApiProvider` acepta `batch={false}` para no agrupar lecturas en `POST /q`: un host cuyo backend no implementa `/q` ya no paga un POST fallido por cada lectura. Además, con el batch activo, si `/q` responde 404/405 el cliente deja de intentarlo tras el primer fallo y la lectura se resuelve por el GET normal. El comportamiento por defecto no cambia para hosts que sí tienen `/q`.
8
+ - 2575411: DynamicTable: nueva primitiva de acciones masivas. `extraBulkActions` (nodo o función `({ selectedRows, selectedIds, clearSelection, refresh }) => nodo`) pinta las acciones del host en la barra flotante de selección, junto al "Eliminar" integrado; `hideBulkDelete` oculta ese "Eliminar". La selección ahora se indexa por `id` del registro (antes por posición en la página) y, con paginación clásica, se vacía al cambiar de página, tamaño, orden, búsqueda o filtros de columna, de modo que una acción masiva nunca actúa sobre filas que el usuario ya no ve.
9
+ - e030a86: DynamicTable: un fallo del endpoint de lista que no es 403 (500, red o `success: false`) ya no se ve como "No se encontraron resultados": muestra "No se pudieron cargar los datos" con un botón "Reintentar" que vuelve a pedir la lista (claves `dynamic.load_error_title`, `dynamic.load_error_hint`, `dynamic.retry`). Nueva prop opcional `emptyState` para mostrar contenido de primer uso cuando la lista carga bien, está vacía y no hay búsqueda ni filtros activos; nunca tapa un error ni un 403.
10
+
3
11
  ## 49.4.0
4
12
 
5
13
  ### Minor Changes
@@ -20,9 +20,17 @@ export interface ApiClient {
20
20
  }
21
21
  export interface ApiProviderProps {
22
22
  client: ApiClient;
23
+ /**
24
+ * Groups list, metadata and options reads into one `POST /q`. Defaults to
25
+ * `true`. Pass `false` when the host backend does not implement `/q`: every
26
+ * read then goes straight to its own GET and no POST is attempted. When
27
+ * left on and `/q` answers 404/405, the batch turns itself off for that
28
+ * client after the first failed attempt and the read falls back to GET.
29
+ */
30
+ batch?: boolean;
23
31
  children: React.ReactNode;
24
32
  }
25
- export declare function ApiProvider({ client, children }: ApiProviderProps): React.JSX.Element;
33
+ export declare function ApiProvider({ client, batch, children }: ApiProviderProps): React.JSX.Element;
26
34
  /** Returns the host-injected api client. Throws if no <ApiProvider> is mounted. */
27
35
  export declare function useApi(): ApiClient;
28
36
  /** Optional branch context — hosts that support tenant branches can supply
@@ -1 +1 @@
1
- {"version":3,"file":"api-context.d.ts","sourceRoot":"","sources":["../src/api-context.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAwD,MAAM,OAAO,CAAA;AAI5E,uEAAuE;AACvE,MAAM,WAAW,SAAS;IACtB,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;IACzE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;IACtF,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;IACrF,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;CAC/E;AAID,MAAM,WAAW,gBAAgB;IAC7B,MAAM,EAAE,SAAS,CAAA;IACjB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAC5B;AAED,wBAAgB,WAAW,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,gBAAgB,qBAuBjE;AAmCD,mFAAmF;AACnF,wBAAgB,MAAM,IAAI,SAAS,CAkBlC;AAED;;wEAEwE;AACxE,MAAM,WAAW,WAAW;IACxB,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;CACzC;AAID,MAAM,WAAW,mBAAmB;IAChC,MAAM,EAAE,WAAW,CAAA;IACnB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAC5B;AAED,wBAAgB,cAAc,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,mBAAmB,qBAEvE;AAED,wBAAgB,gBAAgB,IAAI,WAAW,CAE9C"}
1
+ {"version":3,"file":"api-context.d.ts","sourceRoot":"","sources":["../src/api-context.tsx"],"names":[],"mappings":"AAIA,OAAO,KAAwD,MAAM,OAAO,CAAA;AAI5E,uEAAuE;AACvE,MAAM,WAAW,SAAS;IACtB,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;IACzE,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;IACtF,GAAG,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,GAAG,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;IACrF,MAAM,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,GAAG,KAAK,OAAO,CAAC;QAAE,IAAI,EAAE,GAAG,CAAC;QAAC,OAAO,CAAC,EAAE,GAAG,CAAA;KAAE,CAAC,CAAA;CAC/E;AAKD,MAAM,WAAW,gBAAgB;IAC7B,MAAM,EAAE,SAAS,CAAA;IACjB;;;;;;OAMG;IACH,KAAK,CAAC,EAAE,OAAO,CAAA;IACf,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAC5B;AAED,wBAAgB,WAAW,CAAC,EAAE,MAAM,EAAE,KAAY,EAAE,QAAQ,EAAE,EAAE,gBAAgB,qBA2B/E;AAmCD,mFAAmF;AACnF,wBAAgB,MAAM,IAAI,SAAS,CAoBlC;AAED;;wEAEwE;AACxE,MAAM,WAAW,WAAW;IACxB,EAAE,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,GAAG,SAAS,CAAA;CACzC;AAID,MAAM,WAAW,mBAAmB;IAChC,MAAM,EAAE,WAAW,CAAA;IACnB,QAAQ,EAAE,KAAK,CAAC,SAAS,CAAA;CAC5B;AAED,wBAAgB,cAAc,CAAC,EAAE,MAAM,EAAE,QAAQ,EAAE,EAAE,mBAAmB,qBAEvE;AAED,wBAAgB,gBAAgB,IAAI,WAAW,CAE9C"}
@@ -7,7 +7,8 @@ import { createContext, useContext, useEffect, useMemo } from 'react';
7
7
  import { batchGet, invalidateQueryBatchData } from './query-batch';
8
8
  import { invalidateOptionsCache } from './use-options-resolver';
9
9
  const ApiContext = createContext(null);
10
- export function ApiProvider({ client, children }) {
10
+ const BatchContext = createContext(true);
11
+ export function ApiProvider({ client, batch = true, children }) {
11
12
  // Hosts also write through their own client (native dialogs, settings
12
13
  // screens) without going through useApi(). When the client is axios,
13
14
  // every non-GET response drops what the runtime remembered as well.
@@ -28,7 +29,7 @@ export function ApiProvider({ client, children }) {
28
29
  });
29
30
  return () => interceptors.eject?.(id);
30
31
  }, [client]);
31
- return _jsx(ApiContext.Provider, { value: client, children: children });
32
+ return (_jsx(ApiContext.Provider, { value: client, children: _jsx(BatchContext.Provider, { value: batch, children: children }) }));
32
33
  }
33
34
  // POSTs that do not change any record a list or picker shows: the read batch
34
35
  // (/q) and the bell inbox the host writes on every control toast
@@ -54,6 +55,7 @@ function mutating(request) {
54
55
  /** Returns the host-injected api client. Throws if no <ApiProvider> is mounted. */
55
56
  export function useApi() {
56
57
  const ctx = useContext(ApiContext);
58
+ const batch = useContext(BatchContext);
57
59
  if (!ctx) {
58
60
  throw new Error('useApi() requires an <ApiProvider> ancestor. Hosts must inject an axios-like client via runtime-react ApiProvider.');
59
61
  }
@@ -64,11 +66,11 @@ export function useApi() {
64
66
  return useMemo(() => ({
65
67
  // Each call reaches the host client with the arguments it was given —
66
68
  // never a trailing `undefined` it didn't ask for.
67
- get: (...args) => batchGet(ctx, ...args),
69
+ get: (...args) => batch ? batchGet(ctx, ...args) : ctx.get(...args),
68
70
  post: (...args) => isWrite('post', args[0]) ? mutating(ctx.post(...args)) : ctx.post(...args),
69
71
  put: (...args) => mutating(ctx.put(...args)),
70
72
  delete: (...args) => mutating(ctx.delete(...args)),
71
- }), [ctx]);
73
+ }), [ctx, batch]);
72
74
  }
73
75
  const BranchContext = createContext({ id: undefined });
74
76
  export function BranchProvider({ branch, children }) {
@@ -1,3 +1,4 @@
1
+ import { type ReactNode } from 'react';
1
2
  import { type ColumnDef } from '@tanstack/react-table';
2
3
  import type { GetDynamicColumns } from './dynamic-columns-shim';
3
4
  export interface DynamicTableProps {
@@ -90,6 +91,35 @@ export interface DynamicTableProps {
90
91
  * render every row. A positive number overrides the threshold.
91
92
  */
92
93
  virtualizeRows?: boolean | number;
94
+ /**
95
+ * Host-provided actions rendered in the floating bar that appears while
96
+ * rows are selected, next to the built-in "Eliminar". Pass a node, or a
97
+ * function that receives the current selection:
98
+ * - `selectedRows`: the selected row objects (current page only);
99
+ * - `selectedIds`: their `id`s;
100
+ * - `clearSelection()`: unselect everything (call after a successful action);
101
+ * - `refresh()`: reload the list.
102
+ * The selection is cleared whenever the page, page size, sort, search or
103
+ * column filters change (classic pagination), so a bulk action can never
104
+ * hit rows the user no longer sees. The host owns the write call and its
105
+ * authorization — the table only supplies the selection.
106
+ */
107
+ extraBulkActions?: ReactNode | ((ctx: DynamicTableBulkContext) => ReactNode);
108
+ /** Hide the built-in bulk "Eliminar" (e.g. a model whose rows must not be deleted in batch). */
109
+ hideBulkDelete?: boolean;
110
+ /**
111
+ * First-use content shown instead of "No se encontraron resultados" when the
112
+ * list loaded fine, is empty, and no search/filter is active. Never shown
113
+ * after a load error (a retry is offered) or a 403.
114
+ */
115
+ emptyState?: ReactNode;
116
+ }
117
+ /** Context handed to `DynamicTableProps.extraBulkActions` when it is a function. */
118
+ export interface DynamicTableBulkContext {
119
+ selectedRows: any[];
120
+ selectedIds: Array<string | number>;
121
+ clearSelection: () => void;
122
+ refresh: () => void;
93
123
  }
94
- export declare function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync, hideImport, hideExport, hiddenColumns, allowedActionKeys, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns, getDynamicColumns, timeZone: timeZoneProp, currency: currencyProp, pagination: paginationMode, infiniteScroll: infiniteScrollProp, virtualizeRows, }: DynamicTableProps): import("react").JSX.Element;
124
+ export declare function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync, hideImport, hideExport, hiddenColumns, allowedActionKeys, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns, getDynamicColumns, timeZone: timeZoneProp, currency: currencyProp, pagination: paginationMode, infiniteScroll: infiniteScrollProp, virtualizeRows, extraBulkActions, hideBulkDelete, emptyState, }: DynamicTableProps): import("react").JSX.Element;
95
125
  //# sourceMappingURL=dynamic-table.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAkBA,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAmC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAyGnF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB;;;;;;;;;OASG;IACH,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAA;IAC5B,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;;;;;;;;;;;;;OAaG;IACH,UAAU,CAAC,EAAE,OAAO,GAAG,UAAU,CAAA;IACjC;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,OAAO,GAAG,MAAM,CAAA;CACpC;AAQD,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,gBAAgB,EAChB,aAAoB,EACpB,UAAU,EACV,UAAU,EACV,aAAkB,EAClB,iBAAiB,EACjB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EAAE,YAAY,EACtB,QAAQ,EAAE,YAAY,EACtB,UAAU,EAAE,cAAc,EAC1B,cAAc,EAAE,kBAA0B,EAC1C,cAAc,GACjB,EAAE,iBAAiB,+BA2kDnB"}
1
+ {"version":3,"file":"dynamic-table.d.ts","sourceRoot":"","sources":["../src/dynamic-table.tsx"],"names":[],"mappings":"AAYA,OAAO,EAAsE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAA;AAM1G,OAAO,EAKH,KAAK,SAAS,EAajB,MAAM,uBAAuB,CAAA;AAmC9B,OAAO,KAAK,EAAsB,iBAAiB,EAAE,MAAM,wBAAwB,CAAA;AAyGnF,MAAM,WAAW,iBAAiB;IAC9B,KAAK,EAAE,MAAM,CAAA;IACb,QAAQ,CAAC,EAAE,MAAM,CAAA;IACjB;;;;OAIG;IACH,gBAAgB,CAAC,EAAE,MAAM,CAAA;IACzB,aAAa,CAAC,EAAE,OAAO,CAAA;IACvB;;;;;OAKG;IACH,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,6DAA6D;IAC7D,UAAU,CAAC,EAAE,OAAO,CAAA;IACpB,aAAa,CAAC,EAAE,MAAM,EAAE,CAAA;IACxB;;;;;;;;;OASG;IACH,iBAAiB,CAAC,EAAE,MAAM,EAAE,CAAA;IAC5B,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;;;;;;;;;;;;;OAaG;IACH,UAAU,CAAC,EAAE,OAAO,GAAG,UAAU,CAAA;IACjC;;;;OAIG;IACH,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,OAAO,GAAG,MAAM,CAAA;IACjC;;;;;;;;;;;;OAYG;IACH,gBAAgB,CAAC,EAAE,SAAS,GAAG,CAAC,CAAC,GAAG,EAAE,uBAAuB,KAAK,SAAS,CAAC,CAAA;IAC5E,gGAAgG;IAChG,cAAc,CAAC,EAAE,OAAO,CAAA;IACxB;;;;OAIG;IACH,UAAU,CAAC,EAAE,SAAS,CAAA;CACzB;AAED,oFAAoF;AACpF,MAAM,WAAW,uBAAuB;IACpC,YAAY,EAAE,GAAG,EAAE,CAAA;IACnB,WAAW,EAAE,KAAK,CAAC,MAAM,GAAG,MAAM,CAAC,CAAA;IACnC,cAAc,EAAE,MAAM,IAAI,CAAA;IAC1B,OAAO,EAAE,MAAM,IAAI,CAAA;CACtB;AAQD,wBAAgB,YAAY,CAAC,EACzB,KAAK,EACL,QAAQ,EACR,gBAAgB,EAChB,aAAoB,EACpB,UAAU,EACV,UAAU,EACV,aAAkB,EAClB,iBAAiB,EACjB,QAAQ,EACR,UAAU,EACV,cAAc,EACd,cAAc,EACd,YAAiB,EACjB,iBAA4C,EAC5C,QAAQ,EAAE,YAAY,EACtB,QAAQ,EAAE,YAAY,EACtB,UAAU,EAAE,cAAc,EAC1B,cAAc,EAAE,kBAA0B,EAC1C,cAAc,EACd,gBAAgB,EAChB,cAAc,EACd,UAAU,GACb,EAAE,iBAAiB,+BAioDnB"}
@@ -118,7 +118,7 @@ function isForbiddenError(error) {
118
118
  const e = error;
119
119
  return e?.response?.status === 403 || e?.status === 403;
120
120
  }
121
- export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync = true, hideImport, hideExport, hiddenColumns = [], allowedActionKeys, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns = [], getDynamicColumns = defaultGetDynamicColumns, timeZone: timeZoneProp, currency: currencyProp, pagination: paginationMode, infiniteScroll: infiniteScrollProp = false, virtualizeRows, }) {
121
+ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync = true, hideImport, hideExport, hiddenColumns = [], allowedActionKeys, onAction, onRowClick, refreshTrigger, defaultFilters, extraColumns = [], getDynamicColumns = defaultGetDynamicColumns, timeZone: timeZoneProp, currency: currencyProp, pagination: paginationMode, infiniteScroll: infiniteScrollProp = false, virtualizeRows, extraBulkActions, hideBulkDelete, emptyState, }) {
122
122
  // The org's timezone/currency: an explicit prop wins, else the app-wide
123
123
  // OrgRuntimeProvider (without it, money fell back to USD).
124
124
  const orgTimeZone = useTimeZone();
@@ -165,6 +165,9 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
165
165
  // model. Rendered as "no permission" instead of masquerading as an empty
166
166
  // list. Purely display state — never a fetch dependency (no refetch loop).
167
167
  const [forbidden, setForbidden] = useState(false);
168
+ // A list request that failed for a reason other than 403 (500, network,
169
+ // `success: false`). Shown with a retry instead of a misleading "no results".
170
+ const [loadError, setLoadError] = useState(false);
168
171
  const infPageRef = useRef(1);
169
172
  const [optionsMap, setOptionsMap] = useState(new Map());
170
173
  const [exportOpen, setExportOpen] = useState(false);
@@ -744,6 +747,7 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
744
747
  params.include = relationInclude;
745
748
  const res = await api.get(endpoint || `/data/${model}`, { params });
746
749
  setForbidden(false);
750
+ setLoadError(!res.data.success);
747
751
  if (res.data.success) {
748
752
  const rows = res.data.data || [];
749
753
  setData(rows);
@@ -759,11 +763,13 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
759
763
  catch (error) {
760
764
  if (isForbiddenError(error)) {
761
765
  setForbidden(true);
766
+ setLoadError(false);
762
767
  setData([]);
763
768
  setRowCount(0);
764
769
  }
765
770
  else {
766
771
  console.error('Error al cargar los datos', error);
772
+ setLoadError(true);
767
773
  }
768
774
  }
769
775
  finally {
@@ -813,6 +819,7 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
813
819
  params,
814
820
  }));
815
821
  setForbidden(false);
822
+ setLoadError(!res.data.success);
816
823
  if (res.data.success) {
817
824
  const rows = res.data.data || [];
818
825
  setData((prev) => (append ? dedupeById(prev, rows) : rows));
@@ -832,6 +839,7 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
832
839
  catch (error) {
833
840
  if (isForbiddenError(error)) {
834
841
  setForbidden(true);
842
+ setLoadError(false);
835
843
  if (!append) {
836
844
  setData([]);
837
845
  setRowCount(0);
@@ -840,6 +848,7 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
840
848
  }
841
849
  else {
842
850
  console.error('Error al cargar los datos', error);
851
+ setLoadError(true);
843
852
  }
844
853
  }
845
854
  finally {
@@ -1237,6 +1246,10 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
1237
1246
  manualSorting: true,
1238
1247
  manualFiltering: true,
1239
1248
  enableRowSelection: true,
1249
+ // Key the selection by record id, not by position in the page: a bulk
1250
+ // action must act on the rows the user ticked, never on whatever row
1251
+ // lands at the same index after the data changes.
1252
+ getRowId: (row, index) => (row?.id != null ? String(row.id) : String(index)),
1240
1253
  onRowSelectionChange: setRowSelection,
1241
1254
  onSortingChange: setSorting,
1242
1255
  onColumnVisibilityChange: setColumnVisibility,
@@ -1251,6 +1264,19 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
1251
1264
  getFacetedUniqueValues: getFacetedUniqueValues(),
1252
1265
  });
1253
1266
  const tableRows = table.getRowModel().rows;
1267
+ const bulkSelectedRows = table.getFilteredSelectedRowModel().rows.map((r) => r.original);
1268
+ // Classic pagination swaps the visible rows on every page / sort / search /
1269
+ // filter change; drop the selection so it never refers to rows that are no
1270
+ // longer on screen. Infinite mode accumulates rows (selection is id-keyed,
1271
+ // so it stays valid) and is left alone.
1272
+ const selectionScope = infiniteScroll ? '' : JSON.stringify([pagination.pageIndex, pagination.pageSize, sorting, columnFilters, globalFilter]);
1273
+ const prevSelectionScope = useRef(selectionScope);
1274
+ useEffect(() => {
1275
+ if (prevSelectionScope.current !== selectionScope) {
1276
+ prevSelectionScope.current = selectionScope;
1277
+ setRowSelection({});
1278
+ }
1279
+ }, [selectionScope]);
1254
1280
  const virtualizeThreshold = resolveVirtualizeThreshold(virtualizeRows);
1255
1281
  const shouldVirtualize = virtualizeThreshold !== false && tableRows.length >= virtualizeThreshold;
1256
1282
  const colSpan = Math.max(columns.length, 1);
@@ -1302,7 +1328,7 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
1302
1328
  const isActionsColumn = cell.column.id === 'actions';
1303
1329
  const isSelectColumn = cell.column.id === 'select';
1304
1330
  return (_jsx(TableCell, { style: cell.column.columnDef.size ? { width: cell.column.columnDef.size } : undefined, className: cn('py-2', isActionsColumn && 'sticky right-0 bg-card shadow-[-2px_0_5px_-2px_rgba(0,0,0,0.1)]'), onClick: (isActionsColumn || isSelectColumn) ? (e) => e.stopPropagation() : undefined, children: flexRender(cell.column.columnDef.cell, cell.getContext()) }, cell.id));
1305
- }) }, row.id)))), aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && (_jsx(TableRow, { className: 'border-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0' }) }))] })) : (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0', children: _jsxs("div", { className: "flex h-full py-12 flex-col items-center justify-center gap-2 text-muted-foreground", children: [_jsx("div", { className: "flex h-20 w-20 items-center justify-center rounded-full bg-muted/50", children: _jsx(Inbox, { className: "h-10 w-10" }) }), _jsx("div", { className: "flex flex-col items-center gap-1", children: forbidden ? (_jsxs(_Fragment, { children: [_jsx("h3", { className: "text-lg font-semibold text-foreground", role: "alert", children: t('dynamic.forbidden_title', { defaultValue: 'Sin permiso para ver este módulo' }) }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('dynamic.forbidden_hint', { defaultValue: 'Tu rol no tiene acceso de lectura a estos datos. Pide acceso a un administrador.' }) })] })) : (_jsxs(_Fragment, { children: [_jsx("h3", { className: "text-lg font-semibold text-foreground", children: "No se encontraron resultados" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "No hay datos para mostrar en este momento." })] })) })] }) }) })) }), aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && (_jsx(TableFooter, { className: "bg-transparent", children: _jsx(TableRow, { className: "hover:bg-transparent", children: table.getVisibleLeafColumns().map((leaf, idx) => {
1331
+ }) }, row.id)))), aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && (_jsx(TableRow, { className: 'border-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0' }) }))] })) : (_jsx(TableRow, { className: 'border-b-0 hover:bg-transparent', children: _jsx(TableCell, { colSpan: columns.length, className: 'h-full p-0', children: _jsxs("div", { className: "flex h-full py-12 flex-col items-center justify-center gap-2 text-muted-foreground", children: [_jsx("div", { className: "flex h-20 w-20 items-center justify-center rounded-full bg-muted/50", children: _jsx(Inbox, { className: "h-10 w-10" }) }), _jsx("div", { className: "flex flex-col items-center gap-1", children: forbidden ? (_jsxs(_Fragment, { children: [_jsx("h3", { className: "text-lg font-semibold text-foreground", role: "alert", children: t('dynamic.forbidden_title', { defaultValue: 'Sin permiso para ver este módulo' }) }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('dynamic.forbidden_hint', { defaultValue: 'Tu rol no tiene acceso de lectura a estos datos. Pide acceso a un administrador.' }) })] })) : loadError ? (_jsxs(_Fragment, { children: [_jsx("h3", { className: "text-lg font-semibold text-foreground", role: "alert", children: t('dynamic.load_error_title', { defaultValue: 'No se pudieron cargar los datos' }) }), _jsx("p", { className: "text-sm text-muted-foreground", children: t('dynamic.load_error_hint', { defaultValue: 'Ocurrió un error al consultar la lista. Inténtalo de nuevo.' }) }), _jsx(Button, { variant: "outline", size: "sm", className: "mt-2", onClick: handleRefresh, children: t('dynamic.retry', { defaultValue: 'Reintentar' }) })] })) : emptyState && !hasActiveFilters ? (emptyState) : (_jsxs(_Fragment, { children: [_jsx("h3", { className: "text-lg font-semibold text-foreground", children: "No se encontraron resultados" }), _jsx("p", { className: "text-sm text-muted-foreground", children: "No hay datos para mostrar en este momento." })] })) })] }) }) })) }), aggregateColumns.length > 0 && Object.keys(footerTotals).length > 0 && (_jsx(TableFooter, { className: "bg-transparent", children: _jsx(TableRow, { className: "hover:bg-transparent", children: table.getVisibleLeafColumns().map((leaf, idx) => {
1306
1332
  const col = (metadata?.columns ?? []).find((c) => c.key === leaf.id);
1307
1333
  const isFirst = idx === 0;
1308
1334
  const stickyBase = 'sticky bottom-0 z-10 border-t bg-background py-2 font-semibold';
@@ -1342,9 +1368,16 @@ export function DynamicTable({ model, endpoint, mutationEndpoint, enableUrlSync
1342
1368
  (typeof header === 'string' ? header : cell.column.id);
1343
1369
  return (_jsxs("div", { className: 'flex items-start justify-between gap-3 text-sm', children: [_jsx("span", { className: 'shrink-0 text-muted-foreground', children: label }), _jsx("span", { className: 'min-w-0 overflow-hidden break-words text-right font-medium [&_[data-slot=badge]]:max-w-full [&_[data-slot=badge]]:whitespace-normal [&_[data-slot=badge]]:text-start', children: flexRender(cell.column.columnDef.cell, cell.getContext()) })] }, cell.id));
1344
1370
  }), actionsCell && (_jsx("div", { className: 'flex justify-end border-t pt-2', onClick: onRowClick ? (e) => e.stopPropagation() : undefined, children: flexRender(actionsCell.column.columnDef.cell, actionsCell.getContext()) }))] }, row.id));
1345
- }))) : (_jsxs("div", { className: 'flex flex-col items-center justify-center gap-2 rounded-lg border bg-card py-12 text-muted-foreground', children: [_jsx("div", { className: 'flex h-16 w-16 items-center justify-center rounded-full bg-muted/50', children: _jsx(Inbox, { className: 'h-8 w-8' }) }), forbidden ? (_jsxs(_Fragment, { children: [_jsx("h3", { className: 'text-base font-semibold text-foreground', role: 'alert', children: t('dynamic.forbidden_title', { defaultValue: 'Sin permiso para ver este módulo' }) }), _jsx("p", { className: 'text-sm text-muted-foreground', children: t('dynamic.forbidden_hint', { defaultValue: 'Tu rol no tiene acceso de lectura a estos datos. Pide acceso a un administrador.' }) })] })) : (_jsxs(_Fragment, { children: [_jsx("h3", { className: 'text-base font-semibold text-foreground', children: "No se encontraron resultados" }), _jsx("p", { className: 'text-sm text-muted-foreground', children: "No hay datos para mostrar en este momento." })] }))] })), infiniteScroll && (_jsxs(_Fragment, { children: [loadingMore && (_jsx(Skeleton, { className: 'h-16 w-full shrink-0', "data-testid": 'table-loading-more-mobile' })), _jsx("div", { ref: infMobileSentinel, className: 'h-1 w-full shrink-0', "aria-hidden": true })] }))] }), _jsx("div", { className: 'shrink-0 pt-4', children: infiniteScroll ? (data.length > 0 && (_jsx("p", { className: 'text-center text-xs text-muted-foreground tabular-nums', children: t('common.showingCount', {
1371
+ }))) : (_jsxs("div", { className: 'flex flex-col items-center justify-center gap-2 rounded-lg border bg-card py-12 text-muted-foreground', children: [_jsx("div", { className: 'flex h-16 w-16 items-center justify-center rounded-full bg-muted/50', children: _jsx(Inbox, { className: 'h-8 w-8' }) }), forbidden ? (_jsxs(_Fragment, { children: [_jsx("h3", { className: 'text-base font-semibold text-foreground', role: 'alert', children: t('dynamic.forbidden_title', { defaultValue: 'Sin permiso para ver este módulo' }) }), _jsx("p", { className: 'text-sm text-muted-foreground', children: t('dynamic.forbidden_hint', { defaultValue: 'Tu rol no tiene acceso de lectura a estos datos. Pide acceso a un administrador.' }) })] })) : loadError ? (_jsxs(_Fragment, { children: [_jsx("h3", { className: 'text-base font-semibold text-foreground', role: 'alert', children: t('dynamic.load_error_title', { defaultValue: 'No se pudieron cargar los datos' }) }), _jsx("p", { className: 'text-sm text-muted-foreground', children: t('dynamic.load_error_hint', { defaultValue: 'Ocurrió un error al consultar la lista. Inténtalo de nuevo.' }) }), _jsx(Button, { variant: 'outline', size: 'sm', onClick: handleRefresh, children: t('dynamic.retry', { defaultValue: 'Reintentar' }) })] })) : emptyState && !hasActiveFilters ? (emptyState) : (_jsxs(_Fragment, { children: [_jsx("h3", { className: 'text-base font-semibold text-foreground', children: "No se encontraron resultados" }), _jsx("p", { className: 'text-sm text-muted-foreground', children: "No hay datos para mostrar en este momento." })] }))] })), infiniteScroll && (_jsxs(_Fragment, { children: [loadingMore && (_jsx(Skeleton, { className: 'h-16 w-full shrink-0', "data-testid": 'table-loading-more-mobile' })), _jsx("div", { ref: infMobileSentinel, className: 'h-1 w-full shrink-0', "aria-hidden": true })] }))] }), _jsx("div", { className: 'shrink-0 pt-4', children: infiniteScroll ? (data.length > 0 && (_jsx("p", { className: 'text-center text-xs text-muted-foreground tabular-nums', children: t('common.showingCount', {
1346
1372
  defaultValue: '{{count}} de {{total}}',
1347
1373
  count: data.length,
1348
1374
  total: rowCount,
1349
- }) }))) : (_jsx(DataTablePagination, { table: table, pageSizeOptions: metadata.perPageOptions })) })] }), rowActionDialogs, _jsx(AlertDialog, { open: showBulkDeleteConfirm, onOpenChange: (open) => !open && !isBulkDeleting && setShowBulkDeleteConfirm(false), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: isBulkDeleting ? 'Eliminando registros...' : '¿Eliminar múltiples registros?' }), _jsx(AlertDialogDescription, { children: isBulkDeleting ? (_jsxs("div", { className: "space-y-4 mt-4", children: [_jsx(Progress, { value: (bulkDeleteProgress / bulkDeleteTotal) * 100 }), _jsxs("p", { className: "text-center text-sm", children: ["Procesando ", bulkDeleteProgress, " de ", bulkDeleteTotal, " registros..."] })] })) : (_jsxs(_Fragment, { children: ["Esta acci\u00F3n no se puede deshacer. Se eliminar\u00E1n permanentemente ", _jsx("strong", { children: Object.keys(rowSelection).length }), " registro(s) de nuestros servidores."] })) })] }), !isBulkDeleting && (_jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { children: t('common.cancel') }), _jsx(AlertDialogAction, { onClick: (e) => { e.preventDefault(); confirmBulkDelete(); }, className: "bg-red-600 hover:bg-red-700", children: "Eliminar todos" })] }))] }) }), bulkReasonPrompt.dialog, exportEnabled && (_jsx(ExportDialog, { open: exportOpen, onOpenChange: setExportOpen, model: model, metadata: metadata, currentFilters: buildFilterParams(), hasActiveFilters: hasActiveFilters })), importEnabled && (_jsx(ImportDialog, { open: importOpen, onOpenChange: setImportOpen, model: model, metadata: metadata, onImported: handleRefresh })), _jsx(DataTableBulkActions, { table: table, entityName: "registro", children: _jsxs(Button, { variant: "destructive", size: "sm", className: "h-8", onClick: () => setShowBulkDeleteConfirm(true), children: [_jsx(Trash2, { className: "h-4 w-4 mr-1.5" }), " Eliminar"] }) })] }) }));
1375
+ }) }))) : (_jsx(DataTablePagination, { table: table, pageSizeOptions: metadata.perPageOptions })) })] }), rowActionDialogs, _jsx(AlertDialog, { open: showBulkDeleteConfirm, onOpenChange: (open) => !open && !isBulkDeleting && setShowBulkDeleteConfirm(false), children: _jsxs(AlertDialogContent, { children: [_jsxs(AlertDialogHeader, { children: [_jsx(AlertDialogTitle, { children: isBulkDeleting ? 'Eliminando registros...' : '¿Eliminar múltiples registros?' }), _jsx(AlertDialogDescription, { children: isBulkDeleting ? (_jsxs("div", { className: "space-y-4 mt-4", children: [_jsx(Progress, { value: (bulkDeleteProgress / bulkDeleteTotal) * 100 }), _jsxs("p", { className: "text-center text-sm", children: ["Procesando ", bulkDeleteProgress, " de ", bulkDeleteTotal, " registros..."] })] })) : (_jsxs(_Fragment, { children: ["Esta acci\u00F3n no se puede deshacer. Se eliminar\u00E1n permanentemente ", _jsx("strong", { children: Object.keys(rowSelection).length }), " registro(s) de nuestros servidores."] })) })] }), !isBulkDeleting && (_jsxs(AlertDialogFooter, { children: [_jsx(AlertDialogCancel, { children: t('common.cancel') }), _jsx(AlertDialogAction, { onClick: (e) => { e.preventDefault(); confirmBulkDelete(); }, className: "bg-red-600 hover:bg-red-700", children: "Eliminar todos" })] }))] }) }), bulkReasonPrompt.dialog, exportEnabled && (_jsx(ExportDialog, { open: exportOpen, onOpenChange: setExportOpen, model: model, metadata: metadata, currentFilters: buildFilterParams(), hasActiveFilters: hasActiveFilters })), importEnabled && (_jsx(ImportDialog, { open: importOpen, onOpenChange: setImportOpen, model: model, metadata: metadata, onImported: handleRefresh })), _jsxs(DataTableBulkActions, { table: table, entityName: "registro", children: [typeof extraBulkActions === 'function'
1376
+ ? extraBulkActions({
1377
+ selectedRows: bulkSelectedRows,
1378
+ selectedIds: bulkSelectedRows.map((r) => r.id).filter((id) => id != null),
1379
+ clearSelection: () => table.resetRowSelection(),
1380
+ refresh: handleRefresh,
1381
+ })
1382
+ : extraBulkActions, !hideBulkDelete && (_jsxs(Button, { variant: "destructive", size: "sm", className: "h-8", onClick: () => setShowBulkDeleteConfirm(true), children: [_jsx(Trash2, { className: "h-4 w-4 mr-1.5" }), " Eliminar"] }))] })] }) }));
1350
1383
  }
@@ -1 +1 @@
1
- {"version":3,"file":"query-batch.d.ts","sourceRoot":"","sources":["../src/query-batch.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAE9C,MAAM,WAAW,SAAS;IACtB,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,YAAY,CAAC,EAAE,OAAO,CAAA;CACzB;AAyCD,MAAM,WAAW,WAAW;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,EAAE,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CAChB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAuB9D;AAkBD,MAAM,WAAW,UAAU;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAA;IAC/B,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CAChB;AAED,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,SAAa,GAAG,UAAU,GAAG,IAAI,CAsBhF;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,EAAE,EACb,IAAI,EAAE,OAAO,EACb,GAAG,SAAa,GACjB,IAAI,CAkBN;AAED,wBAAgB,oBAAoB,IAAI,IAAI,CAM3C;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAM/C;AAsBD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG9D;AAED,wBAAgB,iBAAiB,CAC7B,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,MAAM,EAAE,MAAM,GAAG,SAAS,GAC3B,MAAM,CAOR;AAED,oEAAoE;AACpE,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAYjG;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAsB/E;AAmED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,GAAG,IAAI,CA2BxF;AAMD;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE;;;GA2BxG"}
1
+ {"version":3,"file":"query-batch.d.ts","sourceRoot":"","sources":["../src/query-batch.ts"],"names":[],"mappings":"AAAA;;;;;;GAMG;AACH,OAAO,KAAK,EAAE,SAAS,EAAE,MAAM,eAAe,CAAA;AAE9C,MAAM,WAAW,SAAS;IACtB,OAAO,EAAE,OAAO,CAAA;IAChB,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,IAAI,CAAC,EAAE,OAAO,CAAA;IACd,OAAO,CAAC,EAAE,MAAM,CAAA;IAChB,IAAI,CAAC,EAAE,MAAM,CAAA;IACb,YAAY,CAAC,EAAE,OAAO,CAAA;CACzB;AAyCD,MAAM,WAAW,WAAW;IACxB,KAAK,EAAE,MAAM,CAAA;IACb,GAAG,EAAE,MAAM,EAAE,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CAChB;AAED;;;;GAIG;AACH,wBAAgB,YAAY,CAAC,KAAK,EAAE,MAAM,GAAG,WAAW,GAAG,IAAI,CAuB9D;AAkBD,MAAM,WAAW,UAAU;IACvB,IAAI,EAAE,MAAM,CAAA;IACZ,IAAI,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,CAAA;IAC/B,UAAU,EAAE,MAAM,EAAE,CAAA;IACpB,KAAK,EAAE,MAAM,CAAA;IACb,KAAK,EAAE,MAAM,CAAA;CAChB;AAED,0FAA0F;AAC1F,wBAAgB,aAAa,CAAC,KAAK,EAAE,MAAM,EAAE,GAAG,SAAa,GAAG,UAAU,GAAG,IAAI,CAsBhF;AAED;;;;GAIG;AACH,wBAAgB,cAAc,CAC1B,KAAK,EAAE,MAAM,EACb,GAAG,EAAE,MAAM,EAAE,EACb,IAAI,EAAE,OAAO,EACb,GAAG,SAAa,GACjB,IAAI,CAkBN;AAaD,wBAAgB,oBAAoB,IAAI,IAAI,CAO3C;AAED;;;;GAIG;AACH,wBAAgB,wBAAwB,IAAI,IAAI,CAM/C;AAsBD,wBAAgB,mBAAmB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAG9D;AAED,wBAAgB,iBAAiB,CAC7B,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,EACb,KAAK,EAAE,MAAM,GAAG,SAAS,EACzB,MAAM,EAAE,MAAM,GAAG,SAAS,GAC3B,MAAM,CAOR;AAED,oEAAoE;AACpE,wBAAgB,eAAe,CAAC,MAAM,EAAE,MAAM,EAAE,GAAG;IAAE,IAAI,EAAE,MAAM,CAAC;IAAC,KAAK,EAAE,MAAM,CAAC;IAAC,IAAI,EAAE,MAAM,CAAA;CAAE,EAAE,CAYjG;AAED,wBAAgB,aAAa,CAAC,GAAG,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,GAAG,OAAO,CAAC,SAAS,CAAC,CAsB/E;AAwED;;;GAGG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GAAG,MAAM,GAAG,IAAI,CA2BxF;AAMD;;;GAGG;AACH,wBAAsB,QAAQ,CAAC,GAAG,EAAE,SAAS,EAAE,GAAG,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE;IAAE,MAAM,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAA;CAAE;;;GA2BxG"}
@@ -122,7 +122,17 @@ export function rememberInRows(token, ids, rows, now = Date.now()) {
122
122
  entityCache.set(entityKey(scope, parsed.shape, id), { rows: bucket, at: now });
123
123
  }
124
124
  }
125
+ /**
126
+ * Clients whose backend has no `/q` (it answered 404/405). They get plain GETs
127
+ * for the rest of the session instead of one failed POST per read.
128
+ */
129
+ let unsupportedClients = new WeakSet();
130
+ function errorStatus(err) {
131
+ const e = err;
132
+ return e?.status ?? e?.response?.status;
133
+ }
125
134
  export function resetQueryBatchCache() {
135
+ unsupportedClients = new WeakSet();
126
136
  partCache.clear();
127
137
  entityCache.clear();
128
138
  waiters = [];
@@ -272,6 +282,12 @@ async function flushQueryBatch() {
272
282
  }
273
283
  }
274
284
  catch (err) {
285
+ // The host has no /q route: remember it so later reads skip the POST.
286
+ // Every waiter of this window still rejects and batchGet answers it
287
+ // with the plain GET, so no read is lost.
288
+ const status = errorStatus(err);
289
+ if (status === 404 || status === 405)
290
+ unsupportedClients.add(api);
275
291
  const error = err instanceof Error ? err : new Error(String(err));
276
292
  for (const waiter of batch)
277
293
  waiter.reject(error);
@@ -330,7 +346,7 @@ export async function batchGet(api, url, config) {
330
346
  // trailing `undefined` config reaching the host client.
331
347
  const plainGet = () => (config === undefined ? api.get(url) : api.get(url, config));
332
348
  const token = tokenForGet(url, config?.params);
333
- if (!token)
349
+ if (!token || unsupportedClients.has(api))
334
350
  return plainGet();
335
351
  try {
336
352
  const part = await loadQueryPart(api, token);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "49.4.0",
3
+ "version": "49.5.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -0,0 +1,83 @@
1
+ // @vitest-environment happy-dom
2
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3
+ import { renderHook } from '@testing-library/react'
4
+ import type { ReactNode } from 'react'
5
+ import { ApiProvider, useApi, type ApiClient } from '../api-context'
6
+ import { resetQueryBatchCache } from '../query-batch'
7
+
8
+ const rows = { success: true, data: [{ id: 1 }], meta: { total: 1 } }
9
+
10
+ function makeClient(post: ApiClient['post']) {
11
+ const get = vi.fn(async () => ({ data: rows }))
12
+ const client: ApiClient = {
13
+ get,
14
+ post,
15
+ put: vi.fn(async () => ({ data: {} })),
16
+ delete: vi.fn(async () => ({ data: {} })),
17
+ }
18
+ return { client, get }
19
+ }
20
+
21
+ function render(client: ApiClient, batch?: boolean) {
22
+ const wrapper = ({ children }: { children: ReactNode }) => (
23
+ <ApiProvider client={client} batch={batch}>
24
+ {children}
25
+ </ApiProvider>
26
+ )
27
+ return renderHook(() => useApi(), { wrapper }).result.current
28
+ }
29
+
30
+ describe('ApiProvider batch', () => {
31
+ beforeEach(() => resetQueryBatchCache())
32
+ afterEach(() => resetQueryBatchCache())
33
+
34
+ it('groups reads into one POST /q by default', async () => {
35
+ const post = vi.fn(async () => ({
36
+ data: { success: true, parts: { q1: { success: true, data: [{ id: 1 }], meta: { total: 1 } } } },
37
+ }))
38
+ const { client, get } = makeClient(post)
39
+ const api = render(client)
40
+ const res = await api.get('/data/products')
41
+ expect(post).toHaveBeenCalledTimes(1)
42
+ expect(post.mock.calls[0][0]).toBe('/q')
43
+ expect(get).not.toHaveBeenCalled()
44
+ expect(res.data.data).toEqual([{ id: 1 }])
45
+ })
46
+
47
+ it('does not emit POST /q with batch={false}', async () => {
48
+ const post = vi.fn(async () => ({ data: {} }))
49
+ const { client, get } = makeClient(post)
50
+ const api = render(client, false)
51
+ const res = await api.get('/data/products')
52
+ expect(post).not.toHaveBeenCalled()
53
+ expect(get).toHaveBeenCalledWith('/data/products')
54
+ expect(res.data).toEqual(rows)
55
+ })
56
+
57
+ it('turns itself off after a 404 and resolves the read through GET', async () => {
58
+ const post = vi.fn(async () => {
59
+ throw Object.assign(new Error('Not Found'), { response: { status: 404 } })
60
+ })
61
+ const { client, get } = makeClient(post)
62
+ const api = render(client)
63
+ const first = await api.get('/data/products')
64
+ expect(first.data).toEqual(rows)
65
+ expect(post).toHaveBeenCalledTimes(1)
66
+ await api.get('/metadata/table/products')
67
+ await api.get('/data/orders')
68
+ expect(post).toHaveBeenCalledTimes(1)
69
+ expect(get).toHaveBeenCalledTimes(3)
70
+ })
71
+
72
+ it('keeps trying /q after a transient 500', async () => {
73
+ const post = vi.fn(async () => {
74
+ throw Object.assign(new Error('boom'), { response: { status: 500 } })
75
+ })
76
+ const { client, get } = makeClient(post)
77
+ const api = render(client)
78
+ await api.get('/data/products')
79
+ await api.get('/data/orders')
80
+ expect(post).toHaveBeenCalledTimes(2)
81
+ expect(get).toHaveBeenCalledTimes(2)
82
+ })
83
+ })
@@ -0,0 +1,140 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // DynamicTable `extraBulkActions` / `hideBulkDelete`:
4
+ // 1. Host actions render in the floating bar once rows are selected and
5
+ // receive the selected rows / ids.
6
+ // 2. `clearSelection()` empties the selection.
7
+ // 3. Changing page drops the selection (classic pagination) so a bulk
8
+ // action can never target rows that are no longer visible.
9
+ // 4. `hideBulkDelete` removes the built-in "Eliminar".
10
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
11
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
12
+
13
+ vi.mock('@tanstack/react-router', () => ({
14
+ useNavigate: () => () => {},
15
+ }))
16
+ const I18N = {
17
+ t: (_k: string, o?: { defaultValue?: string; count?: number; total?: number }) => {
18
+ if (o?.defaultValue && o.count != null && o.total != null) {
19
+ return o.defaultValue.replace('{{count}}', String(o.count)).replace('{{total}}', String(o.total))
20
+ }
21
+ return o?.defaultValue ?? _k
22
+ },
23
+ i18n: { language: 'es' },
24
+ }
25
+ vi.mock('react-i18next', () => ({ useTranslation: () => I18N }))
26
+
27
+ import { DynamicTable } from '../dynamic-table'
28
+ import { makeDefaultGetDynamicColumns } from '../dynamic-columns'
29
+ import { ApiProvider, type ApiClient } from '../api-context'
30
+ import { useMetadataCache } from '../metadata-cache'
31
+ import type { TableMetadata } from '../types'
32
+
33
+ afterEach(cleanup)
34
+
35
+ const getDynamicColumns = makeDefaultGetDynamicColumns()
36
+
37
+ function meta(): TableMetadata {
38
+ return {
39
+ title: 'Issues',
40
+ endpoint: '/data/issue',
41
+ columns: [
42
+ { key: 'title', label: 'Title', type: 'text', sortable: true, filterable: false, searchable: true },
43
+ ],
44
+ actions: [],
45
+ perPageOptions: [10, 20, 50],
46
+ defaultPerPage: 10,
47
+ searchPlaceholder: 'Buscar...',
48
+ enableCRUDActions: false,
49
+ hasActions: false,
50
+ }
51
+ }
52
+
53
+ function fakeApi(total: number): ApiClient {
54
+ const ok = (data: unknown) => ({ data: { success: true, data, meta: { total } } })
55
+ return {
56
+ get: vi.fn(async (url: string, cfg?: any) => {
57
+ if (url.startsWith('/metadata/table/')) return ok(meta())
58
+ if (url.endsWith('/facets')) return ok([])
59
+ const p = cfg?.params?.page ?? 1
60
+ const size = cfg?.params?.per_page ?? 10
61
+ const rows: Array<{ id: number; title: string }> = []
62
+ for (let i = (p - 1) * size; i < Math.min(p * size, total); i++) rows.push({ id: i + 1, title: `Issue ${i + 1}` })
63
+ return ok(rows)
64
+ }),
65
+ post: vi.fn(async () => ok(null)),
66
+ put: vi.fn(async () => ok(null)),
67
+ delete: vi.fn(async () => ok(null)),
68
+ }
69
+ }
70
+
71
+ async function selectFirstRow() {
72
+ await screen.findAllByText('Issue 1')
73
+ const boxes = screen.getAllByRole('checkbox')
74
+ fireEvent.click(boxes[1]) // [0] is the select-all header checkbox
75
+ }
76
+
77
+ describe('DynamicTable extraBulkActions', () => {
78
+ beforeEach(() => {
79
+ localStorage.clear()
80
+ sessionStorage.clear()
81
+ useMetadataCache.getState().setMetadata('issue', meta())
82
+ })
83
+
84
+ it('renders host actions with the selection and lets them clear it', async () => {
85
+ const seen: unknown[] = []
86
+ render(
87
+ <ApiProvider client={fakeApi(5)}>
88
+ <DynamicTable
89
+ model="issue"
90
+ enableUrlSync={false} getDynamicColumns={getDynamicColumns}
91
+ extraBulkActions={({ selectedRows, selectedIds, clearSelection }) => (
92
+ <button onClick={() => { seen.push({ rows: selectedRows.length, ids: selectedIds }); clearSelection() }}>
93
+ Aprobar seleccionados
94
+ </button>
95
+ )}
96
+ />
97
+ </ApiProvider>,
98
+ )
99
+ expect(screen.queryByText('Aprobar seleccionados')).toBeNull()
100
+ await selectFirstRow()
101
+ fireEvent.click(await screen.findByText('Aprobar seleccionados'))
102
+ expect(seen).toEqual([{ rows: 1, ids: [1] }])
103
+ await waitFor(() => expect(screen.queryByText('Aprobar seleccionados')).toBeNull())
104
+ })
105
+
106
+ it('accepts a plain node and keeps the built-in delete by default', async () => {
107
+ render(
108
+ <ApiProvider client={fakeApi(5)}>
109
+ <DynamicTable model="issue" enableUrlSync={false} getDynamicColumns={getDynamicColumns} extraBulkActions={<button>Extra</button>} />
110
+ </ApiProvider>,
111
+ )
112
+ await selectFirstRow()
113
+ expect(await screen.findByText('Extra')).toBeTruthy()
114
+ expect(screen.getByText('Eliminar')).toBeTruthy()
115
+ })
116
+
117
+ it('hideBulkDelete removes the built-in delete', async () => {
118
+ render(
119
+ <ApiProvider client={fakeApi(5)}>
120
+ <DynamicTable model="issue" enableUrlSync={false} getDynamicColumns={getDynamicColumns} hideBulkDelete extraBulkActions={<button>Extra</button>} />
121
+ </ApiProvider>,
122
+ )
123
+ await selectFirstRow()
124
+ expect(await screen.findByText('Extra')).toBeTruthy()
125
+ expect(screen.queryByText('Eliminar')).toBeNull()
126
+ })
127
+
128
+ it('drops the selection when the page changes', async () => {
129
+ render(
130
+ <ApiProvider client={fakeApi(25)}>
131
+ <DynamicTable model="issue" enableUrlSync={false} getDynamicColumns={getDynamicColumns} extraBulkActions={<button>Extra</button>} />
132
+ </ApiProvider>,
133
+ )
134
+ await selectFirstRow()
135
+ expect(await screen.findByText('Extra')).toBeTruthy()
136
+ fireEvent.click(await screen.findByRole('button', { name: /siguiente|next/i }))
137
+ await screen.findAllByText('Issue 11')
138
+ await waitFor(() => expect(screen.queryByText('Extra')).toBeNull())
139
+ })
140
+ })
@@ -0,0 +1,97 @@
1
+ // @vitest-environment happy-dom
2
+ //
3
+ // Un fallo del endpoint de lista que NO es 403 (500, red, `success: false`)
4
+ // no debe verse como "sin resultados": se muestra "No se pudieron cargar los
5
+ // datos" con "Reintentar", que vuelve a pedir la lista. `emptyState` solo
6
+ // reemplaza el vacío cuando la lista cargó bien y no hay filtros activos.
7
+ import { afterEach, describe, expect, it, vi } from 'vitest'
8
+ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
9
+
10
+ vi.mock('@tanstack/react-router', () => ({ useNavigate: () => () => {} }))
11
+
12
+ import { ApiProvider, type ApiClient } from '../api-context'
13
+ import { useMetadataCache } from '../metadata-cache'
14
+ import { DynamicTable } from '../dynamic-table'
15
+ import type { TableMetadata } from '../types'
16
+
17
+ afterEach(cleanup)
18
+
19
+ const meta = {
20
+ title: 'Ajustes',
21
+ endpoint: '/data/load_err',
22
+ columns: [],
23
+ actions: [],
24
+ perPageOptions: [10],
25
+ defaultPerPage: 10,
26
+ searchPlaceholder: 'Buscar...',
27
+ enableCRUDActions: false,
28
+ hasActions: false,
29
+ } as unknown as TableMetadata
30
+
31
+ type ListResult = 'error500' | 'successFalse' | 'empty'
32
+
33
+ function mount(results: ListResult[], model: string, extra: Record<string, unknown> = {}) {
34
+ useMetadataCache.getState().setMetadata(model, meta)
35
+ let listCalls = 0
36
+ const client: ApiClient = {
37
+ get: vi.fn(async (url: string) => {
38
+ if (url.startsWith('/metadata/table/')) return { data: { success: true, data: meta } }
39
+ if (url.endsWith('/facets')) return { data: { success: true, data: [] } }
40
+ const r = results[Math.min(listCalls, results.length - 1)]
41
+ listCalls++
42
+ if (r === 'error500') throw Object.assign(new Error('500'), { response: { status: 500 } })
43
+ if (r === 'successFalse') return { data: { success: false, message: 'boom' } }
44
+ return { data: { success: true, data: [], meta: { total: 0 } } }
45
+ }),
46
+ post: vi.fn(async () => ({ data: { success: true, data: null } })),
47
+ put: vi.fn(async () => ({ data: { success: true, data: null } })),
48
+ delete: vi.fn(async () => ({ data: { success: true, data: null } })),
49
+ }
50
+ render(
51
+ <ApiProvider client={client}>
52
+ <DynamicTable model={model} enableUrlSync={false} {...extra} />
53
+ </ApiProvider>,
54
+ )
55
+ return () => listCalls
56
+ }
57
+
58
+ describe('DynamicTable — fallo de la lista (no 403)', () => {
59
+ it('un 500 muestra error con Reintentar, no "No se encontraron resultados"', async () => {
60
+ vi.spyOn(console, 'error').mockImplementation(() => {})
61
+ mount(['error500'], 'lerr_a')
62
+ expect((await screen.findAllByText('No se pudieron cargar los datos')).length).toBeGreaterThan(0)
63
+ expect(screen.queryByText('No se encontraron resultados')).toBeNull()
64
+ expect(screen.queryByText('Sin permiso para ver este módulo')).toBeNull()
65
+ })
66
+
67
+ it('`success: false` también es un error de carga', async () => {
68
+ mount(['successFalse'], 'lerr_b')
69
+ expect((await screen.findAllByText('No se pudieron cargar los datos')).length).toBeGreaterThan(0)
70
+ })
71
+
72
+ it('Reintentar vuelve a pedir la lista y, si ahora carga, limpia el error', async () => {
73
+ vi.spyOn(console, 'error').mockImplementation(() => {})
74
+ const calls = mount(['error500', 'empty'], 'lerr_c')
75
+ const retry = (await screen.findAllByRole('button', { name: 'Reintentar' }))[0]
76
+ const before = calls()
77
+ fireEvent.click(retry)
78
+ await waitFor(() => expect(calls()).toBeGreaterThan(before))
79
+ await waitFor(() => expect(screen.queryByText('No se pudieron cargar los datos')).toBeNull())
80
+ expect((await screen.findAllByText('No se encontraron resultados')).length).toBeGreaterThan(0)
81
+ })
82
+ })
83
+
84
+ describe('DynamicTable — emptyState', () => {
85
+ it('reemplaza el vacío cuando la lista cargó bien y no hay filtros', async () => {
86
+ mount(['empty'], 'lerr_d', { emptyState: <p>Aún no hay registros</p> })
87
+ expect((await screen.findAllByText('Aún no hay registros')).length).toBeGreaterThan(0)
88
+ expect(screen.queryByText('No se encontraron resultados')).toBeNull()
89
+ })
90
+
91
+ it('no tapa un error de carga', async () => {
92
+ vi.spyOn(console, 'error').mockImplementation(() => {})
93
+ mount(['error500'], 'lerr_e', { emptyState: <p>Aún no hay registros</p> })
94
+ expect((await screen.findAllByText('No se pudieron cargar los datos')).length).toBeGreaterThan(0)
95
+ expect(screen.queryByText('Aún no hay registros')).toBeNull()
96
+ })
97
+ })
@@ -15,13 +15,22 @@ export interface ApiClient {
15
15
  }
16
16
 
17
17
  const ApiContext = createContext<ApiClient | null>(null)
18
+ const BatchContext = createContext<boolean>(true)
18
19
 
19
20
  export interface ApiProviderProps {
20
21
  client: ApiClient
22
+ /**
23
+ * Groups list, metadata and options reads into one `POST /q`. Defaults to
24
+ * `true`. Pass `false` when the host backend does not implement `/q`: every
25
+ * read then goes straight to its own GET and no POST is attempted. When
26
+ * left on and `/q` answers 404/405, the batch turns itself off for that
27
+ * client after the first failed attempt and the read falls back to GET.
28
+ */
29
+ batch?: boolean
21
30
  children: React.ReactNode
22
31
  }
23
32
 
24
- export function ApiProvider({ client, children }: ApiProviderProps) {
33
+ export function ApiProvider({ client, batch = true, children }: ApiProviderProps) {
25
34
  // Hosts also write through their own client (native dialogs, settings
26
35
  // screens) without going through useApi(). When the client is axios,
27
36
  // every non-GET response drops what the runtime remembered as well.
@@ -43,7 +52,11 @@ export function ApiProvider({ client, children }: ApiProviderProps) {
43
52
  )
44
53
  return () => interceptors.eject?.(id)
45
54
  }, [client])
46
- return <ApiContext.Provider value={client}>{children}</ApiContext.Provider>
55
+ return (
56
+ <ApiContext.Provider value={client}>
57
+ <BatchContext.Provider value={batch}>{children}</BatchContext.Provider>
58
+ </ApiContext.Provider>
59
+ )
47
60
  }
48
61
 
49
62
  interface AxiosLike {
@@ -82,6 +95,7 @@ function mutating<T>(request: Promise<T>): Promise<T> {
82
95
  /** Returns the host-injected api client. Throws if no <ApiProvider> is mounted. */
83
96
  export function useApi(): ApiClient {
84
97
  const ctx = useContext(ApiContext)
98
+ const batch = useContext(BatchContext)
85
99
  if (!ctx) {
86
100
  throw new Error('useApi() requires an <ApiProvider> ancestor. Hosts must inject an axios-like client via runtime-react ApiProvider.')
87
101
  }
@@ -92,12 +106,13 @@ export function useApi(): ApiClient {
92
106
  return useMemo<ApiClient>(() => ({
93
107
  // Each call reaches the host client with the arguments it was given —
94
108
  // never a trailing `undefined` it didn't ask for.
95
- get: (...args: Parameters<ApiClient['get']>) => batchGet(ctx, ...args),
109
+ get: (...args: Parameters<ApiClient['get']>) =>
110
+ batch ? batchGet(ctx, ...args) : ctx.get(...args),
96
111
  post: (...args: Parameters<ApiClient['post']>) =>
97
112
  isWrite('post', args[0]) ? mutating(ctx.post(...args)) : ctx.post(...args),
98
113
  put: (...args: Parameters<ApiClient['put']>) => mutating(ctx.put(...args)),
99
114
  delete: (...args: Parameters<ApiClient['delete']>) => mutating(ctx.delete(...args)),
100
- }), [ctx])
115
+ }), [ctx, batch])
101
116
  }
102
117
 
103
118
  /** Optional branch context — hosts that support tenant branches can supply
@@ -10,7 +10,7 @@
10
10
  // * `@/components/dynamic/dynamic-columns` → host-injected via the
11
11
  // `getDynamicColumns` prop (hosts retain ownership because the rendered
12
12
  // column cells are tightly coupled to their design system).
13
- import { useEffect, useState, useMemo, useCallback, useRef, type MouseEvent } from 'react'
13
+ import { useEffect, useState, useMemo, useCallback, useRef, type MouseEvent, type ReactNode } from 'react'
14
14
  import { useTranslation } from 'react-i18next'
15
15
  import { useTimeZone, useCurrency } from './org-runtime-context'
16
16
  import { format } from 'date-fns'
@@ -264,6 +264,36 @@ export interface DynamicTableProps {
264
264
  * render every row. A positive number overrides the threshold.
265
265
  */
266
266
  virtualizeRows?: boolean | number
267
+ /**
268
+ * Host-provided actions rendered in the floating bar that appears while
269
+ * rows are selected, next to the built-in "Eliminar". Pass a node, or a
270
+ * function that receives the current selection:
271
+ * - `selectedRows`: the selected row objects (current page only);
272
+ * - `selectedIds`: their `id`s;
273
+ * - `clearSelection()`: unselect everything (call after a successful action);
274
+ * - `refresh()`: reload the list.
275
+ * The selection is cleared whenever the page, page size, sort, search or
276
+ * column filters change (classic pagination), so a bulk action can never
277
+ * hit rows the user no longer sees. The host owns the write call and its
278
+ * authorization — the table only supplies the selection.
279
+ */
280
+ extraBulkActions?: ReactNode | ((ctx: DynamicTableBulkContext) => ReactNode)
281
+ /** Hide the built-in bulk "Eliminar" (e.g. a model whose rows must not be deleted in batch). */
282
+ hideBulkDelete?: boolean
283
+ /**
284
+ * First-use content shown instead of "No se encontraron resultados" when the
285
+ * list loaded fine, is empty, and no search/filter is active. Never shown
286
+ * after a load error (a retry is offered) or a 403.
287
+ */
288
+ emptyState?: ReactNode
289
+ }
290
+
291
+ /** Context handed to `DynamicTableProps.extraBulkActions` when it is a function. */
292
+ export interface DynamicTableBulkContext {
293
+ selectedRows: any[]
294
+ selectedIds: Array<string | number>
295
+ clearSelection: () => void
296
+ refresh: () => void
267
297
  }
268
298
 
269
299
  /** True when an api-client error is an HTTP 403 (axios-style or fetch-style). */
@@ -292,6 +322,9 @@ export function DynamicTable({
292
322
  pagination: paginationMode,
293
323
  infiniteScroll: infiniteScrollProp = false,
294
324
  virtualizeRows,
325
+ extraBulkActions,
326
+ hideBulkDelete,
327
+ emptyState,
295
328
  }: DynamicTableProps) {
296
329
  // The org's timezone/currency: an explicit prop wins, else the app-wide
297
330
  // OrgRuntimeProvider (without it, money fell back to USD).
@@ -347,6 +380,9 @@ export function DynamicTable({
347
380
  // model. Rendered as "no permission" instead of masquerading as an empty
348
381
  // list. Purely display state — never a fetch dependency (no refetch loop).
349
382
  const [forbidden, setForbidden] = useState(false)
383
+ // A list request that failed for a reason other than 403 (500, network,
384
+ // `success: false`). Shown with a retry instead of a misleading "no results".
385
+ const [loadError, setLoadError] = useState(false)
350
386
  const infPageRef = useRef(1)
351
387
  const [optionsMap, setOptionsMap] = useState<Map<string, any[]>>(new Map())
352
388
 
@@ -897,6 +933,7 @@ export function DynamicTable({
897
933
  if (relationInclude) params.include = relationInclude
898
934
  const res = await api.get(endpoint || `/data/${model}`, { params }) as { data: ApiResponse<any[]> }
899
935
  setForbidden(false)
936
+ setLoadError(!res.data.success)
900
937
  if (res.data.success) {
901
938
  const rows = res.data.data || []
902
939
  setData(rows)
@@ -914,10 +951,12 @@ export function DynamicTable({
914
951
  } catch (error) {
915
952
  if (isForbiddenError(error)) {
916
953
  setForbidden(true)
954
+ setLoadError(false)
917
955
  setData([])
918
956
  setRowCount(0)
919
957
  } else {
920
958
  console.error('Error al cargar los datos', error)
959
+ setLoadError(true)
921
960
  }
922
961
  } finally {
923
962
  setLoadingData(false)
@@ -968,6 +1007,7 @@ export function DynamicTable({
968
1007
  params,
969
1008
  })) as { data: ApiResponse<any[]> }
970
1009
  setForbidden(false)
1010
+ setLoadError(!res.data.success)
971
1011
  if (res.data.success) {
972
1012
  const rows = res.data.data || []
973
1013
  setData((prev) => (append ? dedupeById(prev, rows) : rows))
@@ -989,6 +1029,7 @@ export function DynamicTable({
989
1029
  } catch (error) {
990
1030
  if (isForbiddenError(error)) {
991
1031
  setForbidden(true)
1032
+ setLoadError(false)
992
1033
  if (!append) {
993
1034
  setData([])
994
1035
  setRowCount(0)
@@ -996,6 +1037,7 @@ export function DynamicTable({
996
1037
  setInfExhausted(true)
997
1038
  } else {
998
1039
  console.error('Error al cargar los datos', error)
1040
+ setLoadError(true)
999
1041
  }
1000
1042
  } finally {
1001
1043
  if (append) setLoadingMore(false)
@@ -1372,6 +1414,10 @@ export function DynamicTable({
1372
1414
  manualSorting: true,
1373
1415
  manualFiltering: true,
1374
1416
  enableRowSelection: true,
1417
+ // Key the selection by record id, not by position in the page: a bulk
1418
+ // action must act on the rows the user ticked, never on whatever row
1419
+ // lands at the same index after the data changes.
1420
+ getRowId: (row: any, index: number) => (row?.id != null ? String(row.id) : String(index)),
1375
1421
  onRowSelectionChange: setRowSelection,
1376
1422
  onSortingChange: setSorting,
1377
1423
  onColumnVisibilityChange: setColumnVisibility,
@@ -1387,6 +1433,21 @@ export function DynamicTable({
1387
1433
  })
1388
1434
 
1389
1435
  const tableRows = table.getRowModel().rows
1436
+ const bulkSelectedRows = table.getFilteredSelectedRowModel().rows.map((r) => r.original as any)
1437
+
1438
+ // Classic pagination swaps the visible rows on every page / sort / search /
1439
+ // filter change; drop the selection so it never refers to rows that are no
1440
+ // longer on screen. Infinite mode accumulates rows (selection is id-keyed,
1441
+ // so it stays valid) and is left alone.
1442
+ const selectionScope = infiniteScroll ? '' : JSON.stringify([pagination.pageIndex, pagination.pageSize, sorting, columnFilters, globalFilter])
1443
+ const prevSelectionScope = useRef(selectionScope)
1444
+ useEffect(() => {
1445
+ if (prevSelectionScope.current !== selectionScope) {
1446
+ prevSelectionScope.current = selectionScope
1447
+ setRowSelection({})
1448
+ }
1449
+ }, [selectionScope])
1450
+
1390
1451
  const virtualizeThreshold = resolveVirtualizeThreshold(virtualizeRows)
1391
1452
  const shouldVirtualize =
1392
1453
  virtualizeThreshold !== false && tableRows.length >= virtualizeThreshold
@@ -1624,6 +1685,14 @@ export function DynamicTable({
1624
1685
  <h3 className="text-lg font-semibold text-foreground" role="alert">{t('dynamic.forbidden_title', { defaultValue: 'Sin permiso para ver este módulo' })}</h3>
1625
1686
  <p className="text-sm text-muted-foreground">{t('dynamic.forbidden_hint', { defaultValue: 'Tu rol no tiene acceso de lectura a estos datos. Pide acceso a un administrador.' })}</p>
1626
1687
  </>
1688
+ ) : loadError ? (
1689
+ <>
1690
+ <h3 className="text-lg font-semibold text-foreground" role="alert">{t('dynamic.load_error_title', { defaultValue: 'No se pudieron cargar los datos' })}</h3>
1691
+ <p className="text-sm text-muted-foreground">{t('dynamic.load_error_hint', { defaultValue: 'Ocurrió un error al consultar la lista. Inténtalo de nuevo.' })}</p>
1692
+ <Button variant="outline" size="sm" className="mt-2" onClick={handleRefresh}>{t('dynamic.retry', { defaultValue: 'Reintentar' })}</Button>
1693
+ </>
1694
+ ) : emptyState && !hasActiveFilters ? (
1695
+ emptyState
1627
1696
  ) : (
1628
1697
  <>
1629
1698
  <h3 className="text-lg font-semibold text-foreground">No se encontraron resultados</h3>
@@ -1823,6 +1892,14 @@ export function DynamicTable({
1823
1892
  <h3 className='text-base font-semibold text-foreground' role='alert'>{t('dynamic.forbidden_title', { defaultValue: 'Sin permiso para ver este módulo' })}</h3>
1824
1893
  <p className='text-sm text-muted-foreground'>{t('dynamic.forbidden_hint', { defaultValue: 'Tu rol no tiene acceso de lectura a estos datos. Pide acceso a un administrador.' })}</p>
1825
1894
  </>
1895
+ ) : loadError ? (
1896
+ <>
1897
+ <h3 className='text-base font-semibold text-foreground' role='alert'>{t('dynamic.load_error_title', { defaultValue: 'No se pudieron cargar los datos' })}</h3>
1898
+ <p className='text-sm text-muted-foreground'>{t('dynamic.load_error_hint', { defaultValue: 'Ocurrió un error al consultar la lista. Inténtalo de nuevo.' })}</p>
1899
+ <Button variant='outline' size='sm' onClick={handleRefresh}>{t('dynamic.retry', { defaultValue: 'Reintentar' })}</Button>
1900
+ </>
1901
+ ) : emptyState && !hasActiveFilters ? (
1902
+ emptyState
1826
1903
  ) : (
1827
1904
  <>
1828
1905
  <h3 className='text-base font-semibold text-foreground'>No se encontraron resultados</h3>
@@ -1896,9 +1973,19 @@ export function DynamicTable({
1896
1973
  <ImportDialog open={importOpen} onOpenChange={setImportOpen} model={model} metadata={metadata} onImported={handleRefresh} />
1897
1974
  )}
1898
1975
  <DataTableBulkActions table={table} entityName="registro">
1899
- <Button variant="destructive" size="sm" className="h-8" onClick={() => setShowBulkDeleteConfirm(true)}>
1900
- <Trash2 className="h-4 w-4 mr-1.5" /> Eliminar
1901
- </Button>
1976
+ {typeof extraBulkActions === 'function'
1977
+ ? extraBulkActions({
1978
+ selectedRows: bulkSelectedRows,
1979
+ selectedIds: bulkSelectedRows.map((r) => r.id).filter((id) => id != null),
1980
+ clearSelection: () => table.resetRowSelection(),
1981
+ refresh: handleRefresh,
1982
+ })
1983
+ : extraBulkActions}
1984
+ {!hideBulkDelete && (
1985
+ <Button variant="destructive" size="sm" className="h-8" onClick={() => setShowBulkDeleteConfirm(true)}>
1986
+ <Trash2 className="h-4 w-4 mr-1.5" /> Eliminar
1987
+ </Button>
1988
+ )}
1902
1989
  </DataTableBulkActions>
1903
1990
  </OptionsContext.Provider>
1904
1991
  </RowActionsModelContext.Provider>
@@ -170,7 +170,19 @@ export function rememberInRows(
170
170
  }
171
171
  }
172
172
 
173
+ /**
174
+ * Clients whose backend has no `/q` (it answered 404/405). They get plain GETs
175
+ * for the rest of the session instead of one failed POST per read.
176
+ */
177
+ let unsupportedClients = new WeakSet<object>()
178
+
179
+ function errorStatus(err: unknown): number | undefined {
180
+ const e = err as { status?: number; response?: { status?: number } } | null
181
+ return e?.status ?? e?.response?.status
182
+ }
183
+
173
184
  export function resetQueryBatchCache(): void {
185
+ unsupportedClients = new WeakSet<object>()
174
186
  partCache.clear()
175
187
  entityCache.clear()
176
188
  waiters = []
@@ -320,6 +332,11 @@ async function flushQueryBatch() {
320
332
  waiter.resolve(merged)
321
333
  }
322
334
  } catch (err) {
335
+ // The host has no /q route: remember it so later reads skip the POST.
336
+ // Every waiter of this window still rejects and batchGet answers it
337
+ // with the plain GET, so no read is lost.
338
+ const status = errorStatus(err)
339
+ if (status === 404 || status === 405) unsupportedClients.add(api)
323
340
  const error = err instanceof Error ? err : new Error(String(err))
324
341
  for (const waiter of batch) waiter.reject(error)
325
342
  }
@@ -381,7 +398,7 @@ export async function batchGet(api: ApiClient, url: string, config?: { params?:
381
398
  // trailing `undefined` config reaching the host client.
382
399
  const plainGet = () => (config === undefined ? api.get(url) : api.get(url, config))
383
400
  const token = tokenForGet(url, config?.params)
384
- if (!token) return plainGet()
401
+ if (!token || unsupportedClients.has(api)) return plainGet()
385
402
  try {
386
403
  const part = await loadQueryPart(api, token)
387
404
  if (!part.success) {