From f3399808c5405ef0c7ced5c5eb3c5f91a3ae42c8 Mon Sep 17 00:00:00 2001 From: ericasch6 Date: Wed, 16 Sep 2026 09:42:45 +0200 Subject: [PATCH] feat: add public entry page for KAN-50 --- resources/js/locales/en.ts | 34 ++ resources/js/pages/Welcome.vue | 633 ++++++++++------------------- tests/Feature/WelcomeTest.php | 45 ++ tests/Fixtures/welcome-browser.mjs | 138 +++++++ tests/Fixtures/welcome-preview.mjs | 19 + tests/Fixtures/welcome.mjs | 147 +++++++ 6 files changed, 604 insertions(+), 412 deletions(-) create mode 100644 tests/Feature/WelcomeTest.php create mode 100644 tests/Fixtures/welcome-browser.mjs create mode 100644 tests/Fixtures/welcome-preview.mjs create mode 100644 tests/Fixtures/welcome.mjs diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts index f2a434b..90beac5 100644 --- a/resources/js/locales/en.ts +++ b/resources/js/locales/en.ts @@ -497,6 +497,40 @@ 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', + 'welcome.metaTitle': 'Project work with Git context', + 'welcome.homeLabel': 'KanbanGit home', + 'welcome.navigation': 'Account navigation', + 'welcome.eyebrow': 'Self-hosted project management', + 'welcome.title': 'Plan the work. Keep Git context close.', + 'welcome.description': + 'Plan projects on configurable boards, keep ticket decisions visible, and connect delivery work to the repositories your team already uses.', + 'welcome.action.signIn': 'Sign in to KanbanGit', + 'welcome.action.signInShort': 'Sign in', + 'welcome.action.dashboard': 'Open dashboard', + 'welcome.action.dashboardShort': 'Dashboard', + 'welcome.actionHint': 'One workspace for boards, tickets, and Git context.', + 'welcome.preview.label': 'KanbanGit board preview', + 'welcome.preview.project': 'Product delivery board', + 'welcome.preview.connected': 'Git connected', + 'welcome.preview.backlog': 'Backlog', + 'welcome.preview.inProgress': 'In progress', + 'welcome.preview.review': 'Review', + 'welcome.preview.ticketOnboarding': 'Refine onboarding flow', + 'welcome.preview.ticketRelease': 'Plan release workflow', + 'welcome.preview.ticketRepository': 'Connect project repository', + 'welcome.preview.ticketPullRequest': 'Review pull request context', + 'welcome.features.eyebrow': 'A focused workflow', + 'welcome.features.title': 'The project context your team needs', + 'welcome.features.workflows.title': 'Configurable workflows', + 'welcome.features.workflows.description': + 'Shape project boards, backlog, statuses, and transitions around the way your team works.', + 'welcome.features.tickets.title': 'Structured ticket context', + 'welcome.features.tickets.description': + 'Keep metadata, acceptance criteria, relationships, blockers, and history with the work.', + 'welcome.features.git.title': 'Repository-aware delivery', + 'welcome.features.git.description': + 'Link project repositories and see matching branches and pull requests on the ticket.', + 'welcome.footer': 'Self-hosted project and workflow management.', 'dashboard.eyebrow': 'Personal workspace', 'dashboard.greeting': 'Welcome back, {name}', 'dashboard.description': diff --git a/resources/js/pages/Welcome.vue b/resources/js/pages/Welcome.vue index 1ebd161..88e9bfd 100644 --- a/resources/js/pages/Welcome.vue +++ b/resources/js/pages/Welcome.vue @@ -1,436 +1,245 @@ diff --git a/tests/Feature/WelcomeTest.php b/tests/Feature/WelcomeTest.php new file mode 100644 index 0000000..2946e66 --- /dev/null +++ b/tests/Feature/WelcomeTest.php @@ -0,0 +1,45 @@ +get(route('home')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('Welcome') + ->where('auth.user', null)); + } + + public function test_public_entry_page_recognizes_authenticated_users(): void + { + $user = User::factory()->create(); + + $this->actingAs($user) + ->get(route('home')) + ->assertOk() + ->assertInertia(fn (Assert $page) => $page + ->component('Welcome') + ->where('auth.user.id', $user->id)); + } + + public function test_public_entry_page_presentation_and_localization(): void + { + $process = new Process(['node', 'tests/Fixtures/welcome.mjs'], base_path()); + $process->setTimeout(60); + $process->run(); + + $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput()); + $this->assertStringContainsString('Welcome behavior checks passed.', $process->getOutput()); + } +} diff --git a/tests/Fixtures/welcome-browser.mjs b/tests/Fixtures/welcome-browser.mjs new file mode 100644 index 0000000..2fe70d9 --- /dev/null +++ b/tests/Fixtures/welcome-browser.mjs @@ -0,0 +1,138 @@ +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:4192'; +const context = await browser.newContext(); +const page = await context.newPage(); +page.setDefaultTimeout(10000); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); + +const focusWithTab = async (link) => { + await page.locator('body').focus(); + for (let step = 0; step < 12; 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('Primary welcome 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, action] of [ + ['/', 'Sign in to KanbanGit'], + ['/?auth=1', 'Open dashboard'], + ]) { + await page.goto(base + path); + await page + .getByRole('heading', { + name: 'Plan the work. Keep Git context close.', + 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, + `${path} overflows at ${width}px in ${theme} mode`, + ); + const clippedElements = await page + .locator( + 'header a, main h1, main p, main section, main article, main li, footer p', + ) + .evaluateAll((elements) => + elements + .filter((element) => { + const bounds = element.getBoundingClientRect(); + return ( + bounds.width > 0 && + (bounds.left < -0.5 || + bounds.right > window.innerWidth + 0.5) + ); + }) + .map((element) => element.outerHTML.slice(0, 120)), + ); + assert.deepEqual( + clippedElements, + [], + `${path} clips content at ${width}px in ${theme} mode`, + ); + assert.equal(await page.locator('main h1').count(), 1); + const primaryAction = page + .locator('main') + .getByRole('link', { name: action, exact: true }); + assert.ok( + (await primaryAction.evaluate( + (element) => element.getBoundingClientRect().height, + )) >= 44, + ); + await page.screenshot({ + path: `/private/tmp/kan50-welcome-${path.includes('auth') ? 'authenticated' : 'guest'}-${width}-${theme}.png`, + fullPage: true, + }); + } + } + } + + await page.setViewportSize({ width: 390, height: 1000 }); + await page.goto(`${base}/`); + const signIn = page + .locator('main') + .getByRole('link', { name: 'Sign in to KanbanGit', exact: true }); + await focusWithTab(signIn); + await page.keyboard.press('Enter'); + await page.waitForURL('**/login'); + await page + .getByRole('heading', { name: 'Destination reached', exact: true }) + .waitFor(); + + await page.goto(`${base}/?auth=1`); + const dashboard = page + .locator('main') + .getByRole('link', { name: 'Open dashboard', exact: true }); + await focusWithTab(dashboard); + await page.keyboard.press('Enter'); + await page.waitForURL('**/dashboard'); + await page + .getByRole('heading', { name: 'Destination reached', exact: true }) + .waitFor(); + + assert.deepEqual(errors, []); + console.log( + 'Welcome browser checks passed: keyboard, guest and authenticated actions, 320/390/1440px and both themes.', + ); +} finally { + await browser.close(); +} diff --git a/tests/Fixtures/welcome-preview.mjs b/tests/Fixtures/welcome-preview.mjs new file mode 100644 index 0000000..e4cb388 --- /dev/null +++ b/tests/Fixtures/welcome-preview.mjs @@ -0,0 +1,19 @@ +import { createApp, h } from 'vue'; +import { createInertiaApp } from '@inertiajs/vue3'; +import '../../resources/css/app.css'; +import Welcome from '../../resources/js/pages/Welcome.vue'; +import { createAppearance } from '../../resources/js/composables/useAppearance'; +import { createLocalization } from '../../resources/js/lib/localization'; + +createInertiaApp({ + resolve: (name) => + ({ Welcome })[name] ?? { + render: () => h('main', [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/welcome.mjs b/tests/Fixtures/welcome.mjs new file mode 100644 index 0000000..d508a4a --- /dev/null +++ b/tests/Fixtures/welcome.mjs @@ -0,0 +1,147 @@ +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/welcome-fixture', + plugins: [vue(), ...(serve ? [tailwind()] : [])], + resolve: { + alias: { + '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), + }, + }, + server: serve + ? { host: '127.0.0.1', port: 4192, strictPort: true, hmr: false } + : { middlewareMode: true, hmr: false, watch: null }, + appType: 'custom', +}); + +const user = { + id: 8, + name: 'Alex Morgan', + email: 'alex@example.test', +}; + +function makePage(url) { + const parsedUrl = new URL(url, 'http://localhost'); + const isWelcome = parsedUrl.pathname === '/'; + const isAuthenticated = + isWelcome && parsedUrl.searchParams.get('auth') === '1'; + + return { + component: isWelcome ? 'Welcome' : 'Destination', + url, + version: null, + clearHistory: false, + encryptHistory: false, + props: { + name: 'KanbanGit', + errors: {}, + auth: { + user: isAuthenticated ? user : null, + }, + }, + }; +} + +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('Welcome fixture ready at http://127.0.0.1:4192/'); +} else { + try { + const { createAppearance } = await server.ssrLoadModule( + '/resources/js/composables/useAppearance.ts', + ); + const { createLocalization } = await server.ssrLoadModule( + '/resources/js/lib/localization.ts', + ); + const { en } = await server.ssrLoadModule( + '/resources/js/locales/en.ts', + ); + const { default: Welcome } = await server.ssrLoadModule( + '/resources/js/pages/Welcome.vue', + ); + + const render = async (url, localization = createLocalization()) => { + const response = await createInertiaApp({ + page: makePage(url), + resolve: () => Welcome, + render: renderToString, + setup: ({ App, props, plugin }) => + createSSRApp({ render: () => h(App, props) }) + .use(plugin) + .use(localization) + .use(createAppearance('light')), + }); + return response.body; + }; + + const guest = await render('/'); + assert.match(guest, /Plan the work\. Keep Git context close\./); + assert.match(guest, /Configurable workflows/); + assert.match(guest, /Structured ticket context/); + assert.match(guest, /Repository-aware delivery/); + assert.equal((guest.match(/href="\/login"/g) ?? []).length, 2); + assert.doesNotMatch(guest, /href="\/dashboard"/); + assert.doesNotMatch(guest, /Laravel|Laracasts|Deploy now/); + + const authenticated = await render('/?auth=1'); + assert.equal( + (authenticated.match(/href="\/dashboard"/g) ?? []).length, + 2, + ); + assert.doesNotMatch(authenticated, /href="\/login"/); + + const german = { + ...en, + 'welcome.title': 'Arbeit planen. Git-Kontext behalten.', + 'welcome.action.signIn': 'Bei KanbanGit anmelden', + }; + const localized = await render( + '/', + createLocalization({ locale: 'de' }, { de: german }), + ); + assert.match(localized, /Arbeit planen\. Git-Kontext behalten\./); + assert.match(localized, /Bei KanbanGit anmelden/); + assert.doesNotMatch( + localized, + /Plan the work\. Keep Git context close\./, + ); + + console.log('Welcome behavior checks passed.'); + } finally { + await server.close(); + } +}