From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from gate001.proxmox.com (gate001.proxmox.com [IPv6:2a0f:8001:1:32::40]) by lore.proxmox.com (Postfix) with ESMTPS id DE4C31FF0DF for ; Thu, 05 Nov 2026 11:17:21 +0100 (CET) Received: from gate001.proxmox.com (localhost.localdomain [127.0.0.1]) by gate001.proxmox.com (Proxmox) with ESMTP id AEECB2141D; Thu, 05 Nov 2026 11:17:21 +0100 (CET) Mime-Version: 1.0 Content-Transfer-Encoding: quoted-printable Content-Type: text/plain; charset=UTF-8 Date: Thu, 05 Nov 2026 11:17:15 +0100 Message-Id: Subject: Re: [PATCH proxmox-yew-widget-toolkit 4/5] widget: form: implement multi select field From: "Nicolas Frey" To: "Dominik Csapak" , X-Mailer: aerc 0.20.0 References: <20261028151517.518742-1-n.frey@proxmox.com> <20261028151517.518742-5-n.frey@proxmox.com> <7ed54cd3-6dcb-4ecb-bbb3-86302ade8df5@proxmox.com> In-Reply-To: <7ed54cd3-6dcb-4ecb-bbb3-86302ade8df5@proxmox.com> X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1793873835678 X-SPAM-LEVEL: Spam detection results: 0 AWL 1.168 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: E2PN3A53RU2IVXCMVU3YIU2YUQY4MZMA X-Message-ID-Hash: E2PN3A53RU2IVXCMVU3YIU2YUQY4MZMA 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: On Mon Nov 2, 2026 at 3:23 PM CET, Dominik Csapak wrote: > 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... I mention it in the cover letter, so yes it was intended. though the more I think about it, it would be great to have this configurable. meaning if it's not expected to break layout, it can grow - otherwise have it cut off with ellipses at the end? > > 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_sel= ect.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, IntoTextF= ilterFn, RenderFn, >> + TextFilterFn, WidgetBuilder, >> + }, >> + state::{Selection, SelectionObserver, Store}, >> + tr, >> + widget::{ >> + Dropdown, DropdownController, GridPicker, Row, >> + data_table::{DataTable, DataTableColumn, DataTableHeader, Multi= SelectMode}, >> + form::{ >> + ManagedField, ManagedFieldContext, ManagedFieldMaster, Mana= gedFieldScopeExt, >> + ManagedFieldState, >> + }, >> + }, >> +}; >> +use pwt_macros::{builder, widget}; >> +use yew::{AttrValue, Html, Properties, html, html::IntoPropValue, virtu= al_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=3Dcrate, comp=3DManagedFieldMaster, @inp= ut)] >> +#[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 value= s directly. >> + #[builder_cb(IntoOptionalRenderFn, into_optional_render_fn, AttrVal= ue)] >> + #[prop_or_default] >> + pub render_value: Option>, >> + >> + /// Show filter >> + /// >> + /// Default behavior is to show the filter for pickers with more th= an 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, ManagedFieldSt= ate); >> + >> +impl MultiSelectField { >> + fn columns(props: &MultiSelect) -> Rc>> { >> + let render_value =3D 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_s= tr()))) >> + .collect() >> + } >> + >> + /// Adds the values of `value` that are neither items nor already k= nown extras. >> + fn add_extra(&mut self, props: &MultiSelect, value: &Value) -> bool= { >> + let unknown: Vec =3D value >> + .as_array() >> + .into_iter() >> + .flatten() >> + .filter_map(|item| item.as_str()) >> + .filter(|item| { >> + !props.items.iter().any(|known| known =3D=3D item) >> + && !self.extra.iter().any(|known| known =3D=3D 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) =3D> items >> + .iter() >> + .filter_map(|item| item.as_str()) >> + .map(|item| Key::from(item.to_string())) >> + .collect(), >> + None =3D> HashSet::new(), >> + } >> +} >> + >> +impl ManagedField for MultiSelectField { >> + type Message =3D (); >> + type Properties =3D MultiSelect; >> + type ValidateClosure =3D bool; >> + >> + fn validation_args(props: &Self::Properties) -> Self::ValidateClosu= re { >> + props.input_props.required >> + } >> + >> + fn validator(required: &Self::ValidateClosure, value: &Value) -> Re= sult { >> + let items =3D match value { >> + Value::Null =3D> Vec::new(), >> + Value::Array(items) if items.iter().all(Value::is_string) = =3D> items.clone(), >> + _ =3D> 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 =3D ctx.props(); >> + >> + let store =3D Store::with_extract_key(|item: &AttrValue| Key::f= rom(item.as_str())); >> + store.set_data(props.items.as_ref().clone()); >> + >> + let selection =3D Selection::new().multiselect(true); >> + let _selection_observer =3D selection.add_listener(ctx.link().c= allback(|_| ())); >> + >> + let default =3D Value::Array( >> + props >> + .default >> + .iter() >> + .map(|item| Value::String(item.to_string())) >> + .collect(), >> + ); >> + selection.bulk_select(selected_keys(&default)); >> + >> + let mut this =3D Self { >> + state: ManagedFieldState::new(default.clone(), default.clon= e()), >> + 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 =3D ctx.props(); >> + >> + if props.items !=3D old_props.items { >> + // keep selected values that are no longer items, so they s= tay visible and are not >> + // dropped from the value on the next selection change >> + let selected =3D 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 asynchro= nously do >> + // not keep the label they had when the field was created >> + self.columns =3D Self::columns(props); >> + >> + true >> + } >> + >> + fn value_changed(&mut self, ctx: &ManagedFieldContext) { >> + let value =3D self.value.clone(); >> + self.add_extra(ctx.props(), &value); >> + self.selection.bulk_select(selected_keys(&value)); >> + } >> + >> + fn update(&mut self, ctx: &ManagedFieldContext, _msg: Self::M= essage) -> bool { >> + let selected =3D self.selected(ctx.props()); >> + >> + // the selection also changes when a value is loaded, >> + // so update only what the user changed >> + let selected_set: HashSet =3D selected >> + .iter() >> + .map(|item| Key::from(item.to_string())) >> + .collect(); >> + if selected_set =3D=3D selected_keys(&self.value) { >> + return true; >> + } >> + >> + let value =3D 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 =3D ctx.props(); >> + >> + let store =3D self.store.clone(); >> + let selection =3D self.selection.clone(); >> + let columns =3D Rc::clone(&self.columns); >> + let show_filter =3D props.show_filter; >> + let filter =3D props.filter.clone(); >> + let picker =3D move |_controller: &DropdownController| { >> + let table =3D DataTable::new(Rc::clone(&columns), store.clo= ne()) >> + .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 =3D self.selected(props); >> + >> + let mut text =3D 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) =3D> render_value.apply(item), >> + None =3D> html! { item }, >> + }); >> + } >> + let text: Html =3D 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() >> + } >> +}