Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,35 @@ describe("DashboardGrid outside a dashboard", () => {
),
).not.toThrow()
})

// `containerPadding` defaults to `margin`, which indented the first column by
// a gutter's width — so tiles sat 12px inside everything stacked above them
// (section headers, the share page's time-range label and refresh controls).
// The gutter belongs between tiles; the surrounding layout owns the outer
// padding. Asserted on the inline position because that is the only place the
// offset exists — there is no class to look for.
it("starts the first column flush with its container, not a gutter inside it", () => {
const [tier] = GRID_TIERS
const { container } = render(
<ShareWidgetStatesProvider states={{}}>
<DashboardGrid
widgets={[widget("w-1")]}
width={1200}
tier={tier}
editable={false}
renderWidget={SharedWidgetRenderer}
/>
</ShareWidgetStatesProvider>,
)

const item = container.querySelector<HTMLElement>(".react-grid-item")
expect(item).not.toBeNull()
// x only. y deliberately keeps `margin[1]`, so asserting on the whole
// transform would pass for the wrong reason (or fail for a good one).
const [x, y] = (item?.style.transform ?? "").match(/-?\d+(\.\d+)?px/g) ?? []
expect(x).toBe("0px")
expect(y).toBe(`${tier.margin[1]}px`)
})
})

describe("SharedWidgetRenderer", () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,14 @@ export function DashboardGrid<W extends CanvasWidget>({
cols: tier.cols,
rowHeight: GRID_ROW_HEIGHT,
margin: tier.margin,
// `containerPadding` defaults to `margin`, which indents the first and
// last column by a gutter's width — so tiles sat inset from everything
// stacked above them (section headers, the share page's time-range
// label and refresh controls, the page title). The gutter belongs
// *between* tiles, not around them; the surrounding layout owns the
// outer padding. Vertical keeps the margin, so the gap under a section
// header is unchanged.
containerPadding: [0, tier.margin[1]],
}}
dragConfig={{
enabled: editable,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ import {
DropdownMenuSeparator,
} from "@maple/ui/components/ui/dropdown-menu"
import { TimeRangePicker } from "@/components/time-range-picker/time-range-picker"
import { ReloadControls } from "@/components/time-range-picker/reload-controls"
import { PageRefreshControls } from "@/components/time-range-picker/refresh-controls"
import { VariableSelects } from "@/components/dashboard-builder/toolbar/variable-selects"
import { useDashboardTimeRange } from "@/components/dashboard-builder/dashboard-providers"
import { useDashboardActions } from "@/components/dashboard-builder/dashboard-actions-context"
Expand All @@ -33,19 +33,25 @@ import { ShareDashboardDialog } from "@/components/dashboard-builder/toolbar/sha
import { collectTags } from "@/components/dashboard-builder/list/dashboard-summary"
import { useDashboardStore } from "@/hooks/use-dashboard-store"
import type { Dashboard } from "@/components/dashboard-builder/types"
import type { DashboardRefreshIntervalSeconds } from "@maple/domain/http"

interface DashboardToolbarProps {
dashboard: Dashboard
onToggleEdit: () => void
onAddWidget: () => void
onOpenHistory?: () => void
/** Effective cadence for this viewer: `?refresh=` if set, else the board's default. */
refreshIntervalSeconds: DashboardRefreshIntervalSeconds
onRefreshIntervalChange: (value: DashboardRefreshIntervalSeconds) => void
}

export function DashboardToolbar({
dashboard,
onToggleEdit,
onAddWidget,
onOpenHistory,
refreshIntervalSeconds,
onRefreshIntervalChange,
}: DashboardToolbarProps) {
const { mode, readOnly, autoLayoutWidgets, addSection } = useDashboardActions()
const {
Expand Down Expand Up @@ -94,7 +100,11 @@ export function DashboardToolbar({
}}
/>

<ReloadControls />
<PageRefreshControls
value={refreshIntervalSeconds}
onChange={onRefreshIntervalChange}
savedDefault={dashboard.refreshIntervalSeconds}
/>

<div className="flex items-center gap-1">
{/* Labels collapse to icon-only on a narrow canvas; `aria-label`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { act, cleanup, fireEvent, render, screen } from "@testing-library/react"
import { useState, type ReactNode } from "react"
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"

import { useMountEffect } from "@/hooks/use-mount-effect"
import { useRefreshableAtomValue } from "@/hooks/use-refreshable-atom-value"

import {
Expand Down Expand Up @@ -136,3 +137,84 @@ describe("page refresh controller", () => {
expect(onRelativeRangeRefresh).not.toHaveBeenCalled()
})
})

/**
* Stands in for a widget: every tile subscribes through
* `useRefreshableAtomValue`, so counting listener calls counts canvas refreshes.
*/
function Subscriber({ onReload }: { onReload: () => void }) {
const { subscribeReload } = usePageRefreshContext()
// Mount-scoped rather than render-time: subscribing during render would
// double-add under StrictMode with no matching cleanup.
useMountEffect(() => subscribeReload(onReload))
return null
}

function AutoRefreshHarness({
onReload,
autoRefreshMs,
autoRefreshPaused,
}: {
onReload: () => void
autoRefreshMs?: number
autoRefreshPaused?: boolean
}) {
return (
<PageRefreshProvider autoRefreshMs={autoRefreshMs} autoRefreshPaused={autoRefreshPaused}>
<Subscriber onReload={onReload} />
</PageRefreshProvider>
)
}

describe("page refresh auto-refresh", () => {
beforeEach(() => vi.useFakeTimers())

afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
})

it("fans a tick out to every subscriber on the configured cadence", () => {
const onReload = vi.fn()
render(<AutoRefreshHarness onReload={onReload} autoRefreshMs={5_000} />)

act(() => vi.advanceTimersByTime(5_000))
expect(onReload).toHaveBeenCalledTimes(1)

act(() => vi.advanceTimersByTime(10_000))
expect(onReload).toHaveBeenCalledTimes(3)
})

// `0`/absent is the off sentinel every non-dashboard page relies on; it must
// register no interval at all rather than a `setInterval(…, 0)` hot loop.
it("registers no timer when the cadence is absent or zero", () => {
const onReload = vi.fn()
render(<AutoRefreshHarness onReload={onReload} />)
act(() => vi.advanceTimersByTime(60_000))
expect(onReload).not.toHaveBeenCalled()

cleanup()
render(<AutoRefreshHarness onReload={onReload} autoRefreshMs={0} />)
act(() => vi.advanceTimersByTime(60_000))
expect(onReload).not.toHaveBeenCalled()
})

// Editing a dashboard or previewing a version suspends the timer without
// forgetting the cadence, so leaving that state resumes at the same interval.
it("suspends while paused and resumes on the same cadence", () => {
const onReload = vi.fn()
const view = render(
<AutoRefreshHarness onReload={onReload} autoRefreshMs={5_000} autoRefreshPaused />,
)

act(() => vi.advanceTimersByTime(20_000))
expect(onReload).not.toHaveBeenCalled()

view.rerender(
<AutoRefreshHarness onReload={onReload} autoRefreshMs={5_000} autoRefreshPaused={false} />,
)
act(() => vi.advanceTimersByTime(5_000))
expect(onReload).toHaveBeenCalledTimes(1)
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
// @vitest-environment jsdom

import { cleanup, fireEvent, render, screen } from "@testing-library/react"
import { afterEach, describe, expect, it, vi } from "vitest"

import { RefreshControls } from "./refresh-controls"

afterEach(cleanup)

const cadenceTrigger = () => screen.getByRole("button", { name: /Auto-refresh/ })
const openMenu = () => fireEvent.click(cadenceTrigger())

describe("RefreshControls", () => {
// The two halves are one split button: reload now on the left, reload every N
// on the right. Standalone, the cadence half read as a second time-range
// control next to the real one — attached to "Reload" it can only mean one
// thing, which is the entire reason this is one component.
it("pairs a reload action with the cadence it repeats at", () => {
const onReload = vi.fn()
render(<RefreshControls onReload={onReload} value={30} onChange={() => {}} />)

fireEvent.click(screen.getByRole("button", { name: "Reload" }))
expect(onReload).toHaveBeenCalledTimes(1)
expect(cadenceTrigger().textContent).toContain("30s")
})

it("says so when auto-refresh is off rather than going blank", () => {
render(<RefreshControls onReload={() => {}} value={0} onChange={() => {}} />)

expect(screen.getByRole("button", { name: "Auto-refresh off" }).textContent).toContain("Off")
})

// A reload already in flight must not be re-triggered by an impatient click.
it("disables reload while one is in flight", () => {
const onReload = vi.fn()
render(<RefreshControls onReload={onReload} isReloading value={0} onChange={() => {}} />)

fireEvent.click(screen.getByRole("button", { name: "Reload" }))
expect(onReload).not.toHaveBeenCalled()
})

// Base UI's GroupLabel throws production error #31 outside a Group, which is a
// full error-boundary crash rather than a warning — and only reachable by
// actually opening the menu. A type check would not catch this.
it("opens without throwing and names the group", () => {
render(<RefreshControls onReload={() => {}} value={0} onChange={() => {}} />)

openMenu()
expect(screen.getByText("Auto-refresh")).toBeDefined()
expect(screen.getByRole("menuitemradio", { name: "Off" })).toBeDefined()
expect(screen.getByRole("menuitemradio", { name: "15m" })).toBeDefined()
})

// The URL and the document both hold numbers; the radio group only speaks
// strings, so this is the seam where a stray string would leak outward.
it("reports the chosen cadence as a number", () => {
const onChange = vi.fn()
render(<RefreshControls onReload={() => {}} value={0} onChange={onChange} />)

openMenu()
fireEvent.click(screen.getByRole("menuitemradio", { name: "5s" }))
expect(onChange).toHaveBeenCalledWith(5)
})

it("marks the board's saved cadence so a `?refresh=` override stays legible", () => {
render(<RefreshControls onReload={() => {}} value={5} onChange={() => {}} savedDefault={60} />)

openMenu()
expect(screen.getByRole("menuitemradio", { name: /1m\s*default/ })).toBeDefined()
expect(screen.getByRole("menuitemradio", { name: "5s" }).getAttribute("aria-checked")).toBe("true")
})
})
Loading
Loading