feat(KAN-46): rebuild reusable project template management

This commit is contained in:
2026-09-16 07:01:03 +00:00
parent 5a10c85e61
commit 0941745883
12 changed files with 1322 additions and 500 deletions
@@ -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();
@@ -0,0 +1,18 @@
<?php
namespace Tests\Feature\Projects;
use Symfony\Component\Process\Process;
use Tests\TestCase;
class ProjectTemplatesUiTest extends TestCase
{
public function test_template_pages_show_reusable_configuration_and_protect_linked_projects(): void
{
$process = new Process(['node', 'tests/Fixtures/project-templates.mjs'], base_path());
$process->setTimeout(60);
$process->run();
$this->assertTrue($process->isSuccessful(), $process->getOutput().$process->getErrorOutput());
$this->assertStringContainsString('Project template checks passed.', $process->getOutput());
}
}
@@ -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();
}
@@ -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),
});
+288
View File
@@ -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('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;');
response.setHeader('Content-Type', 'text/html');
response.end(
await server.transformIndexHtml(
request.url,
'<!doctype html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><div id="app" data-page="' +
escaped +
'"></div><script type="module" src="/tests/Fixtures/project-templates-preview.mjs"></script></body></html>',
),
);
});
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(/<input[^>]*id="slug"[^>]*>/)?.[0] ?? '',
/ disabled(?:[ =>])/,
);
const edit = await render('Edit');
assert.match(
edit.match(/<input[^>]*id="slug"[^>]*>/)?.[0] ?? '',
/ disabled(?:[ =>])/,
);
assert.match(edit, /Linked projects will be affected/);
assert.match(
edit.match(/<button[^>]*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(/<button[^>]*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();
}
}