@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.
@@ -89,6 +89,7 @@ const handleDelete = async () => {
89
89
  <template>
90
90
  <UModal v-model:open="isOpen">
91
91
  <UButton
92
+ v-if="open === undefined"
92
93
  :icon="triggerIcon"
93
94
  :color="triggerColor as any"
94
95
  :variant="triggerVariant as any"
@@ -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>