mirror of
https://github.com/we-promise/sure.git
synced 2026-07-19 16:25:24 +00:00
feat(dashboard): masonry packing + per-widget size controls (#2328)
* feat(dashboard): masonry packing + per-widget size controls In two-column mode the dashboard used a row-based CSS grid, so cards stretched to equal row height and left dead space (e.g. the Net Worth chart padded out to match the tall Balance Sheet table). Replace the row-based layout with masonry packing and add per-widget size guardrails. - Masonry: CSS grid with computed row-spans + grid-auto-flow: dense, driven by a dashboard-masonry Stimulus controller (ResizeObserver + turbo:frame-load). The DOM stays a single flat list, so drag/keyboard reorder is unaffected. Active only in multi-column mode; single column falls back to normal flow. - Internal sizing: the net worth chart height is now driven by a --dash-widget-h CSS var (fixes an inert flex-1) so the card no longer pads out below the chart. - Guardrails: per-widget layout metadata (col_span, grow, min_height, width_toggle) in PagesController, with per-user overrides persisted under preferences["dashboard_section_layout"], deep-merged so width and height coexist. - Size menu: a hover control on size-capable cards — Width (Half/Full) for the cashflow sankey and net worth chart, Height (Compact/Auto/Tall) for grow widgets. The sankey defaults to full width. Adds model + controller tests for preference persistence and i18n keys. * refactor(dashboard): redesign size menu with segmented controls The size menu used a plain radio list and a diagonal maximize-2 trigger that collided with the cashflow sankey's modal-expand button. Replace it with a layout-config popover: a sliders-horizontal trigger plus two labeled axis groups (Width, Height), each rendered as a DS::SegmentedControl with the active option filled. Clearer, more compact, and it reads as a card-layout control. The widget-size controller now mirrors the segmented control's active-class + aria-pressed contract. * feat(dashboard): expose width toggle on balance sheet and investments Tables benefit from horizontal room, so give Balance Sheet and Investments the same Width (Half/Full) control as the sankey and net worth chart. Height presets stay chart-only — a table sized to a fixed height would just add whitespace or force scrolling. The Outflows donut is intentionally left out (full width is mostly whitespace for a donut). * fix(dashboard): address review feedback on size controls - Only apply the full-width col-span and show the Width control when the two-column layout is enabled. A full widget previously leaked 2xl:col-span-2 into the single-column grid, creating an implicit second column at 2xl widths and breaking the single-column preference. This also keeps the Width control coherent with the Appearance two-column setting (it now appears only where it does something). [Codex] - Stop size-menu keydowns from bubbling to the section reorder handler, so keyboard users can open the menu and pick options without entering grab/reorder mode. [Codex] - Harden preferences params: ignore a malformed (non-hash) dashboard_section_layout / collapsed_sections instead of raising a 500, and require section_order to be an array. [CodeRabbit] - Localize the dashboard sections aria-label. [CodeRabbit] * chore(settings): mention per-widget size controls in two-column copy Surface the new per-widget width/height controls in the Appearance "Two-column layout" description so the capability is discoverable. * test(dashboard): assert non-mutation for malformed layout input Addresses review feedback: asserting assert_nil made the test depend on the fixture happening to have no dashboard height for net_worth_chart. Capture the pre-PATCH value and assert it is unchanged, so the test stays valid (malformed input ignored) even if the fixture later gets a default height. --------- Signed-off-by: Juan José Mata <juanjo.mata@gmail.com> Co-authored-by: Guillem Arias <guillem.arias@col.vueling.com> Co-authored-by: Juan José Mata <juanjo.mata@gmail.com>
This commit is contained in:
committed by
GitHub
parent
08bfd43775
commit
c29380ce57
@@ -1,6 +1,24 @@
|
||||
class PagesController < ApplicationController
|
||||
include Periodable
|
||||
|
||||
# Per-widget dashboard layout guardrails. Deterministic defaults the masonry
|
||||
# packer reads; users may override a grow widget's height via presets.
|
||||
# col_span: "single" | "full" (full spans both columns in 2-col mode)
|
||||
# grow: true for charts that should fill an allotted height,
|
||||
# false for content-sized widgets (tables, stat grids)
|
||||
# min_height: floor in px
|
||||
DASHBOARD_SECTION_LAYOUTS = {
|
||||
"cashflow_sankey" => { col_span: "full", grow: false, min_height: 384, width_toggle: true },
|
||||
"outflows_donut" => { col_span: "single", grow: false, min_height: 0 },
|
||||
"investment_summary" => { col_span: "single", grow: false, min_height: 0, width_toggle: true },
|
||||
"net_worth_chart" => { col_span: "single", grow: true, min_height: 208, width_toggle: true },
|
||||
"balance_sheet" => { col_span: "single", grow: false, min_height: 0, width_toggle: true }
|
||||
}.freeze
|
||||
|
||||
# Selectable height presets (px) for grow widgets.
|
||||
DASHBOARD_HEIGHT_PRESETS = { "compact" => 208, "auto" => 288, "tall" => 416 }.freeze
|
||||
DEFAULT_HEIGHT_PRESET = "auto"
|
||||
|
||||
skip_authentication only: %i[redis_configuration_error privacy terms]
|
||||
before_action :ensure_intro_guest!, only: :intro
|
||||
|
||||
@@ -78,8 +96,9 @@ class PagesController < ApplicationController
|
||||
def preferences_params
|
||||
prefs = params.require(:preferences)
|
||||
{}.tap do |permitted|
|
||||
permitted["collapsed_sections"] = prefs[:collapsed_sections].to_unsafe_h if prefs[:collapsed_sections]
|
||||
permitted["section_order"] = prefs[:section_order] if prefs[:section_order]
|
||||
permitted["collapsed_sections"] = prefs[:collapsed_sections].to_unsafe_h if prefs[:collapsed_sections].respond_to?(:to_unsafe_h)
|
||||
permitted["section_order"] = prefs[:section_order] if prefs[:section_order].is_a?(Array)
|
||||
permitted["dashboard_section_layout"] = prefs[:dashboard_section_layout].to_unsafe_h if prefs[:dashboard_section_layout].respond_to?(:to_unsafe_h)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -89,6 +108,7 @@ class PagesController < ApplicationController
|
||||
key: "cashflow_sankey",
|
||||
title: "pages.dashboard.cashflow_sankey.title",
|
||||
partial: "pages/dashboard/cashflow_sankey",
|
||||
layout: section_layout("cashflow_sankey"),
|
||||
locals: { sankey_data: @cashflow_sankey_data, period: @period },
|
||||
visible: @accounts.any?,
|
||||
collapsible: true
|
||||
@@ -97,6 +117,7 @@ class PagesController < ApplicationController
|
||||
key: "outflows_donut",
|
||||
title: "pages.dashboard.outflows_donut.title",
|
||||
partial: "pages/dashboard/outflows_donut",
|
||||
layout: section_layout("outflows_donut"),
|
||||
locals: { outflows_data: @outflows_data, period: @period },
|
||||
visible: @accounts.any? && @outflows_data[:categories].present?,
|
||||
collapsible: true
|
||||
@@ -105,6 +126,7 @@ class PagesController < ApplicationController
|
||||
key: "investment_summary",
|
||||
title: "pages.dashboard.investment_summary.title",
|
||||
partial: "pages/dashboard/investment_summary",
|
||||
layout: section_layout("investment_summary"),
|
||||
locals: { investment_statement: @investment_statement, period: @period },
|
||||
visible: @accounts.any? && @investment_statement.investment_accounts.any?,
|
||||
collapsible: true
|
||||
@@ -113,6 +135,7 @@ class PagesController < ApplicationController
|
||||
key: "net_worth_chart",
|
||||
title: "pages.dashboard.net_worth_chart.title",
|
||||
partial: "pages/dashboard/net_worth_chart",
|
||||
layout: section_layout("net_worth_chart"),
|
||||
locals: { balance_sheet: @balance_sheet, period: @period },
|
||||
visible: @accounts.any?,
|
||||
collapsible: true
|
||||
@@ -121,6 +144,7 @@ class PagesController < ApplicationController
|
||||
key: "balance_sheet",
|
||||
title: "pages.dashboard.balance_sheet.title",
|
||||
partial: "pages/dashboard/balance_sheet",
|
||||
layout: section_layout("balance_sheet"),
|
||||
locals: { balance_sheet: @balance_sheet },
|
||||
visible: @accounts.any?,
|
||||
collapsible: true
|
||||
@@ -141,6 +165,22 @@ class PagesController < ApplicationController
|
||||
ordered_sections
|
||||
end
|
||||
|
||||
# Resolves a section's layout guardrails, applying the user's height preset
|
||||
# override (falling back to the deterministic default) for grow widgets.
|
||||
def section_layout(key)
|
||||
base = DASHBOARD_SECTION_LAYOUTS.fetch(key, { col_span: "single", grow: false, min_height: 0, width_toggle: false })
|
||||
preset = Current.user.dashboard_section_height(key)
|
||||
preset = DEFAULT_HEIGHT_PRESET unless DASHBOARD_HEIGHT_PRESETS.key?(preset)
|
||||
|
||||
col_span = base[:col_span]
|
||||
if base[:width_toggle]
|
||||
user_span = Current.user.dashboard_section_width(key)
|
||||
col_span = user_span if %w[single full].include?(user_span)
|
||||
end
|
||||
|
||||
base.merge(col_span: col_span, height_preset: preset, height_px: DASHBOARD_HEIGHT_PRESETS.fetch(preset))
|
||||
end
|
||||
|
||||
def github_provider
|
||||
Provider::Registry.get_provider(:github)
|
||||
end
|
||||
|
||||
82
app/javascript/controllers/dashboard_masonry_controller.js
Normal file
82
app/javascript/controllers/dashboard_masonry_controller.js
Normal file
@@ -0,0 +1,82 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Packs variable-height dashboard cards into a tight masonry layout by computing
|
||||
// each card's grid row span from its measured height, letting `grid-auto-flow:
|
||||
// dense` fill the gaps. Active only when the grid is actually multi-column; in
|
||||
// single-column mode it clears the spans and normal block flow takes over.
|
||||
//
|
||||
// The DOM stays a single flat list of <section data-section-key> children, so
|
||||
// the dashboard-sortable controller (drag / keyboard reorder) is unaffected —
|
||||
// reordering just moves a node and the CSS re-packs.
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this._scheduleLayout = this._scheduleLayout.bind(this);
|
||||
this._frame = null;
|
||||
this._resizeObserver = new ResizeObserver(this._scheduleLayout);
|
||||
|
||||
this._cards().forEach((card) => this._resizeObserver.observe(card));
|
||||
this._scheduleLayout();
|
||||
|
||||
// Re-pack after the dashboard turbo frame re-renders (period change, reorder
|
||||
// save) and on viewport changes that may cross the column breakpoint.
|
||||
this.element.addEventListener("turbo:frame-load", this._scheduleLayout);
|
||||
window.addEventListener("resize", this._scheduleLayout);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this._resizeObserver?.disconnect();
|
||||
this.element.removeEventListener("turbo:frame-load", this._scheduleLayout);
|
||||
window.removeEventListener("resize", this._scheduleLayout);
|
||||
if (this._frame) cancelAnimationFrame(this._frame);
|
||||
this._cards().forEach((card) => {
|
||||
card.style.gridRowEnd = "";
|
||||
delete card.dataset.masonrySpan;
|
||||
});
|
||||
}
|
||||
|
||||
_cards() {
|
||||
return Array.from(
|
||||
this.element.querySelectorAll(":scope > [data-section-key]"),
|
||||
);
|
||||
}
|
||||
|
||||
_scheduleLayout() {
|
||||
if (this._frame) cancelAnimationFrame(this._frame);
|
||||
this._frame = requestAnimationFrame(() => this._layout());
|
||||
}
|
||||
|
||||
_layout() {
|
||||
const styles = getComputedStyle(this.element);
|
||||
const columns = styles.gridTemplateColumns
|
||||
.split(" ")
|
||||
.filter(Boolean).length;
|
||||
|
||||
// Single column: let natural block flow handle it, clear any stale spans.
|
||||
if (columns < 2) {
|
||||
this._cards().forEach((card) => {
|
||||
if (card.dataset.masonrySpan) {
|
||||
card.style.gridRowEnd = "";
|
||||
delete card.dataset.masonrySpan;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// The row gap is zeroed in masonry mode (2xl:gap-y-0). We reproduce a uniform
|
||||
// inter-card gap by padding each card's span with empty tracks equal to the
|
||||
// column gap. Integer row-spans over a non-zero row gap otherwise overshoot
|
||||
// by up to one gap's worth of slack, giving visibly uneven vertical gaps.
|
||||
const rowUnit = Number.parseFloat(styles.gridAutoRows) || 1;
|
||||
const gap = Number.parseFloat(styles.columnGap) || 0;
|
||||
const gapTracks = Math.round(gap / rowUnit);
|
||||
|
||||
this._cards().forEach((card) => {
|
||||
const height = card.getBoundingClientRect().height;
|
||||
const span = Math.max(1, Math.ceil(height / rowUnit) + gapTracks);
|
||||
if (card.dataset.masonrySpan !== String(span)) {
|
||||
card.style.gridRowEnd = `span ${span}`;
|
||||
card.dataset.masonrySpan = String(span);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
110
app/javascript/controllers/dashboard_widget_size_controller.js
Normal file
110
app/javascript/controllers/dashboard_widget_size_controller.js
Normal file
@@ -0,0 +1,110 @@
|
||||
import { Controller } from "@hotwired/stimulus";
|
||||
|
||||
// Per-widget layout picker (mounted on the <details> menu). Optimistically applies
|
||||
// the choice for instant feedback, then persists it fire-and-forget — mirroring how
|
||||
// collapse / reorder already save dashboard preferences:
|
||||
//
|
||||
// - Height: sets the `--dash-widget-h` CSS var; the chart redraws via its own
|
||||
// ResizeObserver and dashboard-masonry re-packs from the height change.
|
||||
// - Width: toggles the `2xl:col-span-2` class; the CSS grid re-flows and a
|
||||
// synthetic resize nudges dashboard-masonry to re-pack.
|
||||
export default class extends Controller {
|
||||
static values = { sectionKey: String };
|
||||
|
||||
connect() {
|
||||
this._closeOnOutsideClick = this._closeOnOutsideClick.bind(this);
|
||||
document.addEventListener("click", this._closeOnOutsideClick);
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener("click", this._closeOnOutsideClick);
|
||||
}
|
||||
|
||||
// Keep Enter/Space/arrow keydowns inside the menu from bubbling to the
|
||||
// section-level dashboard-sortable handler, which would otherwise hijack them
|
||||
// to toggle keyboard reorder mode.
|
||||
stopKeydown(event) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
selectHeight(event) {
|
||||
const { preset, height } = event.currentTarget.dataset;
|
||||
const section = this._section();
|
||||
if (section && height) {
|
||||
section.style.setProperty("--dash-widget-h", `${height}px`);
|
||||
}
|
||||
this._markSelected(event.currentTarget);
|
||||
this.element.open = false;
|
||||
this._save({ height: preset });
|
||||
}
|
||||
|
||||
selectWidth(event) {
|
||||
const { colSpan } = event.currentTarget.dataset;
|
||||
const section = this._section();
|
||||
if (section) {
|
||||
section.classList.toggle("2xl:col-span-2", colSpan === "full");
|
||||
}
|
||||
this._markSelected(event.currentTarget);
|
||||
this.element.open = false;
|
||||
this._save({ col_span: colSpan });
|
||||
// Width change re-flows the grid; nudge dashboard-masonry to re-pack.
|
||||
window.dispatchEvent(new Event("resize"));
|
||||
}
|
||||
|
||||
_section() {
|
||||
return this.element.closest("[data-section-key]");
|
||||
}
|
||||
|
||||
// Move the active state to the clicked segment within its segmented control,
|
||||
// mirroring DS::SegmentedControl's class + aria-pressed contract.
|
||||
_markSelected(button) {
|
||||
const group = button.closest(".segmented-control");
|
||||
if (!group) return;
|
||||
group.querySelectorAll(".segmented-control__segment").forEach((el) => {
|
||||
const on = el === button;
|
||||
el.classList.toggle("segmented-control__segment--active", on);
|
||||
el.setAttribute("aria-pressed", on ? "true" : "false");
|
||||
});
|
||||
}
|
||||
|
||||
_closeOnOutsideClick(event) {
|
||||
if (this.element.open && !this.element.contains(event.target)) {
|
||||
this.element.open = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _save(payload) {
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]');
|
||||
if (!csrfToken) {
|
||||
console.error("[Dashboard Widget Size] CSRF token not found.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch("/dashboard/preferences", {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"X-CSRF-Token": csrfToken.content,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
preferences: {
|
||||
dashboard_section_layout: { [this.sectionKeyValue]: payload },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(
|
||||
"[Dashboard Widget Size] Failed to save layout:",
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(
|
||||
"[Dashboard Widget Size] Network error saving layout:",
|
||||
error,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,6 +314,16 @@ class User < ApplicationRecord
|
||||
preferences&.[]("section_order") || default_dashboard_section_order
|
||||
end
|
||||
|
||||
# Per-widget height preset override ("compact" | "auto" | "tall"); nil = use default.
|
||||
def dashboard_section_height(section_key)
|
||||
preferences&.dig("dashboard_section_layout", section_key, "height")
|
||||
end
|
||||
|
||||
# Per-widget column-span override ("single" | "full"); nil = use default.
|
||||
def dashboard_section_width(section_key)
|
||||
preferences&.dig("dashboard_section_layout", section_key, "col_span")
|
||||
end
|
||||
|
||||
def update_dashboard_preferences(prefs)
|
||||
# Use pessimistic locking to ensure atomic read-modify-write
|
||||
# This prevents race conditions when multiple sections are collapsed quickly
|
||||
@@ -324,7 +334,9 @@ class User < ApplicationRecord
|
||||
prefs.each do |key, value|
|
||||
if value.is_a?(Hash)
|
||||
updated_prefs[key] ||= {}
|
||||
updated_prefs[key] = updated_prefs[key].merge(value)
|
||||
# deep_merge so a partial update of one nested dimension (e.g. a widget's
|
||||
# col_span) doesn't clobber a sibling dimension (e.g. its height).
|
||||
updated_prefs[key] = updated_prefs[key].deep_merge(value)
|
||||
else
|
||||
updated_prefs[key] = value
|
||||
end
|
||||
|
||||
@@ -36,12 +36,14 @@
|
||||
</div>
|
||||
<% end %>
|
||||
|
||||
<div class="grid grid-cols-1 <%= "xl:grid-cols-2" if Current.user.dashboard_two_column? %> gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="Dashboard sections">
|
||||
<% two_column = Current.user.dashboard_two_column? %>
|
||||
<div class="grid grid-cols-1 <%= "2xl:grid-cols-2 2xl:items-start 2xl:gap-y-0 2xl:[grid-auto-flow:row_dense] 2xl:[grid-auto-rows:1px]" if two_column %> gap-6 pb-6 lg:pb-12" data-controller="dashboard-sortable dashboard-masonry" data-action="dragover->dashboard-sortable#dragOver drop->dashboard-sortable#drop" role="list" aria-label="<%= t("pages.dashboard.sections_aria_label") %>">
|
||||
<% if accessible_accounts.any? %>
|
||||
<% @dashboard_sections.each do |section| %>
|
||||
<% next unless section[:visible] %>
|
||||
<section
|
||||
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<%= " 2xl:col-span-2" if two_column && section[:layout][:col_span] == "full" %>"
|
||||
style="<%= "--dash-widget-h: #{section[:layout][:height_px]}px;" if section[:layout][:grow] %>"
|
||||
data-dashboard-sortable-target="section"
|
||||
data-section-key="<%= section[:key] %>"
|
||||
data-controller="dashboard-section<%= " cashflow-expand" if section[:key] == "cashflow_sankey" %>"
|
||||
@@ -85,6 +87,57 @@
|
||||
<%= icon("maximize-2", size: "sm", class: "!w-3.5 !h-3.5") %>
|
||||
</button>
|
||||
<% end %>
|
||||
<% layout = section[:layout] %>
|
||||
<% show_width = layout[:width_toggle] && two_column %>
|
||||
<% if layout[:grow] || show_width %>
|
||||
<details
|
||||
class="relative hidden lg:block"
|
||||
data-controller="dashboard-widget-size"
|
||||
data-dashboard-widget-size-section-key-value="<%= section[:key] %>"
|
||||
data-action="keydown->dashboard-widget-size#stopKeydown">
|
||||
<summary
|
||||
class="list-none cursor-pointer text-secondary hover:text-primary transition-colors p-0.5 opacity-0 group-hover:opacity-100 [&::-webkit-details-marker]:hidden"
|
||||
aria-label="<%= t("pages.dashboard.widget_size.label") %>">
|
||||
<%= icon("sliders-horizontal", size: "sm", class: "!w-3.5 !h-3.5") %>
|
||||
</summary>
|
||||
<div class="absolute right-0 top-full mt-1.5 z-10 w-48 p-2.5 space-y-2.5 bg-surface rounded-xl shadow-border-xs border border-primary">
|
||||
<% if show_width %>
|
||||
<div class="space-y-1.5">
|
||||
<p class="flex items-center gap-1.5 text-xs font-medium text-secondary">
|
||||
<%= icon("move-horizontal", size: "xs") %>
|
||||
<%= t("pages.dashboard.widget_size.width_label") %>
|
||||
</p>
|
||||
<%= render DS::SegmentedControl.new(full_width: true, aria_label: t("pages.dashboard.widget_size.width_label")) do |sc| %>
|
||||
<% { "single" => "half", "full" => "full" }.each do |value, i18n_key| %>
|
||||
<% sc.with_segment(
|
||||
t("pages.dashboard.widget_size.#{i18n_key}"),
|
||||
active: layout[:col_span] == value,
|
||||
data: { action: "dashboard-widget-size#selectWidth", col_span: value }
|
||||
) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
<% if layout[:grow] %>
|
||||
<div class="space-y-1.5">
|
||||
<p class="flex items-center gap-1.5 text-xs font-medium text-secondary">
|
||||
<%= icon("move-vertical", size: "xs") %>
|
||||
<%= t("pages.dashboard.widget_size.height_label") %>
|
||||
</p>
|
||||
<%= render DS::SegmentedControl.new(full_width: true, aria_label: t("pages.dashboard.widget_size.height_label")) do |sc| %>
|
||||
<% PagesController::DASHBOARD_HEIGHT_PRESETS.each do |preset, px| %>
|
||||
<% sc.with_segment(
|
||||
t("pages.dashboard.widget_size.#{preset}"),
|
||||
active: layout[:height_preset] == preset,
|
||||
data: { action: "dashboard-widget-size#selectHeight", preset: preset, height: px }
|
||||
) %>
|
||||
<% end %>
|
||||
<% end %>
|
||||
</div>
|
||||
<% end %>
|
||||
</div>
|
||||
</details>
|
||||
<% 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"
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
<% if series.any? %>
|
||||
<div
|
||||
id="netWorthChart"
|
||||
class="w-full flex-1 min-h-52 privacy-sensitive"
|
||||
class="w-full min-h-52 privacy-sensitive"
|
||||
style="height: var(--dash-widget-h, 13rem);"
|
||||
data-controller="time-series-chart"
|
||||
data-time-series-chart-data-value="<%= series.to_json %>"></div>
|
||||
<% else %>
|
||||
|
||||
@@ -41,8 +41,18 @@ en:
|
||||
welcome: "Welcome back, %{name}"
|
||||
subtitle: "Here's what's happening with your finances"
|
||||
new: "New"
|
||||
sections_aria_label: "Dashboard sections"
|
||||
drag_to_reorder: "Drag to reorder section"
|
||||
toggle_section: "Toggle section visibility"
|
||||
widget_size:
|
||||
label: "Adjust size"
|
||||
width_label: "Width"
|
||||
half: "Half"
|
||||
full: "Full"
|
||||
height_label: "Height"
|
||||
compact: "Compact"
|
||||
auto: "Auto"
|
||||
tall: "Tall"
|
||||
net_worth_chart:
|
||||
data_not_available: Data not available for the selected period
|
||||
title: Net Worth
|
||||
|
||||
@@ -112,7 +112,7 @@ en:
|
||||
dashboard_title: Dashboard
|
||||
dashboard_subtitle: Customize how the dashboard is displayed
|
||||
dashboard_two_column_title: Two-column layout
|
||||
dashboard_two_column_description: Display dashboard widgets in two columns on large screens. When off, widgets stack in a single column.
|
||||
dashboard_two_column_description: Display dashboard widgets in two columns on large screens, with per-widget width and height controls in each widget's header. When off, widgets stack in a single column.
|
||||
split_grouped_title: Group split transactions
|
||||
split_grouped_description: Show split transactions grouped under their parent in the transaction list. When off, split children appear as individual rows.
|
||||
preferences:
|
||||
|
||||
@@ -14,6 +14,35 @@ class PagesControllerTest < ActionDispatch::IntegrationTest
|
||||
assert_response :ok
|
||||
end
|
||||
|
||||
test "update_preferences persists dashboard section layout height" do
|
||||
patch "/dashboard/preferences", params: {
|
||||
preferences: { dashboard_section_layout: { net_worth_chart: { height: "compact" } } }
|
||||
}, as: :json
|
||||
|
||||
assert_response :ok
|
||||
assert_equal "compact", @user.reload.dashboard_section_height("net_worth_chart")
|
||||
end
|
||||
|
||||
test "update_preferences persists dashboard section width" do
|
||||
patch "/dashboard/preferences", params: {
|
||||
preferences: { dashboard_section_layout: { cashflow_sankey: { col_span: "single" } } }
|
||||
}, as: :json
|
||||
|
||||
assert_response :ok
|
||||
assert_equal "single", @user.reload.dashboard_section_width("cashflow_sankey")
|
||||
end
|
||||
|
||||
test "update_preferences ignores malformed dashboard_section_layout without erroring" do
|
||||
previous_height = @user.reload.dashboard_section_height("net_worth_chart")
|
||||
|
||||
patch "/dashboard/preferences", params: {
|
||||
preferences: { dashboard_section_layout: "not-a-hash" }
|
||||
}, as: :json
|
||||
|
||||
assert_response :ok
|
||||
assert_equal previous_height, @user.reload.dashboard_section_height("net_worth_chart")
|
||||
end
|
||||
|
||||
test "dashboard memoizes income statement period totals while rendering" do
|
||||
income_statement = IncomeStatement.new(@family)
|
||||
IncomeStatement.stubs(:new).returns(income_statement)
|
||||
|
||||
@@ -510,6 +510,45 @@ class UserTest < ActiveSupport::TestCase
|
||||
assert_equal new_order, @user.dashboard_section_order
|
||||
end
|
||||
|
||||
test "dashboard_section_height returns stored preset or nil" do
|
||||
@user.update!(preferences: {})
|
||||
assert_nil @user.dashboard_section_height("net_worth_chart")
|
||||
|
||||
@user.update_dashboard_preferences({
|
||||
"dashboard_section_layout" => { "net_worth_chart" => { "height" => "tall" } }
|
||||
})
|
||||
@user.reload
|
||||
|
||||
assert_equal "tall", @user.dashboard_section_height("net_worth_chart")
|
||||
assert_nil @user.dashboard_section_height("balance_sheet")
|
||||
end
|
||||
|
||||
test "dashboard_section_width returns stored col_span or nil" do
|
||||
@user.update!(preferences: {})
|
||||
assert_nil @user.dashboard_section_width("cashflow_sankey")
|
||||
|
||||
@user.update_dashboard_preferences({
|
||||
"dashboard_section_layout" => { "cashflow_sankey" => { "col_span" => "single" } }
|
||||
})
|
||||
|
||||
assert_equal "single", @user.reload.dashboard_section_width("cashflow_sankey")
|
||||
end
|
||||
|
||||
test "dashboard_section_layout merges width and height without clobbering" do
|
||||
@user.update!(preferences: {})
|
||||
|
||||
@user.update_dashboard_preferences({
|
||||
"dashboard_section_layout" => { "net_worth_chart" => { "height" => "tall" } }
|
||||
})
|
||||
@user.update_dashboard_preferences({
|
||||
"dashboard_section_layout" => { "net_worth_chart" => { "col_span" => "full" } }
|
||||
})
|
||||
@user.reload
|
||||
|
||||
assert_equal "tall", @user.dashboard_section_height("net_worth_chart")
|
||||
assert_equal "full", @user.dashboard_section_width("net_worth_chart")
|
||||
end
|
||||
|
||||
test "handles empty preferences gracefully for dashboard methods" do
|
||||
@user.update!(preferences: {})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user