feat(KAN-26): add reusable ticket presentations
This commit is contained in:
@@ -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),
|
||||
});
|
||||
@@ -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,
|
||||
`<!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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user