@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,21 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorField } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
|
|
5
|
+
const model = defineModel<string | null>()
|
|
6
|
+
const type = computed(() => ({ email: 'email', url: 'url', password: 'password' } as Record<string, string>)[props.field.type] ?? 'text')
|
|
7
|
+
</script>
|
|
8
|
+
|
|
9
|
+
<template>
|
|
10
|
+
<v-text-field
|
|
11
|
+
v-model="model"
|
|
12
|
+
:label="field.label"
|
|
13
|
+
:type="type"
|
|
14
|
+
:rules="rules"
|
|
15
|
+
:error-messages="errorMessages"
|
|
16
|
+
:hint="field.help ?? undefined"
|
|
17
|
+
:persistent-hint="!!field.help"
|
|
18
|
+
:clearable="field.nullable === true"
|
|
19
|
+
:prepend-inner-icon="field.type === 'color' ? 'mdi-palette' : undefined"
|
|
20
|
+
/>
|
|
21
|
+
</template>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorField } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
|
|
5
|
+
const model = defineModel<string | null>()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<v-textarea
|
|
10
|
+
v-model="model"
|
|
11
|
+
:label="field.label"
|
|
12
|
+
:rules="rules"
|
|
13
|
+
:error-messages="errorMessages"
|
|
14
|
+
:hint="field.help ?? undefined"
|
|
15
|
+
:persistent-hint="!!field.help"
|
|
16
|
+
:class="{ 'font-monospace': props.field.type === 'json' }"
|
|
17
|
+
auto-grow
|
|
18
|
+
rows="3"
|
|
19
|
+
/>
|
|
20
|
+
</template>
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorField, VectorUploaded } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// A file or image field: pick a file, it uploads at once, and the field
|
|
5
|
+
// takes the key the server issued. The server detects the format from the
|
|
6
|
+
// bytes and enforces the size; accept and the size here only guide the
|
|
7
|
+
// picker. Without upload support the key is edited as text.
|
|
8
|
+
const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
|
|
9
|
+
const model = defineModel<string | null>()
|
|
10
|
+
|
|
11
|
+
const { t } = useVectorT()
|
|
12
|
+
const { schema, resource: find } = useVectorSchema()
|
|
13
|
+
const client = useVectorResource(() => props.resource ?? '')
|
|
14
|
+
const owner = computed(() => find(props.resource ?? ''))
|
|
15
|
+
// The rules read the field's value (the key), not the picked file.
|
|
16
|
+
const keyRules = computed(() => props.rules.map(rule => () => rule(model.value)))
|
|
17
|
+
const uploads = computed(() => schema.value?.capabilities.uploads === true)
|
|
18
|
+
const fieldProps = computed(() => (Array.isArray(props.field.props) ? {} : props.field.props) as { accept?: Record<string, string>, maxBytes?: number })
|
|
19
|
+
const accept = computed(() => Object.keys(fieldProps.value.accept ?? {}).join(','))
|
|
20
|
+
const formats = computed(() => [...new Set(Object.values(fieldProps.value.accept ?? {}))].join(', '))
|
|
21
|
+
const maxMb = computed(() => fieldProps.value.maxBytes ? Math.round(fieldProps.value.maxBytes / 100_000) / 10 : null)
|
|
22
|
+
const image = computed(() => props.field.type === 'image')
|
|
23
|
+
|
|
24
|
+
const uploading = ref(false)
|
|
25
|
+
const uploadError = ref<string | null>(null)
|
|
26
|
+
const picked = ref<VectorUploaded | null>(null)
|
|
27
|
+
const preview = ref<string | null>(null)
|
|
28
|
+
const stored = computed(() => typeof props.record?.[props.field.name] === 'string' ? props.record![props.field.name] as string : null)
|
|
29
|
+
|
|
30
|
+
async function choose(files: File | File[] | null) {
|
|
31
|
+
const file = Array.isArray(files) ? files[0] : files
|
|
32
|
+
if (!file) return
|
|
33
|
+
uploadError.value = null
|
|
34
|
+
|
|
35
|
+
if (fieldProps.value.maxBytes && file.size > fieldProps.value.maxBytes) {
|
|
36
|
+
uploadError.value = t('fields.upload.tooLarge', { size: maxMb.value })
|
|
37
|
+
return
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
uploading.value = true
|
|
41
|
+
try {
|
|
42
|
+
picked.value = await client.upload(props.field.name, file)
|
|
43
|
+
model.value = picked.value.key
|
|
44
|
+
preview.value = image.value ? await dataUrl(file) : null
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const failure = error as { data?: { errors?: { message: string }[] } } | null
|
|
47
|
+
uploadError.value = failure?.data?.errors?.[0]?.message ? t('fields.upload.rejected', { message: failure.data.errors[0].message }) : vectorErrorMessage(error)
|
|
48
|
+
} finally {
|
|
49
|
+
uploading.value = false
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function clear() {
|
|
54
|
+
model.value = null
|
|
55
|
+
picked.value = null
|
|
56
|
+
preview.value = null
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
</script>
|
|
60
|
+
|
|
61
|
+
<template>
|
|
62
|
+
<v-text-field
|
|
63
|
+
v-if="!uploads"
|
|
64
|
+
v-model="model"
|
|
65
|
+
:label="field.label"
|
|
66
|
+
:rules="rules"
|
|
67
|
+
:error-messages="errorMessages"
|
|
68
|
+
:hint="field.help ?? t('fields.upload.storageKey')"
|
|
69
|
+
persistent-hint
|
|
70
|
+
:clearable="field.nullable === true"
|
|
71
|
+
/>
|
|
72
|
+
<div v-else class="d-flex align-center ga-3">
|
|
73
|
+
<v-img v-if="preview" :src="preview" width="56" height="56" cover class="rounded flex-grow-0" :alt="t('fields.upload.preview', { label: field.label })" />
|
|
74
|
+
<VxCell v-else-if="model && model === stored && record && owner" :field="field" :value="model" :record="record" :resource="owner" context="index" />
|
|
75
|
+
<v-file-input
|
|
76
|
+
:label="field.label"
|
|
77
|
+
:accept="accept || undefined"
|
|
78
|
+
:loading="uploading"
|
|
79
|
+
:rules="keyRules"
|
|
80
|
+
:error-messages="uploadError ? [uploadError, ...errorMessages] : errorMessages"
|
|
81
|
+
:hint="picked ? t('fields.upload.uploaded', { name: picked.name ?? t('fields.upload.theFile'), size: Math.max(1, Math.round(picked.size / 1000)) }) : (field.help ?? [formats, maxMb ? t('fields.upload.upTo', { size: maxMb }) : ''].filter(Boolean).join(' · '))"
|
|
82
|
+
persistent-hint
|
|
83
|
+
:prepend-icon="image ? 'mdi-image-outline' : 'mdi-paperclip'"
|
|
84
|
+
:clearable="false"
|
|
85
|
+
@update:model-value="choose"
|
|
86
|
+
/>
|
|
87
|
+
<v-btn v-if="model && field.nullable === true" icon="mdi-close" variant="text" size="small" :aria-label="t('fields.upload.remove', { label: field.label })" @click="clear" />
|
|
88
|
+
</div>
|
|
89
|
+
</template>
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorLayoutNode } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// A resource's layout (sections, tabs, column spans) around the fields
|
|
5
|
+
// the owner renders through the `field` slot: the record form's inputs,
|
|
6
|
+
// the show page's values. Layout only groups — it never decides what is
|
|
7
|
+
// edited or shown; `shown` does, per field. A section or tab none of
|
|
8
|
+
// whose fields is shown is not drawn. A field with an error opens the
|
|
9
|
+
// section holding it and activates its tab, which carries a badge.
|
|
10
|
+
const props = withDefaults(defineProps<{
|
|
11
|
+
nodes: VectorLayoutNode[]
|
|
12
|
+
/** The grid columns at this level. */
|
|
13
|
+
columns?: number
|
|
14
|
+
shown: (name: string) => boolean
|
|
15
|
+
/** Names of the fields holding an error. */
|
|
16
|
+
errors?: string[]
|
|
17
|
+
/** A field's width when its node does not say. */
|
|
18
|
+
defaultSpan: (name: string) => 1 | 2 | 'full'
|
|
19
|
+
}>(), { columns: 2, errors: () => [] })
|
|
20
|
+
|
|
21
|
+
// the slot prop is `field`, not `name`: on <slot>, `name` picks which slot renders
|
|
22
|
+
defineSlots<{ field(props: { field: string }): unknown }>()
|
|
23
|
+
|
|
24
|
+
function names(node: VectorLayoutNode): string[] {
|
|
25
|
+
return node.kind === 'field' ? [node.name!] : (node.children ?? []).flatMap(names)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const visible = (node: VectorLayoutNode) => names(node).some(name => props.shown(name))
|
|
29
|
+
const hasError = (node: VectorLayoutNode) => names(node).some(name => props.errors.includes(name))
|
|
30
|
+
const visibleNodes = computed(() => props.nodes.map((node, index) => ({ node, key: String(index) })).filter(({ node }) => visible(node)))
|
|
31
|
+
|
|
32
|
+
/** The md width of a field node on this level's grid. */
|
|
33
|
+
function width(node: VectorLayoutNode): number {
|
|
34
|
+
const span = node.span ?? props.defaultSpan(node.name!)
|
|
35
|
+
if (span === 'full') return 12
|
|
36
|
+
return Math.round((12 * Math.min(span, props.columns)) / props.columns)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// Sections open unless declared collapsed; tabs start on their first visible tab.
|
|
40
|
+
const open = reactive<Record<string, boolean>>(Object.fromEntries(props.nodes.map((node, i) => [String(i), !node.collapsed])))
|
|
41
|
+
const active = reactive<Record<string, string | undefined>>({})
|
|
42
|
+
const tabsOf = (node: VectorLayoutNode) => (node.children ?? []).filter(visible)
|
|
43
|
+
|
|
44
|
+
// An error inside a closed section opens it; one in another tab switches to it.
|
|
45
|
+
watch(() => props.errors, () => {
|
|
46
|
+
for (const { node, key } of visibleNodes.value) {
|
|
47
|
+
if (node.kind === 'section' && hasError(node)) open[key] = true
|
|
48
|
+
|
|
49
|
+
if (node.kind === 'tabs') {
|
|
50
|
+
const tabs = tabsOf(node)
|
|
51
|
+
const current = tabs.find(tab => tab.label === (active[key] ?? tabs[0]?.label))
|
|
52
|
+
const failing = tabs.find(hasError)
|
|
53
|
+
if (failing && (!current || !hasError(current))) active[key] = failing.label ?? undefined
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}, { deep: true, immediate: true })
|
|
57
|
+
</script>
|
|
58
|
+
|
|
59
|
+
<template>
|
|
60
|
+
<v-row density="compact">
|
|
61
|
+
<template v-for="{ node, key } in visibleNodes" :key="key">
|
|
62
|
+
<v-col v-if="node.kind === 'field'" cols="12" :md="width(node)" :data-layout-field="node.name">
|
|
63
|
+
<slot name="field" :field="node.name!" />
|
|
64
|
+
</v-col>
|
|
65
|
+
|
|
66
|
+
<v-col v-else-if="node.kind === 'section'" cols="12" class="vx-layout-section" :data-layout-section="node.label">
|
|
67
|
+
<v-expansion-panels v-if="node.collapsible" :model-value="open[key] ? [0] : []" variant="accordion" @update:model-value="(v: unknown) => open[key] = Array.isArray(v) && v.length > 0">
|
|
68
|
+
<v-expansion-panel :value="0">
|
|
69
|
+
<v-expansion-panel-title>
|
|
70
|
+
<span class="font-weight-medium">{{ node.label }}</span>
|
|
71
|
+
<v-icon v-if="hasError(node)" icon="mdi-alert-circle" color="error" size="small" class="ms-2" />
|
|
72
|
+
</v-expansion-panel-title>
|
|
73
|
+
<v-expansion-panel-text eager>
|
|
74
|
+
<p v-if="node.description" class="text-body-2 text-medium-emphasis mb-4">{{ node.description }}</p>
|
|
75
|
+
<VxLayout :nodes="node.children ?? []" :columns="node.columns ?? 2" :shown="shown" :errors="errors" :default-span="defaultSpan">
|
|
76
|
+
<template #field="{ field }"><slot name="field" :field="field" /></template>
|
|
77
|
+
</VxLayout>
|
|
78
|
+
</v-expansion-panel-text>
|
|
79
|
+
</v-expansion-panel>
|
|
80
|
+
</v-expansion-panels>
|
|
81
|
+
<template v-else>
|
|
82
|
+
<div class="text-subtitle-1 font-weight-medium mt-2" :class="node.description ? 'mb-1' : 'mb-4'">{{ node.label }}</div>
|
|
83
|
+
<p v-if="node.description" class="text-body-2 text-medium-emphasis mb-4">{{ node.description }}</p>
|
|
84
|
+
<VxLayout :nodes="node.children ?? []" :columns="node.columns ?? 2" :shown="shown" :errors="errors" :default-span="defaultSpan">
|
|
85
|
+
<template #field="{ field }"><slot name="field" :field="field" /></template>
|
|
86
|
+
</VxLayout>
|
|
87
|
+
</template>
|
|
88
|
+
</v-col>
|
|
89
|
+
|
|
90
|
+
<v-col v-else-if="node.kind === 'tabs'" cols="12" class="vx-layout-tabs">
|
|
91
|
+
<div v-if="node.label" class="text-subtitle-1 font-weight-medium mt-2">{{ node.label }}</div>
|
|
92
|
+
<v-tabs :model-value="active[key] ?? tabsOf(node)[0]?.label ?? undefined" density="compact" color="primary" @update:model-value="(v: unknown) => active[key] = String(v)">
|
|
93
|
+
<v-tab v-for="tab in tabsOf(node)" :key="tab.label ?? ''" :value="tab.label" :data-layout-tab="tab.label">
|
|
94
|
+
{{ tab.label }}
|
|
95
|
+
<v-badge v-if="hasError(tab)" dot inline color="error" class="ms-1" />
|
|
96
|
+
</v-tab>
|
|
97
|
+
</v-tabs>
|
|
98
|
+
<v-divider class="mb-2" />
|
|
99
|
+
<!-- eager: an inactive tab's inputs stay mounted, so the form validates and keeps them -->
|
|
100
|
+
<v-tabs-window :model-value="active[key] ?? tabsOf(node)[0]?.label ?? undefined">
|
|
101
|
+
<!-- padded: the window clips its content, and an outlined field's label sits above its border -->
|
|
102
|
+
<v-tabs-window-item v-for="tab in tabsOf(node)" :key="tab.label ?? ''" :value="tab.label" eager class="pt-3">
|
|
103
|
+
<VxLayout :nodes="tab.children ?? []" :columns="tab.columns ?? 2" :shown="shown" :errors="errors" :default-span="defaultSpan">
|
|
104
|
+
<template #field="{ field }"><slot name="field" :field="field" /></template>
|
|
105
|
+
</VxLayout>
|
|
106
|
+
</v-tabs-window-item>
|
|
107
|
+
</v-tabs-window>
|
|
108
|
+
</v-col>
|
|
109
|
+
</template>
|
|
110
|
+
</v-row>
|
|
111
|
+
</template>
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorAction, VectorResource } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// What a selection can do: the built-in edit, delete, restore and
|
|
5
|
+
// force-delete of the resource and every index action. "All matching" widens the
|
|
6
|
+
// selection from the checked rows to every record the list matches.
|
|
7
|
+
defineProps<{
|
|
8
|
+
resource: VectorResource
|
|
9
|
+
selected: number
|
|
10
|
+
total: number
|
|
11
|
+
allMatching: boolean
|
|
12
|
+
trashedOnly: boolean
|
|
13
|
+
actions: VectorAction[]
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits<{
|
|
17
|
+
'update:allMatching': [value: boolean]
|
|
18
|
+
clear: []
|
|
19
|
+
operation: [op: 'delete' | 'restore' | 'forceDelete']
|
|
20
|
+
action: [action: VectorAction]
|
|
21
|
+
edit: []
|
|
22
|
+
}>()
|
|
23
|
+
|
|
24
|
+
const { t } = useVectorT()
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<v-sheet color="primary" rounded class="d-flex align-center flex-wrap ga-2 px-4 py-2 mb-2">
|
|
29
|
+
<span class="font-weight-medium">{{ allMatching ? t('list.bulk.allMatching', { total }) : t('list.bulk.selected', { count: selected }) }}</span>
|
|
30
|
+
<v-btn v-if="!allMatching && total > selected" size="small" variant="outlined" @click="emit('update:allMatching', true)">{{ t('list.bulk.selectAllMatching', { total }) }}</v-btn>
|
|
31
|
+
<v-btn v-else-if="allMatching" size="small" variant="outlined" @click="emit('update:allMatching', false)">{{ t('list.bulk.onlyChecked') }}</v-btn>
|
|
32
|
+
<v-spacer />
|
|
33
|
+
<template v-if="resource.softDelete && trashedOnly">
|
|
34
|
+
<v-btn size="small" variant="flat" color="white" prepend-icon="mdi-restore" @click="emit('operation', 'restore')">{{ t('common.restore') }}</v-btn>
|
|
35
|
+
<v-btn size="small" variant="flat" color="error" prepend-icon="mdi-delete-forever-outline" @click="emit('operation', 'forceDelete')">{{ t('common.deleteForever') }}</v-btn>
|
|
36
|
+
</template>
|
|
37
|
+
<template v-else>
|
|
38
|
+
<v-btn v-if="resource.permissions.update" size="small" variant="flat" color="white" prepend-icon="mdi-pencil-box-multiple-outline" @click="emit('edit')">{{ t('common.edit') }}</v-btn>
|
|
39
|
+
<v-btn v-if="resource.permissions.delete" size="small" variant="flat" color="white" prepend-icon="mdi-delete-outline" @click="emit('operation', 'delete')">{{ t('common.delete') }}</v-btn>
|
|
40
|
+
</template>
|
|
41
|
+
<v-btn v-for="action in actions" :key="action.name" size="small" variant="flat" color="white" :prepend-icon="action.icon ?? undefined" @click="emit('action', action)">{{ action.label }}</v-btn>
|
|
42
|
+
<v-btn size="small" variant="text" icon="mdi-close" :aria-label="t('list.bulk.clearSelection')" @click="emit('clear')" />
|
|
43
|
+
</v-sheet>
|
|
44
|
+
</template>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SubmitEventPromise } from 'vuetify'
|
|
3
|
+
import type { VectorResource, VectorSelection } from '#vector/app/types/vector'
|
|
4
|
+
|
|
5
|
+
// Writes the same values to every selected record: the user picks which
|
|
6
|
+
// fields to change, fills them in, and the server validates once and
|
|
7
|
+
// writes all records in one transaction, or none.
|
|
8
|
+
const props = defineProps<{
|
|
9
|
+
resource: VectorResource
|
|
10
|
+
selection: VectorSelection
|
|
11
|
+
count: number
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
const emit = defineEmits<{ done: [affected: number], cancel: [] }>()
|
|
15
|
+
|
|
16
|
+
const { t, noun } = useVectorT()
|
|
17
|
+
const notifications = useVectorNotifications()
|
|
18
|
+
const client = useVectorResource(() => props.resource.slug)
|
|
19
|
+
const { editable } = useVectorInputs()
|
|
20
|
+
|
|
21
|
+
const editableFields = computed(() => props.resource.fields.filter(f => editable(f, 'update') && f.type !== 'password' && f.type !== 'hasMany'))
|
|
22
|
+
const chosen = ref<string[]>([])
|
|
23
|
+
const fields = computed(() => editableFields.value.filter(f => chosen.value.includes(f.name)))
|
|
24
|
+
const values = reactive<Record<string, unknown>>({})
|
|
25
|
+
const serverErrors = reactive<Record<string, string[]>>({})
|
|
26
|
+
const formError = ref<string | null>(null)
|
|
27
|
+
const running = ref(false)
|
|
28
|
+
|
|
29
|
+
watch(chosen, (names) => {
|
|
30
|
+
for (const field of editableFields.value) {
|
|
31
|
+
if (names.includes(field.name) && !(field.name in values)) values[field.name] = initialValue(field)
|
|
32
|
+
if (!names.includes(field.name)) delete values[field.name]
|
|
33
|
+
}
|
|
34
|
+
})
|
|
35
|
+
|
|
36
|
+
const subject = computed(() => props.selection.all
|
|
37
|
+
? t('list.bulkEdit.everyMatching', { noun: noun(props.resource.labelSingular), count: props.count })
|
|
38
|
+
: t('list.bulkEdit.some', { count: props.count, noun: noun(props.count === 1 ? props.resource.labelSingular : props.resource.label) }))
|
|
39
|
+
|
|
40
|
+
async function save(event: SubmitEventPromise) {
|
|
41
|
+
const { valid } = await event
|
|
42
|
+
if (!valid || !chosen.value.length) return
|
|
43
|
+
|
|
44
|
+
running.value = true
|
|
45
|
+
formError.value = null
|
|
46
|
+
for (const key of Object.keys(serverErrors)) delete serverErrors[key]
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
const { affected } = await client.batch('update', props.selection, { ...values })
|
|
50
|
+
notifications.success(t('list.bulkEdit.updated', { count: affected, noun: noun(affected === 1 ? props.resource.labelSingular : props.resource.label) }))
|
|
51
|
+
emit('done', affected)
|
|
52
|
+
} catch (error) {
|
|
53
|
+
formError.value = applyViolations(error, values, serverErrors)
|
|
54
|
+
} finally {
|
|
55
|
+
running.value = false
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
</script>
|
|
59
|
+
|
|
60
|
+
<template>
|
|
61
|
+
<v-dialog :model-value="true" max-width="640" persistent @update:model-value="emit('cancel')">
|
|
62
|
+
<v-form validate-on="blur lazy" @submit.prevent="save">
|
|
63
|
+
<v-card>
|
|
64
|
+
<v-card-title class="d-flex align-center ga-2">
|
|
65
|
+
<v-icon icon="mdi-pencil-box-multiple-outline" />
|
|
66
|
+
{{ t('list.bulkEdit.title', { subject }) }}
|
|
67
|
+
</v-card-title>
|
|
68
|
+
<v-card-text>
|
|
69
|
+
<v-alert v-if="formError" type="error" variant="tonal" class="mb-4" :text="formError" />
|
|
70
|
+
<v-select
|
|
71
|
+
v-model="chosen"
|
|
72
|
+
:items="editableFields.map(f => ({ title: f.label, value: f.name }))"
|
|
73
|
+
:label="t('list.bulkEdit.fieldsToChange')"
|
|
74
|
+
multiple
|
|
75
|
+
chips
|
|
76
|
+
closable-chips
|
|
77
|
+
:hint="t('list.bulkEdit.fieldsHint')"
|
|
78
|
+
persistent-hint
|
|
79
|
+
class="mb-4"
|
|
80
|
+
/>
|
|
81
|
+
<VxFieldsForm v-if="fields.length" :fields="fields" :values="values" :server-errors="serverErrors" :resource="resource.slug" @clear="(name: string) => delete serverErrors[name]" />
|
|
82
|
+
</v-card-text>
|
|
83
|
+
<v-card-actions>
|
|
84
|
+
<v-spacer />
|
|
85
|
+
<v-btn variant="text" :disabled="running" @click="emit('cancel')">{{ t('common.cancel') }}</v-btn>
|
|
86
|
+
<v-btn type="submit" color="primary" :loading="running" :disabled="!chosen.length">{{ t('list.bulkEdit.applyTo', { count }) }}</v-btn>
|
|
87
|
+
</v-card-actions>
|
|
88
|
+
</v-card>
|
|
89
|
+
</v-form>
|
|
90
|
+
</v-dialog>
|
|
91
|
+
</template>
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorResource } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// Which index columns show, and in what order. Persisted per user and
|
|
5
|
+
// resource through the preferences store.
|
|
6
|
+
const props = defineProps<{ resource: VectorResource }>()
|
|
7
|
+
const model = defineModel<string[]>({ required: true })
|
|
8
|
+
|
|
9
|
+
const { t } = useVectorT()
|
|
10
|
+
const open = ref(false)
|
|
11
|
+
const draft = ref<string[]>([])
|
|
12
|
+
const available = computed(() => props.resource.table.columns)
|
|
13
|
+
|
|
14
|
+
function start() {
|
|
15
|
+
draft.value = [...model.value.filter(c => available.value.includes(c)), ...available.value.filter(c => !model.value.includes(c))]
|
|
16
|
+
open.value = true
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const visible = (name: string) => model.value.includes(name)
|
|
20
|
+
const shown = ref<Record<string, boolean>>({})
|
|
21
|
+
|
|
22
|
+
watch(open, (o) => {
|
|
23
|
+
if (o) shown.value = Object.fromEntries(available.value.map(c => [c, visible(c)]))
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
function move(index: number, delta: number) {
|
|
27
|
+
const target = index + delta
|
|
28
|
+
if (target < 0 || target >= draft.value.length) return
|
|
29
|
+
const next = [...draft.value]
|
|
30
|
+
const [item] = next.splice(index, 1)
|
|
31
|
+
next.splice(target, 0, item!)
|
|
32
|
+
draft.value = next
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function apply() {
|
|
36
|
+
const chosen = draft.value.filter(c => shown.value[c])
|
|
37
|
+
model.value = chosen.length ? chosen : [available.value[0]!]
|
|
38
|
+
open.value = false
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function reset() {
|
|
42
|
+
model.value = [...available.value]
|
|
43
|
+
open.value = false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const label = (name: string) => props.resource.fields.find(f => f.name === name)?.label ?? name
|
|
47
|
+
</script>
|
|
48
|
+
|
|
49
|
+
<template>
|
|
50
|
+
<v-btn size="small" variant="text" icon="mdi-table-column-plus-after" :aria-label="t('list.columns.title')" @click="start" />
|
|
51
|
+
<v-dialog v-model="open" max-width="420">
|
|
52
|
+
<v-card :title="t('list.columns.title')">
|
|
53
|
+
<v-card-text>
|
|
54
|
+
<v-list density="compact">
|
|
55
|
+
<v-list-item v-for="(name, index) in draft" :key="name" :title="label(name)">
|
|
56
|
+
<template #prepend>
|
|
57
|
+
<v-checkbox-btn v-model="shown[name]" />
|
|
58
|
+
</template>
|
|
59
|
+
<template #append>
|
|
60
|
+
<v-btn icon="mdi-chevron-up" size="x-small" variant="text" :disabled="index === 0" :aria-label="t('list.columns.moveUp')" @click="move(index, -1)" />
|
|
61
|
+
<v-btn icon="mdi-chevron-down" size="x-small" variant="text" :disabled="index === draft.length - 1" :aria-label="t('list.columns.moveDown')" @click="move(index, 1)" />
|
|
62
|
+
</template>
|
|
63
|
+
</v-list-item>
|
|
64
|
+
</v-list>
|
|
65
|
+
</v-card-text>
|
|
66
|
+
<v-card-actions>
|
|
67
|
+
<v-btn variant="text" @click="reset">{{ t('list.columns.reset') }}</v-btn>
|
|
68
|
+
<v-spacer />
|
|
69
|
+
<v-btn variant="text" @click="open = false">{{ t('common.cancel') }}</v-btn>
|
|
70
|
+
<v-btn color="primary" @click="apply">{{ t('common.apply') }}</v-btn>
|
|
71
|
+
</v-card-actions>
|
|
72
|
+
</v-card>
|
|
73
|
+
</v-dialog>
|
|
74
|
+
</template>
|