diff --git a/ui/src/components/wizard-step-controls.ts b/ui/src/components/wizard-step-controls.ts index 10c2f56e639a..a5412b6cb9aa 100644 --- a/ui/src/components/wizard-step-controls.ts +++ b/ui/src/components/wizard-step-controls.ts @@ -17,13 +17,31 @@ type WizardStepControlsProps = { inputId: string; onValueChange: (value: unknown) => void; onAnswer: (value: unknown, includeValue?: boolean) => void; + presentation?: "channels"; + answerLabel?: string; }; -function renderMessage(step: WizardStep) { - return step.message ? html`
${step.message}
` : nothing; +function stepClass(props: WizardStepControlsProps, name: string): string { + return `${props.presentation === "channels" ? "channels-wizard" : "wizard-step"}__${name}`; } -function renderOptionBody(option: WizardStepOption) { +function renderMessage(props: WizardStepControlsProps) { + return props.step.message + ? html`
${props.step.message}
` + : nothing; +} + +function renderOptionBody(option: WizardStepOption, presentation?: "channels", selected?: boolean) { + if (presentation === "channels") { + return html` + + ${selected === undefined ? nothing : selected ? "☑ " : "☐ "}${option.label} + + ${option.hint + ? html`${option.hint}` + : nothing} + `; + } return html` ${option.label} @@ -59,32 +77,95 @@ function renderDeviceCode(step: WizardStep) { `; } +function renderAnswerButton( + props: WizardStepControlsProps, + label: string, + onClick?: () => void, + disabled = props.busy, +) { + const button = html` + + `; + return props.presentation === "channels" + ? html`` + : button; +} + +function renderOption( + props: WizardStepControlsProps, + option: WizardStepOption, + index: number, + selected: unknown[], +) { + const checked = selected.some((value) => Object.is(value, option.value)); + if (props.presentation === "channels") { + return props.step.type === "select" + ? html` + ${renderOptionBody(option, props.presentation)} + ` + : html``; + } + return html``; +} + function renderContinueStep(props: WizardStepControlsProps) { const step = props.step; return html` - ${renderMessage(step)} + ${renderMessage(props)} ${step.externalUrl ? html` ${t("modelSetup.wizard.openSignIn")} ` : nothing} ${renderDeviceCode(step)} - + ${renderAnswerButton(props, t("modelSetup.wizard.continue"), () => + props.onAnswer(undefined, false), + )} `; } -function renderProgressStep(step: WizardStep) { +function renderProgressStep(props: WizardStepControlsProps) { return html`
- ${renderMessage(step)} + ${renderMessage(props)}
`; } @@ -97,11 +178,14 @@ function renderTextStep(props: WizardStepControlsProps) { class="wizard-step__form" @submit=${(event: Event) => { event.preventDefault(); - props.onAnswer(value); + const input = (event.currentTarget as HTMLFormElement).elements.namedItem( + "wizard-text", + ) as HTMLInputElement | null; + props.onAnswer(props.presentation === "channels" ? (input?.value ?? "") : value); }} > ${step.message - ? html`
+ ? html`
` : nothing} @@ -115,101 +199,74 @@ function renderTextStep(props: WizardStepControlsProps) { .value=${value} ?disabled=${props.busy} @input=${(event: Event) => + props.presentation !== "channels" && props.onValueChange((event.currentTarget as HTMLInputElement).value)} /> - + ${renderAnswerButton(props, t("modelSetup.wizard.submit"))} `; } -function renderSelectStep(props: WizardStepControlsProps) { +function renderOptionsStep(props: WizardStepControlsProps) { + const options = props.step.options ?? []; + const multiple = props.step.type === "multiselect"; + const selected = multiple ? (Array.isArray(props.value) ? props.value : []) : [props.value]; + if (props.presentation === "channels" && !multiple) { + const selectedIndex = options.findIndex((option) => Object.is(option.value, props.value)); + return html` + = 0 ? String(selectedIndex) : null} + ?disabled=${props.busy} + @change=${(event: Event) => { + const index = (event.currentTarget as HTMLElement & { value?: string | number | null }) + .value; + const option = options[Number(index)]; + if (option) { + props.onAnswer(option.value); + } + }} + > + ${options.map((option, index) => renderOption(props, option, index, selected))} + + `; + } + const answer = multiple + ? props.presentation === "channels" + ? [...selected] + : selected + : props.value; return html` - ${renderMessage(props.step)} -
- ${(props.step.options ?? []).map( - (option) => html` - - `, - )} + ${renderMessage(props)} +
+ ${options.map((option, index) => renderOption(props, option, index, selected))}
- + ${renderAnswerButton( + props, + t("modelSetup.wizard.continue"), + () => props.onAnswer(answer), + props.busy || (!multiple && props.value === undefined), + )} `; } function renderConfirmStep(props: WizardStepControlsProps) { return html` - ${renderMessage(props.step)} -
- - -
- `; -} - -function renderMultiselectStep(props: WizardStepControlsProps) { - const selected = Array.isArray(props.value) ? props.value : []; - return html` - ${renderMessage(props.step)} -
- ${(props.step.options ?? []).map( - (option) => html` - - `, + ${renderMessage(props)} +
+ ${[false, true].map( + (answer) => html``, )}
- `; } @@ -225,14 +282,13 @@ export function renderWizardStepControls( case "text": return renderTextStep(props); case "select": - return renderSelectStep(props); + case "multiselect": + return renderOptionsStep(props); case "confirm": return renderConfirmStep(props); - case "multiselect": - return renderMultiselectStep(props); case "progress": return props.step.executor === "gateway" - ? renderProgressStep(props.step) + ? renderProgressStep(props) : renderContinueStep(props); // These show whatever the step supplies behind a single Continue. case "note": diff --git a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts index b8a8572221db..086bf1e2fe2c 100644 --- a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts +++ b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts @@ -106,4 +106,146 @@ describeControlUiE2e("Control UI WhatsApp logout mocked Gateway E2E", () => { await context.close(); } }); + + it("preserves standard channel details and the complete Telegram setup wizard", async () => { + const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const channelEntries = [ + ["discord", "Discord"], + ["googlechat", "Google Chat"], + ["imessage", "iMessage"], + ["signal", "Signal"], + ["slack", "Slack"], + ["telegram", "Telegram"], + ] as const; + const running = { configured: true, running: true }; + const details: Record> = { + googlechat: { + credentialSource: "service-account", + audienceType: "url", + audience: "https://chat.example.test", + }, + signal: { baseUrl: "https://signal.example.test" }, + }; + const bot = (accountId: string, username: string) => ({ + accountId, + ...running, + probe: { bot: { username } }, + }); + const step = (id: string, type: string, values: Record = {}) => ({ + done: false, + status: "running", + step: { id, type, ...values }, + }); + const gateway = await installMockGateway(page, { + featureMethods: ["channels.status", "channels.pairing.list", "wizard.start", "wizard.next"], + methodResponses: { + "channels.status": { + ts: Date.now(), + channelOrder: channelEntries.map(([id]) => id), + channelLabels: Object.fromEntries(channelEntries), + channelMeta: channelEntries.map(([id, label]) => ({ id, label })), + channels: Object.fromEntries( + channelEntries.map(([id]) => [id, { ...running, ...details[id] }]), + ), + channelAccounts: { telegram: [bot("personal", "alpha_bot"), bot("work", "work_bot")] }, + channelDefaultAccountId: { telegram: "personal" }, + }, + "channels.pairing.list": { + accounts: [], + requests: [], + commandOwnerConfigured: true, + limits: { pendingPerAccount: 3, ttlMs: 3_600_000 }, + }, + "wizard.start": { + sessionId: "channel-standard-proof", + ...step("account", "select", { + message: "Choose Telegram account", + initialValue: "personal", + options: ["personal", "work"].map((value) => ({ + value, + label: value === "work" ? "Work bot" : "Personal bot", + })), + }), + }, + "wizard.next": { + sequence: [ + step("token", "text", { message: "Telegram bot token", sensitive: true }), + step("features", "multiselect", { + initialValue: ["alpha"], + options: ["alpha", "beta"].map((value) => ({ + value, + label: value === "alpha" ? "Alpha" : "Beta", + })), + }), + step("confirm", "confirm", { message: "Apply Telegram settings?" }), + step("progress", "progress", { executor: "gateway", message: "Finish preparation" }), + { done: true, status: "done", channels: ["telegram"], accounts: [] }, + ], + }, + }, + }); + + try { + await page.goto(`${server.baseUrl}settings/channels`); + const expectedFields: Record = { + googlechat: ["service-account", "url · https://chat.example.test"], + signal: ["https://signal.example.test"], + telegram: ["@alpha_bot", "@work_bot", "2"], + }; + for (const [channelId, label] of channelEntries) { + await page.locator(".channels-item", { hasText: label }).first().click(); + const detail = page.locator(".channels-detail"); + await expect + .poll(() => detail.locator("h2.settings-section__heading").textContent()) + .toContain(label); + await detail.getByRole("button", { name: "Probe" }).waitFor(); + for (const value of expectedFields[channelId] ?? []) { + await detail.getByText(value, { exact: true }).waitFor(); + } + if (channelId !== "telegram") { + await detail.getByRole("button", { name: "Close" }).click(); + } + } + + await page.locator(".channels-detail").getByRole("button", { name: "Run setup" }).click(); + const wizard = page.locator(".channels-wizard"); + await gateway.deferNext("wizard.next"); + await wizard.getByRole("radio", { name: "Work bot" }).click(); + await expect.poll(async () => gateway.getRequests("wizard.next")).toHaveLength(1); + await expect + .poll(() => wizard.locator("wa-radio-group").getAttribute("disabled")) + .not.toBeNull(); + await gateway.resolveDeferred("wizard.next"); + + const token = wizard.getByLabel("Telegram bot token"); + await expect.poll(() => token.getAttribute("type")).toBe("password"); + await token.fill("123456:proof-secret"); + await wizard.getByRole("button", { name: "Continue" }).click(); + const beta = wizard.getByRole("button", { name: /Beta/u }); + await expect.poll(() => beta.getAttribute("aria-pressed")).toBe("false"); + await beta.click(); + await expect.poll(() => beta.getAttribute("aria-pressed")).toBe("true"); + await wizard.getByRole("button", { name: "Continue" }).click(); + await wizard.getByRole("button", { name: "Yes" }).click(); + await wizard.getByRole("button", { name: "Continue" }).click(); + await wizard.getByRole("button", { name: "Finish" }).waitFor(); + + const answers = [ + ["account", "work"], + ["token", "123456:proof-secret"], + ["features", ["alpha", "beta"]], + ["confirm", true], + ["progress", null], + ] as const; + expect((await gateway.getRequests("wizard.next")).map(({ params }) => params)).toEqual( + answers.map(([stepId, value]) => ({ + sessionId: "channel-standard-proof", + answer: { stepId, value }, + })), + ); + } finally { + await context.close(); + } + }); }); diff --git a/ui/src/lib/channels/index.ts b/ui/src/lib/channels/index.ts index ee0a5b188267..68cf7e7f94be 100644 --- a/ui/src/lib/channels/index.ts +++ b/ui/src/lib/channels/index.ts @@ -1,6 +1,7 @@ import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; import { roleScopesAllow } from "../../../../src/shared/operator-scope-compat.ts"; import type { + ChannelAccountSnapshot, ChannelsPairingApproveResult, ChannelsPairingListResult, ChannelsStatusSnapshot, @@ -81,6 +82,15 @@ export type ChannelCapability = { dispose: () => void; }; +export function resolveChannelAccounts( + channelAccounts: ChannelsStatusSnapshot["channelAccounts"] | null | undefined, + channelId: string, +): ChannelAccountSnapshot[] { + const accounts = + channelAccounts && Object.hasOwn(channelAccounts, channelId) && channelAccounts[channelId]; + return Array.isArray(accounts) ? accounts : []; +} + export function channelSnapshotEntryIsActive( snapshot: ChannelsStatusSnapshot | null, channelId: string, @@ -88,11 +98,13 @@ export function channelSnapshotEntryIsActive( if (!snapshot) { return false; } - const status = asRecord(snapshot.channels[channelId]); + const status = asRecord( + Object.hasOwn(snapshot.channels, channelId) ? snapshot.channels[channelId] : undefined, + ); if (status?.configured === true || status?.running === true || status?.connected === true) { return true; } - return (snapshot.channelAccounts[channelId] ?? []).some( + return resolveChannelAccounts(snapshot.channelAccounts, channelId).some( (account) => account.configured === true || account.running === true || account.connected === true, ); @@ -159,9 +171,9 @@ function createInitialChannelsState(snapshot: Partial = }; } -function delay(ms: number): Promise<"timeout"> { +function delay(ms: number): Promise { return new Promise((resolve) => { - setTimeout(() => resolve("timeout"), ms); + setTimeout(resolve, ms); }); } @@ -220,14 +232,9 @@ async function loadChannels( })(); const softTimeoutMs = options.softTimeoutMs; - if (typeof softTimeoutMs === "number" && softTimeoutMs > 0) { - const outcome = await Promise.race([refresh.then(() => "done" as const), delay(softTimeoutMs)]); - if (outcome === "timeout") { - return; - } - return; - } - await refresh; + await (typeof softTimeoutMs === "number" && softTimeoutMs > 0 + ? Promise.race([refresh, delay(softTimeoutMs)]) + : refresh); } function isCurrentPairingRefresh( diff --git a/ui/src/pages/channels/view.detail.ts b/ui/src/pages/channels/view.detail.ts index 5108524cf93e..84d565f87d69 100644 --- a/ui/src/pages/channels/view.detail.ts +++ b/ui/src/pages/channels/view.detail.ts @@ -1,32 +1,172 @@ // Channel detail overlay: full status + advanced schema config form for one // channel, reusing the per-channel settings-language renderers. +import { asNullableRecord, readStringField } from "@openclaw/normalization-core/record-coerce"; import { html, nothing, type TemplateResult } from "lit"; -import type { ChannelAccountSnapshot, NostrProfile } from "../../api/types.ts"; +import type { NostrProfile } from "../../api/types.ts"; import { renderSettingsSection } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; import "../../components/modal-dialog.ts"; +import { resolveChannelAccounts } from "../../lib/channels/index.ts"; +import { formatRelativeTimestamp } from "../../lib/format.ts"; import { channelDocsUrl, renderChannelArt } from "./hub-meta.ts"; import { renderChannelConfigSection } from "./view.config.ts"; -import { renderDiscordCard } from "./view.discord.ts"; -import { renderGoogleChatCard } from "./view.googlechat.ts"; -import { renderIMessageCard } from "./view.imessage.ts"; import { renderNostrCard } from "./view.nostr.ts"; import { renderChannelPairingDetail } from "./view.pairing.ts"; import { boolStatusKind, formatNullableBoolean, renderChannelAccountRow, + renderChannelActionRow, renderChannelErrorRow, renderChannelFacts, + renderChannelProbeRow, resolveChannelAccountCount, resolveChannelDisplayState, } from "./view.shared.ts"; -import { renderSignalCard } from "./view.signal.ts"; -import { renderSlackCard } from "./view.slack.ts"; -import { renderTelegramCard } from "./view.telegram.ts"; import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./view.types.ts"; import { renderWhatsAppCard } from "./view.whatsapp.ts"; +const STANDARD_CHANNEL_LOCALE_KEYS = { + discord: "discord", + googlechat: "googleChat", + imessage: "imessage", + signal: "signal", + slack: "slack", + telegram: "telegram", +} as const; + +type StandardChannelKey = keyof typeof STANDARD_CHANNEL_LOCALE_KEYS; + +function isStandardChannel(key: ChannelKey): key is StandardChannelKey { + return Object.hasOwn(STANDARD_CHANNEL_LOCALE_KEYS, key); +} + +function renderChannelStatusBody( + key: ChannelKey, + props: ChannelsProps, + data: ChannelsChannelData, + accountCount: number | undefined, +) { + const standardKey = isStandardChannel(key) ? key : null; + const localeKey = standardKey ? STANDARD_CHANNEL_LOCALE_KEYS[standardKey] : null; + const status = standardKey ? data[standardKey] : undefined; + const displayState = resolveChannelDisplayState(key, props); + const configured = displayState.configured; + const accounts = resolveChannelAccounts(data.channelAccounts, key); + const showAccounts = + standardKey === "telegram" ? accounts.length > 1 : !standardKey && accounts.length > 0; + const extraRows = + standardKey === "googlechat" + ? [ + { + label: t("common.credential"), + value: data.googlechat?.credentialSource ?? t("common.na"), + }, + { + label: t("common.audience"), + value: data.googlechat?.audienceType + ? `${data.googlechat.audienceType}${data.googlechat.audience ? ` · ${data.googlechat.audience}` : ""}` + : t("common.na"), + }, + ] + : standardKey === "signal" + ? [{ label: t("common.baseUrl"), value: data.signal?.baseUrl ?? t("common.na") }] + : standardKey === "telegram" + ? [{ label: t("common.mode"), value: data.telegram?.mode ?? t("common.na") }] + : []; + const statusRows = [ + { + label: t("common.configured"), + value: formatNullableBoolean(configured), + kind: boolStatusKind(configured), + }, + { + label: t("common.running"), + value: !standardKey + ? formatNullableBoolean(displayState.running) + : standardKey === "googlechat" && !status + ? t("common.na") + : formatNullableBoolean(status?.running ?? false), + kind: boolStatusKind(standardKey ? status?.running : displayState.running), + }, + ...(standardKey + ? [ + ...extraRows, + ...(["lastStartAt", "lastProbeAt"] as const).map((field) => ({ + label: t(field === "lastStartAt" ? "common.lastStart" : "common.lastProbe"), + value: status?.[field] ? formatRelativeTimestamp(status[field]) : t("common.na"), + })), + ] + : [ + { + label: t("common.connected"), + value: formatNullableBoolean(displayState.connected), + kind: boolStatusKind(displayState.connected), + }, + ]), + ]; + const lastError = readStringField( + asNullableRecord(standardKey ? status : displayState.status), + "lastError", + ); + + return renderSettingsSection( + { + title: localeKey + ? t(`channels.${localeKey}.title`) + : (readStringField(props.snapshot?.channelLabels, key) ?? key), + description: localeKey ? t(`channels.${localeKey}.subtitle`) : t("channels.generic.subtitle"), + ...(accountCount !== undefined ? { count: accountCount } : {}), + }, + html` + ${showAccounts + ? accounts.map((account) => { + const username = + standardKey === "telegram" + ? readStringField( + asNullableRecord(asNullableRecord(account.probe)?.bot), + "username", + ) + : undefined; + return renderChannelAccountRow({ + title: username ? `@${username}` : account.name || account.accountId, + accountId: account.accountId, + ...(standardKey === "telegram" + ? { + facts: [ + `${t("common.configured")}: ${account.configured ? t("common.yes") : t("common.no")}`, + ], + } + : {}), + status: { + kind: boolStatusKind( + standardKey === "telegram" + ? account.running + : (account.running ?? account.configured), + ), + label: account.running + ? t("common.running") + : !standardKey && account.configured + ? t("common.configured") + : t("common.no"), + }, + lastInboundAt: account.lastInboundAt, + lastError: account.lastError, + }); + }) + : renderChannelFacts(statusRows)} + ${lastError ? renderChannelErrorRow(lastError) : nothing} + ${standardKey && status?.probe ? renderChannelProbeRow(status.probe) : nothing} + ${renderChannelConfigSection({ channelId: key, props })} + ${standardKey + ? renderChannelActionRow(html``) + : nothing} + `, + ); +} + function renderChannelBody(key: ChannelKey, props: ChannelsProps, data: ChannelsChannelData) { const accountCount = resolveChannelAccountCount(key, data.channelAccounts); switch (key) { @@ -36,45 +176,8 @@ function renderChannelBody(key: ChannelKey, props: ChannelsProps, data: Channels whatsapp: data.whatsapp, accountCount, }); - case "telegram": - return renderTelegramCard({ - props, - telegram: data.telegram, - telegramAccounts: data.channelAccounts?.telegram ?? [], - accountCount, - }); - case "discord": - return renderDiscordCard({ - props, - discord: data.discord, - accountCount, - }); - case "googlechat": - return renderGoogleChatCard({ - props, - googleChat: data.googlechat, - accountCount, - }); - case "slack": - return renderSlackCard({ - props, - slack: data.slack, - accountCount, - }); - case "signal": - return renderSignalCard({ - props, - signal: data.signal, - accountCount, - }); - case "imessage": - return renderIMessageCard({ - props, - imessage: data.imessage, - accountCount, - }); case "nostr": { - const nostrAccounts = data.channelAccounts?.nostr ?? []; + const nostrAccounts = resolveChannelAccounts(data.channelAccounts, "nostr"); const primaryAccount = nostrAccounts[0]; const accountId = primaryAccount?.accountId ?? "default"; const profile = @@ -101,69 +204,10 @@ function renderChannelBody(key: ChannelKey, props: ChannelsProps, data: Channels }); } default: - return renderGenericChannelBody(key, props, data.channelAccounts ?? {}); + return renderChannelStatusBody(key, props, data, accountCount); } } -function renderGenericChannelBody( - key: ChannelKey, - props: ChannelsProps, - channelAccounts: Record, -) { - const label = props.snapshot?.channelLabels?.[key] ?? key; - const displayState = resolveChannelDisplayState(key, props); - const lastError = - typeof displayState.status?.lastError === "string" ? displayState.status.lastError : undefined; - const accounts = channelAccounts[key] ?? []; - const accountCount = resolveChannelAccountCount(key, channelAccounts); - - return renderSettingsSection( - { - title: label, - description: t("channels.generic.subtitle"), - ...(accountCount !== undefined ? { count: accountCount } : {}), - }, - html` - ${accounts.length > 0 - ? accounts.map((account) => - renderChannelAccountRow({ - title: account.name || account.accountId, - accountId: account.accountId, - status: { - kind: boolStatusKind(account.running ?? account.configured), - label: account.running - ? t("common.running") - : account.configured - ? t("common.configured") - : t("common.no"), - }, - lastInboundAt: account.lastInboundAt, - lastError: account.lastError, - }), - ) - : renderChannelFacts([ - { - label: t("common.configured"), - value: formatNullableBoolean(displayState.configured), - kind: boolStatusKind(displayState.configured), - }, - { - label: t("common.running"), - value: formatNullableBoolean(displayState.running), - kind: boolStatusKind(displayState.running), - }, - { - label: t("common.connected"), - value: formatNullableBoolean(displayState.connected), - kind: boolStatusKind(displayState.connected), - }, - ])} - ${lastError ? renderChannelErrorRow(lastError) : nothing} - ${renderChannelConfigSection({ channelId: key, props })} - `, - ); -} - export function renderChannelDetail(params: { channelId: string; label: string; diff --git a/ui/src/pages/channels/view.discord.ts b/ui/src/pages/channels/view.discord.ts deleted file mode 100644 index 78d6047bd467..000000000000 --- a/ui/src/pages/channels/view.discord.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Channels page renders Discord status. -import { html, nothing } from "lit"; -import type { DiscordStatus } from "../../api/types.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderDiscordCard(params: { - props: ChannelsProps; - discord?: DiscordStatus | null; - accountCount?: number; -}) { - const { props, discord, accountCount } = params; - const configured = resolveChannelConfigured("discord", props); - - return renderSingleAccountChannelCard({ - title: t("channels.discord.title"), - subtitle: t("channels.discord.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: discord?.running ? t("common.yes") : t("common.no"), - kind: boolStatusKind(discord?.running), - }, - { - label: t("common.lastStart"), - value: discord?.lastStartAt ? formatRelativeTimestamp(discord.lastStartAt) : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: discord?.lastProbeAt ? formatRelativeTimestamp(discord.lastProbeAt) : t("common.na"), - }, - ], - lastError: discord?.lastError, - secondaryCallout: discord?.probe ? renderChannelProbeRow(discord.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "discord", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.googlechat.ts b/ui/src/pages/channels/view.googlechat.ts deleted file mode 100644 index 2f06c0fadb8d..000000000000 --- a/ui/src/pages/channels/view.googlechat.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Channels page renders Google Chat status. -import { html, nothing } from "lit"; -import type { GoogleChatStatus } from "../../api/types.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderGoogleChatCard(params: { - props: ChannelsProps; - googleChat?: GoogleChatStatus | null; - accountCount?: number; -}) { - const { props, googleChat, accountCount } = params; - const configured = resolveChannelConfigured("googlechat", props); - - return renderSingleAccountChannelCard({ - title: t("channels.googleChat.title"), - subtitle: t("channels.googleChat.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: googleChat - ? googleChat.running - ? t("common.yes") - : t("common.no") - : t("common.na"), - kind: boolStatusKind(googleChat?.running), - }, - { label: t("common.credential"), value: googleChat?.credentialSource ?? t("common.na") }, - { - label: t("common.audience"), - value: googleChat?.audienceType - ? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}` - : t("common.na"), - }, - { - label: t("common.lastStart"), - value: googleChat?.lastStartAt - ? formatRelativeTimestamp(googleChat.lastStartAt) - : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: googleChat?.lastProbeAt - ? formatRelativeTimestamp(googleChat.lastProbeAt) - : t("common.na"), - }, - ], - lastError: googleChat?.lastError, - secondaryCallout: googleChat?.probe ? renderChannelProbeRow(googleChat.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "googlechat", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.imessage.ts b/ui/src/pages/channels/view.imessage.ts deleted file mode 100644 index ad4e05169054..000000000000 --- a/ui/src/pages/channels/view.imessage.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Channels page renders iMessage status. -import { html, nothing } from "lit"; -import type { IMessageStatus } from "../../api/types.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderIMessageCard(params: { - props: ChannelsProps; - imessage?: IMessageStatus | null; - accountCount?: number; -}) { - const { props, imessage, accountCount } = params; - const configured = resolveChannelConfigured("imessage", props); - - return renderSingleAccountChannelCard({ - title: t("channels.imessage.title"), - subtitle: t("channels.imessage.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: imessage?.running ? t("common.yes") : t("common.no"), - kind: boolStatusKind(imessage?.running), - }, - { - label: t("common.lastStart"), - value: imessage?.lastStartAt - ? formatRelativeTimestamp(imessage.lastStartAt) - : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: imessage?.lastProbeAt - ? formatRelativeTimestamp(imessage.lastProbeAt) - : t("common.na"), - }, - ], - lastError: imessage?.lastError, - secondaryCallout: imessage?.probe ? renderChannelProbeRow(imessage.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "imessage", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.shared.ts b/ui/src/pages/channels/view.shared.ts index 54c3342980a8..40fc8bfd8a41 100644 --- a/ui/src/pages/channels/view.shared.ts +++ b/ui/src/pages/channels/view.shared.ts @@ -1,9 +1,10 @@ // Channels page shared view helpers. +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { html, nothing } from "lit"; import type { ChannelAccountSnapshot } from "../../api/types.ts"; import { renderSettingsSection, renderSettingsStatus } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; -import { channelSnapshotEntryIsActive } from "../../lib/channels/index.ts"; +import { channelSnapshotEntryIsActive, resolveChannelAccounts } from "../../lib/channels/index.ts"; import { formatRelativeTimestamp } from "../../lib/format.ts"; import type { ChannelKey, ChannelsProps } from "./view.types.ts"; @@ -28,16 +29,20 @@ function resolveChannelStatus( key: ChannelKey, props: ChannelsProps, ): Record | undefined { - const channels = props.snapshot?.channels as Record | null; - return channels?.[key] as Record | undefined; + const channels = props.snapshot?.channels; + return channels && Object.hasOwn(channels, key) + ? (asNullableRecord(channels[key]) ?? undefined) + : undefined; } function resolveDefaultChannelAccount( key: ChannelKey, props: ChannelsProps, ): ChannelAccountSnapshot | null { - const accounts = props.snapshot?.channelAccounts?.[key] ?? []; - const defaultAccountId = props.snapshot?.channelDefaultAccountId?.[key]; + const accounts = resolveChannelAccounts(props.snapshot?.channelAccounts, key); + const defaultAccountIds = props.snapshot?.channelDefaultAccountId; + const defaultAccountId = + defaultAccountIds && Object.hasOwn(defaultAccountIds, key) ? defaultAccountIds[key] : undefined; return ( (defaultAccountId ? accounts.find((account) => account.accountId === defaultAccountId) @@ -219,18 +224,11 @@ export function renderSingleAccountChannelCard(params: { ); } -function getChannelAccountCount( - key: ChannelKey, - channelAccounts?: Record | null, -): number { - return channelAccounts?.[key]?.length ?? 0; -} - /** Multi-account channels surface the account count next to the heading. */ export function resolveChannelAccountCount( key: ChannelKey, channelAccounts?: Record | null, ): number | undefined { - const count = getChannelAccountCount(key, channelAccounts); + const count = resolveChannelAccounts(channelAccounts, key).length; return count >= 2 ? count : undefined; } diff --git a/ui/src/pages/channels/view.signal.ts b/ui/src/pages/channels/view.signal.ts deleted file mode 100644 index e94b32de2b5f..000000000000 --- a/ui/src/pages/channels/view.signal.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Channels page renders Signal status. -import { html, nothing } from "lit"; -import type { SignalStatus } from "../../api/types.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderSignalCard(params: { - props: ChannelsProps; - signal?: SignalStatus | null; - accountCount?: number; -}) { - const { props, signal, accountCount } = params; - const configured = resolveChannelConfigured("signal", props); - - return renderSingleAccountChannelCard({ - title: t("channels.signal.title"), - subtitle: t("channels.signal.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: signal?.running ? t("common.yes") : t("common.no"), - kind: boolStatusKind(signal?.running), - }, - { label: t("common.baseUrl"), value: signal?.baseUrl ?? t("common.na") }, - { - label: t("common.lastStart"), - value: signal?.lastStartAt ? formatRelativeTimestamp(signal.lastStartAt) : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: signal?.lastProbeAt ? formatRelativeTimestamp(signal.lastProbeAt) : t("common.na"), - }, - ], - lastError: signal?.lastError, - secondaryCallout: signal?.probe ? renderChannelProbeRow(signal.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "signal", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.slack.ts b/ui/src/pages/channels/view.slack.ts deleted file mode 100644 index 4c9457af36ac..000000000000 --- a/ui/src/pages/channels/view.slack.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Channels page renders Slack status. -import { html, nothing } from "lit"; -import type { SlackStatus } from "../../api/types.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderSlackCard(params: { - props: ChannelsProps; - slack?: SlackStatus | null; - accountCount?: number; -}) { - const { props, slack, accountCount } = params; - const configured = resolveChannelConfigured("slack", props); - - return renderSingleAccountChannelCard({ - title: t("channels.slack.title"), - subtitle: t("channels.slack.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: slack?.running ? t("common.yes") : t("common.no"), - kind: boolStatusKind(slack?.running), - }, - { - label: t("common.lastStart"), - value: slack?.lastStartAt ? formatRelativeTimestamp(slack.lastStartAt) : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: slack?.lastProbeAt ? formatRelativeTimestamp(slack.lastProbeAt) : t("common.na"), - }, - ], - lastError: slack?.lastError, - secondaryCallout: slack?.probe ? renderChannelProbeRow(slack.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "slack", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.telegram.ts b/ui/src/pages/channels/view.telegram.ts deleted file mode 100644 index 2a32597e39c3..000000000000 --- a/ui/src/pages/channels/view.telegram.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Channels page renders Telegram status. -import { html, nothing } from "lit"; -import type { ChannelAccountSnapshot, TelegramStatus } from "../../api/types.ts"; -import { renderSettingsSection } from "../../components/settings-ui.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelAccountRow, - renderChannelActionRow, - renderChannelErrorRow, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderTelegramCard(params: { - props: ChannelsProps; - telegram?: TelegramStatus; - telegramAccounts: ChannelAccountSnapshot[]; - accountCount?: number; -}) { - const { props, telegram, telegramAccounts, accountCount } = params; - const hasMultipleAccounts = telegramAccounts.length > 1; - const configured = resolveChannelConfigured("telegram", props); - - const renderAccountRow = (account: ChannelAccountSnapshot) => { - const probe = account.probe as { bot?: { username?: string } } | undefined; - const botUsername = probe?.bot?.username; - const label = account.name || account.accountId; - return renderChannelAccountRow({ - title: botUsername ? `@${botUsername}` : label, - accountId: account.accountId, - facts: [ - `${t("common.configured")}: ${account.configured ? t("common.yes") : t("common.no")}`, - ], - status: { - kind: boolStatusKind(account.running), - label: account.running ? t("common.running") : t("common.no"), - }, - lastInboundAt: account.lastInboundAt, - lastError: account.lastError, - }); - }; - - if (hasMultipleAccounts) { - return renderSettingsSection( - { - title: t("channels.telegram.title"), - description: t("channels.telegram.subtitle"), - ...(accountCount !== undefined ? { count: accountCount } : {}), - }, - html` - ${telegramAccounts.map((account) => renderAccountRow(account))} - ${telegram?.lastError ? renderChannelErrorRow(telegram.lastError) : nothing} - ${telegram?.probe ? renderChannelProbeRow(telegram.probe) : nothing} - ${renderChannelConfigSection({ channelId: "telegram", props })} - ${renderChannelActionRow( - html``, - )} - `, - ); - } - - return renderSingleAccountChannelCard({ - title: t("channels.telegram.title"), - subtitle: t("channels.telegram.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: telegram?.running ? t("common.yes") : t("common.no"), - kind: boolStatusKind(telegram?.running), - }, - { label: t("common.mode"), value: telegram?.mode ?? t("common.na") }, - { - label: t("common.lastStart"), - value: telegram?.lastStartAt - ? formatRelativeTimestamp(telegram.lastStartAt) - : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: telegram?.lastProbeAt - ? formatRelativeTimestamp(telegram.lastProbeAt) - : t("common.na"), - }, - ], - lastError: telegram?.lastError, - secondaryCallout: telegram?.probe ? renderChannelProbeRow(telegram.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "telegram", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.test.ts b/ui/src/pages/channels/view.test.ts index 6c3386f7878c..5285dfea8a94 100644 --- a/ui/src/pages/channels/view.test.ts +++ b/ui/src/pages/channels/view.test.ts @@ -8,7 +8,8 @@ import { resolveChannelConfigured, resolveChannelDisplayState, } from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; +import { renderChannels } from "./view.ts"; +import type { ChannelsChannelData, ChannelsProps } from "./view.types.ts"; import { renderWhatsAppCard } from "./view.whatsapp.ts"; function createProps(snapshot: ChannelsProps["snapshot"]): ChannelsProps { @@ -122,6 +123,40 @@ function renderWhatsAppButtons(params: { }; } +function renderChannelDetailFixture( + channelId: string, + data: ChannelsChannelData, + options: { label?: string; onRefresh?: ChannelsProps["onRefresh"] } = {}, +) { + const status = Object.entries(data).find(([key]) => key === channelId)?.[1] ?? {}; + const channelAccounts = data.channelAccounts ?? {}; + const accounts = Object.hasOwn(channelAccounts, channelId) ? channelAccounts[channelId] : []; + const props = createProps({ + ts: Date.now(), + channelOrder: [channelId], + channelLabels: { [channelId]: options.label ?? channelId }, + channels: { [channelId]: status }, + channelAccounts, + channelDefaultAccountId: accounts?.length ? { [channelId]: accounts[0]!.accountId } : {}, + }); + if (options.onRefresh) { + props.onRefresh = options.onRefresh; + } + const container = document.createElement("div"); + render( + renderChannelDetail({ + channelId, + label: options.label ?? channelId, + props, + data: { ...data, channelAccounts }, + onClose: () => {}, + onSetup: () => {}, + }), + container, + ); + return container; +} + // Mirrors the tiers the gateway materializes on every channel schema path. const CHANNEL_TIER_SCHEMA = { type: "object", @@ -257,6 +292,103 @@ describe("channel detail", () => { expect(docs?.href).toBe("https://docs.openclaw.ai/channels/telegram"); expect(docs?.textContent?.trim()).toBe("Docs"); }); + + it.each([ + ["discord", "Discord", []], + ["slack", "Slack", []], + ["signal", "Signal", [["Base URL", "https://signal.example"]]], + ["imessage", "iMessage", []], + [ + "googlechat", + "Google Chat", + [ + ["Credential", "service-account"], + ["Audience", "url · https://chat.example"], + ], + ], + ["telegram", "Telegram", [["Mode", "polling"]]], + ] satisfies Array<[string, string, Array<[string, string]>]>)( + "preserves localized status facts and probe actions for %s", + (channelId, title, extraFacts) => { + const onRefresh = vi.fn(); + const status = { + configured: true, + running: true, + baseUrl: "https://signal.example", + credentialSource: "service-account", + audienceType: "url", + audience: "https://chat.example", + mode: "polling", + }; + const data: ChannelsChannelData = { channelAccounts: {}, [channelId]: status }; + const container = renderChannelDetailFixture(channelId, data, { onRefresh }); + const facts = Array.from(container.querySelectorAll("dt"), (node) => [ + node.textContent?.trim(), + node.nextElementSibling?.textContent?.trim(), + ]); + + expect(container.querySelector(".settings-section__heading")?.textContent?.trim()).toBe( + title, + ); + expect(facts).toEqual([ + ["Configured", "Yes"], + ["Running", "Yes"], + ...extraFacts, + ["Last start", "n/a"], + ["Last probe", "n/a"], + ]); + container.querySelector(".settings-row--actions button")!.click(); + expect(onRefresh).toHaveBeenCalledWith(true); + }, + ); + + it("keeps missing Google Chat status unknown while other known channels are stopped", () => { + const google = renderChannelDetailFixture("googlechat", { googlechat: null }); + const discord = renderChannelDetailFixture("discord", { discord: null }); + const fact = (container: HTMLElement, label: string) => + Array.from(container.querySelectorAll("dt")) + .find((node) => node.textContent?.trim() === label) + ?.nextElementSibling?.textContent?.trim(); + + expect(fact(google, "Running")).toBe("n/a"); + expect(fact(discord, "Running")).toBe("No"); + }); + + it.each(["guildchat", "constructor", "__proto__"])( + "opens accountless plugin %s from its actual hub row without inherited account values", + (channelId) => { + for (const configured of [false, true]) { + const props = createProps({ + ts: Date.now(), + channelOrder: [channelId], + channelLabels: { [channelId]: "Custom channel" }, + channels: { [channelId]: { configured, running: configured } }, + channelAccounts: {}, + channelDefaultAccountId: {}, + }); + const container = document.createElement("div"); + props.onShowDetail = (selected) => { + props.selectedChannel = selected; + render(renderChannels(props), container); + }; + render(renderChannels(props), container); + const trigger = container.querySelector( + configured ? "button.channels-item" : ".channels-item__detail", + ); + + expect(trigger).toBeInstanceOf(HTMLButtonElement); + trigger!.click(); + const detail = container.querySelector(".channels-detail"); + expect(detail?.querySelector(".settings-section__heading")?.textContent?.trim()).toBe( + "Custom channel", + ); + expect(detail?.textContent).toContain("Channel status and configuration."); + expect( + Array.from(detail!.querySelectorAll("dt"), (node) => node.textContent?.trim()), + ).toEqual(["Configured", "Running", "Connected"]); + } + }, + ); }); describe("channel display selectors", () => { diff --git a/ui/src/pages/channels/view.ts b/ui/src/pages/channels/view.ts index 2276f10e0842..0e9caaf5c992 100644 --- a/ui/src/pages/channels/view.ts +++ b/ui/src/pages/channels/view.ts @@ -3,9 +3,7 @@ import { html, nothing } from "lit"; import "../../styles/channels.css"; import type { - ChannelAccountSnapshot, ChannelsStatusSnapshot, - ChannelUiMetaEntry, DiscordStatus, GoogleChatStatus, IMessageStatus, @@ -24,6 +22,7 @@ import { renderSettingsStatus, } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; +import { resolveChannelAccounts } from "../../lib/channels/index.ts"; import { formatRelativeTimestamp } from "../../lib/format.ts"; import { renderChannelArt } from "./hub-meta.ts"; import { renderChannelDetail } from "./view.detail.ts"; @@ -156,26 +155,23 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe return ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage", "nostr"]; } -function resolveChannelMetaMap( - snapshot: ChannelsStatusSnapshot | null, -): Record { - if (!snapshot?.channelMeta?.length) { - return {}; - } - return Object.fromEntries(snapshot.channelMeta.map((entry) => [entry.id, entry])); -} - function resolveChannelLabel(snapshot: ChannelsStatusSnapshot | null, key: string): string { - const meta = resolveChannelMetaMap(snapshot)[key]; - return meta?.label ?? snapshot?.channelLabels?.[key] ?? key; + const labels = snapshot?.channelLabels; + return ( + snapshot?.channelMeta?.find((entry) => entry.id === key)?.label ?? + (labels && Object.hasOwn(labels, key) ? labels[key] : undefined) ?? + key + ); } function resolveChannelDetailLabel( snapshot: ChannelsStatusSnapshot | null, key: string, ): string | null { - const meta = resolveChannelMetaMap(snapshot)[key]; - const detail = meta?.detailLabel ?? snapshot?.channelDetailLabels?.[key] ?? null; + const labels = snapshot?.channelDetailLabels; + const detail = + snapshot?.channelMeta?.find((entry) => entry.id === key)?.detailLabel ?? + (labels && Object.hasOwn(labels, key) ? labels[key] : null); return detail && detail !== resolveChannelLabel(snapshot, key) ? detail : null; } @@ -184,8 +180,9 @@ function resolveRowState(key: ChannelKey, props: ChannelsProps): ChannelCardStat const lastError = typeof displayState.status?.lastError === "string" && displayState.status.lastError.trim() ? displayState.status.lastError - : (props.snapshot?.channelAccounts?.[key] ?? []).find((account) => account.lastError) - ?.lastError; + : resolveChannelAccounts(props.snapshot?.channelAccounts, key).find( + (account) => account.lastError, + )?.lastError; if (lastError) { return "attention"; } @@ -209,10 +206,10 @@ function rowStatus(state: ChannelCardState) { } function lastActivityLine(key: ChannelKey, props: ChannelsProps): string | null { - const accounts: ChannelAccountSnapshot[] = props.snapshot?.channelAccounts?.[key] ?? []; - const lastInbound = accounts - .map((account) => account.lastInboundAt ?? 0) - .reduce((a, b) => Math.max(a, b), 0); + const lastInbound = resolveChannelAccounts(props.snapshot?.channelAccounts, key).reduce( + (latest, account) => Math.max(latest, account.lastInboundAt ?? 0), + 0, + ); if (!lastInbound) { return null; } diff --git a/ui/src/pages/channels/wizard-controller.ts b/ui/src/pages/channels/wizard-controller.ts index 2cba18ccbff5..474febd85610 100644 --- a/ui/src/pages/channels/wizard-controller.ts +++ b/ui/src/pages/channels/wizard-controller.ts @@ -38,7 +38,6 @@ async function requestWithTimeout( } } -export type ChannelWizardStepOption = NonNullable[number]; export type ChannelWizardStep = WizardStep; type WizardNextResult = { diff --git a/ui/src/pages/channels/wizard-view.ts b/ui/src/pages/channels/wizard-view.ts index 2feabe472b07..04e1eca53f88 100644 --- a/ui/src/pages/channels/wizard-view.ts +++ b/ui/src/pages/channels/wizard-view.ts @@ -3,15 +3,12 @@ import "@awesome.me/webawesome/dist/components/radio/radio.js"; import "@awesome.me/webawesome/dist/components/radio-group/radio-group.js"; import { html, nothing, type TemplateResult } from "lit"; +import { renderWizardStepControls } from "../../components/wizard-step-controls.ts"; import { t } from "../../i18n/index.ts"; import "../../components/modal-dialog.ts"; import { copyToClipboard } from "../../lib/clipboard.ts"; import { channelDocsUrl, channelHubMeta, renderChannelArt } from "./hub-meta.ts"; -import type { - ChannelWizardState, - ChannelWizardStep, - ChannelWizardStepOption, -} from "./wizard-controller.ts"; +import type { ChannelWizardState, ChannelWizardStep } from "./wizard-controller.ts"; type ChannelWizardViewProps = { wizard: ChannelWizardState; @@ -30,10 +27,6 @@ type ChannelWizardViewProps = { onWhatsAppWait: () => void; }; -function stepKeyboardValue(step: ChannelWizardStep): string { - return typeof step.initialValue === "string" ? step.initialValue : ""; -} - function stepIsBusy(props: ChannelWizardViewProps): boolean { return props.wizard.phase === "step" && props.wizard.busy; } @@ -72,151 +65,20 @@ function renderNoteStep(step: ChannelWizardStep, props: ChannelWizardViewProps) `; } -function renderSelectStep(step: ChannelWizardStep, props: ChannelWizardViewProps) { - const options = step.options ?? []; - const selectedIndex = options.findIndex((option) => option.value === step.initialValue); - return html` - = 0 ? String(selectedIndex) : null} - ?disabled=${stepIsBusy(props)} - @change=${(event: Event) => { - const rawIndex = (event.currentTarget as HTMLElement & { value?: string | number | null }) - .value; - const option = options[Number(rawIndex)]; - if (option) { - props.onAnswer(option.value); - } - }} - > - ${options.map( - (option: ChannelWizardStepOption, index) => html` - - ${option.label} - ${option.hint - ? html`${option.hint}` - : nothing} - - `, - )} - - `; -} - -function renderMultiselectStep(step: ChannelWizardStep, props: ChannelWizardViewProps) { - const options = step.options ?? []; - const selected = new Set(props.multiselectValues); - return html` -
${step.message ?? ""}
-
- ${options.map( - (option: ChannelWizardStepOption) => html` - - `, - )} -
- - `; -} - -function renderTextStep(step: ChannelWizardStep, props: ChannelWizardViewProps) { - const submit = (event: Event) => { - event.preventDefault(); - const form = event.currentTarget as HTMLFormElement; - const input = form.elements.namedItem("wizard-text") as HTMLInputElement | null; - props.onAnswer(input?.value ?? ""); - }; - return html` -
-
- -
- - -
- `; -} - -function renderConfirmStep(step: ChannelWizardStep, props: ChannelWizardViewProps) { - return html` -
${step.message ?? ""}
- - `; -} - function renderStepBody(step: ChannelWizardStep, props: ChannelWizardViewProps) { - switch (step.type) { - case "select": - return renderSelectStep(step, props); - case "multiselect": - return renderMultiselectStep(step, props); - case "text": - return renderTextStep(step, props); - case "confirm": - return renderConfirmStep(step, props); - default: - return renderNoteStep(step, props); + if (step.type === "note" || step.type === "progress" || step.type === "action") { + return renderNoteStep(step, props); } + return renderWizardStepControls({ + step, + value: step.type === "multiselect" ? props.multiselectValues : step.initialValue, + busy: stepIsBusy(props), + inputId: "channel-wizard-text-input", + presentation: "channels", + answerLabel: t("channels.setup.continue"), + onValueChange: props.onToggleMultiselect, + onAnswer: props.onAnswer, + }); } function renderWhatsAppLinking(props: ChannelWizardViewProps) { @@ -285,23 +147,17 @@ function renderDoneBody(channels: readonly string[], props: ChannelWizardViewPro if (channels.includes("whatsapp")) { return renderWhatsAppLinking(props); } - if (channels.length === 0) { - return html` -
${t("channels.setup.doneNoChangesTitle")}
-
${t("channels.setup.doneNoChangesBody")}
- - `; - } + const changed = channels.length > 0; return html` -
${t("channels.setup.doneTitle")}
-
${t("channels.setup.doneBody")}
+
+ ${t(changed ? "channels.setup.doneTitle" : "channels.setup.doneNoChangesTitle")} +
+
+ ${t(changed ? "channels.setup.doneBody" : "channels.setup.doneNoChangesBody")} +
`;