innoboxrr-vue-datatable 2.3.0 → 3.1.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,431 +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
- data-tooltip="Buscar"
63
- aria-label="Buscar"
64
- :aria-expanded="filtersOpen"
65
- @click="filtersOpen = ! filtersOpen">
66
- <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">
67
- <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"/>
68
- </svg>
69
- </button>
70
- </div>
71
- </div>
72
- </div>
73
- </div>
74
- </div>
75
- <div
76
- v-if="hasFilter"
77
- class="filter-form fe-card fe-card-body fe-pt-0"
78
- :hidden="! filtersOpen">
79
- <slot name="filterForm"></slot>
80
- </div>
81
- </div>
82
- <div
83
- class="fe-container fe-container-wide"
84
- :class="{ 'ptb-20': showTopbar }">
85
- <div
86
- class="fe-p-sm"
87
- :class="{ 'bg-white p-6 rounded-lg shadow dark:border-slate-700 dark:bg-slate-800': cardWrapper }">
88
- <DataTableComponent
89
- :actions="hasActions"
90
- :data-table="dataTable"
91
- :extra-params="extraParams"
92
- :extra-query="extraQuery"
93
- :show-table-header="showTableHeader"
94
- :data-table-components="dataTableComponents"
95
- @sortColumn="sortColumn"
96
- @actionButtonClicked="actionButtonClicked"
97
- @actionClicked="actionClicked" />
98
- <SelectPaginationComponent
99
- :meta="pagination.meta"
100
- :links="pagination.links"
101
- @updatePage="updatePage" />
102
- </div>
103
- </div>
104
- </div>
105
- </template>
106
-
107
- <script setup>
108
-
109
- import { markRaw, onMounted, reactive, ref, watch } from 'vue'
110
- import axios from 'axios'
111
-
112
- import NavDropdownComponent from './components/NavDropdownComponent.vue'
113
- import IconRouteComponent from './components/IconRouteComponent.vue'
114
- import IconLinkComponent from './components/IconLinkComponent.vue'
115
- import DisabledLinkComponent from './components/DisabledLinkComponent.vue'
116
- import DataTableComponent from './components/DataTableComponent.vue'
117
- import SelectPaginationComponent from './components/SelectPaginationComponent.vue'
118
-
119
- const props = defineProps({
120
- dataUrl: {
121
- type: String,
122
- required: true
123
- },
124
- dataMethod: {
125
- type: String,
126
- default: 'post',
127
- },
128
- model: {
129
- type: Object,
130
- required: true
131
- },
132
- policyUrl: {
133
- type: String,
134
- required: true
135
- },
136
- policyMethod: {
137
- type: String,
138
- default: 'post'
139
- },
140
- showTopbar:{
141
- type: Boolean,
142
- default: true
143
- },
144
- hasActions: {
145
- type: Boolean,
146
- default: true,
147
- },
148
- hasFilter: {
149
- type: Boolean,
150
- default: true,
151
- },
152
- // Vue 3 exige factoria en los defaults de objeto y array.
153
- formFilters: {
154
- type: Object,
155
- default: () => ({})
156
- },
157
- externalFilters: {
158
- type: Object,
159
- default: () => ({})
160
- },
161
- extraParams: {
162
- type: Object,
163
- default: () => ({})
164
- },
165
- extraQuery: {
166
- type: Object,
167
- default: () => ({})
168
- },
169
- hideColumns: {
170
- type: Array,
171
- default: () => []
172
- },
173
- cardWrapper: {
174
- type: Boolean,
175
- default: true
176
- },
177
- showTableHeader: {
178
- type: Boolean,
179
- default: true,
180
- }
181
- })
182
-
183
- /**
184
- * Se leia de la global `csrf_token`, que la aplicacion anfitriona tenia
185
- * que definir en window: el componente no se podia montar fuera de ella.
186
- */
187
- // Abrir un panel es estado de componente, no una instruccion al DOM. Antes
188
- // lo hacia uk-toggle buscando `.filter-form` por selector, que es como la
189
- // rama Vue y la de React acabaron comportandose distinto.
190
- const filtersOpen = ref(false)
191
-
192
- const csrfToken = () => globalThis.csrf_token
193
- ?? document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
194
- ?? ''
195
-
196
- /**
197
- * Sustituye a `_.isEqual` de lodash, que se usaba como global sin
198
- * declararla como dependencia.
199
- */
200
- const isEqual = (a, b) => {
201
-
202
- if (a === b) {
203
- return true
204
- }
205
-
206
- if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) {
207
- return false
208
- }
209
-
210
- const keysA = Object.keys(a)
211
- const keysB = Object.keys(b)
212
-
213
- return keysA.length === keysB.length && keysA.every((key) => isEqual(a[key], b[key]))
214
-
215
- }
216
-
217
- const hiddenColumnIds = () => props.hideColumns.map(
218
- // Se admite tanto ['name'] como [{ id: 'name' }]: el contrato nunca
219
- // estuvo documentado y por ahi circulan las dos formas.
220
- (column) => typeof column === 'string' ? column : column?.id
221
- )
222
-
223
- const buildHead = () => {
224
-
225
- const hidden = hiddenColumnIds()
226
-
227
- return props.model.dataTableHead().filter((column) => ! hidden.includes(column.id))
228
-
229
- }
230
-
231
- const registerComponents = () => {
232
-
233
- if (typeof props.model.dataTableComponents !== 'function') {
234
- return {}
235
- }
236
-
237
- const declared = props.model.dataTableComponents()
238
-
239
- return Object.keys(declared).reduce((components, key) => {
240
- components[key] = markRaw(declared[key])
241
-
242
- return components
243
- }, {})
244
-
245
- }
246
-
247
- const crudActions = ref(props.model.crudActions())
248
-
249
- const dataTable = reactive({
250
- head: buildHead(),
251
- body: [],
252
- })
253
-
254
- const pagination = reactive({
255
- meta: [],
256
- links: [],
257
- })
258
-
259
- const dataTableComponents = registerComponents()
260
-
261
- const sort = reactive(props.model.dataTableSort())
262
-
263
- const orderBy = ref('id')
264
- const internalSort = ref(false)
265
- const page = ref(1)
266
-
267
- let fetchDataAttempts = 0
268
- let fetchPoliciesAttempts = 0
269
-
270
- const getFilters = () => {
271
-
272
- const params = {
273
- _token: csrfToken(),
274
- managed: true,
275
- except_view_any: true,
276
- }
277
-
278
- const order = {
279
- orderBy: orderBy.value,
280
- orderMode: sort[orderBy.value],
281
- }
282
-
283
- // Con orden interno, el del usuario gana a lo que traigan los filtros
284
- // externos; sin el, es al reves.
285
- return internalSort.value
286
- ? { ...params, ...props.formFilters, ...props.externalFilters, ...order, page: page.value }
287
- : { ...params, ...props.formFilters, ...order, ...props.externalFilters, page: page.value }
288
2
 
289
- }
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>
290
104
 
291
- const fetchData = () => {
292
-
293
- const filters = getFilters()
294
-
295
- return axios({
296
- method: props.dataMethod,
297
- url: props.dataUrl,
298
- data: props.dataMethod === 'post' ? filters : null,
299
- params: props.dataMethod === 'get' ? filters : null,
300
- }).then((res) => {
301
-
302
- fetchDataAttempts = 0
303
- dataTable.body = res.data.data
304
- pagination.meta = res.data.meta
305
- pagination.links = res.data.links
306
-
307
- }).catch((error) => {
308
-
309
- // Un fallo de red no trae respuesta: leer error.response.status
310
- // sin comprobarlo lanzaba un TypeError dentro del propio manejador.
311
- if (error.response?.status === 403) {
312
- return
313
- }
314
-
315
- if (fetchDataAttempts <= 3) {
316
- setTimeout(() => {
317
- ++fetchDataAttempts
318
- fetchData()
319
- }, 1500)
320
- }
321
-
322
- })
323
-
324
- }
325
-
326
- const updateFilters = () => {
327
- props.model.setFilters(getFilters())
328
-
329
- return fetchData()
330
- }
331
-
332
- const sortColumn = (column) => {
333
-
334
- if (column.sortable !== true) {
335
- return
336
- }
337
-
338
- // A partir de aqui el orden elegido por el usuario manda sobre el que
339
- // puedan traer los filtros externos.
340
- internalSort.value = true
341
- orderBy.value = column.id
342
- sort[column.id] = sort[column.id] === 'asc' ? 'desc' : 'asc'
343
-
344
- updateFilters()
345
-
346
- }
347
-
348
- const updatePage = (newPage) => {
349
- page.value = newPage
350
-
351
- updateFilters()
352
- }
353
-
354
- const actionClicked = (action) => {
355
- return props.model[action.callback](action.params)
356
- .then(() => updateFilters())
357
- .catch((error) => console.error(error))
358
- }
359
-
360
- const actionButtonClicked = (actions) => {
361
-
362
- const requestData = {
363
- _token: csrfToken(),
364
- id: actions[0]?.params?.id ?? null,
365
- }
366
-
367
- return axios({
368
- method: props.policyMethod,
369
- url: props.policyUrl,
370
- data: props.policyMethod === 'post' ? requestData : null,
371
- params: props.policyMethod === 'get' ? requestData : null,
372
- }).then((res) => {
373
-
374
- fetchPoliciesAttempts = 0
375
-
376
- actions.forEach((action) => {
377
- if (res.data[action.id]) {
378
- action.policy = true
379
- }
380
- })
381
-
382
- }).catch(() => {
383
-
384
- if (fetchPoliciesAttempts <= 3) {
385
- setTimeout(() => {
386
- ++fetchPoliciesAttempts
387
- actionButtonClicked(actions)
388
- }, 1500)
389
-
390
- return
391
- }
392
-
393
- setTimeout(() => {
394
- fetchPoliciesAttempts = 0
395
- }, 3000)
396
-
397
- })
398
-
399
- }
400
-
401
- // Un popover se cierra solo: no hace falta preguntarle nada a nadie.
402
- const closeDropdown = (event) => {
403
- event.target.closest('[popover]')?.hidePopover()
404
- }
405
-
406
- watch(() => props.formFilters, () => {
407
- page.value = 1
408
- updateFilters()
409
- }, { deep: true })
410
-
411
- watch(() => props.externalFilters, (value, previous) => {
412
- if (! isEqual(value, previous)) {
413
- updateFilters()
414
- }
415
- }, { deep: true })
105
+ </template>
416
106
 
417
- onMounted(fetchData)
107
+ <script setup>
418
108
 
419
- // El test y el anfitrion consultan el estado de las acciones; con
420
- // <script setup> los bindings son privados si no se exponen.
421
- 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 })
422
255
 
423
256
  </script>
424
-
425
- <style scoped>
426
- .tools-icon {
427
- font-size: 30px;
428
- color: var(--secondary-color);
429
- padding: 10px 15px;
430
- }
431
- </style>