From d5144ec834f0eec26f0b4dca061c26268964712e Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 1 Jul 2026 12:52:16 -0700 Subject: [PATCH 1/4] feat(autopilot:fleet-overview-filters): default-actionable fleet filters + filtered-empty state (phase 0) --- frontend/public/locales/en/remediation.json | 6 + .../remediation/FleetOverview.test.tsx | 108 +++++++++++++++++- .../components/remediation/FleetOverview.tsx | 52 ++++++++- 3 files changed, 160 insertions(+), 6 deletions(-) diff --git a/frontend/public/locales/en/remediation.json b/frontend/public/locales/en/remediation.json index af292372..0a21faba 100644 --- a/frontend/public/locales/en/remediation.json +++ b/frontend/public/locales/en/remediation.json @@ -28,6 +28,12 @@ "policyState": "Policy", "airGapped": "Air-gap", "actions": "Actions" + }, + "filters": { + "onlyWithPrs": "Only repos with open PRs", + "onlyEligible": "Only eligible repos", + "noMatch": "No repositories match the current filters.", + "clear": "Show all repositories" } }, "policyState": { diff --git a/frontend/src/components/remediation/FleetOverview.test.tsx b/frontend/src/components/remediation/FleetOverview.test.tsx index 118e8f7c..d7a8bf98 100644 --- a/frontend/src/components/remediation/FleetOverview.test.tsx +++ b/frontend/src/components/remediation/FleetOverview.test.tsx @@ -20,6 +20,11 @@ vi.mock('@/hooks/useFleetRemediation', () => ({ usePreviewRepository: () => mockUsePreview(), })); +// Fixture spans all four filter quadrants so each toggle can be proven independently: +// app — has PRs + eligible → visible by default (actionable) +// lib — no PRs + eligible → hidden only by "only with PRs" +// tool — has PRs + not eligible → hidden only by "only eligible" +// old — no PRs + not eligible → hidden by both (also air-gapped) const rows: FleetRow[] = [ { repositoryId: 'repo-1', @@ -33,50 +38,142 @@ const rows: FleetRow[] = [ repositoryId: 'repo-2', name: 'acme/lib', openPrCount: 0, + eligible: true, + policyState: 'suggest', + airGapped: false, + }, + { + repositoryId: 'repo-3', + name: 'acme/tool', + openPrCount: 3, + eligible: false, + policyState: 'none', + airGapped: false, + }, + { + repositoryId: 'repo-4', + name: 'acme/old', + openPrCount: 0, eligible: false, policyState: 'none', airGapped: true, }, ]; +const onlyWithPrsToggle = () => + screen.getByLabelText('remediation:fleet.filters.onlyWithPrs'); +const onlyEligibleToggle = () => + screen.getByLabelText('remediation:fleet.filters.onlyEligible'); + beforeEach(() => { vi.clearAllMocks(); mockUsePreview.mockReturnValue({ mutate: mockPreviewMutate, isPending: false, isError: false }); }); describe('FleetOverview', () => { - it('should_renderRepositoryRows_when_dataLoaded', () => { + it('should_showOnlyActionableRows_when_defaultFiltersActive', () => { + mockUseFleet.mockReturnValue({ data: rows, isLoading: false, isError: false }); + + render(); + + // Default: both filters ON — only the has-PRs + eligible repo shows. + expect(screen.getByText('acme/app')).toBeInTheDocument(); + // The 0-PR repo and the ineligible repo are hidden by default. + expect(screen.queryByText('acme/lib')).not.toBeInTheDocument(); + expect(screen.queryByText('acme/tool')).not.toBeInTheDocument(); + expect(screen.queryByText('acme/old')).not.toBeInTheDocument(); + }); + + it('should_revealZeroPrRepo_when_onlyWithPrsToggledOff', async () => { + const user = userEvent.setup(); mockUseFleet.mockReturnValue({ data: rows, isLoading: false, isError: false }); render(); + await user.click(onlyWithPrsToggle()); + // onlyWithPrs OFF, onlyEligible still ON → eligible repos regardless of PR count. expect(screen.getByText('acme/app')).toBeInTheDocument(); expect(screen.getByText('acme/lib')).toBeInTheDocument(); + // Still hidden: the ineligible ones (onlyEligible remains active). + expect(screen.queryByText('acme/tool')).not.toBeInTheDocument(); + expect(screen.queryByText('acme/old')).not.toBeInTheDocument(); }); - it('should_renderEligibilityBadges_when_dataLoaded', () => { + it('should_revealIneligibleRepo_when_onlyEligibleToggledOff', async () => { + const user = userEvent.setup(); mockUseFleet.mockReturnValue({ data: rows, isLoading: false, isError: false }); render(); + await user.click(onlyEligibleToggle()); - expect(screen.getByText('remediation:fleet.eligible')).toBeInTheDocument(); - expect(screen.getByText('remediation:fleet.notEligible')).toBeInTheDocument(); + // onlyEligible OFF, onlyWithPrs still ON → repos with PRs regardless of eligibility. + expect(screen.getByText('acme/app')).toBeInTheDocument(); + expect(screen.getByText('acme/tool')).toBeInTheDocument(); + // Still hidden: the 0-PR ones (onlyWithPrs remains active). + expect(screen.queryByText('acme/lib')).not.toBeInTheDocument(); + expect(screen.queryByText('acme/old')).not.toBeInTheDocument(); }); - it('should_renderAirGappedIndicator_when_repoAirGapped', () => { + it('should_showAllRows_when_bothFiltersToggledOff', async () => { + const user = userEvent.setup(); mockUseFleet.mockReturnValue({ data: rows, isLoading: false, isError: false }); render(); + await user.click(onlyWithPrsToggle()); + await user.click(onlyEligibleToggle()); + expect(screen.getByText('acme/app')).toBeInTheDocument(); + expect(screen.getByText('acme/lib')).toBeInTheDocument(); + expect(screen.getByText('acme/tool')).toBeInTheDocument(); + expect(screen.getByText('acme/old')).toBeInTheDocument(); + // Both eligibility badges and the air-gapped indicator are now visible. + expect(screen.getAllByText('remediation:fleet.notEligible').length).toBeGreaterThan(0); + expect(screen.getAllByText('remediation:fleet.eligible').length).toBeGreaterThan(0); expect(screen.getByText('remediation:fleet.airGappedOn')).toBeInTheDocument(); }); + it('should_renderFilteredEmptyState_when_activeFiltersHideEveryRow', () => { + // A fleet with no actionable repos: default filters exclude everything. + mockUseFleet.mockReturnValue({ + data: [rows[3]], // acme/old: 0 PRs + ineligible + isLoading: false, + isError: false, + }); + + render(); + + expect(screen.getByText('remediation:fleet.filters.noMatch')).toBeInTheDocument(); + // Distinct from the zero-repositories empty state. + expect(screen.queryByText('remediation:fleet.empty')).not.toBeInTheDocument(); + expect(screen.queryByText('acme/old')).not.toBeInTheDocument(); + }); + + it('should_restoreRows_when_filtersClearedFromEmptyState', async () => { + const user = userEvent.setup(); + mockUseFleet.mockReturnValue({ + data: [rows[3]], // acme/old: hidden by default filters + isLoading: false, + isError: false, + }); + + render(); + expect(screen.getByText('remediation:fleet.filters.noMatch')).toBeInTheDocument(); + + await user.click(screen.getByRole('button', { name: /remediation:fleet.filters.clear/ })); + + // Clearing the filters widens the view so the previously-hidden repo appears. + expect(screen.getByText('acme/old')).toBeInTheDocument(); + expect(screen.queryByText('remediation:fleet.filters.noMatch')).not.toBeInTheDocument(); + }); + it('should_renderEmptyState_when_noRepositories', () => { mockUseFleet.mockReturnValue({ data: [], isLoading: false, isError: false }); render(); expect(screen.getByText('remediation:fleet.empty')).toBeInTheDocument(); + // Not the filtered-empty state — there are genuinely zero repositories. + expect(screen.queryByText('remediation:fleet.filters.noMatch')).not.toBeInTheDocument(); }); it('should_invokePreview_when_previewClicked', async () => { @@ -85,6 +182,7 @@ describe('FleetOverview', () => { render(); + // acme/app is visible under the default filters. const previewButtons = screen.getAllByRole('button', { name: /remediation:fleet.preview/ }); await user.click(previewButtons[0]); diff --git a/frontend/src/components/remediation/FleetOverview.tsx b/frontend/src/components/remediation/FleetOverview.tsx index 45bd9da5..5cf2fea7 100644 --- a/frontend/src/components/remediation/FleetOverview.tsx +++ b/frontend/src/components/remediation/FleetOverview.tsx @@ -3,6 +3,8 @@ import { useTranslation } from 'react-i18next'; import { Eye, ShieldOff } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; +import { Label } from '@/components/ui/label'; +import { Switch } from '@/components/ui/switch'; import { Dialog, DialogContent, @@ -38,6 +40,10 @@ export function FleetOverview() { const previewMutation = usePreviewRepository(); const [activeRepo, setActiveRepo] = useState(null); const [plan, setPlan] = useState(null); + // Default to the actionable rows: repos with open PRs that are remediation-eligible. + // Either filter can be toggled off independently to widen the view. + const [onlyWithPrs, setOnlyWithPrs] = useState(true); + const [onlyEligible, setOnlyEligible] = useState(true); const handlePreview = (row: FleetRow) => { setActiveRepo(row); @@ -71,8 +77,52 @@ export function FleetOverview() { ); } + // Active filters AND together; toggling either off relaxes it. Purely client-side over + // the already-fetched rows — no backend/API involvement. + const filteredFleet = fleet.filter( + (row) => + (!onlyWithPrs || row.openPrCount > 0) && (!onlyEligible || row.eligible) + ); + + const clearFilters = () => { + setOnlyWithPrs(false); + setOnlyEligible(false); + }; + + const filters = ( +
+
+ + +
+
+ + +
+
+ ); + + if (filteredFleet.length === 0) { + return ( + <> + {filters} +
+

{t('remediation:fleet.filters.noMatch')}

+ +
+ + ); + } + return ( <> + {filters}
@@ -98,7 +148,7 @@ export function FleetOverview() { - {fleet.map((row) => ( + {filteredFleet.map((row) => ( From 570123c7ff4fb0904c2bbb0c49510fc9d9541240 Mon Sep 17 00:00:00 2001 From: Chris Phillipson Date: Wed, 1 Jul 2026 12:57:45 -0700 Subject: [PATCH 2/4] test(autopilot:fleet-overview-filters): cover toggle round-trip, single-toggle escape, revealed-row preview + a11y group/aria-live (phase 0 review) --- frontend/public/locales/en/remediation.json | 1 + .../remediation/FleetOverview.test.tsx | 68 +++++++++++++++++-- .../components/remediation/FleetOverview.tsx | 17 +++-- 3 files changed, 76 insertions(+), 10 deletions(-) diff --git a/frontend/public/locales/en/remediation.json b/frontend/public/locales/en/remediation.json index 0a21faba..bbc6603e 100644 --- a/frontend/public/locales/en/remediation.json +++ b/frontend/public/locales/en/remediation.json @@ -30,6 +30,7 @@ "actions": "Actions" }, "filters": { + "groupLabel": "Repository filters", "onlyWithPrs": "Only repos with open PRs", "onlyEligible": "Only eligible repos", "noMatch": "No repositories match the current filters.", diff --git a/frontend/src/components/remediation/FleetOverview.test.tsx b/frontend/src/components/remediation/FleetOverview.test.tsx index d7a8bf98..dd2e6bf1 100644 --- a/frontend/src/components/remediation/FleetOverview.test.tsx +++ b/frontend/src/components/remediation/FleetOverview.test.tsx @@ -1,5 +1,5 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { FleetOverview } from './FleetOverview'; import type { ConsolidationPlan, FleetRow } from '@/types/remediation'; @@ -60,10 +60,8 @@ const rows: FleetRow[] = [ }, ]; -const onlyWithPrsToggle = () => - screen.getByLabelText('remediation:fleet.filters.onlyWithPrs'); -const onlyEligibleToggle = () => - screen.getByLabelText('remediation:fleet.filters.onlyEligible'); +const onlyWithPrsToggle = () => screen.getByLabelText('remediation:fleet.filters.onlyWithPrs'); +const onlyEligibleToggle = () => screen.getByLabelText('remediation:fleet.filters.onlyEligible'); beforeEach(() => { vi.clearAllMocks(); @@ -84,6 +82,35 @@ describe('FleetOverview', () => { expect(screen.queryByText('acme/old')).not.toBeInTheDocument(); }); + it('should_showEveryActionableRow_when_multipleReposPassDefaults', () => { + // Two rows both satisfy the default filters (PRs > 0 AND eligible): both must show, + // guarding against a predicate that is accidentally too strict on a passing row. + const twoActionable: FleetRow[] = [ + { ...rows[0], repositoryId: 'a', name: 'acme/one', openPrCount: 2, eligible: true }, + { ...rows[0], repositoryId: 'b', name: 'acme/two', openPrCount: 9, eligible: true }, + ]; + mockUseFleet.mockReturnValue({ data: twoActionable, isLoading: false, isError: false }); + + render(); + + expect(screen.getByText('acme/one')).toBeInTheDocument(); + expect(screen.getByText('acme/two')).toBeInTheDocument(); + }); + + it('should_reapplyFilter_when_toggledOffThenBackOn', async () => { + const user = userEvent.setup(); + mockUseFleet.mockReturnValue({ data: rows, isLoading: false, isError: false }); + + render(); + // Off → the 0-PR eligible repo appears. + await user.click(onlyWithPrsToggle()); + expect(screen.getByText('acme/lib')).toBeInTheDocument(); + // On again → it is hidden once more (state round-trips correctly). + await user.click(onlyWithPrsToggle()); + expect(screen.queryByText('acme/lib')).not.toBeInTheDocument(); + expect(screen.getByText('acme/app')).toBeInTheDocument(); + }); + it('should_revealZeroPrRepo_when_onlyWithPrsToggledOff', async () => { const user = userEvent.setup(); mockUseFleet.mockReturnValue({ data: rows, isLoading: false, isError: false }); @@ -166,6 +193,22 @@ describe('FleetOverview', () => { expect(screen.queryByText('remediation:fleet.filters.noMatch')).not.toBeInTheDocument(); }); + it('should_escapeFilteredEmptyState_when_singleToggleFlippedOff', async () => { + const user = userEvent.setup(); + // acme/tool: has PRs but ineligible → hidden only by onlyEligible. + mockUseFleet.mockReturnValue({ data: [rows[2]], isLoading: false, isError: false }); + + render(); + expect(screen.getByText('remediation:fleet.filters.noMatch')).toBeInTheDocument(); + + // The toggles remain available in the empty state; flipping the one filter that + // hides the row reveals it — no need for the nuclear "clear". + await user.click(onlyEligibleToggle()); + + expect(screen.getByText('acme/tool')).toBeInTheDocument(); + expect(screen.queryByText('remediation:fleet.filters.noMatch')).not.toBeInTheDocument(); + }); + it('should_renderEmptyState_when_noRepositories', () => { mockUseFleet.mockReturnValue({ data: [], isLoading: false, isError: false }); @@ -215,4 +258,19 @@ describe('FleetOverview', () => { await waitFor(() => expect(screen.getByText('Bump deps')).toBeInTheDocument()); }); + + it('should_previewRevealedRow_when_previewClickedAfterToggle', async () => { + const user = userEvent.setup(); + mockUseFleet.mockReturnValue({ data: rows, isLoading: false, isError: false }); + + render(); + // acme/tool is hidden by default; widen the view so it renders, then preview it. + await user.click(onlyEligibleToggle()); + const toolRow = screen.getByText('acme/tool').closest('tr') as HTMLElement; + await user.click(within(toolRow).getByRole('button', { name: /remediation:fleet.preview/ })); + + await waitFor(() => + expect(mockPreviewMutate).toHaveBeenCalledWith('repo-3', expect.anything()) + ); + }); }); diff --git a/frontend/src/components/remediation/FleetOverview.tsx b/frontend/src/components/remediation/FleetOverview.tsx index 5cf2fea7..da1d1556 100644 --- a/frontend/src/components/remediation/FleetOverview.tsx +++ b/frontend/src/components/remediation/FleetOverview.tsx @@ -80,8 +80,7 @@ export function FleetOverview() { // Active filters AND together; toggling either off relaxes it. Purely client-side over // the already-fetched rows — no backend/API involvement. const filteredFleet = fleet.filter( - (row) => - (!onlyWithPrs || row.openPrCount > 0) && (!onlyEligible || row.eligible) + (row) => (!onlyWithPrs || row.openPrCount > 0) && (!onlyEligible || row.eligible) ); const clearFilters = () => { @@ -90,7 +89,11 @@ export function FleetOverview() { }; const filters = ( -
+
- + @@ -110,7 +117,7 @@ export function FleetOverview() { return ( <> {filters} -
+

{t('remediation:fleet.filters.noMatch')}

{row.name} {row.openPrCount}