mirror of
https://github.com/we-promise/sure.git
synced 2026-07-12 21:05:20 +00:00
* 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>
165 lines
5.4 KiB
Ruby
165 lines
5.4 KiB
Ruby
require "test_helper"
|
|
|
|
class PagesControllerTest < ActionDispatch::IntegrationTest
|
|
include EntriesTestHelper
|
|
|
|
setup do
|
|
sign_in @user = users(:family_admin)
|
|
@intro_user = users(:intro_user)
|
|
@family = @user.family
|
|
end
|
|
|
|
test "dashboard" do
|
|
get root_path
|
|
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)
|
|
|
|
fake_expense_period_total = IncomeStatement::PeriodTotal.new(
|
|
classification: "expense",
|
|
total: 0,
|
|
currency: @family.currency,
|
|
category_totals: []
|
|
)
|
|
|
|
fake_income_period_total = IncomeStatement::PeriodTotal.new(
|
|
classification: "income",
|
|
total: 0,
|
|
currency: @family.currency,
|
|
category_totals: []
|
|
)
|
|
|
|
income_statement.expects(:build_period_total)
|
|
.with(classification: "expense", period: kind_of(Period))
|
|
.once
|
|
.returns(fake_expense_period_total)
|
|
|
|
income_statement.expects(:build_period_total)
|
|
.with(classification: "income", period: kind_of(Period))
|
|
.once
|
|
.returns(fake_income_period_total)
|
|
|
|
get root_path
|
|
|
|
assert_response :ok
|
|
end
|
|
|
|
test "intro page requires guest role" do
|
|
get intro_path
|
|
|
|
assert_redirected_to root_path
|
|
assert_equal "Intro is only available to guest users.", flash[:alert]
|
|
end
|
|
|
|
test "intro page is accessible for guest users" do
|
|
sign_in @intro_user
|
|
|
|
get intro_path
|
|
|
|
assert_response :ok
|
|
end
|
|
|
|
test "dashboard renders sankey chart with subcategories" do
|
|
# Create parent category with subcategory
|
|
parent_category = @family.categories.create!(name: "Shopping", color: "#FF5733")
|
|
subcategory = @family.categories.create!(name: "Groceries", parent: parent_category, color: "#33FF57")
|
|
|
|
# Create transactions using helper
|
|
create_transaction(account: @family.accounts.first, name: "General shopping", amount: 100, category: parent_category)
|
|
create_transaction(account: @family.accounts.first, name: "Grocery store", amount: 50, category: subcategory)
|
|
|
|
get root_path
|
|
assert_response :ok
|
|
assert_select "[data-controller='sankey-chart']"
|
|
end
|
|
|
|
test "dashboard renders sankey chart zoom controls and stable node ids" do
|
|
parent_category = @family.categories.create!(name: "Shopping", color: "#FF5733")
|
|
subcategory = @family.categories.create!(name: "Groceries", parent: parent_category, color: "#33FF57")
|
|
|
|
create_transaction(account: @family.accounts.first, name: "General shopping", amount: 100, category: parent_category)
|
|
create_transaction(account: @family.accounts.first, name: "Grocery store", amount: 50, category: subcategory)
|
|
|
|
get root_path
|
|
|
|
assert_response :ok
|
|
assert_select "[data-sankey-chart-target='zoomOutButton'][hidden]", count: 2
|
|
|
|
chart = css_select("[data-controller='sankey-chart']").first
|
|
sankey_data = JSON.parse(chart["data-sankey-chart-data-value"])
|
|
|
|
assert_includes sankey_data.fetch("nodes").map { |node| node.fetch("id") }, "cash_flow_node"
|
|
assert sankey_data.fetch("nodes").any? { |node| node.fetch("id").start_with?("expense_") }
|
|
end
|
|
|
|
test "changelog" do
|
|
VCR.use_cassette("git_repository_provider/fetch_latest_release_notes") do
|
|
get changelog_path
|
|
assert_response :ok
|
|
end
|
|
end
|
|
|
|
test "changelog with nil release notes" do
|
|
# Mock the GitHub provider to return nil (simulating API failure or no releases)
|
|
github_provider = mock
|
|
github_provider.expects(:fetch_latest_release_notes).returns(nil)
|
|
Provider::Registry.stubs(:get_provider).with(:github).returns(github_provider)
|
|
|
|
get changelog_path
|
|
assert_response :ok
|
|
assert_select "h2", text: "Release notes unavailable"
|
|
assert_select "a[href='https://github.com/we-promise/sure/releases']"
|
|
end
|
|
|
|
test "changelog with incomplete release notes" do
|
|
# Mock the GitHub provider to return incomplete data (missing some fields)
|
|
github_provider = mock
|
|
incomplete_data = {
|
|
avatar: nil,
|
|
username: "maybe-finance",
|
|
name: "Test Release",
|
|
published_at: nil,
|
|
body: nil
|
|
}
|
|
github_provider.expects(:fetch_latest_release_notes).returns(incomplete_data)
|
|
Provider::Registry.stubs(:get_provider).with(:github).returns(github_provider)
|
|
|
|
get changelog_path
|
|
assert_response :ok
|
|
assert_select "h2", text: "Test Release"
|
|
# Should not crash even with nil values
|
|
end
|
|
end
|