From 824427ef46ed3288d0919bd3b2fea51172fe3e85 Mon Sep 17 00:00:00 2001 From: John Coffey Date: Sat, 19 Sep 2026 01:59:14 -0700 Subject: [PATCH] Help on every option and every page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 ). Until then no link shows. --- src/components/common/IconTile.tsx | 4 +- src/components/common/Logo.tsx | 36 +++++-- src/components/forms/DynamicForm.tsx | 12 +++ src/components/forms/FieldWidget.tsx | 20 ++-- src/components/layout/Sidebar.tsx | 8 +- src/components/layout/TopBar.tsx | 6 +- src/components/lists/DynamicList.tsx | 2 + src/help/HelpPanel.tsx | 150 ++++++++++++++++++++++++++ src/help/HelpTip.tsx | 62 +++++++++++ src/help/help.test.ts | 38 +++++++ src/help/manual.ts | 39 +++++++ src/help/texts.ts | 153 +++++++++++++++++++++++++++ 12 files changed, 512 insertions(+), 18 deletions(-) create mode 100644 src/help/HelpPanel.tsx create mode 100644 src/help/HelpTip.tsx create mode 100644 src/help/help.test.ts create mode 100644 src/help/manual.ts create mode 100644 src/help/texts.ts diff --git a/src/components/common/IconTile.tsx b/src/components/common/IconTile.tsx index 5c6e77b..6202c0f 100644 --- a/src/components/common/IconTile.tsx +++ b/src/components/common/IconTile.tsx @@ -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 ( - + {createElement(iconComponent(name), { className: glyph, strokeWidth: 2, 'aria-hidden': true })} ); diff --git a/src/components/common/Logo.tsx b/src/components/common/Logo.tsx index d6fccb5..bd9c080 100644 --- a/src/components/common/Logo.tsx +++ b/src/components/common/Logo.tsx @@ -22,12 +22,36 @@ export function DefaultLogo() { className="h-7 w-auto max-w-[320px]" > - - - - - - + + + + + + ); } diff --git a/src/components/forms/DynamicForm.tsx b/src/components/forms/DynamicForm.tsx index b8c8908..08c505b 100644 --- a/src/components/forms/DynamicForm.tsx +++ b/src/components/forms/DynamicForm.tsx @@ -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={} /> {generalError && ( @@ -834,6 +845,7 @@ export function DynamicForm({ viewName, objectId }: DynamicFormProps) { sieveScriptName={ isSieveScriptField(resolved.obj.objectName, formField.name) ? scriptName : undefined } + helpScope={helpScope} /> ); diff --git a/src/components/forms/FieldWidget.tsx b/src/components/forms/FieldWidget.tsx index 3931146..ce62d3f 100644 --- a/src/components/forms/FieldWidget.tsx +++ b/src/components/forms/FieldWidget.tsx @@ -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) { )} + - {field.description && ( -
- {field.description.replace(/\\n/g, '\n')} -
- )} {widget} {sieveScriptName !== undefined && ft.type === 'string' && ( @@ -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} /> ))} @@ -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} /> ))} diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 08df8ff..4346dbc 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -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() { />