feat: course libraries page [FC-0076] (#1641)

Adds Libraries page that lists all library components being used in the current course to Content > Libraries
This commit is contained in:
Navin Karkera
2025-02-18 15:36:31 +00:00
committed by GitHub
parent 59243b0cb3
commit 8275bbe8ce
17 changed files with 1283 additions and 1 deletions

View File

@@ -25,6 +25,7 @@ import CourseImportPage from './import-page/CourseImportPage';
import { DECODED_ROUTES } from './constants';
import CourseChecklist from './course-checklist';
import GroupConfigurations from './group-configurations';
import CourseLibraries from './course-libraries';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:
@@ -56,6 +57,10 @@ const CourseAuthoringRoutes = () => {
path="course_info"
element={<PageWrap><CourseUpdates courseId={courseId} /></PageWrap>}
/>
<Route
path="libraries"
element={<PageWrap><CourseLibraries courseId={courseId} /></PageWrap>}
/>
<Route
path="assets"
element={<PageWrap><FilesPage courseId={courseId} /></PageWrap>}

View File

@@ -0,0 +1,148 @@
import fetchMock from 'fetch-mock-jest';
import { cloneDeep } from 'lodash';
import userEvent from '@testing-library/user-event';
import {
initializeMocks,
render,
screen,
within,
} from '../testUtils';
import { mockContentSearchConfig } from '../search-manager/data/api.mock';
import mockInfoResult from './__mocks__/courseBlocksInfo.json';
import CourseLibraries from './CourseLibraries';
import { mockGetEntityLinksByDownstreamContext } from './data/api.mocks';
mockContentSearchConfig.applyMock();
mockGetEntityLinksByDownstreamContext.applyMock();
const searchEndpoint = 'http://mock.meilisearch.local/indexes/studio/search';
jest.mock('../studio-home/hooks', () => ({
useStudioHome: () => ({
isLoadingPage: false,
isFailedLoadingPage: false,
librariesV2Enabled: true,
}),
}));
describe('<CourseLibraries />', () => {
beforeEach(() => {
initializeMocks();
fetchMock.mockReset();
// The Meilisearch client-side API uses fetch, not Axios.
fetchMock.post(searchEndpoint, (_url, req) => {
const requestData = JSON.parse(req.body?.toString() ?? '');
const filter = requestData?.filter[1];
const mockInfoResultCopy = cloneDeep(mockInfoResult);
const resp = mockInfoResultCopy.filter((o: { filter: string }) => o.filter === filter)[0] || {
result: {
hits: [],
query: '',
processingTimeMs: 0,
limit: 4,
offset: 0,
estimatedTotalHits: 0,
},
};
const { result } = resp;
return result;
});
});
const renderCourseLibrariesPage = async (courseKey?: string) => {
const courseId = courseKey || mockGetEntityLinksByDownstreamContext.courseKey;
render(<CourseLibraries courseId={courseId} />);
};
it('shows the spinner before the query is complete', async () => {
// This mock will never return data (it loads forever):
await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKeyLoading);
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
it('shows empty state wheen no links are present', async () => {
await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKeyEmpty);
const emptyMsg = await screen.findByText('This course does not use any content from libraries.');
expect(emptyMsg).toBeInTheDocument();
});
it('shows alert when out of sync components are present', async () => {
await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKey);
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
'1 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
expect(allTab).toHaveAttribute('aria-selected', 'true');
const reviewBtn = await screen.findByRole('button', { name: 'Review' });
userEvent.click(reviewBtn);
expect(allTab).toHaveAttribute('aria-selected', 'false');
expect(await screen.findByRole('tab', { name: 'Review Content Updates (1)' })).toHaveAttribute('aria-selected', 'true');
expect(alert).not.toBeInTheDocument();
// go back to all tab
userEvent.click(allTab);
// alert should not be back
expect(alert).not.toBeInTheDocument();
expect(allTab).toHaveAttribute('aria-selected', 'true');
// review updates button
const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' });
userEvent.click(reviewActionBtn);
expect(await screen.findByRole('tab', { name: 'Review Content Updates (1)' })).toHaveAttribute('aria-selected', 'true');
});
it('hide alert on dismiss', async () => {
await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKey);
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
'1 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
userEvent.click(dismissBtn);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
expect(allTab).toHaveAttribute('aria-selected', 'true');
expect(alert).not.toBeInTheDocument();
});
it('shows links split by library', async () => {
await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKey);
const msg = await screen.findByText('This course contains content from these libraries.');
expect(msg).toBeInTheDocument();
const allButtons = await screen.findAllByRole('button');
// total 3 components used from lib 1
const expectedLib1Blocks = 3;
// total 4 components used from lib 1
const expectedLib2Blocks = 4;
// 1 component has updates.
const expectedLib2ToUpdate = 1;
const libraryCards = allButtons.filter((el) => el.classList.contains('collapsible-trigger'));
expect(libraryCards.length).toEqual(2);
expect(await within(libraryCards[0]).findByText('CS problems 2')).toBeInTheDocument();
expect(await within(libraryCards[0]).findByText(`${expectedLib1Blocks} components applied`)).toBeInTheDocument();
expect(await within(libraryCards[0]).findByText('All components up to date')).toBeInTheDocument();
const libParent1 = libraryCards[0].parentElement;
expect(libParent1).not.toBeNull();
userEvent.click(libraryCards[0]);
const xblockCards1 = libParent1!.querySelectorAll('div.card');
expect(xblockCards1.length).toEqual(expectedLib1Blocks);
expect(await within(libraryCards[1]).findByText('CS problems 3')).toBeInTheDocument();
expect(await within(libraryCards[1]).findByText(`${expectedLib2Blocks} components applied`)).toBeInTheDocument();
expect(await within(libraryCards[1]).findByText(`${expectedLib2ToUpdate} component out of sync`)).toBeInTheDocument();
const libParent2 = libraryCards[1].parentElement;
expect(libParent2).not.toBeNull();
userEvent.click(libraryCards[1]);
const xblockCards2 = libParent2!.querySelectorAll('div.card');
expect(xblockCards2.length).toEqual(expectedLib2Blocks);
});
});

