innoboxrr-vue-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,181 +1,225 @@
1
1
  <template>
2
- <div class="sm:rounded-lg overflow-x-auto">
3
- <table class="min-w-full w-full text-sm text-left text-slate-500 dark:text-slate-400 p-4">
4
- <thead
5
- v-if="showTableHeader"
6
- class="text-xs text-gray-700 uppercase bg-gray-50 dark:bg-gray-700 dark:text-gray-400 rounded-sm">
7
- <tr>
8
- <th
9
- v-for="head in dataTable.head"
10
- :key="head.id"
11
- :id="`th_${head.id}`"
12
- class="px-6 py-3"
13
- scope="col"
14
- :class="{pointer: head.sortable}"
15
- @click="emit('sortColumn', head)">
16
- {{ head.value }}
17
- </th>
18
- <th
19
- class="fe-shrink"
20
- v-if="actions"></th>
21
- </tr>
22
- </thead>
23
- <tbody>
24
- <tr
25
- v-for="(body, rowIndex) in dataTable.body"
26
- :key="body.id"
27
- class="bg-white border-b dark:bg-gray-800 dark:border-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
28
- <td
29
- v-for="head in dataTable.head"
30
- :key="head.id"
31
- class="px-6 py-4">
32
- <template v-if="head.component">
33
- <component
34
- :is="getComponent(head.component)"
35
- v-bind="setData(head, body, rowIndex)"
36
- @callback="head.callback && typeof head.callback === 'function' ? head.callback($event, body) : null" />
37
- </template>
38
- <span
39
- v-else-if="head.html"
40
- class="dark:text-white"
41
- v-html="setData(head, body, rowIndex)"></span>
42
- <span
43
- v-else
44
- class="dark:text-white">{{ setData(head, body, rowIndex) }}</span>
45
- </td>
46
- <td v-if="actions" class="fe-text-right">
47
- <button
48
- class="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"
49
- :popovertarget="`dropdown_${body.id}`"
50
- :aria-label="'Actions'"
51
- @click="emit('actionButtonClicked', body.actions)">
52
- <Icon :icon="iconFor('actions')" aria-hidden="true" />
53
- </button>
54
- <NavDropdownComponent :id="`dropdown_${body.id}`" pos="left">
55
- <li
56
- v-for="action in body.actions"
57
- :key="action.name"
58
- class="hover:bg-slate-100 dark:hover:bg-slate-600 px-2 py-1">
59
- <template v-if="action.route && !action?.link">
60
- <IconRouteComponent
61
- v-if="action.policy"
62
- :name="action.params.to.name"
63
- :params="{...action.params.to.params, ...extraParams}"
64
- :query="action.params.to.query ? {...action.params.to.query, ...extraQuery} : {...extraQuery}"
65
- :icon="action.icon"
66
- :text="action.name" />
67
- <DisabledLinkComponent
68
- v-else
69
- :icon="action.icon"
70
- :text="action.name"/>
71
- </template>
72
- <template v-else-if="action.route && action.link">
73
- <IconLinkComponent
74
- v-if="action.policy"
75
- :link="action.params.link"
76
- :target="action.params.target"
77
- :icon="action.icon"
78
- :text="action.name" />
79
- </template>
80
- <template v-else>
81
- <IconLinkComponent
82
- v-if="action.policy"
83
- :icon="action.icon"
84
- :text="action.name"
85
- @click.prevent="emit('actionClicked', action), closeDropdown($event)" />
86
- <DisabledLinkComponent
87
- v-else
88
- :icon="action.icon"
89
- :text="action.name"/>
90
- </template>
91
- </li>
92
- </NavDropdownComponent>
93
- </td>
94
- </tr>
95
- </tbody>
96
- </table>
97
- </div>
2
+
3
+ <div :class="theme.tableContainer">
4
+
5
+ <table :class="[theme.table, theme.tableSticky]" :aria-busy="loading ? 'true' : 'false'">
6
+
7
+ <thead v-if="showTableHeader">
8
+ <tr>
9
+ <th v-if="selectable" scope="col" :class="theme.tableSelect">
10
+ <input
11
+ type="checkbox"
12
+ :class="theme.checkbox"
13
+ :aria-label="labels.selectAll"
14
+ :checked="allSelected"
15
+ :indeterminate="someSelected"
16
+ :disabled="rows.length === 0"
17
+ @change="table.toggleAllPageRowsSelected($event.target.checked)">
18
+ </th>
19
+
20
+ <th
21
+ v-for="column in head"
22
+ :id="`th_${column.id}`"
23
+ :key="column.id"
24
+ scope="col"
25
+ :class="column.numeric ? theme.tableNumeric : null"
26
+ :aria-sort="ariaSort(column, orderBy, sort)">
27
+ <button
28
+ v-if="column.sortable === true"
29
+ type="button"
30
+ :class="theme.tableSort"
31
+ @click="emit('sortColumn', column)">
32
+ <span>{{ column.value }}</span>
33
+ <IconComponent :name="sortIcon(column, orderBy, sort)" :size="12" />
34
+ </button>
35
+ <template v-else>{{ column.value }}</template>
36
+ </th>
37
+
38
+ <th v-if="actions" scope="col" :class="theme.tableSelect" :aria-label="labels.actions" />
39
+ </tr>
40
+ </thead>
41
+
42
+ <tbody>
43
+
44
+ <!-- Primera carga: la forma de las filas, mientras llegan. -->
45
+ <template v-if="loading && rows.length === 0">
46
+ <tr v-for="n in SKELETON_ROWS" :key="`skeleton-${n}`" data-skeleton="true">
47
+ <td v-if="selectable" :class="theme.tableSelect" />
48
+ <td v-for="column in head" :key="column.id">
49
+ <SkeletonComponent />
50
+ </td>
51
+ <td v-if="actions" :class="theme.tableSelect" />
52
+ </tr>
53
+ </template>
54
+
55
+ <!-- Sin filas se dice por qué: no es lo mismo vacío que prohibido. -->
56
+ <tr v-else-if="rows.length === 0">
57
+ <td :colspan="colspan" :class="theme.tableEmpty">
58
+ <template v-if="error">
59
+ <span role="alert">{{ error.message }}</span>
60
+ <button
61
+ v-if="error.retryable"
62
+ type="button"
63
+ :class="theme.buttonLink"
64
+ @click="emit('retry')">{{ labels.retry }}</button>
65
+ </template>
66
+ <template v-else>{{ labels.empty }}</template>
67
+ </td>
68
+ </tr>
69
+
70
+ <template v-else>
71
+ <tr
72
+ v-for="row in table.getRowModel().rows"
73
+ :key="row.id"
74
+ :data-selected="row.getIsSelected() ? 'true' : undefined">
75
+
76
+ <td v-if="selectable" :class="theme.tableSelect">
77
+ <input
78
+ type="checkbox"
79
+ :class="theme.checkbox"
80
+ :aria-label="`${labels.selectRow} ${row.original.id}`"
81
+ :checked="row.getIsSelected()"
82
+ @click="row.getToggleSelectedHandler()($event)">
83
+ </td>
84
+
85
+ <td
86
+ v-for="cell in row.getVisibleCells()"
87
+ :key="cell.id"
88
+ :class="headOf(cell).numeric ? theme.tableNumeric : null">
89
+ <component
90
+ :is="componentFor(cell)"
91
+ v-if="componentFor(cell)"
92
+ v-bind="componentProps(valueFor(cell, row))"
93
+ @callback="onCallback(cell, row, $event)" />
94
+ <span v-else-if="headOf(cell).html" v-html="valueFor(cell, row)" />
95
+ <template v-else>{{ valueFor(cell, row) }}</template>
96
+ </td>
97
+
98
+ <td v-if="actions" :class="theme.tableSelect">
99
+ <MenuComponent
100
+ :items="itemsFor(row.original)"
101
+ :label="`${labels.rowActions} ${row.original.id}`"
102
+ :before-open="() => prepareRow(row.original.id)" />
103
+ </td>
104
+
105
+ </tr>
106
+ </template>
107
+
108
+ </tbody>
109
+
110
+ </table>
111
+
112
+ </div>
113
+
98
114
  </template>
