Skip to content
Closed
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
5 changes: 3 additions & 2 deletions plugins/course-apps/proctoring/Settings.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -460,8 +460,9 @@ describe('ProctoredExamSettings', () => {
screen.getByDisplayValue('mockproc');
});
// (1) for studio settings
// (2) for course details
expect(axiosMock.history.get.length).toBe(2);
// (2) waffle flags
// (3) for course details
expect(axiosMock.history.get.length).toBe(3);
expect(axiosMock.history.get[0].url.includes('proctored_exam_settings')).toEqual(true);
});

Expand Down
119 changes: 108 additions & 11 deletions src/CourseAuthoringContext.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,32 @@
import { getConfig } from '@edx/frontend-platform';
import { createContext, useContext, useMemo } from 'react';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { useCreateCourseBlock } from '@src/course-outline/data/apiHooks';
import { getCourseItem } from '@src/course-outline/data/api';
import { useDispatch, useSelector } from 'react-redux';
import { addSection, addSubsection, updateSavingStatus } from '@src/course-outline/data/slice';
import { addNewSectionQuery, addNewSubsectionQuery, addNewUnitQuery } from '@src/course-outline/data/thunk';
import { useNavigate } from 'react-router';
import { getOutlineIndexData } from '@src/course-outline/data/selectors';
import { RequestStatus, RequestStatusType } from './data/constants';
import { useCourseDetails, useWaffleFlags } from './data/apiHooks';
import { CourseDetailsData } from './data/api';
import { useCourseDetails } from './data/apiHooks';
import { RequestStatusType } from './data/constants';

export type CourseAuthoringContextData = {
/** The ID of the current course */
courseId: string;
courseUsageKey: string;
courseDetails?: CourseDetailsData;
courseDetailStatus: RequestStatusType;
canChangeProviders: boolean;
handleAddSectionFromLibrary: ReturnType<typeof useCreateCourseBlock>;
handleAddSubsectionFromLibrary: ReturnType<typeof useCreateCourseBlock>;
handleAddUnitFromLibrary: ReturnType<typeof useCreateCourseBlock>;
handleNewSectionSubmit: () => void;
handleNewSubsectionSubmit: (sectionId: string) => void;
handleNewUnitSubmit: (subsectionId: string) => void;
openUnitPage: (locator: string) => void;
getUnitUrl: (locator: string) => string;
};

/**
Expand All @@ -30,23 +47,103 @@ export const CourseAuthoringProvider = ({
children,
courseId,
}: CourseAuthoringProviderProps) => {
const dispatch = useDispatch();
const navigate = useNavigate();
const waffleFlags = useWaffleFlags();
const { data: courseDetails, status: courseDetailStatus } = useCourseDetails(courseId);
const canChangeProviders = getAuthenticatedUser().administrator || new Date(courseDetails?.start ?? 0) > new Date();
const { courseStructure } = useSelector(getOutlineIndexData);
const { id: courseUsageKey } = courseStructure || {};

const context = useMemo<CourseAuthoringContextData>(() => {
const contextValue = {
courseId,
courseDetails,
courseDetailStatus,
canChangeProviders,
};
const getUnitUrl = (locator: string) => {
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
// instanbul ignore next
return `/course/${courseId}/container/${locator}`;
}
return `${getConfig().STUDIO_BASE_URL}/container/${locator}`;
Copy link
Contributor

Choose a reason for hiding this comment

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

Do we still need this? Wasn't the studio unit outline deprecated?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

@rpenido Did not change the actual implementation, just moved it into the context.

};

return contextValue;
}, [
/**
* Open the unit page for a given locator.
*/
const openUnitPage = (locator: string) => {
const url = getUnitUrl(locator);
if (getConfig().ENABLE_UNIT_PAGE === 'true' && waffleFlags.useNewUnitPage) {
// instanbul ignore next
navigate(url);
} else {
window.location.assign(url);
}
};

const handleNewSectionSubmit = () => {
dispatch(addNewSectionQuery(courseUsageKey));
};

const handleNewSubsectionSubmit = (sectionId: string) => {
dispatch(addNewSubsectionQuery(sectionId));
};

const handleNewUnitSubmit = (subsectionId: string) => {
dispatch(addNewUnitQuery(subsectionId, openUnitPage));
};

