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 = () => ( +