Skip to content
Open
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
12 changes: 7 additions & 5 deletions plugins/course-apps/proctoring/Settings.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -470,11 +470,13 @@ describe('ProctoredExamSettings', () => {
await waitFor(() => {
screen.getByDisplayValue('mockproc');
});
// (1) for studio settings
// (2) for course details
// (3) for user course permissions
expect(axiosMock.history.get.length).toBe(3);
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
7 changes: 6 additions & 1 deletion src/course-outline/OutlineNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -347,7 +347,12 @@ const OutlineNode = ({
readyToSync={blk.upstreamInfo?.readyToSync}
/>
<div
className={levelConfig.contentClass}
className={classNames(levelConfig.contentClass, {
// `item-children` pulls the content 2.75rem to the right so it spans
// under the drag handle column. Without the handle there is no column
// to fill, and the negative margin would overflow the card.
'item-children': isDraggable,
})}
data-testid={levelConfig.contentTestId}
onClick={(e) => onClickCard(e, false)}
>
Expand Down
4 changes: 2 additions & 2 deletions src/course-outline/outline-level.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,7 @@ export const LEVEL_CONFIG: Record<Depth, LevelConfig> = {
},
1: {
name: 'subsection',
contentClass: 'subsection-card__content item-children',
contentClass: 'subsection-card__content',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for removing the class here, could you please do the same in unit (the second object in the array)?

@dcoa dcoa Sep 7, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you I dont have any extra comments

contentTestId: 'subsection-card__content',
childContainerClass: 'subsection-card__units',
childContainerTestId: 'subsection-card__units',
Expand All @@ -99,7 +99,7 @@ export const LEVEL_CONFIG: Record<Depth, LevelConfig> = {
},
2: {
name: 'unit',
contentClass: 'unit-card__content item-children',
contentClass: 'unit-card__content',
contentTestId: 'unit-card__content',
iconSize: 'xs',
background: { background: '#fdfdfd' },
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
@@ -0,0 +1,56 @@
import {
initializeMocks,
render,
screen,
userEvent,
} from '@src/testUtils';

import messages from '../messages';
import { PublishButon } from './PublishButon';

const mockCourseAuthoringContext = {
canPublishCourseContent: true,
};

jest.mock('@src/CourseAuthoringContext', () => ({
useCourseAuthoringContext: () => mockCourseAuthoringContext,
}));

const onClickMock = jest.fn();

// The button's accessible name is the publish label plus the draft status,
// e.g. "Publish Changes (Draft)".
const publishButtonName = new RegExp(messages.publishContainerButton.defaultMessage, 'i');

describe('<PublishButon />', () => {
beforeEach(() => {
initializeMocks();
mockCourseAuthoringContext.canPublishCourseContent = true;
onClickMock.mockClear();
});

it('renders the publish button when the user can publish course content', async () => {
render(<PublishButon onClick={onClickMock} />);

expect(
await screen.findByRole('button', { name: publishButtonName }),
).toBeInTheDocument();
});

it('calls onClick when the button is clicked', async () => {
const user = userEvent.setup();
render(<PublishButon onClick={onClickMock} />);

await user.click(await screen.findByRole('button', { name: publishButtonName }));
expect(onClickMock).toHaveBeenCalledTimes(1);
});

it('does not render the button when the user cannot publish course content', () => {
mockCourseAuthoringContext.canPublishCourseContent = false;
render(<PublishButon onClick={onClickMock} />);

expect(
screen.queryByRole('button', { name: publishButtonName }),
).not.toBeInTheDocument();
});
});
35 changes: 21 additions & 14 deletions src/course-outline/outline-sidebar/info-sidebar/PublishButon.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';
import messages from '../messages';

interface Props {
onClick: () => void;
}

export const PublishButon = ({ onClick }: Props) => (
<Button
variant="outline-primary w-100 rounded status-button draft-status"
className="m-1"
onClick={onClick}
>
<strong className="mr-1">
<FormattedMessage
{...messages.publishContainerButton}
/>
</strong>
<FormattedMessage {...messages.draftText} />
</Button>
);
export const PublishButon = ({ onClick }: Props) => {
const { canPublishCourseContent } = useCourseAuthoringContext();

if (!canPublishCourseContent) {
return null;
}

return (
<Button
variant="outline-primary w-100 rounded status-button draft-status"
className="m-1"
onClick={onClick}
>
<strong className="mr-1">
<FormattedMessage {...messages.publishContainerButton} />
</strong>
<FormattedMessage {...messages.draftText} />
</Button>
);
};
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
Loading