Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: preview library block changes in course unit [FC-0062] #1506

Merged
merged 10 commits into from
Nov 22, 2024
12 changes: 6 additions & 6 deletions src/course-outline/CourseOutline.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -597,10 +597,10 @@ describe('<CourseOutline />', () => {
});

it('check whether section, subsection and unit is deleted when corresponding delete button is clicked', async () => {
const { findAllByTestId, findByTestId, queryByText } = render(<RootWrapper />);
render(<RootWrapper />);
// get section, subsection and unit
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [sectionElement] = await findAllByTestId('section-card');
const [sectionElement] = await screen.findAllByTestId('section-card');
const [subsection] = section.childInfo.children;
const [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
Expand All @@ -610,7 +610,7 @@ describe('<CourseOutline />', () => {

const checkDeleteBtn = async (item, element, elementName) => {
await waitFor(() => {
expect(queryByText(item.displayName), `Failed for ${elementName}!`).toBeInTheDocument();
expect(screen.queryByText(item.displayName), `Failed for ${elementName}!`).toBeInTheDocument();
});

axiosMock.onDelete(getCourseItemApiUrl(item.id)).reply(200);
Expand All @@ -619,11 +619,11 @@ describe('<CourseOutline />', () => {
fireEvent.click(menu);
const deleteButton = await within(element).findByTestId(`${elementName}-card-header__menu-delete-button`);
fireEvent.click(deleteButton);
const confirmButton = await findByTestId('delete-confirm-button');
await act(async () => fireEvent.click(confirmButton));
const confirmButton = await screen.findByRole('button', { name: 'Delete' });
fireEvent.click(confirmButton);

await waitFor(() => {
expect(queryByText(item.displayName), `Failed for ${elementName}!`).not.toBeInTheDocument();
expect(screen.queryByText(item.displayName), `Failed for ${elementName}!`).not.toBeInTheDocument();
});
};

Expand Down
2 changes: 2 additions & 0 deletions src/course-unit/CourseUnit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import TagsSidebarControls from '../content-tags-drawer/tags-sidebar-controls';
import { PasteNotificationAlert } from './clipboard';
import XBlockContainerIframe from './xblock-container-iframe';
import MoveModal from './move-modal';
import PreviewLibraryXBlockChanges from './preview-changes';

const CourseUnit = ({ courseId }) => {
const { blockId } = useParams();
Expand Down Expand Up @@ -200,6 +201,7 @@ const CourseUnit = ({ courseId }) => {
closeModal={closeMoveModal}
courseId={courseId}
/>
<PreviewLibraryXBlockChanges />
</Layout.Element>
<Layout.Element>
<Stack gap={3}>
Expand Down
1 change: 1 addition & 0 deletions src/course-unit/CourseUnit.scss
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
@import "./sidebar/Sidebar";
@import "./header-title/HeaderTitle";
@import "./move-modal";
@import "./preview-changes";

.course-unit__alert {
margin-bottom: 1.75rem;
Expand Down
3 changes: 3 additions & 0 deletions src/course-unit/CourseUnit.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,9 @@ jest.mock('@tanstack/react-query', () => ({
useQueryClient: jest.fn(() => ({
setQueryData: jest.fn(),
})),
useMutation: jest.fn(() => ({
mutateAsync: jest.fn(),
})),
}));

const clipboardBroadcastChannelMock = {
Expand Down
1 change: 1 addition & 0 deletions src/course-unit/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export const messageTypes = {
videoFullScreen: 'plugin.videoFullScreen',
refreshXBlock: 'refreshXBlock',
showMoveXBlockModal: 'showMoveXBlockModal',
showXBlockLibraryChangesPreview: 'showXBlockLibraryChangesPreview',
};

export const IFRAME_FEATURE_POLICY = (
Expand Down
19 changes: 19 additions & 0 deletions src/course-unit/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export const getCourseSectionVerticalApiUrl = (itemId) => `${getStudioBaseUrl()}
export const getCourseVerticalChildrenApiUrl = (itemId) => `${getStudioBaseUrl()}/api/contentstore/v1/container/vertical/${itemId}/children`;
export const getCourseOutlineInfoUrl = (courseId) => `${getStudioBaseUrl()}/course/${courseId}?format=concise`;
export const postXBlockBaseApiUrl = () => `${getStudioBaseUrl()}/xblock/`;
export const libraryBlockChangesUrl = (blockId) => `${getStudioBaseUrl()}/api/contentstore/v2/downstreams/${blockId}/sync`;

/**
* Get course unit.
Expand Down Expand Up @@ -206,3 +207,21 @@ export async function patchUnitItem(sourceLocator, targetParentLocator) {

return camelCaseObject(data);
}

/**
* 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) {
await getAuthenticatedHttpClient()
.post(libraryBlockChangesUrl(blockId));
}

/**
* 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) {
await getAuthenticatedHttpClient()
.delete(libraryBlockChangesUrl(blockId));
}
19 changes: 19 additions & 0 deletions src/course-unit/data/apiHooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { useMutation } from '@tanstack/react-query';

import { acceptLibraryBlockChanges, ignoreLibraryBlockChanges } from './api';

/**
* Hook that provides a "mutation" that can be used to accept library block changes.
*/
// eslint-disable-next-line import/prefer-default-export
export const useAcceptLibraryBlockChanges = () => useMutation({
mutationFn: acceptLibraryBlockChanges,
});

/**
* Hook that provides a "mutation" that can be used to ignore library block changes.
*/
// eslint-disable-next-line import/prefer-default-export
export const useIgnoreLibraryBlockChanges = () => useMutation({
mutationFn: ignoreLibraryBlockChanges,
});
4 changes: 4 additions & 0 deletions src/course-unit/preview-changes/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.lib-preview-xblock-changes-modal {
border-bottom-right-radius: 0;
border-bottom-left-radius: 0;
}
138 changes: 138 additions & 0 deletions src/course-unit/preview-changes/index.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
import userEvent from '@testing-library/user-event';
import MockAdapter from 'axios-mock-adapter/types';
import {
act,
render as baseRender,
screen,
initializeMocks,
waitFor,
} from '../../testUtils';

import PreviewLibraryXBlockChanges, { LibraryChangesMessageData } from '.';
import { messageTypes } from '../constants';
import { IframeProvider } from '../context/iFrameContext';
import { libraryBlockChangesUrl } from '../data/api';
import { ToastActionData } from '../../generic/toast-context';
import { getLibraryBlockMetadataUrl } from '../../library-authoring/data/api';

const usageKey = 'some-id';
const defaultEventData: LibraryChangesMessageData = {
displayName: 'Test block',
downstreamBlockId: usageKey,
upstreamBlockId: 'some-lib-id',
upstreamBlockVersionSynced: 1,
isVertical: false,
};

const mockSendMessageToIframe = jest.fn();
jest.mock('../context/hooks', () => ({
useIframe: () => ({
sendMessageToIframe: mockSendMessageToIframe,
}),
}));
const render = (eventData?: LibraryChangesMessageData) => {
baseRender(<PreviewLibraryXBlockChanges />, {
extraWrapper: ({ children }) => <IframeProvider>{ children }</IframeProvider>,
});
const message = {
data: {
type: messageTypes.showXBlockLibraryChangesPreview,
payload: eventData || defaultEventData,
},
};
// Dispatch showXBlockLibraryChangesPreview message event to open the preivew modal.
act(() => {
window.dispatchEvent(new MessageEvent('message', message));
});
};

let axiosMock: MockAdapter;
let mockShowToast: (message: string, action?: ToastActionData | undefined) => void;

describe('<PreviewLibraryXBlockChanges />', () => {
beforeEach(() => {
const mocks = initializeMocks();
axiosMock = mocks.axiosMock;
mockShowToast = mocks.mockShowToast;
});

it('renders modal', async () => {
render();

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();
});

it('renders displayName for units', async () => {
render({ ...defaultEventData, isVertical: true, displayName: '' });

expect(await screen.findByText('Preview changes: Unit')).toBeInTheDocument();
});

it('renders default displayName for components with no displayName', async () => {
render({ ...defaultEventData, displayName: '' });

expect(await screen.findByText('Preview changes: Component')).toBeInTheDocument();
});

it('renders both new and old title if they are different', async () => {
axiosMock.onGet(getLibraryBlockMetadataUrl(defaultEventData.upstreamBlockId)).reply(200, {
displayName: 'New test block',
});
render();

expect(await screen.findByText('Preview changes: Test block -> New test block')).toBeInTheDocument();
});

it('accept changes works', async () => {
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(200, {});
render();

expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
const acceptBtn = await screen.findByRole('button', { name: 'Accept changes' });
userEvent.click(acceptBtn);
await waitFor(() => {
expect(mockSendMessageToIframe).toHaveBeenCalledWith(messageTypes.refreshXBlock, null);
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('shows toast if accept changes fails', async () => {
axiosMock.onPost(libraryBlockChangesUrl(usageKey)).reply(500, {});
render();

expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
const acceptBtn = await screen.findByRole('button', { name: 'Accept changes' });
userEvent.click(acceptBtn);
await waitFor(() => {
expect(mockSendMessageToIframe).not.toHaveBeenCalledWith(messageTypes.refreshXBlock, null);
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();
expect(mockShowToast).toHaveBeenCalledWith('Failed to update component');
});

it('ignore changes works', async () => {
axiosMock.onDelete(libraryBlockChangesUrl(usageKey)).reply(200, {});
render();

expect(await screen.findByText('Preview changes: Test block')).toBeInTheDocument();
const ignoreBtn = await screen.findByRole('button', { name: 'Ignore changes' });
userEvent.click(ignoreBtn);
const ignoreConfirmBtn = await screen.findByRole('button', { name: 'Ignore' });
userEvent.click(ignoreConfirmBtn);
await waitFor(() => {
expect(mockSendMessageToIframe).toHaveBeenCalledWith(messageTypes.refreshXBlock, null);
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();
});
});
Loading