feat: Document list — extended sort options, broader search scope, eager tag filter, search feedback #180
Reference in New Issue
Block a user
Delete Branch "%!s()"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
User Feedback Summary
Several friction points were identified in the document list view during usability testing. Users wanted more control over sorting, expected the search to find more than it currently does, were confused about when a search was running, and found the tag filter too passive.
Problem 1 — Too few sort options
Users expected to be able to sort by fields beyond the current set. Suggested additions based on user requests:
Fix: Extend the sort dropdown with the fields above. Backend must support these sort parameters. Ensure the active sort option is clearly indicated (icon or highlight).
Problem 2 — Search only covers a subset of fields
The search input currently does not match against all relevant document fields. Users expected results when searching for sender names, receiver names, or tags — and were surprised when nothing appeared.
Fix: Extend the backend search to cover:
title,sender name,receiver name(s),tags, andnotes/transcription(if present). Document which fields are searched, either in a tooltip or placeholder text (e.g. "Titel, Personen, Tags durchsuchen…").Problem 3 — Tag filter is passive (requires explicit selection)
The tag input waits for the user to select a tag from a dropdown before filtering. Users expected results to update while they were still typing — as they do in modern search UIs.
Fix: Switch to an eager/live filter: as the user types in the tag field, the document list should filter by partial tag name match in real time (debounced ~200ms). Do not require the user to select a tag chip first to trigger filtering.
Problem 4 — No feedback when a search is running
After entering a search term, there is no visual indicator that anything is happening. The result list below changes silently, which users either missed entirely or attributed to a bug.
Two feedback mechanisms are needed:
Fix: Add a spinner to the search input (trailing icon slot, replaces search icon while loading). Add a result count label above the document list that updates after each search. If zero results, show a friendly empty state ("Keine Dokumente gefunden für „Suchbegriff"").
Acceptance Criteria
👨💻 Felix Brandt — Senior Fullstack Developer
Questions & Observations
Current sort mechanism: Before writing the first failing test for the new sort fields, I need to know how the backend currently handles sorting — Spring Data
Sortparameter, a custom@Query, or aSpecification? The new fields (sender name, receiver name, tag) require JOINs, so if the current mechanism is a simple column sort, this is a meaningful extension, not just adding an enum value.Sort by "tag" and "receiver" are multi-value — needs definition: A document can have multiple tags and multiple receivers. Which value does the sort key on? Alphabetically first? Most recently added? This must be defined before implementation starts, because it directly determines the SQL and the failing test.
Debounce belongs in a utility, not inline: The 200ms debounce for the eager tag filter should live in a reusable
debounce.tsutility function, not inlined in the component. This makes it independently testable withvi.useFakeTimers()and reusable if a second search field needs the same treatment.Loading state via
$state, result count via$derived:isLoadingis a piece of state driven by the fetch lifecycle. The result count label is derived from the response — use$derived, not$state+$effect.Component decomposition: The search bar, sort dropdown, tag filter, result count label, and empty state are five distinct visual regions. I'd plan at minimum:
SearchBar.svelte,SortDropdown.svelte,ResultCount.svelte,EmptyState.svelte. The page orchestrates state; components receive props.Empty state string interpolation: "Keine Dokumente gefunden für „Suchbegriff"" — the search term must be injected via Paraglide's parameter interpolation (
m.no_results({ term: searchTerm })), not string concatenation. Concatenation bypasses i18n and produces untranslatable strings.Suggestions
{#each sortedDocuments as doc (doc.id)}— confirm all{#each}blocks in the document list remain keyed bydoc.idafter this refactor.🏗️ Markus Keller — Application Architect
Questions & Observations
Multi-value sort fields need a design decision: Sort by
senderis unambiguous (one sender per document). Sort byreceiverand sort bytagare not — a document has multiple receivers and multiple tags. SQLORDER BYon a JOIN-expanded result set will produce duplicate rows unless you useDISTINCT ONor aggregate. What's the defined sort key for these fields? Without a decision here, the query will be subtly wrong.Are search and sort one backend request or two? The eager tag filter and the existing text search — do they hit the same endpoint with combined parameters, or are these separate calls? If separate, a fast typist can produce two inflight requests simultaneously. The UI will need to cancel the earlier request (AbortController) or accept that the last response wins. Either is fine, but it needs to be explicit.
Full-text search scope + sort scope = complex query risk: Searching across
title,sender name,receiver name,tags, andnoteswhile also supporting JOIN-based sorting will produce a non-trivial SQL query. At this scale it's probably fine, but atsvectorcolumn on documents (PostgreSQL full-text search) would give you both the search and a ranking signal for free. Worth at least noting as a future path if the ILIKE JOIN approach gets slow.Does the backend return a total count? The result count AC ("N Dokumente gefunden") requires the backend to return a total, either as a dedicated field in the response envelope or as a header. Does the current
/api/documentsendpoint return a count, or does it return only the list? If the latter, this needs a response shape change.Suggestions
receiverandtagexplicitly in this issue before implementation: "sort by first receiver alphabetically" or "sort by alphabetically first tag" — whichever is chosen, it should be a comment in the SQL or JPQL.Set.of("date", "title", "sender", "receiver", "tag", "uploadDate")). Never pass the sort field directly to anORDER BYclause without validation.{ documents: [...], total: N }as the shape — this also enables pagination later without a further API change.🧪 Sara Holt — QA Engineer & Test Strategist
Questions & Observations
The ACs are well-structured but have gaps that will make test cases ambiguous. A few I'd want closed before implementation:
Sort + search combination not specified: What happens when both a search term and a sort field are active simultaneously? E.g. search for "Karl" sorted by sender. The ACs treat these as independent features, but the most common user interaction is both at once. This needs explicit coverage.
Debounce test strategy: Asserting a 200ms debounce in Vitest requires
vi.useFakeTimers(). Without fake timers, the test either waits 200ms (slow, fragile) or can't verify the debounce fires. This is straightforward but needs to be planned.Race condition — two inflight requests: If the user types "Karl" quickly, three requests may fire. The second response arriving after the third would display stale results. Is there a cancellation strategy (AbortController)? If yes, there should be a test: fire two requests, resolve the first after the second — assert the UI shows the second response's results, not the first.
Suggested Test Cases (not yet in ACs)
debounce should not fire before 200msdebounce should fire exactly once after 200ms of inactivityshould return documents sorted by sender name ascendingshould return documents sorted by receiver nameshould return documents matching sender name in searchshould return documents matching tag name in searchshould return total count in response envelopeshould reject unknown sort field with 400should show spinner while search is in-flightshould show result count after search completesshould show empty state with search term when no resultsuser can sort by sender and see ordered resultsuser types in tag filter and list updates without explicit submitTestability Concerns
<script>renders as plain text — Svelte escapes by default, but this is worth a snapshot test.🔒 Nora "NullX" Steiner — Application Security
Questions & Observations
Three areas to verify before or during implementation:
1. Sort parameter injection — CWE-89 (SQL Injection via ORDER BY)
The sort field is user-controlled and will be passed to the backend.
ORDER BYclauses cannot use parameterized values in JDBC/JPA — only column names, which means if the sort value is interpolated directly into the query, it's injectable:This is the most important security check for this feature. Add a
@WebMvcTestthat asserts?sort=1;DROP TABLE documents--returns 400.2. Reflected search term in empty state — potential XSS surface
The empty state message "Keine Dokumente gefunden für „{term}"" renders the search term back to the user. Svelte's
{term}syntax escapes HTML by default — this is safe. The risk arises only if someone uses{@html term}in the template. Flag this during code review: never use{@html}for user-supplied search terms.3. Tag filter live search — unauthenticated enumeration check
The eager tag filter likely calls a tag suggestions endpoint on every keystroke. Confirm this endpoint requires authentication. An unauthenticated tag enumeration endpoint leaks the full tag taxonomy of the archive to anyone who can reach the server — which for a family archive is probably undesirable.
Other Observations
:term), not string concatenation. The existing queries appear to follow this pattern; just verify the extensions do too.🎨 Leonie Voss — UI/UX Design Lead
Questions & Observations
Active sort indicator design: The issue says "icon or highlight" — I'd push for both: a directional arrow (↑↓) next to the active sort label, and the active option in
brand-navyweight rather than just a checkmark. A checkmark alone is too small to notice at a glance, especially on mobile. Also: does clicking the same sort option toggle direction (asc → desc)? This is expected behavior but not specified.Spinner swap in the search icon slot: Replacing the search icon with a spinner during loading is a common pattern, but the transition matters. An abrupt icon swap feels like a glitch. A CSS opacity transition (fade out icon, fade in spinner) over ~100ms reads as intentional. The icon area must remain exactly the same size — no layout shift when the spinner appears.
Result count placement and stale state: "12 Dokumente gefunden" appearing above the list — what does this label show while the next search is loading? Options: (a) hide it, (b) show the previous count greyed out, (c) show nothing. Option (b) is most informative but requires a
loadingclass on the count element. Whatever is chosen, it should be consistent and not flicker between states.Empty state copy with search term: The proposed format is
Keine Dokumente gefunden für „Suchbegriff"— the German typographic angle quotes„..."are correct. Make sure the Paraglide template produces these, not straight double quotes. Also: if the search term is very long (40+ chars), does the empty state label overflow on mobile? Clamp or truncate with ellipsis.Eager tag filter — changed interaction model: Users who have learned to select a chip before results filter will be surprised when results change while they're still typing. Consider a short transitional affordance: a subtle "live search" label near the tag input, or a tooltip on first interaction. This is especially important for senior users who may interpret unexpected result changes as an error.
Suggestions
font-size: 1rem(16px) minimum and each option hasmin-height: 44pxfor touch usability.<select>behavior if we use a native element, but if using a custom dropdown, all of these must be implemented manually and tested.de.json,en.json,es.json. Placeholder text is often forgotten in i18n.⚙️ Tobias Wendt — DevOps & Platform Engineer
Questions & Observations
Request volume increase from eager tag filter: The current "search on submit" pattern produces one backend request per user search. A 200ms debounce on keystrokes produces roughly one request per 200ms of typing. For a user typing a 10-character tag name, that's ~5–8 requests vs. 1. For a family archive with a handful of concurrent users this is fine — but worth a mental note. If the search endpoint is slow (JOIN-heavy after the broadened scope), those 8 requests could queue up. A quick
EXPLAIN ANALYZEon the extended search query before shipping is cheap insurance.Does the backend return a total count today? If not, a response envelope change is needed (
{ documents: [...], total: N }). This is a non-breaking addition if the frontend is already handling the response as a typed object — existing consumers that only readdocumentscontinue to work. Just confirm the OpenAPI spec is updated andnpm run generate:apiis re-run.No new infrastructure required: This is entirely application-layer work. No new services, no schema changes, no Docker Compose changes. Clean from an ops perspective.
AbortController for inflight request cancellation: If the frontend doesn't cancel superseded requests, the backend will process all of them regardless — wasted compute. Using
AbortControllerin the fetch call is a frontend concern, but it directly reduces unnecessary backend load. Worth a note in the implementation.What Looks Good
🏗️ Markus Keller — Application Architect (follow-up discussion)
This comment captures the outcomes of an architecture discussion on the four open items from my earlier review, plus two pre-resolved decisions brought in by the team.
Pre-resolved (before discussion)
last_namethenfirst_name, take the first in that list, and use that person'slast_name/first_nameas the document's sort key.Resolved items
1. SQL strategy for "first receiver alphabetically" sort
Use a native SQL subquery in the
ORDER BYclause:JPQL cannot express a subquery in
ORDER BY(spec limitation), so this sort case gets its own@Query(nativeQuery = true). Add an index on(document_id, last_name, first_name)on the join table side.NULLS LASThandles documents with no receivers gracefully.2. Search + sort + tag filter — one endpoint, combined params
All filters go to the existing
GET /api/documentsendpoint as combined query parameters:One request per interaction (debounced for the tag field). No separate tag suggestions endpoint. The backend applies all filters in a single query.
3. Response shape change to include total count
The current endpoint returns a plain list. The result count AC requires a total. Agreed shape:
Switch the repository method to return
Page<Document>(Spring Data) —page.getTotalElements()gives the count for free. Additive change; existing consumers that only readdocumentsare unaffected. Update the OpenAPI spec and re-runnpm run generate:api.4. Tag filter is server-side
The tag partial match is a backend filter, not client-side. The frontend cannot filter client-side since the document list is paginated — filtering only the current page would give wrong results. The tag text goes directly into the documents query as a partial ILIKE match, consistent with item 2.
Overall this is a clean, well-bounded feature. The receiver sort is the only non-trivial query; everything else is additive to the existing endpoint. No new infrastructure, no breaking API changes.
Design specs — Sort integration (Problem 1)
Two spec files committed to
main(commite6f12e6):docs/specs/sort-integration-spec.htmldocs/specs/sort-inline-final-spec.htmlDecision: Variant A — Inline sort in search bar row
Sort dropdown sits between the text input and the Filter button in row 1. On mobile it wraps to a second row inside the search card as an equal-width button alongside Filter.
Why this variant: highest discoverability (always visible, 0 clicks to reach), clearest active state (navy border + updated label), and the cleanest keyboard/accessibility flow of the four options. The pill strip (Variant B) was the runner-up if row 1 feels too crowded at 375px.
What the final spec covers
DocumentService, i18n keys)Sort options (6)
Datum·Titel·Absender·Empfänger·Tag·HochgeladenDirection: ↑ ascending / ↓ descending per option. Default: Datum ↓ (omitted from URL when default).
Spec authored by Leonie Voss (@leonievoss)
Implementation complete — PR #183
All tasks implemented with red/green/refactor TDD. Here's what was built:
Backend
DocumentSpecifications.hasTextextended with EXISTS subqueries for receiver name and tag name matching (avoids duplicate rows from multi-value joins)DocumentSpecifications.hasTagPartialnew method for live tag substring filter (tagQparam)DocumentSortenum (DATE, TITLE, SENDER, RECEIVER, UPLOAD_DATE) — Spring MVC auto-validates, returns 400 for unknown valuesDocumentSearchResultrecord wrapping{ List<Document> documents, long total }DocumentService.searchDocumentsextended withtagQ,sort,dirparams; SENDER and RECEIVER sort in-memory to avoid INNER JOIN excluding nullsDocumentControllerreturnsResponseEntity<DocumentSearchResult>Bug fixed during manual testing: SENDER sort with
Sort.by("sender.lastName")generated an INNER JOIN in Hibernate, silently dropping documents withsender = null. Fixed by sorting in-memory.Frontend
debounceutility insrc/lib/utils/debounce.ts(5 unit tests)SortDropdowncomponent — native<select>+ direction toggle buttonTagInput—onTextInputcallback prop for live filterSearchFilterBar—sort/dir/tagQbindable props, SortDropdown in row 1+page.server.ts— readssort/dir/tagQfrom URL, passes to API, unwraps{ documents, total }envelope+page.svelte—sort/dir/tagQstate, included intriggerSearch()URL paramsDocumentList—totalprop (shows "N Dokumente"),qprop (shows term in empty state)Commits
feat(backend): extend hasText spec to match sender/receiver/tag namesfeat(backend): add hasTagPartial spec for live tag name filterfeat(backend): add DocumentSort enum with Spring MVC auto-validationfeat(backend): wrap search response in DocumentSearchResult envelopefeat(backend): add sort/dir/tagQ params to searchDocumentsfeat(frontend): regenerate API types with new search params and result envelopefeat(search): add debounce utilityfeat(i18n): add sort, result count, and empty-state translation keysfeat(search): add SortDropdown component with direction togglefeat(search): add onTextInput callback to TagInput for live tag filterfeat(search): add sort/dir/tagQ props to SearchFilterBar with SortDropdownfeat(search): read sort/dir/tagQ from URL and unwrap DocumentSearchResult envelopefeat(search): wire sort/dir/tagQ state into page.svelte and URL paramsfeat(search): show result count and term-aware empty state in DocumentListfix(search): use in-memory sort for SENDER to include documents with null sender