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 @@
+
+
+
+
+
+
+
+ {{ ticket.ticket_key }}
+
+
+
+
+
+ {{ ticket.title }}
+
+
+
+
+
+ {{ ticket.description }}
+
+
+
+ {{
+ t('tickets.blockerComment', {
+ comment: ticket.blocked_comment,
+ })
+ }}
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+ {{ ticket.ticket_key }}
+
+
+ {{ ticket.title }}
+
+
+
+
+
+
+
+ {{ ticket.description }}
+
+
+
+ {{
+ t('tickets.blockerComment', {
+ comment: ticket.blocked_comment,
+ })
+ }}
+
+
+
+
+
+
+
+
+
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 @@
+
+
+
+
+
+
+ {{ t('tickets.blocked') }}
+
+
+
+ {{ ticket.ticket_type.name }}
+
+
+
+ {{ ticket.ticket_priority.name }}
+
+
+
+ {{ statusLabel }}
+
+
+
+
+ {{ ticket.assignee.name }}
+
+
+
+
+ {{ t('tickets.unassigned') }}
+
+
+
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.
-
-
+
-
-
- | Key |
- Title |
- Type |
-
- Status
- |
-
- Priority
- |
-
+ |
-
-
-
-
- |
-
+ |
-
-
- {{ ticket.title }}
-
-
- {{ ticket.description }}
-
- |
-
- {{ ticket.ticket_type.name }}
- |
-
- {{
- ticketStatusLabel(
- ticket.ticket_status,
- )
- }}
- |
-
- {{ ticket.ticket_priority.name }}
- |
-
-
- |
-
-
-
-
+ 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();
+ }
+}