feat: View for comparing published version of library block to previous (#1393)
This commit is contained in:
@@ -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 = () => {
|
||||
<Route path="/library/create" element={<CreateLibrary />} />
|
||||
<Route path="/library/:libraryId/*" element={<LibraryLayout />} />
|
||||
<Route path="/component-picker" element={<ComponentPicker />} />
|
||||
<Route path="/legacy/preview-changes/:usageKey" element={<PreviewChangesEmbed />} />
|
||||
<Route path="/course/:courseId/*" element={<CourseAuthoringRoutes />} />
|
||||
<Route path="/course_rerun/:courseId" element={<CourseRerun />} />
|
||||
{getConfig().ENABLE_ACCESSIBILITY_PAGE === 'true' && (
|
||||
|
||||
@@ -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<HTMLIFrameElement>(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 (
|
||||
<div style={{
|
||||
height: `${iFrameHeight}px`,
|
||||
@@ -74,7 +79,7 @@ const LibraryBlock = ({ onBlockNotification, usageKey }: LibraryBlockProps) => {
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
title={intl.formatMessage(messages.iframeTitle)}
|
||||
src={`${studioBaseUrl}/xblocks/v2/${usageKey}/embed/student_view/`}
|
||||
src={`${studioBaseUrl}/xblocks/v2/${usageKey}/embed/student_view/${queryStr}`}
|
||||
data-testid="block-preview"
|
||||
style={{
|
||||
width: '100%',
|
||||
@@ -89,5 +94,3 @@ const LibraryBlock = ({ onBlockNotification, usageKey }: LibraryBlockProps) => {
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LibraryBlock;
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
/* eslint-disable-next-line import/prefer-default-export */
|
||||
export { default as LibraryBlock } from './LibraryBlock';
|
||||
export { LibraryBlock, type VersionSpec } from './LibraryBlock';
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
import {
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
initializeMocks,
|
||||
within,
|
||||
} from '../../testUtils';
|
||||
|
||||
import CompareChangesWidget from './CompareChangesWidget';
|
||||
|
||||
const usageKey = 'lb:org:lib:type:id';
|
||||
|
||||
describe('<CompareChangesWidget />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('can compare published (old) and draft (new) versions by default', async () => {
|
||||
render(<CompareChangesWidget usageKey={usageKey} />);
|
||||
|
||||
// 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(<CompareChangesWidget usageKey={usageKey} oldVersion={7} newVersion="published" />);
|
||||
|
||||
// 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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div>
|
||||
<Tabs variant="tabs" defaultActiveKey="new" id="preview-version-toggle">
|
||||
<Tab eventKey="old" title={intl.formatMessage(messages.oldVersionTitle)}>
|
||||
<div className="p-2 bg-white">
|
||||
<LibraryBlock usageKey={usageKey} version={oldVersion} />
|
||||
</div>
|
||||
</Tab>
|
||||
<Tab eventKey="new" title={intl.formatMessage(messages.newVersionTitle)}>
|
||||
<div className="p-2 bg-white">
|
||||
<LibraryBlock usageKey={usageKey} version={newVersion} />
|
||||
</div>
|
||||
</Tab>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompareChangesWidget;
|
||||
22
src/library-authoring/component-comparison/messages.ts
Normal file
22
src/library-authoring/component-comparison/messages.ts
Normal file
@@ -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;
|
||||
@@ -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';
|
||||
|
||||
@@ -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('<CompareChangesWidget />', () => {
|
||||
beforeEach(() => {
|
||||
initializeMocks();
|
||||
});
|
||||
|
||||
it('can compare published (old) and draft (new) versions by default', async () => {
|
||||
render(<PreviewChangesEmbed />, {
|
||||
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`,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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 <CompareChangesWidget> 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 (
|
||||
<LibraryProvider libraryId={libraryId}>
|
||||
{/* It's not necessary since this will usually be in an <iframe>,
|
||||
but it's good practice to set a title for any top level page */}
|
||||
<Helmet><title>{intl.formatMessage(messages.iframeTitlePrefix)} | {metadata?.displayName ?? ''} | {process.env.SITE_NAME}</title></Helmet>
|
||||
<CompareChangesWidget usageKey={usageKey} oldVersion={oldVersion} newVersion="published" />
|
||||
</LibraryProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default PreviewChangesEmbed;
|
||||
@@ -30,10 +30,6 @@ describe('<LibraryInfoHeader />', () => {
|
||||
mockShowToast = mocks.mockShowToast;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render Library info Header', async () => {
|
||||
render();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user