public inbox for yew-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Dominik Csapak <d.csapak@proxmox.com>
To: Nicolas Frey <n.frey@proxmox.com>, yew-devel@lists.proxmox.com
Subject: Re: [PATCH proxmox-yew-widget-toolkit 4/5] widget: form: implement multi select field
Date: Mon, 2 Nov 2026 15:12:18 +0100	[thread overview]
Message-ID: <1d940f6b-355d-46ed-80ba-e8e14f58503b@proxmox.com> (raw)
In-Reply-To: <20261028151517.518742-5-n.frey@proxmox.com>

some high level comments:

I'd really like for this to follow the existing conventions for our
other fields. It's Basically a Combobox but with multiselect, so I'd
like to see either:

* make it an option in combobox  `.multiselect(true)`
* or keep it separate but implement the same interface (with_item,
triggers, etc.)

(wheatever is easier)

If it's a separate field, something like 'multicombobox' would
maybe be more fitting? (to convey the combobox base)

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 <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()
> +    }
> +}





  reply	other threads:[~2026-11-02 14:12 UTC|newest]

Thread overview: 13+ 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-11-02 14:12   ` Dominik Csapak
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-11-02 14:12   ` Dominik Csapak
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-11-02 14:12   ` Dominik Csapak
2026-10-28 15:15 ` [PATCH proxmox-yew-widget-toolkit 4/5] widget: form: implement multi select field Nicolas Frey
2026-11-02 14:12   ` Dominik Csapak [this message]
2026-11-02 14:23   ` Dominik Csapak
2026-10-28 15:15 ` [PATCH proxmox-yew-widget-toolkit 5/5] widget: form: combobox: update doc to point to `MultiSelect` Nicolas Frey
2026-11-02 14:12   ` Dominik Csapak
2026-11-02 14:12 ` [PATCH proxmox-yew-widget-toolkit 0/5] add multi-select component Dominik Csapak

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=1d940f6b-355d-46ed-80ba-e8e14f58503b@proxmox.com \
    --to=d.csapak@proxmox.com \
    --cc=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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal