Custom workflow screens
View as MarkdownA custom workflow screen composes generated reads and application actions around one human workflow. It does not replace generated tables that still own ordinary record work. Register the completed component through Frontend routes, navigation, and layout.
Build the workflow screen
Section titled “Build the workflow screen”Create packages/frontend/src/ProjectProgress.tsx. The generated application
already mounts a TanStack Query provider. The table query option builders call
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.
projectRowSchema and taskRowSchema are
row projections:
the columns this screen reads.
import { useMemo } from "react";import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";import { reloadTGridRows } from "@sapporta/frontend";import { AppPage } from "@sapporta/frontend/layout";import { tableQueryKeys, tableRecordsPageQueryOptions,} from "@sapporta/frontend/table/query";import { ApiError } from "@sapporta/shared/client";import { Button } from "@sapporta/ui";import { Link } from "react-router-dom";import type { Row } from "@sapporta/shared/contracts";import { projectRowSchema, taskCompletionErrorSchema, taskRowSchema, type ProjectRow, type TaskRow, type TaskCompletionErrorBody,} from "task-app-shared";import { taskActionsApi } from "./api";
const TABLE_ROW_CAP = 100;
const decodeProject = (row: Row): ProjectRow => projectRowSchema.parse(row);const decodeTask = (row: Row): TaskRow => taskRowSchema.parse(row);
function taskActionFailure( error: unknown,): TaskCompletionErrorBody | undefined { if (!(error instanceof ApiError)) return undefined;
const parsed = taskCompletionErrorSchema.safeParse(error.body); if (!parsed.success) return undefined;
if ( (error.status === 404 && parsed.data.code === "TASK_NOT_FOUND") || (error.status === 409 && parsed.data.code === "TASK_ALREADY_COMPLETED") ) { return parsed.data; }
return undefined;}
export function ProjectProgress() { const queryClient = useQueryClient(); const projectsQuery = useQuery( tableRecordsPageQueryOptions({ tableName: "projects", page: 1, limit: TABLE_ROW_CAP, decodeRow: decodeProject, }), ); const tasksQuery = useQuery( tableRecordsPageQueryOptions({ tableName: "tasks", page: 1, limit: TABLE_ROW_CAP, decodeRow: decodeTask, }), );
async function refreshCompletionState() { reloadTGridRows("tasks"); reloadTGridRows("task_events"); await Promise.all([ queryClient.invalidateQueries({ queryKey: tableQueryKeys.table("tasks"), }), queryClient.invalidateQueries({ queryKey: tableQueryKeys.table("task_events"), }), ]); }
const completeTask = useMutation({ mutationFn: async (task: TaskRow) => { await taskActionsApi.completeTask({ params: { id: task.id }, body: {}, }); return task; }, onSuccess: refreshCompletionState, onError: async (error) => { if (taskActionFailure(error)) { await refreshCompletionState(); } }, throwOnError: (error) => taskActionFailure(error) === undefined, });
const projects = projectsQuery.data?.data ?? []; const tasks = tasksQuery.data?.data ?? []; const loading = projectsQuery.isPending || tasksQuery.isPending; const loadError = projectsQuery.error ?? tasksQuery.error; const actionError = taskActionFailure(completeTask.error); const pendingTaskId = completeTask.isPending ? completeTask.variables?.id : undefined; const incomplete = (projectsQuery.data?.meta.total ?? 0) > (projectsQuery.data?.data.length ?? 0) || (tasksQuery.data?.meta.total ?? 0) > (tasksQuery.data?.data.length ?? 0);
const tasksByProject = useMemo(() => { const grouped = new Map<number, TaskRow[]>(); for (const task of tasks) { const projectTasks = grouped.get(task.project_id) ?? []; projectTasks.push(task); grouped.set(task.project_id, projectTasks); } return grouped; }, [tasks]);
if (loading) { return ( <AppPage title="Project progress" bodyClassName="p-6"> <p className="text-sm text-sap-muted">Loading progress…</p> </AppPage> ); }
if (loadError) { return ( <AppPage title="Project progress" bodyClassName="p-6"> <p role="alert" className="text-sm text-red-700"> Project progress could not be loaded. </p> <Button className="mt-3" onClick={() => void Promise.all([projectsQuery.refetch(), tasksQuery.refetch()]) } > Retry </Button> </AppPage> ); }
if (incomplete) { return ( <AppPage title="Project progress" bodyClassName="space-y-3 p-6"> <p role="status" className="text-sm text-sap-muted"> This bounded summary is incomplete because at least one generated read exceeded the {TABLE_ROW_CAP}-row cap. Use the scoped project-progress report for complete totals. </p> </AppPage> ); }
if (projects.length === 0) { return ( <AppPage title="Project progress" bodyClassName="p-6"> <p className="text-sm text-sap-muted">No projects are visible.</p> <Link className="mt-4 inline-block underline" to="/tables/projects/new"> Create a project </Link> </AppPage> ); }
return ( <AppPage title="Project progress" actions={ <Link className="text-sm underline" to="/tables/tasks"> Open Tasks </Link> } bodyClassName="space-y-4 p-6" > <p className="text-sm text-sap-muted"> Complete tasks here. Edit records in the generated tables. </p>
{completeTask.isSuccess && ( <p role="status" className="text-sm text-green-700"> {completeTask.data.title} is complete. </p> )} {actionError && ( <p role="alert" className="text-sm text-red-700"> {actionError.error} </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={() => completeTask.mutate(task)} > {pendingTaskId === task.id ? "Completing…" : "Complete"} </Button> )} </li> ))} </ul> </section> ); })} </AppPage> );}AppPage gives every state the shell’s standard fixed header and one scrolling
body. The shell keeps its responsive sidebar control available without adding a
toggle to ProjectProgress. A workspace that owns its own height and overflow
can compose PageFrame, PageHeader, and PageBody directly; a naturally
growing route can omit these wrappers and use the shell scroller.
tableRecordsPageQueryOptions() supplies stable table cache keys, passes query
cancellation to the generated HTTP request, and decodes each row at the browser
boundary. taskCompletionErrorSchema narrows only the feature’s declared
recovery bodies. The component does not maintain a second loader or hand-copy
the error envelope.
The completion transaction changes both tasks and task_events. Success,
404 TASK_NOT_FOUND, and 409 TASK_ALREADY_COMPLETED all make the visible
collections potentially stale, so those declared branches invalidate both
TanStack Query table prefixes. The 409 contract describes a sequential repeat
after the task is already complete; it does not promise a cross-process
simultaneous-writer conflict.
Malformed declared bodies, transport and response-validation failures, and
unexpected errors do not become local action messages. throwOnError keeps them
on the application’s central error-boundary path.
reloadTGridRows() signals an affected mounted, registered TGrid root and is a
no-op when that session is absent. TanStack Query and TGrid are separate
server-state consumers.
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.
Exercise every screen state
Section titled “Exercise every screen state”Use rows created inside the test or test session; do not depend on a previous guide’s seed data or fixed IDs. Cover these states and boundaries:
- Loading, retryable read error, empty, ready, per-task pending, success, declared stale failure, and the central unexpected-error path.
- Only the task being submitted is disabled; the rest of the screen remains readable.
- An aborted query publishes no replacement page, and one malformed row fails the query instead of producing a smaller aggregate.
- When either response has
meta.total > data.length, the 100-row incomplete state appears and no complete-looking counts are rendered. - Success and declared stale
404/repeat409branches invalidate thetasksandtask_eventsTanStack Query prefixes. - Only mounted TGrids registered for those affected root tables reload.
- Stable project and task IDs survive sort and refresh; no interaction state depends on an array index.
- A completion ratio of
0.4displays as40%. - The empty-state action navigates to
/tables/projects/new, while the Tasks link returns to/tables/tasks. - Negative API tests still prove server ability and row-scope enforcement.
Route reload and responsive shell checks belong to the routing and layout guide.
This screen intentionally reads at most 100 records from each table. It is for a small, screen-local projection. Put reusable or complete authoritative totals behind a scoped report route; large datasets need scoped SQL grouping or another store-level implementation.