From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from firstgate.proxmox.com (firstgate.proxmox.com [IPv6:2a01:7e0:0:424::9]) by lore.proxmox.com (Postfix) with ESMTPS id ACE371FF179 for ; Wed, 12 Nov 2025 17:18:48 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id EDBAE949F; Wed, 12 Nov 2025 17:19:33 +0100 (CET) From: Dominik Csapak To: pdm-devel@lists.proxmox.com Date: Wed, 12 Nov 2025 17:11:49 +0100 Message-ID: <20251112161900.75032-15-d.csapak@proxmox.com> X-Mailer: git-send-email 2.47.3 In-Reply-To: <20251112161900.75032-1-d.csapak@proxmox.com> References: <20251112161900.75032-1-d.csapak@proxmox.com> MIME-Version: 1.0 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.971 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: [pdm-devel] [PATCH datacenter-manager 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 Content-Type: text/plain; charset="us-ascii" Content-Transfer-Encoding: 7bit Errors-To: pdm-devel-bounces@lists.proxmox.com Sender: "pdm-devel" 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 --- 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, + 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 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)) + .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>>> + { + 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), + ) + .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, +} + +#[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::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, +} + +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() + } +} -- 2.47.3 _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel