From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [212.224.123.68]) by lore.proxmox.com (Postfix) with ESMTPS id 7F8D11FF185 for ; Mon, 17 Nov 2025 16:00:25 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id CD7061D34B; Mon, 17 Nov 2025 16:00:28 +0100 (CET) Mime-Version: 1.0 Date: Mon, 17 Nov 2025 16:00:20 +0100 Message-Id: To: "Dominik Csapak" X-Mailer: aerc 0.20.0 References: <20251117125041.1931382-1-d.csapak@proxmox.com> <20251117125041.1931382-15-d.csapak@proxmox.com> In-Reply-To: <20251117125041.1931382-15-d.csapak@proxmox.com> From: "Shannon Sterz" X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1763391591553 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.936 Adjusted score from AWL reputation of From: address BAYES_00 -1.9 Bayes spam probability is 0 to 1% DMARC_MISSING 0.1 Missing DMARC policy KAM_DMARC_STATUS 0.01 Test Rule for DKIM or SPF Failure with Strict Alignment KAM_MAILER 2 Automated Mailer Tag Left in Email SPF_HELO_NONE 0.001 SPF: HELO does not publish an SPF Record SPF_PASS -0.001 SPF: sender matches SPF record Subject: Re: [pdm-devel] [PATCH datacenter-manager v3 14/18] ui: configuration: add view CRUD panels X-BeenThere: pdm-devel@lists.proxmox.com X-Mailman-Version: 2.1.29 Precedence: list List-Id: Proxmox Datacenter Manager development discussion List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Reply-To: Proxmox Datacenter Manager development discussion Cc: Proxmox Datacenter Manager development discussion Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" 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 > --- > 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, > + 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 for VNode { > + fn from(val: ViewGrid) -> Self { > + VComp::new::>(Rc::new(val), None).into() > + } > +} > + > +pub enum Msg { > + SelectionChanged, > + LoadFinished(Vec), > + Remove(Key), > + Reload, > +} > + > +#[derive(PartialEq)] > +pub enum ViewState { > + Create, > + Edit, > + Remove, > +} > + > +#[doc(hidden)] > +pub struct ViewGridComp { > + store: Store, > + columns: Rc>>, > + selection: Selection, > +} > + > +impl ViewGridComp { > + fn columns() -> Rc>> { > + 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) -> 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) -> 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 { > + 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, > + 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::(Callback::from(|_| {})) > + { > + context.update_views(); > + } > + } > + } > + true > + } > + > + fn toolbar(&self, ctx: &proxmox_yew_comp::LoadableComponentContext) -> Option { > + 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, > + ) -> std::pin::Pin>>> 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 = http_get(base_url.as_str(), None).await?; > + link.send_message(Msg::LoadFinished(data)); > + Ok(()) > + }) > + } > + > + fn main_view(&self, ctx: &proxmox_yew_comp::LoadableComponentContext) -> 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, > + view_state: &Self::ViewState, > + ) -> Option { > + 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) -> 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, > +} > + > +#[derive(PartialEq, Clone, Copy)] > +enum FilterRuleType { > + ResourceType, > + ResourcePool, > + ResourceId, > + Tag, > + Remote, > +} > + > +impl FromStr for FilterRuleType { > + type Err = Error; > + fn from_str(s: &str) -> Result { > + 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 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, @input)] > +#[derive(PartialEq, Clone, Properties)] > +pub struct ViewFilterSelector {} > + > +impl ViewFilterSelector { > + pub fn new() -> Self { > + yew::props!(Self {}) > + } > +} > + > +pub struct ViewFilterSelectorComp { > + store: Store, > +} > + > +impl ViewFilterSelectorComp { > + fn update_value(&self, ctx: &ManagedFieldContext) { > + 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, > + 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 = 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 { > + 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 { > + let store = Store::with_extract_key(|rule: &FilterRuleEntry| Key::from(rule.index)); > + > + Self { store } > + } > + > + fn value_changed(&mut self, ctx: &ManagedFieldContext) { > + if let Ok(data) = serde_json::from_value::>(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) -> 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, > +) -> Rc>> { > + 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, > +} > + > +impl ViewSelector { > + pub fn new(store: Store) -> 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 {} > + } > + > + fn view(&self, ctx: &Context) -> 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