@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 +8 -0
- package/dist/api-context.d.ts +9 -1
- package/dist/api-context.d.ts.map +1 -1
- package/dist/api-context.js +6 -4
- package/dist/dynamic-table.d.ts +31 -1
- package/dist/dynamic-table.d.ts.map +1 -1
- package/dist/dynamic-table.js +37 -4
- package/dist/query-batch.d.ts.map +1 -1
- package/dist/query-batch.js +17 -1
- package/package.json +1 -1
- package/src/__tests__/api-batch-opt-out.test.tsx +83 -0
- package/src/__tests__/dynamic-table-bulk-actions.test.tsx +140 -0
- package/src/__tests__/dynamic-table-load-error.test.tsx +97 -0
- package/src/api-context.tsx +19 -4
- package/src/dynamic-table.tsx +91 -4
- package/src/query-batch.ts +18 -1
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
|
package/dist/api-context.d.ts
CHANGED
|
@@ -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;
|
|
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"}
|
package/dist/api-context.js
CHANGED
|
@@ -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
|
-
|
|
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 }) {
|
package/dist/dynamic-table.d.ts
CHANGED
|
@@ -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":"
|
|
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"}
|
package/dist/dynamic-table.js
CHANGED
|
@@ -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 })),
|
|
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;
|
|
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"}
|
package/dist/query-batch.js
CHANGED
|
@@ -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
|
@@ -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
|
+
})
|
package/src/api-context.tsx
CHANGED
|
@@ -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
|
|
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']>) =>
|
|
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
|
package/src/dynamic-table.tsx
CHANGED
|
@@ -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
|
-
|
|
1900
|
-
|
|
1901
|
-
|
|
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>
|
package/src/query-batch.ts
CHANGED
|
@@ -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) {
|