diff --git a/crates/rm-handlers/src/common.rs b/crates/rm-handlers/src/common.rs index 8f1a7b8..07b5b93 100644 --- a/crates/rm-handlers/src/common.rs +++ b/crates/rm-handlers/src/common.rs @@ -96,10 +96,29 @@ fn hex_nibble(n: u8) -> char { } } -/// Wrap a fragment in a minimal HTML5 document. G1 (Plan §5 GUI -/// chrome) will replace this with the master `base.askama` template -/// — at that point this fn deletes and every handler uses -/// `askama_axum::Template` directly. +/// Redmine's global `#top-menu` — the persistent bar of cross-app links +/// rendered on every page. The link set is fixed, so it's a `const` the +/// formatter splices in verbatim (no per-request allocation beyond the +/// outer `format!`). Mirrors Redmine's top menu shape (Home + the global +/// resource lists); the `id`/`class` hooks match Redmine so G1's +/// stylesheet drops straight in over this skeleton. +const TOP_MENU: &str = concat!( + r#"
"#, +); + +/// Wrap a fragment in the **master-layout skeleton** — an HTML5 document +/// carrying Redmine's persistent chrome: the global `#top-menu` nav, the +/// `#header` app title, and a `#main` > `#content` wrapper the fragment +/// lands in. G1 (Plan §5 GUI chrome) swaps this Rust-built shell for the +/// real `base.askama` master template + a stylesheet; the markup shape +/// (ids/classes) is already Redmine-compatible so that swap is cosmetic. /// /// The `title` parameter is **HTML-escaped** — handlers pass /// user-controlled strings (issue subjects, project names) and the @@ -109,18 +128,49 @@ fn hex_nibble(n: u8) -> char { /// /// `body` is treated as already-rendered HTML — askama-emitted by /// the kit's `render_list` / `render_detail`, which run their own -/// `escape = "html"` on data-derived strings. +/// `escape = "html"` on data-derived strings — and the handler-built +/// chrome (`render_action_bar`, the filter/sort/pagination strips), +/// which escape their own user-derived inputs. #[must_use] pub fn wrap_in_doc(title: &str, body: &str) -> String { let escaped_title = html_escape(title); format!( "\n\n\ \ + \ {escaped_title} · Redmine RS\ - {body}" + {TOP_MENU}\ +

Redmine RS

\ +
{body}
\ + " ) } +/// Render the validation-error block shown above a create/edit form — a +/// `role="alert"` list of messages. Empty string when there are no errors +/// (so a clean form renders no stray block). +/// +/// Factored here once a **third** form module (News) joined Issue + +/// Project as a caller — the project's "three points form a line" rule +/// (Plan §1.6). Callers pass `&'static str` literals from their own +/// `validate`, so no user-controlled content reaches this HTML; the +/// messages are spliced verbatim. +#[must_use] +pub(crate) fn render_errors(errors: &[&str]) -> String { + if errors.is_empty() { + return String::new(); + } + let mut out = String::with_capacity(64 + errors.len() * 32); + out.push_str(r#""); + out +} + /// Hash a SurrealDB `RecordId` to a `u64` — the render kit's /// `render_detail(record_id: u64, ...)` parameter takes an integer /// for display + the `data-record-id` HTML attribute. SurrealDB @@ -209,6 +259,44 @@ mod tests { ); } + #[test] + fn wrap_in_doc_renders_global_top_menu_and_content_wrapper() { + // Every page carries Redmine's persistent chrome: the global + // top menu, the app header, and a #content wrapper the fragment + // lands inside. + let html = wrap_in_doc("Issues", "

body

"); + assert!( + html.contains(r#"id="top-menu""#), + "top menu missing:\n{html}" + ); + assert!( + html.contains(r#"Home"#), + "Home link missing:\n{html}" + ); + assert!( + html.contains(r#"href="/projects""#) && html.contains(r#"href="/issues""#), + "global resource links missing:\n{html}" + ); + assert!( + html.contains(r#"id="content""#), + "content wrapper missing:\n{html}" + ); + // The fragment still renders inside the shell. + assert!(html.contains("

body

