Skip to content

Commit

Permalink
feat: preview library block changes in course unit [FC-0062] (#1506)
Browse files Browse the repository at this point in the history
Creates a new preview library block modal. Intercepts the message when the block is iframed to open the new modal.
  • Loading branch information
navinkarkera authored Nov 22, 2024
1 parent 31f59d6 commit 7aa5acc
Show file tree
Hide file tree
Showing 17 changed files with 458 additions and 69 deletions.
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

0 comments on commit 7aa5acc

Please sign in to comment.