fix: name the template, advanced search and admin server fields

This commit is contained in:
Linus Rath
2026-10-29 18:03:00 +01:00
parent 0e2a05e0ea
commit f73643bbbd
6 changed files with 110 additions and 38 deletions
@@ -1,6 +1,6 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import { useEffect, useId, useRef, useState } from 'react';
import { Plus, Trash2, ChevronDown, ChevronRight } from '@/components/icons';
import type { JmapServerEntry } from '@/lib/admin/jmap-servers';
import { Button } from '@/components/ui/button';
@@ -101,6 +101,9 @@ const fieldClass = cn(inputClass, 'w-full');
export function JmapServersSection({ value, source, onChange, onRevert }: Props) {
const [drafts, setDrafts] = useState<RowDraft[]>(() => value.map(entryToDraft));
// Ids that tie each row's labels to its fields.
const idBase = useId();
const labelFor = (row: number) => (key: string) => `${idBase}-${row}-${key}`;
const lastEmittedRef = useRef(value);
useEffect(() => {
@@ -166,8 +169,9 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
<div key={i} className="rounded-md border border-border bg-muted/20 p-3 space-y-2">
<div className="grid grid-cols-1 sm:grid-cols-12 gap-2 items-start">
<div className="sm:col-span-3">
<label className={fieldLabelClass}>ID</label>
<label htmlFor={labelFor(i)("id")} className={fieldLabelClass}>ID</label>
<input
id={labelFor(i)("id")}
type="text"
value={d.id}
onChange={(e) => update(i, { id: e.target.value })}
@@ -177,8 +181,9 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
{isDuplicate && <p className="text-[10px] text-destructive mt-0.5">Duplicate id</p>}
</div>
<div className="sm:col-span-4">
<label className={fieldLabelClass}>Label</label>
<label htmlFor={labelFor(i)("label")} className={fieldLabelClass}>Label</label>
<input
id={labelFor(i)("label")}
type="text"
value={d.label}
onChange={(e) => update(i, { label: e.target.value })}
@@ -187,9 +192,10 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
/>
</div>
<div className="sm:col-span-5">
<label className={fieldLabelClass}>JMAP URL</label>
<label htmlFor={labelFor(i)("url")} className={fieldLabelClass}>JMAP URL</label>
<div className="flex items-center gap-2">
<input
id={labelFor(i)("url")}
type="url"
value={d.url}
onChange={(e) => update(i, { url: e.target.value })}
@@ -209,10 +215,11 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
</div>
</div>
<div>
<label className={fieldLabelClass}>
<label htmlFor={labelFor(i)("domains")} className={fieldLabelClass}>
Email domains (comma-separated, used for auto-pick)
</label>
<input
id={labelFor(i)("domains")}
type="text"
value={d.domains}
onChange={(e) => update(i, { domains: e.target.value })}
@@ -231,8 +238,9 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
{d.oauthExpanded && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2 ps-4 border-s border-border">
<div>
<label className={fieldLabelClass}>OAuth Client ID</label>
<label htmlFor={labelFor(i)("oauth-client-id")} className={fieldLabelClass}>OAuth Client ID</label>
<input
id={labelFor(i)("oauth-client-id")}
type="text"
value={d.oauthClientId}
onChange={(e) => update(i, { oauthClientId: e.target.value })}
@@ -240,8 +248,9 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
/>
</div>
<div>
<label className={fieldLabelClass}>OAuth Issuer URL</label>
<label htmlFor={labelFor(i)("oauth-issuer")} className={fieldLabelClass}>OAuth Issuer URL</label>
<input
id={labelFor(i)("oauth-issuer")}
type="url"
value={d.oauthIssuerUrl}
onChange={(e) => update(i, { oauthIssuerUrl: e.target.value })}
@@ -250,8 +259,9 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
/>
</div>
<div>
<label className={fieldLabelClass}>OAuth Client Secret</label>
<label htmlFor={labelFor(i)("oauth-secret")} className={fieldLabelClass}>OAuth Client Secret</label>
<input
id={labelFor(i)("oauth-secret")}
type="password"
value={d.oauthClientSecret}
onChange={(e) => update(i, { oauthClientSecret: e.target.value })}
@@ -260,8 +270,9 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
/>
</div>
<div className="sm:col-span-3">
<label className={fieldLabelClass}>Sign-in button label</label>
<label htmlFor={labelFor(i)("oauth-button")} className={fieldLabelClass}>Sign-in button label</label>
<input
id={labelFor(i)("oauth-button")}
type="text"
value={d.oauthButtonLabel}
onChange={(e) => update(i, { oauthButtonLabel: e.target.value })}
@@ -274,8 +285,9 @@ export function JmapServersSection({ value, source, onChange, onRevert }: Props)
</p>
</div>
<div className="sm:col-span-3">
<label className={fieldLabelClass}>Provider logout URL</label>
<label htmlFor={labelFor(i)("logout-url")} className={fieldLabelClass}>Provider logout URL</label>
<input
id={labelFor(i)("logout-url")}
type="url"
value={d.oauthEndSessionUrl}
onChange={(e) => update(i, { oauthEndSessionUrl: e.target.value })}
+27
View File
@@ -3,6 +3,10 @@ import { describe, expect, it, vi } from 'vitest';
import { unnamedFields } from '@/lib/__tests__/helpers/form-labels';
import { ContactForm } from '@/components/contacts/contact-form';
import type { ContactCard } from '@/lib/jmap/types';
import { TemplateForm } from '@/components/templates/template-form';
import { MvSearchForm } from '@/components/search/mv-search-form';
import { DEFAULT_SEARCH_FILTERS } from '@/lib/jmap/search-utils';
import { JmapServersSection } from '@/app/(main)/admin/_pages/_jmap-servers-section';
// Every field of the big forms has a name a screen reader reads out (WCAG
// 1.3.1, 3.3.2, 4.1.2): a <label> tied to it or an aria-label, never just
@@ -36,4 +40,27 @@ describe('form fields have labels', () => {
expect(document.querySelectorAll('input, select, textarea').length).toBeGreaterThan(20);
expect(unnamedFields()).toEqual([]);
});
it('template form', () => {
render(<TemplateForm onSave={vi.fn()} onCancel={vi.fn()} />);
expect(unnamedFields()).toEqual([]);
});
it('advanced search form', () => {
render(<MvSearchForm filters={DEFAULT_SEARCH_FILTERS} onChange={vi.fn()} onSearch={vi.fn()} onClear={vi.fn()} />);
expect(document.querySelectorAll('input').length).toBeGreaterThan(5);
expect(unnamedFields()).toEqual([]);
});
it('admin server list, with two servers', () => {
render(
<JmapServersSection
value={[{ id: 'a', label: 'A', url: 'https://a.example' }, { id: 'b', label: 'B', url: 'https://b.example' }] as never}
onChange={vi.fn()}
onRevert={vi.fn()}
/>,
);
expect(document.querySelectorAll('input').length).toBeGreaterThanOrEqual(8);
expect(unnamedFields()).toEqual([]);
});
});
+18 -9
View File
@@ -1222,7 +1222,8 @@ export function EventModal({
<div className="px-6 py-4 space-y-4">
<div>
<label htmlFor={labelFor("title")} className="text-sm font-medium mb-1 block">{t("form.title")}</label>
<Input id={labelFor("title")}
<Input
id={labelFor("title")}
ref={titleRef}
value={title}
onChange={(e) => setTitle(e.target.value)}
@@ -1233,7 +1234,8 @@ export function EventModal({
<div>
<label htmlFor={labelFor("description")} className="text-sm font-medium mb-1 block">{t("form.description")}</label>
<textarea id={labelFor("description")}
<textarea
id={labelFor("description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t("form.description")}
@@ -1245,7 +1247,8 @@ export function EventModal({
<div>
<label htmlFor={labelFor("location")} className="text-sm font-medium mb-1 block">{t("form.location")}</label>
<Input id={labelFor("location")}
<Input
id={labelFor("location")}
value={location}
onChange={(e) => setLocation(e.target.value)}
placeholder={t("form.location")}
@@ -1344,7 +1347,8 @@ export function EventModal({
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
<div>
<label htmlFor={labelFor("start_date")} className="text-sm font-medium mb-1 block">{t("form.start_date")}</label>
<input id={labelFor("start_date")}
<input
id={labelFor("start_date")}
type="date"
value={startDate}
onChange={(e) => handleStartDateChange(e.target.value)}
@@ -1354,7 +1358,8 @@ export function EventModal({
{!allDay && (
<div>
<label htmlFor={labelFor("start_time")} className="text-sm font-medium mb-1 block">{t("form.start_time")}</label>
<input id={labelFor("start_time")}
<input
id={labelFor("start_time")}
type="time"
value={startTime}
onChange={(e) => handleStartTimeChange(e.target.value)}
@@ -1364,7 +1369,8 @@ export function EventModal({
)}
<div>
<label htmlFor={labelFor("end_date")} className="text-sm font-medium mb-1 block">{t("form.end_date")}</label>
<input id={labelFor("end_date")}
<input
id={labelFor("end_date")}
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
@@ -1374,7 +1380,8 @@ export function EventModal({
{!allDay && (
<div>
<label htmlFor={labelFor("end_time")} className="text-sm font-medium mb-1 block">{t("form.end_time")}</label>
<input id={labelFor("end_time")}
<input
id={labelFor("end_time")}
type="time"
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
@@ -1407,7 +1414,8 @@ export function EventModal({
{selectableCalendars.length > 1 && (
<div>
<label htmlFor={labelFor("calendar_select")} className="text-sm font-medium mb-1 block">{t("form.calendar_select")}</label>
<select id={labelFor("calendar_select")}
<select
id={labelFor("calendar_select")}
value={calendarId}
onChange={(e) => setCalendarId(e.target.value)}
className="w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
@@ -1424,7 +1432,8 @@ export function EventModal({
<div>
<label htmlFor={labelFor("title-1")} className="text-sm font-medium mb-1 block">{t("recurrence.title")}</label>
<div className="flex items-center gap-2">
<select id={labelFor("title-1")}
<select
id={labelFor("title-1")}
value={recurrence}
onChange={(e) => {
const value = e.target.value as RecurrenceOption;
+2
View File
@@ -65,6 +65,7 @@ export function MvSearchForm({ filters, onChange, onSearch, onClear, folderSelec
inputMode="numeric"
className={FIELD}
placeholder={t("size_min")}
aria-label={t("size_min")}
value={filters.minSizeKb}
onChange={(e) => onChange({ minSizeKb: e.target.value })}
/>
@@ -74,6 +75,7 @@ export function MvSearchForm({ filters, onChange, onSearch, onClear, folderSelec
inputMode="numeric"
className={FIELD}
placeholder={t("size_max")}
aria-label={t("size_max")}
value={filters.maxSizeKb}
onChange={(e) => onChange({ maxSizeKb: e.target.value })}
/>
@@ -87,7 +87,8 @@ function PasswordChangeSection() {
<div>
<label htmlFor={labelFor("current")} className="text-xs text-muted-foreground mb-1 block">{t('password.current')}</label>
<div className="relative">
<Input id={labelFor("current")}
<Input
id={labelFor("current")}
type={showCurrent ? 'text' : 'password'}
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
@@ -108,7 +109,8 @@ function PasswordChangeSection() {
{otpEnabled && (
<div>
<label htmlFor={labelFor("verification_code")} className="text-xs text-muted-foreground mb-1 block">{t('totp.verification_code')}</label>
<Input id={labelFor("verification_code")}
<Input
id={labelFor("verification_code")}
value={otpCode}
onChange={(e) => setOtpCode(e.target.value)}
required
@@ -121,7 +123,8 @@ function PasswordChangeSection() {
<div>
<label htmlFor={labelFor("new")} className="text-xs text-muted-foreground mb-1 block">{t('password.new')}</label>
<div className="relative">
<Input id={labelFor("new")}
<Input
id={labelFor("new")}
type={showNew ? 'text' : 'password'}
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
@@ -142,7 +145,8 @@ function PasswordChangeSection() {
</div>
<div>
<label htmlFor={labelFor("confirm")} className="text-xs text-muted-foreground mb-1 block">{t('password.confirm')}</label>
<Input id={labelFor("confirm")}
<Input
id={labelFor("confirm")}
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
@@ -567,7 +571,8 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
<form onSubmit={handleAdd} className="p-3 bg-muted rounded-md space-y-2">
<div>
<label htmlFor={labelFor("field6")} className="text-xs text-muted-foreground mb-1 block">{tk('name_label')}</label>
<Input id={labelFor("field6")}
<Input
id={labelFor("field6")}
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
placeholder={tk('name_placeholder')}
@@ -580,7 +585,8 @@ function CredentialSection({ icon: Icon, i18nNamespace, entries, onCreate, onRem
</div>
<div>
<label htmlFor={labelFor("allowed_ips_label")} className="text-xs text-muted-foreground mb-1 block">{t('app_passwords.allowed_ips_label')}</label>
<textarea id={labelFor("allowed_ips_label")}
<textarea
id={labelFor("allowed_ips_label")}
value={allowedIpsRaw}
onChange={(e) => setAllowedIpsRaw(e.target.value)}
placeholder={t('app_passwords.allowed_ips_placeholder')}
@@ -779,7 +785,8 @@ function PublicKeysSection() {
<form onSubmit={handleAdd} className="p-3 bg-muted rounded-md space-y-2">
<div>
<label htmlFor={labelFor("field9")} className="text-xs text-muted-foreground mb-1 block">{tk('name_label')}</label>
<Input id={labelFor("field9")}
<Input
id={labelFor("field9")}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={tk('name_placeholder')}
@@ -788,7 +795,8 @@ function PublicKeysSection() {
</div>
<div>
<label htmlFor={labelFor("field10")} className="text-xs text-muted-foreground mb-1 block">{tk('key_label')}</label>
<textarea id={labelFor("field10")}
<textarea
id={labelFor("field10")}
value={publicKey}
onChange={(e) => setPublicKey(e.target.value)}
placeholder={tk('key_placeholder')}
@@ -815,7 +823,8 @@ function PublicKeysSection() {
<div>
<label htmlFor={labelFor("algorithm_label")} className="text-xs text-muted-foreground mb-1 block">{t('encryption.algorithm_label')}</label>
<select id={labelFor("algorithm_label")}
<select
id={labelFor("algorithm_label")}
value={selectedAlgorithm}
onChange={(e) => setSelectedAlgorithm(e.target.value as 'Aes128' | 'Aes256')}
className="w-full text-xs px-3 py-1.5 rounded-md border border-border bg-background focus:outline-none focus:ring-2 focus:ring-ring"
+23 -10
View File
@@ -1,6 +1,6 @@
'use client';
import { useState, useMemo, useEffect, useRef } from 'react';
import { useState, useMemo, useEffect, useId, useRef } from 'react';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
@@ -43,6 +43,9 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
const t = useTranslations('templates');
const tSettings = useTranslations('settings.templates');
const tComposer = useTranslations('email_composer');
// Ids that tie each visible label to its field.
const labelIdBase = useId();
const labelFor = (key: string) => `${labelIdBase}-${key}`;
const { identities } = useAuthStore();
const browserList = useTemplateStore((s) => s.templates);
@@ -139,24 +142,28 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
return (
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-sm font-medium text-foreground">{tSettings('name')}</label>
<label htmlFor={labelFor("name")} className="text-sm font-medium text-foreground">{tSettings('name')}</label>
<Input
id={labelFor("name")}
value={name}
onChange={(e) => { setName(e.target.value); setNameError(null); }}
placeholder={tSettings('name_placeholder')}
className={cn('mt-1', nameError && 'border-destructive')}
aria-invalid={nameError ? true : undefined}
aria-describedby={nameError ? labelFor("name-error") : undefined}
autoFocus
/>
{nameError && (
<p className="text-xs text-red-600 dark:text-red-400 mt-1">
<p id={labelFor("name-error")} className="text-xs text-red-600 dark:text-red-400 mt-1">
{tSettings(`validation.${nameError}`)}
</p>
)}
</div>
<div>
<label className="text-sm font-medium text-foreground">{tSettings('category')}</label>
<label htmlFor={labelFor("category")} className="text-sm font-medium text-foreground">{tSettings('category')}</label>
<Input
id={labelFor("category")}
value={category}
onChange={(e) => setCategory(e.target.value)}
placeholder={tSettings('category_placeholder')}
@@ -174,10 +181,11 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
<div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">{tSettings('subject')}</label>
<label htmlFor={labelFor("subject")} className="text-sm font-medium text-foreground">{tSettings('subject')}</label>
{placeholderButton('subject')}
</div>
<Input
id={labelFor("subject")}
value={subject}
onChange={(e) => setSubject(e.target.value)}
placeholder={tSettings('subject_placeholder')}
@@ -187,10 +195,11 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
<div>
<div className="flex items-center justify-between">
<label className="text-sm font-medium text-foreground">{tSettings('body')}</label>
<label htmlFor={labelFor("body")} className="text-sm font-medium text-foreground">{tSettings('body')}</label>
{placeholderButton('body')}
</div>
<textarea
id={labelFor("body")}
value={body}
onChange={(e) => setBody(e.target.value)}
placeholder={tSettings('body_placeholder')}
@@ -209,8 +218,9 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div>
<label className="text-sm font-medium text-foreground">{tComposer('to')}</label>
<label htmlFor={labelFor("to")} className="text-sm font-medium text-foreground">{tComposer('to')}</label>
<Input
id={labelFor("to")}
value={toRecipients}
onChange={(e) => setToRecipients(e.target.value)}
placeholder={tSettings('recipients_placeholder')}
@@ -218,8 +228,9 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">{tComposer('cc')}</label>
<label htmlFor={labelFor("cc")} className="text-sm font-medium text-foreground">{tComposer('cc')}</label>
<Input
id={labelFor("cc")}
value={ccRecipients}
onChange={(e) => setCcRecipients(e.target.value)}
placeholder={tSettings('recipients_placeholder')}
@@ -227,8 +238,9 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
/>
</div>
<div>
<label className="text-sm font-medium text-foreground">{tComposer('bcc')}</label>
<label htmlFor={labelFor("bcc")} className="text-sm font-medium text-foreground">{tComposer('bcc')}</label>
<Input
id={labelFor("bcc")}
value={bccRecipients}
onChange={(e) => setBccRecipients(e.target.value)}
placeholder={tSettings('recipients_placeholder')}
@@ -239,8 +251,9 @@ export function TemplateForm({ template, initialData, onSave, onCancel, shared =
{!shared && identities.length > 1 && (
<div>
<label className="text-sm font-medium text-foreground">{tSettings('identity')}</label>
<label htmlFor={labelFor("identity")} className="text-sm font-medium text-foreground">{tSettings('identity')}</label>
<select
id={labelFor("identity")}
value={identityId}
onChange={(e) => setIdentityId(e.target.value)}
className="mt-1 w-full px-3 py-2 text-sm rounded-md bg-background border border-border text-foreground focus:outline-none focus:ring-2 focus:ring-primary"