feat(KAN-20): unify dialogs feedback and protect ticket drafts
This commit is contained in:
@@ -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([
|
||||
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
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');
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user