feat: KAN-30
This commit is contained in:
@@ -1511,6 +1511,74 @@ class ProjectTicketControllerTest extends TestCase
|
||||
]);
|
||||
}
|
||||
|
||||
public function test_read_only_users_cannot_open_or_submit_the_ticket_create_form(): void
|
||||
{
|
||||
$viewer = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
$this->syncProjectPermissions($project, $viewer, ['view']);
|
||||
|
||||
$this->actingAs($viewer)
|
||||
->get(route('projects.tickets.create', $project))
|
||||
->assertForbidden();
|
||||
|
||||
$this->post(route('projects.tickets.store', $project), [
|
||||
'title' => 'Unauthorized ticket',
|
||||
'ticket_type_id' => $this->nonParentType($project)->id,
|
||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||
'ticket_status_id' => $project->ticketStatuses()->firstOrFail()->id,
|
||||
])->assertForbidden();
|
||||
|
||||
$this->assertDatabaseCount('tickets', 0);
|
||||
}
|
||||
|
||||
public function test_ticket_forms_reject_metadata_from_another_project(): void
|
||||
{
|
||||
$editor = User::factory()->create();
|
||||
$project = Project::factory()->create();
|
||||
$otherProject = Project::factory()->create();
|
||||
app(InitializeProjectTicketMetadata::class)->handle($project);
|
||||
app(InitializeProjectTicketMetadata::class)->handle($otherProject);
|
||||
$this->syncProjectPermissions($project, $editor, ['write', 'view']);
|
||||
$parent = Ticket::factory()->create([
|
||||
'project_id' => $otherProject->id,
|
||||
'ticket_key' => 'OTHER-1',
|
||||
'ticket_type_id' => $this->parentType($otherProject)->id,
|
||||
'ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id,
|
||||
]);
|
||||
$invalidMetadata = [
|
||||
'parent_ticket_id' => $parent->id,
|
||||
'ticket_type_id' => $this->nonParentType($otherProject)->id,
|
||||
'ticket_priority_id' => $otherProject->ticketPriorities()->firstOrFail()->id,
|
||||
];
|
||||
|
||||
$this->actingAs($editor)
|
||||
->post(route('projects.tickets.store', $project), [
|
||||
...$invalidMetadata,
|
||||
'title' => 'Rejected creation',
|
||||
'ticket_status_id' => $otherProject->ticketStatuses()->firstOrFail()->id,
|
||||
])
|
||||
->assertSessionHasErrors([...array_keys($invalidMetadata), 'ticket_status_id']);
|
||||
|
||||
$this->assertSame(0, $project->tickets()->count());
|
||||
|
||||
$ticket = Ticket::factory()->create([
|
||||
'project_id' => $project->id,
|
||||
'ticket_key' => 'LOCAL-1',
|
||||
'ticket_type_id' => $this->nonParentType($project)->id,
|
||||
'ticket_priority_id' => $project->ticketPriorities()->firstOrFail()->id,
|
||||
'title' => 'Original title',
|
||||
]);
|
||||
|
||||
$this->patch(route('projects.tickets.update', [$project, $ticket]), $this->updatePayload($ticket, [
|
||||
...$invalidMetadata,
|
||||
'title' => 'Rejected update',
|
||||
]))->assertSessionHasErrors(array_keys($invalidMetadata));
|
||||
|
||||
$this->assertSame('Original title', $ticket->fresh()->title);
|
||||
$this->assertNull($ticket->fresh()->parent_ticket_id);
|
||||
}
|
||||
|
||||
private function nonParentType(Project $project): TicketType
|
||||
{
|
||||
return $project->ticketTypes()
|
||||
|
||||
@@ -2,14 +2,17 @@ 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 TicketCreate from '../../resources/js/pages/projects/TicketCreate.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') },
|
||||
name === 'TicketCreate'
|
||||
? TicketCreate
|
||||
: name === 'Ticket'
|
||||
? Ticket
|
||||
: { render: () => h('h1', 'Destination reached') },
|
||||
setup: ({ el, App, props, plugin }) =>
|
||||
createApp({ render: () => h(App, props) })
|
||||
.use(plugin)
|
||||
|
||||
@@ -123,10 +123,13 @@ const fieldDefinitions = [
|
||||
{ id: 1, label: 'Impact score', field_type: 'number' },
|
||||
{ id: 2, label: 'Reviewer', field_type: 'user' },
|
||||
];
|
||||
let savedForm = {};
|
||||
function makePage(url) {
|
||||
const parsed = new URL(url, 'http://localhost');
|
||||
const params = parsed.searchParams;
|
||||
const detail = parsed.pathname.startsWith('/projects/7/tickets/');
|
||||
const creating = parsed.pathname.endsWith('/create');
|
||||
const detail =
|
||||
!creating && parsed.pathname.startsWith('/projects/7/tickets/');
|
||||
const data = structuredClone(ticket);
|
||||
if (parsed.pathname.endsWith('/41')) {
|
||||
Object.assign(data, linked, {
|
||||
@@ -136,7 +139,7 @@ function makePage(url) {
|
||||
});
|
||||
}
|
||||
if (params.has('saved')) {
|
||||
data.title = params.get('saved');
|
||||
Object.assign(data, savedForm, { title: params.get('saved') });
|
||||
}
|
||||
if (params.has('long')) {
|
||||
data.title = 'LongUnbrokenTicketTitle'.repeat(10);
|
||||
@@ -168,7 +171,11 @@ function makePage(url) {
|
||||
? 'Repository temporarily unavailable.'
|
||||
: null;
|
||||
return {
|
||||
component: detail ? 'Ticket' : 'Destination',
|
||||
component: creating
|
||||
? 'TicketCreate'
|
||||
: detail
|
||||
? 'Ticket'
|
||||
: 'Destination',
|
||||
url,
|
||||
version: null,
|
||||
clearHistory: false,
|
||||
@@ -176,7 +183,28 @@ function makePage(url) {
|
||||
props: {
|
||||
name: 'KanbanGit',
|
||||
errors: params.has('rejected')
|
||||
? { title: 'This title is not allowed.' }
|
||||
? {
|
||||
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: {
|
||||
@@ -204,7 +232,17 @@ function makePage(url) {
|
||||
canManageProject: params.has('admin'),
|
||||
status: params.has('saved') ? 'Ticket updated.' : null,
|
||||
users: [user, ticket.author],
|
||||
ticketTypes: [data.ticket_type],
|
||||
ticketTypes: params.has('noMetadata') ? [] : [data.ticket_type],
|
||||
source: params.get('source') === 'board' ? 'board' : 'backlog',
|
||||
defaultTicketStatusId: 3,
|
||||
ticketStatusOptions: [
|
||||
{
|
||||
id: 3,
|
||||
name: 'In progress',
|
||||
board_column: { name: 'Delivery' },
|
||||
},
|
||||
{ id: 4, name: 'Backlog', board_column: null },
|
||||
],
|
||||
ticketPriorities: [data.ticket_priority],
|
||||
ticketFieldDefinitions: params.has('empty') ? [] : fieldDefinitions,
|
||||
parentTicketOptions: [parent],
|
||||
@@ -282,6 +320,11 @@ if (serve) {
|
||||
const data = JSON.parse(body);
|
||||
const previous = new URL(request.headers.referer);
|
||||
const params = previous.searchParams;
|
||||
if (params.has('serverError')) {
|
||||
response.writeHead(503);
|
||||
response.end('Simulated failure');
|
||||
return;
|
||||
}
|
||||
if (!request.headers['x-inertia']) {
|
||||
assert.equal(request.method, 'PATCH');
|
||||
assert.equal(request.url, '/projects/7/tickets/42');
|
||||
@@ -311,18 +354,19 @@ if (serve) {
|
||||
);
|
||||
} else {
|
||||
assert.ok(
|
||||
'field_values' in data &&
|
||||
(request.method === 'POST' || 'field_values' in data) &&
|
||||
'acceptance_criteria' in data &&
|
||||
'parent_ticket_id' in data,
|
||||
);
|
||||
if (params.has('reject')) {
|
||||
params.set('rejected', '1');
|
||||
} else {
|
||||
savedForm = data;
|
||||
params.set('saved', data.title);
|
||||
}
|
||||
}
|
||||
response.writeHead(303, {
|
||||
Location: `/projects/7/tickets/42?${params}`,
|
||||
Location: `${params.has('rejected') && previous.pathname.endsWith('/create') ? previous.pathname : '/projects/7/tickets/42'}?${params}`,
|
||||
});
|
||||
response.end();
|
||||
return;
|
||||
@@ -364,10 +408,15 @@ if (serve) {
|
||||
const { default: Ticket } = await server.ssrLoadModule(
|
||||
'/resources/js/pages/projects/Ticket.vue',
|
||||
);
|
||||
const render = async (query = '') => {
|
||||
const { default: TicketCreate } = await server.ssrLoadModule(
|
||||
'/resources/js/pages/projects/TicketCreate.vue',
|
||||
);
|
||||
const render = async (query = '', creating = false) => {
|
||||
const result = await createInertiaApp({
|
||||
page: makePage(`/projects/7/tickets/42${query}`),
|
||||
resolve: () => Ticket,
|
||||
page: makePage(
|
||||
`/projects/7/tickets/${creating ? 'create' : '42'}${query}`,
|
||||
),
|
||||
resolve: () => (creating ? TicketCreate : Ticket),
|
||||
render: renderToString,
|
||||
setup: ({ App, props, plugin }) =>
|
||||
createSSRApp({ render: () => h(App, props) })
|
||||
@@ -473,6 +522,37 @@ if (serve) {
|
||||
await render('?gitError'),
|
||||
/Repository temporarily unavailable/,
|
||||
);
|
||||
const create = await render('?source=board', true);
|
||||
assert.match(create, /Create ticket/);
|
||||
assert.match(create, /Delivery \/ In progress/);
|
||||
assert.match(create, /Back to board/);
|
||||
assert.match(create, /id="ticket_status_id"/);
|
||||
assert.doesNotMatch(
|
||||
create,
|
||||
/id="author_user_id"|id="blocked_comment"|id="field-1"/,
|
||||
);
|
||||
assert.doesNotMatch(
|
||||
await render('?hidePoints', true),
|
||||
/id="story_points"/,
|
||||
);
|
||||
const unavailable = await render('?noMetadata', true);
|
||||
assert.doesNotMatch(unavailable, /<form/);
|
||||
assert.match(unavailable, /Ticket creation needs at least one type/);
|
||||
for (const creating of [false, true]) {
|
||||
const invalid = await render('?rejected&allErrors', creating);
|
||||
for (const id of [
|
||||
'title',
|
||||
'description',
|
||||
'ticket_type_id',
|
||||
'ticket_priority_id',
|
||||
'assignee_user_id',
|
||||
'parent_ticket_id',
|
||||
'story_points',
|
||||
'acceptance_criteria_checklist',
|
||||
]) {
|
||||
assert.ok(invalid.includes(`id="${id}-error"`));
|
||||
}
|
||||
}
|
||||
console.log('Ticket detail behavior checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
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({ viewport: { width: 1440, height: 1000 } });
|
||||
page.setDefaultTimeout(10000);
|
||||
const base = 'http://127.0.0.1:4185/projects/7/tickets';
|
||||
const errors = [];
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
const field = (label) => page.getByLabel(label, { exact: true });
|
||||
const form = () => page.locator('form').first();
|
||||
const state = (value) =>
|
||||
form().locator(`[data-form-state="${value}"]`).waitFor();
|
||||
const button = (label) =>
|
||||
form().getByRole('button', { name: label, exact: true });
|
||||
async function visit(create, query = '') {
|
||||
await page.goto(`${base}/${create ? 'create' : '42'}${query}`);
|
||||
await page.getByRole('heading', { level: 1 }).waitFor();
|
||||
if (!create) {
|
||||
await page.locator('#ticket-edit > summary').click();
|
||||
}
|
||||
}
|
||||
async function fill(create) {
|
||||
await field('Title').fill('A complete ticket');
|
||||
await field('Description').fill('Description with context.');
|
||||
await field('Assignee').selectOption('2');
|
||||
await field('Parent ticket').selectOption('40');
|
||||
await field('Estimated story points').fill('0');
|
||||
await field('Acceptance criteria checklist').fill(
|
||||
'[ ] Main criterion\n [x] Nested criterion',
|
||||
);
|
||||
if (create) {
|
||||
await field('Status').selectOption('4');
|
||||
} else {
|
||||
await field('Author').selectOption('1');
|
||||
await field('Blocker comment').fill('Waiting for review');
|
||||
await field('Impact score').fill('2.5');
|
||||
await field('Reviewer').selectOption('2');
|
||||
}
|
||||
}
|
||||
async function submit(create) {
|
||||
const request = page.waitForRequest(
|
||||
(r) =>
|
||||
r.method() === (create ? 'POST' : 'PATCH') &&
|
||||
r.url().split('?')[0] === (create ? base : `${base}/42`),
|
||||
);
|
||||
await button(create ? 'Create ticket' : 'Save changes').click();
|
||||
return (await request).postDataJSON();
|
||||
}
|
||||
try {
|
||||
for (const create of [true, false]) {
|
||||
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(create, '?admin');
|
||||
assert.equal(
|
||||
await page
|
||||
.locator('html')
|
||||
.evaluate((el) => el.classList.contains('dark')),
|
||||
theme === 'dark',
|
||||
);
|
||||
assert.equal(
|
||||
await page.evaluate(
|
||||
() =>
|
||||
document.documentElement.scrollWidth <= innerWidth,
|
||||
),
|
||||
true,
|
||||
`Overflow ${create}/${width}/${theme}`,
|
||||
);
|
||||
await field('Title').focus();
|
||||
await page.keyboard.press('Tab');
|
||||
assert.equal(
|
||||
await field('Description').evaluate(
|
||||
(el) =>
|
||||
el === document.activeElement &&
|
||||
el.matches(':focus-visible'),
|
||||
),
|
||||
true,
|
||||
);
|
||||
await fill(create);
|
||||
await page.screenshot({
|
||||
path: `/private/tmp/kan31-${create ? 'create' : 'edit'}-${width}-${theme}.png`,
|
||||
fullPage: true,
|
||||
});
|
||||
page.once('dialog', (dialog) => dialog.dismiss());
|
||||
await button('Discard changes').click();
|
||||
assert.equal(
|
||||
await field('Title').inputValue(),
|
||||
'A complete ticket',
|
||||
);
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await button('Discard changes').click();
|
||||
assert.equal(
|
||||
await button('Discard changes').isDisabled(),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
await field('Title').inputValue(),
|
||||
create
|
||||
? ''
|
||||
: 'Implement a resilient caching layer for the project API',
|
||||
);
|
||||
}
|
||||
}
|
||||
await visit(create, '?admin&reject&allErrors');
|
||||
await fill(create);
|
||||
const data = await submit(create);
|
||||
assert.equal(data.title, 'A complete ticket');
|
||||
assert.equal(data.description, 'Description with context.');
|
||||
assert.equal(Number(data.ticket_type_id), 1);
|
||||
assert.equal(Number(data.ticket_priority_id), 2);
|
||||
assert.equal(Number(data.assignee_user_id), 2);
|
||||
assert.equal(Number(data.parent_ticket_id), 40);
|
||||
assert.equal(Number(data.story_points), 0);
|
||||
assert.deepEqual(
|
||||
data.acceptance_criteria.map(({ text, checked, indent }) => ({
|
||||
text,
|
||||
checked,
|
||||
indent,
|
||||
})),
|
||||
[
|
||||
{ text: 'Main criterion', checked: false, indent: 0 },
|
||||
{ text: 'Nested criterion', checked: true, indent: 1 },
|
||||
],
|
||||
);
|
||||
if (create) {
|
||||
assert.equal(Number(data.ticket_status_id), 4);
|
||||
assert.equal('author_user_id' in data, false);
|
||||
} else {
|
||||
assert.equal(Number(data.author_user_id), 1);
|
||||
assert.equal(data.is_blocked, true);
|
||||
assert.equal(data.blocked_comment, 'Waiting for review');
|
||||
assert.equal(Number(data.field_values[0].value_number), 2.5);
|
||||
assert.equal(Number(data.field_values[1].value_user_id), 2);
|
||||
assert.deepEqual(
|
||||
data.acceptance_criteria.map((item) => item.id),
|
||||
[10, 11],
|
||||
);
|
||||
assert.equal('ticket_status_id' in data, false);
|
||||
}
|
||||
await state('validation');
|
||||
const fields = [
|
||||
'Title',
|
||||
'Description',
|
||||
'Type',
|
||||
'Priority',
|
||||
'Assignee',
|
||||
'Parent ticket',
|
||||
'Estimated story points',
|
||||
'Acceptance criteria checklist',
|
||||
...(create
|
||||
? ['Status']
|
||||
: ['Author', 'Blocker comment', 'Impact score', 'Reviewer']),
|
||||
];
|
||||
for (const label of fields) {
|
||||
const control = field(label);
|
||||
assert.equal(
|
||||
await control.getAttribute('aria-invalid'),
|
||||
'true',
|
||||
label,
|
||||
);
|
||||
const errorId = (await control.getAttribute('aria-describedby'))
|
||||
.split(' ')
|
||||
.at(-1);
|
||||
assert.ok(
|
||||
(await page.locator(`[id="${errorId}"]`).innerText()).length,
|
||||
`Missing associated error: ${label}`,
|
||||
);
|
||||
}
|
||||
assert.equal(await field('Title').inputValue(), 'A complete ticket');
|
||||
await page.route(create ? `${base}` : `${base}/42`, (route) =>
|
||||
route.abort('failed'),
|
||||
);
|
||||
await button(create ? 'Create ticket' : 'Save changes').click();
|
||||
await state('network');
|
||||
assert.equal(
|
||||
await field('Description').inputValue(),
|
||||
'Description with context.',
|
||||
);
|
||||
await page.unrouteAll();
|
||||
await visit(create, '?admin&serverError');
|
||||
await fill(create);
|
||||
await submit(create);
|
||||
await state('server');
|
||||
assert.equal(await field('Title').inputValue(), 'A complete ticket');
|
||||
let pending;
|
||||
let requests = 0;
|
||||
await page.route(create ? `${base}` : `${base}/42`, (route) => {
|
||||
requests++;
|
||||
pending = route;
|
||||
});
|
||||
await button('Try again').click();
|
||||
await state('loading');
|
||||
assert.equal(await field('Title').isDisabled(), true);
|
||||
assert.equal(
|
||||
await button(
|
||||
create ? 'Creating ticket…' : 'Saving changes…',
|
||||
).isDisabled(),
|
||||
true,
|
||||
);
|
||||
await form().evaluate((el) =>
|
||||
el.dispatchEvent(
|
||||
new Event('submit', { bubbles: true, cancelable: true }),
|
||||
),
|
||||
);
|
||||
assert.equal(requests, 1);
|
||||
await pending.abort();
|
||||
await state('network');
|
||||
await page.unrouteAll();
|
||||
await visit(create, '?admin');
|
||||
await fill(create);
|
||||
page.once('dialog', (dialog) => dialog.dismiss());
|
||||
await page
|
||||
.locator('#main-content')
|
||||
.getByRole('link', {
|
||||
name: create ? 'Back to backlog' : 'Open backlog',
|
||||
exact: true,
|
||||
})
|
||||
.click();
|
||||
assert.ok(page.url().includes('/tickets/'));
|
||||
const successData = await submit(create);
|
||||
await page
|
||||
.getByRole('heading', { name: successData.title, exact: true })
|
||||
.waitFor();
|
||||
if (create) {
|
||||
await page.goBack();
|
||||
await field('Title').waitFor();
|
||||
assert.equal(await field('Title').inputValue(), '');
|
||||
assert.equal(
|
||||
await field('Acceptance criteria checklist').inputValue(),
|
||||
'',
|
||||
);
|
||||
} else {
|
||||
await form()
|
||||
.getByText('All changes saved', { exact: true })
|
||||
.waitFor();
|
||||
await field('Title').fill('Another draft');
|
||||
page.once('dialog', (dialog) => dialog.accept());
|
||||
await button('Discard changes').click();
|
||||
assert.equal(
|
||||
await field('Title').inputValue(),
|
||||
'A complete ticket',
|
||||
);
|
||||
assert.equal(await field('Impact score').inputValue(), '2.5');
|
||||
}
|
||||
}
|
||||
await visit(false, '?hideAuthor&hidePoints');
|
||||
assert.equal(await field('Author').count(), 0);
|
||||
assert.equal(await field('Estimated story points').count(), 0);
|
||||
await visit(false);
|
||||
assert.equal(await field('Author').isDisabled(), true);
|
||||
await visit(true, '?hidePoints');
|
||||
assert.equal(await field('Estimated story points').count(), 0);
|
||||
await visit(true, '?noMetadata');
|
||||
assert.equal(await page.locator('form').count(), 0);
|
||||
assert.deepEqual(errors, []);
|
||||
console.log(
|
||||
'Ticket form browser checks passed: create/edit payloads, nested validation, retries, drafts, resets, permissions, keyboard, mobile and both themes.',
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
Reference in New Issue
Block a user