feat(KAN-43): rebuild custom fields and link types
CI/CD / preconditions (pull_request) Successful in 1m0s
CI/CD / test_and_check_app (pull_request) Canceled after 0s
CI/CD / Publish immutable image (pull_request) Canceled after 0s

This commit is contained in:
2026-09-15 17:50:10 +02:00
parent b226ee8d48
commit fb4faa6070
15 changed files with 1240 additions and 331 deletions
@@ -0,0 +1,207 @@
import assert from 'node:assert/strict';
import process from 'node:process';
const playwright = await import(process.env.PLAYWRIGHT_MODULE ?? 'playwright');
const { chromium } = playwright.default ?? playwright;
const browser = await chromium.launch({ channel: 'chrome', headless: true });
const base = 'http://127.0.0.1:4189';
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 requests = [];
page.on('request', (request) => {
if (['POST', 'PATCH', 'DELETE'].includes(request.method())) {
requests.push(request);
}
});
const button = (name) => page.getByRole('button', { name, exact: true });
const dialog = () => page.getByRole('dialog');
const result = (value) =>
context.addCookies([{ name: 'metadata_test_result', value, url: base }]);
const visit = async () => {
await result('success');
await page.goto(base + '/projects/1/settings/metadata?reset');
await page
.getByRole('heading', { name: 'Ticket settings', exact: true })
.waitFor();
};
const noOverflow = async () =>
assert.equal(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
true,
);
const submit = async (label, method, pathname, expected) => {
const sent = page.waitForRequest((request) => request.method() === method);
await dialog().getByRole('button', { name: label, exact: true }).click();
const request = await sent;
assert.equal(new URL(request.url()).pathname, pathname);
assert.deepEqual(request.postDataJSON(), expected);
};
const closed = () => dialog().waitFor({ state: 'hidden' });
const formState = (state) =>
dialog()
.locator('[data-form-state="' + state + '"]')
.waitFor({ state: 'visible' });
try {
for (const width of [1440, 768, 390, 320]) {
await page.setViewportSize({ width, height: 1000 });
for (const theme of ['light', 'dark']) {
await page.emulateMedia({ colorScheme: theme });
await visit();
await noOverflow();
await page
.getByLabel('Blocks points to Blocked by', { exact: true })
.waitFor();
const trigger = button('Edit Reviewer');
await trigger.focus();
await page.keyboard.press('Enter');
await dialog().waitFor();
assert.equal(
await page
.getByLabel('Field label', { exact: true })
.inputValue(),
'Reviewer',
);
assert.equal(
await page
.getByLabel('Field type', { exact: true })
.inputValue(),
'user',
);
await page.keyboard.press('Tab');
assert.equal(
await dialog().evaluate((element) =>
element.contains(document.activeElement),
),
true,
);
await noOverflow();
await page.keyboard.press('Escape');
await closed();
assert.equal(
await trigger.evaluate(
(element) => document.activeElement === element,
),
true,
);
}
}
await visit();
await button('Add field').click();
await page.getByLabel('Field label', { exact: true }).fill('Owner');
await page.getByLabel('Field type', { exact: true }).selectOption('user');
await result('validation');
await submit('Add field', 'POST', '/projects/1/settings/metadata/fields', {
label: 'Owner',
field_type: 'user',
});
await formState('validation');
assert.equal(
await page
.getByLabel('Field label', { exact: true })
.getAttribute('aria-invalid'),
'true',
);
await result('server');
await dialog()
.getByRole('button', { name: 'Add field', exact: true })
.click();
await formState('server');
await result('success');
await dialog()
.getByRole('button', { name: 'Try again', exact: true })
.click();
await closed();
await button('Edit Owner').click();
await page.getByLabel('Field label', { exact: true }).fill('Effort');
await page.getByLabel('Field type', { exact: true }).selectOption('number');
await page.getByLabel('Position', { exact: true }).fill('1');
await submit('Save', 'PATCH', '/projects/1/settings/metadata/fields/100', {
label: 'Effort',
field_type: 'number',
position: 1,
});
await closed();
await button('Edit Effort').waitFor();
const beforeFieldDelete = requests.length;
await button('Delete Reviewer').click();
await dialog()
.getByText(/permanently removes 2 stored ticket values/)
.waitFor();
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
await closed();
assert.equal(requests.length, beforeFieldDelete);
await button('Delete Reviewer').click();
await submit(
'Delete',
'DELETE',
'/projects/1/settings/metadata/fields/5',
{},
);
await closed();
await button('Add link type').click();
await page.getByLabel('Source label', { exact: true }).fill('Depends on');
await page
.getByLabel('Counterpart label', { exact: true })
.fill('Required by');
await submit(
'Add link type',
'POST',
'/projects/1/settings/metadata/link-types',
{ name: 'Depends on', counterpart_name: 'Required by' },
);
await closed();
await button('Edit Depends on').click();
assert.equal(
await page
.getByLabel('Counterpart label', { exact: true })
.inputValue(),
'Required by',
);
await page.getByLabel('Source label', { exact: true }).fill('Requires');
await page
.getByLabel('Counterpart label', { exact: true })
.fill('Required for');
await page.getByLabel('Position', { exact: true }).fill('1');
await submit(
'Save',
'PATCH',
'/projects/1/settings/metadata/link-types/101',
{ name: 'Requires', counterpart_name: 'Required for', position: 1 },
);
await closed();
await page
.getByLabel('Requires points to Required for', { exact: true })
.waitFor();
const beforeLinkDelete = requests.length;
await button('Delete Blocks').click();
await dialog()
.getByText(/permanently removes 1 ticket relationships/)
.waitFor();
await dialog().getByRole('button', { name: 'Cancel', exact: true }).click();
await closed();
assert.equal(requests.length, beforeLinkDelete);
await button('Delete Blocks').click();
await submit(
'Delete',
'DELETE',
'/projects/1/settings/metadata/link-types/7',
{},
);
await closed();
assert.deepEqual(errors, []);
console.log(
'Metadata browser checks passed: field and link-type CRUD payloads, direction labels, deletion impact, validation, retries, keyboard focus, responsive widths and both themes.',
);
} finally {
await browser.close();
}
+94 -13
View File
@@ -55,9 +55,35 @@ function initial() {
{ id: 3, name: 'Normal', position: 1 },
{ id: 4, name: 'Urgent', position: 2 },
],
ticketFieldDefinitions: [],
ticketLinkTypes: [],
fieldTypeOptions: [],
ticketFieldDefinitions: [
{
id: 5,
label: 'Reviewer',
field_type: 'user',
position: 1,
ticket_field_values_count: 2,
},
{
id: 6,
label: 'Complexity',
field_type: 'number',
position: 2,
ticket_field_values_count: 0,
},
],
ticketLinkTypes: [
{
id: 7,
label: 'Blocks',
counterpart_label: 'Blocked by',
position: 1,
ticket_links_count: 1,
},
],
fieldTypeOptions: [
{ label: 'User', value: 'user' },
{ label: 'Number', value: 'number' },
],
};
}
function makePage(
@@ -132,16 +158,30 @@ if (serve) {
response.end('Unavailable');
return;
}
const kind = url.pathname.includes('/types') ? 'type' : 'priority';
const kind = url.pathname.includes('/link-types')
? 'link'
: url.pathname.includes('/fields')
? 'field'
: url.pathname.includes('/types')
? 'type'
: 'priority';
const entries =
kind === 'type' ? data.ticketTypes : data.ticketPriorities;
kind === 'type'
? data.ticketTypes
: kind === 'priority'
? data.ticketPriorities
: kind === 'field'
? data.ticketFieldDefinitions
: data.ticketLinkTypes;
const id = Number(url.pathname.split('/').at(-1));
if (
result === 'validation' ||
(submitted.name &&
((submitted.name || submitted.label) &&
entries.some(
(entry) =>
entry.name === submitted.name && entry.id !== id,
(entry.name ?? entry.label) ===
(submitted.name ?? submitted.label) &&
entry.id !== id,
))
) {
errors =
@@ -150,7 +190,14 @@ if (serve) {
replacement_ticket_type_id:
'Choose a parent-enabled replacement type.',
}
: { name: 'This name is already in use.' };
: kind === 'field'
? { label: 'This field label is already in use.' }
: kind === 'link'
? {
counterpart_name:
'Enter a distinct counterpart label.',
}
: { name: 'This name is already in use.' };
} else if (request.method === 'DELETE') {
entries.splice(
entries.findIndex((item) => item.id === id),
@@ -159,16 +206,43 @@ if (serve) {
} else if (url.pathname.endsWith('/metadata')) {
Object.assign(data.project, submitted);
} else if (request.method === 'POST') {
entries.push({
const created = {
id: nextId++,
position: entries.length + 1,
tickets_count: 0,
...submitted,
});
...(kind === 'type'
? { tickets_count: 0, ...submitted }
: kind === 'priority'
? submitted
: kind === 'field'
? {
label: submitted.label,
field_type: submitted.field_type,
ticket_field_values_count: 0,
}
: {
label: submitted.name,
counterpart_label: submitted.counterpart_name,
ticket_links_count: 0,
}),
};
entries.push(created);
} else {
const item = entries.find((item) => item.id === id);
entries.splice(entries.indexOf(item), 1);
Object.assign(item, submitted);
Object.assign(
item,
kind === 'field'
? {
label: submitted.label,
field_type: submitted.field_type,
}
: kind === 'link'
? {
label: submitted.name,
counterpart_label: submitted.counterpart_name,
}
: submitted,
);
entries.splice(submitted.position - 1, 0, item);
}
entries.forEach((entry, index) => (entry.position = index + 1));
@@ -240,6 +314,13 @@ if (serve) {
);
assert.match(html, /Assigned tickets: 2/);
assert.match(html, /Parent type/);
assert.match(html, /data-ticket-field-definition="5"/);
assert.match(html, /Reviewer/);
assert.match(html, /Stored values: 2/);
assert.match(html, /data-ticket-link-type="7"/);
assert.match(html, /Blocks/);
assert.match(html, /Blocked by/);
assert.match(html, /Ticket relationships: 1/);
assert.match(html, /id="show_author"[^>]*aria-checked="true"/);
assert.match(html, /id="show_story_points"[^>]*aria-checked="false"/);
const single = await render({