From cd7fae3841876150c7a9dbaf62a77bce8c5eaf28 Mon Sep 17 00:00:00 2001 From: Twyco Date: Thu, 10 Sep 2026 19:47:34 +0200 Subject: [PATCH] feat(KAN-24): rebuild project creation flow --- resources/js/locales/en.ts | 82 ++ resources/js/pages/projects/Create.vue | 848 ++++++++++++------ .../Feature/Projects/ProjectCreationTest.php | 50 ++ tests/Fixtures/project-creation-browser.mjs | 334 +++++++ tests/Fixtures/project-creation-preview.mjs | 19 + tests/Fixtures/project-creation.mjs | 256 ++++++ 6 files changed, 1307 insertions(+), 282 deletions(-) create mode 100644 tests/Feature/Projects/ProjectCreationTest.php create mode 100644 tests/Fixtures/project-creation-browser.mjs create mode 100644 tests/Fixtures/project-creation-preview.mjs create mode 100644 tests/Fixtures/project-creation.mjs diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts index e687c1f..711ccef 100644 --- a/resources/js/locales/en.ts +++ b/resources/js/locales/en.ts @@ -1,4 +1,86 @@ export const en = { + 'projectCreation.title': 'Create a project', + 'projectCreation.description': + 'Give your work a home. Start fresh or reuse a setup that already works for your team.', + 'projectCreation.start': 'How would you like to start?', + 'projectCreation.scratch': 'Start from scratch', + 'projectCreation.scratchDescription': + 'Create an empty project with the default setup.', + 'projectCreation.duplicate': 'Duplicate a project', + 'projectCreation.duplicateDescription': + 'Choose an existing project and copy selected settings.', + 'projectCreation.template': 'Use a template', + 'projectCreation.templateDescription': + 'Start with a saved, reusable project configuration.', + 'projectCreation.noProjects': + 'No accessible projects are available to copy yet.', + 'projectCreation.noTemplates': 'No published templates are available yet.', + 'projectCreation.details': 'Project details', + 'projectCreation.name': 'Project name', + 'projectCreation.namePlaceholder': 'Platform engineering', + 'projectCreation.key': 'Project key', + 'projectCreation.keyPlaceholder': 'CORE', + 'projectCreation.keyHelp': + 'Use up to 5 letters or numbers. Keys are unique and converted to uppercase.', + 'projectCreation.copyTitle': 'Choose what to copy', + 'projectCreation.copyHelp': + 'Copy configuration into an independent project. Select only what you need.', + 'projectCreation.sourceProject': 'Source project', + 'projectCreation.selectProject': 'Select a source project', + 'projectCreation.copyOptions': 'Configuration areas', + 'projectCreation.copyDefaults': + 'Unselected areas keep the default setup. You can also continue without copying any settings.', + 'projectCreation.chooseTemplate': 'Choose a template', + 'projectCreation.templateLabel': 'Template', + 'projectCreation.selectTemplate': 'Select a template', + 'projectCreation.templateHelp': + 'The project stays linked to this template. Inherited areas follow its configuration; you can override them in project settings.', + 'projectCreation.includedAreas': 'Included configuration', + 'projectCreation.templateDefaults': + 'This template uses the default project setup.', + 'projectCreation.summary': 'Your new project', + 'projectCreation.untitled': 'Add a project name', + 'projectCreation.ticketPreview': 'First ticket key', + 'projectCreation.setup': 'Starting point', + 'projectCreation.cleanStart': + 'Every project starts with an empty backlog and its own ticket numbering. Tickets and their history are never copied.', + 'projectCreation.submit': 'Create project', + 'projectCreation.creating': 'Creating project…', + 'projectCreation.areas.projectSettings.label': 'Project settings', + 'projectCreation.areas.projectSettings.description': + 'Story points and author visibility.', + 'projectCreation.areas.boardColumns.label': 'Board columns', + 'projectCreation.areas.boardColumns.description': + 'Columns and their order on the board.', + 'projectCreation.areas.ticketStatuses.label': 'Ticket statuses', + 'projectCreation.areas.ticketStatuses.description': + 'Statuses and their board columns.', + 'projectCreation.areas.statusTransitions.label': 'Status transitions', + 'projectCreation.areas.statusTransitions.description': + 'Allowed moves between statuses.', + 'projectCreation.areas.ticketTypes.label': 'Ticket types', + 'projectCreation.areas.ticketTypes.description': + 'Available types, including parent types.', + 'projectCreation.areas.ticketPriorities.label': 'Ticket priorities', + 'projectCreation.areas.ticketPriorities.description': + 'Available priority levels.', + 'projectCreation.areas.ticketFields.label': 'Custom ticket fields', + 'projectCreation.areas.ticketFields.description': + 'Field definitions without ticket values.', + 'projectCreation.areas.ticketLinkTypes.label': 'Ticket link types', + 'projectCreation.areas.ticketLinkTypes.description': + 'Relationship labels between tickets.', + 'projectCreation.areas.repositories.label': 'Repository connections', + 'projectCreation.areas.repositories.description': + 'Repositories linked to the project.', + 'projectCreation.areas.notifications.label': 'Notification actions', + 'projectCreation.areas.notifications.description': + 'Notification rules and delivery settings.', + 'projectCreation.selectedCount': { + one: '{count} area selected', + other: '{count} areas selected', + }, + 'projects.workspace': 'Workspace', 'projects.description': 'Your project spaces, ready to work in. Open a project or jump straight to its board and backlog.', diff --git a/resources/js/pages/projects/Create.vue b/resources/js/pages/projects/Create.vue index fefa968..b157e78 100644 --- a/resources/js/pages/projects/Create.vue +++ b/resources/js/pages/projects/Create.vue @@ -1,349 +1,633 @@ diff --git a/tests/Feature/Projects/ProjectCreationTest.php b/tests/Feature/Projects/ProjectCreationTest.php new file mode 100644 index 0000000..fc1bc46 --- /dev/null +++ b/tests/Feature/Projects/ProjectCreationTest.php @@ -0,0 +1,50 @@ +admin()->create(); + $this->actingAs($user)->from(route('projects.create')) + ->post(route('projects.store'), ['creation_mode' => $mode, 'name' => 'New project', 'key' => 'NEW']) + ->assertRedirect(route('projects.create'))->assertSessionHasErrors($field); + $this->assertDatabaseMissing('projects', ['key' => 'NEW']); + } + + /** @return array */ + public static function missingSources(): array + { + return ['duplicate' => ['duplicate', 'source_project_id'], 'template' => ['template', 'source_template_id']]; + } + + public function test_an_unpublished_template_cannot_be_used_to_create_a_project(): void + { + $user = User::factory()->admin()->create(); + $template = ProjectTemplate::factory()->create(['is_published' => false]); + $this->actingAs($user)->post(route('projects.store'), [ + 'creation_mode' => 'template', 'source_template_id' => $template->id, 'name' => 'New project', 'key' => 'NEW', + ])->assertSessionHasErrors('source_template_id'); + $this->assertDatabaseMissing('projects', ['key' => 'NEW']); + } + + public function test_creation_page_rendering_and_localization(): void + { + $process = new Process(['node', 'tests/Fixtures/project-creation.mjs'], base_path()); + $process->setTimeout(60); + $process->run(); + $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput()); + $this->assertStringContainsString('Project creation rendering checks passed.', $process->getOutput()); + } +} diff --git a/tests/Fixtures/project-creation-browser.mjs b/tests/Fixtures/project-creation-browser.mjs new file mode 100644 index 0000000..6307817 --- /dev/null +++ b/tests/Fixtures/project-creation-browser.mjs @@ -0,0 +1,334 @@ +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:4179'; +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)); +page.on('dialog', (dialog) => dialog.accept()); +const setResult = (value) => + context.addCookies([{ name: 'creation_test_result', value, url: base }]); +const content = () => page.locator('#main-content'); +const state = (value) => + page.locator(`[data-form-state="${value}"]`).waitFor({ state: 'visible' }); +const choose = async (value) => { + await page.locator(`input[name="creation_mode"][value="${value}"]`).focus(); + await page.keyboard.press('Space'); +}; +const fillDetails = async () => { + await page + .getByLabel('Project name', { exact: true }) + .fill('Frontend workspace'); + await page.getByLabel('Project key', { exact: true }).fill('web'); +}; +const submit = () => + page.getByRole('button', { name: 'Create project', exact: true }).click(); +const focusWithTab = async (target) => { + await content().focus(); + for (let step = 0; step < 40; step++) { + await page.keyboard.press('Tab'); + if ( + await target.evaluate( + (element) => element === document.activeElement, + ) + ) { + assert.equal( + await target.evaluate((element) => + element.matches(':focus-visible'), + ), + true, + ); + return; + } + } + assert.fail('Form control 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 page.goto( + `${base}/projects/create?viewport=${width}&theme=${theme}`, + ); + await page + .getByRole('heading', { name: 'Create a project' }) + .waitFor(); + for (const mode of ['scratch', 'duplicate', 'template']) { + await choose(mode); + 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, + `${mode} overflows at ${width} in ${theme}`, + ); + await page.screenshot({ + path: `/private/tmp/kan24-${mode}-${width}-${theme}.png`, + fullPage: true, + }); + } + await choose('scratch'); + const scratch = page.getByRole('radio', { + name: 'Start from scratch', + exact: true, + }); + await focusWithTab(scratch); + await page.keyboard.press('ArrowRight'); + assert.equal( + await page + .getByRole('radio', { + name: 'Duplicate a project', + exact: true, + }) + .isChecked(), + true, + ); + const board = page.getByRole('checkbox', { + name: 'Board columns', + exact: true, + }); + await focusWithTab(board); + await page.keyboard.press('Space'); + assert.equal(await board.isChecked(), true); + const source = page.getByLabel('Source project', { exact: true }); + await focusWithTab(source); + await page.keyboard.press('o'); + await page.keyboard.press('Tab'); + await page.waitForFunction( + () => + document.querySelector('#source_project_id').value === '8', + ); + assert.equal(await source.inputValue(), '8'); + } + } + + await page.goto(`${base}/projects/create?empty`); + assert.equal( + await page + .getByRole('radio', { name: 'Start from scratch', exact: true }) + .isChecked(), + true, + ); + assert.equal( + await page + .getByRole('radio', { name: 'Duplicate a project', exact: true }) + .isDisabled(), + true, + ); + assert.equal( + await page + .getByRole('radio', { name: 'Use a template', exact: true }) + .isDisabled(), + true, + ); + await fillDetails(); + const scratchRequest = page.waitForRequest( + (request) => request.method() === 'POST', + ); + await submit(); + assert.deepEqual((await scratchRequest).postDataJSON(), { + name: 'Frontend workspace', + key: 'web', + creation_mode: 'scratch', + source_project_id: '', + source_template_id: '', + copy_areas: [], + }); + await page.waitForURL('**/projects/99'); + + await page.goto(`${base}/projects/create?template=4`); + assert.equal( + await page.getByLabel('Template', { exact: true }).inputValue(), + '4', + ); + await page + .getByText('This template uses the default project setup.', { + exact: true, + }) + .waitFor(); + await fillDetails(); + const templateRequest = page.waitForRequest( + (request) => request.method() === 'POST', + ); + await submit(); + const templateData = (await templateRequest).postDataJSON(); + assert.equal(templateData.creation_mode, 'template'); + assert.equal(templateData.source_template_id, '4'); + assert.equal(templateData.source_project_id, ''); + assert.deepEqual(templateData.copy_areas, []); + await page.waitForURL('**/projects/99'); + + await page.goto(`${base}/projects/create`); + await choose('duplicate'); + await fillDetails(); + await page.getByLabel('Source project', { exact: true }).selectOption('8'); + await page + .getByRole('checkbox', { name: 'Board columns', exact: true }) + .check(); + await page + .getByRole('checkbox', { name: 'Custom ticket fields', exact: true }) + .check(); + await page.getByText('2 areas selected', { exact: true }).waitFor(); + await page.getByText('WEB-1', { exact: true }).waitFor(); + await choose('template'); + await choose('scratch'); + await choose('duplicate'); + assert.equal( + await page.getByLabel('Source project', { exact: true }).inputValue(), + '8', + ); + assert.equal( + await page + .getByRole('checkbox', { name: 'Board columns', exact: true }) + .isChecked(), + true, + ); + assert.equal( + await page + .getByRole('checkbox', { + name: 'Custom ticket fields', + exact: true, + }) + .isChecked(), + true, + ); + + await setResult('validation'); + const duplicateRequest = page.waitForRequest( + (request) => request.method() === 'POST', + ); + await submit(); + const duplicateData = (await duplicateRequest).postDataJSON(); + assert.equal(duplicateData.source_project_id, '8'); + assert.equal(duplicateData.source_template_id, ''); + assert.deepEqual(duplicateData.copy_areas, [ + 'board_columns', + 'ticket_fields', + ]); + await state('validation'); + assert.equal( + await page.getByLabel('Project name', { exact: true }).inputValue(), + 'Frontend workspace', + ); + assert.equal( + await page + .getByLabel('Project key', { exact: true }) + .getAttribute('aria-invalid'), + 'true', + ); + await page + .getByText('The key has already been taken.', { exact: true }) + .waitFor(); + await page + .getByText('Select a valid configuration area.', { exact: true }) + .waitFor(); + assert.equal( + await page + .getByRole('checkbox', { name: 'Board columns', exact: true }) + .getAttribute('aria-invalid'), + 'true', + ); + + await choose('scratch'); + assert.equal( + await page + .getByText('Select a valid configuration area.', { exact: true }) + .count(), + 0, + ); + assert.equal( + await page + .getByLabel('Project key', { exact: true }) + .getAttribute('aria-invalid'), + 'true', + ); + await choose('duplicate'); + assert.equal( + await page + .getByRole('checkbox', { name: 'Board columns', exact: true }) + .isChecked(), + true, + ); + assert.equal( + await page + .getByRole('checkbox', { name: 'Board columns', exact: true }) + .getAttribute('aria-invalid'), + 'false', + ); + + await page.route('**/projects', (route) => route.abort('failed')); + await submit(); + await state('network'); + assert.equal( + await page.getByLabel('Project name', { exact: true }).inputValue(), + 'Frontend workspace', + ); + await page.unroute('**/projects'); + await setResult('server'); + await page.getByRole('button', { name: 'Try again' }).click(); + await state('server'); + await setResult('success'); + let pending; + let submissions = 0; + await page.route('**/projects', (route) => { + submissions++; + pending = route; + }); + await page.getByRole('button', { name: 'Try again' }).click(); + await state('loading'); + assert.equal( + await page + .getByRole('button', { name: 'Creating project…', exact: true }) + .isDisabled(), + true, + ); + assert.equal( + await page.getByLabel('Project name', { exact: true }).isDisabled(), + true, + ); + await page.locator('form').evaluate((form) => form.requestSubmit()); + assert.equal(submissions, 1); + await pending.continue(); + await page.waitForURL('**/projects/99'); + await page.unroute('**/projects'); + + await page.goto(`${base}/projects/create`); + await choose('duplicate'); + await fillDetails(); + const emptyCopyRequest = page.waitForRequest( + (request) => request.method() === 'POST', + ); + await submit(); + assert.deepEqual((await emptyCopyRequest).postDataJSON().copy_areas, []); + await page.waitForURL('**/projects/99'); + assert.deepEqual(errors, []); + console.log( + 'Project creation browser checks passed: all modes, preselection, payloads, retained input, validation, retry, duplicate submission protection, keyboard, 320/390/1440px and both themes.', + ); +} finally { + await browser.close(); +} diff --git a/tests/Fixtures/project-creation-preview.mjs b/tests/Fixtures/project-creation-preview.mjs new file mode 100644 index 0000000..2c4b387 --- /dev/null +++ b/tests/Fixtures/project-creation-preview.mjs @@ -0,0 +1,19 @@ +import { createApp, h } from 'vue'; +import { createInertiaApp } from '@inertiajs/vue3'; +import '../../resources/css/app.css'; +import Create from '../../resources/js/pages/projects/Create.vue'; +import { createAppearance } from '../../resources/js/composables/useAppearance'; +import { createLocalization } from '../../resources/js/lib/localization'; + +createInertiaApp({ + resolve: (name) => + name === 'Create' + ? Create + : { render: () => h('h1', 'Project created') }, + setup: ({ el, App, props, plugin }) => + createApp({ render: () => h(App, props) }) + .use(plugin) + .use(createLocalization()) + .use(createAppearance()) + .mount(el), +}); diff --git a/tests/Fixtures/project-creation.mjs b/tests/Fixtures/project-creation.mjs new file mode 100644 index 0000000..67e71f7 --- /dev/null +++ b/tests/Fixtures/project-creation.mjs @@ -0,0 +1,256 @@ +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-creation-fixture', + plugins: [vue(), ...(serve ? [tailwind()] : [])], + resolve: { + alias: { + '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), + }, + }, + server: serve + ? { host: '127.0.0.1', port: 4179, strictPort: true, hmr: false } + : { middlewareMode: true, hmr: false, watch: null }, + appType: 'custom', +}); +const areas = [ + 'project_settings', + 'board_columns', + 'ticket_statuses', + 'status_transitions', + 'ticket_types', + 'ticket_priorities', + 'ticket_fields', + 'ticket_link_types', + 'repository_connections', + 'notification_actions', +]; +function makePage(url, errors = {}) { + const query = new URL(url, 'http://localhost').searchParams; + return { + component: url.startsWith('/projects/create') + ? 'Create' + : '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: true, + canManageTeams: false, + }, + }, + navigation: { project: null }, + copyAreaOptions: areas.map((value) => ({ + value, + label: value, + description: 'Server description', + })), + sourceProjects: query.has('empty') + ? [] + : [ + { + id: 7, + key: 'ENG', + name: 'Engineering · Platform and developer experience', + }, + { id: 8, key: 'OPS', name: 'Operations' }, + ], + sourceTemplates: + query.has('empty') || query.has('no-templates') + ? [] + : [ + { + id: 3, + name: 'Team baseline', + slug: 'team-baseline', + copy_areas: ['board_columns', 'ticket_types'], + }, + { + id: 4, + name: 'Minimal setup', + slug: 'minimal', + copy_areas: [], + }, + ], + selectedSourceTemplateId: query.has('template') + ? Number(query.get('template')) + : null, + }, + }; +} +if (serve) { + // Real Vue/Inertia form with isolated transport; PHPUnit covers persisted projects. + server.middlewares.use(async (request, response, next) => { + if (!request.url.startsWith('/projects')) { + return next(); + } + let url = request.url; + let errors = {}; + if (request.method === 'POST') { + const result = + request.headers.cookie?.match( + /creation_test_result=(\w+)/, + )?.[1] ?? 'success'; + let body = ''; + for await (const chunk of request) { + body += chunk; + } + const data = JSON.parse(body); + if (result === 'server') { + response.statusCode = 503; + response.end('Simulated server failure'); + return; + } + url = result === 'validation' ? '/projects/create' : '/projects/99'; + if (result === 'validation') { + errors = { + key: 'The key has already been taken.', + ...(data.creation_mode === 'duplicate' + ? { + 'copy_areas.0': + 'Select a valid configuration area.', + } + : {}), + }; + } + } + const page = makePage(url, 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 creation fixture ready at http://127.0.0.1:4179/projects/create', + ); +} 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 Create = ( + await server.ssrLoadModule( + '/resources/js/pages/projects/Create.vue', + ) + ).default; + const render = async ( + url, + errors = {}, + locale = createLocalization(), + ) => { + const response = await createInertiaApp({ + page: makePage(url, errors), + resolve: () => Create, + render: renderToString, + setup: ({ App, props, plugin }) => + createSSRApp({ render: () => h(App, props) }) + .use(plugin) + .use(locale) + .use(createAppearance('light')), + }); + return response.body.split('')[0]; + }; + const empty = await render('/projects/create?empty'); + assert.match(empty, /value="scratch"[^>]*checked/); + assert.match(empty, /value="duplicate"[^>]*disabled/); + assert.match(empty, /value="template"[^>]*disabled/); + assert.match(empty, /No accessible projects/); + assert.match(empty, /No published templates/); + assert.doesNotMatch(empty, /