Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 18 additions & 4 deletions src/apps/work/src/lib/components/ProjectCard/ProjectCard.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -16,8 +27,11 @@ interface ProjectCardProps {

export const ProjectCard: FC<ProjectCardProps> = (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(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
FC,
useCallback,
useContext,
useMemo,
} from 'react'
import { Link } from 'react-router-dom'
Expand All @@ -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'

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -116,9 +121,16 @@ export const ProjectsTable: FC<ProjectsTableProps> = (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 => ([
Expand All @@ -135,7 +147,7 @@ export const ProjectsTable: FC<ProjectsTableProps> = (props: ProjectsTableProps)
label: 'Project Name',
propertyName: 'name',
renderer: (project: Project) => {
const path = getProjectPath(project)
const path = getProjectPath(project, accessToken)

return (
<Link className={styles.projectLink} to={path}>
Expand Down Expand Up @@ -171,7 +183,7 @@ export const ProjectsTable: FC<ProjectsTableProps> = (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)

Expand All @@ -193,7 +205,7 @@ export const ProjectsTable: FC<ProjectsTableProps> = (props: ProjectsTableProps)
type: 'action',
},
],
[billingAccountNames, canEditProject],
[accessToken, billingAccountNames, canEditProject],
)

const forceSort = useMemo<Sort>(
Expand Down
99 changes: 99 additions & 0 deletions src/apps/work/src/lib/utils/navigation.utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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<typeof decodeToken>

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<typeof decodeToken>)

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<typeof decodeToken>)

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<typeof decodeToken>)

expect(buildProjectLandingPath({
...project,
invites: [
{
email: 'invitee@example.com',
userId: 123,
},
],
}, 'token'))
.toBe('/projects/200/challenges')
})
})
26 changes: 26 additions & 0 deletions src/apps/work/src/lib/utils/navigation.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ 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,
rootRoute,
taasEditRouteId,
} from '../../config/routes.config'

import { checkIsUserInvitedToProject } from './permissions.utils'

interface QueryValueMap {
[key: string]: boolean | null | number | string | string[] | undefined
}
Expand Down Expand Up @@ -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`
}
Loading