ADR-013: Frontend Party UI Parameterized by Kind Config¶
Date: 2026-04-15 Status: Accepted
Context¶
ADR-011 established a unified party archetype on the backend: all actor types (persons,
groups, projects, and future kinds) share a single parties table discriminated by kind,
and route registration is driven by a PartyKindConfig table in internal/api/party_kind_configs.go.
Adding a new party kind on the backend costs ~10 lines of config with zero handler code.
The frontend, however, shipped Groups and Projects as two parallel implementations during
the party archetype rollout: web/src/routes/groups/* and web/src/routes/projects/* — two
Tab components, two detail pages, two dialog sets. Both implement ~70% identical behaviour
(list + create + delete + member CRUD) and diverge only in a few details (URL prefix, labels,
write-permission name, member-role options, whether an "Assigned catalog entries" panel is
rendered, cycle-error message wording).
This parallel structure has two problems:
- Drift. The two trees inevitably grow apart — a bug fix or UX polish applied to one side doesn't propagate to the other. We already hit this during the Projects rollout: the groups cycle-error translation, keyboard handling in dialogs, and "click-row-to-drill-down" pattern all had to be re-applied manually.
- Asymmetric extensibility. The backend's key win from ADR-011 is that adding a new kind is a config change. The frontend as shipped would require duplicating a page tree for every new kind — a structural change, not a config one. The two tiers' extensibility stories don't match.
Decision¶
Parameterize the frontend party UI by a PartyUIConfig record — the frontend mirror of
the backend's PartyKindConfig. A single set of components in web/src/routes/parties/ is
mounted once per kind with a different config. Kind-specific sections (member-role column,
entries panel, etc.) are gated by boolean flags on the config, not by kind checks scattered
through the components.
Shape:
export interface PartyUIConfig {
kind: 'group' | 'project'
urlPrefix: 'groups' | 'projects'
detailPath: (id: string) => string
labels: { single: string; plural: string }
writePermission: string // global permission that gates mutation UI
memberRoleOptions: string[] // ['member'] | ['project:owner', 'project:developer', 'project:viewer']
defaultMemberRole: string
showMemberRoleColumn: boolean
showEntriesPanel: boolean
cycleErrorMessage: string
}
Components (all in web/src/routes/parties/):
| Component | Responsibility | Kind-aware via |
|---|---|---|
PartyTab |
List + create + delete | config.urlPrefix, config.writePermission, config.labels |
CreatePartyDialog |
Name input + create | config.labels, kind-appropriate description text |
PartyDetailPage |
Members table + conditional sections | config.showMemberRoleColumn, config.showEntriesPanel |
AddMemberDialog |
Party picker + optional role select | config.memberRoleOptions, config.defaultMemberRole |
EditMemberRoleDialog |
Change member role (projects only) | Mounted only when showMemberRoleColumn |
ProjectEntriesPanel |
Assigned catalog entries (projects only) | Mounted only when showEntriesPanel |
AssignEntryDialog |
Search-then-pick catalog entry | — |
Each component takes config: PartyUIConfig as a prop. Kind-specific API functions
(listGroups vs listProjects, etc.) are selected inside the component via a small switch
on config.kind — the frontend does not unify the API client across kinds, because the
backend route trees are already kind-scoped and the generated request shapes match that
scoping.
Mounting: SettingsPage.tsx mounts <PartyTab config={groupUIConfig} /> and
<PartyTab config={projectUIConfig} /> as two tabs. App.tsx registers two detail routes
(/settings/groups/:id and /settings/projects/:id) pointing at the same
PartyDetailPage with different config props.
Consequences¶
Positive¶
- Single source of truth. A polish or bug fix to member-row rendering, cycle-error translation, keyboard handling, or Add-member filter logic ships to both kinds at once.
- Symmetric extensibility with the backend. Adding a third kind (e.g. an
organization) is a newPartyUIConfigentry plus one tab mount — no page-tree duplication. The frontend extensibility story now matches ADR-011's backend story. - Config as documentation.
partyUIConfig.tsis a readable map of what differs between kinds — instead of that knowledge being spread across two page trees. - Test reuse. Parametric
describe.eachover both configs runs the same suite for every kind, catching behaviour drift the moment it appears.
Negative / Trade-offs¶
- Project-specific sections live in the shared tree.
EditMemberRoleDialogandProjectEntriesPanelare mounted conditionally fromPartyDetailPagebased on config flags. They still live inroutes/parties/alongside kind-agnostic components. A reader has to look at the config to know which kinds use them. Mitigated by filename prefix (Project*for project-only components) and a comment at the top of each project-only component naming the flag that mounts it. - Small indirection cost. A developer debugging
PartyTabmust hop topartyUIConfig.tsto see why, say, the Create button's permission check iscatalog:writein one mount andusers:writein another. Acceptable — the same indirection exists on the backend withPartyKindConfig, and matches the ADR-011 mental model. - Not all divergence fits a flag. Future features that aren't "add/remove a section"
may not map cleanly onto boolean config flags. When that happens, the config gains a
named hook (e.g. a
renderExtraDetailSection?: () => ReactNode) rather than aswitch(kind)in the component. We accept this as a case-by-case judgment call.
Neutral¶
- Per-kind API functions (
listGroups,listProjects, etc.) are kept inweb/src/api.tsrather than unified behind a singlelistParties(kind)call. The backend already scopes routes by kind; mirroring that in the client keeps the types precise and avoids runtime kind dispatch. - The rename from
routes/groups/toroutes/parties/is a one-time cost; git history follows viagit mv.
Alternatives considered¶
| Option | Why rejected |
|---|---|
| Keep Groups and Projects as parallel page trees | Drift between near-identical code paths (~70% shared); extensibility asymmetric with backend; manual re-application of shared fixes. |
Unify everything into a single <PartyTab kind="..." /> with switch(kind) branches throughout |
Scatters kind knowledge through the code; config-driven approach makes the per-kind differences a single readable table. |
| Generate per-kind pages from the config at build time | Over-engineered; React's runtime prop-driven composition already does this cleanly without codegen. |
Unify the API client behind listParties(kind) |
Loses per-kind type precision (e.g. project members always carry a role, group members don't); runtime kind dispatch for no gain since the backend routes are already kind-scoped. |