feat(KAN-27): rebuild responsive project board
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
namespace Tests\Feature\Projects;
|
||||
|
||||
use Symfony\Component\Process\Process;
|
||||
use Tests\TestCase;
|
||||
|
||||
class ProjectBoardPresentationTest extends TestCase
|
||||
{
|
||||
public function test_board_renders_configured_status_groups_counts_and_permission_aware_actions(): void
|
||||
{
|
||||
$process = new Process(['node', 'tests/Fixtures/board.mjs'], base_path());
|
||||
$process->setTimeout(60);
|
||||
$process->run();
|
||||
|
||||
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
|
||||
$this->assertStringContainsString('Board behavior checks passed.', $process->getOutput());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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),
|
||||
});
|
||||
@@ -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,
|
||||
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
|
||||
escaped +
|
||||
'"></div><script type="module" src="/tests/Fixtures/board-preview.mjs"></script></body></html>',
|
||||
),
|
||||
);
|
||||
});
|
||||
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(/<article/g) ?? []).length, 2);
|
||||
const readonly = await render('?readonly');
|
||||
assert.doesNotMatch(readonly, /<form|Move ENG-|New ticket/);
|
||||
const empty = await render('?empty');
|
||||
assert.match(empty, /No workflow columns yet/);
|
||||
assert.doesNotMatch(empty, /id="board-column"|<article/);
|
||||
const one = await render('?one&noTickets');
|
||||
assert.equal((one.match(/id="board-column-title-/g) ?? []).length, 1);
|
||||
assert.match(one, /0 tickets/);
|
||||
assert.doesNotMatch(one, /<article/);
|
||||
console.log('Board behavior checks passed.');
|
||||
} finally {
|
||||
await server.close();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user