polish: rebrand to Fleet Ops, add trilingual i18n, adaptive demo guide, and UX overhaul
Rebrands the product from MobilityOps to Fleet Ops across the UI, backend defaults and knowledge base, and makes nl-BE/en-GB/fr-BE full first-class languages: i18next with eager-bundled per-namespace resources, a persisted accessible language switcher (topbar and mobile drawer), locale-aware date/number formatting, and a coverage test that fails the build on any missing or empty translation key. Backend dynamic content (demo scenarios, blocked-reason text, integration status) moves from fixed English/Dutch prose to stable message codes + params so the frontend can localize it; the demo knowledge base gains a fully translated NL/EN/FR procedure corpus (11 documents each) with per-language retrieval and localized evidence-state messages. The Demo Guide becomes breakpoint-adaptive: a docked rail on extra-wide desktop, a floating panel that auto-collapses to a persistent, closable progress chip on standard desktop/tablet, and a collapsed/half/full bottom sheet on mobile -- with scroll+focus+ highlight on "go to this step", Escape handling, and reduced-motion support. The Data Quality Workbench gets accessible choice-card decisions with a clear primary/ secondary/tertiary action hierarchy; the Automation ledger groups repeated successes and uses meaningful short refs; the Audit trail groups events by correlation id with human action labels and readable before/after diffs. Attention Queue, Today's movements, Vehicles, Bookings and Data Quality rows are fully clickable (stretched-link pattern) with independent secondary links, keyboard support and mobile touch targets. Fixes a topbar overflow on mobile caused by the new language switcher (moved into the mobile drawer at <=960px) and two dangling aria-labelledby references introduced this session. Updates all affected Playwright specs for the new nl-BE default and the new Audit/DemoGuide DOM structure, and adds new i18n-coverage, demo-guide-adaptive and clickable-rows specs. 131 backend tests, Ruff and mypy, and 71 Playwright tests pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
257a4cf6c0
commit
337f8716bb
@@ -7,9 +7,13 @@ async function resetDemoData(request: APIRequestContext) {
|
||||
|
||||
test.describe.configure({ mode: "serial" });
|
||||
|
||||
// This file's assertions were authored against the English UI copy; nl-BE is now the
|
||||
// app's default for a fresh session, so force English explicitly rather than rewriting
|
||||
// every assertion (the equivalent Dutch/French coverage lives in the i18n-specific specs).
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.addInitScript(() => localStorage.setItem("fleetops.language", "en-GB"));
|
||||
await page.goto("/login");
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
@@ -52,7 +56,7 @@ test("vehicles page: free-text search actually filters the rendered rows", async
|
||||
const totalRows = await page.locator(".data-table tbody tr").count();
|
||||
expect(totalRows).toBeGreaterThan(1);
|
||||
|
||||
const searchBox = page.getByRole("form", { name: "Filter vehicles" }).getByLabel("Search");
|
||||
const searchBox = page.getByRole("form", { name: "Vehicle fleet" }).getByLabel("Search");
|
||||
await searchBox.fill("MO-001");
|
||||
await expect(async () => {
|
||||
const rows = await page.locator(".data-table tbody tr").count();
|
||||
@@ -140,7 +144,7 @@ test("data quality page: status and rule-type filters work", async ({ page }) =>
|
||||
);
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const rules = await page.locator(".data-table tbody tr td:nth-child(2)").allTextContents();
|
||||
expect(rules.every((r) => r.includes("possible duplicate customer"))).toBeTruthy();
|
||||
expect(rules.every((r) => r.includes("Possible duplicate customer"))).toBeTruthy();
|
||||
|
||||
await page.getByRole("combobox", { name: "Rule type", exact: true }).selectOption("");
|
||||
await page.getByRole("combobox", { name: "Status", exact: true }).selectOption("resolved");
|
||||
@@ -159,7 +163,7 @@ test("data quality issue detail: defer and reject buttons work", async ({ page,
|
||||
await firstLink.click();
|
||||
await expect(page.getByRole("heading", { name: ref ?? "" })).toBeVisible();
|
||||
await page.getByRole("button", { name: "Defer" }).click();
|
||||
await expect(page.getByText("deferred", { exact: true })).toBeVisible();
|
||||
await expect(page.locator(".badge.status-deferred")).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: providing missing fields resolves a vehicle issue", async ({ page, request }) => {
|
||||
@@ -173,7 +177,7 @@ test("data quality: providing missing fields resolves a vehicle issue", async ({
|
||||
await page.getByLabel("Location").fill("Depot");
|
||||
await page.getByRole("button", { name: "Save and re-check" }).click();
|
||||
|
||||
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Resolved").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: resolving a booking overlap blocks one booking", async ({ page, request }) => {
|
||||
@@ -184,7 +188,7 @@ test("data quality: resolving a booking overlap blocks one booking", async ({ pa
|
||||
await page.getByRole("radio", { name: /Block BK-DEMO-OVERLAP-A/ }).check();
|
||||
await page.getByRole("button", { name: /^Block BK-DEMO-OVERLAP-A$/ }).click();
|
||||
|
||||
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Resolved").first()).toBeVisible();
|
||||
const booking = await page.request.get("/api/v1/bookings/BK-DEMO-OVERLAP-A");
|
||||
expect((await booking.json()).status).toBe("blocked");
|
||||
});
|
||||
@@ -220,7 +224,7 @@ test("data quality: retaining canonical resolves an odometer regression issue",
|
||||
await page.getByRole("radio", { name: /Retain canonical/ }).check();
|
||||
await page.getByRole("button", { name: "Resolve issue" }).click();
|
||||
|
||||
await expect(page.getByText("resolved", { exact: true })).toBeVisible();
|
||||
await expect(page.getByText("Resolved").first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("data quality: manual scan runs and shows a result summary", async ({ page, request }) => {
|
||||
@@ -255,11 +259,11 @@ test("automation page: status filter and retry button work", async ({ page, requ
|
||||
|
||||
test("audit page: action filter works", async ({ page }) => {
|
||||
await page.goto("/audit");
|
||||
await expect(page.locator(".data-table")).toBeVisible();
|
||||
await expect(page.locator(".audit-group-list")).toBeVisible();
|
||||
await page.getByLabel("Action").fill("demo_login");
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const actions = await page.locator(".data-table tbody tr td:nth-child(3)").allTextContents();
|
||||
expect(actions.every((a) => a.includes("demo login"))).toBeTruthy();
|
||||
await expect(page.locator(".audit-group").first()).toBeVisible();
|
||||
const headings = await page.locator(".audit-group-heading strong").allTextContents();
|
||||
expect(headings.every((a) => a === "Logged in")).toBeTruthy();
|
||||
});
|
||||
|
||||
test("audit page: shows human-readable before/after and a safe entity link", async ({
|
||||
@@ -284,14 +288,14 @@ test("audit page: shows human-readable before/after and a safe entity link", asy
|
||||
|
||||
await page.goto("/audit");
|
||||
await page.getByLabel("Action").fill("return_registered");
|
||||
await expect(page.locator(".data-table tbody tr").first()).toBeVisible();
|
||||
const firstGroup = page.locator(".audit-group").first();
|
||||
await expect(firstGroup).toBeVisible();
|
||||
|
||||
const changeCell = page.locator(".data-table tbody tr").first().locator("td").nth(4);
|
||||
await expect(changeCell).toContainText("status");
|
||||
await expect(changeCell).toContainText("returned");
|
||||
const changeDiff = firstGroup.locator(".change-diff");
|
||||
await expect(changeDiff).toContainText(/status/i);
|
||||
await expect(changeDiff).toContainText("returned");
|
||||
|
||||
const entityCell = page.locator(".data-table tbody tr").first().locator("td").nth(3);
|
||||
await expect(entityCell.locator("a")).toHaveAttribute("href", /\/bookings\/BK-/);
|
||||
await expect(firstGroup.locator(".audit-group-meta a")).toHaveAttribute("href", /\/bookings\/BK-/);
|
||||
});
|
||||
|
||||
test("knowledge page: form submits and clears input", async ({ page }) => {
|
||||
@@ -340,7 +344,7 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: "Switch role" }).click();
|
||||
await page.getByRole("button", { name: "Verken als Rental Employee" }).click();
|
||||
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
// Manager-only nav items are not shown at all, not merely disabled.
|
||||
@@ -352,16 +356,16 @@ test("rental employee role has a restricted nav and cannot reach manager-only pa
|
||||
// message as a defense-in-depth measure, not just a hidden button.
|
||||
await page.goto("/automation");
|
||||
await expect(
|
||||
page.getByText("Automation delivery status is visible to Operations Managers only."),
|
||||
page.getByText("Automation is visible to Operations Managers only.").first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto("/data-quality");
|
||||
await expect(
|
||||
page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only."),
|
||||
page.getByText("Data-quality evidence and resolutions are visible to Operations Managers only.").first(),
|
||||
).toBeVisible();
|
||||
|
||||
await page.goto("/audit");
|
||||
await expect(page.getByText("Audit history is visible to Operations Managers only.")).toBeVisible();
|
||||
await expect(page.getByText("Audit history is visible to Operations Managers only.").first()).toBeVisible();
|
||||
|
||||
await expect(page.getByRole("button", { name: "Reset demo data" })).toHaveCount(0);
|
||||
});
|
||||
@@ -374,7 +378,7 @@ test("operations manager can reset demo data and is returned to login", async ({
|
||||
await expect(page).toHaveURL(/\/login$/);
|
||||
|
||||
// The reset must not have affected the ability to log back in against fresh data.
|
||||
await page.getByRole("button", { name: "Verken als Operations Manager" }).click();
|
||||
await page.getByRole("button", { name: "Explore as Operations Manager" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
});
|
||||
|
||||
@@ -392,7 +396,7 @@ test("rental employee direct API access to manager-only endpoints is rejected",
|
||||
page,
|
||||
}) => {
|
||||
await page.getByRole("button", { name: "Switch role" }).click();
|
||||
await page.getByRole("button", { name: "Verken als Rental Employee" }).click();
|
||||
await page.getByRole("button", { name: "Explore as Rental Employee" }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard$/);
|
||||
|
||||
// page.request shares the browser context's cookies, and (via the web container's
|
||||
|
||||
Reference in New Issue
Block a user