Skip to content
Draft
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
42 changes: 42 additions & 0 deletions apps/spruce/cypress/integration/waterfall/menu.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
20 changes: 10 additions & 10 deletions apps/spruce/src/components/ButtonDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,46 +1,46 @@
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<React.SetStateAction<boolean>>;
}
} & Omit<MenuProps, "children" | "refEl" | "trigger">;

export const ButtonDropdown: React.FC<Props> = ({
children,
"data-cy": dataCy = "ellipsis-btn",
disabled = false,
dropdownItems,
loading = false,
open = undefined,
setOpen = undefined,
size = "small",
...rest
...menuProps
}) => (
<Menu
adjustOnMutation
data-cy="card-dropdown"
open={open}
setOpen={setOpen}
{...menuProps}
trigger={
<LoadingButton
data-cy={dataCy}
disabled={disabled}
loading={loading}
size={size}
{...rest}
>
<Icon glyph="Ellipsis" />
</LoadingButton>
}
>
{dropdownItems}
{dropdownItems ?? children}
</Menu>
);

Expand Down
1 change: 1 addition & 0 deletions apps/spruce/src/constants/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
6 changes: 6 additions & 0 deletions apps/spruce/src/pages/waterfall/WaterfallFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<WaterfallFiltersProps> = ({
omitInactiveBuilds,
pagination,
projectIdentifier,
restartWalkthrough,
setOmitInactiveBuilds,
}) => {
const { sendEvent } = useWaterfallAnalytics();

Expand Down Expand Up @@ -83,8 +87,10 @@ export const WaterfallFilters: React.FC<WaterfallFiltersProps> = ({
/>
</ProjectFilterItem>
<WaterfallMenu
omitInactiveBuilds={omitInactiveBuilds}
projectIdentifier={projectIdentifier}
restartWalkthrough={restartWalkthrough}
setOmitInactiveBuilds={setOmitInactiveBuilds}
/>
<PaginationButtons pagination={pagination} />
</Container>
Expand Down
81 changes: 48 additions & 33 deletions apps/spruce/src/pages/waterfall/WaterfallGrid.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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<WalkthroughGuideCueRef>;
omitInactiveBuilds: boolean;
projectIdentifier: string;
setPagination: (pagination: Pagination) => void;
guideCueRef: React.RefObject<WalkthroughGuideCueRef>;
};

export const WaterfallGrid: React.FC<WaterfallGridProps> = ({
guideCueRef,
omitInactiveBuilds,
projectIdentifier,
setPagination,
}) => {
Expand Down Expand Up @@ -104,6 +106,19 @@ export const WaterfallGrid: React.FC<WaterfallGridProps> = ({
});
}, [pins, projectIdentifier]);

const [requesters] = useQueryParam<string[]>(
WaterfallFilterOptions.Requesters,
[],
);
const [statuses] = useQueryParam<string[]>(
WaterfallFilterOptions.Statuses,
[],
);
const [tasks] = useQueryParam<string[]>(WaterfallFilterOptions.Task, []);
const [variants] = useQueryParam<string[]>(
WaterfallFilterOptions.BuildVariant,
[],
);
const [maxOrder] = useQueryParam<number>(WaterfallFilterOptions.MaxOrder, 0);
const [minOrder] = useQueryParam<number>(WaterfallFilterOptions.MinOrder, 0);
const [revision] = useQueryParam<string | null>(
Expand All @@ -115,9 +130,18 @@ export const WaterfallGrid: React.FC<WaterfallGridProps> = ({
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<ServerFilters>(resetFilterState);
// Initialize serverFilters from query params to avoid double query
const getServerFiltersFromParams = () => ({
requesters,
statuses,
tasks,
variants,
});

const [serverFilters, setServerFilters] = useState<ServerFilters>(
getServerFiltersFromParams,
);
const serverParams = useDeferredValue(serverFilters);

const { data, dataState } = useSuspenseQuery<
WaterfallQuery,
Expand All @@ -129,9 +153,10 @@ export const WaterfallGrid: React.FC<WaterfallGridProps> = ({
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.
Expand Down Expand Up @@ -173,37 +198,27 @@ export const WaterfallGrid: React.FC<WaterfallGridProps> = ({
? 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];
Expand Down
Original file line number Diff line number Diff line change
@@ -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<OmitInactiveBuildsProps> = ({
omitInactiveBuilds,
setOmitInactiveBuilds,
}) => {
const { sendEvent } = useWaterfallAnalytics();

const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<StyledFocusableMenuItem>
<StyledCheckbox
checked={omitInactiveBuilds}
data-cy="omit-inactive-builds-checkbox"
description="When filtering, omit build variants with 0 activated tasks."
label="Omit inactive builds"
onChange={handleChange}
/>
</StyledFocusableMenuItem>
);
};

const StyledFocusableMenuItem = styled(FocusableMenuItem)`
padding-left: 14px;
padding-right: 14px;
`;

const StyledCheckbox = styled(Checkbox)`
label > span {
font-weight: 500;
}
`;
Loading