fix(mangas): address review on sort feature — alias, index, validation, a11y
Backend - sort=recent stays a back-compat alias for created (parse_sort); invalid sort/order now return the structured 422 envelope instead of plain-text 400 - per-field default direction on omitted order (dates desc, text asc), matching the frontend so a bare ?sort=<field> reads the same in UI and API - migration 0033: index mangas(updated_at DESC, id) backing the default sort and its id tie-break; NULLS LAST now applied only to the nullable author key - tests: recent alias, per-field default (title+author), invalid-value 422, and a tie-break test that pins ordering by ascending id (mutation-verified) Frontend - pure sort helpers extracted to $lib/mangaSort with unit tests; coerceSort honors the recent alias so pasted/legacy URLs resolve to the same field - SegmentedControl: roving tabindex + arrow/Home/End keyboard nav, anchored on focus so rapid keypresses don't stick - UX: visible "Direction" labels (desktop + mobile), mobile sort-sheet section headings and a "Done" button; README sort/order contract updated Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -51,16 +51,58 @@ pub struct ListParams {
|
||||
pub limit: i64,
|
||||
#[serde(default)]
|
||||
pub offset: i64,
|
||||
/// Sort field: `created`, `updated` (default), `title`, or `author`.
|
||||
/// Also accepts the legacy `recent` alias (== `created`). Anything
|
||||
/// else → 422.
|
||||
#[serde(default)]
|
||||
pub sort: repo::manga::SortField,
|
||||
pub sort: Option<String>,
|
||||
/// `asc` or `desc`. Omitted → the field's natural default
|
||||
/// (dates `desc`, text `asc`). Anything else → 422.
|
||||
#[serde(default)]
|
||||
pub order: repo::manga::SortOrder,
|
||||
pub order: Option<String>,
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 {
|
||||
50
|
||||
}
|
||||
|
||||
/// Parse the `sort` wire value into a [`repo::manga::SortField`]. Validation
|
||||
/// and the legacy `recent` alias live here (not in serde) so unknown values
|
||||
/// return the structured 422 envelope rather than axum's plain-text
|
||||
/// `QueryRejection`, matching the rest of this handler's filters.
|
||||
fn parse_sort(raw: Option<&str>) -> AppResult<repo::manga::SortField> {
|
||||
use repo::manga::SortField;
|
||||
match raw.map(str::trim) {
|
||||
None | Some("") | Some("updated") => Ok(SortField::Updated),
|
||||
// `recent` was the pre-0.88 name for "newest created first"; keep it
|
||||
// as an alias so existing bots/scripts don't break.
|
||||
Some("created") | Some("recent") => Ok(SortField::Created),
|
||||
Some("title") => Ok(SortField::Title),
|
||||
Some("author") => Ok(SortField::Author),
|
||||
Some(other) => Err(AppError::ValidationFailed {
|
||||
message: format!(
|
||||
"sort must be one of created, updated, title, author (got {other:?})"
|
||||
),
|
||||
details: json!({ "sort": "invalid" }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse the `order` wire value. `None` means the client omitted it, so the
|
||||
/// caller applies the field's natural default. An explicit unknown value → 422.
|
||||
fn parse_order(raw: Option<&str>) -> AppResult<Option<repo::manga::SortOrder>> {
|
||||
use repo::manga::SortOrder;
|
||||
match raw.map(str::trim) {
|
||||
None | Some("") => Ok(None),
|
||||
Some("asc") => Ok(Some(SortOrder::Asc)),
|
||||
Some("desc") => Ok(Some(SortOrder::Desc)),
|
||||
Some(other) => Err(AppError::ValidationFailed {
|
||||
message: format!("order must be 'asc' or 'desc' (got {other:?})"),
|
||||
details: json!({ "order": "invalid" }),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_uuid_csv(field: &str, raw: Option<&str>) -> AppResult<Vec<Uuid>> {
|
||||
let Some(raw) = raw else { return Ok(Vec::new()) };
|
||||
let mut out = Vec::new();
|
||||
@@ -96,6 +138,10 @@ async fn list(
|
||||
Some(s.to_string())
|
||||
}
|
||||
};
|
||||
let sort = parse_sort(params.sort.as_deref())?;
|
||||
// Omitted `order` falls back to the field's natural direction, so a bare
|
||||
// `?sort=title` reads A→Z just like the UI shows it.
|
||||
let order = parse_order(params.order.as_deref())?.unwrap_or_else(|| sort.default_order());
|
||||
let q = repo::manga::ListQuery {
|
||||
search: params.search.filter(|s| !s.trim().is_empty()),
|
||||
status,
|
||||
@@ -106,8 +152,8 @@ async fn list(
|
||||
cw_exclude: crate::api::page_tags::parse_warnings_csv(params.cw_exclude.as_deref())?,
|
||||
limit,
|
||||
offset,
|
||||
sort: params.sort,
|
||||
order: params.order,
|
||||
sort,
|
||||
order,
|
||||
};
|
||||
let (items, total) = repo::manga::list_cards(&state.db, &q).await?;
|
||||
Ok(Json(PagedResponse::with_total(items, limit, offset, total)))
|
||||
|
||||
Reference in New Issue
Block a user