View File

@@ -0,0 +1,342 @@
import React, {
useCallback, useMemo, useState,
} from 'react';
import { Helmet } from 'react-helmet';
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import {
Alert,
Breadcrumb, Button, Card, Collapsible, Container, Dropdown, Hyperlink, Icon, IconButton, Layout, Stack, Tab, Tabs,
} from '@openedx/paragon';
import {
Cached, CheckCircle, KeyboardArrowDown, KeyboardArrowRight, Loop, MoreVert,
} from '@openedx/paragon/icons';
import {
countBy, groupBy, keyBy, tail, uniq,
} from 'lodash';
import classNames from 'classnames';
import getPageHeadTitle from '../generic/utils';
import { useModel } from '../generic/model-store';
import messages from './messages';
import SubHeader from '../generic/sub-header/SubHeader';
import { useEntityLinksByDownstreamContext } from './data/apiHooks';
import type { PublishableEntityLink } from './data/api';
import { useFetchIndexDocuments } from '../search-manager/data/apiHooks';
import { getItemIcon } from '../generic/block-type-utils';
import { BlockTypeLabel } from '../search-manager';
import AlertMessage from '../generic/alert-message';
import type { ContentHit } from '../search-manager/data/api';
import { SearchSortOption } from '../search-manager/data/api';
import Loading from '../generic/Loading';
import { useStudioHome } from '../studio-home/hooks';
interface Props {
courseId: string;
}
interface LibraryCardProps {
courseId: string;
title: string;
links: PublishableEntityLink[];
}
interface ComponentInfo extends ContentHit {
readyToSync: boolean;
}
interface BlockCardProps {
info: ComponentInfo;
}
export enum CourseLibraryTabs {
home = '',
review = 'review',
}
const BlockCard: React.FC<BlockCardProps> = ({ info }) => {
const intl = useIntl();
const componentIcon = getItemIcon(info.blockType);
const breadcrumbs = tail(info.breadcrumbs) as Array<{ displayName: string, usageKey: string }>;
const getBlockLink = useCallback(() => {
let key = info.usageKey;
if (breadcrumbs?.length > 1) {
key = breadcrumbs[breadcrumbs.length - 1].usageKey || key;
}
return `${getConfig().STUDIO_BASE_URL}/container/${key}`;
}, [info]);
return (
<Card
className={classNames(
'my-3 shadow-none border-light-600 border',
{ 'bg-primary-100': info.readyToSync },
)}
orientation="horizontal"
key={info.usageKey}
>
<Card.Section
className="py-2"
>
<Stack direction="vertical" gap={1}>
<Stack direction="horizontal" gap={1} className="micro text-gray-500">
<Icon src={componentIcon} size="xs" />
<BlockTypeLabel blockType={info.blockType} />
<Hyperlink className="lead ml-auto text-black" destination={getBlockLink()} target="_blank">
{' '}
</Hyperlink>
</Stack>
<Stack direction="horizontal" className="small" gap={1}>
{info.readyToSync && <Icon src={Loop} size="xs" />}
{info.formatted?.displayName}
</Stack>
<div className="micro">{info.formatted?.description}</div>
<Breadcrumb
className="micro text-gray-500"
ariaLabel={intl.formatMessage(messages.breadcrumbAriaLabel)}
links={breadcrumbs.map((breadcrumb) => ({ label: breadcrumb.displayName }))}
spacer={<span className="custom-spacer">/</span>}
linkAs="span"
/>
</Stack>
</Card.Section>
</Card>
);
};
const LibraryCard: React.FC<LibraryCardProps> = ({ courseId, title, links }) => {
const intl = useIntl();
const linksInfo = useMemo(() => keyBy(links, 'downstreamUsageKey'), [links]);
const totalComponents = links.length;
const outOfSyncCount = useMemo(() => countBy(links, 'readyToSync').true, [links]);
const downstreamKeys = useMemo(() => uniq(Object.keys(linksInfo)), [links]);
const { data: downstreamInfo } = useFetchIndexDocuments(
[`context_key = "${courseId}"`, `usage_key IN ["${downstreamKeys.join('","')}"]`],
downstreamKeys.length,
['usage_key', 'display_name', 'breadcrumbs', 'description', 'block_type'],
['description:30'],
[SearchSortOption.TITLE_AZ],
) as unknown as { data: ComponentInfo[] };
const renderBlockCards = (info: ComponentInfo) => {
// eslint-disable-next-line no-param-reassign
info.readyToSync = linksInfo[info.usageKey].readyToSync;
return <BlockCard info={info} key={info.usageKey} />;
};
return (
<Collapsible.Advanced>
<Collapsible.Trigger className="bg-white shadow px-2 py-2 my-3 collapsible-trigger d-flex font-weight-normal text-dark">
<Collapsible.Visible whenClosed>
<Icon src={KeyboardArrowRight} />
</Collapsible.Visible>
<Collapsible.Visible whenOpen>
<Icon src={KeyboardArrowDown} />
</Collapsible.Visible>
<Stack direction="vertical" className="flex-grow-1 pl-2 x-small" gap={1}>
<h4>{title}</h4>
<Stack direction="horizontal" gap={2}>
<span>
{intl.formatMessage(messages.totalComponentLabel, { totalComponents })}
</span>
<span>/</span>
{outOfSyncCount ? (
<>
<Icon src={Loop} size="xs" />
<span>
{intl.formatMessage(messages.outOfSyncCountLabel, { outOfSyncCount })}
</span>
</>
) : (
<>
<Icon src={CheckCircle} size="xs" />
<span>
{intl.formatMessage(messages.allUptodateLabel)}
</span>
</>
)}
</Stack>
</Stack>
<Dropdown onClick={(e: { stopPropagation: () => void; }) => e.stopPropagation()}>
<Dropdown.Toggle
id={`dropdown-toggle-${title}`}
alt="dropdown-toggle-menu-items"
as={IconButton}
src={MoreVert}
iconAs={Icon}
variant="primary"
disabled
/>
<Dropdown.Menu>
<Dropdown.Item>TODO 1</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
</Collapsible.Trigger>
<Collapsible.Body className="collapsible-body border-left border-left-purple px-2">
{downstreamInfo?.map(info => renderBlockCards(info))}
</Collapsible.Body>
</Collapsible.Advanced>
);
};
interface ReviewAlertProps {
show: boolean;
outOfSyncCount: number;
onDismiss: () => void;
onReview: () => void;
}
const ReviewAlert: React.FC<ReviewAlertProps> = ({
show, outOfSyncCount, onDismiss, onReview,
}) => {
const intl = useIntl();
return (
<AlertMessage
title={intl.formatMessage(messages.outOfSyncCountAlertTitle, { outOfSyncCount })}
dismissible
show={show}
icon={Loop}
variant="info"
onClose={onDismiss}
actions={[
<Button
onClick={onReview}
>
{intl.formatMessage(messages.outOfSyncCountAlertReviewBtn)}
</Button>,
]}
/>
);
};
const TabContent = ({ children }: { children: React.ReactNode }) => (
<Layout
lg={[{ span: 9 }, { span: 3 }]}
md={[{ span: 9 }, { span: 3 }]}
sm={[{ span: 12 }, { span: 12 }]}
xs={[{ span: 12 }, { span: 12 }]}
xl={[{ span: 9 }, { span: 3 }]}
>
<Layout.Element>
{children}
</Layout.Element>
<Layout.Element>
Help panel
</Layout.Element>
</Layout>
);
const CourseLibraries: React.FC<Props> = ({ courseId }) => {
const intl = useIntl();
const courseDetails = useModel('courseDetails', courseId);
const [tabKey, setTabKey] = useState<CourseLibraryTabs>(CourseLibraryTabs.home);
const [showReviewAlert, setShowReviewAlert] = useState(true);
const { data: links, isLoading } = useEntityLinksByDownstreamContext(courseId);
const linksByLib = useMemo(() => groupBy(links, 'upstreamContextKey'), [links]);
const outOfSyncCount = useMemo(() => countBy(links, 'readyToSync').true, [links]);
const {
isLoadingPage: isLoadingStudioHome,
isFailedLoadingPage: isFailedLoadingStudioHome,
librariesV2Enabled,
} = useStudioHome();
const onAlertReview = () => {
setTabKey(CourseLibraryTabs.review);
setShowReviewAlert(false);
};
const onAlertDismiss = () => {
setShowReviewAlert(false);
};
const renderLibrariesTabContent = useCallback(() => {
if (isLoading) {
return <Loading />;
}
if (links?.length === 0) {
return <small><FormattedMessage {...messages.homeTabDescriptionEmpty} /></small>;
}
return (
<>
<small><FormattedMessage {...messages.homeTabDescription} /></small>
{Object.entries(linksByLib).map(([libKey, libLinks]) => (
<LibraryCard
courseId={courseId}
title={libLinks[0].upstreamContextTitle}
links={libLinks}
key={libKey}
/>
))}
</>
);
}, [links, isLoading, linksByLib]);
if (!isLoadingStudioHome && (!librariesV2Enabled || isFailedLoadingStudioHome)) {
return (
<Alert variant="danger">
{intl.formatMessage(messages.librariesV2DisabledError)}
</Alert>
);
}
return (
<>
<Helmet>
<title>
{getPageHeadTitle(courseDetails?.name, intl.formatMessage(messages.headingTitle))}
</title>
</Helmet>
<Container size="xl" className="px-4 pt-4 mt-3">
<ReviewAlert
show={outOfSyncCount > 0 && tabKey === CourseLibraryTabs.home && showReviewAlert}
outOfSyncCount={outOfSyncCount}
onDismiss={onAlertDismiss}
onReview={onAlertReview}
/>
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={!showReviewAlert && tabKey === CourseLibraryTabs.home && (
<Button
variant="primary"
onClick={onAlertReview}
iconBefore={Cached}
>
{intl.formatMessage(messages.reviewUpdatesBtn)}
</Button>
)}
hideBorder
/>
<section className="mb-4">
<Tabs
id="course-library-tabs"
activeKey={tabKey}
onSelect={(k: CourseLibraryTabs) => setTabKey(k)}
>
<Tab
eventKey={CourseLibraryTabs.home}
title={intl.formatMessage(messages.homeTabTitle)}
className="px-2 mt-3"
>
<TabContent>
{renderLibrariesTabContent()}
</TabContent>
</Tab>
<Tab
eventKey={CourseLibraryTabs.review}
title={intl.formatMessage(
outOfSyncCount > 0 ? messages.reviewTabTitle : messages.reviewTabTitleEmpty,
{ count: outOfSyncCount },
)}
>
<TabContent>Help</TabContent>
</Tab>
</Tabs>
</section>
</Container>
</>
);
};
export default CourseLibraries;

View File

@@ -0,0 +1,374 @@
[
{
"filter": "usage_key IN [\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@5be06d70f09c45a581d973d215e7ad62\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@d1b006742e0745b0948c8ba8dcd9bc07\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@276914b632eb49049b2c2568ef24fe4d\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@problem+block@fcf430ac5b7a44b6ab316b53a7e67fef\"]",
"result": {
"hits": [
{
"display_name": "Dropdown",
"description": "asfd sdaf afd",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@problem+block@fcf430ac5b7a44b6ab316b53a7e67fef",
"block_type": "problem",
"_formatted": {
"display_name": "Dropdown",
"description": "asfd sdaf afd",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@problem+block@fcf430ac5b7a44b6ab316b53a7e67fef",
"block_type": "problem"
}
},
{
"display_name": "HTML 12",
"description": "A step beyond the simplicity of the WYSIWYG editor is the Raw HTML component which allows you to use HTML, CSS, and Javascript to create highly flexible content that looks good, excites your learners, and keeps them engaged and coming back to your courses. Unlike many learning platforms, the Open edX platform does not restrict authors in terms of what can be used in HTML components. If you can build it, the platform will not stop you. But use judgement and consider consulting with a web design expert. When used correctly, custom HTML, CSS and Javascript can be powerful tools for a course developer. But they can also lead to experiences that are inaccessible, unpleasant or even insecure to the learner and the provider. 1",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@5be06d70f09c45a581d973d215e7ad62",
"block_type": "html",
"_formatted": {
"display_name": "HTML 12",
"description": "A step beyond the simplicity of the WYSIWYG editor is the Raw HTML component which allows you to use HTML, CSS, and Javascript to create highly flexible content that looks…",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@5be06d70f09c45a581d973d215e7ad62",
"block_type": "html"
}
},
{
"display_name": "Text",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@d1b006742e0745b0948c8ba8dcd9bc07",
"block_type": "html",
"_formatted": {
"display_name": "Text",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@d1b006742e0745b0948c8ba8dcd9bc07",
"block_type": "html"
}
},
{
"display_name": "Text",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@276914b632eb49049b2c2568ef24fe4d",
"block_type": "html",
"_formatted": {
"display_name": "Text",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@eb4f4db1032f46fdad5a7a9ebde12698"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@eaea16cbcf2d45ba98ddfe72631b9734"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@itembank+block@abca641f6a19447c83e64730808d36d3"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@276914b632eb49049b2c2568ef24fe4d",
"block_type": "html"
}
}
],
"query": "",
"processingTimeMs": 0,
"limit": 4,
"offset": 0,
"estimatedTotalHits": 4
}
},
{
"filter": "usage_key IN [\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@0dfbabb8c93140caa3a004ab621757f5\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@37a916b53c2f430989d33ddeb47e224d\",\"block-v1:NewOrg3+TSTCS+2025_T1+type@video+block@33518787c3e14844ad823af8ff158a83\"]",
"result": {
"hits": [
{
"display_name": "Edited title",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@05da683dc74e405ca355c6b90d58ad6e"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@video+block@33518787c3e14844ad823af8ff158a83",
"block_type": "video",
"_formatted": {
"display_name": "Edited title",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@05da683dc74e405ca355c6b90d58ad6e"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@video+block@33518787c3e14844ad823af8ff158a83",
"block_type": "video"
}
},
{
"display_name": "Text 1",
"description": " 8¹⁺² 3² Accept change now!d",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@bab96d80267041138975e91a96e20f35"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@0dfbabb8c93140caa3a004ab621757f5",
"block_type": "html",
"_formatted": {
"display_name": "Text 1",
"description": " 8¹⁺² 3² Accept change now!d",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@bab96d80267041138975e91a96e20f35"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@0dfbabb8c93140caa3a004ab621757f5",
"block_type": "html"
}
},
{
"display_name": "Text 23",
"description": " AB = \\begin{pmatrix} 7 & 10 \\\\ 13 & 18 \\end{pmatrix} ",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@bab96d80267041138975e91a96e20f35"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@37a916b53c2f430989d33ddeb47e224d",
"block_type": "html",
"_formatted": {
"display_name": "Text 23",
"description": " AB = \\begin{pmatrix} 7 & 10 \\\\ 13 & 18 \\end{pmatrix} ",
"breadcrumbs": [
{
"display_name": "Learn UNIXY"
},
{
"display_name": "Section",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@chapter+block@6104ecc39b0046c5888db9e64db30887"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@sequential+block@d6e696f3d7d14cde82876a72a75cb208"
},
{
"display_name": "Unit",
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@vertical+block@bab96d80267041138975e91a96e20f35"
}
],
"usage_key": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@37a916b53c2f430989d33ddeb47e224d",
"block_type": "html"
}
}
],
"query": "",
"processingTimeMs": 0,
"limit": 3,
"offset": 0,
"estimatedTotalHits": 3
}
}
]

View File

@@ -0,0 +1,100 @@
[
{
"id": 970,
"upstreamContextTitle": "CS problems 2",
"upstreamVersion": 15,
"readyToSync": false,
"upstreamUsageKey": "lb:OpenedX:CSPROB2:html:c0c1ca28-ff25-4757-83bc-3a2c2a0fe9c8",
"upstreamContextKey": "lib:OpenedX:CSPROB2",
"downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@0dfbabb8c93140caa3a004ab621757f5",
"downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1",
"versionSynced": 15,
"versionDeclined": 13,
"created": "2025-02-08T14:11:23.650589Z",
"updated": "2025-02-08T14:11:23.650589Z"
},
{
"id": 971,
"upstreamContextTitle": "CS problems 2",
"upstreamVersion": 3,
"readyToSync": false,
"upstreamUsageKey": "lb:OpenedX:CSPROB2:html:fd2d3827-e633-4217-bca9-c6661086b4b2",
"upstreamContextKey": "lib:OpenedX:CSPROB2",
"downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@37a916b53c2f430989d33ddeb47e224d",
"downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1",
"versionSynced": 3,
"versionDeclined": null,
"created": "2025-02-08T14:11:23.650589Z",
"updated": "2025-02-08T14:11:23.650589Z"
},
{
"id": 972,
"upstreamContextTitle": "CS problems 2",
"upstreamVersion": 3,
"readyToSync": false,
"upstreamUsageKey": "lb:OpenedX:CSPROB2:video:ba2023d4-b4e4-44a5-bfc8-322203e8737f",
"upstreamContextKey": "lib:OpenedX:CSPROB2",
"downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@video+block@33518787c3e14844ad823af8ff158a83",
"downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1",
"versionSynced": 3,
"versionDeclined": null,
"created": "2025-02-08T14:11:23.650589Z",
"updated": "2025-02-08T14:11:23.650589Z"
},
{
"id": 974,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 18,
"readyToSync": false,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@5be06d70f09c45a581d973d215e7ad62",
"downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1",
"versionSynced": 17,
"versionDeclined": 18,
"created": "2025-02-12T05:38:53.967738Z",
"updated": "2025-02-12T05:41:01.225542Z"
},
{
"id": 975,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 1,
"readyToSync": false,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:html:4abdfa10-dd1a-4ebb-bad3-489000671acb",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@d1b006742e0745b0948c8ba8dcd9bc07",
"downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1",
"versionSynced": 1,
"versionDeclined": null,
"created": "2025-02-12T05:38:55.899821Z",
"updated": "2025-02-12T05:38:55.899821Z"
},
{
"id": 976,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 1,
"readyToSync": false,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:html:6aff1b41-e406-41ff-9d31-70d02ef42deb",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@html+block@276914b632eb49049b2c2568ef24fe4d",
"downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1",
"versionSynced": 1,
"versionDeclined": null,
"created": "2025-02-12T05:38:57.228152Z",
"updated": "2025-02-12T05:38:57.228152Z"
},
{
"id": 977,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 3,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:NewOrg3+TSTCS+2025_T1+type@problem+block@fcf430ac5b7a44b6ab316b53a7e67fef",
"downstreamContextKey": "course-v1:NewOrg3+TSTCS+2025_T1",
"versionSynced": 2,
"versionDeclined": null,
"created": "2025-02-12T05:38:58.538280Z",
"updated": "2025-02-12T05:38:58.538280Z"
}
]

