From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [45.144.208.40]) by lore.proxmox.com (Postfix) with ESMTPS id 432411FF0B3 for ; Wed, 28 Oct 2026 16:15:27 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 0829D2125A; Wed, 28 Oct 2026 16:15:27 +0100 (CET) From: Nicolas Frey To: yew-devel@lists.proxmox.com Subject: [PATCH proxmox-yew-widget-toolkit 4/5] widget: form: implement multi select field Date: Wed, 28 Oct 2026 16:15:14 +0100 Message-ID: <20261028151517.518742-5-n.frey@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20261028151517.518742-1-n.frey@proxmox.com> References: <20261028151517.518742-1-n.frey@proxmox.com> MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793200517513 X-SPAM-LEVEL: Spam detection results: 0 AWL 1.233 Adjusted score from AWL reputation of From: address DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment (newer systems) RCVD_IN_DNSWL_MED -2.3 Sender listed at https://www.dnswl.org/, medium trust SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Message-ID-Hash: OQTYGYKZI3RDTLYA33XZWPXWRBBYE6BV X-Message-ID-Hash: OQTYGYKZI3RDTLYA33XZWPXWRBBYE6BV X-MailFrom: n.frey@proxmox.com X-Mailman-Rule-Misses: dmarc-mitigation; no-senders; approved; loop; banned-address; emergency; member-moderation; nonmember-moderation; administrivia; implicit-dest; max-recipients; max-size; news-moderation; no-subject; digests; suspicious-header X-Mailman-Version: 3.3.10 Precedence: list List-Id: Yew framework devel list at Proxmox List-Help: List-Owner: List-Post: List-Subscribe: List-Unsubscribe: as stated in the `Combobox` documentation, its implementation does not support a multi-select mode. add a separate widget that shows a `GridPicker` inside a dropdown, to keep the implementations separate. values that are not among the items, e.g. loaded from the form or left over after the items changed, are kept as extra entries. that way they stay visible and are not dropped on the next selection change. Signed-off-by: Nicolas Frey --- src/widget/form/mod.rs | 5 + src/widget/form/multi_select.rs | 308 ++++++++++++++++++++++++++++++++ 2 files changed, 313 insertions(+) create mode 100644 src/widget/form/multi_select.rs diff --git a/src/widget/form/mod.rs b/src/widget/form/mod.rs index 2bf030d..14dbcec 100644 --- a/src/widget/form/mod.rs +++ b/src/widget/form/mod.rs @@ -85,3 +85,8 @@ pub use date_field::DateField; #[doc(hidden)] pub use date_field::DateFieldComp; pub use date_field::PlainDate; + +mod multi_select; +pub use multi_select::MultiSelect; +#[doc(hidden)] +pub use multi_select::MultiSelectField; diff --git a/src/widget/form/multi_select.rs b/src/widget/form/multi_select.rs new file mode 100644 index 0000000..b8f34a5 --- /dev/null +++ b/src/widget/form/multi_select.rs @@ -0,0 +1,308 @@ +use std::collections::HashSet; +use std::rc::Rc; + +use anyhow::{Error, bail}; +use serde_json::Value; + +use crate::{ + props::{ + ContainerBuilder, FieldBuilder, IntoOptionalRenderFn, IntoTextFilterFn, RenderFn, + TextFilterFn, WidgetBuilder, + }, + state::{Selection, SelectionObserver, Store}, + tr, + widget::{ + Dropdown, DropdownController, GridPicker, Row, + data_table::{DataTable, DataTableColumn, DataTableHeader, MultiSelectMode}, + form::{ + ManagedField, ManagedFieldContext, ManagedFieldMaster, ManagedFieldScopeExt, + ManagedFieldState, + }, + }, +}; +use pwt_macros::{builder, widget}; +use yew::{AttrValue, Html, Properties, html, html::IntoPropValue, virtual_dom::Key}; + +/// MultiSelect widget +/// +/// Allows to select multiple text options. +/// +/// Select any number of the given items. The value is a json array of the +/// selected items, in the order the items were passed. +/// +/// Use [Combobox](super::Combobox) if only one option has to be +/// selectable (single-select). +#[widget(pwt=crate, comp=ManagedFieldMaster, @input)] +#[derive(Clone, Properties, PartialEq)] +#[builder] +pub struct MultiSelect { + /// The selectable items. + #[prop_or_default] + pub items: Rc>, + + /// The items to select unless the form is populated. + #[prop_or_default] + #[builder(IntoPropValue, into_prop_value)] + pub default: Vec, + + /// Display the output of this function instead of displaying values directly. + #[builder_cb(IntoOptionalRenderFn, into_optional_render_fn, AttrValue)] + #[prop_or_default] + pub render_value: Option>, + + /// Show filter + /// + /// Default behavior is to show the filter for pickers with more than 10 items. + #[builder(IntoPropValue, into_prop_value)] + #[prop_or_default] + pub show_filter: Option, + + /// Custom filter function. + #[builder_cb(IntoTextFilterFn, into_text_filter_fn, AttrValue)] + #[prop_or_default] + pub filter: Option>, +} + +impl MultiSelect { + /// Create a new instance. + pub fn new(items: Rc>) -> Self { + yew::props!(Self { items }) + } +} + +pub struct MultiSelectField { + state: ManagedFieldState, + store: Store, + selection: Selection, + columns: Rc>>, + /// Values the field was loaded with that are not among the items. + extra: Vec, + _selection_observer: SelectionObserver, +} + +crate::impl_deref_mut_property!(MultiSelectField, state, ManagedFieldState); + +impl MultiSelectField { + fn columns(props: &MultiSelect) -> Rc>> { + let render_value = props + .render_value + .clone() + .unwrap_or_else(|| RenderFn::new(|item: &AttrValue| html! { item })); + + Rc::new(vec![ + DataTableColumn::::selection_indicator().into(), + DataTableColumn::new("Value") + .show_menu(false) + .render(render_value) + .into(), + ]) + } + + fn chain_extra(&self, props: &MultiSelect) -> Vec { + props + .items + .iter() + .chain(self.extra.iter()) + .cloned() + .collect() + } + + fn selected(&self, props: &MultiSelect) -> Vec { + self.chain_extra(props) + .into_iter() + .filter(|item| self.selection.contains(&Key::from(item.as_str()))) + .collect() + } + + /// Adds the values of `value` that are neither items nor already known extras. + fn add_extra(&mut self, props: &MultiSelect, value: &Value) -> bool { + let unknown: Vec = value + .as_array() + .into_iter() + .flatten() + .filter_map(|item| item.as_str()) + .filter(|item| { + !props.items.iter().any(|known| known == item) + && !self.extra.iter().any(|known| known == item) + }) + .map(|item| AttrValue::from(item.to_string())) + .collect(); + + if unknown.is_empty() { + return false; + } + self.extra.extend(unknown); + self.store.set_data(self.chain_extra(props)); + true + } +} + +fn selected_keys(value: &Value) -> HashSet { + match value.as_array() { + Some(items) => items + .iter() + .filter_map(|item| item.as_str()) + .map(|item| Key::from(item.to_string())) + .collect(), + None => HashSet::new(), + } +} + +impl ManagedField for MultiSelectField { + type Message = (); + type Properties = MultiSelect; + type ValidateClosure = bool; + + fn validation_args(props: &Self::Properties) -> Self::ValidateClosure { + props.input_props.required + } + + fn validator(required: &Self::ValidateClosure, value: &Value) -> Result { + let items = match value { + Value::Null => Vec::new(), + Value::Array(items) if items.iter().all(Value::is_string) => items.clone(), + _ => bail!(tr!("got wrong data type.")), + }; + + if *required && items.is_empty() { + bail!(tr!("Field may not be empty.")); + } + + Ok(Value::Array(items)) + } + + fn create(ctx: &ManagedFieldContext) -> Self { + let props = ctx.props(); + + let store = Store::with_extract_key(|item: &AttrValue| Key::from(item.as_str())); + store.set_data(props.items.as_ref().clone()); + + let selection = Selection::new().multiselect(true); + let _selection_observer = selection.add_listener(ctx.link().callback(|_| ())); + + let default = Value::Array( + props + .default + .iter() + .map(|item| Value::String(item.to_string())) + .collect(), + ); + selection.bulk_select(selected_keys(&default)); + + let mut this = Self { + state: ManagedFieldState::new(default.clone(), default.clone()), + store, + selection, + columns: Self::columns(props), + extra: Vec::new(), + _selection_observer, + }; + this.add_extra(props, &default); + this + } + + fn changed(&mut self, ctx: &ManagedFieldContext, old_props: &Self::Properties) -> bool { + let props = ctx.props(); + + if props.items != old_props.items { + // keep selected values that are no longer items, so they stay visible and are not + // dropped from the value on the next selection change + let selected = selected_keys(&self.value); + for item in old_props.items.iter() { + if !props.items.contains(item) + && !self.extra.contains(item) + && selected.contains(&Key::from(item.as_str())) + { + self.extra.push(item.clone()); + } + } + self.extra.retain(|item| !props.items.contains(item)); + self.store.set_data(self.chain_extra(props)); + } + // rebuild unconditionally, so items whose label loads asynchronously do + // not keep the label they had when the field was created + self.columns = Self::columns(props); + + true + } + + fn value_changed(&mut self, ctx: &ManagedFieldContext) { + let value = self.value.clone(); + self.add_extra(ctx.props(), &value); + self.selection.bulk_select(selected_keys(&value)); + } + + fn update(&mut self, ctx: &ManagedFieldContext, _msg: Self::Message) -> bool { + let selected = self.selected(ctx.props()); + + // the selection also changes when a value is loaded, + // so update only what the user changed + let selected_set: HashSet = selected + .iter() + .map(|item| Key::from(item.to_string())) + .collect(); + if selected_set == selected_keys(&self.value) { + return true; + } + + let value = selected + .into_iter() + .map(|item| Value::String(item.to_string())) + .collect(); + + ctx.link().update_value(Value::Array(value)); + true + } + + fn view(&self, ctx: &ManagedFieldContext) -> Html { + let props = ctx.props(); + + let store = self.store.clone(); + let selection = self.selection.clone(); + let columns = Rc::clone(&self.columns); + let show_filter = props.show_filter; + let filter = props.filter.clone(); + let picker = move |_controller: &DropdownController| { + let table = DataTable::new(Rc::clone(&columns), store.clone()) + .striped(false) + .borderless(true) + .bordered(false) + .show_header(false) + .multiselect_mode(MultiSelectMode::Simple); + + GridPicker::new(table) + .selection(selection.clone()) + .show_filter(show_filter) + .filter(filter.clone()) + .into() + }; + + let selected = self.selected(props); + + let mut text = Row::new(); + for (pos, item) in selected.iter().enumerate() { + if pos > 0 { + text.add_child(html! { {", "} }); + } + text.add_child(match &props.render_value { + Some(render_value) => render_value.apply(item), + None => html! { item }, + }); + } + let text: Html = text.into(); + + Dropdown::new(picker) + .with_std_props(&props.std_props) + .with_input_props(&props.input_props) + .value( + selected + .iter() + .map(|item| item.to_string()) + .collect::>() + .join(","), + ) + .render_value(move |_: &AttrValue| text.clone()) + .valid(self.result.is_ok()) + .into() + } +} -- 2.47.3