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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 7 additions & 4 deletions plugins/course-apps/proctoring/Settings.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -470,10 +470,13 @@ describe('ProctoredExamSettings', () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
// (1) for studio settings
// (2) for course details
expect(axiosMock.history.get.length).toBe(2);
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
// With no exam service URL configured, no request should be made to the exams
// service for provider options. (Total GET count is not asserted because the
// CourseAuthoringProvider also fetches course details and waffle flags.)
const examProvidersRequested = axiosMock.history.get.some((req) => req.url.includes('/api/v1/providers'));
expect(examProvidersRequested).toBe(false);
const studioSettingsRequested = axiosMock.history.get.some((req) => req.url.includes('proctored_exam_settings'));
expect(studioSettingsRequested).toBe(true);
});

it('Selected LTI proctoring provider is shown on page load', async () => {
Expand Down
19 changes: 19 additions & 0 deletions src/CourseAuthoringContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ import { type UnitXBlock, type XBlock } from '@src/data/types';
import { CourseDetailsData } from './data/api';
import { useCourseDetails } from './data/apiHooks';
import { RequestStatusType } from './data/constants';
import { useCourseUserPermissions } from '@src/authz/hooks';
import { getCourseOutlinePermissions } from '@src/authz/permissionHelpers';

export type ModalState = {
value?: XBlock | UnitXBlock;
Expand All @@ -29,6 +31,9 @@ export type CourseAuthoringContextData = {
currentUnlinkModalData?: ModalState;
openUnlinkModal: (value: ModalState) => void;
closeUnlinkModal: () => void;
isLoading: boolean;
canEditCourseContent: boolean;
canPublishCourseContent: boolean;
};

/**
Expand Down Expand Up @@ -58,6 +63,14 @@ export const CourseAuthoringProvider = ({
closeUnlinkModal,
] = useToggleWithValue<ModalState>();

const {
canEditCourseContent,
canPublishCourseContent,
isLoading: isUserPermissionsLoading,
} = useCourseUserPermissions(courseId, getCourseOutlinePermissions(courseId));

const isLoading = isUserPermissionsLoading;

const getUnitUrl = (locator: string) => `/course/${courseId}/container/${locator}`;

/**
Expand All @@ -78,6 +91,9 @@ export const CourseAuthoringProvider = ({
openUnlinkModal,
closeUnlinkModal,
currentUnlinkModalData,
isLoading,
canEditCourseContent,
canPublishCourseContent,
}), [
courseId,
courseDetails,
Expand All @@ -89,6 +105,9 @@ export const CourseAuthoringProvider = ({
openUnlinkModal,
closeUnlinkModal,
currentUnlinkModalData,
isLoading,
canEditCourseContent,
canPublishCourseContent,
]);

return (
Expand Down
2 changes: 2 additions & 0 deletions src/authz/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ export const CONTENT_LIBRARY_PERMISSIONS = {

export const COURSE_PERMISSIONS = {
VIEW_COURSE: 'courses.view_course',
CREATE_COURSE: 'courses.create_course',
EDIT_COURSE_CONTENT: 'courses.edit_course_content',
PUBLISH_COURSE_CONTENT: 'courses.publish_course_content',

MANAGE_ADVANCED_SETTINGS: 'courses.manage_advanced_settings',

Expand Down
4 changes: 4 additions & 0 deletions src/authz/permissionHelpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,6 +164,10 @@ describe('permissionHelpers', () => {
action: COURSE_PERMISSIONS.EDIT_COURSE_CONTENT,
scope: courseId,
},
canPublishCourseContent: {
action: COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT,
scope: courseId,
},
});
});
});
Expand Down
4 changes: 4 additions & 0 deletions src/authz/permissionHelpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ export const getCourseOutlinePermissions = (courseId: string) => ({
action: COURSE_PERMISSIONS.EDIT_COURSE_CONTENT,
scope: courseId,
},
canPublishCourseContent: {
action: COURSE_PERMISSIONS.PUBLISH_COURSE_CONTENT,
scope: courseId,
},
});

export const getLibraryUpdatesPermissions = (courseId: string) => ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,8 @@ jest.mock('@src/CourseAuthoringContext', () => ({
courseId,
openUnlinkModal,
getUnitUrl: jest.fn(),
canEditCourseContent: true,
canPublishCourseContent: true,
}),
}));

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,9 @@ import {
import userEvent from '@testing-library/user-event';
import { useCourseItemData } from '@src/course-outline/data/apiHooks';
import { VisibilityTypes } from '@src/data/constants';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import { VisibilitySection } from './VisibilitySection';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';

jest.mock('@src/course-outline/data/apiHooks', () => ({
...jest.requireActual('@src/course-outline/data/apiHooks'),
Expand All @@ -22,14 +24,30 @@ const defaultProps = {
onChange: jest.fn(),
};

const WrapperProvider = ({ children }) => (
<CourseAuthoringProvider courseId={'courseId'}>{children}</CourseAuthoringProvider>
);
const renderWithWrapper = (children) => {
render(children, {
extraWrapper: WrapperProvider,
});
};

let validateUserPermissionsMock;

describe('VisibilitySection component', () => {
beforeEach(() => {
initializeMocks();
const mocks = initializeMocks();
mockUseCourseItemData.mockReturnValue({ data: undefined });
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
validateUserPermissionsMock = mocks.validateUserPermissionsMock;
validateUserPermissionsMock.mockResolvedValue({
canEditCourseContent: true,
});
});

it('renders title and buttons', async () => {
render(<VisibilitySection {...defaultProps} />);
renderWithWrapper(<VisibilitySection {...defaultProps} />);
expect(await screen.findByText('Visibility')).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Student Visible' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Staff Only' })).toBeInTheDocument();
Expand All @@ -38,7 +56,7 @@ describe('VisibilitySection component', () => {
it('clicking staff only calls onChange with staff and hideAfterDue false', async () => {
const user = userEvent.setup();
const onChange = jest.fn();
render(<VisibilitySection {...defaultProps} onChange={onChange} />);
renderWithWrapper(<VisibilitySection {...defaultProps} onChange={onChange} />);

await user.click(await screen.findByRole('button', { name: 'Staff Only' }));
await waitFor(async () => {
Expand All @@ -50,7 +68,7 @@ describe('VisibilitySection component', () => {
const user = userEvent.setup();
const onChange = jest.fn();
mockUseCourseItemData.mockReturnValue({ data: { visibilityState: VisibilityTypes.STAFF_ONLY } });
render(<VisibilitySection {...defaultProps} onChange={onChange} />);
renderWithWrapper(<VisibilitySection {...defaultProps} onChange={onChange} />);

await user.click(await screen.findByRole('button', { name: 'Student Visible' }));
await waitFor(async () => {
Expand All @@ -63,7 +81,7 @@ describe('VisibilitySection component', () => {
const onChange = jest.fn();
// initial data not staff only
mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: false } });
render(<VisibilitySection {...defaultProps} onChange={onChange} />);
renderWithWrapper(<VisibilitySection {...defaultProps} onChange={onChange} />);

const checkbox = await screen.findByRole('checkbox');
await user.click(checkbox);
Expand All @@ -76,7 +94,7 @@ describe('VisibilitySection component', () => {
const user = userEvent.setup();
const onChange = jest.fn();
mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: true } });
render(<VisibilitySection {...defaultProps} isSubsection={false} onChange={onChange} />);
renderWithWrapper(<VisibilitySection {...defaultProps} isSubsection={false} onChange={onChange} />);

await user.click(await screen.findByRole('button', { name: 'Staff Only' }));
await waitFor(async () => {
Expand All @@ -88,7 +106,27 @@ describe('VisibilitySection component', () => {
const onChange = jest.fn();
// when item is staff only, checkbox should not be present
mockUseCourseItemData.mockReturnValue({ data: { visibilityState: VisibilityTypes.STAFF_ONLY } });
render(<VisibilitySection {...defaultProps} onChange={onChange} />);
renderWithWrapper(<VisibilitySection {...defaultProps} onChange={onChange} />);
expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
});

it('disables both visibility buttons when the user cannot edit course content', async () => {
validateUserPermissionsMock.mockResolvedValue({
canEditCourseContent: false,
});
renderWithWrapper(<VisibilitySection {...defaultProps} />);

expect(await screen.findByRole('button', { name: 'Student Visible' })).toBeDisabled();
expect(await screen.findByRole('button', { name: 'Staff Only' })).toBeDisabled();
});

it('disables the hide-after-due checkbox when the user cannot edit course content', async () => {
validateUserPermissionsMock.mockResolvedValue({
canEditCourseContent: false,
});
mockUseCourseItemData.mockReturnValue({ data: { visibilityState: undefined, hideAfterDue: false } });
renderWithWrapper(<VisibilitySection {...defaultProps} />);

expect(await screen.findByRole('checkbox')).toBeDisabled();
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { SidebarSection } from '@src/generic/sidebar';
import { useFieldDraft } from '@src/hooks/useFieldDraft';
import { useMemo } from 'react';
import messages from '../messages';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';

interface Props<T = Partial<ConfigureSubsectionData>> {
itemId: string;
Expand All @@ -22,6 +23,7 @@ interface State {
export const VisibilitySection = ({ itemId, isSubsection, onChange }: Props) => {
const intl = useIntl();
const { data: itemData } = useCourseItemData(itemId);
const { canEditCourseContent } = useCourseAuthoringContext();

const serverState = useMemo<State>(() => ({
isVisibleToStaffOnly: itemData?.visibilityState === VisibilityTypes.STAFF_ONLY,
Expand All @@ -42,12 +44,14 @@ export const VisibilitySection = ({ itemId, isSubsection, onChange }: Props) =>
>
<ButtonGroup toggle>
<Button
disabled={!canEditCourseContent}
variant={localState?.isVisibleToStaffOnly ? 'outline-primary' : 'primary'}
onClick={() => setLocalState((prev) => ({ ...prev, isVisibleToStaffOnly: false }))}
>
<FormattedMessage {...messages.subsectionVisibilityStudentVisible} />
</Button>
<Button
disabled={!canEditCourseContent}
variant={localState?.isVisibleToStaffOnly ? 'primary' : 'outline-primary'}
onClick={() =>
setLocalState((prev) => ({
Expand All @@ -61,6 +65,7 @@ export const VisibilitySection = ({ itemId, isSubsection, onChange }: Props) =>
</ButtonGroup>
{isSubsection && !localState?.isVisibleToStaffOnly && (
<Form.Checkbox
disabled={!canEditCourseContent}
checked={localState?.hideAfterDue}
className="mt-2"
onChange={(e: React.ChangeEvent<HTMLInputElement>) =>
Expand Down
21 changes: 13 additions & 8 deletions src/course-outline/status-bar/StatusBar.test.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import { VIDEO_SHARING_OPTIONS } from '@src/course-outline/constants';
import { CourseOutlineStatusBar } from '@src/course-outline/data/types';
import { initializeMocks, render, screen } from '@src/testUtils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import { StatusBar, StatusBarProps } from './StatusBar';
import messages from './messages';

Expand Down Expand Up @@ -41,19 +43,22 @@ const mockHandleVideoSharingOptionChange = jest.fn();

const renderComponent = (props?: Partial<StatusBarProps>) =>
render(
<StatusBar
courseId={courseId}
isLoading={isLoading}
statusBarData={statusBarData}
openEnableHighlightsModal={mockOpenEnableHighlightsModal}
handleVideoSharingOptionChange={mockHandleVideoSharingOptionChange}
{...props}
/>,
<CourseAuthoringProvider courseId={courseId}>
<StatusBar
courseId={courseId}
isLoading={isLoading}
statusBarData={statusBarData}
openEnableHighlightsModal={mockOpenEnableHighlightsModal}
handleVideoSharingOptionChange={mockHandleVideoSharingOptionChange}
{...props}
/>
</CourseAuthoringProvider>,
);

describe('<StatusBar />', () => {
beforeEach(() => {
initializeMocks();
mockWaffleFlags();
jest.useFakeTimers();
jest.setSystemTime(new Date('2013-03-05'));
});
Expand Down
Loading