feat(10-03): tabs, toggles, relation fields, form lifecycle and settings pages

- FormTabs groups fields by tab (untabbed fields in the default tab) as a
  segmented tablist; a tab holding invalid fields after a 422 shows a
  count badge and the form switches to the first invalid field
- switch, checkbox and relation join the renderer registry: toggle cards
  (role=switch, 20px checkbox) keep a numeric value numeric; relation
  fields are read-only labels from meta.labels, a searchable single
  select over fields/{field}/options with emptyOption first, or ordered
  removable chips with an appending search (300 ms debounce, 20 per
  page, more on scroll or the more action)
- FormView gets the back button, record title and update subtitle, a
  sticky footer (Usuń with deleteConfirm then DELETE, Anuluj, Zapisz i
  zamknij to the mapped redirectClose, Zapisz), and asks before leaving a
  dirty form on any route change plus a beforeunload guard
- Settings: the rail pins Ustawienia to the bottom when /settings is
  non-empty; /settings lists pages by category; /settings/:code renders
  the settings schema through FormGrid and the registry, PUTs the values
  and maps a 422 like the record form
- New backend::lang keys (form.more_options, tab_default, discard,
  settings.back); form and settings smoke tests; boardwalk/dist rebuilt
This commit is contained in:
Jakub Zych
2026-09-27 17:00:18 +02:00
parent 8b5f85603f
commit 8f32416f4f
30 changed files with 1898 additions and 58 deletions

View File