99
115
 
100
116
  <script setup>
101
117
 
102
- import { computed } from 'vue'
103
- import { Icon } from '@iconify/vue'
104
- import { iconFor } from 'innoboxrr-form-core'
105
-
106
- import NavDropdownComponent from './NavDropdownComponent.vue'
107
- import IconRouteComponent from './IconRouteComponent.vue'
108
- import IconLinkComponent from './IconLinkComponent.vue'
109
- import DisabledLinkComponent from './DisabledLinkComponent.vue'
110
-
111
- const props = defineProps({
112
- actions: {
113
- type: Boolean,
114
- default: false
115
- },
116
- dataTable: {
117
- type: [Object, Boolean],
118
- required: true
119
- },
120
- // Vue 3 exige factoria en los defaults de objeto.
121
- extraParams: {
122
- type: Object,
123
- default: () => ({})
124
- },
125
- extraQuery: {
126
- type: Object,
127
- default: () => ({})
128
- },
129
- showTableHeader: {
130
- type: Boolean,
131
- default: true,
132
- },
133
- dataTableComponents: {
134
- type: Object,
135
- default: () => ({})
136
- }
137
- })
138
-
139
- const emit = defineEmits(['sortColumn', 'actionButtonClicked', 'actionClicked'])
140
-
141
- const getComponent = (componentName) => props.dataTableComponents[componentName] || null
142
-
143
- /**
144
- * Copia aislada de cada fila, para que un parser del modelo no pueda
145
- * mutar los datos de la tabla.
146
- *
147
- * Se clonaba con JSON dentro de setData(), es decir una vez por celda: con
148
- * 20 filas y 8 columnas eran 160 clonados en cada repintado. Un clon por
149
- * fila y repintado deja lo mismo en 20.
150
- *
151
- * La cache que hacia eso era un WeakMap de modulo, y eso traia dos
152
- * problemas propios: la compartian todas las tablas de la pagina, y un
153
- * parser que escribiera en su copia la envenenaba para el resto de la vida
154
- * de la aplicacion. Una computed sobre el cuerpo da el mismo ahorro sin
155
- * ninguna de las dos cosas.
156
- */
157
- const rows = computed(() => (props.dataTable.body ?? []).map(
158
- (row) => JSON.parse(JSON.stringify(row))
159
- ))
160
-
161
- const setData = (head, body, index) => {
162
-
163
- const data = rows.value[index] ?? body
164
-
165
- return typeof head.parser === 'function' ? head.parser(data[head.id], data) : data[head.id]
166
-
167
- }
168
-
169
- // Un popover se cierra solo: no hace falta preguntarle nada a nadie.
170
- const closeDropdown = (event) => {
171
- event.target.closest('[popover]')?.hidePopover()
172
- }
118
+ /**
119
+ * La tabla propiamente dicha, sobre una instancia de TanStack Table.
120
+ *
121
+ * El menú de cada fila espera a conocer los permisos antes de abrirse.
122
+ * Antes se abría al instante con todo deshabilitado y se habilitaba cuando
123
+ * llegaba la respuesta —si llegaba: un fallo se reintentaba en silencio—,
124
+ * y el usuario veía parpadear lo que no podía hacer.
125
+ */
173
126
 
