@revenexx/studio-shared 0.1.1 → 0.2.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/README.md +22 -12
- package/dist/module.d.mts +9 -0
- package/dist/module.json +9 -0
- package/dist/module.mjs +27 -0
- package/dist/runtime/components/DataTable/ColumnsMenu.d.vue.ts +12 -0
- package/{components → dist/runtime/components}/DataTable/ColumnsMenu.vue +10 -17
- package/dist/runtime/components/DataTable/ColumnsMenu.vue.d.ts +12 -0
- package/dist/runtime/components/DataTable/DataTable.d.vue.ts +59 -0
- package/dist/runtime/components/DataTable/DataTable.vue +453 -0
- package/dist/runtime/components/DataTable/DataTable.vue.d.ts +59 -0
- package/dist/runtime/components/DataTable/FilterMenu.d.vue.ts +12 -0
- package/{components → dist/runtime/components}/DataTable/FilterMenu.vue +4 -8
- package/dist/runtime/components/DataTable/FilterMenu.vue.d.ts +12 -0
- package/dist/runtime/components/DataTable/types.d.ts +70 -0
- package/dist/runtime/components/DataTable/types.js +0 -0
- package/dist/runtime/components/Shared/BuildLogsFlyout.d.vue.ts +30 -0
- package/{components → dist/runtime/components}/Shared/BuildLogsFlyout.vue +27 -45
- package/dist/runtime/components/Shared/BuildLogsFlyout.vue.d.ts +30 -0
- package/dist/runtime/components/Shared/GitDeploymentSettings.d.vue.ts +17 -0
- package/{components → dist/runtime/components}/Shared/GitDeploymentSettings.vue +22 -35
- package/dist/runtime/components/Shared/GitDeploymentSettings.vue.d.ts +17 -0
- package/dist/runtime/components/Shared/KpiTile.d.vue.ts +13 -0
- package/{components → dist/runtime/components}/Shared/KpiTile.vue +11 -19
- package/dist/runtime/components/Shared/KpiTile.vue.d.ts +13 -0
- package/dist/runtime/components/Shared/PageHeader.d.vue.ts +23 -0
- package/{components → dist/runtime/components}/Shared/PageHeader.vue +6 -8
- package/dist/runtime/components/Shared/PageHeader.vue.d.ts +23 -0
- package/dist/runtime/components/Shared/SidebarNav.d.vue.ts +36 -0
- package/{components → dist/runtime/components}/Shared/SidebarNav.vue +15 -47
- package/dist/runtime/components/Shared/SidebarNav.vue.d.ts +36 -0
- package/dist/runtime/components/Shared/SortHeader.d.vue.ts +23 -0
- package/dist/runtime/components/Shared/SortHeader.vue +35 -0
- package/dist/runtime/components/Shared/SortHeader.vue.d.ts +23 -0
- package/dist/runtime/components/Shared/StateCard.d.vue.ts +32 -0
- package/{components → dist/runtime/components}/Shared/StateCard.vue +21 -36
- package/dist/runtime/components/Shared/StateCard.vue.d.ts +32 -0
- package/dist/runtime/components/Shared/StudioCard.d.vue.ts +15 -0
- package/dist/runtime/components/Shared/StudioCard.vue +19 -0
- package/dist/runtime/components/Shared/StudioCard.vue.d.ts +15 -0
- package/dist/runtime/components/Shared/TabBar.d.vue.ts +23 -0
- package/{components → dist/runtime/components}/Shared/TabBar.vue +7 -16
- package/dist/runtime/components/Shared/TabBar.vue.d.ts +23 -0
- package/dist/runtime/components/Shared/ViewTab.d.vue.ts +7 -0
- package/{components → dist/runtime/components}/Shared/ViewTab.vue +7 -9
- package/dist/runtime/components/Shared/ViewTab.vue.d.ts +7 -0
- package/dist/runtime/components/Shared/Wizard.d.vue.ts +47 -0
- package/{components → dist/runtime/components}/Shared/Wizard.vue +24 -41
- package/dist/runtime/components/Shared/Wizard.vue.d.ts +47 -0
- package/{composables/usePlatformAuth.ts → dist/runtime/composables/usePlatformAuth.d.ts} +3 -21
- package/dist/runtime/composables/usePlatformAuth.js +17 -0
- package/dist/runtime/composables/usePlatformTenant.d.ts +14 -0
- package/dist/runtime/composables/usePlatformTenant.js +18 -0
- package/dist/runtime/types/nav.d.ts +23 -0
- package/dist/runtime/types/nav.js +0 -0
- package/dist/types.d.mts +15 -0
- package/package.json +25 -12
- package/components/DataTable/DataTable.vue +0 -518
- package/components/DataTable/types.ts +0 -82
- package/components/Shared/SortHeader.vue +0 -50
- package/components/Shared/StudioCard.vue +0 -24
- package/composables/usePlatformTenant.ts +0 -35
- package/index.ts +0 -12
- package/nuxt.config.ts +0 -23
|
@@ -1,518 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts" generic="T">
|
|
2
|
-
import { AltArrowDown, Filter, Magnifier as Search } from '#solar-icons'
|
|
3
|
-
import {
|
|
4
|
-
Button,
|
|
5
|
-
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator,
|
|
6
|
-
DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger,
|
|
7
|
-
Input,
|
|
8
|
-
} from '@revenexx/studio'
|
|
9
|
-
import type { Component } from 'vue'
|
|
10
|
-
import SortHeader from '../Shared/SortHeader.vue'
|
|
11
|
-
import ColumnsMenu from './ColumnsMenu.vue'
|
|
12
|
-
import FilterMenu from './FilterMenu.vue'
|
|
13
|
-
import type { DataTableColumn, DataTableFilter, DataTableState, RowKey, SortState } from './types'
|
|
14
|
-
|
|
15
|
-
// One DataTable to rule them all. Two operating modes:
|
|
16
|
-
//
|
|
17
|
-
// Static (default): the table holds search/sort/page state internally
|
|
18
|
-
// and filters/sorts/paginates the `data` array in-memory.
|
|
19
|
-
//
|
|
20
|
-
// Server (`server-mode`): the page owns the data pipeline. The table
|
|
21
|
-
// just renders `data` (the current page) and emits `state-change`
|
|
22
|
-
// whenever search / sort / page changes — the page should re-fetch.
|
|
23
|
-
// Pass `total` so the footer knows the un-paginated count.
|
|
24
|
-
//
|
|
25
|
-
// Per-row rendering is slot-based: `#cell-<key>` receives `{ row }`.
|
|
26
|
-
// Cells fall back to the column's sortGetter value if no slot is given.
|
|
27
|
-
|
|
28
|
-
const props = withDefaults(defineProps<{
|
|
29
|
-
data: T[]
|
|
30
|
-
columns: DataTableColumn<T>[]
|
|
31
|
-
rowKey: RowKey<T>
|
|
32
|
-
|
|
33
|
-
// Toolbar
|
|
34
|
-
search?: boolean | { placeholder?: string }
|
|
35
|
-
columnsMenu?: boolean
|
|
36
|
-
filter?: boolean
|
|
37
|
-
/** Built-in status/dimension filter pills (static mode). One dimension, or
|
|
38
|
-
* several (each renders as its own sub-menu). */
|
|
39
|
-
filterTabs?: DataTableFilter<T> | DataTableFilter<T>[]
|
|
40
|
-
|
|
41
|
-
// Pagination
|
|
42
|
-
pagination?: boolean | { pageSize?: number }
|
|
43
|
-
|
|
44
|
-
// Sort
|
|
45
|
-
defaultSort?: { column: string, direction: 'asc' | 'desc' }
|
|
46
|
-
|
|
47
|
-
// Server mode
|
|
48
|
-
serverMode?: boolean
|
|
49
|
-
total?: number
|
|
50
|
-
loading?: boolean
|
|
51
|
-
|
|
52
|
-
// Empty state
|
|
53
|
-
emptyText?: string
|
|
54
|
-
}>(), {
|
|
55
|
-
search: true,
|
|
56
|
-
columnsMenu: true,
|
|
57
|
-
filter: false,
|
|
58
|
-
pagination: true,
|
|
59
|
-
serverMode: false,
|
|
60
|
-
loading: false,
|
|
61
|
-
})
|
|
62
|
-
|
|
63
|
-
const emit = defineEmits<{
|
|
64
|
-
rowClick: [row: T]
|
|
65
|
-
stateChange: [state: DataTableState]
|
|
66
|
-
}>()
|
|
67
|
-
|
|
68
|
-
// ---------------------------------------------------------------------------
|
|
69
|
-
// Internal state
|
|
70
|
-
// ---------------------------------------------------------------------------
|
|
71
|
-
const query = ref('')
|
|
72
|
-
// `sort` and `page` are optionally controlled: a parent in server mode
|
|
73
|
-
// can `v-model:sort` / `v-model:page` to stay authoritative over the data
|
|
74
|
-
// pipeline (e.g. AppRenderer/ListView driving PostgREST). Left unbound,
|
|
75
|
-
// they behave as plain local state seeded from `defaultSort`.
|
|
76
|
-
const sort = defineModel<SortState>('sort', {
|
|
77
|
-
default: () => ({
|
|
78
|
-
column: null,
|
|
79
|
-
direction: null,
|
|
80
|
-
}),
|
|
81
|
-
})
|
|
82
|
-
const page = defineModel<number>('page', { default: 0 })
|
|
83
|
-
if (props.defaultSort && sort.value.column == null) {
|
|
84
|
-
sort.value = { column: props.defaultSort.column, direction: props.defaultSort.direction }
|
|
85
|
-
}
|
|
86
|
-
// Page size is local state (the footer lets the user change it), seeded from
|
|
87
|
-
// the `pagination.pageSize` prop. Defaults to 20.
|
|
88
|
-
const pageSize = ref(
|
|
89
|
-
typeof props.pagination === 'object' && props.pagination?.pageSize
|
|
90
|
-
? props.pagination.pageSize
|
|
91
|
-
: 20,
|
|
92
|
-
)
|
|
93
|
-
const pageSizeOptions = computed(() => Array.from(new Set([10, 20, 50, 100, pageSize.value])).sort((a, b) => a - b))
|
|
94
|
-
|
|
95
|
-
// Column visibility — initialised once from column defaults.
|
|
96
|
-
const visibleKeys = ref<Set<string>>(
|
|
97
|
-
new Set(props.columns.filter(c => c.visible !== false).map(c => c.key)),
|
|
98
|
-
)
|
|
99
|
-
function toggleColumn(key: string) {
|
|
100
|
-
const next = new Set(visibleKeys.value)
|
|
101
|
-
if (next.has(key)) next.delete(key)
|
|
102
|
-
else next.add(key)
|
|
103
|
-
visibleKeys.value = next
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
const visibleColumns = computed(() =>
|
|
107
|
-
props.columns.filter(c => visibleKeys.value.has(c.key)),
|
|
108
|
-
)
|
|
109
|
-
|
|
110
|
-
// ColumnsMenu is non-generic (DataTableColumn<unknown>[]); the per-row getters
|
|
111
|
-
// make DataTableColumn<T> invariant in T, so widen for the menu prop.
|
|
112
|
-
const menuColumns = computed(() => props.columns as unknown as DataTableColumn[])
|
|
113
|
-
|
|
114
|
-
// ---------------------------------------------------------------------------
|
|
115
|
-
// Static-mode data pipeline (search → sort → paginate). Skipped in
|
|
116
|
-
// server mode; in server mode the page is expected to do this work and
|
|
117
|
-
// pass back the already-prepared `data`.
|
|
118
|
-
// ---------------------------------------------------------------------------
|
|
119
|
-
function searchOf(col: DataTableColumn<T>, row: T): string {
|
|
120
|
-
if (col.searchGetter) return col.searchGetter(row).toLowerCase()
|
|
121
|
-
if (col.sortGetter) {
|
|
122
|
-
const v = col.sortGetter(row)
|
|
123
|
-
return v == null ? '' : String(v).toLowerCase()
|
|
124
|
-
}
|
|
125
|
-
const raw = (row as Record<string, unknown>)[col.key]
|
|
126
|
-
return raw == null ? '' : String(raw).toLowerCase()
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
// Filter dimensions (static mode) run before search. One or many dimensions,
|
|
130
|
-
// each rendered as a sub-menu under the generic "Filter" dropdown.
|
|
131
|
-
interface FilterDim { key: string, label: string, getter: (row: T) => string | null | undefined, options: { value: string, label: string }[], allLabel?: string }
|
|
132
|
-
const filterDims = computed<FilterDim[]>(() => {
|
|
133
|
-
const raw = props.filterTabs
|
|
134
|
-
if (!raw) return []
|
|
135
|
-
const arr = Array.isArray(raw) ? raw : [raw]
|
|
136
|
-
return arr.map((f, i) => ({ key: f.key ?? f.label ?? `dim${i}`, label: f.label ?? 'Status', getter: f.getter, options: f.options, allLabel: f.allLabel }))
|
|
137
|
-
})
|
|
138
|
-
const activeFilters = ref<Record<string, string | null>>({})
|
|
139
|
-
const anyFilterActive = computed(() => Object.values(activeFilters.value).some(Boolean))
|
|
140
|
-
function setFilter(key: string, value: string | null) {
|
|
141
|
-
activeFilters.value = { ...activeFilters.value, [key]: value }
|
|
142
|
-
}
|
|
143
|
-
function dimCounts(d: FilterDim): Record<string, number> {
|
|
144
|
-
const m: Record<string, number> = {}
|
|
145
|
-
for (const row of props.data) { const k = String(d.getter(row) ?? ''); m[k] = (m[k] ?? 0) + 1 }
|
|
146
|
-
return m
|
|
147
|
-
}
|
|
148
|
-
const filtered = computed(() => {
|
|
149
|
-
if (props.serverMode || !filterDims.value.length) return props.data
|
|
150
|
-
const active = filterDims.value.filter(d => activeFilters.value[d.key])
|
|
151
|
-
if (!active.length) return props.data
|
|
152
|
-
return props.data.filter(row => active.every(d => String(d.getter(row) ?? '') === activeFilters.value[d.key]))
|
|
153
|
-
})
|
|
154
|
-
|
|
155
|
-
const searched = computed(() => {
|
|
156
|
-
if (props.serverMode) return props.data
|
|
157
|
-
const q = query.value.trim().toLowerCase()
|
|
158
|
-
if (!q) return filtered.value
|
|
159
|
-
return filtered.value.filter(row =>
|
|
160
|
-
props.columns.some(col => searchOf(col, row).includes(q)),
|
|
161
|
-
)
|
|
162
|
-
})
|
|
163
|
-
|
|
164
|
-
function sortKey(col: DataTableColumn<T>, row: T) {
|
|
165
|
-
if (col.sortGetter) return col.sortGetter(row)
|
|
166
|
-
const raw = (row as Record<string, unknown>)[col.key]
|
|
167
|
-
return raw as never
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
const sorted = computed(() => {
|
|
171
|
-
if (props.serverMode) return searched.value
|
|
172
|
-
const col = sort.value.column
|
|
173
|
-
const dir = sort.value.direction
|
|
174
|
-
if (!col || !dir) return searched.value
|
|
175
|
-
const c = props.columns.find(x => x.key === col)
|
|
176
|
-
if (!c) return searched.value
|
|
177
|
-
const sign = dir === 'asc' ? 1 : -1
|
|
178
|
-
return [...searched.value].sort((a, b) => {
|
|
179
|
-
const av = sortKey(c, a)
|
|
180
|
-
const bv = sortKey(c, b)
|
|
181
|
-
const aNull = av == null || av === ''
|
|
182
|
-
const bNull = bv == null || bv === ''
|
|
183
|
-
if (aNull && bNull) return 0
|
|
184
|
-
if (aNull) return 1
|
|
185
|
-
if (bNull) return -1
|
|
186
|
-
const aCmp = typeof av === 'boolean' ? (av ? 1 : 0) : av
|
|
187
|
-
const bCmp = typeof bv === 'boolean' ? (bv ? 1 : 0) : bv
|
|
188
|
-
if (aCmp < bCmp) return -1 * sign
|
|
189
|
-
if (aCmp > bCmp) return 1 * sign
|
|
190
|
-
return 0
|
|
191
|
-
})
|
|
192
|
-
})
|
|
193
|
-
|
|
194
|
-
const totalCount = computed(() =>
|
|
195
|
-
props.serverMode ? (props.total ?? props.data.length) : sorted.value.length,
|
|
196
|
-
)
|
|
197
|
-
|
|
198
|
-
const paginated = computed(() => {
|
|
199
|
-
if (props.serverMode || !props.pagination) return sorted.value
|
|
200
|
-
const start = page.value * pageSize.value
|
|
201
|
-
return sorted.value.slice(start, start + pageSize.value)
|
|
202
|
-
})
|
|
203
|
-
|
|
204
|
-
const displayedRows = computed(() => paginated.value)
|
|
205
|
-
|
|
206
|
-
// Guarantee unique v-for keys. External data sources (e.g. the mail gateway's
|
|
207
|
-
// queued/held messages) can hand back rows with a null or duplicated row-key;
|
|
208
|
-
// duplicate keys make Vue's keyed patch crash on the next re-sort/re-filter
|
|
209
|
-
// with "Cannot read properties of null (reading 'nextSibling')". Suffixing
|
|
210
|
-
// repeats keeps every key distinct without changing what the page passed.
|
|
211
|
-
const keyedRows = computed<{ row: T, key: string | number }[]>(() => {
|
|
212
|
-
const seen = new Map<string | number, number>()
|
|
213
|
-
return displayedRows.value.map((row) => {
|
|
214
|
-
const base = rowKeyFor(row) ?? '∅'
|
|
215
|
-
const n = seen.get(base) ?? 0
|
|
216
|
-
seen.set(base, n + 1)
|
|
217
|
-
return { row, key: n === 0 ? base : `${base}#${n}` }
|
|
218
|
-
})
|
|
219
|
-
})
|
|
220
|
-
|
|
221
|
-
// ---------------------------------------------------------------------------
|
|
222
|
-
// State-change hook for server mode
|
|
223
|
-
// ---------------------------------------------------------------------------
|
|
224
|
-
function emitState() {
|
|
225
|
-
emit('stateChange', {
|
|
226
|
-
search: query.value,
|
|
227
|
-
sort: { ...sort.value },
|
|
228
|
-
page: page.value,
|
|
229
|
-
pageSize: pageSize.value,
|
|
230
|
-
})
|
|
231
|
-
}
|
|
232
|
-
watch([query, sort, page, pageSize], () => {
|
|
233
|
-
if (props.serverMode) emitState()
|
|
234
|
-
})
|
|
235
|
-
|
|
236
|
-
// Reset to first page whenever search / sort / filter / page-size changes.
|
|
237
|
-
watch([query, sort, pageSize, activeFilters], () => {
|
|
238
|
-
page.value = 0
|
|
239
|
-
}, { deep: true })
|
|
240
|
-
|
|
241
|
-
// ---------------------------------------------------------------------------
|
|
242
|
-
// Header sort
|
|
243
|
-
// ---------------------------------------------------------------------------
|
|
244
|
-
function toggleSort(column: string) {
|
|
245
|
-
if (sort.value.column !== column) {
|
|
246
|
-
sort.value = { column, direction: 'asc' }
|
|
247
|
-
return
|
|
248
|
-
}
|
|
249
|
-
if (sort.value.direction === 'asc') {
|
|
250
|
-
sort.value = { column, direction: 'desc' }
|
|
251
|
-
return
|
|
252
|
-
}
|
|
253
|
-
sort.value = { column: null, direction: null }
|
|
254
|
-
}
|
|
255
|
-
|
|
256
|
-
// ---------------------------------------------------------------------------
|
|
257
|
-
// Misc
|
|
258
|
-
// ---------------------------------------------------------------------------
|
|
259
|
-
function rowKeyFor(row: T): string | number {
|
|
260
|
-
if (typeof props.rowKey === 'function') return props.rowKey(row)
|
|
261
|
-
return row[props.rowKey] as unknown as string | number
|
|
262
|
-
}
|
|
263
|
-
|
|
264
|
-
const gridTemplate = computed(() => {
|
|
265
|
-
const tracks = visibleColumns.value.map(c => c.width ?? 'minmax(0,1fr)')
|
|
266
|
-
return `grid-template-columns: ${tracks.join(' ')};`
|
|
267
|
-
})
|
|
268
|
-
|
|
269
|
-
const placeholder = computed(() =>
|
|
270
|
-
typeof props.search === 'object' ? (props.search.placeholder ?? 'Search…') : 'Search…',
|
|
271
|
-
)
|
|
272
|
-
|
|
273
|
-
const slots = useSlots()
|
|
274
|
-
const showToolbar = computed(() =>
|
|
275
|
-
props.search || props.columnsMenu || props.filter || !!props.filterTabs || !!slots.actions || !!slots['toolbar-left'],
|
|
276
|
-
)
|
|
277
|
-
|
|
278
|
-
// Leading icon tile for a (primary) column. `cellIcon` is a component — we must
|
|
279
|
-
// NOT call it: Solar icons are functional components (typeof === 'function'), so
|
|
280
|
-
// a `typeof ci === 'function'` "is this a getter?" check wrongly invokes the
|
|
281
|
-
// icon as `icon(row)` and it crashes destructuring its render context. For a
|
|
282
|
-
// per-row icon, use a `#cell-<key>` slot instead.
|
|
283
|
-
function cellIconFor(col: DataTableColumn<T>): Component | null {
|
|
284
|
-
return col.cellIcon ?? null
|
|
285
|
-
}
|
|
286
|
-
const showFooter = computed(() => props.pagination !== false)
|
|
287
|
-
|
|
288
|
-
// Pagination
|
|
289
|
-
const lastPage = computed(() =>
|
|
290
|
-
pageSize.value > 0 ? Math.max(0, Math.ceil(totalCount.value / pageSize.value) - 1) : 0,
|
|
291
|
-
)
|
|
292
|
-
const rangeStart = computed(() => totalCount.value === 0 ? 0 : page.value * pageSize.value + 1)
|
|
293
|
-
const rangeEnd = computed(() =>
|
|
294
|
-
Math.min((page.value + 1) * pageSize.value, totalCount.value),
|
|
295
|
-
)
|
|
296
|
-
|
|
297
|
-
function prevPage() {
|
|
298
|
-
if (page.value > 0) page.value -= 1
|
|
299
|
-
}
|
|
300
|
-
function nextPage() {
|
|
301
|
-
if (page.value < lastPage.value) page.value += 1
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
// Default cell value when no slot is provided.
|
|
305
|
-
function defaultCell(col: DataTableColumn<T>, row: T): string {
|
|
306
|
-
const v = col.sortGetter
|
|
307
|
-
? col.sortGetter(row)
|
|
308
|
-
: (row as Record<string, unknown>)[col.key]
|
|
309
|
-
if (v == null) return '—'
|
|
310
|
-
return String(v)
|
|
311
|
-
}
|
|
312
|
-
</script>
|
|
313
|
-
|
|
314
|
-
<template>
|
|
315
|
-
<div class="rounded-lg border border-black/[0.06] overflow-hidden flex flex-col">
|
|
316
|
-
<!-- Toolbar ------------------------------------------------------ -->
|
|
317
|
-
<!-- Toolbar: LEFT = search · filter · columns · RIGHT = actions.
|
|
318
|
-
(Pagination always lives in the footer.) -->
|
|
319
|
-
<div
|
|
320
|
-
v-if="showToolbar"
|
|
321
|
-
class="flex items-center justify-between gap-2 px-4 py-3 bg-white/55 backdrop-blur-md"
|
|
322
|
-
>
|
|
323
|
-
<div class="flex items-center gap-2 min-w-0">
|
|
324
|
-
<div v-if="search" class="relative w-[21rem] max-w-full">
|
|
325
|
-
<Search :size="13" class="absolute left-2.5 top-1/2 -translate-y-1/2 text-muted-foreground" />
|
|
326
|
-
<Input
|
|
327
|
-
v-model="query"
|
|
328
|
-
:placeholder="placeholder"
|
|
329
|
-
class="h-7 pl-8 text-sm bg-white"
|
|
330
|
-
/>
|
|
331
|
-
</div>
|
|
332
|
-
<!-- Generic "Filter" dropdown — each dimension is a labelled sub-menu. -->
|
|
333
|
-
<DropdownMenu v-if="filterDims.length">
|
|
334
|
-
<DropdownMenuTrigger as-child>
|
|
335
|
-
<Button
|
|
336
|
-
variant="outline"
|
|
337
|
-
size="sm"
|
|
338
|
-
class="h-7 gap-1.5 text-sm"
|
|
339
|
-
:class="anyFilterActive ? 'border-primary/30 text-primary' : ''"
|
|
340
|
-
>
|
|
341
|
-
<Filter :size="14" />
|
|
342
|
-
Filter
|
|
343
|
-
<AltArrowDown :size="12" class="opacity-60" />
|
|
344
|
-
</Button>
|
|
345
|
-
</DropdownMenuTrigger>
|
|
346
|
-
<DropdownMenuContent align="start" class="w-48">
|
|
347
|
-
<DropdownMenuSub v-for="d in filterDims" :key="d.key">
|
|
348
|
-
<DropdownMenuSubTrigger class="gap-2">
|
|
349
|
-
<span class="flex-1">{{ d.label }}</span>
|
|
350
|
-
<span v-if="activeFilters[d.key]" class="text-xs text-primary capitalize">{{ activeFilters[d.key] }}</span>
|
|
351
|
-
</DropdownMenuSubTrigger>
|
|
352
|
-
<DropdownMenuSubContent class="w-48">
|
|
353
|
-
<DropdownMenuItem class="gap-2" @select="setFilter(d.key, null)">
|
|
354
|
-
<span class="flex-1">{{ d.allLabel ?? 'All' }}</span>
|
|
355
|
-
<span class="tabular-nums text-muted-foreground">{{ data.length }}</span>
|
|
356
|
-
</DropdownMenuItem>
|
|
357
|
-
<DropdownMenuSeparator />
|
|
358
|
-
<DropdownMenuItem
|
|
359
|
-
v-for="o in d.options"
|
|
360
|
-
:key="o.value"
|
|
361
|
-
class="gap-2"
|
|
362
|
-
@select="setFilter(d.key, o.value)"
|
|
363
|
-
>
|
|
364
|
-
<span class="flex-1">{{ o.label }}</span>
|
|
365
|
-
<span class="tabular-nums text-muted-foreground">{{ dimCounts(d)[o.value] ?? 0 }}</span>
|
|
366
|
-
</DropdownMenuItem>
|
|
367
|
-
</DropdownMenuSubContent>
|
|
368
|
-
</DropdownMenuSub>
|
|
369
|
-
</DropdownMenuContent>
|
|
370
|
-
</DropdownMenu>
|
|
371
|
-
<FilterMenu v-if="filter">
|
|
372
|
-
<slot name="filter-menu" />
|
|
373
|
-
</FilterMenu>
|
|
374
|
-
<ColumnsMenu
|
|
375
|
-
v-if="columnsMenu"
|
|
376
|
-
:columns="menuColumns"
|
|
377
|
-
:visible="visibleKeys"
|
|
378
|
-
@toggle="toggleColumn"
|
|
379
|
-
/>
|
|
380
|
-
<slot name="toolbar-left" />
|
|
381
|
-
</div>
|
|
382
|
-
<div class="flex items-center gap-1.5 ml-auto shrink-0">
|
|
383
|
-
<slot name="actions" />
|
|
384
|
-
</div>
|
|
385
|
-
</div>
|
|
386
|
-
|
|
387
|
-
<!-- Header row -------------------------------------------------- -->
|
|
388
|
-
<div
|
|
389
|
-
class="grid items-center gap-2 border-t rounded-t-lg border-gray-200 bg-gray-100 px-4 py-2.5 text-sm font-medium uppercase tracking-normal text-muted-foreground"
|
|
390
|
-
:style="gridTemplate"
|
|
391
|
-
>
|
|
392
|
-
<div
|
|
393
|
-
v-for="col in visibleColumns"
|
|
394
|
-
:key="col.key"
|
|
395
|
-
:class="col.alignRight ? 'text-right' : ''"
|
|
396
|
-
>
|
|
397
|
-
<SortHeader
|
|
398
|
-
v-if="col.sortable"
|
|
399
|
-
:column="col.key"
|
|
400
|
-
:active="sort.column"
|
|
401
|
-
:direction="sort.direction"
|
|
402
|
-
:align-right="col.alignRight"
|
|
403
|
-
@toggle="toggleSort"
|
|
404
|
-
>
|
|
405
|
-
{{ col.label }}
|
|
406
|
-
</SortHeader>
|
|
407
|
-
<span v-else>{{ col.label }}</span>
|
|
408
|
-
</div>
|
|
409
|
-
</div>
|
|
410
|
-
|
|
411
|
-
<!-- Body --------------------------------------------------------- -->
|
|
412
|
-
<div
|
|
413
|
-
v-for="{ row, key } in keyedRows"
|
|
414
|
-
:key="key"
|
|
415
|
-
class="group grid items-center gap-2 border-t border-black/[0.04] px-4 py-3 cursor-pointer bg-gray-50 transition-colors"
|
|
416
|
-
:style="gridTemplate"
|
|
417
|
-
@click="emit('rowClick', row)"
|
|
418
|
-
>
|
|
419
|
-
<div
|
|
420
|
-
v-for="col in visibleColumns"
|
|
421
|
-
:key="col.key"
|
|
422
|
-
class="min-w-0"
|
|
423
|
-
:class="col.alignRight ? 'text-right' : ''"
|
|
424
|
-
>
|
|
425
|
-
<slot :name="`cell-${col.key}`" :row="row">
|
|
426
|
-
<!-- Primary column: bold record anchor + optional tinted icon tile. -->
|
|
427
|
-
<div v-if="col.primary" class="flex items-center gap-2.5 min-w-0">
|
|
428
|
-
<span
|
|
429
|
-
v-if="cellIconFor(col)"
|
|
430
|
-
class="flex size-7 shrink-0 items-center justify-center rounded-md bg-primary/10 text-primary"
|
|
431
|
-
>
|
|
432
|
-
<component :is="cellIconFor(col)" :size="13" />
|
|
433
|
-
</span>
|
|
434
|
-
<span class="text-sm font-semibold text-[#0B0522] truncate transition-colors group-hover:text-primary">{{ defaultCell(col, row) }}</span>
|
|
435
|
-
</div>
|
|
436
|
-
<span v-else class="text-sm truncate">{{ defaultCell(col, row) }}</span>
|
|
437
|
-
</slot>
|
|
438
|
-
</div>
|
|
439
|
-
</div>
|
|
440
|
-
|
|
441
|
-
<!-- Loading skeleton (only when there are no rows yet) ----------- -->
|
|
442
|
-
<template v-if="loading && !displayedRows.length">
|
|
443
|
-
<div
|
|
444
|
-
v-for="n in 6"
|
|
445
|
-
:key="`sk-${n}`"
|
|
446
|
-
class="grid items-center gap-2 border-t border-black/[0.04] bg-gray-50 px-4 py-3"
|
|
447
|
-
:style="gridTemplate"
|
|
448
|
-
>
|
|
449
|
-
<div
|
|
450
|
-
v-for="col in visibleColumns"
|
|
451
|
-
:key="col.key"
|
|
452
|
-
:class="col.alignRight ? 'flex justify-end' : ''"
|
|
453
|
-
>
|
|
454
|
-
<div
|
|
455
|
-
class="h-3 rounded bg-black/[0.07] animate-pulse"
|
|
456
|
-
:class="col.alignRight ? 'w-10' : 'w-2/3'"
|
|
457
|
-
/>
|
|
458
|
-
</div>
|
|
459
|
-
</div>
|
|
460
|
-
</template>
|
|
461
|
-
|
|
462
|
-
<!-- Empty -------------------------------------------------------- -->
|
|
463
|
-
<div
|
|
464
|
-
v-if="!displayedRows.length && !loading"
|
|
465
|
-
class="px-3 py-8 text-center text-sm text-muted-foreground"
|
|
466
|
-
>
|
|
467
|
-
<slot name="empty">
|
|
468
|
-
{{ emptyText ?? 'No entries.' }}
|
|
469
|
-
</slot>
|
|
470
|
-
</div>
|
|
471
|
-
|
|
472
|
-
<!-- Footer ------------------------------------------------------- -->
|
|
473
|
-
<div
|
|
474
|
-
v-if="showFooter"
|
|
475
|
-
class="flex items-center justify-between gap-2 border-t border-gray-200 bg-white/55 backdrop-blur-md px-4 py-3 text-sm text-muted-foreground/60"
|
|
476
|
-
>
|
|
477
|
-
<div class="flex items-center gap-2.5">
|
|
478
|
-
<span class="tabular-nums">
|
|
479
|
-
<span v-if="totalCount === 0">No results</span>
|
|
480
|
-
<span v-else>Showing {{ rangeStart }}–{{ rangeEnd }} of {{ totalCount }}</span>
|
|
481
|
-
</span>
|
|
482
|
-
<span class="text-muted-foreground/40">-</span>
|
|
483
|
-
<label class="flex items-center gap-1.5">
|
|
484
|
-
<span class="hidden sm:inline">Rows per page</span>
|
|
485
|
-
<select
|
|
486
|
-
v-model.number="pageSize"
|
|
487
|
-
class="h-7 rounded-md border border-input bg-white px-1.5 text-sm tabular-nums focus:outline-none focus:ring-1 focus:ring-ring"
|
|
488
|
-
>
|
|
489
|
-
<option v-for="s in pageSizeOptions" :key="s" :value="s">{{ s }}</option>
|
|
490
|
-
</select>
|
|
491
|
-
</label>
|
|
492
|
-
</div>
|
|
493
|
-
<div v-if="lastPage > 0" class="flex items-center gap-2">
|
|
494
|
-
<Button
|
|
495
|
-
variant="outline"
|
|
496
|
-
size="sm"
|
|
497
|
-
class="h-7 text-sm"
|
|
498
|
-
:disabled="page === 0 || loading"
|
|
499
|
-
@click="prevPage"
|
|
500
|
-
>
|
|
501
|
-
Previous
|
|
502
|
-
</Button>
|
|
503
|
-
<span class="tabular-nums">
|
|
504
|
-
{{ page + 1 }} / {{ lastPage + 1 }}
|
|
505
|
-
</span>
|
|
506
|
-
<Button
|
|
507
|
-
variant="outline"
|
|
508
|
-
size="sm"
|
|
509
|
-
class="h-7 text-sm"
|
|
510
|
-
:disabled="page >= lastPage || loading"
|
|
511
|
-
@click="nextPage"
|
|
512
|
-
>
|
|
513
|
-
Next
|
|
514
|
-
</Button>
|
|
515
|
-
</div>
|
|
516
|
-
</div>
|
|
517
|
-
</div>
|
|
518
|
-
</template>
|
|
@@ -1,82 +0,0 @@
|
|
|
1
|
-
// Shared types for the cockpit-wide DataTable component.
|
|
2
|
-
//
|
|
3
|
-
// A page builds a `columns` array (mapping data fields to display +
|
|
4
|
-
// behaviour) and passes it to `<DataTable>` together with its source
|
|
5
|
-
// rows. Sorting, text search, column visibility and pagination are
|
|
6
|
-
// handled inside the table; backend-driven modes can take over by
|
|
7
|
-
// setting `server-mode` and reacting to the emitted `state-change`.
|
|
8
|
-
|
|
9
|
-
import type { Component } from 'vue'
|
|
10
|
-
|
|
11
|
-
export type Comparable = string | number | boolean | Date | null | undefined
|
|
12
|
-
|
|
13
|
-
export interface DataTableColumn<T = unknown> {
|
|
14
|
-
/** Stable key — matches the slot name `cell-<key>` and is used in the
|
|
15
|
-
* sort state. */
|
|
16
|
-
key: string
|
|
17
|
-
/** Header label. */
|
|
18
|
-
label: string
|
|
19
|
-
/** CSS grid track. Defaults to `minmax(0,1fr)` for text columns; pass
|
|
20
|
-
* a fixed value like `120px` or `minmax(0,140px)` for compact ones. */
|
|
21
|
-
width?: string
|
|
22
|
-
/** Enable sort affordance on this column. */
|
|
23
|
-
sortable?: boolean
|
|
24
|
-
/** Returns the sort key for the row. Falls back to `row[key]` if it
|
|
25
|
-
* looks like a primitive. ISO date strings sort correctly as-is. */
|
|
26
|
-
sortGetter?: (row: T) => Comparable
|
|
27
|
-
/** Returns the string a text-search query is matched against. Falls
|
|
28
|
-
* back to the stringified sortGetter result. */
|
|
29
|
-
searchGetter?: (row: T) => string
|
|
30
|
-
/** Initial visibility (default `true`). */
|
|
31
|
-
visible?: boolean
|
|
32
|
-
/** Hide-able via the column menu (default `true`). */
|
|
33
|
-
toggleable?: boolean
|
|
34
|
-
/** Right-align cell content (numeric columns). */
|
|
35
|
-
alignRight?: boolean
|
|
36
|
-
/** Optional icon component for the header. */
|
|
37
|
-
icon?: Component
|
|
38
|
-
/** Marks the identifying column. When set (and no `#cell-<key>` slot is
|
|
39
|
-
* given) the DataTable renders the value as the bold record anchor —
|
|
40
|
-
* `font-semibold text-[#0B0522]`, truncated — optionally led by a tinted
|
|
41
|
-
* icon tile (see `cellIcon`). One per table. (datatable-ux) */
|
|
42
|
-
primary?: boolean
|
|
43
|
-
/** Leading tinted icon tile for the cell (typically the `primary` column).
|
|
44
|
-
* A static icon component, rendered as the `bg-primary/10` rounded tile the
|
|
45
|
-
* scanning rules call for. (For a per-row icon use a `#cell-<key>` slot —
|
|
46
|
-
* `cellIcon` must NOT be a getter: Solar icons are functional components, so
|
|
47
|
-
* the table can't tell a getter from an icon and would crash invoking it.) */
|
|
48
|
-
cellIcon?: Component
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/** A built-in filter dimension rendered under the generic "Filter" dropdown as
|
|
52
|
-
* a labelled sub-menu (datatable-ux). Static mode only: the DataTable filters
|
|
53
|
-
* `data` in-memory and shows a count per option. Pass one, or an array of
|
|
54
|
-
* several dimensions — each becomes its own sub-menu. */
|
|
55
|
-
export interface DataTableFilter<T = unknown> {
|
|
56
|
-
/** Stable id for this dimension (defaults to `label`). */
|
|
57
|
-
key?: string
|
|
58
|
-
/** Sub-menu title, e.g. "Status" (default `Status`). */
|
|
59
|
-
label?: string
|
|
60
|
-
/** The row's value for this dimension (e.g. `r => r.status`). */
|
|
61
|
-
getter: (row: T) => string | null | undefined
|
|
62
|
-
/** The selectable values + their labels. */
|
|
63
|
-
options: { value: string, label: string }[]
|
|
64
|
-
/** Label for the reset/"all" entry (default `All`). */
|
|
65
|
-
allLabel?: string
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
export type SortDirection = 'asc' | 'desc' | null
|
|
69
|
-
|
|
70
|
-
export interface SortState {
|
|
71
|
-
column: string | null
|
|
72
|
-
direction: SortDirection
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
export interface DataTableState {
|
|
76
|
-
search: string
|
|
77
|
-
sort: SortState
|
|
78
|
-
page: number
|
|
79
|
-
pageSize: number
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export type RowKey<T> = keyof T | ((row: T) => string | number)
|
|
@@ -1,50 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
import { RoundSortVertical, SortFromBottomToTop, SortFromTopToBottom } from '#solar-icons'
|
|
3
|
-
|
|
4
|
-
// Clickable column header for data tables (datatable-ux). One Solar sort
|
|
5
|
-
// glyph that switches with the sort state — matching cover's tokens:
|
|
6
|
-
// idle → round-sort-vertical
|
|
7
|
-
// asc → sort-from-bottom-to-top
|
|
8
|
-
// desc → sort-from-top-to-bottom
|
|
9
|
-
// Outline weight (Linear), never duotone. Active = primary accent;
|
|
10
|
-
// inactive = dimmed, revealed on hover.
|
|
11
|
-
//
|
|
12
|
-
// <SortHeader column="name" :active="sort.column"
|
|
13
|
-
// :direction="sort.direction" @toggle="toggle">Name</SortHeader>
|
|
14
|
-
|
|
15
|
-
const props = defineProps<{
|
|
16
|
-
column: string
|
|
17
|
-
active: string | null
|
|
18
|
-
direction: 'asc' | 'desc' | null
|
|
19
|
-
/** Right-align the indicator for numeric columns. */
|
|
20
|
-
alignRight?: boolean
|
|
21
|
-
}>()
|
|
22
|
-
|
|
23
|
-
const emit = defineEmits<{ toggle: [column: string] }>()
|
|
24
|
-
|
|
25
|
-
const isActive = computed(() => props.active === props.column && !!props.direction)
|
|
26
|
-
const sortIcon = computed(() => {
|
|
27
|
-
if (isActive.value && props.direction === 'asc') return SortFromBottomToTop
|
|
28
|
-
if (isActive.value && props.direction === 'desc') return SortFromTopToBottom
|
|
29
|
-
return RoundSortVertical
|
|
30
|
-
})
|
|
31
|
-
</script>
|
|
32
|
-
|
|
33
|
-
<template>
|
|
34
|
-
<button
|
|
35
|
-
type="button"
|
|
36
|
-
class="group inline-flex cursor-pointer items-center gap-1.5 -m-1 p-1 rounded-md text-left transition-colors hover:text-primary"
|
|
37
|
-
:class="alignRight ? 'flex-row-reverse w-full justify-end' : ''"
|
|
38
|
-
@click="emit('toggle', column)"
|
|
39
|
-
>
|
|
40
|
-
<slot />
|
|
41
|
-
<component
|
|
42
|
-
:is="sortIcon"
|
|
43
|
-
weight="Linear"
|
|
44
|
-
:size="14"
|
|
45
|
-
class="shrink-0 transition-colors"
|
|
46
|
-
:class="isActive ? 'text-primary' : 'text-foreground/30 group-hover:text-primary'"
|
|
47
|
-
aria-hidden="true"
|
|
48
|
-
/>
|
|
49
|
-
</button>
|
|
50
|
-
</template>
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
<script setup lang="ts">
|
|
2
|
-
/**
|
|
3
|
-
* The standard cockpit card — the 2-layer "glass" used on the dashboard:
|
|
4
|
-
* an outer transparent ring (hairline border + white outline) wrapping a solid
|
|
5
|
-
* white inner pane clipped to the radius. Use everywhere instead of a single
|
|
6
|
-
* bordered div. Optional `to` makes the whole card a link.
|
|
7
|
-
*/
|
|
8
|
-
import { NuxtLink } from '#components'
|
|
9
|
-
|
|
10
|
-
defineProps<{ to?: string }>()
|
|
11
|
-
</script>
|
|
12
|
-
|
|
13
|
-
<template>
|
|
14
|
-
<component
|
|
15
|
-
:is="to ? NuxtLink : 'div'"
|
|
16
|
-
:to="to"
|
|
17
|
-
class="block rounded-lg border border-gray-400/10 outline-1 outline-white/30"
|
|
18
|
-
:class="to ? 'transition-transform hover:-translate-y-0.5' : ''"
|
|
19
|
-
>
|
|
20
|
-
<div class="relative h-full overflow-hidden rounded-lg bg-white">
|
|
21
|
-
<slot />
|
|
22
|
-
</div>
|
|
23
|
-
</component>
|
|
24
|
-
</template>
|