innoboxrr-react-datatable 2.2.0 → 3.0.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/README.md CHANGED
@@ -1,87 +1,103 @@
1
1
  # innoboxrr-react-datatable
2
2
 
3
- Gemelo React de [`innoboxrr-vue-datatable`](../vue-datatable). Los mismos props,
4
- el mismo contrato de modelo.
3
+ Gemelo React de [`innoboxrr-vue-datatable`](../vue-datatable): los mismos
4
+ props, el mismo contrato de modelo y el mismo comportamiento —barra de
5
+ acciones, filtros, orden y paginación en el servidor, selección con acciones
6
+ masivas, permisos antes de abrir cada menú, esqueletos y errores que se ven—.
5
7
 
6
- El contrato vive en `resources/<framework>/src/models/<entity>/index.js`, que
7
- es **el mismo archivo** para Vue y para React: funciones puras y llamadas HTTP,
8
- sin nada de un framework de UI. Por eso las dos tablas reciben lo mismo.
8
+ Por debajo, [TanStack Table](https://tanstack.com/table) lleva el estado de la
9
+ tabla y `innoboxrr-react-form-elements` pone el menú, los iconos y los
10
+ esqueletos, con el tema de `innoboxrr-form-core`.
9
11
 
10
12
  ## Instalación
11
13
 
12
14
  ```
13
- npm i innoboxrr-react-datatable
15
+ npm i innoboxrr-react-datatable innoboxrr-form-core innoboxrr-react-form-elements react-router-dom
16
+ ```
17
+
18
+ ```js
19
+ import 'innoboxrr-form-core/styles'
14
20
  ```
15
21
 
16
22
  ## Uso
17
23
 
18
24
  ```jsx
25
+ import { useRef, useState } from 'react'
19
26
  import DataTable, { registerRoutes } from 'innoboxrr-react-datatable'
20
- import * as postModel from './models/post'
27
+ import * as productModel from './models/product'
21
28
 
22
29
  registerRoutes({
23
- AdminCreatePost: '/admin/posts/create',
24
- AdminEditPost: '/admin/posts/:id/edit',
25
- AdminShowPost: '/admin/posts/:id',
30
+ AdminCreateProduct: '/admin/products/create',
31
+ AdminEditProduct: '/admin/products/:id/edit',
26
32
  })
27
33
 
28
- <DataTable
29
- dataUrl={route('api.acme.blog.post.index')}
30
- policyUrl={route('api.acme.blog.post.policies')}
31
- model={postModel}
32
- filterForm={<FilterForm onSubmit={setFilters} />} />
34
+ export default function Products() {
35
+ const table = useRef(null)
36
+ const [filters, setFilters] = useState({})
37
+
38
+ // Tras crear o editar en un drawer: table.current.refresh()
39
+
40
+ return (
41
+ <DataTable
42
+ ref={table}
43
+ dataUrl={route('api.acme.shop.product.index')}
44
+ dataMethod="get"
45
+ policyUrl={route('api.acme.shop.product.policies')}
46
+ policyMethod="get"
47
+ model={productModel}
48
+ formFilters={filters}
49
+ selectable
50
+ filterForm={<FilterForm onSubmit={setFilters} />} />
51
+ )
52
+ }
33
53
  ```
34
54
 
55
+ Los avisos y las confirmaciones necesitan, una vez en la aplicación,
56
+ `ToastRegionComponent` y `ConfirmHostComponent` de
57
+ `innoboxrr-react-form-elements`.
58
+
59
+ Los props, el contrato del modelo, `bulkActions`, los permisos y lo que se ve
60
+ mientras carga o cuando falla están descritos en el
61
+ [README de la versión Vue](../vue-datatable/README.md): son idénticos.
62
+
35
63
  ## Equivalencias con la versión Vue
36
64
 
37
65
  | Vue | React |
38
66
  |---|---|
39
- | `<slot name="filterForm">` | prop `filterForm` (React no tiene slots con nombre) |
40
- | `@sortColumn`, `@actionClicked`… | props `onSortColumn`, `onActionClicked`… |
41
- | `defineExpose({ crudActions, dataTable, pagination })` | el hook `useDataTable`, exportado |
42
- | rutas con nombre de vue-router | `registerRoutes()` (ver abajo) |
43
- | `uk-toggle` sobre el formulario de filtros | estado del componente |
67
+ | `<template #filterForm>` | prop `filterForm` |
68
+ | `ref` con `defineExpose` | `ref` (React 19): `refresh()`, `clearSelection()`, `selectedIds`, `table` |
69
+ | rutas con nombre de vue-router | `registerRoutes()` (abajo) |
70
+ | componente de celda con `@callback` | componente de celda con `onCallback` |
44
71
 
45
- Todo lo demás —`dataUrl`, `dataMethod`, `model`, `policyUrl`, `policyMethod`,
46
- `showTopbar`, `hasActions`, `hasFilter`, `formFilters`, `externalFilters`,
47
- `extraParams`, `extraQuery`, `hideColumns`, `cardWrapper`, `showTableHeader`—
48
- se llama y significa lo mismo.
72
+ La lógica común —columnas, petición, errores, acciones— vive en
73
+ `src/table.js`, que es el mismo archivo en los dos paquetes.
49
74
 
50
75
  ## Rutas con nombre
51
76
 
52
- El contrato del modelo apunta a rutas **por nombre**:
53
-
54
- ```js
55
- params: { to: { name: 'AdminEditPost', params: { id: 1 } } }
56
- ```
57
-
58
- vue-router resuelve eso de fábrica; React Router 7 no tiene rutas con nombre.
59
- `registerRoutes({ nombre: patrón })` cierra ese hueco, y el módulo generado lo
60
- llama al montarse. Una ruta sin registrar **lanza**: devolver `#` escondería el
61
- fallo hasta que alguien hiciera clic.
77
+ El contrato apunta a rutas **por nombre** (`params.to.name`). React Router 7 no
78
+ las tiene, así que `registerRoutes({ nombre: patrón })` dice a qué patrón
79
+ corresponde cada una. Una acción hacia una ruta sin registrar avisa al usuario y
80
+ deja el detalle en la consola.
62
81
 
63
82
  ## `useDataTable`
64
83
 
65
- Toda la lógica —cargar, ordenar, paginar, resolver políticas— está en el hook,
66
- fuera del componente. Se puede probar sin montar nada y sirve para pintar la
67
- misma tabla de otra forma:
68
-
69
84
  ```jsx
70
- const { dataTable, pagination, sortColumn, updatePage } = useDataTable({ ... })
85
+ import { useDataTable } from 'innoboxrr-react-datatable'
86
+
87
+ const { table, rows, meta, loading, error, sortColumn, updatePage, refresh } = useDataTable({ ...props, navigate, labels })
71
88
  ```
72
89
 
73
- ## Diferencias deliberadas
74
-
75
- - **El clon de cada fila se hace una vez por repintado**, no en una caché de
76
- módulo. La versión Vue guarda los clones en un `WeakMap` global, así que un
77
- `parser` que escriba en su fila envenena esa copia para el resto de la vida
78
- de la página, y dos tablas que compartan objetos de fila comparten clones.
79
- - **La paginación no tiene estado propio.** En Vue lo tenía y se le
80
- desincronizaba cuando la página cambiaba desde fuera, por ejemplo al
81
- reiniciar los filtros.
82
- - **`ActionListComponent` es uno solo.** En Vue el bloque de acciones está
83
- copiado en `DataTable.vue` y en `DataTableComponent.vue`, y las dos copias no
84
- hacen lo mismo: la de dentro soporta `action.link` y la de fuera no.
90
+ ## De 2.x a 3.0
91
+
92
+ - Ya no existen `ActionListComponent`, `DatatableIcon`, `NavDropdownComponent`,
93
+ `IconRouteComponent`, `IconLinkComponent` ni `DisabledLinkComponent`.
94
+ - `DataTableComponent` recibe la instancia de TanStack Table en `table`;
95
+ `SelectPaginationComponent` sigue recibiendo `meta` y `onPageChange`.
96
+ - Las acciones de ruta navegan desde el menú con `useNavigate`.
97
+ - Los textos están en español y se cambian con `labels`.
98
+ - Un fallo ya no se reintenta: se ve, y se reintenta a mano.
99
+ - `innoboxrr-react-form-elements` pasa a ser dependencia par, y
100
+ `innoboxrr-form-core` sube a `^2.6`.
85
101
 
86
102
  ## Pruebas
87
103
 
package/index.js CHANGED
@@ -1,10 +1,10 @@
1
1
  /**
2
2
  * Gemelo React de innoboxrr-vue-datatable.
3
3
  *
4
- * El export por defecto es la tabla, igual que en la version Vue. Se exponen
5
- * ademas el hook con la logica (por si alguien quiere pintarla de otra forma)
6
- * y el registro de rutas con nombre, que es lo que React Router no trae y el
7
- * contrato del modelo necesita.
4
+ * El export por defecto es la tabla, igual que en la versión Vue. Se exponen
5
+ * además la lógica (por si alguien quiere pintarla de otra forma), las piezas
6
+ * con las que está hecha y el registro de rutas con nombre, que es lo que
7
+ * React Router no trae y el contrato del modelo necesita.
8
8
  */
9
9
 
10
10
  import DataTable from './src/DataTable.jsx'
@@ -15,3 +15,4 @@ export { default as useDataTable } from './src/useDataTable.js'
15
15
  export { buildPath, hasRoute, registerRoutes, resetRoutes } from './src/routes.js'
16
16
  export { default as DataTableComponent } from './src/components/DataTableComponent.jsx'
17
17
  export { default as SelectPaginationComponent } from './src/components/SelectPaginationComponent.jsx'
18
+ export { DEFAULT_LABELS } from './src/table.js'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "innoboxrr-react-datatable",
3
- "version": "2.2.0",
3
+ "version": "3.0.0",
4
4
  "description": "Gemelo React de innoboxrr-vue-datatable: el mismo contrato de modelo, la misma tabla.",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -37,11 +37,12 @@
37
37
  "node": ">=20"
38
38
  },
