feat(KAN-33): redesign ticket relationships and hierarchy
This commit is contained in:
@@ -251,15 +251,20 @@ try {
|
||||
response.url().includes('/tickets/42/links') &&
|
||||
response.request().method() === 'POST',
|
||||
);
|
||||
await page.getByLabel('Linked ticket', { exact: true }).selectOption('41');
|
||||
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();
|
||||
await page
|
||||
.getByRole('button', { name: 'Remove link to ENG-41', exact: true })
|
||||
.waitFor();
|
||||
const linkRemoved = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/tickets/42/links/3?_method=DELETE') &&
|
||||
response.request().method() === 'POST',
|
||||
response.url().includes('/tickets/42/links/3') &&
|
||||
response.request().method() === 'DELETE',
|
||||
);
|
||||
await page.getByRole('button', { name: 'Remove', exact: true }).click();
|
||||
await page
|
||||
.getByRole('button', { name: 'Remove link to ENG-41', exact: true })
|
||||
.click();
|
||||
assert.equal((await linkRemoved).status(), 303);
|
||||
await visit('?readonly&hideAuthor&hidePoints');
|
||||
assert.equal(await content().locator('form').count(), 0);
|
||||
@@ -273,7 +278,12 @@ try {
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
await tabTo(content().getByRole('link', { name: 'ENG-41', exact: true }));
|
||||
await tabTo(
|
||||
content().getByRole('link', {
|
||||
name: 'ENG-41 Related infrastructure',
|
||||
exact: true,
|
||||
}),
|
||||
);
|
||||
await page.keyboard.press('Enter');
|
||||
await page.waitForURL('**/tickets/41');
|
||||
await page
|
||||
|
||||
@@ -182,30 +182,35 @@ function makePage(url) {
|
||||
encryptHistory: false,
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors: params.has('rejected')
|
||||
errors: params.has('linkRejected')
|
||||
? {
|
||||
title: 'This title is not allowed.',
|
||||
...(params.has('allErrors')
|
||||
? {
|
||||
description: 'Invalid description.',
|
||||
ticket_type_id: 'Invalid type.',
|
||||
ticket_priority_id: 'Invalid priority.',
|
||||
ticket_status_id: 'Invalid status.',
|
||||
assignee_user_id: 'Invalid assignee.',
|
||||
author_user_id: 'Invalid author.',
|
||||
parent_ticket_id: 'Invalid parent.',
|
||||
story_points: 'Invalid points.',
|
||||
'acceptance_criteria.0.text':
|
||||
'Invalid checklist.',
|
||||
blocked_comment: 'Invalid blocker.',
|
||||
'field_values.0.value_number':
|
||||
'Invalid number.',
|
||||
'field_values.1.value_user_id':
|
||||
'Invalid reviewer.',
|
||||
}
|
||||
: {}),
|
||||
ticket_link_type_id: 'Invalid relationship type.',
|
||||
linked_ticket_id: 'Choose another ticket.',
|
||||
}
|
||||
: {},
|
||||
: params.has('rejected')
|
||||
? {
|
||||
title: 'This title is not allowed.',
|
||||
...(params.has('allErrors')
|
||||
? {
|
||||
description: 'Invalid description.',
|
||||
ticket_type_id: 'Invalid type.',
|
||||
ticket_priority_id: 'Invalid priority.',
|
||||
ticket_status_id: 'Invalid status.',
|
||||
assignee_user_id: 'Invalid assignee.',
|
||||
author_user_id: 'Invalid author.',
|
||||
parent_ticket_id: 'Invalid parent.',
|
||||
story_points: 'Invalid points.',
|
||||
'acceptance_criteria.0.text':
|
||||
'Invalid checklist.',
|
||||
blocked_comment: 'Invalid blocker.',
|
||||
'field_values.0.value_number':
|
||||
'Invalid number.',
|
||||
'field_values.1.value_user_id':
|
||||
'Invalid reviewer.',
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: {},
|
||||
sidebarOpen: true,
|
||||
auth: {
|
||||
user,
|
||||
@@ -250,25 +255,45 @@ function makePage(url) {
|
||||
? [
|
||||
{
|
||||
...linked,
|
||||
ticket_type: { is_parent: false },
|
||||
children: [],
|
||||
ticket_type_name: 'Epic',
|
||||
children: [
|
||||
{
|
||||
id: 43,
|
||||
ticket_key: 'ENG-43',
|
||||
title: 'Nested child with a long title that must wrap on mobile',
|
||||
ticket_type_name: 'Task',
|
||||
children: [],
|
||||
},
|
||||
],
|
||||
children_count: 0,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
linkedTickets: params.has('empty')
|
||||
linkedTickets:
|
||||
params.has('empty') || params.has('noLinks')
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: 3,
|
||||
source_ticket_id: params.has('incoming')
|
||||
? 41
|
||||
: 42,
|
||||
target_ticket_id: params.has('incoming')
|
||||
? 42
|
||||
: 41,
|
||||
ticket_link_type: {
|
||||
id: 1,
|
||||
label: params.has('incoming')
|
||||
? 'Blocked by'
|
||||
: 'Blocks',
|
||||
},
|
||||
linked_ticket: linked,
|
||||
},
|
||||
],
|
||||
ticketLinkOptions: params.has('noTargets') ? [] : [linked],
|
||||
ticketLinkTypes: params.has('noTypes')
|
||||
? []
|
||||
: [
|
||||
{
|
||||
id: 3,
|
||||
ticket_link_type: { id: 1, label: 'Blocks' },
|
||||
linked_ticket: linked,
|
||||
},
|
||||
],
|
||||
ticketLinkOptions: [linked],
|
||||
ticketLinkTypes: [
|
||||
{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' },
|
||||
],
|
||||
: [{ id: 1, label: 'Blocks', counterpart_label: 'Blocked by' }],
|
||||
repositoryBranches: params.has('empty')
|
||||
? []
|
||||
: [
|
||||
@@ -317,7 +342,7 @@ if (serve) {
|
||||
for await (const chunk of request) {
|
||||
body += chunk;
|
||||
}
|
||||
const data = JSON.parse(body);
|
||||
const data = body ? JSON.parse(body) : {};
|
||||
const previous = new URL(request.headers.referer);
|
||||
const params = previous.searchParams;
|
||||
if (params.has('serverError')) {
|
||||
@@ -350,8 +375,17 @@ if (serve) {
|
||||
} else if (request.url.includes('/links')) {
|
||||
assert.ok(
|
||||
request.url.endsWith('/links/3?_method=DELETE') ||
|
||||
request.method === 'DELETE' ||
|
||||
Number(data.linked_ticket_id) === 41,
|
||||
);
|
||||
if (params.has('rejectLink')) {
|
||||
params.set('linkRejected', '1');
|
||||
} else if (request.method === 'DELETE') {
|
||||
params.set('noLinks', '1');
|
||||
} else {
|
||||
params.delete('noLinks');
|
||||
params.delete('linkRejected');
|
||||
}
|
||||
} else {
|
||||
assert.ok(
|
||||
(request.method === 'POST' || 'field_values' in data) &&
|
||||
@@ -492,6 +526,15 @@ if (serve) {
|
||||
assert.match(html, /Add criterion/);
|
||||
assert.match(html, /Create branch/);
|
||||
assert.match(html, /Save link/);
|
||||
assert.match(html, /Outgoing/);
|
||||
const incoming = await render('?incoming');
|
||||
assert.match(incoming, /Incoming/);
|
||||
assert.match(incoming, /Blocked by/);
|
||||
assert.match(await render('?noTypes'), /No link types are configured/);
|
||||
assert.match(
|
||||
await render('?noTargets'),
|
||||
/No other tickets are available/,
|
||||
);
|
||||
assert.match(html, /Remove/);
|
||||
const readOnly = await render('?readonly');
|
||||
assert.doesNotMatch(
|
||||
@@ -517,7 +560,11 @@ if (serve) {
|
||||
]) {
|
||||
assert.ok(empty.includes(text), `Missing empty state: ${text}`);
|
||||
}
|
||||
assert.match(await render('?parent'), /Child tickets/);
|
||||
const hierarchy = await render('?parent');
|
||||
assert.match(hierarchy, /Child tickets/);
|
||||
assert.match(hierarchy, /Platform delivery/);
|
||||
assert.match(hierarchy, /Nested child/);
|
||||
assert.match(hierarchy, /Change parent/);
|
||||
assert.match(await render('?noAccess'), /Open Git connections/);
|
||||
assert.match(
|
||||
await render('?gitError'),
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
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 page = await browser.newPage();
|
||||
page.setDefaultTimeout(10000);
|
||||
const base = 'http://127.0.0.1:4185/projects/7/tickets/42';
|
||||
const section = () => page.locator('#ticket-relationships');
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
async function visit(query = '') {
|
||||
await page.goto(base + query);
|
||||
await section().waitFor();
|
||||
}
|
||||
const control = (label) => section().getByLabel(label, { exact: true });
|
||||
const save = () =>
|
||||
section().getByRole('button', { name: 'Save link', exact: true });
|
||||
const remove = () =>
|
||||
section().getByRole('button', {
|
||||
name: 'Remove link to ENG-41',
|
||||
exact: true,
|
||||
});
|
||||
try {
|
||||
for (const width of [1440, 390, 320]) {
|
||||
await page.setViewportSize({ width, height: 1000 });
|
||||
for (const theme of ['light', 'dark']) {
|
||||
await page.emulateMedia({
|
||||
colorScheme: theme,
|
||||
reducedMotion: 'reduce',
|
||||
});
|
||||
await visit('?parent&incoming');
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('html')
|
||||
.evaluate((el) => el.classList.contains('dark')),
|
||||
theme === 'dark',
|
||||
);
|
||||
for (const text of [
|
||||
'Platform delivery',
|
||||
'Child tickets',
|
||||
'Nested child with a long title that must wrap on mobile',
|
||||
'Incoming',
|
||||
'Blocked by',
|
||||
]) {
|
||||
assert.equal(
|
||||
await section()
|
||||
.getByText(text, { exact: true })
|
||||
.isVisible(),
|
||||
true,
|
||||
text,
|
||||
);
|
||||
}
|
||||
const collapse = section().getByRole('button', {
|
||||
name: 'Collapse children of ENG-41',
|
||||
});
|
||||
await collapse.focus();
|
||||
await page.keyboard.press('Enter');
|
||||
assert.equal(
|
||||
await section()
|
||||
.getByText(
|
||||
'Nested child with a long title that must wrap on mobile',
|
||||
{ exact: true },
|
||||
)
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
await page.keyboard.press('Space');
|
||||
assert.equal(await collapse.getAttribute('aria-expanded'), 'true');
|
||||
await control('Link type').focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await control('Linked ticket').evaluate(
|
||||
(el) =>
|
||||
el === document.activeElement &&
|
||||
el.matches(':focus-visible'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
await control('Linked ticket').selectOption('41');
|
||||
await section()
|
||||
.locator('form')
|
||||
.last()
|
||||
.getByText('ENG-42', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() => document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
true,
|
||||
`Overflow ${width}/${theme}`,
|
||||
);
|
||||
await section().screenshot({
|
||||
path: `/private/tmp/kan33-relationships-${width}-${theme}.png`,
|
||||
});
|
||||
await section()
|
||||
.getByRole('button', { name: 'Change parent', exact: true })
|
||||
.click();
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('#parent_ticket_id')
|
||||
.evaluate((el) => el === document.activeElement),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await page.locator('#ticket-edit').evaluate((el) => el.open),
|
||||
true,
|
||||
);
|
||||
}
|
||||
}
|
||||
await visit('?noLinks');
|
||||
assert.equal(await save().isDisabled(), true);
|
||||
await control('Linked ticket').selectOption('41');
|
||||
let request = page.waitForRequest(
|
||||
(r) => r.method() === 'POST' && r.url().endsWith('/links'),
|
||||
);
|
||||
await save().click();
|
||||
assert.deepEqual((await request).postDataJSON(), {
|
||||
ticket_link_type_id: 1,
|
||||
linked_ticket_id: 41,
|
||||
});
|
||||
await section().getByText('Outgoing', { exact: true }).waitFor();
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('#linked_ticket_id')?.value === '',
|
||||
);
|
||||
await page.waitForFunction(() =>
|
||||
document.activeElement?.textContent.includes('Linked tickets'),
|
||||
);
|
||||
request = page.waitForRequest(
|
||||
(r) => r.method() === 'DELETE' && r.url().endsWith('/links/3'),
|
||||
);
|
||||
await remove().click();
|
||||
await request;
|
||||
await section()
|
||||
.getByText('No ticket links are configured for this ticket yet.', {
|
||||
exact: true,
|
||||
})
|
||||
.waitFor();
|
||||
await page.waitForFunction(() =>
|
||||
document.activeElement?.textContent.includes('Linked tickets'),
|
||||
);
|
||||
|
||||
await visit('?rejectLink');
|
||||
await control('Linked ticket').selectOption('41');
|
||||
await save().click();
|
||||
await section()
|
||||
.getByText('Choose another ticket.', { exact: true })
|
||||
.waitFor();
|
||||
assert.equal(await control('Linked ticket').inputValue(), '41');
|
||||
for (const label of ['Link type', 'Linked ticket']) {
|
||||
assert.equal(await control(label).getAttribute('aria-invalid'), 'true');
|
||||
}
|
||||
await page.route('**/tickets/42/links', (route) => route.abort('failed'));
|
||||
await save().click();
|
||||
await section().locator('[data-form-state="network"]').waitFor();
|
||||
assert.equal(await control('Linked ticket').inputValue(), '41');
|
||||
await page.unrouteAll();
|
||||
|
||||
await visit('?serverError');
|
||||
await remove().click();
|
||||
await section().locator('[data-form-state="server"]').waitFor();
|
||||
assert.equal(await section().locator('[data-ticket-link]').count(), 1);
|
||||
let pending;
|
||||
await page.route('**/tickets/42/links/3', (route) => {
|
||||
pending = route;
|
||||
});
|
||||
await section()
|
||||
.getByRole('button', { name: 'Try again', exact: true })
|
||||
.click();
|
||||
await section().locator('[data-form-state="loading"]').waitFor();
|
||||
assert.equal(await remove().isDisabled(), true);
|
||||
await pending.abort();
|
||||
await section().locator('[data-form-state="network"]').waitFor();
|
||||
await page.unrouteAll();
|
||||
|
||||
await visit();
|
||||
await section()
|
||||
.getByRole('button', { name: 'Change parent', exact: true })
|
||||
.click();
|
||||
await page.getByLabel('Parent ticket', { exact: true }).selectOption('');
|
||||
request = page.waitForRequest(
|
||||
(r) => r.method() === 'PATCH' && r.url().endsWith('/tickets/42'),
|
||||
);
|
||||
await page
|
||||
.getByRole('button', { name: 'Save changes', exact: true })
|
||||
.click();
|
||||
assert.equal((await request).postDataJSON().parent_ticket_id, '');
|
||||
await page.getByText('Ticket updated.', { exact: true }).waitFor();
|
||||
|
||||
await visit('?readonly&parent&incoming');
|
||||
assert.equal(await section().locator('form').count(), 0);
|
||||
assert.equal(
|
||||
await section()
|
||||
.getByRole('button', { name: 'Change parent', exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assert.equal(
|
||||
await section().getByText('Blocked by', { exact: true }).isVisible(),
|
||||
true,
|
||||
);
|
||||
for (const [query, message] of [
|
||||
['?noTypes', 'No link types are configured for this project.'],
|
||||
[
|
||||
'?noTargets',
|
||||
'No other tickets are available to link in this project.',
|
||||
],
|
||||
]) {
|
||||
await visit(query);
|
||||
assert.equal(
|
||||
await section().getByText(message, { exact: true }).isVisible(),
|
||||
true,
|
||||
);
|
||||
assert.equal(await save().count(), 0);
|
||||
}
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Ticket relationships browser checks passed: hierarchy, direction, add/remove, validation, retries, permissions, keyboard, mobile and themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user