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; +}