Skip to content

feat(ou-tree): render browse mode as a virtualised list of composable rows - #1786

Merged
HendrikThePendric merged 30 commits into
feat/paged-org-unit-treefrom
feat/ou-tree-rendering
Oct 28, 2026
Merged

HendrikThePendric merged 30 commits into
feat/paged-org-unit-treefrom
feat/ou-tree-rendering

Conversation

@HendrikThePendric

@HendrikThePendric HendrikThePendric commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

Implements DHIS2-21263 (client side, batch 3 of OuTree)


Description

Rendering for OuTree's browse mode, over the batch 2 data layer: a virtualised list that looks like OrganisationUnitTree, built from exported hooks and small style pieces with OuTree as a few lines of glue. Still over the stand-in; still not exported from any package.

  • Rows are measured, with @tanstack/react-virtual: 24px by default, taller when a long name wraps or a custom label needs it. Rows are keyed by identity (node id, or parent and slot, rowKey), the row builder reuses every row whose content is unchanged, and the virtualiser's getItemKey changes only with the tree, so opening a node does not remount or re-measure the rows below it and scrolling costs nothing per row.
  • Indentation and guide lines are markup: OuTreeRowShell renders one span.indent per level, each drawing its ancestor's line, so right-to-left needs no extra rule and a later batch can highlight one ancestor's lines across rows. No nesting of subtrees.
  • Failures, one model for both kinds: red means failed, hover or keyboard focus colours the control and shows a sync icon at its end, one tooltip beside the control says what failed and that a click retries, and the control is a plain button. A node whose first page failed did not open: the row body is the button, hugging its content: the error icon in the arrow's place, the label and, on hover, the sync icon after it; and clicking it opens the node again, which requests the page again. Each empty slot of a failed later page, and each failed root, is a retry skeleton: a button that is itself the red error bar, named "Retry"; clicking any slot reloads the page. Roots load as one skeleton row per root id. Skeleton and retry bars share one silhouette, 20px tall with 2px above and below, and sit after the empty toggle slot, so they line up with labels and a loaded row keeps its place.
  • Three layers, all exported from src/ou-tree/index.ts: hooks (useOuTree, useOuTreeNode, new useOuTreeActions for components that act on the tree without subscribing, new useOuTreeVirtualRows); styled pieces that take plain props and read nothing from the tree (OuTreeContainer, OuTreeVirtualSizer, OuTreeVirtualRow, OuTreeRowShell, OuTreeToggle with a leaf state, OuTreeNodeLabel, OuTreeLoader, OuTreeSkeleton, OuTreeRetrySkeleton, OuTreeRetryNode); glue with no styles (OuTreeNodeToggle, OuTreeNodeRow, OuTreeSkeletonRow, OuTreeErrorRow, OuTreeRow, OuTreeRootsRows, OuTreeList, OuTree).
  • renderLabel is the one customisation convenience: it swaps what sits in the label slot and keeps the indent, toggle and loader. It must be a stable function, since rows are memoised on the row object. Batch 5's checkbox and single-selection labels go in that slot; while a node's children failed to load the slot sits inside the row's retry button, which batch 5's focus model has to resolve; that model should also replace both retry buttons, which are non-conforming children of a tree, with the treeitem's own action.
  • Naming: the tree exposes rootIds (as given) and rootNodes (loaded, nested ones excluded); the component prop and the endpoint query parameter are rootIds, as DHIS2-21263 specifies (the dhis2-core PR still says roots). useOuTreeNode and renderLabel call the children status childrenStatus. The _País_Test unit is dropped from the PAHO fixture.
  • Stories: the tree over the PAHO stand-in (default, initially open, long names, custom label, loading and errors, roots failed, right to left) and, in the same OuTree sidebar entry, a Building block: story per piece, each in each state with no tree behind it. The stand-in gained delay and a gist failWith for them, and the stand-in answers after 1.5 seconds so the Netlify preview demos every loading state; STORYBOOK_OU_TREE_DELAY=<milliseconds> sets another latency when Storybook starts, 0 included.

Deviations from the old look, on purpose: a long name wraps instead of overflowing sideways; the first-page error is an icon and a button in the row instead of text under it; the guide line runs through the last descendant's whole row; the toggle's hover no longer darkens the line. New dependencies on this package: @tanstack/react-virtual, @dhis2-ui/tooltip, @dhis2/ui-icons.


Known issues

  • Failure states are visual only: the error message is in a tooltip, a failed node's button is named by its label and the retry skeletons are all named "Retry". Announcing failures to assistive technology is part of batch 5 (selection and keyboard), with the rest of the ARIA tree pattern.
  • eslint prints a React Compiler warning on useVirtualizer (react-hooks/incompatible-library), a warning, not an error, and not suppressible without an eslint-disable.