174
- </script>
127
+ import { computed } from 'vue'
128
+ import IconComponent from 'innoboxrr-form-elements/src/IconComponent.vue'
129
+ import MenuComponent from 'innoboxrr-form-elements/src/MenuComponent.vue'
130
+ import SkeletonComponent from 'innoboxrr-form-elements/src/SkeletonComponent.vue'
131
+
132
+ import useTheme from '../useTheme.js'
133
+ import { DEFAULT_LABELS, ariaSort, cellValue, componentProps, sortIcon } from '../table.js'
134
+
135
+ const SKELETON_ROWS = 5
136
+
137
+ const props = defineProps({
138
+ table: {
139
+ type: Object,
140
+ required: true,
141
+ },
142
+ head: {
143
+ type: Array,
144
+ default: () => [],
145
+ },
146
+ rows: {
147
+ type: Array,
148
+ default: () => [],
149
+ },
150
+ clones: {
151
+ type: Array,
152
+ default: () => [],
153
+ },
154
+ loading: {
155
+ type: Boolean,
156
+ default: false,
157
+ },
158
+ error: {
159
+ type: Object,
160
+ default: null,
161
+ },
162
+ actions: {
163
+ type: Boolean,
164
+ default: false,
165
+ },
166
+ selectable: {
167
+ type: Boolean,
168
+ default: false,
169
+ },
170
+ showTableHeader: {
171
+ type: Boolean,
172
+ default: true,
173
+ },
174
+ dataTableComponents: {
175
+ type: Object,
176
+ default: () => ({}),
177
+ },
178
+ labels: {
179
+ type: Object,
180
+ default: () => DEFAULT_LABELS,
181
+ },
182
+ orderBy: {
183
+ type: String,
184
+ default: null,
185
+ },
186
+ sort: {
187
+ type: Object,
188
+ default: () => ({}),
189
+ },
190
+ itemsFor: {
191
+ type: Function,
192
+ default: () => [],
193
+ },
194
+ prepareRow: {
195
+ type: Function,
196
+ default: async () => {},
197
+ },
198
+ })
199
+
200
+ const emit = defineEmits(['sortColumn', 'retry'])
201
+
202
+ const theme = useTheme()
203
+
204
+ const colspan = computed(() => props.head.length + (props.selectable ? 1 : 0) + (props.actions ? 1 : 0))
175
205
 
