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:
@@ -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>}
|
||||
|
||||
148
src/course-libraries/CourseLibraries.test.tsx
Normal file
148
src/course-libraries/CourseLibraries.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
342
src/course-libraries/CourseLibraries.tsx
Normal file
342
src/course-libraries/CourseLibraries.tsx
Normal 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;
|
||||
374
src/course-libraries/__mocks__/courseBlocksInfo.json
Normal file
374
src/course-libraries/__mocks__/courseBlocksInfo.json
Normal 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
|
||||
}
|
||||
}
|
||||
]
|
||||
100
src/course-libraries/__mocks__/publishableEntityLinks.json
Normal file
100
src/course-libraries/__mocks__/publishableEntityLinks.json
Normal 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"
|
||||
}
|
||||
]
|
||||
36
src/course-libraries/data/api.mocks.ts
Normal file
36
src/course-libraries/data/api.mocks.ts
Normal 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);
|
||||
};
|
||||
29
src/course-libraries/data/api.ts
Normal file
29
src/course-libraries/data/api.ts
Normal 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);
|
||||
};
|
||||
50
src/course-libraries/data/apiHooks.test.tsx
Normal file
50
src/course-libraries/data/apiHooks.test.tsx
Normal 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);
|
||||
});
|
||||
});
|
||||
20
src/course-libraries/data/apiHooks.ts
Normal file
20
src/course-libraries/data/apiHooks.ts
Normal 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,
|
||||
})
|
||||
);
|
||||
1
src/course-libraries/index.tsx
Normal file
1
src/course-libraries/index.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './CourseLibraries';
|
||||
81
src/course-libraries/messages.ts
Normal file
81
src/course-libraries/messages.ts
Normal 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;
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -66,3 +66,7 @@ body {
|
||||
mark {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.border-left-purple {
|
||||
border-left: 3px solid #5E35B1 !important;
|
||||
}
|
||||
|
||||
@@ -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[];
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user