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
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { Context, PropsWithChildren, ReactNode } from 'react'
import {
render,
screen,
within,
} from '@testing-library/react'
import { MemoryRouter, Route, Routes } from 'react-router-dom'

Expand All @@ -13,6 +14,10 @@ import {
useFetchProjectAttachments,
useFetchProjectMembers,
} from '../../../lib/hooks'
import {
checkCanEditProjectDetails,
checkCanManageProject,
} from '../../../lib/utils'

import { ProjectAssetsPage } from './ProjectAssetsPage'

Expand Down Expand Up @@ -116,12 +121,15 @@ jest.mock('../../../lib/services', () => ({
updateProjectAttachment: jest.fn(),
}))
jest.mock('../../../lib/utils', () => ({
checkCanEditProjectDetails: jest.fn(() => false),
checkCanManageProject: jest.fn(() => false),
}))

const mockedUseFetchProject = useFetchProject as jest.Mock
const mockedUseFetchProjectAttachments = useFetchProjectAttachments as jest.Mock
const mockedUseFetchProjectMembers = useFetchProjectMembers as jest.Mock
const mockedCheckCanEditProjectDetails = checkCanEditProjectDetails as jest.Mock
const mockedCheckCanManageProject = checkCanManageProject as jest.Mock

const defaultContextValue: WorkAppContextModel = {
isAdmin: true,
Expand Down Expand Up @@ -160,6 +168,8 @@ function renderPage(
describe('ProjectAssetsPage', () => {
beforeEach(() => {
jest.clearAllMocks()
mockedCheckCanEditProjectDetails.mockReturnValue(false)
mockedCheckCanManageProject.mockReturnValue(false)

mockedUseFetchProject.mockReturnValue({
error: undefined,
Expand Down Expand Up @@ -201,4 +211,62 @@ describe('ProjectAssetsPage', () => {
expect(screen.getByText('Assets Library'))
.toBeTruthy()
})

it('hides project edit action when a copilot can manage but cannot edit project details', () => {
mockedCheckCanManageProject.mockReturnValue(true)
mockedUseFetchProject.mockReturnValue({
error: undefined,
isLoading: false,
mutate: jest.fn(),
project: {
id: 200,
members: [
{
role: 'copilot',
userId: 12345,
},
],
name: 'Copilot Project',
},
})

renderPage('/projects/200/assets', {
...defaultContextValue,
isAdmin: false,
isCopilot: true,
loginUserInfo: {
email: 'copilot@example.com',
exp: 0,
handle: 'copilot-user',
iat: 0,
roles: ['copilot'],
userId: 12345,
} as WorkAppContextModel['loginUserInfo'],
userRoles: ['copilot'],
})

expect(within(screen.getByTestId('page-title-action'))
.queryByRole('link', { name: 'Edit project' }))
.toBeNull()
})

it('shows project edit action when project detail editing is allowed', () => {
mockedCheckCanEditProjectDetails.mockReturnValue(true)
mockedUseFetchProject.mockReturnValue({
error: undefined,
isLoading: false,
mutate: jest.fn(),
project: {
id: 200,
name: 'Payment Testing',
},
})

renderPage('/projects/200/assets')

expect(within(screen.getByTestId('page-title-action'))
.getByRole('link', { name: 'Edit project' })
.getAttribute('href'))
.toBe('/projects/200/edit')
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,10 @@ import {
removeProjectAttachment,
updateProjectAttachment,
} from '../../../lib/services'
import { checkCanManageProject } from '../../../lib/utils'
import {
checkCanEditProjectDetails,
checkCanManageProject,
} from '../../../lib/utils'

import styles from './ProjectAssetsPage.module.scss'

Expand Down Expand Up @@ -324,6 +327,12 @@ export const ProjectAssetsPage: FC = () => {
workAppContext.loginUserInfo?.userId,
projectResult.project,
)
const canEditProjectDetails = !!projectResult.project
&& checkCanEditProjectDetails(
workAppContext.userRoles,
workAppContext.loginUserInfo?.userId,
projectResult.project,
)

const [activeTab, setActiveTab] = useState<AssetsTab>('files')
const [isOpeningPicker, setIsOpeningPicker] = useState<boolean>(false)
Expand Down Expand Up @@ -749,7 +758,7 @@ export const ProjectAssetsPage: FC = () => {
const titleAction = projectId
? (
<div className={styles.projectTitleActions}>
{canManageProject
{canEditProjectDetails
? (
<Link
aria-label='Edit project'
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
import {
buildProjectLandingPath,
canCreateEngagement,
checkCanEditProjectDetails,
checkProjectAccess,
getAuthAccessToken,
} from '../../../lib/utils'
Expand Down Expand Up @@ -131,6 +132,7 @@ jest.mock('../../../lib/hooks', () => ({
jest.mock('../../../lib/utils', () => ({
buildProjectLandingPath: jest.fn((project: { id?: string | number }) => `/projects/${project.id}/challenges`),
canCreateEngagement: jest.fn(() => false),
checkCanEditProjectDetails: jest.fn(() => false),
checkCanManageProject: jest.fn(() => false),
checkProjectAccess: jest.fn((
_userRoles: string[],
Expand All @@ -148,6 +150,7 @@ const mockedUseFetchChallengeTypes = useFetchChallengeTypes as jest.Mock
const mockedUseFetchProject = useFetchProject as jest.Mock
const mockedUseFetchProjects = useFetchProjects as jest.Mock
const mockedCanCreateEngagement = canCreateEngagement as jest.Mock
const mockedCheckCanEditProjectDetails = checkCanEditProjectDetails as jest.Mock
const mockedGetAuthAccessToken = getAuthAccessToken as jest.Mock

const defaultContextValue: WorkAppContextModel = {
Expand Down Expand Up @@ -194,6 +197,7 @@ describe('ChallengesListPage', () => {
jest.clearAllMocks()

mockedCanCreateEngagement.mockReturnValue(false)
mockedCheckCanEditProjectDetails.mockReturnValue(false)
mockedCheckProjectAccess.mockImplementation((
_userRoles: string[],
_userId: number | string | undefined,
Expand Down Expand Up @@ -292,6 +296,60 @@ describe('ChallengesListPage', () => {
.toBe(true)
})

it('hides project edit action when a copilot can manage but cannot edit project details', () => {
const project = {
id: 200,
members: [
{
role: 'copilot',
userId: 12345,
},
],
name: 'Authorized Project',
status: 'active',
}

mockedUseFetchProject.mockReturnValue({
error: undefined,
isLoading: false,
project,
})

renderPage('/projects/200/challenges', '/projects/:projectId/challenges')

expect(mockedCheckCanEditProjectDetails)
.toHaveBeenCalledWith(['copilot'], 12345, project)
expect(within(screen.getByTestId('page-title-action'))
.queryByRole('link', { name: 'Edit project' }))
.toBeNull()
})

it('shows project edit action when project detail editing is allowed', () => {
mockedCheckCanEditProjectDetails.mockReturnValue(true)
mockedUseFetchProject.mockReturnValue({
error: undefined,
isLoading: false,
project: {
id: 200,
members: [
{
role: 'manager',
userId: 12345,
},
],
name: 'Authorized Project',
status: 'active',
},
})

renderPage('/projects/200/challenges', '/projects/:projectId/challenges')

expect(within(screen.getByTestId('page-title-action'))
.getByRole('link', { name: 'Edit project' })
.getAttribute('href'))
.toBe('/projects/200/edit')
})

it('waits for project access before fetching project challenges', () => {
mockedUseFetchProject.mockReturnValue({
error: undefined,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ import {
import {
buildProjectLandingPath,
canCreateEngagement,
checkCanEditProjectDetails,
checkCanManageProject,
checkProjectAccess,
getAuthAccessToken,
Expand Down Expand Up @@ -187,7 +188,7 @@ function renderContextualActions(params: RenderContextualActionsParams): JSX.Ele
}

interface RenderProjectTitleActionParams {
canManageProject: boolean
canEditProjectDetails: boolean
projectId: string | undefined
projectStatus: ProjectStatusValue | undefined
}
Expand All @@ -202,7 +203,7 @@ function renderProjectTitleAction(params: RenderProjectTitleActionParams): JSX.E
{params.projectStatus
? <ProjectStatus status={params.projectStatus} />
: undefined}
{params.canManageProject
{params.canEditProjectDetails
? (
<Link
aria-label='Edit project'
Expand All @@ -217,6 +218,30 @@ function renderProjectTitleAction(params: RenderProjectTitleActionParams): JSX.E
)
}

/**
* Returns whether the project-title edit action should render.
*
* @param userRoles caller roles from the current work app context.
* @param userId logged-in user identifier used for project membership checks.
* @param project loaded project detail for the current route.
* @returns `true` only after project detail is loaded and editable by the caller.
* @remarks Used by the project challenges page title action so admins, managers,
* and copilots keep the same loading behavior while sharing the narrower project
* detail edit permission.
*/
function canRenderProjectDetailsEditAction(
userRoles: string[],
userId: number | string | undefined,
project: Project | undefined,
): boolean {
return !!project
&& checkCanEditProjectDetails(
userRoles,
userId,
project,
)
}

interface RenderBillingAccountNoticeParams {
billingAccountId?: number | string
billingAccountName?: string
Expand Down Expand Up @@ -686,6 +711,11 @@ export const ChallengesListPage: FC = () => {
: 'Challenges'
const canManageProject = !!projectResult.project
&& checkCanManageProject(userRoles, loginUserInfo?.userId, projectResult.project)
const canEditProjectDetails = canRenderProjectDetailsEditAction(
userRoles,
loginUserInfo?.userId,
projectResult.project,
)
const isProjectActive = String(projectResult.project?.status || '')
.trim()
.toLowerCase() === PROJECT_STATUS.ACTIVE
Expand All @@ -710,7 +740,7 @@ export const ChallengesListPage: FC = () => {
})

const titleAction = renderProjectTitleAction({
canManageProject,
canEditProjectDetails,
projectId: projectIdFromRoute,
projectStatus: projectResult.project?.status,
})
Expand Down
Loading
Loading