feat(runtime): add URL-state GET navigation
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
use axum::body::Body;
|
||||
use axum::extract::State;
|
||||
use axum::http::Uri;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::get;
|
||||
use axum::Router;
|
||||
@@ -27,7 +28,6 @@ struct GalleryState {
|
||||
category: Mutex<String>,
|
||||
value: Mutex<String>,
|
||||
reset_status: Mutex<String>,
|
||||
query: Mutex<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -125,11 +125,6 @@ struct ResetMessage {
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[hemx::form("search")]
|
||||
struct SearchInput {
|
||||
query: String,
|
||||
}
|
||||
|
||||
#[derive(Hemplate)]
|
||||
struct AppShell {
|
||||
runtime_src: &'static str,
|
||||
@@ -287,14 +282,18 @@ impl GalleryState {
|
||||
category: Mutex::new("letters".into()),
|
||||
value: Mutex::new("alpha".into()),
|
||||
reset_status: Mutex::new("No message sent yet".into()),
|
||||
query: Mutex::new(String::new()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn home(State(state): State<Arc<GalleryState>>, request: PageRequest) -> impl IntoResponse {
|
||||
async fn home(
|
||||
State(state): State<Arc<GalleryState>>,
|
||||
uri: Uri,
|
||||
request: PageRequest,
|
||||
) -> impl IntoResponse {
|
||||
let query = query_param(uri.query(), "q");
|
||||
request
|
||||
.page_html(ui::page(&gallery_view(&state)), shell)
|
||||
.page_html(ui::page(&gallery_view(&state, &query)), shell)
|
||||
.title("hemx HTML examples")
|
||||
.fingerprint(ui::BUILD_FINGERPRINT)
|
||||
}
|
||||
@@ -329,7 +328,7 @@ fn shell(body: Html) -> Html {
|
||||
})
|
||||
}
|
||||
|
||||
fn gallery_view(state: &GalleryState) -> Gallery {
|
||||
fn gallery_view(state: &GalleryState, query: &str) -> Gallery {
|
||||
let contacts = state
|
||||
.contacts
|
||||
.lock()
|
||||
@@ -355,7 +354,7 @@ fn gallery_view(state: &GalleryState) -> Gallery {
|
||||
let category = state.category.lock().unwrap().clone();
|
||||
let value = state.value.lock().unwrap().clone();
|
||||
let reset_status = state.reset_status.lock().unwrap().clone();
|
||||
let query = state.query.lock().unwrap().clone();
|
||||
let query = query.trim().to_owned();
|
||||
let search_results = search_results_for(&query);
|
||||
Gallery {
|
||||
contacts,
|
||||
@@ -458,6 +457,43 @@ fn search_results_for(query: &str) -> Vec<SearchResult> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn query_param(query: Option<&str>, name: &str) -> String {
|
||||
query
|
||||
.unwrap_or("")
|
||||
.split('&')
|
||||
.filter_map(|pair| pair.split_once('='))
|
||||
.find_map(|(key, value)| (key == name).then(|| form_decode(value)))
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn form_decode(value: &str) -> String {
|
||||
let mut bytes = Vec::with_capacity(value.len());
|
||||
let mut input = value.as_bytes().iter().copied();
|
||||
while let Some(byte) = input.next() {
|
||||
match byte {
|
||||
b'+' => bytes.push(b' '),
|
||||
b'%' => {
|
||||
let high = input.next().and_then(hex_value);
|
||||
let low = input.next().and_then(hex_value);
|
||||
if let (Some(high), Some(low)) = (high, low) {
|
||||
bytes.push((high << 4) | low);
|
||||
}
|
||||
}
|
||||
byte => bytes.push(byte),
|
||||
}
|
||||
}
|
||||
String::from_utf8_lossy(&bytes).into_owned()
|
||||
}
|
||||
|
||||
fn hex_value(byte: u8) -> Option<u8> {
|
||||
match byte {
|
||||
b'0'..=b'9' => Some(byte - b'0'),
|
||||
b'a'..=b'f' => Some(byte - b'a' + 10),
|
||||
b'A'..=b'F' => Some(byte - b'A' + 10),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[hemx::component("gallery")]
|
||||
mod gallery_handlers {
|
||||
use super::*;
|
||||
@@ -586,50 +622,6 @@ mod gallery_handlers {
|
||||
effects.push(gallery::load_status.set(format!("Showing {} rows", *loaded)));
|
||||
effects
|
||||
}
|
||||
|
||||
#[hemx::handler]
|
||||
async fn search(
|
||||
State(state): State<Arc<GalleryState>>,
|
||||
Form(input): Form<SearchInput>,
|
||||
) -> impl IntoEffect {
|
||||
let query = input.query.trim().to_owned();
|
||||
let previous_query = {
|
||||
let mut stored_query = state.query.lock().unwrap();
|
||||
let previous_query = stored_query.clone();
|
||||
*stored_query = query.clone();
|
||||
previous_query
|
||||
};
|
||||
let previous_results = search_results_for(&previous_query);
|
||||
let current_results = search_results_for(&query);
|
||||
let previous_keys = previous_results
|
||||
.iter()
|
||||
.map(|result| result.id.to_string())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
let current_keys = current_results
|
||||
.iter()
|
||||
.map(|result| result.id.to_string())
|
||||
.collect::<std::collections::BTreeSet<_>>();
|
||||
// Keep filtered keyed collections stable: remove filtered-out rows, replace retained
|
||||
// rows, and append newly visible rows instead of clearing the whole list. req: list/006
|
||||
let mut effects = previous_keys
|
||||
.difference(¤t_keys)
|
||||
.cloned()
|
||||
.map(|key| gallery::search_result.remove(key))
|
||||
.collect::<Vec<_>>();
|
||||
effects.extend(current_results.into_iter().map(|result| {
|
||||
if previous_keys.contains(&result.id.to_string()) {
|
||||
gallery::search_result.replace(result)
|
||||
} else {
|
||||
gallery::search_result.append(result)
|
||||
}
|
||||
}));
|
||||
effects.push(gallery::search_status.set(if query.is_empty() {
|
||||
"Showing all results".into()
|
||||
} else {
|
||||
format!("Results for {query}")
|
||||
}));
|
||||
effects
|
||||
}
|
||||
}
|
||||
|
||||
#[hemx::component("contact_card")]
|
||||
@@ -731,18 +723,45 @@ mod tests {
|
||||
#[test]
|
||||
fn inline_validation_form_uses_input_event_for_revalidation() {
|
||||
let state = Arc::new(GalleryState::seeded());
|
||||
let html = gallery_view(&state).render().expect("render gallery");
|
||||
let html = gallery_view(&state, "").render().expect("render gallery");
|
||||
assert!(
|
||||
html.contains("data-hemx-on=\"input\""),
|
||||
"inline validation form must listen on input events"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_search_is_reconstructed_from_url_query() {
|
||||
// req: page_swap/009 req: page_swap/010
|
||||
let state = Arc::new(GalleryState::seeded());
|
||||
let html = gallery_view(&state, &query_param(Some("q=ga"), "q"))
|
||||
.render()
|
||||
.expect("render gallery");
|
||||
assert!(html.contains("name=\"q\" value=\"ga\""));
|
||||
assert!(html.contains("Results for ga"));
|
||||
assert!(html.contains("Gamma"));
|
||||
assert!(!html.contains("Alpha"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_search_form_marks_live_replace_and_submit_push() {
|
||||
// req: page_swap/009
|
||||
let state = Arc::new(GalleryState::seeded());
|
||||
let html = gallery_view(&state, "").render().expect("render gallery");
|
||||
assert!(html.contains("method=\"get\""));
|
||||
assert!(html.contains("data-hemx-history=\"replace\""));
|
||||
assert!(html.contains("data-hemx-on=\"input\""));
|
||||
assert!(html.contains("data-hemx-history=\"push\""));
|
||||
assert!(!html.contains("data-hemx-handle=\"search\""));
|
||||
assert!(!html.contains("data-hemx-form=\"search\""));
|
||||
assert!(!html.contains("name=\"__h\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn revealed_forms_preserve_data_hemx_revealed_attribute() {
|
||||
// req: convention/005
|
||||
let state = Arc::new(GalleryState::seeded());
|
||||
let html = gallery_view(&state).render().expect("render gallery");
|
||||
let html = gallery_view(&state, "").render().expect("render gallery");
|
||||
assert!(
|
||||
html.contains("data-hemx-revealed=\"true\""),
|
||||
"lazy-load and infinite-scroll forms must preserve data-hemx-revealed attribute"
|
||||
@@ -792,7 +811,7 @@ mod tests {
|
||||
#[tokio::test]
|
||||
async fn gallery_covers_core_html_patterns_with_generated_resources() {
|
||||
let state = Arc::new(GalleryState::seeded());
|
||||
let html = gallery_view(&state).render().expect("render gallery");
|
||||
let html = gallery_view(&state, "").render().expect("render gallery");
|
||||
assert!(html.contains("data-hemx-root=\"gallery\""));
|
||||
for slug in [
|
||||
"click-to-edit",
|
||||
@@ -952,34 +971,6 @@ mod tests {
|
||||
);
|
||||
assert!(load.payload_contains("Loaded row 6"));
|
||||
|
||||
let search = inspect_batch(
|
||||
InteractionRequest::from(form(gallery::search, &[("query", "ga")]))
|
||||
.dispatch_async(handlers(state.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.batch,
|
||||
);
|
||||
assert!(search.removes_key(gallery::search_result, "1"));
|
||||
assert!(search.removes_key(gallery::search_result, "2"));
|
||||
assert!(search.removes_key(gallery::search_result, "4"));
|
||||
assert!(search.replaces_keyed_html_containing(gallery::search_result, "3", "Gamma"));
|
||||
|
||||
let broader_search = inspect_batch(
|
||||
InteractionRequest::from(form(gallery::search, &[("query", "a")]))
|
||||
.dispatch_async(handlers(state.clone()))
|
||||
.await
|
||||
.unwrap()
|
||||
.batch,
|
||||
);
|
||||
assert!(broader_search.inserts_html_containing(gallery::search_result, "1", "Alpha"));
|
||||
assert!(broader_search.inserts_html_containing(gallery::search_result, "2", "Beta"));
|
||||
assert!(broader_search.replaces_keyed_html_containing(
|
||||
gallery::search_result,
|
||||
"3",
|
||||
"Gamma"
|
||||
));
|
||||
assert!(broader_search.inserts_html_containing(gallery::search_result, "4", "Delta"));
|
||||
|
||||
let row_save = inspect_batch(
|
||||
InteractionRequest::from(form(
|
||||
editable_row::save_row,
|
||||
|
||||
Reference in New Issue
Block a user