fix(KAN-14): preserve template inheritance and project data
CI/CD / preconditions (pull_request) Successful in 4s
CI/CD / test_and_check_app (pull_request) Failing after 2m14s
CI/CD / Publish immutable image (pull_request) Skipped

This commit is contained in:
2026-10-15 11:38:17 +02:00
parent 5769d84526
commit 1291a0fcad
61 changed files with 2696 additions and 335 deletions
+4
View File
@@ -222,6 +222,10 @@ if (serve) {
assert.match(admin, /Board columns · inherited/);
assert.match(admin, /Ticket types · overridden/);
assert.match(
await render('/projects/7', { create: true, manage: true }),
/href="\/projects\/7\/templates\/create"/,
);
assert.doesNotMatch(
await render('/projects/7', { create: true }),
/href="\/projects\/7\/templates\/create"/,
);
+90 -3
View File
@@ -46,6 +46,7 @@ try {
'/project-templates?reset',
'/projects/1/templates/create?reset',
'/project-templates/2/edit?reset',
'/project-templates/create?reset',
]) {
await visit(path);
await noOverflow();
@@ -73,6 +74,7 @@ try {
});
}
}
await visit('/project-templates/2/edit?reset');
assert.equal(await page.locator('#slug').isDisabled(), true);
assert.equal(
await button('Save template changes').isDisabled(),
@@ -193,6 +195,14 @@ try {
name: 'Updated foundation',
copy_areas: ['project_settings', 'ticket_types'],
confirm_linked_projects_update: true,
configuration_snapshot: await page.evaluate(
() =>
JSON.parse(
document
.querySelector('[data-page]')
.getAttribute('data-page'),
).props.template.configuration_snapshot,
),
});
await page
.getByRole('heading', { name: 'Project templates', exact: true })
@@ -207,15 +217,92 @@ try {
assert.deepEqual((await unlinked).postDataJSON(), {
name: 'Foundation',
copy_areas: ['project_settings', 'ticket_types'],
configuration_snapshot: await page.evaluate(
() =>
JSON.parse(
document
.querySelector('[data-page]')
.getAttribute('data-page'),
).props.template.configuration_snapshot,
),
});
await page
.getByRole('heading', { name: 'Project templates', exact: true })
.waitFor();
await visit('/project-templates/2/edit?reset&missing');
await visit('/project-templates/2/edit?reset&missing&unlinked');
assert.equal(
await page
.getByText('This template cannot be refreshed', { exact: true })
.count(),
0,
);
assert.equal(await button('Save template changes').isEnabled(), true);
await button('Save template changes').click();
await page
.getByText('This template cannot be refreshed', { exact: true })
.getByRole('heading', { name: 'Project templates', exact: true })
.waitFor();
await visit('/project-templates/create?reset');
await page.locator('#name').fill('New independent template');
await page.locator('#slug').fill('independent-template');
const columns = page
.locator('details')
.filter({ has: page.locator('summary', { hasText: 'Board columns' }) });
await columns
.getByRole('button', { name: 'Add entry', exact: true })
.click();
await columns.getByLabel('Name', { exact: true }).fill('Todo');
const statuses = page.locator('details').filter({
has: page.locator('summary', { hasText: 'Ticket statuses' }),
});
await statuses
.getByRole('button', { name: 'Add entry', exact: true })
.click();
await statuses.getByLabel('Name', { exact: true }).last().fill('Review');
await statuses
.getByLabel('Board column', { exact: true })
.last()
.selectOption({ label: 'Todo' });
const types = page
.locator('details')
.filter({ has: page.locator('summary', { hasText: 'Ticket types' }) });
await types
.getByRole('button', { name: 'Move down', exact: true })
.first()
.click();
const standaloneCreated = page.waitForRequest(
(request) => request.method() === 'POST',
);
await button('Save new template').click();
const body = (await standaloneCreated).postDataJSON();
assert.equal(
new URL((await standaloneCreated).url()).pathname,
'/project-templates',
);
assert.equal(body.configuration_snapshot.ticket_statuses[1].name, 'Review');
assert.equal(
body.configuration_snapshot.ticket_statuses[1].board_column_key,
body.configuration_snapshot.board_columns[0].configuration_key,
);
assert.deepEqual(
body.configuration_snapshot.ticket_types.map((item) => item.name),
['Release', 'Task'],
);
assert.deepEqual(
body.configuration_snapshot.ticket_types.map((item) => item.position),
[1, 2],
);
assert.equal(
new Set(
body.configuration_snapshot.ticket_types.map(
(item) => item.configuration_key,
),
).size,
2,
);
await page
.getByRole('heading', { name: 'Project templates', 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');
+72 -6
View File
@@ -43,10 +43,54 @@ const template = {
is_published: true,
source_project: project,
copy_areas: ['project_settings', 'ticket_types'],
configuration_snapshot: {
project_settings: { show_author: true, show_story_points: true },
board_columns: [],
ticket_statuses: [
{
configuration_key: '10000000-0000-4000-8000-000000000001',
name: 'Backlog',
position: 1,
board_column_key: null,
},
],
status_transitions: [
{
status_key: '10000000-0000-4000-8000-000000000001',
target_keys: [],
},
],
ticket_types: [
{
configuration_key: '20000000-0000-4000-8000-000000000001',
name: 'Task',
position: 1,
is_parent: false,
},
{
configuration_key: '20000000-0000-4000-8000-000000000002',
name: 'Release',
position: 2,
is_parent: true,
},
],
ticket_priorities: [
{
configuration_key: '30000000-0000-4000-8000-000000000001',
name: 'Medium',
position: 1,
},
],
ticket_fields: [],
ticket_link_types: [],
repository_connections: [],
notification_actions: [],
},
linked_project_count: 2,
};
const initial = () => ({
project,
initialConfiguration: structuredClone(template.configuration_snapshot),
copyAreaOptions: areas.map((value) => ({
value,
label: value,
@@ -100,7 +144,8 @@ if (serve) {
const url = new URL(request.url, 'http://localhost');
let component = url.pathname.endsWith('/edit')
? 'Edit'
: url.pathname.includes('/templates/create')
: url.pathname.includes('/templates/create') ||
url.pathname === '/project-templates/create'
? 'Create'
: url.pathname === '/project-templates'
? 'Index'
@@ -117,6 +162,9 @@ if (serve) {
if (url.searchParams.has('empty')) {
data.templates = [];
}
if (url.pathname === '/project-templates/create') {
data.project = null;
}
let errors = {};
if (request.method !== 'GET') {
let body = '';
@@ -259,12 +307,30 @@ if (serve) {
unlinked.match(/<button[^>]*type="submit"[^>]*>/)?.[0] ?? '',
/ disabled(?:[ =>])/,
);
assert.match(
await render('Edit', {
template: { ...template, source_project: null },
}),
/This template cannot be refreshed/,
const independent = await render('Edit', {
template: {
...template,
source_project: null,
linked_project_count: 0,
},
});
assert.match(independent, /Template configuration/);
assert.doesNotMatch(independent, /This template cannot be refreshed/);
assert.doesNotMatch(
independent.match(/<button[^>]*type="submit"[^>]*>/)?.[0] ?? '',
/ disabled(?:[ =>])/,
);
const empty = await render('Create', {
project: null,
template: undefined,
});
assert.match(empty, /New empty template/);
assert.match(
empty.match(/<input[^>]*id="name"[^>]*>/)?.[0] ?? '',
/\bvalue(?:="")?(?:\s|>)/,
);
assert.match(empty, /Template configuration/);
assert.match(list, /href="\/project-templates\/create"/);
assert.match(
await render(
'Index',