135 lines
4.7 KiB
Vue
135 lines
4.7 KiB
Vue
<script setup lang="ts">
|
|
import { computed, nextTick, ref } from 'vue'
|
|
import { useRoute } from 'vue-router'
|
|
import { ArrowLeft } from '@lucide/vue'
|
|
import { api } from '../api/client'
|
|
import type { AdminRecord, FormView } from '../api/types'
|
|
import { t } from '../app/i18n'
|
|
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
|
import FormGrid from '../components/form/FormGrid.vue'
|
|
import { contextAllows, editablePayload, fieldErrors, focusField } from '../components/form/formState'
|
|
import Button from '../components/ui/Button.vue'
|
|
import { settingsEntry } from '../state/useSettings'
|
|
import { showToast } from '../state/useToasts'
|
|
|
|
// One settings page (D-21) through the same FormGrid and field registry as
|
|
// records: GET the schema and the values, PUT the values keyed by field name,
|
|
// map a 422 the same way. Settings forms have no create step.
|
|
const route = useRoute()
|
|
const code = String(route.params.code ?? '')
|
|
const ID_PREFIX = 'setting'
|
|
|
|
const schema = ref<FormView | null>(null)
|
|
const values = ref<AdminRecord>({})
|
|
const errors = ref<Record<string, string[]>>({})
|
|
const loading = ref(true)
|
|
const failed = ref(false)
|
|
const busy = ref(false)
|
|
|
|
const fields = computed(() => (schema.value?.fields ?? []).filter((field) => contextAllows(field, 'update')))
|
|
const fieldNames = computed(() => fields.value.map((field) => field.name))
|
|
const title = computed(() => settingsEntry(code)?.label || schema.value?.name || code)
|
|
const description = computed(() => settingsEntry(code)?.description ?? '')
|
|
|
|
async function load(): Promise<void> {
|
|
const params = { path: { code } }
|
|
const [schemaResult, valuesResult] = await Promise.all([
|
|
api.GET('/settings/{code}/schema', { params }),
|
|
api.GET('/settings/{code}', { params }),
|
|
])
|
|
schema.value = schemaResult.data?.data ?? null
|
|
values.value = { ...(valuesResult.data?.data.data ?? {}) }
|
|
failed.value = !schemaResult.data || !valuesResult.data
|
|
loading.value = false
|
|
}
|
|
|
|
function update(name: string, value: unknown): void {
|
|
values.value = { ...values.value, [name]: value }
|
|
if (errors.value[name]) {
|
|
const next = { ...errors.value }
|
|
delete next[name]
|
|
errors.value = next
|
|
}
|
|
}
|
|
|
|
async function onSave(): Promise<void> {
|
|
if (busy.value || !schema.value) {
|
|
return
|
|
}
|
|
busy.value = true
|
|
try {
|
|
const result = await api.PUT('/settings/{code}', {
|
|
params: { path: { code } },
|
|
body: editablePayload(fields.value, values.value),
|
|
})
|
|
if (result.data) {
|
|
errors.value = {}
|
|
values.value = { ...result.data.data.data }
|
|
showToast(t('backend::lang.settings.saved'))
|
|
return
|
|
}
|
|
if (result.response.status === 422) {
|
|
errors.value = fieldErrors(result.error?.error.details)
|
|
const first = fields.value.find((field) => errors.value[field.name])
|
|
await nextTick()
|
|
if (first) {
|
|
focusField(ID_PREFIX, first.name)
|
|
}
|
|
return
|
|
}
|
|
showToast(result.error?.error.message || t('backend::lang.form.error_generic'), 'danger')
|
|
} catch {
|
|
showToast(t('backend::lang.form.error_generic'), 'danger')
|
|
} finally {
|
|
busy.value = false
|
|
}
|
|
}
|
|
|
|
void load()
|
|
</script>
|
|
|
|
<template>
|
|
<section class="flex w-full flex-col gap-5 pb-24">
|
|
<header class="flex items-center gap-4">
|
|
<Button
|
|
variant="outline"
|
|
data-action="back"
|
|
to="/settings"
|
|
:aria-label="t('backend::lang.settings.back')"
|
|
class="size-10! px-0!"
|
|
>
|
|
<ArrowLeft :size="18" aria-hidden="true" />
|
|
</Button>
|
|
<div class="flex min-w-0 flex-col">
|
|
<h1 class="truncate text-[24px] font-bold tracking-[-0.02em]">{{ title }}</h1>
|
|
<p v-if="description" class="text-muted">{{ description }}</p>
|
|
</div>
|
|
</header>
|
|
|
|
<p v-if="failed && !loading" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
|
|
{{ t('backend::lang.form.load_failed') }}
|
|
</p>
|
|
|
|
<template v-else-if="schema">
|
|
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
|
|
<form class="rounded-card border border-border bg-surface p-7 shadow-card" novalidate @submit.prevent="onSave">
|
|
<FormGrid
|
|
:fields="fields"
|
|
:values="values"
|
|
:errors="errors"
|
|
:source="null"
|
|
:id-prefix="ID_PREFIX"
|
|
@update="update"
|
|
/>
|
|
</form>
|
|
</template>
|
|
|
|
<footer class="fixed inset-x-0 bottom-0 z-30 flex items-center justify-end gap-2.5 border-t border-border bg-surface px-8 py-3.5">
|
|
<Button variant="ghost" to="/settings">{{ t('backend::lang.form.cancel') }}</Button>
|
|
<Button variant="primary" data-action="save" :disabled="busy || loading || !schema" @click="onSave">
|
|
{{ busy ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
|
|
</Button>
|
|
</footer>
|
|
</section>
|
|
</template>
|