@veristone/nuxt-v-app 0.2.2 → 0.2.4
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/app/components/V/A/Crud/Delete.vue +1 -0
- package/app/components/V/A/CrudTable/index.vue +486 -0
- package/app/components/V/A/Table/ActionColumn.vue +133 -0
- package/app/components/V/A/Table/Actions.vue +79 -0
- package/app/components/V/A/Table/CellRenderer.vue +198 -0
- package/app/components/V/A/Table/ColumnToggle.vue +131 -0
- package/app/components/V/A/Table/EditableCell.vue +176 -0
- package/app/components/V/A/Table/Export.vue +154 -0
- package/app/components/V/A/Table/FilterBar.vue +140 -0
- package/app/components/V/A/Table/FilterChips.vue +107 -0
- package/app/components/V/A/Table/README.md +380 -0
- package/app/components/V/A/Table/Toolbar.vue +163 -0
- package/app/components/V/A/Table/index.vue +483 -0
- package/app/composables/useDataTable.js +169 -0
- package/app/composables/useXATableColumns.ts +279 -386
- package/app/pages/playground/tables.vue +182 -553
- package/app/types/table.ts +52 -0
- package/package.json +4 -2
- package/app/components/V/A/Table.vue +0 -674
|
@@ -0,0 +1,486 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<section class="space-y-4">
|
|
3
|
+
<!-- Toolbar -->
|
|
4
|
+
<VATableToolbar
|
|
5
|
+
:search="searchValue"
|
|
6
|
+
@update:search="val => updateSearch(val)"
|
|
7
|
+
v-model:visible-columns="visibleColumns"
|
|
8
|
+
:searchable="config.toolbar?.search ?? true"
|
|
9
|
+
:search-placeholder="config.toolbar?.searchPlaceholder ?? 'Search...'"
|
|
10
|
+
:show-column-toggle="config.toolbar?.columns ?? true"
|
|
11
|
+
:columns="columns"
|
|
12
|
+
:exportable="config.toolbar?.export ?? true"
|
|
13
|
+
:data="tableData"
|
|
14
|
+
:export-filename="config.toolbar?.exportFilename ?? 'export'"
|
|
15
|
+
>
|
|
16
|
+
<template #left>
|
|
17
|
+
<slot name="toolbar-left" />
|
|
18
|
+
</template>
|
|
19
|
+
<template #right>
|
|
20
|
+
<slot name="toolbar-right" />
|
|
21
|
+
</template>
|
|
22
|
+
<template #actions>
|
|
23
|
+
<UButton
|
|
24
|
+
v-if="config.actions?.create ?? true"
|
|
25
|
+
icon="i-lucide-plus"
|
|
26
|
+
:label="config.actions?.createLabel ?? 'Create'"
|
|
27
|
+
@click="$emit('create')"
|
|
28
|
+
/>
|
|
29
|
+
<slot name="toolbar-actions" />
|
|
30
|
+
</template>
|
|
31
|
+
</VATableToolbar>
|
|
32
|
+
|
|
33
|
+
<!-- Table -->
|
|
34
|
+
<div class="overflow-x-auto rounded-lg border border-neutral-200 dark:border-neutral-800">
|
|
35
|
+
<!-- Loading -->
|
|
36
|
+
<div v-if="isLoading" class="p-8">
|
|
37
|
+
<VAStateLoading :rows="5" />
|
|
38
|
+
</div>
|
|
39
|
+
|
|
40
|
+
<!-- Empty -->
|
|
41
|
+
<VAStateEmpty
|
|
42
|
+
v-else-if="!tableData.length"
|
|
43
|
+
:title="emptyState.title"
|
|
44
|
+
:description="emptyState.description"
|
|
45
|
+
:icon="emptyState.icon"
|
|
46
|
+
>
|
|
47
|
+
<template v-if="$slots.empty" #action>
|
|
48
|
+
<slot name="empty" />
|
|
49
|
+
</template>
|
|
50
|
+
</VAStateEmpty>
|
|
51
|
+
|
|
52
|
+
<!-- Table -->
|
|
53
|
+
<UTable
|
|
54
|
+
v-else
|
|
55
|
+
:data="tableData"
|
|
56
|
+
:columns="visibleTableColumns"
|
|
57
|
+
class="w-full"
|
|
58
|
+
@select="handleRowClick"
|
|
59
|
+
>
|
|
60
|
+
<!-- Selection column -->
|
|
61
|
+
<template v-if="config.selection?.enabled" #select-header>
|
|
62
|
+
<UCheckbox
|
|
63
|
+
:model-value="allSelected"
|
|
64
|
+
:indeterminate="someSelected"
|
|
65
|
+
@update:model-value="toggleAll"
|
|
66
|
+
/>
|
|
67
|
+
</template>
|
|
68
|
+
<template v-if="config.selection?.enabled" #select="{ row }">
|
|
69
|
+
<UCheckbox
|
|
70
|
+
:model-value="isSelected(row)"
|
|
71
|
+
@update:model-value="toggleSelect(row)"
|
|
72
|
+
/>
|
|
73
|
+
</template>
|
|
74
|
+
|
|
75
|
+
<!-- Actions column -->
|
|
76
|
+
<template v-if="showActions" #actions-cell="{ row }">
|
|
77
|
+
<slot name="row-actions" :row="row?.original">
|
|
78
|
+
<div class="flex items-center justify-end gap-1">
|
|
79
|
+
<UButton
|
|
80
|
+
v-if="config.actions?.view"
|
|
81
|
+
icon="i-lucide-eye"
|
|
82
|
+
color="neutral"
|
|
83
|
+
variant="ghost"
|
|
84
|
+
size="xs"
|
|
85
|
+
@click.stop="handleView(row?.original)"
|
|
86
|
+
/>
|
|
87
|
+
<UButton
|
|
88
|
+
v-if="config.actions?.edit"
|
|
89
|
+
icon="i-lucide-pencil"
|
|
90
|
+
color="neutral"
|
|
91
|
+
variant="ghost"
|
|
92
|
+
size="xs"
|
|
93
|
+
@click.stop="handleEdit(row?.original)"
|
|
94
|
+
/>
|
|
95
|
+
<UButton
|
|
96
|
+
v-if="config.actions?.delete"
|
|
97
|
+
icon="i-lucide-trash-2"
|
|
98
|
+
color="error"
|
|
99
|
+
variant="ghost"
|
|
100
|
+
size="xs"
|
|
101
|
+
@click.stop="handleDelete(row?.original)"
|
|
102
|
+
/>
|
|
103
|
+
<slot name="extra-actions" :row="row?.original" />
|
|
104
|
+
</div>
|
|
105
|
+
</slot>
|
|
106
|
+
</template>
|
|
107
|
+
|
|
108
|
+
<!-- Forward custom slots -->
|
|
109
|
+
<template v-for="(_, slotName) in customSlots" :key="slotName" #[slotName]="slotProps">
|
|
110
|
+
<slot :name="slotName" v-bind="slotProps" />
|
|
111
|
+
</template>
|
|
112
|
+
</UTable>
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
<!-- Pagination -->
|
|
116
|
+
<footer v-if="config.pagination?.enabled && totalPages > 1" class="flex items-center justify-between">
|
|
117
|
+
<p class="text-sm text-neutral-500">
|
|
118
|
+
Showing {{ startIndex + 1 }} to {{ endIndex }} of {{ totalCount }} results
|
|
119
|
+
</p>
|
|
120
|
+
<UPagination
|
|
121
|
+
v-model="currentPage"
|
|
122
|
+
:total="totalCount"
|
|
123
|
+
:page-count="config.pagination?.pageSize ?? 20"
|
|
124
|
+
/>
|
|
125
|
+
</footer>
|
|
126
|
+
|
|
127
|
+
<!-- Delete confirmation modal -->
|
|
128
|
+
<VAModalConfirm
|
|
129
|
+
v-model:open="showDeleteModal"
|
|
130
|
+
title="Delete Item"
|
|
131
|
+
:message="deleteMessage"
|
|
132
|
+
confirm-text="Delete"
|
|
133
|
+
color="error"
|
|
134
|
+
:loading="isDeleting"
|
|
135
|
+
@confirm="confirmDeleteItem"
|
|
136
|
+
/>
|
|
137
|
+
</section>
|
|
138
|
+
</template>
|
|
139
|
+
|
|
140
|
+
<script setup>
|
|
141
|
+
import { h, resolveComponent } from 'vue'
|
|
142
|
+
|
|
143
|
+
defineOptions({
|
|
144
|
+
name: 'VACrudTable'
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
const props = defineProps({
|
|
148
|
+
// Core
|
|
149
|
+
endpoint: {
|
|
150
|
+
type: String,
|
|
151
|
+
default: null
|
|
152
|
+
},
|
|
153
|
+
columns: {
|
|
154
|
+
type: Array,
|
|
155
|
+
required: true
|
|
156
|
+
},
|
|
157
|
+
rowKey: {
|
|
158
|
+
type: String,
|
|
159
|
+
default: 'id'
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
// Optional: Inject existing crud instance
|
|
163
|
+
crud: {
|
|
164
|
+
type: Object,
|
|
165
|
+
default: null
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
// Configuration (Grouped)
|
|
169
|
+
config: {
|
|
170
|
+
type: Object,
|
|
171
|
+
default: () => ({
|
|
172
|
+
toolbar: {
|
|
173
|
+
search: true,
|
|
174
|
+
searchPlaceholder: 'Search...',
|
|
175
|
+
columns: true,
|
|
176
|
+
export: true,
|
|
177
|
+
exportFilename: 'export'
|
|
178
|
+
},
|
|
179
|
+
actions: {
|
|
180
|
+
create: true,
|
|
181
|
+
createLabel: 'Create',
|
|
182
|
+
view: true,
|
|
183
|
+
edit: true,
|
|
184
|
+
delete: true,
|
|
185
|
+
viewRoute: null,
|
|
186
|
+
editRoute: null
|
|
187
|
+
},
|
|
188
|
+
selection: {
|
|
189
|
+
enabled: false
|
|
190
|
+
},
|
|
191
|
+
pagination: {
|
|
192
|
+
enabled: true,
|
|
193
|
+
pageSize: 20
|
|
194
|
+
}
|
|
195
|
+
})
|
|
196
|
+
},
|
|
197
|
+
|
|
198
|
+
// Empty State
|
|
199
|
+
emptyState: {
|
|
200
|
+
type: Object,
|
|
201
|
+
default: () => ({
|
|
202
|
+
title: 'No data',
|
|
203
|
+
description: '',
|
|
204
|
+
icon: 'i-lucide-inbox'
|
|
205
|
+
})
|
|
206
|
+
},
|
|
207
|
+
|
|
208
|
+
// Initial filters for internal useXCrud
|
|
209
|
+
initialFilters: {
|
|
210
|
+
type: Object,
|
|
211
|
+
default: () => ({})
|
|
212
|
+
}
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
const emit = defineEmits(['create', 'view', 'edit', 'delete', 'select', 'row-click'])
|
|
216
|
+
|
|
217
|
+
const slots = useSlots()
|
|
218
|
+
const router = useRouter()
|
|
219
|
+
|
|
220
|
+
// Merge user config with defaults
|
|
221
|
+
const defaultConfig = {
|
|
222
|
+
toolbar: {
|
|
223
|
+
search: true,
|
|
224
|
+
searchPlaceholder: 'Search...',
|
|
225
|
+
columns: true,
|
|
226
|
+
export: true,
|
|
227
|
+
exportFilename: 'export'
|
|
228
|
+
},
|
|
229
|
+
actions: {
|
|
230
|
+
create: true,
|
|
231
|
+
createLabel: 'Create',
|
|
232
|
+
view: true,
|
|
233
|
+
edit: true,
|
|
234
|
+
delete: true,
|
|
235
|
+
viewRoute: null,
|
|
236
|
+
editRoute: null
|
|
237
|
+
},
|
|
238
|
+
selection: {
|
|
239
|
+
enabled: false
|
|
240
|
+
},
|
|
241
|
+
pagination: {
|
|
242
|
+
enabled: true,
|
|
243
|
+
pageSize: 20
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const config = computed(() => ({
|
|
248
|
+
toolbar: { ...defaultConfig.toolbar, ...props.config?.toolbar },
|
|
249
|
+
actions: { ...defaultConfig.actions, ...props.config?.actions },
|
|
250
|
+
selection: { ...defaultConfig.selection, ...props.config?.selection },
|
|
251
|
+
pagination: { ...defaultConfig.pagination, ...props.config?.pagination }
|
|
252
|
+
}))
|
|
253
|
+
|
|
254
|
+
// Initialize useXCrud
|
|
255
|
+
let crudInstance
|
|
256
|
+
try {
|
|
257
|
+
if (props.crud) {
|
|
258
|
+
crudInstance = props.crud
|
|
259
|
+
} else if (props.endpoint) {
|
|
260
|
+
// Use explicit .all() factory method
|
|
261
|
+
crudInstance = useXCrud(props.endpoint, {
|
|
262
|
+
initialFilters: props.initialFilters
|
|
263
|
+
}).all()
|
|
264
|
+
}
|
|
265
|
+
} catch (e) {
|
|
266
|
+
console.error('VACrudTable: Failed to initialize useXCrud', e)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// Fallback empty crud instance
|
|
270
|
+
if (!crudInstance) {
|
|
271
|
+
crudInstance = {
|
|
272
|
+
data: ref([]),
|
|
273
|
+
loading: ref(false),
|
|
274
|
+
error: ref(null),
|
|
275
|
+
total: ref(0),
|
|
276
|
+
search: ref(''),
|
|
277
|
+
filters: ref({}),
|
|
278
|
+
deleting: ref(false),
|
|
279
|
+
refresh: async () => {},
|
|
280
|
+
remove: async () => false
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Expose crud for parent
|
|
285
|
+
defineExpose({ crud: crudInstance })
|
|
286
|
+
|
|
287
|
+
// Accessors
|
|
288
|
+
const searchValue = computed(() => crudInstance?.search?.value ?? crudInstance?.search ?? '')
|
|
289
|
+
const isLoading = computed(() => crudInstance?.loading?.value ?? crudInstance?.loading ?? false)
|
|
290
|
+
const isDeleting = computed(() => crudInstance?.deleting?.value ?? crudInstance?.deleting ?? false)
|
|
291
|
+
const totalCount = computed(() => crudInstance?.total?.value ?? crudInstance?.total ?? 0)
|
|
292
|
+
|
|
293
|
+
function updateSearch(val) {
|
|
294
|
+
if (crudInstance?.search !== undefined) {
|
|
295
|
+
if (typeof crudInstance.search === 'object' && 'value' in crudInstance.search) {
|
|
296
|
+
crudInstance.search.value = val
|
|
297
|
+
} else {
|
|
298
|
+
crudInstance.search = val
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Get table data
|
|
304
|
+
const tableData = computed(() => {
|
|
305
|
+
const d = crudInstance?.data?.value ?? crudInstance?.data
|
|
306
|
+
return Array.isArray(d) ? d : []
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
// Column visibility
|
|
310
|
+
const visibleColumns = ref([])
|
|
311
|
+
|
|
312
|
+
// Check actions
|
|
313
|
+
const showActions = computed(() => {
|
|
314
|
+
return config.value.actions?.view || config.value.actions?.edit || config.value.actions?.delete
|
|
315
|
+
})
|
|
316
|
+
|
|
317
|
+
const CellRenderer = resolveComponent('VATableCellRenderer')
|
|
318
|
+
|
|
319
|
+
const processedColumns = computed(() => {
|
|
320
|
+
const cols = props.columns.map(col => {
|
|
321
|
+
const colDef = {
|
|
322
|
+
...col,
|
|
323
|
+
id: col.id || col.accessorKey || col.key,
|
|
324
|
+
accessorKey: col.accessorKey || col.key,
|
|
325
|
+
header: col.header || col.label
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (col.meta?.preset && col.meta.preset !== 'actions') {
|
|
329
|
+
colDef.cell = ({ row }) => {
|
|
330
|
+
const value = getCellValue(row?.original, col)
|
|
331
|
+
return h(CellRenderer, {
|
|
332
|
+
value,
|
|
333
|
+
column: col,
|
|
334
|
+
row: row?.original
|
|
335
|
+
})
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
return colDef
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
if (showActions.value) {
|
|
343
|
+
cols.push({
|
|
344
|
+
id: 'actions',
|
|
345
|
+
header: '',
|
|
346
|
+
enableSorting: false,
|
|
347
|
+
enableHiding: false,
|
|
348
|
+
meta: {
|
|
349
|
+
class: { td: 'w-24 text-right' }
|
|
350
|
+
}
|
|
351
|
+
})
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return cols
|
|
355
|
+
})
|
|
356
|
+
|
|
357
|
+
const visibleTableColumns = computed(() => {
|
|
358
|
+
if (!visibleColumns.value.length) return processedColumns.value
|
|
359
|
+
|
|
360
|
+
return processedColumns.value.filter(col => {
|
|
361
|
+
if (col.id === 'actions') return true
|
|
362
|
+
if (col.enableHiding === false) return true
|
|
363
|
+
return visibleColumns.value.includes(col.id)
|
|
364
|
+
})
|
|
365
|
+
})
|
|
366
|
+
|
|
367
|
+
const columnsWithPresets = computed(() =>
|
|
368
|
+
props.columns.filter(col => col.meta?.preset)
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
function getCellValue(row, col) {
|
|
372
|
+
if (!row) return null
|
|
373
|
+
const key = col.accessorKey || col.key || col.id
|
|
374
|
+
if (!key) return null
|
|
375
|
+
|
|
376
|
+
if (key.includes('.')) {
|
|
377
|
+
return key.split('.').reduce((obj, k) => obj?.[k], row)
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return row[key]
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
const customSlots = computed(() => {
|
|
384
|
+
const internal = [
|
|
385
|
+
'select-header', 'select', 'actions-cell', 'row-actions',
|
|
386
|
+
'extra-actions', 'toolbar-left', 'toolbar-right', 'toolbar-actions',
|
|
387
|
+
'empty'
|
|
388
|
+
]
|
|
389
|
+
|
|
390
|
+
return Object.fromEntries(
|
|
391
|
+
Object.entries(slots).filter(([name]) =>
|
|
392
|
+
!internal.includes(name) &&
|
|
393
|
+
!name.startsWith('cell-') &&
|
|
394
|
+
!columnsWithPresets.value.some(col =>
|
|
395
|
+
name === `${col.id || col.accessorKey}-cell`
|
|
396
|
+
)
|
|
397
|
+
)
|
|
398
|
+
)
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
const selectedRows = ref([])
|
|
402
|
+
|
|
403
|
+
const allSelected = computed(() =>
|
|
404
|
+
tableData.value.length > 0 && selectedRows.value.length === tableData.value.length
|
|
405
|
+
)
|
|
406
|
+
|
|
407
|
+
const someSelected = computed(() =>
|
|
408
|
+
selectedRows.value.length > 0 && selectedRows.value.length < tableData.value.length
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
function isSelected(row) {
|
|
412
|
+
return selectedRows.value.some(r => r[props.rowKey] === row[props.rowKey])
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function toggleSelect(row) {
|
|
416
|
+
const index = selectedRows.value.findIndex(r => r[props.rowKey] === row[props.rowKey])
|
|
417
|
+
if (index >= 0) {
|
|
418
|
+
selectedRows.value.splice(index, 1)
|
|
419
|
+
} else {
|
|
420
|
+
selectedRows.value.push(row)
|
|
421
|
+
}
|
|
422
|
+
emit('select', selectedRows.value)
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function toggleAll(value) {
|
|
426
|
+
selectedRows.value = value ? [...tableData.value] : []
|
|
427
|
+
emit('select', selectedRows.value)
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const currentPage = ref(1)
|
|
431
|
+
|
|
432
|
+
const totalPages = computed(() =>
|
|
433
|
+
Math.ceil(totalCount.value / (config.value.pagination?.pageSize ?? 20))
|
|
434
|
+
)
|
|
435
|
+
|
|
436
|
+
const startIndex = computed(() =>
|
|
437
|
+
(currentPage.value - 1) * (config.value.pagination?.pageSize ?? 20)
|
|
438
|
+
)
|
|
439
|
+
|
|
440
|
+
const endIndex = computed(() =>
|
|
441
|
+
Math.min(startIndex.value + (config.value.pagination?.pageSize ?? 20), totalCount.value)
|
|
442
|
+
)
|
|
443
|
+
|
|
444
|
+
function handleRowClick(row) {
|
|
445
|
+
emit('row-click', row)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function handleView(row) {
|
|
449
|
+
if (config.value.actions?.viewRoute) {
|
|
450
|
+
const route = config.value.actions.viewRoute.replace(':id', row[props.rowKey])
|
|
451
|
+
router.push(route)
|
|
452
|
+
} else {
|
|
453
|
+
emit('view', row)
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
function handleEdit(row) {
|
|
458
|
+
if (config.value.actions?.editRoute) {
|
|
459
|
+
const route = config.value.actions.editRoute.replace(':id', row[props.rowKey])
|
|
460
|
+
router.push(route)
|
|
461
|
+
} else {
|
|
462
|
+
emit('edit', row)
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
const showDeleteModal = ref(false)
|
|
467
|
+
const itemToDelete = ref(null)
|
|
468
|
+
const deleteMessage = ref('Are you sure you want to delete this item?')
|
|
469
|
+
|
|
470
|
+
function handleDelete(row) {
|
|
471
|
+
itemToDelete.value = row
|
|
472
|
+
deleteMessage.value = `Are you sure you want to delete "${row.name || row.title || 'this item'}"?`
|
|
473
|
+
showDeleteModal.value = true
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
async function confirmDeleteItem() {
|
|
477
|
+
if (!itemToDelete.value || !crudInstance?.remove) return
|
|
478
|
+
|
|
479
|
+
const success = await crudInstance.remove(itemToDelete.value[props.rowKey])
|
|
480
|
+
if (success) {
|
|
481
|
+
showDeleteModal.value = false
|
|
482
|
+
itemToDelete.value = null
|
|
483
|
+
emit('delete', itemToDelete.value)
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
</script>
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="flex items-center justify-end gap-1">
|
|
3
|
+
<!-- Primary actions (always visible) -->
|
|
4
|
+
<template v-for="action in primaryActions" :key="action.name">
|
|
5
|
+
<UTooltip v-if="action.label" :text="action.label">
|
|
6
|
+
<UButton
|
|
7
|
+
:icon="action.icon"
|
|
8
|
+
color="neutral"
|
|
9
|
+
variant="ghost"
|
|
10
|
+
size="xs"
|
|
11
|
+
:disabled="action.disabled"
|
|
12
|
+
@click="emit(action.name, row)"
|
|
13
|
+
/>
|
|
14
|
+
</UTooltip>
|
|
15
|
+
<UButton
|
|
16
|
+
v-else
|
|
17
|
+
:icon="action.icon"
|
|
18
|
+
color="neutral"
|
|
19
|
+
variant="ghost"
|
|
20
|
+
size="xs"
|
|
21
|
+
:disabled="action.disabled"
|
|
22
|
+
@click="emit(action.name, row)"
|
|
23
|
+
/>
|
|
24
|
+
</template>
|
|
25
|
+
|
|
26
|
+
<!-- Overflow menu (for additional actions) -->
|
|
27
|
+
<UDropdownMenu v-if="overflowActions.length" :items="overflowMenuItems">
|
|
28
|
+
<UButton
|
|
29
|
+
icon="i-lucide-more-horizontal"
|
|
30
|
+
color="neutral"
|
|
31
|
+
variant="ghost"
|
|
32
|
+
size="xs"
|
|
33
|
+
/>
|
|
34
|
+
</UDropdownMenu>
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<script setup>
|
|
39
|
+
defineOptions({
|
|
40
|
+
name: 'VATableActionColumn'
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const props = defineProps({
|
|
44
|
+
row: {
|
|
45
|
+
type: Object,
|
|
46
|
+
required: true
|
|
47
|
+
},
|
|
48
|
+
actions: {
|
|
49
|
+
type: Array,
|
|
50
|
+
default: () => []
|
|
51
|
+
},
|
|
52
|
+
showView: {
|
|
53
|
+
type: Boolean,
|
|
54
|
+
default: false
|
|
55
|
+
},
|
|
56
|
+
showEdit: {
|
|
57
|
+
type: Boolean,
|
|
58
|
+
default: true
|
|
59
|
+
},
|
|
60
|
+
showDelete: {
|
|
61
|
+
type: Boolean,
|
|
62
|
+
default: true
|
|
63
|
+
},
|
|
64
|
+
maxVisible: {
|
|
65
|
+
type: Number,
|
|
66
|
+
default: 2
|
|
67
|
+
}
|
|
68
|
+
})
|
|
69
|
+
|
|
70
|
+
const emit = defineEmits(['view', 'edit', 'delete'])
|
|
71
|
+
|
|
72
|
+
// Build default actions based on props
|
|
73
|
+
const defaultActions = computed(() => {
|
|
74
|
+
const actions = []
|
|
75
|
+
|
|
76
|
+
if (props.showView) {
|
|
77
|
+
actions.push({
|
|
78
|
+
name: 'view',
|
|
79
|
+
label: 'View',
|
|
80
|
+
icon: 'i-lucide-eye'
|
|
81
|
+
})
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (props.showEdit) {
|
|
85
|
+
actions.push({
|
|
86
|
+
name: 'edit',
|
|
87
|
+
label: 'Edit',
|
|
88
|
+
icon: 'i-lucide-pencil'
|
|
89
|
+
})
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (props.showDelete) {
|
|
93
|
+
actions.push({
|
|
94
|
+
name: 'delete',
|
|
95
|
+
label: 'Delete',
|
|
96
|
+
icon: 'i-lucide-trash-2',
|
|
97
|
+
color: 'error'
|
|
98
|
+
})
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return actions
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
// Combine default and custom actions
|
|
105
|
+
const allActions = computed(() => {
|
|
106
|
+
return [...defaultActions.value, ...props.actions]
|
|
107
|
+
})
|
|
108
|
+
|
|
109
|
+
// Split into primary (visible) and overflow actions
|
|
110
|
+
const primaryActions = computed(() => {
|
|
111
|
+
return allActions.value
|
|
112
|
+
.filter(a => !a.overflow)
|
|
113
|
+
.slice(0, props.maxVisible)
|
|
114
|
+
})
|
|
115
|
+
|
|
116
|
+
const overflowActions = computed(() => {
|
|
117
|
+
const explicitOverflow = allActions.value.filter(a => a.overflow)
|
|
118
|
+
const implicitOverflow = allActions.value
|
|
119
|
+
.filter(a => !a.overflow)
|
|
120
|
+
.slice(props.maxVisible)
|
|
121
|
+
return [...implicitOverflow, ...explicitOverflow]
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
// Build dropdown menu items for overflow
|
|
125
|
+
const overflowMenuItems = computed(() => {
|
|
126
|
+
return overflowActions.value.map(action => ({
|
|
127
|
+
label: action.label || action.name,
|
|
128
|
+
icon: action.icon,
|
|
129
|
+
disabled: action.disabled,
|
|
130
|
+
onSelect: () => emit(action.name, props.row)
|
|
131
|
+
}))
|
|
132
|
+
})
|
|
133
|
+
</script>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="flex items-center justify-end gap-1">
|
|
3
|
+
<!-- View button -->
|
|
4
|
+
<UButton
|
|
5
|
+
v-if="showView"
|
|
6
|
+
icon="i-lucide-eye"
|
|
7
|
+
color="neutral"
|
|
8
|
+
variant="ghost"
|
|
9
|
+
size="xs"
|
|
10
|
+
@click="handleView"
|
|
11
|
+
/>
|
|
12
|
+
|
|
13
|
+
<!-- Edit button -->
|
|
14
|
+
<UButton
|
|
15
|
+
v-if="showEdit"
|
|
16
|
+
icon="i-lucide-pencil"
|
|
17
|
+
color="neutral"
|
|
18
|
+
variant="ghost"
|
|
19
|
+
size="xs"
|
|
20
|
+
@click="$emit('edit', row)"
|
|
21
|
+
/>
|
|
22
|
+
|
|
23
|
+
<!-- Delete button -->
|
|
24
|
+
<UButton
|
|
25
|
+
v-if="showDelete"
|
|
26
|
+
icon="i-lucide-trash-2"
|
|
27
|
+
color="error"
|
|
28
|
+
variant="ghost"
|
|
29
|
+
size="xs"
|
|
30
|
+
@click="$emit('delete', row)"
|
|
31
|
+
/>
|
|
32
|
+
|
|
33
|
+
<!-- Extra actions slot -->
|
|
34
|
+
<slot :row="row" />
|
|
35
|
+
</div>
|
|
36
|
+
</template>
|
|
37
|
+
|
|
38
|
+
<script setup>
|
|
39
|
+
defineOptions({
|
|
40
|
+
name: 'VATableActions'
|
|
41
|
+
})
|
|
42
|
+
|
|
43
|
+
const props = defineProps({
|
|
44
|
+
row: {
|
|
45
|
+
type: Object,
|
|
46
|
+
required: true
|
|
47
|
+
},
|
|
48
|
+
showView: {
|
|
49
|
+
type: Boolean,
|
|
50
|
+
default: true
|
|
51
|
+
},
|
|
52
|
+
showEdit: {
|
|
53
|
+
type: Boolean,
|
|
54
|
+
default: true
|
|
55
|
+
},
|
|
56
|
+
showDelete: {
|
|
57
|
+
type: Boolean,
|
|
58
|
+
default: true
|
|
59
|
+
},
|
|
60
|
+
viewRoute: {
|
|
61
|
+
type: String,
|
|
62
|
+
default: ''
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
const emit = defineEmits(['view', 'edit', 'delete'])
|
|
67
|
+
|
|
68
|
+
function handleView() {
|
|
69
|
+
if (props.viewRoute) {
|
|
70
|
+
// Replace :id or {id} with actual row id
|
|
71
|
+
const route = props.viewRoute
|
|
72
|
+
.replace(':id', props.row.id)
|
|
73
|
+
.replace('{id}', props.row.id)
|
|
74
|
+
navigateTo(route)
|
|
75
|
+
} else {
|
|
76
|
+
emit('view', props.row)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
</script>
|