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:
@@ -5,6 +5,8 @@ import { homePath } from '../state/useNavigation'
|
|||||||
import LoginView from '../views/LoginView.vue'
|
import LoginView from '../views/LoginView.vue'
|
||||||
import ListView from '../views/ListView.vue'
|
import ListView from '../views/ListView.vue'
|
||||||
import FormView from '../views/FormView.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'
|
import NotFoundView from '../views/NotFoundView.vue'
|
||||||
|
|
||||||
declare module 'vue-router' {
|
declare module 'vue-router' {
|
||||||
@@ -43,6 +45,8 @@ export function createAdminRouter(history: RouterHistory = createWebHistory(runt
|
|||||||
meta: { shell: true },
|
meta: { shell: true },
|
||||||
beforeEnter: () => homePath() ?? 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', name: 'list', component: ListView, meta: { shell: true } },
|
||||||
{ path: '/:vendor/:plugin/:controller/create', name: 'create', component: FormView, 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 } },
|
{ path: '/:vendor/:plugin/:controller/:id(\\d+)', name: 'record', component: FormView, meta: { shell: true } },
|
||||||
|
|||||||
53
admin/src/components/form/FormTabs.vue
Normal file
53
admin/src/components/form/FormTabs.vue
Normal 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>
|
||||||
39
admin/src/components/form/fields/CheckboxField.vue
Normal file
39
admin/src/components/form/fields/CheckboxField.vue
Normal 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>
|
||||||
403
admin/src/components/form/fields/RelationField.vue
Normal file
403
admin/src/components/form/fields/RelationField.vue
Normal 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>
|
||||||
47
admin/src/components/form/fields/SwitchField.vue
Normal file
47
admin/src/components/form/fields/SwitchField.vue
Normal 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>
|
||||||
@@ -5,6 +5,30 @@ import { isRegistered } from './registry'
|
|||||||
|
|
||||||
export type FormMode = 'create' | 'update'
|
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. */
|
/** Winter `context:` semantics: no context shows the field everywhere. */
|
||||||
export function contextAllows(field: FormField, mode: FormMode): boolean {
|
export function contextAllows(field: FormField, mode: FormMode): boolean {
|
||||||
const context = field.context
|
const context = field.context
|
||||||
|
|||||||
@@ -1,11 +1,15 @@
|
|||||||
// Field renderer registry (D-05). A form field's `type` selects the control
|
// Field renderer registry (D-05). A form field's `type` selects the control
|
||||||
// component; any type without a renderer gets UnsupportedField, the design's
|
// 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
|
// dashed box, so an unknown type never breaks the form. The relation manager
|
||||||
// seam into the plugin extension point.
|
// 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 { Component } from 'vue'
|
||||||
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
|
import type { ControllerParams, FormField, RelationOption } from '../../api/types'
|
||||||
|
import CheckboxField from './fields/CheckboxField.vue'
|
||||||
import DropdownField from './fields/DropdownField.vue'
|
import DropdownField from './fields/DropdownField.vue'
|
||||||
import NumberField from './fields/NumberField.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 TextField from './fields/TextField.vue'
|
||||||
import TextareaField from './fields/TextareaField.vue'
|
import TextareaField from './fields/TextareaField.vue'
|
||||||
import UnsupportedField from './fields/UnsupportedField.vue'
|
import UnsupportedField from './fields/UnsupportedField.vue'
|
||||||
@@ -30,10 +34,13 @@ const renderers = new Map<string, Component>([
|
|||||||
['textarea', TextareaField],
|
['textarea', TextareaField],
|
||||||
['number', NumberField],
|
['number', NumberField],
|
||||||
['dropdown', DropdownField],
|
['dropdown', DropdownField],
|
||||||
|
['switch', SwitchField],
|
||||||
|
['checkbox', CheckboxField],
|
||||||
|
['relation', RelationField],
|
||||||
])
|
])
|
||||||
|
|
||||||
/** Types whose control shows the label itself (toggle cards). */
|
/** 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 {
|
export function rendererFor(type: string): Component {
|
||||||
return renderers.get(type) ?? UnsupportedField
|
return renderers.get(type) ?? UnsupportedField
|
||||||
@@ -82,3 +89,16 @@ export function controlClass(invalid: boolean | undefined): string {
|
|||||||
invalid ? 'border-danger' : 'border-border-strong',
|
invalid ? 'border-danger' : 'border-border-strong',
|
||||||
].join(' ')
|
].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
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,7 +45,10 @@ const initials = computed(() =>
|
|||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<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">
|
<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">
|
<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>
|
<span :class="section ? 'text-muted' : 'font-semibold'">{{ active.label }}</span>
|
||||||
<template v-if="section">
|
<template v-if="section">
|
||||||
<ChevronRight :size="14" class="text-muted" aria-hidden="true" />
|
<ChevronRight :size="14" class="text-muted" aria-hidden="true" />
|
||||||
|
|||||||
@@ -1,15 +1,20 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed } from 'vue'
|
import { computed } from 'vue'
|
||||||
import { RouterLink, useRoute } from 'vue-router'
|
import { RouterLink, useRoute } from 'vue-router'
|
||||||
import { Sun } from '@lucide/vue'
|
import { Settings, Sun } from '@lucide/vue'
|
||||||
import { t } from '../../app/i18n'
|
import { t } from '../../app/i18n'
|
||||||
import { iconFor } from '../../app/icons'
|
import { iconFor } from '../../app/icons'
|
||||||
import { activeEntry, firstControllerPath, railEntries } from '../../state/useNavigation'
|
import { activeEntry, firstControllerPath, railEntries } from '../../state/useNavigation'
|
||||||
|
import { hasSettings } from '../../state/useSettings'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
|
||||||
const active = computed(() => activeEntry(String(route.params.vendor ?? ''), String(route.params.plugin ?? '')))
|
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(() =>
|
const items = computed(() =>
|
||||||
railEntries.value.map((entry) => ({
|
railEntries.value.map((entry) => ({
|
||||||
entry,
|
entry,
|
||||||
@@ -39,5 +44,17 @@ const items = computed(() =>
|
|||||||
<component :is="item.icon" :size="20" aria-hidden="true" />
|
<component :is="item.icon" :size="20" aria-hidden="true" />
|
||||||
<span>{{ item.entry.label }}</span>
|
<span>{{ item.entry.label }}</span>
|
||||||
</RouterLink>
|
</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>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { onUnauthorized } from './api/client'
|
|||||||
import { loadStrings } from './app/i18n'
|
import { loadStrings } from './app/i18n'
|
||||||
import { clearUser, me } from './state/useAuth'
|
import { clearUser, me } from './state/useAuth'
|
||||||
import { loadNavigation } from './state/useNavigation'
|
import { loadNavigation } from './state/useNavigation'
|
||||||
|
import { loadSettings } from './state/useSettings'
|
||||||
import './styles/main.css'
|
import './styles/main.css'
|
||||||
|
|
||||||
async function boot(): Promise<void> {
|
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.
|
// visitor to the login route with the requested path as redirect.
|
||||||
const user = await me().catch(() => null)
|
const user = await me().catch(() => null)
|
||||||
if (user) {
|
if (user) {
|
||||||
await loadNavigation().catch(() => [])
|
await Promise.all([loadNavigation().catch(() => []), loadSettings().catch(() => [])])
|
||||||
}
|
}
|
||||||
const router = createAdminRouter()
|
const router = createAdminRouter()
|
||||||
onUnauthorized(() => {
|
onUnauthorized(() => {
|
||||||
|
|||||||
47
admin/src/state/useSettings.ts
Normal file
47
admin/src/state/useSettings.ts
Normal 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 }
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref } from 'vue'
|
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { onBeforeRouteLeave, onBeforeRouteUpdate, useRoute, useRouter } from 'vue-router'
|
||||||
|
import { ArrowLeft, Trash2 } from '@lucide/vue'
|
||||||
import { api } from '../api/client'
|
import { api } from '../api/client'
|
||||||
import type { AdminRecord, ErrorEnvelope, FormView, RecordEnvelope, RecordMeta } from '../api/types'
|
import type { AdminRecord, ErrorEnvelope, FormView, RecordEnvelope, RecordMeta } from '../api/types'
|
||||||
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
import { controllerIdFromParams, controllerPath } from '../app/controllerRoutes'
|
||||||
@@ -8,20 +9,30 @@ import { message, t } from '../app/i18n'
|
|||||||
import { mapWinterUrl } from '../app/winterUrl'
|
import { mapWinterUrl } from '../app/winterUrl'
|
||||||
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
import FormErrorBanner from '../components/form/FormErrorBanner.vue'
|
||||||
import FormGrid from '../components/form/FormGrid.vue'
|
import FormGrid from '../components/form/FormGrid.vue'
|
||||||
|
import FormTabs from '../components/form/FormTabs.vue'
|
||||||
import {
|
import {
|
||||||
|
DEFAULT_TAB,
|
||||||
contextAllows,
|
contextAllows,
|
||||||
editablePayload,
|
editablePayload,
|
||||||
fieldErrors,
|
fieldErrors,
|
||||||
focusField,
|
focusField,
|
||||||
initialValues,
|
initialValues,
|
||||||
|
panelDomId,
|
||||||
snapshot,
|
snapshot,
|
||||||
|
tabDomId,
|
||||||
|
tabOf,
|
||||||
type FormMode,
|
type FormMode,
|
||||||
|
type TabItem,
|
||||||
} from '../components/form/formState'
|
} from '../components/form/formState'
|
||||||
import Button from '../components/ui/Button.vue'
|
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'
|
import { showToast } from '../state/useToasts'
|
||||||
|
|
||||||
// Schema-driven create/update form (D-05, D-09, D-10, D-18). The view is
|
// Schema-driven create/update form (D-05, D-09, D-10, D-18; design screen
|
||||||
// keyed by route path, so one instance serves one record or one create.
|
// 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 route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
|
|
||||||
@@ -43,12 +54,53 @@ const labels = ref<RecordMeta['labels']>({})
|
|||||||
const errors = ref<Record<string, string[]>>({})
|
const errors = ref<Record<string, string[]>>({})
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const failed = ref(false)
|
const failed = ref(false)
|
||||||
const saving = ref(false)
|
const busy = ref(false)
|
||||||
const saved = ref('')
|
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 fields = computed(() => (schema.value?.fields ?? []).filter((field) => contextAllows(field, mode)))
|
||||||
const fieldNames = computed(() => fields.value.map((field) => field.name))
|
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. */
|
/** The record's display name: the first text field's value, in schema order. */
|
||||||
const recordName = computed(() => {
|
const recordName = computed(() => {
|
||||||
const first = schema.value?.fields.find((field) => field.type === 'text')
|
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 }) : '',
|
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 {
|
function adopt(record: RecordEnvelope | undefined): void {
|
||||||
if (record) {
|
if (record) {
|
||||||
@@ -94,6 +146,7 @@ async function load(): Promise<void> {
|
|||||||
values.value = initialValues(fields.value)
|
values.value = initialValues(fields.value)
|
||||||
}
|
}
|
||||||
adopt(recordResult?.data)
|
adopt(recordResult?.data)
|
||||||
|
activeTab.value = tabs.value[0]?.key ?? DEFAULT_TAB
|
||||||
loading.value = false
|
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> {
|
async function showErrors(error: ErrorEnvelope['error'] | undefined): Promise<void> {
|
||||||
errors.value = fieldErrors(error?.details)
|
errors.value = fieldErrors(error?.details)
|
||||||
await nextTick()
|
const invalid = fields.value.filter((field) => errors.value[field.name])
|
||||||
const first = fields.value.find((field) => errors.value[field.name])
|
const first = invalid.find((field) => tabOf(field) === activeTab.value) ?? invalid[0]
|
||||||
if (first) {
|
if (!first) {
|
||||||
focusField(ID_PREFIX, first.name)
|
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 {
|
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. */
|
/** Saves the record; returns the saved envelope, or null after an error. */
|
||||||
async function save(): Promise<RecordEnvelope | null> {
|
async function save(): Promise<RecordEnvelope | null> {
|
||||||
if (saving.value || !schema.value) {
|
if (busy.value || !schema.value) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
saving.value = true
|
busy.value = true
|
||||||
try {
|
try {
|
||||||
const body = editablePayload(fields.value, values.value)
|
const body = editablePayload(fields.value, values.value)
|
||||||
const result =
|
const result =
|
||||||
@@ -149,29 +208,110 @@ async function save(): Promise<RecordEnvelope | null> {
|
|||||||
showToast(t('backend::lang.form.error_generic'), 'danger')
|
showToast(t('backend::lang.form.error_generic'), 'danger')
|
||||||
return null
|
return null
|
||||||
} finally {
|
} 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> {
|
async function onSave(): Promise<void> {
|
||||||
const record = await save()
|
const record = await save()
|
||||||
if (record && mode === 'create') {
|
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()
|
void load()
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<section class="mx-auto flex w-full max-w-[980px] flex-col gap-5 pb-24">
|
<section class="mx-auto flex w-full max-w-[980px] flex-col gap-5 pb-24">
|
||||||
<header class="flex items-center gap-4">
|
<header class="flex flex-wrap items-center gap-4">
|
||||||
<div class="flex min-w-0 flex-col">
|
<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>
|
<h1 class="truncate text-[24px] font-bold tracking-[-0.02em]">{{ loading ? '' : title }}</h1>
|
||||||
<p v-if="subtitle" class="text-muted">{{ subtitle }}</p>
|
<p v-if="subtitle" class="text-muted">{{ subtitle }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
<FormTabs v-if="tabs.length > 0" v-model="activeTab" :tabs="tabs" :id-prefix="ID_PREFIX" />
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p v-if="failed && !loading" role="alert" class="rounded-inner bg-danger-soft px-[18px] py-3.5 text-danger">
|
<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">
|
<template v-else-if="schema">
|
||||||
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
|
<FormErrorBanner :errors="errors" :field-names="fieldNames" />
|
||||||
<form
|
<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"
|
class="rounded-card border border-border bg-surface p-7 shadow-card"
|
||||||
novalidate
|
novalidate
|
||||||
@submit.prevent="onSave"
|
@submit.prevent="onSave"
|
||||||
>
|
>
|
||||||
<FormGrid
|
<FormGrid
|
||||||
:fields="fields"
|
:fields="panelFields"
|
||||||
:values="values"
|
:values="values"
|
||||||
:errors="errors"
|
:errors="errors"
|
||||||
:labels="labels"
|
:labels="labels"
|
||||||
@@ -198,12 +341,36 @@ void load()
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<footer
|
<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
|
||||||
<Button variant="primary" :disabled="saving || loading || !schema" data-action="save" @click="onSave">
|
v-if="mode === 'update'"
|
||||||
{{ saving ? t('backend::lang.form.saving') : t('backend::lang.form.save') }}
|
variant="danger"
|
||||||
|
data-action="delete"
|
||||||
|
:icon="Trash2"
|
||||||
|
:disabled="busy || loading || !schema"
|
||||||
|
@click="onDelete"
|
||||||
|
>
|
||||||
|
{{ t('backend::lang.form.delete') }}
|
||||||
</Button>
|
</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>
|
</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>
|
</section>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { t } from '../app/i18n'
|
|||||||
import { safeRedirect } from '../app/router'
|
import { safeRedirect } from '../app/router'
|
||||||
import { login, me } from '../state/useAuth'
|
import { login, me } from '../state/useAuth'
|
||||||
import { loadNavigation } from '../state/useNavigation'
|
import { loadNavigation } from '../state/useNavigation'
|
||||||
|
import { loadSettings } from '../state/useSettings'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@@ -28,7 +29,7 @@ async function submit(): Promise<void> {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
password.value = ''
|
password.value = ''
|
||||||
await loadNavigation()
|
await Promise.all([loadNavigation(), loadSettings().catch(() => [])])
|
||||||
await router.replace(safeRedirect(route.query.redirect) ?? '/')
|
await router.replace(safeRedirect(route.query.redirect) ?? '/')
|
||||||
} finally {
|
} finally {
|
||||||
busy.value = false
|
busy.value = false
|
||||||
|
|||||||
134
admin/src/views/SettingsFormView.vue
Normal file
134
admin/src/views/SettingsFormView.vue
Normal 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>
|
||||||
45
admin/src/views/SettingsIndexView.vue
Normal file
45
admin/src/views/SettingsIndexView.vue
Normal 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>
|
||||||
12
admin/tests/fixtures/lang.json
vendored
12
admin/tests/fixtures/lang.json
vendored
@@ -45,6 +45,9 @@
|
|||||||
"backend::lang.form.deleting": {
|
"backend::lang.form.deleting": {
|
||||||
"other": "Usuwanie…"
|
"other": "Usuwanie…"
|
||||||
},
|
},
|
||||||
|
"backend::lang.form.discard": {
|
||||||
|
"other": "Porzuć zmiany"
|
||||||
|
},
|
||||||
"backend::lang.form.error_fields": {
|
"backend::lang.form.error_fields": {
|
||||||
"one": "Popraw :count pole oznaczone poniżej.",
|
"one": "Popraw :count pole oznaczone poniżej.",
|
||||||
"few": "Popraw :count pola oznaczone poniżej.",
|
"few": "Popraw :count pola oznaczone poniżej.",
|
||||||
@@ -63,6 +66,9 @@
|
|||||||
"backend::lang.form.loading_options": {
|
"backend::lang.form.loading_options": {
|
||||||
"other": "Wczytywanie…"
|
"other": "Wczytywanie…"
|
||||||
},
|
},
|
||||||
|
"backend::lang.form.more_options": {
|
||||||
|
"other": "Więcej wyników"
|
||||||
|
},
|
||||||
"backend::lang.form.no_options": {
|
"backend::lang.form.no_options": {
|
||||||
"other": "Brak wyników"
|
"other": "Brak wyników"
|
||||||
},
|
},
|
||||||
@@ -87,6 +93,9 @@
|
|||||||
"backend::lang.form.select_placeholder": {
|
"backend::lang.form.select_placeholder": {
|
||||||
"other": "Wybierz…"
|
"other": "Wybierz…"
|
||||||
},
|
},
|
||||||
|
"backend::lang.form.tab_default": {
|
||||||
|
"other": "Podstawowe"
|
||||||
|
},
|
||||||
"backend::lang.form.tab_errors": {
|
"backend::lang.form.tab_errors": {
|
||||||
"one": ":count błąd",
|
"one": ":count błąd",
|
||||||
"few": ":count błędy",
|
"few": ":count błędy",
|
||||||
@@ -309,6 +318,9 @@
|
|||||||
"backend::lang.relation.unlink": {
|
"backend::lang.relation.unlink": {
|
||||||
"other": "Odłącz"
|
"other": "Odłącz"
|
||||||
},
|
},
|
||||||
|
"backend::lang.settings.back": {
|
||||||
|
"other": "Wróć do ustawień"
|
||||||
|
},
|
||||||
"backend::lang.settings.empty": {
|
"backend::lang.settings.empty": {
|
||||||
"other": "Brak dostępnych ustawień."
|
"other": "Brak dostępnych ustawień."
|
||||||
},
|
},
|
||||||
|
|||||||
104
admin/tests/fixtures/settings.json
vendored
Normal file
104
admin/tests/fixtures/settings.json
vendored
Normal 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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
157
admin/tests/fixtures/widgets.form-schema.json
vendored
157
admin/tests/fixtures/widgets.form-schema.json
vendored
@@ -2,8 +2,20 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"name": "Widget",
|
"name": "Widget",
|
||||||
"fields": [
|
"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",
|
"name": "size",
|
||||||
"label": "Size",
|
"label": "Size",
|
||||||
@@ -11,28 +23,139 @@
|
|||||||
"span": "left",
|
"span": "left",
|
||||||
"emptyOption": "— none —",
|
"emptyOption": "— none —",
|
||||||
"options": [
|
"options": [
|
||||||
{ "value": "s", "label": "Small" },
|
{
|
||||||
{ "value": "m", "label": "Medium" },
|
"value": "s",
|
||||||
{ "value": "l", "label": "Large" }
|
"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": "weight",
|
||||||
{ "name": "color", "label": "Colour", "type": "colorpicker", "span": "left" }
|
"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": {
|
"messages": {
|
||||||
"create": { "other": "New widget" },
|
"create": {
|
||||||
"update": { "other": "Editing widget" },
|
"other": "New widget"
|
||||||
"saved": { "other": "Widget „:name” saved" },
|
},
|
||||||
"deleteConfirm": { "other": "Delete this widget?" },
|
"update": {
|
||||||
"deleted": { "other": "Widget deleted" }
|
"other": "Editing widget"
|
||||||
|
},
|
||||||
|
"saved": {
|
||||||
|
"other": "Widget „:name” saved"
|
||||||
|
},
|
||||||
|
"deleteConfirm": {
|
||||||
|
"other": "Delete this widget?"
|
||||||
|
},
|
||||||
|
"deleted": {
|
||||||
|
"other": "Widget deleted"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"meta": {
|
||||||
|
"locale": "en"
|
||||||
},
|
},
|
||||||
"meta": { "locale": "en" },
|
|
||||||
"redirects": {
|
"redirects": {
|
||||||
"create": { "redirect": "acme/demo/widgets/update/:id", "redirectClose": "acme/demo/widgets" },
|
"create": {
|
||||||
"update": { "redirect": "acme/demo/widgets", "redirectClose": "acme/demo/widgets" },
|
"redirect": "acme/demo/widgets/update/:id",
|
||||||
|
"redirectClose": "acme/demo/widgets"
|
||||||
|
},
|
||||||
|
"update": {
|
||||||
|
"redirect": "acme/demo/widgets",
|
||||||
|
"redirectClose": "acme/demo/widgets"
|
||||||
|
},
|
||||||
"default": "acme/demo/widgets"
|
"default": "acme/demo/widgets"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"meta": { "locale": "en" }
|
"meta": {
|
||||||
|
"locale": "en"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
82
admin/tests/fixtures/widgets.options.json
vendored
82
admin/tests/fixtures/widgets.options.json
vendored
@@ -1,9 +1,85 @@
|
|||||||
{
|
{
|
||||||
"maker": {
|
"maker": {
|
||||||
"data": [
|
"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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
38
admin/tests/fixtures/widgets.record.json
vendored
38
admin/tests/fixtures/widgets.record.json
vendored
@@ -6,7 +6,41 @@
|
|||||||
"size": "m",
|
"size": "m",
|
||||||
"weight": 12,
|
"weight": 12,
|
||||||
"notes": "Round",
|
"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"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -128,7 +128,20 @@ describe('edit a record', () => {
|
|||||||
const [put] = requestsTo(calls, 'PUT', RECORD)
|
const [put] = requestsTo(calls, 'PUT', RECORD)
|
||||||
expect(put).toBeDefined()
|
expect(put).toBeDefined()
|
||||||
expect(put!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
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"]')
|
const toast = wrapper.find('[role="status"]')
|
||||||
expect(toast.text()).toContain('Widget „Red widget” saved')
|
expect(toast.text()).toContain('Widget „Red widget” saved')
|
||||||
expect(toast.find('button').attributes('aria-label')).toBe('Zamknij')
|
expect(toast.find('button').attributes('aria-label')).toBe('Zamknij')
|
||||||
|
|||||||
338
admin/tests/smoke/form.smoke.test.ts
Normal file
338
admin/tests/smoke/form.smoke.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
127
admin/tests/smoke/settings.smoke.test.ts
Normal file
127
admin/tests/smoke/settings.smoke.test.ts
Normal 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')
|
||||||
|
})
|
||||||
|
})
|
||||||
3
boardwalk/dist/assets/index-CAZobGnD.js
vendored
3
boardwalk/dist/assets/index-CAZobGnD.js
vendored
File diff suppressed because one or more lines are too long
1
boardwalk/dist/assets/index-CWPavuit.css
vendored
Normal file
1
boardwalk/dist/assets/index-CWPavuit.css
vendored
Normal file
File diff suppressed because one or more lines are too long
3
boardwalk/dist/assets/index-DvYDRBo9.js
vendored
Normal file
3
boardwalk/dist/assets/index-DvYDRBo9.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
boardwalk/dist/assets/index-W7axD3vY.css
vendored
1
boardwalk/dist/assets/index-W7axD3vY.css
vendored
File diff suppressed because one or more lines are too long
4
boardwalk/dist/index.html
vendored
4
boardwalk/dist/index.html
vendored
@@ -6,8 +6,8 @@
|
|||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
<meta name="summer-admin-base" content="__SUMMER_ADMIN_BASE__" />
|
||||||
<title>SummerCMS</title>
|
<title>SummerCMS</title>
|
||||||
<script type="module" crossorigin src="./assets/index-CAZobGnD.js"></script>
|
<script type="module" crossorigin src="./assets/index-DvYDRBo9.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="./assets/index-W7axD3vY.css">
|
<link rel="stylesheet" crossorigin href="./assets/index-CWPavuit.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ form:
|
|||||||
select_placeholder: Select…
|
select_placeholder: Select…
|
||||||
loading_options: Loading…
|
loading_options: Loading…
|
||||||
no_options: No results
|
no_options: No results
|
||||||
|
more_options: More results
|
||||||
|
tab_default: General
|
||||||
unsupported_field: "Unsupported field type: :type"
|
unsupported_field: "Unsupported field type: :type"
|
||||||
error_title: Could not save.
|
error_title: Could not save.
|
||||||
error_fields:
|
error_fields:
|
||||||
@@ -78,6 +80,7 @@ form:
|
|||||||
load_failed: The form could not be loaded.
|
load_failed: The form could not be loaded.
|
||||||
error_generic: The changes could not be saved. Please try again.
|
error_generic: The changes could not be saved. Please try again.
|
||||||
unsaved_confirm: You have unsaved changes. Discard them?
|
unsaved_confirm: You have unsaved changes. Discard them?
|
||||||
|
discard: Discard changes
|
||||||
tab_errors:
|
tab_errors:
|
||||||
one: ":count error"
|
one: ":count error"
|
||||||
other: ":count errors"
|
other: ":count errors"
|
||||||
@@ -93,6 +96,7 @@ settings:
|
|||||||
title: Settings
|
title: Settings
|
||||||
empty: No settings are available.
|
empty: No settings are available.
|
||||||
saved: Settings saved
|
saved: Settings saved
|
||||||
|
back: Back to settings
|
||||||
messages:
|
messages:
|
||||||
list:
|
list:
|
||||||
record_count:
|
record_count:
|
||||||
|
|||||||
@@ -74,6 +74,8 @@ form:
|
|||||||
select_placeholder: Wybierz…
|
select_placeholder: Wybierz…
|
||||||
loading_options: Wczytywanie…
|
loading_options: Wczytywanie…
|
||||||
no_options: Brak wyników
|
no_options: Brak wyników
|
||||||
|
more_options: Więcej wyników
|
||||||
|
tab_default: Podstawowe
|
||||||
unsupported_field: "Nieobsługiwany typ pola: :type"
|
unsupported_field: "Nieobsługiwany typ pola: :type"
|
||||||
error_title: Nie udało się zapisać.
|
error_title: Nie udało się zapisać.
|
||||||
error_fields:
|
error_fields:
|
||||||
@@ -84,6 +86,7 @@ form:
|
|||||||
load_failed: Nie udało się wczytać formularza.
|
load_failed: Nie udało się wczytać formularza.
|
||||||
error_generic: Nie udało się zapisać zmian. Spróbuj ponownie.
|
error_generic: Nie udało się zapisać zmian. Spróbuj ponownie.
|
||||||
unsaved_confirm: Masz niezapisane zmiany. Porzucić je?
|
unsaved_confirm: Masz niezapisane zmiany. Porzucić je?
|
||||||
|
discard: Porzuć zmiany
|
||||||
tab_errors:
|
tab_errors:
|
||||||
one: ":count błąd"
|
one: ":count błąd"
|
||||||
few: ":count błędy"
|
few: ":count błędy"
|
||||||
@@ -101,6 +104,7 @@ settings:
|
|||||||
title: Ustawienia
|
title: Ustawienia
|
||||||
empty: Brak dostępnych ustawień.
|
empty: Brak dostępnych ustawień.
|
||||||
saved: Zapisano ustawienia
|
saved: Zapisano ustawienia
|
||||||
|
back: Wróć do ustawień
|
||||||
messages:
|
messages:
|
||||||
list:
|
list:
|
||||||
record_count:
|
record_count:
|
||||||
|
|||||||
Reference in New Issue
Block a user