View File

@@ -0,0 +1,36 @@
import mockLinksResult from '../__mocks__/publishableEntityLinks.json';
import { createAxiosError } from '../../testUtils';
import * as api from './api';
/**
* Mock for `getEntityLinksByDownstreamContext()`
*
* This mock returns a fixed response for the downstreamContextKey.
*/
export async function mockGetEntityLinksByDownstreamContext(
downstreamContextKey: string,
): Promise<api.PublishableEntityLink[]> {
switch (downstreamContextKey) {
case mockGetEntityLinksByDownstreamContext.invalidCourseKey:
throw createAxiosError({
code: 404,
message: 'Not found.',
path: api.getEntityLinksByDownstreamContextUrl(downstreamContextKey),
});
case mockGetEntityLinksByDownstreamContext.courseKeyLoading:
return new Promise(() => {});
case mockGetEntityLinksByDownstreamContext.courseKeyEmpty:
return Promise.resolve([]);
default:
return Promise.resolve(mockGetEntityLinksByDownstreamContext.response);
}
}
mockGetEntityLinksByDownstreamContext.courseKey = mockLinksResult[0].downstreamContextKey;
mockGetEntityLinksByDownstreamContext.invalidCourseKey = 'course_key_error';
mockGetEntityLinksByDownstreamContext.courseKeyLoading = 'courseKeyLoading';
mockGetEntityLinksByDownstreamContext.courseKeyEmpty = 'courseKeyEmpty';
mockGetEntityLinksByDownstreamContext.response = mockLinksResult;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockGetEntityLinksByDownstreamContext.applyMock = () => {
jest.spyOn(api, 'getEntityLinksByDownstreamContext').mockImplementation(mockGetEntityLinksByDownstreamContext);
};

