Skip to content
This repository has been archived by the owner on Jul 2, 2024. It is now read-only.

Commit

Permalink
DEVPROD-4193: Redirect to project identifier on Project Health page (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
minnakt authored Mar 18, 2024
1 parent fa0cf16 commit c94a593
Show file tree
Hide file tree
Showing 8 changed files with 280 additions and 9 deletions.
7 changes: 6 additions & 1 deletion src/analytics/projectHealth/useProjectHealthAnalytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,12 @@ type Action =
name: "Add Notification";
subscription: SaveSubscriptionForUserMutationVariables["subscription"];
}
| { name: "Toggle view"; toggle: ProjectHealthView };
| { name: "Toggle view"; toggle: ProjectHealthView }
| {
name: "Redirect to project identifier";
projectId: string;
projectIdentifier: string;
};

export const useProjectHealthAnalytics = (p: { page: pageType }) =>
useAnalyticsRoot<Action>("ProjectHealthPages", { page: p.page });
17 changes: 12 additions & 5 deletions src/components/Header/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,12 @@ import { useAuthStateContext } from "context/Auth";
import { UserQuery, SpruceConfigQuery } from "gql/generated/types";
import { USER, SPRUCE_CONFIG } from "gql/queries";
import { useLegacyUIURL } from "hooks";
import { validators } from "utils";
import { AuxiliaryDropdown } from "./AuxiliaryDropdown";
import { UserDropdown } from "./UserDropdown";

const { validateObjectId } = validators;

const { blue, gray, white } = palette;

