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:
12
admin/tests/fixtures/lang.json
vendored
12
admin/tests/fixtures/lang.json
vendored
@@ -45,6 +45,9 @@
|
||||
"backend::lang.form.deleting": {
|
||||
"other": "Usuwanie…"
|
||||
},
|
||||
"backend::lang.form.discard": {
|
||||
"other": "Porzuć zmiany"
|
||||
},
|
||||
"backend::lang.form.error_fields": {
|
||||
"one": "Popraw :count pole oznaczone poniżej.",
|
||||
"few": "Popraw :count pola oznaczone poniżej.",
|
||||
@@ -63,6 +66,9 @@
|
||||
"backend::lang.form.loading_options": {
|
||||
"other": "Wczytywanie…"
|
||||
},
|
||||
"backend::lang.form.more_options": {
|
||||
"other": "Więcej wyników"
|
||||
},
|
||||
"backend::lang.form.no_options": {
|
||||
"other": "Brak wyników"
|
||||
},
|
||||
@@ -87,6 +93,9 @@
|
||||
"backend::lang.form.select_placeholder": {
|
||||
"other": "Wybierz…"
|
||||
},
|
||||
"backend::lang.form.tab_default": {
|
||||
"other": "Podstawowe"
|
||||
},
|
||||
"backend::lang.form.tab_errors": {
|
||||
"one": ":count błąd",
|
||||
"few": ":count błędy",
|
||||
@@ -309,6 +318,9 @@
|
||||
"backend::lang.relation.unlink": {
|
||||
"other": "Odłącz"
|
||||
},
|
||||
"backend::lang.settings.back": {
|
||||
"other": "Wróć do ustawień"
|
||||
},
|
||||
"backend::lang.settings.empty": {
|
||||
"other": "Brak dostępnych ustawień."
|
||||
},
|
||||
|
||||
104
admin/tests/fixtures/settings.json
vendored
Normal file
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": {
|
||||
"name": "Widget",
|
||||
"fields": [
|
||||
{ "name": "name", "label": "Name", "type": "text", "span": "left", "required": true },
|
||||
{ "name": "code", "label": "Code", "type": "text", "span": "right", "comment": "Unique widget code" },
|
||||
{
|
||||
"name": "name",
|
||||
"label": "Name",
|
||||
"type": "text",
|
||||
"span": "left",
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "code",
|
||||
"label": "Code",
|
||||
"type": "text",
|
||||
"span": "right",
|
||||
"comment": "Unique widget code"
|
||||
},
|
||||
{
|
||||
"name": "size",
|
||||
"label": "Size",
|
||||
@@ -11,28 +23,139 @@
|
||||
"span": "left",
|
||||
"emptyOption": "— none —",
|
||||
"options": [
|
||||
{ "value": "s", "label": "Small" },
|
||||
{ "value": "m", "label": "Medium" },
|
||||
{ "value": "l", "label": "Large" }
|
||||
{
|
||||
"value": "s",
|
||||
"label": "Small"
|
||||
},
|
||||
{
|
||||
"value": "m",
|
||||
"label": "Medium"
|
||||
},
|
||||
{
|
||||
"value": "l",
|
||||
"label": "Large"
|
||||
}
|
||||
]
|
||||
},
|
||||
{ "name": "weight", "label": "Weight", "type": "number", "span": "right" },
|
||||
{ "name": "notes", "label": "Notes", "type": "textarea", "span": "full", "size": "small" },
|
||||
{ "name": "color", "label": "Colour", "type": "colorpicker", "span": "left" }
|
||||
{
|
||||
"name": "weight",
|
||||
"label": "Weight",
|
||||
"type": "number",
|
||||
"span": "right"
|
||||
},
|
||||
{
|
||||
"name": "notes",
|
||||
"label": "Notes",
|
||||
"type": "textarea",
|
||||
"span": "full",
|
||||
"size": "small"
|
||||
},
|
||||
{
|
||||
"name": "color",
|
||||
"label": "Colour",
|
||||
"type": "colorpicker",
|
||||
"span": "left"
|
||||
},
|
||||
{
|
||||
"name": "active",
|
||||
"label": "Active",
|
||||
"type": "switch",
|
||||
"span": "left",
|
||||
"comment": "Shown in the catalogue"
|
||||
},
|
||||
{
|
||||
"name": "featured",
|
||||
"label": "Featured",
|
||||
"type": "checkbox",
|
||||
"span": "right",
|
||||
"comment": "Pinned to the top"
|
||||
},
|
||||
{
|
||||
"name": "maker",
|
||||
"label": "Maker",
|
||||
"type": "relation",
|
||||
"span": "left",
|
||||
"nameFrom": "name",
|
||||
"emptyOption": "— none —"
|
||||
},
|
||||
{
|
||||
"name": "tags",
|
||||
"label": "Tags",
|
||||
"type": "relation",
|
||||
"span": "right",
|
||||
"nameFrom": "name",
|
||||
"multiple": true,
|
||||
"required": true
|
||||
},
|
||||
{
|
||||
"name": "owner",
|
||||
"label": "Owner",
|
||||
"type": "relation",
|
||||
"span": "left",
|
||||
"nameFrom": "email",
|
||||
"emptyOption": "Current user",
|
||||
"readOnly": true
|
||||
},
|
||||
{
|
||||
"name": "serial",
|
||||
"label": "Serial",
|
||||
"type": "text",
|
||||
"span": "left",
|
||||
"tab": "Details",
|
||||
"context": "update"
|
||||
},
|
||||
{
|
||||
"name": "batch",
|
||||
"label": "Batch",
|
||||
"type": "text",
|
||||
"span": "right",
|
||||
"tab": "Details",
|
||||
"context": [
|
||||
"create"
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "parts",
|
||||
"type": "relation-manager",
|
||||
"relation": "parts",
|
||||
"span": "full",
|
||||
"tab": "Parts",
|
||||
"context": "update"
|
||||
}
|
||||
],
|
||||
"messages": {
|
||||
"create": { "other": "New widget" },
|
||||
"update": { "other": "Editing widget" },
|
||||
"saved": { "other": "Widget „:name” saved" },
|
||||
"deleteConfirm": { "other": "Delete this widget?" },
|
||||
"deleted": { "other": "Widget deleted" }
|
||||
"create": {
|
||||
"other": "New widget"
|
||||
},
|
||||
"update": {
|
||||
"other": "Editing widget"
|
||||
},
|
||||
"saved": {
|
||||
"other": "Widget „:name” saved"
|
||||
},
|
||||
"deleteConfirm": {
|
||||
"other": "Delete this widget?"
|
||||
},
|
||||
"deleted": {
|
||||
"other": "Widget deleted"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
},
|
||||
"meta": { "locale": "en" },
|
||||
"redirects": {
|
||||
"create": { "redirect": "acme/demo/widgets/update/:id", "redirectClose": "acme/demo/widgets" },
|
||||
"update": { "redirect": "acme/demo/widgets", "redirectClose": "acme/demo/widgets" },
|
||||
"create": {
|
||||
"redirect": "acme/demo/widgets/update/:id",
|
||||
"redirectClose": "acme/demo/widgets"
|
||||
},
|
||||
"update": {
|
||||
"redirect": "acme/demo/widgets",
|
||||
"redirectClose": "acme/demo/widgets"
|
||||
},
|
||||
"default": "acme/demo/widgets"
|
||||
}
|
||||
},
|
||||
"meta": { "locale": "en" }
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
}
|
||||
}
|
||||
|
||||
82
admin/tests/fixtures/widgets.options.json
vendored
82
admin/tests/fixtures/widgets.options.json
vendored
@@ -1,9 +1,85 @@
|
||||
{
|
||||
"maker": {
|
||||
"data": [
|
||||
{ "value": "1", "label": "Acme Works" },
|
||||
{ "value": "2", "label": "Bolt & Co" }
|
||||
{
|
||||
"value": "1",
|
||||
"label": "Acme Works"
|
||||
},
|
||||
{
|
||||
"value": "2",
|
||||
"label": "Bolt & Co"
|
||||
}
|
||||
],
|
||||
"meta": { "locale": "en" }
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
}
|
||||
},
|
||||
"makerField": {
|
||||
"data": [
|
||||
{
|
||||
"value": 1,
|
||||
"label": "Acme Works"
|
||||
},
|
||||
{
|
||||
"value": 2,
|
||||
"label": "Bolt & Co"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"total": 3,
|
||||
"last_page": 2
|
||||
}
|
||||
},
|
||||
"makerFieldPage2": {
|
||||
"data": [
|
||||
{
|
||||
"value": 7,
|
||||
"label": "Cog Ltd"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"page": 2,
|
||||
"per_page": 20,
|
||||
"total": 3,
|
||||
"last_page": 2
|
||||
}
|
||||
},
|
||||
"makerFieldSearch": {
|
||||
"data": [
|
||||
{
|
||||
"value": 1,
|
||||
"label": "Acme Works"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"total": 1,
|
||||
"last_page": 1
|
||||
}
|
||||
},
|
||||
"tagsField": {
|
||||
"data": [
|
||||
{
|
||||
"value": 1,
|
||||
"label": "Small"
|
||||
},
|
||||
{
|
||||
"value": 3,
|
||||
"label": "Round"
|
||||
},
|
||||
{
|
||||
"value": 4,
|
||||
"label": "Large"
|
||||
}
|
||||
],
|
||||
"meta": {
|
||||
"page": 1,
|
||||
"per_page": 20,
|
||||
"total": 3,
|
||||
"last_page": 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
38
admin/tests/fixtures/widgets.record.json
vendored
38
admin/tests/fixtures/widgets.record.json
vendored
@@ -6,7 +6,41 @@
|
||||
"size": "m",
|
||||
"weight": 12,
|
||||
"notes": "Round",
|
||||
"color": "#0000ff"
|
||||
"color": "#0000ff",
|
||||
"active": true,
|
||||
"featured": false,
|
||||
"maker": 2,
|
||||
"tags": [
|
||||
3,
|
||||
1
|
||||
],
|
||||
"owner": 5,
|
||||
"serial": "SN-1"
|
||||
},
|
||||
"meta": { "labels": {} }
|
||||
"meta": {
|
||||
"labels": {
|
||||
"maker": [
|
||||
{
|
||||
"value": 2,
|
||||
"label": "Bolt & Co"
|
||||
}
|
||||
],
|
||||
"tags": [
|
||||
{
|
||||
"value": 3,
|
||||
"label": "Round"
|
||||
},
|
||||
{
|
||||
"value": 1,
|
||||
"label": "Small"
|
||||
}
|
||||
],
|
||||
"owner": [
|
||||
{
|
||||
"value": 5,
|
||||
"label": "dana@example.test"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -128,7 +128,20 @@ describe('edit a record', () => {
|
||||
const [put] = requestsTo(calls, 'PUT', RECORD)
|
||||
expect(put).toBeDefined()
|
||||
expect(put!.headers.get('X-Requested-With')).toBe('XMLHttpRequest')
|
||||
expect(await put!.json()).toEqual({ name: 'Red widget', code: 'W-01', size: 'l', weight: null, notes: 'Round' })
|
||||
// Every editable, context-allowed field; not the unsupported colorpicker,
|
||||
// the read-only owner or the relation manager.
|
||||
expect(await put!.json()).toEqual({
|
||||
name: 'Red widget',
|
||||
code: 'W-01',
|
||||
size: 'l',
|
||||
weight: null,
|
||||
notes: 'Round',
|
||||
active: true,
|
||||
featured: false,
|
||||
maker: 2,
|
||||
tags: [3, 1],
|
||||
serial: 'SN-1',
|
||||
})
|
||||
const toast = wrapper.find('[role="status"]')
|
||||
expect(toast.text()).toContain('Widget „Red widget” saved')
|
||||
expect(toast.find('button').attributes('aria-label')).toBe('Zamknij')
|
||||
|
||||
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')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user