@plutocms/pluto 0.6.0 → 0.8.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.
Files changed (29) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/FEATURES.md +4 -1
  3. package/app/components/Pluto/Content/PlutoContentField.vue +34 -0
  4. package/app/components/Pluto/Content/PlutoContentForm.vue +222 -0
  5. package/app/components/Pluto/Content/PlutoContentList.vue +197 -0
  6. package/app/components/Pluto/Content/fields/PlutoFieldBoolean.vue +23 -0
  7. package/app/components/Pluto/Content/fields/PlutoFieldDate.vue +41 -0
  8. package/app/components/Pluto/Content/fields/PlutoFieldNumber.vue +30 -0
  9. package/app/components/Pluto/Content/fields/PlutoFieldReference.vue +79 -0
  10. package/app/components/Pluto/Content/fields/PlutoFieldSelect.vue +28 -0
  11. package/app/components/Pluto/Content/fields/PlutoFieldSlug.vue +33 -0
  12. package/app/components/Pluto/Content/fields/PlutoFieldText.vue +40 -0
  13. package/app/composables/pluto-admin-nav.ts +49 -9
  14. package/app/composables/pluto-admin-pages.ts +28 -3
  15. package/app/composables/pluto-content-item.ts +98 -0
  16. package/app/composables/pluto-content-list.ts +60 -0
  17. package/app/pages/admin/content/[type]/[id].vue +19 -0
  18. package/app/pages/admin/content/[type]/index.vue +19 -0
  19. package/app/pages/admin/content/[type]/new.vue +19 -0
  20. package/app/plugins/pluto-core-registrations.ts +32 -3
  21. package/package.json +2 -1
  22. package/server/api/_pluto/content/[type]/[id].delete.ts +15 -0
  23. package/server/api/_pluto/content/[type]/[id].get.ts +13 -0
  24. package/server/api/_pluto/content/[type]/[id].patch.ts +14 -0
  25. package/server/api/_pluto/content/[type]/index.get.ts +16 -0
  26. package/server/api/_pluto/content/[type]/index.post.ts +13 -0
  27. package/server/utils/pluto-content-handlers.ts +168 -0
  28. package/server/utils/pluto-content.ts +44 -0
  29. package/shared/types/content.ts +57 -0
package/CHANGELOG.md CHANGED
@@ -1,6 +1,20 @@
1
1
  # Changelog
2
2
 
3
3
 
4
+ ## [0.8.0](https://github.com/plutocms/pluto/compare/v0.7.0...v0.8.0) (2026-09-13)
5
+
6
+
7
+ ### Features
8
+
9
+ * **content:** add generated admin UI for content model (wave 3) ([#135](https://github.com/plutocms/pluto/issues/135)) ([b08c5c2](https://github.com/plutocms/pluto/commit/b08c5c28f58380c805d527c1f7a7c9d194fb08f9))
10
+
11
+ ## [0.7.0](https://github.com/plutocms/pluto/compare/v0.6.0...v0.7.0) (2026-09-12)
12
+
13
+
14
+ ### Features
15
+
16
+ * **content:** add server-side content registry and generic CRUD routes (wave 2) ([#133](https://github.com/plutocms/pluto/issues/133)) ([7846340](https://github.com/plutocms/pluto/commit/784634081f5a267fbe33dde8264e6b848bf7bdda))
17
+
4
18
  ## [0.6.0](https://github.com/plutocms/pluto/compare/v0.5.0...v0.6.0) (2026-09-12)
5
19
 
6
20
 
package/FEATURES.md CHANGED
@@ -14,5 +14,8 @@ policies shared by every project here.
14
14
  permissions driver. @.claude/skills/permissions/SKILL.md
15
15
  - Content model: a typed content-type contract (`defineContentType`, field types, payload
16
16
  validation, field-to-column mapping) and a media-adapter contract, with registry wiring and
17
- consumer composables. Client-side contracts only; no server registry or generated UI yet.
17
+ consumer composables, a server-side content registry and generic list/get/create/update/
18
+ delete routes backed by a `PlutoContentAdapter` a backend layer implements, and a generated
19
+ admin UI (list/form components, eight built-in field widgets, generated
20
+ `/admin/content/**` pages, and nav/pages derivation). No real adapter yet.
18
21
  @.claude/skills/content-model/SKILL.md
@@ -0,0 +1,34 @@
1
+ <script setup lang="ts">
2
+ // Dispatches to whichever widget is registered for `field`, and re-emits
3
+ // `update:modelValue` unchanged. Never needs to know which concrete widget
4
+ // it dispatched to — every widget shares the same field/modelValue/disabled
5
+ // contract (see the content-model skill).
6
+ const props = defineProps<{
7
+ field: PlutoField
8
+ modelValue: unknown
9
+ disabled?: boolean
10
+ }>()
11
+
12
+ const emit = defineEmits<{
13
+ 'update:modelValue': [value: unknown]
14
+ }>()
15
+
16
+ const widget = usePlutoContentFieldWidget(props.field)
17
+ </script>
18
+
19
+ <template>
20
+ <component
21
+ :is="widget"
22
+ v-if="widget"
23
+ :field="field"
24
+ :model-value="modelValue"
25
+ :disabled="disabled"
26
+ @update:model-value="emit('update:modelValue', $event)"
27
+ />
28
+
29
+ <UFormField v-else :label="field.label">
30
+ <p class="text-muted text-sm">
31
+ No widget registered for field type "{{ field.type }}".
32
+ </p>
33
+ </UFormField>
34
+ </template>
@@ -0,0 +1,222 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ /** A content type's `name` (not a registry entry id) — see `usePlutoContentTypes`. */
4
+ type: string
5
+ /** Present in edit mode. Absent in "new entry" mode. */
6
+ id?: string | number
7
+ }>()
8
+
9
+ const { byName } = usePlutoContentTypes()
10
+ const contentType = computed(() => byName(props.type))
11
+
12
+ if (!contentType.value) {
13
+ // The generated page (`app/pages/admin/content/[type]/{new,[id]}.vue`)
14
+ // already resolves the type and 404s before this component ever mounts,
15
+ // so this should not normally happen.
16
+ console.error(`PlutoContentForm: content type "${props.type}" was not found in the registry.`)
17
+ }
18
+
19
+ const { can } = usePlutoPermissions()
20
+
21
+ // "No capability declared" means the operation is open to anyone who can
22
+ // reach this page — never call can('') as a stand-in for that.
23
+ const canWrite = computed(
24
+ () => !contentType.value?.capabilities?.write || can(contentType.value.capabilities.write)
25
+ )
26
+
27
+ const editing = computed(() => props.id !== undefined)
28
+ const basePath = computed(() => contentType.value?.basePath ?? `/admin/content/${props.type}`)
29
+
30
+ const { item, save } = usePlutoContentItem(props.type, () => props.id)
31
+
32
+ function sortedFields(region: 'main' | 'side') {
33
+ return computed(() =>
34
+ (contentType.value?.fields ?? [])
35
+ .filter((field) => field.inForm !== false && (field.region ?? 'main') === region)
36
+ .sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
37
+ )
38
+ }
39
+
40
+ const mainFields = sortedFields('main')
41
+ const sideFields = sortedFields('side')
42
+
43
+ // --- Slug auto-derivation, with the PostForm.vue bug fixed -----------------
44
+ //
45
+ // PostForm.vue re-derives its slug from the title on every keystroke, with
46
+ // no gate at all — including while editing an already-published,
47
+ // already-shared post, silently changing a live URL. Auto-derivation here
48
+ // stops the moment either:
49
+ //
50
+ // 1. The form is in edit mode (`editing` is true) — an existing entry's
51
+ // slug is presumed already meaningful, and never auto-changes without
52
+ // the user directly touching the slug field.
53
+ // 2. The user has typed into the slug field directly, even while creating
54
+ // a new entry — tracked by `slugTouched`, flipped by `updateField` below
55
+ // the moment a 'slug' field's own `update:modelValue` fires.
56
+ const slugTouched = ref(false)
57
+ const slugField = computed(() =>
58
+ contentType.value?.fields.find((field): field is PlutoSlugField => field.type === 'slug')
59
+ )
60
+
61
+ function slugify(value: string): string {
62
+ return value
63
+ .toLowerCase()
64
+ .trim()
65
+ .replace(/[^a-z0-9]+/g, '-')
66
+ .replace(/(^-|-$)/g, '')
67
+ }
68
+
69
+ watch(
70
+ () => {
71
+ const from = slugField.value?.from
72
+ return from ? item.value[from] : undefined
73
+ },
74
+ (value) => {
75
+ if (!slugField.value || editing.value || slugTouched.value) {
76
+ return
77
+ }
78
+
79
+ item.value[slugField.value.name] = slugify(String(value ?? ''))
80
+ }
81
+ )
82
+
83
+ function updateField(field: PlutoField, value: unknown) {
84
+ if (field.type === 'slug') {
85
+ slugTouched.value = true
86
+ }
87
+
88
+ item.value[field.name] = value
89
+ }
90
+
91
+ // --- Save -------------------------------------------------------------------
92
+
93
+ const toast = useToast()
94
+ const saving = ref(false)
95
+ const validationErrors = ref<ContentValidationError[]>([])
96
+
97
+ function buildPayload(): Record<string, unknown> {
98
+ const type = contentType.value
99
+ if (!type) {
100
+ return {}
101
+ }
102
+
103
+ const payload: Record<string, unknown> = {}
104
+ for (const field of type.fields) {
105
+ if (Object.hasOwn(item.value, field.name)) {
106
+ payload[field.name] = item.value[field.name]
107
+ }
108
+ }
109
+
110
+ return payload
111
+ }
112
+
113
+ async function onSave() {
114
+ const type = contentType.value
115
+ if (!type) {
116
+ return
117
+ }
118
+
119
+ const payload = buildPayload()
120
+ const errors = validateContentPayload(type, payload, { partial: editing.value })
121
+
122
+ if (errors.length > 0) {
123
+ validationErrors.value = errors
124
+ return
125
+ }
126
+
127
+ validationErrors.value = []
128
+ saving.value = true
129
+
130
+ try {
131
+ const saved = await save(payload)
132
+
133
+ if (!editing.value) {
134
+ await navigateTo(`${basePath.value}/${saved.id}`)
135
+ return
136
+ }
137
+
138
+ toast.add({
139
+ title: 'Saved',
140
+ description: `${type.label} updated successfully.`,
141
+ color: 'success',
142
+ })
143
+ } catch {
144
+ // A raced permissions check, or any other server-side rejection
145
+ // (a 403 included), surfaces here rather than as an unhandled
146
+ // rejection.
147
+ toast.add({
148
+ title: 'Error',
149
+ description: 'An error occurred while saving.',
150
+ color: 'error',
151
+ })
152
+ } finally {
153
+ saving.value = false
154
+ }
155
+ }
156
+ </script>
157
+
158
+ <template>
159
+ <div v-if="contentType">
160
+ <AdminView>
161
+ <UAlert
162
+ v-if="validationErrors.length > 0"
163
+ color="error"
164
+ title="Please fix the following before saving"
165
+ >
166
+ <template #description>
167
+ <ul class="list-disc pl-4">
168
+ <li v-for="fieldError in validationErrors" :key="fieldError.field">
169
+ {{ fieldError.message }}
170
+ </li>
171
+ </ul>
172
+ </template>
173
+ </UAlert>
174
+
175
+ <div class="flex flex-col gap-6 lg:flex-row lg:items-start lg:gap-10">
176
+ <div class="flex flex-1 flex-col gap-6">
177
+ <h1 class="text-3xl font-bold lg:text-4xl">
178
+ {{ editing ? `Edit ${contentType.label}` : `New ${contentType.label}` }}
179
+ </h1>
180
+
181
+ <PlutoContentField
182
+ v-for="field in mainFields"
183
+ :key="field.name"
184
+ :field="field"
185
+ :model-value="item[field.name]"
186
+ :disabled="!canWrite || saving"
187
+ @update:model-value="updateField(field, $event)"
188
+ />
189
+ </div>
190
+
191
+ <div class="flex w-full shrink-0 flex-col gap-6 lg:max-w-xs">
192
+ <UButton
193
+ v-if="canWrite"
194
+ :loading="saving"
195
+ icon="lucide:save"
196
+ class="justify-center"
197
+ @click="onSave"
198
+ >
199
+ Save
200
+ </UButton>
201
+
202
+ <PlutoContentField
203
+ v-for="field in sideFields"
204
+ :key="field.name"
205
+ :field="field"
206
+ :model-value="item[field.name]"
207
+ :disabled="!canWrite || saving"
208
+ @update:model-value="updateField(field, $event)"
209
+ />
210
+ </div>
211
+ </div>
212
+ </AdminView>
213
+ </div>
214
+
215
+ <AdminView v-else>
216
+ <UAlert
217
+ :description="`No content type named &quot;${type}&quot; is registered.`"
218
+ color="error"
219
+ title="Content type not found"
220
+ />
221
+ </AdminView>
222
+ </template>
@@ -0,0 +1,197 @@
1
+ <script setup lang="ts">
2
+ import type { TableColumn } from '@nuxt/ui'
3
+
4
+ const props = defineProps<{
5
+ /** A content type's `name` (not a registry entry id) — see `usePlutoContentTypes`. */
6
+ type: string
7
+ }>()
8
+
9
+ const { byName } = usePlutoContentTypes()
10
+ const contentType = computed(() => byName(props.type))
11
+
12
+ if (!contentType.value) {
13
+ // The generated page (`app/pages/admin/content/[type]/index.vue`) already
14
+ // resolves the type and 404s before this component ever mounts, so this
15
+ // should not normally happen.
16
+ console.error(`PlutoContentList: content type "${props.type}" was not found in the registry.`)
17
+ }
18
+
19
+ const { can } = usePlutoPermissions()
20
+
21
+ // "No capability declared" means the operation is open to anyone who can
22
+ // reach this page — never call can('') as a stand-in for that.
23
+ const canWrite = computed(
24
+ () => !contentType.value?.capabilities?.write || can(contentType.value.capabilities.write)
25
+ )
26
+ const canDelete = computed(
27
+ () => !contentType.value?.capabilities?.delete || can(contentType.value.capabilities.delete)
28
+ )
29
+
30
+ const basePath = computed(() => contentType.value?.basePath ?? `/admin/content/${props.type}`)
31
+
32
+ const { items, pending, refresh, remove } = usePlutoContentList(props.type)
33
+
34
+ const listFields = computed(() =>
35
+ (contentType.value?.fields ?? [])
36
+ .filter((field) => field.inList)
37
+ .sort((a, b) => (a.order ?? 100) - (b.order ?? 100))
38
+ )
39
+
40
+ const columns = computed<TableColumn<PlutoContentItem>[]>(() => [
41
+ ...listFields.value.map((field) => ({ accessorKey: field.name, header: field.label })),
42
+ { id: 'actions', header: '' },
43
+ ])
44
+
45
+ const pendingDeleteId = ref<string | number | null>(null)
46
+ const isDeleteModalOpen = ref(false)
47
+
48
+ function openDeleteModal(id: string | number) {
49
+ pendingDeleteId.value = id
50
+ isDeleteModalOpen.value = true
51
+ }
52
+
53
+ function closeDeleteModal() {
54
+ pendingDeleteId.value = null
55
+ isDeleteModalOpen.value = false
56
+ }
57
+
58
+ async function confirmDelete() {
59
+ if (pendingDeleteId.value === null) {
60
+ return
61
+ }
62
+
63
+ await remove(pendingDeleteId.value)
64
+ closeDeleteModal()
65
+ }
66
+ </script>
67
+
68
+ <template>
69
+ <div v-if="contentType">
70
+ <Modal v-model="isDeleteModalOpen" :custom-size="480">
71
+ <ModalHeader @close="closeDeleteModal">Remove item</ModalHeader>
72
+
73
+ <ModalContent>
74
+ <p>Do you really want to remove this item?</p>
75
+ </ModalContent>
76
+
77
+ <ModalFooter>
78
+ <div class="flex items-center gap-4">
79
+ <UButton icon="lucide:x" variant="ghost" color="neutral" @click="closeDeleteModal">
80
+ Cancel
81
+ </UButton>
82
+
83
+ <UButton icon="lucide:trash" color="error" @click="confirmDelete">Remove</UButton>
84
+ </div>
85
+ </ModalFooter>
86
+ </Modal>
87
+
88
+ <AdminView>
89
+ <div class="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
90
+ <hgroup class="flex items-center justify-between gap-x-3 lg:justify-start">
91
+ <h1 class="text-3xl font-bold lg:text-4xl">{{ contentType.labelPlural }}</h1>
92
+
93
+ <UButton
94
+ :loading="pending"
95
+ icon="lucide:refresh-ccw"
96
+ variant="ghost"
97
+ title="Refresh"
98
+ square
99
+ @click="refresh()"
100
+ />
101
+ </hgroup>
102
+
103
+ <div v-if="canWrite" class="flex lg:shrink-0">
104
+ <UButton
105
+ :to="`${basePath}/new`"
106
+ icon="lucide:plus"
107
+ as="NuxtLink"
108
+ class="flex-1 justify-center lg:flex-none"
109
+ >
110
+ Add {{ contentType.labelPlural }}
111
+ </UButton>
112
+ </div>
113
+ </div>
114
+
115
+ <div class="grid gap-3 lg:hidden">
116
+ <UCard v-for="item in items" :key="item.id">
117
+ <div class="flex flex-col gap-4">
118
+ <div class="min-w-0">
119
+ <NuxtLink
120
+ :to="`${basePath}/${item.id}`"
121
+ class="block truncate text-lg font-semibold hover:underline"
122
+ >
123
+ {{ item[contentType.titleField] }}
124
+ </NuxtLink>
125
+ </div>
126
+
127
+ <dl class="grid grid-cols-2 gap-3 text-sm">
128
+ <div v-for="field in listFields" :key="field.name">
129
+ <dt class="text-muted">{{ field.label }}</dt>
130
+ <dd class="font-medium">{{ item[field.name] }}</dd>
131
+ </div>
132
+ </dl>
133
+
134
+ <div class="flex gap-2 border-t border-default pt-3">
135
+ <UButton
136
+ v-if="canWrite"
137
+ :to="`${basePath}/${item.id}`"
138
+ icon="lucide:pen-line"
139
+ color="neutral"
140
+ variant="soft"
141
+ class="flex-1 justify-center"
142
+ >
143
+ Edit
144
+ </UButton>
145
+
146
+ <UButton
147
+ v-if="canDelete"
148
+ icon="lucide:trash"
149
+ color="error"
150
+ variant="soft"
151
+ class="flex-1 justify-center"
152
+ @click="openDeleteModal(item.id)"
153
+ >
154
+ Remove
155
+ </UButton>
156
+ </div>
157
+ </div>
158
+ </UCard>
159
+ </div>
160
+
161
+ <UCard :ui="{ body: 'sm:p-0 p-0' }" class="hidden lg:block">
162
+ <div class="overflow-x-auto">
163
+ <UTable :data="items" :columns="columns" :loading="pending" empty="No items yet.">
164
+ <template #actions-cell="{ row }">
165
+ <div class="flex justify-end gap-3">
166
+ <NuxtLink
167
+ v-if="canWrite"
168
+ :to="`${basePath}/${row.original.id}`"
169
+ class="text-info px-0 py-0.5 hover:underline"
170
+ >
171
+ Edit
172
+ </NuxtLink>
173
+
174
+ <button
175
+ v-if="canDelete"
176
+ type="button"
177
+ class="text-error cursor-pointer px-0 py-0.5 hover:underline"
178
+ @click="openDeleteModal(row.original.id)"
179
+ >
180
+ Remove
181
+ </button>
182
+ </div>
183
+ </template>
184
+ </UTable>
185
+ </div>
186
+ </UCard>
187
+ </AdminView>
188
+ </div>
189
+
190
+ <AdminView v-else>
191
+ <UAlert
192
+ :description="`No content type named &quot;${type}&quot; is registered.`"
193
+ color="error"
194
+ title="Content type not found"
195
+ />
196
+ </AdminView>
197
+ </template>
@@ -0,0 +1,23 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ field: PlutoBooleanField
4
+ modelValue: unknown
5
+ disabled?: boolean
6
+ }>()
7
+
8
+ const emit = defineEmits<{
9
+ 'update:modelValue': [value: boolean]
10
+ }>()
11
+
12
+ const value = computed(() => props.modelValue === true)
13
+ </script>
14
+
15
+ <template>
16
+ <UFormField :label="field.label" :description="field.description">
17
+ <USwitch
18
+ :model-value="value"
19
+ :disabled="disabled"
20
+ @update:model-value="emit('update:modelValue', $event)"
21
+ />
22
+ </UFormField>
23
+ </template>
@@ -0,0 +1,41 @@
1
+ <script setup lang="ts">
2
+ // UInputDate's modelValue is a `DateValue` (from `@internationalized/date`),
3
+ // not a plain string. A content item stores a 'date' field as a plain ISO
4
+ // date string ('YYYY-MM-DD'), so this widget converts both ways at its own
5
+ // boundary — the rest of the content model never sees a `DateValue`.
6
+ import type { CalendarDate, DateValue } from '@internationalized/date'
7
+ import { parseDate } from '@internationalized/date'
8
+
9
+ const props = defineProps<{
10
+ field: PlutoDateField
11
+ modelValue: unknown
12
+ disabled?: boolean
13
+ }>()
14
+
15
+ const emit = defineEmits<{
16
+ 'update:modelValue': [value: string | undefined]
17
+ }>()
18
+
19
+ function toCalendarDate(value: unknown): CalendarDate | undefined {
20
+ if (typeof value !== 'string' || value === '') {
21
+ return undefined
22
+ }
23
+
24
+ try {
25
+ return parseDate(value.slice(0, 10))
26
+ } catch {
27
+ return undefined
28
+ }
29
+ }
30
+
31
+ const value = computed<DateValue | undefined>({
32
+ get: () => toCalendarDate(props.modelValue),
33
+ set: (next) => emit('update:modelValue', next ? next.toString() : undefined),
34
+ })
35
+ </script>
36
+
37
+ <template>
38
+ <UFormField :label="field.label" :description="field.description" :required="field.required">
39
+ <UInputDate v-model="value" :disabled="disabled" />
40
+ </UFormField>
41
+ </template>
@@ -0,0 +1,30 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ field: PlutoNumberField
4
+ modelValue: unknown
5
+ disabled?: boolean
6
+ }>()
7
+
8
+ const emit = defineEmits<{
9
+ 'update:modelValue': [value: number | null]
10
+ }>()
11
+
12
+ const value = computed(() => {
13
+ const raw = props.modelValue
14
+ return typeof raw === 'number' ? raw : null
15
+ })
16
+ </script>
17
+
18
+ <template>
19
+ <UFormField :label="field.label" :description="field.description" :required="field.required">
20
+ <UInputNumber
21
+ :model-value="value"
22
+ :min="field.min"
23
+ :max="field.max"
24
+ :step="field.integer ? 1 : undefined"
25
+ :disabled="disabled"
26
+ class="w-full"
27
+ @update:model-value="emit('update:modelValue', $event)"
28
+ />
29
+ </UFormField>
30
+ </template>
@@ -0,0 +1,79 @@
1
+ <script setup lang="ts">
2
+ // Only `field.optionsUrl` is resolved in this wave. `field.target` (a
3
+ // same-app content-type reference with no `optionsUrl`) needs the generic
4
+ // list endpoint of *another* content type, which is out of scope here — see
5
+ // the content-model skill.
6
+ const props = defineProps<{
7
+ field: PlutoReferenceField
8
+ modelValue: unknown
9
+ disabled?: boolean
10
+ }>()
11
+
12
+ defineEmits<{
13
+ 'update:modelValue': [value: string | number]
14
+ }>()
15
+
16
+ interface ReferenceOption {
17
+ label: string
18
+ value: string | number
19
+ }
20
+
21
+ interface ReferenceOptionsResponse {
22
+ data: Array<Record<string, unknown>>
23
+ }
24
+
25
+ const options = ref<ReferenceOption[]>([])
26
+ const pending = ref(false)
27
+
28
+ async function loadOptions() {
29
+ if (!props.field.optionsUrl) {
30
+ return
31
+ }
32
+
33
+ pending.value = true
34
+
35
+ try {
36
+ const result = await $fetch<ReferenceOptionsResponse>(props.field.optionsUrl)
37
+ const labelKey = props.field.labelKey ?? 'label'
38
+ const valueKey = props.field.valueKey ?? 'id'
39
+
40
+ options.value = result.data.map((row) => ({
41
+ label: String(row[labelKey] ?? ''),
42
+ value: row[valueKey] as string | number,
43
+ }))
44
+ } finally {
45
+ pending.value = false
46
+ }
47
+ }
48
+
49
+ onMounted(loadOptions)
50
+
51
+ const hasOptionsSource = computed(() => Boolean(props.field.optionsUrl))
52
+ const value = computed(() => props.modelValue as string | number | undefined)
53
+ </script>
54
+
55
+ <template>
56
+ <UFormField :label="field.label" :description="field.description" :required="field.required">
57
+ <UInputMenu
58
+ v-if="hasOptionsSource"
59
+ :model-value="value"
60
+ :items="options"
61
+ :loading="pending"
62
+ :disabled="disabled"
63
+ value-key="value"
64
+ label-key="label"
65
+ class="w-full"
66
+ @update:model-value="$emit('update:modelValue', $event)"
67
+ />
68
+
69
+ <UInputMenu
70
+ v-else
71
+ :items="[]"
72
+ value-key="value"
73
+ label-key="label"
74
+ placeholder="No options source configured for this reference field."
75
+ class="w-full"
76
+ disabled
77
+ />
78
+ </UFormField>
79
+ </template>
@@ -0,0 +1,28 @@
1
+ <script setup lang="ts">
2
+ const props = defineProps<{
3
+ field: PlutoSelectField
4
+ modelValue: unknown
5
+ disabled?: boolean
6
+ }>()
7
+
8
+ defineEmits<{
9
+ 'update:modelValue': [value: string | number]
10
+ }>()
11
+
12
+ const items = computed(() =>
13
+ props.field.options.map((option) => ({ label: option.label, value: option.value }))
14
+ )
15
+ const value = computed(() => props.modelValue as string | number | undefined)
16
+ </script>
17
+
18
+ <template>
19
+ <UFormField :label="field.label" :description="field.description" :required="field.required">
20
+ <USelect
21
+ :model-value="value"
22
+ :items="items"
23
+ :disabled="disabled"
24
+ class="w-full"
25
+ @update:model-value="$emit('update:modelValue', $event)"
26
+ />
27
+ </UFormField>
28
+ </template>