Skip to content

Drill-through and cross-report links

View as Markdown

A report value becomes navigation only when the frontend can turn authorized, stable data identity into a route. The dataset carries IDs and values. The React screen carries URL policy.

The backend mapper includes project_id as a visually hidden column. It does not serialize href values. The frontend decides what each visible cell should do:

import type { ReportCellLinkResolvers } from "@sapporta/frontend/report";
import type { ProjectProgressQuery } from "task-app-shared";
function generatedTableHref(
table: string,
filters: ReadonlyArray<readonly [column: string, value: string | number]>,
): string {
const query = new URLSearchParams();
for (const [column, value] of filters) {
query.set(`filter[${column}][eq]`, String(value));
}
return `/tables/${table}?${query.toString()}`;
}
function taskTableHref(projectId: number, status?: string): string {
const filters: Array<readonly [string, string | number]> = [
["project_id", projectId],
];
if (status !== undefined) filters.push(["status", status]);
return generatedTableHref("tasks", filters);
}
export const projectProgressLinks = {
project: {
cell: {
project: ({ node }) => {
const projectId = node.columns.project_id;
if (typeof projectId !== "number") return [];
return [
{
label: "Open project",
href: generatedTableHref("projects", [["id", projectId]]),
kind: "drill-down",
icon: "drill-up",
},
];
},
open: ({ node }) => {
const projectId = node.columns.project_id;
if (typeof projectId !== "number") return [];
return [
{
label: "Open tasks",
href: taskTableHref(projectId, "open"),
kind: "drill-down",
icon: "drill-into",
},
];
},
completed: ({ node }) => {
const projectId = node.columns.project_id;
if (typeof projectId !== "number") return [];
return [
{
label: "Completed tasks",
href: taskTableHref(projectId, "completed"),
kind: "drill-down",
icon: "drill-into",
},
];
},
},
},
} satisfies ReportCellLinkResolvers<ProjectProgressQuery>;

The resolver record is keyed first by levelName and then by column ID. Its context contains the current node, value, column, ancestors, dataset, and optional report input. A hierarchical report can therefore resolve a child cell from its own hidden ID plus an ancestor ID.

A resolver may return a list, but the current renderer follows only the first link. Put one canonical action first; later entries are not rendered as a menu.

Pass the resolvers and current query to the renderer:

<ReportGridDataset
dataset={dataset}
links={projectProgressLinks}
linkContext={{ input: query }}
/>

Cross-report links can use the same input to preserve relevant state:

completion: ({ node, input }) => {
const projectId = node.columns.project_id;
if (typeof projectId !== "number") return [];
const targetProjectId = input?.project_id ?? projectId;
const query = new URLSearchParams({
project_id: String(targetProjectId),
});
return [{
label: "Open completion history",
href: `/reports/task-completion-history?${query.toString()}`,
kind: "route",
icon: "report",
}];
},

Only use target: "_blank" for deliberately external destinations. Internal report and generated-table navigation should keep normal app-shell behavior.

Choose a destination the application actually owns

Section titled “Choose a destination the application actually owns”

The generated project destination is the filtered table route:

/tables/projects?filter[id][eq]=<project-id>

URLSearchParams percent-encodes the bracket characters, but it represents the same strict filter grammar. Sapporta generates /tables/:tableName and /tables/:tableName/new; it does not generate /tables/:tableName/:id. An application /projects/:id route is valid only when the application explicitly registers it.

Task links use the same generated query syntax:

GET /api/tables/tasks?filter[project_id][eq]=1&filter[status][eq]=open

Open /reports/project-progress and check each linked value:

  • a project name opens the project table filtered by primary key;
  • open and completed counts open task tables with both project_id and status;
  • the returned task count agrees with the linked report value;
  • a cross-report link preserves the intended project_id; and
  • a synthetic or identifier-less node returns [] and renders no link.

Footer rows do not invoke cell resolvers in the current renderer. There are no general public row or footer resolver slots to configure.

Links are navigation, not authorization. Hidden IDs remain visible to anyone who can read the report response, URL filters are user-controlled, and every destination must repeat its own ability and row-security checks.

Also reload and share the filtered report URL before following its links. That browser check proves the screen reconstructs both the report input and its drill-through context from URL state.