Help on every option and every page

- Each option's explanation moves from a line of text under its label to
  a small tooltip on an ⓘ beside it, so forms read calmer and the help is
  still one hover away.
- Help text is ours where written (src/help/texts.ts: domains, people,
  DNS providers, blocked addresses, and the main pages), and the schema's
  description elsewhere.
- A "?" on every list and form opens a side panel: what the page is for,
  what people usually do there, and every option explained.
- Every tooltip and panel carries a stable help id (x:Domain.dnsManagement,
  x:Domain), and manual.ts turns an id into a link to the admin manual
  once one is configured (VITE_MANUAL_URL, or <meta name="manual-url">).
  Until then no link shows.
This commit is contained in:
2026-09-19 01:59:14 -07:00
parent 7ab0b8099c
commit 824427ef46
12 changed files with 512 additions and 18 deletions
+150
View File
@@ -0,0 +1,150 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import { ArrowUpRight, CircleHelp, Lightbulb } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { useSchemaStore } from '@/stores/schemaStore';
import { resolveList, resolveObject, resolveSchema } from '@/lib/schemaResolver';
import { humanize } from '@/lib/humanize';
import type { Schema } from '@/types/schema';
import { fieldHelp, PAGE_HELP } from './texts';
import { manualUrl } from './manual';
interface OptionHelp {
id: string;
label: string;
text: string;
}
/**
* Everything the panel says about a page: what it's for, what people do
* there, and every option on its form with its explanation, in form order.
*/
function pageHelp(schema: Schema, viewName: string) {
const obj = resolveObject(schema, viewName);
if (!obj) return null;
const sch = resolveSchema(schema, obj.objectName);
const ours = PAGE_HELP[viewName] ?? PAGE_HELP[obj.objectName];
const about =
ours?.about ?? (schema.objects[obj.objectName] as { description?: string } | undefined)?.description ?? '';
// A view of one variant (People is x:Account of @type User) shows that variant's options.
const list = resolveList(schema, viewName, obj.objectName);
const variantName = (list?.filtersStatic as Record<string, unknown> | undefined)?.['@type'];
let scope = obj.objectName;
let fields = sch?.type === 'single' ? sch.fields : null;
if (sch?.type === 'multiple') {
const v = sch.variants.find((x) => x.name === variantName) ?? sch.variants.find((x) => x.fields);
scope = v?.schemaName ?? obj.objectName;
fields = v?.fields ?? null;
}
const form = schema.forms[scope] ?? schema.forms[obj.objectName];
const order = form?.sections.flatMap((s) => s.fields.map((f) => ({ name: f.name, label: f.label }))) ?? [];
const names = order.length ? order : Object.keys(fields?.properties ?? {}).map((name) => ({ name, label: '' }));
const options: OptionHelp[] = [];
for (const { name, label } of names) {
const field = fields?.properties[name];
if (!field || field.update === 'serverSet' || name === '@type') continue;
const id = `${scope}.${name}`;
const text = fieldHelp(id, field.description);
if (text) options.push({ id, label: label || humanize(name), text });
}
return { id: obj.objectName, about, tasks: ours?.tasks ?? [], options };
}
/**
* The "?" at the top of a page: a panel with what the page is for, the
* things people usually do there, and a plain explanation of every option.
* The manual link appears once a manual is configured.
*/
export function HelpPanel({ viewName, title }: { viewName: string; title: string }) {
const { t } = useTranslation();
const schema = useSchemaStore((s) => s.schema);
const [open, setOpen] = useState(false);
const help = useMemo(() => (schema ? pageHelp(schema, viewName) : null), [schema, viewName]);
if (!help || (!help.about && help.options.length === 0)) return null;
const more = manualUrl(help.id);
return (
<>
<TooltipProvider delayDuration={200}>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon"
className="rounded-xl text-muted-foreground hover:text-primary"
aria-label={t('help.page', 'Help for this page')}
data-help-id={help.id}
onClick={() => setOpen(true)}
>
<CircleHelp className="h-5 w-5" />
</Button>
</TooltipTrigger>
<TooltipContent>{t('help.page', 'Help for this page')}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="left-auto right-0 top-0 flex h-dvh max-w-md translate-x-0 translate-y-0 flex-col gap-0 overflow-hidden rounded-none p-0 data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:rounded-l-2xl">
<div className="border-b px-6 pb-4 pt-6">
<p className="text-xs font-medium uppercase tracking-wide text-primary">{t('help.label', 'Help')}</p>
<DialogTitle className="mt-1 text-xl">{title}</DialogTitle>
{help.about && <DialogDescription className="mt-2 text-sm leading-relaxed">{help.about}</DialogDescription>}
</div>
<div className="flex-1 space-y-6 overflow-y-auto px-6 py-5">
{help.tasks.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-semibold">{t('help.tasks', 'What people do here')}</h3>
<ul className="space-y-2">
{help.tasks.map((task) => (
<li key={task} className="flex gap-2.5 text-sm">
<Lightbulb className="mt-0.5 h-4 w-4 shrink-0 text-amber-500" />
<span>{task}</span>
</li>
))}
</ul>
</section>
)}
{help.options.length > 0 && (
<section className="space-y-2">
<h3 className="text-sm font-semibold">{t('help.options', 'The options on this page')}</h3>
<dl className="divide-y rounded-xl border">
{help.options.map((o) => (
<div key={o.id} className="px-4 py-3" data-help-id={o.id}>
<dt className="text-sm font-medium">{o.label}</dt>
<dd className="mt-0.5 text-sm text-muted-foreground [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_p]:m-0">
<ReactMarkdown>{o.text.replace(/\\n/g, '\n')}</ReactMarkdown>
</dd>
</div>
))}
</dl>
</section>
)}
</div>
{more && (
<div className="border-t px-6 py-4">
<a
href={more}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1 text-sm font-medium text-primary hover:underline"
>
{t('help.manual', 'Read more in the admin manual')}
<ArrowUpRight className="h-4 w-4" />
</a>
</div>
)}
</DialogContent>
</Dialog>
</>
);
}
+62
View File
@@ -0,0 +1,62 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { useTranslation } from 'react-i18next';
import ReactMarkdown from 'react-markdown';
import { ArrowUpRight, Info } from 'lucide-react';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip';
import { cn } from '@/lib/utils';
import { manualUrl } from './manual';
/**
* The ⓘ beside an option: a sentence or two on what it does, on hover or
* 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 }) {
const { t } = useTranslation();
if (!text) return null;
const more = id ? manualUrl(id) : null;
return (
<TooltipProvider delayDuration={150}>
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
data-help-id={id}
aria-label={t('help.about', 'About this option')}
className={cn(
'inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-muted-foreground/60 transition-colors hover:text-primary focus-visible:text-primary focus-visible:outline-none',
className,
)}
>
<Info className="h-3.5 w-3.5" />
</button>
</TooltipTrigger>
<TooltipContent
side="top"
align="start"
className="max-w-xs space-y-1.5 border bg-popover px-3 py-2 text-xs leading-relaxed text-popover-foreground shadow-soft"
>
<div className="[&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_p]:m-0">
<ReactMarkdown>{text.replace(/\\n/g, '\n')}</ReactMarkdown>
</div>
{more && (
<a
href={more}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-primary hover:underline"
>
{t('help.learnMore', 'Learn more')}
<ArrowUpRight className="h-3 w-3" />
</a>
)}
</TooltipContent>
</Tooltip>
</TooltipProvider>
);
}
+38
View File
@@ -0,0 +1,38 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
import { afterEach, describe, expect, it } from 'vitest';
import { manualUrl } from './manual';
import { fieldHelp, FIELD_HELP } from './texts';
describe('manualUrl', () => {
afterEach(() => document.querySelector('meta[name="manual-url"]')?.remove());
it('shows no link until a manual is configured', () => {
expect(manualUrl('x:Domain.dnsManagement')).toBeNull();
});
it('maps help ids to stable manual pages and anchors', () => {
const meta = document.createElement('meta');
meta.name = 'manual-url';
meta.content = 'https://docs.example.org/admin/';
document.head.appendChild(meta);
expect(manualUrl('x:Domain')).toBe('https://docs.example.org/admin/reference/domain/');
expect(manualUrl('x:Domain.dnsManagement')).toBe('https://docs.example.org/admin/reference/domain/#dnsmanagement');
expect(manualUrl('x:DnsServerCloudflare.secret')).toBe(
'https://docs.example.org/admin/reference/dns-server-cloudflare/#secret',
);
expect(manualUrl('x:Account/User')).toBe('https://docs.example.org/admin/reference/account-user/');
});
});
describe('fieldHelp', () => {
it('prefers our words, then the schema description', () => {
expect(fieldHelp('x:Domain.catchAllAddress', 'schema text')).toBe(FIELD_HELP['x:Domain.catchAllAddress']);
expect(fieldHelp('x:Domain.unknownField', 'schema text')).toBe('schema text');
expect(fieldHelp(undefined, null)).toBeNull();
});
});
+39
View File
@@ -0,0 +1,39 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* Links from in-app help to the INBUXA admin manual (MkDocs). Every tooltip
* and help panel carries a stable id, `x:Domain.dnsManagement` for a field
* or `x:Domain` for a page, and this is the one place that turns an id into
* an address. Until a manual is published there is no base URL and no link
* is shown.
*
* The base URL comes from VITE_MANUAL_URL at build time, or from
* <meta name="manual-url" content="https://…"> at deploy time.
*/
function manualBase(): string | null {
const fromMeta =
typeof document !== 'undefined' ? document.querySelector('meta[name="manual-url"]')?.getAttribute('content') : null;
const base = (fromMeta || (import.meta.env.VITE_MANUAL_URL as string | undefined) || '').trim();
return base ? base.replace(/\/+$/, '') : null;
}
/**
* The manual page for a help id: `x:Domain.dnsManagement` becomes
* `<base>/reference/domain/#dnsmanagement`, `x:Domain` becomes
* `<base>/reference/domain/`. The manual's page names must follow this.
*/
export function manualUrl(id: string): string | null {
const base = manualBase();
if (!base) return null;
const [object, field] = id.split('.', 2);
const page = object
.replace(/^x:/, '')
.replace(/\//g, '-')
.replace(/([a-z0-9])([A-Z])/g, '$1-$2')
.toLowerCase();
return `${base}/reference/${page}/${field ? `#${field.toLowerCase()}` : ''}`;
}
+153
View File
@@ -0,0 +1,153 @@
/*
* SPDX-FileCopyrightText: 2026 Coffey Labs
*
* SPDX-License-Identifier: AGPL-3.0-only
*/
/**
* INBUXA's own help, in plain words, keyed by help id. Options without an
* entry here fall back to the description the server's schema gives. Write
* new entries in our own words: say what the option does for the person
* using it, and what goes wrong if it's set badly. Keep a tooltip to one
* or two sentences; the manual is where the detail goes.
*
* Keys: `object` for a page, `object.field` for an option on it.
*/
export const FIELD_HELP: Record<string, string> = {
// Domains
'x:Domain.name': 'The domain peoples addresses end in, like example.com.',
'x:Domain.aliases':
'Other domains that deliver to the same people. Mail to [email protected] lands in [email protected].',
'x:Domain.isEnabled': 'Turn off to stop accepting mail for this domain without deleting anything.',
'x:Domain.catchAllAddress':
'Where mail to addresses that dont exist goes. Handy for small teams; on a busy domain it collects spam.',
'x:Domain.subAddressing':
'Lets people use [email protected] to sort or trace their mail. On for most domains.',
'x:Domain.allowRelaying':
'Forward mail for unknown people to another server, for domains split between two systems. Leave off otherwise.',
'x:Domain.dkimManagement':
'Signing keys that prove mail from this domain is really yours. Automatic creates and rotates them for you.',
'x:Domain.certificateManagement':
'The TLS certificate for this domains mail and web addresses. Automatic gets and renews one for you.',
'x:Domain.dnsManagement':
'Whether the server writes this domains DNS records itself through your DNS host, or you add them by hand.',
'x:Domain.reportAddressUri':
'Where other mail servers send reports about mail claiming to be from you (DMARC, TLS). Postmaster is a good choice.',
'x:Domain.memberTenantId': 'The customer or organization this domain belongs to, if you host more than one.',
'x:Domain.directoryId': 'Where this domains accounts and passwords are kept: here, or an outside directory.',
'x:Domain.logo': 'A logo for this domains sign-in page and mail apps. A link or an uploaded image.',
// People
'x:UserAccount.name': 'The part before the @. Together with the domain it makes the persons address.',
'x:UserAccount.description': 'The persons full name, as others see it.',
'x:UserAccount.aliases': 'More addresses that deliver to this person.',
'x:UserAccount.quotas':
'Limits for this person, like how much storage they may use. Empty means the servers defaults.',
'x:UserAccount.roles': 'What this person may do. Most people are plain users; admins manage the server.',
'x:UserAccount.memberGroupIds': 'Groups this person belongs to. They share the groups mail and can send as it.',
'x:UserAccount.credentials': 'How this person signs in: a password, app passwords for mail apps, and more.',
'x:UserAccount.locale': 'The language for messages the server sends this person.',
'x:UserAccount.timeZone': 'Used for calendar invitations and scheduled messages.',
'x:GroupAccount.name': 'The groups address, before the @. Mail to it reaches every member.',
// DNS providers
'x:DnsServerCloudflare.secret':
'A Cloudflare API token that can edit DNS for your zone. Make it with the “Edit zone DNS” template.',
'x:DnsServerCloudflare.email': 'Only for the old Global API Key. Leave empty when you use an API token.',
'x:DnsServerCloud.secret': 'The API token or key from your DNS host. Give it DNS access for this domain only.',
'x:DnsServerCloudflare.ttl': 'How long other servers may cache the records written. Five minutes is a good default.',
'x:DnsServerCloud.ttl': 'How long other servers may cache the records written. Five minutes is a good default.',
// Security
'x:BlockedIp.address': 'An address or network, like 203.0.113.7 or 203.0.113.0/24, refused before it can talk.',
'x:BlockedIp.reason': 'A note for yourself on why it was blocked.',
'x:BlockedIp.expiresAt': 'When the block lifts by itself. Empty means it stays until you remove it.',
'x:AllowedIp.address': 'An address or network that is never blocked automatically, like your office or monitoring.',
};
export interface PageHelp {
/** What the page is for, in a sentence or two. */
about: string;
/** The things people come here to do. */
tasks?: string[];
}
export const PAGE_HELP: Record<string, PageHelp> = {
'x:Domain': {
about: 'The domains this server receives and sends mail for. Each persons address belongs to one of them.',
tasks: [
'Add a domain, then publish its DNS records so mail can find you.',
'Let the server publish DNS for you: open a domain and use “Set it up” in its DNS section.',
'Turn on automatic DKIM and certificates so keys and certificates renew themselves.',
],
},
'x:Account/User': {
about: 'Everyone with a mailbox here. Each person has an address, a password and, optionally, limits.',
tasks: [
'Add a person and give them a password.',
'Give someone more addresses with aliases.',
'Set a storage limit under quotas.',
],
},
'x:Account/Group': {
about: 'Shared mailboxes, like sales@ or support@, that several people read and send from.',
tasks: ['Create a group, then add people to it from their own page under Groups.'],
},
'x:MailingList': {
about: 'Addresses that pass each message on to a list of recipients, inside or outside this server.',
},
'x:Tenant': {
about: 'Separate customers or organizations on one server, each with their own domains, people and limits.',
},
'x:Role': {
about: 'Named sets of permissions. Give a role to a person to let them do more, or less.',
},
'x:OAuthClient': {
about: 'Apps allowed to sign people in through this server, like INBUXA webmail and INBUXA Admin.',
},
'x:DkimSignature': {
about: 'The keys that sign outgoing mail so receivers can check it really came from you.',
tasks: ['Let domains manage their own keys: set DKIM to automatic on the domain.'],
},
'x:QueuedMessage': {
about:
'Mail waiting to go out. Most leaves within seconds; what stays here is waiting for a server that isnt answering.',
tasks: [
'See why a message is stuck: open it and look at each recipients status.',
'Retry now, or cancel mail that will never be delivered.',
],
},
'x:DnsServer': {
about: 'Connections to your DNS hosts, so the server can publish and update its own DNS records.',
tasks: ['Connect one the easy way: open a domain and use “Set it up” in its DNS section.'],
},
'x:BlockedIp': {
about: 'Addresses refused before they can talk to the server. The server adds some itself when it spots attacks.',
tasks: ['Unblock someone: find their address and delete the entry.'],
},
'x:AllowedIp': {
about: 'Addresses the server never blocks by itself, like your office network or monitoring.',
},
'x:DmarcExternalReport': {
about:
'Reports from other mail providers on mail they received claiming to be from your domains, and whether it passed.',
},
'x:TlsExternalReport': {
about: 'Reports from other mail providers on whether they could reach you over an encrypted connection.',
},
'x:Task': {
about: 'Background work the server has scheduled: DNS updates, key rotation, certificate renewal and upkeep.',
},
'x:Task/TaskFailed': {
about: 'Background work that failed. Each entry says why; most retry by themselves once the cause is fixed.',
},
'x:Log': {
about: 'What the server has been doing, newest first. Useful for tracing a problem back to its cause.',
},
};
/** The help text for an option: ours when written, else the schema's. */
export function fieldHelp(id: string | undefined, fallback?: string | null): string | null {
return (id && FIELD_HELP[id]) || fallback || null;
}