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
19 changes: 10 additions & 9 deletions frontend/src/pages/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -65,19 +65,20 @@ export function DashboardPage() {
const queryClient = useQueryClient()
const { confirm, ConfirmComponent } = useConfirm()
const [deletingStackId, setDeletingStackId] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<SortOption>('name')
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
// Read the persisted sort/filter in the initialiser rather than in a mount
// effect. An effect would render once with the default and again with the
// stored value, which is both a visible flash of the wrong sort order and
// what react-hooks/set-state-in-effect flags.
const [sortBy, setSortBy] = useState<SortOption>(
() => (localStorage.getItem('dashboard-sort') as SortOption) || 'name',
)
const [statusFilter, setStatusFilter] = useState<StatusFilter>(
() => (localStorage.getItem('dashboard-filter') as StatusFilter) || 'all',
)
const [isRefreshing, setIsRefreshing] = useState(false)

const { containers: metricsContainers, aggregates, latestMetrics, isConnected: metricsConnected, ws: metricsWs } = useDashboardMetrics()

useEffect(() => {
const savedSort = localStorage.getItem('dashboard-sort') as SortOption
const savedFilter = localStorage.getItem('dashboard-filter') as StatusFilter
if (savedSort) setSortBy(savedSort)
if (savedFilter) setStatusFilter(savedFilter)
}, [])

useEffect(() => {
localStorage.setItem('dashboard-sort', sortBy)
localStorage.setItem('dashboard-filter', statusFilter)
Expand Down
42 changes: 41 additions & 1 deletion frontend/src/pages/__tests__/DashboardPage.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -70,8 +70,12 @@ vi.mock('@/components/dashboard/DashboardHeader', () => ({
vi.mock('@/components/dashboard/DashboardMetricsTab', () => ({
DashboardMetricsTab: () => <div data-testid="tab-overview" />,
}))
// Surfaces the sort/filter the page hands down, so the localStorage-restore
// contract is observable without exercising StacksTab's internals.
vi.mock('@/components/dashboard/StacksTab', () => ({
StacksTab: () => <div data-testid="tab-stacks" />,
StacksTab: ({ sortBy, statusFilter }: { sortBy: string; statusFilter: string }) => (
<div data-testid="tab-stacks" data-sort-by={sortBy} data-status-filter={statusFilter} />
),
}))
vi.mock('@/components/dashboard/ContainersOverviewTab', () => ({
ContainersOverviewTab: () => <div data-testid="tab-containers" />,
Expand Down Expand Up @@ -144,6 +148,9 @@ function renderPage(route = '/') {
describe('DashboardPage', () => {
beforeEach(() => {
vi.clearAllMocks()
// The page persists sort/filter on every change, so state leaks between
// tests unless this is cleared.
localStorage.clear()
listDirectories.mockResolvedValue([])
listStacks.mockResolvedValue([])
dashboardStats.mockResolvedValue({ runningContainers: 0 })
Expand Down Expand Up @@ -243,6 +250,39 @@ describe('DashboardPage', () => {
expect(screen.getByText('Create Your First Stack')).toBeInTheDocument()
})

// Pins the localStorage-restore contract itself, which had no coverage
// before agent-os-14b moved the read out of a mount effect and into the
// useState initialiser. These assert the restored values are what the page
// hands to StacksTab; they deliberately do NOT claim to distinguish the two
// implementations. They cannot: StacksTab is gated behind the loading state,
// so it first renders after effects have already flushed, and both versions
// look identical from the DOM. Verified by reverting the fix — these still
// passed. The guard against a relapse to setState-in-effect is the
// react-hooks/set-state-in-effect lint rule, not this file.
it('restores the persisted sort and filter', async () => {
localStorage.setItem('dashboard-sort', 'status')
localStorage.setItem('dashboard-filter', 'running')
listStacks.mockResolvedValue([makeStack({ id: 's1' })])
listDirectories.mockResolvedValue([makeDir({ path: '/stacks/s1' })])

renderPage('/?tab=stacks')

const tab = await screen.findByTestId('tab-stacks')
expect(tab).toHaveAttribute('data-sort-by', 'status')
expect(tab).toHaveAttribute('data-status-filter', 'running')
})

it('falls back to the defaults when nothing is persisted', async () => {
listStacks.mockResolvedValue([makeStack({ id: 's1' })])
listDirectories.mockResolvedValue([makeDir({ path: '/stacks/s1' })])

renderPage('/?tab=stacks')

const tab = await screen.findByTestId('tab-stacks')
expect(tab).toHaveAttribute('data-sort-by', 'name')
expect(tab).toHaveAttribute('data-status-filter', 'all')
})

it('hides the Quick Start empty state once stacks exist', async () => {
listStacks.mockResolvedValue([makeStack({ id: 's1' })])
listDirectories.mockResolvedValue([makeDir({ path: '/stacks/s1' })])
Expand Down
Loading