Custom frontend routes and screens
View as MarkdownA custom screen combines generated records and app-owned actions into one domain workflow. The generated table routes remain the standard create, edit, filter, and export surfaces; the custom route adds only the interaction the application needs.
This page builds a protected project-progress screen, loads row-scoped table data, invokes the typed completion endpoint, and connects the screen to application navigation. The same structure supports dashboards, review queues, import wizards, master-detail workspaces, and other multi-record workflows.
Add a protected project-progress screen. Load visible projects and tasks through Sapporta's table client, show explicit loading, empty, ready, pending, success, and error states, complete tasks through the typed client, and link back to generated tables.Build the workflow screen
Section titled “Build the workflow screen”Create packages/frontend/src/ProjectProgress.tsx. fetchTableRows() calls the
generated /api/tables/:tableName routes, so server-side row visibility still
applies. The custom endpoint owns completion because it changes both task status
and history.
import { useCallback, useEffect, useMemo, useState } from "react";import { fetchTableRows } from "@sapporta/frontend";import { ApiError } from "@sapporta/shared/client";import { Button } from "@sapporta/ui";import { Link } from "react-router-dom";import { taskActionsApi } from "./api";
type Project = { id: number; name: string };type Task = { id: number; project_id: number; title: string; status: string;};
type ErrorBody = { error: string; code: string };
function isErrorBody(value: unknown): value is ErrorBody { return ( typeof value === "object" && value !== null && "error" in value && typeof value.error === "string" && "code" in value && typeof value.code === "string" );}
function projectsFrom(rows: Record<string, unknown>[]): Project[] { return rows.flatMap((row) => typeof row.id === "number" && typeof row.name === "string" ? [{ id: row.id, name: row.name }] : [], );}
function tasksFrom(rows: Record<string, unknown>[]): Task[] { return rows.flatMap((row) => typeof row.id === "number" && typeof row.project_id === "number" && typeof row.title === "string" && typeof row.status === "string" ? [ { id: row.id, project_id: row.project_id, title: row.title, status: row.status, }, ] : [], );}
export function ProjectProgress() { const [projects, setProjects] = useState<Project[]>([]); const [tasks, setTasks] = useState<Task[]>([]); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState<string | null>(null); const [actionError, setActionError] = useState<string | null>(null); const [notice, setNotice] = useState<string | null>(null); const [pendingTaskId, setPendingTaskId] = useState<number | null>(null);
const refresh = useCallback(async () => { setLoadError(null); try { const [projectResult, taskResult] = await Promise.all([ fetchTableRows({ tableName: "projects", limit: 100 }), fetchTableRows({ tableName: "tasks", limit: 100 }), ]); setProjects(projectsFrom(projectResult.data)); setTasks(tasksFrom(taskResult.data)); } catch { setLoadError("Project progress could not be loaded."); } finally { setLoading(false); } }, []);
useEffect(() => { void refresh(); }, [refresh]);
const tasksByProject = useMemo(() => { const grouped = new Map<number, Task[]>(); for (const task of tasks) { grouped.set(task.project_id, [ ...(grouped.get(task.project_id) ?? []), task, ]); } return grouped; }, [tasks]);
async function handleComplete(task: Task) { setPendingTaskId(task.id); setActionError(null); setNotice(null); try { await taskActionsApi.completeTask({ params: { id: task.id }, body: {}, }); await refresh(); setNotice(`${task.title} is complete.`); } catch (error) { if (error instanceof ApiError && isErrorBody(error.body)) { setActionError(error.body.error); } else { setActionError("The task could not be completed."); } } finally { setPendingTaskId(null); } }
if (loading) { return <p className="p-6 text-sm text-sap-muted">Loading progress…</p>; }
if (loadError) { return ( <div className="p-6"> <p role="alert" className="text-sm text-red-700"> {loadError} </p> <Button className="mt-3" onClick={() => void refresh()}> Retry </Button> </div> ); }
if (projects.length === 0) { return ( <div className="p-6"> <h1 className="text-xl font-semibold">Project progress</h1> <p className="mt-2 text-sm text-sap-muted">No projects are visible.</p> <Link className="mt-4 inline-block underline" to="/tables/projects"> Create a project </Link> </div> ); }
return ( <main className="space-y-4 p-6"> <div className="flex items-center justify-between gap-4"> <div> <h1 className="text-xl font-semibold">Project progress</h1> <p className="text-sm text-sap-muted"> Complete tasks here. Edit records in the generated tables. </p> </div> <Link className="text-sm underline" to="/tables/tasks"> Open Tasks </Link> </div>
{notice && ( <p role="status" className="text-sm text-green-700"> {notice} </p> )} {actionError && ( <p role="alert" className="text-sm text-red-700"> {actionError} </p> )}
{projects.map((project) => { const projectTasks = tasksByProject.get(project.id) ?? []; const completed = projectTasks.filter( (task) => task.status === "completed", ).length;
return ( <section key={project.id} className="rounded-lg border p-4"> <div className="flex items-center justify-between gap-4"> <h2 className="font-medium">{project.name}</h2> <span className="text-sm text-sap-muted"> {completed} of {projectTasks.length} complete </span> </div>
<ul className="mt-3 divide-y"> {projectTasks.map((task) => ( <li key={task.id} className="flex items-center gap-3 py-2"> <span className="min-w-0 flex-1 truncate">{task.title}</span> <span className="text-xs text-sap-muted">{task.status}</span> {task.status !== "completed" && ( <Button size="sm" disabled={pendingTaskId === task.id} onClick={() => void handleComplete(task)} > {pendingTaskId === task.id ? "Completing…" : "Complete"} </Button> )} </li> ))} </ul> </section> ); })} </main> );}The screen intentionally loads at most 100 records for an introductory dashboard. A larger dataset should move aggregation and pagination into a report or custom endpoint instead of downloading every row.
Client filters, hidden fields, and route parameters are product constraints, not
authorization. Do not add workspace_id or scoped_to_user_id to this
component. The generated table APIs and custom workflow enforce authority on the
server.
Add the protected route and navigation item
Section titled “Add the protected route and navigation item”Update packages/frontend/src/App.tsx using the project’s existing exported
values:
import { Navigate, Route } from "react-router-dom";import type { Navigation } from "@sapporta/frontend/shell";import { ChartNoAxesColumnIncreasing, Sparkles } from "lucide-react";import { ProjectProgress } from "./ProjectProgress";import { Welcome } from "./Welcome";
const welcomePath = "/welcome";
export const appNavigation: Navigation = [ { label: "Views", items: [ { label: "Welcome", icon: Sparkles, to: welcomePath }, { label: "Project progress", icon: ChartNoAxesColumnIncreasing, to: "/projects/progress", }, ], },];
export const appHomeRoute = ( <Route index element={<Navigate to={welcomePath} replace />} />);
export const appProtectedRoutes = ( <> <Route path="projects/progress" element={<ProjectProgress />} /> </>);Keep the existing appPublicRoutes export and any other routes in the file.
Protected routes render after the application has loaded the authenticated
session and active workspace. A public route belongs in appPublicRoutes only
when both the page and its data are intentionally anonymous.
Exercise every screen state
Section titled “Exercise every screen state”Run the frontend build, then start the app:
pnpm buildpnpm devUse the canonical task dataset. Open /projects/progress, complete
Audit launch checklist, and follow the Tasks link to the generated table. The
project count should increase, the task status should read completed, and the
generated Task history should contain the completion event.
Capture the empty state separately only if that state needs documentation:
The application now has one protected workflow screen without replacing the generated record system. Table reads remain row-scoped, completion remains a server transaction, and ordinary editing stays on generated routes. The less obvious design boundary is scale: client-side aggregation is suitable for a bounded tutorial dataset, while larger workloads belong in report routes. From here, add report-backed summaries, role-aware action visibility, or a Grid-based worklist when selection and bulk commands become central to the workflow.