Skip to content

Commit

Permalink
feat: added edit and move modals for xblocks
Browse files Browse the repository at this point in the history
* feat: [AXIMST-28] added edit modals for xblocks

* refactor: added postMessages

* refactor: modals refactoring

* refactor: code refactoring

* refactor: after rebase

* refactor: styles refactoring

* refactor: code refactoring

* refactor: create iframe component

* refactor: error handling

* fix: fixed tests

* refactor: removed error handling

* refactor: refactoring after review

feat: added move modal and tests

feat: added alert for successful unit movement

feat: added alert logic

refactor: code refactoring

refactor: some refactoring

refactor: code refactoring

refactor: code refactoring

refactor: refactoring after review

refactor: after second review

refactor: after rebase

refactor: code refactoring

refactor: tests refactoring
  • Loading branch information
PKulkoRaccoonGang committed May 9, 2024
1 parent 5c1df3e commit ec0ae6b
Show file tree
Hide file tree
Showing 18 changed files with 620 additions and 79 deletions.
1 change: 1 addition & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const NOTIFICATION_MESSAGES = {
copying: 'Copying',
pasting: 'Pasting',
discardChanges: 'Discarding changes',
undoMoving: 'Undo moving',
publishing: 'Publishing',
hidingFromStudents: 'Hiding from students',
makingVisibleToStudents: 'Making visible to students',
Expand Down
41 changes: 39 additions & 2 deletions src/course-unit/CourseUnit.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,16 @@ import { useEffect, useMemo, useState } from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';
import { useParams } from 'react-router-dom';
import { Container, Layout, Stack } from '@openedx/paragon';
import {
Container, Layout, Stack, Button, TransitionReplace,
} from '@openedx/paragon';
import { getConfig } from '@edx/frontend-platform';
import { useIntl, injectIntl } from '@edx/frontend-platform/i18n';
import { DraggableList, ErrorAlert } from '@edx/frontend-lib-content-components';
import { Warning as WarningIcon } from '@openedx/paragon/icons';
import {
Warning as WarningIcon,
CheckCircle as CheckCircleIcon,
} from '@openedx/paragon/icons';
import { SortableContext, verticalListSortingStrategy } from '@dnd-kit/sortable';

import { getProcessingNotification } from '../generic/processing-notification/data/selectors';
Expand Down Expand Up @@ -61,6 +66,10 @@ const CourseUnit = ({ courseId }) => {
courseVerticalChildren,
handleXBlockDragAndDrop,
canPasteComponent,
movedXBlockParams,
handleRollbackMovedXBlock,
handleCloseXBlockMovedAlert,
handleNavigateToTargetUnit,
} = useCourseUnit({ courseId, blockId });

const initialXBlocksData = useMemo(() => courseVerticalChildren.children ?? [], [courseVerticalChildren.children]);
Expand Down Expand Up @@ -101,6 +110,34 @@ const CourseUnit = ({ courseId }) => {
<>
<Container size="xl" className="course-unit px-4">
<section className="course-unit-container mb-4 mt-5">
<TransitionReplace>
{movedXBlockParams.isSuccess ? (
<AlertMessage
key="xblock-moved-alert"
data-testid="xblock-moved-alert"
show={movedXBlockParams.isSuccess}
variant="success"
icon={CheckCircleIcon}
title={movedXBlockParams.isUndo
? intl.formatMessage(messages.alertMoveCancelTitle)
: intl.formatMessage(messages.alertMoveSuccessTitle)}
description={movedXBlockParams.isUndo
? intl.formatMessage(messages.alertMoveCancelDescription, { title: movedXBlockParams.title })
: intl.formatMessage(messages.alertMoveSuccessDescription, { title: movedXBlockParams.title })}
aria-hidden={movedXBlockParams.isSuccess}
dismissible
actions={movedXBlockParams.isUndo ? null : [
<Button onClick={handleRollbackMovedXBlock}>
{intl.formatMessage(messages.undoMoveButton)}
</Button>,
<Button onClick={handleNavigateToTargetUnit}>
{intl.formatMessage(messages.newLocationButton)}
</Button>,
]}
onClose={handleCloseXBlockMovedAlert}
/>
) : null}
</TransitionReplace>
<ErrorAlert hideHeading isError={savingStatus === RequestStatus.FAILED && isErrorAlert}>
{intl.formatMessage(messages.alertFailedGeneric, { actionName: 'save', type: 'changes' })}
</ErrorAlert>
Expand Down
168 changes: 166 additions & 2 deletions src/course-unit/CourseUnit.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,7 @@ import userEvent from '@testing-library/user-event';
import { IntlProvider } from '@edx/frontend-platform/i18n';
import { AppProvider } from '@edx/frontend-platform/react';
import {
camelCaseObject,
getConfig,
camelCaseObject, getConfig,
initializeMockApp,
setConfig,
} from '@edx/frontend-platform';
Expand All @@ -28,6 +27,7 @@ import {
fetchCourseSectionVerticalData,
fetchCourseUnitQuery,
fetchCourseVerticalChildrenData,
rollbackUnitItemQuery,
} from './data/thunk';
import initializeStore from '../store';
import {
Expand Down Expand Up @@ -110,8 +110,23 @@ const clipboardBroadcastChannelMock = {
close: jest.fn(),
};

jest.mock('../generic/hooks', () => ({
useOverflowControl: () => jest.fn(),
}));

global.BroadcastChannel = jest.fn(() => clipboardBroadcastChannelMock);

const getIFramePostMessages = (method) => ({
data: {
method,
params: {
targetParentLocator: courseId,
sourceDisplayName: courseVerticalChildrenMock.children[0].name,
sourceLocator: courseVerticalChildrenMock.children[0].block_id,
},
},
});

const RootWrapper = () => (
<AppProvider store={store}>
<IntlProvider locale="en">
Expand Down Expand Up @@ -1522,4 +1537,153 @@ describe('<CourseUnit />', () => {
expect(xBlock1).toBe(xBlock2);
});
});

describe('Edit and move modals', () => {
it('should close the edit modal when the close button is clicked', async () => {
const { getByTitle, getAllByTestId } = render(<RootWrapper />);

axiosMock
.onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, courseVerticalChildrenMock);

await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);

const [discussionXBlock] = getAllByTestId('course-xblock');
const xblockEditBtn = within(discussionXBlock)
.getByLabelText(courseXBlockMessages.blockAltButtonEdit.defaultMessage);

userEvent.click(xblockEditBtn);

const iframePostMsg = getIFramePostMessages('close_modal');
const editModalIFrame = getByTitle('xblock-edit-modal-iframe');

expect(editModalIFrame).toHaveAttribute('src', `${getConfig().STUDIO_BASE_URL}/xblock/${courseVerticalChildrenMock.children[0].block_id}/actions/edit`);

await act(async () => window.dispatchEvent(new MessageEvent('message', iframePostMsg)));

expect(editModalIFrame).not.toBeInTheDocument();
});

it('should display success alert and close move modal when move event is triggered', async () => {
const {
getByTitle,
getByRole,
getAllByLabelText,
getByText,
} = render(<RootWrapper />);

const iframePostMsg = getIFramePostMessages('move_xblock');

axiosMock
.onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, courseVerticalChildrenMock);

await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);

const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
userEvent.click(xblockActionBtn);

const xblockMoveBtn = getByRole('button', { name: courseXBlockMessages.blockLabelButtonMove.defaultMessage });
userEvent.click(xblockMoveBtn);

const moveModalIFrame = getByTitle('xblock-move-modal-iframe');

await act(async () => window.dispatchEvent(new MessageEvent('message', iframePostMsg)));

expect(moveModalIFrame).not.toBeInTheDocument();
expect(getByText(messages.alertMoveSuccessTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(
messages.alertMoveSuccessDescription.defaultMessage
.replace('{title}', courseVerticalChildrenMock.children[0].name),
)).toBeInTheDocument();

await waitFor(() => {
userEvent.click(getByText(/Cancel/i));
expect(moveModalIFrame).not.toBeInTheDocument();
});
});

it('should navigate to new location when new location button is clicked after successful move', async () => {
const {
getByTitle,
getByRole,
getAllByLabelText,
getByText,
} = render(<RootWrapper />);

const iframePostMsg = getIFramePostMessages('move_xblock');

axiosMock
.onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, courseVerticalChildrenMock);

await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);

const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
userEvent.click(xblockActionBtn);

const xblockMoveBtn = getByRole('button', { name: courseXBlockMessages.blockLabelButtonMove.defaultMessage });
userEvent.click(xblockMoveBtn);

const moveModalIFrame = getByTitle('xblock-move-modal-iframe');

await act(async () => window.dispatchEvent(new MessageEvent('message', iframePostMsg)));

expect(moveModalIFrame).not.toBeInTheDocument();
expect(getByText(messages.alertMoveSuccessTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(
messages.alertMoveSuccessDescription.defaultMessage
.replace('{title}', courseVerticalChildrenMock.children[0].name),
)).toBeInTheDocument();

await waitFor(() => {
userEvent.click(getByText(messages.newLocationButton.defaultMessage));
expect(mockedUsedNavigate).toHaveBeenCalledWith(`/course/${courseId}/container/${iframePostMsg.data.params.targetParentLocator}`);
});
});

