From: Dietmar Maurer Date: Fri, 6 Sep 2024 06:09:02 +0000 (+0200) Subject: add search dropdown widget X-Git-Url: https://git.proxmox.com/?a=commitdiff_plain;h=15608823ccddd420aa8e81587de2623e305b21d4;p=ui%2Fproxmox-yew-widget-toolkit.git add search dropdown widget --- diff --git a/src/widget/mod.rs b/src/widget/mod.rs index 52e9125..7396c2a 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -95,6 +95,11 @@ pub use row::Row; mod rtl_switcher; pub use rtl_switcher::RtlSwitcher; +mod search_dropdown; +pub use search_dropdown::{ + FilteredLoadCallback, PwtSearchDropdown, SearchDropdown, SearchDropdownRenderArgs, +}; + mod selection_view; pub use selection_view::{ PwtSelectionView, SelectionView, SelectionViewRenderInfo, VisibilityContext, diff --git a/src/widget/search_dropdown.rs b/src/widget/search_dropdown.rs new file mode 100644 index 0000000..8e7280c --- /dev/null +++ b/src/widget/search_dropdown.rs @@ -0,0 +1,289 @@ +use std::future::Future; +use std::pin::Pin; +use std::rc::Rc; + +use anyhow::Error; +use derivative::Derivative; + +use yew::html::{IntoEventCallback, Scope}; +use yew::virtual_dom::Key; + +use crate::prelude::*; +use crate::props::{CssLength, FieldBuilder, RenderFn, WidgetBuilder}; +use crate::state::DataStore; +use crate::widget::data_table::{ + DataTable, DataTableColumn, DataTableHeader, DataTableKeyboardEvent, DataTableMouseEvent, +}; +use crate::widget::{Dropdown, DropdownController}; + +use pwt_macros::{builder, widget}; + +#[derive(Clone)] +/// Parameters passed to the [SearchDropdown::picker] callback. +/// +/// The select function trigger a selection and closes the dropdown. +pub struct SearchDropdownRenderArgs { + /// The [DataStore] used by the [Selector]. + pub store: S, + /// Drowdown controller. + pub controller: DropdownController, + + link: Scope>, +} + +impl SearchDropdownRenderArgs { + /// Trigger a selection and close the dropdown. + pub fn select(&self, key: Key) { + self.link.send_message(Msg::Select(key)); + self.controller.change_value(String::from("")); // close dropdown, clear filter + } +} + +/// Load callback with filter parameter. +/// +/// The callback gets called with the current value of the dropdown input, and +/// should return the filtered data. +pub struct FilteredLoadCallback { + callback: Rc Pin>>>>, +} + +impl FilteredLoadCallback { + pub fn new(callback: F) -> Self + where + F: 'static + Fn(String) -> R, + R: 'static + Future>, + { + Self { + callback: Rc::new(move |filter| { + let future = callback(filter); + Box::pin(future) + }), + } + } + + pub async fn apply(&self, filter: String) -> Result { + (self.callback)(filter).await + } +} + +impl Clone for FilteredLoadCallback { + fn clone(&self) -> Self { + Self { + callback: Rc::clone(&self.callback), + } + } +} + +impl PartialEq for FilteredLoadCallback { + fn eq(&self, _other: &Self) -> bool { + true // never trigger redraw + } +} + +#[widget(pwt=crate, comp=PwtSearchDropdown, @input)] +#[derive(Derivative, Properties)] +#[derivative(Clone(bound = ""), PartialEq(bound = ""))] +#[builder] +pub struct SearchDropdown { + /// Value change callback. + #[builder_cb(IntoEventCallback, into_event_callback, Key)] + #[prop_or_default] + pub on_select: Option>, + + /// Data loader callback. + loader: FilteredLoadCallback, + + /// Function to generate the picker widget. + picker: RenderFn>, +} + +impl SearchDropdown { + /// Create a new instance. + pub fn new(picker: impl Into>>, loader: F) -> Self + where + F: Fn(String) -> Fut + 'static, + Fut: Future> + 'static, + { + let loader = FilteredLoadCallback::new(loader); + yew::props!(Self { + loader, + picker: picker.into() + }) + } + + pub fn simple(render: impl Into>, loader: F) -> Self + where + F: Fn(String) -> Fut + 'static, + Fut: Future> + 'static, + { + let loader = FilteredLoadCallback::new(loader); + let render = render.into(); + + let picker: RenderFn> = + RenderFn::new(move |args: &SearchDropdownRenderArgs| { + let columns = Rc::new(vec![DataTableColumn::new("Value") + .show_menu(false) + .render(render.clone()) + .into()]); + + DataTable::new(columns, args.store.clone()) + .max_height(CssLength::Em(20.0)) + .show_header(false) + .on_row_click({ + let args = args.clone(); + move |event: &mut DataTableMouseEvent| { + args.select(event.record_key.clone()); + } + }) + .on_row_keydown({ + let args = args.clone(); + move |event: &mut DataTableKeyboardEvent| match event.key().as_str() { + " " | "Enter" => { + args.select(event.record_key.clone()); + } + _ => {} + } + }) + .into() + }); + + yew::props!(Self { loader, picker }) + } + + pub fn table(columns: Rc>>, loader: F) -> Self + where + F: Fn(String) -> Fut + 'static, + Fut: Future> + 'static, + { + let loader = FilteredLoadCallback::new(loader); + + let picker: RenderFn> = + RenderFn::new(move |args: &SearchDropdownRenderArgs| { + DataTable::new(columns.clone(), args.store.clone()) + .max_height(CssLength::Em(20.0)) + .header_focusable(false) + .on_row_click({ + let args = args.clone(); + move |event: &mut DataTableMouseEvent| { + args.select(event.record_key.clone()); + } + }) + .on_row_keydown({ + let args = args.clone(); + move |event: &mut DataTableKeyboardEvent| match event.key().as_str() { + " " | "Enter" => { + args.select(event.record_key.clone()); + } + _ => {} + } + }) + .into() + }); + + yew::props!(Self { loader, picker }) + } +} + +pub enum Msg { + UpdateFilter(String), + LoadResult(Result), + Select(Key), +} + +pub struct PwtSearchDropdown { + filter: String, + load_error: Option, + store: Option, +} + +impl Component for PwtSearchDropdown { + type Message = Msg; + type Properties = SearchDropdown; + + fn create(ctx: &Context) -> Self { + let me = Self { + filter: String::new(), + load_error: None, + store: None, + }; + me.reload(ctx); + me + } + + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { + match msg { + Msg::UpdateFilter(filter) => { + self.filter = filter; + self.reload(ctx); + true + } + Msg::LoadResult(result) => { + match result { + Ok(store) => { + self.store = Some(store); + self.load_error = None; + } + Err(err) => { + self.store = None; + self.load_error = Some(err.to_string()); + } + } + true + } + Msg::Select(key) => { + if let Some(on_select) = &ctx.props().on_select { + on_select.emit(key); + } + true + } + } + } + + fn view(&self, ctx: &Context) -> Html { + let props = ctx.props(); + + let store = self.store.clone(); + let load_error = self.load_error.clone(); + let link = ctx.link().clone(); + let picker = props.picker.clone(); + + Dropdown::new(move |controller: &DropdownController| -> Html { + if let Some(store) = &store { + if let Some(load_error) = &load_error { + crate::widget::error_message(&format!("Error: {}", load_error)) + .padding(2) + .into() + } else { + let args = SearchDropdownRenderArgs { + store: store.clone(), + controller: controller.clone(), + link: link.clone(), + }; + picker.apply(&args) + } + } else { + crate::widget::error_message("no data loaded") + .padding(2) + .into() + } + }) + .with_std_props(&props.std_props) + .with_input_props(&props.input_props) + .value(self.filter.clone()) + .editable(true) + .on_change(ctx.link().callback(Msg::UpdateFilter)) + .into() + } +} + +impl PwtSearchDropdown { + fn reload(&self, ctx: &Context) { + let loader = ctx.props().loader.clone(); + let filter = self.filter.clone(); + let link = ctx.link().clone(); + wasm_bindgen_futures::spawn_local(async move { + let res = loader.apply(filter).await; + link.send_message(Msg::LoadResult(res)); + }); + } +}