Skip to content
This repository was archived by the owner on Jul 18, 2024. It is now read-only.

feat: View/Edit tags for Library Components #400

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .env
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,4 @@ PRIVACY_POLICY_URL=
SUPPORT_EMAIL=
ENABLE_ACCESSIBILITY_PAGE=false
LOGO_URL=
COURSE_AUTHORING_MICROFRONTEND_URL=
3 changes: 2 additions & 1 deletion .env.development
Original file line number Diff line number Diff line change
Expand Up @@ -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'
MFE_NAME='frontend-app-library-authoring'
COURSE_AUTHORING_MICROFRONTEND_URL='http://localhost:2001'
1 change: 1 addition & 0 deletions .env.test
Original file line number Diff line number Diff line change
Expand Up @@ -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'
1 change: 1 addition & 0 deletions src/index.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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, () => {
Expand Down
193 changes: 193 additions & 0 deletions src/library-authoring/author-library/BlockPreviewBase.jsx
Original file line number Diff line number Diff line change
@@ -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
}) => (
<Card className="w-auto my-3">
<Card.Header
className="library-authoring-block-card-header"
title={block.display_name}
actions={(
<ActionRow>
{
!!block.tags_count && (
<OverlayTrigger
placement="top"
overlay={
<Tooltip id="manage-tags-tooltip">{intl.formatMessage(messages['library.detail.block.manage_tags'])}</Tooltip>
}
>
<Button
variant="outline-primary"
iconBefore={Tag}
className="tags-count-manage-button"
onClick={() => setOpenContentTagsDrawer(block.id)}
data-testid="tags-count-manage-tags-button"
>
{ block.tags_count }
</Button>
</OverlayTrigger>
)
}
<IconButtonWithTooltip
aria-label={intl.formatMessage(messages['library.detail.block.edit'])}
onClick={() => setShowEditorModal(true)}
src={EditOutline}
iconAs={Icon}
tooltipContent={intl.formatMessage(messages['library.detail.block.edit'])}
/>
<OverlayTrigger
placement="top"
overlay={(
<Tooltip id="more-actions-tooltip">
{intl.formatMessage(messages['library.detail.block.more_actions'])}
</Tooltip>
)}
>
<Dropdown>
<Dropdown.Toggle
aria-label={intl.formatMessage(messages['library.detail.block.more_actions'])}
as={IconButton}
src={MoreVert}
iconAs={Icon}
/>
<Dropdown.Menu align="right">
<Dropdown.Item
aria-label={intl.formatMessage(messages['library.detail.block.manage_tags'])}
onClick={() => setOpenContentTagsDrawer(block.id)}
>
{intl.formatMessage(messages['library.detail.block.manage_tags'])}
</Dropdown.Item>
<Dropdown.Item
aria-label={intl.formatMessage(messages['library.detail.block.delete'])}
onClick={() => setShowDeleteModal(true)}
>
{intl.formatMessage(messages['library.detail.block.delete'])}
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</OverlayTrigger>
</ActionRow>
)}
/>
<ModalDialog
isOpen={showEditorModal}
hasCloseButton={false}
size="fullscreen"
>
<EditorPage
blockType={block.block_type}
blockId={block.id}
studioEndpointUrl={getConfig().STUDIO_BASE_URL}
lmsEndpointUrl={getConfig().LMS_BASE_URL}
returnFunction={() => (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 });
}
}}
/>
</ModalDialog>
<ModalDialog
isOpen={showDeleteModal}
onClose={() => setShowDeleteModal(false)}
>
<ModalDialog.Header>
<ModalDialog.Title>
{intl.formatMessage(messages['library.detail.block.delete.modal.title'])}
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{intl.formatMessage(messages['library.detail.block.delete.modal.body'])}
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<ModalDialog.CloseButton variant="tertiary">
{intl.formatMessage(messages['library.detail.block.delete.modal.cancel.button'])}
</ModalDialog.CloseButton>
<Button onClick={() => props.deleteLibraryBlock({ blockId: block.id })} variant="primary">
{intl.formatMessage(messages['library.detail.block.delete.modal.confirmation.button'])}
</Button>
</ActionRow>
</ModalDialog.Footer>
</ModalDialog>
{showPreviews && (
<Card.Body>
<LibraryBlock getHandlerUrl={getHandlerUrl} view={view} />
</Card.Body>
)}
</Card>
);

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);
129 changes: 129 additions & 0 deletions src/library-authoring/author-library/BlockPreviewContainerBase.jsx
Original file line number Diff line number Diff line change
@@ -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 <LoadingPage loadingMessage={intl.formatMessage(messages['library.detail.loading.message'])} />;
}
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 (
<BlockPreview
block={block}
canEdit={canEdit}
editView={editView}
isLtiUrlGenerating={isLtiUrlGenerating}
library={library}
setShowDeleteModal={setShowDeleteModal}
setShowEditorModal={setShowEditorModal}
showDeleteModal={showDeleteModal}
showEditorModal={showEditorModal}
showPreviews={showPreviews}
setOpenContentTagsDrawer={setOpenContentTagsDrawer}
view={blockView(block)}
{...props}
/>
);
};

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;
44 changes: 44 additions & 0 deletions src/library-authoring/author-library/ButtonTogglesBase.jsx
Original file line number Diff line number Diff line change
@@ -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 }) => (
<>
<Button
variant="outline-primary"
className="ml-1"
onClick={() => setShowPreviews(!showPreviews)}
size="sm"
>
{ intl.formatMessage(showPreviews ? messages['library.detail.hide_previews'] : messages['library.detail.show_previews']) }
</Button>
{/* 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 */}
<Button
variant="primary"
className="mr-1"
size="sm"
onClick={() => {
const addComponentSection = document.getElementById('add-component-section');
addComponentSection.scrollIntoView({ behavior: 'smooth' });
}}
iconBefore={Add}
>
{intl.formatMessage(messages['library.detail.add.new.component.item'])}
</Button>
</>
);

ButtonTogglesBase.propTypes = {
intl: intlShape.isRequired,
showPreviews: PropTypes.bool.isRequired,
setShowPreviews: PropTypes.func.isRequired,
};

const ButtonToggles = injectIntl(ButtonTogglesBase);
export default ButtonToggles;
Loading