39
39
  "dependencies": {
40
- "@iconify/react": "^6.0.0",
40
+ "@tanstack/react-table": "^9.2.4",
41
41
  "axios": "^1.7.0"
42
42
  },
43
43
  "peerDependencies": {
44
- "innoboxrr-form-core": "^2.2.0",
44
+ "innoboxrr-form-core": "^2.6.0",
45
+ "innoboxrr-react-form-elements": "^3.4.0",
45
46
  "react": "^19.0.0",
46
47
  "react-dom": "^19.0.0",
47
48
  "react-router-dom": "^7.0.0"
@@ -51,6 +52,8 @@
51
52
  "@testing-library/react": "^16.1.0",
52
53
  "@testing-library/user-event": "^14.5.2",
53
54
  "@vitejs/plugin-react": "^5.0.0",
55
+ "innoboxrr-form-core": "^2.6.0",
56
+ "innoboxrr-react-form-elements": "^3.4.0",
54
57
  "jsdom": "^25.0.0",
55
58
  "react": "^19.0.0",
56
59
  "react-dom": "^19.0.0",
package/src/DataTable.jsx CHANGED
@@ -1,9 +1,14 @@
1
- import { useState } from 'react'
2
- import ActionListComponent from './components/ActionListComponent.jsx'
1
+ import { useCallback, useId, useImperativeHandle, useMemo, useState } from 'react'
2
+ import { useNavigate } from 'react-router-dom'
3
+ import IconComponent from 'innoboxrr-react-form-elements/src/IconComponent.jsx'
4
+ import MenuComponent from 'innoboxrr-react-form-elements/src/MenuComponent.jsx'
5
+
3
6
  import DataTableComponent from './components/DataTableComponent.jsx'
4
- import NavDropdownComponent from './components/NavDropdownComponent.jsx'
5
7
  import SelectPaginationComponent from './components/SelectPaginationComponent.jsx'
8
+ import { buildPath } from './routes.js'
9
+ import { DEFAULT_LABELS, menuItems } from './table.js'
6
10
  import useDataTable from './useDataTable.js'
11
+ import useTheme, { joinClasses } from './useTheme.js'
7
12
 
8
13
  /**
9
14
  * Gemelo de DataTable.vue.
@@ -13,10 +18,11 @@ import useDataTable from './useDataTable.js'
13
18
  * puras y llamadas HTTP, sin nada de un framework de UI. Por eso este
14
19
  * componente recibe exactamente los mismos props que su gemelo.
15
20
  *
16
- * La única diferencia real: el slot `filterForm` de Vue aquí es el prop
17
- * `filterForm`, porque React no tiene slots con nombre.
21
+ * Las diferencias son de React: el slot `filterForm` es un prop, y lo que Vue
22
+ * expone con defineExpose —`refresh()`, `clearSelection()`— llega por `ref`.
18
23
  */
19
24
  export default function DataTable({
25
+ ref = null,
20
26
  dataUrl,
21
27
  dataMethod = 'post',
22
28
  model,
@@ -32,19 +38,46 @@ export default function DataTable({
32
38
  hideColumns = [],
33
39
  cardWrapper = true,
34
40
  showTableHeader = true,
41
+ selectable = false,
42
+ labels = null,
35
43
  filterForm = null,
36
44
  }) {
45
+ const theme = useTheme()
46
+ const routerNavigate = useNavigate()
47
+
37
48
  const [filtersOpen, setFiltersOpen] = useState(false)
49
+ const filtersId = useId()
50
+
51
+ const text = useMemo(() => ({ ...DEFAULT_LABELS, ...(labels ?? {}) }), [labels])
52
+
53
+ // React Router no tiene rutas con nombre: el contrato apunta a un nombre y
54
+ // `registerRoutes` dice a qué patrón corresponde.
55
+ const navigate = useCallback(
56
+ (target) => routerNavigate(buildPath(target.name, target.params, target.query)),
57
+ [routerNavigate]
58
+ )
38
59
 
39
60
  const {
40
- dataTable,
41
- pagination,
61
+ table,
62
+ visibleHead,
63
+ rows,
64
+ clones,
65
+ meta,
66
+ loading,
67
+ error,
68
+ sort,
69
+ orderBy,
42
70
  crudActions,
43
- updateFilters,
71
+ bulkActions,
72
+ rowActions,
73
+ selectedIds,
74
+ refresh,
75
+ clearSelection,
44
76
  sortColumn,
45
77
  updatePage,
46
- actionClicked,
47
- actionButtonClicked,
78
+ preparePolicies,
79
+ run,
80
+ runBulk,
48
81
  } = useDataTable({
49
82
  dataUrl,
50
83
  dataMethod,
@@ -53,99 +86,135 @@ export default function DataTable({
53
86
  policyMethod,
54
87
  formFilters,
55
88
  externalFilters,
89
+ extraParams,
90
+ extraQuery,
56
91
  hideColumns,
92
+ selectable,
93
+ labels: text,
94
+ navigate,
57
95
  })
58
96
 
59
- const dataTableComponents = typeof model.dataTableComponents === 'function'
60
- ? model.dataTableComponents()
61
- : {}
97
+ useImperativeHandle(ref, () => ({ refresh, clearSelection, selectedIds, table }), [refresh, clearSelection, selectedIds, table])
98
+
99
+ const dataTableComponents = useMemo(
100
+ () => (typeof model.dataTableComponents === 'function' ? model.dataTableComponents() : {}),
101
+ [model]
102
+ )
103
+
104
+ const crudItems = useMemo(() => menuItems(crudActions, text, run), [crudActions, text, run])
105
+ const rowItems = useCallback((row) => menuItems(rowActions(row), text, run), [rowActions, text, run])
106
+ const prepareCrud = useCallback(() => preparePolicies(null), [preparePolicies])
107
+
108
+ const bulkVisible = selectable && selectedIds.length > 0
109
+
110
+ const renderBar = () => {
111
+ // Con filas seleccionadas, la barra dice cuántas y qué hacer con ellas,
112
+ // en el mismo sitio que la de siempre para que la tabla no salte.
113
+ if (bulkVisible) {
114
+ return (
115
+ <div className={theme.bulkBar} role="region" aria-label={text.selection}>
116
+ <span className={theme.bulkCount} aria-live="polite">{selectedIds.length} {text.selected}</span>
117
+
118
+ {bulkActions.map((action) => (
119
+ <button
120
+ key={action.id ?? action.name}
121
+ type="button"
122
+ className={joinClasses(action.danger ? theme.buttonDanger : theme.buttonSecondary, 'fe-button-sm')}
123
+ onClick={() => runBulk(action)}>
124
+ {action.icon ? <IconComponent name={action.icon} size={14} /> : null}
125
+ <span>{action.name}</span>
126
+ </button>
127
+ ))}
128
+
129
+ <span className={theme.toolbarSpacer} />
130
+
131
+ <button type="button" className={theme.buttonLink} onClick={() => clearSelection()}>
132
+ {text.clearSelection}
133
+ </button>
134
+ </div>
135
+ )
136
+ }
137
+
138
+ if (! showTopbar) {
139
+ return null
140
+ }
141
+
142
+ return (
143
+ <div className={theme.toolbar}>
144
+ {hasActions ? (
145
+ <MenuComponent
146
+ items={crudItems}
147
+ label={text.actions}
148
+ placement="bottom-start"
149
+ beforeOpen={prepareCrud}
150
+ renderTrigger={({ toggle, loading: busy, triggerProps }) => (
151
+ <button
152
+ type="button"
153
+ className={theme.buttonSecondary}
154
+ {...triggerProps}
155
+ disabled={busy}
156
+ onClick={toggle}>
157
+ <IconComponent name="actions" size={14} />
158
+ <span>{text.actions}</span>
159
+ </button>
160
+ )} />
161
+ ) : null}
162
+
163
+ <span className={theme.toolbarSpacer} />
164
+
165
+ <button type="button" className={theme.iconButton} aria-label={text.refresh} onClick={() => refresh()}>
166
+ <IconComponent name="refresh" size={16} />
167
+ </button>
168
+
169
+ {hasFilter ? (
170
+ <button
171
+ type="button"
172
+ className={theme.iconButton}
173
+ aria-label={text.filters}
174
+ aria-expanded={filtersOpen ? 'true' : 'false'}
175
+ aria-controls={filtersId}
176
+ onClick={() => setFiltersOpen((open) => ! open)}>
177
+ <IconComponent name="filter" size={16} />
178
+ </button>
179
+ ) : null}
180
+ </div>
181
+ )
182
+ }
62
183
 
63
184
  return (
64
- <div>
65
- {showTopbar ? (
66
- <div>
67
- <div className="fe-container fe-container-wide pt-4">
68
- <div fe-grid="">
69
- {hasActions ? (
70
- <div className="fe-w-expand">
71
- <button
72
- type="button"
73
- className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 mr-2 mb-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
74
- onClick={() => actionButtonClicked(crudActions)}>
75
- Acciones
76
- </button>
77
-
78
- <NavDropdownComponent id="actionCrudDropdown" pos="right">
79
- <ActionListComponent
80
- actions={crudActions}
81
- extraParams={extraParams}
82
- extraQuery={extraQuery}
83
- onActionClicked={actionClicked} />
84
- </NavDropdownComponent>
85
- </div>
86
- ) : (
87
- <div><div className="fe-w-expand"></div></div>
88
- )}
89
-
90
- {hasFilter ? (
91
- <div className="fe-w-auto">
92
- <div className="fe-grid-divider fe-children-expand fe-text-center" fe-grid="">
93
- <div>
94
- <button
95
- type="button"
96
- aria-label="Update results"
97
- className="fe-text-right pointer"
98
- onClick={updateFilters}>
99
- <svg className="w-6 h-6 text-slate-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 18 20">
100
- <path stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M16 1v5h-5M2 19v-5h5m10-4a8 8 0 0 1-14.947 3.97M1 10a8 8 0 0 1 14.947-3.97" />
101
- </svg>
102
- </button>
103
- </div>
104
-
105
- <div>
106
- <button
107
- type="button"
108
- aria-label="Buscar"
109
- aria-expanded={filtersOpen}
110
- className="fe-text-right pointer"
111
- onClick={() => setFiltersOpen((open) => ! open)}>
112
- <svg className="w-6 h-6 text-slate-800 dark:text-white" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 20 18">
113
- <path d="M18.85 1.1A1.99 1.99 0 0 0 17.063 0H2.937a2 2 0 0 0-1.566 3.242L6.99 9.868 7 14a1 1 0 0 0 .4.8l4 3A1 1 0 0 0 13 17l.01-7.134 5.66-6.676a1.99 1.99 0 0 0 .18-2.09Z" />
114
- </svg>
115
- </button>
116
- </div>
117
- </div>
118
- </div>
119
- ) : null}
120
- </div>
121
- </div>
122
-
123
- {hasFilter ? (
124
- // El uk-toggle de la version Vue trabaja sobre el DOM
125
- // por su cuenta; en React el estado gobierna la vista.
126
- <div className="filter-form fe-card fe-card-body fe-pt-0" hidden={! filtersOpen}>
127
- {filterForm}
128
- </div>
129
- ) : null}
185
+ <section className={joinClasses(theme.datatable, cardWrapper && theme.surface)}>
186
+ {renderBar()}
187
+
188
+ {showTopbar && hasFilter ? (
189
+ <div id={filtersId} className={joinClasses('filter-form', theme.datatableFilters)} hidden={! filtersOpen}>
190
+ {filterForm}
130
191
  </div>
131
192
  ) : null}
132
193
 
133
- <div className={`fe-container fe-container-wide${showTopbar ? ' ptb-20' : ''}`}>
134
- <div className={`fe-p-sm${cardWrapper ? ' bg-white p-6 rounded-lg shadow dark:border-slate-700 dark:bg-slate-800' : ''}`}>
135
- <DataTableComponent
136
- actions={hasActions}
137
- dataTable={dataTable}
138
- extraParams={extraParams}
139
- extraQuery={extraQuery}
140
- showTableHeader={showTableHeader}
141
- dataTableComponents={dataTableComponents}
142
- onSortColumn={sortColumn}
143
- onActionButtonClicked={actionButtonClicked}
144
- onActionClicked={actionClicked} />
145
-
146
- <SelectPaginationComponent meta={pagination.meta} onPageChange={updatePage} />
147
- </div>
148
- </div>
149
- </div>
194
+ <DataTableComponent
195
+ table={table}
196
+ head={visibleHead}
197
+ rows={rows}
198
+ clones={clones}
199
+ loading={loading}
200
+ error={error}
201
+ actions={hasActions}
202
+ selectable={selectable}
203
+ showTableHeader={showTableHeader}
204
+ dataTableComponents={dataTableComponents}
205
+ labels={text}
206
+ orderBy={orderBy}
207
+ sort={sort}
208
+ itemsFor={rowItems}
209
+ prepareRow={preparePolicies}
210
+ onSortColumn={sortColumn}
211
+ onRetry={refresh} />
212
+
213
+ {/* Con un error y sin filas, un «No hay resultados» en el pie
214
+ contradiría el motivo que ya da la tabla. */}
215
+ {error && rows.length === 0 ? null : (
216
+ <SelectPaginationComponent meta={meta} labels={text} onPageChange={updatePage} />
217
+ )}
218
+ </section>
150
219
  )
151
220
  }