diff --git a/cypress/integration/projectHealth/project_banners.ts b/cypress/integration/projectHealth/project_banners.ts new file mode 100644 index 0000000000..aa84717cb8 --- /dev/null +++ b/cypress/integration/projectHealth/project_banners.ts @@ -0,0 +1,27 @@ +describe("project banners", () => { + const projectWithRepotrackerError = "/commits/mongodb-mongo-test"; + + describe("repotracker banner", () => { + beforeEach(() => { + cy.visit(projectWithRepotrackerError); + }); + + it("should be able to clear the repotracker error", () => { + cy.dataCy("repotracker-error-banner").should("be.visible"); + cy.dataCy("repotracker-error-trigger").should("be.visible"); + cy.dataCy("repotracker-error-trigger").click(); + cy.dataCy("repotracker-error-modal").should("be.visible"); + cy.getInputByLabel("Base Revision").type( + "7ad0f0571691fa5063b757762a5b103999290fa8" + ); + cy.contains("button", "Confirm").should( + "have.attr", + "aria-disabled", + "false" + ); + cy.contains("button", "Confirm").click(); + cy.validateToast("success", "Successfully updated merge base revision"); + cy.dataCy("repotracker-error-banner").should("not.exist"); + }); + }); +}); diff --git a/src/components/Banners/PortalBanner.tsx b/src/components/Banners/PortalBanner.tsx index 4fca2ea9e6..f8c4ff0ab3 100644 --- a/src/components/Banners/PortalBanner.tsx +++ b/src/components/Banners/PortalBanner.tsx @@ -1,10 +1,10 @@ import { createPortal } from "react-dom"; -import { SiteBanner, SiteBannerProps } from "./SiteBanner"; -interface PortalBannerProps extends SiteBannerProps {} -export const PortalBanner: React.FC = ({ text, theme }) => { +interface PortalBannerProps { + banner: React.ReactNode; +} + +export const PortalBanner: React.FC = ({ banner }) => { const bannerContainerEl = document.getElementById("banner-container"); - return bannerContainerEl - ? createPortal(, bannerContainerEl) - : null; + return bannerContainerEl ? createPortal(banner, bannerContainerEl) : null; }; diff --git a/src/components/Banners/ProjectBanner.tsx b/src/components/Banners/ProjectBanner.tsx index c589e76e8b..44e111217f 100644 --- a/src/components/Banners/ProjectBanner.tsx +++ b/src/components/Banners/ProjectBanner.tsx @@ -5,6 +5,7 @@ import { } from "gql/generated/types"; import { PROJECT_BANNER } from "gql/queries"; import { PortalBanner } from "./PortalBanner"; +import { SiteBanner } from "./SiteBanner"; interface ProjectBannerProps { projectIdentifier: string; @@ -23,5 +24,5 @@ export const ProjectBanner: React.FC = ({ if (!text) { return null; } - return ; + return } />; }; diff --git a/src/components/Banners/RepotrackerBanner.test.tsx b/src/components/Banners/RepotrackerBanner.test.tsx new file mode 100644 index 0000000000..b4e0987a67 --- /dev/null +++ b/src/components/Banners/RepotrackerBanner.test.tsx @@ -0,0 +1,240 @@ +import { MockedProvider } from "@apollo/client/testing"; +import { RepotrackerBanner } from "components/Banners"; +import { RenderFakeToastContext } from "context/toast/__mocks__"; +import { + UserProjectSettingsPermissionsQuery, + UserProjectSettingsPermissionsQueryVariables, + RepotrackerErrorQuery, + RepotrackerErrorQueryVariables, + SetLastRevisionMutation, + SetLastRevisionMutationVariables, +} from "gql/generated/types"; +import { SET_LAST_REVISION } from "gql/mutations"; +import { + USER_PROJECT_SETTINGS_PERMISSIONS, + REPOTRACKER_ERROR, +} from "gql/queries"; +import { render, screen, userEvent, waitFor } from "test_utils"; +import { ApolloMock } from "types/gql"; + +describe("repotracker banner", () => { + beforeEach(() => { + const bannerContainer = document.createElement("div"); + bannerContainer.setAttribute("id", "banner-container"); + const body = document.body as HTMLElement; + body.appendChild(bannerContainer); + }); + + afterEach(() => { + const bannerContainer = document.getElementById("banner-container"); + const body = document.body as HTMLElement; + body.removeChild(bannerContainer); + }); + + describe("repotracker error does not exist", () => { + it("does not render banner", async () => { + const { Component } = RenderFakeToastContext( + + + + ); + render(); + await waitFor(() => { + expect(screen.queryByDataCy("repotracker-error-banner")).toBeNull(); + }); + }); + }); + + describe("repotracker error exists", () => { + it("renders a banner", async () => { + const { Component } = RenderFakeToastContext( + + + + ); + render(); + await waitFor(() => { + expect(screen.queryByDataCy("repotracker-error-banner")).toBeVisible(); + }); + }); + + it("does not render modal trigger if user is not admin", async () => { + const { Component } = RenderFakeToastContext( + + + + ); + render(); + await waitFor(() => { + expect(screen.queryByDataCy("repotracker-error-banner")).toBeVisible(); + }); + expect(screen.queryByDataCy("repotracker-error-trigger")).toBeNull(); + }); + + it("renders modal trigger if user is admin", async () => { + const { Component } = RenderFakeToastContext( + + + + ); + render(); + await waitFor(() => { + expect(screen.queryByDataCy("repotracker-error-banner")).toBeVisible(); + }); + expect(screen.queryByDataCy("repotracker-error-trigger")).toBeVisible(); + }); + + it("can submit new base revision via modal", async () => { + const user = userEvent.setup(); + const { Component, dispatchToast } = RenderFakeToastContext( + + + + ); + render(); + await waitFor(() => { + expect(screen.queryByDataCy("repotracker-error-banner")).toBeVisible(); + }); + expect(screen.queryByDataCy("repotracker-error-trigger")).toBeVisible(); + + // Open modal. + await user.click(screen.queryByDataCy("repotracker-error-trigger")); + await waitFor(() => { + expect(screen.queryByDataCy("repotracker-error-modal")).toBeVisible(); + }); + + // Submit new base revision. + const confirmButton = screen.getByRole("button", { name: "Confirm" }); + expect(confirmButton).toHaveAttribute("aria-disabled", "true"); + await user.type(screen.getByLabelText("Base Revision"), baseRevision); + expect(confirmButton).toHaveAttribute("aria-disabled", "false"); + await user.click(confirmButton); + expect(dispatchToast.success).toHaveBeenCalledTimes(1); + }); + }); +}); + +const baseRevision = "7ad0f0571691fa5063b757762a5b103999290fa8"; + +const projectNoError: ApolloMock< + RepotrackerErrorQuery, + RepotrackerErrorQueryVariables +> = { + request: { + query: REPOTRACKER_ERROR, + variables: { + projectIdentifier: "evergreen", + }, + }, + result: { + data: { + project: { + __typename: "Project", + id: "evergreen", + branch: "", + repotrackerError: null, + }, + }, + }, +}; + +const projectWithError: ApolloMock< + RepotrackerErrorQuery, + RepotrackerErrorQueryVariables +> = { + request: { + query: REPOTRACKER_ERROR, + variables: { + projectIdentifier: "evergreen", + }, + }, + result: { + data: { + project: { + __typename: "Project", + id: "evergreen", + branch: "main", + repotrackerError: { + __typename: "RepotrackerError", + exists: true, + invalidRevision: "invalid_revision", + }, + }, + }, + }, +}; + +const adminUser: ApolloMock< + UserProjectSettingsPermissionsQuery, + UserProjectSettingsPermissionsQueryVariables +> = { + request: { + query: USER_PROJECT_SETTINGS_PERMISSIONS, + variables: { projectIdentifier: "evergreen" }, + }, + result: { + data: { + user: { + __typename: "User", + userId: "admin", + permissions: { + __typename: "Permissions", + canCreateProject: true, + projectPermissions: { + __typename: "ProjectPermissions", + edit: true, + }, + }, + }, + }, + }, +}; + +const basicUser: ApolloMock< + UserProjectSettingsPermissionsQuery, + UserProjectSettingsPermissionsQueryVariables +> = { + request: { + query: USER_PROJECT_SETTINGS_PERMISSIONS, + variables: { projectIdentifier: "evergreen" }, + }, + result: { + data: { + user: { + __typename: "User", + userId: "basic", + permissions: { + __typename: "Permissions", + canCreateProject: false, + projectPermissions: { + __typename: "ProjectPermissions", + edit: false, + }, + }, + }, + }, + }, +}; + +const setLastRevision: ApolloMock< + SetLastRevisionMutation, + SetLastRevisionMutationVariables +> = { + request: { + query: SET_LAST_REVISION, + variables: { + projectIdentifier: "evergreen", + revision: baseRevision, + }, + }, + result: { + data: { + setLastRevision: { + __typename: "SetLastRevisionPayload", + mergeBaseRevision: baseRevision, + }, + }, + }, +}; diff --git a/src/components/Banners/RepotrackerBanner.tsx b/src/components/Banners/RepotrackerBanner.tsx new file mode 100644 index 0000000000..00048a3ab0 --- /dev/null +++ b/src/components/Banners/RepotrackerBanner.tsx @@ -0,0 +1,150 @@ +import { useState } from "react"; +import { useQuery, useMutation } from "@apollo/client"; +import styled from "@emotion/styled"; +import Banner from "@leafygreen-ui/banner"; +import TextInput from "@leafygreen-ui/text-input"; +import { InlineCode } from "@leafygreen-ui/typography"; +import { ConfirmationModal } from "components/ConfirmationModal"; +import { size } from "constants/tokens"; +import { useToastContext } from "context/toast"; +import { + UserProjectSettingsPermissionsQuery, + UserProjectSettingsPermissionsQueryVariables, + RepotrackerErrorQuery, + RepotrackerErrorQueryVariables, + SetLastRevisionMutation, + SetLastRevisionMutationVariables, +} from "gql/generated/types"; +import { SET_LAST_REVISION } from "gql/mutations"; +import { + USER_PROJECT_SETTINGS_PERMISSIONS, + REPOTRACKER_ERROR, +} from "gql/queries"; +import { PortalBanner } from "./PortalBanner"; + +interface RepotrackerBannerProps { + projectIdentifier: string; +} +export const RepotrackerBanner: React.FC = ({ + projectIdentifier, +}) => { + const dispatchToast = useToastContext(); + const [openModal, setOpenModal] = useState(false); + const [baseRevision, setBaseRevision] = useState(""); + + const { data: repotrackerData } = useQuery< + RepotrackerErrorQuery, + RepotrackerErrorQueryVariables + >(REPOTRACKER_ERROR, { + variables: { projectIdentifier }, + }); + const hasRepotrackerError = + repotrackerData?.project?.repotrackerError?.exists ?? false; + + const { data: permissionsData } = useQuery< + UserProjectSettingsPermissionsQuery, + UserProjectSettingsPermissionsQueryVariables + >(USER_PROJECT_SETTINGS_PERMISSIONS, { + variables: { projectIdentifier }, + // If there's no repotracker error, there is no need to determine whether the current user is an admin. + skip: !hasRepotrackerError, + }); + const isProjectAdmin = + permissionsData?.user?.permissions?.projectPermissions?.edit ?? false; + + const [setLastRevision] = useMutation< + SetLastRevisionMutation, + SetLastRevisionMutationVariables + >(SET_LAST_REVISION, { + onCompleted: () => { + dispatchToast.success( + "Successfully updated merge base revision. The repotracker job has been scheduled to run." + ); + }, + onError: (err) => { + dispatchToast.error( + `Error when attempting to update merge base revision: ${err.message}` + ); + }, + }); + + const resetModal = () => { + setOpenModal(false); + setBaseRevision(""); + }; + + if (!hasRepotrackerError) { + return null; + } + return ( + <> + + {isProjectAdmin ? ( + + The project was unable to build. Please specify a new base + revision by clicking{" "} + setOpenModal(true)} + > + here + + . + + ) : ( + "The project was unable to build. Please reach out to a project admin to fix." + )} + + } + /> + { + setLastRevision({ + variables: { projectIdentifier, revision: baseRevision }, + refetchQueries: ["RepotrackerError"], + }); + resetModal(); + }} + open={openModal} + setOpen={setOpenModal} + submitDisabled={baseRevision.length < 40} + title="Enter New Base Revision" + > + + The current base revision{" "} + + {repotrackerData?.project?.repotrackerError?.invalidRevision} + {" "} + cannot be found on branch '{repotrackerData?.project?.branch} + '. In order to resume tracking the repository, please enter a new + base revision. + + setBaseRevision(e.target.value)} + value={baseRevision} + /> + + + ); +}; + +const ModalDescription = styled.div` + margin-bottom: ${size.xs}; +`; + +const ModalTriggerText = styled.span` + font-weight: bold; + text-decoration-line: underline; + text-underline-offset: 2px; + text-decoration-thickness: 2px; + :hover { + cursor: pointer; + } +`; diff --git a/src/components/Banners/index.ts b/src/components/Banners/index.ts index 3edc7dddba..335d3282fe 100644 --- a/src/components/Banners/index.ts +++ b/src/components/Banners/index.ts @@ -5,3 +5,4 @@ export { SlackNotificationBanner } from "./SlackNotificationBanner"; export { AdminBanner } from "./AdminBanner"; export { PortalBanner } from "./PortalBanner"; export { ProjectBanner } from "./ProjectBanner"; +export { RepotrackerBanner } from "./RepotrackerBanner"; diff --git a/src/gql/generated/types.ts b/src/gql/generated/types.ts index a1a61e1479..9d4fd7a574 100644 --- a/src/gql/generated/types.ts +++ b/src/gql/generated/types.ts @@ -5084,6 +5084,19 @@ export type ScheduleUndispatchedBaseTasksMutation = { }> | null; }; +export type SetLastRevisionMutationVariables = Exact<{ + projectIdentifier: Scalars["String"]["input"]; + revision: Scalars["String"]["input"]; +}>; + +export type SetLastRevisionMutation = { + __typename?: "Mutation"; + setLastRevision: { + __typename?: "SetLastRevisionPayload"; + mergeBaseRevision: string; + }; +}; + export type SetPatchPriorityMutationVariables = Exact<{ patchId: Scalars["String"]["input"]; priority: Scalars["Int"]["input"]; @@ -8022,6 +8035,24 @@ export type RepoSettingsQuery = { }; }; +export type RepotrackerErrorQueryVariables = Exact<{ + projectIdentifier: Scalars["String"]["input"]; +}>; + +export type RepotrackerErrorQuery = { + __typename?: "Query"; + project: { + __typename?: "Project"; + branch: string; + id: string; + repotrackerError?: { + __typename?: "RepotrackerError"; + exists: boolean; + invalidRevision: string; + } | null; + }; +}; + export type SpawnExpirationInfoQueryVariables = Exact<{ [key: string]: never }>; export type SpawnExpirationInfoQuery = { @@ -8624,7 +8655,7 @@ export type UserPatchesQuery = { }; export type UserProjectSettingsPermissionsQueryVariables = Exact<{ - [key: string]: never; + projectIdentifier: Scalars["String"]["input"]; }>; export type UserProjectSettingsPermissionsQuery = { @@ -8632,7 +8663,11 @@ export type UserProjectSettingsPermissionsQuery = { user: { __typename?: "User"; userId: string; - permissions: { __typename?: "Permissions"; canCreateProject: boolean }; + permissions: { + __typename?: "Permissions"; + canCreateProject: boolean; + projectPermissions: { __typename?: "ProjectPermissions"; edit: boolean }; + }; }; }; diff --git a/src/gql/mutations/index.ts b/src/gql/mutations/index.ts index 6463cce645..57039e9e36 100644 --- a/src/gql/mutations/index.ts +++ b/src/gql/mutations/index.ts @@ -42,6 +42,7 @@ import SAVE_SUBSCRIPTION from "./save-subscription.graphql"; import SCHEDULE_PATCH from "./schedule-patch.graphql"; import SCHEDULE_TASKS from "./schedule-tasks.graphql"; import SCHEDULE_UNDISPATCHED_BASE_TASKS from "./schedule-undispatched-base-tasks.graphql"; +import SET_LAST_REVISION from "./set-last-revision.graphql"; import SET_PATCH_PRIORITY from "./set-patch-priority.graphql"; import SET_PATCH_VISIBILITY from "./set-patch-visibility.graphql"; import SET_TASK_PRIORITY from "./set-task-priority.graphql"; @@ -101,6 +102,7 @@ export { SCHEDULE_PATCH, SCHEDULE_TASKS, SCHEDULE_UNDISPATCHED_BASE_TASKS, + SET_LAST_REVISION, SET_PATCH_PRIORITY, SET_PATCH_VISIBILITY, SET_TASK_PRIORITY, diff --git a/src/gql/mutations/set-last-revision.graphql b/src/gql/mutations/set-last-revision.graphql new file mode 100644 index 0000000000..3834f9c3f2 --- /dev/null +++ b/src/gql/mutations/set-last-revision.graphql @@ -0,0 +1,7 @@ +mutation SetLastRevision($projectIdentifier: String!, $revision: String!) { + setLastRevision( + opts: { projectIdentifier: $projectIdentifier, revision: $revision } + ) { + mergeBaseRevision + } +} diff --git a/src/gql/queries/index.ts b/src/gql/queries/index.ts index f796b6013b..c1e97bad63 100644 --- a/src/gql/queries/index.ts +++ b/src/gql/queries/index.ts @@ -50,6 +50,7 @@ import PROJECTS from "./projects.graphql"; import MY_PUBLIC_KEYS from "./public-keys.graphql"; import REPO_EVENT_LOGS from "./repo-event-logs.graphql"; import REPO_SETTINGS from "./repo-settings.graphql"; +import REPOTRACKER_ERROR from "./repotracker-error.graphql"; import SPAWN_EXPIRATION_INFO from "./spawn-expiration.graphql"; import SPAWN_TASK from "./spawn-task.graphql"; import SPRUCE_CONFIG from "./spruce-config.graphql"; @@ -131,6 +132,7 @@ export { PROJECTS, REPO_EVENT_LOGS, REPO_SETTINGS, + REPOTRACKER_ERROR, SPAWN_EXPIRATION_INFO, SPAWN_TASK, SPRUCE_CONFIG, diff --git a/src/gql/queries/repotracker-error.graphql b/src/gql/queries/repotracker-error.graphql new file mode 100644 index 0000000000..9084474a8f --- /dev/null +++ b/src/gql/queries/repotracker-error.graphql @@ -0,0 +1,10 @@ +query RepotrackerError($projectIdentifier: String!) { + project(projectIdentifier: $projectIdentifier) { + branch + id + repotrackerError { + exists + invalidRevision + } + } +} diff --git a/src/gql/queries/user-project-settings-permissions.graphql b/src/gql/queries/user-project-settings-permissions.graphql index bddd2ea71b..6004031fc2 100644 --- a/src/gql/queries/user-project-settings-permissions.graphql +++ b/src/gql/queries/user-project-settings-permissions.graphql @@ -1,7 +1,10 @@ -query UserProjectSettingsPermissions { +query UserProjectSettingsPermissions($projectIdentifier: String!) { user { permissions { canCreateProject + projectPermissions(options: { projectIdentifier: $projectIdentifier }) { + edit + } } userId } diff --git a/src/pages/commits/index.tsx b/src/pages/commits/index.tsx index 67d3fe2783..593d948a7f 100644 --- a/src/pages/commits/index.tsx +++ b/src/pages/commits/index.tsx @@ -4,7 +4,7 @@ import styled from "@emotion/styled"; import Cookies from "js-cookie"; import { useParams, useLocation, useNavigate } from "react-router-dom"; import { useProjectHealthAnalytics } from "analytics/projectHealth/useProjectHealthAnalytics"; -import { ProjectBanner } from "components/Banners"; +import { ProjectBanner, RepotrackerBanner } from "components/Banners"; import FilterBadges, { useFilterBadgeQueryParams, } from "components/FilterBadges"; @@ -178,6 +178,7 @@ const Commits = () => { return ( + diff --git a/src/pages/projectSettings/CreateDuplicateProjectButton.test.tsx b/src/pages/projectSettings/CreateDuplicateProjectButton.test.tsx index fe69704242..9465a16799 100644 --- a/src/pages/projectSettings/CreateDuplicateProjectButton.test.tsx +++ b/src/pages/projectSettings/CreateDuplicateProjectButton.test.tsx @@ -3,8 +3,10 @@ import { RenderFakeToastContext } from "context/toast/__mocks__"; import { UserProjectSettingsPermissionsQuery, UserProjectSettingsPermissionsQueryVariables, + GithubOrgsQuery, + GithubOrgsQueryVariables, } from "gql/generated/types"; -import { USER_PROJECT_SETTINGS_PERMISSIONS } from "gql/queries"; +import { USER_PROJECT_SETTINGS_PERMISSIONS, GITHUB_ORGS } from "gql/queries"; import { renderWithRouterMatch as render, screen, @@ -25,7 +27,7 @@ const Button = ({ mock?: MockedResponse; projectType?: ProjectType; }) => ( - + { > = { request: { query: USER_PROJECT_SETTINGS_PERMISSIONS, - variables: {}, + variables: { projectIdentifier: "my_id" }, }, result: { data: { @@ -54,6 +56,10 @@ describe("createDuplicateProjectField", () => { permissions: { __typename: "Permissions", canCreateProject: false, + projectPermissions: { + __typename: "ProjectPermissions", + edit: false, + }, }, }, }, @@ -133,7 +139,7 @@ const permissionsMock: ApolloMock< > = { request: { query: USER_PROJECT_SETTINGS_PERMISSIONS, - variables: {}, + variables: { projectIdentifier: "my_id" }, }, result: { data: { @@ -143,8 +149,27 @@ const permissionsMock: ApolloMock< permissions: { __typename: "Permissions", canCreateProject: true, + projectPermissions: { + __typename: "ProjectPermissions", + edit: true, + }, }, }, }, }, }; + +const githubOrgsMock: ApolloMock = { + request: { + query: GITHUB_ORGS, + variables: {}, + }, + result: { + data: { + spruceConfig: { + __typename: "SpruceConfig", + githubOrgs: ["evergreen"], + }, + }, + }, +}; diff --git a/src/pages/projectSettings/CreateDuplicateProjectButton.tsx b/src/pages/projectSettings/CreateDuplicateProjectButton.tsx index 33c51a3632..3c3c28abb3 100644 --- a/src/pages/projectSettings/CreateDuplicateProjectButton.tsx +++ b/src/pages/projectSettings/CreateDuplicateProjectButton.tsx @@ -3,7 +3,10 @@ import { useQuery } from "@apollo/client"; import { Menu, MenuItem } from "@leafygreen-ui/menu"; import { PlusButton, Size, Variant } from "components/Buttons"; import { zIndex } from "constants/tokens"; -import { UserProjectSettingsPermissionsQuery } from "gql/generated/types"; +import { + UserProjectSettingsPermissionsQuery, + UserProjectSettingsPermissionsQueryVariables, +} from "gql/generated/types"; import { USER_PROJECT_SETTINGS_PERMISSIONS } from "gql/queries"; import { CopyProjectModal } from "./CopyProjectModal"; import { CreateProjectModal } from "./CreateProjectModal"; @@ -34,9 +37,13 @@ export const CreateDuplicateProjectButton: React.FC = ({ projectType, repo, }) => { - const { data } = useQuery( - USER_PROJECT_SETTINGS_PERMISSIONS - ); + const { data } = useQuery< + UserProjectSettingsPermissionsQuery, + UserProjectSettingsPermissionsQueryVariables + >(USER_PROJECT_SETTINGS_PERMISSIONS, { + variables: { projectIdentifier: id }, + }); + const { user: { permissions: { canCreateProject },