Apply the filter modal's search on enter, not as you type

Typing narrowed the mounted checkboxes without touching the search the listing
was built from, so a term matching more than one page hid rows from whichever
page the user happened to be on, left the rest of the matches a page away, and
paged on the previous search while the term still sat in the box. The term now
applies on enter, and the footer says so until it does.
This commit is contained in:
Yiorgis Gozadinos 2026-08-28 09:59:33 +03:00
parent bee67e736a
commit 5e81054a1e
No known key found for this signature in database
2 changed files with 49 additions and 30 deletions

View file

@ -18,8 +18,6 @@ class DocumentCheckbox(Checkbox):
def __init__(self, label: str, doc_id: str, *, value: bool) -> None: def __init__(self, label: str, doc_id: str, *, value: bool) -> None:
super().__init__(label, value=value, classes="doc-checkbox") super().__init__(label, value=value, classes="doc-checkbox")
self.doc_id = doc_id self.doc_id = doc_id
# `label` is a reactive Text; narrowing the page wants the plain string.
self.search_text = label
def _labelled(docs) -> list[tuple[str, str]]: def _labelled(docs) -> list[tuple[str, str]]:
@ -147,7 +145,10 @@ class DocumentFilterModal(ModalScreen):
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
with Vertical(id="filter-container"): with Vertical(id="filter-container"):
yield Static("[bold]Filter Documents[/bold]", id="filter-header") yield Static("[bold]Filter Documents[/bold]", id="filter-header")
yield Input(placeholder="Search documents...", id="filter-search") yield Input(
placeholder="Search documents; press Enter to search...",
id="filter-search",
)
with VerticalScroll(id="filter-list"): with VerticalScroll(id="filter-list"):
yield Static("Loading...", id="loading-indicator") yield Static("Loading...", id="loading-indicator")
yield Static("", id="filter-footer") yield Static("", id="filter-footer")
@ -170,7 +171,7 @@ class DocumentFilterModal(ModalScreen):
is showing and every selection stays reachable. is showing and every selection stays reachable.
""" """
if search is not None: if search is not None:
self._search = search self._search = search.strip()
self._page = 0 self._page = 0
self._listing_selected = False self._listing_selected = False
@ -223,11 +224,8 @@ class DocumentFilterModal(ModalScreen):
return max(1, -(-self._matching // DOCUMENT_PAGE)) return max(1, -(-self._matching // DOCUMENT_PAGE))
def _update_footer(self) -> None: def _update_footer(self) -> None:
"""Report the selection, and where in the listing this page sits. """Report the selection, where in the listing this page sits, and
whether the search box holds a term the listing was not built from."""
Counted from the checkboxes on screen, so narrowing the page as the user
types reports what they can actually see.
"""
footer = self.query_one("#filter-footer", Static) footer = self.query_one("#filter-footer", Static)
count = len(self._selected) count = len(self._selected)
if count == 0: if count == 0:
@ -236,17 +234,13 @@ class DocumentFilterModal(ModalScreen):
state = f"[bold]{count}[/bold] document(s) selected" state = f"[bold]{count}[/bold] document(s) selected"
if self._listing_selected: if self._listing_selected:
state += " [dim]— listing the selected[/dim]" state += " [dim]— listing the selected[/dim]"
visible = sum(1 for box in self.query(DocumentCheckbox) if box.display)
if self._pages > 1: if self._pages > 1:
state += ( state += (
f" [dim]— page {self._page + 1} of {self._pages}" f" [dim]— page {self._page + 1} of {self._pages}"
f" ({self._matching} total)[/dim]" f" ({self._matching} total)[/dim]"
) )
elif self._matching > visible: if self.query_one("#filter-search", Input).value.strip() != self._search:
state += ( state += " [dim]— press enter to search[/dim]"
f" [dim]— showing {visible} of {self._matching};"
" type and press enter to search[/dim]"
)
footer.update(state) footer.update(state)
async def on_checkbox_changed(self, event: Checkbox.Changed) -> None: async def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
@ -275,14 +269,7 @@ class DocumentFilterModal(ModalScreen):
await self._load_documents(event.value) await self._load_documents(event.value)
def on_input_changed(self, event: Input.Changed) -> None: def on_input_changed(self, event: Input.Changed) -> None:
"""Narrow the page already shown, for feedback while typing.""" """Prompt when the search term has not been submitted."""
search_term = event.value.lower().strip()
filter_list = self.query_one("#filter-list", VerticalScroll)
for checkbox in filter_list.query(DocumentCheckbox):
checkbox.display = (
search_term == "" or search_term in checkbox.search_text.lower()
)
self._update_footer() self._update_footer()
async def _turn_to(self, page: int) -> None: async def _turn_to(self, page: int) -> None:

View file

@ -945,10 +945,22 @@ class TestKeepingSelectionsReachable:
assert "No documents match" in str(empty.content) assert "No documents match" in str(empty.content)
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_narrowing_as_you_type_updates_the_count(self, temp_db_path: Path): async def test_typing_without_submitting_leaves_the_listing_alone(
from textual.widgets import Input, Static self, temp_db_path: Path
):
"""The listing is what the last submitted search asked for, and it pages.
from haiku.rag.chat.widgets.document_filter_modal import DocumentFilterModal Narrowing it as the user types would hide rows from the page the search
landed on while the rest of the matches stayed a page away, so the term
applies on enter and says so until then.
"""
from textual.widgets import Button, Input, Static
from haiku.rag.chat.widgets.document_filter_modal import (
DOCUMENT_PAGE,
DocumentCheckbox,
DocumentFilterModal,
)
from haiku.rag.store.models.document import Document from haiku.rag.store.models.document import Document
client = AsyncMock() client = AsyncMock()
@ -958,7 +970,7 @@ class TestKeepingSelectionsReachable:
Document(id="id-one", content="", title="Capital region"), Document(id="id-one", content="", title="Capital region"),
Document(id="id-two", content="", title="Nobel laureates"), Document(id="id-two", content="", title="Nobel laureates"),
] ]
client.count_documents.return_value = 9 client.count_documents.return_value = DOCUMENT_PAGE * 2
modal = DocumentFilterModal(client=client) modal = DocumentFilterModal(client=client)
app, _ = _make_app(temp_db_path, client) app, _ = _make_app(temp_db_path, client)
@ -970,12 +982,32 @@ class TestKeepingSelectionsReachable:
await app.push_screen(modal) await app.push_screen(modal)
await pilot.pause() await pilot.pause()
footer = modal.query_one("#filter-footer", Static) footer = modal.query_one("#filter-footer", Static)
assert "showing 2 of 9" in str(footer.content) assert "press enter to search" not in str(footer.content)
modal.on_input_changed(Input.Changed(Input(), "nobel")) search = modal.query_one("#filter-search", Input)
search.value = "nobel"
modal.on_input_changed(Input.Changed(search, "nobel"))
assert "press enter to search" in str(footer.content)
assert all(box.display for box in modal.query(DocumentCheckbox))
await modal.on_button_pressed(
Button.Pressed(modal.query_one("#next-btn", Button))
)
await pilot.pause() await pilot.pause()
assert "showing 1 of 9" in str(footer.content) paged = client.list_documents.await_args.kwargs
assert paged["offset"] == DOCUMENT_PAGE
assert paged["filter"] is None
assert "press enter to search" in str(footer.content)
await modal.on_input_submitted(Input.Submitted(search, search.value))
await pilot.pause()
submitted = client.list_documents.await_args.kwargs
assert submitted["offset"] == 0
assert "nobel" in submitted["filter"]
assert "press enter to search" not in str(footer.content)
class TestDocumentSearchFilter: class TestDocumentSearchFilter: