Skip to content

Commit

Permalink
DEVPROD-7654: Add image dropdown to SideNav (#257)
Browse files Browse the repository at this point in the history
  • Loading branch information
sophiabarness authored Jul 24, 2024
1 parent fc62698 commit 0e85267
Show file tree
Hide file tree
Showing 13 changed files with 177 additions and 11 deletions.
1 change: 1 addition & 0 deletions .evergreen/scripts/add-runtime-environments-credentials.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
echo "{ \"_id\": \"runtime_environments\", \"base_url\": \"${BASE_URL}\", \"api_key\":\"${API_KEY}\" }" >> ../evergreen/testdata/local/admin.json
10 changes: 10 additions & 0 deletions .evergreen/shared.yml
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
functions:
add-runtime-environments-credentials:
command: subprocess.exec
params:
working_dir: ui/.evergreen
command: bash scripts/add-runtime-environments-credentials.sh
env:
BASE_URL: ${staging_runtime_environments_base_url}
API_KEY: ${staging_runtime_environments_api_key}

assume-ec2-role:
command: ec2.assume_role
params:
Expand Down Expand Up @@ -259,6 +268,7 @@ tasks:
- name: e2e
commands:
- func: setup-mongodb
- func: add-runtime-environments-credentials
- func: run-make-background
vars:
target: local-evergreen
Expand Down
18 changes: 18 additions & 0 deletions apps/spruce/cypress/integration/image/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,22 @@ describe("/image/imageId/random redirect route", () => {
cy.location("pathname").should("not.contain", "/random");
cy.location("pathname").should("eq", "/image/imageId/build-information");
});

it("navigates to the image when clicked", () => {
cy.visit("/image/amazon2/build-information");
cy.dataCy("images-select").should("be.visible").as("button");
cy.get("@button").click();
cy.get(".images-select-options").find("li").should("exist");
cy.get(".images-select-options").within(() => {
cy.get("li").eq(1).click();
cy.get("li")
.eq(1)
.invoke("text")
.then((text) => {
cy.location("pathname").then((pathname) => {
expect(pathname).to.include(text);
});
});
});
});
});
4 changes: 2 additions & 2 deletions apps/spruce/src/components/Content/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
UserPatchesRedirect,
WaterfallCommitsRedirect,
} from "components/Redirects";
import { showImageVisibilityPage } from "constants/featureFlags";
import { redirectRoutes, routes, slugs } from "constants/routes";
import { CommitQueue } from "pages/CommitQueue";
import { Commits } from "pages/Commits";
Expand All @@ -27,7 +28,6 @@ import { TaskQueue } from "pages/TaskQueue";
import { UserPatches } from "pages/UserPatches";
import { VariantHistory } from "pages/VariantHistory";
import { VersionPage } from "pages/Version";
import { isProduction } from "utils/environmentVariables";
import { Layout } from "./Layout";

export const Content: React.FC = () => (
Expand All @@ -54,7 +54,7 @@ export const Content: React.FC = () => (
/>
<Route path={routes.host} element={<Host />} />
<Route path={routes.hosts} element={<Hosts />} />
{!isProduction() && (
{showImageVisibilityPage && (
<Route path={`${routes.image}/*`} element={<Image />}>
<Route path={`:${slugs.tab}`} element={null} />
</Route>
Expand Down
1 change: 1 addition & 0 deletions apps/spruce/src/constants/featureFlags.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { isProduction } from "utils/environmentVariables";

export const showGitHubAccessTokenProject = !isProduction();
export const showImageVisibilityPage = !isProduction();
4 changes: 4 additions & 0 deletions apps/spruce/src/gql/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6098,6 +6098,10 @@ export type HostsQuery = {
};
};

export type ImagesQueryVariables = Exact<{ [key: string]: never }>;

export type ImagesQuery = { __typename?: "Query"; images: Array<string> };

export type InstanceTypesQueryVariables = Exact<{ [key: string]: never }>;

export type InstanceTypesQuery = {
Expand Down
3 changes: 3 additions & 0 deletions apps/spruce/src/gql/queries/images.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
query Images {
images
}
2 changes: 2 additions & 0 deletions apps/spruce/src/gql/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import HAS_VERSION from "./has-version.graphql";
import HOST_EVENTS from "./host-events.graphql";
import HOST from "./host.graphql";
import HOSTS from "./hosts.graphql";
import IMAGES from "./images.graphql";
import INSTANCE_TYPES from "./instance-types.graphql";
import IS_PATCH_CONFIGURED from "./is-patch-configured.graphql";
import JIRA_CUSTOM_CREATED_ISSUES from "./jira-custom-created-issues.graphql";
Expand Down Expand Up @@ -109,6 +110,7 @@ export {
HOST_EVENTS,
HOST,
HOSTS,
IMAGES,
INSTANCE_TYPES,
IS_PATCH_CONFIGURED,
JIRA_CUSTOM_CREATED_ISSUES,
Expand Down
1 change: 1 addition & 0 deletions apps/spruce/src/hooks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export { useUserSettings } from "./useUserSettings";
export { useUserTimeZone } from "./useUserTimeZone";
export { useDateFormat } from "./useDateFormat";
export { useFirstDistro } from "./useFirstDistro";
export { useFirstImage } from "./useFirstImage";
export { useBreadcrumbRoot } from "./useBreadcrumbRoot";
export { useResize } from "./useResize";
export { useRunningTime } from "./useRunningTime";
13 changes: 13 additions & 0 deletions apps/spruce/src/hooks/useFirstImage/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useQuery } from "@apollo/client";
import { ImagesQuery, ImagesQueryVariables } from "gql/generated/types";
import { IMAGES } from "gql/queries";

/**
* `useFirstImage` returns the alphabetically first image from Evergreen's list of images.
* @returns an object containing the image ID (string) and loading state (boolean)
*/
export const useFirstImage = () => {
const { data, loading } = useQuery<ImagesQuery, ImagesQueryVariables>(IMAGES);

return { image: data?.images?.[0] ?? "ubuntu2204", loading };
};
44 changes: 44 additions & 0 deletions apps/spruce/src/hooks/useFirstImage/useFirstImage.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { MockedProvider, MockedProviderProps } from "@apollo/client/testing";
import { ImagesQuery, ImagesQueryVariables } from "gql/generated/types";
import { IMAGES } from "gql/queries";
import { renderHook, waitFor } from "test_utils";
import { ApolloMock } from "types/gql";
import { useFirstImage } from ".";

interface ProviderProps {
mocks?: MockedProviderProps["mocks"];
children: React.ReactNode;
}
const ProviderWrapper: React.FC<ProviderProps> = ({ children, mocks = [] }) => (
<MockedProvider mocks={mocks}>{children}</MockedProvider>
);

describe("useFirstImage", () => {
it("retrieve first image from list", async () => {
const { result } = renderHook(() => useFirstImage(), {
wrapper: ({ children }) =>
ProviderWrapper({
children,
mocks: [getImages],
}),
});

await waitFor(() => {
expect(result.current.loading).toBe(false);
});

expect(result.current.image).toBe("amazon2");
});
});

const getImages: ApolloMock<ImagesQuery, ImagesQueryVariables> = {
request: {
query: IMAGES,
variables: {},
},
result: {
data: {
images: ["amazon2", "debian12", "suse15", "ubuntu1604"],
},
},
};
55 changes: 55 additions & 0 deletions apps/spruce/src/pages/image/ImageSelect/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { useQuery } from "@apollo/client";
import { Combobox, ComboboxOption } from "@leafygreen-ui/combobox";
import { Skeleton } from "@leafygreen-ui/skeleton-loader";
import { useNavigate } from "react-router-dom";
import { getImageRoute } from "constants/routes";
import { zIndex } from "constants/tokens";
import { useToastContext } from "context/toast";
import { ImagesQuery, ImagesQueryVariables } from "gql/generated/types";
import { IMAGES } from "gql/queries";

interface ImageSelectProps {
selectedImage: string;
}

export const ImageSelect: React.FC<ImageSelectProps> = ({ selectedImage }) => {
const navigate = useNavigate();

const dispatchToast = useToastContext();
const { data: imagesData, loading } = useQuery<
ImagesQuery,
ImagesQueryVariables
>(IMAGES, {
onError: (err) => {
dispatchToast.warning(`Failed to retrieve images: ${err.message}`);
},
});

const { images } = imagesData || {};

if (!loading) {
return (
<Combobox
clearable={false}
data-cy="images-select"
label="Images"
placeholder="Select an image"
popoverZIndex={zIndex.popover}
portalClassName="images-select-options"
disabled={loading}
// @ts-expect-error: onChange expects type string | null
onChange={(imageId: string) => {
navigate(getImageRoute(imageId));
}}
value={selectedImage}
>
{images?.map((image) => (
<ComboboxOption key={image} value={image}>
{image}
</ComboboxOption>
))}
</Combobox>
);
}
return <Skeleton />;
};
32 changes: 23 additions & 9 deletions apps/spruce/src/pages/image/index.tsx
Original file line number Diff line number Diff line change
@@ -1,37 +1,44 @@
import styled from "@emotion/styled";
import { sideNavItemSidePadding } from "@leafygreen-ui/side-nav";
import { Link, useParams, Navigate } from "react-router-dom";
import { SideNav, SideNavGroup, SideNavItem } from "components/styles";
import { ImageTabRoutes, getImageRoute, slugs } from "constants/routes";
import { size } from "constants/tokens";
import { useFirstImage } from "hooks";
import { getTabTitle } from "./getTabTitle";
import { ImageSelect } from "./ImageSelect";

const Image: React.FC = () => {
const {
[slugs.imageId]: imageId,
[slugs.tab]: currentTab = ImageTabRoutes.BuildInformation,
} = useParams<{
const { [slugs.imageId]: imageId, [slugs.tab]: currentTab } = useParams<{
[slugs.imageId]: string;
[slugs.tab]: ImageTabRoutes;
}>();

if (!Object.values(ImageTabRoutes).includes(currentTab)) {
const { image: firstImage } = useFirstImage();

const selectedImage = imageId ?? firstImage;

if (!Object.values(ImageTabRoutes).includes(currentTab as ImageTabRoutes)) {
return (
<Navigate
replace
// @ts-expect-error: TODO fix in DEVPROD-7654
to={getImageRoute(imageId, ImageTabRoutes.BuildInformation)}
to={getImageRoute(selectedImage, ImageTabRoutes.BuildInformation)}
/>
);
}

return (
<SideNav aria-label="Image" widthOverride={250}>
<ButtonsContainer>
<ImageSelect selectedImage={selectedImage} />
</ButtonsContainer>
<SideNavGroup>
{Object.values(ImageTabRoutes).map((tab) => (
<SideNavItem
active={tab === currentTab}
as={Link}
key={tab}
// @ts-expect-error: TODO fix in DEVPROD-7654
to={getImageRoute(imageId, tab)}
to={getImageRoute(selectedImage, tab)}
data-cy={`navitem-${tab}`}
>
{getTabTitle(tab).title}
Expand All @@ -42,4 +49,11 @@ const Image: React.FC = () => {
);
};

const ButtonsContainer = styled.div`
display: flex;
flex-direction: column;
gap: ${size.xs};
margin: 0 ${sideNavItemSidePadding}px;
`;

export default Image;

0 comments on commit 0e85267

Please sign in to comment.