From 4681ce4b5c8994a1c611de5618d9423c8d0169da Mon Sep 17 00:00:00 2001 From: Rassl Date: Tue, 11 Aug 2026 15:47:37 +0400 Subject: [PATCH] feat: show the parked subgraph in the promotion dialog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The dialog showed aggregate property counts only, so an admin approving a schema type could not see which nodes it would promote, nor the relationships between them. Add a subgraph panel above the property table listing the nodes by name and the relationships joining them. Edges whose other end is parked under a different type are rendered greyed and labelled "still parked" — those are not promoted by this approval, and the resulting node comes out disconnected from that side until the other type is approved too. That distinction is the reason the panel exists; a plain edge list would imply the whole structure survives. Also surface edge failures after approval. Previously the row spoke up only when ENTRIES failed to promote, so a perfect node promotion with broken relationships read as clean success. edges.pending is deliberately not treated as a problem — it is the expected steady state for a subgraph approved one type at a time. --- src/components/admin/review-row.tsx | 65 ++++++++++---- .../admin/schema-promotion-dialog.tsx | 87 +++++++++++++++++++ .../schema-promotion-dialog.test.tsx | 75 ++++++++++++++++ src/lib/graph-api.ts | 40 +++++++++ 4 files changed, 252 insertions(+), 15 deletions(-) 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.map((f) => ( -
  • - {f.entry_ref_id.slice(0, 8)}{" "} - — {f.error} -
  • - ))} -
+ {promotionSummary.failed.length > 0 && ( + <> + Type created. {promotionSummary.promoted.length} of{" "} + {promotionSummary.attempted} entries promoted;{" "} + {promotionSummary.failed.length} failed: +
    + {promotionSummary.failed.map((f) => ( +
  • + + {f.entry_ref_id.slice(0, 8)} + {" "} + — {f.error} +
  • + ))} +
+ + )} + {promotionSummary.edges && + promotionSummary.edges.failed.length > 0 && ( + <> + {promotionSummary.edges.failed.length} relationship(s) could not + be promoted and are still parked: +
    + {promotionSummary.edges.failed.map((f) => ( +
  • + + {f.edge_ref_id.slice(0, 8)} + {" "} + — {f.error} +
  • + ))} +
+ + )}
)} 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. */