From: Dominik Csapak <d.csapak@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager v2 08/18] ui: dashboard: prepare view for editing custom views
Date: Fri, 14 Nov 2025 13:11:22 +0100 [thread overview]
Message-ID: <20251114121218.2479318-9-d.csapak@proxmox.com> (raw)
In-Reply-To: <20251114121218.2479318-1-d.csapak@proxmox.com>
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 <d.csapak@proxmox.com>
---
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<f64>,
show_config_window: bool,
show_create_wizard: Option<RemoteType>,
+
+ editing_state: SharedState<Vec<EditingMessage>>,
}
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<Self>) -> 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<ViewTemplate, Error> {
\"leaderboard-type\": \"node-memory\"
}
],
+ [],
[
{
\"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<RowWidget>,
+
+ #[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<Callback<()>>,
+
+ #[builder_cb(IntoEventCallback, into_event_callback, u32)]
+ #[prop_or_default]
+ on_flex_change: Option<Callback<u32>>,
+}
+
+impl RowElement {
+ pub fn new(item: RowWidget, widget_renderer: impl Into<RenderFn<RowWidget>>) -> Self {
+ let widget_renderer = widget_renderer.into();
+ yew::props!(Self {
+ item,
+ widget_renderer
+ })
+ }
+}
+
+pub enum Msg {
+ FlexReduce,
+ FlexIncrease,
+}
+
+pub struct RowElementComp {}
+
+impl Component for RowElementComp {
+ type Message = Msg;
+ type Properties = RowElement;
+
+ fn create(_ctx: &Context<Self>) -> Self {
+ Self {}
+ }
+
+ fn update(&mut self, ctx: &Context<Self>, 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<Self>) -> 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<Vec<RowWidget>>,
widget_renderer: RenderFn<RowWidget>,
+
+ #[prop_or_default]
+ #[builder(IntoPropValue, into_prop_value)]
+ /// If set, enables/disables editing mode
+ editing_state: Option<SharedState<Vec<EditingMessage>>>,
+
+ #[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<Callback<ViewLayout>>,
}
impl RowView {
@@ -33,6 +54,33 @@ impl From<RowView> 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,
+}
+
#[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<Vec<(Position, RowWidget)>>,
+ new_layout: Option<Vec<Vec<(Position, RowWidget)>>>,
+ dragging: Option<Position>, // index of item
+ dragging_target: Option<Position>, // index of item
+ drag_timeout: Option<Timeout>,
+
+ next_row_indices: HashMap<usize, usize>, // for saving the max index for new widgets
+
+ node_ref: NodeRef,
+ edit_mode: bool,
+ _editing_observer: Option<SharedStateObserver<Vec<EditingMessage>>>,
}
fn extract_row_layout(rows: &Vec<Vec<RowWidget>>) -> Vec<Vec<(Position, RowWidget)>> {
@@ -65,7 +123,7 @@ fn extract_row_layout(rows: &Vec<Vec<RowWidget>>) -> Vec<Vec<(Position, RowWidge
}
impl Component for RowViewComp {
- type Message = ();
+ type Message = Msg;
type Properties = RowView;
fn create(ctx: &Context<Self>) -> 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<Self>, 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::<Element>().ok()),
+ (event.client_x(), event.client_y()),
+ ),
+ OverEvent::Drag(event) => (
+ event.target().and_then(|t| t.dyn_into::<Element>().ok()),
+ (event.client_x(), event.client_y()),
+ ),
+ };
+ if let Some(el) = self.node_ref.cast::<Element>() {
+ 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<Self>, 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<Self>) -> 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::<Element>().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<RowViewComp>, 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)))
+}
--
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-14 12:12 UTC|newest]
Thread overview: 20+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-11-14 12:11 [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 01/18] lib: pdm-config: views: add locking/saving methods Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 02/18] lib: api-types: add 'layout' property to ViewConfig Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 03/18] server: api: implement CRUD api for views Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 04/18] server: api: resources: add 'view' category to search syntax Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 05/18] ui: remote selector: allow forcing of value Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 06/18] ui: dashboard types: add missing 'default' to de-serialization Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 07/18] ui: dashboard: status row: add optional 'editing state' Dominik Csapak
2025-11-14 12:11 ` Dominik Csapak [this message]
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 09/18] ui: views: implement view loading from api Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 10/18] ui: views: make 'view' name property optional Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 11/18] ui: views: add 'view' parameter to api calls Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 12/18] ui: views: save updated layout to backend Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 13/18] ui: add view list context Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 14/18] ui: configuration: add view CRUD panels Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 15/18] ui: main menu: add optional view_list property Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 16/18] ui: load view list on page init Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 17/18] lib/ui: move views types to pdm-api-types Dominik Csapak
2025-11-14 12:11 ` [pdm-devel] [PATCH datacenter-manager v2 18/18] server: api: views: check layout string for validity Dominik Csapak
2025-11-14 12:22 ` [pdm-devel] [PATCH datacenter-manager v2 00/18] enable custom views on the UI 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=20251114121218.2479318-9-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 an external index of several public inboxes,
see mirroring instructions on how to clone and mirror
all data and code used by this external index.