@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,224 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { fieldProps, type VectorField, type VectorFilterGroup, type VectorFilterQuery, type VectorFilters, type VectorOperator, type VectorResource } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// The active filters as chips, plus a menu to add one: a field, an
|
|
5
|
+
// operator it admits, a value shaped by the field. The conditions (the
|
|
6
|
+
// model, `{field: {op: value}}`) must all hold; each "any of" group holds
|
|
7
|
+
// alternatives, one of which must. A saved view's deeper nesting shows as
|
|
8
|
+
// one chip that can only be removed.
|
|
9
|
+
const props = defineProps<{ resource: VectorResource }>()
|
|
10
|
+
const model = defineModel<VectorFilters>({ required: true })
|
|
11
|
+
const groups = defineModel<VectorFilterGroup[]>('groups', { default: () => [] })
|
|
12
|
+
const advanced = defineModel<VectorFilterQuery[]>('advanced', { default: () => [] })
|
|
13
|
+
|
|
14
|
+
/** Where the menu's condition goes: the conditions, an alternative, a new alternative (-1), or a new group (-1). */
|
|
15
|
+
type Target = { group: number, alternative: number } | null
|
|
16
|
+
const target = ref<Target>(null)
|
|
17
|
+
const menuActivator = ref<HTMLElement | undefined>(undefined)
|
|
18
|
+
|
|
19
|
+
function openAdd(where: Target, event: Event) {
|
|
20
|
+
target.value = where
|
|
21
|
+
menuActivator.value = event.currentTarget as HTMLElement
|
|
22
|
+
fieldName.value = null
|
|
23
|
+
adding.value = true
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const { t, locale } = useVectorT()
|
|
27
|
+
|
|
28
|
+
const OPERATORS: readonly VectorOperator[] = ['eq', 'ne', 'lt', 'lte', 'gt', 'gte', 'in', 'nin', 'like', 'nlike', 'between', 'null', 'nnull']
|
|
29
|
+
|
|
30
|
+
const operatorLabel = (op: string): string => (OPERATORS as readonly string[]).includes(op) ? t(`list.filter.operators.${op}`) : op
|
|
31
|
+
|
|
32
|
+
const filterable = computed(() => props.resource.fields.filter(f => f.operators.length > 0))
|
|
33
|
+
|
|
34
|
+
const adding = ref(false)
|
|
35
|
+
const fieldName = ref<string | null>(null)
|
|
36
|
+
const operator = ref<VectorOperator | null>(null)
|
|
37
|
+
const value = ref<string>('')
|
|
38
|
+
const valueTo = ref<string>('')
|
|
39
|
+
const client = useVectorResource(() => props.resource.slug)
|
|
40
|
+
const relationOptions = ref<{ id: number | string, label: string }[]>([])
|
|
41
|
+
const relationSearch = ref('')
|
|
42
|
+
|
|
43
|
+
const field = computed(() => filterable.value.find(f => f.name === fieldName.value) ?? null)
|
|
44
|
+
|
|
45
|
+
watch(field, (f) => {
|
|
46
|
+
operator.value = f?.operators[0] ?? null
|
|
47
|
+
value.value = ''
|
|
48
|
+
valueTo.value = ''
|
|
49
|
+
relationOptions.value = []
|
|
50
|
+
|
|
51
|
+
if (f?.type === 'belongsTo') searchRelation('')
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
const needsValue = computed(() => operator.value !== null && !['null', 'nnull'].includes(operator.value))
|
|
55
|
+
const list = computed(() => operator.value === 'in' || operator.value === 'nin')
|
|
56
|
+
|
|
57
|
+
const searchRelation = useDebounceFn(async (q: string) => {
|
|
58
|
+
if (!field.value) return
|
|
59
|
+
relationOptions.value = (await client.options(field.value.name, q)).data
|
|
60
|
+
}, 250)
|
|
61
|
+
|
|
62
|
+
watch(relationSearch, q => searchRelation(q ?? ''))
|
|
63
|
+
|
|
64
|
+
function inputType(f: VectorField): string {
|
|
65
|
+
if (f.type === 'date') return 'date'
|
|
66
|
+
if (f.type === 'datetime') return 'datetime-local'
|
|
67
|
+
if (['number', 'money', 'id'].includes(f.type)) return 'number'
|
|
68
|
+
return 'text'
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A money filter compares minor units. A fixed currency says how many
|
|
73
|
+
* decimals the typed amount has; one priced per record has no single
|
|
74
|
+
* answer, so it is read in hundredths.
|
|
75
|
+
*/
|
|
76
|
+
function moneyDigits(f: VectorField): number {
|
|
77
|
+
const currency = fieldProps(f).currency
|
|
78
|
+
return minorDigits(typeof currency === 'string' ? currency : null)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function add() {
|
|
82
|
+
if (!field.value || !operator.value) return
|
|
83
|
+
|
|
84
|
+
let v = value.value.trim()
|
|
85
|
+
|
|
86
|
+
if (field.value.type === 'money' && v !== '' && !list.value) {
|
|
87
|
+
v = String(Math.round(Number.parseFloat(v.replace(',', '.')) * 10 ** moneyDigits(field.value)))
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// a datetime typed in the browser's zone goes over as UTC, each bound of a `between` alike
|
|
91
|
+
const utc = (typed: string) => field.value?.type === 'datetime' && typed.includes('T') ? new Date(typed).toISOString() : typed
|
|
92
|
+
if (operator.value === 'between') v = `${utc(value.value.trim())},${utc(valueTo.value.trim())}`
|
|
93
|
+
else if (field.value.type === 'datetime' && v !== '' && !list.value) v = utc(v)
|
|
94
|
+
if (needsValue.value && v === '') return
|
|
95
|
+
|
|
96
|
+
const name = field.value.name
|
|
97
|
+
const condition = { [operator.value]: needsValue.value ? v : '' }
|
|
98
|
+
const where = target.value
|
|
99
|
+
|
|
100
|
+
if (where === null) {
|
|
101
|
+
model.value = { ...model.value, [name]: { ...(model.value[name] ?? {}), ...condition } }
|
|
102
|
+
} else if (where.group === -1) {
|
|
103
|
+
groups.value = [...groups.value, [{ [name]: condition }]]
|
|
104
|
+
} else {
|
|
105
|
+
groups.value = groups.value.map((group, g) => {
|
|
106
|
+
if (g !== where.group) return group
|
|
107
|
+
if (where.alternative === -1) return [...group, { [name]: condition }]
|
|
108
|
+
return group.map((alternative, a) => (a === where.alternative ? { ...alternative, [name]: { ...(alternative[name] ?? {}), ...condition } } : alternative))
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
adding.value = false
|
|
113
|
+
fieldName.value = null
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function removeFromGroup(g: number, a: number, name: string, op: string) {
|
|
117
|
+
groups.value = groups.value
|
|
118
|
+
.map((group, gi) => gi !== g
|
|
119
|
+
? group
|
|
120
|
+
: group.map((alternative, ai) => {
|
|
121
|
+
if (ai !== a) return alternative
|
|
122
|
+
const next = { ...alternative }
|
|
123
|
+
const ops = { ...(next[name] ?? {}) }
|
|
124
|
+
delete ops[op as VectorOperator]
|
|
125
|
+
if (Object.keys(ops).length === 0) delete next[name]
|
|
126
|
+
else next[name] = ops
|
|
127
|
+
return next
|
|
128
|
+
}).filter(alternative => Object.keys(alternative).length > 0))
|
|
129
|
+
.filter(group => group.length > 0)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const chipsOf = (conditions: VectorFilters) => Object.entries(conditions).flatMap(([name, ops]) => Object.entries(ops).map(([op, v]) => ({ name, op, value: v ?? '' })))
|
|
133
|
+
|
|
134
|
+
function clearAll() {
|
|
135
|
+
model.value = {}
|
|
136
|
+
groups.value = []
|
|
137
|
+
advanced.value = []
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function remove(name: string, op: string) {
|
|
141
|
+
const next = { ...model.value }
|
|
142
|
+
const ops = { ...(next[name] ?? {}) }
|
|
143
|
+
delete ops[op as VectorOperator]
|
|
144
|
+
|
|
145
|
+
if (Object.keys(ops).length === 0) delete next[name]
|
|
146
|
+
else next[name] = ops
|
|
147
|
+
|
|
148
|
+
model.value = next
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function chipText(name: string, op: string, v: string): string {
|
|
152
|
+
const f = props.resource.fields.find(x => x.name === name)
|
|
153
|
+
const label = f?.label ?? name
|
|
154
|
+
const opLabel = operatorLabel(op)
|
|
155
|
+
|
|
156
|
+
if (!f || op === 'null' || op === 'nnull') return t('list.filter.chipNoValue', { field: label, operator: opLabel })
|
|
157
|
+
|
|
158
|
+
let shown = v
|
|
159
|
+
|
|
160
|
+
if (f.type === 'enum') shown = v.split(',').map(x => f.options?.find(o => String(o.value) === x)?.label ?? x).join(', ')
|
|
161
|
+
if (f.type === 'currency') shown = v.split(',').map(x => currencyLabel(x, locale.value)).join(', ')
|
|
162
|
+
if (f.type === 'boolean') shown = v === 'true' || v === '1' ? t('list.filter.yes') : t('list.filter.no')
|
|
163
|
+
if (f.type === 'money' && !['in', 'nin', 'between'].includes(op)) shown = (Number(v) / 10 ** moneyDigits(f)).toFixed(moneyDigits(f))
|
|
164
|
+
if (op === 'between') shown = v.replace(',', ` ${t('list.filter.and')} `)
|
|
165
|
+
|
|
166
|
+
return t('list.filter.chip', { field: label, operator: opLabel, value: shown })
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
const active = computed(() => chipsOf(model.value))
|
|
170
|
+
const anything = computed(() => active.value.length > 0 || groups.value.length > 0 || advanced.value.length > 0)
|
|
171
|
+
</script>
|
|
172
|
+
|
|
173
|
+
<template>
|
|
174
|
+
<div class="d-flex align-center flex-wrap ga-2">
|
|
175
|
+
<v-chip v-for="chip in active" :key="`${chip.name}.${chip.op}`" closable size="small" variant="tonal" color="primary" @click:close="remove(chip.name, chip.op)">
|
|
176
|
+
{{ chipText(chip.name, chip.op, chip.value) }}
|
|
177
|
+
</v-chip>
|
|
178
|
+
<div
|
|
179
|
+
v-for="(group, g) in groups"
|
|
180
|
+
:key="`group-${g}`"
|
|
181
|
+
class="d-inline-flex align-center flex-wrap ga-1 px-2 py-1 rounded border"
|
|
182
|
+
role="group"
|
|
183
|
+
:aria-label="t('list.filter.anyOfGroup', { n: g + 1 })"
|
|
184
|
+
>
|
|
185
|
+
<span class="text-caption text-medium-emphasis">{{ t('list.filter.anyOf') }}</span>
|
|
186
|
+
<template v-for="(alternative, a) in group" :key="`alternative-${g}-${a}`">
|
|
187
|
+
<span v-if="a > 0" class="text-caption font-weight-medium">{{ t('list.filter.or') }}</span>
|
|
188
|
+
<span class="d-inline-flex align-center ga-1" role="group" :aria-label="t('list.filter.alternative', { n: a + 1 })">
|
|
189
|
+
<v-chip v-for="chip in chipsOf(alternative)" :key="`${chip.name}.${chip.op}`" closable size="small" variant="tonal" color="primary" @click:close="removeFromGroup(g, a, chip.name, chip.op)">
|
|
190
|
+
{{ chipText(chip.name, chip.op, chip.value) }}
|
|
191
|
+
</v-chip>
|
|
192
|
+
<v-btn icon="mdi-plus" size="x-small" variant="text" :aria-label="t('list.filter.addToAlternative', { n: a + 1 })" @click="openAdd({ group: g, alternative: a }, $event)" />
|
|
193
|
+
</span>
|
|
194
|
+
</template>
|
|
195
|
+
<v-btn size="x-small" variant="text" prepend-icon="mdi-plus" @click="openAdd({ group: g, alternative: -1 }, $event)">{{ t('list.filter.orCondition') }}</v-btn>
|
|
196
|
+
<v-btn icon="mdi-close" size="x-small" variant="text" :aria-label="t('list.filter.removeGroup', { n: g + 1 })" @click="groups = groups.filter((_, i) => i !== g)" />
|
|
197
|
+
</div>
|
|
198
|
+
<v-chip v-for="(node, i) in advanced" :key="`advanced-${i}`" closable size="small" variant="outlined" prepend-icon="mdi-code-braces" :title="JSON.stringify(node)" @click:close="advanced = advanced.filter((_, j) => j !== i)">
|
|
199
|
+
{{ t('list.filter.advanced') }}
|
|
200
|
+
</v-chip>
|
|
201
|
+
<v-btn size="small" variant="text" prepend-icon="mdi-filter-variant-plus" :disabled="filterable.length === 0" @click="openAdd(null, $event)">{{ t('list.filter.add') }}</v-btn>
|
|
202
|
+
<v-btn size="small" variant="text" prepend-icon="mdi-set-merge" :disabled="filterable.length === 0" @click="openAdd({ group: -1, alternative: -1 }, $event)">{{ t('list.filter.addAnyOf') }}</v-btn>
|
|
203
|
+
<v-menu v-model="adding" :activator="menuActivator" :close-on-content-click="false" location="bottom start">
|
|
204
|
+
<v-card min-width="360" class="pa-4">
|
|
205
|
+
<v-select v-model="fieldName" :label="t('list.field')" :items="filterable" item-title="label" item-value="name" density="compact" hide-details class="mb-3" />
|
|
206
|
+
<v-select v-if="field" v-model="operator" :label="t('list.filter.condition')" :items="field.operators.map(op => ({ title: operatorLabel(op), value: op }))" density="compact" hide-details class="mb-3" />
|
|
207
|
+
<template v-if="field && needsValue">
|
|
208
|
+
<v-select v-if="field.type === 'enum' || field.type === 'currency'" v-model="value" :label="t('list.filter.value')" :items="field.type === 'currency' ? (field.options ?? []).map(o => ({ value: o.value, label: currencyLabel(String(o.value), locale) })) : (field.options ?? [])" item-title="label" item-value="value" density="compact" hide-details class="mb-3" :multiple="list" @update:model-value="(v: unknown) => value = Array.isArray(v) ? v.join(',') : String(v ?? '')" />
|
|
209
|
+
<v-select v-else-if="field.type === 'boolean'" v-model="value" :label="t('list.filter.value')" :items="[{ title: t('common.yes'), value: 'true' }, { title: t('common.no'), value: 'false' }]" density="compact" hide-details class="mb-3" />
|
|
210
|
+
<v-autocomplete v-else-if="field.type === 'belongsTo'" v-model:search="relationSearch" :label="t('list.filter.value')" :items="relationOptions" item-title="label" item-value="id" density="compact" hide-details class="mb-3" :multiple="list" no-filter @update:model-value="(v: unknown) => value = Array.isArray(v) ? v.join(',') : String(v ?? '')" />
|
|
211
|
+
<template v-else>
|
|
212
|
+
<v-text-field v-model="value" :label="operator === 'between' ? t('list.filter.from') : t('list.filter.value')" :type="list ? 'text' : inputType(field)" :hint="list ? t('list.filter.commaSeparated') : undefined" density="compact" hide-details class="mb-3" @keyup.enter="add" />
|
|
213
|
+
<v-text-field v-if="operator === 'between'" v-model="valueTo" :label="t('list.filter.to')" :type="inputType(field)" density="compact" hide-details class="mb-3" @keyup.enter="add" />
|
|
214
|
+
</template>
|
|
215
|
+
</template>
|
|
216
|
+
<div class="d-flex justify-end ga-2">
|
|
217
|
+
<v-btn variant="text" size="small" @click="adding = false">{{ t('common.cancel') }}</v-btn>
|
|
218
|
+
<v-btn color="primary" size="small" :disabled="!field || !operator" @click="add">{{ t('common.apply') }}</v-btn>
|
|
219
|
+
</div>
|
|
220
|
+
</v-card>
|
|
221
|
+
</v-menu>
|
|
222
|
+
<v-btn v-if="anything" size="small" variant="text" @click="clearAll">{{ t('list.filter.clear') }}</v-btn>
|
|
223
|
+
</div>
|
|
224
|
+
</template>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorListState, VectorResource, VectorSavedView } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// Saved views: pick one, save the current list as a new one, update or
|
|
5
|
+
// delete your own, share, set your default — or, where the policy allows,
|
|
6
|
+
// the default of everyone who has none of their own.
|
|
7
|
+
const props = defineProps<{ resource: VectorResource, current: VectorListState, activeId: number | null }>()
|
|
8
|
+
const emit = defineEmits<{ apply: [view: VectorSavedView | null], 'update:activeId': [id: number | null] }>()
|
|
9
|
+
|
|
10
|
+
const api = useVectorViews(() => props.resource.slug)
|
|
11
|
+
const { t } = useVectorT()
|
|
12
|
+
const notifications = useVectorNotifications()
|
|
13
|
+
const { confirm } = useConfirm()
|
|
14
|
+
const views = ref<VectorSavedView[]>([])
|
|
15
|
+
const loading = ref(false)
|
|
16
|
+
|
|
17
|
+
async function load() {
|
|
18
|
+
loading.value = true
|
|
19
|
+
try {
|
|
20
|
+
views.value = await api.list()
|
|
21
|
+
} finally {
|
|
22
|
+
loading.value = false
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
defineExpose({ views, load })
|
|
27
|
+
await load()
|
|
28
|
+
|
|
29
|
+
const saving = ref(false)
|
|
30
|
+
const name = ref('')
|
|
31
|
+
const shared = ref(false)
|
|
32
|
+
const isDefault = ref(false)
|
|
33
|
+
const resourceDefault = ref(false)
|
|
34
|
+
const mayDefaultForEveryone = computed(() => props.resource.permissions.setDefaultView === true)
|
|
35
|
+
const editing = ref<VectorSavedView | null>(null)
|
|
36
|
+
watch(shared, (on) => { if (!on) resourceDefault.value = false })
|
|
37
|
+
|
|
38
|
+
function openSave(view: VectorSavedView | null) {
|
|
39
|
+
editing.value = view
|
|
40
|
+
name.value = view?.name ?? ''
|
|
41
|
+
shared.value = view?.shared ?? false
|
|
42
|
+
isDefault.value = view?.isDefault ?? false
|
|
43
|
+
resourceDefault.value = view?.resourceDefault ?? false
|
|
44
|
+
saving.value = true
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function save() {
|
|
48
|
+
if (name.value.trim() === '') return
|
|
49
|
+
|
|
50
|
+
try {
|
|
51
|
+
const view = editing.value
|
|
52
|
+
? await api.update(editing.value.id, name.value.trim(), props.current, shared.value, isDefault.value, resourceDefault.value)
|
|
53
|
+
: await api.create(name.value.trim(), props.current, shared.value, isDefault.value, resourceDefault.value)
|
|
54
|
+
notifications.success(t('list.views.saved', { name: view.name }))
|
|
55
|
+
saving.value = false
|
|
56
|
+
await load()
|
|
57
|
+
emit('update:activeId', view.id)
|
|
58
|
+
} catch (error) {
|
|
59
|
+
notifications.error(vectorErrorMessage(error))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function remove(view: VectorSavedView) {
|
|
64
|
+
if (!await confirm({ title: t('list.views.deleteTitle', { name: view.name }), confirmText: t('common.delete'), destructive: true })) return
|
|
65
|
+
|
|
66
|
+
await api.remove(view.id)
|
|
67
|
+
await load()
|
|
68
|
+
|
|
69
|
+
if (props.activeId === view.id) emit('apply', null)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const active = computed(() => views.value.find(v => v.id === props.activeId) ?? null)
|
|
73
|
+
</script>
|
|
74
|
+
|
|
75
|
+
<template>
|
|
76
|
+
<v-menu>
|
|
77
|
+
<template #activator="{ props: activator }">
|
|
78
|
+
<v-btn v-bind="activator" size="small" variant="tonal" prepend-icon="mdi-view-list-outline" append-icon="mdi-menu-down">
|
|
79
|
+
{{ active?.name ?? t('list.views.menu') }}
|
|
80
|
+
</v-btn>
|
|
81
|
+
</template>
|
|
82
|
+
<v-list density="compact" min-width="260">
|
|
83
|
+
<v-list-item :title="t('list.views.allRecords')" :active="activeId === null" prepend-icon="mdi-table" @click="emit('apply', null)" />
|
|
84
|
+
<v-divider v-if="views.length" />
|
|
85
|
+
<v-list-item v-for="view in views" :key="view.id" :title="view.name" :active="view.id === activeId" :prepend-icon="view.shared ? 'mdi-account-group-outline' : 'mdi-account-outline'" @click="emit('apply', view)">
|
|
86
|
+
<template #subtitle>
|
|
87
|
+
<span v-if="view.isDefault">{{ t('list.views.default') }}</span>
|
|
88
|
+
<span v-else-if="view.resourceDefault">{{ t('list.views.everyoneDefault') }}</span>
|
|
89
|
+
</template>
|
|
90
|
+
<template v-if="view.mine" #append>
|
|
91
|
+
<v-btn icon="mdi-pencil-outline" size="x-small" variant="text" :aria-label="t('list.views.updateWithCurrent')" @click.stop="openSave(view)" />
|
|
92
|
+
<v-btn icon="mdi-delete-outline" size="x-small" variant="text" :aria-label="t('list.views.delete')" @click.stop="remove(view)" />
|
|
93
|
+
</template>
|
|
94
|
+
</v-list-item>
|
|
95
|
+
<v-divider />
|
|
96
|
+
<v-list-item :title="t('list.views.saveCurrent')" prepend-icon="mdi-content-save-plus-outline" @click="openSave(null)" />
|
|
97
|
+
</v-list>
|
|
98
|
+
</v-menu>
|
|
99
|
+
|
|
100
|
+
<v-dialog v-model="saving" max-width="420">
|
|
101
|
+
<v-card :title="editing ? t('list.views.updateTitle', { name: editing.name }) : t('list.views.saveTitle')">
|
|
102
|
+
<v-card-text>
|
|
103
|
+
<v-text-field v-model="name" :label="t('list.views.name')" autofocus :rules="[(v: string) => v.trim() !== '' || t('common.required')]" @keyup.enter="save" />
|
|
104
|
+
<v-switch v-model="shared" :label="t('list.views.share')" color="primary" hide-details />
|
|
105
|
+
<v-switch v-model="isDefault" :label="t('list.views.openWith')" color="primary" hide-details />
|
|
106
|
+
<v-switch v-if="mayDefaultForEveryone" v-model="resourceDefault" :disabled="!shared" :label="t('list.views.openWithForEveryone')" :hint="shared ? undefined : t('list.views.shareFirst')" persistent-hint color="primary" />
|
|
107
|
+
</v-card-text>
|
|
108
|
+
<v-card-actions>
|
|
109
|
+
<v-spacer />
|
|
110
|
+
<v-btn variant="text" @click="saving = false">{{ t('common.cancel') }}</v-btn>
|
|
111
|
+
<v-btn color="primary" @click="save">{{ t('common.save') }}</v-btn>
|
|
112
|
+
</v-card-actions>
|
|
113
|
+
</v-card>
|
|
114
|
+
</v-dialog>
|
|
115
|
+
</template>
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// Transient toasts. Anything in the layer pushes through
|
|
3
|
+
// useVectorNotifications(); Vuetify's queue shows them one at a time.
|
|
4
|
+
const { queue } = useVectorNotifications()
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<v-snackbar-queue v-model="queue" :timeout="4000" closable location="bottom right" />
|
|
9
|
+
</template>
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorAuditEntry, VectorResource } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// The audit trail of one record: who changed what, when, as whom. An
|
|
5
|
+
// update (or a revert) can be reverted by whoever may edit the record.
|
|
6
|
+
const props = defineProps<{ resource: VectorResource, id: string | number, canUpdate?: boolean }>()
|
|
7
|
+
const emit = defineEmits<{ reverted: [] }>()
|
|
8
|
+
const { t } = useVectorT()
|
|
9
|
+
const { confirm } = useConfirm()
|
|
10
|
+
const notifications = useVectorNotifications()
|
|
11
|
+
const client = useVectorResource(() => props.resource.slug)
|
|
12
|
+
const { data, status, error, refresh } = await useAsyncData(() => `vector.audit.${props.resource.slug}.${props.id}`, () => client.audit(props.id))
|
|
13
|
+
const entries = computed<VectorAuditEntry[]>(() => data.value?.data ?? [])
|
|
14
|
+
|
|
15
|
+
const label = (name: string) => props.resource.fields.find(f => f.name === name)?.label ?? name
|
|
16
|
+
const show = (value: unknown) => value === null || value === undefined ? t('common.none') : typeof value === 'object' ? JSON.stringify(value) : String(value)
|
|
17
|
+
const icon: Record<string, string> = { create: 'mdi-plus-circle-outline', update: 'mdi-pencil-outline', revert: 'mdi-undo-variant', delete: 'mdi-delete-outline', restore: 'mdi-restore', forceDelete: 'mdi-delete-forever-outline' }
|
|
18
|
+
|
|
19
|
+
/** The fields of an entry a revert would put back: shown, and editable on update. */
|
|
20
|
+
function revertableFields(entry: VectorAuditEntry): string[] {
|
|
21
|
+
if (!props.canUpdate || !['update', 'revert'].includes(entry.event)) return []
|
|
22
|
+
return Object.keys(entry.changes).filter((name) => {
|
|
23
|
+
const field = props.resource.fields.find(f => f.name === name)
|
|
24
|
+
return field !== undefined && !field.readonly && field.views.includes('update') && !['id', 'hasOne', 'hasMany', 'manyToMany', 'password'].includes(field.type)
|
|
25
|
+
})
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const reverting = ref<number | null>(null)
|
|
29
|
+
|
|
30
|
+
async function revert(entry: VectorAuditEntry) {
|
|
31
|
+
const fields = revertableFields(entry).map(label).join(', ')
|
|
32
|
+
if (!await confirm({ title: t('record.audit.revertTitle'), text: t('record.audit.revertText', { fields }), confirmText: t('record.audit.revert') })) return
|
|
33
|
+
|
|
34
|
+
reverting.value = entry.id
|
|
35
|
+
try {
|
|
36
|
+
await client.revert(props.id, entry.id)
|
|
37
|
+
notifications.success(t('record.audit.reverted'))
|
|
38
|
+
await refresh()
|
|
39
|
+
emit('reverted')
|
|
40
|
+
} catch (failure) {
|
|
41
|
+
notifications.error(vectorErrorMessage(failure))
|
|
42
|
+
} finally {
|
|
43
|
+
reverting.value = null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
</script>
|
|
47
|
+
|
|
48
|
+
<template>
|
|
49
|
+
<v-card class="mt-4">
|
|
50
|
+
<v-card-title class="text-subtitle-1">{{ t('record.audit.title') }}</v-card-title>
|
|
51
|
+
<v-card-text>
|
|
52
|
+
<v-skeleton-loader v-if="status === 'pending'" type="list-item-two-line" />
|
|
53
|
+
<v-alert v-else-if="error" type="error" variant="tonal" density="compact" :text="vectorErrorMessage(error)" />
|
|
54
|
+
<v-timeline v-else-if="entries.length" side="end" density="compact" align="start">
|
|
55
|
+
<v-timeline-item v-for="entry in entries" :key="entry.id" :icon="icon[entry.event] ?? 'mdi-play-circle-outline'" dot-color="primary" size="small">
|
|
56
|
+
<div class="text-body-2">
|
|
57
|
+
<strong>{{ entry.event }}</strong> {{ t('record.audit.byUser', { actor: entry.actorId }) }}<span v-if="entry.impersonatorId"> {{ t('record.audit.impersonatedBy', { impersonator: entry.impersonatorId }) }}</span>
|
|
58
|
+
<span class="text-medium-emphasis"> · {{ formatDateTime(entry.createdAt) }}</span>
|
|
59
|
+
<v-btn
|
|
60
|
+
v-if="revertableFields(entry).length"
|
|
61
|
+
size="x-small"
|
|
62
|
+
variant="text"
|
|
63
|
+
prepend-icon="mdi-undo-variant"
|
|
64
|
+
class="ms-2"
|
|
65
|
+
:loading="reverting === entry.id"
|
|
66
|
+
:aria-label="t('record.audit.revertLabel')"
|
|
67
|
+
@click="revert(entry)"
|
|
68
|
+
>{{ t('record.audit.revert') }}</v-btn>
|
|
69
|
+
</div>
|
|
70
|
+
<ul v-if="Object.keys(entry.changes).length" class="text-caption mt-1 ps-4">
|
|
71
|
+
<li v-for="(change, field) in entry.changes" :key="field">
|
|
72
|
+
<span class="text-medium-emphasis">{{ label(String(field)) }}:</span>
|
|
73
|
+
<template v-if="entry.event === 'create'"> {{ show(change) }}</template>
|
|
74
|
+
<template v-else> {{ show(change.from) }} → {{ show(change.to) }}</template>
|
|
75
|
+
</li>
|
|
76
|
+
</ul>
|
|
77
|
+
</v-timeline-item>
|
|
78
|
+
</v-timeline>
|
|
79
|
+
<p v-else class="text-medium-emphasis text-body-2">{{ t('record.audit.empty') }}</p>
|
|
80
|
+
</v-card-text>
|
|
81
|
+
</v-card>
|
|
82
|
+
</template>
|