diff --git a/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts b/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts index 44b331da..6143f28b 100644 --- a/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts @@ -46,15 +46,35 @@ test("scheduled job create, adapters, pause/resume, receive now, list, delete", await expect(page).toHaveURL(/\/subscriptions\/\d+$/); await expect(page.getByRole("heading", { name })).toBeVisible(); - await expect(page.getByRole("button", { name: "Active" })).toBeVisible(); + // A badge now, not a button — the switch that turns it off lives with Pause on the right. + await expect(page.getByText("Active", { exact: true }).first()).toBeVisible(); + + // Disable / enable. Distinct from pause below: this one drops the work rather than holding it, + // and unlike the chip it replaced it applies on confirm instead of waiting for a save. + await page.getByRole("button", { name: "Disable", exact: true }).click(); + await page + .getByRole("dialog", { name: "Disable this subscription?" }) + .getByRole("button", { name: "Disable" }) + .click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByText("Disabled", { exact: true }).first()).toBeVisible(); + + await page.getByRole("button", { name: "Enable", exact: true }).click(); + await page + .getByRole("dialog", { name: "Enable this subscription?" }) + .getByRole("button", { name: "Enable" }) + .click(); + await expect(page.getByRole("dialog")).toHaveCount(0); + await expect(page.getByText("Disabled", { exact: true })).toHaveCount(0); - // Pause / resume. - await page.getByRole("button", { name: "Pause" }).click(); + // Pause / resume. `exact` because the history card's "Show what changed: …, PausedOn, …" + // button is also a substring match for "Pause" once there are edits to show. + await page.getByRole("button", { name: "Pause", exact: true }).click(); await page.getByRole("dialog", { name: "Pause this subscription?" }).getByRole("button", { name: "Pause" }).click(); await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByText("Paused", { exact: true }).first()).toBeVisible(); - await page.getByRole("button", { name: "Resume" }).click(); + await page.getByRole("button", { name: "Resume", exact: true }).click(); await page.getByRole("dialog", { name: "Resume this subscription?" }).getByRole("button", { name: "Resume" }).click(); await expect(page.getByRole("dialog")).toHaveCount(0); await expect(page.getByText("Paused", { exact: true })).toHaveCount(0); diff --git a/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx b/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx index a3219328..a1471167 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/shared.tsx @@ -675,6 +675,18 @@ export function WiredHealthBadge({ * Everything here comes from caches the app already holds, keyed by subscription * id; the gateway endpoints know none of it. */ +/** + * Work group and retry policy are the two columns that sit between "Runs" and + * "Status" and, rendered as plain links like it, gave the row three stretches of + * identical 13px text with nothing but padding between them — which is what made + * the table hard to read across. They are both *an assignment to a named thing*, + * so they take a shared chip: it groups the pair, and separates them from the + * subscription link on one side and the status badges on the other. + */ +const assignmentChip = + "inline-flex items-center rounded-md bg-ink-50 px-1.5 py-0.5 text-[12px] text-ink-600 " + + "hover:bg-ink-100 hover:text-crimson-700"; + export function useWiredSubscriptionColumns( subscriptionIdOf: (row: T) => number, /** Off where the parent already fixes it — a bus gateway listens for one type. */ @@ -726,10 +738,7 @@ export function useWiredSubscriptionColumns( return Ungrouped; const name = workGroupNames.get(id); return name ? ( - + {name} ) : ( @@ -745,10 +754,7 @@ export function useWiredSubscriptionColumns( return None; const name = retryPolicyNames.get(id); return name ? ( - + {name} ) : ( diff --git a/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx b/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx index 2cefa427..6a2e3a2d 100644 --- a/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/ui/Table.tsx @@ -192,8 +192,12 @@ export function MiniTable({ const start = Math.min(page, Math.max(0, Math.ceil(matches.length / pageSize) - 1)) * pageSize; const shown = paged ? matches.slice(start, start + pageSize) : matches; + // `px-2`, not the `px-1` this started at: shrink-to-content columns sit at + // exactly twice the padding from their neighbours, and at 2px a panel holding + // half a dozen of them read as one run-on row. Still tighter than the + // page-level table's `px-3`, which is the density a panel is for. const cell = (c: Column) => - `px-1 ${widthClass(c, true)} ${c.align === "right" ? "text-right" : ""} ${c.className ?? ""}`; + `px-2 ${widthClass(c, true)} ${c.align === "right" ? "text-right" : ""} ${c.className ?? ""}`; return (
@@ -217,7 +221,7 @@ export function MiniTable({ {matches.length === 0 ? (

No {search?.noun ?? "rows"} match “{query.trim()}”.

) : ( -
+
diff --git a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx index 2924ca7d..0fee0cca 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/api-gateways/ApiGatewayPage.tsx @@ -219,6 +219,12 @@ export function ApiGatewayPage() { columns={[ { header: "Partner", + // Both name columns wrap rather than shrink to fit. Every column here but + // "Last error" was shrink-to-content, so all the slack in a full-width panel + // pooled in that one column and left the other six bunched against each other + // down the left-hand side — which is what read as cramped, more than the + // padding did. `wrap` is what the width strategy says a name should use anyway. + wrap: true, cell: (a) => ( ( (null); const [deleting, setDeleting] = useState(false); const [confirmingPause, setConfirmingPause] = useState(false); + // The state being asked for, not a flag: the dialog then confirms and writes the + // action the operator actually opened it for, rather than re-deriving it from a + // `s.enabled` that a refetch could have flipped underneath the open dialog. + const [enabling, setEnabling] = useState(null); const [confirmingReceive, setConfirmingReceive] = useState(false); const [confirmingAggregate, setConfirmingAggregate] = useState(false); const [loaded, setLoaded] = useState(false); @@ -133,6 +142,19 @@ export function SubscriptionPage() { onSuccess: invalidate, }); + // Its own one-field write rather than a draft edit ridden along on the save + // bar: this is the switch that drops work on the floor, so it applies when + // asked for and not at some later save of unrelated configuration. The draft + // is patched to match so an operator's half-finished edits below survive it — + // and so the value doesn't immediately read back as unsaved. + const setEnabled = useMutation({ + mutationFn: (enabled: boolean) => api.updateSubscription(subscriptionId, { enabled }), + onSuccess: async (updated) => { + await invalidate(); + setDraft((d) => (d ? { ...d, enabled: updated.enabled } : d)); + }, + }); + const aggregate = useMutation({ mutationFn: () => api.aggregateNow(subscriptionId), onSuccess: async () => { @@ -402,20 +424,12 @@ export function SubscriptionPage() { placeholder="Subscription name" /> - {draft && ( - - )} - {paused && Paused} + {/* Status is reported here and changed from the buttons on the right. + It used to be a clickable chip, indistinguishable from the Paused + badge beside it, that only edited the draft — so the lossy switch + saved later and quietly, while Pause, which keeps the work, asked + first and applied at once. */} +

Carries{" "} @@ -473,6 +487,22 @@ export function SubscriptionPage() { {paused ? "Resume" : "Pause"} )} + {/* Beside Pause, because the two questions an operator actually has + are the same shape — stop it, or hold it — and the answer differs + only in whether the work survives. */} + {canEdit && ( + + )}