innoboxrr-react-datatable 2.3.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.
@@ -1,45 +1,35 @@
1
1
  import axios from 'axios'
2
2
  import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
3
+ import { flushSync } from 'react-dom'
4
+ import {
5
+ columnVisibilityFeature,
6
+ rowSelectionFeature,
7
+ rowSortingFeature,
8
+ tableFeatures,
9
+ useTable,
10
+ } from '@tanstack/react-table'
11
+ import { notifyError, notifySuccess } from 'innoboxrr-form-core'
12
+
13
+ import * as core from './table.js'
3
14
 
4
15
  /**
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.
16
+ * Solo lo que la tabla usa. El orden y la paginación los hace el servidor, así
17
+ * que no hay modelos de filas ordenadas ni paginadas: TanStack Table lleva el
18
+ * estado —qué columnas se ven, qué filas están seleccionadas, por qué columna
19
+ * se ordena— y la tabla pinta.
7
20
  */
8
- export const csrfToken = () => globalThis.csrf_token
9
- ?? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
10
- ?? ''
21
+ const features = tableFeatures({ rowSortingFeature, columnVisibilityFeature, rowSelectionFeature })
11
22
 
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
- )
23
+ // Un array nuevo en cada render invalidaría los modelos de TanStack Table.
24
+ const NO_ROWS = []
25
+ const NO_SELECTION = {}
36
26
 
37
27
  /**
38
- * Toda la lógica de la tabla: cargar, ordenar, paginar y resolver políticas.
28
+ * Toda la lógica de la tabla: cargar, ordenar, paginar, seleccionar y resolver
29
+ * permisos. Es la misma que `useDataTable` de innoboxrr-vue-datatable.
39
30
  *
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.
31
+ * Está fuera del componente para poder probarla y para pintar la misma tabla
32
+ * de otra forma.
43
33
  */
