diff --git a/src/authz/constants.ts b/src/authz/constants.ts index add6f21983..0159dfa913 100644 --- a/src/authz/constants.ts +++ b/src/authz/constants.ts @@ -29,4 +29,8 @@ export const COURSE_PERMISSIONS = { VIEW_PAGES_AND_RESOURCES: 'courses.view_pages_and_resources', MANAGE_PAGES_AND_RESOURCES: 'courses.manage_pages_and_resources', + VIEW_FILES: 'courses.view_files', + CREATE_FILES: 'courses.create_files', + DELETE_FILES: 'courses.delete_files', + EDIT_FILES: 'courses.edit_files', }; diff --git a/src/authz/hooks.test.ts b/src/authz/hooks.test.ts index ff3a40de13..b0d246dd82 100644 --- a/src/authz/hooks.test.ts +++ b/src/authz/hooks.test.ts @@ -49,7 +49,7 @@ describe('useCourseUserPermissions', () => { expect(result.current.canEdit).toBe(false); }); - it('returns isLoading=true and no permission keys while authz permissions are loading', () => { + it('returns isLoading=true and permissions as false while authz permissions are loading', () => { mockWaffleFlags({ enableAuthzCourseAuthoring: true }); jest.mocked(useUserPermissions).mockReturnValue({ isLoading: true, diff --git a/src/authz/hooks.test.tsx b/src/authz/hooks.test.tsx deleted file mode 100644 index 7a56c36c6e..0000000000 --- a/src/authz/hooks.test.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import React from 'react'; -import { renderHook, waitFor } from '@testing-library/react'; -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; - -import * as authzApi from '@src/authz/data/api'; -import { PermissionValidationQuery } from '@src/authz/types'; -import { mockWaffleFlags } from '@src/data/apiHooks.mock'; -import { useCourseUserPermissions } from './hooks'; - -jest.mock('@src/data/api'); -jest.mock('@src/authz/data/api'); - -const mockedAuthzApi = jest.mocked(authzApi); - -describe('useCourseUserPermissions', () => { - let queryClient: QueryClient; - - const createWrapper = () => - function TestWrapper({ children }: { children: React.ReactNode; }) { - return ( - - {children} - - ); - }; - - const mockPermissions: PermissionValidationQuery = { - canViewFiles: { - action: 'course.view_files', - scope: 'course-v1:Test+101+2023', - }, - canManageFiles: { - action: 'course.manage_files', - scope: 'course-v1:Test+101+2023', - }, - }; - - beforeEach(() => { - queryClient = new QueryClient({ - defaultOptions: { - queries: { retry: false }, - }, - }); - jest.clearAllMocks(); - }); - - it('returns all permissions as true when authz is disabled', async () => { - mockWaffleFlags({ - enableAuthzCourseAuthoring: false, - }); - - const { result } = renderHook( - () => useCourseUserPermissions('course-v1:Test+101+2023', mockPermissions), - { wrapper: createWrapper() }, - ); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.isAuthzEnabled).toBe(false); - expect(result.current.canViewFiles).toBe(true); - expect(result.current.canManageFiles).toBe(true); - }); - - it('returns loading state when authz is enabled and permissions are loading', async () => { - mockWaffleFlags({ - enableAuthzCourseAuthoring: true, - }); - - mockedAuthzApi.validateUserPermissions.mockImplementation( - () => new Promise(() => {}), - ); - - const { result } = renderHook( - () => useCourseUserPermissions('course-v1:Test+101+2023', mockPermissions), - { wrapper: createWrapper() }, - ); - - await waitFor(() => { - expect(result.current.isAuthzEnabled).toBe(true); - }); - - expect(result.current.isLoading).toBe(true); - expect(result.current.canViewFiles).toBe(false); - expect(result.current.canManageFiles).toBe(false); - }); - - it('returns actual permission values when authz is enabled and permissions loaded', async () => { - mockWaffleFlags({ - enableAuthzCourseAuthoring: true, - }); - - mockedAuthzApi.validateUserPermissions.mockResolvedValue({ - canViewFiles: true, - canManageFiles: false, - }); - - const { result } = renderHook( - () => useCourseUserPermissions('course-v1:Test+101+2023', mockPermissions), - { wrapper: createWrapper() }, - ); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.isAuthzEnabled).toBe(true); - expect(result.current.canViewFiles).toBe(true); - expect(result.current.canManageFiles).toBe(false); - }); - - it('falls back to false for undefined permissions when authz is enabled', async () => { - mockWaffleFlags({ - enableAuthzCourseAuthoring: true, - }); - - mockedAuthzApi.validateUserPermissions.mockResolvedValue({ - canViewFiles: true, - }); - - const { result } = renderHook( - () => useCourseUserPermissions('course-v1:Test+101+2023', mockPermissions), - { wrapper: createWrapper() }, - ); - - await waitFor(() => { - expect(result.current.isLoading).toBe(false); - }); - - expect(result.current.isAuthzEnabled).toBe(true); - expect(result.current.canViewFiles).toBe(true); - expect(result.current.canManageFiles).toBe(false); - }); -}); diff --git a/src/authz/permissionHelpers.test.ts b/src/authz/permissionHelpers.test.ts index 5baf604ba6..ecc03f3187 100644 --- a/src/authz/permissionHelpers.test.ts +++ b/src/authz/permissionHelpers.test.ts @@ -3,12 +3,15 @@ import { getPagesAndResourcesPermissions, getAdvancedSettingsPermissions, getCourseUpdatesPermissions, + getFilesPermissions, } from './permissionHelpers'; import { COURSE_PERMISSIONS } from './constants'; const courseId = 'course-v1:org+course+run'; describe('permissionHelpers', () => { + const mockCourseId = 'course-v1:edX+DemoX+Demo_Course'; + describe('getCourseUpdatesPermissions', () => { it('should return correct permission structure for course updates operations', () => { const result = getCourseUpdatesPermissions(courseId); @@ -25,51 +28,118 @@ describe('permissionHelpers', () => { }); }); - it('should use the provided courseId as scope for all permissions', () => { - const customCourseId = 'course-v1:TestOrg+TestCourse+2024'; - const result = getCourseUpdatesPermissions(customCourseId); + describe('getFilesPermissions', () => { + it('should return correct permission structure for file operations', () => { + const result = getFilesPermissions(mockCourseId); - Object.values(result).forEach(permission => { - expect(permission.scope).toBe(customCourseId); + expect(result).toEqual({ + canViewFiles: { + action: COURSE_PERMISSIONS.VIEW_FILES, + scope: mockCourseId, + }, + canCreateFiles: { + action: COURSE_PERMISSIONS.CREATE_FILES, + scope: mockCourseId, + }, + canDeleteFiles: { + action: COURSE_PERMISSIONS.DELETE_FILES, + scope: mockCourseId, + }, + canEditFiles: { + action: COURSE_PERMISSIONS.EDIT_FILES, + scope: mockCourseId, + }, + }); + }); + + it('should use the provided courseId as scope for all permissions', () => { + const customCourseId = 'course-v1:TestOrg+TestCourse+2024'; + const result = getFilesPermissions(customCourseId); + + Object.values(result).forEach(permission => { + expect(permission.scope).toBe(customCourseId); + }); }); }); - }); - describe('getGradingPermissions', () => { - it('returns VIEW and EDIT permissions with the correct actions and scope', () => { - const result = getGradingPermissions(courseId); + describe('getGradingPermissions', () => { + it('returns VIEW and EDIT permissions with the correct actions and scope', () => { + const result = getGradingPermissions(courseId); - expect(result.canViewGradingSettings.action).toBe(COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS); - expect(result.canViewGradingSettings.scope).toBe(courseId); - expect(result.canEditGradingSettings.action).toBe(COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS); - expect(result.canEditGradingSettings.scope).toBe(courseId); + expect(result.canViewGradingSettings.action).toBe(COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS); + expect(result.canViewGradingSettings.scope).toBe(courseId); + expect(result.canEditGradingSettings.action).toBe(COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS); + expect(result.canEditGradingSettings.scope).toBe(courseId); + }); + }); + + describe('getPagesAndResourcesPermissions', () => { + it('returns VIEW and MANAGE permissions with the correct actions and scope', () => { + const result = getPagesAndResourcesPermissions(courseId); + + expect(result.canViewPagesAndResources.action).toBe(COURSE_PERMISSIONS.VIEW_PAGES_AND_RESOURCES); + expect(result.canViewPagesAndResources.scope).toBe(courseId); + expect(result.canManagePagesAndResources.action).toBe(COURSE_PERMISSIONS.MANAGE_PAGES_AND_RESOURCES); + expect(result.canManagePagesAndResources.scope).toBe(courseId); + }); + }); + + describe('getAdvancedSettingsPermissions', () => { + it('returns MANAGE permission with the correct action and scope', () => { + const result = getAdvancedSettingsPermissions(courseId); + + expect(result.canManageAdvancedSettings.action).toBe(COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS); + expect(result.canManageAdvancedSettings.scope).toBe(courseId); + }); + + it('uses the provided courseId as scope', () => { + const otherId = 'course-v1:another+test+run'; + const result = getAdvancedSettingsPermissions(otherId); + + expect(result.canManageAdvancedSettings.scope).toBe(otherId); + }); }); - }); - describe('getPagesAndResourcesPermissions', () => { - it('returns VIEW and MANAGE permissions with the correct actions and scope', () => { - const result = getPagesAndResourcesPermissions(courseId); + it('should use correct COURSE_PERMISSIONS constants for each action', () => { + const result = getFilesPermissions(mockCourseId); - expect(result.canViewPagesAndResources.action).toBe(COURSE_PERMISSIONS.VIEW_PAGES_AND_RESOURCES); - expect(result.canViewPagesAndResources.scope).toBe(courseId); - expect(result.canManagePagesAndResources.action).toBe(COURSE_PERMISSIONS.MANAGE_PAGES_AND_RESOURCES); - expect(result.canManagePagesAndResources.scope).toBe(courseId); + expect(result.canViewFiles.action).toBe(COURSE_PERMISSIONS.VIEW_FILES); + expect(result.canCreateFiles.action).toBe(COURSE_PERMISSIONS.CREATE_FILES); + expect(result.canDeleteFiles.action).toBe(COURSE_PERMISSIONS.DELETE_FILES); + expect(result.canEditFiles.action).toBe(COURSE_PERMISSIONS.EDIT_FILES); }); }); - describe('getAdvancedSettingsPermissions', () => { - it('returns MANAGE permission with the correct action and scope', () => { - const result = getAdvancedSettingsPermissions(courseId); + describe('getGradingPermissions', () => { + it('should return correct permission structure for grading operations', () => { + const result = getGradingPermissions(mockCourseId); + + expect(result).toEqual({ + canViewGradingSettings: { + action: COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS, + scope: mockCourseId, + }, + canEditGradingSettings: { + action: COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS, + scope: mockCourseId, + }, + }); + }); + + it('should use the provided courseId as scope for all permissions', () => { + const customCourseId = 'course-v1:TestOrg+TestCourse+2024'; + const result = getGradingPermissions(customCourseId); - expect(result.canManageAdvancedSettings.action).toBe(COURSE_PERMISSIONS.MANAGE_ADVANCED_SETTINGS); - expect(result.canManageAdvancedSettings.scope).toBe(courseId); + Object.values(result).forEach(permission => { + expect(permission.scope).toBe(customCourseId); + }); }); - it('uses the provided courseId as scope', () => { - const otherId = 'course-v1:another+test+run'; - const result = getAdvancedSettingsPermissions(otherId); + it('should use correct COURSE_PERMISSIONS constants for each action', () => { + const result = getGradingPermissions(mockCourseId); - expect(result.canManageAdvancedSettings.scope).toBe(otherId); + expect(result.canViewGradingSettings.action).toBe(COURSE_PERMISSIONS.VIEW_GRADING_SETTINGS); + expect(result.canEditGradingSettings.action).toBe(COURSE_PERMISSIONS.EDIT_GRADING_SETTINGS); }); }); }); diff --git a/src/authz/permissionHelpers.ts b/src/authz/permissionHelpers.ts index bc06f8988a..976a25f837 100644 --- a/src/authz/permissionHelpers.ts +++ b/src/authz/permissionHelpers.ts @@ -54,3 +54,21 @@ export const getAdvancedSettingsPermissions = (courseId: string) => ({ scope: courseId, }, }); +export const getFilesPermissions = (courseId: string) => ({ + canViewFiles: { + action: COURSE_PERMISSIONS.VIEW_FILES, + scope: courseId, + }, + canCreateFiles: { + action: COURSE_PERMISSIONS.CREATE_FILES, + scope: courseId, + }, + canDeleteFiles: { + action: COURSE_PERMISSIONS.DELETE_FILES, + scope: courseId, + }, + canEditFiles: { + action: COURSE_PERMISSIONS.EDIT_FILES, + scope: courseId, + }, +}); diff --git a/src/files-and-videos/files-page/CourseFilesTable.tsx b/src/files-and-videos/files-page/CourseFilesTable.tsx index 77f08bc2e8..a08602ee9e 100644 --- a/src/files-and-videos/files-page/CourseFilesTable.tsx +++ b/src/files-and-videos/files-page/CourseFilesTable.tsx @@ -28,6 +28,8 @@ import { getFileSizeToClosestByte } from '@src/utils'; import React from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useParams } from 'react-router-dom'; +import { useCourseUserPermissions } from '@src/authz/hooks'; +import { getFilesPermissions } from '@src/authz/permissionHelpers'; export const CourseFilesTable = () => { const intl = useIntl(); @@ -40,6 +42,12 @@ export const CourseFilesTable = () => { errors: errorMessages, } = useSelector((state: DeprecatedReduxState) => state.assets); + const { + canCreateFiles, + canDeleteFiles, + canEditFiles, + } = useCourseUserPermissions(courseId, getFilesPermissions(courseId)); + const handleErrorReset = (error) => dispatch(resetErrors(error)); const handleDeleteFile = (id) => dispatch(deleteAssetFile(courseId, id)); const handleDownloadFile = (selectedRows) => @@ -69,6 +77,7 @@ export const CourseFilesTable = () => { FileInfoModalSidebar({ asset, handleLockedAsset: handleLockFile, + canLockFile: canEditFiles, }); const assets = useModels('assets', assetIds); @@ -180,6 +189,11 @@ export const CourseFilesTable = () => { thumbnailPreview, infoModalSidebar, files: assets, + permissions: { + canCreateFiles, + canDeleteFiles, + canEditFiles, + }, }} /> diff --git a/src/files-and-videos/files-page/FileInfoModalSidebar.jsx b/src/files-and-videos/files-page/FileInfoModalSidebar.jsx index 4c0062bce1..6877320bef 100644 --- a/src/files-and-videos/files-page/FileInfoModalSidebar.jsx +++ b/src/files-and-videos/files-page/FileInfoModalSidebar.jsx @@ -19,6 +19,7 @@ import './FileInfoModalSidebar.scss'; const FileInfoModalSidebar = ({ asset, handleLockedAsset, + canLockFile = true, }) => { const intl = useIntl(); const [lockedState, setLockedState] = useState(asset?.locked); @@ -78,26 +79,28 @@ const FileInfoModalSidebar = ({ onClick={() => navigator.clipboard.writeText(asset?.externalUrl)} /> - -
- -
- - - -
+ {canLockFile && ( + +
+ +
+ + + +
+ )} ); }; @@ -115,6 +118,7 @@ FileInfoModalSidebar.propTypes = { usageLocations: PropTypes.arrayOf(PropTypes.string), }).isRequired, handleLockedAsset: PropTypes.func.isRequired, + canLockFile: PropTypes.bool, }; export default FileInfoModalSidebar; diff --git a/src/files-and-videos/files-page/FileInfoModalSidebar.test.jsx b/src/files-and-videos/files-page/FileInfoModalSidebar.test.jsx new file mode 100644 index 0000000000..d84ce193da --- /dev/null +++ b/src/files-and-videos/files-page/FileInfoModalSidebar.test.jsx @@ -0,0 +1,74 @@ +import { render, screen } from '@testing-library/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import FileInfoModalSidebar from './FileInfoModalSidebar'; + +const mockAsset = { + id: 'test-file-id', + displayName: 'test-file.png', + wrapperType: 'image', + externalUrl: 'https://example.com/test-file.png', + portableUrl: '/static/test-file.png', + locked: false, + thumbnail: 'https://example.com/thumbnail.png', + dateAdded: '2024-01-15T10:30:00Z', + fileSize: 1024000, + usageLocations: [], +}; + +const mockHandleLockedAsset = jest.fn(); + +const renderComponent = (props = {}) => + render( + + + , + ); + +describe('FileInfoModalSidebar', () => { + beforeEach(() => { + jest.clearAllMocks(); + Object.assign(navigator, { + clipboard: { + writeText: jest.fn(), + }, + }); + }); + + it('renders asset information correctly', () => { + renderComponent(); + + expect(screen.getByText('Date added')).toBeInTheDocument(); + expect(screen.getByText('File size')).toBeInTheDocument(); + expect(screen.getByText('Studio URL')).toBeInTheDocument(); + expect(screen.getByText('Web URL')).toBeInTheDocument(); + expect(screen.getByText('Lock file')).toBeInTheDocument(); + }); + + it('hides Lock file section when canLockFile is false', () => { + renderComponent({ canLockFile: false }); + + expect(screen.getByText('Date added')).toBeInTheDocument(); + expect(screen.getByText('Studio URL')).toBeInTheDocument(); + expect(screen.queryByText('Lock file')).not.toBeInTheDocument(); + }); + + it('shows Lock file section when canLockFile is true', () => { + renderComponent({ canLockFile: true }); + + expect(screen.getByText('Lock file')).toBeInTheDocument(); + }); + + it('displays the portable URL', () => { + renderComponent(); + expect(screen.getByText('/static/test-file.png')).toBeInTheDocument(); + }); + + it('displays the external URL', () => { + renderComponent(); + expect(screen.getByText('https://example.com/test-file.png')).toBeInTheDocument(); + }); +}); diff --git a/src/files-and-videos/files-page/FilesPage.jsx b/src/files-and-videos/files-page/FilesPage.jsx index 22f9e00980..56ef91e159 100644 --- a/src/files-and-videos/files-page/FilesPage.jsx +++ b/src/files-and-videos/files-page/FilesPage.jsx @@ -13,9 +13,13 @@ import EditFileAlertsSlot from '@src/plugin-slots/EditFileAlertsSlot'; import { AlertAgreementGatedFeature } from '@src/generic/agreement-gated-feature'; import { AgreementGated } from '@src/constants'; +import { useCourseUserPermissions } from '@src/authz/hooks'; +import { getFilesPermissions } from '@src/authz/permissionHelpers'; +import PermissionDeniedAlert from '@src/generic/PermissionDeniedAlert'; import { EditFileErrors } from '../generic'; import { fetchAssets, resetErrors } from './data/thunks'; import FilesPageProvider from './FilesPageProvider'; +import Loading from '@src/generic/Loading'; import messages from './messages'; import './FilesPage.scss'; @@ -32,10 +36,23 @@ const FilesPage = () => { errors: errorMessages, } = useSelector(state => state.assets); + const { + isLoading: isLoadingPermissions, + canViewFiles, + } = useCourseUserPermissions(courseId, getFilesPermissions(courseId)); + useEffect(() => { dispatch(fetchAssets(courseId)); }, [courseId]); + if (isLoadingPermissions) { + return ; + } + + if (!isLoadingPermissions && !canViewFiles) { + return ; + } + const handleErrorReset = (error) => dispatch(resetErrors(error)); if (loadingStatus === RequestStatus.DENIED) { diff --git a/src/files-and-videos/files-page/FilesPage.test.jsx b/src/files-and-videos/files-page/FilesPage.test.jsx index 14753ba53b..ed2798e060 100644 --- a/src/files-and-videos/files-page/FilesPage.test.jsx +++ b/src/files-and-videos/files-page/FilesPage.test.jsx @@ -13,6 +13,7 @@ import { initializeMocks, } from '@src/testUtils'; import { CourseAuthoringProvider } from '@src/CourseAuthoringContext'; +import { useCourseUserPermissions } from '@src/authz/hooks'; import { executeThunk } from '@src/utils'; import { RequestStatus } from '@src/data/constants'; import FilesPage from './FilesPage'; @@ -45,6 +46,16 @@ let file; ReactDOM.createPortal = jest.fn(node => node); jest.mock('file-saver'); +jest.mock('@src/authz/hooks', () => ({ + useCourseUserPermissions: jest.fn().mockReturnValue({ + isLoading: false, + canViewFiles: true, + canEditFiles: true, + canDeleteFiles: true, + canCreateFiles: true, + }), +})); + const renderComponent = () => { render( @@ -706,4 +717,107 @@ describe('FilesAndUploads', () => { }); }); }); + + describe('permissions', () => { + beforeEach(() => { + const mocks = initializeMocks({ + initialState: { + ...initialState, + assets: { + ...initialState.assets, + assetIds: [], + }, + models: {}, + }, + }); + store = mocks.reduxStore; + axiosMock = mocks.axiosMock; + file = new File(['(⌐□_□)'], 'download.png', { type: 'image/png' }); + global.localStorage.clear(); + }); + it('should render loading spinner when permissions are loading', async () => { + useCourseUserPermissions.mockReturnValue({ + isLoading: true, + canViewFiles: false, + canEditFiles: false, + canDeleteFiles: false, + canCreateFiles: false, + }); + renderComponent(); + expect(screen.getByRole('status')).toBeInTheDocument(); + expect(screen.queryByTestId('files-dropzone')).not.toBeInTheDocument(); + }); + + it('should render permission alert when the user is not authorized to view files', async () => { + useCourseUserPermissions.mockReturnValue({ + isLoading: false, + canViewFiles: false, + canEditFiles: true, + canDeleteFiles: true, + canCreateFiles: true, + }); + await mockStore(RequestStatus.SUCCESSFUL); + expect(await screen.findByText(/You are not authorized to view this page/)).toBeInTheDocument(); + }); + + it('should not render dropzone when is not authorized to create files', async () => { + useCourseUserPermissions.mockReturnValue({ + isLoading: false, + canViewFiles: true, + canEditFiles: true, + canDeleteFiles: true, + canCreateFiles: false, + }); + await emptyMockStore(RequestStatus.SUCCESSFUL); + + expect(screen.queryByTestId('files-dropzone')).toBeNull(); + }); + + it('should render dropzone when is authorized to create files', async () => { + useCourseUserPermissions.mockReturnValue({ + isLoading: false, + canViewFiles: true, + canEditFiles: true, + canDeleteFiles: true, + canCreateFiles: true, + }); + await emptyMockStore(RequestStatus.SUCCESSFUL); + + expect(screen.queryByTestId('files-dropzone')).toBeInTheDocument(); + }); + + it('should not render delete item when is not authorized to delete files', async () => { + const user = userEvent.setup(); + useCourseUserPermissions.mockReturnValue({ + isLoading: false, + canViewFiles: true, + canEditFiles: true, + canDeleteFiles: false, + canCreateFiles: true, + }); + await mockStore(RequestStatus.SUCCESSFUL); + + const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); + await user.click(actionsButton); + + expect(screen.queryByText(messages.deleteTitle.defaultMessage)).toBeNull(); + }); + + it('should render delete item when is authorized to delete files', async () => { + const user = userEvent.setup(); + useCourseUserPermissions.mockReturnValue({ + isLoading: false, + canViewFiles: true, + canEditFiles: true, + canDeleteFiles: true, + canCreateFiles: true, + }); + await mockStore(RequestStatus.SUCCESSFUL); + + const actionsButton = screen.getByText(messages.actionsButtonLabel.defaultMessage); + await user.click(actionsButton); + + expect(screen.queryByText(messages.deleteTitle.defaultMessage)).toBeInTheDocument(); + }); + }); }); diff --git a/src/files-and-videos/generic/FileMenu.jsx b/src/files-and-videos/generic/FileMenu.jsx index 070f6c3aba..8abbee2ec9 100644 --- a/src/files-and-videos/generic/FileMenu.jsx +++ b/src/files-and-videos/generic/FileMenu.jsx @@ -19,6 +19,10 @@ const FileMenu = ({ portableUrl, id, fileType, + permissions = { + canEditFiles: true, + canDeleteFiles: true, + }, }) => { const intl = useIntl(); return ( @@ -52,9 +56,11 @@ const FileMenu = ({ > {intl.formatMessage(messages.copyWebUrlTitle)} - - {locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)} - + {permissions.canEditFiles && ( + + {locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)} + + )} )} @@ -63,13 +69,17 @@ const FileMenu = ({ {intl.formatMessage(messages.infoTitle)} - - - {intl.formatMessage(messages.deleteTitle)} - + {permissions.canDeleteFiles && ( + <> + + + {intl.formatMessage(messages.deleteTitle)} + + + )} ); @@ -85,6 +95,10 @@ FileMenu.propTypes = { portableUrl: PropTypes.string, id: PropTypes.string.isRequired, fileType: PropTypes.string.isRequired, + permissions: PropTypes.shape({ + canEditFiles: PropTypes.bool, + canDeleteFiles: PropTypes.bool, + }), }; FileMenu.defaultProps = { diff --git a/src/files-and-videos/generic/FileMenu.test.jsx b/src/files-and-videos/generic/FileMenu.test.jsx new file mode 100644 index 0000000000..58070cb119 --- /dev/null +++ b/src/files-and-videos/generic/FileMenu.test.jsx @@ -0,0 +1,87 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import FileMenu from './FileMenu'; + +const mockHandlers = { + handleLock: jest.fn(), + onDownload: jest.fn(), + openAssetInfo: jest.fn(), + openDeleteConfirmation: jest.fn(), +}; + +const defaultProps = { + id: 'test-file-id', + externalUrl: 'https://example.com/test-file.png', + portableUrl: '/static/test-file.png', + locked: false, + fileType: 'file', + ...mockHandlers, +}; + +const renderComponent = (props = {}) => + render( + + + , + ); + +describe('FileMenu', () => { + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: jest.fn() }, + writable: true, + configurable: true, + }); + }); + + it('renders the menu toggle button', () => { + renderComponent(); + expect(screen.getByRole('button', { name: 'file-menu-toggle' })).toBeInTheDocument(); + }); + + it('opens dropdown menu when toggle is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + + const toggleButton = screen.getByRole('button', { name: 'file-menu-toggle' }); + await user.click(toggleButton); + + expect(screen.getByText('Download')).toBeInTheDocument(); + expect(screen.getByText('Info')).toBeInTheDocument(); + }); + + describe('Lock/Unlock visibility based on canEditFiles permission', () => { + it('shows Lock option when canEditFiles is true', async () => { + const user = userEvent.setup(); + renderComponent({ permissions: { canEditFiles: true, canDeleteFiles: true } }); + + const toggleButton = screen.getByRole('button', { name: 'file-menu-toggle' }); + await user.click(toggleButton); + + expect(screen.getByText('Lock')).toBeInTheDocument(); + }); + + it('hides Lock option when canEditFiles is false', async () => { + const user = userEvent.setup(); + renderComponent({ permissions: { canEditFiles: false, canDeleteFiles: true } }); + + const toggleButton = screen.getByRole('button', { name: 'file-menu-toggle' }); + await user.click(toggleButton); + + expect(screen.queryByText('Lock')).not.toBeInTheDocument(); + expect(screen.queryByText('Unlock')).not.toBeInTheDocument(); + }); + + it('shows Unlock option when file is locked and canEditFiles is true', async () => { + const user = userEvent.setup(); + renderComponent({ locked: true, permissions: { canEditFiles: true, canDeleteFiles: true } }); + + const toggleButton = screen.getByRole('button', { name: 'file-menu-toggle' }); + await user.click(toggleButton); + + expect(screen.getByText('Unlock')).toBeInTheDocument(); + }); + }); +}); diff --git a/src/files-and-videos/generic/FileTable.jsx b/src/files-and-videos/generic/FileTable.jsx index 0dd632aacb..98e794ff7a 100644 --- a/src/files-and-videos/generic/FileTable.jsx +++ b/src/files-and-videos/generic/FileTable.jsx @@ -42,6 +42,11 @@ const FileTable = ({ maxFileSize, thumbnailPreview, infoModalSidebar, + permissions = { + canCreateFiles: true, + canDeleteFiles: true, + canEditFiles: true, + }, }) => { const intl = useIntl(); const pageCount = Math.ceil(files.length / 50); @@ -153,6 +158,10 @@ const FileTable = ({ fileType, setInitialState, }} + permissions={{ + canCreateFiles: permissions.canCreateFiles, + canDeleteFiles: permissions.canDeleteFiles, + }} /> ); @@ -167,6 +176,10 @@ const FileTable = ({ className, original, fileType, + permissions: { + canEditFiles: permissions.canEditFiles, + canDeleteFiles: permissions.canDeleteFiles, + }, }} /> ); @@ -182,6 +195,10 @@ const FileTable = ({ handleOpenFileInfo, handleOpenDeleteConfirmation, fileType, + permissions: { + canEditFiles: permissions.canEditFiles, + canDeleteFiles: permissions.canDeleteFiles, + }, }), }; @@ -224,7 +241,7 @@ const FileTable = ({ FilterStatusComponent={FilterStatus} RowStatusComponent={RowStatus} > - {isEmpty(files) && loadingStatus !== RequestStatus.IN_PROGRESS ? + {permissions.canCreateFiles && isEmpty(files) && loadingStatus !== RequestStatus.IN_PROGRESS ? ( { const lockFile = () => { const { locked, id } = original; @@ -49,6 +53,7 @@ const GalleryCard = ({ }, }])} openDeleteConfirmation={() => handleOpenDeleteConfirmation([{ original }])} + permissions={permissions} /> } @@ -105,6 +110,10 @@ GalleryCard.propTypes = { handleOpenFileInfo: PropTypes.func.isRequired, thumbnailPreview: PropTypes.func.isRequired, fileType: PropTypes.string.isRequired, + permissions: PropTypes.shape({ + canEditFiles: PropTypes.bool, + canDeleteFiles: PropTypes.bool, + }), }; export default GalleryCard; diff --git a/src/files-and-videos/generic/table-components/GalleryCard.test.jsx b/src/files-and-videos/generic/table-components/GalleryCard.test.jsx new file mode 100644 index 0000000000..a16b68d065 --- /dev/null +++ b/src/files-and-videos/generic/table-components/GalleryCard.test.jsx @@ -0,0 +1,182 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import GalleryCard from './GalleryCard'; + +const mockOriginal = { + id: 'test-file-id', + displayName: 'test-file.png', + wrapperType: 'image', + externalUrl: 'https://example.com/test-file.png', + portableUrl: '/static/test-file.png', + locked: false, + thumbnail: 'https://example.com/thumbnail.png', + status: 'active', + transcripts: [], + downloadLink: 'https://example.com/download/test-file.png', +}; + +const mockHandlers = { + handleBulkDownload: jest.fn(), + handleLockFile: jest.fn(), + handleOpenDeleteConfirmation: jest.fn(), + handleOpenFileInfo: jest.fn(), + thumbnailPreview: jest.fn(() =>
), +}; + +const renderComponent = (props = {}) => + render( + + + , + ); + +describe('GalleryCard', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders the card with file display name', () => { + renderComponent(); + expect(screen.getByText('test-file.png')).toBeInTheDocument(); + }); + + it('renders the card with file type chip', () => { + renderComponent(); + expect(screen.getByText('image')).toBeInTheDocument(); + }); + + it('renders with custom className', () => { + const { container } = renderComponent({ className: 'custom-class' }); + expect(container.querySelector('.custom-class')).toBeInTheDocument(); + }); + + it('renders closed caption icon when transcripts are present', () => { + renderComponent({ + original: { ...mockOriginal, transcripts: ['en', 'es'] }, + }); + // The card should be present when transcripts exist + expect(screen.getByText('test-file.png')).toBeInTheDocument(); + }); + + describe('permissions', () => { + it('passes permissions to FileMenu with default values when not provided', async () => { + const user = userEvent.setup(); + renderComponent(); + + // The FileMenu should be rendered with default permissions + const menuButton = screen.getByRole('button', { name: 'file-menu-toggle' }); + expect(menuButton).toBeInTheDocument(); + + // Open the menu and check delete option is visible (default permission) + await user.click(menuButton); + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); + + it('passes permissions to FileMenu - delete visible when canDeleteFiles is true', async () => { + const user = userEvent.setup(); + renderComponent({ + permissions: { canEditFiles: true, canDeleteFiles: true }, + }); + + const menuButton = screen.getByRole('button', { name: 'file-menu-toggle' }); + await user.click(menuButton); + + expect(screen.getByText('Delete')).toBeInTheDocument(); + }); + + it('passes permissions to FileMenu - delete hidden when canDeleteFiles is false', async () => { + const user = userEvent.setup(); + renderComponent({ + permissions: { canEditFiles: true, canDeleteFiles: false }, + }); + + const menuButton = screen.getByRole('button', { name: 'file-menu-toggle' }); + await user.click(menuButton); + + expect(screen.queryByText('Delete')).not.toBeInTheDocument(); + }); + }); + + describe('menu actions', () => { + it('calls handleLockFile when lock is triggered from menu', async () => { + const user = userEvent.setup(); + renderComponent(); + + const menuButton = screen.getByRole('button', { name: 'file-menu-toggle' }); + await user.click(menuButton); + + const lockButton = screen.getByText('Lock'); + await user.click(lockButton); + + expect(mockHandlers.handleLockFile).toHaveBeenCalledWith('test-file-id', true); + }); + + it('calls handleLockFile with false when file is already locked', async () => { + const user = userEvent.setup(); + renderComponent({ + original: { ...mockOriginal, locked: true }, + }); + + const menuButton = screen.getByRole('button', { name: 'file-menu-toggle' }); + await user.click(menuButton); + + const unlockButton = screen.getByText('Unlock'); + await user.click(unlockButton); + + expect(mockHandlers.handleLockFile).toHaveBeenCalledWith('test-file-id', false); + }); + + it('calls handleOpenFileInfo when info is triggered from menu', async () => { + const user = userEvent.setup(); + renderComponent(); + + const menuButton = screen.getByRole('button', { name: 'file-menu-toggle' }); + await user.click(menuButton); + + const infoButton = screen.getByText('Info'); + await user.click(infoButton); + + expect(mockHandlers.handleOpenFileInfo).toHaveBeenCalledWith(mockOriginal); + }); + + it('calls handleBulkDownload when download is triggered from menu', async () => { + const user = userEvent.setup(); + renderComponent(); + + const menuButton = screen.getByRole('button', { name: 'file-menu-toggle' }); + await user.click(menuButton); + + const downloadButton = screen.getByText('Download'); + await user.click(downloadButton); + + expect(mockHandlers.handleBulkDownload).toHaveBeenCalledWith([{ + original: { + id: 'test-file-id', + displayName: 'test-file.png', + downloadLink: 'https://example.com/download/test-file.png', + }, + }]); + }); + + it('calls handleOpenDeleteConfirmation when delete is triggered from menu', async () => { + const user = userEvent.setup(); + renderComponent({ + permissions: { canEditFiles: true, canDeleteFiles: true }, + }); + + const menuButton = screen.getByRole('button', { name: 'file-menu-toggle' }); + await user.click(menuButton); + + const deleteButton = screen.getByText('Delete'); + await user.click(deleteButton); + + expect(mockHandlers.handleOpenDeleteConfirmation).toHaveBeenCalledWith([{ original: mockOriginal }]); + }); + }); +}); diff --git a/src/files-and-videos/generic/table-components/TableActions.jsx b/src/files-and-videos/generic/table-components/TableActions.jsx index 188de71315..809a2871c3 100644 --- a/src/files-and-videos/generic/table-components/TableActions.jsx +++ b/src/files-and-videos/generic/table-components/TableActions.jsx @@ -22,6 +22,10 @@ const TableActions = ({ encodingsDownloadUrl, fileType, setInitialState, + permissions = { + canCreateFiles: true, + canDeleteFiles: true, + }, }) => { const intl = useIntl(); const [isSortOpen, openSort, closeSort] = useToggle(false); @@ -67,19 +71,26 @@ const TableActions = ({ > - - handleOpenDeleteConfirmation(selectedFlatRows)} - disabled={isEmpty(selectedFlatRows)} - > - - + {permissions.canDeleteFiles + && ( + <> + + handleOpenDeleteConfirmation(selectedFlatRows)} + disabled={isEmpty(selectedFlatRows)} + > + + + + )} - + {permissions.canCreateFiles && ( + + )} ); @@ -111,6 +122,10 @@ TableActions.propTypes = { handleSort: PropTypes.func.isRequired, fileType: PropTypes.string.isRequired, setInitialState: PropTypes.func.isRequired, + permissions: PropTypes.shape({ + canCreateFiles: PropTypes.bool, + canDeleteFiles: PropTypes.bool, + }), }; TableActions.defaultProps = { diff --git a/src/files-and-videos/generic/table-components/TableActions.test.jsx b/src/files-and-videos/generic/table-components/TableActions.test.jsx index 178aa96285..57d4885075 100644 --- a/src/files-and-videos/generic/table-components/TableActions.test.jsx +++ b/src/files-and-videos/generic/table-components/TableActions.test.jsx @@ -1,5 +1,6 @@ import React from 'react'; import { screen, fireEvent } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; import { DataTableContext } from '@openedx/paragon'; import { initializeMocks, render } from '../../../testUtils'; import TableActions from './TableActions'; @@ -139,4 +140,48 @@ describe('TableActions', () => { expect.stringContaining(encodingsDownloadUrl), ); }); + + test('does not render delete menu item when canDeleteFiles permission is false', async () => { + const user = userEvent.setup(); + const permissions = { + canCreateFiles: true, + canDeleteFiles: false, + }; + + renderWithContext({ + permissions, + selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }], + }); + + await user.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + + expect(screen.queryByText(messages.deleteTitle.defaultMessage)).not.toBeInTheDocument(); + expect(screen.getByText(messages.downloadTitle.defaultMessage)).toBeInTheDocument(); + }); + + test('does not render create button when canEditFiles permission is false', () => { + const permissions = { + canCreateFiles: true, + canDeleteFiles: false, + }; + + renderWithContext({ + permissions, + selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }], + }); + + expect(screen.getByRole('button', { name: /Add videos/ })).toBeInTheDocument(); + }); + + test('renders add videos button and delete menu item with permissions defaults', async () => { + const user = userEvent.setup(); + renderWithContext({ + selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }], + }); + + expect(screen.getByRole('button', { name: /Add videos/ })).toBeInTheDocument(); + await user.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })); + + expect(screen.queryByText(messages.deleteTitle.defaultMessage)).toBeInTheDocument(); + }); }); diff --git a/src/files-and-videos/generic/table-components/table-custom-columns/MoreInfoColumn.jsx b/src/files-and-videos/generic/table-components/table-custom-columns/MoreInfoColumn.jsx index 1c5206af70..4122ac573b 100644 --- a/src/files-and-videos/generic/table-components/table-custom-columns/MoreInfoColumn.jsx +++ b/src/files-and-videos/generic/table-components/table-custom-columns/MoreInfoColumn.jsx @@ -21,6 +21,10 @@ const MoreInfoColumn = ({ handleOpenFileInfo, handleOpenDeleteConfirmation, fileType, + permissions = { + canEditFiles: true, + canDeleteFiles: true, + }, }) => { const intl = useIntl(); const [isOpen, , close, toggle] = useToggle(); @@ -89,13 +93,15 @@ const MoreInfoColumn = ({ > {intl.formatMessage(messages.copyWebUrlTitle)} - handleLock(id, !locked)} - > - {locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)} - + {permissions.canEditFiles && ( + handleLock(id, !locked)} + > + {locked ? intl.formatMessage(messages.unlockMenuTitle) : intl.formatMessage(messages.lockMenuTitle)} + + )} )} {intl.formatMessage(messages.infoTitle)} -
- { - handleOpenDeleteConfirmation([{ original: row.original }]); - close(); - }} - > - {intl.formatMessage(messages.deleteTitle)} - + + {permissions.canDeleteFiles && ( + <> +
+ { + handleOpenDeleteConfirmation([{ original: row.original }]); + close(); + }} + > + {intl.formatMessage(messages.deleteTitle)} + + + )} @@ -147,6 +158,10 @@ MoreInfoColumn.propTypes = { handleOpenFileInfo: PropTypes.func.isRequired, handleOpenDeleteConfirmation: PropTypes.func.isRequired, fileType: PropTypes.string.isRequired, + permissions: PropTypes.shape({ + canEditFiles: PropTypes.bool, + canDeleteFiles: PropTypes.bool, + }), }; MoreInfoColumn.defaultProps = { diff --git a/src/files-and-videos/generic/table-components/table-custom-columns/MoreInfoColumn.test.jsx b/src/files-and-videos/generic/table-components/table-custom-columns/MoreInfoColumn.test.jsx new file mode 100644 index 0000000000..e69228add5 --- /dev/null +++ b/src/files-and-videos/generic/table-components/table-custom-columns/MoreInfoColumn.test.jsx @@ -0,0 +1,149 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import MoreInfoColumn from './MoreInfoColumn'; + +const mockRow = { + original: { + id: 'test-file-id', + displayName: 'test-file.png', + externalUrl: 'https://example.com/test-file.png', + portableUrl: '/static/test-file.png', + locked: false, + downloadLink: 'https://example.com/download/test-file.png', + }, +}; + +const mockHandlers = { + handleLock: jest.fn(), + handleBulkDownload: jest.fn(), + handleOpenFileInfo: jest.fn(), + handleOpenDeleteConfirmation: jest.fn(), +}; + +const renderComponent = (props = {}) => + render( + + + , + ); + +describe('MoreInfoColumn', () => { + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(navigator, 'clipboard', { + value: { writeText: jest.fn() }, + writable: true, + configurable: true, + }); + }); + + it('renders the more info icon button', () => { + renderComponent(); + expect(screen.getByRole('button', { name: /more info/i })).toBeInTheDocument(); + }); + + it('opens menu when icon button is clicked', async () => { + const user = userEvent.setup(); + renderComponent(); + + const iconButton = screen.getByRole('button', { name: /more info/i }); + await user.click(iconButton); + + expect(screen.getByText('Copy Studio Url')).toBeInTheDocument(); + expect(screen.getByText('Copy Web Url')).toBeInTheDocument(); + expect(screen.getByText('Download')).toBeInTheDocument(); + expect(screen.getByText('Info')).toBeInTheDocument(); + }); + + describe('Lock/Unlock visibility based on canEditFiles permission', () => { + it('shows Lock option when canEditFiles is true', async () => { + const user = userEvent.setup(); + renderComponent({ permissions: { canEditFiles: true, canDeleteFiles: true } }); + + const iconButton = screen.getByRole('button', { name: /more info/i }); + await user.click(iconButton); + + expect(screen.getByText('Lock')).toBeInTheDocument(); + }); + + it('hides Lock option when canEditFiles is false', async () => { + const user = userEvent.setup(); + renderComponent({ permissions: { canEditFiles: false, canDeleteFiles: true } }); + + const iconButton = screen.getByRole('button', { name: /more info/i }); + await user.click(iconButton); + + expect(screen.queryByText('Lock')).not.toBeInTheDocument(); + expect(screen.queryByText('Unlock')).not.toBeInTheDocument(); + }); + + it('shows Unlock option when file is locked and canEditFiles is true', async () => { + const user = userEvent.setup(); + const lockedRow = { + ...mockRow, + original: { ...mockRow.original, locked: true }, + }; + renderComponent({ row: lockedRow, permissions: { canEditFiles: true, canDeleteFiles: true } }); + + const iconButton = screen.getByRole('button', { name: /more info/i }); + await user.click(iconButton); + + expect(screen.getByText('Unlock')).toBeInTheDocument(); + }); + + it('calls handleLock with correct arguments when Lock is clicked', async () => { + const user = userEvent.setup(); + renderComponent({ permissions: { canEditFiles: true, canDeleteFiles: true } }); + + const iconButton = screen.getByRole('button', { name: /more info/i }); + await user.click(iconButton); + await user.click(screen.getByText('Lock')); + + expect(mockHandlers.handleLock).toHaveBeenCalledWith('test-file-id', true); + }); + + it('calls handleLock to unlock when file is locked', async () => { + const user = userEvent.setup(); + const lockedRow = { + ...mockRow, + original: { ...mockRow.original, locked: true }, + }; + renderComponent({ row: lockedRow, permissions: { canEditFiles: true, canDeleteFiles: true } }); + + const iconButton = screen.getByRole('button', { name: /more info/i }); + await user.click(iconButton); + await user.click(screen.getByText('Unlock')); + + expect(mockHandlers.handleLock).toHaveBeenCalledWith('test-file-id', false); + }); + }); + + describe('Delete button based on canDeleteFiles permission', () => { + it('calls handleOpenDeleteConfirmation and closes menu when Delete is clicked', async () => { + const user = userEvent.setup(); + renderComponent({ permissions: { canEditFiles: true, canDeleteFiles: true } }); + + const iconButton = screen.getByRole('button', { name: /more info/i }); + await user.click(iconButton); + await user.click(screen.getByTestId('open-delete-confirmation-button')); + + expect(mockHandlers.handleOpenDeleteConfirmation).toHaveBeenCalledWith([{ original: mockRow.original }]); + }); + + it('hides Delete option when canDeleteFiles is false', async () => { + const user = userEvent.setup(); + renderComponent({ permissions: { canEditFiles: true, canDeleteFiles: false } }); + + const iconButton = screen.getByRole('button', { name: /more info/i }); + await user.click(iconButton); + + expect(screen.queryByTestId('open-delete-confirmation-button')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/header/hooks.test.tsx b/src/header/hooks.test.tsx index acd2ced039..59a39b162e 100644 --- a/src/header/hooks.test.tsx +++ b/src/header/hooks.test.tsx @@ -22,11 +22,7 @@ jest.mock('@edx/frontend-platform/i18n', () => ({ })); // Bypass React Query for waffle flags, and just return the default values. -mockWaffleFlags({ - // Some flags can be enabled with either a config value or a waffle flag. - // For test purposes, we'll configure the video upload page using the config, so leave the waffle flag off. - useNewVideoUploadsPage: false, -}); +mockWaffleFlags({}); jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -63,6 +59,7 @@ describe('header utils', () => { canViewPagesAndResources: true, canManagePagesAndResources: true, canViewCourseUpdates: true, + canViewFiles: true, } as ReturnType); }); @@ -72,6 +69,7 @@ describe('header utils', () => { isLoading: false, canViewCourseUpdates: true, canViewPagesAndResources: true, + canViewFiles: true, } as any); jest.mocked(useSelector).mockReturnValue({ librariesV2Enabled: false, @@ -93,6 +91,7 @@ describe('header utils', () => { isLoading: false, canViewCourseUpdates: true, canViewPagesAndResources: true, + canViewFiles: true, } as any); jest.mocked(useSelector).mockReturnValue({ librariesV2Enabled: false, @@ -105,6 +104,48 @@ describe('header utils', () => { renderHook(() => useContentMenuItems('course-123'), { wrapper: createWrapper() }).result.current; expect(actualItems).toHaveLength(4); }); + it('when authz enabled and user has no permission to view files should not include files option', () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + canViewFiles: false, + } as any); + jest.mocked(useSelector).mockReturnValue({ + librariesV2Enabled: false, + }); + const actualItems = + renderHook(() => useContentMenuItems('course-123'), { wrapper: createWrapper() }).result.current; + const actualItemsTitle = actualItems.map((item) => item.title); + expect(actualItemsTitle).not.toContain(messages['header.links.filesAndUploads'].defaultMessage); + }); + it('when authz enabled and user has permission to view files should include files option', () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + canViewFiles: true, + } as any); + jest.mocked(useSelector).mockReturnValue({ + librariesV2Enabled: false, + }); + const actualItems = + renderHook(() => useContentMenuItems('course-123'), { wrapper: createWrapper() }).result.current; + const actualItemsTitle = actualItems.map((item) => item.title); + expect(actualItemsTitle).toContain(messages['header.links.filesAndUploads'].defaultMessage); + }); + it('when authz disabled user should view files option', () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + canViewFiles: true, + } as any); + jest.mocked(useSelector).mockReturnValue({ + librariesV2Enabled: false, + }); + const actualItems = + renderHook(() => useContentMenuItems('course-123'), { wrapper: createWrapper() }).result.current; + const actualItemsTitle = actualItems.map((item) => item.title); + expect(actualItemsTitle).toContain(messages['header.links.filesAndUploads'].defaultMessage); + }); it('adds course libraries link to content menu when libraries v2 is enabled', () => { mockWaffleFlags({ enableAuthzCourseAuthoring: false }); jest.mocked(useCourseUserPermissions).mockReturnValue({ @@ -222,15 +263,6 @@ describe('header utils', () => { renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() }).result.current; expect(actualItems).toHaveLength(6); }); - it('when certificate page disabled should not include certificates option', () => { - setConfig({ - ...getConfig(), - ENABLE_CERTIFICATE_PAGE: 'false', - }); - const actualItems = - renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() }).result.current; - expect(actualItems).toHaveLength(5); - }); it('when user has access to advanced settings should include advanced settings option', () => { const actualItemsTitle = renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() }).result .current.map((item) => item.title); @@ -258,6 +290,44 @@ describe('header utils', () => { expect(actualItemsTitle).toContain('Advanced Settings'); }); }); + + it('should include course team option when authz is disabled', () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: false }); + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + canManageAdvancedSettings: true, + } as any); + const actualItems = + renderHook(() => useSettingMenuItems('course-123'), { wrapper: createWrapper() }).result.current; + const courseTeamItem = actualItems.find(item => item.title === 'Course Team'); + expect(courseTeamItem).toEqual({ + href: '/course/course-123/course_team', + title: 'Course Team', + }); + const rolesPermissionsItem = actualItems.find(item => item.title === 'Roles and Permissions'); + expect(rolesPermissionsItem).toBeUndefined(); + }); + + it('should encode courseId in roles and permissions URL', () => { + mockWaffleFlags({ enableAuthzCourseAuthoring: true }); + setConfig({ + ...getConfig(), + ADMIN_CONSOLE_URL: 'http://admin-console.example.com', + }); + jest.mocked(useCourseUserPermissions).mockReturnValue({ + isLoading: false, + isAuthzEnabled: true, + canManageAdvancedSettings: true, + } as any); + const courseIdWithSpecialChars = 'course-v1:org+course+run'; + const actualItems = + renderHook(() => useSettingMenuItems(courseIdWithSpecialChars), { wrapper: createWrapper() }).result.current; + const rolesPermissionsItem = actualItems.find(item => item.title === 'Roles and Permissions'); + expect(rolesPermissionsItem?.href).toBe( + `http://admin-console.example.com/authz?scope=${encodeURIComponent(courseIdWithSpecialChars)}`, + ); + }); + it('when authz.enable_course_authoring flag is enabled and user has no access to advanced settings should not include advanced settings option', async () => { mockWaffleFlags({ enableAuthzCourseAuthoring: true }); jest.mocked(useCourseUserPermissions).mockReturnValue({ diff --git a/src/header/hooks.tsx b/src/header/hooks.tsx index 7fcd828869..fd67ce1e08 100644 --- a/src/header/hooks.tsx +++ b/src/header/hooks.tsx @@ -16,6 +16,7 @@ import { getGradingPermissions, getPagesAndResourcesPermissions, getScheduleAndDetailsPermissions, + getFilesPermissions, } from '@src/authz/permissionHelpers'; import messages from './messages'; import { getCourseUpdatesPermissions } from '@src/authz/permissionHelpers'; @@ -26,11 +27,12 @@ export const useContentMenuItems = (courseId: string) => { const waffleFlags = useWaffleFlags(courseId); const { librariesV2Enabled } = useSelector(getStudioHomeData); - const { canViewCourseUpdates, canViewPagesAndResources } = useCourseUserPermissions( + const { canViewCourseUpdates, canViewPagesAndResources, canViewFiles } = useCourseUserPermissions( courseId, { ...getPagesAndResourcesPermissions(courseId), ...getCourseUpdatesPermissions(courseId), + ...getFilesPermissions(courseId), }, ); @@ -53,10 +55,12 @@ export const useContentMenuItems = (courseId: string) => { title: intl.formatMessage(messages['header.links.pages']), }] : []), - { - href: waffleFlags.useNewFilesUploadsPage ? `/course/${courseId}/assets` : `${studioBaseUrl}/assets/${courseId}`, - title: intl.formatMessage(messages['header.links.filesAndUploads']), - }, + ...(canViewFiles + ? [{ + href: waffleFlags.useNewFilesUploadsPage ? `/course/${courseId}/assets` : `${studioBaseUrl}/assets/${courseId}`, + title: intl.formatMessage(messages['header.links.filesAndUploads']), + }] : + []), ]; if (getConfig().ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN === 'true' || waffleFlags.useNewVideoUploadsPage) { items.push({