public inbox for pdm-devel@lists.proxmox.com
 help / color / mirror / Atom feed
From: "Shannon Sterz" <s.sterz@proxmox.com>
To: "Dominik Csapak" <d.csapak@proxmox.com>
Cc: Proxmox Datacenter Manager development discussion
	<pdm-devel@lists.proxmox.com>
Subject: Re: [pdm-devel] [PATCH datacenter-manager v3 08/18] ui: dashboard: prepare view for editing custom views
Date: Mon, 17 Nov 2025 15:59:19 +0100	[thread overview]
Message-ID: <DEB24Z5L4WN2.2OPJ4XAVOFM3J@proxmox.com> (raw)
In-Reply-To: <20251117125041.1931382-9-d.csapak@proxmox.com>

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 <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\"
>                  }
>                ],
> +              [],

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<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 {

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 {
> +        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,
> +}

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<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)))
> +}



_______________________________________________
pdm-devel mailing list
pdm-devel@lists.proxmox.com
https://lists.proxmox.com/cgi-bin/mailman/listinfo/pdm-devel


  reply	other threads:[~2025-11-17 14:59 UTC|newest]

Thread overview: 29+ messages / expand[flat|nested]  mbox.gz  Atom feed  top
2025-11-17 12:44 [pdm-devel] [PATCH datacenter-manager v3 00/18] enable custom views on the UI Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 01/18] lib: pdm-config: views: add locking/saving methods Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 02/18] lib: api-types: add 'layout' property to ViewConfig Dominik Csapak
2025-11-17 14:58   ` Shannon Sterz
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 03/18] server: api: implement CRUD api for views Dominik Csapak
2025-11-17 14:58   ` Shannon Sterz
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 04/18] server: api: resources: add 'view' category to search syntax Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 05/18] ui: remote selector: allow forcing of value Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 06/18] ui: dashboard types: add missing 'default' to de-serialization Dominik Csapak
2025-11-17 14:59   ` Shannon Sterz
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 07/18] ui: dashboard: status row: add optional 'editing state' Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 08/18] ui: dashboard: prepare view for editing custom views Dominik Csapak
2025-11-17 14:59   ` Shannon Sterz [this message]
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 09/18] ui: views: implement view loading from api Dominik Csapak
2025-11-17 14:59   ` Shannon Sterz
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 10/18] ui: views: make 'view' name property optional Dominik Csapak
2025-11-17 14:59   ` Shannon Sterz
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 11/18] ui: views: add 'view' parameter to api calls Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 12/18] ui: views: save updated layout to backend Dominik Csapak
2025-11-17 15:00   ` Shannon Sterz
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 13/18] ui: add view list context Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 14/18] ui: configuration: add view CRUD panels Dominik Csapak
2025-11-17 15:00   ` Shannon Sterz
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 15/18] ui: main menu: add optional view_list property Dominik Csapak
2025-11-17 15:01   ` Shannon Sterz
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 16/18] ui: load view list on page init Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 17/18] lib/ui: move views types to pdm-api-types Dominik Csapak
2025-11-17 12:44 ` [pdm-devel] [PATCH datacenter-manager v3 18/18] server: api: views: check layout string for validity Dominik Csapak
2025-11-17 15:03 ` [pdm-devel] [PATCH datacenter-manager v3 00/18] enable custom views on the UI Shannon Sterz

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=DEB24Z5L4WN2.2OPJ4XAVOFM3J@proxmox.com \
    --to=s.sterz@proxmox.com \
    --cc=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 a public inbox, see mirroring instructions
for how to clone and mirror all data and code used for this inbox
Service provided by Proxmox Server Solutions GmbH | Privacy | Legal