diff --git a/.env b/.env
index 6ece553f..f5a6e245 100644
--- a/.env
+++ b/.env
@@ -25,3 +25,4 @@ PRIVACY_POLICY_URL=
SUPPORT_EMAIL=
ENABLE_ACCESSIBILITY_PAGE=false
LOGO_URL=
+COURSE_AUTHORING_MICROFRONTEND_URL=
diff --git a/.env.development b/.env.development
index 51658b84..ea6132a6 100644
--- a/.env.development
+++ b/.env.development
@@ -26,4 +26,5 @@ PRIVACY_POLICY_URL=
SUPPORT_EMAIL=
ENABLE_ACCESSIBILITY_PAGE=false
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
-MFE_NAME='frontend-app-library-authoring'
\ No newline at end of file
+MFE_NAME='frontend-app-library-authoring'
+COURSE_AUTHORING_MICROFRONTEND_URL='http://localhost:2001'
diff --git a/.env.test b/.env.test
index eb70e996..fb739f23 100644
--- a/.env.test
+++ b/.env.test
@@ -25,3 +25,4 @@ PRIVACY_POLICY_URL=
SUPPORT_EMAIL=
ENABLE_ACCESSIBILITY_PAGE=false
LOGO_URL=https://edx-cdn.org/v3/default/logo.svg
+COURSE_AUTHORING_MICROFRONTEND_URL='http://localhost:2001'
diff --git a/src/index.jsx b/src/index.jsx
index 804e4c39..df18c2fc 100644
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -39,6 +39,7 @@ mergeConfig({
PRIVACY_POLICY_URL: process.env.PRIVACY_POLICY_URL,
SUPPORT_EMAIL: process.env.SUPPORT_EMAIL,
SHOW_ACCESSIBILITY_PAGE: process.env.SHOW_ACCESSIBILITY_PAGE,
+ COURSE_AUTHORING_MICROFRONTEND_URL: process.env.COURSE_AUTHORING_MICROFRONTEND_URL,
});
subscribe(APP_READY, () => {
diff --git a/src/library-authoring/author-library/BlockPreviewBase.jsx b/src/library-authoring/author-library/BlockPreviewBase.jsx
new file mode 100644
index 00000000..a8273b66
--- /dev/null
+++ b/src/library-authoring/author-library/BlockPreviewBase.jsx
@@ -0,0 +1,193 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import {
+ ActionRow,
+ Button,
+ IconButton,
+ Card,
+ Dropdown,
+ ModalDialog,
+ Icon,
+ IconButtonWithTooltip,
+ OverlayTrigger,
+ Tooltip,
+} from '@edx/paragon';
+import {
+ EditOutline,
+ MoreVert,
+ Tag,
+} from '@edx/paragon/icons';
+import { EditorPage } from '@edx/frontend-lib-content-components';
+import { ensureConfig, getConfig } from '@edx/frontend-platform';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+import { LibraryBlock } from '../edit-block/LibraryBlock';
+import {
+ getXBlockHandlerUrl,
+ libraryBlockShape,
+ libraryShape,
+ fetchable,
+ XBLOCK_VIEW_SYSTEM,
+} from '../common';
+import messages from './messages';
+import { blockViewShape } from '../edit-block/data/shapes';
+
+ensureConfig(['STUDIO_BASE_URL'], 'library API service');
+
+const getHandlerUrl = async (blockId) => getXBlockHandlerUrl(blockId, XBLOCK_VIEW_SYSTEM.Studio, 'handler_name');
+
+/**
+ * BlockPreviewBase
+ * Template component for BlockPreview cards, which are used to display
+ * components and render controls for them in a library listing.
+ */
+export const BlockPreviewBase = ({
+ intl, block, view, canEdit, showPreviews, showDeleteModal,
+ setShowDeleteModal, showEditorModal, setShowEditorModal, setOpenContentTagsDrawer,
+ library, editView, isLtiUrlGenerating,
+ ...props
+}) => (
+
+
+ {
+ !!block.tags_count && (
+ {intl.formatMessage(messages['library.detail.block.manage_tags'])}
+ }
+ >
+
+
+ )
+ }
+ setShowEditorModal(true)}
+ src={EditOutline}
+ iconAs={Icon}
+ tooltipContent={intl.formatMessage(messages['library.detail.block.edit'])}
+ />
+
+ {intl.formatMessage(messages['library.detail.block.more_actions'])}
+
+ )}
+ >
+
+
+
+ setOpenContentTagsDrawer(block.id)}
+ >
+ {intl.formatMessage(messages['library.detail.block.manage_tags'])}
+
+ setShowDeleteModal(true)}
+ >
+ {intl.formatMessage(messages['library.detail.block.delete'])}
+
+
+
+
+
+ )}
+ />
+
+ (response) => {
+ setShowEditorModal(false);
+ if (response && response.metadata) {
+ props.setLibraryBlockDisplayName({
+ blockId: block.id,
+ displayName: response.metadata.display_name,
+ });
+ // This state change triggers the iframe to reload.
+ props.updateLibraryBlockView({ blockId: block.id });
+ }
+ }}
+ />
+
+ setShowDeleteModal(false)}
+ >
+
+
+ {intl.formatMessage(messages['library.detail.block.delete.modal.title'])}
+
+
+
+ {intl.formatMessage(messages['library.detail.block.delete.modal.body'])}
+
+
+
+
+ {intl.formatMessage(messages['library.detail.block.delete.modal.cancel.button'])}
+
+
+
+
+
+ {showPreviews && (
+
+
+
+ )}
+
+);
+
+BlockPreviewBase.propTypes = {
+ block: libraryBlockShape.isRequired,
+ canEdit: PropTypes.bool.isRequired,
+ deleteLibraryBlock: PropTypes.func.isRequired,
+ editView: PropTypes.string.isRequired,
+ intl: intlShape.isRequired,
+ isLtiUrlGenerating: PropTypes.bool,
+ library: libraryShape.isRequired,
+ setLibraryBlockDisplayName: PropTypes.func.isRequired,
+ setShowDeleteModal: PropTypes.func.isRequired,
+ setShowEditorModal: PropTypes.func.isRequired,
+ showDeleteModal: PropTypes.bool.isRequired,
+ showEditorModal: PropTypes.bool.isRequired,
+ showPreviews: PropTypes.bool.isRequired,
+ setOpenContentTagsDrawer: PropTypes.func.isRequired,
+ updateLibraryBlockView: PropTypes.bool.isRequired,
+ view: fetchable(blockViewShape).isRequired,
+};
+
+BlockPreviewBase.defaultProps = {
+ isLtiUrlGenerating: false,
+};
+
+export const BlockPreview = injectIntl(BlockPreviewBase);
diff --git a/src/library-authoring/author-library/BlockPreviewContainerBase.jsx b/src/library-authoring/author-library/BlockPreviewContainerBase.jsx
new file mode 100644
index 00000000..8e0bf997
--- /dev/null
+++ b/src/library-authoring/author-library/BlockPreviewContainerBase.jsx
@@ -0,0 +1,129 @@
+/* eslint-disable react-hooks/exhaustive-deps */
+import React, { useEffect, useState } from 'react';
+import PropTypes from 'prop-types';
+import { ensureConfig, getConfig } from '@edx/frontend-platform';
+import { intlShape } from '@edx/frontend-platform/i18n';
+import { BlockPreview } from './BlockPreviewBase';
+import {
+ BLOCK_TYPE_EDIT_DENYLIST,
+ libraryBlockShape,
+ libraryShape,
+ LOADING_STATUS,
+ ROUTES,
+ XBLOCK_VIEW_SYSTEM,
+ fetchable,
+} from '../common';
+import { LoadingPage } from '../../generic';
+import messages from './messages';
+import { blockStatesShape } from '../edit-block/data/shapes';
+
+ensureConfig(['STUDIO_BASE_URL'], 'library API service');
+
+const inStandby = ({ blockStates, id, attr }) => blockStates[id][attr].status === LOADING_STATUS.STANDBY;
+const needsView = ({ blockStates, id }) => inStandby({ blockStates, id, attr: 'view' });
+const needsMeta = ({ blockStates, id }) => inStandby({ blockStates, id, attr: 'metadata' });
+
+/**
+ * BlockPreviewContainerBase
+ * Container component for the BlockPreview cards.
+ * Handles the fetching of the block view and metadata.
+ */
+export const BlockPreviewContainerBase = ({
+ intl, block, blockView, blockStates, showPreviews, setOpenContentTagsDrawer, library, ltiUrlClipboard, ...props
+}) => {
+ // There are enough events that trigger the effects here that we need to keep track of what we're doing to avoid
+ // doing it more than once, or running them when the state can no longer support these actions.
+ //
+ // This problem feels like there should be some way to generalize it and wrap it to avoid this issue.
+ const [showDeleteModal, setShowDeleteModal] = useState(false);
+ const [showEditorModal, setShowEditorModal] = useState(false);
+
+ useEffect(() => {
+ props.initializeBlock({
+ blockId: block.id,
+ });
+ }, []);
+ useEffect(() => {
+ if (!blockStates[block.id] || !showPreviews) {
+ return;
+ }
+ if (needsMeta({ blockStates, id: block.id })) {
+ props.fetchLibraryBlockMetadata({ blockId: block.id });
+ }
+ if (needsView({ blockStates, id: block.id })) {
+ props.fetchLibraryBlockView({
+ blockId: block.id,
+ viewSystem: XBLOCK_VIEW_SYSTEM.Studio,
+ viewName: 'student_view',
+ });
+ }
+ }, [blockStates[block.id], showPreviews]);
+
+ if (blockStates[block.id] === undefined) {
+ return ;
+ }
+ const { metadata } = blockStates[block.id];
+ const canEdit = metadata !== null && !BLOCK_TYPE_EDIT_DENYLIST.includes(metadata.block_type);
+
+ let editView;
+ if (canEdit) {
+ editView = ROUTES.Block.EDIT_SLUG(library.id, block.id);
+ } else {
+ editView = ROUTES.Detail.HOME_SLUG(library.id, block.id);
+ }
+
+ let isLtiUrlGenerating;
+ if (library.allow_lti) {
+ const isBlockOnClipboard = ltiUrlClipboard.value.blockId === block.id;
+ isLtiUrlGenerating = isBlockOnClipboard && ltiUrlClipboard.status === LOADING_STATUS.LOADING;
+
+ if (isBlockOnClipboard && ltiUrlClipboard.status === LOADING_STATUS.LOADED) {
+ const clipboard = document.createElement('textarea');
+ clipboard.value = getConfig().STUDIO_BASE_URL + ltiUrlClipboard.value.lti_url;
+ document.body.appendChild(clipboard);
+ clipboard.select();
+ document.execCommand('copy');
+ document.body.removeChild(clipboard);
+ }
+ }
+
+ return (
+
+ );
+};
+
+BlockPreviewContainerBase.defaultProps = {
+ blockView: null,
+ ltiUrlClipboard: null,
+};
+
+BlockPreviewContainerBase.propTypes = {
+ block: libraryBlockShape.isRequired,
+ blockStates: blockStatesShape.isRequired,
+ blockView: PropTypes.func,
+ fetchLibraryBlockView: PropTypes.func.isRequired,
+ fetchLibraryBlockMetadata: PropTypes.func.isRequired,
+ initializeBlock: PropTypes.func.isRequired,
+ intl: intlShape.isRequired,
+ library: libraryShape.isRequired,
+ // eslint-disable-next-line react/forbid-prop-types
+ ltiUrlClipboard: fetchable(PropTypes.object),
+ showPreviews: PropTypes.bool.isRequired,
+ setOpenContentTagsDrawer: PropTypes.func.isRequired,
+};
+
+export const BlockPreviewContainer = BlockPreviewContainerBase;
diff --git a/src/library-authoring/author-library/ButtonTogglesBase.jsx b/src/library-authoring/author-library/ButtonTogglesBase.jsx
new file mode 100644
index 00000000..3821a72a
--- /dev/null
+++ b/src/library-authoring/author-library/ButtonTogglesBase.jsx
@@ -0,0 +1,44 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { Button } from '@edx/paragon';
+import { Add } from '@edx/paragon/icons';
+import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
+
+import messages from './messages';
+
+const ButtonTogglesBase = ({ setShowPreviews, showPreviews, intl }) => (
+ <>
+
+ {/* todo: either replace the scroll to the add components button functionality
+ with a better UX for the add component button at the top, or just
+ remove it entirely */}
+
+ >
+);
+
+ButtonTogglesBase.propTypes = {
+ intl: intlShape.isRequired,
+ showPreviews: PropTypes.bool.isRequired,
+ setShowPreviews: PropTypes.func.isRequired,
+};
+
+const ButtonToggles = injectIntl(ButtonTogglesBase);
+export default ButtonToggles;
diff --git a/src/library-authoring/author-library/ContentTagsDrawer.jsx b/src/library-authoring/author-library/ContentTagsDrawer.jsx
new file mode 100644
index 00000000..25725f5a
--- /dev/null
+++ b/src/library-authoring/author-library/ContentTagsDrawer.jsx
@@ -0,0 +1,72 @@
+import React, { useEffect, useRef } from 'react';
+import PropTypes from 'prop-types';
+import { getConfig } from '@edx/frontend-platform';
+
+const ContentTagsDrawer = ({ openContentTagsDrawer, setOpenContentTagsDrawer }) => {
+ const iFrameRef = useRef();
+
+ if (openContentTagsDrawer) {
+ document.body.classList.add('drawer-open');
+ } else {
+ document.body.classList.remove('drawer-open');
+ }
+
+ useEffect(() => {
+ const handleCloseMessage = (event) => {
+ if (event.data === 'closeManageTagsDrawer') {
+ setOpenContentTagsDrawer('');
+ }
+ };
+
+ const handleCloseEsc = (event) => {
+ if (event.key === 'Escape' || event.keyCode === 27) {
+ setOpenContentTagsDrawer('');
+ }
+ };
+
+ // Add event listener to close drawer when close button is clicked or ESC pressed
+ // from within the Iframe
+ window.addEventListener('message', handleCloseMessage);
+ // Add event listener to close the drawer when ESC pressed and focus outside iframe
+ // If ESC is pressed while the Iframe is in focus, it will send the close message
+ // to the parent window and it will be handled with the above event listener
+ window.addEventListener('keyup', handleCloseEsc);
+
+ return () => {
+ window.removeEventListener('message', handleCloseMessage);
+ window.removeEventListener('keyup', handleCloseEsc);
+ };
+ }, [setOpenContentTagsDrawer]);
+
+ useEffect(() => {
+ if (openContentTagsDrawer && iFrameRef.current) {
+ iFrameRef.current.focus();
+ }
+ }, [openContentTagsDrawer]);
+
+ // TODO: The use of an iframe in the implementation will likely change
+ const renderIFrame = () => (
+
+ );
+
+ return openContentTagsDrawer && (
+ <>
+
+ { renderIFrame() }
+
+
+ >
+ );
+};
+
+ContentTagsDrawer.propTypes = {
+ openContentTagsDrawer: PropTypes.string.isRequired,
+ setOpenContentTagsDrawer: PropTypes.func.isRequired,
+};
+
+export default ContentTagsDrawer;
diff --git a/src/library-authoring/author-library/LibraryAuthoringPage.jsx b/src/library-authoring/author-library/LibraryAuthoringPage.jsx
index 4de54d55..d6c0786f 100644
--- a/src/library-authoring/author-library/LibraryAuthoringPage.jsx
+++ b/src/library-authoring/author-library/LibraryAuthoringPage.jsx
@@ -7,32 +7,24 @@ import {
Container,
Row,
Button,
- IconButton,
Card,
- // Dropdown,
SearchField,
Form,
Pagination,
- ModalDialog,
SelectableBox,
Icon,
- IconButtonWithTooltip,
} from '@edx/paragon';
import {
Add,
- DeleteOutline,
- EditOutline,
HelpOutline,
TextFields,
VideoCamera,
} from '@edx/paragon/icons';
-import { EditorPage } from '@edx/frontend-lib-content-components';
import { v4 as uuid4 } from 'uuid';
import { connect } from 'react-redux';
-import { ensureConfig, getConfig } from '@edx/frontend-platform';
+import { ensureConfig } from '@edx/frontend-platform';
import { injectIntl, intlShape } from '@edx/frontend-platform/i18n';
import { useParams } from 'react-router-dom';
-import { LibraryBlock } from '../edit-block/LibraryBlock';
import {
clearLibrary,
clearLibraryError,
@@ -50,19 +42,19 @@ import {
} from '../configure-library/data';
import {
BLOCK_FILTER_ORDER,
- BLOCK_TYPE_EDIT_DENYLIST,
- getXBlockHandlerUrl,
LIBRARY_TYPES,
libraryBlockShape,
libraryShape,
LOADING_STATUS,
- ROUTES,
- XBLOCK_VIEW_SYSTEM,
fetchable,
paginated,
} from '../common';
import { LoadingPage } from '../../generic';
import messages from './messages';
+import { BlockPreviewContainerBase } from './BlockPreviewContainerBase';
+import ButtonToggles from './ButtonTogglesBase';
+import LibraryAuthoringPageHeaderBase from './LibraryAuthoringPageHeaderBase';
+import ContentTagsDrawer from './ContentTagsDrawer';
import {
deleteLibraryBlock,
fetchLibraryBlockMetadata,
@@ -72,7 +64,7 @@ import {
updateAllLibraryBlockView,
updateLibraryBlockView,
} from '../edit-block/data';
-import { blockStatesShape, blockViewShape } from '../edit-block/data/shapes';
+import { blockStatesShape } from '../edit-block/data/shapes';
import commonMessages from '../common/messages';
import selectLibraryDetail from '../common/data/selectors';
import { ErrorAlert } from '../common/ErrorAlert';
@@ -80,259 +72,6 @@ import { SuccessAlert } from '../common/SuccessAlert';
import { LoadGuard } from '../../generic/LoadingPage';
ensureConfig(['STUDIO_BASE_URL'], 'library API service');
-const getHandlerUrl = async (blockId) => getXBlockHandlerUrl(blockId, XBLOCK_VIEW_SYSTEM.Studio, 'handler_name');
-
-/**
- * BlockPreviewBase
- * Template component for BlockPreview cards, which are used to display
- * components and render controls for them in a library listing.
- */
-export const BlockPreviewBase = ({
- intl, block, view, canEdit, showPreviews, showDeleteModal,
- setShowDeleteModal, showEditorModal, setShowEditorModal, library, editView, isLtiUrlGenerating,
- ...props
-}) => (
-
-
- setShowEditorModal(true)}
- src={EditOutline}
- iconAs={Icon}
- tooltipContent={intl.formatMessage(messages['library.detail.block.edit'])}
- />
- setShowDeleteModal(true)}
- src={DeleteOutline}
- iconAs={Icon}
- tooltipContent={intl.formatMessage(messages['library.detail.block.delete'])}
- />
-
- )}
- />
-
- (response) => {
- setShowEditorModal(false);
- if (response && response.metadata) {
- props.setLibraryBlockDisplayName({
- blockId: block.id,
- displayName: response.metadata.display_name,
- });
- // This state change triggers the iframe to reload.
- props.updateLibraryBlockView({ blockId: block.id });
- }
- }}
- />
-
- setShowDeleteModal(false)}
- >
-
-
- {intl.formatMessage(messages['library.detail.block.delete.modal.title'])}
-
-
-
- {intl.formatMessage(messages['library.detail.block.delete.modal.body'])}
-
-
-
-
- {intl.formatMessage(messages['library.detail.block.delete.modal.cancel.button'])}
-
-
-
-
-
- {showPreviews && (
-
-
-
- )}
-
-);
-
-BlockPreviewBase.propTypes = {
- block: libraryBlockShape.isRequired,
- canEdit: PropTypes.bool.isRequired,
- deleteLibraryBlock: PropTypes.func.isRequired,
- editView: PropTypes.string.isRequired,
- intl: intlShape.isRequired,
- isLtiUrlGenerating: PropTypes.bool,
- library: libraryShape.isRequired,
- setLibraryBlockDisplayName: PropTypes.func.isRequired,
- setShowDeleteModal: PropTypes.func.isRequired,
- setShowEditorModal: PropTypes.func.isRequired,
- showDeleteModal: PropTypes.bool.isRequired,
- showEditorModal: PropTypes.bool.isRequired,
- showPreviews: PropTypes.bool.isRequired,
- updateLibraryBlockView: PropTypes.bool.isRequired,
- view: fetchable(blockViewShape).isRequired,
-};
-
-BlockPreviewBase.defaultProps = {
- isLtiUrlGenerating: false,
-};
-
-export const BlockPreview = injectIntl(BlockPreviewBase);
-
-const inStandby = ({ blockStates, id, attr }) => blockStates[id][attr].status === LOADING_STATUS.STANDBY;
-const needsView = ({ blockStates, id }) => inStandby({ blockStates, id, attr: 'view' });
-const needsMeta = ({ blockStates, id }) => inStandby({ blockStates, id, attr: 'metadata' });
-
-/**
- * BlockPreviewContainerBase
- * Container component for the BlockPreview cards.
- * Handles the fetching of the block view and metadata.
- */
-const BlockPreviewContainerBase = ({
- intl, block, blockView, blockStates, showPreviews, library, ltiUrlClipboard, ...props
-}) => {
- // There are enough events that trigger the effects here that we need to keep track of what we're doing to avoid
- // doing it more than once, or running them when the state can no longer support these actions.
- //
- // This problem feels like there should be some way to generalize it and wrap it to avoid this issue.
- const [showDeleteModal, setShowDeleteModal] = useState(false);
- const [showEditorModal, setShowEditorModal] = useState(false);
-
- useEffect(() => {
- props.initializeBlock({
- blockId: block.id,
- });
- }, []);
- useEffect(() => {
- if (!blockStates[block.id] || !showPreviews) {
- return;
- }
- if (needsMeta({ blockStates, id: block.id })) {
- props.fetchLibraryBlockMetadata({ blockId: block.id });
- }
- if (needsView({ blockStates, id: block.id })) {
- props.fetchLibraryBlockView({
- blockId: block.id,
- viewSystem: XBLOCK_VIEW_SYSTEM.Studio,
- viewName: 'student_view',
- });
- }
- }, [blockStates[block.id], showPreviews]);
-
- if (blockStates[block.id] === undefined) {
- return ;
- }
- const { metadata } = blockStates[block.id];
- const canEdit = metadata !== null && !BLOCK_TYPE_EDIT_DENYLIST.includes(metadata.block_type);
-
- let editView;
- if (canEdit) {
- editView = ROUTES.Block.EDIT_SLUG(library.id, block.id);
- } else {
- editView = ROUTES.Detail.HOME_SLUG(library.id, block.id);
- }
-
- let isLtiUrlGenerating;
- if (library.allow_lti) {
- const isBlockOnClipboard = ltiUrlClipboard.value.blockId === block.id;
- isLtiUrlGenerating = isBlockOnClipboard && ltiUrlClipboard.status === LOADING_STATUS.LOADING;
-
- if (isBlockOnClipboard && ltiUrlClipboard.status === LOADING_STATUS.LOADED) {
- const clipboard = document.createElement('textarea');
- clipboard.value = getConfig().STUDIO_BASE_URL + ltiUrlClipboard.value.lti_url;
- document.body.appendChild(clipboard);
- clipboard.select();
- document.execCommand('copy');
- document.body.removeChild(clipboard);
- }
- }
-
- return (
-
- );
-};
-
-BlockPreviewContainerBase.defaultProps = {
- blockView: null,
- ltiUrlClipboard: null,
-};
-
-BlockPreviewContainerBase.propTypes = {
- block: libraryBlockShape.isRequired,
- blockStates: blockStatesShape.isRequired,
- blockView: PropTypes.func,
- fetchLibraryBlockView: PropTypes.func.isRequired,
- fetchLibraryBlockMetadata: PropTypes.func.isRequired,
- initializeBlock: PropTypes.func.isRequired,
- intl: intlShape.isRequired,
- library: libraryShape.isRequired,
- // eslint-disable-next-line react/forbid-prop-types
- ltiUrlClipboard: fetchable(PropTypes.object),
- showPreviews: PropTypes.bool.isRequired,
-};
-
-const ButtonTogglesBase = ({ setShowPreviews, showPreviews, intl }) => (
- <>
-
- {/* todo: either replace the scroll to the add components button functionality
- with a better UX for the add component button at the top, or just
- remove it entirely */}
-
- >
-);
-
-ButtonTogglesBase.propTypes = {
- intl: intlShape.isRequired,
- showPreviews: PropTypes.bool.isRequired,
- setShowPreviews: PropTypes.func.isRequired,
-};
-
-const ButtonToggles = injectIntl(ButtonTogglesBase);
const BlockPreviewContainer = connect(
selectLibraryDetail,
@@ -372,61 +111,6 @@ const deriveTypeOptions = (blockTypes, intl) => {
return typeOptions;
};
-/**
- * LibraryAuthoringPageHeaderBase
- * Title component for the LibraryAuthoringPageBase.
- */
-const LibraryAuthoringPageHeaderBase = ({ intl, library, ...props }) => {
- const [inputIsActive, setIsActive] = useState(false);
- const handleSaveTitle = (event) => {
- const newTitle = event.target.value;
- if (newTitle && newTitle !== library.title) {
- props.updateLibrary({ data: { title: newTitle, libraryId: library.id } });
- }
- setIsActive(false);
- };
- const handleClick = () => {
- setIsActive(true);
- };
-
- return (
-
- { inputIsActive
- ? (
-
{
- if (event.key === 'Enter') { handleSaveTitle(event); }
- }}
- />
- )
- : (
-
- {library.title}
-
-
- )}
-
- );
-};
-
-LibraryAuthoringPageHeaderBase.propTypes = {
- intl: intlShape.isRequired,
- library: libraryShape.isRequired,
- updateLibrary: PropTypes.func.isRequired,
-};
-
const LibraryAuthoringPageHeader = connect(
selectLibraryEdit,
{
@@ -443,248 +127,257 @@ export const LibraryAuthoringPageBase = ({
sending, addBlock, revertChanges, commitChanges, hasChanges, errorMessage, successMessage,
quickAddBehavior, otherTypes, blocks, changeQuery, changeType, changePage,
paginationOptions, typeOptions, query, type, getCurrentViewRange, ...props
-}) => (
-
-
- {intl.formatMessage(messages['library.detail.page.heading'])}
-
-
-
-
-
-
-
-
-
-
+}) => {
+ const [openContentTagsDrawer, setOpenContentTagsDrawer] = useState('');
+
+ return (
+
+
+ {intl.formatMessage(messages['library.detail.page.heading'])}
- {(library.type === LIBRARY_TYPES.COMPLEX) && (
- <>
- changeQuery(value)}
- onChange={(value) => changeQuery(value)}
- />
-
- changeType(event.target.value)}
- >
- {typeOptions.map(typeOption => (
-
- ))}
-
- >
- )}
-
-
-
- {intl.formatMessage(
- messages['library.detail.component.showingCount'],
- {
- currentViewRange: getCurrentViewRange(paginationOptions.currentPage, blocks.value.count),
- total: blocks.value.count,
- },
- )}
-
+
- {paginationOptions.pageCount > 1 ? (
- changePage(page)}
- />
- ) : null}
-
- {/* todo: figure out how we want to handle these at low screen widths.
- mobile is currently unsupported: so it doesn't make sense
- to have partially implemented responsive logic */}
- {/*
- */}
-
- {() => blocks.value.data.map((block) => (
-
+
+
+
+
+
+
+ {(library.type === LIBRARY_TYPES.COMPLEX) && (
+ <>
+ changeQuery(value)}
+ onChange={(value) => changeQuery(value)}
+ />
+
+ changeType(event.target.value)}
+ >
+ {typeOptions.map(typeOption => (
+
+ ))}
+
+ >
+ )}
+
+
+
+ {intl.formatMessage(
+ messages['library.detail.component.showingCount'],
+ {
+ currentViewRange: getCurrentViewRange(paginationOptions.currentPage, blocks.value.count),
+ total: blocks.value.count,
+ },
+ )}
+
+
+ {paginationOptions.pageCount > 1 ? (
+ changePage(page)}
+ />
+ ) : null}
+
+ {/* todo: figure out how we want to handle these at low screen widths.
+ mobile is currently unsupported: so it doesn't make sense
+ to have partially implemented responsive logic */}
+ {/*
+
- ))}
-
-
- {library.type !== LIBRARY_TYPES.COMPLEX && (
-
- )}
- {library.type === LIBRARY_TYPES.COMPLEX && (
-
-
- {intl.formatMessage(messages['library.detail.add_component_heading'])}
-
-
- addBlock(e.target.value)}
- columns={3}
- ariaLabel="component-selection"
- name="components"
- className="px-6 mx-6 text-primary-500"
- style={{ 'font-weight': 500 }}
- >
- {/* Update to use a SelectableBox that triggers a modal for options
-
-
- blocks.value.data.map((block) => (
+
+ ))}
+
+
+ {library.type !== LIBRARY_TYPES.COMPLEX && (
+
+ )}
+ {library.type === LIBRARY_TYPES.COMPLEX && (
+
+
+ {intl.formatMessage(messages['library.detail.add_component_heading'])}
+
+
+ addBlock(e.target.value)}
+ columns={3}
+ ariaLabel="component-selection"
+ name="components"
+ className="px-6 mx-6 text-primary-500"
+ style={{ 'font-weight': 500 }}
+ >
+ {/* Update to use a SelectableBox that triggers a modal for options
+
+
+
+ Advanced
+
+
+ {otherTypes.map((blockSpec) => (
+ addBlock(blockSpec.block_type)}
+ key={blockSpec.block_type}
+ >
+ {blockSpec.display_name}
+
+ ))}
+
+
+
*/}
+
- Advanced
-
-
- {otherTypes.map((blockSpec) => (
- addBlock(blockSpec.block_type)}
- key={blockSpec.block_type}
- >
- {blockSpec.display_name}
-
- ))}
-
-
-
*/}
-
-
-
- {intl.formatMessage(messages['library.detail.add.new.component.html'])}
-
-
-
-
-
- {intl.formatMessage(messages['library.detail.add.new.component.problem'])}
-
-
-
-
-
- {intl.formatMessage(messages['library.detail.add.new.component.video'])}
-
-
-
+
+
+ {intl.formatMessage(messages['library.detail.add.new.component.html'])}
+
+
+
+
+
+ {intl.formatMessage(messages['library.detail.add.new.component.problem'])}
+
+
+
+
+
+ {intl.formatMessage(messages['library.detail.add.new.component.video'])}
+
+
+
+
+
+ )}
+
+ {paginationOptions.pageCount > 1
+ ? (
+
+ changePage(page)}
+ />
+
+ )
+ : null}
+
+
+
- {paginationOptions.pageCount > 1
- ? (
-
- changePage(page)}
- />
-
- )
- : null}
-
-
-
-
-
-
-);
+
+
+
+ );
+};
LibraryAuthoringPageBase.defaultProps = {
errorMessage: '',
diff --git a/src/library-authoring/author-library/LibraryAuthoringPageHeaderBase.jsx b/src/library-authoring/author-library/LibraryAuthoringPageHeaderBase.jsx
new file mode 100644
index 00000000..858ee564
--- /dev/null
+++ b/src/library-authoring/author-library/LibraryAuthoringPageHeaderBase.jsx
@@ -0,0 +1,67 @@
+import React, { useState } from 'react';
+import PropTypes from 'prop-types';
+import {
+ ActionRow,
+ IconButton,
+ Form,
+} from '@edx/paragon';
+import { EditOutline } from '@edx/paragon/icons';
+import { intlShape } from '@edx/frontend-platform/i18n';
+import { libraryShape } from '../common';
+
+/**
+ * LibraryAuthoringPageHeaderBase
+ * Title component for the LibraryAuthoringPageBase.
+ */
+const LibraryAuthoringPageHeaderBase = ({ intl, library, ...props }) => {
+ const [inputIsActive, setIsActive] = useState(false);
+ const handleSaveTitle = (event) => {
+ const newTitle = event.target.value;
+ if (newTitle && newTitle !== library.title) {
+ props.updateLibrary({ data: { title: newTitle, libraryId: library.id } });
+ }
+ setIsActive(false);
+ };
+ const handleClick = () => {
+ setIsActive(true);
+ };
+
+ return (
+
+ { inputIsActive
+ ? (
+
{
+ if (event.key === 'Enter') { handleSaveTitle(event); }
+ }}
+ />
+ )
+ : (
+
+ {library.title}
+
+
+ )}
+
+ );
+};
+
+LibraryAuthoringPageHeaderBase.propTypes = {
+ intl: intlShape.isRequired,
+ library: libraryShape.isRequired,
+ updateLibrary: PropTypes.func.isRequired,
+};
+
+export default LibraryAuthoringPageHeaderBase;
diff --git a/src/library-authoring/author-library/index.scss b/src/library-authoring/author-library/index.scss
index 42c33422..27f5df2b 100644
--- a/src/library-authoring/author-library/index.scss
+++ b/src/library-authoring/author-library/index.scss
@@ -33,4 +33,32 @@
h2 {
font-size: 120%;
}
-}
\ No newline at end of file
+}
+
+// TODO: We might need to move these styles to another file
+.drawer-cover {
+ z-index: 1000;
+
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background: rgba(0, 0, 0, 0.8);
+}
+
+.drawer {
+ z-index: 10000;
+
+ position: fixed;
+ top: 0;
+ right: 0;
+ width: 33.33vw;
+ height: 100vh;
+ background-color: $light-200;
+}
+
+// This is to prevent the darkened part of the page from being scrollable
+body.drawer-open {
+ overflow: hidden;
+}
diff --git a/src/library-authoring/author-library/messages.js b/src/library-authoring/author-library/messages.js
index f0a938c6..1744203e 100644
--- a/src/library-authoring/author-library/messages.js
+++ b/src/library-authoring/author-library/messages.js
@@ -160,6 +160,16 @@ const messages = defineMessages({
defaultMessage: 'Delete',
description: 'Message for delete confirmation button',
},
+ 'library.detail.block.manage_tags': {
+ id: 'library.detail.block.manage_tags',
+ defaultMessage: 'Manage tags',
+ description: 'Aria label for manage tags button',
+ },
+ 'library.detail.block.more_actions': {
+ id: 'library.detail.block.more_actions',
+ defaultMessage: 'More actions',
+ description: 'Aria label for more actions button',
+ },
'library.detail.block.copy': {
id: 'library.detail.block.copy',
defaultMessage: 'Copy',
diff --git a/src/library-authoring/author-library/specs/LibraryAuthoringPage.spec.jsx b/src/library-authoring/author-library/specs/LibraryAuthoringPage.spec.jsx
index cb1247fc..89e46df7 100644
--- a/src/library-authoring/author-library/specs/LibraryAuthoringPage.spec.jsx
+++ b/src/library-authoring/author-library/specs/LibraryAuthoringPage.spec.jsx
@@ -360,11 +360,16 @@ testSuite('', () => {
const library = libraryFactory();
const block = blockFactory(undefined, { library });
await render(library, genState(library, [block]));
- const del = screen.getByLabelText('Delete');
+ const moreActionsButton = screen.getByLabelText('More actions');
act(() => {
- del.click();
+ moreActionsButton.click();
});
- const yes = await screen.findByText('Delete');
+ const deleteAction = await screen.getByLabelText('Delete');
+ act(() => {
+ deleteAction.click();
+ });
+ const deleteElems = await screen.findAllByText('Delete');
+ const yes = deleteElems[0];
act(() => {
yes.click();
});
@@ -387,4 +392,49 @@ testSuite('', () => {
() => expect(updateLibrary.fn).toHaveBeenCalledWith({ data: { title: 'New title', libraryId: library.id } }),
);
});
+
+ it('Opens (and closes) block tags drawer', async () => {
+ const library = libraryFactory();
+ const block = blockFactory(undefined, { library });
+ await render(library, genState(library, [block]));
+ const moreActionsButton = screen.getByLabelText('More actions');
+ act(() => {
+ moreActionsButton.click();
+ });
+ const manageTagsAction = await screen.getByLabelText('Manage tags');
+ // Open the tags drawer
+ act(() => {
+ manageTagsAction.click();
+ });
+
+ const testExistingManageTagsIFrame = await screen.getByTitle('manage-tags-drawer');
+ expect(testExistingManageTagsIFrame).not.toBeNull();
+
+ // Close the tags drawer
+ fireEvent.keyUp(testExistingManageTagsIFrame, {
+ key: 'Escape',
+ code: 'Escape',
+ keyCode: 27,
+ charCode: 27,
+ });
+
+ const testMissingManageTagsIFrame = await screen.queryByTitle('manage-tags-drawer');
+ expect(testMissingManageTagsIFrame).toBeNull();
+ });
+
+ it('Shows tags count button in block that opens tags drawer', async () => {
+ const library = libraryFactory();
+ const block = blockFactory({ tags_count: 2 }, { library });
+ await render(library, genState(library, [block]));
+
+ const manageTagsCountButton = screen.getByTestId('tags-count-manage-tags-button');
+ expect(manageTagsCountButton).not.toBeNull();
+ expect(manageTagsCountButton.textContent).toContain('2');
+ act(() => {
+ manageTagsCountButton.click();
+ });
+
+ const testExistingManageTagsIFrame = await screen.getByTitle('manage-tags-drawer');
+ expect(testExistingManageTagsIFrame).not.toBeNull();
+ });
});
diff --git a/src/library-authoring/index.scss b/src/library-authoring/index.scss
index 2fe0e440..cf7a149a 100644
--- a/src/library-authoring/index.scss
+++ b/src/library-authoring/index.scss
@@ -40,4 +40,13 @@ body {
padding-right: 1.5rem;
flex: 1 0 auto;
display: block;
-}
\ No newline at end of file
+}
+
+.tags-count-manage-button {
+ border: 0;
+
+ &:hover {
+ color: white;
+ background-color: $primary;
+ }
+}