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
8 changes: 4 additions & 4 deletions SW.Bitween.Web/ClientApp/e2e/subscriptions.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");

Expand All @@ -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");

Expand Down Expand Up @@ -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
Expand Down
22 changes: 20 additions & 2 deletions SW.Bitween.Web/ClientApp/src/api/http/adapters.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,25 @@ const KIND_PREFIX: Record<AdapterKind, string> = {
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<string, RawStartupValue> | null): AdapterProp[] {
return Object.entries(values ?? {}).map(([key, v]) => ({
key,
Expand All @@ -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),
Expand Down
37 changes: 10 additions & 27 deletions SW.Bitween.Web/ClientApp/src/components/config/AdapterConfig.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,13 +34,6 @@ const KIND_LABELS: Record<AdapterKind, string> = {
handler: "Handler",
};

export function useAdapterCatalog(kind: AdapterKind) {
return useQuery({
queryKey: keys.adapters(kind),
queryFn: () => api.listAdapters(kind),
});
}

interface ReferenceToken {
label: string;
token: string;
Expand Down Expand Up @@ -488,36 +485,22 @@ export function AdapterConfig({
{KIND_LABELS[kind]}
</span>
<div className="w-full max-w-sm">
<SearchSelect
aria-label={`${kind} adapter`}
<AdapterPicker
kind={kind}
value={adapterId ?? ""}
catalog={catalog.data ?? []}
loading={catalog.isPending}
disabled={disabled || catalog.isPending}
onChange={pick}
placeholder={`Pick a ${kind}…`}
clearLabel={required ? undefined : noneLabel}
options={[
...(catalog.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",
})),
// 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" }]
: []),
]}
/>
</div>
{adapter && (
<span className="text-[12px] text-ink-400">
{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"}`}
Expand Down
141 changes: 141 additions & 0 deletions SW.Bitween.Web/ClientApp/src/components/config/AdapterPicker.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="flex items-center gap-1.5">
<button
type="button"
disabled={disabled}
onClick={() => {
setBrowsingCustom(false);
// Leaving the custom list keeps nothing selected from it: the built-in
// list is about to be shown with a custom adapter still chosen, which
// is the one state that reads as a lie.
if (value !== "" && !known?.native) onChange("");
}}
className="shrink-0 rounded-md px-1.5 py-1 text-[12px] font-medium text-ink-500 hover:bg-ink-100 hover:text-ink-800 disabled:opacity-50"
>
<ChevronLeft className="-ml-0.5 inline size-3.5" aria-hidden /> Built-in
</button>
<div className="min-w-0 flex-1">
<SearchSelect
id={id}
aria-label={`Custom ${label ?? `${kind} adapter`}`}
value={value}
disabled={disabled}
onChange={onChange}
placeholder={custom.length === 0 ? "No custom adapters deployed" : "Pick a custom adapter…"}
options={[...custom.map(toOption), ...unlisted]}
/>
</div>
</div>
);
}

return (
<SearchSelect
id={id}
aria-label={label ?? `${kind} adapter`}
value={value}
disabled={disabled}
onChange={(v) => {
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",
},
]}
/>
);
}
16 changes: 6 additions & 10 deletions SW.Bitween.Web/ClientApp/src/pages/notifiers/NotifierPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -219,19 +219,15 @@ export function NotifierPage() {
<div className="space-y-4">
<div className="max-w-sm">
<Field label="Deliver via" htmlFor="nf-channel">
<SearchSelect
<AdapterPicker
id="nf-channel"
aria-label="Deliver via"
kind="handler"
label="Deliver via"
value={draft.channelId}
catalog={channels.data ?? []}
loading={channels.isPending}
disabled={!canEdit || channels.isPending}
onChange={(v) => 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",
}))}
/>
</Field>
</div>
Expand Down
Loading