@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,351 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { SubmitEventPromise } from 'vuetify'
|
|
3
|
+
import type { VectorField, VectorFieldState, VectorLayoutNode, VectorRecord, VectorResource } from '#vector/app/types/vector'
|
|
4
|
+
|
|
5
|
+
// A create or edit form generated from the descriptor: one input per
|
|
6
|
+
// editable field, client rules mirroring the server's, and the server's
|
|
7
|
+
// 422 mapped onto the field it names. It knows whether it holds unsaved
|
|
8
|
+
// changes (`dirty`), and on a page offers save variants: back to the
|
|
9
|
+
// record, keep editing, or (creating) start another — the last one chosen
|
|
10
|
+
// remembered per user, Ctrl/Cmd+S running it.
|
|
11
|
+
const props = defineProps<{
|
|
12
|
+
resource: VectorResource
|
|
13
|
+
operation: 'create' | 'update'
|
|
14
|
+
record?: VectorRecord
|
|
15
|
+
/** Values fixed by the context (a parent a child is created under): pre-filled, not shown. */
|
|
16
|
+
preset?: Record<string, unknown>
|
|
17
|
+
/** Offer the save variants and Ctrl/Cmd+S: the create and edit pages do, a dialog does not. */
|
|
18
|
+
variants?: boolean
|
|
19
|
+
/** Creating a copy: the record whose values the form starts from (Duplicate). */
|
|
20
|
+
copyOf?: VectorRecord
|
|
21
|
+
}>()
|
|
22
|
+
|
|
23
|
+
/** What happens after a save: back to the record, keep editing it, or start another. */
|
|
24
|
+
export type SaveVariant = 'close' | 'continue' | 'another'
|
|
25
|
+
|
|
26
|
+
const emit = defineEmits<{ saved: [record: VectorRecord, variant: SaveVariant], cancel: [] }>()
|
|
27
|
+
|
|
28
|
+
const { t } = useVectorT()
|
|
29
|
+
const { inputFor, editable } = useVectorInputs()
|
|
30
|
+
const { resource: find } = useVectorSchema()
|
|
31
|
+
const notifications = useVectorNotifications()
|
|
32
|
+
const client = useVectorResource(() => props.resource.slug)
|
|
33
|
+
|
|
34
|
+
const serverErrors = reactive<Record<string, string[]>>({})
|
|
35
|
+
const submitting = ref(false)
|
|
36
|
+
const formError = ref<string | null>(null)
|
|
37
|
+
/** Relation fields whose current rows could not be read: shown as they are, never written from a guess. */
|
|
38
|
+
const unloaded = reactive(new Set<string>())
|
|
39
|
+
|
|
40
|
+
const fields = computed<VectorField[]>(() => props.resource.fields.filter(f => editable(f, props.operation)))
|
|
41
|
+
|
|
42
|
+
// The form's values, keyed by field name: relation values reduce to ids;
|
|
43
|
+
// `initial` is what they were loaded as, which `dirty` and an update compare against.
|
|
44
|
+
const values = reactive<Record<string, unknown>>({})
|
|
45
|
+
const initial = reactive<Record<string, unknown>>({})
|
|
46
|
+
const clone = (value: unknown) => value === undefined ? undefined : JSON.parse(JSON.stringify(value)) as unknown
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* What a copy takes from the record it duplicates: every value the create
|
|
50
|
+
* form writes, but never a unique one (it would clash) nor a file or image
|
|
51
|
+
* (a stored key belongs to its record). Collections load below.
|
|
52
|
+
*/
|
|
53
|
+
const copies = (field: VectorField) => props.operation === 'create' && props.copyOf !== undefined
|
|
54
|
+
&& field.unique !== true && !['file', 'image', 'manyToMany', 'hasMany'].includes(field.type)
|
|
55
|
+
|
|
56
|
+
/** Fills the values from the record, the preset, the record being copied, or each field's default. */
|
|
57
|
+
function start() {
|
|
58
|
+
for (const field of fields.value) {
|
|
59
|
+
const current = props.preset?.[field.name] ?? props.record?.[field.name] ?? (copies(field) ? props.copyOf![field.name] : undefined)
|
|
60
|
+
// a new record starts from each field's default, which the server would apply as well
|
|
61
|
+
let value: unknown = current !== undefined
|
|
62
|
+
? current
|
|
63
|
+
: props.operation === 'create' && field.hasDefault ? JSON.parse(JSON.stringify(field.default ?? null)) : defaultFor(field)
|
|
64
|
+
|
|
65
|
+
if (field.type === 'belongsTo' && current && typeof current === 'object' && 'id' in current) {
|
|
66
|
+
value = (current as { id: unknown }).id
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
if (field.type === 'manyToMany' || field.type === 'hasMany') {
|
|
70
|
+
value = [] // the ids, or a repeater's rows, load from their endpoint below
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
values[field.name] = value
|
|
74
|
+
initial[field.name] = clone(value)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
start()
|
|
78
|
+
|
|
79
|
+
function defaultFor(field: VectorField): unknown {
|
|
80
|
+
if (field.type === 'boolean') return false
|
|
81
|
+
if (field.type === 'manyToMany' || field.type === 'hasMany') return []
|
|
82
|
+
return null
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// A manyToMany field's current membership is not in the record (it lists
|
|
86
|
+
// as a count), so an edit form fetches every id it holds: the server
|
|
87
|
+
// syncs the whole list, so a partial one would detach what it left out.
|
|
88
|
+
// A copy takes the same collections: every membership, every repeater row (as a new row, without its id).
|
|
89
|
+
const loadFrom = props.operation === 'update' ? props.record : props.copyOf
|
|
90
|
+
|
|
91
|
+
if (loadFrom) {
|
|
92
|
+
for (const field of fields.value.filter(f => f.type === 'manyToMany')) {
|
|
93
|
+
allRelatedIds(loadFrom.id, field).then((ids) => {
|
|
94
|
+
values[field.name] = ids
|
|
95
|
+
initial[field.name] = [...ids]
|
|
96
|
+
}).catch((error) => {
|
|
97
|
+
unloaded.add(field.name)
|
|
98
|
+
formError.value = vectorErrorMessage(error)
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// A repeater's rows: each child as a row of the fields it writes, its relations as ids.
|
|
103
|
+
for (const field of fields.value.filter(f => f.repeater)) {
|
|
104
|
+
client.rows(loadFrom.id, field.name).catch((error) => {
|
|
105
|
+
unloaded.add(field.name)
|
|
106
|
+
formError.value = vectorErrorMessage(error)
|
|
107
|
+
|
|
108
|
+
return null
|
|
109
|
+
}).then((page) => {
|
|
110
|
+
if (page === null) return
|
|
111
|
+
|
|
112
|
+
const rows = page.data.map(record => Object.fromEntries([
|
|
113
|
+
...(props.operation === 'update' ? [['id', record.id]] : []),
|
|
114
|
+
...field.repeater!.fields.map((name) => {
|
|
115
|
+
const value = record[name]
|
|
116
|
+
return [name, value && typeof value === 'object' && 'id' in value ? (value as { id: unknown }).id : value]
|
|
117
|
+
}),
|
|
118
|
+
]))
|
|
119
|
+
values[field.name] = rows
|
|
120
|
+
initial[field.name] = JSON.parse(JSON.stringify(rows))
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Every id a many-to-many field holds, page by page: the target's page cap bounds one request, not the membership. */
|
|
126
|
+
async function allRelatedIds(id: string | number, field: VectorField): Promise<unknown[]> {
|
|
127
|
+
const perPage = (field.relation ? find(field.relation.resource)?.table.maxPerPage : undefined) ?? 100
|
|
128
|
+
const ids: unknown[] = []
|
|
129
|
+
|
|
130
|
+
for (let page = 1; ; page++) {
|
|
131
|
+
const result = await client.related(id, field.name, { page, perPage })
|
|
132
|
+
ids.push(...result.data.map(r => r.id))
|
|
133
|
+
|
|
134
|
+
if (page >= result.lastPage) return ids
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const wide = (field: VectorField) => ['markdown', 'richtext', 'text', 'json', 'keyValue'].includes(field.type) || !!field.repeater
|
|
139
|
+
|
|
140
|
+
/** Fields the browser's own rules refused on the last submit, by the input id each carries. */
|
|
141
|
+
const clientErrors = ref<string[]>([])
|
|
142
|
+
const inputId = (name: string) => `vx-field-${name}`
|
|
143
|
+
|
|
144
|
+
/** Whether a value differs from what the form loaded: what leaving would lose. */
|
|
145
|
+
const dirty = computed(() => fields.value.some(f => !unloaded.has(f.name) && JSON.stringify(values[f.name] ?? null) !== JSON.stringify(initial[f.name] ?? null)))
|
|
146
|
+
|
|
147
|
+
// The save variant: the user's last choice, where this form offers it.
|
|
148
|
+
const preferences = useVectorPreferences()
|
|
149
|
+
preferences.load().catch(() => undefined)
|
|
150
|
+
const offered = computed<SaveVariant[]>(() => props.operation === 'create' ? ['close', 'continue', 'another'] : ['close', 'continue'])
|
|
151
|
+
const chosen = computed<SaveVariant>(() => {
|
|
152
|
+
const stored = preferences.get<SaveVariant>('form.saveVariant')
|
|
153
|
+
return stored && offered.value.includes(stored) ? stored : 'close'
|
|
154
|
+
})
|
|
155
|
+
const variantLabel = (variant: SaveVariant) => {
|
|
156
|
+
const verb = props.operation === 'create' ? 'create' : 'save'
|
|
157
|
+
return variant === 'close' ? t(`common.${verb}`) : t(`record.variants.${verb}${variant === 'continue' ? 'Continue' : 'Another'}`)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const formRef = ref<{ validate: () => Promise<{ valid: boolean, errors: { id: string | number }[] }>, resetValidation: () => void } | null>(null)
|
|
161
|
+
let pendingVariant: SaveVariant | null = null
|
|
162
|
+
|
|
163
|
+
/** Saves as $variant, remembering it as this user's default. */
|
|
164
|
+
async function saveAs(variant: SaveVariant) {
|
|
165
|
+
if (variant !== chosen.value) preferences.put({ 'form.saveVariant': variant }).catch(() => undefined)
|
|
166
|
+
pendingVariant = variant
|
|
167
|
+
const result = await formRef.value?.validate()
|
|
168
|
+
if (result) await save(result)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Ctrl/Cmd+S saves with the default variant, on a page form
|
|
172
|
+
if (import.meta.client) {
|
|
173
|
+
useEventListener(window, 'keydown', (event: KeyboardEvent) => {
|
|
174
|
+
if (props.variants && (event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 's') {
|
|
175
|
+
event.preventDefault()
|
|
176
|
+
if (!submitting.value) saveAs(chosen.value)
|
|
177
|
+
}
|
|
178
|
+
})
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function submit(event: SubmitEventPromise) {
|
|
182
|
+
await save(await event)
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
async function save({ valid, errors }: { valid: boolean, errors: { id: string | number }[] }) {
|
|
186
|
+
const variant = pendingVariant ?? chosen.value
|
|
187
|
+
pendingVariant = null
|
|
188
|
+
clientErrors.value = errors.map(e => String(e.id)).filter(id => id.startsWith('vx-field-')).map(id => id.slice('vx-field-'.length))
|
|
189
|
+
|
|
190
|
+
if (!valid) {
|
|
191
|
+
return
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
submitting.value = true
|
|
195
|
+
formError.value = null
|
|
196
|
+
|
|
197
|
+
for (const key of Object.keys(serverErrors)) {
|
|
198
|
+
delete serverErrors[key]
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
try {
|
|
202
|
+
let response
|
|
203
|
+
|
|
204
|
+
if (props.operation === 'create') {
|
|
205
|
+
response = await client.create(payload(false))
|
|
206
|
+
} else {
|
|
207
|
+
const version = props.record?._version as number | undefined
|
|
208
|
+
response = await client.update(props.record!.id, payload(true), version)
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
notifications.success(t('record.saved', { label: props.resource.labelSingular }))
|
|
212
|
+
// what is saved is no longer a change: leaving now asks nothing
|
|
213
|
+
for (const field of fields.value) initial[field.name] = clone(values[field.name])
|
|
214
|
+
emit('saved', response.data, variant)
|
|
215
|
+
} catch (error) {
|
|
216
|
+
const failure = error as { status?: number, statusCode?: number, data?: { errors?: unknown } } | null
|
|
217
|
+
const violations = failure?.data?.errors
|
|
218
|
+
|
|
219
|
+
if ((failure?.status ?? failure?.statusCode) === 422 && Array.isArray(violations)) {
|
|
220
|
+
for (const violation of violations as { path: (string | number)[], message: string }[]) {
|
|
221
|
+
// a repeater row's own violation stays with its row: `lines.2.quantity`
|
|
222
|
+
const repeated = violation.path.length >= 3 && fields.value.some(f => f.name === violation.path[0] && f.repeater)
|
|
223
|
+
const key = repeated ? violation.path.slice(0, 3).join('.') : String(violation.path[0] ?? '')
|
|
224
|
+
|
|
225
|
+
if (key && (key in values || repeated)) {
|
|
226
|
+
(serverErrors[key] ??= []).push(`${violation.message.charAt(0).toUpperCase()}${violation.message.slice(1)}`)
|
|
227
|
+
} else {
|
|
228
|
+
formError.value = violation.message
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (!formError.value && Object.keys(serverErrors).length === 0) {
|
|
233
|
+
formError.value = t('common.serverRejected')
|
|
234
|
+
}
|
|
235
|
+
} else {
|
|
236
|
+
formError.value = vectorErrorMessage(error)
|
|
237
|
+
}
|
|
238
|
+
} finally {
|
|
239
|
+
submitting.value = false
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Create sends every editable field; update sends only what changed. */
|
|
244
|
+
function payload(onlyChanged: boolean): Record<string, unknown> {
|
|
245
|
+
const body: Record<string, unknown> = {}
|
|
246
|
+
|
|
247
|
+
for (const field of fields.value) {
|
|
248
|
+
const value = values[field.name]
|
|
249
|
+
|
|
250
|
+
if (unloaded.has(field.name)) {
|
|
251
|
+
continue // its current rows never arrived; writing an empty list would detach them all
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
if (onlyChanged && JSON.stringify(value) === JSON.stringify(initial[field.name])) {
|
|
255
|
+
continue
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (!onlyChanged && (value === null || value === '') && field.nullable !== true && field.type !== 'manyToMany') {
|
|
259
|
+
continue // let the server report the required field rather than sending an empty string
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
body[field.name] = value === '' ? null : value
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
return body
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function clearServerError(name: string) {
|
|
269
|
+
delete serverErrors[name]
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Back to a fresh form: the defaults a new record starts from, no errors. */
|
|
273
|
+
function reset() {
|
|
274
|
+
for (const key of Object.keys(serverErrors)) delete serverErrors[key]
|
|
275
|
+
formError.value = null
|
|
276
|
+
clientErrors.value = []
|
|
277
|
+
start()
|
|
278
|
+
formRef.value?.resetValidation()
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
defineExpose({ dirty, reset })
|
|
282
|
+
|
|
283
|
+
// Conditions (visibleWhen, requiredWhen, disabledWhen) follow the values as they are edited.
|
|
284
|
+
// Like the server, they read the stored record overlaid with the values being edited.
|
|
285
|
+
const states = computed<Record<string, VectorFieldState>>(() => {
|
|
286
|
+
const current = { ...(props.record ?? {}), ...values }
|
|
287
|
+
return Object.fromEntries(fields.value.map(f => [f.name, fieldState(f, current)]))
|
|
288
|
+
})
|
|
289
|
+
const shownFields = computed(() => fields.value.filter(f => states.value[f.name]?.visible !== false && !(props.preset && f.name in props.preset)))
|
|
290
|
+
const fieldsByName = computed(() => new Map(fields.value.map(f => [f.name, f])))
|
|
291
|
+
// what an input sees of the record: the values being edited, and the stored record's id (a rich-text image loads through it)
|
|
292
|
+
const inputRecord = computed(() => ({ ...values, id: props.record?.id }))
|
|
293
|
+
const shownNames = computed(() => new Set(shownFields.value.map(f => f.name)))
|
|
294
|
+
|
|
295
|
+
// The declared layout, or every field in order; either way VxLayout draws it.
|
|
296
|
+
const layout = computed<VectorLayoutNode[]>(() => props.resource.layout?.form
|
|
297
|
+
?? fields.value.map(f => ({ kind: 'field', label: null, description: null, collapsible: false, collapsed: false, columns: null, span: null, name: f.name, children: null })))
|
|
298
|
+
const defaultSpan = (name: string) => {
|
|
299
|
+
const field = fieldsByName.value.get(name)
|
|
300
|
+
return field && wide(field) ? 'full' as const : 1 as const
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** Every field holding an error: the server's (a repeater row's under its field) and the browser's. */
|
|
304
|
+
const errorNames = computed(() => [...new Set([
|
|
305
|
+
...Object.entries(serverErrors).filter(([, messages]) => messages.length > 0).map(([key]) => key.split('.')[0]!),
|
|
306
|
+
...clientErrors.value,
|
|
307
|
+
])])
|
|
308
|
+
</script>
|
|
309
|
+
|
|
310
|
+
<template>
|
|
311
|
+
<v-form ref="formRef" validate-on="blur lazy" @submit.prevent="submit">
|
|
312
|
+
<v-card>
|
|
313
|
+
<v-card-text>
|
|
314
|
+
<v-alert v-if="formError" type="error" variant="tonal" class="mb-4" :text="formError" />
|
|
315
|
+
<VxLayout :nodes="layout" :shown="name => shownNames.has(name)" :errors="errorNames" :default-span="defaultSpan">
|
|
316
|
+
<template #field="{ field: name }">
|
|
317
|
+
<component
|
|
318
|
+
:is="inputFor(fieldsByName.get(name)!)"
|
|
319
|
+
:id="inputId(name)"
|
|
320
|
+
v-model="values[name]"
|
|
321
|
+
:field="fieldsByName.get(name)!"
|
|
322
|
+
:disabled="states[name]?.disabled === true"
|
|
323
|
+
:rules="rulesFor(fieldsByName.get(name)!, operation, states[name])"
|
|
324
|
+
:error-messages="serverErrors[name] ?? []"
|
|
325
|
+
:record="inputRecord"
|
|
326
|
+
:resource="resource.slug"
|
|
327
|
+
v-bind="fieldsByName.get(name)!.repeater ? { serverErrors } : {}"
|
|
328
|
+
@update:model-value="clearServerError(name)"
|
|
329
|
+
/>
|
|
330
|
+
</template>
|
|
331
|
+
</VxLayout>
|
|
332
|
+
</v-card-text>
|
|
333
|
+
<v-card-actions>
|
|
334
|
+
<v-spacer />
|
|
335
|
+
<v-btn variant="text" @click="emit('cancel')">{{ t('common.cancel') }}</v-btn>
|
|
336
|
+
<v-btn v-if="!variants" type="submit" color="primary" :loading="submitting">{{ operation === 'create' ? t('common.create') : t('common.save') }}</v-btn>
|
|
337
|
+
<v-btn-group v-else color="primary" density="comfortable" divided>
|
|
338
|
+
<v-btn type="submit" :loading="submitting">{{ variantLabel(chosen) }}</v-btn>
|
|
339
|
+
<v-menu location="top end">
|
|
340
|
+
<template #activator="{ props: activator }">
|
|
341
|
+
<v-btn v-bind="activator" icon="mdi-menu-down" :aria-label="t('record.variants.menu')" :disabled="submitting" />
|
|
342
|
+
</template>
|
|
343
|
+
<v-list density="compact">
|
|
344
|
+
<v-list-item v-for="variant in offered" :key="variant" :title="variantLabel(variant)" :active="variant === chosen" @click="saveAs(variant)" />
|
|
345
|
+
</v-list>
|
|
346
|
+
</v-menu>
|
|
347
|
+
</v-btn-group>
|
|
348
|
+
</v-card-actions>
|
|
349
|
+
</v-card>
|
|
350
|
+
</v-form>
|
|
351
|
+
</template>
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorField, VectorRecord, VectorRelationValue, VectorResource } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
// One collection of a record (hasMany or manyToMany) as a panel under
|
|
5
|
+
// its show page: the related records as a server-paged table in the
|
|
6
|
+
// target's own columns, and the changes the relation allows here —
|
|
7
|
+
// creating a child with the parent pre-filled (hasMany), attaching and
|
|
8
|
+
// detaching existing records (manyToMany).
|
|
9
|
+
const props = defineProps<{
|
|
10
|
+
owner: VectorResource
|
|
11
|
+
record: VectorRecord
|
|
12
|
+
field: VectorField
|
|
13
|
+
canUpdate: boolean
|
|
14
|
+
}>()
|
|
15
|
+
|
|
16
|
+
const emit = defineEmits<{ changed: [] }>()
|
|
17
|
+
|
|
18
|
+
const { t, noun } = useVectorT()
|
|
19
|
+
const appConfig = useAppConfig()
|
|
20
|
+
const { resource: find } = useVectorSchema()
|
|
21
|
+
const notifications = useVectorNotifications()
|
|
22
|
+
const { confirm } = useConfirm()
|
|
23
|
+
const client = useVectorResource(() => props.owner.slug)
|
|
24
|
+
|
|
25
|
+
const target = computed(() => props.field.relation ? find(props.field.relation.resource) : undefined)
|
|
26
|
+
const inverse = computed(() => props.field.relation?.inverse ?? null)
|
|
27
|
+
const columns = computed<VectorField[]>(() => (target.value?.table.columns ?? [])
|
|
28
|
+
.filter(name => name !== inverse.value)
|
|
29
|
+
.map(name => target.value!.fields.find(f => f.name === name))
|
|
30
|
+
.filter((f): f is VectorField => !!f)
|
|
31
|
+
.slice(0, 6))
|
|
32
|
+
const manyToMany = computed(() => props.field.type === 'manyToMany')
|
|
33
|
+
const canAttach = computed(() => manyToMany.value && props.canUpdate && !props.field.readonly && props.field.views.includes('update'))
|
|
34
|
+
const canCreate = computed(() => props.field.type === 'hasMany' && !!inverse.value && target.value?.permissions.create === true
|
|
35
|
+
&& target.value.fields.some(f => f.name === inverse.value))
|
|
36
|
+
|
|
37
|
+
const headers = computed(() => [
|
|
38
|
+
...columns.value.map(f => ({ title: f.label, key: f.name, sortable: f.sortable })),
|
|
39
|
+
...(canAttach.value ? [{ title: '', key: '_actions', sortable: false, align: 'end' as const }] : []),
|
|
40
|
+
])
|
|
41
|
+
|
|
42
|
+
// --- the related page ---------------------------------------------------
|
|
43
|
+
const items = ref<VectorRecord[]>([])
|
|
44
|
+
const total = ref(0)
|
|
45
|
+
const page = ref(1)
|
|
46
|
+
const perPage = ref(10)
|
|
47
|
+
const sortBy = ref<{ key: string, order: 'asc' | 'desc' }[]>([])
|
|
48
|
+
const loading = ref(false)
|
|
49
|
+
|
|
50
|
+
async function load() {
|
|
51
|
+
loading.value = true
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const result = await client.related(props.record.id, props.field.name, {
|
|
55
|
+
page: page.value, perPage: perPage.value,
|
|
56
|
+
sort: sortBy.value.map(s => ({ field: s.key, order: s.order })),
|
|
57
|
+
})
|
|
58
|
+
items.value = result.data
|
|
59
|
+
total.value = result.total
|
|
60
|
+
} catch (error) {
|
|
61
|
+
notifications.error(vectorErrorMessage(error))
|
|
62
|
+
} finally {
|
|
63
|
+
loading.value = false
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
watch(() => props.record.id, () => { page.value = 1; load() })
|
|
68
|
+
|
|
69
|
+
function open(item: VectorRecord) {
|
|
70
|
+
if (target.value) navigateTo(`${appConfig.vector.basePath}/r/${target.value.slug}/${encodeURIComponent(String(item.id))}`)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// --- manyToMany: attach and detach ---------------------------------------
|
|
74
|
+
const attaching = ref(false)
|
|
75
|
+
const choice = ref<(number | string)[]>([])
|
|
76
|
+
const options = ref<VectorRelationValue[]>([])
|
|
77
|
+
const searching = ref(false)
|
|
78
|
+
const saving = ref(false)
|
|
79
|
+
|
|
80
|
+
const search = useDebounceFn(async (term: string) => {
|
|
81
|
+
searching.value = true
|
|
82
|
+
try {
|
|
83
|
+
options.value = (await client.options(props.field.name, term ?? '')).data
|
|
84
|
+
} catch (error) {
|
|
85
|
+
notifications.error(vectorErrorMessage(error))
|
|
86
|
+
} finally {
|
|
87
|
+
searching.value = false
|
|
88
|
+
}
|
|
89
|
+
}, 250)
|
|
90
|
+
|
|
91
|
+
function startAttach() {
|
|
92
|
+
choice.value = []
|
|
93
|
+
attaching.value = true
|
|
94
|
+
search('')
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
async function attach() {
|
|
98
|
+
if (!choice.value.length) return
|
|
99
|
+
saving.value = true
|
|
100
|
+
|
|
101
|
+
try {
|
|
102
|
+
await client.attach(props.record.id, props.field.name, choice.value)
|
|
103
|
+
notifications.success(t('record.panel.attached', { count: choice.value.length, label: label(choice.value.length) }))
|
|
104
|
+
attaching.value = false
|
|
105
|
+
await load()
|
|
106
|
+
emit('changed')
|
|
107
|
+
} catch (error) {
|
|
108
|
+
notifications.error(vectorErrorMessage(error))
|
|
109
|
+
} finally {
|
|
110
|
+
saving.value = false
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async function detach(item: VectorRecord) {
|
|
115
|
+
const title = String(item[target.value?.titleField ?? 'id'] ?? `#${item.id}`)
|
|
116
|
+
if (!await confirm({
|
|
117
|
+
title: t('record.panel.detachTitle', { title }),
|
|
118
|
+
text: t('record.panel.detachText', { target: target.value ? noun(target.value.labelSingular) : t('record.panel.record'), owner: noun(props.owner.labelSingular) }),
|
|
119
|
+
confirmText: t('record.panel.detach'),
|
|
120
|
+
destructive: true,
|
|
121
|
+
})) return
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
await client.detach(props.record.id, props.field.name, [item.id])
|
|
125
|
+
notifications.success(t('record.panel.detached', { title }))
|
|
126
|
+
await load()
|
|
127
|
+
emit('changed')
|
|
128
|
+
} catch (error) {
|
|
129
|
+
notifications.error(vectorErrorMessage(error))
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// --- hasMany: create a child under this record ---------------------------
|
|
134
|
+
const creating = ref(false)
|
|
135
|
+
const preset = computed(() => inverse.value ? { [inverse.value]: { id: props.record.id } } : {})
|
|
136
|
+
|
|
137
|
+
async function created() {
|
|
138
|
+
creating.value = false
|
|
139
|
+
await load()
|
|
140
|
+
emit('changed')
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function label(n: number): string {
|
|
144
|
+
return target.value ? noun(n === 1 ? target.value.labelSingular : target.value.label) : t('record.panel.records')
|
|
145
|
+
}
|
|
146
|
+
</script>
|
|
147
|
+
|
|
148
|
+
<template>
|
|
149
|
+
<v-card v-if="target" class="mt-4">
|
|
150
|
+
<v-card-title class="d-flex align-center ga-2 text-subtitle-1">
|
|
151
|
+
<v-icon v-if="target.icon" :icon="target.icon" size="small" />
|
|
152
|
+
{{ field.label }}
|
|
153
|
+
<v-chip size="x-small" variant="tonal">{{ total }}</v-chip>
|
|
154
|
+
<v-spacer />
|
|
155
|
+
<v-btn v-if="canAttach" size="small" variant="tonal" prepend-icon="mdi-link-plus" @click="startAttach">{{ t('record.panel.attach') }}</v-btn>
|
|
156
|
+
<v-btn v-if="canCreate" size="small" variant="tonal" color="primary" prepend-icon="mdi-plus" @click="creating = true">{{ t('record.newTitle', { label: noun(target.labelSingular) }) }}</v-btn>
|
|
157
|
+
</v-card-title>
|
|
158
|
+
|
|
159
|
+
<v-data-table-server
|
|
160
|
+
v-model:page="page"
|
|
161
|
+
v-model:items-per-page="perPage"
|
|
162
|
+
v-model:sort-by="sortBy"
|
|
163
|
+
:headers="headers"
|
|
164
|
+
:items="items"
|
|
165
|
+
:items-length="total"
|
|
166
|
+
:loading="loading"
|
|
167
|
+
:items-per-page-options="[5, 10, 25, 50]"
|
|
168
|
+
item-value="id"
|
|
169
|
+
density="compact"
|
|
170
|
+
hover
|
|
171
|
+
@update:options="load"
|
|
172
|
+
@click:row="(_: Event, { item }: { item: VectorRecord }) => open(item)"
|
|
173
|
+
>
|
|
174
|
+
<template v-for="column in columns" :key="column.name" #[`item.${column.name}`]="{ item }">
|
|
175
|
+
<VxCell :field="column" :value="item[column.name]" :record="item" :resource="target" context="index" />
|
|
176
|
+
</template>
|
|
177
|
+
<template #[`item._actions`]="{ item }">
|
|
178
|
+
<v-btn icon="mdi-link-off" size="small" variant="text" :aria-label="t('record.panel.detachLabel', { title: String(item[target.titleField] ?? item.id) })" @click.stop="detach(item)" />
|
|
179
|
+
</template>
|
|
180
|
+
<template #no-data>
|
|
181
|
+
<div class="text-medium-emphasis py-4">{{ t('record.panel.empty', { label: noun(target.label) }) }}</div>
|
|
182
|
+
</template>
|
|
183
|
+
</v-data-table-server>
|
|
184
|
+
|
|
185
|
+
<v-dialog v-model="attaching" max-width="520">
|
|
186
|
+
<v-card :title="t('record.panel.attachTitle', { label: noun(target.label) })">
|
|
187
|
+
<v-card-text>
|
|
188
|
+
<v-autocomplete
|
|
189
|
+
v-model="choice"
|
|
190
|
+
:items="options"
|
|
191
|
+
item-title="label"
|
|
192
|
+
item-value="id"
|
|
193
|
+
:label="target.label"
|
|
194
|
+
:loading="searching"
|
|
195
|
+
multiple
|
|
196
|
+
chips
|
|
197
|
+
closable-chips
|
|
198
|
+
no-filter
|
|
199
|
+
autofocus
|
|
200
|
+
@update:search="search"
|
|
201
|
+
/>
|
|
202
|
+
</v-card-text>
|
|
203
|
+
<v-card-actions>
|
|
204
|
+
<v-spacer />
|
|
205
|
+
<v-btn variant="text" :disabled="saving" @click="attaching = false">{{ t('common.cancel') }}</v-btn>
|
|
206
|
+
<v-btn color="primary" :loading="saving" :disabled="!choice.length" @click="attach">{{ t('record.panel.attachCount', { count: choice.length || '' }) }}</v-btn>
|
|
207
|
+
</v-card-actions>
|
|
208
|
+
</v-card>
|
|
209
|
+
</v-dialog>
|
|
210
|
+
|
|
211
|
+
<v-dialog v-model="creating" max-width="900">
|
|
212
|
+
<v-card :title="t('record.newTitle', { label: noun(target.labelSingular) })">
|
|
213
|
+
<VxResourceForm v-if="creating" :resource="target" operation="create" :preset="preset" @saved="created" @cancel="creating = false" />
|
|
214
|
+
</v-card>
|
|
215
|
+
</v-dialog>
|
|
216
|
+
</v-card>
|
|
217
|
+
</template>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
<script setup lang="ts">
|
|
2
|
+
import type { VectorField, VectorLayoutNode, VectorRecord, VectorResource } from '#vector/app/types/vector'
|
|
3
|
+
|
|
4
|
+
const props = defineProps<{ resource: VectorResource, record: VectorRecord }>()
|
|
5
|
+
|
|
6
|
+
const fields = computed<VectorField[]>(() => props.resource.show.fields
|
|
7
|
+
.map(name => props.resource.fields.find(f => f.name === name))
|
|
8
|
+
.filter((f): f is VectorField => f !== undefined))
|
|
9
|
+
const fieldsByName = computed(() => new Map(fields.value.map(f => [f.name, f])))
|
|
10
|
+
|
|
11
|
+
const wide = (field: VectorField) => ['markdown', 'richtext', 'text', 'json'].includes(field.type)
|
|
12
|
+
|
|
13
|
+
// The declared layout, or every field in order; either way VxLayout draws it.
|
|
14
|
+
const layout = computed<VectorLayoutNode[]>(() => props.resource.layout?.show
|
|
15
|
+
?? fields.value.map(f => ({ kind: 'field', label: null, description: null, collapsible: false, collapsed: false, columns: null, span: null, name: f.name, children: null })))
|
|
16
|
+
const defaultSpan = (name: string) => {
|
|
17
|
+
const field = fieldsByName.value.get(name)
|
|
18
|
+
return field && wide(field) ? 'full' as const : 1 as const
|
|
19
|
+
}
|
|
20
|
+
</script>
|
|
21
|
+
|
|
22
|
+
<template>
|
|
23
|
+
<v-card>
|
|
24
|
+
<v-card-text>
|
|
25
|
+
<VxLayout :nodes="layout" :shown="name => fieldsByName.has(name)" :default-span="defaultSpan">
|
|
26
|
+
<template #field="{ field: name }">
|
|
27
|
+
<div class="text-caption text-medium-emphasis">{{ fieldsByName.get(name)!.label }}</div>
|
|
28
|
+
<div class="text-body-1">
|
|
29
|
+
<VxCell :field="fieldsByName.get(name)!" :value="record[name]" :record="record" :resource="resource" context="show" />
|
|
30
|
+
</div>
|
|
31
|
+
</template>
|
|
32
|
+
</VxLayout>
|
|
33
|
+
</v-card-text>
|
|
34
|
+
</v-card>
|
|
35
|
+
</template>
|