Checklist

  • API docs are generated (nothing is exported, so there is nothing to document)
  • Tests were added
  • Storybook demos were added

Screenshots

Initially open (AMR, Brazil, Minas Gerais); the first-page failure on Rio de Janeiro hovered, with its tooltip; the failed page 3 of Minas Gerais with one slot hovered; and a loading page beside leaf rows, the skeletons lined up with the labels.
outree--initially-open

outree--loading-and-errors-rio-hover outree--loading-and-errors-page3-hover outree--loading-and-errors-page2-skeletons

🤖 Generated with Claude Code

HendrikThePendric and others added 11 commits October 27, 2026 13:23
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…with measured rows

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the label

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…pieces with their rows

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…dispatcher

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tates

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ggle-column placeholder

The roots-failed rows used the error message as the retry button's
accessible name, through an extra label prop. Every failure now shows the
same button named Retry; how failures are announced is for the keyboard
and accessibility batch. The leaf's empty toggle column is a styled span
rather than an inline style.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@HendrikThePendric
HendrikThePendric requested a review from a team as a code owner October 27, 2026 13:22
HendrikThePendric and others added 4 commits October 27, 2026 15:32
A test unit under AMR that looks out of place among the countries. The
derivation script skips it so a re-derive gives the same fixture; the
recorded root counts follow (AMR 26, CAR 27).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The tree exposes rootIds, the ids as given, and rootNodes, the loaded
roots with nested ones excluded, empty until loaded. The prop on
OuTreeProvider and OuTree is rootIds, so prop and handle agree. The
data layer keeps roots, the endpoint's parameter name.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
DHIS2-21263 specifies rootIds; the dhis2-core PR still says roots and
is expected to follow the ticket. The query type, the stand-in and its
resolver follow the ticket, and the stand-in's option is defaultRootIds.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A failed later page showed a retry button beside each of its empty
slots: up to fifty identical icons for one action. Each slot is now an
OuTreeRetrySkeleton, an error-state skeleton inside a button named
Retry with a tooltip; the icon appears on hover and keyboard focus only.
Clicking any slot reloads the page. Failed roots use the same piece.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@HendrikThePendric
HendrikThePendric changed the base branch from master to feat/paged-org-unit-tree October 27, 2026 14:21
HendrikThePendric and others added 3 commits October 27, 2026 16:53
OuTreeList carried the only styles of the glue layer. They move into
OuTreeContainer (the scroll element with role tree), OuTreeVirtualSizer
(the box as tall as every row) and OuTreeVirtualRow (the element the
virtualiser places and measures), and the roots placeholders into
OuTreeRootsRows, so the list is a short composition over them. A
Structure story shows the three together.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The node row's inline switch over leaf, loading, failed and plain
toggle moves into OuTreeNodeToggle, glue with no styles. The toggle
piece gains a leaf state: the toggle's space, hidden from assistive
technology, so labels line up and the row keeps no styles of its own.
A node row whose node is not stored is an error now, not an empty
render; the row tests wait for the node as the list does.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
On the node handle and in the renderLabel props, `children` was the
ChildrenStatus, which reads as React children.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dhis2-bot

Copy link
Copy Markdown
Contributor

🚀 Deployed on https://pr-1786--dhis2-ui.netlify.app

@dhis2-bot
dhis2-bot temporarily deployed to netlify October 27, 2026 15:19 Inactive
HendrikThePendric and others added 3 commits October 27, 2026 17:34
…th the tree

An inline function changed every render, and TanStack rebuilds its
measurements for every row when it does, so each scroll-driven render
walked all rows. The callback is keyed on the tree handle now.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
An error row's key included its flat row index, so error rows remounted
and lost their measurement when rows above them shifted. The error row
carries its slot index now and is keyed on it, like a skeleton row, and
the row builder reuses skeleton and error rows as it reused node rows.
rowKey lives in the tree layer with the rows it names.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
The story description counts 19,190 units. CLAUDE.md lists every glue
component and the test-id suffixes, including the leaf's -toggle-space
and the composite OuTreeRetrySkeleton. The pieces stories' layout
helper is Stack. A list test counts a node row's renders while another
node opens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dhis2-bot
dhis2-bot temporarily deployed to netlify October 27, 2026 16:01 Inactive
Storybook forwards STORYBOOK_* variables to the preview, so the OuTree
stories read STORYBOOK_OU_TREE_DELAY and pass it as `delay` to every
resolver. The gist resolver gains the same `delay` option the tree
resolver has, so roots wait too, and both share one `wait` helper. The
loading and errors story keeps at least its one second.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dhis2-bot
dhis2-bot temporarily deployed to netlify October 28, 2026 09:07 Inactive
…fixed stories

`OuTree/Pieces` made `OuTree` a folder in the sidebar, unlike every other
component. The piece stories now live in the tree's stories file under the
same title, after the tree's own stories, named `Building block: <piece>`.
A `Tree` wrapper replaces the file decorator, since the building block
stories need no providers or box.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dhis2-bot
dhis2-bot temporarily deployed to netlify October 28, 2026 09:36 Inactive
…r tooltip's reference

Wrapped as a node, the Tooltip puts its children in a focusable span and
positions over that span. Outside a flex row the span spans the line, so
the tooltip landed in the middle of the page, and the retry skeleton had
two tab stops. Both now use the Tooltip's function child: the button and
the icon carry the handlers and the ref themselves.

The loading and errors story opens Rio de Janeiro, so the failed first
page is on screen without a click.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dhis2-bot
dhis2-bot temporarily deployed to netlify October 28, 2026 10:11 Inactive
…ntrol for both failure kinds

A node whose first children page failed never opened: isOpen is false
while the status is failed, so there is nothing to close, and opening the
node again requests the page again. Before, clicking the label of such a
node closed it and hid the error.

Both failure kinds now share one model. Red means failed; hover and
keyboard focus show a sync icon inside the control and colour it red200
with a red400 edge; one tooltip says what failed and that a click retries;
the whole control is the button. OuTreeRetryNode wraps a failed node's
label slot with the error icon in the toggle's place and replaces
OuTreeErrorIcon and OuTreeRetryButton. OuTreeRetrySkeleton draws its sync
icon inside the bar. OuTreeNodeToggle takes `loading` instead of the
status, having no failed branch left.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dhis2-bot
dhis2-bot temporarily deployed to netlify October 28, 2026 13:17 Inactive
…error icon and the label is the button

The retry skeleton's button is itself the red bar, with no skeleton element
inside it, and the sync icon sits at the bar's end with air around it.
OuTreeSkeleton loses its error state, which only the retry skeleton used.

A node whose children did not load keeps the error icon in the toggle's
place (OuTreeErrorIcon is back, without a tooltip; OuTreeNodeToggle takes
`failed`), and the label slot is the button: it hugs the label instead of
spanning the row, and the sync icon follows the label on hover and focus,
keeping its space while hidden so nothing moves.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dhis2-bot
dhis2-bot temporarily deployed to netlify October 28, 2026 13:40 Inactive
…ide their control, demo latency by default

The error icon in a failed node's toggle slot opens the node again like the
toggle it replaces, and shows the pointer, so the whole row reads as
clickable. Both retry tooltips are placed beside their control, where they
cover no row above.

The stories' stand-in answers after 1500ms unless STORYBOOK_OU_TREE_DELAY
says otherwise, so the Netlify preview demos every loading state without a
change to the deploy workflow.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dhis2-bot
dhis2-bot temporarily deployed to netlify October 28, 2026 13:50 Inactive
HendrikThePendric and others added 2 commits October 28, 2026 16:01
The missing pointer over the error icon was a macOS quirk of an inactive
window, so the clickable icon goes; instead the icon sits inside the retry
button, which is the row's body in the failed state: error icon in the
toggle's place, the label slot, and the sync icon after the label. The
pointer, the tooltip and the click then cover the icon as well.
OuTreeNodeToggle has no failed branch and OuTreeErrorIcon is gone again.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…p with labels

The loading skeleton is 20px tall with 2px above and below, like the retry
bar, so a row keeps its shape when loading turns to failed and stacked
bars keep a gap. Every bar sits after the empty toggle slot, as a leaf's
label does, so bars line up with the label box and a loaded row keeps its
place while the arrow appears in the blank slot.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@dhis2-bot
dhis2-bot temporarily deployed to netlify October 28, 2026 14:29 Inactive
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
4 New issues
B Reliability Rating on New Code (required ≥ A)
2 New Bugs (required ≤ 0)
2 New Code Smells (required ≤ 0)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

@dhis2-bot
dhis2-bot temporarily deployed to netlify October 28, 2026 15:07 Inactive
@HendrikThePendric
HendrikThePendric merged commit 5ebd5e8 into feat/paged-org-unit-tree Oct 28, 2026
19 of 23 checks passed
@HendrikThePendric
HendrikThePendric deleted the feat/ou-tree-rendering branch October 28, 2026 15:30

This branch was previously deployed

1 inactive deployment
netlify — 48136b32 Deployed Oct 28, 2026 by dhis2-bot
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants