Skip to content

Commit

Permalink
feat: copy & paste units
Browse files Browse the repository at this point in the history
refactor: paste component

fix: lint issues and delete unused hook

test: add test

fix: update api for npm broadcast channel
  • Loading branch information
navinkarkera committed Jan 31, 2024
1 parent 9a6ae9a commit 01b94bd
Show file tree
Hide file tree
Showing 20 changed files with 507 additions and 17 deletions.
69 changes: 64 additions & 5 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@
"@fortawesome/react-fontawesome": "0.2.0",
"@reduxjs/toolkit": "1.9.7",
"@tanstack/react-query": "4.36.1",
"broadcast-channel": "^7.0.0",
"classnames": "2.2.6",
"core-js": "3.8.1",
"email-validator": "2.0.4",
Expand Down
2 changes: 2 additions & 0 deletions src/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const NOTIFICATION_MESSAGES = {
saving: 'Saving',
duplicating: 'Duplicating',
deleting: 'Deleting',
copying: 'Copying',
pasting: 'Pasting',
empty: '',
};

Expand Down
4 changes: 4 additions & 0 deletions src/course-outline/CourseOutline.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ const CourseOutline = ({ courseId }) => {
handleSubsectionDragAndDrop,
handleVideoSharingOptionChange,
handleUnitDragAndDrop,
handleCopyToClipboardClick,
handlePasteClipboardClick,
} = useCourseOutline({ courseId });

const [sections, setSections] = useState(sectionsList);
Expand Down Expand Up @@ -351,6 +353,7 @@ const CourseOutline = ({ courseId }) => {
section,
section.childInfo.children,
)}
onPasteClick={handlePasteClipboardClick}
>
<DraggableList
itemList={subsection.childInfo.children}
Expand Down Expand Up @@ -381,6 +384,7 @@ const CourseOutline = ({ courseId }) => {
subsection,
subsection.childInfo.children,
)}
onCopyToClipboardClick={handleCopyToClipboardClick}
/>
))}
</DraggableList>
Expand Down
77 changes: 77 additions & 0 deletions src/course-outline/CourseOutline.test.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
getCourseBlockApiUrl,
getCourseItemApiUrl,
getXBlockBaseApiUrl,
getClipboardUrl,
} from './data/api';
import { RequestStatus } from '../data/constants';
import {
Expand All @@ -43,6 +44,8 @@ import cardHeaderMessages from './card-header/messages';
import enableHighlightsModalMessages from './enable-highlights-modal/messages';
import statusBarMessages from './status-bar/messages';
import configureModalMessages from './configure-modal/messages';
import pasteButtonMessages from './paste-button/messages';
import subsectionMessages from './subsection-card/messages';

let axiosMock;
let store;
Expand Down Expand Up @@ -1338,4 +1341,78 @@ describe('<CourseOutline />', () => {
expect(within(sectionElement).queryByText(section.displayName)).toBeInTheDocument();
});
});