const handleAddSectionFromLibrary = useCreateCourseBlock(async (locator) => {
try {
const data = await getCourseItem(locator);
// instanbul ignore next
// Page should scroll to newly added section.
data.shouldScroll = true;
dispatch(addSection(data));
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
});

const handleAddSubsectionFromLibrary = useCreateCourseBlock(async (locator, parentLocator) => {
try {
const data = await getCourseItem(locator);
data.shouldScroll = true;
// Page should scroll to newly added subsection.
dispatch(addSubsection({ parentLocator, data }));
} catch {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
}
});

/**
* import a unit block from library and redirect user to this unit page.
*/
const handleAddUnitFromLibrary = useCreateCourseBlock(openUnitPage);

const context = useMemo<CourseAuthoringContextData>(() => ({
courseId,
courseUsageKey,
courseDetails,
courseDetailStatus,
canChangeProviders,
handleNewSectionSubmit,
handleNewSubsectionSubmit,
handleNewUnitSubmit,
handleAddSectionFromLibrary,
handleAddSubsectionFromLibrary,
handleAddUnitFromLibrary,
getUnitUrl,
openUnitPage,
}), [
courseId,
courseUsageKey,
courseDetails,
courseDetailStatus,
canChangeProviders,
handleNewSectionSubmit,
handleNewSubsectionSubmit,
handleNewUnitSubmit,
handleAddSectionFromLibrary,
handleAddSubsectionFromLibrary,
handleAddUnitFromLibrary,
getUnitUrl,
openUnitPage,
]);

return (
Expand Down
3 changes: 3 additions & 0 deletions src/CourseAuthoringPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,9 @@ const CourseAuthoringPage = ({ children }: Props) => {
org={courseOrg}
title={courseTitle}
contextId={courseId}
containerProps={{
size: 'fluid',
}}
/>
)
)}
Expand Down
5 changes: 3 additions & 2 deletions src/authz/data/apiHooks.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { skipToken, useQuery } from '@tanstack/react-query';
import { PermissionValidationAnswer, PermissionValidationQuery } from '@src/authz/types';
import { validateUserPermissions } from './api';

