feat(KAN-30): rebuild responsive ticket detail view

This commit is contained in:
2026-09-12 06:22:31 +00:00
parent 11b06d9b8c
commit 5cc1840592
15 changed files with 2912 additions and 1712 deletions
+1
View File
@@ -357,6 +357,7 @@ try {
await page.getByRole('heading', { name: 'Destination' }).waitFor();
await page.goto(`${base}/projects/7/tickets/31`);
await page.locator('#ticket-edit > summary').click();
await page.locator('#title').fill('Updated ticket draft');
await page
.locator('#acceptance_criteria_checklist')
+2
View File
@@ -39,6 +39,8 @@ let ticket = {
acceptance_criteria: [],
field_values: [],
worklog_entries: [],
created_at: '2026-09-10T12:00:00Z',
updated_at: '2026-09-11T12:00:00Z',
};
server.middlewares.use(async (request, response, next) => {
+281
View File
@@ -0,0 +1,281 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
const { chromium } = playwright.default ?? playwright;
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const base = 'http://127.0.0.1:4185/projects/7/tickets/42';
const content = () => page.locator('#main-content');
const visit = async (query = '') => {
await page.goto(base + query);
await page.getByRole('heading', { level: 1 }).waitFor();
};
const edit = () => page.locator('#ticket-edit > summary');
const tabTo = async (locator) => {
await content().focus();
for (let step = 0; step < 45; step++) {
await page.keyboard.press('Tab');
if (
await locator.evaluate(
(element) => element === document.activeElement,
)
) {
assert.equal(
await locator.evaluate((element) =>
element.matches(':focus-visible'),
),
true,
);
return;
}
}
assert.fail('Ticket control is unreachable by keyboard');
};
try {
for (const width of [1440, 768, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({
colorScheme: theme,
reducedMotion: 'reduce',
});
for (const query of ['', '?empty', '?long']) {
await visit(query);
assert.equal(
await page
.locator('html')
.evaluate((el) => el.classList.contains('dark')),
theme === 'dark',
);
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
window.innerWidth,
),
true,
`Page overflow at ${width}/${theme}/${query}`,
);
const description = await page
.locator('#ticket-description')
.boundingBox();
const metadata = await page
.locator('#ticket-metadata')
.boundingBox();
if (width >= 1280) {
assert.ok(
metadata.x > description.x + description.width,
'Desktop metadata must be beside the content.',
);
} else {
assert.ok(
metadata.y > description.y + description.height,
'Mobile metadata must be stacked.',
);
}
await edit().click();
assert.equal(
await page.getByLabel('Title', { exact: true }).isVisible(),
true,
);
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
window.innerWidth,
),
true,
`Editor overflow at ${width}/${theme}/${query}`,
);
await edit().click();
}
await visit();
await page.screenshot({
path: `/private/tmp/kan30-detail-${width}-${theme}.png`,
fullPage: true,
});
}
}
await page.setViewportSize({ width: 390, height: 1000 });
await visit();
await tabTo(edit());
await page.keyboard.press('Enter');
await page.getByLabel('Title', { exact: true }).fill('Unsaved title draft');
await page
.getByRole('checkbox', { name: 'Cache requests safely', exact: true })
.check();
await page.waitForFunction(
() => document.querySelector('#acceptance-criteria-11').checked,
);
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved title draft',
);
await page
.getByLabel('Acceptance criteria checklist', { exact: true })
.fill('[ ] A draft checklist item');
assert.equal(
await page
.getByRole('checkbox', {
name: 'Cache requests safely',
exact: true,
})
.isDisabled(),
true,
);
await edit().click();
await edit().click();
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved title draft',
);
page.once('dialog', (dialog) => dialog.dismiss());
await content()
.getByRole('link', { name: 'Open board', exact: true })
.click();
assert.match(page.url(), /\/tickets\/42/);
page.once('dialog', (dialog) => dialog.accept());
await content()
.getByRole('link', { name: 'Open board', exact: true })
.click();
await page.waitForURL('**/projects/7/board');
await page.goBack();
await edit().waitFor();
if (!(await page.locator('#ticket-edit').getAttribute('open'))) {
await edit().click();
}
if (!(await page.getByLabel('Title', { exact: true }).isVisible())) {
await edit().click();
}
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Unsaved title draft',
);
assert.equal(
await page
.getByLabel('Acceptance criteria checklist', { exact: true })
.inputValue(),
'[ ] A draft checklist item',
);
await visit('?reject');
await edit().click();
await page.getByLabel('Title', { exact: true }).fill('Rejected draft');
await page
.getByRole('button', { name: 'Save changes', exact: true })
.click();
await page
.getByText('This title is not allowed.', { exact: true })
.waitFor();
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Rejected draft',
);
assert.equal(
await page
.getByLabel('Title', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
await visit();
await edit().click();
await page
.getByLabel('Title', { exact: true })
.fill('Saved updated ticket title');
await page
.getByRole('button', { name: 'Save changes', exact: true })
.click();
await page
.getByRole('heading', {
name: 'Saved updated ticket title',
exact: true,
})
.waitFor();
await page.getByText('Ticket updated.', { exact: true }).waitFor();
await visit('?rejectToggle');
await page
.getByRole('checkbox', { name: 'Cache requests safely', exact: true })
.check();
await page
.getByRole('alert')
.filter({ hasText: 'Checklist status could not be saved' })
.waitFor();
assert.equal(
await page
.getByRole('checkbox', {
name: 'Cache requests safely',
exact: true,
})
.isChecked(),
false,
);
await visit();
const createBranch = page.getByRole('button', {
name: 'Create branch',
exact: true,
});
await tabTo(createBranch);
await page.keyboard.press('Enter');
const dialog = page.getByRole('dialog');
await dialog.waitFor();
await page
.getByText('Branch name is available.', { exact: true })
.waitFor();
await page.keyboard.press('Escape');
await dialog.waitFor({ state: 'hidden' });
assert.equal(
await createBranch.evaluate((el) => el === document.activeElement),
true,
);
await createBranch.click();
await page
.getByText('Branch name is available.', { exact: true })
.waitFor();
await page.getByRole('button', { name: 'Create now', exact: true }).click();
await dialog.waitFor({ state: 'hidden' });
const linkSaved = page.waitForResponse(
(response) =>
response.url().includes('/tickets/42/links') &&
response.request().method() === 'POST',
);
await page.getByRole('button', { name: 'Save link', exact: true }).click();
assert.equal((await linkSaved).status(), 303);
await page.getByRole('button', { name: 'Remove', exact: true }).waitFor();
const linkRemoved = page.waitForResponse(
(response) =>
response.url().includes('/tickets/42/links/3?_method=DELETE') &&
response.request().method() === 'POST',
);
await page.getByRole('button', { name: 'Remove', exact: true }).click();
assert.equal((await linkRemoved).status(), 303);
await visit('?readonly&hideAuthor&hidePoints');
assert.equal(await content().locator('form').count(), 0);
assert.equal(
await content().getByText('Sam Chen', { exact: true }).count(),
0,
);
assert.equal(
await content()
.getByText('Estimated story points', { exact: true })
.count(),
0,
);
await tabTo(content().getByRole('link', { name: 'ENG-41', exact: true }));
await page.keyboard.press('Enter');
await page.waitForURL('**/tickets/41');
await page
.getByRole('heading', { name: 'Related infrastructure', exact: true })
.waitFor();
assert.equal(
await page.getByLabel('Title', { exact: true }).inputValue(),
'Related infrastructure',
);
assert.deepEqual(errors, []);
console.log(
'Ticket detail browser checks passed: desktop/mobile, both themes, keyboard, drafts, saves, checklist rollback, links, Git dialog and direct navigation.',
);
} finally {
await browser.close();
}
+19
View File
@@ -0,0 +1,19 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Ticket from '../../resources/js/pages/projects/Ticket.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) =>
name === 'Ticket'
? Ticket
: { render: () => h('h1', 'Destination reached') },
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+480
View File
@@ -0,0 +1,480 @@
import assert from 'node:assert/strict';
import process from 'node:process';
import { fileURLToPath } from 'node:url';
import tailwind from '@tailwindcss/vite';
import vue from '@vitejs/plugin-vue';
import { createInertiaApp } from '@inertiajs/vue3';
import { createServer } from 'vite';
import { createSSRApp, h } from 'vue';
import { renderToString } from 'vue/server-renderer';
const serve = process.argv.includes('--serve');
const server = await createServer({
configFile: false,
cacheDir: 'node_modules/.vite/ticket-detail-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4185, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const user = {
id: 1,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: null,
};
const project = {
id: 7,
key: 'ENG',
name: 'Engineering',
show_author: true,
show_story_points: true,
};
const repository = {
id: 9,
provider: 'gitea',
provider_label: 'Gitea',
base_url: 'https://git.example.test',
repository_full_name: 'Engineering/platform',
has_personal_access: true,
};
const parent = {
id: 40,
ticket_key: 'ENG-40',
title: 'Platform delivery',
project_id: 7,
};
const linked = {
id: 41,
ticket_key: 'ENG-41',
title: 'Related infrastructure',
project_id: 7,
};
const ticket = {
id: 42,
project_id: 7,
sequence_number: 42,
ticket_key: 'ENG-42',
title: 'Implement a resilient caching layer for the project API',
description:
'Keep every existing ticket action available.\nLongUnbrokenDescriptionThatMustWrapWithoutCausingHorizontalScrollingOnTheSmallestSupportedMobileViewport.',
acceptance_criteria: [
{ id: 10, text: 'Cache requests safely', checked: false, indent: 0 },
{
id: 11,
text: 'Invalidate when records change',
checked: false,
indent: 1,
},
],
assignee_user_id: 1,
assignee: user,
author_user_id: 2,
author: { ...user, id: 2, name: 'Sam Chen' },
parent_ticket_id: 40,
parent_ticket: parent,
is_blocked: true,
blocked_comment: 'Waiting for infrastructure approval.',
story_points: 0,
ticket_type_id: 1,
ticket_type: { id: 1, name: 'Custom development task', is_parent: false },
ticket_priority_id: 2,
ticket_priority: { id: 2, name: 'Urgent project priority' },
ticket_status_id: 3,
ticket_status: {
id: 3,
name: 'In progress',
board_column: { id: 1, name: 'Delivery' },
},
field_values: [
{
id: 1,
ticket_field_definition_id: 1,
value_number: 0,
value_user_id: null,
},
{
id: 2,
ticket_field_definition_id: 2,
value_number: null,
value_user_id: 1,
user_value: user,
},
],
worklog_entries: [
{
id: 1,
actor: user,
entry_type: 'created',
summary: 'Ticket created in the project backlog.',
created_at: '2026-09-11T12:00:00Z',
},
],
created_at: '2026-09-10T12:00:00Z',
updated_at: '2026-09-11T12:00:00Z',
};
const fieldDefinitions = [
{ id: 1, label: 'Impact score', field_type: 'number' },
{ id: 2, label: 'Reviewer', field_type: 'user' },
];
function makePage(url) {
const parsed = new URL(url, 'http://localhost');
const params = parsed.searchParams;
const detail = parsed.pathname.startsWith('/projects/7/tickets/');
const data = structuredClone(ticket);
if (parsed.pathname.endsWith('/41')) {
Object.assign(data, linked, {
parent_ticket_id: null,
parent_ticket: null,
title: 'Related infrastructure',
});
}
if (params.has('saved')) {
data.title = params.get('saved');
}
if (params.has('long')) {
data.title = 'LongUnbrokenTicketTitle'.repeat(10);
}
if (params.has('parent')) {
data.ticket_type.is_parent = true;
}
if (params.has('empty')) {
Object.assign(data, {
description: null,
acceptance_criteria: [],
assignee: null,
assignee_user_id: null,
author: null,
author_user_id: null,
parent_ticket: null,
parent_ticket_id: null,
is_blocked: false,
blocked_comment: null,
story_points: null,
ticket_status: null,
field_values: [],
worklog_entries: [],
});
}
const noAccess = params.has('noAccess');
const repo = { ...repository, has_personal_access: !noAccess };
const repositoryError = params.has('gitError')
? 'Repository temporarily unavailable.'
: null;
return {
component: detail ? 'Ticket' : 'Destination',
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: params.has('rejected')
? { title: 'This title is not allowed.' }
: {},
sidebarOpen: true,
auth: {
user,
permissions: {
globalAdmin: false,
projectCreate: false,
canManageTeams: false,
},
},
navigation: {
project: {
...project,
canManage: false,
canCreateTickets: !params.has('readonly'),
},
},
project: {
...project,
show_author: !params.has('hideAuthor'),
show_story_points: !params.has('hidePoints'),
},
ticket: data,
canEditTicket: !params.has('readonly'),
canManageProject: params.has('admin'),
status: params.has('saved') ? 'Ticket updated.' : null,
users: [user, ticket.author],
ticketTypes: [data.ticket_type],
ticketPriorities: [data.ticket_priority],
ticketFieldDefinitions: params.has('empty') ? [] : fieldDefinitions,
parentTicketOptions: [parent],
childTicketTree: params.has('parent')
? [
{
...linked,
ticket_type: { is_parent: false },
children: [],
children_count: 0,
},
]
: [],
linkedTickets: params.has('empty')
? []
: [
{
id: 3,
ticket_link_type: { id: 1, label: 'Blocks' },
linked_ticket: linked,
},
],
ticketLinkOptions: [linked],
ticketLinkTypes: [
{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' },
],
repositoryBranches: params.has('empty')
? []
: [
{
repository_connection: repo,
branches: [{ name: 'feature/ENG-42-cache' }],
error: repositoryError,
},
],
repositoryPullRequests: params.has('empty')
? []
: [
{
repository_connection: repo,
pull_requests: [
{
number: 12,
status: 'open',
title: 'Cache implementation',
source_branch: 'feature/ENG-42-cache',
html_url:
'https://git.example.test/Engineering/platform/pulls/12',
},
],
error: repositoryError,
},
],
suggestedBranchName: `${data.ticket_key.toLowerCase()}-cache`,
},
};
}
if (serve) {
server.middlewares.use(async (request, response) => {
if (request.url.includes('/branches/validate')) {
response.setHeader('Content-Type', 'application/json');
response.end(
JSON.stringify({
is_valid: true,
message: 'Branch name is available.',
}),
);
return;
}
if (request.method !== 'GET') {
let body = '';
for await (const chunk of request) {
body += chunk;
}
const data = JSON.parse(body);
const previous = new URL(request.headers.referer);
const params = previous.searchParams;
if (!request.headers['x-inertia']) {
assert.equal(request.method, 'PATCH');
assert.equal(request.url, '/projects/7/tickets/42');
assert.equal(
data.title,
ticket.title,
'Checklist toggle must not save the title draft.',
);
assert.equal(
data.acceptance_criteria[0].checked,
data.acceptance_criteria[1].checked,
'Toggling a parent must update its descendants.',
);
response.writeHead(params.has('rejectToggle') ? 422 : 200, {
'Content-Type': 'application/json',
});
response.end('{}');
return;
}
if (request.url.includes('/branches')) {
assert.equal(data.branch_name, 'eng-42-cache');
assert.equal(Number(data.repository_connection_id), 9);
} else if (request.url.includes('/links')) {
assert.ok(
request.url.endsWith('/links/3?_method=DELETE') ||
Number(data.linked_ticket_id) === 41,
);
} else {
assert.ok(
'field_values' in data &&
'acceptance_criteria' in data &&
'parent_ticket_id' in data,
);
if (params.has('reject')) {
params.set('rejected', '1');
} else {
params.set('saved', data.title);
}
}
response.writeHead(303, {
Location: `/projects/7/tickets/42?${params}`,
});
response.end();
return;
}
const page = makePage(request.url);
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.writeHead(200, {
'X-Inertia': 'true',
'Content-Type': 'application/json',
});
response.end(JSON.stringify(page));
return;
}
const escaped = JSON.stringify(page)
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
response.setHeader('Content-Type', 'text/html');
response.end(
await server.transformIndexHtml(
request.url,
`<!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="${escaped}"></div><script type="module" src="/tests/Fixtures/ticket-detail-preview.mjs"></script></body></html>`,
),
);
});
await server.listen();
console.log(
'Ticket detail fixture ready at http://127.0.0.1:4185/projects/7/tickets/42',
);
} else {
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { default: Ticket } = await server.ssrLoadModule(
'/resources/js/pages/projects/Ticket.vue',
);
const render = async (query = '') => {
const result = await createInertiaApp({
page: makePage(`/projects/7/tickets/42${query}`),
resolve: () => Ticket,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance('light')),
});
return result.body.split('id="main-content"')[1];
};
const html = await render();
for (const text of [
ticket.title,
'Keep every existing ticket action available.',
'Cache requests safely',
'Invalidate when records change',
'Urgent project priority',
'Delivery / In progress',
'Alex Morgan',
'Sam Chen',
'Impact score',
'Reviewer',
'Waiting for infrastructure approval.',
'feature/ENG-42-cache',
'Cache implementation',
'Ticket created in the project backlog.',
]) {
assert.ok(
html.includes(text),
`Missing ticket information: ${text}`,
);
}
for (const section of [
'description',
'checklist',
'metadata',
'relationships',
'history',
'git',
]) {
assert.ok(html.includes(`href="#ticket-${section}"`));
assert.ok(html.includes(`id="ticket-${section}"`));
}
for (const route of [
'/projects/7',
'/projects/7/backlog',
'/projects/7/board',
'/projects/7/tickets/40',
'/projects/7/tickets/41',
]) {
assert.ok(html.includes(`href="${route}"`));
}
assert.match(html, /<details[^>]+id="ticket-edit"/);
for (const field of [
'title',
'ticket_type_id',
'ticket_priority_id',
'assignee_user_id',
'author_user_id',
'story_points',
'parent_ticket_id',
'description',
'acceptance_criteria_checklist',
'is_blocked',
'blocked_comment',
'field-1',
'field-2',
]) {
assert.ok(
html.includes(`id="${field}"`),
`Missing editor: ${field}`,
);
}
assert.match(html, /Create branch/);
assert.match(html, /Save link/);
assert.match(html, /Remove/);
const readOnly = await render('?readonly');
assert.doesNotMatch(
readOnly,
/<form|<details|Create branch|Save changes|Save link/,
);
assert.match(readOnly, /type="checkbox" disabled/);
assert.match(readOnly, /Read only/);
const hidden = await render('?readonly&hideAuthor&hidePoints');
assert.doesNotMatch(hidden, /Sam Chen|Estimated story points/);
assert.match(await render('?readonly&hideAuthor&admin'), /Sam Chen/);
const empty = await render('?empty');
for (const text of [
'No description has been added yet.',
'No acceptance criteria have been added yet.',
'No status',
'Unassigned',
'No author',
'Not set',
'No matching branches',
'No matching pull requests',
'No worklog entries exist',
]) {
assert.ok(empty.includes(text), `Missing empty state: ${text}`);
}
assert.match(await render('?parent'), /Child tickets/);
assert.match(await render('?noAccess'), /Open Git connections/);
assert.match(
await render('?gitError'),
/Repository temporarily unavailable/,
);
console.log('Ticket detail behavior checks passed.');
} finally {
await server.close();
}
}