export const Navbar: React.FC = () => {
Expand All @@ -33,16 +36,20 @@ export const Navbar: React.FC = () => {
const { projectIdentifier: projectFromUrl } = useParams<{
projectIdentifier: string;
}>();
const currProject = Cookies.get(CURRENT_PROJECT);

// Update current project cookie if the project in the URL does not equal the cookie value.
// Update current project cookie if the project in the URL is not an objectId and is not equal
// to the current project.
// This will inform future navigations to the /commits page.
useEffect(() => {
if (projectFromUrl && projectFromUrl !== Cookies.get(CURRENT_PROJECT)) {
if (
projectFromUrl &&
!validateObjectId(projectFromUrl) &&
projectFromUrl !== currProject
) {
Cookies.set(CURRENT_PROJECT, projectFromUrl);
}
}, [projectFromUrl]);

const currProject = projectFromUrl ?? Cookies.get(CURRENT_PROJECT);
}, [currProject, projectFromUrl]);

const { data: configData } = useQuery<SpruceConfigQuery>(SPRUCE_CONFIG, {
skip: currProject !== undefined,
Expand Down
36 changes: 36 additions & 0 deletions src/gql/generated/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -699,6 +699,7 @@ export type Host = {
instanceType?: Maybe<Scalars["String"]["output"]>;
lastCommunicationTime?: Maybe<Scalars["Time"]["output"]>;
noExpiration: Scalars["Boolean"]["output"];
persistentDnsName: Scalars["String"]["output"];
provider: Scalars["String"]["output"];
runningTask?: Maybe<TaskInfo>;
startedBy: Scalars["String"]["output"];
Expand Down Expand Up @@ -1057,6 +1058,7 @@ export type Mutation = {
unschedulePatchTasks?: Maybe<Scalars["String"]["output"]>;
unscheduleTask: Task;
updateHostStatus: Scalars["Int"]["output"];
updateParsleySettings?: Maybe<UpdateParsleySettingsPayload>;
updatePublicKey: Array<PublicKey>;
updateSpawnHostStatus: Host;
updateUserSettings: Scalars["Boolean"]["output"];
Expand Down Expand Up @@ -1315,6 +1317,10 @@ export type MutationUpdateHostStatusArgs = {
status: Scalars["String"]["input"];
};

export type MutationUpdateParsleySettingsArgs = {
opts: UpdateParsleySettingsInput;
};

export type MutationUpdatePublicKeyArgs = {
targetKeyName: Scalars["String"]["input"];
updateInfo: PublicKeyInput;
Expand Down Expand Up @@ -1406,6 +1412,16 @@ export type ParsleyFilterInput = {
expression: Scalars["String"]["input"];
};

/** ParsleySettings contains information about a user's settings for Parsley. */
export type ParsleySettings = {
__typename?: "ParsleySettings";
sectionsEnabled: Scalars["Boolean"]["output"];
};

export type ParsleySettingsInput = {
sectionsEnabled?: InputMaybe<Scalars["Boolean"]["input"]>;
};

/** Patch is a manually initiated version submitted to test local code changes. */
export type Patch = {
__typename?: "Patch";
Expand Down Expand Up @@ -2532,6 +2548,7 @@ export type Task = {
startTime?: Maybe<Scalars["Time"]["output"]>;
status: Scalars["String"]["output"];
stepbackInfo?: Maybe<StepbackInfo>;
tags: Array<Scalars["String"]["output"]>;
/** @deprecated Use files instead */
taskFiles: TaskFiles;
taskGroup?: Maybe<Scalars["String"]["output"]>;
Expand Down Expand Up @@ -2865,6 +2882,15 @@ export type UiConfig = {
userVoice?: Maybe<Scalars["String"]["output"]>;
};

export type UpdateParsleySettingsInput = {
parsleySettings: ParsleySettingsInput;
};

export type UpdateParsleySettingsPayload = {
__typename?: "UpdateParsleySettingsPayload";
parsleySettings?: Maybe<ParsleySettings>;
};

/**
* UpdateVolumeInput is the input to the updateVolume mutation.
* Its fields determine how a given volume will be modified.
Expand Down Expand Up @@ -2911,6 +2937,7 @@ export type User = {
displayName: Scalars["String"]["output"];
emailAddress: Scalars["String"]["output"];
parsleyFilters: Array<ParsleyFilter>;
parsleySettings: ParsleySettings;
patches: Patches;
permissions: Permissions;
subscriptions?: Maybe<Array<GeneralSubscription>>;
Expand Down Expand Up @@ -7389,6 +7416,15 @@ export type ProjectSettingsQuery = {
};
};

export type ProjectQueryVariables = Exact<{
idOrIdentifier: Scalars["String"]["input"];
}>;

export type ProjectQuery = {
__typename?: "Query";
project: { __typename?: "Project"; id: string; identifier: string };
};

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

export type ProjectsQuery = {
Expand Down
2 changes: 2 additions & 0 deletions src/gql/queries/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import PROJECT_EVENT_LOGS from "./project-event-logs.graphql";
import PROJECT_HEALTH_VIEW from "./project-health-view.graphql";
import PROJECT_PATCHES from "./project-patches.graphql";
import PROJECT_SETTINGS from "./project-settings.graphql";
import PROJECT from "./project.graphql";
import PROJECTS from "./projects.graphql";
import MY_PUBLIC_KEYS from "./public-keys.graphql";
import REPO_EVENT_LOGS from "./repo-event-logs.graphql";
Expand Down Expand Up @@ -124,6 +125,7 @@ export {
PATCH,
POD_EVENTS,
POD,
PROJECT,
PROJECT_BANNER,
PROJECT_EVENT_LOGS,
PROJECT_HEALTH_VIEW,
Expand Down
6 changes: 6 additions & 0 deletions src/gql/queries/project.graphql
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
query Project($idOrIdentifier: String!) {
project(projectIdentifier: $idOrIdentifier) {
id
identifier
}
}
45 changes: 45 additions & 0 deletions src/hooks/useProjectRedirect/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { useQuery } from "@apollo/client";
import { useParams, useLocation, useNavigate } from "react-router-dom";
import { ProjectQuery, ProjectQueryVariables } from "gql/generated/types";
import { PROJECT } from "gql/queries";
import { validators } from "utils";

const { validateObjectId } = validators;

interface UseProjectRedirectProps {
sendAnalyticsEvent: (projectId: string, projectIdentifier: string) => void;
}

/**
* useProjectRedirect will replace the project id with the project identifier in the URL.
* @param props - Object containing the following:
* @param props.sendAnalyticsEvent - analytics event to send upon redirect
* @returns isRedirecting - boolean to indicate if a redirect is in progress
*/
export const useProjectRedirect = ({
sendAnalyticsEvent = () => {},
}: UseProjectRedirectProps) => {
const { projectIdentifier: project } = useParams<{
projectIdentifier: string;
}>();
const navigate = useNavigate();
const location = useLocation();

const needsRedirect = validateObjectId(project);

const { loading } = useQuery<ProjectQuery, ProjectQueryVariables>(PROJECT, {
skip: !needsRedirect,
variables: {
idOrIdentifier: project,
},
onCompleted: (projectData) => {
const { identifier } = projectData.project;
const currentUrl = location.pathname.concat(location.search);
const redirectPathname = currentUrl.replace(project, identifier);
sendAnalyticsEvent(project, identifier);
navigate(redirectPathname);
},
});

return { isRedirecting: needsRedirect && loading };
};
157 changes: 157 additions & 0 deletions src/hooks/useProjectRedirect/useProjectRedirect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
import { MockedProvider } from "@apollo/client/testing";
import { GraphQLError } from "graphql";
import { MemoryRouter, Routes, Route, useLocation } from "react-router-dom";
import { ProjectQuery, ProjectQueryVariables } from "gql/generated/types";
import { PROJECT } from "gql/queries";
import { renderHook, waitFor } from "test_utils";
import { ApolloMock } from "types/gql";
import { useProjectRedirect } from ".";

const useJointHook = ({
sendAnalyticsEvent,
}: {
sendAnalyticsEvent: (projectId: string, projectIdentifier: string) => void;
}) => {
const { isRedirecting } = useProjectRedirect({ sendAnalyticsEvent });
const { pathname, search } = useLocation();
return { isRedirecting, pathname, search };
};

const ProviderWrapper: React.FC<{
children: React.ReactNode;
location: string;
}> = ({ children, location }) => (
<MockedProvider mocks={[repoMock, projectMock]}>
<MemoryRouter initialEntries={[location]}>
<Routes>
<Route element={children} path="/commits/:projectIdentifier" />
</Routes>
</MemoryRouter>
</MockedProvider>
);

describe("useProjectRedirect", () => {
it("should not redirect if URL has project identifier", async () => {
const sendAnalyticsEvent = jest.fn();
const { result } = renderHook(() => useJointHook({ sendAnalyticsEvent }), {
wrapper: ({ children }) =>
ProviderWrapper({ children, location: "/commits/my-project" }),
});
expect(result.current).toMatchObject({
isRedirecting: false,
pathname: "/commits/my-project",
search: "",
});
expect(sendAnalyticsEvent).toHaveBeenCalledTimes(0);
});

it("should redirect if URL has project ID", async () => {
const sendAnalyticsEvent = jest.fn();
const { result } = renderHook(() => useJointHook({ sendAnalyticsEvent }), {
wrapper: ({ children }) =>
ProviderWrapper({ children, location: `/commits/${projectId}` }),
});
expect(result.current).toMatchObject({
isRedirecting: true,
pathname: "/commits/5f74d99ab2373627c047c5e5",
search: "",
});
await waitFor(() => {
expect(result.current).toMatchObject({
isRedirecting: false,
pathname: "/commits/my-project",
search: "",
});
});
expect(sendAnalyticsEvent).toHaveBeenCalledTimes(1);
expect(sendAnalyticsEvent).toHaveBeenCalledWith(
"5f74d99ab2373627c047c5e5",
"my-project",
);
});

it("should preserve query params when redirecting", async () => {
const sendAnalyticsEvent = jest.fn();
const { result } = renderHook(() => useJointHook({ sendAnalyticsEvent }), {
wrapper: ({ children }) =>
ProviderWrapper({
children,
location: `/commits/${projectId}?taskName=thirdparty`,
}),
});
expect(result.current).toMatchObject({
isRedirecting: true,
pathname: "/commits/5f74d99ab2373627c047c5e5",
search: "?taskName=thirdparty",
});
await waitFor(() => {
expect(result.current).toMatchObject({
isRedirecting: false,
pathname: "/commits/my-project",
search: "?taskName=thirdparty",
});
});
expect(sendAnalyticsEvent).toHaveBeenCalledTimes(1);
expect(sendAnalyticsEvent).toHaveBeenCalledWith(
"5f74d99ab2373627c047c5e5",
"my-project",
);
});

it("should attempt redirect if URL has repo ID but stop attempting after query", async () => {
const sendAnalyticsEvent = jest.fn();
const { result } = renderHook(() => useJointHook({ sendAnalyticsEvent }), {
wrapper: ({ children }) =>
ProviderWrapper({ children, location: `/commits/${repoId}` }),
});
expect(result.current).toMatchObject({
isRedirecting: true,
pathname: "/commits/5e6bb9e23066155a993e0f1a",
search: "",
});
await waitFor(() => {
expect(result.current).toMatchObject({
isRedirecting: false,
pathname: "/commits/5e6bb9e23066155a993e0f1a",
search: "",
});
});
expect(sendAnalyticsEvent).toHaveBeenCalledTimes(0);
});
});

const projectId = "5f74d99ab2373627c047c5e5";
const projectMock: ApolloMock<ProjectQuery, ProjectQueryVariables> = {
request: {
query: PROJECT,
variables: {
idOrIdentifier: projectId,
},
},
result: {
data: {
project: {
__typename: "Project",
id: projectId,
identifier: "my-project",
},
},
},
};

const repoId = "5e6bb9e23066155a993e0f1a";
const repoMock: ApolloMock<ProjectQuery, ProjectQueryVariables> = {
request: {
query: PROJECT,
variables: {
idOrIdentifier: repoId,
},
},
result: {
errors: [
new GraphQLError(
`Error finding project by id ${repoId}: 404 (Not Found): project '${repoId}' not found`,
),
],
},
};
Loading

0 comments on commit c94a593

Please sign in to comment.