diff --git a/src/content-tags-drawer/ContentTagsDrawer.test.jsx b/src/content-tags-drawer/ContentTagsDrawer.test.jsx index 91ad97d8fc..426f0d5323 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.test.jsx +++ b/src/content-tags-drawer/ContentTagsDrawer.test.jsx @@ -44,7 +44,7 @@ jest.mock('react-router-dom', () => ({ const renderDrawer = (contentId, drawerParams = {}) => ( render( - + , { path, params: { contentId } }, ) @@ -256,7 +256,7 @@ describe('', () => { ])( 'should hide "$editButton" button on $variant variant if not allowed to tag object', async ({ variant, editButton }) => { - renderDrawer(stagedTagsId, { variant, canTagObject: false }); + renderDrawer(stagedTagsId, { variant, readOnly: true }); expect(await screen.findByText('Taxonomy 1')).toBeInTheDocument(); expect(screen.queryByRole('button', { name: editButton })).not.toBeInTheDocument(); diff --git a/src/content-tags-drawer/ContentTagsDrawer.tsx b/src/content-tags-drawer/ContentTagsDrawer.tsx index 040cce196b..4f225666fd 100644 --- a/src/content-tags-drawer/ContentTagsDrawer.tsx +++ b/src/content-tags-drawer/ContentTagsDrawer.tsx @@ -100,10 +100,10 @@ const ContentTagsDrawerTitle = () => { interface ContentTagsDrawerVariantFooterProps { onClose: () => void, - canTagObject: boolean, + readOnly: boolean, } -const ContentTagsDrawerVariantFooter = ({ onClose, canTagObject }: ContentTagsDrawerVariantFooterProps) => { +const ContentTagsDrawerVariantFooter = ({ onClose, readOnly }: ContentTagsDrawerVariantFooterProps) => { const intl = useIntl(); const { commitGlobalStagedTagsStatus, @@ -131,7 +131,7 @@ const ContentTagsDrawerVariantFooter = ({ onClose, canTagObject }: ContentTagsDr ? messages.tagsDrawerCancelButtonText : messages.tagsDrawerCloseButtonText)} - {canTagObject && ( + {!readOnly && ( - ) + ) : !readOnly && ( + )} ); @@ -216,8 +218,8 @@ const ContentTagsComponentVariantFooter = ({ canTagObject }: { canTagObject: boo interface ContentTagsDrawerProps { id?: string; onClose?: () => void; - canTagObject?: boolean; variant?: 'drawer' | 'component'; + readOnly?: boolean; } /** @@ -232,8 +234,8 @@ interface ContentTagsDrawerProps { const ContentTagsDrawer = ({ id, onClose, - canTagObject = false, variant = 'drawer', + readOnly = false, }: ContentTagsDrawerProps) => { const intl = useIntl(); // TODO: We can delete 'params' when the iframe is no longer used on edx-platform @@ -244,7 +246,7 @@ const ContentTagsDrawer = ({ throw new Error('Error: contentId cannot be null.'); } - const context = useContentTagsDrawerContext(contentId, canTagObject); + const context = useContentTagsDrawerContext(contentId, !readOnly); const { blockingSheet } = useContext(ContentTagsDrawerSheetContext); const { @@ -308,9 +310,9 @@ const ContentTagsDrawer = ({ if (isTaxonomyListLoaded && isContentTaxonomyTagsLoaded) { switch (variant) { case 'drawer': - return ; + return ; case 'component': - return ; + return ; default: return null; } diff --git a/src/content-tags-drawer/ContentTagsDrawerSheet.jsx b/src/content-tags-drawer/ContentTagsDrawerSheet.jsx index f4661fa26c..25db618649 100644 --- a/src/content-tags-drawer/ContentTagsDrawerSheet.jsx +++ b/src/content-tags-drawer/ContentTagsDrawerSheet.jsx @@ -14,7 +14,7 @@ const ContentTagsDrawerSheet = ({ id, onClose, showSheet }) => { // ContentTagsDrawerSheet is only used when editing Courses/Course Units, // so we assume it's ok to edit the object tags too. - const canTagObject = true; + const readOnly = false; return ( @@ -27,7 +27,7 @@ const ContentTagsDrawerSheet = ({ id, onClose, showSheet }) => { diff --git a/src/course-outline/page-alerts/PageAlerts.test.jsx b/src/course-outline/page-alerts/PageAlerts.test.jsx index 21d2f74916..6d646f7cb4 100644 --- a/src/course-outline/page-alerts/PageAlerts.test.jsx +++ b/src/course-outline/page-alerts/PageAlerts.test.jsx @@ -1,6 +1,12 @@ import React from 'react'; import { useSelector } from 'react-redux'; -import { act, render, fireEvent } from '@testing-library/react'; +import { + act, + render, + fireEvent, + screen, + waitFor, +} from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; import { initializeMockApp, getConfig } from '@edx/frontend-platform'; @@ -84,7 +90,7 @@ describe('', () => { }); it('renders discussion alerts', async () => { - const { queryByText } = renderComponent({ + renderComponent({ ...pageAlertsData, discussionsSettings: { providerType: 'openedx', @@ -93,19 +99,21 @@ describe('', () => { discussionsIncontextLearnmoreUrl: 'some-learn-more-url', }); - expect(queryByText(messages.discussionNotificationText.defaultMessage)).toBeInTheDocument(); - const learnMoreBtn = queryByText(messages.discussionNotificationLearnMore.defaultMessage); + expect(screen.queryByText(messages.discussionNotificationText.defaultMessage)).toBeInTheDocument(); + const learnMoreBtn = screen.queryByText(messages.discussionNotificationLearnMore.defaultMessage); expect(learnMoreBtn).toBeInTheDocument(); expect(learnMoreBtn).toHaveAttribute('href', 'some-learn-more-url'); - const dismissBtn = queryByText('Dismiss'); - await act(async () => fireEvent.click(dismissBtn)); + const dismissBtn = screen.queryByText('Dismiss'); + fireEvent.click(dismissBtn); const discussionAlertDismissKey = `discussionAlertDismissed-${pageAlertsData.courseId}`; expect(localStorage.getItem(discussionAlertDismissKey)).toBe('true'); - const feedbackLink = queryByText(messages.discussionNotificationFeedback.defaultMessage); - expect(feedbackLink).toBeInTheDocument(); - expect(feedbackLink).toHaveAttribute('href', 'some-feedback-url'); + await waitFor(() => { + const feedbackLink = screen.queryByText(messages.discussionNotificationFeedback.defaultMessage); + expect(feedbackLink).toBeInTheDocument(); + expect(feedbackLink).toHaveAttribute('href', 'some-feedback-url'); + }); }); it('renders deprecation warning alerts', async () => { diff --git a/src/generic/block-type-utils/index.scss b/src/generic/block-type-utils/index.scss index c68dfbee49..713509c0ba 100644 --- a/src/generic/block-type-utils/index.scss +++ b/src/generic/block-type-utils/index.scss @@ -1,7 +1,7 @@ .component-style-default { background-color: #005C9E; - .pgn__icon { + .pgn__icon:not(.btn-icon-before) { color: white; } @@ -10,12 +10,23 @@ background-color: darken(#005C9E, 15%); } } + + .btn { + background-color: lighten(#005C9E, 10%); + border: 0; + + &:hover, &:active, &:focus { + background-color: lighten(#005C9E, 20%); + border: 1px solid $primary; + margin: -1px; + } + } } .component-style-html { background-color: #9747FF; - .pgn__icon { + .pgn__icon:not(.btn-icon-before) { color: white; } @@ -24,12 +35,23 @@ background-color: darken(#9747FF, 15%); } } + + .btn { + background-color: lighten(#9747FF, 10%); + border: 0; + + &:hover, &:active, &:focus { + background-color: lighten(#9747FF, 20%); + border: 1px solid $primary; + margin: -1px; + } + } } .component-style-collection { background-color: #FFCD29; - .pgn__icon { + .pgn__icon:not(.btn-icon-before) { color: black; } @@ -38,12 +60,23 @@ background-color: darken(#FFCD29, 15%); } } + + .btn { + background-color: lighten(#FFCD29, 10%); + border: 0; + + &:hover, &:active, &:focus { + background-color: lighten(#FFCD29, 20%); + border: 1px solid $primary; + margin: -1px; + } + } } .component-style-video { background-color: #358F0A; - .pgn__icon { + .pgn__icon:not(.btn-icon-before) { color: white; } @@ -52,12 +85,23 @@ background-color: darken(#358F0A, 15%); } } + + .btn { + background-color: lighten(#358F0A, 10%); + border: 0; + + &:hover, &:active, &:focus { + background-color: lighten(#358F0A, 20%); + border: 1px solid $primary; + margin: -1px; + } + } } .component-style-vertical { background-color: #0B8E77; - .pgn__icon { + .pgn__icon:not(.btn-icon-before) { color: white; } @@ -66,12 +110,23 @@ background-color: darken(#0B8E77, 15%); } } + + .btn { + background-color: lighten(#0B8E77, 10%); + border: 0; + + &:hover, &:active, &:focus { + background-color: lighten(#0B8E77, 20%); + border: 1px solid $primary; + margin: -1px; + } + } } .component-style-other { background-color: #646464; - .pgn__icon { + .pgn__icon:not(.btn-icon-before) { color: white; } @@ -80,4 +135,15 @@ background-color: darken(#646464, 15%); } } + + .btn { + background-color: lighten(#646464, 10%); + border: 0; + + &:hover, &:active, &:focus { + background-color: lighten(#646464, 20%); + border: 1px solid $primary; + margin: -1px; + } + } } diff --git a/src/index.jsx b/src/index.jsx index 150f41dffd..29d0a9ac5e 100755 --- a/src/index.jsx +++ b/src/index.jsx @@ -16,7 +16,7 @@ import { initializeHotjar } from '@edx/frontend-enterprise-hotjar'; import { logError } from '@edx/frontend-platform/logging'; import messages from './i18n'; -import { CreateLibrary, LibraryLayout } from './library-authoring'; +import { ComponentPicker, CreateLibrary, LibraryLayout } from './library-authoring'; import initializeStore from './store'; import CourseAuthoringRoutes from './CourseAuthoringRoutes'; import Head from './head/Head'; @@ -55,6 +55,7 @@ const App = () => { } /> } /> } /> + } /> } /> } /> {getConfig().ENABLE_ACCESSIBILITY_PAGE === 'true' && ( diff --git a/src/library-authoring/EmptyStates.tsx b/src/library-authoring/EmptyStates.tsx index 9470f0ad5c..71297926de 100644 --- a/src/library-authoring/EmptyStates.tsx +++ b/src/library-authoring/EmptyStates.tsx @@ -6,7 +6,6 @@ import { import { Add } from '@openedx/paragon/icons'; import { ClearFiltersButton } from '../search-manager'; import messages from './messages'; -import { useContentLibrary } from './data/apiHooks'; import { useLibraryContext } from './common/context'; export const NoComponents = ({ @@ -18,14 +17,12 @@ export const NoComponents = ({ addBtnText?: MessageDescriptor; handleBtnClick: () => void; }) => { - const { libraryId } = useLibraryContext(); - const { data: libraryData } = useContentLibrary(libraryId); - const canEditLibrary = libraryData?.canEditLibrary ?? false; + const { readOnly } = useLibraryContext(); return ( - {canEditLibrary && ( + {!readOnly && ( diff --git a/src/library-authoring/LibraryAuthoringPage.test.tsx b/src/library-authoring/LibraryAuthoringPage.test.tsx index ab3f67d303..981a8af60c 100644 --- a/src/library-authoring/LibraryAuthoringPage.test.tsx +++ b/src/library-authoring/LibraryAuthoringPage.test.tsx @@ -519,7 +519,7 @@ describe('', () => { expect(showProbTypesSubmenuBtn).not.toBeNull(); fireEvent.click(showProbTypesSubmenuBtn!); - const validateSubmenu = async (submenuText : string) => { + const validateSubmenu = async (submenuText: string) => { const submenu = screen.getByText(submenuText); expect(submenu).toBeInTheDocument(); fireEvent.click(submenu); diff --git a/src/library-authoring/LibraryAuthoringPage.tsx b/src/library-authoring/LibraryAuthoringPage.tsx index 816f13b1c4..786c976196 100644 --- a/src/library-authoring/LibraryAuthoringPage.tsx +++ b/src/library-authoring/LibraryAuthoringPage.tsx @@ -1,19 +1,24 @@ -import React, { useEffect } from 'react'; +import { useEffect, useState } from 'react'; import { Helmet } from 'react-helmet'; import classNames from 'classnames'; import { StudioFooter } from '@edx/frontend-component-footer'; import { useIntl } from '@edx/frontend-platform/i18n'; import { Badge, + Breadcrumb, Button, Container, + Icon, Stack, Tab, Tabs, } from '@openedx/paragon'; -import { Add, InfoOutline } from '@openedx/paragon/icons'; +import { Add, ArrowBack, InfoOutline } from '@openedx/paragon/icons'; import { - Routes, Route, useLocation, useNavigate, useSearchParams, + Link, + useLocation, + useNavigate, + useSearchParams, } from 'react-router-dom'; import Loading from '../generic/Loading'; @@ -31,7 +36,6 @@ import { import LibraryComponents from './components/LibraryComponents'; import LibraryCollections from './collections/LibraryCollections'; import LibraryHome from './LibraryHome'; -import { useContentLibrary } from './data/apiHooks'; import { LibrarySidebar } from './library-sidebar'; import { SidebarBodyComponentId, useLibraryContext } from './common/context'; import messages from './messages'; @@ -42,23 +46,33 @@ enum TabList { collections = 'collections', } -interface HeaderActionsProps { - canEditLibrary: boolean; +interface TabContentProps { + eventKey: string; + handleTabChange: (key: string) => void; } -const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => { +const TabContent = ({ eventKey, handleTabChange }: TabContentProps) => { + switch (eventKey) { + case TabList.components: + return ; + case TabList.collections: + return ; + default: + return ; + } +}; + +const HeaderActions = () => { const intl = useIntl(); const { + componentPickerMode, openAddContentSidebar, openInfoSidebar, closeLibrarySidebar, sidebarBodyComponent, + readOnly, } = useLibraryContext(); - if (!canEditLibrary) { - return null; - } - const infoSidebarIsOpen = () => ( sidebarBodyComponent === SidebarBodyComponentId.Info ); @@ -84,26 +98,32 @@ const HeaderActions = ({ canEditLibrary }: HeaderActionsProps) => { > {intl.formatMessage(messages.libraryInfoButton)} - + {!componentPickerMode && ( + + )} ); }; -const SubHeaderTitle = ({ title, canEditLibrary }: { title: string, canEditLibrary: boolean }) => { +const SubHeaderTitle = ({ title }: { title: string }) => { const intl = useIntl(); + const { readOnly, componentPickerMode } = useLibraryContext(); + + const showReadOnlyBadge = readOnly && !componentPickerMode; + return ( {title} - { !canEditLibrary && ( + {showReadOnlyBadge && (
{intl.formatMessage(messages.readOnlyBadge)} @@ -114,64 +134,109 @@ const SubHeaderTitle = ({ title, canEditLibrary }: { title: string, canEditLibra ); }; -const LibraryAuthoringPage = () => { +interface LibraryAuthoringPageProps { + returnToLibrarySelection?: () => void, +} + +const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPageProps) => { const intl = useIntl(); const location = useLocation(); const navigate = useNavigate(); - const { libraryId } = useLibraryContext(); - const { data: libraryData, isLoading } = useContentLibrary(libraryId); - - const currentPath = location.pathname.split('/').pop(); - const activeKey = (currentPath && currentPath in TabList) ? TabList[currentPath] : TabList.home; const { + libraryId, + libraryData, + isLoadingLibraryData, + componentPickerMode, sidebarBodyComponent, openInfoSidebar, } = useLibraryContext(); + const [activeKey, setActiveKey] = useState(''); + useEffect(() => { - openInfoSidebar(); + const currentPath = location.pathname.split('/').pop(); + + if (componentPickerMode || currentPath === libraryId || currentPath === '') { + setActiveKey(TabList.home); + } else if (currentPath && currentPath in TabList) { + setActiveKey(TabList[currentPath]); + } + }, [location.pathname]); + + useEffect(() => { + if (!componentPickerMode) { + openInfoSidebar(); + } }, []); const [searchParams] = useSearchParams(); - if (isLoading) { + if (isLoadingLibraryData) { return ; } + // istanbul ignore if: this should never happen + if (activeKey === undefined) { + return ; + } + if (!libraryData) { return ; } const handleTabChange = (key: string) => { - navigate({ - pathname: key, - search: searchParams.toString(), - }); + setActiveKey(key); + if (!componentPickerMode) { + navigate({ + pathname: key, + search: searchParams.toString(), + }); + } }; + const breadcumbs = componentPickerMode ? ( + } + linkAs={Link} + /> + ) : undefined; + return (
{libraryData.title} | {process.env.SITE_NAME} -
+ {!componentPickerMode && ( +
+ )} } - subtitle={intl.formatMessage(messages.headingSubtitle)} - headerActions={} + title={} + subtitle={!componentPickerMode ? intl.formatMessage(messages.headingSubtitle) : undefined} + breadcrumbs={breadcumbs} + headerActions={} />
@@ -191,33 +256,14 @@ const LibraryAuthoringPage = () => { - - - )} - /> - } - /> - } - /> - } - /> - + - + {!componentPickerMode && }
- { !!sidebarBodyComponent && ( + {!!sidebarBodyComponent && (
- +
)}
diff --git a/src/library-authoring/LibraryLayout.tsx b/src/library-authoring/LibraryLayout.tsx index 653e98cf11..82c268603c 100644 --- a/src/library-authoring/LibraryLayout.tsx +++ b/src/library-authoring/LibraryLayout.tsx @@ -2,6 +2,7 @@ import { Route, Routes, useParams, + useMatch, } from 'react-router-dom'; import LibraryAuthoringPage from './LibraryAuthoringPage'; @@ -14,13 +15,17 @@ import { ComponentEditorModal } from './components/ComponentEditorModal'; const LibraryLayout = () => { const { libraryId } = useParams(); + const match = useMatch('/library/:libraryId/collection/:collectionId'); + + const collectionId = match?.params.collectionId; + if (libraryId === undefined) { // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker. throw new Error('Error: route is missing libraryId.'); } return ( - + void; +mockContentLibrary.applyMock(); mockGetCollectionMetadata.applyMock(); mockContentSearchConfig.applyMock(); mockGetBlockTypes.applyMock(); @@ -26,6 +28,14 @@ const { description: originalDescription } = mockGetCollectionMetadata.collectio const library = mockContentLibrary.libraryData; +const render = () => baseRender(, { + extraWrapper: ({ children }) => ( + + { children } + + ), +}); + describe('', () => { beforeEach(() => { const mocks = initializeMocks(); @@ -38,7 +48,7 @@ describe('', () => { }); it('should render Collection Details', async () => { - render(); + render(); // Collection Description expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); @@ -53,7 +63,7 @@ describe('', () => { }); it('should allow modifying the description', async () => { - render(); + render(); expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); expect(screen.getByText(originalDescription)).toBeInTheDocument(); @@ -87,7 +97,7 @@ describe('', () => { }); it('should show error while modifing the description', async () => { - render(); + render(); expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); expect(screen.getByText(originalDescription)).toBeInTheDocument(); @@ -112,7 +122,7 @@ describe('', () => { it('should render Collection stats', async () => { mockGetBlockTypes('someBlocks'); - render(); + render(); expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); expect(screen.getByText('Collection Stats')).toBeInTheDocument(); @@ -131,7 +141,7 @@ describe('', () => { it('should render Collection stats for empty collection', async () => { mockGetBlockTypes('noBlocks'); - render(); + render(); expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); expect(screen.getByText('Collection Stats')).toBeInTheDocument(); @@ -140,7 +150,7 @@ describe('', () => { it('should render Collection stats for big collection', async () => { mockGetBlockTypes('moreBlocks'); - render(); + render(); expect(await screen.findByText('Description / Card Preview Text')).toBeInTheDocument(); expect(screen.getByText('Collection Stats')).toBeInTheDocument(); diff --git a/src/library-authoring/collections/CollectionDetails.tsx b/src/library-authoring/collections/CollectionDetails.tsx index 2d58fd4ee2..73f9f69593 100644 --- a/src/library-authoring/collections/CollectionDetails.tsx +++ b/src/library-authoring/collections/CollectionDetails.tsx @@ -6,7 +6,7 @@ import classNames from 'classnames'; import { getItemIcon } from '../../generic/block-type-utils'; import { ToastContext } from '../../generic/toast-context'; import { BlockTypeLabel, useGetBlockTypes } from '../../search-manager'; -import type { ContentLibrary } from '../data/api'; +import { useLibraryContext } from '../common/context'; import { useCollection, useUpdateCollection } from '../data/apiHooks'; import HistoryWidget from '../generic/history-widget'; import messages from './messages'; @@ -36,12 +36,9 @@ const BlockCount = ({ ); }; -interface CollectionStatsWidgetProps { - libraryId: string, - collectionId: string, -} +const CollectionStatsWidget = () => { + const { libraryId, sidebarCollectionId: collectionId } = useLibraryContext(); -const CollectionStatsWidget = ({ libraryId, collectionId }: CollectionStatsWidgetProps) => { const { data: blockTypes } = useGetBlockTypes([ `context_key = "${libraryId}"`, `collections.key = "${collectionId}"`, @@ -96,17 +93,22 @@ const CollectionStatsWidget = ({ libraryId, collectionId }: CollectionStatsWidge ); }; -interface CollectionDetailsProps { - library: ContentLibrary, - collectionId: string, -} - -const CollectionDetails = ({ library, collectionId }: CollectionDetailsProps) => { +const CollectionDetails = () => { const intl = useIntl(); const { showToast } = useContext(ToastContext); + const { + libraryId, + sidebarCollectionId: collectionId, + readOnly, + } = useLibraryContext(); + + // istanbul ignore next: This should never happen + if (!collectionId) { + throw new Error('collectionId is required'); + } - const updateMutation = useUpdateCollection(library.id, collectionId); - const { data: collection } = useCollection(library.id, collectionId); + const updateMutation = useUpdateCollection(libraryId, collectionId); + const { data: collection } = useCollection(libraryId, collectionId); const [description, setDescription] = useState(collection?.description || ''); @@ -142,7 +144,7 @@ const CollectionDetails = ({ library, collectionId }: CollectionDetailsProps) =>

{intl.formatMessage(messages.detailsTabDescriptionTitle)}

- {library.canEditLibrary ? ( + {!readOnly ? (