From 610e2eee78b31a64a58a7960394d30346309251d Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Wed, 12 Aug 2026 18:23:41 -0700 Subject: [PATCH] fix(ui): unify channel pickers with icons Preserve channel values and mutation ownership across Automations, pairing, setup, and Workboard while sharing accessible picker rendering and artwork. --- ui/src/components/channel-icon.ts | 30 +++++++ ui/src/components/channel-picker.test.ts | 64 ++++++++++++++ ui/src/components/channel-picker.ts | 17 ++++ ui/src/components/select-picker.ts | 71 +++++++++++++++ ui/src/components/wizard-step-controls.ts | 71 ++++++++------- .../e2e/channels-whatsapp-logout.e2e.test.ts | 12 ++- ui/src/e2e/cron-select-values.e2e.test.ts | 24 ++++-- ui/src/pages/channels/hub-meta.ts | 26 ------ ui/src/pages/channels/view.detail.ts | 5 +- ui/src/pages/channels/view.pairing.test.ts | 39 ++++++++- ui/src/pages/channels/view.pairing.ts | 50 +++++------ ui/src/pages/channels/view.ts | 6 +- .../pages/channels/wizard-view.busy.test.ts | 12 +-- ui/src/pages/channels/wizard-view.ts | 8 +- ui/src/pages/cron/view.test.ts | 83 ++++++++++++++---- ui/src/pages/cron/view.ts | 67 +++++++-------- ui/src/pages/workboard/view.test.ts | 20 +++-- ui/src/pages/workboard/workboard-select.ts | 86 +++++-------------- ui/src/styles/channels.css | 6 +- ui/src/styles/settings.css | 33 +++++++ ui/src/styles/workboard.css | 33 ------- 21 files changed, 482 insertions(+), 281 deletions(-) create mode 100644 ui/src/components/channel-icon.ts create mode 100644 ui/src/components/channel-picker.test.ts create mode 100644 ui/src/components/channel-picker.ts create mode 100644 ui/src/components/select-picker.ts diff --git a/ui/src/components/channel-icon.ts b/ui/src/components/channel-icon.ts new file mode 100644 index 000000000000..e0b87a0464b2 --- /dev/null +++ b/ui/src/components/channel-icon.ts @@ -0,0 +1,30 @@ +import { html } from "lit"; +import { + pluginArtPath, + pluginFallbackGradient, + pluginMonogram, +} from "../pages/plugins/presentation.ts"; +import "../styles/channels.css"; + +/** Bundled channel art reuses the plugin art set because channel ids match plugin slugs. */ +export function renderChannelIcon( + channelId: string, + label: string, + variant: "tile" | "cover" | "picker", +) { + const artVariant = variant === "picker" ? "tile" : variant; + const art = pluginArtPath(channelId); + const [from, to] = art ? ["", ""] : pluginFallbackGradient(channelId); + const style = `${variant === "picker" ? "--channels-art-size:24px;" : ""}${ + art ? "" : `--channels-art-a:${from};--channels-art-b:${to}` + }`; + return html``; +} diff --git a/ui/src/components/channel-picker.test.ts b/ui/src/components/channel-picker.test.ts new file mode 100644 index 000000000000..55dd805d6e9f --- /dev/null +++ b/ui/src/components/channel-picker.test.ts @@ -0,0 +1,64 @@ +/* @vitest-environment jsdom */ +import { render } from "lit"; +import { describe, expect, it, vi } from "vitest"; +import { renderChannelPicker } from "./channel-picker.ts"; + +describe("renderChannelPicker", () => { + it("renders neutral and channel artwork while preserving a missing current channel", () => { + const container = document.createElement("div"); + render( + renderChannelPicker({ + label: "Channel", + value: "retired-channel", + options: [ + { value: "last", label: "last", kind: "neutral" }, + { value: "telegram", label: "Telegram" }, + ], + onChange: vi.fn(), + }), + container, + ); + + expect(container.querySelector('wa-option[value="last"] [slot="start"]')).toBeNull(); + expect(container.querySelector('wa-option[value="telegram"] img')).not.toBeNull(); + expect(container.querySelector('wa-option[value="retired-channel"]')?.textContent).toContain( + "retired-channel", + ); + expect( + container.querySelector('wa-option[value="retired-channel"] .channels-tile--fallback'), + ).not.toBeNull(); + }); + + it("honors disabled choices and reports enabled changes", () => { + const container = document.createElement("div"); + const onChange = vi.fn(); + render( + renderChannelPicker({ + label: "Channel", + value: "telegram", + options: [ + { value: "telegram", label: "Telegram" }, + { value: "disabled", label: "Disabled", disabled: true }, + ], + onChange, + }), + container, + ); + + const picker = container.querySelector("wa-select"); + expect(container.querySelector('wa-option[value="disabled"]')?.hasAttribute("disabled")).toBe( + true, + ); + if (!picker) { + return; + } + Object.defineProperty(picker, "value", { configurable: true, value: "disabled" }); + picker.dispatchEvent(new Event("change", { bubbles: true })); + Reflect.deleteProperty(picker, "value"); + expect(onChange).not.toHaveBeenCalled(); + Object.defineProperty(picker, "value", { configurable: true, value: "telegram" }); + picker.dispatchEvent(new Event("change", { bubbles: true })); + Reflect.deleteProperty(picker, "value"); + expect(onChange).toHaveBeenCalledWith("telegram"); + }); +}); diff --git a/ui/src/components/channel-picker.ts b/ui/src/components/channel-picker.ts new file mode 100644 index 000000000000..21942b21c9f6 --- /dev/null +++ b/ui/src/components/channel-picker.ts @@ -0,0 +1,17 @@ +import { nothing } from "lit"; +import { renderChannelIcon } from "./channel-icon.ts"; +import { renderPicker, type PickerOption, type PickerParams } from "./select-picker.ts"; + +export type ChannelPickerOption = PickerOption & { + /** Neutral choices such as "last" or "all" are routing policy, not transports. */ + kind?: "channel" | "neutral"; +}; + +export function renderChannelPicker(params: PickerParams) { + return renderPicker({ + ...params, + className: "channel-picker", + renderLeading: (option) => + option.kind === "neutral" ? nothing : renderChannelIcon(option.value, option.label, "picker"), + }); +} diff --git a/ui/src/components/select-picker.ts b/ui/src/components/select-picker.ts new file mode 100644 index 000000000000..ee5e666bd59c --- /dev/null +++ b/ui/src/components/select-picker.ts @@ -0,0 +1,71 @@ +import { html, nothing } from "lit"; +import "./web-awesome-select.ts"; + +export type PickerOption = { + value: string; + label: string; + description?: string; + disabled?: boolean; +}; + +export type PickerParams