"), "body not wrapped:\n{html}"); + // Viewport meta for the eventual responsive stylesheet. + assert!(html.contains("viewport"), "viewport meta missing:\n{html}"); + } + + #[test] + fn render_errors_is_empty_without_errors_and_lists_them_otherwise() { + assert!(render_errors(&[]).is_empty(), "no block when no errors"); + let html = render_errors(&["Subject is required.", "Too long."]); + assert!(html.contains(r#"class="form-errors""#), "{html}"); + assert!(html.contains(r#"role="alert""#), "{html}"); + assert!(html.contains("
  • Subject is required.
  • "), "{html}"); + assert!(html.contains("
  • Too long.
  • "), "{html}"); + } + #[test] fn html_escape_covers_the_five_chars() { assert_eq!(html_escape("&<>\"'"), "&<>"'"); diff --git a/crates/rm-handlers/src/home.rs b/crates/rm-handlers/src/home.rs new file mode 100644 index 0000000..ceffc8b --- /dev/null +++ b/crates/rm-handlers/src/home.rs @@ -0,0 +1,230 @@ +//! Home / overview page (`GET /`). +//! +//! Redmine's landing page is a jumping-off point: a heading plus links +//! into every resource. This is the rm-* equivalent — a single overview +//! that lists each seeded concept with its live row count and links to +//! that resource's list route. It's the first thing a demo visitor sees, +//! so it doubles as the site map until G1's master template lands a +//! persistent top nav. +//! +//! Unlike the W1..W8 resource modules this one is **cross-cutting**: it +//! reads a count from every store table. On the in-memory store that's a +//! cheap `list_*().len()`; when a SQL backend lands these become +//! `COUNT(*)` and the handler shape doesn't change. +//! +//! Per Plan §8 file ownership it still owns exactly one file + one route +//! (`/`), so it merges into `rm-server` the same way every W* track does. + +use std::fmt::Write as _; + +use axum::extract::State; +use axum::response::Html; +use axum::routing::get; +use axum::Router; + +use crate::common::{html_escape, wrap_in_doc, AppState, HandlerError}; + +/// One row of the overview index: a human label, the list route, and the +/// live count of records behind it. Labels + hrefs are static (the route +/// table is fixed); only `count` is read from the store. +struct ResourceEntry { + label: &'static str, + href: &'static str, + count: usize, +} + +/// `GET /` — the overview / landing page. +/// +/// Reads one count per seeded resource and renders the index. Any store +/// error short-circuits to a 500 via `?` — a dead store means nothing +/// renders anyway, so there's no partial-page to salvage. +pub async fn home(State(state): State) -> Result, HandlerError> { + let s = &state.store; + // Counts in Redmine's sidebar grouping: work first, then taxonomy, + // then people, then SCM / meta. Each `?` bubbles a store failure. + let entries = [ + ResourceEntry { + label: "Issues", + href: "/issues", + count: s.list_issues().await?.len(), + }, + ResourceEntry { + label: "Projects", + href: "/projects", + count: s.list_projects().await?.len(), + }, + ResourceEntry { + label: "Time entries", + href: "/time_entries", + count: s.list_time_entries().await?.len(), + }, + ResourceEntry { + label: "News", + href: "/news", + count: s.list_news().await?.len(), + }, + ResourceEntry { + label: "Wiki pages", + href: "/wiki", + count: s.list_wiki_pages().await?.len(), + }, + ResourceEntry { + label: "Users", + href: "/users", + count: s.list_users().await?.len(), + }, + ResourceEntry { + label: "Roles", + href: "/roles", + count: s.list_roles().await?.len(), + }, + ResourceEntry { + label: "Repositories", + href: "/repositories", + count: s.list_repositories().await?.len(), + }, + ResourceEntry { + label: "Issue statuses", + href: "/issue_statuses", + count: s.list_issue_statuses().await?.len(), + }, + ResourceEntry { + label: "Trackers", + href: "/trackers", + count: s.list_trackers().await?.len(), + }, + ResourceEntry { + label: "Priorities", + href: "/enumerations/issue_priorities", + count: s.list_issue_priorities().await?.len(), + }, + ResourceEntry { + label: "Custom queries", + href: "/queries", + count: s.list_queries().await?.len(), + }, + ResourceEntry { + label: "Relations", + href: "/relations", + count: s.list_relations().await?.len(), + }, + ]; + let body = render_home(&entries); + Ok(Html(wrap_in_doc("Home", &body))) +} + +/// Pure render of the overview page from the resource counts. Split from +/// [`home`] so the markup is unit-testable without booting a store. All +/// labels + hrefs are HTML-escaped (uniform with the rest of the chrome, +/// even though today's values are static). +fn render_home(entries: &[ResourceEntry]) -> String { + let mut out = String::with_capacity(512 + 96 * entries.len()); + out.push_str(r#"
    "#); + out.push_str("

    Redmine RS

    "); + out.push_str( + r#"

    A faithful Redmine port on the OGAR canonical-vocabulary render kit.

    "#, + ); + // Quick-action row mirrors Redmine's home "+ New issue" jump. + out.push_str(r#""); + out.push_str(r#"

    Overview

      "#); + for e in entries { + let _ = write!( + &mut out, + r#"
    • {label} {count}
    • "#, + href = html_escape(e.href), + label = html_escape(e.label), + count = e.count, + ); + } + out.push_str("
    "); + out +} + +/// Build the home router. `rm-server` merges this at `/` — it's the one +/// route that owns the document root, so it sorts first in the merge +/// block. +pub fn router(state: AppState) -> Router { + Router::new().route("/", get(home)).with_state(state) +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use http_body_util::BodyExt; + use rm_store::{NewIssue, Store}; + use tower::ServiceExt; + + #[test] + fn render_home_lists_entries_with_counts_and_links() { + let entries = [ + ResourceEntry { + label: "Issues", + href: "/issues", + count: 27, + }, + ResourceEntry { + label: "Projects", + href: "/projects", + count: 3, + }, + ]; + let html = render_home(&entries); + assert!(html.contains(r#"href="/issues""#), "{html}"); + assert!(html.contains(">Issues"), "{html}"); + assert!( + html.contains(r#"27"#), + "issue count missing:\n{html}" + ); + assert!(html.contains(r#"href="/projects""#), "{html}"); + assert!( + html.contains(r#"3"#), + "project count missing:\n{html}" + ); + // The New issue quick-action is always present (it doesn't depend + // on the counts). + assert!( + html.contains(r#"href="/issues/new""#), + "new-issue quick action missing:\n{html}" + ); + } + + #[tokio::test] + async fn home_route_renders_overview_with_live_counts() { + let store = Store::open().await.expect("store boots"); + // Seed three issues so the issues row count is observable end-to-end. + for subject in ["one", "two", "three"] { + store + .create_issue(NewIssue { + subject: subject.to_string(), + description: None, + }) + .await + .expect("seed insert"); + } + let app = router(AppState { store }); + let res = app + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(res.status(), StatusCode::OK); + let bytes = res.into_body().collect().await.unwrap().to_bytes(); + let s = String::from_utf8(bytes.to_vec()).unwrap(); + assert!(s.contains("Redmine RS"), "title heading missing:\n{s}"); + assert!(s.contains(r#"href="/issues""#), "issues link missing:\n{s}"); + // 3 seeded issues → the issues row shows count 3. + assert!( + s.contains(r#"3"#), + "expected issue count 3:\n{s}" + ); + // Cross-resource: the projects link renders even with 0 projects, + // proving the overview spans every table, not just the seeded one. + assert!( + s.contains(r#"href="/projects""#), + "projects link missing:\n{s}" + ); + } +} diff --git a/crates/rm-handlers/src/issues.rs b/crates/rm-handlers/src/issues.rs index 55e4fef..2357369 100644 --- a/crates/rm-handlers/src/issues.rs +++ b/crates/rm-handlers/src/issues.rs @@ -49,7 +49,7 @@ use rm_store::IssueRow; use surrealdb_types::{RecordId, ToSql}; use crate::common::{html_escape, record_id_to_u64, wrap_in_doc, AppState, HandlerError}; -use crate::list_chrome::{ListQuery, SortDir}; +use crate::list_chrome::{render_action_bar, ListQuery, SortDir}; /// The list URL — used by the chrome to build self-referential links /// (filter form action, sort headers, pagination Prev/Next). @@ -117,14 +117,16 @@ pub async fn list( let table = render_list("Issues", 0x0102, "project_work_item", &cols, &[], &rows) .map_err(|e| HandlerError::Render(e.to_string()))?; - // Chrome composes around the table: filter bar above (with search + - // clear), clickable sort headers (column-by-column links over the - // existing table rows — emitted as a small nav block above the - // table), and pagination strip below. + // Chrome composes around the table: the contextual action bar at the + // very top (Redmine's "+ New issue" jump), filter bar below it (search + // + clear), clickable sort headers (column-by-column links over the + // existing table rows — emitted as a small nav block above the table), + // and pagination strip below. + let action_bar = render_action_bar(&[("New issue", "/issues/new")]); let filter_bar = q.render_filter_bar(LIST_PATH, "Filter by subject"); let sort_nav = render_sort_nav(&q); let pagination = q.render_pagination(LIST_PATH, total); - let body = format!("{filter_bar}\n{sort_nav}\n{table}\n{pagination}"); + let body = format!("{action_bar}\n{filter_bar}\n{sort_nav}\n{table}\n{pagination}"); Ok(Html(wrap_in_doc("Issues", &body))) } @@ -321,6 +323,20 @@ mod tests { assert!(s.contains("list-filter"), "filter bar always renders:\n{s}"); } + #[tokio::test] + async fn list_shows_new_issue_action_link() { + // The Redmine "+ New issue" CTA must sit on the list page and point + // at the D1 create form — present even on an empty list (that's the + // whole point of the empty state: a place to start adding). + let app = app_with_issues(&[]).await; + let (status, s) = body_of(app, "/issues").await; + assert_eq!(status, StatusCode::OK); + assert!( + s.contains(r#"href="/issues/new""#) && s.contains("New issue"), + "expected a New issue CTA linking to the create form:\n{s}" + ); + } + #[tokio::test] async fn list_renders_seeded_issues() { let app = diff --git a/crates/rm-handlers/src/issues_form.rs b/crates/rm-handlers/src/issues_form.rs index 6651708..2c3cb7e 100644 --- a/crates/rm-handlers/src/issues_form.rs +++ b/crates/rm-handlers/src/issues_form.rs @@ -50,7 +50,7 @@ use rm_store::NewIssue; use serde::Deserialize; use surrealdb_types::ToSql; -use crate::common::{wrap_in_doc, AppState, HandlerError}; +use crate::common::{render_errors, wrap_in_doc, AppState, HandlerError}; /// The submit URL — also the action the GET form points to. const SUBMIT_PATH: &str = "/issues"; @@ -172,26 +172,6 @@ fn render(form: &IssueForm, errors: &[&str]) -> Result, HandlerErro Ok(Html(wrap_in_doc("New issue", &body))) } -/// Render the validation-error block above the form. Empty string when -/// there are no errors. Errors are static `&'static str` literals from -/// [`validate`] — no user-controlled content reaches the HTML here, so -/// the literal strings are safe. -fn render_errors(errors: &[&str]) -> String { - if errors.is_empty() { - return String::new(); - } - let mut out = String::with_capacity(64 + errors.len() * 32); - out.push_str(r#""); - out -} - /// Form columns: Subject (the kit pulls `required` from the field's /// `InputData::Text { required: true }` — `RenderColumn` is just /// name+caption+kind), Description (optional TextArea). diff --git a/crates/rm-handlers/src/lib.rs b/crates/rm-handlers/src/lib.rs index 91beb6b..fdf0327 100644 --- a/crates/rm-handlers/src/lib.rs +++ b/crates/rm-handlers/src/lib.rs @@ -46,11 +46,14 @@ #![warn(missing_docs)] mod common; +pub mod home; pub mod issues; pub mod issues_form; pub mod list_chrome; pub mod news; +pub mod news_form; pub mod projects; +pub mod projects_form; pub mod queries; pub mod relations; pub mod roles; @@ -58,6 +61,7 @@ pub mod scm; pub mod taxonomy; pub mod time_entries; pub mod users; +pub mod wiki_form; pub mod wiki_pages; pub use common::{ diff --git a/crates/rm-handlers/src/list_chrome.rs b/crates/rm-handlers/src/list_chrome.rs index 57f27c8..3a3061b 100644 --- a/crates/rm-handlers/src/list_chrome.rs +++ b/crates/rm-handlers/src/list_chrome.rs @@ -294,6 +294,38 @@ impl ListQuery { } } +/// Render a Redmine-style **contextual action bar** — the strip of +/// action links (e.g. "New issue") Redmine renders top-right of a list +/// or detail page, in the `#content`'s `.contextual` slot. +/// +/// Each entry is `(label, href)`. Both are HTML-escaped: the label is +/// user-facing text and the href lands in an attribute, so even though +/// today's callers pass static strings, escaping keeps the contract +/// uniform with the rest of the chrome. An empty slice yields an empty +/// string — no stray `