diff --git a/Cargo.lock b/Cargo.lock index 673b4c3..6ab93d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -771,7 +771,9 @@ dependencies = [ "futures", "gloo-net 0.5.0", "gloo-timers", + "gloo-utils", "http 1.2.0", + "js-sys", "leptos", "leptos_actix", "leptos_meta", diff --git a/Cargo.toml b/Cargo.toml index 7780520..af16311 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,9 @@ crate-type = ["cdylib", "rlib"] actix-files = { version = "0.6", optional = true } actix-web = { version = "4", optional = true, features = ["macros"] } console_error_panic_hook = "0.1" +gloo-utils = "0.2" http = { version = "1.0.0", optional = true } +js-sys = "0.3" leptos = { version = "0.6" } leptos_meta = { version = "0.6" } leptos_actix = { version = "0.6", optional = true } diff --git a/assets/style.css b/assets/style.css index e6da611..36aa5d1 100644 --- a/assets/style.css +++ b/assets/style.css @@ -155,4 +155,52 @@ th { height: 100px; resize: vertical; overflow: auto; -} \ No newline at end of file +} + +.typeahead-container { + position: relative; +} + +.tt-menu { + width: 100%; + background: white; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + margin-top: 5px; +} + +.tt-suggestion { + padding: 8px 12px; + cursor: pointer; +} + +.tt-suggestion:hover { + background-color: #f5f5f5; +} + +.tt-cursor { + background-color: #e9e9e9; +} + +.tt-hint { + display: none !important; + } + + .tt-menu { + background: white; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 0 5px 10px rgba(0,0,0,.2); + margin-top: 5px; + width: 100%; + } + + .suggestion-item { + padding: 8px 12px; + cursor: pointer; + } + + .suggestion-item.tt-cursor { + background-color: #f5f5f5; + } \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 7a13fad..927f816 100644 --- a/src/app.rs +++ b/src/app.rs @@ -29,6 +29,9 @@ pub fn App() -> impl IntoView { // } // }); view! { + + + event_target_value(&e), InputType::TextArea => event_target_value(&e), - InputType::Search => event_target_value(&e), }; log!("Input event: {}", new_value); - set_local_value.set(new_value.clone()); - on_input(new_value); + set_local_value.set(new_value); }; - // // Commit the input value on blur or enter - // let commit_input = move || { - // let value = local_value.get(); - // log!("Committing input: {}", value); - // on_input(value); - // }; + // Commit the input value on blur or enter + let commit_input = move || { + let value = local_value.get(); + log!("Committing input: {}", value); + on_input(value); + }; // Focus handling let handle_focus = { @@ -51,7 +49,7 @@ pub fn EditableCell( let handle_blur = move |_| { log!("Focus lost"); set_focused_cell.set(None); - // commit_input(); + commit_input(); if let Some(on_blur) = &on_blur { on_blur.call(()); } @@ -60,23 +58,10 @@ pub fn EditableCell( // Update input field value when focused cell changes create_effect(move |_| { if focused_cell.get().as_deref() == Some(key.as_str()) { - log!("Persisting focus for key: {}", key); - let input_ref = input_ref.clone(); - let key_clone = key.clone(); - - // Use requestAnimationFrame for better focus timing - spawn_local(async move { - for _ in 0..3 { // Retry up to 3 times - gloo_timers::future::sleep(std::time::Duration::from_millis(10)).await; - if let Some(input) = input_ref.get() { - let _ = input.focus(); - if document().active_element().as_ref() == Some(input.as_ref() as &web_sys::Element) { - break; - } - } - log!("Focus retry for {}", key_clone); - } - }); + log!("Setting focus for key: {}", key); + if let Some(input) = input_ref.get() { + let _ = input.focus(); + } } }); @@ -103,17 +88,6 @@ pub fn EditableCell( node_ref=textarea_ref class="editable-cell-input" /> - }.into_view(), - InputType::Search => view! { - }.into_view() }} @@ -124,5 +98,4 @@ pub fn EditableCell( pub enum InputType { Text, TextArea, - Search } \ No newline at end of file diff --git a/src/components/items_list.rs b/src/components/items_list.rs index 5e32b63..8e582a7 100644 --- a/src/components/items_list.rs +++ b/src/components/items_list.rs @@ -10,12 +10,9 @@ use std::sync::Arc; use wasm_bindgen::JsCast; use std::rc::Rc; use urlencoding::encode; -#[derive(Deserialize, Clone, Debug)] -struct WikidataSuggestion { - id: String, - label: String, - description: Option, -} +use crate::components::typeahead_input::TypeaheadInput; +use crate::models::item::WikidataSuggestion; +use leptos::html::Input; //function to load items from database pub async fn load_items_from_db(current_url: &str) -> Result, String> { @@ -123,6 +120,7 @@ pub fn ItemsList( items: ReadSignal>, set_items: WriteSignal>, ) -> impl IntoView { + let node_ref = create_node_ref::(); // State to track selected properties let (selected_properties, set_selected_properties) = create_signal(HashMap::::new()); @@ -143,6 +141,8 @@ pub fn ItemsList( // State to manage property cache let (property_cache, set_property_cache) = create_signal(HashMap::>::new()); + + let (suggestions, set_suggestions) = create_signal(Vec::::new()); #[cfg(feature = "ssr")] fn get_current_url() -> String { use leptos::use_context; @@ -814,89 +814,49 @@ pub fn ItemsList( {match property { "Name" => view! { -
- + Vec { + let key = format!("name-{}", index); + fetch_wikidata_suggestions(key.clone(), query.clone()); + + // Add a small delay to ensure state is updated + let suggestions = wikidata_suggestions.get(); + suggestions.get(&key).cloned().unwrap_or_default() + }) + on_select=Callback::new(move |suggestion: WikidataSuggestion| { + set_items.update(|items| { + if let Some(item) = items.get_mut(index) { + item.name = suggestion.label.clone(); + item.description = suggestion.description.clone().unwrap_or_default(); + item.wikidata_id = Some(suggestion.id.clone()); + + // Automatically fetch properties when Wikidata ID is set + if let Some(wikidata_id) = &item.wikidata_id { + spawn_local({ + let set_property_labels = set_property_labels.clone(); + let property_cache = property_cache.clone(); + let set_property_cache = set_property_cache.clone(); + let property_labels = property_labels.clone(); + let wikidata_id = wikidata_id.clone(); + + async move { + fetch_item_properties( + &wikidata_id, + set_property_labels, + property_cache, + set_property_cache, + property_labels + ).await; + } + }); } - }); - } - key=Arc::new(format!("name-{}", index)) - focused_cell=focused_cell - set_focused_cell=set_focused_cell.clone() - on_focus=Some(Callback::new(move |_| { - set_show_suggestions.update(|suggestions| { - suggestions.insert(format!("name-{}", index), true); - }); - })) - on_blur=Some(Callback::new(move |_| { - spawn_local(async move { - set_show_suggestions.update(|suggestions| { - suggestions.insert(format!("name-{}", index), false); - }); - }); - })) - input_type=InputType::Search - /> - {move || { - if *show_suggestions.get().get(&format!("name-{}", index)).unwrap_or(&false) { - view! { -
-
    - {move || { - let suggestions = wikidata_suggestions.get() - .get(&format!("name-{}", index)) - .cloned() - .unwrap_or_default(); - suggestions.into_iter().map(|suggestion| { - let label = suggestion.label.clone(); - let description = suggestion.description.clone().unwrap_or_default(); - let id = suggestion.id.clone(); - - // Clone values for the closure - let closure_label = label.clone(); - let closure_description = description.clone(); - let closure_id = id.clone(); - - view! { -
  • -
    {label}
    -
    {description}
    - //
    {id}
    -
  • - } - }).collect::>() - }} -
-
} - } else { - view! {
} - } - }} + }); + }) + node_ref=node_ref + />
}.into_view(), diff --git a/src/components/mod.rs b/src/components/mod.rs index ca9be88..8cbce8a 100644 --- a/src/components/mod.rs +++ b/src/components/mod.rs @@ -1,2 +1,3 @@ pub mod items_list; -pub mod editable_cell; \ No newline at end of file +pub mod editable_cell; +pub mod typeahead_input; \ No newline at end of file diff --git a/src/components/typeahead_input.rs b/src/components/typeahead_input.rs new file mode 100644 index 0000000..02484f1 --- /dev/null +++ b/src/components/typeahead_input.rs @@ -0,0 +1,183 @@ +use leptos::*; +use wasm_bindgen::prelude::*; +use crate::models::item::WikidataSuggestion; +use js_sys::{Object, Array, Function, JSON, Reflect}; +use leptos::html::Input; +use gloo_utils::format::JsValueSerdeExt; +use wasm_bindgen::JsCast; +use web_sys::HtmlInputElement; +use leptos::logging::log; +#[component] +pub fn TypeaheadInput( + value: String, + on_select: Callback, + fetch_suggestions: Callback>, + node_ref: NodeRef, +) -> impl IntoView { + let (is_initialized, set_initialized) = create_signal(false); + + create_effect(move |_| { + if let (Some(input), false) = (node_ref.get(), is_initialized.get()) { + let bloodhound = initialize_bloodhound(fetch_suggestions.clone()); + initialize_typeahead(&input, bloodhound, on_select.clone(), node_ref.clone()); + set_initialized.set(true); + } + }); + + view! { + + } +} + +#[wasm_bindgen] +extern "C" { + #[wasm_bindgen(js_name = "Bloodhound")] + type Bloodhound; + + #[wasm_bindgen(constructor, js_namespace = window)] + fn new(options: &JsValue) -> Bloodhound; + + #[wasm_bindgen(method)] + fn initialize(this: &Bloodhound, prefetch: bool); + + #[wasm_bindgen(method, js_name = "ttAdapter")] + fn tt_adapter(this: &Bloodhound) -> JsValue; +} + +fn initialize_bloodhound(fetch: Callback>) -> JsValue { + let bloodhound_options = Object::new(); + + // Store the Closure in a variable to prevent premature garbage collection + let remote_fn = Closure::wrap(Box::new(move |query: String, sync: js_sys::Function| { + log!("Fetching suggestions for: {}", query); + let suggestions = fetch.call(query.clone()); + let array = Array::new(); + for suggestion in &suggestions { + let obj = Object::new(); + Reflect::set(&obj, &"label".into(), &suggestion.label.clone().into()).unwrap(); + Reflect::set(&obj, &"value".into(), &suggestion.id.clone().into()).unwrap(); + array.push(&obj); + } + sync.call1(&JsValue::NULL, &array).unwrap(); + }) as Box); + + // Configure Bloodhound + let remote_config = Object::new(); + Reflect::set(&remote_config, &"url".into(), &"".into()).unwrap(); + Reflect::set(&remote_config, &"wildcard".into(), &"%QUERY".into()).unwrap(); + Reflect::set(&remote_config, &"prepare".into(), &remote_fn.as_ref()).unwrap(); + Reflect::set(&remote_config, &"rateLimitWait".into(), &JsValue::from(300)).unwrap(); + + Reflect::set(&bloodhound_options, &"remote".into(), &remote_config).unwrap(); + Reflect::set(&bloodhound_options, &"queryTokenizer".into(), &JsValue::from("whitespace")).unwrap(); + Reflect::set(&bloodhound_options, &"datumTokenizer".into(), &JsValue::from("whitespace")).unwrap(); + + let bloodhound = Bloodhound::new(&bloodhound_options.into()); + bloodhound.initialize(true); + + // Prevent Closure from being dropped + remote_fn.forget(); + + bloodhound.into() +} + +fn initialize_typeahead( + input: &HtmlInputElement, + bloodhound: JsValue, + on_select: Callback, + node_ref: NodeRef, +) { + // input event handler for direct typing + let node_ref_clone = node_ref.clone(); + let input_handler = Closure::wrap(Box::new(move |_event: web_sys::Event| { + if let Some(input) = node_ref_clone.get() { + let value = input.value(); + log!("Input updated: {}", value); + // Create synthetic change event for Leptos + let event = web_sys::CustomEvent::new("input").unwrap(); + input.dispatch_event(&event).unwrap(); + } + }) as Box); + + input.add_event_listener_with_callback( + "input", + input_handler.as_ref().unchecked_ref() + ).unwrap(); + input_handler.forget(); + + let typeahead_options = Object::new(); + Reflect::set(&typeahead_options, &"hint".into(), &JsValue::TRUE).unwrap(); + Reflect::set(&typeahead_options, &"highlight".into(), &JsValue::TRUE).unwrap(); + Reflect::set(&typeahead_options, &"minLength".into(), &JsValue::from(1)).unwrap(); + + // Bloodhound remote configuration + let bloodhound_ref = bloodhound.unchecked_ref::(); + let remote_config = Object::new(); + Reflect::set(&remote_config, &"prepare".into(), &JsValue::from_str("function(q) { return { q: q }; }")).unwrap(); + Reflect::set(&remote_config, &"transform".into(), &js_sys::Function::new_with_args("response", "return response;")).unwrap(); + + // Update dataset configuration + let dataset = Object::new(); + Reflect::set(&dataset, &"source".into(), &bloodhound_ref.tt_adapter()).unwrap(); + Reflect::set(&dataset, &"display".into(), &"label".into()).unwrap(); + Reflect::set(&dataset, &"limit".into(), &JsValue::from(10)).unwrap(); + + // Create proper templates + let templates = Object::new(); + Reflect::set(&templates, &"suggestion".into(), &js_sys::eval(r#" + function(data) { + return '
' + + '' + data.label + '' + + (data.description ? '
' + data.description + '' : '') + + '
'; + } + "#).unwrap()).unwrap(); + + Reflect::set(&dataset, &"templates".into(), &templates).unwrap(); + + // Typeahead initialization using jQuery + let init_script = format!( + r#"(function() {{ + $('#{}').typeahead({}, {}); + }})"#, + input.id(), + JSON::stringify(&typeahead_options).unwrap(), + JSON::stringify(&dataset).unwrap() + ); + + let _ = js_sys::eval(&init_script).unwrap(); + + // Handle selection events + let closure = Closure::wrap(Box::new(move |event: web_sys::Event| { + if let Some(selected) = event.target() { + let js_value = selected.unchecked_into::(); + let data: WikidataSuggestion = js_sys::JSON::parse( + &js_sys::JSON::stringify(&js_value) + .unwrap() + .as_string() + .unwrap() + .as_str() + ).unwrap() + .into_serde() + .unwrap(); + let data_clone = data.clone(); + + on_select.call(data); + // Explicitly update the input value + if let Some(input) = node_ref.get() { + input.set_value(&data_clone.label); + } + } + }) as Box); + + input.add_event_listener_with_callback( + "typeahead:select", + closure.as_ref().unchecked_ref() + ).unwrap(); + closure.forget(); +} \ No newline at end of file diff --git a/src/models/item.rs b/src/models/item.rs index dd980b0..154b44d 100644 --- a/src/models/item.rs +++ b/src/models/item.rs @@ -10,3 +10,10 @@ pub struct Item { pub wikidata_id: Option, pub custom_properties: HashMap, } + +#[derive(Deserialize, Clone, Debug)] +pub struct WikidataSuggestion { + pub id: String, + pub label: String, + pub description: Option, +} \ No newline at end of file