diff --git a/resources/js/components/projects/TemplateInheritancePanel.vue b/resources/js/components/projects/TemplateInheritancePanel.vue index cc87e57..c708e70 100644 --- a/resources/js/components/projects/TemplateInheritancePanel.vue +++ b/resources/js/components/projects/TemplateInheritancePanel.vue @@ -1,26 +1,32 @@ diff --git a/resources/js/layouts/projects/SettingsLayout.vue b/resources/js/layouts/projects/SettingsLayout.vue new file mode 100644 index 0000000..bb7affc --- /dev/null +++ b/resources/js/layouts/projects/SettingsLayout.vue @@ -0,0 +1,73 @@ + + + diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts index 5be342d..fe6766b 100644 --- a/resources/js/locales/en.ts +++ b/resources/js/locales/en.ts @@ -731,6 +731,84 @@ export const en = { 'ticketForm.saved': 'All changes saved', 'ticketForm.authorRestricted': 'Only project admins can change the author.', 'ticketForm.blocker': 'Blocker', + 'projectSettings.pageTitle': '{project} general settings', + 'projectSettings.description': + 'Manage settings and template inheritance for {project}.', + 'projectSettings.generalDescription': + 'Review project identity, setting sources, and administrative actions.', + 'projectSettings.identity': 'Project identity', + 'projectSettings.identityDescription': + 'The name and key belong to this project and are never inherited from a template.', + 'projectSettings.name': 'Project name', + 'projectSettings.key': 'Project key', + 'projectSettings.local': 'Local', + 'projectSettings.inherited': 'Inherited', + 'projectSettings.override': 'Local override', + 'projectSettings.display': 'Ticket display', + 'projectSettings.showAuthor': 'Show author', + 'projectSettings.showStoryPoints': 'Show story points', + 'projectSettings.enabled': 'Enabled', + 'projectSettings.disabled': 'Disabled', + 'projectSettings.displayDescription': + 'Manage these display options in Ticket settings.', + 'projectSettings.template': 'Template baseline', + 'projectSettings.templateDescription': + 'Manage the linked template or reset individual areas using the inheritance panel.', + 'projectSettings.currentTemplate': 'Current linked template', + 'projectSettings.noTemplate': 'No linked template', + 'projectSettings.linked': 'Template inheritance active', + 'projectSettings.standalone': 'Standalone', + 'projectSettings.standaloneDescription': + 'This project manages its settings locally. Published template updates are not applied.', + 'projectSettings.noAlternatives': 'No other published templates', + 'projectSettings.noAlternativesDescription': + 'A different published template is required to switch this project.', + 'projectSettings.switch': 'Switch template', + 'projectSettings.switching': 'Switching template…', + 'projectSettings.switchWarning': + 'Switching templates can replace or remove project configuration in copied areas, including workflows, ticket metadata, repositories, and notifications.', + 'projectSettings.publishedTemplate': 'Published template', + 'projectSettings.selectTemplate': 'Select a template', + 'projectSettings.confirmation': 'Confirmation key', + 'projectSettings.confirmPlaceholder': 'Type {key} exactly', + 'projectSettings.confirmHelp': + 'Enter the exact project key {key} to confirm this action. The key is case sensitive.', + 'projectSettings.danger': 'Danger zone', + 'projectSettings.deleteDescription': + 'Permanently delete this project and all of its tickets, board columns, ticket metadata, repository connections, and project permissions.', + 'projectSettings.irreversible': 'This action cannot be undone.', + 'projectSettings.delete': 'Delete project', + 'projectSettings.deleting': 'Deleting project…', + 'projectSettings.updated': 'Project settings updated.', + 'inheritance.title': 'Template inheritance', + 'inheritance.description': 'Settings linked to {template}.', + 'inheritance.help': + 'Each area inherits independently. Settings outside the template are managed locally.', + 'inheritance.areas': 'Setting sources', + 'inheritance.overrideHelp': + 'Local changes stay in place when the template is updated.', + 'inheritance.reset': 'Reset to template', + 'inheritance.resetArea': 'Reset {area} to template', + 'inheritance.resetting': 'Resetting…', + 'inheritance.confirmTitle': 'Reset this setting area?', + 'inheritance.confirmDescription': + 'Replace local changes in {area} with the current baseline from {template}. Other setting areas keep their current values.', + 'inheritance.resetWarning': + 'Local configuration in this area may be replaced or removed.', + 'inheritance.statusHelp': + 'Choose where tickets in local statuses should move before those statuses are removed.', + 'inheritance.statusLabel': '{status} ({count} tickets)', + 'inheritance.selectStatus': 'Select replacement status', + 'inheritance.area.projectSettings': 'Project settings', + 'inheritance.area.boardColumns': 'Board columns', + 'inheritance.area.ticketStatuses': 'Ticket statuses', + 'inheritance.area.statusTransitions': 'Status transitions', + 'inheritance.area.ticketTypes': 'Ticket types', + 'inheritance.area.ticketPriorities': 'Ticket priorities', + 'inheritance.area.ticketFields': 'Custom ticket fields', + 'inheritance.area.ticketLinkTypes': 'Ticket link types', + 'inheritance.area.repositoryConnections': 'Repository connections', + 'inheritance.area.notificationActions': 'Notification actions', 'gitConnections.description': 'Connect your personal Git account to browse repositories, branches, and pull requests you can access.', 'gitConnections.privacy': diff --git a/resources/js/pages/projects/settings/General.vue b/resources/js/pages/projects/settings/General.vue index 67e12d3..b2ff14f 100644 --- a/resources/js/pages/projects/settings/General.vue +++ b/resources/js/pages/projects/settings/General.vue @@ -1,281 +1,316 @@ diff --git a/tests/Feature/Projects/ProjectGeneralSettingsUiTest.php b/tests/Feature/Projects/ProjectGeneralSettingsUiTest.php new file mode 100644 index 0000000..644d258 --- /dev/null +++ b/tests/Feature/Projects/ProjectGeneralSettingsUiTest.php @@ -0,0 +1,18 @@ +setTimeout(60); + $process->run(); + $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput()); + $this->assertStringContainsString('Project general settings rendering checks passed.', $process->getOutput()); + } +} diff --git a/tests/Fixtures/project-general-settings-browser.mjs b/tests/Fixtures/project-general-settings-browser.mjs new file mode 100644 index 0000000..143d229 --- /dev/null +++ b/tests/Fixtures/project-general-settings-browser.mjs @@ -0,0 +1,263 @@ +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:4187'; +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 mutations = []; +page.on('request', (request) => { + if (['POST', 'DELETE'].includes(request.method())) { + mutations.push(request); + } +}); +const dialog = () => page.getByRole('dialog'); +const button = (name) => page.getByRole('button', { name, exact: true }); +const confirmReset = () => + dialog().getByRole('button', { name: 'Reset to template', exact: true }); +const result = (value) => + context.addCookies([{ name: 'general_test_result', value, url: base }]); +const visit = async (query = '?reset') => { + await result('success'); + await page.goto(base + '/projects/1/settings/general' + query); + await page.getByRole('heading', { name: 'General', exact: true }).waitFor(); +}; +const noOverflow = async () => + assert.equal( + await page.evaluate( + () => document.documentElement.scrollWidth <= window.innerWidth, + ), + true, + ); +const formState = (form, state) => + form + .locator('[data-form-state="' + state + '"]') + .waitFor({ state: 'visible' }); +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 }); + await visit(); + await noOverflow(); + const nav = page.getByRole('navigation', { + name: 'Project settings', + exact: true, + }); + assert.equal( + await nav + .getByRole('link', { name: 'General', exact: true }) + .getAttribute('aria-current'), + 'page', + ); + assert.equal( + await nav + .getByRole('link', { name: 'Ticket settings', exact: true }) + .getAttribute('href'), + '/projects/1/settings/metadata', + ); + const sources = page.getByRole('list', { name: 'Setting sources' }); + await sources.getByText('Inherited', { exact: true }).waitFor(); + await sources + .getByText('Local override', { exact: true }) + .first() + .waitFor(); + assert.equal(await button('Switch template').isDisabled(), true); + assert.equal(await button('Delete project').isDisabled(), true); + if (width === 390 || width === 1440) { + await page.screenshot({ + path: '/private/tmp/KAN-39-' + width + '-' + theme + '.png', + fullPage: true, + }); + } + const trigger = button('Reset Project settings to template'); + await trigger.focus(); + await page.keyboard.press('Enter'); + await dialog().waitFor(); + await page.keyboard.press('Tab'); + assert.equal( + await dialog().evaluate((element) => + element.contains(document.activeElement), + ), + true, + ); + await noOverflow(); + await page.keyboard.press('Escape'); + await dialog().waitFor({ state: 'hidden' }); + assert.equal( + await trigger.evaluate( + (element) => document.activeElement === element, + ), + true, + ); + assert.equal( + mutations.length, + 0, + 'Opening and cancelling reset must not send a mutation', + ); + } + } + await visit('?reset&noTemplate'); + await page.getByText('No linked template', { exact: true }).waitFor(); + assert.equal(await button('Reset Project settings to template').count(), 0); + await visit('?reset&baseline'); + assert.equal( + await page.getByText('Local override', { exact: true }).count(), + 0, + ); + await visit('?reset&noAlternatives'); + await page + .getByText('No other published templates', { exact: true }) + .waitFor(); + assert.equal(await button('Switch template').count(), 0); + + await visit(); + await button('Reset Ticket statuses to template').click(); + await dialog().waitFor(); + assert.equal(await confirmReset().isDisabled(), true); + const replacement = page.locator('#replacement-10'); + await replacement.selectOption('1'); + await result('validation'); + const resetRequest = page.waitForRequest( + (request) => request.method() === 'POST', + ); + await confirmReset().click(); + assert.deepEqual((await resetRequest).postDataJSON(), { + areas: ['ticket_statuses'], + status_replacements: { 10: 1 }, + }); + await formState(dialog(), 'validation'); + await page + .getByText('This area could not be reset.', { exact: true }) + .waitFor(); + assert.equal(await replacement.inputValue(), '1'); + await result('server'); + await confirmReset().click(); + await formState(dialog(), 'server'); + await result('success'); + await dialog() + .getByRole('button', { name: 'Try again', exact: true }) + .click(); + await dialog().waitFor({ state: 'hidden' }); + const sources = page.getByRole('list', { name: 'Setting sources' }); + assert.equal( + await sources.getByText('Local override', { exact: true }).count(), + 1, + 'Resetting statuses preserves project settings override', + ); + assert.equal(await button('Reset Ticket statuses to template').count(), 0); + await button('Reset Project settings to template').click(); + await confirmReset().click(); + await dialog().waitFor({ state: 'hidden' }); + assert.equal( + await sources.getByText('Local override', { exact: true }).count(), + 0, + ); + assert.equal(await page.getByText('Enabled', { exact: true }).count(), 2); + + await visit(); + const switchForm = page + .locator('form') + .filter({ has: page.locator('#source_template_id') }); + const select = page.locator('#source_template_id'); + assert.equal( + await select.locator('option[value="1"]').count(), + 0, + 'Current template must not be selectable', + ); + await select.selectOption('2'); + await page.locator('#switch_confirmation_key').fill('core'); + assert.equal(await button('Switch template').isDisabled(), true); + const countBefore = mutations.length; + await switchForm.evaluate((element) => + element.dispatchEvent( + new Event('submit', { bubbles: true, cancelable: true }), + ), + ); + assert.equal( + mutations.length, + countBefore, + 'Submit guard must reject a mismatching key', + ); + await page.locator('#switch_confirmation_key').fill('CORE'); + await result('validation'); + await button('Switch template').click(); + await formState(switchForm, 'validation'); + assert.equal( + await page.locator('#switch_confirmation_key').inputValue(), + 'CORE', + ); + assert.equal( + await page + .locator('#switch_confirmation_key') + .getAttribute('aria-invalid'), + 'true', + ); + await page.route('**/switch-template', (route) => route.abort('failed')); + await button('Switch template').click(); + await formState(switchForm, 'network'); + await page.unroute('**/switch-template'); + await result('success'); + const switched = page.waitForRequest( + (request) => request.method() === 'POST', + ); + await switchForm + .getByRole('button', { name: 'Try again', exact: true }) + .click(); + assert.deepEqual((await switched).postDataJSON(), { + confirmation_key: 'CORE', + source_template_id: 2, + }); + await page.getByText('Delivery baseline', { exact: true }).waitFor(); + assert.equal( + await page.locator('#switch_confirmation_key').inputValue(), + '', + ); + assert.equal(await button('Switch template').isDisabled(), true); + + await visit(); + const deleteForm = page + .locator('form') + .filter({ has: page.locator('#confirmation_key') }); + await page.locator('#confirmation_key').fill('core'); + assert.equal(await button('Delete project').isDisabled(), true); + const beforeDelete = mutations.length; + await deleteForm.evaluate((element) => + element.dispatchEvent( + new Event('submit', { bubbles: true, cancelable: true }), + ), + ); + assert.equal(mutations.length, beforeDelete); + await page.locator('#confirmation_key').fill('CORE'); + await result('validation'); + await button('Delete project').click(); + await formState(deleteForm, 'validation'); + assert.equal( + await page.locator('#confirmation_key').getAttribute('aria-invalid'), + 'true', + ); + assert.equal(await page.locator('#confirmation_key').inputValue(), 'CORE'); + await result('success'); + const deleted = page.waitForRequest( + (request) => request.method() === 'DELETE', + ); + await button('Delete project').click(); + const deletion = await deleted; + assert.equal(new URL(deletion.url()).pathname, '/projects/1'); + assert.deepEqual(deletion.postDataJSON(), { confirmation_key: 'CORE' }); + await page.getByRole('heading', { name: 'Destination reached' }).waitFor(); + assert.deepEqual(errors, []); + console.log( + 'Project general settings browser checks passed: setting sources, scoped resets, confirmations, replacements, errors, retries, keyboard, 320/390/1440px and both themes.', + ); +} finally { + await browser.close(); +} diff --git a/tests/Fixtures/project-general-settings-preview.mjs b/tests/Fixtures/project-general-settings-preview.mjs new file mode 100644 index 0000000..4a35136 --- /dev/null +++ b/tests/Fixtures/project-general-settings-preview.mjs @@ -0,0 +1,19 @@ +import { createApp, h } from 'vue'; +import { createInertiaApp } from '@inertiajs/vue3'; +import '../../resources/css/app.css'; +import General from '../../resources/js/pages/projects/settings/General.vue'; +import { createAppearance } from '../../resources/js/composables/useAppearance'; +import { createLocalization } from '../../resources/js/lib/localization'; + +createInertiaApp({ + resolve: (name) => + name === 'General' + ? General + : { 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-general-settings.mjs b/tests/Fixtures/project-general-settings.mjs new file mode 100644 index 0000000..d728d7a --- /dev/null +++ b/tests/Fixtures/project-general-settings.mjs @@ -0,0 +1,307 @@ +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-general-settings-fixture', + plugins: [vue(), ...(serve ? [tailwind()] : [])], + resolve: { + alias: { + '@': fileURLToPath(new URL('../../resources/js', import.meta.url)), + }, + }, + server: serve + ? { host: '127.0.0.1', port: 4187, strictPort: true, hmr: false } + : { middlewareMode: true, hmr: false, watch: null }, + appType: 'custom', +}); +const area = (value, label) => ({ value, label }); +const template = { + id: 1, + name: 'Team baseline', + is_published: true, + copy_areas: [], +}; +const alternative = { ...template, id: 2, name: 'Delivery baseline' }; +function initial() { + return { + project: { + id: 1, + key: 'CORE', + name: 'Core platform', + project_template_id: 1, + project_template: template, + show_author: false, + show_story_points: false, + }, + sourceTemplates: [template, alternative], + status: null, + templateState: { + templateId: 1, + templateName: 'Team baseline', + inheritsFromTemplate: true, + inheritedAreas: [area('board_columns', 'Board columns')], + overriddenAreas: [ + area('project_settings', 'Project settings'), + area('ticket_statuses', 'Ticket statuses'), + ], + effectiveConfiguration: {}, + resetRequirements: { + ticket_statuses: { + removedStatuses: [ + { id: 10, name: 'Local review', ticketCount: 3 }, + ], + replacementOptions: [{ id: 1, name: 'Ready' }], + }, + }, + }, + }; +} +function makePage( + extra = {}, + url = '/projects/1/settings/general', + component = 'General', +) { + 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(); + 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'; + const result = + request.headers.cookie?.match(/general_test_result=(\w+)/)?.[1] ?? + 'success'; + if (!mutation && url.searchParams.has('reset')) { + data = initial(); + } + let extra = {}; + let component = 'General'; + let pageUrl = '/projects/1/settings/general'; + if (mutation) { + if (['server', 'forbidden'].includes(result)) { + response.statusCode = result === 'forbidden' ? 403 : 503; + response.end('Simulated request failure'); + return; + } + let body = ''; + for await (const chunk of request) { + body += chunk; + } + const submitted = JSON.parse(body || '{}'); + if (result === 'validation') { + extra.errors = url.pathname.endsWith('reset-template') + ? { 'areas.0': 'This area could not be reset.' } + : { confirmation_key: 'Enter the exact project key.' }; + } else if (request.method === 'DELETE') { + component = 'Destination'; + pageUrl = '/projects'; + } else if (url.pathname.endsWith('reset-template')) { + const reset = data.templateState.overriddenAreas.find( + (item) => item.value === submitted.areas[0], + ); + data.templateState.overriddenAreas = + data.templateState.overriddenAreas.filter( + (item) => item.value !== reset.value, + ); + data.templateState.inheritedAreas.push(reset); + if (reset.value === 'project_settings') { + data.project.show_author = true; + data.project.show_story_points = true; + } + data.status = 'Project template baseline was reset.'; + } else { + data.project.project_template = alternative; + data.project.project_template_id = 2; + data.templateState.templateName = alternative.name; + data.templateState.templateId = 2; + data.status = 'Project template baseline was switched.'; + } + } + if (url.searchParams.has('noTemplate')) { + extra = { + project: { + ...data.project, + project_template: null, + project_template_id: null, + }, + templateState: null, + }; + } + if (url.searchParams.has('baseline')) { + extra.templateState = { + ...data.templateState, + inheritsFromTemplate: false, + }; + } + if (url.searchParams.has('noAlternatives')) { + extra.sourceTemplates = [template]; + } + const page = makePage({ ...data, ...extra }, pageUrl, component); + 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 general 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/General.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, /Local override/); + assert.match(html, /Inherited/); + assert.match(html, /Team baseline/); + assert.match(html, /Show author/); + assert.match(html, /href="\/projects\/1\/settings\/metadata"/); + assert.match(html, /aria-current="page"/); + assert.match(html, /Reset Project settings to template/); + assert.match(html, /Type CORE exactly/); + const buttons = html.match(/]*>[\s\S]*?<\/button>/g); + for (const label of ['Switch template', 'Delete project']) { + const button = buttons.find((item) => item.includes(label)); + assert.match(button, /disabled/); + assert.match(button, /type="submit"/); + } + const standalone = await render({ + templateState: null, + project: { ...initial().project, project_template: null }, + }); + assert.match(standalone, /No linked template/); + assert.doesNotMatch(standalone, /Reset Project settings to template/); + assert.doesNotMatch( + await render({ + templateState: { + ...initial().templateState, + inheritsFromTemplate: false, + }, + }), + /Local override/, + ); + assert.match( + await render({ sourceTemplates: [template] }), + /No other published templates/, + ); + const inherited = await render({ + templateState: { + ...initial().templateState, + inheritedAreas: [area('project_settings', 'Project settings')], + overriddenAreas: [], + }, + }); + assert.doesNotMatch( + inherited, + /Local override|Reset Project settings to template/, + ); + assert.match( + await render( + {}, + createLocalization( + { locale: 'de' }, + { + de: { + ...en, + 'projectSettings.danger': 'Gefahrenbereich', + 'inheritance.area.projectSettings': + 'Projekteinstellungen', + }, + }, + ), + ), + /Gefahrenbereich/, + ); + console.log('Project general settings rendering checks passed.'); + } finally { + await server.close(); + } +}