feat(10-03): open, edit and save a record with toast and 422 feedback
- The SPA loads the backend::lang bundle before /auth/me, sets the document language from meta.locale and renders plural messages with Intl.PluralRules; interpolate mirrors phrasebook for :name/:Name/:NAME - Create and record routes; mapWinterUrl maps recordUrl and redirects onto the controller's list, create and record routes only - List rows open their record; FormView loads the form schema and the record, shows context-allowed fields in the span grid, saves values keyed by field name and toasts the resolved saved message - A 422 puts each message under its field (aria-invalid, aria-describedby), shows the plural banner, focuses the first invalid field in schema order and clears a field's error on change - The D-05 registry maps text, textarea, number and dropdown; any other type renders the unsupported-field box with the type in DM Mono - The admin OpenAPI document declares the write request bodies (AdminRecord, AdminIDsRequest) and the list filter query as a deepObject, so the typed client can send them - New backend::lang form.load_failed key; boardwalk/dist rebuilt
This commit is contained in:
304
admin/tests/fixtures/lang.json
vendored
Normal file
304
admin/tests/fixtures/lang.json
vendored
Normal file
@@ -0,0 +1,304 @@
|
||||
{
|
||||
"data": {
|
||||
"backend::lang.auth.invalid": {
|
||||
"other": "Nieprawidłowe dane logowania"
|
||||
},
|
||||
"backend::lang.auth.login": {
|
||||
"other": "Login lub e-mail"
|
||||
},
|
||||
"backend::lang.auth.logout": {
|
||||
"other": "Wyloguj"
|
||||
},
|
||||
"backend::lang.auth.password": {
|
||||
"other": "Hasło"
|
||||
},
|
||||
"backend::lang.auth.session_expired": {
|
||||
"other": "Sesja wygasła. Zaloguj się ponownie."
|
||||
},
|
||||
"backend::lang.auth.submit": {
|
||||
"other": "Zaloguj"
|
||||
},
|
||||
"backend::lang.auth.title": {
|
||||
"other": "Witaj ponownie"
|
||||
},
|
||||
"backend::lang.form.add": {
|
||||
"other": "Dodaj"
|
||||
},
|
||||
"backend::lang.form.add_item": {
|
||||
"other": "Dodaj…"
|
||||
},
|
||||
"backend::lang.form.cancel": {
|
||||
"other": "Anuluj"
|
||||
},
|
||||
"backend::lang.form.close": {
|
||||
"other": "Zamknij"
|
||||
},
|
||||
"backend::lang.form.confirm": {
|
||||
"other": "Potwierdź"
|
||||
},
|
||||
"backend::lang.form.create": {
|
||||
"other": "Utwórz"
|
||||
},
|
||||
"backend::lang.form.delete": {
|
||||
"other": "Usuń"
|
||||
},
|
||||
"backend::lang.form.deleting": {
|
||||
"other": "Usuwanie…"
|
||||
},
|
||||
"backend::lang.form.error_fields": {
|
||||
"one": "Popraw :count pole oznaczone poniżej.",
|
||||
"few": "Popraw :count pola oznaczone poniżej.",
|
||||
"many": "Popraw :count pól oznaczonych poniżej.",
|
||||
"other": "Popraw :count pola oznaczonego poniżej."
|
||||
},
|
||||
"backend::lang.form.error_generic": {
|
||||
"other": "Nie udało się zapisać zmian. Spróbuj ponownie."
|
||||
},
|
||||
"backend::lang.form.error_title": {
|
||||
"other": "Nie udało się zapisać."
|
||||
},
|
||||
"backend::lang.form.load_failed": {
|
||||
"other": "Nie udało się wczytać formularza."
|
||||
},
|
||||
"backend::lang.form.loading_options": {
|
||||
"other": "Wczytywanie…"
|
||||
},
|
||||
"backend::lang.form.no_options": {
|
||||
"other": "Brak wyników"
|
||||
},
|
||||
"backend::lang.form.none": {
|
||||
"other": "— brak —"
|
||||
},
|
||||
"backend::lang.form.remove_item": {
|
||||
"other": "Usuń: :name"
|
||||
},
|
||||
"backend::lang.form.return_to_list": {
|
||||
"other": "Wróć do listy"
|
||||
},
|
||||
"backend::lang.form.save": {
|
||||
"other": "Zapisz"
|
||||
},
|
||||
"backend::lang.form.save_and_close": {
|
||||
"other": "Zapisz i zamknij"
|
||||
},
|
||||
"backend::lang.form.saving": {
|
||||
"other": "Zapisywanie…"
|
||||
},
|
||||
"backend::lang.form.select_placeholder": {
|
||||
"other": "Wybierz…"
|
||||
},
|
||||
"backend::lang.form.tab_errors": {
|
||||
"one": ":count błąd",
|
||||
"few": ":count błędy",
|
||||
"many": ":count błędów",
|
||||
"other": ":count błędu"
|
||||
},
|
||||
"backend::lang.form.unsaved_confirm": {
|
||||
"other": "Masz niezapisane zmiany. Porzucić je?"
|
||||
},
|
||||
"backend::lang.form.unsupported_field": {
|
||||
"other": "Nieobsługiwany typ pola: :type"
|
||||
},
|
||||
"backend::lang.form.update": {
|
||||
"other": "Edytuj"
|
||||
},
|
||||
"backend::lang.list.clear_search": {
|
||||
"other": "Wyczyść wyszukiwanie"
|
||||
},
|
||||
"backend::lang.list.column_created": {
|
||||
"other": "Utworzono"
|
||||
},
|
||||
"backend::lang.list.column_switch_false": {
|
||||
"other": "Nie"
|
||||
},
|
||||
"backend::lang.list.column_switch_true": {
|
||||
"other": "Tak"
|
||||
},
|
||||
"backend::lang.list.column_updated": {
|
||||
"other": "Zaktualizowano"
|
||||
},
|
||||
"backend::lang.list.delete_selected": {
|
||||
"other": "Usuń zaznaczone"
|
||||
},
|
||||
"backend::lang.list.empty_value": {
|
||||
"other": "—"
|
||||
},
|
||||
"backend::lang.list.load_failed": {
|
||||
"other": "Nie udało się wczytać listy."
|
||||
},
|
||||
"backend::lang.list.loading": {
|
||||
"other": "Wczytywanie…"
|
||||
},
|
||||
"backend::lang.list.next_page": {
|
||||
"other": "Następna"
|
||||
},
|
||||
"backend::lang.list.no_records": {
|
||||
"other": "Brak rekordów w tym widoku."
|
||||
},
|
||||
"backend::lang.list.no_results": {
|
||||
"other": "0 wyników"
|
||||
},
|
||||
"backend::lang.list.pagination_range": {
|
||||
"other": ":from–:to z :total"
|
||||
},
|
||||
"backend::lang.list.prev_page": {
|
||||
"other": "Poprzednia"
|
||||
},
|
||||
"backend::lang.list.records_per_page": {
|
||||
"other": "Na stronę"
|
||||
},
|
||||
"backend::lang.list.results": {
|
||||
"one": ":count wynik",
|
||||
"few": ":count wyniki",
|
||||
"many": ":count wyników",
|
||||
"other": ":count wyniku"
|
||||
},
|
||||
"backend::lang.list.search_prompt": {
|
||||
"other": "Szukaj…"
|
||||
},
|
||||
"backend::lang.list.select_all": {
|
||||
"other": "Zaznacz wszystkie na stronie"
|
||||
},
|
||||
"backend::lang.list.select_row": {
|
||||
"other": "Zaznacz wiersz"
|
||||
},
|
||||
"backend::lang.list.sort_ascending": {
|
||||
"other": "Sortuj rosnąco"
|
||||
},
|
||||
"backend::lang.list.sort_descending": {
|
||||
"other": "Sortuj malejąco"
|
||||
},
|
||||
"backend::lang.messages.form.create": {
|
||||
"other": "Nowy rekord"
|
||||
},
|
||||
"backend::lang.messages.form.delete_confirm": {
|
||||
"other": "Usunąć ten rekord?"
|
||||
},
|
||||
"backend::lang.messages.form.deleted": {
|
||||
"other": "Usunięto rekord"
|
||||
},
|
||||
"backend::lang.messages.form.saved": {
|
||||
"other": "Zapisano"
|
||||
},
|
||||
"backend::lang.messages.form.update": {
|
||||
"other": "Edycja rekordu"
|
||||
},
|
||||
"backend::lang.messages.list.create": {
|
||||
"other": "Nowy rekord"
|
||||
},
|
||||
"backend::lang.messages.list.delete_confirm": {
|
||||
"other": "Usunąć zaznaczone (:count)?"
|
||||
},
|
||||
"backend::lang.messages.list.deleted": {
|
||||
"one": "Usunięto :count rekord",
|
||||
"few": "Usunięto :count rekordy",
|
||||
"many": "Usunięto :count rekordów",
|
||||
"other": "Usunięto :count rekordu"
|
||||
},
|
||||
"backend::lang.messages.list.empty_search": {
|
||||
"other": "Nic nie znaleziono"
|
||||
},
|
||||
"backend::lang.messages.list.empty_search_hint": {
|
||||
"other": "Żaden rekord nie pasuje do frazy „:term”. Spróbuj innej frazy."
|
||||
},
|
||||
"backend::lang.messages.list.record_count": {
|
||||
"one": ":count rekord",
|
||||
"few": ":count rekordy",
|
||||
"many": ":count rekordów",
|
||||
"other": ":count rekordu"
|
||||
},
|
||||
"backend::lang.messages.list.selected": {
|
||||
"other": "Zaznaczono :count"
|
||||
},
|
||||
"backend::lang.messages.relation.candidate_search": {
|
||||
"other": "Szukaj…"
|
||||
},
|
||||
"backend::lang.messages.relation.empty": {
|
||||
"other": "Brak powiązanych rekordów."
|
||||
},
|
||||
"backend::lang.messages.relation.link": {
|
||||
"other": "Dodaj"
|
||||
},
|
||||
"backend::lang.messages.relation.link_hint": {
|
||||
"other": "Wybierz rekordy, które chcesz dołączyć."
|
||||
},
|
||||
"backend::lang.messages.relation.linked": {
|
||||
"one": "Dołączono :count rekord",
|
||||
"few": "Dołączono :count rekordy",
|
||||
"many": "Dołączono :count rekordów",
|
||||
"other": "Dołączono :count rekordu"
|
||||
},
|
||||
"backend::lang.messages.relation.unlink_confirm": {
|
||||
"other": "Odłączyć zaznaczone (:count)?"
|
||||
},
|
||||
"backend::lang.messages.relation.unlink_selected": {
|
||||
"other": "Odłącz zaznaczone"
|
||||
},
|
||||
"backend::lang.messages.relation.unlinked": {
|
||||
"one": "Odłączono :count rekord",
|
||||
"few": "Odłączono :count rekordy",
|
||||
"many": "Odłączono :count rekordów",
|
||||
"other": "Odłączono :count rekordu"
|
||||
},
|
||||
"backend::lang.nav.breadcrumbs": {
|
||||
"other": "Ścieżka nawigacji"
|
||||
},
|
||||
"backend::lang.nav.collapse": {
|
||||
"other": "Zwiń menu"
|
||||
},
|
||||
"backend::lang.nav.empty": {
|
||||
"other": "Brak dostępnych sekcji."
|
||||
},
|
||||
"backend::lang.nav.expand": {
|
||||
"other": "Rozwiń menu"
|
||||
},
|
||||
"backend::lang.nav.plugins": {
|
||||
"other": "Wtyczki"
|
||||
},
|
||||
"backend::lang.nav.sections": {
|
||||
"other": "Sekcje"
|
||||
},
|
||||
"backend::lang.nav.settings": {
|
||||
"other": "Ustawienia"
|
||||
},
|
||||
"backend::lang.nav.user_menu": {
|
||||
"other": "Menu użytkownika"
|
||||
},
|
||||
"backend::lang.page.not_found": {
|
||||
"other": "Nie znaleziono strony."
|
||||
},
|
||||
"backend::lang.relation.add": {
|
||||
"other": "Dodaj"
|
||||
},
|
||||
"backend::lang.relation.add_count": {
|
||||
"other": "Dodaj (:count)"
|
||||
},
|
||||
"backend::lang.relation.candidates_range": {
|
||||
"other": ":from–:to z :total"
|
||||
},
|
||||
"backend::lang.relation.link": {
|
||||
"other": "Dołącz"
|
||||
},
|
||||
"backend::lang.relation.remove": {
|
||||
"other": "Usuń"
|
||||
},
|
||||
"backend::lang.relation.search": {
|
||||
"other": "Szukaj…"
|
||||
},
|
||||
"backend::lang.relation.unlink": {
|
||||
"other": "Odłącz"
|
||||
},
|
||||
"backend::lang.settings.empty": {
|
||||
"other": "Brak dostępnych ustawień."
|
||||
},
|
||||
"backend::lang.settings.saved": {
|
||||
"other": "Zapisano ustawienia"
|
||||
},
|
||||
"backend::lang.settings.title": {
|
||||
"other": "Ustawienia"
|
||||
}
|
||||
},
|
||||
"meta": {
|
||||
"locale": "pl"
|
||||
}
|
||||
}
|
||||
38
admin/tests/fixtures/widgets.form-schema.json
vendored
Normal file
38
admin/tests/fixtures/widgets.form-schema.json
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
{
|
||||
"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": "size",
|
||||
"label": "Size",
|
||||
"type": "dropdown",
|
||||
"span": "left",
|
||||
"emptyOption": "— none —",
|
||||
"options": [
|
||||
{ "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" }
|
||||
],
|
||||
"messages": {
|
||||
"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" },
|
||||
"redirects": {
|
||||
"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" }
|
||||
}
|
||||
35
admin/tests/fixtures/widgets.list-schema.json
vendored
35
admin/tests/fixtures/widgets.list-schema.json
vendored
@@ -8,15 +8,38 @@
|
||||
"showCheckboxes": true,
|
||||
"showSorting": true,
|
||||
"searchTerm": "",
|
||||
"toolbarButtons": ["create"],
|
||||
"toolbarButtons": [
|
||||
"create"
|
||||
],
|
||||
"columns": [
|
||||
{ "key": "name", "label": "Name", "searchable": true, "sortable": true },
|
||||
{ "key": "code", "label": "Code", "searchable": true, "sortable": true },
|
||||
{ "key": "tags", "label": "Tags", "searchable": false, "sortable": false, "type": "relation", "relation": "tags", "select": "name" }
|
||||
{
|
||||
"key": "name",
|
||||
"label": "Name",
|
||||
"searchable": true,
|
||||
"sortable": true
|
||||
},
|
||||
{
|
||||
"key": "code",
|
||||
"label": "Code",
|
||||
"searchable": true,
|
||||
"sortable": true
|
||||
},
|
||||
{
|
||||
"key": "tags",
|
||||
"label": "Tags",
|
||||
"searchable": false,
|
||||
"sortable": false,
|
||||
"type": "relation",
|
||||
"relation": "tags",
|
||||
"select": "name"
|
||||
}
|
||||
],
|
||||
"filters": [],
|
||||
"rowActions": [],
|
||||
"bulkActions": []
|
||||
"bulkActions": [],
|
||||
"recordUrl": "acme/demo/widgets/update/:id"
|
||||
},
|
||||
"meta": { "locale": "en" }
|
||||
"meta": {
|
||||
"locale": "en"
|
||||
}
|
||||
}
|
||||
|
||||
12
admin/tests/fixtures/widgets.record.json
vendored
Normal file
12
admin/tests/fixtures/widgets.record.json
vendored
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"data": {
|
||||
"id": 1,
|
||||
"name": "Blue widget",
|
||||
"code": "W-01",
|
||||
"size": "m",
|
||||
"weight": 12,
|
||||
"notes": "Round",
|
||||
"color": "#0000ff"
|
||||
},
|
||||
"meta": { "labels": {} }
|
||||
}
|
||||
87
admin/tests/helpers.ts
Normal file
87
admin/tests/helpers.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Shared smoke-test helpers: a fetch mock keyed by "METHOD /path" and an app
|
||||
// mount at a route with a signed-in admin. Fixtures are neutral acme.demo.*
|
||||
// data; no application names appear in framework tests.
|
||||
import { vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory } from 'vue-router'
|
||||
import App from '../src/App.vue'
|
||||
import { createAdminRouter } from '../src/app/router'
|
||||
import { me } from '../src/state/useAuth'
|
||||
import { loadNavigation } from '../src/state/useNavigation'
|
||||
import navigation from './fixtures/navigation.json'
|
||||
|
||||
export const API = '/admin-test/api/v1'
|
||||
|
||||
export const profile = {
|
||||
data: {
|
||||
id: 7,
|
||||
login: 'dev',
|
||||
email: 'dev@example.test',
|
||||
first_name: 'Dana',
|
||||
last_name: 'Dev',
|
||||
is_superuser: false,
|
||||
role: { id: 2, code: 'developer', name: 'Developer' },
|
||||
},
|
||||
meta: {},
|
||||
}
|
||||
|
||||
export interface Reply {
|
||||
status?: number
|
||||
body: unknown
|
||||
}
|
||||
|
||||
export type Route = Reply | ((request: Request) => Reply | Promise<Reply>)
|
||||
|
||||
export function json(status: number, body: unknown): Response {
|
||||
return new Response(JSON.stringify(body), { status, headers: { 'Content-Type': 'application/json' } })
|
||||
}
|
||||
|
||||
export const notFound = { error: { code: 'not_found', message: 'Not found', details: {} } }
|
||||
|
||||
/** Replaces fetch; routes are keyed by "METHOD pathname" (query ignored). */
|
||||
export function mockApi(routes: Record<string, Route>): Request[] {
|
||||
const calls: Request[] = []
|
||||
vi.spyOn(globalThis, 'fetch').mockImplementation(async (input: RequestInfo | URL) => {
|
||||
const request = input as Request
|
||||
calls.push(request)
|
||||
const route = routes[`${request.method} ${new URL(request.url).pathname}`]
|
||||
if (!route) {
|
||||
return json(404, notFound)
|
||||
}
|
||||
const reply = typeof route === 'function' ? await route(request.clone()) : route
|
||||
return json(reply.status ?? 200, reply.body)
|
||||
})
|
||||
return calls
|
||||
}
|
||||
|
||||
export function pathOf(request: Request): string {
|
||||
return new URL(request.url).pathname
|
||||
}
|
||||
|
||||
export function queryOf(request: Request): URLSearchParams {
|
||||
return new URL(request.url).searchParams
|
||||
}
|
||||
|
||||
/** Requests matching a method and pathname, in call order. */
|
||||
export function requestsTo(calls: Request[], method: string, pathname: string): Request[] {
|
||||
return calls.filter((request) => request.method === method && pathOf(request) === pathname)
|
||||
}
|
||||
|
||||
/** Mounts the whole app at a path with the profile and navigation loaded. */
|
||||
export async function mountApp(path: string, routes: Record<string, Route>, options: { attach?: boolean } = {}) {
|
||||
const calls = mockApi({
|
||||
[`GET ${API}/auth/me`]: { body: profile },
|
||||
[`GET ${API}/navigation`]: { body: navigation },
|
||||
...routes,
|
||||
})
|
||||
await me()
|
||||
await loadNavigation()
|
||||
const router = createAdminRouter(createMemoryHistory())
|
||||
await router.push(path)
|
||||
const wrapper = mount(App, {
|
||||
global: { plugins: [router] },
|
||||
attachTo: options.attach ? document.body : undefined,
|
||||
})
|
||||
await flushPromises()
|
||||
return { wrapper, calls, router }
|
||||
}
|
||||
205
admin/tests/smoke/edit.smoke.test.ts
Normal file
205
admin/tests/smoke/edit.smoke.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { flushPromises } from '@vue/test-utils'
|
||||
import { interpolate, loadStrings, message, setBundle, t, tc } from '../../src/app/i18n'
|
||||
import { mapWinterUrl } from '../../src/app/winterUrl'
|
||||
import { clearUser } from '../../src/state/useAuth'
|
||||
import { setNavigation } from '../../src/state/useNavigation'
|
||||
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 { API, mockApi, mountApp, pathOf, requestsTo, type Route } from '../helpers'
|
||||
|
||||
const RECORD = `${API}/acme/demo/widgets/1`
|
||||
|
||||
const formRoutes: Record<string, Route> = {
|
||||
[`GET ${API}/acme/demo/widgets/schema/form`]: { body: formSchema },
|
||||
[`GET ${RECORD}`]: { body: record },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
clearUser()
|
||||
setNavigation([])
|
||||
clearToasts()
|
||||
setBundle(lang.data, 'pl')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('strings', () => {
|
||||
it('loads the backend::lang bundle and sets the document language', async () => {
|
||||
setBundle({}, 'en')
|
||||
const calls = mockApi({ [`GET ${API}/lang`]: { body: lang } })
|
||||
|
||||
await loadStrings()
|
||||
|
||||
expect(calls.map(pathOf)).toEqual([`${API}/lang`])
|
||||
expect(document.documentElement.lang).toBe('pl')
|
||||
expect(t('backend::lang.form.save')).toBe('Zapisz')
|
||||
expect(t('backend::lang.form.missing_key')).toBe('backend::lang.form.missing_key')
|
||||
})
|
||||
|
||||
it('interpolates :name, :Name and :NAME like phrasebook.interpolate', () => {
|
||||
expect(interpolate('Hello :name, :Name, :NAME', { name: 'ala' })).toBe('Hello ala, Ala, ALA')
|
||||
// Longer placeholders win, so :names is not eaten by :name.
|
||||
expect(interpolate(':names and :name', { name: 'x', names: 'y' })).toBe('y and x')
|
||||
expect(interpolate('Item :name', { ':name': 'żaba' })).toBe('Item żaba')
|
||||
expect(interpolate(':Name', { name: 'żaba' })).toBe('Żaba')
|
||||
expect(interpolate(':count items', { count: 3 })).toBe('3 items')
|
||||
expect(interpolate('no placeholders', {})).toBe('no placeholders')
|
||||
})
|
||||
|
||||
it('picks the CLDR plural form for the locale and falls back to other', () => {
|
||||
expect(tc('backend::lang.form.error_fields', 1)).toBe('Popraw 1 pole oznaczone poniżej.')
|
||||
expect(tc('backend::lang.form.error_fields', 2)).toBe('Popraw 2 pola oznaczone poniżej.')
|
||||
expect(tc('backend::lang.form.error_fields', 5)).toBe('Popraw 5 pól oznaczonych poniżej.')
|
||||
expect(message({ other: ':count things' }, 1)).toBe('1 things')
|
||||
})
|
||||
})
|
||||
|
||||
describe('winter URLs', () => {
|
||||
it('maps recordUrl and redirects onto the controller routes only', () => {
|
||||
const id = 'acme.demo.widgets'
|
||||
expect(mapWinterUrl('acme/demo/widgets', id)).toBe('/acme/demo/widgets')
|
||||
expect(mapWinterUrl('/acme/demo/widgets/create', id)).toBe('/acme/demo/widgets/create')
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', id, 42)).toBe('/acme/demo/widgets/42')
|
||||
expect(mapWinterUrl('acme/demo/widgets/update/:id', id, null)).toBe('/acme/demo/widgets')
|
||||
expect(mapWinterUrl('acme/demo/widgets/preview/:id', id, 42)).toBe('/acme/demo/widgets')
|
||||
expect(mapWinterUrl('https://evil.example/acme/demo/widgets', id, 1)).toBe('/acme/demo/widgets')
|
||||
expect(mapWinterUrl('acme/other/things/update/:id', id, 1)).toBe('/acme/demo/widgets')
|
||||
expect(mapWinterUrl('', id)).toBe('/acme/demo/widgets')
|
||||
})
|
||||
})
|
||||
|
||||
describe('edit a record', () => {
|
||||
it('opens the record route from a list row', async () => {
|
||||
const { wrapper, router } = await mountApp('/acme/demo/widgets', {
|
||||
[`GET ${API}/acme/demo/widgets/schema/list`]: { body: listSchema },
|
||||
[`GET ${API}/acme/demo/widgets`]: { body: listRows },
|
||||
...formRoutes,
|
||||
})
|
||||
|
||||
const firstRow = wrapper.findAll('tbody tr')[0]!
|
||||
expect(firstRow.find('td a').attributes('href')).toBe('/acme/demo/widgets/1')
|
||||
expect(firstRow.findAll('td').at(-2)!.classes()).not.toContain('font-semibold')
|
||||
|
||||
await firstRow.findAll('td').at(-1)!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.fullPath).toBe('/acme/demo/widgets/1')
|
||||
expect(wrapper.find('h1').text()).toBe('Blue widget')
|
||||
expect(wrapper.text()).toContain('Editing widget')
|
||||
})
|
||||
|
||||
it('renders the schema fields with their values and marks required fields', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes)
|
||||
|
||||
const name = wrapper.find('#field-name')
|
||||
expect((name.element as HTMLInputElement).value).toBe('Blue widget')
|
||||
expect(name.attributes('aria-required')).toBe('true')
|
||||
expect(wrapper.find('label[for="field-name"] [aria-hidden="true"]').text()).toBe('*')
|
||||
expect((wrapper.find('#field-weight').element as HTMLInputElement).value).toBe('12')
|
||||
expect(wrapper.find('#field-notes').attributes('rows')).toBe('4')
|
||||
const size = wrapper.find('#field-size')
|
||||
expect(size.findAll('option').map((option) => option.text())).toEqual(['— none —', 'Small', 'Medium', 'Large'])
|
||||
expect((size.element as HTMLSelectElement).value).toBe('1')
|
||||
expect(wrapper.find('#field-code').attributes('aria-describedby')).toBe('field-code-comment')
|
||||
expect(wrapper.find('#field-code-comment').text()).toBe('Unique widget code')
|
||||
})
|
||||
|
||||
it('PUTs the typed body keyed by field name and shows the saved toast', async () => {
|
||||
const { wrapper, calls } = await mountApp('/acme/demo/widgets/1', {
|
||||
...formRoutes,
|
||||
[`PUT ${RECORD}`]: async (request) => ({
|
||||
body: { data: { ...record.data, ...((await request.json()) as object) }, meta: { labels: {} } },
|
||||
}),
|
||||
})
|
||||
|
||||
await wrapper.find('#field-name').setValue('Red widget')
|
||||
await wrapper.find('#field-size').setValue('2')
|
||||
await wrapper.find('#field-weight').setValue('')
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
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' })
|
||||
const toast = wrapper.find('[role="status"]')
|
||||
expect(toast.text()).toContain('Widget „Red widget” saved')
|
||||
expect(toast.find('button').attributes('aria-label')).toBe('Zamknij')
|
||||
})
|
||||
|
||||
it('maps a 422 onto fields, focuses the first invalid one and clears on change', async () => {
|
||||
const { wrapper } = await mountApp(
|
||||
'/acme/demo/widgets/1',
|
||||
{
|
||||
...formRoutes,
|
||||
[`PUT ${RECORD}`]: {
|
||||
status: 422,
|
||||
body: {
|
||||
error: {
|
||||
code: 'validation_failed',
|
||||
message: 'The given data was invalid.',
|
||||
details: { weight: ['The weight must be positive.'], name: ['The name field is required.'] },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{ attach: true },
|
||||
)
|
||||
|
||||
await wrapper.find('#field-name').setValue('')
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const banner = wrapper.find('[data-error-banner]')
|
||||
expect(banner.attributes('role')).toBe('alert')
|
||||
expect(banner.text()).toContain('Nie udało się zapisać.')
|
||||
expect(banner.text()).toContain('Popraw 2 pola oznaczone poniżej.')
|
||||
const name = wrapper.find('#field-name')
|
||||
expect(name.attributes('aria-invalid')).toBe('true')
|
||||
expect(name.attributes('aria-describedby')).toBe('field-name-error')
|
||||
expect(wrapper.find('#field-name-error').text()).toBe('The name field is required.')
|
||||
expect(wrapper.find('#field-weight').attributes('aria-invalid')).toBe('true')
|
||||
// Schema order decides which invalid field is first, not the details order.
|
||||
expect(document.activeElement?.id).toBe('field-name')
|
||||
|
||||
await name.setValue('Fixed')
|
||||
expect(wrapper.find('#field-name').attributes('aria-invalid')).toBeUndefined()
|
||||
expect(wrapper.find('#field-name-error').exists()).toBe(false)
|
||||
expect(wrapper.find('#field-weight').attributes('aria-invalid')).toBe('true')
|
||||
expect(wrapper.find('[data-error-banner]').text()).toContain('Popraw 1 pole oznaczone poniżej.')
|
||||
})
|
||||
|
||||
it('shows a danger toast with the envelope message for other errors', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', {
|
||||
...formRoutes,
|
||||
[`PUT ${RECORD}`]: { status: 403, body: { error: { code: 'forbidden', message: 'Forbidden.', details: {} } } },
|
||||
})
|
||||
|
||||
await wrapper.find('[data-action="save"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
const toast = wrapper.find('[data-tone="danger"]')
|
||||
expect(toast.attributes('role')).toBe('alert')
|
||||
expect(toast.text()).toContain('Forbidden.')
|
||||
expect(wrapper.find('[data-error-banner]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('renders an unknown field type as the unsupported box instead of breaking the form', async () => {
|
||||
const { wrapper } = await mountApp('/acme/demo/widgets/1', formRoutes)
|
||||
|
||||
const box = wrapper.find('[data-unsupported-field]')
|
||||
expect(box.exists()).toBe(true)
|
||||
expect(box.text()).toBe('Nieobsługiwany typ pola: colorpicker')
|
||||
expect(box.find('code').text()).toBe('colorpicker')
|
||||
expect(box.find('code').classes()).toContain('font-mono')
|
||||
expect(box.find('svg').exists()).toBe(true)
|
||||
expect(wrapper.find('label[for="field-color"]').text()).toBe('Colour')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user