From 682898b5f8f59f9533f6af379be964355ac6c34a Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 7 Apr 2026 15:21:42 +0530 Subject: [PATCH 01/13] feat(history-log): View previous change state Include the option to view a previous change state from history entries. --- .../LibraryBlock/LibraryBlock.tsx | 5 ++ .../CompareChangesWidget.tsx | 34 ++++++------ src/library-authoring/data/api.ts | 2 + .../HistoryCompareChangesModal.tsx | 52 +++++++++++++++++++ .../generic/history-log/HistoryLog.tsx | 1 + .../generic/history-log/HistoryLogGroup.tsx | 4 +- .../history-log/HistoryLogGroupEntries.tsx | 35 ++++++++++++- .../generic/history-log/messages.ts | 15 ++++++ 8 files changed, 130 insertions(+), 18 deletions(-) create mode 100644 src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx diff --git a/src/library-authoring/LibraryBlock/LibraryBlock.tsx b/src/library-authoring/LibraryBlock/LibraryBlock.tsx index 3bc6a85ec8..2515616183 100644 --- a/src/library-authoring/LibraryBlock/LibraryBlock.tsx +++ b/src/library-authoring/LibraryBlock/LibraryBlock.tsx @@ -50,6 +50,11 @@ export const LibraryBlock = ({ const intl = useIntl(); const params = new URLSearchParams(); + + if (version === 0) { + return null; + } + if (version) { params.set('version', version.toString()); } diff --git a/src/library-authoring/component-comparison/CompareChangesWidget.tsx b/src/library-authoring/component-comparison/CompareChangesWidget.tsx index e7dd66eb15..743afb2e02 100644 --- a/src/library-authoring/component-comparison/CompareChangesWidget.tsx +++ b/src/library-authoring/component-comparison/CompareChangesWidget.tsx @@ -45,24 +45,26 @@ const CompareChangesWidget = ({ return (
- -
- {oldTitle && hasLocalChanges && ( -
- {oldTitle} + {oldVersion !== 0 && + +
+ {oldTitle && hasLocalChanges && ( +
+ {oldTitle} +
+ )} +
+ + +
- )} -
- - -
-
-
+ + }
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.tsx b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx new file mode 100644 index 0000000000..c6c5a7ec85 --- /dev/null +++ b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx @@ -0,0 +1,52 @@ +import { ModalDialog } from '@openedx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; + +import CompareChangesWidget from '@src/library-authoring/component-comparison/CompareChangesWidget'; +import { type VersionSpec } from '@src/library-authoring/LibraryBlock'; + +import messages from './messages'; + +interface HistoryCompareChangesModalProps { + isOpen: boolean; + onClose: () => void; + usageKey: string; + oldTitle?: string; + oldVersion?: VersionSpec; + newVersion?: VersionSpec; +} + +const HistoryCompareChangesModal = ({ + isOpen, + onClose, + usageKey, + oldTitle, + oldVersion, + newVersion = 'published', +}: HistoryCompareChangesModalProps) => { + const intl = useIntl(); + const title = intl.formatMessage(messages.previewChangesTitle, { title: oldTitle }); + + return ( + + + {title} + + + + + + ); +}; + +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..1c001e6bc0 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 && ( diff --git a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx index 033b151ffd..6b9687baaa 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 = ({ /> - +
diff --git a/src/library-authoring/generic/history-log/HistoryLogGroupEntries.tsx b/src/library-authoring/generic/history-log/HistoryLogGroupEntries.tsx index 05eb175bb6..805021eaa4 100644 --- a/src/library-authoring/generic/history-log/HistoryLogGroupEntries.tsx +++ b/src/library-authoring/generic/history-log/HistoryLogGroupEntries.tsx @@ -1,19 +1,25 @@ 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 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(); const getEntryMessage = (entry: LibraryHistoryEntry) => { switch (entry.action) { @@ -29,6 +35,7 @@ export const HistoryLogGroupEntries = ({ }; return ( + <>
{entries.map((entry, index, arr) => { @@ -59,11 +66,37 @@ export const HistoryLogGroupEntries = ({ {moment(entry.changedAt).fromNow()} + + + + openChangeModal(entry)}> + {intl.formatMessage(messages.showThisVersion)} + + + {!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..38a5841698 100644 --- a/src/library-authoring/generic/history-log/messages.ts +++ b/src/library-authoring/generic/history-log/messages.ts @@ -56,6 +56,21 @@ 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.', + }, }); export default messages; From b47454fe54e94663416141c351d3c6d8f6511548 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 7 Apr 2026 17:17:49 +0530 Subject: [PATCH 02/13] feat: side by side view --- .../CompareChangesWidget.tsx | 99 ++++++++++++------- .../HistoryCompareChangesModal.tsx | 6 ++ 2 files changed, 70 insertions(+), 35 deletions(-) diff --git a/src/library-authoring/component-comparison/CompareChangesWidget.tsx b/src/library-authoring/component-comparison/CompareChangesWidget.tsx index 743afb2e02..9fe143d941 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,42 +47,66 @@ const CompareChangesWidget = ({ ? intl.formatMessage(messages.publishedLibraryContentTitle) : intl.formatMessage(messages.newVersionTitle); + const oldBlock = oldVersion !== 0 && ( + + + {sideBySide && ( +

+ {oldTabMessage} +

+ )} + {oldTitle && hasLocalChanges && ( +
+ {oldTitle} +
+ )} +
+ + + +
+
+
+ ); + + const newBlock = ( + + + {sideBySide && ( +

+ {newTabMessage} +

+ )} + + + +
+
+ ); + return (
- - {oldVersion !== 0 && - -
- {oldTitle && hasLocalChanges && ( -
- {oldTitle} -
- )} -
- - - -
-
-
- } - -
- - - -
-
-
+ {sideBySide ? ( + + {oldBlock} + {newBlock} + + ) : ( + + {oldBlock && {oldBlock}} + {newBlock} + + )}
); }; diff --git a/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx index c6c5a7ec85..30c677f0f3 100644 --- a/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx +++ b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx @@ -5,6 +5,7 @@ import CompareChangesWidget from '@src/library-authoring/component-comparison/Co import { type VersionSpec } from '@src/library-authoring/LibraryBlock'; import messages from './messages'; +import classNames from 'classnames'; interface HistoryCompareChangesModalProps { isOpen: boolean; @@ -13,6 +14,7 @@ interface HistoryCompareChangesModalProps { oldTitle?: string; oldVersion?: VersionSpec; newVersion?: VersionSpec; + sideBySide?: boolean; } const HistoryCompareChangesModal = ({ @@ -22,6 +24,7 @@ const HistoryCompareChangesModal = ({ oldTitle, oldVersion, newVersion = 'published', + sideBySide = true, }: HistoryCompareChangesModalProps) => { const intl = useIntl(); const title = intl.formatMessage(messages.previewChangesTitle, { title: oldTitle }); @@ -31,6 +34,7 @@ const HistoryCompareChangesModal = ({ isOpen={isOpen} onClose={onClose} size="xl" + className={classNames({'w-xl-100 mw-xl': sideBySide})} title={title} isOverflowVisible={false} > @@ -43,6 +47,8 @@ const HistoryCompareChangesModal = ({ oldTitle={oldTitle} oldVersion={oldVersion} newVersion={newVersion} + sideBySide={sideBySide} + showTitle /> From e7ce51f6ca520e5a756eeaf768152879cb70e3ef Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Tue, 7 Apr 2026 17:38:25 +0530 Subject: [PATCH 03/13] fix: scrolling in compare preview --- .../LibraryBlock/LibraryBlock.tsx | 4 +++- .../component-comparison/CompareChangesWidget.tsx | 14 +++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/library-authoring/LibraryBlock/LibraryBlock.tsx b/src/library-authoring/LibraryBlock/LibraryBlock.tsx index 2515616183..eeccb719ed 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'; @@ -97,7 +99,7 @@ export const LibraryBlock = ({ referrerPolicy="origin" style={{ width: '100%', - height: iframeHeight, + height: iframeHeight + addHeight, pointerEvents: 'auto', minHeight, }} diff --git a/src/library-authoring/component-comparison/CompareChangesWidget.tsx b/src/library-authoring/component-comparison/CompareChangesWidget.tsx index 9fe143d941..d47134289c 100644 --- a/src/library-authoring/component-comparison/CompareChangesWidget.tsx +++ b/src/library-authoring/component-comparison/CompareChangesWidget.tsx @@ -1,5 +1,5 @@ import { useIntl } from '@edx/frontend-platform/i18n'; -import { Card, Stack, Tab, Tabs } from '@openedx/paragon'; +import { Card, Scrollable, Stack, Tab, Tabs } from '@openedx/paragon'; import classNames from 'classnames'; import { IframeProvider } from '@src/generic/hooks/context/iFrameContext'; @@ -49,7 +49,10 @@ const CompareChangesWidget = ({ const oldBlock = oldVersion !== 0 && ( - + {sideBySide && (

{oldTabMessage} @@ -67,6 +70,7 @@ const CompareChangesWidget = ({ version={oldVersion} minHeight="50vh" showTitle={showTitle} + addHeight={40} />

@@ -76,7 +80,10 @@ const CompareChangesWidget = ({ const newBlock = ( - + {sideBySide && (

{newTabMessage} @@ -88,6 +95,7 @@ const CompareChangesWidget = ({ version={newVersion} showTitle={showNewTitle || showTitle} minHeight="50vh" + addHeight={40} /> From 54295a5cce8960259617e7bd9562a3acf3be5fe7 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 8 Apr 2026 15:17:41 +0530 Subject: [PATCH 04/13] fix: minor issues --- .../component-comparison/CompareChangesWidget.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/library-authoring/component-comparison/CompareChangesWidget.tsx b/src/library-authoring/component-comparison/CompareChangesWidget.tsx index d47134289c..af2c1721c9 100644 --- a/src/library-authoring/component-comparison/CompareChangesWidget.tsx +++ b/src/library-authoring/component-comparison/CompareChangesWidget.tsx @@ -1,5 +1,5 @@ import { useIntl } from '@edx/frontend-platform/i18n'; -import { Card, Scrollable, Stack, 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'; From b2c045c55799f45aa78acd5df4d00aaa9ded383b Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 8 Apr 2026 15:32:14 +0530 Subject: [PATCH 05/13] test: add tests --- .../CompareChangesWidget.test.tsx | 34 ++++++--- .../HistoryCompareChangesModal.test.tsx | 45 ++++++++++++ .../history-log/HistoryLogGroup.test.tsx | 71 +++++++++++++++++++ 3 files changed, 142 insertions(+), 8 deletions(-) create mode 100644 src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx create mode 100644 src/library-authoring/generic/history-log/HistoryLogGroup.test.tsx diff --git a/src/library-authoring/component-comparison/CompareChangesWidget.test.tsx b/src/library-authoring/component-comparison/CompareChangesWidget.test.tsx index ef508eacd9..598c382904 100644 --- a/src/library-authoring/component-comparison/CompareChangesWidget.test.tsx +++ b/src/library-authoring/component-comparison/CompareChangesWidget.test.tsx @@ -19,11 +19,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( @@ -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/generic/history-log/HistoryCompareChangesModal.test.tsx b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx new file mode 100644 index 0000000000..158f63d577 --- /dev/null +++ b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx @@ -0,0 +1,45 @@ +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( + , + ); + + expect(await screen.findByText('Preview changes: Electron Arcs')).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'); + }); +}); 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..586a0b1ce5 --- /dev/null +++ b/src/library-authoring/generic/history-log/HistoryLogGroup.test.tsx @@ -0,0 +1,71 @@ +import { userEvent } from '@testing-library/user-event'; +import { + initializeMocks, + render, + screen, + findByDeepTextContent, +} from '@src/testUtils'; + +import { + mockLibraryBlockDraftHistory, + mockLibraryBlockPublishHistory, + mockLibraryBlockPublishHistoryEntries, +} 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]; + +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(); + }); +}); From 50d198771d0ed10c38c1227c78cb58dc1a6867bf Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Wed, 22 Apr 2026 20:15:54 +0530 Subject: [PATCH 06/13] fix: rebase issues --- src/library-authoring/generic/history-log/HistoryLogGroup.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx index 6b9687baaa..6c9c6d00e5 100644 --- a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx +++ b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx @@ -186,7 +186,7 @@ export const HistoryPublishLogGroup = ({
) : - } + } ) : From ed5efc5949b1b468e4daf7528152bda1f0c6b4ef Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 27 Apr 2026 13:00:45 +0530 Subject: [PATCH 07/13] feat: show changes option for non-container blocks --- src/generic/key-utils.test.ts | 54 +++++++++++++++++++ src/generic/key-utils.ts | 24 +++++++++ .../HistoryCompareChangesModal.test.tsx | 16 ++++++ .../HistoryCompareChangesModal.tsx | 7 +++ .../history-log/HistoryLogGroup.test.tsx | 20 +++++++ .../generic/history-log/HistoryLogGroup.tsx | 1 + 6 files changed, 122 insertions(+) diff --git a/src/generic/key-utils.test.ts b/src/generic/key-utils.test.ts index f9e12072a2..c5b2b0b55e 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,56 @@ 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/generic/history-log/HistoryCompareChangesModal.test.tsx b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx index 158f63d577..c256b78833 100644 --- a/src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx +++ b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx @@ -42,4 +42,20 @@ describe('', () => { 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'); }); + + 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 index 30c677f0f3..75f144f693 100644 --- a/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx +++ b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx @@ -3,6 +3,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import CompareChangesWidget from '@src/library-authoring/component-comparison/CompareChangesWidget'; import { type VersionSpec } from '@src/library-authoring/LibraryBlock'; +import { isContainerUsageKey } from '@src/generic/key-utils'; import messages from './messages'; import classNames from 'classnames'; @@ -27,6 +28,12 @@ const HistoryCompareChangesModal = ({ sideBySide = true, }: HistoryCompareChangesModalProps) => { const intl = useIntl(); + const shouldShowDiff = !isContainerUsageKey(usageKey); + + if (!shouldShowDiff) { + return null; + } + const title = intl.formatMessage(messages.previewChangesTitle, { title: oldTitle }); return ( diff --git a/src/library-authoring/generic/history-log/HistoryLogGroup.test.tsx b/src/library-authoring/generic/history-log/HistoryLogGroup.test.tsx index 586a0b1ce5..0b9b947b44 100644 --- a/src/library-authoring/generic/history-log/HistoryLogGroup.test.tsx +++ b/src/library-authoring/generic/history-log/HistoryLogGroup.test.tsx @@ -10,6 +10,7 @@ import { mockLibraryBlockDraftHistory, mockLibraryBlockPublishHistory, mockLibraryBlockPublishHistoryEntries, + mockLibraryContainerDraftHistory, } from '@src/library-authoring/data/api.mocks'; import { HistoryDraftLogGroup, HistoryPublishLogGroup } from './HistoryLogGroup'; @@ -19,6 +20,7 @@ mockLibraryBlockPublishHistoryEntries.applyMock(); const draftEntries = mockLibraryBlockDraftHistory.data; const publishGroup = mockLibraryBlockPublishHistory.data[0]; +const containerDraftEntries = mockLibraryContainerDraftHistory.data; describe('', () => { beforeEach(() => { @@ -68,4 +70,22 @@ describe('', () => { 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 6c9c6d00e5..93ce1ca364 100644 --- a/src/library-authoring/generic/history-log/HistoryLogGroup.tsx +++ b/src/library-authoring/generic/history-log/HistoryLogGroup.tsx @@ -7,6 +7,7 @@ import { useIntl } from '@edx/frontend-platform/i18n'; import { useLibraryPublishHistoryEntries } from '@src/library-authoring/data/apiHooks'; import { LoadingSpinner } from '@src/generic/Loading'; +import { isContainerUsageKey } from '@src/generic/key-utils'; import { LibraryHistoryEntry, LibraryPublishHistoryGroup } from '../../data/api'; import messages from './messages'; From 69f9ad69237a19a53bd010d6c082765ae5472121 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 27 Apr 2026 13:09:54 +0530 Subject: [PATCH 08/13] fix(history-log): add icon to preview changes modal --- .../HistoryCompareChangesModal.test.tsx | 4 +++- .../HistoryCompareChangesModal.tsx | 24 +++++++++++++++---- 2 files changed, 23 insertions(+), 5 deletions(-) diff --git a/src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx index c256b78833..eb4d16fd43 100644 --- a/src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx +++ b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx @@ -34,7 +34,9 @@ describe('', () => { />, ); - expect(await screen.findByText('Preview changes: Electron Arcs')).toBeInTheDocument(); + 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'); diff --git a/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx index 75f144f693..8995133f32 100644 --- a/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx +++ b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx @@ -1,9 +1,10 @@ -import { ModalDialog } from '@openedx/paragon'; -import { useIntl } from '@edx/frontend-platform/i18n'; +import { 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 { isContainerUsageKey } from '@src/generic/key-utils'; +import { getItemIcon } from '@src/generic/block-type-utils'; +import { getBlockType, isContainerUsageKey } from '@src/generic/key-utils'; import messages from './messages'; import classNames from 'classnames'; @@ -35,6 +36,7 @@ const HistoryCompareChangesModal = ({ } const title = intl.formatMessage(messages.previewChangesTitle, { title: oldTitle }); + const blockType = getBlockType(usageKey, 'empty'); return ( - {title} + + + + + {oldTitle} + + ), + }} + /> + + Date: Mon, 27 Apr 2026 15:03:02 +0530 Subject: [PATCH 09/13] refactor(compare-components): update background style --- .../component-comparison/CompareChangesWidget.tsx | 4 ++-- .../generic/history-log/HistoryCompareChangesModal.tsx | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/library-authoring/component-comparison/CompareChangesWidget.tsx b/src/library-authoring/component-comparison/CompareChangesWidget.tsx index af2c1721c9..9c8ba30bb0 100644 --- a/src/library-authoring/component-comparison/CompareChangesWidget.tsx +++ b/src/library-authoring/component-comparison/CompareChangesWidget.tsx @@ -103,9 +103,9 @@ const CompareChangesWidget = ({ ); return ( -
+
{sideBySide ? ( - + {oldBlock} {newBlock} diff --git a/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx index 8995133f32..89ba99be68 100644 --- a/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx +++ b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx @@ -64,7 +64,7 @@ const HistoryCompareChangesModal = ({ - + Date: Mon, 27 Apr 2026 15:11:55 +0530 Subject: [PATCH 10/13] feat: add footer and fix styles --- .../CompareChangesWidget.tsx | 12 +++++------ .../HistoryCompareChangesModal.test.tsx | 21 +++++++++++++++++++ .../HistoryCompareChangesModal.tsx | 9 ++++++-- .../generic/history-log/messages.ts | 5 +++++ 4 files changed, 39 insertions(+), 8 deletions(-) diff --git a/src/library-authoring/component-comparison/CompareChangesWidget.tsx b/src/library-authoring/component-comparison/CompareChangesWidget.tsx index 9c8ba30bb0..872ab33808 100644 --- a/src/library-authoring/component-comparison/CompareChangesWidget.tsx +++ b/src/library-authoring/component-comparison/CompareChangesWidget.tsx @@ -51,7 +51,7 @@ const CompareChangesWidget = ({ {sideBySide && (

@@ -68,9 +68,9 @@ const CompareChangesWidget = ({

@@ -82,7 +82,7 @@ const CompareChangesWidget = ({ {sideBySide && (

@@ -94,8 +94,8 @@ const CompareChangesWidget = ({ usageKey={usageKey} version={newVersion} showTitle={showNewTitle || showTitle} - minHeight="50vh" - addHeight={40} + minHeight="45vh" + addHeight={70} /> diff --git a/src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx index eb4d16fd43..e63afef23b 100644 --- a/src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx +++ b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.test.tsx @@ -1,3 +1,4 @@ +import { userEvent } from '@testing-library/user-event'; import { render, screen, initializeMocks } from '@src/testUtils'; import HistoryCompareChangesModal from './HistoryCompareChangesModal'; @@ -43,6 +44,26 @@ describe('', () => { 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', () => { diff --git a/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx index 89ba99be68..58270d4272 100644 --- a/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx +++ b/src/library-authoring/generic/history-log/HistoryCompareChangesModal.tsx @@ -1,4 +1,4 @@ -import { Icon, ModalDialog, Stack } from '@openedx/paragon'; +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'; @@ -49,7 +49,7 @@ const HistoryCompareChangesModal = ({ > - + + + + ); }; diff --git a/src/library-authoring/generic/history-log/messages.ts b/src/library-authoring/generic/history-log/messages.ts index 38a5841698..1df4616551 100644 --- a/src/library-authoring/generic/history-log/messages.ts +++ b/src/library-authoring/generic/history-log/messages.ts @@ -71,6 +71,11 @@ const messages = defineMessages({ 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; From 4fc5edd3f65c5c2702db5d86094751478dd56743 Mon Sep 17 00:00:00 2001 From: Navin Karkera Date: Mon, 27 Apr 2026 15:19:50 +0530 Subject: [PATCH 11/13] fix: lint issues --- src/generic/key-utils.test.ts | 60 ++++++++++--------- .../LibraryBlock/LibraryBlock.tsx | 8 +-- .../CompareChangesWidget.tsx | 24 ++++---- .../HistoryCompareChangesModal.tsx | 6 +- .../generic/history-log/HistoryLog.tsx | 1 + 5 files changed, 54 insertions(+), 45 deletions(-) diff --git a/src/generic/key-utils.test.ts b/src/generic/key-utils.test.ts index c5b2b0b55e..db61856458 100644 --- a/src/generic/key-utils.test.ts +++ b/src/generic/key-utils.test.ts @@ -169,14 +169,16 @@ describe('component utils', () => { }); describe('isContainerType', () => { - for (const containerType of [ - ContainerType.Vertical, - ContainerType.Sequential, - ContainerType.Chapter, - ContainerType.Unit, - ContainerType.Subsection, - ContainerType.Section, - ] as const) { + 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); }); @@ -190,30 +192,34 @@ describe('component utils', () => { }); 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', - ]) { + 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, - ]) { + 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/library-authoring/LibraryBlock/LibraryBlock.tsx b/src/library-authoring/LibraryBlock/LibraryBlock.tsx index eeccb719ed..9dc971a272 100644 --- a/src/library-authoring/LibraryBlock/LibraryBlock.tsx +++ b/src/library-authoring/LibraryBlock/LibraryBlock.tsx @@ -53,10 +53,6 @@ export const LibraryBlock = ({ const intl = useIntl(); const params = new URLSearchParams(); - if (version === 0) { - return null; - } - if (version) { params.set('version', version.toString()); } @@ -86,6 +82,10 @@ export const LibraryBlock = ({ useIframeContent(iframeRef, setIframeRef); + if (version === 0) { + return null; + } + return (