diff --git a/src/generic/key-utils.test.ts b/src/generic/key-utils.test.ts
index f9e12072a2..db61856458 100644
--- a/src/generic/key-utils.test.ts
+++ b/src/generic/key-utils.test.ts
@@ -3,6 +3,8 @@ import {
ContainerType,
getBlockType,
getLibraryId,
+ isContainerType,
+ isContainerUsageKey,
isLibraryKey,
isLibraryV1Key,
normalizeContainerType,
@@ -165,4 +167,62 @@ describe('component utils', () => {
});
}
});
+
+ describe('isContainerType', () => {
+ for (
+ const containerType of [
+ ContainerType.Vertical,
+ ContainerType.Sequential,
+ ContainerType.Chapter,
+ ContainerType.Unit,
+ ContainerType.Subsection,
+ ContainerType.Section,
+ ] as const
+ ) {
+ it(`returns true for '${containerType}'`, () => {
+ expect(isContainerType(containerType)).toBe(true);
+ });
+ }
+
+ for (const containerType of ['html', 'problem', '', 'video']) {
+ it(`returns false for '${containerType}'`, () => {
+ expect(isContainerType(containerType)).toBe(false);
+ });
+ }
+ });
+
+ describe('isContainerUsageKey', () => {
+ for (
+ const usageKey of [
+ 'lct:org:lib:section:my-section-9284e2',
+ 'lct:org:lib:subsection:my-subsection-9284e2',
+ 'lct:org:lib:unit:my-unit-9284e2',
+ 'block-v1:org+type@chapter+block@1',
+ 'block-v1:org+type@sequential+block@1',
+ 'block-v1:org+type@vertical+block@1',
+ 'block-v1:org+type@section+block@1',
+ 'block-v1:org+type@subsection+block@1',
+ 'block-v1:org+type@unit+block@1',
+ ]
+ ) {
+ it(`returns true for '${usageKey}'`, () => {
+ expect(isContainerUsageKey(usageKey)).toBe(true);
+ });
+ }
+
+ for (
+ const usageKey of [
+ 'lb:org:lib:html:id',
+ 'block-v1:org+type@problem+block@1',
+ 'not a key',
+ '',
+ undefined,
+ null,
+ ]
+ ) {
+ it(`returns false for '${usageKey}'`, () => {
+ expect(isContainerUsageKey(usageKey as any)).toBe(false);
+ });
+ }
+ });
});
diff --git a/src/generic/key-utils.ts b/src/generic/key-utils.ts
index beb8b283f7..b7a75c1028 100644
--- a/src/generic/key-utils.ts
+++ b/src/generic/key-utils.ts
@@ -141,3 +141,27 @@ export function normalizeContainerType(containerType: ContainerType | string) {
return containerType;
}
}
+
+/**
+ * Check whether a block/container type points to a container (section/subsection/unit),
+ * including legacy names (chapter/sequential/vertical).
+ */
+export function isContainerType(containerType: ContainerType | string): boolean {
+ const normalizedType = normalizeContainerType(containerType);
+
+ return [ContainerType.Section, ContainerType.Subsection, ContainerType.Unit].includes(
+ normalizedType as ContainerType,
+ );
+}
+
+/**
+ * Check whether a usage key belongs to a container.
+ */
+export function isContainerUsageKey(usageKey: string | undefined | null): boolean {
+ if (typeof usageKey !== 'string') {
+ return false;
+ }
+
+ const blockType = getBlockType(usageKey, 'empty');
+ return blockType ? isContainerType(blockType) : false;
+}
diff --git a/src/library-authoring/LibraryBlock/LibraryBlock.tsx b/src/library-authoring/LibraryBlock/LibraryBlock.tsx
index 3bc6a85ec8..9dc971a272 100644
--- a/src/library-authoring/LibraryBlock/LibraryBlock.tsx
+++ b/src/library-authoring/LibraryBlock/LibraryBlock.tsx
@@ -21,6 +21,7 @@ interface LibraryBlockProps {
minHeight?: string;
scrollIntoView?: boolean;
showTitle?: boolean;
+ addHeight?: number;
}
/**
* React component that displays an XBlock in a sandboxed IFrame.
@@ -40,6 +41,7 @@ export const LibraryBlock = ({
scrolling = 'no',
scrollIntoView = false,
showTitle = false,
+ addHeight = 0,
}: LibraryBlockProps) => {
const { iframeRef, setIframeRef } = useIframe();
const xblockView = view ?? 'student_view';
@@ -50,6 +52,7 @@ export const LibraryBlock = ({
const intl = useIntl();
const params = new URLSearchParams();
+
if (version) {
params.set('version', version.toString());
}
@@ -79,6 +82,10 @@ export const LibraryBlock = ({
useIframeContent(iframeRef, setIframeRef);
+ if (version === 0) {
+ return null;
+ }
+
return (
', () => {
render();
// By default we see the new version:
- const newTab = screen.getByRole('tab', { name: 'New version' });
+ const newTab = await screen.findByRole('tab', { name: 'New version' });
expect(newTab).toBeInTheDocument();
expect(newTab).toHaveClass('active');
- const newTabPanel = screen.getByRole('tabpanel', { name: 'New version' });
+ const newTabPanel = await screen.findByRole('tabpanel', { name: 'New version' });
const newIframe = within(newTabPanel).getByTitle('Preview');
expect(newIframe).toBeVisible();
expect(newIframe).toHaveAttribute(
@@ -32,10 +32,10 @@ describe('', () => {
);
// Now switch to the "old version" tab:
- const oldTab = screen.getByRole('tab', { name: 'Old version' });
+ const oldTab = await screen.findByRole('tab', { name: 'Old version' });
fireEvent.click(oldTab);
- const oldTabPanel = screen.getByRole('tabpanel', { name: 'Old version' });
+ const oldTabPanel = await screen.findByRole('tabpanel', { name: 'Old version' });
expect(oldTabPanel).toBeVisible();
const oldIframe = within(oldTabPanel).getByTitle('Preview');
expect(oldIframe).toBeVisible();
@@ -49,11 +49,11 @@ describe('', () => {
render();
// By default we see the new version:
- const newTab = screen.getByRole('tab', { name: 'New version' });
+ const newTab = await screen.findByRole('tab', { name: 'New version' });
expect(newTab).toBeInTheDocument();
expect(newTab).toHaveClass('active');
- const newTabPanel = screen.getByRole('tabpanel', { name: 'New version' });
+ const newTabPanel = await screen.findByRole('tabpanel', { name: 'New version' });
const newIframe = within(newTabPanel).getByTitle('Preview');
expect(newIframe).toBeVisible();
expect(newIframe).toHaveAttribute(
@@ -62,10 +62,10 @@ describe('', () => {
);
// Now switch to the "old version" tab:
- const oldTab = screen.getByRole('tab', { name: 'Old version' });
+ const oldTab = await screen.findByRole('tab', { name: 'Old version' });
fireEvent.click(oldTab);
- const oldTabPanel = screen.getByRole('tabpanel', { name: 'Old version' });
+ const oldTabPanel = await screen.findByRole('tabpanel', { name: 'Old version' });
expect(oldTabPanel).toBeVisible();
const oldIframe = within(oldTabPanel).getByTitle('Preview');
expect(oldIframe).toBeVisible();
@@ -74,4 +74,22 @@ describe('', () => {
`http://localhost:18010/xblocks/v2/${usageKey}/embed/student_view/?version=7`,
);
});
+
+ it('renders side-by-side compare mode with both iframes and expected sources', async () => {
+ render();
+
+ expect(await screen.findByText('Old version')).toBeInTheDocument();
+ expect(await screen.findByText('New version')).toBeInTheDocument();
+
+ const iframes = await screen.findAllByTitle('Preview');
+ expect(iframes).toHaveLength(2);
+ expect(iframes[0]).toHaveAttribute(
+ 'src',
+ `http://localhost:18010/xblocks/v2/${usageKey}/embed/student_view/?version=7`,
+ );
+ expect(iframes[1]).toHaveAttribute(
+ 'src',
+ `http://localhost:18010/xblocks/v2/${usageKey}/embed/student_view/?version=published`,
+ );
+ });
});
diff --git a/src/library-authoring/component-comparison/CompareChangesWidget.tsx b/src/library-authoring/component-comparison/CompareChangesWidget.tsx
index e7dd66eb15..0db9c84b95 100644
--- a/src/library-authoring/component-comparison/CompareChangesWidget.tsx
+++ b/src/library-authoring/component-comparison/CompareChangesWidget.tsx
@@ -1,5 +1,6 @@
import { useIntl } from '@edx/frontend-platform/i18n';
-import { Tab, Tabs } from '@openedx/paragon';
+import { Card, Stack, Tab, Tabs } from '@openedx/paragon';
+import classNames from 'classnames';
import { IframeProvider } from '@src/generic/hooks/context/iFrameContext';
import { LibraryBlock, type VersionSpec } from '../LibraryBlock';
@@ -14,6 +15,8 @@ interface Props {
showNewTitle?: boolean;
hasLocalChanges?: boolean;
oldUsageKey?: string;
+ sideBySide?: boolean;
+ showTitle?: boolean;
}
/**
@@ -32,6 +35,8 @@ const CompareChangesWidget = ({
showNewTitle = false,
oldUsageKey,
hasLocalChanges = false,
+ sideBySide = false,
+ showTitle = false,
}: Props) => {
const intl = useIntl();
@@ -42,40 +47,76 @@ const CompareChangesWidget = ({
? intl.formatMessage(messages.publishedLibraryContentTitle)
: intl.formatMessage(messages.newVersionTitle);
- return (
-
-
-
-
- {oldTitle && hasLocalChanges && (
-
- {oldTitle}
-
- )}
-
-
-
-
-
-
-
-
-
-
-
-
+ const oldBlock = oldVersion !== 0 && (
+
+
+ {sideBySide && (
+
+ {oldTabMessage}
+
+ )}
+ {oldTitle && hasLocalChanges && (
+
+ {oldTitle}
-
-
+ )}
+
+
+
+
+
+
+
+ );
+
+ const newBlock = (
+
+
+ {sideBySide && (
+
+ {newTabMessage}
+
+ )}
+
+
+
+
+
+ );
+
+ return (
+
+ {sideBySide ?
+ (
+
+ {oldBlock}
+ {newBlock}
+
+ ) :
+ (
+
+ {oldBlock && {oldBlock}}
+ {newBlock}
+
+ )}
);
};
diff --git a/src/library-authoring/data/api.ts b/src/library-authoring/data/api.ts
index 1f560ce892..ea28bb91c7 100644
--- a/src/library-authoring/data/api.ts
+++ b/src/library-authoring/data/api.ts
@@ -1016,6 +1016,8 @@ export interface LibraryHistoryEntry {
title: string;
itemType: string;
action: 'edited' | 'renamed' | 'created';
+ oldVersion?: number;
+ newVersion?: number;
}
/**
diff --git a/src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx
new file mode 100644
index 0000000000..e63afef23b
--- /dev/null
+++ b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx
@@ -0,0 +1,84 @@
+import { userEvent } from '@testing-library/user-event';
+import { render, screen, initializeMocks } from '@src/testUtils';
+
+import HistoryCompareChangesModal from './HistoryCompareChangesModal';
+
+jest.mock('@src/library-authoring/component-comparison/CompareChangesWidget', () => ({
+ __esModule: true,
+ default: jest.fn(({ usageKey, oldTitle, oldVersion, newVersion, sideBySide, showTitle }) => (
+
+ )),
+}));
+
+describe('
', () => {
+ beforeEach(() => {
+ initializeMocks();
+ });
+
+ it('renders the comparison widget with the selected versions', async () => {
+ render(
+
,
+ );
+
+ const modalTitle = await screen.findByText('Preview changes: Electron Arcs');
+ expect(modalTitle).toBeInTheDocument();
+ expect(modalTitle.querySelector('svg')).toBeInTheDocument();
+ expect(await screen.findByTestId('compare-changes-widget')).toHaveAttribute('data-usage-key', 'lb:org:lib:type:id');
+ expect(await screen.findByTestId('compare-changes-widget')).toHaveAttribute('data-old-title', 'Electron Arcs');
+ expect(await screen.findByTestId('compare-changes-widget')).toHaveAttribute('data-old-version', '3');
+ expect(await screen.findByTestId('compare-changes-widget')).toHaveAttribute('data-new-version', 'published');
+ expect(await screen.findByTestId('compare-changes-widget')).toHaveAttribute('data-side-by-side', 'true');
+ expect(await screen.findByTestId('compare-changes-widget')).toHaveAttribute('data-show-title', 'true');
+ expect(screen.getByRole('button', { name: 'Done' })).toBeInTheDocument();
+ });
+
+ it('closes modal when clicking done button', async () => {
+ const onClose = jest.fn();
+ const user = userEvent.setup();
+
+ render(
+
,
+ );
+
+ await user.click(screen.getByRole('button', { name: 'Done' }));
+ expect(onClose).toHaveBeenCalledTimes(1);
+ });
+
+ it('renders nothing for container usage keys', () => {
+ render(
+
,
+ );
+
+ expect(screen.queryByText('Preview changes: Intro Unit')).not.toBeInTheDocument();
+ expect(screen.queryByTestId('compare-changes-widget')).not.toBeInTheDocument();
+ });
+});
diff --git a/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx
new file mode 100644
index 0000000000..ee7ed919b1
--- /dev/null
+++ b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx
@@ -0,0 +1,86 @@
+import { Button, Icon, ModalDialog, Stack } from '@openedx/paragon';
+import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
+
+import CompareChangesWidget from '@src/library-authoring/component-comparison/CompareChangesWidget';
+import { type VersionSpec } from '@src/library-authoring/LibraryBlock';
+import { getItemIcon } from '@src/generic/block-type-utils';
+import { getBlockType, isContainerUsageKey } from '@src/generic/key-utils';
+
+import messages from './messages';
+import classNames from 'classnames';
+
+interface HistoryCompareChangesModalProps {
+ isOpen: boolean;
+ onClose: () => void;
+ usageKey: string;
+ oldTitle?: string;
+ oldVersion?: VersionSpec;
+ newVersion?: VersionSpec;
+ sideBySide?: boolean;
+}
+
+const HistoryCompareChangesModal = ({
+ isOpen,
+ onClose,
+ usageKey,
+ oldTitle,
+ oldVersion,
+ newVersion = 'published',
+ sideBySide = true,
+}: HistoryCompareChangesModalProps) => {
+ const intl = useIntl();
+ const shouldShowDiff = !isContainerUsageKey(usageKey);
+
+ if (!shouldShowDiff) {
+ return null;
+ }
+
+ const title = intl.formatMessage(messages.previewChangesTitle, { title: oldTitle });
+ const blockType = getBlockType(usageKey, 'empty');
+
+ return (
+
+
+
+
+
+
+ {oldTitle}
+ >
+ ),
+ }}
+ />
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default HistoryCompareChangesModal;
diff --git a/src/library-authoring/generic/history-log/HistoryLog.tsx b/src/library-authoring/generic/history-log/HistoryLog.tsx
index 8007559e24..230a9c8c92 100644
--- a/src/library-authoring/generic/history-log/HistoryLog.tsx
+++ b/src/library-authoring/generic/history-log/HistoryLog.tsx
@@ -49,6 +49,7 @@ export const HistoryComponentLog = ({ componentId }: { componentId: string; }) =
{draftHistory && draftHistory.length !== 0 && (
@@ -150,6 +151,7 @@ export const HistoryContainerLog = ({ containerId }: { containerId: string; }) =
{draftHistory && draftHistory.length !== 0 && (
diff --git a/src/library-authoring/generic/history-log/HistoryLogGroup.test.tsx b/src/library-authoring/generic/history-log/HistoryLogGroup.test.tsx
new file mode 100644
index 0000000000..0b9b947b44
--- /dev/null
+++ b/src/library-authoring/generic/history-log/HistoryLogGroup.test.tsx
@@ -0,0 +1,91 @@
+import { userEvent } from '@testing-library/user-event';
+import {
+ initializeMocks,
+ render,
+ screen,
+ findByDeepTextContent,
+} from '@src/testUtils';
+
+import {
+ mockLibraryBlockDraftHistory,
+ mockLibraryBlockPublishHistory,
+ mockLibraryBlockPublishHistoryEntries,
+ mockLibraryContainerDraftHistory,
+} from '@src/library-authoring/data/api.mocks';
+import { HistoryDraftLogGroup, HistoryPublishLogGroup } from './HistoryLogGroup';
+
+mockLibraryBlockDraftHistory.applyMock();
+mockLibraryBlockPublishHistory.applyMock();
+mockLibraryBlockPublishHistoryEntries.applyMock();
+
+const draftEntries = mockLibraryBlockDraftHistory.data;
+const publishGroup = mockLibraryBlockPublishHistory.data[0];
+const containerDraftEntries = mockLibraryContainerDraftHistory.data;
+
+describe('
', () => {
+ beforeEach(() => {
+ initializeMocks();
+ });
+
+ it('opens the compare modal from a draft entry action menu', async () => {
+ const user = userEvent.setup();
+
+ render(
+
,
+ );
+
+ const trigger = await findByDeepTextContent(/Test Component is a draft/i);
+ await user.click(trigger);
+
+ const firstEntry = await findByDeepTextContent(/test_user_1 edited.*Electron Arcs/i);
+ expect(firstEntry).toBeInTheDocument();
+
+ await user.click(await screen.findAllByRole('button', { name: /more actions/i }).then(buttons => buttons[0]));
+ await user.click(await screen.findByText('Show this version'));
+
+ expect(await screen.findByText('Preview changes: Electron Arcs')).toBeInTheDocument();
+ });
+
+ it('shows publish history entries and opens the compare modal after expanding', async () => {
+ const user = userEvent.setup();
+
+ render(
+
,
+ );
+
+ const publishTrigger = await findByDeepTextContent(/author published.*Protons/i);
+ await user.click(publishTrigger);
+
+ expect(await findByDeepTextContent(/test_user edited.*Protons/i)).toBeInTheDocument();
+
+ await user.click(await screen.findByRole('button', { name: /more actions/i }));
+ await user.click(await screen.findByText('Show this version'));
+
+ expect(await screen.findByText('Preview changes: Protons')).toBeInTheDocument();
+ });
+
+ it('hides entry action dropdown for container item ids', async () => {
+ const user = userEvent.setup();
+
+ render(
+
,
+ );
+
+ const trigger = await findByDeepTextContent(/Intro Unit is a draft/i);
+ await user.click(trigger);
+
+ expect(await findByDeepTextContent(/container_user_1 edited.*Intro Unit/i)).toBeInTheDocument();
+ expect(screen.queryByRole('button', { name: /more actions/i })).not.toBeInTheDocument();
+ });
+});
diff --git a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx
index 033b151ffd..6c9c6d00e5 100644
--- a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx
+++ b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx
@@ -30,6 +30,7 @@ export interface HistoryCreatedLogGroupProps {
}
export interface HistoryDraftLogGroupProps {
+ itemId: string;
displayName: string;
entries: LibraryHistoryEntry[];
}
@@ -97,6 +98,7 @@ export const HistoryCreatedLogGroup = ({
};
export const HistoryDraftLogGroup = ({
+ itemId,
displayName,
entries,
}: HistoryDraftLogGroupProps) => {
@@ -123,7 +125,7 @@ export const HistoryDraftLogGroup = ({
/>
-
+
@@ -184,7 +186,7 @@ export const HistoryPublishLogGroup = ({
>
) :
-
}
+
}
) :
diff --git a/src/library-authoring/generic/history-log/HistoryLogGroupEntries.tsx b/src/library-authoring/generic/history-log/HistoryLogGroupEntries.tsx
index 05eb175bb6..63801d3281 100644
--- a/src/library-authoring/generic/history-log/HistoryLogGroupEntries.tsx
+++ b/src/library-authoring/generic/history-log/HistoryLogGroupEntries.tsx
@@ -1,19 +1,28 @@
import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n';
import { LibraryHistoryEntry } from '@src/library-authoring/data/api';
import messages from './messages';
-import { Icon, Stack } from '@openedx/paragon';
+import { Dropdown, Icon, IconButton, Stack } from '@openedx/paragon';
import { ContributorAvatar } from './ContributorAvatars';
import { getItemIcon } from '@src/generic/block-type-utils';
+import { isContainerUsageKey } from '@src/generic/key-utils';
import moment from 'moment';
+import { MoreVert } from '@openedx/paragon/icons';
+import { useToggleWithValue } from '@src/hooks';
+import HistoryCompareChangesModal from '@src/library-authoring/generic/history-log/HistoryCompareChangesModal';
export interface HistoryLogGroupEntriesProps {
+ itemId: string;
entries: LibraryHistoryEntry[];
}
export const HistoryLogGroupEntries = ({
+ itemId,
entries,
}: HistoryLogGroupEntriesProps) => {
const intl = useIntl();
+ const [isChangeModalOpen, changeModalData, openChangeModal, closeChangeModal] = useToggleWithValue<
+ LibraryHistoryEntry
+ >();
const getEntryMessage = (entry: LibraryHistoryEntry) => {
switch (entry.action) {
@@ -29,41 +38,71 @@ export const HistoryLogGroupEntries = ({
};
return (
-
-
- {entries.map((entry, index, arr) => {
- const isLast = index === arr.length - 1;
- const entryMessage = getEntryMessage(entry);
+ <>
+
+
+ {entries.map((entry, index, arr) => {
+ const isLast = index === arr.length - 1;
+ const entryMessage = getEntryMessage(entry);
- return (
-
-
-
-
-
- {entry.title},
- icon: ,
- }}
- />
+ return (
+
+
+
+
+
+ {entry.title},
+ icon: ,
+ }}
+ />
+
+
+ {moment(entry.changedAt).fromNow()}
+
-
- {moment(entry.changedAt).fromNow()}
-
+ {!isContainerUsageKey(itemId) &&
+ (
+
+
+
+ openChangeModal(entry)}>
+ {intl.formatMessage(messages.showThisVersion)}
+
+
+
+ )}
-
- {!isLast &&
}
-
- );
- })}
-
+ {!isLast && }
+
+ );
+ })}
+
+ {isChangeModalOpen && changeModalData && (
+
+ )}
+ >
);
};
diff --git a/src/library-authoring/generic/history-log/messages.ts b/src/library-authoring/generic/history-log/messages.ts
index 6fbdc8514c..1df4616551 100644
--- a/src/library-authoring/generic/history-log/messages.ts
+++ b/src/library-authoring/generic/history-log/messages.ts
@@ -56,6 +56,26 @@ const messages = defineMessages({
defaultMessage: '{count} {count, plural, one {author} other {authors}} contributed',
description: 'Contributors count in a publish history group',
},
+ previewChangesTitle: {
+ id: 'course-authoring.library-authoring.history.preview-changes.title',
+ defaultMessage: 'Preview changes: {title}',
+ description: 'Title for the modal that previews changes for a history entry.',
+ },
+ moreActions: {
+ id: 'course-authoring.library-authoring.history.more-actions',
+ defaultMessage: 'More actions',
+ description: 'Dropdown label for history log actions.',
+ },
+ showThisVersion: {
+ id: 'course-authoring.library-authoring.history.show-this-version',
+ defaultMessage: 'Show this version',
+ description: 'Action to open the version comparison modal for a history entry.',
+ },
+ doneButton: {
+ id: 'course-authoring.library-authoring.history.done-button',
+ defaultMessage: 'Done',
+ description: 'Button label to close preview changes modal.',
+ },
});
export default messages;