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
65 changes: 50 additions & 15 deletions src/components/admin/review-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,18 @@ interface MergeDirection {
toId: string
}

/**
* Whether a promotion needs reporting back rather than silently succeeding.
*
* Edge failures count: the type and nodes can land perfectly while the
* relationships between them stay parked, and "approved" cannot express that.
* `edges.pending` is deliberately NOT a problem — those are edges whose other
* end simply is not promoted yet, which is the expected steady state.
*/
function hasPromotionProblem(summary: PromotionSummary): boolean {
return summary.failed.length > 0 || (summary.edges?.failed.length ?? 0) > 0
}

function extractDirection(action_name: string, action_payload: unknown): MergeDirection | null {
if (!action_payload || typeof action_payload !== "object") return null
const p = action_payload as Record<string, unknown>
Expand Down Expand Up @@ -699,7 +711,7 @@ export function ReviewRow({
}
// Surface partial promotion outcomes before the row is refetched away —
// "approved" alone doesn't say whether the entries actually landed.
if (res.promotion_summary && res.promotion_summary.failed.length > 0) {
if (res.promotion_summary && hasPromotionProblem(res.promotion_summary)) {
setPromotionSummary(res.promotion_summary)
onCountRefresh?.()
return
Expand Down Expand Up @@ -957,22 +969,45 @@ export function ReviewRow({
</div>
)}

{/* Partial promotion: the type was created but some entries did not
replay. Reported here because the row's status ("approved") can't
{/* Partial promotion: the type was created but some entries or edges did
not replay. Reported here because the row's status ("approved") can't
express it. */}
{promotionSummary && promotionSummary.failed.length > 0 && (
{promotionSummary && hasPromotionProblem(promotionSummary) && (
<div className="px-3 pb-2 pl-[82px] text-[11px] text-amber-400">
Type created. {promotionSummary.promoted.length} of{" "}
{promotionSummary.attempted} entries promoted;{" "}
{promotionSummary.failed.length} failed:
<ul className="mt-0.5 list-disc pl-4 text-muted-foreground">
{promotionSummary.failed.map((f) => (
<li key={f.entry_ref_id}>
<span className="font-mono">{f.entry_ref_id.slice(0, 8)}</span>{" "}
— {f.error}
</li>
))}
</ul>
{promotionSummary.failed.length > 0 && (
<>
Type created. {promotionSummary.promoted.length} of{" "}
{promotionSummary.attempted} entries promoted;{" "}
{promotionSummary.failed.length} failed:
<ul className="mt-0.5 list-disc pl-4 text-muted-foreground">
{promotionSummary.failed.map((f) => (
<li key={f.entry_ref_id}>
<span className="font-mono">
{f.entry_ref_id.slice(0, 8)}
</span>{" "}
— {f.error}
</li>
))}
</ul>
</>
)}
{promotionSummary.edges &&
promotionSummary.edges.failed.length > 0 && (
<>
{promotionSummary.edges.failed.length} relationship(s) could not
be promoted and are still parked:
<ul className="mt-0.5 list-disc pl-4 text-muted-foreground">
{promotionSummary.edges.failed.map((f) => (
<li key={f.edge_ref_id}>
<span className="font-mono">
{f.edge_ref_id.slice(0, 8)}
</span>{" "}
— {f.error}
</li>
))}
</ul>
</>
)}
</div>
)}

Expand Down
87 changes: 87 additions & 0 deletions src/components/admin/schema-promotion-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,89 @@ function formatSample(sample: unknown): string {
return JSON.stringify(sample)
}

/** A short, stable label for a parked node: its payload name, else its ref_id. */
function entryLabel(name: string | null, refId: string): string {
return name?.trim() || `${refId.slice(0, 8)}…`
}

/**
* The subgraph this approval touches: which nodes, and how they are joined.
*
* Without this the dialog shows only aggregate property counts ("5 parked
* entries"), so an admin cannot tell whether approving yields a connected graph
* or a fragment. The one-ended edges are the point: their other side stays
* parked, so they are NOT replayed now and the promoted nodes come out
* disconnected from them until that side is approved too.
*/
function SubgraphPreview({ proposal }: { proposal: SchemaProposal }) {
const joined = proposal.edges.filter((e) => e.both_ends_in_review)
const dangling = proposal.edges.filter((e) => !e.both_ends_in_review)

return (
<div className="flex flex-col gap-2">
<div className="font-mono text-[9px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Nodes being promoted ({proposal.entries.length})
</div>
<div className="flex flex-wrap gap-1.5">
{proposal.entries.map((entry) => (
<span
key={entry.ref_id}
title={entry.ref_id}
className="rounded border border-border/60 bg-muted/40 px-2 py-0.5 font-mono text-[11px]"
>
{entryLabel(entry.name, entry.ref_id)}
</span>
))}
</div>

{proposal.edges.length > 0 && (
<>
<div className="mt-1 font-mono text-[9px] font-semibold uppercase tracking-[0.18em] text-muted-foreground">
Relationships ({joined.length} promoted
{dangling.length > 0 ? `, ${dangling.length} left parked` : ""})
</div>
<div className="flex flex-col gap-1">
{joined.map((edge) => (
<div
key={edge.ref_id}
className="flex flex-wrap items-center gap-1.5 text-[11px]"
>
<span className="font-mono">
{entryLabel(edge.source_name, edge.source_ref_id)}
</span>
<span className="rounded border border-emerald-500/30 bg-emerald-500/10 px-1.5 py-px font-mono text-[10px] text-emerald-400">
{edge.intended_type ?? "—"}
</span>
<span className="font-mono">
{entryLabel(edge.target_name, edge.target_ref_id)}
</span>
</div>
))}
{dangling.map((edge) => (
<div
key={edge.ref_id}
className="flex flex-wrap items-center gap-1.5 text-[11px] text-muted-foreground"
title="The other end is parked under a different type, so this edge stays in the scratchpad until that side is approved."
>
<span className="font-mono">
{entryLabel(edge.source_name, edge.source_ref_id)}
</span>
<span className="rounded border border-border/60 bg-muted/40 px-1.5 py-px font-mono text-[10px]">
{edge.intended_type ?? "—"}
</span>
<span className="font-mono">
{entryLabel(edge.target_name, edge.target_ref_id)}
</span>
<span className="text-[10px] italic">still parked</span>
</div>
))}
</div>
</>
)}
</div>
)
}

function rowsFromProposal(proposal: SchemaProposal): PropertyRow[] {
return proposal.properties.map((p) => ({
id: p.name,
Expand Down Expand Up @@ -409,6 +492,10 @@ export function SchemaPromotionDialog({
</label>
</div>

<div className="rounded border border-border/60 bg-muted/20 p-2.5">
<SubgraphPreview proposal={proposal} />
</div>

{droppedNames.length > 0 && (
<p className="text-[11px] text-muted-foreground">
Excluded from the type, and dropped when the parked entries are
Expand Down
75 changes: 75 additions & 0 deletions src/lib/__tests__/schema-promotion-dialog.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,21 @@ function makeProposal(overrides: Partial<SchemaProposal> = {}): SchemaProposal {
status: "pending",
entry_count: 2,
entry_ref_ids: ["entry-1", "entry-2"],
entries: [
{
ref_id: "entry-1",
name: "PO-1",
intended_type: "PurchaseOrder",
rejection_reason: "unknown_type",
},
{
ref_id: "entry-2",
name: "PO-2",
intended_type: "PurchaseOrder",
rejection_reason: "unknown_type",
},
],
edges: [],
unresolved_subject_ids: [],
properties: [
{
Expand Down Expand Up @@ -218,4 +233,64 @@ describe("SchemaPromotionDialog", () => {
await screen.findByText(/no longer parked entries and will be skipped/i)
).toBeInTheDocument()
})

it("names the nodes being promoted, not just how many", async () => {
renderDialog()
expect(await screen.findByText("PO-1")).toBeInTheDocument()
expect(screen.getByText("PO-2")).toBeInTheDocument()
})

it("shows the relationships that will be promoted with them", async () => {
mockGetSchemaProposal.mockResolvedValue(
makeProposal({
edges: [
{
ref_id: "edge-1",
intended_type: "SUPPLIED_BY",
source_ref_id: "entry-1",
source_name: "PO-1",
source_intended_type: "PurchaseOrder",
target_ref_id: "entry-2",
target_name: "PO-2",
target_intended_type: "PurchaseOrder",
both_ends_in_review: true,
},
],
})
)
renderDialog()
expect(await screen.findByText("SUPPLIED_BY")).toBeInTheDocument()
expect(screen.getByText(/1 promoted/)).toBeInTheDocument()
})

it("flags an edge whose other end stays parked", async () => {
// The admin needs this before approving: the promoted node comes out
// disconnected from whatever is on the far side.
mockGetSchemaProposal.mockResolvedValue(
makeProposal({
edges: [
{
ref_id: "edge-2",
intended_type: "FULFILLED_BY",
source_ref_id: "entry-1",
source_name: "PO-1",
source_intended_type: "PurchaseOrder",
target_ref_id: "other-review-entry",
target_name: "Globex",
target_intended_type: "Supplier",
both_ends_in_review: false,
},
],
})
)
renderDialog()
expect(await screen.findByText(/still parked/)).toBeInTheDocument()
expect(screen.getByText(/1 left parked/)).toBeInTheDocument()
})

it("omits the relationships section when there are none", async () => {
renderDialog()
await screen.findByText("PO-1")
expect(screen.queryByText(/Relationships/)).not.toBeInTheDocument()
})
})
40 changes: 40 additions & 0 deletions src/lib/graph-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -894,12 +894,40 @@ export interface ProposedProperty {
sample: unknown
}

/** A parked node this approval would promote. */
export interface ProposedEntry {
ref_id: string
name: string | null
intended_type: string | null
rejection_reason: string | null
}

/** A parked edge touching this review's entries. */
export interface ProposedEdge {
ref_id: string
/** The edge type the caller originally sent, kept on the parked edge. */
intended_type: string | null
source_ref_id: string
source_name: string | null
source_intended_type: string | null
target_ref_id: string
target_name: string | null
target_intended_type: string | null
/**
* Only an edge with BOTH ends in this review becomes a real edge on approval.
* A one-ended edge stays parked until its other side is promoted.
*/
both_ends_in_review: boolean
}

export interface SchemaProposal {
review_ref_id: string
intended_type: string | null
status: ReviewStatus
entry_count: number
entry_ref_ids: string[]
entries: ProposedEntry[]
edges: ProposedEdge[]
unresolved_subject_ids: string[]
properties: ProposedProperty[]
conflicts: Array<{
Expand All @@ -922,6 +950,18 @@ export interface PromotionSummary {
}>
failed: Array<{ entry_ref_id: string; error: string }>
skipped: Array<{ entry_ref_id: string; reason: string }>
/** Parked edges converted to real edges once both ends became canonical. */
edges?: {
replayed: Array<{
edge_ref_id: string
edge_type: string
source_ref_id: string
target_ref_id: string
}>
failed: Array<{ edge_ref_id: string; error: string }>
/** Left parked because the other end is not promoted yet. */
pending: number
}
}

/** The admin's confirmed property table, sent back as the approve override. */
Expand Down
Loading