From d34216f1b8e58929b63694a714af21717391a3fd Mon Sep 17 00:00:00 2001 From: Twyco Date: Thu, 10 Sep 2026 16:12:32 +0200 Subject: [PATCH] feat(KAN-23): rebuild project overview and entry pages --- resources/js/locales/en.ts | 37 ++ resources/js/pages/projects/Index.vue | 235 ++++++++---- resources/js/pages/projects/Show.vue | 351 ++++++++++-------- .../Feature/Projects/ProjectOverviewTest.php | 84 +++++ tests/Fixtures/project-overview-browser.mjs | 167 +++++++++ tests/Fixtures/project-overview-preview.mjs | 20 + tests/Fixtures/project-overview.mjs | 263 +++++++++++++ 7 files changed, 915 insertions(+), 242 deletions(-) create mode 100644 tests/Feature/Projects/ProjectOverviewTest.php create mode 100644 tests/Fixtures/project-overview-browser.mjs create mode 100644 tests/Fixtures/project-overview-preview.mjs create mode 100644 tests/Fixtures/project-overview.mjs diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts index e3652e2..e687c1f 100644 --- a/resources/js/locales/en.ts +++ b/resources/js/locales/en.ts @@ -1,4 +1,41 @@ export const en = { + 'projects.workspace': 'Workspace', + 'projects.description': + 'Your project spaces, ready to work in. Open a project or jump straight to its board and backlog.', + 'projects.create': 'New project', + 'projects.updated': 'Project update', + 'projects.available': { + one: '{count} project available', + other: '{count} projects available', + }, + 'projects.open': 'Open {name} overview', + 'projects.openBoard': 'Open {name} board', + 'projects.openBacklog': 'Open {name} backlog', + 'projects.overview': 'Project overview', + 'projects.empty.title': 'No projects to show', + 'projects.empty.createDescription': + 'Create a project to give your team a place to plan and track work.', + 'projects.empty.accessDescription': + 'Projects appear here when you have access. Ask a workspace or project administrator to add you.', + 'projects.empty.create': 'Create your first project', + 'projects.back': 'All projects', + 'projects.hubDescription': + 'Follow work on the board or explore tickets in the backlog.', + 'projects.workAreas': 'Project work areas', + 'projects.boardDescription': + 'See work across the project’s workflow and open tickets in their current status.', + 'projects.backlogDescription': + 'Explore incoming work, review ticket details, and plan what comes next.', + 'projects.boardAction': 'Open board', + 'projects.backlogAction': 'Open backlog', + 'projects.settingsDescription': + 'Manage this project’s configuration, integrations, and access.', + 'projects.saveTemplate': 'Save as new template', + 'projects.templateTitle': 'Based on {name}', + 'projects.templateDescription': + 'Inherited areas follow the linked template. Overridden areas use this project’s own configuration.', + 'projects.inheritedArea': '{name} · inherited', + 'projects.overriddenArea': '{name} · overridden', 'auth.home': 'KanbanGit home', 'auth.accountAccess': 'Account access', 'auth.backToLogin': 'Return to sign in', diff --git a/resources/js/pages/projects/Index.vue b/resources/js/pages/projects/Index.vue index 213f17c..b90b4b0 100644 --- a/resources/js/pages/projects/Index.vue +++ b/resources/js/pages/projects/Index.vue @@ -1,103 +1,184 @@ diff --git a/resources/js/pages/projects/Show.vue b/resources/js/pages/projects/Show.vue index fb5c612..96cbb28 100644 --- a/resources/js/pages/projects/Show.vue +++ b/resources/js/pages/projects/Show.vue @@ -1,206 +1,227 @@ diff --git a/tests/Feature/Projects/ProjectOverviewTest.php b/tests/Feature/Projects/ProjectOverviewTest.php new file mode 100644 index 0000000..da6d967 --- /dev/null +++ b/tests/Feature/Projects/ProjectOverviewTest.php @@ -0,0 +1,84 @@ +create(); + Project::factory()->create(['name' => 'Private workspace']); + + $this->actingAs($user)->get(route('projects.index')) + ->assertOk()->assertInertia(fn (Assert $page) => $page + ->component('projects/Index') + ->has('projects', 0) + ->where('auth.permissions.projectCreate', false)); + + $this->syncGlobalPermissions($user, ['project:create']); + $this->get(route('projects.index')) + ->assertOk()->assertInertia(fn (Assert $page) => $page + ->has('projects', 0) + ->where('auth.permissions.projectCreate', true)); + } + + #[DataProvider('projectRoles')] + public function test_project_entry_actions_follow_effective_permissions(string $role, bool $teamAccess, bool $canManage): void + { + $user = User::factory()->create(); + $project = Project::factory()->create(); + $assignee = $user; + if ($teamAccess) { + $assignee = Team::factory()->create(); + $assignee->users()->attach($user); + } + $this->syncProjectPermissions($project, $assignee, [$role]); + + $this->actingAs($user)->get(route('projects.show', $project)) + ->assertOk()->assertInertia(fn (Assert $page) => $page + ->component('projects/Show') + ->where('project.id', $project->id) + ->where('canManageAccess', $canManage) + ->where('auth.permissions.projectCreate', false)); + + foreach (['projects.board.index', 'projects.backlog.index'] as $route) { + $this->get(route($route, $project))->assertOk(); + } + foreach (['projects.settings.general', 'projects.settings.board', 'projects.settings.metadata', 'projects.settings.repositories', 'projects.settings.notifications', 'projects.access'] as $route) { + $this->get(route($route, $project))->assertStatus($canManage ? 200 : 403); + } + } + + /** @return array */ + public static function projectRoles(): array + { + return [ + 'reader' => ['read', false, false], + 'writer' => ['write', false, false], + 'project admin' => ['admin', false, true], + 'team reader' => ['read', true, false], + 'team admin' => ['admin', true, true], + ]; + } + + public function test_project_overview_rendering_and_actions(): void + { + $process = new Process(['node', 'tests/Fixtures/project-overview.mjs'], base_path()); + $process->setTimeout(60); + $process->run(); + + $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput()); + $this->assertStringContainsString('Project overview behavior checks passed.', $process->getOutput()); + } +} diff --git a/tests/Fixtures/project-overview-browser.mjs b/tests/Fixtures/project-overview-browser.mjs new file mode 100644 index 0000000..58ea608 --- /dev/null +++ b/tests/Fixtures/project-overview-browser.mjs @@ -0,0 +1,167 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; + +const { chromium } = await import( + process.env.PLAYWRIGHT_MODULE ?? 'playwright' +); +const browser = await chromium.launch({ channel: 'chrome', headless: true }); +const base = 'http://127.0.0.1:4178'; +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 setRole = (value) => + context.addCookies([{ name: 'project_test_role', value, url: base }]); +const content = () => page.locator('#main-content'); +const focusWithTab = async (link) => { + await content().focus(); + for (let step = 0; step < 30; 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('Project 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', + }); + await setRole('admin'); + for (const [path, title, label] of [ + ['/projects', 'Projects', 'index'], + [ + '/projects/7?template', + 'Engineering · Platform and developer experience', + 'hub', + ], + ['/projects?empty', 'Projects', 'empty'], + ]) { + await page.goto(base + path); + await page + .getByRole('heading', { + name: title, + 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, + `${label} overflows at ${width} in ${theme}`, + ); + assert.equal( + await content().locator('a a, a button').count(), + 0, + ); + await page.screenshot({ + path: `/private/tmp/kan23-${label}-${width}-${theme}.png`, + fullPage: true, + }); + } + await page.goto(`${base}/projects/7`); + const board = content().getByRole('link', { + name: 'Open board', + exact: true, + }); + await focusWithTab(board); + await page.keyboard.press('Enter'); + await page.waitForURL('**/projects/7/board'); + await page + .getByRole('heading', { name: 'Destination reached' }) + .waitFor(); + } + } + await setRole('reader'); + await page.goto(`${base}/projects`); + assert.equal( + await content() + .getByRole('link', { name: 'New project', exact: true }) + .count(), + 0, + ); + const overview = content().getByRole('link', { + name: 'Open Engineering · Platform and developer experience overview', + exact: true, + }); + await focusWithTab(overview); + await page.keyboard.press('Enter'); + await page.waitForURL('**/projects/7'); + assert.equal( + await content() + .locator( + 'a[href*="/settings/"], a[href$="/access"], a[href*="/templates/"]', + ) + .count(), + 0, + ); + await content() + .getByRole('link', { name: 'Open backlog', exact: true }) + .click(); + await page.waitForURL('**/projects/7/backlog'); + + await setRole('admin'); + await page.goto(`${base}/projects/7`); + const settings = content().getByRole('region', { + name: 'Project settings', + exact: true, + }); + assert.equal(await settings.getByRole('link').count(), 6); + const general = settings.getByRole('link', { + name: 'General', + exact: true, + }); + await focusWithTab(general); + await page.keyboard.press('Enter'); + await page.waitForURL('**/projects/7/settings/general'); + + await setRole('creator'); + await page.goto(`${base}/projects?empty`); + const create = content().getByRole('link', { + name: 'Create your first project', + exact: true, + }); + await focusWithTab(create); + await page.keyboard.press('Enter'); + await page.waitForURL('**/projects/create'); + await page.goto(`${base}/projects/7`); + await content() + .getByRole('link', { name: 'Save as new template', exact: true }) + .click(); + await page.waitForURL('**/projects/7/templates/create'); + assert.deepEqual(errors, []); + console.log( + 'Project overview browser checks passed: keyboard, navigation, permissions, empty states, 320/390/1440px and both themes.', + ); +} finally { + await browser.close(); +} diff --git a/tests/Fixtures/project-overview-preview.mjs b/tests/Fixtures/project-overview-preview.mjs new file mode 100644 index 0000000..cb15c21 --- /dev/null +++ b/tests/Fixtures/project-overview-preview.mjs @@ -0,0 +1,20 @@ +import { createApp, h } from 'vue'; +import { createInertiaApp } from '@inertiajs/vue3'; +import '../../resources/css/app.css'; +import Index from '../../resources/js/pages/projects/Index.vue'; +import Show from '../../resources/js/pages/projects/Show.vue'; +import { createAppearance } from '../../resources/js/composables/useAppearance'; +import { createLocalization } from '../../resources/js/lib/localization'; + +createInertiaApp({ + resolve: (name) => + ({ Index, Show })[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/project-overview.mjs b/tests/Fixtures/project-overview.mjs new file mode 100644 index 0000000..16dd413 --- /dev/null +++ b/tests/Fixtures/project-overview.mjs @@ -0,0 +1,263 @@ +import assert from 'node:assert/strict'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import vue from '@vitejs/plugin-vue'; +import tailwind from '@tailwindcss/vite'; +import { createServer } from 'vite'; +import { createSSRApp, h } from 'vue'; +import { renderToString } from 'vue/server-renderer'; +import { createInertiaApp } from '@inertiajs/vue3'; + +const serve = process.argv.includes('--serve'); +const server = await createServer({ + configFile: false, + cacheDir: 'node_modules/.vite/project-overview-fixture', + plugins: [vue(), ...(serve ? [tailwind()] : [])], + resolve: { + alias: { + '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), + }, + }, + server: serve + ? { host: '127.0.0.1', port: 4178, 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 secondProject = { id: 8, key: 'OPS', name: 'Operations' }; +const templateState = { + inheritsFromTemplate: true, + templateName: 'Team baseline', + inheritedAreas: [{ value: 'board_columns', label: 'Board columns' }], + overriddenAreas: [{ value: 'ticket_types', label: 'Ticket types' }], +}; +function makePage( + url, + { + manage = false, + create = false, + empty = false, + template = false, + status = null, + } = {}, +) { + const path = new URL(url, 'http://localhost').pathname; + const isIndex = path === '/projects'; + const isShow = path === '/projects/7'; + return { + component: isIndex ? 'Index' : isShow ? 'Show' : 'Destination', + url, + version: null, + clearHistory: false, + encryptHistory: false, + props: { + name: 'KanbanGit', + errors: {}, + sidebarOpen: true, + auth: { + user: { + id: 1, + name: 'Alex Morgan', + email: 'alex@example.test', + avatar: null, + }, + permissions: { + globalAdmin: false, + projectCreate: create, + canManageTeams: false, + }, + }, + navigation: { + project: isIndex + ? null + : { + ...project, + canManage: manage, + canCreateTickets: manage, + }, + }, + ...(isIndex + ? { projects: empty ? [] : [project, secondProject], status } + : { + project, + canManageAccess: manage, + templateState: template ? templateState : null, + }), + }, + }; +} + +if (serve) { + // Isolated transport for real Vue pages. PHPUnit covers actual permissions and resources. + server.middlewares.use(async (request, response, next) => { + const url = new URL(request.url, 'http://localhost'); + if (!url.pathname.startsWith('/projects')) { + return next(); + } + const role = request.headers.cookie?.match( + /project_test_role=(\w+)/, + )?.[1]; + const page = makePage(request.url, { + manage: role === 'admin', + create: role === 'creator', + empty: url.searchParams.has('empty'), + template: url.searchParams.has('template'), + }); + 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( + 'Project overview fixture ready at http://127.0.0.1:4178/projects', + ); +} else { + try { + const { createLocalization } = await server.ssrLoadModule( + '/resources/js/lib/localization.ts', + ); + const { createAppearance } = await server.ssrLoadModule( + '/resources/js/composables/useAppearance.ts', + ); + const { en } = await server.ssrLoadModule( + '/resources/js/locales/en.ts', + ); + const pages = Object.fromEntries( + await Promise.all( + ['Index', 'Show'].map(async (name) => [ + name, + ( + await server.ssrLoadModule( + `/resources/js/pages/projects/${name}.vue`, + ) + ).default, + ]), + ), + ); + const render = async ( + url, + options = {}, + locale = createLocalization(), + ) => { + const response = await createInertiaApp({ + page: makePage(url, options), + resolve: (name) => pages[name], + render: renderToString, + setup: ({ App, props, plugin }) => + createSSRApp({ render: () => h(App, props) }) + .use(plugin) + .use(locale) + .use(createAppearance('light')), + }); + return response.body.split('')[0]; + }; + const index = await render('/projects'); + assert.match(index, /2 projects available/); + for (const id of [7, 8]) { + for (const suffix of ['', '/board', '/backlog']) { + assert.ok(index.includes(`href="/projects/${id}${suffix}"`)); + } + } + assert.doesNotMatch( + index, + /href="\/projects\/create"|upcoming|will later/, + ); + const empty = await render('/projects', { empty: true }); + assert.match(empty, /No projects to show/); + assert.match(empty, /Ask a workspace or project administrator/); + assert.doesNotMatch(empty, /href="\/projects\/create"|
]*data-feedback="success"/); + assert.match(creator, /Project deleted\./); + const reader = await render('/projects/7'); + assert.match(reader, /aria-label="Open board"/); + assert.match(reader, /aria-label="Open backlog"/); + assert.doesNotMatch( + reader, + /href="\/projects\/7\/(settings|access|templates)/, + ); + const admin = await render('/projects/7', { + manage: true, + template: true, + }); + for (const suffix of [ + 'settings/general', + 'settings/board', + 'settings/metadata', + 'settings/repositories', + 'settings/notifications', + 'access', + ]) { + assert.ok(admin.includes(`href="/projects/7/${suffix}"`)); + } + assert.doesNotMatch(admin, /href="\/projects\/7\/templates/); + assert.match(admin, /Based on Team baseline/); + assert.match(admin, /Board columns · inherited/); + assert.match(admin, /Ticket types · overridden/); + assert.match( + await render('/projects/7', { create: true }), + /href="\/projects\/7\/templates\/create"/, + ); + const translated = await render( + '/projects', + {}, + createLocalization( + { locale: 'de' }, + { + de: { + ...en, + 'navigation.projects': 'Projekte', + 'projects.overview': 'Projektübersicht', + }, + }, + ), + ); + assert.match(translated, /Projekte/); + assert.match(translated, /Projektübersicht/); + assert.doesNotMatch( + index + reader + admin, + /upcoming|will later|Use the project shortcuts/, + ); + let insideLink = false; + for (const [tag] of (index + reader + admin).matchAll( + /<\/?a\b[^>]*>/g, + )) { + if (tag.startsWith('