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,119 +1,178 @@
1
- import { useMemo } from 'react'
2
- import ActionListComponent from './ActionListComponent.jsx'
3
- import DatatableIcon from './DatatableIcon.jsx'
4
- import NavDropdownComponent from './NavDropdownComponent.jsx'
1
+ import IconComponent from 'innoboxrr-react-form-elements/src/IconComponent.jsx'
2
+ import MenuComponent from 'innoboxrr-react-form-elements/src/MenuComponent.jsx'
3
+ import SkeletonComponent from 'innoboxrr-react-form-elements/src/SkeletonComponent.jsx'
5
4
 
6
- const cellValue = (head, row) => (
7
- typeof head.parser === 'function' ? head.parser(row[head.id], row) : row[head.id]
8
- )
5
+ import { DEFAULT_LABELS, ariaSort, cellValue, componentProps, sortIcon } from '../table.js'
6
+ import useTheme from '../useTheme.js'
7
+
8
+ const SKELETON_ROWS = 5
9
+
10
+ const headOf = (cell) => cell.column.columnDef.meta?.head ?? {}
9
11
 
10
12
  /**
11
- * Gemelo de DataTableComponent.vue: la tabla propiamente dicha.
13
+ * Gemelo de DataTableComponent.vue: la tabla propiamente dicha, sobre una
14
+ * instancia de TanStack Table.
15
+ *
16
+ * El menú de cada fila espera a conocer los permisos antes de abrirse. Antes
17
+ * se abría al instante con todo deshabilitado y se habilitaba cuando llegaba
18
+ * la respuesta, y el usuario veía parpadear lo que no podía hacer.
12
19
  */
13
20
  export default function DataTableComponent({
21
+ table,
22
+ head = [],
23
+ rows = [],
24
+ clones = [],
25
+ loading = false,
26
+ error = null,
14
27
  actions = false,
15
- dataTable,
16
- extraParams = {},
17
- extraQuery = {},
28
+ selectable = false,
18
29
  showTableHeader = true,
19
30
  dataTableComponents = {},
31
+ labels = DEFAULT_LABELS,
32
+ orderBy = null,
33
+ sort = {},
34
+ itemsFor = () => [],
35
+ prepareRow = async () => {},
20
36
  onSortColumn,
21
- onActionButtonClicked,
22
- onActionClicked,
37
+ onRetry,
23
38
  }) {
24
- const head = useMemo(() => dataTable.head ?? [], [dataTable.head])
25
-
26
- /**
27
- * Copia aislada de cada fila, para que un parser del modelo no pueda mutar
28
- * los datos de la tabla.
29
- *
30
- * La versión Vue clona con JSON dentro de setData(), es decir una vez por
31
- * celda: con 20 filas y 8 columnas son 160 clonados en cada repintado. Un
32
- * clon por fila y repintado deja lo mismo en 20 — y, a diferencia de una
33
- * caché de módulo, un parser que escriba en su copia no la envenena para
34
- * los repintados siguientes.
35
- */
36
- const body = useMemo(
37
- () => (dataTable.body ?? []).map((row) => structuredClone(row)),
38
- [dataTable.body]
39
- )
39
+ const theme = useTheme()
40
+
41
+ const colspan = head.length + (selectable ? 1 : 0) + (actions ? 1 : 0)
42
+
43
+ const allSelected = rows.length > 0 && table.getIsAllPageRowsSelected()
44
+ const someSelected = table.getIsSomePageRowsSelected() && ! allSelected
45
+
46
+ const renderCell = (cell, row) => {
47
+ const column = headOf(cell)
48
+ const Component = column.component ? (dataTableComponents[column.component] ?? null) : null
49
+
50
+ if (Component) {
51
+ return (
52
+ <Component
53
+ {...componentProps(cellValue(column, clones[row.index] ?? row.original))}
54
+ onCallback={(payload) => (
55
+ typeof column.callback === 'function' ? column.callback(payload, row.original) : null
56
+ )} />
57
+ )
58
+ }
59
+
60
+ const value = cellValue(column, clones[row.index] ?? row.original)
61
+
62
+ return column.html ? <span dangerouslySetInnerHTML={{ __html: value }} /> : value
63
+ }
64
+
65
+ const renderBody = () => {
66
+ // Primera carga: la forma de las filas, mientras llegan.
67
+ if (loading && rows.length === 0) {
68
+ return Array.from({ length: SKELETON_ROWS }, (_, index) => (
69
+ <tr key={`skeleton-${index}`} data-skeleton="true">
70
+ {selectable ? <td className={theme.tableSelect} /> : null}
71
+ {head.map((column) => (
72
+ <td key={column.id}><SkeletonComponent /></td>
73
+ ))}
74
+ {actions ? <td className={theme.tableSelect} /> : null}
75
+ </tr>
76
+ ))
77
+ }
78
+
79
+ // Sin filas se dice por qué: no es lo mismo vacío que prohibido.
80
+ if (rows.length === 0) {
81
+ return (
82
+ <tr>
83
+ <td colSpan={colspan} className={theme.tableEmpty}>
84
+ {error ? (
85
+ <>
86
+ <span role="alert">{error.message}</span>
87
+ {error.retryable ? (
88
+ <button type="button" className={theme.buttonLink} onClick={() => onRetry?.()}>
89
+ {labels.retry}
90
+ </button>
91
+ ) : null}
92
+ </>
93
+ ) : labels.empty}
94
+ </td>
95
+ </tr>
96
+ )
97
+ }
98
+
99
+ return table.getRowModel().rows.map((row) => (
100
+ <tr key={row.id} data-selected={row.getIsSelected() ? 'true' : undefined}>
101
+ {selectable ? (
102
+ <td className={theme.tableSelect}>
103
+ <input
104
+ type="checkbox"
105
+ className={theme.checkbox}
106
+ aria-label={`${labels.selectRow} ${row.original.id}`}
107
+ checked={row.getIsSelected()}
108
+ onChange={row.getToggleSelectedHandler()} />
109
+ </td>
110
+ ) : null}
111
+
112
+ {row.getVisibleCells().map((cell) => (
113
+ <td key={cell.id} className={headOf(cell).numeric ? theme.tableNumeric : undefined}>
114
+ {renderCell(cell, row)}
115
+ </td>
116
+ ))}
117
+
118
+ {actions ? (
119
+ <td className={theme.tableSelect}>
120
+ <MenuComponent
121
+ items={itemsFor(row.original)}
122
+ label={`${labels.rowActions} ${row.original.id}`}
123
+ beforeOpen={() => prepareRow(row.original.id)} />
124
+ </td>
125
+ ) : null}
126
+ </tr>
127
+ ))
128
+ }
40
129
 
41
130
  return (
42
- <div className="sm:rounded-lg overflow-x-auto">
43
- <table className="min-w-full w-full text-sm text-left text-slate-500 dark:text-slate-400 p-4">
131
+ <div className={theme.tableContainer}>
132
+ <table className={[theme.table, theme.tableSticky].filter(Boolean).join(' ')} aria-busy={loading ? 'true' : 'false'}>
44
133
  {showTableHeader ? (
45
- <thead className="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 rounded-sm">
134
+ <thead>
46
135
  <tr>
136
+ {selectable ? (
137
+ <th scope="col" className={theme.tableSelect}>
138
+ <input
139
+ type="checkbox"
140
+ className={theme.checkbox}
141
+ aria-label={labels.selectAll}
142
+ checked={allSelected}
143
+ // React no tiene prop para esto: es una propiedad del DOM.
144
+ ref={(element) => {
145
+ if (element) {
146
+ element.indeterminate = someSelected
147
+ }
148
+ }}
149
+ disabled={rows.length === 0}
150
+ onChange={(event) => table.toggleAllPageRowsSelected(event.target.checked)} />
151
+ </th>
152
+ ) : null}
153
+
47
154
  {head.map((column) => (
48
155
  <th
49
156
  key={column.id}
50
157
  id={`th_${column.id}`}
51
- className={`px-6 py-3${column.sortable ? ' pointer' : ''}`}
52
158
  scope="col"
53
- onClick={() => onSortColumn?.(column)}>
54
- {column.value}
159
+ className={column.numeric ? theme.tableNumeric : undefined}
160
+ aria-sort={ariaSort(column, orderBy, sort)}>
161
+ {column.sortable === true ? (
162
+ <button type="button" className={theme.tableSort} onClick={() => onSortColumn?.(column)}>
163
+ <span>{column.value}</span>
164
+ <IconComponent name={sortIcon(column, orderBy, sort)} size={12} />
165
+ </button>
166
+ ) : column.value}
55
167
  </th>
56
168
  ))}
57
- {actions ? <th className="fe-shrink"></th> : null}
169
+
170
+ {actions ? <th scope="col" className={theme.tableSelect} aria-label={labels.actions} /> : null}
58
171
  </tr>
59
172
  </thead>
60
173
  ) : null}
61
174
 
62
- <tbody>
63
- {(dataTable.body ?? []).map((row, index) => (
64
- <tr
65
- key={row.id}
66
- className="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
67
- {head.map((column) => {
68
- // Las celdas leen de la copia; las acciones,
69
- // de la fila real, porque su identidad es lo
70
- // que el hook usa para sustituirlas al
71
- // resolver las politicas.
72
- const value = cellValue(column, body[index] ?? row)
73
- const Component = column.component ? dataTableComponents[column.component] : null
74
-
75
- return (
76
- <td key={column.id} className="px-6 py-4">
77
- {Component ? (
78
- <Component
79
- {...(typeof value === 'object' && value !== null ? value : { value })}
80
- onCallback={(payload) => (
81
- typeof column.callback === 'function'
82
- ? column.callback(payload, row)
83
- : null
84
- )} />
85
- ) : column.html ? (
86
- <span className="dark:text-white" dangerouslySetInnerHTML={{ __html: value }}></span>
87
- ) : (
88
- <span className="dark:text-white">{value}</span>
89
- )}
90
- </td>
91
- )
92
- })}
93
-
94
- {actions ? (
95
- <td className="fe-text-right">
96
- <button
97
- type="button"
98
- aria-label={`Acciones del registro ${row.id}`}
99
- 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"
100
- popoverTarget={`dropdown_${row.id}`}
101
- onClick={() => onActionButtonClicked?.(row.actions)}>
102
- <DatatableIcon icon="actions" />
103
- </button>
104
-
105
- <NavDropdownComponent id={`dropdown_${row.id}`} pos="left">
106
- <ActionListComponent
107
- actions={row.actions ?? []}
108
- extraParams={extraParams}
109
- extraQuery={extraQuery}
110
- onActionClicked={onActionClicked} />
111
- </NavDropdownComponent>
112
- </td>
113
- ) : null}
114
- </tr>
115
- ))}
116
- </tbody>
175
+ <tbody>{renderBody()}</tbody>
117
176
  </table>
118
177
  </div>
119
178
  )
@@ -1,69 +1,67 @@
1
+ import IconComponent from 'innoboxrr-react-form-elements/src/IconComponent.jsx'
2
+
3
+ import { DEFAULT_LABELS, summary } from '../table.js'
4
+ import useTheme from '../useTheme.js'
5
+
1
6
  /**
2
- * Gemelo de SelectPaginationComponent.vue.
7
+ * Gemelo de SelectPaginationComponent.vue: cuántos registros se ven y en qué
8
+ * página se está.
3
9
  *
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.
10
+ * No guarda la página: la dueña es la tabla, y una copia local se
11
+ * desincronizaba cuando la página cambiaba desde fuera.
7
12
  */
8
- export default function SelectPaginationComponent({ meta = {}, onPageChange }) {
9
- const current = meta.current_page ?? 1
10
- const last = meta.last_page ?? 1
13
+ export default function SelectPaginationComponent({ meta = {}, labels = null, onPageChange }) {
14
+ const theme = useTheme()
15
+ const text = { ...DEFAULT_LABELS, ...(labels ?? {}) }
16
+
17
+ const current = Number(meta?.current_page ?? 1)
18
+ const last = Number(meta?.last_page ?? 1)
19
+
20
+ const go = (value) => {
21
+ const page = Number(value)
22
+
23
+ if (page >= 1 && page <= last && page !== current) {
24
+ onPageChange?.(page)
25
+ }
26
+ }
11
27
 
12
28
  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>
29
+ <div className={theme.tableFooter}>
30
+ <span>{summary(meta ?? {}, text)}</span>
31
+
32
+ {last > 1 ? (
33
+ <div className={theme.tablePager}>
34
+ <button
35
+ type="button"
36
+ className={theme.iconButton}
37
+ aria-label={text.previous}
38
+ disabled={current <= 1}
39
+ onClick={() => go(current - 1)}>
40
+ <IconComponent name="previous" size={14} />
41
+ </button>
23
42
 
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}
43
+ <select
44
+ className={theme.select}
45
+ aria-label={text.page}
46
+ value={current}
47
+ onChange={(event) => go(event.target.value)}>
48
+ {Array.from({ length: last }, (_, index) => index + 1).map((page) => (
49
+ <option key={page} value={page}>{page}</option>
50
+ ))}
51
+ </select>
39
52
 
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>
53
+ <span>{text.of} {last}</span>
51
54
 
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>
55
+ <button
56
+ type="button"
57
+ className={theme.iconButton}
58
+ aria-label={text.next}
59
+ disabled={current >= last}
60
+ onClick={() => go(current + 1)}>
61
+ <IconComponent name="next" size={14} />
62
+ </button>
63
+ </div>
64
+ ) : null}
67
65
  </div>
68
66
  )
69
67
  }
package/src/table.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Lo que la tabla decide sin depender de Vue ni de React: cómo se traduce el
3
+ * contrato del modelo a columnas de TanStack Table, qué se pide al servidor,
4
+ * qué se le dice al usuario cuando algo falla y en qué se convierte cada
5
+ * acción.
6
+ *
7
+ * El archivo es idéntico en innoboxrr-vue-datatable y en
8
+ * innoboxrr-react-datatable. Es lo que hace que las dos tablas se comporten
9
+ * igual: si cambia aquí, cambia allí.
10
+ */
11
+
12
+ /** Las acciones que borran se pintan en rojo aunque el modelo no lo diga. */
13
+ const DANGER_ACTIONS = ['delete', 'forceDelete']
14
+
15
+ /** La clave con la que se guardan los permisos de la barra superior. */
16
+ export const CRUD_POLICIES = '__crud__'
17
+
18
+ export const DEFAULT_LABELS = {
19
+ actions: 'Acciones',
20
+ rowActions: 'Acciones del registro',
21
+ refresh: 'Actualizar',
22
+ filters: 'Filtros',
23
+ selectAll: 'Seleccionar todos los de esta página',
24
+ selectRow: 'Seleccionar el registro',
25
+ selection: 'Selección',
26
+ selected: 'seleccionados',
27
+ clearSelection: 'Quitar selección',
28
+ empty: 'No hay resultados',
29
+ retry: 'Reintentar',
30
+ of: 'de',
31
+ page: 'Página',
32
+ previous: 'Página anterior',
33
+ next: 'Página siguiente',
34
+ forbidden: 'No tienes permiso para ver estos registros.',
35
+ offline: 'No se pudo conectar con el servidor.',
36
+ failed: 'No se pudieron cargar los registros.',
37
+ policiesFailed: 'No se pudieron comprobar los permisos.',
38
+ notAllowed: 'No tienes permiso para esta acción.',
39
+ actionFailed: 'No se pudo completar la acción.',
40
+ }
41
+
42
+ /**
43
+ * Se leía de la global `csrf_token`, que la aplicación anfitriona tenía que
44
+ * definir en window: el componente no se podía montar fuera de ella.
45
+ */
46
+ export const csrfToken = () => globalThis.csrf_token
47
+ ?? globalThis.document?.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
48
+ ?? ''
49
+
50
+ /**
51
+ * Sustituye a `_.isEqual` de lodash, que se usaba como global sin declararla
52
+ * como dependencia.
53
+ */
54
+ export const isEqual = (a, b) => {
55
+ if (a === b) {
56
+ return true
57
+ }
58
+
59
+ if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
60
+ return false
61
+ }
62
+
63
+ const keysA = Object.keys(a)
64
+ const keysB = Object.keys(b)
65
+
66
+ return keysA.length === keysB.length && keysA.every((key) => isEqual(a[key], b[key]))
67
+ }
68
+
69
+ /**
70
+ * Una foto de un objeto de filtros. Comparar contra el valor anterior no sirve
71
+ * cuando el anfitrión muta el mismo objeto: el anterior y el nuevo son el mismo.
72
+ */
73
+ export const snapshot = (value) => JSON.stringify(value ?? {})
74
+
75
+ /**
76
+ * Se admite tanto ['name'] como [{ id: 'name' }]: el contrato nunca estuvo
77
+ * documentado y por ahí circulan las dos formas.
78
+ */
79
+ export const hiddenColumnIds = (hideColumns = []) => hideColumns
80
+ .map((column) => (typeof column === 'string' ? column : column?.id))
81
+ .filter(Boolean)
82
+
83
+ /**
84
+ * `dataTableHead()` del modelo, en columnas de TanStack Table. La columna
85
+ * original viaja en `meta.head`, que es de donde la tabla saca el parser, el
86
+ * componente y si el valor es HTML.
87
+ */
88
+ export const columnsFrom = (head = []) => head.map((column) => ({
89
+ id: column.id,
90
+ accessorFn: (row) => row?.[column.id],
91
+ header: column.value,
92
+ enableSorting: column.sortable === true,
93
+ meta: { head: column },
94
+ }))
95
+
96
+ /** Solo un `false` explícito oculta una columna en TanStack Table. */
97
+ export const visibilityFrom = (hideColumns) => Object.fromEntries(
98
+ hiddenColumnIds(hideColumns).map((id) => [id, false])
99
+ )
100
+
101
+ /** El orden lo decide el servidor; la tabla solo lo refleja. */
102
+ export const sortingFrom = (orderBy, sort = {}) => (
103
+ orderBy ? [{ id: orderBy, desc: sort[orderBy] === 'desc' }] : []
104
+ )
105
+
106
+ /**
107
+ * La primera pulsación sobre una columna que el modelo no ordena por defecto
108
+ * la deja ascendente; las siguientes alternan.
109
+ */
110
+ export const toggledSort = (sort, id) => ({ ...sort, [id]: sort[id] === 'asc' ? 'desc' : 'asc' })
111
+
112
+ export const ariaSort = (column, orderBy, sort = {}) => {
113
+ if (column.sortable !== true) {
114
+ return undefined
115
+ }
116
+
117
+ if (column.id !== orderBy) {
118
+ return 'none'
119
+ }
120
+
121
+ return sort[column.id] === 'desc' ? 'descending' : 'ascending'
122
+ }
123
+
124
+ export const sortIcon = (column, orderBy, sort = {}) => {
125
+ const state = ariaSort(column, orderBy, sort)
126
+
127
+ return state === 'ascending' ? 'sortUp' : state === 'descending' ? 'sortDown' : 'sort'
128
+ }
129
+
130
+ /**
131
+ * Lo que espera el backend. Con orden interno, el del usuario gana a lo que
132
+ * traigan los filtros externos; sin él, es al revés.
133
+ */
134
+ export const requestFilters = ({ formFilters = {}, externalFilters = {}, orderBy, sort = {}, page, internalSort }) => {
135
+ const params = { _token: csrfToken(), managed: true, except_view_any: true }
136
+ const order = { orderBy, orderMode: sort[orderBy] }
137
+
138
+ return internalSort
139
+ ? { ...params, ...formFilters, ...externalFilters, ...order, page }
140
+ : { ...params, ...formFilters, ...order, ...externalFilters, page }
141
+ }
142
+
143
+ export const requestConfig = (method, url, payload) => ({
144
+ method,
145
+ url,
146
+ data: method === 'post' ? payload : null,
147
+ params: method === 'get' ? payload : null,
148
+ })
149
+
150
+ /** El usuario canceló una confirmación: no es un error que haya que contar. */
151
+ export const isCancelled = (error) => ['RequestCancelledError', 'CanceledError'].includes(error?.name)
152
+
153
+ /**
154
+ * Qué decirle al usuario. Antes un fallo se reintentaba tres veces en silencio
155
+ * y un 403 dejaba la tabla vacía con «No results found», como si no hubiera
156
+ * registros.
157
+ */
158
+ export const describeError = (error, labels = DEFAULT_LABELS, fallback = labels.failed) => {
159
+ const status = error?.response?.status ?? null
160
+
161
+ if (status === 403) {
162
+ return { status, message: labels.forbidden, retryable: false }
163
+ }
164
+
165
+ // Un fallo de red no trae respuesta; un error de programa, tampoco, y no
166
+ // es cosa de la conexión.
167
+ if (! error?.response) {
168
+ const offline = Boolean(error?.request) || error?.code === 'ERR_NETWORK' || error?.message === 'Network Error'
169
+
170
+ return { status, message: offline ? labels.offline : fallback, retryable: true }
171
+ }
172
+
173
+ const message = error.response.data?.message
174
+
175
+ return {
176
+ status,
177
+ message: typeof message === 'string' && message !== '' ? message : fallback,
178
+ retryable: true,
179
+ }
180
+ }
181
+
182
+ /**
183
+ * Marca `policy` en cada acción según lo que respondió el backend. Una acción
184
+ * que el modelo declara ya permitida se queda así.
185
+ */
186
+ export const withPolicies = (actions = [], allowed = null) => actions.map((action) => ({
187
+ ...action,
188
+ policy: action.policy === true || allowed?.[action.id] === true,
189
+ }))
190
+
191
+ export const actionKind = (action) => {
192
+ if (! action.route) {
193
+ return 'callback'
194
+ }
195
+
196
+ return action.link ? 'link' : 'route'
197
+ }
198
+
199
+ export const routeTarget = (action, extraParams = {}, extraQuery = {}) => {
200
+ const to = action.params?.to ?? {}
201
+
202
+ return {
203
+ name: to.name,
204
+ params: { ...(to.params ?? {}), ...extraParams },
205
+ query: to.query ? { ...to.query, ...extraQuery } : { ...extraQuery },
206
+ }
207
+ }
208
+
209
+ /**
210
+ * Las acciones del contrato, como elementos de MenuComponent. Una acción sin
211
+ * permiso se ve deshabilitada y dice por qué: quien no puede tiene que saber
212
+ * que existe.
213
+ */
214
+ export const menuItems = (actions = [], labels, run) => actions.map((action, index) => {
215
+ const allowed = action.policy === true
216
+
217
+ return {
218
+ id: action.id ?? `${action.name}-${index}`,
219
+ label: action.name,
220
+ icon: action.icon || undefined,
221
+ danger: action.danger ?? DANGER_ACTIONS.includes(action.id),
222
+ disabled: ! allowed,
223
+ disabledReason: allowed ? undefined : labels.notAllowed,
224
+ action: () => run(action),
225
+ }
226
+ })
227
+
228
+ /**
229
+ * Copia aislada de una fila, para que un parser del modelo no pueda mutar los
230
+ * datos de la tabla. Se hace una vez por fila y carga, no una por celda.
231
+ */
232
+ export const cloneRow = (row) => JSON.parse(JSON.stringify(row ?? {}))
233
+
234
+ export const cellValue = (column, row) => (
235
+ typeof column.parser === 'function' ? column.parser(row?.[column.id], row) : row?.[column.id]
236
+ )
237
+
238
+ /** Un componente de celda recibe el objeto que devuelve el parser, o `{ value }`. */
239
+ export const componentProps = (value) => (
240
+ typeof value === 'object' && value !== null && ! Array.isArray(value) ? value : { value }
241
+ )
242
+
243
+ export const summary = (meta = {}, labels = DEFAULT_LABELS) => (
244
+ meta.total > 0 ? `${meta.from}–${meta.to} ${labels.of} ${meta.total}` : labels.empty
245
+ )
246
+
247
+ /**
248
+ * La casilla de un rango con Mayúsculas. El evento de cambio de una casilla
249
+ * no siempre trae la tecla; el clic, sí.
250
+ */
251
+ export const isRangeEvent = (event) => Boolean(event?.shiftKey ?? event?.nativeEvent?.shiftKey)