feat: New modal to sync changes for standalone text components [FC-0097] (#2449)

Adds a new sync modal when a Text component has local changes.
This commit is contained in:
Chris Chávez
2025-09-30 09:45:13 -05:00
committed by GitHub
parent 1c7ad2f725
commit a975f3b716
13 changed files with 408 additions and 100 deletions

View File

@@ -327,4 +327,19 @@ describe('<CourseLibraries ReviewTab />', () => {
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
});
it('should show sync modal with local changes', async () => {
const itemIndex = 3;
const user = userEvent.setup();
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
expect(previewBtns.length).toEqual(7);
await user.click(previewBtns[itemIndex]);
expect(screen.getByText('This library content has local edits.')).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /course content/i })).toBeInTheDocument();
expect(screen.getByRole('tab', { name: /published library content/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /update to published library content/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /keep course content/i })).toBeInTheDocument();
});
});

View File

@@ -173,6 +173,8 @@ const ItemReviewList = ({
upstreamBlockId: outOfSyncItemsByKey[info.usageKey].upstreamKey,
upstreamBlockVersionSynced: outOfSyncItemsByKey[info.usageKey].versionSynced,
isContainer: info.blockType === 'vertical' || info.blockType === 'sequential' || info.blockType === 'chapter',
blockType: info.blockType,
isLocallyModified: outOfSyncItemsByKey[info.usageKey].downstreamIsModified,
});
}, [outOfSyncItemsByKey]);
@@ -213,7 +215,10 @@ const ItemReviewList = ({
const updateBlock = useCallback(async (info: ContentHit) => {
try {
await acceptChangesMutation.mutateAsync(info.usageKey);
await acceptChangesMutation.mutateAsync({
blockId: info.usageKey,
overrideCustomizations: info.blockType === 'html' && outOfSyncItemsByKey[info.usageKey].downstreamIsModified,
});
reloadLinks(info.usageKey);
showToast(intl.formatMessage(
messages.updateSingleBlockSuccess,
@@ -230,7 +235,9 @@ const ItemReviewList = ({
return;
}
try {
await ignoreChangesMutation.mutateAsync(blockData.downstreamBlockId);
await ignoreChangesMutation.mutateAsync({
blockId: blockData.downstreamBlockId,
});
reloadLinks(blockData.downstreamBlockId);
showToast(intl.formatMessage(
messages.ignoreSingleBlockSuccess,

View File

@@ -11,6 +11,7 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"downstreamIsModified": false,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
@@ -26,6 +27,7 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"downstreamIsModified": false,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
@@ -41,6 +43,7 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 16,
"versionDeclined": null,
"downstreamIsModified": true,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
@@ -56,6 +59,7 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"downstreamIsModified": false,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
@@ -71,6 +75,7 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"downstreamIsModified": false,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},
@@ -86,6 +91,7 @@
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
"versionSynced": 2,
"versionDeclined": null,
"downstreamIsModified": false,
"created": "2025-02-08T14:07:05.588484Z",
"updated": "2025-02-08T14:07:05.588484Z"
},

View File

@@ -29,6 +29,7 @@ export interface BasePublishableEntityLink {
created: string;
updated: string;
readyToSync: boolean;
downstreamIsModified: boolean;
}
export interface ComponentPublishableEntityLink extends BasePublishableEntityLink {

View File

@@ -1,4 +1,3 @@
// @ts-check
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
@@ -44,14 +43,6 @@ export async function getVerticalData(unitId: string): Promise<object> {
/**
* Creates a new course XBlock.
* @param {Object} options - The options for creating the XBlock.
* @param {string} options.type - The type of the XBlock.
* @param {string} [options.category] - The category of the XBlock. Defaults to the type if not provided.
* @param {string} options.parentLocator - The parent locator.
* @param {string} [options.displayName] - The display name.
* @param {string} [options.boilerplate] - The boilerplate.
* @param {string} [options.stagedContent] - The staged content.
* @param {string} [options.libraryContentKey] - component key from library if being imported.
*/
export async function createCourseXblock({
type,
@@ -63,13 +54,13 @@ export async function createCourseXblock({
libraryContentKey,
}: {
type: string,
category?: string,
category?: string, // The category of the XBlock. Defaults to the type if not provided.
parentLocator: string,
displayName?: string,
boilerplate?: string,
stagedContent?: string,
libraryContentKey?: string,
}): Promise<any> {
libraryContentKey?: string, // component key from library if being imported.
}) {
const body = {
type,
boilerplate,
@@ -92,8 +83,8 @@ export async function createCourseXblock({
*/
export async function handleCourseUnitVisibilityAndData(
unitId: string,
type: string,
isVisible: boolean,
type: string, // The action type (e.g., PUBLISH_TYPES.discardChanges).
isVisible: boolean, // The visibility status for students.
groupAccess: boolean,
isDiscussionEnabled: boolean,
): Promise<object> {
@@ -160,9 +151,6 @@ export async function getCourseOutlineInfo(courseId: string): Promise<CourseOutl
/**
* Move a unit item to new unit.
* @param {string} sourceLocator - The ID of the item to be moved.
* @param {string} targetParentLocator - The ID of the XBlock associated with the item.
* @returns {Promise<moveInfo>} - The move information.
*/
export async function patchUnitItem(sourceLocator: string, targetParentLocator: string): Promise<MoveInfoData> {
const { data } = await getAuthenticatedHttpClient()
@@ -176,18 +164,22 @@ export async function patchUnitItem(sourceLocator: string, targetParentLocator:
/**
* Accept the changes from upstream library block in course
* @param {string} blockId - The ID of the item to be updated from library.
*/
export async function acceptLibraryBlockChanges(blockId: string) {
export async function acceptLibraryBlockChanges({
blockId,
overrideCustomizations = false,
}: {
blockId: string,
overrideCustomizations?: boolean,
}) {
await getAuthenticatedHttpClient()
.post(libraryBlockChangesUrl(blockId));
.post(libraryBlockChangesUrl(blockId), { override_customizations: overrideCustomizations });
}
/**
* Ignore the changes from upstream library block in course
* @param {string} blockId - The ID of the item to be updated from library.
*/
export async function ignoreLibraryBlockChanges(blockId: string) {
export async function ignoreLibraryBlockChanges({ blockId } : { blockId: string }) {
await getAuthenticatedHttpClient()
.delete(libraryBlockChangesUrl(blockId));
}

View File

@@ -1,4 +1,10 @@
.lib-preview-xblock-changes-modal {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
.preview-title {
span {
margin: 0 10px;
}
}
}

View File

@@ -1,17 +1,18 @@
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter/types';
import {
act,
render as baseRender,
screen,
initializeMocks,
waitFor,
} from '../../testUtils';
} from '@src/testUtils';
import { ToastActionData } from '@src/generic/toast-context';
import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.';
import { messageTypes } from '../constants';
import { libraryBlockChangesUrl } from '../data/api';
import { ToastActionData } from '../../generic/toast-context';
const usageKey = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@1';
const defaultEventData: LibraryChangesMessageData = {
@@ -20,10 +21,12 @@ const defaultEventData: LibraryChangesMessageData = {
upstreamBlockId: 'lct:org:lib1:unit:1',
upstreamBlockVersionSynced: 1,
isContainer: false,
isLocallyModified: false,
blockType: 'html',
};
const mockSendMessageToIframe = jest.fn();
jest.mock('../../generic/hooks/context/hooks', () => ({
jest.mock('@src/generic/hooks/context/hooks', () => ({
useIframe: () => ({
iframeRef: { current: { contentWindow: {} as HTMLIFrameElement } },
setIframeRef: () => {},
@@ -60,7 +63,6 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Accept changes' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Ignore changes' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Cancel' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'New version' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Old version' })).toBeInTheDocument();
});
@@ -132,4 +134,59 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
});
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
});
it('should render modal of text with local changes', async () => {
render({ ...defaultEventData, isLocallyModified: true });
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
expect(screen.getByText('This library content has local edits.')).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Update to published library content' })).toBeInTheDocument();
expect(await screen.findByRole('button', { name: 'Keep course content' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Course content' })).toBeInTheDocument();
expect(await screen.findByRole('tab', { name: 'Published library content' })).toBeInTheDocument();
});
it('update changes works', async () => {
const user = userEvent.setup();
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
render({ ...defaultEventData, isLocallyModified: true });
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
const acceptBtn = await screen.findByRole('button', { name: 'Update to published library content' });
await user.click(acceptBtn);
const confirmBtn = await screen.findByRole('button', { name: 'Discard local edits and update' });
await user.click(confirmBtn);
await waitFor(() => {
expect(mockSendMessageToIframe).toHaveBeenCalledWith(
messageTypes.completeXBlockEditing,
{ locator: usageKey },
);
expect(axiosMock.history.post.length).toEqual(1);
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
});
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
});
it('keep changes work', async () => {
const user = userEvent.setup();
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(200, {});
render({ ...defaultEventData, isLocallyModified: true });
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
const ignoreBtn = await screen.findByRole('button', { name: 'Keep course content' });
await user.click(ignoreBtn);
const ignoreConfirmBtn = (await screen.findAllByRole('button', { name: 'Keep course content' }))[0];
await user.click(ignoreConfirmBtn);
await waitFor(() => {
expect(mockSendMessageToIframe).toHaveBeenCalledWith(
messageTypes.completeXBlockEditing,
{ locator: usageKey },
);
expect(axiosMock.history.delete.length).toEqual(1);
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
});
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
});
});

View File

@@ -1,29 +1,110 @@
import { useCallback, useContext, useState } from 'react';
import {
ActionRow, Button, ModalDialog, useToggle,
useCallback, useContext, useMemo, useState,
} from 'react';
import {
ActionRow, Button, Icon, ModalDialog, useToggle,
} from '@openedx/paragon';
import { Warning } from '@openedx/paragon/icons';
import { Info, Warning } from '@openedx/paragon/icons';
import { useIntl, FormattedMessage } from '@edx/frontend-platform/i18n';
import { CompareContainersWidget } from '@src/container-comparison/CompareContainersWidget';
import { useEventListener } from '@src/generic/hooks';
import { ToastContext } from '@src/generic/toast-context';
import Loading from '@src/generic/Loading';
import CompareChangesWidget from '@src/library-authoring/component-comparison/CompareChangesWidget';
import AlertMessage from '@src/generic/alert-message';
import { useIframe } from '@src/generic/hooks/context/hooks';
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
import { ToastContext } from '@src/generic/toast-context';
import LoadingButton from '@src/generic/loading-button';
import Loading from '@src/generic/Loading';
import messages from './messages';
import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../data/apiHooks';
import DeleteModal from '@src/generic/delete-modal/DeleteModal';
import { useIframe } from '@src/generic/hooks/context/hooks';
import { useEventListener } from '@src/generic/hooks';
import { getItemIcon } from '@src/generic/block-type-utils';
import { CompareContainersWidget } from '@src/container-comparison/CompareContainersWidget';
import { messageTypes } from '../constants';
import { useAcceptLibraryBlockChanges, useIgnoreLibraryBlockChanges } from '../data/apiHooks';
import messages from './messages';
type ConfirmationModalType = 'ignore' | 'update' | 'keep' | undefined;
const ConfirmationModal = ({
modalType,
onClose,
updateAndRefresh,
}: {
modalType: ConfirmationModalType,
onClose: () => void,
updateAndRefresh: (accept: boolean, overrideCustomizations: boolean) => void,
}) => {
const intl = useIntl();
const {
title,
description,
btnLabel,
btnVariant,
accept,
overrideCustomizations,
} = useMemo(() => {
let resultTitle: string | undefined;
let resultDescription: string | undefined;
let resutlBtnLabel: string | undefined;
let resultAccept: boolean = false;
let resultOverrideCustomizations: boolean = false;
let resultBtnVariant: 'danger' | 'primary' = 'danger';
switch (modalType) {
case 'ignore':
resultTitle = intl.formatMessage(messages.confirmationTitle);
resultDescription = intl.formatMessage(messages.confirmationDescription);
resutlBtnLabel = intl.formatMessage(messages.confirmationConfirmBtn);
break;
case 'update':
resultTitle = intl.formatMessage(messages.updateToPublishedLibraryContentTitle);
resultDescription = intl.formatMessage(messages.updateToPublishedLibraryContentBody);
resutlBtnLabel = intl.formatMessage(messages.updateToPublishedLibraryContentConfirm);
resultAccept = true;
resultOverrideCustomizations = true;
break;
case 'keep':
resultTitle = intl.formatMessage(messages.keepCourseContentTitle);
resultDescription = intl.formatMessage(messages.keepCourseContentBody);
resutlBtnLabel = intl.formatMessage(messages.keepCourseContentButton);
resultBtnVariant = 'primary';
break;
default:
break;
}
return {
title: resultTitle,
description: resultDescription,
btnLabel: resutlBtnLabel,
accept: resultAccept,
btnVariant: resultBtnVariant,
overrideCustomizations: resultOverrideCustomizations,
};
}, [modalType]);
return (
<DeleteModal
isOpen={modalType !== undefined}
close={onClose}
variant="warning"
title={title}
description={description}
onDeleteSubmit={() => updateAndRefresh(accept, overrideCustomizations)}
btnLabel={btnLabel}
buttonVariant={btnVariant}
/>
);
};
export interface LibraryChangesMessageData {
displayName: string,
downstreamBlockId: string,
upstreamBlockId: string,
upstreamBlockVersionSynced: number,
isLocallyModified?: boolean,
isContainer: boolean,
blockType?: string | null,
}
export interface PreviewLibraryXBlockChangesProps {
@@ -46,12 +127,13 @@ export const PreviewLibraryXBlockChanges = ({
const { showToast } = useContext(ToastContext);
const intl = useIntl();
// ignore changes confirmation modal toggle.
const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false);
const [confirmationModalType, setConfirmationModalType] = useState<ConfirmationModalType>();
const acceptChangesMutation = useAcceptLibraryBlockChanges();
const ignoreChangesMutation = useIgnoreLibraryBlockChanges();
const isTextWithLocalChanges = (blockData.blockType === 'html' && blockData.isLocallyModified);
const getBody = useCallback(() => {
if (!blockData) {
return <Loading />;
@@ -69,13 +151,17 @@ export const PreviewLibraryXBlockChanges = ({
return (
<CompareChangesWidget
usageKey={blockData.upstreamBlockId}
oldUsageKey={isTextWithLocalChanges ? blockData.downstreamBlockId : undefined}
oldTitle={isTextWithLocalChanges ? blockData.displayName : undefined}
oldVersion={blockData.upstreamBlockVersionSynced || 'published'}
newVersion="published"
hasLocalChanges={isTextWithLocalChanges}
showNewTitle={isTextWithLocalChanges}
/>
);
}, [blockData]);
}, [blockData, isTextWithLocalChanges]);
const updateAndRefresh = useCallback(async (accept: boolean) => {
const updateAndRefresh = useCallback(async (accept: boolean, overrideCustomizations: boolean) => {
// istanbul ignore if: this should never happen
if (!blockData) {
return;
@@ -85,7 +171,10 @@ export const PreviewLibraryXBlockChanges = ({
const failureMsg = accept ? messages.acceptChangesFailure : messages.ignoreChangesFailure;
try {
await mutation.mutateAsync(blockData.downstreamBlockId);
await mutation.mutateAsync({
blockId: blockData.downstreamBlockId,
overrideCustomizations,
});
postChange(accept);
} catch (e) {
showToast(intl.formatMessage(failureMsg));
@@ -94,21 +183,46 @@ export const PreviewLibraryXBlockChanges = ({
}
}, [blockData]);
const itemIcon = getItemIcon(blockData.blockType || '');
// Build title
const defaultTitle = intl.formatMessage(
blockData.isContainer
? messages.defaultContainerTitle
: messages.defaultComponentTitle,
{
itemIcon: <Icon size="lg" src={itemIcon} />,
},
);
const title = blockData.displayName
? intl.formatMessage(messages.title, { blockTitle: blockData?.displayName })
? intl.formatMessage(messages.title, {
blockTitle: blockData?.displayName,
blockIcon: <Icon size="lg" src={itemIcon} />,
})
: defaultTitle;
// Build aria label
const defaultAriaLabel = intl.formatMessage(
blockData.isContainer
? messages.defaultContainerTitle
: messages.defaultComponentTitle,
{
itemIcon: '',
},
);
const ariaLabel = blockData.displayName
? intl.formatMessage(messages.title, {
blockTitle: blockData?.displayName,
blockIcon: '',
})
: defaultAriaLabel;
return (
<ModalDialog
isOpen={isModalOpen}
onClose={closeModal}
size="xl"
title={title}
title={ariaLabel}
className="lib-preview-xblock-changes-modal"
hasCloseButton
isFullscreenOnMobile
@@ -116,45 +230,64 @@ export const PreviewLibraryXBlockChanges = ({
>
<ModalDialog.Header>
<ModalDialog.Title>
{title}
<div className="d-flex preview-title">
{title}
</div>
</ModalDialog.Title>
</ModalDialog.Header>
<ModalDialog.Body className="bg-light-300">
{!blockData.isContainer && (
<AlertMessage
show
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.olderVersionPreviewAlert)}
/>
)}
<ModalDialog.Body>
{isTextWithLocalChanges ? (
<AlertMessage
show
variant="info"
icon={Info}
title={intl.formatMessage(messages.localEditsAlert)}
/>
) : (!blockData.isContainer && (
<AlertMessage
show
variant="warning"
icon={Warning}
title={intl.formatMessage(messages.olderVersionPreviewAlert)}
/>
))}
{getBody()}
</ModalDialog.Body>
<ModalDialog.Footer>
<ActionRow>
<LoadingButton
onClick={() => updateAndRefresh(true)}
label={intl.formatMessage(messages.acceptChangesBtn)}
/>
<Button
variant="tertiary"
onClick={openConfirmModal}
>
<FormattedMessage {...messages.ignoreChangesBtn} />
</Button>
<ModalDialog.CloseButton variant="tertiary">
<FormattedMessage {...messages.cancelBtn} />
</ModalDialog.CloseButton>
{isTextWithLocalChanges ? (
<Button
variant="tertiary"
onClick={() => setConfirmationModalType('update')}
>
<FormattedMessage {...messages.updateToPublishedLibraryContentButton} />
</Button>
) : (
<LoadingButton
onClick={() => updateAndRefresh(true, false)}
label={intl.formatMessage(messages.acceptChangesBtn)}
/>
)}
{isTextWithLocalChanges ? (
<Button
onClick={() => setConfirmationModalType('keep')}
>
<FormattedMessage {...messages.keepCourseContentButton} />
</Button>
) : (
<Button
variant="tertiary"
onClick={() => setConfirmationModalType('ignore')}
>
<FormattedMessage {...messages.ignoreChangesBtn} />
</Button>
)}
</ActionRow>
</ModalDialog.Footer>
<DeleteModal
isOpen={isConfirmModalOpen}
close={closeConfirmModal}
variant="warning"
title={intl.formatMessage(messages.confirmationTitle)}
description={intl.formatMessage(messages.confirmationDescription)}
onDeleteSubmit={() => updateAndRefresh(false)}
btnLabel={intl.formatMessage(messages.confirmationConfirmBtn)}
<ConfirmationModal
modalType={confirmationModalType}
onClose={() => setConfirmationModalType(undefined)}
updateAndRefresh={updateAndRefresh}
/>
</ModalDialog>
);

View File

@@ -3,17 +3,17 @@ import { defineMessages } from '@edx/frontend-platform/i18n';
const messages = defineMessages({
title: {
id: 'authoring.course-unit.preview-changes.modal-title',
defaultMessage: 'Preview changes: {blockTitle}',
defaultMessage: 'Preview changes: {blockIcon} {blockTitle}',
description: 'Preview changes modal title text',
},
defaultContainerTitle: {
id: 'authoring.course-unit.preview-changes.modal-default-unit-title',
defaultMessage: 'Preview changes: Container',
defaultMessage: 'Preview changes: {itemIcon} Container',
description: 'Preview changes modal default title text for containers',
},
defaultComponentTitle: {
id: 'authoring.course-unit.preview-changes.modal-default-component-title',
defaultMessage: 'Preview changes: Component',
defaultMessage: 'Preview changes: {itemIcon} Component',
description: 'Preview changes modal default title text for components',
},
acceptChangesBtn: {
@@ -36,11 +36,6 @@ const messages = defineMessages({
defaultMessage: 'Failed to ignore changes',
description: 'Toast message to display when ignore changes call fails',
},
cancelBtn: {
id: 'authoring.course-unit.preview-changes.cancel-btn',
defaultMessage: 'Cancel',
description: 'Preview changes modal cancel button text.',
},
confirmationTitle: {
id: 'authoring.course-unit.preview-changes.confirmation-dialog-title',
defaultMessage: 'Ignore these changes?',
@@ -61,6 +56,46 @@ const messages = defineMessages({
defaultMessage: 'The old version preview is the previous library version',
description: 'Alert message stating that older version in preview is of library block',
},
localEditsAlert: {
id: 'course-authoring.review-tab.preview.loal-edits-alert',
defaultMessage: 'This library content has local edits.',
description: 'Alert message stating that the content has local edits',
},
updateToPublishedLibraryContentButton: {
id: 'course-authoring.review-tab.preview.update-to-published.button.text',
defaultMessage: 'Update to published library content',
description: 'Label of the button to update a content to the published library content',
},
updateToPublishedLibraryContentTitle: {
id: 'course-authoring.review-tab.preview.update-to-published.modal.title',
defaultMessage: 'Update to published library content?',
description: 'Title of the modal to update a content to the published library content',
},
updateToPublishedLibraryContentBody: {
id: 'course-authoring.review-tab.preview.update-to-published.modal.body',
defaultMessage: 'Updating this block will discard local changes. Any eidts made within this course will be discarted, and cannot be recovered',
description: 'Body of the modal to update a content to the published library content',
},
updateToPublishedLibraryContentConfirm: {
id: 'course-authoring.review-tab.preview.update-to-published.modal.confirm',
defaultMessage: 'Discard local edits and update',
description: 'Label of the button in the modal to update a content to the published library content',
},
keepCourseContentButton: {
id: 'course-authoring.review-tab.preview.keep-course-content.button.text',
defaultMessage: 'Keep course content',
description: 'Label of the button to keep the content of a course component',
},
keepCourseContentTitle: {
id: 'course-authoring.review-tab.preview.keep-course-content.modal.title',
defaultMessage: 'Keep course content?',
description: 'Title of the modal to keep the content of a course component',
},
keepCourseContentBody: {
id: 'course-authoring.review-tab.preview.keep-course-content.modal.body',
defaultMessage: 'This will keep the locally edited course content. if the component is published again in its library, you can choose to update to published library content',
description: 'Body of the modal to keep the content of a course component',
},
});
export default messages;

View File

@@ -71,6 +71,11 @@ export function isLibraryV1Key(learningContextKey: string | undefined | null): l
return typeof learningContextKey === 'string' && learningContextKey.startsWith('library-v1:');
}
/** Check if this is a V1 block key. */
export function isBlockV1Key(usageKey: string | undefined | null): usageKey is string {
return typeof usageKey === 'string' && usageKey.startsWith('block-v1:');
}
/**
* Build a collection usage key from library V2 context key and collection Id.
* This Collection Usage Key is only used on tagging.

View File

@@ -2,11 +2,13 @@ import { useEffect } from 'react';
import { useIntl } from '@edx/frontend-platform/i18n';
import { getConfig } from '@edx/frontend-platform';
import { IFRAME_FEATURE_POLICY } from '@src/constants';
import { useIframeBehavior } from '@src/generic/hooks/useIframeBehavior';
import { useIframe } from '@src/generic/hooks/context/hooks';
import { useIframeContent } from '@src/generic/hooks/useIframeContent';
import { isBlockV1Key } from '@src/generic/key-utils';
import messages from './messages';
import { IFRAME_FEATURE_POLICY } from '../../constants';
import { useIframeBehavior } from '../../generic/hooks/useIframeBehavior';
import { useIframe } from '../../generic/hooks/context/hooks';
import { useIframeContent } from '../../generic/hooks/useIframeContent';
export type VersionSpec = 'published' | 'draft' | number;
@@ -18,6 +20,7 @@ interface LibraryBlockProps {
scrolling?: string;
minHeight?: string;
scrollIntoView?: boolean;
showTitle?: boolean,
}
/**
* React component that displays an XBlock in a sandboxed IFrame.
@@ -36,15 +39,30 @@ export const LibraryBlock = ({
minHeight,
scrolling = 'no',
scrollIntoView = false,
showTitle = false,
}: LibraryBlockProps) => {
const { iframeRef, setIframeRef } = useIframe();
const xblockView = view ?? 'student_view';
const studioBaseUrl = getConfig().STUDIO_BASE_URL;
const lmsBaseUrl = getConfig().LMS_BASE_URL;
const isBlockV1 = isBlockV1Key(usageKey);
const intl = useIntl();
const queryStr = version ? `?version=${version}` : '';
const iframeUrl = `${studioBaseUrl}/xblocks/v2/${usageKey}/embed/${xblockView}/${queryStr}`;
const params = new URLSearchParams();
if (version) {
params.set('version', version.toString());
}
if (showTitle) {
params.set('show_title', 'true');
}
// For now, always show the draft version of the Xblock v1
// It would be better to use a Studio URL for this, but there is no embeddable URL
// to render a single component in the Studio API as far as we can tell.
const iframeUrl = isBlockV1
? `${lmsBaseUrl}/xblock/${usageKey.replace('+type@', '+branch@draft-branch+type@')}?disable_staff_debug_info=True`
: `${studioBaseUrl}/xblocks/v2/${usageKey}/embed/${xblockView}/${params.toString() ? `?${params.toString()}` : ''}`;
const { iframeHeight } = useIframeBehavior({
id: usageKey,
iframeUrl,

View File

@@ -10,6 +10,10 @@ interface Props {
usageKey: string;
oldVersion?: VersionSpec;
newVersion?: VersionSpec;
oldTitle?: string;
showNewTitle?: boolean;
hasLocalChanges?: boolean;
oldUsageKey?: string;
}
/**
@@ -24,29 +28,48 @@ const CompareChangesWidget = ({
usageKey,
oldVersion = 'published',
newVersion = 'draft',
oldTitle,
showNewTitle = false,
oldUsageKey,
hasLocalChanges = false,
}: Props) => {
const intl = useIntl();
const oldTabMessage = hasLocalChanges
? intl.formatMessage(messages.courseContentTitle)
: intl.formatMessage(messages.oldVersionTitle);
const newTabMessage = hasLocalChanges
? intl.formatMessage(messages.publishedLibraryContentTitle)
: intl.formatMessage(messages.newVersionTitle);
return (
<div className="bg-white p-2">
<Tabs variant="tabs" defaultActiveKey="new" id="preview-version-toggle" mountOnEnter>
<Tab eventKey="old" title={intl.formatMessage(messages.oldVersionTitle)}>
<Tab eventKey="old" title={oldTabMessage}>
<div className="p-2 bg-white">
<IframeProvider>
<LibraryBlock
usageKey={usageKey}
version={oldVersion}
minHeight="50vh"
/>
</IframeProvider>
{oldTitle && hasLocalChanges && (
<div className="h3 mt-3.5">
{oldTitle}
</div>
)}
<div style={hasLocalChanges ? { marginLeft: '-35px', marginTop: '-15px' } : {}}>
<IframeProvider>
<LibraryBlock
usageKey={oldUsageKey || usageKey}
version={oldVersion}
minHeight="50vh"
/>
</IframeProvider>
</div>
</div>
</Tab>
<Tab eventKey="new" title={intl.formatMessage(messages.newVersionTitle)}>
<Tab eventKey="new" title={newTabMessage}>
<div className="p-2 bg-white">
<IframeProvider>
<LibraryBlock
usageKey={usageKey}
version={newVersion}
showTitle={showNewTitle}
minHeight="50vh"
/>
</IframeProvider>

View File

@@ -17,6 +17,16 @@ const messages = defineMessages({
defaultMessage: 'Compare Changes',
description: 'Title used for the compare changes dialog',
},
courseContentTitle: {
id: 'course-authoring.library-authoring.component-comparison.courseContent',
defaultMessage: 'Course content',
description: 'Title shown for course content when comparing changes',
},
publishedLibraryContentTitle: {
id: 'course-authoring.library-authoring.component-comparison.publishedLibraryContent',
defaultMessage: 'Published library content',
description: 'Title shown for published library content when comparing changes',
},
});
export default messages;