From d0e651809e5257aaf8b79aa4f4dd4daa8151b2ea Mon Sep 17 00:00:00 2001 From: Twyco Date: Fri, 11 Sep 2026 11:00:47 +0200 Subject: [PATCH] feat(KAN-25): build personal dashboard --- .../ListAssignedTicketsByVisibleProject.php | 2 +- app/Http/Controllers/DashboardController.php | 24 ++ resources/js/locales/en.ts | 18 ++ resources/js/pages/Dashboard.vue | 230 +++++++++++++--- resources/js/types/page-props.ts | 9 + routes/web.php | 3 +- tests/Feature/DashboardTest.php | 99 ++++++- tests/Fixtures/dashboard-browser.mjs | 108 ++++++++ tests/Fixtures/dashboard-preview.mjs | 19 ++ tests/Fixtures/dashboard.mjs | 248 ++++++++++++++++++ 10 files changed, 721 insertions(+), 39 deletions(-) create mode 100644 app/Http/Controllers/DashboardController.php create mode 100644 tests/Fixtures/dashboard-browser.mjs create mode 100644 tests/Fixtures/dashboard-preview.mjs create mode 100644 tests/Fixtures/dashboard.mjs diff --git a/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php b/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php index de2faf1..15d052a 100644 --- a/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php +++ b/app/Actions/Tickets/ListAssignedTicketsByVisibleProject.php @@ -41,8 +41,8 @@ class ListAssignedTicketsByVisibleProject ->where('assignee_user_id', $assignee->id) ->whereIn('project_id', $visibleProjectIds) ->with([ + 'acceptanceCriteria', 'assignee', - 'project', 'ticketPriority', 'ticketStatus.boardColumn', 'ticketType', diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php new file mode 100644 index 0000000..63b887a --- /dev/null +++ b/app/Http/Controllers/DashboardController.php @@ -0,0 +1,24 @@ +user(); + + return Inertia::render('Dashboard', [ + 'assignedTicketsByProject' => $this->listAssignedTicketsByVisibleProject->handle($user, $user), + ]); + } +} diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts index eb32394..5e11e4a 100644 --- a/resources/js/locales/en.ts +++ b/resources/js/locales/en.ts @@ -118,6 +118,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', diff --git a/resources/js/pages/Dashboard.vue b/resources/js/pages/Dashboard.vue index 7b3a935..611d124 100644 --- a/resources/js/pages/Dashboard.vue +++ b/resources/js/pages/Dashboard.vue @@ -1,47 +1,211 @@ diff --git a/resources/js/types/page-props.ts b/resources/js/types/page-props.ts index 00e8ee2..e6fb2f9 100644 --- a/resources/js/types/page-props.ts +++ b/resources/js/types/page-props.ts @@ -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; diff --git a/routes/web.php b/routes/web.php index 7e87613..ffb392a 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,5 +1,6 @@ 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'; diff --git a/tests/Feature/DashboardTest.php b/tests/Feature/DashboardTest.php index 10a9671..bc4033c 100644 --- a/tests/Feature/DashboardTest.php +++ b/tests/Feature/DashboardTest.php @@ -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()); } } diff --git a/tests/Fixtures/dashboard-browser.mjs b/tests/Fixtures/dashboard-browser.mjs new file mode 100644 index 0000000..67a98af --- /dev/null +++ b/tests/Fixtures/dashboard-browser.mjs @@ -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(); +} diff --git a/tests/Fixtures/dashboard-preview.mjs b/tests/Fixtures/dashboard-preview.mjs new file mode 100644 index 0000000..ae81432 --- /dev/null +++ b/tests/Fixtures/dashboard-preview.mjs @@ -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), +}); diff --git a/tests/Fixtures/dashboard.mjs b/tests/Fixtures/dashboard.mjs new file mode 100644 index 0000000..8a9b8b3 --- /dev/null +++ b/tests/Fixtures/dashboard.mjs @@ -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, + '
', + ), + ); + }); + 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('')[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|