Expand Down Expand Up @@ -29,8 +29,9 @@ const adminConsoleQueryKeys = {
*/
export const useUserPermissions = (
permissions: PermissionValidationQuery,
enabled: boolean = true,
) => useQuery<PermissionValidationAnswer, Error>({
queryKey: adminConsoleQueryKeys.permissions(permissions),
queryFn: () => validateUserPermissions(permissions),
queryFn: enabled ? () => validateUserPermissions(permissions) : skipToken,
retry: false,
});
5 changes: 3 additions & 2 deletions src/course-outline/CourseOutline.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -438,8 +438,9 @@ describe('<CourseOutline />', () => {
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [subsection] = section.childInfo.children;
expect(axiosMock.history.post[2].data).toBe(JSON.stringify({
parent_locator: subsection.id,
type: COURSE_BLOCK_NAMES.vertical.id,
category: COURSE_BLOCK_NAMES.vertical.id,
parent_locator: subsection.id,
display_name: COURSE_BLOCK_NAMES.vertical.name,
}));
});
Expand Down Expand Up @@ -2495,7 +2496,7 @@ describe('<CourseOutline />', () => {
const btn = await screen.findByRole('button', { name: 'Collapse all' });
expect(btn).toBeInTheDocument();
expect(await screen.findByRole('link', { name: 'View live' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Add' })).toBeInTheDocument();
expect((await screen.findAllByRole('button', { name: 'Add' })).length).toEqual(2);
expect(await screen.findByRole('button', { name: 'More actions' })).toBeInTheDocument();
const user = userEvent.setup();
await user.click(btn);
Expand Down
24 changes: 9 additions & 15 deletions src/course-outline/CourseOutline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,13 @@ import { LegacyStatusBar } from './status-bar/LegacyStatusBar';
const CourseOutline = () => {
const intl = useIntl();
const location = useLocation();
const { courseId } = useCourseAuthoringContext();
const {
courseId,
handleAddSubsectionFromLibrary,
handleAddUnitFromLibrary,
handleAddSectionFromLibrary,
handleNewSectionSubmit,
} = useCourseAuthoringContext();

const {
courseUsageKey,
Expand Down Expand Up @@ -123,13 +129,6 @@ const CourseOutline = () => {
handleDuplicateSectionSubmit,
handleDuplicateSubsectionSubmit,
handleDuplicateUnitSubmit,
handleNewSectionSubmit,
handleNewSubsectionSubmit,
handleNewUnitSubmit,
handleAddUnitFromLibrary,
handleAddSubsectionFromLibrary,
handleAddSectionFromLibrary,
getUnitUrl,
handleVideoSharingOptionChange,
handlePasteClipboardClick,
notificationDismissUrl,
Expand Down Expand Up @@ -269,7 +268,7 @@ const CourseOutline = () => {

if (isLoadingDenied) {
return (
<Container size="xl" className="px-4 mt-4">
<Container fluid className="px-3 mt-4">
<PageAlerts
courseId={courseId}
notificationDismissUrl={notificationDismissUrl}
Expand All @@ -292,7 +291,7 @@ const CourseOutline = () => {
<Helmet>
<title>{getPageHeadTitle(courseName, intl.formatMessage(messages.headingTitle))}</title>
</Helmet>
<Container size="xl" className="px-4">
<Container fluid className="px-3">
<section className="course-outline-container mb-4 mt-5">
<PageAlerts
courseId={courseId}
Expand Down Expand Up @@ -413,9 +412,7 @@ const CourseOutline = () => {
onEditSectionSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateSectionSubmit}
isSectionsExpanded={isSectionsExpanded}
onNewSubsectionSubmit={handleNewSubsectionSubmit}
onOrderChange={updateSectionOrderByIndex}
onAddSubsectionFromLibrary={handleAddSubsectionFromLibrary.mutateAsync}
resetScrollState={resetScrollState}
>
<SortableContext
Expand Down Expand Up @@ -445,8 +442,6 @@ const CourseOutline = () => {
onEditSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateSubsectionSubmit}
onOpenConfigureModal={openConfigureModal}
onNewUnitSubmit={handleNewUnitSubmit}
onAddUnitFromLibrary={handleAddUnitFromLibrary.mutateAsync}
onOrderChange={updateSubsectionOrderByIndex}
onPasteClick={handlePasteClipboardClick}
resetScrollState={resetScrollState}
Expand Down Expand Up @@ -480,7 +475,6 @@ const CourseOutline = () => {
onOpenUnlinkModal={openUnlinkModal}
onEditSubmit={handleEditSubmit}
onDuplicateSubmit={handleDuplicateUnitSubmit}
getTitleLink={getUnitUrl}
onOrderChange={updateUnitOrderByIndex}
discussionsSettings={discussionsSettings}
/>
Expand Down
43 changes: 32 additions & 11 deletions src/course-outline/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -382,19 +382,40 @@ export async function duplicateCourseItem(itemId: string, parentId: string): Pro
}

/**
* Add new course item like section, subsection or unit.
* @param {string} parentLocator
* @param {string} category
* @param {string} displayName
* @returns {Promise<Object>}
* Creates a new course XBlock. Can be used to create any type of block
* and also import a content from library.
*/
export async function addNewCourseItem(parentLocator: string, category: string, displayName: string): Promise<object> {
export async function createCourseXblock({
type,
category,
parentLocator,
displayName,
boilerplate,
stagedContent,
libraryContentKey,
}: {
type: string,
/** The category of the XBlock. Defaults to the type if not provided. */
category?: string,
parentLocator: string,
displayName?: string,
boilerplate?: string,
stagedContent?: string,
/** component key from library if being imported. */
libraryContentKey?: string,
}) {
const body = {
type,
boilerplate,
category: category || type,
parent_locator: parentLocator,
display_name: displayName,
staged_content: stagedContent,
library_content_key: libraryContentKey,
};

const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(), {
parent_locator: parentLocator,
category,
display_name: displayName,
});
.post(getXBlockBaseApiUrl(), body);

return data;
}
Expand Down
16 changes: 5 additions & 11 deletions src/course-outline/data/apiHooks.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,5 @@
import {
skipToken, useMutation, useQuery,
} from '@tanstack/react-query';
import { createCourseXblock } from '@src/course-unit/data/api';
import {
getCourseDetails,
getCourseItem,
} from './api';
import { skipToken, useMutation, useQuery } from '@tanstack/react-query';
import { createCourseXblock, getCourseDetails, getCourseItem } from './api';

export const courseOutlineQueryKeys = {
all: ['courseOutline'],
Expand All @@ -29,11 +23,11 @@ export const courseOutlineQueryKeys = {
* Can also be used to import block from library by passing `libraryContentKey` in request body
*/
export const useCreateCourseBlock = (
callback?: ((locator?: string, parentLocator?: string) => void),
callback?: ((locator: string, parentLocator: string) => void),
) => useMutation({
mutationFn: createCourseXblock,
onSettled: async (data) => {
callback?.(data?.locator, data.parent_locator);
onSettled: async (data: { locator: string, parent_locator: string }) => {
callback?.(data.locator, data.parent_locator);
},
});

Expand Down
Loading