Skip to content

feat(rm-handlers): D2 — issue list filter, sort, paginate (17-yr Redmine UX)#21

Merged
AdaWorldAPI merged 2 commits into
mainfrom
claude/issues-filter-sort-page-d2
Jun 23, 2026
Merged

feat(rm-handlers): D2 — issue list filter, sort, paginate (17-yr Redmine UX)#21
AdaWorldAPI merged 2 commits into
mainfrom
claude/issues-filter-sort-page-d2

Conversation

@AdaWorldAPI

Copy link
Copy Markdown
Owner

What

Adds the iconic Redmine issue-list chrome — the Plan §4 D2 depth track on top of the W1 width track. The "17 years of Redmine" UX layer, made generic from day one so the W2/W3/W4 handlers can reuse it:

GET /issues?q=foo&sort=subject:desc&page=2&per_page=50

New surface

A new rm_handlers::list_chrome module ships the helper every D2-enabled W-handler will consume:

item purpose
pub struct ListQuery { q, sort, page, per_page } axum Query<T> extractor — all fields optional
pub enum SortDir { Asc, Desc } parsed from :asc / :desc suffix
ListQuery::search_needle() -> String lowercased + trimmed; empty ⇒ no filter
ListQuery::page() -> u32 clamps to ≥ 1
ListQuery::per_page() -> u32 Redmine default 25; capped at 100 (hostile-URL guard)
ListQuery::sort() -> Option<(col, SortDir)> URL → (column, direction); :gibberish ⇒ Asc
ListQuery::page_window(total) -> (start, end) never out-of-bounds
ListQuery::render_filter_bar(action, placeholder) <form> with search input + Clear link
ListQuery::sort_href(action, column) self-toggling link, resets page to 1 (Redmine convention)
ListQuery::render_pagination(action, total) Prev / "Page N of M (total)" / Next strip

URLs are canonical (defaults like page=1 / per_page=25 omitted), values percent-encoded in URL boundaries and HTML-escaped in attribute boundaries — both pinned by tests.

W1 /issues integration

The list handler now wraps render_list with:

filter bar  ← search + Clear (active only when q is set)
sort nav    ← clickable column headers, ↑ / ↓ indicator on the active column
<the table> ← canonical render_list output, unchanged
pagination  ← Prev / position / Next, empty when total == 0

/issues/:id detail unchanged.

Honest scope (today)

Filter/sort/paginate runs in-memory on Store::list_issues() today — the store itself explicitly defers WHERE + LIMIT/OFFSET to a follow-on. The handler's URL contract is fixed by ListQuery; pushing the predicate into SurrealQL later doesn't change the wire shape.

Deferred (need data shape changes first, not chrome changes):

  • Status / priority / tracker / assignee facets — wait until those FKs land on IssueRow (W2/W3 taxonomy, W4 actor). When they do, the chrome grows one helper per facet without re-deriving the pagination math.
  • Group-by (status / tracker / assignee) — depends on facets first.
  • "Save query" — Query model already exists (W8a); wiring it into the chrome is its own follow-on.

Robustness pins (the tests guard these)

  • Hostile ?per_page=50000 → clamps to 100; no DoS via render.
  • Hostile ?sort=<unknown_col> → silent fallback to insertion order; no 4xx.
  • Hostile ?page=99999 → page window empty; "No data." renders; no panic.
  • Hostile ?q="><script>alert(1)</script> → escaped in the <input value="…"> echo; the raw payload never reaches the DOM.

Build

  • Toolchain 1.95. cargo test -p rm-handlers75 passed (was 64; +11 new — 13 list_chrome unit tests + 5 new /issues integration tests).
  • cargo fmt --check clean. cargo clippy -p rm-handlers clean.
  • One workspace dep added: serde = { workspace = true } (axum Query<T> deserialization).
  • 4 files: Cargo.toml (+3), lib.rs (+3), new list_chrome.rs, issues.rs rewritten.

Future W-handlers consume this

Next time a list view lands (Project, TimeEntry, Member, Forum, Comment, ...), the handler imports ListQuery + the chrome methods and gets the iconic Redmine UX for free. The pattern is in code; the pagination math has exactly one home.

