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:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -29,6 +29,7 @@ export interface BasePublishableEntityLink {
|
||||
created: string;
|
||||
updated: string;
|
||||
readyToSync: boolean;
|
||||
downstreamIsModified: boolean;
|
||||
}
|
||||
|
||||
export interface ComponentPublishableEntityLink extends BasePublishableEntityLink {
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user