diff --git a/src/course-outline/__mocks__/courseOutlineIndex.js b/src/course-outline/__mocks__/courseOutlineIndex.js index 3d3ce8813f..1cd5bb99f4 100644 --- a/src/course-outline/__mocks__/courseOutlineIndex.js +++ b/src/course-outline/__mocks__/courseOutlineIndex.js @@ -292,6 +292,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, ], }, @@ -675,6 +680,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_2dbb0072785e', @@ -759,6 +769,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_98cf62510471', @@ -843,6 +858,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_d32bf9b2242c', @@ -927,6 +947,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@4e592689563243c484af947465eaef0d', @@ -1011,6 +1036,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, ], }, @@ -1196,6 +1226,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_aae927868e55', @@ -1280,6 +1315,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_c037f3757df1', @@ -1364,6 +1404,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_bc69a47c6fae', @@ -1448,6 +1493,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@8f89194410954e768bde1764985454a7', @@ -1532,6 +1582,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, ], }, @@ -1717,6 +1772,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, ], }, @@ -1995,6 +2055,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_f04afeac0131', @@ -2079,6 +2144,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@b6662b497c094bcc9b870d8270c90c93', @@ -2163,6 +2233,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@f91d8d31f7cf48ce990f8d8745ae4cfa', @@ -2247,6 +2322,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_ac391cde8a91', @@ -2331,6 +2411,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@vertical_36e0beb03f0a', @@ -2415,6 +2500,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@1b0e2c2c84884b95b1c99fb678cc964c', @@ -2499,6 +2589,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@c7e98fd39a6944edb6b286c32e1150ff', @@ -2583,6 +2678,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, { id: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@d6eaa391d2be41dea20b8b1bfbcb1c45', @@ -2667,6 +2767,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, ], }, @@ -2945,6 +3050,11 @@ module.exports = { selectedPartitionIndex: -1, selectedGroupsLabel: '', }, + upstreamInfo: { + readyToSync: false, + upstreamRef: undefined, + versionSynced: undefined, + }, }, ], }, diff --git a/src/course-outline/card-header/CardHeader.jsx b/src/course-outline/card-header/CardHeader.jsx index eda1ff6be4..d4a0af75d7 100644 --- a/src/course-outline/card-header/CardHeader.jsx +++ b/src/course-outline/card-header/CardHeader.jsx @@ -15,6 +15,7 @@ import { import { MoreVert as MoveVertIcon, EditOutline as EditIcon, + Sync as SyncIcon, } from '@openedx/paragon/icons'; import { useContentTagsCount } from '../../generic/data/apiHooks'; @@ -55,6 +56,8 @@ const CardHeader = ({ discussionsSettings, parentInfo, extraActionsComponent, + onClickSync, + readyToSync, }) => { const intl = useIntl(); const [searchParams] = useSearchParams(); @@ -130,8 +133,17 @@ const CardHeader = ({ ) : ( <> {titleComponent} + {readyToSync && ( + + )} ', () => { renderComponent(); expect(screen.queryByText('0')).not.toBeInTheDocument(); }); + + it('should render sync button when is ready to sync', () => { + const mockClickSync = jest.fn(); + + renderComponent({ + readyToSync: true, + onClickSync: mockClickSync, + }); + + const syncButton = screen.getByRole('button', { name: /update available - click to sync/i }); + expect(syncButton).toBeInTheDocument(); + fireEvent.click(syncButton); + + expect(mockClickSync).toHaveBeenCalled(); + }); }); diff --git a/src/course-outline/card-header/messages.js b/src/course-outline/card-header/messages.js index 0aeb34b20e..4874525d5b 100644 --- a/src/course-outline/card-header/messages.js +++ b/src/course-outline/card-header/messages.js @@ -77,6 +77,11 @@ const messages = defineMessages({ id: 'course-authoring.course-outline.card.menu.manageTags', defaultMessage: 'Manage tags', }, + readyToSyncButtonAlt: { + id: 'course-authoring.course-outline.card.button.sync.alt', + defaultMessage: 'Update available - click to sync', + description: 'Alt text for the sync icon button.', + }, }); export default messages; diff --git a/src/course-outline/unit-card/UnitCard.jsx b/src/course-outline/unit-card/UnitCard.jsx index bd0b58e559..82df0d28fe 100644 --- a/src/course-outline/unit-card/UnitCard.jsx +++ b/src/course-outline/unit-card/UnitCard.jsx @@ -1,5 +1,10 @@ // @ts-check -import React, { useEffect, useRef } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, +} from 'react'; import PropTypes from 'prop-types'; import { useDispatch } from 'react-redux'; import { useToggle } from '@openedx/paragon'; @@ -8,6 +13,7 @@ import { useSearchParams } from 'react-router-dom'; import CourseOutlineUnitCardExtraActionsSlot from '../../plugin-slots/CourseOutlineUnitCardExtraActionsSlot'; import { setCurrentItem, setCurrentSection, setCurrentSubsection } from '../data/slice'; +import { fetchCourseSectionQuery } from '../data/thunk'; import { RequestStatus } from '../../data/constants'; import { isUnitReadOnly } from '../../course-unit/data/utils'; import CardHeader from '../card-header/CardHeader'; @@ -16,6 +22,7 @@ import TitleLink from '../card-header/TitleLink'; import XBlockStatus from '../xblock-status/XBlockStatus'; import { getItemStatus, getItemStatusBorder, scrollToElement } from '../utils'; import { useClipboard } from '../../generic/clipboard'; +import { PreviewLibraryXBlockChanges } from '../../course-unit/preview-changes'; const UnitCard = ({ unit, @@ -41,6 +48,7 @@ const UnitCard = ({ const locatorId = searchParams.get('show'); const isScrolledToElement = locatorId === unit.id; const [isFormOpen, openForm, closeForm] = useToggle(false); + const [isSyncModalOpen, openSyncModal, closeSyncModal] = useToggle(false); const namePrefix = 'unit'; const { copyToClipboard } = useClipboard(); @@ -56,8 +64,22 @@ const UnitCard = ({ isHeaderVisible = true, enableCopyPasteUnits = false, discussionEnabled, + upstreamInfo, } = unit; + const blockSyncData = useMemo(() => { + if (!upstreamInfo.readyToSync) { + return undefined; + } + return { + displayName, + downstreamBlockId: id, + upstreamBlockId: upstreamInfo.upstreamRef, + upstreamBlockVersionSynced: upstreamInfo.versionSynced, + isVertical: true, + }; + }, [upstreamInfo]); + const readOnly = isUnitReadOnly(unit); // re-create actions object for customizations @@ -107,6 +129,10 @@ const UnitCard = ({ copyToClipboard(id); }; + const handleOnPostChangeSync = useCallback(async () => { + await dispatch(fetchCourseSectionQuery([section.id])); + }, [dispatch, section]); + const titleComponent = ( -
+ - -
- + +
+ +
-
- + + + ); }; @@ -225,6 +261,11 @@ UnitCard.propTypes = { isHeaderVisible: PropTypes.bool, enableCopyPasteUnits: PropTypes.bool, discussionEnabled: PropTypes.bool, + upstreamInfo: PropTypes.shape({ + readyToSync: PropTypes.bool.isRequired, + upstreamRef: PropTypes.string.isRequired, + versionSynced: PropTypes.number.isRequired, + }).isRequired, }).isRequired, subsection: PropTypes.shape({ id: PropTypes.string.isRequired, diff --git a/src/course-outline/unit-card/UnitCard.test.jsx b/src/course-outline/unit-card/UnitCard.test.jsx index 91e7207d96..663fa59c28 100644 --- a/src/course-outline/unit-card/UnitCard.test.jsx +++ b/src/course-outline/unit-card/UnitCard.test.jsx @@ -1,5 +1,6 @@ import { - act, render, fireEvent, within, + act, render, fireEvent, within, screen, + waitFor, } from '@testing-library/react'; import { IntlProvider } from '@edx/frontend-platform/i18n'; import { AppProvider } from '@edx/frontend-platform/react'; @@ -11,6 +12,17 @@ import UnitCard from './UnitCard'; import cardMessages from '../card-header/messages'; let store; +const mockUseAcceptLibraryBlockChanges = jest.fn(); +const mockUseIgnoreLibraryBlockChanges = jest.fn(); + +jest.mock('../../course-unit/data/apiHooks', () => ({ + useAcceptLibraryBlockChanges: () => ({ + mutateAsync: mockUseAcceptLibraryBlockChanges, + }), + useIgnoreLibraryBlockChanges: () => ({ + mutateAsync: mockUseIgnoreLibraryBlockChanges, + }), +})); const section = { id: '1', @@ -43,6 +55,11 @@ const unit = { duplicable: true, }, isHeaderVisible: true, + upstreamInfo: { + readyToSync: true, + upstreamRef: 'lct:org1:lib1:unit:1', + versionSynced: 1, + }, }; const queryClient = new QueryClient(); @@ -147,4 +164,51 @@ describe('', () => { }); expect(queryByRole('status')).not.toBeInTheDocument(); }); + + it('should sync unit changes from upstream', async () => { + renderComponent(); + + expect(await screen.findByTestId('unit-card-header')).toBeInTheDocument(); + + // Click on sync button + const syncButton = screen.getByRole('button', { name: /update available - click to sync/i }); + fireEvent.click(syncButton); + + // Should open compare preview modal + expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument(); + expect(screen.getByText('Preview not available')).toBeInTheDocument(); + + // Click on accept changes + const acceptChangesButton = screen.getByText(/accept changes/i); + fireEvent.click(acceptChangesButton); + + await waitFor(() => expect(mockUseAcceptLibraryBlockChanges).toHaveBeenCalled()); + }); + + it('should decline sync unit changes from upstream', async () => { + renderComponent(); + + expect(await screen.findByTestId('unit-card-header')).toBeInTheDocument(); + + // Click on sync button + const syncButton = screen.getByRole('button', { name: /update available - click to sync/i }); + fireEvent.click(syncButton); + + // Should open compare preview modal + expect(screen.getByRole('heading', { name: /preview changes: unit name/i })).toBeInTheDocument(); + expect(screen.getByText('Preview not available')).toBeInTheDocument(); + + // Click on ignore changes + const ignoreChangesButton = screen.getByRole('button', { name: /ignore changes/i }); + fireEvent.click(ignoreChangesButton); + + // Should open the confirmation modal + expect(screen.getByRole('heading', { name: /ignore these changes\?/i })).toBeInTheDocument(); + + // Click on ignore button + const ignoreButton = screen.getByRole('button', { name: /ignore/i }); + fireEvent.click(ignoreButton); + + await waitFor(() => expect(mockUseIgnoreLibraryBlockChanges).toHaveBeenCalled()); + }); }); diff --git a/src/course-unit/preview-changes/index.test.tsx b/src/course-unit/preview-changes/index.test.tsx index a67fe6a7d2..d28f02575f 100644 --- a/src/course-unit/preview-changes/index.test.tsx +++ b/src/course-unit/preview-changes/index.test.tsx @@ -12,13 +12,13 @@ import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.' import { messageTypes } from '../constants'; import { libraryBlockChangesUrl } from '../data/api'; import { ToastActionData } from '../../generic/toast-context'; -import { getLibraryBlockMetadataUrl } from '../../library-authoring/data/api'; +import { getLibraryBlockMetadataUrl, getLibraryContainerApiUrl } from '../../library-authoring/data/api'; const usageKey = 'some-id'; const defaultEventData: LibraryChangesMessageData = { displayName: 'Test block', downstreamBlockId: usageKey, - upstreamBlockId: 'some-lib-id', + upstreamBlockId: 'lct:org:lib1:unit:1', upstreamBlockVersionSynced: 1, isVertical: false, }; @@ -87,6 +87,15 @@ describe('', () => { expect(await screen.findByText('Preview changes: Test block -> New test block')).toBeInTheDocument(); }); + it('renders both new and old title if they are different on units', async () => { + axiosMock.onGet(getLibraryContainerApiUrl(defaultEventData.upstreamBlockId)).reply(200, { + displayName: 'New test Unit', + }); + render({ ...defaultEventData, isVertical: true, displayName: 'Test Unit' }); + + expect(await screen.findByText('Preview changes: Test Unit -> New test Unit')).toBeInTheDocument(); + }); + it('accept changes works', async () => { axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {}); render(); diff --git a/src/course-unit/preview-changes/index.tsx b/src/course-unit/preview-changes/index.tsx index c26550eb12..157aaeab0d 100644 --- a/src/course-unit/preview-changes/index.tsx +++ b/src/course-unit/preview-changes/index.tsx @@ -14,7 +14,7 @@ import messages from './messages'; import { ToastContext } from '../../generic/toast-context'; import LoadingButton from '../../generic/loading-button'; import Loading from '../../generic/Loading'; -import { useLibraryBlockMetadata } from '../../library-authoring/data/apiHooks'; +import { useContainer, useLibraryBlockMetadata } from '../../library-authoring/data/apiHooks'; export interface LibraryChangesMessageData { displayName: string, @@ -48,14 +48,20 @@ export const PreviewLibraryXBlockChanges = ({ // ignore changes confirmation modal toggle. const [isConfirmModalOpen, openConfirmModal, closeConfirmModal] = useToggle(false); + + // TODO: Split into two different components to avoid making these two calls in which + // one will definitely fail const { data: componentMetadata } = useLibraryBlockMetadata(blockData?.upstreamBlockId); + const { data: unitMetadata } = useContainer(blockData?.upstreamBlockId); + + const metadata = blockData?.isVertical ? unitMetadata : componentMetadata; const acceptChangesMutation = useAcceptLibraryBlockChanges(); const ignoreChangesMutation = useIgnoreLibraryBlockChanges(); const getTitle = useCallback(() => { const oldName = blockData?.displayName; - const newName = componentMetadata?.displayName; + const newName = metadata?.displayName; if (!oldName) { if (blockData?.isVertical) { @@ -67,7 +73,7 @@ export const PreviewLibraryXBlockChanges = ({ return intl.formatMessage(messages.title, { blockTitle: oldName }); } return intl.formatMessage(messages.diffTitle, { oldName, newName }); - }, [blockData, componentMetadata]); + }, [blockData, metadata]); const getBody = useCallback(() => { if (!blockData) { @@ -78,6 +84,7 @@ export const PreviewLibraryXBlockChanges = ({ usageKey={blockData.upstreamBlockId} oldVersion={blockData.upstreamBlockVersionSynced || 'published'} newVersion="published" + isContainer={blockData.isVertical} /> ); }, [blockData]); diff --git a/src/library-authoring/component-comparison/CompareChangesWidget.tsx b/src/library-authoring/component-comparison/CompareChangesWidget.tsx index 19581e5c56..aaca0df279 100644 --- a/src/library-authoring/component-comparison/CompareChangesWidget.tsx +++ b/src/library-authoring/component-comparison/CompareChangesWidget.tsx @@ -6,10 +6,21 @@ import { LibraryBlock, type VersionSpec } from '../LibraryBlock'; import messages from './messages'; +const PreviewNotAvailable = () => { + const intl = useIntl(); + + return ( +
+ {intl.formatMessage(messages.previewNotAvailable)} +
+ ); +}; + interface Props { usageKey: string; oldVersion?: VersionSpec; newVersion?: VersionSpec; + isContainer?: boolean; } /** @@ -20,7 +31,12 @@ interface Props { * In the future, it would be better to have a way of highlighting the changes * or showing a diff. */ -const CompareChangesWidget = ({ usageKey, oldVersion = 'published', newVersion = 'draft' }: Props) => { +const CompareChangesWidget = ({ + usageKey, + oldVersion = 'published', + newVersion = 'draft', + isContainer = false, +}: Props) => { const intl = useIntl(); return ( @@ -28,24 +44,28 @@ const CompareChangesWidget = ({ usageKey, oldVersion = 'published', newVersion =
- - - + {isContainer ? () : ( + + + + )}
- - - + {isContainer ? () : ( + + + + )}
diff --git a/src/library-authoring/component-comparison/messages.ts b/src/library-authoring/component-comparison/messages.ts index 89275918ad..322eaee3a4 100644 --- a/src/library-authoring/component-comparison/messages.ts +++ b/src/library-authoring/component-comparison/messages.ts @@ -17,6 +17,11 @@ const messages = defineMessages({ defaultMessage: 'Compare Changes', description: 'Title used for the compare changes dialog', }, + previewNotAvailable: { + id: 'course-authoring.library-authoring.component-comparison.preview-not-available', + defaultMessage: 'Preview not available', + description: 'Message shown when preview is not available.', + }, }); export default messages;