refactor(webui): clarify shared controls and status feedback

This commit is contained in:
2026-07-20 20:03:42 +02:00
parent 344fc0077f
commit c50ce58ad8
9 changed files with 211 additions and 195 deletions

View File

@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Archive, LockKeyhole, Paperclip } from "lucide-react";
import { Archive, Link2, LockKeyhole, Paperclip, X } from "lucide-react";
import { i18nMessage, usePlatformLanguage } from "../i18n/LanguageContext";
import SegmentedControl from "./SegmentedControl";
@@ -14,6 +14,7 @@ export type MessageDisplayAttachment = {
contentType?: string | null;
sizeBytes?: number | null;
detail?: string | null;
linkedToCampaign?: boolean | null;
archiveGroup?: string | null;
archiveLabel?: string | null;
protected?: boolean | null;
@@ -159,9 +160,15 @@ function AttachmentRow({ attachment, index }: {attachment: MessageDisplayAttachm
const contentType = formatContentType(attachment.contentType);
const size = formatBytes(attachment.sizeBytes);
const hasMeta = Boolean(contentType || size);
const LinkIcon = attachment.linkedToCampaign === true ? Link2 : attachment.linkedToCampaign === false ? X : Paperclip;
const linkStateClass = attachment.linkedToCampaign === true ?
" is-linked" :
attachment.linkedToCampaign === false ?
" is-unlinked" :
"";
return (
<div className="message-display-attachment-row">
<Paperclip size={14} aria-hidden="true" />
<div className={`message-display-attachment-row${linkStateClass}`}>
<LinkIcon size={14} aria-hidden="true" />
<span>
<strong>{attachment.filename || i18nMessage("i18n:govoplan-core.attachment_value.01801a54", { value0: index + 1 })}</strong>
{attachment.detail && <small className="message-display-attachment-detail">{attachment.detail}</small>}

View File

@@ -5,15 +5,21 @@ import { usePlatformLanguage } from "../i18n/LanguageContext";
type ToggleSwitchProps = {
label: ReactNode;
activeLabel?: ReactNode;
inactiveLabel?: ReactNode;
checked: boolean;
onChange?: (checked: boolean) => void;
disabled?: boolean;
help?: ReactNode;
};
export default function ToggleSwitch({ label, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
export default function ToggleSwitch({ label, activeLabel, inactiveLabel, checked, onChange, disabled = false, help }: ToggleSwitchProps) {
const { translateText } = usePlatformLanguage();
const hasStateLabels = activeLabel !== undefined || inactiveLabel !== undefined;
const renderedLabel = typeof label === "string" ? translateText(label) : label;
const renderedInactiveLabel = typeof inactiveLabel === "string" ? translateText(inactiveLabel) : inactiveLabel;
const renderedActiveLabel = typeof activeLabel === "string" ? translateText(activeLabel) : activeLabel;
const inputLabel = typeof renderedLabel === "string" ? renderedLabel : undefined;
return (
<label className={`toggle-switch-row ${disabled ? "disabled" : ""}`}>
<input
@@ -21,12 +27,25 @@ export default function ToggleSwitch({ label, checked, onChange, disabled = fals
type="checkbox"
checked={checked}
disabled={disabled}
aria-label={inputLabel}
onChange={(event) => onChange?.(event.target.checked)}
/>
{hasStateLabels && inactiveLabel !== undefined &&
<span className="toggle-switch-state-label is-inactive" data-selected={!checked || undefined}>
{renderedInactiveLabel}
</span>
}
<span className="toggle-switch-track" aria-hidden="true"><span className="toggle-switch-thumb" /></span>
{hasStateLabels && activeLabel !== undefined &&
<span className="toggle-switch-state-label is-active" data-selected={checked || undefined}>
{renderedActiveLabel}
</span>
}
{!hasStateLabels &&
<span className="toggle-switch-copy">
<FieldLabel className="toggle-switch-label" help={help ?? helpForFieldLabel(label)}>{renderedLabel}</FieldLabel>
</span>
}
</label>
);
}

View File

@@ -1,10 +1,9 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import Button from "../Button";
import { CredentialFields } from "../CredentialPanel";
import DismissibleAlert from "../DismissibleAlert";
import FormField from "../FormField";
import SegmentedControl from "../SegmentedControl";
import ToggleSwitch from "../ToggleSwitch";
export type MailServerSecurity = "plain" | "tls" | "starttls" | string;
@@ -48,7 +47,7 @@ export type MailServerFolderLookupResult = {
details?: Record<string, unknown> | null;
};
export type MailServerSettingsSection = "smtp" | "imap" | "advanced";
export type MailServerSettingsSection = "smtp" | "imap";
export type MailServerSettingsMode = "all" | "server" | "credentials";
export type MailServerSettingsPanelProps = {
@@ -72,30 +71,11 @@ export type MailServerSettingsPanelProps = {
imapActionDisabled?: boolean;
smtpTestLabel?: string;
imapTestLabel?: string;
folderLookupLabel?: string;
busyAction?: "smtp" | "imap" | "folders" | string | null;
onTestSmtp?: () => void;
onTestImap?: () => void;
onLookupFolders?: () => void;
smtpTestResult?: MailServerConnectionTestResult | null;
imapTestResult?: MailServerConnectionTestResult | null;
folderLookupResult?: MailServerFolderLookupResult | null;
onUseDetectedFolder?: () => void;
useDetectedFolderDisabled?: boolean;
mockToggle?: {
label?: string;
checked: boolean;
disabled?: boolean;
onChange: (checked: boolean) => void;
};
append?: {
enabled: boolean;
folder: string;
disabled?: boolean;
folderDisabled?: boolean;
onEnabledChange: (enabled: boolean) => void;
onFolderChange: (folder: string) => void;
};
disabled?: boolean;
className?: string;
floatingResults?: boolean;
@@ -202,6 +182,19 @@ export function hasMailImapSettings(values: Array<string | number | null | undef
return values.some((value) => String(value ?? "").trim() !== "");
}
export function resolveMailServerSettingsActiveSection(
activeSection: MailServerSettingsSection,
initialSection: MailServerSettingsSection,
previousInitialSection: MailServerSettingsSection,
visibleSectionIds: readonly MailServerSettingsSection[])
: MailServerSettingsSection {
const fallbackSection = visibleSectionIds[0] ?? "smtp";
const initialSectionVisible = visibleSectionIds.includes(initialSection);
if (previousInitialSection !== initialSection && initialSectionVisible) return initialSection;
if (!visibleSectionIds.includes(activeSection)) return initialSectionVisible ? initialSection : fallbackSection;
return activeSection;
}
export default function MailServerSettingsPanel({
smtp,
imap,
@@ -223,18 +216,11 @@ export default function MailServerSettingsPanel({
imapActionDisabled = imapServerDisabled,
smtpTestLabel = "i18n:govoplan-core.test_smtp.e5697981",
imapTestLabel = "i18n:govoplan-core.test_imap.ef1bd79c",
folderLookupLabel = "i18n:govoplan-core.folders.c603ab65",
busyAction = null,
onTestSmtp,
onTestImap,
onLookupFolders,
smtpTestResult = null,
imapTestResult = null,
folderLookupResult = null,
onUseDetectedFolder,
useDetectedFolderDisabled = false,
mockToggle,
append,
disabled = false,
className = "",
floatingResults = false,
@@ -254,35 +240,31 @@ export default function MailServerSettingsPanel({
const imapSecurity = stringValue(imap.security, "tls");
const smtpPort = stringValue(smtp.port, String(defaultSmtpPort(smtpSecurity)));
const imapPort = stringValue(imap.port, String(defaultImapPort(imapSecurity)));
const appendTargetFolder = append ? append.folder : stringValue(imap.sent_folder, "auto");
const appendTargetDisabled = append ? disabled || append.folderDisabled : imapFieldsDisabled;
const canLookupAppendFolders = Boolean(onLookupFolders) && !appendTargetDisabled;
const appendTargetHelp = append ?
"i18n:govoplan-core.folder_for_sent_message_copies_leave_as_auto_unl.a62586e9" :
"i18n:govoplan-core.folder_used_when_this_imap_account_is_used_for_s.08503f5e";
const allSections: {id: MailServerSettingsSection;label: string;}[] = [
{ id: "smtp", label: "i18n:govoplan-core.smtp.efff9cca" },
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" },
{ id: "advanced", label: "i18n:govoplan-core.advanced.4d064726" }];
{ id: "imap", label: "i18n:govoplan-core.imap.271f9ef2" }];
const visibleSectionSet = new Set(visibleSections ?? allSections.map((section) => section.id));
const sections = allSections.filter((section) => visibleSectionSet.has(section.id));
const fallbackSection = sections[0]?.id ?? "smtp";
const resolvedInitialSection = sections.some((section) => section.id === initialSection) ? initialSection : fallbackSection;
const sectionKey = sections.map((section) => section.id).join("|");
const visibleSectionIds = sections.map((section) => section.id);
const fallbackSection = visibleSectionIds[0] ?? "smtp";
const resolvedInitialSection = visibleSectionIds.includes(initialSection) ? initialSection : fallbackSection;
const sectionKey = visibleSectionIds.join("|");
const [activeSection, setActiveSection] = useState<MailServerSettingsSection>(resolvedInitialSection);
const previousInitialSectionRef = useRef(initialSection);
const showSectionSwitcher = sections.length > 1;
const showServerFields = mode !== "credentials";
const showCredentialFields = mode !== "server";
useEffect(() => {
if (!sections.some((section) => section.id === activeSection)) {
setActiveSection(resolvedInitialSection);
return;
}
if (initialSection !== activeSection && sections.some((section) => section.id === initialSection)) {
setActiveSection(initialSection);
}
}, [activeSection, initialSection, resolvedInitialSection, sectionKey]);
const nextSection = resolveMailServerSettingsActiveSection(
activeSection,
initialSection,
previousInitialSectionRef.current,
visibleSectionIds
);
previousInitialSectionRef.current = initialSection;
if (nextSection !== activeSection) setActiveSection(nextSection);
}, [activeSection, initialSection, sectionKey]);
function patchSmtpSecurity(security: MailServerSecurity) {
@@ -324,10 +306,10 @@ export default function MailServerSettingsPanel({
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
{showServerFields &&
<>
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(smtp.host)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ host: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={smtpPort} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ port: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={smtpSecurity} disabled={smtpFieldsDisabled} onChange={patchSmtpSecurity} /></FormField>
<FormField label="i18n:govoplan-core.timeout_seconds.0bfc6553"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
</>
}
{showCredentialFields &&
@@ -336,9 +318,7 @@ export default function MailServerSettingsPanel({
onChange={patchSmtpCredentials}
disabled={smtpCredentialFieldsDisabled}
savedPassword={smtpPasswordSaved}
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder}
heading="i18n:govoplan-core.credentials.dd097a22"
headingClassName="mail-server-field-heading" />
savedPasswordPlaceholder={smtpSavedPasswordPlaceholder} />
}
</div>
{onTestSmtp &&
@@ -355,10 +335,10 @@ export default function MailServerSettingsPanel({
<div className="form-grid compact responsive-form-grid mail-server-form-grid">
{showServerFields &&
<>
<div className="mail-server-field-heading">i18n:govoplan-core.server.cb0cb170</div>
<FormField label="i18n:govoplan-core.host.3960ec4c"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.server.cb0cb170" help="i18n:govoplan-core.mail_server_hostname_or_ip_address_for_the_selec.596c2d69"><input value={stringValue(imap.host)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ host: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.port.fe035157"><input type="number" min={1} max={65535} value={imapPort} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ port: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.security.f25ce1b8"><SecuritySelect value={imapSecurity} disabled={imapFieldsDisabled} onChange={patchImapSecurity} /></FormField>
<FormField label="i18n:govoplan-core.timeout_seconds.0bfc6553"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
</>
}
{showCredentialFields &&
@@ -367,9 +347,7 @@ export default function MailServerSettingsPanel({
onChange={patchImapCredentials}
disabled={imapCredentialFieldsDisabled}
savedPassword={imapPasswordSaved}
savedPasswordPlaceholder={imapSavedPasswordPlaceholder}
heading="i18n:govoplan-core.credentials.dd097a22"
headingClassName="mail-server-field-heading" />
savedPasswordPlaceholder={imapSavedPasswordPlaceholder} />
}
</div>
{onTestImap &&
@@ -381,41 +359,6 @@ export default function MailServerSettingsPanel({
</section>
}
{activeSection === "advanced" &&
<section className="mail-server-subsection" role="tabpanel" aria-label="i18n:govoplan-core.advanced_mail_settings.1f69439a">
<div className="form-grid compact responsive-form-grid mail-server-form-grid mail-server-advanced-grid">
{mockToggle &&
<div className="mail-server-field-span mail-server-toggle-row">
<ToggleSwitch
label={mockToggle.label ?? "i18n:govoplan-core.mock_server_settings.9361d5c5"}
checked={mockToggle.checked}
disabled={disabled || mockToggle.disabled}
onChange={mockToggle.onChange} />
</div>
}
<FormField label="i18n:govoplan-core.smtp_timeout_seconds.ac87c8d2"><input type="number" min={1} value={stringValue(smtp.timeout_seconds)} disabled={smtpFieldsDisabled} onChange={(event) => onSmtpChange({ timeout_seconds: event.target.value })} /></FormField>
<FormField label="i18n:govoplan-core.imap_timeout_seconds.489af2a4"><input type="number" min={1} value={stringValue(imap.timeout_seconds)} disabled={imapFieldsDisabled} onChange={(event) => onImapChange({ timeout_seconds: event.target.value })} /></FormField>
{append &&
<div className="mail-server-field-span mail-server-toggle-row mail-server-plain-toggle-row">
<ToggleSwitch label="i18n:govoplan-core.append_successfully_sent_messages_to_sent.002fd67e" checked={append.enabled} disabled={disabled || append.disabled} onChange={append.onEnabledChange} />
</div>
}
<FormField label="i18n:govoplan-core.append_target_folder.0aaacc0c" help={appendTargetHelp}>
<div className="field-with-action mail-server-folder-field">
<input
value={appendTargetFolder}
disabled={appendTargetDisabled}
onChange={(event) => append ? append.onFolderChange(event.target.value) : onImapChange({ sent_folder: event.target.value })}
placeholder="i18n:govoplan-core.auto.0d612c12" />
{canLookupAppendFolders && <Button type="button" variant="primary" onClick={onLookupFolders} disabled={imapActionsDisabled || busyAction === "folders"}>{busyAction === "folders" ? "i18n:govoplan-core.looking_up.5fc6d2a2" : folderLookupLabel}</Button>}
</div>
</FormField>
{canLookupAppendFolders && <MailServerFolderLookupResultView result={folderLookupResult} disabled={useDetectedFolderDisabled || appendTargetDisabled} onUseDetected={onUseDetectedFolder} />}
</div>
</section>
}
</div>
</div>);
@@ -437,20 +380,18 @@ export function MailServerActionResult({ result, floating = false }: {result: Ma
export function MailServerFolderLookupResultView({
result,
disabled = false,
onUseDetected
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;}) {
onUseDetected,
compact = true,
floatingFailures = false
}: {result: MailServerFolderLookupResult | null | undefined;disabled?: boolean;onUseDetected?: () => void;compact?: boolean;floatingFailures?: boolean;}) {
if (!result) return null;
if (!result.ok) {
return <DismissibleAlert tone="danger" resetKey={result.message}>{result.message}</DismissibleAlert>;
return <DismissibleAlert tone="warning" compact={compact} resetKey={result.message} floating={floatingFailures}>{result.message}</DismissibleAlert>;
}
const folders = result.folders ?? [];
return (
<DismissibleAlert tone="success" resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
<DismissibleAlert tone="success" compact={compact} resetKey={`${result.message}:${result.detected_sent_folder || ""}`}>
<p>{result.message}</p>
<p>i18n:govoplan-core.detected_sent_folder.cbf8ec8d <strong>{result.detected_sent_folder || "-"}</strong></p>
{result.detected_sent_folder && onUseDetected && <Button type="button" onClick={onUseDetected} disabled={disabled}>i18n:govoplan-core.use_detected_folder.5ec4965c</Button>}