fix(dashboard): keep standard salary amounts readable
This commit is contained in:
@@ -34,6 +34,10 @@ Extend the existing account preference owner (`ApplicationUser`) with nullable l
|
||||
|
||||
SQLite and MariaDB use provider-neutral EF mapping and conditional updates. Explicitly check the existing SQLite startup schema reconciliation/migration infrastructure and the complete migration chain. Legacy accounts gain no layout until first Save. Language, notifications, profile, security stamps and all job/document data remain unchanged.
|
||||
|
||||
### Adding or changing widgets later
|
||||
|
||||
Add a stable ID to both `src/dashboardLayout.ts` and the backend `DashboardLayout` allowlist, supply EN/NB titles and the existing report/data owner, then prove allowed widths, empty/error states and ownership. Do not recycle an ID for another meaning. Existing saved layouts append missing known IDs hidden; unsaved accounts receive catalogue defaults. Unknown stored IDs are ignored on read and rejected on write, so preserving metadata across an incompatible client requires a schema-version increment. Future versions are read-only in an older client and cannot be downgraded by its PUT. A genuine rename/removal needs an explicit versioned ID migration and regression proof; there is no speculative alias framework. Feature availability must remain enforced by the data endpoint, independently of layout metadata.
|
||||
|
||||
## Risk and dependency register
|
||||
|
||||
- Phase 7 is CONDITIONAL GO, not unconditional release acceptance. Read [final acceptance](../phase-7/p7-12/FINAL_ACCEPTANCE.md) and [handoff](../phase-7/p7-12/PHASE8_HANDOFF.md).
|
||||
@@ -49,7 +53,7 @@ SQLite and MariaDB use provider-neutral EF mapping and conditional updates. Expl
|
||||
- [x] P8-01: editing, visibility, draft/cancel, empty recovery, localisation; focused component tests and shared P8-01/02 checkpoint commit/push.
|
||||
- [x] P8-02: existing drag helper, named move buttons, DOM ordering and discrete grid sizing; normalisation/focus tests and shared P8-01/02 checkpoint commit/push.
|
||||
- [x] P8-03: additive account storage, validated endpoint, revision conflict, retry/reset/session isolation; backend/frontend integration proof and checkpoint commit/push.
|
||||
- [ ] P8-04: actual browser desktop/tablet/mobile, EN/NB, themes, keyboard, failures and axe; inspect captures, retain human qualifications; commit/push.
|
||||
- [x] P8-04: actual browser desktop/tablet/mobile, EN/NB, themes, keyboard, failures and axe; inspected captures and populated sizing regression; human qualifications retained; checkpoint commit/push.
|
||||
- [ ] P8-05: full backend/frontend/browser, separate standard contracts/sidecar, types/lint/build/security; 57-group reconciliation, final decisions and handoff; commit/push and direct remote verification.
|
||||
|
||||
## Implementation and verification plan
|
||||
|
||||
@@ -10,8 +10,11 @@ Viewports320/390/768/1440px cover editor EN/Bokmål and light/dark; retained das
|
||||
|
||||
Forty-two initial Dashboard/editor PNGs were inspected through four contact sheets and representative full-size crops. Final desktop/light/English and320px/dark/Bokmål editor crops were separately inspected after the focus repair; final raw qualification contains60 distinct screenshots including Analytics. This does not claim an individual full-size inspection of every image. Exact metric/month/sample/source-link and persisted API assertions supply behavioral evidence beyond screenshots.
|
||||
|
||||
Two final-build captures from the uninterrupted whole-browser run are retained with this checkpoint: [English/light desktop editor](evidence/editor-en-light-1440.png) and [Bokmål/dark320px editor](evidence/editor-nb-dark-320.png). Both were inspected; they show the repaired edit-entry position and short visible size labels with the complete control names preserved in the accessibility tree.
|
||||
|
||||
## Reproduced issues and repairs
|
||||
|
||||
- A subsequent populated sizing review found salary amounts breaking into digit fragments at standard width despite passing overflow checks. The new diagnostic first failed its actual text-line assertion (one failed, one passed), then passed both complete standard/full cases after the standard personal widget's salary groups became a single column. Full-width and Analytics retain their existing three-column desktop layout. All eight resizable widgets were saved/read back at both sizes and measured at320/768/1024/1440px, with tables/charts contained and no root overflow. Eight captures were produced; the standard1024 capture was inspected and its [salary crop](evidence/salary-standard-1024.png) retained. The complete ordinary browser suite was then rerun against the rebuilt bundle; final results are recorded in acceptance.
|
||||
- Edit entry focused the panel beneath the fixed app header. A browser geometry check first failed (`focus-red.json`); deliberate preventScroll focus, the existing `--app-shell-header-height` scroll margin and explicit scroll now pass at320/768/1440px. The final reviewed crops show the heading and Save/Cancel controls below the header.
|
||||
- Pending-save route navigation offered a misleading discard promise. A deferred acknowledgement unit regression first failed, then passed; SPA navigation now waits with a localized status while saving. Browser unload warnings do not promise rollback of server writes.
|
||||
- Axe's incomplete triage identified a labelled preview `div` without a valid role. A role-based test failed first (`preview-semantics-red.log`); the preview is now a named region. The new editor browser case explicitly rejects `aria-prohibited-attr` incompletes. Final scan totals are in final acceptance; the earlier ten scans had0 automated violations and68 incomplete nodes (61contrast,6preview semantics,1drawer focus), so those earlier results are not presented as the repaired totals.
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import { defineConfig } from '../../../../../job-tracker-ui/node_modules/@playwright/test';
|
||||
import base from '../../../../../job-tracker-ui/playwright.config';
|
||||
import path from 'node:path';
|
||||
|
||||
const cwd = path.resolve(__dirname, '../../../../../job-tracker-ui');
|
||||
const servers = Array.isArray(base.webServer) ? base.webServer : [base.webServer!];
|
||||
export default defineConfig({ ...base, testDir: __dirname, testMatch: 'sizes.spec.ts',
|
||||
webServer: servers.map(server => ({ ...server, cwd })) });
|
||||
@@ -0,0 +1,61 @@
|
||||
import { test, expect, type APIRequestContext } from '../../../../../job-tracker-ui/node_modules/@playwright/test';
|
||||
import { WIDGETS } from '../../../../../job-tracker-ui/src/dashboardLayout';
|
||||
|
||||
const api = 'http://localhost:5302/api';
|
||||
const password = 'E2ePassword123!';
|
||||
async function login(request: APIRequestContext, email: string) {
|
||||
expect((await request.post(`${api}/auth/login`, { data: { email, password } })).status()).toBe(200);
|
||||
const me = await (await request.get(`${api}/auth/me`)).json();
|
||||
const csrf = (await request.storageState()).cookies.find(cookie => cookie.name === 'XSRF-TOKEN')!.value;
|
||||
return { 'X-CSRF-TOKEN': csrf, 'X-Session-Context': me.sessionContext as string };
|
||||
}
|
||||
|
||||
for (const size of ['standard', 'full'] as const) test(`All populated widgets at ${size} across responsive widths`, async ({ page, playwright }, info) => {
|
||||
test.setTimeout(120000);
|
||||
const admin = await playwright.request.newContext();
|
||||
try {
|
||||
const adminHeaders = await login(admin, 'e2e@example.test');
|
||||
const email = `p8-sizes-${size}-${Date.now()}@example.test`;
|
||||
expect((await admin.post(`${api}/users`, { headers: adminHeaders, data: { email, password, roles: [] } })).status()).toBe(200);
|
||||
const headers = await login(page.request, email);
|
||||
const created = await page.request.post(`${api}/companies`, { headers, data: { name: 'Very long Norwegian company name for readable widget layouts', source: 'Native size qualification' } });
|
||||
expect(created.status()).toBe(201);
|
||||
const companyId = (await created.json()).id;
|
||||
expect((await page.request.post(`${api}/jobapplications`, { headers, data: { companyId, jobTitle: 'Senior platform developer', status: 'Applied', dateApplied: new Date(Date.now() - 86400000).toISOString(), responseReceived: true, responseDate: new Date().toISOString(), tags: 'TypeScript, Norsk programvareutvikling', salaryMin: 600000, salaryMax: 850000, salaryCurrency: 'NOK', salaryPeriod: 'year' } })).status()).toBe(201);
|
||||
await page.route('**/*', route => ['localhost', '127.0.0.1'].includes(new URL(route.request().url()).hostname) ? route.continue() : route.abort());
|
||||
await page.goto('/dashboard');
|
||||
await page.getByRole('button', { name: 'Customize dashboard', exact: true }).click();
|
||||
const editor = page.getByRole('region', { name: 'Editing dashboard', exact: true });
|
||||
for (const widget of WIDGETS) if (widget.sizes.length > 1) {
|
||||
const select = editor.locator(`[data-layout-row="${widget.id}"] select`);
|
||||
await select.selectOption(size); await expect(select).toHaveValue(size);
|
||||
}
|
||||
await editor.getByRole('button', { name: 'Save layout', exact: true }).click();
|
||||
await expect(editor).toHaveCount(0);
|
||||
const readback = await (await page.request.get(`${api}/auth/preferences/dashboard`)).json();
|
||||
expect(readback.widgets.every((widget: { id: string; size: string }) => widget.size === (widget.id === 'cards' ? 'full' : size))).toBe(true);
|
||||
for (const summary of await page.locator('[data-dashboard-widget] details > summary').all()) await summary.click();
|
||||
for (const width of [320, 768, 1024, 1440]) {
|
||||
await page.setViewportSize({ width, height: 900 });
|
||||
await expect(page.locator('[data-dashboard-widget]')).toHaveCount(9);
|
||||
await expect.poll(() => page.evaluate(() => document.documentElement.scrollWidth <= innerWidth + 1)).toBe(true);
|
||||
const failures = await page.locator('[data-dashboard-widget]').evaluateAll(widgets => widgets.flatMap(widget => {
|
||||
const box = widget.getBoundingClientRect();
|
||||
return [...widget.querySelectorAll('table, svg, th, td')].filter(element => {
|
||||
const rect = element.getBoundingClientRect();
|
||||
return rect.width > 0 && (rect.right > box.right + 1 || rect.left < box.left - 1);
|
||||
}).map(element => ({ widget: widget.getAttribute('data-dashboard-widget'), tag: element.tagName }));
|
||||
}));
|
||||
expect(failures).toEqual([]);
|
||||
const amount = page.locator('[data-dashboard-widget="salary"]').getByText(new Intl.NumberFormat('en-GB', { style: 'currency', currency: 'NOK', maximumFractionDigits: 0 }).format(725000), { exact: true });
|
||||
await expect(amount).toBeVisible();
|
||||
expect(await amount.evaluate(element => {
|
||||
const range = document.createRange(); range.selectNodeContents(element);
|
||||
return range.getClientRects().length;
|
||||
})).toBe(1);
|
||||
const screenshot = info.outputPath(`${size}-${width}.png`);
|
||||
await page.screenshot({ path: screenshot, fullPage: true });
|
||||
await info.attach(`${size}-${width}`, { path: screenshot, contentType: 'image/png' });
|
||||
}
|
||||
} finally { await admin.dispose(); }
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 12 KiB |
Reference in New Issue
Block a user