diff --git a/deployments/api/API_REFERENCE.md b/deployments/api/API_REFERENCE.md index 91871712..ee975323 100644 --- a/deployments/api/API_REFERENCE.md +++ b/deployments/api/API_REFERENCE.md @@ -54,7 +54,7 @@ **Request Body:** `OGFieldResource` - `id`: integer | null -- `source_data`: array[GemSource-Input | WoodMacSource-Input | RMISource-Input | LLMSource-Input] +- `source_data`: array[GemSource-Input | WoodMacSource-Input | RMISource-Input | LLMSource-Input | CCRSource-Input | ALBSource-Input | BCSource-Input] - `repointed_to`: integer | null - `constituents`: array[integer] - `provenance`: object[string, array[object] | null] @@ -63,7 +63,7 @@ **Response:** `200` - `id`: integer | null -- `source_data`: array[GemSourceView | WoodMacSourceView | RMISourceView | LLMSourceView] +- `source_data`: array[GemSourceView | WoodMacSourceView | RMISourceView | LLMSourceView | CCRSourceView | ALBSourceView | BCSourceView] - `repointed_to`: integer | null - `constituents`: array[integer] - `provenance`: object[string, array[object] | null] @@ -77,8 +77,12 @@ **Response:** `200` -- `field`: string -- `values`: array[string] +- `basin`: array[string] +- `country`: array[string] +- `field_status`: array[string] +- `primary_hydrocarbon_group`: array[string] +- `region`: array[string] +- `state_province`: array[string] --- @@ -133,18 +137,7 @@ - `last_updated_by_id`: integer - `reviewed_at`: string | null - `reviewed_by_id`: integer | null - ---- - -### `GET /api/v1/oil-gas-fields/merge-candidates/{id}/preview` - - - -**Response:** `200` - -- `resource_ids`: array[integer] -- `data`: OilGasFieldBase -- `provenance`: object[string, string | null] +- `compare`: array[FieldComparisonView] --- @@ -219,6 +212,7 @@ - `fid_year`: integer | null - `field_status`: string | null - `id`: integer +- `requested_resource_id`: integer | null --- @@ -231,7 +225,8 @@ - `id`: integer - `data`: OilGasFieldBase - `provenance`: object[string, string | null] -- `source_data`: array[GemSourceView | WoodMacSourceView | RMISourceView | LLMSourceView] +- `source_data`: array[GemSourceView | WoodMacSourceView | RMISourceView | LLMSourceView | CCRSourceView | ALBSourceView | BCSourceView] +- `requested_resource_id`: integer | null --- @@ -245,16 +240,30 @@ --- +### `PUT /api/v1/oil-gas-fields/{id}/fields/{field}/sources/priority` + + + +**Request Body:** `SetFieldPriorityRequest` + +- `ordered_source_pks`: array[integer] + +**Response:** `200` + +- array[OGFieldSourceValueView] + +--- + ### `POST /api/v1/oil-gas-fields/{id}/sources` -**Request Body:** GemSource-Input | WoodMacSource-Input | RMISource-Input | LLMSource-Input +**Request Body:** GemSource-Input | WoodMacSource-Input | RMISource-Input | LLMSource-Input | CCRSource-Input | ALBSource-Input | BCSource-Input **Response:** `200` -- GemSourceView | WoodMacSourceView | RMISourceView | LLMSourceView +- GemSourceView | WoodMacSourceView | RMISourceView | LLMSourceView | CCRSourceView | ALBSourceView | BCSourceView --- @@ -264,12 +273,12 @@ -**Request Body:** GemSource-Input | WoodMacSource-Input | RMISource-Input | LLMSource-Input +**Request Body:** GemSource-Input | WoodMacSource-Input | RMISource-Input | LLMSource-Input | CCRSource-Input | ALBSource-Input | BCSource-Input **Response:** `200` -- GemSourceView | WoodMacSourceView | RMISourceView | LLMSourceView +- GemSourceView | WoodMacSourceView | RMISourceView | LLMSourceView | CCRSourceView | ALBSourceView | BCSourceView --- @@ -279,7 +288,7 @@ **Response:** `200` -- `items`: array[GemSourceView | WoodMacSourceView | RMISourceView | LLMSourceView] +- `items`: array[GemSourceView | WoodMacSourceView | RMISourceView | LLMSourceView | CCRSourceView | ALBSourceView | BCSourceView] - `total_count`: integer - `page`: integer - `page_size`: integer @@ -293,7 +302,7 @@ **Response:** `200` -- GemSourceView | WoodMacSourceView | RMISourceView | LLMSourceView +- GemSourceView | WoodMacSourceView | RMISourceView | LLMSourceView | CCRSourceView | ALBSourceView | BCSourceView --- @@ -303,6 +312,6 @@ **Response:** `200` -- GemSource-Output | WoodMacSource-Output | RMISource-Output | LLMSource-Output +- GemSource-Output | WoodMacSource-Output | RMISource-Output | LLMSource-Output | CCRSource-Output | ALBSource-Output | BCSource-Output --- 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 540c4a75..0565aab2 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 @@ -14,8 +14,7 @@ ) from stitch.api.auth import CurrentUser from stitch.api.entities import ( - FilterOptionField, - OGFieldFilterOptionsParams, + FILTER_OPTION_FIELDS, OGFieldQueryParams, ) from stitch.api.db.og_field_source_actions import ( @@ -38,13 +37,11 @@ from .model.oil_gas_field_source_value import ( ATTRIBUTE_NAMES, materialize_value, - value_attr_for, ) from .queries import ( - add_ranking, base_resource_query, - construct_base_query_statement, field_source_candidates, + filter_option_rows, ) from .utils import ( coalesce_resources, @@ -53,7 +50,6 @@ ) -_FILTER_OPTION_FIELDS: frozenset[str] = frozenset(get_args(FilterOptionField)) _ALL_SOURCES: frozenset[OGSISrcKey] = frozenset(get_args(OGSISrcKey)) @@ -93,29 +89,13 @@ async def query( async def filter_options( session: AsyncSession, - params: OGFieldFilterOptionsParams, licensed_sources: Collection[OGSISrcKey] | None = None, -) -> list[str]: - """Return distinct coalesced resource values for one filterable field. - - Reads the shared coalescing core narrowed to the single field, so values are - priority/override-coalesced and licensed before being deduped and sorted. - ``params.source`` is ignored (see ``query``). - """ - if params.field not in _FILTER_OPTION_FIELDS: - raise HTTPException( - status_code=422, - detail=f"field={params.field} is not supported for resource filter options.", - ) - - base_cte = construct_base_query_statement(licensed_sources) - filtered = select(base_cte).where(base_cte.c.colname == params.field).cte() - ranked = add_ranking(filtered).cte("ranked") - value_col = getattr(ranked.c, value_attr_for(params.field)) - labeled = value_col.label("value") - stmt = select(labeled).where(value_col.is_not(None)).distinct().order_by(labeled) - values = await session.scalars(stmt) - return list(values.all()) +) -> dict[str, list[str]]: + """Distinct coalesced values for every filterable field, in one query.""" + options: dict[str, list[str]] = {field: [] for field in FILTER_OPTION_FIELDS} + for colname, value in await session.execute(filter_option_rows(licensed_sources)): + options[colname].append(value) + return options async def get( diff --git a/deployments/api/src/stitch/api/db/queries.py b/deployments/api/src/stitch/api/db/queries.py index 61a6781d..5f4ec5b3 100644 --- a/deployments/api/src/stitch/api/db/queries.py +++ b/deployments/api/src/stitch/api/db/queries.py @@ -38,7 +38,7 @@ ResourceModel, ) from stitch.api.db.model.oil_gas_field_source_value import value_attr_for -from stitch.api.entities import OGFieldQueryParams +from stitch.api.entities import FILTER_OPTION_FIELDS, OGFieldQueryParams from stitch.ogsi.model.types import OGSISrcKey # Single source of truth for the source-list field metadata. This is a shared @@ -88,6 +88,19 @@ def _participating_columns(params: OGFieldQueryParams) -> list[str]: return list(dict.fromkeys(participating)) +def _override_join(resource_id: Any) -> ColumnElement[bool]: + """Join condition matching an override row to its value row.""" + m = MembershipModel + v = OilGasFieldSourceValueModel + o = OGFieldResourceSourcePriority + return and_( + o.resource_id == resource_id, + o.source_pk == m.source_pk, + o.source == m.source, + o.colname == v.colname, + ) + + def construct_base_query_statement( licensed_sources: Collection[OGSISrcKey] | None = None, resource_ids: Collection[int] | None = None, @@ -120,21 +133,7 @@ def construct_base_query_statement( .join(p, p.source == m.source) .join(s, and_(s.id == m.source_pk, s.source == m.source)) .join(v, v.source_pk == m.source_pk) - # Override join at the value grain (source_pk, colname), matching v exactly, - # so at most one override row per value row -- no fan-out. o.source == - # m.source is the same dual-key guard as the header join above: source_pk - # and source aren't FK-tied, so matching source_pk alone could apply a - # stray override with a mismatched (source, source_pk) pair to the wrong - # membership. Requiring both fails safe to the default instead. - .outerjoin( - o, - and_( - o.resource_id == r.id, - o.source_pk == m.source_pk, - o.source == m.source, - o.colname == v.colname, - ), - ) + .outerjoin(o, _override_join(r.id)) .where( r.repointed_id.is_(None), m.status == MembershipStatus.ACTIVE, @@ -250,6 +249,47 @@ def coalesced_winner_rows( ) +def filter_option_rows( + licensed_sources: Collection[OGSISrcKey] | None = None, +) -> Select[tuple[str, str]]: + """Distinct winning ``(colname, value)`` pairs for every filterable field.""" + m = MembershipModel + v = OilGasFieldSourceValueModel + p = OGFieldSourcePriority + o = OGFieldResourceSourcePriority + + base = ( + select( + m.resource_id.label("resource_id"), + m.source.label("source"), + m.source_pk.label("source_pk"), + o.priority.label("override_priority"), + p.priority.label("default_priority"), + v.colname.label("colname"), + v.value_text, + ) + .select_from(m) + .join(v, v.source_pk == m.source_pk) + .join(p, p.source == m.source) + .outerjoin(o, _override_join(m.resource_id)) + .where( + m.status == MembershipStatus.ACTIVE, + v.colname.in_(FILTER_OPTION_FIELDS), + ) + ) + if licensed_sources is not None: + base = base.where(m.source.in_(list(dict.fromkeys(licensed_sources)))) + + ranked = add_ranking(base.cte("filter_option_base")).cte("filter_option_ranked") + c = ranked.c + return ( + select(c.colname, c.value_text) + .where(c.value_text.is_not(None)) # rn == 1 already applied by add_ranking + .distinct() + .order_by(c.colname, c.value_text) + ) + + def field_source_candidates( resource_id: int, field: str, diff --git a/deployments/api/src/stitch/api/entities.py b/deployments/api/src/stitch/api/entities.py index 92752b8c..ca841be0 100644 --- a/deployments/api/src/stitch/api/entities.py +++ b/deployments/api/src/stitch/api/entities.py @@ -98,19 +98,6 @@ def total_pages(self) -> int: "resource_id", ] -FilterOptionField = Literal[ - "name", - "name_local", - "basin", - "state_province", - "region", - "country", - "field_status", - "location_type", - "production_conventionality", - "primary_hydrocarbon_group", -] - class OGFieldFilterParams(BaseModel): q: str | None = None @@ -136,14 +123,18 @@ class OGFieldQueryParams(PaginationParams, OGFieldFilterParams, OGFieldSortParam source: list[OGSISrcKey] = Field(default_factory=lambda: list(OGSI_SOURCE_DEFAULT)) -class OGFieldFilterOptionsParams(BaseModel): - field: FilterOptionField - source: list[OGSISrcKey] = Field(default_factory=lambda: list(OGSI_SOURCE_DEFAULT)) +class OGFieldFilterOptionsResponse(BaseModel): + """Every filterable field's distinct coalesced values, one list per field.""" + basin: list[str] + country: list[str] + field_status: list[str] + primary_hydrocarbon_group: list[str] + region: list[str] + state_province: list[str] -class OGFieldFilterOptionsResponse(BaseModel): - field: FilterOptionField - values: list[str] + +FILTER_OPTION_FIELDS: tuple[str, ...] = tuple(OGFieldFilterOptionsResponse.model_fields) class MergeCandidateStatus(StrEnum): 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 4b5d4d7b..9e0c5c04 100644 --- a/deployments/api/src/stitch/api/routers/oil_gas_fields.py +++ b/deployments/api/src/stitch/api/routers/oil_gas_fields.py @@ -13,7 +13,6 @@ ) from stitch.api.entities import ( - OGFieldFilterOptionsParams, OGFieldFilterOptionsResponse, MergeCandidateCreateRequest, MergeCandidateDetailView, @@ -84,20 +83,20 @@ async def get_all_resources( ) +# Must stay above GET /{id}: Starlette matches routes in declaration order, so a +# later static path would be swallowed by the id route. @router.get("/filter-options", response_model=OGFieldFilterOptionsResponse) async def get_resource_filter_options( *, uow: UnitOfWorkDep, _user: CurrentUser, claims: Claims, - params: Annotated[OGFieldFilterOptionsParams, Query()], ) -> OGFieldFilterOptionsResponse: - values = await resource_actions.filter_options( + opts = await resource_actions.filter_options( session=uow.session, - params=params, licensed_sources=licensed_sources(claims), ) - return OGFieldFilterOptionsResponse(field=params.field, values=values) + return OGFieldFilterOptionsResponse(**opts) @router.get( diff --git a/deployments/api/tests/db/test_resource_actions.py b/deployments/api/tests/db/test_resource_actions.py index 1f048271..3699581f 100644 --- a/deployments/api/tests/db/test_resource_actions.py +++ b/deployments/api/tests/db/test_resource_actions.py @@ -21,8 +21,9 @@ OilGasFieldSourceValueModel, ResourceModel, ) +from stitch.api.db.queries import filter_option_rows from stitch.api.entities import ( - OGFieldFilterOptionsParams, + FILTER_OPTION_FIELDS, OGFieldQueryParams, User, ) @@ -955,6 +956,7 @@ async def test_returns_distinct_sorted_coalesced_values( seeded_integration_session, test_user, {"source": "rmi", "country": "USA"}, + {"source": "llm", "country": "GBR"}, ) await _create_resource_with_sources( seeded_integration_session, @@ -967,12 +969,9 @@ async def test_returns_distinct_sorted_coalesced_values( {"source": "rmi", "country": None}, ) - values = await resource_actions.filter_options( - seeded_integration_session, - OGFieldFilterOptionsParams(field="country"), - ) + options = await resource_actions.filter_options(seeded_integration_session) - assert values == ["CAN", "USA"] + assert options["country"] == ["CAN", "USA"] @pytest.mark.anyio async def test_honors_licensed_sources_after_coalescing( @@ -992,29 +991,51 @@ async def test_honors_licensed_sources_after_coalescing( {"source": "rmi", "country": "USA"}, ) - values = await resource_actions.filter_options( + options = await resource_actions.filter_options( seeded_integration_session, - OGFieldFilterOptionsParams(field="country"), licensed_sources=frozenset({"gem", "wm", "llm"}), ) - assert values == ["CAN"] + assert options["country"] == ["CAN"] @pytest.mark.anyio - async def test_excludes_repointed_and_inactive_memberships( + async def test_licensing_promotes_next_priority_value( self, seeded_integration_session: AsyncSession, test_user: User, ): - active_id = await _create_resource_with_sources( + """Dropping the winning source's license promotes the runner-up value, + rather than blanking the field. + """ + await _create_resource_with_sources( seeded_integration_session, test_user, - {"source": "rmi", "country": "USA"}, + {"source": "wm", "country": "USA"}, + {"source": "gem", "country": "CAN"}, ) - repointed_to_id = await _create_resource_with_sources( + + with_wm = await resource_actions.filter_options( + seeded_integration_session, + licensed_sources=frozenset({"gem", "wm"}), + ) + assert with_wm["country"] == ["USA"] + + without_wm = await resource_actions.filter_options( + seeded_integration_session, + licensed_sources=frozenset({"gem"}), + ) + assert without_wm["country"] == ["CAN"] + + @pytest.mark.anyio + async def test_excludes_inactive_memberships( + self, + seeded_integration_session: AsyncSession, + test_user: User, + ): + await _create_resource_with_sources( seeded_integration_session, test_user, - {"source": "rmi", "country": "BRA"}, + {"source": "rmi", "country": "USA"}, ) inactive_id = await _create_resource_with_sources( seeded_integration_session, @@ -1022,12 +1043,6 @@ async def test_excludes_repointed_and_inactive_memberships( {"source": "rmi", "country": "CAN"}, ) - repointed_resource = await seeded_integration_session.get( - ResourceModel, repointed_to_id - ) - assert repointed_resource is not None - repointed_resource.repointed_id = active_id - inactive_membership = await seeded_integration_session.scalar( select(MembershipModel).where(MembershipModel.resource_id == inactive_id) ) @@ -1035,36 +1050,68 @@ async def test_excludes_repointed_and_inactive_memberships( inactive_membership.status = MembershipStatus.INACTIVE await seeded_integration_session.flush() - values = await resource_actions.filter_options( + options = await resource_actions.filter_options(seeded_integration_session) + + assert options["country"] == ["USA"] + + @pytest.mark.anyio + async def test_returns_every_field_including_empty_ones( + self, + seeded_integration_session: AsyncSession, + test_user: User, + ): + await _create_resource_with_sources( seeded_integration_session, - OGFieldFilterOptionsParams(field="country"), + test_user, + {"source": "rmi", "country": "USA"}, ) - assert values == ["USA"] + options = await resource_actions.filter_options(seeded_integration_session) - def test_postgres_distinct_query_orders_by_selected_value_alias(self): - """The rewritten filter_options construction compiles on Postgres. + assert set(options.keys()) == set(FILTER_OPTION_FIELDS) + assert options["country"] == ["USA"] + assert options["field_status"] == [] + assert options["basin"] == [] - Mirrors ``filter_options``: distinct over the coalesced value column for - one field, ordered by the selected alias. - """ - params = OGFieldFilterOptionsParams(field="basin") - base_cte = resource_actions.construct_base_query_statement( - licensed_sources=frozenset({"gem", "wm", "rmi", "llm"}), + @pytest.mark.anyio + async def test_returns_each_field_from_one_pass( + self, + seeded_integration_session: AsyncSession, + test_user: User, + ): + await _create_resource_with_sources( + seeded_integration_session, + test_user, + { + "source": "rmi", + "country": "USA", + "state_province": "Texas", + "basin": "Permian", + "region": "North America", + }, ) - filtered = ( - resource_actions.select(base_cte) - .where(base_cte.c.colname == params.field) - .cte() + await _create_resource_with_sources( + seeded_integration_session, + test_user, + { + "source": "gem", + "country": "CAN", + "state_province": "Alberta", + "basin": "Texas", + }, ) - ranked = resource_actions.add_ranking(filtered).cte("ranked") - value_col = getattr(ranked.c, resource_actions.value_attr_for(params.field)) - labeled = value_col.label("value") - stmt = ( - resource_actions.select(labeled) - .where(value_col.is_not(None), value_col != "") - .distinct() - .order_by(labeled) + + options = await resource_actions.filter_options(seeded_integration_session) + + assert options["country"] == ["CAN", "USA"] + assert options["state_province"] == ["Alberta", "Texas"] + assert options["basin"] == ["Permian", "Texas"] + assert options["region"] == ["North America"] + + def test_postgres_distinct_query_orders_by_selected_columns(self): + """``filter_option_rows`` compiles on Postgres.""" + stmt = filter_option_rows( + licensed_sources=frozenset({"gem", "wm", "rmi", "llm"}), ) sql = str( @@ -1074,8 +1121,10 @@ def test_postgres_distinct_query_orders_by_selected_value_alias(self): ) ) - assert "SELECT DISTINCT ranked.value_text AS value" in sql - assert "ORDER BY value" in sql + assert "SELECT DISTINCT" in sql + assert "ORDER BY" in sql + ordered = sql[sql.index("ORDER BY") :] + assert "colname" in ordered and "value_text" in ordered @pytest.mark.anyio async def test_only_unlicensed_selected_sources_still_return_resource( diff --git a/deployments/api/tests/routers/test_query_param_validation.py b/deployments/api/tests/routers/test_query_param_validation.py index 9e326814..2d6a97f0 100644 --- a/deployments/api/tests/routers/test_query_param_validation.py +++ b/deployments/api/tests/routers/test_query_param_validation.py @@ -61,16 +61,6 @@ async def test_sort_by_source_returns_422(self, integration_client: AsyncClient) ) assert resp.status_code == 422 - @pytest.mark.anyio - async def test_invalid_filter_options_field_returns_422( - self, async_client: AsyncClient - ): - """filter-options field must be one of the supported coalesced scalar fields.""" - resp = await async_client.get( - "/oil-gas-fields/filter-options", params={"field": "owners"} - ) - assert resp.status_code == 422 - @pytest.mark.anyio async def test_unknown_field_in_sources_path_returns_422( self, async_client: AsyncClient diff --git a/deployments/api/tests/routers/test_resources_unit.py b/deployments/api/tests/routers/test_resources_unit.py index e9388be6..408d5c04 100644 --- a/deployments/api/tests/routers/test_resources_unit.py +++ b/deployments/api/tests/routers/test_resources_unit.py @@ -229,17 +229,31 @@ async def override_get_uow(): app.dependency_overrides[get_uow] = override_get_uow with patch("stitch.api.routers.oil_gas_fields.resource_actions") as mock_repo: - mock_repo.filter_options = AsyncMock(return_value=["CAN", "USA"]) - - response = await async_client.get( - "/oil-gas-fields/filter-options?field=country" + mock_repo.filter_options = AsyncMock( + return_value=dict( + basin=["Permian"], + country=["CAN", "USA"], + field_status=[], + primary_hydrocarbon_group=[], + region=["North America"], + state_province=["Alberta", "Texas"], + ) ) + response = await async_client.get("/oil-gas-fields/filter-options") + assert response.status_code == 200 - assert response.json() == {"field": "country", "values": ["CAN", "USA"]} + assert response.json() == { + "basin": ["Permian"], + "country": ["CAN", "USA"], + "field_status": [], + "primary_hydrocarbon_group": [], + "region": ["North America"], + "state_province": ["Alberta", "Texas"], + } @pytest.mark.anyio - async def test_passes_licensed_sources_and_source_filters( + async def test_passes_licensed_sources( self, async_client, mock_uow, @@ -250,17 +264,22 @@ async def override_get_uow(): app.dependency_overrides[get_uow] = override_get_uow with patch("stitch.api.routers.oil_gas_fields.resource_actions") as mock_repo: - mock_repo.filter_options = AsyncMock(return_value=["CAN", "USA"]) - - response = await async_client.get( - "/oil-gas-fields/filter-options?field=country&source=gem&source=wm" + mock_repo.filter_options = AsyncMock( + return_value=dict( + basin=[], + country=["CAN", "USA"], + field_status=[], + primary_hydrocarbon_group=[], + region=[], + state_province=[], + ) ) + response = await async_client.get("/oil-gas-fields/filter-options") + assert response.status_code == 200 mock_repo.filter_options.assert_awaited_once() call_kwargs = mock_repo.filter_options.call_args.kwargs assert call_kwargs["licensed_sources"] == frozenset( {"rmi", "gem", "wm", "ccr", "alb", "bc", "llm"} ) - assert call_kwargs["params"].field == "country" - assert call_kwargs["params"].source == ["gem", "wm"] diff --git a/deployments/stitch-frontend/src/components/FilterBar.jsx b/deployments/stitch-frontend/src/components/FilterBar.jsx index 71801b79..64865730 100644 --- a/deployments/stitch-frontend/src/components/FilterBar.jsx +++ b/deployments/stitch-frontend/src/components/FilterBar.jsx @@ -2,35 +2,9 @@ import FilterDropdown from "./FilterDropdown"; import { FILTER_FIELDS, EMPTY_FILTERS } from "../config/filters"; import { useResourceFilterOptions } from "../hooks/useResources"; -function FilterFieldDropdown({ - endpoint, - field, - label, - formatValue, - selected, - onChange, -}) { - const { data } = useResourceFilterOptions(endpoint, field); - // The API sorts by the stored value, which can differ from the order of the - // labels we display (country codes vs. country names), so sort by label. - const options = (data?.values ?? []) - .map((value) => ({ - value, - label: formatValue ? formatValue(value) : value, - })) - .sort((a, b) => a.label.localeCompare(b.label, "en")); - - return ( - - ); -} - export default function FilterBar({ endpoint, filters, onFiltersChange }) { + const { data: filterOptions } = useResourceFilterOptions(endpoint); + // Flatten active filters into chips: [{ field, label, value, displayValue }, ...] // `value` is the stored/API value; `displayValue` is what the user sees. const chips = FILTER_FIELDS.flatMap(({ key, label, formatValue }) => @@ -57,17 +31,24 @@ export default function FilterBar({ endpoint, filters, onFiltersChange }) {
{/* Dropdowns row */}
- {FILTER_FIELDS.map(({ key, label, formatValue }) => ( - handleDropdownChange(key, values)} - /> - ))} + {FILTER_FIELDS.map(({ key, label, formatValue }) => { + const options = (filterOptions?.[key] ?? []) + .map((value) => ({ + value, + label: formatValue ? formatValue(value) : value, + })) + .sort((a, b) => a.label.localeCompare(b.label, "en")); + + return ( + handleDropdownChange(key, values)} + /> + ); + })}
{/* Chips + clear button — only rendered when at least one filter is active */} diff --git a/deployments/stitch-frontend/src/components/ResourcesView.test.jsx b/deployments/stitch-frontend/src/components/ResourcesView.test.jsx index 97577d8b..3be830b5 100644 --- a/deployments/stitch-frontend/src/components/ResourcesView.test.jsx +++ b/deployments/stitch-frontend/src/components/ResourcesView.test.jsx @@ -71,23 +71,17 @@ beforeEach(() => { ...defaultHookReturn, refetch: vi.fn(), }); - const FILTER_OPTION_VALUES = { - region: ["Middle East"], - basin: ["Arabian", "Permian"], - state_province: ["Kuwait"], - field_status: ["Producing"], - country: ["NOR", "SAU"], - primary_hydrocarbon_group: ["Oil", "Gas"], - }; - vi.mocked(useResourceFilterOptions).mockImplementation( - (_endpoint, field) => ({ - ...defaultHookReturn, - data: { - field, - values: FILTER_OPTION_VALUES[field] ?? [], - }, - }), - ); + vi.mocked(useResourceFilterOptions).mockReturnValue({ + ...defaultHookReturn, + data: { + region: ["Middle East"], + basin: ["Arabian", "Permian"], + state_province: ["Kuwait"], + field_status: ["Producing"], + country: ["NOR", "SAU"], + primary_hydrocarbon_group: ["Oil", "Gas"], + }, + }); }); describe("ResourcesView", () => { @@ -454,7 +448,7 @@ describe("ResourcesView", () => { }); describe("filtering", () => { - it("loads dropdown options from filter-options queries", () => { + it("loads every dropdown's options from a single filter-options query", () => { vi.mocked(useResources).mockReturnValue({ ...defaultHookReturn, data: mockResourceData, @@ -462,24 +456,8 @@ describe("ResourcesView", () => { renderWithQueryClient(); - expect(useResourceFilterOptions).toHaveBeenCalledWith(ENDPOINT, "region"); - expect(useResourceFilterOptions).toHaveBeenCalledWith( - ENDPOINT, - "state_province", - ); - expect(useResourceFilterOptions).toHaveBeenCalledWith(ENDPOINT, "basin"); - expect(useResourceFilterOptions).toHaveBeenCalledWith( - ENDPOINT, - "field_status", - ); - expect(useResourceFilterOptions).toHaveBeenCalledWith( - ENDPOINT, - "country", - ); - expect(useResourceFilterOptions).toHaveBeenCalledWith( - ENDPOINT, - "primary_hydrocarbon_group", - ); + expect(useResourceFilterOptions).toHaveBeenCalledTimes(1); + expect(useResourceFilterOptions).toHaveBeenCalledWith(ENDPOINT); }); it("shows country options as conventional names but filters by the code", () => { @@ -513,15 +491,17 @@ describe("ResourcesView", () => { it("lists country options alphabetically by displayed name", () => { // The API returns values sorted by the stored alpha-3 code, which is not // the same order as the country names the user actually sees. - vi.mocked(useResourceFilterOptions).mockImplementation( - (_endpoint, field) => ({ - ...defaultHookReturn, - data: { - field, - values: field === "country" ? ["CHN", "DEU", "DNK"] : [], - }, - }), - ); + vi.mocked(useResourceFilterOptions).mockReturnValue({ + ...defaultHookReturn, + data: { + region: [], + basin: [], + state_province: [], + field_status: [], + country: ["CHN", "DEU", "DNK"], + primary_hydrocarbon_group: [], + }, + }); vi.mocked(useResources).mockReturnValue({ ...defaultHookReturn, data: mockResourceData, diff --git a/deployments/stitch-frontend/src/hooks/useResources.js b/deployments/stitch-frontend/src/hooks/useResources.js index cb824443..14009924 100644 --- a/deployments/stitch-frontend/src/hooks/useResources.js +++ b/deployments/stitch-frontend/src/hooks/useResources.js @@ -9,6 +9,7 @@ import { } from "../queries/resources"; import { createSourceForResource, reviewMergeCandidate } from "../queries/api"; import mockResources from "../mockData/og_field_resources.json"; +import { FILTER_FIELDS } from "../config/filters"; import { getResourceField, normalizeResourceListItem, @@ -54,15 +55,11 @@ function useResourcesReal( }); } -function useResourceFilterOptionsReal( - endpoint = "resources", - field, - enabled = true, -) { +function useResourceFilterOptionsReal(endpoint = "resources", enabled = true) { const config = useConfig(); return useAuthenticatedQuery({ - ...resourceQueries.filterOptions(config, endpoint, field), - enabled: enabled && Boolean(field), + ...resourceQueries.filterOptions(config, endpoint), + enabled, }); } @@ -217,16 +214,21 @@ function getMockResourcePage({ }; } -function getMockFilterOptions(field) { - const values = Array.from( - new Set( - MOCK_RESOURCE_ITEMS.map((resource) => getResourceField(resource, field)) - .filter((value) => value != null && value !== "") - .map(String), - ), - ).sort((a, b) => a.localeCompare(b)); - - return { field, values }; +function getMockFilterOptions() { + return Object.fromEntries( + FILTER_FIELDS.map(({ key: field }) => [ + field, + Array.from( + new Set( + MOCK_RESOURCE_ITEMS.map((resource) => + getResourceField(resource, field), + ) + .filter((value) => value != null && value !== "") + .map(String), + ), + ).sort((a, b) => a.localeCompare(b)), + ]), + ); } function useResourcesMock( @@ -267,15 +269,11 @@ function useResourcesMock( }); } -function useResourceFilterOptionsMock( - endpoint = "resources", - field, - enabled = true, -) { +function useResourceFilterOptionsMock(endpoint = "resources", enabled = true) { return useQuery({ - ...resourceQueries.filterOptions(UNUSED_MOCK_CONFIG, endpoint, field), - queryFn: () => Promise.resolve(getMockFilterOptions(field)), - enabled: enabled && Boolean(field), + ...resourceQueries.filterOptions(UNUSED_MOCK_CONFIG, endpoint), + queryFn: () => Promise.resolve(getMockFilterOptions()), + enabled, }); } diff --git a/deployments/stitch-frontend/src/queries/api.js b/deployments/stitch-frontend/src/queries/api.js index b1398f7e..c7909045 100644 --- a/deployments/stitch-frontend/src/queries/api.js +++ b/deployments/stitch-frontend/src/queries/api.js @@ -27,10 +27,8 @@ export async function getResourceFilterOptions( config, fetcher, endpoint = "resources", - field, ) { - const params = new URLSearchParams({ field }); - const url = `${config.apiBaseUrl}/${endpoint}/filter-options?${params}`; + const url = `${config.apiBaseUrl}/${endpoint}/filter-options`; const response = await fetcher(url); if (!response.ok) { const error = new Error(`HTTP error! status: ${response.status}`); diff --git a/deployments/stitch-frontend/src/queries/api.test.js b/deployments/stitch-frontend/src/queries/api.test.js index e4a5b33c..5722bb08 100644 --- a/deployments/stitch-frontend/src/queries/api.test.js +++ b/deployments/stitch-frontend/src/queries/api.test.js @@ -129,7 +129,14 @@ describe("API Functions", () => { describe("getResourceFilterOptions", () => { it("fetches and returns filter options successfully", async () => { - const mockOptions = { field: "basin", values: ["Arabian", "Permian"] }; + const mockOptions = { + basin: ["Arabian", "Permian"], + country: ["NOR", "SAU"], + field_status: ["Producing"], + primary_hydrocarbon_group: [], + region: ["Middle East"], + state_province: ["Kuwait"], + }; mockFetcher.mockResolvedValueOnce({ ok: true, @@ -141,11 +148,10 @@ describe("API Functions", () => { config, mockFetcher, "oil-gas-fields", - "basin", ); expect(mockFetcher).toHaveBeenCalledWith( - "http://localhost:8000/api/v1/oil-gas-fields/filter-options?field=basin", + "http://localhost:8000/api/v1/oil-gas-fields/filter-options", ); expect(result).toEqual(mockOptions); }); @@ -157,12 +163,7 @@ describe("API Functions", () => { }); await expect( - getResourceFilterOptions( - config, - mockFetcher, - "oil-gas-fields", - "basin", - ), + getResourceFilterOptions(config, mockFetcher, "oil-gas-fields"), ).rejects.toThrow("HTTP error! status: 500"); }); }); diff --git a/deployments/stitch-frontend/src/queries/resources.js b/deployments/stitch-frontend/src/queries/resources.js index 7f3d6062..7aa4e5f0 100644 --- a/deployments/stitch-frontend/src/queries/resources.js +++ b/deployments/stitch-frontend/src/queries/resources.js @@ -26,10 +26,9 @@ const keys = { all: (endpoint = "resources") => [endpoint], lists: (endpoint = "resources") => [...keys.all(endpoint), "list"], list: (endpoint = "resources", filters) => [...keys.lists(endpoint), filters], - filterOptions: (endpoint = "resources", field) => [ + filterOptions: (endpoint = "resources") => [ ...keys.all(endpoint), "filter-options", - field, ], details: (endpoint = "resources") => [...keys.all(endpoint), "detail"], detail: (endpoint = "resources", id) => [...keys.details(endpoint), id], @@ -91,11 +90,10 @@ export const resourceQueries = { placeholderData: keepPreviousData, }), - filterOptions: (config, endpoint = "resources", field) => + filterOptions: (config, endpoint = "resources") => queryOptions({ - queryKey: keys.filterOptions(endpoint, field), - queryFn: (fetcher) => - getResourceFilterOptions(config, fetcher, endpoint, field), + queryKey: keys.filterOptions(endpoint), + queryFn: (fetcher) => getResourceFilterOptions(config, fetcher, endpoint), staleTime: DEFAULT_STALE_TIME, }), diff --git a/deployments/stitch-frontend/src/queries/resources.test.js b/deployments/stitch-frontend/src/queries/resources.test.js index b5383467..52099d67 100644 --- a/deployments/stitch-frontend/src/queries/resources.test.js +++ b/deployments/stitch-frontend/src/queries/resources.test.js @@ -20,10 +20,7 @@ describe("resourceQueries key prefixes", () => { const endpoint = "oil-gas-fields"; const cases = [ ["list", () => resourceQueries.list(config, endpoint, 1, 10, {})], - [ - "filterOptions", - () => resourceQueries.filterOptions(config, endpoint, "basin"), - ], + ["filterOptions", () => resourceQueries.filterOptions(config, endpoint)], ["detail", () => resourceQueries.detail(config, endpoint, 42)], [ "fieldSources",