max / goingson
6 files changed,
+523 insertions,
-8 deletions
| @@ -102,10 +102,23 @@ | |||
| 102 | 102 | /// Returns `DATABASE_ERROR` if the search query fails. | |
| 103 | 103 | #[tauri::command] | |
| 104 | 104 | #[instrument(skip_all)] | |
| 105 | - | pub async fn search( | |
| 105 | + | #[allow( | |
| 106 | + | clippy::needless_pass_by_value, | |
| 107 | + | reason = "Tauri command handler: State and payload args are supplied by value per the #[tauri::command] contract" | |
| 108 | + | )] | |
| 109 | + | pub fn search( | |
| 106 | 110 | state: State<'_, Arc<AppState>>, | |
| 107 | 111 | input: SearchInput, | |
| 108 | 112 | ) -> Result<SearchResultsResponse, ApiError> { | |
| 113 | + | run_search(&state, input) | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | /// The search itself, against state a described route can hold. | |
| 117 | + | /// | |
| 118 | + | /// Split from the command so the described screen can call it: a quasi handler | |
| 119 | + | /// sees `&AppState` and nothing else, and there is no second copy of the filter | |
| 120 | + | /// parsing for it to drift against. | |
| 121 | + | pub fn run_search(state: &AppState, input: SearchInput) -> Result<SearchResultsResponse, ApiError> { | |
| 109 | 122 | // Parse the query to extract filters | |
| 110 | 123 | let parsed = search_parser::parse_search_query(&input.query); | |
| 111 | 124 |
| @@ -55,12 +55,11 @@ | |||
| 55 | 55 | //! | Settings > Sharing | its reads are remote, so there is no local state to draw a section from. quasicoherent `82273265` | | |
| 56 | 56 | //! | ~~Settings > About~~ | back 2026-08-22: `AppState` holds the version and the platform now, which is what `theme_dirs` did for the theme path. See [`settings::about`] | | |
| 57 | 57 | //! | Create Backup | [`data`] finding 2: a described write cannot be long-running | | |
| 58 | - | //! | The search box | quasicoherent `d52884b0` settled the caret as the renderer's | | |
| 58 | + | //! | ~~The search box~~ | back 2026-08-28 as a screen of its own, on `Field::consults`. See [`search`] | | |
| 59 | 59 | //! | The blocking graph | `524261ac` ruled it bespoke; it draws an SVG with computed coordinates | | |
| 60 | 60 | //! | |
| 61 | - | //! The last two are not coming back as descriptions. They are refused for | |
| 62 | - | //! reasons that are correct, and if they return it is as something a host | |
| 63 | - | //! draws. | |
| 61 | + | //! The blocking graph is not coming back as a description. It is refused for a | |
| 62 | + | //! reason that is correct, and if it returns it is as something a host draws. | |
| 64 | 63 | //! | |
| 65 | 64 | //! **Two checks lost their subject in the same commit**, and one of them is a | |
| 66 | 65 | //! real hole. `check_touch_density` is dropped on the instruction the check | |
| @@ -101,6 +100,7 @@ | |||
| 101 | 100 | pub mod monthly_review; | |
| 102 | 101 | pub mod problems; | |
| 103 | 102 | pub mod projects; | |
| 103 | + | pub mod search; | |
| 104 | 104 | pub mod settings; | |
| 105 | 105 | pub mod shell; | |
| 106 | 106 | pub mod task_list; | |
| @@ -333,6 +333,7 @@ | |||
| 333 | 333 | let router = weekly_review::routes(router); | |
| 334 | 334 | let router = monthly_review::routes(router); | |
| 335 | 335 | let router = problems::routes(router); | |
| 336 | + | let router = search::routes(router); | |
| 336 | 337 | let router = day_planning::routes(router); | |
| 337 | 338 | let router = contexts::routes(router); | |
| 338 | 339 | let router = board::routes(router); |
| @@ -98,6 +98,8 @@ | |||
| 98 | 98 | pub const OUTBOX: &str = "outbox"; | |
| 99 | 99 | /// Contacts, and the contact dashboard behind it. | |
| 100 | 100 | pub const CONTACTS: &str = "contacts"; | |
| 101 | + | /// Search, across all five kinds the index holds. | |
| 102 | + | pub const SEARCH: &str = "search"; | |
| 101 | 103 | /// Settings, and Import & Export, which its sidebar navigates to. | |
| 102 | 104 | pub const SETTINGS: &str = "settings"; | |
| 103 | 105 | ||
| @@ -106,7 +108,7 @@ | |||
| 106 | 108 | /// A group's own action is the view its tab opens, which is `TAB_DEFAULTS` in | |
| 107 | 109 | /// `navigation.js`. A group with nowhere to go would be a tab that does | |
| 108 | 110 | /// nothing, and pressing Work in the shipped app opens Tasks. | |
| 109 | - | fn nav() -> [Place; 4] { | |
| 111 | + | fn nav() -> [Place; 5] { | |
| 110 | 112 | [ | |
| 111 | 113 | Place::new("work", "Work", Action::get("/tasks")).within([ | |
| 112 | 114 | Place::new(TASKS, "Tasks", Action::get("/tasks")), | |
| @@ -127,6 +129,13 @@ | |||
| 127 | 129 | Place::new(OUTBOX, "Out box", Action::get("/outbox")), | |
| 128 | 130 | Place::new(CONTACTS, "Contacts", Action::get("/contacts")), | |
| 129 | 131 | ]), | |
| 132 | + | // Search reaches every kind, so it belongs to no group and is a place | |
| 133 | + | // of its own. It is a nav entry rather than a binding because the app | |
| 134 | + | // binds no keys and cannot until quasicoherent `858be2a6` gives the | |
| 135 | + | // overlay a container -- and because a nav entry is the one entry point | |
| 136 | + | // that serves pointer, touch and keyboard alike, which is what goingson | |
| 137 | + | // `6b3aa22b` was actually complaining about. | |
| 138 | + | Place::new(SEARCH, "Search", Action::get("/search")), | |
| 130 | 139 | // No sub-places, so it draws as a place of its own. The shipped header | |
| 131 | 140 | // puts it on the right; where it lands here is the stylesheet's. | |
| 132 | 141 | Place::new(SETTINGS, "Settings", Action::get("/settings")), |
| @@ -52,14 +52,17 @@ | |||
| 52 | 52 | async fn the_nav_offers_the_tabs_the_shipped_header_offers() { | |
| 53 | 53 | // Transcribed from `navigation.js`'s TAB_GROUPS rather than invented. A nav | |
| 54 | 54 | // that offered a different set would be a second answer to what the app | |
| 55 | - | // contains. | |
| 55 | + | // contains. Search is the one place that is not in TAB_GROUPS: the shipped | |
| 56 | + | // header reached it from a box rather than a tab, and goingson `6b3aa22b` | |
| 57 | + | // chose a nav entry because it reaches every kind and the app can bind no | |
| 58 | + | // keys. | |
| 56 | 59 | let chrome = super::chrome(); | |
| 57 | 60 | let tabs: Vec<&str> = chrome | |
| 58 | 61 | .nav | |
| 59 | 62 | .iter() | |
| 60 | 63 | .map(|place| place.label.as_str()) | |
| 61 | 64 | .collect(); | |
| 62 | - | assert_eq!(tabs, ["Work", "Time", "Messages", "Settings"]); | |
| 65 | + | assert_eq!(tabs, ["Work", "Time", "Messages", "Search", "Settings"]); | |
| 63 | 66 | ||
| 64 | 67 | let work = &chrome.nav[0]; | |
| 65 | 68 | let inside: Vec<&str> = work.within.iter().map(|p| p.label.as_str()).collect(); |
| @@ -1,0 +1,234 @@ | |||
| 1 | + | //! The search screen, described rather than built. | |
| 2 | + | //! | |
| 3 | + | //! <!-- wiki: quasi-overview --> | |
| 4 | + | //! | |
| 5 | + | //! The backend was never the missing half. The repository, the FTS index | |
| 6 | + | //! (`migrations/sqlite/011_full_text_search.sql`), the colon grammar | |
| 7 | + | //! (`goingson_core::search_parser`) and the command are all in the tree and all | |
| 8 | + | //! still work; `search.js` went with the rest of the frontend in the port and | |
| 9 | + | //! nothing replaced it, so the whole of it was unreachable from the app. Max | |
| 10 | + | //! ruled (a) on goingson `6b3aa22b`: it comes back as a description rather than | |
| 11 | + | //! being deleted, because what is stranded here is a built feature with a | |
| 12 | + | //! schema migration behind it. | |
| 13 | + | //! | |
| 14 | + | //! # Why a synchronous handler can serve it | |
| 15 | + | //! | |
| 16 | + | //! [`crate::commands::run_search`] is synchronous and always was under the | |
| 17 | + | //! surface: the repository trait's `search` is a plain function, and the `async` | |
| 18 | + | //! on the command was a leftover from the sqlx era that the rusqlite migration | |
| 19 | + | //! of 2026-08-07 did not clean up. It is gone in the same pass. So this needs | |
| 20 | + | //! none of the offload machinery a long-running write needs (goingson | |
| 21 | + | //! `dc2f2b46`); it calls the command's own body and answers with the result. | |
| 22 | + | //! | |
| 23 | + | //! # The shape | |
| 24 | + | //! | |
| 25 | + | //! - `GET /search` — the document: the query box, and whatever the current | |
| 26 | + | //! query finds. | |
| 27 | + | //! - `GET /search/results` — the filters and the result list alone, which is | |
| 28 | + | //! what the box asks for as it is typed. | |
| 29 | + | //! | |
| 30 | + | //! The box is a [`Field`] with [`Field::consults`], which landed 2026-08-17 for | |
| 31 | + | //! exactly this shape: a field that asks a route about its value once the value | |
| 32 | + | //! has stood still, with the answer landing where [`Action::replacing`] points. | |
| 33 | + | //! Search-as-you-type is its first consumer here. The query is also a query | |
| 34 | + | //! param on `/search`, so a search has an address and is not a state the field | |
| 35 | + | //! holds privately. | |
| 36 | + | //! | |
| 37 | + | //! # The parsed filters are shown back | |
| 38 | + | //! | |
| 39 | + | //! `commands::search` already collects a label per filter it understood, for | |
| 40 | + | //! the frontend that is gone. Drawing them is what makes a colon grammar | |
| 41 | + | //! learnable rather than secret: typing `is:overdue` and seeing `is:overdue` | |
| 42 | + | //! come back is the parser saying it agreed. A filter the parser did not | |
| 43 | + | //! recognise stays in the free text and simply does not appear, which is the | |
| 44 | + | //! same answer, said by omission. | |
| 45 | + | //! | |
| 46 | + | //! # A row says which kind it is | |
| 47 | + | //! | |
| 48 | + | //! Results are heterogeneous across all five `type:` values, so a row carries a | |
| 49 | + | //! [`Tag`] naming its kind, the way status is carried elsewhere. Each row | |
| 50 | + | //! activates to that kind's own screen; every one of the five has a described | |
| 51 | + | //! detail route, which is why no kind is left inert. | |
| 52 | + | ||
| 53 | + | // Handlers take their request by value because `quasi_router::Handler` is a | |
| 54 | + | // plain `fn(&S, Request)` pointer, so the signature is the router's. | |
| 55 | + | #![allow(clippy::needless_pass_by_value)] | |
| 56 | + | ||
| 57 | + | use quasi_router::screen::{Field, Row, Tag}; | |
| 58 | + | use quasi_router::{Action, Node, RegionKind, Response, RouteError, Router, Screen, Slot}; | |
| 59 | + | ||
| 60 | + | use crate::commands::{SearchInput, SearchResultResponse, SearchResultsResponse}; | |
| 61 | + | use crate::state::AppState; | |
| 62 | + | ||
| 63 | + | #[cfg(test)] | |
| 64 | + | mod tests; | |
| 65 | + | ||
| 66 | + | /// The region the query box replaces as it is typed. | |
| 67 | + | const RESULTS: &str = "search-results"; | |
| 68 | + | ||
| 69 | + | /// The kinds a result can be, and the routes they open. | |
| 70 | + | /// | |
| 71 | + | /// Spelled here rather than inferred from the string, because a row that | |
| 72 | + | /// activated a path built by interpolating an unrecognised kind is a 404 the | |
| 73 | + | /// first time somebody presses it. | |
| 74 | + | fn destination(result: &SearchResultResponse) -> Option<Action> { | |
| 75 | + | let path = match result.result_type.as_str() { | |
| 76 | + | "task" => format!("/tasks/{}", result.id), | |
| 77 | + | "project" => format!("/projects/{}", result.id), | |
| 78 | + | "event" => format!("/events/{}", result.id), | |
| 79 | + | "contact" => format!("/contacts/{}", result.id), | |
| 80 | + | "email" => format!("/emails/{}", result.id), | |
| 81 | + | _ => return None, | |
| 82 | + | }; | |
| 83 | + | Some(Action::get(path)) | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | /// What a row's tag calls its kind. | |
| 87 | + | fn kind_label(result_type: &str) -> &str { | |
| 88 | + | match result_type { | |
| 89 | + | "task" => "Task", | |
| 90 | + | "project" => "Project", | |
| 91 | + | "event" => "Event", | |
| 92 | + | "contact" => "Contact", | |
| 93 | + | "email" => "Email", | |
| 94 | + | other => other, | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | /// One result as a row. | |
| 99 | + | /// | |
| 100 | + | /// The snippet is the FTS extract around the match, which is the sentence that | |
| 101 | + | /// says why this row is here, so it is the row's secondary text. The project is | |
| 102 | + | /// a plain fact and goes in `meta`. | |
| 103 | + | fn row_for(result: &SearchResultResponse) -> Row { | |
| 104 | + | let mut row = Row::new(&result.title); | |
| 105 | + | ||
| 106 | + | if let Some(snippet) = result.snippet.as_deref().filter(|s| !s.is_empty()) { | |
| 107 | + | row = row.secondary(snippet); | |
| 108 | + | } | |
| 109 | + | if let Some(project) = result.project_name.as_deref().filter(|p| !p.is_empty()) { | |
| 110 | + | row = row.meta(project); | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | row = row.token(Tag::badge(kind_label(&result.result_type))); | |
| 114 | + | row.activate = destination(result); | |
| 115 | + | row | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | /// A param that is present and not blank. Blank is absent, which is what an | |
| 119 | + | /// emptied query box means. | |
| 120 | + | fn text<'a>(params: &'a quasi_router::Params, name: &str) -> Option<&'a str> { | |
| 121 | + | params.get(name).map(str::trim).filter(|v| !v.is_empty()) | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | /// Run the search behind the command, from state a route handler holds. | |
| 125 | + | fn look(state: &AppState, query: &str) -> Result<SearchResultsResponse, RouteError> { | |
| 126 | + | crate::commands::run_search( | |
| 127 | + | state, | |
| 128 | + | SearchInput { | |
| 129 | + | query: query.to_owned(), | |
| 130 | + | result_type: None, | |
| 131 | + | project_id: None, | |
| 132 | + | date_from: None, | |
| 133 | + | date_to: None, | |
| 134 | + | limit: None, | |
| 135 | + | offset: None, | |
| 136 | + | }, | |
| 137 | + | ) | |
| 138 | + | .map_err(|error| RouteError::internal(error.to_string())) | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | /// The grammar, said once on an empty screen. | |
| 142 | + | /// | |
| 143 | + | /// Not a permanent panel: it is what there is to show when there is no query | |
| 144 | + | /// and no results, and it is the only place the vocabulary is written down for | |
| 145 | + | /// somebody who has not read the parser. | |
| 146 | + | fn grammar() -> Vec<Node> { | |
| 147 | + | vec![ | |
| 148 | + | Node::text( | |
| 149 | + | "Type to search across tasks, emails, events, projects and contacts. \ | |
| 150 | + | Words narrow by text; a colon filter narrows by fact.", | |
| 151 | + | ), | |
| 152 | + | Node::text("is:overdue is:today is:tomorrow is:thisweek is:snoozed"), | |
| 153 | + | Node::text("is:pending is:started is:completed is:waiting"), | |
| 154 | + | Node::text("priority:high priority:medium priority:low"), | |
| 155 | + | Node::text("type:task type:email type:event type:project type:contact"), | |
| 156 | + | Node::text("in:ProjectName tag:name -tag:name"), | |
| 157 | + | Node::text("after:2026-08-01 before:2026-09-01"), | |
| 158 | + | ] | |
| 159 | + | } | |
| 160 | + | ||
| 161 | + | /// The filters and the results, which is what the query box replaces. | |
| 162 | + | fn results(state: &AppState, query: Option<&str>) -> Result<Slot, RouteError> { | |
| 163 | + | let mut slot = Slot::new(RESULTS, RegionKind::Pane); | |
| 164 | + | ||
| 165 | + | let Some(query) = query else { | |
| 166 | + | for node in grammar() { | |
| 167 | + | slot = slot.with(node); | |
| 168 | + | } | |
| 169 | + | return Ok(slot); | |
| 170 | + | }; | |
| 171 | + | ||
| 172 | + | let found = look(state, query)?; | |
| 173 | + | ||
| 174 | + | // What the parser understood, said back. A badge rather than a chip: these | |
| 175 | + | // are the query's own words read aloud, and pressing one would have to mean | |
| 176 | + | // removing it from a string the reader is holding the caret in. | |
| 177 | + | for filter in &found.active_filters { | |
| 178 | + | slot = slot.with(Node::Token(Tag::badge(filter))); | |
| 179 | + | } | |
| 180 | + | ||
| 181 | + | if found.results.is_empty() { | |
| 182 | + | return Ok(slot.with(Node::empty("Nothing matches that."))); | |
| 183 | + | } | |
| 184 | + | ||
| 185 | + | slot = slot.with(Node::text(match found.total { | |
| 186 | + | 1 => "1 result".to_owned(), | |
| 187 | + | total => format!("{total} results"), | |
| 188 | + | })); | |
| 189 | + | ||
| 190 | + | Ok(slot.with(Node::list(found.results.iter().map(row_for)))) | |
| 191 | + | } | |
| 192 | + | ||
| 193 | + | /// The query box. | |
| 194 | + | fn box_for(query: Option<&str>) -> Field { | |
| 195 | + | let mut field = Field::new(makeover_layout::FieldKind::Text, "q", "Search") | |
| 196 | + | // The answer replaces the results region, and the field asks for it | |
| 197 | + | // once the value has stood still. No submit: a search is a read, and | |
| 198 | + | // waiting for one would be the box pretending it writes something. | |
| 199 | + | .consults(Action::get("/search/results").replacing(RESULTS)); | |
| 200 | + | field.placeholder = Some("invoice is:overdue in:Ledger".to_owned()); | |
| 201 | + | if let Some(query) = query { | |
| 202 | + | field = field.value(query); | |
| 203 | + | } | |
| 204 | + | field | |
| 205 | + | } | |
| 206 | + | ||
| 207 | + | /// The whole screen. | |
| 208 | + | fn index(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 209 | + | let query = text(&request.carried, "q"); | |
| 210 | + | ||
| 211 | + | let band = Slot::new("search-band", RegionKind::Band) | |
| 212 | + | .with(Node::page("Search")) | |
| 213 | + | .with(Node::Field(Box::new(box_for(query)))); | |
| 214 | + | ||
| 215 | + | Ok(Screen::list_detail("Search", false) | |
| 216 | + | .at_place(super::shell::SEARCH) | |
| 217 | + | .with(band) | |
| 218 | + | .with(results(state, query)?) | |
| 219 | + | .into()) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | /// The results alone, which is what the query box asks for as it is typed. | |
| 223 | + | fn results_only(state: &AppState, request: quasi_router::Request) -> Result<Response, RouteError> { | |
| 224 | + | let slot = results(state, text(&request.carried, "q"))?; | |
| 225 | + | Ok(Response::fragment(RESULTS, Node::Region(slot))) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | /// The search screen's routes. | |
| 229 | + | #[must_use] | |
| 230 | + | pub fn routes(router: Router<AppState>) -> Router<AppState> { | |
| 231 | + | router | |
| 232 | + | .get("/search", index) | |
| 233 | + | .get("/search/results", results_only) | |
| 234 | + | } |
| @@ -1,0 +1,255 @@ | |||
| 1 | + | //! The search screen, driven through the router against a real database. | |
| 2 | + | //! | |
| 3 | + | //! Same property as the contacts tests: no Tauri runtime and no window, because | |
| 4 | + | //! a route is a function from state and params to a description. | |
| 5 | + | ||
| 6 | + | use std::sync::Arc; | |
| 7 | + | ||
| 8 | + | use goingson_core::{ | |
| 9 | + | NewContact, NewEmail, NewEvent, NewProject, NewTask, Priority, ProjectStatus, ProjectType, | |
| 10 | + | }; | |
| 11 | + | use quasi_http::Serves as _; | |
| 12 | + | use quasi_router::Outcome; | |
| 13 | + | use quasi_router::{Params, Request, Response}; | |
| 14 | + | ||
| 15 | + | use super::super::router; | |
| 16 | + | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 17 | + | ||
| 18 | + | /// State with the desktop user in place, which is who the handlers read as. | |
| 19 | + | async fn state() -> Arc<AppState> { | |
| 20 | + | let (state, _) = crate::test_utils::setup_test_state().await; | |
| 21 | + | let now = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(); | |
| 22 | + | state | |
| 23 | + | .db | |
| 24 | + | .conn() | |
| 25 | + | .unwrap() | |
| 26 | + | .execute( | |
| 27 | + | "INSERT OR IGNORE INTO users (id, email, password_hash, display_name, created_at) \ | |
| 28 | + | VALUES (?, ?, ?, ?, ?)", | |
| 29 | + | rusqlite::params![ | |
| 30 | + | DESKTOP_USER_ID.to_string(), | |
| 31 | + | "desktop@localhost", | |
| 32 | + | "x", | |
| 33 | + | "Desktop User", | |
| 34 | + | &now, | |
| 35 | + | ], | |
| 36 | + | ) | |
| 37 | + | .unwrap(); | |
| 38 | + | state | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | /// One of each of the five kinds the index holds, all matching "kestrel". | |
| 42 | + | fn seed_all_five(state: &AppState) { | |
| 43 | + | state | |
| 44 | + | .tasks | |
| 45 | + | .create( | |
| 46 | + | DESKTOP_USER_ID, | |
| 47 | + | NewTask::builder("Kestrel migration") | |
| 48 | + | .priority(Priority::High) | |
| 49 | + | .build(), | |
| 50 | + | ) | |
| 51 | + | .unwrap(); | |
| 52 | + | state | |
| 53 | + | .projects | |
| 54 | + | .create( | |
| 55 | + | DESKTOP_USER_ID, | |
| 56 | + | NewProject { | |
| 57 | + | name: "Kestrel".to_owned(), | |
| 58 | + | description: String::new(), | |
| 59 | + | project_type: ProjectType::SideProject, | |
| 60 | + | status: ProjectStatus::Active, | |
| 61 | + | }, | |
| 62 | + | ) | |
| 63 | + | .unwrap(); | |
| 64 | + | state | |
| 65 | + | .events | |
| 66 | + | .create( | |
| 67 | + | DESKTOP_USER_ID, | |
| 68 | + | NewEvent::builder("Kestrel planning", chrono::Utc::now()).build(), | |
| 69 | + | ) | |
| 70 | + | .unwrap(); | |
| 71 | + | state | |
| 72 | + | .contacts | |
| 73 | + | .create( | |
| 74 | + | DESKTOP_USER_ID, | |
| 75 | + | NewContact { | |
| 76 | + | display_name: "Kestrel Vendor".to_owned(), | |
| 77 | + | nickname: None, | |
| 78 | + | company: None, | |
| 79 | + | title: None, | |
| 80 | + | notes: String::new(), | |
| 81 | + | tags: Vec::new(), | |
| 82 | + | birthday: None, | |
| 83 | + | timezone: None, | |
| 84 | + | is_implicit: false, | |
| 85 | + | }, | |
| 86 | + | ) | |
| 87 | + | .unwrap(); | |
| 88 | + | state | |
| 89 | + | .emails | |
| 90 | + | .create( | |
| 91 | + | DESKTOP_USER_ID, | |
| 92 | + | NewEmail { | |
| 93 | + | project_id: None, | |
| 94 | + | from_address: "someone@example.com".to_owned(), | |
| 95 | + | to_address: "desktop@localhost".to_owned(), | |
| 96 | + | subject: "Kestrel delivery".to_owned(), | |
| 97 | + | body: "The kestrel ships Tuesday.".to_owned(), | |
| 98 | + | is_read: false, | |
| 99 | + | received_at: None, | |
| 100 | + | }, | |
| 101 | + | ) | |
| 102 | + | .unwrap(); | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | fn get(state: &AppState, path: &str, params: Params) -> Response { | |
| 106 | + | router() | |
| 107 | + | .handle(state, Request::get(path).carrying(params)) | |
| 108 | + | .expect("the route answers") | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | fn screen_html(response: Response) -> String { | |
| 112 | + | let Outcome::Screen(screen) = (response).outcome else { | |
| 113 | + | panic!("the route answers with a screen"); | |
| 114 | + | }; | |
| 115 | + | quasi_webview::Webview::new().screen(&screen) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | fn fragment_html(response: Response) -> String { | |
| 119 | + | let Outcome::Fragment { node, .. } = (response).outcome else { | |
| 120 | + | panic!("the route answers with a fragment"); | |
| 121 | + | }; | |
| 122 | + | quasi_webview::Webview::new().fragment(&node) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | #[tokio::test] | |
| 126 | + | async fn an_empty_query_teaches_the_grammar_rather_than_searching() { | |
| 127 | + | // The colon filters are the feature, and a screen that showed nothing until | |
| 128 | + | // a query arrived would keep them secret. | |
| 129 | + | let state = state().await; | |
| 130 | + | let html = screen_html(get(&state, "/search", Params::new())); | |
| 131 | + | assert!(html.contains("is:overdue"), "{html}"); | |
| 132 | + | assert!(html.contains("type:contact"), "{html}"); | |
| 133 | + | assert!(html.contains("-tag:name"), "{html}"); | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | #[tokio::test] | |
| 137 | + | async fn a_blank_query_is_the_same_as_no_query() { | |
| 138 | + | // An emptied box sends the param with nothing in it. Searching for the empty | |
| 139 | + | // string instead would answer with the whole database. | |
| 140 | + | let state = state().await; | |
| 141 | + | seed_all_five(&state); | |
| 142 | + | ||
| 143 | + | let html = screen_html(get(&state, "/search", Params::new().with("q", " "))); | |
| 144 | + | assert!(html.contains("is:overdue"), "{html}"); | |
| 145 | + | assert!(!html.contains("Kestrel migration"), "{html}"); | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | #[tokio::test] | |
| 149 | + | async fn the_screen_searches_across_all_five_kinds() { | |
| 150 | + | let state = state().await; | |
| 151 | + | seed_all_five(&state); | |
| 152 | + | ||
| 153 | + | let html = screen_html(get(&state, "/search", Params::new().with("q", "kestrel"))); | |
| 154 | + | for hit in [ | |
| 155 | + | "Kestrel migration", | |
| 156 | + | "Kestrel", | |
| 157 | + | "Kestrel planning", | |
| 158 | + | "Kestrel Vendor", | |
| 159 | + | "Kestrel delivery", | |
| 160 | + | ] { | |
| 161 | + | assert!(html.contains(hit), "missing {hit}: {html}"); | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | #[tokio::test] | |
| 166 | + | async fn a_row_says_which_kind_it_is() { | |
| 167 | + | // The results are heterogeneous, so a row that only said its title would | |
| 168 | + | // leave the reader to guess whether "Kestrel" is a project or a contact. | |
| 169 | + | let state = state().await; | |
| 170 | + | seed_all_five(&state); | |
| 171 | + | ||
| 172 | + | let html = screen_html(get(&state, "/search", Params::new().with("q", "kestrel"))); | |
| 173 | + | for kind in ["Task", "Project", "Event", "Contact", "Email"] { | |
| 174 | + | assert!(html.contains(kind), "missing the {kind} tag: {html}"); | |
| 175 | + | } | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | #[tokio::test] | |
| 179 | + | async fn the_parsed_filters_are_shown_back() { | |
| 180 | + | // What makes a colon grammar learnable: the parser saying which words it | |
| 181 | + | // took as filters, from the labels the command already collects. | |
| 182 | + | let state = state().await; | |
| 183 | + | seed_all_five(&state); | |
| 184 | + | ||
| 185 | + | let html = fragment_html(get( | |
| 186 | + | &state, | |
| 187 | + | "/search/results", | |
| 188 | + | Params::new().with("q", "kestrel is:pending priority:high type:task"), | |
| 189 | + | )); | |
| 190 | + | assert!(html.contains("is:pending"), "{html}"); | |
| 191 | + | assert!(html.contains("priority:high"), "{html}"); | |
| 192 | + | assert!(html.contains("type:task"), "{html}"); | |
| 193 | + | } | |
| 194 | + | ||
| 195 | + | #[tokio::test] | |
| 196 | + | async fn a_type_filter_narrows_to_that_kind() { | |
| 197 | + | let state = state().await; | |
| 198 | + | seed_all_five(&state); | |
| 199 | + | ||
| 200 | + | let html = fragment_html(get( | |
| 201 | + | &state, | |
| 202 | + | "/search/results", | |
| 203 | + | Params::new().with("q", "kestrel type:contact"), | |
| 204 | + | )); | |
| 205 | + | assert!(html.contains("Kestrel Vendor"), "{html}"); | |
| 206 | + | assert!(!html.contains("Kestrel migration"), "{html}"); | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | #[tokio::test] | |
| 210 | + | async fn a_query_that_matches_nothing_says_so() { | |
| 211 | + | let state = state().await; | |
| 212 | + | seed_all_five(&state); | |
| 213 | + | ||
| 214 | + | let html = fragment_html(get( | |
| 215 | + | &state, | |
| 216 | + | "/search/results", | |
| 217 | + | Params::new().with("q", "peregrine"), | |
| 218 | + | )); | |
| 219 | + | assert!(html.contains("Nothing matches that."), "{html}"); | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | #[tokio::test] | |
| 223 | + | async fn the_box_asks_the_results_route_as_it_is_typed() { | |
| 224 | + | // `Field::consults` is what makes this a search screen rather than a form: | |
| 225 | + | // no submit, and the answer lands in the results region. | |
| 226 | + | let state = state().await; | |
| 227 | + | let html = screen_html(get(&state, "/search", Params::new())); | |
| 228 | + | assert!(html.contains("/search/results"), "{html}"); | |
| 229 | + | assert!(html.contains("search-results"), "{html}"); | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | #[tokio::test] | |
| 233 | + | async fn a_search_has_an_address() { | |
| 234 | + | // The query is a param rather than state the field holds privately, so the | |
| 235 | + | // view a reader is looking at can be linked to and reloaded. | |
| 236 | + | let state = state().await; | |
| 237 | + | seed_all_five(&state); | |
| 238 | + | ||
| 239 | + | let html = screen_html(get(&state, "/search", Params::new().with("q", "kestrel"))); | |
| 240 | + | assert!(html.contains("Kestrel migration"), "{html}"); | |
| 241 | + | assert!( | |
| 242 | + | html.contains("kestrel"), | |
| 243 | + | "the box holds the query back: {html}" | |
| 244 | + | ); | |
| 245 | + | } | |
| 246 | + | ||
| 247 | + | #[tokio::test] | |
| 248 | + | async fn search_is_a_place_in_the_nav() { | |
| 249 | + | // The entry point, decided on goingson `6b3aa22b`: a nav entry serves | |
| 250 | + | // pointer, touch and keyboard alike, and the app can bind no keys. | |
| 251 | + | let state = state().await; | |
| 252 | + | let html = screen_html(get(&state, "/search", Params::new())); | |
| 253 | + | assert!(html.contains("/search"), "{html}"); | |
| 254 | + | assert!(html.contains("Search"), "{html}"); | |
| 255 | + | } |