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 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
+ }