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)}
-
+ {permissions.canEditFiles && (
+
+ )}
>
)}
-
-
+
+ {permissions.canDeleteFiles && (
+ <>
+
+
+ >
+ )}
>
@@ -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({