it('should display move cancellation alert when undo move button is clicked', async () => {
const {
getByRole,
getAllByLabelText,
getByText,
} = render(<RootWrapper />);

const iframePostMsg = getIFramePostMessages('move_xblock');

axiosMock
.onGet(getCourseVerticalChildrenApiUrl(blockId))
.reply(200, courseVerticalChildrenMock);

await executeThunk(fetchCourseVerticalChildrenData(blockId), store.dispatch);

const [xblockActionBtn] = getAllByLabelText(courseXBlockMessages.blockActionsDropdownAlt.defaultMessage);
userEvent.click(xblockActionBtn);

const xblockMoveBtn = getByRole('button', { name: courseXBlockMessages.blockLabelButtonMove.defaultMessage });
userEvent.click(xblockMoveBtn);

await act(async () => window.dispatchEvent(new MessageEvent('message', iframePostMsg)));

await waitFor(() => userEvent.click(getByText(messages.undoMoveButton.defaultMessage)));

axiosMock
.onPatch(postXBlockBaseApiUrl(), {
parent_locator: blockId,
move_source_locator: courseVerticalChildrenMock.children[0].block_id,
})
.reply(200, {
parent_locator: blockId,
move_source_locator: courseVerticalChildrenMock.children[0].block_id,
});

await executeThunk(rollbackUnitItemQuery(blockId, courseVerticalChildrenMock.children[0].block_id, 'Discussion'), store.dispatch);

expect(getByText(messages.alertMoveCancelTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(
messages.alertMoveCancelDescription.defaultMessage
.replace('{title}', courseVerticalChildrenMock.children[0].name),
)).toBeInTheDocument();
});
});
});
35 changes: 35 additions & 0 deletions src/course-unit/course-xblock/CourseIFrame.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { forwardRef } from 'react';
import PropTypes from 'prop-types';

import { IFRAME_FEATURE_POLICY } from './constants';

const CourseIFrame = forwardRef(({ title, ...props }, ref) => (
<iframe
title={title}
// allowing 'autoplay' is required to allow the video XBlock to control the YouTube iframe it has.
allow={IFRAME_FEATURE_POLICY}
referrerPolicy="origin"
frameBorder={0}
scrolling="no"
ref={ref}
sandbox={[
'allow-forms',
'allow-modals',
'allow-popups',
'allow-popups-to-escape-sandbox',
'allow-presentation',
'allow-same-origin', // This is only secure IF the IFrame source
// is served from a completely different domain name
// e.g. labxchange-xblocks.net vs www.labxchange.org
'allow-scripts',
'allow-top-navigation-by-user-activation',
].join(' ')}
{...props}
/>
));

CourseIFrame.propTypes = {
title: PropTypes.string.isRequired,
};

export default CourseIFrame;
Loading

0 comments on commit ec0ae6b

Please sign in to comment.