diff --git a/deployments/api/src/stitch/api/db/merge_candidate_actions.py b/deployments/api/src/stitch/api/db/merge_candidate_actions.py index 7e4337e8..8b23f989 100644 --- a/deployments/api/src/stitch/api/db/merge_candidate_actions.py +++ b/deployments/api/src/stitch/api/db/merge_candidate_actions.py @@ -4,7 +4,7 @@ from datetime import datetime, timezone from typing import Any -from sqlalchemy import func, select +from sqlalchemy import func, or_, select, union from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.orm import selectinload @@ -20,6 +20,7 @@ MergeCandidateCreateRequest, MergeCandidateDetailView, MergeCandidateListItemView, + OGFieldQueryParams, MergeCandidatePage, MergeCandidateQueryParams, MergeCandidateReviewRequest, @@ -38,6 +39,7 @@ ResourceModel, ) from .og_field_resource_actions import apply_resource_merge +from .queries import base_resource_query from .utils import coalesce_resources, coalesce_resources_with_sources @@ -261,6 +263,45 @@ async def _load_candidate_model( return model +# Postgres bigint; a longer digit string cannot be a resource id (and would +# overflow the comparison). +_MAX_RESOURCE_ID = 2**63 - 1 + + +async def _resource_ids_matching( + session: AsyncSession, + q: str, + licensed_sources: Collection[OGSISrcKey] | None, +) -> list[int]: + """Ids of queue resources (candidate members and merged resources) that + match ``q``; see ``MergeCandidateFilterParams.q``.""" + with named_query("merge_candidates.list.search_scope"): + queue_resource_ids = list( + await session.scalars( + union( + select(MergeCandidateItemModel.resource_id), + select(MergeCandidateModel.merged_resource_id).where( + MergeCandidateModel.merged_resource_id.is_not(None) + ), + ) + ) + ) + + # The resource list's own search, limited to the queue's resources. Sorting + # by id keeps it from pivoting a sort column it does not need. + text_match = base_resource_query( + OGFieldQueryParams(q=q, sort_by="id"), + licensed_sources, + resource_ids=queue_resource_ids, + ) + with named_query("merge_candidates.list.search"): + matching = set(await session.scalars(text_match)) + + if q.isdigit() and int(q) <= _MAX_RESOURCE_ID and int(q) in queue_resource_ids: + matching.add(int(q)) + return sorted(matching) + + async def list_merge_candidates( session: AsyncSession, params: MergeCandidateQueryParams, @@ -270,6 +311,19 @@ async def list_merge_candidates( filtered = select(MergeCandidateModel.id) if params.status: filtered = filtered.where(MergeCandidateModel.status.in_(params.status)) + q = (params.q or "").strip() + if q: + matching = await _resource_ids_matching(session, q, licensed_sources) + filtered = filtered.where( + or_( + MergeCandidateModel.merged_resource_id.in_(matching), + MergeCandidateModel.id.in_( + select(MergeCandidateItemModel.merge_candidate_id).where( + MergeCandidateItemModel.resource_id.in_(matching) + ) + ), + ) + ) with named_query("merge_candidates.list.count"): total = ( diff --git a/deployments/api/src/stitch/api/db/queries.py b/deployments/api/src/stitch/api/db/queries.py index 02ba5d5c..b657ac60 100644 --- a/deployments/api/src/stitch/api/db/queries.py +++ b/deployments/api/src/stitch/api/db/queries.py @@ -388,28 +388,42 @@ def coalesced_candidate_rows( ) -def _resource_universe() -> Select[tuple[int]]: +def _resource_universe( + resource_ids: Collection[int] | None = None, +) -> Select[tuple[int]]: """Resources eligible to appear in a list: any non-repointed resource with an active membership. Membership-derived and ungated by licensing/source, so a resource whose licensed values are all absent still appears as a null-shell on - an unfiltered list (and drops out once a field is filtered).""" + an unfiltered list (and drops out once a field is filtered). + + ``resource_ids`` narrows the universe to those resources.""" m = MembershipModel r = ResourceModel - return ( + stmt = ( select(r.id.label("resource_id")) .select_from(r) .join(m, m.resource_id == r.id) .where(r.repointed_id.is_(None), m.status == MembershipStatus.ACTIVE) .distinct() ) + if resource_ids is not None: + stmt = stmt.where(r.id.in_(list(dict.fromkeys(resource_ids)))) + return stmt def base_resource_query( params: OGFieldQueryParams, licensed_sources: Collection[OGSISrcKey] | None = None, + resource_ids: Collection[int] | None = None, ) -> Select[tuple[int]]: + """Ids of the resources matching ``params``, in ``params``' sort order. + + ``resource_ids`` limits the search to those resources, so callers that only + care about a known subset (e.g. the merge queue's resources) do not pivot + and rank every resource in the table. + """ involved = _participating_columns(params) - universe = _resource_universe().cte("resource_universe") + universe = _resource_universe(resource_ids).cte("resource_universe") if not involved: # No value field filtered or sorted -> the universe alone (every active @@ -417,7 +431,7 @@ def base_resource_query( base = universe conditions: list[ColumnElement[bool]] = [] else: - base_cte = construct_base_query_statement(licensed_sources) + base_cte = construct_base_query_statement(licensed_sources, resource_ids) ranked = add_ranking(base_cte).cte("ranked") pivot = _add_pivot_columns( select(ranked.c.resource_id.label("resource_id")), diff --git a/deployments/api/src/stitch/api/entities.py b/deployments/api/src/stitch/api/entities.py index 26e4e459..ca09f97e 100644 --- a/deployments/api/src/stitch/api/entities.py +++ b/deployments/api/src/stitch/api/entities.py @@ -198,9 +198,16 @@ class MergeCandidateListItemView(MergeCandidateView): class MergeCandidateFilterParams(BaseModel): """Repeat ``status`` once per value (``?status=PENDING&status=DENIED``) to - match any of them. Omitted, every status is returned.""" + match any of them. Omitted, every status is returned. + + ``q`` matches a candidate when any of its resources -- or, once approved, + its merged resource -- matches the resource list's ``q`` search (substring + of the coalesced name, local name, basin, state/province or region). A + purely numeric ``q`` also matches those resources' ids. + """ status: list[MergeCandidateStatus] | None = None + q: str | None = None class MergeCandidateSortParams(BaseModel): diff --git a/deployments/api/tests/observability/test_query_name_actions.py b/deployments/api/tests/observability/test_query_name_actions.py index 450ac947..60bcf80c 100644 --- a/deployments/api/tests/observability/test_query_name_actions.py +++ b/deployments/api/tests/observability/test_query_name_actions.py @@ -381,6 +381,20 @@ async def test_merge_candidate_action_labels( }, ) + captured_query_events.clear() + await mca.list_merge_candidates(session, MergeCandidateQueryParams(q="Cand")) + _assert_labels( + captured_query_events, + { + "merge_candidates.list.search_scope", + "merge_candidates.list.search", + "merge_candidates.list.count", + "merge_candidates.list", + "merge_candidates.list.status_counts", + "merge_candidates.list.names", + }, + ) + captured_query_events.clear() await mca.get_merge_candidate(session, approved.id) _assert_labels( diff --git a/deployments/api/tests/routers/test_merge_candidates_integration.py b/deployments/api/tests/routers/test_merge_candidates_integration.py index f4fbf991..8bae3128 100644 --- a/deployments/api/tests/routers/test_merge_candidates_integration.py +++ b/deployments/api/tests/routers/test_merge_candidates_integration.py @@ -15,6 +15,14 @@ OGFieldResourceSourcePriority, ) from stitch.ogsi.model import OGFieldResource, OGFieldSource +from stitch.api.auth import get_token_claims +from stitch.api.main import app +from stitch.auth import TokenClaims +from stitch.auth.permissions import ( + MERGE_CANDIDATE_READ, + RESOURCE_READ, + SOURCE_READ_RMI, +) async def _create_resource( @@ -698,3 +706,187 @@ async def test_rejects_invalid_params( "/oil-gas-fields/merge-candidates", params=params ) assert resp.status_code == 422, resp.text + + +class TestMergeCandidateSearchIntegration: + """``q`` on the list: a candidate matches when any of its resources (or its + merged resource) matches the resource list's search, or a numeric ``q`` + names one of those resources.""" + + @pytest.fixture + def make_resource( + self, integration_client, og_field_resource_factory, source_maker + ): + # The searchable fields default to blank, so only what a test sets can + # match (the factory would otherwise fill them with random text). + async def _make(*, source: str = "rmi", **fields) -> int: + searchable = { + "name_local": None, + "basin": None, + "state_province": None, + "region": None, + } + return await _create_resource_with_sources( + integration_client, + og_field_resource_factory, + [ + source_maker( + source=source, managed=False, **{**searchable, **fields} + ) + ], + ) + + return _make + + async def _search(self, client: AsyncClient, **params) -> list[int]: + resp = await client.get("/oil-gas-fields/merge-candidates", params=params) + assert resp.status_code == 200, resp.text + return [item["id"] for item in resp.json()["items"]] + + @pytest.mark.anyio + async def test_matches_either_resource_by_name( + self, integration_client: AsyncClient, make_resource + ): + first = await make_resource(name="Zubair Main") + second = await make_resource(name="Zubayr Main") + candidate_id = await _create_candidate(integration_client, [first, second]) + other = await _create_candidate( + integration_client, + [await make_resource(name="Ghawar"), await make_resource(name="Ghawar")], + ) + + # Case-insensitive substring, matching only the second resource. + assert await self._search(integration_client, q="zubayr") == [candidate_id] + assert await self._search(integration_client, q="Zub") == [candidate_id] + assert other not in await self._search(integration_client, q="zub") + + @pytest.mark.anyio + async def test_matches_the_other_search_fields( + self, integration_client: AsyncClient, make_resource + ): + candidate_id = await _create_candidate( + integration_client, + [ + await make_resource(name="Alpha", basin="Qwertian Basin"), + await make_resource(name="Alpha", region="Middle East"), + ], + ) + + assert await self._search(integration_client, q="qwertian") == [candidate_id] + + @pytest.mark.anyio + async def test_finds_an_approved_candidate_by_its_merged_resource( + self, integration_client: AsyncClient, make_resource + ): + candidate_id = await _create_candidate( + integration_client, + [ + await make_resource(name="Safaniya"), + await make_resource(name="Safaniya"), + ], + ) + approve = await integration_client.post( + f"/oil-gas-fields/merge-candidates/{candidate_id}/approve" + ) + assert approve.status_code == 200, approve.text + + # The originals are emptied by the merge; only the merged resource + # still carries the name. + assert await self._search(integration_client, q="safaniya") == [candidate_id] + + @pytest.mark.anyio + async def test_numeric_q_matches_resource_ids( + self, integration_client: AsyncClient, make_resource + ): + target = await make_resource(name="Unrelated") + by_id = await _create_candidate( + integration_client, [target, await make_resource(name="Unrelated")] + ) + await _create_candidate( + integration_client, + [await make_resource(name="Ghawar"), await make_resource(name="Ghawar")], + ) + + assert await self._search(integration_client, q=str(target)) == [by_id] + # Candidate ids are not searched: no resource has this id. + assert await self._search(integration_client, q="987654321") == [] + # Too long to be an id; still searched as text, without erroring. + assert await self._search(integration_client, q="9" * 30) == [] + + @pytest.mark.anyio + async def test_numeric_q_also_matches_digits_in_text( + self, integration_client: AsyncClient, make_resource + ): + by_text = await _create_candidate( + integration_client, + [ + await make_resource(name="Block 990077"), + await make_resource(name="Block 990077"), + ], + ) + + # Far above any resource id here, so only the name can match. + assert await self._search(integration_client, q="990077") == [by_text] + + @pytest.mark.anyio + async def test_combines_with_status_and_leaves_counts_whole_queue( + self, integration_client: AsyncClient, make_resource + ): + pending = await _create_candidate( + integration_client, + [await make_resource(name="Rumaila"), await make_resource(name="Rumaila")], + ) + denied = await _create_candidate( + integration_client, + [await make_resource(name="Rumaila"), await make_resource(name="Rumaila")], + ) + await _create_candidate( + integration_client, + [await make_resource(name="Ghawar"), await make_resource(name="Ghawar")], + ) + resp = await integration_client.post( + f"/oil-gas-fields/merge-candidates/{denied}/deny" + ) + assert resp.status_code == 200, resp.text + + resp = await integration_client.get( + "/oil-gas-fields/merge-candidates", + params={"q": "rumaila", "status": "PENDING"}, + ) + body = resp.json() + assert [item["id"] for item in body["items"]] == [pending] + assert body["total_count"] == 1 + assert body["status_counts"] == {"PENDING": 2, "APPROVED": 0, "DENIED": 1} + + @pytest.mark.anyio + async def test_blank_q_is_no_search( + self, integration_client: AsyncClient, make_resource + ): + candidate_id = await _create_candidate( + integration_client, + [await make_resource(name="Ghawar"), await make_resource(name="Ghawar")], + ) + + assert await self._search(integration_client, q=" ") == [candidate_id] + + @pytest.mark.anyio + async def test_ignores_names_from_unlicensed_sources( + self, integration_client: AsyncClient, make_resource + ): + await _create_candidate( + integration_client, + [ + await make_resource(source="gem", name="Gemonly Field"), + await make_resource(source="gem", name="Gemonly Field"), + ], + ) + app.dependency_overrides[get_token_claims] = lambda: TokenClaims( + sub="test|rmi-only", + email="rmi@test.com", + name="RMI Only", + permissions=frozenset( + {RESOURCE_READ, MERGE_CANDIDATE_READ, SOURCE_READ_RMI} + ), + ) + + assert await self._search(integration_client, q="gemonly") == [] diff --git a/deployments/stitch-frontend/README.md b/deployments/stitch-frontend/README.md index cb01ceda..8257037d 100644 --- a/deployments/stitch-frontend/README.md +++ b/deployments/stitch-frontend/README.md @@ -226,6 +226,7 @@ The Merge Review queue (`/merge-candidate-review`) encodes its view the same way | ------------------------ | --------------------------------------------------------- | ----------------------------- | | `page` | integer ≥ 1 | page 1 | | `page_size` | 10, 25, 50, 100 | 25 | +| `q` | free text | empty | | `status` | `PENDING`, `APPROVED`, `DENIED` (repeat per value), `all` | only `PENDING` is selected | | `sort_by` + `sort_order` | `created` or `reviewed_at`, with `asc` or `desc` | newest first (`created desc`) | @@ -238,6 +239,7 @@ Example — approved and denied candidates, most recently reviewed first, page 2 Notes: - The queue shows pending candidates by default, so a bare `/merge-candidate-review` is the review work still to do. Clearing every status in the Status filter shows all of them, written as `status=all`. +- `q` finds a candidate when any of its resources matches the resources list search (name, local name, basin, state/province or region), or once approved, when its merged resource does. A number also matches the resource with that ID, so `q=2024` finds both "Block 2024" and resource 2024. Candidate IDs are not searched. - The page size is not remembered between visits, unlike the resources list. - Unknown statuses are dropped, and a malformed `page`, `page_size` or sort falls back to its default. A `page` past the end steps back to the last page. diff --git a/deployments/stitch-frontend/src/config/mergeReviewParams.js b/deployments/stitch-frontend/src/config/mergeReviewParams.js index 9af3d651..b605c438 100644 --- a/deployments/stitch-frontend/src/config/mergeReviewParams.js +++ b/deployments/stitch-frontend/src/config/mergeReviewParams.js @@ -9,7 +9,7 @@ * * Rules: * - Defaults are omitted, so the default view (pending candidates, newest - * first, page 1) is a bare /merge-candidate-review. + * first, page 1, no search) is a bare /merge-candidate-review. * - Serialization order is fixed, so the same view always produces the same URL. * - Junk is tolerated, never fatal: an unknown status is dropped, and a * malformed page, page_size or sort falls back to its default. @@ -91,6 +91,7 @@ export function parseMergeReviewParams(searchParams) { return { page: parsePage(searchParams.get("page")), pageSize: parsePageSize(searchParams.get("page_size")), + q: (searchParams.get("q") ?? "").trim(), statuses: parseStatuses(searchParams.getAll("status")), sortKey: parseSort( searchParams.get("sort_by"), @@ -99,13 +100,16 @@ export function parseMergeReviewParams(searchParams) { }; } -export function toMergeReviewParams({ page, pageSize, statuses, sortKey }) { +export function toMergeReviewParams({ page, pageSize, q, statuses, sortKey }) { const params = new URLSearchParams(); if (page > DEFAULT_PAGE) params.set("page", String(page)); if (pageSize !== DEFAULT_QUEUE_PAGE_SIZE) { params.set("page_size", String(pageSize)); } + const trimmedSearch = (q ?? "").trim(); + if (trimmedSearch) params.set("q", trimmedSearch); + if (statuses.length === 0) { params.set("status", ALL_STATUSES); } else if (!isDefaultStatuses(statuses)) { @@ -125,12 +129,19 @@ export function toMergeReviewParams({ page, pageSize, statuses, sortKey }) { // The list endpoint's query params for a parsed view. An empty status list // sends no status filter, which the API reads as every status. -export function toMergeCandidateQuery({ page, pageSize, statuses, sortKey }) { +export function toMergeCandidateQuery({ + page, + pageSize, + q, + statuses, + sortKey, +}) { const sort = QUEUE_SORT_OPTIONS.find((option) => option.key === sortKey) ?? DEFAULT_SORT; return { page, page_size: pageSize, + q: q || undefined, status: statuses.length ? statuses : undefined, sort_by: sort.sortBy, sort_order: sort.sortOrder, diff --git a/deployments/stitch-frontend/src/config/mergeReviewParams.test.js b/deployments/stitch-frontend/src/config/mergeReviewParams.test.js index dc5adc89..da1ee711 100644 --- a/deployments/stitch-frontend/src/config/mergeReviewParams.test.js +++ b/deployments/stitch-frontend/src/config/mergeReviewParams.test.js @@ -11,6 +11,7 @@ const serialize = (state) => toMergeReviewParams(state).toString(); const DEFAULT_STATE = { page: 1, pageSize: 25, + q: "", statuses: ["PENDING"], sortKey: "newest", }; @@ -23,17 +24,24 @@ describe("parseMergeReviewParams", () => { it("reads every supported param", () => { expect( parse( - "page=3&page_size=50&status=DENIED&status=APPROVED&sort_by=reviewed_at&sort_order=asc", + "page=3&page_size=50&q=ghawar&status=DENIED&status=APPROVED&sort_by=reviewed_at&sort_order=asc", ), ).toEqual({ page: 3, pageSize: 50, + q: "ghawar", // Canonical order, whatever order the URL used. statuses: ["APPROVED", "DENIED"], sortKey: "earliest-reviewed", }); }); + it("trims the search and treats a blank one as none", () => { + expect(parse("q=%20ghawar%20").q).toBe("ghawar"); + expect(parse("q=%20%20").q).toBe(""); + expect(serialize({ ...DEFAULT_STATE, q: " " })).toBe(""); + }); + it("reads status=all as every status", () => { expect(parse("status=all").statuses).toEqual([]); }); @@ -63,11 +71,12 @@ describe("toMergeReviewParams", () => { serialize({ page: 2, pageSize: 10, + q: " ghawar ", statuses: ["DENIED", "PENDING"], sortKey: "oldest", }), ).toBe( - "page=2&page_size=10&status=PENDING&status=DENIED&sort_by=created&sort_order=asc", + "page=2&page_size=10&q=ghawar&status=PENDING&status=DENIED&sort_by=created&sort_order=asc", ); }); @@ -79,6 +88,7 @@ describe("toMergeReviewParams", () => { const state = { page: 4, pageSize: 100, + q: "Block 2024", statuses: [], sortKey: "recently-reviewed", }; @@ -92,18 +102,24 @@ describe("toMergeCandidateQuery", () => { toMergeCandidateQuery({ page: 2, pageSize: 50, + q: "ghawar", statuses: ["PENDING", "DENIED"], sortKey: "recently-reviewed", }), ).toEqual({ page: 2, page_size: 50, + q: "ghawar", status: ["PENDING", "DENIED"], sort_by: "reviewed_at", sort_order: "desc", }); }); + it("sends no search when the box is empty", () => { + expect(toMergeCandidateQuery(DEFAULT_STATE).q).toBeUndefined(); + }); + it("sends no status filter when every status is wanted", () => { expect( toMergeCandidateQuery({ ...DEFAULT_STATE, statuses: [] }).status, diff --git a/deployments/stitch-frontend/src/hooks/useMergeReviewState.js b/deployments/stitch-frontend/src/hooks/useMergeReviewState.js index 99961429..a1bace54 100644 --- a/deployments/stitch-frontend/src/hooks/useMergeReviewState.js +++ b/deployments/stitch-frontend/src/hooks/useMergeReviewState.js @@ -3,7 +3,7 @@ * * Mirrors useListState: the URL is the single source of truth, and every * setter round-trips the complete state through the schema - * (config/mergeReviewParams.js). Refining the view (statuses, sort) rewrites + * (config/mergeReviewParams.js). Refining the view (search, statuses, sort) rewrites * the current history entry; paging pushes, so Back steps through pages. * Page size is not remembered across visits, unlike the resources list. */ @@ -35,5 +35,6 @@ export function useMergeReviewState() { write({ statuses, page: DEFAULT_PAGE }, { replace: true }), setSortKey: (sortKey) => write({ sortKey, page: DEFAULT_PAGE }, { replace: true }), + setSearch: (q) => write({ q, page: DEFAULT_PAGE }, { replace: true }), }; } diff --git a/deployments/stitch-frontend/src/pages/MergeCandidateReviewPage.jsx b/deployments/stitch-frontend/src/pages/MergeCandidateReviewPage.jsx index 349f23c5..f3ef568f 100644 --- a/deployments/stitch-frontend/src/pages/MergeCandidateReviewPage.jsx +++ b/deployments/stitch-frontend/src/pages/MergeCandidateReviewPage.jsx @@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react"; import { Link } from "react-router"; import Button from "../components/Button"; import FilterDropdown from "../components/FilterDropdown"; +import Input from "../components/Input"; import MergeSourceComparison from "../components/MergeSourceComparison"; import MergedResourceView from "../components/MergedResourceView"; import Pagination from "../components/Pagination"; @@ -98,6 +99,66 @@ function CandidateQueueItem({ candidate, isSelected, onSelect }) { ); } +// Mirrors the resources list search: the box holds its own text while you +// type, and only submit and clear write to the URL, so typing neither rewrites +// history nor refetches per keystroke. When the URL's q changes underneath it +// (Back/Forward), the box is re-seeded to match. +function QueueSearch({ q, onSearch }) { + const [searchText, setSearchText] = useState(q); + const [lastQ, setLastQ] = useState(q); + if (q !== lastQ) { + setLastQ(q); + setSearchText(q); + } + + function handleChange(event) { + const newValue = event.target.value; + setSearchText(newValue); + // Emptying the box is a clear, not a search for nothing. + if (newValue === "" && q !== "") onSearch(""); + } + + function handleSubmit(event) { + event.preventDefault(); + const normalizedSearch = searchText.trim(); + setSearchText(normalizedSearch); + onSearch(normalizedSearch); + } + + function handleClear() { + setSearchText(""); + onSearch(""); + } + + return ( +
+
+ + {searchText && ( + + )} +
+ +
+ ); +} + function QueueControls({ statuses, onStatusesChange, @@ -110,7 +171,7 @@ function QueueControls({ })); return ( -
+

Queue

- +
+ + +
@@ -196,6 +260,11 @@ function QueuePanel({ /> ))}
+ ) : viewState.q ? ( +

+ No candidates match this search. Try a different name, or clear the + search. +

) : queueTotal > 0 ? (

No candidates match the selected statuses. Change the Status filter diff --git a/deployments/stitch-frontend/src/pages/MergeCandidateReviewPage.test.jsx b/deployments/stitch-frontend/src/pages/MergeCandidateReviewPage.test.jsx index f22e1b82..a5f8cc77 100644 --- a/deployments/stitch-frontend/src/pages/MergeCandidateReviewPage.test.jsx +++ b/deployments/stitch-frontend/src/pages/MergeCandidateReviewPage.test.jsx @@ -835,6 +835,77 @@ describe("MergeCandidateReviewPage", () => { ); }); + describe("search", () => { + function searchBox() { + return within(queue()).getByRole("searchbox", { + name: "Search candidates", + }); + } + + it("sends the trimmed search on submit and returns to page 1", async () => { + const user = userEvent.setup(); + renderWithQueryClient(, { + initialEntries: ["/?page=2"], + }); + + await user.type(searchBox(), " ghawar "); + // Typing alone does not search. + expect(lastQueueRequest().q).toBeUndefined(); + + await user.click( + within(queue()).getByRole("button", { name: "Search" }), + ); + + expect(lastQueueRequest()).toMatchObject({ q: "ghawar", page: 1 }); + expect(searchBox()).toHaveValue("ghawar"); + }); + + it("searches on Enter", async () => { + const user = userEvent.setup(); + renderWithQueryClient(); + + await user.type(searchBox(), "12345{Enter}"); + + expect(lastQueueRequest().q).toBe("12345"); + }); + + it("clears the search with the clear button or by emptying the box", async () => { + const user = userEvent.setup(); + renderWithQueryClient(, { + initialEntries: ["/?q=ghawar"], + }); + + await user.click(screen.getByRole("button", { name: "Clear search" })); + expect(lastQueueRequest().q).toBeUndefined(); + expect(searchBox()).toHaveValue(""); + + await user.type(searchBox(), "x{Enter}"); + expect(lastQueueRequest().q).toBe("x"); + await user.clear(searchBox()); + expect(lastQueueRequest().q).toBeUndefined(); + }); + + it("opens with the URL's search in the box", () => { + renderWithQueryClient(, { + initialEntries: ["/?q=ghawar"], + }); + + expect(searchBox()).toHaveValue("ghawar"); + expect(lastQueueRequest().q).toBe("ghawar"); + }); + + it("explains an empty queue caused by the search", () => { + mockQueue([]); + renderWithQueryClient(, { + initialEntries: ["/?q=nothing-matches"], + }); + + expect( + screen.getByText(/No candidates match this search/), + ).toBeInTheDocument(); + }); + }); + it("explains an empty queue caused by the filter", () => { mockQueue([approvedCandidate]); renderWithQueryClient(); diff --git a/deployments/stitch-frontend/src/queries/api.js b/deployments/stitch-frontend/src/queries/api.js index 49ecdc32..4482dd67 100644 --- a/deployments/stitch-frontend/src/queries/api.js +++ b/deployments/stitch-frontend/src/queries/api.js @@ -190,14 +190,15 @@ export async function createSourceForResource( } // `status` is a list sent as a repeated param (any of them matches); omitted, -// the API returns every status. +// the API returns every status. `q` searches the candidates' resources. export async function getMergeCandidates( config, fetcher, endpoint = "oil-gas-fields", - { page = 1, page_size = 50, status, sort_by, sort_order } = {}, + { page = 1, page_size = 50, q, status, sort_by, sort_order } = {}, ) { const params = new URLSearchParams({ page, page_size }); + if (q) params.set("q", q); for (const value of status ?? []) { params.append("status", value); } diff --git a/deployments/stitch-frontend/src/queries/api.test.js b/deployments/stitch-frontend/src/queries/api.test.js index 28f962f6..4f3fa306 100644 --- a/deployments/stitch-frontend/src/queries/api.test.js +++ b/deployments/stitch-frontend/src/queries/api.test.js @@ -430,6 +430,22 @@ describe("API Functions", () => { ); }); + it("sends the search as q", async () => { + mockFetcher.mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ items: [] }), + }); + + await getMergeCandidates(config, mockFetcher, "oil-gas-fields", { + q: "Block 2024", + }); + + expect(mockFetcher).toHaveBeenCalledWith( + "http://localhost:8000/api/v1/oil-gas-fields/merge-candidates?page=1&page_size=50&q=Block+2024", + ); + }); + it("throws with the HTTP status on failure", async () => { mockFetcher.mockResolvedValueOnce({ ok: false, status: 503 }); diff --git a/deployments/stitch-frontend/src/queries/resources.js b/deployments/stitch-frontend/src/queries/resources.js index ea71ddab..9b068990 100644 --- a/deployments/stitch-frontend/src/queries/resources.js +++ b/deployments/stitch-frontend/src/queries/resources.js @@ -124,7 +124,7 @@ export const resourceQueries = { queryFn: (fetcher) => getResource(config, id, fetcher, endpoint), }), - // `params` is { page, page_size, status, sort_by, sort_order }; see + // `params` is { page, page_size, q, status, sort_by, sort_order }; see // getMergeCandidates. mergeCandidates: (config, endpoint = "oil-gas-fields", params = {}) => queryOptions({