@kinetis/vector-ui 0.0.0-stage → 0.9.1
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 +34 -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 +83 -5
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorField } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ field: VectorField, value: unknown, context: 'index' | 'show' }>()
|
|
5
|
+
const text = computed(() => props.value === null || props.value === undefined ? '' : String(props.value))
|
|
6
|
+
const truncated = computed(() => props.context === 'index' && text.value.length > 80 ? text.value.slice(0, 77) + '…' : text.value)
|
|
7
|
+
const { t } = useVectorT()
|
|
8
|
+
</script>
|
|
9
|
+
|
|
10
|
+
<template>
|
|
11
|
+
<span v-if="text === ''" class="text-disabled">{{ t('common.none') }}</span>
|
|
12
|
+
<span v-else :title="truncated !== text ? text : undefined">{{ truncated }}</span>
|
|
13
|
+
</template>
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
defineProps<{ value: unknown }>()
|
|
3
|
+
const { t } = useVectorT()
|
|
4
|
+
</script>
|
|
5
|
+
|
|
6
|
+
<template>
|
|
7
|
+
<a v-if="typeof value === 'string' && value && allowedHref(value)" :href="value" target="_blank" rel="noopener noreferrer" @click.stop>{{ value }}</a>
|
|
8
|
+
<!-- a value with another scheme is shown, never followed -->
|
|
9
|
+
<span v-else-if="typeof value === 'string' && value">{{ value }}</span>
|
|
10
|
+
<span v-else class="text-disabled">{{ t('common.none') }}</span>
|
|
11
|
+
</template>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorField, VectorResource } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// Dispatches a value to the component the field registry names. A
|
|
5
|
+
// built-in arrives as a component; an app override arrives as the name
|
|
6
|
+
// of a globally registered one and is resolved here, in setup.
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
field: VectorField
|
|
9
|
+
value: unknown
|
|
10
|
+
record: Record<string, unknown>
|
|
11
|
+
resource: VectorResource
|
|
12
|
+
context: 'index' | 'show'
|
|
13
|
+
}>()
|
|
14
|
+
|
|
15
|
+
const { cellFor } = useVectorFields()
|
|
16
|
+
const component = computed(() => {
|
|
17
|
+
const cell = cellFor(props.field)
|
|
18
|
+
|
|
19
|
+
return typeof cell === 'string' ? resolveComponent(cell) : cell
|
|
20
|
+
})
|
|
21
|
+
</script>
|
|
22
|
+
|
|
23
|
+
<template>
|
|
24
|
+
<component :is="component" :field="field" :value="value" :record="record" :resource="resource" :context="context" />
|
|
25
|
+
</template>
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { fieldProps, type VectorSearchResult } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// Ctrl/Cmd+K: search every globally searchable resource, jump to a
|
|
5
|
+
// record or a resource. A dialog and a list — no Labs dependency.
|
|
6
|
+
const appConfig = useAppConfig()
|
|
7
|
+
const api = useVectorApi()
|
|
8
|
+
const { menu, resource: resourceOf } = useVectorSchema()
|
|
9
|
+
const open = ref(false)
|
|
10
|
+
const query = ref<string | null>('')
|
|
11
|
+
const results = ref<VectorSearchResult[]>([])
|
|
12
|
+
const loading = ref(false)
|
|
13
|
+
let requestId = 0
|
|
14
|
+
const { t } = useVectorT()
|
|
15
|
+
|
|
16
|
+
const search = useDebounceFn(async (q: string) => {
|
|
17
|
+
const id = ++requestId
|
|
18
|
+
const term = q.trim()
|
|
19
|
+
|
|
20
|
+
if (term === '') {
|
|
21
|
+
results.value = []
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
loading.value = true
|
|
26
|
+
try {
|
|
27
|
+
const response = await api<{ results: VectorSearchResult[] }>('/vector/search', { query: { q: term } })
|
|
28
|
+
if (id === requestId) results.value = response.results
|
|
29
|
+
} finally {
|
|
30
|
+
if (id === requestId) loading.value = false
|
|
31
|
+
}
|
|
32
|
+
}, 200)
|
|
33
|
+
|
|
34
|
+
watch(query, q => search(q ?? ''))
|
|
35
|
+
watch(open, (o) => { if (!o) { query.value = ''; results.value = [] } })
|
|
36
|
+
|
|
37
|
+
const keyOf = (r: VectorSearchResult) => `${r.resource}-${r.id}`
|
|
38
|
+
|
|
39
|
+
/** The subtitle as its field shows it — an enum by its label — then the resource. */
|
|
40
|
+
function subtitleOf(r: VectorSearchResult): string {
|
|
41
|
+
const resource = resourceOf(r.resource)
|
|
42
|
+
const field = resource?.fields.find(f => f.name === resource.searchResult?.subtitle)
|
|
43
|
+
let text = r.subtitle ?? null
|
|
44
|
+
if (text !== null && field?.type === 'enum') text = field.options?.find(o => String(o.value) === text)?.label ?? text
|
|
45
|
+
else if (text !== null && field?.type === 'number' && text !== '' && !Number.isNaN(Number(text))) {
|
|
46
|
+
const decimals = fieldProps(field).decimals
|
|
47
|
+
text = formatNumber(Number(text), typeof decimals === 'number' ? decimals : undefined)
|
|
48
|
+
}
|
|
49
|
+
return [text, r.resourceLabel].filter(Boolean).join(' · ')
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Thumbnails come through the API client, like the image cells, so a
|
|
53
|
+
// bearer-authenticated admin sees them too; a missing file keeps the icon.
|
|
54
|
+
const thumbs = reactive<Record<string, string>>({})
|
|
55
|
+
watch(results, (list) => {
|
|
56
|
+
for (const key of Object.keys(thumbs)) {
|
|
57
|
+
delete thumbs[key]
|
|
58
|
+
}
|
|
59
|
+
for (const r of list) {
|
|
60
|
+
if (!r.image) continue
|
|
61
|
+
api<Blob>(`/vector/r/${encodeURIComponent(r.resource)}/${encodeURIComponent(String(r.id))}/files/${encodeURIComponent(r.image)}`, { responseType: 'blob' })
|
|
62
|
+
.then(dataUrl)
|
|
63
|
+
.then((url) => { if (results.value.includes(r)) thumbs[keyOf(r)] = url })
|
|
64
|
+
.catch(() => {})
|
|
65
|
+
}
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const pages = useVectorPages()
|
|
69
|
+
const pageItems = computed(() => Object.entries(pages.value).filter(([, p]) => (query.value ?? '').trim() === '' || p.title.toLowerCase().includes((query.value ?? '').trim().toLowerCase())))
|
|
70
|
+
const resources = computed(() => menu.value.flatMap(g => g.items).filter(r => (query.value ?? '').trim() === '' || r.label.toLowerCase().includes((query.value ?? '').trim().toLowerCase())))
|
|
71
|
+
|
|
72
|
+
function go(path: string) {
|
|
73
|
+
open.value = false
|
|
74
|
+
navigateTo(`${appConfig.vector.basePath}${path}`)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (import.meta.client) {
|
|
78
|
+
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
|
79
|
+
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k') {
|
|
80
|
+
event.preventDefault()
|
|
81
|
+
open.value = !open.value
|
|
82
|
+
}
|
|
83
|
+
})
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
defineExpose({ open })
|
|
87
|
+
</script>
|
|
88
|
+
|
|
89
|
+
<template>
|
|
90
|
+
<v-dialog v-model="open" max-width="600" scrollable>
|
|
91
|
+
<v-card>
|
|
92
|
+
<v-text-field v-model="query" autofocus variant="solo" flat :placeholder="t('shell.palette.placeholder')" :aria-label="t('common.search')" prepend-inner-icon="mdi-magnify" :loading="loading" hide-details clearable class="flex-grow-0" />
|
|
93
|
+
<v-divider />
|
|
94
|
+
<v-card-text class="pa-0" style="max-height: 420px">
|
|
95
|
+
<v-list density="compact">
|
|
96
|
+
<template v-if="results.length">
|
|
97
|
+
<v-list-subheader>{{ t('shell.palette.records') }}</v-list-subheader>
|
|
98
|
+
<v-list-item v-for="r in results" :key="keyOf(r)" :title="r.title" :subtitle="subtitleOf(r)" @click="go(`/r/${r.resource}/${encodeURIComponent(String(r.id))}`)">
|
|
99
|
+
<template #prepend>
|
|
100
|
+
<v-avatar v-if="thumbs[keyOf(r)]" rounded size="32" class="me-4">
|
|
101
|
+
<v-img :src="thumbs[keyOf(r)]" alt="" cover />
|
|
102
|
+
</v-avatar>
|
|
103
|
+
<v-icon v-else icon="mdi-file-document-outline" />
|
|
104
|
+
</template>
|
|
105
|
+
</v-list-item>
|
|
106
|
+
</template>
|
|
107
|
+
<v-list-subheader>{{ t('shell.palette.resources') }}</v-list-subheader>
|
|
108
|
+
<v-list-item v-for="r in resources" :key="r.slug" :title="r.label" :prepend-icon="r.icon ?? 'mdi-table'" @click="go(`/r/${r.slug}`)" />
|
|
109
|
+
<template v-if="pageItems.length">
|
|
110
|
+
<v-list-subheader>{{ t('shell.nav.pages') }}</v-list-subheader>
|
|
111
|
+
<v-list-item v-for="[name, p] in pageItems" :key="name" :title="p.title" :prepend-icon="p.icon ?? 'mdi-file-document-outline'" @click="go(`/p/${name}`)" />
|
|
112
|
+
</template>
|
|
113
|
+
<v-list-item v-if="(query ?? '').trim() && !loading && results.length === 0" :title="t('shell.palette.noMatch')" disabled />
|
|
114
|
+
</v-list>
|
|
115
|
+
</v-card-text>
|
|
116
|
+
</v-card>
|
|
117
|
+
</v-dialog>
|
|
118
|
+
</template>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
const { t } = useVectorT()
|
|
3
|
+
const { pending, settle } = useConfirm()
|
|
4
|
+
const open = computed({ get: () => pending.value !== null, set: (value) => { if (!value) settle(false) } })
|
|
5
|
+
</script>
|
|
6
|
+
|
|
7
|
+
<template>
|
|
8
|
+
<v-dialog v-model="open" max-width="440" persistent>
|
|
9
|
+
<v-card v-if="pending">
|
|
10
|
+
<v-card-title>{{ pending.title }}</v-card-title>
|
|
11
|
+
<v-card-text v-if="pending.text">{{ pending.text }}</v-card-text>
|
|
12
|
+
<v-card-actions>
|
|
13
|
+
<v-spacer />
|
|
14
|
+
<v-btn variant="text" @click="settle(false)">{{ pending.cancelText ?? t('common.cancel') }}</v-btn>
|
|
15
|
+
<v-btn :color="pending.destructive ? 'error' : 'primary'" @click="settle(true)">{{ pending.confirmText ?? t('common.confirm') }}</v-btn>
|
|
16
|
+
</v-card-actions>
|
|
17
|
+
</v-card>
|
|
18
|
+
</v-dialog>
|
|
19
|
+
</template>
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorDashboard } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// One dashboard page: its cards in a responsive grid, each loading its own data.
|
|
5
|
+
// The server translates its labels, so it is fetched again when the language changes.
|
|
6
|
+
const props = defineProps<{ name: string }>()
|
|
7
|
+
const dashboards = useVectorDashboards()
|
|
8
|
+
const { t } = useVectorT()
|
|
9
|
+
const { current: locale } = useVectorLocale()
|
|
10
|
+
const { data, error } = await useAsyncData(() => `vector.dashboard.${props.name}`, () => dashboards.show(props.name), { watch: [locale] })
|
|
11
|
+
const dashboard = computed<VectorDashboard | null>(() => data.value ?? null)
|
|
12
|
+
</script>
|
|
13
|
+
|
|
14
|
+
<template>
|
|
15
|
+
<div>
|
|
16
|
+
<div class="d-flex align-center mb-4">
|
|
17
|
+
<v-icon v-if="dashboard?.icon" :icon="dashboard.icon" class="me-2" />
|
|
18
|
+
<h1 class="text-h4">{{ dashboard?.label ?? t('dashboard.title') }}</h1>
|
|
19
|
+
</div>
|
|
20
|
+
<v-alert v-if="error" type="error" variant="tonal" :text="vectorErrorMessage(error)" />
|
|
21
|
+
<v-row v-else-if="dashboard" density="compact">
|
|
22
|
+
<v-col v-for="widget in dashboard.widgets" :key="widget.name" cols="12" :md="widget.width">
|
|
23
|
+
<VxWidgetCard :widget="widget" :load="(range: string | undefined) => dashboards.widget(props.name, widget.name, range)" />
|
|
24
|
+
</v-col>
|
|
25
|
+
</v-row>
|
|
26
|
+
</div>
|
|
27
|
+
</template>
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorField, VectorFieldState } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// A block of inputs for a loose field list — an action's dialog, a
|
|
5
|
+
// settings group — sharing the record form's registry, rules and 422
|
|
6
|
+
// mapping. The owner holds the values and the server errors.
|
|
7
|
+
const props = defineProps<{
|
|
8
|
+
fields: VectorField[]
|
|
9
|
+
values: Record<string, unknown>
|
|
10
|
+
serverErrors: Record<string, string[]>
|
|
11
|
+
resource?: string
|
|
12
|
+
}>()
|
|
13
|
+
|
|
14
|
+
const emit = defineEmits<{ clear: [name: string] }>()
|
|
15
|
+
const { inputFor } = useVectorInputs()
|
|
16
|
+
const wide = (field: VectorField) => ['markdown', 'richtext', 'text', 'json'].includes(field.type)
|
|
17
|
+
|
|
18
|
+
// Conditions (visibleWhen, requiredWhen, disabledWhen) follow the values as they are edited.
|
|
19
|
+
const states = computed<Record<string, VectorFieldState>>(() => Object.fromEntries(props.fields.map(f => [f.name, fieldState(f, props.values)])))
|
|
20
|
+
const shownFields = computed(() => props.fields.filter(f => states.value[f.name]?.visible !== false))
|
|
21
|
+
|
|
22
|
+
function set(name: string, value: unknown) {
|
|
23
|
+
// The parent owns the values object and hands it down to be filled in.
|
|
24
|
+
// eslint-disable-next-line vue/no-mutating-props
|
|
25
|
+
props.values[name] = value
|
|
26
|
+
emit('clear', name)
|
|
27
|
+
}
|
|
28
|
+
</script>
|
|
29
|
+
|
|
30
|
+
<template>
|
|
31
|
+
<v-row density="compact">
|
|
32
|
+
<v-col v-for="field in shownFields" :key="field.name" cols="12" :md="wide(field) || fields.length === 1 ? 12 : 6">
|
|
33
|
+
<component
|
|
34
|
+
:is="inputFor(field)"
|
|
35
|
+
:model-value="values[field.name]"
|
|
36
|
+
:field="field"
|
|
37
|
+
:disabled="states[field.name]?.disabled === true"
|
|
38
|
+
:rules="rulesFor(field, 'create', states[field.name])"
|
|
39
|
+
:error-messages="serverErrors[field.name] ?? []"
|
|
40
|
+
:record="values"
|
|
41
|
+
:resource="resource ?? ''"
|
|
42
|
+
@update:model-value="(v: unknown) => set(field.name, v)"
|
|
43
|
+
/>
|
|
44
|
+
</v-col>
|
|
45
|
+
</v-row>
|
|
46
|
+
</template>
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorImportMode, VectorImportPreview, VectorResource } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// CSV import in two steps: upload for a preview and a suggested
|
|
5
|
+
// mapping, adjust the mapping, then queue the import and follow the job.
|
|
6
|
+
// An import creates records, or updates the records a key column finds
|
|
7
|
+
// (the key or a unique field), or does either row by row.
|
|
8
|
+
const props = defineProps<{ resource: VectorResource }>()
|
|
9
|
+
const emit = defineEmits<{ queued: [jobId: number], cancel: [] }>()
|
|
10
|
+
|
|
11
|
+
const appConfig = useAppConfig()
|
|
12
|
+
const client = useVectorResource(() => props.resource.slug)
|
|
13
|
+
const { t, noun } = useVectorT()
|
|
14
|
+
const notifications = useVectorNotifications()
|
|
15
|
+
const file = ref<File | null>(null)
|
|
16
|
+
const preview = ref<VectorImportPreview | null>(null)
|
|
17
|
+
const mapping = reactive<Record<string, string>>({})
|
|
18
|
+
const busy = ref(false)
|
|
19
|
+
const error = ref<string | null>(null)
|
|
20
|
+
const mode = ref<VectorImportMode>(props.resource.permissions.create ? 'create' : 'update')
|
|
21
|
+
const key = ref('')
|
|
22
|
+
|
|
23
|
+
const modes = computed(() => (['create', 'update', 'upsert'] as const)
|
|
24
|
+
.filter(m => m === 'create' ? props.resource.permissions.create : m === 'update' ? props.resource.permissions.update : props.resource.permissions.create && props.resource.permissions.update)
|
|
25
|
+
.map(m => ({ title: t(`list.import.modes.${m}`), value: m })))
|
|
26
|
+
|
|
27
|
+
// what the mode writes; an update also maps the column it matches rows by
|
|
28
|
+
const fields = computed(() => {
|
|
29
|
+
const views = mode.value === 'create' ? ['create'] : mode.value === 'update' ? ['update'] : ['create', 'update']
|
|
30
|
+
const writable = props.resource.fields.filter(f => !f.readonly && f.views.some(v => views.includes(v)) && !['id', 'hasOne', 'hasMany', 'computed'].includes(f.type))
|
|
31
|
+
const keys = mode.value === 'create' ? [] : keyFields.value.filter(f => !writable.includes(f))
|
|
32
|
+
|
|
33
|
+
return [...keys, ...writable].map(f => ({ title: f.label, value: f.name }))
|
|
34
|
+
})
|
|
35
|
+
const keyFields = computed(() => props.resource.fields.filter(f => f.name === props.resource.primaryKey.field || f.unique))
|
|
36
|
+
const keyChoices = computed(() => keyFields.value.filter(f => Object.values(mapping).includes(f.name)).map(f => ({ title: f.label, value: f.name })))
|
|
37
|
+
|
|
38
|
+
watch(keyChoices, choices => {
|
|
39
|
+
if (!choices.some(c => c.value === key.value)) key.value = choices[0]?.value ?? ''
|
|
40
|
+
})
|
|
41
|
+
// the suggestion maps the key column too; a mode that does not take a field skips its column
|
|
42
|
+
const suggested = ref<Record<string, string>>({})
|
|
43
|
+
watch(mode, applyMapping)
|
|
44
|
+
|
|
45
|
+
function applyMapping() {
|
|
46
|
+
const allowed = new Set(fields.value.map(f => f.value))
|
|
47
|
+
for (const key of Object.keys(mapping)) delete mapping[key]
|
|
48
|
+
for (const header of preview.value?.headers ?? []) {
|
|
49
|
+
const field = suggested.value[header] ?? ''
|
|
50
|
+
mapping[header] = allowed.has(field) ? field : ''
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function analyse() {
|
|
55
|
+
if (!file.value) return
|
|
56
|
+
busy.value = true
|
|
57
|
+
error.value = null
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
preview.value = await client.importPreview(file.value)
|
|
61
|
+
suggested.value = preview.value.mapping
|
|
62
|
+
applyMapping()
|
|
63
|
+
} catch (caught) {
|
|
64
|
+
error.value = vectorErrorMessage(caught)
|
|
65
|
+
} finally {
|
|
66
|
+
busy.value = false
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
async function start() {
|
|
71
|
+
if (!file.value) return
|
|
72
|
+
busy.value = true
|
|
73
|
+
error.value = null
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const chosen = Object.fromEntries(Object.entries(mapping).filter(([, v]) => v))
|
|
77
|
+
const { jobId, rows } = await client.importCsv(file.value, chosen, mode.value, key.value)
|
|
78
|
+
notifications.success(t('list.import.queued', { rows }))
|
|
79
|
+
emit('queued', jobId)
|
|
80
|
+
await navigateTo(`${appConfig.vector.basePath}/jobs/${jobId}`)
|
|
81
|
+
} catch (caught) {
|
|
82
|
+
error.value = vectorErrorMessage(caught)
|
|
83
|
+
} finally {
|
|
84
|
+
busy.value = false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
</script>
|
|
88
|
+
|
|
89
|
+
<template>
|
|
90
|
+
<v-dialog :model-value="true" max-width="720" persistent scrollable @update:model-value="emit('cancel')">
|
|
91
|
+
<v-card :title="t('list.import.title', { label: noun(resource.label) })">
|
|
92
|
+
<v-card-text>
|
|
93
|
+
<v-alert v-if="error" type="error" variant="tonal" class="mb-4" :text="error" />
|
|
94
|
+
<v-file-input v-model="file" :label="t('list.import.file')" accept=".csv,text/csv" prepend-icon="mdi-file-delimited-outline" :disabled="busy" @update:model-value="preview = null" />
|
|
95
|
+
<v-btn v-if="!preview" color="primary" :disabled="!file" :loading="busy" @click="analyse">{{ t('list.import.analyse') }}</v-btn>
|
|
96
|
+
<template v-else>
|
|
97
|
+
<p class="text-body-2 mb-3">{{ t('list.import.instructions', { rows: preview.rows, pipe: '|' }) }}</p>
|
|
98
|
+
<div class="d-flex flex-wrap ga-3 mb-3">
|
|
99
|
+
<v-select v-model="mode" :items="modes" :label="t('list.import.mode')" density="compact" hide-details style="min-width: 220px" />
|
|
100
|
+
<v-select
|
|
101
|
+
v-if="mode !== 'create'"
|
|
102
|
+
v-model="key"
|
|
103
|
+
:items="keyChoices"
|
|
104
|
+
:label="t('list.import.key')"
|
|
105
|
+
:no-data-text="t('list.import.noKey')"
|
|
106
|
+
density="compact"
|
|
107
|
+
hide-details
|
|
108
|
+
style="min-width: 220px"
|
|
109
|
+
/>
|
|
110
|
+
</div>
|
|
111
|
+
<p v-if="mode !== 'create'" class="text-body-2 text-medium-emphasis mb-3">{{ t(`list.import.modeHelp.${mode}`) }}</p>
|
|
112
|
+
<v-table density="compact">
|
|
113
|
+
<thead><tr><th>{{ t('list.import.csvColumn') }}</th><th>{{ t('list.import.sample') }}</th><th style="width: 260px">{{ t('list.field') }}</th></tr></thead>
|
|
114
|
+
<tbody>
|
|
115
|
+
<tr v-for="header in preview.headers" :key="header">
|
|
116
|
+
<td>{{ header }}</td>
|
|
117
|
+
<td class="text-medium-emphasis text-truncate" style="max-width: 200px">{{ preview.sample[0]?.[header] ?? '' }}</td>
|
|
118
|
+
<td><v-select v-model="mapping[header]" :items="[{ title: t('list.import.skip'), value: '' }, ...fields]" density="compact" hide-details /></td>
|
|
119
|
+
</tr>
|
|
120
|
+
</tbody>
|
|
121
|
+
</v-table>
|
|
122
|
+
</template>
|
|
123
|
+
</v-card-text>
|
|
124
|
+
<v-card-actions>
|
|
125
|
+
<v-spacer />
|
|
126
|
+
<v-btn variant="text" :disabled="busy" @click="emit('cancel')">{{ t('common.cancel') }}</v-btn>
|
|
127
|
+
<v-btn v-if="preview" color="primary" :loading="busy" :disabled="mode !== 'create' && !key" @click="start">{{ t('list.import.start', { rows: preview.rows }) }}</v-btn>
|
|
128
|
+
</v-card-actions>
|
|
129
|
+
</v-card>
|
|
130
|
+
</v-dialog>
|
|
131
|
+
</template>
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
// The bell: unread count polled every 30 seconds, the latest messages in a menu.
|
|
3
|
+
const appConfig = useAppConfig()
|
|
4
|
+
const inbox = useVectorNotificationsInbox()
|
|
5
|
+
const open = ref(false)
|
|
6
|
+
const { t } = useVectorT()
|
|
7
|
+
|
|
8
|
+
onMounted(() => inbox.refresh().catch(() => undefined))
|
|
9
|
+
const { pause } = useIntervalFn(() => inbox.refresh().catch(() => undefined), 30000)
|
|
10
|
+
onUnmounted(pause)
|
|
11
|
+
watch(open, o => { if (o) inbox.refresh().catch(() => undefined) })
|
|
12
|
+
|
|
13
|
+
async function go(id: number, link: string | null) {
|
|
14
|
+
await inbox.markRead(id)
|
|
15
|
+
open.value = false
|
|
16
|
+
if (link) navigateTo(`${appConfig.vector.basePath}${link}`)
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
|
|
20
|
+
<template>
|
|
21
|
+
<v-menu v-model="open" :close-on-content-click="false" location="bottom end" width="380">
|
|
22
|
+
<template #activator="{ props: activator }">
|
|
23
|
+
<v-btn v-bind="activator" icon variant="text" :aria-label="t('shell.inbox.title')">
|
|
24
|
+
<v-badge :content="inbox.unread.value" :model-value="inbox.unread.value > 0" color="error">
|
|
25
|
+
<v-icon icon="mdi-bell-outline" />
|
|
26
|
+
</v-badge>
|
|
27
|
+
</v-btn>
|
|
28
|
+
</template>
|
|
29
|
+
<v-card>
|
|
30
|
+
<v-card-title class="d-flex align-center text-subtitle-1">
|
|
31
|
+
{{ t('shell.inbox.title') }}
|
|
32
|
+
<v-spacer />
|
|
33
|
+
<v-btn v-if="inbox.unread.value" size="small" variant="text" @click="inbox.markAllRead()">{{ t('shell.inbox.markAllRead') }}</v-btn>
|
|
34
|
+
</v-card-title>
|
|
35
|
+
<v-divider />
|
|
36
|
+
<v-list density="compact" max-height="420" style="overflow-y: auto">
|
|
37
|
+
<v-list-item v-for="n in inbox.items.value" :key="n.id" :title="n.title" :subtitle="n.body ?? undefined" :class="{ 'bg-surface-light': !n.readAt }" @click="go(n.id, n.link)">
|
|
38
|
+
<template #prepend>
|
|
39
|
+
<v-icon :icon="{ info: 'mdi-information-outline', success: 'mdi-check-circle-outline', warning: 'mdi-alert-outline', error: 'mdi-alert-circle-outline' }[n.kind]" :color="n.kind === 'info' ? undefined : n.kind" />
|
|
40
|
+
</template>
|
|
41
|
+
<template #append>
|
|
42
|
+
<span class="text-caption text-medium-emphasis">{{ formatDateTime(n.createdAt) }}</span>
|
|
43
|
+
</template>
|
|
44
|
+
</v-list-item>
|
|
45
|
+
<v-list-item v-if="inbox.items.value.length === 0" :title="t('shell.inbox.empty')" disabled />
|
|
46
|
+
</v-list>
|
|
47
|
+
</v-card>
|
|
48
|
+
</v-menu>
|
|
49
|
+
</template>
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorField } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
|
|
5
|
+
const model = defineModel<boolean | null>()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<v-switch v-model="model" :label="field.label" :error-messages="errorMessages" :hint="field.help ?? undefined" :persistent-hint="!!field.help" color="primary" inset />
|
|
10
|
+
</template>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorField } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// A currency chosen from the field's codes, each named in the UI's
|
|
5
|
+
// language and searchable by code or name.
|
|
6
|
+
const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
|
|
7
|
+
const model = defineModel<string | null>()
|
|
8
|
+
const { locale } = useVectorT()
|
|
9
|
+
|
|
10
|
+
const items = computed(() => (props.field.options ?? []).map(o => ({ value: String(o.value), title: currencyLabel(String(o.value), locale.value) })))
|
|
11
|
+
</script>
|
|
12
|
+
|
|
13
|
+
<template>
|
|
14
|
+
<v-autocomplete
|
|
15
|
+
v-model="model"
|
|
16
|
+
:label="field.label"
|
|
17
|
+
:items="items"
|
|
18
|
+
:rules="rules"
|
|
19
|
+
:error-messages="errorMessages"
|
|
20
|
+
:clearable="field.nullable === true"
|
|
21
|
+
:hint="field.help ?? undefined"
|
|
22
|
+
:persistent-hint="!!field.help"
|
|
23
|
+
auto-select-first
|
|
24
|
+
/>
|
|
25
|
+
</template>
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorField } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// A native date/datetime control: the wire value is ISO 8601 in UTC, the
|
|
5
|
+
// control edits local time, and the conversion lives here.
|
|
6
|
+
const props = defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
|
|
7
|
+
const model = defineModel<string | null>()
|
|
8
|
+
const dateOnly = computed(() => props.field.type === 'date')
|
|
9
|
+
|
|
10
|
+
function pad(n: number) { return String(n).padStart(2, '0') }
|
|
11
|
+
|
|
12
|
+
const local = computed({
|
|
13
|
+
get: () => {
|
|
14
|
+
if (!model.value) return ''
|
|
15
|
+
if (dateOnly.value) return model.value.slice(0, 10)
|
|
16
|
+
const d = new Date(model.value)
|
|
17
|
+
if (Number.isNaN(d.getTime())) return ''
|
|
18
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
|
|
19
|
+
},
|
|
20
|
+
set: (value: string) => {
|
|
21
|
+
if (!value) { model.value = null; return }
|
|
22
|
+
model.value = dateOnly.value ? value : new Date(value).toISOString()
|
|
23
|
+
},
|
|
24
|
+
})
|
|
25
|
+
</script>
|
|
26
|
+
|
|
27
|
+
<template>
|
|
28
|
+
<v-text-field
|
|
29
|
+
v-model="local"
|
|
30
|
+
:label="field.label"
|
|
31
|
+
:type="dateOnly ? 'date' : 'datetime-local'"
|
|
32
|
+
:rules="rules"
|
|
33
|
+
:error-messages="errorMessages"
|
|
34
|
+
:clearable="field.nullable === true"
|
|
35
|
+
:hint="field.help ?? undefined"
|
|
36
|
+
:persistent-hint="!!field.help"
|
|
37
|
+
/>
|
|
38
|
+
</template>
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorField } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
defineProps<{ field: VectorField, rules: ((v: unknown) => true | string)[], errorMessages: string[], record?: Record<string, unknown>, resource?: string }>()
|
|
5
|
+
const model = defineModel<string | number | null>()
|
|
6
|
+
</script>
|
|
7
|
+
|
|
8
|
+
<template>
|
|
9
|
+
<v-select
|
|
10
|
+
v-model="model"
|
|
11
|
+
:label="field.label"
|
|
12
|
+
:items="field.options ?? []"
|
|
13
|
+
item-title="label"
|
|
14
|
+
item-value="value"
|
|
15
|
+
:rules="rules"
|
|
16
|
+
:error-messages="errorMessages"
|
|
17
|
+
:clearable="field.nullable === true"
|
|
18
|
+
:hint="field.help ?? undefined"
|
|
19
|
+
:persistent-hint="!!field.help"
|
|
20
|
+
/>
|
|
21
|
+
</template>
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import { fieldProps, type VectorField } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// A key-value field's entries as rows of two text inputs. The rows are the
|
|
5
|
+
// editing state — a row may be half typed — and the model is the object
|
|
6
|
+
// they make: a row without a key is left out, a repeated key's last row wins.
|
|
7
|
+
const props = defineProps<{ field: VectorField, rules?: unknown[], errorMessages: string[], record?: Record<string, unknown>, resource?: string, disabled?: boolean }>()
|
|
8
|
+
const model = defineModel<Record<string, string> | null>()
|
|
9
|
+
|
|
10
|
+
const { t } = useVectorT()
|
|
11
|
+
const rows = ref<{ key: string, value: string }[]>([])
|
|
12
|
+
const max = computed(() => Number(fieldProps(props.field).maxEntries ?? 50))
|
|
13
|
+
const keyLabel = computed(() => String(fieldProps(props.field).keyLabel ?? t('fields.keyValue.key')))
|
|
14
|
+
const valueLabel = computed(() => String(fieldProps(props.field).valueLabel ?? t('fields.keyValue.value')))
|
|
15
|
+
|
|
16
|
+
// Adopt a value set from outside (a record loaded), not the one these rows
|
|
17
|
+
// just made — which comes back as the form's reactive copy, so by content.
|
|
18
|
+
let emitted = ''
|
|
19
|
+
watch(model, (value) => {
|
|
20
|
+
if (JSON.stringify(value ?? {}) === emitted) return
|
|
21
|
+
rows.value = value && typeof value === 'object' ? Object.entries(value).map(([key, v]) => ({ key, value: String(v ?? '') })) : []
|
|
22
|
+
}, { immediate: true })
|
|
23
|
+
|
|
24
|
+
function sync() {
|
|
25
|
+
const made = Object.fromEntries(rows.value.filter(r => r.key.trim() !== '').map(r => [r.key.trim(), r.value]))
|
|
26
|
+
emitted = JSON.stringify(made)
|
|
27
|
+
model.value = made
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function add() {
|
|
31
|
+
rows.value = [...rows.value, { key: '', value: '' }]
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function remove(index: number) {
|
|
35
|
+
rows.value = rows.value.filter((_, i) => i !== index)
|
|
36
|
+
sync()
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const repeated = computed(() => {
|
|
40
|
+
const seen = new Set<string>()
|
|
41
|
+
const twice = new Set<string>()
|
|
42
|
+
for (const row of rows.value) {
|
|
43
|
+
const key = row.key.trim()
|
|
44
|
+
if (key && seen.has(key)) twice.add(key)
|
|
45
|
+
seen.add(key)
|
|
46
|
+
}
|
|
47
|
+
return [...twice]
|
|
48
|
+
})
|
|
49
|
+
const messages = computed(() => [...(repeated.value.length ? [t('fields.keyValue.repeated', { keys: repeated.value.join(', ') })] : []), ...props.errorMessages])
|
|
50
|
+
</script>
|
|
51
|
+
|
|
52
|
+
<template>
|
|
53
|
+
<div role="group" :aria-label="field.label">
|
|
54
|
+
<div class="text-body-2 text-medium-emphasis mb-1">{{ field.label }}</div>
|
|
55
|
+
<div v-for="(row, index) in rows" :key="index" class="d-flex ga-2 align-start mb-1" role="group" :aria-label="t('fields.keyValue.entry', { n: index + 1 })">
|
|
56
|
+
<v-text-field
|
|
57
|
+
v-model="row.key"
|
|
58
|
+
:label="keyLabel"
|
|
59
|
+
density="compact"
|
|
60
|
+
hide-details
|
|
61
|
+
:disabled="disabled"
|
|
62
|
+
@update:model-value="sync"
|
|
63
|
+
/>
|
|
64
|
+
<v-text-field
|
|
65
|
+
v-model="row.value"
|
|
66
|
+
:label="valueLabel"
|
|
67
|
+
density="compact"
|
|
68
|
+
hide-details
|
|
69
|
+
:disabled="disabled"
|
|
70
|
+
@update:model-value="sync"
|
|
71
|
+
/>
|
|
72
|
+
<v-btn icon="mdi-close" variant="text" size="small" class="mt-1" :disabled="disabled" :aria-label="t('fields.keyValue.remove', { n: index + 1 })" @click="remove(index)" />
|
|
73
|
+
</div>
|
|
74
|
+
<v-btn variant="text" size="small" prepend-icon="mdi-plus" :disabled="disabled || rows.length >= max" @click="add">{{ t('fields.keyValue.add') }}</v-btn>
|
|
75
|
+
<div v-for="message in messages" :key="message" class="text-error text-caption" role="alert">{{ message }}</div>
|
|
76
|
+
<div v-if="field.help" class="text-caption text-medium-emphasis">{{ field.help }}</div>
|
|
77
|
+
</div>
|
|
78
|
+
</template>
|