innoboxrr-vue-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/src/DataTable.vue CHANGED
@@ -1,430 +1,256 @@
1
1
  <template>
2
- <div>
3
- <div v-if="showTopbar">
4
- <div class="fe-container fe-container-wide pt-4">
5
- <div fe-grid>
6
- <div class="fe-w-expand" v-if="hasActions">
7
- <button
8
- class="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"
9
- @click="actionButtonClicked(crudActions)">
10
- Acciones
11
- </button>
12
- <NavDropdownComponent id="actionCrudDropdown" pos="right">
13
- <li v-for="action in crudActions" :key="action.id">
14
- <template v-if="action.route">
15
- <IconRouteComponent
16
- v-if="action.policy"
17
- :name="action.params.to.name"
18
- :params="{...action.params.to.params, ...extraParams}"
19
- :query="action.params.to.query ? {...action.params.to.query, ...extraQuery} : {...extraQuery}"
20
- :icon="action.icon"
21
- :text="action.name" />
22
- <DisabledLinkComponent
23
- v-else
24
- :icon="action.icon"
25
- :text="action.name"/>
26
- </template>
27
- <template v-else>
28
- <IconLinkComponent
29
- v-if="action.policy"
30
- :icon="action.icon"
31
- :text="action.name"
32
- @click="actionClicked(action) , closeDropdown($event)" />
33
- <DisabledLinkComponent
34
- v-else
35
- :icon="action.icon"
36
- :text="action.name"/>
37
- </template>
38
- </li>
39
- </NavDropdownComponent>
40
- </div>
41
- <div v-else>
42
- <div class="fe-w-expand"></div>
43
- </div>
44
- <div v-if="hasFilter" class="fe-w-auto">
45
- <div class="fe-grid-divider fe-children-expand fe-text-center" fe-grid>
46
- <div>
47
- <button
48
- type="button"
49
- class="fe-text-right pointer"
50
- data-tooltip="Update results"
51
- aria-label="Update results"
52
- @click="updateFilters">
53
- <svg class="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">
54
- <path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="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"/>
55
- </svg>
56
- </button>
57
- </div>
58
- <div>
59
- <button
60
- type="button"
61
- class="fe-text-right pointer"
62
- uk-toggle="target: .filter-form; animation: uk-animation-scale-up;"
63
- data-tooltip="Buscar"
64
- aria-label="Buscar">
65
- <svg class="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">
66
- <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"/>
67
- </svg>
68
- </button>
69
- </div>
70
- </div>
71
- </div>
72
- </div>
73
- </div>
74
- <div
75
- v-if="hasFilter"
76
- class="filter-form fe-card fe-card-body fe-pt-0"
77
- hidden>
78
- <slot name="filterForm"></slot>
79
- </div>
80
- </div>
81
- <div
82
- class="fe-container fe-container-wide"
83
- :class="{ 'ptb-20': showTopbar }">
84
- <div
85
- class="fe-p-sm"
86
- :class="{ 'bg-white p-6 rounded-lg shadow dark:border-slate-700 dark:bg-slate-800': cardWrapper }">
87
- <DataTableComponent
88
- :actions="hasActions"
89
- :data-table="dataTable"
90
- :extra-params="extraParams"
91
- :extra-query="extraQuery"
92
- :show-table-header="showTableHeader"
93
- :data-table-components="dataTableComponents"
94
- @sortColumn="sortColumn"
95
- @actionButtonClicked="actionButtonClicked"
96
- @actionClicked="actionClicked" />
97
- <SelectPaginationComponent
98
- :meta="pagination.meta"
99
- :links="pagination.links"
100
- @updatePage="updatePage" />
101
- </div>
102
- </div>
103
- </div>
104
- </template>
105
-
106
- <script setup>
107
-
108
- import { markRaw, onMounted, reactive, ref, watch } from 'vue'
109
- import axios from 'axios'
110
-
111
- import NavDropdownComponent from './components/NavDropdownComponent.vue'
112
- import IconRouteComponent from './components/IconRouteComponent.vue'
113
- import IconLinkComponent from './components/IconLinkComponent.vue'
114
- import DisabledLinkComponent from './components/DisabledLinkComponent.vue'
115
- import DataTableComponent from './components/DataTableComponent.vue'
116
- import SelectPaginationComponent from './components/SelectPaginationComponent.vue'
117
-
118
- const props = defineProps({
119
- dataUrl: {
120
- type: String,
121
- required: true
122
- },
123
- dataMethod: {
124
- type: String,
125
- default: 'post',
126
- },
127
- model: {
128
- type: Object,
129
- required: true
130
- },
131
- policyUrl: {
132
- type: String,
133
- required: true
134
- },
135
- policyMethod: {
136
- type: String,
137
- default: 'post'
138
- },
139
- showTopbar:{
140
- type: Boolean,
141
- default: true
142
- },
143
- hasActions: {
144
- type: Boolean,
145
- default: true,
146
- },
147
- hasFilter: {
148
- type: Boolean,
149
- default: true,
150
- },
151
- // Vue 3 exige factoria en los defaults de objeto y array.
152
- formFilters: {
153
- type: Object,
154
- default: () => ({})
155
- },
156
- externalFilters: {
157
- type: Object,
158
- default: () => ({})
159
- },
160
- extraParams: {
161
- type: Object,
162
- default: () => ({})
163
- },
164
- extraQuery: {
165
- type: Object,
166
- default: () => ({})
167
- },
168
- hideColumns: {
169
- type: Array,
170
- default: () => []
171
- },
172
- cardWrapper: {
173
- type: Boolean,
174
- default: true
175
- },
176
- showTableHeader: {
177
- type: Boolean,
178
- default: true,
179
- }
180
- })
181
-
182
- /**
183
- * Se leia de la global `csrf_token`, que la aplicacion anfitriona tenia
184
- * que definir en window: el componente no se podia montar fuera de ella.
185
- */
186
- const csrfToken = () => globalThis.csrf_token
187
- ?? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
188
- ?? ''
189
-
190
- /**
191
- * Sustituye a `_.isEqual` de lodash, que se usaba como global sin
192
- * declararla como dependencia.
193
- */
194
- const isEqual = (a, b) => {
195
-
196
- if (a === b) {
197
- return true
198
- }
199
-
200
- if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
201
- return false
202
- }
203
-
204
- const keysA = Object.keys(a)
205
- const keysB = Object.keys(b)
206
-
207
- return keysA.length === keysB.length && keysA.every((key) => isEqual(a[key], b[key]))
208
-
209
- }
210
-
211
- const hiddenColumnIds = () => props.hideColumns.map(
212
- // Se admite tanto ['name'] como [{ id: 'name' }]: el contrato nunca
213
- // estuvo documentado y por ahi circulan las dos formas.
214
- (column) => typeof column === 'string' ? column : column?.id
215
- )
216
-
217
- const buildHead = () => {
218
-
219
- const hidden = hiddenColumnIds()
220
-
221
- return props.model.dataTableHead().filter((column) => ! hidden.includes(column.id))
222
-
223
- }
224
-
225
- const registerComponents = () => {
226
-
227
- if (typeof props.model.dataTableComponents !== 'function') {
228
- return {}
229
- }
230
-
231
- const declared = props.model.dataTableComponents()
232
-
233
- return Object.keys(declared).reduce((components, key) => {
234
- components[key] = markRaw(declared[key])
235
-
236
- return components
237
- }, {})
238
-
239
- }
240
-
241
- const crudActions = ref(props.model.crudActions())
242
-
243
- const dataTable = reactive({
244
- head: buildHead(),
245
- body: [],
246
- })
247
-
248
- const pagination = reactive({
249
- meta: [],
250
- links: [],
251
- })
252
-
253
- const dataTableComponents = registerComponents()
254
-
255
- const sort = reactive(props.model.dataTableSort())
256
-
257
- const orderBy = ref('id')
258
- const internalSort = ref(false)
259
- const page = ref(1)
260
-
261
- let fetchDataAttempts = 0
262
- let fetchPoliciesAttempts = 0
263
-
264
- const getFilters = () => {
265
-
266
- const params = {
267
- _token: csrfToken(),
268
- managed: true,
269
- except_view_any: true,
270
- }
271
-
272
- const order = {
273
- orderBy: orderBy.value,
274
- orderMode: sort[orderBy.value],
275
- }
276
-
277
- // Con orden interno, el del usuario gana a lo que traigan los filtros
278
- // externos; sin el, es al reves.
279
- return internalSort.value
280
- ? { ...params, ...props.formFilters, ...props.externalFilters, ...order, page: page.value }
281
- : { ...params, ...props.formFilters, ...order, ...props.externalFilters, page: page.value }
282
-
283
- }
284
-
285
- const fetchData = () => {
286
2
 
287
- const filters = getFilters()
3
+ <section :class="[theme.datatable, cardWrapper ? theme.surface : null]">
4
+
5
+ <!-- Con filas seleccionadas, la barra dice cuántas y qué hacer con ellas,
6
+ en el mismo sitio que la de siempre para que la tabla no salte. -->
7
+ <div
8
+ v-if="bulkVisible"
9
+ :class="theme.bulkBar"
10
+ role="region"
11
+ :aria-label="text.selection">
12
+ <span :class="theme.bulkCount" aria-live="polite">{{ selectedIds.length }} {{ text.selected }}</span>
13
+ <button
14
+ v-for="action in bulkActions"
15
+ :key="action.id ?? action.name"
16
+ type="button"
17
+ :class="[action.danger ? theme.buttonDanger : theme.buttonSecondary, 'fe-button-sm']"
18
+ @click="runBulk(action)">
19
+ <IconComponent v-if="action.icon" :name="action.icon" :size="14" />
20
+ <span>{{ action.name }}</span>
21
+ </button>
22
+ <span :class="theme.toolbarSpacer" />
23
+ <button type="button" :class="theme.buttonLink" @click="clearSelection">{{ text.clearSelection }}</button>
24
+ </div>
25
+
26
+ <div v-else-if="showTopbar" :class="theme.toolbar">
27
+ <MenuComponent
28
+ v-if="hasActions"
29
+ :items="crudItems"
30
+ :label="text.actions"
31
+ placement="bottom-start"
32
+ :before-open="prepareCrud">
33
+ <template #trigger="{ toggle, loading: busy, triggerProps }">
34
+ <button
35
+ type="button"
36
+ :class="theme.buttonSecondary"
37
+ v-bind="triggerProps"
38
+ :disabled="busy"
39
+ @click="toggle">
40
+ <IconComponent name="actions" :size="14" />
41
+ <span>{{ text.actions }}</span>
42
+ </button>
43
+ </template>
44
+ </MenuComponent>
45
+
46
+ <span :class="theme.toolbarSpacer" />
47
+
48
+ <button type="button" :class="theme.iconButton" :aria-label="text.refresh" @click="refresh">
49
+ <IconComponent name="refresh" :size="16" />
50
+ </button>
51
+
52
+ <button
53
+ v-if="hasFilter"
54
+ type="button"
55
+ :class="theme.iconButton"
56
+ :aria-label="text.filters"
57
+ :aria-expanded="filtersOpen ? 'true' : 'false'"
58
+ :aria-controls="filtersId"
59
+ @click="filtersOpen = ! filtersOpen">
60
+ <IconComponent name="filter" :size="16" />
61
+ </button>
62
+ </div>
63
+
64
+ <!-- Abrir el panel es estado del componente. Antes lo hacía uk-toggle
65
+ buscando `.filter-form` por selector, y Vue y React acabaron
66
+ comportándose distinto. -->
67
+ <div
68
+ v-if="showTopbar && hasFilter"
69
+ :id="filtersId"
70
+ :class="['filter-form', theme.datatableFilters]"
71
+ :hidden="! filtersOpen">
72
+ <slot name="filterForm"></slot>
73
+ </div>
74
+
75
+ <DataTableComponent
76
+ :table="table"
77
+ :head="visibleHead"
78
+ :rows="rows"
79
+ :clones="clones"
80
+ :loading="loading"
81
+ :error="error"
82
+ :actions="hasActions"
83
+ :selectable="selectable"
84
+ :show-table-header="showTableHeader"
85
+ :data-table-components="dataTableComponents"
86
+ :labels="text"
87
+ :order-by="orderBy"
88
+ :sort="sort"
89
+ :items-for="rowItems"
90
+ :prepare-row="preparePolicies"
91
+ @sort-column="sortColumn"
92
+ @retry="refresh" />
93
+
94
+ <!-- Con un error y sin filas, un «No hay resultados» en el pie
95
+ contradiría el motivo que ya da la tabla. -->
96
+ <SelectPaginationComponent
97
+ v-if="! (error && rows.length === 0)"
98
+ :meta="meta"
99
+ :links="links"
100
+ :labels="text"
101
+ @update-page="updatePage" />
102
+
103
+ </section>
288
104
 
289
- return axios({
290
- method: props.dataMethod,
291
- url: props.dataUrl,
292
- data: props.dataMethod === 'post' ? filters : null,
293
- params: props.dataMethod === 'get' ? filters : null,
294
- }).then((res) => {
295
-
296
- fetchDataAttempts = 0
297
- dataTable.body = res.data.data
298
- pagination.meta = res.data.meta
299
- pagination.links = res.data.links
300
-
301
- }).catch((error) => {
302
-
303
- // Un fallo de red no trae respuesta: leer error.response.status
304
- // sin comprobarlo lanzaba un TypeError dentro del propio manejador.
305
- if (error.response?.status === 403) {
306
- return
307
- }
308
-
309
- if (fetchDataAttempts <= 3) {
310
- setTimeout(() => {
311
- ++fetchDataAttempts
312
- fetchData()
313
- }, 1500)
314
- }
315
-
316
- })
317
-
318
- }
319
-
320
- const updateFilters = () => {
321
- props.model.setFilters(getFilters())
322
-
323
- return fetchData()
324
- }
325
-
326
- const sortColumn = (column) => {
327
-
328
- if (column.sortable !== true) {
329
- return
330
- }
331
-
332
- // A partir de aqui el orden elegido por el usuario manda sobre el que
333
- // puedan traer los filtros externos.
334
- internalSort.value = true
335
- orderBy.value = column.id
336
- sort[column.id] = sort[column.id] === 'asc' ? 'desc' : 'asc'
337
-
338
- updateFilters()
339
-
340
- }
341
-
342
- const updatePage = (newPage) => {
343
- page.value = newPage
344
-
345
- updateFilters()
346
- }
347
-
348
- const actionClicked = (action) => {
349
- return props.model[action.callback](action.params)
350
- .then(() => updateFilters())
351
- .catch((error) => console.error(error))
352
- }
353
-
354
- const actionButtonClicked = (actions) => {
355
-
356
- const requestData = {
357
- _token: csrfToken(),
358
- id: actions[0]?.params?.id ?? null,
359
- }
360
-
361
- return axios({
362
- method: props.policyMethod,
363
- url: props.policyUrl,
364
- data: props.policyMethod === 'post' ? requestData : null,
365
- params: props.policyMethod === 'get' ? requestData : null,
366
- }).then((res) => {
367
-
368
- fetchPoliciesAttempts = 0
369
-
370
- actions.forEach((action) => {
371
- if (res.data[action.id]) {
372
- action.policy = true
373
- }
374
- })
375
-
376
- }).catch(() => {
377
-
378
- if (fetchPoliciesAttempts <= 3) {
379
- setTimeout(() => {
380
- ++fetchPoliciesAttempts
381
- actionButtonClicked(actions)
382
- }, 1500)
383
-
384
- return
385
- }
386
-
387
- setTimeout(() => {
388
- fetchPoliciesAttempts = 0
389
- }, 3000)
390
-
391
- })
392
-
393
- }
394
-
395
- const closeDropdown = (event) => {
396
-
397
- const dropdown = event.target.closest('.uk-dropdown')
398
-
399
- if (dropdown) {
400
- globalThis.UIkit?.dropdown(dropdown)?.hide(false)
401
- }
402
-
403
- }
404
-
405
- watch(() => props.formFilters, () => {
406
- page.value = 1
407
- updateFilters()
408
- }, { deep: true })
409
-
410
- watch(() => props.externalFilters, (value, previous) => {
411
- if (! isEqual(value, previous)) {
412
- updateFilters()
413
- }
414
- }, { deep: true })
105
+ </template>
415
106
 
