From 5afb199cb43eec14544a4453c871dce1cf203a5b Mon Sep 17 00:00:00 2001 From: Sophie Stadler Date: Tue, 27 Jan 2026 13:13:21 -0500 Subject: [PATCH 1/4] Add inactive bv filter --- .../waterfall/useWaterfallAnalytics.ts | 3 +- apps/spruce/src/components/ButtonDropdown.tsx | 20 ++--- apps/spruce/src/constants/cookies.ts | 1 + .../src/pages/waterfall/WaterfallFilters.tsx | 6 ++ .../src/pages/waterfall/WaterfallGrid.tsx | 74 ++++++++++++++----- .../WaterfallMenu/OmitInactiveBuilds.tsx | 50 +++++++++++++ .../pages/waterfall/WaterfallMenu/index.tsx | 62 ++++++++++------ apps/spruce/src/pages/waterfall/index.tsx | 9 +++ .../src/pages/waterfall/useFilters.test.tsx | 11 +++ apps/spruce/src/pages/waterfall/useFilters.ts | 11 +++ 10 files changed, 192 insertions(+), 55 deletions(-) create mode 100644 apps/spruce/src/pages/waterfall/WaterfallMenu/OmitInactiveBuilds.tsx diff --git a/apps/spruce/src/analytics/waterfall/useWaterfallAnalytics.ts b/apps/spruce/src/analytics/waterfall/useWaterfallAnalytics.ts index 0d40cba1a3..029744efdf 100644 --- a/apps/spruce/src/analytics/waterfall/useWaterfallAnalytics.ts +++ b/apps/spruce/src/analytics/waterfall/useWaterfallAnalytics.ts @@ -39,7 +39,8 @@ type Action = name: "Viewed waterfall modal"; navigated_to_waterfall: boolean; } - | { name: "Redirected to waterfall page"; referrer: string }; + | { name: "Redirected to waterfall page"; referrer: string } + | { name: "Toggled omit inactive builds"; enabled: boolean }; export const useWaterfallAnalytics = () => { const { [slugs.projectIdentifier]: projectIdentifier } = useParams(); diff --git a/apps/spruce/src/components/ButtonDropdown.tsx b/apps/spruce/src/components/ButtonDropdown.tsx index cfd744b0b4..3eb8cc784a 100644 --- a/apps/spruce/src/components/ButtonDropdown.tsx +++ b/apps/spruce/src/components/ButtonDropdown.tsx @@ -1,19 +1,19 @@ import { Size as ButtonSize } from "@leafygreen-ui/button"; import { Icon } from "@leafygreen-ui/icon"; -import { Menu, MenuItem } from "@leafygreen-ui/menu"; +import { Menu, MenuItem, MenuProps } from "@leafygreen-ui/menu"; import { LoadingButton } from "components/Buttons"; -interface Props { +type Props = { + children?: React.ReactNode; disabled?: boolean; + "data-cy"?: string; + dropdownItems?: React.ReactNode[]; loading?: boolean; - dropdownItems: React.JSX.Element[]; size?: ButtonSize; - "data-cy"?: string; - open?: boolean; - setOpen?: React.Dispatch>; -} +} & Omit; export const ButtonDropdown: React.FC = ({ + children, "data-cy": dataCy = "ellipsis-btn", disabled = false, dropdownItems, @@ -21,26 +21,26 @@ export const ButtonDropdown: React.FC = ({ open = undefined, setOpen = undefined, size = "small", - ...rest + ...menuProps }) => ( } > - {dropdownItems} + {dropdownItems ?? children} ); diff --git a/apps/spruce/src/constants/cookies.ts b/apps/spruce/src/constants/cookies.ts index 24d65b687d..dafe0641e0 100644 --- a/apps/spruce/src/constants/cookies.ts +++ b/apps/spruce/src/constants/cookies.ts @@ -19,3 +19,4 @@ export const TASK_HISTORY_INACTIVE_COMMITS_VIEW = "task-history-inactive-commits-view"; export const SEEN_TASK_REVIEW_TOOLTIP = "seen-task-review-tooltip"; export const SEEN_TEST_SELECTION_GUIDE_CUE = "seen-test-selection-guide-cue"; +export const OMIT_INACTIVE_WATERFALL_BUILDS = "omit-inactive-waterfall-builds"; diff --git a/apps/spruce/src/pages/waterfall/WaterfallFilters.tsx b/apps/spruce/src/pages/waterfall/WaterfallFilters.tsx index e1d10949dd..77b230598a 100644 --- a/apps/spruce/src/pages/waterfall/WaterfallFilters.tsx +++ b/apps/spruce/src/pages/waterfall/WaterfallFilters.tsx @@ -15,15 +15,19 @@ import { Pagination, WaterfallFilterOptions } from "./types"; import { WaterfallMenu } from "./WaterfallMenu"; type WaterfallFiltersProps = { + omitInactiveBuilds: boolean; projectIdentifier: string; pagination: Pagination | undefined; restartWalkthrough: () => void; + setOmitInactiveBuilds: (value: boolean) => void; }; export const WaterfallFilters: React.FC = ({ + omitInactiveBuilds, pagination, projectIdentifier, restartWalkthrough, + setOmitInactiveBuilds, }) => { const { sendEvent } = useWaterfallAnalytics(); @@ -83,8 +87,10 @@ export const WaterfallFilters: React.FC = ({ /> diff --git a/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx b/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx index d5eb7dbd38..254531e175 100644 --- a/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx +++ b/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx @@ -52,13 +52,15 @@ const resetFilterState: ServerFilters = { }; type WaterfallGridProps = { + guideCueRef: React.RefObject; + omitInactiveBuilds: boolean; projectIdentifier: string; setPagination: (pagination: Pagination) => void; - guideCueRef: React.RefObject; }; export const WaterfallGrid: React.FC = ({ guideCueRef, + omitInactiveBuilds, projectIdentifier, setPagination, }) => { @@ -115,9 +117,40 @@ export const WaterfallGrid: React.FC = ({ const timezone = useUserTimeZone() ?? utcTimeZone; const utcDate = getUTCEndOfDay(date, timezone); + const [allQueryParams] = useQueryParams(); + + // Initialize serverFilters from query params to avoid double query + const getServerFiltersFromParams = (): ServerFilters => { + const hasServerParams = + Object.keys(allQueryParams).includes(WaterfallFilterOptions.Requesters) || + Object.keys(allQueryParams).includes(WaterfallFilterOptions.Statuses) || + Object.keys(allQueryParams).includes(WaterfallFilterOptions.Task) || + Object.keys(allQueryParams).includes(WaterfallFilterOptions.BuildVariant); + + if (!hasServerParams) { + return resetFilterState; + } + + return { + requesters: allQueryParams[WaterfallFilterOptions.Requesters] as + | string[] + | undefined, + statuses: allQueryParams[WaterfallFilterOptions.Statuses] as + | string[] + | undefined, + tasks: allQueryParams[WaterfallFilterOptions.Task] as + | string[] + | undefined, + variants: allQueryParams[WaterfallFilterOptions.BuildVariant] as + | string[] + | undefined, + }; + }; + // TODO DEVPROD-23578: serverFilters can be calculated from queryParams directly, useState should not be necessary - const [serverFilters, setServerFilters] = - useState(resetFilterState); + const [serverFilters, setServerFilters] = useState( + getServerFiltersFromParams, + ); const { data, dataState } = useSuspenseQuery< WaterfallQuery, @@ -129,6 +162,7 @@ export const WaterfallGrid: React.FC = ({ limit: VERSION_LIMIT, maxOrder, minOrder, + omitInactiveBuilds, revision, date: utcDate, ...serverFilters, @@ -173,37 +207,37 @@ export const WaterfallGrid: React.FC = ({ ? data.waterfall.pagination.activeVersionIds : [], flattenedVersions: dataIsComplete ? data.waterfall.flattenedVersions : [], + omitInactiveBuilds, pins, }); const [isPending, startTransition] = useTransition(); - const [allQueryParams] = useQueryParams(); useEffect(() => { + const newFilters = getServerFiltersFromParams(); const hasServerParams = Object.keys(allQueryParams).includes(WaterfallFilterOptions.Requesters) || Object.keys(allQueryParams).includes(WaterfallFilterOptions.Statuses) || Object.keys(allQueryParams).includes(WaterfallFilterOptions.Task) || Object.keys(allQueryParams).includes(WaterfallFilterOptions.BuildVariant); + + // Check if filters have actually changed + const filtersChanged = + JSON.stringify(serverFilters) !== JSON.stringify(newFilters); + + // Only apply server filters if we have server params and need more results + // Otherwise, use client-side filtering if (activeVersionIds.length < VERSION_LIMIT && hasServerParams) { - const filters = { - requesters: allQueryParams[ - WaterfallFilterOptions.Requesters - ] as string[], - statuses: allQueryParams[WaterfallFilterOptions.Statuses] as string[], - tasks: allQueryParams[WaterfallFilterOptions.Task] as string[], - variants: allQueryParams[ - WaterfallFilterOptions.BuildVariant - ] as string[], - }; - startTransition(() => { - setServerFilters(filters); - }); - } else if (!hasServerParams) { + if (filtersChanged) { + startTransition(() => { + setServerFilters(newFilters); + }); + } + } else if (!hasServerParams && filtersChanged) { // Because this data is already loaded and no animation is necessary, omitting startTransition for snappiness setServerFilters(resetFilterState); // eslint-disable-line react-hooks/set-state-in-effect } - }, [allQueryParams]); // eslint-disable-line react-hooks/exhaustive-deps + }, [allQueryParams, activeVersionIds.length, serverFilters]); // eslint-disable-line react-hooks/exhaustive-deps const firstActiveVersionId = activeVersionIds[0]; const lastActiveVersionId = activeVersionIds[activeVersionIds.length - 1]; @@ -211,7 +245,7 @@ export const WaterfallGrid: React.FC = ({ const isHighlighted = (v: Version, i: number) => (revision !== null && v.revision.includes(revision)) || (!!date && i === 0); - if (activeVersionIds.length === 0) { + if (dataIsComplete && activeVersionIds.length === 0) { return ; } diff --git a/apps/spruce/src/pages/waterfall/WaterfallMenu/OmitInactiveBuilds.tsx b/apps/spruce/src/pages/waterfall/WaterfallMenu/OmitInactiveBuilds.tsx new file mode 100644 index 0000000000..1a37ce70ef --- /dev/null +++ b/apps/spruce/src/pages/waterfall/WaterfallMenu/OmitInactiveBuilds.tsx @@ -0,0 +1,50 @@ +import styled from "@emotion/styled"; +import { Checkbox } from "@leafygreen-ui/checkbox"; +import { FocusableMenuItem } from "@leafygreen-ui/menu"; +import Cookies from "js-cookie"; +import { useWaterfallAnalytics } from "analytics"; +import { OMIT_INACTIVE_WATERFALL_BUILDS } from "constants/cookies"; + +interface OmitInactiveBuildsProps { + omitInactiveBuilds: boolean; + setOmitInactiveBuilds: (value: boolean) => void; +} + +export const OmitInactiveBuilds: React.FC = ({ + omitInactiveBuilds, + setOmitInactiveBuilds, +}) => { + const { sendEvent } = useWaterfallAnalytics(); + + const handleChange = (e: React.ChangeEvent) => { + const newValue = e.target.checked; + setOmitInactiveBuilds(newValue); + Cookies.set(OMIT_INACTIVE_WATERFALL_BUILDS, newValue.toString(), { + expires: 365, + }); + sendEvent({ name: "Toggled omit inactive builds", enabled: newValue }); + }; + + return ( + + + + ); +}; + +const StyledFocusableMenuItem = styled(FocusableMenuItem)` + padding-left: 14px; + padding-right: 14px; +`; + +const StyledCheckbox = styled(Checkbox)` + label > span { + font-weight: 500; + } +`; diff --git a/apps/spruce/src/pages/waterfall/WaterfallMenu/index.tsx b/apps/spruce/src/pages/waterfall/WaterfallMenu/index.tsx index 9a8842d9ac..ca86e39cfe 100644 --- a/apps/spruce/src/pages/waterfall/WaterfallMenu/index.tsx +++ b/apps/spruce/src/pages/waterfall/WaterfallMenu/index.tsx @@ -1,4 +1,5 @@ import { useState } from "react"; +import { MenuSeparator, SubMenu } from "@leafygreen-ui/menu"; import Icon from "@evg-ui/lib/components/Icon"; import { ButtonDropdown, DropdownItem } from "components/ButtonDropdown"; import { walkthroughSteps, waterfallGuideId } from "../constants"; @@ -6,50 +7,63 @@ import { AddNotification } from "./AddNotification"; import { ClearAllFilters } from "./ClearAllFilters"; import { GitCommitSearch } from "./GitCommitSearch"; import { JumpToMostRecent } from "./JumpToMostRecent"; +import { OmitInactiveBuilds } from "./OmitInactiveBuilds"; const menuProps = { [waterfallGuideId]: walkthroughSteps[4].targetId }; type Props = { + omitInactiveBuilds: boolean; projectIdentifier: string; restartWalkthrough: () => void; + setOmitInactiveBuilds: (value: boolean) => void; }; export const WaterfallMenu: React.FC = ({ + omitInactiveBuilds, projectIdentifier, restartWalkthrough, + setOmitInactiveBuilds, }) => { const [menuOpen, setMenuOpen] = useState(false); - const dropdownItems = [ - , - , - , - , - } - onClick={() => { - setMenuOpen(false); - restartWalkthrough(); - }} - > - Restart walkthrough - , - ]; - return ( + > + + + + + } + onClick={() => { + setMenuOpen(false); + restartWalkthrough(); + }} + > + Restart walkthrough + + + + + } title="Settings"> + + + ); }; diff --git a/apps/spruce/src/pages/waterfall/index.tsx b/apps/spruce/src/pages/waterfall/index.tsx index d305839d46..f66b9ded4f 100644 --- a/apps/spruce/src/pages/waterfall/index.tsx +++ b/apps/spruce/src/pages/waterfall/index.tsx @@ -1,6 +1,7 @@ import { Suspense, useCallback, useRef, useState } from "react"; import { Global, css } from "@emotion/react"; import styled from "@emotion/styled"; +import Cookies from "js-cookie"; import { useParams } from "react-router-dom"; import { size } from "@evg-ui/lib/constants/tokens"; import { usePageTitle } from "@evg-ui/lib/hooks/usePageTitle"; @@ -9,6 +10,7 @@ import { ProjectBanner, RepotrackerBanner } from "components/Banners"; import FilterChips, { useFilterChipQueryParams } from "components/FilterChips"; import { navBarHeight } from "components/styles/Layout"; import { WalkthroughGuideCueRef } from "components/WalkthroughGuideCue"; +import { OMIT_INACTIVE_WATERFALL_BUILDS } from "constants/cookies"; import { slugs } from "constants/routes"; import { waterfallPageContainerId } from "./constants"; import { Pagination, WaterfallFilterOptions } from "./types"; @@ -29,6 +31,10 @@ const Waterfall: React.FC = () => { const [pagination, setPagination] = useState(); + const [omitInactiveBuilds, setOmitInactiveBuilds] = useState( + Cookies.get(OMIT_INACTIVE_WATERFALL_BUILDS) === "true", + ); + const guideCueRef = useRef(null); const restartWalkthrough = useCallback( () => guideCueRef.current?.restart(), @@ -44,9 +50,11 @@ const Waterfall: React.FC = () => { { diff --git a/apps/spruce/src/pages/waterfall/useFilters.test.tsx b/apps/spruce/src/pages/waterfall/useFilters.test.tsx index 23e21941bb..8213e36668 100644 --- a/apps/spruce/src/pages/waterfall/useFilters.test.tsx +++ b/apps/spruce/src/pages/waterfall/useFilters.test.tsx @@ -24,6 +24,7 @@ describe("useFilters", () => { useFilters({ activeVersionIds: ["b", "c", "f"], flattenedVersions: versions, + omitInactiveBuilds: false, pins: [], }), { @@ -43,6 +44,7 @@ describe("useFilters", () => { useFilters({ activeVersionIds: ["b", "c", "f"], flattenedVersions: versions, + omitInactiveBuilds: false, pins: [], }), { @@ -75,6 +77,7 @@ describe("useFilters", () => { useFilters({ activeVersionIds: ["b", "c", "f"], flattenedVersions: versions, + omitInactiveBuilds: false, pins: ["3", "2"], }), { @@ -101,6 +104,7 @@ describe("useFilters", () => { useFilters({ activeVersionIds: ["b", "c", "f"], flattenedVersions: versions, + omitInactiveBuilds: false, pins: [], }), { @@ -130,6 +134,7 @@ describe("useFilters", () => { useFilters({ activeVersionIds: ["b", "c", "f"], flattenedVersions: versions, + omitInactiveBuilds: false, pins: [], }), { @@ -165,6 +170,7 @@ describe("useFilters", () => { useFilters({ activeVersionIds: ["b", "c", "f"], flattenedVersions: versions, + omitInactiveBuilds: false, pins: [], }), { @@ -211,6 +217,7 @@ describe("useFilters", () => { useFilters({ activeVersionIds: ["b", "c", "f"], flattenedVersions: versions, + omitInactiveBuilds: false, pins: [], }), { @@ -263,6 +270,7 @@ describe("useFilters", () => { useFilters({ activeVersionIds: ["b", "c", "f"], flattenedVersions: versions, + omitInactiveBuilds: false, pins: [], }), { @@ -292,6 +300,7 @@ describe("useFilters", () => { useFilters({ activeVersionIds: ["b", "c", "f"], flattenedVersions: versions, + omitInactiveBuilds: false, pins: [], }), { @@ -338,6 +347,7 @@ describe("useFilters", () => { useFilters({ activeVersionIds: ["b", "c", "f"], flattenedVersions: versions, + omitInactiveBuilds: false, pins: [], }), { @@ -383,6 +393,7 @@ describe("useFilters", () => { useFilters({ activeVersionIds: ["b", "c", "f"], flattenedVersions: versions, + omitInactiveBuilds: false, pins: [], }), { diff --git a/apps/spruce/src/pages/waterfall/useFilters.ts b/apps/spruce/src/pages/waterfall/useFilters.ts index cb8d357925..009bbf8e5e 100644 --- a/apps/spruce/src/pages/waterfall/useFilters.ts +++ b/apps/spruce/src/pages/waterfall/useFilters.ts @@ -14,12 +14,14 @@ import { groupBuildVariants, groupInactiveVersions } from "./utils"; type UseFiltersProps = { activeVersionIds: Pagination["activeVersionIds"]; flattenedVersions: Version[]; + omitInactiveBuilds: boolean; pins: string[]; }; export const useFilters = ({ activeVersionIds, flattenedVersions, + omitInactiveBuilds, pins, }: UseFiltersProps) => { const buildVariants = groupBuildVariants(flattenedVersions); @@ -84,6 +86,14 @@ export const useFilters = ({ const activeBuilds: Build[] = []; bv.builds.forEach((b) => { if (activeVersions.find(({ id }) => id === b.version)) { + // Omit inactive builds if setting is enabled and filtering is active + if ( + omitInactiveBuilds && + buildVariantFilterRegex.length && + !b.activated + ) { + return; + } if (taskFilterRegex.length || statuses.length) { const activeTasks = b.tasks.filter( (t) => @@ -108,6 +118,7 @@ export const useFilters = ({ buildVariantFilterRegex, buildVariants, flattenedVersions, + omitInactiveBuilds, pins, requesters, statuses, From f06d5c904c9eb674fd0fb0b6d7f5ef75847d300c Mon Sep 17 00:00:00 2001 From: Sophie Stadler Date: Tue, 3 Feb 2026 12:07:25 -0500 Subject: [PATCH 2/4] Fix loading state --- apps/spruce/src/pages/waterfall/WaterfallGrid.tsx | 2 +- apps/spruce/src/pages/waterfall/WaterfallMenu/index.tsx | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx b/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx index 92bcae07db..c2391bb62f 100644 --- a/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx +++ b/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx @@ -245,7 +245,7 @@ export const WaterfallGrid: React.FC = ({ const isHighlighted = (v: Version, i: number) => (revision !== null && v.revision.includes(revision)) || (!!date && i === 0); - if (dataIsComplete && activeVersionIds.length === 0) { + if (dataIsComplete && !isPending && activeVersionIds.length === 0) { return ; } diff --git a/apps/spruce/src/pages/waterfall/WaterfallMenu/index.tsx b/apps/spruce/src/pages/waterfall/WaterfallMenu/index.tsx index ca86e39cfe..cf5061b605 100644 --- a/apps/spruce/src/pages/waterfall/WaterfallMenu/index.tsx +++ b/apps/spruce/src/pages/waterfall/WaterfallMenu/index.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { MenuSeparator, SubMenu } from "@leafygreen-ui/menu"; +import { MenuGroup, MenuSeparator } from "@leafygreen-ui/menu"; import Icon from "@evg-ui/lib/components/Icon"; import { ButtonDropdown, DropdownItem } from "components/ButtonDropdown"; import { walkthroughSteps, waterfallGuideId } from "../constants"; @@ -57,13 +57,13 @@ export const WaterfallMenu: React.FC = ({ - } title="Settings"> + } title="Settings"> - + ); }; From 6953b84bb9d835d24cb6cc7afcc243b7178538c1 Mon Sep 17 00:00:00 2001 From: Sophie Stadler Date: Tue, 3 Feb 2026 12:19:29 -0500 Subject: [PATCH 3/4] Add tests --- .../cypress/integration/waterfall/menu.ts | 42 ++++++ .../WaterfallMenu/WaterfallMenu.test.tsx | 121 ++++++++++++++++++ 2 files changed, 163 insertions(+) create mode 100644 apps/spruce/src/pages/waterfall/WaterfallMenu/WaterfallMenu.test.tsx diff --git a/apps/spruce/cypress/integration/waterfall/menu.ts b/apps/spruce/cypress/integration/waterfall/menu.ts index 18246f9f76..cbfb13e952 100644 --- a/apps/spruce/cypress/integration/waterfall/menu.ts +++ b/apps/spruce/cypress/integration/waterfall/menu.ts @@ -1,5 +1,47 @@ import { mockErrorResponse } from "../../utils/mockErrorResponse"; +describe("Waterfall menu settings", () => { + beforeEach(() => { + cy.visit("/project/evergreen/waterfall"); + }); + + it("displays Settings section with omit inactive builds option", () => { + cy.dataCy("waterfall-menu").click(); + cy.contains("Settings").should("be.visible"); + cy.dataCy("omit-inactive-builds-checkbox").should("be.visible"); + }); + + it("toggles the omit inactive builds checkbox and persists the setting", () => { + cy.dataCy("waterfall-menu").click(); + cy.dataCy("omit-inactive-builds-checkbox").should("not.be.checked"); + cy.dataCy("omit-inactive-builds-checkbox").check({ force: true }); + cy.dataCy("omit-inactive-builds-checkbox").should("be.checked"); + + cy.reload(); + cy.dataCy("waterfall-menu").click(); + cy.dataCy("omit-inactive-builds-checkbox").should("be.checked"); + + cy.dataCy("omit-inactive-builds-checkbox").uncheck({ force: true }); + }); + + it("omits inactive build variants when filter is applied and setting is enabled", () => { + cy.dataCy("build-variant-filter-input").type("Lint{enter}"); + cy.dataCy("build-variant-label").should("have.length", 1); + + cy.dataCy("waterfall-menu").click(); + cy.dataCy("omit-inactive-builds-checkbox").check({ force: true }); + cy.get("body").click(); + + cy.dataCy("build-variant-filter-input").clear(); + cy.dataCy("build-variant-filter-input").type("Ubuntu{enter}"); + + cy.dataCy("build-variant-label").should("have.length.at.least", 1); + + cy.dataCy("waterfall-menu").click(); + cy.dataCy("omit-inactive-builds-checkbox").uncheck({ force: true }); + }); +}); + describe("Waterfall subscription modal", () => { const route = "/project/spruce/waterfall"; const type = "project"; diff --git a/apps/spruce/src/pages/waterfall/WaterfallMenu/WaterfallMenu.test.tsx b/apps/spruce/src/pages/waterfall/WaterfallMenu/WaterfallMenu.test.tsx new file mode 100644 index 0000000000..b36180204b --- /dev/null +++ b/apps/spruce/src/pages/waterfall/WaterfallMenu/WaterfallMenu.test.tsx @@ -0,0 +1,121 @@ +import { MemoryRouter } from "react-router-dom"; +import { RenderFakeToastContext } from "@evg-ui/lib/context/toast/__mocks__"; +import { + MockedProvider, + render, + screen, + userEvent, + waitFor, +} from "@evg-ui/lib/test_utils"; +import { WaterfallMenu } from "."; + +const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( + + {children} + +); + +const renderWaterfallMenu = (props: { + omitInactiveBuilds?: boolean; + projectIdentifier?: string; + restartWalkthrough?: () => void; + setOmitInactiveBuilds?: (value: boolean) => void; +}) => { + const { + omitInactiveBuilds = false, + projectIdentifier = "spruce", + restartWalkthrough = vi.fn(), + setOmitInactiveBuilds = vi.fn(), + } = props; + + const { Component } = RenderFakeToastContext( + + + , + ); + + return render(, { wrapper: Wrapper }); +}; + +describe("WaterfallMenu", () => { + it("renders the menu button", async () => { + const user = userEvent.setup(); + renderWaterfallMenu({}); + + const menuButton = screen.getByRole("button"); + expect(menuButton).toBeInTheDocument(); + + await user.click(menuButton); + + await waitFor(() => { + expect(screen.getByText("Settings")).toBeVisible(); + }); + }); + + it("renders the Settings section with OmitInactiveBuilds option", async () => { + const user = userEvent.setup(); + renderWaterfallMenu({}); + + await user.click(screen.getByRole("button")); + + await waitFor(() => { + expect(screen.getByText("Omit inactive builds")).toBeVisible(); + }); + }); + + it("calls setOmitInactiveBuilds when checkbox is toggled", async () => { + const setOmitInactiveBuilds = vi.fn(); + const user = userEvent.setup(); + + renderWaterfallMenu({ setOmitInactiveBuilds }); + + await user.click(screen.getByRole("button")); + + await waitFor(() => { + expect(screen.getByText("Omit inactive builds")).toBeVisible(); + }); + + await user.click(screen.getByText("Omit inactive builds")); + + expect(setOmitInactiveBuilds).toHaveBeenCalledWith(true); + }); + + it("renders all menu items", async () => { + const user = userEvent.setup(); + renderWaterfallMenu({}); + + await user.click(screen.getByRole("button")); + + await waitFor(() => { + expect(screen.getByText("Search by git hash")).toBeVisible(); + }); + + expect(screen.getByText("Jump to most recent commit")).toBeVisible(); + expect(screen.getByText("Clear all filters")).toBeVisible(); + expect(screen.getByText("Add notification")).toBeVisible(); + expect(screen.getByText("Restart walkthrough")).toBeVisible(); + expect(screen.getByText("Settings")).toBeVisible(); + }); + + it("calls restartWalkthrough when restart walkthrough is clicked", async () => { + const restartWalkthrough = vi.fn(); + const user = userEvent.setup(); + + renderWaterfallMenu({ restartWalkthrough }); + + await user.click(screen.getByRole("button")); + + await waitFor(() => { + expect(screen.getByText("Restart walkthrough")).toBeVisible(); + }); + + await user.click(screen.getByText("Restart walkthrough")); + + expect(restartWalkthrough).toHaveBeenCalled(); + }); +}); From 2ee63f4ff785b13de447635966779df40a4f2bbe Mon Sep 17 00:00:00 2001 From: Sophie Stadler Date: Tue, 3 Feb 2026 19:13:42 -0500 Subject: [PATCH 4/4] Simplify state --- .../pages/waterfall/RequesterFilter/index.tsx | 8 +- .../pages/waterfall/StatusFilter/index.tsx | 8 +- .../src/pages/waterfall/WaterfallGrid.tsx | 93 ++++++++----------- 3 files changed, 41 insertions(+), 68 deletions(-) diff --git a/apps/spruce/src/pages/waterfall/RequesterFilter/index.tsx b/apps/spruce/src/pages/waterfall/RequesterFilter/index.tsx index 111ecc9059..30aca12196 100644 --- a/apps/spruce/src/pages/waterfall/RequesterFilter/index.tsx +++ b/apps/spruce/src/pages/waterfall/RequesterFilter/index.tsx @@ -1,4 +1,3 @@ -import { useTransition } from "react"; import { Combobox, ComboboxOption } from "@leafygreen-ui/combobox"; import { useQueryParam } from "@evg-ui/lib/hooks"; import { useWaterfallAnalytics } from "analytics"; @@ -7,28 +6,25 @@ import { WaterfallFilterOptions } from "../types"; export const RequesterFilter = () => { const { sendEvent } = useWaterfallAnalytics(); - const [, startTransition] = useTransition(); const [requesters, setRequesters] = useQueryParam( WaterfallFilterOptions.Requesters, [], ); const handleChange = (value: string[]) => { - startTransition(() => { - setRequesters(value); - }); + setRequesters(value); sendEvent({ name: "Filtered by requester", requesters: value }); }; return ( {mainlineRequesters.map((requester) => ( { const { sendEvent } = useWaterfallAnalytics(); - const [, startTransition] = useTransition(); const [statuses, setStatuses] = useQueryParam( WaterfallFilterOptions.Statuses, [], ); const handleChange = (value: string[]) => { - startTransition(() => { - setStatuses(value); - }); + setStatuses(value); sendEvent({ name: "Filtered by task status", statuses: value }); }; return ( {SortedTaskStatus.map((ts) => ( ; -const resetFilterState: ServerFilters = { - requesters: undefined, - statuses: undefined, - tasks: undefined, - variants: undefined, -}; - type WaterfallGridProps = { guideCueRef: React.RefObject; omitInactiveBuilds: boolean; @@ -106,6 +106,19 @@ export const WaterfallGrid: React.FC = ({ }); }, [pins, projectIdentifier]); + const [requesters] = useQueryParam( + WaterfallFilterOptions.Requesters, + [], + ); + const [statuses] = useQueryParam( + WaterfallFilterOptions.Statuses, + [], + ); + const [tasks] = useQueryParam(WaterfallFilterOptions.Task, []); + const [variants] = useQueryParam( + WaterfallFilterOptions.BuildVariant, + [], + ); const [maxOrder] = useQueryParam(WaterfallFilterOptions.MaxOrder, 0); const [minOrder] = useQueryParam(WaterfallFilterOptions.MinOrder, 0); const [revision] = useQueryParam( @@ -117,40 +130,18 @@ export const WaterfallGrid: React.FC = ({ const timezone = useUserTimeZone() ?? utcTimeZone; const utcDate = getUTCEndOfDay(date, timezone); - const [allQueryParams] = useQueryParams(); - // Initialize serverFilters from query params to avoid double query - const getServerFiltersFromParams = (): ServerFilters => { - const hasServerParams = - Object.keys(allQueryParams).includes(WaterfallFilterOptions.Requesters) || - Object.keys(allQueryParams).includes(WaterfallFilterOptions.Statuses) || - Object.keys(allQueryParams).includes(WaterfallFilterOptions.Task) || - Object.keys(allQueryParams).includes(WaterfallFilterOptions.BuildVariant); - - if (!hasServerParams) { - return resetFilterState; - } - - return { - requesters: allQueryParams[WaterfallFilterOptions.Requesters] as - | string[] - | undefined, - statuses: allQueryParams[WaterfallFilterOptions.Statuses] as - | string[] - | undefined, - tasks: allQueryParams[WaterfallFilterOptions.Task] as - | string[] - | undefined, - variants: allQueryParams[WaterfallFilterOptions.BuildVariant] as - | string[] - | undefined, - }; - }; + const getServerFiltersFromParams = () => ({ + requesters, + statuses, + tasks, + variants, + }); - // TODO DEVPROD-23578: serverFilters can be calculated from queryParams directly, useState should not be necessary const [serverFilters, setServerFilters] = useState( getServerFiltersFromParams, ); + const serverParams = useDeferredValue(serverFilters); const { data, dataState } = useSuspenseQuery< WaterfallQuery, @@ -165,7 +156,7 @@ export const WaterfallGrid: React.FC = ({ omitInactiveBuilds, revision, date: utcDate, - ...serverFilters, + ...serverParams, }, }, // @ts-expect-error pollInterval isn't officially supported by useSuspenseQuery, but it works so let's use it anyway. @@ -215,29 +206,19 @@ export const WaterfallGrid: React.FC = ({ useEffect(() => { const newFilters = getServerFiltersFromParams(); - const hasServerParams = - Object.keys(allQueryParams).includes(WaterfallFilterOptions.Requesters) || - Object.keys(allQueryParams).includes(WaterfallFilterOptions.Statuses) || - Object.keys(allQueryParams).includes(WaterfallFilterOptions.Task) || - Object.keys(allQueryParams).includes(WaterfallFilterOptions.BuildVariant); - - // Check if filters have actually changed - const filtersChanged = - JSON.stringify(serverFilters) !== JSON.stringify(newFilters); + const hasServerParams = Object.values(newFilters).some((f) => f.length > 0); // Only apply server filters if we have server params and need more results // Otherwise, use client-side filtering if (activeVersionIds.length < VERSION_LIMIT && hasServerParams) { - if (filtersChanged) { - startTransition(() => { - setServerFilters(newFilters); - }); - } - } else if (!hasServerParams && filtersChanged) { - // Because this data is already loaded and no animation is necessary, omitting startTransition for snappiness - setServerFilters(resetFilterState); // eslint-disable-line react-hooks/set-state-in-effect + startTransition(() => { + setServerFilters(newFilters); + }); + } else if (!hasServerParams) { + // This data may or may not be cached, but either way it seems more natural not to show fetchMore upon clearing filters + setServerFilters(newFilters); // eslint-disable-line react-hooks/set-state-in-effect } - }, [allQueryParams, activeVersionIds.length, serverFilters]); // eslint-disable-line react-hooks/exhaustive-deps + }, [activeVersionIds.length, requesters, statuses, tasks, variants]); // eslint-disable-line react-hooks/exhaustive-deps const firstActiveVersionId = activeVersionIds[0]; const lastActiveVersionId = activeVersionIds[activeVersionIds.length - 1];