Compare commits

...

20 Commits

Author SHA1 Message Date
Muhammad Faraz Maqsood
f16ccfe9cf fix: use hyperlink instead of Link 2025-04-21 11:10:42 +05:00
edX requirements bot
febf5cf5d0 chore: update browserslist DB (#1839)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-04-21 00:20:48 +00:00
Raymond Zhou
ac127e2b15 Revert "fix: use navigate instead of Link from react-dom"
This reverts commit 06bdff1796.
2025-04-19 10:26:42 +05:00
Muhammad Faraz Maqsood
06bdff1796 fix: use navigate instead of Link from react-dom
getting TypeError: r is not a function. Replace Link with navigate.
2025-04-18 21:26:22 +05:00
Braden MacDonald
ea0a031d7b feat: button to publish a container [FC-0083] (#1827)
- Publish button with functionality of publish units and components inside the unit
2025-04-18 09:34:46 -05:00
Muhammad Faraz Maqsood
ea8a8e5285 fix: toggle behaviour for video & file view
- fix toggle behaviour for video and file view.
- Before:
  - The default view was card. And The videos and files both pages were sharing same variable & default view.
  - Whenever user selects list view on videos/files page and redirects to another page, the toggle/view shifts again to default(card) view whenever it returns to videos/files page.

- After:
  - The default view is list now. And The videos and files both pages can have different state & default view.
  - Whenever user selects card view on videos/files page and redirects to another page, the toggle/view remain same whatever user had selected before when it returns to videos/files page.

Note: Refreshing a page will use default(list) view.
2025-04-18 11:13:32 +05:00
Chris Chávez
9adfa58d65 feat: Remove component from unit [FC-0083] (#1824)
* Users can remove a component from a unit
* The component is NOT deleted, and remains present in the library
* A toast shows that the component was removed, and allows the user to undo
* Overflow menu item appears in sidebar for selected components in unit
* Overflow menu item appears directly on components in full page unit view
2025-04-17 17:51:42 -05:00
Navin Karkera
4ddb8c3168 feat: edit components in unit page [FC-0083] (#1821)
Allows authors to edit components from unit page. It makes sure that the component preview is updated on save, allows user to double click and open editor in modal etc.
2025-04-17 09:59:16 -05:00
Navin Karkera
3b2adc2fc1 feat: reorder components in unit page [FC-00083] (#1816)
Reorders components in unit page via drag and drop. This PR also refactors and moves draggable list and sortable item components to appropriate location.

Course authors will be affected by this change.
2025-04-16 14:34:28 -05:00
Régis Behmo
4bd2c3b29a feat: lighter build by rewriting lodash imports (#1772)
Incorrect lodash imports are causing MFEs to import the entire lodash
library. This change shaves off a few kB of the compressed build.
2025-04-15 17:07:16 -07:00
Braden MacDonald
f531d5471d fix: merge errors in previous commit (#1819) 2025-04-15 23:46:16 +00:00
Braden MacDonald
f24b89c847 feat: allow pasting units from a course into a library (#1812) 2025-04-15 15:26:19 -07:00
Rômulo Penido
d9dcdfe1e3 feat: add existing components to unit [FC-0083] (#1811)
allows adding existing components to units
2025-04-15 16:49:53 -05:00
Rômulo Penido
990073cb38 feat: renames unit in LibraryUnitPage and adds InplaceTextEditor component (#1810) 2025-04-15 15:42:36 -05:00
Jillian
afecd8ba83 fix: sort Advanced Blocks by default display name (#1817) 2025-04-15 15:07:21 -05:00
Rômulo Penido
aa8a5bfba4 feat: add collections support for containers [FC-0083] (#1797)
Adds support to add Units to Collections.
2025-04-15 13:13:12 -05:00
Navin Karkera
87695ae636 fix: auto adjust min height of xblock previews [FC-0083] (#1813)
Sets minimum height of library block previews based on its render
location and block type.
2025-04-15 10:37:59 -05:00
Braden MacDonald
681854209a fix: Copy to clipboard would seemingly fail even if it worked 2025-04-14 17:21:10 -07:00
Chris Chávez
a522c48045 feat: Add component to Unit [FC-0083] (#1784)
Creation workflow in unit page.
2025-04-14 22:36:46 +00:00
edX requirements bot
f46e4ce4e8 chore: update browserslist DB (#1814)
Co-authored-by: abdullahwaheed <42172960+abdullahwaheed@users.noreply.github.com>
2025-04-14 00:21:14 +00:00
122 changed files with 2624 additions and 1112 deletions

6
package-lock.json generated
View File

@@ -7860,9 +7860,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001712",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001712.tgz",
"integrity": "sha512-MBqPpGYYdQ7/hfKiet9SCI+nmN5/hp4ZzveOJubl5DTAMa5oggjAuoi0Z4onBpKPFI2ePGnQuQIzF3VxDjDJig==",
"version": "1.0.30001715",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001715.tgz",
"integrity": "sha512-7ptkFGMm2OAOgvZpwgA4yjQ5SQbrNVGdRjzH0pBdy1Fasvcr+KAeECmbCAECzTuDuoX0FCY8KzUxjf9+9kfZEw==",
"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-slot-footer": "^1.2.0",
"@openedx/frontend-plugin-framework": "^1.6.0",
"@openedx/frontend-slot-footer": "^1.2.0",
"@openedx/paragon": "^22.16.0",
"@redux-devtools/extension": "^3.3.0",
"@reduxjs/toolkit": "1.9.7",

View File

@@ -0,0 +1,16 @@
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,2 +1,3 @@
export { default as clipboardUnit } from './clipboardUnit';
export { default as clipboardSubsection } from './clipboardSubsection';
export { default as clipboardXBlock } from './clipboardXBlock';

View File

@@ -13,6 +13,7 @@ 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}"`);
}
@@ -204,6 +205,7 @@ 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 _ from 'lodash';
import sumBy from 'lodash/sumBy';
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,7 +14,9 @@ import {
useToggle,
} from '@openedx/paragon';
import _ from 'lodash';
import {
tail, keyBy, orderBy, merge, omitBy,
} from 'lodash';
import { useQueryClient } from '@tanstack/react-query';
import { Loop, Warning } from '@openedx/paragon/icons';
import messages from './messages';
@@ -49,7 +51,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;
@@ -138,11 +140,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();
@@ -241,9 +243,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 '../generic/drag-helper/DraggableList';
import DraggableList from './drag-helper/DraggableList';
import {
canMoveSection,
possibleUnitMoves,
possibleSubsectionMoves,
} from '../generic/drag-helper/utils';
} from './drag-helper/utils';
import { useCourseOutline } from './hooks';
import messages from './messages';
import { getTagsExportFile } from './data/api';

View File

@@ -7,3 +7,4 @@
@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 '../generic/drag-helper/utils';
} from './drag-helper/utils';
let axiosMock;
let store;
@@ -68,13 +68,6 @@ 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 '../../generic/drag-helper/SortableItem';
import { DragContext } from '../../generic/drag-helper/DragContextProvider';
import SortableItem from '../drag-helper/SortableItem';
import { DragContext } from '../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 '../../generic/drag-helper/SortableItem';
import { DragContext } from '../../generic/drag-helper/DragContextProvider';
import SortableItem from '../drag-helper/SortableItem';
import { DragContext } from '../drag-helper/DragContextProvider';
import { useClipboard, PasteComponent } from '../../generic/clipboard';
import TitleButton from '../card-header/TitleButton';
import XBlockStatus from '../xblock-status/XBlockStatus';

View File

@@ -21,13 +21,6 @@ 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 '../../generic/drag-helper/SortableItem';
import SortableItem from '../drag-helper/SortableItem';
import TitleLink from '../card-header/TitleLink';
import XBlockStatus from '../xblock-status/XBlockStatus';
import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils';

View File

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

View File

@@ -22,13 +22,6 @@ 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,7 +6,6 @@ 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 '..';
@@ -20,8 +19,6 @@ 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 '../editors/sharedComponents/DraggableList';
import DraggableList, { SortableItem } from '../generic/DraggableList';
import ErrorAlert from '../editors/sharedComponents/ErrorAlerts/ErrorAlert';
import { RequestStatus } from '../data/constants';

View File

@@ -55,6 +55,7 @@ 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/AddContentContainer';
import { parseErrorMsg } from '../../../library-authoring/add-content/AddContent';
import libraryMessages from '../../../library-authoring/add-content/messages';
import './index.scss';

View File

@@ -1,6 +1,8 @@
import { useState, useEffect } from 'react';
import _ from 'lodash';
import {
includes, isEmpty, isFinite, isNaN, isNil,
} 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.
@@ -65,7 +67,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: '' };
@@ -114,9 +116,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);
@@ -135,9 +137,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('');
@@ -154,7 +156,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;
@@ -164,7 +166,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 } });
@@ -187,18 +189,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 } });
@@ -214,7 +216,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 _ from 'lodash';
import isEmpty from 'lodash/isEmpty';
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 _ from 'lodash';
import isNil from 'lodash/isNil';
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,7 +2,9 @@
/* eslint no-eval: 0 */
import { XMLParser, XMLBuilder } from 'fast-xml-parser';
import _ from 'lodash';
import {
get, has, keys, isArray, isEmpty,
} from 'lodash';
import {
ProblemTypeKeys,
RichTextProblems,
@@ -92,7 +94,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;
}
@@ -112,7 +114,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;
}
}
@@ -188,17 +190,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],
@@ -206,7 +208,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`));
@@ -265,11 +267,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';
}
@@ -287,9 +289,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({
@@ -338,12 +340,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({
@@ -364,8 +366,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({
@@ -387,9 +389,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'),
},
};
@@ -416,16 +418,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,
};
@@ -441,8 +443,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({
@@ -475,7 +477,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 => {
@@ -548,7 +550,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);
@@ -578,21 +580,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);
}
@@ -610,7 +612,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;
}
@@ -636,7 +638,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;
}
@@ -671,7 +673,7 @@ export class OLXParser {
}
getParsedOLXData() {
if (_.isEmpty(this.problem)) {
if (isEmpty(this.problem)) {
return {};
}
@@ -720,16 +722,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 _ from 'lodash';
import { get, has } 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 _ from 'lodash';
import { includes } 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,15 +1,17 @@
import _ from 'lodash';
import {
get, isEmpty, isFinite, isNil,
} 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;
@@ -19,18 +21,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;
}
@@ -48,8 +50,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 };
}
@@ -61,22 +63,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 _ from 'lodash';
import { has } 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 _ from 'lodash';
import { get, isEmpty } 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 _, { isEmpty } from 'lodash';
import { has, find, 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,6 @@
import React from 'react';
import PropTypes from 'prop-types';
import _ from 'lodash';
import { isEmpty } 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,6 +40,27 @@ 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,4 +1,5 @@
import React, { useCallback, useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import PropTypes from 'prop-types';
import isEmpty from 'lodash/isEmpty';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
@@ -26,6 +27,10 @@ import {
} from './table-components';
import ApiStatusToast from './ApiStatusToast';
import DeleteConfirmationModal from './DeleteConfirmationModal';
import {
setFilesCurrentViewState,
setVideosCurrentViewState,
} from '../videos-page/data/slice';
const FileTable = ({
files,
@@ -44,7 +49,7 @@ const FileTable = ({
// injected
intl,
}) => {
const defaultVal = 'card';
const dispatch = useDispatch();
const pageCount = Math.ceil(files.length / 50);
const columnSizes = {
xs: 12,
@@ -53,7 +58,10 @@ const FileTable = ({
lg: 3,
xl: 2,
};
const [currentView, setCurrentView] = useState(defaultVal);
const {
filesCurrentView,
videosCurrentView,
} = useSelector((state) => state.videos);
const [isDeleteOpen, setDeleteOpen, setDeleteClose] = useToggle(false);
const [isDownloadOpen, setDownloadOpen, setDownloadClose] = useToggle(false);
const [isAssetInfoOpen, openAssetInfo, closeAssetinfo] = useToggle(false);
@@ -198,8 +206,15 @@ const FileTable = ({
defaultColumnValues={{ Filter: TextFilter }}
dataViewToggleOptions={{
isDataViewToggleEnabled: true,
onDataViewToggle: val => setCurrentView(val),
defaultActiveStateValue: defaultVal,
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),
togglePlacement: 'left',
}}
initialState={initialState}
@@ -227,8 +242,8 @@ const FileTable = ({
<div data-testid="files-data-table" className="bg-light-200">
<DataTable.TableControlBar />
<hr className="mb-5 border-light-700" />
{ currentView === 'card' && <CardView CardComponent={fileCard} columnSizes={columnSizes} selectionPlacement="left" skeletonCardCount={6} /> }
{ currentView === 'list' && <DataTable.Table /> }
{ ((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 /> }
<DataTable.EmptyTable content={intl.formatMessage(messages.noResultsFoundMessage)} />
<Footer />
</div>
@@ -243,7 +258,7 @@ const FileTable = ({
fileType={fileType}
/>
{fileType === 'files' && (
{fileType === 'file' && (
<ApiStatusToast
actionType={intl.formatMessage(messages.apiStatusAddingAction)}
selectedRowCount={selectedRows.length}

View File

@@ -1,5 +1,5 @@
import React, { useContext, useEffect } from 'react';
import _ from 'lodash';
import { isEmpty } 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,11 +23,19 @@ 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;
},
@@ -98,6 +106,8 @@ const slice = createSlice({
export const {
setVideoIds,
setVideosCurrentViewState,
setFilesCurrentViewState,
setPageSettings,
updateLoadingStatus,
deleteVideoSuccess,

View File

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

View File

@@ -1,5 +1,6 @@
import React from 'react';
import React, { useCallback } from 'react';
import PropTypes from 'prop-types';
import { createPortal } from 'react-dom';
import {
DndContext,
@@ -8,6 +9,7 @@ import {
PointerSensor,
useSensor,
useSensors,
DragOverlay,
} from '@dnd-kit/core';
import {
arrayMove,
@@ -22,6 +24,9 @@ const DraggableList = ({
setState,
updateOrder,
children,
renderOverlay,
activeId,
setActiveId,
}) => {
const sensors = useSensors(
useSensor(PointerSensor),
@@ -30,7 +35,7 @@ const DraggableList = ({
}),
);
const handleDragEnd = (event) => {
const handleDragEnd = useCallback((event) => {
const { active, over } = event;
if (active.id !== over.id) {
let updatedArray;
@@ -44,13 +49,19 @@ 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
@@ -59,10 +70,22 @@ 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,
@@ -70,6 +93,9 @@ 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,6 +17,7 @@ const SortableItem = ({
children,
isClickable,
onClick,
disabled,
// injected
intl,
}) => {
@@ -31,6 +32,9 @@ const SortableItem = ({
} = useSortable({
id,
animateLayoutChanges: () => false,
disabled: {
draggable: disabled,
},
});
const style = {
@@ -52,6 +56,7 @@ const SortableItem = ({
>
<ActionRow style={actionStyle}>
{actions}
{!disabled && (
<IconButtonWithTooltip
key="drag-to-reorder-icon"
ref={setActivatorNodeRef}
@@ -64,6 +69,7 @@ const SortableItem = ({
{...attributes}
{...listeners}
/>
)}
</ActionRow>
{children}
</Card>
@@ -76,6 +82,7 @@ SortableItem.defaultProps = {
actionStyle: null,
isClickable: false,
onClick: null,
disabled: false,
};
SortableItem.propTypes = {
id: PropTypes.string.isRequired,
@@ -85,6 +92,7 @@ SortableItem.propTypes = {
componentStyle: PropTypes.shape({}),
isClickable: PropTypes.bool,
onClick: PropTypes.func,
disabled: PropTypes.bool,
// injected
intl: intlShape.isRequired,
};

View File

@@ -2,12 +2,13 @@ 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 from './useClipboard';
import useClipboard, { _testingOverrideBroadcastChannel } from './useClipboard';
initializeMocks();
@@ -16,13 +17,14 @@ 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';
const clipboardBroadcastChannelMock = {
postMessage: jest.fn(),
close: jest.fn(),
onmessage: jest.fn(),
};
(global as any).BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);
let broadcastMockListener: (x: unknown) => void | undefined;
const clipboardBroadcastChannelMock = {
postMessage: (message: unknown) => { broadcastMockListener(message); },
addEventListener: (_eventName: string, handler: typeof broadcastMockListener) => { broadcastMockListener = handler; },
removeEventListener: jest.fn(),
};
_testingOverrideBroadcastChannel(clipboardBroadcastChannelMock as any);
describe('useClipboard', () => {
beforeEach(async () => {
@@ -41,7 +43,7 @@ describe('useClipboard', () => {
axiosMock
.onPost(getClipboardUrl())
.reply(200, clipboardUnit);
.reply(200, clipboardSubsection);
await result.current.copyToClipboard(unitId);
@@ -88,14 +90,15 @@ 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() });
clipboardBroadcastChannelMock.onmessage({ data: clipboardUnit });
// Subsections cannot be pasted:
clipboardBroadcastChannelMock.postMessage({ data: clipboardUnit });
rerender();
expect(result.current.showPasteUnit).toBe(true);
expect(result.current.showPasteXBlock).toBe(false);
clipboardBroadcastChannelMock.onmessage({ data: clipboardXBlock });
clipboardBroadcastChannelMock.postMessage({ 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 { useContext, useEffect, useState } from 'react';
import { useCallback, useContext, useEffect } from 'react';
import { getClipboard, updateClipboard } from '../../data/api';
import {
@@ -11,6 +11,14 @@ 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.
*
@@ -23,7 +31,6 @@ import messages from './messages';
*/
const useClipboard = (canEdit: boolean = true) => {
const intl = useIntl();
const [clipboardBroadcastChannel] = useState(() => new BroadcastChannel(STUDIO_CLIPBOARD_CHANNEL));
const { data: clipboardData } = useQuery({
queryKey: ['clipboard'],
queryFn: getClipboard,
@@ -33,37 +40,48 @@ const useClipboard = (canEdit: boolean = true) => {
const queryClient = useQueryClient();
const copyToClipboard = async (usageKey: string) => {
const copyToClipboard = useCallback(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 {
const newData = await updateClipboard(usageKey);
clipboardBroadcastChannel.postMessage(newData);
newData = await updateClipboard(usageKey);
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.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);
};
clipboardBroadcastChannel?.addEventListener('message', handleBroadcastMessage);
// Cleanup function for the BroadcastChannel when the hook is unmounted
return () => {
clipboardBroadcastChannel.close();
clipboardBroadcastChannel?.removeEventListener('message', handleBroadcastMessage);
};
}, [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 _ from 'lodash';
import last from 'lodash/last';
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,12 +38,3 @@ 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,7 +3,6 @@ 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';
@@ -18,8 +17,6 @@ jest.mock('@edx/frontend-platform/logging', () => ({
logError: jest.fn(),
}));
mockBroadcastChannel();
describe('useIframeBehavior', () => {
const id = 'test-id';
const iframeUrl = 'http://example.com';

View File

@@ -0,0 +1,65 @@
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

@@ -0,0 +1,91 @@
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

@@ -0,0 +1,11 @@
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,6 +4,8 @@ import {
getLibraryId,
isLibraryKey,
isLibraryV1Key,
getContainerTypeFromId,
ContainerType,
} from './key-utils';
describe('component utils', () => {
@@ -97,4 +99,16 @@ 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,3 +49,26 @@ 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,6 +11,5 @@
@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

@@ -66,8 +66,14 @@ 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 />} />
<Route path="/component-picker/multiple" element={<ComponentPicker componentPickerMode="multiple" />} />
<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="/legacy/preview-changes/:usageKey" element={<PreviewChangesEmbed />} />
<Route path="/course/:courseId/*" element={<CourseAuthoringRoutes />} />
<Route path="/course_rerun/:courseId" element={<CourseRerun />} />

View File

@@ -21,7 +21,6 @@ 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';
@@ -34,7 +33,6 @@ mockContentSearchConfig.applyMock();
mockContentLibrary.applyMock();
mockGetLibraryTeam.applyMock();
mockXBlockFields.applyMock();
mockBroadcastChannel();
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
@@ -394,7 +392,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', async () => {
it('should open component sidebar, showing manage tab on clicking add to collection menu item (component)', async () => {
const mockResult0 = { ...mockResult }.results[0].hits[0];
const displayName = 'Introduction to Testing';
expect(mockResult0.display_name).toStrictEqual(displayName);
@@ -419,6 +417,29 @@ 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();
@@ -734,7 +755,7 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(cancelButton);
expect(unitModalHeading).not.toBeInTheDocument();
// Open new unit modal again and create a collection
// Open new unit modal again and create a unit
fireEvent.click(newUnitButton);
const createButton = screen.getByRole('button', { name: /create/i });
const nameField = screen.getByRole('textbox', { name: /name your unit/i });
@@ -802,7 +823,7 @@ describe('<LibraryAuthoringPage />', () => {
fireEvent.click(newButton);
expect(screen.getByText(/add content/i)).toBeInTheDocument();
// Open New collection Modal
// Open New Unit Modal
const sidebar = screen.getByTestId('library-sidebar');
const newUnitButton = within(sidebar).getAllByRole('button', { name: /unit/i })[0];
fireEvent.click(newUnitButton);

View File

@@ -1,4 +1,9 @@
import { useCallback, useEffect, useState } from 'react';
import {
type ReactNode,
useCallback,
useEffect,
useState,
} from 'react';
import { Helmet } from 'react-helmet';
import classNames from 'classnames';
import { StudioFooterSlot } from '@openedx/frontend-slot-footer';
@@ -100,7 +105,7 @@ const HeaderActions = () => {
);
};
export const SubHeaderTitle = ({ title }: { title: string }) => {
export const SubHeaderTitle = ({ title }: { title: ReactNode }) => {
const intl = useIntl();
const { readOnly } = useLibraryContext();
@@ -141,6 +146,7 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
libraryData,
isLoadingLibraryData,
showOnlyPublished,
extraFilter: contextExtraFilter,
componentId,
collectionId,
unitId,
@@ -223,6 +229,10 @@ 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,6 +15,7 @@ interface LibraryBlockProps {
version?: VersionSpec;
view?: string;
scrolling?: string;
minHeight?: string;
}
/**
* React component that displays an XBlock in a sandboxed IFrame.
@@ -30,6 +31,7 @@ export const LibraryBlock = ({
usageKey,
version,
view,
minHeight,
scrolling = 'no',
}: LibraryBlockProps) => {
const { iframeRef, setIframeRef } = useIframe();
@@ -61,7 +63,7 @@ export const LibraryBlock = ({
loading="lazy"
referrerPolicy="origin"
style={{
width: '100%', height: iframeHeight, pointerEvents: 'auto', minHeight: '700px',
width: '100%', height: iframeHeight, pointerEvents: 'auto', minHeight,
}}
allow={IFRAME_FEATURE_POLICY}
allowFullScreen

View File

@@ -47,13 +47,6 @@ 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 > AddContentContainer > ComponentPicker */
* Sidebar > AddContent > ComponentPicker */
componentPicker={ComponentPicker}
>
<SidebarProvider>

View File

@@ -218,8 +218,45 @@
"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,18 +12,21 @@ import {
mockXBlockFields,
} from '../data/api.mocks';
import {
getContentLibraryApiUrl, getCreateLibraryBlockUrl, getLibraryCollectionComponentApiUrl, getLibraryPasteClipboardUrl,
getContentLibraryApiUrl,
getCreateLibraryBlockUrl,
getLibraryCollectionItemsApiUrl,
getLibraryContainerChildrenApiUrl,
getLibraryPasteClipboardUrl,
getXBlockFieldsApiUrl,
} from '../data/api';
import { mockBroadcastChannel, mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
import { mockClipboardEmpty, mockClipboardHtml } from '../../generic/data/api.mock';
import { LibraryProvider } from '../common/context/LibraryContext';
import AddContentContainer from './AddContentContainer';
import AddContent from './AddContent';
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.
@@ -32,7 +35,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(<AddContentContainer />, {
return baseRender(<AddContent />, {
path: '/library/:libraryId/:collectionId?',
params,
extraWrapper: ({ children }) => (
@@ -45,10 +48,25 @@ 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('<AddContentContainer />', () => {
describe('<AddContent />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
@@ -137,7 +155,7 @@ describe('<AddContentContainer />', () => {
const url = getCreateLibraryBlockUrl(libraryId);
const usageKey = mockXBlockFields.usageKeyNewHtml;
const updateBlockUrl = getXBlockFieldsApiUrl(usageKey);
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
const collectionComponentUrl = getLibraryCollectionItemsApiUrl(
libraryId,
collectionId,
);
@@ -189,13 +207,30 @@ describe('<AddContentContainer />', () => {
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 = getLibraryCollectionComponentApiUrl(
const collectionComponentUrl = getLibraryCollectionItemsApiUrl(
libraryId,
collectionId,
);
@@ -220,7 +255,7 @@ describe('<AddContentContainer />', () => {
const pasteUrl = getLibraryPasteClipboardUrl(libraryId);
const collectionId = 'some-collection-id';
const collectionComponentUrl = getLibraryCollectionComponentApiUrl(
const collectionComponentUrl = getLibraryCollectionItemsApiUrl(
libraryId,
collectionId,
);
@@ -290,4 +325,71 @@ describe('<AddContentContainer />', () => {
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,8 +21,9 @@ 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';
@@ -30,6 +31,7 @@ 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,
@@ -87,7 +89,12 @@ const AddContentView = ({
const {
collectionId,
componentPicker,
unitId,
} = useLibraryContext();
let upstreamContainerType: ContainerType | undefined;
if (unitId) {
upstreamContainerType = getContainerTypeFromId(unitId);
}
const collectionButtonData = {
name: intl.formatMessage(messages.collectionButton),
@@ -109,20 +116,24 @@ const AddContentView = ({
return (
<>
{collectionId ? (
componentPicker && (
<>
<AddContentButton contentType={libraryContentButtonData} onCreateContent={onCreateContent} />
<PickLibraryContentModal
isOpen={isAddLibraryContentModalOpen}
onClose={closeAddLibraryContentModal}
/>
</>
)
) : (
{(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
<AddContentButton contentType={collectionButtonData} onCreateContent={onCreateContent} />
)}
<AddContentButton contentType={unitButtonData} onCreateContent={onCreateContent} />
{upstreamContainerType !== ContainerType.Unit && (
// Doesn't show the "Unit" button if we are in a unit
<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) => (
@@ -143,6 +154,10 @@ 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">
@@ -150,7 +165,7 @@ const AddAdvancedContentView = ({
{intl.formatMessage(messages.backToAddContentListButton)}
</Button>
</div>
{Object.keys(advancedBlocks).map((blockType) => (
{sortedBlockTypes.map((blockType) => (
isBlockTypeEnabled(blockType) ? (
<AddContentButton
key={`add-content-${blockType}`}
@@ -176,7 +191,15 @@ export const parseErrorMsg = (
) => {
try {
const { response: { data } } = error;
const detail = data && (Array.isArray(data) ? data.join() : String(data));
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);
}
if (detail) {
return intl.formatMessage(detailedMessage, { detail });
}
@@ -186,7 +209,7 @@ export const parseErrorMsg = (
return intl.formatMessage(defaultMessage);
};
const AddContentContainer = () => {
const AddContent = () => {
const intl = useIntl();
const {
libraryId,
@@ -194,13 +217,15 @@ const AddContentContainer = () => {
openCreateCollectionModal,
openCreateUnitModal,
openComponentEditor,
unitId,
} = useLibraryContext();
const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId);
const addComponentsToCollectionMutation = useAddItemsToCollection(libraryId, collectionId);
const addComponentsToContainerMutation = useAddComponentsToContainer(unitId);
const createBlockMutation = useCreateLibraryBlock();
const pasteClipboardMutation = useLibraryPasteClipboard();
const { showToast } = useContext(ToastContext);
const canEdit = useSelector(getCanEdit);
const { showPasteXBlock, sharedClipboardData } = useClipboard(canEdit);
const { showPasteUnit, showPasteXBlock, sharedClipboardData } = useClipboard(canEdit);
const [isAddLibraryContentModalOpen, showAddLibraryContentModal, closeAddLibraryContentModal] = useToggle();
const [isAdvancedListOpen, showAdvancedList, closeAdvancedList] = useToggle();
@@ -264,7 +289,7 @@ const AddContentContainer = () => {
// Include the 'Paste from Clipboard' button if there is an Xblock in the clipboard
// that can be pasted
if (showPasteXBlock) {
if (showPasteXBlock || showPasteUnit) {
const pasteButton = {
name: intl.formatMessage(messages.pasteButton),
disabled: false,
@@ -273,10 +298,17 @@ const AddContentContainer = () => {
contentTypes.push(pasteButton);
}
const linkComponent = (usageKey: string) => {
updateComponentsMutation.mutateAsync([usageKey]).catch(() => {
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
});
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 onPaste = () => {
@@ -293,7 +325,6 @@ const AddContentContainer = () => {
}
pasteClipboardMutation.mutateAsync({
libraryId,
blockId: `${uuid4()}`,
}).then((data) => {
linkComponent(data.id);
showToast(intl.formatMessage(messages.successPasteClipboardMessage));
@@ -374,4 +405,4 @@ const AddContentContainer = () => {
);
};
export default AddContentContainer;
export default AddContent;

View File

@@ -17,7 +17,7 @@ import {
mockCreateLibraryBlock,
mockXBlockFields,
} from '../data/api.mocks';
import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock';
import { 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,7 +25,6 @@ import LibraryLayout from '../LibraryLayout';
mockContentSearchConfig.applyMock();
mockClipboardEmpty.applyMock();
mockBroadcastChannel();
mockContentLibrary.applyMock();
mockCreateLibraryBlock.applyMock();
mockSearchResult(mockResult);

View File

@@ -28,9 +28,21 @@ const { libraryId } = mockContentLibrary;
const onClose = jest.fn();
let mockShowToast: (message: string) => void;
const render = () => baseRender(<PickLibraryContentModal isOpen onClose={onClose} />, {
path: '/library/:libraryId/collection/:collectionId/*',
params: { libraryId, collectionId: 'collectionId' },
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 }),
},
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
@@ -46,62 +58,80 @@ describe('<PickLibraryContentModal />', () => {
const mocks = initializeMocks();
mockShowToast = mocks.mockShowToast;
mocks.axiosMock.onGet(getStudioHomeApiUrl()).reply(200, studioHomeMock);
jest.clearAllMocks();
});
it('can pick components from the modal', async () => {
const mockAddComponentsToCollection = jest.fn();
jest.spyOn(api, 'addComponentsToCollection').mockImplementation(mockAddComponentsToCollection);
['collection' as const, 'unit' as const].forEach((context) => {
it(`can pick components from the modal (${context})`, async () => {
render(context);
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(() => {
expect(mockAddComponentsToCollection).toHaveBeenCalledWith(
libraryId,
'collectionId',
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
);
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'],
);
}
});
expect(onClose).toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith('Content linked successfully.');
});
});
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();
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);
// 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.queryAllByRole('button', { name: 'Add to Collection' })[0]);
fireEvent.click(screen.getByRole('button', { name: /add to .*/i }));
await waitFor(() => {
expect(mockAddComponentsToCollection).toHaveBeenCalledWith(
libraryId,
'collectionId',
['lb:Axim:TEST:html:571fe018-f3ce-45c9-8f53-5dafcb422fdd'],
);
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'],
);
}
});
expect(onClose).toHaveBeenCalled();
expect(mockShowToast).toHaveBeenCalledWith('There was an error linking the content to this collection.');
const name = context === 'collection' ? 'collection' : 'container';
expect(mockShowToast).toHaveBeenCalledWith(`There was an error linking the content to this ${name}.`);
});
});
});

View File

@@ -5,23 +5,25 @@ 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 { useAddComponentsToCollection } from '../data/apiHooks';
import { useAddItemsToCollection, useAddComponentsToContainer } 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}>
<FormattedMessage {...messages.addToCollectionButton} />
{buttonText}
</Button>
</ActionRow>
);
@@ -29,29 +31,33 @@ 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 > AddContentContainer > ComponentPicker */
* Sidebar > AddContent > ComponentPicker */
componentPicker: ComponentPicker,
} = useLibraryContext();
// istanbul ignore if: this should never happen
if (!collectionId || !ComponentPicker) {
throw new Error('libraryId and componentPicker are required');
if (!(collectionId || unitId) || !ComponentPicker) {
throw new Error('collectionId/unitId and componentPicker are required');
}
const updateComponentsMutation = useAddComponentsToCollection(libraryId, collectionId);
const updateCollectionItemsMutation = useAddItemsToCollection(libraryId, collectionId);
const updateUnitComponentsMutation = useAddComponentsToContainer(unitId);
const { showToast } = useContext(ToastContext);
@@ -60,13 +66,24 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
const onSubmit = useCallback(() => {
const usageKeys = selectedComponents.map(({ usageKey }) => usageKey);
onClose();
updateComponentsMutation.mutateAsync(usageKeys)
.then(() => {
showToast(intl.formatMessage(messages.successAssociateComponentMessage));
})
.catch(() => {
showToast(intl.formatMessage(messages.errorAssociateComponentMessage));
});
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));
});
}
}, [selectedComponents]);
return (
@@ -76,12 +93,22 @@ export const PickLibraryContentModal: React.FC<PickLibraryContentModalProps> = (
size="xl"
isOpen={isOpen}
onClose={onClose}
footerNode={<PickLibraryContentModalFooter onSubmit={onSubmit} selectedComponents={selectedComponents} />}
footerNode={(
<PickLibraryContentModalFooter
onSubmit={onSubmit}
selectedComponents={selectedComponents}
buttonText={(collectionId
? intl.formatMessage(messages.addToCollectionButton)
: intl.formatMessage(messages.addToUnitButton)
)}
/>
)}
>
<ComponentPicker
libraryId={libraryId}
componentPickerMode="multiple"
onChangeComponentSelection={setSelectedComponents}
extraFilter={extraFilter}
/>
</StandardModal>
);

View File

@@ -1,2 +1,3 @@
export { default as AddContentContainer } from './AddContentContainer';
export { default as AddContent } from './AddContent';
export { default as AddContentHeader } from './AddContentHeader';
export { PickLibraryContentModal } from './PickLibraryContentModal';

View File

@@ -21,6 +21,11 @@ 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}}',
@@ -84,11 +89,16 @@ const messages = defineMessages({
defaultMessage: 'Content linked successfully.',
description: 'Message when linking of content to a collection in library is success',
},
errorAssociateComponentMessage: {
errorAssociateComponentToCollectionMessage: {
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 collection title/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /edit/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 collection title/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /edit/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 collection title/i }));
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const textBox = screen.getByRole('textbox', { name: /title input/i });
const textBox = screen.getByRole('textbox', { name: /text 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 collection title/i }));
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const textBox = screen.getByRole('textbox', { name: /title input/i });
const textBox = screen.getByRole('textbox', { name: /text 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 collection title/i }));
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const textBox = screen.getByRole('textbox', { name: /title input/i });
const textBox = screen.getByRole('textbox', { name: /text 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 collection title/i }));
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const textBox = screen.getByRole('textbox', { name: /title input/i });
const textBox = screen.getByRole('textbox', { name: /text 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 collection title/i }));
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const textBox = screen.getByRole('textbox', { name: /title input/i });
const textBox = screen.getByRole('textbox', { name: /text input/i });
userEvent.clear(textBox);
userEvent.type(textBox, 'New Collection Title{enter}');

View File

@@ -1,13 +1,7 @@
import React, { useState, useContext, useCallback } from 'react';
import { useContext } 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';
@@ -16,12 +10,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');
@@ -32,74 +26,28 @@ const CollectionInfoHeader = () => {
const updateMutation = useUpdateCollection(libraryId, collectionId);
const { showToast } = useContext(ToastContext);
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],
);
const handleSaveTitle = (newTitle: string) => {
updateMutation.mutateAsync({
title: newTitle,
}).then(() => {
showToast(intl.formatMessage(messages.updateCollectionSuccessMsg));
}).catch(() => {
showToast(intl.formatMessage(messages.updateCollectionErrorMsg));
});
};
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 (
<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>
<InplaceTextEditor
onSave={handleSaveTitle}
text={collection.title}
readOnly={readOnly}
textClassName="font-weight-bold m-1.5"
showEditButton
/>
);
};

View File

@@ -15,12 +15,16 @@ import {
mockContentLibrary,
mockXBlockFields,
mockGetCollectionMetadata,
mockGetContainerMetadata,
} from '../data/api.mocks';
import { mockContentSearchConfig, mockGetBlockTypes } from '../../search-manager/data/api.mock';
import { mockBroadcastChannel, mockClipboardEmpty } from '../../generic/data/api.mock';
import { mockClipboardEmpty } from '../../generic/data/api.mock';
import { LibraryLayout } from '..';
import { ContentTagsDrawer } from '../../content-tags-drawer';
import { getLibraryCollectionComponentApiUrl } from '../data/api';
import {
getLibraryCollectionItemsApiUrl,
getLibraryContainersApiUrl,
} from '../data/api';
let axiosMock: MockAdapter;
let mockShowToast;
@@ -31,7 +35,7 @@ mockContentSearchConfig.applyMock();
mockGetBlockTypes.applyMock();
mockContentLibrary.applyMock();
mockXBlockFields.applyMock();
mockBroadcastChannel();
mockGetContainerMetadata.applyMock();
const searchEndpoint = 'http://mock.meilisearch.local/multi-search';
const path = '/library/:libraryId/*';
@@ -351,7 +355,7 @@ describe('<LibraryCollectionPage />', () => {
});
it('should remove component from collection and hides sidebar', async () => {
const url = getLibraryCollectionComponentApiUrl(
const url = getLibraryCollectionItemsApiUrl(
mockContentLibrary.libraryId,
mockCollection.collectionId,
);
@@ -370,9 +374,110 @@ 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,7 +109,9 @@ const LibraryCollectionPage = () => {
}
const { componentPickerMode } = useComponentPickerContext();
const { showOnlyPublished, setCollectionId, componentId } = useLibraryContext();
const {
showOnlyPublished, extraFilter: contextExtraFilter, setCollectionId, componentId,
} = useLibraryContext();
const { sidebarComponentInfo, openInfoSidebar } = useSidebarContext();
const {
@@ -182,6 +184,10 @@ 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,11 +111,6 @@ 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,6 +34,8 @@ 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;
@@ -66,13 +68,14 @@ 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 > AddContentContainer > ComponentPicker */
* Sidebar > AddContent > ComponentPicker */
componentPicker?: typeof ComponentPicker;
};
@@ -83,6 +86,7 @@ export const LibraryProvider = ({
children,
libraryId,
showOnlyPublished = false,
extraFilter = [],
skipUrlUpdate = false,
componentPicker,
}: LibraryProviderProps) => {
@@ -139,6 +143,7 @@ export const LibraryProvider = ({
readOnly,
isLoadingLibraryData,
showOnlyPublished,
extraFilter,
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,
@@ -164,6 +169,7 @@ export const LibraryProvider = ({
readOnly,
isLoadingLibraryData,
showOnlyPublished,
extraFilter,
isCreateCollectionModalOpen,
openCreateCollectionModal,
closeCreateCollectionModal,

View File

@@ -29,14 +29,22 @@ 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} />
<LibraryBlock
usageKey={usageKey}
version={oldVersion}
minHeight="50vh"
/>
</IframeProvider>
</div>
</Tab>
<Tab eventKey="new" title={intl.formatMessage(messages.newVersionTitle)}>
<div className="p-2 bg-white">
<IframeProvider>
<LibraryBlock usageKey={usageKey} version={newVersion} />
<LibraryBlock
usageKey={usageKey}
version={newVersion}
minHeight="50vh"
/>
</IframeProvider>
</div>
</Tab>

View File

@@ -10,14 +10,12 @@ 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 component name/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /edit/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 component name/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /edit/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 component name/i }));
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const textBox = screen.getByRole('textbox', { name: /display name input/i });
const textBox = screen.getByRole('textbox', { name: /text 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 component name/i }));
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const textBox = screen.getByRole('textbox', { name: /display name input/i });
const textBox = screen.getByRole('textbox', { name: /text 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 component name/i }));
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const textBox = screen.getByRole('textbox', { name: /display name input/i });
const textBox = screen.getByRole('textbox', { name: /text input/i });
fireEvent.change(textBox, { target: { value: 'New component name' } });
fireEvent.keyDown(textBox, { key: 'Enter', code: 'Enter', charCode: 13 });

View File

@@ -1,13 +1,7 @@
import React, { useState, useContext, useCallback } from 'react';
import { useContext } 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';
@@ -16,7 +10,6 @@ import messages from './messages';
const ComponentInfoHeader = () => {
const intl = useIntl();
const [inputIsActive, setIsActive] = useState(false);
const { readOnly, showOnlyPublished } = useLibraryContext();
const { sidebarComponentInfo } = useSidebarContext();
@@ -33,69 +26,30 @@ const ComponentInfoHeader = () => {
const updateMutation = useUpdateXBlockFields(usageKey);
const { showToast } = useContext(ToastContext);
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);
const handleSaveDisplayName = (newDisplayName: string) => {
updateMutation.mutateAsync({
metadata: {
display_name: newDisplayName,
},
}).then(() => {
showToast(intl.formatMessage(messages.updateComponentSuccessMsg));
}).catch(() => {
showToast(intl.formatMessage(messages.updateComponentErrorMsg));
});
};
const hanldeOnKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
handleSaveDisplayName(event);
} else if (event.key === 'Escape') {
setIsActive(false);
}
};
if (!xblockFields) {
return null;
}
return (
<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>
<InplaceTextEditor
onSave={handleSaveDisplayName}
text={xblockFields?.displayName}
readOnly={readOnly}
textClassName="font-weight-bold m-1.5"
showEditButton
/>
);
};

View File

@@ -8,11 +8,11 @@ import {
import { useLibraryContext } from '../common/context/LibraryContext';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
import { useLibraryBlockMetadata } from '../data/apiHooks';
import StatusWidget from '../generic/status-widget';
import messages from './messages';
import { ContentTagsDrawer, useContentTaxonomyTagsData } from '../../content-tags-drawer';
import ManageCollections from './ManageCollections';
import { useLibraryBlockMetadata, useUpdateComponentCollections } from '../data/apiHooks';
import StatusWidget from '../generic/status-widget';
import { ManageCollections } from '../generic/manage-collections';
import messages from './messages';
const ComponentManagement = () => {
const intl = useIntl();
@@ -130,7 +130,11 @@ const ComponentManagement = () => {
</Collapsible.Visible>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body">
<ManageCollections usageKey={usageKey} collections={componentMetadata.collections} />
<ManageCollections
opaqueKey={usageKey}
collections={componentMetadata.collections}
useUpdateCollectionsHook={useUpdateComponentCollections}
/>
</Collapsible.Body>
</Collapsible.Advanced>
</Stack>

View File

@@ -31,6 +31,7 @@ const ModalComponentPreview = ({ isOpen, close, usageKey }: ModalComponentPrevie
<LibraryBlock
usageKey={usageKey}
version={showOnlyPublished ? 'published' : undefined}
minHeight="60vh"
/>
</StandardModal>
);
@@ -71,6 +72,7 @@ const ComponentPreview = () => {
usageKey={usageKey}
key={componentMetadata.modified}
version={showOnlyPublished ? 'published' : undefined}
minHeight="60vh"
/>
)
: null

View File

@@ -61,11 +61,6 @@ 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.',
@@ -136,51 +131,6 @@ 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 } & (
type ComponentPickerProps = { libraryId?: string, showOnlyPublished?: boolean, extraFilter?: string[] } & (
{
componentPickerMode?: 'single',
onComponentSelected?: ComponentSelectedEvent,
@@ -54,6 +54,7 @@ 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.
@@ -105,6 +106,7 @@ export const ComponentPicker: React.FC<ComponentPickerProps> = ({
<LibraryProvider
libraryId={selectedLibrary}
showOnlyPublished={calcShowOnlyPublished}
extraFilter={extraFilter}
skipUrlUpdate
>
<SidebarProvider>

View File

@@ -0,0 +1,82 @@
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,13 +39,6 @@ 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,185 +1,21 @@
import { useCallback, useContext } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { useCallback } from 'react';
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 { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
import { useRemoveComponentsFromCollection } from '../data/apiHooks';
import { useSidebarContext } from '../common/context/SidebarContext';
import { useLibraryRoutes } from '../routes';
import AddComponentWidget from './AddComponentWidget';
import BaseCard from './BaseCard';
import { canEditComponent } from './ComponentEditorModal';
import messages from './messages';
import ComponentDeleter from './ComponentDeleter';
import { ComponentMenu } from './ComponentMenu';
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

@@ -0,0 +1,137 @@
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 { useCallback, ReactNode } from 'react';
import { ReactNode, useCallback, useContext } from 'react';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
@@ -12,14 +12,16 @@ 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 { useSidebarContext } from '../common/context/SidebarContext';
import BaseCard from './BaseCard';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
import { useContainerChildren, useRemoveItemsFromCollection } from '../data/apiHooks';
import { useLibraryRoutes } from '../routes';
import AddComponentWidget from './AddComponentWidget';
import BaseCard from './BaseCard';
import messages from './messages';
import { useContainerChildren } from '../data/apiHooks';
import ContainerDeleter from './ContainerDeleter';
type ContainerMenuProps = {
@@ -30,13 +32,38 @@ 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">
@@ -46,20 +73,27 @@ const ContainerMenu = ({ hit } : ContainerMenuProps) => {
src={MoreVert}
iconAs={Icon}
variant="primary"
alt={intl.formatMessage(messages.collectionCardMenuAlt)}
alt={intl.formatMessage(messages.containerCardMenuAlt)}
data-testid="container-card-menu-toggle"
/>
<Dropdown.Menu>
<Dropdown.Item
as={Link}
to={`/library/${contextKey}/container/${blockId}`}
disabled
to={`/library/${contextKey}/unit/${containerId}`}
>
<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
@@ -78,8 +112,7 @@ type ContainerCardPreviewProps = {
};
const ContainerCardPreview = ({ containerId, showMaxChildren = 5 }: ContainerCardPreviewProps) => {
const { libraryId } = useLibraryContext();
const { data, isLoading, isError } = useContainerChildren(libraryId, containerId);
const { data, isLoading, isError } = useContainerChildren(containerId);
if (isLoading || isError) {
return null;
}
@@ -170,9 +203,13 @@ const ContainerCard = ({ hit } : ContainerCardProps) => {
preview={<ContainerCardPreview containerId={unitId} />}
tags={tags}
numChildren={numChildrenCount}
actions={!componentPickerMode && (
actions={(
<ActionRow>
<ContainerMenu hit={hit} />
{componentPickerMode ? (
<AddComponentWidget usageKey={unitId} blockType={itemType} />
) : (
<ContainerMenu hit={hit} />
)}
</ActionRow>
)}
hasUnpublishedChanges={publishStatus !== PublishStatus.Published}

View File

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

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 a component from collection.',
description: 'Menu item for remove an item from collection.',
},
removeComponentSucess: {
removeComponentFromCollectionSuccess: {
id: 'course-authoring.library-authoring.component.remove-from-collection-success',
defaultMessage: 'Component successfully removed',
description: 'Message for successful removal of component from collection.',
defaultMessage: 'Item successfully removed',
description: 'Message for successful removal of an item from collection.',
},
removeComponentFailure: {
removeComponentFromCollectionFailure: {
id: 'course-authoring.library-authoring.component.remove-from-collection-failure',
defaultMessage: 'Failed to remove Component',
description: 'Message for failure of removal of component from collection.',
defaultMessage: 'Failed to remove item',
description: 'Message for failure of removal of an item 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,5 +231,35 @@ 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 container title/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /edit/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 container title/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /edit/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 container title/i }));
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const textBox = screen.getByRole('textbox', { name: /title input/i });
const textBox = screen.getByRole('textbox', { name: /text 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 container title/i }));
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const textBox = screen.getByRole('textbox', { name: /title input/i });
const textBox = screen.getByRole('textbox', { name: /text 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 container title/i }));
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const textBox = screen.getByRole('textbox', { name: /title input/i });
const textBox = screen.getByRole('textbox', { name: /text 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 container title/i }));
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const textBox = screen.getByRole('textbox', { name: /title input/i });
const textBox = screen.getByRole('textbox', { name: /text 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 container title/i }));
fireEvent.click(screen.getByRole('button', { name: /edit/i }));
const textBox = screen.getByRole('textbox', { name: /title input/i });
const textBox = screen.getByRole('textbox', { name: /text input/i });
userEvent.clear(textBox);
userEvent.type(textBox, 'New Unit Title{enter}');

View File

@@ -1,13 +1,7 @@
import React, { useState, useContext, useCallback } from 'react';
import { useContext } 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';
@@ -16,9 +10,8 @@ import messages from './messages';
const ContainerInfoHeader = () => {
const intl = useIntl();
const [inputIsActive, setIsActive] = useState(false);
const { libraryId, readOnly } = useLibraryContext();
const { readOnly } = useLibraryContext();
const { sidebarComponentInfo } = useSidebarContext();
const containerId = sidebarComponentInfo?.id;
@@ -27,79 +20,33 @@ const ContainerInfoHeader = () => {
throw new Error('containerId is required');
}
const { data: container } = useContainer(libraryId, containerId);
const { data: container } = useContainer(containerId);
const updateMutation = useUpdateContainer(containerId);
const { showToast } = useContext(ToastContext);
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],
);
const handleSaveDisplayName = (newDisplayName: string) => {
updateMutation.mutateAsync({
displayName: newDisplayName,
}).then(() => {
showToast(intl.formatMessage(messages.updateContainerSuccessMsg));
}).catch(() => {
showToast(intl.formatMessage(messages.updateContainerErrorMsg));
});
};
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 (
<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>
<InplaceTextEditor
onSave={handleSaveDisplayName}
text={container.displayName}
readOnly={readOnly}
textClassName="font-weight-bold m-1.5"
showEditButton
/>
);
};

View File

@@ -23,14 +23,18 @@ mockGetContainerMetadata.applyMock();
mockContentLibrary.applyMock();
mockContentTaxonomyTagsData.applyMock();
const { containerIdForTags } = mockGetContainerMetadata;
const render = (libraryId?: string) => baseRender(<ContainerOrganize />, {
const render = ({
libraryId = mockContentLibrary.libraryId,
containerId = mockGetContainerMetadata.containerId,
}: {
libraryId?: string;
containerId?: string;
}) => baseRender(<ContainerOrganize />, {
extraWrapper: ({ children }) => (
<LibraryProvider libraryId={libraryId || mockContentLibrary.libraryId}>
<LibraryProvider libraryId={libraryId}>
<SidebarProvider
initialSidebarComponentInfo={{
id: containerIdForTags,
id: containerId,
type: SidebarBodyComponentId.ComponentInfo,
}}
>
@@ -61,7 +65,7 @@ describe('<ContainerOrganize />', () => {
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
render(libraryId);
render({ libraryId });
await waitFor(() => {
expect(screen.getByText(`Mocked ${expected} ContentTagsDrawer`)).toBeInTheDocument();
});
@@ -73,7 +77,12 @@ describe('<ContainerOrganize />', () => {
...getConfig(),
ENABLE_TAGGING_TAXONOMY_PAGES: 'true',
});
render();
render({ containerId: mockGetContainerMetadata.containerIdForTags });
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 } from 'react';
import { useMemo, useEffect } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import {
@@ -8,20 +8,27 @@ import {
useToggle,
} from '@openedx/paragon';
import {
ExpandLess, ExpandMore, Tag,
BookOpen,
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 { useSidebarContext } from '../common/context/SidebarContext';
import { SidebarActions, useSidebarContext } from '../common/context/SidebarContext';
import messages from './messages';
const ContainerOrganize = () => {
const intl = useIntl();
const [tagsCollapseIsOpen, , , toggleTags] = useToggle(true);
const [tagsCollapseIsOpen, ,setTagsCollapseClose, toggleTags] = useToggle(true);
const [collectionsCollapseIsOpen, setCollectionsCollapseOpen, , toggleCollections] = useToggle(true);
const { readOnly } = useLibraryContext();
const { sidebarComponentInfo } = useSidebarContext();
const { sidebarComponentInfo, sidebarAction } = useSidebarContext();
const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections;
const containerId = sidebarComponentInfo?.id;
// istanbul ignore if: this should never happen
@@ -29,8 +36,17 @@ 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;
@@ -50,6 +66,11 @@ 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)
@@ -82,6 +103,33 @@ 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>
);
};

View File

@@ -8,12 +8,16 @@ import {
import { mockContentLibrary, mockGetContainerMetadata } from '../data/api.mocks';
import { LibraryProvider } from '../common/context/LibraryContext';
import UnitInfo from './UnitInfo';
import { getLibraryContainerApiUrl } from '../data/api';
import { getLibraryContainerApiUrl, getLibraryContainerPublishApiUrl } from '../data/api';
import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext';
mockGetContainerMetadata.applyMock();
mockContentLibrary.applyMock();
mockGetContainerMetadata.applyMock();
const { libraryId } = mockContentLibrary;
const { containerId } = mockGetContainerMetadata;
const render = () => baseRender(<UnitInfo />, {
extraWrapper: ({ children }) => (
<LibraryProvider
@@ -38,7 +42,7 @@ describe('<UnitInfo />', () => {
({ axiosMock, mockShowToast } = initializeMocks());
});
it('should detele the unit using the menu', async () => {
it('should delete the unit using the menu', async () => {
axiosMock.onDelete(getLibraryContainerApiUrl(containerId)).reply(200);
render();
@@ -61,4 +65,34 @@ describe('<UnitInfo />', () => {
});
expect(mockShowToast).toHaveBeenCalled();
});
it('can publish the container', async () => {
axiosMock.onPost(getLibraryContainerPublishApiUrl(containerId)).reply(200);
render();
// Click on Publish button
const publishButton = await screen.findByRole('button', { name: 'Publish' });
expect(publishButton).toBeInTheDocument();
userEvent.click(publishButton);
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
});
expect(mockShowToast).toHaveBeenCalledWith('All changes published');
});
it('shows an error if publishing the container fails', async () => {
axiosMock.onPost(getLibraryContainerPublishApiUrl(containerId)).reply(500);
render();
// Click on Publish button
const publishButton = await screen.findByRole('button', { name: 'Publish' });
expect(publishButton).toBeInTheDocument();
userEvent.click(publishButton);
await waitFor(() => {
expect(axiosMock.history.post.length).toBe(1);
});
expect(mockShowToast).toHaveBeenCalledWith('Failed to publish changes');
});
});

View File

@@ -9,13 +9,15 @@ import {
IconButton,
useToggle,
} from '@openedx/paragon';
import React, { useEffect, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { MoreVert } from '@openedx/paragon/icons';
import { useCallback } from 'react';
import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
import { useLibraryContext } from '../common/context/LibraryContext';
import {
type UnitInfoTab,
SidebarActions,
UNIT_INFO_TABS,
isUnitInfoTab,
useSidebarContext,
@@ -26,7 +28,8 @@ import { LibraryUnitBlocks } from '../units/LibraryUnitBlocks';
import messages from './messages';
import componentMessages from '../components/messages';
import ContainerDeleter from '../components/ContainerDeleter';
import { useContainer } from '../data/apiHooks';
import { useContainer, usePublishContainer } from '../data/apiHooks';
import { ToastContext } from '../../generic/toast-context';
type ContainerMenuProps = {
containerId: string,
@@ -69,29 +72,29 @@ const UnitMenu = ({ containerId, displayName }: ContainerMenuProps) => {
const UnitInfo = () => {
const intl = useIntl();
const { libraryId, setUnitId } = useLibraryContext();
const { libraryId, readOnly } = useLibraryContext();
const { componentPickerMode } = useComponentPickerContext();
const { showToast } = React.useContext(ToastContext);
const {
defaultTab, hiddenTabs, sidebarComponentInfo, sidebarTab, setSidebarTab,
defaultTab,
hiddenTabs,
sidebarTab,
setSidebarTab,
sidebarComponentInfo,
sidebarAction,
} = useSidebarContext();
const { insideUnit, navigateTo } = useLibraryRoutes();
const jumpToCollections = sidebarAction === SidebarActions.JumpToAddCollections;
const { insideUnit } = useLibraryRoutes();
const tab: UnitInfoTab = (
sidebarTab && isUnitInfoTab(sidebarTab)
) ? sidebarTab : defaultTab.unit;
const unitId = sidebarComponentInfo?.id;
const { data: container } = useContainer(libraryId, unitId);
const { data: container } = useContainer(unitId);
const publishContainer = usePublishContainer(unitId!);
const handleOpenUnit = useCallback(() => {
if (componentPickerMode) {
setUnitId(unitId);
} else {
navigateTo({ unitId });
}
}, [componentPickerMode, navigateTo, unitId]);
const showOpenUnitButton = !insideUnit || componentPickerMode;
const showOpenUnitButton = !insideUnit && !componentPickerMode;
const renderTab = useCallback((infoTab: UnitInfoTab, component: React.ReactNode, title: string) => {
if (hiddenTabs.includes(infoTab)) {
@@ -105,32 +108,56 @@ const UnitInfo = () => {
);
}, [hiddenTabs, defaultTab.unit, unitId]);
// istanbul ignore if: this should never happen
if (!unitId) {
throw new Error('unitId is required');
}
const handlePublish = React.useCallback(async () => {
try {
await publishContainer.mutateAsync();
showToast(intl.formatMessage(messages.publishContainerSuccess));
} catch (error) {
showToast(intl.formatMessage(messages.publishContainerFailed));
}
}, [publishContainer]);
if (!container) {
useEffect(() => {
// Show Organize tab if JumpToAddCollections action is set in sidebarComponentInfo
if (jumpToCollections) {
setSidebarTab(UNIT_INFO_TABS.Organize);
}
}, [jumpToCollections, setSidebarTab]);
if (!container || !unitId) {
return null;
}
return (
<Stack>
{showOpenUnitButton && (
<div className="d-flex flex-wrap">
<div className="d-flex flex-wrap">
{showOpenUnitButton && (
<Button
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
onClick={handleOpenUnit}
as={Link}
to={`/library/${libraryId}/unit/${unitId}`}
>
{intl.formatMessage(messages.openUnitButton)}
</Button>
)}
{!componentPickerMode && !readOnly && (
<Button
variant="outline-primary"
className="m-1 text-nowrap flex-grow-1"
disabled={!container.hasUnpublishedChanges || publishContainer.isLoading}
onClick={handlePublish}
>
{intl.formatMessage(messages.publishContainerButton)}
</Button>
)}
{showOpenUnitButton && ( // Check: should we still show this on the unit page?
<UnitMenu
containerId={unitId}
displayName={container.displayName}
/>
</div>
)}
)}
</div>
<Tabs
variant="tabs"
className="my-3 d-flex justify-content-around"
@@ -138,7 +165,7 @@ const UnitInfo = () => {
activeKey={tab}
onSelect={setSidebarTab}
>
{renderTab(UNIT_INFO_TABS.Preview, <LibraryUnitBlocks />, intl.formatMessage(messages.previewTabTitle))}
{renderTab(UNIT_INFO_TABS.Preview, <LibraryUnitBlocks preview />, intl.formatMessage(messages.previewTabTitle))}
{renderTab(UNIT_INFO_TABS.Organize, <ContainerOrganize />, intl.formatMessage(messages.organizeTabTitle))}
{renderTab(UNIT_INFO_TABS.Settings, 'Unit Settings', intl.formatMessage(messages.settingsTabTitle))}
</Tabs>

Some files were not shown because too many files have changed in this diff Show More