…ine UX)

Adds the iconic Redmine issue-list chrome (the Plan §4 D2 depth track on
top of the W1 width track): `?q=` subject filter, `?sort=<col>[:asc|desc]`
clickable sort headers, `?page=N&per_page=N` pagination.

  GET /issues?q=foo&sort=subject:desc&page=2&per_page=50

Designed once, generic across resources. The new `rm_handlers::list_chrome`
module is the helper the W2 (projects), W3 (time-entries), W4 (users /
members), and subsequent W-handlers reuse instead of each re-rolling its own
search bar + pagination math. The exported surface is:

  pub struct ListQuery { q, sort, page, per_page }   // axum Query<T>
  pub enum SortDir { Asc, Desc }
  impl ListQuery {
      pub fn search_needle(&self) -> String;         // lowercased+trimmed
      pub fn page(&self) -> u32;                     // clamps to >= 1
      pub fn per_page(&self) -> u32;                 // Redmine default 25, cap 100
      pub fn sort(&self) -> Option<(&str, SortDir)>;
      pub fn page_window(&self, total) -> (start, end);  // never out-of-bounds
      pub fn render_filter_bar(&self, action, placeholder) -> String;
      pub fn sort_href(&self, action, column) -> String;
      pub fn render_pagination(&self, action, total) -> String;
  }

The W1 /issues handler now consumes the chrome around the canonical
`render_list` output: filter bar above (with Clear link when active),
sort-affordance nav above the table, pagination strip below. Per-page is
hard-capped at 100 so a hostile URL can't force the whole table into one
render. Unknown sort columns fall back silently to insertion order
(never errors the request). XSS-escaping covers the filter input value,
the action URL, and the HTML-attribute hrefs (percent-encoding the q=
value covers the URL boundary; html-escape the attribute boundary).

Filter/sort/page math runs in-memory on `Store::list_issues()` today
(deliberate first step — the Store explicitly defers WHERE + LIMIT/OFFSET
push-down to a follow-on). The handler's public shape is fixed by ListQuery;
moving the predicate into SurrealQL later doesn't change the URL contract.

Status / priority / tracker / assignee facets are deferred — IssueRow
doesn't carry those FKs yet (W2/W3 taxonomy + W4 actor land them; the
chrome grows one helper per facet without re-deriving the pagination math).

Scope: 4 files, +650 lines (mostly tests).
  - rm-handlers/Cargo.toml      +3   (workspace serde for Query<T>)
  - rm-handlers/src/lib.rs      +3   (pub mod list_chrome; re-export)
  - rm-handlers/src/list_chrome.rs   (NEW) 14 unit tests
  - rm-handlers/src/issues.rs   replaced — 6 new integration tests
                                 (filter substring, sort asc/desc, paginate,
                                  per_page cap, unknown-sort silent fallback,
                                  filter-input XSS escape)

cargo +1.95 test -p rm-handlers: 75 passed (was 64; +11 new).
cargo fmt --check + cargo clippy clean. No new deps beyond workspace serde.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.
To continue using code reviews, you can upgrade your account or add credits to your account and enable them for code reviews in your settings.

CI's `cargo clippy --all-targets -- -D warnings` caught two lints my
local `cargo clippy -p rm-handlers` (no --all-targets, no -D warnings)
silently let through:

1. `collapsible_if` — six nested `if let Some(x) { if pred(x) { ... } }`
   patterns in list_chrome.rs + one in issues.rs. The workspace is
   edition 2021, so let-chains aren't available; collapsed via
   `.filter(|p| pred)` on the Option / pattern instead — same semantics,
   single `if let` per site.

2. `needless_update` — one test struct literal set all four ListQuery
   fields then trailed `..Default::default()`. Dropped the spread.

CI's gate (.github/workflows/ci.yml):
  - cargo fmt --all --check
  - cargo clippy --all-targets -- -D warnings
  - cargo test --workspace

Local reproduction now matches CI's invocation (-p rm-handlers narrows
it without changing the lint set). cargo clippy --all-targets -- -D
warnings clean; 75 tests still pass.
@AdaWorldAPI AdaWorldAPI merged commit 0d31799 into main Jun 23, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants