Skip to content

Step 4 — Transclusion / Composition (Reference Nodes) — Implementation Spec

Hand-off-ready spec for Step 4 of the multi-chart plan (docs/admin/org-chart-multi-chart-design.md, issue #1549). Depends on Step 2 (#1558) — reuses scopePeople and the view model. This is the Institution-tier differentiator: distributed maintenance, one published artifact.

Goal

Let a node in a master org reference the root of another org (maintained separately) so that at render time both compose into one coherent, keyboard-navigable ARIA tree. Example: central comms owns the university-wide chart; each college maintains its own; the university chart “plugs in” each college under its VP without re-keying the data.

Non-negotiable guardrail (the moat)

Transclusion resolves into a single ARIA tree with one keyboard/focus model at render time. Never nest iframes-within-iframes; never stitch multiple independent ARIA trees. An accessibility product cannot ship a composed chart that breaks arrow-key nav or screen-reader announcement across the boundary. Every acceptance test enforces this.

Scope

In scope

  1. New org_chart_references table (a reference node: where org B plugs into org A).
  2. POST/GET/DELETE /api/orgcharts/:id/references — manage reference nodes; validate existence, no cycles, ownership.
  3. Render resolution: compose referenced org/view subtrees into the host tree via scopePeople, with ID namespacing, cycle/ depth/ fan-out guards, and read-only transcluded data.
  4. Graceful degradation when a target is deleted/unavailable (placeholder node, not a broken tree).
  5. Client: add a reference node under a person; visual + programmatic indication that a subtree is sourced from another chart.

Out of scope

  • Cross-owner references — MVP restricts references to orgs owned by the same user. Cross-owner grants require the workspaces/permission model from Phase 3 (teams). Ship same-owner now; note the extension point.
  • Editing transcluded data from the host (it is read-only here; edit the source org).
  • Nested iframe embedding (explicitly rejected).
  • Lazy-load-on-expand for huge subtrees is a follow-up (note it; not required for MVP).

Migration (additive, no backfill)

CREATE TABLE public.org_chart_references (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
host_org_chart_id UUID NOT NULL REFERENCES public.org_charts(id) ON DELETE CASCADE,
host_parent_person_id UUID NOT NULL REFERENCES public.org_chart_people(id) ON DELETE CASCADE, -- where B attaches under A
target_org_chart_id UUID NOT NULL REFERENCES public.org_charts(id) ON DELETE CASCADE,
target_view_id UUID REFERENCES public.org_chart_views(id) ON DELETE SET NULL, -- plug in a view of B, or whole B
user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(host_parent_person_id, target_org_chart_id)
);
CREATE INDEX idx_refs_host ON public.org_chart_references(host_org_chart_id);
CREATE INDEX idx_refs_target ON public.org_chart_references(target_org_chart_id);
ALTER TABLE public.org_chart_references ENABLE ROW LEVEL SECURITY;
CREATE POLICY org_chart_references_owner ON public.org_chart_references
FOR ALL USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id);

Note target_org_chart_id uses ON DELETE CASCADE — deleting the target org removes the reference rows; render then simply has no reference (see degradation for the softer in-flight case). Additive; ship with code + npm run rebuild.

API (workers/api/src/routes/org-chart/references.ts)

Mount /api/orgcharts/:id/references in both index.ts and index-aws.ts.

  • POST /api/orgcharts/:id/references{ hostParentPersonId, targetOrgChartId, targetViewId? }. Validate: host person ∈ :id; target org exists and is owned by the same user (MVP); targetOrgChartId !== :id; no cycle (walk the reference graph — reject if target transitively references the host); fan-out cap per host.
  • GET /api/orgcharts/:id/references — list.
  • DELETE /api/orgcharts/:id/references/:refId.

Render resolution (extend generate.ts / preview / export)

New workers/api/src/utils/compose-tree.ts:

composeTree(hostOrg, references, loadOrg) → { people, relationships } // one connected, namespaced set
  1. Build the host tree (or host view via scopePeople if rendering a view).
  2. For each reference on an in-scope host person: load the target org’s people/relationships, run scopePeople for target_view_id (or whole org), and re-root the target subtree under host_parent_person_id.
  3. Namespace IDs across orgs: prefix every id with its org id (e.g. ${orgId}:${personId}) so ids never collide when composed; remap manager_id/relationships accordingly; set the target root’s manager_id to the namespaced host parent.
  4. Cycle guard at render too (defense in depth): track visited org ids on the compose stack; refuse to expand an org already on the stack.
  5. Depth/fan-out caps: bound total composed node count and reference-nesting depth; if exceeded, stop expanding and mark the boundary node (log + surface, per the no-silent-caps rule).
  6. Output is one connected tree; transcluded nodes carry a sourceOrgChartId marker.

Transcluded data is read-only in the host; person edits in the host UI are disabled for referenced nodes (edit the source org).

Degradation

If a target is unavailable at render (deleted mid-request, or over the cap), render a placeholder node (“Referenced chart unavailable”) in place of the subtree — never a broken/partial tree with no signal. Announce it to AT.

Client (apps/org-chart/src/app/)

  • “Reference another chart here” action on a person (Institution-tier gated once Phase 3 lands; ungated in dev). Picker lists the user’s other orgs (+ optional view). Creates the reference and re-renders.
  • Transcluded subtrees are visually and programmatically labeled as sourced from another chart (badge + accessible text like “Org: College of Engineering (linked)”) so viewers and screen readers understand the composition; referenced nodes are non-editable in the host.
  • Placeholder rendering for unavailable targets.

Accessibility: the composed render is a single ARIA tree with one roving-tabindex/arrow-key model spanning the boundary (reuse the civic AccessibleTreeView); the “linked chart” label is real text, not color/icon only; focus order flows continuously across the seam. test:a11y axe fixtures cover a composed multi-org tree.

Tests

  • Unit compose-tree.test.ts: reference resolves into one connected tree; ID namespacing prevents collisions between orgs sharing person ids; target re-rooted under host parent; relationships remapped; whole-org vs view target.
  • Cycle: A→B→A rejected at create and at render; self-reference rejected.
  • Caps: depth/fan-out limits enforced; boundary node marked + logged (not silently truncated).
  • Degradation: deleted/unavailable target → placeholder node, tree still valid.
  • Ownership: cross-owner reference rejected (MVP).
  • Route references.test.ts (route-test-coverage rule): CRUD + validations; composed generate/preview returns the merged tree; transcluded nodes flagged read-only.
  • A11y: axe on a composed 2-org tree; keyboard traversal crosses the seam continuously.
  • npm run typecheck (via npm) + test:ci green; coverage not decreased.

Acceptance criteria

  1. A user attaches another of their orgs (or a view of it) under a person; the rendered chart shows both as one navigable tree.
  2. Person ids that collide across the two orgs do not clash after composition (namespacing).
  3. Cycles and self-references are rejected at creation and cannot be produced at render.
  4. Transcluded nodes are read-only in the host and labeled (visually + for AT) as linked from another chart.
  5. Deleting/unavailable target degrades to a clearly-marked placeholder, never a broken tree.
  6. Depth/fan-out caps prevent runaway composition and are logged when hit.
  7. The composed render passes axe as a single ARIA tree with continuous keyboard traversal; no nested iframes.
  8. Cross-owner references are refused (MVP); works on both Node and Lambda.

Runtime & deploy

Node + Lambda; register references routes in both entry points; additive migration ships with code → npm run rebuild on 10.1.1.4 after merge; Lambda/CF auto-deploy. New @org-chart/shared types → rebuild the shared package (Dockerfile workspace-deps rule). Smoke-test: reference org B under a node in org A, render, traverse the seam by keyboard, delete B → confirm placeholder.

Follow-ups (post-MVP)

  • Cross-owner references via Phase 3 workspaces + an authorization grant from the target owner.
  • Lazy-load-on-expand for very large transcluded subtrees (the drill-down technique from the design doc §3b).
  • Reference a live external published chart (out of the single-tree guarantee — would require careful a11y design; not the iframe path).