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
+3 -1
View File
@@ -46,7 +46,9 @@ export function IconTile({
const box = size === 'sm' ? 'h-6 w-6 rounded-md' : size === 'lg' ? 'h-10 w-10 rounded-xl' : 'h-7 w-7 rounded-lg';
const glyph = size === 'sm' ? 'h-3.5 w-3.5' : size === 'lg' ? 'h-5 w-5' : 'h-4 w-4';
return (
<span className={cn('inline-flex shrink-0 items-center justify-center', box, TONE_CLASSES[toneFor(name)], className)}>
<span
className={cn('inline-flex shrink-0 items-center justify-center', box, TONE_CLASSES[toneFor(name)], className)}
>
{createElement(iconComponent(name), { className: glyph, strokeWidth: 2, 'aria-hidden': true })}
</span>
);
+30 -6
View File
@@ -22,12 +22,36 @@ export function DefaultLogo() {
className="h-7 w-auto max-w-[320px]"
>
<image x="165.85" y="35.00" width="109.39" height="130.00" href={inbuxaMark} />
<path className="fill-current" d="M70 0V496H196V0ZM133 554Q99 554 75.5 576.0Q52 598 52 634Q52 670 75.5 692.0Q99 714 133 714Q168 714 191.0 692.0Q214 670 214 634Q214 598 191.0 576.0Q168 554 133 554Z" transform="translate(303.25,151.90) scale(0.150000,-0.150000)" />
<path className="fill-current" d="M70 0V496H194V431H212Q224 457 257.0 480.5Q290 504 357 504Q415 504 458.5 477.5Q502 451 526.0 404.5Q550 358 550 296V0H424V286Q424 342 396.5 370.0Q369 398 318 398Q260 398 228.0 359.5Q196 321 196 252V0Z" transform="translate(340.15,151.90) scale(0.150000,-0.150000)" />
<path className="fill-current" d="M368 -14Q301 -14 265.0 9.0Q229 32 212 60H194V0H70V700H196V439H214Q225 457 243.5 473.0Q262 489 292.5 499.5Q323 510 368 510Q428 510 479.0 480.5Q530 451 561.0 394.0Q592 337 592 256V240Q592 159 561.0 102.0Q530 45 479.0 15.5Q428 -14 368 -14ZM330 96Q388 96 427.0 133.5Q466 171 466 243V253Q466 325 427.5 362.5Q389 400 330 400Q272 400 233.0 362.5Q194 325 194 253V243Q194 171 233.0 133.5Q272 96 330 96Z" transform="translate(429.55,151.90) scale(0.150000,-0.150000)" />
<path className="fill-current" d="M259 -8Q201 -8 157.5 18.5Q114 45 90.0 92.0Q66 139 66 200V496H192V210Q192 154 219.5 126.0Q247 98 298 98Q356 98 388.0 136.5Q420 175 420 244V496H546V0H422V65H404Q392 40 359.0 16.0Q326 -8 259 -8Z" transform="translate(522.25,151.90) scale(0.150000,-0.150000)" />
<path className="fill-current" d="M26 0 206 250 28 496H174L287 331H305L418 496H564L386 250L566 0H418L305 167H287L174 0Z" transform="translate(611.65,151.90) scale(0.150000,-0.150000)" />
<path className="fill-current" d="M224 -14Q171 -14 129.0 4.5Q87 23 62.5 58.5Q38 94 38 145Q38 196 62.5 230.5Q87 265 130.5 282.5Q174 300 230 300H366V328Q366 363 344.0 385.5Q322 408 274 408Q227 408 204.0 386.5Q181 365 174 331L58 370Q70 408 96.5 439.5Q123 471 167.5 490.5Q212 510 276 510Q374 510 431.0 461.0Q488 412 488 319V134Q488 104 516 104H556V0H472Q435 0 411.0 18.0Q387 36 387 66V67H368Q364 55 350.0 35.5Q336 16 306.0 1.0Q276 -14 224 -14ZM246 88Q299 88 332.5 117.5Q366 147 366 196V206H239Q204 206 184.0 191.0Q164 176 164 149Q164 122 185.0 105.0Q206 88 246 88Z" transform="translate(697.45,151.90) scale(0.150000,-0.150000)" />
<path
className="fill-current"
d="M70 0V496H196V0ZM133 554Q99 554 75.5 576.0Q52 598 52 634Q52 670 75.5 692.0Q99 714 133 714Q168 714 191.0 692.0Q214 670 214 634Q214 598 191.0 576.0Q168 554 133 554Z"
transform="translate(303.25,151.90) scale(0.150000,-0.150000)"
/>
<path
className="fill-current"
d="M70 0V496H194V431H212Q224 457 257.0 480.5Q290 504 357 504Q415 504 458.5 477.5Q502 451 526.0 404.5Q550 358 550 296V0H424V286Q424 342 396.5 370.0Q369 398 318 398Q260 398 228.0 359.5Q196 321 196 252V0Z"
transform="translate(340.15,151.90) scale(0.150000,-0.150000)"
/>
<path
className="fill-current"
d="M368 -14Q301 -14 265.0 9.0Q229 32 212 60H194V0H70V700H196V439H214Q225 457 243.5 473.0Q262 489 292.5 499.5Q323 510 368 510Q428 510 479.0 480.5Q530 451 561.0 394.0Q592 337 592 256V240Q592 159 561.0 102.0Q530 45 479.0 15.5Q428 -14 368 -14ZM330 96Q388 96 427.0 133.5Q466 171 466 243V253Q466 325 427.5 362.5Q389 400 330 400Q272 400 233.0 362.5Q194 325 194 253V243Q194 171 233.0 133.5Q272 96 330 96Z"
transform="translate(429.55,151.90) scale(0.150000,-0.150000)"
/>
<path
className="fill-current"
d="M259 -8Q201 -8 157.5 18.5Q114 45 90.0 92.0Q66 139 66 200V496H192V210Q192 154 219.5 126.0Q247 98 298 98Q356 98 388.0 136.5Q420 175 420 244V496H546V0H422V65H404Q392 40 359.0 16.0Q326 -8 259 -8Z"
transform="translate(522.25,151.90) scale(0.150000,-0.150000)"
/>
<path
className="fill-current"
d="M26 0 206 250 28 496H174L287 331H305L418 496H564L386 250L566 0H418L305 167H287L174 0Z"
transform="translate(611.65,151.90) scale(0.150000,-0.150000)"
/>
<path
className="fill-current"
d="M224 -14Q171 -14 129.0 4.5Q87 23 62.5 58.5Q38 94 38 145Q38 196 62.5 230.5Q87 265 130.5 282.5Q174 300 230 300H366V328Q366 363 344.0 385.5Q322 408 274 408Q227 408 204.0 386.5Q181 365 174 331L58 370Q70 408 96.5 439.5Q123 471 167.5 490.5Q212 510 276 510Q374 510 431.0 461.0Q488 412 488 319V134Q488 104 516 104H556V0H472Q435 0 411.0 18.0Q387 36 387 66V67H368Q364 55 350.0 35.5Q336 16 306.0 1.0Q276 -14 224 -14ZM246 88Q299 88 332.5 117.5Q366 147 366 196V206H239Q204 206 184.0 191.0Q164 176 164 149Q164 122 185.0 105.0Q206 88 246 88Z"
transform="translate(697.45,151.90) scale(0.150000,-0.150000)"
/>
</svg>
);
}
+12
View File
@@ -7,6 +7,7 @@
import { humanize } from '@/lib/humanize';
import { PageHeader } from '@/components/common/PageHeader';
import { HelpPanel } from '@/help/HelpPanel';
import { iconForView } from '@/lib/viewIcon';
import { useState, useEffect, useCallback, useMemo } from 'react';
import { flushSync } from 'react-dom';
@@ -124,6 +125,15 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
return { ...fields, properties: filtered };
}, [resolved, selectedVariant, schema]);
// INBUXA: whose fields these are, for their help ids: the variant's schema
// (x:UserAccount) when the object has variants, else the object (x:Domain).
const helpScope = useMemo(() => {
if (!resolved) return undefined;
const { sch, obj } = resolved;
if (sch.type === 'single') return obj.objectName;
return sch.variants.find((v) => v.name === selectedVariant)?.schemaName ?? obj.objectName;
}, [resolved, selectedVariant]);
const currentForm = useMemo((): Form | null => {
if (!schema || !resolved) return null;
const { obj, sch } = resolved;
@@ -761,6 +771,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
icon={iconForView(schema, viewName)}
title={formTitle}
subtitle={formSubtitle}
actions={<HelpPanel viewName={viewName} title={String(formTitle ?? '')} />}
/>
{generalError && (
@@ -834,6 +845,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) {
sieveScriptName={
isSieveScriptField(resolved.obj.objectName, formField.name) ? scriptName : undefined
}
helpScope={helpScope}
/>
);
+13 -7
View File
@@ -9,7 +9,8 @@ import { humanize } from '@/lib/humanize';
import { useState, useEffect, useMemo, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { useBufferedValue, useResetOnChange } from '@/hooks/useBufferedValue';
import ReactMarkdown from 'react-markdown';
import { HelpTip } from '@/help/HelpTip';
import { fieldHelp } from '@/help/texts';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Button } from '@/components/ui/button';
@@ -63,6 +64,8 @@ export interface FieldWidgetProps {
error?: string;
schema: Schema;
sieveScriptName?: string;
/** INBUXA: the object or schema that owns this field, for its help id (`scope.field`). */
helpScope?: string;
}
function getRequiredMarker(field: Field, readOnly: boolean): 'required' | 'optional' | null {
@@ -83,7 +86,8 @@ function getRequiredMarker(field: Field, readOnly: boolean): 'required' | 'optio
export function FieldWidget(props: FieldWidgetProps) {
const { t } = useTranslation();
const { field, formField, value, onChange, readOnly, error, schema, sieveScriptName } = props;
const { field, formField, value, onChange, readOnly, error, schema, sieveScriptName, helpScope } = props;
const helpId = helpScope ? `${helpScope}.${formField.name}` : undefined;
const ft = field.type;
const edition = useEffectiveEdition();
@@ -233,12 +237,8 @@ export function FieldWidget(props: FieldWidgetProps) {
</span>
)}
</Label>
<HelpTip id={helpId} text={fieldHelp(helpId, field.description)} />
</div>
{field.description && (
<div className="text-xs text-muted-foreground prose prose-sm max-w-none [&_p]:m-0">
<ReactMarkdown>{field.description.replace(/\\n/g, '\n')}</ReactMarkdown>
</div>
)}
{widget}
{sieveScriptName !== undefined && ft.type === 'string' && (
<SievepadButton scriptName={sieveScriptName} source={typeof value === 'string' ? value : ''} />
@@ -1409,6 +1409,7 @@ function EmbeddedObjectField({
if (resolvedSchema.type === 'single') {
const fields = resolvedSchema.fields;
const helpScopeHere = resolvedSchema.schemaName ?? objectName;
const form = resolveVariantForm(schema, objectName, objectName, resolvedSchema.schemaName);
const formFields = form?.sections.flatMap((s) => s.fields) ?? [];
@@ -1427,6 +1428,7 @@ function EmbeddedObjectField({
onChange={(v) => handleFieldChange(ff.name, v)}
readOnly={readOnly}
schema={schema}
helpScope={helpScopeHere}
/>
);
})}
@@ -1442,6 +1444,7 @@ function EmbeddedObjectField({
onChange={(v) => handleFieldChange(name, v)}
readOnly={readOnly}
schema={schema}
helpScope={helpScopeHere}
/>
))}
</div>
@@ -1451,6 +1454,7 @@ function EmbeddedObjectField({
const currentType = (objValue['@type'] as string) ?? resolvedSchema.variants[0]?.name ?? '';
const currentVariant = resolvedSchema.variants.find((v) => v.name === currentType);
const variantFields = currentVariant?.fields;
const helpScopeHere = currentVariant?.schemaName ?? objectName;
const variantForm = resolveVariantForm(schema, objectName, objectName, currentVariant?.schemaName);
const variantFormFields = variantForm?.sections.flatMap((s) => s.fields) ?? [];
@@ -1491,6 +1495,7 @@ function EmbeddedObjectField({
onChange={(v) => handleFieldChange(ff.name, v)}
readOnly={readOnly}
schema={schema}
helpScope={helpScopeHere}
/>
);
})}
@@ -1507,6 +1512,7 @@ function EmbeddedObjectField({
onChange={(v) => handleFieldChange(name, v)}
readOnly={readOnly}
schema={schema}
helpScope={helpScopeHere}
/>
))}
</div>
+5 -3
View File
@@ -427,9 +427,9 @@ export function Sidebar() {
const layout: Layout | undefined = layouts.find((l) => l.name === activeSection);
if (!layout) return null;
// Folding to a rail is for wide screens; a phone keeps the slide-over.
const collapsed = sidebarCollapsed && typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches;
const collapsed =
sidebarCollapsed && typeof window !== 'undefined' && window.matchMedia('(min-width: 768px)').matches;
if (collapsed) {
return (
@@ -476,7 +476,9 @@ export function Sidebar() {
/>
<aside className="fixed top-14 left-0 bottom-0 z-30 flex w-64 flex-col border-r bg-background">
<div className="flex items-center justify-between px-4 pt-3 pb-1">
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">{layout.name}</span>
<span className="text-[11px] font-semibold uppercase tracking-wider text-muted-foreground">
{layout.name}
</span>
<button
type="button"
aria-label="Collapse sidebar"
+5 -1
View File
@@ -137,7 +137,11 @@ export function TopBar() {
{/* INBUXA: the three areas, one click away, where the eye already looks. */}
{schema && navigableLayouts.length > 1 && (
<TooltipProvider delayDuration={150}>
<div className="hidden items-center gap-0.5 rounded-xl bg-muted p-1 sm:flex" role="tablist" aria-label={t('sections', 'Sections')}>
<div
className="hidden items-center gap-0.5 rounded-xl bg-muted p-1 sm:flex"
role="tablist"
aria-label={t('sections', 'Sections')}
>
{navigableLayouts.map((layout) => {
const isActive = layout.name === activeSection;
return (
+2
View File
@@ -7,6 +7,7 @@
import { EmptyState } from '@/components/common/EmptyState';
import { PageHeader } from '@/components/common/PageHeader';
import { HelpPanel } from '@/help/HelpPanel';
import { iconForView } from '@/lib/viewIcon';
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
@@ -1113,6 +1114,7 @@ export function DynamicList({ viewName }: DynamicListProps) {
<div className="flex flex-wrap items-start justify-between gap-4">
<PageHeader icon={iconForView(schema, viewName)} title={list.title} subtitle={list.subtitle} />
<div className="flex items-center gap-2">
<HelpPanel viewName={viewName} title={list.title} />
{hasMassActions && selectedIds.size > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
+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;
}