From: Dominik Csapak <d.csapak@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager v5 08/26] ui: dashboard: prepare view for editing custom views
Date: Wed, 26 Nov 2025 16:18:01 +0100 [thread overview]
Message-ID: <20251126151833.3637080-9-d.csapak@proxmox.com> (raw)
In-Reply-To: <20251126151833.3637080-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 | 110 ++++--
ui/src/dashboard/view/row_element.rs | 130 +++++++
ui/src/dashboard/view/row_view.rs | 519 ++++++++++++++++++++++++++-
5 files changed, 711 insertions(+), 54 deletions(-)
create mode 100644 ui/src/dashboard/view/row_element.rs
diff --git a/ui/Cargo.toml b/ui/Cargo.toml
index d9acbe9e..04bb816d 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 a1c465e6..772fa244 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -41,6 +41,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,
@@ -82,6 +84,7 @@ pub enum Msg {
ConfigWindow(bool), // show
UpdateConfig(RefreshConfig),
ShowSubscriptionsDialog(Option<Dialog>),
+ LayoutUpdate(ViewLayout),
}
struct ViewComp {
@@ -101,6 +104,8 @@ struct ViewComp {
show_config_window: bool,
show_create_wizard: Option<RemoteType>,
subscriptions_dialog: Option<Dialog>,
+
+ editing_state: SharedState<Vec<EditingMessage>>,
}
fn render_widget(
@@ -290,6 +295,8 @@ impl Component for ViewComp {
show_config_window: false,
show_create_wizard: None,
subscriptions_dialog: None,
+
+ editing_state: SharedState::new(Vec::new()),
}
}
@@ -348,6 +355,12 @@ impl Component for ViewComp {
Msg::ShowSubscriptionsDialog(dialog) => {
self.subscriptions_dialog = dialog;
}
+ Msg::LayoutUpdate(view_layout) => {
+ // FIXME: update backend layout
+ if let Some(template) = &mut self.template.data {
+ template.layout = view_layout;
+ }
+ }
}
true
}
@@ -362,58 +375,79 @@ 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()) {
let subs = self.subscriptions.clone();
let link = ctx.link().clone();
- view.add_child(Row::new().class("pwt-content-spacer").with_child(
- create_subscription_panel(
- subs.clone(),
- link.clone().callback(move |_| {
- let on_dialog_close = link.callback(|_| Msg::ShowSubscriptionsDialog(None));
- let dialog = create_subscriptions_dialog(subs.clone(), on_dialog_close);
- Msg::ShowSubscriptionsDialog(dialog)
- }),
- ),
- ));
+ view.add_child(
+ Row::new()
+ .padding_x(4)
+ .padding_bottom(4)
+ .padding_top(0)
+ .class("pwt-content-spacer-colors")
+ .with_child(
+ create_subscription_panel(
+ subs.clone(),
+ link.clone().callback(move |_| {
+ let on_dialog_close =
+ link.callback(|_| Msg::ShowSubscriptionsDialog(None));
+ let dialog =
+ create_subscriptions_dialog(subs.clone(), on_dialog_close);
+ Msg::ShowSubscriptionsDialog(dialog)
+ }),
+ )
+ .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 => {}
}
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-26 15:19 UTC|newest]
Thread overview: 29+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-11-26 15:17 [pdm-devel] [PATCH datacenter-manager v5 00/26] enable custom views on the UI Dominik Csapak
2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 01/26] lib: pdm-config: views: add locking/saving methods Dominik Csapak
2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 02/26] lib: api-types: add 'layout' property to ViewConfig Dominik Csapak
2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 03/26] server: api: implement CRUD api for views Dominik Csapak
2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 04/26] server: api: resources: add 'view' category to search syntax Dominik Csapak
2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 05/26] ui: remote selector: allow forcing of value Dominik Csapak
2025-11-26 15:17 ` [pdm-devel] [PATCH datacenter-manager v5 06/26] ui: dashboard types: add missing 'default's to de-serialization Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 07/26] ui: dashboard: status row: add optional 'editing state' Dominik Csapak
2025-11-26 15:18 ` Dominik Csapak [this message]
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 09/26] ui: views: implement view loading from api Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 10/26] ui: views: make 'view' name property optional Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 11/26] ui: views: add 'view' parameter to api calls Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 12/26] ui: views: save updated layout to backend Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 13/26] ui: add view list context Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 14/26] ui: configuration: add view CRUD panels Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 15/26] ui: main menu: add optional view_list property Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 16/26] ui: load view list on page init Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 17/26] lib/ui: move views types to pdm-api-types Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 18/26] server: api: views: check layout string for validity Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 19/26] ui: dashboard: add current view to search terms Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 20/26] ui: resource tree: fix loading logic Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 21/26] ui: resource tree: move error message into first column Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 22/26] ui: resource tree: use `ViewContext` to limit the api calls to a view Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 23/26] ui: resource tree: show guest tags Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 24/26] api-types/ui: add ResourceTree variant for WidgetType Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 25/26] ui: dashboard view: refactor widget rendering arguments into struct Dominik Csapak
2025-11-26 15:18 ` [pdm-devel] [PATCH datacenter-manager v5 26/26] ui: resource tree/view: reload tree in a view on refresh Dominik Csapak
2025-11-26 21:15 ` [pdm-devel] applied: [PATCH datacenter-manager v5 00/26] enable custom views on the UI Thomas Lamprecht
2025-11-26 21:17 ` [pdm-devel] " Thomas Lamprecht
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=20251126151833.3637080-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.