176
- <style>
177
- .fe-table td {
178
- padding: 12px 14px;
179
- vertical-align: middle;
180
- }
181
- </style>
206
+ const allSelected = computed(() => props.rows.length > 0 && props.table.getIsAllPageRowsSelected())
207
+ const someSelected = computed(() => props.table.getIsSomePageRowsSelected() && ! allSelected.value)
208
+
209
+ const headOf = (cell) => cell.column.columnDef.meta?.head ?? {}
210
+
211
+ const componentFor = (cell) => {
212
+ const name = headOf(cell).component
213
+
214
+ return name ? (props.dataTableComponents[name] ?? null) : null
215
+ }
216
+
217
+ const valueFor = (cell, row) => cellValue(headOf(cell), props.clones[row.index] ?? row.original)
218
+
219
+ const onCallback = (cell, row, payload) => {
220
+ const callback = headOf(cell).callback
221
+
222
+ return typeof callback === 'function' ? callback(payload, row.original) : null
223
+ }
224
+
225
+ </script>
@@ -1,110 +1,95 @@
1
1
  <template>
2
2
 
3
- <div class="pagination" fe-grid>
3
+ <div :class="theme.tableFooter">
4
4
 
5
- <div class="fe-w-auto">
5
+ <span>{{ summaryText }}</span>
6
6
 
7
- <ul class="fe-pagination fe-justify-start fe-mt-md" fe-mb>
7
+ <div v-if="last > 1" :class="theme.tablePager">
8
8
 
9
- <li>
9
+ <button
10
+ type="button"
11
+ :class="theme.iconButton"
12
+ :aria-label="text.previous"
13
+ :disabled="current <= 1"
14
+ @click="go(current - 1)">
15
+ <IconComponent name="previous" :size="14" />
16
+ </button>
10
17
 
11
- <span v-if="meta.total > 0">
18
+ <select
19
+ :class="theme.select"
20
+ :aria-label="text.page"
21
+ :value="current"
22
+ @change="go($event.target.value)">
23
+ <option v-for="page in last" :key="page" :value="page">{{ page }}</option>
24
+ </select>
12
25
 
13
- {{ 'Showing' }} {{ meta.from }} {{ 'to' }} {{ meta.to }} {{ 'of' }} {{ meta.total }} {{ 'entries' }}
26
+ <span>{{ text.of }} {{ last }}</span>
14
27
 
15
- </span>
28
+ <button
29
+ type="button"
30
+ :class="theme.iconButton"
31
+ :aria-label="text.next"
32
+ :disabled="current >= last"
33
+ @click="go(current + 1)">
34
+ <IconComponent name="next" :size="14" />
35
+ </button>
16
36
 
