Fix undo reverts and update flcc to working version (#748)
* Revert "Fix tinymce editor problems (#743)"
This reverts commit e6ce05571f.
* chore: update flcc to working version
* chore: update flcc to version that disables plugins
This commit is contained in:
8
package-lock.json
generated
8
package-lock.json
generated
@@ -14,7 +14,7 @@
|
||||
"@edx/frontend-component-footer": "^12.3.0",
|
||||
"@edx/frontend-component-header": "^4.7.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^2.0.0",
|
||||
"@edx/frontend-lib-content-components": "1.176.4",
|
||||
"@edx/frontend-lib-content-components": "^1.177.6",
|
||||
"@edx/frontend-platform": "5.6.1",
|
||||
"@edx/paragon": "^21.5.6",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
@@ -2762,9 +2762,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@edx/frontend-lib-content-components": {
|
||||
"version": "1.176.4",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.176.4.tgz",
|
||||
"integrity": "sha512-scxJWs2nVnsUNno5CFHQWIrROSp3m/8b6dxBza6fKaX78Xm6TRTYpnpVM+uMw6XffJRY1NzUKGyT7AbF+36jvA==",
|
||||
"version": "1.177.6",
|
||||
"resolved": "https://registry.npmjs.org/@edx/frontend-lib-content-components/-/frontend-lib-content-components-1.177.6.tgz",
|
||||
"integrity": "sha512-skR0QgWYtkWAQEsfxR4dDAiHIPI7yfWckwmj9oJEzuy9VNUYwcn2tXRe6DupuwVHULnR/60pFAHaYINvtCP0tg==",
|
||||
"dependencies": {
|
||||
"@codemirror/lang-html": "^6.0.0",
|
||||
"@codemirror/lang-xml": "^6.0.0",
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
"@edx/frontend-component-footer": "^12.3.0",
|
||||
"@edx/frontend-component-header": "^4.7.0",
|
||||
"@edx/frontend-enterprise-hotjar": "^2.0.0",
|
||||
"@edx/frontend-lib-content-components": "1.176.4",
|
||||
"@edx/frontend-lib-content-components": "^1.177.6",
|
||||
"@edx/frontend-platform": "5.6.1",
|
||||
"@edx/paragon": "^21.5.6",
|
||||
"@fortawesome/fontawesome-svg-core": "1.2.36",
|
||||
|
||||
@@ -12,7 +12,7 @@ export const getContentDataApiUrl = (contentId) => new URL(`/xblock/outline/${co
|
||||
* @param {number} taxonomyId The id of the taxonomy to fetch tags for
|
||||
* @param {string} fullPathProvided Optional param that contains the full URL to fetch data
|
||||
* If provided, we use it instead of generating the URL. This is usually for fetching subTags
|
||||
* @returns {Promise<Object>}
|
||||
* @returns {Promise<import("./types.mjs").TaxonomyTagsData>}
|
||||
*/
|
||||
export async function getTaxonomyTagsData(taxonomyId, fullPathProvided) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(
|
||||
@@ -24,7 +24,7 @@ export async function getTaxonomyTagsData(taxonomyId, fullPathProvided) {
|
||||
/**
|
||||
* Get the tags that are applied to the content object
|
||||
* @param {string} contentId The id of the content object to fetch the applied tags for
|
||||
* @returns {Promise<Object>}
|
||||
* @returns {Promise<import("./types.mjs").ContentTaxonomyTagsData>}
|
||||
*/
|
||||
export async function getContentTaxonomyTagsData(contentId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getContentTaxonomyTagsApiUrl(contentId));
|
||||
@@ -34,7 +34,7 @@ export async function getContentTaxonomyTagsData(contentId) {
|
||||
/**
|
||||
* Fetch meta data (eg: display_name) about the content object (unit/compoenent)
|
||||
* @param {string} contentId The id of the content object (unit/component)
|
||||
* @returns {Promise<Object>}
|
||||
* @returns {Promise<import("./types.mjs").ContentData>}
|
||||
*/
|
||||
export async function getContentData(contentId) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getContentDataApiUrl(contentId));
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
* @param {number} taxonomyId The id of the taxonomy to fetch tags for
|
||||
* @param {string} fullPathProvided Optional param that contains the full URL to fetch data
|
||||
* If provided, we use it instead of generating the URL. This is usually for fetching subTags
|
||||
* @returns {import("@tanstack/react-query").UseQueryResult<import("./types.mjs").TaxonomyTagsData>}
|
||||
*/
|
||||
export const useTaxonomyTagsData = (taxonomyId, fullPathProvided) => (
|
||||
useQuery({
|
||||
@@ -24,7 +23,6 @@ export const useTaxonomyTagsData = (taxonomyId, fullPathProvided) => (
|
||||
/**
|
||||
* Builds the query to get the taxonomy tags applied to the content object
|
||||
* @param {string} contentId The id of the content object to fetch the applied tags for
|
||||
* @returns {import("@tanstack/react-query").UseQueryResult<import("./types.mjs").ContentTaxonomyTagsData>}
|
||||
*/
|
||||
export const useContentTaxonomyTagsData = (contentId) => (
|
||||
useQuery({
|
||||
@@ -36,7 +34,6 @@ export const useContentTaxonomyTagsData = (contentId) => (
|
||||
/**
|
||||
* Builds the query to get meta data about the content object
|
||||
* @param {string} contentId The id of the content object (unit/component)
|
||||
* @returns {import("@tanstack/react-query").UseQueryResult<import("./types.mjs").ContentData>}
|
||||
*/
|
||||
export const useContentData = (contentId) => (
|
||||
useQuery({
|
||||
|
||||
@@ -100,8 +100,3 @@
|
||||
* @property {TaxonomyTagData[]} results
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} UseQueryResult
|
||||
* @property {Object} data
|
||||
* @property {string} status
|
||||
*/
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import { convertObjectToSnakeCase } from '../../utils';
|
||||
|
||||
class ExamsApiService {
|
||||
static isAvailable() {
|
||||
@@ -26,8 +27,9 @@ class ExamsApiService {
|
||||
}
|
||||
|
||||
static saveCourseExamConfiguration(courseId, dataToSave) {
|
||||
const snakecaseDataToSave = convertObjectToSnakeCase(dataToSave, true);
|
||||
const apiClient = getAuthenticatedHttpClient();
|
||||
return apiClient.patch(this.getExamConfigurationUrl(courseId), dataToSave);
|
||||
return apiClient.patch(this.getExamConfigurationUrl(courseId), snakecaseDataToSave);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -57,6 +57,7 @@ const FileTable = ({
|
||||
};
|
||||
const [currentView, setCurrentView] = useState(defaultVal);
|
||||
const [isDeleteOpen, setDeleteOpen, setDeleteClose] = useToggle(false);
|
||||
const [isDownloadOpen, setDownloadOpen, setDownloadClose] = useToggle(false);
|
||||
const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false);
|
||||
const [isAddOpen, setAddOpen, setAddClose] = useToggle(false);
|
||||
const [selectedRows, setSelectedRows] = useState([]);
|
||||
@@ -114,6 +115,8 @@ const FileTable = ({
|
||||
|
||||
const handleBulkDownload = useCallback(async (selectedFlatRows) => {
|
||||
handleErrorReset({ errorType: 'download' });
|
||||
setSelectedRows(selectedFlatRows);
|
||||
setDownloadOpen();
|
||||
handleDownloadFile(selectedFlatRows);
|
||||
}, []);
|
||||
|
||||
@@ -240,6 +243,14 @@ const FileTable = ({
|
||||
setSelectedRows={setSelectedRows}
|
||||
fileType={fileType}
|
||||
/>
|
||||
<ApiStatusToast
|
||||
actionType={intl.formatMessage(messages.apiStatusDownloadingAction)}
|
||||
selectedRowCount={selectedRows.length}
|
||||
isOpen={isDownloadOpen}
|
||||
setClose={setDownloadClose}
|
||||
setSelectedRows={setSelectedRows}
|
||||
fileType={fileType}
|
||||
/>
|
||||
</DataTable>
|
||||
<FileInput key="generic-file-upload" fileInput={fileInputControl} supportedFileFormats={supportedFileFormats} />
|
||||
{!isEmpty(selectedRows) && (
|
||||
|
||||
@@ -17,6 +17,10 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.files-and-upload.apiStatus.deletingAction.message',
|
||||
defaultMessage: 'Deleting',
|
||||
},
|
||||
apiStatusDownloadingAction: {
|
||||
id: 'course-authoring.files-and-upload.apiStatus.downloadingAction.message',
|
||||
defaultMessage: 'Downloading',
|
||||
},
|
||||
fileSizeError: {
|
||||
id: 'course-authoring.files-and-upload.addFiles.error.fileSize',
|
||||
defaultMessage: 'Uploaded file(s) must be 20 MB or less. Please resize file(s) and try again.',
|
||||
|
||||
@@ -33,6 +33,7 @@ const MoreInfoColumn = ({
|
||||
id,
|
||||
wrapperType,
|
||||
displayName,
|
||||
downloadLink,
|
||||
} = row.original;
|
||||
return (
|
||||
<>
|
||||
@@ -99,7 +100,7 @@ const MoreInfoColumn = ({
|
||||
as={Button}
|
||||
variant="tertiary"
|
||||
onClick={() => handleBulkDownload(
|
||||
[{ original: { id, displayName } }],
|
||||
[{ original: { id, displayName, downloadLink } }],
|
||||
)}
|
||||
>
|
||||
{intl.formatMessage(messages.downloadTitle)}
|
||||
|
||||
@@ -82,7 +82,7 @@ const VideosPage = ({
|
||||
|
||||
const handleAddFile = (file) => dispatch(addVideoFile(courseId, file));
|
||||
const handleDeleteFile = (id) => dispatch(deleteVideoFile(courseId, id));
|
||||
const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows }));
|
||||
const handleDownloadFile = (selectedRows) => dispatch(fetchVideoDownload({ selectedRows, courseId }));
|
||||
const handleUsagePaths = (video) => dispatch(getUsagePaths({ video, courseId }));
|
||||
const handleErrorReset = (error) => dispatch(resetErrors(error));
|
||||
const handleFileOrder = ({ newFileIdOrder, sortType }) => {
|
||||
|
||||
@@ -36,7 +36,7 @@ import {
|
||||
addVideoThumbnail,
|
||||
fetchVideoDownload,
|
||||
} from './data/thunks';
|
||||
import { getVideosUrl, getCoursVideosApiUrl, getApiBaseUrl } from './data/api';
|
||||
import { getVideosUrl, getCourseVideosApiUrl, getApiBaseUrl } from './data/api';
|
||||
import videoMessages from './messages';
|
||||
import messages from '../generic/messages';
|
||||
|
||||
@@ -127,8 +127,8 @@ describe('FilesAndUploads', () => {
|
||||
const mockFetchResponse = Promise.resolve(mockResponseData);
|
||||
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
|
||||
|
||||
axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
|
||||
axiosMock.onGet(getCoursVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
|
||||
axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
|
||||
axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
|
||||
|
||||
Object.defineProperty(dropzone, 'files', {
|
||||
value: [file],
|
||||
@@ -223,8 +223,8 @@ describe('FilesAndUploads', () => {
|
||||
const mockFetchResponse = Promise.resolve(mockResponseData);
|
||||
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
|
||||
|
||||
axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
|
||||
axiosMock.onGet(getCoursVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
|
||||
axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
|
||||
axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
|
||||
|
||||
const addFilesButton = screen.getAllByLabelText('file-input')[3];
|
||||
await act(async () => {
|
||||
@@ -263,7 +263,7 @@ describe('FilesAndUploads', () => {
|
||||
const deleteButton = screen.getByText(messages.deleteTitle.defaultMessage).closest('a');
|
||||
expect(deleteButton).not.toHaveClass('disabled');
|
||||
|
||||
axiosMock.onDelete(`${getCoursVideosApiUrl(courseId)}/mOckID1`).reply(204);
|
||||
axiosMock.onDelete(`${getCourseVideosApiUrl(courseId)}/mOckID1`).reply(204);
|
||||
|
||||
fireEvent.click(deleteButton);
|
||||
expect(screen.getByText('Delete video(s) confirmation')).toBeVisible();
|
||||
@@ -322,8 +322,7 @@ describe('FilesAndUploads', () => {
|
||||
await waitFor(() => {
|
||||
fireEvent.click(actionsButton);
|
||||
});
|
||||
axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1`).reply(200, { download_link: 'http://download.org' });
|
||||
axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID5`).reply(200, { download_link: 'http://download.org' });
|
||||
axiosMock.onPut(`${getVideosUrl(courseId)}/download`).reply(200, null);
|
||||
|
||||
const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a');
|
||||
expect(downloadButton).not.toHaveClass('disabled');
|
||||
@@ -533,7 +532,6 @@ describe('FilesAndUploads', () => {
|
||||
const videoMenuButton = screen.getByTestId('file-menu-dropdown-mOckID1');
|
||||
expect(videoMenuButton).toBeVisible();
|
||||
|
||||
axiosMock.onGet(`${getVideosUrl(courseId)}/mOckID1`).reply(200, { download_link: 'test' });
|
||||
await waitFor(() => {
|
||||
fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByText('Download'));
|
||||
@@ -553,7 +551,7 @@ describe('FilesAndUploads', () => {
|
||||
expect(fileMenuButton).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
axiosMock.onDelete(`${getCoursVideosApiUrl(courseId)}/mOckID1`).reply(204);
|
||||
axiosMock.onDelete(`${getCourseVideosApiUrl(courseId)}/mOckID1`).reply(204);
|
||||
fireEvent.click(within(fileMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByTestId('open-delete-confirmation-button'));
|
||||
expect(screen.getByText('Delete video(s) confirmation')).toBeVisible();
|
||||
@@ -575,7 +573,7 @@ describe('FilesAndUploads', () => {
|
||||
const errorMessage = 'File download.png exceeds maximum size of 5 GB.';
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(413, { error: errorMessage });
|
||||
axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(413, { error: errorMessage });
|
||||
const addFilesButton = screen.getAllByLabelText('file-input')[3];
|
||||
await act(async () => {
|
||||
userEvent.upload(addFilesButton, file);
|
||||
@@ -590,7 +588,7 @@ describe('FilesAndUploads', () => {
|
||||
it('404 add file should show error', async () => {
|
||||
renderComponent();
|
||||
await mockStore(RequestStatus.SUCCESSFUL);
|
||||
axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(404);
|
||||
axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(404);
|
||||
const addFilesButton = screen.getAllByLabelText('file-input')[3];
|
||||
await act(async () => {
|
||||
userEvent.upload(addFilesButton, file);
|
||||
@@ -625,8 +623,8 @@ describe('FilesAndUploads', () => {
|
||||
const mockFetchResponse = Promise.reject(mockResponseData);
|
||||
global.fetch = jest.fn().mockImplementation(() => mockFetchResponse);
|
||||
|
||||
axiosMock.onPost(getCoursVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
|
||||
axiosMock.onGet(getCoursVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
|
||||
axiosMock.onPost(getCourseVideosApiUrl(courseId)).reply(204, generateNewVideoApiResponse());
|
||||
axiosMock.onGet(getCourseVideosApiUrl(courseId)).reply(200, generateAddVideoApiResponse());
|
||||
const addFilesButton = screen.getAllByLabelText('file-input')[3];
|
||||
await act(async () => {
|
||||
userEvent.upload(addFilesButton, file);
|
||||
@@ -647,7 +645,7 @@ describe('FilesAndUploads', () => {
|
||||
expect(videoMenuButton).toBeVisible();
|
||||
|
||||
await waitFor(() => {
|
||||
axiosMock.onDelete(`${getCoursVideosApiUrl(courseId)}/mOckID1`).reply(404);
|
||||
axiosMock.onDelete(`${getCourseVideosApiUrl(courseId)}/mOckID1`).reply(404);
|
||||
fireEvent.click(within(videoMenuButton).getByLabelText('file-menu-toggle'));
|
||||
fireEvent.click(screen.getByTestId('open-delete-confirmation-button'));
|
||||
expect(screen.getByText('Delete video(s) confirmation')).toBeVisible();
|
||||
@@ -701,9 +699,11 @@ describe('FilesAndUploads', () => {
|
||||
const downloadButton = screen.getByText(messages.downloadTitle.defaultMessage).closest('a');
|
||||
expect(downloadButton).not.toHaveClass('disabled');
|
||||
|
||||
axiosMock.onPut(`${getVideosUrl(courseId)}/download`).reply(404);
|
||||
|
||||
await waitFor(() => {
|
||||
fireEvent.click(downloadButton);
|
||||
executeThunk(fetchVideoDownload([{ original: { displayName: 'mOckID1', id: '2' } }]), store.dispatch);
|
||||
executeThunk(fetchVideoDownload([{ original: { displayName: 'mOckID1', id: '2', downloadLink: 'test' } }]), store.dispatch);
|
||||
});
|
||||
|
||||
const updateStatus = store.getState().videos.updatingStatus;
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import saveAs from 'file-saver';
|
||||
import { camelCaseObject, ensureConfig, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import saveAs from 'file-saver';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
ensureConfig([
|
||||
@@ -11,7 +10,7 @@ ensureConfig([
|
||||
|
||||
export const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
|
||||
export const getVideosUrl = (courseId) => `${getApiBaseUrl()}/api/contentstore/v1/videos/${courseId}`;
|
||||
export const getCoursVideosApiUrl = (courseId) => `${getApiBaseUrl()}/videos/${courseId}`;
|
||||
export const getCourseVideosApiUrl = (courseId) => `${getApiBaseUrl()}/videos/${courseId}`;
|
||||
|
||||
/**
|
||||
* Fetches the course custom pages for provided course
|
||||
@@ -39,7 +38,7 @@ export async function getVideos(courseId) {
|
||||
*/
|
||||
export async function fetchVideoList(courseId) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.get(getCoursVideosApiUrl(courseId));
|
||||
.get(getCourseVideosApiUrl(courseId));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -75,27 +74,48 @@ export async function uploadTranscript({
|
||||
await getAuthenticatedHttpClient().post(`${getApiBaseUrl()}${apiUrl}`, formData);
|
||||
}
|
||||
|
||||
export async function getDownload(selectedRows) {
|
||||
export async function getDownload(selectedRows, courseId) {
|
||||
const downloadErrors = [];
|
||||
if (selectedRows?.length > 0) {
|
||||
await Promise.allSettled(
|
||||
selectedRows.map(async row => {
|
||||
try {
|
||||
const video = row.original;
|
||||
const { downloadLink } = video;
|
||||
if (!isEmpty(downloadLink)) {
|
||||
saveAs(downloadLink, video.displayName);
|
||||
} else {
|
||||
downloadErrors.push(`Cannot find download file for ${video?.displayName}.`);
|
||||
}
|
||||
} catch (error) {
|
||||
downloadErrors.push('Failed to download video.');
|
||||
}
|
||||
}),
|
||||
);
|
||||
let file;
|
||||
let filename;
|
||||
if (selectedRows?.length > 1) {
|
||||
const downloadLinks = selectedRows.map(row => {
|
||||
const video = row.original;
|
||||
try {
|
||||
const url = video.downloadLink;
|
||||
const name = video.displayName;
|
||||
return { url, name };
|
||||
} catch (error) {
|
||||
downloadErrors.push(`Cannot find download file for ${video?.displayName || 'video'}.`);
|
||||
return null;
|
||||
}
|
||||
});
|
||||
if (!isEmpty(downloadLinks)) {
|
||||
const json = { files: downloadLinks };
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.put(`${getVideosUrl(courseId)}/download`, json, { responseType: 'arraybuffer' });
|
||||
|
||||
const date = new Date().toString();
|
||||
filename = `${courseId}-videos-${date}`;
|
||||
file = new Blob([data], { type: 'application/zip' });
|
||||
saveAs(file, filename);
|
||||
}
|
||||
} else if (selectedRows?.length === 1) {
|
||||
try {
|
||||
const video = selectedRows[0].original;
|
||||
const { downloadLink } = video;
|
||||
if (!isEmpty(downloadLink)) {
|
||||
saveAs(downloadLink, video.displayName);
|
||||
} else {
|
||||
downloadErrors.push(`Cannot find download file for ${video?.displayName}.`);
|
||||
}
|
||||
} catch (error) {
|
||||
downloadErrors.push('Failed to download video.');
|
||||
}
|
||||
} else {
|
||||
downloadErrors.push('No files were selected to download.');
|
||||
}
|
||||
|
||||
return downloadErrors;
|
||||
}
|
||||
|
||||
@@ -117,7 +137,7 @@ export async function getVideoUsagePaths({ courseId, videoId }) {
|
||||
*/
|
||||
export async function deleteVideo(courseId, videoId) {
|
||||
await getAuthenticatedHttpClient()
|
||||
.delete(`${getCoursVideosApiUrl(courseId)}/${videoId}`);
|
||||
.delete(`${getCourseVideosApiUrl(courseId)}/${videoId}`);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -144,7 +164,7 @@ export async function addVideo(courseId, file) {
|
||||
};
|
||||
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(getCoursVideosApiUrl(courseId), postJson);
|
||||
.post(getCourseVideosApiUrl(courseId), postJson);
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
@@ -166,7 +186,7 @@ export async function uploadVideo(
|
||||
})
|
||||
.then(async () => {
|
||||
await getAuthenticatedHttpClient()
|
||||
.post(getCoursVideosApiUrl(courseId), [{
|
||||
.post(getCourseVideosApiUrl(courseId), [{
|
||||
edxVideoId,
|
||||
message: 'Upload completed',
|
||||
status: 'upload_completed',
|
||||
@@ -175,7 +195,7 @@ export async function uploadVideo(
|
||||
.catch(async () => {
|
||||
uploadErrors.push(`Failed to upload ${uploadFile.name} to server.`);
|
||||
await getAuthenticatedHttpClient()
|
||||
.post(getCoursVideosApiUrl(courseId), [{
|
||||
.post(getCourseVideosApiUrl(courseId), [{
|
||||
edxVideoId,
|
||||
message: 'Upload failed',
|
||||
status: 'upload_failed',
|
||||
|
||||
@@ -1,9 +1,26 @@
|
||||
import { getDownload } from './api';
|
||||
import 'file-saver';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
import { getDownload, getVideosUrl } from './api';
|
||||
|
||||
jest.mock('file-saver');
|
||||
|
||||
let axiosMock;
|
||||
|
||||
describe('api.js', () => {
|
||||
beforeEach(() => {
|
||||
initializeMockApp({
|
||||
authenticatedUser: {
|
||||
userId: 3,
|
||||
username: 'abc123',
|
||||
administrator: true,
|
||||
roles: [],
|
||||
},
|
||||
});
|
||||
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
|
||||
});
|
||||
describe('getDownload', () => {
|
||||
describe('selectedRows length is undefined or less than zero', () => {
|
||||
it('should return with no files selected error if selectedRows is empty', async () => {
|
||||
@@ -18,36 +35,39 @@ describe('api.js', () => {
|
||||
});
|
||||
});
|
||||
describe('selectedRows length is greater than one', () => {
|
||||
beforeEach(() => {
|
||||
axiosMock.onPut(`${getVideosUrl('SoMEiD')}/download`).reply(200, null);
|
||||
});
|
||||
it('should not throw error when blob returns null', async () => {
|
||||
const expected = [];
|
||||
const actual = await getDownload([
|
||||
{ original: { displayName: 'test1', downloadLink: 'test1.com' } },
|
||||
{ original: { displayName: 'test2', id: '2', downloadLink: 'test2.com' } },
|
||||
]);
|
||||
], 'SoMEiD');
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it('should return error if row does not contain .original attribute', async () => {
|
||||
const expected = ['Failed to download video.'];
|
||||
const expected = ['Cannot find download file for video.'];
|
||||
const actual = await getDownload([
|
||||
{ asset: { displayName: 'test1', id: '1' } },
|
||||
{ original: { displayName: 'test2', id: '2', downloadLink: 'test1.com' } },
|
||||
]);
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it('should return error if original does not contain .downloadLink attribute', async () => {
|
||||
const expected = ['Cannot find download file for test2.'];
|
||||
const actual = await getDownload([
|
||||
{ original: { displayName: 'test2', id: '2' } },
|
||||
]);
|
||||
], 'SoMEiD');
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
describe('selectedRows length equals one', () => {
|
||||
it('should return error if original does not contain .downloadLink attribute', async () => {
|
||||
const expected = ['Cannot find download file for test2.'];
|
||||
const actual = await getDownload([
|
||||
{ original: { displayName: 'test2', id: '2' } },
|
||||
], 'SoMEiD');
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
it('should return error if row does not contain .original ancestor', async () => {
|
||||
const expected = ['Failed to download video.'];
|
||||
const actual = await getDownload([
|
||||
{ asset: { displayName: 'test1', id: '1', download_link: 'test1.com' } },
|
||||
]);
|
||||
], 'SoMEiD');
|
||||
expect(actual).toEqual(expected);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -296,16 +296,20 @@ export function getUsagePaths({ video, courseId }) {
|
||||
};
|
||||
}
|
||||
|
||||
export function fetchVideoDownload({ selectedRows }) {
|
||||
export function fetchVideoDownload({ selectedRows, courseId }) {
|
||||
return async (dispatch) => {
|
||||
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.IN_PROGRESS }));
|
||||
const errors = await getDownload(selectedRows);
|
||||
if (isEmpty(errors)) {
|
||||
try {
|
||||
const errors = await getDownload(selectedRows, courseId);
|
||||
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.SUCCESSFUL }));
|
||||
} else {
|
||||
errors.forEach(error => {
|
||||
dispatch(updateErrors({ error: 'download', message: error }));
|
||||
});
|
||||
if (!isEmpty(errors)) {
|
||||
errors.forEach(error => {
|
||||
dispatch(updateErrors({ error: 'download', message: error }));
|
||||
});
|
||||
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.FAILED }));
|
||||
}
|
||||
} catch (error) {
|
||||
dispatch(updateErrors({ error: 'download', message: 'Failed to download zip file of videos.' }));
|
||||
dispatch(updateEditStatus({ editType: 'download', status: RequestStatus.FAILED }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
|
||||
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
|
||||
|
||||
@@ -14,9 +13,42 @@ export const getImportStatusApiUrl = (courseId, fileName) => `${getApiBaseUrl()}
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function startCourseImporting(courseId, fileData, requestConfig) {
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(postImportCourseApiUrl(courseId), { 'course-data': fileData }, { headers: { 'content-type': 'multipart/form-data' }, ...requestConfig });
|
||||
return camelCaseObject(data);
|
||||
const chunkSize = 20 * 1000000; // 20 MB
|
||||
const fileSize = fileData.size || 0;
|
||||
const chunkLength = Math.ceil(fileSize / chunkSize);
|
||||
let resp;
|
||||
|
||||
const upload = async (blob, start, stop) => {
|
||||
const contentRange = `bytes ${start}-${stop}/${fileSize}`;
|
||||
const contentDisposition = `attachment; filename="${fileData.name}"`;
|
||||
const headers = {
|
||||
'Content-Range': contentRange,
|
||||
'Content-Disposition': contentDisposition,
|
||||
};
|
||||
const formData = new FormData();
|
||||
formData.append('course-data', blob, fileData.name);
|
||||
const { data } = await getAuthenticatedHttpClient()
|
||||
.post(
|
||||
postImportCourseApiUrl(courseId),
|
||||
formData,
|
||||
{ headers, ...requestConfig },
|
||||
);
|
||||
resp = camelCaseObject(data);
|
||||
};
|
||||
|
||||
const chunkUpload = async (file, index) => {
|
||||
const start = index * chunkSize;
|
||||
const stop = start + chunkSize < fileSize ? start + chunkSize : fileSize;
|
||||
const blob = file.slice(start, stop, file.type);
|
||||
await upload(blob, start, stop - 1);
|
||||
};
|
||||
|
||||
/* eslint-disable no-await-in-loop */
|
||||
for (let i = 0; i < chunkLength; i++) {
|
||||
await chunkUpload(fileData, i);
|
||||
}
|
||||
|
||||
return resp;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -25,11 +25,11 @@ describe('API Functions', () => {
|
||||
});
|
||||
|
||||
it('should fetch status on start importing', async () => {
|
||||
const file = new File(['(⌐□_□)'], 'download.tar.gz', { size: 20 });
|
||||
const data = { importStatus: 1 };
|
||||
axiosMock.onPost(postImportCourseApiUrl(courseId)).reply(200, data);
|
||||
|
||||
const result = await startCourseImporting(courseId);
|
||||
|
||||
const result = await startCourseImporting(courseId, file);
|
||||
expect(axiosMock.history.post[0].url).toEqual(postImportCourseApiUrl(courseId));
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
@@ -42,7 +42,6 @@ const ImportSidebar = ({ intl, courseId }) => {
|
||||
className="small"
|
||||
href={importLearnMoreUrl}
|
||||
target="_blank"
|
||||
variant="outline-primary"
|
||||
>
|
||||
{intl.formatMessage(messages.learnMoreButtonTitle)}
|
||||
</Hyperlink>
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
@import "course-updates/CourseUpdates";
|
||||
@import "export-page/CourseExportPage";
|
||||
@import "import-page/CourseImportPage";
|
||||
@import "taxonomy/taxonomy-card/TaxonomyCard";
|
||||
@import "taxonomy";
|
||||
@import "files-and-videos";
|
||||
@import "content-tags-drawer/TagBubble";
|
||||
@import "course-outline/CourseOutline";
|
||||
|
||||
@@ -28,7 +28,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
const initialFormValues = {
|
||||
enableProctoredExams: false,
|
||||
proctoringProvider: false,
|
||||
proctortrackEscalationEmail: '',
|
||||
escalationEmail: '',
|
||||
allowOptingOut: false,
|
||||
createZendeskTickets: false,
|
||||
};
|
||||
@@ -44,7 +44,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
const [saveSuccess, setSaveSuccess] = useState(false);
|
||||
const [saveError, setSaveError] = useState(false);
|
||||
const [submissionInProgress, setSubmissionInProgress] = useState(false);
|
||||
const [showProctortrackEscalationEmail, setShowProctortrackEscalationEmail] = useState(false);
|
||||
const [showEscalationEmail, setShowEscalationEmail] = useState(false);
|
||||
const isEdxStaff = getAuthenticatedUser().administrator;
|
||||
const [formStatus, setFormStatus] = useState({
|
||||
isValid: true,
|
||||
@@ -53,6 +53,15 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
const isMobile = useIsMobile();
|
||||
const modalVariant = isMobile ? 'dark' : 'default';
|
||||
|
||||
const isLtiProvider = (provider) => (
|
||||
ltiProctoringProviders.some(p => p.name === provider)
|
||||
);
|
||||
|
||||
function getProviderDisplayLabel(provider) {
|
||||
// if a display label exists for this provider return it
|
||||
return ltiProctoringProviders.find(p => p.name === provider)?.verbose_name || provider;
|
||||
}
|
||||
|
||||
const { courseId } = useContext(PagesAndResourcesContext);
|
||||
const appInfo = useModel('courseApps', 'proctoring');
|
||||
const alertRef = React.createRef();
|
||||
@@ -73,38 +82,36 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
|
||||
if (value === 'proctortrack') {
|
||||
setFormValues({ ...newFormValues, createZendeskTickets: false });
|
||||
setShowProctortrackEscalationEmail(true);
|
||||
setShowEscalationEmail(true);
|
||||
} else if (value === 'software_secure') {
|
||||
setFormValues({ ...newFormValues, createZendeskTickets: true });
|
||||
setShowEscalationEmail(false);
|
||||
} else if (isLtiProvider(value)) {
|
||||
setFormValues(newFormValues);
|
||||
setShowEscalationEmail(true);
|
||||
} else {
|
||||
if (value === 'software_secure') {
|
||||
setFormValues({ ...newFormValues, createZendeskTickets: true });
|
||||
} else {
|
||||
setFormValues(newFormValues);
|
||||
}
|
||||
|
||||
setShowProctortrackEscalationEmail(false);
|
||||
setFormValues(newFormValues);
|
||||
setShowEscalationEmail(false);
|
||||
}
|
||||
} else {
|
||||
setFormValues({ ...formValues, [name]: value });
|
||||
}
|
||||
};
|
||||
|
||||
function isLtiProvider(provider) {
|
||||
return ltiProctoringProviders.some(p => p.name === provider);
|
||||
}
|
||||
|
||||
const setFocusToProctortrackEscalationEmailInput = () => {
|
||||
const setFocusToEscalationEmailInput = () => {
|
||||
if (proctoringEscalationEmailInputRef && proctoringEscalationEmailInputRef.current) {
|
||||
proctoringEscalationEmailInputRef.current.focus();
|
||||
}
|
||||
};
|
||||
|
||||
function postSettingsBackToServer() {
|
||||
const providerIsLti = isLtiProvider(formValues.proctoringProvider);
|
||||
const selectedProvider = formValues.proctoringProvider;
|
||||
const isLtiProviderSelected = isLtiProvider(selectedProvider);
|
||||
const studioDataToPostBack = {
|
||||
proctored_exam_settings: {
|
||||
enable_proctored_exams: formValues.enableProctoredExams,
|
||||
// lti providers are managed outside edx-platform, lti_external indicates this
|
||||
proctoring_provider: providerIsLti ? 'lti_external' : formValues.proctoringProvider,
|
||||
proctoring_provider: isLtiProviderSelected ? 'lti_external' : selectedProvider,
|
||||
create_zendesk_tickets: formValues.createZendeskTickets,
|
||||
},
|
||||
};
|
||||
@@ -113,17 +120,23 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
}
|
||||
|
||||
if (formValues.proctoringProvider === 'proctortrack') {
|
||||
studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.proctortrackEscalationEmail === '' ? null : formValues.proctortrackEscalationEmail;
|
||||
studioDataToPostBack.proctored_exam_settings.proctoring_escalation_email = formValues.escalationEmail === '' ? null : formValues.escalationEmail;
|
||||
}
|
||||
|
||||
// only save back to exam service if necessary
|
||||
setSubmissionInProgress(true);
|
||||
|
||||
const saveOperations = [StudioApiService.saveProctoredExamSettingsData(courseId, studioDataToPostBack)];
|
||||
if (allowLtiProviders && ExamsApiService.isAvailable()) {
|
||||
const selectedEscalationEmail = formValues.escalationEmail;
|
||||
|
||||
saveOperations.push(
|
||||
ExamsApiService.saveCourseExamConfiguration(
|
||||
courseId,
|
||||
{ provider: providerIsLti ? formValues.proctoringProvider : null },
|
||||
{
|
||||
provider: isLtiProviderSelected ? formValues.proctoringProvider : null,
|
||||
escalationEmail: (isLtiProviderSelected && selectedEscalationEmail !== '') ? selectedEscalationEmail : null,
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -141,20 +154,21 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
|
||||
const handleSubmit = (event) => {
|
||||
event.preventDefault();
|
||||
const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider);
|
||||
if (
|
||||
formValues.proctoringProvider === 'proctortrack'
|
||||
&& !EmailValidator.validate(formValues.proctortrackEscalationEmail)
|
||||
&& !(formValues.proctortrackEscalationEmail === '' && !formValues.enableProctoredExams)
|
||||
(formValues.proctoringProvider === 'proctortrack' || isLtiProviderSelected)
|
||||
&& !EmailValidator.validate(formValues.escalationEmail)
|
||||
&& !(formValues.escalationEmail === '' && !formValues.enableProctoredExams)
|
||||
) {
|
||||
if (formValues.proctortrackEscalationEmail === '') {
|
||||
const errorMessage = intl.formatMessage(messages['authoring.proctoring.escalationemail.error.blank']);
|
||||
if (formValues.escalationEmail === '') {
|
||||
const errorMessage = intl.formatMessage(messages['authoring.proctoring.escalationemail.error.blank'], { proctoringProviderName: getProviderDisplayLabel(formValues.proctoringProvider) });
|
||||
|
||||
setFormStatus({
|
||||
isValid: false,
|
||||
errors: {
|
||||
formProctortrackEscalationEmail: {
|
||||
formEscalationEmail: {
|
||||
dialogErrorMessage: (
|
||||
<Alert.Link onClick={setFocusToProctortrackEscalationEmailInput} href="#formProctortrackEscalationEmail" data-testid="proctorTrackEscalationEmailErrorLink">
|
||||
<Alert.Link onClick={setFocusToEscalationEmailInput} href="#formEscalationEmail" data-testid="escalationEmailErrorLink">
|
||||
{errorMessage}
|
||||
</Alert.Link>
|
||||
),
|
||||
@@ -168,8 +182,8 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
setFormStatus({
|
||||
isValid: false,
|
||||
errors: {
|
||||
formProctortrackEscalationEmail: {
|
||||
dialogErrorMessage: (<Alert.Link onClick={setFocusToProctortrackEscalationEmailInput} href="#formProctortrackEscalationEmail" data-testid="proctorTrackEscalationEmailErrorLink">{errorMessage}</Alert.Link>),
|
||||
formEscalationEmail: {
|
||||
dialogErrorMessage: (<Alert.Link onClick={setFocusToEscalationEmailInput} href="#formEscalationEmail" data-testid="escalationEmailErrorLink">{errorMessage}</Alert.Link>),
|
||||
inputErrorMessage: errorMessage,
|
||||
},
|
||||
},
|
||||
@@ -178,7 +192,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
} else {
|
||||
postSettingsBackToServer();
|
||||
const errors = { ...formStatus.errors };
|
||||
delete errors.formProctortrackEscalationEmail;
|
||||
delete errors.formEscalationEmail;
|
||||
setFormStatus({
|
||||
isValid: true,
|
||||
errors,
|
||||
@@ -202,11 +216,6 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
return markDisabled;
|
||||
}
|
||||
|
||||
function getProviderDisplayLabel(provider) {
|
||||
// if a display label exists for this provider return it
|
||||
return ltiProctoringProviders.find(p => p.name === provider)?.verbose_name || provider;
|
||||
}
|
||||
|
||||
function getProctoringProviderOptions(providers) {
|
||||
return providers.map(provider => (
|
||||
<option
|
||||
@@ -247,16 +256,18 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
);
|
||||
|
||||
function renderContent() {
|
||||
const isLtiProviderSelected = isLtiProvider(formValues.proctoringProvider);
|
||||
|
||||
return (
|
||||
<>
|
||||
{!formStatus.isValid && formStatus.errors.formProctortrackEscalationEmail
|
||||
{!formStatus.isValid && formStatus.errors.formEscalationEmail
|
||||
&& (
|
||||
// tabIndex="-1" to make non-focusable element focusable
|
||||
<Alert
|
||||
id="proctortrackEscalationEmailError"
|
||||
id="escalationEmailError"
|
||||
variant="danger"
|
||||
tabIndex="-1"
|
||||
data-testid="proctortrackEscalationEmailError"
|
||||
data-testid="escalationEmailError"
|
||||
ref={alertRef}
|
||||
>
|
||||
{getFormErrorMessage()}
|
||||
@@ -319,30 +330,30 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* PROCTORTRACK ESCALATION EMAIL */}
|
||||
{showProctortrackEscalationEmail && formValues.enableProctoredExams && (
|
||||
<Form.Group controlId="formProctortrackEscalationEmail">
|
||||
{/* ESCALATION EMAIL */}
|
||||
{showEscalationEmail && formValues.enableProctoredExams && (
|
||||
<Form.Group controlId="formEscalationEmail">
|
||||
<Form.Label className="font-weight-bold">
|
||||
{intl.formatMessage(messages['authoring.proctoring.escalationemail.label'])}
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
ref={proctoringEscalationEmailInputRef}
|
||||
type="email"
|
||||
name="proctortrackEscalationEmail"
|
||||
name="escalationEmail"
|
||||
data-testid="escalationEmail"
|
||||
onChange={handleChange}
|
||||
value={formValues.proctortrackEscalationEmail}
|
||||
isInvalid={Object.prototype.hasOwnProperty.call(formStatus.errors, 'formProctortrackEscalationEmail')}
|
||||
aria-describedby="proctortrackEscalationEmailHelpText"
|
||||
value={formValues.escalationEmail}
|
||||
isInvalid={Object.prototype.hasOwnProperty.call(formStatus.errors, 'formEscalationEmail')}
|
||||
aria-describedby="escalationEmailHelpText"
|
||||
/>
|
||||
<Form.Text id="proctortrackEscalationEmailHelpText">
|
||||
<Form.Text id="escalationEmailHelpText">
|
||||
{intl.formatMessage(messages['authoring.proctoring.escalationemail.help'])}
|
||||
</Form.Text>
|
||||
{Object.prototype.hasOwnProperty.call(formStatus.errors, 'formProctortrackEscalationEmail') && (
|
||||
{Object.prototype.hasOwnProperty.call(formStatus.errors, 'formEscalationEmail') && (
|
||||
<Form.Control.Feedback type="invalid">
|
||||
{
|
||||
formStatus.errors.formProctortrackEscalationEmail
|
||||
&& formStatus.errors.formProctortrackEscalationEmail.inputErrorMessage
|
||||
formStatus.errors.formEscalationEmail
|
||||
&& formStatus.errors.formEscalationEmail.inputErrorMessage
|
||||
}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
@@ -350,7 +361,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
)}
|
||||
|
||||
{/* ALLOW OPTING OUT OF PROCTORED EXAMS */}
|
||||
{ isEdxStaff && formValues.enableProctoredExams && !isLtiProvider(formValues.proctoringProvider) && (
|
||||
{ isEdxStaff && formValues.enableProctoredExams && !isLtiProviderSelected && (
|
||||
<fieldset aria-describedby="allowOptingOutHelpText">
|
||||
<Form.Group controlId="formAllowingOptingOut">
|
||||
<Form.Label as="legend" className="font-weight-bold">
|
||||
@@ -374,7 +385,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
)}
|
||||
|
||||
{/* CREATE ZENDESK TICKETS */}
|
||||
{ isEdxStaff && formValues.enableProctoredExams && !isLtiProvider(formValues.proctoringProvider) && (
|
||||
{ isEdxStaff && formValues.enableProctoredExams && !isLtiProviderSelected && (
|
||||
<fieldset aria-describedby="createZendeskTicketsText">
|
||||
<Form.Group controlId="formCreateZendeskTickets">
|
||||
<Form.Label as="legend" className="font-weight-bold">
|
||||
@@ -487,10 +498,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
setLoading(false);
|
||||
setSubmissionInProgress(false);
|
||||
setCourseStartDate(settingsResponse.data.course_start_date);
|
||||
const isProctortrack = proctoredExamSettings.proctoring_provider === 'proctortrack';
|
||||
setShowProctortrackEscalationEmail(isProctortrack);
|
||||
setAvailableProctoringProviders(settingsResponse.data.available_proctoring_providers);
|
||||
const proctoringEscalationEmail = proctoredExamSettings.proctoring_escalation_email;
|
||||
|
||||
// The list of providers returned by studio settings are the default behavior. If lti_external
|
||||
// is available as an option display the list of LTI providers returned by the exam service.
|
||||
@@ -517,6 +525,18 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
} else {
|
||||
selectedProvider = proctoredExamSettings.proctoring_provider;
|
||||
}
|
||||
|
||||
const isProctortrack = selectedProvider === 'proctortrack';
|
||||
const ltiProviderSelected = proctoringProvidersLti.some(p => p.name === selectedProvider);
|
||||
|
||||
if (isProctortrack || ltiProviderSelected) {
|
||||
setShowEscalationEmail(true);
|
||||
}
|
||||
|
||||
const proctoringEscalationEmail = ltiProviderSelected
|
||||
? examConfigResponse.data.escalation_email
|
||||
: proctoredExamSettings.proctoring_escalation_email;
|
||||
|
||||
setFormValues({
|
||||
...formValues,
|
||||
proctoringProvider: selectedProvider,
|
||||
@@ -526,7 +546,7 @@ const ProctoringSettings = ({ intl, onClose }) => {
|
||||
// The backend API may return null for the proctoringEscalationEmail value, which is the default.
|
||||
// In order to keep our email input component controlled, we use the empty string as the default
|
||||
// and perform this conversion during GETs and POSTs.
|
||||
proctortrackEscalationEmail: proctoringEscalationEmail === null ? '' : proctoringEscalationEmail,
|
||||
escalationEmail: proctoringEscalationEmail === null ? '' : proctoringEscalationEmail,
|
||||
});
|
||||
},
|
||||
).catch(
|
||||
|
||||
@@ -196,7 +196,6 @@ describe('ProctoredExamSettings', () => {
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
expect(screen.queryByTestId('allowOptingOutRadio')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsYes')).toBeNull();
|
||||
expect(screen.queryByTestId('createZendeskTicketsNo')).toBeNull();
|
||||
@@ -204,6 +203,8 @@ describe('ProctoredExamSettings', () => {
|
||||
});
|
||||
|
||||
describe('Validation with invalid escalation email', () => {
|
||||
const proctoringProvidersRequiringEscalationEmail = ['proctortrack', 'test_lti'];
|
||||
|
||||
beforeEach(async () => {
|
||||
axiosMock.onGet(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
@@ -215,10 +216,14 @@ describe('ProctoredExamSettings', () => {
|
||||
proctoring_escalation_email: 'test@example.com',
|
||||
create_zendesk_tickets: true,
|
||||
},
|
||||
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc'],
|
||||
available_proctoring_providers: ['software_secure', 'proctortrack', 'mockproc', 'lti_external'],
|
||||
course_start_date: '2070-01-01T00:00:00Z',
|
||||
});
|
||||
|
||||
axiosMock.onPatch(
|
||||
ExamsApiService.getExamConfigurationUrl(defaultProps.courseId),
|
||||
).reply(204, {});
|
||||
|
||||
axiosMock.onPost(
|
||||
StudioApiService.getProctoredExamSettingsUrl(defaultProps.courseId),
|
||||
).reply(200, {});
|
||||
@@ -226,175 +231,183 @@ describe('ProctoredExamSettings', () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
});
|
||||
|
||||
it('Creates an alert when no proctoring escalation email is provided with proctortrack selected', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
proctoringProvidersRequiringEscalationEmail.forEach(provider => {
|
||||
it(`Creates an alert when no proctoring escalation email is provided with ${provider} selected`, async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||
expect(escalationEmailError.textContent).not.toBeNull();
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
|
||||
// verify alert link links to offending input
|
||||
const errorLink = screen.getByTestId('escalationEmailErrorLink');
|
||||
await act(async () => {
|
||||
fireEvent.click(errorLink);
|
||||
});
|
||||
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
||||
expect(document.activeElement).toEqual(escalationEmailInput);
|
||||
});
|
||||
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('proctortrackEscalationEmailError');
|
||||
expect(escalationEmailError.textContent).not.toBeNull();
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
it(`Creates an alert when invalid proctoring escalation email is provided with ${provider} selected`, async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
|
||||
// verify alert link links to offending input
|
||||
const errorLink = screen.getByTestId('proctorTrackEscalationEmailErrorLink');
|
||||
await act(async () => {
|
||||
fireEvent.click(errorLink);
|
||||
});
|
||||
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
||||
expect(document.activeElement).toEqual(escalationEmailInput);
|
||||
});
|
||||
const selectElement = screen.getByDisplayValue('proctortrack');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: provider } });
|
||||
});
|
||||
|
||||
it('Creates an alert when invalid proctoring escalation email is provided with proctortrack selected', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
});
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
});
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
expect(escalationEmailError.textContent).not.toBeNull();
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
|
||||
// verify alert link links to offending input
|
||||
const errorLink = screen.getByTestId('escalationEmailErrorLink');
|
||||
await act(async () => {
|
||||
fireEvent.click(errorLink);
|
||||
});
|
||||
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
||||
expect(document.activeElement).toEqual(escalationEmailInput);
|
||||
});
|
||||
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('proctortrackEscalationEmailError');
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
expect(escalationEmailError.textContent).not.toBeNull();
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
it('Creates an alert when invalid proctoring escalation email is provided with proctoring disabled', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
});
|
||||
const enableProctoringElement = screen.getByText('Proctored exams');
|
||||
await act(async () => fireEvent.click(enableProctoringElement));
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
|
||||
// verify alert link links to offending input
|
||||
const errorLink = screen.getByTestId('proctorTrackEscalationEmailErrorLink');
|
||||
await act(async () => {
|
||||
fireEvent.click(errorLink);
|
||||
});
|
||||
const escalationEmailInput = screen.getByTestId('escalationEmail');
|
||||
expect(document.activeElement).toEqual(escalationEmailInput);
|
||||
});
|
||||
|
||||
it('Creates an alert when invalid proctoring escalation email is provided with proctoring disabled', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo.bar' } });
|
||||
});
|
||||
const enableProctoringElement = screen.getByText('Proctored exams');
|
||||
await act(async () => fireEvent.click(enableProctoringElement));
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('escalationEmailError');
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
expect(escalationEmailError.textContent).not.toBeNull();
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
});
|
||||
|
||||
// verify alert content and focus management
|
||||
const escalationEmailError = screen.getByTestId('proctortrackEscalationEmailError');
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
expect(escalationEmailError.textContent).not.toBeNull();
|
||||
expect(document.activeElement).toEqual(escalationEmailError);
|
||||
});
|
||||
it('Has no error when empty proctoring escalation email is provided with proctoring disabled', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
const enableProctoringElement = screen.getByText('Proctored exams');
|
||||
await act(async () => fireEvent.click(enableProctoringElement));
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
|
||||
it('Has no error when invalid proctoring escalation email is provided with proctoring disabled', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
const enableProctoringElement = screen.getByText('Proctored exams');
|
||||
await act(async () => fireEvent.click(enableProctoringElement));
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
// verify there is no escalation email alert, and focus has been set on save success alert
|
||||
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
// verify there is no escalation email alert, and focus has been set on save success alert
|
||||
expect(screen.queryByTestId('proctortrackEscalationEmailError')).toBeNull();
|
||||
it(`Has no error when valid proctoring escalation email is provided with ${provider} selected`, async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
|
||||
});
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
});
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
// verify there is no escalation email alert, and focus has been set on save success alert
|
||||
expect(screen.queryByTestId('escalationEmailError')).toBeNull();
|
||||
|
||||
it('Has no error when valid proctoring escalation email is provided with proctortrack selected', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: 'foo@bar.com' } });
|
||||
});
|
||||
const selectButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(selectButton);
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
|
||||
// verify there is no escalation email alert, and focus has been set on save success alert
|
||||
expect(screen.queryByTestId('proctortrackEscalationEmailError')).toBeNull();
|
||||
it(`Escalation email field hidden when proctoring backend is not ${provider}`, async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
});
|
||||
|
||||
const errorAlert = screen.getByTestId('saveSuccess');
|
||||
expect(errorAlert.textContent).toEqual(
|
||||
expect.stringContaining('Proctored exam settings saved successfully.'),
|
||||
);
|
||||
expect(document.activeElement).toEqual(errorAlert);
|
||||
});
|
||||
it(`Escalation email Field Show when proctoring backend is switched back to ${provider}`, async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||
let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
|
||||
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||
});
|
||||
|
||||
it('Escalation email field hidden when proctoring backend is not Proctortrack', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
it('Submits form when "Enter" key is hit in the escalation email field', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.submit(selectEscalationEmailElement);
|
||||
});
|
||||
// if the error appears, the form has been submitted
|
||||
expect(screen.getByTestId('escalationEmailError')).toBeDefined();
|
||||
});
|
||||
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||
const selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
});
|
||||
|
||||
it('Escalation email Field Show when proctoring backend is switched back to Proctortrack', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const proctoringBackendSelect = screen.getByDisplayValue('proctortrack');
|
||||
let selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'software_secure' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeNull();
|
||||
await act(async () => {
|
||||
fireEvent.change(proctoringBackendSelect, { target: { value: 'proctortrack' } });
|
||||
});
|
||||
expect(screen.queryByTestId('escalationEmail')).toBeDefined();
|
||||
selectEscalationEmailElement = screen.getByTestId('escalationEmail');
|
||||
expect(selectEscalationEmailElement.value).toEqual('test@example.com');
|
||||
});
|
||||
|
||||
it('Submits form when "Enter" key is hit in the escalation email field', async () => {
|
||||
await waitFor(() => {
|
||||
screen.getByDisplayValue('proctortrack');
|
||||
});
|
||||
const selectEscalationEmailElement = screen.getByDisplayValue('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectEscalationEmailElement, { target: { value: '' } });
|
||||
});
|
||||
await act(async () => {
|
||||
fireEvent.submit(selectEscalationEmailElement);
|
||||
});
|
||||
// if the error appears, the form has been submitted
|
||||
expect(screen.getByTestId('proctortrackEscalationEmailError')).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -687,11 +700,19 @@ describe('ProctoredExamSettings', () => {
|
||||
|
||||
it('Successfully updates exam configuration and studio provider is set to "lti_external" for lti providers', async () => {
|
||||
await act(async () => render(intlWrapper(<IntlProctoredExamSettings {...defaultProps} />)));
|
||||
// Make a change to the provider to proctortrack and set the email
|
||||
// Make a change to the provider to test_lti and set the email
|
||||
const selectElement = screen.getByDisplayValue('mockproc');
|
||||
await act(async () => {
|
||||
fireEvent.change(selectElement, { target: { value: 'test_lti' } });
|
||||
});
|
||||
|
||||
const escalationEmail = screen.getByTestId('escalationEmail');
|
||||
expect(escalationEmail.value).toEqual('test@example.com');
|
||||
await act(async () => {
|
||||
fireEvent.change(escalationEmail, { target: { value: 'test_lti@example.com' } });
|
||||
});
|
||||
expect(escalationEmail.value).toEqual('test_lti@example.com');
|
||||
|
||||
const submitButton = screen.getByTestId('submissionButton');
|
||||
await act(async () => {
|
||||
fireEvent.click(submitButton);
|
||||
@@ -701,6 +722,7 @@ describe('ProctoredExamSettings', () => {
|
||||
expect(axiosMock.history.patch.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
||||
provider: 'test_lti',
|
||||
escalation_email: 'test_lti@example.com',
|
||||
});
|
||||
|
||||
// update studio settings
|
||||
@@ -731,6 +753,7 @@ describe('ProctoredExamSettings', () => {
|
||||
expect(axiosMock.history.patch.length).toBe(1);
|
||||
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
||||
provider: null,
|
||||
escalation_email: null,
|
||||
});
|
||||
expect(axiosMock.history.patch.length).toBe(1);
|
||||
expect(axiosMock.history.post.length).toBe(1);
|
||||
|
||||
@@ -53,7 +53,7 @@ const messages = defineMessages({
|
||||
},
|
||||
'authoring.proctoring.escalationemail.label': {
|
||||
id: 'authoring.proctoring.escalationemail.label',
|
||||
defaultMessage: 'Proctortrack escalation email',
|
||||
defaultMessage: 'Escalation email',
|
||||
description: 'Label for escalation email text field',
|
||||
},
|
||||
'authoring.proctoring.escalationemail.help': {
|
||||
@@ -63,12 +63,12 @@ const messages = defineMessages({
|
||||
},
|
||||
'authoring.proctoring.escalationemail.error.blank': {
|
||||
id: 'authoring.proctoring.escalationemail.error.blank',
|
||||
defaultMessage: 'The Proctortrack Escalation Email field cannot be empty if proctortrack is the selected provider.',
|
||||
defaultMessage: 'The Escalation Email field cannot be empty if {proctoringProviderName} is the selected provider.',
|
||||
description: 'Error message for missing required email field.',
|
||||
},
|
||||
'authoring.proctoring.escalationemail.error.invalid': {
|
||||
id: 'authoring.proctoring.escalationemail.error.invalid',
|
||||
defaultMessage: 'The Proctortrack Escalation Email field is in the wrong format and is not valid.',
|
||||
defaultMessage: 'The Escalation Email field is in the wrong format and is not valid.',
|
||||
description: 'Error message for a invalid email format.',
|
||||
},
|
||||
'authoring.proctoring.allowoptout.label': {
|
||||
|
||||
@@ -1,14 +1,35 @@
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { StudioFooter } from '@edx/frontend-component-footer';
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import { Toast } from '@edx/paragon';
|
||||
|
||||
import Header from '../header';
|
||||
import { TaxonomyContext } from './common/context';
|
||||
|
||||
const TaxonomyLayout = () => (
|
||||
<div className="bg-light-400">
|
||||
<Header isHiddenMainMenu />
|
||||
<Outlet />
|
||||
<StudioFooter />
|
||||
</div>
|
||||
);
|
||||
const TaxonomyLayout = () => {
|
||||
// Use `setToastMessage` to show the toast.
|
||||
const [toastMessage, setToastMessage] = useState(null);
|
||||
|
||||
const context = useMemo(() => ({
|
||||
toastMessage, setToastMessage,
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<TaxonomyContext.Provider value={context}>
|
||||
<div className="bg-light-400">
|
||||
<Header isHiddenMainMenu />
|
||||
<Outlet />
|
||||
<StudioFooter />
|
||||
<Toast
|
||||
show={toastMessage !== null}
|
||||
onClose={() => setToastMessage(null)}
|
||||
data-testid="taxonomy-toast"
|
||||
>
|
||||
{toastMessage}
|
||||
</Toast>
|
||||
</div>
|
||||
</TaxonomyContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export default TaxonomyLayout;
|
||||
|
||||
@@ -2,13 +2,13 @@ import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { render } from '@testing-library/react';
|
||||
import { render, act } from '@testing-library/react';
|
||||
|
||||
import initializeStore from '../store';
|
||||
import TaxonomyLayout from './TaxonomyLayout';
|
||||
|
||||
let store;
|
||||
|
||||
const toastMessage = 'Hello, this is a toast!';
|
||||
jest.mock('../header', () => jest.fn(() => <div data-testid="mock-header" />));
|
||||
jest.mock('@edx/frontend-component-footer', () => ({
|
||||
StudioFooter: jest.fn(() => <div data-testid="mock-footer" />),
|
||||
@@ -17,6 +17,15 @@ jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
Outlet: jest.fn(() => <div data-testid="mock-content" />),
|
||||
}));
|
||||
jest.mock('react', () => ({
|
||||
...jest.requireActual('react'),
|
||||
useState: jest.fn((initial) => {
|
||||
if (initial === null) {
|
||||
return [toastMessage, jest.fn()];
|
||||
}
|
||||
return [initial, jest.fn()];
|
||||
}),
|
||||
}));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
@@ -45,4 +54,12 @@ describe('<TaxonomyLayout />', async () => {
|
||||
expect(getByTestId('mock-content')).toBeInTheDocument();
|
||||
expect(getByTestId('mock-footer')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show toast', async () => {
|
||||
const { getByTestId, getByText } = render(<RootWrapper />);
|
||||
act(() => {
|
||||
expect(getByTestId('taxonomy-toast')).toBeInTheDocument();
|
||||
expect(getByText(toastMessage)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useContext } from 'react';
|
||||
import {
|
||||
CardView,
|
||||
Container,
|
||||
@@ -7,21 +7,34 @@ import {
|
||||
} from '@edx/paragon';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Helmet } from 'react-helmet';
|
||||
|
||||
import SubHeader from '../generic/sub-header/SubHeader';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import messages from './messages';
|
||||
import TaxonomyCard from './taxonomy-card';
|
||||
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks';
|
||||
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded, useDeleteTaxonomy } from './data/apiHooks';
|
||||
import { TaxonomyContext } from './common/context';
|
||||
|
||||
const TaxonomyListPage = () => {
|
||||
const intl = useIntl();
|
||||
const deleteTaxonomy = useDeleteTaxonomy();
|
||||
const { setToastMessage } = useContext(TaxonomyContext);
|
||||
|
||||
const onDeleteTaxonomy = React.useCallback((id, name) => {
|
||||
deleteTaxonomy({ pk: id }, {
|
||||
onSuccess: async () => {
|
||||
setToastMessage(intl.formatMessage(messages.taxonomyDeleteToast, { name }));
|
||||
},
|
||||
onError: async () => {
|
||||
// TODO: display the error to the user
|
||||
},
|
||||
});
|
||||
}, [setToastMessage]);
|
||||
|
||||
const useTaxonomyListData = () => {
|
||||
const taxonomyListData = useTaxonomyListDataResponse();
|
||||
const isLoaded = useIsTaxonomyListDataLoaded();
|
||||
return { taxonomyListData, isLoaded };
|
||||
};
|
||||
|
||||
const { taxonomyListData, isLoaded } = useTaxonomyListData();
|
||||
|
||||
const getHeaderButtons = () => (
|
||||
@@ -70,11 +83,14 @@ const TaxonomyListPage = () => {
|
||||
{
|
||||
accessor: 'systemDefined',
|
||||
},
|
||||
{
|
||||
accessor: 'tagsCount',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<CardView
|
||||
className="bg-light-400 p-5"
|
||||
CardComponent={TaxonomyCard}
|
||||
CardComponent={(row) => TaxonomyCard({ ...row, onDeleteTaxonomy })}
|
||||
/>
|
||||
</DataTable>
|
||||
)}
|
||||
|
||||
@@ -1,28 +1,50 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { act, render } from '@testing-library/react';
|
||||
import { act, render, fireEvent } from '@testing-library/react';
|
||||
|
||||
import initializeStore from '../store';
|
||||
|
||||
import TaxonomyListPage from './TaxonomyListPage';
|
||||
import { useTaxonomyListDataResponse, useIsTaxonomyListDataLoaded } from './data/apiHooks';
|
||||
import { TaxonomyContext } from './common/context';
|
||||
|
||||
let store;
|
||||
const mockSetToastMessage = jest.fn();
|
||||
const mockDeleteTaxonomy = jest.fn();
|
||||
const taxonomies = [{
|
||||
id: 1,
|
||||
name: 'Taxonomy',
|
||||
description: 'This is a description',
|
||||
}];
|
||||
|
||||
jest.mock('./data/apiHooks', () => ({
|
||||
useTaxonomyListDataResponse: jest.fn(),
|
||||
useIsTaxonomyListDataLoaded: jest.fn(),
|
||||
useDeleteTaxonomy: () => mockDeleteTaxonomy,
|
||||
}));
|
||||
jest.mock('./taxonomy-card/TaxonomyCardMenu', () => jest.fn(({ onClickMenuItem }) => (
|
||||
// eslint-disable-next-line jsx-a11y/control-has-associated-label
|
||||
<button type="button" data-testid="test-delete-button" onClick={() => onClickMenuItem('delete')} />
|
||||
)));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<TaxonomyListPage intl={injectIntl} />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
const RootWrapper = () => {
|
||||
const context = useMemo(() => ({
|
||||
toastMessage: null,
|
||||
setToastMessage: mockSetToastMessage,
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<TaxonomyContext.Provider value={context}>
|
||||
<TaxonomyListPage intl={injectIntl} />
|
||||
</TaxonomyContext.Provider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('<TaxonomyListPage />', async () => {
|
||||
beforeEach(async () => {
|
||||
@@ -54,15 +76,28 @@ describe('<TaxonomyListPage />', async () => {
|
||||
it('shows the data table after the query is complete', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(true);
|
||||
useTaxonomyListDataResponse.mockReturnValue({
|
||||
results: [{
|
||||
id: 1,
|
||||
name: 'Taxonomy',
|
||||
description: 'This is a description',
|
||||
}],
|
||||
results: taxonomies,
|
||||
});
|
||||
await act(async () => {
|
||||
const { getByTestId } = render(<RootWrapper />);
|
||||
expect(getByTestId('taxonomy-card-1')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show the success toast after delete', async () => {
|
||||
useIsTaxonomyListDataLoaded.mockReturnValue(true);
|
||||
useTaxonomyListDataResponse.mockReturnValue({
|
||||
results: taxonomies,
|
||||
});
|
||||
mockDeleteTaxonomy.mockImplementationOnce(async (params, callbacks) => {
|
||||
callbacks.onSuccess();
|
||||
});
|
||||
const { getByTestId, getByLabelText } = render(<RootWrapper />);
|
||||
fireEvent.click(getByTestId('test-delete-button'));
|
||||
fireEvent.change(getByLabelText('Type DELETE to confirm'), { target: { value: 'DELETE' } });
|
||||
fireEvent.click(getByTestId('delete-button'));
|
||||
|
||||
expect(mockDeleteTaxonomy).toBeCalledTimes(1);
|
||||
expect(mockSetToastMessage).toBeCalledWith(`"${taxonomies[0].name}" deleted`);
|
||||
});
|
||||
});
|
||||
|
||||
7
src/taxonomy/common/context.js
Normal file
7
src/taxonomy/common/context.js
Normal file
@@ -0,0 +1,7 @@
|
||||
/* eslint-disable import/prefer-default-export */
|
||||
import React from 'react';
|
||||
|
||||
export const TaxonomyContext = React.createContext({
|
||||
toastMessage: null,
|
||||
setToastMessage: null,
|
||||
});
|
||||
@@ -18,17 +18,27 @@ export const getExportTaxonomyApiUrl = (pk, format) => new URL(
|
||||
`api/content_tagging/v1/taxonomies/${pk}/export/?output_format=${format}&download=1`,
|
||||
getApiBaseUrl(),
|
||||
).href;
|
||||
export const getTaxonomyApiUrl = (pk) => new URL(`api/content_tagging/v1/taxonomies/${pk}/`, getApiBaseUrl()).href;
|
||||
|
||||
/**
|
||||
* Get list of taxonomies.
|
||||
* @param {string} org Optioanl organization query param
|
||||
* @returns {Promise<Object>}
|
||||
* @returns {Promise<import("./types.mjs").TaxonomyListData>}
|
||||
*/
|
||||
export async function getTaxonomyListData(org) {
|
||||
const { data } = await getAuthenticatedHttpClient().get(getTaxonomyListApiUrl(org));
|
||||
return camelCaseObject(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a Taxonomy
|
||||
* @param {number} pk
|
||||
* @returns {Promise<Object>}
|
||||
*/
|
||||
export async function deleteTaxonomy(pk) {
|
||||
await getAuthenticatedHttpClient().delete(getTaxonomyApiUrl(pk));
|
||||
}
|
||||
|
||||
/**
|
||||
* Downloads the file of the exported taxonomy
|
||||
* @param {number} pk
|
||||
|
||||
@@ -9,6 +9,8 @@ import {
|
||||
getExportTaxonomyApiUrl,
|
||||
getTaxonomyListData,
|
||||
getTaxonomyExportFile,
|
||||
getTaxonomyApiUrl,
|
||||
deleteTaxonomy,
|
||||
} from './api';
|
||||
|
||||
let axiosMock;
|
||||
@@ -59,6 +61,13 @@ describe('taxonomy api calls', () => {
|
||||
expect(result).toEqual(taxonomyListMock);
|
||||
});
|
||||
|
||||
it('should delete a taxonomy', async () => {
|
||||
axiosMock.onDelete(getTaxonomyApiUrl()).reply(200);
|
||||
await deleteTaxonomy();
|
||||
|
||||
expect(axiosMock.history.delete[0].url).toEqual(getTaxonomyApiUrl());
|
||||
});
|
||||
|
||||
it('should set window.location.href correctly', () => {
|
||||
const pk = 1;
|
||||
const format = 'json';
|
||||
|
||||
@@ -11,13 +11,12 @@
|
||||
* - Hooks that calls the query hook, prepare and return the data.
|
||||
* Ex. useTaxonomyListDataResponse & useIsTaxonomyListDataLoaded.
|
||||
*/
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getTaxonomyListData } from './api';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getTaxonomyListData, deleteTaxonomy } from './api';
|
||||
|
||||
/**
|
||||
* Builds the query to get the taxonomy list
|
||||
* @param {string} org Optional organization query param
|
||||
* @returns {import("./types.mjs").UseQueryResult}
|
||||
*/
|
||||
const useTaxonomyListData = (org) => (
|
||||
useQuery({
|
||||
@@ -26,6 +25,22 @@ const useTaxonomyListData = (org) => (
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* Builds the mutation to delete a taxonomy.
|
||||
* @returns An object with the mutation configuration.
|
||||
*/
|
||||
export const useDeleteTaxonomy = () => {
|
||||
const queryClient = useQueryClient();
|
||||
const { mutate } = useMutation({
|
||||
/** @type {import("@tanstack/react-query").MutateFunction<any, any, {pk: number}>} */
|
||||
mutationFn: async ({ pk }) => deleteTaxonomy(pk),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['taxonomyList'] });
|
||||
},
|
||||
});
|
||||
return mutate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the taxonomy list data
|
||||
* @param {string} org Optional organization query param
|
||||
@@ -34,7 +49,7 @@ const useTaxonomyListData = (org) => (
|
||||
export const useTaxonomyListDataResponse = (org) => {
|
||||
const response = useTaxonomyListData(org);
|
||||
if (response.status === 'success') {
|
||||
return response.data;
|
||||
return { ...response.data, refetch: response.refetch };
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
@@ -1,13 +1,26 @@
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { act } from '@testing-library/react';
|
||||
import {
|
||||
useTaxonomyListDataResponse,
|
||||
useIsTaxonomyListDataLoaded,
|
||||
useDeleteTaxonomy,
|
||||
} from './apiHooks';
|
||||
import { deleteTaxonomy } from './api';
|
||||
|
||||
jest.mock('@tanstack/react-query', () => ({
|
||||
useQuery: jest.fn(),
|
||||
useMutation: jest.fn(),
|
||||
useQueryClient: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('./api', () => ({
|
||||
deleteTaxonomy: jest.fn(),
|
||||
}));
|
||||
|
||||
/*
|
||||
* TODO: We can refactor this test: Mock the API response using axiosMock.
|
||||
* Ref: https://github.com/openedx/frontend-app-course-authoring/pull/684#issuecomment-1847694090
|
||||
*/
|
||||
describe('useTaxonomyListDataResponse', () => {
|
||||
it('should return data when status is success', () => {
|
||||
useQuery.mockReturnValueOnce({ status: 'success', data: { data: 'data' } });
|
||||
@@ -43,3 +56,22 @@ describe('useIsTaxonomyListDataLoaded', () => {
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('useDeleteTaxonomy', () => {
|
||||
it('should call the delete function', async () => {
|
||||
useMutation.mockReturnValueOnce({ mutate: jest.fn() });
|
||||
|
||||
const mutation = useDeleteTaxonomy();
|
||||
mutation();
|
||||
|
||||
expect(useMutation).toBeCalled();
|
||||
|
||||
const [config] = useMutation.mock.calls[0];
|
||||
const { mutationFn } = config;
|
||||
|
||||
await act(async () => {
|
||||
await mutationFn({ pk: 1 });
|
||||
expect(deleteTaxonomy).toBeCalledWith(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,16 +19,7 @@
|
||||
* @property {number} num_pages
|
||||
* @property {number} current_page
|
||||
* @property {number} start
|
||||
* @property {function} refetch
|
||||
* @property {TaxonomyData[]} results
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} QueryTaxonomyListData
|
||||
* @property {TaxonomyListData} data
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} UseQueryResult
|
||||
* @property {Object} data
|
||||
* @property {string} status
|
||||
*/
|
||||
|
||||
6
src/taxonomy/delete-dialog/DeleteDialog.scss
Normal file
6
src/taxonomy/delete-dialog/DeleteDialog.scss
Normal file
@@ -0,0 +1,6 @@
|
||||
.taxonomy-delete-dialog {
|
||||
.warning-icon {
|
||||
top: 5px;
|
||||
right: 5px;
|
||||
}
|
||||
}
|
||||
99
src/taxonomy/delete-dialog/index.jsx
Normal file
99
src/taxonomy/delete-dialog/index.jsx
Normal file
@@ -0,0 +1,99 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
ActionRow,
|
||||
Button,
|
||||
Form,
|
||||
ModalDialog,
|
||||
Icon,
|
||||
} from '@edx/paragon';
|
||||
import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Warning } from '@edx/paragon/icons';
|
||||
import messages from './messages';
|
||||
|
||||
const DeleteDialog = ({
|
||||
taxonomyName,
|
||||
tagsCount,
|
||||
isOpen,
|
||||
onClose,
|
||||
onDelete,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [deleteButtonDisabled, setDeleteButtonDisabled] = useState(true);
|
||||
const deleteLabel = intl.formatMessage(messages.deleteDialogConfirmDeleteLabel);
|
||||
|
||||
const handleInputChange = React.useCallback((event) => {
|
||||
if (event.target.value === deleteLabel) {
|
||||
setDeleteButtonDisabled(false);
|
||||
} else {
|
||||
setDeleteButtonDisabled(true);
|
||||
}
|
||||
});
|
||||
|
||||
const onClickDelete = React.useCallback(() => {
|
||||
onClose();
|
||||
onDelete();
|
||||
}, [onClose, onDelete]);
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
title={intl.formatMessage(messages.deleteDialogTitle, { taxonomyName })}
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
size="md"
|
||||
hasCloseButton={false}
|
||||
variant="warning"
|
||||
className="taxonomy-delete-dialog"
|
||||
>
|
||||
<ModalDialog.Header>
|
||||
<ModalDialog.Title>
|
||||
<Icon src={Warning} className="d-inline-block text-warning warning-icon" />
|
||||
{intl.formatMessage(messages.deleteDialogTitle, { taxonomyName })}
|
||||
</ModalDialog.Title>
|
||||
</ModalDialog.Header>
|
||||
<ModalDialog.Body>
|
||||
<div className="mb-4">
|
||||
{/* Delete `(?)` after implement get tags count of a taxonomy */}
|
||||
{intl.formatMessage(messages.deleteDialogBody, {
|
||||
tagsCount: tagsCount !== undefined ? tagsCount : '(?)',
|
||||
})}
|
||||
</div>
|
||||
<Form.Group>
|
||||
<Form.Label>
|
||||
{intl.formatMessage(messages.deleteDialogConfirmLabel, {
|
||||
deleteLabel: <b>{deleteLabel}</b>,
|
||||
})}
|
||||
</Form.Label>
|
||||
<Form.Control
|
||||
onChange={handleInputChange}
|
||||
/>
|
||||
</Form.Group>
|
||||
</ModalDialog.Body>
|
||||
<ModalDialog.Footer>
|
||||
<ActionRow>
|
||||
<ModalDialog.CloseButton variant="tertiary">
|
||||
{intl.formatMessage(messages.deleteDialogCancelLabel)}
|
||||
</ModalDialog.CloseButton>
|
||||
<Button
|
||||
variant="primary"
|
||||
disabled={deleteButtonDisabled}
|
||||
onClick={onClickDelete}
|
||||
data-testid="delete-button"
|
||||
>
|
||||
{intl.formatMessage(messages.deleteDialogDeleteLabel)}
|
||||
</Button>
|
||||
</ActionRow>
|
||||
</ModalDialog.Footer>
|
||||
</ModalDialog>
|
||||
);
|
||||
};
|
||||
|
||||
DeleteDialog.propTypes = {
|
||||
taxonomyName: PropTypes.string.isRequired,
|
||||
tagsCount: PropTypes.number.isRequired,
|
||||
isOpen: PropTypes.bool.isRequired,
|
||||
onClose: PropTypes.func.isRequired,
|
||||
onDelete: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default DeleteDialog;
|
||||
30
src/taxonomy/delete-dialog/messages.js
Normal file
30
src/taxonomy/delete-dialog/messages.js
Normal file
@@ -0,0 +1,30 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
deleteDialogTitle: {
|
||||
id: 'course-authoring.taxonomy-list.dialog.delete.title',
|
||||
defaultMessage: 'Delete "{taxonomyName}"',
|
||||
},
|
||||
deleteDialogBody: {
|
||||
id: 'course-authoring.taxonomy-list.dialog.delete.body',
|
||||
defaultMessage: 'Warning! You are about to delete a taxonomy containing {tagsCount} tags. Assigned organizations will no longer be able to access the taxonomy, and any tags applied to course content will be removed.',
|
||||
},
|
||||
deleteDialogConfirmLabel: {
|
||||
id: 'course-authoring.taxonomy-list.dialog.delete.confirm.label',
|
||||
defaultMessage: 'Type {deleteLabel} to confirm',
|
||||
},
|
||||
deleteDialogConfirmDeleteLabel: {
|
||||
id: 'course-authoring.taxonomy-list.dialog.delete.confirmDelete.label',
|
||||
defaultMessage: 'DELETE',
|
||||
},
|
||||
deleteDialogCancelLabel: {
|
||||
id: 'course-authoring.taxonomy-list.dialog.delete.cancel.label',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
deleteDialogDeleteLabel: {
|
||||
id: 'course-authoring.taxonomy-list.dialog.delete.delete.label',
|
||||
defaultMessage: 'Delete',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -18,10 +18,10 @@ const ExportModal = ({
|
||||
const intl = useIntl();
|
||||
const [outputFormat, setOutputFormat] = useState('csv');
|
||||
|
||||
const onClickExport = () => {
|
||||
const onClickExport = React.useCallback(() => {
|
||||
onClose();
|
||||
getTaxonomyExportFile(taxonomyId, outputFormat);
|
||||
};
|
||||
}, [onClose, taxonomyId, outputFormat]);
|
||||
|
||||
return (
|
||||
<ModalDialog
|
||||
|
||||
2
src/taxonomy/index.scss
Normal file
2
src/taxonomy/index.scss
Normal file
@@ -0,0 +1,2 @@
|
||||
@import "taxonomy/taxonomy-card/TaxonomyCard";
|
||||
@import "taxonomy/delete-dialog/DeleteDialog";
|
||||
@@ -21,6 +21,10 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.taxonomy-list.spinner.loading',
|
||||
defaultMessage: 'Loading',
|
||||
},
|
||||
taxonomyDeleteToast: {
|
||||
id: 'course-authoring.taxonomy-list.toast.delete',
|
||||
defaultMessage: '"{name}" deleted',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider, injectIntl } from '@edx/frontend-platform/i18n';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
import { render, fireEvent } from '@testing-library/react';
|
||||
@@ -11,6 +11,7 @@ import TaxonomyCard from '.';
|
||||
|
||||
let store;
|
||||
const taxonomyId = 1;
|
||||
const onDeleteTaxonomy = jest.fn();
|
||||
|
||||
const data = {
|
||||
id: taxonomyId,
|
||||
@@ -25,7 +26,10 @@ jest.mock('../data/api', () => ({
|
||||
const TaxonomyCardComponent = ({ original }) => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<TaxonomyCard intl={injectIntl} original={original} />
|
||||
<TaxonomyCard
|
||||
original={original}
|
||||
onDeleteTaxonomy={onDeleteTaxonomy}
|
||||
/>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
@@ -37,6 +41,7 @@ TaxonomyCardComponent.propTypes = {
|
||||
description: PropTypes.string,
|
||||
systemDefined: PropTypes.bool,
|
||||
orgsCount: PropTypes.number,
|
||||
onDeleteTaxonomy: PropTypes.func,
|
||||
}).isRequired,
|
||||
};
|
||||
|
||||
@@ -147,4 +152,51 @@ describe('<TaxonomyCard />', async () => {
|
||||
expect(() => getByText('Select format to export')).toThrow();
|
||||
expect(getTaxonomyExportFile).toHaveBeenCalledWith(taxonomyId, 'json');
|
||||
});
|
||||
|
||||
test('should open delete dialog on delete menu click', () => {
|
||||
const { getByTestId, getByText } = render(<TaxonomyCardComponent original={data} />);
|
||||
|
||||
// Modal closed
|
||||
expect(() => getByText(`Delete "${data.name}"`)).toThrow();
|
||||
|
||||
// Click on delete menu
|
||||
fireEvent.click(getByTestId('taxonomy-card-menu-button-1'));
|
||||
fireEvent.click(getByText('Delete'));
|
||||
|
||||
// Modal opened
|
||||
expect(getByText(`Delete "${data.name}"`)).toBeInTheDocument();
|
||||
|
||||
// Click on cancel button
|
||||
fireEvent.click(getByText('Cancel'));
|
||||
|
||||
// Modal closed
|
||||
expect(() => getByText(`Delete "${data.name}"`)).toThrow();
|
||||
});
|
||||
|
||||
test('should delete a taxonomy', () => {
|
||||
const { getByTestId, getByText, getByLabelText } = render(<TaxonomyCardComponent original={data} />);
|
||||
|
||||
// Click on delete menu
|
||||
fireEvent.click(getByTestId('taxonomy-card-menu-button-1'));
|
||||
fireEvent.click(getByText('Delete'));
|
||||
|
||||
const deleteButton = getByTestId('delete-button');
|
||||
|
||||
// The delete button must to be disabled
|
||||
expect(deleteButton).toBeDisabled();
|
||||
|
||||
// Testing delete button enabled/disabled changes
|
||||
const input = getByLabelText('Type DELETE to confirm');
|
||||
fireEvent.change(input, { target: { value: 'DELETE_INVALID' } });
|
||||
expect(deleteButton).toBeDisabled();
|
||||
fireEvent.change(input, { target: { value: 'DELETE' } });
|
||||
expect(deleteButton).toBeEnabled();
|
||||
|
||||
// Click on delete button
|
||||
fireEvent.click(deleteButton);
|
||||
|
||||
// Modal closed
|
||||
expect(() => getByText(`Delete "${data.name}"`)).toThrow();
|
||||
expect(onDeleteTaxonomy).toHaveBeenCalledWith(taxonomyId, data.name);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -9,12 +9,17 @@ import PropTypes from 'prop-types';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
|
||||
const menuMessages = {
|
||||
export: messages.taxonomyCardExportMenu,
|
||||
delete: messages.taxonomyCardDeleteMenu,
|
||||
};
|
||||
|
||||
const TaxonomyCardMenu = ({
|
||||
id, name, onClickMenuItem,
|
||||
id, name, onClickMenuItem, disabled, menuItems,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const onClickItem = (e, menuName) => {
|
||||
const onClickItem = (menuName) => (e) => {
|
||||
e.preventDefault();
|
||||
onClickMenuItem(menuName);
|
||||
};
|
||||
@@ -29,16 +34,18 @@ const TaxonomyCardMenu = ({
|
||||
alt={intl.formatMessage(messages.taxonomyMenuAlt, { name })}
|
||||
id={`taxonomy-card-menu-button-${id}`}
|
||||
data-testid={`taxonomy-card-menu-button-${id}`}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Dropdown.Menu data-testid={`taxonomy-card-menu-${id}`}>
|
||||
{/* Add more menu items here */}
|
||||
<Dropdown.Item
|
||||
className="taxonomy-menu-item"
|
||||
data-testid={`taxonomy-card-menu-export-${id}`}
|
||||
onClick={(e) => onClickItem(e, 'export')}
|
||||
>
|
||||
{intl.formatMessage(messages.taxonomyCardExportMenu)}
|
||||
</Dropdown.Item>
|
||||
{ menuItems.map(item => (
|
||||
<Dropdown.Item
|
||||
className="taxonomy-menu-item"
|
||||
data-testid={`taxonomy-card-menu-${item}-${id}`}
|
||||
onClick={onClickItem(item)}
|
||||
>
|
||||
{intl.formatMessage(menuMessages[item])}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
);
|
||||
@@ -48,6 +55,8 @@ TaxonomyCardMenu.propTypes = {
|
||||
id: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
onClickMenuItem: PropTypes.func.isRequired,
|
||||
disabled: PropTypes.bool.isRequired,
|
||||
menuItems: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
};
|
||||
|
||||
export default TaxonomyCardMenu;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useState } from 'react';
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
@@ -12,6 +12,7 @@ import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import messages from './messages';
|
||||
import TaxonomyCardMenu from './TaxonomyCardMenu';
|
||||
import ExportModal from '../export-modal';
|
||||
import DeleteDialog from '../delete-dialog';
|
||||
|
||||
const orgsCountEnabled = (orgsCount) => orgsCount !== undefined && orgsCount !== 0;
|
||||
|
||||
@@ -64,38 +65,50 @@ HeaderSubtitle.propTypes = {
|
||||
orgsCount: PropTypes.number.isRequired,
|
||||
};
|
||||
|
||||
const TaxonomyCard = ({ className, original }) => {
|
||||
const TaxonomyCard = ({ className, original, onDeleteTaxonomy }) => {
|
||||
const {
|
||||
id, name, description, systemDefined, orgsCount,
|
||||
id, name, description, systemDefined, orgsCount, tagsCount,
|
||||
} = original;
|
||||
|
||||
const intl = useIntl();
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const [isMenuEnalbed, setIsMenuEnabled] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
// Resets the card to the initial state
|
||||
setIsMenuEnabled(true);
|
||||
}, [id]);
|
||||
|
||||
// Add here more menu item actions
|
||||
const menuItemActions = {
|
||||
export: () => setIsExportModalOpen(true),
|
||||
delete: () => setIsDeleteDialogOpen(true),
|
||||
};
|
||||
const menuItems = ['export', 'delete'];
|
||||
const systemDefinedMenuItems = ['export'];
|
||||
|
||||
const onClickMenuItem = (menuName) => (
|
||||
menuItemActions[menuName]?.()
|
||||
);
|
||||
|
||||
const onClickDeleteTaxonomy = () => {
|
||||
setIsMenuEnabled(false);
|
||||
onDeleteTaxonomy(id, name);
|
||||
};
|
||||
|
||||
const getHeaderActions = () => {
|
||||
let enabledMenuItems = menuItems;
|
||||
if (systemDefined) {
|
||||
// We don't show the export menu, because the system-taxonomies
|
||||
// can't be exported. The API returns and error.
|
||||
// The entire menu has been hidden because currently only
|
||||
// the export menu exists.
|
||||
//
|
||||
// TODO When adding more menus, change this logic to hide only the export menu.
|
||||
return undefined;
|
||||
enabledMenuItems = systemDefinedMenuItems;
|
||||
}
|
||||
return (
|
||||
<TaxonomyCardMenu
|
||||
id={id}
|
||||
name={name}
|
||||
onClickMenuItem={onClickMenuItem}
|
||||
disabled={!isMenuEnalbed}
|
||||
menuItems={enabledMenuItems}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -108,6 +121,16 @@ const TaxonomyCard = ({ className, original }) => {
|
||||
/>
|
||||
);
|
||||
|
||||
const renderDeleteDialog = () => isDeleteDialogOpen && (
|
||||
<DeleteDialog
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onClose={() => setIsDeleteDialogOpen(false)}
|
||||
onDelete={onClickDeleteTaxonomy}
|
||||
taxonomyName={name}
|
||||
tagsCount={tagsCount}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card
|
||||
@@ -140,6 +163,7 @@ const TaxonomyCard = ({ className, original }) => {
|
||||
</Card.Body>
|
||||
</Card>
|
||||
{renderExportModal()}
|
||||
{renderDeleteDialog()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -156,7 +180,9 @@ TaxonomyCard.propTypes = {
|
||||
description: PropTypes.string,
|
||||
systemDefined: PropTypes.bool,
|
||||
orgsCount: PropTypes.number,
|
||||
tagsCount: PropTypes.number,
|
||||
}).isRequired,
|
||||
onDeleteTaxonomy: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export default TaxonomyCard;
|
||||
|
||||
@@ -21,6 +21,10 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.taxonomy-list.menu.export.label',
|
||||
defaultMessage: 'Export',
|
||||
},
|
||||
taxonomyCardDeleteMenu: {
|
||||
id: 'course-authoring.taxonomy-list.menu.delete.label',
|
||||
defaultMessage: 'Delete',
|
||||
},
|
||||
taxonomyMenuAlt: {
|
||||
id: 'course-authoring.taxonomy-list.menu.alt',
|
||||
defaultMessage: '{name} menu',
|
||||
|
||||
@@ -8,8 +8,13 @@ import PropTypes from 'prop-types';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
const menuMessages = {
|
||||
export: messages.exportMenu,
|
||||
delete: messages.deleteMenu,
|
||||
};
|
||||
|
||||
const TaxonomyDetailMenu = ({
|
||||
id, name, disabled, onClickMenuItem,
|
||||
id, name, disabled, onClickMenuItem, menuItems,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
@@ -20,9 +25,13 @@ const TaxonomyDetailMenu = ({
|
||||
alt={intl.formatMessage(messages.actionsButtonAlt, { name })}
|
||||
disabled={disabled}
|
||||
>
|
||||
<Dropdown.Item onClick={() => onClickMenuItem('export')}>
|
||||
{intl.formatMessage(messages.exportMenu)}
|
||||
</Dropdown.Item>
|
||||
{ menuItems.map(item => (
|
||||
<Dropdown.Item
|
||||
onClick={() => onClickMenuItem(item)}
|
||||
>
|
||||
{intl.formatMessage(menuMessages[item])}
|
||||
</Dropdown.Item>
|
||||
))}
|
||||
</DropdownButton>
|
||||
);
|
||||
};
|
||||
@@ -32,6 +41,7 @@ TaxonomyDetailMenu.propTypes = {
|
||||
name: PropTypes.string.isRequired,
|
||||
disabled: PropTypes.bool,
|
||||
onClickMenuItem: PropTypes.func.isRequired,
|
||||
menuItems: PropTypes.arrayOf(PropTypes.string).isRequired,
|
||||
};
|
||||
|
||||
TaxonomyDetailMenu.defaultProps = {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// ts-check
|
||||
import React, { useState } from 'react';
|
||||
import React, { useContext, useState } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Breadcrumb,
|
||||
@@ -7,7 +6,7 @@ import {
|
||||
Layout,
|
||||
} from '@edx/paragon';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import { Link, useParams } from 'react-router-dom';
|
||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||
|
||||
import ConnectionErrorAlert from '../../generic/ConnectionErrorAlert';
|
||||
import Loading from '../../generic/Loading';
|
||||
@@ -19,16 +18,42 @@ import TaxonomyDetailSideCard from './TaxonomyDetailSideCard';
|
||||
import { TagListTable } from '../tag-list';
|
||||
import ExportModal from '../export-modal';
|
||||
import { useTaxonomyDetailDataResponse, useTaxonomyDetailDataStatus } from './data/apiHooks';
|
||||
import DeleteDialog from '../delete-dialog';
|
||||
import { useDeleteTaxonomy } from '../data/apiHooks';
|
||||
import { TaxonomyContext } from '../common/context';
|
||||
|
||||
const TaxonomyDetailPage = () => {
|
||||
const intl = useIntl();
|
||||
const { taxonomyId: taxonomyIdString } = useParams();
|
||||
const { setToastMessage } = useContext(TaxonomyContext);
|
||||
const taxonomyId = Number(taxonomyIdString);
|
||||
|
||||
const taxonomy = useTaxonomyDetailDataResponse(taxonomyId);
|
||||
const { isError, isFetched } = useTaxonomyDetailDataStatus(taxonomyId);
|
||||
|
||||
const [isExportModalOpen, setIsExportModalOpen] = useState(false);
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
|
||||
const deleteTaxonomy = useDeleteTaxonomy();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const onClickDeleteTaxonomy = React.useCallback(() => {
|
||||
deleteTaxonomy({ pk: taxonomy.id }, {
|
||||
onSuccess: async () => {
|
||||
setToastMessage(intl.formatMessage(taxonomyMessages.taxonomyDeleteToast, { name: taxonomy.name }));
|
||||
navigate('/taxonomies');
|
||||
},
|
||||
onError: async () => {
|
||||
// TODO: display the error to the user
|
||||
},
|
||||
});
|
||||
}, [setToastMessage, taxonomy]);
|
||||
|
||||
const menuItems = ['export', 'delete'];
|
||||
const systemDefinedMenuItems = ['export'];
|
||||
const menuItemActions = {
|
||||
export: () => setIsExportModalOpen(true),
|
||||
delete: () => setIsDeleteDialogOpen(true),
|
||||
};
|
||||
|
||||
if (!isFetched) {
|
||||
return (
|
||||
@@ -47,36 +72,38 @@ const TaxonomyDetailPage = () => {
|
||||
isOpen={isExportModalOpen}
|
||||
onClose={() => setIsExportModalOpen(false)}
|
||||
taxonomyId={taxonomy.id}
|
||||
/>
|
||||
);
|
||||
|
||||
const renderDeleteDialog = () => isDeleteDialogOpen && (
|
||||
<DeleteDialog
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onClose={() => setIsDeleteDialogOpen(false)}
|
||||
onDelete={onClickDeleteTaxonomy}
|
||||
taxonomyName={taxonomy.name}
|
||||
tagsCount={0}
|
||||
/>
|
||||
);
|
||||
|
||||
const onClickMenuItem = (menuName) => {
|
||||
switch (menuName) {
|
||||
case 'export':
|
||||
setIsExportModalOpen(true);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
const onClickMenuItem = (menuName) => (
|
||||
menuItemActions[menuName]?.()
|
||||
);
|
||||
|
||||
const getHeaderActions = () => {
|
||||
let enabledMenuItems = menuItems;
|
||||
if (taxonomy.systemDefined) {
|
||||
enabledMenuItems = systemDefinedMenuItems;
|
||||
}
|
||||
return (
|
||||
<TaxonomyDetailMenu
|
||||
id={taxonomy.id}
|
||||
name={taxonomy.name}
|
||||
onClickMenuItem={onClickMenuItem}
|
||||
menuItems={enabledMenuItems}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
const getHeaderActions = () => (
|
||||
<TaxonomyDetailMenu
|
||||
id={taxonomy.id}
|
||||
name={taxonomy.name}
|
||||
disabled={
|
||||
// We don't show the export menu, because the system-taxonomies
|
||||
// can't be exported. The API returns and error.
|
||||
// The entire menu has been disabled because currently only
|
||||
// the export menu exists.
|
||||
// ToDo: When adding more menus, change this logic to hide only the export menu.
|
||||
taxonomy.systemDefined
|
||||
}
|
||||
onClickMenuItem={onClickMenuItem}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Helmet>
|
||||
@@ -117,6 +144,7 @@ const TaxonomyDetailPage = () => {
|
||||
</Container>
|
||||
</div>
|
||||
{renderModals()}
|
||||
{renderDeleteDialog()}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React from 'react';
|
||||
import React, { useMemo } from 'react';
|
||||
import { initializeMockApp } from '@edx/frontend-platform';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { AppProvider } from '@edx/frontend-platform/react';
|
||||
@@ -7,8 +7,12 @@ import { fireEvent, render } from '@testing-library/react';
|
||||
import { useTaxonomyDetailData } from './data/api';
|
||||
import initializeStore from '../../store';
|
||||
import TaxonomyDetailPage from './TaxonomyDetailPage';
|
||||
import { TaxonomyContext } from '../common/context';
|
||||
|
||||
let store;
|
||||
const mockNavigate = jest.fn();
|
||||
const mockMutate = jest.fn();
|
||||
const mockSetToastMessage = jest.fn();
|
||||
|
||||
jest.mock('./data/api', () => ({
|
||||
useTaxonomyDetailData: jest.fn(),
|
||||
@@ -18,18 +22,31 @@ jest.mock('react-router-dom', () => ({
|
||||
useParams: () => ({
|
||||
taxonomyId: '1',
|
||||
}),
|
||||
useNavigate: () => mockNavigate,
|
||||
}));
|
||||
jest.mock('../data/apiHooks', () => ({
|
||||
useDeleteTaxonomy: () => mockMutate,
|
||||
}));
|
||||
|
||||
jest.mock('./TaxonomyDetailSideCard', () => jest.fn(() => <>Mock TaxonomyDetailSideCard</>));
|
||||
jest.mock('../tag-list/TagListTable', () => jest.fn(() => <>Mock TagListTable</>));
|
||||
|
||||
const RootWrapper = () => (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<TaxonomyDetailPage />
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
const RootWrapper = () => {
|
||||
const context = useMemo(() => ({
|
||||
toastMessage: null,
|
||||
setToastMessage: mockSetToastMessage,
|
||||
}), []);
|
||||
|
||||
return (
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en" messages={{}}>
|
||||
<TaxonomyContext.Provider value={context}>
|
||||
<TaxonomyDetailPage />
|
||||
</TaxonomyContext.Provider>
|
||||
</IntlProvider>
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
|
||||
describe('<TaxonomyDetailPage />', async () => {
|
||||
beforeEach(async () => {
|
||||
@@ -71,7 +88,7 @@ describe('<TaxonomyDetailPage />', async () => {
|
||||
id: 1,
|
||||
name: 'Test taxonomy',
|
||||
description: 'This is a description',
|
||||
systemDefined: false,
|
||||
systemDefined: true,
|
||||
},
|
||||
});
|
||||
const { getByRole } = render(<RootWrapper />);
|
||||
@@ -108,4 +125,83 @@ describe('<TaxonomyDetailPage />', async () => {
|
||||
// Modal closed
|
||||
expect(() => getByText('Select format to export')).toThrow();
|
||||
});
|
||||
|
||||
it('should open delete dialog on delete menu click', () => {
|
||||
const taxonomyName = 'Test taxonomy';
|
||||
|
||||
useTaxonomyDetailData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
isFetched: true,
|
||||
isError: false,
|
||||
data: {
|
||||
id: 1,
|
||||
name: taxonomyName,
|
||||
description: 'This is a description',
|
||||
},
|
||||
});
|
||||
|
||||
const { getByRole, getByText } = render(<RootWrapper />);
|
||||
|
||||
// Modal closed
|
||||
expect(() => getByText(`Delete "${taxonomyName}"`)).toThrow();
|
||||
|
||||
// Click on delete menu
|
||||
fireEvent.click(getByRole('button'));
|
||||
fireEvent.click(getByText('Delete'));
|
||||
|
||||
// Modal opened
|
||||
expect(getByText(`Delete "${taxonomyName}"`)).toBeInTheDocument();
|
||||
|
||||
// Click on cancel button
|
||||
fireEvent.click(getByText('Cancel'));
|
||||
|
||||
// Modal closed
|
||||
expect(() => getByText(`Delete "${taxonomyName}"`)).toThrow();
|
||||
});
|
||||
|
||||
it('should delete a taxonomy', () => {
|
||||
const taxonomyName = 'Test taxonomy';
|
||||
|
||||
useTaxonomyDetailData.mockReturnValue({
|
||||
isSuccess: true,
|
||||
isFetched: true,
|
||||
isError: false,
|
||||
data: {
|
||||
id: 1,
|
||||
name: taxonomyName,
|
||||
description: 'This is a description',
|
||||
},
|
||||
});
|
||||
mockMutate.mockImplementationOnce(async (params, callbacks) => {
|
||||
callbacks.onSuccess();
|
||||
});
|
||||
|
||||
const {
|
||||
getByRole, getByText, getByLabelText, getByTestId,
|
||||
} = render(<RootWrapper />);
|
||||
|
||||
// Modal closed
|
||||
expect(() => getByText(`Delete "${taxonomyName}"`)).toThrow();
|
||||
|
||||
// Click on delete menu
|
||||
fireEvent.click(getByRole('button'));
|
||||
fireEvent.click(getByText('Delete'));
|
||||
|
||||
// Modal opened
|
||||
expect(getByText(`Delete "${taxonomyName}"`)).toBeInTheDocument();
|
||||
|
||||
const input = getByLabelText('Type DELETE to confirm');
|
||||
fireEvent.change(input, { target: { value: 'DELETE' } });
|
||||
|
||||
// Click on delete button
|
||||
fireEvent.click(getByTestId('delete-button'));
|
||||
|
||||
// Modal closed
|
||||
expect(() => getByText(`Delete "${taxonomyName}"`)).toThrow();
|
||||
expect(mockMutate).toBeCalledTimes(1);
|
||||
|
||||
// Should redirect after a success delete
|
||||
expect(mockSetToastMessage).toBeCalledTimes(1);
|
||||
expect(mockNavigate).toBeCalledWith('/taxonomies');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,10 @@ const messages = defineMessages({
|
||||
id: 'course-authoring.taxonomy-detail.action.export',
|
||||
defaultMessage: 'Export',
|
||||
},
|
||||
deleteMenu: {
|
||||
id: 'course-authoring.taxonomy-detail.action.delete',
|
||||
defaultMessage: 'Delete',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
|
||||
Reference in New Issue
Block a user