Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
3 changes: 2 additions & 1 deletion plugins/course-apps/proctoring/Settings.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -472,7 +472,8 @@ describe('ProctoredExamSettings', () => {
});
// (1) for studio settings
// (2) for course details
expect(axiosMock.history.get.length).toBe(2);
// (3) for user course permissions
expect(axiosMock.history.get.length).toBe(3);
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
});

Expand Down
18 changes: 18 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,7 +63,14 @@ export const CourseAuthoringProvider = ({
closeUnlinkModal,
] = useToggleWithValue<ModalState>();

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

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

/**
* Open the unit page for a given locator.
Expand All @@ -78,6 +90,9 @@ export const CourseAuthoringProvider = ({
openUnlinkModal,
closeUnlinkModal,
currentUnlinkModalData,
isLoading,
canEditCourseContent,
canPublishCourseContent,
}), [
courseId,
courseDetails,
Expand All @@ -89,6 +104,9 @@ export const CourseAuthoringProvider = ({
openUnlinkModal,
closeUnlinkModal,
currentUnlinkModalData,
canEditCourseContent,
canPublishCourseContent,
isLoading,
Comment thread
jacobo-dominguez-wgu marked this conversation as resolved.
Outdated
]);

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
6 changes: 6 additions & 0 deletions src/course-outline/CourseOutline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import { getClipboardUrl } from '@src/generic/data/api';
import { ContainerType } from '@src/generic/key-utils';
import { getDownstreamApiUrl } from '@src/generic/unlink-modal/data/api';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import {
act,
fireEvent,
Expand Down Expand Up @@ -552,6 +553,11 @@ const renderComponent = () =>
describe('<CourseOutline />', () => {
beforeEach(async () => {
const mocks = initializeMocks();
mockWaffleFlags({ enableAuthzCourseAuthoring: false });
mocks.validateUserPermissionsMock.mockResolvedValue({
canEditCourseContent: true,
canPublishCourseContent: true,
});
selectedContainerId = undefined;
// restore index mock — use reorder outline spec (section[0] has 2 subsections for configure/drag tests)
courseOutlineIndexMock = buildTestOutline({
Expand Down
15 changes: 12 additions & 3 deletions src/course-outline/CourseOutline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ const CourseOutline = () => {
const location = useLocation();
const {
courseId,
canEditCourseContent,
isLoading: isLoadingAuthoringContext,
} = useCourseAuthoringContext();
const {
courseUsageKey,
Expand Down Expand Up @@ -94,7 +96,14 @@ const CourseOutline = () => {
const [showSuccessAlert, setShowSuccessAlert] = useState(false);

const isInternetConnectionAlertFailed = savingStatus === RequestStatus.FAILED;
const isReIndexShow = Boolean(reindexLink);
const isReIndexShow = canEditCourseContent && Boolean(reindexLink);

// The header's "+ Add" button creates course content, so gate its visibility behind the
// edit permission. This is scoped to the header actions and leaves the outline tree unaffected.
const headerCourseActions = useMemo(
() => ({ ...courseActions, childAddable: canEditCourseContent && courseActions.childAddable }),
[courseActions, canEditCourseContent],
);

const handleAddBlock = useCreateCourseBlock(courseId);
const pasteMutation = usePasteItem(courseId);
Expand Down Expand Up @@ -169,7 +178,7 @@ const CourseOutline = () => {
}
}, [location, courseId, courseName]);

if (isLoading) {
if (isLoading || isLoadingAuthoringContext) {
// eslint-disable-next-line react/jsx-no-useless-fragment
return (
<Row className="m-0 mt-4 justify-content-center">
Expand Down Expand Up @@ -249,7 +258,7 @@ const CourseOutline = () => {
headerNavigationsActions={headerNavigationsActions}
isDisabledReindexButton={isDisabledReindexButton}
hasSections={Boolean(sections.length)}
courseActions={courseActions}
courseActions={headerCourseActions}
errors={errors}
sections={sections}
/>
Expand Down
10 changes: 5 additions & 5 deletions src/course-outline/OutlineNode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ const OutlineNode = ({

const { activeId, overId } = useContext(DragContext);
const { selectedContainerState, openContainerSidebar, setSelectedContainerState } = useOutlineSidebarContext();
const { courseId, openUnlinkModal, getUnitUrl } = useCourseAuthoringContext();
const { courseId, openUnlinkModal, getUnitUrl, canEditCourseContent } = useCourseAuthoringContext();
const duplicateMutation = useDuplicateItem(courseId);
const { openPublishModal } = useCourseOutlineContext();
const queryClient = useQueryClient();
Expand Down Expand Up @@ -236,7 +236,7 @@ const OutlineNode = ({
else { onOrderChange(effectiveSection, getPossibleMoves!(index, 1)); }
};

const isDraggable = model.isDraggable(actions, isHeaderVisible);
const isDraggable = canEditCourseContent && model.isDraggable(actions, isHeaderVisible);

const titleComponent = depth < 2 ?
(
Expand Down Expand Up @@ -352,7 +352,7 @@ const OutlineNode = ({
data-testid={levelConfig.contentTestId}
onClick={(e) => onClickCard(e, false)}
>
{depth === 0 && onOpenHighlightsModal && (
{canEditCourseContent && depth === 0 && onOpenHighlightsModal && (
<div className="outline-section__status mb-1">
<Button
className="p-0 bg-transparent"
Expand Down Expand Up @@ -381,14 +381,14 @@ const OutlineNode = ({
})}
>
{children}
{actions.childAddable && (
{canEditCourseContent && actions.childAddable && (
<OutlineAddChildButtons
childType={levelConfig.containerType!}
parentLocator={blk.id}
grandParentLocator={depth === 1 ? parentSection?.id : undefined}
/>
)}
{showPaste && (
{canEditCourseContent && showPaste && (
<PasteComponent
className="mt-4 border-gray-500 rounded-0"
text={intl.formatMessage(outlineNodeMessages.pasteButton)}
Expand Down
7 changes: 5 additions & 2 deletions src/course-outline/OutlineTree.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
} from './drag-helper/utils';
import { applyReorderMove } from './drag-helper/utils';
import { type Depth, LEVEL_NAMES } from './outline-level';
import { useCourseAuthoringContext } from '@src/CourseAuthoringContext';

export interface OutlineTreeProps {
sections: XBlock[];
Expand Down Expand Up @@ -81,6 +82,8 @@ const OutlineTree = ({
await commitSectionReorder(sectionListIds);
}, [sections, previewSections, commitSectionReorder]);

const { canEditCourseContent } = useCourseAuthoringContext();

const handleSubsectionOrderChange = useCallback(
async (section: XBlock, moveDetails: SubsectionMoveDetails | null) => {
applyReorderMove(moveDetails, section, previewSections, commitSubsectionReorder);
Expand Down Expand Up @@ -180,7 +183,7 @@ const OutlineTree = ({
)}
</SortableContext>
</DraggableList>
{courseActions.childAddable && (
{canEditCourseContent && courseActions.childAddable && (
<OutlineAddChildButtons
childType={ContainerType.Section}
parentLocator={courseUsageKey}
Expand All @@ -190,7 +193,7 @@ const OutlineTree = ({
) :
(
<EmptyPlaceholder>
{courseActions.childAddable ?
{canEditCourseContent && courseActions.childAddable ?
(
<OutlineAddChildButtons
childType={ContainerType.Section}
Expand Down
39 changes: 38 additions & 1 deletion src/course-outline/card-header/CardHeader.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
waitFor,
} from '@src/testUtils';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { mockWaffleFlags } from '@src/data/apiHooks.mock';
import { courseId } from '@src/schedule-and-details/__mocks__/courseDetails';
import { userEvent } from '@testing-library/user-event';
import { renderCard, setupCardTestMocks } from '../__mocks__/testSetup';
Expand Down Expand Up @@ -66,6 +67,13 @@ const cardHeaderProps = {
},
};

let validateUserPermissionsMock;

const mockPermissions = (canEditCourseContent = true) => {
mockWaffleFlags({ enableAuthzCourseAuthoring: !canEditCourseContent });
validateUserPermissionsMock.mockResolvedValue({ canEditCourseContent });
};

const renderComponent = (props?: object, entry = '/') => {
const titleComponent = (
<TitleButton
Expand Down Expand Up @@ -99,7 +107,9 @@ const renderComponent = (props?: object, entry = '/') => {

describe('<CardHeader />', () => {
beforeEach(() => {
setupCardTestMocks();
const mocks = setupCardTestMocks();
validateUserPermissionsMock = mocks.validateUserPermissionsMock;
mockPermissions(true);
useUpdateCourseBlockNameMock.isPending = false;
useUpdateCourseBlockNameMock.mutate.mockClear();
useUpdateCourseBlockNameMock.mutateAsync.mockClear();
Expand Down Expand Up @@ -579,4 +589,31 @@ describe('<CardHeader />', () => {
await act(async () => fireEvent.click(unlinkMenuItem));
expect(onClickUnlinkMock).toHaveBeenCalled();
});

describe('canEditCourseContent permission', () => {
it('renders the rename button and actions menu when canEditCourseContent is true', async () => {
mockPermissions(true);
renderComponent();

expect(await screen.findByTestId('subsection-edit-button')).toBeInTheDocument();
expect(await screen.findByTestId('subsection-card-header__menu')).toBeInTheDocument();
});

it('does not render the rename button when canEditCourseContent is false', async () => {
mockPermissions(false);
renderComponent();

expect(await screen.findByText(cardHeaderProps.title)).toBeInTheDocument();
expect(screen.queryByTestId('subsection-edit-button')).not.toBeInTheDocument();
});

it('does not render the actions menu when canEditCourseContent is false', async () => {
mockPermissions(false);
renderComponent();

expect(await screen.findByText(cardHeaderProps.title)).toBeInTheDocument();
expect(screen.queryByTestId('subsection-card-header__menu')).not.toBeInTheDocument();
expect(screen.queryByTestId('subsection-card-header__menu-button')).not.toBeInTheDocument();
});
});
});
Loading