From 3d3dcb0227aa42585ea049ea807470eed4f108c7 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 19 Sep 2026 02:06:50 -0700 Subject: [PATCH] Hover cards for domains and people, and defaults in option tooltips - A domain's name in any list opens a card on hover: whether it's taking mail, how many people it has, which of its key records (mail routing, SPF, DKIM, DMARC) are live in public DNS, and whether DNS, signing keys and certificates are automatic. - A person's shows their name, storage against their quota, role, groups and when they joined. The server keeps no last sign-in on the account, so the card doesn't claim one. - Cards load on open and are cached for a minute. - Option tooltips end with the option's default, and an option set away from its default gets a small "changed" mark. --- src/components/forms/FieldWidget.tsx | 20 ++- src/components/lists/DynamicList.tsx | 40 +++-- src/features/hovercards/ObjectHoverCard.tsx | 182 ++++++++++++++++++++ src/features/hovercards/facts.ts | 147 ++++++++++++++++ src/help/HelpTip.tsx | 18 +- src/help/defaults.test.ts | 55 ++++++ src/help/defaults.ts | 74 ++++++++ 7 files changed, 519 insertions(+), 17 deletions(-) create mode 100644 src/features/hovercards/ObjectHoverCard.tsx create mode 100644 src/features/hovercards/facts.ts create mode 100644 src/help/defaults.test.ts create mode 100644 src/help/defaults.ts diff --git a/src/components/forms/FieldWidget.tsx b/src/components/forms/FieldWidget.tsx index ce62d3f..89bbb64 100644 --- a/src/components/forms/FieldWidget.tsx +++ b/src/components/forms/FieldWidget.tsx @@ -11,6 +11,7 @@ import { useTranslation } from 'react-i18next'; import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue'; import { HelpTip } from '@/help/HelpTip'; import { fieldHelp } from '@/help/texts'; +import { describeDefault, differsFromDefault } from '@/help/defaults'; import { Input } from '@/components/ui/input'; import { Textarea } from '@/components/ui/textarea'; import { Button } from '@/components/ui/button'; @@ -88,6 +89,15 @@ export function FieldWidget(props: FieldWidgetProps) { const { t } = useTranslation(); const { field, formField, value, onChange, readOnly, error, schema, sieveScriptName, helpScope } = props; const helpId = helpScope ? `${helpScope}.${formField.name}` : undefined; + // INBUXA: the option's default, for its tooltip, and whether it has been changed. + const defaultValue = helpScope ? schema.fields[helpScope]?.defaults?.[formField.name] : undefined; + const defaultWords = describeDefault(field, defaultValue, schema, { + on: t('field.on', 'On'), + off: t('field.off', 'Off'), + none: t('field.none', 'None'), + }); + const defaultNote = defaultWords ? t('field.default', 'Default: {{value}}', { value: defaultWords }) : null; + const changed = defaultWords !== null && differsFromDefault(value, defaultValue); const ft = field.type; const edition = useEffectiveEdition(); @@ -237,7 +247,15 @@ export function FieldWidget(props: FieldWidgetProps) { )} - + + {changed && ( + + {t('field.changed', 'changed')} + + )} {widget} {sieveScriptName !== undefined && ft.type === 'string' && ( diff --git a/src/components/lists/DynamicList.tsx b/src/components/lists/DynamicList.tsx index 2d04564..80840a7 100644 --- a/src/components/lists/DynamicList.tsx +++ b/src/components/lists/DynamicList.tsx @@ -8,6 +8,7 @@ import { EmptyState } from '@/components/common/EmptyState'; import { PageHeader } from '@/components/common/PageHeader'; import { HelpPanel } from '@/help/HelpPanel'; +import { ObjectHoverCard } from '@/features/hovercards/ObjectHoverCard'; import { iconForView } from '@/lib/viewIcon'; import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { useNavigate, useSearchParams } from 'react-router-dom'; @@ -270,7 +271,11 @@ function renderCellValue( case 'objectId': { const id = String(value); const display = getDisplayName(ft.objectName, id); - return display ?? id; + return ( + + {display ?? id} + + ); } case 'set': { @@ -1305,18 +1310,27 @@ export function DynamicList({ viewName }: DynamicListProps) { /> )} - {list.columns.map((col) => ( - - {renderCellValue( - item[col.name], - fields[col.name], - col.name, - schema!, - resolved.obj.objectName, - getDisplayName, - )} - - ))} + {list.columns.map((col, colIndex) => { + const cell = renderCellValue( + item[col.name], + fields[col.name], + col.name, + schema!, + resolved.obj.objectName, + getDisplayName, + ); + return ( + + {colIndex === 0 && item[col.name] != null ? ( + + {cell} + + ) : ( + cell + )} + + ); + })} {hasItemActions && {renderItemActions(item)}} ); diff --git a/src/features/hovercards/ObjectHoverCard.tsx b/src/features/hovercards/ObjectHoverCard.tsx new file mode 100644 index 0000000..bddebe3 --- /dev/null +++ b/src/features/hovercards/ObjectHoverCard.tsx @@ -0,0 +1,182 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +import { useEffect, useState, type ReactNode } from 'react'; +import { useTranslation } from 'react-i18next'; +import { CheckCircle2, CircleDashed, Globe, Loader2, UserRound } from 'lucide-react'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { formatSize } from '@/lib/durationFormat'; +import { cn } from '@/lib/utils'; +import { CARD_OBJECTS, loadCardFacts, type CardFacts, type DomainFacts, type PersonFacts } from './facts'; + +const KIND_LABEL: Record = { mx: 'Mail routing', spf: 'SPF', dkim: 'DKIM', dmarc: 'DMARC' }; + +function Row({ label, children }: { label: string; children: ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +function DomainCard({ f }: { f: DomainFacts }) { + const { t } = useTranslation(); + const auto = (m: string) => (m === 'Automatic' ? t('hover.automatic', 'Automatic') : t('hover.manual', 'Manual')); + const live = f.checks.filter((c) => c.live).length; + return ( +
+
+ + + +
+

{f.name}

+

+ {f.enabled ? t('hover.enabled', 'Receiving mail') : t('hover.disabled', 'Turned off')} + {f.people !== undefined && + ` · ${t('hover.people', { count: f.people, defaultValue_one: '{{count}} person', defaultValue_other: '{{count}} people' })}`} +

+
+
+ {f.checks.length > 0 && ( +
+

+ {live === f.checks.length + ? t('hover.dnsAllLive', 'DNS is in place') + : t('hover.dnsSome', '{{live}} of {{total}} key records live', { live, total: f.checks.length })} +

+
+ {f.checks.map((c) => ( + + {c.live ? : } + {KIND_LABEL[c.kind] ?? c.kind} + + ))} +
+
+ )} +
+ {auto(f.dns)} + {auto(f.dkim)} + {auto(f.certs)} +
+
+ ); +} + +function PersonCard({ f }: { f: PersonFacts }) { + const { t, i18n } = useTranslation(); + const pct = f.quota ? Math.min(100, Math.round((f.used / f.quota) * 100)) : null; + const roleLabel = + f.role === 'Admin' + ? t('hover.roleAdmin', 'Administrator') + : f.role === 'Custom' + ? t('hover.roleCustom', 'Custom role') + : t('hover.roleUser', 'User'); + return ( +
+
+ + + +
+

{f.name ?? f.address}

+ {f.name &&

{f.address}

} +
+
+
+
+ {t('hover.storage', 'Storage')} + + {formatSize(f.used)} + {f.quota ? ` / ${formatSize(f.quota)}` : ''} + +
+ {pct !== null && ( +
+
= 90 ? 'bg-rose-500' : pct >= 75 ? 'bg-amber-500' : 'bg-primary', + )} + style={{ width: `${pct}%` }} + /> +
+ )} +
+
+ {roleLabel} + {f.groups > 0 && {f.groups}} + {f.createdAt && ( + + {new Date(f.createdAt).toLocaleDateString(i18n.language, { dateStyle: 'medium' })} + + )} +
+
+ ); +} + +function CardBody({ objectName, id }: { objectName: string; id: string }) { + const { t } = useTranslation(); + const [facts, setFacts] = useState(undefined); + useEffect(() => { + let live = true; + loadCardFacts(objectName, id).then((f) => { + if (live) setFacts(f); + }); + return () => { + live = false; + }; + }, [objectName, id]); + if (facts === undefined) + return ( +
+ + {t('hover.loading', 'Looking…')} +
+ ); + if (!facts) return

{t('hover.unavailable', 'No details available.')}

; + return facts.kind === 'domain' ? : ; +} + +/** + * A card with the essentials of a domain or person, on hover or focus of its + * name in a list, so a glance answers "is this one healthy?" without + * opening it. Other objects render their name unchanged. + */ +export function ObjectHoverCard({ objectName, id, children }: { objectName: string; id: string; children: ReactNode }) { + const [open, setOpen] = useState(false); + if (!CARD_OBJECTS.has(objectName) || !id) return <>{children}; + return ( + + + + + {children} + + + e.stopPropagation()} + > + {open && } + + + + ); +} diff --git a/src/features/hovercards/facts.ts b/src/features/hovercards/facts.ts new file mode 100644 index 0000000..9f3aa9b --- /dev/null +++ b/src/features/hovercards/facts.ts @@ -0,0 +1,147 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * What a hover card shows, fetched when the card opens and kept for a + * minute, so moving across a list doesn't ask the server twice. + */ +import { getAccountId, jmapRequest } from '@/services/jmap/client'; +import { parseZone, type RecordKind, type ZoneRecord } from '@/features/dns/zone'; +import { checkRecords } from '@/features/dns/liveCheck'; + +export interface DomainFacts { + kind: 'domain'; + name: string; + enabled: boolean; + people?: number; + dns: string; + dkim: string; + certs: string; + /** The core records, and which are live in public DNS. */ + checks: { kind: RecordKind; live: boolean }[]; +} + +export interface PersonFacts { + kind: 'person'; + address: string; + name?: string; + used: number; + quota: number | null; + role?: string; + groups: number; + createdAt?: string; +} + +export type CardFacts = DomainFacts | PersonFacts; + +/** The records a domain can't work without, in the order the card lists them. */ +export const CORE_KINDS: RecordKind[] = ['mx', 'spf', 'dkim', 'dmarc']; + +const TTL_MS = 60_000; +const cache = new Map }>(); + +function mode(value: unknown): string { + return ((value as { '@type'?: string } | undefined)?.['@type'] ?? 'Manual').toString(); +} + +/** Of the domain's core records, which are live. Kinds with no records (DKIM before keys exist) are left out. */ +export async function coreChecks(zone: ZoneRecord[], domain: string): Promise<{ kind: RecordKind; live: boolean }[]> { + const apex = domain.toLowerCase(); + const core = zone.filter((r) => CORE_KINDS.includes(r.kind) && (r.kind !== 'spf' || r.name.toLowerCase() === apex)); + const states = await checkRecords(core); + return CORE_KINDS.filter((k) => core.some((r) => r.kind === k)).map((kind) => ({ + kind, + live: core.filter((r) => r.kind === kind).every((r) => states.get(r) === 'live'), + })); +} + +async function domainFacts(id: string): Promise { + const accountId = getAccountId('x:Domain'); + const responses = await jmapRequest([ + [ + 'x:Domain/get', + { + accountId, + ids: [id], + properties: ['name', 'isEnabled', 'dnsManagement', 'dkimManagement', 'certificateManagement', 'dnsZoneFile'], + }, + 'd', + ], + [ + 'x:Account/query', + { accountId: getAccountId('x:Account'), filter: { domainId: id }, limit: 1, calculateTotal: true }, + 'n', + ], + ]); + const d = (responses.find((r) => r[2] === 'd')?.[1] as { list?: Record[] })?.list?.[0]; + if (!d) return null; + const count = responses.find((r) => r[2] === 'n' && r[0] !== 'error')?.[1] as { total?: number } | undefined; + const name = String(d.name); + const checks = await coreChecks(parseZone(d.dnsZoneFile as string | undefined), name).catch( + (): DomainFacts['checks'] => [], + ); + return { + kind: 'domain', + name, + enabled: d.isEnabled !== false, + people: count?.total, + dns: mode(d.dnsManagement), + dkim: mode(d.dkimManagement), + certs: mode(d.certificateManagement), + checks, + }; +} + +async function personFacts(id: string): Promise { + const responses = await jmapRequest([ + [ + 'x:Account/get', + { + accountId: getAccountId('x:Account'), + ids: [id], + properties: [ + '@type', + 'emailAddress', + 'name', + 'description', + 'usedDiskQuota', + 'quotas', + 'roles', + 'memberGroupIds', + 'createdAt', + ], + }, + 'a', + ], + ]); + const a = (responses[0]?.[1] as { list?: Record[] })?.list?.[0]; + if (!a) return null; + const quotas = (a.quotas ?? {}) as Record; + const quota = typeof quotas.maxDiskQuota === 'number' && quotas.maxDiskQuota > 0 ? quotas.maxDiskQuota : null; + const groups = a.memberGroupIds && typeof a.memberGroupIds === 'object' ? Object.keys(a.memberGroupIds).length : 0; + return { + kind: 'person', + address: String(a.emailAddress ?? a.name ?? id), + name: typeof a.description === 'string' && a.description.trim() ? a.description.trim() : undefined, + used: typeof a.usedDiskQuota === 'number' ? a.usedDiskQuota : 0, + quota, + role: (a.roles as { '@type'?: string } | undefined)?.['@type'], + groups, + createdAt: typeof a.createdAt === 'string' ? a.createdAt : undefined, + }; +} + +/** Object types that have a hover card. */ +export const CARD_OBJECTS = new Set(['x:Domain', 'x:Account']); + +export function loadCardFacts(objectName: string, id: string): Promise { + const key = `${objectName}|${id}`; + const hit = cache.get(key); + if (hit && Date.now() - hit.at < TTL_MS) return hit.facts; + const facts = (objectName === 'x:Domain' ? domainFacts(id) : personFacts(id)).catch(() => null); + cache.set(key, { at: Date.now(), facts }); + return facts; +} diff --git a/src/help/HelpTip.tsx b/src/help/HelpTip.tsx index 9485a97..4162174 100644 --- a/src/help/HelpTip.tsx +++ b/src/help/HelpTip.tsx @@ -16,9 +16,20 @@ import { manualUrl } from './manual'; * focus, and a way into the manual once there is one. `id` is the option's * stable help id, the key the manual links hang on. */ -export function HelpTip({ id, text, className }: { id?: string; text?: string | null; className?: string }) { +export function HelpTip({ + id, + text, + footnote, + className, +}: { + id?: string; + text?: string | null; + /** A short line under the text, like the option's default. */ + footnote?: string | null; + className?: string; +}) { const { t } = useTranslation(); - if (!text) return null; + if (!text && !footnote) return null; const more = id ? manualUrl(id) : null; return ( @@ -42,8 +53,9 @@ export function HelpTip({ id, text, className }: { id?: string; text?: string | className="max-w-xs space-y-1.5 border bg-popover px-3 py-2 text-xs leading-relaxed text-popover-foreground shadow-soft" >
- {text.replace(/\\n/g, '\n')} + {text && {text.replace(/\\n/g, '\n')}}
+ {footnote &&

{footnote}

} {more && ( ) => ({ description: '', update: 'mutable', type }) as unknown as Field; + +describe('describeDefault', () => { + it('says defaults the way people would', () => { + expect(describeDefault(f({ type: 'boolean' }), true, schema, words)).toBe('On'); + expect(describeDefault(f({ type: 'number', format: 'duration' }), 300000, schema, words)).toBe('5m'); + expect(describeDefault(f({ type: 'enum', enumName: 'Proto' }), 'udp', schema, words)).toBe('UDP'); + expect(describeDefault(f({ type: 'object', objectName: 'x:Mgmt' }), { '@type': 'Manual' }, schema, words)).toBe( + 'Manual DNS management', + ); + expect( + describeDefault( + f({ type: 'set', class: { type: 'enum', enumName: 'RecType' } }), + { mx: true, spf: true }, + schema, + words, + ), + ).toBe('MX records, SPF records'); + expect(describeDefault(f({ type: 'string' }), 'mailto:postmaster', schema, words)).toBe('mailto:postmaster'); + }); + + it('stays quiet without a default', () => { + expect(describeDefault(f({ type: 'string' }), undefined, schema, words)).toBeNull(); + }); +}); + +describe('differsFromDefault', () => { + it('compares by value, ignoring key order', () => { + expect(differsFromDefault({ a: 1, b: 2 }, { b: 2, a: 1 })).toBe(false); + expect(differsFromDefault(600000, 300000)).toBe(true); + expect(differsFromDefault(undefined, 300000)).toBe(false); + expect(differsFromDefault(true, undefined)).toBe(false); + }); +}); diff --git a/src/help/defaults.ts b/src/help/defaults.ts new file mode 100644 index 0000000..a1628e2 --- /dev/null +++ b/src/help/defaults.ts @@ -0,0 +1,74 @@ +/* + * SPDX-FileCopyrightText: 2026 Coffey Labs + * + * SPDX-License-Identifier: AGPL-3.0-only + */ + +/** + * An option's default, as words, and whether the current value differs + * from it, so tooltips can say "Default: 5 min" and a form can mark what + * someone has changed. + */ +import type { Field, Schema } from '@/types/schema'; +import { formatDuration, formatSize } from '@/lib/durationFormat'; + +function stable(v: unknown): string { + if (v && typeof v === 'object' && !Array.isArray(v)) { + const o = v as Record; + return `{${Object.keys(o) + .sort() + .map((k) => `${JSON.stringify(k)}:${stable(o[k])}`) + .join(',')}}`; + } + return JSON.stringify(v ?? null); +} + +/** Does the value differ from the default? An unset value counts as the default. */ +export function differsFromDefault(value: unknown, def: unknown): boolean { + if (def === undefined) return false; + if (value === undefined || value === null) return false; + return stable(value) !== stable(def); +} + +/** The default as someone would say it, or null when there's nothing short to say. */ +export function describeDefault( + field: Field, + def: unknown, + schema: Schema, + words: { on: string; off: string; none: string }, +): string | null { + if (def === undefined) return null; + if (def === null) return words.none; + const ft = field.type as { type: string; format?: string; enumName?: string; objectName?: string }; + if (typeof def === 'boolean') return def ? words.on : words.off; + if (typeof def === 'number') { + if (ft.format === 'duration') return formatDuration(def); + if (ft.format === 'size') return formatSize(def); + return String(def); + } + if (typeof def === 'string') { + if (ft.type === 'enum' && ft.enumName) { + return schema.enums[ft.enumName]?.find((e) => e.name === def)?.label ?? def; + } + return def.length > 60 ? null : def; + } + if (typeof def === 'object' && !Array.isArray(def)) { + const variant = (def as Record)['@type']; + if (typeof variant === 'string' && ft.objectName) { + const sch = schema.schemas[ft.objectName]; + const label = sch?.type === 'multiple' ? sch.variants.find((v) => v.name === variant)?.label : undefined; + return label ?? variant; + } + const keys = Object.keys(def as Record); + if (ft.type === 'set') { + if (keys.length === 0) return words.none; + if (ft.enumName || (field.type as { class?: { enumName?: string } }).class?.enumName) { + const en = (field.type as { class?: { enumName?: string } }).class?.enumName ?? ft.enumName!; + const labels = keys.map((k) => schema.enums[en]?.find((e) => e.name === k)?.label ?? k); + return labels.length > 4 ? `${labels.slice(0, 4).join(', ')}…` : labels.join(', '); + } + return keys.length > 4 ? `${keys.slice(0, 4).join(', ')}…` : keys.join(', '); + } + } + return null; +}