feat(KAN-30): rebuild responsive ticket detail view
This commit is contained in:
@@ -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')
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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('&', '&')
|
||||
.replaceAll('"', '"')
|
||||
.replaceAll('<', '<');
|
||||
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user