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
3 changes: 2 additions & 1 deletion src/data/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,7 @@ export interface CourseSettingsData {
courseDisplayName: string;
courseDisplayNameWithDefault: string;
creditEligibilityEnabled: boolean;
creditRequirements?: Record<string, any>;
enableExtendedCourseDetails: boolean;
enrollmentEndEditable: boolean;
isCreditCourse: boolean;
Expand All @@ -259,7 +260,7 @@ export interface CourseSettingsData {
rerunLink: string;
run: string;
url: string;
};
}[];
shortDescriptionEditable: boolean;
showMinGradeWarning: boolean;
sidebarHtmlEnabled: boolean;
Expand Down
5 changes: 4 additions & 1 deletion src/data/apiHooks.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/* eslint-disable import/no-extraneous-dependencies */
import { AxiosError } from 'axios';
import { getConfig } from '@edx/frontend-platform';
import { getAuthenticatedUser } from '@edx/frontend-platform/auth';
import { UserAgreement, UserAgreementRecord } from '@src/data/types';
Expand All @@ -22,6 +24,7 @@ import {
updateUserAgreementRecord,
waffleFlagDefaults,
getCourseSettings,
CourseSettingsData,
} from './api';
import { RequestStatus, RequestStatusType } from './constants';

Expand Down Expand Up @@ -228,7 +231,7 @@ export const useUserAgreement = (agreementType: string) => (
* Get the course settings
*/
export const useCourseSettings = (courseId: string) => (
useQuery({
useQuery<CourseSettingsData, AxiosError>({
queryKey: ['courseSettings', courseId],
queryFn: () => getCourseSettings(courseId),
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,9 @@
// @ts-check
import {
act,
initializeMocks,
render,
waitFor,
fireEvent,
screen,
} from '@src/testUtils';
import { executeThunk } from '@src/utils';
import genericMessages from '@src/generic/help-sidebar/messages';
import { DATE_FORMAT } from '@src/constants';
import { getCourseSettingsApiUrl } from '@src/data/api';
Expand All @@ -16,7 +13,6 @@ import { useCourseUserPermissions } from '@src/authz/hooks';
import { CourseAuthoringProvider } from '@src/CourseAuthoringContext';
import { courseDetailsMock, courseSettingsMock } from './__mocks__';
import { getCourseDetailsApiUrl } from './data/api';
import { updateCourseDetailsQuery } from './data/thunks';
import creditMessages from './credit-section/messages';
import pacingMessages from './pacing-section/messages';
import basicMessages from './basic-section/messages';
Expand Down Expand Up @@ -45,7 +41,6 @@ const mockPermissions = (overrides = {}) =>
});

let axiosMock;
let store;
const courseId = '123';

// Mock the tinymce lib
Expand Down Expand Up @@ -84,7 +79,6 @@ describe('<ScheduleAndDetails />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
store = mocks.reduxStore;
axiosMock
.onGet(getCourseDetailsApiUrl(courseId))
.reply(200, courseDetailsMock);
Expand All @@ -97,29 +91,27 @@ describe('<ScheduleAndDetails />', () => {
});

it('should render without errors', async () => {
const { getByText, getByRole, getAllByText } = renderComponent();
await waitFor(() => {
const scheduleAndDetailElements = getAllByText(messages.headingTitle.defaultMessage);
const scheduleAndDetailTitle = scheduleAndDetailElements[0];
expect(
getByText(pacingMessages.pacingTitle.defaultMessage),
).toBeInTheDocument();
expect(scheduleAndDetailTitle).toBeInTheDocument();
expect(
getByText(basicMessages.basicTitle.defaultMessage),
).toBeInTheDocument();
expect(
getByText(creditMessages.creditTitle.defaultMessage),
).toBeInTheDocument();
expect(
getByText(scheduleMessages.scheduleTitle.defaultMessage),
).toBeInTheDocument();
expect(
getByRole('navigation', {
name: genericMessages.sidebarTitleOther.defaultMessage,
}),
).toBeInTheDocument();
});
renderComponent();
expect(
await screen.findByText(pacingMessages.pacingTitle.defaultMessage),
).toBeInTheDocument();
const scheduleAndDetailElements = screen.getAllByText(messages.headingTitle.defaultMessage);
const scheduleAndDetailTitle = scheduleAndDetailElements[0];
expect(scheduleAndDetailTitle).toBeInTheDocument();
expect(
screen.getByText(basicMessages.basicTitle.defaultMessage),
).toBeInTheDocument();
expect(
screen.getByText(creditMessages.creditTitle.defaultMessage),
).toBeInTheDocument();
expect(
screen.getByText(scheduleMessages.scheduleTitle.defaultMessage),
).toBeInTheDocument();
expect(
screen.getByRole('navigation', {
name: genericMessages.sidebarTitleOther.defaultMessage,
}),
).toBeInTheDocument();
});

it('should hide credit section with condition', async () => {
Expand All @@ -132,63 +124,56 @@ describe('<ScheduleAndDetails />', () => {
.onGet(getCourseSettingsApiUrl(courseId))
.reply(200, updatedResponse);

const { queryAllByText } = renderComponent();
await waitFor(() => {
expect(
queryAllByText(creditMessages.creditTitle.defaultMessage).length,
).toBe(0);
});
renderComponent();
expect(
screen.queryAllByText(creditMessages.creditTitle.defaultMessage).length,
).toBe(0);
});

it('should show save alert onChange ', async () => {
const { getAllByPlaceholderText, getByText } = renderComponent();
let inputs;
await waitFor(() => {
inputs = getAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase());
});
renderComponent();
const inputs = await screen.findAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase());
// @ts-ignore
fireEvent.change(inputs[0], { target: { value: '06/16/2023' } });

expect(
getByText(messages.alertWarning.defaultMessage),
screen.getByText(messages.alertWarning.defaultMessage),
).toBeInTheDocument();
});

it('should display a success message when course details saves', async () => {
const { getByText } = renderComponent();
await executeThunk(updateCourseDetailsQuery(courseId, 'DaTa'), store.dispatch);
expect(getByText(messages.alertSuccess.defaultMessage)).toBeInTheDocument();
renderComponent();
const inputs = await screen.findAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase());
fireEvent.change(inputs[0], { target: { value: '06/16/2023' } });
fireEvent.click(await screen.findByText(messages.buttonSaveText.defaultMessage));
expect(await screen.findByText(messages.alertSuccess.defaultMessage)).toBeInTheDocument();
});

it('should display an error when GET CourseDetails fails', async () => {
axiosMock
.onGet(getCourseDetailsApiUrl(courseId))
.reply(404, 'error');
const { getByText } = renderComponent();
await waitFor(() => {
expect(getByText(messages.alertLoadFail.defaultMessage)).toBeInTheDocument();
});
renderComponent();
expect(await screen.findByText(messages.alertLoadFail.defaultMessage)).toBeInTheDocument();
});

it('should display an error when GET CourseSettings fails', async () => {
axiosMock
.onGet(getCourseSettingsApiUrl(courseId))
.reply(404, 'error');
const { getByText } = renderComponent();
await waitFor(() => {
expect(getByText(messages.alertLoadFail.defaultMessage)).toBeInTheDocument();
});
renderComponent();
expect(await screen.findByText(messages.alertLoadFail.defaultMessage)).toBeInTheDocument();
});

it('should display an error when PUT CourseDetails fails', async () => {
axiosMock
.onPut(getCourseDetailsApiUrl(courseId))
.reply(404, 'error');
const { getByText } = renderComponent();
await act(async () => {
await executeThunk(updateCourseDetailsQuery(courseId, 'DaTa'), store.dispatch);
});
expect(getByText(messages.alertFail.defaultMessage)).toBeInTheDocument();
renderComponent();
const inputs = await screen.findAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase());
fireEvent.change(inputs[0], { target: { value: '06/16/2023' } });
fireEvent.click(await screen.findByText(messages.buttonSaveText.defaultMessage));
expect(await screen.findByText(messages.alertFail.defaultMessage)).toBeInTheDocument();
});
});

Expand All @@ -197,7 +182,6 @@ describe('<ScheduleAndDetails /> permissions', () => {
jest.restoreAllMocks();
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
store = mocks.reduxStore;
axiosMock.onGet(getCourseDetailsApiUrl(courseId)).reply(200, courseDetailsMock);
axiosMock.onGet(getCourseSettingsApiUrl(courseId)).reply(200, courseSettingsMock);
axiosMock.onPut(getCourseDetailsApiUrl(courseId)).reply(200);
Expand All @@ -206,58 +190,48 @@ describe('<ScheduleAndDetails /> permissions', () => {

it('renders normally when authz flag is disabled (no regression)', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: false });
const { getAllByText } = renderComponent();
await waitFor(() => {
expect(getAllByText(messages.headingTitle.defaultMessage).length).toBeGreaterThan(0);
});
renderComponent();
expect((await screen.findAllByText(messages.headingTitle.defaultMessage)).length).toBeGreaterThan(0);
});

it('renders normally when user has all permissions', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
const { getAllByText } = renderComponent();
await waitFor(() => {
expect(getAllByText(messages.headingTitle.defaultMessage).length).toBeGreaterThan(0);
});
renderComponent();
expect((await screen.findAllByText(messages.headingTitle.defaultMessage)).length).toBeGreaterThan(0);
});

it('shows PermissionDeniedAlert when user lacks view permission', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
mockPermissions({ canViewScheduleAndDetails: false, canEditSchedule: false, canEditDetails: false });
const { getByTestId } = renderComponent();
await waitFor(() => {
expect(getByTestId('permissionDeniedAlert')).toBeInTheDocument();
});
renderComponent();
expect(await screen.findByTestId('permissionDeniedAlert')).toBeInTheDocument();
});