44
34
  export default function useDataTable({
45
35
  dataUrl,
@@ -49,208 +39,360 @@ export default function useDataTable({
49
39
  policyMethod = 'post',
50
40
  formFilters = {},
51
41
  externalFilters = {},
42
+ extraParams = {},
43
+ extraQuery = {},
52
44
  hideColumns = [],
45
+ selectable = false,
46
+ labels = core.DEFAULT_LABELS,
47
+ navigate = null,
53
48
  }) {
54
- const head = useMemo(() => {
55
- const hidden = hiddenColumnIds(hideColumns)
49
+ const head = useMemo(() => model.dataTableHead(), [model])
50
+ const columns = useMemo(() => core.columnsFrom(head), [head])
51
+
52
+ // Los props llegan como objetos nuevos en cada render del padre: se
53
+ // compara su contenido, no su identidad.
54
+ const hiddenKey = core.snapshot(core.hiddenColumnIds(hideColumns))
55
+ const formKey = core.snapshot(formFilters)
56
+ const externalKey = core.snapshot(externalFilters)
56
57
 
57
- return model.dataTableHead().filter((column) => ! hidden.includes(column.id))
58
- }, [model, hideColumns])
58
+ const visibleHead = useMemo(() => {
59
+ const hidden = JSON.parse(hiddenKey)
59
60
 
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())
61
+ return head.filter((column) => ! hidden.includes(column.id))
62
+ }, [head, hiddenKey])
63
+
64
+ const columnVisibility = useMemo(() => core.visibilityFrom(JSON.parse(hiddenKey)), [hiddenKey])
65
+
66
+ const [rows, setRows] = useState(NO_ROWS)
67
+ const [meta, setMeta] = useState({})
68
+ const [links, setLinks] = useState(NO_ROWS)
69
+ const [loading, setLoading] = useState(false)
70
+ const [error, setError] = useState(null)
71
+ const [sort, setSort] = useState(() => ({ ...model.dataTableSort() }))
64
72
  const [orderBy, setOrderBy] = useState('id')
65
73
  const [page, setPage] = useState(1)
66
74
 
67
- const internalSort = useRef(false)
68
- const dataAttempts = useRef(0)
69
- const policyAttempts = useRef(0)
70
- const timers = useRef([])
75
+ /** Lo que respondió el backend de permisos, por fila y para la barra. */
76
+ const [allowed, setAllowed] = useState({})
71
77
 
72
- // Los props cambian de identidad en cada render del padre; las refs
73
- // evitan que eso reprograme la carga en bucle.
78
+ const sorting = useMemo(() => core.sortingFrom(orderBy, sort), [orderBy, sort])
79
+
80
+ const table = useTable({
81
+ features,
82
+ columns,
83
+ data: rows,
84
+ getRowId: (row, index) => String(row?.id ?? index),
85
+ manualSorting: true,
86
+ enableRowSelection: selectable === true,
87
+ isRowRangeSelectionEvent: core.isRangeEvent,
88
+ state: { columnVisibility, sorting },
89
+ })
90
+
91
+ const rowSelection = table.state.rowSelection ?? NO_SELECTION
92
+ const selectedIds = useMemo(() => Object.keys(rowSelection).filter((id) => rowSelection[id]), [rowSelection])
93
+
94
+ /**
95
+ * Una copia por fila y carga, para que un parser del modelo no pueda mutar
96
+ * los datos de la tabla.
97
+ */
98
+ const clones = useMemo(() => rows.map(core.cloneRow), [rows])
99
+
100
+ const crudActions = useMemo(
101
+ () => core.withPolicies(model.crudActions(), allowed[core.CRUD_POLICIES]),
102
+ [model, allowed]
103
+ )
104
+
105
+ const bulkActions = useMemo(
106
+ () => (typeof model.bulkActions === 'function' ? model.bulkActions() : NO_ROWS),
107
+ [model]
108
+ )
109
+
110
+ const rowActions = useCallback(
111
+ (row) => core.withPolicies(row?.actions ?? NO_ROWS, allowed[String(row?.id)]),
112
+ [allowed]
113
+ )
114
+
115
+ // Lo que las funciones asíncronas leen cuando terminan: el valor de ese
116
+ // momento, no el del render en que empezaron.
74
117
  const latest = useRef({})
75
- latest.current = { dataUrl, dataMethod, model, formFilters, externalFilters, orderBy, sort, page }
76
118
 
77
- const getFilters = useCallback(() => {
78
- const { formFilters: form, externalFilters: external, orderBy: by, sort: order, page: current } = latest.current
119
+ latest.current = {
120
+ dataUrl, dataMethod, model, policyUrl, policyMethod, formFilters, externalFilters,
121
+ extraParams, extraQuery, labels, navigate, orderBy, sort, page, rows, allowed, table,
122
+ }
123
+
124
+ const internalSort = useRef(false)
125
+ const requestId = useRef(0)
126
+ const mounted = useRef(false)
79
127
 
80
- const params = { _token: csrfToken(), managed: true, except_view_any: true }
81
- const ordering = { orderBy: by, orderMode: order[by] }
128
+ useEffect(() => {
129
+ mounted.current = true
82
130
 
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 }
131
+ return () => {
132
+ mounted.current = false
133
+ }
88
134
  }, [])
89
135
 
90
- const fetchData = useCallback(async () => {
91
- const filters = getFilters()
92
- const { dataUrl: url, dataMethod: method } = latest.current
136
+ const load = useCallback(async () => {
137
+ const id = ++requestId.current
138
+ const current = latest.current
139
+
140
+ const filters = core.requestFilters({
141
+ formFilters: current.formFilters,
142
+ externalFilters: current.externalFilters,
143
+ orderBy: current.orderBy,
144
+ sort: current.sort,
145
+ page: current.page,
146
+ internalSort: internalSort.current,
147
+ })
148
+
149
+ current.model.setFilters?.(filters)
150
+ setLoading(true)
151
+
152
+ // Una respuesta que llega tarde no pisa a la de una petición posterior.
153
+ const stale = () => id !== requestId.current || ! mounted.current
93
154
 
94
155
  try {
95
- const response = await axios({
96
- method,
97
- url,
98
- data: method === 'post' ? filters : null,
99
- params: method === 'get' ? filters : null,
100
- })
156
+ const response = await axios(core.requestConfig(current.dataMethod, current.dataUrl, filters))
101
157
 
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) {
158
+ if (stale()) {
109
159
  return
110
160
  }
111
161
 
112
- if (dataAttempts.current <= 3) {
113
- timers.current.push(window.setTimeout(() => {
114
- dataAttempts.current += 1
115
- fetchData()
116
- }, 1500))
162
+ setRows(response.data?.data ?? NO_ROWS)
163
+ setMeta(response.data?.meta ?? {})
164
+ setLinks(response.data?.links ?? NO_ROWS)
165
+ setError(null)
166
+
167
+ // Con datos nuevos los permisos se vuelven a preguntar.
168
+ setAllowed({})
169
+ } catch (failure) {
170
+ if (stale()) {
171
+ return
172
+ }
173
+
174
+ const described = core.describeError(failure, latest.current.labels)
175
+
176
+ if (described.status === 403) {
177
+ setRows(NO_ROWS)
178
+ setMeta({})
179
+ } else if (latest.current.rows.length > 0) {
180
+ // Con filas en pantalla el error no tiene sitio en la tabla:
181
+ // se avisa y se deja lo que había.
182
+ notifyError(described.message)
183
+ }
184
+
185
+ setError(described)
186
+ } finally {
187
+ if (! stale()) {
188
+ setLoading(false)
117
189
  }
118
190
  }
119
- }, [getFilters])
191
+ }, [])
192
+
193
+ const clearSelection = useCallback(() => {
194
+ latest.current.table.resetRowSelection(true)
195
+ }, [])
196
+
197
+ /**
198
+ * Una sola petición por cambio. Un filtro nuevo en otra página primero
199
+ * vuelve a la 1 y carga en la pasada siguiente; antes eran dos peticiones
200
+ * iguales, porque la página y los filtros se vigilaban por separado.
201
+ */
202
+ const seen = useRef({ formKey, externalKey })
203
+ const sortKey = core.snapshot(sort)
204
+
205
+ useEffect(() => {
206
+ const previous = seen.current
207
+
208
+ seen.current = { formKey, externalKey }
209
+
210
+ // Un filtro nuevo o externo descarta la selección, que era de otro
211
+ // listado; solo el del formulario vuelve a la primera página.
212
+ if (previous.formKey !== formKey || previous.externalKey !== externalKey) {
213
+ clearSelection()
214
+ }
215
+
216
+ if (previous.formKey !== formKey && page !== 1) {
217
+ setPage(1)
218
+
219
+ return
220
+ }
120
221
 
121
- const updateFilters = useCallback(() => {
122
- latest.current.model.setFilters(getFilters())
222
+ load()
223
+ }, [formKey, externalKey, orderBy, sortKey, page, load, clearSelection])
123
224
 
124
- return fetchData()
125
- }, [fetchData, getFilters])
225
+ const refresh = useCallback(() => load(), [load])
126
226
 
127
227
  const sortColumn = useCallback((column) => {
128
- if (column.sortable !== true) {
228
+ if (column?.sortable !== true) {
129
229
  return
130
230
  }
131
231
 
132
- // A partir de aquí el orden elegido por el usuario manda sobre el que
133
- // puedan traer los filtros externos.
134
232
  internalSort.current = true
135
233
 
234
+ setSort((current) => core.toggledSort(current, column.id))
136
235
  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
236
  }, [])
146
237
 
147
238
  const updatePage = useCallback((next) => {
148
- latest.current.page = next
149
- setPage(next)
150
- }, [])
239
+ const value = Number(next)
151
240
 
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)
241
+ if (Number.isInteger(value) && value >= 1) {
242
+ setPage(value)
159
243
  }
160
- }, [updateFilters])
244
+ }, [])
161
245
 
