Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,9 @@ against a live local stack (`make up`) and self-skip without one -- see

`frontend/` has its own toolchain (Node pinned via `frontend/mise.toml`,
pnpm via Corepack -- do not add a second Node package manager or a
floating Node version):
floating Node version). Related-node walk chips live in
`RelatedNodeChip` (ADR 0014). Caption and accessible name stay in
`relatedNodeCaption.ts`. Do not invent a primary organization.

```bash
cd frontend && pnpm install
Expand Down
2 changes: 2 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,8 @@ unresolved aliases of that same org collapse into it. Related-node
organization chips use the
entity-level label (`Demo Corp (Company)`), not `Organization`.
Related-node post chips show the post title only, not `(Post)`.
`RelatedNodeChip` plus `RelatedNodeChip.stories.tsx` are the
repeating walk inventory (ADR 0014).

`GET /api/posts` and `GET /api/posts/{post_id}` include
`voc_type_label` / `visibility_label` from `common_lookup_value` so
Expand Down
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,17 @@ All notable changes to this project are documented here. Format follows
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); versioning follows
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.76.0] - 2026-08-17

### Changed

- Related-node walk chips share `RelatedNodeChip` and a Storybook
inventory (ADR 0014). After seed, walking from Demo Corp still
shows "Ada West, Demo Corp (Our side)". Priya Nair stays
"Priya Nair (Counterparty)" — two orgs are never collapsed into
an invented primary. Click a chip to continue the walk or open
that post.

## [0.75.0] - 2026-08-16

### Changed
Expand Down
41 changes: 41 additions & 0 deletions docs/adr/0014-related-node-chip-stories.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# ADR-0014: Related-node chips share one module and story inventory

- Status: Accepted
- Date: 2026-08-17
- Stack: `feat/related-node-person-side-labels-main` (#92) @ `9bb5829`

## Context

Related-node walk chips live inline in `App.tsx`. The Figma synthetic
chip library (ADR 0002,
https://www.figma.com/design/nMmCeOdwGMKPxDrG8pWEAX) names the same
four buyer states: unique affiliation, side-only when two orgs would
invent a primary, organization level, and post title only. Repeating
the caption and accessible-name rules in App, tests, and a later
Storybook host would drift.

## Decision

1. `relatedNodeCaption` / `relatedNodeChipAccessibleName` own the
caption contract. Person chips name side plus a unique org.
Multiple distinct affiliations stay omitted. Organization chips
use the entity-level label. Post chips are the title only.
2. `RelatedNodeChip` is the only repeating walk control.
3. `RelatedNodeChip.stories.tsx` is the inventory. Host it with
Storybook 10 when the later token stack lands. Until then the
same states are locked by vitest.

This slice does not add `affiliation_ambiguous` or a "multiple
organizations" caption. That next-action copy is #123 / #192.

## Consequences

Walking from Demo Corp still shows `Ada West, Demo Corp (Our side)`.
Priya Nair stays `Priya Nair (Counterparty)`. Click a chip to continue
the walk or open the post. Do not mix this increment into #74.

## References

World Wide Web Consortium. (2024). *Web content accessibility
guidelines (WCAG) 2.2* (Success Criterion 2.5.3 Label in Name).
https://www.w3.org/TR/WCAG22/#label-in-name
2 changes: 1 addition & 1 deletion frontend/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "frontend",
"private": true,
"version": "0.75.0",
"version": "0.76.0",
"type": "module",
"scripts": {
"dev": "vite",
Expand Down
80 changes: 29 additions & 51 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,13 @@ import {
} from "./api";
import { LineageDag } from "./LineageDag";
import { subgraphForPost } from "./lineageLayout";
import { RelatedNodeChip } from "./RelatedNodeChip";
import {
NODE_CORPORATE_ENTITY,
NODE_PERSON,
NODE_POST,
relatedNodeCaption,
} from "./relatedNodeCaption";
import "./App.css";

function orchestratorUnavailableMessage(err: unknown, action: string): string {
Expand Down Expand Up @@ -465,34 +472,6 @@ function VocEvidenceSection({
);
}

const NODE_PERSON = "node_person";
const NODE_POST = "node_post";
const NODE_CORPORATE_ENTITY = "node_corporate_entity";

function relatedNodeCaption(node: RelatedNode): string {
const name = node.label ?? node.node_id;
if (node.node_type_code === NODE_PERSON) {
const side = node.person_side_label?.trim() || node.person_side_code?.trim();
const org = node.affiliation_organization_name?.trim();
if (side && org) {
return `${name}, ${org} (${side})`;
}
if (side) {
return `${name} (${side})`;
}
}
if (node.node_type_code === NODE_CORPORATE_ENTITY) {
const level = node.entity_level_label?.trim() || node.entity_level_code?.trim();
if (level) {
return `${name} (${level})`;
}
}
if (node.node_type_code === NODE_POST) {
return name;
}
return `${name} (${node.ontology_label ?? node.node_type_code})`;
}

const VERIFICATION_BADGE: Record<string, string> = {
verify_pending: "Not yet checked",
verify_corroborated: "Corroborated",
Expand Down Expand Up @@ -701,48 +680,47 @@ function KeymanPanel({
) : (
<ul>
{related.map((node) => {
const caption = relatedNodeCaption(node);
if (node.node_type_code === NODE_POST && onSelectPost) {
return (
<li key={`${node.node_type_code}:${node.node_id}`}>
<button
className="keyman-select"
aria-label={`Open related post: ${node.label ?? node.node_id}`}
onClick={() => onSelectPost(node.node_id)}
>
{caption}
</button>
<RelatedNodeChip
node={node}
action="open_post"
onSelect={(selected) => onSelectPost(selected.node_id)}
/>
</li>
);
}
if (node.node_type_code === NODE_PERSON) {
return (
<li key={`${node.node_type_code}:${node.node_id}`}>
<button
className="keyman-select"
aria-label={`Related nodes for ${caption}`}
onClick={() => handleSelect(node.node_id, node.label ?? node.node_id)}
>
{caption}
</button>
<RelatedNodeChip
node={node}
action="walk_person"
onSelect={(selected) =>
handleSelect(selected.node_id, selected.label ?? selected.node_id)
}
/>
</li>
);
}
if (node.node_type_code === NODE_CORPORATE_ENTITY) {
return (
<li key={`${node.node_type_code}:${node.node_id}`}>
<button
className="keyman-select"
aria-label={`Related nodes for ${caption}`}
onClick={() => handleSelectEntity(node.node_id, node.label ?? node.node_id)}
>
{caption}
</button>
<RelatedNodeChip
node={node}
action="walk_entity"
onSelect={(selected) =>
handleSelectEntity(selected.node_id, selected.label ?? selected.node_id)
}
/>
</li>
);
}
return (
<li key={`${node.node_type_code}:${node.node_id}`}>{caption}</li>
<li key={`${node.node_type_code}:${node.node_id}`}>
{relatedNodeCaption(node)}
</li>
);
})}
</ul>
Expand Down
90 changes: 90 additions & 0 deletions frontend/src/RelatedNodeChip.stories.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import type { RelatedNode } from "./api";
import { RelatedNodeChip } from "./RelatedNodeChip";
import type { RelatedNodeChipAction } from "./relatedNodeCaption";

type RelatedNodeStoryArgs = {
action: RelatedNodeChipAction;
onSelect: (node: RelatedNode) => void;
node: RelatedNode;
};

/**
* Storybook inventory for the repeating related-node chip.
*
* Host this file with Storybook 10 (Vite + React) when the later
* token stack lands. Until then the same four states are locked by
* RelatedNodeChip.test.tsx and relatedNodeCaption.test.ts.
*
* Buyer states after seed:
* - Ada West, Demo Corp (Our side)
* - Priya Nair (Counterparty) — two orgs stay omitted
* - Demo Corp (Company)
* - Linked post (title only)
*/
const meta = {
title: "Lineage/RelatedNodeChip",
component: RelatedNodeChip,
};

export default meta;

function node(partial: Partial<RelatedNode> & Pick<RelatedNode, "node_type_code">): RelatedNode {
return {
node_id: "node-1",
relevance: 0.4,
...partial,
};
}

export const UniqueAffiliation = {
args: {
action: "walk_person",
onSelect: () => undefined,
node: node({
node_type_code: "node_person",
label: "Ada West",
person_side_label: "Our side",
affiliation_organization_name: "Demo Corp",
}),
} satisfies RelatedNodeStoryArgs,
};

export const SideOnlyPluralAffiliations = {
args: {
action: "walk_person",
onSelect: () => undefined,
node: node({
node_type_code: "node_person",
label: "Priya Nair",
person_side_label: "Counterparty",
}),
} satisfies RelatedNodeStoryArgs,
};

export const OrganizationAndPost = {
render: () => (
<ul>
<li>
<RelatedNodeChip
action="walk_entity"
onSelect={() => undefined}
node={node({
node_type_code: "node_corporate_entity",
label: "Demo Corp",
entity_level_label: "Company",
})}
/>
</li>
<li>
<RelatedNodeChip
action="open_post"
onSelect={() => undefined}
node={node({
node_type_code: "node_post",
label: "Linked post",
})}
/>
</li>
</ul>
),
};
66 changes: 66 additions & 0 deletions frontend/src/RelatedNodeChip.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import type { RelatedNode } from "./api";
import { RelatedNodeChip } from "./RelatedNodeChip";
import {
UniqueAffiliation,
SideOnlyPluralAffiliations,
} from "./RelatedNodeChip.stories";

function node(partial: Partial<RelatedNode> & Pick<RelatedNode, "node_type_code">): RelatedNode {
return {
node_id: "node-1",
relevance: 0.4,
...partial,
};
}

describe("RelatedNodeChip", () => {
it("keeps the unique-affiliation caption inside the walk name", () => {
const caption = "Ada West, Demo Corp (Our side)";
render(
<RelatedNodeChip
node={UniqueAffiliation.args.node}
action={UniqueAffiliation.args.action}
onSelect={() => undefined}
/>,
);
expect(screen.getByRole("button", { name: `Related nodes for ${caption}` })).toHaveTextContent(
caption,
);
});

it("does not invent a primary org on a side-only chip", () => {
const caption = "Priya Nair (Counterparty)";
render(
<RelatedNodeChip
node={SideOnlyPluralAffiliations.args.node}
action={SideOnlyPluralAffiliations.args.action}
onSelect={() => undefined}
/>,
);
expect(screen.getByRole("button", { name: `Related nodes for ${caption}` })).toHaveTextContent(
caption,
);
expect(screen.queryByText(/Northridge/)).not.toBeInTheDocument();
});

it("opens the post when the buyer clicks a title-only chip", async () => {
const onSelect = vi.fn();
render(
<RelatedNodeChip
node={node({
node_type_code: "node_post",
node_id: "post-1",
label: "Linked post",
})}
action="open_post"
onSelect={onSelect}
/>,
);
await userEvent.click(screen.getByRole("button", { name: "Open related post: Linked post" }));
expect(onSelect).toHaveBeenCalledTimes(1);
expect(onSelect.mock.calls[0][0].node_id).toBe("post-1");
});
});
Loading