Skip to content

Commit a975f3b

Browse files
authored
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.
1 parent 1c7ad2f commit a975f3b

13 files changed

Lines changed: 408 additions & 100 deletions

File tree

src/course-libraries/CourseLibraries.test.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -327,4 +327,19 @@ describe('<CourseLibraries ReviewTab />', () => {
327327
expect(mockShowToast).toHaveBeenCalledWith(expectedToastMsg);
328328
expect(mockInvalidateQueries).toHaveBeenCalledWith({ queryKey: ['courseLibraries', 'course-v1:OpenEdx+DemoX+CourseX'] });
329329
});
330+
331+
it('should show sync modal with local changes', async () => {
332+
const itemIndex = 3;
333+
const user = userEvent.setup();
334+
await renderCourseLibrariesReviewPage(mockGetEntityLinksSummaryByDownstreamContext.courseKey);
335+
const previewBtns = await screen.findAllByRole('button', { name: 'Review Updates' });
336+
expect(previewBtns.length).toEqual(7);
337+
await user.click(previewBtns[itemIndex]);
338+
339+
expect(screen.getByText('This library content has local edits.')).toBeInTheDocument();
340+
expect(screen.getByRole('tab', { name: /course content/i })).toBeInTheDocument();
341+
expect(screen.getByRole('tab', { name: /published library content/i })).toBeInTheDocument();
342+
expect(screen.getByRole('button', { name: /update to published library content/i })).toBeInTheDocument();
343+
expect(screen.getByRole('button', { name: /keep course content/i })).toBeInTheDocument();
344+
});
330345
});

src/course-libraries/ReviewTabContent.tsx

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ const ItemReviewList = ({
173173
upstreamBlockId: outOfSyncItemsByKey[info.usageKey].upstreamKey,
174174
upstreamBlockVersionSynced: outOfSyncItemsByKey[info.usageKey].versionSynced,
175175
isContainer: info.blockType === 'vertical' || info.blockType === 'sequential' || info.blockType === 'chapter',
176+
blockType: info.blockType,
177+
isLocallyModified: outOfSyncItemsByKey[info.usageKey].downstreamIsModified,
176178
});
177179
}, [outOfSyncItemsByKey]);
178180

@@ -213,7 +215,10 @@ const ItemReviewList = ({
213215

214216
const updateBlock = useCallback(async (info: ContentHit) => {
215217
try {
216-
await acceptChangesMutation.mutateAsync(info.usageKey);
218+
await acceptChangesMutation.mutateAsync({
219+
blockId: info.usageKey,
220+
overrideCustomizations: info.blockType === 'html' && outOfSyncItemsByKey[info.usageKey].downstreamIsModified,
221+
});
217222
reloadLinks(info.usageKey);
218223
showToast(intl.formatMessage(
219224
messages.updateSingleBlockSuccess,
@@ -230,7 +235,9 @@ const ItemReviewList = ({
230235
return;
231236
}
232237
try {
233-
await ignoreChangesMutation.mutateAsync(blockData.downstreamBlockId);
238+
await ignoreChangesMutation.mutateAsync({
239+
blockId: blockData.downstreamBlockId,
240+
});
234241
reloadLinks(blockData.downstreamBlockId);
235242
showToast(intl.formatMessage(
236243
messages.ignoreSingleBlockSuccess,

src/course-libraries/__mocks__/publishableEntityLinks.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
1212
"versionSynced": 2,
1313
"versionDeclined": null,
14+
"downstreamIsModified": false,
1415
"created": "2025-02-08T14:07:05.588484Z",
1516
"updated": "2025-02-08T14:07:05.588484Z"
1617
},
@@ -26,6 +27,7 @@
2627
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
2728
"versionSynced": 2,
2829
"versionDeclined": null,
30+
"downstreamIsModified": false,
2931
"created": "2025-02-08T14:07:05.588484Z",
3032
"updated": "2025-02-08T14:07:05.588484Z"
3133
},
@@ -41,6 +43,7 @@
4143
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
4244
"versionSynced": 16,
4345
"versionDeclined": null,
46+
"downstreamIsModified": true,
4447
"created": "2025-02-08T14:07:05.588484Z",
4548
"updated": "2025-02-08T14:07:05.588484Z"
4649
},
@@ -56,6 +59,7 @@
5659
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
5760
"versionSynced": 2,
5861
"versionDeclined": null,
62+
"downstreamIsModified": false,
5963
"created": "2025-02-08T14:07:05.588484Z",
6064
"updated": "2025-02-08T14:07:05.588484Z"
6165
},
@@ -71,6 +75,7 @@
7175
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
7276
"versionSynced": 2,
7377
"versionDeclined": null,
78+
"downstreamIsModified": false,
7479
"created": "2025-02-08T14:07:05.588484Z",
7580
"updated": "2025-02-08T14:07:05.588484Z"
7681
},
@@ -86,6 +91,7 @@
8691
"downstreamContextKey": "course-v1:OpenEdx+DemoX+CourseX",
8792
"versionSynced": 2,
8893
"versionDeclined": null,
94+
"downstreamIsModified": false,
8995
"created": "2025-02-08T14:07:05.588484Z",
9096
"updated": "2025-02-08T14:07:05.588484Z"
9197
},

src/course-libraries/data/api.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export interface BasePublishableEntityLink {
2929
created: string;
3030
updated: string;
3131
readyToSync: boolean;
32+
downstreamIsModified: boolean;
3233
}
3334

3435
export interface ComponentPublishableEntityLink extends BasePublishableEntityLink {

src/course-unit/data/api.ts

Lines changed: 14 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
// @ts-check
21
import { camelCaseObject, getConfig } from '@edx/frontend-platform';
32
import { getAuthenticatedHttpClient } from '@edx/frontend-platform/auth';
43

@@ -44,14 +43,6 @@ export async function getVerticalData(unitId: string): Promise<object> {
4443

4544
/**
4645
* Creates a new course XBlock.
47-
* @param {Object} options - The options for creating the XBlock.
48-
* @param {string} options.type - The type of the XBlock.
49-
* @param {string} [options.category] - The category of the XBlock. Defaults to the type if not provided.
50-
* @param {string} options.parentLocator - The parent locator.
51-
* @param {string} [options.displayName] - The display name.
52-
* @param {string} [options.boilerplate] - The boilerplate.
53-
* @param {string} [options.stagedContent] - The staged content.
54-
* @param {string} [options.libraryContentKey] - component key from library if being imported.
5546
*/
5647
export async function createCourseXblock({
5748
type,
@@ -63,13 +54,13 @@ export async function createCourseXblock({
6354
libraryContentKey,
6455
}: {
6556
type: string,
66-
category?: string,
57+
category?: string, // The category of the XBlock. Defaults to the type if not provided.
6758
parentLocator: string,
6859
displayName?: string,
6960
boilerplate?: string,
7061
stagedContent?: string,
71-
libraryContentKey?: string,
72-
}): Promise<any> {
62+
libraryContentKey?: string, // component key from library if being imported.
63+
}) {
7364
const body = {
7465
type,
7566
boilerplate,
@@ -92,8 +83,8 @@ export async function createCourseXblock({
9283
*/
9384
export async function handleCourseUnitVisibilityAndData(
9485
unitId: string,
95-
type: string,
96-
isVisible: boolean,
86+
type: string, // The action type (e.g., PUBLISH_TYPES.discardChanges).
87+
isVisible: boolean, // The visibility status for students.
9788
groupAccess: boolean,
9889
isDiscussionEnabled: boolean,
9990
): Promise<object> {
@@ -160,9 +151,6 @@ export async function getCourseOutlineInfo(courseId: string): Promise<CourseOutl
160151

161152
/**
162153
* Move a unit item to new unit.
163-
* @param {string} sourceLocator - The ID of the item to be moved.
164-
* @param {string} targetParentLocator - The ID of the XBlock associated with the item.
165-
* @returns {Promise<moveInfo>} - The move information.
166154
*/
167155
export async function patchUnitItem(sourceLocator: string, targetParentLocator: string): Promise<MoveInfoData> {
168156
const { data } = await getAuthenticatedHttpClient()
@@ -176,18 +164,22 @@ export async function patchUnitItem(sourceLocator: string, targetParentLocator:
176164

177165
/**
178166
* Accept the changes from upstream library block in course
179-
* @param {string} blockId - The ID of the item to be updated from library.
180167
*/
181-
export async function acceptLibraryBlockChanges(blockId: string) {
168+
export async function acceptLibraryBlockChanges({
169+
blockId,
170+
overrideCustomizations = false,
171+
}: {
172+
blockId: string,
173+
overrideCustomizations?: boolean,
174+
}) {
182175
await getAuthenticatedHttpClient()
183-
.post(libraryBlockChangesUrl(blockId));
176+
.post(libraryBlockChangesUrl(blockId), { override_customizations: overrideCustomizations });
184177
}
185178

186179
/**
187180
* Ignore the changes from upstream library block in course
188-
* @param {string} blockId - The ID of the item to be updated from library.
189181
*/
190-
export async function ignoreLibraryBlockChanges(blockId: string) {
182+
export async function ignoreLibraryBlockChanges({ blockId } : { blockId: string }) {
191183
await getAuthenticatedHttpClient()
192184
.delete(libraryBlockChangesUrl(blockId));
193185
}
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,10 @@
11
.lib-preview-xblock-changes-modal {
22
border-bottom-right-radius: 0;
33
border-bottom-left-radius: 0;
4+
5+
.preview-title {
6+
span {
7+
margin: 0 10px;
8+
}
9+
}
410
}

src/course-unit/preview-changes/index.test.tsx

Lines changed: 61 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,18 @@
11
import userEvent from '@testing-library/user-event';
22
import MockAdapter from 'axios-mock-adapter/types';
3+
34
import {
45
act,
56
render as baseRender,
67
screen,
78
initializeMocks,
89
waitFor,
9-
} from '../../testUtils';
10+
} from '@src/testUtils';
11+
import { ToastActionData } from '@src/generic/toast-context';
1012

1113
import IframePreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.';
1214
import { messageTypes } from '../constants';
1315
import { libraryBlockChangesUrl } from '../data/api';
14-
import { ToastActionData } from '../../generic/toast-context';
1516

1617
const usageKey = 'block-v1:UNIX+UX1+2025_T3+type@unit+block@1';
1718
const defaultEventData: LibraryChangesMessageData = {
@@ -20,10 +21,12 @@ const defaultEventData: LibraryChangesMessageData = {
2021
upstreamBlockId: 'lct:org:lib1:unit:1',
2122
upstreamBlockVersionSynced: 1,
2223
isContainer: false,
24+
isLocallyModified: false,
25+
blockType: 'html',
2326
};
2427

2528
const mockSendMessageToIframe = jest.fn();
26-
jest.mock('../../generic/hooks/context/hooks', () => ({
29+
jest.mock('@src/generic/hooks/context/hooks', () => ({
2730
useIframe: () => ({
2831
iframeRef: { current: { contentWindow: {} as HTMLIFrameElement } },
2932
setIframeRef: () => {},
@@ -60,7 +63,6 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
6063
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
6164
expect(await screen.findByRole('button', { name: 'Accept changes' })).toBeInTheDocument();
6265
expect(await screen.findByRole('button', { name: 'Ignore changes' })).toBeInTheDocument();
63-
expect(await screen.findByRole('button', { name: 'Cancel' })).toBeInTheDocument();
6466
expect(await screen.findByRole('tab', { name: 'New version' })).toBeInTheDocument();
6567
expect(await screen.findByRole('tab', { name: 'Old version' })).toBeInTheDocument();
6668
});
@@ -132,4 +134,59 @@ describe('<IframePreviewLibraryXBlockChanges />', () => {
132134
});
133135
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
134136
});
137+
138+
it('should render modal of text with local changes', async () => {
139+
render({ ...defaultEventData, isLocallyModified: true });
140+
141+
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
142+
143+
expect(screen.getByText('This library content has local edits.')).toBeInTheDocument();
144+
expect(await screen.findByRole('button', { name: 'Update to published library content' })).toBeInTheDocument();
145+
expect(await screen.findByRole('button', { name: 'Keep course content' })).toBeInTheDocument();
146+
expect(await screen.findByRole('tab', { name: 'Course content' })).toBeInTheDocument();
147+
expect(await screen.findByRole('tab', { name: 'Published library content' })).toBeInTheDocument();
148+
});
149+
150+
it('update changes works', async () => {
151+
const user = userEvent.setup();
152+
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
153+
render({ ...defaultEventData, isLocallyModified: true });
154+
155+
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
156+
const acceptBtn = await screen.findByRole('button', { name: 'Update to published library content' });
157+
await user.click(acceptBtn);
158+
const confirmBtn = await screen.findByRole('button', { name: 'Discard local edits and update' });
159+
await user.click(confirmBtn);
160+
161+
await waitFor(() => {
162+
expect(mockSendMessageToIframe).toHaveBeenCalledWith(
163+
messageTypes.completeXBlockEditing,
164+
{ locator: usageKey },
165+
);
166+
expect(axiosMock.history.post.length).toEqual(1);
167+
expect(axiosMock.history.post[0].url).toEqual(libraryBlockChangesUrl(usageKey));
168+
});
169+
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
170+
});
171+
172+
it('keep changes work', async () => {
173+
const user = userEvent.setup();
174+
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(200, {});
175+
render({ ...defaultEventData, isLocallyModified: true });
176+
177+
expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
178+
const ignoreBtn = await screen.findByRole('button', { name: 'Keep course content' });
179+
await user.click(ignoreBtn);
180+
const ignoreConfirmBtn = (await screen.findAllByRole('button', { name: 'Keep course content' }))[0];
181+
await user.click(ignoreConfirmBtn);
182+
await waitFor(() => {
183+
expect(mockSendMessageToIframe).toHaveBeenCalledWith(
184+
messageTypes.completeXBlockEditing,
185+
{ locator: usageKey },
186+
);
187+
expect(axiosMock.history.delete.length).toEqual(1);
188+
expect(axiosMock.history.delete[0].url).toEqual(libraryBlockChangesUrl(usageKey));
189+
});
190+
expect(screen.queryByText('Preview changes: Test block')).not.toBeInTheDocument();
191+
});
135192
});

0 commit comments

Comments
 (0)