From f461439ce69513e515b98581537fbe605fdef927 Mon Sep 17 00:00:00 2001 From: Twyco Date: Tue, 15 Sep 2026 10:46:06 +0200 Subject: [PATCH] feat(KAN-40): rebuild workflow column and status settings --- .../projects/WorkflowEditorDialog.vue | 277 ++++++ .../projects/WorkflowStatusList.vue | 106 +++ .../projects/WorkflowTransitionMatrix.vue | 91 ++ resources/js/locales/en.ts | 49 ++ .../js/pages/projects/settings/Board.vue | 815 ++++-------------- resources/js/types/project-workflow.ts | 20 + .../ProjectBoardSettingsControllerTest.php | 47 + .../Projects/ProjectBoardSettingsUiTest.php | 18 + .../project-board-settings-browser.mjs | 280 ++++++ .../project-board-settings-preview.mjs | 19 + tests/Fixtures/project-board-settings.mjs | 330 +++++++ 11 files changed, 1412 insertions(+), 640 deletions(-) create mode 100644 resources/js/components/projects/WorkflowEditorDialog.vue create mode 100644 resources/js/components/projects/WorkflowStatusList.vue create mode 100644 resources/js/components/projects/WorkflowTransitionMatrix.vue create mode 100644 resources/js/types/project-workflow.ts create mode 100644 tests/Feature/Projects/ProjectBoardSettingsUiTest.php create mode 100644 tests/Fixtures/project-board-settings-browser.mjs create mode 100644 tests/Fixtures/project-board-settings-preview.mjs create mode 100644 tests/Fixtures/project-board-settings.mjs diff --git a/resources/js/components/projects/WorkflowEditorDialog.vue b/resources/js/components/projects/WorkflowEditorDialog.vue new file mode 100644 index 0000000..f457bfe --- /dev/null +++ b/resources/js/components/projects/WorkflowEditorDialog.vue @@ -0,0 +1,277 @@ + + + diff --git a/resources/js/components/projects/WorkflowStatusList.vue b/resources/js/components/projects/WorkflowStatusList.vue new file mode 100644 index 0000000..3c2335e --- /dev/null +++ b/resources/js/components/projects/WorkflowStatusList.vue @@ -0,0 +1,106 @@ + + + diff --git a/resources/js/components/projects/WorkflowTransitionMatrix.vue b/resources/js/components/projects/WorkflowTransitionMatrix.vue new file mode 100644 index 0000000..51176d3 --- /dev/null +++ b/resources/js/components/projects/WorkflowTransitionMatrix.vue @@ -0,0 +1,91 @@ + + + diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts index fe6766b..40a0200 100644 --- a/resources/js/locales/en.ts +++ b/resources/js/locales/en.ts @@ -1,4 +1,53 @@ export const en = { + 'workflow.title': 'Board workflow', + 'workflow.pageTitle': '{project} · Board workflow', + 'workflow.description': + 'Organize your board into columns and give each stage the statuses your team needs.', + 'workflow.updated': 'Workflow updated.', + 'workflow.backlog': 'Backlog', + 'workflow.offBoard': 'Off the board', + 'workflow.backlogDescription': + 'Your intake queue. Tickets with these statuses stay outside the board columns.', + 'workflow.columns': 'Board columns', + 'workflow.columnsDescription': + 'Columns appear from left to right in this order. Edit a column to change its name or position.', + 'workflow.emptyColumns': 'No board columns yet', + 'workflow.emptyColumnsDescription': + 'Add your first column to start organizing tickets on the board.', + 'workflow.addColumn': 'Add column', + 'workflow.editColumn': 'Edit column', + 'workflow.deleteColumn': 'Delete column?', + 'workflow.addColumnDescription': + 'The column is added at the end of the board with a first status of the same name.', + 'workflow.editColumnDescription': + 'Rename this column or move it to a different position. Its statuses keep their names.', + 'workflow.deleteColumnDescription': + 'Delete “{name}”? Its statuses move to the backlog and their tickets keep their status. Remaining columns are reordered automatically.', + 'workflow.addStatus': 'Add status', + 'workflow.editStatus': 'Rename status', + 'workflow.deleteStatus': 'Delete status?', + 'workflow.statusContext': 'This status belongs to {context}.', + 'workflow.deleteStatusDescription': + 'Delete “{name}”? Tickets using it are assigned to “{replacement}”, the first remaining status in {context}. Its move rules are removed.', + 'workflow.columnName': 'Column name', + 'workflow.statusName': 'Status name', + 'workflow.position': 'Position', + 'workflow.positionValue': 'Position {position}', + 'workflow.positionHelp': + 'Choose a position from 1 to {count}. Other columns shift automatically.', + 'workflow.statusesIn': 'Statuses in {context}', + 'workflow.renameStatus': 'Rename status {name}', + 'workflow.removeStatus': 'Delete status {name}', + 'workflow.changeColumn': 'Edit column {name}', + 'workflow.removeColumn': 'Delete column {name}', + 'workflow.addStatusTo': 'Add status to {context}', + 'workflow.lastStatus': + 'Keep at least one status in this group. The last status cannot be deleted.', + 'workflow.transitions': 'Status moves', + 'workflow.transitionsDescription': + 'Choose exactly where each status is allowed to move next.', + 'workflow.noTargets': 'No other statuses exist yet.', + 'workflow.confirmDelete': 'Delete', 'security.title': 'Security settings', 'security.passwordTitle': 'Update password', 'security.passwordDescription': diff --git a/resources/js/pages/projects/settings/Board.vue b/resources/js/pages/projects/settings/Board.vue index 580d5d9..2125e60 100644 --- a/resources/js/pages/projects/settings/Board.vue +++ b/resources/js/pages/projects/settings/Board.vue @@ -1,674 +1,209 @@ diff --git a/resources/js/types/project-workflow.ts b/resources/js/types/project-workflow.ts new file mode 100644 index 0000000..cc207c4 --- /dev/null +++ b/resources/js/types/project-workflow.ts @@ -0,0 +1,20 @@ +import type { BoardColumnModel } from '@/types/models/board-column-model'; +import type { TicketStatusModel } from '@/types/models/ticket-status-model'; + +export type WorkflowEditorTarget = + | { kind: 'column'; action: 'create'; column?: never } + | { kind: 'column'; action: 'edit' | 'delete'; column: BoardColumnModel } + | { + kind: 'status'; + action: 'create'; + column: BoardColumnModel | null; + status?: never; + replacement?: never; + } + | { + kind: 'status'; + action: 'edit' | 'delete'; + column: BoardColumnModel | null; + status: TicketStatusModel; + replacement?: TicketStatusModel; + }; diff --git a/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php b/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php index 994c368..37ca35f 100644 --- a/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php +++ b/tests/Feature/Projects/ProjectBoardSettingsControllerTest.php @@ -372,4 +372,51 @@ class ProjectBoardSettingsControllerTest extends TestCase 'id' => $backlogStatus->id, ]); } + + public function test_deleting_a_used_column_preserves_statuses_tickets_and_backlog_order(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create(); + $this->syncProjectPermissions($project, $user, ['edit']); + $backlog = TicketStatus::factory()->for($project)->create(['position' => 1]); + $column = BoardColumn::factory()->for($project)->create(['position' => 1]); + $statuses = TicketStatus::factory()->for($project)->for($column)->count(2) + ->sequence(['position' => 1], ['position' => 2])->create(); + $ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $statuses[1]->id]); + $statuses[0]->allowedTargetStatuses()->sync([$statuses[1]->id]); + + $this->actingAs($user) + ->delete(route('projects.settings.board.columns.destroy', [$project, $column])) + ->assertRedirect(route('projects.settings.board', $project)) + ->assertSessionHasNoErrors(); + + $this->assertModelMissing($column); + $this->assertSame($statuses[1]->id, $ticket->fresh()->ticket_status_id); + $this->assertSame([$backlog->id, ...$statuses->modelKeys()], $project->ticketStatuses()->pluck('id')->all()); + $this->assertSame([1, 2, 3], $project->ticketStatuses()->pluck('position')->all()); + $this->assertTrue($project->ticketStatuses()->get()->every(fn (TicketStatus $status): bool => $status->board_column_id === null)); + $this->assertSame([$statuses[1]->id], $statuses[0]->allowedTargetStatuses()->pluck('ticket_statuses.id')->all()); + } + + public function test_last_column_status_is_protected_and_invalid_position_preserves_order(): void + { + $user = User::factory()->create(); + $project = Project::factory()->create(); + $this->syncProjectPermissions($project, $user, ['edit']); + $column = BoardColumn::factory()->for($project)->create(['position' => 1]); + $status = TicketStatus::factory()->for($project)->for($column)->create(); + $ticket = Ticket::factory()->for($project)->create(['ticket_status_id' => $status->id]); + + $this->actingAs($user)->from(route('projects.settings.board', $project)) + ->delete(route('projects.settings.board.statuses.destroy', [$project, $status])) + ->assertSessionHasErrors('ticket_status_id'); + $this->assertModelExists($status); + $this->assertSame($status->id, $ticket->fresh()->ticket_status_id); + + $this->actingAs($user)->from(route('projects.settings.board', $project)) + ->patch(route('projects.settings.board.columns.update', [$project, $column]), ['name' => 'Invalid move', 'position' => 0]) + ->assertSessionHasErrors('position'); + $this->assertSame(1, $column->fresh()->position); + $this->assertSame($column->name, $column->fresh()->name); + } } diff --git a/tests/Feature/Projects/ProjectBoardSettingsUiTest.php b/tests/Feature/Projects/ProjectBoardSettingsUiTest.php new file mode 100644 index 0000000..e007c72 --- /dev/null +++ b/tests/Feature/Projects/ProjectBoardSettingsUiTest.php @@ -0,0 +1,18 @@ +setTimeout(60); + $process->run(); + $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput()); + $this->assertStringContainsString('Project board settings rendering checks passed.', $process->getOutput()); + } +} diff --git a/tests/Fixtures/project-board-settings-browser.mjs b/tests/Fixtures/project-board-settings-browser.mjs new file mode 100644 index 0000000..f0a4511 --- /dev/null +++ b/tests/Fixtures/project-board-settings-browser.mjs @@ -0,0 +1,280 @@ +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:4188'; +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 requests = []; +page.on('request', (request) => { + if (['POST', 'PATCH', 'DELETE'].includes(request.method())) { + requests.push(request); + } +}); +const button = (name) => page.getByRole('button', { name, exact: true }); +const dialog = () => page.getByRole('dialog'); +const result = (value) => + context.addCookies([{ name: 'workflow_test_result', value, url: base }]); +const visit = async (query = '?reset') => { + await result('success'); + await page.goto(base + '/projects/1/settings/board' + query); + await page + .getByRole('heading', { name: 'Board workflow', exact: true }) + .waitFor(); +}; +const noOverflow = async () => + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + true, + ); +const submit = async (label, method, pathname, expected) => { + const sent = page.waitForRequest((request) => request.method() === method); + await dialog().getByRole('button', { name: label, exact: true }).click(); + const request = await sent; + assert.equal(new URL(request.url()).pathname, pathname); + assert.deepEqual(request.postDataJSON(), expected); +}; +const closed = () => dialog().waitFor({ state: 'hidden' }); +const formState = (state) => + dialog() + .locator('[data-form-state="' + state + '"]') + .waitFor({ state: 'visible' }); +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 }); + await visit(); + await noOverflow(); + assert.equal( + await button('Delete status Backlog').isDisabled(), + true, + ); + assert.equal( + await button('Delete status Ready').isDisabled(), + true, + ); + assert.equal( + await button('Delete status Review').isDisabled(), + false, + ); + const trigger = button('Edit column In progress'); + await trigger.focus(); + await page.keyboard.press('Enter'); + await dialog().waitFor(); + assert.equal( + await page + .getByLabel('Column name', { exact: true }) + .inputValue(), + 'In progress', + ); + await page.keyboard.press('Tab'); + assert.equal( + await dialog().evaluate((element) => + element.contains(document.activeElement), + ), + true, + ); + await noOverflow(); + await page.keyboard.press('Escape'); + await closed(); + assert.equal( + await trigger.evaluate( + (element) => document.activeElement === element, + ), + true, + ); + if ([390, 1440].includes(width)) { + await page.screenshot({ + path: '/private/tmp/KAN-40-' + width + '-' + theme + '.png', + fullPage: true, + }); + } + assert.equal(requests.length, 0); + } + } + await visit('?reset&empty'); + await page.getByText('No board columns yet', { exact: true }).waitFor(); + await visit(); + await button('Add column').click(); + await page + .getByLabel('Column name', { exact: true }) + .fill('Quality assurance'); + await result('validation'); + await submit('Add column', 'POST', '/projects/1/settings/board/columns', { + name: 'Quality assurance', + }); + await formState('validation'); + assert.equal( + await page.getByLabel('Column name', { exact: true }).inputValue(), + 'Quality assurance', + ); + assert.equal( + await page + .getByLabel('Column name', { exact: true }) + .getAttribute('aria-invalid'), + 'true', + ); + await result('server'); + await dialog() + .getByRole('button', { name: 'Add column', exact: true }) + .click(); + await formState('server'); + await result('success'); + await dialog() + .getByRole('button', { name: 'Try again', exact: true }) + .click(); + await closed(); + await button('Edit column Quality assurance').waitFor(); + const newColumnId = await page + .locator('[data-column-id]') + .last() + .getAttribute('data-column-id'); + await button('Edit column Quality assurance').click(); + await page.getByLabel('Column name', { exact: true }).fill('QA'); + await page.getByLabel('Position', { exact: true }).fill('1'); + await result('position'); + await dialog().getByRole('button', { name: 'Save', exact: true }).click(); + await formState('validation'); + assert.equal( + await page + .getByLabel('Position', { exact: true }) + .getAttribute('aria-invalid'), + 'true', + ); + await result('success'); + await submit( + 'Save', + 'PATCH', + '/projects/1/settings/board/columns/' + newColumnId, + { name: 'QA', position: 1 }, + ); + await closed(); + assert.equal( + await page + .locator('[data-column-id]') + .first() + .getAttribute('data-column-id'), + newColumnId, + ); + await button('Edit column QA').click(); + assert.equal( + await page.getByLabel('Position', { exact: true }).inputValue(), + '1', + ); + assert.equal( + await page.getByLabel('Column name', { exact: true }).inputValue(), + 'QA', + ); + await page.getByLabel('Column name', { exact: true }).fill('Unsaved'); + page.once('dialog', (prompt) => prompt.dismiss()); + await dialog().getByRole('button', { name: 'Cancel', exact: true }).click(); + assert.equal(await dialog().isVisible(), true); + page.once('dialog', (prompt) => prompt.accept()); + await dialog().getByRole('button', { name: 'Cancel', exact: true }).click(); + await closed(); + await button('Add status to Backlog').click(); + assert.equal( + await page.getByLabel('Status name', { exact: true }).inputValue(), + '', + ); + await page.getByLabel('Status name', { exact: true }).fill('Triage'); + await submit('Add status', 'POST', '/projects/1/settings/board/statuses', { + name: 'Triage', + board_column_id: null, + }); + await closed(); + await button('Rename status Triage').waitFor(); + await button('Add status to Todo').click(); + await page.getByLabel('Status name', { exact: true }).fill('Refinement'); + await submit('Add status', 'POST', '/projects/1/settings/board/statuses', { + name: 'Refinement', + board_column_id: 10, + }); + await closed(); + await button('Rename status Review').click(); + await page.getByLabel('Status name', { exact: true }).fill('Peer review'); + await page.route('**/statuses/4', (route) => route.abort('failed')); + await dialog().getByRole('button', { name: 'Save', exact: true }).click(); + await formState('network'); + await page.unroute('**/statuses/4'); + await submit( + 'Try again', + 'PATCH', + '/projects/1/settings/board/statuses/4', + { name: 'Peer review' }, + ); + await closed(); + const before = requests.length; + await button('Delete status Peer review').click(); + await dialog() + .getByText(/assigned to “Building”/) + .waitFor(); + await dialog().getByRole('button', { name: 'Cancel', exact: true }).click(); + await closed(); + assert.equal(requests.length, before); + await button('Delete status Peer review').click(); + await result('validation'); + await submit( + 'Delete', + 'DELETE', + '/projects/1/settings/board/statuses/4', + {}, + ); + await formState('validation'); + await dialog() + .getByText('This group needs at least one status.', { exact: true }) + .waitFor(); + await result('success'); + await dialog().getByRole('button', { name: 'Delete', exact: true }).click(); + await closed(); + assert.equal(await button('Delete status Building').isDisabled(), true); + await button('Delete column In progress').click(); + await dialog() + .getByText(/statuses move to the backlog/) + .waitFor(); + await submit( + 'Delete', + 'DELETE', + '/projects/1/settings/board/columns/20', + {}, + ); + await closed(); + await page + .getByRole('list', { name: 'Statuses in Backlog', exact: true }) + .getByText('Building', { exact: true }) + .waitFor(); + assert.equal(await button('Edit column In progress').count(), 0); + await page.locator('summary').filter({ hasText: 'Status moves' }).click(); + const transition = page.locator('details form').first(); + const checkbox = transition.getByRole('checkbox').first(); + await checkbox.check(); + const move = page.waitForRequest((request) => request.method() === 'POST'); + await transition.getByRole('button', { name: 'Save', exact: true }).click(); + assert.equal( + new URL((await move).url()).pathname, + '/projects/1/settings/board/statuses/1/transitions', + ); + assert.equal( + new URL((await move).url()).searchParams.get('_method'), + 'PATCH', + ); + assert.deepEqual((await move).postDataJSON(), { + allowed_ticket_status_ids: [await checkbox.getAttribute('value')], + }); + assert.deepEqual(errors, []); + console.log( + 'Workflow browser checks passed: CRUD payloads, server refresh, order, deletion protection, validation, retries, dirty forms, keyboard, responsive widths and both themes.', + ); +} finally { + await browser.close(); +} diff --git a/tests/Fixtures/project-board-settings-preview.mjs b/tests/Fixtures/project-board-settings-preview.mjs new file mode 100644 index 0000000..08c7983 --- /dev/null +++ b/tests/Fixtures/project-board-settings-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/settings/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/project-board-settings.mjs b/tests/Fixtures/project-board-settings.mjs new file mode 100644 index 0000000..73b06d3 --- /dev/null +++ b/tests/Fixtures/project-board-settings.mjs @@ -0,0 +1,330 @@ +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-board-settings-fixture', + plugins: [vue(), ...(serve ? [tailwind()] : [])], + resolve: { + alias: { + '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), + }, + }, + server: serve + ? { host: '127.0.0.1', port: 4188, strictPort: true, hmr: false } + : { middlewareMode: true, hmr: false, watch: null }, + appType: 'custom', +}); +const makeStatus = (id, name, column, position = 1) => ({ + id, + name, + board_column_id: column, + position, + allowed_target_statuses: [], +}); +function initial() { + const backlog = makeStatus(1, 'Backlog', null); + const ready = makeStatus(2, 'Ready', 10); + const active = makeStatus(3, 'Building', 20); + const review = makeStatus(4, 'Review', 20, 2); + const columns = [ + { id: 10, name: 'Todo', position: 1, ticket_statuses: [ready] }, + { + id: 20, + name: 'In progress', + position: 2, + ticket_statuses: [active, review], + }, + ]; + return { + project: { id: 1, key: 'CORE', name: 'Core platform' }, + status: null, + templateState: null, + backlogTicketStatuses: [backlog], + boardColumns: columns, + ticketStatuses: [ + backlog, + ...columns.flatMap((column) => + column.ticket_statuses.map((status) => ({ + ...status, + board_column: { id: column.id, name: column.name }, + })), + ), + ], + }; +} +function makePage( + extra = {}, + url = '/projects/1/settings/board', + component = 'Board', +) { + return { + component, + url, + version: null, + clearHistory: false, + encryptHistory: false, + props: { + name: 'KanbanGit', + errors: {}, + sidebarOpen: true, + appearance: 'system', + auth: { + user: { + id: 1, + name: 'Alex Morgan', + email: 'alex@example.test', + avatar: null, + avatar_color: '#10B981', + avatar_is_generated: true, + }, + permissions: { + globalAdmin: false, + projectCreate: false, + canManageTeams: false, + }, + }, + navigation: { + project: { + id: 1, + name: 'Core platform', + key: 'CORE', + canManage: true, + }, + }, + ...initial(), + ...extra, + }, + }; +} +if (serve) { + let data = initial(); + let nextId = 100; + server.middlewares.use(async (request, response, next) => { + if (!request.url.startsWith('/projects')) { + return next(); + } + const url = new URL(request.url, 'http://localhost'); + const mutation = request.method !== 'GET'; + if (!mutation && url.searchParams.has('reset')) { + data = initial(); + } + let errors = {}; + if (mutation) { + const result = + request.headers.cookie?.match( + /workflow_test_result=(\w+)/, + )?.[1] ?? 'success'; + let body = ''; + for await (const chunk of request) { + body += chunk; + } + const submitted = JSON.parse(body || '{}'); + if (['server', 'forbidden'].includes(result)) { + response.statusCode = result === 'server' ? 503 : 403; + response.end('Simulated request failure'); + return; + } + if (result === 'validation') { + errors = + request.method === 'DELETE' + ? { + ticket_status_id: + 'This group needs at least one status.', + } + : { name: 'Choose a different name.' }; + } else if (result === 'position') { + errors = { position: 'The position must be at least 1.' }; + } else { + const segments = url.pathname.split('/'); + const id = Number(segments.at(-1)); + const columns = data.boardColumns; + if (url.pathname.includes('/columns')) { + if (request.method === 'POST') { + const column = { + id: nextId++, + name: submitted.name, + position: columns.length + 1, + ticket_statuses: [], + }; + column.ticket_statuses.push( + makeStatus(nextId++, submitted.name, column.id), + ); + columns.push(column); + } else if (request.method === 'PATCH') { + const column = columns.find((item) => item.id === id); + column.name = submitted.name; + columns.splice(columns.indexOf(column), 1); + columns.splice( + Math.max( + 0, + Math.min( + Number(submitted.position) - 1, + columns.length, + ), + ), + 0, + column, + ); + } else { + const column = columns.find((item) => item.id === id); + for (const status of column.ticket_statuses) { + status.board_column_id = null; + status.position = + data.backlogTicketStatuses.length + 1; + data.backlogTicketStatuses.push(status); + } + columns.splice(columns.indexOf(column), 1); + } + columns.forEach((column, index) => { + column.position = index + 1; + }); + } else if (url.pathname.endsWith('/transitions')) { + // The browser assertion checks the preserved transition endpoint and payload. + } else if (request.method === 'POST') { + const group = + submitted.board_column_id === null + ? data.backlogTicketStatuses + : columns.find( + (item) => + item.id === submitted.board_column_id, + ).ticket_statuses; + group.push( + makeStatus( + nextId++, + submitted.name, + submitted.board_column_id, + group.length + 1, + ), + ); + } else { + const group = [ + data.backlogTicketStatuses, + ...columns.map((column) => column.ticket_statuses), + ].find((items) => items.some((item) => item.id === id)); + const status = group.find((item) => item.id === id); + if (request.method === 'PATCH') { + status.name = submitted.name; + } else { + group.splice(group.indexOf(status), 1); + } + } + data.ticketStatuses = [ + ...data.backlogTicketStatuses, + ...columns.flatMap((column) => + column.ticket_statuses.map((status) => ({ + ...status, + board_column: { id: column.id, name: column.name }, + })), + ), + ]; + data.status = 'Updated'; + } + } + if (url.searchParams.has('empty')) { + data.boardColumns = []; + data.ticketStatuses = data.backlogTicketStatuses; + } + const page = makePage({ ...data, errors }); + 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 board settings fixture ready.'); +} 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 component = ( + await server.ssrLoadModule( + '/resources/js/pages/projects/settings/Board.vue', + ) + ).default; + const render = async (extra = {}, locale = createLocalization()) => + ( + await createInertiaApp({ + page: makePage(extra), + resolve: () => component, + render: renderToString, + setup: ({ App, props, plugin }) => + createSSRApp({ render: () => h(App, props) }) + .use(plugin) + .use(locale) + .use(createAppearance()), + }) + ).body; + const html = await render(); + assert.match(html, /Board workflow/); + assert.match(html, /Off the board/); + assert.match(html, /aria-current="page"/); + assert.ok( + html.indexOf('data-column-id="10"') < + html.indexOf('data-column-id="20"'), + ); + const buttons = html.match(/]*>[\s\S]*?<\/button>/g); + for (const name of ['Backlog', 'Ready']) { + assert.match( + buttons.find((button) => + button.includes('aria-label="Delete status ' + name + '"'), + ), + /aria-disabled="true"/, + ); + } + assert.doesNotMatch( + buttons.find((button) => + button.includes('aria-label="Delete status Review"'), + ), + /aria-disabled="true"/, + ); + assert.match( + await render({ boardColumns: [] }), + /No board columns yet/, + ); + assert.match( + await render( + {}, + createLocalization( + { locale: 'de' }, + { de: { ...en, 'workflow.title': 'Board-Ablauf' } }, + ), + ), + /Board-Ablauf/, + ); + console.log('Project board settings rendering checks passed.'); + } finally { + await server.close(); + } +} -- 2.54.0