Skip to content

Commit

Permalink
feat: adds sharable URLs for library components/collections
Browse files Browse the repository at this point in the history
* Restructure LibraryLayout so that LibraryContext can (optionally)
  useParams() to initialize its componentId/collectionId instead of
  having to parse route strings.

  This behavior can be disabled for the content pickers by passing
  initializeFromUrl={false} to the LibraryContext.

* Add useLibraryRoutes() hook so components can easily navigate to the
  best available route without having to know the route strings or
  maintain search params.

  Also moved ContentType declaration to the new routes.ts to avoid
  circular imports.

* Clicking/selecting a ComponentCard/CollectionCard navigates to an
  appropriate component/collection route given the current page.

* Rename openInfoSidebar to openLibrarySidebar, so that openInfoSidebar
  can be used to open the best sidebar for a given
  library/component/collection.
  • Loading branch information
pomegranited committed Dec 19, 2024
1 parent a4728e1 commit 5fad981
Show file tree
Hide file tree
Showing 16 changed files with 280 additions and 103 deletions.
58 changes: 30 additions & 28 deletions src/library-authoring/LibraryAuthoringPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,7 @@ import {
Tabs,
} from '@openedx/paragon';
import { Add, ArrowBack, InfoOutline } from '@openedx/paragon/icons';
import {
Link,
useLocation,
useNavigate,
useSearchParams,
} from 'react-router-dom';
import { Link } from 'react-router-dom';

import Loading from '../generic/Loading';
import SubHeader from '../generic/sub-header/SubHeader';
Expand All @@ -35,11 +30,12 @@ import {
SearchKeywordsField,
SearchSortWidget,
} from '../search-manager';
import LibraryContent, { ContentType } from './LibraryContent';
import LibraryContent from './LibraryContent';
import { LibrarySidebar } from './library-sidebar';
import { useComponentPickerContext } from './common/context/ComponentPickerContext';
import { useLibraryContext } from './common/context/LibraryContext';
import { SidebarBodyComponentId, useSidebarContext } from './common/context/SidebarContext';
import { ContentType, useLibraryRoutes } from './routes';

import messages from './messages';

