Compare commits
1 Commits
test_hyper
...
kshitij/fu
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1eb47c7431 |
6
package-lock.json
generated
6
package-lock.json
generated
@@ -7860,9 +7860,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001715",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz",
|
||||
"integrity": "sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==",
|
||||
"version": "1.0.30001712",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001712.tgz",
|
||||
"integrity": "sha512-MBqPpGYYdQ7/hfKiet9SCI+nmN5/hp4ZzveOJubl5DTAMa5oggjAuoi0Z4onBpKPFI2ePGnQuQIzF3VxDjDJig==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
|
||||
@@ -60,8 +60,8 @@
|
||||
"@openedx-plugins/course-app-wiki": "file:plugins/course-apps/wiki",
|
||||
"@openedx-plugins/course-app-xpert_unit_summary": "file:plugins/course-apps/xpert_unit_summary",
|
||||
"@openedx/frontend-build": "^14.3.3",
|
||||
"@openedx/frontend-plugin-framework": "^1.6.0",
|
||||
"@openedx/frontend-slot-footer": "^1.2.0",
|
||||
"@openedx/frontend-plugin-framework": "^1.6.0",
|
||||
"@openedx/paragon": "^22.16.0",
|
||||
"@redux-devtools/extension": "^3.3.0",
|
||||
"@reduxjs/toolkit": "1.9.7",
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
export default {
|
||||
content: {
|
||||
id: 67,
|
||||
userId: 3,
|
||||
created: '2024-01-16T13:09:11.540615Z',
|
||||
purpose: 'clipboard',
|
||||
status: 'ready',
|
||||
blockType: 'sequential',
|
||||
blockTypeDisplay: 'Subsection',
|
||||
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/67/olx',
|
||||
displayName: 'Sequences',
|
||||
},
|
||||
sourceUsageKey: 'block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential_0270f6de40fc',
|
||||
sourceContextTitle: 'Demonstration Course',
|
||||
sourceEditUrl: 'http://localhost:18010/container/block-v1:edX+DemoX+Demo_Course+type@sequential+block@sequential_0270f6de40fc',
|
||||
};
|
||||
@@ -1,3 +1,2 @@
|
||||
export { default as clipboardUnit } from './clipboardUnit';
|
||||
export { default as clipboardSubsection } from './clipboardSubsection';
|
||||
export { default as clipboardXBlock } from './clipboardXBlock';
|
||||
|
||||
@@ -13,7 +13,6 @@ export async function mockContentTaxonomyTagsData(contentId: string): Promise<an
|
||||
case thisMock.languageWithTagsId: return thisMock.languageWithTags;
|
||||
case thisMock.languageWithoutTagsId: return thisMock.languageWithoutTags;
|
||||
case thisMock.largeTagsId: return thisMock.largeTags;
|
||||
case thisMock.containerTagsId: return thisMock.largeTags;
|
||||
case thisMock.emptyTagsId: return thisMock.emptyTags;
|
||||
default: throw new Error(`No mock has been set up for contentId "${contentId}"`);
|
||||
}
|
||||
@@ -205,7 +204,6 @@ mockContentTaxonomyTagsData.emptyTagsId = 'block-v1:EmptyTagsOrg+STC1+2023_1+typ
|
||||
mockContentTaxonomyTagsData.emptyTags = {
|
||||
taxonomies: [],
|
||||
};
|
||||
mockContentTaxonomyTagsData.containerTagsId = 'lct:org:lib:unit:container_tags';
|
||||
mockContentTaxonomyTagsData.applyMock = () => jest.spyOn(api, 'getContentTaxonomyTagsData').mockImplementation(mockContentTaxonomyTagsData);
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
Cached, CheckCircle, Launch, Loop,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import sumBy from 'lodash/sumBy';
|
||||
import _ from 'lodash';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import getPageHeadTitle from '../generic/utils';
|
||||
import { useModel } from '../generic/model-store';
|
||||
@@ -109,7 +109,7 @@ export const CourseLibraries: React.FC<Props> = ({ courseId }) => {
|
||||
);
|
||||
const [showReviewAlert, setShowReviewAlert] = useState(false);
|
||||
const { data: libraries, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId);
|
||||
const outOfSyncCount = useMemo(() => sumBy(libraries, (lib) => lib.readyToSyncCount), [libraries]);
|
||||
const outOfSyncCount = useMemo(() => _.sumBy(libraries, (lib) => lib.readyToSyncCount), [libraries]);
|
||||
const {
|
||||
isLoadingPage: isLoadingStudioHome,
|
||||
isFailedLoadingPage: isFailedLoadingStudioHome,
|
||||
|
||||
@@ -14,9 +14,7 @@ import {
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
|
||||
import {
|
||||
tail, keyBy, orderBy, merge, omitBy,
|
||||
} from 'lodash';
|
||||
import _ from 'lodash';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { Loop, Warning } from '@openedx/paragon/icons';
|
||||
import messages from './messages';
|
||||
@@ -51,7 +49,7 @@ interface BlockCardProps {
|
||||
const BlockCard: React.FC<BlockCardProps> = ({ info, actions }) => {
|
||||
const intl = useIntl();
|
||||
const componentIcon = getItemIcon(info.blockType);
|
||||
const breadcrumbs = tail(info.breadcrumbs) as Array<{ displayName: string, usageKey: string }>;
|
||||
const breadcrumbs = _.tail(info.breadcrumbs) as Array<{ displayName: string, usageKey: string }>;
|
||||
|
||||
const getBlockLink = useCallback(() => {
|
||||
let key = info.usageKey;
|
||||
@@ -140,11 +138,11 @@ const ComponentReviewList = ({
|
||||
);
|
||||
|
||||
const outOfSyncComponentsByKey = useMemo(
|
||||
() => keyBy(outOfSyncComponents, 'downstreamUsageKey'),
|
||||
() => _.keyBy(outOfSyncComponents, 'downstreamUsageKey'),
|
||||
[outOfSyncComponents],
|
||||
);
|
||||
const downstreamInfoByKey = useMemo(
|
||||
() => keyBy(downstreamInfo, 'usageKey'),
|
||||
() => _.keyBy(downstreamInfo, 'usageKey'),
|
||||
[downstreamInfo],
|
||||
);
|
||||
const queryClient = useQueryClient();
|
||||
@@ -243,9 +241,9 @@ const ComponentReviewList = ({
|
||||
if (isIndexDataLoading) {
|
||||
return [];
|
||||
}
|
||||
let merged = merge(downstreamInfoByKey, outOfSyncComponentsByKey);
|
||||
merged = omitBy(merged, (o) => !o.displayName);
|
||||
const ordered = orderBy(Object.values(merged), 'updated', 'desc');
|
||||
let merged = _.merge(downstreamInfoByKey, outOfSyncComponentsByKey);
|
||||
merged = _.omitBy(merged, (o) => !o.displayName);
|
||||
const ordered = _.orderBy(Object.values(merged), 'updated', 'desc');
|
||||
return ordered;
|
||||
}, [downstreamInfoByKey, outOfSyncComponentsByKey]);
|
||||
|
||||
|
||||
@@ -45,12 +45,12 @@ import HighlightsModal from './highlights-modal/HighlightsModal';
|
||||
import EmptyPlaceholder from './empty-placeholder/EmptyPlaceholder';
|
||||
import PublishModal from './publish-modal/PublishModal';
|
||||
import PageAlerts from './page-alerts/PageAlerts';
|
||||
import DraggableList from './drag-helper/DraggableList';
|
||||
import DraggableList from '../generic/drag-helper/DraggableList';
|
||||
import {
|
||||
canMoveSection,
|
||||
possibleUnitMoves,
|
||||
possibleSubsectionMoves,
|
||||
} from './drag-helper/utils';
|
||||
} from '../generic/drag-helper/utils';
|
||||
import { useCourseOutline } from './hooks';
|
||||
import messages from './messages';
|
||||
import { getTagsExportFile } from './data/api';
|
||||
|
||||
@@ -7,4 +7,3 @@
|
||||
@import "./highlights-modal/HighlightsModal";
|
||||
@import "./publish-modal/PublishModal";
|
||||
@import "./xblock-status/XBlockStatus";
|
||||
@import "./drag-helper/SortableItem";
|
||||
|
||||
@@ -59,7 +59,7 @@ import {
|
||||
moveUnitOver,
|
||||
moveSubsection,
|
||||
moveUnit,
|
||||
} from './drag-helper/utils';
|
||||
} from '../generic/drag-helper/utils';
|
||||
|
||||
let axiosMock;
|
||||
let store;
|
||||
@@ -68,6 +68,13 @@ const courseId = '123';
|
||||
|
||||
window.HTMLElement.prototype.scrollIntoView = jest.fn();
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
jest.mock('react-router-dom', () => ({
|
||||
...jest.requireActual('react-router-dom'),
|
||||
useLocation: jest.fn(),
|
||||
|
||||
@@ -13,8 +13,8 @@ import classNames from 'classnames';
|
||||
import { setCurrentItem, setCurrentSection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import SortableItem from '../drag-helper/SortableItem';
|
||||
import { DragContext } from '../drag-helper/DragContextProvider';
|
||||
import SortableItem from '../../generic/drag-helper/SortableItem';
|
||||
import { DragContext } from '../../generic/drag-helper/DragContextProvider';
|
||||
import TitleButton from '../card-header/TitleButton';
|
||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
||||
|
||||
@@ -15,8 +15,8 @@ import CourseOutlineSubsectionCardExtraActionsSlot from '../../plugin-slots/Cour
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import SortableItem from '../drag-helper/SortableItem';
|
||||
import { DragContext } from '../drag-helper/DragContextProvider';
|
||||
import SortableItem from '../../generic/drag-helper/SortableItem';
|
||||
import { DragContext } from '../../generic/drag-helper/DragContextProvider';
|
||||
import { useClipboard, PasteComponent } from '../../generic/clipboard';
|
||||
import TitleButton from '../card-header/TitleButton';
|
||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||
|
||||
@@ -21,6 +21,13 @@ jest.mock('react-router-dom', () => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const unit = {
|
||||
id: 'unit-1',
|
||||
};
|
||||
|
||||
@@ -10,7 +10,7 @@ import CourseOutlineUnitCardExtraActionsSlot from '../../plugin-slots/CourseOutl
|
||||
import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice';
|
||||
import { RequestStatus } from '../../data/constants';
|
||||
import CardHeader from '../card-header/CardHeader';
|
||||
import SortableItem from '../drag-helper/SortableItem';
|
||||
import SortableItem from '../../generic/drag-helper/SortableItem';
|
||||
import TitleLink from '../card-header/TitleLink';
|
||||
import XBlockStatus from '../xblock-status/XBlockStatus';
|
||||
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';
|
||||
|
||||
@@ -47,6 +47,13 @@ const unit = {
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const renderComponent = (props) => render(
|
||||
<AppProvider store={store}>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
|
||||
@@ -93,6 +93,13 @@ jest.mock('react-router-dom', () => ({
|
||||
useNavigate: () => mockedUsedNavigate,
|
||||
}));
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
/**
|
||||
* Simulates receiving a post message event for testing purposes.
|
||||
* This can be used to mimic events like deletion or other actions
|
||||
|
||||
@@ -195,7 +195,6 @@ const AddComponent = ({
|
||||
>
|
||||
<ComponentPicker
|
||||
showOnlyPublished
|
||||
extraFilter={['NOT block_type = "unit"']}
|
||||
componentPickerMode={isAddLibraryContentModalOpen ? 'single' : 'multiple'}
|
||||
onComponentSelected={handleLibraryV2Selection}
|
||||
onChangeComponentSelection={setSelectedComponents}
|
||||
|
||||
@@ -22,6 +22,13 @@ let axiosMock;
|
||||
let queryClient;
|
||||
const courseId = '123';
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const renderComponent = (props = {}) => render(
|
||||
<AppProvider store={store}>
|
||||
<IntlProvider locale="en">
|
||||
|
||||
@@ -6,6 +6,7 @@ import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { Provider } from 'react-redux';
|
||||
|
||||
import { messageTypes } from '../../../constants';
|
||||
import { mockBroadcastChannel } from '../../../../generic/data/api.mock';
|
||||
import initializeStore from '../../../../store';
|
||||
import { useMessageHandlers } from '..';
|
||||
|
||||
@@ -19,6 +20,8 @@ jest.mock('@edx/frontend-platform/logging', () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
mockBroadcastChannel();
|
||||
|
||||
describe('useMessageHandlers', () => {
|
||||
let handlers;
|
||||
let result;
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
} from '@openedx/paragon';
|
||||
import { Add, SpinnerSimple } from '@openedx/paragon/icons';
|
||||
import Placeholder from '../editors/Placeholder';
|
||||
import DraggableList, { SortableItem } from '../generic/DraggableList';
|
||||
import DraggableList, { SortableItem } from '../editors/sharedComponents/DraggableList';
|
||||
import ErrorAlert from '../editors/sharedComponents/ErrorAlerts/ErrorAlert';
|
||||
|
||||
import { RequestStatus } from '../data/constants';
|
||||
|
||||
@@ -55,7 +55,6 @@ const AdvancedEditor = ({ usageKey, onClose }: AdvancedEditorProps) => {
|
||||
usageKey={usageKey}
|
||||
view="studio_view"
|
||||
scrolling="yes"
|
||||
minHeight="70vh"
|
||||
/>
|
||||
</IframeProvider>
|
||||
</EditorModalWrapper>
|
||||
|
||||
@@ -18,7 +18,7 @@ import { useEditorContext } from '../../EditorContext';
|
||||
import TitleHeader from './components/TitleHeader';
|
||||
import * as hooks from './hooks';
|
||||
import messages from './messages';
|
||||
import { parseErrorMsg } from '../../../library-authoring/add-content/AddContent';
|
||||
import { parseErrorMsg } from '../../../library-authoring/add-content/AddContentContainer';
|
||||
import libraryMessages from '../../../library-authoring/add-content/messages';
|
||||
|
||||
import './index.scss';
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
import {
|
||||
includes, isEmpty, isFinite, isNaN, isNil,
|
||||
} from 'lodash';
|
||||
import _ from 'lodash';
|
||||
// This 'module' self-import hack enables mocking during tests.
|
||||
// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
|
||||
// should be re-thought and cleaned up to avoid this pattern.
|
||||
@@ -67,7 +65,7 @@ export const hintsCardHooks = (hints, updateSettings) => {
|
||||
|
||||
const handleAdd = () => {
|
||||
let newId = 0;
|
||||
if (!isEmpty(hints)) {
|
||||
if (!_.isEmpty(hints)) {
|
||||
newId = Math.max(...hints.map(hint => hint.id)) + 1;
|
||||
}
|
||||
const hint = { id: newId, value: '' };
|
||||
@@ -116,9 +114,9 @@ export const resetCardHooks = (updateSettings) => {
|
||||
|
||||
export const scoringCardHooks = (scoring, updateSettings, defaultValue) => {
|
||||
let loadedAttemptsNumber = scoring.attempts.number;
|
||||
if ((loadedAttemptsNumber === defaultValue || !isFinite(loadedAttemptsNumber)) && isFinite(defaultValue)) {
|
||||
if ((loadedAttemptsNumber === defaultValue || !_.isFinite(loadedAttemptsNumber)) && _.isFinite(defaultValue)) {
|
||||
loadedAttemptsNumber = `${defaultValue} (Default)`;
|
||||
} else if (loadedAttemptsNumber === defaultValue && isNil(defaultValue)) {
|
||||
} else if (loadedAttemptsNumber === defaultValue && _.isNil(defaultValue)) {
|
||||
loadedAttemptsNumber = '';
|
||||
}
|
||||
const [attemptDisplayValue, setAttemptDisplayValue] = module.state.attemptDisplayValue(loadedAttemptsNumber);
|
||||
@@ -137,9 +135,9 @@ export const scoringCardHooks = (scoring, updateSettings, defaultValue) => {
|
||||
let unlimitedAttempts = false;
|
||||
let attemptNumber = parseInt(event.target.value, 10);
|
||||
|
||||
if (!isFinite(attemptNumber) || attemptNumber === defaultValue) {
|
||||
if (!_.isFinite(attemptNumber) || attemptNumber === defaultValue) {
|
||||
attemptNumber = null;
|
||||
if (isFinite(defaultValue)) {
|
||||
if (_.isFinite(defaultValue)) {
|
||||
setAttemptDisplayValue(`${defaultValue} (Default)`);
|
||||
} else {
|
||||
setAttemptDisplayValue('');
|
||||
@@ -156,7 +154,7 @@ export const scoringCardHooks = (scoring, updateSettings, defaultValue) => {
|
||||
let newMaxAttempt = parseInt(event.target.value, 10);
|
||||
if (newMaxAttempt === defaultValue) {
|
||||
newMaxAttempt = `${defaultValue} (Default)`;
|
||||
} else if (isNaN(newMaxAttempt)) {
|
||||
} else if (_.isNaN(newMaxAttempt)) {
|
||||
newMaxAttempt = '';
|
||||
} else if (newMaxAttempt < 0) {
|
||||
newMaxAttempt = 0;
|
||||
@@ -166,7 +164,7 @@ export const scoringCardHooks = (scoring, updateSettings, defaultValue) => {
|
||||
|
||||
const handleWeightChange = (event) => {
|
||||
let weight = parseFloat(event.target.value);
|
||||
if (isNaN(weight) || weight < 0) {
|
||||
if (_.isNaN(weight) || weight < 0) {
|
||||
weight = 0;
|
||||
}
|
||||
updateSettings({ scoring: { ...scoring, weight } });
|
||||
@@ -189,18 +187,18 @@ export const useAnswerSettings = (showAnswer, updateSettings) => {
|
||||
];
|
||||
|
||||
useEffect(() => {
|
||||
setShowAttempts(includes(numberOfAttemptsChoice, showAnswer.on));
|
||||
setShowAttempts(_.includes(numberOfAttemptsChoice, showAnswer.on));
|
||||
}, [showAttempts]);
|
||||
|
||||
const handleShowAnswerChange = (event) => {
|
||||
const { value } = event.target;
|
||||
setShowAttempts(includes(numberOfAttemptsChoice, value));
|
||||
setShowAttempts(_.includes(numberOfAttemptsChoice, value));
|
||||
updateSettings({ showAnswer: { ...showAnswer, on: value } });
|
||||
};
|
||||
|
||||
const handleAttemptsChange = (event) => {
|
||||
let attempts = parseInt(event.target.value, 10);
|
||||
if (isNaN(attempts) || attempts < 0) {
|
||||
if (_.isNaN(attempts) || attempts < 0) {
|
||||
attempts = 0;
|
||||
}
|
||||
updateSettings({ showAnswer: { ...showAnswer, afterAttempts: attempts } });
|
||||
@@ -216,7 +214,7 @@ export const useAnswerSettings = (showAnswer, updateSettings) => {
|
||||
export const timerCardHooks = (updateSettings) => ({
|
||||
handleChange: (event) => {
|
||||
let time = parseInt(event.target.value, 10);
|
||||
if (isNaN(time) || time < 0) {
|
||||
if (_.isNaN(time) || time < 0) {
|
||||
time = 0;
|
||||
}
|
||||
updateSettings({ timeBetween: time });
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import _ from 'lodash';
|
||||
import messages from './messages';
|
||||
// This 'module' self-import hack enables mocking during tests.
|
||||
// See src/editors/decisions/0005-internal-editor-testability-decisions.md. The whole approach to how hooks are tested
|
||||
@@ -19,7 +19,7 @@ export const generalFeedbackHooks = (generalFeedback, updateSettings) => {
|
||||
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
useEffect(() => {
|
||||
if (isEmpty(generalFeedback)) {
|
||||
if (_.isEmpty(generalFeedback)) {
|
||||
setSummary({ message: messages.noGeneralFeedbackSummary, values: {}, intl: true });
|
||||
} else {
|
||||
setSummary({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react';
|
||||
import isNil from 'lodash/isNil';
|
||||
import _ from 'lodash';
|
||||
import PropTypes from 'prop-types';
|
||||
import { connect } from 'react-redux';
|
||||
import { FormattedMessage, injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -76,7 +76,7 @@ const ScoringCard = ({
|
||||
className="mt-3 decoration-control-label"
|
||||
checked={scoring.attempts.unlimited}
|
||||
onChange={handleUnlimitedChange}
|
||||
disabled={!isNil(defaultValue)}
|
||||
disabled={!_.isNil(defaultValue)}
|
||||
>
|
||||
<div className="x-small">
|
||||
<FormattedMessage {...messages.unlimitedAttemptsCheckboxLabel} />
|
||||
|
||||
@@ -2,9 +2,7 @@
|
||||
/* eslint no-eval: 0 */
|
||||
|
||||
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
|
||||
import {
|
||||
get, has, keys, isArray, isEmpty,
|
||||
} from 'lodash';
|
||||
import _ from 'lodash';
|
||||
import {
|
||||
ProblemTypeKeys,
|
||||
RichTextProblems,
|
||||
@@ -94,7 +92,7 @@ export class OLXParser {
|
||||
const parser = new XMLParser(parserOptions);
|
||||
this.builder = new XMLBuilder(builderOptions);
|
||||
this.parsedOLX = parser.parse(olxString);
|
||||
if (has(this.parsedOLX, 'problem')) {
|
||||
if (_.has(this.parsedOLX, 'problem')) {
|
||||
this.problem = this.parsedOLX.problem;
|
||||
}
|
||||
|
||||
@@ -114,7 +112,7 @@ export class OLXParser {
|
||||
const richTextParser = new XMLParser(richTextOptions);
|
||||
this.richTextBuilder = new XMLBuilder(richTextBuilderOptions);
|
||||
this.richTextOLX = richTextParser.parse(olxString);
|
||||
if (has(this.parsedOLX, 'problem')) {
|
||||
if (_.has(this.parsedOLX, 'problem')) {
|
||||
this.richTextProblem = this.richTextOLX[0].problem;
|
||||
}
|
||||
}
|
||||
@@ -190,17 +188,17 @@ export class OLXParser {
|
||||
);
|
||||
const answers = [];
|
||||
let data = {};
|
||||
const widget = get(this.problem, `${problemType}.${widgetName}`);
|
||||
const widget = _.get(this.problem, `${problemType}.${widgetName}`);
|
||||
const permissableTags = ['choice', '@_type', 'compoundhint', 'option', '#text'];
|
||||
if (keys(widget).some((tag) => !permissableTags.includes(tag))) {
|
||||
if (_.keys(widget).some((tag) => !permissableTags.includes(tag))) {
|
||||
throw new Error('Misc Tags, reverting to Advanced Editor');
|
||||
}
|
||||
if (get(this.problem, `${problemType}.@_partial_credit`)) {
|
||||
if (_.get(this.problem, `${problemType}.@_partial_credit`)) {
|
||||
throw new Error('Partial credit not supported by GUI, reverting to Advanced Editor');
|
||||
}
|
||||
const choice = get(widget, option);
|
||||
const choice = _.get(widget, option);
|
||||
const isComplexAnswer = RichTextProblems.includes(problemType);
|
||||
if (isEmpty(choice)) {
|
||||
if (_.isEmpty(choice)) {
|
||||
answers.push(
|
||||
{
|
||||
id: indexToLetterMap[answers.length],
|
||||
@@ -208,7 +206,7 @@ export class OLXParser {
|
||||
correct: true,
|
||||
},
|
||||
);
|
||||
} else if (isArray(choice)) {
|
||||
} else if (_.isArray(choice)) {
|
||||
choice.forEach((element, index) => {
|
||||
const preservedAnswer = preservedAnswers[index].filter(answer => !Object.keys(answer).includes(`${option}hint`));
|
||||
const preservedFeedback = preservedAnswers[index].filter(answer => Object.keys(answer).includes(`${option}hint`));
|
||||
@@ -267,11 +265,11 @@ export class OLXParser {
|
||||
getAnswerFeedback(preservedFeedback, hintKey) {
|
||||
const feedback = {};
|
||||
let feedbackKeys = 'selectedFeedback';
|
||||
if (isEmpty(preservedFeedback)) { return feedback; }
|
||||
if (_.isEmpty(preservedFeedback)) { return feedback; }
|
||||
|
||||
preservedFeedback.forEach((feedbackArr) => {
|
||||
if (has(feedbackArr, hintKey)) {
|
||||
if (has(feedbackArr, ':@') && has(feedbackArr[':@'], '@_selected')) {
|
||||
if (_.has(feedbackArr, hintKey)) {
|
||||
if (_.has(feedbackArr, ':@') && _.has(feedbackArr[':@'], '@_selected')) {
|
||||
const isSelectedFeedback = feedbackArr[':@']['@_selected'] === 'true';
|
||||
feedbackKeys = isSelectedFeedback ? 'selectedFeedback' : 'unselectedFeedback';
|
||||
}
|
||||
@@ -289,9 +287,9 @@ export class OLXParser {
|
||||
*/
|
||||
getGroupedFeedback(choices) {
|
||||
const groupFeedback = [];
|
||||
if (has(choices, 'compoundhint')) {
|
||||
if (_.has(choices, 'compoundhint')) {
|
||||
const groupFeedbackArray = choices.compoundhint;
|
||||
if (isArray(groupFeedbackArray)) {
|
||||
if (_.isArray(groupFeedbackArray)) {
|
||||
groupFeedbackArray.forEach((element) => {
|
||||
const parsedFeedback = stripNonTextTags({ input: element, tag: '@_value' });
|
||||
groupFeedback.push({
|
||||
@@ -340,12 +338,12 @@ export class OLXParser {
|
||||
selectedFeedback: firstFeedback,
|
||||
});
|
||||
|
||||
const additionalAnswerFeedback = preservedFeedback.filter(feedback => isArray(feedback));
|
||||
const stringEqualHintFeedback = preservedFeedback.filter(feedback => !isArray(feedback));
|
||||
const additionalAnswerFeedback = preservedFeedback.filter(feedback => _.isArray(feedback));
|
||||
const stringEqualHintFeedback = preservedFeedback.filter(feedback => !_.isArray(feedback));
|
||||
|
||||
// Parsing additional_answer for string response.
|
||||
const additionalAnswer = get(stringresponse, 'additional_answer', []);
|
||||
if (isArray(additionalAnswer)) {
|
||||
const additionalAnswer = _.get(stringresponse, 'additional_answer', []);
|
||||
if (_.isArray(additionalAnswer)) {
|
||||
additionalAnswer.forEach((newAnswer, indx) => {
|
||||
answerFeedback = this.getFeedback(additionalAnswerFeedback[indx]);
|
||||
answers.push({
|
||||
@@ -366,8 +364,8 @@ export class OLXParser {
|
||||
}
|
||||
|
||||
// Parsing stringequalhint for string response.
|
||||
const stringEqualHint = get(stringresponse, 'stringequalhint', []);
|
||||
if (isArray(stringEqualHint)) {
|
||||
const stringEqualHint = _.get(stringresponse, 'stringequalhint', []);
|
||||
if (_.isArray(stringEqualHint)) {
|
||||
stringEqualHint.forEach((newAnswer, indx) => {
|
||||
answerFeedback = this.getFeedback(stringEqualHintFeedback[indx]?.stringequalhint);
|
||||
answers.push({
|
||||
@@ -389,9 +387,9 @@ export class OLXParser {
|
||||
|
||||
// TODO: Support multiple types.
|
||||
additionalStringAttributes = {
|
||||
type: get(stringresponse, '@_type'),
|
||||
type: _.get(stringresponse, '@_type'),
|
||||
textline: {
|
||||
size: get(stringresponse, 'textline.@_size'),
|
||||
size: _.get(stringresponse, 'textline.@_size'),
|
||||
},
|
||||
};
|
||||
|
||||
@@ -418,16 +416,16 @@ export class OLXParser {
|
||||
'correcthint',
|
||||
);
|
||||
const { numericalresponse } = this.problem;
|
||||
if (get(numericalresponse, '@_partial_credit')) {
|
||||
if (_.get(numericalresponse, '@_partial_credit')) {
|
||||
throw new Error('Partial credit not supported by GUI, reverting to Advanced Editor');
|
||||
}
|
||||
let answerFeedback = '';
|
||||
const answers = [];
|
||||
let responseParam = {};
|
||||
const feedback = this.getFeedback(firstCorrectFeedback);
|
||||
if (has(numericalresponse, 'responseparam')) {
|
||||
const type = get(numericalresponse, 'responseparam.@_type');
|
||||
const defaultValue = get(numericalresponse, 'responseparam.@_default');
|
||||
if (_.has(numericalresponse, 'responseparam')) {
|
||||
const type = _.get(numericalresponse, 'responseparam.@_type');
|
||||
const defaultValue = _.get(numericalresponse, 'responseparam.@_default');
|
||||
responseParam = {
|
||||
[type]: defaultValue,
|
||||
};
|
||||
@@ -443,8 +441,8 @@ export class OLXParser {
|
||||
});
|
||||
|
||||
// Parsing additional_answer for numerical response.
|
||||
const additionalAnswer = get(numericalresponse, 'additional_answer', []);
|
||||
if (isArray(additionalAnswer)) {
|
||||
const additionalAnswer = _.get(numericalresponse, 'additional_answer', []);
|
||||
if (_.isArray(additionalAnswer)) {
|
||||
additionalAnswer.forEach((newAnswer, indx) => {
|
||||
answerFeedback = this.getFeedback(preservedFeedback[indx]);
|
||||
answers.push({
|
||||
@@ -477,7 +475,7 @@ export class OLXParser {
|
||||
* @return {string} string of OLX
|
||||
*/
|
||||
parseQuestions(problemType) {
|
||||
const problemArray = get(this.richTextProblem[0], problemType) || this.richTextProblem;
|
||||
const problemArray = _.get(this.richTextProblem[0], problemType) || this.richTextProblem;
|
||||
|
||||
const questionArray = [];
|
||||
problemArray.forEach(tag => {
|
||||
@@ -550,7 +548,7 @@ export class OLXParser {
|
||||
*/
|
||||
getHints() {
|
||||
const hintsObject = [];
|
||||
if (has(this.problem, 'demandhint.hint')) {
|
||||
if (_.has(this.problem, 'demandhint.hint')) {
|
||||
const preservedProblem = this.richTextProblem;
|
||||
preservedProblem.forEach(obj => {
|
||||
const objKeys = Object.keys(obj);
|
||||
@@ -580,21 +578,21 @@ export class OLXParser {
|
||||
* @return {string} string of OLX
|
||||
*/
|
||||
getSolutionExplanation(problemType) {
|
||||
if (!has(this.problem, `${problemType}.solution`) && !has(this.problem, 'solution')) { return null; }
|
||||
if (!_.has(this.problem, `${problemType}.solution`) && !_.has(this.problem, 'solution')) { return null; }
|
||||
const [problemBody] = this.richTextProblem.filter(section => Object.keys(section).includes(problemType));
|
||||
const [solutionBody] = problemBody[problemType].filter(section => Object.keys(section).includes('solution'));
|
||||
const [divBody] = solutionBody.solution.filter(section => Object.keys(section).includes('div'));
|
||||
const solutionArray = [];
|
||||
if (divBody && divBody.div) {
|
||||
divBody.div.forEach(tag => {
|
||||
const tagText = get(Object.values(tag)[0][0], '#text', '');
|
||||
const tagText = _.get(Object.values(tag)[0][0], '#text', '');
|
||||
if (tagText.toString().trim() !== 'Explanation') {
|
||||
solutionArray.push(tag);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
solutionBody.solution.forEach(tag => {
|
||||
const tagText = get(Object.values(tag)[0][0], '#text', '');
|
||||
const tagText = _.get(Object.values(tag)[0][0], '#text', '');
|
||||
if (tagText.toString().trim() !== 'Explanation') {
|
||||
solutionArray.push(tag);
|
||||
}
|
||||
@@ -612,7 +610,7 @@ export class OLXParser {
|
||||
* @return {string} string of feedback
|
||||
*/
|
||||
getFeedback(xmlElement) {
|
||||
if (isEmpty(xmlElement)) { return ''; }
|
||||
if (_.isEmpty(xmlElement)) { return ''; }
|
||||
const feedbackString = this.richTextBuilder.build(xmlElement);
|
||||
return feedbackString;
|
||||
}
|
||||
@@ -638,7 +636,7 @@ export class OLXParser {
|
||||
}
|
||||
// make sure compound problems are treated as advanced
|
||||
if ((problemTypeKeys.length > 1)
|
||||
|| (isArray(this.problem[problemTypeKeys[0]])
|
||||
|| (_.isArray(this.problem[problemTypeKeys[0]])
|
||||
&& this.problem[problemTypeKeys[0]].length > 1)) {
|
||||
return ProblemTypeKeys.ADVANCED;
|
||||
}
|
||||
@@ -673,7 +671,7 @@ export class OLXParser {
|
||||
}
|
||||
|
||||
getParsedOLXData() {
|
||||
if (isEmpty(this.problem)) {
|
||||
if (_.isEmpty(this.problem)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
@@ -722,16 +720,16 @@ export class OLXParser {
|
||||
return {};
|
||||
}
|
||||
const generalFeedback = this.getGeneralFeedback({ answers: answersObject.answers, problemType });
|
||||
if (has(answersObject, 'additionalStringAttributes')) {
|
||||
if (_.has(answersObject, 'additionalStringAttributes')) {
|
||||
additionalAttributes = { ...answersObject.additionalStringAttributes };
|
||||
}
|
||||
|
||||
if (has(answersObject, 'groupFeedbackList')) {
|
||||
if (_.has(answersObject, 'groupFeedbackList')) {
|
||||
groupFeedbackList = answersObject.groupFeedbackList;
|
||||
}
|
||||
const { answers } = answersObject;
|
||||
const settings = { hints };
|
||||
if (ProblemTypeKeys.NUMERIC === problemType && has(answers[0], 'tolerance')) {
|
||||
if (ProblemTypeKeys.NUMERIC === problemType && _.has(answers[0], 'tolerance')) {
|
||||
const toleranceValue = answers[0].tolerance;
|
||||
if (!toleranceValue || toleranceValue.length === 0) {
|
||||
settings.tolerance = { value: null, type: 'None' };
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { get, has } from 'lodash';
|
||||
import _ from 'lodash';
|
||||
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
|
||||
import { ProblemTypeKeys } from '../../../data/constants/problem';
|
||||
import { ToleranceTypes } from '../components/EditProblemView/SettingsWidget/settingsComponents/Tolerance/constants';
|
||||
@@ -169,7 +169,7 @@ class ReactStateOLXParser {
|
||||
choice.push(singleAnswer);
|
||||
}
|
||||
});
|
||||
if (has(this.problemState, 'groupFeedbackList') && problemType === ProblemTypeKeys.MULTISELECT) {
|
||||
if (_.has(this.problemState, 'groupFeedbackList') && problemType === ProblemTypeKeys.MULTISELECT) {
|
||||
compoundhint = this.addGroupFeedbackList();
|
||||
choice.push(...compoundhint);
|
||||
}
|
||||
@@ -333,7 +333,7 @@ class ReactStateOLXParser {
|
||||
answerObject = {
|
||||
':@': {
|
||||
'@_answer': answer.title,
|
||||
'@_type': get(this.problemState, 'additionalAttributes.type', 'ci'),
|
||||
'@_type': _.get(this.problemState, 'additionalAttributes.type', 'ci'),
|
||||
},
|
||||
[problemType]: [...correcthint],
|
||||
};
|
||||
@@ -348,7 +348,7 @@ class ReactStateOLXParser {
|
||||
});
|
||||
answerObject[problemType].push({
|
||||
textline: { '#text': '' },
|
||||
':@': { '@_size': get(this.problemState, 'additionalAttributes.textline.size', 20) },
|
||||
':@': { '@_size': _.get(this.problemState, 'additionalAttributes.textline.size', 20) },
|
||||
});
|
||||
return answerObject;
|
||||
}
|
||||
@@ -490,7 +490,7 @@ class ReactStateOLXParser {
|
||||
* @return {bool}
|
||||
*/
|
||||
hasAttributeWithValue(obj, attr) {
|
||||
return has(obj, attr) && get(obj, attr, '').toString().trim() !== '';
|
||||
return _.has(obj, attr) && _.get(obj, attr, '').toString().trim() !== '';
|
||||
}
|
||||
|
||||
buildOLX() {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { XMLParser } from 'fast-xml-parser';
|
||||
import { includes } from 'lodash';
|
||||
import _ from 'lodash';
|
||||
|
||||
import {
|
||||
ShowAnswerTypesKeys,
|
||||
@@ -34,7 +34,7 @@ class ReactStateSettingsParser {
|
||||
settings = popuplateItem(settings, 'number', 'max_attempts', stateSettings.scoring.attempts, defaultSettings?.maxAttempts, true);
|
||||
settings = popuplateItem(settings, 'weight', 'weight', stateSettings.scoring);
|
||||
settings = popuplateItem(settings, 'on', 'showanswer', stateSettings.showAnswer, defaultSettings?.showanswer, true);
|
||||
if (includes(numberOfAttemptsChoice, stateSettings.showAnswer.on)) {
|
||||
if (_.includes(numberOfAttemptsChoice, stateSettings.showAnswer.on)) {
|
||||
settings = popuplateItem(settings, 'afterAttempts', 'attempts_before_showanswer_button', stateSettings.showAnswer);
|
||||
}
|
||||
settings = popuplateItem(settings, 'showResetButton', 'show_reset_button', stateSettings, defaultSettings?.showResetButton, true);
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import {
|
||||
get, isEmpty, isFinite, isNil,
|
||||
} from 'lodash';
|
||||
import _ from 'lodash';
|
||||
|
||||
import { ShowAnswerTypes, RandomizationTypesKeys } from '../../../data/constants/problem';
|
||||
|
||||
export const popuplateItem = (parentObject, itemName, statekey, metadata, defaultValue = null, allowNull = false) => {
|
||||
let parent = parentObject;
|
||||
const item = get(metadata, itemName, null);
|
||||
const item = _.get(metadata, itemName, null);
|
||||
|
||||
// if item is null, undefined, or empty string, use defaultValue
|
||||
const finalValue = (isNil(item) || item === '') ? defaultValue : item;
|
||||
const finalValue = (_.isNil(item) || item === '') ? defaultValue : item;
|
||||
|
||||
if (!isNil(finalValue) || allowNull) {
|
||||
if (!_.isNil(finalValue) || allowNull) {
|
||||
parent = { ...parentObject, [statekey]: finalValue };
|
||||
}
|
||||
return parent;
|
||||
@@ -21,18 +19,18 @@ export const parseScoringSettings = (metadata, defaultSettings) => {
|
||||
let scoring = {};
|
||||
|
||||
const attempts = popuplateItem({}, 'max_attempts', 'number', metadata);
|
||||
const initialAttempts = get(attempts, 'number', null);
|
||||
const defaultAttempts = get(defaultSettings, 'max_attempts', null);
|
||||
const initialAttempts = _.get(attempts, 'number', null);
|
||||
const defaultAttempts = _.get(defaultSettings, 'max_attempts', null);
|
||||
attempts.unlimited = false;
|
||||
|
||||
// isFinite checks if value is a finite primitive number.
|
||||
if (!isFinite(initialAttempts) || initialAttempts === defaultAttempts) {
|
||||
if (!_.isFinite(initialAttempts) || initialAttempts === defaultAttempts) {
|
||||
// set number to null in any case as lms will pick default value if it exists.
|
||||
attempts.number = null;
|
||||
}
|
||||
|
||||
// if both block number and default number are null set unlimited to true.
|
||||
if (isNil(initialAttempts) && isNil(defaultAttempts)) {
|
||||
if (_.isNil(initialAttempts) && _.isNil(defaultAttempts)) {
|
||||
attempts.unlimited = true;
|
||||
}
|
||||
|
||||
@@ -50,8 +48,8 @@ export const parseScoringSettings = (metadata, defaultSettings) => {
|
||||
export const parseShowAnswer = (metadata) => {
|
||||
let showAnswer = {};
|
||||
|
||||
const showAnswerType = get(metadata, 'showanswer', {});
|
||||
if (!isNil(showAnswerType) && showAnswerType in ShowAnswerTypes) {
|
||||
const showAnswerType = _.get(metadata, 'showanswer', {});
|
||||
if (!_.isNil(showAnswerType) && showAnswerType in ShowAnswerTypes) {
|
||||
showAnswer = { ...showAnswer, on: showAnswerType };
|
||||
}
|
||||
|
||||
@@ -63,22 +61,22 @@ export const parseShowAnswer = (metadata) => {
|
||||
export const parseSettings = (metadata, defaultSettings) => {
|
||||
let settings = {};
|
||||
|
||||
if (isNil(metadata) || isEmpty(metadata)) {
|
||||
if (_.isNil(metadata) || _.isEmpty(metadata)) {
|
||||
return settings;
|
||||
}
|
||||
|
||||
const scoring = parseScoringSettings(metadata, defaultSettings);
|
||||
if (!isEmpty(scoring)) {
|
||||
if (!_.isEmpty(scoring)) {
|
||||
settings = { ...settings, scoring };
|
||||
}
|
||||
|
||||
const showAnswer = parseShowAnswer(metadata);
|
||||
if (!isEmpty(showAnswer)) {
|
||||
if (!_.isEmpty(showAnswer)) {
|
||||
settings = { ...settings, showAnswer };
|
||||
}
|
||||
|
||||
const randomizationType = get(metadata, 'rerandomize', {});
|
||||
if (!isEmpty(randomizationType) && Object.values(RandomizationTypesKeys).includes(randomizationType)) {
|
||||
const randomizationType = _.get(metadata, 'rerandomize', {});
|
||||
if (!_.isEmpty(randomizationType) && Object.values(RandomizationTypesKeys).includes(randomizationType)) {
|
||||
settings = popuplateItem(settings, 'rerandomize', 'randomization', metadata);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { has } from 'lodash';
|
||||
import _ from 'lodash';
|
||||
import { createSlice } from '@reduxjs/toolkit';
|
||||
import { indexToLetterMap } from '../../../containers/ProblemEditor/data/OLXParser';
|
||||
import { StrictDict } from '../../../utils';
|
||||
@@ -61,17 +61,17 @@ const problem = createSlice({
|
||||
let { correctAnswerCount } = state;
|
||||
const answers = state.answers.map(obj => {
|
||||
if (obj.id === id) {
|
||||
if (has(answer, 'correct') && payload.correct) {
|
||||
if (_.has(answer, 'correct') && payload.correct) {
|
||||
correctAnswerCount += 1;
|
||||
}
|
||||
if (has(answer, 'correct') && payload.correct === false && correctAnswerCount > 0) {
|
||||
if (_.has(answer, 'correct') && payload.correct === false && correctAnswerCount > 0) {
|
||||
correctAnswerCount -= 1;
|
||||
}
|
||||
return { ...obj, ...answer };
|
||||
}
|
||||
// set other answers as incorrect if problem only has one answer correct
|
||||
// and changes object include correct key change
|
||||
if (hasSingleAnswer && has(answer, 'correct') && obj.correct) {
|
||||
if (hasSingleAnswer && _.has(answer, 'correct') && obj.correct) {
|
||||
return { ...obj, correct: false };
|
||||
}
|
||||
return obj;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { get, isEmpty } from 'lodash';
|
||||
import _ from 'lodash';
|
||||
import { actions as problemActions } from '../problem';
|
||||
import { actions as requestActions } from '../requests';
|
||||
import { selectors as appSelectors } from '../app';
|
||||
@@ -47,7 +47,7 @@ export const getDataFromOlx = ({ rawOLX, rawSettings, defaultSettings }) => {
|
||||
}
|
||||
const { settings, ...data } = parsedProblem;
|
||||
const parsedSettings = { ...settings, ...parseSettings(rawSettings, defaultSettings) };
|
||||
if (!isEmpty(rawOLX) && !isEmpty(data)) {
|
||||
if (!_.isEmpty(rawOLX) && !_.isEmpty(data)) {
|
||||
return { ...data, rawOLX, settings: parsedSettings };
|
||||
}
|
||||
return { settings: parsedSettings };
|
||||
@@ -79,8 +79,8 @@ export const fetchAdvancedSettings = ({ rawOLX, rawSettings }) => (dispatch) =>
|
||||
};
|
||||
|
||||
export const initializeProblem = (blockValue) => (dispatch, getState) => {
|
||||
const rawOLX = get(blockValue, 'data.data', '');
|
||||
const rawSettings = get(blockValue, 'data.metadata', {});
|
||||
const rawOLX = _.get(blockValue, 'data.data', '');
|
||||
const rawSettings = _.get(blockValue, 'data.metadata', {});
|
||||
const learningContextId = selectors.app.learningContextId(getState());
|
||||
if (isLibraryKey(learningContextId)) {
|
||||
// Content libraries don't yet support defaults for fields like max_attempts, showanswer, etc.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { has, find, isEmpty } from 'lodash';
|
||||
import _, { isEmpty } from 'lodash';
|
||||
import { removeItemOnce } from '../../../utils';
|
||||
import * as requests from './requests';
|
||||
// This 'module' self-import hack enables mocking during tests.
|
||||
@@ -21,8 +21,8 @@ export const loadVideoData = (selectedVideoId, selectedVideoUrl) => (dispatch, g
|
||||
let rawVideoData = blockValueData?.metadata ? blockValueData.metadata : {};
|
||||
const rawVideos = Object.values(selectors.app.videos(state));
|
||||
if (selectedVideoId !== undefined && selectedVideoId !== null) {
|
||||
const selectedVideo = find(rawVideos, video => {
|
||||
if (has(video, 'edx_video_id')) {
|
||||
const selectedVideo = _.find(rawVideos, video => {
|
||||
if (_.has(video, 'edx_video_id')) {
|
||||
return video.edx_video_id === selectedVideoId;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import React, { useCallback } from 'react';
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import {
|
||||
DndContext,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
DragOverlay,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
@@ -24,9 +22,6 @@ const DraggableList = ({
|
||||
setState,
|
||||
updateOrder,
|
||||
children,
|
||||
renderOverlay,
|
||||
activeId,
|
||||
setActiveId,
|
||||
}) => {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor),
|
||||
@@ -35,7 +30,7 @@ const DraggableList = ({
|
||||
}),
|
||||
);
|
||||
|
||||
const handleDragEnd = useCallback((event) => {
|
||||
const handleDragEnd = (event) => {
|
||||
const { active, over } = event;
|
||||
if (active.id !== over.id) {
|
||||
let updatedArray;
|
||||
@@ -49,19 +44,13 @@ const DraggableList = ({
|
||||
});
|
||||
updateOrder()(updatedArray);
|
||||
}
|
||||
setActiveId?.(null);
|
||||
}, [updateOrder, setActiveId]);
|
||||
|
||||
const handleDragStart = useCallback((event) => {
|
||||
setActiveId?.(event.active.id);
|
||||
}, [setActiveId]);
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
collisionDetection={closestCenter}
|
||||
onDragStart={handleDragStart}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
@@ -70,22 +59,10 @@ const DraggableList = ({
|
||||
>
|
||||
{children}
|
||||
</SortableContext>
|
||||
{renderOverlay && createPortal(
|
||||
<DragOverlay>
|
||||
{renderOverlay(activeId)}
|
||||
</DragOverlay>,
|
||||
document.body,
|
||||
)}
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
DraggableList.defaultProps = {
|
||||
renderOverlay: undefined,
|
||||
activeId: null,
|
||||
setActiveId: () => {},
|
||||
};
|
||||
|
||||
DraggableList.propTypes = {
|
||||
itemList: PropTypes.arrayOf(PropTypes.shape({
|
||||
id: PropTypes.string.isRequired,
|
||||
@@ -93,9 +70,6 @@ DraggableList.propTypes = {
|
||||
setState: PropTypes.func.isRequired,
|
||||
updateOrder: PropTypes.func.isRequired,
|
||||
children: PropTypes.node.isRequired,
|
||||
renderOverlay: PropTypes.func,
|
||||
activeId: PropTypes.string,
|
||||
setActiveId: PropTypes.func,
|
||||
};
|
||||
|
||||
export default DraggableList;
|
||||
@@ -17,7 +17,6 @@ const SortableItem = ({
|
||||
children,
|
||||
isClickable,
|
||||
onClick,
|
||||
disabled,
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
@@ -32,9 +31,6 @@ const SortableItem = ({
|
||||
} = useSortable({
|
||||
id,
|
||||
animateLayoutChanges: () => false,
|
||||
disabled: {
|
||||
draggable: disabled,
|
||||
},
|
||||
});
|
||||
|
||||
const style = {
|
||||
@@ -56,7 +52,6 @@ const SortableItem = ({
|
||||
>
|
||||
<ActionRow style={actionStyle}>
|
||||
{actions}
|
||||
{!disabled && (
|
||||
<IconButtonWithTooltip
|
||||
key="drag-to-reorder-icon"
|
||||
ref={setActivatorNodeRef}
|
||||
@@ -69,7 +64,6 @@ const SortableItem = ({
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
/>
|
||||
)}
|
||||
</ActionRow>
|
||||
{children}
|
||||
</Card>
|
||||
@@ -82,7 +76,6 @@ SortableItem.defaultProps = {
|
||||
actionStyle: null,
|
||||
isClickable: false,
|
||||
onClick: null,
|
||||
disabled: false,
|
||||
};
|
||||
SortableItem.propTypes = {
|
||||
id: PropTypes.string.isRequired,
|
||||
@@ -92,7 +85,6 @@ SortableItem.propTypes = {
|
||||
componentStyle: PropTypes.shape({}),
|
||||
isClickable: PropTypes.bool,
|
||||
onClick: PropTypes.func,
|
||||
disabled: PropTypes.bool,
|
||||
// injected
|
||||
intl: intlShape.isRequired,
|
||||
};
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { isEmpty } from 'lodash';
|
||||
import _ from 'lodash';
|
||||
import { Form } from '@openedx/paragon';
|
||||
|
||||
const FormGroup = (props) => {
|
||||
@@ -35,13 +35,13 @@ const FormGroup = (props) => {
|
||||
|
||||
{props.children}
|
||||
|
||||
{props.helpText && isEmpty(props.errorMessage) && (
|
||||
{props.helpText && _.isEmpty(props.errorMessage) && (
|
||||
<Form.Control.Feedback type="default" key="help-text">
|
||||
{props.helpText}
|
||||
</Form.Control.Feedback>
|
||||
)}
|
||||
|
||||
{!isEmpty(props.errorMessage) && (
|
||||
{!_.isEmpty(props.errorMessage) && (
|
||||
<Form.Control.Feedback
|
||||
type="invalid"
|
||||
key="error"
|
||||
|
||||
@@ -40,27 +40,6 @@ export const initialState = {
|
||||
},
|
||||
},
|
||||
},
|
||||
videos: {
|
||||
videoIds: ['mOckID1'],
|
||||
pageSettings: {},
|
||||
loadingStatus: RequestStatus.SUCCESSFUL,
|
||||
updatingStatus: '',
|
||||
addingStatus: '',
|
||||
deletingStatus: '',
|
||||
usageStatus: '',
|
||||
transcriptStatus: '',
|
||||
errors: {
|
||||
add: [],
|
||||
delete: [],
|
||||
thumbnail: [],
|
||||
download: [],
|
||||
usageMetrics: [],
|
||||
transcript: [],
|
||||
loading: '',
|
||||
},
|
||||
filesCurrentView: 'card',
|
||||
videosCurrentView: 'list',
|
||||
},
|
||||
};
|
||||
|
||||
export const generateFetchAssetApiResponse = () => ({
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import isEmpty from 'lodash/isEmpty';
|
||||
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
|
||||
@@ -27,10 +26,6 @@ import {
|
||||
} from './table-components';
|
||||
import ApiStatusToast from './ApiStatusToast';
|
||||
import DeleteConfirmationModal from './DeleteConfirmationModal';
|
||||
import {
|
||||
setFilesCurrentViewState,
|
||||
setVideosCurrentViewState,
|
||||
} from '../videos-page/data/slice';
|
||||
|
||||
const FileTable = ({
|
||||
files,
|
||||
@@ -49,7 +44,7 @@ const FileTable = ({
|
||||
// injected
|
||||
intl,
|
||||
}) => {
|
||||
const dispatch = useDispatch();
|
||||
const defaultVal = 'card';
|
||||
const pageCount = Math.ceil(files.length / 50);
|
||||
const columnSizes = {
|
||||
xs: 12,
|
||||
@@ -58,10 +53,7 @@ const FileTable = ({
|
||||
lg: 3,
|
||||
xl: 2,
|
||||
};
|
||||
const {
|
||||
filesCurrentView,
|
||||
videosCurrentView,
|
||||
} = useSelector((state) => state.videos);
|
||||
const [currentView, setCurrentView] = useState(defaultVal);
|
||||
const [isDeleteOpen, setDeleteOpen, setDeleteClose] = useToggle(false);
|
||||
const [isDownloadOpen, setDownloadOpen, setDownloadClose] = useToggle(false);
|
||||
const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false);
|
||||
@@ -206,15 +198,8 @@ const FileTable = ({
|
||||
defaultColumnValues={{ Filter: TextFilter }}
|
||||
dataViewToggleOptions={{
|
||||
isDataViewToggleEnabled: true,
|
||||
onDataViewToggle: (val) => {
|
||||
if (fileType === 'video') {
|
||||
dispatch(setVideosCurrentViewState({ videosCurrentView: val }));
|
||||
} else {
|
||||
// There's only 2 fileTypes currently being used i.e. video or file
|
||||
dispatch(setFilesCurrentViewState({ filesCurrentView: val }));
|
||||
}
|
||||
},
|
||||
defaultActiveStateValue: (fileType === 'video' && videosCurrentView) || (fileType === 'file' && filesCurrentView),
|
||||
onDataViewToggle: val => setCurrentView(val),
|
||||
defaultActiveStateValue: defaultVal,
|
||||
togglePlacement: 'left',
|
||||
}}
|
||||
initialState={initialState}
|
||||
@@ -242,8 +227,8 @@ const FileTable = ({
|
||||
<div data-testid="files-data-table" className="bg-light-200">
|
||||
<DataTable.TableControlBar />
|
||||
<hr className="mb-5 border-light-700" />
|
||||
{ ((fileType === 'video' && videosCurrentView === 'card') || (fileType === 'file' && filesCurrentView === 'card')) && <CardView CardComponent={fileCard} columnSizes={columnSizes} selectionPlacement="left" skeletonCardCount={6} /> }
|
||||
{ ((fileType === 'video' && videosCurrentView === 'list') || (fileType === 'file' && filesCurrentView === 'list')) && <DataTable.Table /> }
|
||||
{ currentView === 'card' && <CardView CardComponent={fileCard} columnSizes={columnSizes} selectionPlacement="left" skeletonCardCount={6} /> }
|
||||
{ currentView === 'list' && <DataTable.Table /> }
|
||||
<DataTable.EmptyTable content={intl.formatMessage(messages.noResultsFoundMessage)} />
|
||||
<Footer />
|
||||
</div>
|
||||
@@ -258,7 +243,7 @@ const FileTable = ({
|
||||
fileType={fileType}
|
||||
/>
|
||||
|
||||
{fileType === 'file' && (
|
||||
{fileType === 'files' && (
|
||||
<ApiStatusToast
|
||||
actionType={intl.formatMessage(messages.apiStatusAddingAction)}
|
||||
selectedRowCount={selectedRows.length}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useContext, useEffect } from 'react';
|
||||
import { isEmpty } from 'lodash';
|
||||
import _ from 'lodash';
|
||||
import { PropTypes } from 'prop-types';
|
||||
import { injectIntl, intlShape, FormattedMessage } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
@@ -57,7 +57,7 @@ const TableActions = ({
|
||||
) : null}
|
||||
<Dropdown.Item
|
||||
onClick={() => handleBulkDownload(selectedFlatRows)}
|
||||
disabled={isEmpty(selectedFlatRows)}
|
||||
disabled={_.isEmpty(selectedFlatRows)}
|
||||
>
|
||||
<FormattedMessage {...messages.downloadTitle} />
|
||||
</Dropdown.Item>
|
||||
@@ -65,7 +65,7 @@ const TableActions = ({
|
||||
<Dropdown.Item
|
||||
data-testid="open-delete-confirmation-button"
|
||||
onClick={() => handleOpenDeleteConfirmation(selectedFlatRows)}
|
||||
disabled={isEmpty(selectedFlatRows)}
|
||||
disabled={_.isEmpty(selectedFlatRows)}
|
||||
>
|
||||
<FormattedMessage {...messages.deleteTitle} />
|
||||
</Dropdown.Item>
|
||||
|
||||
@@ -23,19 +23,11 @@ const slice = createSlice({
|
||||
transcript: [],
|
||||
loading: '',
|
||||
},
|
||||
filesCurrentView: 'list',
|
||||
videosCurrentView: 'list',
|
||||
},
|
||||
reducers: {
|
||||
setVideoIds: (state, { payload }) => {
|
||||
state.videoIds = payload.videoIds;
|
||||
},
|
||||
setVideosCurrentViewState: (state, { payload }) => {
|
||||
state.videosCurrentView = payload.videosCurrentView;
|
||||
},
|
||||
setFilesCurrentViewState: (state, { payload }) => {
|
||||
state.filesCurrentView = payload.filesCurrentView;
|
||||
},
|
||||
setPageSettings: (state, { payload }) => {
|
||||
state.pageSettings = payload;
|
||||
},
|
||||
@@ -106,8 +98,6 @@ const slice = createSlice({
|
||||
|
||||
export const {
|
||||
setVideoIds,
|
||||
setVideosCurrentViewState,
|
||||
setFilesCurrentViewState,
|
||||
setPageSettings,
|
||||
updateLoadingStatus,
|
||||
deleteVideoSuccess,
|
||||
|
||||
@@ -84,8 +84,6 @@ export const initialState = {
|
||||
transcript: [],
|
||||
loading: '',
|
||||
},
|
||||
filesCurrentView: 'list',
|
||||
videosCurrentView: 'card',
|
||||
},
|
||||
models: {
|
||||
videos: {
|
||||
|
||||
@@ -2,13 +2,12 @@ import { renderHook } from '@testing-library/react';
|
||||
import MockAdapter from 'axios-mock-adapter';
|
||||
|
||||
import {
|
||||
clipboardSubsection,
|
||||
clipboardUnit,
|
||||
clipboardXBlock,
|
||||
} from '../../../__mocks__';
|
||||
import { initializeMocks, makeWrapper } from '../../../testUtils';
|
||||
import { getClipboardUrl } from '../../data/api';
|
||||
import useClipboard, { _testingOverrideBroadcastChannel } from './useClipboard';
|
||||
import useClipboard from './useClipboard';
|
||||
|
||||
initializeMocks();
|
||||
|
||||
@@ -17,14 +16,13 @@ let mockShowToast: jest.Mock;
|
||||
|
||||
const unitId = 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_0270f6de40fc';
|
||||
const xblockId = 'block-v1:edX+DemoX+Demo_Course+type@html+block@030e35c4756a4ddc8d40b95fbbfff4d4';
|
||||
|
||||
let broadcastMockListener: (x: unknown) => void | undefined;
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: (message: unknown) => { broadcastMockListener(message); },
|
||||
addEventListener: (_eventName: string, handler: typeof broadcastMockListener) => { broadcastMockListener = handler; },
|
||||
removeEventListener: jest.fn(),
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
onmessage: jest.fn(),
|
||||
};
|
||||
_testingOverrideBroadcastChannel(clipboardBroadcastChannelMock as any);
|
||||
|
||||
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
describe('useClipboard', () => {
|
||||
beforeEach(async () => {
|
||||
@@ -43,7 +41,7 @@ describe('useClipboard', () => {
|
||||
|
||||
axiosMock
|
||||
.onPost(getClipboardUrl())
|
||||
.reply(200, clipboardSubsection);
|
||||
.reply(200, clipboardUnit);
|
||||
|
||||
await result.current.copyToClipboard(unitId);
|
||||
|
||||
@@ -90,15 +88,14 @@ describe('useClipboard', () => {
|
||||
describe('broadcast channel message handling', () => {
|
||||
it('updates states correctly on receiving a broadcast message', async () => {
|
||||
const { result, rerender } = renderHook(() => useClipboard(true), { wrapper: makeWrapper() });
|
||||
// Subsections cannot be pasted:
|
||||
clipboardBroadcastChannelMock.postMessage({ data: clipboardUnit });
|
||||
clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit });
|
||||
|
||||
rerender();
|
||||
|
||||
expect(result.current.showPasteUnit).toBe(true);
|
||||
expect(result.current.showPasteXBlock).toBe(false);
|
||||
|
||||
clipboardBroadcastChannelMock.postMessage({ data: clipboardXBlock });
|
||||
clipboardBroadcastChannelMock.onmessage({ data: clipboardXBlock });
|
||||
rerender();
|
||||
|
||||
expect(result.current.showPasteUnit).toBe(false);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useCallback, useContext, useEffect } from 'react';
|
||||
import { useContext, useEffect, useState } from 'react';
|
||||
|
||||
import { getClipboard, updateClipboard } from '../../data/api';
|
||||
import {
|
||||
@@ -11,14 +11,6 @@ import {
|
||||
import { ToastContext } from '../../toast-context';
|
||||
import messages from './messages';
|
||||
|
||||
// Global, shared broadcast channel for the clipboard. Disabled by default in test environment where it's not defined.
|
||||
let clipboardBroadcastChannel = (
|
||||
typeof BroadcastChannel !== 'undefined' ? new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL) : null
|
||||
);
|
||||
/** To allow mocking the broadcast channel for testing */
|
||||
// eslint-disable-next-line
|
||||
export const _testingOverrideBroadcastChannel = (x: BroadcastChannel) => { clipboardBroadcastChannel = x; };
|
||||
|
||||
/**
|
||||
* Custom React hook for managing clipboard functionality.
|
||||
*
|
||||
@@ -31,6 +23,7 @@ export const _testingOverrideBroadcastChannel = (x: BroadcastChannel) => { clipb
|
||||
*/
|
||||
const useClipboard = (canEdit: boolean = true) => {
|
||||
const intl = useIntl();
|
||||
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
|
||||
const { data: clipboardData } = useQuery({
|
||||
queryKey: ['clipboard'],
|
||||
queryFn: getClipboard,
|
||||
@@ -40,48 +33,37 @@ const useClipboard = (canEdit: boolean = true) => {
|
||||
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const copyToClipboard = useCallback(async (usageKey: string) => {
|
||||
const copyToClipboard = async (usageKey: string) => {
|
||||
// This code is synchronous for now, but it could be made asynchronous in the future.
|
||||
// In that case, the `done` message should be shown after the asynchronous operation completes.
|
||||
showToast(intl.formatMessage(messages.copying));
|
||||
let newData;
|
||||
try {
|
||||
newData = await updateClipboard(usageKey);
|
||||
const newData = await updateClipboard(usageKey);
|
||||
clipboardBroadcastChannel.postMessage(newData);
|
||||
queryClient.setQueryData(['clipboard'], newData);
|
||||
showToast(intl.formatMessage(messages.done));
|
||||
} catch (error) {
|
||||
showToast(intl.formatMessage(messages.error));
|
||||
return;
|
||||
}
|
||||
// Update the clipboard state across all other open browser tabs too:
|
||||
try {
|
||||
clipboardBroadcastChannel?.postMessage(newData);
|
||||
} catch (error) {
|
||||
// Log the error but no need to show it to the user.
|
||||
// istanbul ignore next
|
||||
// eslint-disable-next-line no-console
|
||||
console.error('Unable to sync clipboard state across other open tabs:', error);
|
||||
}
|
||||
showToast(intl.formatMessage(messages.done));
|
||||
}, [showToast, intl, queryClient]);
|
||||
|
||||
const handleBroadcastMessage = useCallback((event: MessageEvent) => {
|
||||
// Note: if this useClipboard() hook is used many times on one page,
|
||||
// this will result in many separate calls to setQueryData() whenever
|
||||
// the clipboard contents change, but that is fine and shouldn't actually
|
||||
// cause any issues. If it did, we could refactor this into a
|
||||
// <ClipboardContextProvider> that manages a single clipboardBroadcastChannel
|
||||
// rather than having a separate channel per useClipboard hook.
|
||||
queryClient.setQueryData(['clipboard'], event.data);
|
||||
}, [queryClient]);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Handle messages from the broadcast channel
|
||||
clipboardBroadcastChannel?.addEventListener('message', handleBroadcastMessage);
|
||||
clipboardBroadcastChannel.onmessage = (event) => {
|
||||
// Note: if this useClipboard() hook is used many times on one page,
|
||||
// this will result in many separate calls to setQueryData() whenever
|
||||
// the clipboard contents change, but that is fine and shouldn't actually
|
||||
// cause any issues. If it did, we could refactor this into a
|
||||
// <ClipboardContextProvider> that manages a single clipboardBroadcastChannel
|
||||
// rather than having a separate channel per useClipboard hook.
|
||||
queryClient.setQueryData(['clipboard'], event.data);
|
||||
};
|
||||
|
||||
// Cleanup function for the BroadcastChannel when the hook is unmounted
|
||||
return () => {
|
||||
clipboardBroadcastChannel?.removeEventListener('message', handleBroadcastMessage);
|
||||
clipboardBroadcastChannel.close();
|
||||
};
|
||||
}, []);
|
||||
}, [clipboardBroadcastChannel]);
|
||||
|
||||
const isPasteable = canEdit && clipboardData?.content?.status !== CLIPBOARD_STATUS.expired;
|
||||
const showPasteUnit = isPasteable && clipboardData?.content?.blockType === 'vertical';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import last from 'lodash/last';
|
||||
import _ from 'lodash';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { FileUpload as FileUploadIcon } from '@openedx/paragon/icons';
|
||||
@@ -35,9 +35,9 @@ const CourseUploadImage = ({
|
||||
const assetsUrl = () => new URL(`/assets/${courseId}`, getConfig().STUDIO_BASE_URL);
|
||||
|
||||
const handleChangeImageAsset = (path) => {
|
||||
const assetPath = last(path.split('/'));
|
||||
const assetPath = _.last(path.split('/'));
|
||||
// If image path is entered directly, we need to strip the asset prefix
|
||||
const imageName = last(assetPath.split('block@'));
|
||||
const imageName = _.last(assetPath.split('block@'));
|
||||
onChange(path, assetImageField);
|
||||
if (imageNameField) {
|
||||
onChange(imageName, imageNameField);
|
||||
|
||||
@@ -38,3 +38,12 @@ export async function mockClipboardHtml(blockType?: string): Promise<api.Clipboa
|
||||
}
|
||||
mockClipboardHtml.applyMock = (blockType?: string) => jest.spyOn(api, 'getClipboard').mockImplementation(() => mockClipboardHtml(blockType));
|
||||
mockClipboardHtml.applyMockOnce = () => jest.spyOn(api, 'getClipboard').mockImplementationOnce(mockClipboardHtml);
|
||||
|
||||
/** Mock the DOM `BroadcastChannel` API which the clipboard code uses */
|
||||
export function mockBroadcastChannel() {
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { getConfig } from '@edx/frontend-platform';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useKeyedState } from '@edx/react-unit-test-utils';
|
||||
import { logError } from '@edx/frontend-platform/logging';
|
||||
import { mockBroadcastChannel } from '../../data/api.mock';
|
||||
import { iframeMessageTypes, iframeStateKeys } from '../../../constants';
|
||||
import { useIframeBehavior } from '../useIframeBehavior';
|
||||
import { useLoadBearingHook } from '../useLoadBearingHook';
|
||||
@@ -17,6 +18,8 @@ jest.mock('@edx/frontend-platform/logging', () => ({
|
||||
logError: jest.fn(),
|
||||
}));
|
||||
|
||||
mockBroadcastChannel();
|
||||
|
||||
describe('useIframeBehavior', () => {
|
||||
const id = 'test-id';
|
||||
const iframeUrl = 'http://example.com';
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
import React from 'react';
|
||||
import { IntlProvider } from '@edx/frontend-platform/i18n';
|
||||
import { fireEvent, render as baseRender, screen } from '@testing-library/react';
|
||||
import { InplaceTextEditor } from '.';
|
||||
|
||||
const mockOnSave = jest.fn();
|
||||
|
||||
const RootWrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<IntlProvider locale="en">
|
||||
{children}
|
||||
</IntlProvider>
|
||||
);
|
||||
const render = (component: React.ReactNode) => baseRender(component, { wrapper: RootWrapper });
|
||||
|
||||
describe('<InplaceTextEditor />', () => {
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render the text', () => {
|
||||
render(<InplaceTextEditor text="Test text" onSave={mockOnSave} />);
|
||||
|
||||
expect(screen.getByText('Test text')).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /edit/ })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render the edit button if showEditButton is true', () => {
|
||||
render(<InplaceTextEditor text="Test text" onSave={mockOnSave} showEditButton />);
|
||||
|
||||
expect(screen.getByText('Test text')).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should edit the text', () => {
|
||||
render(<InplaceTextEditor text="Test text" onSave={mockOnSave} />);
|
||||
|
||||
const title = screen.getByText('Test text');
|
||||
expect(title).toBeInTheDocument();
|
||||
fireEvent.click(title);
|
||||
|
||||
const textBox = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.change(textBox, { target: { value: 'New text' } });
|
||||
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
|
||||
|
||||
expect(textBox).not.toBeInTheDocument();
|
||||
expect(mockOnSave).toHaveBeenCalledWith('New text');
|
||||
});
|
||||
|
||||
it('should close edit text on press Escape', async () => {
|
||||
render(<InplaceTextEditor text="Test text" onSave={mockOnSave} />);
|
||||
|
||||
const title = screen.getByText('Test text');
|
||||
expect(title).toBeInTheDocument();
|
||||
fireEvent.click(title);
|
||||
|
||||
const textBox = screen.getByRole('textbox');
|
||||
|
||||
fireEvent.change(textBox, { target: { value: 'New text' } });
|
||||
fireEvent.keyDown(textBox, { key: 'Escape', code: 'Escape', charCode: 27 });
|
||||
|
||||
expect(textBox).not.toBeInTheDocument();
|
||||
expect(mockOnSave).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import {
|
||||
Form,
|
||||
Icon,
|
||||
IconButton,
|
||||
Stack,
|
||||
} from '@openedx/paragon';
|
||||
import { Edit } from '@openedx/paragon/icons';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
|
||||
import messages from './messages';
|
||||
|
||||
interface InplaceTextEditorProps {
|
||||
text: string;
|
||||
onSave: (newText: string) => void;
|
||||
readOnly?: boolean;
|
||||
textClassName?: string;
|
||||
showEditButton?: boolean;
|
||||
}
|
||||
|
||||
export const InplaceTextEditor: React.FC<InplaceTextEditorProps> = ({
|
||||
text,
|
||||
onSave,
|
||||
readOnly = false,
|
||||
textClassName,
|
||||
showEditButton = false,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
const [inputIsActive, setIsActive] = useState(false);
|
||||
|
||||
const handleOnChangeText = useCallback(
|
||||
(event) => {
|
||||
const newText = event.target.value;
|
||||
if (newText && newText !== text) {
|
||||
onSave(newText);
|
||||
}
|
||||
setIsActive(false);
|
||||
},
|
||||
[text],
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsActive(true);
|
||||
};
|
||||
|
||||
const handleOnKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleOnChangeText(event);
|
||||
} else if (event.key === 'Escape') {
|
||||
setIsActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack direction="horizontal">
|
||||
{inputIsActive
|
||||
? (
|
||||
<Form.Control
|
||||
autoFocus
|
||||
type="text"
|
||||
aria-label="Text input"
|
||||
defaultValue={text}
|
||||
onBlur={handleOnChangeText}
|
||||
onKeyDown={handleOnKeyDown}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<span
|
||||
className={textClassName}
|
||||
role="button"
|
||||
onClick={!readOnly ? handleClick : undefined}
|
||||
onKeyDown={!readOnly ? handleClick : undefined}
|
||||
tabIndex={0}
|
||||
>
|
||||
{text}
|
||||
</span>
|
||||
{!readOnly && showEditButton && (
|
||||
<IconButton
|
||||
src={Edit}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.editTextButtonAlt)}
|
||||
onClick={handleClick}
|
||||
size="inline"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
@@ -1,11 +0,0 @@
|
||||
import { defineMessages } from '@edx/frontend-platform/i18n';
|
||||
|
||||
const messages = defineMessages({
|
||||
editTextButtonAlt: {
|
||||
id: 'course-authoring.inplace-text-editor.button.alt',
|
||||
defaultMessage: 'Edit',
|
||||
description: 'Alt text for edit text icon button',
|
||||
},
|
||||
});
|
||||
|
||||
export default messages;
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
getLibraryId,
|
||||
isLibraryKey,
|
||||
isLibraryV1Key,
|
||||
getContainerTypeFromId,
|
||||
ContainerType,
|
||||
} from './key-utils';
|
||||
|
||||
describe('component utils', () => {
|
||||
@@ -99,16 +97,4 @@ describe('component utils', () => {
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('getContainerTypeFromId', () => {
|
||||
for (const [input, expected] of [
|
||||
['lct:org:lib:unit:my-unit-9284e2', ContainerType.Unit],
|
||||
['lct:OpenCraftX:ALPHA:my-unit-a3223f', undefined],
|
||||
['', undefined],
|
||||
]) {
|
||||
it(`returns '${expected}' for container key '${input}'`, () => {
|
||||
expect(getContainerTypeFromId(input!)).toStrictEqual(expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -49,26 +49,3 @@ export const buildCollectionUsageKey = (learningContextKey: string, collectionId
|
||||
const orgLib = learningContextKey.replace('lib:', '');
|
||||
return `lib-collection:${orgLib}:${collectionId}`;
|
||||
};
|
||||
|
||||
export enum ContainerType {
|
||||
Unit = 'unit',
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a container key like `ltc:org:lib:unit:id`
|
||||
* get the container type
|
||||
*/
|
||||
export function getContainerTypeFromId(containerId: string): ContainerType | undefined {
|
||||
const parts = containerId.split(':');
|
||||
if (parts.length < 2) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const maybeType = parts[parts.length - 2];
|
||||
|
||||
if (Object.values(ContainerType).includes(maybeType as ContainerType)) {
|
||||
return maybeType as ContainerType;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -11,5 +11,6 @@
|
||||
@import "./tag-count/TagCount";
|
||||
@import "./modal-dropzone/ModalDropzone";
|
||||
@import "./configure-modal/ConfigureModal";
|
||||
@import "./drag-helper/SortableItem";
|
||||
@import "./block-type-utils";
|
||||
@import "./modal-iframe"
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
APP_INIT_ERROR, APP_READY, subscribe, initialize, mergeConfig, getConfig, getPath,
|
||||
} from '@edx/frontend-platform';
|
||||
import { AppProvider, ErrorPage } from '@edx/frontend-platform/react';
|
||||
import { AuthoringAppSlot } from 'CourseAuthoring/plugin-slots/AuthoringAppSlot';
|
||||
import React, { StrictMode, useEffect } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import {
|
||||
@@ -66,14 +67,8 @@ const App = () => {
|
||||
<Route path="/libraries-v1" element={<StudioHome />} />
|
||||
<Route path="/library/create" element={<CreateLibrary />} />
|
||||
<Route path="/library/:libraryId/*" element={<LibraryLayout />} />
|
||||
<Route
|
||||
path="/component-picker"
|
||||
element={<ComponentPicker extraFilter={['NOT block_type = "unit"']} />}
|
||||
/>
|
||||
<Route
|
||||
path="/component-picker/multiple"
|
||||
element={<ComponentPicker componentPickerMode="multiple" extraFilter={['NOT block_type = "unit"']} />}
|
||||
/>
|
||||
<Route path="/component-picker" element={<ComponentPicker />} />
|
||||
<Route path="/component-picker/multiple" element={<ComponentPicker componentPickerMode="multiple" />} />
|
||||
<Route path="/legacy/preview-changes/:usageKey" element={<PreviewChangesEmbed />} />
|
||||
<Route path="/course/:courseId/*" element={<CourseAuthoringRoutes />} />
|
||||
<Route path="/course_rerun/:courseId" element={<CourseRerun />} />
|
||||
@@ -103,12 +98,14 @@ const App = () => {
|
||||
|
||||
return (
|
||||
<AppProvider store={initializeStore()} wrapWithRouter={false}>
|
||||
<ToastProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Head />
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ToastProvider>
|
||||
<AuthoringAppSlot>
|
||||
<ToastProvider>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<Head />
|
||||
<RouterProvider router={router} />
|
||||
</QueryClientProvider>
|
||||
</ToastProvider>
|
||||
</AuthoringAppSlot>
|
||||
</AppProvider>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
|
||||
import { studioHomeMock } from '../studio-home/__mocks__';
|
||||
import { getStudioHomeApiUrl } from '../studio-home/data/api';
|
||||
import { mockBroadcastChannel } from '../generic/data/api.mock';
|
||||
import { LibraryLayout } from '.';
|
||||
import { getLibraryCollectionsApiUrl, getLibraryContainersApiUrl } from './data/api';
|
||||
|
||||
@@ -33,6 +34,7 @@ mockContentSearchConfig.applyMock();
|
||||
mockContentLibrary.applyMock();
|
||||
mockGetLibraryTeam.applyMock();
|
||||
mockXBlockFields.applyMock();
|
||||
mockBroadcastChannel();
|
||||
|
||||
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
|
||||
|
||||
@@ -392,7 +394,7 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should open component sidebar, showing manage tab on clicking add to collection menu item (component)', async () => {
|
||||
it('should open component sidebar, showing manage tab on clicking add to collection menu item', async () => {
|
||||
const mockResult0 = { ...mockResult }.results[0].hits[0];
|
||||
const displayName = 'Introduction to Testing';
|
||||
expect(mockResult0.display_name).toStrictEqual(displayName);
|
||||
@@ -417,29 +419,6 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should open component sidebar, showing manage tab on clicking add to collection menu item (unit)', async () => {
|
||||
const displayName = 'Test Unit';
|
||||
await renderLibraryPage();
|
||||
|
||||
waitFor(() => expect(screen.getAllByTestId('container-card-menu-toggle').length).toBeGreaterThan(0));
|
||||
|
||||
// Open menu
|
||||
fireEvent.click((await screen.findAllByTestId('container-card-menu-toggle'))[0]);
|
||||
// Click add to collection
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Add to collection' }));
|
||||
|
||||
const sidebar = screen.getByTestId('library-sidebar');
|
||||
|
||||
const { getByRole, queryByText } = within(sidebar);
|
||||
|
||||
await waitFor(() => expect(queryByText(displayName)).toBeInTheDocument());
|
||||
expect(getByRole('tab', { selected: true })).toHaveTextContent('Organize');
|
||||
const closeButton = getByRole('button', { name: /close/i });
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should open and close the collection sidebar', async () => {
|
||||
await renderLibraryPage();
|
||||
|
||||
@@ -755,7 +734,7 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
fireEvent.click(cancelButton);
|
||||
expect(unitModalHeading).not.toBeInTheDocument();
|
||||
|
||||
// Open new unit modal again and create a unit
|
||||
// Open new unit modal again and create a collection
|
||||
fireEvent.click(newUnitButton);
|
||||
const createButton = screen.getByRole('button', { name: /create/i });
|
||||
const nameField = screen.getByRole('textbox', { name: /name your unit/i });
|
||||
@@ -823,7 +802,7 @@ describe('<LibraryAuthoringPage />', () => {
|
||||
fireEvent.click(newButton);
|
||||
expect(screen.getByText(/add content/i)).toBeInTheDocument();
|
||||
|
||||
// Open New Unit Modal
|
||||
// Open New collection Modal
|
||||
const sidebar = screen.getByTestId('library-sidebar');
|
||||
const newUnitButton = within(sidebar).getAllByRole('button', { name: /unit/i })[0];
|
||||
fireEvent.click(newUnitButton);
|
||||
|
||||
@@ -1,9 +1,4 @@
|
||||
import {
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Helmet } from 'react-helmet';
|
||||
import classNames from 'classnames';
|
||||
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
|
||||
@@ -105,7 +100,7 @@ const HeaderActions = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const SubHeaderTitle = ({ title }: { title: ReactNode }) => {
|
||||
export const SubHeaderTitle = ({ title }: { title: string }) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const { readOnly } = useLibraryContext();
|
||||
@@ -146,7 +141,6 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
|
||||
libraryData,
|
||||
isLoadingLibraryData,
|
||||
showOnlyPublished,
|
||||
extraFilter: contextExtraFilter,
|
||||
componentId,
|
||||
collectionId,
|
||||
unitId,
|
||||
@@ -229,10 +223,6 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
|
||||
extraFilter.push('last_published IS NOT NULL');
|
||||
}
|
||||
|
||||
if (contextExtraFilter) {
|
||||
extraFilter.push(...contextExtraFilter);
|
||||
}
|
||||
|
||||
const activeTypeFilters = {
|
||||
components: 'type = "library_block"',
|
||||
collections: 'type = "collection"',
|
||||
|
||||
@@ -15,7 +15,6 @@ interface LibraryBlockProps {
|
||||
version?: VersionSpec;
|
||||
view?: string;
|
||||
scrolling?: string;
|
||||
minHeight?: string;
|
||||
}
|
||||
/**
|
||||
* React component that displays an XBlock in a sandboxed IFrame.
|
||||
@@ -31,7 +30,6 @@ export const LibraryBlock = ({
|
||||
usageKey,
|
||||
version,
|
||||
view,
|
||||
minHeight,
|
||||
scrolling = 'no',
|
||||
}: LibraryBlockProps) => {
|
||||
const { iframeRef, setIframeRef } = useIframe();
|
||||
@@ -63,7 +61,7 @@ export const LibraryBlock = ({
|
||||
loading="lazy"
|
||||
referrerPolicy="origin"
|
||||
style={{
|
||||
width: '100%', height: iframeHeight, pointerEvents: 'auto', minHeight,
|
||||
width: '100%', height: iframeHeight, pointerEvents: 'auto', minHeight: '700px',
|
||||
}}
|
||||
allow={IFRAME_FEATURE_POLICY}
|
||||
allowFullScreen
|
||||
|
||||
@@ -47,6 +47,13 @@ jest.mock('../search-manager', () => ({
|
||||
useSearchContext: () => mockUseSearchContext(),
|
||||
}));
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const withLibraryId = (libraryId: string) => ({
|
||||
extraWrapper: ({ children }: { children: React.ReactNode }) => (
|
||||
<LibraryProvider libraryId={libraryId}>
|
||||
|
||||
@@ -43,7 +43,7 @@ const LibraryLayout = () => {
|
||||
/** The component picker modal to use. We need to pass it as a reference instead of
|
||||
* directly importing it to avoid the import cycle:
|
||||
* ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage >
|
||||
* Sidebar > AddContent > ComponentPicker */
|
||||
* Sidebar > AddContentContainer > ComponentPicker */
|
||||
componentPicker={ComponentPicker}
|
||||
>
|
||||
<SidebarProvider>
|
||||
|
||||
@@ -218,45 +218,8 @@
|
||||
"org": "OpenedX",
|
||||
"access_id": 16,
|
||||
"num_children": 1
|
||||
},
|
||||
{
|
||||
"display_name": "Test Unit",
|
||||
"block_id": "test-unit-9284e2",
|
||||
"id": "lctAximTESTunittest-unit-9284e2-a9a4386e",
|
||||
"type": "library_container",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Test Library"
|
||||
}
|
||||
],
|
||||
"created": 1742221203.895054,
|
||||
"modified": 1742221203.895054,
|
||||
"usage_key": "lct:Axim:TEST:unit:test-unit-9284e2",
|
||||
"block_type": "unit",
|
||||
"context_key": "lib:Axim:TEST",
|
||||
"org": "Axim",
|
||||
"access_id": 15,
|
||||
"num_children": 0,
|
||||
"_formatted": {
|
||||
"display_name": "Test Unit",
|
||||
"block_id": "test-unit-9284e2",
|
||||
"id": "lctAximTESTunittest-unit-9284e2-a9a4386e",
|
||||
"type": "library_container",
|
||||
"breadcrumbs": [
|
||||
{
|
||||
"display_name": "Test Library"
|
||||
}
|
||||
],
|
||||
"created": "1742221203.895054",
|
||||
"modified": "1742221203.895054",
|
||||
"usage_key": "lct:Axim:TEST:unit:test-unit-9284e2",
|
||||
"block_type": "unit",
|
||||
"context_key": "lib:Axim:TEST",
|
||||
"org": "Axim",
|
||||
"access_id": "15",
|
||||
"num_children": "0"
|
||||
}
|
||||
}
|
||||
|
||||
],
|
||||
"query": "",
|
||||
"processingTimeMs": 1,
|
||||
|
||||
@@ -12,21 +12,18 @@ import {
|
||||
mockXBlockFields,
|
||||
} from '../data/api.mocks';
|
||||
import {
|
||||
getContentLibraryApiUrl,
|
||||
getCreateLibraryBlockUrl,
|
||||
getLibraryCollectionItemsApiUrl,
|
||||
getLibraryContainerChildrenApiUrl,
|
||||
getLibraryPasteClipboardUrl,
|
||||
getContentLibraryApiUrl, getCreateLibraryBlockUrl, getLibraryCollectionComponentApiUrl, getLibraryPasteClipboardUrl,
|
||||
getXBlockFieldsApiUrl,
|
||||
} from '../data/api';
|
||||
import { mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
|
||||
import { mockBroadcastChannel, mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
|
||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
import AddContent from './AddContent';
|
||||
import AddContentContainer from './AddContentContainer';
|
||||
import { ComponentEditorModal } from '../components/ComponentEditorModal';
|
||||
import editorCmsApi from '../../editors/data/services/cms/api';
|
||||
import { ToastActionData } from '../../generic/toast-context';
|
||||
import * as textEditorHooks from '../../editors/containers/TextEditor/hooks';
|
||||
|
||||
mockBroadcastChannel();
|
||||
// mockCreateLibraryBlock.applyMock();
|
||||
|
||||
// Mocks for ComponentEditorModal to work in tests.
|
||||
@@ -35,7 +32,7 @@ jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCs
|
||||
const { libraryId } = mockContentLibrary;
|
||||
const render = (collectionId?: string) => {
|
||||
const params: { libraryId: string, collectionId?: string } = { libraryId, collectionId };
|
||||
return baseRender(<AddContent />, {
|
||||
return baseRender(<AddContentContainer />, {
|
||||
path: '/library/:libraryId/:collectionId?',
|
||||
params,
|
||||
extraWrapper: ({ children }) => (
|
||||
@@ -48,25 +45,10 @@ const render = (collectionId?: string) => {
|
||||
),
|
||||
});
|
||||
};
|
||||
const renderWithUnit = (unitId: string) => {
|
||||
const params: { libraryId: string, unitId?: string } = { libraryId, unitId };
|
||||
return baseRender(<AddContent />, {
|
||||
path: '/library/:libraryId/:unitId?',
|
||||
params,
|
||||
extraWrapper: ({ children }) => (
|
||||
<LibraryProvider
|
||||
libraryId={libraryId}
|
||||
>
|
||||
{ children }
|
||||
<ComponentEditorModal />
|
||||
</LibraryProvider>
|
||||
),
|
||||
});
|
||||
};
|
||||
let axiosMock: MockAdapter;
|
||||
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
|
||||
|
||||
describe('<AddContent />', () => {
|
||||
describe('<AddContentContainer />', () => {
|
||||
beforeEach(() => {
|
||||
const mocks = initializeMocks();
|
||||
axiosMock = mocks.axiosMock;
|
||||
@@ -155,7 +137,7 @@ describe('<AddContent />', () => {
|
||||
const url = getCreateLibraryBlockUrl(libraryId);
|
||||
const usageKey = mockXBlockFields.usageKeyNewHtml;
|
||||
const updateBlockUrl = getXBlockFieldsApiUrl(usageKey);
|
||||
const collectionComponentUrl = getLibraryCollectionItemsApiUrl(
|
||||
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
|
||||
libraryId,
|
||||
collectionId,
|
||||
);
|
||||
@@ -207,30 +189,13 @@ describe('<AddContent />', () => {
|
||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
|
||||
});
|
||||
|
||||
it('should show error toast on paste failure', async () => {
|
||||
// Simulate having an HTML block in the clipboard:
|
||||
mockClipboardHtml.applyMock();
|
||||
|
||||
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
|
||||
axiosMock.onPost(pasteUrl).reply(500, { block_type: 'Unsupported block type.' });
|
||||
|
||||
render();
|
||||
const pasteButton = await screen.findByRole('button', { name: /paste from clipboard/i });
|
||||
fireEvent.click(pasteButton);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.post[0].url).toEqual(pasteUrl));
|
||||
expect(mockShowToast).toHaveBeenCalledWith(
|
||||
'There was an error pasting the content: {"block_type":"Unsupported block type."}',
|
||||
);
|
||||
});
|
||||
|
||||
it('should paste content inside a collection', async () => {
|
||||
// Simulate having an HTML block in the clipboard:
|
||||
const getClipboardSpy = mockClipboardHtml.applyMock();
|
||||
|
||||
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
|
||||
const collectionId = 'some-collection-id';
|
||||
const collectionComponentUrl = getLibraryCollectionItemsApiUrl(
|
||||
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
|
||||
libraryId,
|
||||
collectionId,
|
||||
);
|
||||
@@ -255,7 +220,7 @@ describe('<AddContent />', () => {
|
||||
|
||||
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
|
||||
const collectionId = 'some-collection-id';
|
||||
const collectionComponentUrl = getLibraryCollectionItemsApiUrl(
|
||||
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
|
||||
libraryId,
|
||||
collectionId,
|
||||
);
|
||||
@@ -325,71 +290,4 @@ describe('<AddContent />', () => {
|
||||
expect(mockShowToast).toHaveBeenCalledWith(expectedError);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show collection/unit buttons when create component in container', async () => {
|
||||
const unitId = 'lct:orf1:lib1:unit:test-1';
|
||||
renderWithUnit(unitId);
|
||||
|
||||
expect(await screen.findByRole('button', { name: 'Text' })).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByRole('button', { name: 'Collection' })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: 'Unit' })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should create a component in unit', async () => {
|
||||
const unitId = 'lct:orf1:lib1:unit:test-1';
|
||||
const usageKey = mockXBlockFields.usageKeyNewHtml;
|
||||
const createUrl = getCreateLibraryBlockUrl(libraryId);
|
||||
const updateBlockUrl = getXBlockFieldsApiUrl(usageKey);
|
||||
const linkUrl = getLibraryContainerChildrenApiUrl(unitId);
|
||||
|
||||
axiosMock.onPost(createUrl).reply(200, {
|
||||
id: usageKey,
|
||||
});
|
||||
axiosMock.onPost(updateBlockUrl).reply(200, mockXBlockFields.dataHtml);
|
||||
axiosMock.onPost(linkUrl).reply(200);
|
||||
|
||||
renderWithUnit(unitId);
|
||||
|
||||
const textButton = screen.getByRole('button', { name: /text/i });
|
||||
fireEvent.click(textButton);
|
||||
|
||||
// Component should be linked to Unit on saving the changes in the editor.
|
||||
const saveButton = screen.getByLabelText('Save changes and return to learning context');
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.post.length).toEqual(3));
|
||||
expect(axiosMock.history.post[0].url).toEqual(createUrl);
|
||||
expect(axiosMock.history.post[1].url).toEqual(updateBlockUrl);
|
||||
expect(axiosMock.history.post[2].url).toEqual(linkUrl);
|
||||
});
|
||||
|
||||
it('should show error on create a component in unit', async () => {
|
||||
const unitId = 'lct:orf1:lib1:unit:test-1';
|
||||
const usageKey = mockXBlockFields.usageKeyNewHtml;
|
||||
const createUrl = getCreateLibraryBlockUrl(libraryId);
|
||||
const updateBlockUrl = getXBlockFieldsApiUrl(usageKey);
|
||||
const linkUrl = getLibraryContainerChildrenApiUrl(unitId);
|
||||
|
||||
axiosMock.onPost(createUrl).reply(200, {
|
||||
id: usageKey,
|
||||
});
|
||||
axiosMock.onPost(updateBlockUrl).reply(200, mockXBlockFields.dataHtml);
|
||||
axiosMock.onPost(linkUrl).reply(400);
|
||||
|
||||
renderWithUnit(unitId);
|
||||
|
||||
const textButton = screen.getByRole('button', { name: /text/i });
|
||||
fireEvent.click(textButton);
|
||||
|
||||
const saveButton = screen.getByLabelText('Save changes and return to learning context');
|
||||
fireEvent.click(saveButton);
|
||||
|
||||
await waitFor(() => expect(axiosMock.history.post.length).toEqual(3));
|
||||
expect(axiosMock.history.post[0].url).toEqual(createUrl);
|
||||
expect(axiosMock.history.post[1].url).toEqual(updateBlockUrl);
|
||||
expect(axiosMock.history.post[2].url).toEqual(linkUrl);
|
||||
|
||||
expect(mockShowToast).toHaveBeenCalledWith('There was an error linking the content to this container.');
|
||||
});
|
||||
});
|
||||
@@ -21,9 +21,8 @@ import { getCanEdit } from '../../course-unit/data/selectors';
|
||||
import {
|
||||
useCreateLibraryBlock,
|
||||
useLibraryPasteClipboard,
|
||||
useAddComponentsToCollection,
|
||||
useBlockTypesMetadata,
|
||||
useAddItemsToCollection,
|
||||
useAddComponentsToContainer,
|
||||
} from '../data/apiHooks';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { PickLibraryContentModal } from './PickLibraryContentModal';
|
||||
@@ -31,7 +30,6 @@ import { blockTypes } from '../../editors/data/constants/app';
|
||||
|
||||
import messages from './messages';
|
||||
import type { BlockTypeMetadata } from '../data/api';
|
||||
import { getContainerTypeFromId, ContainerType } from '../../generic/key-utils';
|
||||
|
||||
type ContentType = {
|
||||
name: string,
|
||||
@@ -89,12 +87,7 @@ const AddContentView = ({
|
||||
const {
|
||||
collectionId,
|
||||
componentPicker,
|
||||
unitId,
|
||||
} = useLibraryContext();
|
||||
let upstreamContainerType: ContainerType | undefined;
|
||||
if (unitId) {
|
||||
upstreamContainerType = getContainerTypeFromId(unitId);
|
||||
}
|
||||
|
||||
const collectionButtonData = {
|
||||
name: intl.formatMessage(messages.collectionButton),
|
||||
@@ -116,24 +109,20 @@ const AddContentView = ({
|
||||
|
||||
return (
|
||||
<>
|
||||
{(collectionId || unitId) && componentPicker && (
|
||||
/// Show the "Add Library Content" button for units and collections
|
||||
<>
|
||||
<AddContentButton contentType={libraryContentButtonData} onCreateContent={onCreateContent} />
|
||||
<PickLibraryContentModal
|
||||
isOpen={isAddLibraryContentModalOpen}
|
||||
onClose={closeAddLibraryContentModal}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{!collectionId && !unitId && (
|
||||
// Doesn't show the "Collection" button if we are in a unit or collection
|
||||
{collectionId ? (
|
||||
componentPicker && (
|
||||
<>
|
||||
<AddContentButton contentType={libraryContentButtonData} onCreateContent={onCreateContent} />
|
||||
<PickLibraryContentModal
|
||||
isOpen={isAddLibraryContentModalOpen}
|
||||
onClose={closeAddLibraryContentModal}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
) : (
|
||||
<AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />
|
||||
)}
|
||||
{upstreamContainerType !== ContainerType.Unit && (
|
||||
// Doesn't show the "Unit" button if we are in a unit
|
||||
<AddContentButton contentType={unitButtonData} onCreateContent={onCreateContent} />
|
||||
)}
|
||||
<AddContentButton contentType={unitButtonData} onCreateContent={onCreateContent} />
|
||||
<hr className="w-100 bg-gray-500" />
|
||||
{/* Note: for MVP we are hiding the unuspported types, not just disabling them. */}
|
||||
{contentTypes.filter(ct => !ct.disabled).map((contentType) => (
|
||||
@@ -154,10 +143,6 @@ const AddAdvancedContentView = ({
|
||||
isBlockTypeEnabled,
|
||||
}: AddAdvancedContentViewProps) => {
|
||||
const intl = useIntl();
|
||||
// Sort block types alphabetically by default display name
|
||||
const sortedBlockTypes = Object.keys(advancedBlocks).sort((typeA, typeB) => (
|
||||
advancedBlocks[typeA].displayName.localeCompare(advancedBlocks[typeB].displayName)
|
||||
));
|
||||
return (
|
||||
<>
|
||||
<div className="d-flex">
|
||||
@@ -165,7 +150,7 @@ const AddAdvancedContentView = ({
|
||||
{intl.formatMessage(messages.backToAddContentListButton)}
|
||||
</Button>
|
||||
</div>
|
||||
{sortedBlockTypes.map((blockType) => (
|
||||
{Object.keys(advancedBlocks).map((blockType) => (
|
||||
isBlockTypeEnabled(blockType) ? (
|
||||
<AddContentButton
|
||||
key={`add-content-${blockType}`}
|
||||
@@ -191,15 +176,7 @@ export const parseErrorMsg = (
|
||||
) => {
|
||||
try {
|
||||
const { response: { data } } = error;
|
||||
let detail = '';
|
||||
if (Array.isArray(data)) {
|
||||
detail = data.join(', ');
|
||||
} else if (typeof data === 'string') {
|
||||
/* istanbul ignore next */
|
||||
detail = data.substring(0, 400); // In case this is a giant HTML response, only show the first little bit.
|
||||
} else if (data) {
|
||||
detail = JSON.stringify(data);
|
||||
}
|
||||
const detail = data && (Array.isArray(data) ? data.join() : String(data));
|
||||
if (detail) {
|
||||
return intl.formatMessage(detailedMessage, { detail });
|
||||
}
|
||||
@@ -209,7 +186,7 @@ export const parseErrorMsg = (
|
||||
return intl.formatMessage(defaultMessage);
|
||||
};
|
||||
|
||||
const AddContent = () => {
|
||||
const AddContentContainer = () => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
libraryId,
|
||||
@@ -217,15 +194,13 @@ const AddContent = () => {
|
||||
openCreateCollectionModal,
|
||||
openCreateUnitModal,
|
||||
openComponentEditor,
|
||||
unitId,
|
||||
} = useLibraryContext();
|
||||
const addComponentsToCollectionMutation = useAddItemsToCollection(libraryId, collectionId);
|
||||
const addComponentsToContainerMutation = useAddComponentsToContainer(unitId);
|
||||
const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId);
|
||||
const createBlockMutation = useCreateLibraryBlock();
|
||||
const pasteClipboardMutation = useLibraryPasteClipboard();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const canEdit = useSelector(getCanEdit);
|
||||
const { showPasteUnit, showPasteXBlock, sharedClipboardData } = useClipboard(canEdit);
|
||||
const { showPasteXBlock, sharedClipboardData } = useClipboard(canEdit);
|
||||
|
||||
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
|
||||
const [isAdvancedListOpen, showAdvancedList, closeAdvancedList] = useToggle();
|
||||
@@ -289,7 +264,7 @@ const AddContent = () => {
|
||||
|
||||
// Include the 'Paste from Clipboard' button if there is an Xblock in the clipboard
|
||||
// that can be pasted
|
||||
if (showPasteXBlock || showPasteUnit) {
|
||||
if (showPasteXBlock) {
|
||||
const pasteButton = {
|
||||
name: intl.formatMessage(messages.pasteButton),
|
||||
disabled: false,
|
||||
@@ -298,17 +273,10 @@ const AddContent = () => {
|
||||
contentTypes.push(pasteButton);
|
||||
}
|
||||
|
||||
const linkComponent = (opaqueKey: string) => {
|
||||
if (collectionId) {
|
||||
addComponentsToCollectionMutation.mutateAsync([opaqueKey]).catch(() => {
|
||||
showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage));
|
||||
});
|
||||
}
|
||||
if (unitId) {
|
||||
addComponentsToContainerMutation.mutateAsync([opaqueKey]).catch(() => {
|
||||
showToast(intl.formatMessage(messages.errorAssociateComponentToContainerMessage));
|
||||
});
|
||||
}
|
||||
const linkComponent = (usageKey: string) => {
|
||||
updateComponentsMutation.mutateAsync([usageKey]).catch(() => {
|
||||
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
|
||||
});
|
||||
};
|
||||
|
||||
const onPaste = () => {
|
||||
@@ -325,6 +293,7 @@ const AddContent = () => {
|
||||
}
|
||||
pasteClipboardMutation.mutateAsync({
|
||||
libraryId,
|
||||
blockId: `${uuid4()}`,
|
||||
}).then((data) => {
|
||||
linkComponent(data.id);
|
||||
showToast(intl.formatMessage(messages.successPasteClipboardMessage));
|
||||
@@ -405,4 +374,4 @@ const AddContent = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export default AddContent;
|
||||
export default AddContentContainer;
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
mockCreateLibraryBlock,
|
||||
mockXBlockFields,
|
||||
} from '../data/api.mocks';
|
||||
import { mockClipboardEmpty } from '../../generic/data/api.mock';
|
||||
import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock';
|
||||
import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock';
|
||||
import { studioHomeMock } from '../../studio-home/__mocks__';
|
||||
import { getStudioHomeApiUrl } from '../../studio-home/data/api';
|
||||
@@ -25,6 +25,7 @@ import LibraryLayout from '../LibraryLayout';
|
||||
|
||||
mockContentSearchConfig.applyMock();
|
||||
mockClipboardEmpty.applyMock();
|
||||
mockBroadcastChannel();
|
||||
mockContentLibrary.applyMock();
|
||||
mockCreateLibraryBlock.applyMock();
|
||||
mockSearchResult(mockResult);
|
||||
|
||||
@@ -28,21 +28,9 @@ const { libraryId } = mockContentLibrary;
|
||||
const onClose = jest.fn();
|
||||
let mockShowToast: (message: string) => void;
|
||||
|
||||
const mockAddItemsToCollection = jest.fn();
|
||||
const mockAddComponentsToContainer = jest.fn();
|
||||
jest.spyOn(api, 'addItemsToCollection').mockImplementation(mockAddItemsToCollection);
|
||||
jest.spyOn(api, 'addComponentsToContainer').mockImplementation(mockAddComponentsToContainer);
|
||||
const unitId = 'lct:Axim:TEST:unit:test-unit-1';
|
||||
|
||||
const render = (context: 'collection' | 'unit') => baseRender(<PickLibraryContentModal isOpen onClose={onClose} />, {
|
||||
path: context === 'collection'
|
||||
? '/library/:libraryId/collection/:collectionId/*'
|
||||
: '/library/:libraryId/container/:unitId/*',
|
||||
params: {
|
||||
libraryId,
|
||||
...(context === 'collection' && { collectionId: 'collectionId' }),
|
||||
...(context === 'unit' && { unitId }),
|
||||
},
|
||||
const render = () => baseRender(<PickLibraryContentModal isOpen onClose={onClose} />, {
|
||||
path: '/library/:libraryId/collection/:collectionId/*',
|
||||
params: { libraryId, collectionId: 'collectionId' },
|
||||
extraWrapper: ({ children }) => (
|
||||
<LibraryProvider
|
||||
libraryId={libraryId}
|
||||
@@ -58,80 +46,62 @@ describe('<PickLibraryContentModal />', () => {
|
||||
const mocks = initializeMocks();
|
||||
mockShowToast = mocks.mockShowToast;
|
||||
mocks.axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
['collection' as const, 'unit' as const].forEach((context) => {
|
||||
it(`can pick components from the modal (${context})`, async () => {
|
||||
render(context);
|
||||
it('can pick components from the modal', async () => {
|
||||
const mockAddComponentsToCollection = jest.fn();
|
||||
jest.spyOn(api, 'addComponentsToCollection').mockImplementation(mockAddComponentsToCollection);
|
||||
|
||||
// Wait for the content library to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Library')).toBeInTheDocument();
|
||||
expect(screen.queryAllByText('Introduction to Testing')[0]).toBeInTheDocument();
|
||||
});
|
||||
render();
|
||||
|
||||
// Select the first component
|
||||
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[0]);
|
||||
expect(await screen.findByText('1 Selected Component')).toBeInTheDocument();
|
||||
// Wait for the content library to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Library')).toBeInTheDocument();
|
||||
expect(screen.queryAllByText('Introduction to Testing')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /add to .*/i }));
|
||||
// Select the first component
|
||||
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[0]);
|
||||
expect(await screen.findByText('1 Selected Component')).toBeInTheDocument();
|
||||
|
||||
await waitFor(() => {
|
||||
if (context === 'collection') {
|
||||
expect(mockAddItemsToCollection).toHaveBeenCalledWith(
|
||||
libraryId,
|
||||
'collectionId',
|
||||
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
|
||||
);
|
||||
} else {
|
||||
expect(mockAddComponentsToContainer).toHaveBeenCalledWith(
|
||||
unitId,
|
||||
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
|
||||
);
|
||||
}
|
||||
});
|
||||
fireEvent.click(screen.queryAllByRole('button', { name: 'Add to Collection' })[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockAddComponentsToCollection).toHaveBeenCalledWith(
|
||||
libraryId,
|
||||
'collectionId',
|
||||
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Content linked successfully.');
|
||||
});
|
||||
});
|
||||
|
||||
it(`show error when api call fails (${context})`, async () => {
|
||||
if (context === 'collection') {
|
||||
mockAddItemsToCollection.mockRejectedValueOnce(new Error('Error'));
|
||||
} else {
|
||||
mockAddComponentsToContainer.mockRejectedValueOnce(new Error('Error'));
|
||||
}
|
||||
render(context);
|
||||
it('show error when api call fails', async () => {
|
||||
const mockAddComponentsToCollection = jest.fn().mockRejectedValue(new Error('Failed to add components'));
|
||||
jest.spyOn(api, 'addComponentsToCollection').mockImplementation(mockAddComponentsToCollection);
|
||||
render();
|
||||
|
||||
// Wait for the content library to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Library')).toBeInTheDocument();
|
||||
expect(screen.queryAllByText('Introduction to Testing')[0]).toBeInTheDocument();
|
||||
});
|
||||
// Wait for the content library to load
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText('Test Library')).toBeInTheDocument();
|
||||
expect(screen.queryAllByText('Introduction to Testing')[0]).toBeInTheDocument();
|
||||
});
|
||||
|
||||
// Select the first component
|
||||
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[0]);
|
||||
expect(await screen.findByText('1 Selected Component')).toBeInTheDocument();
|
||||
// Select the first component
|
||||
fireEvent.click(screen.queryAllByRole('button', { name: 'Select' })[0]);
|
||||
expect(await screen.findByText('1 Selected Component')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /add to .*/i }));
|
||||
fireEvent.click(screen.queryAllByRole('button', { name: 'Add to Collection' })[0]);
|
||||
|
||||
await waitFor(() => {
|
||||
if (context === 'collection') {
|
||||
expect(mockAddItemsToCollection).toHaveBeenCalledWith(
|
||||
libraryId,
|
||||
'collectionId',
|
||||
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
|
||||
);
|
||||
} else {
|
||||
expect(mockAddComponentsToContainer).toHaveBeenCalledWith(
|
||||
unitId,
|
||||
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
|
||||
);
|
||||
}
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(mockAddComponentsToCollection).toHaveBeenCalledWith(
|
||||
libraryId,
|
||||
'collectionId',
|
||||
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
|
||||
);
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
const name = context === 'collection' ? 'collection' : 'container';
|
||||
expect(mockShowToast).toHaveBeenCalledWith(`There was an error linking the content to this ${name}.`);
|
||||
expect(mockShowToast).toHaveBeenCalledWith('There was an error linking the content to this collection.');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,25 +5,23 @@ import { ActionRow, Button, StandardModal } from '@openedx/paragon';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import type { SelectedComponent } from '../common/context/ComponentPickerContext';
|
||||
import { useAddItemsToCollection, useAddComponentsToContainer } from '../data/apiHooks';
|
||||
import { useAddComponentsToCollection } from '../data/apiHooks';
|
||||
import messages from './messages';
|
||||
|
||||
interface PickLibraryContentModalFooterProps {
|
||||
onSubmit: () => void;
|
||||
selectedComponents: SelectedComponent[];
|
||||
buttonText: React.ReactNode;
|
||||
}
|
||||
|
||||
const PickLibraryContentModalFooter: React.FC<PickLibraryContentModalFooterProps> = ({
|
||||
onSubmit,
|
||||
selectedComponents,
|
||||
buttonText,
|
||||
}) => (
|
||||
<ActionRow>
|
||||
<FormattedMessage {...messages.selectedComponents} values={{ count: selectedComponents.length }} />
|
||||
<ActionRow.Spacer />
|
||||
<Button variant="primary" onClick={onSubmit}>
|
||||
{buttonText}
|
||||
<FormattedMessage {...messages.addToCollectionButton} />
|
||||
</Button>
|
||||
</ActionRow>
|
||||
);
|
||||
@@ -31,33 +29,29 @@ const PickLibraryContentModalFooter: React.FC<PickLibraryContentModalFooterProps
|
||||
interface PickLibraryContentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
extraFilter?: string[];
|
||||
}
|
||||
|
||||
export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
extraFilter,
|
||||
}) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
libraryId,
|
||||
collectionId,
|
||||
unitId,
|
||||
/** We need to get it as a reference instead of directly importing it to avoid the import cycle:
|
||||
* ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage >
|
||||
* Sidebar > AddContent > ComponentPicker */
|
||||
* Sidebar > AddContentContainer > ComponentPicker */
|
||||
componentPicker: ComponentPicker,
|
||||
} = useLibraryContext();
|
||||
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!(collectionId || unitId) || !ComponentPicker) {
|
||||
throw new Error('collectionId/unitId and componentPicker are required');
|
||||
if (!collectionId || !ComponentPicker) {
|
||||
throw new Error('libraryId and componentPicker are required');
|
||||
}
|
||||
|
||||
const updateCollectionItemsMutation = useAddItemsToCollection(libraryId, collectionId);
|
||||
const updateUnitComponentsMutation = useAddComponentsToContainer(unitId);
|
||||
const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId);
|
||||
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
@@ -66,24 +60,13 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
|
||||
const onSubmit = useCallback(() => {
|
||||
const usageKeys = selectedComponents.map(({ usageKey }) => usageKey);
|
||||
onClose();
|
||||
if (collectionId) {
|
||||
updateCollectionItemsMutation.mutateAsync(usageKeys)
|
||||
.then(() => {
|
||||
showToast(intl.formatMessage(messages.successAssociateComponentMessage));
|
||||
})
|
||||
.catch(() => {
|
||||
showToast(intl.formatMessage(messages.errorAssociateComponentToCollectionMessage));
|
||||
});
|
||||
}
|
||||
if (unitId) {
|
||||
updateUnitComponentsMutation.mutateAsync(usageKeys)
|
||||
.then(() => {
|
||||
showToast(intl.formatMessage(messages.successAssociateComponentMessage));
|
||||
})
|
||||
.catch(() => {
|
||||
showToast(intl.formatMessage(messages.errorAssociateComponentToContainerMessage));
|
||||
});
|
||||
}
|
||||
updateComponentsMutation.mutateAsync(usageKeys)
|
||||
.then(() => {
|
||||
showToast(intl.formatMessage(messages.successAssociateComponentMessage));
|
||||
})
|
||||
.catch(() => {
|
||||
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
|
||||
});
|
||||
}, [selectedComponents]);
|
||||
|
||||
return (
|
||||
@@ -93,22 +76,12 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
|
||||
size="xl"
|
||||
isOpen={isOpen}
|
||||
onClose={onClose}
|
||||
footerNode={(
|
||||
<PickLibraryContentModalFooter
|
||||
onSubmit={onSubmit}
|
||||
selectedComponents={selectedComponents}
|
||||
buttonText={(collectionId
|
||||
? intl.formatMessage(messages.addToCollectionButton)
|
||||
: intl.formatMessage(messages.addToUnitButton)
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
footerNode={<PickLibraryContentModalFooter onSubmit={onSubmit} selectedComponents={selectedComponents} />}
|
||||
>
|
||||
<ComponentPicker
|
||||
libraryId={libraryId}
|
||||
componentPickerMode="multiple"
|
||||
onChangeComponentSelection={setSelectedComponents}
|
||||
extraFilter={extraFilter}
|
||||
/>
|
||||
</StandardModal>
|
||||
);
|
||||
|
||||
@@ -1,3 +1,2 @@
|
||||
export { default as AddContent } from './AddContent';
|
||||
export { default as AddContentContainer } from './AddContentContainer';
|
||||
export { default as AddContentHeader } from './AddContentHeader';
|
||||
export { PickLibraryContentModal } from './PickLibraryContentModal';
|
||||
|
||||
@@ -21,11 +21,6 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Add to Collection',
|
||||
description: 'Button to add library content to a collection.',
|
||||
},
|
||||
addToUnitButton: {
|
||||
id: 'course-authoring.library-authoring.add-content.buttons.library-content.add-to-unit',
|
||||
defaultMessage: 'Add to Unit',
|
||||
description: 'Button to add library content to a unit.',
|
||||
},
|
||||
selectedComponents: {
|
||||
id: 'course-authoring.library-authoring.add-content.selected-components',
|
||||
defaultMessage: '{count, plural, one {# Selected Component} other {# Selected Components}}',
|
||||
@@ -89,16 +84,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Content linked successfully.',
|
||||
description: 'Message when linking of content to a collection in library is success',
|
||||
},
|
||||
errorAssociateComponentToCollectionMessage: {
|
||||
errorAssociateComponentMessage: {
|
||||
id: 'course-authoring.library-authoring.associate-collection-content.error.text',
|
||||
defaultMessage: 'There was an error linking the content to this collection.',
|
||||
description: 'Message when linking of content to a collection in library fails',
|
||||
},
|
||||
errorAssociateComponentToContainerMessage: {
|
||||
id: 'course-authoring.library-authoring.associate-container-content.error.text',
|
||||
defaultMessage: 'There was an error linking the content to this container.',
|
||||
description: 'Message when linking of content to a container in library fails',
|
||||
},
|
||||
addContentTitle: {
|
||||
id: 'course-authoring.library-authoring.sidebar.title.add-content',
|
||||
defaultMessage: 'Add Content',
|
||||
|
||||
@@ -58,14 +58,14 @@ describe('<CollectionInfoHeader />', () => {
|
||||
render();
|
||||
expect(await screen.findByText('Test Collection')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /edit collection title/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render edit title button without permission', async () => {
|
||||
render(libraryIdReadOnly);
|
||||
expect(await screen.findByText('Test Collection')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /edit collection title/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should update collection title', async () => {
|
||||
@@ -76,9 +76,9 @@ describe('<CollectionInfoHeader />', () => {
|
||||
const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId);
|
||||
axiosMock.onPatch(url).reply(200);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /text input/i });
|
||||
const textBox = screen.getByRole('textbox', { name: /title input/i });
|
||||
|
||||
userEvent.clear(textBox);
|
||||
userEvent.type(textBox, 'New Collection Title{enter}');
|
||||
@@ -99,9 +99,9 @@ describe('<CollectionInfoHeader />', () => {
|
||||
const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId);
|
||||
axiosMock.onPatch(url).reply(200);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /text input/i });
|
||||
const textBox = screen.getByRole('textbox', { name: /title input/i });
|
||||
|
||||
userEvent.clear(textBox);
|
||||
userEvent.type(textBox, `${mockGetCollectionMetadata.collectionData.title}{enter}`);
|
||||
@@ -118,9 +118,9 @@ describe('<CollectionInfoHeader />', () => {
|
||||
const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId);
|
||||
axiosMock.onPatch(url).reply(200);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /text input/i });
|
||||
const textBox = screen.getByRole('textbox', { name: /title input/i });
|
||||
|
||||
userEvent.clear(textBox);
|
||||
userEvent.type(textBox, '{enter}');
|
||||
@@ -137,9 +137,9 @@ describe('<CollectionInfoHeader />', () => {
|
||||
const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId);
|
||||
axiosMock.onPatch(url).reply(200);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /text input/i });
|
||||
const textBox = screen.getByRole('textbox', { name: /title input/i });
|
||||
|
||||
userEvent.clear(textBox);
|
||||
userEvent.type(textBox, 'New Collection Title{esc}');
|
||||
@@ -156,9 +156,9 @@ describe('<CollectionInfoHeader />', () => {
|
||||
const url = api.getLibraryCollectionApiUrl(mockLibraryId, collectionId);
|
||||
axiosMock.onPatch(url).reply(500);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit collection title/i }));
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /text input/i });
|
||||
const textBox = screen.getByRole('textbox', { name: /title input/i });
|
||||
|
||||
userEvent.clear(textBox);
|
||||
userEvent.type(textBox, 'New Collection Title{enter}');
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useContext } from 'react';
|
||||
import React, { useState, useContext, useCallback } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
Stack,
|
||||
Form,
|
||||
} from '@openedx/paragon';
|
||||
import { Edit } from '@openedx/paragon/icons';
|
||||
|
||||
import { InplaceTextEditor } from '../../generic/inplace-text-editor';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
@@ -10,12 +16,12 @@ import messages from './messages';
|
||||
|
||||
const CollectionInfoHeader = () => {
|
||||
const intl = useIntl();
|
||||
const [inputIsActive, setIsActive] = useState(false);
|
||||
|
||||
const { libraryId, readOnly } = useLibraryContext();
|
||||
const { sidebarComponentInfo } = useSidebarContext();
|
||||
|
||||
const collectionId = sidebarComponentInfo?.id;
|
||||
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!collectionId) {
|
||||
throw new Error('collectionId is required');
|
||||
@@ -26,28 +32,74 @@ const CollectionInfoHeader = () => {
|
||||
const updateMutation = useUpdateCollection(libraryId, collectionId);
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const handleSaveTitle = (newTitle: string) => {
|
||||
updateMutation.mutateAsync({
|
||||
title: newTitle,
|
||||
}).then(() => {
|
||||
showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
|
||||
});
|
||||
};
|
||||
const handleSaveDisplayName = useCallback(
|
||||
(event) => {
|
||||
const newTitle = event.target.value;
|
||||
if (newTitle && newTitle !== collection?.title) {
|
||||
updateMutation.mutateAsync({
|
||||
title: newTitle,
|
||||
}).then(() => {
|
||||
showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
|
||||
}).finally(() => {
|
||||
setIsActive(false);
|
||||
});
|
||||
} else {
|
||||
setIsActive(false);
|
||||
}
|
||||
},
|
||||
[collection, showToast, intl],
|
||||
);
|
||||
|
||||
if (!collection) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
setIsActive(true);
|
||||
};
|
||||
|
||||
const handleOnKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSaveDisplayName(event);
|
||||
} else if (event.key === 'Escape') {
|
||||
setIsActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<InplaceTextEditor
|
||||
onSave={handleSaveTitle}
|
||||
text={collection.title}
|
||||
readOnly={readOnly}
|
||||
textClassName="font-weight-bold m-1.5"
|
||||
showEditButton
|
||||
/>
|
||||
<Stack direction="horizontal">
|
||||
{inputIsActive
|
||||
? (
|
||||
<Form.Control
|
||||
autoFocus
|
||||
name="title"
|
||||
id="title"
|
||||
type="text"
|
||||
aria-label="Title input"
|
||||
defaultValue={collection.title}
|
||||
onBlur={handleSaveDisplayName}
|
||||
onKeyDown={handleOnKeyDown}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<span className="font-weight-bold m-1.5">
|
||||
{collection.title}
|
||||
</span>
|
||||
{!readOnly && (
|
||||
<IconButton
|
||||
src={Edit}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.editTitleButtonAlt)}
|
||||
onClick={handleClick}
|
||||
size="inline"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -15,16 +15,12 @@ import {
|
||||
mockContentLibrary,
|
||||
mockXBlockFields,
|
||||
mockGetCollectionMetadata,
|
||||
mockGetContainerMetadata,
|
||||
} from '../data/api.mocks';
|
||||
import { mockContentSearchConfig, mockGetBlockTypes } from '../../search-manager/data/api.mock';
|
||||
import { mockClipboardEmpty } from '../../generic/data/api.mock';
|
||||
import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock';
|
||||
import { LibraryLayout } from '..';
|
||||
import { ContentTagsDrawer } from '../../content-tags-drawer';
|
||||
import {
|
||||
getLibraryCollectionItemsApiUrl,
|
||||
getLibraryContainersApiUrl,
|
||||
} from '../data/api';
|
||||
import { getLibraryCollectionComponentApiUrl } from '../data/api';
|
||||
|
||||
let axiosMock: MockAdapter;
|
||||
let mockShowToast;
|
||||
@@ -35,7 +31,7 @@ mockContentSearchConfig.applyMock();
|
||||
mockGetBlockTypes.applyMock();
|
||||
mockContentLibrary.applyMock();
|
||||
mockXBlockFields.applyMock();
|
||||
mockGetContainerMetadata.applyMock();
|
||||
mockBroadcastChannel();
|
||||
|
||||
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
|
||||
const path = '/library/:libraryId/*';
|
||||
@@ -355,7 +351,7 @@ describe('<LibraryCollectionPage />', () => {
|
||||
});
|
||||
|
||||
it('should remove component from collection and hides sidebar', async () => {
|
||||
const url = getLibraryCollectionItemsApiUrl(
|
||||
const url = getLibraryCollectionComponentApiUrl(
|
||||
mockContentLibrary.libraryId,
|
||||
mockCollection.collectionId,
|
||||
);
|
||||
@@ -374,110 +370,9 @@ describe('<LibraryCollectionPage />', () => {
|
||||
fireEvent.click(await screen.findByText('Remove from collection'));
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.delete.length).toEqual(1);
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Component successfully removed');
|
||||
});
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Item successfully removed');
|
||||
// Should close sidebar as component was removed
|
||||
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should show error when remove component from collection', async () => {
|
||||
const url = getLibraryCollectionItemsApiUrl(
|
||||
mockContentLibrary.libraryId,
|
||||
mockCollection.collectionId,
|
||||
);
|
||||
axiosMock.onDelete(url).reply(404);
|
||||
await renderLibraryCollectionPage();
|
||||
|
||||
const menuBtns = await screen.findAllByRole('button', { name: 'Component actions menu' });
|
||||
// open menu
|
||||
fireEvent.click(menuBtns[0]);
|
||||
|
||||
fireEvent.click(await screen.findByText('Remove from collection'));
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.delete.length).toEqual(1);
|
||||
});
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Failed to remove item');
|
||||
});
|
||||
|
||||
it('should remove unit from collection and hides sidebar', async () => {
|
||||
const url = getLibraryCollectionItemsApiUrl(
|
||||
mockContentLibrary.libraryId,
|
||||
mockCollection.collectionId,
|
||||
);
|
||||
axiosMock.onDelete(url).reply(204);
|
||||
const displayName = 'Test Unit';
|
||||
await renderLibraryCollectionPage();
|
||||
|
||||
// Wait for the unit cards to load
|
||||
waitFor(() => expect(screen.getAllByTestId('container-card-menu-toggle').length).toBeGreaterThan(0));
|
||||
|
||||
// open sidebar
|
||||
fireEvent.click(await screen.findByText(displayName));
|
||||
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).toBeInTheDocument());
|
||||
|
||||
// Open menu
|
||||
fireEvent.click((await screen.findAllByTestId('container-card-menu-toggle'))[0]);
|
||||
|
||||
// Click remove to collection
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Remove from collection' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.delete.length).toEqual(1);
|
||||
});
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Item successfully removed');
|
||||
// Should close sidebar as component was removed
|
||||
await waitFor(() => expect(screen.queryByTestId('library-sidebar')).not.toBeInTheDocument());
|
||||
});
|
||||
|
||||
it('should create a unit inside a collection', async () => {
|
||||
await renderLibraryCollectionPage();
|
||||
const unitTitle = 'This is a Test';
|
||||
const containerUrl = getLibraryContainersApiUrl(mockContentLibrary.libraryId);
|
||||
axiosMock.onPost(containerUrl).reply(200, {
|
||||
id: 'unit-1',
|
||||
slug: 'this-is-a-test',
|
||||
title: unitTitle,
|
||||
});
|
||||
const collectionUrl = getLibraryCollectionItemsApiUrl(
|
||||
mockContentLibrary.libraryId,
|
||||
mockCollection.collectionId,
|
||||
);
|
||||
axiosMock.onPatch(collectionUrl).reply(200);
|
||||
|
||||
expect(await screen.findByRole('heading')).toBeInTheDocument();
|
||||
expect(screen.queryByText(/add content/i)).not.toBeInTheDocument();
|
||||
|
||||
// Open Add content sidebar
|
||||
const newButton = screen.getByRole('button', { name: /new/i });
|
||||
fireEvent.click(newButton);
|
||||
expect(screen.getByText(/add content/i)).toBeInTheDocument();
|
||||
|
||||
// Open New unit Modal
|
||||
const sidebar = screen.getByTestId('library-sidebar');
|
||||
const newUnitButton = within(sidebar).getAllByRole('button', { name: /unit/i })[0];
|
||||
fireEvent.click(newUnitButton);
|
||||
const unitModalHeading = await screen.findByRole('heading', { name: /new unit/i });
|
||||
expect(unitModalHeading).toBeInTheDocument();
|
||||
|
||||
// Fill the form
|
||||
const createButton = screen.getByRole('button', { name: /create/i });
|
||||
const nameField = screen.getByRole('textbox', { name: /name your unit/i });
|
||||
|
||||
fireEvent.change(nameField, { target: { value: unitTitle } });
|
||||
fireEvent.click(createButton);
|
||||
|
||||
// Check success
|
||||
await waitFor(() => expect(axiosMock.history.post.length).toBe(1));
|
||||
|
||||
// Check that the unit was created
|
||||
expect(axiosMock.history.post[0].url).toBe(containerUrl);
|
||||
expect(axiosMock.history.post[0].data).toContain(`"display_name":"${unitTitle}"`);
|
||||
expect(axiosMock.history.post[0].data).toContain('"container_type":"unit"');
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Unit created successfully');
|
||||
|
||||
// Check that the unit was added to the collection
|
||||
expect(axiosMock.history.patch.length).toBe(1);
|
||||
expect(axiosMock.history.patch[0].url).toBe(collectionUrl);
|
||||
expect(axiosMock.history.patch[0].data).toContain('"usage_keys":["unit-1"]');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,9 +109,7 @@ const LibraryCollectionPage = () => {
|
||||
}
|
||||
|
||||
const { componentPickerMode } = useComponentPickerContext();
|
||||
const {
|
||||
showOnlyPublished, extraFilter: contextExtraFilter, setCollectionId, componentId,
|
||||
} = useLibraryContext();
|
||||
const { showOnlyPublished, setCollectionId, componentId } = useLibraryContext();
|
||||
const { sidebarComponentInfo, openInfoSidebar } = useSidebarContext();
|
||||
|
||||
const {
|
||||
@@ -184,10 +182,6 @@ const LibraryCollectionPage = () => {
|
||||
extraFilter.push('last_published IS NOT NULL');
|
||||
}
|
||||
|
||||
if (contextExtraFilter) {
|
||||
extraFilter.push(...contextExtraFilter);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="d-flex">
|
||||
<div className="flex-grow-1">
|
||||
|
||||
@@ -111,6 +111,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Failed to update collection.',
|
||||
description: 'Message displayed when collection update fails',
|
||||
},
|
||||
editTitleButtonAlt: {
|
||||
id: 'course-authoring.library-authoring.collection.sidebar.edit-name.alt',
|
||||
defaultMessage: 'Edit collection title',
|
||||
description: 'Alt text for edit collection title icon button',
|
||||
},
|
||||
returnToLibrary: {
|
||||
id: 'course-authoring.library-authoring.collection.component-picker.return-to-library',
|
||||
defaultMessage: 'Back to Library',
|
||||
|
||||
@@ -34,8 +34,6 @@ export type LibraryContextData = {
|
||||
setUnitId: (unitId?: string) => void;
|
||||
// Only show published components
|
||||
showOnlyPublished: boolean;
|
||||
// Additional filtering
|
||||
extraFilter?: string[];
|
||||
// "Create New Collection" modal
|
||||
isCreateCollectionModalOpen: boolean;
|
||||
openCreateCollectionModal: () => void;
|
||||
@@ -68,14 +66,13 @@ type LibraryProviderProps = {
|
||||
children?: React.ReactNode;
|
||||
libraryId: string;
|
||||
showOnlyPublished?: boolean;
|
||||
extraFilter?: string[]
|
||||
// If set, will initialize the current collection and/or component from the current URL
|
||||
skipUrlUpdate?: boolean;
|
||||
|
||||
/** The component picker modal to use. We need to pass it as a reference instead of
|
||||
* directly importing it to avoid the import cycle:
|
||||
* ComponentPicker > LibraryAuthoringPage/LibraryCollectionPage >
|
||||
* Sidebar > AddContent > ComponentPicker */
|
||||
* Sidebar > AddContentContainer > ComponentPicker */
|
||||
componentPicker?: typeof ComponentPicker;
|
||||
};
|
||||
|
||||
@@ -86,7 +83,6 @@ export const LibraryProvider = ({
|
||||
children,
|
||||
libraryId,
|
||||
showOnlyPublished = false,
|
||||
extraFilter = [],
|
||||
skipUrlUpdate = false,
|
||||
componentPicker,
|
||||
}: LibraryProviderProps) => {
|
||||
@@ -143,7 +139,6 @@ export const LibraryProvider = ({
|
||||
readOnly,
|
||||
isLoadingLibraryData,
|
||||
showOnlyPublished,
|
||||
extraFilter,
|
||||
isCreateCollectionModalOpen,
|
||||
openCreateCollectionModal,
|
||||
closeCreateCollectionModal,
|
||||
@@ -169,7 +164,6 @@ export const LibraryProvider = ({
|
||||
readOnly,
|
||||
isLoadingLibraryData,
|
||||
showOnlyPublished,
|
||||
extraFilter,
|
||||
isCreateCollectionModalOpen,
|
||||
openCreateCollectionModal,
|
||||
closeCreateCollectionModal,
|
||||
|
||||
@@ -29,22 +29,14 @@ const CompareChangesWidget = ({ usageKey, oldVersion = 'published', newVersion =
|
||||
<Tab eventKey="old" title={intl.formatMessage(messages.oldVersionTitle)}>
|
||||
<div className="p-2 bg-white">
|
||||
<IframeProvider>
|
||||
<LibraryBlock
|
||||
usageKey={usageKey}
|
||||
version={oldVersion}
|
||||
minHeight="50vh"
|
||||
/>
|
||||
<LibraryBlock usageKey={usageKey} version={oldVersion} />
|
||||
</IframeProvider>
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab eventKey="new" title={intl.formatMessage(messages.newVersionTitle)}>
|
||||
<div className="p-2 bg-white">
|
||||
<IframeProvider>
|
||||
<LibraryBlock
|
||||
usageKey={usageKey}
|
||||
version={newVersion}
|
||||
minHeight="50vh"
|
||||
/>
|
||||
<LibraryBlock usageKey={usageKey} version={newVersion} />
|
||||
</IframeProvider>
|
||||
</div>
|
||||
</Tab>
|
||||
|
||||
@@ -10,12 +10,14 @@ import {
|
||||
mockGetUnpaginatedEntityLinks,
|
||||
} from '../data/api.mocks';
|
||||
import { mockContentSearchConfig, mockFetchIndexDocuments } from '../../search-manager/data/api.mock';
|
||||
import { mockBroadcastChannel } from '../../generic/data/api.mock';
|
||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext';
|
||||
import ComponentInfo from './ComponentInfo';
|
||||
import { getXBlockPublishApiUrl } from '../data/api';
|
||||
|
||||
mockContentSearchConfig.applyMock();
|
||||
mockBroadcastChannel();
|
||||
mockContentLibrary.applyMock();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
mockGetUnpaginatedEntityLinks.applyMock();
|
||||
|
||||
@@ -61,7 +61,7 @@ describe('<ComponentInfoHeader />', () => {
|
||||
|
||||
expect(await screen.findByText('Test HTML Block')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /edit component name/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render edit title button without permission', async () => {
|
||||
@@ -69,7 +69,7 @@ describe('<ComponentInfoHeader />', () => {
|
||||
|
||||
expect(await screen.findByText('Test HTML Block')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /edit component name/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should edit component title', async () => {
|
||||
@@ -79,9 +79,9 @@ describe('<ComponentInfoHeader />', () => {
|
||||
|
||||
expect(await screen.findByText('Test HTML Block')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit component name/i }));
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /text input/i });
|
||||
const textBox = screen.getByRole('textbox', { name: /display name input/i });
|
||||
|
||||
fireEvent.change(textBox, { target: { value: 'New component name' } });
|
||||
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
|
||||
@@ -105,9 +105,9 @@ describe('<ComponentInfoHeader />', () => {
|
||||
|
||||
expect(await screen.findByText('Test HTML Block')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit component name/i }));
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /text input/i });
|
||||
const textBox = screen.getByRole('textbox', { name: /display name input/i });
|
||||
|
||||
fireEvent.change(textBox, { target: { value: 'New component name' } });
|
||||
fireEvent.keyDown(textBox, { key: 'Escape', code: 'Escape', charCode: 27 });
|
||||
@@ -124,9 +124,9 @@ describe('<ComponentInfoHeader />', () => {
|
||||
|
||||
expect(await screen.findByText('Test HTML Block')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit component name/i }));
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /text input/i });
|
||||
const textBox = screen.getByRole('textbox', { name: /display name input/i });
|
||||
|
||||
fireEvent.change(textBox, { target: { value: 'New component name' } });
|
||||
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useContext } from 'react';
|
||||
import React, { useState, useContext, useCallback } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
Stack,
|
||||
Form,
|
||||
} from '@openedx/paragon';
|
||||
import { Edit } from '@openedx/paragon/icons';
|
||||
|
||||
import { InplaceTextEditor } from '../../generic/inplace-text-editor';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
@@ -10,6 +16,7 @@ import messages from './messages';
|
||||
|
||||
const ComponentInfoHeader = () => {
|
||||
const intl = useIntl();
|
||||
const [inputIsActive, setIsActive] = useState(false);
|
||||
|
||||
const { readOnly, showOnlyPublished } = useLibraryContext();
|
||||
const { sidebarComponentInfo } = useSidebarContext();
|
||||
@@ -26,30 +33,69 @@ const ComponentInfoHeader = () => {
|
||||
const updateMutation = useUpdateXBlockFields(usageKey);
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const handleSaveDisplayName = (newDisplayName: string) => {
|
||||
updateMutation.mutateAsync({
|
||||
metadata: {
|
||||
display_name: newDisplayName,
|
||||
},
|
||||
}).then(() => {
|
||||
showToast(intl.formatMessage(messages.updateComponentSuccessMsg));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.updateComponentErrorMsg));
|
||||
});
|
||||
const handleSaveDisplayName = useCallback(
|
||||
(event) => {
|
||||
const newDisplayName = event.target.value;
|
||||
if (newDisplayName && newDisplayName !== xblockFields?.displayName) {
|
||||
updateMutation.mutateAsync({
|
||||
metadata: {
|
||||
display_name: newDisplayName,
|
||||
},
|
||||
}).then(() => {
|
||||
showToast(intl.formatMessage(messages.updateComponentSuccessMsg));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.updateComponentErrorMsg));
|
||||
});
|
||||
}
|
||||
setIsActive(false);
|
||||
},
|
||||
[xblockFields, showToast, intl],
|
||||
);
|
||||
|
||||
const handleClick = () => {
|
||||
setIsActive(true);
|
||||
};
|
||||
|
||||
if (!xblockFields) {
|
||||
return null;
|
||||
}
|
||||
const hanldeOnKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSaveDisplayName(event);
|
||||
} else if (event.key === 'Escape') {
|
||||
setIsActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<InplaceTextEditor
|
||||
onSave={handleSaveDisplayName}
|
||||
text={xblockFields?.displayName}
|
||||
readOnly={readOnly}
|
||||
textClassName="font-weight-bold m-1.5"
|
||||
showEditButton
|
||||
/>
|
||||
<Stack direction="horizontal">
|
||||
{inputIsActive
|
||||
? (
|
||||
<Form.Control
|
||||
autoFocus
|
||||
name="displayName"
|
||||
id="displayName"
|
||||
type="text"
|
||||
aria-label="Display name input"
|
||||
defaultValue={xblockFields?.displayName}
|
||||
onBlur={handleSaveDisplayName}
|
||||
onKeyDown={hanldeOnKeyDown}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<span className="font-weight-bold m-1.5">
|
||||
{xblockFields?.displayName}
|
||||
</span>
|
||||
{!readOnly && (
|
||||
<IconButton
|
||||
src={Edit}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.editNameButtonAlt)}
|
||||
onClick={handleClick}
|
||||
size="inline"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { ContentTagsDrawer, useContentTaxonomyTagsData } from '../../content-tags-drawer';
|
||||
import { useLibraryBlockMetadata, useUpdateComponentCollections } from '../data/apiHooks';
|
||||
import { useLibraryBlockMetadata } from '../data/apiHooks';
|
||||
import StatusWidget from '../generic/status-widget';
|
||||
import { ManageCollections } from '../generic/manage-collections';
|
||||
import messages from './messages';
|
||||
import { ContentTagsDrawer, useContentTaxonomyTagsData } from '../../content-tags-drawer';
|
||||
import ManageCollections from './ManageCollections';
|
||||
|
||||
const ComponentManagement = () => {
|
||||
const intl = useIntl();
|
||||
@@ -130,11 +130,7 @@ const ComponentManagement = () => {
|
||||
</Collapsible.Visible>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Body className="collapsible-body">
|
||||
<ManageCollections
|
||||
opaqueKey={usageKey}
|
||||
collections={componentMetadata.collections}
|
||||
useUpdateCollectionsHook={useUpdateComponentCollections}
|
||||
/>
|
||||
<ManageCollections usageKey={usageKey} collections={componentMetadata.collections} />
|
||||
</Collapsible.Body>
|
||||
</Collapsible.Advanced>
|
||||
</Stack>
|
||||
|
||||
@@ -31,7 +31,6 @@ const ModalComponentPreview = ({ isOpen, close, usageKey }: ModalComponentPrevie
|
||||
<LibraryBlock
|
||||
usageKey={usageKey}
|
||||
version={showOnlyPublished ? 'published' : undefined}
|
||||
minHeight="60vh"
|
||||
/>
|
||||
</StandardModal>
|
||||
);
|
||||
@@ -72,7 +71,6 @@ const ComponentPreview = () => {
|
||||
usageKey={usageKey}
|
||||
key={componentMetadata.modified}
|
||||
version={showOnlyPublished ? 'published' : undefined}
|
||||
minHeight="60vh"
|
||||
/>
|
||||
)
|
||||
: null
|
||||
|
||||
@@ -2,27 +2,25 @@ import fetchMock from 'fetch-mock-jest';
|
||||
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import MockAdapter from 'axios-mock-adapter/types';
|
||||
import { mockContentSearchConfig } from '../../../search-manager/data/api.mock';
|
||||
import {
|
||||
initializeMocks,
|
||||
render as baseRender,
|
||||
screen,
|
||||
waitFor,
|
||||
} from '../../../testUtils';
|
||||
import mockCollectionsResults from '../../__mocks__/collection-search.json';
|
||||
import { LibraryProvider } from '../../common/context/LibraryContext';
|
||||
import { SidebarProvider } from '../../common/context/SidebarContext';
|
||||
import { getLibraryBlockCollectionsUrl, getLibraryContainerCollectionsUrl } from '../../data/api';
|
||||
import { useUpdateComponentCollections, useUpdateContainerCollections } from '../../data/apiHooks';
|
||||
import { mockContentLibrary, mockLibraryBlockMetadata, mockGetContainerMetadata } from '../../data/api.mocks';
|
||||
} from '../../testUtils';
|
||||
import mockCollectionsResults from '../__mocks__/collection-search.json';
|
||||
import { mockContentSearchConfig } from '../../search-manager/data/api.mock';
|
||||
import { mockContentLibrary, mockLibraryBlockMetadata } from '../data/api.mocks';
|
||||
import ManageCollections from './ManageCollections';
|
||||
import { LibraryProvider } from '../common/context/LibraryContext';
|
||||
import { SidebarProvider } from '../common/context/SidebarContext';
|
||||
import { getLibraryBlockCollectionsUrl } from '../data/api';
|
||||
|
||||
let axiosMock: MockAdapter;
|
||||
let mockShowToast;
|
||||
|
||||
mockContentLibrary.applyMock();
|
||||
mockLibraryBlockMetadata.applyMock();
|
||||
mockGetContainerMetadata.applyMock();
|
||||
mockContentSearchConfig.applyMock();
|
||||
|
||||
const render = (ui: React.ReactElement) => baseRender(ui, {
|
||||
@@ -58,13 +56,12 @@ describe('<ManageCollections />', () => {
|
||||
});
|
||||
});
|
||||
|
||||
it('should show all collections in library and allow users to select for the current component', async () => {
|
||||
it('should show all collections in library and allow users to select for the current component ', async () => {
|
||||
const url = getLibraryBlockCollectionsUrl(mockLibraryBlockMetadata.usageKeyWithCollections);
|
||||
axiosMock.onPatch(url).reply(200);
|
||||
render(<ManageCollections
|
||||
opaqueKey={mockLibraryBlockMetadata.usageKeyWithCollections}
|
||||
usageKey={mockLibraryBlockMetadata.usageKeyWithCollections}
|
||||
collections={[{ title: 'My first collection', key: 'my-first-collection' }]}
|
||||
useUpdateCollectionsHook={useUpdateComponentCollections}
|
||||
/>);
|
||||
const manageBtn = await screen.findByRole('button', { name: 'Manage Collections' });
|
||||
userEvent.click(manageBtn);
|
||||
@@ -76,36 +73,10 @@ describe('<ManageCollections />', () => {
|
||||
userEvent.click(confirmBtn);
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.patch.length).toEqual(1);
|
||||
});
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Item collections updated');
|
||||
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
||||
collection_keys: ['my-first-collection', 'my-second-collection'],
|
||||
});
|
||||
expect(screen.queryByRole('search')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should show all collections in library and allow users to select for the current container', async () => {
|
||||
const url = getLibraryContainerCollectionsUrl(mockGetContainerMetadata.containerIdWithCollections);
|
||||
axiosMock.onPatch(url).reply(200);
|
||||
render(<ManageCollections
|
||||
opaqueKey={mockGetContainerMetadata.containerIdWithCollections}
|
||||
collections={[{ title: 'My first collection', key: 'my-first-collection' }]}
|
||||
useUpdateCollectionsHook={useUpdateContainerCollections}
|
||||
/>);
|
||||
const manageBtn = await screen.findByRole('button', { name: 'Manage Collections' });
|
||||
userEvent.click(manageBtn);
|
||||
await waitFor(() => { expect(fetchMock).toHaveFetchedTimes(1, searchEndpoint, 'post'); });
|
||||
expect(screen.queryByRole('search')).toBeInTheDocument();
|
||||
const secondCollection = await screen.findByRole('button', { name: 'My second collection' });
|
||||
userEvent.click(secondCollection);
|
||||
const confirmBtn = await screen.findByRole('button', { name: 'Confirm' });
|
||||
userEvent.click(confirmBtn);
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.patch.length).toEqual(1);
|
||||
});
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Item collections updated');
|
||||
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
||||
collection_keys: ['my-first-collection', 'my-second-collection'],
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Component collections updated');
|
||||
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
||||
collection_keys: ['my-first-collection', 'my-second-collection'],
|
||||
});
|
||||
});
|
||||
expect(screen.queryByRole('search')).not.toBeInTheDocument();
|
||||
});
|
||||
@@ -114,9 +85,8 @@ describe('<ManageCollections />', () => {
|
||||
const url = getLibraryBlockCollectionsUrl(mockLibraryBlockMetadata.usageKeyWithCollections);
|
||||
axiosMock.onPatch(url).reply(400);
|
||||
render(<ManageCollections
|
||||
opaqueKey={mockLibraryBlockMetadata.usageKeyWithCollections}
|
||||
usageKey={mockLibraryBlockMetadata.usageKeyWithCollections}
|
||||
collections={[]}
|
||||
useUpdateCollectionsHook={useUpdateComponentCollections}
|
||||
/>);
|
||||
screen.logTestingPlaygroundURL();
|
||||
const manageBtn = await screen.findByRole('button', { name: 'Add to Collection' });
|
||||
@@ -129,11 +99,11 @@ describe('<ManageCollections />', () => {
|
||||
userEvent.click(confirmBtn);
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.patch.length).toEqual(1);
|
||||
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
||||
collection_keys: ['my-second-collection'],
|
||||
});
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Failed to update Component collections');
|
||||
});
|
||||
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual({
|
||||
collection_keys: ['my-second-collection'],
|
||||
});
|
||||
expect(mockShowToast).toHaveBeenCalledWith('Failed to update item collections');
|
||||
expect(screen.queryByRole('search')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
@@ -141,9 +111,8 @@ describe('<ManageCollections />', () => {
|
||||
const url = getLibraryBlockCollectionsUrl(mockLibraryBlockMetadata.usageKeyWithCollections);
|
||||
axiosMock.onPatch(url).reply(400);
|
||||
render(<ManageCollections
|
||||
opaqueKey={mockLibraryBlockMetadata.usageKeyWithCollections}
|
||||
usageKey={mockLibraryBlockMetadata.usageKeyWithCollections}
|
||||
collections={[]}
|
||||
useUpdateCollectionsHook={useUpdateComponentCollections}
|
||||
/>);
|
||||
const manageBtn = await screen.findByRole('button', { name: 'Add to Collection' });
|
||||
userEvent.click(manageBtn);
|
||||
@@ -155,8 +124,8 @@ describe('<ManageCollections />', () => {
|
||||
userEvent.click(cancelBtn);
|
||||
await waitFor(() => {
|
||||
expect(axiosMock.history.patch.length).toEqual(0);
|
||||
expect(mockShowToast).not.toHaveBeenCalled();
|
||||
});
|
||||
expect(mockShowToast).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole('search')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useContext, useState } from 'react';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
import {
|
||||
Button, Icon, Scrollable, SelectableBox, Stack, StatefulButton, useCheckboxSetValues,
|
||||
} from '@openedx/paragon';
|
||||
@@ -11,29 +10,24 @@ import {
|
||||
SearchKeywordsField,
|
||||
SearchSortWidget,
|
||||
useSearchContext,
|
||||
} from '../../../search-manager';
|
||||
import { ToastContext } from '../../../generic/toast-context';
|
||||
import { CollectionMetadata } from '../../data/api';
|
||||
import { useLibraryContext } from '../../common/context/LibraryContext';
|
||||
import { SidebarActions, useSidebarContext } from '../../common/context/SidebarContext';
|
||||
} from '../../search-manager';
|
||||
import messages from './messages';
|
||||
import { useUpdateComponentCollections } from '../data/apiHooks';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { CollectionMetadata } from '../data/api';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
|
||||
|
||||
interface ManageCollectionsProps {
|
||||
opaqueKey: string;
|
||||
usageKey: string;
|
||||
collections: CollectionMetadata[],
|
||||
useUpdateCollectionsHook: (opaqueKey: string) => UseMutationResult<void, unknown, string[], unknown>;
|
||||
}
|
||||
|
||||
interface CollectionsDrawerProps extends ManageCollectionsProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const CollectionsSelectableBox = ({
|
||||
opaqueKey,
|
||||
collections,
|
||||
useUpdateCollectionsHook,
|
||||
onClose,
|
||||
}: CollectionsDrawerProps) => {
|
||||
const CollectionsSelectableBox = ({ usageKey, collections, onClose }: CollectionsDrawerProps) => {
|
||||
const type = 'checkbox';
|
||||
const intl = useIntl();
|
||||
const { hits } = useSearchContext();
|
||||
@@ -45,7 +39,9 @@ const CollectionsSelectableBox = ({
|
||||
}] = useCheckboxSetValues(collectionKeys);
|
||||
const [btnState, setBtnState] = useState('default');
|
||||
|
||||
const updateCollectionsMutation = useUpdateCollectionsHook(opaqueKey);
|
||||
const { libraryId } = useLibraryContext();
|
||||
|
||||
const updateCollectionsMutation = useUpdateComponentCollections(libraryId, usageKey);
|
||||
|
||||
const handleConfirmation = () => {
|
||||
setBtnState('pending');
|
||||
@@ -111,12 +107,7 @@ const CollectionsSelectableBox = ({
|
||||
);
|
||||
};
|
||||
|
||||
const AddToCollectionsDrawer = ({
|
||||
opaqueKey,
|
||||
collections,
|
||||
useUpdateCollectionsHook,
|
||||
onClose,
|
||||
}: CollectionsDrawerProps) => {
|
||||
const AddToCollectionsDrawer = ({ usageKey, collections, onClose }: CollectionsDrawerProps) => {
|
||||
const intl = useIntl();
|
||||
const { libraryId } = useLibraryContext();
|
||||
|
||||
@@ -137,20 +128,19 @@ const AddToCollectionsDrawer = ({
|
||||
/>
|
||||
<SearchSortWidget iconOnly />
|
||||
</Stack>
|
||||
{/* Set key to update selection when entity opaqueKey changes */}
|
||||
{/* Set key to update selection when component usageKey changes */}
|
||||
<CollectionsSelectableBox
|
||||
opaqueKey={opaqueKey}
|
||||
usageKey={usageKey}
|
||||
collections={collections}
|
||||
useUpdateCollectionsHook={useUpdateCollectionsHook}
|
||||
onClose={onClose}
|
||||
key={opaqueKey}
|
||||
key={usageKey}
|
||||
/>
|
||||
</Stack>
|
||||
</SearchContextProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const EntityCollections = ({ collections, onManageClick }: {
|
||||
const ComponentCollections = ({ collections, onManageClick }: {
|
||||
collections?: string[];
|
||||
onManageClick: () => void;
|
||||
}) => {
|
||||
@@ -200,7 +190,7 @@ const EntityCollections = ({ collections, onManageClick }: {
|
||||
);
|
||||
};
|
||||
|
||||
const ManageCollections = ({ opaqueKey, collections, useUpdateCollectionsHook }: ManageCollectionsProps) => {
|
||||
const ManageCollections = ({ usageKey, collections }: ManageCollectionsProps) => {
|
||||
const { sidebarAction, resetSidebarAction, setSidebarAction } = useSidebarContext();
|
||||
const collectionNames = collections.map((collection) => collection.title);
|
||||
|
||||
@@ -208,13 +198,12 @@ const ManageCollections = ({ opaqueKey, collections, useUpdateCollectionsHook }:
|
||||
sidebarAction === SidebarActions.JumpToAddCollections
|
||||
? (
|
||||
<AddToCollectionsDrawer
|
||||
opaqueKey={opaqueKey}
|
||||
usageKey={usageKey}
|
||||
collections={collections}
|
||||
useUpdateCollectionsHook={useUpdateCollectionsHook}
|
||||
onClose={() => resetSidebarAction()}
|
||||
/>
|
||||
) : (
|
||||
<EntityCollections
|
||||
<ComponentCollections
|
||||
collections={collectionNames}
|
||||
onManageClick={() => setSidebarAction(SidebarActions.JumpToAddCollections)}
|
||||
/>
|
||||
@@ -61,6 +61,11 @@ const messages = defineMessages({
|
||||
defaultMessage: 'ID (Usage key)',
|
||||
description: 'Heading for the component\'s ID',
|
||||
},
|
||||
editNameButtonAlt: {
|
||||
id: 'course-authoring.library-authoring.component.edit-name.alt',
|
||||
defaultMessage: 'Edit component name',
|
||||
description: 'Alt text for edit component name icon button',
|
||||
},
|
||||
updateComponentSuccessMsg: {
|
||||
id: 'course-authoring.library-authoring.component.update.success',
|
||||
defaultMessage: 'Component updated successfully.',
|
||||
@@ -131,6 +136,51 @@ const messages = defineMessages({
|
||||
defaultMessage: 'Component Preview',
|
||||
description: 'Title for preview modal',
|
||||
},
|
||||
manageCollectionsText: {
|
||||
id: 'course-authoring.library-authoring.component.manage-tab.collections.text',
|
||||
defaultMessage: 'Manage Collections',
|
||||
description: 'Header and button text for collection section in manage tab',
|
||||
},
|
||||
manageCollectionsAddBtnText: {
|
||||
id: 'course-authoring.library-authoring.component.manage-tab.collections.btn-text',
|
||||
defaultMessage: 'Add to Collection',
|
||||
description: 'Button text for collection section in manage tab',
|
||||
},
|
||||
manageCollectionsSearchPlaceholder: {
|
||||
id: 'course-authoring.library-authoring.component.manage-tab.collections.search-placeholder',
|
||||
defaultMessage: 'Search',
|
||||
description: 'Placeholder text for collection search in manage tab',
|
||||
},
|
||||
manageCollectionsSelectionLabel: {
|
||||
id: 'course-authoring.library-authoring.component.manage-tab.collections.selection-aria-label',
|
||||
defaultMessage: 'Collection selection',
|
||||
description: 'Aria label text for collection selection box',
|
||||
},
|
||||
manageCollectionsToComponentSuccess: {
|
||||
id: 'course-authoring.library-authoring.component.manage-tab.collections.add-success',
|
||||
defaultMessage: 'Component collections updated',
|
||||
description: 'Message to display on updating component collections',
|
||||
},
|
||||
manageCollectionsToComponentFailed: {
|
||||
id: 'course-authoring.library-authoring.component.manage-tab.collections.add-failed',
|
||||
defaultMessage: 'Failed to update Component collections',
|
||||
description: 'Message to display on failure of updating component collections',
|
||||
},
|
||||
manageCollectionsToComponentConfirmBtn: {
|
||||
id: 'course-authoring.library-authoring.component.manage-tab.collections.add-confirm-btn',
|
||||
defaultMessage: 'Confirm',
|
||||
description: 'Button text to confirm collections for a component',
|
||||
},
|
||||
manageCollectionsToComponentCancelBtn: {
|
||||
id: 'course-authoring.library-authoring.component.manage-tab.collections.add-cancel-btn',
|
||||
defaultMessage: 'Cancel',
|
||||
description: 'Button text to cancel collections selection for a component',
|
||||
},
|
||||
componentNotOrganizedIntoCollection: {
|
||||
id: 'course-authoring.library-authoring.component.manage-tab.collections.no-collections',
|
||||
defaultMessage: 'This component is not organized into any collection.',
|
||||
description: 'Message to display in manage collections section when component is not part of any collection.',
|
||||
},
|
||||
componentPickerSingleSelect: {
|
||||
id: 'course-authoring.library-authoring.component-picker.single-select',
|
||||
defaultMessage: 'Add to Course', // TODO: Change this message to a generic one?
|
||||
|
||||
@@ -38,7 +38,7 @@ const defaultSelectionChangedCallback: ComponentSelectionChangedEvent = (selecti
|
||||
window.parent.postMessage({ type: 'pickerSelectionChanged', selections }, '*');
|
||||
};
|
||||
|
||||
type ComponentPickerProps = { libraryId?: string, showOnlyPublished?: boolean, extraFilter?: string[] } & (
|
||||
type ComponentPickerProps = { libraryId?: string, showOnlyPublished?: boolean } & (
|
||||
{
|
||||
componentPickerMode?: 'single',
|
||||
onComponentSelected?: ComponentSelectedEvent,
|
||||
@@ -54,7 +54,6 @@ export const ComponentPicker: React.FC<ComponentPickerProps> = ({
|
||||
/** Restrict the component picker to a specific library */
|
||||
libraryId,
|
||||
showOnlyPublished,
|
||||
extraFilter,
|
||||
componentPickerMode = 'single',
|
||||
/** This default callback is used to send the selected component back to the parent window,
|
||||
* when the component picker is used in an iframe.
|
||||
@@ -106,7 +105,6 @@ export const ComponentPicker: React.FC<ComponentPickerProps> = ({
|
||||
<LibraryProvider
|
||||
libraryId={selectedLibrary}
|
||||
showOnlyPublished={calcShowOnlyPublished}
|
||||
extraFilter={extraFilter}
|
||||
skipUrlUpdate
|
||||
>
|
||||
<SidebarProvider>
|
||||
|
||||
@@ -1,82 +0,0 @@
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { Button } from '@openedx/paragon';
|
||||
import {
|
||||
AddCircleOutline,
|
||||
CheckBoxIcon,
|
||||
CheckBoxOutlineBlank,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
|
||||
import messages from './messages';
|
||||
|
||||
interface AddComponentWidgetProps {
|
||||
usageKey: string;
|
||||
blockType: string;
|
||||
}
|
||||
|
||||
const AddComponentWidget = ({ usageKey, blockType }: AddComponentWidgetProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
componentPickerMode,
|
||||
onComponentSelected,
|
||||
addComponentToSelectedComponents,
|
||||
removeComponentFromSelectedComponents,
|
||||
selectedComponents,
|
||||
} = useComponentPickerContext();
|
||||
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!usageKey) {
|
||||
throw new Error('usageKey is required');
|
||||
}
|
||||
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!componentPickerMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (componentPickerMode === 'single') {
|
||||
return (
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={AddCircleOutline}
|
||||
onClick={() => {
|
||||
onComponentSelected({ usageKey, blockType });
|
||||
}}
|
||||
>
|
||||
<FormattedMessage {...messages.componentPickerSingleSelectTitle} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (componentPickerMode === 'multiple') {
|
||||
const isChecked = selectedComponents.some((component) => component.usageKey === usageKey);
|
||||
|
||||
const handleChange = () => {
|
||||
const selectedComponent = {
|
||||
usageKey,
|
||||
blockType,
|
||||
};
|
||||
if (!isChecked) {
|
||||
addComponentToSelectedComponents(selectedComponent);
|
||||
} else {
|
||||
removeComponentFromSelectedComponents(selectedComponent);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={isChecked ? CheckBoxIcon : CheckBoxOutlineBlank}
|
||||
onClick={handleChange}
|
||||
>
|
||||
{intl.formatMessage(messages.componentPickerMultipleSelectTitle)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// istanbul ignore next: this should never happen
|
||||
return null;
|
||||
};
|
||||
|
||||
export default AddComponentWidget;
|
||||
@@ -39,6 +39,13 @@ const contentHit: ContentHit = {
|
||||
publishStatus: PublishStatus.Published,
|
||||
};
|
||||
|
||||
const clipboardBroadcastChannelMock = {
|
||||
postMessage: jest.fn(),
|
||||
close: jest.fn(),
|
||||
};
|
||||
|
||||
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
|
||||
|
||||
const libraryId = 'lib:org1:Demo_Course';
|
||||
const render = () => baseRender(<ComponentCard hit={contentHit} />, {
|
||||
extraWrapper: ({ children }) => (
|
||||
|
||||
@@ -1,21 +1,185 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
Button,
|
||||
Dropdown,
|
||||
Icon,
|
||||
IconButton,
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
AddCircleOutline,
|
||||
CheckBoxIcon,
|
||||
CheckBoxOutlineBlank,
|
||||
MoreVert,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { useClipboard } from '../../generic/clipboard';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { type ContentHit, PublishStatus } from '../../search-manager';
|
||||
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useRemoveComponentsFromCollection } from '../data/apiHooks';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
import AddComponentWidget from './AddComponentWidget';
|
||||
|
||||
import BaseCard from './BaseCard';
|
||||
import { ComponentMenu } from './ComponentMenu';
|
||||
import { canEditComponent } from './ComponentEditorModal';
|
||||
import messages from './messages';
|
||||
import ComponentDeleter from './ComponentDeleter';
|
||||
|
||||
type ComponentCardProps = {
|
||||
hit: ContentHit,
|
||||
};
|
||||
|
||||
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
libraryId,
|
||||
collectionId,
|
||||
openComponentEditor,
|
||||
} = useLibraryContext();
|
||||
|
||||
const {
|
||||
sidebarComponentInfo,
|
||||
openComponentInfoSidebar,
|
||||
closeLibrarySidebar,
|
||||
setSidebarAction,
|
||||
} = useSidebarContext();
|
||||
|
||||
const canEdit = usageKey && canEditComponent(usageKey);
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const removeComponentsMutation = useRemoveComponentsFromCollection(libraryId, collectionId);
|
||||
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
|
||||
const { copyToClipboard } = useClipboard();
|
||||
|
||||
const updateClipboardClick = () => {
|
||||
copyToClipboard(usageKey);
|
||||
};
|
||||
|
||||
const removeFromCollection = () => {
|
||||
removeComponentsMutation.mutateAsync([usageKey]).then(() => {
|
||||
if (sidebarComponentInfo?.id === usageKey) {
|
||||
// Close sidebar if current component is open
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
showToast(intl.formatMessage(messages.removeComponentSucess));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.removeComponentFailure));
|
||||
});
|
||||
};
|
||||
|
||||
const showManageCollections = useCallback(() => {
|
||||
setSidebarAction(SidebarActions.JumpToAddCollections);
|
||||
openComponentInfoSidebar(usageKey);
|
||||
}, [setSidebarAction, openComponentInfoSidebar, usageKey]);
|
||||
|
||||
return (
|
||||
<Dropdown id="component-card-dropdown">
|
||||
<Dropdown.Toggle
|
||||
id="component-card-menu-toggle"
|
||||
as={IconButton}
|
||||
src={MoreVert}
|
||||
iconAs={Icon}
|
||||
variant="primary"
|
||||
alt={intl.formatMessage(messages.componentCardMenuAlt)}
|
||||
data-testid="component-card-menu-toggle"
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item {...(canEdit ? { onClick: () => openComponentEditor(usageKey) } : { disabled: true })}>
|
||||
<FormattedMessage {...messages.menuEdit} />
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={updateClipboardClick}>
|
||||
<FormattedMessage {...messages.menuCopyToClipboard} />
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={confirmDelete}>
|
||||
<FormattedMessage {...messages.menuDelete} />
|
||||
</Dropdown.Item>
|
||||
{collectionId && (
|
||||
<Dropdown.Item onClick={removeFromCollection}>
|
||||
<FormattedMessage {...messages.menuRemoveFromCollection} />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item onClick={showManageCollections}>
|
||||
<FormattedMessage {...messages.menuAddToCollection} />
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
<ComponentDeleter usageKey={usageKey} isConfirmingDelete={isConfirmingDelete} cancelDelete={cancelDelete} />
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
interface AddComponentWidgetProps {
|
||||
usageKey: string;
|
||||
blockType: string;
|
||||
}
|
||||
|
||||
const AddComponentWidget = ({ usageKey, blockType }: AddComponentWidgetProps) => {
|
||||
const intl = useIntl();
|
||||
|
||||
const {
|
||||
componentPickerMode,
|
||||
onComponentSelected,
|
||||
addComponentToSelectedComponents,
|
||||
removeComponentFromSelectedComponents,
|
||||
selectedComponents,
|
||||
} = useComponentPickerContext();
|
||||
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!usageKey) {
|
||||
throw new Error('usageKey is required');
|
||||
}
|
||||
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!componentPickerMode) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (componentPickerMode === 'single') {
|
||||
return (
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={AddCircleOutline}
|
||||
onClick={() => {
|
||||
onComponentSelected({ usageKey, blockType });
|
||||
}}
|
||||
>
|
||||
<FormattedMessage {...messages.componentPickerSingleSelectTitle} />
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
if (componentPickerMode === 'multiple') {
|
||||
const isChecked = selectedComponents.some((component) => component.usageKey === usageKey);
|
||||
|
||||
const handleChange = () => {
|
||||
const selectedComponent = {
|
||||
usageKey,
|
||||
blockType,
|
||||
};
|
||||
if (!isChecked) {
|
||||
addComponentToSelectedComponents(selectedComponent);
|
||||
} else {
|
||||
removeComponentFromSelectedComponents(selectedComponent);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline-primary"
|
||||
iconBefore={isChecked ? CheckBoxIcon : CheckBoxOutlineBlank}
|
||||
onClick={handleChange}
|
||||
>
|
||||
{intl.formatMessage(messages.componentPickerMultipleSelectTitle)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// istanbul ignore next: this should never happen
|
||||
return null;
|
||||
};
|
||||
|
||||
const ComponentCard = ({ hit }: ComponentCardProps) => {
|
||||
const { showOnlyPublished } = useLibraryContext();
|
||||
const { openComponentInfoSidebar } = useSidebarContext();
|
||||
|
||||
@@ -1,137 +0,0 @@
|
||||
import { useCallback, useContext } from 'react';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Dropdown,
|
||||
Icon,
|
||||
IconButton,
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import { MoreVert } from '@openedx/paragon/icons';
|
||||
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useClipboard } from '../../generic/clipboard';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import {
|
||||
useAddComponentsToContainer,
|
||||
useRemoveContainerChildren,
|
||||
useRemoveItemsFromCollection,
|
||||
} from '../data/apiHooks';
|
||||
import { canEditComponent } from './ComponentEditorModal';
|
||||
import ComponentDeleter from './ComponentDeleter';
|
||||
import messages from './messages';
|
||||
|
||||
export const ComponentMenu = ({ usageKey }: { usageKey: string }) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
libraryId,
|
||||
collectionId,
|
||||
unitId,
|
||||
openComponentEditor,
|
||||
} = useLibraryContext();
|
||||
|
||||
const {
|
||||
sidebarComponentInfo,
|
||||
openComponentInfoSidebar,
|
||||
closeLibrarySidebar,
|
||||
setSidebarAction,
|
||||
} = useSidebarContext();
|
||||
|
||||
const canEdit = usageKey && canEditComponent(usageKey);
|
||||
const { showToast } = useContext(ToastContext);
|
||||
const addComponentToContainerMutation = useAddComponentsToContainer(unitId);
|
||||
const removeCollectionComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId);
|
||||
const removeContainerComponentsMutation = useRemoveContainerChildren(unitId);
|
||||
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
|
||||
const { copyToClipboard } = useClipboard();
|
||||
|
||||
const updateClipboardClick = () => {
|
||||
copyToClipboard(usageKey);
|
||||
};
|
||||
|
||||
const removeFromCollection = () => {
|
||||
removeCollectionComponentsMutation.mutateAsync([usageKey]).then(() => {
|
||||
if (sidebarComponentInfo?.id === usageKey) {
|
||||
// Close sidebar if current component is open
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
showToast(intl.formatMessage(messages.removeComponentFromCollectionSuccess));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.removeComponentFromCollectionFailure));
|
||||
});
|
||||
};
|
||||
|
||||
const removeFromContainer = () => {
|
||||
const restoreComponent = () => {
|
||||
addComponentToContainerMutation.mutateAsync([usageKey]).then(() => {
|
||||
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastSuccess));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.undoRemoveComponentFromContainerToastFailed));
|
||||
});
|
||||
};
|
||||
|
||||
removeContainerComponentsMutation.mutateAsync([usageKey]).then(() => {
|
||||
if (sidebarComponentInfo?.id === usageKey) {
|
||||
// Close sidebar if current component is open
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
showToast(
|
||||
intl.formatMessage(messages.removeComponentFromContainerSuccess),
|
||||
{
|
||||
label: intl.formatMessage(messages.undoRemoveComponentFromContainerToastAction),
|
||||
onClick: restoreComponent,
|
||||
},
|
||||
);
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.removeComponentFromContainerFailure));
|
||||
});
|
||||
};
|
||||
|
||||
const showManageCollections = useCallback(() => {
|
||||
setSidebarAction(SidebarActions.JumpToAddCollections);
|
||||
openComponentInfoSidebar(usageKey);
|
||||
}, [setSidebarAction, openComponentInfoSidebar, usageKey]);
|
||||
|
||||
return (
|
||||
<Dropdown id="component-card-dropdown">
|
||||
<Dropdown.Toggle
|
||||
id="component-card-menu-toggle"
|
||||
as={IconButton}
|
||||
src={MoreVert}
|
||||
iconAs={Icon}
|
||||
variant="primary"
|
||||
alt={intl.formatMessage(messages.componentCardMenuAlt)}
|
||||
data-testid="component-card-menu-toggle"
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item {...(canEdit ? { onClick: () => openComponentEditor(usageKey) } : { disabled: true })}>
|
||||
<FormattedMessage {...messages.menuEdit} />
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={updateClipboardClick}>
|
||||
<FormattedMessage {...messages.menuCopyToClipboard} />
|
||||
</Dropdown.Item>
|
||||
{unitId && (
|
||||
<Dropdown.Item onClick={removeFromContainer}>
|
||||
<FormattedMessage {...messages.removeComponentFromUnitMenu} />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item onClick={confirmDelete}>
|
||||
<FormattedMessage {...messages.menuDelete} />
|
||||
</Dropdown.Item>
|
||||
{collectionId && (
|
||||
<Dropdown.Item onClick={removeFromCollection}>
|
||||
<FormattedMessage {...messages.menuRemoveFromCollection} />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
{!unitId && (
|
||||
<Dropdown.Item onClick={showManageCollections}>
|
||||
<FormattedMessage {...messages.menuAddToCollection} />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
</Dropdown.Menu>
|
||||
<ComponentDeleter usageKey={usageKey} isConfirmingDelete={isConfirmingDelete} cancelDelete={cancelDelete} />
|
||||
</Dropdown>
|
||||
);
|
||||
};
|
||||
|
||||
export default ComponentMenu;
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode, useCallback, useContext } from 'react';
|
||||
import { useCallback, ReactNode } from 'react';
|
||||
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
ActionRow,
|
||||
@@ -12,16 +12,14 @@ import { MoreVert } from '@openedx/paragon/icons';
|
||||
import { Link } from 'react-router-dom';
|
||||
|
||||
import { getItemIcon, getComponentStyleColor } from '../../generic/block-type-utils';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { type ContainerHit, PublishStatus } from '../../search-manager';
|
||||
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useContainerChildren, useRemoveItemsFromCollection } from '../data/apiHooks';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
import AddComponentWidget from './AddComponentWidget';
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
import BaseCard from './BaseCard';
|
||||
import { useLibraryRoutes } from '../routes';
|
||||
import messages from './messages';
|
||||
import { useContainerChildren } from '../data/apiHooks';
|
||||
import ContainerDeleter from './ContainerDeleter';
|
||||
|
||||
type ContainerMenuProps = {
|
||||
@@ -32,38 +30,13 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => {
|
||||
const intl = useIntl();
|
||||
const {
|
||||
contextKey,
|
||||
blockId,
|
||||
usageKey: containerId,
|
||||
displayName,
|
||||
} = hit;
|
||||
const { libraryId, collectionId } = useLibraryContext();
|
||||
const {
|
||||
sidebarComponentInfo,
|
||||
openUnitInfoSidebar,
|
||||
closeLibrarySidebar,
|
||||
setSidebarAction,
|
||||
} = useSidebarContext();
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const [isConfirmingDelete, confirmDelete, cancelDelete] = useToggle(false);
|
||||
|
||||
const removeComponentsMutation = useRemoveItemsFromCollection(libraryId, collectionId);
|
||||
|
||||
const removeFromCollection = () => {
|
||||
removeComponentsMutation.mutateAsync([containerId]).then(() => {
|
||||
if (sidebarComponentInfo?.id === containerId) {
|
||||
// Close sidebar if current component is open
|
||||
closeLibrarySidebar();
|
||||
}
|
||||
showToast(intl.formatMessage(messages.removeComponentFromCollectionSuccess));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.removeComponentFromCollectionFailure));
|
||||
});
|
||||
};
|
||||
|
||||
const showManageCollections = useCallback(() => {
|
||||
setSidebarAction(SidebarActions.JumpToAddCollections);
|
||||
openUnitInfoSidebar(containerId);
|
||||
}, [setSidebarAction, openUnitInfoSidebar, containerId]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dropdown id="container-card-dropdown">
|
||||
@@ -73,27 +46,20 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => {
|
||||
src={MoreVert}
|
||||
iconAs={Icon}
|
||||
variant="primary"
|
||||
alt={intl.formatMessage(messages.containerCardMenuAlt)}
|
||||
alt={intl.formatMessage(messages.collectionCardMenuAlt)}
|
||||
data-testid="container-card-menu-toggle"
|
||||
/>
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
as={Link}
|
||||
to={`/library/${contextKey}/unit/${containerId}`}
|
||||
to={`/library/${contextKey}/container/${blockId}`}
|
||||
disabled
|
||||
>
|
||||
<FormattedMessage {...messages.menuOpen} />
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item onClick={confirmDelete}>
|
||||
<FormattedMessage {...messages.menuDeleteContainer} />
|
||||
</Dropdown.Item>
|
||||
{collectionId && (
|
||||
<Dropdown.Item onClick={removeFromCollection}>
|
||||
<FormattedMessage {...messages.menuRemoveFromCollection} />
|
||||
</Dropdown.Item>
|
||||
)}
|
||||
<Dropdown.Item onClick={showManageCollections}>
|
||||
<FormattedMessage {...messages.menuAddToCollection} />
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
</Dropdown>
|
||||
<ContainerDeleter
|
||||
@@ -112,7 +78,8 @@ type ContainerCardPreviewProps = {
|
||||
};
|
||||
|
||||
const ContainerCardPreview = ({ containerId, showMaxChildren = 5 }: ContainerCardPreviewProps) => {
|
||||
const { data, isLoading, isError } = useContainerChildren(containerId);
|
||||
const { libraryId } = useLibraryContext();
|
||||
const { data, isLoading, isError } = useContainerChildren(libraryId, containerId);
|
||||
if (isLoading || isError) {
|
||||
return null;
|
||||
}
|
||||
@@ -203,13 +170,9 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
|
||||
preview={<ContainerCardPreview containerId={unitId} />}
|
||||
tags={tags}
|
||||
numChildren={numChildrenCount}
|
||||
actions={(
|
||||
actions={!componentPickerMode && (
|
||||
<ActionRow>
|
||||
{componentPickerMode ? (
|
||||
<AddComponentWidget usageKey={unitId} blockType={itemType} />
|
||||
) : (
|
||||
<ContainerMenu hit={hit} />
|
||||
)}
|
||||
<ContainerMenu hit={hit} />
|
||||
</ActionRow>
|
||||
)}
|
||||
hasUnpublishedChanges={publishStatus !== PublishStatus.Published}
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { ComponentMenu as default } from './ComponentMenu';
|
||||
export { ComponentMenu as default } from './ComponentCard';
|
||||
|
||||
@@ -44,17 +44,17 @@ const messages = defineMessages({
|
||||
menuRemoveFromCollection: {
|
||||
id: 'course-authoring.library-authoring.component.menu.remove',
|
||||
defaultMessage: 'Remove from collection',
|
||||
description: 'Menu item for remove an item from collection.',
|
||||
description: 'Menu item for remove a component from collection.',
|
||||
},
|
||||
removeComponentFromCollectionSuccess: {
|
||||
removeComponentSucess: {
|
||||
id: 'course-authoring.library-authoring.component.remove-from-collection-success',
|
||||
defaultMessage: 'Item successfully removed',
|
||||
description: 'Message for successful removal of an item from collection.',
|
||||
defaultMessage: 'Component successfully removed',
|
||||
description: 'Message for successful removal of component from collection.',
|
||||
},
|
||||
removeComponentFromCollectionFailure: {
|
||||
removeComponentFailure: {
|
||||
id: 'course-authoring.library-authoring.component.remove-from-collection-failure',
|
||||
defaultMessage: 'Failed to remove item',
|
||||
description: 'Message for failure of removal of an item from collection.',
|
||||
defaultMessage: 'Failed to remove Component',
|
||||
description: 'Message for failure of removal of component from collection.',
|
||||
},
|
||||
deleteComponentWarningTitle: {
|
||||
id: 'course-authoring.library-authoring.component.delete-confirmation-title',
|
||||
@@ -137,7 +137,7 @@ const messages = defineMessages({
|
||||
description: 'Message to display on failure to undo delete collection',
|
||||
},
|
||||
componentPickerSingleSelectTitle: {
|
||||
id: 'course-authoring.library-authoring.component-picker.single.title',
|
||||
id: 'course-authoring.library-authoring.component-picker.single..title',
|
||||
defaultMessage: 'Add',
|
||||
description: 'Button title for picking a component',
|
||||
},
|
||||
@@ -231,35 +231,5 @@ const messages = defineMessages({
|
||||
defaultMessage: '+{count}',
|
||||
description: 'Count shown when a container has more blocks than will fit on the card preview.',
|
||||
},
|
||||
removeComponentFromUnitMenu: {
|
||||
id: 'course-authoring.library-authoring.unit.component.remove.button',
|
||||
defaultMessage: 'Remove from unit',
|
||||
description: 'Text of the menu item to remove a component from a unit',
|
||||
},
|
||||
removeComponentFromContainerSuccess: {
|
||||
id: 'course-authoring.library-authoring.component.remove-from-container-success',
|
||||
defaultMessage: 'Component successfully removed',
|
||||
description: 'Message for successful removal of a component from container.',
|
||||
},
|
||||
removeComponentFromContainerFailure: {
|
||||
id: 'course-authoring.library-authoring.component.remove-from-container-failure',
|
||||
defaultMessage: 'Failed to remove component',
|
||||
description: 'Message for failure of removal of a component from container.',
|
||||
},
|
||||
undoRemoveComponentFromContainerToastAction: {
|
||||
id: 'course-authoring.library-authoring.component.undo-remove-from-container-toast-button',
|
||||
defaultMessage: 'Undo',
|
||||
description: 'Toast message to undo remove a component from container.',
|
||||
},
|
||||
undoRemoveComponentFromContainerToastSuccess: {
|
||||
id: 'course-authoring.library-authoring.component.undo-remove-component-from-container-toast-text',
|
||||
defaultMessage: 'Undo successful',
|
||||
description: 'Message to display on undo delete component success',
|
||||
},
|
||||
undoRemoveComponentFromContainerToastFailed: {
|
||||
id: 'course-authoring.library-authoring.component.undo-remove-component-from-container-failed',
|
||||
defaultMessage: 'Failed to undo remove component operation',
|
||||
description: 'Message to display on failure to undo delete component',
|
||||
},
|
||||
});
|
||||
export default messages;
|
||||
|
||||
@@ -58,14 +58,14 @@ describe('<ContainerInfoHeader />', () => {
|
||||
render();
|
||||
expect(await screen.findByText('Test Unit')).toBeInTheDocument();
|
||||
|
||||
expect(screen.getByRole('button', { name: /edit/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /edit container title/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should not render edit title button without permission', async () => {
|
||||
render(libraryIdReadOnly);
|
||||
expect(await screen.findByText('Test Unit')).toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByRole('button', { name: /edit/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /edit container title/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should update container title', async () => {
|
||||
@@ -76,9 +76,9 @@ describe('<ContainerInfoHeader />', () => {
|
||||
const url = api.getLibraryContainerApiUrl(containerId);
|
||||
axiosMock.onPatch(url).reply(200);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit container title/i }));
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /text input/i });
|
||||
const textBox = screen.getByRole('textbox', { name: /title input/i });
|
||||
|
||||
userEvent.clear(textBox);
|
||||
userEvent.type(textBox, 'New Unit Title{enter}');
|
||||
@@ -99,9 +99,9 @@ describe('<ContainerInfoHeader />', () => {
|
||||
const url = api.getLibraryContainerApiUrl(containerId);
|
||||
axiosMock.onPatch(url).reply(200);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit container title/i }));
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /text input/i });
|
||||
const textBox = screen.getByRole('textbox', { name: /title input/i });
|
||||
|
||||
userEvent.clear(textBox);
|
||||
userEvent.type(textBox, `${mockGetContainerMetadata.containerData.displayName}{enter}`);
|
||||
@@ -118,9 +118,9 @@ describe('<ContainerInfoHeader />', () => {
|
||||
const url = api.getLibraryContainerApiUrl(containerId);
|
||||
axiosMock.onPatch(url).reply(200);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit container title/i }));
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /text input/i });
|
||||
const textBox = screen.getByRole('textbox', { name: /title input/i });
|
||||
|
||||
userEvent.clear(textBox);
|
||||
userEvent.type(textBox, '{enter}');
|
||||
@@ -137,9 +137,9 @@ describe('<ContainerInfoHeader />', () => {
|
||||
const url = api.getLibraryContainerApiUrl(containerId);
|
||||
axiosMock.onPatch(url).reply(200);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit container title/i }));
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /text input/i });
|
||||
const textBox = screen.getByRole('textbox', { name: /title input/i });
|
||||
|
||||
userEvent.clear(textBox);
|
||||
userEvent.type(textBox, 'New Unit Title{esc}');
|
||||
@@ -156,9 +156,9 @@ describe('<ContainerInfoHeader />', () => {
|
||||
const url = api.getLibraryContainerApiUrl(containerId);
|
||||
axiosMock.onPatch(url).reply(500);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /edit container title/i }));
|
||||
|
||||
const textBox = screen.getByRole('textbox', { name: /text input/i });
|
||||
const textBox = screen.getByRole('textbox', { name: /title input/i });
|
||||
|
||||
userEvent.clear(textBox);
|
||||
userEvent.type(textBox, 'New Unit Title{enter}');
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
import { useContext } from 'react';
|
||||
import React, { useState, useContext, useCallback } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import {
|
||||
Icon,
|
||||
IconButton,
|
||||
Stack,
|
||||
Form,
|
||||
} from '@openedx/paragon';
|
||||
import { Edit } from '@openedx/paragon/icons';
|
||||
|
||||
import { InplaceTextEditor } from '../../generic/inplace-text-editor';
|
||||
import { ToastContext } from '../../generic/toast-context';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
@@ -10,8 +16,9 @@ import messages from './messages';
|
||||
|
||||
const ContainerInfoHeader = () => {
|
||||
const intl = useIntl();
|
||||
const [inputIsActive, setIsActive] = useState(false);
|
||||
|
||||
const { readOnly } = useLibraryContext();
|
||||
const { libraryId, readOnly } = useLibraryContext();
|
||||
const { sidebarComponentInfo } = useSidebarContext();
|
||||
|
||||
const containerId = sidebarComponentInfo?.id;
|
||||
@@ -20,33 +27,79 @@ const ContainerInfoHeader = () => {
|
||||
throw new Error('containerId is required');
|
||||
}
|
||||
|
||||
const { data: container } = useContainer(containerId);
|
||||
const { data: container } = useContainer(libraryId, containerId);
|
||||
|
||||
const updateMutation = useUpdateContainer(containerId);
|
||||
const { showToast } = useContext(ToastContext);
|
||||
|
||||
const handleSaveDisplayName = (newDisplayName: string) => {
|
||||
updateMutation.mutateAsync({
|
||||
displayName: newDisplayName,
|
||||
}).then(() => {
|
||||
showToast(intl.formatMessage(messages.updateContainerSuccessMsg));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.updateContainerErrorMsg));
|
||||
});
|
||||
};
|
||||
const handleSaveDisplayName = useCallback(
|
||||
(event) => {
|
||||
const newDisplayName = event.target.value;
|
||||
if (newDisplayName && newDisplayName !== container?.displayName) {
|
||||
updateMutation.mutateAsync({
|
||||
displayName: newDisplayName,
|
||||
}).then(() => {
|
||||
showToast(intl.formatMessage(messages.updateContainerSuccessMsg));
|
||||
}).catch(() => {
|
||||
showToast(intl.formatMessage(messages.updateContainerErrorMsg));
|
||||
}).finally(() => {
|
||||
setIsActive(false);
|
||||
});
|
||||
} else {
|
||||
setIsActive(false);
|
||||
}
|
||||
},
|
||||
[container, showToast, intl],
|
||||
);
|
||||
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const handleClick = () => {
|
||||
setIsActive(true);
|
||||
};
|
||||
|
||||
const handleOnKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter') {
|
||||
handleSaveDisplayName(event);
|
||||
} else if (event.key === 'Escape') {
|
||||
setIsActive(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<InplaceTextEditor
|
||||
onSave={handleSaveDisplayName}
|
||||
text={container.displayName}
|
||||
readOnly={readOnly}
|
||||
textClassName="font-weight-bold m-1.5"
|
||||
showEditButton
|
||||
/>
|
||||
<Stack direction="horizontal">
|
||||
{inputIsActive
|
||||
? (
|
||||
<Form.Control
|
||||
autoFocus
|
||||
name="title"
|
||||
id="title"
|
||||
type="text"
|
||||
aria-label="Title input"
|
||||
defaultValue={container.displayName}
|
||||
onBlur={handleSaveDisplayName}
|
||||
onKeyDown={handleOnKeyDown}
|
||||
/>
|
||||
)
|
||||
: (
|
||||
<>
|
||||
<span className="font-weight-bold m-1.5">
|
||||
{container.displayName}
|
||||
</span>
|
||||
{!readOnly && (
|
||||
<IconButton
|
||||
src={Edit}
|
||||
iconAs={Icon}
|
||||
alt={intl.formatMessage(messages.editTitleButtonAlt)}
|
||||
onClick={handleClick}
|
||||
size="inline"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -23,18 +23,14 @@ mockGetContainerMetadata.applyMock();
|
||||
mockContentLibrary.applyMock();
|
||||
mockContentTaxonomyTagsData.applyMock();
|
||||
|
||||
const render = ({
|
||||
libraryId = mockContentLibrary.libraryId,
|
||||
containerId = mockGetContainerMetadata.containerId,
|
||||
}: {
|
||||
libraryId?: string;
|
||||
containerId?: string;
|
||||
}) => baseRender(<ContainerOrganize />, {
|
||||
const { containerIdForTags } = mockGetContainerMetadata;
|
||||
|
||||
const render = (libraryId?: string) => baseRender(<ContainerOrganize />, {
|
||||
extraWrapper: ({ children }) => (
|
||||
<LibraryProvider libraryId={libraryId}>
|
||||
<LibraryProvider libraryId={libraryId || mockContentLibrary.libraryId}>
|
||||
<SidebarProvider
|
||||
initialSidebarComponentInfo={{
|
||||
id: containerId,
|
||||
id: containerIdForTags,
|
||||
type: SidebarBodyComponentId.ComponentInfo,
|
||||
}}
|
||||
>
|
||||
@@ -65,7 +61,7 @@ describe('<ContainerOrganize />', () => {
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
|
||||
});
|
||||
render({ libraryId });
|
||||
render(libraryId);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(`Mocked ${expected} ContentTagsDrawer`)).toBeInTheDocument();
|
||||
});
|
||||
@@ -77,12 +73,7 @@ describe('<ContainerOrganize />', () => {
|
||||
...getConfig(),
|
||||
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
|
||||
});
|
||||
render({ containerId: mockGetContainerMetadata.containerIdForTags });
|
||||
render();
|
||||
expect(await screen.findByText('Tags (6)')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render collection count in collection info section', async () => {
|
||||
render({ containerId: mockGetContainerMetadata.containerIdWithCollections });
|
||||
expect(await screen.findByText('Collections (1)')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useEffect } from 'react';
|
||||
import { useMemo } from 'react';
|
||||
import { useIntl } from '@edx/frontend-platform/i18n';
|
||||
import { getConfig } from '@edx/frontend-platform';
|
||||
import {
|
||||
@@ -8,27 +8,20 @@ import {
|
||||
useToggle,
|
||||
} from '@openedx/paragon';
|
||||
import {
|
||||
BookOpen,
|
||||
ExpandLess,
|
||||
ExpandMore,
|
||||
Tag,
|
||||
ExpandLess, ExpandMore, Tag,
|
||||
} from '@openedx/paragon/icons';
|
||||
|
||||
import { ContentTagsDrawer, useContentTaxonomyTagsData } from '../../content-tags-drawer';
|
||||
import { ManageCollections } from '../generic/manage-collections';
|
||||
import { useContainer, useUpdateContainerCollections } from '../data/apiHooks';
|
||||
import { useLibraryContext } from '../common/context/LibraryContext';
|
||||
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
|
||||
import { useSidebarContext } from '../common/context/SidebarContext';
|
||||
import messages from './messages';
|
||||
|
||||
const ContainerOrganize = () => {
|
||||
const intl = useIntl();
|
||||
const [tagsCollapseIsOpen, ,setTagsCollapseClose, toggleTags] = useToggle(true);
|
||||
const [collectionsCollapseIsOpen, setCollectionsCollapseOpen, , toggleCollections] = useToggle(true);
|
||||
const [tagsCollapseIsOpen, , , toggleTags] = useToggle(true);
|
||||
|
||||
const { readOnly } = useLibraryContext();
|
||||
const { sidebarComponentInfo, sidebarAction } = useSidebarContext();
|
||||
const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections;
|
||||
const { sidebarComponentInfo } = useSidebarContext();
|
||||
|
||||
const containerId = sidebarComponentInfo?.id;
|
||||
// istanbul ignore if: this should never happen
|
||||
@@ -36,17 +29,8 @@ const ContainerOrganize = () => {
|
||||
throw new Error('containerId is required');
|
||||
}
|
||||
|
||||
const { data: containerMetadata } = useContainer(containerId);
|
||||
const { data: componentTags } = useContentTaxonomyTagsData(containerId);
|
||||
|
||||
useEffect(() => {
|
||||
if (jumpToCollections) {
|
||||
setTagsCollapseClose();
|
||||
setCollectionsCollapseOpen();
|
||||
}
|
||||
}, [jumpToCollections, tagsCollapseIsOpen, collectionsCollapseIsOpen]);
|
||||
|
||||
const collectionsCount = useMemo(() => containerMetadata?.collections?.length || 0, [containerMetadata]);
|
||||
const tagsCount = useMemo(() => {
|
||||
if (!componentTags) {
|
||||
return 0;
|
||||
@@ -66,11 +50,6 @@ const ContainerOrganize = () => {
|
||||
return result;
|
||||
}, [componentTags]);
|
||||
|
||||
// istanbul ignore if: this should never happen
|
||||
if (!containerMetadata) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack gap={3}>
|
||||
{[true, 'true'].includes(getConfig().ENABLE_TAGGING_TAXONOMY_PAGES)
|
||||
@@ -103,33 +82,6 @@ const ContainerOrganize = () => {
|
||||
</Collapsible.Body>
|
||||
</Collapsible.Advanced>
|
||||
)}
|
||||
<Collapsible.Advanced
|
||||
open={collectionsCollapseIsOpen}
|
||||
className="collapsible-card border-0"
|
||||
>
|
||||
<Collapsible.Trigger
|
||||
onClick={toggleCollections}
|
||||
className="collapsible-trigger d-flex justify-content-between p-2"
|
||||
>
|
||||
<Stack gap={1} direction="horizontal">
|
||||
<Icon src={BookOpen} />
|
||||
{intl.formatMessage(messages.organizeTabCollectionsTitle, { count: collectionsCount })}
|
||||
</Stack>
|
||||
<Collapsible.Visible whenClosed>
|
||||
<Icon src={ExpandMore} />
|
||||
</Collapsible.Visible>
|
||||
<Collapsible.Visible whenOpen>
|
||||
<Icon src={ExpandLess} />
|
||||
</Collapsible.Visible>
|
||||
</Collapsible.Trigger>
|
||||
<Collapsible.Body className="collapsible-body">
|
||||
<ManageCollections
|
||||
opaqueKey={containerId}
|
||||
collections={containerMetadata.collections}
|
||||
useUpdateCollectionsHook={useUpdateContainerCollections}
|
||||
/>
|
||||
</Collapsible.Body>
|
||||
</Collapsible.Advanced>
|
||||
</Stack>
|
||||
);
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user