+
+ {{ t('inheritance.help') }}
+
+
-
- {{ area.label }} · template overridden
-
- {{
- activeArea === area.value && form.processing
- ? 'Resetting…'
- : 'Reset to template'
- }}
-
-
-
-
-
- Choose where tickets in local statuses should move
- before those statuses are removed.
-
-
{{
+ areaLabel(area)
+ }}
+
{{ t('projectSettings.inherited') }}
-
- {{ status.name }} ({{ status.ticketCount }} tickets)
-
-
-
-
- Select replacement status
-
-
- {{ option.name }}
-
-
-
-
+
+
+
+ {{
+ areaLabel(area)
+ }}
+ {{
+ t('projectSettings.override')
+ }}
-
-
-
-
+
+
+ {{ t('inheritance.overrideHelp') }}
+
+
+ {{
+ t('inheritance.reset')
+ }}
+
+
+
+
+
+
+
+ {{
+ t('inheritance.confirmTitle')
+ }}
+ {{
+ t('inheritance.confirmDescription', {
+ area: pendingArea ? areaLabel(pendingArea) : '',
+ template: templateState.templateName,
+ })
+ }}
+
+
+
+
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 @@
+
+
+
+
+
+
+
+
+
+
+ {{ item.title }}
+
+
+
+
+
+
+
+
+
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 @@
-
-
-
-
-
-
- {{ project.key }}
-
-
-
-
-
-
-
- Board settings
-
-
-
-
-
- Ticket settings
-
-
-
-
-
- Repository settings
-
-
-
-
-
- Notification settings
-
-
-
-
- Back to project
-
-
-
-
-
+
+
+
- {{ status }}
-
-
-
-
- Project identity
-
- These values define the project across tickets, boards,
- and repository matching.
-
+
+ {{ t('navigation.general') }}
+
+ {{ t('projectSettings.generalDescription') }}
+
-
-
-
-
- Project name
-
-
- {{ project.name }}
-
+
+
+
+ {{ t('projectSettings.identity') }}
+
+ {{
+ t('projectSettings.local')
+ }}
-
-
-
+
+
+ {{ t('projectSettings.name') }}
+
+
+ {{ project.name }}
+
+
+
+
+ {{ t('projectSettings.key') }}
+
+
+ {{ project.key }}
+
+
+
+
+ {{ t('projectSettings.identityDescription') }}
+
+
+
+ {{ t('projectSettings.display') }}
+
+
{{ t(settingsSource) }}
- Project key
-
-
- {{ project.key }}
-
+
+
+
+ {{ t('projectSettings.showAuthor') }}
+
+
+ {{
+ t(
+ project.show_author
+ ? 'projectSettings.enabled'
+ : 'projectSettings.disabled',
+ )
+ }}
+
+
+
+
+ {{ t('projectSettings.showStoryPoints') }}
+
+
+ {{
+ t(
+ project.show_story_points
+ ? 'projectSettings.enabled'
+ : 'projectSettings.disabled',
+ )
+ }}
+
+
+
+
+ {{ t('projectSettings.displayDescription') }}
+
-
-
-
- Template baseline
-
- Switch this project to another published template. Use
- the inheritance panels to reset individual settings to
- the currently linked template.
-
-
-
-
-
-
-
- Current linked template
-
-
- {{
- currentTemplate?.name ??
- 'No linked template'
- }}
-
-
- {{
- templateState?.inheritsFromTemplate
- ? 'Inherited and overridden areas stay visible after switching.'
- : 'This project currently behaves like a standalone baseline.'
- }}
-
-
-
-
-
- Warning
-
-
- Switching templates can replace inherited board
- workflow, ticket metadata, repositories, and
- notifications.
-
-
- Confirm each action with the exact project key
- to avoid accidental overwrites.
-
-
+
+
+
+
+
+
+ {{ t('projectSettings.template') }}
+
-
-
-
-
-
-
-
-
- Danger zone
-
-
- Deleting this project is permanent. All tickets, board
- columns, ticket metadata, repository connections, and
- project permissions will be removed.
-
-
-
-
-
-
- Confirm by typing the exact project key:
- {{ project.key }}
-
-
-
-
-
-
- Confirmation key
-
-
-
-
-
-
-
- This action cannot be undone.
-
-
{{
+ t(
+ switchTemplateForm.processing
+ ? 'projectSettings.switching'
+ : 'projectSettings.switch',
+ )
+ }}
- Delete project
-
-
+
-
+
+
+
+
+
+ {{ t('projectSettings.danger') }}
+
+
+
+ {{ t('projectSettings.deleteDescription') }}
+
+
+
+
+
+
+
+ {{ t('projectSettings.delete') }}
+
+
+ {{ t('projectSettings.irreversible') }}
+
+
+
{{
+ t('projectSettings.confirmation')
+ }}
+
+
+ {{
+ t('projectSettings.confirmHelp', {
+ key: project.key,
+ })
+ }}
+
+
+
+ {{
+ t(
+ deleteForm.processing
+ ? 'projectSettings.deleting'
+ : 'projectSettings.delete',
+ )
+ }}
+
+
+
+
+
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();
+ }
+}