diff --git a/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts b/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts index afac38a8..44b331da 100644 --- a/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts +++ b/SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts @@ -25,7 +25,7 @@ test("scheduled job create, adapters, pause/resume, receive now, list, delete", // Source — open by default. Receiver adapter plus its one required prop. await pickOption(page, "receiver adapter", "NativeHttpReceiver"); - await expect(page.getByRole("combobox", { name: "receiver adapter" })).toHaveValue("NativeHttpReceiver"); + await expect(page.getByRole("combobox", { name: "receiver adapter" })).toHaveValue("HttpReceiver"); await page.locator("#prop-Url").fill("https://example.com/feed"); await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/feed"); @@ -37,7 +37,7 @@ test("scheduled job create, adapters, pause/resume, receive now, list, delete", // Only one stage is open at a time, so #prop-Url is unambiguous here. await page.getByRole("button", { name: /^Delivery/ }).click(); await pickOption(page, "handler adapter", "NativeHttpHandler"); - await expect(page.getByRole("combobox", { name: "handler adapter" })).toHaveValue("NativeHttpHandler"); + await expect(page.getByRole("combobox", { name: "handler adapter" })).toHaveValue("HttpHandler"); await page.locator("#prop-Url").fill("https://example.com/sink"); await expect(page.locator("#prop-Url")).toHaveValue("https://example.com/sink"); @@ -69,10 +69,10 @@ test("scheduled job create, adapters, pause/resume, receive now, list, delete", // summarises what it saved, so both the adapter and its property show without opening it. await page.reload(); const source = page.getByRole("button", { name: /^Source/ }); - await expect(source).toContainText("NativeHttpReceiver"); + await expect(source).toContainText("HttpReceiver"); await expect(source).toContainText("https://example.com/feed"); const delivery = page.getByRole("button", { name: /^Delivery/ }); - await expect(delivery).toContainText("NativeHttpHandler"); + await expect(delivery).toContainText("HttpHandler"); await expect(delivery).toContainText("https://example.com/sink"); // Narrow by type — the supported filter — so the row can't be paged out of sight. Deliberately diff --git a/SW.Bitween.Web/ClientApp/src/api/http/adapters.ts b/SW.Bitween.Web/ClientApp/src/api/http/adapters.ts index 867e7e16..9d8259c1 100644 --- a/SW.Bitween.Web/ClientApp/src/api/http/adapters.ts +++ b/SW.Bitween.Web/ClientApp/src/api/http/adapters.ts @@ -23,6 +23,25 @@ const KIND_PREFIX: Record = { validator: "validators", }; +/** + * What to call an adapter on screen. + * + * An adapter's id is its C# class name, so every in-process one is spelled + * `NativeSomething` — and with no friendly name coming from the backend, that + * prefix was what people read in every picker. It says nothing a user acts on: + * where an adapter runs is the *other* list's business now, not part of its name. + * Stripped for display only — `id` is untouched, and stays what a subscription + * stores, what the row is searchable by, and what is shown when the catalog + * doesn't know an adapter. + */ +function displayName(key: string): string { + // "NativeMapper" would strip to a bare "Mapper", which names the kind rather + // than the adapter and reads as the generic one next to "JSONMapper". + if (key === "NativeMapper") return "Visual mapper"; + const stripped = key.replace(/^Native/, ""); + return stripped === "" ? key : stripped; +} + function toProps(values: Record | null): AdapterProp[] { return Object.entries(values ?? {}).map(([key, v]) => ({ key, @@ -43,8 +62,7 @@ export const adapterMethods = { return (rows ?? []).map((r) => ({ id: r.key, kind, - // No backend source for a friendly display name — fall back to the raw id. - label: r.key, + label: displayName(r.key), native: r.native, versions: r.versions ?? [], props: toProps(r.startupValues), diff --git a/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx b/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx index d39e0f1a..058f9994 100644 --- a/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx +++ b/SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx @@ -5,10 +5,14 @@ import { ArrowUpRight, Braces, ChevronDown, ChevronRight, Search } from "lucide- import { api, type AdapterInfo, type AdapterKind, type PartnerRow } from "../../api"; import { Button } from "../ui/basics"; import { Field } from "../ui/forms"; -import { SearchSelect } from "../ui/SearchSelect"; +import { AdapterPicker, useAdapterCatalog } from "./AdapterPicker"; import { keys } from "../../api/queryKeys"; import { NATIVE_MAPPER_ID } from "../../lib/nativeMapper/types"; +// Lives with the picker, re-exported here because this is where every screen +// already imports it from. +export { useAdapterCatalog }; + /** * Mappers whose mapping is built in the visual editor rather than typed into * adapter properties. Both are listed while the old mapper is still in use by @@ -30,13 +34,6 @@ const KIND_LABELS: Record = { handler: "Handler", }; -export function useAdapterCatalog(kind: AdapterKind) { - return useQuery({ - queryKey: keys.adapters(kind), - queryFn: () => api.listAdapters(kind), - }); -} - interface ReferenceToken { label: string; token: string; @@ -488,28 +485,14 @@ export function AdapterConfig({ {KIND_LABELS[kind]}
- ({ - value: a.id, - label: a.label, - code: a.id, - hint: a.native ? "Native" : a.versions.length > 0 ? `v${a.versions.at(-1)}` : "Custom", - })), - // What is configured, when the catalog does not list it — an adapter that has been - // unpublished, or one that no longer declares this kind. Without it the select - // reads as empty on a subscription that is in fact wired up, and the only way to - // save the page is to pick something else, silently replacing a working adapter. - ...(adapterId && !catalog.isPending && !catalog.data?.some((a) => a.id === adapterId) - ? [{ value: adapterId, label: adapterId, code: adapterId, hint: "Not in catalog" }] - : []), - ]} />
{adapter && ( @@ -517,7 +500,7 @@ export function AdapterConfig({ {adapter.native ? "Runs in-process" : adapter.versions.length > 0 - ? `v${adapter.versions.at(-1)}` + ? `Custom · v${adapter.versions.at(-1)}` : "Custom package"} {adapter.props.length > 0 && ` · ${adapter.props.length} setting${adapter.props.length === 1 ? "" : "s"}`} diff --git a/SW.Bitween.Web/ClientApp/src/components/config/AdapterPicker.tsx b/SW.Bitween.Web/ClientApp/src/components/config/AdapterPicker.tsx new file mode 100644 index 00000000..55040509 --- /dev/null +++ b/SW.Bitween.Web/ClientApp/src/components/config/AdapterPicker.tsx @@ -0,0 +1,141 @@ +import { useState } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { ChevronLeft } from "lucide-react"; +import { api, type AdapterInfo, type AdapterKind } from "../../api"; +import { SearchSelect } from "../ui/SearchSelect"; +import { keys } from "../../api/queryKeys"; + +export function useAdapterCatalog(kind: AdapterKind) { + return useQuery({ + queryKey: keys.adapters(kind), + queryFn: () => api.listAdapters(kind), + }); +} + +/** The sentinel option that opens the custom list. Not an adapter id. */ +const CUSTOM = "__custom__"; + +/** + * Picks one adapter, in two steps: the adapters that ship with Bitween, and — + * behind one row — the custom ones deployed to this instance. + * + * The two are never listed together. Mixed, the only thing separating a + * supported adapter from something a client uploaded was a word at the end of + * the row, and the list grew with every deployment, so the common choice got + * harder to find the more an instance was used. + */ +export function AdapterPicker({ + kind, + value, + catalog, + loading = false, + onChange, + disabled = false, + clearLabel, + id, + label, +}: { + kind: AdapterKind; + /** "" = nothing selected. */ + value: string; + catalog: AdapterInfo[]; + /** + * True while the catalog is still arriving. Without it an empty catalog is + * indistinguishable from one that simply doesn't list the saved adapter, and + * every configured slot would flash the custom list on the way in. + */ + loading?: boolean; + onChange: (adapterId: string) => void; + disabled?: boolean; + /** When set, an empty choice with this label is offered (e.g. "None"). */ + clearLabel?: string; + id?: string; + /** + * What this field is called, where a visible label already names it something + * of its own ("Deliver via"). Defaults to the kind; pass the visible wording so + * the accessible name doesn't contradict what is on screen. + */ + label?: string; +}) { + const builtIn = catalog.filter((a) => a.native); + const custom = catalog.filter((a) => !a.native); + const known = catalog.find((a) => a.id === value); + + /* + Which list is open follows what is *selected*, so opening a subscription that + runs a custom adapter shows it straight away rather than an empty built-in + list. An adapter the catalog no longer lists — unpublished, or no longer + declaring this kind — counts as custom: it is certainly not one of ours, and + it has to stay visible, or the page reads as unconfigured when it is not. + State only carries the case the value can't: "custom, nothing picked yet". + */ + const [browsingCustom, setBrowsingCustom] = useState(false); + const showCustom = !loading && (browsingCustom || (value !== "" && !known?.native)); + + const toOption = (a: AdapterInfo) => ({ + value: a.id, + label: a.label, + // The real id: what a subscription stores, and what someone who knows an + // adapter by its class name will type to find it. + code: a.id, + hint: a.versions.length > 0 ? `v${a.versions.at(-1)}` : undefined, + }); + + const unlisted = + value !== "" && !known ? [{ value, label: value, code: value, hint: "Not in catalog" }] : []; + + if (showCustom) { + return ( +
+ +
+ +
+
+ ); + } + + return ( + { + if (v === CUSTOM) return setBrowsingCustom(true); + onChange(v); + }} + placeholder={`Pick a ${kind}…`} + clearLabel={clearLabel} + options={[ + ...builtIn.map(toOption), + { + value: CUSTOM, + label: "Custom adapter…", + hint: custom.length > 0 ? `${custom.length}` : "none deployed", + }, + ]} + /> + ); +} diff --git a/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx b/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx index 4eb474e5..8a416600 100644 --- a/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx +++ b/SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx @@ -10,7 +10,7 @@ import { Checkbox, Field, TextInput } from "../../components/ui/forms"; import { EditableTitle, Panel, UnsavedBar } from "../../components/ui/Panel"; import { ConfirmDialog } from "../../components/ui/overlays"; import { MiniTable } from "../../components/ui/Table"; -import { SearchSelect } from "../../components/ui/SearchSelect"; +import { AdapterPicker } from "../../components/config/AdapterPicker"; import { useAdapterCatalog } from "../../components/config/AdapterConfig"; import { useSubscriptionsCache } from "../../components/config/shared"; import { timeAgo } from "../../lib/dates"; @@ -219,19 +219,15 @@ export function NotifierPage() {
- set("channelId", v)} - placeholder="Pick a handler…" - options={(channels.data ?? []).map((a) => ({ - value: a.id, - label: a.label, - code: a.id, - hint: a.native ? "Native" : a.versions.length > 0 ? `v${a.versions.at(-1)}` : "Custom", - }))} />