Settings tabs
View on GitHubFlip a placeholder tab live
Worked example: turning Export (Account section) from an
unreachable nav item into a real tab. Its component,
AccountSettingsExportTab.tsx, already exists and already renders a
full UI (two export cards, an account-deletion row) — it's just never
reachable, because its registry entry is enabled: false and it has
no route. This is the same "the UI already exists, just make it real"
shape
../integrations/01-add-a-new-integration.mdIntegrationsAdd a new integrationWorked example: turning Slack from a locked "coming soon" catalog card into a fully working integration, exactly the way Discord works today. Slack already…
covers for a catalog integration — skip straight to whichever step
below actually applies if a placeholder tab you're working with is
further along than Export.
Step 1 — Flip the flag
File: resources/js/types/Settings.ts
{
id: 'export',
path: '/settings/export',
label: 'Export',
icon: 'Download',
section: 'account',
description: 'Prepare and download your account-related data exports.',
enabled: true, // was false
},
This flag is what makes the nav item clickable: the main app Sidebar
(resources/js/Components/Organisms/Sidebar/Sidebar.tsx) renders the
Account/Workspace sections straight from SETTINGS_TABS whenever the
current URL is under /settings, linking each enabled tab to its
path and rendering each disabled one as an inert div with a "Soon"
badge (see ../architecture/03-frontend-architecture-and-atomic-design.mdArchitecture & project technicalsFrontend architecture and atomic designBrowser requests /projects/5 │ ▼ routes/web.php → ProjectController::show() - Inertia::render('Projects/Show', [ ...props ]) │ ▼ Inertia (server) serialize…).
Flipping it alone isn't enough any more, though — without steps 2–4
the link now points at a URL that 404s.
Step 2 — Add the route
File: routes/web.php
Route::get('/settings/export', [SettingsController::class, 'export'])->name('settings.export');
Keep the path identical to the registry entry's path and the route
name at settings.<tab id> — that convention is what lets backend
code link to a tab with route('settings.export') the way
ImportJiraIssuesJob links to route('settings.integrations', ['project' => ...]).
Step 3 — Add the controller action
File: app/Http/Controllers/SettingsController.php
public function export(Request $request): Response
{
return Inertia::render('Settings/Export', [
'projects' => $this->projects($request),
'exportRequests' => $this->exportService->getRequests($request->user()->id),
]);
}
One action per tab, each returning only the props that tab's UI
reads. projects is the exception every action passes — the Sidebar
needs it on every settings page. Anything project-scoped instead
spreads $this->projectScope($projects, $selectedProject) (see the
labels()/members() actions), which supplies projects,
memberProjects and selectedProjectId together. Resist adding a
prop "while you're here" that only a different tab needs — not
over-fetching is the whole point of the per-tab split.
Step 4 — Add the page
New file: resources/js/Pages/Settings/Export.tsx
import AccountSettingsExportTab from '@/Components/Organisms/AccountSettingsContent/AccountSettingsExportTab';
import SettingsLayout from '@/Components/Organisms/SettingsLayout/SettingsLayout';
import { Project } from '@/types/Projects';
interface SettingsExportProps {
projects?: Project[];
exportRequests?: ExportRequest[];
}
export default function SettingsExport({
projects = [],
exportRequests = [],
}: SettingsExportProps) {
return (
<SettingsLayout tabId="export" projects={projects}>
<AccountSettingsExportTab exportRequests={exportRequests} />
</SettingsLayout>
);
}
The page is deliberately thin: declare the props the controller sends,
hand them to the tab component, and let SettingsLayout draw the
sidebar, breadcrumb, heading and description from the tabId's
registry entry. The Inertia page name in
Inertia::render('Settings/Export', ...) has to match this file's
path under resources/js/Pages/ exactly.
Step 5 — Thread real data into the tab component (don't leave it static)
AccountSettingsExportTab today takes no props at all — every string
on it is hardcoded copy. Before shipping it live, thread through
whatever real data the tab actually needs, the same way every other
Account tab already does (AccountSettingsProfileTab takes
userName/userAvatar, AccountSettingsNotificationsTab takes
notificationSettings — see
../notifications/03-frontend-backend-wiring-overview.mdNotifications (in-app & email)Frontend ↔ backend wiring overviewA map of the whole feature: how notifications reach the browser, how read/unread state round-trips, and how the settings tab persists preferences. Useful t…
for that one's full round trip).
File: resources/js/Components/Organisms/AccountSettingsContent/AccountSettingsExportTab.tsx
import Button from '@/Components/Atoms/Button/Button';
import SettingsPanel from '@/Components/Molecules/SettingsPanel/SettingsPanel';
interface AccountSettingsExportTabProps {
userEmail?: string;
}
export default function AccountSettingsExportTab({
userEmail,
}: AccountSettingsExportTabProps) {
return (
<div className="space-y-5">
<SettingsPanel
title="Export data"
description="Generate portable snapshots of your account data."
>
<div className="grid grid-cols-1 gap-3 px-5 py-4 md:grid-cols-2">
<div className="rounded-xl border border-[var(--bg-light-color)] bg-[var(--bg-color)] p-4">
<p className="text-sm font-medium text-[var(--text-color)]">
Full account export
</p>
<p className="mt-1 text-xs text-[var(--text-gray-color)]">
{userEmail
? `We'll email the export to ${userEmail}.`
: 'Includes profile, preferences, and activity.'}
</p>
<Button
type="button"
isBox
className="mt-3 w-full py-1.5"
>
Request export
</Button>
</div>
</div>
</SettingsPanel>
</div>
);
}
Data that's already on the auth shared prop (the user's name, email,
avatar) doesn't need a controller prop at all — read it off
usePage<PageProps>() in the page, the way Pages/Settings/Profile.tsx
does (see
../architecture/03-frontend-architecture-and-atomic-design.mdArchitecture & project technicalsFrontend architecture and atomic designBrowser requests /projects/5 │ ▼ routes/web.php → ProjectController::show() - Inertia::render('Projects/Show', [ ...props ]) │ ▼ Inertia (server) serialize…).
Building the actual "generate and email an export" backend feature is
outside this guide's scope — steps 2–5 are the settings-tab wiring
pattern, not the feature behind a specific placeholder.
Tests
tests/Feature/SettingsControllerTest.php— add a case assertingGET /settings/exportreturns theSettings/Exportcomponent with the props from step 3, mirroring the existing per-tab tests there. If the tab's data is permission-gated, also assert the gated shape for a user who lacks access, the way the labels/integrations cases do.resources/js/Pages/Settings/Export.test.tsx— new file, following any existing page test (e.g.Preferences.test.tsx): mockSettingsLayoutand the tab component, assert the page renders its tab with the righttabIdand forwards its props.resources/js/Components/Organisms/AccountSettingsContent/AccountSettingsExportTab.test.tsx(create if it doesn't exist) — assert the email-conditional copy renders correctly with and without auserEmailprop.resources/js/Components/Organisms/Sidebar/Sidebar.test.tsx— the existing "renders disabled settings tabs without a link" case counts the disabled tabs; check it still holds once one fewer tab is disabled.
