diff --git a/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx b/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx index 5d01d8501..4c8d2fd2f 100644 --- a/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx +++ b/src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx @@ -1,9 +1,20 @@ -import { FC } from 'react' +import { + FC, + useContext, +} from 'react' import { Link } from 'react-router-dom' import classNames from 'classnames' -import { Project } from '../../models' -import { formatDateTime } from '../../utils' +import { WorkAppContext } from '../../contexts' +import { + Project, + WorkAppContextModel, +} from '../../models' +import { + buildProjectLandingPath, + formatDateTime, + getAuthAccessToken, +} from '../../utils' import { ProjectStatus } from '../ProjectStatus' import styles from './ProjectCard.module.scss' @@ -16,8 +27,11 @@ interface ProjectCardProps { export const ProjectCard: FC = (props: ProjectCardProps) => { const project: Project = props.project + const { + loginUserInfo, + }: WorkAppContextModel = useContext(WorkAppContext) const projectId: string = String(project.id) - const path: string = `/projects/${projectId}/challenges` + const path: string = buildProjectLandingPath(project, getAuthAccessToken(loginUserInfo)) const editPath = `/projects/${projectId}/edit` const lastActivity = formatDateTime( diff --git a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx index 7da3eb285..d1adffa12 100644 --- a/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx +++ b/src/apps/work/src/lib/components/ProjectsTable/ProjectsTable.tsx @@ -1,6 +1,7 @@ import { FC, useCallback, + useContext, useMemo, } from 'react' import { Link } from 'react-router-dom' @@ -13,12 +14,18 @@ import { } from '~/libs/ui' import { PROJECT_STATUS } from '../../constants' +import { WorkAppContext } from '../../contexts' import { Project, ProjectStatusValue, + WorkAppContextModel, } from '../../models' import { useFetchBillingAccounts } from '../../hooks' import type { UseFetchBillingAccountsResult } from '../../hooks' +import { + buildProjectLandingPath, + getAuthAccessToken, +} from '../../utils' import { ProjectCard } from '../ProjectCard' import { ProjectStatus } from '../ProjectStatus' @@ -82,10 +89,8 @@ interface ProjectsTableProps { onSort: (fieldName: string) => void } -function getProjectPath(project: Project): string { - const projectId = String(project.id) - - return `/projects/${projectId}/challenges` +function getProjectPath(project: Project, accessToken: string): string { + return buildProjectLandingPath(project, accessToken) } function getBillingAccountDisplay( @@ -116,9 +121,16 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) const onSort: (fieldName: string) => void = props.onSort const sortBy: string = props.sortBy const sortOrder: SortOrder = props.sortOrder + const { + loginUserInfo, + }: WorkAppContextModel = useContext(WorkAppContext) const { billingAccounts, }: UseFetchBillingAccountsResult = useFetchBillingAccounts() + const accessToken = useMemo( + () => getAuthAccessToken(loginUserInfo), + [loginUserInfo], + ) const billingAccountNames = useMemo( () => new Map( billingAccounts.map(account => ([ @@ -135,7 +147,7 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) label: 'Project Name', propertyName: 'name', renderer: (project: Project) => { - const path = getProjectPath(project) + const path = getProjectPath(project, accessToken) return ( @@ -171,7 +183,7 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) isSortable: false, label: 'Actions', renderer: (project: Project) => { - const projectPath = getProjectPath(project) + const projectPath = getProjectPath(project, accessToken) const editPath = `/projects/${project.id}/edit` const canEdit = canEditProject(project) @@ -193,7 +205,7 @@ export const ProjectsTable: FC = (props: ProjectsTableProps) type: 'action', }, ], - [billingAccountNames, canEditProject], + [accessToken, billingAccountNames, canEditProject], ) const forceSort = useMemo( diff --git a/src/apps/work/src/lib/utils/navigation.utils.spec.ts b/src/apps/work/src/lib/utils/navigation.utils.spec.ts new file mode 100644 index 000000000..24f43edea --- /dev/null +++ b/src/apps/work/src/lib/utils/navigation.utils.spec.ts @@ -0,0 +1,99 @@ +import { decodeToken } from 'tc-auth-lib' + +import type { Project } from '../models' + +import { buildProjectLandingPath } from './navigation.utils' + +jest.mock('tc-auth-lib', () => ({ + decodeToken: jest.fn(), +})) + +jest.mock('~/config', () => ({ + AppSubdomain: { + work: 'work', + }, + EnvironmentConfig: new Proxy({}, { + get: (_target, property: string): string => { + if (property === 'SUBDOMAIN') { + return 'work' + } + + return 'https://www.topcoder-dev.com' + }, + }), +}), { virtual: true }) + +jest.mock('../services/resources.service', () => ({ + fetchResourceRoles: jest.fn(), + fetchResources: jest.fn(), +})) + +const mockedDecodeToken = decodeToken as jest.MockedFunction + +describe('buildProjectLandingPath', () => { + const project: Project = { + id: '200', + name: 'Invited project', + status: 'active', + } + + afterEach(() => { + mockedDecodeToken.mockReset() + }) + + it('routes the invited user to the invitation modal path', () => { + mockedDecodeToken.mockReturnValue({ + email: 'invitee@example.com', + userId: '123', + } as ReturnType) + + expect(buildProjectLandingPath({ + ...project, + invites: [ + { + email: 'INVITEE@EXAMPLE.COM', + status: 'pending', + userId: 123, + }, + ], + }, 'token')) + .toBe('/projects/200/invitations') + }) + + it('keeps the challenges path when the matched invite is already accepted', () => { + mockedDecodeToken.mockReturnValue({ + email: 'invitee@example.com', + userId: '123', + } as ReturnType) + + expect(buildProjectLandingPath({ + ...project, + invites: [ + { + email: 'invitee@example.com', + status: 'accepted', + userId: 123, + }, + ], + }, 'token')) + .toBe('/projects/200/challenges') + }) + + it('keeps the challenges path when the invites belong to another user', () => { + mockedDecodeToken.mockReturnValue({ + email: 'manager@example.com', + userId: '999', + } as ReturnType) + + expect(buildProjectLandingPath({ + ...project, + invites: [ + { + email: 'invitee@example.com', + userId: 123, + }, + ], + }, 'token')) + .toBe('/projects/200/challenges') + }) +}) diff --git a/src/apps/work/src/lib/utils/navigation.utils.ts b/src/apps/work/src/lib/utils/navigation.utils.ts index 438cad676..4e06eb0f2 100644 --- a/src/apps/work/src/lib/utils/navigation.utils.ts +++ b/src/apps/work/src/lib/utils/navigation.utils.ts @@ -3,6 +3,8 @@ import { DIRECT_PROJECT_URL, REVIEW_APP_URL, } from '../constants' +import type { Project } from '../models' +import { PROJECT_MEMBER_INVITE_STATUS } from '../constants/project-roles.constants' import { challengeEditRouteId, projectEditRouteId, @@ -10,6 +12,8 @@ import { taasEditRouteId, } from '../../config/routes.config' +import { checkIsUserInvitedToProject } from './permissions.utils' + interface QueryValueMap { [key: string]: boolean | null | number | string | string[] | undefined } @@ -82,3 +86,25 @@ export function buildCommunityMemberUrl(handle: string): string { export function buildDirectProjectUrl(projectId: string | number): string { return `${DIRECT_PROJECT_URL}/projects/${encodeURIComponent(String(projectId))}` } + +/** + * Resolves the default in-app landing route for a project. + * + * Users with a pending invite must land on the invitation route so they can + * accept or decline the project before the app opens a project workspace tab. + * + * @param project The project summary or detail being opened. + * @param accessToken The current user's access token used to match invite ownership. + * @returns The relative route for the project landing page. + */ +export function buildProjectLandingPath(project: Project, accessToken: string = ''): string { + const projectId = encodeURIComponent(String(project.id)) + const invite = checkIsUserInvitedToProject(accessToken, project) + const normalizedInviteStatus = invite?.status + ?.trim() + .toLowerCase() + + return normalizedInviteStatus === PROJECT_MEMBER_INVITE_STATUS.PENDING + ? `/projects/${projectId}/invitations` + : `/projects/${projectId}/challenges` +}