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:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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()}` : ''}`;
|
||||
}
|
||||
@@ -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 people’s 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 don’t 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 domain’s mail and web addresses. Automatic gets and renews one for you.',
|
||||
'x:Domain.dnsManagement':
|
||||
'Whether the server writes this domain’s 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 domain’s accounts and passwords are kept: here, or an outside directory.',
|
||||
'x:Domain.logo': 'A logo for this domain’s 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 person’s address.',
|
||||
'x:UserAccount.description': 'The person’s 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 server’s 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 group’s 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 group’s 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 person’s 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 isn’t answering.',
|
||||
tasks: [
|
||||
'See why a message is stuck: open it and look at each recipient’s 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;
|
||||
}
|
||||
Reference in New Issue
Block a user