diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts index 5e11e4a..4fd0d4d 100644 --- a/resources/js/locales/en.ts +++ b/resources/js/locales/en.ts @@ -1,4 +1,25 @@ export const en = { + 'board.title': '{name} board', + 'board.description': + 'Work in your configured workflow. Tickets without a board column stay in the backlog.', + 'board.columns': 'Workflow columns', + 'board.selectColumn': 'Choose a column', + 'board.columnOption': '{name} ({count})', + 'board.scrollHint': 'Scroll horizontally to explore all columns.', + 'board.openBacklog': 'Open backlog', + 'board.backToProject': 'Project overview', + 'board.empty.title': 'No workflow columns yet', + 'board.empty.description': + 'Set up columns to start tracking work on this board.', + 'board.emptyColumn': 'No tickets in this column.', + 'board.emptyStatus': 'No tickets in this status.', + 'board.noStatuses': 'No statuses configured for this column.', + 'board.moveTicket': 'Move {key}', + 'board.updateStatus': 'Update status', + 'board.updatingStatus': 'Updating…', + 'board.noMoves': 'No allowed moves', + 'board.noMovesDescription': + 'This status does not allow moving anywhere else.', 'projectCreation.title': 'Create a project', 'projectCreation.description': 'Give your work a home. Start fresh or reuse a setup that already works for your team.', diff --git a/resources/js/pages/projects/Board.vue b/resources/js/pages/projects/Board.vue index 003acc5..057efcb 100644 --- a/resources/js/pages/projects/Board.vue +++ b/resources/js/pages/projects/Board.vue @@ -1,71 +1,103 @@ + + + + + + + diff --git a/tests/Feature/Projects/ProjectBoardPresentationTest.php b/tests/Feature/Projects/ProjectBoardPresentationTest.php new file mode 100644 index 0000000..b989da6 --- /dev/null +++ b/tests/Feature/Projects/ProjectBoardPresentationTest.php @@ -0,0 +1,19 @@ +setTimeout(60); + $process->run(); + + $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput()); + $this->assertStringContainsString('Board behavior checks passed.', $process->getOutput()); + } +} diff --git a/tests/Fixtures/board-browser.mjs b/tests/Fixtures/board-browser.mjs new file mode 100644 index 0000000..21c5c18 --- /dev/null +++ b/tests/Fixtures/board-browser.mjs @@ -0,0 +1,239 @@ +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 page = await browser.newPage({ viewport: { width: 1440, height: 1000 } }); +page.setDefaultTimeout(10000); +const errors = []; +page.on('pageerror', (error) => errors.push(error.message)); +const base = 'http://127.0.0.1:4181/projects/7/board'; +const content = () => page.locator('#main-content'); +const visibleColumns = () => + content().locator('[id^="board-column-title-"]:visible'); +const visit = async (query = '') => { + await page.goto(base + query); + await page.getByRole('heading', { level: 1 }).waitFor(); + await page.evaluate(() => document.fonts.ready); +}; +const tabTo = async (locator) => { + await content().focus(); + for (let step = 0; step < 40; step++) { + await page.keyboard.press('Tab'); + if ( + await locator.evaluate( + (element) => element === document.activeElement, + ) + ) { + assert.equal( + await locator.evaluate((element) => + element.matches(':focus-visible'), + ), + true, + ); + return; + } + } + assert.fail('Board control is unreachable by keyboard'); +}; +try { + for (const width of [1440, 768, 390, 320]) { + await page.setViewportSize({ width, height: 1000 }); + for (const theme of ['light', 'dark']) { + await page.emulateMedia({ + colorScheme: theme, + reducedMotion: 'reduce', + }); + for (const [query, state] of [ + ['', 'filled'], + ['?empty', 'empty'], + ['?one&noTickets', 'one-empty'], + ]) { + await visit(query); + assert.equal( + await page + .locator('html') + .evaluate((el) => el.classList.contains('dark')), + theme === 'dark', + ); + assert.equal( + await page.evaluate( + () => + document.documentElement.scrollWidth <= + window.innerWidth, + ), + true, + `${state} overflow at ${width}/${theme}`, + ); + assert.equal( + await visibleColumns().count(), + state === 'empty' + ? 0 + : width < 768 || state === 'one-empty' + ? 1 + : 8, + ); + if (width < 768 && state !== 'empty') { + assert.ok( + (await page.getByLabel('Choose a column').boundingBox()) + .height >= 44, + ); + } + if (state === 'filled' && width >= 768) { + const region = content().getByRole('region', { + name: 'Workflow columns', + exact: true, + }); + assert.equal( + await region.evaluate( + (el) => el.scrollWidth > el.clientWidth, + ), + true, + ); + await region.focus(); + await page.keyboard.press('End'); + await content() + .locator('#board-column-8') + .scrollIntoViewIfNeeded(); + assert.ok(await region.evaluate((el) => el.scrollLeft > 0)); + await region.evaluate((el) => { + el.scrollLeft = 0; + }); + } + await page.evaluate(() => window.scrollTo(0, 0)); + await page.screenshot({ + path: `/private/tmp/kan27-board-${state}-${width}-${theme}.png`, + fullPage: true, + }); + } + } + } + await page.setViewportSize({ width: 390, height: 1000 }); + await visit(); + const chooseColumn = page.getByLabel('Choose a column'); + await tabTo(chooseColumn); + // Native popup selection is handled by macOS outside the browser DOM. + await chooseColumn.selectOption('2'); + assert.equal(await chooseColumn.inputValue(), '2'); + assert.equal(await visibleColumns().innerText(), 'In development'); + await chooseColumn.selectOption('8'); + assert.equal(await visibleColumns().innerText(), 'Column without statuses'); + await content() + .getByText('No statuses configured for this column.') + .waitFor(); + await chooseColumn.selectOption('1'); + await content() + .getByRole('heading', { name: 'Ready · Planned', exact: true }) + .waitFor(); + await content() + .getByRole('heading', { name: 'Ready · Active', exact: true }) + .waitFor(); + const terminalSelect = page.getByLabel('Move ENG-43', { exact: true }); + assert.equal(await terminalSelect.isDisabled(), true); + const moveSelect = page.getByLabel('Move ENG-42', { exact: true }); + assert.deepEqual( + await moveSelect + .locator('option') + .evaluateAll((options) => options.map((option) => option.value)), + ['20', '11', '99'], + ); + await tabTo(moveSelect); + await moveSelect.selectOption('20'); + await page.keyboard.press('Tab'); + assert.equal(await page.locator(':focus').innerText(), 'Update status'); + await page.keyboard.press('Enter'); + await page + .getByText('ENG-42 moved successfully.', { exact: true }) + .waitFor(); + assert.equal(await chooseColumn.inputValue(), '1'); + assert.match( + await chooseColumn.locator('option').first().innerText(), + /\(1\)/, + ); + await chooseColumn.selectOption('2'); + await content() + .getByRole('link', { + name: 'Open ENG-42: Ship accessible board navigation', + exact: true, + }) + .waitFor(); + + await visit(); + await page.getByLabel('Move ENG-42', { exact: true }).selectOption('99'); + await content() + .locator('article') + .filter({ hasText: 'ENG-42' }) + .getByRole('button', { name: 'Update status' }) + .click(); + await page + .getByText('ENG-42 moved successfully.', { exact: true }) + .waitFor(); + assert.equal( + await content() + .getByRole('link', { + name: 'Open ENG-42: Ship accessible board navigation', + exact: true, + }) + .count(), + 0, + ); + assert.match( + await chooseColumn.locator('option').first().innerText(), + /\(1\)/, + ); + + await visit('?reject'); + await page.getByLabel('Move ENG-42', { exact: true }).selectOption('20'); + await content() + .locator('article') + .filter({ hasText: 'ENG-42' }) + .getByRole('button', { name: 'Update status' }) + .click(); + await page + .getByText('This transition is no longer allowed.', { exact: true }) + .waitFor(); + assert.equal( + await page + .getByLabel('Move ENG-42', { exact: true }) + .getAttribute('aria-invalid'), + 'true', + ); + assert.equal( + await content() + .getByText('This transition is no longer allowed.', { exact: true }) + .count(), + 1, + ); + assert.equal( + await page + .getByLabel('Move ENG-43', { exact: true }) + .getAttribute('aria-invalid'), + null, + ); + + await visit('?readonly'); + assert.equal(await content().locator('form').count(), 0); + const ticketLink = content().getByRole('link', { + name: 'Open ENG-42: Ship accessible board navigation', + exact: true, + }); + await tabTo(ticketLink); + await page.keyboard.press('Enter'); + await page.waitForURL('**/projects/7/tickets/42'); + await page + .getByRole('heading', { name: 'Destination reached', exact: true }) + .waitFor(); + await visit('?empty'); + await tabTo( + content().getByRole('link', { name: 'Open backlog', exact: true }), + ); + await page.keyboard.press('Enter'); + await page.waitForURL('**/projects/7/backlog'); + assert.deepEqual(errors, []); + console.log( + 'Board browser checks passed: responsive columns, keyboard, status changes and validation, permissions, empty states and both themes.', + ); +} finally { + await browser.close(); +} diff --git a/tests/Fixtures/board-preview.mjs b/tests/Fixtures/board-preview.mjs new file mode 100644 index 0000000..a4b72f0 --- /dev/null +++ b/tests/Fixtures/board-preview.mjs @@ -0,0 +1,19 @@ +import { createApp, h } from 'vue'; +import { createInertiaApp } from '@inertiajs/vue3'; +import '../../resources/css/app.css'; +import Board from '../../resources/js/pages/projects/Board.vue'; +import { createAppearance } from '../../resources/js/composables/useAppearance'; +import { createLocalization } from '../../resources/js/lib/localization'; + +createInertiaApp({ + resolve: (name) => + name === 'Board' + ? Board + : { 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/board.mjs b/tests/Fixtures/board.mjs new file mode 100644 index 0000000..7443472 --- /dev/null +++ b/tests/Fixtures/board.mjs @@ -0,0 +1,275 @@ +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/board-fixture', + plugins: [vue(), ...(serve ? [tailwind()] : [])], + resolve: { + alias: { + '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), + }, + }, + server: serve + ? { host: '127.0.0.1', port: 4181, 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 columnNames = [ + 'Ready', + 'In development', + 'Review', + 'Testing', + 'Release', + 'Done', + 'Archived', + 'Column without statuses', +]; +const columns = columnNames.map((name, index) => ({ + id: index + 1, + name, + position: index + 1, + project_id: 7, + tickets: [], + ticket_statuses: [], +})); +const statuses = columns.slice(0, -1).flatMap((column) => + ['Planned', 'Active'].map((name, index) => ({ + id: column.id * 10 + index, + name: `${column.name} · ${name}`, + position: index + 1, + project_id: 7, + board_column_id: column.id, + board_column: { ...column }, + })), +); +const backlog = { + id: 99, + name: 'Backlog', + board_column_id: null, + board_column: null, +}; +for (const column of columns) { + column.ticket_statuses = statuses.filter( + (status) => status.board_column_id === column.id, + ); +} +function ticket(id, status, title) { + return { + id, + title, + ticket_key: `ENG-${id}`, + project_id: 7, + sequence_number: id, + description: + 'A real ticket card in the isolated board behavior fixture.', + ticket_status_id: status.id, + ticket_status: { + ...status, + allowed_target_statuses: + id === 42 ? [status, statuses[2], statuses[1], backlog] : [], + }, + ticket_priority: { id: 1, name: 'Urgent custom priority', position: 1 }, + ticket_type: { id: 1, name: 'Development', is_parent: false }, + assignee: null, + assignee_user_id: null, + is_blocked: id === 43, + blocked_comment: id === 43 ? 'Awaiting the external review.' : null, + }; +} +function makePage(url) { + const parsed = new URL(url, 'http://localhost'); + const params = parsed.searchParams; + const isBoard = parsed.pathname === '/projects/7/board'; + const boardColumns = structuredClone( + params.has('empty') + ? [] + : params.has('one') + ? columns.slice(0, 1) + : columns, + ); + if (!params.has('noTickets')) { + const target = + [...statuses, backlog].find( + (status) => status.id === Number(params.get('target')), + ) ?? statuses[0]; + const tickets = [ + ticket(42, target, 'Ship accessible board navigation'), + ticket( + 43, + statuses[1], + 'InvestigateALongUnbrokenTicketTitleThatMustWrapEvenOnTheSmallestMobileScreenWithoutClipping', + ), + ]; + for (const column of boardColumns) { + column.tickets = tickets.filter( + (item) => item.ticket_status.board_column_id === column.id, + ); + } + } + return { + component: isBoard ? 'Board' : 'Destination', + url, + version: null, + clearHistory: false, + encryptHistory: false, + props: { + name: 'KanbanGit', + errors: params.has('rejected') + ? { + 'board-ticket-42': { + ticket_status_id: + 'This transition is no longer allowed.', + }, + } + : {}, + sidebarOpen: true, + auth: { + user: { + id: 1, + name: 'Alex Morgan', + email: 'alex@example.test', + avatar: null, + }, + permissions: { + globalAdmin: false, + projectCreate: false, + canManageTeams: false, + }, + }, + navigation: { + project: { + ...project, + canManage: false, + canCreateTickets: !params.has('readonly'), + }, + }, + project, + boardColumns, + canCreateTickets: !params.has('readonly'), + canEditTickets: !params.has('readonly'), + status: params.has('target') ? 'ENG-42 moved successfully.' : null, + }, + }; +} +if (serve) { + server.middlewares.use(async (request, response) => { + if (request.method === 'POST') { + let body = ''; + for await (const chunk of request) { + body += chunk; + } + const data = JSON.parse(body); + assert.equal( + request.url, + '/projects/7/tickets/42/status?_method=PATCH', + ); + assert.equal(data.redirect_to, 'board'); + assert.ok([11, 20, 99].includes(Number(data.ticket_status_id))); + const previous = new URL(request.headers.referer); + const reject = previous.searchParams.has('reject'); + response.writeHead(303, { + Location: `/projects/7/board?${reject ? 'reject&rejected' : `target=${data.ticket_status_id}`}`, + }); + response.end(); + return; + } + 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( + 'Board fixture ready at http://127.0.0.1:4181/projects/7/board', + ); +} else { + try { + const { createLocalization } = await server.ssrLoadModule( + '/resources/js/lib/localization.ts', + ); + const { createAppearance } = await server.ssrLoadModule( + '/resources/js/composables/useAppearance.ts', + ); + const { default: Board } = await server.ssrLoadModule( + '/resources/js/pages/projects/Board.vue', + ); + const render = async (query = '') => { + const result = await createInertiaApp({ + page: makePage(`/projects/7/board${query}`), + resolve: () => Board, + render: renderToString, + setup: ({ App, props, plugin }) => + createSSRApp({ render: () => h(App, props) }) + .use(plugin) + .use(createLocalization()) + .use(createAppearance('light')), + }); + return result.body.split('id="main-content"')[1]; + }; + const board = await render(); + assert.equal((board.match(/id="board-column-title-/g) ?? []).length, 8); + assert.equal((board.match(/id="board-status-/g) ?? []).length, 14); + assert.match(board, /Ready · Planned/); + assert.match(board, /Ready · Active/); + assert.match(board, /No statuses configured for this column/); + assert.match(board, /No tickets in this column/); + assert.match(board, /No tickets in this status/); + assert.match(board, /2 tickets/); + assert.match(board, /Urgent custom priority/); + assert.match(board, /href="\/projects\/7\/tickets\/42"/); + assert.match( + board, + /action="\/projects\/7\/tickets\/42\/status\?_method=PATCH"/, + ); + assert.match( + board, + /href="\/projects\/7\/tickets\/create\?source=board"/, + ); + assert.match(board, /href="\/projects\/7\/backlog"/); + assert.match(board, /No allowed moves/); + assert.equal((board.match(/