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 50AFA1FF09C for ; Mon, 02 Nov 2026 15:23:20 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id 1A5B22168B; Mon, 02 Nov 2026 15:23:20 +0100 (CET) Message-ID: <7ed54cd3-6dcb-4ecb-bbb3-86302ade8df5@proxmox.com> Date: Mon, 2 Nov 2026 15:23:14 +0100 MIME-Version: 1.0 User-Agent: Mozilla Thunderbird Beta Subject: Re: [PATCH proxmox-yew-widget-toolkit 4/5] widget: form: implement multi select field To: Nicolas Frey , yew-devel@lists.proxmox.com References: <20261028151517.518742-1-n.frey@proxmox.com> <20261028151517.518742-5-n.frey@proxmox.com> Content-Language: en-US From: Dominik Csapak In-Reply-To: <20261028151517.518742-5-n.frey@proxmox.com> Content-Type: text/plain; charset=UTF-8; format=flowed Content-Transfer-Encoding: 7bit X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793629394481 X-SPAM-LEVEL: Spam detection results: 0 AWL 0.333 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: SYTJUNHU7UFS5POY5YUQMQ5M5L4EKUVK X-Message-ID-Hash: SYTJUNHU7UFS5POY5YUQMQ5M5L4EKUVK X-MailFrom: d.csapak@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: another thing i forgot to write in my previous message: Is it intended for the field to grow when many items are selected? I can imagine that his breaks some ui layout depending on where it's located... On 10/28/26 4:15 PM, Nicolas Frey wrote: > 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() > + } > +}