Compare commits

..

1 Commits

Author SHA1 Message Date
kshitij.sobti
1eb47c7431 feat: Add slot for whole application
Adds a slot wrapping the entire application.
2025-04-13 16:35:21 +05:30
125 changed files with 1174 additions and 2628 deletions

6
package-lock.json generated
View File

@@ -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",

View File

@@ -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",

View File

@@ -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',
};

View File

@@ -1,3 +1,2 @@
export { default as clipboardUnit } from './clipboardUnit';
export { default as clipboardSubsection } from './clipboardSubsection';
export { default as clipboardXBlock } from './clipboardXBlock';

View File

@@ -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);
/**

View File

@@ -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,

View File

@@ -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]);

View File

@@ -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';

View File

@@ -7,4 +7,3 @@
@import "./highlights-modal/HighlightsModal";
@import "./publish-modal/PublishModal";
@import "./xblock-status/XBlockStatus";
@import "./drag-helper/SortableItem";

View File

@@ -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(),

View File

@@ -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';

View File

@@ -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';

View File

@@ -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',
};

View File

@@ -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';

View File

@@ -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}>

View File

@@ -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

View File

@@ -195,7 +195,6 @@ const AddComponent = ({
>
<ComponentPicker
showOnlyPublished
extraFilter={['NOT block_type = "unit"']}
componentPickerMode={isAddLibraryContentModalOpen ? 'single' : 'multiple'}
onComponentSelected={handleLibraryV2Selection}
onChangeComponentSelection={setSelectedComponents}

View File

@@ -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">

View File

@@ -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;

View File

@@ -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';

View File

@@ -55,7 +55,6 @@ const AdvancedEditor = ({ usageKey, onClose }: AdvancedEditorProps) => {
usageKey={usageKey}
view="studio_view"
scrolling="yes"
minHeight="70vh"
/>
</IframeProvider>
</EditorModalWrapper>

View File

@@ -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';

View File

@@ -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 });

View File

@@ -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({

View File

@@ -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} />

View File

@@ -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' };

View File

@@ -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() {

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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;

View File

@@ -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.

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,
};

View File

@@ -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"

View File

@@ -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 = () => ({

View File

@@ -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}

View File

@@ -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>

View File

@@ -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,

View File

@@ -84,8 +84,6 @@ export const initialState = {
transcript: [],
loading: '',
},
filesCurrentView: 'list',
videosCurrentView: 'card',
},
models: {
videos: {

View File

@@ -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);

View File

@@ -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';

View File

@@ -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);

View File

@@ -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);
}

View File

@@ -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';

View File

@@ -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();
});
});

View File

@@ -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>
);
};

View File

@@ -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;

View File

@@ -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);
});
}
});
});

View File

@@ -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;
}

View File

@@ -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"

View File

@@ -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>
);
};

View File

@@ -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);

View File

@@ -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"',

View File

@@ -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

View File

@@ -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}>

View File

@@ -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>

View File

@@ -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,

View File

@@ -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.');
});
});

View File

@@ -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;

View File

@@ -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);

View File

@@ -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.');
});
});
});

View File

@@ -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>
);

View File

@@ -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';

View File

@@ -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',

View File

@@ -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}');

View File

@@ -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>
);
};

View File

@@ -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"]');
});
});

View File

@@ -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">

View File

@@ -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',

View File

@@ -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,

View File

@@ -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>

View File

@@ -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();

View File

@@ -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 });

View File

@@ -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>
);
};

View File

@@ -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>

View File

@@ -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

View File

@@ -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();
});
});

View File

@@ -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)}
/>

View File

@@ -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?

View File

@@ -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>

View File

@@ -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;

View File

@@ -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 }) => (

View File

@@ -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();

View File

@@ -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;

View File

@@ -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}

View File

@@ -1 +1 @@
export { ComponentMenu as default } from './ComponentMenu';
export { ComponentMenu as default } from './ComponentCard';

View File

@@ -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;

View File

@@ -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}');

View File

@@ -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>
);
};

View File

@@ -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();
});
});

View File

@@ -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