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 @@
-
+
-
-
-
-
- {{ status }}
-
-
-
- No projects exist yet. Create the first one to start building
- out board, backlog, and settings areas.
-
-
-
-
-
{{
+ t('projects.create')
+ }}
-
-
-
- {{ project.name }}
-
- Project workspace entry point
-
-
+
+
-
- {{ project.key }}
-
-
-
+
-
+
+ {{
+ t('projects.empty.create')
+ }}
+
+
+
+
+
+ {{ t('projects.available', { count: projects.length }) }}
+
+
+
+
-
- Open board, backlog, and settings hub
-
-
-
-
+
+
+
+
+
+ {{ project.key }}
+
+ {{ project.name }}
+
+ {{ t('projects.overview') }}
+
+
+
+
+
+
+ {{
+ t('navigation.board')
+ }}
+
+
+
+
+ {{
+ t('navigation.backlog')
+ }}
+
+
+
+
+
+
+
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 @@
-
-
+
+
{{
+ t('projects.back')
+ }}
+
+
+
+
+ {{
+ t('projects.saveTemplate')
+ }}
+
+
-
-
-
- Built from template {{ templateState.templateName }}
-
-
- This project keeps its template link. Inherited areas
- follow the template baseline; overridden areas were
- customized locally.
-
-
-
+
+
+
+
+
+
+
{{ area.title }}
+
+ {{ area.description }}
+
+
+
+ {{ area.action
+ }}
+
+
+
+
+
+
+
+ {{ t('navigation.projectSettings') }}
+
+
+ {{ t('projects.settingsDescription') }}
+
+
+
+
+
+
+ {{ item.title }}
+
+
+
+
+
+
+
+
+
+
+
+ {{
+ t('projects.templateTitle', {
+ name: templateState.templateName,
+ })
+ }}
+
+
+ {{ t('projects.templateDescription') }}
+
+
+
+
- {{ area.label }} · inherited
+ {{ t('projects.inheritedArea', { name: area.label }) }}
- {{ area.label }} · overridden
+ {{ t('projects.overriddenArea', { name: area.label }) }}
-
-
-
-
-
-
- Board settings
-
-
-
-
-
- General settings
-
-
-
-
-
- Ticket settings
-
-
-
-
-
- Repository settings
-
-
-
-
-
- Notification settings
-
-
-
-
- Manage access
-
-
-
-
-
-
-
-
-
- {{ area.title }}
- {{
- area.description
- }}
-
-
- Use the project shortcuts above to open this area.
-
-
-
+
+
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('')) {
+ insideLink = false;
+ } else {
+ assert.equal(insideLink, false, 'Links must not be nested');
+ insideLink = true;
+ }
+ }
+ console.log('Project overview behavior checks passed.');
+ } finally {
+ await server.close();
+ }
+}