416
- onMounted(fetchData)
107
+ <script setup>
417
108
 
418
- // El test y el anfitrion consultan el estado de las acciones; con
419
- // <script setup> los bindings son privados si no se exponen.
420
- defineExpose({ crudActions, dataTable, pagination })
109
+ /**
110
+ * Un listado sobre el contrato del modelo: `crudActions()`,
111
+ * `dataTableHead()`, `dataTableSort()`, `setFilters()` y, si las hay,
112
+ * `bulkActions()` y `dataTableComponents()`.
113
+ *
114
+ * El modelo es el mismo archivo para Vue y para React, y esta tabla recibe
115
+ * exactamente los mismos props que su gemela.
116
+ */
117
+
118
+ import { computed, inject, markRaw, ref, useId } from 'vue'
119
+ import { routerKey } from 'vue-router'
120
+ import IconComponent from 'innoboxrr-form-elements/src/IconComponent.vue'
121
+ import MenuComponent from 'innoboxrr-form-elements/src/MenuComponent.vue'
122
+
123
+ import DataTableComponent from './components/DataTableComponent.vue'
124
+ import SelectPaginationComponent from './components/SelectPaginationComponent.vue'
125
+ import useDataTable from './useDataTable.js'
126
+ import useTheme from './useTheme.js'
127
+ import { DEFAULT_LABELS, menuItems } from './table.js'
128
+
129
+ const props = defineProps({
130
+ dataUrl: {
131
+ type: String,
132
+ required: true,
133
+ },
134
+ dataMethod: {
135
+ type: String,
136
+ default: 'post',
137
+ },
138
+ model: {
139
+ type: Object,
140
+ required: true,
141
+ },
142
+ policyUrl: {
143
+ type: String,
144
+ required: true,
145
+ },
146
+ policyMethod: {
147
+ type: String,
148
+ default: 'post',
149
+ },
150
+ showTopbar: {
151
+ type: Boolean,
152
+ default: true,
153
+ },
154
+ hasActions: {
155
+ type: Boolean,
156
+ default: true,
157
+ },
158
+ hasFilter: {
159
+ type: Boolean,
160
+ default: true,
161
+ },
162
+ // Vue 3 exige factoría en los defaults de objeto y array.
163
+ formFilters: {
164
+ type: Object,
165
+ default: () => ({}),
166
+ },
167
+ externalFilters: {
168
+ type: Object,
169
+ default: () => ({}),
170
+ },
171
+ extraParams: {
172
+ type: Object,
173
+ default: () => ({}),
174
+ },
175
+ extraQuery: {
176
+ type: Object,
177
+ default: () => ({}),
178
+ },
179
+ hideColumns: {
180
+ type: Array,
181
+ default: () => [],
182
+ },
183
+ cardWrapper: {
184
+ type: Boolean,
185
+ default: true,
186
+ },
187
+ showTableHeader: {
188
+ type: Boolean,
189
+ default: true,
190
+ },
191
+ // Casillas para elegir filas y la barra de acciones masivas.
192
+ selectable: {
193
+ type: Boolean,
194
+ default: false,
195
+ },
196
+ // Los textos de la tabla; se mezclan con los de fábrica.
197
+ labels: {
198
+ type: Object,
199
+ default: () => ({}),
200
+ },
201
+ })
202
+
203
+ // Sin router montado no hay a dónde navegar, pero la tabla se puede
204
+ // pintar igual: inject con valor por defecto no avisa.
205
+ const router = inject(routerKey, null)
206
+
207
+ const navigate = (target) => router?.push(target)
208
+
209
+ const theme = useTheme()
210
+
211
+ const text = computed(() => ({ ...DEFAULT_LABELS, ...props.labels }))
212
+
213
+ const filtersOpen = ref(false)
214
+ const filtersId = useId()
215
+
216
+ const {
217
+ table,
218
+ visibleHead,
219
+ rows,
220
+ clones,
221
+ meta,
222
+ links,
223
+ loading,
224
+ error,
225
+ sort,
226
+ orderBy,
227
+ crudActions,
228
+ bulkActions,
229
+ rowActions,
230
+ selectedIds,
231
+ dataTable,
232
+ pagination,
233
+ refresh,
234
+ clearSelection,
235
+ sortColumn,
236
+ updatePage,
237
+ preparePolicies,
238
+ run,
239
+ runBulk,
240
+ } = useDataTable(props, { navigate: router ? navigate : null, labels: text })
241
+
242
+ const dataTableComponents = typeof props.model.dataTableComponents === 'function'
243
+ ? Object.fromEntries(Object.entries(props.model.dataTableComponents()).map(([key, component]) => [key, markRaw(component)]))
244
+ : {}
245
+
246
+ const bulkVisible = computed(() => props.selectable && selectedIds.value.length > 0)
247
+
248
+ const crudItems = computed(() => menuItems(crudActions.value, text.value, run))
249
+
250
+ const rowItems = (row) => menuItems(rowActions(row), text.value, run)
251
+
252
+ const prepareCrud = () => preparePolicies(null)
253
+
254
+ defineExpose({ refresh, clearSelection, table, selectedIds, crudActions, dataTable, pagination })
421
255
 
422
256
  </script>
423
-
424
- <style scoped>
425
- .tools-icon {
426
- font-size: 30px;
427
- color: var(--secondary-color);
428
- padding: 10px 15px;
429
- }
430
- </style>