Expand All @@ -50,7 +46,7 @@ const HeaderActions = () => {

const {
openAddContentSidebar,
openInfoSidebar,
openLibrarySidebar,
closeLibrarySidebar,
sidebarComponentInfo,
} = useSidebarContext();
Expand All @@ -61,11 +57,15 @@ const HeaderActions = () => {
sidebarComponentInfo?.type === SidebarBodyComponentId.Info
);

const { navigateTo } = useLibraryRoutes();
const handleOnClickInfoSidebar = () => {
// Reset URL to library home
navigateTo();

if (infoSidebarIsOpen()) {
closeLibrarySidebar();
} else {
openInfoSidebar();
openLibrarySidebar();
}
};

Expand Down Expand Up @@ -125,8 +125,6 @@ interface LibraryAuthoringPageProps {

const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPageProps) => {
const intl = useIntl();
const location = useLocation();
const navigate = useNavigate();

const {
isLoadingPage: isLoadingStudioHome,
Expand All @@ -140,29 +138,41 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
libraryData,
isLoadingLibraryData,
showOnlyPublished,
componentId,
collectionId,
} = useLibraryContext();
const { openInfoSidebar, sidebarComponentInfo } = useSidebarContext();

const [activeKey, setActiveKey] = useState<ContentType | undefined>(ContentType.home);
const { insideCollections, insideComponents, navigateTo } = useLibraryRoutes();

// The activeKey determines the currently selected tab.
const [activeKey, setActiveKey] = useState<ContentType>(ContentType.home);
const getActiveKey = () => {
if (insideCollections) {
return ContentType.collections;
}
if (insideComponents) {
return ContentType.components;
}
return ContentType.home;
};

useEffect(() => {
const currentPath = location.pathname.split('/').pop();
const contentType = getActiveKey();

if (componentPickerMode || currentPath === libraryId || currentPath === '') {
if (componentPickerMode) {
setActiveKey(ContentType.home);
} else if (currentPath && currentPath in ContentType) {
setActiveKey(ContentType[currentPath]);
} else {
setActiveKey(contentType);
}
}, []);

useEffect(() => {
if (!componentPickerMode) {
openInfoSidebar();
openInfoSidebar(componentId, collectionId);
}
}, []);

const [searchParams] = useSearchParams();

if (isLoadingLibraryData) {
return <Loading />;
}
Expand All @@ -175,22 +185,14 @@ const LibraryAuthoringPage = ({ returnToLibrarySelection }: LibraryAuthoringPage
);
}

// istanbul ignore if: this should never happen
if (activeKey === undefined) {
return <NotFoundAlert />;
}

if (!libraryData) {
return <NotFoundAlert />;
}

const handleTabChange = (key: ContentType) => {
setActiveKey(key);
if (!componentPickerMode) {
navigate({
pathname: key,
search: searchParams.toString(),
});
navigateTo({ contentType: key });
}
};

Expand Down
7 changes: 1 addition & 6 deletions src/library-authoring/LibraryContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,15 +6,10 @@ import { useLibraryContext } from './common/context/LibraryContext';
import { useSidebarContext } from './common/context/SidebarContext';
import CollectionCard from './components/CollectionCard';
import ComponentCard from './components/ComponentCard';
import { ContentType } from './routes';
import { useLoadOnScroll } from '../hooks';
import messages from './collections/messages';

export enum ContentType {
home = '',
components = 'components',
collections = 'collections',
}

/**
* Library Content to show content grid
*
Expand Down
60 changes: 38 additions & 22 deletions src/library-authoring/LibraryLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useCallback } from 'react';
import {
Route,
Routes,
useParams,
useMatch,
useLocation,
} from 'react-router-dom';

import { ROUTES } from './routes';
import LibraryAuthoringPage from './LibraryAuthoringPage';
import { LibraryProvider } from './common/context/LibraryContext';
import { SidebarProvider } from './common/context/SidebarContext';
Expand All @@ -16,43 +18,57 @@ 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 (
const location = useLocation();
const context = useCallback((childPage) => (
<LibraryProvider
/** We need to pass the collectionId as key to the LibraryProvider to force a re-render
* when we navigate to a collection page. */
key={collectionId}
/** We need to pass the pathname as key to the LibraryProvider to force a
* re-render when we navigate to a new path or page. */
key={location.pathname}
libraryId={libraryId}
collectionId={collectionId}
/** 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 */
componentPicker={ComponentPicker}
>
<SidebarProvider>
<Routes>
<Route
path="collection/:collectionId"
element={<LibraryCollectionPage />}
/>
<Route
path="*"
element={<LibraryAuthoringPage />}
/>
</Routes>
<CreateCollectionModal />
<ComponentEditorModal />
<>
{childPage}
<CreateCollectionModal />
<ComponentEditorModal />
</>
</SidebarProvider>
</LibraryProvider>
), [location.pathname]);

