innoboxrr-react-datatable 2.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 +90 -0
- package/index.js +17 -0
- package/package.json +60 -0
- package/src/DataTable.jsx +151 -0
- package/src/components/ActionListComponent.jsx +62 -0
- package/src/components/DataTableComponent.jsx +118 -0
- package/src/components/DisabledLinkComponent.jsx +20 -0
- package/src/components/IconLinkComponent.jsx +26 -0
- package/src/components/IconRouteComponent.jsx +33 -0
- package/src/components/NavDropdownComponent.jsx +35 -0
- package/src/components/SelectPaginationComponent.jsx +69 -0
- package/src/routes.js +86 -0
- package/src/useDataTable.js +256 -0
package/README.md
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# innoboxrr-react-datatable
|
|
2
|
+
|
|
3
|
+
Gemelo React de [`innoboxrr-vue-datatable`](../vue-datatable). Los mismos props,
|
|
4
|
+
el mismo contrato de modelo.
|
|
5
|
+
|
|
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.
|
|
9
|
+
|
|
10
|
+
## Instalación
|
|
11
|
+
|
|
12
|
+
```
|
|
13
|
+
npm i innoboxrr-react-datatable
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
## Uso
|
|
17
|
+
|
|
18
|
+
```jsx
|
|
19
|
+
import DataTable, { registerRoutes } from 'innoboxrr-react-datatable'
|
|
20
|
+
import * as postModel from './models/post'
|
|
21
|
+
|
|
22
|
+
registerRoutes({
|
|
23
|
+
AdminCreatePost: '/admin/posts/create',
|
|
24
|
+
AdminEditPost: '/admin/posts/:id/edit',
|
|
25
|
+
AdminShowPost: '/admin/posts/:id',
|
|
26
|
+
})
|
|
27
|
+
|
|
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} />} />
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## Equivalencias con la versión Vue
|
|
36
|
+
|
|
37
|
+
| Vue | React |
|
|
38
|
+
|---|---|
|
|
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 |
|
|
44
|
+
|
|
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.
|
|
49
|
+
|
|
50
|
+
## Rutas con nombre
|
|
51
|
+
|
|
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.
|
|
62
|
+
|
|
63
|
+
## `useDataTable`
|
|
64
|
+
|
|
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
|
+
```jsx
|
|
70
|
+
const { dataTable, pagination, sortColumn, updatePage } = useDataTable({ ... })
|
|
71
|
+
```
|
|
72
|
+
|
|
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.
|
|
85
|
+
|
|
86
|
+
## Pruebas
|
|
87
|
+
|
|
88
|
+
```
|
|
89
|
+
npm test
|
|
90
|
+
```
|
package/index.js
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemelo React de innoboxrr-vue-datatable.
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import DataTable from './src/DataTable.jsx'
|
|
11
|
+
|
|
12
|
+
export default DataTable
|
|
13
|
+
|
|
14
|
+
export { default as useDataTable } from './src/useDataTable.js'
|
|
15
|
+
export { buildPath, hasRoute, registerRoutes, resetRoutes } from './src/routes.js'
|
|
16
|
+
export { default as DataTableComponent } from './src/components/DataTableComponent.jsx'
|
|
17
|
+
export { default as SelectPaginationComponent } from './src/components/SelectPaginationComponent.jsx'
|
package/package.json
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "innoboxrr-react-datatable",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Gemelo React de innoboxrr-vue-datatable: el mismo contrato de modelo, la misma tabla.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./src/*": "./src/*"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"src"
|
|
14
|
+
],
|
|
15
|
+
"sideEffects": [
|
|
16
|
+
"*.css"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"test": "vitest run",
|
|
20
|
+
"test:watch": "vitest"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/innoboxrr/react-datatable.git"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"react",
|
|
28
|
+
"react19",
|
|
29
|
+
"datatable",
|
|
30
|
+
"table",
|
|
31
|
+
"crud",
|
|
32
|
+
"innoboxrr"
|
|
33
|
+
],
|
|
34
|
+
"author": "Homero Raul Vargas Cruz",
|
|
35
|
+
"license": "MIT",
|
|
36
|
+
"engines": {
|
|
37
|
+
"node": ">=20"
|
|
38
|
+
},
|
|
39
|
+
"dependencies": {
|
|
40
|
+
"axios": "^1.7.0"
|
|
41
|
+
},
|
|
42
|
+
"peerDependencies": {
|
|
43
|
+
"innoboxrr-form-core": "^2.0.0",
|
|
44
|
+
"react": "^19.0.0",
|
|
45
|
+
"react-dom": "^19.0.0",
|
|
46
|
+
"react-router-dom": "^7.0.0"
|
|
47
|
+
},
|
|
48
|
+
"devDependencies": {
|
|
49
|
+
"@testing-library/jest-dom": "^6.6.0",
|
|
50
|
+
"@testing-library/react": "^16.1.0",
|
|
51
|
+
"@testing-library/user-event": "^14.5.2",
|
|
52
|
+
"@vitejs/plugin-react": "^5.0.0",
|
|
53
|
+
"jsdom": "^25.0.0",
|
|
54
|
+
"react": "^19.0.0",
|
|
55
|
+
"react-dom": "^19.0.0",
|
|
56
|
+
"react-router-dom": "^7.0.0",
|
|
57
|
+
"vite": "^7.1.0",
|
|
58
|
+
"vitest": "^3.0.0"
|
|
59
|
+
}
|
|
60
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { useState } from 'react'
|
|
2
|
+
import ActionListComponent from './components/ActionListComponent.jsx'
|
|
3
|
+
import DataTableComponent from './components/DataTableComponent.jsx'
|
|
4
|
+
import NavDropdownComponent from './components/NavDropdownComponent.jsx'
|
|
5
|
+
import SelectPaginationComponent from './components/SelectPaginationComponent.jsx'
|
|
6
|
+
import useDataTable from './useDataTable.js'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Gemelo de DataTable.vue.
|
|
10
|
+
*
|
|
11
|
+
* `model` es el contrato de `resources/<framework>/src/models/<entity>/
|
|
12
|
+
* index.js`, que es **el mismo archivo** para Vue y para React: funciones
|
|
13
|
+
* puras y llamadas HTTP, sin nada de un framework de UI. Por eso este
|
|
14
|
+
* componente recibe exactamente los mismos props que su gemelo.
|
|
15
|
+
*
|
|
16
|
+
* La única diferencia real: el slot `filterForm` de Vue aquí es el prop
|
|
17
|
+
* `filterForm`, porque React no tiene slots con nombre.
|
|
18
|
+
*/
|
|
19
|
+
export default function DataTable({
|
|
20
|
+
dataUrl,
|
|
21
|
+
dataMethod = 'post',
|
|
22
|
+
model,
|
|
23
|
+
policyUrl,
|
|
24
|
+
policyMethod = 'post',
|
|
25
|
+
showTopbar = true,
|
|
26
|
+
hasActions = true,
|
|
27
|
+
hasFilter = true,
|
|
28
|
+
formFilters = {},
|
|
29
|
+
externalFilters = {},
|
|
30
|
+
extraParams = {},
|
|
31
|
+
extraQuery = {},
|
|
32
|
+
hideColumns = [],
|
|
33
|
+
cardWrapper = true,
|
|
34
|
+
showTableHeader = true,
|
|
35
|
+
filterForm = null,
|
|
36
|
+
}) {
|
|
37
|
+
const [filtersOpen, setFiltersOpen] = useState(false)
|
|
38
|
+
|
|
39
|
+
const {
|
|
40
|
+
dataTable,
|
|
41
|
+
pagination,
|
|
42
|
+
crudActions,
|
|
43
|
+
updateFilters,
|
|
44
|
+
sortColumn,
|
|
45
|
+
updatePage,
|
|
46
|
+
actionClicked,
|
|
47
|
+
actionButtonClicked,
|
|
48
|
+
} = useDataTable({
|
|
49
|
+
dataUrl,
|
|
50
|
+
dataMethod,
|
|
51
|
+
model,
|
|
52
|
+
policyUrl,
|
|
53
|
+
policyMethod,
|
|
54
|
+
formFilters,
|
|
55
|
+
externalFilters,
|
|
56
|
+
hideColumns,
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const dataTableComponents = typeof model.dataTableComponents === 'function'
|
|
60
|
+
? model.dataTableComponents()
|
|
61
|
+
: {}
|
|
62
|
+
|
|
63
|
+
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}
|
|
130
|
+
</div>
|
|
131
|
+
) : null}
|
|
132
|
+
|
|
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>
|
|
150
|
+
)
|
|
151
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import DisabledLinkComponent from './DisabledLinkComponent.jsx'
|
|
2
|
+
import IconLinkComponent from './IconLinkComponent.jsx'
|
|
3
|
+
import IconRouteComponent from './IconRouteComponent.jsx'
|
|
4
|
+
|
|
5
|
+
const closeDropdown = (event) => {
|
|
6
|
+
const dropdown = event.target.closest('.uk-dropdown')
|
|
7
|
+
|
|
8
|
+
// UIkit lo aporta la aplicación anfitriona.
|
|
9
|
+
if (dropdown) {
|
|
10
|
+
globalThis.UIkit?.dropdown(dropdown)?.hide(false)
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Las tres formas que puede tomar una acción del contrato del modelo, en un
|
|
16
|
+
* solo sitio. En la versión Vue este bloque está copiado en `DataTable.vue` y
|
|
17
|
+
* en `DataTableComponent.vue`, con una diferencia entre ambas copias — la de
|
|
18
|
+
* dentro soporta `action.link` y la de fuera no.
|
|
19
|
+
*/
|
|
20
|
+
export default function ActionListComponent({ actions = [], extraParams = {}, extraQuery = {}, onActionClicked }) {
|
|
21
|
+
return actions.map((action) => (
|
|
22
|
+
<li key={action.id ?? action.name} className="hover:bg-slate-100 dark:hover:bg-slate-600 px-2 py-1">
|
|
23
|
+
{(() => {
|
|
24
|
+
if (! action.policy) {
|
|
25
|
+
return <DisabledLinkComponent icon={action.icon} text={action.name} />
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
if (action.route && ! action.link) {
|
|
29
|
+
return (
|
|
30
|
+
<IconRouteComponent
|
|
31
|
+
name={action.params.to.name}
|
|
32
|
+
params={{ ...action.params.to.params, ...extraParams }}
|
|
33
|
+
query={action.params.to.query ? { ...action.params.to.query, ...extraQuery } : { ...extraQuery }}
|
|
34
|
+
icon={action.icon}
|
|
35
|
+
text={action.name} />
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (action.route && action.link) {
|
|
40
|
+
return (
|
|
41
|
+
<IconLinkComponent
|
|
42
|
+
link={action.params.link}
|
|
43
|
+
target={action.params.target}
|
|
44
|
+
icon={action.icon}
|
|
45
|
+
text={action.name} />
|
|
46
|
+
)
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return (
|
|
50
|
+
<IconLinkComponent
|
|
51
|
+
icon={action.icon}
|
|
52
|
+
text={action.name}
|
|
53
|
+
onClick={(event) => {
|
|
54
|
+
event.preventDefault()
|
|
55
|
+
onActionClicked?.(action)
|
|
56
|
+
closeDropdown(event)
|
|
57
|
+
}} />
|
|
58
|
+
)
|
|
59
|
+
})()}
|
|
60
|
+
</li>
|
|
61
|
+
))
|
|
62
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import { useMemo } from 'react'
|
|
2
|
+
import ActionListComponent from './ActionListComponent.jsx'
|
|
3
|
+
import NavDropdownComponent from './NavDropdownComponent.jsx'
|
|
4
|
+
|
|
5
|
+
const cellValue = (head, row) => (
|
|
6
|
+
typeof head.parser === 'function' ? head.parser(row[head.id], row) : row[head.id]
|
|
7
|
+
)
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Gemelo de DataTableComponent.vue: la tabla propiamente dicha.
|
|
11
|
+
*/
|
|
12
|
+
export default function DataTableComponent({
|
|
13
|
+
actions = false,
|
|
14
|
+
dataTable,
|
|
15
|
+
extraParams = {},
|
|
16
|
+
extraQuery = {},
|
|
17
|
+
showTableHeader = true,
|
|
18
|
+
dataTableComponents = {},
|
|
19
|
+
onSortColumn,
|
|
20
|
+
onActionButtonClicked,
|
|
21
|
+
onActionClicked,
|
|
22
|
+
}) {
|
|
23
|
+
const head = useMemo(() => dataTable.head ?? [], [dataTable.head])
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Copia aislada de cada fila, para que un parser del modelo no pueda mutar
|
|
27
|
+
* los datos de la tabla.
|
|
28
|
+
*
|
|
29
|
+
* La versión Vue clona con JSON dentro de setData(), es decir una vez por
|
|
30
|
+
* celda: con 20 filas y 8 columnas son 160 clonados en cada repintado. Un
|
|
31
|
+
* clon por fila y repintado deja lo mismo en 20 — y, a diferencia de una
|
|
32
|
+
* caché de módulo, un parser que escriba en su copia no la envenena para
|
|
33
|
+
* los repintados siguientes.
|
|
34
|
+
*/
|
|
35
|
+
const body = useMemo(
|
|
36
|
+
() => (dataTable.body ?? []).map((row) => structuredClone(row)),
|
|
37
|
+
[dataTable.body]
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="sm:rounded-lg overflow-x-auto">
|
|
42
|
+
<table className="min-w-full w-full text-sm text-left text-slate-500 dark:text-slate-400 p-4">
|
|
43
|
+
{showTableHeader ? (
|
|
44
|
+
<thead className="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 rounded-sm">
|
|
45
|
+
<tr>
|
|
46
|
+
{head.map((column) => (
|
|
47
|
+
<th
|
|
48
|
+
key={column.id}
|
|
49
|
+
id={`th_${column.id}`}
|
|
50
|
+
className={`px-6 py-3${column.sortable ? ' pointer' : ''}`}
|
|
51
|
+
scope="col"
|
|
52
|
+
onClick={() => onSortColumn?.(column)}>
|
|
53
|
+
{column.value}
|
|
54
|
+
</th>
|
|
55
|
+
))}
|
|
56
|
+
{actions ? <th className="fe-shrink"></th> : null}
|
|
57
|
+
</tr>
|
|
58
|
+
</thead>
|
|
59
|
+
) : null}
|
|
60
|
+
|
|
61
|
+
<tbody>
|
|
62
|
+
{(dataTable.body ?? []).map((row, index) => (
|
|
63
|
+
<tr
|
|
64
|
+
key={row.id}
|
|
65
|
+
className="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
|
|
66
|
+
{head.map((column) => {
|
|
67
|
+
// Las celdas leen de la copia; las acciones,
|
|
68
|
+
// de la fila real, porque su identidad es lo
|
|
69
|
+
// que el hook usa para sustituirlas al
|
|
70
|
+
// resolver las politicas.
|
|
71
|
+
const value = cellValue(column, body[index] ?? row)
|
|
72
|
+
const Component = column.component ? dataTableComponents[column.component] : null
|
|
73
|
+
|
|
74
|
+
return (
|
|
75
|
+
<td key={column.id} className="px-6 py-4">
|
|
76
|
+
{Component ? (
|
|
77
|
+
<Component
|
|
78
|
+
{...(typeof value === 'object' && value !== null ? value : { value })}
|
|
79
|
+
onCallback={(payload) => (
|
|
80
|
+
typeof column.callback === 'function'
|
|
81
|
+
? column.callback(payload, row)
|
|
82
|
+
: null
|
|
83
|
+
)} />
|
|
84
|
+
) : column.html ? (
|
|
85
|
+
<span className="dark:text-white" dangerouslySetInnerHTML={{ __html: value }}></span>
|
|
86
|
+
) : (
|
|
87
|
+
<span className="dark:text-white">{value}</span>
|
|
88
|
+
)}
|
|
89
|
+
</td>
|
|
90
|
+
)
|
|
91
|
+
})}
|
|
92
|
+
|
|
93
|
+
{actions ? (
|
|
94
|
+
<td className="fe-text-right">
|
|
95
|
+
<button
|
|
96
|
+
type="button"
|
|
97
|
+
aria-label={`Acciones del registro ${row.id}`}
|
|
98
|
+
className="text-white bg-blue-700 hover:bg-blue-800 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-2 py-2 dark:bg-blue-600 dark:hover:bg-blue-700 focus:outline-none dark:focus:ring-blue-800"
|
|
99
|
+
onClick={() => onActionButtonClicked?.(row.actions)}>
|
|
100
|
+
<i className="fas fa-cogs"></i>
|
|
101
|
+
</button>
|
|
102
|
+
|
|
103
|
+
<NavDropdownComponent id={`dropdown_${row.id}`} pos="left">
|
|
104
|
+
<ActionListComponent
|
|
105
|
+
actions={row.actions ?? []}
|
|
106
|
+
extraParams={extraParams}
|
|
107
|
+
extraQuery={extraQuery}
|
|
108
|
+
onActionClicked={onActionClicked} />
|
|
109
|
+
</NavDropdownComponent>
|
|
110
|
+
</td>
|
|
111
|
+
) : null}
|
|
112
|
+
</tr>
|
|
113
|
+
))}
|
|
114
|
+
</tbody>
|
|
115
|
+
</table>
|
|
116
|
+
</div>
|
|
117
|
+
)
|
|
118
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemelo de DisabledLinkComponent.vue: la acción que el usuario no puede
|
|
3
|
+
* ejecutar. Se sigue mostrando, deshabilitada, para que la interfaz no cambie
|
|
4
|
+
* de forma según los permisos.
|
|
5
|
+
*/
|
|
6
|
+
export default function DisabledLinkComponent({ icon = '', text }) {
|
|
7
|
+
const showIcon = icon !== '' && icon != null
|
|
8
|
+
|
|
9
|
+
return (
|
|
10
|
+
<a
|
|
11
|
+
href="#"
|
|
12
|
+
aria-disabled="true"
|
|
13
|
+
className="disabled-link block px-4 py-2 dark:hover:text-white dark:text-slate-400"
|
|
14
|
+
uk-tooltip="title: This action is not authorized; pos:right"
|
|
15
|
+
onClick={(event) => event.preventDefault()}>
|
|
16
|
+
{showIcon ? <span className="fe-mr-sm uk-icon" uk-icon={icon}></span> : null}
|
|
17
|
+
<span>{text}</span>
|
|
18
|
+
</a>
|
|
19
|
+
)
|
|
20
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemelo de IconLinkComponent.vue.
|
|
3
|
+
*/
|
|
4
|
+
export default function IconLinkComponent({
|
|
5
|
+
link = '#',
|
|
6
|
+
text,
|
|
7
|
+
icon,
|
|
8
|
+
ratio = 1,
|
|
9
|
+
textClass = '',
|
|
10
|
+
target = '_self',
|
|
11
|
+
onClick,
|
|
12
|
+
}) {
|
|
13
|
+
return (
|
|
14
|
+
<a
|
|
15
|
+
className="block px-4 py-2 dark:hover:text-white dark:text-slate-400"
|
|
16
|
+
href={link}
|
|
17
|
+
target={target}
|
|
18
|
+
onClick={onClick}>
|
|
19
|
+
<span
|
|
20
|
+
className="fe-mr-sm uk-icon"
|
|
21
|
+
uk-icon={`icon: ${icon}; ratio: ${ratio};`}
|
|
22
|
+
style={{ fontSize: `${ratio * 16}px` }}></span>
|
|
23
|
+
<span className={textClass}>{text}</span>
|
|
24
|
+
</a>
|
|
25
|
+
)
|
|
26
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { Link } from 'react-router-dom'
|
|
2
|
+
import { buildPath } from '../routes.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Gemelo de IconRouteComponent.vue.
|
|
6
|
+
*
|
|
7
|
+
* vue-router resuelve `{ name, params, query }`; React Router 7 no tiene rutas
|
|
8
|
+
* con nombre, así que la ruta se construye con el mapa que declare la
|
|
9
|
+
* aplicación (ver `registerRoutes` en `src/routes.js`). El contrato del modelo
|
|
10
|
+
* —`params.to.name`— no cambia, que es lo que importa: `models/<entity>/
|
|
11
|
+
* index.js` es el mismo archivo para Vue y para React.
|
|
12
|
+
*/
|
|
13
|
+
export default function IconRouteComponent({
|
|
14
|
+
name,
|
|
15
|
+
params = {},
|
|
16
|
+
query = {},
|
|
17
|
+
text,
|
|
18
|
+
icon,
|
|
19
|
+
ratio = 1,
|
|
20
|
+
textClass = '',
|
|
21
|
+
}) {
|
|
22
|
+
return (
|
|
23
|
+
<Link
|
|
24
|
+
to={buildPath(name, params, query)}
|
|
25
|
+
className="block px-4 py-2 dark:hover:text-white dark:text-slate-400">
|
|
26
|
+
<span
|
|
27
|
+
className="fe-mr-sm uk-icon"
|
|
28
|
+
uk-icon={`icon: ${icon}; ratio: ${ratio};`}
|
|
29
|
+
style={{ fontSize: `${ratio * 16}px` }}></span>
|
|
30
|
+
<span className={textClass}>{text}</span>
|
|
31
|
+
</Link>
|
|
32
|
+
)
|
|
33
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import { useEffect, useRef } from 'react'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Gemelo de NavDropdownComponent.vue.
|
|
5
|
+
*
|
|
6
|
+
* UIkit lee el atributo `uk-dropdown` del DOM. React lo escribe igual, pero
|
|
7
|
+
* UIkit sólo lo procesa al montarse el nodo, así que se le avisa — con guarda,
|
|
8
|
+
* porque UIkit lo aporta la aplicación anfitriona y en una prueba no está.
|
|
9
|
+
*/
|
|
10
|
+
export default function NavDropdownComponent({
|
|
11
|
+
id,
|
|
12
|
+
pos = 'bottom-left',
|
|
13
|
+
mode = 'click',
|
|
14
|
+
offset = 0,
|
|
15
|
+
// Es el nombre de una animacion de UIkit, no una clase nuestra.
|
|
16
|
+
animation = 'uk-animation-slide-top-small',
|
|
17
|
+
duration = 500,
|
|
18
|
+
children,
|
|
19
|
+
}) {
|
|
20
|
+
const host = useRef(null)
|
|
21
|
+
|
|
22
|
+
useEffect(() => {
|
|
23
|
+
globalThis.UIkit?.update?.(host.current)
|
|
24
|
+
}, [])
|
|
25
|
+
|
|
26
|
+
return (
|
|
27
|
+
<div
|
|
28
|
+
ref={host}
|
|
29
|
+
id={id}
|
|
30
|
+
uk-dropdown={`pos: ${pos}; mode: ${mode}; offset: ${offset}; animation: ${animation}; duration: ${duration};`}
|
|
31
|
+
className="fe-p-0 z-10 hidden text-base list-none bg-white divide-y divide-gray-100 rounded-lg shadow w-44 dark:bg-slate-800 p-2">
|
|
32
|
+
<ul className="fe-menu">{children}</ul>
|
|
33
|
+
</div>
|
|
34
|
+
)
|
|
35
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Gemelo de SelectPaginationComponent.vue.
|
|
3
|
+
*
|
|
4
|
+
* La página vive en el padre (el hook), así que aquí no hay estado local: la
|
|
5
|
+
* versión Vue lo tenía y se le desincronizaba cuando la página cambiaba desde
|
|
6
|
+
* fuera, por ejemplo al reiniciar los filtros.
|
|
7
|
+
*/
|
|
8
|
+
export default function SelectPaginationComponent({ meta = {}, onPageChange }) {
|
|
9
|
+
const current = meta.current_page ?? 1
|
|
10
|
+
const last = meta.last_page ?? 1
|
|
11
|
+
|
|
12
|
+
return (
|
|
13
|
+
<div className="pagination" fe-grid="">
|
|
14
|
+
<div className="fe-w-auto">
|
|
15
|
+
<ul className="fe-pagination fe-justify-start fe-mt-md" fe-mb="">
|
|
16
|
+
<li>
|
|
17
|
+
{meta.total > 0
|
|
18
|
+
? <span>Showing {meta.from} to {meta.to} of {meta.total} entries</span>
|
|
19
|
+
: <span>No results found</span>}
|
|
20
|
+
</li>
|
|
21
|
+
</ul>
|
|
22
|
+
</div>
|
|
23
|
+
|
|
24
|
+
<div className="fe-w-expand">
|
|
25
|
+
<ul className="fe-pagination fe-justify-end fe-mt-md" fe-mb="">
|
|
26
|
+
{current > 1 ? (
|
|
27
|
+
<li>
|
|
28
|
+
<a
|
|
29
|
+
href="#"
|
|
30
|
+
aria-label="Anterior"
|
|
31
|
+
onClick={(event) => {
|
|
32
|
+
event.preventDefault()
|
|
33
|
+
onPageChange?.(current - 1)
|
|
34
|
+
}}>
|
|
35
|
+
<span fe-page-prev=""></span>
|
|
36
|
+
</a>
|
|
37
|
+
</li>
|
|
38
|
+
) : null}
|
|
39
|
+
|
|
40
|
+
<li>
|
|
41
|
+
<select
|
|
42
|
+
aria-label="Página"
|
|
43
|
+
className="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
|
|
44
|
+
value={current}
|
|
45
|
+
onChange={(event) => onPageChange?.(Number(event.target.value))}>
|
|
46
|
+
{Array.from({ length: last }, (_, index) => index + 1).map((page) => (
|
|
47
|
+
<option key={`page_${page}`} value={page}>{page}</option>
|
|
48
|
+
))}
|
|
49
|
+
</select>
|
|
50
|
+
</li>
|
|
51
|
+
|
|
52
|
+
{current < last ? (
|
|
53
|
+
<li>
|
|
54
|
+
<a
|
|
55
|
+
href="#"
|
|
56
|
+
aria-label="Siguiente"
|
|
57
|
+
onClick={(event) => {
|
|
58
|
+
event.preventDefault()
|
|
59
|
+
onPageChange?.(current + 1)
|
|
60
|
+
}}>
|
|
61
|
+
<span fe-page-next=""></span>
|
|
62
|
+
</a>
|
|
63
|
+
</li>
|
|
64
|
+
) : null}
|
|
65
|
+
</ul>
|
|
66
|
+
</div>
|
|
67
|
+
</div>
|
|
68
|
+
)
|
|
69
|
+
}
|
package/src/routes.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rutas con nombre para React Router.
|
|
3
|
+
*
|
|
4
|
+
* El contrato del modelo (`models/<entity>/index.js`) es el mismo archivo para
|
|
5
|
+
* Vue y para React, y en él las acciones apuntan a una ruta **por nombre**:
|
|
6
|
+
*
|
|
7
|
+
* params: { to: { name: 'AdminEditPost', params: { id: 1 } } }
|
|
8
|
+
*
|
|
9
|
+
* vue-router resuelve eso de fábrica. React Router 7 no, así que el módulo
|
|
10
|
+
* generado registra aquí su mapa nombre → patrón y este archivo hace la
|
|
11
|
+
* sustitución. Sin esto, cada gemelo React tendría que reescribir las acciones
|
|
12
|
+
* — y ahí es donde el contrato dejaría de ser uno.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
/** @type {Map<string, string>} */
|
|
16
|
+
const routes = new Map()
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @param {Record<string, string>} definitions nombre → patrón, p. ej.
|
|
20
|
+
* `{ AdminEditPost: '/admin/posts/:id/edit' }`
|
|
21
|
+
*/
|
|
22
|
+
export function registerRoutes(definitions) {
|
|
23
|
+
Object.entries(definitions).forEach(([name, pattern]) => routes.set(name, pattern))
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function hasRoute(name) {
|
|
27
|
+
return routes.has(name)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function resetRoutes() {
|
|
31
|
+
routes.clear()
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* @param {string} name
|
|
36
|
+
* @param {Record<string, string|number>} params
|
|
37
|
+
* @param {Record<string, string|number>} query
|
|
38
|
+
* @returns {string}
|
|
39
|
+
*/
|
|
40
|
+
export function buildPath(name, params = {}, query = {}) {
|
|
41
|
+
const pattern = routes.get(name)
|
|
42
|
+
|
|
43
|
+
if (! pattern) {
|
|
44
|
+
// Devolver '#' escondería el fallo hasta que alguien hiciera clic. La
|
|
45
|
+
// ruta que falta es un error de registro, y se ve antes si grita.
|
|
46
|
+
throw new Error(
|
|
47
|
+
`[innoboxrr-react-datatable] La ruta '${name}' no está registrada. `
|
|
48
|
+
+ 'Llama a registerRoutes({ ' + name + ': \'/tu/patron/:id\' }) al montar el módulo.'
|
|
49
|
+
)
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const used = new Set()
|
|
53
|
+
|
|
54
|
+
const path = pattern.replace(/:([A-Za-z0-9_]+)\??/g, (match, key) => {
|
|
55
|
+
if (params[key] === undefined || params[key] === null) {
|
|
56
|
+
if (match.endsWith('?')) {
|
|
57
|
+
return ''
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
throw new Error(`[innoboxrr-react-datatable] Falta el parámetro '${key}' para la ruta '${name}'.`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
used.add(key)
|
|
64
|
+
|
|
65
|
+
return encodeURIComponent(String(params[key]))
|
|
66
|
+
}).replace(/\/{2,}/g, '/').replace(/\/$/, '') || '/'
|
|
67
|
+
|
|
68
|
+
// Lo que no encaje en el patrón viaja como query, igual que en vue-router.
|
|
69
|
+
const search = new URLSearchParams()
|
|
70
|
+
|
|
71
|
+
Object.entries(params).forEach(([key, value]) => {
|
|
72
|
+
if (! used.has(key) && value !== undefined && value !== null) {
|
|
73
|
+
search.set(key, String(value))
|
|
74
|
+
}
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
Object.entries(query).forEach(([key, value]) => {
|
|
78
|
+
if (value !== undefined && value !== null) {
|
|
79
|
+
search.set(key, String(value))
|
|
80
|
+
}
|
|
81
|
+
})
|
|
82
|
+
|
|
83
|
+
const queryString = search.toString()
|
|
84
|
+
|
|
85
|
+
return queryString ? `${path}?${queryString}` : path
|
|
86
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
import axios from 'axios'
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Se leía de la global `csrf_token`, que la aplicación anfitriona tenía que
|
|
6
|
+
* definir en window: el componente no se podía montar fuera de ella.
|
|
7
|
+
*/
|
|
8
|
+
export const csrfToken = () => globalThis.csrf_token
|
|
9
|
+
?? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
|
10
|
+
?? ''
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Sustituye a `_.isEqual` de lodash, que la versión Vue usaba como global sin
|
|
14
|
+
* declararla como dependencia.
|
|
15
|
+
*/
|
|
16
|
+
export const isEqual = (a, b) => {
|
|
17
|
+
if (a === b) {
|
|
18
|
+
return true
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
|
|
22
|
+
return false
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const keysA = Object.keys(a)
|
|
26
|
+
const keysB = Object.keys(b)
|
|
27
|
+
|
|
28
|
+
return keysA.length === keysB.length && keysA.every((key) => isEqual(a[key], b[key]))
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const hiddenColumnIds = (hideColumns) => hideColumns.map(
|
|
32
|
+
// Se admite tanto ['name'] como [{ id: 'name' }]: el contrato nunca estuvo
|
|
33
|
+
// documentado y por ahí circulan las dos formas.
|
|
34
|
+
(column) => (typeof column === 'string' ? column : column?.id)
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Toda la lógica de la tabla: cargar, ordenar, paginar y resolver políticas.
|
|
39
|
+
*
|
|
40
|
+
* Está fuera del componente a propósito. Es exactamente lo mismo que hace la
|
|
41
|
+
* versión Vue, y así se puede probar sin montar nada — y reutilizar si alguien
|
|
42
|
+
* quiere pintar la tabla de otra forma.
|
|
43
|
+
*/
|
|
44
|
+
export default function useDataTable({
|
|
45
|
+
dataUrl,
|
|
46
|
+
dataMethod = 'post',
|
|
47
|
+
model,
|
|
48
|
+
policyUrl,
|
|
49
|
+
policyMethod = 'post',
|
|
50
|
+
formFilters = {},
|
|
51
|
+
externalFilters = {},
|
|
52
|
+
hideColumns = [],
|
|
53
|
+
}) {
|
|
54
|
+
const head = useMemo(() => {
|
|
55
|
+
const hidden = hiddenColumnIds(hideColumns)
|
|
56
|
+
|
|
57
|
+
return model.dataTableHead().filter((column) => ! hidden.includes(column.id))
|
|
58
|
+
}, [model, hideColumns])
|
|
59
|
+
|
|
60
|
+
const [body, setBody] = useState([])
|
|
61
|
+
const [pagination, setPagination] = useState({ meta: {}, links: [] })
|
|
62
|
+
const [crudActions, setCrudActions] = useState(() => model.crudActions())
|
|
63
|
+
const [sort, setSort] = useState(() => model.dataTableSort())
|
|
64
|
+
const [orderBy, setOrderBy] = useState('id')
|
|
65
|
+
const [page, setPage] = useState(1)
|
|
66
|
+
|
|
67
|
+
const internalSort = useRef(false)
|
|
68
|
+
const dataAttempts = useRef(0)
|
|
69
|
+
const policyAttempts = useRef(0)
|
|
70
|
+
const timers = useRef([])
|
|
71
|
+
|
|
72
|
+
// Los props cambian de identidad en cada render del padre; las refs
|
|
73
|
+
// evitan que eso reprograme la carga en bucle.
|
|
74
|
+
const latest = useRef({})
|
|
75
|
+
latest.current = { dataUrl, dataMethod, model, formFilters, externalFilters, orderBy, sort, page }
|
|
76
|
+
|
|
77
|
+
const getFilters = useCallback(() => {
|
|
78
|
+
const { formFilters: form, externalFilters: external, orderBy: by, sort: order, page: current } = latest.current
|
|
79
|
+
|
|
80
|
+
const params = { _token: csrfToken(), managed: true, except_view_any: true }
|
|
81
|
+
const ordering = { orderBy: by, orderMode: order[by] }
|
|
82
|
+
|
|
83
|
+
// Con orden interno, el del usuario gana a lo que traigan los filtros
|
|
84
|
+
// externos; sin él, es al revés.
|
|
85
|
+
return internalSort.current
|
|
86
|
+
? { ...params, ...form, ...external, ...ordering, page: current }
|
|
87
|
+
: { ...params, ...form, ...ordering, ...external, page: current }
|
|
88
|
+
}, [])
|
|
89
|
+
|
|
90
|
+
const fetchData = useCallback(async () => {
|
|
91
|
+
const filters = getFilters()
|
|
92
|
+
const { dataUrl: url, dataMethod: method } = latest.current
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const response = await axios({
|
|
96
|
+
method,
|
|
97
|
+
url,
|
|
98
|
+
data: method === 'post' ? filters : null,
|
|
99
|
+
params: method === 'get' ? filters : null,
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
dataAttempts.current = 0
|
|
103
|
+
setBody(response.data.data)
|
|
104
|
+
setPagination({ meta: response.data.meta, links: response.data.links })
|
|
105
|
+
} catch (error) {
|
|
106
|
+
// Un fallo de red no trae respuesta: leer error.response.status sin
|
|
107
|
+
// comprobarlo lanzaba un TypeError dentro del propio manejador.
|
|
108
|
+
if (error.response?.status === 403) {
|
|
109
|
+
return
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (dataAttempts.current <= 3) {
|
|
113
|
+
timers.current.push(window.setTimeout(() => {
|
|
114
|
+
dataAttempts.current += 1
|
|
115
|
+
fetchData()
|
|
116
|
+
}, 1500))
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}, [getFilters])
|
|
120
|
+
|
|
121
|
+
const updateFilters = useCallback(() => {
|
|
122
|
+
latest.current.model.setFilters(getFilters())
|
|
123
|
+
|
|
124
|
+
return fetchData()
|
|
125
|
+
}, [fetchData, getFilters])
|
|
126
|
+
|
|
127
|
+
const sortColumn = useCallback((column) => {
|
|
128
|
+
if (column.sortable !== true) {
|
|
129
|
+
return
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// A partir de aquí el orden elegido por el usuario manda sobre el que
|
|
133
|
+
// puedan traer los filtros externos.
|
|
134
|
+
internalSort.current = true
|
|
135
|
+
|
|
136
|
+
setOrderBy(column.id)
|
|
137
|
+
setSort((current) => {
|
|
138
|
+
const next = { ...current, [column.id]: current[column.id] === 'asc' ? 'desc' : 'asc' }
|
|
139
|
+
|
|
140
|
+
latest.current.sort = next
|
|
141
|
+
latest.current.orderBy = column.id
|
|
142
|
+
|
|
143
|
+
return next
|
|
144
|
+
})
|
|
145
|
+
}, [])
|
|
146
|
+
|
|
147
|
+
const updatePage = useCallback((next) => {
|
|
148
|
+
latest.current.page = next
|
|
149
|
+
setPage(next)
|
|
150
|
+
}, [])
|
|
151
|
+
|
|
152
|
+
const actionClicked = useCallback(async (action) => {
|
|
153
|
+
try {
|
|
154
|
+
await latest.current.model[action.callback](action.params)
|
|
155
|
+
|
|
156
|
+
return updateFilters()
|
|
157
|
+
} catch (error) {
|
|
158
|
+
console.error(error)
|
|
159
|
+
}
|
|
160
|
+
}, [updateFilters])
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Pregunta al backend qué acciones puede ejecutar el usuario sobre esa
|
|
164
|
+
* fila y marca `policy` en cada una.
|
|
165
|
+
*/
|
|
166
|
+
const actionButtonClicked = useCallback(async (actions) => {
|
|
167
|
+
const requestData = { _token: csrfToken(), id: actions[0]?.params?.id ?? null }
|
|
168
|
+
|
|
169
|
+
try {
|
|
170
|
+
const response = await axios({
|
|
171
|
+
method: policyMethod,
|
|
172
|
+
url: policyUrl,
|
|
173
|
+
data: policyMethod === 'post' ? requestData : null,
|
|
174
|
+
params: policyMethod === 'get' ? requestData : null,
|
|
175
|
+
})
|
|
176
|
+
|
|
177
|
+
policyAttempts.current = 0
|
|
178
|
+
|
|
179
|
+
const allowed = actions.map((action) => (
|
|
180
|
+
response.data[action.id] ? { ...action, policy: true } : action
|
|
181
|
+
))
|
|
182
|
+
|
|
183
|
+
// La versión Vue mutaba las acciones en sitio; en React eso no
|
|
184
|
+
// repinta nada, así que se sustituye la lista.
|
|
185
|
+
if (actions === crudActions) {
|
|
186
|
+
setCrudActions(allowed)
|
|
187
|
+
} else {
|
|
188
|
+
setBody((rows) => rows.map((row) => (
|
|
189
|
+
row.actions === actions ? { ...row, actions: allowed } : row
|
|
190
|
+
)))
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return allowed
|
|
194
|
+
} catch {
|
|
195
|
+
if (policyAttempts.current <= 3) {
|
|
196
|
+
timers.current.push(window.setTimeout(() => {
|
|
197
|
+
policyAttempts.current += 1
|
|
198
|
+
actionButtonClicked(actions)
|
|
199
|
+
}, 1500))
|
|
200
|
+
|
|
201
|
+
return
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
timers.current.push(window.setTimeout(() => {
|
|
205
|
+
policyAttempts.current = 0
|
|
206
|
+
}, 3000))
|
|
207
|
+
}
|
|
208
|
+
}, [policyMethod, policyUrl, crudActions])
|
|
209
|
+
|
|
210
|
+
// Carga inicial y recarga cuando cambian orden o página.
|
|
211
|
+
useEffect(() => {
|
|
212
|
+
updateFilters()
|
|
213
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
214
|
+
}, [orderBy, sort, page])
|
|
215
|
+
|
|
216
|
+
// Un filtro nuevo vuelve a la primera página; uno externo no.
|
|
217
|
+
const previousForm = useRef(formFilters)
|
|
218
|
+
const previousExternal = useRef(externalFilters)
|
|
219
|
+
|
|
220
|
+
useEffect(() => {
|
|
221
|
+
if (isEqual(previousForm.current, formFilters)) {
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
previousForm.current = formFilters
|
|
226
|
+
updatePage(1)
|
|
227
|
+
updateFilters()
|
|
228
|
+
}, [formFilters, updateFilters, updatePage])
|
|
229
|
+
|
|
230
|
+
useEffect(() => {
|
|
231
|
+
if (isEqual(previousExternal.current, externalFilters)) {
|
|
232
|
+
return
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
previousExternal.current = externalFilters
|
|
236
|
+
updateFilters()
|
|
237
|
+
}, [externalFilters, updateFilters])
|
|
238
|
+
|
|
239
|
+
useEffect(() => () => {
|
|
240
|
+
timers.current.forEach((timer) => window.clearTimeout(timer))
|
|
241
|
+
}, [])
|
|
242
|
+
|
|
243
|
+
return {
|
|
244
|
+
dataTable: { head, body },
|
|
245
|
+
pagination,
|
|
246
|
+
crudActions,
|
|
247
|
+
sort,
|
|
248
|
+
orderBy,
|
|
249
|
+
page,
|
|
250
|
+
updateFilters,
|
|
251
|
+
sortColumn,
|
|
252
|
+
updatePage,
|
|
253
|
+
actionClicked,
|
|
254
|
+
actionButtonClicked,
|
|
255
|
+
}
|
|
256
|
+
}
|