diff --git a/src/index.jsx b/src/index.jsx
index 29d0a9ac5..933e67f40 100755
--- a/src/index.jsx
+++ b/src/index.jsx
@@ -16,7 +16,12 @@ import { initializeHotjar } from '@edx/frontend-enterprise-hotjar';
import { logError } from '@edx/frontend-platform/logging';
import messages from './i18n';
-import { ComponentPicker, CreateLibrary, LibraryLayout } from './library-authoring';
+import {
+ ComponentPicker,
+ CreateLibrary,
+ LibraryLayout,
+ PreviewChangesEmbed,
+} from './library-authoring';
import initializeStore from './store';
import CourseAuthoringRoutes from './CourseAuthoringRoutes';
import Head from './head/Head';
@@ -56,6 +61,7 @@ const App = () => {
} />
} />
} />
+ } />
} />
} />
{getConfig().ENABLE_ACCESSIBILITY_PAGE === 'true' && (
diff --git a/src/library-authoring/LibraryBlock/LibraryBlock.tsx b/src/library-authoring/LibraryBlock/LibraryBlock.tsx
index dd4920612..b76e2b1ae 100644
--- a/src/library-authoring/LibraryBlock/LibraryBlock.tsx
+++ b/src/library-authoring/LibraryBlock/LibraryBlock.tsx
@@ -4,9 +4,12 @@ import { getConfig } from '@edx/frontend-platform';
import messages from './messages';
+export type VersionSpec = 'published' | 'draft' | number;
+
interface LibraryBlockProps {
onBlockNotification?: (event: { eventType: string; [key: string]: any }) => void;
usageKey: string;
+ version?: VersionSpec;
}
/**
* React component that displays an XBlock in a sandboxed IFrame.
@@ -17,7 +20,7 @@ interface LibraryBlockProps {
* cannot access things like the user's cookies, nor can it make GET/POST
* requests as the user. However, it is allowed to call any XBlock handlers.
*/
-const LibraryBlock = ({ onBlockNotification, usageKey }: LibraryBlockProps) => {
+export const LibraryBlock = ({ onBlockNotification, usageKey, version }: LibraryBlockProps) => {
const iframeRef = useRef(null);
const [iFrameHeight, setIFrameHeight] = useState(600);
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
@@ -62,6 +65,8 @@ const LibraryBlock = ({ onBlockNotification, usageKey }: LibraryBlockProps) => {
};
}, []);
+ const queryStr = version ? `?version=${version}` : '';
+
return (
{
);
};
-
-export default LibraryBlock;
diff --git a/src/library-authoring/LibraryBlock/index.ts b/src/library-authoring/LibraryBlock/index.ts
index a6bb1665d..74638b8bc 100644
--- a/src/library-authoring/LibraryBlock/index.ts
+++ b/src/library-authoring/LibraryBlock/index.ts
@@ -1,2 +1 @@
-/* eslint-disable-next-line import/prefer-default-export */
-export { default as LibraryBlock } from './LibraryBlock';
+export { LibraryBlock, type VersionSpec } from './LibraryBlock';
diff --git a/src/library-authoring/component-comparison/CompareChangesWidget.test.tsx b/src/library-authoring/component-comparison/CompareChangesWidget.test.tsx
new file mode 100644
index 000000000..ef508eacd
--- /dev/null
+++ b/src/library-authoring/component-comparison/CompareChangesWidget.test.tsx
@@ -0,0 +1,77 @@
+import {
+ fireEvent,
+ render,
+ screen,
+ initializeMocks,
+ within,
+} from '../../testUtils';
+
+import CompareChangesWidget from './CompareChangesWidget';
+
+const usageKey = 'lb:org:lib:type:id';
+
+describe('', () => {
+ beforeEach(() => {
+ initializeMocks();
+ });
+
+ it('can compare published (old) and draft (new) versions by default', async () => {
+ render();
+
+ // By default we see the new version:
+ const newTab = screen.getByRole('tab', { name: 'New version' });
+ expect(newTab).toBeInTheDocument();
+ expect(newTab).toHaveClass('active');
+
+ const newTabPanel = screen.getByRole('tabpanel', { name: 'New version' });
+ const newIframe = within(newTabPanel).getByTitle('Preview');
+ expect(newIframe).toBeVisible();
+ expect(newIframe).toHaveAttribute(
+ 'src',
+ `http://localhost:18010/xblocks/v2/${usageKey}/embed/student_view/?version=draft`,
+ );
+
+ // Now switch to the "old version" tab:
+ const oldTab = screen.getByRole('tab', { name: 'Old version' });
+ fireEvent.click(oldTab);
+
+ const oldTabPanel = screen.getByRole('tabpanel', { name: 'Old version' });
+ expect(oldTabPanel).toBeVisible();
+ const oldIframe = within(oldTabPanel).getByTitle('Preview');
+ expect(oldIframe).toBeVisible();
+ expect(oldIframe).toHaveAttribute(
+ 'src',
+ `http://localhost:18010/xblocks/v2/${usageKey}/embed/student_view/?version=published`,
+ );
+ });
+
+ it('can compare a specific old and published (new) version', async () => {
+ render();
+
+ // By default we see the new version:
+ const newTab = screen.getByRole('tab', { name: 'New version' });
+ expect(newTab).toBeInTheDocument();
+ expect(newTab).toHaveClass('active');
+
+ const newTabPanel = screen.getByRole('tabpanel', { name: 'New version' });
+ const newIframe = within(newTabPanel).getByTitle('Preview');
+ expect(newIframe).toBeVisible();
+ expect(newIframe).toHaveAttribute(
+ 'src',
+ `http://localhost:18010/xblocks/v2/${usageKey}/embed/student_view/?version=published`,
+ );
+
+ // Now switch to the "old version" tab:
+ const oldTab = screen.getByRole('tab', { name: 'Old version' });
+ fireEvent.click(oldTab);
+
+ const oldTabPanel = screen.getByRole('tabpanel', { name: 'Old version' });
+ expect(oldTabPanel).toBeVisible();
+ const oldIframe = within(oldTabPanel).getByTitle('Preview');
+ expect(oldIframe).toBeVisible();
+ expect(oldIframe).toHaveAttribute(
+ 'src',
+ `http://localhost:18010/xblocks/v2/${usageKey}/embed/student_view/?version=7`,
+ );
+ });
+});
diff --git a/src/library-authoring/component-comparison/CompareChangesWidget.tsx b/src/library-authoring/component-comparison/CompareChangesWidget.tsx
new file mode 100644
index 000000000..4a6e25303
--- /dev/null
+++ b/src/library-authoring/component-comparison/CompareChangesWidget.tsx
@@ -0,0 +1,43 @@
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { Tab, Tabs } from '@openedx/paragon';
+
+import { LibraryBlock, type VersionSpec } from '../LibraryBlock';
+
+import messages from './messages';
+
+interface Props {
+ usageKey: string;
+ oldVersion?: VersionSpec;
+ newVersion?: VersionSpec;
+}
+
+/**
+ * A widget with tabs that can be used to show the old version and the new
+ * version of a component (XBlock), so users can switch back and forth to spot
+ * the differences.
+ *
+ * In the future, it would be better to have a way of highlighting the changes
+ * or showing a diff.
+ */
+const CompareChangesWidget = ({ usageKey, oldVersion = 'published', newVersion = 'draft' }: Props) => {
+ const intl = useIntl();
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export default CompareChangesWidget;
diff --git a/src/library-authoring/component-comparison/messages.ts b/src/library-authoring/component-comparison/messages.ts
new file mode 100644
index 000000000..89275918a
--- /dev/null
+++ b/src/library-authoring/component-comparison/messages.ts
@@ -0,0 +1,22 @@
+import { defineMessages } from '@edx/frontend-platform/i18n';
+
+const messages = defineMessages({
+ oldVersionTitle: {
+ id: 'course-authoring.library-authoring.component-comparison.oldVersion',
+ defaultMessage: 'Old version',
+ description: 'Title shown for old version when comparing changes',
+ },
+ newVersionTitle: {
+ id: 'course-authoring.library-authoring.component-comparison.newVersion',
+ defaultMessage: 'New version',
+ description: 'Title shown for new version when comparing changes',
+ },
+ iframeTitlePrefix: {
+ // This is only used in the "PreviewChangesEmbed" iframe for the legacy UI
+ id: 'course-authoring.library-authoring.component-comparison.iframeTitlePrefix',
+ defaultMessage: 'Compare Changes',
+ description: 'Title used for the compare changes dialog',
+ },
+});
+
+export default messages;
diff --git a/src/library-authoring/index.tsx b/src/library-authoring/index.tsx
index 5318ffbe3..91100f090 100644
--- a/src/library-authoring/index.tsx
+++ b/src/library-authoring/index.tsx
@@ -2,3 +2,4 @@ export { default as LibraryLayout } from './LibraryLayout';
export { ComponentPicker } from './component-picker';
export { CreateLibrary } from './create-library';
export { libraryAuthoringQueryKeys, useContentLibraryV2List } from './data/apiHooks';
+export { default as PreviewChangesEmbed } from './legacy-integration/PreviewChangesEmbed';
diff --git a/src/library-authoring/legacy-integration/PreviewChangesEmbed.test.tsx b/src/library-authoring/legacy-integration/PreviewChangesEmbed.test.tsx
new file mode 100644
index 000000000..bd1e143f2
--- /dev/null
+++ b/src/library-authoring/legacy-integration/PreviewChangesEmbed.test.tsx
@@ -0,0 +1,53 @@
+import { mockContentLibrary, mockLibraryBlockMetadata } from '../data/api.mocks';
+import {
+ fireEvent,
+ render,
+ screen,
+ initializeMocks,
+ within,
+} from '../../testUtils';
+
+import PreviewChangesEmbed from './PreviewChangesEmbed';
+
+mockContentLibrary.applyMock();
+mockLibraryBlockMetadata.applyMock();
+const usageKey = mockLibraryBlockMetadata.usageKeyWithCollections;
+
+describe('', () => {
+ beforeEach(() => {
+ initializeMocks();
+ });
+
+ it('can compare published (old) and draft (new) versions by default', async () => {
+ render(, {
+ path: '/legacy/preview-changes/:usageKey',
+ routerProps: { initialEntries: [`/legacy/preview-changes/${usageKey}/?old=13`] },
+ });
+
+ // By default we see the new version (the published version):
+ const newTab = screen.getByRole('tab', { name: 'New version' });
+ expect(newTab).toBeInTheDocument();
+ expect(newTab).toHaveClass('active');
+
+ const newTabPanel = screen.getByRole('tabpanel', { name: 'New version' });
+ const newIframe = within(newTabPanel).getByTitle('Preview');
+ expect(newIframe).toBeVisible();
+ expect(newIframe).toHaveAttribute(
+ 'src',
+ `http://localhost:18010/xblocks/v2/${usageKey}/embed/student_view/?version=published`,
+ );
+
+ // Now switch to the "old version" tab:
+ const oldTab = screen.getByRole('tab', { name: 'Old version' });
+ fireEvent.click(oldTab);
+
+ const oldTabPanel = screen.getByRole('tabpanel', { name: 'Old version' });
+ expect(oldTabPanel).toBeVisible();
+ const oldIframe = within(oldTabPanel).getByTitle('Preview');
+ expect(oldIframe).toBeVisible();
+ expect(oldIframe).toHaveAttribute(
+ 'src',
+ `http://localhost:18010/xblocks/v2/${usageKey}/embed/student_view/?version=13`,
+ );
+ });
+});
diff --git a/src/library-authoring/legacy-integration/PreviewChangesEmbed.tsx b/src/library-authoring/legacy-integration/PreviewChangesEmbed.tsx
new file mode 100644
index 000000000..648ca83d6
--- /dev/null
+++ b/src/library-authoring/legacy-integration/PreviewChangesEmbed.tsx
@@ -0,0 +1,44 @@
+import { useIntl } from '@edx/frontend-platform/i18n';
+import { useParams, useSearchParams } from 'react-router-dom';
+import { Helmet } from 'react-helmet';
+
+import { LibraryProvider } from '../common/context';
+import { getLibraryId } from '../../generic/key-utils';
+import CompareChangesWidget from '../component-comparison/CompareChangesWidget';
+import { useLibraryBlockMetadata } from '../data/apiHooks';
+import messages from '../component-comparison/messages';
+
+/**
+ * This view is only used to support the legacy UI.
+ * On the legacy unit page, when a v2 library block has been used in a course
+ * AND an updated version of that block is available, this view is rendered in
+ * an iframe in a modal, and allows the author to preview the changes before
+ * accepting them (before syncing the course version with the latest library
+ * version).
+ *
+ * The inner will be used by this MFE as well, on the
+ * new MFE unit page.
+ */
+const PreviewChangesEmbed = () => {
+ const intl = useIntl();
+ const { usageKey } = useParams();
+ if (usageKey === undefined) {
+ // istanbul ignore next - This shouldn't be possible; it's just here to satisfy the type checker.
+ throw new Error('Error: route is missing usageKey.');
+ }
+ const [queryString] = useSearchParams();
+ const oldVersion = parseInt(queryString.get('old') ?? '', 10) || 'published';
+ const libraryId = getLibraryId(usageKey);
+ const { data: metadata } = useLibraryBlockMetadata(usageKey);
+
+ return (
+
+ {/* It's not necessary since this will usually be in an
+ );
+};
+
+export default PreviewChangesEmbed;
diff --git a/src/library-authoring/library-info/LibraryInfoHeader.test.tsx b/src/library-authoring/library-info/LibraryInfoHeader.test.tsx
index 00e743afd..855d68f44 100644
--- a/src/library-authoring/library-info/LibraryInfoHeader.test.tsx
+++ b/src/library-authoring/library-info/LibraryInfoHeader.test.tsx
@@ -30,10 +30,6 @@ describe('', () => {
mockShowToast = mocks.mockShowToast;
});
- afterEach(() => {
- jest.clearAllMocks();
- });
-
it('should render Library info Header', async () => {
render();