162
246
  /**
163
- * Pregunta al backend qué acciones puede ejecutar el usuario sobre esa
164
- * fila y marca `policy` en cada una.
247
+ * Pregunta al backend qué se puede hacer con una fila —o con la barra, sin
248
+ * id— antes de abrir su menú. Lo que responde se guarda hasta la próxima
249
+ * carga.
165
250
  */
166
- const actionButtonClicked = useCallback(async (actions) => {
167
- const requestData = { _token: csrfToken(), id: actions[0]?.params?.id ?? null }
251
+ const preparePolicies = useCallback(async (id = null) => {
252
+ const key = id == null ? core.CRUD_POLICIES : String(id)
253
+ const current = latest.current
254
+
255
+ if (current.allowed[key]) {
256
+ return
257
+ }
168
258
 
169
259
  try {
170
- const response = await axios({
171
- method: policyMethod,
172
- url: policyUrl,
173
- data: policyMethod === 'post' ? requestData : null,
174
- params: policyMethod === 'get' ? requestData : null,
260
+ const response = await axios(core.requestConfig(current.policyMethod, current.policyUrl, {
261
+ _token: core.csrfToken(),
262
+ id,
263
+ }))
264
+
265
+ if (! mounted.current) {
266
+ return
267
+ }
268
+
269
+ // Se pinta ya: el menú se abre justo después, y tiene que abrir con
270
+ // los permisos puestos y no enseñarlos cambiando.
271
+ flushSync(() => {
272
+ setAllowed((previous) => ({ ...previous, [key]: response.data ?? {} }))
175
273
  })
274
+ } catch {
275
+ // El menú se abre igual, con todo deshabilitado.
276
+ notifyError(latest.current.labels.policiesFailed)
277
+ }
278
+ }, [])
176
279
 
177
- policyAttempts.current = 0
280
+ const run = useCallback(async (action) => {
281
+ const current = latest.current
282
+ const text = current.labels
283
+ const kind = core.actionKind(action)
178
284
 
179
- const allowed = actions.map((action) => (
180
- response.data[action.id] ? { ...action, policy: true } : action
181
- ))
285
+ if (kind === 'route') {
286
+ if (! current.navigate) {
287
+ notifyError(text.actionFailed)
182
288
 
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
- )))
289
+ return undefined
191
290
  }
192
291
 
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))
292
+ try {
293
+ await current.navigate(core.routeTarget(action, current.extraParams, current.extraQuery))
294
+ } catch (failure) {
295
+ // Una ruta que no existe es un error de quien declaró la
296
+ // acción: el usuario recibe un aviso y la consola, el detalle.
297
+ notifyError(text.actionFailed)
298
+ console.error(failure)
299
+ }
200
300
 
201
- return
301
+ return undefined
302
+ }
303
+
304
+ if (kind === 'link') {
305
+ globalThis.window?.open(action.params?.link, action.params?.target ?? '_self')
306
+
307
+ return undefined
308
+ }
309
+
310
+ if (typeof current.model[action.callback] !== 'function') {
311
+ notifyError(text.actionFailed)
312
+
313
+ return undefined
314
+ }
315
+
316
+ try {
317
+ await current.model[action.callback](action.params)
318
+ } catch (failure) {
319
+ if (! core.isCancelled(failure)) {
320
+ notifyError(core.describeError(failure, text, text.actionFailed).message)
202
321
  }
203
322
 
204
- timers.current.push(window.setTimeout(() => {
205
- policyAttempts.current = 0
206
- }, 3000))
323
+ return undefined
207
324
  }
208
- }, [policyMethod, policyUrl, crudActions])
209
325
 
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])
326
+ if (action.success) {
327
+ notifySuccess(action.success)
328
+ }
215
329
 
