public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: Dominik Csapak <d.csapak@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager v2 14/18] ui: configuration: add view CRUD panels
Date: Fri, 14 Nov 2025 13:11:28 +0100	[thread overview]
Message-ID: <20251114121218.2479318-15-d.csapak@proxmox.com> (raw)
In-Reply-To: <20251114121218.2479318-1-d.csapak@proxmox.com>

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>
---
 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 | 378 ++++++++++++++++++++++++++
 ui/src/widget/view_selector.rs        |  55 ++++
 6 files changed, 765 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
+}
+
+#[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))
+            .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>>>>
+    {
+        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),
+        )
+        .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..3776ad39
--- /dev/null
+++ b/ui/src/widget/view_filter_selector.rs
@@ -0,0 +1,378 @@
+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};
+
+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::PveSdnZone.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::PveSdnZone) => tr!("PVE SDN Zone"),
+                                    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)
+                            .on_change({
+                                move |value: String| {
+                                    send_change(FilterRule::ResourceId(value));
+                                }
+                            })
+                            .into(),
+                        Some(FilterRule::ResourcePool(pool)) => Field::new()
+                            .value(pool.clone())
+                            .required(true)
+                            .on_change({
+                                move |value: String| {
+                                    send_change(FilterRule::ResourcePool(value));
+                                }
+                            })
+                            .into(),
+                        Some(FilterRule::Tag(tag)) => Field::new()
+                            .value(tag.clone())
+                            .required(true)
+                            .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()
+    }
+}
-- 
2.47.3



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


  parent reply	other threads:[~2025-11-14 12:11 UTC|newest]

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