@kinetis/vector-ui 0.0.0-stage → 0.9.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/CHANGELOG.md +26 -0
- package/LICENSE +18 -0
- package/README.md +72 -2
- package/app/app.config.ts +63 -0
- package/app/app.vue +11 -0
- package/app/components/Vx/Action/Dialog.vue +107 -0
- package/app/components/Vx/Cell/Boolean.vue +10 -0
- package/app/components/Vx/Cell/Color.vue +12 -0
- package/app/components/Vx/Cell/Count.vue +12 -0
- package/app/components/Vx/Cell/Date.vue +10 -0
- package/app/components/Vx/Cell/DateTime.vue +10 -0
- package/app/components/Vx/Cell/Email.vue +9 -0
- package/app/components/Vx/Cell/Enum.vue +14 -0
- package/app/components/Vx/Cell/File.vue +30 -0
- package/app/components/Vx/Cell/Image.vue +38 -0
- package/app/components/Vx/Cell/Json.vue +18 -0
- package/app/components/Vx/Cell/KeyValue.vue +25 -0
- package/app/components/Vx/Cell/Markdown.vue +18 -0
- package/app/components/Vx/Cell/Money.vue +16 -0
- package/app/components/Vx/Cell/Number.vue +16 -0
- package/app/components/Vx/Cell/Relation.vue +16 -0
- package/app/components/Vx/Cell/RichText.vue +41 -0
- package/app/components/Vx/Cell/Text.vue +13 -0
- package/app/components/Vx/Cell/Url.vue +11 -0
- package/app/components/Vx/Cell.vue +25 -0
- package/app/components/Vx/CommandPalette.vue +118 -0
- package/app/components/Vx/Confirm.vue +19 -0
- package/app/components/Vx/Dashboard.vue +27 -0
- package/app/components/Vx/FieldsForm.vue +46 -0
- package/app/components/Vx/Import/Dialog.vue +131 -0
- package/app/components/Vx/Inbox.vue +49 -0
- package/app/components/Vx/Input/Boolean.vue +10 -0
- package/app/components/Vx/Input/Currency.vue +25 -0
- package/app/components/Vx/Input/DateTime.vue +38 -0
- package/app/components/Vx/Input/Enum.vue +21 -0
- package/app/components/Vx/Input/KeyValue.vue +78 -0
- package/app/components/Vx/Input/Markdown.vue +120 -0
- package/app/components/Vx/Input/Money.vue +47 -0
- package/app/components/Vx/Input/Number.vue +24 -0
- package/app/components/Vx/Input/Relation.vue +127 -0
- package/app/components/Vx/Input/Repeater.vue +134 -0
- package/app/components/Vx/Input/RichText.vue +182 -0
- package/app/components/Vx/Input/Text.vue +21 -0
- package/app/components/Vx/Input/Textarea.vue +20 -0
- package/app/components/Vx/Input/Upload.vue +89 -0
- package/app/components/Vx/Layout.vue +111 -0
- package/app/components/Vx/List/BulkBar.vue +44 -0
- package/app/components/Vx/List/BulkEdit.vue +91 -0
- package/app/components/Vx/List/ColumnManager.vue +74 -0
- package/app/components/Vx/List/FilterBar.vue +224 -0
- package/app/components/Vx/List/ViewMenu.vue +115 -0
- package/app/components/Vx/Notifications.vue +9 -0
- package/app/components/Vx/Resource/Audit.vue +82 -0
- package/app/components/Vx/Resource/Form.vue +351 -0
- package/app/components/Vx/Resource/RelationPanel.vue +217 -0
- package/app/components/Vx/Resource/Show.vue +35 -0
- package/app/components/Vx/Resource/Table.vue +513 -0
- package/app/components/Vx/Widget/Card.vue +80 -0
- package/app/components/Vx/Widget/Partition.vue +21 -0
- package/app/components/Vx/Widget/Progress.vue +22 -0
- package/app/components/Vx/Widget/Stat.vue +22 -0
- package/app/components/Vx/Widget/Table.vue +31 -0
- package/app/components/Vx/Widget/Trend.vue +37 -0
- package/app/composables/useConfirm.ts +34 -0
- package/app/composables/useLeaveGuard.ts +21 -0
- package/app/composables/useVectorApi.ts +74 -0
- package/app/composables/useVectorAuthStore.ts +252 -0
- package/app/composables/useVectorBranding.ts +45 -0
- package/app/composables/useVectorDashboards.ts +15 -0
- package/app/composables/useVectorExtensions.ts +44 -0
- package/app/composables/useVectorFields.ts +68 -0
- package/app/composables/useVectorForm.ts +36 -0
- package/app/composables/useVectorIdle.ts +39 -0
- package/app/composables/useVectorImpersonation.ts +78 -0
- package/app/composables/useVectorInputs.ts +61 -0
- package/app/composables/useVectorJobs.ts +62 -0
- package/app/composables/useVectorLocale.ts +70 -0
- package/app/composables/useVectorNotifications.ts +24 -0
- package/app/composables/useVectorNotificationsInbox.ts +26 -0
- package/app/composables/useVectorPreferences.ts +30 -0
- package/app/composables/useVectorResource.ts +305 -0
- package/app/composables/useVectorRuntime.ts +42 -0
- package/app/composables/useVectorSchema.ts +56 -0
- package/app/composables/useVectorT.ts +9 -0
- package/app/composables/useVectorViews.ts +25 -0
- package/app/i18n/ar/actions.ts +11 -0
- package/app/i18n/ar/common.ts +23 -0
- package/app/i18n/ar/dashboard.ts +28 -0
- package/app/i18n/ar/fields.ts +77 -0
- package/app/i18n/ar/index.ts +12 -0
- package/app/i18n/ar/jobs.ts +36 -0
- package/app/i18n/ar/list.ts +160 -0
- package/app/i18n/ar/record.ts +62 -0
- package/app/i18n/ar/settings.ts +10 -0
- package/app/i18n/ar/shell.ts +54 -0
- package/app/i18n/de/actions.ts +11 -0
- package/app/i18n/de/common.ts +23 -0
- package/app/i18n/de/dashboard.ts +28 -0
- package/app/i18n/de/fields.ts +77 -0
- package/app/i18n/de/index.ts +12 -0
- package/app/i18n/de/jobs.ts +36 -0
- package/app/i18n/de/list.ts +160 -0
- package/app/i18n/de/record.ts +62 -0
- package/app/i18n/de/settings.ts +10 -0
- package/app/i18n/de/shell.ts +54 -0
- package/app/i18n/en/actions.ts +11 -0
- package/app/i18n/en/common.ts +23 -0
- package/app/i18n/en/dashboard.ts +28 -0
- package/app/i18n/en/fields.ts +77 -0
- package/app/i18n/en/index.ts +12 -0
- package/app/i18n/en/jobs.ts +36 -0
- package/app/i18n/en/list.ts +160 -0
- package/app/i18n/en/record.ts +62 -0
- package/app/i18n/en/settings.ts +10 -0
- package/app/i18n/en/shell.ts +54 -0
- package/app/i18n/es/actions.ts +11 -0
- package/app/i18n/es/common.ts +23 -0
- package/app/i18n/es/dashboard.ts +28 -0
- package/app/i18n/es/fields.ts +77 -0
- package/app/i18n/es/index.ts +12 -0
- package/app/i18n/es/jobs.ts +36 -0
- package/app/i18n/es/list.ts +160 -0
- package/app/i18n/es/record.ts +62 -0
- package/app/i18n/es/settings.ts +10 -0
- package/app/i18n/es/shell.ts +54 -0
- package/app/i18n/fr/actions.ts +11 -0
- package/app/i18n/fr/common.ts +23 -0
- package/app/i18n/fr/dashboard.ts +28 -0
- package/app/i18n/fr/fields.ts +77 -0
- package/app/i18n/fr/index.ts +12 -0
- package/app/i18n/fr/jobs.ts +36 -0
- package/app/i18n/fr/list.ts +160 -0
- package/app/i18n/fr/record.ts +62 -0
- package/app/i18n/fr/settings.ts +10 -0
- package/app/i18n/fr/shell.ts +54 -0
- package/app/i18n/he/actions.ts +11 -0
- package/app/i18n/he/common.ts +23 -0
- package/app/i18n/he/dashboard.ts +28 -0
- package/app/i18n/he/fields.ts +77 -0
- package/app/i18n/he/index.ts +12 -0
- package/app/i18n/he/jobs.ts +36 -0
- package/app/i18n/he/list.ts +160 -0
- package/app/i18n/he/record.ts +62 -0
- package/app/i18n/he/settings.ts +10 -0
- package/app/i18n/he/shell.ts +54 -0
- package/app/i18n/index.ts +111 -0
- package/app/i18n/it/actions.ts +11 -0
- package/app/i18n/it/common.ts +23 -0
- package/app/i18n/it/dashboard.ts +28 -0
- package/app/i18n/it/fields.ts +77 -0
- package/app/i18n/it/index.ts +12 -0
- package/app/i18n/it/jobs.ts +36 -0
- package/app/i18n/it/list.ts +160 -0
- package/app/i18n/it/record.ts +62 -0
- package/app/i18n/it/settings.ts +10 -0
- package/app/i18n/it/shell.ts +54 -0
- package/app/i18n/ja/actions.ts +11 -0
- package/app/i18n/ja/common.ts +23 -0
- package/app/i18n/ja/dashboard.ts +28 -0
- package/app/i18n/ja/fields.ts +77 -0
- package/app/i18n/ja/index.ts +12 -0
- package/app/i18n/ja/jobs.ts +36 -0
- package/app/i18n/ja/list.ts +160 -0
- package/app/i18n/ja/record.ts +62 -0
- package/app/i18n/ja/settings.ts +10 -0
- package/app/i18n/ja/shell.ts +54 -0
- package/app/i18n/ko/actions.ts +11 -0
- package/app/i18n/ko/common.ts +23 -0
- package/app/i18n/ko/dashboard.ts +28 -0
- package/app/i18n/ko/fields.ts +77 -0
- package/app/i18n/ko/index.ts +12 -0
- package/app/i18n/ko/jobs.ts +36 -0
- package/app/i18n/ko/list.ts +160 -0
- package/app/i18n/ko/record.ts +62 -0
- package/app/i18n/ko/settings.ts +10 -0
- package/app/i18n/ko/shell.ts +54 -0
- package/app/i18n/nl/actions.ts +11 -0
- package/app/i18n/nl/common.ts +23 -0
- package/app/i18n/nl/dashboard.ts +28 -0
- package/app/i18n/nl/fields.ts +77 -0
- package/app/i18n/nl/index.ts +12 -0
- package/app/i18n/nl/jobs.ts +36 -0
- package/app/i18n/nl/list.ts +160 -0
- package/app/i18n/nl/record.ts +62 -0
- package/app/i18n/nl/settings.ts +10 -0
- package/app/i18n/nl/shell.ts +54 -0
- package/app/i18n/pl/actions.ts +11 -0
- package/app/i18n/pl/common.ts +23 -0
- package/app/i18n/pl/dashboard.ts +28 -0
- package/app/i18n/pl/fields.ts +77 -0
- package/app/i18n/pl/index.ts +12 -0
- package/app/i18n/pl/jobs.ts +36 -0
- package/app/i18n/pl/list.ts +160 -0
- package/app/i18n/pl/record.ts +62 -0
- package/app/i18n/pl/settings.ts +10 -0
- package/app/i18n/pl/shell.ts +54 -0
- package/app/i18n/pt/actions.ts +11 -0
- package/app/i18n/pt/common.ts +23 -0
- package/app/i18n/pt/dashboard.ts +28 -0
- package/app/i18n/pt/fields.ts +77 -0
- package/app/i18n/pt/index.ts +12 -0
- package/app/i18n/pt/jobs.ts +36 -0
- package/app/i18n/pt/list.ts +160 -0
- package/app/i18n/pt/record.ts +62 -0
- package/app/i18n/pt/settings.ts +10 -0
- package/app/i18n/pt/shell.ts +54 -0
- package/app/i18n/pt-BR/actions.ts +11 -0
- package/app/i18n/pt-BR/common.ts +23 -0
- package/app/i18n/pt-BR/dashboard.ts +28 -0
- package/app/i18n/pt-BR/fields.ts +77 -0
- package/app/i18n/pt-BR/index.ts +12 -0
- package/app/i18n/pt-BR/jobs.ts +36 -0
- package/app/i18n/pt-BR/list.ts +160 -0
- package/app/i18n/pt-BR/record.ts +62 -0
- package/app/i18n/pt-BR/settings.ts +10 -0
- package/app/i18n/pt-BR/shell.ts +54 -0
- package/app/i18n/ru/actions.ts +11 -0
- package/app/i18n/ru/common.ts +23 -0
- package/app/i18n/ru/dashboard.ts +28 -0
- package/app/i18n/ru/fields.ts +77 -0
- package/app/i18n/ru/index.ts +12 -0
- package/app/i18n/ru/jobs.ts +36 -0
- package/app/i18n/ru/list.ts +160 -0
- package/app/i18n/ru/record.ts +62 -0
- package/app/i18n/ru/settings.ts +10 -0
- package/app/i18n/ru/shell.ts +54 -0
- package/app/i18n/tr/actions.ts +11 -0
- package/app/i18n/tr/common.ts +23 -0
- package/app/i18n/tr/dashboard.ts +28 -0
- package/app/i18n/tr/fields.ts +77 -0
- package/app/i18n/tr/index.ts +12 -0
- package/app/i18n/tr/jobs.ts +36 -0
- package/app/i18n/tr/list.ts +160 -0
- package/app/i18n/tr/record.ts +62 -0
- package/app/i18n/tr/settings.ts +10 -0
- package/app/i18n/tr/shell.ts +54 -0
- package/app/i18n/uk/actions.ts +11 -0
- package/app/i18n/uk/common.ts +23 -0
- package/app/i18n/uk/dashboard.ts +28 -0
- package/app/i18n/uk/fields.ts +77 -0
- package/app/i18n/uk/index.ts +12 -0
- package/app/i18n/uk/jobs.ts +36 -0
- package/app/i18n/uk/list.ts +160 -0
- package/app/i18n/uk/record.ts +62 -0
- package/app/i18n/uk/settings.ts +10 -0
- package/app/i18n/uk/shell.ts +54 -0
- package/app/i18n/zh-Hans/actions.ts +11 -0
- package/app/i18n/zh-Hans/common.ts +23 -0
- package/app/i18n/zh-Hans/dashboard.ts +28 -0
- package/app/i18n/zh-Hans/fields.ts +77 -0
- package/app/i18n/zh-Hans/index.ts +12 -0
- package/app/i18n/zh-Hans/jobs.ts +36 -0
- package/app/i18n/zh-Hans/list.ts +160 -0
- package/app/i18n/zh-Hans/record.ts +62 -0
- package/app/i18n/zh-Hans/settings.ts +10 -0
- package/app/i18n/zh-Hans/shell.ts +54 -0
- package/app/layouts/default.vue +8 -0
- package/app/layouts/vector.vue +238 -0
- package/app/middleware/vector-auth.ts +16 -0
- package/app/pages/d/[name].vue +12 -0
- package/app/pages/index.vue +22 -0
- package/app/pages/jobs/[id].vue +52 -0
- package/app/pages/jobs/index.vue +42 -0
- package/app/pages/login.vue +109 -0
- package/app/pages/p/[name].vue +37 -0
- package/app/pages/r/[resource]/[id]/edit.vue +54 -0
- package/app/pages/r/[resource]/[id]/index.vue +143 -0
- package/app/pages/r/[resource]/create.vue +53 -0
- package/app/pages/r/[resource]/index.vue +29 -0
- package/app/pages/settings.vue +104 -0
- package/app/plugins/vector-csp.client.ts +14 -0
- package/app/plugins/vector-extensions.client.ts +84 -0
- package/app/plugins/vector-i18n.ts +76 -0
- package/app/types/vector.ts +438 -0
- package/app/utils/files.ts +14 -0
- package/app/utils/format.ts +100 -0
- package/app/utils/richtext.ts +305 -0
- package/app/utils/rules.ts +113 -0
- package/app/utils/translate.ts +45 -0
- package/app/utils/widgets.ts +11 -0
- package/nuxt.config.ts +83 -0
- package/package.json +82 -5
|
@@ -0,0 +1,513 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { fieldProps, type VectorAction, type VectorExportFormat, type VectorField, type VectorFilterGroup, type VectorFilterQuery, type VectorFilters, type VectorListState, type VectorPage, type VectorRecord, type VectorResource, type VectorSelection, type VectorSort, type VectorSavedView } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// The list view: a server-driven table whose every option — page, size,
|
|
5
|
+
// sort, filters, search, grouping, columns — becomes one list request,
|
|
6
|
+
// with saved views, per-user column state, summaries, bulk operations,
|
|
7
|
+
// actions and export on top.
|
|
8
|
+
const props = defineProps<{ resource: VectorResource }>()
|
|
9
|
+
|
|
10
|
+
const appConfig = useAppConfig()
|
|
11
|
+
const { t, noun } = useVectorT()
|
|
12
|
+
const notifications = useVectorNotifications()
|
|
13
|
+
const { schema } = useVectorSchema()
|
|
14
|
+
const { confirm } = useConfirm()
|
|
15
|
+
const client = useVectorResource(() => props.resource.slug)
|
|
16
|
+
const preferences = useVectorPreferences()
|
|
17
|
+
await preferences.load()
|
|
18
|
+
|
|
19
|
+
// --- list state -------------------------------------------------------
|
|
20
|
+
const columns = ref<string[]>(preferences.get<string[]>(`columns.${props.resource.slug}`)?.filter(c => props.resource.table.columns.includes(c)) ?? [...props.resource.table.columns])
|
|
21
|
+
// The filter: conditions that must all hold, "any of" groups of
|
|
22
|
+
// alternatives, and whatever a saved view holds that the bar cannot edit.
|
|
23
|
+
const filters = ref<VectorFilters>({})
|
|
24
|
+
const groups = ref<VectorFilterGroup[]>([])
|
|
25
|
+
const advanced = ref<VectorFilterQuery[]>([])
|
|
26
|
+
const search = ref<string | null>('')
|
|
27
|
+
const term = computed(() => (search.value ?? '').trim())
|
|
28
|
+
const group = ref<string | null>(null)
|
|
29
|
+
const trashed = ref<'without' | 'only' | 'with'>('without')
|
|
30
|
+
const summaries = ref(props.resource.table.summaries.length > 0)
|
|
31
|
+
const page = ref(1)
|
|
32
|
+
const itemsPerPage = ref(props.resource.table.perPage)
|
|
33
|
+
const sortBy = ref<{ key: string, order?: boolean | 'asc' | 'desc' }[]>(props.resource.table.defaultSort.map(s => ({ key: s.field, order: s.order })))
|
|
34
|
+
const activeView = ref<number | null>(null)
|
|
35
|
+
|
|
36
|
+
const fields = computed<VectorField[]>(() => columns.value
|
|
37
|
+
.map(name => props.resource.fields.find(f => f.name === name))
|
|
38
|
+
.filter((f): f is VectorField => f !== undefined))
|
|
39
|
+
|
|
40
|
+
/** Numbers and money align right, a computed one shown as either too. */
|
|
41
|
+
const numeric = (field: VectorField) => ['number', 'money'].includes(field.type === 'computed' ? field.computed?.as ?? '' : field.type)
|
|
42
|
+
|
|
43
|
+
const headers = computed(() => fields.value.map(field => ({
|
|
44
|
+
key: field.name,
|
|
45
|
+
title: field.label,
|
|
46
|
+
sortable: field.sortable,
|
|
47
|
+
align: (numeric(field) ? 'end' : 'start') as 'start' | 'end',
|
|
48
|
+
nowrap: true,
|
|
49
|
+
})))
|
|
50
|
+
|
|
51
|
+
/** The filter as the list grammar reads it. */
|
|
52
|
+
const wireFilters = computed<VectorFilterQuery>(() => {
|
|
53
|
+
const all: VectorFilterQuery = {}
|
|
54
|
+
for (const [name, ops] of Object.entries(filters.value)) all[name] = { ...ops }
|
|
55
|
+
const extra: VectorFilterQuery[] = [
|
|
56
|
+
...groups.value.filter(g => g.length > 0).map(g => ({ or: g.map(alternative => ({ ...alternative })) })),
|
|
57
|
+
...advanced.value,
|
|
58
|
+
]
|
|
59
|
+
if (extra.length === 1 && Array.isArray(extra[0]!.or)) all.or = extra[0]!.or
|
|
60
|
+
else if (extra.length > 0) all.and = extra
|
|
61
|
+
if (props.resource.softDelete && trashed.value !== 'without') all.trashed = trashed.value
|
|
62
|
+
return all
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
/** The list grammar as a view stores it and a bulk selection sends it. */
|
|
66
|
+
const state = computed<VectorListState>(() => ({
|
|
67
|
+
filter: wireFilters.value,
|
|
68
|
+
q: term.value || undefined,
|
|
69
|
+
sort: sortBy.value.map(s => (s.order === 'desc' ? '-' : '') + s.key).join(',') || undefined,
|
|
70
|
+
group: group.value ?? undefined,
|
|
71
|
+
columns: columns.value.join(','),
|
|
72
|
+
perPage: itemsPerPage.value,
|
|
73
|
+
summaries: summaries.value,
|
|
74
|
+
}))
|
|
75
|
+
|
|
76
|
+
// --- loading ----------------------------------------------------------
|
|
77
|
+
const items = ref<VectorRecord[]>([])
|
|
78
|
+
const total = ref(0)
|
|
79
|
+
const summaryValues = ref<Record<string, number | null>>({})
|
|
80
|
+
const loading = ref(false)
|
|
81
|
+
let requestId = 0
|
|
82
|
+
|
|
83
|
+
async function load() {
|
|
84
|
+
const id = ++requestId
|
|
85
|
+
loading.value = true
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const sort: VectorSort[] = sortBy.value.map(s => ({ field: s.key, order: s.order === 'desc' ? 'desc' : 'asc' }))
|
|
89
|
+
const result: VectorPage = await client.list({
|
|
90
|
+
page: page.value, perPage: itemsPerPage.value, sort, filter: rawFilters(), q: term.value || undefined,
|
|
91
|
+
group: group.value ?? undefined, summaries: summaries.value, columns: columns.value,
|
|
92
|
+
})
|
|
93
|
+
|
|
94
|
+
if (id !== requestId) return
|
|
95
|
+
|
|
96
|
+
items.value = result.data
|
|
97
|
+
total.value = result.total
|
|
98
|
+
summaryValues.value = result.summaries ?? {}
|
|
99
|
+
} catch (error) {
|
|
100
|
+
notifications.error(vectorErrorMessage(error))
|
|
101
|
+
} finally {
|
|
102
|
+
if (id === requestId) loading.value = false
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** The wire form of the filters, `trashed` as the server reads it. */
|
|
107
|
+
function rawFilters(): VectorFilterQuery {
|
|
108
|
+
return wireFilters.value
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const debouncedLoad = useDebounceFn(() => { page.value = 1; load() }, 300)
|
|
112
|
+
watch(search, () => debouncedLoad())
|
|
113
|
+
watch([filters, groups, advanced, group, trashed, summaries], () => { page.value = 1; load() }, { deep: true })
|
|
114
|
+
watch(columns, (c) => { preferences.put({ [`columns.${props.resource.slug}`]: c }).catch(() => undefined); load() })
|
|
115
|
+
|
|
116
|
+
function onOptions() {
|
|
117
|
+
load()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// --- views ------------------------------------------------------------
|
|
121
|
+
const viewMenu = ref<{ views: VectorSavedView[] } | null>(null)
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A stored filter back into what the bar edits: its conditions, its
|
|
125
|
+
* "any of" groups of plain alternatives, and anything deeper kept as it
|
|
126
|
+
* is. Conditions on fields this account cannot see are dropped.
|
|
127
|
+
*/
|
|
128
|
+
function decodeFilter(filter: VectorFilterQuery, known: Set<string>) {
|
|
129
|
+
const out = { filters: {} as VectorFilters, groups: [] as VectorFilterGroup[], advanced: [] as VectorFilterQuery[], trashed: 'without' as 'without' | 'only' | 'with' }
|
|
130
|
+
const plain = (node: VectorFilterQuery): node is VectorFilters => Object.entries(node).every(([k, v]) => k !== 'or' && k !== 'and' && k !== 'trashed' && typeof v === 'object' && !Array.isArray(v))
|
|
131
|
+
|
|
132
|
+
for (const [key, spec] of Object.entries(filter)) {
|
|
133
|
+
if (key === 'trashed') {
|
|
134
|
+
const value = typeof spec === 'string' ? spec : (spec as Record<string, string> | undefined)?.eq ?? (spec as Record<string, string> | undefined)?.['']
|
|
135
|
+
out.trashed = (value === 'only' || value === 'with' ? value : 'without')
|
|
136
|
+
} else if (key === 'or' && Array.isArray(spec)) {
|
|
137
|
+
if (spec.every(plain)) out.groups.push(spec.map(a => Object.fromEntries(Object.entries(a).filter(([name]) => known.has(name)))).filter(a => Object.keys(a).length > 0))
|
|
138
|
+
else out.advanced.push({ or: spec })
|
|
139
|
+
} else if (key === 'and' && Array.isArray(spec)) {
|
|
140
|
+
for (const child of spec) {
|
|
141
|
+
const inner = decodeFilter(child, known)
|
|
142
|
+
for (const [name, ops] of Object.entries(inner.filters)) out.filters[name] = { ...(out.filters[name] ?? {}), ...ops }
|
|
143
|
+
out.groups.push(...inner.groups)
|
|
144
|
+
out.advanced.push(...inner.advanced)
|
|
145
|
+
}
|
|
146
|
+
} else if (known.has(key) && spec !== undefined && !Array.isArray(spec)) {
|
|
147
|
+
out.filters[key] = typeof spec === 'string' ? { eq: spec } : { ...spec }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
out.groups = out.groups.filter(g => g.length > 0)
|
|
152
|
+
return out
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function applyView(view: VectorSavedView | null) {
|
|
156
|
+
activeView.value = view?.id ?? null
|
|
157
|
+
const s = view?.state ?? {}
|
|
158
|
+
// A shared view may name fields this account cannot see; those parts are dropped, not sent.
|
|
159
|
+
const known = new Set(props.resource.fields.map(f => f.name))
|
|
160
|
+
const decoded = decodeFilter(s.filter ?? {}, known)
|
|
161
|
+
trashed.value = decoded.trashed
|
|
162
|
+
filters.value = decoded.filters
|
|
163
|
+
groups.value = decoded.groups
|
|
164
|
+
advanced.value = decoded.advanced
|
|
165
|
+
search.value = s.q ?? ''
|
|
166
|
+
group.value = s.group && known.has(s.group) ? s.group : null
|
|
167
|
+
const sorts = s.sort ? s.sort.split(',').map(e => ({ key: e.replace(/^-/, ''), order: e.startsWith('-') ? 'desc' as const : 'asc' as const })).filter(x => known.has(x.key)) : []
|
|
168
|
+
sortBy.value = sorts.length ? sorts : props.resource.table.defaultSort.map(x => ({ key: x.field, order: x.order }))
|
|
169
|
+
if (s.columns) columns.value = s.columns.split(',').filter(c => props.resource.table.columns.includes(c))
|
|
170
|
+
itemsPerPage.value = s.perPage ?? props.resource.table.perPage
|
|
171
|
+
summaries.value = s.summaries ?? summaries.value
|
|
172
|
+
page.value = 1
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
onMounted(() => {
|
|
176
|
+
// your own default, else the one set for everyone
|
|
177
|
+
const preset = viewMenu.value?.views?.find(v => v.isDefault) ?? viewMenu.value?.views?.find(v => v.resourceDefault)
|
|
178
|
+
if (preset) applyView(preset)
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
// --- selection, bulk operations, actions -----------------------------
|
|
182
|
+
const selected = ref<(number | string)[]>([])
|
|
183
|
+
const allMatching = ref(false)
|
|
184
|
+
watch(items, () => { selected.value = selected.value.filter(id => items.value.some(i => i.id === id)); if (!selected.value.length) allMatching.value = false })
|
|
185
|
+
|
|
186
|
+
const selection = computed<VectorSelection>(() => allMatching.value
|
|
187
|
+
? { all: { filter: rawFilters(), q: term.value || undefined } }
|
|
188
|
+
: { ids: selected.value })
|
|
189
|
+
const selectionCount = computed(() => allMatching.value ? total.value : selected.value.length)
|
|
190
|
+
const indexActions = computed(() => props.resource.actions.filter(a => a.showOnIndex && !a.standalone))
|
|
191
|
+
const standaloneActions = computed(() => props.resource.actions.filter(a => a.showOnIndex && a.standalone))
|
|
192
|
+
const dialogAction = ref<VectorAction | null>(null)
|
|
193
|
+
const bulkEditing = ref(false)
|
|
194
|
+
|
|
195
|
+
async function operation(op: 'delete' | 'restore' | 'forceDelete') {
|
|
196
|
+
const n = selectionCount.value
|
|
197
|
+
const subject = noun(n === 1 ? props.resource.labelSingular : props.resource.label)
|
|
198
|
+
const titles = { delete: t('list.operation.deleteTitle', { count: n, noun: subject }), restore: t('list.operation.restoreTitle', { count: n, noun: subject }), forceDelete: t('list.operation.forceDeleteTitle', { count: n, noun: subject }) }
|
|
199
|
+
|
|
200
|
+
if (!await confirm({ title: titles[op], text: op === 'restore' ? undefined : (props.resource.softDelete && op === 'delete' ? t('list.operation.toTrash') : t('list.operation.irreversible')), confirmText: op === 'restore' ? t('common.restore') : t('common.delete'), destructive: op !== 'restore' })) return
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
const { affected } = await client.batch(op, selection.value)
|
|
204
|
+
notifications.success(t(op === 'restore' ? 'list.operation.restored' : 'list.operation.deleted', { count: affected, noun: affected === 1 ? noun(props.resource.labelSingular) : noun(props.resource.label) }))
|
|
205
|
+
selected.value = []
|
|
206
|
+
allMatching.value = false
|
|
207
|
+
await load()
|
|
208
|
+
} catch (error) {
|
|
209
|
+
notifications.error(vectorErrorMessage(error))
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// one format is a button; more are a menu
|
|
214
|
+
const exportFormats = computed<VectorExportFormat[]>(() => schema.value?.capabilities.exportFormats ?? ['csv'])
|
|
215
|
+
|
|
216
|
+
async function exportAs(format: VectorExportFormat) {
|
|
217
|
+
try {
|
|
218
|
+
const queued = await client.exportFile({ sort: sortBy.value.map(s => ({ field: s.key, order: s.order === 'desc' ? 'desc' : 'asc' })), filter: rawFilters(), q: term.value || undefined, columns: columns.value }, format)
|
|
219
|
+
|
|
220
|
+
if (queued) {
|
|
221
|
+
// an XLSX is always prepared in the background; a CSV only when it is large
|
|
222
|
+
notifications.success(t(format === 'xlsx' ? 'list.exportPrepared' : 'list.exportQueued'))
|
|
223
|
+
await navigateTo(`${appConfig.vector.basePath}/jobs/${queued.jobId}`)
|
|
224
|
+
}
|
|
225
|
+
} catch (error) {
|
|
226
|
+
notifications.error(vectorErrorMessage(error))
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const importing = ref(false)
|
|
231
|
+
|
|
232
|
+
function actionDone() {
|
|
233
|
+
dialogAction.value = null
|
|
234
|
+
selected.value = []
|
|
235
|
+
allMatching.value = false
|
|
236
|
+
load()
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// --- rendering helpers ------------------------------------------------
|
|
240
|
+
const groupField = computed(() => props.resource.fields.find(f => f.name === group.value) ?? null)
|
|
241
|
+
|
|
242
|
+
function formatSummary(field: VectorField, value: number | null): string {
|
|
243
|
+
if (value === null) return t('common.none')
|
|
244
|
+
if (field.type === 'money') {
|
|
245
|
+
const p = fieldProps(field)
|
|
246
|
+
return formatMoney(Math.round(value), typeof p.currency === 'string' ? p.currency : (typeof p.currencyField === 'string' ? String(items.value[0]?.[p.currencyField] ?? 'EUR') : 'EUR'))
|
|
247
|
+
}
|
|
248
|
+
return formatNumber(value, field.summary === 'avg' ? 2 : undefined)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function open(record: VectorRecord) {
|
|
252
|
+
navigateTo(`${appConfig.vector.basePath}/r/${props.resource.slug}/${encodeURIComponent(String(record.id))}`)
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
// --- inline editing ---------------------------------------------------
|
|
256
|
+
// A click on an inline-editable cell opens its editor; a changed cell keeps
|
|
257
|
+
// its editor (highlighted) until the changeset is saved or discarded, so
|
|
258
|
+
// nothing has to render a value the list has not loaded.
|
|
259
|
+
const { inputFor } = useVectorInputs()
|
|
260
|
+
const pending = reactive<Record<string, Record<string, unknown>>>({})
|
|
261
|
+
const opened = reactive<Record<string, true>>({})
|
|
262
|
+
const cellErrors = reactive<Record<string, string>>({})
|
|
263
|
+
const savingChanges = ref(false)
|
|
264
|
+
const cellKey = (id: string | number, field: string) => `${id}\u0000${field}`
|
|
265
|
+
const pendingCount = computed(() => Object.values(pending).reduce((n, values) => n + Object.keys(values).length, 0))
|
|
266
|
+
|
|
267
|
+
function editValue(item: VectorRecord, field: VectorField): unknown {
|
|
268
|
+
const value = item[field.name]
|
|
269
|
+
return field.type === 'belongsTo' && value && typeof value === 'object' && 'id' in value ? (value as { id: unknown }).id : value
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function startEdit(item: VectorRecord, field: VectorField) {
|
|
273
|
+
if (item._trashed === true) return
|
|
274
|
+
closeUnchanged()
|
|
275
|
+
opened[cellKey(item.id, field.name)] = true
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Closes every open editor whose value was left as loaded. */
|
|
279
|
+
function closeUnchanged() {
|
|
280
|
+
for (const key of Object.keys(opened)) {
|
|
281
|
+
const [id, name] = key.split('\u0000') as [string, string]
|
|
282
|
+
if (!(pending[id] && name in pending[id])) delete opened[key]
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function isEditing(item: VectorRecord, field: VectorField): boolean {
|
|
287
|
+
return opened[cellKey(item.id, field.name)] === true
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function setCell(item: VectorRecord, field: VectorField, value: unknown) {
|
|
291
|
+
delete cellErrors[cellKey(item.id, field.name)]
|
|
292
|
+
const row = (pending[String(item.id)] ??= {})
|
|
293
|
+
if (value === editValue(item, field)) delete row[field.name]
|
|
294
|
+
else row[field.name] = value
|
|
295
|
+
if (!Object.keys(row).length) delete pending[String(item.id)]
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
function discardChanges() {
|
|
299
|
+
for (const key of Object.keys(pending)) delete pending[key]
|
|
300
|
+
for (const key of Object.keys(opened)) delete opened[key]
|
|
301
|
+
for (const key of Object.keys(cellErrors)) delete cellErrors[key]
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function saveChanges() {
|
|
305
|
+
const ids = Object.keys(pending)
|
|
306
|
+
const list = ids.map((id) => {
|
|
307
|
+
const item = items.value.find(i => String(i.id) === id)
|
|
308
|
+
return { id: item?.id ?? id, version: item?._version as number | undefined, values: pending[id]! }
|
|
309
|
+
})
|
|
310
|
+
savingChanges.value = true
|
|
311
|
+
for (const key of Object.keys(cellErrors)) delete cellErrors[key]
|
|
312
|
+
|
|
313
|
+
try {
|
|
314
|
+
await client.changes(list)
|
|
315
|
+
notifications.success(t('list.inline.saved', { count: list.length }, list.length))
|
|
316
|
+
discardChanges()
|
|
317
|
+
await load()
|
|
318
|
+
} catch (error) {
|
|
319
|
+
const failure = error as { status?: number, statusCode?: number, data?: { errors?: { path: (string | number)[], message: string }[] } } | null
|
|
320
|
+
const status = failure?.status ?? failure?.statusCode
|
|
321
|
+
|
|
322
|
+
if (status === 422 && Array.isArray(failure?.data?.errors)) {
|
|
323
|
+
for (const violation of failure.data.errors) {
|
|
324
|
+
const [, index, field] = violation.path
|
|
325
|
+
const change = typeof index === 'number' ? list[index] : undefined
|
|
326
|
+
if (change && typeof field === 'string' && field in change.values) cellErrors[cellKey(change.id, field)] = violation.message
|
|
327
|
+
else notifications.error(field ? `${String(field)}: ${violation.message}` : violation.message)
|
|
328
|
+
}
|
|
329
|
+
} else if (status === 409) {
|
|
330
|
+
notifications.error(t('list.inline.conflict'))
|
|
331
|
+
} else {
|
|
332
|
+
notifications.error(vectorErrorMessage(error))
|
|
333
|
+
}
|
|
334
|
+
} finally {
|
|
335
|
+
savingChanges.value = false
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
async function mayLeave(): Promise<boolean> {
|
|
340
|
+
if (!pendingCount.value) return true
|
|
341
|
+
return await confirm({ title: t('list.inline.leaveTitle', { count: pendingCount.value }, pendingCount.value), text: t('list.inline.leaveText'), confirmText: t('list.inline.discard'), destructive: true })
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Another resource's list is the same route with another slug, so an update guards too.
|
|
345
|
+
onBeforeRouteLeave(mayLeave)
|
|
346
|
+
onBeforeRouteUpdate((to, from) => to.path === from.path || mayLeave())
|
|
347
|
+
|
|
348
|
+
function warnBeforeUnload(event: BeforeUnloadEvent) {
|
|
349
|
+
if (pendingCount.value) event.preventDefault()
|
|
350
|
+
}
|
|
351
|
+
onMounted(() => window.addEventListener('beforeunload', warnBeforeUnload))
|
|
352
|
+
onBeforeUnmount(() => window.removeEventListener('beforeunload', warnBeforeUnload))
|
|
353
|
+
</script>
|
|
354
|
+
|
|
355
|
+
<template>
|
|
356
|
+
<div>
|
|
357
|
+
<v-row v-if="resource.widgets.length" density="compact" class="mb-2">
|
|
358
|
+
<v-col v-for="widget in resource.widgets" :key="widget.name" cols="12" :md="widget.width">
|
|
359
|
+
<VxWidgetCard :widget="widget" :load="(range: string | undefined) => client.widget(widget.name, range).then(r => r.data)" />
|
|
360
|
+
</v-col>
|
|
361
|
+
</v-row>
|
|
362
|
+
<div class="d-flex align-center flex-wrap ga-2 mb-2">
|
|
363
|
+
<v-text-field v-model="search" :placeholder="resource.table.search.length ? t('list.search', { label: noun(resource.label) }) : t('list.noSearch')" :disabled="resource.table.search.length === 0" prepend-inner-icon="mdi-magnify" density="compact" hide-details clearable :aria-label="t('common.search')" style="max-width: 320px" />
|
|
364
|
+
<VxListViewMenu ref="viewMenu" :resource="resource" :current="state" :active-id="activeView" @apply="applyView" @update:active-id="(id: number | null) => activeView = id" />
|
|
365
|
+
<v-select v-if="resource.table.groupable.length" v-model="group" :items="[{ title: t('list.noGrouping'), value: null }, ...resource.table.groupable.map(g => ({ title: resource.fields.find(f => f.name === g)?.label ?? g, value: g }))]" :label="t('list.groupBy')" density="compact" hide-details style="max-width: 200px" />
|
|
366
|
+
<v-select v-if="resource.softDelete" v-model="trashed" :items="[{ title: t('list.trash.active'), value: 'without' }, { title: t('list.trash.only'), value: 'only' }, { title: t('list.trash.with'), value: 'with' }]" density="compact" hide-details :aria-label="t('list.trash.label')" style="max-width: 180px" prepend-inner-icon="mdi-delete-clock-outline" />
|
|
367
|
+
<v-spacer />
|
|
368
|
+
<v-btn v-for="action in standaloneActions" :key="action.name" size="small" variant="tonal" :prepend-icon="action.icon ?? undefined" @click="dialogAction = action">{{ action.label }}</v-btn>
|
|
369
|
+
<template v-if="resource.permissions.export">
|
|
370
|
+
<v-btn v-if="exportFormats.length === 1" size="small" variant="text" icon="mdi-download-outline" :aria-label="t('list.exportCsv')" @click="exportAs('csv')" />
|
|
371
|
+
<v-menu v-else>
|
|
372
|
+
<template #activator="{ props: menu }">
|
|
373
|
+
<v-btn v-bind="menu" size="small" variant="text" icon="mdi-download-outline" :aria-label="t('list.export')" />
|
|
374
|
+
</template>
|
|
375
|
+
<v-list density="compact">
|
|
376
|
+
<v-list-item v-for="format in exportFormats" :key="format" :prepend-icon="format === 'xlsx' ? 'mdi-file-excel-outline' : 'mdi-file-delimited-outline'" :title="t(`list.exportFormats.${format}`)" @click="exportAs(format)" />
|
|
377
|
+
</v-list>
|
|
378
|
+
</v-menu>
|
|
379
|
+
</template>
|
|
380
|
+
<v-btn v-if="(resource.permissions.create || resource.permissions.update) && schema?.capabilities.queue" size="small" variant="text" icon="mdi-upload-outline" :aria-label="t('list.importCsv')" @click="importing = true" />
|
|
381
|
+
<VxListColumnManager v-model="columns" :resource="resource" />
|
|
382
|
+
</div>
|
|
383
|
+
<VxListFilterBar v-model="filters" v-model:groups="groups" v-model:advanced="advanced" :resource="resource" class="mb-2" />
|
|
384
|
+
|
|
385
|
+
<VxListBulkBar
|
|
386
|
+
v-if="selected.length"
|
|
387
|
+
:resource="resource"
|
|
388
|
+
:selected="selected.length"
|
|
389
|
+
:total="total"
|
|
390
|
+
:all-matching="allMatching"
|
|
391
|
+
:trashed-only="trashed === 'only'"
|
|
392
|
+
:actions="indexActions"
|
|
393
|
+
@update:all-matching="(v: boolean) => allMatching = v"
|
|
394
|
+
@clear="selected = []; allMatching = false"
|
|
395
|
+
@operation="operation"
|
|
396
|
+
@action="(a: VectorAction) => dialogAction = a"
|
|
397
|
+
@edit="bulkEditing = true"
|
|
398
|
+
/>
|
|
399
|
+
|
|
400
|
+
<VxListBulkEdit v-if="bulkEditing" :resource="resource" :selection="selection" :count="selectionCount" @done="bulkEditing = false; actionDone()" @cancel="bulkEditing = false" />
|
|
401
|
+
|
|
402
|
+
<v-card v-if="pendingCount" color="warning" variant="tonal" flat class="d-flex align-center flex-wrap ga-2 px-4 py-2 mb-2" role="status">
|
|
403
|
+
<v-icon icon="mdi-pencil-outline" />
|
|
404
|
+
<span class="font-weight-medium">{{ t('list.inline.pending', { count: pendingCount }, pendingCount) }}</span>
|
|
405
|
+
<v-spacer />
|
|
406
|
+
<v-btn size="small" variant="text" :disabled="savingChanges" @click="discardChanges">{{ t('list.inline.discard') }}</v-btn>
|
|
407
|
+
<v-btn size="small" color="primary" variant="flat" :loading="savingChanges" @click="saveChanges">{{ t('list.inline.save') }}</v-btn>
|
|
408
|
+
</v-card>
|
|
409
|
+
|
|
410
|
+
<v-data-table-server
|
|
411
|
+
v-model="selected"
|
|
412
|
+
v-model:page="page"
|
|
413
|
+
v-model:items-per-page="itemsPerPage"
|
|
414
|
+
v-model:sort-by="sortBy"
|
|
415
|
+
:headers="headers"
|
|
416
|
+
:items="items"
|
|
417
|
+
:items-length="total"
|
|
418
|
+
:loading="loading"
|
|
419
|
+
:items-per-page-options="resource.table.perPageOptions"
|
|
420
|
+
:group-by="group ? [{ key: group, order: 'asc' }] : []"
|
|
421
|
+
item-value="id"
|
|
422
|
+
show-select
|
|
423
|
+
hover
|
|
424
|
+
fixed-header
|
|
425
|
+
density="comfortable"
|
|
426
|
+
@update:options="onOptions"
|
|
427
|
+
@click:row="(_: Event, { item }: { item: VectorRecord }) => open(item)"
|
|
428
|
+
>
|
|
429
|
+
<template v-for="field in fields" :key="field.name" #[`item.${field.name}`]="{ item }">
|
|
430
|
+
<div v-if="field.inline && isEditing(item, field)" class="vx-inline-editor" :class="{ 'vx-inline-changed': pending[String(item.id)] && field.name in pending[String(item.id)]! }" @click.stop @keydown.esc="closeUnchanged">
|
|
431
|
+
<component
|
|
432
|
+
:is="inputFor(field)"
|
|
433
|
+
:model-value="pending[String(item.id)]?.[field.name] ?? editValue(item, field)"
|
|
434
|
+
:field="field"
|
|
435
|
+
:rules="rulesFor(field, 'update')"
|
|
436
|
+
:error-messages="cellErrors[cellKey(item.id, field.name)] ? [cellErrors[cellKey(item.id, field.name)]!] : []"
|
|
437
|
+
:record="item"
|
|
438
|
+
:resource="resource.slug"
|
|
439
|
+
:aria-label="t('list.inline.editLabel', { label: field.label })"
|
|
440
|
+
label=""
|
|
441
|
+
density="compact"
|
|
442
|
+
hide-details="auto"
|
|
443
|
+
autofocus
|
|
444
|
+
@update:model-value="(v: unknown) => setCell(item, field, v)"
|
|
445
|
+
/>
|
|
446
|
+
</div>
|
|
447
|
+
<span
|
|
448
|
+
v-else-if="field.inline && item._trashed !== true && resource.permissions.update"
|
|
449
|
+
class="vx-inline-cell"
|
|
450
|
+
role="button"
|
|
451
|
+
tabindex="0"
|
|
452
|
+
:aria-label="t('list.inline.editLabel', { label: field.label })"
|
|
453
|
+
@click.stop="startEdit(item, field)"
|
|
454
|
+
@keydown.enter.stop.prevent="startEdit(item, field)"
|
|
455
|
+
>
|
|
456
|
+
<VxCell :field="field" :value="item[field.name]" :record="item" :resource="resource" context="index" />
|
|
457
|
+
<v-icon icon="mdi-pencil-outline" size="x-small" class="vx-inline-pencil" />
|
|
458
|
+
</span>
|
|
459
|
+
<span v-else :class="{ 'text-disabled': item._trashed === true }">
|
|
460
|
+
<VxCell :field="field" :value="item[field.name]" :record="item" :resource="resource" context="index" />
|
|
461
|
+
</span>
|
|
462
|
+
</template>
|
|
463
|
+
|
|
464
|
+
<template #group-header="{ item, columns: cols, toggleGroup, isGroupOpen }">
|
|
465
|
+
<tr class="bg-surface-light">
|
|
466
|
+
<td :colspan="cols.length" class="font-weight-medium">
|
|
467
|
+
<v-btn size="small" variant="text" :icon="isGroupOpen(item) ? 'mdi-chevron-down' : 'mdi-chevron-right'" :aria-label="isGroupOpen(item) ? t('list.collapseGroup') : t('list.expandGroup')" :aria-expanded="isGroupOpen(item)" @click="toggleGroup(item)" />
|
|
468
|
+
<span class="text-medium-emphasis me-2">{{ t('list.groupHeader', { label: groupField?.label ?? '' }) }}</span>
|
|
469
|
+
<VxCell v-if="groupField" :field="groupField" :value="item.value" :record="{}" :resource="resource" context="index" />
|
|
470
|
+
<v-chip size="x-small" class="ms-2" variant="tonal">{{ item.items.length }}</v-chip>
|
|
471
|
+
</td>
|
|
472
|
+
</tr>
|
|
473
|
+
</template>
|
|
474
|
+
|
|
475
|
+
<template v-if="summaries && Object.keys(summaryValues).length" #body.append>
|
|
476
|
+
<tr class="font-weight-medium bg-surface-light">
|
|
477
|
+
<td />
|
|
478
|
+
<td v-if="group" />
|
|
479
|
+
<td v-for="field in fields" :key="field.name" :class="numeric(field) ? 'text-end' : ''">
|
|
480
|
+
<template v-if="field.summary && field.name in summaryValues">
|
|
481
|
+
<span class="text-caption text-medium-emphasis me-1">{{ t(`list.summary.${field.summary}`) }}</span>{{ formatSummary(field, summaryValues[field.name] ?? null) }}
|
|
482
|
+
</template>
|
|
483
|
+
</td>
|
|
484
|
+
</tr>
|
|
485
|
+
</template>
|
|
486
|
+
|
|
487
|
+
<template #no-data>
|
|
488
|
+
<v-empty-state icon="mdi-database-off-outline" :title="Object.keys(filters).length || groups.length || advanced.length || term ? t('list.nothingMatches') : t('list.empty', { label: noun(resource.label) })" />
|
|
489
|
+
</template>
|
|
490
|
+
</v-data-table-server>
|
|
491
|
+
|
|
492
|
+
<VxImportDialog v-if="importing" :resource="resource" @queued="importing = false" @cancel="importing = false" />
|
|
493
|
+
|
|
494
|
+
<VxActionDialog
|
|
495
|
+
v-if="dialogAction"
|
|
496
|
+
:resource="resource"
|
|
497
|
+
:action="dialogAction"
|
|
498
|
+
:selection="dialogAction.standalone ? null : selection"
|
|
499
|
+
:count="selectionCount"
|
|
500
|
+
@done="actionDone"
|
|
501
|
+
@cancel="dialogAction = null"
|
|
502
|
+
/>
|
|
503
|
+
</div>
|
|
504
|
+
</template>
|
|
505
|
+
|
|
506
|
+
<style scoped>
|
|
507
|
+
.vx-inline-cell { display: inline-flex; align-items: center; gap: 4px; cursor: text; border-radius: 4px; padding: 2px 4px; margin: -2px -4px; }
|
|
508
|
+
.vx-inline-cell:hover, .vx-inline-cell:focus-visible { outline: 1px dashed rgba(var(--v-theme-primary), 0.6); }
|
|
509
|
+
.vx-inline-pencil { opacity: 0; }
|
|
510
|
+
.vx-inline-cell:hover .vx-inline-pencil, .vx-inline-cell:focus-visible .vx-inline-pencil { opacity: 0.6; }
|
|
511
|
+
.vx-inline-editor { min-width: 140px; padding-block: 4px; }
|
|
512
|
+
.vx-inline-changed { background: rgba(var(--v-theme-warning), 0.12); border-radius: 4px; }
|
|
513
|
+
</style>
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorWidget, VectorWidgetData } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// One widget card: loads its data for the selected range — and again in a
|
|
5
|
+
// new language, whose labels the server translates — and renders the shape
|
|
6
|
+
// its type declares. A consuming app replaces a type through
|
|
7
|
+
// app.config.vector.widgets, like fields and inputs.
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
widget: VectorWidget
|
|
10
|
+
load: (range: string | undefined) => Promise<VectorWidgetData>
|
|
11
|
+
}>()
|
|
12
|
+
|
|
13
|
+
const appConfig = useAppConfig()
|
|
14
|
+
const { t } = useVectorT()
|
|
15
|
+
const range = ref<string | undefined>(Object.keys(props.widget.ranges)[0])
|
|
16
|
+
const data = ref<VectorWidgetData | null>(null)
|
|
17
|
+
const error = ref<string | null>(null)
|
|
18
|
+
const loading = ref(false)
|
|
19
|
+
let requestId = 0
|
|
20
|
+
|
|
21
|
+
async function refresh() {
|
|
22
|
+
const id = ++requestId
|
|
23
|
+
loading.value = true
|
|
24
|
+
error.value = null
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
const result = await props.load(range.value)
|
|
28
|
+
if (id === requestId) data.value = result
|
|
29
|
+
} catch (caught) {
|
|
30
|
+
if (id === requestId) error.value = vectorErrorMessage(caught)
|
|
31
|
+
} finally {
|
|
32
|
+
if (id === requestId) loading.value = false
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const { current: locale } = useVectorLocale()
|
|
37
|
+
watch([range, locale], refresh)
|
|
38
|
+
onMounted(refresh)
|
|
39
|
+
|
|
40
|
+
const builtIn: Record<string, Component> = {
|
|
41
|
+
stat: defineAsyncComponent(() => import('#vector/app/components/Vx/Widget/Stat.vue')),
|
|
42
|
+
trend: defineAsyncComponent(() => import('#vector/app/components/Vx/Widget/Trend.vue')),
|
|
43
|
+
partition: defineAsyncComponent(() => import('#vector/app/components/Vx/Widget/Partition.vue')),
|
|
44
|
+
progress: defineAsyncComponent(() => import('#vector/app/components/Vx/Widget/Progress.vue')),
|
|
45
|
+
table: defineAsyncComponent(() => import('#vector/app/components/Vx/Widget/Table.vue')),
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const component = computed(() => {
|
|
49
|
+
const override = appConfig.vector.widgets[props.widget.type] ?? useVectorExtensions().widgets.get(props.widget.type)
|
|
50
|
+
return override ? (typeof override === 'string' ? resolveComponent(override) : override) : builtIn[props.widget.type] ?? builtIn.stat!
|
|
51
|
+
})
|
|
52
|
+
</script>
|
|
53
|
+
|
|
54
|
+
<template>
|
|
55
|
+
<v-card class="h-100 d-flex flex-column">
|
|
56
|
+
<v-card-item class="pb-0">
|
|
57
|
+
<template v-if="widget.icon" #prepend>
|
|
58
|
+
<v-icon :icon="widget.icon" color="primary" />
|
|
59
|
+
</template>
|
|
60
|
+
<v-card-title class="text-subtitle-1">{{ widget.label }}</v-card-title>
|
|
61
|
+
<template #append>
|
|
62
|
+
<v-select
|
|
63
|
+
v-if="Object.keys(widget.ranges).length > 1"
|
|
64
|
+
v-model="range"
|
|
65
|
+
:items="Object.entries(widget.ranges).map(([value, title]) => ({ value, title }))"
|
|
66
|
+
density="compact"
|
|
67
|
+
variant="plain"
|
|
68
|
+
hide-details
|
|
69
|
+
:aria-label="t('dashboard.range')"
|
|
70
|
+
style="min-width: 110px"
|
|
71
|
+
/>
|
|
72
|
+
</template>
|
|
73
|
+
</v-card-item>
|
|
74
|
+
<v-card-text class="flex-grow-1 d-flex flex-column justify-center">
|
|
75
|
+
<v-progress-linear v-if="loading && !data" indeterminate color="primary" :aria-label="t('dashboard.loading')" />
|
|
76
|
+
<v-alert v-else-if="error" type="error" variant="tonal" density="compact" :text="error" />
|
|
77
|
+
<component :is="component" v-else-if="data" :data="data" :widget="widget" />
|
|
78
|
+
</v-card-text>
|
|
79
|
+
</v-card>
|
|
80
|
+
</template>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorWidgetData } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ data: VectorWidgetData }>()
|
|
5
|
+
const { t } = useVectorT()
|
|
6
|
+
const slices = computed(() => props.data.slices ?? [])
|
|
7
|
+
const total = computed(() => slices.value.reduce((sum, s) => sum + s.value, 0))
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<div>
|
|
12
|
+
<div class="d-flex rounded overflow-hidden mb-3" style="height: 12px">
|
|
13
|
+
<div v-for="slice in slices" :key="slice.label" :class="`bg-${slice.color ?? 'primary'}`" :style="{ width: `${total ? (slice.value / total) * 100 : 0}%` }" :title="slice.label" />
|
|
14
|
+
</div>
|
|
15
|
+
<div v-for="slice in slices" :key="slice.label" class="d-flex justify-space-between text-body-2 py-1">
|
|
16
|
+
<span class="d-flex align-center ga-2"><v-icon size="x-small" icon="mdi-circle" :color="slice.color ?? 'primary'" />{{ slice.label }}</span>
|
|
17
|
+
<span class="font-weight-medium">{{ formatWidgetValue(slice.value, data.format) }} <span class="text-medium-emphasis">({{ t('dashboard.percent', { value: total ? Math.round((slice.value / total) * 100) : 0 }) }})</span></span>
|
|
18
|
+
</div>
|
|
19
|
+
<div v-if="slices.length === 0" class="text-medium-emphasis text-body-2">{{ t('dashboard.partition.empty') }}</div>
|
|
20
|
+
</div>
|
|
21
|
+
</template>
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorWidgetData } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ data: VectorWidgetData }>()
|
|
5
|
+
const { t } = useVectorT()
|
|
6
|
+
const percent = computed(() => {
|
|
7
|
+
const value = props.data.value ?? 0
|
|
8
|
+
const target = props.data.target ?? 0
|
|
9
|
+
return target > 0 ? Math.min(100, (value / target) * 100) : 0
|
|
10
|
+
})
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<div>
|
|
15
|
+
<div class="d-flex justify-space-between align-baseline mb-2">
|
|
16
|
+
<span class="text-h5 font-weight-medium">{{ formatWidgetValue(data.value, data.format) }}</span>
|
|
17
|
+
<span class="text-caption text-medium-emphasis">{{ t('dashboard.progress.of', { target: formatWidgetValue(data.target, data.format) }) }}</span>
|
|
18
|
+
</div>
|
|
19
|
+
<v-progress-linear :model-value="percent" color="primary" height="10" rounded :aria-label="data.caption ?? t('dashboard.progress.label')" />
|
|
20
|
+
<div v-if="data.caption" class="text-caption text-medium-emphasis mt-2">{{ data.caption }}</div>
|
|
21
|
+
</div>
|
|
22
|
+
</template>
|