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>

View File

@@ -45,6 +45,9 @@
"backend::lang.form.deleting": {
"other": "Usuwanie…"
},
"backend::lang.form.discard": {
"other": "Porzuć zmiany"
},
"backend::lang.form.error_fields": {
"one": "Popraw :count pole oznaczone poniżej.",
"few": "Popraw :count pola oznaczone poniżej.",
@@ -63,6 +66,9 @@
"backend::lang.form.loading_options": {
"other": "Wczytywanie…"
},
"backend::lang.form.more_options": {
"other": "Więcej wyników"
},
"backend::lang.form.no_options": {
"other": "Brak wyników"
},
@@ -87,6 +93,9 @@
"backend::lang.form.select_placeholder": {
"other": "Wybierz…"
},
"backend::lang.form.tab_default": {
"other": "Podstawowe"
},
"backend::lang.form.tab_errors": {
"one": ":count błąd",
"few": ":count błędy",
@@ -309,6 +318,9 @@
"backend::lang.relation.unlink": {
"other": "Odłącz"
},
"backend::lang.settings.back": {
"other": "Wróć do ustawień"
},
"backend::lang.settings.empty": {
"other": "Brak dostępnych ustawień."
},

104
admin/tests/fixtures/settings.json vendored Normal file
View File

@@ -0,0 +1,104 @@
{
"list": {
"data": [
{
"code": "search",
"label": "Search",
"description": "Search engine options",
"icon": "search",
"category": "System",
"keywords": [],
"model": "Acme\\Demo\\Models\\Settings",
"order": 20
},
{
"code": "mail",
"label": "Mail",
"description": "Outgoing mail",
"icon": "icon-cog",
"category": "System",
"keywords": [],
"model": "Acme\\Demo\\Models\\MailSettings",
"order": 10
},
{
"code": "branding",
"label": "Branding",
"description": "Logo and colours",
"icon": "palette",
"category": "Appearance",
"keywords": [],
"model": "Acme\\Demo\\Models\\Branding",
"order": 5
}
],
"meta": {
"locale": "en"
}
},
"schema": {
"data": {
"name": "Search",
"fields": [
{
"name": "use_engine",
"label": "Use the search engine",
"type": "switch",
"default": 0,
"comment": "Queries go to the external engine",
"required": true
},
{
"name": "engine_host",
"label": "Engine host",
"type": "text",
"span": "full"
}
],
"messages": {
"create": {
"other": "New record"
},
"update": {
"other": "Edit record"
},
"saved": {
"other": "Saved"
},
"deleteConfirm": {
"other": "Delete?"
},
"deleted": {
"other": "Deleted"
}
},
"meta": {
"locale": "en"
},
"redirects": {
"create": {
"redirect": "",
"redirectClose": ""
},
"update": {
"redirect": "",
"redirectClose": ""
},
"default": ""
}
},
"meta": {
"locale": "en"
}
},
"values": {
"data": {
"data": {
"use_engine": 0,
"engine_host": "search.local"
},
"exists": true
},
"meta": {}
}
}

View File

@@ -2,8 +2,20 @@
"data": {
"name": "Widget",
"fields": [
{ "name": "name", "label": "Name", "type": "text", "span": "left", "required": true },
{ "name": "code", "label": "Code", "type": "text", "span": "right", "comment": "Unique widget code" },
{
"name": "name",
"label": "Name",
"type": "text",
"span": "left",
"required": true
},
{
"name": "code",
"label": "Code",
"type": "text",
"span": "right",
"comment": "Unique widget code"
},
{
"name": "size",
"label": "Size",
@@ -11,28 +23,139 @@
"span": "left",
"emptyOption": "— none —",
"options": [
{ "value": "s", "label": "Small" },
{ "value": "m", "label": "Medium" },
{ "value": "l", "label": "Large" }
{
"value": "s",
"label": "Small"
},
{
"value": "m",
"label": "Medium"
},
{
"value": "l",
"label": "Large"
}
]
},
{ "name": "weight", "label": "Weight", "type": "number", "span": "right" },
{ "name": "notes", "label": "Notes", "type": "textarea", "span": "full", "size": "small" },
{ "name": "color", "label": "Colour", "type": "colorpicker", "span": "left" }
{
"name": "weight",
"label": "Weight",
"type": "number",
"span": "right"
},
{
"name": "notes",
"label": "Notes",
"type": "textarea",
"span": "full",
"size": "small"
},
{
"name": "color",
"label": "Colour",
"type": "colorpicker",
"span": "left"
},
{
"name": "active",
"label": "Active",
"type": "switch",
"span": "left",
"comment": "Shown in the catalogue"
},
{
"name": "featured",
"label": "Featured",
"type": "checkbox",
"span": "right",
"comment": "Pinned to the top"
},
{
"name": "maker",
"label": "Maker",
"type": "relation",
"span": "left",
"nameFrom": "name",
"emptyOption": "— none —"
},
{
"name": "tags",
"label": "Tags",
"type": "relation",
"span": "right",
"nameFrom": "name",
"multiple": true,
"required": true
},
{
"name": "owner",
"label": "Owner",
"type": "relation",
"span": "left",
"nameFrom": "email",
"emptyOption": "Current user",
"readOnly": true
},
{
"name": "serial",
"label": "Serial",
"type": "text",
"span": "left",
"tab": "Details",
"context": "update"
},
{
"name": "batch",
"label": "Batch",
"type": "text",
"span": "right",
"tab": "Details",
"context": [
"create"
]
},
{
"name": "parts",
"type": "relation-manager",
"relation": "parts",
"span": "full",
"tab": "Parts",
"context": "update"
}
],
"messages": {
"create": { "other": "New widget" },
"update": { "other": "Editing widget" },
"saved": { "other": "Widget „:name” saved" },
"deleteConfirm": { "other": "Delete this widget?" },
"deleted": { "other": "Widget deleted" }
"create": {
"other": "New widget"
},
"update": {
"other": "Editing widget"
},
"saved": {
"other": "Widget „:name” saved"
},
"deleteConfirm": {
"other": "Delete this widget?"
},
"deleted": {
"other": "Widget deleted"
}
},
"meta": {
"locale": "en"
},
"meta": { "locale": "en" },
"redirects": {
"create": { "redirect": "acme/demo/widgets/update/:id", "redirectClose": "acme/demo/widgets" },
"update": { "redirect": "acme/demo/widgets", "redirectClose": "acme/demo/widgets" },
"create": {
"redirect": "acme/demo/widgets/update/:id",
"redirectClose": "acme/demo/widgets"
},
"update": {
"redirect": "acme/demo/widgets",
"redirectClose": "acme/demo/widgets"
},
"default": "acme/demo/widgets"
}
},
"meta": { "locale": "en" }
"meta": {
"locale": "en"
}
}

View File

@@ -1,9 +1,85 @@
{
"maker": {
"data": [
{ "value": "1", "label": "Acme Works" },
{ "value": "2", "label": "Bolt & Co" }
{
"value": "1",
"label": "Acme Works"
},
{
"value": "2",
"label": "Bolt & Co"
}
],
"meta": { "locale": "en" }
"meta": {
"locale": "en"
}
},
"makerField": {
"data": [
{
"value": 1,
"label": "Acme Works"
},
{
"value": 2,
"label": "Bolt & Co"
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 3,
"last_page": 2
}
},
"makerFieldPage2": {
"data": [
{
"value": 7,
"label": "Cog Ltd"
}
],
"meta": {
"page": 2,
"per_page": 20,
"total": 3,
"last_page": 2
}
},
"makerFieldSearch": {
"data": [
{
"value": 1,
"label": "Acme Works"
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 1,
"last_page": 1
}
},
"tagsField": {
"data": [
{
"value": 1,
"label": "Small"
},
{
"value": 3,
"label": "Round"
},
{
"value": 4,
"label": "Large"
}
],
"meta": {
"page": 1,
"per_page": 20,
"total": 3,
"last_page": 1
}
}
}

View File

@@ -6,7 +6,41 @@
"size": "m",
"weight": 12,
"notes": "Round",
"color": "#0000ff"
"color": "#0000ff",
"active": true,
"featured": false,
"maker": 2,
"tags": [
3,
1
],
"owner": 5,
"serial": "SN-1"
},
"meta": { "labels": {} }
"meta": {
"labels": {
"maker": [
{
"value": 2,
"label": "Bolt & Co"
}
],
"tags": [
{
"value": 3,
"label": "Round"
},
{
"value": 1,
"label": "Small"
}
],
"owner": [
{
"value": 5,
"label": "dana@example.test"
}
]
}
}
}

View File

@@ -128,7 +128,20 @@ describe('edit a record', () => {
const [put] = requestsTo(calls, 'PUT', RECORD)
expect(put).toBeDefined()
expect(put!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(await put!.json()).toEqual({ name: 'Red widget', code: 'W-01', size: 'l', weight: null, notes: 'Round' })
// Every editable, context-allowed field; not the unsupported colorpicker,
// the read-only owner or the relation manager.
expect(await put!.json()).toEqual({
name: 'Red widget',
code: 'W-01',
size: 'l',
weight: null,
notes: 'Round',
active: true,
featured: false,
maker: 2,
tags: [3, 1],
serial: 'SN-1',
})
const toast = wrapper.find('[role="status"]')
expect(toast.text()).toContain('Widget „Red widget” saved')
expect(toast.find('button').attributes('aria-label')).toBe('Zamknij')

View File

@@ -0,0 +1,338 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { flushPromises, type VueWrapper } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import { clearUser } from '../../src/state/useAuth'
import { setNavigation } from '../../src/state/useNavigation'
import { setSettings } from '../../src/state/useSettings'
import { clearToasts } from '../../src/state/useToasts'
import lang from '../fixtures/lang.json'
import formSchema from '../fixtures/widgets.form-schema.json'
import record from '../fixtures/widgets.record.json'
import listSchema from '../fixtures/widgets.list-schema.json'
import listRows from '../fixtures/widgets.list.json'
import options from '../fixtures/widgets.options.json'
import { API, mountApp, queryOf, requestsTo, type Route } from '../helpers'
const BASE = `${API}/acme/demo/widgets`
const RECORD = `${BASE}/1`
const MAKER_OPTIONS = `${BASE}/fields/maker/options`
const TAGS_OPTIONS = `${BASE}/fields/tags/options`
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${BASE}/schema/form`]: { body: formSchema },
[`GET ${RECORD}`]: { body: record },
[`GET ${BASE}/schema/list`]: { body: listSchema },
[`GET ${BASE}`]: { body: listRows },
[`GET ${MAKER_OPTIONS}`]: (request) => {
const query = queryOf(request)
if (query.get('search') === 'acme') {
return { body: options.makerFieldSearch }
}
return { body: query.get('page') === '2' ? options.makerFieldPage2 : options.makerField }
},
[`GET ${TAGS_OPTIONS}`]: { body: options.tagsField },
[`PUT ${RECORD}`]: async (request) => ({
body: { data: { ...record.data, ...((await request.json()) as object) }, meta: record.meta },
}),
...overrides,
}
}
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
function tabLabels(wrapper: VueWrapper): string[] {
return wrapper.findAll('[role="tab"]').map((tab) => tab.text())
}
async function save(wrapper: VueWrapper, action = 'save'): Promise<void> {
await wrapper.find(`[data-action="${action}"]`).trigger('click')
await flushPromises()
}
function dialog(): HTMLElement | null {
return document.body.querySelector('[data-confirm-dialog]')
}
async function answer(action: 'confirm' | 'cancel'): Promise<void> {
;(dialog()!.querySelector(`[data-action="${action}"]`) as HTMLButtonElement).click()
await flushPromises()
}
beforeEach(() => {
clearUser()
setNavigation([])
setSettings([])
clearToasts()
setBundle(lang.data, 'pl')
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('tabs and context', () => {
it('groups fields into tabs with the default tab first and filters by context', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/1', routes())
expect(tabLabels(wrapper)).toEqual(['Podstawowe', 'Details', 'Parts'])
const [general, details, parts] = wrapper.findAll('[role="tab"]')
expect(general!.attributes('aria-selected')).toBe('true')
expect(wrapper.find('[role="tabpanel"]').attributes('aria-labelledby')).toBe(general!.attributes('id'))
expect(wrapper.find('#field-name').exists()).toBe(true)
expect(wrapper.find('#field-serial').exists()).toBe(false)
await details!.trigger('click')
expect(details!.attributes('aria-selected')).toBe('true')
expect(wrapper.find('#field-serial').exists()).toBe(true)
expect(wrapper.find('#field-batch').exists()).toBe(false)
expect(wrapper.find('#field-name').exists()).toBe(false)
// The relation manager is not registered yet: it renders the fallback.
await parts!.trigger('click')
expect(wrapper.find('[data-unsupported-field]').text()).toBe('Nieobsługiwany typ pola: relation-manager')
})
it('shows create-context fields on create and hides update-only tabs', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets/create', routes())
expect(wrapper.find('h1').text()).toBe('New widget')
expect(tabLabels(wrapper)).toEqual(['Podstawowe', 'Details'])
await wrapper.findAll('[role="tab"]')[1]!.trigger('click')
expect(wrapper.find('#field-batch').exists()).toBe(true)
expect(wrapper.find('#field-serial').exists()).toBe(false)
expect(wrapper.find('[data-action="delete"]').exists()).toBe(false)
})
it('badges tabs holding invalid fields and switches to the first invalid field', async () => {
const { wrapper } = await mountApp(
'/acme/demo/widgets/1',
routes({
[`PUT ${RECORD}`]: {
status: 422,
body: {
error: {
code: 'validation_failed',
message: 'The given data was invalid.',
details: { serial: ['The serial has already been taken.'] },
},
},
},
}),
{ attach: true },
)
await save(wrapper)
const [general, details] = wrapper.findAll('[role="tab"]')
expect(general!.find('[data-tab-errors]').exists()).toBe(false)
expect(details!.find('[data-tab-errors]').text()).toBe('1')
expect(details!.find('[data-tab-errors]').attributes('aria-label')).toBe('1 błąd')
expect(details!.attributes('aria-selected')).toBe('true')
expect(document.activeElement?.id).toBe('field-serial')
expect(wrapper.find('#field-serial-error').text()).toBe('The serial has already been taken.')
})
})
describe('toggles and relations', () => {
it('renders switch and checkbox toggle cards and sends their values', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes())
const toggle = wrapper.find('#field-active')
expect(toggle.attributes('role')).toBe('switch')
expect(toggle.attributes('aria-checked')).toBe('true')
expect(toggle.attributes('aria-describedby')).toBe('field-active-comment')
const card = wrapper.find('[data-field="active"] [data-toggle-card]')
expect(card.find('label').text()).toBe('Active')
expect(card.text()).toContain('Shown in the catalogue')
await toggle.trigger('click')
expect(wrapper.find('#field-active').attributes('aria-checked')).toBe('false')
const box = wrapper.find('#field-featured')
expect((box.element as HTMLInputElement).checked).toBe(false)
await box.setValue(true)
await save(wrapper)
const body = (await requestsTo(calls, 'PUT', RECORD)[0]!.json()) as Record<string, unknown>
expect(body.active).toBe(false)
expect(body.featured).toBe(true)
})
it('searches a single relation with emptyOption first and sends the id or null', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes())
const input = wrapper.find('#field-maker')
expect((input.element as HTMLInputElement).value).toBe('Bolt & Co')
expect(input.attributes('role')).toBe('combobox')
await input.trigger('click')
await flushPromises()
const first = requestsTo(calls, 'GET', MAKER_OPTIONS)[0]!
expect(queryOf(first).get('page')).toBe('1')
expect(queryOf(first).get('per_page')).toBe('20')
expect(queryOf(first).has('search')).toBe(false)
const listbox = wrapper.find('#field-maker-listbox')
expect(listbox.attributes('role')).toBe('listbox')
const labels = () => wrapper.findAll('#field-maker-listbox [role="option"]').map((option) => option.text())
expect(labels()).toEqual(['— none —', 'Acme Works', 'Bolt & Co'])
expect(wrapper.find('#field-maker-listbox [role="option"]').classes()).toContain('text-muted')
expect(wrapper.find('[data-value="2"]').attributes('aria-selected')).toBe('true')
// More results load on the next-page action.
await wrapper.find('[data-action="more-options"]').trigger('mousedown')
await flushPromises()
expect(queryOf(requestsTo(calls, 'GET', MAKER_OPTIONS)[1]!).get('page')).toBe('2')
expect(labels()).toEqual(['— none —', 'Acme Works', 'Bolt & Co', 'Cog Ltd'])
expect(wrapper.find('[data-action="more-options"]').exists()).toBe(false)
await input.setValue('acme')
await wait(350)
await flushPromises()
const searched = requestsTo(calls, 'GET', MAKER_OPTIONS).at(-1)!
expect(queryOf(searched).get('search')).toBe('acme')
expect(queryOf(searched).get('page')).toBe('1')
expect(labels()).toEqual(['— none —', 'Acme Works'])
await wrapper.find('#field-maker-listbox [data-value="1"]').trigger('mousedown')
expect((wrapper.find('#field-maker').element as HTMLInputElement).value).toBe('Acme Works')
await save(wrapper)
expect(((await requestsTo(calls, 'PUT', RECORD)[0]!.json()) as Record<string, unknown>).maker).toBe(1)
await wrapper.find('#field-maker').trigger('click')
await flushPromises()
await wrapper.find('#field-maker-listbox [data-value="empty"]').trigger('mousedown')
const field = wrapper.find('#field-maker')
expect((field.element as HTMLInputElement).value).toBe('— none —')
expect(field.classes()).toContain('text-muted')
await save(wrapper)
expect(((await requestsTo(calls, 'PUT', RECORD)[1]!.json()) as Record<string, unknown>).maker).toBeNull()
})
it('keeps multiple relation chips in order, removes and appends ids', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes())
const chips = () => wrapper.findAll('[data-field="tags"] [data-chip]')
expect(chips().map((chip) => chip.text())).toEqual(['RRound', 'SSmall'])
expect(chips()[0]!.find('[aria-hidden="true"]').text()).toBe('R')
await wrapper.find('[data-field="tags"] button[aria-label="Usuń: Round"]').trigger('click')
expect(chips().map((chip) => chip.find('.font-semibold').text())).toEqual(['Small'])
const input = wrapper.find('#field-tags')
expect(input.attributes('placeholder')).toBe('Dodaj…')
await input.trigger('click')
await flushPromises()
// Chosen tags are not offered again.
expect(wrapper.findAll('#field-tags-listbox [role="option"]').map((option) => option.text())).toEqual([
'Round',
'Large',
])
await wrapper.find('#field-tags-listbox [data-value="4"]').trigger('mousedown')
expect(chips().map((chip) => chip.find('.font-semibold').text())).toEqual(['Small', 'Large'])
await save(wrapper)
expect(((await requestsTo(calls, 'PUT', RECORD)[0]!.json()) as Record<string, unknown>).tags).toEqual([1, 4])
})
it('shows a read-only relation as its label only and never sends it', async () => {
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', routes())
const owner = wrapper.find('[data-field="owner"]')
expect(owner.find('[data-relation-readonly]').text()).toBe('dana@example.test')
expect(owner.find('input').exists()).toBe(false)
expect(owner.find('[role="combobox"]').exists()).toBe(false)
await save(wrapper)
expect(requestsTo(calls, 'GET', `${BASE}/fields/owner/options`)).toHaveLength(0)
expect((await requestsTo(calls, 'PUT', RECORD)[0]!.json()) as Record<string, unknown>).not.toHaveProperty('owner')
})
})
describe('footer and lifecycle', () => {
it('confirms delete with the form message, deletes and returns to the list', async () => {
const { wrapper, router, calls } = await mountApp(
'/acme/demo/widgets/1',
routes({ [`DELETE ${RECORD}`]: { body: { data: { deleted: 1 }, meta: {} } } }),
)
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
expect(dialog()!.textContent).toContain('Delete this widget?')
await answer('cancel')
expect(requestsTo(calls, 'DELETE', RECORD)).toHaveLength(0)
await wrapper.find('[data-action="delete"]').trigger('click')
await flushPromises()
await answer('confirm')
const [del] = requestsTo(calls, 'DELETE', RECORD)
expect(del!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
expect(wrapper.find('[role="status"]').text()).toContain('Widget deleted')
})
it('asks before leaving a dirty form and guards the page unload', async () => {
const { wrapper, router } = await mountApp('/acme/demo/widgets/1', routes())
const clean = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(clean)
expect(clean.defaultPrevented).toBe(false)
await wrapper.find('#field-name').setValue('Changed')
const unload = new Event('beforeunload', { cancelable: true })
window.dispatchEvent(unload)
expect(unload.defaultPrevented).toBe(true)
await wrapper.find('[data-action="cancel"]').trigger('click')
await flushPromises()
expect(dialog()!.textContent).toContain('Masz niezapisane zmiany. Porzucić je?')
await answer('cancel')
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/1')
await wrapper.find('[data-action="back"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-action="back"]').attributes('aria-label')).toBe('Wróć do listy')
await answer('confirm')
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
})
it('leaves a clean form without asking', async () => {
const { wrapper, router } = await mountApp('/acme/demo/widgets/1', routes())
await wrapper.find('[data-action="cancel"]').trigger('click')
await flushPromises()
expect(dialog()).toBeNull()
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
})
it('creates a record and follows the mapped create redirect', async () => {
const { wrapper, router, calls } = await mountApp(
'/acme/demo/widgets/create',
routes({
[`POST ${BASE}`]: async (request) => ({
status: 201,
body: { data: { id: 9, ...((await request.json()) as object) }, meta: { labels: {} } },
}),
[`GET ${BASE}/9`]: { body: { data: { ...record.data, id: 9, name: 'Fresh widget' }, meta: record.meta } },
}),
)
expect((wrapper.find('#field-active').attributes('aria-checked'))).toBe('false')
await wrapper.find('#field-name').setValue('Fresh widget')
await save(wrapper)
const [post] = requestsTo(calls, 'POST', BASE)
const body = (await post!.json()) as Record<string, unknown>
expect(body).toMatchObject({ name: 'Fresh widget', active: false, featured: false, tags: [] })
expect(body).not.toHaveProperty('serial')
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/9')
expect(wrapper.find('[role="status"]').text()).toContain('Widget „Fresh widget” saved')
expect(wrapper.find('h1').text()).toBe('Fresh widget')
})
it('saves and closes to the mapped redirectClose with the toast on the list', async () => {
const { wrapper, router } = await mountApp('/acme/demo/widgets/1', routes())
await wrapper.find('#field-name').setValue('Closed widget')
await save(wrapper, 'save-close')
expect(dialog()).toBeNull()
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets')
expect(wrapper.find('h1').text()).toBe('Widgets')
expect(wrapper.find('[role="status"]').text()).toContain('Widget „Closed widget” saved')
})
})

View File

@@ -0,0 +1,127 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { flushPromises } from '@vue/test-utils'
import { setBundle } from '../../src/app/i18n'
import { clearUser } from '../../src/state/useAuth'
import { setNavigation } from '../../src/state/useNavigation'
import { loadSettings, setSettings } from '../../src/state/useSettings'
import { clearToasts } from '../../src/state/useToasts'
import lang from '../fixtures/lang.json'
import listSchema from '../fixtures/widgets.list-schema.json'
import listRows from '../fixtures/widgets.list.json'
import settings from '../fixtures/settings.json'
import { API, mockApi, mountApp, requestsTo, type Route } from '../helpers'
const SETTING = `${API}/settings/search`
function routes(overrides: Record<string, Route> = {}): Record<string, Route> {
return {
[`GET ${API}/settings`]: { body: settings.list },
[`GET ${SETTING}/schema`]: { body: settings.schema },
[`GET ${SETTING}`]: { body: settings.values },
[`GET ${API}/acme/demo/widgets/schema/list`]: { body: listSchema },
[`GET ${API}/acme/demo/widgets`]: { body: listRows },
[`PUT ${SETTING}`]: async (request) => ({
body: { data: { data: { ...settings.values.data.data, ...((await request.json()) as object) }, exists: true }, meta: {} },
}),
...overrides,
}
}
beforeEach(() => {
clearUser()
setNavigation([])
setSettings([])
clearToasts()
setBundle(lang.data, 'pl')
})
afterEach(() => {
document.body.innerHTML = ''
})
describe('settings', () => {
it('shows the Ustawienia rail item only when settings pages are permitted', async () => {
const { wrapper } = await mountApp('/acme/demo/widgets', routes())
expect(wrapper.find('[data-rail-settings]').exists()).toBe(false)
mockApi(routes())
await loadSettings()
await flushPromises()
const item = wrapper.find('[data-rail-settings]')
expect(item.text()).toBe('Ustawienia')
expect(item.attributes('href')).toBe('/settings')
expect(item.attributes('aria-current')).toBeUndefined()
expect(item.find('svg').exists()).toBe(true)
})
it('lists every permitted settings page grouped by category', async () => {
mockApi(routes())
await loadSettings()
const { wrapper } = await mountApp('/settings', routes())
expect(wrapper.find('h1').text()).toBe('Ustawienia')
expect(wrapper.find('[data-rail-settings]').attributes('aria-current')).toBe('page')
const groups = wrapper.findAll('[data-settings-group]')
expect(groups.map((group) => group.find('h2').text())).toEqual(['Appearance', 'System'])
expect(groups[1]!.findAll('[data-settings]').map((link) => link.attributes('data-settings'))).toEqual([
'mail',
'search',
])
const search = wrapper.find('[data-settings="search"]')
expect(search.attributes('href')).toBe('/settings/search')
expect(search.text()).toContain('Search')
expect(search.text()).toContain('Search engine options')
expect(search.find('svg').exists()).toBe(true)
})
it('renders a settings page through the form registry and PUTs its values', async () => {
mockApi(routes())
await loadSettings()
const { wrapper, calls } = await mountApp('/settings/search', routes())
expect(wrapper.find('h1').text()).toBe('Search')
const toggle = wrapper.find('#setting-use_engine')
expect(toggle.attributes('role')).toBe('switch')
expect(toggle.attributes('aria-checked')).toBe('false')
expect((wrapper.find('#setting-engine_host').element as HTMLInputElement).value).toBe('search.local')
await toggle.trigger('click')
await wrapper.find('#setting-engine_host').setValue('engine.local')
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
const [put] = requestsTo(calls, 'PUT', SETTING)
expect(put!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
// A numeric toggle keeps its type: 0 becomes 1, not true.
expect(await put!.json()).toEqual({ use_engine: 1, engine_host: 'engine.local' })
expect(wrapper.find('[role="status"]').text()).toContain('Zapisano ustawienia')
expect(wrapper.find('#setting-use_engine').attributes('aria-checked')).toBe('true')
})
it('maps a settings 422 onto its fields', async () => {
const { wrapper } = await mountApp(
'/settings/search',
routes({
[`PUT ${SETTING}`]: {
status: 422,
body: {
error: {
code: 'validation_failed',
message: 'The given data was invalid.',
details: { engine_host: ['The engine host format is invalid.'] },
},
},
},
}),
{ attach: true },
)
await wrapper.find('[data-action="save"]').trigger('click')
await flushPromises()
expect(wrapper.find('[data-error-banner]').text()).toContain('Popraw 1 pole oznaczone poniżej.')
expect(wrapper.find('#setting-engine_host').attributes('aria-invalid')).toBe('true')
expect(wrapper.find('#setting-engine_host-error').text()).toBe('The engine host format is invalid.')
expect(document.activeElement?.id).toBe('setting-engine_host')
})
})

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="robots" content="noindex, nofollow" />
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
<title>SummerCMS</title>
<script type="module" crossorigin src="./assets/index-CAZobGnD.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-W7axD3vY.css">
<script type="module" crossorigin src="./assets/index-DvYDRBo9.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CWPavuit.css">
</head>
<body>
<div id="app"></div>

View File

@@ -70,6 +70,8 @@ form:
select_placeholder: Select…
loading_options: Loading…
no_options: No results
more_options: More results
tab_default: General
unsupported_field: "Unsupported field type: :type"
error_title: Could not save.
error_fields:
@@ -78,6 +80,7 @@ form:
load_failed: The form could not be loaded.
error_generic: The changes could not be saved. Please try again.
unsaved_confirm: You have unsaved changes. Discard them?
discard: Discard changes
tab_errors:
one: ":count error"
other: ":count errors"
@@ -93,6 +96,7 @@ settings:
title: Settings
empty: No settings are available.
saved: Settings saved
back: Back to settings
messages:
list:
record_count:

View File

@@ -74,6 +74,8 @@ form:
select_placeholder: Wybierz…
loading_options: Wczytywanie…
no_options: Brak wyników
more_options: Więcej wyników
tab_default: Podstawowe
unsupported_field: "Nieobsługiwany typ pola: :type"
error_title: Nie udało się zapisać.
error_fields:
@@ -84,6 +86,7 @@ form:
load_failed: Nie udało się wczytać formularza.
error_generic: Nie udało się zapisać zmian. Spróbuj ponownie.
unsaved_confirm: Masz niezapisane zmiany. Porzucić je?
discard: Porzuć zmiany
tab_errors:
one: ":count błąd"
few: ":count błędy"
@@ -101,6 +104,7 @@ settings:
title: Ustawienia
empty: Brak dostępnych ustawień.
saved: Zapisano ustawienia
back: Wróć do ustawień
messages:
list:
record_count: