Compare commits
5 Commits
release/te
...
release/te
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
06497bf85c | ||
|
|
7e0b7f94e8 | ||
|
|
4bc34c268b | ||
|
|
2973614e3b | ||
|
|
bdc99fddc3 |
@@ -41,6 +41,7 @@ const CourseUnit = ({ courseId }) => {
|
|||||||
courseUnit,
|
courseUnit,
|
||||||
isLoading,
|
isLoading,
|
||||||
sequenceId,
|
sequenceId,
|
||||||
|
courseUnitLoadingStatus,
|
||||||
unitTitle,
|
unitTitle,
|
||||||
unitCategory,
|
unitCategory,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
@@ -210,6 +211,7 @@ const CourseUnit = ({ courseId }) => {
|
|||||||
courseId={courseId}
|
courseId={courseId}
|
||||||
blockId={blockId}
|
blockId={blockId}
|
||||||
isUnitVerticalType={isUnitVerticalType}
|
isUnitVerticalType={isUnitVerticalType}
|
||||||
|
courseUnitLoadingStatus={courseUnitLoadingStatus}
|
||||||
unitXBlockActions={unitXBlockActions}
|
unitXBlockActions={unitXBlockActions}
|
||||||
courseVerticalChildren={courseVerticalChildren.children}
|
courseVerticalChildren={courseVerticalChildren.children}
|
||||||
handleConfigureSubmit={handleConfigureSubmit}
|
handleConfigureSubmit={handleConfigureSubmit}
|
||||||
|
|||||||
@@ -12,16 +12,19 @@ export function useSequenceNavigationMetadata(courseId, currentSequenceId, curre
|
|||||||
const isLastUnit = !nextUrl;
|
const isLastUnit = !nextUrl;
|
||||||
const sequenceIds = useSelector(getSequenceIds);
|
const sequenceIds = useSelector(getSequenceIds);
|
||||||
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
|
const sequenceIndex = sequenceIds.indexOf(currentSequenceId);
|
||||||
const unitIndex = sequence.unitIds.indexOf(currentUnitId);
|
let unitIndex = sequence?.unitIds.indexOf(currentUnitId);
|
||||||
|
|
||||||
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
|
const nextSequenceId = sequenceIndex < sequenceIds.length - 1 ? sequenceIds[sequenceIndex + 1] : null;
|
||||||
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
|
const previousSequenceId = sequenceIndex > 0 ? sequenceIds[sequenceIndex - 1] : null;
|
||||||
|
if (!unitIndex) {
|
||||||
|
// Handle case where unitIndex is not found
|
||||||
|
unitIndex = 0;
|
||||||
|
}
|
||||||
let nextLink;
|
let nextLink;
|
||||||
const nextIndex = unitIndex + 1;
|
const nextIndex = unitIndex + 1;
|
||||||
|
|
||||||
if (nextIndex < sequence.unitIds.length) {
|
if (nextIndex < sequence?.unitIds.length) {
|
||||||
const nextUnitId = sequence.unitIds[nextIndex];
|
const nextUnitId = sequence?.unitIds[nextIndex];
|
||||||
nextLink = `/course/${courseId}/container/${nextUnitId}/${currentSequenceId}`;
|
nextLink = `/course/${courseId}/container/${nextUnitId}/${currentSequenceId}`;
|
||||||
} else if (nextSequenceId) {
|
} else if (nextSequenceId) {
|
||||||
const pathToNextUnit = decodeURIComponent(nextUrl);
|
const pathToNextUnit = decodeURIComponent(nextUrl);
|
||||||
@@ -32,7 +35,7 @@ export function useSequenceNavigationMetadata(courseId, currentSequenceId, curre
|
|||||||
const previousIndex = unitIndex - 1;
|
const previousIndex = unitIndex - 1;
|
||||||
|
|
||||||
if (previousIndex >= 0) {
|
if (previousIndex >= 0) {
|
||||||
const previousUnitId = sequence.unitIds[previousIndex];
|
const previousUnitId = sequence?.unitIds[previousIndex];
|
||||||
previousLink = `/course/${courseId}/container/${previousUnitId}/${currentSequenceId}`;
|
previousLink = `/course/${courseId}/container/${previousUnitId}/${currentSequenceId}`;
|
||||||
} else if (previousSequenceId) {
|
} else if (previousSequenceId) {
|
||||||
const pathToPreviousUnit = decodeURIComponent(prevUrl);
|
const pathToPreviousUnit = decodeURIComponent(prevUrl);
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ const SequenceNavigation = ({
|
|||||||
|
|
||||||
const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth;
|
const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth;
|
||||||
const renderUnitButtons = () => {
|
const renderUnitButtons = () => {
|
||||||
if (sequence.unitIds?.length === 0 || unitId === null) {
|
if (sequence?.unitIds?.length === 0 || unitId === null) {
|
||||||
return (
|
return (
|
||||||
<div style={{ flexBasis: '100%', minWidth: 0, borderBottom: 'solid 1px #EAEAEA' }} />
|
<div style={{ flexBasis: '100%', minWidth: 0, borderBottom: 'solid 1px #EAEAEA' }} />
|
||||||
);
|
);
|
||||||
@@ -43,7 +43,7 @@ const SequenceNavigation = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SequenceNavigationTabs
|
<SequenceNavigationTabs
|
||||||
unitIds={sequence.unitIds || []}
|
unitIds={sequence?.unitIds || []}
|
||||||
unitId={unitId}
|
unitId={unitId}
|
||||||
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
|
handleCreateNewCourseXBlock={handleCreateNewCourseXBlock}
|
||||||
showPasteUnit={showPasteUnit}
|
showPasteUnit={showPasteUnit}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export const getCourseVerticalChildren = (state) => state.courseUnit.courseVerti
|
|||||||
export const getCourseOutlineInfo = (state) => state.courseUnit.courseOutlineInfo;
|
export const getCourseOutlineInfo = (state) => state.courseUnit.courseOutlineInfo;
|
||||||
export const getCourseOutlineInfoLoadingStatus = (state) => state.courseUnit.courseOutlineInfoLoadingStatus;
|
export const getCourseOutlineInfoLoadingStatus = (state) => state.courseUnit.courseOutlineInfoLoadingStatus;
|
||||||
export const getMovedXBlockParams = (state) => state.courseUnit.movedXBlockParams;
|
export const getMovedXBlockParams = (state) => state.courseUnit.movedXBlockParams;
|
||||||
const getLoadingStatuses = (state) => state.courseUnit.loadingStatus;
|
export const getLoadingStatuses = (state) => state.courseUnit.loadingStatus;
|
||||||
export const getIsLoading = createSelector(
|
export const getIsLoading = createSelector(
|
||||||
[getLoadingStatuses],
|
[getLoadingStatuses],
|
||||||
loadingStatus => Object.values(loadingStatus)
|
loadingStatus => Object.values(loadingStatus)
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
getSavingStatus,
|
getSavingStatus,
|
||||||
getSequenceStatus,
|
getSequenceStatus,
|
||||||
getStaticFileNotices,
|
getStaticFileNotices,
|
||||||
|
getLoadingStatuses,
|
||||||
} from './data/selectors';
|
} from './data/selectors';
|
||||||
import {
|
import {
|
||||||
changeEditTitleFormOpen,
|
changeEditTitleFormOpen,
|
||||||
@@ -51,6 +52,7 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
|||||||
const [isMoveModalOpen, openMoveModal, closeMoveModal] = useToggle(false);
|
const [isMoveModalOpen, openMoveModal, closeMoveModal] = useToggle(false);
|
||||||
|
|
||||||
const courseUnit = useSelector(getCourseUnitData);
|
const courseUnit = useSelector(getCourseUnitData);
|
||||||
|
const courseUnitLoadingStatus = useSelector(getLoadingStatuses);
|
||||||
const savingStatus = useSelector(getSavingStatus);
|
const savingStatus = useSelector(getSavingStatus);
|
||||||
const isLoading = useSelector(getIsLoading);
|
const isLoading = useSelector(getIsLoading);
|
||||||
const errorMessage = useSelector(getErrorMessage);
|
const errorMessage = useSelector(getErrorMessage);
|
||||||
@@ -215,9 +217,28 @@ export const useCourseUnit = ({ courseId, blockId }) => {
|
|||||||
}
|
}
|
||||||
}, [isMoveModalOpen]);
|
}, [isMoveModalOpen]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handlePageRefreshUsingStorage = (event) => {
|
||||||
|
// ignoring tests for if block, because it triggers when someone
|
||||||
|
// edits the component using editor which has a separate store
|
||||||
|
/* istanbul ignore next */
|
||||||
|
if (event.key === 'courseRefreshTriggerOnComponentEditSave') {
|
||||||
|
dispatch(fetchCourseSectionVerticalData(blockId, sequenceId));
|
||||||
|
dispatch(fetchCourseVerticalChildrenData(blockId, isSplitTestType));
|
||||||
|
localStorage.removeItem(event.key);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('storage', handlePageRefreshUsingStorage);
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('storage', handlePageRefreshUsingStorage);
|
||||||
|
};
|
||||||
|
}, [blockId, sequenceId, isSplitTestType]);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
sequenceId,
|
sequenceId,
|
||||||
courseUnit,
|
courseUnit,
|
||||||
|
courseUnitLoadingStatus,
|
||||||
unitTitle,
|
unitTitle,
|
||||||
unitCategory,
|
unitCategory,
|
||||||
errorMessage,
|
errorMessage,
|
||||||
|
|||||||
@@ -99,4 +99,4 @@ export const getIconVariant = (visibilityState, published, hasChanges) => {
|
|||||||
* @param {string} id - The course unit ID.
|
* @param {string} id - The course unit ID.
|
||||||
* @returns {string} The clear course unit ID extracted from the provided data.
|
* @returns {string} The clear course unit ID extracted from the provided data.
|
||||||
*/
|
*/
|
||||||
export const extractCourseUnitId = (id) => id.match(/block@(.+)$/)[1];
|
export const extractCourseUnitId = (id) => id?.match(/block@(.+)$/)[1];
|
||||||
|
|||||||
@@ -37,9 +37,16 @@ import { useIframeContent } from '../../generic/hooks/useIframeContent';
|
|||||||
import { useIframeMessages } from '../../generic/hooks/useIframeMessages';
|
import { useIframeMessages } from '../../generic/hooks/useIframeMessages';
|
||||||
import VideoSelectorPage from '../../editors/VideoSelectorPage';
|
import VideoSelectorPage from '../../editors/VideoSelectorPage';
|
||||||
import EditorPage from '../../editors/EditorPage';
|
import EditorPage from '../../editors/EditorPage';
|
||||||
|
import { RequestStatus } from '../../data/constants';
|
||||||
|
|
||||||
const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
||||||
courseId, blockId, unitXBlockActions, courseVerticalChildren, handleConfigureSubmit, isUnitVerticalType,
|
courseId,
|
||||||
|
blockId,
|
||||||
|
unitXBlockActions,
|
||||||
|
courseVerticalChildren,
|
||||||
|
handleConfigureSubmit,
|
||||||
|
isUnitVerticalType,
|
||||||
|
courseUnitLoadingStatus,
|
||||||
}) => {
|
}) => {
|
||||||
const intl = useIntl();
|
const intl = useIntl();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
@@ -70,6 +77,23 @@ const XBlockContainerIframe: FC<XBlockContainerIframeProps> = ({
|
|||||||
setIframeRef(iframeRef);
|
setIframeRef(iframeRef);
|
||||||
}, [setIframeRef]);
|
}, [setIframeRef]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const iframe = iframeRef?.current;
|
||||||
|
if (!iframe) { return undefined; }
|
||||||
|
|
||||||
|
const handleIframeLoad = () => {
|
||||||
|
if (courseUnitLoadingStatus.fetchUnitLoadingStatus === RequestStatus.FAILED) {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
iframe.addEventListener('load', handleIframeLoad);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
iframe.removeEventListener('load', handleIframeLoad);
|
||||||
|
};
|
||||||
|
}, [iframeRef]);
|
||||||
|
|
||||||
const onXBlockSave = useCallback(/* istanbul ignore next */ () => {
|
const onXBlockSave = useCallback(/* istanbul ignore next */ () => {
|
||||||
closeXBlockEditorModal();
|
closeXBlockEditorModal();
|
||||||
closeVideoSelectorModal();
|
closeVideoSelectorModal();
|
||||||
|
|||||||
@@ -42,6 +42,11 @@ export interface XBlockContainerIframeProps {
|
|||||||
courseId: string;
|
courseId: string;
|
||||||
blockId: string;
|
blockId: string;
|
||||||
isUnitVerticalType: boolean,
|
isUnitVerticalType: boolean,
|
||||||
|
courseUnitLoadingStatus: {
|
||||||
|
fetchUnitLoadingStatus: string;
|
||||||
|
fetchVerticalChildrenLoadingStatus: string;
|
||||||
|
fetchXBlockDataLoadingStatus: string;
|
||||||
|
};
|
||||||
unitXBlockActions: {
|
unitXBlockActions: {
|
||||||
handleDelete: (XBlockId: string | null) => void;
|
handleDelete: (XBlockId: string | null) => void;
|
||||||
handleDuplicate: (XBlockId: string | null) => void;
|
handleDuplicate: (XBlockId: string | null) => void;
|
||||||
|
|||||||
@@ -125,6 +125,16 @@ export const saveBlock = (content, returnToUnit) => (dispatch) => {
|
|||||||
content,
|
content,
|
||||||
onSuccess: (response) => {
|
onSuccess: (response) => {
|
||||||
dispatch(actions.app.setSaveResponse(response));
|
dispatch(actions.app.setSaveResponse(response));
|
||||||
|
const parsedData = JSON.parse(response.config.data);
|
||||||
|
if (parsedData?.has_changes) {
|
||||||
|
const storageKey = 'courseRefreshTriggerOnComponentEditSave';
|
||||||
|
localStorage.setItem(storageKey, Date.now());
|
||||||
|
|
||||||
|
window.dispatchEvent(new StorageEvent('storage', {
|
||||||
|
key: storageKey,
|
||||||
|
newValue: Date.now().toString(),
|
||||||
|
}));
|
||||||
|
}
|
||||||
returnToUnit(response.data);
|
returnToUnit(response.data);
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|||||||
@@ -352,7 +352,11 @@ describe('app thunkActions', () => {
|
|||||||
});
|
});
|
||||||
it('dispatches actions.app.setSaveResponse with response and then calls returnToUnit', () => {
|
it('dispatches actions.app.setSaveResponse with response and then calls returnToUnit', () => {
|
||||||
dispatch.mockClear();
|
dispatch.mockClear();
|
||||||
const response = 'testRESPONSE';
|
const mockParsedData = { has_changes: true };
|
||||||
|
const response = {
|
||||||
|
config: { data: JSON.stringify(mockParsedData) },
|
||||||
|
data: {},
|
||||||
|
};
|
||||||
calls[1][0].saveBlock.onSuccess(response);
|
calls[1][0].saveBlock.onSuccess(response);
|
||||||
expect(dispatch).toHaveBeenCalledWith(actions.app.setSaveResponse(response));
|
expect(dispatch).toHaveBeenCalledWith(actions.app.setSaveResponse(response));
|
||||||
expect(returnToUnit).toHaveBeenCalled();
|
expect(returnToUnit).toHaveBeenCalled();
|
||||||
|
|||||||
@@ -70,15 +70,6 @@ const mockStore = async (
|
|||||||
}
|
}
|
||||||
renderComponent();
|
renderComponent();
|
||||||
await executeThunk(fetchAssets(courseId), store.dispatch);
|
await executeThunk(fetchAssets(courseId), store.dispatch);
|
||||||
|
|
||||||
// Finish loading the expected files into the data table before returning,
|
|
||||||
// because loading new files can disrupt things like accessing file menus.
|
|
||||||
if (status === RequestStatus.SUCCESSFUL) {
|
|
||||||
const numFiles = skipNextPageFetch ? 13 : 15;
|
|
||||||
await waitFor(() => {
|
|
||||||
expect(screen.getByText(`Showing ${numFiles} of ${numFiles}`)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const emptyMockStore = async (status) => {
|
const emptyMockStore = async (status) => {
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ const slice = createSlice({
|
|||||||
if (isEmpty(state.assetIds)) {
|
if (isEmpty(state.assetIds)) {
|
||||||
state.assetIds = payload.assetIds;
|
state.assetIds = payload.assetIds;
|
||||||
} else {
|
} else {
|
||||||
state.assetIds = [...state.assetIds, ...payload.assetIds];
|
state.assetIds = [...new Set([...state.assetIds, ...payload.assetIds])];
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
setSortedAssetIds: (state, { payload }) => {
|
setSortedAssetIds: (state, { payload }) => {
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import React from 'react';
|
import React, { useContext } from 'react';
|
||||||
import PropTypes from 'prop-types';
|
import PropTypes from 'prop-types';
|
||||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||||
import { getConfig } from '@edx/frontend-platform';
|
import { getConfig } from '@edx/frontend-platform';
|
||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
AlertModal,
|
AlertModal,
|
||||||
Button,
|
Button,
|
||||||
Collapsible,
|
Collapsible,
|
||||||
|
DataTableContext,
|
||||||
Hyperlink,
|
Hyperlink,
|
||||||
Truncate,
|
Truncate,
|
||||||
} from '@openedx/paragon';
|
} from '@openedx/paragon';
|
||||||
@@ -22,6 +23,13 @@ const DeleteConfirmationModal = ({
|
|||||||
// injected
|
// injected
|
||||||
intl,
|
intl,
|
||||||
}) => {
|
}) => {
|
||||||
|
const { clearSelection } = useContext(DataTableContext);
|
||||||
|
|
||||||
|
const handleConfirmDeletion = () => {
|
||||||
|
handleBulkDelete();
|
||||||
|
clearSelection();
|
||||||
|
};
|
||||||
|
|
||||||
const firstSelectedRow = selectedRows[0]?.original;
|
const firstSelectedRow = selectedRows[0]?.original;
|
||||||
let activeContentRows = [];
|
let activeContentRows = [];
|
||||||
if (Array.isArray(selectedRows)) {
|
if (Array.isArray(selectedRows)) {
|
||||||
@@ -73,7 +81,7 @@ const DeleteConfirmationModal = ({
|
|||||||
<Button variant="tertiary" onClick={closeDeleteConfirmation}>
|
<Button variant="tertiary" onClick={closeDeleteConfirmation}>
|
||||||
{intl.formatMessage(messages.cancelButtonLabel)}
|
{intl.formatMessage(messages.cancelButtonLabel)}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={handleBulkDelete}>
|
<Button onClick={handleConfirmDeletion}>
|
||||||
{intl.formatMessage(messages.deleteFileButtonLabel)}
|
{intl.formatMessage(messages.deleteFileButtonLabel)}
|
||||||
</Button>
|
</Button>
|
||||||
</ActionRow>
|
</ActionRow>
|
||||||
|
|||||||
@@ -273,6 +273,16 @@ const FileTable = ({
|
|||||||
setSelectedRows={setSelectedRows}
|
setSelectedRows={setSelectedRows}
|
||||||
fileType={fileType}
|
fileType={fileType}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<DeleteConfirmationModal
|
||||||
|
{...{
|
||||||
|
isDeleteConfirmationOpen,
|
||||||
|
closeDeleteConfirmation,
|
||||||
|
handleBulkDelete,
|
||||||
|
selectedRows,
|
||||||
|
fileType,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</DataTable>
|
</DataTable>
|
||||||
<FileInput key="generic-file-upload" fileInput={fileInputControl} supportedFileFormats={supportedFileFormats} />
|
<FileInput key="generic-file-upload" fileInput={fileInputControl} supportedFileFormats={supportedFileFormats} />
|
||||||
{!isEmpty(selectedRows) && (
|
{!isEmpty(selectedRows) && (
|
||||||
@@ -286,15 +296,7 @@ const FileTable = ({
|
|||||||
sidebar={infoModalSidebar}
|
sidebar={infoModalSidebar}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<DeleteConfirmationModal
|
|
||||||
{...{
|
|
||||||
isDeleteConfirmationOpen,
|
|
||||||
closeDeleteConfirmation,
|
|
||||||
handleBulkDelete,
|
|
||||||
selectedRows,
|
|
||||||
fileType,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -26,13 +26,18 @@ const TableActions = ({
|
|||||||
intl,
|
intl,
|
||||||
}) => {
|
}) => {
|
||||||
const [isSortOpen, openSort, closeSort] = useToggle(false);
|
const [isSortOpen, openSort, closeSort] = useToggle(false);
|
||||||
const { state } = useContext(DataTableContext);
|
const { state, clearSelection } = useContext(DataTableContext);
|
||||||
|
|
||||||
// This useEffect saves DataTable state so it can persist after table re-renders due to data reload.
|
// This useEffect saves DataTable state so it can persist after table re-renders due to data reload.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setInitialState(state);
|
setInitialState(state);
|
||||||
}, [state]);
|
}, [state]);
|
||||||
|
|
||||||
|
const handleOpenFileSelector = () => {
|
||||||
|
fileInputControl.click();
|
||||||
|
clearSelection();
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Button variant="outline-primary" onClick={openSort} iconBefore={Tune}>
|
<Button variant="outline-primary" onClick={openSort} iconBefore={Tune}>
|
||||||
@@ -71,7 +76,7 @@ const TableActions = ({
|
|||||||
</Dropdown.Item>
|
</Dropdown.Item>
|
||||||
</Dropdown.Menu>
|
</Dropdown.Menu>
|
||||||
</Dropdown>
|
</Dropdown>
|
||||||
<Button iconBefore={Add} onClick={fileInputControl.click}>
|
<Button iconBefore={Add} onClick={handleOpenFileSelector}>
|
||||||
{intl.formatMessage(messages.addFilesButtonLabel, { fileType })}
|
{intl.formatMessage(messages.addFilesButtonLabel, { fileType })}
|
||||||
</Button>
|
</Button>
|
||||||
<SortAndFilterModal {...{ isSortOpen, closeSort, handleSort }} />
|
<SortAndFilterModal {...{ isSortOpen, closeSort, handleSort }} />
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { DataTableContext } from '@openedx/paragon';
|
||||||
|
import { initializeMocks, render } from '../../../testUtils';
|
||||||
|
import TableActions from './TableActions';
|
||||||
|
import messages from '../messages';
|
||||||
|
|
||||||
|
const defaultProps = {
|
||||||
|
selectedFlatRows: [],
|
||||||
|
fileInputControl: { click: jest.fn() },
|
||||||
|
handleOpenDeleteConfirmation: jest.fn(),
|
||||||
|
handleBulkDownload: jest.fn(),
|
||||||
|
encodingsDownloadUrl: null,
|
||||||
|
handleSort: jest.fn(),
|
||||||
|
fileType: 'video',
|
||||||
|
setInitialState: jest.fn(),
|
||||||
|
intl: {
|
||||||
|
formatMessage: (msg, values) => msg.defaultMessage.replace('{fileType}', values?.fileType ?? ''),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockColumns = [
|
||||||
|
{
|
||||||
|
id: 'wrapperType',
|
||||||
|
Header: 'Type',
|
||||||
|
accessor: 'wrapperType',
|
||||||
|
filter: 'includes',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const renderWithContext = (props = {}, contextOverrides = {}) => {
|
||||||
|
const contextValue = {
|
||||||
|
state: {
|
||||||
|
selectedRowIds: {},
|
||||||
|
filters: [],
|
||||||
|
...contextOverrides.state,
|
||||||
|
},
|
||||||
|
clearSelection: jest.fn(),
|
||||||
|
gotoPage: jest.fn(),
|
||||||
|
setAllFilters: jest.fn(),
|
||||||
|
columns: mockColumns,
|
||||||
|
...contextOverrides,
|
||||||
|
};
|
||||||
|
|
||||||
|
return render(
|
||||||
|
<DataTableContext.Provider value={contextValue}>
|
||||||
|
<TableActions {...defaultProps} {...props} />
|
||||||
|
</DataTableContext.Provider>,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('TableActions', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
initializeMocks();
|
||||||
|
jest.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders buttons and dropdown', () => {
|
||||||
|
renderWithContext();
|
||||||
|
|
||||||
|
expect(screen.getByRole('button', { name: messages.sortButtonLabel.defaultMessage })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage })).toBeInTheDocument();
|
||||||
|
expect(screen.getByRole('button', { name: messages.addFilesButtonLabel.defaultMessage.replace('{fileType}', 'video') })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('disables bulk and delete actions if no rows selected', () => {
|
||||||
|
renderWithContext();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
|
||||||
|
|
||||||
|
const downloadOption = screen.getByText(messages.downloadTitle.defaultMessage);
|
||||||
|
const deleteButton = screen.getByTestId('open-delete-confirmation-button');
|
||||||
|
|
||||||
|
expect(downloadOption).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
expect(downloadOption).toHaveClass('disabled');
|
||||||
|
|
||||||
|
expect(deleteButton).toHaveAttribute('aria-disabled', 'true');
|
||||||
|
expect(deleteButton).toHaveClass('disabled');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('enables bulk and delete actions when rows are selected', () => {
|
||||||
|
renderWithContext({
|
||||||
|
selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }],
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
|
||||||
|
expect(screen.getByText(messages.downloadTitle.defaultMessage)).not.toBeDisabled();
|
||||||
|
expect(screen.getByTestId('open-delete-confirmation-button')).not.toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls file input click and clears selection when add button clicked', () => {
|
||||||
|
const mockClick = jest.fn();
|
||||||
|
const mockClear = jest.fn();
|
||||||
|
|
||||||
|
renderWithContext({ fileInputControl: { click: mockClick } }, {}, mockClear);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: messages.addFilesButtonLabel.defaultMessage.replace('{fileType}', 'video') }));
|
||||||
|
expect(mockClick).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('opens sort modal when sort button clicked', () => {
|
||||||
|
renderWithContext();
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: messages.sortButtonLabel.defaultMessage }));
|
||||||
|
expect(screen.getByRole('dialog')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls handleBulkDownload when selected and clicked', () => {
|
||||||
|
const handleBulkDownload = jest.fn();
|
||||||
|
renderWithContext({
|
||||||
|
selectedFlatRows: [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }],
|
||||||
|
handleBulkDownload,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
|
||||||
|
fireEvent.click(screen.getByText(messages.downloadTitle.defaultMessage));
|
||||||
|
expect(handleBulkDownload).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('calls handleOpenDeleteConfirmation when clicked', () => {
|
||||||
|
const handleOpenDeleteConfirmation = jest.fn();
|
||||||
|
const selectedFlatRows = [{ original: { id: '1', displayName: 'Video 1', wrapperType: 'video' } }];
|
||||||
|
renderWithContext({
|
||||||
|
selectedFlatRows,
|
||||||
|
handleOpenDeleteConfirmation,
|
||||||
|
});
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
|
||||||
|
fireEvent.click(screen.getByTestId('open-delete-confirmation-button'));
|
||||||
|
expect(handleOpenDeleteConfirmation).toHaveBeenCalledWith(selectedFlatRows);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('shows encoding download link when provided', () => {
|
||||||
|
const encodingsDownloadUrl = '/some/path/to/encoding.zip';
|
||||||
|
renderWithContext({ encodingsDownloadUrl });
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: messages.actionsButtonLabel.defaultMessage }));
|
||||||
|
expect(screen.getByRole('link', { name: messages.downloadEncodingsTitle.defaultMessage })).toHaveAttribute('href', expect.stringContaining(encodingsDownloadUrl));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -92,7 +92,7 @@ const PagesAndResources = ({ courseId }) => {
|
|||||||
<Route path=":appId/settings" element={<PageWrap><SettingsComponent url={redirectUrl} /></PageWrap>} />
|
<Route path=":appId/settings" element={<PageWrap><SettingsComponent url={redirectUrl} /></PageWrap>} />
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|
||||||
<PageGrid pages={pages} pluginSlotComponent={AdditionalCoursePluginSlot} courseId={courseId} />
|
<PageGrid pages={pages} pluginSlotComponent={<AdditionalCoursePluginSlot />} courseId={courseId} />
|
||||||
{
|
{
|
||||||
(contentPermissionsPages.length > 0 || hasAdditionalCoursePlugin)
|
(contentPermissionsPages.length > 0 || hasAdditionalCoursePlugin)
|
||||||
&& (
|
&& (
|
||||||
@@ -100,7 +100,7 @@ const PagesAndResources = ({ courseId }) => {
|
|||||||
<div className="d-flex justify-content-between my-4 my-md-5 align-items-center">
|
<div className="d-flex justify-content-between my-4 my-md-5 align-items-center">
|
||||||
<h3 className="m-0">{intl.formatMessage(messages.contentPermissions)}</h3>
|
<h3 className="m-0">{intl.formatMessage(messages.contentPermissions)}</h3>
|
||||||
</div>
|
</div>
|
||||||
<PageGrid pages={contentPermissionsPages} pluginSlotComponent={AdditionalCourseContentPluginSlot} />
|
<PageGrid pages={contentPermissionsPages} pluginSlotComponent={<AdditionalCourseContentPluginSlot />} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,39 @@
|
|||||||
import { screen, waitFor } from '@testing-library/react';
|
import { screen, waitFor } from '@testing-library/react';
|
||||||
|
|
||||||
|
import { getConfig, setConfig } from '@edx/frontend-platform';
|
||||||
|
import { PLUGIN_OPERATIONS, DIRECT_PLUGIN } from '@openedx/frontend-plugin-framework';
|
||||||
import { PagesAndResources } from '.';
|
import { PagesAndResources } from '.';
|
||||||
import { render } from './utils.test';
|
import { render } from './utils.test';
|
||||||
|
|
||||||
|
const mockPlugin = (identifier) => ({
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
op: PLUGIN_OPERATIONS.Insert,
|
||||||
|
widget: {
|
||||||
|
id: 'mock-plugin-1',
|
||||||
|
type: DIRECT_PLUGIN,
|
||||||
|
priority: 1,
|
||||||
|
RenderWidget: () => <div data-testid={identifier}>HELLO</div>,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const courseId = 'course-v1:edX+TestX+Test_Course';
|
const courseId = 'course-v1:edX+TestX+Test_Course';
|
||||||
|
|
||||||
describe('PagesAndResources', () => {
|
describe('PagesAndResources', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
|
setConfig({
|
||||||
|
...getConfig(),
|
||||||
|
pluginSlots: {
|
||||||
|
'org.openedx.frontend.authoring.additional_course_plugin.v1': mockPlugin('additional_course_plugin'),
|
||||||
|
'org.openedx.frontend.authoring.additional_course_content_plugin.v1': mockPlugin('additional_course_content_plugin'),
|
||||||
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('doesn\'t show content permissions section if relevant apps are not enabled', () => {
|
it('doesn\'t show content permissions section if relevant apps are not enabled', async () => {
|
||||||
const initialState = {
|
const initialState = {
|
||||||
models: {
|
models: {
|
||||||
courseApps: {},
|
courseApps: {},
|
||||||
@@ -25,8 +48,11 @@ describe('PagesAndResources', () => {
|
|||||||
{ preloadedState: initialState },
|
{ preloadedState: initialState },
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(screen.queryByRole('heading', { name: 'Content permissions' })).not.toBeInTheDocument();
|
await waitFor(() => expect(screen.queryByRole('heading', { name: 'Content permissions' })).not.toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).not.toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('show content permissions section if Learning Assistant app is enabled', async () => {
|
it('show content permissions section if Learning Assistant app is enabled', async () => {
|
||||||
const initialState = {
|
const initialState = {
|
||||||
models: {
|
models: {
|
||||||
@@ -56,6 +82,8 @@ describe('PagesAndResources', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument());
|
||||||
await waitFor(() => expect(screen.getByText('Learning Assistant')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Learning Assistant')).toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
|
|
||||||
it('show content permissions section if Xpert learning summaries app is enabled', async () => {
|
it('show content permissions section if Xpert learning summaries app is enabled', async () => {
|
||||||
@@ -89,5 +117,7 @@ describe('PagesAndResources', () => {
|
|||||||
|
|
||||||
await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByRole('heading', { name: 'Content permissions' })).toBeInTheDocument());
|
||||||
await waitFor(() => expect(screen.getByText('Xpert unit summaries')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Xpert unit summaries')).toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('additional_course_plugin')).toBeInTheDocument());
|
||||||
|
await waitFor(() => expect(screen.queryByTestId('additional_course_content_plugin')).toBeInTheDocument());
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -3,4 +3,4 @@
|
|||||||
### Slot ID: `org.openedx.frontend.authoring.additional_course_content_plugin.v1`
|
### Slot ID: `org.openedx.frontend.authoring.additional_course_content_plugin.v1`
|
||||||
|
|
||||||
### Slot ID Aliases
|
### Slot ID Aliases
|
||||||
* `additional_course_content_plugin`
|
* `additional_course_content_plugin`
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { PluginSlot } from '@openedx/frontend-plugin-framework/dist';
|
import { PluginSlot } from '@openedx/frontend-plugin-framework/dist';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export const AdditionalCourseContentPluginSlot = () => (
|
export const AdditionalCourseContentPluginSlot = () => (
|
||||||
<PluginSlot
|
<PluginSlot
|
||||||
|
|||||||
@@ -1,6 +1,64 @@
|
|||||||
# AdditionalCoursePluginSlot
|
# Additional Course Plugin Slot
|
||||||
|
|
||||||
### Slot ID: `org.openedx.frontend.authoring.additional_course_plugin.v1`
|
### Slot ID: `org.openedx.frontend.authoring.additional_course_plugin.v1`
|
||||||
|
|
||||||
### Slot ID Aliases
|
### Slot ID Aliases
|
||||||
* `additional_course_plugin`
|
* `additional_course_plugin`
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
This slot is used to add a custom card on the the page & resources page.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
The following `env.config.jsx` will add a custom card at the end of the page & resources section.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||||
|
import { Badge, Card } from '@openedx/paragon';
|
||||||
|
import { Settings } from '@openedx/paragon/icons';
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
pluginSlots: {
|
||||||
|
'org.openedx.frontend.authoring.additional_course_plugin.v1': {
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
op: PLUGIN_OPERATIONS.Hide,
|
||||||
|
widgetId: 'default_contents',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
op: PLUGIN_OPERATIONS.Insert,
|
||||||
|
widget: {
|
||||||
|
id: 'custom_additional_course',
|
||||||
|
type: DIRECT_PLUGIN,
|
||||||
|
RenderWidget: () => (
|
||||||
|
<Card className={'shadow justify-content-between'} >
|
||||||
|
<Card.Header
|
||||||
|
title={'Additional Course'}
|
||||||
|
subtitle={(
|
||||||
|
<Badge variant="success" className="mt-1">
|
||||||
|
slot props course
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
actions={<Settings />}
|
||||||
|
size="sm"
|
||||||
|
/>
|
||||||
|
<Card.Body>
|
||||||
|
<Card.Section>
|
||||||
|
Additional course from slot props description.
|
||||||
|
Or anything else.
|
||||||
|
</Card.Section>
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 324 KiB |
@@ -1,5 +1,4 @@
|
|||||||
import { PluginSlot } from '@openedx/frontend-plugin-framework/dist';
|
import { PluginSlot } from '@openedx/frontend-plugin-framework/dist';
|
||||||
import React from 'react';
|
|
||||||
|
|
||||||
export const AdditionalCoursePluginSlot = () => (
|
export const AdditionalCoursePluginSlot = () => (
|
||||||
<PluginSlot
|
<PluginSlot
|
||||||
|
|||||||
@@ -13,3 +13,54 @@
|
|||||||
* `additionalProps` - Object
|
* `additionalProps` - Object
|
||||||
* `transcriptType` - String
|
* `transcriptType` - String
|
||||||
* `isAiTranslationsEnabled` - Boolean
|
* `isAiTranslationsEnabled` - Boolean
|
||||||
|
|
||||||
|
|
||||||
|
## Description
|
||||||
|
|
||||||
|
This slot is used to add a custom block in the **Video Transcription Settings** drawer.
|
||||||
|
|
||||||
|
## Example
|
||||||
|
|
||||||
|
The following `env.config.jsx` will add a custom transcript option in the Transcript Settings drawer.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
```jsx
|
||||||
|
import { DIRECT_PLUGIN, PLUGIN_OPERATIONS } from '@openedx/frontend-plugin-framework';
|
||||||
|
import { Collapsible, Icon } from '@openedx/paragon';
|
||||||
|
import { ChevronRight } from '@openedx/paragon/icons';
|
||||||
|
|
||||||
|
const TranslationsBlock = ({ setIsAiTranslations, courseId }) => (
|
||||||
|
<div key="transcript-type-selection" className="mt-3">
|
||||||
|
<Collapsible.Advanced
|
||||||
|
onOpen={() => setIsAiTranslations(courseId === 'anyId')}
|
||||||
|
>
|
||||||
|
<Collapsible.Trigger
|
||||||
|
className="row m-0 justify-content-between align-items-center"
|
||||||
|
>
|
||||||
|
Custom transcript 💬
|
||||||
|
<Icon src={ChevronRight} />
|
||||||
|
</Collapsible.Trigger>
|
||||||
|
</Collapsible.Advanced>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const config = {
|
||||||
|
pluginSlots: {
|
||||||
|
'org.openedx.frontend.authoring.video_transcript_additional_translations_component.v1': {
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
op: PLUGIN_OPERATIONS.Insert,
|
||||||
|
widget: {
|
||||||
|
id: 'custom_additional_translation_id',
|
||||||
|
type: DIRECT_PLUGIN,
|
||||||
|
RenderWidget: TranslationsBlock,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config;
|
||||||
|
```
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
Reference in New Issue
Block a user