all lists on lists.proxmox.com
 help / color / mirror / Atom feed
From: Nicolas Frey <n.frey@proxmox.com>
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	[thread overview]
Message-ID: <20261028151517.518742-5-n.frey@proxmox.com> (raw)
In-Reply-To: <20261028151517.518742-1-n.frey@proxmox.com>

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 <n.frey@proxmox.com>
---
 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<MultiSelectField>, @input)]
+#[derive(Clone, Properties, PartialEq)]
+#[builder]
+pub struct MultiSelect {
+    /// The selectable items.
+    #[prop_or_default]
+    pub items: Rc<Vec<AttrValue>>,
+
+    /// The items to select unless the form is populated.
+    #[prop_or_default]
+    #[builder(IntoPropValue, into_prop_value)]
+    pub default: Vec<AttrValue>,
+
+    /// 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<RenderFn<AttrValue>>,
+
+    /// 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<bool>,
+
+    /// Custom filter function.
+    #[builder_cb(IntoTextFilterFn, into_text_filter_fn, AttrValue)]
+    #[prop_or_default]
+    pub filter: Option<TextFilterFn<AttrValue>>,
+}
+
+impl MultiSelect {
+    /// Create a new instance.
+    pub fn new(items: Rc<Vec<AttrValue>>) -> Self {
+        yew::props!(Self { items })
+    }
+}
+
+pub struct MultiSelectField {
+    state: ManagedFieldState,
+    store: Store<AttrValue>,
+    selection: Selection,
+    columns: Rc<Vec<DataTableHeader<AttrValue>>>,
+    /// Values the field was loaded with that are not among the items.
+    extra: Vec<AttrValue>,
+    _selection_observer: SelectionObserver,
+}
+
+crate::impl_deref_mut_property!(MultiSelectField, state, ManagedFieldState);
+
+impl MultiSelectField {
+    fn columns(props: &MultiSelect) -> Rc<Vec<DataTableHeader<AttrValue>>> {
+        let render_value = props
+            .render_value
+            .clone()
+            .unwrap_or_else(|| RenderFn::new(|item: &AttrValue| html! { item }));
+
+        Rc::new(vec![
+            DataTableColumn::<AttrValue>::selection_indicator().into(),
+            DataTableColumn::new("Value")
+                .show_menu(false)
+                .render(render_value)
+                .into(),
+        ])
+    }
+
+    fn chain_extra(&self, props: &MultiSelect) -> Vec<AttrValue> {
+        props
+            .items
+            .iter()
+            .chain(self.extra.iter())
+            .cloned()
+            .collect()
+    }
+
+    fn selected(&self, props: &MultiSelect) -> Vec<AttrValue> {
+        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<AttrValue> = 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<Key> {
+    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<Value, Error> {
+        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>) -> 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<Self>, 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<Self>) {
+        let value = self.value.clone();
+        self.add_extra(ctx.props(), &value);
+        self.selection.bulk_select(selected_keys(&value));
+    }
+
+    fn update(&mut self, ctx: &ManagedFieldContext<Self>, _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<Key> = 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<Self>) -> 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::<Vec<_>>()
+                    .join(","),
+            )
+            .render_value(move |_: &AttrValue| text.clone())
+            .valid(self.result.is_ok())
+            .into()
+    }
+}
-- 
2.47.3





  parent reply	other threads:[~2026-10-28 15:15 UTC|newest]

Thread overview: 6+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2026-10-28 15:15 [PATCH proxmox-yew-widget-toolkit 0/5] add multi-select component Nicolas Frey
2026-10-28 15:15 ` [PATCH proxmox-yew-widget-toolkit 1/5] widget: data table: simplify lookup of keys near the cursor Nicolas Frey
2026-10-28 15:15 ` [PATCH proxmox-yew-widget-toolkit 2/5] widget: data table: only extend explicit select all to new rows Nicolas Frey
2026-10-28 15:15 ` [PATCH proxmox-yew-widget-toolkit 3/5] widget: grid picker: keep selected records hidden by the filter Nicolas Frey
2026-10-28 15:15 ` Nicolas Frey [this message]
2026-10-28 15:15 ` [PATCH proxmox-yew-widget-toolkit 5/5] widget: form: combobox: update doc to point to `MultiSelect` Nicolas Frey

Reply instructions:

You may reply publicly to this message via plain-text email
using any one of the following methods:

* Save the following mbox file, import it into your mail client,
  and reply-to-all from there: mbox

  Avoid top-posting and favor interleaved quoting:
  https://en.wikipedia.org/wiki/Posting_style#Interleaved_style

* Reply using the --to, --cc, and --in-reply-to
  switches of git-send-email(1):

  git send-email \
    --in-reply-to=20261028151517.518742-5-n.frey@proxmox.com \
    --to=n.frey@proxmox.com \
    --cc=yew-devel@lists.proxmox.com \
    /path/to/YOUR_REPLY

  https://kernel.org/pub/software/scm/git/docs/git-send-email.html

* If your mail client supports setting the In-Reply-To header
  via mailto: links, try the mailto: link
Be sure your reply has a Subject: header at the top and a blank line before the message body.
This is an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal