feat: course libraries review tab [FC-0076] (#1699)

Adds review tab to course libraries page. Also refactors all libraries page as per new designs.
This commit is contained in:
Navin Karkera
2025-03-12 17:58:27 +00:00
committed by GitHub
parent 3aa409d065
commit 77a55d9ad3
36 changed files with 1868 additions and 712 deletions

View File

@@ -25,7 +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';
import { CourseLibraries } from './course-libraries';
/**
* As of this writing, these routes are mounted at a path prefixed with the following:

View File

@@ -1,22 +1,35 @@
import fetchMock from 'fetch-mock-jest';
import { cloneDeep } from 'lodash';
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter/types';
import { QueryClient } from '@tanstack/react-query';
import {
initializeMocks,
render,
screen,
waitFor,
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';
import { CourseLibraries } from './CourseLibraries';
import {
mockGetEntityLinks,
mockGetEntityLinksSummaryByDownstreamContext,
mockFetchIndexDocuments,
mockUseLibBlockMetadata,
} from './data/api.mocks';
import { libraryBlockChangesUrl } from '../course-unit/data/api';
import { type ToastActionData } from '../generic/toast-context';
mockContentSearchConfig.applyMock();
mockGetEntityLinksByDownstreamContext.applyMock();
mockGetEntityLinks.applyMock();
mockGetEntityLinksSummaryByDownstreamContext.applyMock();
mockUseLibBlockMetadata.applyMock();
const searchEndpoint = 'http://mock.meilisearch.local/indexes/studio/search';
const searchParamsGetMock = jest.fn();
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
let queryClient: QueryClient;
jest.mock('../studio-home/hooks', () => ({
useStudioHome: () => ({
@@ -26,54 +39,46 @@ jest.mock('../studio-home/hooks', () => ({
}),
}));
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'), // use actual for all non-hook parts
useSearchParams: () => [{
get: searchParamsGetMock,
getAll: () => [],
}],
}));
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;
});
mockFetchIndexDocuments.applyMock();
localStorage.clear();
searchParamsGetMock.mockReturnValue('all');
});
const renderCourseLibrariesPage = async (courseKey?: string) => {
const courseId = courseKey || mockGetEntityLinksByDownstreamContext.courseKey;
const courseId = courseKey || mockGetEntityLinks.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);
await renderCourseLibrariesPage(mockGetEntityLinksSummaryByDownstreamContext.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);
it('shows empty state when no links are present', async () => {
await renderCourseLibrariesPage(mockGetEntityLinks.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);
await renderCourseLibrariesPage(mockGetEntityLinks.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',
'5 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');
@@ -82,7 +87,7 @@ describe('<CourseLibraries />', () => {
userEvent.click(reviewBtn);
expect(allTab).toHaveAttribute('aria-selected', 'false');
expect(await screen.findByRole('tab', { name: 'Review Content Updates (1)' })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
expect(alert).not.toBeInTheDocument();
// go back to all tab
@@ -94,14 +99,14 @@ describe('<CourseLibraries />', () => {
// 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');
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
});
it('hide alert on dismiss', async () => {
await renderCourseLibrariesPage(mockGetEntityLinksByDownstreamContext.courseKey);
await renderCourseLibrariesPage(mockGetEntityLinks.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',
'5 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);
@@ -110,39 +115,127 @@ describe('<CourseLibraries />', () => {
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;
describe('<CourseLibraries ReviewTab />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
fetchMock.mockReset();
mockFetchIndexDocuments.applyMock();
localStorage.clear();
searchParamsGetMock.mockReturnValue('review');
queryClient = mocks.queryClient;
});
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 renderCourseLibrariesReviewPage = async (courseKey?: string) => {
const courseId = courseKey || mockGetEntityLinks.courseKey;
render(<CourseLibraries courseId={courseId} />);
};
const libParent1 = libraryCards[0].parentElement;
expect(libParent1).not.toBeNull();
userEvent.click(libraryCards[0]);
const xblockCards1 = libParent1!.querySelectorAll('div.card');
expect(xblockCards1.length).toEqual(expectedLib1Blocks);
it('shows the spinner before the query is complete', async () => {
// This mock will never return data (it loads forever):
await renderCourseLibrariesReviewPage(mockGetEntityLinks.courseKeyLoading);
const spinner = await screen.findByRole('status');
expect(spinner.textContent).toEqual('Loading...');
});
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();
it('shows empty state when no readyToSync links are present', async () => {
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKeyUpToDate);
const emptyMsg = await screen.findByText('All components are up to date');
expect(emptyMsg).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);
it('shows all readyToSync links', async () => {
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
expect(updateBtns.length).toEqual(5);
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
expect(ignoreBtns.length).toEqual(5);
});
it('update changes works', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
expect(updateBtns.length).toEqual(5);
userEvent.click(updateBtns[0]);
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
});
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith('Success! "Dropdown" is updated');
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
});
it('update changes works in preview modal', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(previewBtns.length).toEqual(5);
userEvent.click(previewBtns[0]);
const dialog = await screen.findByRole('dialog');
const confirmBtn = await within(dialog).findByRole('button', { name: 'Accept changes' });
userEvent.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.post.length).toEqual(1);
});
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith('Success! "Dropdown" is updated');
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
});
it('ignore change works', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
expect(ignoreBtns.length).toEqual(5);
// Show confirmation modal on clicking ignore.
userEvent.click(ignoreBtns[0]);
const dialog = await screen.findByRole('dialog', { name: 'Ignore these changes?' });
expect(dialog).toBeInTheDocument();
const confirmBtn = await within(dialog).findByRole('button', { name: 'Ignore' });
userEvent.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.delete.length).toEqual(1);
});
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith(
'"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.',
);
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
});
it('ignore change works in preview', async () => {
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response.results[0].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(previewBtns.length).toEqual(5);
userEvent.click(previewBtns[0]);
const previewDialog = await screen.findByRole('dialog');
const ignoreBtn = await within(previewDialog).findByRole('button', { name: 'Ignore changes' });
userEvent.click(ignoreBtn);
// Show confirmation modal on clicking ignore.
const dialog = await screen.findByRole('dialog', { name: 'Ignore these changes?' });
expect(dialog).toBeInTheDocument();
const confirmBtn = await within(dialog).findByRole('button', { name: 'Ignore' });
userEvent.click(confirmBtn);
await waitFor(() => {
expect(axiosMock.history.delete.length).toEqual(1);
});
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
expect(mockShowToast).toHaveBeenCalledWith(
'"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.',
);
expect(mockInvalidateQueries).toHaveBeenCalledWith(['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX']);
});
});

View File

@@ -6,236 +6,110 @@ 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,
ActionRow,
Button,
Card,
Container,
Hyperlink,
Icon,
Stack,
Tab,
Tabs,
} from '@openedx/paragon';
import {
Cached, CheckCircle, KeyboardArrowDown, KeyboardArrowRight, Loop, MoreVert,
Cached, CheckCircle, Launch, Loop,
} from '@openedx/paragon/icons';
import {
countBy, groupBy, keyBy, tail, uniq,
} from 'lodash';
import classNames from 'classnames';
import _ from 'lodash';
import { useSearchParams } from 'react-router-dom';
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 { useEntityLinksSummaryByDownstreamContext } from './data/apiHooks';
import type { PublishableEntityLinkSummary } from './data/api';
import Loading from '../generic/Loading';
import { useStudioHome } from '../studio-home/hooks';
import NewsstandIcon from '../generic/NewsstandIcon';
import ReviewTabContent from './ReviewTabContent';
import { OutOfSyncAlert } from './OutOfSyncAlert';
interface Props {
courseId: string;
}
interface LibraryCardProps {
courseId: string;
title: string;
links: PublishableEntityLink[];
}
interface ComponentInfo extends ContentHit {
readyToSync: boolean;
}
interface BlockCardProps {
info: ComponentInfo;
linkSummary: PublishableEntityLinkSummary;
}
export enum CourseLibraryTabs {
home = '',
all = 'all',
review = 'review',
}
const BlockCard: React.FC<BlockCardProps> = ({ info }) => {
const LibraryCard = ({ linkSummary }: LibraryCardProps) => {
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>
<Card className="my-3 border-light-500 border shadow-none">
<Card.Header
title={(
<Stack direction="horizontal" gap={2}>
<Icon src={NewsstandIcon} />
{linkSummary.upstreamContextTitle}
</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"
/>
)}
actions={(
<ActionRow>
<Button
destination={`${getConfig().PUBLIC_PATH}library/${linkSummary.upstreamContextKey}`}
target="_blank"
className="border border-light-300"
variant="tertiary"
as={Hyperlink}
size="sm"
showLaunchIcon={false}
iconAfter={Launch}
>
View Library
</Button>
</ActionRow>
)}
size="sm"
/>
<Card.Section>
<Stack
direction="horizontal"
gap={4}
className="x-small"
>
<span>
{intl.formatMessage(messages.totalComponentLabel, { totalComponents: linkSummary.totalCount })}
</span>
{linkSummary.readyToSyncCount > 0 && (
<Stack direction="horizontal" gap={1}>
<Icon src={Loop} size="xs" />
<span>
{intl.formatMessage(messages.outOfSyncCountLabel, { outOfSyncCount: linkSummary.readyToSyncCount })}
</span>
</Stack>
)}
</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({
filter: [`context_key = "${courseId}"`, `usage_key IN ["${downstreamKeys.join('","')}"]`],
limit: downstreamKeys.length,
attributesToRetrieve: ['usage_key', 'display_name', 'breadcrumbs', 'description', 'block_type'],
attributesToCrop: ['description:30'],
sort: [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 }) => {
export 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 [searchParams] = useSearchParams();
const [tabKey, setTabKey] = useState<CourseLibraryTabs>(
() => searchParams.get('tab') as CourseLibraryTabs || CourseLibraryTabs.all,
);
const [showReviewAlert, setShowReviewAlert] = useState(false);
const { data: libraries, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId);
const outOfSyncCount = useMemo(() => _.sumBy(libraries, (lib) => lib.readyToSyncCount), [libraries]);
const {
isLoadingPage: isLoadingStudioHome,
isFailedLoadingPage: isFailedLoadingStudioHome,
@@ -244,33 +118,51 @@ const CourseLibraries: React.FC<Props> = ({ courseId }) => {
const onAlertReview = () => {
setTabKey(CourseLibraryTabs.review);
setShowReviewAlert(false);
};
const onAlertDismiss = () => {
setShowReviewAlert(false);
};
const tabChange = useCallback((selectedTab: CourseLibraryTabs) => {
setTabKey(selectedTab);
}, []);
const renderLibrariesTabContent = useCallback(() => {
if (isLoading) {
return <Loading />;
}
if (links?.length === 0) {
if (libraries?.length === 0) {
return <small><FormattedMessage {...messages.homeTabDescriptionEmpty} /></small>;
}
return (
<>
<small><FormattedMessage {...messages.homeTabDescription} /></small>
{Object.entries(linksByLib).map(([libKey, libLinks]) => (
{libraries?.map((library) => (
<LibraryCard
courseId={courseId}
title={libLinks[0].upstreamContextTitle}
links={libLinks}
key={libKey}
linkSummary={library}
key={library.upstreamContextKey}
/>
))}
</>
);
}, [links, isLoading, linksByLib]);
}, [libraries, isLoading]);
const renderReviewTabContent = useCallback(() => {
if (isLoading) {
return <Loading />;
}
if (tabKey !== CourseLibraryTabs.review) {
return null;
}
if (!outOfSyncCount || outOfSyncCount === 0) {
return (
<Stack direction="horizontal" gap={2}>
<Icon src={CheckCircle} size="xs" />
<small>
<FormattedMessage {...messages.reviewTabDescriptionEmpty} />
</small>
</Stack>
);
}
return <ReviewTabContent courseId={courseId} />;
}, [outOfSyncCount, isLoading, tabKey]);
if (!isLoadingStudioHome && (!librariesV2Enabled || isFailedLoadingStudioHome)) {
return (
@@ -288,16 +180,16 @@ const CourseLibraries: React.FC<Props> = ({ courseId }) => {
</title>
</Helmet>
<Container size="xl" className="px-4 pt-4 mt-3">
<ReviewAlert
show={outOfSyncCount > 0 && tabKey === CourseLibraryTabs.home && showReviewAlert}
outOfSyncCount={outOfSyncCount}
onDismiss={onAlertDismiss}
<OutOfSyncAlert
courseId={courseId}
onReview={onAlertReview}
showAlert={showReviewAlert && tabKey === CourseLibraryTabs.all}
setShowAlert={setShowReviewAlert}
/>
<SubHeader
title={intl.formatMessage(messages.headingTitle)}
subtitle={intl.formatMessage(messages.headingSubtitle)}
headerActions={!showReviewAlert && tabKey === CourseLibraryTabs.home && (
headerActions={!showReviewAlert && outOfSyncCount > 0 && tabKey === CourseLibraryTabs.all && (
<Button
variant="primary"
onClick={onAlertReview}
@@ -312,25 +204,27 @@ const CourseLibraries: React.FC<Props> = ({ courseId }) => {
<Tabs
id="course-library-tabs"
activeKey={tabKey}
onSelect={(k: CourseLibraryTabs) => setTabKey(k)}
onSelect={tabChange}
>
<Tab
eventKey={CourseLibraryTabs.home}
eventKey={CourseLibraryTabs.all}
title={intl.formatMessage(messages.homeTabTitle)}
className="px-2 mt-3"
>
<TabContent>
{renderLibrariesTabContent()}
</TabContent>
{renderLibrariesTabContent()}
</Tab>
<Tab
eventKey={CourseLibraryTabs.review}
title={intl.formatMessage(
outOfSyncCount > 0 ? messages.reviewTabTitle : messages.reviewTabTitleEmpty,
{ count: outOfSyncCount },
title={(
<Stack direction="horizontal" gap={1}>
<Icon src={Loop} />
{intl.formatMessage(messages.reviewTabTitle)}
</Stack>
)}
notification={outOfSyncCount}
className="px-2 mt-3"
>
<TabContent>Help</TabContent>
{renderReviewTabContent()}
</Tab>
</Tabs>
</section>
@@ -338,5 +232,3 @@ const CourseLibraries: React.FC<Props> = ({ courseId }) => {
</>
);
};
export default CourseLibraries;

View File

@@ -0,0 +1,73 @@
import React, { useEffect } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button } from '@openedx/paragon';
import { Loop } from '@openedx/paragon/icons';
import AlertMessage from '../generic/alert-message';
import { useEntityLinksSummaryByDownstreamContext } from './data/apiHooks';
import messages from './messages';
interface OutOfSyncAlertProps {
showAlert: boolean,
setShowAlert: React.Dispatch<React.SetStateAction<boolean>>,
courseId: string,
onDismiss?: () => void;
onReview: () => void;
}
/*
Shows an alert when library components used in the current course were updated and the blocks in course can be updated.
Dismiss or review action is persisted using localStorage to avoid displaying the alert on every refresh.
*/
export const OutOfSyncAlert: React.FC<OutOfSyncAlertProps> = ({
showAlert,
setShowAlert,
courseId,
onDismiss,
onReview,
}) => {
const intl = useIntl();
const { data, isLoading } = useEntityLinksSummaryByDownstreamContext(courseId);
const outOfSyncCount = data?.reduce((count, lib) => count + lib.readyToSyncCount, 0);
const alertKey = `outOfSyncCountAlert-${courseId}`;
useEffect(() => {
if (isLoading) {
return;
}
if (outOfSyncCount === 0) {
localStorage.removeItem(alertKey);
setShowAlert(false);
return;
}
const dismissedAlert = localStorage.getItem(alertKey);
setShowAlert(parseInt(dismissedAlert || '', 10) !== outOfSyncCount);
}, [outOfSyncCount, isLoading, data]);
const dismissAlert = () => {
setShowAlert(false);
localStorage.setItem(alertKey, String(outOfSyncCount));
onDismiss?.();
};
const reviewAlert = () => {
dismissAlert();
onReview();
};
return (
<AlertMessage
title={intl.formatMessage(messages.outOfSyncCountAlertTitle, { outOfSyncCount })}
dismissible
show={showAlert}
icon={Loop}
variant="info"
onClose={dismissAlert}
actions={[
<Button
onClick={reviewAlert}
>
{intl.formatMessage(messages.outOfSyncCountAlertReviewBtn)}
</Button>,
]}
/>
);
};

View File

@@ -0,0 +1,391 @@
import React, {
useCallback, useContext, useEffect, useMemo, useState,
} from 'react';
import { getConfig } from '@edx/frontend-platform';
import { useIntl } from '@edx/frontend-platform/i18n';
import {
ActionRow,
Breadcrumb,
Button,
Card,
Hyperlink,
Icon,
Stack,
useToggle,
} from '@openedx/paragon';
import _ from 'lodash';
import { useQueryClient } from '@tanstack/react-query';
import { Loop, Warning } from '@openedx/paragon/icons';
import messages from './messages';
import previewChangesMessages from '../course-unit/preview-changes/messages';
import { courseLibrariesQueryKeys, useEntityLinks } from './data/apiHooks';
import {
SearchContextProvider, SearchKeywordsField, useSearchContext, BlockTypeLabel, Highlight, SearchSortWidget,
} from '../search-manager';
import { getItemIcon } from '../generic/block-type-utils';
import type { ContentHit } from '../search-manager/data/api';
import { SearchSortOption } from '../search-manager/data/api';
import Loading from '../generic/Loading';
import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../course-unit/data/apiHooks';
import { PreviewLibraryXBlockChanges, LibraryChangesMessageData } from '../course-unit/preview-changes';
import LoadingButton from '../generic/loading-button';
import { ToastContext } from '../generic/toast-context';
import { useLoadOnScroll } from '../hooks';
import DeleteModal from '../generic/delete-modal/DeleteModal';
import { PublishableEntityLink } from './data/api';
import AlertError from '../generic/alert-error';
import AlertMessage from '../generic/alert-message';
interface Props {
courseId: string;
}
interface BlockCardProps {
info: ContentHit;
actions?: React.ReactNode;
}
const BlockCard: React.FC<BlockCardProps> = ({ info, actions }) => {
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="my-3 border-light-500 border shadow-none"
orientation="horizontal"
>
<Card.Section
className="py-3"
>
<Stack direction="horizontal" gap={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} />
</Stack>
<Stack direction="horizontal" className="small" gap={1}>
<strong>
<Highlight text={info.formatted?.displayName ?? ''} />
</strong>
</Stack>
<Stack direction="horizontal" className="micro" gap={3}>
{intl.formatMessage(messages.breadcrumbLabel)}
<Hyperlink showLaunchIcon={false} destination={getBlockLink()} target="_blank">
<Breadcrumb
className="micro text-gray-700 border-bottom"
ariaLabel={intl.formatMessage(messages.breadcrumbLabel)}
links={breadcrumbs.map((breadcrumb) => ({ label: breadcrumb.displayName }))}
spacer={<span className="custom-spacer">/</span>}
linkAs="span"
/>
</Hyperlink>
</Stack>
</Stack>
{actions}
</Stack>
</Card.Section>
</Card>
);
};
const ComponentReviewList = ({
outOfSyncComponents,
onSearchUpdate,
}: {
outOfSyncComponents: PublishableEntityLink[];
onSearchUpdate: () => void;
}) => {
const intl = useIntl();
const { showToast } = useContext(ToastContext);
const [blockData, setBlockData] = useState<LibraryChangesMessageData | undefined>(undefined);
// ignore changes confirmation modal toggle.
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
const {
hits: downstreamInfo,
isLoading: isIndexDataLoading,
searchKeywords,
searchSortOrder,
hasError,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
} = useSearchContext() as {
hits: ContentHit[];
isLoading: boolean;
searchKeywords: string;
searchSortOrder: SearchSortOption;
hasError: boolean;
hasNextPage: boolean | undefined,
isFetchingNextPage: boolean;
fetchNextPage: () => void;
};
useLoadOnScroll(
hasNextPage,
isFetchingNextPage,
fetchNextPage,
true,
);
const outOfSyncComponentsByKey = useMemo(
() => _.keyBy(outOfSyncComponents, 'downstreamUsageKey'),
[outOfSyncComponents],
);
const downstreamInfoByKey = useMemo(
() => _.keyBy(downstreamInfo, 'usageKey'),
[downstreamInfo],
);
const queryClient = useQueryClient();
useEffect(() => {
if (searchKeywords) {
onSearchUpdate();
}
}, [searchKeywords]);
// Toggle preview changes modal
const [isModalOpen, openModal, closeModal] = useToggle(false);
const acceptChangesMutation = useAcceptLibraryBlockChanges();
const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
const setSeletecdBlockData = (info: ContentHit) => {
setBlockData({
displayName: info.displayName,
downstreamBlockId: info.usageKey,
upstreamBlockId: outOfSyncComponentsByKey[info.usageKey].upstreamUsageKey,
upstreamBlockVersionSynced: outOfSyncComponentsByKey[info.usageKey].versionSynced,
isVertical: info.blockType === 'vertical',
});
};
// Show preview changes on review
const onReview = useCallback((info: ContentHit) => {
setSeletecdBlockData(info);
openModal();
}, [setSeletecdBlockData, openModal]);
const onIgnoreClick = useCallback((info: ContentHit) => {
setSeletecdBlockData(info);
openConfirmModal();
}, [setSeletecdBlockData, openConfirmModal]);
const reloadLinks = useCallback((usageKey: string) => {
const courseKey = outOfSyncComponentsByKey[usageKey].downstreamContextKey;
queryClient.invalidateQueries(courseLibrariesQueryKeys.courseLibraries(courseKey));
}, [outOfSyncComponentsByKey]);
const postChange = (accept: boolean) => {
// istanbul ignore if: this should never happen
if (!blockData) {
return;
}
reloadLinks(blockData.downstreamBlockId);
if (accept) {
showToast(intl.formatMessage(
messages.updateSingleBlockSuccess,
{ name: blockData.displayName },
));
} else {
showToast(intl.formatMessage(
messages.ignoreSingleBlockSuccess,
{ name: blockData.displayName },
));
}
};
const updateBlock = useCallback(async (info: ContentHit) => {
try {
await acceptChangesMutation.mutateAsync(info.usageKey);
reloadLinks(info.usageKey);
showToast(intl.formatMessage(
messages.updateSingleBlockSuccess,
{ name: info.displayName },
));
} catch (e) {
showToast(intl.formatMessage(previewChangesMessages.acceptChangesFailure));
}
}, []);
const ignoreBlock = useCallback(async () => {
// istanbul ignore if: this should never happen
if (!blockData) {
return;
}
try {
await ignoreChangesMutation.mutateAsync(blockData.downstreamBlockId);
reloadLinks(blockData.downstreamBlockId);
showToast(intl.formatMessage(
messages.ignoreSingleBlockSuccess,
{ name: blockData.displayName },
));
} catch (e) {
showToast(intl.formatMessage(previewChangesMessages.ignoreChangesFailure));
} finally {
closeConfirmModal();
}
}, [blockData]);
const orderInfo = useMemo(() => {
if (searchSortOrder !== SearchSortOption.RECENTLY_MODIFIED) {
return downstreamInfo;
}
if (isIndexDataLoading) {
return [];
}
let merged = _.merge(downstreamInfoByKey, outOfSyncComponentsByKey);
merged = _.omitBy(merged, (o) => !o.displayName);
const ordered = _.orderBy(Object.values(merged), 'updated', 'desc');
return ordered;
}, [downstreamInfoByKey, outOfSyncComponentsByKey]);
if (isIndexDataLoading) {
return <Loading />;
}
if (hasError) {
return <AlertError error={intl.formatMessage(messages.genericErrorMessage)} />;
}
return (
<>
{orderInfo?.map((info) => (
<BlockCard
key={info.usageKey}
info={info}
actions={(
<ActionRow>
<Button
size="sm"
variant="outline-primary border-light-300"
onClick={() => onReview(info)}
iconBefore={Loop}
className="mr-2"
>
{intl.formatMessage(messages.cardReviewContentBtn)}
</Button>
<span className="border border-dark py-3 ml-4 mr-3" />
<Button
variant="tertiary"
size="sm"
onClick={() => onIgnoreClick(info)}
>
{intl.formatMessage(messages.cardIgnoreContentBtn)}
</Button>
<LoadingButton
label={intl.formatMessage(messages.cardUpdateContentBtn)}
variant="primary"
size="sm"
onClick={() => updateBlock(info)}
className="rounded-0"
/>
</ActionRow>
)}
/>
))}
<PreviewLibraryXBlockChanges
blockData={blockData}
isModalOpen={isModalOpen}
closeModal={closeModal}
postChange={postChange}
alertNode={(
<AlertMessage
show
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.olderVersionPreviewAlert)}
/>
)}
/>
<DeleteModal
isOpen={isConfirmModalOpen}
close={closeConfirmModal}
variant="warning"
title={intl.formatMessage(previewChangesMessages.confirmationTitle)}
description={intl.formatMessage(previewChangesMessages.confirmationDescription)}
onDeleteSubmit={ignoreBlock}
btnLabel={intl.formatMessage(previewChangesMessages.confirmationConfirmBtn)}
/>
</>
);
};
const ReviewTabContent = ({ courseId }: Props) => {
const intl = useIntl();
const {
data: linkPages,
isLoading: isSyncComponentsLoading,
hasNextPage,
isFetchingNextPage,
fetchNextPage,
isError,
error,
} = useEntityLinks({ courseId, readyToSync: true });
const outOfSyncComponents = useMemo(
() => linkPages?.pages?.reduce((links, page) => [...links, ...page.results], []) ?? [],
[linkPages],
);
const downstreamKeys = useMemo(
() => outOfSyncComponents?.map(link => link.downstreamUsageKey),
[outOfSyncComponents],
);
useLoadOnScroll(
hasNextPage,
isFetchingNextPage,
fetchNextPage,
true,
);
const onSearchUpdate = () => {
if (hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
};
const disableSortOptions = [
SearchSortOption.RELEVANCE,
SearchSortOption.OLDEST,
SearchSortOption.NEWEST,
SearchSortOption.RECENTLY_PUBLISHED,
];
if (isSyncComponentsLoading) {
return <Loading />;
}
if (isError) {
return <AlertError error={error} />;
}
return (
<SearchContextProvider
extraFilter={[`context_key = "${courseId}"`, `usage_key IN ["${downstreamKeys?.join('","')}"]`]}
skipUrlUpdate
skipBlockTypeFetch
>
<ActionRow>
<SearchKeywordsField
placeholder={intl.formatMessage(messages.searchPlaceholder)}
/>
<SearchSortWidget disableOptions={disableSortOptions} />
<ActionRow.Spacer />
</ActionRow>
<ComponentReviewList
outOfSyncComponents={outOfSyncComponents}
onSearchUpdate={onSearchUpdate}
/>
</SearchContextProvider>
);
};
export default ReviewTabContent;

View File

@@ -0,0 +1,23 @@
{
"id": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"def_key": null,
"block_type": "problem",
"display_name": "Dropdown 123",
"last_published": "2025-02-19T13:58:49Z",
"published_by": "edx",
"last_draft_created": "2025-02-19T13:58:48Z",
"last_draft_created_by": null,
"has_unpublished_changes": false,
"created": "2024-10-30T10:48:35Z",
"modified": "2025-02-19T13:58:48Z",
"collections": [
{
"key": "second-collection",
"title": "Second collection"
},
{
"key": "test-collection-2",
"title": "Test collection 2"
}
]
}

View File

@@ -0,0 +1,20 @@
[
{
"upstreamContextTitle": "CS problems 3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"readyToSyncCount": 5,
"totalCount": 14
},
{
"upstreamContextTitle": "CS problems 2",
"upstreamContextKey": "lib:OpenedX:CSPROB2",
"readyToSyncCount": 0,
"totalCount": 21
},
{
"upstreamContextTitle": "CS problems",
"upstreamContextKey": "lib:OpenedX:CSPROB",
"readyToSyncCount": 0,
"totalCount": 3
}
]

View File

@@ -0,0 +1,376 @@
{
"results": [
{
"indexUid": "tutor_studio_content",
"hits": [
{
"display_name": "Dropdown",
"block_id": "problem3",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "asfd sdaf afd"
},
"description": "asfd sdaf afd",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeproblemblockproblem3-31ecf30b",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@43eb5cbc0ea2491aa93f968f5e705ebd"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3",
"block_type": "problem",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": 4,
"_formatted": {
"display_name": "Dropdown",
"block_id": "problem3",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "asfd sdaf afd"
},
"description": "asfd sdaf afd",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeproblemblockproblem3-31ecf30b",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@43eb5cbc0ea2491aa93f968f5e705ebd"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3",
"block_type": "problem",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": "4"
}
},
{
"display_name": "Dropdown",
"block_id": "problem6",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "asfd sdaf afd"
},
"description": "asfd sdaf afd",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeproblemblockproblem6-5bb64bab",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@43eb5cbc0ea2491aa93f968f5e705ebd"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6",
"block_type": "problem",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": 4,
"_formatted": {
"display_name": "Dropdown",
"block_id": "problem6",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "asfd sdaf afd"
},
"description": "asfd sdaf afd",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeproblemblockproblem6-5bb64bab",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@43eb5cbc0ea2491aa93f968f5e705ebd"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6",
"block_type": "problem",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": "4"
}
},
{
"display_name": "Dropdown",
"block_id": "210e356cfa304b0aac591af53f6a6ae0",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "asfd sdaf afd"
},
"description": "asfd sdaf afd",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeproblemblock210e356cfa304b0aac591af53f6a6ae0-d02782e3",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@itembank+block@3ba55d8eae5544aa9d3fb5e3f100ed62"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0",
"block_type": "problem",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": 4,
"_formatted": {
"display_name": "Dropdown",
"block_id": "210e356cfa304b0aac591af53f6a6ae0",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "asfd sdaf afd"
},
"description": "asfd sdaf afd",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeproblemblock210e356cfa304b0aac591af53f6a6ae0-d02782e3",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
},
{
"display_name": "Problem Bank",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@itembank+block@3ba55d8eae5544aa9d3fb5e3f100ed62"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0",
"block_type": "problem",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": "4"
}
},
{
"display_name": "HTML 1",
"block_id": "257e68e3386d4a8f8739d45b67e76a9b",
"content": {
"html_content": "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"
},
"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",
"tags": {},
"id": "block-v1itcracydemoxcoursextypehtmlblock257e68e3386d4a8f8739d45b67e76a9b-e5cd0344",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b",
"block_type": "html",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": 4,
"_formatted": {
"display_name": "HTML 1",
"block_id": "257e68e3386d4a8f8739d45b67e76a9b",
"content": {
"html_content": "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"
},
"description": "A step beyond the simplicity of the WYSIWYG editor is…",
"tags": {},
"id": "block-v1itcracydemoxcoursextypehtmlblock257e68e3386d4a8f8739d45b67e76a9b-e5cd0344",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b",
"block_type": "html",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": "4"
}
},
{
"display_name": "Dropdown",
"block_id": "a4455860b03647219ff8b01cde49cf37",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "asfd sdaf afd"
},
"description": "asfd sdaf afd",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeproblemblocka4455860b03647219ff8b01cde49cf37-709012d2",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37",
"block_type": "problem",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": 4,
"_formatted": {
"display_name": "Dropdown",
"block_id": "a4455860b03647219ff8b01cde49cf37",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "asfd sdaf afd"
},
"description": "asfd sdaf afd",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeproblemblocka4455860b03647219ff8b01cde49cf37-709012d2",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
},
{
"display_name": "Module 1: Dive into the Open edX® platform!",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@30b3fbb840024953b2d4b2e700a53002"
},
{
"display_name": "Subsection",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@sequential+block@46032ed27e6c47e887782fed91703240"
},
{
"display_name": "Unit",
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@59dc4eea07fa4c20aad677d433ada9f1"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37",
"block_type": "problem",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": "4"
}
}
],
"query": "",
"processingTimeMs": 8,
"limit": 20,
"offset": 0,
"estimatedTotalHits": 5
}
]
}

