mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-27 21:07:01 -06:00
refactor(ui): unify channel status and setup controls (#117499)
This commit is contained in:
committed by
GitHub
parent
3f9b24237b
commit
fd343ca8cc
@@ -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`<div class="wizard-step__message">${step.message}</div>` : 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`<div class=${stepClass(props, "message")}>${props.step.message}</div>`
|
||||
: nothing;
|
||||
}
|
||||
|
||||
function renderOptionBody(option: WizardStepOption, presentation?: "channels", selected?: boolean) {
|
||||
if (presentation === "channels") {
|
||||
return html`
|
||||
<span class="channels-wizard__option-label">
|
||||
${selected === undefined ? nothing : selected ? "☑ " : "☐ "}${option.label}
|
||||
</span>
|
||||
${option.hint
|
||||
? html`<span class="channels-wizard__option-hint">${option.hint}</span>`
|
||||
: nothing}
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<span>
|
||||
<strong>${option.label}</strong>
|
||||
@@ -59,32 +77,95 @@ function renderDeviceCode(step: WizardStep) {
|
||||
`;
|
||||
}
|
||||
|
||||
function renderAnswerButton(
|
||||
props: WizardStepControlsProps,
|
||||
label: string,
|
||||
onClick?: () => void,
|
||||
disabled = props.busy,
|
||||
) {
|
||||
const button = html`
|
||||
<button
|
||||
type=${onClick ? "button" : "submit"}
|
||||
class="btn primary"
|
||||
?disabled=${disabled}
|
||||
@click=${onClick}
|
||||
>
|
||||
${props.answerLabel ?? label}
|
||||
</button>
|
||||
`;
|
||||
return props.presentation === "channels"
|
||||
? html`<div class="channels-wizard__footer">${button}</div>`
|
||||
: 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`<wa-radio
|
||||
class="channels-wizard__option"
|
||||
appearance="button"
|
||||
value=${String(index)}
|
||||
.checked=${checked}
|
||||
>
|
||||
${renderOptionBody(option, props.presentation)}
|
||||
</wa-radio>`
|
||||
: html`<button
|
||||
type="button"
|
||||
class="channels-wizard__option"
|
||||
aria-pressed=${checked ? "true" : "false"}
|
||||
?disabled=${props.busy}
|
||||
@click=${() => props.onValueChange(option.value)}
|
||||
>
|
||||
${renderOptionBody(option, props.presentation, checked)}
|
||||
</button>`;
|
||||
}
|
||||
return html`<label class="wizard-step__option">
|
||||
<input
|
||||
type=${props.step.type === "select" ? "radio" : "checkbox"}
|
||||
name=${props.step.type === "select" ? "wizard-option" : nothing}
|
||||
.checked=${checked}
|
||||
?disabled=${props.busy}
|
||||
@change=${(event: Event) => {
|
||||
const nextValue =
|
||||
props.step.type === "select"
|
||||
? option.value
|
||||
: (event.currentTarget as HTMLInputElement).checked
|
||||
? [...selected, option.value]
|
||||
: selected.filter((value) => !Object.is(value, option.value));
|
||||
props.onValueChange(nextValue);
|
||||
}}
|
||||
/>
|
||||
${renderOptionBody(option)}
|
||||
</label>`;
|
||||
}
|
||||
|
||||
function renderContinueStep(props: WizardStepControlsProps) {
|
||||
const step = props.step;
|
||||
return html`
|
||||
${renderMessage(step)}
|
||||
${renderMessage(props)}
|
||||
${step.externalUrl
|
||||
? html`<a class="btn btn--sm" href=${step.externalUrl} target="_blank" rel="noreferrer">
|
||||
${t("modelSetup.wizard.openSignIn")}
|
||||
</a>`
|
||||
: nothing}
|
||||
${renderDeviceCode(step)}
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
?disabled=${props.busy}
|
||||
@click=${() => props.onAnswer(undefined, false)}
|
||||
>
|
||||
${t("modelSetup.wizard.continue")}
|
||||
</button>
|
||||
${renderAnswerButton(props, t("modelSetup.wizard.continue"), () =>
|
||||
props.onAnswer(undefined, false),
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderProgressStep(step: WizardStep) {
|
||||
function renderProgressStep(props: WizardStepControlsProps) {
|
||||
return html`
|
||||
<div class="wizard-step__progress" role="status" aria-live="polite">
|
||||
<span class="wizard-step__spinner" aria-hidden="true"></span>
|
||||
${renderMessage(step)}
|
||||
${renderMessage(props)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@@ -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`<div class="wizard-step__message">
|
||||
? html`<div class=${stepClass(props, "message")}>
|
||||
<label for=${props.inputId}>${step.message}</label>
|
||||
</div>`
|
||||
: 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)}
|
||||
/>
|
||||
<button type="submit" class="btn primary" ?disabled=${props.busy}>
|
||||
${t("modelSetup.wizard.submit")}
|
||||
</button>
|
||||
${renderAnswerButton(props, t("modelSetup.wizard.submit"))}
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<wa-radio-group
|
||||
class="channels-wizard__options"
|
||||
label=${props.step.message ?? ""}
|
||||
orientation="vertical"
|
||||
.value=${selectedIndex >= 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))}
|
||||
</wa-radio-group>
|
||||
`;
|
||||
}
|
||||
const answer = multiple
|
||||
? props.presentation === "channels"
|
||||
? [...selected]
|
||||
: selected
|
||||
: props.value;
|
||||
return html`
|
||||
${renderMessage(props.step)}
|
||||
<div class="wizard-step__options" role="radiogroup">
|
||||
${(props.step.options ?? []).map(
|
||||
(option) => html`
|
||||
<label class="wizard-step__option">
|
||||
<input
|
||||
type="radio"
|
||||
name="wizard-option"
|
||||
.checked=${Object.is(props.value, option.value)}
|
||||
@change=${() => props.onValueChange(option.value)}
|
||||
/>
|
||||
${renderOptionBody(option)}
|
||||
</label>
|
||||
`,
|
||||
)}
|
||||
${renderMessage(props)}
|
||||
<div class=${stepClass(props, "options")} role=${multiple ? nothing : "radiogroup"}>
|
||||
${options.map((option, index) => renderOption(props, option, index, selected))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
?disabled=${props.busy || props.value === undefined}
|
||||
@click=${() => props.onAnswer(props.value)}
|
||||
>
|
||||
${t("modelSetup.wizard.continue")}
|
||||
</button>
|
||||
${renderAnswerButton(
|
||||
props,
|
||||
t("modelSetup.wizard.continue"),
|
||||
() => props.onAnswer(answer),
|
||||
props.busy || (!multiple && props.value === undefined),
|
||||
)}
|
||||
`;
|
||||
}
|
||||
|
||||
function renderConfirmStep(props: WizardStepControlsProps) {
|
||||
return html`
|
||||
${renderMessage(props.step)}
|
||||
<div class="wizard-step__actions">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
?disabled=${props.busy}
|
||||
@click=${() => props.onAnswer(false)}
|
||||
>
|
||||
${t("common.no")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
?disabled=${props.busy}
|
||||
@click=${() => props.onAnswer(true)}
|
||||
>
|
||||
${t("common.yes")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMultiselectStep(props: WizardStepControlsProps) {
|
||||
const selected = Array.isArray(props.value) ? props.value : [];
|
||||
return html`
|
||||
${renderMessage(props.step)}
|
||||
<div class="wizard-step__options">
|
||||
${(props.step.options ?? []).map(
|
||||
(option) => html`
|
||||
<label class="wizard-step__option">
|
||||
<input
|
||||
type="checkbox"
|
||||
.checked=${selected.some((value) => Object.is(value, option.value))}
|
||||
@change=${(event: Event) => {
|
||||
const checked = (event.currentTarget as HTMLInputElement).checked;
|
||||
props.onValueChange(
|
||||
checked
|
||||
? [...selected, option.value]
|
||||
: selected.filter((value) => !Object.is(value, option.value)),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
${renderOptionBody(option)}
|
||||
</label>
|
||||
`,
|
||||
${renderMessage(props)}
|
||||
<div class=${stepClass(props, props.presentation === "channels" ? "footer" : "actions")}>
|
||||
${[false, true].map(
|
||||
(answer) => html`<button
|
||||
type="button"
|
||||
class=${answer ? "btn primary" : "btn"}
|
||||
?disabled=${props.busy}
|
||||
@click=${() => props.onAnswer(answer)}
|
||||
>
|
||||
${t(answer ? "common.yes" : "common.no")}
|
||||
</button>`,
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
?disabled=${props.busy}
|
||||
@click=${() => props.onAnswer(selected)}
|
||||
>
|
||||
${t("modelSetup.wizard.continue")}
|
||||
</button>
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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<string, Record<string, unknown>> = {
|
||||
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<string, unknown> = {}) => ({
|
||||
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<string, string[]> = {
|
||||
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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<ChannelGatewaySnapshot> =
|
||||
};
|
||||
}
|
||||
|
||||
function delay(ms: number): Promise<"timeout"> {
|
||||
function delay(ms: number): Promise<void> {
|
||||
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(
|
||||
|
||||
@@ -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`<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
${t("common.probe")}
|
||||
</button>`)
|
||||
: 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<string, ChannelAccountSnapshot[]>,
|
||||
) {
|
||||
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;
|
||||
|
||||
@@ -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`<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
${t("common.probe")}
|
||||
</button>`,
|
||||
});
|
||||
}
|
||||
@@ -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`<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
${t("common.probe")}
|
||||
</button>`,
|
||||
});
|
||||
}
|
||||
@@ -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`<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
${t("common.probe")}
|
||||
</button>`,
|
||||
});
|
||||
}
|
||||
@@ -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<string, unknown> | undefined {
|
||||
const channels = props.snapshot?.channels as Record<string, unknown> | null;
|
||||
return channels?.[key] as Record<string, unknown> | 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<string, ChannelAccountSnapshot[]> | 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<string, ChannelAccountSnapshot[]> | null,
|
||||
): number | undefined {
|
||||
const count = getChannelAccountCount(key, channelAccounts);
|
||||
const count = resolveChannelAccounts(channelAccounts, key).length;
|
||||
return count >= 2 ? count : undefined;
|
||||
}
|
||||
|
||||
@@ -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`<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
${t("common.probe")}
|
||||
</button>`,
|
||||
});
|
||||
}
|
||||
@@ -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`<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
${t("common.probe")}
|
||||
</button>`,
|
||||
});
|
||||
}
|
||||
@@ -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`<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
${t("common.probe")}
|
||||
</button>`,
|
||||
)}
|
||||
`,
|
||||
);
|
||||
}
|
||||
|
||||
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`<button class="btn" @click=${() => props.onRefresh(true)}>
|
||||
${t("common.probe")}
|
||||
</button>`,
|
||||
});
|
||||
}
|
||||
@@ -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<HTMLButtonElement>(".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<HTMLButtonElement>(
|
||||
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", () => {
|
||||
|
||||
@@ -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<string, ChannelUiMetaEntry> {
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ async function requestWithTimeout<T>(
|
||||
}
|
||||
}
|
||||
|
||||
export type ChannelWizardStepOption = NonNullable<WizardStep["options"]>[number];
|
||||
export type ChannelWizardStep = WizardStep;
|
||||
|
||||
type WizardNextResult = {
|
||||
|
||||
@@ -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`
|
||||
<wa-radio-group
|
||||
class="channels-wizard__options"
|
||||
label=${step.message ?? ""}
|
||||
orientation="vertical"
|
||||
.value=${selectedIndex >= 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`
|
||||
<wa-radio
|
||||
class="channels-wizard__option"
|
||||
appearance="button"
|
||||
value=${String(index)}
|
||||
.checked=${index === selectedIndex}
|
||||
>
|
||||
<span class="channels-wizard__option-label">${option.label}</span>
|
||||
${option.hint
|
||||
? html`<span class="channels-wizard__option-hint">${option.hint}</span>`
|
||||
: nothing}
|
||||
</wa-radio>
|
||||
`,
|
||||
)}
|
||||
</wa-radio-group>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderMultiselectStep(step: ChannelWizardStep, props: ChannelWizardViewProps) {
|
||||
const options = step.options ?? [];
|
||||
const selected = new Set(props.multiselectValues);
|
||||
return html`
|
||||
<div class="channels-wizard__message">${step.message ?? ""}</div>
|
||||
<div class="channels-wizard__options">
|
||||
${options.map(
|
||||
(option: ChannelWizardStepOption) => html`
|
||||
<button
|
||||
type="button"
|
||||
class="channels-wizard__option"
|
||||
aria-pressed=${selected.has(option.value) ? "true" : "false"}
|
||||
?disabled=${stepIsBusy(props)}
|
||||
@click=${() => props.onToggleMultiselect(option.value)}
|
||||
>
|
||||
<span class="channels-wizard__option-label">
|
||||
${selected.has(option.value) ? "☑" : "☐"} ${option.label}
|
||||
</span>
|
||||
${option.hint
|
||||
? html`<span class="channels-wizard__option-hint">${option.hint}</span>`
|
||||
: nothing}
|
||||
</button>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
<div class="channels-wizard__footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
?disabled=${stepIsBusy(props)}
|
||||
@click=${() => props.onAnswer([...props.multiselectValues])}
|
||||
>
|
||||
${t("channels.setup.continue")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<form @submit=${submit}>
|
||||
<div class="channels-wizard__message">
|
||||
<label for="channel-wizard-text-input">${step.message ?? ""}</label>
|
||||
</div>
|
||||
<input
|
||||
id="channel-wizard-text-input"
|
||||
class="input"
|
||||
style="margin-top: 10px; width: 100%;"
|
||||
name="wizard-text"
|
||||
type=${step.sensitive ? "password" : "text"}
|
||||
autocomplete=${step.sensitive ? "off" : "on"}
|
||||
placeholder=${step.placeholder ?? ""}
|
||||
.value=${stepKeyboardValue(step)}
|
||||
?disabled=${stepIsBusy(props)}
|
||||
/>
|
||||
<div class="channels-wizard__footer" style="margin-top: 12px;">
|
||||
<button type="submit" class="btn primary" ?disabled=${stepIsBusy(props)}>
|
||||
${t("channels.setup.continue")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderConfirmStep(step: ChannelWizardStep, props: ChannelWizardViewProps) {
|
||||
return html`
|
||||
<div class="channels-wizard__message">${step.message ?? ""}</div>
|
||||
<div class="channels-wizard__footer">
|
||||
<button
|
||||
type="button"
|
||||
class="btn"
|
||||
?disabled=${stepIsBusy(props)}
|
||||
@click=${() => props.onAnswer(false)}
|
||||
>
|
||||
${t("common.no")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="btn primary"
|
||||
?disabled=${stepIsBusy(props)}
|
||||
@click=${() => props.onAnswer(true)}
|
||||
>
|
||||
${t("common.yes")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
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`
|
||||
<div class="channels-wizard__message">${t("channels.setup.doneNoChangesTitle")}</div>
|
||||
<div class="channels-wizard__note">${t("channels.setup.doneNoChangesBody")}</div>
|
||||
<div class="channels-wizard__footer">
|
||||
<button type="button" class="btn primary" @click=${() => props.onClose()}>
|
||||
${t("common.close")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
const changed = channels.length > 0;
|
||||
return html`
|
||||
<div class="channels-wizard__message">${t("channels.setup.doneTitle")}</div>
|
||||
<div class="channels-wizard__note">${t("channels.setup.doneBody")}</div>
|
||||
<div class="channels-wizard__message">
|
||||
${t(changed ? "channels.setup.doneTitle" : "channels.setup.doneNoChangesTitle")}
|
||||
</div>
|
||||
<div class="channels-wizard__note">
|
||||
${t(changed ? "channels.setup.doneBody" : "channels.setup.doneNoChangesBody")}
|
||||
</div>
|
||||
<div class="channels-wizard__footer">
|
||||
<button type="button" class="btn primary" @click=${() => props.onClose()}>
|
||||
${t("channels.setup.finish")}
|
||||
${t(changed ? "channels.setup.finish" : "common.close")}
|
||||
</button>
|
||||
</div>
|
||||
`;
|
||||
|
||||
Reference in New Issue
Block a user