diff --git a/src/components/admin/review-row.tsx b/src/components/admin/review-row.tsx index 5bdefdc..3a8b6f8 100644 --- a/src/components/admin/review-row.tsx +++ b/src/components/admin/review-row.tsx @@ -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 @@ -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 @@ -957,22 +969,45 @@ export function ReviewRow({ )} - {/* 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) && (
- Type created. {promotionSummary.promoted.length} of{" "} - {promotionSummary.attempted} entries promoted;{" "} - {promotionSummary.failed.length} failed: - + {promotionSummary.failed.length > 0 && ( + <> + Type created. {promotionSummary.promoted.length} of{" "} + {promotionSummary.attempted} entries promoted;{" "} + {promotionSummary.failed.length} failed: + + + )} + {promotionSummary.edges && + promotionSummary.edges.failed.length > 0 && ( + <> + {promotionSummary.edges.failed.length} relationship(s) could not + be promoted and are still parked: + + + )}
)} diff --git a/src/components/admin/schema-promotion-dialog.tsx b/src/components/admin/schema-promotion-dialog.tsx index 3707754..1ce796d 100644 --- a/src/components/admin/schema-promotion-dialog.tsx +++ b/src/components/admin/schema-promotion-dialog.tsx @@ -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 ( +
+
+ Nodes being promoted ({proposal.entries.length}) +
+
+ {proposal.entries.map((entry) => ( + + {entryLabel(entry.name, entry.ref_id)} + + ))} +
+ + {proposal.edges.length > 0 && ( + <> +
+ Relationships ({joined.length} promoted + {dangling.length > 0 ? `, ${dangling.length} left parked` : ""}) +
+
+ {joined.map((edge) => ( +
+ + {entryLabel(edge.source_name, edge.source_ref_id)} + + + {edge.intended_type ?? "—"} + + + {entryLabel(edge.target_name, edge.target_ref_id)} + +
+ ))} + {dangling.map((edge) => ( +
+ + {entryLabel(edge.source_name, edge.source_ref_id)} + + + {edge.intended_type ?? "—"} + + + {entryLabel(edge.target_name, edge.target_ref_id)} + + still parked +
+ ))} +
+ + )} +
+ ) +} + function rowsFromProposal(proposal: SchemaProposal): PropertyRow[] { return proposal.properties.map((p) => ({ id: p.name, @@ -409,6 +492,10 @@ export function SchemaPromotionDialog({ +
+ +
+ {droppedNames.length > 0 && (

Excluded from the type, and dropped when the parked entries are diff --git a/src/lib/__tests__/schema-promotion-dialog.test.tsx b/src/lib/__tests__/schema-promotion-dialog.test.tsx index d80bf6b..c7cb92d 100644 --- a/src/lib/__tests__/schema-promotion-dialog.test.tsx +++ b/src/lib/__tests__/schema-promotion-dialog.test.tsx @@ -26,6 +26,21 @@ function makeProposal(overrides: Partial = {}): 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: [ { @@ -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() + }) }) diff --git a/src/lib/graph-api.ts b/src/lib/graph-api.ts index 3de4356..23cad39 100644 --- a/src/lib/graph-api.ts +++ b/src/lib/graph-api.ts @@ -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<{ @@ -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. */