From ece3ccfc15289eb7c150157f1c7eaba568df90f8 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Fri, 3 Jul 2026 13:59:30 +0200 Subject: [PATCH 01/12] PLAN --- PLAN.md | 133 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 133 insertions(+) create mode 100644 PLAN.md diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 00000000..82b07970 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,133 @@ +# See source values per field (Resource Detail) + +## Context + +On the Resource Detail page, each field currently shows only the **coalesced/winning** +value with a source-colored left border ([FieldCard.jsx](deployments/stitch-frontend/src/components/FieldCard.jsx)). +Users can't see the competing values from other sources without scrolling to the +separate **Sources** section (which is organized by source, not by field). + +This feature makes a field value clickable to reveal an **inline "All sources" panel** +listing every source record that has a value for that field — the winner highlighted, +the rest diminished and shown in priority order. This satisfies the Jira AC: +per-field source/value/row-ID visibility, color-coding, winner marking, and clean +handling of empty values — with no raw JSON. + +Key discovery from exploration: the resource-detail response **already carries all the +data we need in memory** — `detailView.source_data` holds every source record with its +per-field values and `source.id` (source row ID), and `detailView.provenance[field]` +already names the winning source. **No new frontend query or lazy-load is needed for +the core feature.** The only backend change is exposing the effective per-resource +source **priority** so the losing rows can be ordered correctly (respecting overrides). + +Decisions confirmed with the user: +- **Row content:** color bar + source label + value + **source row ID**. Zero extra + queries. Import date / producer are explicitly **deferred** to a follow-up (they + require the per-source lazy endpoint and already live in the Sources section). +- **Interaction:** inline expand beneath the clicked field. +- **Ordering:** expose real priority from the backend (do **not** approximate on the + frontend), mindful of the EAV source-value construction. + +--- + +## Backend — expose effective source priority + +The effective per-resource priority (`COALESCE(override, default)`) is **already computed** +in `coalesce_resources` but discarded ([utils.py:87-88](deployments/api/src/stitch/api/db/utils.py:87)): +`prio_map = {src.source: prio ...}`. We just need to carry it to the detail view. + +Note on EAV: source **values** now live in the long/EAV table +`oil_gas_field_source_values` ([oil_gas_field_source_value.py](deployments/api/src/stitch/api/db/model/oil_gas_field_source_value.py)), +but **priority** comes from the `og_field_source_priority` / `og_field_resource_source_priority` +tables — this change does not touch EAV. Source values reach the frontend already +materialized on each `OGFieldSourceView`, so the panel reads `record[field]` as today. + +1. **Entity** — add a field to `OGFieldResource` in + [packages/stitch-ogsi/.../model/__init__.py:118](packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py:118): + `source_priority: dict[OGSISrcKey, int] = Field(default_factory=dict)` + (lower int = higher priority). + +2. **Populate it** in `coalesce_resources` + ([utils.py:91](deployments/api/src/stitch/api/db/utils.py:91)): pass the existing + `prio_map` into the `OGFieldResource(...)` construction as `source_priority=prio_map`. + +3. **View** — add `source_priority: dict[OGSISrcKey, int]` to `OGFieldDetailView` + ([model/__init__.py:104](packages/stitch-ogsi/src/stitch/ogsi/model/__init__.py:104)) + and set it in `resource_to_detail_view` + ([utils.py:142](deployments/api/src/stitch/api/db/utils.py:142)) from + `resource.source_priority`. Leave `OGFieldListItemView` untouched (keeps list + payloads lean; detail-only). + +`provenance[field]` (winning source key) and `data[field]` (winning value) already flow +to the client and are enough to mark the winner — no change to `provenance` shape (avoids +breaking `FieldCard`'s current `source` prop). + +--- + +## Frontend — clickable field + inline "All sources" panel + +### 1. New selector util — [utils/resourceDisplay.js](deployments/stitch-frontend/src/utils/resourceDisplay.js) +`getFieldSources(detailView, fieldKey)` → array of `{ id, source, value, isWinner }`: +- Iterate `detailView.source_data`; include a record only when `record[fieldKey]` is + non-null / non-empty (empty-handling AC — no clutter). +- `isWinner = record.source === detailView.provenance[fieldKey] && record[fieldKey] === detailView.data[fieldKey]`. +- Sort: winners first, then ascending by `detailView.source_priority[source]` + (best-first), tiebreak by `id`. Fall back gracefully if `source_priority` is missing. +- Scope to primitive fields (identity + production); owners/operators (JSON arrays) + are out of scope and won't be passed this prop. + +### 2. `FieldCard` gains an optional expandable panel — [components/FieldCard.jsx](deployments/stitch-frontend/src/components/FieldCard.jsx) +- Add optional prop `sources` (the `getFieldSources` array). When present and non-empty, + render the value box as a ` + ) : ( +
+ {boxContent}
- {sourceLabel && ( -
-
- )} - + )} + {isInteractive && isOpen && ( + + )} ); } diff --git a/deployments/stitch-frontend/src/components/FieldCard.test.jsx b/deployments/stitch-frontend/src/components/FieldCard.test.jsx index 1a825509..00e91d1a 100644 --- a/deployments/stitch-frontend/src/components/FieldCard.test.jsx +++ b/deployments/stitch-frontend/src/components/FieldCard.test.jsx @@ -1,5 +1,6 @@ import { describe, it, expect } from "vitest"; import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { FieldCard, FieldGrid } from "./FieldCard"; import { SOURCE_COLORS, @@ -82,6 +83,78 @@ describe("FieldCard", () => { }); }); +describe("FieldCard sources panel", () => { + const sources = [ + { id: 20, source: "wm", value: "Foo Basin", isWinner: true }, + { id: 10, source: "gem", value: "Bar Basin", isWinner: false }, + ]; + + it("is not interactive when no sources are provided", () => { + render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + expect(screen.queryByText("All sources")).not.toBeInTheDocument(); + }); + + it("is not interactive when sources is empty", () => { + render(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); + }); + + it("reveals the All sources panel on click and hides it again", async () => { + const user = userEvent.setup(); + render( + , + ); + + const toggle = screen.getByRole("button"); + expect(toggle).toHaveAttribute("aria-expanded", "false"); + expect(screen.queryByText("All sources")).not.toBeInTheDocument(); + + await user.click(toggle); + expect(toggle).toHaveAttribute("aria-expanded", "true"); + expect(screen.getByText("All sources")).toBeInTheDocument(); + expect(screen.getByText('"Foo Basin"')).toBeInTheDocument(); + expect(screen.getByText('"Bar Basin"')).toBeInTheDocument(); + + await user.click(toggle); + expect(screen.queryByText("All sources")).not.toBeInTheDocument(); + }); + + it("marks the coalesced winner", async () => { + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole("button")); + expect(screen.getByText("Winner")).toBeInTheDocument(); + }); + + it("orders rows winner-first (priority order)", async () => { + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole("button")); + const labels = screen + .getAllByText(/Database:$/) + .map((el) => el.textContent); + expect(labels).toEqual([ + `${SOURCE_LABELS.wm}:`, + `${SOURCE_LABELS.gem}:`, + ]); + }); + + it("shows the source row id for each value", async () => { + const user = userEvent.setup(); + render( + , + ); + await user.click(screen.getByRole("button")); + expect(screen.getByText("#20")).toBeInTheDocument(); + expect(screen.getByText("#10")).toBeInTheDocument(); + }); +}); + describe("FieldGrid", () => { it("renders its children", () => { render( diff --git a/deployments/stitch-frontend/src/pages/ResourceDetailPage.jsx b/deployments/stitch-frontend/src/pages/ResourceDetailPage.jsx index 6354c680..eeb7f01e 100644 --- a/deployments/stitch-frontend/src/pages/ResourceDetailPage.jsx +++ b/deployments/stitch-frontend/src/pages/ResourceDetailPage.jsx @@ -12,6 +12,7 @@ import { import SourceMixBar from "../components/SourceMixBar"; import SectionHeader from "../components/SectionHeader"; import { FieldCard, FieldGrid } from "../components/FieldCard"; +import { getFieldSources } from "../utils/resourceDisplay"; import { SOURCE_LABELS } from "../constants/sourceMeta"; import Button from "../components/Button"; import { @@ -583,6 +584,7 @@ export default function ResourceDetailPage() { label={FIELD_META[key].label} value={detailView.data[key]} source={detailView.provenance[key]} + sources={getFieldSources(detailView, key)} /> ))} @@ -604,6 +606,7 @@ export default function ResourceDetailPage() { label={FIELD_META[key].label} value={detailView.data[key]} source={detailView.provenance[key]} + sources={getFieldSources(detailView, key)} /> ))} diff --git a/deployments/stitch-frontend/src/utils/resourceDisplay.js b/deployments/stitch-frontend/src/utils/resourceDisplay.js index 73fa06a0..0051b344 100644 --- a/deployments/stitch-frontend/src/utils/resourceDisplay.js +++ b/deployments/stitch-frontend/src/utils/resourceDisplay.js @@ -41,6 +41,53 @@ export function deriveProvenance(resource) { return provenance; } +/** + * All source values competing for a single field, best-first. + * + * Reads the per-field values already present on the detail view's `source_data` + * (no extra request). The winner is the record whose source matches + * `provenance[fieldKey]` and whose value equals the coalesced `data[fieldKey]`. + * Losers follow, ordered by the effective per-resource `source_priority` + * (lower = higher priority), tie-broken by source row id. + * + * Returns `[]` when nothing has a value for the field (empty-handling), so the + * caller can leave the field non-interactive. + */ +export function getFieldSources(detailView, fieldKey) { + const records = detailView?.source_data; + if (!Array.isArray(records)) return []; + + const winnerSource = detailView.provenance?.[fieldKey] ?? null; + const winnerValue = detailView.data?.[fieldKey]; + const priority = detailView.source_priority ?? {}; + // Fallback rank keeps ordering stable when a source is absent from the map. + const rankOf = (source) => + Object.prototype.hasOwnProperty.call(priority, source) + ? priority[source] + : Number.MAX_SAFE_INTEGER; + + const rows = []; + for (const record of records) { + const value = record?.[fieldKey]; + if (value === null || value === undefined || value === "") continue; + rows.push({ + id: record.id ?? null, + source: record.source, + value, + isWinner: record.source === winnerSource && value === winnerValue, + }); + } + + rows.sort((a, b) => { + if (a.isWinner !== b.isWinner) return a.isWinner ? -1 : 1; + const rankDiff = rankOf(a.source) - rankOf(b.source); + if (rankDiff !== 0) return rankDiff; + return (a.id ?? 0) - (b.id ?? 0); + }); + + return rows; +} + export function normalizeResourceListItem(resource) { if (!resource || resource.data) { return resource; diff --git a/deployments/stitch-frontend/src/utils/resourceDisplay.test.js b/deployments/stitch-frontend/src/utils/resourceDisplay.test.js index 20ab2948..1f0b367a 100644 --- a/deployments/stitch-frontend/src/utils/resourceDisplay.test.js +++ b/deployments/stitch-frontend/src/utils/resourceDisplay.test.js @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { deriveProvenance, + getFieldSources, getResourceField, isPrimitive, normalizeResourceListItem, @@ -84,3 +85,83 @@ describe("resourceDisplay utilities", () => { expect(isPrimitive([1])).toBe(false); }); }); + +describe("getFieldSources", () => { + const detailView = { + data: { basin: "Foo Basin" }, + provenance: { basin: "wm" }, + // wm outranks gem outranks llm for this resource. + source_priority: { wm: 1, gem: 2, llm: 3 }, + source_data: [ + { id: 10, source: "gem", basin: "Bar Basin" }, + { id: 20, source: "wm", basin: "Foo Basin" }, + { id: 30, source: "llm", basin: "" }, + { id: 40, source: "gem", basin: null }, + ], + }; + + it("marks the coalesced winner and orders losers by priority", () => { + expect(getFieldSources(detailView, "basin")).toEqual([ + { id: 20, source: "wm", value: "Foo Basin", isWinner: true }, + { id: 10, source: "gem", value: "Bar Basin", isWinner: false }, + ]); + }); + + it("excludes records with empty, null, or undefined values", () => { + const rows = getFieldSources(detailView, "basin"); + expect(rows.map((r) => r.id)).toEqual([20, 10]); + expect(rows.map((r) => r.id)).not.toContain(30); + expect(rows.map((r) => r.id)).not.toContain(40); + }); + + it("keeps duplicate same-source records, tie-broken by id", () => { + const view = { + data: { basin: "Foo Basin" }, + provenance: { basin: "rmi" }, + source_priority: { rmi: 1 }, + source_data: [ + { id: 30, source: "rmi", basin: "Other" }, + { id: 10, source: "rmi", basin: "Foo Basin" }, + { id: 20, source: "rmi", basin: "Other" }, + ], + }; + expect(getFieldSources(view, "basin")).toEqual([ + { id: 10, source: "rmi", value: "Foo Basin", isWinner: true }, + { id: 20, source: "rmi", value: "Other", isWinner: false }, + { id: 30, source: "rmi", value: "Other", isWinner: false }, + ]); + }); + + it("returns an empty array when nothing has a value", () => { + expect( + getFieldSources( + { + data: {}, + provenance: {}, + source_priority: {}, + source_data: [{ id: 1, source: "gem", basin: null }], + }, + "basin", + ), + ).toEqual([]); + }); + + it("orders gracefully when source_priority is missing", () => { + const view = { + data: { basin: "Foo Basin" }, + provenance: { basin: "wm" }, + source_data: [ + { id: 10, source: "gem", basin: "Bar Basin" }, + { id: 20, source: "wm", basin: "Foo Basin" }, + ], + }; + const rows = getFieldSources(view, "basin"); + expect(rows[0]).toEqual({ + id: 20, + source: "wm", + value: "Foo Basin", + isWinner: true, + }); + expect(rows).toHaveLength(2); + }); +}); From 1bd0705c5bfe12e984a3765b09861eb1360647a3 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Fri, 3 Jul 2026 15:06:08 +0200 Subject: [PATCH 04/12] Add demo resource --- .../seed/data/006-source-values-demo.json | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 deployments/seed/data/006-source-values-demo.json diff --git a/deployments/seed/data/006-source-values-demo.json b/deployments/seed/data/006-source-values-demo.json new file mode 100644 index 00000000..8ad7e9cf --- /dev/null +++ b/deployments/seed/data/006-source-values-demo.json @@ -0,0 +1,56 @@ +[ + { + "id": 0, + "source_data": [ + { + "source": "rmi", + "name": "Coalescing Demo Field", + "country": "USA", + "location_type": "Onshore", + "discovery_year": 1969 + }, + { + "source": "gem", + "name": "Coalescing Demo (GEM A)", + "country": "USA", + "basin": "Permian Basin", + "latitude": 31.9, + "longitude": -102.1, + "production_conventionality": "Unconventional", + "discovery_year": 1970, + "production_start_year": 1975 + }, + { + "source": "gem", + "name": "Coalescing Demo (GEM B)", + "country": "CAN", + "basin": "Williston Basin", + "production_conventionality": "Conventional", + "discovery_year": 1971 + }, + { + "source": "wm", + "name": "Coalescing Demo (WM)", + "country": "GBR", + "basin": "North Sea", + "state_province": "Texas", + "latitude": 31.85, + "longitude": -102.2, + "location_type": "Offshore", + "primary_hydrocarbon_group": "Light Oil", + "discovery_year": 1968, + "production_start_year": 1974, + "field_status": "Producing" + }, + { + "source": "llm", + "name": "Coalescing Demo (LLM)", + "country": null, + "basin": "Ghawar (guess)", + "primary_hydrocarbon_group": "Medium Oil", + "field_status": "Non-Producing" + } + ], + "constituents": [] + } +] From 8bf056a3fbb1ba34b54bea7c4161d4e0ff3ecd99 Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Fri, 3 Jul 2026 15:06:27 +0200 Subject: [PATCH 05/12] Update UI --- .../src/components/FieldCard.jsx | 20 +++++--------- .../src/components/FieldCard.test.jsx | 27 +++++++++++-------- 2 files changed, 22 insertions(+), 25 deletions(-) diff --git a/deployments/stitch-frontend/src/components/FieldCard.jsx b/deployments/stitch-frontend/src/components/FieldCard.jsx index 1caf8dea..017e37d0 100644 --- a/deployments/stitch-frontend/src/components/FieldCard.jsx +++ b/deployments/stitch-frontend/src/components/FieldCard.jsx @@ -9,26 +9,18 @@ import { function SourceValueRow({ source, value, id, isWinner }) { const barColor = SOURCE_COLORS[source] ?? DEFAULT_FIELD_COLOR; const sourceLabel = SOURCE_LABELS[source] ?? UNKNOWN_SOURCE_LABEL; + const meta = + id !== null && id !== undefined ? `${sourceLabel} · #${id}` : sourceLabel; return (
- {sourceLabel}: - "{String(value)}" - - {isWinner && ( - - Winner - - )} - {id !== null && id !== undefined && ( - #{id} - )} - +
"{String(value)}"
+
{meta}
); } diff --git a/deployments/stitch-frontend/src/components/FieldCard.test.jsx b/deployments/stitch-frontend/src/components/FieldCard.test.jsx index 00e91d1a..14911815 100644 --- a/deployments/stitch-frontend/src/components/FieldCard.test.jsx +++ b/deployments/stitch-frontend/src/components/FieldCard.test.jsx @@ -120,13 +120,17 @@ describe("FieldCard sources panel", () => { expect(screen.queryByText("All sources")).not.toBeInTheDocument(); }); - it("marks the coalesced winner", async () => { + it("highlights the coalesced winner's row", async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole("button")); - expect(screen.getByText("Winner")).toBeInTheDocument(); + + const winnerRow = screen.getByText('"Foo Basin"').closest(".border-l-4"); + const loserRow = screen.getByText('"Bar Basin"').closest(".border-l-4"); + expect(winnerRow).toHaveClass("bg-surface"); + expect(loserRow).toHaveClass("bg-panel"); }); it("orders rows winner-first (priority order)", async () => { @@ -135,23 +139,24 @@ describe("FieldCard sources panel", () => { , ); await user.click(screen.getByRole("button")); - const labels = screen - .getAllByText(/Database:$/) + const values = screen + .getAllByText(/^".*"$/) .map((el) => el.textContent); - expect(labels).toEqual([ - `${SOURCE_LABELS.wm}:`, - `${SOURCE_LABELS.gem}:`, - ]); + expect(values).toEqual(['"Foo Basin"', '"Bar Basin"']); }); - it("shows the source row id for each value", async () => { + it("shows the source label and row id beneath each value", async () => { const user = userEvent.setup(); render( , ); await user.click(screen.getByRole("button")); - expect(screen.getByText("#20")).toBeInTheDocument(); - expect(screen.getByText("#10")).toBeInTheDocument(); + expect( + screen.getByText(`${SOURCE_LABELS.wm} · #20`), + ).toBeInTheDocument(); + expect( + screen.getByText(`${SOURCE_LABELS.gem} · #10`), + ).toBeInTheDocument(); }); }); From 7e3a2fb5523476b9deb4adeb74892ea04cfc36be Mon Sep 17 00:00:00 2001 From: Alex Axthelm Date: Fri, 3 Jul 2026 15:47:01 +0200 Subject: [PATCH 06/12] Improve API, expose new endpoint for interrogating source composition New endpoint keeps existing models stable, and provides good ergonomics for when we introduce per-field overrides --- .../api/db/og_field_resource_actions.py | 47 ++++++- deployments/api/src/stitch/api/db/utils.py | 2 - .../src/stitch/api/routers/oil_gas_fields.py | 22 ++++ .../api/tests/db/test_resource_actions.py | 116 +++++++++++++++-- .../src/components/FieldCard.jsx | 73 +++-------- .../src/components/FieldCard.test.jsx | 79 ++++-------- .../src/components/ResourceFieldCard.jsx | 101 +++++++++++++++ .../src/components/ResourceFieldCard.test.jsx | 122 ++++++++++++++++++ .../stitch-frontend/src/hooks/useResources.js | 31 +++++ .../src/pages/ResourceDetailPage.jsx | 14 +- .../src/pages/ResourceDetailPage.test.jsx | 11 +- .../stitch-frontend/src/queries/api.js | 19 +++ .../stitch-frontend/src/queries/resources.js | 14 ++ .../src/utils/resourceDisplay.js | 47 ------- .../src/utils/resourceDisplay.test.js | 81 ------------ .../src/stitch/ogsi/model/__init__.py | 22 +++- 16 files changed, 536 insertions(+), 265 deletions(-) create mode 100644 deployments/stitch-frontend/src/components/ResourceFieldCard.jsx create mode 100644 deployments/stitch-frontend/src/components/ResourceFieldCard.test.jsx diff --git a/deployments/api/src/stitch/api/db/og_field_resource_actions.py b/deployments/api/src/stitch/api/db/og_field_resource_actions.py index 94c5d157..d5960d1f 100644 --- a/deployments/api/src/stitch/api/db/og_field_resource_actions.py +++ b/deployments/api/src/stitch/api/db/og_field_resource_actions.py @@ -22,7 +22,11 @@ attach_sources_to_resource, get_or_create_sources, ) -from stitch.ogsi.model import OGFieldListItemView, OGFieldResource +from stitch.ogsi.model import ( + OGFieldListItemView, + OGFieldResource, + OGFieldSourceValueView, +) from stitch.ogsi.model.types import OGSISrcKey from .model import ( @@ -30,7 +34,7 @@ MembershipStatus, ResourceModel, ) -from .model.oil_gas_field_source_value import value_attr_for +from .model.oil_gas_field_source_value import ATTRIBUTE_NAMES, value_attr_for from .queries import base_resource_query, construct_base_query_statement, add_ranking from .utils import ( coalesce_resources, @@ -127,6 +131,45 @@ async def get( ) +async def field_source_values( + session: AsyncSession, + id: int, + field: str, + licensed_sources: Collection[OGSISrcKey] | None = None, +) -> list[OGFieldSourceValueView]: + """Every source's value for one field of a resource, best-priority first. + + Returns only sources that carry a value for ``field`` (empty/null omitted), + each with its effective per-resource priority. The first entry is the + coalesced winner. Licensing is applied. Priority is source-scoped today; the + contract is unchanged when it becomes resource/field-scoped. + """ + if field not in ATTRIBUTE_NAMES: + raise HTTPException( + status_code=422, + detail=f"field={field} is not a known resource field.", + ) + if await session.get(ResourceModel, id) is None: + raise HTTPException( + status_code=HTTP_404_NOT_FOUND, detail=f"No Resource with id `{id}` found." + ) + + by_id = await ResourceModel.source_data_by_resource_id( + session, [id], licensed_sources + ) + values = [ + OGFieldSourceValueView( + source=src.source, id=src.id, value=value, priority=priority + ) + for src, priority in by_id.get(id, []) + if src.id is not None + and (value := getattr(src, field)) is not None + and value != "" + ] + values.sort(key=lambda v: (v.priority, v.id)) + return values + + async def create( session: AsyncSession, user: CurrentUser, resource: OGFieldResource ) -> OGFieldResource: diff --git a/deployments/api/src/stitch/api/db/utils.py b/deployments/api/src/stitch/api/db/utils.py index f3b8a8ca..bb166359 100644 --- a/deployments/api/src/stitch/api/db/utils.py +++ b/deployments/api/src/stitch/api/db/utils.py @@ -93,7 +93,6 @@ async def coalesce_resources( source_data=sources, view=view, provenance=provenance, - source_priority=prio_map, constituents=frozenset(), ) return out @@ -150,5 +149,4 @@ def resource_to_detail_view( OG_FIELD_SOURCE_VIEW_ADAPTER.validate_python(source) for source in resource.source_data ], - source_priority=resource.source_priority, ) diff --git a/deployments/api/src/stitch/api/routers/oil_gas_fields.py b/deployments/api/src/stitch/api/routers/oil_gas_fields.py index b38c1f00..75c7b620 100644 --- a/deployments/api/src/stitch/api/routers/oil_gas_fields.py +++ b/deployments/api/src/stitch/api/routers/oil_gas_fields.py @@ -41,6 +41,7 @@ OGFieldListItemView, OGFieldResource, OGFieldResourceView, + OGFieldSourceValueView, OGFieldView, ) @@ -284,6 +285,27 @@ async def get_resource_detail( return resource_to_detail_view(resource=res) +@router.get( + "/{id}/fields/{field}/sources", + response_model=list[OGFieldSourceValueView], + dependencies=[Depends(require_permissions(RESOURCE_READ))], +) +async def get_field_source_values( + *, + uow: UnitOfWorkDep, + user: CurrentUser, + claims: Claims, + id: int, + field: str, +) -> list[OGFieldSourceValueView]: + return await resource_actions.field_source_values( + session=uow.session, + id=id, + field=field, + licensed_sources=licensed_sources(claims), + ) + + @router.post( "/", response_model=OGFieldResourceView, diff --git a/deployments/api/tests/db/test_resource_actions.py b/deployments/api/tests/db/test_resource_actions.py index 1f92871a..5ea1900d 100644 --- a/deployments/api/tests/db/test_resource_actions.py +++ b/deployments/api/tests/db/test_resource_actions.py @@ -1051,8 +1051,6 @@ async def test_override_flips_winner_in_detail_and_list( before = await resource_actions.get(session, rid) assert before.view.name == "GEM Name" assert before.provenance["name"][1] == "gem" - # Effective priority exposed for the client (gem(2) outranks wm(3)). - assert before.source_priority == {"gem": 2, "wm": 3} # Override wm to top priority for THIS resource only. session.add( @@ -1065,13 +1063,6 @@ async def test_override_flips_winner_in_detail_and_list( assert after.view.name == "WM Name" assert after.view.country == "CAN" assert after.provenance["name"][1] == "wm" - # The override re-ranks wm above gem in the exposed priority... - assert after.source_priority == {"gem": 2, "wm": 1} - # ...and the detail view surfaces that same effective priority. - assert utils.resource_to_detail_view(after).source_priority == { - "gem": 2, - "wm": 1, - } # List path reflects it too. items, _ = await resource_actions.query(session, _QueryParams()) @@ -1101,6 +1092,113 @@ async def test_override_is_scoped_to_its_resource( assert (await resource_actions.get(session, untouched)).view.name == "GEM Name" +class TestFieldSourceValues: + """Per-field source-value listing, best-priority first.""" + + async def _seed(self, session, user) -> int: + # gem(2) outranks wm(3) by default; llm has no state_province. + return await _create_resource_with_sources( + session, + user, + {"source": "gem", "name": "GEM Name", "country": "USA", "basin": "Alpha"}, + {"source": "wm", "name": "WM Name", "country": "CAN", "basin": "Beta"}, + {"source": "llm", "name": "LLM Name", "country": "GBR"}, + ) + + @pytest.mark.anyio + async def test_lists_values_sorted_by_priority_with_winner_first( + self, + seeded_integration_session: AsyncSession, + test_user: User, + ): + session = seeded_integration_session + rid = await self._seed(session, test_user) + + rows = await resource_actions.field_source_values(session, rid, "name") + + # gem(2) < wm(3) < llm(4): winner (gem) first, then in priority order. + assert [(r.source, r.value) for r in rows] == [ + ("gem", "GEM Name"), + ("wm", "WM Name"), + ("llm", "LLM Name"), + ] + assert [r.priority for r in rows] == sorted(r.priority for r in rows) + + @pytest.mark.anyio + async def test_override_reorders_field_values( + self, + seeded_integration_session: AsyncSession, + test_user: User, + ): + session = seeded_integration_session + rid = await self._seed(session, test_user) + session.add( + OGFieldResourceSourcePriority(resource_id=rid, source="wm", priority=1) + ) + await session.flush() + + rows = await resource_actions.field_source_values(session, rid, "basin") + + # wm promoted above gem; llm has no basin so it is omitted. + assert [(r.source, r.value) for r in rows] == [ + ("wm", "Beta"), + ("gem", "Alpha"), + ] + + @pytest.mark.anyio + async def test_omits_sources_without_a_value( + self, + seeded_integration_session: AsyncSession, + test_user: User, + ): + session = seeded_integration_session + rid = await self._seed(session, test_user) + + rows = await resource_actions.field_source_values(session, rid, "state_province") + + assert rows == [] + + @pytest.mark.anyio + async def test_unlicensed_sources_are_excluded( + self, + seeded_integration_session: AsyncSession, + test_user: User, + ): + session = seeded_integration_session + rid = await self._seed(session, test_user) + + rows = await resource_actions.field_source_values( + session, rid, "name", licensed_sources=["gem"] + ) + + assert [r.source for r in rows] == ["gem"] + + @pytest.mark.anyio + async def test_unknown_field_is_rejected( + self, + seeded_integration_session: AsyncSession, + test_user: User, + ): + session = seeded_integration_session + rid = await self._seed(session, test_user) + + with pytest.raises(HTTPException) as exc: + await resource_actions.field_source_values(session, rid, "not_a_field") + assert exc.value.status_code == 422 + + @pytest.mark.anyio + async def test_missing_resource_is_404( + self, + seeded_integration_session: AsyncSession, + test_user: User, + ): + with pytest.raises(HTTPException) as exc: + await resource_actions.field_source_values( + seeded_integration_session, 9_999_999, "name" + ) + assert exc.value.status_code == 404 + + class TestResourceDetailCoalescing: """Detail-path (``resource_actions.get``) coalescing behavior. diff --git a/deployments/stitch-frontend/src/components/FieldCard.jsx b/deployments/stitch-frontend/src/components/FieldCard.jsx index 017e37d0..99d408f2 100644 --- a/deployments/stitch-frontend/src/components/FieldCard.jsx +++ b/deployments/stitch-frontend/src/components/FieldCard.jsx @@ -1,4 +1,4 @@ -import { useId, useState } from "react"; +import { useId } from "react"; import { SOURCE_COLORS, SOURCE_LABELS, @@ -6,56 +6,20 @@ import { DEFAULT_FIELD_COLOR, } from "../constants/sourceMeta"; -function SourceValueRow({ source, value, id, isWinner }) { - const barColor = SOURCE_COLORS[source] ?? DEFAULT_FIELD_COLOR; - const sourceLabel = SOURCE_LABELS[source] ?? UNKNOWN_SOURCE_LABEL; - const meta = - id !== null && id !== undefined ? `${sourceLabel} · #${id}` : sourceLabel; - - return ( -
-
"{String(value)}"
-
{meta}
-
- ); -} - -function FieldSourcesPanel({ id, sources }) { - return ( -
- {/* Reserved header — future controls will live alongside this label. */} -

- All sources -

-
- {sources.map((row) => ( - - ))} -
-
- ); -} - // Used to display a single field value in a card, as seen in the ResourceDetailPage. // Pass `source` (one of "gem" | "wm" | "rmi" | "llm") to tint the left border by data source. -// Pass `sources` (from getFieldSources) to make the value clickable, revealing an -// inline "All sources" panel with every competing source value. -export function FieldCard({ label, value, source, sources }) { - const [isOpen, setIsOpen] = useState(false); +// Pass `expandable` + `isOpen` + `onToggle` to make the value a toggle button; `children` +// (e.g. an "All sources" panel) render below the box while open. The card is presentational: +// the parent owns open state and any data fetching. +export function FieldCard({ + label, + value, + source, + expandable = false, + isOpen = false, + onToggle, + children, +}) { const panelId = useId(); const display = value === null || value === undefined || value === "" @@ -66,7 +30,6 @@ export function FieldCard({ label, value, source, sources }) { const sourceLabel = hasSource ? (SOURCE_LABELS[source] ?? UNKNOWN_SOURCE_LABEL) : null; - const isInteractive = Array.isArray(sources) && sources.length > 0; const boxContent = ( <> @@ -74,7 +37,7 @@ export function FieldCard({ label, value, source, sources }) {
{display ?? }
- {isInteractive && ( + {expandable && ( @@ -101,12 +64,12 @@ export function FieldCard({ label, value, source, sources }) {

{label}

- {isInteractive ? ( + {expandable ? (