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.
@@ -1,183 +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
- @click="emit('actionButtonClicked', body.actions)">
50
- <i class="fas fa-cogs"></i>
51
- </button>
52
- <NavDropdownComponent :id="`dropdown_${body.id}`" pos="left">
53
- <li
54
- v-for="action in body.actions"
55
- :key="action.name"
56
- class="hover:bg-slate-100 dark:hover:bg-slate-600 px-2 py-1">
57
- <template v-if="action.route && !action?.link">
58
- <IconRouteComponent
59
- v-if="action.policy"
60
- :name="action.params.to.name"
61
- :params="{...action.params.to.params, ...extraParams}"
62
- :query="action.params.to.query ? {...action.params.to.query, ...extraQuery} : {...extraQuery}"
63
- :icon="action.icon"
64
- :text="action.name" />
65
- <DisabledLinkComponent
66
- v-else
67
- :icon="action.icon"
68
- :text="action.name"/>
69
- </template>
70
- <template v-else-if="action.route && action.link">
71
- <IconLinkComponent
72
- v-if="action.policy"
73
- :link="action.params.link"
74
- :target="action.params.target"
75
- :icon="action.icon"
76
- :text="action.name" />
77
- </template>
78
- <template v-else>
79
- <IconLinkComponent
80
- v-if="action.policy"
81
- :icon="action.icon"
82
- :text="action.name"
83
- @click.prevent="emit('actionClicked', action), closeDropdown($event)" />
84
- <DisabledLinkComponent
85
- v-else
86
- :icon="action.icon"
87
- :text="action.name"/>
88
- </template>
89
- </li>
90
- </NavDropdownComponent>
91
- </td>
92
- </tr>
93
- </tbody>
94
- </table>
95
- </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
+
96
114
  </template>
97
115
 
98
116
  <script setup>
99
117
 
100
- import { computed } from 'vue'
101
-
102
- import NavDropdownComponent from './NavDropdownComponent.vue'
103
- import IconRouteComponent from './IconRouteComponent.vue'
104
- import IconLinkComponent from './IconLinkComponent.vue'
105
- import DisabledLinkComponent from './DisabledLinkComponent.vue'
106
-
107
- const props = defineProps({
108
- actions: {
109
- type: Boolean,
110
- default: false
111
- },
112
- dataTable: {
113
- type: [Object, Boolean],
114
- required: true
115
- },
116
- // Vue 3 exige factoria en los defaults de objeto.
117
- extraParams: {
118
- type: Object,
119
- default: () => ({})
120
- },
121
- extraQuery: {
122
- type: Object,
123
- default: () => ({})
124
- },
125
- showTableHeader: {
126
- type: Boolean,
127
- default: true,
128
- },
129
- dataTableComponents: {
130
- type: Object,
131
- default: () => ({})
132
- }
133
- })
134
-
135
- const emit = defineEmits(['sortColumn', 'actionButtonClicked', 'actionClicked'])
136
-
137
- const getComponent = (componentName) => props.dataTableComponents[componentName] || null
138
-
139
- /**
140
- * Copia aislada de cada fila, para que un parser del modelo no pueda
141
- * mutar los datos de la tabla.
142
- *
143
- * Se clonaba con JSON dentro de setData(), es decir una vez por celda: con
144
- * 20 filas y 8 columnas eran 160 clonados en cada repintado. Un clon por
145
- * fila y repintado deja lo mismo en 20.
146
- *
147
- * La cache que hacia eso era un WeakMap de modulo, y eso traia dos
148
- * problemas propios: la compartian todas las tablas de la pagina, y un
149
- * parser que escribiera en su copia la envenenaba para el resto de la vida
150
- * de la aplicacion. Una computed sobre el cuerpo da el mismo ahorro sin
151
- * ninguna de las dos cosas.
152
- */
153
- const rows = computed(() => (props.dataTable.body ?? []).map(
154
- (row) => JSON.parse(JSON.stringify(row))
155
- ))
156
-
157
- const setData = (head, body, index) => {
158
-
159
- const data = rows.value[index] ?? body
160
-
161
- return typeof head.parser === 'function' ? head.parser(data[head.id], data) : data[head.id]
162
-
163
- }
164
-
165
- const closeDropdown = (event) => {
166
-
167
- const dropdown = event.target.closest('.uk-dropdown')
168
-
169
- // UIkit lo aporta la aplicacion anfitriona.
170
- if (dropdown) {
171
- globalThis.UIkit?.dropdown(dropdown)?.hide(false)
172
- }
173
-
174
- }
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
+ */
126
+
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))
205
+
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
+ }
175
224
 
176
225
  </script>
177
-
178
- <style>
179
- .fe-table td {
180
- padding: 12px 14px;
181
- vertical-align: middle;
182
- }
183
- </style>
@@ -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>