it('disables schedule date inputs when user lacks edit_schedule permission', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
mockPermissions({ canEditSchedule: false });
const { getAllByPlaceholderText } = renderComponent();
await waitFor(() => {
const dateInputs = getAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase());
dateInputs.forEach((input) => expect(input).toBeDisabled());
});
renderComponent();
const dateInputs = await screen.findAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase());
dateInputs.forEach((input) => expect(input).toBeDisabled());
});

it('disables pacing and details inputs when user lacks edit_details permission', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
mockPermissions({ canEditDetails: false });
const { getAllByRole } = renderComponent();
await waitFor(() => {
const radios = getAllByRole('radio');
radios.forEach((radio) => expect(radio).toBeDisabled());
});
renderComponent();
const radios = await screen.findAllByRole('radio');
radios.forEach((radio) => expect(radio).toBeDisabled());
});

it('save button cannot be triggered when user has no edit permissions', async () => {
mockWaffleFlags({ enableAuthzCourseAuthoring: true });
mockPermissions({ canEditSchedule: false, canEditDetails: false });
const { getAllByPlaceholderText, queryByText } = renderComponent();
renderComponent();
// Wait for page to load
const dateInputs = await waitFor(() => getAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase()));
const dateInputs = await screen.findAllByPlaceholderText(DATE_FORMAT.toLocaleUpperCase());
// All date inputs must be disabled (no edit_schedule permission)
dateInputs.forEach((input) => expect(input).toBeDisabled());
// No changes can be made so the save button never appears
expect(queryByText(messages.buttonSaveText.defaultMessage)).not.toBeInTheDocument();
expect(screen.queryByText(messages.buttonSaveText.defaultMessage)).not.toBeInTheDocument();
});
});
33 changes: 0 additions & 33 deletions src/schedule-and-details/data/api.js

