From: Dominik Csapak <d.csapak@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager 14/18] ui: configuration: add view CRUD panels
Date: Wed, 12 Nov 2025 17:11:49 +0100 [thread overview]
Message-ID: <20251112161900.75032-15-d.csapak@proxmox.com> (raw)
In-Reply-To: <20251112161900.75032-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
next prev parent reply other threads:[~2025-11-12 16:18 UTC|newest]
Thread overview: 22+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-11-12 16:11 [pdm-devel] [PATCH datacenter-manager 00/18] enable custom views on the UI Dominik Csapak
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 01/18] lib: pdm-config: views: add locking/saving methods Dominik Csapak
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 02/18] lib: api-types: add 'layout' property to ViewConfig Dominik Csapak
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 03/18] server: api: implement CRUD api for views Dominik Csapak
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 04/18] server: api: resources: add 'view' category to search syntax Dominik Csapak
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 05/18] ui: remote selector: allow forcing of value Dominik Csapak
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 06/18] ui: dashboard types: add missing 'default' to de-serialization Dominik Csapak
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 07/18] ui: dashboard: status row: add optional 'editing state' Dominik Csapak
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 08/18] ui: dashboard: prepare view for editing custom views Dominik Csapak
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 09/18] ui: views: implement view loading from api Dominik Csapak
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 10/18] ui: views: make 'view' name property optional Dominik Csapak
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 11/18] ui: views: add 'view' parameter to api calls Dominik Csapak
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 12/18] ui: views: save updated layout to backend Dominik Csapak
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 13/18] ui: add view list context Dominik Csapak
2025-11-12 16:11 ` Dominik Csapak [this message]
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 15/18] ui: main menu: add optional view_list property Dominik Csapak
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 16/18] ui: load view list on page init Dominik Csapak
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 17/18] lib/ui: move views types to pdm-api-types Dominik Csapak
2025-11-12 16:11 ` [pdm-devel] [PATCH datacenter-manager 18/18] server: api: views: check layout string for validity Dominik Csapak
2025-11-14 10:20 ` [pdm-devel] [PATCH datacenter-manager 00/18] enable custom views on the UI Lukas Wagner
2025-11-14 10:56 ` Dominik Csapak
2025-11-14 12:13 ` [pdm-devel] superseded: " 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=20251112161900.75032-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