Merge pull request 'KAN-25 Personal dashboard' (#119) from feature/KAN-25-personal-dashboard into main
Reviewed-on: #119
This commit was merged in pull request #119.
This commit is contained in:
@@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject
|
|||||||
->where('assignee_user_id', $assignee->id)
|
->where('assignee_user_id', $assignee->id)
|
||||||
->whereIn('project_id', $visibleProjectIds)
|
->whereIn('project_id', $visibleProjectIds)
|
||||||
->with([
|
->with([
|
||||||
|
'acceptanceCriteria',
|
||||||
'assignee',
|
'assignee',
|
||||||
'project',
|
|
||||||
'ticketPriority',
|
'ticketPriority',
|
||||||
'ticketStatus.boardColumn',
|
'ticketStatus.boardColumn',
|
||||||
'ticketType',
|
'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),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -118,6 +118,24 @@ export const en = {
|
|||||||
'Inherited areas follow the linked template. Overridden areas use this project’s own configuration.',
|
'Inherited areas follow the linked template. Overridden areas use this project’s own configuration.',
|
||||||
'projects.inheritedArea': '{name} · inherited',
|
'projects.inheritedArea': '{name} · inherited',
|
||||||
'projects.overriddenArea': '{name} · overridden',
|
'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.home': 'KanbanGit home',
|
||||||
'auth.accountAccess': 'Account access',
|
'auth.accountAccess': 'Account access',
|
||||||
'auth.backToLogin': 'Return to sign in',
|
'auth.backToLogin': 'Return to sign in',
|
||||||
|
|||||||
@@ -1,47 +1,211 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { Head } from '@inertiajs/vue3';
|
import { Head, Link } from '@inertiajs/vue3';
|
||||||
import PlaceholderPattern from '@/components/PlaceholderPattern.vue';
|
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 AppLayout from '@/layouts/AppLayout.vue';
|
||||||
import { dashboard } from '@/routes/index';
|
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',
|
label: t('dashboard.summary.assignedTickets'),
|
||||||
href: dashboard(),
|
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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Head title="Dashboard" />
|
|
||||||
|
|
||||||
<AppLayout :breadcrumbs="breadcrumbs">
|
<AppLayout :breadcrumbs="breadcrumbs">
|
||||||
<div
|
<Head :title="t('navigation.dashboard')" />
|
||||||
class="flex h-full flex-1 flex-col gap-4 overflow-x-auto rounded-xl p-4"
|
|
||||||
>
|
<div class="gap-page md:p-page flex min-w-0 flex-1 flex-col p-4">
|
||||||
<div class="grid auto-rows-min gap-4 md:grid-cols-3">
|
<header class="gap-stack grid min-w-0">
|
||||||
<div
|
<p class="text-label text-muted-foreground uppercase">
|
||||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
{{ t('dashboard.eyebrow') }}
|
||||||
>
|
</p>
|
||||||
<PlaceholderPattern />
|
<h1 class="text-headline-mobile md:text-headline break-words">
|
||||||
</div>
|
{{
|
||||||
<div
|
t('dashboard.greeting', {
|
||||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
name: $page.props.auth.user.name,
|
||||||
>
|
})
|
||||||
<PlaceholderPattern />
|
}}
|
||||||
</div>
|
</h1>
|
||||||
<div
|
<p class="text-muted-foreground max-w-2xl">
|
||||||
class="border-sidebar-border/70 dark:border-sidebar-border relative aspect-video overflow-hidden rounded-xl border"
|
{{ t('dashboard.description') }}
|
||||||
>
|
</p>
|
||||||
<PlaceholderPattern />
|
</header>
|
||||||
</div>
|
|
||||||
</div>
|
<section :aria-label="t('dashboard.summary')">
|
||||||
<div
|
<ul class="grid gap-3 sm:grid-cols-3">
|
||||||
class="border-sidebar-border/70 dark:border-sidebar-border relative min-h-[100vh] flex-1 rounded-xl border md:min-h-min"
|
<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 />
|
<header class="gap-stack grid min-w-0">
|
||||||
</div>
|
<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>
|
</div>
|
||||||
</AppLayout>
|
</AppLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -39,6 +39,15 @@ export type ProjectDuplicateCopyAreaOption = {
|
|||||||
value: string;
|
value: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AssignedTicketsProjectGroup = {
|
||||||
|
project: ProjectModel;
|
||||||
|
tickets: TicketModel[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type DashboardPageProps = {
|
||||||
|
assignedTicketsByProject: AssignedTicketsProjectGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
export type ProjectsIndexPageProps = {
|
export type ProjectsIndexPageProps = {
|
||||||
projects: ProjectModel[];
|
projects: ProjectModel[];
|
||||||
status?: string | null;
|
status?: string | null;
|
||||||
|
|||||||
+2
-1
@@ -1,5 +1,6 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
|
use App\Http\Controllers\DashboardController;
|
||||||
use Illuminate\Http\Response;
|
use Illuminate\Http\Response;
|
||||||
use Illuminate\Support\Facades\File;
|
use Illuminate\Support\Facades\File;
|
||||||
use Illuminate\Support\Facades\Route;
|
use Illuminate\Support\Facades\Route;
|
||||||
@@ -17,7 +18,7 @@ Route::get('api/docs/openapi.json', function (): Response {
|
|||||||
})->name('api.docs.openapi');
|
})->name('api.docs.openapi');
|
||||||
|
|
||||||
Route::middleware(['auth', 'verified'])->group(function () {
|
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/admin.php';
|
||||||
require __DIR__.'/web/project-templates.php';
|
require __DIR__.'/web/project-templates.php';
|
||||||
|
|||||||
@@ -2,26 +2,117 @@
|
|||||||
|
|
||||||
namespace Tests\Feature;
|
namespace Tests\Feature;
|
||||||
|
|
||||||
|
use App\Models\Project;
|
||||||
|
use App\Models\Team;
|
||||||
|
use App\Models\Ticket;
|
||||||
use App\Models\User;
|
use App\Models\User;
|
||||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||||
|
use Inertia\Testing\AssertableInertia as Assert;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
use Tests\TestCase;
|
use Tests\TestCase;
|
||||||
|
|
||||||
class DashboardTest extends TestCase
|
class DashboardTest extends TestCase
|
||||||
{
|
{
|
||||||
use RefreshDatabase;
|
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 = $this->get(route('dashboard'));
|
||||||
$response->assertRedirect(route('login'));
|
$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();
|
$user = User::factory()->create();
|
||||||
$this->actingAs($user);
|
$this->actingAs($user);
|
||||||
|
|
||||||
$response = $this->get(route('dashboard'));
|
$this->get(route('dashboard'))
|
||||||
$response->assertOk();
|
->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,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();
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
});
|
||||||
@@ -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('&', '&')
|
||||||
|
.replaceAll('"', '"')
|
||||||
|
.replaceAll('<', '<');
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user