Files
redmine-theme-tonalmine/e2e/check-form-alignment.mjs
T
Andreas Reinhold / reiniandClaude Opus 5.5 db58c6e86f fix: align form field widths across the app
From 900px up, selects and text fields fill their column up to 480px,
numbers and dates are 140px, and long text stays full width, so every
form shares one right edge. Below 900px every field runs full width.
Overrides core's id-pinned widths (% Done, mobile dates, settings
textareas). check-form-alignment.mjs now checks widths at 1440, 1024
and 390px across all settings tabs and admin forms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-05 14:12:44 +02:00

85 lines
5.4 KiB
JavaScript

// Checks .tabular forms and query filters at desktop, tablet landscape and phone width:
// - each form label lines up with the field next to it (not on phones, where labels stack)
// - field widths follow the three tracks (components.css): standard fields fill the column up
// to 480px, compact fields (numbers, dates) are 140px, long-text fields fill the column;
// on phones every field fills its row
// Usage: node check-form-alignment.mjs (exits 1 if any row is off by more than 2px)
import { chromium } from 'playwright';
const BASE = process.env.REDMINE_URL ?? 'http://127.0.0.1:3070';
const settingsTabs = ['general', 'display', 'authentication', 'api', 'projects', 'users', 'issues', 'timelog', 'attachments', 'mail_handler', 'repositories'];
const pages = [
'/projects?display_type=list', '/projects/apollo/issues', '/projects/apollo/time_entries', '/projects/apollo/issues/new', '/issues/13/edit', '/issues/bulk_edit?ids[]=13&ids[]=17',
'/projects/apollo/time_entries/new', '/my/account', '/my/password', '/users/5/edit',
'/projects/apollo/settings', '/projects/apollo/settings/issues', '/custom_fields/1/edit', '/projects/apollo/wiki/Wiki/edit',
'/projects/apollo/wiki/Wiki/rename', '/projects/apollo/versions/new', '/projects/apollo/news/new', '/projects/apollo/documents/new',
'/projects/apollo/queries/new', '/projects/new', '/roles/new', '/trackers/new', '/groups/new',
...settingsTabs.map((tab) => `/settings?tab=${tab}`),
];
const browser = await chromium.launch();
let failures = 0;
for (const width of [1440, 1024, 390]) {
const page = await browser.newPage({ viewport: { width, height: 900 } });
await page.goto(`${BASE}/login`);
await page.fill('#username', 'admin');
await page.fill('#password', 'admin');
await page.click('#login-submit');
await page.waitForURL((url) => !url.pathname.startsWith('/login'));
for (const path of pages) {
await page.goto(`${BASE}${path}`, { waitUntil: 'networkidle' });
const rows = await page.evaluate((stacked) => {
const visible = (el) => el && el.offsetParent;
const out = [];
if (!stacked) {
// Query filters: label text vs. operator select
for (const row of document.querySelectorAll('#filters-table .filter')) {
const label = row.querySelector('.field label'), control = row.querySelector('.operator select');
if (!label || !visible(control)) continue;
const l = label.getBoundingClientRect(), c = control.getBoundingClientRect();
out.push({ label: `filter ${label.textContent.trim().slice(0, 22)}`, offset: Math.round(l.top + l.height / 2 - (c.top + c.height / 2)) });
}
// Labels vs. fields (and the default appearance groups on the Display tab)
for (const row of document.querySelectorAll('.tabular p')) {
const label = row.querySelector(':scope > label:not(.floating):not(.block):not(.inline):not(.no-css)');
const control = row.querySelector(':scope > input:not([type=hidden]), :scope > select, :scope > span > input:not([type=hidden]), :scope > span > select, :scope > .nm-appearance > [role="group"]');
if (!visible(label) || !visible(control)) continue;
const style = getComputedStyle(label);
const labelBox = label.getBoundingClientRect();
const controlBox = control.getBoundingClientRect();
const labelMiddle = labelBox.top + parseFloat(style.paddingTop) + parseFloat(style.lineHeight) / 2;
out.push({ label: label.textContent.trim().slice(0, 30), offset: Math.round(labelMiddle - (controlBox.top + controlBox.height / 2)) });
}
}
// Field widths
const textLike = 'textarea, select:not([multiple]), input:is([type="text"], [type="password"], [type="email"], [type="url"], [type="search"], [type="tel"], [type="number"], [type="date"], :not([type]))';
const compact = 'input:is([type="number"], [type="date"], .date, [size="1"], [size="2"], [size="3"], [size="4"], [size="5"], [size="6"])';
const full = 'textarea, input:is(.string_cf, .link_cf, #issue_subject, #document_title, #content_comments, #time_entry_comments)';
for (const field of document.querySelectorAll(`.tabular p > :is(${textLike}), .tabular p > span > :is(${textLike})`)) {
if (!visible(field)) continue;
// The project select shares its row with the floated "Private" checkbox
if (field.id === 'issue_project_id') continue;
const row = field.closest('p');
const style = getComputedStyle(row);
const column = row.clientWidth - parseFloat(style.paddingInlineStart) - parseFloat(style.paddingInlineEnd);
const icon = field.nextElementSibling?.matches('.icon-only') ? 36 : 0;
const expected = stacked || field.matches(full) ? column - icon
: field.matches(compact) ? 140
: Math.min(480, column) - icon;
const actual = field.getBoundingClientRect().width;
const name = row.querySelector(':scope > label')?.textContent.trim().slice(0, 30) || field.id || field.name;
out.push({ label: `width ${name}`, offset: Math.round(actual - expected) });
}
return out;
}, width < 900);
const bad = rows.filter((r) => Math.abs(r.offset) > 2);
failures += bad.length;
console.log(`${bad.length ? 'FAIL' : 'ok '} ${width} ${path} (${rows.length} checks)${bad.map((r) => `\n ${r.label}: ${r.offset}px`).join('')}`);
}
await page.close();
}
await browser.close();
process.exit(failures ? 1 : 0);