216
- // Un filtro nuevo vuelve a la primera página; uno externo no.
217
- const previousForm = useRef(formFilters)
218
- const previousExternal = useRef(externalFilters)
330
+ return load()
331
+ }, [load])
219
332
 
220
- useEffect(() => {
221
- if (isEqual(previousForm.current, formFilters)) {
222
- return
333
+ /**
334
+ * Una acción masiva recibe los ids seleccionados —también los de otras
335
+ * páginas— y las filas cargadas que están entre ellos.
336
+ */
337
+ const runBulk = useCallback(async (action) => {
338
+ const current = latest.current
339
+ const text = current.labels
340
+
341
+ if (typeof current.model[action.callback] !== 'function') {
342
+ notifyError(text.actionFailed)
343
+
344
+ return undefined
223
345
  }
224
346
 
225
- previousForm.current = formFilters
226
- updatePage(1)
227
- updateFilters()
228
- }, [formFilters, updateFilters, updatePage])
347
+ const selected = current.table.state.rowSelection ?? NO_SELECTION
348
+ const ids = Object.keys(selected).filter((id) => selected[id])
349
+ const loaded = current.table.getSelectedRowModel().rows.map((row) => row.original)
229
350
 
230
- useEffect(() => {
231
- if (isEqual(previousExternal.current, externalFilters)) {
232
- return
351
+ try {
352
+ await current.model[action.callback](ids, loaded)
353
+ } catch (failure) {
354
+ if (! core.isCancelled(failure)) {
355
+ notifyError(core.describeError(failure, text, text.actionFailed).message)
356
+ }
357
+
358
+ return undefined
233
359
  }
234
360
 
235
- previousExternal.current = externalFilters
236
- updateFilters()
237
- }, [externalFilters, updateFilters])
361
+ clearSelection()
238
362
 
239
- useEffect(() => () => {
240
- timers.current.forEach((timer) => window.clearTimeout(timer))
241
- }, [])
363
+ if (action.success) {
364
+ notifySuccess(action.success)
365
+ }
366
+
367
+ return load()
368
+ }, [clearSelection, load])
242
369
 
243
370
  return {
244
- dataTable: { head, body },
245
- pagination,
246
- crudActions,
371
+ table,
372
+ head,
373
+ visibleHead,
374
+ rows,
375
+ clones,
376
+ meta,
377
+ links,
378
+ loading,
379
+ error,
247
380
  sort,
248
381
  orderBy,
249
382
  page,
250
- updateFilters,
383
+ crudActions,
384
+ bulkActions,
385
+ rowActions,
386
+ selectedIds,
387
+ // Lo que devolvía la versión anterior, para quien lo lea desde fuera.
388
+ dataTable: { head: visibleHead, body: rows },
389
+ pagination: { meta, links },
390
+ refresh,
391
+ clearSelection,
251
392
  sortColumn,
252
393
  updatePage,
253
- actionClicked,
254
- actionButtonClicked,
394
+ preparePolicies,
395
+ run,
396
+ runBulk,
255
397
  }
256
398
  }
@@ -0,0 +1,17 @@
1
+ import { useSyncExternalStore } from 'react'
2
+ import { getTheme, onThemeChange } from 'innoboxrr-form-core'
3
+
4
+ /**
5
+ * El tema de innoboxrr-form-core.
6
+ *
7
+ * Va por `useSyncExternalStore` porque el tema es estado de módulo: un
8
+ * `setTheme` con la tabla ya montada tiene que repintarla.
9
+ *
10
+ * @returns {Record<string, string>}
11
+ */
12
+ export default function useTheme() {
13
+ return useSyncExternalStore(onThemeChange, () => getTheme(), () => getTheme())
14
+ }
15
+
16
+ /** Une clases descartando las vacías. */
17
+ export const joinClasses = (...classes) => classes.filter(Boolean).join(' ')
@@ -1,61 +0,0 @@
1
- import DisabledLinkComponent from './DisabledLinkComponent.jsx'
2
- import IconLinkComponent from './IconLinkComponent.jsx'
3
- import IconRouteComponent from './IconRouteComponent.jsx'
4
-
5
- // Un popover se cierra solo: no hace falta preguntarle nada a nadie.
6
- // La llamada es opcional porque hidePopover no existe en un navegador anterior
7
- // a la Popover API —ni en jsdom—, y un menu que no cierra es mejor que una
8
- // excepcion.
9
- const closeDropdown = (event) => {
10
- event.target.closest('[popover]')?.hidePopover?.()
11
- }
12
-
13
- /**
14
- * Las tres formas que puede tomar una acción del contrato del modelo, en un
15
- * solo sitio. En la versión Vue este bloque está copiado en `DataTable.vue` y
16
- * en `DataTableComponent.vue`, con una diferencia entre ambas copias — la de
17
- * dentro soporta `action.link` y la de fuera no.
18
- */
19
- export default function ActionListComponent({ actions = [], extraParams = {}, extraQuery = {}, onActionClicked }) {
20
- return actions.map((action) => (
21
- <li key={action.id ?? action.name} className="hover:bg-slate-100 dark:hover:bg-slate-600 px-2 py-1">
22
- {(() => {
23
- if (! action.policy) {
24
- return <DisabledLinkComponent icon={action.icon} text={action.name} />
25
- }
26
-
27
- if (action.route && ! action.link) {
28
- return (
29
- <IconRouteComponent
30
- name={action.params.to.name}
31
- params={{ ...action.params.to.params, ...extraParams }}
32
- query={action.params.to.query ? { ...action.params.to.query, ...extraQuery } : { ...extraQuery }}
33
- icon={action.icon}
34
- text={action.name} />
35
- )
36
- }
37
-
38
- if (action.route && action.link) {
39
- return (
40
- <IconLinkComponent
41
- link={action.params.link}
42
- target={action.params.target}
43
- icon={action.icon}
44
- text={action.name} />
45
- )
46
- }
47
-
48
- return (
49
- <IconLinkComponent
50
- icon={action.icon}
51
- text={action.name}
52
- onClick={(event) => {
53
- event.preventDefault()
54
- onActionClicked?.(action)
55
- closeDropdown(event)
56
- }} />
57
- )
58
- })()}
59
- </li>
60
- ))
61
- }