diff --git a/src/course-libraries/CourseLibraries.test.tsx b/src/course-libraries/CourseLibraries.test.tsx index 4753d0d52..a51458d83 100644 --- a/src/course-libraries/CourseLibraries.test.tsx +++ b/src/course-libraries/CourseLibraries.test.tsx @@ -327,4 +327,19 @@ describe('', () => { 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(); + }); }); diff --git a/src/course-libraries/ReviewTabContent.tsx b/src/course-libraries/ReviewTabContent.tsx index 63f02cef0..51c9ac9c1 100644 --- a/src/course-libraries/ReviewTabContent.tsx +++ b/src/course-libraries/ReviewTabContent.tsx @@ -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, diff --git a/src/course-libraries/__mocks__/publishableEntityLinks.json b/src/course-libraries/__mocks__/publishableEntityLinks.json index daf938525..6af348d67 100644 --- a/src/course-libraries/__mocks__/publishableEntityLinks.json +++ b/src/course-libraries/__mocks__/publishableEntityLinks.json @@ -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" }, diff --git a/src/course-libraries/data/api.ts b/src/course-libraries/data/api.ts index 048083874..03b01b2c8 100644 --- a/src/course-libraries/data/api.ts +++ b/src/course-libraries/data/api.ts @@ -29,6 +29,7 @@ export interface BasePublishableEntityLink { created: string; updated: string; readyToSync: boolean; + downstreamIsModified: boolean; } export interface ComponentPublishableEntityLink extends BasePublishableEntityLink { diff --git a/src/course-unit/data/api.ts b/src/course-unit/data/api.ts index e4abc9619..11efbe7e5 100644 --- a/src/course-unit/data/api.ts +++ b/src/course-unit/data/api.ts @@ -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 { /** * 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 { + 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 { @@ -160,9 +151,6 @@ export async function getCourseOutlineInfo(courseId: string): Promise} - The move information. */ export async function patchUnitItem(sourceLocator: string, targetParentLocator: string): Promise { 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)); } diff --git a/src/course-unit/preview-changes/index.scss b/src/course-unit/preview-changes/index.scss index b6749c921..cfed9d5e5 100644 --- a/src/course-unit/preview-changes/index.scss +++ b/src/course-unit/preview-changes/index.scss @@ -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; + } + } } diff --git a/src/course-unit/preview-changes/index.test.tsx b/src/course-unit/preview-changes/index.test.tsx index dd0f5f626..3cae24bd9 100644 --- a/src/course-unit/preview-changes/index.test.tsx +++ b/src/course-unit/preview-changes/index.test.tsx @@ -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('', () => { 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('', () => { }); 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(); + }); }); diff --git a/src/course-unit/preview-changes/index.tsx b/src/course-unit/preview-changes/index.tsx index 1ef8ab088..44e620cb4 100644 --- a/src/course-unit/preview-changes/index.tsx +++ b/src/course-unit/preview-changes/index.tsx @@ -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 ( + 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(); const acceptChangesMutation = useAcceptLibraryBlockChanges(); const ignoreChangesMutation = useIgnoreLibraryBlockChanges(); + const isTextWithLocalChanges = (blockData.blockType === 'html' && blockData.isLocallyModified); + const getBody = useCallback(() => { if (!blockData) { return ; @@ -69,13 +151,17 @@ export const PreviewLibraryXBlockChanges = ({ return ( ); - }, [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: , + }, ); const title = blockData.displayName - ? intl.formatMessage(messages.title, { blockTitle: blockData?.displayName }) + ? intl.formatMessage(messages.title, { + blockTitle: blockData?.displayName, + blockIcon: , + }) : 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 ( - {title} +
+ {title} +
- - {!blockData.isContainer && ( - - )} + + {isTextWithLocalChanges ? ( + + ) : (!blockData.isContainer && ( + + ))} {getBody()} - updateAndRefresh(true)} - label={intl.formatMessage(messages.acceptChangesBtn)} - /> - - - - + {isTextWithLocalChanges ? ( + + ) : ( + updateAndRefresh(true, false)} + label={intl.formatMessage(messages.acceptChangesBtn)} + /> + )} + {isTextWithLocalChanges ? ( + + ) : ( + + )} - updateAndRefresh(false)} - btnLabel={intl.formatMessage(messages.confirmationConfirmBtn)} + setConfirmationModalType(undefined)} + updateAndRefresh={updateAndRefresh} />
); diff --git a/src/course-unit/preview-changes/messages.ts b/src/course-unit/preview-changes/messages.ts index c91deeb8b..244b89d15 100644 --- a/src/course-unit/preview-changes/messages.ts +++ b/src/course-unit/preview-changes/messages.ts @@ -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; diff --git a/src/generic/key-utils.ts b/src/generic/key-utils.ts index 88dbb14bf..86f84752b 100644 --- a/src/generic/key-utils.ts +++ b/src/generic/key-utils.ts @@ -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. diff --git a/src/library-authoring/LibraryBlock/LibraryBlock.tsx b/src/library-authoring/LibraryBlock/LibraryBlock.tsx index 52e0794e0..6bb6d0058 100644 --- a/src/library-authoring/LibraryBlock/LibraryBlock.tsx +++ b/src/library-authoring/LibraryBlock/LibraryBlock.tsx @@ -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, diff --git a/src/library-authoring/component-comparison/CompareChangesWidget.tsx b/src/library-authoring/component-comparison/CompareChangesWidget.tsx index 976826a44..26b686efc 100644 --- a/src/library-authoring/component-comparison/CompareChangesWidget.tsx +++ b/src/library-authoring/component-comparison/CompareChangesWidget.tsx @@ -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 (
- +
- - - + {oldTitle && hasLocalChanges && ( +
+ {oldTitle} +
+ )} +
+ + + +
- +
diff --git a/src/library-authoring/component-comparison/messages.ts b/src/library-authoring/component-comparison/messages.ts index 89275918a..ec2f95ed9 100644 --- a/src/library-authoring/component-comparison/messages.ts +++ b/src/library-authoring/component-comparison/messages.ts @@ -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;