17
- <span v-else>{{ ('No results found') }}</span>
37
+ </div>
18
38
 
19
- </li>
20
-
21
- </ul>
22
-
23
- </div>
24
-
25
- <div class="fe-w-expand">
26
-
27
- <ul class="fe-pagination fe-justify-end fe-mt-md" fe-mb>
28
-
29
- <!-- Prev -->
30
- <li v-if="currentPage > 1">
31
-
32
- <a href="#" @click="prevPage()">
33
-
34
- <span fe-page-prev></span>
35
-
36
- </a>
37
-
38
- </li>
39
-
40
- <li>
41
- <select
42
- class="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"
43
- v-model="currentPage">
44
-
45
- <option
46
- v-for="page in meta.last_page"
47
- :key="'page_' + page"
48
- :value="page">{{ page }}</option>
49
-
50
- </select>
51
- </li>
52
-
53
- <!-- Next -->
54
- <li v-if="currentPage < meta.last_page">
55
-
56
- <a href="#" @click="nextPage()">
57
-
58
- <span fe-page-next></span>
59
-
60
- </a>
61
-
62
- </li>
63
-
64
- </ul>
65
-
66
- </div>
67
-
68
- </div>
39
+ </div>
69
40
 
70
41
  </template>
71
42
 
72
43
  <script setup>
73
44
 
74
- import { ref, watch } from 'vue'
75
-
76
- const props = defineProps({
77
-
78
- meta: {
79
- type: Object,
80
- required: true
81
- },
82
-
83
- links: {
84
- type: Object,
85
- required: true
86
- }
87
-
88
- })
89
-
90
- const emit = defineEmits(['updatePage'])
91
-
92
- const currentPage = ref(props.meta.current_page ?? 1)
93
-
94
- // El select nacia siempre en 1 y no se enteraba de los cambios de pagina
95
- // hechos desde fuera (por ejemplo al reiniciar los filtros).
96
- watch(() => props.meta.current_page, (page) => {
97
-
98
- if (page != null && page !== currentPage.value) {
99
- currentPage.value = page
100
- }
101
-
102
- })
103
-
104
- watch(currentPage, (page) => emit('updatePage', page))
105
-
106
- const prevPage = () => currentPage.value--
107
-
108
- const nextPage = () => currentPage.value++
45
+ /**
46
+ * El pie de la tabla: cuántos registros se ven y en qué página se está.
47
+ *
48
+ * No guarda la página. Antes tenía su propia copia: al reiniciar los
49
+ * filtros el padre volvía a la 1, el paginador se enteraba después y la
50
+ * devolvía, y eso era una segunda petición idéntica.
51
+ */
52
+
53
+ import { computed } from 'vue'
54
+ import IconComponent from 'innoboxrr-form-elements/src/IconComponent.vue'
55
+
56
+ import useTheme from '../useTheme.js'
57
+ import { DEFAULT_LABELS, summary } from '../table.js'
58
+
59
+ const props = defineProps({
60
+ meta: {
61
+ type: Object,
62
+ default: () => ({}),
63
+ },
64
+ // Se sigue aceptando para no romper a quien lo pasa; la página sale de
65
+ // `meta`.
66
+ links: {
67
+ type: [Array, Object],
68
+ default: () => [],
69
+ },
70
+ labels: {
71
+ type: Object,
72
+ default: () => ({}),
73
+ },
74
+ })
75
+
76
+ const emit = defineEmits(['updatePage'])
77
+
78
+ const theme = useTheme()
79
+
80
+ const text = computed(() => ({ ...DEFAULT_LABELS, ...props.labels }))
81
+
82
+ const current = computed(() => Number(props.meta?.current_page ?? 1))
83
+ const last = computed(() => Number(props.meta?.last_page ?? 1))
84
+
85
+ const summaryText = computed(() => summary(props.meta ?? {}, text.value))
86
+
87
+ const go = (value) => {
88
+ const page = Number(value)
89
+
90
+ if (page >= 1 && page <= last.value && page !== current.value) {
91
+ emit('updatePage', page)
92
+ }
93
+ }
109
94
 
110
95
  </script>