View File

@@ -0,0 +1,29 @@
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getEntityLinksByDownstreamContextUrl = (downstreamContextKey: string) => `${getApiBaseUrl()}/api/contentstore/v2/upstreams/${downstreamContextKey}`;
export interface PublishableEntityLink {
upstreamUsageKey: string;
upstreamContextKey: string;
upstreamContextTitle: string;
upstreamVersion: string;
downstreamUsageKey: string;
downstreamContextTitle: string;
downstreamContextKey: string;
versionSynced: string;
versionDeclined: string;
created: string;
updated: string;
readyToSync: boolean;
}
export const getEntityLinksByDownstreamContext = async (
downstreamContextKey: string,
): Promise<PublishableEntityLink[]> => {
const { data } = await getAuthenticatedHttpClient()
.get(getEntityLinksByDownstreamContextUrl(downstreamContextKey));
return camelCaseObject(data);
};

View File

@@ -0,0 +1,50 @@
import { initializeMockApp } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
import { renderHook } from '@testing-library/react-hooks';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import MockAdapter from 'axios-mock-adapter';
import { waitFor } from '@testing-library/react';
import { getEntityLinksByDownstreamContextUrl } from './api';
import { useEntityLinksByDownstreamContext } from './apiHooks';
let axiosMock: MockAdapter;
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
const wrapper = ({ children }) => (
<QueryClientProvider client={queryClient}>
{children}
</QueryClientProvider>
);
describe('course libraries api hooks', () => {
beforeEach(() => {
initializeMockApp({
authenticatedUser: {
userId: 3,
username: 'abc123',
administrator: true,
roles: [],
},
});
axiosMock = new MockAdapter(getAuthenticatedHttpClient());
});
it('should create library block', async () => {
const courseKey = 'course-v1:some+key';
const url = getEntityLinksByDownstreamContextUrl(courseKey);
axiosMock.onGet(url).reply(200, []);
const { result } = renderHook(() => useEntityLinksByDownstreamContext(courseKey), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
expect(result.current.data).toEqual([]);
expect(axiosMock.history.get[0].url).toEqual(url);
});
});

View File

@@ -0,0 +1,20 @@
import {
useQuery,
} from '@tanstack/react-query';
import { getEntityLinksByDownstreamContext } from './api';
export const courseLibrariesQueryKeys = {
all: ['courseLibraries'],
courseLibraries: (courseKey?: string) => [...courseLibrariesQueryKeys.all, courseKey],
};
/**
* Hook to fetch a content library by its ID.
*/
export const useEntityLinksByDownstreamContext = (courseKey: string | undefined) => (
useQuery({
queryKey: courseLibrariesQueryKeys.courseLibraries(courseKey),
queryFn: () => getEntityLinksByDownstreamContext(courseKey!),
enabled: courseKey !== undefined,
})
);

View File

@@ -0,0 +1 @@
export { default } from './CourseLibraries';

View File

@@ -0,0 +1,81 @@
import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
headingTitle: {
id: 'course-authoring.course-libraries.header.title',
defaultMessage: 'Libraries',
description: 'Title for page',
},
headingSubtitle: {
id: 'course-authoring.course-libraries.header.subtitle',
defaultMessage: 'Content',
description: 'Subtitle for page',
},
homeTabTitle: {
id: 'course-authoring.course-libraries.tab.home.title',
defaultMessage: 'Libraries',
description: 'Tab title for home tab',
},
homeTabDescription: {
id: 'course-authoring.course-libraries.tab.home.description',
defaultMessage: 'This course contains content from these libraries.',
description: 'Description text for home tab',
},
homeTabDescriptionEmpty: {
id: 'course-authoring.course-libraries.tab.home.description-no-links',
defaultMessage: 'This course does not use any content from libraries.',
description: 'Description text for home tab',
},
reviewTabTitle: {
id: 'course-authoring.course-libraries.tab.review.title',
defaultMessage: 'Review Content Updates ({count})',
description: 'Tab title for review tab',
},
reviewTabTitleEmpty: {
id: 'course-authoring.course-libraries.tab.review.title-no-updates',
defaultMessage: 'Review Content Updates',
description: 'Tab title for review tab when no updates are available',
},
breadcrumbAriaLabel: {
id: 'course-authoring.course-libraries.downstream-block.breadcrumb.aria-label',
defaultMessage: 'Component breadcrumb',
description: 'Aria label for breadcrumb in component cards in course libraries page.',
},
totalComponentLabel: {
id: 'course-authoring.course-libraries.libcard.total-component.label',
defaultMessage: '{totalComponents, plural, one {# component} other {# components}} applied',
description: 'Prints total components applied from library',
},
allUptodateLabel: {
id: 'course-authoring.course-libraries.libcard.up-to-date.label',
defaultMessage: 'All components up to date',
description: 'Shown if all components under a library are up to date',
},
outOfSyncCountLabel: {
id: 'course-authoring.course-libraries.libcard.out-of-sync.label',
defaultMessage: '{outOfSyncCount, plural, one {# component} other {# components}} out of sync',
description: 'Prints number of components out of sync from library',
},
outOfSyncCountAlertTitle: {
id: 'course-authoring.course-libraries.libcard.out-of-sync.alert.title',
defaultMessage: '{outOfSyncCount} library components are out of sync. Review updates to accept or ignore changes',
description: 'Alert message shown when library components are out of sync',
},
reviewUpdatesBtn: {
id: 'course-authoring.course-libraries.libcard.review-updates.btn.text',
defaultMessage: 'Review Updates',
description: 'Action button to review updates',
},
outOfSyncCountAlertReviewBtn: {
id: 'course-authoring.course-libraries.libcard.out-of-sync.alert.review-btn-text',
defaultMessage: 'Review',
description: 'Alert review button text',
},
librariesV2DisabledError: {
id: 'course-authoring.course-libraries.alert.error.libraries.v2.disabled',
defaultMessage: 'This page cannot be shown: Libraries v2 are disabled.',
description: 'Error message shown to users when trying to load a libraries V2 page while libraries v2 are disabled.',
},
});
export default messages;

