Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion SW.Bitween.Web/ClientApp/e2e/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { expect, type Page } from "@playwright/test";
import { type Page } from "@playwright/test";

/** Checkbox labels carry their description in the accessible name, so anchor at the start. */
export const startsWith = (text: string) =>
Expand Down
22 changes: 9 additions & 13 deletions SW.Bitween.Web/ClientApp/e2e/mapper-cases.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,12 @@ import {
addFixedRule,
addList,
addListField,
addListValue,
addPathRule,
buildFromSample,
createSubscription,
expectPreview,
openDetail,
openMapper,
openWithSample,
preview,
saveAndReload,
Expand Down Expand Up @@ -372,20 +372,19 @@ test("every filter comparison keeps the entries it should", async ({ page }) =>
await openWithSample(page, { line: [{ qty: 1 }, { qty: 2 }, { qty: 3 }] });

for (const { field, operator } of OPERATOR_CASES) {
await addList(page, field, "line");
const list = await addList(page, field, "line");
await page.getByRole("button", { name: `Settings for the list ${field}` }).click();

await page.getByRole("checkbox", { name: "Only some entries" }).last().check();
await page.getByRole("textbox", { name: "Filter field" }).last().fill("qty");
await page.getByRole("combobox", { name: "Filter comparison" }).last().selectOption(operator);
await page.getByRole("textbox", { name: "Filter value" }).last().fill("2");

await page.getByRole("button", { name: `Settings for the list ${field}` }).click();

// A list of plain values, so what survived the filter reads straight off the
// preview rather than through a wrapper object.
await page.getByRole("checkbox", { name: /A list of plain values/ }).last().check();
await setSourcePath(page, "qty");

await page.getByRole("button", { name: `Settings for the list ${field}` }).click();
await addListValue(list, field, "qty");
}

for (const { expect: shape } of OPERATOR_CASES)
Expand Down Expand Up @@ -464,9 +463,10 @@ test("the whole output can be a list, of records or of plain values", async ({ p
await expect(preview(page)).toHaveText(/^\[[\s\S]*\]$/);

// ── And the same thing as plain values ─────────────────────────────────────
await page.getByRole("button", { name: "Settings for the list at the root" }).click();
await page.getByRole("checkbox", { name: /A list of plain values/ }).check();
await setSourcePath(page, "sku");
// A list holds one or the other, and says so by what it will let you add: the
// record's field has to go before the value can be put in its place.
await root.getByRole("button", { name: "Remove the rule for code" }).click();
await addListValue(root, "the root list", "sku");

await expect(preview(page)).toHaveText(/^\[\s*"A1",\s*"B7"\s*\]$/, { timeout: 15000 });
});
Expand Down Expand Up @@ -999,10 +999,6 @@ test("a checkbox in a settings panel can be ticked by its text", async ({ page }
// The entry with qty 0 is gone, which is the whole point of the checkbox.
await expectPreview(page, '"qty": 2');
await expect(preview(page)).not.toContainText('"qty": 0');

// And the same for the other checkbox in the panel.
await page.getByText("A list of plain values, not records").click();
await expect(page.getByText("each walked entry is")).toBeVisible();
});

test("a checkbox in a rule's detail can be ticked by its text", async ({ page }) => {
Expand Down
17 changes: 17 additions & 0 deletions SW.Bitween.Web/ClientApp/e2e/mapperHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,23 @@ export async function addListField(
await setSourcePath(list, path, from);
}

/**
* Makes a list hold plain values, and points its one value at a path.
*
* The counterpart of `addListField`. What a list holds is decided by what is put into
* it, so this is a click that adds a row rather than a setting that changes a mode —
* and it is only offered while the list is still empty.
*/
export async function addListValue(
list: Locator,
addTo: string,
path: string,
from: "entry" | "document" = "entry",
) {
await list.getByRole("button", { name: `Add a value to ${addTo}` }).click();
await setSourcePath(list, path, from);
}

/** The mapped document, which the server produces. */
export const preview = (page: Page): Locator => page.locator("pre").first();

Expand Down
66 changes: 62 additions & 4 deletions SW.Bitween.Web/ClientApp/e2e/native-mapper.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ import { test, expect } from "@playwright/test";
import { pickOption, signInAsAdmin } from "./helpers";
import {
SAMPLE,
addList,
addListValue,
addPathRule,
buildFromSample,
createSubscription,
Expand Down Expand Up @@ -298,6 +300,63 @@ test("builds the whole output from a sample of it, and matches the source fields
await expect(preview).toContainText('"sku": "B7"');
});

test("a list of plain values built from a sample is wired up and says so", async ({
page,
}) => {
// The shape that sent this round: both sides hold `[1,2,3]`, and the scaffolder
// wires each entry to the entry itself — the right answer, which used to be shown
// as an empty box behind a checkbox and read as nothing configured at all.
const subscriptionId = await createSubscription(page);
await openMapper(page, subscriptionId);

await page
.getByRole("textbox", { name: "Sample source document" })
.fill(JSON.stringify({ city: "errr", test: [1, 2, 3] }));
await buildFromSample(page, { city: "", test: [1, 2, 3] });
await page.getByRole("button", { name: "Build from a sample of the output" }).click();
await page.keyboard.press("Escape");

const list = page.getByRole("group", { name: "Rules for the list test" });

// A row in the tree, not a setting behind a chevron — and it reads as an answer
// rather than as a box waiting to be filled in.
const value = list.getByRole("combobox", { name: "Source field" });
await expect(value).toHaveAttribute("placeholder", "the entry itself");
await expect(value).toHaveValue("");
await expect(list.getByText("each entry")).toBeVisible();

// Nothing is left unassigned, which is what the count above the tree has to agree
// with: an empty path here is the answer, not a blank.
await expect(page.getByText("2 rules · 2 assigned")).toBeVisible();

// And it runs: the source values come straight through.
await expect(page.locator("pre").first()).toHaveText(/"test":\s*\[\s*1,\s*2,\s*3\s*\]/, {
timeout: 15000,
});
});

test("a list's value takes a type and a transform like any other rule", async ({ page }) => {
const subscriptionId = await createSubscription(page);
await openMapper(page, subscriptionId);
await page
.getByRole("textbox", { name: "Sample source document" })
.fill(JSON.stringify({ price: [10, 20] }));

const list = await addList(page, "totals", "price");
await addListValue(list, "totals", "");

// The row carries the whole rule, which is the point of it being a row: the value
// each entry produces can be multiplied and typed exactly like a named field.
await list.getByRole("button", { name: "Details for each entry" }).click();
await list.getByRole("combobox", { name: "Transform" }).selectOption("multiply");
await list.getByRole("textbox", { name: /Multiply.*By/ }).fill("2");
await list.getByRole("combobox", { name: "Value type" }).selectOption("number");

await expect(page.locator("pre").first()).toHaveText(/"totals":\s*\[\s*20,\s*40\s*\]/, {
timeout: 15000,
});
});

test("a list inside a list offers the entry's own lists, not the document's", async ({ page }) => {
const subscriptionId = await createSubscription(page);
await openMapper(page, subscriptionId);
Expand Down Expand Up @@ -499,11 +558,10 @@ test("a list of values with a slot per rule, walking nothing", async ({ page })
// Nothing to walk, so the list is exactly what is written into it. This is what
// the old mapper called a primitive array.
await page.getByRole("combobox", { name: "Source list" }).selectOption("none");
await page.getByRole("button", { name: "Settings for the list codes" }).click();
await page.getByRole("checkbox", { name: /A list of plain values/ }).check();

// Each entry mirrors the list, so each is one value rather than a record.
await page.getByRole("button", { name: "Add an entry to codes" }).click();
// What the list holds is decided by what is put in it, not by a setting: the first
// slot says these are plain values, and every entry after it follows.
await page.getByRole("button", { name: "Add a value to codes" }).click();
await page.getByRole("button", { name: "Add an entry to codes" }).click();

const first = page.getByRole("group", { name: "Entry 1" });
Expand Down
77 changes: 56 additions & 21 deletions SW.Bitween.Web/ClientApp/src/components/nativeMapper/EntryRow.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,15 @@
import { CornerDownRight, Trash2 } from "lucide-react";
import { useState } from "react";
import { ChevronDown, ChevronRight, CornerDownRight, Trash2 } from "lucide-react";
import type { OutputEntryNode } from "../../lib/nativeMapper/outputTree";
import { useRules, useRulesDispatch } from "../../lib/nativeMapper/RulesEditorContext";
import { SOURCE_KINDS, freshSource } from "../../lib/nativeMapper/types";
import {
SOURCE_KINDS,
TYPE_BADGES,
freshSource,
type EditorFieldRule,
} from "../../lib/nativeMapper/types";
import { SegmentedControl } from "../ui/SegmentedControl";
import { RuleDetail } from "./RuleDetail";
import { ValueCell, type SourcePaths } from "./ValueCell";

/**
Expand All @@ -12,6 +19,11 @@ import { ValueCell, type SourcePaths } from "./ValueCell";
* from somewhere different. Its rules read whatever the list reads, because there
* is no entry of its own to read — which is what lets a constant entry still pull a
* value out of the document, the partner or a values set.
*
* An entry holding one value is a whole rule on this line, so it gets what any other
* rule gets: a transform, a substitution table and a type, behind the chevron. It is
* a slot in the output like any other, and "it is written in by hand" is no reason
* for it to be the one value in the mapping that cannot be rounded or reformatted.
*/
export function EntryRow({
node,
Expand All @@ -24,9 +36,18 @@ export function EntryRow({
const { entry, position } = node;
const dispatch = useRulesDispatch();
const { ruleErrors } = useRules();
const [open, setOpen] = useState(false);

const error = ruleErrors[node.errorKey];
const isValue = entry.item !== undefined;
const item = entry.item;
const extras = (item?.transform ? 1 : 0) + (item?.lookup ? 1 : 0);

const updateItem = (changes: Partial<Omit<EditorFieldRule, "id">>) =>
dispatch({
type: "UPDATE_FIXED_ENTRY",
id: entry.id,
changes: { item: { ...item!, ...changes } },
});

return (
<div
Expand All @@ -44,7 +65,7 @@ export function EntryRow({
entry {position}
</span>

{isValue && entry.item && (
{item && (
<>
<span aria-hidden className="flex-shrink-0 text-ink-300">
Expand All @@ -53,27 +74,35 @@ export function EntryRow({
size="sm"
label={`Where entry ${position} comes from`}
options={SOURCE_KINDS}
value={entry.item.from.kind === "rootPath" ? "path" : entry.item.from.kind}
onChange={(kind) =>
dispatch({
type: "UPDATE_FIXED_ENTRY",
id: entry.id,
changes: { item: { ...entry.item!, from: freshSource(kind) } },
})
}
value={item.from.kind === "rootPath" ? "path" : item.from.kind}
onChange={(kind) => updateItem({ from: freshSource(kind) })}
/>
<ValueCell
source={entry.item.from}
source={item.from}
Comment thread
hamzahalq marked this conversation as resolved.
paths={paths}
valueType={entry.item.type}
onChange={(from) =>
dispatch({
type: "UPDATE_FIXED_ENTRY",
id: entry.id,
changes: { item: { ...entry.item!, from } },
})
}
valueType={item.type}
onChange={(from) => updateItem({ from })}
/>
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
aria-label={`Details for entry ${position}`}
title={
extras > 0
? "Has a transform or a table"
: "Change it, substitute it, or set its type"
}
className={`flex flex-shrink-0 items-center gap-0.5 rounded px-1 py-0.5 hover:bg-ink-100 ${
extras > 0 ? "text-crimson-600" : "text-ink-400 hover:text-ink-700"
}`}
>
{open ? <ChevronDown size={13} /> : <ChevronRight size={13} />}
{item.type && (
<span className="font-mono text-[9px]">{TYPE_BADGES[item.type]}</span>
)}
{extras > 0 && <span aria-hidden className="size-1 rounded-full bg-crimson-500" />}
</button>
</>
)}

Expand All @@ -88,6 +117,12 @@ export function EntryRow({
</button>
</div>

{open && item && (
<div className="px-1.5 pb-1.5">
<RuleDetail rule={item} onChange={updateItem} />
</div>
)}

{/* A colour is not a message. The reason is rendered here as a list's is, so it
reaches a reader who cannot tell the two borders apart. */}
{error && (
Expand Down
Loading
Loading