This file was deleted.

50 changes: 50 additions & 0 deletions src/schedule-and-details/data/api.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { convertObjectToSnakeCase } from '@src/utils';

const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getCourseDetailsApiUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/course_details/${courseId}`;
export const getUploadAssetsUrl = (courseId) => `${getApiBaseUrl()}/assets/${courseId}/`;

// TODO: This interface has only basic data, all the rest needs to be added.
export interface CourseDetails {
courseId: string;
run: string;
title: string;
subtitle?: string;
org: string;
description?: string;
hasChanges: boolean;
selfPaced: boolean;
overview: string;
aboutSidebarHtml: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
[key: string]: any;
}

/**
* Get course details.
* TODO: This API call is also done in `course-outline`,
* and a similar one is also done in `CourseAuthoringContext`.
* We need to find a way to unify them.
*/
export async function getCourseDetails(courseId: string): Promise<CourseDetails> {
const { data } = await getAuthenticatedHttpClient().get(
`${getCourseDetailsApiUrl(courseId)}`,
);
return camelCaseObject(data);
}

/**
* Update course details.
*/
export async function updateCourseDetails(
courseId: string,
details: CourseDetails,
): Promise<CourseDetails> {
const { data } = await getAuthenticatedHttpClient().put(
`${getCourseDetailsApiUrl(courseId)}`,
convertObjectToSnakeCase(details, true),
);
return camelCaseObject(data);
}
Loading