mirror of
https://github.com/we-promise/sure.git
synced 2026-04-19 12:04:08 +00:00
feat: implement expandable view for cashflow sankey chart (#739)
* 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 <juanjo.mata@gmail.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
@@ -33,7 +33,7 @@ class DS::Dialog < DesignSystemComponent
|
|||||||
end
|
end
|
||||||
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
|
VARIANTS = %w[modal drawer].freeze
|
||||||
WIDTHS = {
|
WIDTHS = {
|
||||||
@@ -43,13 +43,14 @@ class DS::Dialog < DesignSystemComponent
|
|||||||
full: "lg:max-w-full"
|
full: "lg:max-w-full"
|
||||||
}.freeze
|
}.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
|
@variant = variant.to_sym
|
||||||
@auto_open = auto_open
|
@auto_open = auto_open
|
||||||
@reload_on_close = reload_on_close
|
@reload_on_close = reload_on_close
|
||||||
@width = width.to_sym
|
@width = width.to_sym
|
||||||
@frame = frame
|
@frame = frame
|
||||||
@disable_frame = disable_frame
|
@disable_frame = disable_frame
|
||||||
|
@content_class = content_class
|
||||||
@disable_click_outside = disable_click_outside
|
@disable_click_outside = disable_click_outside
|
||||||
@opts = opts
|
@opts = opts
|
||||||
end
|
end
|
||||||
@@ -92,7 +93,8 @@ class DS::Dialog < DesignSystemComponent
|
|||||||
|
|
||||||
class_names(
|
class_names(
|
||||||
"flex flex-col bg-container rounded-xl shadow-border-xs mx-3 lg:mx-0 w-full overflow-hidden",
|
"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
|
end
|
||||||
|
|
||||||
@@ -104,7 +106,7 @@ class DS::Dialog < DesignSystemComponent
|
|||||||
data[:DS__dialog_auto_open_value] = auto_open
|
data[:DS__dialog_auto_open_value] = auto_open
|
||||||
data[:DS__dialog_reload_on_close_value] = reload_on_close
|
data[:DS__dialog_reload_on_close_value] = reload_on_close
|
||||||
data[:DS__dialog_disable_click_outside_value] = disable_click_outside
|
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"
|
data[:hotkey] = "esc:DS--dialog#close"
|
||||||
merged_opts[:data] = data
|
merged_opts[:data] = data
|
||||||
|
|
||||||
|
|||||||
23
app/javascript/controllers/cashflow_expand_controller.js
Normal file
23
app/javascript/controllers/cashflow_expand_controller.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -77,6 +77,9 @@ export default class extends Controller {
|
|||||||
);
|
);
|
||||||
if (!section) return;
|
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.pendingSection = section;
|
||||||
this.touchStartY = event.touches[0].clientY;
|
this.touchStartY = event.touches[0].clientY;
|
||||||
this.currentTouchY = this.touchStartY;
|
this.currentTouchY = this.touchStartY;
|
||||||
|
|||||||
@@ -45,6 +45,9 @@ export default class extends Controller {
|
|||||||
const { nodes = [], links = [] } = this.dataValue || {};
|
const { nodes = [], links = [] } = this.dataValue || {};
|
||||||
if (!nodes.length || !links.length) return;
|
if (!nodes.length || !links.length) return;
|
||||||
|
|
||||||
|
// Hide tooltip and reset any hover states before redrawing
|
||||||
|
this.#hideTooltip();
|
||||||
|
|
||||||
d3.select(this.element).selectAll("svg").remove();
|
d3.select(this.element).selectAll("svg").remove();
|
||||||
|
|
||||||
const width = this.element.clientWidth || 600;
|
const width = this.element.clientWidth || 600;
|
||||||
@@ -221,7 +224,7 @@ export default class extends Controller {
|
|||||||
.style("cursor", "default")
|
.style("cursor", "default")
|
||||||
.style("opacity", d => hiddenLabels.has(d.index) ? 0 : 1)
|
.style("opacity", d => hiddenLabels.has(d.index) ? 0 : 1)
|
||||||
.style("transition", "opacity 0.2s ease")
|
.style("transition", "opacity 0.2s ease")
|
||||||
.each(function(d) {
|
.each(function (d) {
|
||||||
const textEl = d3.select(this);
|
const textEl = d3.select(this);
|
||||||
textEl.selectAll("tspan").remove();
|
textEl.selectAll("tspan").remove();
|
||||||
|
|
||||||
@@ -241,7 +244,9 @@ export default class extends Controller {
|
|||||||
// Calculate which labels should be hidden to prevent overlap
|
// Calculate which labels should be hidden to prevent overlap
|
||||||
#calculateHiddenLabels(nodes) {
|
#calculateHiddenLabels(nodes) {
|
||||||
const hiddenLabels = new Set();
|
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)
|
// Group nodes by column (using depth which d3-sankey assigns)
|
||||||
const columns = new Map();
|
const columns = new Map();
|
||||||
@@ -260,8 +265,11 @@ export default class extends Controller {
|
|||||||
|
|
||||||
columnNodes.forEach(node => {
|
columnNodes.forEach(node => {
|
||||||
const nodeY = (node.y0 + node.y1) / 2;
|
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
|
// Too close to previous visible label, hide this one
|
||||||
hiddenLabels.add(node.index);
|
hiddenLabels.add(node.index);
|
||||||
} else {
|
} else {
|
||||||
@@ -338,7 +346,8 @@ export default class extends Controller {
|
|||||||
// Tooltip methods
|
// Tooltip methods
|
||||||
|
|
||||||
#createTooltip() {
|
#createTooltip() {
|
||||||
this.tooltip = d3.select("body")
|
const dialog = this.element.closest("dialog");
|
||||||
|
this.tooltip = d3.select(dialog || document.body)
|
||||||
.append("div")
|
.append("div")
|
||||||
.attr("class", "bg-gray-700 text-white text-sm p-2 rounded pointer-events-none absolute z-50")
|
.attr("class", "bg-gray-700 text-white text-sm p-2 rounded pointer-events-none absolute z-50")
|
||||||
.style("opacity", 0)
|
.style("opacity", 0)
|
||||||
@@ -348,32 +357,44 @@ export default class extends Controller {
|
|||||||
#showTooltip(event, value, percentage, title = null) {
|
#showTooltip(event, value, percentage, title = null) {
|
||||||
if (!this.tooltip) this.#createTooltip();
|
if (!this.tooltip) this.#createTooltip();
|
||||||
|
|
||||||
const formattedValue = this.#formatCurrency(value);
|
|
||||||
const percentageText = percentage ? `${percentage}%` : "0%";
|
|
||||||
const content = title
|
const content = title
|
||||||
? `${title}<br/>${formattedValue} (${percentageText})`
|
? `${title}<br/>${this.#formatCurrency(value)} (${percentage || 0}%)`
|
||||||
: `${formattedValue} (${percentageText})`;
|
: `${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
|
this.tooltip
|
||||||
.html(content)
|
.html(content)
|
||||||
.style("left", `${event.pageX + 10}px`)
|
.style("position", isInDialog ? "fixed" : "absolute")
|
||||||
.style("top", `${event.pageY - 10}px`)
|
.style("left", `${x + 10}px`)
|
||||||
|
.style("top", `${y - 10}px`)
|
||||||
.transition()
|
.transition()
|
||||||
.duration(100)
|
.duration(100)
|
||||||
.style("opacity", 1);
|
.style("opacity", 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
#updateTooltipPosition(event) {
|
#updateTooltipPosition(event) {
|
||||||
this.tooltip
|
if (this.tooltip) {
|
||||||
?.style("left", `${event.pageX + 10}px`)
|
const isInDialog = !!this.element.closest("dialog");
|
||||||
.style("top", `${event.pageY - 10}px`);
|
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() {
|
#hideTooltip() {
|
||||||
this.tooltip
|
if (this.tooltip) {
|
||||||
?.transition()
|
this.tooltip
|
||||||
.duration(100)
|
?.transition()
|
||||||
.style("opacity", 0);
|
.duration(100)
|
||||||
|
.style("opacity", 0)
|
||||||
|
.style("pointer-events", "none");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#formatCurrency(value) {
|
#formatCurrency(value) {
|
||||||
|
|||||||
@@ -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"
|
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-dashboard-sortable-target="section"
|
||||||
data-section-key="<%= section[:key] %>"
|
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-section-key-value="<%= section[:key] %>"
|
||||||
data-dashboard-section-collapsed-value="<%= Current.user.dashboard_section_collapsed?(section[:key]) %>"
|
data-dashboard-section-collapsed-value="<%= Current.user.dashboard_section_collapsed?(section[:key]) %>"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@@ -65,12 +65,23 @@
|
|||||||
<%= t(section[:title]) %>
|
<%= t(section[:title]) %>
|
||||||
</h2>
|
</h2>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<div class="flex items-center gap-1">
|
||||||
type="button"
|
<% if section[:key] == "cashflow_sankey" && section[:locals][:sankey_data][:links].present? %>
|
||||||
class="cursor-grab active:cursor-grabbing text-secondary hover:text-primary transition-colors p-0.5 opacity-0 group-hover:opacity-100"
|
<button
|
||||||
aria-label="<%= t("pages.dashboard.drag_to_reorder") %>">
|
type="button"
|
||||||
<%= icon("grip-vertical", size: "sm") %>
|
class="text-secondary hover:text-primary transition-colors opacity-100 lg:opacity-0 lg:group-hover:opacity-100 flex items-center justify-center w-5 h-5 ml-auto lg:ml-0"
|
||||||
</button>
|
data-action="click->cashflow-expand#open"
|
||||||
|
aria-label="<%= t("global.expand") %>">
|
||||||
|
<%= icon("maximize-2", size: "sm", class: "!w-3.5 !h-3.5") %>
|
||||||
|
</button>
|
||||||
|
<% end %>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="cursor-grab active:cursor-grabbing text-secondary hover:text-primary transition-colors p-0.5 opacity-0 group-hover:opacity-100 hidden lg:block"
|
||||||
|
aria-label="<%= t("pages.dashboard.drag_to_reorder") %>">
|
||||||
|
<%= icon("grip-vertical", size: "sm") %>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="py-4" data-dashboard-section-target="content">
|
<div class="py-4" data-dashboard-section-target="content">
|
||||||
<%= render partial: section[:partial], locals: section[:locals] %>
|
<%= render partial: section[:partial], locals: section[:locals] %>
|
||||||
|
|||||||
@@ -18,6 +18,19 @@
|
|||||||
data-sankey-chart-currency-symbol-value="<%= sankey_data[:currency_symbol] %>"
|
data-sankey-chart-currency-symbol-value="<%= sankey_data[:currency_symbol] %>"
|
||||||
class="w-full h-full"></div>
|
class="w-full h-full"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<%= 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 %>
|
||||||
|
<div class="w-full h-[85dvh] max-h-[90dvh] overflow-y-auto overscroll-contain">
|
||||||
|
<div
|
||||||
|
data-controller="sankey-chart"
|
||||||
|
data-sankey-chart-data-value="<%= sankey_data.to_json %>"
|
||||||
|
data-sankey-chart-currency-symbol-value="<%= sankey_data[:currency_symbol] %>"
|
||||||
|
class="w-full h-full"></div>
|
||||||
|
</div>
|
||||||
|
<% end %>
|
||||||
|
<% end %>
|
||||||
<% else %>
|
<% else %>
|
||||||
<div class="h-[300px] lg:h-[340px] bg-container py-4 flex flex-col items-center justify-center">
|
<div class="h-[300px] lg:h-[340px] bg-container py-4 flex flex-col items-center justify-center">
|
||||||
<div class="space-y-3 text-center flex flex-col items-center">
|
<div class="space-y-3 text-center flex flex-col items-center">
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ en:
|
|||||||
defaults:
|
defaults:
|
||||||
brand_name: "%{brand_name}"
|
brand_name: "%{brand_name}"
|
||||||
product_name: "%{product_name}"
|
product_name: "%{product_name}"
|
||||||
|
global:
|
||||||
|
expand: "Expand"
|
||||||
activerecord:
|
activerecord:
|
||||||
errors:
|
errors:
|
||||||
messages:
|
messages:
|
||||||
|
|||||||
Reference in New Issue
Block a user