mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 04:45:19 +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
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user