diff --git a/apps/spruce/src/gql/client/cache.ts b/apps/spruce/src/gql/client/cache.ts index 635db70c9..72c6d6d86 100644 --- a/apps/spruce/src/gql/client/cache.ts +++ b/apps/spruce/src/gql/client/cache.ts @@ -1,5 +1,12 @@ import { InMemoryCache } from "@apollo/client"; +import { + WaterfallBuild, + WaterfallPagination, + WaterfallVersionFragment, +} from "gql/generated/types"; import { IMAGE_EVENT_LIMIT } from "pages/image/tabs/EventLogTab/useImageEvents"; +import { mergeVersions, mergeBuilds } from "./mergeFunctions"; +import { readBuilds, readVersions } from "./readFunctions"; export const cache = new InMemoryCache({ typePolicies: { @@ -9,7 +16,7 @@ export const cache = new InMemoryCache({ keyArgs: ["$distroId"], }, projectEvents: { - keyArgs: ["$identifier"], + keyArgs: ["$projectIdentifier"], }, repoEvents: { keyArgs: ["$id"], @@ -17,6 +24,138 @@ export const cache = new InMemoryCache({ hasVersion: { keyArgs: ["$patchId"], }, + waterfall: { + keyArgs: ["options", ["projectIdentifier"]], + read(existing, { args, readField, variables }) { + // A read function should always return undefined if existing is + // undefined. Returning undefined signals that the field is + // missing from the cache, which instructs Apollo Client to + // fetch its value from your GraphQL server. + if (!existing) { + return undefined; + } + + const minOrder = args?.options?.minOrder ?? 0; + const maxOrder = args?.options?.maxOrder ?? 0; + const limit = variables?.options?.limit ?? 0; + + const existingVersions = + readField( + "flattenedVersions", + existing, + ) ?? []; + + const flattenedVersions = readVersions({ + maxOrder, + minOrder, + limit, + versions: existingVersions, + readField, + }); + + const allVersionIds: string[] = []; + const activeVersionIds: string[] = []; + + flattenedVersions.forEach((v) => { + const activated = readField("activated", v) ?? false; + const versionId = readField("id", v) ?? ""; + if (activated) { + activeVersionIds.push(versionId); + } + allVersionIds.push(versionId); + }); + + if (activeVersionIds.length < limit) { + console.log("Number of active versions is below limit: ", { + flattenedVersions, + activeVersionIds, + allVersionIds, + }); + return undefined; + } + + const existingBuilds = + readField("flattenedBuilds", existing) ?? []; + + const builds = readBuilds({ + versionIds: allVersionIds, + builds: existingBuilds, + readField, + }); + + const prevOrderNumber = + readField("order", flattenedVersions[0]) ?? 0; + const nextOrderNumber = + readField( + "order", + flattenedVersions[flattenedVersions.length - 1], + ) ?? 0; + + console.log("Read result: ", { + builds, + flattenedVersions, + prevOrderNumber, + nextOrderNumber, + }); + + return { + flattenedBuilds: builds, + flattenedVersions, + pagination: { + prevPageOrder: prevOrderNumber, + nextPageOrder: nextOrderNumber, + hasNextPage: nextOrderNumber > 0, + hasPrevPage: prevOrderNumber > 0, + }, + }; + }, + merge(existing, incoming, { readField }) { + const existingVersions = + readField( + "flattenedVersions", + existing, + ) ?? []; + const incomingVersions = + readField( + "flattenedVersions", + incoming, + ) ?? []; + const versions = mergeVersions({ + existingVersions, + incomingVersions, + readField, + }); + + const existingBuilds = + readField("flattenedBuilds", existing) ?? []; + const incomingBuilds = + readField("flattenedBuilds", incoming) ?? []; + const flattenedBuilds = mergeBuilds({ + existingBuilds, + incomingBuilds, + readField, + }); + + const pagination = readField( + "pagination", + incoming, + ); + + // To help verify that this is working, inspect these variables and + // check that they do not keep increasing in length as you page + // backwards (since those results were already seen and merged). + console.log("Merge result: ", { + versions, + flattenedBuilds, + }); + + return { + flattenedBuilds, + flattenedVersions: versions, + pagination, + }; + }, + }, }, }, GeneralSubscription: { @@ -105,8 +244,5 @@ export const cache = new InMemoryCache({ }, }, }, - WaterfallBuildVariant: { - keyFields: ["version", "id"], - }, }, }); diff --git a/apps/spruce/src/gql/client/mergeFunctions.ts b/apps/spruce/src/gql/client/mergeFunctions.ts new file mode 100644 index 000000000..3af21fe0a --- /dev/null +++ b/apps/spruce/src/gql/client/mergeFunctions.ts @@ -0,0 +1,125 @@ +import { ReadFieldFunction } from "@apollo/client/cache/core/types/common"; +import { + WaterfallVersionFragment, + WaterfallBuild, + WaterfallBuildVariant, +} from "gql/generated/types"; + +type MergeBuildsProps = { + existingBuilds: readonly WaterfallBuild[]; + incomingBuilds: readonly WaterfallBuild[]; + readField: ReadFieldFunction; +}; + +/** + * `mergeBuilds` is used to merge existing and incoming builds. + * @param opts - object containing arguments to this function + * @param opts.existingBuilds - existing builds in the Apollo cache + * @param opts.incomingBuilds - incoming builds as a result of a GraphQL query + * @param opts.readField - function provided by Apollo to access fields from Reference objects + * @returns an array of builds containing no duplicates and in descending sort by the + * `order` field + */ +const mergeBuilds = ({ + existingBuilds, + incomingBuilds, + readField, +}: MergeBuildsProps): WaterfallBuild[] => { + const builds = [...existingBuilds, ...incomingBuilds]; + const buildsMap = new Map(); + + builds.forEach((b) => { + const id = readField("id", b) ?? ""; + buildsMap.set(id, b); + }); + + return Array.from(buildsMap.values()).sort((a, b) => { + const aOrder = readField("order", a) ?? 0; + const bOrder = readField("order", b) ?? 0; + return bOrder - aOrder; + }); +}; + +type MergeBuildVariantsProps = { + existingBuildVariants: readonly WaterfallBuildVariant[]; + incomingBuildVariants: readonly WaterfallBuildVariant[]; + readField: ReadFieldFunction; +}; + +/** + * `mergeBuildVariants` is used to merge existing and incoming build variants. + * @param opts - object containing arguments to this function + * @param opts.existingBuildVariants - existing build variants in the Apollo cache + * @param opts.incomingBuildVariants - incoming build variants as a result of a GraphQL query + * @param opts.readField - function provided by Apollo to access fields from Reference objects + * @returns an array of build variants containing no duplicates and in descending sort by the + * `order` field + */ +const mergeBuildVariants = ({ + existingBuildVariants, + incomingBuildVariants, + readField, +}: MergeBuildVariantsProps): WaterfallBuildVariant[] => { + const buildVariants = [...existingBuildVariants, ...incomingBuildVariants]; + + const buildVariantsMap = new Map(); + + buildVariants.forEach((bv) => { + const buildVariantId = readField("id", bv) ?? ""; + + const mapItem = buildVariantsMap.get(buildVariantId); + + if (mapItem) { + const existingBuilds = + readField("builds", mapItem) ?? []; + const incomingBuilds = readField("builds", bv) ?? []; + const mergedBuilds = mergeBuilds({ + existingBuilds, + incomingBuilds, + readField, + }); + buildVariantsMap.set(buildVariantId, { ...bv, builds: mergedBuilds }); + } else { + buildVariantsMap.set(buildVariantId, bv); + } + }); + return Array.from(buildVariantsMap.values()); +}; + +type MergeVersionsProps = { + existingVersions: readonly WaterfallVersionFragment[]; + incomingVersions: readonly WaterfallVersionFragment[]; + readField: ReadFieldFunction; +}; + +/** + * `mergeVersions` is used to merge existing and incoming versions. + * @param opts - object containing arguments to this function + * @param opts.existingVersions - existing versions in the Apollo cache + * @param opts.incomingVersions - incoming versions as a result of a GraphQL query + * @param opts.readField - function provided by Apollo to access fields from Reference objects + * @returns an array of versions (both active or inactive) that contains no duplicates and in + * descending sort by the `order` field + */ +const mergeVersions = ({ + existingVersions, + incomingVersions, + readField, +}: MergeVersionsProps): WaterfallVersionFragment[] => { + const versions = [...existingVersions, ...incomingVersions]; + + // Use a map to enforce that there are no duplicates. + const versionsMap = new Map(); + versions.forEach((v) => { + const order = readField("order", v) ?? 0; + versionsMap.set(order, v); + }); + + return Array.from(versionsMap.values()).sort((a, b) => { + const aOrder = readField("order", a) ?? 0; + const bOrder = readField("order", b) ?? 0; + return bOrder - aOrder; + }); +}; + +export { mergeBuildVariants, mergeVersions, mergeBuilds }; diff --git a/apps/spruce/src/gql/client/readFunctions.ts b/apps/spruce/src/gql/client/readFunctions.ts new file mode 100644 index 000000000..54c5b5790 --- /dev/null +++ b/apps/spruce/src/gql/client/readFunctions.ts @@ -0,0 +1,109 @@ +import { ReadFieldFunction } from "@apollo/client/cache/core/types/common"; +import { WaterfallBuild, WaterfallVersionFragment } from "gql/generated/types"; + +type ReadBuildsProps = { + versionIds: string[]; + builds: readonly WaterfallBuild[]; + readField: ReadFieldFunction; +}; + +/** + * `readBuilds` is used to read appropriate builds from the cache. + * @param opts - object containing arguments to this function + * @param opts.builds - existing builds in the Apollo cache + * @param opts.readField - function provided by Apollo to access fields from Reference objects + * @param opts.versionIds - upper bound limit for order + * @returns an array of builds containing no duplicates and in descending sort by the + * `order` field + */ +const readBuilds = ({ + builds, + readField, + versionIds, +}: ReadBuildsProps): WaterfallBuild[] => { + const resultBuilds: WaterfallBuild[] = []; + + builds.forEach((b) => { + const version = readField("version", b) ?? ""; + if (versionIds.includes(version)) { + resultBuilds.push(b); + } + }); + + return resultBuilds; +}; + +type ReadVersionsProps = { + maxOrder?: number; + minOrder?: number; + limit: number; + versions: readonly WaterfallVersionFragment[]; + readField: ReadFieldFunction; +}; + +/** + * `readVersions` is used to read the appropriate versions from the cache (if they exist). + * @param opts - object containing arguments to this function + * @param opts.readField - function provided by Apollo to access fields from Reference objects + * @param opts.versions - the existing versions in the cache + * @param opts.maxOrder - upper bound limit for order + * @param opts.minOrder - lower bound limit for order + * @param opts.limit - number of active versions to display + * @returns an array of versions (activated or inactivated) whose orders fall between prevPageOrder + * and nextPageOrder + */ +const readVersions = ({ + limit, + maxOrder, + minOrder, + readField, + versions, +}: ReadVersionsProps): WaterfallVersionFragment[] => { + const idx = versions.findIndex((v) => { + const versionOrder = readField("order", v) ?? 0; + if (minOrder) { + return versionOrder - 1 === minOrder; + } + if (maxOrder) { + return versionOrder + 1 === maxOrder; + } + return false; + }); + + let startIndex = maxOrder ? idx : 0; + let endIndex = maxOrder ? 0 : idx; + let numActivated = 0; + + // Count backwards for paginating backwards. + if (minOrder) { + for (let i = endIndex; i >= 0; i--) { + if (readField("activated", versions[i])) { + numActivated += 1; + if (numActivated === limit) { + startIndex = i; + break; + } + } + } + } + + // Count forwards for paginating forwards. + if (maxOrder) { + for (let i = startIndex; i < versions.length; i++) { + if (readField("activated", versions[i])) { + numActivated += 1; + if (numActivated === limit) { + endIndex = i; + break; + } + } + } + } + + // need code for leading inactive versions + + // Add 1 because slice is [inclusive, exclusive). + return versions.slice(startIndex, endIndex + 1); +}; + +export { readVersions, readBuilds }; diff --git a/apps/spruce/src/gql/generated/types.ts b/apps/spruce/src/gql/generated/types.ts index bad58de4f..3b2b3ba1a 100644 --- a/apps/spruce/src/gql/generated/types.ts +++ b/apps/spruce/src/gql/generated/types.ts @@ -3445,6 +3445,7 @@ export type VolumeHost = { export type Waterfall = { __typename?: "Waterfall"; buildVariants: Array; + flattenedBuilds: Array; flattenedVersions: Array; pagination: WaterfallPagination; versions: Array; @@ -3453,8 +3454,10 @@ export type Waterfall = { export type WaterfallBuild = { __typename?: "WaterfallBuild"; activated?: Maybe; + buildVariant: Scalars["String"]["output"]; displayName: Scalars["String"]["output"]; id: Scalars["String"]["output"]; + order: Scalars["Int"]["output"]; tasks: Array; version: Scalars["String"]["output"]; }; @@ -9707,25 +9710,21 @@ export type WaterfallQuery = { __typename?: "Query"; waterfall: { __typename?: "Waterfall"; - buildVariants: Array<{ - __typename?: "WaterfallBuildVariant"; + flattenedBuilds: Array<{ + __typename?: "WaterfallBuild"; + activated?: boolean | null; + buildVariant: string; displayName: string; id: string; + order: number; version: string; - builds: Array<{ - __typename?: "WaterfallBuild"; - activated?: boolean | null; + tasks: Array<{ + __typename?: "WaterfallTask"; displayName: string; + displayStatus: string; + execution: number; id: string; - version: string; - tasks: Array<{ - __typename?: "WaterfallTask"; - displayName: string; - displayStatus: string; - execution: number; - id: string; - status: string; - }>; + status: string; }>; }>; flattenedVersions: Array<{ diff --git a/apps/spruce/src/gql/queries/waterfall.graphql b/apps/spruce/src/gql/queries/waterfall.graphql index 6b1b9fb1c..a686b306e 100644 --- a/apps/spruce/src/gql/queries/waterfall.graphql +++ b/apps/spruce/src/gql/queries/waterfall.graphql @@ -2,22 +2,19 @@ query Waterfall($options: WaterfallOptions!) { waterfall(options: $options) { - buildVariants { - builds { - activated + flattenedBuilds { + activated + buildVariant + displayName + id + order + tasks { displayName + displayStatus + execution id - tasks { - displayName - displayStatus - execution - id - status - } - version + status } - displayName - id version } flattenedVersions { diff --git a/apps/spruce/src/pages/waterfall/BuildRow.tsx b/apps/spruce/src/pages/waterfall/BuildRow.tsx index db357c21c..b0bb68627 100644 --- a/apps/spruce/src/pages/waterfall/BuildRow.tsx +++ b/apps/spruce/src/pages/waterfall/BuildRow.tsx @@ -55,7 +55,7 @@ export const BuildRow: React.FC = ({ ); const { builds, displayName } = build; - let buildIndex = 0; + return ( @@ -87,9 +87,9 @@ export const BuildRow: React.FC = ({ } /* The list of builds returned does not include a placeholder for inactive builds, so we need to check whether the build matches the version in the current column. Builds are sorted in descending revision order and so match the versions' sort order. */ - if (version && version.id === builds?.[buildIndex]?.version) { - const b = builds[buildIndex]; - buildIndex += 1; + const matchingBuild = builds.find((b) => b.version === version?.id); + if (version && matchingBuild) { + const b = matchingBuild; return ( = ({ pagination, }) => { const { sendEvent } = useWaterfallAnalytics(); - const [, startTransition] = useTransition(); + // const [, startTransition] = useTransition(); const [queryParams, setQueryParams] = useQueryParams(); const { hasNextPage, hasPrevPage, nextPageOrder, prevPageOrder } = @@ -24,13 +23,12 @@ export const PaginationButtons: React.FC = ({ const onNextClick = () => { sendEvent({ name: "Changed page", direction: "next" }); - startTransition(() => { - setQueryParams({ - ...queryParams, - [WaterfallFilterOptions.Date]: undefined, - [WaterfallFilterOptions.MaxOrder]: nextPageOrder, - [WaterfallFilterOptions.MinOrder]: undefined, - }); + // Omit startTransition for now + setQueryParams({ + ...queryParams, + [WaterfallFilterOptions.Date]: undefined, + [WaterfallFilterOptions.MaxOrder]: nextPageOrder, + [WaterfallFilterOptions.MinOrder]: undefined, }); }; @@ -39,13 +37,12 @@ export const PaginationButtons: React.FC = ({ name: "Changed page", direction: "previous", }); - startTransition(() => { - setQueryParams({ - ...queryParams, - [WaterfallFilterOptions.Date]: undefined, - [WaterfallFilterOptions.MaxOrder]: undefined, - [WaterfallFilterOptions.MinOrder]: prevPageOrder, - }); + // Omit startTransition for now + setQueryParams({ + ...queryParams, + [WaterfallFilterOptions.Date]: undefined, + [WaterfallFilterOptions.MaxOrder]: undefined, + [WaterfallFilterOptions.MinOrder]: prevPageOrder, }); }; diff --git a/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx b/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx index 6a1106c72..411b2ca5b 100644 --- a/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx +++ b/apps/spruce/src/pages/waterfall/WaterfallGrid.tsx @@ -1,21 +1,8 @@ import { useCallback, useEffect, useRef, useState } from "react"; -import { useSuspenseQuery } from "@apollo/client"; import styled from "@emotion/styled"; -import { fromZonedTime } from "date-fns-tz"; -import { - DEFAULT_POLL_INTERVAL, - WATERFALL_PINNED_VARIANTS_KEY, -} from "constants/index"; -import { utcTimeZone } from "constants/time"; -import { - WaterfallPagination, - WaterfallQuery, - WaterfallQueryVariables, -} from "gql/generated/types"; -import { WATERFALL } from "gql/queries"; -import { useUserTimeZone } from "hooks"; +import { WATERFALL_PINNED_VARIANTS_KEY } from "constants/index"; +import { WaterfallQuery } from "gql/generated/types"; import { useDimensions } from "hooks/useDimensions"; -import { useQueryParam } from "hooks/useQueryParam"; import { getObject, setObject } from "utils/localStorage"; import { BuildRow } from "./BuildRow"; import { InactiveVersionsButton } from "./InactiveVersions"; @@ -24,21 +11,19 @@ import { gridGroupCss, InactiveVersion, Row, - VERSION_LIMIT, } from "./styles"; -import { WaterfallFilterOptions } from "./types"; import { useFilters } from "./useFilters"; import { useWaterfallTrace } from "./useWaterfallTrace"; import { VersionLabel, VersionLabelView } from "./VersionLabel"; type WaterfallGridProps = { projectIdentifier: string; - setPagination: (pagination: WaterfallPagination) => void; + data: WaterfallQuery; }; export const WaterfallGrid: React.FC = ({ + data, projectIdentifier, - setPagination, }) => { useWaterfallTrace(); @@ -69,40 +54,13 @@ export const WaterfallGrid: React.FC = ({ }); }, [pins, projectIdentifier]); - const [maxOrder] = useQueryParam(WaterfallFilterOptions.MaxOrder, 0); - const [minOrder] = useQueryParam(WaterfallFilterOptions.MinOrder, 0); - const [date] = useQueryParam(WaterfallFilterOptions.Date, ""); - - const timezone = useUserTimeZone() ?? utcTimeZone; - - const { data } = useSuspenseQuery( - WATERFALL, - { - variables: { - options: { - projectIdentifier, - limit: VERSION_LIMIT, - maxOrder, - minOrder, - date: date ? fromZonedTime(date, timezone) : undefined, - }, - }, - // @ts-expect-error pollInterval isn't officially supported by useSuspenseQuery, but it works so let's use it anyway. - pollInterval: DEFAULT_POLL_INTERVAL, - }, - ); - - useEffect(() => { - setPagination(data.waterfall.pagination); - }, [setPagination, data.waterfall.pagination]); - const refEl = useRef(null); const { height } = useDimensions( refEl as React.MutableRefObject, ); const { activeVersionIds, buildVariants, versions } = useFilters({ - buildVariants: data.waterfall.buildVariants, + flattenedBuilds: data.waterfall.flattenedBuilds, flattenedVersions: data.waterfall.flattenedVersions, pins, }); diff --git a/apps/spruce/src/pages/waterfall/index.tsx b/apps/spruce/src/pages/waterfall/index.tsx index 0f8466d4b..e8e70f724 100644 --- a/apps/spruce/src/pages/waterfall/index.tsx +++ b/apps/spruce/src/pages/waterfall/index.tsx @@ -1,8 +1,10 @@ -import { Suspense, useState, useTransition } from "react"; +import { useTransition } from "react"; +import { useQuery } from "@apollo/client"; import { Global, css } from "@emotion/react"; import styled from "@emotion/styled"; import Banner from "@leafygreen-ui/banner"; import { TableSkeleton } from "@leafygreen-ui/skeleton-loader"; +import { fromZonedTime } from "date-fns-tz"; import { useParams } from "react-router-dom"; import { size } from "@evg-ui/lib/constants/tokens"; import { useWaterfallAnalytics } from "analytics"; @@ -10,9 +12,13 @@ import FilterBadges, { useFilterBadgeQueryParams, } from "components/FilterBadges"; import { navBarHeight } from "components/Header/Navbar"; +import { DEFAULT_POLL_INTERVAL } from "constants/index"; import { slugs } from "constants/routes"; -import { WaterfallPagination } from "gql/generated/types"; -import { useSpruceConfig } from "hooks"; +import { utcTimeZone } from "constants/time"; +import { WaterfallQuery, WaterfallQueryVariables } from "gql/generated/types"; +import { WATERFALL } from "gql/queries"; +import { useSpruceConfig, useUserTimeZone } from "hooks"; +import { useQueryParam } from "hooks/useQueryParam"; import { isBeta } from "utils/environmentVariables"; import { jiraLinkify } from "utils/string"; import { VERSION_LIMIT } from "./styles"; @@ -31,7 +37,28 @@ const Waterfall: React.FC = () => { const { sendEvent } = useWaterfallAnalytics(); - const [pagination, setPagination] = useState(); + const [maxOrder] = useQueryParam(WaterfallFilterOptions.MaxOrder, 0); + const [minOrder] = useQueryParam(WaterfallFilterOptions.MinOrder, 0); + const [date] = useQueryParam(WaterfallFilterOptions.Date, ""); + + const timezone = useUserTimeZone() ?? utcTimeZone; + + // Temporary useQuery for testing + const { data, loading } = useQuery( + WATERFALL, + { + variables: { + options: { + projectIdentifier: projectIdentifier!, + limit: VERSION_LIMIT, + maxOrder, + minOrder, + date: date ? fromZonedTime(date, timezone) : undefined, + }, + }, + pollInterval: DEFAULT_POLL_INTERVAL, + }, + ); return ( <> @@ -46,8 +73,7 @@ const Waterfall: React.FC = () => { )} { startTransition(() => handleOnRemove(b)); }} /> - - } - > + {!data || loading ? ( + + ) : ( - + )} ); diff --git a/apps/spruce/src/pages/waterfall/types.ts b/apps/spruce/src/pages/waterfall/types.ts index f720b5a22..8298646d0 100644 --- a/apps/spruce/src/pages/waterfall/types.ts +++ b/apps/spruce/src/pages/waterfall/types.ts @@ -11,12 +11,14 @@ export type WaterfallVersion = { }; export type Build = Unpacked< - Unpacked["builds"] + Unpacked >; -export type BuildVariant = Unpacked< - WaterfallQuery["waterfall"]["buildVariants"] ->; +export type BuildVariant = { + displayName: string; + id: string; + builds: Build[]; +}; export enum WaterfallFilterOptions { BuildVariant = "buildVariants", diff --git a/apps/spruce/src/pages/waterfall/useFilters.ts b/apps/spruce/src/pages/waterfall/useFilters.ts index b21ff1183..e4d65c9cb 100644 --- a/apps/spruce/src/pages/waterfall/useFilters.ts +++ b/apps/spruce/src/pages/waterfall/useFilters.ts @@ -3,16 +3,16 @@ import { Unpacked } from "@evg-ui/lib/types/utils"; import { WaterfallVersionFragment } from "gql/generated/types"; import { useQueryParam } from "hooks/useQueryParam"; import { Build, BuildVariant, WaterfallFilterOptions } from "./types"; -import { groupInactiveVersions } from "./utils"; +import { groupBuilds, groupInactiveVersions } from "./utils"; type UseFiltersProps = { - buildVariants: BuildVariant[]; + flattenedBuilds: Build[]; flattenedVersions: WaterfallVersionFragment[]; pins: string[]; }; export const useFilters = ({ - buildVariants, + flattenedBuilds, flattenedVersions, pins, }: UseFiltersProps) => { @@ -52,6 +52,11 @@ export const useFilters = ({ [taskFilter], ); + const buildVariants = useMemo( + () => groupBuilds(flattenedBuilds), + [flattenedBuilds], + ); + const filteredBuildVariants = useMemo(() => { if (!hasFilters && !pins.length) { return buildVariants; diff --git a/apps/spruce/src/pages/waterfall/utils.ts b/apps/spruce/src/pages/waterfall/utils.ts index 5093436e1..f4a448506 100644 --- a/apps/spruce/src/pages/waterfall/utils.ts +++ b/apps/spruce/src/pages/waterfall/utils.ts @@ -1,5 +1,22 @@ import { WaterfallVersionFragment } from "gql/generated/types"; -import { WaterfallVersion } from "./types"; +import { Build, BuildVariant, WaterfallVersion } from "./types"; + +export const groupBuilds = (builds: Build[]): BuildVariant[] => { + const idToBuilds: { [index: string]: Build[] } = {}; + + builds.forEach((b) => { + if (!idToBuilds[b.buildVariant]) { + idToBuilds[b.buildVariant] = []; + } + idToBuilds[b.buildVariant].push(b); + }); + + return Object.entries(idToBuilds).map(([k, v]) => ({ + id: k, + displayName: v[0].displayName, + builds: v, + })); +}; export const groupInactiveVersions = ( versions: WaterfallVersionFragment[],