diff --git a/apps/spruce/cypress/integration/waterfall/pagination.ts b/apps/spruce/cypress/integration/waterfall/pagination.ts index ec4726dfb..ce1883646 100644 --- a/apps/spruce/cypress/integration/waterfall/pagination.ts +++ b/apps/spruce/cypress/integration/waterfall/pagination.ts @@ -6,12 +6,14 @@ describe("pagination", () => { it("url query params update as page changes", () => { cy.location("search").should("equal", ""); + cy.dataCy("version-labels").should("contain.text", "2ab1c56"); + cy.dataCy("next-page-button").click(); - cy.dataCy("waterfall-skeleton").should("not.exist"); + cy.dataCy("version-labels").should("contain.text", "e391612"); cy.location("search").should("contain", "maxOrder"); cy.dataCy("prev-page-button").click(); - cy.dataCy("waterfall-skeleton").should("not.exist"); + cy.dataCy("version-labels").should("contain.text", "2ab1c56"); cy.location("search").should("contain", "minOrder"); }); diff --git a/apps/spruce/cypress/integration/waterfall/waterfall.ts b/apps/spruce/cypress/integration/waterfall/waterfall.ts index c8747e061..8012426a9 100644 --- a/apps/spruce/cypress/integration/waterfall/waterfall.ts +++ b/apps/spruce/cypress/integration/waterfall/waterfall.ts @@ -123,4 +123,24 @@ describe("waterfall page", () => { cy.dataCy("version-label-active").should("have.length", 5); }); }); + + describe("pinned build variants", () => { + beforeEach(() => { + cy.visit("/project/evergreen/waterfall"); + }); + + it("clicking the pin button moves the build variant to the top, persist on reload, and unpin on click", () => { + cy.dataCy("build-variant-link").first().should("have.text", "Lint"); + cy.dataCy("pin-button").eq(1).click(); + cy.dataCy("build-variant-link") + .first() + .should("have.text", "Ubuntu 16.04"); + cy.reload(); + cy.dataCy("build-variant-link") + .first() + .should("have.text", "Ubuntu 16.04"); + cy.dataCy("pin-button").eq(1).click(); + cy.dataCy("build-variant-link").first().should("have.text", "Lint"); + }); + }); }); diff --git a/apps/spruce/src/constants/index.ts b/apps/spruce/src/constants/index.ts index f20831617..ca1e1146e 100644 --- a/apps/spruce/src/constants/index.ts +++ b/apps/spruce/src/constants/index.ts @@ -10,3 +10,4 @@ export const FASTER_POLL_INTERVAL = DEFAULT_POLL_INTERVAL / 3; export const PAGE_SIZES = [10, 20, 50, 100]; export const RECENT_PAGE_SIZE_KEY = "recentPageSize"; export const DEFAULT_PAGE_SIZE = 10; +export const WATERFALL_PINNED_VARIANTS_KEY = "waterfall-pinned-variants"; diff --git a/apps/spruce/src/pages/waterfall/BuildRow.tsx b/apps/spruce/src/pages/waterfall/BuildRow.tsx index 0e3e6eb22..5cef0300d 100644 --- a/apps/spruce/src/pages/waterfall/BuildRow.tsx +++ b/apps/spruce/src/pages/waterfall/BuildRow.tsx @@ -1,10 +1,12 @@ import { memo, useCallback } from "react"; import styled from "@emotion/styled"; +import IconButton from "@leafygreen-ui/icon-button"; import { palette } from "@leafygreen-ui/palette"; import { Link } from "react-router-dom"; import { taskStatusToCopy } from "@evg-ui/lib/constants/task"; import { TaskStatus } from "@evg-ui/lib/types/task"; import { useWaterfallAnalytics } from "analytics"; +import Icon from "components/Icon"; import { StyledLink } from "components/styles"; import { getTaskRoute, getVariantHistoryRoute } from "constants/routes"; import { size } from "constants/tokens"; @@ -21,11 +23,21 @@ import { WaterfallVersion } from "./types"; const { black, gray, white } = palette; -export const BuildRow: React.FC<{ +type Props = { build: WaterfallBuildVariant; + handlePinClick: () => void; + pinned: boolean; projectIdentifier: string; versions: WaterfallVersion[]; -}> = ({ build, projectIdentifier, versions }) => { +}; + +export const BuildRow: React.FC = ({ + build, + handlePinClick, + pinned, + projectIdentifier, + versions, +}) => { const { sendEvent } = useWaterfallAnalytics(); const handleVariantClick = useCallback( () => sendEvent({ name: "Clicked variant label" }), @@ -45,7 +57,16 @@ export const BuildRow: React.FC<{ return ( + + + @@ -122,6 +143,11 @@ const Build = styled.div` ${columnBasis} `; +const StyledIconButton = styled(IconButton)` + top: -${size.xxs}; + ${({ active }) => active && "transform: rotate(-30deg);"} +`; + const SQUARE_SIZE = 16; const Square = styled(Link)<{ status: TaskStatus }>` diff --git a/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx b/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx index 4403247d3..851b7b398 100644 --- a/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx +++ b/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx @@ -1,7 +1,10 @@ -import { useEffect, useRef } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useSuspenseQuery } from "@apollo/client"; import styled from "@emotion/styled"; -import { DEFAULT_POLL_INTERVAL } from "constants/index"; +import { + DEFAULT_POLL_INTERVAL, + WATERFALL_PINNED_VARIANTS_KEY, +} from "constants/index"; import { WaterfallPagination, WaterfallQuery, @@ -11,6 +14,7 @@ import { WATERFALL } from "gql/queries"; import { useDimensions } from "hooks/useDimensions"; import { useQueryParam } from "hooks/useQueryParam"; import { WaterfallFilterOptions } from "types/waterfall"; +import { getObject, setObject } from "utils/localStorage"; import { BuildRow } from "./BuildRow"; import { InactiveVersionsButton } from "./InactiveVersions"; import { @@ -35,6 +39,33 @@ export const WaterfallGrid: React.FC = ({ }) => { useWaterfallTrace(); + const [pins, setPins] = useState( + getObject(WATERFALL_PINNED_VARIANTS_KEY)?.[projectIdentifier] ?? [], + ); + + const handlePinBV = useCallback( + (buildVariant: string) => () => { + setPins((prev: string[]) => { + const bvIndex = prev.indexOf(buildVariant); + if (bvIndex > -1) { + const removed = [...prev]; + removed.splice(bvIndex, 1); + return removed; + } + return [...prev, buildVariant]; + }); + }, + [], + ); + + useEffect(() => { + const bvs = getObject(WATERFALL_PINNED_VARIANTS_KEY); + setObject(WATERFALL_PINNED_VARIANTS_KEY, { + ...bvs, + [projectIdentifier]: pins, + }); + }, [pins, projectIdentifier]); + const [maxOrder] = useQueryParam(WaterfallFilterOptions.MaxOrder, 0); const [minOrder] = useQueryParam(WaterfallFilterOptions.MinOrder, 0); @@ -66,6 +97,7 @@ export const WaterfallGrid: React.FC = ({ const { buildVariants, versions } = useFilters({ buildVariants: data.waterfall.buildVariants, flattenedVersions: data.waterfall.flattenedVersions, + pins, }); return ( @@ -95,6 +127,8 @@ export const WaterfallGrid: React.FC = ({ diff --git a/apps/spruce/src/pages/waterfall/index.tsx b/apps/spruce/src/pages/waterfall/index.tsx index ab5736235..14320d30e 100644 --- a/apps/spruce/src/pages/waterfall/index.tsx +++ b/apps/spruce/src/pages/waterfall/index.tsx @@ -74,6 +74,7 @@ const Waterfall: React.FC = () => { } > @@ -86,7 +87,7 @@ const Waterfall: React.FC = () => { const PageContainer = styled.div` display: flex; flex-direction: column; - padding: ${size.m} ${size.l}; + padding: ${size.m}; `; /* Safari performance of the waterfall chokes if using overflow-y: scroll, so we need the page to scroll instead. diff --git a/apps/spruce/src/pages/waterfall/styles.ts b/apps/spruce/src/pages/waterfall/styles.ts index d70f812be..48d678cb9 100644 --- a/apps/spruce/src/pages/waterfall/styles.ts +++ b/apps/spruce/src/pages/waterfall/styles.ts @@ -23,8 +23,10 @@ export const gridGroupCss = css` export const BuildVariantTitle = styled.div` ${wordBreakCss} + display: flex; flex-grow: 0; flex-shrink: 0; + gap: ${size.xxs}; width: ${BUILD_VARIANT_WIDTH}px; `; diff --git a/apps/spruce/src/pages/waterfall/useFilters.test.tsx b/apps/spruce/src/pages/waterfall/useFilters.test.tsx index 25ae4a0c4..7285979ef 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({ buildVariants: waterfall.buildVariants, flattenedVersions, + pins: [], }), { wrapper: createWrapper(), @@ -38,6 +39,7 @@ describe("useFilters", () => { useFilters({ buildVariants: waterfall.buildVariants, flattenedVersions, + pins: [], }), { wrapper: createWrapper({ @@ -64,6 +66,35 @@ describe("useFilters", () => { }); }); + describe("pinned build variants", () => { + it("should push pins to the top of list of build variants and preserve their original order", () => { + const { result } = renderHook( + () => + useFilters({ + buildVariants: waterfall.buildVariants, + flattenedVersions, + pins: ["3", "2"], + }), + { + wrapper: createWrapper({ + initialEntry: "/project/spruce/waterfall", + }), + }, + ); + + const pinnedWaterfall = { + ...waterfall, + buildVariants: [ + waterfall.buildVariants[1], + waterfall.buildVariants[2], + waterfall.buildVariants[0], + ], + }; + + expect(result.current).toMatchObject(pinnedWaterfall); + }); + }); + describe("build variant filters", () => { it("should filter build variant list when filter is applied", () => { const { result } = renderHook( @@ -71,6 +102,7 @@ describe("useFilters", () => { useFilters({ buildVariants: waterfall.buildVariants, flattenedVersions, + pins: [], }), { wrapper: createWrapper({ @@ -93,6 +125,7 @@ describe("useFilters", () => { useFilters({ buildVariants: waterfall.buildVariants, flattenedVersions, + pins: [], }), { wrapper: createWrapper({ @@ -126,6 +159,34 @@ const waterfall = { }, ], }, + { + id: "2", + displayName: "BV 2", + version: "b", + builds: [ + { + activated: true, + displayName: "Build B", + id: "ii", + tasks: [], + version: "b", + }, + ], + }, + { + id: "3", + displayName: "BV 3", + version: "c", + builds: [ + { + activated: true, + displayName: "Build C", + id: "iii", + tasks: [], + version: "c", + }, + ], + }, ], versions: [ { diff --git a/apps/spruce/src/pages/waterfall/useFilters.ts b/apps/spruce/src/pages/waterfall/useFilters.ts index 3403dc6e4..a60a599aa 100644 --- a/apps/spruce/src/pages/waterfall/useFilters.ts +++ b/apps/spruce/src/pages/waterfall/useFilters.ts @@ -12,11 +12,13 @@ import { groupInactiveVersions } from "./utils"; type UseFiltersProps = { buildVariants: WaterfallBuildVariant[]; flattenedVersions: WaterfallVersionFragment[]; + pins: string[]; }; export const useFilters = ({ buildVariants, flattenedVersions, + pins, }: UseFiltersProps) => { const [requesters] = useQueryParam( WaterfallFilterOptions.Requesters, @@ -101,11 +103,23 @@ export const useFilters = ({ ); const buildVariantsResult = useMemo(() => { - if (!hasFilters) { + if (!hasFilters && !pins.length) { return buildVariants; } const bvs: WaterfallBuildVariant[] = []; + + let pinIndex = 0; + const pushVariant = (variant: WaterfallBuildVariant) => { + if (pins.includes(variant.id)) { + // If build variant is pinned, insert it at the end of the list of pinned variants + bvs.splice(pinIndex, 0, variant); + pinIndex += 1; + } else { + bvs.push(variant); + } + }; + buildVariants.forEach((bv) => { const passesBVFilter = !buildVariantFilterRegex.length || @@ -119,15 +133,21 @@ export const useFilters = ({ } }); if (activeBuilds.length) { - bvs.push({ ...bv, builds: activeBuilds }); + pushVariant({ ...bv, builds: activeBuilds }); } } else { - bvs.push(bv); + pushVariant(bv); } } }); return bvs; - }, [activeVersionIds, hasFilters, buildVariants, buildVariantFilterRegex]); + }, [ + activeVersionIds, + buildVariantFilterRegex, + buildVariants, + hasFilters, + pins, + ]); return { buildVariants: buildVariantsResult, versions: versionsResult }; }; diff --git a/apps/spruce/src/utils/localStorage/index.ts b/apps/spruce/src/utils/localStorage/index.ts new file mode 100644 index 000000000..dff819e49 --- /dev/null +++ b/apps/spruce/src/utils/localStorage/index.ts @@ -0,0 +1,19 @@ +import { reportError } from "utils/errorReporting"; + +type LocalStorageObject = Record; + +export const getObject = (key: string): LocalStorageObject => { + const obj = localStorage.getItem(key); + try { + return obj ? JSON.parse(obj) : {}; + } catch (e) { + reportError( + new Error(`Getting object '${key}' from localStorage`, { cause: e }), + ).warning(); + return {}; + } +}; + +export const setObject = (key: string, obj: LocalStorageObject) => { + localStorage.setItem(key, JSON.stringify(obj)); +}; diff --git a/apps/spruce/src/utils/localStorage/localStorage.test.ts b/apps/spruce/src/utils/localStorage/localStorage.test.ts new file mode 100644 index 000000000..a36e7f54d --- /dev/null +++ b/apps/spruce/src/utils/localStorage/localStorage.test.ts @@ -0,0 +1,33 @@ +import { getObject, setObject } from "."; + +describe("getObject", () => { + afterEach(() => { + localStorage.clear(); + }); + + it("gets object", () => { + localStorage.setItem("foo", JSON.stringify({ bar: "baz" })); + expect(getObject("foo")).toMatchObject({ bar: "baz" }); + }); + + it("catches error and returns empty object when fetching malformed object", () => { + localStorage.setItem("invalid", "{"); + expect(() => getObject("invalid")).not.toThrowError(); + expect(getObject("invalid")).toMatchObject({}); + }); + + it("returns empty object when looking up missing key", () => { + expect(getObject("nonexistent")).toMatchObject({}); + }); +}); + +describe("setObject", () => { + afterEach(() => { + localStorage.clear(); + }); + + it("stores object as a JSON-encoded string", () => { + setObject("foo", { bar: "baz" }); + expect(localStorage.getItem("foo")).toEqual('{"bar":"baz"}'); + }); +});