From: Dominik Csapak <d.csapak@proxmox.com>
To: pdm-devel@lists.proxmox.com
Subject: [pdm-devel] [PATCH datacenter-manager v3 21/21] ui: dashboard: enable editing view
Date: Fri, 31 Oct 2025 13:44:04 +0100 [thread overview]
Message-ID: <20251031124822.2739685-22-d.csapak@proxmox.com> (raw)
In-Reply-To: <20251031124822.2739685-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.
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/status_row.rs | 29 +-
ui/src/dashboard/view.rs | 92 ++++---
ui/src/dashboard/view/row_element.rs | 130 +++++++++
ui/src/dashboard/view/row_view.rs | 389 +++++++++++++++++++++++++--
6 files changed, 596 insertions(+), 50 deletions(-)
create mode 100644 ui/src/dashboard/view/row_element.rs
diff --git a/ui/Cargo.toml b/ui/Cargo.toml
index cccb914e..b93ad79a 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/status_row.rs b/ui/src/dashboard/status_row.rs
index 0855b123..72ca195c 100644
--- a/ui/src/dashboard/status_row.rs
+++ b/ui/src/dashboard/status_row.rs
@@ -6,12 +6,13 @@ use pwt::{
css::AlignItems,
widget::{ActionIcon, Container, Row, Tooltip},
};
-use pwt_macros::widget;
+use pwt_macros::{builder, widget};
use proxmox_yew_comp::utils::render_epoch;
#[widget(comp=PdmDashboardStatusRow)]
#[derive(Properties, PartialEq, Clone)]
+#[builder]
pub struct DashboardStatusRow {
last_refresh: Option<f64>,
reload_interval_s: u32,
@@ -19,6 +20,11 @@ pub struct DashboardStatusRow {
on_reload: Callback<bool>,
on_settings_click: Callback<()>,
+
+ #[builder_cb(Into, into, Option<Callback<bool>>)]
+ #[prop_or_default]
+ /// An optional callback to show and toggle an edit button
+ on_edit_toggle: Option<Callback<bool>>,
}
impl DashboardStatusRow {
@@ -40,12 +46,14 @@ impl DashboardStatusRow {
pub enum Msg {
/// The bool denotes if the reload comes from the click or the timer.
Reload(bool),
+ Edit(bool),
}
#[doc(hidden)]
pub struct PdmDashboardStatusRow {
_interval: Interval,
loading: bool,
+ edit: bool,
}
impl PdmDashboardStatusRow {
@@ -70,6 +78,7 @@ impl Component for PdmDashboardStatusRow {
Self {
_interval: Self::create_interval(ctx),
loading: false,
+ edit: false,
}
}
@@ -81,6 +90,10 @@ impl Component for PdmDashboardStatusRow {
self.loading = true;
true
}
+ Msg::Edit(edit) => {
+ self.edit = edit;
+ true
+ }
}
}
@@ -121,6 +134,20 @@ impl Component for PdmDashboardStatusRow {
None => tr!("Now refreshing"),
}))
.with_flex_spacer()
+ .with_optional_child(props.on_edit_toggle.clone().map(|on_edit_toggle| {
+ let (icon, tooltip, new_value) = if self.edit {
+ ("fa fa-check", tr!("Finish Editing"), false)
+ } else {
+ ("fa fa-pencil", tr!("Edit"), true)
+ };
+ Tooltip::new(ActionIcon::new(icon).tabindex(0).on_activate({
+ ctx.link().callback(move |_| {
+ on_edit_toggle.emit(new_value);
+ Msg::Edit(new_value)
+ })
+ }))
+ .tip(tooltip)
+ }))
.with_child(
Tooltip::new(
ActionIcon::new("fa fa-cogs")
diff --git a/ui/src/dashboard/view.rs b/ui/src/dashboard/view.rs
index fb46064c..5239878d 100644
--- a/ui/src/dashboard/view.rs
+++ b/ui/src/dashboard/view.rs
@@ -20,9 +20,7 @@ use crate::dashboard::refresh_config_edit::{
};
use crate::dashboard::tasks::get_task_options;
use crate::dashboard::types::RowWidget;
-use crate::dashboard::types::{
- LeaderboardType, TaskSummaryGrouping, ViewLayout, ViewTemplate, WidgetType,
-};
+use crate::dashboard::types::{TaskSummaryGrouping, ViewLayout, ViewTemplate, WidgetType};
use crate::dashboard::{
create_guest_panel, create_node_panel, create_pbs_datastores_panel,
create_refresh_config_edit_window, create_remote_panel, create_sdn_panel,
@@ -41,6 +39,8 @@ use pdm_client::types::TopEntities;
mod row_view;
pub use row_view::RowView;
+mod row_element;
+
#[derive(Properties, PartialEq)]
pub struct View {
view: AttrValue,
@@ -74,6 +74,8 @@ pub enum Msg {
Reload(bool), // force
ConfigWindow(bool), // show
UpdateConfig(RefreshConfig),
+ EditMode(bool),
+ LayoutUpdate(ViewLayout),
}
struct ViewComp {
@@ -92,6 +94,8 @@ struct ViewComp {
load_finished_time: Option<f64>,
show_config_window: bool,
show_create_wizard: Option<RemoteType>,
+
+ edit_mode: bool,
}
fn render_widget(
@@ -271,6 +275,8 @@ impl Component for ViewComp {
loading: true,
show_config_window: false,
show_create_wizard: None,
+
+ edit_mode: false,
}
}
@@ -326,6 +332,15 @@ impl Component for ViewComp {
self.show_config_window = false;
}
+ Msg::EditMode(edit) => {
+ self.edit_mode = edit;
+ }
+ Msg::LayoutUpdate(view_layout) => {
+ // FIXME: update backend layout
+ if let Some(template) = &mut self.template.data {
+ template.layout = view_layout;
+ }
+ }
}
true
}
@@ -345,46 +360,56 @@ impl Component for ViewComp {
}
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)),
+ )
+ .on_edit_toggle(ctx.link().callback(Msg::EditMode)),
+ ),
);
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(),
+ )
+ }
+ })
+ .edit_mode(self.edit_mode)
+ .on_update_layout(ctx.link().callback(Msg::LayoutUpdate)),
+ );
}
None => {}
}
@@ -485,6 +510,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..adf7733f 100644
--- a/ui/src/dashboard/view/row_view.rs
+++ b/ui/src/dashboard/view/row_view.rs
@@ -1,21 +1,37 @@
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;
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::widget::menu::{Menu, MenuButton, MenuItem};
+use pwt::widget::{Button, Column, Container, Row};
use pwt_macros::builder;
-use crate::dashboard::types::RowWidget;
+use crate::dashboard::types::{RowWidget, ViewLayout, WidgetType};
+use crate::dashboard::view::row_element::RowElement;
+
+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]
+ edit_mode: bool,
+
+ #[prop_or_default]
+ #[builder_cb(IntoEventCallback, into_event_callback, ViewLayout)]
+ on_update_layout: Option<Callback<ViewLayout>>,
}
impl RowView {
@@ -33,6 +49,21 @@ impl From<RowView> for VNode {
}
}
+pub enum DragMsg {
+ Start(Position),
+ End,
+ Enter(Position),
+ EnterDebounced(Position),
+}
+pub enum Msg {
+ DragEvent(DragMsg),
+ AddRow,
+ RemoveRow(usize), // idx
+ EditFlex(Position, u32),
+ AddWidget(Position, WidgetType),
+ RemoveWidget(Position),
+}
+
#[derive(Clone, Copy, Debug, PartialEq)]
/// Represents the position of a widget in a row view
pub struct Position {
@@ -42,6 +73,12 @@ 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
}
fn extract_row_layout(rows: &Vec<Vec<RowWidget>>) -> Vec<Vec<(Position, RowWidget)>> {
@@ -65,7 +102,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 +112,109 @@ impl Component for RowViewComp {
for (row_idx, row) in current_layout.iter().enumerate() {
next_row_indices.insert(row_idx, row.len());
}
- Self { current_layout }
+ Self {
+ new_layout: None,
+ current_layout,
+ dragging: None,
+ dragging_target: None,
+ drag_timeout: None,
+ next_row_indices,
+ }
+ }
+
+ 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::Enter(coords) => {
+ let link = ctx.link().clone();
+ self.drag_timeout = Some(Timeout::new(100, move || {
+ link.send_message(Msg::DragEvent(DragMsg::EnterDebounced(coords)));
+ }));
+ }
+ DragMsg::EnterDebounced(coords) => {
+ // FIXME: only change when item is in correct place,
+ // e.g. when the mouse position is such that it's inside the space
+ // where the item would land
+ 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);
+ }
+ }
+ true
}
fn changed(&mut self, ctx: &Context<Self>, old_props: &Self::Properties) -> bool {
let props = ctx.props();
-
+ if !props.edit_mode && old_props.edit_mode {
+ if let Some(on_update_layout) = &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 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;
+ }
+ if !props.edit_mode {
+ self.dragging = None;
+ self.dragging_target = None;
+ self.drag_timeout = None;
+ }
}
true
@@ -90,8 +222,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() && props.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 +239,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 +247,251 @@ 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(props.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", props.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(props.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)))
+ // necessary for drop event to trigger
+ .ondragover(|event: DragEvent| event.prevent_default())
+ .ondragenter(
+ ctx.link()
+ .callback(move |_| Msg::DragEvent(DragMsg::Enter(current_coords))),
+ )
+ .onpointerenter(
+ (self.dragging.is_some() && props.edit_mode).then_some(
+ ctx.link()
+ .callback(move |_| Msg::DragEvent(DragMsg::Enter(current_coords))),
+ ),
+ )
+ .ondrop(ctx.link().callback(|event: DragEvent| {
+ event.prevent_default();
+ Msg::DragEvent(DragMsg::End)
+ }));
row.add_child(row_element);
}
+ if props.edit_mode {
+ let drop_coords = Position {
+ row: row_idx,
+ item: items.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() && props.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)),
+ ),
+ ),
+ ),
+ );
+ }
row.add_child(
Container::new()
.key(format!("spacer-{row_idx}"))
.style("flex", "1 1 100%"),
);
}
-
+ if props.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()
}
}
+
+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
prev parent reply other threads:[~2025-10-31 12:48 UTC|newest]
Thread overview: 22+ messages / expand[flat|nested] mbox.gz Atom feed top
2025-10-31 12:43 [pdm-devel] [PATCH datacenter-manager v3 00/21] prepare ui for customizable views Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 01/21] ui: dashboard: refactor guest panel creation to its own module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 02/21] ui: dashboard: refactor creating the node panel into " Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 03/21] ui: dashboard: node panel: make remote type optional Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 04/21] ui: dashboard: refactor remote panel creation into its own module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 05/21] ui: dashboard: remote panel: make wizard menu optional Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 06/21] ui: dashboard: refactor sdn panel creation into its own module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 07/21] ui: dashboard: refactor task summary panel creation to " Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 08/21] ui: dashboard: task summary: disable virtual scrolling Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 09/21] ui: dashboard: refactor subscription panel creation to its own module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 10/21] ui: dashboard: refactor top entities " Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 11/21] ui: dashboard: refactor DashboardConfig editing/constants to their module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 12/21] ui: dashboard: factor out task parameter calculation Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 13/21] ui: dashboard: pbs datastores panel: refactor creation into own module Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 14/21] ui: dashboard: remove unused remote list Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 15/21] ui: dashboard: status row: make loading less jarring Dominik Csapak
2025-10-31 12:43 ` [pdm-devel] [PATCH datacenter-manager v3 16/21] ui: introduce `LoadResult` helper type Dominik Csapak
2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 17/21] ui: dashboard: implement 'View' Dominik Csapak
2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 18/21] ui: dashboard: use 'View' instead of the Dashboard Dominik Csapak
2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 19/21] ui: dashboard: subscription info: move subscription loading to view Dominik Csapak
2025-10-31 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 20/21] ui: dashboard: use SharedState for create_*_panel Dominik Csapak
2025-10-31 12:44 ` Dominik Csapak [this message]
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=20251031124822.2739685-22-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.