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
- frontend/src/components/analytics/MetricDrilldownDrawer.tsx — the drawer,
RecordsSection, and the dismiss arbitration - frontend/src/pages/hr/hr-analytics/OverviewTab.tsx — contexts built from chart data (the other four tabs do the same)
- frontend/src/hooks/api/useReports.ts and frontend/src/api/reports.ts —
useHrDrilldownRecordsand its wrapper - frontend/src/components/employees/EmployeeDetailDrawer.tsx — the nested detail drawer
- backend/src/main/java/com/motorph/payroll/controller/ReportingController.java — the guarded endpoint
- backend/src/main/java/com/motorph/payroll/service/impl/HrDrilldownQueries.java — the 21-dimension SQL catalog
- backend/src/main/java/com/motorph/payroll/service/impl/HrAnalyticsServiceImpl.java — search + count + page around any dimension
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 —
RecordsSectionexists only while a drill is open, and every drill switch passes through closed, so page and filter can never leak between drills. keepPreviousDataplus 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
entityIdis a doorway — permission allowing, it stacks the lesson-12EmployeeDetailDraweron top, andcloseOnEscape={viewEmployeeId === null}keeps one Escape from collapsing both. - The backend serves one generic shape —
c1/c2/c3/entity_idfrom a per-dimension SQL catalog, wrapped in escaped-ILIKE search and along-cast OFFSET so hostile input cannot break the page.
Next: 19 — The URL is the state.