From ac74e5743ade94a67c69a40450c2f27781d4c251 Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 20 Sep 2026 16:34:37 +0300 Subject: [PATCH 1/4] fix: let a popover opt out of closing on unrelated scrolls MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scroll listener is on document in the capture phase, so any scrolling element on the page closed the panel — including a pane that had only re-rendered elsewhere. Defaults to the old behaviour. --- .../ClientApp/src/components/ui/Popover.tsx | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/src/components/ui/Popover.tsx b/SW.Bitween.Web/ClientApp/src/components/ui/Popover.tsx index 094d2239..7abecfdf 100644 --- a/SW.Bitween.Web/ClientApp/src/components/ui/Popover.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/ui/Popover.tsx @@ -21,6 +21,7 @@ export function Popover({ label, children, width = "w-72", + closeOnScroll = true, }: { /** Rendered inside the trigger button. */ button: ReactNode; @@ -30,6 +31,14 @@ export function Popover({ children: ReactNode; /** Tailwind width class for the panel. */ width?: string; + /** + * Off for a trigger that cannot scroll away from its panel — one in a fixed + * toolbar, say. The scroll listener is on `document` in the capture phase, so + * it fires for *any* scrolling element on the page, including a pane that + * merely re-rendered somewhere else; where the trigger never moves, that + * closes the panel under the person using it for no reason. + */ + closeOnScroll?: boolean; }) { const [open, setOpen] = useState(false); const [pos, setPos] = useState({ top: 0, left: 0 }); @@ -61,7 +70,7 @@ export function Popover({ document.addEventListener("mousedown", onDown); document.addEventListener("keydown", onKey); - document.addEventListener("scroll", onScroll, true); + if (closeOnScroll) document.addEventListener("scroll", onScroll, true); window.addEventListener("resize", onScroll); return () => { document.removeEventListener("mousedown", onDown); @@ -69,7 +78,7 @@ export function Popover({ document.removeEventListener("scroll", onScroll, true); window.removeEventListener("resize", onScroll); }; - }, [open]); + }, [open, closeOnScroll]); return ( <> From c5bfbd00249806456a12ac223e7789d57ea1a81e Mon Sep 17 00:00:00 2001 From: Hamza Alqurneh Date: Sun, 20 Sep 2026 16:34:43 +0300 Subject: [PATCH 2/4] refactor: group the mapper editor toolbar into one readable row The formats and their CSV options move behind a "JSON -> CSV" chip, which stops the row jumping width when a side changes format. Back/title, rule actions and editor state are now separated rather than all at one level. --- .../ClientApp/e2e/mapper-csv.spec.ts | 39 ++-- .../ClientApp/e2e/mapper-xml.spec.ts | 27 ++- SW.Bitween.Web/ClientApp/e2e/mapperHelpers.ts | 20 ++ .../ClientApp/e2e/native-mapper.spec.ts | 4 +- .../ClientApp/e2e/readable-documents.spec.ts | 5 +- .../nativeMapper/NativeMapperEditor.tsx | 178 ++++++++++++------ 6 files changed, 193 insertions(+), 80 deletions(-) diff --git a/SW.Bitween.Web/ClientApp/e2e/mapper-csv.spec.ts b/SW.Bitween.Web/ClientApp/e2e/mapper-csv.spec.ts index b491f8e0..36376897 100644 --- a/SW.Bitween.Web/ClientApp/e2e/mapper-csv.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/mapper-csv.spec.ts @@ -6,6 +6,7 @@ import { expectPreview, openMapper, preview, + withFormats, } from "./mapperHelpers"; /** @@ -42,11 +43,13 @@ async function openWithCsv(page: import("@playwright/test").Page, sample: string const subscriptionId = await createSubscription(page); await openMapper(page, subscriptionId); - await page.getByLabel("From format").selectOption("csv"); - await page.getByLabel("source delimiter").selectOption(delimiter); - const box = page.getByRole("checkbox", { name: "source header row" }); - if (header) await box.check(); - else await box.uncheck(); + await withFormats(page, async () => { + await page.getByLabel("From format").selectOption("csv"); + await page.getByLabel("source delimiter").selectOption(delimiter); + const box = page.getByRole("checkbox", { name: "source header row" }); + if (header) await box.check(); + else await box.uncheck(); + }); await page.getByRole("textbox", { name: "Sample source document" }).fill(sample); return subscriptionId; @@ -142,9 +145,11 @@ test("writing a delimited file takes its delimiter and header from the target si }) => { await openWithCsv(page, TRACKING, "|", false); - await page.getByLabel("To format").selectOption("csv"); - await page.getByLabel("target delimiter").selectOption(";"); - await page.getByRole("checkbox", { name: "target header row" }).check(); + await withFormats(page, async () => { + await page.getByLabel("To format").selectOption("csv"); + await page.getByLabel("target delimiter").selectOption(";"); + await page.getByRole("checkbox", { name: "target header row" }).check(); + }); const root = await rootListOverTheDocument(page); await page.getByRole("button", { name: "Settings for the list at the root" }).click(); @@ -163,7 +168,7 @@ test("writing a delimited file takes its delimiter and header from the target si test("a nested rule becomes a dotted column", async ({ page }) => { await openWithCsv(page, MOVEMENTS, ",", true); - await page.getByLabel("To format").selectOption("csv"); + await withFormats(page, () => page.getByLabel("To format").selectOption("csv")); const root = await rootListOverTheDocument(page); // A row is flat, so the nesting has to land somewhere. The name box splits on dots, so @@ -176,7 +181,7 @@ test("a nested rule becomes a dotted column", async ({ page }) => { test("a shape a row cannot hold is refused with a reason", async ({ page }) => { await openWithCsv(page, MOVEMENTS, ",", true); - await page.getByLabel("To format").selectOption("csv"); + await withFormats(page, () => page.getByLabel("To format").selectOption("csv")); // A list inside a row. There is no cell that holds one, and inventing a way to fit it — // joining the entries, taking the first — would lose data without a word. @@ -208,9 +213,11 @@ test("the carrier's whole file can be produced, trailer count and all", async ({ // client's ends with a record carrying how many records came before it. await openWithCsv(page, TRACKING, "|", false); - await page.getByLabel("To format").selectOption("csv"); - await page.getByLabel("target delimiter").selectOption("|"); - await page.getByRole("checkbox", { name: "target header row" }).uncheck(); + await withFormats(page, async () => { + await page.getByLabel("To format").selectOption("csv"); + await page.getByLabel("target delimiter").selectOption("|"); + await page.getByRole("checkbox", { name: "target header row" }).uncheck(); + }); const root = await rootListOverTheDocument(page); await page.getByRole("button", { name: "Settings for the list at the root" }).click(); @@ -261,7 +268,7 @@ test("counting is offered inside a list and nowhere else", async ({ page }) => { test("a file can be marked so Excel opens accented names correctly", async ({ page }) => { await openWithCsv(page, MOVEMENTS, ",", true); - await page.getByLabel("To format").selectOption("csv"); + await withFormats(page, () => page.getByLabel("To format").selectOption("csv")); const root = await rootListOverTheDocument(page); await addListField(root, "the root list", "shipment", "ShipmentNumber"); @@ -270,7 +277,9 @@ test("a file can be marked so Excel opens accented names correctly", async ({ pa // The mark itself is invisible, so what is checked is that asking for it changes the // document the server produced rather than that anything looks different. const before = await preview(page).textContent(); - await page.getByRole("checkbox", { name: "write a byte-order mark" }).check(); + await withFormats(page, () => + page.getByRole("checkbox", { name: "write a byte-order mark" }).check(), + ); await expect .poll(async () => (await preview(page).textContent())?.charCodeAt(0), { timeout: 15000 }) .toBe(0xfeff); diff --git a/SW.Bitween.Web/ClientApp/e2e/mapper-xml.spec.ts b/SW.Bitween.Web/ClientApp/e2e/mapper-xml.spec.ts index c6117af7..5051677e 100644 --- a/SW.Bitween.Web/ClientApp/e2e/mapper-xml.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/mapper-xml.spec.ts @@ -11,6 +11,7 @@ import { openMapper, saveAndReload, suggestionsFor, + withFormats, } from "./mapperHelpers"; /** @@ -59,7 +60,7 @@ const SOAP_REQUEST = ` page.getByLabel("From format").selectOption("xml")); await page.getByRole("textbox", { name: "Sample source document" }).fill(sample); return subscriptionId; } @@ -120,8 +121,10 @@ test("a mapping that writes XML takes its namespaces from the sample of the outp }) => { const subscriptionId = await createSubscription(page); await openMapper(page, subscriptionId); - await page.getByLabel("From format").selectOption("xml"); - await page.getByLabel("To format").selectOption("xml"); + await withFormats(page, async () => { + await page.getByLabel("From format").selectOption("xml"); + await page.getByLabel("To format").selectOption("xml"); + }); await page.getByRole("textbox", { name: "Sample source document" }).fill(SOAP_REQUEST); await buildFromSample( @@ -152,8 +155,10 @@ test("a shape XML cannot hold is refused with a reason, not a broken document", }) => { const subscriptionId = await createSubscription(page); await openMapper(page, subscriptionId); - await page.getByLabel("From format").selectOption("xml"); - await page.getByLabel("To format").selectOption("xml"); + await withFormats(page, async () => { + await page.getByLabel("From format").selectOption("xml"); + await page.getByLabel("To format").selectOption("xml"); + }); await page.getByRole("textbox", { name: "Sample source document" }).fill(SOAP_REQUEST); // JSON writes as many top-level keys as it likes; XML has exactly one root element. @@ -179,8 +184,10 @@ test("an element that carries both an attribute and a value maps as two rules", // same convention as reading, in reverse. const subscriptionId = await createSubscription(page); await openMapper(page, subscriptionId); - await page.getByLabel("From format").selectOption("xml"); - await page.getByLabel("To format").selectOption("xml"); + await withFormats(page, async () => { + await page.getByLabel("From format").selectOption("xml"); + await page.getByLabel("To format").selectOption("xml"); + }); await page.getByRole("textbox", { name: "Sample source document" }).fill(SOAP_REQUEST); // The sample needs a value between the tags, not just the attribute: an element with @@ -200,8 +207,10 @@ test("an element that carries both an attribute and a value maps as two rules", test("an attribute can be added to an element by hand, without a sample", async ({ page }) => { const subscriptionId = await createSubscription(page); await openMapper(page, subscriptionId); - await page.getByLabel("From format").selectOption("xml"); - await page.getByLabel("To format").selectOption("xml"); + await withFormats(page, async () => { + await page.getByLabel("From format").selectOption("xml"); + await page.getByLabel("To format").selectOption("xml"); + }); await page.getByRole("textbox", { name: "Sample source document" }).fill(SOAP_REQUEST); // Dots separate the levels, so `order.weight.@unit` puts the attribute on `weight` diff --git a/SW.Bitween.Web/ClientApp/e2e/mapperHelpers.ts b/SW.Bitween.Web/ClientApp/e2e/mapperHelpers.ts index fe0386e6..c83464e4 100644 --- a/SW.Bitween.Web/ClientApp/e2e/mapperHelpers.ts +++ b/SW.Bitween.Web/ClientApp/e2e/mapperHelpers.ts @@ -63,6 +63,26 @@ export async function openWithSample(page: Page, sample: unknown = SAMPLE): Prom return subscriptionId; } +/** + * Runs `set` with the format panel open, and closes it afterwards. + * + * What the mapping reads and writes sits behind a summary chip rather than in the + * toolbar row, so a test that changes a format has to open the panel first — and the + * panel covers the rules underneath, so it has to be closed before touching them. + */ +export async function withFormats(page: Page, set: () => Promise) { + const chip = page.getByRole("button", { name: "What this mapping reads and writes" }); + const panel = page.getByLabel("From format"); + + // Waited for on both sides because the chip toggles: acting before the panel has + // opened, or opening again before the last one has gone, closes it instead. + await chip.click(); + await expect(panel).toBeVisible({ timeout: 15000 }); + await set(); + await page.keyboard.press("Escape"); + await expect(panel).toBeHidden({ timeout: 15000 }); +} + /** Pastes an output sample into the toolbar panel and builds the rules from it. */ export async function buildFromSample(page: Page, target: unknown) { await page.getByRole("button", { name: "Build from a sample of the output" }).click(); diff --git a/SW.Bitween.Web/ClientApp/e2e/native-mapper.spec.ts b/SW.Bitween.Web/ClientApp/e2e/native-mapper.spec.ts index 07137d34..9d320765 100644 --- a/SW.Bitween.Web/ClientApp/e2e/native-mapper.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/native-mapper.spec.ts @@ -220,7 +220,9 @@ test("the editor opens for the mapper you picked, not the one that is saved", as // Saved as the old mapper, picking the new one: the new editor, no save in between. await pickOption(page, "mapper adapter", "NativeMapper"); await link.click(); - await expect(page.getByLabel("From format")).toBeVisible({ timeout: 15000 }); + await expect( + page.getByRole("button", { name: "What this mapping reads and writes" }), + ).toBeVisible({ timeout: 15000 }); // And back the other way, which is the same bug reversed. await page.goto(`subscriptions/${subscriptionId}/mapper?mapper=NativeJSONMapper`); diff --git a/SW.Bitween.Web/ClientApp/e2e/readable-documents.spec.ts b/SW.Bitween.Web/ClientApp/e2e/readable-documents.spec.ts index 79d844e7..778df23b 100644 --- a/SW.Bitween.Web/ClientApp/e2e/readable-documents.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/readable-documents.spec.ts @@ -6,6 +6,7 @@ import { expectPreview, openMapper, preview, + withFormats, } from "./mapperHelpers"; /** @@ -40,7 +41,7 @@ test.beforeEach(async ({ page }) => { test("lays out a one-line XML sample, and the tree still reads it", async ({ page }) => { const subscriptionId = await createSubscription(page); await openMapper(page, subscriptionId); - await page.getByLabel("From format").selectOption("xml"); + await withFormats(page, () => page.getByLabel("From format").selectOption("xml")); const sample = page.getByRole("textbox", { name: "Sample source document" }); await sample.fill(MINIFIED_XML); @@ -133,7 +134,7 @@ test("the mapped document is coloured, in whichever format it is written", async // Switching the output to XML colours it as XML, because the mapping declares the // format rather than the pane guessing from the text. - await page.getByLabel("To format").selectOption("xml"); + await withFormats(page, () => page.getByLabel("To format").selectOption("xml")); await page.getByRole("textbox", { name: "Output field name" }).first().fill("order"); await expect(preview.locator(".hljs-name").first()).toBeVisible({ timeout: 15000 }); }); diff --git a/SW.Bitween.Web/ClientApp/src/components/nativeMapper/NativeMapperEditor.tsx b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/NativeMapperEditor.tsx index c555fe02..061f6708 100644 --- a/SW.Bitween.Web/ClientApp/src/components/nativeMapper/NativeMapperEditor.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/nativeMapper/NativeMapperEditor.tsx @@ -1,7 +1,7 @@ import { useCallback, useMemo, useState, type ReactNode } from "react"; import { useNavigate, useParams } from "react-router"; import { useQuery } from "@tanstack/react-query"; -import { ArrowLeft, Check, Eraser, Eye, EyeOff, Link2, Redo2, Undo2 } from "lucide-react"; +import { ArrowLeft, Check, ChevronDown, Eraser, Eye, EyeOff, Link2, Redo2, Undo2 } from "lucide-react"; import { api } from "../../api"; import { keys } from "../../api/queryKeys"; import { @@ -26,6 +26,7 @@ import { import { Button, FormError } from "../ui/basics"; import { ConnectionLines, type Connection } from "../ui/ConnectionLines"; import { ConfirmDialog } from "../ui/overlays"; +import { Popover } from "../ui/Popover"; import { Select } from "../ui/forms"; import { BuildFromSample } from "./BuildFromSample"; import { OutputPanel } from "./OutputPanel"; @@ -173,44 +174,13 @@ function Editor({ target, onClose }: { target?: MappingTarget; onClose?: () => v Mapping -
- dispatch({ type: "SET_SOURCE_FORMAT", format })} - /> - {rules.sourceFormat === "csv" && ( - dispatch({ type: "SET_CSV_OPTIONS", side: "source", options })} - /> - )} - - → - - dispatch({ type: "SET_TARGET_FORMAT", format })} - /> - {rules.targetFormat === "csv" && ( - dispatch({ type: "SET_CSV_OPTIONS", side: "target", options })} - /> - )} -
+ -
- -
+ - dispatch({ type: "SET_TEST_PARTNER", partnerId: partner })} - /> + + + v icon={} onClick={() => dispatch({ type: "MATCH_SOURCES" })} /> - {match && } v onClick={() => dispatch({ type: "CLEAR_RULES" })} /> + {match && } +
+ dispatch({ type: "SET_TEST_PARTNER", partnerId: partner })} + /> +