View File

@@ -11,6 +11,7 @@ export const useContentMenuItems = courseId => {
const intl = useIntl();
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
const waffleFlags = useSelector(getWaffleFlags);
const { librariesV2Enabled } = useSelector(getStudioHomeData);
const items = [
{
@@ -37,6 +38,13 @@ export const useContentMenuItems = courseId => {
});
}
if (librariesV2Enabled) {
items.splice(1, 0, {
href: `/course/${courseId}/libraries`,
title: intl.formatMessage(messages['header.links.libraries']),
});
}
return items;
};

View File

@@ -19,6 +19,9 @@ jest.mock('react-redux', () => ({
describe('header utils', () => {
describe('getContentMenuItems', () => {
it('when video upload page enabled should include Video Uploads option', () => {
useSelector.mockReturnValue({
librariesV2Enabled: false,
});
setConfig({
...getConfig(),
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'true',
@@ -27,6 +30,9 @@ describe('header utils', () => {
expect(actualItems).toHaveLength(5);
});
it('when video upload page disabled should not include Video Uploads option', () => {
useSelector.mockReturnValue({
librariesV2Enabled: false,
});
setConfig({
...getConfig(),
ENABLE_VIDEO_UPLOAD_PAGE_LINK_IN_CONTENT_DROPDOWN: 'false',
@@ -34,10 +40,21 @@ describe('header utils', () => {
const actualItems = renderHook(() => useContentMenuItems('course-123')).result.current;
expect(actualItems).toHaveLength(4);
});
it('adds course libraries link to content menu when libraries v2 is enabled', () => {
useSelector.mockReturnValue({
librariesV2Enabled: true,
});
const actualItems = renderHook(() => useContentMenuItems('course-123')).result.current;
expect(actualItems[1]).toEqual({ href: '/course/course-123/libraries', title: 'Libraries' });
});
});
describe('getSettingsMenuitems', () => {
useSelector.mockReturnValue({ canAccessAdvancedSettings: true });
beforeEach(() => {
useSelector.mockReturnValue({
canAccessAdvancedSettings: true,
});
});
it('when certificate page enabled should include certificates option', () => {
setConfig({
@@ -67,6 +84,11 @@ describe('header utils', () => {
});
describe('getToolsMenuItems', () => {
beforeEach(() => {
useSelector.mockReturnValue({
waffleFlags: jest.fn(),
});
});
it('when tags enabled should include export tags option', () => {
setConfig({
...getConfig(),

View File

@@ -21,6 +21,11 @@ const messages = defineMessages({
defaultMessage: 'Outline',
description: 'Link to Studio Outline page',
},
'header.links.libraries': {
id: 'header.links.libraries',
defaultMessage: 'Libraries',
description: 'Link to Linked Libraries page in a course',
},
'header.links.updates': {
id: 'header.links.updates',
defaultMessage: 'Updates',

View File

@@ -66,3 +66,7 @@ body {
mark {
padding: 0;
}
.border-left-purple {
border-left: 3px solid #5E35B1 !important;
}

View File

@@ -518,3 +518,29 @@ export async function fetchTagsThatMatchKeyword({
return { matches: Array.from(matches).map((tagPath) => ({ tagPath })), mayBeMissingResults: hits.length === limit };
}
/**
* Generic one-off fetch from meilisearch index.
*/
export const fetchIndexDocuments = async (
client: MeiliSearch,
indexName: string,
filter?: Filter,
limit?: number,
attributesToRetrieve?: string[],
attributesToCrop?: string[],
sort?: SearchSortOption[],
): Promise<ContentHit[]> => {
// Convert 'extraFilter' into an array
const filterFormatted = forceArray(filter);
const { hits } = await client.index(indexName).search('', {
filter: filterFormatted,
limit,
attributesToRetrieve,
attributesToCrop,
sort,
});
return hits.map(formatSearchHit) as ContentHit[];
};

View File

@@ -11,6 +11,7 @@ import {
getContentSearchConfig,
fetchBlockTypes,
type PublishStatus,
fetchIndexDocuments,
} from './api';
/**
@@ -258,3 +259,33 @@ export const useGetBlockTypes = (extraFilters: Filter) => {
queryFn: () => fetchBlockTypes(client!, indexName!, extraFilters),
});
};
export const useFetchIndexDocuments = (
filter: Filter,
limit: number,
attributesToRetrieve?: string[],
attributesToCrop?: string[],
sort?: SearchSortOption[],
) => {
const { client, indexName } = useContentSearchConnection();
return useQuery({
enabled: client !== undefined && indexName !== undefined,
queryKey: [
'content_search',
client?.config.apiKey,
client?.config.host,
indexName,
filter,
'generic-one-off',
],
queryFn: () => fetchIndexDocuments(
client!,
indexName!,
filter,
limit,
attributesToRetrieve,
attributesToCrop,
sort,
),
});
};