fix: show before and after title in diff preview (#2509)

Fix display of Before and After display name in section/subsection sync preview modal.
This commit is contained in:
Navin Karkera
2025-10-08 01:22:22 +05:30
committed by GitHub
parent 8fe5fb6a20
commit c4a439df47
7 changed files with 81 additions and 43 deletions

View File

@@ -11,4 +11,5 @@ coverage:
ignore: ignore:
- "src/grading-settings/grading-scale/react-ranger.js" - "src/grading-settings/grading-scale/react-ranger.js"
- "src/generic/DraggableList/verticalSortableList.ts" - "src/generic/DraggableList/verticalSortableList.ts"
- "src/container-comparison/data/api.mock.ts"
- "src/index.js" - "src/index.js"

View File

@@ -1,64 +1,79 @@
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { mockGetContainerChildren, mockGetContainerMetadata } from '../library-authoring/data/api.mocks'; import MockAdapter from 'axios-mock-adapter/types';
import { initializeMocks, render, screen } from '../testUtils'; import { getLibraryContainerApiUrl } from '@src/library-authoring/data/api';
import { mockGetContainerChildren, mockGetContainerMetadata } from '@src/library-authoring/data/api.mocks';
import { initializeMocks, render, screen } from '@src/testUtils';
import { CompareContainersWidget } from './CompareContainersWidget'; import { CompareContainersWidget } from './CompareContainersWidget';
import { mockGetCourseContainerChildren } from './data/api.mock'; import { mockGetCourseContainerChildren } from './data/api.mock';
mockGetCourseContainerChildren.applyMock(); mockGetCourseContainerChildren.applyMock();
mockGetContainerChildren.applyMock(); mockGetContainerChildren.applyMock();
let axiosMock: MockAdapter;
describe('CompareContainersWidget', () => { describe('CompareContainersWidget', () => {
beforeEach(() => { beforeEach(() => {
initializeMocks(); ({ axiosMock } = initializeMocks());
}); });
test('renders the component with a title', async () => { test('renders the component with a title', async () => {
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId);
axiosMock.onGet(url).reply(200, { publishedDisplayName: 'Test Title' });
render(<CompareContainersWidget render(<CompareContainersWidget
title="Test Title"
upstreamBlockId={mockGetContainerMetadata.sectionId} upstreamBlockId={mockGetContainerMetadata.sectionId}
downstreamBlockId={mockGetCourseContainerChildren.sectionId} downstreamBlockId={mockGetCourseContainerChildren.sectionId}
/>); />);
expect((await screen.findAllByText('Test Title')).length).toEqual(2); expect((await screen.findAllByText('Test Title')).length).toEqual(2);
expect((await screen.findAllByText('subsection block 0')).length).toEqual(2); expect((await screen.findAllByText('subsection block 0')).length).toEqual(1);
expect((await screen.findAllByText('subsection block 00')).length).toEqual(1);
expect((await screen.findAllByText('This subsection will be modified')).length).toEqual(3); expect((await screen.findAllByText('This subsection will be modified')).length).toEqual(3);
expect((await screen.findAllByText('This subsection was modified')).length).toEqual(3); expect((await screen.findAllByText('This subsection was modified')).length).toEqual(3);
expect((await screen.findAllByText('subsection block 1')).length).toEqual(2); expect((await screen.findAllByText('subsection block 1')).length).toEqual(1);
expect((await screen.findAllByText('subsection block 2')).length).toEqual(2); expect((await screen.findAllByText('subsection block 2')).length).toEqual(1);
expect((await screen.findAllByText('subsection block 11')).length).toEqual(1);
expect((await screen.findAllByText('subsection block 22')).length).toEqual(1);
}); });
test('renders loading spinner when data is pending', async () => { test('renders loading spinner when data is pending', async () => {
const url = getLibraryContainerApiUrl(mockGetContainerMetadata.sectionIdLoading);
axiosMock.onGet(url).reply(() => new Promise(() => {}));
render(<CompareContainersWidget render(<CompareContainersWidget
title="Test Title"
upstreamBlockId={mockGetContainerMetadata.sectionIdLoading} upstreamBlockId={mockGetContainerMetadata.sectionIdLoading}
downstreamBlockId={mockGetCourseContainerChildren.sectionIdLoading} downstreamBlockId={mockGetCourseContainerChildren.sectionIdLoading}
/>); />);
expect((await screen.findAllByText('Test Title')).length).toEqual(2);
const spinner = await screen.findAllByRole('status'); const spinner = await screen.findAllByRole('status');
expect(spinner.length).toEqual(2); expect(spinner.length).toEqual(4);
expect(spinner[0].textContent).toEqual('Loading...'); expect(spinner[0].textContent).toEqual('Loading...');
expect(spinner[1].textContent).toEqual('Loading...'); expect(spinner[1].textContent).toEqual('Loading...');
expect(spinner[2].textContent).toEqual('Loading...');
expect(spinner[3].textContent).toEqual('Loading...');
}); });
test('calls onRowClick when a row is clicked and updates diff view', async () => { test('calls onRowClick when a row is clicked and updates diff view', async () => {
// mocks title
axiosMock.onGet(getLibraryContainerApiUrl(mockGetContainerMetadata.sectionId)).reply(200, { publishedDisplayName: 'Test Title' });
axiosMock.onGet(
getLibraryContainerApiUrl('lct:org1:Demo_course_generated:subsection:subsection-0'),
).reply(200, { publishedDisplayName: 'subsection block 0' });
const user = userEvent.setup(); const user = userEvent.setup();
render(<CompareContainersWidget render(<CompareContainersWidget
title="Test Title"
upstreamBlockId={mockGetContainerMetadata.sectionId} upstreamBlockId={mockGetContainerMetadata.sectionId}
downstreamBlockId={mockGetCourseContainerChildren.sectionId} downstreamBlockId={mockGetCourseContainerChildren.sectionId}
/>); />);
expect((await screen.findAllByText('Test Title')).length).toEqual(2); expect((await screen.findAllByText('Test Title')).length).toEqual(2);
let blocks = await screen.findAllByText('subsection block 0'); // left i.e. before side block
expect(blocks.length).toEqual(2); let block = await screen.findByText('subsection block 00');
await user.click(blocks[0]); await user.click(block);
// Breadcrumbs // Breadcrumbs - shows old and new name
const breadcrumbs = await screen.findAllByRole('button', { name: 'subsection block 0' }); expect(await screen.findByRole('button', { name: 'subsection block 00' })).toBeInTheDocument();
expect(breadcrumbs.length).toEqual(2); expect(await screen.findByRole('button', { name: 'subsection block 0' })).toBeInTheDocument();
const removedRows = await screen.findAllByText('This unit was removed'); const removedRows = await screen.findAllByText('This unit was removed');
// clicking on removed or added rows does not updated the page. // clicking on removed or added rows does not updated the page.
await user.click(removedRows[0]); await user.click(removedRows[0]);
// Still in same page // Still in same page
expect((await screen.findAllByRole('button', { name: 'subsection block 0' })).length).toEqual(2); expect(await screen.findByRole('button', { name: 'subsection block 00' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'subsection block 0' })).toBeInTheDocument();
// Back breadcrumb // Back breadcrumb
const backbtns = await screen.findAllByRole('button', { name: 'Back' }); const backbtns = await screen.findAllByRole('button', { name: 'Back' });
@@ -67,11 +82,12 @@ describe('CompareContainersWidget', () => {
// Go back // Go back
await user.click(backbtns[0]); await user.click(backbtns[0]);
expect((await screen.findAllByText('Test Title')).length).toEqual(2); expect((await screen.findAllByText('Test Title')).length).toEqual(2);
blocks = await screen.findAllByText('subsection block 0'); // right i.e. after side block
expect(blocks.length).toEqual(2); block = await screen.findByText('subsection block 0');
// After side click also works // After side click also works
await user.click(blocks[1]); await user.click(block);
expect((await screen.findAllByRole('button', { name: 'subsection block 0' })).length).toEqual(2); expect(await screen.findByRole('button', { name: 'subsection block 00' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'subsection block 0' })).toBeInTheDocument();
}); });
}); });

View File

@@ -4,8 +4,9 @@ import {
import { ArrowBack } from '@openedx/paragon/icons'; import { ArrowBack } from '@openedx/paragon/icons';
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { ContainerType } from '@src/generic/key-utils'; import { ContainerType } from '@src/generic/key-utils';
import ErrorAlert from '@src/generic/alert-error';
import { LoadingSpinner } from '@src/generic/Loading'; import { LoadingSpinner } from '@src/generic/Loading';
import { useContainerChildren } from '@src/library-authoring/data/apiHooks'; import { useContainer, useContainerChildren } from '@src/library-authoring/data/apiHooks';
import { useIntl } from '@edx/frontend-platform/i18n'; import { useIntl } from '@edx/frontend-platform/i18n';
import ChildrenPreview from './ChildrenPreview'; import ChildrenPreview from './ChildrenPreview';
import ContainerRow from './ContainerRow'; import ContainerRow from './ContainerRow';
@@ -15,7 +16,6 @@ import { diffPreviewContainerChildren, isRowClickable } from './utils';
import messages from './messages'; import messages from './messages';
interface ContainerInfoProps { interface ContainerInfoProps {
title: string;
upstreamBlockId: string; upstreamBlockId: string;
downstreamBlockId: string; downstreamBlockId: string;
} }
@@ -30,7 +30,6 @@ interface Props extends ContainerInfoProps {
* Actual implementation of the displaying diff between children of containers. * Actual implementation of the displaying diff between children of containers.
*/ */
const CompareContainersWidgetInner = ({ const CompareContainersWidgetInner = ({
title,
upstreamBlockId, upstreamBlockId,
downstreamBlockId, downstreamBlockId,
parent, parent,
@@ -38,11 +37,17 @@ const CompareContainersWidgetInner = ({
onBackBtnClick, onBackBtnClick,
}: Props) => { }: Props) => {
const intl = useIntl(); const intl = useIntl();
const { data, isPending } = useCourseContainerChildren(downstreamBlockId); const { data, isError, error } = useCourseContainerChildren(downstreamBlockId);
const { const {
data: libData, data: libData,
isPending: libPending, isError: isLibError,
error: libError,
} = useContainerChildren(upstreamBlockId, true); } = useContainerChildren(upstreamBlockId, true);
const {
data: containerData,
isError: isContainerTitleError,
error: containerTitleError,
} = useContainer(upstreamBlockId);
const result = useMemo(() => { const result = useMemo(() => {
if (!data || !libData) { if (!data || !libData) {
@@ -52,7 +57,7 @@ const CompareContainersWidgetInner = ({
}, [data, libData]); }, [data, libData]);
const renderBeforeChildren = useCallback(() => { const renderBeforeChildren = useCallback(() => {
if (isPending) { if (!result[0]) {
return <div className="m-auto"><LoadingSpinner /></div>; return <div className="m-auto"><LoadingSpinner /></div>;
} }
@@ -67,10 +72,10 @@ const CompareContainersWidgetInner = ({
onClick={() => onRowClick(child)} onClick={() => onRowClick(child)}
/> />
)); ));
}, [isPending, result]); }, [result]);
const renderAfterChildren = useCallback(() => { const renderAfterChildren = useCallback(() => {
if (libPending || isPending) { if (!result[1]) {
return <div className="m-auto"><LoadingSpinner /></div>; return <div className="m-auto"><LoadingSpinner /></div>;
} }
@@ -84,9 +89,13 @@ const CompareContainersWidgetInner = ({
onClick={() => onRowClick(child)} onClick={() => onRowClick(child)}
/> />
)); ));
}, [libPending, isPending, result]); }, [result]);
const getTitleComponent = useCallback((title?: string | null) => {
if (!title) {
return <div className="m-auto"><LoadingSpinner /></div>;
}
const getTitleComponent = () => {
if (parent.length === 0) { if (parent.length === 0) {
return title; return title;
} }
@@ -95,6 +104,7 @@ const CompareContainersWidgetInner = ({
ariaLabel={intl.formatMessage(messages.breadcrumbAriaLabel)} ariaLabel={intl.formatMessage(messages.breadcrumbAriaLabel)}
links={[ links={[
{ {
// This raises failed prop-type error as label expects a string but it works without any issues
label: <Stack direction="horizontal" gap={1}><Icon size="xs" src={ArrowBack} />Back</Stack>, label: <Stack direction="horizontal" gap={1}><Icon size="xs" src={ArrowBack} />Back</Stack>,
onClick: onBackBtnClick, onClick: onBackBtnClick,
variant: 'link', variant: 'link',
@@ -110,20 +120,24 @@ const CompareContainersWidgetInner = ({
linkAs={Button} linkAs={Button}
/> />
); );
}; }, [parent]);
if (isError || isLibError || isContainerTitleError) {
return <ErrorAlert error={error || libError || containerTitleError} />;
}
return ( return (
<div className="row"> <div className="row">
<div className="col col-6 p-1"> <div className="col col-6 p-1">
<Card className="p-4"> <Card className="p-4">
<ChildrenPreview title={getTitleComponent()} side="Before"> <ChildrenPreview title={getTitleComponent(data?.displayName)} side="Before">
{renderBeforeChildren()} {renderBeforeChildren()}
</ChildrenPreview> </ChildrenPreview>
</Card> </Card>
</div> </div>
<div className="col col-6 p-1"> <div className="col col-6 p-1">
<Card className="p-4"> <Card className="p-4">
<ChildrenPreview title={getTitleComponent()} side="After"> <ChildrenPreview title={getTitleComponent(containerData?.publishedDisplayName)} side="After">
{renderAfterChildren()} {renderAfterChildren()}
</ChildrenPreview> </ChildrenPreview>
</Card> </Card>
@@ -137,11 +151,10 @@ const CompareContainersWidgetInner = ({
* and allows the user to select the container to view. This is a wrapper component that maintains current * and allows the user to select the container to view. This is a wrapper component that maintains current
* source state. Actual implementation of the diff view is done by CompareContainersWidgetInner. * source state. Actual implementation of the diff view is done by CompareContainersWidgetInner.
*/ */
export const CompareContainersWidget = ({ title, upstreamBlockId, downstreamBlockId }: ContainerInfoProps) => { export const CompareContainersWidget = ({ upstreamBlockId, downstreamBlockId }: ContainerInfoProps) => {
const [currentContainerState, setCurrentContainerState] = useState<ContainerInfoProps & { const [currentContainerState, setCurrentContainerState] = useState<ContainerInfoProps & {
parent: ContainerInfoProps[]; parent: ContainerInfoProps[];
}>({ }>({
title,
upstreamBlockId, upstreamBlockId,
downstreamBlockId, downstreamBlockId,
parent: [], parent: [],
@@ -153,11 +166,9 @@ export const CompareContainersWidget = ({ title, upstreamBlockId, downstreamBloc
} }
setCurrentContainerState((prev) => ({ setCurrentContainerState((prev) => ({
title: row.name,
upstreamBlockId: row.id!, upstreamBlockId: row.id!,
downstreamBlockId: row.downstreamId!, downstreamBlockId: row.downstreamId!,
parent: [...prev.parent, { parent: [...prev.parent, {
title: prev.title,
upstreamBlockId: prev.upstreamBlockId, upstreamBlockId: prev.upstreamBlockId,
downstreamBlockId: prev.downstreamBlockId, downstreamBlockId: prev.downstreamBlockId,
}], }],
@@ -172,7 +183,6 @@ export const CompareContainersWidget = ({ title, upstreamBlockId, downstreamBloc
} }
const prevParent = prev.parent[prev.parent.length - 1]; const prevParent = prev.parent[prev.parent.length - 1];
return { return {
title: prevParent!.title,
upstreamBlockId: prevParent!.upstreamBlockId, upstreamBlockId: prevParent!.upstreamBlockId,
downstreamBlockId: prevParent!.downstreamBlockId, downstreamBlockId: prevParent!.downstreamBlockId,
parent: prev.parent.slice(0, -1), parent: prev.parent.slice(0, -1),
@@ -182,7 +192,6 @@ export const CompareContainersWidget = ({ title, upstreamBlockId, downstreamBloc
return ( return (
<CompareContainersWidgetInner <CompareContainersWidgetInner
title={currentContainerState.title}
upstreamBlockId={currentContainerState.upstreamBlockId} upstreamBlockId={currentContainerState.upstreamBlockId}
downstreamBlockId={currentContainerState.downstreamBlockId} downstreamBlockId={currentContainerState.downstreamBlockId}
parent={currentContainerState.parent} parent={currentContainerState.parent}

View File

@@ -1,3 +1,4 @@
/* istanbul ignore file */
import { CourseContainerChildrenData } from '@src/course-unit/data/types'; import { CourseContainerChildrenData } from '@src/course-unit/data/types';
import * as unitApi from '@src/course-unit/data/api'; import * as unitApi from '@src/course-unit/data/api';
@@ -9,15 +10,19 @@ import * as unitApi from '@src/course-unit/data/api';
export async function mockGetCourseContainerChildren(containerId: string): Promise<CourseContainerChildrenData> { export async function mockGetCourseContainerChildren(containerId: string): Promise<CourseContainerChildrenData> {
const numChildren: number = 3; const numChildren: number = 3;
let blockType: string; let blockType: string;
let displayName: string;
switch (containerId) { switch (containerId) {
case mockGetCourseContainerChildren.unitId: case mockGetCourseContainerChildren.unitId:
blockType = 'text'; blockType = 'text';
displayName = 'unit block 00';
break; break;
case mockGetCourseContainerChildren.sectionId: case mockGetCourseContainerChildren.sectionId:
blockType = 'subsection'; blockType = 'subsection';
displayName = 'Test Title';
break; break;
case mockGetCourseContainerChildren.subsectionId: case mockGetCourseContainerChildren.subsectionId:
blockType = 'unit'; blockType = 'unit';
displayName = 'subsection block 00';
break; break;
case mockGetCourseContainerChildren.unitIdLoading: case mockGetCourseContainerChildren.unitIdLoading:
case mockGetCourseContainerChildren.sectionIdLoading: case mockGetCourseContainerChildren.sectionIdLoading:
@@ -25,6 +30,7 @@ export async function mockGetCourseContainerChildren(containerId: string): Promi
return new Promise(() => { }); return new Promise(() => { });
default: default:
blockType = 'unit'; blockType = 'unit';
displayName = 'subsection block 00';
break; break;
} }
const children = Array(numChildren).fill(mockGetCourseContainerChildren.childTemplate).map((child, idx) => ( const children = Array(numChildren).fill(mockGetCourseContainerChildren.childTemplate).map((child, idx) => (
@@ -32,7 +38,7 @@ export async function mockGetCourseContainerChildren(containerId: string): Promi
...child, ...child,
// Generate a unique ID for each child block to avoid "duplicate key" errors in tests // Generate a unique ID for each child block to avoid "duplicate key" errors in tests
id: `block-v1:UNIX+UX1+2025_T3+type@${blockType}+block@${idx}`, id: `block-v1:UNIX+UX1+2025_T3+type@${blockType}+block@${idx}`,
name: `${blockType} block ${idx}`, name: `${blockType} block ${idx}${idx}`,
blockType, blockType,
upstreamLink: { upstreamLink: {
upstreamRef: `lct:org1:Demo_course_generated:${blockType}:${blockType}-${idx}`, upstreamRef: `lct:org1:Demo_course_generated:${blockType}:${blockType}-${idx}`,
@@ -47,6 +53,7 @@ export async function mockGetCourseContainerChildren(containerId: string): Promi
canPasteComponent: true, canPasteComponent: true,
isPublished: false, isPublished: false,
children, children,
displayName,
}); });
} }
mockGetCourseContainerChildren.unitId = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0'; mockGetCourseContainerChildren.unitId = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@0';

View File

@@ -1,6 +1,11 @@
import { defineMessages } from '@edx/frontend-platform/i18n'; import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({ const messages = defineMessages({
error: {
id: 'course-authoring.container-comparison.diff.error.message',
defaultMessage: 'Unexpected error: Failed to fetch container data',
description: 'Generic error message',
},
removedDiffBeforeMessage: { removedDiffBeforeMessage: {
id: 'course-authoring.container-comparison.diff.before.removed-message', id: 'course-authoring.container-comparison.diff.before.removed-message',
defaultMessage: 'This {blockType} will be removed in the new version', defaultMessage: 'This {blockType} will be removed in the new version',

View File

@@ -42,4 +42,5 @@ export interface CourseContainerChildrenData {
canPasteComponent: boolean; canPasteComponent: boolean;
children: ContainerChildData[], children: ContainerChildData[],
isPublished: boolean; isPublished: boolean;
displayName: string;
} }

View File

@@ -141,7 +141,6 @@ export const PreviewLibraryXBlockChanges = ({
if (blockData.isContainer) { if (blockData.isContainer) {
return ( return (
<CompareContainersWidget <CompareContainersWidget
title={blockData.displayName}
upstreamBlockId={blockData.upstreamBlockId} upstreamBlockId={blockData.upstreamBlockId}
downstreamBlockId={blockData.downstreamBlockId} downstreamBlockId={blockData.downstreamBlockId}
/> />