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 46C3D1FF185 for ; Mon, 17 Nov 2025 15:59:31 +0100 (CET) Received: from firstgate.proxmox.com (localhost [127.0.0.1]) by firstgate.proxmox.com (Proxmox) with ESMTP id 96D6A1D273; Mon, 17 Nov 2025 15:59:34 +0100 (CET) Mime-Version: 1.0 Date: Mon, 17 Nov 2025 15:59:19 +0100 Message-Id: To: "Dominik Csapak" X-Mailer: aerc 0.20.0 References: <20251117125041.1931382-1-d.csapak@proxmox.com> <20251117125041.1931382-9-d.csapak@proxmox.com> In-Reply-To: <20251117125041.1931382-9-d.csapak@proxmox.com> From: "Shannon Sterz" X-Bm-Milter-Handled: 55990f41-d878-4baa-be0a-ee34c49e34d2 X-Bm-Transport-Timestamp: 1763391530793 X-SPAM-LEVEL: Spam detection results: 0 AWL -0.136 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 POISEN_SPAM_PILL 0.1 Meta: its spam POISEN_SPAM_PILL_2 0.1 random spam to be learned in bayes POISEN_SPAM_PILL_4 0.1 random spam to be learned in bayes PROLO_LEO1 0.1 Meta Catches all Leo drug variations so far 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 08/18] ui: dashboard: prepare view for editing custom views 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: > This adds a mechanism to edit a view, namely it adds an edit button > (pencil) in the status row. When in 'edit mode' one can: > * drag the panels around > * delete panels > * add new panels > * set the 'flex' value of panels > * add a new row at the end > * delete a whole row > > There is currently no mechanism to persistently save the result, but > that's only a case of wiring the 'on_update_layout' callback to e.g. a > backend api call. > > Also the editing is only active when the view is not named 'dashboard'. > > The drag&drop works with desktop and touchscreens, but on touchscreens, > there is no 'drag item' shown currently. > > The menu structure for adding new items could probably be improved, but > that should not be a big issue. > > For handling the 'editing overlay' of the panels, there is a new > 'RowElement' component that just abstracts that away to have a less > code in the RowView component. > > Signed-off-by: Dominik Csapak > --- > ui/Cargo.toml | 2 +- > ui/css/pdm.scss | 4 + > ui/src/dashboard/view.rs | 88 +++-- > ui/src/dashboard/view/row_element.rs | 130 +++++++ > ui/src/dashboard/view/row_view.rs | 519 ++++++++++++++++++++++++++- > 5 files changed, 697 insertions(+), 46 deletions(-) > create mode 100644 ui/src/dashboard/view/row_element.rs > > diff --git a/ui/Cargo.toml b/ui/Cargo.toml > index 8da9351a..9f9b594f 100644 > --- a/ui/Cargo.toml > +++ b/ui/Cargo.toml > @@ -23,7 +23,7 @@ serde_json = "1.0" > wasm-bindgen = "0.2.92" > wasm-bindgen-futures = "0.4" > wasm-logger = "0.2" > -web-sys = { version = "0.3", features = ["Location"] } > +web-sys = { version = "0.3", features = ["Location", "DataTransfer"] } > yew = { version = "0.21", features = ["csr"] } > yew-router = { version = "0.18" } > > diff --git a/ui/css/pdm.scss b/ui/css/pdm.scss > index 92182a47..71cd4b05 100644 > --- a/ui/css/pdm.scss > +++ b/ui/css/pdm.scss > @@ -120,3 +120,7 @@ > background-color: var(--pwt-color-background); > } > } > + > +.dragging-item { > + opacity: 0.5; > +} > diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs > index a39f8f58..1d317b0b 100644 > --- a/ui/src/dashboard/view.rs > +++ b/ui/src/dashboard/view.rs > @@ -39,6 +39,8 @@ use pdm_client::types::TopEntities; > mod row_view; > pub use row_view::RowView; > > +mod row_element; > + > #[derive(Debug, Clone, PartialEq, Copy)] > pub enum EditingMessage { > Start, > @@ -79,6 +81,7 @@ pub enum Msg { > Reload(bool), // force > ConfigWindow(bool), // show > UpdateConfig(RefreshConfig), > + LayoutUpdate(ViewLayout), > } > > struct ViewComp { > @@ -97,6 +100,8 @@ struct ViewComp { > load_finished_time: Option, > show_config_window: bool, > show_create_wizard: Option, > + > + editing_state: SharedState>, > } > > fn render_widget( > @@ -276,6 +281,8 @@ impl Component for ViewComp { > loading: true, > show_config_window: false, > show_create_wizard: None, > + > + editing_state: SharedState::new(Vec::new()), > } > } > > @@ -331,6 +338,12 @@ impl Component for ViewComp { > > self.show_config_window = false; > } > + Msg::LayoutUpdate(view_layout) => { > + // FIXME: update backend layout > + if let Some(template) = &mut self.template.data { > + template.layout = view_layout; > + } > + } > } > true > } > @@ -345,51 +358,65 @@ impl Component for ViewComp { > } > > fn view(&self, ctx: &yew::Context) -> yew::Html { > + let props = ctx.props(); > if !self.template.has_data() { > return Progress::new().into(); > } > let mut view = Column::new().class(css::FlexFit).with_child( > Container::new() > - .class("pwt-content-spacer-padding") > + .padding(4) > .class("pwt-content-spacer-colors") > .class("pwt-default-colors") > - .with_child(DashboardStatusRow::new( > - self.load_finished_time, > - self.refresh_config > - .refresh_interval > - .unwrap_or(DEFAULT_REFRESH_INTERVAL_S), > - ctx.link().callback(Msg::Reload), > - ctx.link().callback(|_| Msg::ConfigWindow(true)), > - )), > + .with_child( > + DashboardStatusRow::new( > + self.load_finished_time, > + self.refresh_config > + .refresh_interval > + .unwrap_or(DEFAULT_REFRESH_INTERVAL_S), > + ctx.link().callback(Msg::Reload), > + ctx.link().callback(|_| Msg::ConfigWindow(true)), > + ) > + .editing_state( > + (props.view != "dashboard").then_some(self.editing_state.clone()), > + ), > + ), > ); > + > if !has_sub_panel(self.template.data.as_ref()) { > view.add_child( > Row::new() > - .class("pwt-content-spacer") > - .with_child(create_subscription_panel(self.subscriptions.clone())), > + .padding_x(4) > + .padding_bottom(4) > + .padding_top(0) > + .class("pwt-content-spacer-colors") > + .with_child(create_subscription_panel(self.subscriptions.clone()).flex(1.0)), > ); > } > match self.template.data.as_ref().map(|template| &template.layout) { > Some(ViewLayout::Rows { rows }) => { > - view.add_child(RowView::new(rows.clone(), { > - let link = ctx.link().clone(); > - let status = self.status.clone(); > - let subscriptions = self.subscriptions.clone(); > - let top_entities = self.top_entities.clone(); > - let statistics = self.statistics.clone(); > - let refresh_config = self.refresh_config.clone(); > - move |widget: &RowWidget| { > - render_widget( > - link.clone(), > - widget, > - status.clone(), > - subscriptions.clone(), > - top_entities.clone(), > - statistics.clone(), > - refresh_config.clone(), > - ) > - } > - })); > + view.add_child( > + RowView::new(rows.clone(), { > + let link = ctx.link().clone(); > + let status = self.status.clone(); > + let subscriptions = self.subscriptions.clone(); > + let top_entities = self.top_entities.clone(); > + let statistics = self.statistics.clone(); > + let refresh_config = self.refresh_config.clone(); > + move |widget: &RowWidget| { > + render_widget( > + link.clone(), > + widget, > + status.clone(), > + subscriptions.clone(), > + top_entities.clone(), > + statistics.clone(), > + refresh_config.clone(), > + ) > + } > + }) > + .editing_state(self.editing_state.clone()) > + .on_update_layout(ctx.link().callback(Msg::LayoutUpdate)), > + ); > } > None => {} > } > @@ -490,6 +517,7 @@ async fn load_template() -> Result { > \"leaderboard-type\": \"node-memory\" > } > ], > + [], this is removed again in the very next commit (it's masked a bit because of an indent change). is this intentional? > [ > { > \"flex\": 5.0, > diff --git a/ui/src/dashboard/view/row_element.rs b/ui/src/dashboard/view/row_element.rs > new file mode 100644 > index 00000000..d242195c > --- /dev/null > +++ b/ui/src/dashboard/view/row_element.rs > @@ -0,0 +1,130 @@ > +use yew::html::IntoEventCallback; > + > +use pwt::css; > +use pwt::prelude::*; > +use pwt::props::RenderFn; > +use pwt::widget::{ActionIcon, Card, Fa, Panel, Row}; > +use pwt_macros::{builder, widget}; > + > +use crate::dashboard::types::RowWidget; > + > +#[widget(comp=RowElementComp, @element)] > +#[derive(PartialEq, Properties, Clone)] > +#[builder] > +pub struct RowElement { > + item: RowWidget, > + widget_renderer: RenderFn, > + > + #[builder] > + #[prop_or_default] > + edit_mode: bool, > + > + #[builder] > + #[prop_or_default] > + is_dragging: bool, > + > + #[builder_cb(IntoEventCallback, into_event_callback, ())] > + #[prop_or_default] > + on_remove: Option>, > + > + #[builder_cb(IntoEventCallback, into_event_callback, u32)] > + #[prop_or_default] > + on_flex_change: Option>, > +} > + > +impl RowElement { > + pub fn new(item: RowWidget, widget_renderer: impl Into>) -> Self { > + let widget_renderer = widget_renderer.into(); > + yew::props!(Self { > + item, > + widget_renderer > + }) > + } > +} > + > +pub enum Msg { is this `pub` intentionally? > + FlexReduce, > + FlexIncrease, > +} > + > +pub struct RowElementComp {} > + > +impl Component for RowElementComp { > + type Message = Msg; > + type Properties = RowElement; > + > + fn create(_ctx: &Context) -> Self { > + Self {} > + } > + > + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { > + let props = ctx.props(); > + let flex = props.item.flex.unwrap_or(1.0) as u32; > + match msg { > + Msg::FlexReduce => { > + if let Some(on_flex_change) = &props.on_flex_change { > + on_flex_change.emit(flex.saturating_sub(1)) > + } > + } > + Msg::FlexIncrease => { > + if let Some(on_flex_change) = &props.on_flex_change { > + on_flex_change.emit(flex.saturating_add(1)) > + } > + } > + } > + > + true > + } > + > + fn view(&self, ctx: &Context) -> Html { > + let props = ctx.props(); > + let widget = props.widget_renderer.apply(&props.item); > + > + let edit_overlay = Card::new() > + .padding(2) > + .style("z-index", "10") > + .class(css::AlignItems::Center) > + .with_child(Fa::new("bars").style("cursor", "grab").padding_end(1)) > + .with_child(tr!("Flex")) > + .with_child( > + ActionIcon::new("fa fa-minus") > + .on_activate(ctx.link().callback(|_| Msg::FlexReduce)), > + ) > + .with_child(props.item.flex.unwrap_or(1.0) as u32) > + .with_child( > + ActionIcon::new("fa fa-plus") > + .on_activate(ctx.link().callback(|_| Msg::FlexIncrease)), > + ) > + .with_child(ActionIcon::new("fa fa-times").on_activate({ > + let on_remove = props.on_remove.clone(); > + move |_| { > + if let Some(on_remove) = &on_remove { > + on_remove.emit(()); > + } > + } > + })); > + > + Panel::new() > + .with_std_props(&props.std_props) > + .listeners(&props.listeners) > + .border(true) > + .class(props.is_dragging.then_some("dragging-item")) > + .attribute("draggable", if props.edit_mode { "true" } else { "false" }) > + .style("position", "relative") > + .with_child(widget) > + .with_optional_child( > + props.edit_mode.then_some( > + Row::new() > + .gap(2) > + .class(css::Display::Flex) > + .class(css::AlignItems::Start) > + .class(css::JustifyContent::End) > + .key("overlay") > + .style("position", "absolute") > + .style("inset", "0") > + .with_child(edit_overlay), > + ), > + ) > + .into() > + } > +} > diff --git a/ui/src/dashboard/view/row_view.rs b/ui/src/dashboard/view/row_view.rs > index 69300327..512e63e7 100644 > --- a/ui/src/dashboard/view/row_view.rs > +++ b/ui/src/dashboard/view/row_view.rs > @@ -1,21 +1,42 @@ > use std::collections::HashMap; > use std::rc::Rc; > > +use gloo_timers::callback::Timeout; > +use wasm_bindgen::JsCast; > +use web_sys::Element; > +use yew::html::{IntoEventCallback, IntoPropValue}; > use yew::virtual_dom::{VComp, VNode}; > > use pwt::css; > use pwt::prelude::*; > use pwt::props::RenderFn; > -use pwt::widget::{Column, Container, Panel, Row}; > +use pwt::state::{SharedState, SharedStateObserver}; > +use pwt::widget::menu::{Menu, MenuButton, MenuItem}; > +use pwt::widget::{ActionIcon, Button, Column, Container, Row, Tooltip}; > use pwt_macros::builder; > > -use crate::dashboard::types::RowWidget; > +use crate::dashboard::types::{RowWidget, ViewLayout, WidgetType}; > +use crate::dashboard::view::row_element::RowElement; > +use crate::dashboard::view::EditingMessage; > + > +use pdm_api_types::remotes::RemoteType; > > #[derive(Properties, PartialEq)] > #[builder] > pub struct RowView { > rows: Vec>, > widget_renderer: RenderFn, > + > + #[prop_or_default] > + #[builder(IntoPropValue, into_prop_value)] > + /// If set, enables/disables editing mode > + editing_state: Option>>, > + > + #[prop_or_default] > + #[builder_cb(IntoEventCallback, into_event_callback, ViewLayout)] > + /// Will be called if there is an [`EditingController`] and the editing > + /// is finished. > + on_update_layout: Option>, > } > > impl RowView { > @@ -33,6 +54,33 @@ impl From for VNode { > } > } > > +pub enum OverEvent { > + Pointer(PointerEvent), > + Drag(DragEvent), > +} > + > +pub enum DragMsg { > + Start(Position), > + End, > + DragOver(OverEvent, Position), > + Enter(Position), > +} > + > +pub enum MoveDirection { > + Up, > + Down, > +} > +pub enum Msg { > + DragEvent(DragMsg), > + AddRow, > + RemoveRow(usize), // idx > + EditFlex(Position, u32), > + AddWidget(Position, WidgetType), > + RemoveWidget(Position), > + MoveRow(usize, MoveDirection), // idx > + HandleEditMessages, > +} are these intentionally `pub`? > + > #[derive(Clone, Copy, Debug, PartialEq)] > /// Represents the position of a widget in a row view > pub struct Position { > @@ -42,6 +90,16 @@ pub struct Position { > > pub struct RowViewComp { > current_layout: Vec>, > + new_layout: Option>>, > + dragging: Option, // index of item > + dragging_target: Option, // index of item > + drag_timeout: Option, > + > + next_row_indices: HashMap, // for saving the max index for new widgets > + > + node_ref: NodeRef, > + edit_mode: bool, > + _editing_observer: Option>>, > } > > fn extract_row_layout(rows: &Vec>) -> Vec> { > @@ -65,7 +123,7 @@ fn extract_row_layout(rows: &Vec>) -> Vec } > > impl Component for RowViewComp { > - type Message = (); > + type Message = Msg; > type Properties = RowView; > > fn create(ctx: &Context) -> Self { > @@ -75,14 +133,189 @@ impl Component for RowViewComp { > for (row_idx, row) in current_layout.iter().enumerate() { > next_row_indices.insert(row_idx, row.len()); > } > - Self { current_layout } > + > + let _editing_observer = ctx > + .props() > + .editing_state > + .as_ref() > + .map(|state| state.add_listener(ctx.link().callback(|_| Msg::HandleEditMessages))); > + > + Self { > + new_layout: None, > + current_layout, > + dragging: None, > + dragging_target: None, > + drag_timeout: None, > + next_row_indices, > + node_ref: NodeRef::default(), > + edit_mode: false, > + _editing_observer, > + } > + } > + > + fn update(&mut self, ctx: &Context, msg: Self::Message) -> bool { > + match msg { > + Msg::RemoveRow(idx) => { > + self.current_layout.remove(idx); > + } > + Msg::AddRow => { > + self.current_layout.push(Vec::new()); > + } > + Msg::DragEvent(drag_msg) => match drag_msg { > + DragMsg::Start(coords) => { > + self.dragging = Some(coords); > + self.dragging_target = Some(coords); > + } > + DragMsg::End => { > + self.dragging = None; > + self.dragging_target = None; > + if let Some(layout) = self.new_layout.take() { > + self.current_layout = layout; > + } > + } > + DragMsg::DragOver(event, position) => { > + // check if the pointer is at a position where the item can be dropped > + // without flickering, namely where it fits from it's dimensions > + let (target, pointer_pos) = match event { > + OverEvent::Pointer(event) => ( > + event.target().and_then(|t| t.dyn_into::().ok()), > + (event.client_x(), event.client_y()), > + ), > + OverEvent::Drag(event) => ( > + event.target().and_then(|t| t.dyn_into::().ok()), > + (event.client_x(), event.client_y()), > + ), > + }; > + if let Some(el) = self.node_ref.cast::() { > + if let Ok(Some(dragging_el)) = el.query_selector(".dragging-item") { > + let dragging_rect = dragging_el.get_bounding_client_rect(); > + > + if let Some(target) = target { > + let target_rect = target.get_bounding_client_rect(); > + > + let x = pointer_pos.0 as f64; > + let x_min = target_rect.x(); > + let x_max = target_rect.x() + dragging_rect.width(); > + > + let y = pointer_pos.1 as f64; > + let y_min = target_rect.y(); > + let y_max = target_rect.y() + dragging_rect.height(); > + > + if x >= x_min && x <= x_max && y >= y_min && y <= y_max { > + ctx.link() > + .send_message(Msg::DragEvent(DragMsg::Enter(position))); > + } > + } > + } > + } > + } > + DragMsg::Enter(coords) => { > + if let Some(source_coords) = self.dragging { > + let mut new_layout = self.current_layout.clone(); > + let item = new_layout[source_coords.row].remove(source_coords.item); > + let target_idx = new_layout[coords.row].len().min(coords.item); > + new_layout[coords.row].insert(target_idx, item); > + self.new_layout = Some(new_layout); > + } > + self.dragging_target = Some(coords); > + } > + }, > + Msg::EditFlex(coords, flex) => { > + self.current_layout[coords.row][coords.item].1.flex = Some(flex as f32); > + } > + Msg::AddWidget(coords, widget_type) => { > + let next_idx = *self.next_row_indices.get(&coords.row).unwrap_or(&0); > + self.next_row_indices > + .insert(coords.row, next_idx.saturating_add(1)); > + self.current_layout[coords.row].insert( > + coords.item, > + ( > + Position { > + row: coords.row, > + item: next_idx, > + }, > + RowWidget { > + flex: None, > + title: None, > + r#type: widget_type, > + }, > + ), > + ); > + } > + Msg::RemoveWidget(coords) => { > + self.current_layout[coords.row].remove(coords.item); > + } > + Msg::MoveRow(old, direction) => { > + let mut new_layout = self.current_layout.clone(); > + let row = new_layout.remove(old); > + let new_idx = match direction { > + MoveDirection::Up => old.saturating_sub(1), > + MoveDirection::Down => old.saturating_add(1).min(new_layout.len()), > + }; > + new_layout.insert(new_idx, row); > + self.current_layout = new_layout; > + } > + Msg::HandleEditMessages => { > + let props = ctx.props(); > + let state = match props.editing_state.clone() { > + Some(state) => state, > + None => return false, > + }; > + > + if state.read().len() == 0 { > + return false; > + } // Note: avoid endless loop > + > + let list = state.write().split_off(0); > + let mut editing = self.edit_mode; > + let mut trigger_finish = false; > + let mut cancel = false; > + for msg in list { > + match msg { > + EditingMessage::Start => editing = true, > + EditingMessage::Cancel => { > + if editing { > + cancel = true; > + } > + editing = false; > + } > + EditingMessage::Finish => { > + if editing { > + trigger_finish = true; > + } > + editing = false; > + } > + } > + } > + if let (true, Some(on_update_layout)) = (trigger_finish, &props.on_update_layout) { > + let rows = self > + .current_layout > + .iter() > + .map(|row| row.iter().map(|(_, item)| item.clone()).collect()) > + .collect(); > + on_update_layout.emit(ViewLayout::Rows { rows }); > + } > + if cancel { > + self.current_layout = extract_row_layout(&props.rows); > + } > + self.edit_mode = editing; > + if !self.edit_mode { > + self.dragging = None; > + self.dragging_target = None; > + self.drag_timeout = None; > + } > + } > + } > + true > } > > fn changed(&mut self, ctx: &Context, old_props: &Self::Properties) -> bool { > let props = ctx.props(); > - > if props.rows != old_props.rows { > - self.current_layout = extract_row_layout(&props.rows); > + let new_layout = extract_row_layout(&props.rows); > + if new_layout != self.current_layout { > + self.current_layout = new_layout; > + } > } > > true > @@ -90,8 +323,11 @@ impl Component for RowViewComp { > > fn view(&self, ctx: &Context) -> Html { > let props = ctx.props(); > - let mut view = Column::new(); > - let layout = &self.current_layout; > + let mut view = Column::new().onpointerup( > + (self.dragging.is_some() && self.edit_mode) > + .then_some(ctx.link().callback(|_| Msg::DragEvent(DragMsg::End))), > + ); > + let layout = self.new_layout.as_ref().unwrap_or(&self.current_layout); > let mut row = Row::new() > .padding_x(2) > .class("pwt-content-spacer-colors") > @@ -104,7 +340,7 @@ impl Component for RowViewComp { > .sum(); > let gaps_ratio = 1.0; //items.len().saturating_sub(1) as f32 / items.len() as f32; > > - for (_item_idx, (coords, item)) in items.iter().enumerate() { > + for (item_idx, (coords, item)) in items.iter().enumerate() { > let flex = item.flex.unwrap_or(1.0); > let flex_ratio = 95.0 * (flex.max(1.0)) / flex_sum; > // we have to subtract the gaps too > @@ -112,27 +348,280 @@ impl Component for RowViewComp { > "{} {} calc({}% - calc({} * var(--pwt-spacer-4)))", > flex, flex, flex_ratio, gaps_ratio > ); > + let current_coords = Position { > + row: row_idx, > + item: item_idx, > + }; > > - let widget = props.widget_renderer.apply(&item); > - let row_element = Panel::new() > - .border(true) > + let row_element = RowElement::new(item.clone(), props.widget_renderer.clone()) > .margin_x(2) > .margin_bottom(4) > + .edit_mode(self.edit_mode) > + .is_dragging(self.dragging_target == Some(current_coords)) > .key(format!("item-{}-{}", coords.row, coords.item)) > .style("flex", flex_style) > - .with_child(widget); > + .style("touch-action", self.edit_mode.then_some("none")) > + .on_remove( > + ctx.link() > + .callback(move |_| Msg::RemoveWidget(current_coords)), > + ) > + .on_flex_change( > + ctx.link() > + .callback(move |flex| Msg::EditFlex(current_coords, flex)), > + ) > + .ondragstart(ctx.link().callback(move |event: DragEvent| { > + let data = event.data_transfer().unwrap(); > + let _ = data.clear_data(); > + let _ = data.set_data("", ""); > + Msg::DragEvent(DragMsg::Start(current_coords)) > + })) > + .onpointerdown(self.edit_mode.then_some(ctx.link().callback( > + move |event: PointerEvent| { > + // we need to release the pointer capture to trigger pointer events > + // on other elements > + if let Some(target) = event > + .target() > + .and_then(|target| target.dyn_into::().ok()) > + { > + let _ = target.release_pointer_capture(event.pointer_id()); > + } > + Msg::DragEvent(DragMsg::Start(current_coords)) > + }, > + ))) > + .ondragend(ctx.link().callback(|_| Msg::DragEvent(DragMsg::End))) > + .onpointermove((self.dragging.is_some() && self.edit_mode).then_some( > + ctx.link().callback(move |event: PointerEvent| { > + Msg::DragEvent(DragMsg::DragOver( > + OverEvent::Pointer(event), > + current_coords, > + )) > + }), > + )) > + .ondragover((self.dragging.is_some() && self.edit_mode).then_some( > + ctx.link().callback(move |event: DragEvent| { > + Msg::DragEvent(DragMsg::DragOver( > + OverEvent::Drag(event), > + current_coords, > + )) > + }), > + )) > + .ondragover(|event: DragEvent| event.prevent_default()) > + .ondrop(ctx.link().callback(|event: DragEvent| { > + event.prevent_default(); > + Msg::DragEvent(DragMsg::End) > + })); > > row.add_child(row_element); > } > > + if self.edit_mode { > + let drop_coords = Position { > + row: row_idx, > + item: items.len().saturating_sub(1), > + }; > + let is_first_row = row_idx == 0; > + let is_last_row = row_idx == (layout.len().saturating_sub(1)); > + row.add_child( > + Container::new() > + .key(format!("row-add-{}", row_idx)) > + .style("flex", "1 1 100%") > + .margin_x(2) > + .margin_bottom(4) > + .padding_bottom(4) > + .border_bottom(true) > + .ondragenter( > + ctx.link() > + .callback(move |_| Msg::DragEvent(DragMsg::Enter(drop_coords))), > + ) > + .onpointerenter( > + (self.dragging.is_some() && self.edit_mode) > + .then_some(ctx.link().callback(move |_| { > + Msg::DragEvent(DragMsg::Enter(drop_coords)) > + })), > + ) > + // necessary for drop event to trigger > + .ondragover(|event: DragEvent| event.prevent_default()) > + .ondrop(ctx.link().callback(|event: DragEvent| { > + event.prevent_default(); > + Msg::DragEvent(DragMsg::End) > + })) > + .with_child( > + Row::new() > + .gap(2) > + .with_child( > + MenuButton::new(tr!("Add Widget")) > + .class(css::ColorScheme::Primary) > + .show_arrow(true) > + .icon_class("fa fa-plus-circle") > + .menu(create_menu( > + ctx, > + Position { > + row: row_idx, > + item: items.len(), > + }, > + )), > + ) > + .with_child( > + Button::new(tr!("Remove Row")) > + .icon_class("fa fa-times") > + .class(css::ColorScheme::Error) > + .on_activate( > + ctx.link().callback(move |_| Msg::RemoveRow(row_idx)), > + ), > + ) > + .with_flex_spacer() > + .with_child( > + Tooltip::new( > + ActionIcon::new("fa fa-arrow-down") > + .on_activate(ctx.link().callback(move |_| { > + Msg::MoveRow(row_idx, MoveDirection::Down) > + })) > + .disabled(is_last_row), > + ) > + .tip(tr!("Move Row down")), > + ) > + .with_child( > + Tooltip::new( > + ActionIcon::new("fa fa-arrow-up") > + .on_activate(ctx.link().callback(move |_| { > + Msg::MoveRow(row_idx, MoveDirection::Up) > + })) > + .disabled(is_first_row), > + ) > + .tip(tr!("Move Row up")), > + ), > + ), > + ); > + } > row.add_child( > Container::new() > .key(format!("spacer-{row_idx}")) > .style("flex", "1 1 100%"), > ); > } > - > + if self.edit_mode { > + row.add_child( > + Container::new() > + .key("add-row") > + .padding_x(2) > + .style("flex", "1 1 100%") > + .with_child( > + Button::new(tr!("Add Row")) > + .class(css::ColorScheme::Secondary) > + .icon_class("fa fa-plus-circle") > + .on_activate(ctx.link().callback(|_| Msg::AddRow)), > + ), > + ); > + } > view.add_child(row); > - view.into() > + view.into_html_with_ref(self.node_ref.clone()) > } > } > + > +fn create_menu(ctx: &yew::Context, new_coords: Position) -> Menu { > + let create_callback = |widget: WidgetType| { > + ctx.link() > + .callback(move |_| Msg::AddWidget(new_coords, widget.clone())) > + }; > + Menu::new() > + .with_item( > + MenuItem::new(tr!("Remote Panel")) > + .on_select(create_callback(WidgetType::Remotes { show_wizard: true })), > + ) > + .with_item( > + MenuItem::new(tr!("Node Panels")).menu( > + Menu::new() > + .with_item( > + MenuItem::new(tr!("All Nodes")) > + .on_select(create_callback(WidgetType::Nodes { remote_type: None })), > + ) > + .with_item(MenuItem::new(tr!("PBS Nodes")).on_select(create_callback( > + WidgetType::Nodes { > + remote_type: Some(RemoteType::Pbs), > + }, > + ))) > + .with_item(MenuItem::new(tr!("PVE Nodes")).on_select(create_callback( > + WidgetType::Nodes { > + remote_type: Some(RemoteType::Pve), > + }, > + ))), > + ), > + ) > + .with_item( > + MenuItem::new(tr!("Guest Panels")).menu( > + Menu::new() > + .with_item( > + MenuItem::new(tr!("All Guests")) > + .on_select(create_callback(WidgetType::Guests { guest_type: None })), > + ) > + .with_item( > + MenuItem::new(tr!("Virtual Machines")).on_select(create_callback( > + WidgetType::Guests { > + guest_type: Some(crate::pve::GuestType::Qemu), > + }, > + )), > + ) > + .with_item( > + MenuItem::new(tr!("Linux Container")).on_select(create_callback( > + WidgetType::Guests { > + guest_type: Some(crate::pve::GuestType::Lxc), > + }, > + )), > + ), > + ), > + ) > + .with_item( > + MenuItem::new(tr!("Subscription Panel")) > + .on_select(create_callback(WidgetType::Subscription)), > + ) > + .with_item( > + MenuItem::new(tr!("PBS Datastores")) > + .on_select(create_callback(WidgetType::PbsDatastores)), > + ) > + .with_item( > + MenuItem::new(tr!("Leaderboards")).menu( > + Menu::new() > + .with_item( > + MenuItem::new(tr!("Guests with Highest CPU Usage")).on_select( > + create_callback(WidgetType::Leaderboard { > + leaderboard_type: > + crate::dashboard::types::LeaderboardType::GuestCpu, > + }), > + ), > + ) > + .with_item( > + MenuItem::new(tr!("Nodes With the Hightest CPU Usagge)")).on_select( > + create_callback(WidgetType::Leaderboard { > + leaderboard_type: crate::dashboard::types::LeaderboardType::NodeCpu, > + }), > + ), > + ) > + .with_item( > + MenuItem::new(tr!("Nodes With the Highest Memory Usage")).on_select( > + create_callback(WidgetType::Leaderboard { > + leaderboard_type: > + crate::dashboard::types::LeaderboardType::NodeMemory, > + }), > + ), > + ), > + ), > + ) > + .with_item( > + MenuItem::new(tr!("Task Summaries")).menu( > + Menu::new() > + .with_item(MenuItem::new(tr!("Task Summary by Category")).on_select( > + create_callback(WidgetType::TaskSummary { > + grouping: crate::dashboard::types::TaskSummaryGrouping::Category, > + }), > + )) > + .with_item( > + MenuItem::new(tr!("Task Summary Sorted by Failed Tasks")).on_select( > + create_callback(WidgetType::TaskSummary { > + grouping: crate::dashboard::types::TaskSummaryGrouping::Remote, > + }), > + ), > + ), > + ), > + ) > + .with_item(MenuItem::new(tr!("SDN Panel")).on_select(create_callback(WidgetType::Sdn))) > +} _______________________________________________ pdm-devel mailing list pdm-devel@lists.proxmox.com https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel