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.
|
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
|
## 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).
|
- 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-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-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.
|
- [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.
|
- [ ] 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
|
## 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.
|
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
|
## 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.
|
- 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.
|
- 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.
|
- 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 |
@@ -273,7 +273,7 @@ export default function DashboardView({ mode = "dashboard" }: { mode?: "dashboar
|
|||||||
</Box>)}</Stack>
|
</Box>)}</Stack>
|
||||||
</PageSection> : null,
|
</PageSection> : null,
|
||||||
salary: summaryReady && (overview?.salaryInsights?.length ?? 0) > 0 ? <PageSection title={t("dashboardSalaryInsights")} description={t("dashboardSalaryDefinition")}>
|
salary: summaryReady && (overview?.salaryInsights?.length ?? 0) > 0 ? <PageSection title={t("dashboardSalaryInsights")} description={t("dashboardSalaryDefinition")}>
|
||||||
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: "repeat(3, minmax(0, 1fr))" }, gap: 2 }}>
|
<Box sx={{ display: "grid", gridTemplateColumns: { xs: "1fr", md: reporting || activeLayout.widgets.find(widget => widget.id === "salary")?.size === "full" ? "repeat(3, minmax(0, 1fr))" : "1fr" }, gap: 2 }}>
|
||||||
{overview!.salaryInsights!.map(item => {
|
{overview!.salaryInsights!.map(item => {
|
||||||
const money = new Intl.NumberFormat(locale, { style: "currency", currency: item.currency, maximumFractionDigits: 0 });
|
const money = new Intl.NumberFormat(locale, { style: "currency", currency: item.currency, maximumFractionDigits: 0 });
|
||||||
return <Box key={item.currency + item.period} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
|
return <Box key={item.currency + item.period} sx={{ p: 2, border: "1px solid", borderColor: "divider", borderRadius: 1 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user