@@ -5,6 +5,8 @@ import { homePath } from '../state/useNavigation'
import LoginView from '../views/LoginView.vue'
import ListView from '../views/ListView.vue'
import FormView from '../views/FormView.vue'
import SettingsFormView from '../views/SettingsFormView.vue'
import SettingsIndexView from '../views/SettingsIndexView.vue'
import NotFoundView from '../views/NotFoundView.vue'
declare module 'vue-router' {
@@ -43,6 +45,8 @@ export function createAdminRouter(history: RouterHistory = createWebHistory(runt
meta: { shell: true },
beforeEnter: () => homePath() ?? true,
},
{ path: '/settings', name: 'settings', component: SettingsIndexView, meta: { shell: true } },
{ path: '/settings/:code', name: 'settings-form', component: SettingsFormView, meta: { shell: true } },
{ path: '/:vendor/:plugin/:controller', name: 'list', component: ListView, meta: { shell: true } },
{ path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, meta: { shell: true } },
{ path: '/:vendor/:plugin/:controller/:id(\\d+)', name: 'record', component: FormView, meta: { shell: true } },

View File

@@ -0,0 +1,53 @@
<script setup lang="ts">
import { tc } from '../../app/i18n'
import { panelDomId, tabDomId, type TabItem } from './formState'
// Segmented tab control (design screen 4): track subtle with a border, 34px
// pills, the active one on surface with a shadow. A tab holding invalid
// fields after a 422 carries a count badge. Arrow keys move between tabs.
const props = defineProps<{ tabs: TabItem[]; modelValue: string; idPrefix: string }>()
const emit = defineEmits<{ 'update:modelValue': [key: string] }>()
function onKeydown(event: KeyboardEvent, index: number): void {
const step = event.key === 'ArrowRight' ? 1 : event.key === 'ArrowLeft' ? -1 : 0
if (step === 0) {
return
}
event.preventDefault()
const nextIndex = (index + step + props.tabs.length) % props.tabs.length
const next = props.tabs[nextIndex]
if (next) {
emit('update:modelValue', next.key)
document.getElementById(tabDomId(props.idPrefix, nextIndex))?.focus()
}
}
</script>
<template>
<div role="tablist" class="inline-flex gap-1 rounded-inner border border-border bg-subtle p-1">
<button
v-for="(tab, index) in tabs"
:id="tabDomId(idPrefix, index)"
:key="tab.key"
type="button"
role="tab"
:data-tab="tab.key"
:aria-selected="tab.key === modelValue ? 'true' : 'false'"
:aria-controls="panelDomId(idPrefix, index)"
:tabindex="tab.key === modelValue ? 0 : -1"
:class="tab.key === modelValue ? 'bg-surface text-text shadow-tab' : 'text-muted hover:text-text'"
class="inline-flex h-[34px] items-center gap-2 rounded-tab px-4 font-semibold transition-colors duration-150 ease-out"
@click="emit('update:modelValue', tab.key)"
@keydown="onKeydown($event, index)"
>
{{ tab.label }}
<span
v-if="tab.errors > 0"
data-tab-errors
:aria-label="tc('backend::lang.form.tab_errors', tab.errors)"
class="inline-flex h-5 min-w-5 items-center justify-center rounded-pill bg-danger px-1.5 text-[11px] font-bold text-white"
>{{ tab.errors }}</span
>
</button>
</div>
</template>

View File

@@ -0,0 +1,39 @@
<script setup lang="ts">
import { computed } from 'vue'
import { toggleOn, toggleValue, type FieldControlProps } from '../registry'
// Toggle card (design screen 4) with a 20px checkbox on the right.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean | number] }>()
const on = computed(() => toggleOn(props.modelValue))
function onChange(event: Event): void {
emit('update:modelValue', toggleValue(props.modelValue, (event.target as HTMLInputElement).checked))
}
</script>
<template>
<div
:class="invalid ? 'border-danger' : 'border-border'"
class="flex items-center justify-between gap-4 rounded-inner border px-4 py-3.5"
data-toggle-card
>
<div class="flex min-w-0 flex-col">
<label :for="controlId" class="font-semibold">
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
</label>
<p v-if="field.comment" :id="`${controlId}-comment`" class="text-[13px] text-muted">{{ field.comment }}</p>
</div>
<input
:id="controlId"
type="checkbox"
:name="field.name"
:checked="on"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
class="size-5 shrink-0 rounded-[6px] accent-primary"
@change="onChange"
/>
</div>
</template>

View File

@@ -0,0 +1,403 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { ChevronDown, X } from '@lucide/vue'
import { api } from '../../../api/client'
import type { RelationOption } from '../../../api/types'
import { t } from '../../../app/i18n'
import type { FieldControlProps } from '../registry'
// Relation field (D-17, D-18, D-26), three modes from the schema:
// readOnly the label from meta.labels only, no control
// single a searchable select over fields/{field}/options; emptyOption
// first and muted; sends the id or null
// multiple removable chips in order plus an inline search that appends
// the chosen id
// Options are searched with a 300 ms debounce, 20 per page, with more loaded
// on scroll or through the "more" action. Labels come from meta.labels and
// from every options page seen.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: number | number[] | null] }>()
const PER_PAGE = 20
const SEARCH_DEBOUNCE = 300
const mode = computed(() => (props.field.readOnly ? 'readonly' : props.field.multiple ? 'multiple' : 'single'))
const listId = computed(() => `${props.controlId}-listbox`)
const known = ref<Record<number, string>>({})
watch(
() => props.labels,
(labels) => {
const next = { ...known.value }
for (const option of labels ?? []) {
next[option.value] = option.label
}
known.value = next
},
{ immediate: true },
)
function asId(value: unknown): number | null {
if (typeof value === 'number' && Number.isInteger(value)) {
return value
}
if (typeof value === 'string' && /^\d+$/.test(value)) {
return Number(value)
}
return null
}
const singleId = computed(() => asId(props.modelValue))
const selectedIds = computed(() =>
Array.isArray(props.modelValue) ? props.modelValue.map(asId).filter((id): id is number => id !== null) : [],
)
function labelOf(id: number): string {
return known.value[id] ?? String(id)
}
function initials(label: string): string {
return label
.split(/[\s@._-]+/)
.filter(Boolean)
.slice(0, 2)
.map((part) => part.charAt(0).toUpperCase())
.join('')
}
const readOnlyText = computed(() => {
const label = props.labels?.[0]?.label
if (label) {
return label
}
return props.field.emptyOption ?? '—'
})
// Options listbox.
const open = ref(false)
const query = ref('')
const options = ref<RelationOption[]>([])
const page = ref(0)
const lastPage = ref(0)
const loading = ref(false)
const active = ref(-1)
let generation = 0
let searchTimer: ReturnType<typeof setTimeout> | null = null
const hasMore = computed(() => page.value < lastPage.value)
async function fetchPage(nextPage: number, append: boolean): Promise<void> {
const source = props.source
if (!source) {
options.value = []
return
}
const current = ++generation
loading.value = true
try {
const search = query.value.trim()
const { data } = await api.GET('/{vendor}/{plugin}/{controller}/fields/{field}/options', {
params: {
path: { ...source, field: props.field.name },
query: { search: search === '' ? undefined : search, page: nextPage, per_page: PER_PAGE },
},
})
if (current !== generation) {
return
}
const rows = data?.data ?? []
const labels = { ...known.value }
for (const option of rows) {
labels[option.value] = option.label
}
known.value = labels
options.value = append ? [...options.value, ...rows] : rows
page.value = data?.meta.page ?? nextPage
lastPage.value = data?.meta.last_page ?? nextPage
} catch {
if (current === generation && !append) {
options.value = []
}
} finally {
if (current === generation) {
loading.value = false
}
}
}
interface Entry {
key: string
label: string
value: number | null
muted: boolean
}
const entries = computed<Entry[]>(() => {
const out: Entry[] = []
if (mode.value === 'single' && props.field.emptyOption !== undefined) {
out.push({ key: 'empty', label: props.field.emptyOption, value: null, muted: true })
}
const taken = new Set(selectedIds.value)
for (const option of options.value) {
if (mode.value === 'multiple' && taken.has(option.value)) {
continue
}
out.push({ key: `option-${option.value}`, label: option.label, value: option.value, muted: false })
}
return out
})
function optionId(index: number): string {
return `${listId.value}-${index}`
}
function openList(): void {
if (open.value) {
return
}
open.value = true
active.value = -1
void fetchPage(1, false)
}
function closeList(): void {
open.value = false
query.value = ''
active.value = -1
if (searchTimer !== null) {
clearTimeout(searchTimer)
searchTimer = null
}
}
function onQuery(event: Event): void {
query.value = (event.target as HTMLInputElement).value
open.value = true
active.value = -1
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
searchTimer = setTimeout(() => {
searchTimer = null
void fetchPage(1, false)
}, SEARCH_DEBOUNCE)
}
function loadMore(): void {
if (hasMore.value && !loading.value) {
void fetchPage(page.value + 1, true)
}
}
function onScroll(event: Event): void {
const list = event.target as HTMLElement
if (list.scrollTop + list.clientHeight >= list.scrollHeight - 24) {
loadMore()
}
}
function choose(entry: Entry): void {
if (mode.value === 'multiple') {
if (entry.value !== null && !selectedIds.value.includes(entry.value)) {
emit('update:modelValue', [...selectedIds.value, entry.value])
}
} else {
emit('update:modelValue', entry.value)
}
closeList()
}
function remove(id: number): void {
emit(
'update:modelValue',
selectedIds.value.filter((item) => item !== id),
)
}
function onKeydown(event: KeyboardEvent): void {
switch (event.key) {
case 'ArrowDown':
event.preventDefault()
if (!open.value) {
openList()
} else {
active.value = Math.min(active.value + 1, entries.value.length - 1)
}
break
case 'ArrowUp':
event.preventDefault()
active.value = Math.max(active.value - 1, 0)
break
case 'Enter':
if (open.value) {
event.preventDefault()
const entry = entries.value[active.value]
if (entry) {
choose(entry)
}
}
break
case 'Escape':
if (open.value) {
event.preventDefault()
closeList()
}
break
case 'Backspace':
if (mode.value === 'multiple' && query.value === '' && selectedIds.value.length > 0) {
remove(selectedIds.value[selectedIds.value.length - 1]!)
}
break
}
}
const singleText = computed(() => {
if (singleId.value !== null) {
return labelOf(singleId.value)
}
return props.field.emptyOption ?? ''
})
onBeforeUnmount(() => {
if (searchTimer !== null) {
clearTimeout(searchTimer)
}
})
</script>
<template>
<div
v-if="mode === 'readonly'"
:id="controlId"
data-relation-readonly
:aria-describedby="describedBy || undefined"
:class="labels?.[0]?.label ? 'text-text' : 'text-muted'"
class="flex min-h-input items-center rounded-control border border-border bg-subtle px-3.5"
>
{{ readOnlyText }}
</div>
<div v-else class="relative" :data-relation="mode">
<div
v-if="mode === 'multiple'"
:class="invalid ? 'border-danger' : 'border-border-strong'"
class="flex min-h-input flex-wrap items-center gap-1.5 rounded-control border bg-surface p-1.5"
>
<span
v-for="id in selectedIds"
:key="id"
data-chip
class="inline-flex h-[30px] items-center gap-1.5 rounded-pill bg-subtle pr-1 pl-1"
>
<span
class="flex size-[22px] items-center justify-center rounded-full bg-primary text-[10px] font-bold text-on-primary"
aria-hidden="true"
>{{ initials(labelOf(id)) }}</span
>
<span class="font-semibold">{{ labelOf(id) }}</span>
<button
type="button"
:aria-label="t('backend::lang.form.remove_item', { name: labelOf(id) })"
class="flex size-6 items-center justify-center rounded-full text-muted hover:bg-hover hover:text-text"
@click="remove(id)"
>
<X :size="14" aria-hidden="true" />
</button>
</span>
<input
:id="controlId"
type="text"
role="combobox"
autocomplete="off"
:aria-expanded="open ? 'true' : 'false'"
:aria-controls="listId"
aria-autocomplete="list"
:aria-activedescendant="open && active >= 0 ? optionId(active) : undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:value="query"
:placeholder="t('backend::lang.form.add_item')"
class="h-[30px] min-w-[120px] flex-1 border-0 bg-transparent px-2 focus-visible:shadow-none"
@click="openList"
@input="onQuery"
@keydown="onKeydown"
@blur="closeList"
/>
</div>
<div v-else class="relative">
<input
:id="controlId"
type="text"
role="combobox"
autocomplete="off"
:aria-expanded="open ? 'true' : 'false'"
:aria-controls="listId"
aria-autocomplete="list"
:aria-activedescendant="open && active >= 0 ? optionId(active) : undefined"
:aria-required="field.required ? 'true' : undefined"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:value="open ? query : singleText"
:placeholder="open ? singleText || t('backend::lang.form.select_placeholder') : t('backend::lang.form.select_placeholder')"
:class="[
invalid ? 'border-danger' : 'border-border-strong',
!open && singleId === null ? 'text-muted' : 'text-text',
]"
class="h-input w-full rounded-control border bg-surface pr-10 pl-3.5"
@click="openList"
@input="onQuery"
@keydown="onKeydown"
@blur="closeList"
/>
<ChevronDown
:size="16"
class="pointer-events-none absolute top-1/2 right-3.5 -translate-y-1/2 text-muted"
aria-hidden="true"
/>
</div>
<ul
v-show="open"
:id="listId"
role="listbox"
class="absolute inset-x-0 top-full z-20 mt-1 max-h-64 overflow-y-auto rounded-inner border border-border bg-surface p-1 shadow-menu"
@scroll="onScroll"
>
<li
v-for="(entry, index) in entries"
:id="optionId(index)"
:key="entry.key"
role="option"
:data-value="entry.value ?? 'empty'"
:aria-selected="mode === 'single' && entry.value === singleId ? 'true' : 'false'"
:class="[entry.muted ? 'text-muted' : 'text-text', index === active ? 'bg-hover' : '']"
class="flex h-10 cursor-pointer items-center rounded-control px-3 hover:bg-hover"
@mousedown.prevent="choose(entry)"
>
{{ entry.label }}
</li>
<li v-if="loading" role="presentation" class="px-3 py-2 text-[13px] text-muted">
{{ t('backend::lang.form.loading_options') }}
</li>
<li
v-else-if="entries.filter((entry) => entry.value !== null).length === 0"
role="presentation"
class="px-3 py-2 text-[13px] text-muted"
>
{{ t('backend::lang.form.no_options') }}
</li>
<li v-if="hasMore && !loading" role="presentation" class="p-1">
<button
type="button"
data-action="more-options"
class="h-8 w-full rounded-control text-[13px] font-semibold text-muted hover:bg-hover hover:text-text"
@mousedown.prevent="loadMore"
>
{{ t('backend::lang.form.more_options') }}
</button>
</li>
</ul>
</div>
</template>

View File

@@ -0,0 +1,47 @@
<script setup lang="ts">
import { computed } from 'vue'
import { toggleOn, toggleValue, type FieldControlProps } from '../registry'
// Toggle card (design screen 4): the label as title, the comment as helper
// text and a 44x26 role="switch" control; the off track uses borderStrong.
const props = defineProps<FieldControlProps>()
const emit = defineEmits<{ 'update:modelValue': [value: boolean | number] }>()
const on = computed(() => toggleOn(props.modelValue))
function toggle(): void {
emit('update:modelValue', toggleValue(props.modelValue, !on.value))
}
</script>
<template>
<div
:class="invalid ? 'border-danger' : 'border-border'"
class="flex items-center justify-between gap-4 rounded-inner border px-4 py-3.5"
data-toggle-card
>
<div class="flex min-w-0 flex-col">
<label :for="controlId" class="font-semibold">
{{ field.label || field.name }}<span v-if="field.required" class="text-danger" aria-hidden="true"> *</span>
</label>
<p v-if="field.comment" :id="`${controlId}-comment`" class="text-[13px] text-muted">{{ field.comment }}</p>
</div>
<button
:id="controlId"
type="button"
role="switch"
:aria-checked="on ? 'true' : 'false'"
:aria-invalid="invalid ? 'true' : undefined"
:aria-describedby="describedBy || undefined"
:class="on ? 'bg-primary' : 'bg-border-strong'"
class="relative h-[26px] w-11 shrink-0 rounded-pill transition-colors duration-150 ease-out"
@click="toggle"
>
<span
:class="on ? 'translate-x-[18px] bg-on-primary' : 'translate-x-0 bg-white'"
class="absolute top-[3px] left-[3px] size-5 rounded-full shadow-tab transition-transform duration-150 ease-out"
aria-hidden="true"
/>
</button>
</div>
</template>

View File

@@ -5,6 +5,30 @@ import { isRegistered } from './registry'
export type FormMode = 'create' | 'update'
/** One form tab: the YAML tab label, or the default tab for untabbed fields. */
export interface TabItem {
key: string
label: string
errors: number
}
/** Key of the tab that holds fields without a `tab`. */
export const DEFAULT_TAB = 'default'
/** Tab key of a field: its tab label, or the default tab. */
export function tabOf(field: FormField): string {
return field.tab ? `tab:${field.tab}` : DEFAULT_TAB
}
/** DOM ids of the tab button and its panel, by tab position. */
export function tabDomId(prefix: string, index: number): string {
return `${prefix}-tab-${index}`
}
export function panelDomId(prefix: string, index: number): string {
return `${prefix}-panel-${index}`
}
/** Winter `context:` semantics: no context shows the field everywhere. */
export function contextAllows(field: FormField, mode: FormMode): boolean {
const context = field.context

View File

@@ -1,11 +1,15 @@
// Field renderer registry (D-05). A form field's `type` selects the control
// component; any type without a renderer gets UnsupportedField, the design's
// dashed box, so an unknown type never breaks the form. Phase 10.1 turns this
// seam into the plugin extension point.
// dashed box, so an unknown type never breaks the form. The relation manager
// arrives with its own component in a later plan. Phase 10.1 turns this seam
// into the plugin extension point.
import type { Component } from 'vue'
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
import CheckboxField from './fields/CheckboxField.vue'
import DropdownField from './fields/DropdownField.vue'
import NumberField from './fields/NumberField.vue'
import RelationField from './fields/RelationField.vue'
import SwitchField from './fields/SwitchField.vue'
import TextField from './fields/TextField.vue'
import TextareaField from './fields/TextareaField.vue'
import UnsupportedField from './fields/UnsupportedField.vue'
@@ -30,10 +34,13 @@ const renderers = new Map<string, Component>([
['textarea', TextareaField],
['number', NumberField],
['dropdown', DropdownField],
['switch', SwitchField],
['checkbox', CheckboxField],
['relation', RelationField],
])
/** Types whose control shows the label itself (toggle cards). */
const selfLabelled = new Set<string>()
const selfLabelled = new Set<string>(['switch', 'checkbox'])
export function rendererFor(type: string): Component {
return renderers.get(type) ?? UnsupportedField
@@ -82,3 +89,16 @@ export function controlClass(invalid: boolean | undefined): string {
invalid ? 'border-danger' : 'border-border-strong',
].join(' ')
}
/** Truthiness of a stored toggle value (true, 1, "1", "true"). */
export function toggleOn(value: unknown): boolean {
if (typeof value === 'string') {
return value !== '' && value !== '0' && value.toLowerCase() !== 'false'
}
return Boolean(value)
}
/** A toggle's next value in the stored value's own type (1/0 or boolean). */
export function toggleValue(current: unknown, on: boolean): boolean | number {
return typeof current === 'number' ? (on ? 1 : 0) : on
}

View File

@@ -45,7 +45,10 @@ const initials = computed(() =>
<div class="flex min-w-0 flex-1 flex-col">
<header class="flex h-header shrink-0 items-center justify-between border-b border-border bg-surface pr-6 pl-8">
<nav :aria-label="t('backend::lang.nav.breadcrumbs')" class="flex items-center gap-2">
<template v-if="active">
<span v-if="route.name === 'settings' || route.name === 'settings-form'" class="font-semibold">
{{ t('backend::lang.nav.settings') }}
</span>
<template v-else-if="active">
<span :class="section ? 'text-muted' : 'font-semibold'">{{ active.label }}</span>
<template v-if="section">
<ChevronRight :size="14" class="text-muted" aria-hidden="true" />

View File

@@ -1,15 +1,20 @@
<script setup lang="ts">
import { computed } from 'vue'
import { RouterLink, useRoute } from 'vue-router'
import { Sun } from '@lucide/vue'
import { Settings, Sun } from '@lucide/vue'
import { t } from '../../app/i18n'
import { iconFor } from '../../app/icons'
import { activeEntry, firstControllerPath, railEntries } from '../../state/useNavigation'
import { hasSettings } from '../../state/useSettings'
const route = useRoute()
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
// Ustawienia (D-21) is pinned to the bottom and shown only when the admin may
// manage at least one settings page.
const inSettings = computed(() => route.path === '/settings' || route.path.startsWith('/settings/'))
const items = computed(() =>
railEntries.value.map((entry) => ({
entry,
@@ -39,5 +44,17 @@ const items = computed(() =>
<component :is="item.icon" :size="20" aria-hidden="true" />
<span>{{ item.entry.label }}</span>
</RouterLink>
<span class="flex-1" aria-hidden="true" />
<RouterLink
v-if="hasSettings"
to="/settings"
data-rail-settings
:aria-current="inSettings ? 'page' : undefined"
:class="inSettings ? 'bg-accent-soft font-bold text-accent' : 'text-side-text hover:bg-side-hover hover:text-white'"
class="flex w-[68px] flex-col items-center gap-1 rounded-inner px-1.5 py-2 text-center text-[11px] leading-[1.2] tracking-[-0.01em] no-underline"
>
<Settings :size="20" aria-hidden="true" />
<span>{{ t('backend::lang.nav.settings') }}</span>
</RouterLink>
</nav>
</template>

View File

@@ -5,6 +5,7 @@ import { onUnauthorized } from './api/client'
import { loadStrings } from './app/i18n'
import { clearUser, me } from './state/useAuth'
import { loadNavigation } from './state/useNavigation'
import { loadSettings } from './state/useSettings'
import './styles/main.css'
async function boot(): Promise<void> {
@@ -15,7 +16,7 @@ async function boot(): Promise<void> {
// visitor to the login route with the requested path as redirect.
const user = await me().catch(() => null)
if (user) {
await loadNavigation().catch(() => [])
await Promise.all([loadNavigation().catch(() => []), loadSettings().catch(() => [])])
}
const router = createAdminRouter()
onUnauthorized(() => {

View File

@@ -0,0 +1,47 @@
// Settings pages the admin may manage (D-21), server-filtered like the
// navigation. The rail shows "Ustawienia" only when this list is non-empty.
import { computed, readonly, ref } from 'vue'
import { api } from '../api/client'
import type { SettingsEntry } from '../api/types'
const entries = ref<SettingsEntry[]>([])
export const settings = readonly(entries)
export const hasSettings = computed(() => entries.value.length > 0)
export async function loadSettings(): Promise<SettingsEntry[]> {
const { data, response } = await api.GET('/settings')
entries.value = response.ok && data ? data.data : []
return entries.value
}
export function setSettings(next: SettingsEntry[]): void {
entries.value = next
}
export function settingsEntry(code: string): SettingsEntry | null {
return entries.value.find((entry) => entry.code === code) ?? null
}
/** Entries grouped by category, categories in first-seen order, by order. */
export const settingsGroups = computed(() => {
const sorted = entries.value
.map((entry, index) => ({ entry, index }))
.sort((a, b) => a.entry.order - b.entry.order || a.index - b.index)
.map(({ entry }) => entry)
const groups: Array<{ category: string; entries: SettingsEntry[] }> = []
for (const entry of sorted) {
let group = groups.find((item) => item.category === entry.category)
if (!group) {
group = { category: entry.category, entries: [] }
groups.push(group)
}
group.entries.push(entry)
}
return groups
})
export function useSettings() {
return { settings, hasSettings, settingsGroups, loadSettings, settingsEntry }
}

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, nextTick, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
import { ArrowLeft, Trash2 } from '@lucide/vue'
import { api } from '../api/client'
import type { AdminRecord, ErrorEnvelope, FormView, RecordEnvelope, RecordMeta } from '../api/types'
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
@@ -8,20 +9,30 @@ import { message, t } from '../app/i18n'
import { mapWinterUrl } from '../app/winterUrl'
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
import FormGrid from '../components/form/FormGrid.vue'
import FormTabs from '../components/form/FormTabs.vue'
import {
DEFAULT_TAB,
contextAllows,
editablePayload,
fieldErrors,
focusField,
initialValues,
panelDomId,
snapshot,
tabDomId,
tabOf,
type FormMode,
type TabItem,
} from '../components/form/formState'
import Button from '../components/ui/Button.vue'
import ConfirmDialog from '../components/ui/ConfirmDialog.vue'
import { useConfirm } from '../components/ui/confirm'
import { showToast } from '../state/useToasts'
// Schema-driven create/update form (D-05, D-09, D-10, D-18). The view is
// keyed by route path, so one instance serves one record or one create.
// Schema-driven create/update form (D-05, D-09, D-10, D-18; design screen
// 4). The view is keyed by route path, so one instance serves one record or
// one create. Tabs group fields by `tab`; a sticky footer holds delete,
// cancel, save-and-close and save; a dirty form confirms before leaving.
const route = useRoute()
const router = useRouter()
@@ -43,12 +54,53 @@ const labels = ref<RecordMeta['labels']>({})
const errors = ref<Record<string, string[]>>({})
const loading = ref(true)
const failed = ref(false)
const saving = ref(false)
const busy = ref(false)
const saved = ref('')
const activeTab = ref(DEFAULT_TAB)
const confirm = useConfirm()
// Set right before a navigation the form itself starts after a save or a
// delete, so the dirty guard does not ask.
let leaving = false
const fields = computed(() => (schema.value?.fields ?? []).filter((field) => contextAllows(field, mode)))
const fieldNames = computed(() => fields.value.map((field) => field.name))
const tabs = computed<TabItem[]>(() => {
if (!fields.value.some((field) => field.tab)) {
return []
}
const out: TabItem[] = []
for (const field of fields.value) {
const key = tabOf(field)
let tab = out.find((item) => item.key === key)
if (!tab) {
tab = { key, label: field.tab || t('backend::lang.form.tab_default'), errors: 0 }
if (key === DEFAULT_TAB) {
out.unshift(tab)
} else {
out.push(tab)
}
}
if (errors.value[field.name]) {
tab.errors += 1
}
}
return out
})
const activeIndex = computed(() => Math.max(
tabs.value.findIndex((tab) => tab.key === activeTab.value),
0,
))
const panelFields = computed(() => {
if (tabs.value.length === 0) {
return fields.value
}
const key = tabs.value[activeIndex.value]?.key ?? DEFAULT_TAB
return fields.value.filter((field) => tabOf(field) === key)
})
/** The record's display name: the first text field's value, in schema order. */
const recordName = computed(() => {
const first = schema.value?.fields.find((field) => field.type === 'text')
@@ -68,7 +120,7 @@ const subtitle = computed(() =>
mode === 'update' ? message(schema.value?.messages.update, undefined, { name: recordName.value }) : '',
)
const dirty = computed(() => snapshot(editablePayload(fields.value, values.value)) !== saved.value)
const dirty = computed(() => !loading.value && snapshot(editablePayload(fields.value, values.value)) !== saved.value)
function adopt(record: RecordEnvelope | undefined): void {
if (record) {
@@ -94,6 +146,7 @@ async function load(): Promise<void> {
values.value = initialValues(fields.value)
}
adopt(recordResult?.data)
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
loading.value = false
}
@@ -106,13 +159,19 @@ function update(name: string, value: unknown): void {
}
}
/** 422: messages under fields, the first invalid field (schema order) focused. */
async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<void> {
errors.value = fieldErrors(error?.details)
await nextTick()
const first = fields.value.find((field) => errors.value[field.name])
if (first) {
focusField(ID_PREFIX, first.name)
const invalid = fields.value.filter((field) => errors.value[field.name])
const first = invalid.find((field) => tabOf(field) === activeTab.value) ?? invalid[0]
if (!first) {
return
}
if (tabs.value.length > 0) {
activeTab.value = tabOf(first)
}
await nextTick()
focusField(ID_PREFIX, first.name)
}
function redirectTarget(kind: 'redirect' | 'redirectClose', id: unknown): string {
@@ -123,10 +182,10 @@ function redirectTarget(kind: 'redirect' | 'redirectClose', id: unknown): string
/** Saves the record; returns the saved envelope, or null after an error. */
async function save(): Promise<RecordEnvelope | null> {
if (saving.value || !schema.value) {
if (busy.value || !schema.value) {
return null
}
saving.value = true
busy.value = true
try {
const body = editablePayload(fields.value, values.value)
const result =
@@ -149,29 +208,110 @@ async function save(): Promise<RecordEnvelope | null> {
showToast(t('backend::lang.form.error_generic'), 'danger')
return null
} finally {
saving.value = false
busy.value = false
}
}
async function go(to: string): Promise<void> {
leaving = true
try {
await router.push(to)
} finally {
leaving = false
}
}
/** Zapisz: stays on the form; after a create it follows create.redirect. */
async function onSave(): Promise<void> {
const record = await save()
if (record && mode === 'create') {
await router.push(redirectTarget('redirect', record.data.id))
await go(redirectTarget('redirect', record.data.id))
}
}
defineExpose({ dirty })
/** Zapisz i zamknij: saves, then follows redirectClose (the toast follows). */
async function onSaveAndClose(): Promise<void> {
const record = await save()
if (record) {
await go(redirectTarget('redirectClose', record.data.id))
}
}
async function onDelete(): Promise<void> {
if (recordId === null || busy.value || !schema.value) {
return
}
const ok = await confirm.ask({
message: message(schema.value.messages.deleteConfirm, undefined, { name: recordName.value }),
confirmLabel: t('backend::lang.form.delete'),
danger: true,
})
if (!ok) {
return
}
busy.value = true
try {
const result = await api.DELETE('/{vendor}/{plugin}/{controller}/{id}', {
params: { path: { ...path, id: recordId } },
})
if (result.data) {
showToast(message(schema.value.messages.deleted, undefined, { name: recordName.value }))
await go(listPath)
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
}
}
/** Leaving a dirty form (Anuluj, back, any route change) asks first. */
async function guard(): Promise<boolean> {
if (leaving || !dirty.value) {
return true
}
return confirm.ask({
message: t('backend::lang.form.unsaved_confirm'),
confirmLabel: t('backend::lang.form.discard'),
danger: true,
})
}
onBeforeRouteLeave(guard)
onBeforeRouteUpdate(guard)
function onBeforeUnload(event: BeforeUnloadEvent): void {
if (dirty.value) {
event.preventDefault()
event.returnValue = ''
}
}
onMounted(() => window.addEventListener('beforeunload', onBeforeUnload))
onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload))
void load()
</script>
<template>
<section class="mx-auto flex w-full max-w-[980px] flex-col gap-5 pb-24">
<header class="flex items-center gap-4">
<div class="flex min-w-0 flex-col">
<header class="flex flex-wrap items-center gap-4">
<Button
variant="outline"
data-action="back"
:to="listPath"
:aria-label="t('backend::lang.form.return_to_list')"
class="size-10! px-0!"
>
<ArrowLeft :size="18" aria-hidden="true" />
</Button>
<div class="flex min-w-0 flex-1 flex-col">
<h1 class="truncate text-[24px] font-bold tracking-[-0.02em]">{{ loading ? '' : title }}</h1>
<p v-if="subtitle" class="text-muted">{{ subtitle }}</p>
</div>
<FormTabs v-if="tabs.length > 0" v-model="activeTab" :tabs="tabs" :id-prefix="ID_PREFIX" />
</header>
<p v-if="failed && !loading" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
@@ -181,12 +321,15 @@ void load()
<template v-else-if="schema">
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
<form
:id="tabs.length > 0 ? panelDomId(ID_PREFIX, activeIndex) : undefined"
:role="tabs.length > 0 ? 'tabpanel' : undefined"
:aria-labelledby="tabs.length > 0 ? tabDomId(ID_PREFIX, activeIndex) : undefined"
class="rounded-card border border-border bg-surface p-7 shadow-card"
novalidate
@submit.prevent="onSave"
>
<FormGrid
:fields="fields"
:fields="panelFields"
:values="values"
:errors="errors"
:labels="labels"
@@ -198,12 +341,36 @@ void load()
</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"
class="fixed inset-x-0 bottom-0 z-30 flex items-center gap-2.5 border-t border-border bg-surface px-8 py-3.5"
>
<Button variant="ghost" :to="listPath">{{ t('backend::lang.form.cancel') }}</Button>
<Button variant="primary" :disabled="saving || loading || !schema" data-action="save" @click="onSave">
{{ saving ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
<Button
v-if="mode === 'update'"
variant="danger"
data-action="delete"
:icon="Trash2"
:disabled="busy || loading || !schema"
@click="onDelete"
>
{{ t('backend::lang.form.delete') }}
</Button>
<div class="ml-auto flex items-center gap-2.5">
<Button variant="ghost" data-action="cancel" :to="listPath">{{ t('backend::lang.form.cancel') }}</Button>
<Button variant="outline" data-action="save-close" :disabled="busy || loading || !schema" @click="onSaveAndClose">
{{ t('backend::lang.form.save_and_close') }}
</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>
</div>
</footer>
<ConfirmDialog
:open="confirm.request.value !== null"
:message="confirm.request.value?.message ?? ''"
:confirm-label="confirm.request.value?.confirmLabel"
:danger="confirm.request.value?.danger"
@confirm="confirm.confirm"
@cancel="confirm.cancel"
/>
</section>
</template>

View File

@@ -6,6 +6,7 @@ import { t } from '../app/i18n'
import { safeRedirect } from '../app/router'
import { login, me } from '../state/useAuth'
import { loadNavigation } from '../state/useNavigation'
import { loadSettings } from '../state/useSettings'
const route = useRoute()
const router = useRouter()
@@ -28,7 +29,7 @@ async function submit(): Promise<void> {
return
}
password.value = ''
await loadNavigation()
await Promise.all([loadNavigation(), loadSettings().catch(() => [])])
await router.replace(safeRedirect(route.query.redirect) ?? '/')
} finally {
busy.value = false

View File

@@ -0,0 +1,134 @@
<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="mx-auto flex w-full max-w-[980px] 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>

View File

@@ -0,0 +1,45 @@
<script setup lang="ts">
import { RouterLink } from 'vue-router'
import { t } from '../app/i18n'
import { iconFor } from '../app/icons'
import { settingsGroups } from '../state/useSettings'
// Settings index (D-21): every permitted settings page, grouped by category,
// with its icon, label and description. The server filters the list.
</script>
<template>
<section class="flex flex-col gap-5">
<header>
<h1 class="text-[26px] font-bold tracking-[-0.02em]">{{ t('backend::lang.settings.title') }}</h1>
</header>
<p v-if="settingsGroups.length === 0" class="text-muted">{{ t('backend::lang.settings.empty') }}</p>
<section
v-for="group in settingsGroups"
:key="group.category"
data-settings-group
class="flex flex-col gap-3"
>
<h2 v-if="group.category" class="text-[13px] font-semibold tracking-[0.02em] text-muted uppercase">
{{ group.category }}
</h2>
<div class="grid grid-cols-1 gap-3 min-[900px]:grid-cols-2">
<RouterLink
v-for="entry in group.entries"
:key="entry.code"
:to="`/settings/${encodeURIComponent(entry.code)}`"
:data-settings="entry.code"
class="flex items-start gap-3.5 rounded-card border border-border bg-surface p-5 text-text no-underline shadow-card hover:bg-hover"
>
<span class="flex size-10 shrink-0 items-center justify-center rounded-inner bg-subtle text-muted">
<component :is="iconFor(entry.icon)" :size="20" aria-hidden="true" />
</span>
<span class="flex min-w-0 flex-col">
<span class="font-semibold">{{ entry.label }}</span>
<span v-if="entry.description" class="text-[13px] text-muted">{{ entry.description }}</span>
</span>
</RouterLink>
</div>
</section>
</section>
</template>