View File

@@ -1,100 +1,79 @@
[
{
"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"
}
]
{
"count": 7,
"next": null,
"previous": null,
"num_pages": 1,
"current_page": 1,
"results": [
{
"id": 875,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 10,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
{
"id": 876,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 10,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
{
"id": 884,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 26,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 16,
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
{
"id": 889,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 10,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
{
"id": 890,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 10,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
}
]
}

View File

@@ -1,36 +1,121 @@
/* istanbul ignore file */
// eslint-disable-next-line import/no-extraneous-dependencies
import fetchMock from 'fetch-mock-jest';
import mockLinksResult from '../__mocks__/publishableEntityLinks.json';
import mockSummaryResult from '../__mocks__/linkCourseSummary.json';
import mockLinkDetailsFromIndex from '../__mocks__/linkDetailsFromIndex.json';
import mockLibBlockMetadata from '../__mocks__/libBlockMetadata.json';
import { createAxiosError } from '../../testUtils';
import * as api from './api';
import * as libApi from '../../library-authoring/data/api';
/**
* Mock for `getEntityLinksByDownstreamContext()`
* Mock for `getEntityLinks()`
*
* This mock returns a fixed response for the downstreamContextKey.
*/
export async function mockGetEntityLinksByDownstreamContext(
downstreamContextKey: string,
): Promise<api.PublishableEntityLink[]> {
export async function mockGetEntityLinks(
downstreamContextKey?: string,
readyToSync?: boolean,
): ReturnType<typeof api.getEntityLinks> {
switch (downstreamContextKey) {
case mockGetEntityLinksByDownstreamContext.invalidCourseKey:
case mockGetEntityLinks.invalidCourseKey:
throw createAxiosError({
code: 404,
message: 'Not found.',
path: api.getEntityLinksByDownstreamContextUrl(downstreamContextKey),
path: api.getEntityLinksByDownstreamContextUrl(),
});
case mockGetEntityLinksByDownstreamContext.courseKeyLoading:
case mockGetEntityLinks.courseKeyLoading:
return new Promise(() => {});
case mockGetEntityLinksByDownstreamContext.courseKeyEmpty:
return Promise.resolve([]);
default:
return Promise.resolve(mockGetEntityLinksByDownstreamContext.response);
case mockGetEntityLinks.courseKeyEmpty:
return Promise.resolve({
next: null,
previous: null,
nextPageNum: null,
previousPageNum: null,
count: 0,
numPages: 0,
currentPage: 0,
results: [],
});
default: {
const { response } = mockGetEntityLinks;
if (readyToSync !== undefined) {
response.results = response.results.filter((o) => o.readyToSync === readyToSync);
response.count = response.results.length;
}
return Promise.resolve(response);
}
}
}
mockGetEntityLinksByDownstreamContext.courseKey = mockLinksResult[0].downstreamContextKey;
mockGetEntityLinksByDownstreamContext.invalidCourseKey = 'course_key_error';
mockGetEntityLinksByDownstreamContext.courseKeyLoading = 'courseKeyLoading';
mockGetEntityLinksByDownstreamContext.courseKeyEmpty = 'courseKeyEmpty';
mockGetEntityLinksByDownstreamContext.response = mockLinksResult;
mockGetEntityLinks.courseKey = mockLinksResult.results[0].downstreamContextKey;
mockGetEntityLinks.invalidCourseKey = 'course_key_error';
mockGetEntityLinks.courseKeyLoading = 'courseKeyLoading';
mockGetEntityLinks.courseKeyEmpty = 'courseKeyEmpty';
mockGetEntityLinks.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);
mockGetEntityLinks.applyMock = () => {
jest.spyOn(api, 'getEntityLinks').mockImplementation(mockGetEntityLinks);
};
/**
* Mock for `getEntityLinksSummaryByDownstreamContext()`
*
* This mock returns a fixed response for the downstreamContextKey.
*/
export async function mockGetEntityLinksSummaryByDownstreamContext(
courseId?: string,
): ReturnType<typeof api.getEntityLinksSummaryByDownstreamContext> {
switch (courseId) {
case mockGetEntityLinksSummaryByDownstreamContext.invalidCourseKey:
throw createAxiosError({
code: 404,
message: 'Not found.',
path: api.getEntityLinksByDownstreamContextUrl(),
});
case mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading:
return new Promise(() => {});
case mockGetEntityLinksSummaryByDownstreamContext.courseKeyEmpty:
return Promise.resolve([]);
case mockGetEntityLinksSummaryByDownstreamContext.courseKeyUpToDate:
return Promise.resolve(mockGetEntityLinksSummaryByDownstreamContext.response.filter(
(o: { readyToSyncCount: number }) => o.readyToSyncCount === 0,
));
default:
return Promise.resolve(mockGetEntityLinksSummaryByDownstreamContext.response);
}
}
mockGetEntityLinksSummaryByDownstreamContext.courseKey = mockLinksResult.results[0].downstreamContextKey;
mockGetEntityLinksSummaryByDownstreamContext.invalidCourseKey = 'course_key_error';
mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading = 'courseKeySummaryLoading';
mockGetEntityLinksSummaryByDownstreamContext.courseKeyEmpty = 'courseKeyEmpty';
mockGetEntityLinksSummaryByDownstreamContext.courseKeyUpToDate = 'courseKeyUpToDate';
mockGetEntityLinksSummaryByDownstreamContext.response = mockSummaryResult;
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockGetEntityLinksSummaryByDownstreamContext.applyMock = () => {
jest.spyOn(api, 'getEntityLinksSummaryByDownstreamContext').mockImplementation(mockGetEntityLinksSummaryByDownstreamContext);
};
/**
* Mock for multi-search from meilisearch index for link details.
*/
export async function mockFetchIndexDocuments() {
return mockLinkDetailsFromIndex;
}
mockFetchIndexDocuments.applyMock = () => {
fetchMock.post(
'http://mock.meilisearch.local/multi-search',
mockFetchIndexDocuments,
{ overwriteRoutes: true },
);
};
/**
* Mock for library block metadata
*/
export async function mockUseLibBlockMetadata() {
return mockLibBlockMetadata;
}
mockUseLibBlockMetadata.applyMock = () => {
jest.spyOn(libApi, 'getLibraryBlockMetadata').mockImplementation(mockUseLibBlockMetadata);
};

View File

@@ -3,27 +3,84 @@ 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 const getEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/`;
export const getEntityLinksSummaryByDownstreamContextUrl = (downstreamContextKey: string) => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/${downstreamContextKey}/summary`;
export interface PaginatedData<T> {
next: string | null;
previous: string | null;
nextPageNum: number | null;
previousPageNum: number | null;
count: number;
numPages: number;
currentPage: number;
results: T,
}
export interface PublishableEntityLink {
id: number;
upstreamUsageKey: string;
upstreamContextKey: string;
upstreamContextTitle: string;
upstreamVersion: string;
upstreamVersion: number;
downstreamUsageKey: string;
downstreamContextTitle: string;
downstreamContextKey: string;
versionSynced: string;
versionDeclined: string;
versionSynced: number;
versionDeclined: number | null;
created: string;
updated: string;
readyToSync: boolean;
}
export const getEntityLinksByDownstreamContext = async (
downstreamContextKey: string,
): Promise<PublishableEntityLink[]> => {
export interface PublishableEntityLinkSummary {
upstreamContextKey: string;
upstreamContextTitle: string;
readyToSyncCount: number;
totalCount: number;
}
export const getEntityLinks = async (
downstreamContextKey?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
pageParam?: number,
pageSize?: number,
): Promise<PaginatedData<PublishableEntityLink[]>> => {
const { data } = await getAuthenticatedHttpClient()
.get(getEntityLinksByDownstreamContextUrl(downstreamContextKey));
.get(getEntityLinksByDownstreamContextUrl(), {
params: {
course_id: downstreamContextKey,
ready_to_sync: readyToSync,
upstream_usage_key: upstreamUsageKey,
page_size: pageSize,
page: pageParam,
},
});
return camelCaseObject(data);
};
export const getUnpaginatedEntityLinks = async (
downstreamContextKey?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
): Promise<PublishableEntityLink[]> => {
const { data } = await getAuthenticatedHttpClient()
.get(getEntityLinksByDownstreamContextUrl(), {
params: {
course_id: downstreamContextKey,
ready_to_sync: readyToSync,
upstream_usage_key: upstreamUsageKey,
no_page: true,
},
});
return camelCaseObject(data);
};
export const getEntityLinksSummaryByDownstreamContext = async (
downstreamContextKey: string,
): Promise<PublishableEntityLinkSummary[]> => {
const { data } = await getAuthenticatedHttpClient()
.get(getEntityLinksSummaryByDownstreamContextUrl(downstreamContextKey));
return camelCaseObject(data);
};

View File

@@ -5,7 +5,7 @@ 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';
import { useEntityLinks, useUnpaginatedEntityLinks } from './apiHooks';
let axiosMock: MockAdapter;
@@ -36,15 +36,39 @@ describe('course libraries api hooks', () => {
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 });
afterEach(() => {
axiosMock.reset();
});
it('should return paginated links for course', async () => {
const courseId = 'course-v1:some+key';
const url = getEntityLinksByDownstreamContextUrl();
const expectedResult = {
next: null, results: [], previous: null, total: 0,
};
axiosMock.onGet(url).reply(200, expectedResult);
const { result } = renderHook(() => useEntityLinks({ courseId }), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
expect(result.current.data).toEqual([]);
expect(result.current.data?.pages).toEqual([expectedResult]);
expect(axiosMock.history.get[0].url).toEqual(url);
});
it('should return links for course', async () => {
const courseId = 'course-v1:some+key';
const url = getEntityLinksByDownstreamContextUrl();
axiosMock.onGet(url).reply(200, []);
const { result } = renderHook(() => useUnpaginatedEntityLinks({ courseId }), { wrapper });
await waitFor(() => {
expect(result.current.isLoading).toBeFalsy();
});
expect(axiosMock.history.get[0].url).toEqual(url);
expect(axiosMock.history.get[0].params).toEqual({
course_id: courseId,
ready_to_sync: undefined,
upstream_usage_key: undefined,
no_page: true,
});
});
});

View File

@@ -1,20 +1,95 @@
import {
useInfiniteQuery,
useQuery,
} from '@tanstack/react-query';
import { getEntityLinksByDownstreamContext } from './api';
import { getEntityLinks, getEntityLinksSummaryByDownstreamContext, getUnpaginatedEntityLinks } from './api';
export const courseLibrariesQueryKeys = {
all: ['courseLibraries'],
courseLibraries: (courseKey?: string) => [...courseLibrariesQueryKeys.all, courseKey],
courseLibraries: (courseId?: string) => [...courseLibrariesQueryKeys.all, courseId],
courseReadyToSyncLibraries: ({ courseId, readyToSync, upstreamUsageKey }: {
courseId?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
pageSize?: number,
}) => {
const key: Array<string | boolean | number> = [...courseLibrariesQueryKeys.all];
if (courseId !== undefined) {
key.push(courseId);
}
if (readyToSync !== undefined) {
key.push(readyToSync);
}
if (upstreamUsageKey !== undefined) {
key.push(upstreamUsageKey);
}
return key;
},
courseLibrariesSummary: (courseId?: string) => [...courseLibrariesQueryKeys.courseLibraries(courseId), 'summary'],
};
/**
* Hook to fetch a content library by its ID.
* Hook to fetch publishable entity links by course key.
* (That is, get a list of the library components used in the given course.)
*/
export const useEntityLinksByDownstreamContext = (courseKey: string | undefined) => (
useQuery({
queryKey: courseLibrariesQueryKeys.courseLibraries(courseKey),
queryFn: () => getEntityLinksByDownstreamContext(courseKey!),
enabled: courseKey !== undefined,
export const useEntityLinks = ({
courseId, readyToSync, upstreamUsageKey, pageSize,
}: {
courseId?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
pageSize?: number
}) => (
useInfiniteQuery({
queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({
courseId,
readyToSync,
upstreamUsageKey,
}),
queryFn: ({ pageParam }) => getEntityLinks(
courseId,
readyToSync,
upstreamUsageKey,
pageParam,
pageSize,
),
getNextPageParam: (lastPage) => lastPage.nextPageNum,
enabled: courseId !== undefined || upstreamUsageKey !== undefined || readyToSync !== undefined,
})
);
/**
* Hook to fetch unpaginated list of publishable entity links by course key.
*/
export const useUnpaginatedEntityLinks = ({
courseId, readyToSync, upstreamUsageKey,
}: {
courseId?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
}) => (
useQuery({
queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({
courseId,
readyToSync,
upstreamUsageKey,
}),
queryFn: () => getUnpaginatedEntityLinks(
courseId,
readyToSync,
upstreamUsageKey,
),
enabled: courseId !== undefined || upstreamUsageKey !== undefined || readyToSync !== undefined,
})
);
/**
* Hook to fetch publishable entity links summary by course key.
*/
export const useEntityLinksSummaryByDownstreamContext = (courseId?: string) => (
useQuery({
queryKey: courseLibrariesQueryKeys.courseLibrariesSummary(courseId),
queryFn: () => getEntityLinksSummaryByDownstreamContext(courseId!),
enabled: courseId !== undefined,
})
);

View File

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

View File

@@ -18,7 +18,7 @@ const messages = defineMessages({
},
homeTabDescription: {
id: 'course-authoring.course-libraries.tab.home.description',
defaultMessage: 'This course contains content from these libraries.',
defaultMessage: 'Your course contains content from these libraries.',
description: 'Description text for home tab',
},
homeTabDescriptionEmpty: {
@@ -28,18 +28,18 @@ const messages = defineMessages({
},
reviewTabTitle: {
id: 'course-authoring.course-libraries.tab.review.title',
defaultMessage: 'Review Content Updates ({count})',
defaultMessage: 'Review Content Updates',
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',
reviewTabDescriptionEmpty: {
id: 'course-authoring.course-libraries.tab.home.description-no-links',
defaultMessage: 'All components are up to date',
description: 'Description text for home tab',
},
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.',
breadcrumbLabel: {
id: 'course-authoring.course-libraries.downstream-block.breadcrumb.label',
defaultMessage: 'Location:',
description: 'label for breadcrumb in component cards in course libraries page.',
},
totalComponentLabel: {
id: 'course-authoring.course-libraries.libcard.total-component.label',
@@ -58,7 +58,7 @@ const messages = defineMessages({
},
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',
defaultMessage: '{outOfSyncCount, plural, one {# library component is} other {# 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: {
@@ -76,6 +76,51 @@ const messages = defineMessages({
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.',
},
cardReviewContentBtn: {
id: 'course-authoring.course-libraries.review-tab.libcard.review-btn-text',
defaultMessage: 'Review Updates',
description: 'Card review button for component in review tab',
},
cardUpdateContentBtn: {
id: 'course-authoring.course-libraries.review-tab.libcard.update-btn-text',
defaultMessage: 'Update',
description: 'Card update button for component in review tab',
},
cardIgnoreContentBtn: {
id: 'course-authoring.course-libraries.review-tab.libcard.ignore-btn-text',
defaultMessage: 'Ignore',
description: 'Card ignore button for component in review tab',
},
updateSingleBlockSuccess: {
id: 'course-authoring.course-libraries.review-tab.libcard.update-success-toast',
defaultMessage: 'Success! "{name}" is updated',
description: 'Success toast message when a component is updated.',
},
ignoreSingleBlockSuccess: {
id: 'course-authoring.course-libraries.review-tab.libcard.ignore-success-toast',
defaultMessage: '"{name}" will remain out of sync with library content. You will be notified when this component is updated again.',
description: 'Success toast message when a component update is ignored.',
},
searchPlaceholder: {
id: 'course-authoring.course-libraries.review-tab.search.placeholder',
defaultMessage: 'Search',
description: 'Search text box in review tab placeholder text',
},
brokenLinkTooltip: {
id: 'course-authoring.course-libraries.home-tab.broken-link.tooltip',
defaultMessage: 'Sourced from a library - but the upstream link is broken/invalid.',
description: 'Tooltip text describing broken link in component listing.',
},
genericErrorMessage: {
id: 'course-authoring.course-libraries.home-tab.error.message',
defaultMessage: 'Something went wrong! Could not fetch results.',
description: 'Generic error message displayed when fetching link data fails.',
},
olderVersionPreviewAlert: {
id: 'course-authoring.course-libraries.reviw-tab.preview.old-version-alert',
defaultMessage: 'The old version preview is the previous library version',
description: 'Alert message stating that older version in preview is of library block',
},
});
export default messages;

View File

@@ -13,7 +13,7 @@ import {
import {
Alert, Button, Hyperlink, Truncate,
} from '@openedx/paragon';
import { Link } from 'react-router-dom';
import { Link, useNavigate } from 'react-router-dom';
import ErrorAlert from '../../editors/sharedComponents/ErrorAlerts/ErrorAlert';
import { RequestStatus } from '../../data/constants';
@@ -24,6 +24,7 @@ import advancedSettingsMessages from '../../advanced-settings/messages';
import { getPasteFileNotices } from '../data/selectors';
import { dismissError, removePasteFileNotices } from '../data/slice';
import { API_ERROR_TYPES } from '../constants';
import { OutOfSyncAlert } from '../../course-libraries/OutOfSyncAlert';
const PageAlerts = ({
courseId,
@@ -48,6 +49,8 @@ const PageAlerts = ({
localStorage.getItem(discussionAlertDismissKey) === null,
);
const { newFiles, conflictingFiles, errorFiles } = useSelector(getPasteFileNotices);
const [showOutOfSyncAlert, setShowOutOfSyncAlert] = useState(false);
const navigate = useNavigate();
const getAssetsUrl = () => {
if (getConfig().ENABLE_ASSETS_PAGE === 'true') {
@@ -419,6 +422,15 @@ const PageAlerts = ({
);
};
const renderOutOfSyncAlert = () => (
<OutOfSyncAlert
courseId={courseId}
onReview={() => navigate(`/course/${courseId}/libraries?tab=review`)}
showAlert={showOutOfSyncAlert}
setShowAlert={setShowOutOfSyncAlert}
/>
);
return (
<>
{configurationErrors()}
@@ -432,6 +444,7 @@ const PageAlerts = ({
{errorFilesPasteAlert()}
{conflictingFilesPasteAlert()}
{newFilesPasteAlert()}
{renderOutOfSyncAlert()}
</>
);
};

View File

@@ -28,6 +28,13 @@ jest.mock('react-redux', () => ({
useSelector: jest.fn(),
}));
jest.mock('../../course-libraries/data/apiHooks', () => ({
useEntityLinksSummaryByDownstreamContext: () => ({
data: [],
isLoading: false,
}),
}));
let store;
const handleDismissNotification = jest.fn();
@@ -70,9 +77,9 @@ describe('<PageAlerts />', () => {
useSelector.mockReturnValue({});
});
it('renders null when no alerts are present', () => {
it('renders null when no alerts are present', async () => {
renderComponent();
expect(screen.queryByTestId('browser-router')).toBeEmptyDOMElement();
expect(await screen.findByTestId('browser-router')).toBeEmptyDOMElement();
});
it('renders configuration alerts', async () => {

View File

@@ -36,7 +36,7 @@ import TagsSidebarControls from '../content-tags-drawer/tags-sidebar-controls';
import { PasteNotificationAlert } from './clipboard';
import XBlockContainerIframe from './xblock-container-iframe';
import MoveModal from './move-modal';
import PreviewLibraryXBlockChanges from './preview-changes';
import IframePreviewLibraryXBlockChanges from './preview-changes';
const CourseUnit = ({ courseId }) => {
const { blockId } = useParams();
@@ -213,7 +213,7 @@ const CourseUnit = ({ courseId }) => {
closeModal={closeMoveModal}
courseId={courseId}
/>
<PreviewLibraryXBlockChanges />
<IframePreviewLibraryXBlockChanges />
</Layout.Element>
<Layout.Element>
<Stack gap={3}>

View File

@@ -352,10 +352,10 @@ describe('<CourseUnit />', () => {
it('checks whether xblock is removed when the corresponding delete button is clicked and the sidebar is the updated', async () => {
const {
getByTitle, getByText, queryByRole, getAllByRole, getByRole,
getByTitle, getByText, queryByRole, getByRole,
} = render(<RootWrapper />);
await waitFor(() => {
await waitFor(async () => {
const iframe = getByTitle(xblockContainerIframeMessages.xblockIframeTitle.defaultMessage);
expect(iframe).toHaveAttribute(
'aria-label',
@@ -370,13 +370,12 @@ describe('<CourseUnit />', () => {
expect(getByText(/Delete this component?/i)).toBeInTheDocument();
expect(getByText(/Deleting this component is permanent and cannot be undone./i)).toBeInTheDocument();
expect(getByRole('dialog')).toBeInTheDocument();
const dialog = getByRole('dialog');
expect(dialog).toBeInTheDocument();
// Find the Cancel and Delete buttons within the iframe by their specific classes
const cancelButton = getAllByRole('button', { name: /Cancel/i })
.find(({ classList }) => classList.contains('btn-tertiary'));
const deleteButton = getAllByRole('button', { name: /Delete/i })
.find(({ classList }) => classList.contains('btn-primary'));
const cancelButton = await within(dialog).findByRole('button', { name: /Cancel/i });
const deleteButton = await within(dialog).findByRole('button', { name: /Delete/i });
expect(cancelButton).toBeInTheDocument();

View File

@@ -8,7 +8,7 @@ import {
waitFor,
} from '../../testUtils';
import PreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.';
import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.';
import { messageTypes } from '../constants';
import { IframeProvider } from '../context/iFrameContext';
import { libraryBlockChangesUrl } from '../data/api';
@@ -31,7 +31,7 @@ jest.mock('../context/hooks', () => ({
}),
}));
const render = (eventData?: LibraryChangesMessageData) => {
baseRender(<PreviewLibraryXBlockChanges />, {
baseRender(<IframePreviewLibraryXBlockChanges />, {
extraWrapper: ({ children }) => <IframeProvider>{ children }</IframeProvider>,
});
const message = {
@@ -49,7 +49,7 @@ const render = (eventData?: LibraryChangesMessageData) => {
let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;
describe('<PreviewLibraryXBlockChanges />', () => {
describe('<IframePreviewLibraryXBlockChanges />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;

View File

@@ -1,4 +1,4 @@
import { useCallback, useContext, useState } from 'react';
import React, { useCallback, useContext, useState } from 'react';
import {
ActionRow, Button, ModalDialog, useToggle,
} from '@openedx/paragon';
@@ -24,36 +24,34 @@ export interface LibraryChangesMessageData {
isVertical: boolean,
}
const PreviewLibraryXBlockChanges = () => {
export interface PreviewLibraryXBlockChangesProps {
blockData?: LibraryChangesMessageData,
isModalOpen: boolean,
closeModal: () => void,
postChange: (accept: boolean) => void,
alertNode?: React.ReactNode,
}
/**
* Component to preview two xblock versions in a modal that depends on params
* to display blocks, open-close modal, accept-ignore changes and post change triggers
*/
export const PreviewLibraryXBlockChanges = ({
blockData,
isModalOpen,
closeModal,
postChange,
alertNode,
}: PreviewLibraryXBlockChangesProps) => {
const { showToast } = useContext(ToastContext);
const intl = useIntl();
const [blockData, setBlockData] = useState<LibraryChangesMessageData | undefined>(undefined);
// Main preview library modal toggle.
const [isModalOpen, openModal, closeModal] = useToggle(false);
// ignore changes confirmation modal toggle.
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
const { data: componentMetadata } = useLibraryBlockMetadata(blockData?.upstreamBlockId);
const acceptChangesMutation = useAcceptLibraryBlockChanges();
const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
const { data: componentMetadata } = useLibraryBlockMetadata(blockData?.upstreamBlockId);
const { sendMessageToIframe } = useIframe();
const receiveMessage = useCallback(({ data }: { data: {
payload: LibraryChangesMessageData;
type: string;
} }) => {
const { payload, type } = data;
if (type === messageTypes.showXBlockLibraryChangesPreview) {
setBlockData(payload);
openModal();
}
}, [openModal]);
useEventListener('message', receiveMessage);
const getTitle = useCallback(() => {
const oldName = blockData?.displayName;
@@ -95,7 +93,7 @@ const PreviewLibraryXBlockChanges = () => {
try {
await mutation.mutateAsync(blockData.downstreamBlockId);
sendMessageToIframe(messageTypes.refreshXBlock, null);
postChange(accept);
} catch (e) {
showToast(intl.formatMessage(failureMsg));
} finally {
@@ -112,6 +110,7 @@ const PreviewLibraryXBlockChanges = () => {
className="lib-preview-xblock-changes-modal"
hasCloseButton
isFullscreenOnMobile
isOverflowVisible={false}
>
<ModalDialog.Header>
<ModalDialog.Title>
@@ -119,6 +118,7 @@ const PreviewLibraryXBlockChanges = () => {
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body>
{alertNode}
{getBody()}
</ModalDialog.Body>
<ModalDialog.Footer>
@@ -151,4 +151,42 @@ const PreviewLibraryXBlockChanges = () => {
);
};
export default PreviewLibraryXBlockChanges;
/**
* Wrapper over PreviewLibraryXBlockChanges to preview two xblock versions in a modal
* that depends on iframe message events to setBlockData and display modal.
*/
const IframePreviewLibraryXBlockChanges = () => {
const [blockData, setBlockData] = useState<LibraryChangesMessageData | undefined>(undefined);
// Main preview library modal toggle.
const [isModalOpen, openModal, closeModal] = useToggle(false);
const { sendMessageToIframe } = useIframe();
const receiveMessage = useCallback(({ data }: {
data: {
payload: LibraryChangesMessageData;
type: string;
}
}) => {
const { payload, type } = data;
if (type === messageTypes.showXBlockLibraryChangesPreview) {
setBlockData(payload);
openModal();
}
}, [openModal]);
useEventListener('message', receiveMessage);
return (
<PreviewLibraryXBlockChanges
blockData={blockData}
isModalOpen={isModalOpen}
closeModal={closeModal}
postChange={() => sendMessageToIframe(messageTypes.refreshXBlock, null)}
/>
);
};
export default IframePreviewLibraryXBlockChanges;

View File

@@ -16,12 +16,18 @@ import PropTypes from 'prop-types';
* @param {string} props.label
* @param {function=} props.onClick
* @param {boolean=} props.disabled
* @param {string=} props.size
* @param {string=} props.variant
* @param {string=} props.className
* @returns {JSX.Element}
*/
const LoadingButton = ({
label,
onClick,
disabled,
size,
variant,
className,
}) => {
const [state, setState] = useState('');
// This is used to prevent setting the isLoading state after the component has been unmounted.
@@ -54,6 +60,9 @@ const LoadingButton = ({
onClick={loadingOnClick}
labels={{ default: label }}
state={state}
size={size}
variant={variant}
className={className}
/>
);
};
@@ -62,11 +71,17 @@ LoadingButton.propTypes = {
label: PropTypes.string.isRequired,
onClick: PropTypes.func,
disabled: PropTypes.bool,
size: PropTypes.string,
variant: PropTypes.string,
className: PropTypes.string,
};
LoadingButton.defaultProps = {
onClick: undefined,
disabled: undefined,
size: undefined,
variant: '',
className: '',
};
export default LoadingButton;

View File

@@ -8,10 +8,10 @@ import {
import { mockFetchIndexDocuments, mockContentSearchConfig } from '../../search-manager/data/api.mock';
import {
mockContentLibrary,
mockGetUnpaginatedEntityLinks,
mockLibraryBlockMetadata,
mockXBlockAssets,
mockXBlockOLX,
mockComponentDownstreamLinks,
} from '../data/api.mocks';
import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext';
import ComponentDetails from './ComponentDetails';
@@ -21,7 +21,7 @@ mockContentLibrary.applyMock();
mockLibraryBlockMetadata.applyMock();
mockXBlockAssets.applyMock();
mockXBlockOLX.applyMock();
mockComponentDownstreamLinks.applyMock();
mockGetUnpaginatedEntityLinks.applyMock();
mockFetchIndexDocuments.applyMock();
const render = (usageKey: string) => baseRender(<ComponentDetails />, {
@@ -53,7 +53,7 @@ describe('<ComponentDetails />', () => {
});
it('should render the component usage', async () => {
render(mockComponentDownstreamLinks.usageKey);
render(mockLibraryBlockMetadata.usageKeyPublished);
expect(await screen.findByText('Component Usage')).toBeInTheDocument();
const course1 = await screen.findByText('Course 1');
expect(course1).toBeInTheDocument();

View File

@@ -7,19 +7,20 @@ import {
import {
mockContentLibrary,
mockLibraryBlockMetadata,
mockComponentDownstreamLinks,
mockGetUnpaginatedEntityLinks,
} from '../data/api.mocks';
import { mockFetchIndexDocuments } from '../../search-manager/data/api.mock';
import { mockContentSearchConfig, mockFetchIndexDocuments } from '../../search-manager/data/api.mock';
import { mockBroadcastChannel } from '../../generic/data/api.mock';
import { LibraryProvider } from '../common/context/LibraryContext';
import { SidebarBodyComponentId, SidebarProvider } from '../common/context/SidebarContext';
import ComponentInfo from './ComponentInfo';
import { getXBlockPublishApiUrl } from '../data/api';
mockContentSearchConfig.applyMock();
mockBroadcastChannel();
mockContentLibrary.applyMock();
mockLibraryBlockMetadata.applyMock();
mockComponentDownstreamLinks.applyMock();
mockGetUnpaginatedEntityLinks.applyMock();
mockFetchIndexDocuments.applyMock();
jest.mock('./ComponentPreview', () => ({
__esModule: true, // Required when mocking 'default' export
@@ -99,6 +100,7 @@ describe('<ComponentInfo> Sidebar', () => {
});
it('should show publish confirmation on first publish', async () => {
initializeMocks();
render(
<ComponentInfo />,
withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyNeverPublished),
@@ -114,6 +116,7 @@ describe('<ComponentInfo> Sidebar', () => {
});
it('should show publish confirmation on already published', async () => {
initializeMocks();
render(
<ComponentInfo />,
withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyPublishedWithChanges),
@@ -130,6 +133,7 @@ describe('<ComponentInfo> Sidebar', () => {
});
it('should show publish confirmation on already published empty downstreams', async () => {
initializeMocks();
render(
<ComponentInfo />,
withLibraryId(mockContentLibrary.libraryId, mockLibraryBlockMetadata.usageKeyPublishedWithChangesV2),

View File

@@ -1,11 +1,12 @@
import { getConfig } from '@edx/frontend-platform';
import { FormattedMessage } from '@edx/frontend-platform/i18n';
import { Collapsible, Hyperlink, Stack } from '@openedx/paragon';
import { useMemo } from 'react';
import { useUnpaginatedEntityLinks } from '../../course-libraries/data/apiHooks';
import AlertError from '../../generic/alert-error';
import Loading from '../../generic/Loading';
import { useFetchIndexDocuments } from '../../search-manager';
import { useComponentDownstreamLinks } from '../data/apiHooks';
import { useContentSearchConnection, useContentSearchResults } from '../../search-manager';
import messages from './messages';
interface ComponentUsageProps {
@@ -33,20 +34,27 @@ export const ComponentUsage = ({ usageKey }: ComponentUsageProps) => {
isError: isErrorDownstreamLinks,
error: errorDownstreamLinks,
isLoading: isLoadingDownstreamLinks,
} = useComponentDownstreamLinks(usageKey);
} = useUnpaginatedEntityLinks({ upstreamUsageKey: usageKey });
const downstreamKeys = dataDownstreamLinks || [];
const downstreamKeys = useMemo(
() => dataDownstreamLinks?.map(link => link.downstreamUsageKey) || [],
[dataDownstreamLinks],
);
const { client, indexName } = useContentSearchConnection();
const {
data: downstreamHits,
hits: downstreamHits,
isError: isErrorIndexDocuments,
error: errorIndexDocuments,
isLoading: isLoadingIndexDocuments,
} = useFetchIndexDocuments({
filter: [`usage_key IN ["${downstreamKeys.join('","')}"]`],
} = useContentSearchResults({
client,
indexName,
searchKeywords: '',
extraFilter: [`usage_key IN ["${downstreamKeys.join('","')}"]`],
limit: downstreamKeys.length,
attributesToRetrieve: ['usage_key', 'breadcrumbs', 'context_key'],
enabled: !!downstreamKeys.length,
skipBlockTypeFetch: true,
});
if (isErrorDownstreamLinks || isErrorIndexDocuments) {
@@ -62,9 +70,9 @@ export const ComponentUsage = ({ usageKey }: ComponentUsageProps) => {
}
const componentUsage = downstreamHits.reduce<ComponentUsageTree>((acc, hit) => {
const link = hit.breadcrumbs.at(-1);
const link = hit.breadcrumbs.at(-1) as { displayName: string, usageKey: string };
// istanbul ignore if: this should never happen. it is a type guard for the breadcrumb last item
if (!(link && ('usageKey' in link))) {
if (!link?.usageKey) {
return acc;
}

View File

@@ -5,7 +5,7 @@ import BaseModal from '../../editors/sharedComponents/BaseModal';
import messages from './messages';
import infoMessages from '../component-info/messages';
import { ComponentUsage } from '../component-info/ComponentUsage';
import { useComponentDownstreamLinks } from '../data/apiHooks';
import { useUnpaginatedEntityLinks } from '../../course-libraries/data/apiHooks';
interface PublishConfirmationModalProps {
isOpen: boolean,
@@ -29,7 +29,7 @@ const PublishConfirmationModal = ({
const {
data: dataDownstreamLinks,
isLoading: isLoadingDownstreamLinks,
} = useComponentDownstreamLinks(usageKey);
} = useUnpaginatedEntityLinks({ upstreamUsageKey: usageKey });
const hasDownstreamUsages = !isLoadingDownstreamLinks && dataDownstreamLinks?.length !== 0;

View File

@@ -4,7 +4,9 @@ import { mockContentTaxonomyTagsData } from '../../content-tags-drawer/data/api.
import { getBlockType } from '../../generic/key-utils';
import { createAxiosError } from '../../testUtils';
import contentLibrariesListV2 from '../__mocks__/contentLibrariesListV2';
import downstreamLinkInfo from '../../search-manager/data/__mocks__/downstream-links.json';
import * as api from './api';
import * as courseLibApi from '../../course-libraries/data/api';
/**
* Mock for `getContentLibraryV2List()`
@@ -569,28 +571,38 @@ mockBlockTypesMetadata.blockTypesMetadata = [
/** Apply this mock. Returns a spy object that can tell you if it's been called. */
mockBlockTypesMetadata.applyMock = () => jest.spyOn(api, 'getBlockTypes').mockImplementation(mockBlockTypesMetadata);
export async function mockComponentDownstreamLinks(
usageKey: string,
): ReturnType<typeof api.getComponentDownstreamLinks> {
const thisMock = mockComponentDownstreamLinks;
switch (usageKey) {
case thisMock.usageKey: return thisMock.componentUsage;
case mockLibraryBlockMetadata.usageKeyPublishedWithChanges: return thisMock.componentUsage;
case mockLibraryBlockMetadata.usageKeyPublishedWithChangesV2: return thisMock.emptyComponentUsage;
export async function mockGetUnpaginatedEntityLinks(
_downstreamContextKey?: string,
_readyToSync?: boolean,
upstreamUsageKey?: string,
): ReturnType<typeof courseLibApi.getUnpaginatedEntityLinks> {
const thisMock = mockGetUnpaginatedEntityLinks;
switch (upstreamUsageKey) {
case thisMock.upstreamUsageKey: return thisMock.response;
case mockLibraryBlockMetadata.usageKeyPublishedWithChanges: return thisMock.response;
case thisMock.emptyUsageKey: return thisMock.emptyComponentUsage;
default: return [];
}
}
mockComponentDownstreamLinks.usageKey = mockXBlockFields.usageKeyHtml;
mockComponentDownstreamLinks.componentUsage = [
'block-v1:org+course1+run+type@html+block@blockid1',
'block-v1:org+course1+run+type@html+block@blockid2',
'block-v1:org+course1+run+type@html+block@blockid3',
'block-v1:org+course2+run+type@html+block@blockid1',
] satisfies Awaited<ReturnType<typeof api.getComponentDownstreamLinks>>;
mockComponentDownstreamLinks.emptyUsageKey = 'lb:Axim:TEST1:html:571fe018-f3ce-45c9-8f53-5dafcb422fd1';
mockComponentDownstreamLinks.emptyComponentUsage = [] as string[];
mockGetUnpaginatedEntityLinks.upstreamUsageKey = mockLibraryBlockMetadata.usageKeyPublished;
mockGetUnpaginatedEntityLinks.response = downstreamLinkInfo.results[0].hits.map((obj: { usageKey: any; }) => ({
id: 875,
upstreamContextTitle: 'CS problems 3',
upstreamVersion: 10,
readyToSync: true,
upstreamUsageKey: mockLibraryBlockMetadata.usageKeyPublished,
upstreamContextKey: 'lib:Axim:TEST2',
downstreamUsageKey: obj.usageKey,
downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX',
versionSynced: 2,
versionDeclined: null,
created: '2025-02-08T14:07:05.588484Z',
updated: '2025-02-08T14:07:05.588484Z',
}));
mockGetUnpaginatedEntityLinks.emptyUsageKey = 'lb:Axim:TEST1:html:empty';
mockGetUnpaginatedEntityLinks.emptyComponentUsage = [] as courseLibApi.PublishableEntityLink[];
mockComponentDownstreamLinks.applyMock = () => jest.spyOn(
api,
'getComponentDownstreamLinks',
).mockImplementation(mockComponentDownstreamLinks);
mockGetUnpaginatedEntityLinks.applyMock = () => jest.spyOn(
courseLibApi,
'getUnpaginatedEntityLinks',
).mockImplementation(mockGetUnpaginatedEntityLinks);

View File

@@ -103,10 +103,6 @@ export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
* Get the URL for the content store api.
*/
export const getContentStoreApiUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/`;
/**
* Get the URL for the component downstream contexts API.
*/
export const getComponentDownstreamContextsApiUrl = (usageKey: string) => `${getContentStoreApiUrl()}upstream/${usageKey}/downstream-links`;
export interface ContentLibrary {
id: string;
@@ -561,11 +557,3 @@ export async function updateComponentCollections(usageKey: string, collectionKey
collection_keys: collectionKeys,
});
}
/**
* Fetch downstream links for a component.
*/
export async function getComponentDownstreamLinks(usageKey: string): Promise<string[]> {
const { data } = await getAuthenticatedHttpClient().get(getComponentDownstreamContextsApiUrl(usageKey));
return data;
}

View File

@@ -46,7 +46,6 @@ import {
deleteXBlockAsset,
restoreLibraryBlock,
getBlockTypes,
getComponentDownstreamLinks,
} from './api';
import { VersionSpec } from '../LibraryBlock';
@@ -561,14 +560,3 @@ export const useUpdateComponentCollections = (libraryId: string, usageKey: strin
},
});
};
/**
* Get the downstream links of a component in a library
*/
export const useComponentDownstreamLinks = (usageKey: string) => (
useQuery({
queryKey: xblockQueryKeys.componentDownstreamLinks(usageKey),
queryFn: () => getComponentDownstreamLinks(usageKey),
enabled: !!usageKey,
})
);

View File

@@ -8,7 +8,13 @@ import messages from './messages';
import { SearchSortOption } from './data/api';
import { useSearchContext } from './SearchManager';
export const SearchSortWidget = ({ iconOnly = false }: { iconOnly?: boolean }) => {
export const SearchSortWidget = ({
iconOnly = false,
disableOptions,
}: {
iconOnly?: boolean;
disableOptions?: SearchSortOption[];
}) => {
const intl = useIntl();
const {
searchSortOrder,
@@ -22,43 +28,46 @@ export const SearchSortWidget = ({ iconOnly = false }: { iconOnly?: boolean }) =
id: 'search-sort-option-most-relevant',
name: intl.formatMessage(messages.searchSortMostRelevant),
value: SearchSortOption.RELEVANCE,
show: (defaultSearchSortOrder === SearchSortOption.RELEVANCE),
show: (
!disableOptions?.includes(SearchSortOption.RELEVANCE)
&& defaultSearchSortOrder === SearchSortOption.RELEVANCE
),
},
{
id: 'search-sort-option-recently-modified',
name: intl.formatMessage(messages.searchSortRecentlyModified),
value: SearchSortOption.RECENTLY_MODIFIED,
show: true,
show: !disableOptions?.includes(SearchSortOption.RECENTLY_MODIFIED),
},
{
id: 'search-sort-option-recently-published',
name: intl.formatMessage(messages.searchSortRecentlyPublished),
value: SearchSortOption.RECENTLY_PUBLISHED,
show: true,
show: !disableOptions?.includes(SearchSortOption.RECENTLY_PUBLISHED),
},
{
id: 'search-sort-option-title-az',
name: intl.formatMessage(messages.searchSortTitleAZ),
value: SearchSortOption.TITLE_AZ,
show: true,
show: !disableOptions?.includes(SearchSortOption.TITLE_AZ),
},
{
id: 'search-sort-option-title-za',
name: intl.formatMessage(messages.searchSortTitleZA),
value: SearchSortOption.TITLE_ZA,
show: true,
show: !disableOptions?.includes(SearchSortOption.TITLE_ZA),
},
{
id: 'search-sort-option-newest',
name: intl.formatMessage(messages.searchSortNewest),
value: SearchSortOption.NEWEST,
show: true,
show: !disableOptions?.includes(SearchSortOption.NEWEST),
},
{
id: 'search-sort-option-oldest',
name: intl.formatMessage(messages.searchSortOldest),
value: SearchSortOption.OLDEST,
show: true,
show: !disableOptions?.includes(SearchSortOption.OLDEST),
},
],
[intl, defaultSearchSortOrder],

View File

@@ -1,96 +1,100 @@
{
"comment": "This is a mock of the response from Meilisearch for downstream links",
"estimatedTotalHits": 3,
"query": "",
"limit": 3,
"offset": 0,
"processingTimeMs": 1,
"hits": [
"results": [
{
"usageKey": "block-v1:org+course1+run+type@html+block@blockid1",
"contextKey": "course-v1:org+course1+run",
"breadcrumbs": [
"estimatedTotalHits": 3,
"query": "",
"limit": 3,
"offset": 0,
"processingTimeMs": 1,
"hits": [
{
"display_name": "Course 1"
"usageKey": "block-v1:org+course1+run+type@html+block@blockid1",
"contextKey": "course-v1:org+course1+run",
"breadcrumbs": [
{
"display_name": "Course 1"
},
{
"usage_key": "unit-v1:org+course1+run+section1",
"display_name": "Section 1"
},
{
"usage_key": "unit-v1:org+course1+run+subsection1",
"display_name": "Sub Section 1"
},
{
"usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId1",
"display_name": "Unit 1"
}
]
},
{
"usage_key": "unit-v1:org+course1+run+section1",
"display_name": "Section 1"
"usage_key": "block-v1:org+course1+run+type@html+block@blockid2",
"contextKey": "course-v1:org+course1+run",
"breadcrumbs": [
{
"display_name": "Course 1"
},
{
"usage_key": "unit-v1:org+course1+run+section1",
"display_name": "Section 1"
},
{
"usage_key": "unit-v1:org+course1+run+subsection1",
"display_name": "Sub Section 1"
},
{
"usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId2",
"display_name": "Unit 2"
}
]
},
{
"usage_key": "unit-v1:org+course1+run+subsection1",
"display_name": "Sub Section 1"
"usage_key": "block-v1:org+course1+run+type@html+block@blockid3",
"contextKey": "course-v1:org+course1+run",
"breadcrumbs": [
{
"display_name": "Course 1"
},
{
"usage_key": "unit-v1:org+course1+run+section1",
"display_name": "Section 1"
},
{
"usage_key": "unit-v1:org+course1+run+subsection1",
"display_name": "Sub Section 1"
},
{
"usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId2",
"display_name": "Unit 2"
}
]
},
{
"usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId1",
"display_name": "Unit 1"
}
]
},
{
"usage_key": "block-v1:org+course1+run+type@html+block@blockid2",
"contextKey": "course-v1:org+course1+run",
"breadcrumbs": [
{
"display_name": "Course 1"
},
{
"usage_key": "unit-v1:org+course1+run+section1",
"display_name": "Section 1"
},
{
"usage_key": "unit-v1:org+course1+run+subsection1",
"display_name": "Sub Section 1"
},
{
"usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId2",
"display_name": "Unit 2"
}
]
},
{
"usage_key": "block-v1:org+course1+run+type@html+block@blockid3",
"contextKey": "course-v1:org+course1+run",
"breadcrumbs": [
{
"display_name": "Course 1"
},
{
"usage_key": "unit-v1:org+course1+run+section1",
"display_name": "Section 1"
},
{
"usage_key": "unit-v1:org+course1+run+subsection1",
"display_name": "Sub Section 1"
},
{
"usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId2",
"display_name": "Unit 2"
}
]
},
{
"usage_key": "block-v1:org+course2+run+type@html+block@blockid1",
"contextKey": "course-v1:org+course2+run",
"breadcrumbs": [
{
"display_name": "Course 2"
},
{
"usage_key": "unit-v1:org+course2+run+section1",
"display_name": "Section 1"
},
{
"usage_key": "unit-v1:org+course2+run+subsection1",
"display_name": "Sub Section 1"
},
{
"usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId3",
"display_name": "Unit 3"
},
{
"usage_key": "block-v1:org+course2+run+type@itembank+block@itembankId3",
"display_name": "Problem Bank 3"
"usage_key": "block-v1:org+course2+run+type@html+block@blockid4",
"contextKey": "course-v1:org+course2+run",
"breadcrumbs": [
{
"display_name": "Course 2"
},
{
"usage_key": "unit-v1:org+course2+run+section1",
"display_name": "Section 1"
},
{
"usage_key": "unit-v1:org+course2+run+subsection1",
"display_name": "Sub Section 1"
},
{
"usage_key": "block-v1:org+course1+run+type@vertical+block@verticalId3",
"display_name": "Unit 3"
},
{
"usage_key": "block-v1:org+course2+run+type@itembank+block@itembankId3",
"display_name": "Problem Bank 3"
}
]
}
]
}

View File

@@ -16,7 +16,6 @@ export async function mockContentSearchConfig(): ReturnType<typeof api.getConten
};
}
mockContentSearchConfig.multisearchEndpointUrl = 'http://mock.meilisearch.local/multi-search';
mockContentSearchConfig.searchEndpointUrl = 'http://mock.meilisearch.local/indexes/studio/search';
mockContentSearchConfig.applyMock = () => (
jest.spyOn(api, 'getContentSearchConfig').mockImplementation(mockContentSearchConfig)
);
@@ -73,7 +72,7 @@ export async function mockFetchIndexDocuments() {
mockFetchIndexDocuments.applyMock = () => {
fetchMock.post(
mockContentSearchConfig.searchEndpointUrl,
mockContentSearchConfig.multisearchEndpointUrl,
mockFetchIndexDocuments,
{ overwriteRoutes: true },
);

View File

@@ -197,6 +197,7 @@ interface FetchSearchParams {
/** How many results to skip, e.g. if limit=20 then passing offset=20 gets the second page. */
offset?: number,
skipBlockTypeFetch?: boolean,
limit?: number,
}
export async function fetchSearchResults({
@@ -211,6 +212,7 @@ export async function fetchSearchResults({
sort,
offset = 0,
skipBlockTypeFetch = false,
limit = 20,
}: FetchSearchParams): Promise<{
hits: (ContentHit | CollectionHit)[],
nextOffset: number | undefined,
@@ -232,8 +234,6 @@ export async function fetchSearchResults({
const tagsFilterFormatted = formatTagsFilter(tagsFilter);
const limit = 20; // How many results to retrieve per page.
// To filter normal block types and problem types as 'OR' query
const typeFilters = [[
...blockTypesFilterFormatted,
@@ -521,29 +521,3 @@ 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,7 +11,6 @@ import {
getContentSearchConfig,
fetchBlockTypes,
type PublishStatus,
fetchIndexDocuments,
} from './api';
/**
@@ -59,6 +58,8 @@ export const useContentSearchResults = ({
tagsFilter = [],
sort = [],
skipBlockTypeFetch = false,
limit,
enabled = true,
}: {
/** The Meilisearch API client */
client?: MeiliSearch;
@@ -79,9 +80,13 @@ export const useContentSearchResults = ({
sort?: SearchSortOption[];
/** If true, don't fetch the block types from the server */
skipBlockTypeFetch?: boolean;
/** Limit results */
limit?: number;
/** Enable or disable api */
enabled?: boolean;
}) => {
const query = useInfiniteQuery({
enabled: client !== undefined && indexName !== undefined,
enabled: enabled && client !== undefined && indexName !== undefined,
queryKey: [
'content_search',
'results',
@@ -115,6 +120,7 @@ export const useContentSearchResults = ({
// Note that if there are 20 results per page, the "second page" has offset=20, not 2.
offset: pageParam,
skipBlockTypeFetch,
limit,
});
},
getNextPageParam: (lastPage) => lastPage.nextOffset,
@@ -138,6 +144,7 @@ export const useContentSearchResults = ({
status: query.status,
isLoading: query.isLoading,
isError: query.isError,
error: query.error,
isFetchingNextPage: query.isFetchingNextPage,
// Call this to load more pages. We include some "safety" features recommended by the docs: this should never be
// called while already fetching a page, and parameters (like 'event') should not be passed into fetchNextPage().
@@ -259,46 +266,3 @@ export const useGetBlockTypes = (extraFilters: Filter) => {
queryFn: () => fetchBlockTypes(client!, indexName!, extraFilters),
});
};
interface UseFetchIndexDocumentsParams {
filter: Filter;
limit: number;
attributesToRetrieve?: string[];
attributesToCrop?: string[];
sort?: SearchSortOption[];
enabled?: boolean;
}
/**
* Fetch documents from the index.
*/
export const useFetchIndexDocuments = ({
filter,
limit,
attributesToRetrieve,
attributesToCrop,
sort,
enabled = true,
} : UseFetchIndexDocumentsParams) => {
const { client, indexName } = useContentSearchConnection();
return useQuery({
enabled: enabled && client !== undefined && indexName !== undefined,
queryKey: [
'content_search',
client?.config.apiKey,
client?.config.host,
indexName,
filter,
'generic-one-off',
],
queryFn: enabled ? () => fetchIndexDocuments(
client!,
indexName!,
filter,
limit,
attributesToRetrieve,
attributesToCrop,
sort,
) : undefined,
});
};

View File

@@ -9,7 +9,7 @@ export { default as SearchKeywordsField } from './SearchKeywordsField';
export { default as SearchSortWidget } from './SearchSortWidget';
export { default as Stats } from './Stats';
export { HIGHLIGHT_PRE_TAG, HIGHLIGHT_POST_TAG } from './data/api';
export { useFetchIndexDocuments, useGetBlockTypes } from './data/apiHooks';
export { useContentSearchConnection, useContentSearchResults, useGetBlockTypes } from './data/apiHooks';
export { TypesFilterData } from './hooks';
export type { CollectionHit, ContentHit, ContentHitTags } from './data/api';

View File

@@ -191,6 +191,7 @@ export function initializeMocks({ user = defaultUser, initialState = undefined }
axiosMock,
mockShowToast: mockToastContext.showToast,
mockToastAction: mockToastContext.toastAction,
queryClient,
};
}