Compare commits

...
5 Commits
Author SHA1 Message Date
Twyco ccd199a695 Merge pull request 'KAN-27 Responsive board' (#121) from feature/KAN-27-responsive-board into main
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
Reviewed-on: #121
2026-09-11 12:21:06 +00:00
Twyco f7b50e8715 feat(KAN-27): rebuild responsive project board
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
2026-09-11 14:20:56 +02:00
Twyco 5147c8ed8d Merge pull request 'KAN-25 Personal dashboard' (#119) from feature/KAN-25-personal-dashboard into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #119
2026-09-11 12:20:42 +00:00
Twyco 3914fd5432 feat(KAN-25): build personal dashboard
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
2026-09-11 14:20:27 +02:00
Twyco 4d6da2c5f5 Merge pull request 'KAN-26 Ticket cards and lists' (#120) from feature/KAN-26-ticket-cards-and-lists into main
CI/CD / test_and_check_app (push) Canceled after 0s
CI/CD / Publish immutable image (push) Canceled after 0s
Reviewed-on: #120
2026-09-11 12:20:12 +00:00
15 changed files with 1620 additions and 240 deletions
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
->where('assignee_user_id', $assignee->id)
->whereIn('project_id', $visibleProjectIds)
->with([
'acceptanceCriteria',
'assignee',
'project',
'ticketPriority',
'ticketStatus.boardColumn',
'ticketType',
@@ -0,0 +1,24 @@
<?php
namespace App\Http\Controllers;
use App\Actions\Tickets\ListAssignedTicketsByVisibleProject;
use Illuminate\Http\Request;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __construct(
private readonly ListAssignedTicketsByVisibleProject $listAssignedTicketsByVisibleProject,
) {}
public function __invoke(Request $request): Response
{
$user = $request->user();
return Inertia::render('Dashboard', [
'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user),
]);
}
}
+39
View File
@@ -1,4 +1,25 @@
export const en = {
'board.title': '{name} board',
'board.description':
'Work in your configured workflow. Tickets without a board column stay in the backlog.',
'board.columns': 'Workflow columns',
'board.selectColumn': 'Choose a column',
'board.columnOption': '{name} ({count})',
'board.scrollHint': 'Scroll horizontally to explore all columns.',
'board.openBacklog': 'Open backlog',
'board.backToProject': 'Project overview',
'board.empty.title': 'No workflow columns yet',
'board.empty.description':
'Set up columns to start tracking work on this board.',
'board.emptyColumn': 'No tickets in this column.',
'board.emptyStatus': 'No tickets in this status.',
'board.noStatuses': 'No statuses configured for this column.',
'board.moveTicket': 'Move {key}',
'board.updateStatus': 'Update status',
'board.updatingStatus': 'Updating…',
'board.noMoves': 'No allowed moves',
'board.noMovesDescription':
'This status does not allow moving anywhere else.',
'projectCreation.title': 'Create a project',
'projectCreation.description':
'Give your work a home. Start fresh or reuse a setup that already works for your team.',
@@ -118,6 +139,24 @@ export const en = {
'Inherited areas follow the linked template. Overridden areas use this project’s own configuration.',
'projects.inheritedArea': '{name} · inherited',
'projects.overriddenArea': '{name} · overridden',
'dashboard.eyebrow': 'Personal workspace',
'dashboard.greeting': 'Welcome back, {name}',
'dashboard.description':
'A current view of tickets assigned to you in projects you can access.',
'dashboard.summary': 'Work summary',
'dashboard.summary.assignedTickets': 'Assigned tickets',
'dashboard.summary.projects': 'Projects with assigned work',
'dashboard.summary.blockedTickets': 'Blocked tickets',
'dashboard.assignedWork': 'My assigned tickets',
'dashboard.assignedWorkDescription':
'Your work is grouped by project and ordered with the newest tickets first.',
'dashboard.projectTickets': 'Tickets assigned to you in {name}',
'dashboard.openProject': 'Open {name}',
'dashboard.openProjectAction': 'Open project',
'dashboard.empty.title': 'No tickets assigned to you',
'dashboard.empty.description':
'Tickets assigned to you in projects you can access will appear here.',
'dashboard.empty.action': 'Browse projects',
'auth.home': 'KanbanGit home',
'auth.accountAccess': 'Account access',
'auth.backToLogin': 'Return to sign in',
+197 -33
View File
@@ -1,47 +1,211 @@
<script setup lang="ts">
import { Head } from '@inertiajs/vue3';
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
import { Head, Link } from '@inertiajs/vue3';
import {
ArrowUpRight,
FolderGit2,
ListTodo,
OctagonAlert,
} from 'lucide-vue-next';
import { computed } from 'vue';
import FeedbackState from '@/components/FeedbackState.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
import AppLayout from '@/layouts/AppLayout.vue';
import { dashboard } from '@/routes/index';
import type { BreadcrumbItem } from '@/types';
import { index as projectsIndex, show as showProject } from '@/routes/projects';
import type { BreadcrumbItem, DashboardPageProps } from '@/types';
const breadcrumbs: BreadcrumbItem[] = [
const props = defineProps<DashboardPageProps>();
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.dashboard'), href: dashboard() },
]);
const assignedTickets = computed(() =>
props.assignedTicketsByProject.flatMap((group) => group.tickets),
);
const summaryItems = computed(() => [
{
title: 'Dashboard',
href: dashboard(),
label: t('dashboard.summary.assignedTickets'),
value: assignedTickets.value.length,
icon: ListTodo,
},
];
{
label: t('dashboard.summary.projects'),
value: props.assignedTicketsByProject.length,
icon: FolderGit2,
},
{
label: t('dashboard.summary.blockedTickets'),
value: assignedTickets.value.filter((ticket) => ticket.is_blocked)
.length,
icon: OctagonAlert,
},
]);
</script>
<template>
<Head title="Dashboard" />
<AppLayout :breadcrumbs="breadcrumbs">
<div
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
>
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
>
<PlaceholderPattern />
</div>
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
>
<PlaceholderPattern />
</div>
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
>
<PlaceholderPattern />
</div>
</div>
<div
class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min"
<Head :title="t('navigation.dashboard')" />
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
<header class="gap-stack grid min-w-0">
<p class="text-label text-muted-foreground uppercase">
{{ t('dashboard.eyebrow') }}
</p>
<h1 class="text-headline-mobile md:text-headline break-words">
{{
t('dashboard.greeting', {
name: $page.props.auth.user.name,
})
}}
</h1>
<p class="text-muted-foreground max-w-2xl">
{{ t('dashboard.description') }}
</p>
</header>
<section :aria-label="t('dashboard.summary')">
<ul class="grid gap-3 sm:grid-cols-3">
<li
v-for="item in summaryItems"
:key="item.label"
class="min-w-0"
>
<Card class="h-full gap-0 rounded-md py-0">
<CardContent
class="flex min-w-0 items-center justify-between gap-4 p-4"
>
<div class="min-w-0">
<p
class="text-caption text-muted-foreground break-words"
>
{{ item.label }}
</p>
<p class="text-title mt-1 tabular-nums">
{{ item.value }}
</p>
</div>
<span
class="bg-accent text-primary flex size-10 shrink-0 items-center justify-center rounded-md"
>
<component
:is="item.icon"
class="size-5"
aria-hidden="true"
/>
</span>
</CardContent>
</Card>
</li>
</ul>
</section>
<section
aria-labelledby="assigned-work-title"
class="gap-column grid min-w-0"
>
<PlaceholderPattern />
</div>
<header class="gap-stack grid min-w-0">
<h2 id="assigned-work-title" class="text-title">
{{ t('dashboard.assignedWork') }}
</h2>
<p class="text-muted-foreground max-w-2xl">
{{ t('dashboard.assignedWorkDescription') }}
</p>
</header>
<FeedbackState
v-if="assignedTicketsByProject.length === 0"
state="empty"
:title="t('dashboard.empty.title')"
:description="t('dashboard.empty.description')"
>
<Button as-child variant="outline">
<Link :href="projectsIndex()">
<FolderGit2 aria-hidden="true" />
{{ t('dashboard.empty.action') }}
</Link>
</Button>
</FeedbackState>
<div v-else class="gap-page grid min-w-0">
<section
v-for="group in assignedTicketsByProject"
:key="group.project.id"
:aria-labelledby="`dashboard-project-${group.project.id}`"
class="gap-column grid min-w-0"
>
<header
class="flex min-w-0 flex-col gap-3 border-b pb-3 sm:flex-row sm:items-center sm:justify-between"
>
<div class="flex min-w-0 items-center gap-3">
<Badge
variant="outline"
class="max-w-full shrink-0 whitespace-normal break-all"
>
{{ group.project.key }}
</Badge>
<div class="min-w-0">
<h3
:id="`dashboard-project-${group.project.id}`"
class="text-body break-words font-semibold"
>
{{ group.project.name }}
</h3>
<p
class="text-caption text-muted-foreground"
>
{{
t('tickets.count', {
count: group.tickets.length,
})
}}
</p>
</div>
</div>
<Button
as-child
variant="link"
class="self-start sm:self-auto"
>
<Link
:href="showProject(group.project.id)"
:aria-label="
t('dashboard.openProject', {
name: group.project.name,
})
"
>
{{ t('dashboard.openProjectAction') }}
<ArrowUpRight aria-hidden="true" />
</Link>
</Button>
</header>
<ul
:aria-label="
t('dashboard.projectTickets', {
name: group.project.name,
})
"
class="gap-column grid min-w-0 md:grid-cols-2 2xl:grid-cols-3"
>
<li
v-for="ticket in group.tickets"
:key="ticket.id"
class="min-w-0"
>
<TicketCard
:project-id="group.project.id"
:ticket="ticket"
/>
</li>
</ul>
</section>
</div>
</section>
</div>
</AppLayout>
</template>
+326 -201
View File
@@ -1,71 +1,103 @@
<script setup lang="ts">
import { Form, Head, Link } from '@inertiajs/vue3';
import ProjectBoardController from '@/actions/App/Http/Controllers/ProjectBoardController';
import Heading from '@/components/Heading.vue';
import { ArrowLeft, ListTodo, Plus } from 'lucide-vue-next';
import { computed, ref, watch } from 'vue';
import { move } from '@/actions/App/Http/Controllers/ProjectBoardController';
import FeedbackState from '@/components/FeedbackState.vue';
import InputError from '@/components/InputError.vue';
import { TicketCard } from '@/components/tickets';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { useLocalization } from '@/composables/useLocalization';
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 { index as projectIndex, show } from '@/routes/projects/index';
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';
const props = defineProps<ProjectBoardPageProps>();
const breadcrumbs: BreadcrumbItem[] = [
{
title: 'Projects',
href: projectIndex(),
const { t } = useLocalization();
const breadcrumbs = computed<BreadcrumbItem[]>(() => [
{ title: t('navigation.projects'), href: projectIndex() },
{ title: props.project.name, href: show(props.project.id) },
{ title: t('navigation.board'), href: boardIndex(props.project.id) },
]);
const selectedColumnId = ref<number>();
watch(
() =>
[
props.project.id,
props.boardColumns.map((column) => column.id),
] as const,
([projectId, columnIds], previous) => {
if (
projectId !== previous?.[0] ||
!columnIds.includes(selectedColumnId.value ?? -1)
) {
selectedColumnId.value = columnIds[0];
}
},
{
title: props.project.name,
href: show(props.project.id),
},
{
title: 'Board',
href: boardIndex(props.project.id),
},
];
const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
return (ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(ticketStatus) => ticketStatus.id !== ticket.ticket_status_id,
{ immediate: true },
);
const columns = computed(() =>
props.boardColumns.map((column) => {
const ticketsByStatus = new Map<number | null, TicketModel[]>();
for (const ticket of column.tickets ?? []) {
const tickets = ticketsByStatus.get(ticket.ticket_status_id) ?? [];
tickets.push(ticket);
ticketsByStatus.set(ticket.ticket_status_id, tickets);
}
return {
...column,
count: column.tickets?.length ?? 0,
groups: (column.ticket_statuses ?? []).map((status) => ({
status,
tickets: ticketsByStatus.get(status.id) ?? [],
})),
};
}),
);
const ticketCount = computed(() =>
columns.value.reduce((count, column) => count + column.count, 0),
);
const allowedTargetStatuses = (ticket: TicketModel) =>
(ticket.ticket_status?.allowed_target_statuses ?? []).filter(
(status) => status.id !== ticket.ticket_status_id,
);
};
</script>
<template>
<AppLayout :breadcrumbs="breadcrumbs">
<Head :title="`${project.name} board`" />
<Head :title="t('board.title', { name: project.name })" />
<div class="flex flex-1 flex-col gap-6 p-4">
<div
class="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between"
<div class="gap-column md:p-page flex min-w-0 flex-1 flex-col p-4">
<header
class="flex min-w-0 flex-col gap-4 xl:flex-row xl:items-start xl:justify-between"
>
<div class="space-y-3">
<Badge variant="outline" class="px-3 py-1">
{{ project.key }}
</Badge>
<Heading
:title="`${project.name} board`"
description="Tickets only appear here once they are assigned to one of the project's workflow columns."
/>
<div class="grid min-w-0 gap-2">
<div class="flex min-w-0 flex-wrap items-center gap-3">
<Badge
variant="outline"
class="wrap-anywhere max-w-full whitespace-normal"
>{{ project.key }}</Badge
>
<p class="text-caption text-muted-foreground">
{{ t('tickets.count', { count: ticketCount }) }}
</p>
</div>
<h1
class="text-headline-mobile md:text-headline wrap-anywhere"
>
{{ t('board.title', { name: project.name }) }}
</h1>
<p class="text-caption text-muted-foreground max-w-2xl">
{{ t('board.description') }}
</p>
</div>
<div class="flex flex-wrap gap-3">
<div class="flex flex-wrap gap-2">
<Button v-if="canCreateTickets" as-child>
<Link
:href="
@@ -75,187 +107,280 @@ const allowedTargetStatuses = (ticket: TicketModel): TicketStatusModel[] => {
)
"
>
Create ticket
<Plus aria-hidden="true" />{{
t('navigation.createTicket')
}}
</Link>
</Button>
<Button as-child variant="outline">
<Link :href="backlogIndex(project.id)"
>Open backlog</Link
><ListTodo aria-hidden="true" />{{
t('board.openBacklog')
}}</Link
>
</Button>
<Button as-child variant="outline">
<Link :href="show(project.id)">Back to project</Link>
<Button as-child variant="ghost">
<Link :href="show(project.id)"
><ArrowLeft aria-hidden="true" />{{
t('board.backToProject')
}}</Link
>
</Button>
</div>
</div>
</header>
<div
v-if="status"
class="rounded-xl border border-emerald-200 bg-emerald-50 px-4 py-3 text-sm text-emerald-900 dark:border-emerald-900/70 dark:bg-emerald-950/40 dark:text-emerald-100"
>
{{ status }}
</div>
<FeedbackState v-if="status" state="success" :title="status" />
<FeedbackState
v-if="columns.length === 0"
state="empty"
:title="t('board.empty.title')"
:description="t('board.empty.description')"
/>
<Card v-if="boardColumns.length === 0">
<CardContent class="flex flex-col gap-4 p-6 text-sm">
<p class="text-muted-foreground">
The board has no workflow columns yet, so no tickets can
appear here.
</p>
<div v-if="canEditTickets">
<Button as-child variant="outline">
<Link :href="boardSettings(project.id)">
Open board settings
</Link>
</Button>
</div>
</CardContent>
</Card>
<div v-else class="grid gap-4 xl:grid-cols-3">
<Card
v-for="boardColumn in boardColumns"
:key="boardColumn.id"
class="flex h-full flex-col gap-0 overflow-hidden"
>
<CardHeader class="bg-muted/20 border-b">
<div class="flex items-start justify-between gap-3">
<div>
<CardTitle>{{ boardColumn.name }}</CardTitle>
<CardDescription>
{{ boardColumn.tickets?.length ?? 0 }}
active ticket(s)
</CardDescription>
<div
v-if="
(boardColumn.ticket_statuses?.length ??
0) > 0
"
class="mt-3 flex flex-wrap gap-2"
>
<Badge
v-for="ticketStatus in boardColumn.ticket_statuses"
:key="ticketStatus.id"
variant="outline"
>
{{ ticketStatus.name }}
</Badge>
</div>
</div>
<Badge variant="secondary">
#{{ boardColumn.position }}
</Badge>
</div>
</CardHeader>
<CardContent class="flex flex-1 flex-col gap-4 p-4">
<div
v-if="(boardColumn.tickets?.length ?? 0) === 0"
class="border-border text-muted-foreground rounded-xl border border-dashed px-4 py-6 text-sm"
<template v-else>
<div class="grid min-w-0 gap-2 md:hidden">
<label for="board-column" class="text-body font-semibold">{{
t('board.selectColumn')
}}</label>
<select
id="board-column"
v-model="selectedColumnId"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2"
:aria-controls="`board-column-${selectedColumnId}`"
>
<option
v-for="column in columns"
:key="column.id"
:value="column.id"
>
No tickets are currently in this column.
</div>
<div v-else class="flex flex-col gap-4">
<TicketCard
v-for="ticket in boardColumn.tickets"
:key="ticket.id"
:project-id="project.id"
:ticket="ticket"
{{
t('board.columnOption', {
name: column.name,
count: column.count,
})
}}
</option>
</select>
</div>
<p
id="board-scroll-hint"
class="text-caption text-muted-foreground hidden md:block"
>
{{ t('board.scrollHint') }}
</p>
<section
:aria-label="t('board.columns')"
aria-describedby="board-scroll-hint"
tabindex="0"
class="gap-column focus-visible:ring-ring/35 flex min-w-0 rounded-md pb-2 focus-visible:outline-none focus-visible:ring-2 md:overflow-x-auto md:overscroll-x-contain"
>
<section
v-for="column in columns"
:id="`board-column-${column.id}`"
:key="`${project.id}-${column.id}`"
:aria-labelledby="`board-column-title-${column.id}`"
:class="[
'bg-muted/40 gap-column w-full min-w-0 shrink-0 flex-col self-stretch rounded-md border p-3 md:flex md:w-80',
selectedColumnId === column.id ? 'flex' : 'hidden',
]"
>
<header
class="border-primary/25 flex min-w-0 items-start justify-between gap-3 border-b-2 pb-3"
>
<h2
:id="`board-column-title-${column.id}`"
class="text-title wrap-anywhere"
>
<template v-if="canEditTickets" #actions>
<Form
v-bind="
ProjectBoardController.move.form({
project: project.id,
ticket: ticket.id,
})
"
class="flex flex-col gap-2"
v-slot="{ processing }"
{{ column.name }}
</h2>
<Badge
variant="secondary"
class="shrink-0 tabular-nums"
:aria-label="
t('tickets.count', { count: column.count })
"
>{{ column.count }}</Badge
>
</header>
<p
v-if="column.count === 0"
class="text-caption text-muted-foreground rounded-md border border-dashed p-3"
>
{{ t('board.emptyColumn') }}
</p>
<p
v-if="column.groups.length === 0"
class="text-caption text-muted-foreground"
>
{{ t('board.noStatuses') }}
</p>
<section
v-for="group in column.groups"
:key="group.status.id"
:aria-labelledby="`board-status-${group.status.id}`"
class="grid min-w-0 gap-3"
>
<div
class="text-caption text-muted-foreground flex min-w-0 items-center justify-between gap-2"
>
<h3
:id="`board-status-${group.status.id}`"
class="wrap-anywhere font-semibold"
>
{{ group.status.name }}
</h3>
<span
class="shrink-0 tabular-nums"
:aria-label="
t('tickets.count', {
count: group.tickets.length,
})
"
>{{ group.tickets.length }}</span
>
</div>
<p
v-if="group.tickets.length === 0"
class="text-caption text-muted-foreground border-border rounded-md border border-dashed p-3"
>
{{ t('board.emptyStatus') }}
</p>
<ul v-else class="grid min-w-0 gap-3">
<li
v-for="ticket in group.tickets"
:key="ticket.id"
class="min-w-0"
>
<TicketCard
:project-id="project.id"
:ticket="ticket"
:show-status="false"
>
<input
type="hidden"
name="redirect_to"
value="board"
/>
<label
:for="`ticket-status-${ticket.id}`"
class="text-muted-foreground text-xs font-medium uppercase tracking-wide"
<template
v-if="canEditTickets"
#actions
>
Move ticket
</label>
<div
class="flex flex-col gap-2 sm:flex-row"
>
<select
:id="`ticket-status-${ticket.id}`"
name="ticket_status_id"
:disabled="
allowedTargetStatuses(
ticket,
).length === 0 || processing
<Form
v-bind="
move.form({
project: project.id,
ticket: ticket.id,
})
"
class="border-input bg-background shadow-xs focus-visible:border-ring focus-visible:ring-ring/50 min-w-44 rounded-md border px-3 py-2 text-sm outline-none transition-[color,box-shadow] focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-60"
v-slot="{ processing, errors }"
:error-bag="`board-ticket-${ticket.id}`"
:options="{
preserveScroll: true,
}"
class="grid min-w-0 gap-2"
>
<option
<input
type="hidden"
name="redirect_to"
value="board"
/>
<label
:for="`ticket-status-${ticket.id}`"
class="text-caption text-muted-foreground font-semibold"
>{{
t('board.moveTicket', {
key: ticket.ticket_key,
})
}}</label
>
<select
:id="`ticket-status-${ticket.id}`"
name="ticket_status_id"
:disabled="
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
:aria-invalid="
!!errors.ticket_status_id ||
undefined
"
:aria-describedby="
errors.ticket_status_id
? `ticket-status-error-${ticket.id}`
: undefined
"
class="border-input bg-card text-body min-h-control focus-visible:ring-ring/35 w-full min-w-0 rounded-md border px-3 py-2 focus-visible:outline-none focus-visible:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
>
<option
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
>
{{ t('board.noMoves') }}
</option>
<option
v-for="target in allowedTargetStatuses(
ticket,
)"
:key="target.id"
:value="target.id"
>
{{
ticketStatusLabel(
target,
t(
'tickets.noStatus',
),
)
}}
</option>
</select>
<InputError
:id="`ticket-status-error-${ticket.id}`"
:message="
errors.ticket_status_id
"
/>
<Button
type="submit"
variant="outline"
:disabled="
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>{{
t(
processing
? 'board.updatingStatus'
: 'board.updateStatus',
)
}}</Button
>
<p
v-if="
allowedTargetStatuses(
ticket,
).length === 0
"
value=""
>
No allowed moves
</option>
<option
v-for="ticketStatus in allowedTargetStatuses(
ticket,
)"
:key="ticketStatus.id"
:value="ticketStatus.id"
class="text-caption text-muted-foreground"
>
{{
ticketStatusLabel(
ticketStatus,
'Unknown status',
t(
'board.noMovesDescription',
)
}}
</option>
</select>
<Button
type="submit"
size="sm"
:disabled="
processing ||
allowedTargetStatuses(
ticket,
).length === 0
"
>
Update status
</Button>
</div>
<p
v-if="
allowedTargetStatuses(ticket)
.length === 0
"
class="text-muted-foreground text-xs"
>
This status does not allow moving
anywhere else.
</p>
</Form>
</template>
</TicketCard>
</div>
</CardContent>
</Card>
</div>
</p>
</Form>
</template>
</TicketCard>
</li>
</ul>
</section>
</section>
</section>
</template>
</div>
</AppLayout>
</template>
+9
View File
@@ -39,6 +39,15 @@ export type ProjectDuplicateCopyAreaOption = {
value: string;
};
export type AssignedTicketsProjectGroup = {
project: ProjectModel;
tickets: TicketModel[];
};
export type DashboardPageProps = {
assignedTicketsByProject: AssignedTicketsProjectGroup[];
};
export type ProjectsIndexPageProps = {
projects: ProjectModel[];
status?: string | null;
+2 -1
View File
@@ -1,5 +1,6 @@
<?php
use App\Http\Controllers\DashboardController;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Route;
@@ -17,7 +18,7 @@ Route::get('api/docs/openapi.json', function (): Response {
})->name('api.docs.openapi');
Route::middleware(['auth', 'verified'])->group(function () {
Route::inertia('dashboard', 'Dashboard')->name('dashboard');
Route::get('dashboard', DashboardController::class)->name('dashboard');
require __DIR__.'/web/admin.php';
require __DIR__.'/web/project-templates.php';
+95 -4
View File
@@ -2,26 +2,117 @@
namespace Tests\Feature;
use App\Models\Project;
use App\Models\Team;
use App\Models\Ticket;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Inertia\Testing\AssertableInertia as Assert;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class DashboardTest extends TestCase
{
use RefreshDatabase;
public function test_guests_are_redirected_to_the_login_page()
public function test_guests_are_redirected_to_the_login_page(): void
{
$response = $this->get(route('dashboard'));
$response->assertRedirect(route('login'));
}
public function test_authenticated_users_can_visit_the_dashboard()
public function test_authenticated_users_see_an_empty_dashboard_without_assigned_tickets(): void
{
$user = User::factory()->create();
$this->actingAs($user);
$response = $this->get(route('dashboard'));
$response->assertOk();
$this->get(route('dashboard'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('Dashboard')
->has('assignedTicketsByProject', 0));
}
public function test_dashboard_only_contains_the_users_tickets_from_visible_projects(): void
{
$user = User::factory()->create();
$otherUser = User::factory()->create();
$directProject = Project::factory()->create([
'key' => 'WEB',
'name' => 'Web platform',
]);
$teamProject = Project::factory()->create([
'key' => 'OPS',
'name' => 'Operations',
]);
$hiddenProject = Project::factory()->create([
'key' => 'SEC',
'name' => 'Private security',
]);
$team = Team::factory()->create();
$team->users()->attach($user);
$this->syncProjectPermissions($directProject, $user, ['view']);
$this->syncProjectPermissions($teamProject, $team, ['view']);
Ticket::factory()->create([
'assignee_user_id' => $user->id,
'project_id' => $directProject->id,
'sequence_number' => 1,
'ticket_key' => 'WEB-1',
'title' => 'Older assigned ticket',
]);
Ticket::factory()->create([
'assignee_user_id' => $user->id,
'project_id' => $directProject->id,
'sequence_number' => 2,
'ticket_key' => 'WEB-2',
'title' => 'Newest assigned ticket',
]);
Ticket::factory()->create([
'assignee_user_id' => $user->id,
'project_id' => $teamProject->id,
'sequence_number' => 1,
'ticket_key' => 'OPS-1',
'title' => 'Team-visible assigned ticket',
]);
Ticket::factory()->create([
'assignee_user_id' => $otherUser->id,
'project_id' => $directProject->id,
'sequence_number' => 3,
'ticket_key' => 'WEB-3',
'title' => 'Another users ticket',
]);
Ticket::factory()->create([
'assignee_user_id' => $user->id,
'project_id' => $hiddenProject->id,
'sequence_number' => 1,
'ticket_key' => 'SEC-1',
'title' => 'Hidden project ticket',
]);
$this->actingAs($user)
->get(route('dashboard'))
->assertOk()
->assertInertia(fn (Assert $page) => $page
->component('Dashboard')
->has('assignedTicketsByProject', 2)
->where('assignedTicketsByProject.0.project.id', $teamProject->id)
->has('assignedTicketsByProject.0.tickets', 1)
->where('assignedTicketsByProject.0.tickets.0.ticket_key', 'OPS-1')
->where('assignedTicketsByProject.1.project.id', $directProject->id)
->has('assignedTicketsByProject.1.tickets', 2)
->where('assignedTicketsByProject.1.tickets.0.ticket_key', 'WEB-2')
->where('assignedTicketsByProject.1.tickets.1.ticket_key', 'WEB-1'));
}
public function test_dashboard_presentation_uses_real_data_and_handles_empty_state(): void
{
$process = new Process(['node', 'tests/Fixtures/dashboard.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Dashboard behavior checks passed.', $process->getOutput());
}
}
@@ -0,0 +1,19 @@
<?php
namespace Tests\Feature\Projects;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProjectBoardPresentationTest extends TestCase
{
public function test_board_renders_configured_status_groups_counts_and_permission_aware_actions(): void
{
$process = new Process(['node', 'tests/Fixtures/board.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Board behavior checks passed.', $process->getOutput());
}
}
+239
View File
@@ -0,0 +1,239 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
const { chromium } = playwright.default ?? playwright;
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const base = 'http://127.0.0.1:4181/projects/7/board';
const content = () => page.locator('#main-content');
const visibleColumns = () =>
content().locator('[id^="board-column-title-"]:visible');
const visit = async (query = '') => {
await page.goto(base + query);
await page.getByRole('heading', { level: 1 }).waitFor();
await page.evaluate(() => document.fonts.ready);
};
const tabTo = async (locator) => {
await content().focus();
for (let step = 0; step < 40; step++) {
await page.keyboard.press('Tab');
if (
await locator.evaluate(
(element) => element === document.activeElement,
)
) {
assert.equal(
await locator.evaluate((element) =>
element.matches(':focus-visible'),
),
true,
);
return;
}
}
assert.fail('Board control is unreachable by keyboard');
};
try {
for (const width of [1440, 768, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({
colorScheme: theme,
reducedMotion: 'reduce',
});
for (const [query, state] of [
['', 'filled'],
['?empty', 'empty'],
['?one&noTickets', 'one-empty'],
]) {
await visit(query);
assert.equal(
await page
.locator('html')
.evaluate((el) => el.classList.contains('dark')),
theme === 'dark',
);
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
window.innerWidth,
),
true,
`${state} overflow at ${width}/${theme}`,
);
assert.equal(
await visibleColumns().count(),
state === 'empty'
? 0
: width < 768 || state === 'one-empty'
? 1
: 8,
);
if (width < 768 && state !== 'empty') {
assert.ok(
(await page.getByLabel('Choose a column').boundingBox())
.height >= 44,
);
}
if (state === 'filled' && width >= 768) {
const region = content().getByRole('region', {
name: 'Workflow columns',
exact: true,
});
assert.equal(
await region.evaluate(
(el) => el.scrollWidth > el.clientWidth,
),
true,
);
await region.focus();
await page.keyboard.press('End');
await content()
.locator('#board-column-8')
.scrollIntoViewIfNeeded();
assert.ok(await region.evaluate((el) => el.scrollLeft > 0));
await region.evaluate((el) => {
el.scrollLeft = 0;
});
}
await page.evaluate(() => window.scrollTo(0, 0));
await page.screenshot({
path: `/private/tmp/kan27-board-${state}-${width}-${theme}.png`,
fullPage: true,
});
}
}
}
await page.setViewportSize({ width: 390, height: 1000 });
await visit();
const chooseColumn = page.getByLabel('Choose a column');
await tabTo(chooseColumn);
// Native popup selection is handled by macOS outside the browser DOM.
await chooseColumn.selectOption('2');
assert.equal(await chooseColumn.inputValue(), '2');
assert.equal(await visibleColumns().innerText(), 'In development');
await chooseColumn.selectOption('8');
assert.equal(await visibleColumns().innerText(), 'Column without statuses');
await content()
.getByText('No statuses configured for this column.')
.waitFor();
await chooseColumn.selectOption('1');
await content()
.getByRole('heading', { name: 'Ready · Planned', exact: true })
.waitFor();
await content()
.getByRole('heading', { name: 'Ready · Active', exact: true })
.waitFor();
const terminalSelect = page.getByLabel('Move ENG-43', { exact: true });
assert.equal(await terminalSelect.isDisabled(), true);
const moveSelect = page.getByLabel('Move ENG-42', { exact: true });
assert.deepEqual(
await moveSelect
.locator('option')
.evaluateAll((options) => options.map((option) => option.value)),
['20', '11', '99'],
);
await tabTo(moveSelect);
await moveSelect.selectOption('20');
await page.keyboard.press('Tab');
assert.equal(await page.locator(':focus').innerText(), 'Update status');
await page.keyboard.press('Enter');
await page
.getByText('ENG-42 moved successfully.', { exact: true })
.waitFor();
assert.equal(await chooseColumn.inputValue(), '1');
assert.match(
await chooseColumn.locator('option').first().innerText(),
/\(1\)/,
);
await chooseColumn.selectOption('2');
await content()
.getByRole('link', {
name: 'Open ENG-42: Ship accessible board navigation',
exact: true,
})
.waitFor();
await visit();
await page.getByLabel('Move ENG-42', { exact: true }).selectOption('99');
await content()
.locator('article')
.filter({ hasText: 'ENG-42' })
.getByRole('button', { name: 'Update status' })
.click();
await page
.getByText('ENG-42 moved successfully.', { exact: true })
.waitFor();
assert.equal(
await content()
.getByRole('link', {
name: 'Open ENG-42: Ship accessible board navigation',
exact: true,
})
.count(),
0,
);
assert.match(
await chooseColumn.locator('option').first().innerText(),
/\(1\)/,
);
await visit('?reject');
await page.getByLabel('Move ENG-42', { exact: true }).selectOption('20');
await content()
.locator('article')
.filter({ hasText: 'ENG-42' })
.getByRole('button', { name: 'Update status' })
.click();
await page
.getByText('This transition is no longer allowed.', { exact: true })
.waitFor();
assert.equal(
await page
.getByLabel('Move ENG-42', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
assert.equal(
await content()
.getByText('This transition is no longer allowed.', { exact: true })
.count(),
1,
);
assert.equal(
await page
.getByLabel('Move ENG-43', { exact: true })
.getAttribute('aria-invalid'),
null,
);
await visit('?readonly');
assert.equal(await content().locator('form').count(), 0);
const ticketLink = content().getByRole('link', {
name: 'Open ENG-42: Ship accessible board navigation',
exact: true,
});
await tabTo(ticketLink);
await page.keyboard.press('Enter');
await page.waitForURL('**/projects/7/tickets/42');
await page
.getByRole('heading', { name: 'Destination reached', exact: true })
.waitFor();
await visit('?empty');
await tabTo(
content().getByRole('link', { name: 'Open backlog', exact: true }),
);
await page.keyboard.press('Enter');
await page.waitForURL('**/projects/7/backlog');
assert.deepEqual(errors, []);
console.log(
'Board browser checks passed: responsive columns, keyboard, status changes and validation, permissions, empty states and both themes.',
);
} finally {
await browser.close();
}
+19
View File
@@ -0,0 +1,19 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Board from '../../resources/js/pages/projects/Board.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) =>
name === 'Board'
? Board
: { render: () => h('h1', 'Destination reached') },
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+275
View File
@@ -0,0 +1,275 @@
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/board-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4181, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const project = {
id: 7,
key: 'ENG',
name: 'Engineering · Platform and developer experience',
};
const columnNames = [
'Ready',
'In development',
'Review',
'Testing',
'Release',
'Done',
'Archived',
'Column without statuses',
];
const columns = columnNames.map((name, index) => ({
id: index + 1,
name,
position: index + 1,
project_id: 7,
tickets: [],
ticket_statuses: [],
}));
const statuses = columns.slice(0, -1).flatMap((column) =>
['Planned', 'Active'].map((name, index) => ({
id: column.id * 10 + index,
name: `${column.name} · ${name}`,
position: index + 1,
project_id: 7,
board_column_id: column.id,
board_column: { ...column },
})),
);
const backlog = {
id: 99,
name: 'Backlog',
board_column_id: null,
board_column: null,
};
for (const column of columns) {
column.ticket_statuses = statuses.filter(
(status) => status.board_column_id === column.id,
);
}
function ticket(id, status, title) {
return {
id,
title,
ticket_key: `ENG-${id}`,
project_id: 7,
sequence_number: id,
description:
'A real ticket card in the isolated board behavior fixture.',
ticket_status_id: status.id,
ticket_status: {
...status,
allowed_target_statuses:
id === 42 ? [status, statuses[2], statuses[1], backlog] : [],
},
ticket_priority: { id: 1, name: 'Urgent custom priority', position: 1 },
ticket_type: { id: 1, name: 'Development', is_parent: false },
assignee: null,
assignee_user_id: null,
is_blocked: id === 43,
blocked_comment: id === 43 ? 'Awaiting the external review.' : null,
};
}
function makePage(url) {
const parsed = new URL(url, 'http://localhost');
const params = parsed.searchParams;
const isBoard = parsed.pathname === '/projects/7/board';
const boardColumns = structuredClone(
params.has('empty')
? []
: params.has('one')
? columns.slice(0, 1)
: columns,
);
if (!params.has('noTickets')) {
const target =
[...statuses, backlog].find(
(status) => status.id === Number(params.get('target')),
) ?? statuses[0];
const tickets = [
ticket(42, target, 'Ship accessible board navigation'),
ticket(
43,
statuses[1],
'InvestigateALongUnbrokenTicketTitleThatMustWrapEvenOnTheSmallestMobileScreenWithoutClipping',
),
];
for (const column of boardColumns) {
column.tickets = tickets.filter(
(item) => item.ticket_status.board_column_id === column.id,
);
}
}
return {
component: isBoard ? 'Board' : 'Destination',
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: params.has('rejected')
? {
'board-ticket-42': {
ticket_status_id:
'This transition is no longer allowed.',
},
}
: {},
sidebarOpen: true,
auth: {
user: {
id: 1,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: null,
},
permissions: {
globalAdmin: false,
projectCreate: false,
canManageTeams: false,
},
},
navigation: {
project: {
...project,
canManage: false,
canCreateTickets: !params.has('readonly'),
},
},
project,
boardColumns,
canCreateTickets: !params.has('readonly'),
canEditTickets: !params.has('readonly'),
status: params.has('target') ? 'ENG-42 moved successfully.' : null,
},
};
}
if (serve) {
server.middlewares.use(async (request, response) => {
if (request.method === 'POST') {
let body = '';
for await (const chunk of request) {
body += chunk;
}
const data = JSON.parse(body);
assert.equal(
request.url,
'/projects/7/tickets/42/status?_method=PATCH',
);
assert.equal(data.redirect_to, 'board');
assert.ok([11, 20, 99].includes(Number(data.ticket_status_id)));
const previous = new URL(request.headers.referer);
const reject = previous.searchParams.has('reject');
response.writeHead(303, {
Location: `/projects/7/board?${reject ? 'reject&rejected' : `target=${data.ticket_status_id}`}`,
});
response.end();
return;
}
const page = makePage(request.url);
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.setHeader('X-Inertia', 'true');
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(page));
return;
}
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"><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/board-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log(
'Board fixture ready at http://127.0.0.1:4181/projects/7/board',
);
} else {
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { default: Board } = await server.ssrLoadModule(
'/resources/js/pages/projects/Board.vue',
);
const render = async (query = '') => {
const result = await createInertiaApp({
page: makePage(`/projects/7/board${query}`),
resolve: () => Board,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance('light')),
});
return result.body.split('id="main-content"')[1];
};
const board = await render();
assert.equal((board.match(/id="board-column-title-/g) ?? []).length, 8);
assert.equal((board.match(/id="board-status-/g) ?? []).length, 14);
assert.match(board, /Ready · Planned/);
assert.match(board, /Ready · Active/);
assert.match(board, /No statuses configured for this column/);
assert.match(board, /No tickets in this column/);
assert.match(board, /No tickets in this status/);
assert.match(board, /2 tickets/);
assert.match(board, /Urgent custom priority/);
assert.match(board, /href="\/projects\/7\/tickets\/42"/);
assert.match(
board,
/action="\/projects\/7\/tickets\/42\/status\?_method=PATCH"/,
);
assert.match(
board,
/href="\/projects\/7\/tickets\/create\?source=board"/,
);
assert.match(board, /href="\/projects\/7\/backlog"/);
assert.match(board, /No allowed moves/);
assert.equal((board.match(/<article/g) ?? []).length, 2);
const readonly = await render('?readonly');
assert.doesNotMatch(readonly, /<form|Move ENG-|New ticket/);
const empty = await render('?empty');
assert.match(empty, /No workflow columns yet/);
assert.doesNotMatch(empty, /id="board-column"|<article/);
const one = await render('?one&noTickets');
assert.equal((one.match(/id="board-column-title-/g) ?? []).length, 1);
assert.match(one, /0 tickets/);
assert.doesNotMatch(one, /<article/);
console.log('Board behavior checks passed.');
} finally {
await server.close();
}
}
+108
View File
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
const { chromium } = playwright.default ?? playwright;
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const base = 'http://127.0.0.1:4180';
const context = await browser.newContext({
viewport: { width: 1440, height: 1000 },
});
const page = await context.newPage();
page.setDefaultTimeout(10000);
const errors = [];
page.on('pageerror', (error) => errors.push(error.message));
const content = () => page.locator('#main-content');
const focusWithTab = async (link) => {
await content().focus();
for (let step = 0; step < 40; step++) {
await page.keyboard.press('Tab');
if (
await link.evaluate((element) => element === document.activeElement)
) {
assert.equal(
await link.evaluate((element) =>
element.matches(':focus-visible'),
),
true,
);
return;
}
}
assert.fail('Dashboard action was not reachable by keyboard');
};
try {
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',
});
for (const [path, state] of [
['/dashboard', 'filled'],
['/dashboard?empty', 'empty'],
]) {
await page.goto(base + path);
await page
.getByRole('heading', {
name: 'Welcome back, Alex Morgan',
exact: true,
level: 1,
})
.waitFor();
await page.evaluate(() => document.fonts.ready);
assert.equal(
await page
.locator('html')
.evaluate((element) =>
element.classList.contains('dark'),
),
theme === 'dark',
);
assert.equal(
await page.evaluate(
() =>
document.documentElement.scrollWidth <=
window.innerWidth,
),
true,
`${state} dashboard overflows at ${width} in ${theme}`,
);
await page.screenshot({
path: `/private/tmp/kan25-dashboard-${state}-${width}-${theme}.png`,
fullPage: true,
});
}
}
}
await page.setViewportSize({ width: 390, height: 1000 });
await page.goto(`${base}/dashboard`);
const ticket = content().getByRole('link', {
name: 'Open ENG-42: Ship the dashboard with real assigned work',
exact: true,
});
await focusWithTab(ticket);
await page.keyboard.press('Enter');
await page.waitForURL('**/projects/7/tickets/42');
await page
.getByRole('heading', { name: 'Destination reached', exact: true })
.waitFor();
await page.goto(`${base}/dashboard?empty`);
const projects = content().getByRole('link', {
name: 'Browse projects',
exact: true,
});
await focusWithTab(projects);
await page.keyboard.press('Enter');
await page.waitForURL('**/projects');
assert.deepEqual(errors, []);
console.log(
'Dashboard browser checks passed: keyboard, empty and filled states, 320/390/1440px and both themes.',
);
} finally {
await browser.close();
}
+19
View File
@@ -0,0 +1,19 @@
import { createApp, h } from 'vue';
import { createInertiaApp } from '@inertiajs/vue3';
import '../../resources/css/app.css';
import Dashboard from '../../resources/js/pages/Dashboard.vue';
import { createAppearance } from '../../resources/js/composables/useAppearance';
import { createLocalization } from '../../resources/js/lib/localization';
createInertiaApp({
resolve: (name) =>
({ Dashboard })[name] ?? {
render: () => h('h1', 'Destination reached'),
},
setup: ({ el, App, props, plugin }) =>
createApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance())
.mount(el),
});
+248
View File
@@ -0,0 +1,248 @@
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/dashboard-fixture',
plugins: [vue(), ...(serve ? [tailwind()] : [])],
resolve: {
alias: {
'@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
},
},
server: serve
? { host: '127.0.0.1', port: 4180, strictPort: true, hmr: false }
: { middlewareMode: true, hmr: false, watch: null },
appType: 'custom',
});
const user = {
id: 8,
name: 'Alex Morgan',
email: 'alex@example.test',
avatar: null,
avatar_color: '#22c55e',
avatar_is_generated: true,
email_verified_at: '2026-09-11T00:00:00Z',
must_change_password_on_next_login: false,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
};
const project = {
id: 7,
key: 'ENG',
name: 'Engineering · Platform and developer experience',
show_author: true,
show_story_points: true,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
};
const secondProject = {
...project,
id: 9,
key: 'OPS',
name: 'Operations',
};
const ticketDefaults = {
acceptance_criteria: [],
assignee: user,
assignee_user_id: user.id,
author: null,
author_user_id: null,
blocked_comment: null,
description: 'Real work loaded from the server.',
is_blocked: false,
parent_ticket_id: null,
story_points: 3,
ticket_priority: {
id: 1,
name: 'Medium',
position: 3,
project_id: 7,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
},
ticket_priority_id: 1,
ticket_status: {
id: 3,
board_column_id: 2,
name: 'In Progress',
position: 1,
project_id: 7,
slug: 'in-progress',
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
board_column: {
id: 2,
name: 'In Progress',
position: 2,
project_id: 7,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
},
},
ticket_status_id: 3,
ticket_type: {
id: 1,
is_parent: false,
name: 'Dev task',
position: 1,
project_id: 7,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
},
ticket_type_id: 1,
created_at: '2026-09-11T00:00:00Z',
updated_at: '2026-09-11T00:00:00Z',
};
const tickets = [
{
...ticketDefaults,
id: 42,
project_id: project.id,
sequence_number: 42,
ticket_key: 'ENG-42',
title: 'Ship the dashboard with real assigned work',
},
{
...ticketDefaults,
id: 41,
project_id: project.id,
sequence_number: 41,
ticket_key: 'ENG-41',
title: 'Verify keyboard navigation',
},
];
const blockedTicket = {
...ticketDefaults,
id: 12,
blocked_comment: 'Waiting for review.',
is_blocked: true,
project_id: secondProject.id,
sequence_number: 12,
ticket_key: 'OPS-12',
title: 'Resolve the deployment blocker',
};
const assignedTicketsByProject = [
{ project: secondProject, tickets: [blockedTicket] },
{ project, tickets },
];
function makePage(url) {
const parsedUrl = new URL(url, 'http://localhost');
const isDashboard = parsedUrl.pathname === '/dashboard';
return {
component: isDashboard ? 'Dashboard' : 'Destination',
url,
version: null,
clearHistory: false,
encryptHistory: false,
props: {
name: 'KanbanGit',
errors: {},
sidebarOpen: true,
auth: {
user,
permissions: {
globalAdmin: false,
projectCreate: false,
canManageTeams: false,
},
},
navigation: { project: null },
...(isDashboard
? {
assignedTicketsByProject: parsedUrl.searchParams.has(
'empty',
)
? []
: assignedTicketsByProject,
}
: {}),
},
};
}
if (serve) {
server.middlewares.use(async (request, response) => {
const page = makePage(request.url);
response.setHeader('Vary', 'X-Inertia');
if (request.headers['x-inertia']) {
response.setHeader('X-Inertia', 'true');
response.setHeader('Content-Type', 'application/json');
response.end(JSON.stringify(page));
return;
}
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"><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/dashboard-preview.mjs"></script></body></html>',
),
);
});
await server.listen();
console.log('Dashboard fixture ready at http://127.0.0.1:4180/dashboard');
} else {
try {
const { createLocalization } = await server.ssrLoadModule(
'/resources/js/lib/localization.ts',
);
const { createAppearance } = await server.ssrLoadModule(
'/resources/js/composables/useAppearance.ts',
);
const { default: Dashboard } = await server.ssrLoadModule(
'/resources/js/pages/Dashboard.vue',
);
const render = async (url) => {
const response = await createInertiaApp({
page: makePage(url),
resolve: () => Dashboard,
render: renderToString,
setup: ({ App, props, plugin }) =>
createSSRApp({ render: () => h(App, props) })
.use(plugin)
.use(createLocalization())
.use(createAppearance('light')),
});
return response.body.split('<main')[1].split('</main>')[0];
};
const dashboard = await render('/dashboard');
assert.match(dashboard, /Welcome back, Alex Morgan/);
assert.match(dashboard, /Assigned tickets/);
assert.match(dashboard, /Projects with assigned work/);
assert.match(dashboard, /Blocked tickets/);
assert.match(dashboard, /Ship the dashboard with real assigned work/);
assert.match(dashboard, /Resolve the deployment blocker/);
assert.match(dashboard, /href="\/projects\/7\/tickets\/42"/);
assert.match(dashboard, /href="\/projects\/9"/);
assert.doesNotMatch(
dashboard,
/API-integration|Finished this week|Recent activity|PlaceholderPattern/,
);
const empty = await render('/dashboard?empty');
assert.match(empty, /No tickets assigned to you/);
assert.match(empty, /href="\/projects"/);
assert.doesNotMatch(empty, /ENG-42|OPS-12|<article/);
console.log('Dashboard behavior checks passed.');
} finally {
await server.close();
}
}