return (
<Routes>
<Route
path={ROUTES.COMPONENTS}
element={context(<LibraryAuthoringPage />)}
/>
<Route
path={ROUTES.COLLECTIONS}
element={context(<LibraryAuthoringPage />)}
/>
<Route
path={ROUTES.COMPONENT}
element={context(<LibraryAuthoringPage />)}
/>
<Route
path={ROUTES.COLLECTION}
element={context(<LibraryCollectionPage />)}
/>
<Route
path={ROUTES.HOME}
element={context(<LibraryAuthoringPage />)}
/>
</Routes>
);
};

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,17 +25,13 @@ jest.mock('frontend-components-tinymce-advanced-plugins', () => ({ a11ycheckerCs

const { libraryId } = mockContentLibrary;
const render = (collectionId?: string) => {
const params: { libraryId: string, collectionId?: string } = { libraryId };
if (collectionId) {
params.collectionId = collectionId;
}
const params: { libraryId: string, collectionId?: string } = { libraryId, collectionId };
return baseRender(<AddContentContainer />, {
path: '/library/:libraryId/*',
path: '/library/:libraryId/:collectionId?',
params,
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
collectionId={collectionId}
>
{ children }
<ComponentEditorModal />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,6 @@ const render = () => baseRender(<PickLibraryContentModal isOpen onClose={onClose
extraWrapper: ({ children }) => (
<LibraryProvider
libraryId={libraryId}
collectionId="collectionId"
componentPicker={ComponentPicker}
>
{children}
Expand Down
27 changes: 12 additions & 15 deletions src/library-authoring/collections/CollectionInfo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import {
Tabs,
} from '@openedx/paragon';
import { useCallback } from 'react';
import { useNavigate, useMatch } from 'react-router-dom';

import { useComponentPickerContext } from '../common/context/ComponentPickerContext';
import { useLibraryContext } from '../common/context/LibraryContext';
Expand All @@ -17,43 +16,41 @@ import {
isCollectionInfoTab,
useSidebarContext,
} from '../common/context/SidebarContext';
import { useLibraryRoutes } from '../routes';
import { ContentTagsDrawer } from '../../content-tags-drawer';
import { buildCollectionUsageKey } from '../../generic/key-utils';
import CollectionDetails from './CollectionDetails';
import messages from './messages';

const CollectionInfo = () => {
const intl = useIntl();
const navigate = useNavigate();

const { componentPickerMode } = useComponentPickerContext();
const { libraryId, collectionId, setCollectionId } = useLibraryContext();
const { libraryId, setCollectionId } = useLibraryContext();
const { sidebarComponentInfo, setSidebarCurrentTab } = useSidebarContext();

const tab: CollectionInfoTab = (
sidebarComponentInfo?.currentTab && isCollectionInfoTab(sidebarComponentInfo.currentTab)
) ? sidebarComponentInfo?.currentTab : COLLECTION_INFO_TABS.Manage;

const sidebarCollectionId = sidebarComponentInfo?.id;
const collectionId = sidebarComponentInfo?.id;
// istanbul ignore if: this should never happen
if (!sidebarCollectionId) {
throw new Error('sidebarCollectionId is required');
if (!collectionId) {
throw new Error('collectionId is required');
}

const url = `/library/${libraryId}/collection/${sidebarCollectionId}`;
const urlMatch = useMatch(url);
const collectionUsageKey = buildCollectionUsageKey(libraryId, collectionId);

const showOpenCollectionButton = !urlMatch && collectionId !== sidebarCollectionId;

const collectionUsageKey = buildCollectionUsageKey(libraryId, sidebarCollectionId);
const { insideCollection, navigateTo } = useLibraryRoutes();
const showOpenCollectionButton = !insideCollection || componentPickerMode;

const handleOpenCollection = useCallback(() => {
if (!componentPickerMode) {
navigate(url);
if (componentPickerMode) {
setCollectionId(collectionId);
} else {
setCollectionId(sidebarCollectionId);
navigateTo({ collectionId });
}
}, [componentPickerMode, url]);
}, [componentPickerMode, navigateTo]);

return (
<Stack>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import { NoComponents, NoSearchResults } from '../EmptyStates';
import { useSearchContext } from '../../search-manager';
import messages from './messages';
import { useSidebarContext } from '../common/context/SidebarContext';
import LibraryContent, { ContentType } from '../LibraryContent';
import LibraryContent from '../LibraryContent';
import { ContentType } from '../routes';

const LibraryCollectionComponents = () => {
const { totalHits: componentCount, isFiltered } = useSearchContext();
Expand Down
Loading

0 comments on commit 5fad981

Please sign in to comment.