diff --git a/app/Http/Requests/UpdateProjectTemplateRequest.php b/app/Http/Requests/UpdateProjectTemplateRequest.php
index 1007bdd..1e037ca 100644
--- a/app/Http/Requests/UpdateProjectTemplateRequest.php
+++ b/app/Http/Requests/UpdateProjectTemplateRequest.php
@@ -29,6 +29,7 @@ class UpdateProjectTemplateRequest extends FormRequest
'copy_areas' => ['required', 'array', 'min:1'],
'copy_areas.*' => ['string', Rule::in(DuplicateProjectConfiguration::acceptedCopyAreas())],
'confirm_linked_projects_update' => [
+ Rule::excludeIf(! $requiresImpactConfirmation),
Rule::requiredIf($requiresImpactConfirmation),
'accepted',
],
diff --git a/resources/js/components/projects/ProjectTemplateForm.vue b/resources/js/components/projects/ProjectTemplateForm.vue
new file mode 100644
index 0000000..f0bb3f0
--- /dev/null
+++ b/resources/js/components/projects/ProjectTemplateForm.vue
@@ -0,0 +1,347 @@
+
+
+
+
+
diff --git a/resources/js/composables/useProjectTemplateAreas.ts b/resources/js/composables/useProjectTemplateAreas.ts
new file mode 100644
index 0000000..c8f2aac
--- /dev/null
+++ b/resources/js/composables/useProjectTemplateAreas.ts
@@ -0,0 +1,27 @@
+import { useLocalization } from '@/composables/useLocalization';
+
+const areaKeys = {
+ project_settings: 'projectCreation.areas.projectSettings',
+ board_columns: 'projectCreation.areas.boardColumns',
+ ticket_statuses: 'projectCreation.areas.ticketStatuses',
+ status_transitions: 'projectCreation.areas.statusTransitions',
+ ticket_types: 'projectCreation.areas.ticketTypes',
+ ticket_priorities: 'projectCreation.areas.ticketPriorities',
+ ticket_fields: 'projectCreation.areas.ticketFields',
+ ticket_link_types: 'projectCreation.areas.ticketLinkTypes',
+ repository_connections: 'projectCreation.areas.repositories',
+ notification_actions: 'projectCreation.areas.notifications',
+} as const;
+
+export function useProjectTemplateAreas() {
+ const { t } = useLocalization();
+ function areaText(
+ value: string,
+ part: 'label' | 'description',
+ fallback = value,
+ ): string {
+ const key = areaKeys[value as keyof typeof areaKeys];
+ return key ? t(`${key}.${part}`) : fallback;
+ }
+ return { areaText };
+}
diff --git a/resources/js/locales/en.ts b/resources/js/locales/en.ts
index fd2438c..94fb7a9 100644
--- a/resources/js/locales/en.ts
+++ b/resources/js/locales/en.ts
@@ -1,4 +1,68 @@
export const en = {
+ 'templates.description':
+ 'Reuse a proven project setup. Manage published templates and see which projects inherit their configuration.',
+ 'templates.updated': 'Project templates updated.',
+ 'templates.fromProject': 'Start with an existing project',
+ 'templates.fromProjectHelp':
+ 'Open a project and choose “Save as new template” to capture its configuration.',
+ 'templates.browseProjects': 'Browse projects',
+ 'templates.empty': 'No published templates yet',
+ 'templates.emptyHelp':
+ 'Save a project as a template to reuse its settings. Internal project baselines are kept out of this list.',
+ 'templates.published': 'Published project templates',
+ 'templates.linkedCount': {
+ one: '{count} linked project',
+ other: '{count} linked projects',
+ },
+ 'templates.savedFrom': 'Saved from {project} ({key}).',
+ 'templates.sourceUnavailable': 'Source project unavailable',
+ 'templates.edit': 'Edit',
+ 'templates.use': 'Use template',
+ 'templates.createTitle': 'Save {project} as a template',
+ 'templates.createDescription':
+ 'Choose the configuration to reuse in future projects.',
+ 'templates.editTitle': 'Edit {name}',
+ 'templates.editDescription':
+ 'Refresh the template from its source project and review the impact on linked projects.',
+ 'templates.defaultName': '{project} template',
+ 'templates.details': 'Template details',
+ 'templates.name': 'Template name',
+ 'templates.namePlaceholder': 'Foundation template',
+ 'templates.slug': 'Template slug',
+ 'templates.slugPlaceholder': 'foundation-template',
+ 'templates.slugHelp':
+ 'Slugs are normalized automatically. After creation, the slug cannot be changed.',
+ 'templates.slugLocked':
+ 'The slug is locked so existing template references remain stable.',
+ 'templates.groups': 'Configuration groups',
+ 'templates.createGroupsHelp':
+ 'Only selected groups are saved. Related groups required by the workflow are included automatically.',
+ 'templates.editGroupsHelp':
+ 'Saving rebuilds selected groups from the current source project. Required workflow groups are included automatically.',
+ 'templates.impact': 'Template impact',
+ 'templates.inheritance': 'Inheritance',
+ 'templates.cleanStart':
+ 'Tickets and other operational ticket data are never copied. The source project stays unchanged.',
+ 'templates.refreshHelp':
+ 'Inherited groups follow the refreshed template. Local overrides stay unchanged. Removed groups stay local to each project.',
+ 'templates.linkedWarning': 'Linked projects will be affected',
+ 'templates.linkedImpact':
+ 'Saving updates the baseline for {count} linked projects. Inherited groups follow the new configuration; existing local overrides stay unchanged.',
+ 'templates.acknowledge':
+ 'I understand that projects inheriting from this template will be affected.',
+ 'templates.saveNew': 'Save new template',
+ 'templates.saveChanges': 'Save template changes',
+ 'templates.missingSource': 'This template cannot be refreshed',
+ 'templates.missingSourceHelp':
+ 'The source project is no longer available. You can still use the saved template configuration to create a project.',
+ 'templates.deleteTitle': 'Delete {name}',
+ 'templates.delete': 'Delete template',
+ 'templates.deleting': 'Deleting template…',
+ 'templates.deleteHelp':
+ 'This permanently removes the reusable template. It will no longer be available for new projects.',
+ 'templates.deleteImpact':
+ '{count} linked projects will stop inheriting from this template. Their current configuration and tickets are kept.',
+
'workflow.title': 'Board workflow',
'workflow.pageTitle': '{project} · Board workflow',
'workflow.description':
diff --git a/resources/js/pages/project-templates/Create.vue b/resources/js/pages/project-templates/Create.vue
index 3df553c..e9eb28e 100644
--- a/resources/js/pages/project-templates/Create.vue
+++ b/resources/js/pages/project-templates/Create.vue
@@ -1,174 +1,38 @@
-
-
-
-
- {{
- project.key
- }}
-
-
-
-
-
- Template details
-
- Pick the configuration groups that should be reusable in
- future projects.
-
-
-
-
-
-
-
+
+
diff --git a/resources/js/pages/project-templates/Edit.vue b/resources/js/pages/project-templates/Edit.vue
index 8da0ed7..01e1c79 100644
--- a/resources/js/pages/project-templates/Edit.vue
+++ b/resources/js/pages/project-templates/Edit.vue
@@ -1,223 +1,36 @@
-
-
-
-
-
- {{
- template.slug
- }}
-
- {{ linkedProjectCount }} linked
- {{ linkedProjectCount === 1 ? 'project' : 'projects' }}
-
-
-
-
-
-
-
- Template details
-
-
- This template baseline is rebuilt from
- {{ template.source_project.name }} ({{
- template.source_project.key
- }}) when you save.
-
-
- This template baseline is rebuilt from its source
- project when you save.
-
-
-
-
-
-
-
-
+
+
diff --git a/resources/js/pages/project-templates/Index.vue b/resources/js/pages/project-templates/Index.vue
index c3fbc53..aea924a 100644
--- a/resources/js/pages/project-templates/Index.vue
+++ b/resources/js/pages/project-templates/Index.vue
@@ -1,158 +1,258 @@
-
-
-
-
+
-
-
-
-
-
-
{{ template.name }}
-
{{
- template.slug
- }}
-
+
+
+ {{ template.name }}
+
+
- {{ template.linked_project_count }} linked
- {{
- template.linked_project_count === 1
- ? 'project'
- : 'projects'
- }}
-
+ {{ template.slug }}
+
-
-
- Saved from
- {{ template.source_project.name }} ({{
- template.source_project.key
- }}).
-
-
- Saved template configuration.
-
-
-
-
-
-
- {{ copyArea }}
-
-
-
-
-
-
- Edit
-
-
-
-
- {{
+ t('templates.linkedCount', {
+ count: template.linked_project_count ?? 0,
+ })
+ }}
+
+ {{
+ template.source_project
+ ? t('templates.savedFrom', {
+ project: template.source_project.name,
+ key: template.source_project.key,
+ })
+ : t('templates.sourceUnavailable')
+ }}
+
+
+
+ {{ areaText(area, 'label') }}
- Use template
-
-
-
-
- Delete
-
-
-
-
-
+
+
+
+
+ {{ t('templates.use') }}
+ {{ t('templates.edit') }}
+
+
+
+
+
+
+
+ {{
+ t('templates.deleteTitle', {
+ name: pending?.name ?? '',
+ })
+ }}
+ {{
+ t('templates.deleteHelp')
+ }}
+
+
+ {{
+ t('templates.deleteImpact', {
+ count: pending.linked_project_count,
+ })
+ }}
+
+
+
+ {{ t('common.cancel') }}
+ {{ t('templates.delete') }}
+
+
+
diff --git a/tests/Feature/Projects/ProjectTemplateControllerTest.php b/tests/Feature/Projects/ProjectTemplateControllerTest.php
index 9bb0057..900e4ca 100644
--- a/tests/Feature/Projects/ProjectTemplateControllerTest.php
+++ b/tests/Feature/Projects/ProjectTemplateControllerTest.php
@@ -163,6 +163,26 @@ class ProjectTemplateControllerTest extends TestCase
->assertNotFound();
}
+ public function test_unlinked_templates_can_be_updated_without_an_impact_confirmation(): void
+ {
+ $user = User::factory()->create();
+ $source = Project::factory()->create();
+ $template = ProjectTemplate::factory()->create([
+ 'source_project_id' => $source->id,
+ ]);
+ $this->syncGlobalPermissions($user, ['project:create']);
+
+ $this->actingAs($user)
+ ->patch(route('project-templates.update', $template), [
+ 'name' => 'Updated reusable template',
+ 'copy_areas' => ['project_settings'],
+ ])
+ ->assertSessionHasNoErrors()
+ ->assertRedirect(route('project-templates.index'));
+
+ $this->assertSame('Updated reusable template', $template->fresh()->name);
+ }
+
public function test_updating_a_template_requires_warning_acknowledgement_when_projects_are_linked(): void
{
$user = User::factory()->create();
diff --git a/tests/Feature/Projects/ProjectTemplatesUiTest.php b/tests/Feature/Projects/ProjectTemplatesUiTest.php
new file mode 100644
index 0000000..8381e81
--- /dev/null
+++ b/tests/Feature/Projects/ProjectTemplatesUiTest.php
@@ -0,0 +1,18 @@
+setTimeout(60);
+ $process->run();
+ $this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
+ $this->assertStringContainsString('Project template checks passed.', $process->getOutput());
+ }
+}
diff --git a/tests/Fixtures/project-templates-browser.mjs b/tests/Fixtures/project-templates-browser.mjs
new file mode 100644
index 0000000..4ca9967
--- /dev/null
+++ b/tests/Fixtures/project-templates-browser.mjs
@@ -0,0 +1,260 @@
+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 context = await browser.newContext({
+ viewport: { width: 1440, height: 1000 },
+});
+const page = await context.newPage();
+page.setDefaultTimeout(10000);
+const base = 'http://127.0.0.1:4191';
+const errors = [];
+const mutations = [];
+page.on('pageerror', (error) => errors.push(error.message));
+page.on('request', (request) => {
+ if (['POST', 'PATCH', 'DELETE'].includes(request.method())) {
+ mutations.push(request);
+ }
+});
+const button = (name) => page.getByRole('button', { name, exact: true });
+const result = (value) =>
+ context.addCookies([{ name: 'template_test_result', value, url: base }]);
+const visit = async (path) => {
+ await result('success');
+ await page.goto(base + path);
+ await page.locator('h1').waitFor();
+};
+const noOverflow = async () =>
+ assert.equal(
+ await page.evaluate(
+ () => document.documentElement.scrollWidth <= window.innerWidth,
+ ),
+ true,
+ );
+const feedback = (state) =>
+ page
+ .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 });
+ for (const path of [
+ '/project-templates?reset',
+ '/projects/1/templates/create?reset',
+ '/project-templates/2/edit?reset',
+ ]) {
+ await visit(path);
+ await noOverflow();
+ assert.equal(
+ await page
+ .locator('html')
+ .evaluate((el) => el.classList.contains('dark')),
+ theme === 'dark',
+ );
+ if (width === 390 || width === 1440) {
+ await page.screenshot({
+ path:
+ '/private/tmp/KAN-46-' +
+ width +
+ '-' +
+ theme +
+ '-' +
+ (path.includes('/edit')
+ ? 'edit'
+ : path.includes('/create')
+ ? 'create'
+ : 'index') +
+ '.png',
+ fullPage: true,
+ });
+ }
+ }
+ assert.equal(await page.locator('#slug').isDisabled(), true);
+ assert.equal(
+ await button('Save template changes').isDisabled(),
+ true,
+ );
+ const checkbox = page.locator('#confirm-linked');
+ await checkbox.focus();
+ await page.keyboard.press('Space');
+ assert.equal(await checkbox.isChecked(), true);
+ assert.equal(
+ await button('Save template changes').isEnabled(),
+ true,
+ );
+ await visit('/project-templates?reset');
+ assert.equal(
+ await page
+ .getByText('Internal baseline', { exact: true })
+ .count(),
+ 0,
+ );
+ const trigger = button('Delete Foundation');
+ await trigger.focus();
+ await page.keyboard.press('Enter');
+ const dialog = page.getByRole('dialog');
+ await dialog.waitFor();
+ await page.keyboard.press('Tab');
+ assert.equal(
+ await dialog.evaluate((el) =>
+ el.contains(document.activeElement),
+ ),
+ true,
+ );
+ await noOverflow();
+ await page.keyboard.press('Escape');
+ await dialog.waitFor({ state: 'hidden' });
+ assert.equal(
+ await trigger.evaluate((el) => document.activeElement === el),
+ true,
+ );
+ assert.equal(mutations.length, 0);
+ }
+ }
+ await visit('/projects/1/templates/create?reset');
+ await page.locator('#name').fill('Release setup');
+ await page.locator('#slug').fill('release-setup');
+ const groups = page.locator('fieldset input[type="checkbox"]');
+ for (const group of await groups.all()) {
+ await group.uncheck();
+ }
+ await page
+ .getByRole('checkbox', { name: 'Project settings', exact: true })
+ .check();
+ await page
+ .getByRole('checkbox', { name: 'Ticket types', exact: true })
+ .check();
+ await result('validation');
+ await button('Save new template').click();
+ await feedback('validation');
+ assert.equal(await page.locator('#name').inputValue(), 'Release setup');
+ assert.equal(
+ await page.locator('#name').getAttribute('aria-invalid'),
+ 'true',
+ );
+ await page
+ .getByText('Choose a valid configuration group.', { exact: true })
+ .waitFor();
+ assert.equal(
+ await page
+ .locator('[data-form-state="validation"]')
+ .evaluate((el) => el === document.activeElement),
+ true,
+ );
+ await result('server');
+ await button('Save new template').click();
+ await feedback('server');
+ await result('success');
+ const created = page.waitForRequest(
+ (request) => request.method() === 'POST',
+ );
+ await button('Try again').click();
+ assert.deepEqual((await created).postDataJSON(), {
+ name: 'Release setup',
+ slug: 'release-setup',
+ copy_areas: ['project_settings', 'ticket_types'],
+ });
+ await page
+ .getByRole('heading', { name: 'Project templates', exact: true })
+ .waitFor();
+
+ await visit('/project-templates/2/edit?reset');
+ const before = mutations.length;
+ await page
+ .locator('form')
+ .evaluate((el) =>
+ el.dispatchEvent(
+ new Event('submit', { bubbles: true, cancelable: true }),
+ ),
+ );
+ assert.equal(
+ mutations.length,
+ before,
+ 'Linked updates must wait for acknowledgement',
+ );
+ await page.locator('#confirm-linked').check();
+ await page.locator('#name').fill('Updated foundation');
+ await page.route('**/project-templates/2', (route) =>
+ route.abort('failed'),
+ );
+ await button('Save template changes').click();
+ await feedback('network');
+ assert.equal(await page.locator('#confirm-linked').isChecked(), true);
+ await page.unroute('**/project-templates/2');
+ const updated = page.waitForRequest(
+ (request) => request.method() === 'PATCH',
+ );
+ await button('Try again').click();
+ assert.deepEqual((await updated).postDataJSON(), {
+ name: 'Updated foundation',
+ copy_areas: ['project_settings', 'ticket_types'],
+ confirm_linked_projects_update: true,
+ });
+ await page
+ .getByRole('heading', { name: 'Project templates', exact: true })
+ .waitFor();
+
+ await visit('/project-templates/2/edit?reset&unlinked');
+ assert.equal(await page.locator('#confirm-linked').count(), 0);
+ const unlinked = page.waitForRequest(
+ (request) => request.method() === 'PATCH',
+ );
+ await button('Save template changes').click();
+ assert.deepEqual((await unlinked).postDataJSON(), {
+ name: 'Foundation',
+ copy_areas: ['project_settings', 'ticket_types'],
+ });
+ await page
+ .getByRole('heading', { name: 'Project templates', exact: true })
+ .waitFor();
+ await visit('/project-templates/2/edit?reset&missing');
+ await page
+ .getByText('This template cannot be refreshed', { exact: true })
+ .waitFor();
+ assert.equal(await button('Save template changes').isDisabled(), true);
+
+ await visit('/projects/1/templates/create?reset');
+ await page.locator('#name').fill('Unsaved work');
+ let prompted = false;
+ page.once('dialog', async (dialog) => {
+ prompted = true;
+ await dialog.dismiss();
+ });
+ await page.getByRole('link', { name: 'Cancel', exact: true }).click();
+ assert.equal(prompted, true);
+ assert.equal(await page.locator('#name').inputValue(), 'Unsaved work');
+ // Reload starts the next isolated scenario; accept the browser's unload prompt.
+ page.once('dialog', (dialog) => dialog.accept());
+ await visit('/project-templates?reset');
+ await button('Delete Foundation').click();
+ await result('server');
+ await button('Delete template').click();
+ await feedback('server');
+ await result('success');
+ const deleted = page.waitForRequest(
+ (request) => request.method() === 'DELETE',
+ );
+ await button('Try again').click();
+ assert.equal(
+ new URL((await deleted).url()).pathname,
+ '/project-templates/2',
+ );
+ await page.getByRole('dialog').waitFor({ state: 'hidden' });
+ await page
+ .getByText('No published templates yet', { exact: true })
+ .waitFor();
+ await visit('/project-templates?reset');
+ await page.getByRole('link', { name: 'Use template', exact: true }).click();
+ await page.getByRole('heading', { name: 'Destination reached' }).waitFor();
+ assert.match(page.url(), /\/projects\/create\?template=2$/);
+ assert.deepEqual(errors, []);
+ console.log(
+ 'Project template browser checks passed: create/edit payloads, confirmations, slug lock, validation/network/server retries, missing source, deletion, navigation, keyboard, 320/390/1440px and both themes.',
+ );
+} finally {
+ await browser.close();
+}
diff --git a/tests/Fixtures/project-templates-preview.mjs b/tests/Fixtures/project-templates-preview.mjs
new file mode 100644
index 0000000..d94105a
--- /dev/null
+++ b/tests/Fixtures/project-templates-preview.mjs
@@ -0,0 +1,20 @@
+import { createApp, h } from 'vue';
+import { createInertiaApp } from '@inertiajs/vue3';
+import '../../resources/css/app.css';
+import Index from '../../resources/js/pages/project-templates/Index.vue';
+import Create from '../../resources/js/pages/project-templates/Create.vue';
+import Edit from '../../resources/js/pages/project-templates/Edit.vue';
+import { createAppearance } from '../../resources/js/composables/useAppearance';
+import { createLocalization } from '../../resources/js/lib/localization';
+createInertiaApp({
+ resolve: (name) =>
+ ({ Index, Create, Edit })[name] ?? {
+ 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-templates.mjs b/tests/Fixtures/project-templates.mjs
new file mode 100644
index 0000000..406f883
--- /dev/null
+++ b/tests/Fixtures/project-templates.mjs
@@ -0,0 +1,288 @@
+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-templates-fixture',
+ plugins: [vue(), ...(serve ? [tailwind()] : [])],
+ resolve: {
+ alias: {
+ '@': fileURLToPath(new URL('../../resources/js', import.meta.url)),
+ },
+ },
+ server: serve
+ ? { host: '127.0.0.1', port: 4191, strictPort: true, hmr: false }
+ : { middlewareMode: true, hmr: false, watch: null },
+ appType: 'custom',
+});
+const project = { id: 1, name: 'Core platform', key: 'CORE' };
+const areas = [
+ 'project_settings',
+ 'board_columns',
+ 'ticket_statuses',
+ 'status_transitions',
+ 'ticket_types',
+ 'ticket_priorities',
+ 'ticket_fields',
+ 'ticket_link_types',
+ 'repository_connections',
+ 'notification_actions',
+];
+const template = {
+ id: 2,
+ name: 'Foundation',
+ slug: 'foundation-template',
+ is_published: true,
+ source_project: project,
+ copy_areas: ['project_settings', 'ticket_types'],
+ linked_project_count: 2,
+};
+const initial = () => ({
+ project,
+ copyAreaOptions: areas.map((value) => ({
+ value,
+ label: value,
+ description: value,
+ })),
+ template: structuredClone(template),
+ templates: [
+ structuredClone(template),
+ { ...template, id: 3, name: 'Internal baseline', is_published: false },
+ ],
+});
+function makePage(component = 'Index', extra = {}, url = '/project-templates') {
+ 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: true,
+ canManageTeams: false,
+ },
+ },
+ navigation: { project: null },
+ ...initial(),
+ ...extra,
+ },
+ };
+}
+if (serve) {
+ let data = initial();
+ server.middlewares.use(async (request, response, next) => {
+ if (!request.url.startsWith('/project')) {
+ return next();
+ }
+ const url = new URL(request.url, 'http://localhost');
+ let component = url.pathname.endsWith('/edit')
+ ? 'Edit'
+ : url.pathname.includes('/templates/create')
+ ? 'Create'
+ : url.pathname === '/project-templates'
+ ? 'Index'
+ : 'Destination';
+ if (url.searchParams.has('reset')) {
+ data = initial();
+ }
+ if (url.searchParams.has('unlinked')) {
+ data.template.linked_project_count = 0;
+ }
+ if (url.searchParams.has('missing')) {
+ data.template.source_project = null;
+ }
+ if (url.searchParams.has('empty')) {
+ data.templates = [];
+ }
+ let errors = {};
+ if (request.method !== 'GET') {
+ let body = '';
+ for await (const chunk of request) {
+ body += chunk;
+ }
+ const submitted = JSON.parse(body || '{}');
+ const result =
+ request.headers.cookie?.match(
+ /template_test_result=(\w+)/,
+ )?.[1] ?? 'success';
+ if (result === 'server') {
+ response.statusCode = 500;
+ response.end('Unavailable');
+ return;
+ }
+ if (result === 'validation') {
+ component =
+ request.method === 'POST'
+ ? 'Create'
+ : request.method === 'PATCH'
+ ? 'Edit'
+ : 'Index';
+ errors = {
+ name: 'This template name is unavailable.',
+ 'copy_areas.0': 'Choose a valid configuration group.',
+ };
+ } else {
+ if (request.method === 'DELETE') {
+ data.templates = data.templates.filter(
+ (item) => item.id !== 2,
+ );
+ } else {
+ data.templates = [{ ...data.template, ...submitted }];
+ }
+ component = 'Index';
+ }
+ }
+ const page = makePage(
+ component,
+ {
+ ...data,
+ errors,
+ status:
+ request.method !== 'GET' && !Object.keys(errors).length
+ ? 'Saved'
+ : null,
+ },
+ component === 'Index' ? '/project-templates' : request.url,
+ );
+ response.setHeader('Vary', 'X-Inertia');
+ if (request.headers['x-inertia']) {
+ response.setHeader('X-Inertia', 'true');
+ response.setHeader('Content-Type', 'application/json');
+ response.end(JSON.stringify(page));
+ return;
+ }
+ const escaped = JSON.stringify(page)
+ .replaceAll('&', '&')
+ .replaceAll('"', '"')
+ .replaceAll('<', '<');
+ response.setHeader('Content-Type', 'text/html');
+ response.end(
+ await server.transformIndexHtml(
+ request.url,
+ '
',
+ ),
+ );
+ });
+ await server.listen();
+ console.log('Project template 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 render = async (
+ name,
+ extra = {},
+ locale = createLocalization(),
+ ) => {
+ const component = (
+ await server.ssrLoadModule(
+ '/resources/js/pages/project-templates/' + name + '.vue',
+ )
+ ).default;
+ return (
+ await createInertiaApp({
+ page: makePage(name, extra),
+ resolve: () => component,
+ render: renderToString,
+ setup: ({ App, props, plugin }) =>
+ createSSRApp({ render: () => h(App, props) })
+ .use(plugin)
+ .use(locale)
+ .use(createAppearance()),
+ })
+ ).body.replace(/ data-page="[^"]*"/, '');
+ };
+ const list = await render('Index');
+ assert.match(list, /Foundation/);
+ assert.doesNotMatch(list, /Internal baseline/);
+ assert.match(list, /2 linked projects/);
+ assert.match(list, /Project settings/);
+ assert.match(list, /href="\/projects\/create\?template=2"/);
+ assert.match(
+ await render('Index', { templates: [] }),
+ /No published templates yet/,
+ );
+ const create = await render('Create');
+ assert.match(create, /value="Core platform template"/);
+ assert.equal((create.match(/type="checkbox"/g) ?? []).length, 10);
+ assert.doesNotMatch(
+ create.match(/ ]*id="slug"[^>]*>/)?.[0] ?? '',
+ / disabled(?:[ =>])/,
+ );
+ const edit = await render('Edit');
+ assert.match(
+ edit.match(/ ]*id="slug"[^>]*>/)?.[0] ?? '',
+ / disabled(?:[ =>])/,
+ );
+ assert.match(edit, /Linked projects will be affected/);
+ assert.match(
+ edit.match(/]*type="submit"[^>]*>/)?.[0] ?? '',
+ / disabled(?:[ =>])/,
+ );
+ const unlinked = await render('Edit', {
+ template: { ...template, linked_project_count: 0 },
+ });
+ assert.doesNotMatch(unlinked, /id="confirm-linked"/);
+ assert.doesNotMatch(
+ unlinked.match(/]*type="submit"[^>]*>/)?.[0] ?? '',
+ / disabled(?:[ =>])/,
+ );
+ assert.match(
+ await render('Edit', {
+ template: { ...template, source_project: null },
+ }),
+ /This template cannot be refreshed/,
+ );
+ assert.match(
+ await render(
+ 'Index',
+ {},
+ createLocalization(
+ { locale: 'de' },
+ {
+ de: {
+ ...en,
+ 'navigation.templates': 'Projektvorlagen',
+ },
+ },
+ ),
+ ),
+ /Projektvorlagen/,
+ );
+ console.log('Project template checks passed.');
+ } finally {
+ await server.close();
+ }
+}