From 313e2d239678b49dfe65ea2de97ac426bcdc90b8 Mon Sep 17 00:00:00 2001 From: Twyco Date: Fri, 11 Sep 2026 09:33:50 +0200 Subject: [PATCH] feat(KAN-26): add reusable ticket presentations --- .../Projects/BuildProjectBacklogPayload.php | 1 + .../Projects/BuildProjectBoardPayload.php | 1 + .../js/components/tickets/TicketCard.vue | 84 ++++++ .../js/components/tickets/TicketListItem.vue | 88 ++++++ .../js/components/tickets/TicketMetadata.vue | 91 +++++++ resources/js/components/tickets/index.ts | 2 + resources/js/lib/tickets.ts | 20 ++ resources/js/locales/en.ts | 9 + resources/js/pages/projects/Backlog.vue | 253 ++++++------------ resources/js/pages/projects/Board.vue | 98 +------ resources/js/types/ticket-presentation.ts | 12 + tests/Feature/Projects/ProjectBacklogTest.php | 8 + .../Projects/ProjectBoardControllerTest.php | 8 + tests/Feature/TicketPresentationsTest.php | 19 ++ tests/Fixtures/TicketPresentationsPreview.vue | 30 +++ .../Fixtures/ticket-presentations-preview.mjs | 11 + tests/Fixtures/ticket-presentations.mjs | 228 ++++++++++++++++ 17 files changed, 706 insertions(+), 257 deletions(-) create mode 100644 resources/js/components/tickets/TicketCard.vue create mode 100644 resources/js/components/tickets/TicketListItem.vue create mode 100644 resources/js/components/tickets/TicketMetadata.vue create mode 100644 resources/js/components/tickets/index.ts create mode 100644 resources/js/lib/tickets.ts create mode 100644 resources/js/types/ticket-presentation.ts create mode 100644 tests/Feature/TicketPresentationsTest.php create mode 100644 tests/Fixtures/TicketPresentationsPreview.vue create mode 100644 tests/Fixtures/ticket-presentations-preview.mjs create mode 100644 tests/Fixtures/ticket-presentations.mjs diff --git a/app/Actions/Projects/BuildProjectBacklogPayload.php b/app/Actions/Projects/BuildProjectBacklogPayload.php index bff6315..10eccac 100644 --- a/app/Actions/Projects/BuildProjectBacklogPayload.php +++ b/app/Actions/Projects/BuildProjectBacklogPayload.php @@ -47,6 +47,7 @@ class BuildProjectBacklogPayload 'tickets' => TicketResource::collection( Ticket::query() ->with([ + 'assignee', 'ticketPriority', 'ticketStatus.allowedTargetStatuses.boardColumn', 'ticketStatus.boardColumn', diff --git a/app/Actions/Projects/BuildProjectBoardPayload.php b/app/Actions/Projects/BuildProjectBoardPayload.php index 1afb503..4c08b65 100644 --- a/app/Actions/Projects/BuildProjectBoardPayload.php +++ b/app/Actions/Projects/BuildProjectBoardPayload.php @@ -29,6 +29,7 @@ class BuildProjectBoardPayload 'ticketStatuses', 'tickets' => fn ($query) => $query ->with([ + 'assignee', 'ticketPriority', 'ticketStatus.allowedTargetStatuses.boardColumn', 'ticketStatus.boardColumn', diff --git a/resources/js/components/tickets/TicketCard.vue b/resources/js/components/tickets/TicketCard.vue new file mode 100644 index 0000000..d5b00b5 --- /dev/null +++ b/resources/js/components/tickets/TicketCard.vue @@ -0,0 +1,84 @@ + + + diff --git a/resources/js/components/tickets/TicketListItem.vue b/resources/js/components/tickets/TicketListItem.vue new file mode 100644 index 0000000..9c7971b --- /dev/null +++ b/resources/js/components/tickets/TicketListItem.vue @@ -0,0 +1,88 @@ + + + diff --git a/resources/js/components/tickets/TicketMetadata.vue b/resources/js/components/tickets/TicketMetadata.vue new file mode 100644 index 0000000..0642249 --- /dev/null +++ b/resources/js/components/tickets/TicketMetadata.vue @@ -0,0 +1,91 @@ + + + diff --git a/resources/js/components/tickets/index.ts b/resources/js/components/tickets/index.ts new file mode 100644 index 0000000..ab65759 --- /dev/null +++ b/resources/js/components/tickets/index.ts @@ -0,0 +1,2 @@ +export { default as TicketCard } from './TicketCard.vue'; +export { default as TicketListItem } from './TicketListItem.vue'; diff --git a/resources/js/lib/tickets.ts b/resources/js/lib/tickets.ts new file mode 100644 index 0000000..bdef5cc --- /dev/null +++ b/resources/js/lib/tickets.ts @@ -0,0 +1,20 @@ +import type { TicketStatusModel } from '@/types/models/ticket-status-model'; + +export function ticketStatusLabel( + ticketStatus: TicketStatusModel | null, + fallback: string, +): string { + if (ticketStatus === null) { + return fallback; + } + + if ( + ticketStatus.board_column === null || + ticketStatus.board_column === undefined || + ticketStatus.board_column.name === ticketStatus.name + ) { + return ticketStatus.name; + } + + return `${ticketStatus.board_column.name} / ${ticketStatus.name}`; +} diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts index 711ccef..eb32394 100644 --- a/resources/js/locales/en.ts +++ b/resources/js/locales/en.ts @@ -297,6 +297,15 @@ export const en = { 'tickets.noPullRequests': 'No matching pull requests', 'tickets.noPullRequestsDescription': 'No matching pull requests were found across the linked repositories.', + 'tickets.open': 'Open {key}: {title}', + 'tickets.blocked': 'Blocked', + 'tickets.blockerComment': 'Blocker: {comment}', + 'tickets.typeLabel': 'Type: {name}', + 'tickets.priorityLabel': 'Priority: {name}', + 'tickets.statusLabel': 'Status: {name}', + 'tickets.noStatus': 'No status', + 'tickets.assignedTo': 'Assigned to {name}', + 'tickets.unassigned': 'Unassigned', 'common.save': 'Save', 'common.loading': 'Loading…', 'common.error': 'Something went wrong.', diff --git a/resources/js/pages/projects/Backlog.vue b/resources/js/pages/projects/Backlog.vue index e3a9751..f93e7e1 100644 --- a/resources/js/pages/projects/Backlog.vue +++ b/resources/js/pages/projects/Backlog.vue @@ -2,6 +2,7 @@ import { Form, Head, Link } from '@inertiajs/vue3'; import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController'; import Heading from '@/components/Heading.vue'; +import { TicketListItem } from '@/components/tickets'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -12,13 +13,11 @@ import { CardTitle, } from '@/components/ui/card'; import AppLayout from '@/layouts/AppLayout.vue'; +import { ticketStatusLabel } from '@/lib/tickets'; import { index as boardIndex } from '@/routes/projects/board'; import { index as backlogIndex } from '@/routes/projects/backlog'; import { index, show } from '@/routes/projects/index'; -import { - create as createTicket, - show as showTicket, -} from '@/routes/projects/tickets'; +import { create as createTicket } from '@/routes/projects/tickets'; import type { BreadcrumbItem, ProjectBacklogPageProps } from '@/types'; import type { TicketModel } from '@/types/models/ticket-model'; import type { TicketStatusModel } from '@/types/models/ticket-status-model'; @@ -40,25 +39,6 @@ const breadcrumbs: BreadcrumbItem[] = [ }, ]; -const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => { - if (ticketStatus === null) { - return 'Unknown status'; - } - - if ( - ticketStatus.board_column === null || - ticketStatus.board_column === undefined - ) { - return ticketStatus.name; - } - - if (ticketStatus.board_column.name === ticketStatus.name) { - return ticketStatus.name; - } - - return `${ticketStatus.board_column.name} / ${ticketStatus.name}`; -}; - const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => { return (ticket.ticket_status?.allowed_target_statuses ?? []).filter( (ticketStatus) => ticketStatus.id !== ticket.ticket_status_id, @@ -132,163 +112,98 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => { No backlog tickets exist yet. -
- + - - - - - - - - - - - - - - - - - - - - -
KeyTitleType - Status - - Priority - +
+ +
- + - - {{ ticket.title }} - -
- {{ ticket.description }} -
-
- {{ ticket.ticket_type.name }} - - {{ - ticketStatusLabel( - ticket.ticket_status, - ) - }} - - {{ ticket.ticket_priority.name }} - - - - - -

- This status does not allow - moving anywhere else. -

- -
-
+ No allowed moves + + + + + +

+ This status does not allow moving + anywhere else. +

+ + + + diff --git a/resources/js/pages/projects/Board.vue b/resources/js/pages/projects/Board.vue index 8e327d9..003acc5 100644 --- a/resources/js/pages/projects/Board.vue +++ b/resources/js/pages/projects/Board.vue @@ -2,6 +2,7 @@ import { Form, Head, Link } from '@inertiajs/vue3'; import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController'; import Heading from '@/components/Heading.vue'; +import { TicketCard } from '@/components/tickets'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { @@ -12,14 +13,12 @@ import { CardTitle, } from '@/components/ui/card'; import AppLayout from '@/layouts/AppLayout.vue'; +import { ticketStatusLabel } from '@/lib/tickets'; import { index as backlogIndex } from '@/routes/projects/backlog'; import { index as projectIndex, show } from '@/routes/projects/index'; import { board as boardSettings } from '@/routes/projects/settings'; import { index as boardIndex } from '@/routes/projects/board'; -import { - create as createTicket, - show as showTicket, -} from '@/routes/projects/tickets'; +import { create as createTicket } from '@/routes/projects/tickets'; import type { BreadcrumbItem, ProjectBoardPageProps } from '@/types'; import type { TicketModel } from '@/types/models/ticket-model'; import type { TicketStatusModel } from '@/types/models/ticket-status-model'; @@ -41,25 +40,6 @@ const breadcrumbs: BreadcrumbItem[] = [ }, ]; -const ticketStatusLabel = (ticketStatus: TicketStatusModel | null): string => { - if (ticketStatus === null) { - return 'Unknown status'; - } - - if ( - ticketStatus.board_column === null || - ticketStatus.board_column === undefined - ) { - return ticketStatus.name; - } - - if (ticketStatus.board_column.name === ticketStatus.name) { - return ticketStatus.name; - } - - return `${ticketStatus.board_column.name} / ${ticketStatus.name}`; -}; - const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => { return (ticket.ticket_status?.allowed_target_statuses ?? []).filter( (ticketStatus) => ticketStatus.id !== ticket.ticket_status_id, @@ -180,72 +160,13 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
- - -
- - {{ ticket.ticket_key }} - - - Blocked - - - {{ ticket.ticket_type.name }} - - - {{ ticket.ticket_priority.name }} - - - {{ - ticketStatusLabel( - ticket.ticket_status, - ) - }} - -
- - - {{ ticket.title }} - - - - {{ ticket.description }} - - - {{ ticket.blocked_comment }} - -
- - + diff --git a/tests/Fixtures/ticket-presentations-preview.mjs b/tests/Fixtures/ticket-presentations-preview.mjs new file mode 100644 index 0000000..9b87204 --- /dev/null +++ b/tests/Fixtures/ticket-presentations-preview.mjs @@ -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), +}); diff --git a/tests/Fixtures/ticket-presentations.mjs b/tests/Fixtures/ticket-presentations.mjs new file mode 100644 index 0000000..4e476d0 --- /dev/null +++ b/tests/Fixtures/ticket-presentations.mjs @@ -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('&', '&') + .replaceAll('"', '"') + .replaceAll('<', '<'); + response.setHeader('Content-Type', 'text/html'); + response.end( + await server.transformIndexHtml( + request.url, + `
`, + ), + ); + }); + 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(); + } +}