feat: Show sections/subsections/units available for sync in library sync page [FC-0097] (#2271)

- Adds Units, Subsection, and section cards in the libraries sync page.
- Rename of `mockGetEntityLinks` to `mockGetComponentEntityLinks`
- Use the top-level parent logic
- Which user roles will this change impact? "Course Author".
This commit is contained in:
Chris Chávez
2025-08-14 12:59:29 -05:00
committed by GitHub
parent 6ce7b86e83
commit 25830a2130
21 changed files with 471 additions and 259 deletions

View File

@@ -78,14 +78,14 @@ describe('<CourseLibraries />', () => {
const user = userEvent.setup();
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 7' });
// review tab should be open by default as outOfSyncCount is greater than 0
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
await user.click(allTab);
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
'5 library components are out of sync. Review updates to accept or ignore changes',
'7 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
expect(allTab).toHaveAttribute('aria-selected', 'true');
@@ -93,14 +93,14 @@ describe('<CourseLibraries />', () => {
await user.click(reviewBtn);
expect(allTab).toHaveAttribute('aria-selected', 'false');
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByRole('tab', { name: 'Review Content Updates 7' })).toHaveAttribute('aria-selected', 'true');
expect(alert).not.toBeInTheDocument();
});
it('hide alert on dismiss', async () => {
const user = userEvent.setup();
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 7' });
// review tab should be open by default as outOfSyncCount is greater than 0
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
@@ -109,7 +109,7 @@ describe('<CourseLibraries />', () => {
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
'5 library components are out of sync. Review updates to accept or ignore changes',
'7 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
const dismissBtn = await screen.findByRole('button', { name: 'Dismiss' });
await user.click(dismissBtn);
@@ -118,7 +118,7 @@ describe('<CourseLibraries />', () => {
// review updates button
const reviewActionBtn = await screen.findByRole('button', { name: 'Review Updates' });
await user.click(reviewActionBtn);
expect(await screen.findByRole('tab', { name: 'Review Content Updates 5' })).toHaveAttribute('aria-selected', 'true');
expect(await screen.findByRole('tab', { name: 'Review Content Updates 7' })).toHaveAttribute('aria-selected', 'true');
});
it('show alert if max lastPublishedDate is greated than the local storage value', async () => {
@@ -131,14 +131,14 @@ describe('<CourseLibraries />', () => {
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 7' });
// review tab should be open by default as outOfSyncCount is greater than 0
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
await user.click(allTab);
const alert = await screen.findByRole('alert');
expect(await within(alert).findByText(
'5 library components are out of sync. Review updates to accept or ignore changes',
'7 library components are out of sync. Review updates to accept or ignore changes',
)).toBeInTheDocument();
});
@@ -152,14 +152,12 @@ describe('<CourseLibraries />', () => {
await renderCourseLibrariesPage(mockGetEntityLinks.courseKey);
const allTab = await screen.findByRole('tab', { name: 'Libraries' });
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 5' });
const reviewTab = await screen.findByRole('tab', { name: 'Review Content Updates 7' });
// review tab should be open by default as outOfSyncCount is greater than 0
expect(reviewTab).toHaveAttribute('aria-selected', 'true');
await user.click(allTab);
expect(allTab).toHaveAttribute('aria-selected', 'true');
screen.logTestingPlaygroundURL();
expect(screen.queryByRole('alert')).not.toBeInTheDocument();
});
});
@@ -195,39 +193,61 @@ describe('<CourseLibraries ReviewTab />', () => {
});
it('shows all readyToSync links', async () => {
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
await renderCourseLibrariesReviewPage();
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
expect(updateBtns.length).toEqual(5);
expect(updateBtns.length).toEqual(7);
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
expect(ignoreBtns.length).toEqual(5);
expect(ignoreBtns.length).toEqual(7);
});
it('update changes works', async () => {
test.each([
{
label: 'update changes works with components',
itemIndex: 0,
expectedToastMsg: 'Success! "Dropdown" is updated',
},
{
label: 'update changes works with containers',
itemIndex: 5,
expectedToastMsg: 'Success! "Unit 1" is updated',
},
])('$label', async ({ itemIndex, expectedToastMsg }) => {
const user = userEvent.setup();
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
const usageKey = mockGetEntityLinks.response[itemIndex].downstreamUsageKey;
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const updateBtns = await screen.findAllByRole('button', { name: 'Update' });
expect(updateBtns.length).toEqual(5);
await user.click(updateBtns[0]);
expect(updateBtns.length).toEqual(7);
await user.click(updateBtns[itemIndex]);
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']);
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
});
it('update changes works in preview modal', async () => {
test.each([
{
label: 'update changes works in preview modal with components',
itemIndex: 0,
expectedToastMsg: 'Success! "Dropdown" is updated',
},
{
label: 'update changes works in preview modal with containers',
itemIndex: 5,
expectedToastMsg: 'Success! "Unit 1" is updated',
},
])('$label', async ({ itemIndex, expectedToastMsg }) => {
const user = userEvent.setup();
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
const usageKey = mockGetEntityLinks.response[itemIndex].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);
await user.click(previewBtns[0]);
expect(previewBtns.length).toEqual(7);
await user.click(previewBtns[itemIndex]);
const dialog = await screen.findByRole('dialog');
const confirmBtn = await within(dialog).findByRole('button', { name: 'Accept changes' });
await user.click(confirmBtn);
@@ -235,20 +255,31 @@ describe('<CourseLibraries ReviewTab />', () => {
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']);
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
});
it('ignore change works', async () => {
test.each([
{
label: 'ignore change works with components',
itemIndex: 0,
expectedToastMsg: '"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.',
},
{
label: 'ignore change works with containers',
itemIndex: 5,
expectedToastMsg: '"Unit 1" will remain out of sync with library content. You will be notified when this component is updated again.',
},
])('$label', async ({ itemIndex, expectedToastMsg }) => {
const user = userEvent.setup();
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
const usageKey = mockGetEntityLinks.response[itemIndex].downstreamUsageKey;
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(204, {});
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const ignoreBtns = await screen.findAllByRole('button', { name: 'Ignore' });
expect(ignoreBtns.length).toEqual(5);
expect(ignoreBtns.length).toEqual(7);
// Show confirmation modal on clicking ignore.
await user.click(ignoreBtns[0]);
await user.click(ignoreBtns[itemIndex]);
const dialog = await screen.findByRole('dialog', { name: 'Ignore these changes?' });
expect(dialog).toBeInTheDocument();
const confirmBtn = await within(dialog).findByRole('button', { name: 'Ignore' });
@@ -257,21 +288,30 @@ describe('<CourseLibraries ReviewTab />', () => {
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']);
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
});
it('ignore change works in preview', async () => {
test.each([
{
label: 'ignore change works with components',
itemIndex: 0,
expectedToastMsg: '"Dropdown" will remain out of sync with library content. You will be notified when this component is updated again.',
},
{
label: 'ignore change works with containers',
itemIndex: 5,
expectedToastMsg: '"Unit 1" will remain out of sync with library content. You will be notified when this component is updated again.',
},
])('$label', async ({ itemIndex, expectedToastMsg }) => {
const user = userEvent.setup();
const mockInvalidateQueries = jest.spyOn(queryClient, 'invalidateQueries');
const usageKey = mockGetEntityLinks.response[0].downstreamUsageKey;
const usageKey = mockGetEntityLinks.response[itemIndex].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);
await user.click(previewBtns[0]);
expect(previewBtns.length).toEqual(7);
await user.click(previewBtns[itemIndex]);
const previewDialog = await screen.findByRole('dialog');
const ignoreBtn = await within(previewDialog).findByRole('button', { name: 'Ignore changes' });
await user.click(ignoreBtn);
@@ -284,9 +324,7 @@ describe('<CourseLibraries ReviewTab />', () => {
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']);
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
});
});

View File

@@ -41,23 +41,39 @@ interface Props {
courseId: string;
}
interface BlockCardProps {
interface ItemCardProps {
info: ContentHit;
itemType: 'component' | 'container';
actions?: React.ReactNode;
libraryName?: string;
}
const BlockCard: React.FC<BlockCardProps> = ({ info, actions, libraryName }) => {
const ItemCard: React.FC<ItemCardProps> = ({
info,
itemType,
actions,
libraryName,
}) => {
const intl = useIntl();
const componentIcon = getItemIcon(info.blockType);
const itemIcon = getItemIcon(info.blockType);
const breadcrumbs = tail(info.breadcrumbs) as Array<{ displayName: string, usageKey: string }>;
const getBlockLink = useCallback(() => {
const getItemLink = useCallback(() => {
let key = info.usageKey;
if (breadcrumbs?.length > 1) {
key = breadcrumbs[breadcrumbs.length - 1].usageKey || key;
}
return `${getConfig().STUDIO_BASE_URL}/container/${key}`;
if (itemType === 'component') {
return `${getConfig().STUDIO_BASE_URL}/container/${key}`;
}
if (itemType === 'container') {
const encodedKey = encodeURIComponent(key);
return `${getConfig().STUDIO_BASE_URL}/course/${info.contextKey}?show=${encodedKey}`;
}
// istanbul ignore next
return '';
}, [info]);
return (
@@ -71,7 +87,7 @@ const BlockCard: React.FC<BlockCardProps> = ({ info, actions, libraryName }) =>
<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" />
<Icon src={itemIcon} size="xs" />
<BlockTypeLabel blockType={info.blockType} />
</Stack>
<Stack direction="horizontal" className="small" gap={1}>
@@ -87,14 +103,20 @@ const BlockCard: React.FC<BlockCardProps> = ({ info, actions, libraryName }) =>
</Stack>
)}
{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 showLaunchIcon={false} destination={getItemLink()} target="_blank">
{info.blockType === 'chapter' ? (
<div className="micro text-gray-700 border-bottom">
{intl.formatMessage(messages.viewSectionInCourseLabel)}
</div>
) : (
<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>
@@ -105,16 +127,21 @@ const BlockCard: React.FC<BlockCardProps> = ({ info, actions, libraryName }) =>
);
};
const ComponentReviewList = ({
outOfSyncComponents,
const ItemReviewList = ({
outOfSyncItems,
}: {
outOfSyncComponents: PublishableEntityLink[];
outOfSyncItems: PublishableEntityLink[];
}) => {
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);
// Toggle preview changes modal
const [isPreviewModalOpen, openPreviewModal, closePreviewModal] = useToggle(false);
const acceptChangesMutation = useAcceptLibraryBlockChanges();
const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
const {
hits,
isLoading: isIndexDataLoading,
@@ -133,32 +160,27 @@ const ComponentReviewList = ({
true,
);
const outOfSyncComponentsByKey = useMemo(
() => keyBy(outOfSyncComponents, 'downstreamUsageKey'),
[outOfSyncComponents],
const outOfSyncItemsByKey = useMemo(
() => keyBy(outOfSyncItems, 'downstreamUsageKey'),
[outOfSyncItems],
);
const queryClient = useQueryClient();
// Toggle preview changes modal
const [isModalOpen, openModal, closeModal] = useToggle(false);
const acceptChangesMutation = useAcceptLibraryBlockChanges();
const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
const setSelectedBlockData = useCallback((info: ContentHit) => {
setBlockData({
displayName: info.displayName,
downstreamBlockId: info.usageKey,
upstreamBlockId: outOfSyncComponentsByKey[info.usageKey].upstreamUsageKey,
upstreamBlockVersionSynced: outOfSyncComponentsByKey[info.usageKey].versionSynced,
isVertical: info.blockType === 'vertical',
upstreamBlockId: outOfSyncItemsByKey[info.usageKey].upstreamKey,
upstreamBlockVersionSynced: outOfSyncItemsByKey[info.usageKey].versionSynced,
isContainer: info.blockType === 'vertical' || info.blockType === 'sequencial' || info.blockType === 'chapter',
});
}, [outOfSyncComponentsByKey]);
}, [outOfSyncItemsByKey]);
// Show preview changes on review
const onReview = useCallback((info: ContentHit) => {
setSelectedBlockData(info);
openModal();
}, [setSelectedBlockData, openModal]);
openPreviewModal();
}, [setSelectedBlockData, openPreviewModal]);
const onIgnoreClick = useCallback((info: ContentHit) => {
setSelectedBlockData(info);
@@ -166,9 +188,9 @@ const ComponentReviewList = ({
}, [setSelectedBlockData, openConfirmModal]);
const reloadLinks = useCallback((usageKey: string) => {
const courseKey = outOfSyncComponentsByKey[usageKey].downstreamContextKey;
queryClient.invalidateQueries(courseLibrariesQueryKeys.courseLibraries(courseKey));
}, [outOfSyncComponentsByKey]);
const courseKey = outOfSyncItemsByKey[usageKey].downstreamContextKey;
queryClient.invalidateQueries({ queryKey: courseLibrariesQueryKeys.courseLibraries(courseKey) });
}, [outOfSyncItemsByKey]);
const postChange = (accept: boolean) => {
// istanbul ignore if: this should never happen
@@ -232,10 +254,11 @@ const ComponentReviewList = ({
return (
<>
{downstreamInfo?.map((info) => (
<BlockCard
<ItemCard
key={info.usageKey}
info={info}
libraryName={outOfSyncComponentsByKey[info.usageKey]?.upstreamContextTitle}
itemType={outOfSyncItemsByKey[info.usageKey]?.upstreamType}
libraryName={outOfSyncItemsByKey[info.usageKey]?.upstreamContextTitle}
actions={(
<ActionRow>
<Button
@@ -269,8 +292,8 @@ const ComponentReviewList = ({
{blockData && (
<PreviewLibraryXBlockChanges
blockData={blockData}
isModalOpen={isModalOpen}
closeModal={closeModal}
isModalOpen={isPreviewModalOpen}
closeModal={closePreviewModal}
postChange={postChange}
/>
)}
@@ -290,15 +313,19 @@ const ComponentReviewList = ({
const ReviewTabContent = ({ courseId }: Props) => {
const intl = useIntl();
const {
data: outOfSyncComponents,
isLoading: isSyncComponentsLoading,
data: outOfSyncItems,
isLoading: isSyncItemsLoading,
isError,
error,
} = useEntityLinks({ courseId, readyToSync: true });
} = useEntityLinks({
courseId,
readyToSync: true,
useTopLevelParents: true,
});
const downstreamKeys = useMemo(
() => outOfSyncComponents?.map(link => link.downstreamUsageKey),
[outOfSyncComponents],
() => outOfSyncItems?.map(link => link.downstreamUsageKey),
[outOfSyncItems],
);
const disableSortOptions = [
@@ -308,7 +335,7 @@ const ReviewTabContent = ({ courseId }: Props) => {
SearchSortOption.RECENTLY_PUBLISHED,
];
if (isSyncComponentsLoading) {
if (isSyncItemsLoading) {
return <Loading />;
}
@@ -329,8 +356,8 @@ const ReviewTabContent = ({ courseId }: Props) => {
<SearchSortWidget disableOptions={disableSortOptions} />
<ActionRow.Spacer />
</ActionRow>
<ComponentReviewList
outOfSyncComponents={outOfSyncComponents}
<ItemReviewList
outOfSyncItems={outOfSyncItems}
/>
</SearchContextProvider>
);

View File

@@ -2,7 +2,7 @@
{
"upstreamContextTitle": "CS problems 3",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"readyToSyncCount": 5,
"readyToSyncCount": 7,
"totalCount": 14,
"lastPublishedAt": "2025-05-01T20:20:44.989042Z"
},

View File

@@ -364,13 +364,125 @@
"org": "OpenEdx",
"access_id": "4"
}
},
{
"display_name": "Unit 1",
"block_id": "20f2c12b8b7f4b4bab7a900576c78ad8",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "This is a super unit"
},
"description": "This is a super unit",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeverticalblocka20f2c12b8b7f4b4bab7a900576c78ad8-efa48aff",
"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"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@20f2c12b8b7f4b4bab7a900576c78ad8",
"block_type": "vertical",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": 4,
"_formatted": {
"display_name": "Unit 1",
"block_id": "20f2c12b8b7f4b4bab7a900576c78ad8",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "This is a super unit"
},
"description": "This is a super unit",
"tags": {},
"id": "block-v1itcracydemoxcoursextypeverticalblocka20f2c12b8b7f4b4bab7a900576c78ad8-efa48aff",
"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"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@20f2c12b8b7f4b4bab7a900576c78ad8",
"block_type": "vertical",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": "4"
}
},
{
"display_name": "Section 1",
"block_id": "20f2c12b8b7e4b4cab7a900576c78cv5",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "This is a super section"
},
"description": "This is a super section",
"tags": {},
"id": "block-v1itcracydemoxcoursextypechapterblocka20f2c12b8b7e4b4cab7a900576c78cv5",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@20f2c12b8b7e4b4cab7a900576c78cv5",
"block_type": "chapter",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": 4,
"_formatted": {
"display_name": "Section 1",
"block_id": "20f2c12b8b7e4b4cab7a900576c78cv5",
"content": {
"problem_types": [
"optionresponse"
],
"capa_content": "This is a super section"
},
"description": "This is a super section",
"tags": {},
"id": "block-v1itcracydemoxcoursextypechapterblocka20f2c12b8b7e4b4cab7a900576c78cv5",
"type": "course_block",
"breadcrumbs": [
{
"display_name": "OpenedX Demo Course"
}
],
"usage_key": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@20f2c12b8b7e4b4cab7a900576c78cv5",
"block_type": "chapter",
"context_key": "course-v1:OpenEdx+DemoX+CourseX",
"org": "OpenEdx",
"access_id": "4"
}
}
],
"query": "",
"processingTimeMs": 8,
"limit": 20,
"offset": 0,
"estimatedTotalHits": 5
"estimatedTotalHits": 6
}
]
}

View File

@@ -4,7 +4,8 @@
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 10,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamType": "component",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem3",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
@@ -18,7 +19,8 @@
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 10,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamType": "component",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@problem6",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
@@ -32,7 +34,8 @@
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 26,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477",
"upstreamKey": "lb:OpenedX:CSPROB3:html:ca4d2b1f-0b64-4a2d-88fa-592f7e398477",
"upstreamType": "component",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@html+block@257e68e3386d4a8f8739d45b67e76a9b",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
@@ -46,7 +49,8 @@
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 10,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamType": "component",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@a4455860b03647219ff8b01cde49cf37",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
@@ -60,7 +64,8 @@
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 10,
"readyToSync": true,
"upstreamUsageKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamKey": "lb:OpenedX:CSPROB3:problem:d40264d5-80c4-4be8-bfb6-086391de1cd3",
"upstreamType": "component",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@problem+block@210e356cfa304b0aac591af53f6a6ae0",
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
@@ -68,5 +73,35 @@
"versionDeclined": null,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
{
"id": 891,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 17,
"readyToSync": true,
"upstreamKey": "lct:OpenedX:CSPROB3:unit:cb367f92-bf7d-4d08-86cd-aae9efa48aff",
"upstreamType": "container",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@vertical+block@20f2c12b8b7f4b4bab7a900576c78ad8",
"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": 892,
"upstreamContextTitle": "CS problems 3",
"upstreamVersion": 3,
"readyToSync": true,
"upstreamKey": "lct:OpenedX:CSPROB3:section:9a90e4e4-cdf9-48ce-89b5-a8f9f07ccbfc",
"upstreamType": "container",
"upstreamContextKey": "lib:OpenedX:CSPROB3",
"downstreamUsageKey": "block-v1:OpenEdx+DemoX+CourseX+type@chapter+block@20f2c12b8b7e4b4cab7a900576c78cv5",
"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,19 +1,15 @@
/* istanbul ignore file */
// eslint-disable-next-line import/no-extraneous-dependencies
import fetchMock from 'fetch-mock-jest';
import * as libApi from '@src/library-authoring/data/api';
import { createAxiosError } from '@src/testUtils';
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 `getEntityLinks()`
*
* This mock returns a fixed response for the downstreamContextKey.
*/
export async function mockGetEntityLinks(
downstreamContextKey?: string,
readyToSync?: boolean,
@@ -61,7 +57,7 @@ export async function mockGetEntityLinksSummaryByDownstreamContext(
throw createAxiosError({
code: 404,
message: 'Not found.',
path: api.getEntityLinksByDownstreamContextUrl(),
path: api.getEntityLinksSummaryByDownstreamContextUrl(courseId),
});
case mockGetEntityLinksSummaryByDownstreamContext.courseKeyLoading:
return new Promise(() => {});

View File

@@ -3,9 +3,7 @@ import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
const getApiBaseUrl = () => getConfig().STUDIO_BASE_URL;
export const getEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/`;
export const getContainerEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstream-containers/`;
export const getEntityLinksByDownstreamContextUrl = () => `${getApiBaseUrl()}/api/contentstore/v2/downstreams-all/`;
export const getEntityLinksSummaryByDownstreamContextUrl = (downstreamContextKey: string) => `${getApiBaseUrl()}/api/contentstore/v2/downstreams/${downstreamContextKey}/summary`;
export interface PaginatedData<T> {
@@ -33,7 +31,7 @@ export interface BasePublishableEntityLink {
readyToSync: boolean;
}
export interface PublishableEntityLink extends BasePublishableEntityLink {
export interface ComponentPublishableEntityLink extends BasePublishableEntityLink {
upstreamUsageKey: string;
}
@@ -41,6 +39,11 @@ export interface ContainerPublishableEntityLink extends BasePublishableEntityLin
upstreamContainerKey: string;
}
export interface PublishableEntityLink extends BasePublishableEntityLink {
upstreamKey: string;
upstreamType: 'component' | 'container';
}
export interface PublishableEntityLinkSummary {
upstreamContextKey: string;
upstreamContextTitle: string;
@@ -52,31 +55,18 @@ export interface PublishableEntityLinkSummary {
export const getEntityLinks = async (
downstreamContextKey?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
useTopLevelParents?: boolean,
upstreamKey?: string,
contentType?: 'all' | 'components' | 'containers',
): 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 getContainerEntityLinks = async (
downstreamContextKey?: string,
readyToSync?: boolean,
upstreamContainerKey?: string,
): Promise<ContainerPublishableEntityLink[]> => {
const { data } = await getAuthenticatedHttpClient()
.get(getContainerEntityLinksByDownstreamContextUrl(), {
params: {
course_id: downstreamContextKey,
ready_to_sync: readyToSync,
upstream_container_key: upstreamContainerKey,
upstream_key: upstreamKey,
use_top_level_parents: useTopLevelParents,
item_type: contentType,
no_page: true,
},
});

View File

@@ -39,6 +39,24 @@ describe('course libraries api hooks', () => {
axiosMock.reset();
});
it('should return component links for course', async () => {
const courseId = 'course-v1:some+key';
const url = getEntityLinksByDownstreamContextUrl();
axiosMock.onGet(url).reply(200, []);
const { result } = renderHook(() => useEntityLinks({ courseId, contentType: 'components' }), { 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_key: undefined,
no_page: true,
item_type: 'components',
});
});
it('should return links for course', async () => {
const courseId = 'course-v1:some+key';
const url = getEntityLinksByDownstreamContextUrl();
@@ -53,6 +71,7 @@ describe('course libraries api hooks', () => {
ready_to_sync: undefined,
upstream_usage_key: undefined,
no_page: true,
content_type: undefined,
});
});
});

View File

@@ -1,61 +1,62 @@
import {
useQuery,
} from '@tanstack/react-query';
import { getContainerEntityLinks, getEntityLinks, getEntityLinksSummaryByDownstreamContext } from './api';
import { getEntityLinksSummaryByDownstreamContext, getEntityLinks } from './api';
export const courseLibrariesQueryKeys = {
all: ['courseLibraries'],
courseLibraries: (courseId?: string) => [...courseLibrariesQueryKeys.all, courseId],
courseReadyToSyncLibraries: ({
courseId, readyToSync, upstreamUsageKey, upstreamContainerKey,
contentType, courseId, readyToSync, upstreamKey,
}: {
contentType?: 'all' | 'components' | 'containers',
courseId?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
upstreamContainerKey?: string,
upstreamKey?: string,
pageSize?: number,
}) => {
const key: Array<string | boolean | number> = [...courseLibrariesQueryKeys.all];
if (courseId !== undefined) {
key.push(courseId);
}
if (contentType !== undefined) {
key.push(contentType);
}
if (readyToSync !== undefined) {
key.push(readyToSync);
}
if (upstreamUsageKey !== undefined) {
key.push(upstreamUsageKey);
}
if (upstreamContainerKey !== undefined) {
key.push(upstreamContainerKey);
if (upstreamKey !== undefined) {
key.push(upstreamKey);
}
return key;
},
courseLibrariesSummary: (courseId?: string) => [...courseLibrariesQueryKeys.courseLibraries(courseId), 'summary'],
};
/**
* Hook to fetch list of publishable entity links by course key.
* (That is, get a list of the library components used in the given course.)
*/
export const useEntityLinks = ({
courseId, readyToSync, upstreamUsageKey,
courseId, readyToSync, useTopLevelParents, upstreamKey, contentType,
}: {
courseId?: string,
readyToSync?: boolean,
upstreamUsageKey?: string,
useTopLevelParents?: boolean,
upstreamKey?: string,
contentType?: 'all' | 'components' | 'containers',
}) => (
useQuery({
queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({
contentType: contentType ?? 'all',
courseId,
readyToSync,
upstreamUsageKey,
upstreamKey,
}),
queryFn: () => getEntityLinks(
courseId,
readyToSync,
upstreamUsageKey,
useTopLevelParents,
upstreamKey,
contentType,
),
enabled: courseId !== undefined || upstreamUsageKey !== undefined || readyToSync !== undefined,
enabled: courseId !== undefined || upstreamKey !== undefined || readyToSync !== undefined,
})
);
@@ -69,29 +70,3 @@ export const useEntityLinksSummaryByDownstreamContext = (courseId?: string) => (
enabled: courseId !== undefined,
})
);
/**
* Hook to fetch list of publishable entity links for containers by course key.
* (That is, get a list of the library containers used in the given course.)
*/
export const useContainerEntityLinks = ({
courseId, readyToSync, upstreamContainerKey,
}: {
courseId?: string,
readyToSync?: boolean,
upstreamContainerKey?: string,
}) => (
useQuery({
queryKey: courseLibrariesQueryKeys.courseReadyToSyncLibraries({
courseId,
readyToSync,
upstreamContainerKey,
}),
queryFn: () => getContainerEntityLinks(
courseId,
readyToSync,
upstreamContainerKey,
),
enabled: courseId !== undefined || upstreamContainerKey !== undefined || readyToSync !== undefined,
})
);

View File

@@ -39,7 +39,7 @@ const messages = defineMessages({
breadcrumbLabel: {
id: 'course-authoring.course-libraries.downstream-block.breadcrumb.label',
defaultMessage: 'Location:',
description: 'label for breadcrumb in component cards in course libraries page.',
description: 'Label for breadcrumb in component cards in course libraries page.',
},
totalComponentLabel: {
id: 'course-authoring.course-libraries.libcard.total-component.label',
@@ -79,17 +79,17 @@ const messages = defineMessages({
cardReviewContentBtn: {
id: 'course-authoring.course-libraries.review-tab.libcard.review-btn-text',
defaultMessage: 'Review Updates',
description: 'Card review button for component in review tab',
description: 'Card review button for component/container 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',
description: 'Card update button for component/container 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',
description: 'Card ignore button for component/container in review tab',
},
updateSingleBlockSuccess: {
id: 'course-authoring.course-libraries.review-tab.libcard.update-success-toast',
@@ -116,6 +116,11 @@ const messages = defineMessages({
defaultMessage: 'Something went wrong! Could not fetch results.',
description: 'Generic error message displayed when fetching link data fails.',
},
viewSectionInCourseLabel: {
id: 'course-authoring.course-libraries.review-tab.libcard.view-section.label',
defaultMessage: 'View Section in Course',
description: 'Label of the button to see the section in the course',
},
});
export default messages;

View File

@@ -98,7 +98,7 @@ const UnitCard = ({
downstreamBlockId: id,
upstreamBlockId: upstreamInfo.upstreamRef,
upstreamBlockVersionSynced: upstreamInfo.versionSynced,
isVertical: true,
isContainer: true,
};
}, [upstreamInfo]);

View File

@@ -19,7 +19,7 @@ const defaultEventData: LibraryChangesMessageData = {
downstreamBlockId: usageKey,
upstreamBlockId: 'lct:org:lib1:unit:1',
upstreamBlockVersionSynced: 1,
isVertical: false,
isContainer: false,
};
const mockSendMessageToIframe = jest.fn();
@@ -66,9 +66,9 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
});
it('renders default displayName for units with no displayName', async () => {
render({ ...defaultEventData, isVertical: true, displayName: '' });
render({ ...defaultEventData, isContainer: true, displayName: '' });
expect(await screen.findByText('Preview changes: Unit')).toBeInTheDocument();
expect(await screen.findByText('Preview changes: Container')).toBeInTheDocument();
});
it('renders default displayName for components with no displayName', async () => {

View File

@@ -22,7 +22,7 @@ export interface LibraryChangesMessageData {
downstreamBlockId: string,
upstreamBlockId: string,
upstreamBlockVersionSynced: number,
isVertical: boolean,
isContainer: boolean,
}
export interface PreviewLibraryXBlockChangesProps {
@@ -60,7 +60,7 @@ export const PreviewLibraryXBlockChanges = ({
usageKey={blockData.upstreamBlockId}
oldVersion={blockData.upstreamBlockVersionSynced || 'published'}
newVersion="published"
isContainer={blockData.isVertical}
isContainer={blockData.isContainer}
/>
);
}, [blockData]);
@@ -85,8 +85,8 @@ export const PreviewLibraryXBlockChanges = ({
}, [blockData]);
const defaultTitle = intl.formatMessage(
blockData.isVertical
? messages.defaultUnitTitle
blockData.isContainer
? messages.defaultContainerTitle
: messages.defaultComponentTitle,
);
const title = blockData.displayName

View File

@@ -6,10 +6,10 @@ const messages = defineMessages({
defaultMessage: 'Preview changes: {blockTitle}',
description: 'Preview changes modal title text',
},
defaultUnitTitle: {
defaultContainerTitle: {
id: 'authoring.course-unit.preview-changes.modal-default-unit-title',
defaultMessage: 'Preview changes: Unit',
description: 'Preview changes modal default title text for units',
defaultMessage: 'Preview changes: Container',
description: 'Preview changes modal default title text for containers',
},
defaultComponentTitle: {
id: 'authoring.course-unit.preview-changes.modal-default-component-title',

View File

@@ -4,8 +4,9 @@ import {
render as baseRender,
screen,
fireEvent,
} from '../../testUtils';
import { mockFetchIndexDocuments, mockContentSearchConfig } from '../../search-manager/data/api.mock';
} from '@src/testUtils';
import { mockFetchIndexDocuments, mockContentSearchConfig } from '@src/search-manager/data/api.mock';
import {
mockContentLibrary,
mockGetEntityLinks,

View File

@@ -3,13 +3,14 @@ import {
render,
screen,
waitFor,
} from '../../testUtils';
} from '@src/testUtils';
import { mockContentSearchConfig, mockFetchIndexDocuments } from '@src/search-manager/data/api.mock';
import {
mockContentLibrary,
mockLibraryBlockMetadata,
mockGetEntityLinks,
} from '../data/api.mocks';
import { mockContentSearchConfig, mockFetchIndexDocuments } from '../../search-manager/data/api.mock';
import { LibraryProvider } from '../common/context/LibraryContext';
import { SidebarBodyItemId, SidebarProvider } from '../common/context/SidebarContext';
import ComponentInfo from './ComponentInfo';

View File

@@ -1,11 +1,13 @@
import { useMemo } from 'react';
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 { useEntityLinks } from '../../course-libraries/data/apiHooks';
import AlertError from '../../generic/alert-error';
import Loading from '../../generic/Loading';
import { useEntityLinks } from '@src/course-libraries/data/apiHooks';
import AlertError from '@src/generic/alert-error';
import Loading from '@src/generic/Loading';
import messages from './messages';
import { useContentFromSearchIndex } from '../data/apiHooks';
@@ -34,7 +36,7 @@ export const ComponentUsage = ({ usageKey }: ComponentUsageProps) => {
isError: isErrorDownstreamLinks,
error: errorDownstreamLinks,
isLoading: isLoadingDownstreamLinks,
} = useEntityLinks({ upstreamUsageKey: usageKey });
} = useEntityLinks({ upstreamKey: usageKey, contentType: 'components' });
const downstreamKeys = useMemo(
() => dataDownstreamLinks?.map(link => link.downstreamUsageKey) || [],

View File

@@ -1,11 +1,12 @@
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { Alert, Button } from '@openedx/paragon';
import BaseModal from '../../editors/sharedComponents/BaseModal';
import BaseModal from '@src/editors/sharedComponents/BaseModal';
import { useEntityLinks } from '@src/course-libraries/data/apiHooks';
import messages from './messages';
import infoMessages from '../component-info/messages';
import { ComponentUsage } from '../component-info/ComponentUsage';
import { useEntityLinks } from '../../course-libraries/data/apiHooks';
interface PublishConfirmationModalProps {
isOpen: boolean,
@@ -29,7 +30,7 @@ const PublishConfirmationModal = ({
const {
data: dataDownstreamLinks,
isLoading: isLoadingDownstreamLinks,
} = useEntityLinks({ upstreamUsageKey: usageKey });
} = useEntityLinks({ upstreamKey: usageKey, contentType: 'components' });
const hasDownstreamUsages = !isLoadingDownstreamLinks && dataDownstreamLinks?.length !== 0;

View File

@@ -1,11 +1,13 @@
import type { ToastActionData } from '../../generic/toast-context';
import type { ToastActionData } from '@src/generic/toast-context';
import {
fireEvent,
render,
screen,
initializeMocks,
waitFor,
} from '../../testUtils';
} from '@src/testUtils';
import { mockContentSearchConfig, mockSearchResult } from '@src/search-manager/data/api.mock';
import { LibraryProvider } from '../common/context/LibraryContext';
import { SidebarProvider } from '../common/context/SidebarContext';
import {
@@ -13,14 +15,13 @@ import {
mockGetContainerMetadata,
mockDeleteContainer,
mockRestoreContainer,
mockGetContainerEntityLinks,
mockGetEntityLinks,
} from '../data/api.mocks';
import { mockContentSearchConfig, mockSearchResult } from '../../search-manager/data/api.mock';
import ContainerDeleter from './ContainerDeleter';
mockContentLibrary.applyMock(); // Not required, but avoids 404 errors in the logs when <LibraryProvider> loads data
mockContentSearchConfig.applyMock();
mockGetContainerEntityLinks.applyMock();
mockGetEntityLinks.applyMock();
const mockDelete = mockDeleteContainer.applyMock();
const mockRestore = mockRestoreContainer.applyMock();

View File

@@ -3,15 +3,16 @@ import { useIntl } from '@edx/frontend-platform/i18n';
import { Icon } from '@openedx/paragon';
import { Error, Warning, School } from '@openedx/paragon/icons';
import DeleteModal from '../../generic/delete-modal/DeleteModal';
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
import { ToastContext } from '@src/generic/toast-context';
import { ContainerType } from '@src/generic/key-utils';
import { ContainerHit } from '@src/search-manager';
import { useEntityLinks } from '@src/course-libraries/data/apiHooks';
import { LoadingSpinner } from '@src/generic/Loading';
import { useSidebarContext } from '../common/context/SidebarContext';
import { ToastContext } from '../../generic/toast-context';
import { useContentFromSearchIndex, useDeleteContainer, useRestoreContainer } from '../data/apiHooks';
import messages from './messages';
import { ContainerType } from '../../generic/key-utils';
import { ContainerHit } from '../../search-manager';
import { useContainerEntityLinks } from '../../course-libraries/data/apiHooks';
import { LoadingSpinner } from '../../generic/Loading';
type ContainerDeleterProps = {
isOpen: boolean,
@@ -37,7 +38,7 @@ const ContainerDeleter = ({
const {
data: dataDownstreamLinks,
isLoading: linksIsLoading,
} = useContainerEntityLinks({ upstreamContainerKey: containerId });
} = useEntityLinks({ upstreamKey: containerId, contentType: 'containers' });
const downstreamCount = dataDownstreamLinks?.length ?? 0;
const messageMap = useMemo(() => {

View File

@@ -1,12 +1,14 @@
/* istanbul ignore file */
import { camelCaseObject } from '@edx/frontend-platform';
import { mockContentTaxonomyTagsData } from '../../content-tags-drawer/data/api.mocks';
import { ContainerType, getBlockType } from '../../generic/key-utils';
import { createAxiosError } from '../../testUtils';
import { mockContentTaxonomyTagsData } from '@src/content-tags-drawer/data/api.mocks';
import { ContainerType, getBlockType } from '@src/generic/key-utils';
import { createAxiosError } from '@src/testUtils';
import downstreamLinkInfo from '@src/search-manager/data/__mocks__/downstream-links.json';
import * as courseLibApi from '@src/course-libraries/data/api';
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()`
@@ -769,23 +771,36 @@ mockBlockTypesMetadata.applyMock = () => jest.spyOn(api, 'getBlockTypes').mockIm
export async function mockGetEntityLinks(
_downstreamContextKey?: string,
_readyToSync?: boolean,
upstreamUsageKey?: string,
_useTopLevelParents?: boolean,
upstreamKey?: string,
contentType?: 'all' | 'components' | 'containers',
): ReturnType<typeof courseLibApi.getEntityLinks> {
const thisMock = mockGetEntityLinks;
switch (upstreamUsageKey) {
case thisMock.upstreamContainerKey: return thisMock.response;
case mockLibraryBlockMetadata.usageKeyPublishedWithChanges: return thisMock.response;
case thisMock.emptyUsageKey: return thisMock.emptyComponentUsage;
default: return [];
if (contentType === 'components') {
switch (upstreamKey) {
case thisMock.upstreamContainerKey: return thisMock.componentResponse;
case mockLibraryBlockMetadata.usageKeyPublishedWithChanges: return thisMock.componentResponse;
case thisMock.emptyUsageKey: return thisMock.emptyComponentUsage;
default: return [];
}
} else if (contentType === 'containers') {
switch (upstreamKey) {
case thisMock.unitKey: return thisMock.unitResponse;
case thisMock.subsectionKey: return thisMock.subsectionResponse;
case thisMock.sectionKey: return thisMock.sectionResponse;
default: return [];
}
}
return thisMock.allResponse;
}
mockGetEntityLinks.upstreamContainerKey = mockLibraryBlockMetadata.usageKeyPublished;
mockGetEntityLinks.response = downstreamLinkInfo.results[0].hits.map((obj: { usageKey: any; }) => ({
mockGetEntityLinks.componentResponse = downstreamLinkInfo.results[0].hits.map((obj: { usageKey: any; }) => ({
id: 875,
upstreamContextTitle: 'CS problems 3',
upstreamVersion: 10,
readyToSync: true,
upstreamUsageKey: mockLibraryBlockMetadata.usageKeyPublished,
upstreamKey: mockLibraryBlockMetadata.usageKeyPublished,
upstreamContextKey: 'lib:Axim:TEST2',
downstreamUsageKey: obj.usageKey,
downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX',
@@ -793,36 +808,18 @@ mockGetEntityLinks.response = downstreamLinkInfo.results[0].hits.map((obj: { usa
versionDeclined: null,
created: '2025-02-08T14:07:05.588484Z',
updated: '2025-02-08T14:07:05.588484Z',
upstreamType: 'component',
}));
mockGetEntityLinks.emptyUsageKey = 'lb:Axim:TEST1:html:empty';
mockGetEntityLinks.emptyComponentUsage = [] as courseLibApi.PublishableEntityLink[];
mockGetEntityLinks.applyMock = () => jest.spyOn(
courseLibApi,
'getEntityLinks',
).mockImplementation(mockGetEntityLinks);
export async function mockGetContainerEntityLinks(
_downstreamContextKey?: string,
_readyToSync?: boolean,
upstreamContainerKey?: string,
): ReturnType<typeof courseLibApi.getContainerEntityLinks> {
const thisMock = mockGetContainerEntityLinks;
switch (upstreamContainerKey) {
case thisMock.unitKey: return thisMock.unitResponse;
case thisMock.subsectionKey: return thisMock.subsectionResponse;
case thisMock.sectionKey: return thisMock.sectionResponse;
default: return [];
}
}
mockGetContainerEntityLinks.unitKey = mockGetContainerMetadata.unitId;
mockGetContainerEntityLinks.unitResponse = [
mockGetEntityLinks.unitKey = mockGetContainerMetadata.unitId;
mockGetEntityLinks.unitResponse = [
{
id: 1,
upstreamContextTitle: 'CS problems 3',
upstreamVersion: 1,
readyToSync: false,
upstreamContainerKey: mockGetContainerEntityLinks.unitKey,
upstreamKey: mockGetEntityLinks.unitKey,
upstreamContextKey: 'lib:Axim:TEST2',
downstreamUsageKey: 'some-key',
downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX',
@@ -830,13 +827,14 @@ mockGetContainerEntityLinks.unitResponse = [
versionDeclined: null,
created: '2025-02-08T14:07:05.588484Z',
updated: '2025-02-08T14:07:05.588484Z',
upstreamType: 'container',
},
{
id: 1,
upstreamContextTitle: 'CS problems 3',
upstreamVersion: 1,
readyToSync: false,
upstreamContainerKey: mockGetContainerEntityLinks.unitKey,
upstreamKey: mockGetEntityLinks.unitKey,
upstreamContextKey: 'lib:Axim:TEST2',
downstreamUsageKey: 'some-key-1',
downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX',
@@ -844,16 +842,17 @@ mockGetContainerEntityLinks.unitResponse = [
versionDeclined: null,
created: '2025-02-08T14:07:05.588484Z',
updated: '2025-02-08T14:07:05.588484Z',
upstreamType: 'container',
},
];
mockGetContainerEntityLinks.subsectionKey = mockGetContainerMetadata.subsectionId;
mockGetContainerEntityLinks.subsectionResponse = [
] as courseLibApi.PublishableEntityLink[];
mockGetEntityLinks.subsectionKey = mockGetContainerMetadata.subsectionId;
mockGetEntityLinks.subsectionResponse = [
{
id: 1,
upstreamContextTitle: 'CS problems 3',
upstreamVersion: 1,
readyToSync: false,
upstreamContainerKey: mockGetContainerEntityLinks.subsectionKey,
upstreamKey: mockGetEntityLinks.subsectionKey,
upstreamContextKey: 'lib:Axim:TEST2',
downstreamUsageKey: 'some-subsection-key',
downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX',
@@ -861,13 +860,14 @@ mockGetContainerEntityLinks.subsectionResponse = [
versionDeclined: null,
created: '2025-02-08T14:07:05.588484Z',
updated: '2025-02-08T14:07:05.588484Z',
upstreamType: 'container',
},
{
id: 1,
upstreamContextTitle: 'CS problems 3',
upstreamVersion: 1,
readyToSync: false,
upstreamContainerKey: mockGetContainerEntityLinks.subsectionKey,
upstreamKey: mockGetEntityLinks.subsectionKey,
upstreamContextKey: 'lib:Axim:TEST2',
downstreamUsageKey: 'some-subsection-key-1',
downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX',
@@ -875,16 +875,17 @@ mockGetContainerEntityLinks.subsectionResponse = [
versionDeclined: null,
created: '2025-02-08T14:07:05.588484Z',
updated: '2025-02-08T14:07:05.588484Z',
upstreamType: 'container',
},
];
mockGetContainerEntityLinks.sectionKey = mockGetContainerMetadata.sectionId;
mockGetContainerEntityLinks.sectionResponse = [
] as courseLibApi.PublishableEntityLink[];
mockGetEntityLinks.sectionKey = mockGetContainerMetadata.sectionId;
mockGetEntityLinks.sectionResponse = [
{
id: 1,
upstreamContextTitle: 'CS problems 3',
upstreamVersion: 1,
readyToSync: false,
upstreamContainerKey: mockGetContainerEntityLinks.sectionKey,
upstreamKey: mockGetEntityLinks.sectionKey,
upstreamContextKey: 'lib:Axim:TEST2',
downstreamUsageKey: 'some-section-key',
downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX',
@@ -892,13 +893,14 @@ mockGetContainerEntityLinks.sectionResponse = [
versionDeclined: null,
created: '2025-02-08T14:07:05.588484Z',
updated: '2025-02-08T14:07:05.588484Z',
upstreamType: 'container',
},
{
id: 1,
upstreamContextTitle: 'CS problems 3',
upstreamVersion: 1,
readyToSync: false,
upstreamContainerKey: mockGetContainerEntityLinks.sectionKey,
upstreamKey: mockGetEntityLinks.sectionKey,
upstreamContextKey: 'lib:Axim:TEST2',
downstreamUsageKey: 'some-section-key-1',
downstreamContextKey: 'course-v1:OpenEdx+DemoX+CourseX',
@@ -906,10 +908,16 @@ mockGetContainerEntityLinks.sectionResponse = [
versionDeclined: null,
created: '2025-02-08T14:07:05.588484Z',
updated: '2025-02-08T14:07:05.588484Z',
upstreamType: 'container',
},
] as courseLibApi.PublishableEntityLink[];
mockGetEntityLinks.allResponse = [
...mockGetEntityLinks.componentResponse,
...mockGetEntityLinks.unitResponse,
...mockGetEntityLinks.subsectionResponse,
...mockGetEntityLinks.sectionResponse,
];
mockGetContainerEntityLinks.applyMock = () => jest.spyOn(
mockGetEntityLinks.applyMock = () => jest.spyOn(
courseLibApi,
'getContainerEntityLinks',
).mockImplementation(mockGetContainerEntityLinks);
'getEntityLinks',
).mockImplementation(mockGetEntityLinks);