public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: "Shannon Sterz" <s.sterz@proxmox.com>
To: "Dominik Csapak" <d.csapak@proxmox.com>
Cc: Proxmox Datacenter Manager development discussion
	<pdm-devel@lists.proxmox.com>
Subject: Re: [pdm-devel] [PATCH datacenter-manager v3 14/18] ui: configuration: add view CRUD panels
Date: Mon, 17 Nov 2025 16:00:20 +0100	[thread overview]
Message-ID: <DEB25R2CLWFP.2L1CW8F2QSZRL@proxmox.com> (raw)
In-Reply-To: <20251117125041.1931382-15-d.csapak@proxmox.com>

comments in-line.

On Mon Nov 17, 2025 at 1:44 PM CET, Dominik Csapak wrote:
> it's a simple grid + edit window that enables adding/editing/removing
> views.
>
> We introduce two new widgets here too:
> * ViewSelector: can select a view, used for selecting which view to copy
>   the layout from.
> * ViewFilterSelector: A grid for selecting the include/exclude filters,
>   similar to e.g. the 'GroupFilters' in PBS are done.
>
> Signed-off-by: Dominik Csapak <d.csapak@proxmox.com>
> ---
> changes from v2:
> * added validator for the view filter selector fields, so users see
>   which is wrong
> * adapted to PveSdnZone -> PveNetwork enum change
>
>  ui/src/configuration/mod.rs           |   2 +
>  ui/src/configuration/view_edit.rs     |   4 +
>  ui/src/configuration/views.rs         | 320 +++++++++++++++++++++
>  ui/src/widget/mod.rs                  |   6 +
>  ui/src/widget/view_filter_selector.rs | 393 ++++++++++++++++++++++++++
>  ui/src/widget/view_selector.rs        |  55 ++++
>  6 files changed, 780 insertions(+)
>  create mode 100644 ui/src/configuration/view_edit.rs
>  create mode 100644 ui/src/configuration/views.rs
>  create mode 100644 ui/src/widget/view_filter_selector.rs
>  create mode 100644 ui/src/widget/view_selector.rs
>
> diff --git a/ui/src/configuration/mod.rs b/ui/src/configuration/mod.rs
> index 76d02cb9..35114336 100644
> --- a/ui/src/configuration/mod.rs
> +++ b/ui/src/configuration/mod.rs
> @@ -13,6 +13,8 @@ mod permission_path_selector;
>  mod webauthn;
>  pub use webauthn::WebauthnPanel;
>
> +pub mod views;
> +
>  #[function_component(SystemConfiguration)]
>  pub fn system_configuration() -> Html {
>      let panel = TabPanel::new()
> diff --git a/ui/src/configuration/view_edit.rs b/ui/src/configuration/view_edit.rs
> new file mode 100644
> index 00000000..9ca36f0f
> --- /dev/null
> +++ b/ui/src/configuration/view_edit.rs
> @@ -0,0 +1,4 @@
> +use pwt::prelude::*;
> +
> +#[derive(Properties, PartialEq, Clone)]
> +pub struct ViewEdit {}
> diff --git a/ui/src/configuration/views.rs b/ui/src/configuration/views.rs
> new file mode 100644
> index 00000000..35eb9fc1
> --- /dev/null
> +++ b/ui/src/configuration/views.rs
> @@ -0,0 +1,320 @@
> +use std::rc::Rc;
> +
> +use anyhow::{bail, Error};
> +use proxmox_yew_comp::form::delete_empty_values;
> +use serde_json::json;
> +use yew::virtual_dom::{Key, VComp, VNode};
> +
> +use proxmox_yew_comp::{
> +    http_delete, http_get, http_post, http_put, EditWindow, LoadableComponent,
> +    LoadableComponentContext, LoadableComponentMaster,
> +};
> +use pwt::prelude::*;
> +use pwt::state::{Selection, Store};
> +use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
> +use pwt::widget::form::{DisplayField, Field, FormContext};
> +use pwt::widget::{Button, ConfirmDialog, InputPanel, Toolbar};
> +
> +use pdm_api_types::views::ViewConfig;
> +
> +use crate::widget::{ViewFilterSelector, ViewSelector};
> +use crate::ViewListContext;
> +
> +async fn create_view(
> +    base_url: AttrValue,
> +    store: Store<ViewConfig>,
> +    form_ctx: FormContext,
> +) -> Result<(), Error> {
> +    let mut data = form_ctx.get_submit_data();
> +    let layout = form_ctx.read().get_field_text("copy-from");
> +    let layout = if layout == "__dashboard__" {
> +        None
> +    } else {
> +        let store = store.read();
> +        if let Some(config) = store.lookup_record(&Key::from(layout)) {
> +            Some(config.layout.clone())
> +        } else {
> +            bail!("Source View not found")
> +        }
> +    };
> +
> +    let mut params = json!({
> +        "id": data["id"].take(),
> +    });
> +    if let Some(layout) = layout {
> +        params["layout"] = layout.into();
> +    }
> +    if data["include"].is_array() {
> +        params["include"] = data["include"].take();
> +    }
> +    if data["exclude"].is_array() {
> +        params["exclude"] = data["exclude"].take();
> +    }
> +    http_post(base_url.as_str(), Some(params)).await
> +}
> +
> +async fn update_view(base_url: AttrValue, form_ctx: FormContext) -> Result<(), Error> {
> +    let data = form_ctx.get_submit_data();
> +    let id = form_ctx.read().get_field_text("id");
> +    let params = delete_empty_values(&data, &["include", "exclude"], true);
> +    http_put(&format!("{}/{}", base_url, id), Some(params)).await

base_url/id could be inlined, id might benefit from encoding.

> +}
> +
> +#[derive(PartialEq, Clone, Properties)]
> +pub struct ViewGrid {
> +    #[prop_or("/config/views".into())]
> +    base_url: AttrValue,
> +}
> +
> +impl ViewGrid {
> +    pub fn new() -> Self {
> +        yew::props!(Self {})
> +    }
> +}
> +
> +impl Default for ViewGrid {
> +    fn default() -> Self {
> +        Self::new()
> +    }
> +}
> +
> +impl From<ViewGrid> for VNode {
> +    fn from(val: ViewGrid) -> Self {
> +        VComp::new::<LoadableComponentMaster<ViewGridComp>>(Rc::new(val), None).into()
> +    }
> +}
> +
> +pub enum Msg {
> +    SelectionChanged,
> +    LoadFinished(Vec<ViewConfig>),
> +    Remove(Key),
> +    Reload,
> +}
> +
> +#[derive(PartialEq)]
> +pub enum ViewState {
> +    Create,
> +    Edit,
> +    Remove,
> +}
> +
> +#[doc(hidden)]
> +pub struct ViewGridComp {
> +    store: Store<ViewConfig>,
> +    columns: Rc<Vec<DataTableHeader<ViewConfig>>>,
> +    selection: Selection,
> +}
> +
> +impl ViewGridComp {
> +    fn columns() -> Rc<Vec<DataTableHeader<ViewConfig>>> {
> +        let columns = vec![
> +            DataTableColumn::new("ID")
> +                .flex(5)
> +                .get_property(|value: &ViewConfig| value.id.as_str())
> +                .sort_order(true)
> +                .into(),
> +            DataTableColumn::new(tr!("# Included"))
> +                .flex(1)
> +                .get_property_owned(|value: &ViewConfig| value.include.len())
> +                .into(),
> +            DataTableColumn::new(tr!("# Excluded"))
> +                .flex(1)
> +                .get_property_owned(|value: &ViewConfig| value.exclude.len())
> +                .into(),
> +            DataTableColumn::new(tr!("Custom Layout"))
> +                .flex(1)
> +                .render(|value: &ViewConfig| {
> +                    if value.layout.is_empty() {
> +                        tr!("No").into()
> +                    } else {
> +                        tr!("Yes").into()
> +                    }
> +                })
> +                .into(),
> +        ];
> +
> +        Rc::new(columns)
> +    }
> +
> +    fn create_add_dialog(&self, ctx: &LoadableComponentContext<Self>) -> Html {
> +        let props = ctx.props();
> +        let store = self.store.clone();
> +        EditWindow::new(tr!("Add") + ": " + &tr!("View"))
> +            .renderer(move |_| add_view_input_panel(store.clone()))
> +            .on_submit({
> +                let base_url = props.base_url.clone();
> +                let store = self.store.clone();
> +                move |form| create_view(base_url.clone(), store.clone(), form)
> +            })
> +            .on_done(ctx.link().clone().callback(|_| Msg::Reload))
> +            .into()
> +    }
> +
> +    fn create_edit_dialog(&self, selection: Key, ctx: &LoadableComponentContext<Self>) -> Html {
> +        let props = ctx.props();
> +        let id = selection.to_string();
> +        EditWindow::new(tr!("Edit") + ": " + &tr!("View"))
> +            .renderer(move |_| edit_view_input_panel(id.clone()))
> +            .on_submit({
> +                let base_url = props.base_url.clone();
> +                move |form| update_view(base_url.clone(), form)
> +            })
> +            .loader(format!("{}/{}", props.base_url, selection))

same here for selection.

> +            .on_done(ctx.link().callback(|_| Msg::Reload))
> +            .into()
> +    }
> +}
> +
> +impl LoadableComponent for ViewGridComp {
> +    type Properties = ViewGrid;
> +    type Message = Msg;
> +    type ViewState = ViewState;
> +
> +    fn create(ctx: &proxmox_yew_comp::LoadableComponentContext<Self>) -> Self {
> +        let selection = Selection::new().on_select(ctx.link().callback(|_| Msg::SelectionChanged));
> +        Self {
> +            store: Store::with_extract_key(|config: &ViewConfig| config.id.as_str().into()),
> +            columns: Self::columns(),
> +            selection,
> +        }
> +    }
> +
> +    fn update(
> +        &mut self,
> +        ctx: &proxmox_yew_comp::LoadableComponentContext<Self>,
> +        msg: Self::Message,
> +    ) -> bool {
> +        match msg {
> +            Msg::LoadFinished(data) => self.store.set_data(data),
> +            Msg::Remove(key) => {
> +                if let Some(rec) = self.store.read().lookup_record(&key) {
> +                    let id = rec.id.clone();
> +                    let link = ctx.link().clone();
> +                    let base_url = ctx.props().base_url.clone();
> +                    ctx.link().spawn(async move {
> +                        match http_delete(format!("{base_url}/{id}"), None).await {
> +                            Ok(()) => {}
> +                            Err(err) => {
> +                                link.show_error(
> +                                    tr!("Error"),
> +                                    tr!("Could not delete '{0}': '{1}'", id, err),
> +                                    true,
> +                                );
> +                            }
> +                        }
> +                        link.send_message(Msg::Reload);
> +                    });
> +                }
> +            }
> +            Msg::SelectionChanged => {}
> +            Msg::Reload => {
> +                ctx.link().change_view(None);
> +                ctx.link().send_reload();
> +                if let Some((context, _)) = ctx
> +                    .link()
> +                    .yew_link()
> +                    .context::<ViewListContext>(Callback::from(|_| {}))
> +                {
> +                    context.update_views();
> +                }
> +            }
> +        }
> +        true
> +    }
> +
> +    fn toolbar(&self, ctx: &proxmox_yew_comp::LoadableComponentContext<Self>) -> Option<Html> {
> +        let selection = self.selection.selected_key();
> +        let link = ctx.link();
> +        Some(
> +            Toolbar::new()
> +                .border_bottom(true)
> +                .with_child(
> +                    Button::new(tr!("Add"))
> +                        .on_activate(link.change_view_callback(|_| Some(ViewState::Create))),
> +                )
> +                .with_child(
> +                    Button::new(tr!("Edit"))
> +                        .disabled(selection.is_none())
> +                        .on_activate(link.change_view_callback(move |_| Some(ViewState::Edit))),
> +                )
> +                .with_child(
> +                    Button::new(tr!("Remove"))
> +                        .disabled(selection.is_none())
> +                        .on_activate(link.change_view_callback(move |_| Some(ViewState::Remove))),
> +                )
> +                .into(),
> +        )
> +    }
> +
> +    fn load(
> +        &self,
> +        ctx: &proxmox_yew_comp::LoadableComponentContext<Self>,
> +    ) -> std::pin::Pin<Box<dyn std::prelude::rust_2024::Future<Output = Result<(), anyhow::Error>>>>

nit: this is a bit unnecessarily verbose imo. just do `use
std::future::Future` above and remove the `std::prelude::rust_2024::`
here. similarly for `Pin`, though that's not as bad

> +    {
> +        let base_url = ctx.props().base_url.clone();
> +        let link = ctx.link().clone();
> +        Box::pin(async move {
> +            let data: Vec<ViewConfig> = http_get(base_url.as_str(), None).await?;
> +            link.send_message(Msg::LoadFinished(data));
> +            Ok(())
> +        })
> +    }
> +
> +    fn main_view(&self, ctx: &proxmox_yew_comp::LoadableComponentContext<Self>) -> Html {
> +        let link = ctx.link();
> +        DataTable::new(self.columns.clone(), self.store.clone())
> +            .on_row_dblclick(move |_: &mut _| link.change_view(Some(ViewState::Edit)))
> +            .selection(self.selection.clone())
> +            .into()
> +    }
> +
> +    fn dialog_view(
> +        &self,
> +        ctx: &proxmox_yew_comp::LoadableComponentContext<Self>,
> +        view_state: &Self::ViewState,
> +    ) -> Option<Html> {
> +        match view_state {
> +            ViewState::Create => Some(self.create_add_dialog(ctx)),
> +            ViewState::Edit => self
> +                .selection
> +                .selected_key()
> +                .map(|key| self.create_edit_dialog(key, ctx)),
> +            ViewState::Remove => self.selection.selected_key().map(|key| {
> +                ConfirmDialog::new(
> +                    tr!("Confirm"),
> +                    tr!("Are you sure you want to remove '{0}'", key.to_string()),
> +                )
> +                .on_confirm({
> +                    let link = ctx.link().clone();
> +                    let key = key.clone();
> +                    move |_| {
> +                        link.send_message(Msg::Remove(key.clone()));
> +                    }
> +                })
> +                .into()
> +            }),
> +        }
> +    }
> +}
> +
> +fn add_view_input_panel(store: Store<ViewConfig>) -> Html {
> +    InputPanel::new()
> +        .padding(4)
> +        .with_field(tr!("Name"), Field::new().name("id").required(true))
> +        .with_right_field(
> +            tr!("Copy Layout from"),
> +            ViewSelector::new(store).name("copy-from").required(true),

hm not sure about this being required. if i want to start with a blank
slate, i need to now create a view, copy layout from dashboard and then
remove every single element. that's a bit tedious.

imo adding a default selection + a clear trigger might be nice. and if
the layout is not to be copied, just have an empty layout for now. what
do you think?

> +        )
> +        .with_large_field(tr!("Include"), ViewFilterSelector::new().name("include"))
> +        .with_large_field(tr!("Exclude"), ViewFilterSelector::new().name("exclude"))
> +        .into()
> +}
> +
> +fn edit_view_input_panel(id: String) -> Html {
> +    InputPanel::new()
> +        .padding(4)
> +        .with_field(tr!("Name"), DisplayField::new().name("id").value(id))
> +        .with_large_field(tr!("Include"), ViewFilterSelector::new().name("include"))
> +        .with_large_field(tr!("Exclude"), ViewFilterSelector::new().name("exclude"))
> +        .into()
> +}
> diff --git a/ui/src/widget/mod.rs b/ui/src/widget/mod.rs
> index 9d7840c1..97e7e472 100644
> --- a/ui/src/widget/mod.rs
> +++ b/ui/src/widget/mod.rs
> @@ -26,3 +26,9 @@ mod remote_selector;
>  pub use remote_selector::RemoteSelector;
>
>  mod remote_endpoint_selector;
> +
> +mod view_selector;
> +pub use view_selector::ViewSelector;
> +
> +mod view_filter_selector;
> +pub use view_filter_selector::ViewFilterSelector;
> diff --git a/ui/src/widget/view_filter_selector.rs b/ui/src/widget/view_filter_selector.rs
> new file mode 100644
> index 00000000..35baa07a
> --- /dev/null
> +++ b/ui/src/widget/view_filter_selector.rs
> @@ -0,0 +1,393 @@
> +use std::rc::Rc;
> +use std::str::FromStr;
> +
> +use anyhow::{bail, Error};
> +use pdm_api_types::resource::ResourceType;
> +use pwt::css;
> +use pwt::widget::{ActionIcon, Button, Column, Row};
> +use serde_json::Value;
> +use yew::virtual_dom::Key;
> +
> +use pwt::prelude::*;
> +use pwt::state::Store;
> +use pwt::widget::data_table::{DataTable, DataTableColumn, DataTableHeader};
> +use pwt::widget::form::{
> +    Combobox, Field, ManagedField, ManagedFieldContext, ManagedFieldMaster, ManagedFieldState,
> +};
> +use pwt_macros::widget;
> +
> +use pdm_api_types::views::{FilterRule, FILTER_RULE_LIST_SCHEMA, FILTER_RULE_SCHEMA};
> +
> +use crate::widget::RemoteSelector;
> +
> +#[derive(PartialEq, Clone)]
> +struct FilterRuleEntry {
> +    index: usize,
> +    filter: Option<FilterRule>,
> +}
> +
> +#[derive(PartialEq, Clone, Copy)]
> +enum FilterRuleType {
> +    ResourceType,
> +    ResourcePool,
> +    ResourceId,
> +    Tag,
> +    Remote,
> +}
> +
> +impl FromStr for FilterRuleType {
> +    type Err = Error;
> +    fn from_str(s: &str) -> Result<Self, Self::Err> {
> +        Ok(match s {
> +            "resource-type" => FilterRuleType::ResourceType,
> +            "resource-pool" => FilterRuleType::ResourcePool,
> +            "resource-id" => FilterRuleType::ResourceId,
> +            "tag" => FilterRuleType::Tag,
> +            "remote" => FilterRuleType::Remote,
> +            _ => bail!("unknown filter type"),
> +        })
> +    }
> +}
> +
> +impl From<FilterRuleType> for AttrValue {
> +    fn from(value: FilterRuleType) -> Self {
> +        match value {
> +            FilterRuleType::ResourceType => "resource-type".into(),
> +            FilterRuleType::ResourcePool => "resource-pool".into(),
> +            FilterRuleType::ResourceId => "resource-id".into(),
> +            FilterRuleType::Tag => "tag".into(),
> +            FilterRuleType::Remote => "remote".into(),
> +        }
> +    }
> +}
> +
> +impl From<&FilterRule> for FilterRuleType {
> +    fn from(value: &FilterRule) -> Self {
> +        match value {
> +            FilterRule::ResourceType(_) => FilterRuleType::ResourceType,
> +            FilterRule::ResourcePool(_) => FilterRuleType::ResourcePool,
> +            FilterRule::ResourceId(_) => FilterRuleType::ResourceId,
> +            FilterRule::Tag(_) => FilterRuleType::Tag,
> +            FilterRule::Remote(_) => FilterRuleType::Remote,
> +        }
> +    }
> +}
> +
> +#[widget(comp=ManagedFieldMaster<ViewFilterSelectorComp>, @input)]
> +#[derive(PartialEq, Clone, Properties)]
> +pub struct ViewFilterSelector {}
> +
> +impl ViewFilterSelector {
> +    pub fn new() -> Self {
> +        yew::props!(Self {})
> +    }
> +}
> +
> +pub struct ViewFilterSelectorComp {
> +    store: Store<FilterRuleEntry>,
> +}
> +
> +impl ViewFilterSelectorComp {
> +    fn update_value(&self, ctx: &ManagedFieldContext<Self>) {
> +        let store = self.store.read();
> +        let value: Vec<_> = store
> +            .iter()
> +            .map(|entry| entry.filter.as_ref().map(|filter| filter.to_string()))
> +            .collect();
> +
> +        ctx.link().update_value(value);
> +    }
> +}
> +
> +pub enum Msg {
> +    Add,
> +    Remove(usize),                   // index
> +    ChangeFilter(FilterRule, usize), // index
> +}
> +
> +impl ManagedField for ViewFilterSelectorComp {
> +    type Properties = ViewFilterSelector;
> +    type Message = Msg;
> +    type ValidateClosure = bool;
> +
> +    fn validation_args(props: &Self::Properties) -> Self::ValidateClosure {
> +        props.input_props.required
> +    }
> +
> +    fn update(
> +        &mut self,
> +        ctx: &pwt::widget::form::ManagedFieldContext<Self>,
> +        msg: Self::Message,
> +    ) -> bool {
> +        match msg {
> +            Msg::Add => {
> +                let mut store = self.store.write();
> +                let index = store.len();
> +                store.push(FilterRuleEntry {
> +                    index,
> +                    filter: None,
> +                });
> +                drop(store);
> +                self.update_value(ctx);
> +            }
> +            Msg::Remove(index) => {
> +                let data: Vec<FilterRuleEntry> = self
> +                    .store
> +                    .read()
> +                    .iter()
> +                    .filter(move |&item| item.index != index)
> +                    .cloned()
> +                    .enumerate()
> +                    .map(|(index, mut old)| {
> +                        old.index = index;
> +                        old
> +                    })
> +                    .collect();
> +                self.store.set_data(data);
> +                self.update_value(ctx);
> +            }
> +            Msg::ChangeFilter(filter_rule, index) => {
> +                let mut store = self.store.write();
> +                if let Some(rec) = store.lookup_record_mut(&Key::from(index)) {
> +                    rec.filter = Some(filter_rule);
> +                }
> +                drop(store);
> +                self.update_value(ctx);
> +            }
> +        }
> +
> +        true
> +    }
> +
> +    fn setup(_props: &Self::Properties) -> pwt::widget::form::ManagedFieldState {
> +        ManagedFieldState::new(Value::Array(Vec::new()), Value::Array(Vec::new()))
> +    }
> +
> +    fn validator(required: &Self::ValidateClosure, value: &Value) -> Result<Value, anyhow::Error> {
> +        FILTER_RULE_LIST_SCHEMA.verify_json(value)?;
> +
> +        if value.is_null() && *required {
> +            bail!("value required");
> +        }
> +
> +        Ok(value.clone())
> +    }
> +
> +    fn create(_ctx: &pwt::widget::form::ManagedFieldContext<Self>) -> Self {
> +        let store = Store::with_extract_key(|rule: &FilterRuleEntry| Key::from(rule.index));
> +
> +        Self { store }
> +    }
> +
> +    fn value_changed(&mut self, ctx: &ManagedFieldContext<Self>) {
> +        if let Ok(data) = serde_json::from_value::<Vec<FilterRule>>(ctx.state().value.clone()) {
> +            self.store.set_data(
> +                data.into_iter()
> +                    .enumerate()
> +                    .map(|(index, filter)| FilterRuleEntry {
> +                        index,
> +                        filter: Some(filter),
> +                    })
> +                    .collect(),
> +            );
> +        }
> +    }
> +
> +    fn view(&self, ctx: &pwt::widget::form::ManagedFieldContext<Self>) -> Html {
> +        let toolbar = Row::new().with_child(
> +            Button::new(tr!("Add"))
> +                .class(css::ColorScheme::Primary)
> +                .icon_class("fa fa-plus-circle")
> +                .on_activate(ctx.link().callback(|_| Msg::Add)),
> +        );
> +        Column::new()
> +            .gap(2)
> +            .with_child(
> +                DataTable::new(columns(ctx), self.store.clone())
> +                    .border(true)
> +                    .height(200),
> +            )
> +            .with_child(toolbar)
> +            .into()
> +    }
> +}
> +
> +fn columns(
> +    ctx: &ManagedFieldContext<ViewFilterSelectorComp>,
> +) -> Rc<Vec<DataTableHeader<FilterRuleEntry>>> {
> +    let link = ctx.link().clone();
> +    let columns = vec![
> +        DataTableColumn::new(tr!("Type"))
> +            .render({
> +                let link = link.clone();
> +                move |entry: &FilterRuleEntry| {
> +                    let index = entry.index;
> +                    let filter_type = entry.filter.as_ref().map(FilterRuleType::from);
> +                    Combobox::new()
> +                        .placeholder(tr!("Select"))
> +                        .required(true)
> +                        .default(filter_type.map(AttrValue::from))
> +                        .on_change({
> +                            let link = link.clone();
> +                            move |value: String| {
> +                                let filter = match FilterRuleType::from_str(value.as_str()) {
> +                                    Ok(FilterRuleType::ResourceType) => {
> +                                        FilterRule::ResourceType(ResourceType::Node)
> +                                    }
> +                                    Ok(FilterRuleType::ResourcePool) => {
> +                                        FilterRule::ResourcePool(String::new())
> +                                    }
> +                                    Ok(FilterRuleType::ResourceId) => {
> +                                        FilterRule::ResourceId(String::new())
> +                                    }
> +                                    Ok(FilterRuleType::Tag) => FilterRule::Tag(String::new()),
> +                                    Ok(FilterRuleType::Remote) => FilterRule::Remote(String::new()),
> +                                    Err(_) => return,
> +                                };
> +
> +                                link.send_message(Msg::ChangeFilter(filter, index));
> +                            }
> +                        })
> +                        .items(Rc::new(vec![
> +                            FilterRuleType::ResourceType.into(),
> +                            FilterRuleType::ResourcePool.into(),
> +                            FilterRuleType::ResourceId.into(),
> +                            FilterRuleType::Tag.into(),
> +                            FilterRuleType::Remote.into(),
> +                        ]))
> +                        .render_value(|value: &AttrValue| {
> +                            if value.as_str().is_empty() {
> +                                return "".into();
> +                            }
> +                            match FilterRuleType::from_str(value.as_str()) {
> +                                Ok(FilterRuleType::ResourceType) => tr!("Resource Type"),
> +                                Ok(FilterRuleType::ResourcePool) => tr!("Resource Pool"),
> +                                Ok(FilterRuleType::ResourceId) => tr!("Resource ID"),
> +                                Ok(FilterRuleType::Tag) => tr!("Tag"),
> +                                Ok(FilterRuleType::Remote) => tr!("Remote"),
> +                                Err(err) => tr!("invalid type: {0}", err.to_string()),
> +                            }
> +                            .into()
> +                        })
> +                        .into()
> +                }
> +            })
> +            .into(),
> +        DataTableColumn::new(tr!("Value"))
> +            .render({
> +                let link = link.clone();
> +                move |entry: &FilterRuleEntry| {
> +                    let index = entry.index;
> +
> +                    let send_change = {
> +                        let link = link.clone();
> +                        move |rule: FilterRule| {
> +                            link.send_message(Msg::ChangeFilter(rule, index));
> +                        }
> +                    };
> +                    match entry.filter.as_ref() {
> +                        Some(FilterRule::ResourceType(resource_type)) => Combobox::new()
> +                            .required(true)
> +                            .value(resource_type.to_string())
> +                            .items(Rc::new(vec![
> +                                ResourceType::Node.to_string().into(),
> +                                ResourceType::PveQemu.to_string().into(),
> +                                ResourceType::PveLxc.to_string().into(),
> +                                ResourceType::PveStorage.to_string().into(),
> +                                ResourceType::PveNetwork.to_string().into(),
> +                                ResourceType::PbsDatastore.to_string().into(),
> +                            ]))
> +                            .render_value(|value: &AttrValue| {
> +                                if value.as_str().is_empty() {
> +                                    return "".into();
> +                                }
> +                                match ResourceType::from_str(value.as_str()) {
> +                                    Ok(ResourceType::Node) => tr!("Node"),
> +                                    Ok(ResourceType::PveQemu) => tr!("Virtual Machine"),
> +                                    Ok(ResourceType::PveLxc) => tr!("Container"),
> +                                    Ok(ResourceType::PveStorage) => tr!("PVE Storage"),
> +                                    Ok(ResourceType::PveNetwork) => tr!("PVE Network"),
> +                                    Ok(ResourceType::PbsDatastore) => tr!("PBS Datastore"),
> +                                    Err(err) => tr!("invalid type: {0}", err.to_string()),
> +                                }
> +                                .into()
> +                            })
> +                            .on_change({
> +                                move |value: String| {
> +                                    if let Ok(resource_type) =
> +                                        ResourceType::from_str(value.as_str())
> +                                    {
> +                                        send_change(FilterRule::ResourceType(resource_type));
> +                                    }
> +                                }
> +                            })
> +                            .into(),
> +                        Some(FilterRule::ResourceId(id)) => Field::new()
> +                            .value(id.clone())
> +                            .required(true)
> +                            .validate(|value: &String| {
> +                                let value = FilterRule::ResourceId(value.to_owned()).to_string();
> +                                FILTER_RULE_SCHEMA.parse_simple_value(&value)?;
> +                                Ok(())
> +                            })
> +                            .on_change({
> +                                move |value: String| {
> +                                    send_change(FilterRule::ResourceId(value));
> +                                }
> +                            })
> +                            .into(),
> +                        Some(FilterRule::ResourcePool(pool)) => Field::new()
> +                            .value(pool.clone())
> +                            .required(true)
> +                            .validate(|value: &String| {
> +                                let value = FilterRule::ResourcePool(value.to_owned()).to_string();
> +                                FILTER_RULE_SCHEMA.parse_simple_value(&value)?;
> +                                Ok(())
> +                            })
> +                            .on_change({
> +                                move |value: String| {
> +                                    send_change(FilterRule::ResourcePool(value));
> +                                }
> +                            })
> +                            .into(),
> +                        Some(FilterRule::Tag(tag)) => Field::new()
> +                            .value(tag.clone())
> +                            .required(true)
> +                            .validate(|value: &String| {
> +                                let value = FilterRule::Tag(value.to_owned()).to_string();
> +                                FILTER_RULE_SCHEMA.parse_simple_value(&value)?;
> +                                Ok(())
> +                            })
> +                            .on_change({
> +                                move |value: String| {
> +                                    send_change(FilterRule::Tag(value));
> +                                }
> +                            })
> +                            .into(),
> +                        Some(FilterRule::Remote(remote)) => RemoteSelector::new()
> +                            .value(remote.clone())
> +                            .required(true)
> +                            .on_change(move |value| send_change(FilterRule::Remote(value)))
> +                            .into(),
> +                        None => Field::new()
> +                            .placeholder(tr!("Select Type first"))
> +                            .disabled(true)
> +                            .into(),
> +                    }
> +                }
> +            })
> +            .into(),
> +        DataTableColumn::new("")
> +            .width("50px")
> +            .render(move |entry: &FilterRuleEntry| {
> +                let index = entry.index;
> +                ActionIcon::new("fa fa-lg fa-trash-o")
> +                    .tabindex(0)
> +                    .on_activate(link.callback(move |_| Msg::Remove(index)))
> +                    .into()
> +            })
> +            .into(),
> +    ];
> +
> +    Rc::new(columns)
> +}
> diff --git a/ui/src/widget/view_selector.rs b/ui/src/widget/view_selector.rs
> new file mode 100644
> index 00000000..b48ef4f7
> --- /dev/null
> +++ b/ui/src/widget/view_selector.rs
> @@ -0,0 +1,55 @@
> +use std::rc::Rc;
> +
> +use pwt::prelude::*;
> +use pwt::state::Store;
> +use pwt::widget::form::Combobox;
> +use pwt_macros::{builder, widget};
> +
> +use pdm_api_types::views::ViewConfig;
> +
> +#[widget(comp=ViewSelectorComp, @input)]
> +#[derive(Clone, Properties, PartialEq)]
> +#[builder]
> +pub struct ViewSelector {
> +    store: Store<ViewConfig>,
> +}
> +
> +impl ViewSelector {
> +    pub fn new(store: Store<ViewConfig>) -> Self {
> +        yew::props!(Self { store })
> +    }
> +}
> +
> +#[doc(hidden)]
> +pub struct ViewSelectorComp {}
> +
> +impl Component for ViewSelectorComp {
> +    type Message = ();
> +    type Properties = ViewSelector;
> +
> +    fn create(_ctx: &Context<Self>) -> Self {
> +        Self {}
> +    }
> +
> +    fn view(&self, ctx: &Context<Self>) -> Html {
> +        let mut list = vec!["__dashboard__".into()];
> +        let store = &ctx.props().store;
> +        for item in store.read().data().iter() {
> +            list.push(item.id.clone().into());
> +        }
> +        Combobox::new()
> +            .items(Rc::new(list))
> +            .with_input_props(&ctx.props().input_props)
> +            .on_change(|_| {})
> +            .render_value({
> +                move |value: &AttrValue| {
> +                    if value == "__dashboard__" {
> +                        html! {{tr!("Dashboard")}}
> +                    } else {
> +                        html! {{value}}
> +                    }
> +                }
> +            })
> +            .into()
> +    }
> +}



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


  reply	other threads:[~2025-11-17 15:00 UTC|newest]

Thread overview: 29+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-11-17 12:44 [pdm-devel] [PATCH datacenter-manager v3 00/18] enable custom views on the UI Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 01/18] lib: pdm-config: views: add locking/saving methods Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 02/18] lib: api-types: add 'layout' property to ViewConfig Dominik Csapak
2025-11-17 14:58   ` Shannon Sterz
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 03/18] server: api: implement CRUD api for views Dominik Csapak
2025-11-17 14:58   ` Shannon Sterz
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 04/18] server: api: resources: add 'view' category to search syntax Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 05/18] ui: remote selector: allow forcing of value Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 06/18] ui: dashboard types: add missing 'default' to de-serialization Dominik Csapak
2025-11-17 14:59   ` Shannon Sterz
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 07/18] ui: dashboard: status row: add optional 'editing state' Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 08/18] ui: dashboard: prepare view for editing custom views Dominik Csapak
2025-11-17 14:59   ` Shannon Sterz
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 09/18] ui: views: implement view loading from api Dominik Csapak
2025-11-17 14:59   ` Shannon Sterz
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 10/18] ui: views: make 'view' name property optional Dominik Csapak
2025-11-17 14:59   ` Shannon Sterz
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 11/18] ui: views: add 'view' parameter to api calls Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 12/18] ui: views: save updated layout to backend Dominik Csapak
2025-11-17 15:00   ` Shannon Sterz
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 13/18] ui: add view list context Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 14/18] ui: configuration: add view CRUD panels Dominik Csapak
2025-11-17 15:00   ` Shannon Sterz [this message]
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 15/18] ui: main menu: add optional view_list property Dominik Csapak
2025-11-17 15:01   ` Shannon Sterz
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 16/18] ui: load view list on page init Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 17/18] lib/ui: move views types to pdm-api-types Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 18/18] server: api: views: check layout string for validity Dominik Csapak
2025-11-17 15:03 ` [pdm-devel] [PATCH datacenter-manager v3 00/18] enable custom views on the UI Shannon Sterz

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=DEB25R2CLWFP.2L1CW8F2QSZRL@proxmox.com \
    --to=s.sterz@proxmox.com \
    --cc=d.csapak@proxmox.com \
    --cc=pdm-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