feat(10-03): open, edit and save a record with toast and 422 feedback
- The SPA loads the backend::lang bundle before /auth/me, sets the document language from meta.locale and renders plural messages with Intl.PluralRules; interpolate mirrors phrasebook for :name/:Name/:NAME - Create and record routes; mapWinterUrl maps recordUrl and redirects onto the controller's list, create and record routes only - List rows open their record; FormView loads the form schema and the record, shows context-allowed fields in the span grid, saves values keyed by field name and toasts the resolved saved message - A 422 puts each message under its field (aria-invalid, aria-describedby), shows the plural banner, focuses the first invalid field in schema order and clears a field's error on change - The D-05 registry maps text, textarea, number and dropdown; any other type renders the unsupported-field box with the type in DM Mono - The admin OpenAPI document declares the write request bodies (AdminRecord, AdminIDsRequest) and the list filter query as a deepObject, so the typed client can send them - New backend::lang form.load_failed key; boardwalk/dist rebuilt
This commit is contained in:
79
admin/src/components/form/fields/DropdownField.vue
Normal file
79
admin/src/components/form/fields/DropdownField.vue
Normal file
@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { ChevronDown } from '@lucide/vue'
|
||||
import type { FormOption } from '../../../api/types'
|
||||
import { t } from '../../../app/i18n'
|
||||
import { controlClass, type FieldControlProps } from '../registry'
|
||||
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: FormOption['value']] }>()
|
||||
|
||||
const EMPTY = 'empty'
|
||||
const PLACEHOLDER = 'placeholder'
|
||||
|
||||
const options = computed(() => props.field.options ?? [])
|
||||
|
||||
function same(a: unknown, b: unknown): boolean {
|
||||
if (a === null || a === undefined || b === null || b === undefined) {
|
||||
return a === b || (a == null && b == null)
|
||||
}
|
||||
return String(a) === String(b)
|
||||
}
|
||||
|
||||
// The <select> works on indexes so option values keep their JSON type
|
||||
// (string, number or boolean) when they are sent back.
|
||||
const selected = computed(() => {
|
||||
const index = options.value.findIndex((option) => same(option.value, props.modelValue))
|
||||
if (index >= 0) {
|
||||
return String(index)
|
||||
}
|
||||
if (props.field.emptyOption !== undefined && (props.modelValue === null || props.modelValue === undefined || props.modelValue === '')) {
|
||||
return EMPTY
|
||||
}
|
||||
return PLACEHOLDER
|
||||
})
|
||||
|
||||
const muted = computed(() => selected.value === EMPTY || selected.value === PLACEHOLDER)
|
||||
|
||||
function onChange(event: Event): void {
|
||||
const value = (event.target as HTMLSelectElement).value
|
||||
if (value === EMPTY) {
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
const option = options.value[Number(value)]
|
||||
if (option) {
|
||||
emit('update:modelValue', option.value)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<select
|
||||
:id="controlId"
|
||||
:name="field.name"
|
||||
:value="selected"
|
||||
:required="field.required || undefined"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:class="[controlClass(invalid), muted ? 'text-muted' : '']"
|
||||
class="h-input appearance-none pr-10"
|
||||
@change="onChange"
|
||||
>
|
||||
<option v-if="field.emptyOption !== undefined" :value="EMPTY" class="text-muted">{{ field.emptyOption }}</option>
|
||||
<option v-else-if="selected === PLACEHOLDER" :value="PLACEHOLDER" disabled>
|
||||
{{ t('backend::lang.form.select_placeholder') }}
|
||||
</option>
|
||||
<option v-for="(option, index) in options" :key="index" :value="String(index)" class="text-text">
|
||||
{{ option.label }}
|
||||
</option>
|
||||
</select>
|
||||
<ChevronDown
|
||||
:size="16"
|
||||
class="pointer-events-none absolute top-1/2 right-3.5 -translate-y-1/2 text-muted"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
39
admin/src/components/form/fields/NumberField.vue
Normal file
39
admin/src/components/form/fields/NumberField.vue
Normal file
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
|
||||
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: number | null] }>()
|
||||
|
||||
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
|
||||
const attrs = computed(() => controlAttributes(props.field))
|
||||
|
||||
// An empty input clears the value (null); anything else is sent as a number.
|
||||
function onInput(event: Event): void {
|
||||
const raw = (event.target as HTMLInputElement).value.trim()
|
||||
if (raw === '') {
|
||||
emit('update:modelValue', null)
|
||||
return
|
||||
}
|
||||
const value = Number(raw)
|
||||
emit('update:modelValue', Number.isFinite(value) ? value : null)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:id="controlId"
|
||||
v-bind="attrs"
|
||||
:name="field.name"
|
||||
type="number"
|
||||
inputmode="decimal"
|
||||
:value="text"
|
||||
:required="field.required || undefined"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:class="controlClass(invalid)"
|
||||
class="h-input tabular-nums"
|
||||
@input="onInput"
|
||||
/>
|
||||
</template>
|
||||
27
admin/src/components/form/fields/TextField.vue
Normal file
27
admin/src/components/form/fields/TextField.vue
Normal file
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
|
||||
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
|
||||
const attrs = computed(() => controlAttributes(props.field))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<input
|
||||
:id="controlId"
|
||||
v-bind="attrs"
|
||||
:name="field.name"
|
||||
type="text"
|
||||
:value="text"
|
||||
:required="field.required || undefined"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:class="controlClass(invalid)"
|
||||
class="h-input"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLInputElement).value)"
|
||||
/>
|
||||
</template>
|
||||
31
admin/src/components/form/fields/TextareaField.vue
Normal file
31
admin/src/components/form/fields/TextareaField.vue
Normal file
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { controlAttributes, controlClass, type FieldControlProps } from '../registry'
|
||||
|
||||
const props = defineProps<FieldControlProps>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
|
||||
|
||||
// Winter textarea sizes mapped to rows; the design's Opis uses 4 (small).
|
||||
const rowsBySize: Record<string, number> = { tiny: 2, small: 4, large: 8, huge: 12, giant: 18 }
|
||||
|
||||
const rows = computed(() => rowsBySize[props.field.size ?? ''] ?? 5)
|
||||
const text = computed(() => (props.modelValue === null || props.modelValue === undefined ? '' : String(props.modelValue)))
|
||||
const attrs = computed(() => controlAttributes(props.field))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<textarea
|
||||
:id="controlId"
|
||||
v-bind="attrs"
|
||||
:name="field.name"
|
||||
:rows="rows"
|
||||
:value="text"
|
||||
:required="field.required || undefined"
|
||||
:aria-required="field.required ? 'true' : undefined"
|
||||
:aria-invalid="invalid ? 'true' : undefined"
|
||||
:aria-describedby="describedBy || undefined"
|
||||
:class="controlClass(invalid)"
|
||||
class="min-h-input resize-y py-2.5"
|
||||
@input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
|
||||
/>
|
||||
</template>
|
||||
34
admin/src/components/form/fields/UnsupportedField.vue
Normal file
34
admin/src/components/form/fields/UnsupportedField.vue
Normal file
@@ -0,0 +1,34 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { Puzzle } from '@lucide/vue'
|
||||
import { t } from '../../../app/i18n'
|
||||
import type { FieldControlProps } from '../registry'
|
||||
|
||||
// D-05 fallback for any field type without a renderer. The translated
|
||||
// sentence is split around the type so the type renders in DM Mono, as text.
|
||||
const props = defineProps<FieldControlProps>()
|
||||
|
||||
const MARK = '\u0000'
|
||||
|
||||
const parts = computed(() => {
|
||||
const [before = '', after = ''] = t('backend::lang.form.unsupported_field', { type: MARK }).split(MARK)
|
||||
return { before, after }
|
||||
})
|
||||
|
||||
const hasType = computed(() => t('backend::lang.form.unsupported_field', { type: MARK }).includes(MARK))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:id="controlId"
|
||||
data-unsupported-field
|
||||
:aria-describedby="describedBy || undefined"
|
||||
class="flex min-h-input items-center gap-2.5 rounded-control border-[1.5px] border-dashed border-border-strong bg-subtle px-3.5 text-[13px] text-muted"
|
||||
>
|
||||
<Puzzle :size="16" aria-hidden="true" />
|
||||
<span v-if="hasType"
|
||||
>{{ parts.before }}<code class="font-mono text-text">{{ props.field.type }}</code>{{ parts.after }}</span
|
||||
>
|
||||
<span v-else>{{ t('backend::lang.form.unsupported_field') }} <code class="font-mono text-text">{{ props.field.type }}</code></span>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user