From 0c6d208ef2b9efd6c5131b053a5a6dd5e2f259c8 Mon Sep 17 00:00:00 2001 From: Number Eight <55629655+CylonN8@users.noreply.github.com> Date: Fri, 23 Jan 2026 09:52:15 +0100 Subject: [PATCH] feat: implement expandable view for cashflow sankey chart (#739) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: implement expandable view for cashflow sankey chart * refactor: migrate cashflow dialog sizing to tailwind utilities * refactor: declarative draggable restore on cashflow dialog close * refactor: localized title and use Tailwind utilities * refactor: update dialog interaction especially on mobile * refactor: add global expand text to localization * fix: restore draggable immediately after dialog close * Whitespace noise --------- Signed-off-by: Juan José Mata Co-authored-by: Juan José Mata --- app/components/DS/dialog.rb | 10 ++-- .../controllers/cashflow_expand_controller.js | 23 ++++++++ .../dashboard_sortable_controller.js | 3 + .../controllers/sankey_chart_controller.js | 55 +++++++++++++------ app/views/pages/dashboard.html.erb | 25 ++++++--- .../pages/dashboard/_cashflow_sankey.html.erb | 13 +++++ config/locales/defaults/en.yml | 2 + 7 files changed, 103 insertions(+), 28 deletions(-) create mode 100644 app/javascript/controllers/cashflow_expand_controller.js diff --git a/app/components/DS/dialog.rb b/app/components/DS/dialog.rb index 11fce8f02..a8dba3d0d 100644 --- a/app/components/DS/dialog.rb +++ b/app/components/DS/dialog.rb @@ -33,7 +33,7 @@ class DS::Dialog < DesignSystemComponent end end - attr_reader :variant, :auto_open, :reload_on_close, :width, :disable_frame, :disable_click_outside, :opts + attr_reader :variant, :auto_open, :reload_on_close, :width, :disable_frame, :content_class, :disable_click_outside, :opts VARIANTS = %w[modal drawer].freeze WIDTHS = { @@ -43,13 +43,14 @@ class DS::Dialog < DesignSystemComponent full: "lg:max-w-full" }.freeze - def initialize(variant: "modal", auto_open: true, reload_on_close: false, width: "md", frame: nil, disable_frame: false, disable_click_outside: false, **opts) + def initialize(variant: "modal", auto_open: true, reload_on_close: false, width: "md", frame: nil, disable_frame: false, content_class: nil, disable_click_outside: false, **opts) @variant = variant.to_sym @auto_open = auto_open @reload_on_close = reload_on_close @width = width.to_sym @frame = frame @disable_frame = disable_frame + @content_class = content_class @disable_click_outside = disable_click_outside @opts = opts end @@ -92,7 +93,8 @@ class DS::Dialog < DesignSystemComponent class_names( "flex flex-col bg-container rounded-xl shadow-border-xs mx-3 lg:mx-0 w-full overflow-hidden", - variant_classes + variant_classes, + content_class ) end @@ -104,7 +106,7 @@ class DS::Dialog < DesignSystemComponent data[:DS__dialog_auto_open_value] = auto_open data[:DS__dialog_reload_on_close_value] = reload_on_close data[:DS__dialog_disable_click_outside_value] = disable_click_outside - data[:action] = [ "mousedown->DS--dialog#clickOutside", data[:action] ].compact.join(" ") + data[:action] = [ "click->DS--dialog#clickOutside", data[:action] ].compact.join(" ") data[:hotkey] = "esc:DS--dialog#close" merged_opts[:data] = data diff --git a/app/javascript/controllers/cashflow_expand_controller.js b/app/javascript/controllers/cashflow_expand_controller.js new file mode 100644 index 000000000..a14a3608e --- /dev/null +++ b/app/javascript/controllers/cashflow_expand_controller.js @@ -0,0 +1,23 @@ +import { Controller } from "@hotwired/stimulus"; + +export default class extends Controller { + open() { + const dialog = this.element.querySelector("dialog"); + if (!dialog) return; + + if (typeof this.originalDraggable === "undefined") { + this.originalDraggable = this.element.getAttribute("draggable"); + } + this.element.setAttribute("draggable", "false"); + + dialog.showModal(); + } + + restore() { + if (this.originalDraggable === undefined) return; + this.originalDraggable + ? this.element.setAttribute("draggable", this.originalDraggable) + : this.element.removeAttribute("draggable"); + this.originalDraggable = undefined; + } +} diff --git a/app/javascript/controllers/dashboard_sortable_controller.js b/app/javascript/controllers/dashboard_sortable_controller.js index bb100ab7b..6ce64b470 100644 --- a/app/javascript/controllers/dashboard_sortable_controller.js +++ b/app/javascript/controllers/dashboard_sortable_controller.js @@ -77,6 +77,9 @@ export default class extends Controller { ); if (!section) return; + // Respect strict draggable="false" which might be set by other controllers (e.g. expand-controller) + if (section.getAttribute("draggable") === "false") return; + this.pendingSection = section; this.touchStartY = event.touches[0].clientY; this.currentTouchY = this.touchStartY; diff --git a/app/javascript/controllers/sankey_chart_controller.js b/app/javascript/controllers/sankey_chart_controller.js index f97714ac9..5a1b9bb44 100644 --- a/app/javascript/controllers/sankey_chart_controller.js +++ b/app/javascript/controllers/sankey_chart_controller.js @@ -45,6 +45,9 @@ export default class extends Controller { const { nodes = [], links = [] } = this.dataValue || {}; if (!nodes.length || !links.length) return; + // Hide tooltip and reset any hover states before redrawing + this.#hideTooltip(); + d3.select(this.element).selectAll("svg").remove(); const width = this.element.clientWidth || 600; @@ -221,7 +224,7 @@ export default class extends Controller { .style("cursor", "default") .style("opacity", d => hiddenLabels.has(d.index) ? 0 : 1) .style("transition", "opacity 0.2s ease") - .each(function(d) { + .each(function (d) { const textEl = d3.select(this); textEl.selectAll("tspan").remove(); @@ -241,7 +244,9 @@ export default class extends Controller { // Calculate which labels should be hidden to prevent overlap #calculateHiddenLabels(nodes) { const hiddenLabels = new Set(); - const minSpacing = this.constructor.MIN_LABEL_SPACING; + const height = this.element.clientHeight || 400; + const isLargeGraph = height > 600; + const minSpacing = isLargeGraph ? this.constructor.MIN_LABEL_SPACING * 0.7 : this.constructor.MIN_LABEL_SPACING; // Group nodes by column (using depth which d3-sankey assigns) const columns = new Map(); @@ -260,8 +265,11 @@ export default class extends Controller { columnNodes.forEach(node => { const nodeY = (node.y0 + node.y1) / 2; + const nodeHeight = node.y1 - node.y0; - if (nodeY - lastVisibleY < minSpacing) { + if (isLargeGraph && nodeHeight > minSpacing * 1.5) { + lastVisibleY = nodeY; + } else if (nodeY - lastVisibleY < minSpacing) { // Too close to previous visible label, hide this one hiddenLabels.add(node.index); } else { @@ -338,7 +346,8 @@ export default class extends Controller { // Tooltip methods #createTooltip() { - this.tooltip = d3.select("body") + const dialog = this.element.closest("dialog"); + this.tooltip = d3.select(dialog || document.body) .append("div") .attr("class", "bg-gray-700 text-white text-sm p-2 rounded pointer-events-none absolute z-50") .style("opacity", 0) @@ -348,32 +357,44 @@ export default class extends Controller { #showTooltip(event, value, percentage, title = null) { if (!this.tooltip) this.#createTooltip(); - const formattedValue = this.#formatCurrency(value); - const percentageText = percentage ? `${percentage}%` : "0%"; const content = title - ? `${title}
${formattedValue} (${percentageText})` - : `${formattedValue} (${percentageText})`; + ? `${title}
${this.#formatCurrency(value)} (${percentage || 0}%)` + : `${this.#formatCurrency(value)} (${percentage || 0}%)`; + + const isInDialog = !!this.element.closest("dialog"); + const x = isInDialog ? event.clientX : event.pageX; + const y = isInDialog ? event.clientY : event.pageY; this.tooltip .html(content) - .style("left", `${event.pageX + 10}px`) - .style("top", `${event.pageY - 10}px`) + .style("position", isInDialog ? "fixed" : "absolute") + .style("left", `${x + 10}px`) + .style("top", `${y - 10}px`) .transition() .duration(100) .style("opacity", 1); } #updateTooltipPosition(event) { - this.tooltip - ?.style("left", `${event.pageX + 10}px`) - .style("top", `${event.pageY - 10}px`); + if (this.tooltip) { + const isInDialog = !!this.element.closest("dialog"); + const x = isInDialog ? event.clientX : event.pageX; + const y = isInDialog ? event.clientY : event.pageY; + + this.tooltip + ?.style("left", `${x + 10}px`) + .style("top", `${y - 10}px`); + } } #hideTooltip() { - this.tooltip - ?.transition() - .duration(100) - .style("opacity", 0); + if (this.tooltip) { + this.tooltip + ?.transition() + .duration(100) + .style("opacity", 0) + .style("pointer-events", "none"); + } } #formatCurrency(value) { diff --git a/app/views/pages/dashboard.html.erb b/app/views/pages/dashboard.html.erb index 3b51717e8..7ac96bc80 100644 --- a/app/views/pages/dashboard.html.erb +++ b/app/views/pages/dashboard.html.erb @@ -35,7 +35,7 @@ class="bg-container rounded-xl shadow-border-xs transition-all group focus:outline-none focus-visible:ring-2 focus-visible:ring-gray-900 focus-visible:ring-offset-2" data-dashboard-sortable-target="section" data-section-key="<%= section[:key] %>" - data-controller="dashboard-section" + data-controller="dashboard-section<%= ' cashflow-expand' if section[:key] == 'cashflow_sankey' %>" data-dashboard-section-section-key-value="<%= section[:key] %>" data-dashboard-section-collapsed-value="<%= Current.user.dashboard_section_collapsed?(section[:key]) %>" draggable="true" @@ -65,12 +65,23 @@ <%= t(section[:title]) %> - +
+ <% if section[:key] == "cashflow_sankey" && section[:locals][:sankey_data][:links].present? %> + + <% end %> + +
<%= render partial: section[:partial], locals: section[:locals] %> diff --git a/app/views/pages/dashboard/_cashflow_sankey.html.erb b/app/views/pages/dashboard/_cashflow_sankey.html.erb index b47ca5739..ae9904eb9 100644 --- a/app/views/pages/dashboard/_cashflow_sankey.html.erb +++ b/app/views/pages/dashboard/_cashflow_sankey.html.erb @@ -18,6 +18,19 @@ data-sankey-chart-currency-symbol-value="<%= sankey_data[:currency_symbol] %>" class="w-full h-full">
+ + <%= render DS::Dialog.new(id: "cashflow-expanded-dialog", auto_open: false, width: "custom", disable_frame: true, content_class: "!w-[96vw] max-w-[1650px]", data: { action: "close->cashflow-expand#restore" }) do |dialog| %> + <% dialog.with_header(title: t("pages.dashboard.cashflow_sankey.title"), hide_close_icon: false) %> + <% dialog.with_body do %> +
+
+
+ <% end %> + <% end %> <% else %>
diff --git a/config/locales/defaults/en.yml b/config/locales/defaults/en.yml index 665000f18..f2caaa233 100644 --- a/config/locales/defaults/en.yml +++ b/config/locales/defaults/en.yml @@ -3,6 +3,8 @@ en: defaults: brand_name: "%{brand_name}" product_name: "%{product_name}" + global: + expand: "Expand" activerecord: errors: messages: