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/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 e7437e96ca..8e57ab062c 100644 --- a/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx +++ b/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx @@ -1,4 +1,11 @@ -import { useCallback, useEffect, useRef, useState, useTransition } from "react"; +import { + useCallback, + useDeferredValue, + useEffect, + useRef, + useState, + useTransition, +} from "react"; import { useSuspenseQuery } from "@apollo/client/react"; import styled from "@emotion/styled"; import { size, transitionDuration } from "@evg-ui/lib/constants/tokens"; @@ -44,21 +51,16 @@ type ServerFilters = Pick< "requesters" | "statuses" | "tasks" | "variants" >; -const resetFilterState: ServerFilters = { - requesters: undefined, - statuses: undefined, - tasks: undefined, - variants: undefined, -}; - 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, }) => { @@ -104,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( @@ -115,9 +130,18 @@ export const WaterfallGrid: React.FC = ({ const timezone = useUserTimeZone() ?? utcTimeZone; const utcDate = getUTCEndOfDay(date, timezone); - // TODO DEVPROD-23578: serverFilters can be calculated from queryParams directly, useState should not be necessary - const [serverFilters, setServerFilters] = - useState(resetFilterState); + // Initialize serverFilters from query params to avoid double query + const getServerFiltersFromParams = () => ({ + requesters, + statuses, + tasks, + variants, + }); + + const [serverFilters, setServerFilters] = useState( + getServerFiltersFromParams, + ); + const serverParams = useDeferredValue(serverFilters); const { data, dataState } = useSuspenseQuery< WaterfallQuery, @@ -129,9 +153,10 @@ export const WaterfallGrid: React.FC = ({ limit: VERSION_LIMIT, maxOrder, minOrder, + 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. @@ -173,37 +198,27 @@ 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 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); + const newFilters = getServerFiltersFromParams(); + 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) { - 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); + setServerFilters(newFilters); }); } else if (!hasServerParams) { - // 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 + // 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]); // 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]; 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/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(); + }); +}); diff --git a/apps/spruce/src/pages/waterfall/WaterfallMenu/index.tsx b/apps/spruce/src/pages/waterfall/WaterfallMenu/index.tsx index 9a8842d9ac..cf5061b605 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 { 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"; @@ -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 5fe75ea8b0..3a4687a2d8 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: [], }), { diff --git a/apps/spruce/src/pages/waterfall/useFilters.ts b/apps/spruce/src/pages/waterfall/useFilters.ts index 7dd48d973a..395840326e 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,