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
next prev parent 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