innoboxrr-react-datatable 2.2.0 → 3.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +67 -51
- package/index.js +5 -4
- package/package.json +6 -3
- package/src/DataTable.jsx +165 -96
- package/src/components/DataTableComponent.jsx +150 -89
- package/src/components/SelectPaginationComponent.jsx +56 -58
- package/src/table.js +251 -0
- package/src/useDataTable.js +310 -168
- package/src/useTheme.js +17 -0
- package/src/components/ActionListComponent.jsx +0 -62
- package/src/components/DatatableIcon.jsx +0 -39
- package/src/components/DisabledLinkComponent.jsx +0 -23
- package/src/components/IconLinkComponent.jsx +0 -25
- package/src/components/IconRouteComponent.jsx +0 -31
- package/src/components/NavDropdownComponent.jsx +0 -35
|
@@ -1,117 +1,178 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
3
|
-
import
|
|
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'
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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 ?? {}
|
|
8
11
|
|
|
9
12
|
/**
|
|
10
|
-
* 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.
|
|
11
19
|
*/
|
|
12
20
|
export default function DataTableComponent({
|
|
21
|
+
table,
|
|
22
|
+
head = [],
|
|
23
|
+
rows = [],
|
|
24
|
+
clones = [],
|
|
25
|
+
loading = false,
|
|
26
|
+
error = null,
|
|
13
27
|
actions = false,
|
|
14
|
-
|
|
15
|
-
extraParams = {},
|
|
16
|
-
extraQuery = {},
|
|
28
|
+
selectable = false,
|
|
17
29
|
showTableHeader = true,
|
|
18
30
|
dataTableComponents = {},
|
|
31
|
+
labels = DEFAULT_LABELS,
|
|
32
|
+
orderBy = null,
|
|
33
|
+
sort = {},
|
|
34
|
+
itemsFor = () => [],
|
|
35
|
+
prepareRow = async () => {},
|
|
19
36
|
onSortColumn,
|
|
20
|
-
|
|
21
|
-
onActionClicked,
|
|
37
|
+
onRetry,
|
|
22
38
|
}) {
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
}
|
|
39
129
|
|
|
40
130
|
return (
|
|
41
|
-
<div className=
|
|
42
|
-
<table className=
|
|
131
|
+
<div className={theme.tableContainer}>
|
|
132
|
+
<table className={[theme.table, theme.tableSticky].filter(Boolean).join(' ')} aria-busy={loading ? 'true' : 'false'}>
|
|
43
133
|
{showTableHeader ? (
|
|
44
|
-
<thead
|
|
134
|
+
<thead>
|
|
45
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
|
+
|
|
46
154
|
{head.map((column) => (
|
|
47
155
|
<th
|
|
48
156
|
key={column.id}
|
|
49
157
|
id={`th_${column.id}`}
|
|
50
|
-
className={`px-6 py-3${column.sortable ? ' pointer' : ''}`}
|
|
51
158
|
scope="col"
|
|
52
|
-
|
|
53
|
-
{column
|
|
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}
|
|
54
167
|
</th>
|
|
55
168
|
))}
|
|
56
|
-
|
|
169
|
+
|
|
170
|
+
{actions ? <th scope="col" className={theme.tableSelect} aria-label={labels.actions} /> : null}
|
|
57
171
|
</tr>
|
|
58
172
|
</thead>
|
|
59
173
|
) : null}
|
|
60
174
|
|
|
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>
|
|
175
|
+
<tbody>{renderBody()}</tbody>
|
|
115
176
|
</table>
|
|
116
177
|
</div>
|
|
117
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
|
-
*
|
|
5
|
-
*
|
|
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
|
|
10
|
-
const
|
|
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=
|
|
14
|
-
<
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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)
|