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
next prev parent 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