feat(KAN-20): unify dialogs feedback and protect ticket drafts
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s

This commit is contained in:
2026-09-11 14:17:10 +02:00
parent 850ae337ca
commit f4a8bdd134
27 changed files with 1793 additions and 329 deletions
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class FormFeedbackTest extends TestCase
{
public function test_feedback_states_and_request_failure_classification(): void
{
$process = new Process(['node', 'tests/Fixtures/form-feedback.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Form feedback behavior checks passed.', $process->getOutput());
}
}
@@ -248,6 +248,8 @@ class ProjectTicketControllerTest extends TestCase
*/
public function test_ticket_details_include_matching_branches_pull_requests_and_inaccessible_repository_links(): void
{
config()->set('inertia.ssr.enabled', false);
Http::preventStrayRequests();
Http::fake([
'https://git.example.test/api/v1/repos/core-team/kanbangit/branches*' => Http::response([
+154
View File
@@ -0,0 +1,154 @@
<script setup>
import { Link, useForm } from '@inertiajs/vue3';
import { ref } from 'vue';
import ProjectTicketController from '@/actions/App/Http/Controllers/ProjectTicketController';
import AppearanceTabs from '@/components/AppearanceTabs.vue';
import FeedbackState from '@/components/FeedbackState.vue';
import FormFeedback from '@/components/FormFeedback.vue';
import InputError from '@/components/InputError.vue';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogClose,
DialogScrollContent,
} from '@/components/ui/dialog';
import {
Sheet,
SheetContent,
SheetTitle,
SheetDescription,
SheetTrigger,
} from '@/components/ui/sheet';
import {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
} from '@/components/ui/dropdown-menu';
import { useFormProtection } from '@/composables/useFormProtection';
import { index as projectsIndex } from '@/routes/projects/index';
const form = useForm('feedback-preview', { title: '' });
const protection = useFormProtection(form);
const dialogOpen = ref(false);
const menuSelection = ref('');
const submit = () =>
protection.submit(ProjectTicketController.store({ project: 7 }));
</script>
<template>
<main class="gap-column p-page mx-auto grid max-w-3xl">
<div class="gap-stack flex flex-wrap items-center justify-between">
<h1 class="text-headline">Dialogs and feedback</h1>
<AppearanceTabs />
</div>
<FeedbackState
state="empty"
description="New tickets will appear here."
/>
<FeedbackState state="loading" />
<form
class="gap-card bg-card p-card grid rounded-md border"
@submit.prevent="submit"
>
<label for="title">Title</label>
<Input
id="title"
v-model="form.title"
:invalid="Boolean(form.errors.title)"
aria-describedby="title-error"
/>
<InputError id="title-error" :message="form.errors.title" />
<FormFeedback :state="protection.state.value" @retry="submit" />
<Button type="submit" :loading="form.processing">Save draft</Button>
<Button type="button" variant="outline" @click="form.cancel()"
>Cancel request</Button
>
</form>
<Link :href="projectsIndex()">Leave page</Link>
<div class="gap-stack flex flex-wrap">
<Dialog v-model:open="dialogOpen">
<Button id="programmatic-trigger" @click="dialogOpen = true"
>Open dialog</Button
>
<DialogContent>
<DialogHeader
><DialogTitle>Edit details</DialogTitle
><DialogDescription
>Keyboard and focus preview.</DialogDescription
></DialogHeader
>
<Input aria-label="Dialog title" />
<Dialog>
<DialogTrigger as-child
><Button>Open nested dialog</Button></DialogTrigger
>
<DialogContent>
<DialogHeader
><DialogTitle>Nested details</DialogTitle
><DialogDescription
>Returns focus to the parent
dialog.</DialogDescription
></DialogHeader
>
<DialogClose as-child
><Button>Done</Button></DialogClose
>
</DialogContent>
</Dialog>
<DialogClose as-child
><Button variant="outline">Cancel</Button></DialogClose
>
</DialogContent>
</Dialog>
<Dialog>
<DialogTrigger as-child
><Button>Open long dialog</Button></DialogTrigger
>
<DialogScrollContent>
<DialogHeader
><DialogTitle>Long details</DialogTitle
><DialogDescription
>Scrollable content on small
screens.</DialogDescription
></DialogHeader
>
<p v-for="item in 30" :key="item">Detail {{ item }}</p>
<DialogClose as-child
><Button>Finish reading</Button></DialogClose
>
</DialogScrollContent>
</Dialog>
<Sheet>
<SheetTrigger as-child
><Button>Open sheet</Button></SheetTrigger
>
<SheetContent class="p-page">
<SheetTitle>Sheet details</SheetTitle
><SheetDescription
>Focus returns to the trigger.</SheetDescription
>
<Input aria-label="Sheet title" />
</SheetContent>
</Sheet>
<DropdownMenu>
<DropdownMenuTrigger as-child
><Button>Open actions</Button></DropdownMenuTrigger
>
<DropdownMenuContent>
<DropdownMenuItem disabled>Unavailable</DropdownMenuItem>
<DropdownMenuItem @select="menuSelection = 'selected'"
>Select action</DropdownMenuItem
>
</DropdownMenuContent>
</DropdownMenu>
<span role="status">{{ menuSelection }}</span>
</div>
</main>
</template>
+404
View File
@@ -0,0 +1,404 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const { chromium } = await import(
process.env.PLAYWRIGHT_MODULE ?? 'playwright'
);
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const base = 'http://127.0.0.1:4176';
const context = await browser.newContext({
viewport: { width: 1280, height: 900 },
});
const page = await context.newPage();
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const state = (value) =>
page
.locator(`[data-form-state="${value}"]`)
.first()
.waitFor({ state: 'visible' });
const setResult = (value) =>
context.addCookies([{ name: 'feedback_test_result', value, url: base }]);
const verify = async (condition) => {
assert.equal(await condition(), true);
};
const confirmNavigation = (accepted) =>
page.once('dialog', (dialog) =>
accepted ? dialog.accept() : dialog.dismiss(),
);
try {
await page.goto(`${base}/feedback`);
await page.getByLabel('Title', { exact: true }).fill('Unsaved draft');
await verify(() =>
page.evaluate(() => {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return event.defaultPrevented;
}),
);
confirmNavigation(false);
await page.getByRole('link', { name: 'Leave page' }).click();
assert.equal(new URL(page.url()).pathname, '/feedback');
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved draft',
);
await setResult('validation');
await page.getByRole('button', { name: 'Save draft' }).click();
await state('validation');
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved draft',
);
assert.equal(
await page
.getByLabel('Title', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
assert.equal(
await page.getByRole('button', { name: 'Try again' }).count(),
0,
);
let failedRequests = 0;
await page.route('**/projects/7/tickets', (route) => {
failedRequests++;
return route.abort('failed');
});
await page.getByRole('button', { name: 'Save draft' }).click();
await state('network');
await page.waitForFunction(
() =>
document.activeElement?.getAttribute('data-form-state') ===
'network',
);
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved draft',
);
assert.equal(failedRequests, 1);
await page.unroute('**/projects/7/tickets');
await setResult('server');
await page.getByRole('button', { name: 'Try again' }).click();
await state('server');
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved draft',
);
await setResult('success');
let pending;
let submissions = 0;
await page.route('**/projects/7/tickets', (route) => {
submissions++;
pending = route;
});
await page.getByRole('button', { name: 'Try again' }).click();
await state('loading');
await verify(() =>
page.evaluate(() => {
const event = new CustomEvent('inertia:exception', {
cancelable: true,
detail: { exception: new Error('Unrelated runtime error') },
});
document.dispatchEvent(event);
return !event.defaultPrevented;
}),
);
await verify(() =>
page.getByRole('button', { name: 'Save draft' }).isDisabled(),
);
await page.locator('form').evaluate((form) => form.requestSubmit());
assert.equal(submissions, 1);
await page.getByRole('button', { name: 'Cancel request' }).click();
await state('cancelled');
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved draft',
);
await pending.abort();
await page.unroute('**/projects/7/tickets');
await page.getByRole('button', { name: 'Try again' }).click();
await state('success');
await verify(() =>
page.evaluate(() => {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return !event.defaultPrevented;
}),
);
await page.getByLabel('Title', { exact: true }).fill('History draft');
confirmNavigation(true);
await page.getByRole('link', { name: 'Leave page' }).click();
await page.getByRole('heading', { name: 'Destination' }).waitFor();
await page.goBack();
await page.getByLabel('Title', { exact: true }).waitFor();
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'History draft',
);
await page.goForward();
await page.getByRole('heading', { name: 'Destination' }).waitFor();
await page.goBack();
await page.getByLabel('Title', { exact: true }).waitFor();
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'History draft',
);
// Native history cannot be cancelled; remembered state preserves the draft.
await page.getByRole('button', { name: 'Save draft' }).click();
await state('success');
for (const width of [1280, 390]) {
await page.setViewportSize({ width, height: 844 });
for (const theme of ['Light', 'Dark']) {
console.log(`Checking dialogs: ${width}px, ${theme}`);
await page
.getByRole('button', { name: theme, exact: true })
.click();
await page.waitForFunction(
(dark) =>
document.documentElement.classList.contains('dark') ===
dark,
theme === 'Dark',
);
const opener = page.getByRole('button', {
name: 'Open dialog',
exact: true,
});
await opener.focus();
await page.keyboard.press('Enter');
await page.getByRole('dialog', { name: 'Edit details' }).waitFor();
await page
.getByRole('dialog', { name: 'Edit details' })
.evaluate((element) =>
Promise.all(
[
element,
...document.querySelectorAll(
'[data-slot="dialog-overlay"]',
),
]
.flatMap((surface) => surface.getAnimations())
.map((animation) => animation.finished),
),
);
assert.equal(
await page
.getByRole('dialog', { name: 'Edit details' })
.evaluate((element) => getComputedStyle(element).opacity),
'1',
);
await page.waitForFunction(() =>
document.activeElement?.closest('[role="dialog"]'),
);
for (let step = 0; step < 7; step++) {
await page.keyboard.press('Tab');
await verify(() =>
page.evaluate(() =>
Boolean(
document.activeElement?.closest('[role="dialog"]'),
),
),
);
}
await page.keyboard.press('Shift+Tab');
await verify(() =>
page.evaluate(() =>
Boolean(document.activeElement?.closest('[role="dialog"]')),
),
);
await page.screenshot({
path: `/private/tmp/kan20-dialog-${width}-${theme.toLowerCase()}.png`,
});
await page
.getByRole('button', { name: 'Open nested dialog' })
.click();
await page
.getByRole('dialog', { name: 'Nested details' })
.waitFor();
await page.keyboard.press('Escape');
await page
.getByRole('dialog', { name: 'Nested details' })
.waitFor({ state: 'hidden' });
await page.waitForFunction(() =>
document.activeElement?.textContent.includes(
'Open nested dialog',
),
);
await page.keyboard.press('Escape');
await page
.getByRole('dialog', { name: 'Edit details' })
.waitFor({ state: 'hidden' });
await page.waitForFunction(
() => document.activeElement?.id === 'programmatic-trigger',
);
await opener.click();
await page
.getByRole('dialog', { name: 'Edit details' })
.getByRole('button', { name: 'Cancel', exact: true })
.click();
await page.waitForFunction(
() => document.activeElement?.id === 'programmatic-trigger',
);
const longOpener = page.getByRole('button', {
name: 'Open long dialog',
});
await longOpener.click();
const longDialog = page.getByRole('dialog', {
name: 'Long details',
});
await longDialog.waitFor();
const box = await longDialog.boundingBox();
assert.ok(box.x >= 0 && box.x + box.width <= width);
await longDialog
.getByRole('button', { name: 'Finish reading' })
.click();
await longDialog.waitFor({ state: 'hidden' });
await page.waitForFunction(() =>
document.activeElement?.textContent.includes(
'Open long dialog',
),
);
await page.getByRole('button', { name: 'Open sheet' }).click();
await page.getByRole('dialog', { name: 'Sheet details' }).waitFor();
await page.keyboard.press('Escape');
await page
.getByRole('dialog', { name: 'Sheet details' })
.waitFor({ state: 'hidden' });
await page.waitForFunction(() =>
document.activeElement?.textContent.includes('Open sheet'),
);
const actions = page.getByRole('button', { name: 'Open actions' });
await actions.focus();
await page.keyboard.press('Enter');
await page.getByRole('menu').waitFor();
await page
.getByRole('menu')
.evaluate((element) =>
Promise.all(
element
.getAnimations()
.map((animation) => animation.finished),
),
);
const menuItem = await page
.getByRole('menuitem', { name: 'Select action' })
.boundingBox();
assert.ok(menuItem.height >= 44);
await page.keyboard.press('ArrowDown');
await page.keyboard.press('Enter');
await page.getByRole('menu').waitFor({ state: 'hidden' });
await page.waitForFunction(() =>
document.activeElement?.textContent.includes('Open actions'),
);
assert.equal(
await page.getByText('selected', { exact: true }).count(),
1,
);
await verify(() =>
page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
);
await page.screenshot({
path: `/private/tmp/kan20-feedback-${width}-${theme.toLowerCase()}.png`,
fullPage: true,
});
}
}
// Exercise the real ticket pages as well as the isolated components.
await page.goto(`${base}/projects/7/tickets/create`);
await page.locator('#title').fill('New ticket draft');
await page.locator('#description').fill('Keep this description');
await page
.locator('#acceptance_criteria_checklist')
.fill('[ ] Keep this checklist');
await setResult('validation');
await page.locator('form button[type="submit"]').first().click();
await state('validation');
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Keep this checklist',
);
await page.route('**/projects/7/tickets', (route) => route.abort('failed'));
await page.locator('form button[type="submit"]').first().click();
await state('network');
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
assert.equal(
await page.locator('#description').inputValue(),
'Keep this description',
);
await page.unroute('**/projects/7/tickets');
confirmNavigation(true);
await page
.getByRole('link', { name: 'Back to backlog', exact: true })
.first()
.click();
await page.getByRole('heading', { name: 'Destination' }).waitFor();
await page.goBack();
await page.locator('#title').waitFor();
assert.equal(await page.locator('#title').inputValue(), 'New ticket draft');
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Keep this checklist',
);
await setResult('success');
await page.locator('form button[type="submit"]').first().click();
await page.getByRole('heading', { name: 'Destination' }).waitFor();
await page.goto(`${base}/projects/7/tickets/31`);
await page.locator('#title').fill('Updated ticket draft');
await page
.locator('#acceptance_criteria_checklist')
.fill('[ ] Edited checklist');
await setResult('server');
await page.locator('form button[type="submit"]').first().click();
await state('server');
assert.equal(
await page.locator('#title').inputValue(),
'Updated ticket draft',
);
assert.equal(
await page.locator('#acceptance_criteria_checklist').inputValue(),
'[ ] Edited checklist',
);
await setResult('success');
await page.getByRole('button', { name: 'Try again' }).click();
await state('success');
await verify(() =>
page.evaluate(() => {
const event = new Event('beforeunload', { cancelable: true });
window.dispatchEvent(event);
return !event.defaultPrevented;
}),
);
assert.deepEqual(errors, []);
console.log(
'Feedback browser behavior checks passed (desktop/mobile, light/dark, real ticket forms).',
);
} catch (error) {
console.error(
'Active element:',
await page.evaluate(() =>
document.activeElement?.outerHTML.slice(0, 500),
),
);
console.error('Browser errors:', errors);
await page.screenshot({
path: '/private/tmp/kan20-feedback-failure.png',
fullPage: true,
});
throw error;
} finally {
await browser.close();
}
+26
View File
@@ -0,0 +1,26 @@
import { createApp, h } from 'vue';
import { createInertiaApp, Link } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import FeedbackPreview from './FeedbackPreview.vue';
import TicketCreate from '../../resources/js/pages/projects/TicketCreate.vue';
import Ticket from '../../resources/js/pages/projects/Ticket.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
const Destination = {
render: () =>
h('main', [
h('h1', 'Destination'),
h(Link, { href: '/feedback' }, () => 'Feedback preview'),
]),
};
createInertiaApp({
resolve: (name) =>
({ FeedbackPreview, TicketCreate, Ticket, Destination })[name],
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+159
View File
@@ -0,0 +1,159 @@
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import tailwind from '@tailwindcss/vite';
import { createServer } from 'vite';
const server = await createServer({
configFile: false,
plugins: [vue(), tailwind()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { host: '127.0.0.1', port: 4176, strictPort: true, hmr: false },
appType: 'custom',
});
const project = {
id: 7,
name: 'Engineering',
key: 'ENG',
show_author: true,
show_story_points: true,
};
const ticketType = { id: 1, name: 'Task', is_parent: false };
const priority = { id: 1, name: 'Medium' };
const status = { id: 1, name: 'To do', board_column: null };
let ticket = {
id: 31,
ticket_key: 'ENG-31',
title: 'Existing ticket',
description: '',
ticket_type: ticketType,
ticket_type_id: 1,
ticket_priority: priority,
ticket_priority_id: 1,
ticket_status: status,
is_blocked: false,
story_points: 0,
acceptance_criteria: [],
field_values: [],
worklog_entries: [],
};
server.middlewares.use(async (request, response, next) => {
const url = new URL(request.url, 'http://localhost');
if (!/^\/(feedback|projects|dashboard)(\/|$)/.test(url.pathname)) {
return next();
}
let path = url.pathname;
const mutation = request.method !== 'GET';
const result =
request.headers.cookie?.match(/feedback_test_result=(\w+)/)?.[1] ??
'success';
if (path.endsWith('/branches/validate')) {
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify({ is_valid: true, message: 'Available' }));
return;
}
if (mutation) {
if (result === 'server') {
response.statusCode = 503;
response.setHeader('Content-Type', 'text/html');
response.end('<h1>Temporarily unavailable</h1>');
return;
}
path = new URL(request.headers.referer).pathname;
let body = '';
for await (const chunk of request) {
body += chunk;
}
if (path === '/projects/7/tickets/31' && result === 'success') {
ticket = { ...ticket, ...JSON.parse(body) };
}
if (path === '/projects/7/tickets/create' && result === 'success') {
path = '/projects/7/backlog';
}
}
const page = {
component:
path === '/feedback'
? 'FeedbackPreview'
: path.endsWith('/tickets/create')
? 'TicketCreate'
: path === '/projects/7/tickets/31'
? 'Ticket'
: 'Destination',
url: path,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
sidebarOpen: true,
auth: {
permissions: {
globalAdmin: true,
projectCreate: true,
canManageTeams: true,
},
user: {
id: 1,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: null,
},
},
navigation: {
project: {
...project,
canManage: true,
canCreateTickets: true,
},
},
errors:
mutation && result === 'validation'
? { title: 'Use a different title.' }
: {},
project,
source: 'backlog',
defaultTicketStatusId: 1,
ticketTypes: [ticketType],
ticketPriorities: [priority],
ticketStatusOptions: [status],
users: [],
parentTicketOptions: [],
canEditTicket: true,
canManageProject: true,
childTicketTree: [],
linkedTickets: [],
repositoryBranches: [],
repositoryPullRequests: [],
suggestedBranchName: 'feature/ENG-31-existing-ticket',
ticket,
ticketFieldDefinitions: [],
ticketLinkOptions: [],
ticketLinkTypes: [],
},
};
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.setHeader('X-Inertia', 'true');
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(page));
return;
}
const escapedPage = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
const html =
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
escapedPage +
'"></div><script type="module" src="/tests/Fixtures/feedback-preview.mjs"></script></body></html>';
response.setHeader('Content-Type', 'text/html');
response.end(await server.transformIndexHtml(request.url, html));
});
await server.listen();
console.log('Feedback fixture ready at http://127.0.0.1:4176/feedback');
+129
View File
@@ -0,0 +1,129 @@
import assert from 'node:assert/strict';
import { fileURLToPath } from 'node:url';
import vue from '@vitejs/plugin-vue';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const server = await createServer({
configFile: false,
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
try {
const { isFormNetworkFailure, matchesFormRequest, formRequestHeader } =
await server.ssrLoadModule('/resources/js/lib/form-feedback.ts');
const config = { headers: { [formRequestHeader]: 'submission-a' } };
for (const code of ['ERR_NETWORK', 'ECONNABORTED', 'ETIMEDOUT']) {
assert.equal(
isFormNetworkFailure(
{ isAxiosError: true, code, config },
'submission-a',
),
true,
);
}
for (const error of [
null,
new Error('Render failed'),
{ code: 'ERR_NETWORK', config },
{ isAxiosError: true, code: 'ERR_CANCELED', config },
{
isAxiosError: true,
code: 'ERR_NETWORK',
config,
response: { status: 500 },
},
{ isAxiosError: true, code: 'ERR_NETWORK', config: {} },
]) {
assert.equal(isFormNetworkFailure(error, 'submission-a'), false);
}
assert.equal(
isFormNetworkFailure(
{ isAxiosError: true, code: 'ERR_NETWORK', config },
'submission-b',
),
false,
);
assert.equal(matchesFormRequest(config, null), false);
assert.equal(matchesFormRequest(config, 'submission-a'), true);
assert.equal(matchesFormRequest(undefined, 'submission-a'), false);
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { en } = await server.ssrLoadModule('/resources/js/locales/en.ts');
const { default: FeedbackState } = await server.ssrLoadModule(
'/resources/js/components/FeedbackState.vue',
);
const { default: FormFeedback } = await server.ssrLoadModule(
'/resources/js/components/FormFeedback.vue',
);
const { default: InputError } = await server.ssrLoadModule(
'/resources/js/components/InputError.vue',
);
const localization = createLocalization(
{ locale: 'de' },
{
de: {
...en,
'common.retry': 'Erneut versuchen',
'feedback.empty': 'Noch keine Einträge',
},
},
);
const render = (component, props) =>
renderToString(
createSSRApp({ render: () => h(component, props) }).use(
localization,
),
);
assert.match(
await render(FeedbackState, { state: 'empty' }),
/Noch keine Einträge/,
);
const loading = await render(FormFeedback, { state: 'loading' });
assert.match(loading, /role="status"/);
assert.match(loading, /aria-busy="true"/);
assert.match(loading, /motion-reduce:animate-none/);
assert.doesNotMatch(loading, /<button/);
const validation = await render(FormFeedback, { state: 'validation' });
assert.match(validation, /role="alert"/);
assert.match(validation, /Check your entries/);
assert.doesNotMatch(validation, /<button/);
for (const state of ['network', 'server', 'cancelled']) {
const html = await render(FormFeedback, { state });
assert.match(html, /Erneut versuchen/);
assert.match(html, /type="button"/);
assert.match(html, /role="alert"/);
}
assert.match(
await render(FormFeedback, { state: 'network' }),
/Connection interrupted/,
);
assert.match(
await render(FormFeedback, { state: 'server' }),
/unexpected response/,
);
assert.match(
await render(FormFeedback, { state: 'success' }),
/Changes saved/,
);
assert.doesNotMatch(await render(FormFeedback, { state: 'idle' }), /role=/);
const fieldError = await render(InputError, {
message: '<script>alert(1)</script>',
});
assert.match(fieldError, /role="alert"/);
assert.match(fieldError, /text-destructive/);
assert.doesNotMatch(fieldError, /<script>/);
console.log('Form feedback behavior checks passed.');
} finally {
await server.close();
}