Skip to main content

18 — The drilldown drawer decoded

Read this first: you click a bar in a department chart and a drawer slides in with a headline, a breakdown, a searchable list of the actual employees — and each row opens a second drawer on top. This chapter reads the whole path: one component, one hook, one endpoint, and about 170 lines of Java SQL catalog. The surprise is how little of it is network code.

Time: about 45 minutes. Assumes lesson 17.

One nullable object is the whole API​

MetricDrilldownDrawer has no internal notion of "what was clicked". It receives a DrilldownContext | null and renders whatever it is handed:

export interface DrilldownContext {
eyebrow: string;
title: string;
headline?: { label: string; value: string };
/** Optional composition bar (e.g. present / on-leave / absent). */
bar?: DrilldownBarSeg[];
rows: DrilldownRow[];
note?: string;
/** Lazily fetches and lists the actual records behind the metric. */
records?: { dimension: DrilldownDimension; key?: string | null; heading: string };
/** A primary action, e.g. "Focus this department" (applies the cross-filter). */
focusAction?: { label: string; active: boolean; onClick: () => void };
}

Null means closed. The drawer is fully controlled: open={context !== null}, and closing means the parent sets its state back to null. There is no isOpen boolean to fall out of sync with the content — the content is the open state.

Look at what the fields are: strings, numbers, pre-formatted values. A caller in OverviewTab.tsx builds the entire aggregate view from data the charts already fetched:

openDrill({
eyebrow: 'Department',
title: name,
headline: { label: 'Headcount', value: d.headcount.toLocaleString() },
rows: [
{ label: 'Share of workforce', value: pct(total ? d.headcount / total : 0) },
{ label: 'Rank by headcount', value: `#${rank} of ${byDept.length}` },
// …
],
records: { dimension: 'DEPARTMENT', key: name, heading: 'Employees' },
// …
});

The share, the rank, the overtime rows — all computed client-side from the department array the chart rendered from. The component's doc comment states the split as the design:

/**
* Explains one slice of a chart in place — the drill-down behind a clicked mark.
* The aggregate (headline / bar / rows) is handed in fully-built by the caller; the
* record list is fetched lazily here from the generic drill-down endpoint. Each
* record carries the employee number behind it, so (permission allowing) a row
* click stacks the EmployeeDetailDrawer on top — master → detail without leaving
* the analytics page. Follows the app's detail-drawer idiom
* (see components/crm/OrganizationDetailDrawer).
*/

So opening a drill costs zero requests for the summary and exactly one for the record list — and only if the caller asked for one by setting records. The drawer stays dumb; every tab of the analytics page (lesson 14) reuses it by building different contexts.

A fresh list on every drill​

The record list lives in an inner component, RecordsSection, that owns its own search box and page number. It is rendered only inside context && (…), and its comment explains why that mount boundary is load-bearing:

/**
* The record list, owning its search/page state and its own (paged) fetch.
* Mounted only while a drill is open — every drill switch passes through a
* closed state (the backdrop blocks all drill triggers), so a new drill always
* starts on page one with a clean filter, regardless of whether the previous
* one closed by dismissal or by a controlled prop flip. Search and paging are
* server-side, so arbitrarily large record sets stay reachable.
*/

No useEffect resets page when the dimension changes, because none is needed: you cannot click a chart while a drawer's backdrop covers it, so switching drills always unmounts RecordsSection first. State that dies with its component cannot leak into the next drill.

Search is debounced 300ms before it hits the server, and the debounce carries a subtlety worth copying — a ref that prevents no-op edits from resetting the page:

// The ref mirrors debouncedSearch so a keystroke that nets out to the same
// trimmed value (type-and-erase, lone whitespace) doesn't yank the user back
// to page one.
const debouncedRef = useRef('');
useEffect(() => {
const t = setTimeout(() => {
const next = searchQuery.trim();
if (debouncedRef.current !== next) {
debouncedRef.current = next;
setDebouncedSearch(next);
setPage(0);
}
}, SEARCH_DEBOUNCE_MS);
return () => { clearTimeout(t); };
}, [searchQuery]);

Without the guard, typing a character on page three and deleting it would refetch page one — the kind of paper-cut you only notice when it is gone.

Page flips without skeletons​

