Label notification and settings switches

This commit is contained in:
rcourtman
2026-05-20 15:04:33 +01:00
parent 6ebf0c6886
commit 0d33b50df6
7 changed files with 156 additions and 10 deletions
@@ -1,4 +1,4 @@
import { Component, Show, For, Accessor, Setter } from 'solid-js';
import { Component, Show, For, Accessor, Setter, createUniqueId } from 'solid-js';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { FormSelect } from '@/components/shared/FormSelect';
import { SectionHeader } from '@/components/shared/SectionHeader';
@@ -31,6 +31,8 @@ export interface RecoverySettingsPanelProps {
}
export const RecoverySettingsPanel: Component<RecoverySettingsPanelProps> = (props) => {
const backupPollingTitleId = `settings-recovery-${createUniqueId()}-backup-polling-title`;
return (
<SettingsPanel title="Recovery" noPadding bodyClass="divide-y divide-border">
{/* Backup Polling Section */}
@@ -57,7 +59,9 @@ export const RecoverySettingsPanel: Component<RecoverySettingsPanelProps> = (pro
{/* Enable toggle */}
<div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<p class="text-sm font-medium text-base-content">Enable backup polling</p>
<p id={backupPollingTitleId} class="text-sm font-medium text-base-content">
Enable backup polling
</p>
<p class="text-xs text-muted">
Required for workload backup status, storage snapshots, and alerting.
</p>
@@ -68,6 +72,7 @@ export const RecoverySettingsPanel: Component<RecoverySettingsPanelProps> = (pro
class="sr-only peer"
checked={props.backupPollingEnabled()}
disabled={props.backupPollingEnvLocked()}
aria-labelledby={backupPollingTitleId}
onChange={(e) => {
props.setBackupPollingEnabled(e.currentTarget.checked);
if (!props.backupPollingEnvLocked()) {
@@ -1,4 +1,4 @@
import { Component, Show, For, Accessor, Setter } from 'solid-js';
import { Component, Show, For, Accessor, Setter, createUniqueId } from 'solid-js';
import SettingsPanel from '@/components/shared/SettingsPanel';
import { FormSelect } from '@/components/shared/FormSelect';
import { HelpIcon } from '@/components/shared/HelpIcon';
@@ -47,6 +47,7 @@ export interface UpdatesSettingsPanelProps {
}
export const UpdatesSettingsPanel: Component<UpdatesSettingsPanelProps> = (props) => {
const autoUpdateTitleId = `settings-updates-${createUniqueId()}-auto-update-title`;
const latestVersion = () => props.updateInfo()?.latestVersion;
const dockerImageTag = () => buildDockerImageTag(latestVersion());
const systemdDownloadCommand = () => buildLinuxAmd64DownloadCommand(latestVersion());
@@ -88,11 +89,7 @@ export const UpdatesSettingsPanel: Component<UpdatesSettingsPanelProps> = (props
}));
return (
<SettingsPanel
title={UPDATES_PANEL_COPY.title}
noPadding
bodyClass="divide-y divide-border"
>
<SettingsPanel title={UPDATES_PANEL_COPY.title} noPadding bodyClass="divide-y divide-border">
<div class="p-4 sm:p-6">
<div class="space-y-4">
{/* Version Status Section */}
@@ -296,9 +293,9 @@ export const UpdatesSettingsPanel: Component<UpdatesSettingsPanelProps> = (props
</svg>
</div>
<div>
<label class="text-sm font-medium text-base-content">
<span id={autoUpdateTitleId} class="text-sm font-medium text-base-content">
{UPDATES_PANEL_COPY.autoUpdateTitle}
</label>
</span>
<p class="text-xs text-muted">{UPDATES_PANEL_COPY.autoUpdateDescription}</p>
</div>
</div>
@@ -312,6 +309,7 @@ export const UpdatesSettingsPanel: Component<UpdatesSettingsPanelProps> = (props
props.setHasUnsavedChanges(true);
}}
disabled={autoUpdateLocked()}
aria-labelledby={autoUpdateTitleId}
class="sr-only peer"
/>
<div class="w-11 h-6 bg-surface-alt peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-blue-600 peer-disabled:opacity-50"></div>
@@ -0,0 +1,77 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { createSignal } from 'solid-js';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { RecoverySettingsPanel } from '../RecoverySettingsPanel';
import { UpdatesSettingsPanel } from '../UpdatesSettingsPanel';
describe('settings switch labels', () => {
afterEach(() => {
cleanup();
});
it('associates the auto-update switch with its visible label', () => {
const [updateChannel, setUpdateChannel] = createSignal<'stable' | 'rc'>('stable');
const [autoUpdateEnabled, setAutoUpdateEnabled] = createSignal(false);
const [autoUpdateCheckInterval, setAutoUpdateCheckInterval] = createSignal(24);
const [autoUpdateTime, setAutoUpdateTime] = createSignal('03:00');
render(() => (
<UpdatesSettingsPanel
versionInfo={() => null}
updateInfo={() => null}
checkingForUpdates={() => false}
updateChannel={updateChannel}
setUpdateChannel={setUpdateChannel}
autoUpdateEnabled={autoUpdateEnabled}
setAutoUpdateEnabled={setAutoUpdateEnabled}
autoUpdateCheckInterval={autoUpdateCheckInterval}
setAutoUpdateCheckInterval={setAutoUpdateCheckInterval}
autoUpdateTime={autoUpdateTime}
setAutoUpdateTime={setAutoUpdateTime}
checkForUpdates={vi.fn().mockResolvedValue(undefined)}
setHasUnsavedChanges={vi.fn()}
updatePlan={() => null}
onInstallUpdate={vi.fn()}
isInstalling={() => false}
/>
));
expect(screen.getByRole('checkbox', { name: 'Automatic Stable Updates' })).toBeInTheDocument();
});
it('associates the backup polling switch with its visible label', () => {
const [backupPollingEnabled, setBackupPollingEnabled] = createSignal(true);
const [backupPollingInterval, setBackupPollingInterval] = createSignal(3600);
const [backupPollingCustomMinutes, setBackupPollingCustomMinutes] = createSignal(60);
const [backupPollingUseCustom, setBackupPollingUseCustom] = createSignal(false);
const [showExportDialog, setShowExportDialog] = createSignal(false);
const [showImportDialog, setShowImportDialog] = createSignal(false);
const [useCustomPassphrase, setUseCustomPassphrase] = createSignal(false);
render(() => (
<RecoverySettingsPanel
backupPollingEnabled={backupPollingEnabled}
setBackupPollingEnabled={setBackupPollingEnabled}
backupPollingInterval={backupPollingInterval}
setBackupPollingInterval={setBackupPollingInterval}
backupPollingCustomMinutes={backupPollingCustomMinutes}
setBackupPollingCustomMinutes={setBackupPollingCustomMinutes}
backupPollingUseCustom={backupPollingUseCustom}
setBackupPollingUseCustom={setBackupPollingUseCustom}
backupPollingEnvLocked={() => false}
backupIntervalSelectValue={() => '3600'}
backupIntervalSummary={() => 'Every hour'}
setHasUnsavedChanges={vi.fn()}
showExportDialog={showExportDialog}
setShowExportDialog={setShowExportDialog}
showImportDialog={showImportDialog}
setShowImportDialog={setShowImportDialog}
setUseCustomPassphrase={setUseCustomPassphrase}
securityStatus={() => ({ hasAuthentication: true })}
/>
));
expect(screen.getByRole('checkbox', { name: 'Enable backup polling' })).toBeChecked();
expect(useCustomPassphrase()).toBe(false);
});
});
@@ -39,6 +39,10 @@ describe('AlertAppriseDestinationsSection', () => {
it('associates visible labels with CLI mode fields', () => {
renderSection(makeConfig({ mode: 'cli' }));
expect(screen.getByRole('button', { name: 'Apprise notifications Enabled' })).toHaveAttribute(
'aria-pressed',
'true',
);
expect(screen.getByRole('combobox', { name: 'Delivery mode' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'Delivery targets' })).toBeInTheDocument();
expect(screen.getByRole('textbox', { name: 'CLI path' })).toBeInTheDocument();
@@ -51,6 +51,7 @@ interface AlertAppriseDestinationsSectionProps {
export function AlertAppriseDestinationsSection(props: AlertAppriseDestinationsSectionProps) {
const fieldIdPrefix = `alert-apprise-${createUniqueId()}`;
const fieldIds = {
title: `${fieldIdPrefix}-title`,
targets: `${fieldIdPrefix}-targets`,
cliPath: `${fieldIdPrefix}-cli-path`,
serverUrl: `${fieldIdPrefix}-server-url`,
@@ -63,6 +64,7 @@ export function AlertAppriseDestinationsSection(props: AlertAppriseDestinationsS
return (
<SettingsPanel
titleId={fieldIds.title}
title={ALERT_DESTINATIONS_APPRISE_PANEL_TITLE}
description={ALERT_DESTINATIONS_APPRISE_PANEL_DESCRIPTION}
action={
@@ -73,6 +75,7 @@ export function AlertAppriseDestinationsSection(props: AlertAppriseDestinationsS
props.updateApprise({ enabled: event.currentTarget.checked });
props.setHasUnsavedChanges(true);
}}
ariaLabelledBy={fieldIds.title}
label={
<span class="text-xs font-medium text-muted">
{getAlertDestinationsStatusLabel(props.config.enabled)}
@@ -0,0 +1,53 @@
import { cleanup, render, screen } from '@solidjs/testing-library';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { AlertEmailDestinationsSection } from './AlertEmailDestinationsSection';
import type { UIEmailConfig } from './types';
vi.mock('@/api/notifications', () => ({
NotificationsAPI: {
getEmailProviders: vi.fn().mockResolvedValue([]),
},
}));
function makeConfig(overrides: Partial<UIEmailConfig> = {}): UIEmailConfig {
return {
enabled: false,
provider: '',
server: '',
port: 587,
username: '',
password: '',
from: '',
to: [],
tls: false,
startTLS: true,
replyTo: '',
maxRetries: 3,
retryDelay: 60,
rateLimit: 10,
...overrides,
};
}
describe('AlertEmailDestinationsSection', () => {
afterEach(() => {
cleanup();
});
it('associates the visible panel heading with its status toggle', () => {
render(() => (
<AlertEmailDestinationsSection
config={makeConfig()}
setConfig={vi.fn()}
setHasUnsavedChanges={vi.fn()}
onTest={vi.fn()}
testing={false}
/>
));
expect(screen.getByRole('button', { name: 'Email notifications Disabled' })).toHaveAttribute(
'aria-pressed',
'false',
);
});
});
@@ -1,3 +1,5 @@
import { createUniqueId } from 'solid-js';
import { EmailProviderSelect } from '@/components/Alerts/EmailProviderSelect';
import { SettingsPanel } from '@/components/shared/SettingsPanel';
import { Toggle } from '@/components/shared/Toggle';
@@ -17,8 +19,11 @@ interface AlertEmailDestinationsSectionProps {
}
export function AlertEmailDestinationsSection(props: AlertEmailDestinationsSectionProps) {
const titleId = `alert-email-destinations-${createUniqueId()}-title`;
return (
<SettingsPanel
titleId={titleId}
title={ALERT_DESTINATIONS_EMAIL_PANEL_TITLE}
description={ALERT_DESTINATIONS_EMAIL_PANEL_DESCRIPTION}
action={
@@ -32,6 +37,7 @@ export function AlertEmailDestinationsSection(props: AlertEmailDestinationsSecti
props.setHasUnsavedChanges(true);
}}
containerClass="sm:self-start"
ariaLabelledBy={titleId}
label={
<span class="text-xs font-medium text-muted">
{getAlertDestinationsStatusLabel(props.config.enabled)}