Files
pangolin/docs/ui-conventions.md
2026-11-09 16:23:48 -05:00

229 lines
9.5 KiB
Markdown

# Dashboard UI conventions
The dashboard is Next.js App Router (`src/app`), with shadcn/Radix primitives in
`src/components/ui`, Tailwind, `next-intl` for i18n, and
`react-hook-form` + zod for forms.
## Translations (always)
- Every user-facing string goes through `t()`: labels, descriptions,
placeholders, button text, toasts, tab titles, table headers, empty states,
zod error messages shown to users, and `aria-label`s.
- Client components: `const t = useTranslations();` from `next-intl`.
- Server components / layouts: `const t = await getTranslations();` from
`next-intl/server`.
- Interpolation: `t("resourceSetting", { resourceName: resource.name })`, with
`{resourceName}` in the message string.
- Add new keys **only** to `messages/en-US.json`. **Never** add or edit
translations in the other `messages/*.json` files; Crowdin generates them.
- Before adding a key, search `messages/en-US.json` for an existing one
(`general`, `name`, `saveSettings`, `learnMore`, `cancel`, `close`,
`delete`...). Name new keys camelCase with a feature prefix (e.g.
`orgGeneralSettingsDescription`, `domainsBannerTitle`).
## Modals: always Credenza
Use `Credenza` from `@app/components/Credenza` for every modal or dialog. It
renders a dialog on desktop and a bottom drawer on mobile. Don't import `Dialog`,
`Sheet`, `Drawer` or `AlertDialog` from `components/ui` directly.
```tsx
<Credenza open={open} onOpenChange={setOpen}>
<CredenzaContent>
<CredenzaHeader>
<CredenzaTitle>{t("...")}</CredenzaTitle>
<CredenzaDescription>{t("...")}</CredenzaDescription>
</CredenzaHeader>
<CredenzaBody>{/* form with id="my-form" */}</CredenzaBody>
<CredenzaFooter>
<CredenzaClose asChild>
<Button variant="outline">{t("close")}</Button>
</CredenzaClose>
<Button type="submit" form="my-form" loading={loading} disabled={loading}>
{t("save")}
</Button>
</CredenzaFooter>
</CredenzaContent>
</Credenza>
```
Examples: `src/components/CreateSiteProvisioningKeyCredenza.tsx`,
`src/components/CustomDnsRecordCredenza.tsx`. For destructive confirmations, use
`ConfirmDeleteDialog` (a Credenza where the user types the name to confirm).
## Pages vs modals
- **Use pages** for anything that's an object with more than a few settings
(resources, sites, clients, providers, policies, alert rules, ...):
- list page: `src/app/[orgId]/settings/<things>/page.tsx` (table)
- create page: `.../<things>/create/page.tsx`
- detail page: `.../<things>/[id]/layout.tsx` + one folder per tab
- **Use a Credenza** only for small, quick forms (a couple of fields), pickers,
and confirmations.
## Info for the user: DismissableBanner, not Alert
To explain a feature or give the user context on a page, use
`DismissableBanner` (`src/components/DismissableBanner.tsx`), not an `<Alert>`.
Wrap it in a small `<Feature>Banner.tsx` component:
```tsx
export const DomainsBanner = () => {
const t = useTranslations();
return (
<DismissableBanner
storageKey="domains-banner-dismissed"
version={1}
title={t("domainsBannerTitle")}
titleIcon={<Globe className="w-5 h-5 text-primary" />}
description={t("domainsBannerDescription")}
/>
);
};
```
Render it under the `SettingsSectionTitle` of the page (see
`src/app/[orgId]/settings/domains/page.tsx`). Bump `version` to show it again
after changing the content. Existing banners (copy one): `src/components/*Banner.tsx`, e.g.
`DomainsBanner`, `SitesBanner`, `PublicResourcesBanner`,
`PrivateResourcesBanner`, `UsersBanner`, `ShareLinksBanner`.
Paywalled features use `<PaidFeaturesAlert tiers={tierMatrix.x} />`, which
handles its own visibility.
## Tabs: splitting an object's settings across pages
Detail and settings areas are split into **route-based tabs**. Each tab is its
own route segment with its own `page.tsx`, and a shared `layout.tsx` renders the
header and `HorizontalTabs`.
Example: org settings (`src/app/[orgId]/settings/general/`):
```
general/
layout.tsx server component: fetch org, build navItems, render tabs
page.tsx "General" tab (exact: true, because it is the index route)
security/page.tsx "Security" tab
logs/page.tsx "Logs" tab
auth-page/page.tsx "Auth Page" tab (hidden when enterprise features are disabled)
```
`layout.tsx` pattern:
```tsx
export default async function Layout({ children, params }) {
const { orgId } = await params;
const t = await getTranslations();
// fetch the object server-side (internal + authCookieHeader, or getCachedOrg)
// redirect(...) on failure
const navItems: TabItem[] = [
{ title: t("general"), href: `/{orgId}/settings/general`, exact: true },
{ title: t("security"), href: `/{orgId}/settings/general/security` },
...(!env.flags.disableEnterpriseFeatures
? [{ title: t("authPage"), href: `/{orgId}/settings/general/auth-page` }]
: [])
];
return (
<OrgProvider org={org}>
<SettingsSectionTitle title={t("...")} description={t("...")} />
<div className="space-y-4">
<OrgInfoCard /> {/* summary card for the object */}
<HorizontalTabs items={navItems}>{children}</HorizontalTabs>
</div>
</OrgProvider>
);
}
```
Notes:
- `href`s use placeholders (`{orgId}`, `{niceId}`, `{resourceId}`,
`{clientId}`, `{userId}`, `{domainId}`, `{apiKeyId}`, `{providerId}`,
`{remoteExitNodeId}`) that `HorizontalTabs` fills in from route params. If you
add a new param name, add it to `hydrateHref` in
`src/components/HorizontalTabs.tsx`.
- Use `exact: true` on a tab whose href is a prefix of the other tabs (the index
tab).
- Build tabs conditionally from object state, like the resource layout shows
mode-specific tabs:
`src/app/[orgId]/settings/resources/public/[niceId]/layout.tsx`.
- When there's no index tab, `[id]/page.tsx` just `redirect`s to the first tab
(`.../[niceId]/page.tsx` → `/general`).
- The layout puts the object into a Provider (`OrgProvider`,
`ResourceProvider`, `SiteResourceProvider`, ...). Tab pages are client
components that read it with the matching hook (`useOrgContext`,
`useResourceContext`, `useSiteResourceContext`) and call `updateX(...)` on
the context after saving so the header card stays current.
- `showProfessional` on a `TabItem` marks paid tabs.
- For tabs inside a single page or a Credenza (no routing), use
`<HorizontalTabs clientSide items={...}>` with one child per tab.
### Inside a tab page
Each tab page is a `SettingsContainer` holding one or more `SettingsSection`s.
Each section is a separate concern with its own form and its own Save button in
the footer. See `src/app/[orgId]/settings/general/page.tsx`: a "General"
section form plus a separate danger-zone delete section.
```tsx
<SettingsContainer>
<SettingsSection>
<SettingsSectionHeader>
<SettingsSectionTitle>{t("general")}</SettingsSectionTitle>
<SettingsSectionDescription>{t("...")}</SettingsSectionDescription>
</SettingsSectionHeader>
<SettingsSectionBody>
<SettingsSectionForm variant="half">
<Form {...form}>
<form id="my-section-form" onSubmit={...}>
<SettingsFormGrid>
<SettingsFormCell span="half">{/* FormField */}</SettingsFormCell>
<SettingsFormCell span="full">{/* SwitchInput */}</SettingsFormCell>
</SettingsFormGrid>
</form>
</Form>
</SettingsSectionForm>
</SettingsSectionBody>
<SettingsSectionFooter>
<Button type="submit" form="my-section-form" loading={saving} disabled={saving}>
{t("saveSettings")}
</Button>
</SettingsSectionFooter>
</SettingsSection>
</SettingsContainer>
```
- `SettingsFormGrid` is a 4-column grid. `SettingsFormCell span` is
`"quarter" | "half" | "full"`.
- Switches use `SwitchInput` (`@app/components/SwitchInput`).
- The submit button sits in the footer, outside the `<form>`, and links to it
with `form="<id>"`.
## Forms
- `useForm({ resolver: zodResolver(schema), defaultValues })` with shadcn
`Form`/`FormField`/`FormItem`/`FormLabel`/`FormControl`/`FormDescription`/`FormMessage`.
- If the schema has user-facing error messages, build it inside the component
with `t` (e.g. `createGeneralFormSchema(t)` in `src/lib/privateResourceForm.ts`).
- Saving: `useActionState(performSave, null)` + `startTransition(() => formAction())`
(see general/page.tsx), or a `useTransition`. Give the button `loading` and
keep its text the same while loading.
- Results: `toast({ title: t("..."), description: t("...") })`. On error:
`toast({ variant: "destructive", title: t("..."), description: formatAxiosError(e, t("...")) })`.
Call `router.refresh()` after saving when server components show the value.
## Paywalls and build gating in the UI
- `useEnvContext().env.flags.disableEnterpriseFeatures`: hide enterprise UI.
- `usePaidStatus().isPaidUser(tierMatrix[TierFeature.X])`: disable controls
for unpaid orgs, and pair it with `<PaidFeaturesAlert tiers={tierMatrix.x} />`.
- `build` from `@server/build` for oss/enterprise/saas differences.
## Navigation
Sidebar entries live in `src/app/navigation.tsx` (`orgNavSections`,
`adminNavSections`, `commandBarNavSections`). Add a new top-level settings page
there, with a translated title.