feat(KAN-26): add reusable ticket presentations
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s

This commit is contained in:
2026-09-11 14:20:02 +02:00
parent 810d1850ae
commit 313e2d2396
17 changed files with 706 additions and 257 deletions
@@ -23,6 +23,9 @@ class ProjectBacklogTest extends TestCase
public function test_users_with_view_permission_can_view_a_projects_backlog(): void
{
$user = User::factory()->create();
$assignee = User::factory()->create([
'name' => 'Taylor Reed',
]);
$project = Project::factory()->create([
'key' => 'AUTH',
]);
@@ -39,6 +42,7 @@ class ProjectBacklogTest extends TestCase
->firstOrFail();
$ticket = Ticket::factory()->create([
'assignee_user_id' => $assignee->id,
'project_id' => $project->id,
'ticket_priority_id' => $priority->id,
'ticket_status_id' => $backlogStatus->id,
@@ -59,6 +63,10 @@ class ProjectBacklogTest extends TestCase
->where('canEditTickets', false)
->where('project.id', $project->id)
->where('tickets.0.ticket_key', $ticket->ticket_key)
->where('tickets.0.assignee.id', $assignee->id)
->where('tickets.0.assignee.name', $assignee->name)
->where('tickets.0.ticket_priority.name', $priority->name)
->where('tickets.0.ticket_status.name', $backlogStatus->name)
->where('canCreateTickets', false),
);
}
@@ -24,6 +24,9 @@ class ProjectBoardControllerTest extends TestCase
public function test_users_with_view_permission_can_view_a_projects_board(): void
{
$user = User::factory()->create();
$assignee = User::factory()->create([
'name' => 'Taylor Reed',
]);
$project = Project::factory()->create([
'key' => 'CORE',
]);
@@ -58,6 +61,7 @@ class ProjectBoardControllerTest extends TestCase
]);
$boardTicket = Ticket::factory()->create([
'assignee_user_id' => $assignee->id,
'project_id' => $project->id,
'sequence_number' => 2,
'ticket_key' => 'CORE-2',
@@ -81,6 +85,10 @@ class ProjectBoardControllerTest extends TestCase
->has('boardColumns.1.tickets', 1)
->where('boardColumns.1.tickets.0.ticket_key', $boardTicket->ticket_key)
->where('boardColumns.1.tickets.0.title', $boardTicket->title)
->where('boardColumns.1.tickets.0.assignee.id', $assignee->id)
->where('boardColumns.1.tickets.0.assignee.name', $assignee->name)
->where('boardColumns.1.tickets.0.ticket_priority.name', $priority->name)
->where('boardColumns.1.tickets.0.ticket_status.name', $doingStatus->name)
->where('canCreateTickets', false)
->where('canEditTickets', false),
);
+19
View File
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class TicketPresentationsTest extends TestCase
{
public function test_ticket_cards_and_list_items_share_accessible_data_driven_behavior(): void
{
$process = new Process(['node', 'tests/Fixtures/ticket-presentations.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Ticket presentation behavior checks passed.', $process->getOutput());
}
}
@@ -0,0 +1,30 @@
<script setup>
import { TicketCard, TicketListItem } from '@/components/tickets';
defineProps({
fullTicket: Object,
optionalTicket: Object,
});
</script>
<template>
<main class="mx-auto grid max-w-5xl gap-8 p-4 sm:p-8">
<section class="grid gap-4" aria-labelledby="cards-title">
<h1 id="cards-title" class="text-headline-mobile md:text-headline">
Ticket cards
</h1>
<div class="grid gap-4 sm:grid-cols-2">
<TicketCard :project-id="7" :ticket="fullTicket" />
<TicketCard :project-id="7" :ticket="optionalTicket" />
</div>
</section>
<section class="grid gap-4" aria-labelledby="rows-title">
<h2 id="rows-title" class="text-title">Ticket list items</h2>
<ul class="grid gap-4">
<TicketListItem :project-id="7" :ticket="fullTicket" />
<TicketListItem :project-id="7" :ticket="optionalTicket" />
</ul>
</section>
</main>
</template>
@@ -0,0 +1,11 @@
import { createApp } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import TicketPresentationsPreview from './TicketPresentationsPreview.vue';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: () => TicketPresentationsPreview,
setup: ({ el, App, props, plugin }) =>
createApp(App, props).use(plugin).use(createLocalization()).mount(el),
});
+228
View File
@@ -0,0 +1,228 @@
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-presentations-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4179, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const user = {
id: 8,
name: 'Taylor Reed',
email: 'taylor@example.test',
avatar: null,
avatar_color: '#22c55e',
avatar_is_generated: true,
email_verified_at: null,
must_change_password_on_next_login: false,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
};
const fullTicket = {
id: 42,
acceptance_criteria: [],
assignee: user,
assignee_user_id: user.id,
author: null,
author_user_id: null,
blocked_comment: 'Waiting for the security review.',
description: 'Keep the complete summary available on every surface.',
is_blocked: true,
parent_ticket_id: null,
project_id: 7,
sequence_number: 42,
story_points: 5,
ticket_key: 'ENG-42',
ticket_priority: {
id: 502,
name: 'Critical from project',
position: 4,
project_id: 7,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
},
ticket_priority_id: 502,
ticket_status: {
id: 602,
board_column_id: 12,
name: 'Review from project',
position: 2,
project_id: 7,
slug: 'review',
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
board_column: {
id: 12,
name: 'Delivery',
position: 2,
project_id: 7,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
},
},
ticket_status_id: 602,
ticket_type: {
id: 702,
is_parent: false,
name: 'Incident from project',
position: 1,
project_id: 7,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
},
ticket_type_id: 702,
title: 'A very long ticket title with an_uninterrupted_identifier_that_must_remain_readable_and_actionable_on_small_screens',
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
};
const optionalTicket = {
...fullTicket,
id: 43,
assignee: null,
assignee_user_id: null,
blocked_comment: null,
description: null,
is_blocked: false,
ticket_key: 'ENG-43',
ticket_status: null,
ticket_status_id: null,
title: 'Ticket without optional data',
};
if (serve) {
server.middlewares.use(async (request, response, next) => {
const url = new URL(request.url, 'http://localhost');
if (url.pathname !== '/ticket-presentations') {
return next();
}
const page = {
component: 'TicketPresentationsPreview',
url: request.url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
auth: { user },
errors: {},
fullTicket,
optionalTicket,
},
};
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"${url.searchParams.has('dark') ? ' class="dark"' : ''}><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-presentations-preview.mjs"></script></body></html>`,
),
);
});
await server.listen();
console.log(
'Ticket presentation fixture ready at http://127.0.0.1:4179/ticket-presentations',
);
} else {
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { default: TicketCard } = await server.ssrLoadModule(
'/resources/js/components/tickets/TicketCard.vue',
);
const { default: TicketListItem } = await server.ssrLoadModule(
'/resources/js/components/tickets/TicketListItem.vue',
);
assert.deepEqual(TicketCard.emits, ['open']);
assert.deepEqual(TicketListItem.emits, ['open']);
for (const component of [TicketCard, TicketListItem]) {
assert.ok(component.props.projectId);
assert.ok(component.props.ticket);
assert.ok(component.props.showDescription);
assert.ok(component.props.showStatus);
}
const render = async (component, ticket) => {
const Page = {
render: () =>
component === TicketListItem
? h('ul', {}, [h(component, { projectId: 7, ticket })])
: h(component, { projectId: 7, ticket }),
};
const response = await createInertiaApp({
page: {
component: 'TicketPresentation',
url: '/projects/7/board',
version: null,
clearHistory: false,
encryptHistory: false,
props: {
auth: { user },
errors: {},
},
},
resolve: () => Page,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization()),
});
return response.body;
};
for (const component of [TicketCard, TicketListItem]) {
const html = await render(component, fullTicket);
assert.match(html, /href="\/projects\/7\/tickets\/42"/);
assert.match(html, /aria-label="Open ENG-42:/);
assert.match(html, /min-h-control/);
assert.match(html, /focus-visible:ring-2/);
assert.match(html, /wrap-anywhere/);
assert.match(html, new RegExp(fullTicket.title));
assert.match(html, /data-ticket-type-id="702"/);
assert.match(html, /Incident from project/);
assert.match(html, /data-ticket-priority-id="502"/);
assert.match(html, /Critical from project/);
assert.match(html, /data-ticket-status-id="602"/);
assert.match(html, /Delivery \/ Review from project/);
assert.match(html, /Assigned to Taylor Reed/);
assert.match(html, /Blocked/);
assert.match(html, /Blocker: Waiting for the security review\./);
const optionalHtml = await render(component, optionalTicket);
assert.match(optionalHtml, /href="\/projects\/7\/tickets\/43"/);
assert.match(optionalHtml, /Ticket without optional data/);
assert.match(optionalHtml, /No status/);
assert.match(optionalHtml, /Unassigned/);
assert.doesNotMatch(optionalHtml, /Blocked|Blocker:/);
}
console.log('Ticket presentation behavior checks passed.');
} finally {
await server.close();
}
}