The fetch goes through the data layer's standard shape (lesson 04): a thin wrapper in api/reports.ts and a query hook in useReports.ts. The hook is four lines of configuration, and one of them does the heavy lifting:

queryFn: () => getHrDrilldownRecords(dimension, key, opts),
enabled,
staleTime: 30_000,
// Page flips keep the previous page on screen instead of flashing skeletons.
placeholderData: keepPreviousData,

Every distinct (dimension, key, search, page, size) tuple is a separate cache entry, so flipping back to a page you already saw is instant. keepPreviousData means flipping forward shows the old page dimmed (opacity={isFetching ? 0.6 : 1}) instead of a skeleton flash. Skeletons appear only on the very first load of a drill.

One effect remains, and it earns its eslint-disable: if a search narrows the result set while you sit on page four, the server's total shrinks under you and page four no longer exists.

// If a fetch lands with a smaller total than the page we're on (search narrowed
// the set mid-page-flip, or rows disappeared on a refetch), snap back to the
// last valid page — otherwise the user is stranded on an empty, pager-less list.
useEffect(() => {
if (data && page > 0 && page * PAGE_SIZE >= data.total) {
// Correcting local state from a server response, not a render cascade.
// eslint-disable-next-line react-hooks/set-state-in-effect
setPage(Math.max(0, Math.ceil(data.total / PAGE_SIZE) - 1));
}
}, [data, page]);

The lint rule bans setState-in-effect because it usually hides a render cascade. Here the trigger is a server response, not a render — the comment says so, and the disable is scoped to one line.

A row can be a person​

Every record the backend returns carries an optional entityId — the employee number behind the row. RecordRow branches on it: no id, plain HStack; id present and the caller passed an onOpen, and the row becomes a real <chakra.button> with hover, focus ring, and a chevron.

/** One record: a plain row, or a button that opens the employee behind it. */
const RecordRow = ({ record, onOpen }: { record: DrilldownRecordDto; onOpen?: (id: number) => void }) => {
if (onOpen == null || record.entityId == null) {
// … plain, non-interactive row
}

onOpen is only wired when the viewer holds the employees-view permission — hasPermission(PermissionConstants.HR_EMPLOYEES_VIEW) gates it, so an analytics-only role sees the same list without the affordance. Clicking sets viewEmployeeId, which drives a second drawer rendered after the drill-down:

{/* Master → detail: stacks on top of the drill-down (rendered later, so it
sits above; the drill-down suspends its own dismiss handling meanwhile). */}
<EmployeeDetailDrawer employeeId={viewEmployeeId} onClose={() => setViewEmployeeId(null)} />

This is the exact drawer from lesson 12, reused unchanged — its own props comment explains it fetches the full record because callers only hold projections:

interface EmployeeDetailDrawerProps {
/** The grid row only carries the projected columns, so the full record is fetched here. */
employeeId: number | null;
onClose: () => void;
}

Same contract as the drill drawer itself: nullable id, controlled open. The idiom composes.

Every close goes through one funnel​

Here is the scar. Chakra v3 drawers sit on Zag state machines, and a controlled open flip does not fire onOpenChange — that callback only fires for dismissals the machine itself initiates (Escape, backdrop click, close button). So any close done by setting the prop must clean up manually, and the component funnels every path through one function:

// Every close path must come through here (including focusAction below —
// a controlled `open` flip does not fire onOpenChange, so relying on the
// parent's setState alone would leak a stacked detail into the next drill.
const handleClose = () => {
setViewEmployeeId(null);
onClose();
};

The failure it prevents is concrete: click "Focus this department" while an employee drawer is stacked on top, and if only onClose() ran, viewEmployeeId would survive — the next drill you open would arrive with a stranger's detail drawer already covering it.

Predict: the nested employee drawer is open on top of the drill drawer, and you press Escape. Which drawer closes — the top one, the bottom one, or both? Write your answer down, then read the Drawer.Root props.

<Drawer.Root
open={context !== null}
onOpenChange={({ open }) => { if (!open) handleClose(); }}
size="lg"
// While the employee detail is stacked on top, only it should respond
// to Esc / outside clicks — otherwise both drawers would dismiss.
closeOnEscape={viewEmployeeId === null}
closeOnInteractOutside={viewEmployeeId === null}
>

Only the top one. Both drawers are open, both would happily handle Escape — so the drill drawer disarms its own dismissal whenever a detail is stacked (viewEmployeeId === null is then false). Escape closes the employee drawer; a second Escape, now that the drill has re-armed, closes the drill. Without the arbitration, one keypress would collapse the whole stack.

One endpoint, twenty-one dimensions​

The record list's only request is GET /api/reports/hr-analytics/drilldown, guarded like every other analytics endpoint in ReportingController:

@GetMapping("/hr-analytics/drilldown")
@PreAuthorize("hasAuthority('" + PermissionConstants.HR_DASHBOARD_VIEW + "')")
public ResponseEntity<DrilldownRecordsDto> hrAnalyticsDrilldown(
@RequestParam DrilldownDimension dimension,
@RequestParam(required = false) String key,
@RequestParam(required = false) String search,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "25") int size) {

How does one endpoint list employees, leave requests, overtime, and timesheets? By refusing to model them. The DTO's doc comment is explicit:

/**
* One display-shaped record behind a drilled-into metric. Deliberately generic so a
* single endpoint can return employees, leave requests, overtime requests, or
* timesheets: primary is the headline (usually the employee name), secondary and
* tertiary are supporting context. entityId is the employee number behind the row
* (every dimension resolves to a person), letting the UI open that employee's
* detail view; null when a row has no resolvable employee.
*/
public record DrilldownRecordDto(
String primary, String secondary, String tertiary, Integer entityId
) {}

HrDrilldownQueries is the catalog: a switch over all twenty-one DrilldownDimension values, each producing SQL that aliases its three display columns to c1/c2/c3 and piggybacks the employee number as entity_id. The class comment carries two guarantees: key is always a bound parameter (band orders are parsed to int first), "so nothing here is string-concatenated from user input", and "Every ORDER BY ends in a unique key … Postgres only returns consistent pages when the ordering is total" — the row-order-drift lesson the e2e suite learned the hard way, baked into a comment.

HrAnalyticsServiceImpl then wraps any of those queries in a count plus a page, without knowing which dimension it is:

String where = "";
List<Object> args = new java.util.ArrayList<>(java.util.Arrays.asList(query.args()));
if (search != null && !search.isBlank()) {
where = " WHERE t.c1 ILIKE ? ESCAPE '\\' OR t.c2 ILIKE ? ESCAPE '\\' OR t.c3 ILIKE ? ESCAPE '\\'";
String pattern = "%" + search.trim().replaceAll("([\\\\%_])", "\\\\$1") + "%";
// … bound three times
}

Long total = jdbcTemplate.queryForObject(
"SELECT count(*) FROM (" + query.sql() + ") t" + where, Long.class, args.toArray());

List<Object> pageArgs = new java.util.ArrayList<>(args);
pageArgs.add(pageSize);
// long: a hostile page number would overflow int into a negative OFFSET (Postgres error).
pageArgs.add((long) pageIndex * pageSize);

Three details to keep. The search escapes \, % and _ before binding, so a user typing 100% matches the literal text instead of everything. The count and the page run over the same wrapped query, so total and the rows can never disagree about what matched. And the offset multiplication is cast to long first — page=999999999 times 25 overflows int into a negative number, and a negative OFFSET is a Postgres error a stranger could trigger from a query string. The section comment above the method adds the correctness argument for wrapping: "a filter never reorders the rows it keeps, whether the planner applies it before or after the sort."

The deep link that reopens a drill from ?drill= in the URL is the next chapter's story.

Where this shows up in MotorPH​

Recap​

  • The context object is the open state — open={context !== null}, callers hand in the whole aggregate pre-built, so a drill costs one request (the record list) or none at all.
  • Mount boundaries reset state for free — RecordsSection exists only while a drill is open, and every drill switch passes through closed, so page and filter can never leak between drills.
  • keepPreviousData plus a page-correction effect make server paging feel local — flips dim instead of flashing, and a shrunken result set snaps you to the last valid page.
  • A row with an entityId is a doorway — permission allowing, it stacks the lesson-12 EmployeeDetailDrawer on top, and closeOnEscape={viewEmployeeId === null} keeps one Escape from collapsing both.
  • The backend serves one generic shape — c1/c2/c3/entity_id from a per-dimension SQL catalog, wrapped in escaped-ILIKE search and a long-cast OFFSET so hostile input cannot break the page.

Next: 19 — The URL is the state.