it('check whether unit copy & paste option works correctly', async () => {
const { findAllByTestId } = render(<RootWrapper />);
// get first section -> first subsection -> first unit element
const [section] = courseOutlineIndexMock.courseStructure.childInfo.children;
const [sectionElement] = await findAllByTestId('section-card');
const [subsection] = section.childInfo.children;
let [subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const expandBtn = await within(subsectionElement).findByTestId('subsection-card-header__expanded-btn');
await act(async () => fireEvent.click(expandBtn));
const [unit] = subsection.childInfo.children;
const [unitElement] = await within(subsectionElement).findAllByTestId('unit-card');

const expectedClipboardContent = {
content: {
blockType: 'vertical',
blockTypeDisplay: 'Unit',
created: '2024-01-29T07:58:36.844249Z',
displayName: unit.displayName,
id: 15,
olxUrl: 'http://localhost:18010/api/content-staging/v1/staged-content/15/olx',
purpose: 'clipboard',
status: 'ready',
userId: 3,
},
sourceUsageKey: unit.id,
sourceContexttitle: courseOutlineIndexMock.courseStructure.displayName,
sourceEditUrl: unit.studioUrl,
};
// mock api call
axiosMock
.onPost(getClipboardUrl(), {
usage_key: unit.id,
}).reply(200, expectedClipboardContent);
// check that initialUserClipboard state is empty
const { initialUserClipboard } = store.getState().courseOutline;
expect(initialUserClipboard).toBeUndefined();

// find menu button and click on it to open menu
const menu = await within(unitElement).findByTestId('unit-card-header__menu-button');
await act(async () => fireEvent.click(menu));

// move first unit back to second position to test move down option
const copyButton = await within(unitElement).findByText(cardHeaderMessages.menuCopy.defaultMessage);
await act(async () => fireEvent.click(copyButton));

// check that initialUserClipboard state is updated
expect(store.getState().courseOutline.initialUserClipboard).toEqual(expectedClipboardContent);

[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
// find clipboard content label
const clipboardLabel = await within(subsectionElement).findByText(
pasteButtonMessages.clipboardContentLabel.defaultMessage,
);
await act(async () => fireEvent.mouseOver(clipboardLabel));

// find clipboard content popup link
const clipboardPopover = await within(subsectionElement).findByRole('tooltip');
expect(clipboardPopover.querySelector('a')).toHaveAttribute('href', unit.studioUrl);

// check paste button functionality
// mock api call
axiosMock
.onPost(getXBlockBaseApiUrl(), {
parent_locator: subsection.id,
staged_content: 'clipboard',
}).reply(200, { dummy: 'value' });
const pasteBtn = await within(subsectionElement).findByText(subsectionMessages.pasteButton.defaultMessage);
await act(async () => fireEvent.click(pasteBtn));

[subsectionElement] = await within(sectionElement).findAllByTestId('subsection-card');
const lastUnitElement = (await within(subsectionElement).findAllByTestId('unit-card')).slice(-1)[0];
expect(lastUnitElement).toHaveTextContent(unit.displayName);
});
});
4 changes: 3 additions & 1 deletion src/course-outline/__mocks__/courseOutlineIndex.js
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ module.exports = {
ancestorHasStaffLock: true,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
enableCopyPasteUnits: true,
userPartitionInfo: {
selectablePartitions: [
{
Expand Down Expand Up @@ -292,6 +293,7 @@ module.exports = {
ancestorHasStaffLock: true,
staffOnlyMessage: false,
hasPartitionGroupComponents: false,
enableCopyPasteUnits: true,
userPartitionInfo: {
selectablePartitions: [
{
Expand Down Expand Up @@ -391,7 +393,7 @@ module.exports = {
},
ancestor_has_staff_lock: false,
staff_only_message: false,
enable_copy_paste_units: false,
enable_copy_paste_units: true,
has_partition_group_components: false,
user_partition_info: {
selectable_partitions: [
Expand Down
17 changes: 17 additions & 0 deletions src/course-outline/card-header/CardHeader.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,12 @@ const CardHeader = ({
onClickDuplicate,
onClickMoveUp,
onClickMoveDown,
onClickCopy,
titleComponent,
namePrefix,
actions,
enableCopyPasteUnits,
isVertical,
}) => {
const intl = useIntl();
const [titleValue, setTitleValue] = useState(title);
Expand Down Expand Up @@ -106,6 +109,11 @@ const CardHeader = ({
>
{intl.formatMessage(messages.menuConfigure)}
</Dropdown.Item>
{isVertical && enableCopyPasteUnits && (
<Dropdown.Item onClick={onClickCopy}>
{intl.formatMessage(messages.menuCopy)}
</Dropdown.Item>
)}
{actions.duplicable && (
<Dropdown.Item
data-testid={`${namePrefix}-card-header__menu-duplicate-button`}
Expand Down Expand Up @@ -148,6 +156,12 @@ const CardHeader = ({
);
};

CardHeader.defaultProps = {
enableCopyPasteUnits: false,
isVertical: false,
onClickCopy: null,
};

CardHeader.propTypes = {
title: PropTypes.string.isRequired,
status: PropTypes.string.isRequired,
Expand All @@ -164,6 +178,7 @@ CardHeader.propTypes = {
onClickDuplicate: PropTypes.func.isRequired,
onClickMoveUp: PropTypes.func.isRequired,
onClickMoveDown: PropTypes.func.isRequired,
onClickCopy: PropTypes.func,
titleComponent: PropTypes.node.isRequired,
namePrefix: PropTypes.string.isRequired,
actions: PropTypes.shape({
Expand All @@ -174,6 +189,8 @@ CardHeader.propTypes = {
allowMoveUp: PropTypes.bool,
allowMoveDown: PropTypes.bool,
}).isRequired,
enableCopyPasteUnits: PropTypes.bool,
isVertical: PropTypes.bool,
};

export default CardHeader;
4 changes: 4 additions & 0 deletions src/course-outline/card-header/messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,10 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.card.menu.delete',
defaultMessage: 'Delete',
},
menuCopy: {
id: 'course-authoring.course-outline.card.menu.delete',
defaultMessage: 'Copy to clipboard',
},
});

export default messages;
30 changes: 30 additions & 0 deletions src/course-outline/data/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export const getCourseReindexApiUrl = (reindexLink) => `${getApiBaseUrl()}${rein
export const getXBlockBaseApiUrl = () => `${getApiBaseUrl()}/xblock/`;
export const getCourseItemApiUrl = (itemId) => `${getXBlockBaseApiUrl()}${itemId}`;
export const getXBlockApiUrl = (blockId) => `${getXBlockBaseApiUrl()}outline/${blockId}`;
export const getClipboardUrl = () => `${getApiBaseUrl()}/api/content-staging/v1/clipboard/`;

/**
* @typedef {Object} courseOutline
Expand Down Expand Up @@ -412,3 +413,32 @@ export async function setVideoSharingOption(courseId, videoSharingOption) {

return data;
}

/**
* Copy block to clipboard
* @param {string} usageKey
* @returns {Promise<Object>}
*/
export async function copyBlockToClipboard(usageKey) {
const { data } = await getAuthenticatedHttpClient()
.post(getClipboardUrl(), {
usage_key: usageKey,
});

return camelCaseObject(data);
}

/**
* Paste block to clipboard
* @param {string} parentLocator
* @returns {Promise<Object>}
*/
export async function pasteBlock(parentLocator) {
const { data } = await getAuthenticatedHttpClient()
.post(getXBlockBaseApiUrl(), {
parent_locator: parentLocator,
staged_content: 'clipboard',
});

return data;
}
1 change: 1 addition & 0 deletions src/course-outline/data/selectors.js
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ export const getCurrentSection = (state) => state.courseOutline.currentSection;
export const getCurrentSubsection = (state) => state.courseOutline.currentSubsection;
export const getCourseActions = (state) => state.courseOutline.actions;
export const getCustomRelativeDatesActiveFlag = (state) => state.courseOutline.isCustomRelativeDatesActive;
export const getInitialUserClipboard = (state) => state.courseOutline.initialUserClipboard;
Loading

0 comments on commit 01b94bd

Please sign in to comment.