diff --git a/.evergreen/scripts/add-runtime-environments-credentials.sh b/.evergreen/scripts/add-runtime-environments-credentials.sh new file mode 100755 index 000000000..8599590bc --- /dev/null +++ b/.evergreen/scripts/add-runtime-environments-credentials.sh @@ -0,0 +1 @@ +echo "{ \"_id\": \"runtime_environments\", \"base_url\": \"${BASE_URL}\", \"api_key\":\"${API_KEY}\" }" >> ../evergreen/testdata/local/admin.json diff --git a/.evergreen/shared.yml b/.evergreen/shared.yml index 16783f902..cc3b1ed3d 100644 --- a/.evergreen/shared.yml +++ b/.evergreen/shared.yml @@ -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: @@ -259,6 +268,7 @@ tasks: - name: e2e commands: - func: setup-mongodb + - func: add-runtime-environments-credentials - func: run-make-background vars: target: local-evergreen diff --git a/apps/spruce/cypress/integration/image/navigation.ts b/apps/spruce/cypress/integration/image/navigation.ts index adf39ff1f..559851f04 100644 --- a/apps/spruce/cypress/integration/image/navigation.ts +++ b/apps/spruce/cypress/integration/image/navigation.ts @@ -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); + }); + }); + }); + }); }); diff --git a/apps/spruce/src/components/Content/index.tsx b/apps/spruce/src/components/Content/index.tsx index bc85ee141..76b867a40 100644 --- a/apps/spruce/src/components/Content/index.tsx +++ b/apps/spruce/src/components/Content/index.tsx @@ -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"; @@ -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 = () => ( @@ -54,7 +54,7 @@ export const Content: React.FC = () => ( /> } /> } /> - {!isProduction() && ( + {showImageVisibilityPage && ( }> diff --git a/apps/spruce/src/constants/featureFlags.ts b/apps/spruce/src/constants/featureFlags.ts index cd08f65d4..e0da5faf5 100644 --- a/apps/spruce/src/constants/featureFlags.ts +++ b/apps/spruce/src/constants/featureFlags.ts @@ -1,3 +1,4 @@ import { isProduction } from "utils/environmentVariables"; export const showGitHubAccessTokenProject = !isProduction(); +export const showImageVisibilityPage = !isProduction(); diff --git a/apps/spruce/src/gql/generated/types.ts b/apps/spruce/src/gql/generated/types.ts index 0eb4573ff..6b33341e9 100644 --- a/apps/spruce/src/gql/generated/types.ts +++ b/apps/spruce/src/gql/generated/types.ts @@ -6098,6 +6098,10 @@ export type HostsQuery = { }; }; +export type ImagesQueryVariables = Exact<{ [key: string]: never }>; + +export type ImagesQuery = { __typename?: "Query"; images: Array }; + export type InstanceTypesQueryVariables = Exact<{ [key: string]: never }>; export type InstanceTypesQuery = { diff --git a/apps/spruce/src/gql/queries/images.graphql b/apps/spruce/src/gql/queries/images.graphql new file mode 100644 index 000000000..4c955acca --- /dev/null +++ b/apps/spruce/src/gql/queries/images.graphql @@ -0,0 +1,3 @@ +query Images { + images +} diff --git a/apps/spruce/src/gql/queries/index.ts b/apps/spruce/src/gql/queries/index.ts index d661b4933..e52c50d3a 100644 --- a/apps/spruce/src/gql/queries/index.ts +++ b/apps/spruce/src/gql/queries/index.ts @@ -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"; @@ -109,6 +110,7 @@ export { HOST_EVENTS, HOST, HOSTS, + IMAGES, INSTANCE_TYPES, IS_PATCH_CONFIGURED, JIRA_CUSTOM_CREATED_ISSUES, diff --git a/apps/spruce/src/hooks/index.ts b/apps/spruce/src/hooks/index.ts index 9afda3ba1..80c833b15 100644 --- a/apps/spruce/src/hooks/index.ts +++ b/apps/spruce/src/hooks/index.ts @@ -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"; diff --git a/apps/spruce/src/hooks/useFirstImage/index.ts b/apps/spruce/src/hooks/useFirstImage/index.ts new file mode 100644 index 000000000..b7634879e --- /dev/null +++ b/apps/spruce/src/hooks/useFirstImage/index.ts @@ -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(IMAGES); + + return { image: data?.images?.[0] ?? "ubuntu2204", loading }; +}; diff --git a/apps/spruce/src/hooks/useFirstImage/useFirstImage.test.tsx b/apps/spruce/src/hooks/useFirstImage/useFirstImage.test.tsx new file mode 100644 index 000000000..578a5acab --- /dev/null +++ b/apps/spruce/src/hooks/useFirstImage/useFirstImage.test.tsx @@ -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 = ({ children, mocks = [] }) => ( + {children} +); + +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 = { + request: { + query: IMAGES, + variables: {}, + }, + result: { + data: { + images: ["amazon2", "debian12", "suse15", "ubuntu1604"], + }, + }, +}; diff --git a/apps/spruce/src/pages/image/ImageSelect/index.tsx b/apps/spruce/src/pages/image/ImageSelect/index.tsx new file mode 100644 index 000000000..624b2668e --- /dev/null +++ b/apps/spruce/src/pages/image/ImageSelect/index.tsx @@ -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 = ({ 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 ( + { + navigate(getImageRoute(imageId)); + }} + value={selectedImage} + > + {images?.map((image) => ( + + {image} + + ))} + + ); + } + return ; +}; diff --git a/apps/spruce/src/pages/image/index.tsx b/apps/spruce/src/pages/image/index.tsx index 2d7bf39b7..b7a2af32f 100644 --- a/apps/spruce/src/pages/image/index.tsx +++ b/apps/spruce/src/pages/image/index.tsx @@ -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 ( ); } return ( + + + {Object.values(ImageTabRoutes).map((tab) => ( {getTabTitle(tab).title} @@ -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;