feat: video sharing option dropdown (#779)

* feat: video sharing option dropdown

* test: video sharing option

* fix: lint issues

* refactor: messages for video sharing options

* test: add failure test for video sharing

* refactor: rename course block api url
This commit is contained in:
Navin Karkera
2024-01-16 21:20:16 +05:30
committed by GitHub
parent b59ecafc83
commit 008d619236
12 changed files with 236 additions and 16 deletions

View File

@@ -92,6 +92,7 @@ const CourseOutline = ({ courseId }) => {
handleNewUnitSubmit,
getUnitUrl,
handleDragNDrop,
handleVideoSharingOptionChange,
} = useCourseOutline({ courseId });
const [sections, setSections] = useState(sectionsList);
@@ -177,6 +178,7 @@ const CourseOutline = ({ courseId }) => {
isLoading={isLoading}
statusBarData={statusBarData}
openEnableHighlightsModal={openEnableHighlightsModal}
handleVideoSharingOptionChange={handleVideoSharingOptionChange}
/>
<div className="pt-4">
{sections.length ? (

View File

@@ -14,7 +14,7 @@ import {
getCourseOutlineIndexApiUrl,
getCourseReindexApiUrl,
getXBlockApiUrl,
getEnableHighlightsEmailsApiUrl,
getCourseBlockApiUrl,
getCourseItemApiUrl,
getXBlockBaseApiUrl,
} from './data/api';
@@ -36,12 +36,13 @@ import {
courseSubsectionMock,
} from './__mocks__';
import { executeThunk } from '../utils';
import { COURSE_BLOCK_NAMES } from './constants';
import { COURSE_BLOCK_NAMES, VIDEO_SHARING_OPTIONS } from './constants';
import CourseOutline from './CourseOutline';
import messages from './messages';
import headerMessages from './header-navigations/messages';
import cardHeaderMessages from './card-header/messages';
import enableHighlightsModalMessages from './enable-highlights-modal/messages';
import statusBarMessages from './status-bar/messages';
let axiosMock;
let store;
@@ -114,6 +115,60 @@ describe('<CourseOutline />', () => {
expect(await findByText(messages.alertSuccessDescription.defaultMessage)).toBeInTheDocument();
});
it('check video sharing option udpates correctly', async () => {
const { findByTestId } = render(<RootWrapper />);
axiosMock
.onPost(getCourseBlockApiUrl(courseId), {
metadata: {
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
},
})
.reply(200);
const optionDropdownWrapper = await findByTestId('video-sharing-wrapper');
const optionDropdown = await within(optionDropdownWrapper).findByRole('button');
await act(async () => fireEvent.click(optionDropdown));
const allOffOption = await within(optionDropdownWrapper).findByText(
statusBarMessages.videoSharingAllOffText.defaultMessage,
);
await act(async () => fireEvent.click(allOffOption));
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
metadata: {
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
},
}));
});
it('check video sharing option shows error on failure', async () => {
const { findByTestId, queryByRole } = render(<RootWrapper />);
axiosMock
.onPost(getCourseBlockApiUrl(courseId), {
metadata: {
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
},
})
.reply(500);
const optionDropdownWrapper = await findByTestId('video-sharing-wrapper');
const optionDropdown = await within(optionDropdownWrapper).findByRole('button');
await act(async () => fireEvent.click(optionDropdown));
const allOffOption = await within(optionDropdownWrapper).findByText(
statusBarMessages.videoSharingAllOffText.defaultMessage,
);
await act(async () => fireEvent.click(allOffOption));
expect(axiosMock.history.post.length).toBe(1);
expect(axiosMock.history.post[0].data).toBe(JSON.stringify({
metadata: {
video_sharing_options: VIDEO_SHARING_OPTIONS.allOff,
},
}));
expect(queryByRole('alert')).toBeInTheDocument();
});
it('render error alert after failed reindex correctly', async () => {
const { findByText, findByTestId } = render(<RootWrapper />);
@@ -235,7 +290,7 @@ describe('<CourseOutline />', () => {
axiosMock.reset();
axiosMock
.onPost(getEnableHighlightsEmailsApiUrl(courseId), {
.onPost(getCourseBlockApiUrl(courseId), {
publish: 'republish',
metadata: {
highlights_enabled_for_messaging: true,
@@ -641,7 +696,7 @@ describe('<CourseOutline />', () => {
children = children.splice(2, 0, children.splice(0, 1)[0]);
axiosMock
.onPut(getEnableHighlightsEmailsApiUrl(courseBlockId), { children })
.onPut(getCourseBlockApiUrl(courseBlockId), { children })
.reply(200, { dummy: 'value' });
await executeThunk(setSectionOrderListQuery(courseBlockId, children, () => {}), store.dispatch);
@@ -662,7 +717,7 @@ describe('<CourseOutline />', () => {
const newChildren = children.splice(2, 0, children.splice(0, 1)[0]);
axiosMock
.onPut(getEnableHighlightsEmailsApiUrl(courseBlockId), { children })
.onPut(getCourseBlockApiUrl(courseBlockId), { children })
.reply(500);
await executeThunk(setSectionOrderListQuery(courseBlockId, undefined, () => children), store.dispatch);

View File

@@ -24,6 +24,8 @@ module.exports = {
'Homework',
'Exam',
],
videoSharingEnabled: true,
videoSharingOptions: 'per-video',
hasChanges: false,
actions: {
deletable: true,

View File

@@ -74,3 +74,9 @@ export const BEST_PRACTICES_CHECKLIST = /** @type {const} */ ({
},
],
});
export const VIDEO_SHARING_OPTIONS = /** @type {const} */ ({
perVideo: 'per-video',
allOn: 'all-on',
allOff: 'all-off',
});

View File

@@ -19,7 +19,7 @@ export const getCourseLaunchApiUrl = ({
all,
}) => `${getApiBaseUrl()}/api/courses/v1/validation/${courseId}/?graded_only=${gradedOnly}&validate_oras=${validateOras}&all=${all}`;
export const getEnableHighlightsEmailsApiUrl = (courseId) => {
export const getCourseBlockApiUrl = (courseId) => {
const formattedCourseId = courseId.split('course-v1:')[1];
return `${getApiBaseUrl()}/xblock/block-v1:${formattedCourseId}+type@course+block@course`;
};
@@ -112,7 +112,7 @@ export async function getCourseLaunch({
*/
export async function enableCourseHighlightsEmails(courseId) {
const { data } = await getAuthenticatedHttpClient()
.post(getEnableHighlightsEmailsApiUrl(courseId), {
.post(getCourseBlockApiUrl(courseId), {
publish: 'republish',
metadata: {
highlights_enabled_for_messaging: true,
@@ -305,9 +305,26 @@ export async function addNewCourseItem(parentLocator, category, displayName) {
*/
export async function setSectionOrderList(courseId, children) {
const { data } = await getAuthenticatedHttpClient()
.put(getEnableHighlightsEmailsApiUrl(courseId), {
.put(getCourseBlockApiUrl(courseId), {
children,
});
return data;
}
/**
* Set video sharing setting
* @param {string} courseId
* @param {string} videoSharingOption
* @returns {Promise<Object>}
*/
export async function setVideoSharingOption(courseId, videoSharingOption) {
const { data } = await getAuthenticatedHttpClient()
.post(getCourseBlockApiUrl(courseId), {
metadata: {
video_sharing_options: videoSharingOption,
},
});
return data;
}

View File

@@ -1,6 +1,7 @@
/* eslint-disable no-param-reassign */
import { createSlice } from '@reduxjs/toolkit';
import { VIDEO_SHARING_OPTIONS } from '../constants';
import { RequestStatus } from '../../data/constants';
const slice = createSlice({
@@ -23,6 +24,8 @@ const slice = createSlice({
totalCourseBestPracticesChecks: 0,
completedCourseBestPracticesChecks: 0,
},
videoSharingEnabled: false,
videoSharingOptions: VIDEO_SHARING_OPTIONS.perVideo,
},
sectionsList: [],
currentSection: {},

View File

@@ -24,6 +24,7 @@ import {
restartIndexingOnCourse,
updateCourseSectionHighlights,
setSectionOrderList,
setVideoSharingOption,
} from './api';
import {
addSection,
@@ -50,9 +51,21 @@ export function fetchCourseOutlineIndexQuery(courseId) {
try {
const outlineIndex = await getCourseOutlineIndex(courseId);
const { courseReleaseDate, courseStructure: { highlightsEnabledForMessaging } } = outlineIndex;
const {
courseReleaseDate,
courseStructure: {
highlightsEnabledForMessaging,
videoSharingEnabled,
videoSharingOptions,
},
} = outlineIndex;
dispatch(fetchOutlineIndexSuccess(outlineIndex));
dispatch(updateStatusBar({ courseReleaseDate, highlightsEnabledForMessaging }));
dispatch(updateStatusBar({
courseReleaseDate,
highlightsEnabledForMessaging,
videoSharingOptions,
videoSharingEnabled,
}));
dispatch(updateOutlineIndexLoadingStatus({ status: RequestStatus.SUCCESSFUL }));
} catch (error) {
@@ -116,6 +129,24 @@ export function enableCourseHighlightsEmailsQuery(courseId) {
};
}
export function setVideoSharingOptionQuery(courseId, option) {
return async (dispatch) => {
dispatch(updateSavingStatus({ status: RequestStatus.PENDING }));
dispatch(showProcessingNotification(NOTIFICATION_MESSAGES.saving));
try {
await setVideoSharingOption(courseId, option);
dispatch(updateStatusBar({ videoSharingOptions: option }));
dispatch(updateSavingStatus({ status: RequestStatus.SUCCESSFUL }));
dispatch(hideProcessingNotification());
} catch (error) {
dispatch(updateSavingStatus({ status: RequestStatus.FAILED }));
dispatch(hideProcessingNotification());
}
};
}
export function fetchCourseReindexQuery(courseId, reindexLink) {
return async (dispatch) => {
dispatch(updateReindexLoadingStatus({ status: RequestStatus.IN_PROGRESS }));

View File

@@ -41,6 +41,7 @@ import {
updateCourseSectionHighlightsQuery,
configureCourseSectionQuery,
setSectionOrderListQuery,
setVideoSharingOptionQuery,
} from './data/thunk';
const useCourseOutline = ({ courseId }) => {
@@ -186,6 +187,10 @@ const useCourseOutline = ({ courseId }) => {
dispatch(setSectionOrderListQuery(courseId, newListId, restoreCallback));
};
const handleVideoSharingOptionChange = (value) => {
dispatch(setVideoSharingOptionQuery(courseId, value));
};
useEffect(() => {
dispatch(fetchCourseOutlineIndexQuery(courseId));
dispatch(fetchCourseBestPracticesQuery({ courseId }));
@@ -246,6 +251,7 @@ const useCourseOutline = ({ courseId }) => {
openUnitPage,
handleNewUnitSubmit,
handleDragNDrop,
handleVideoSharingOptionChange,
};
};

View File

@@ -1,17 +1,22 @@
import React, { useContext } from 'react';
import PropTypes from 'prop-types';
import { useIntl } from '@edx/frontend-platform/i18n';
import { Button, Hyperlink, Stack } from '@edx/paragon';
import {
Button, Hyperlink, SelectMenu, MenuItem, Stack,
} from '@edx/paragon';
import { AppContext } from '@edx/frontend-platform/react';
import { useHelpUrls } from '../../help-urls/hooks';
import { VIDEO_SHARING_OPTIONS } from '../constants';
import messages from './messages';
import { getVideoSharingOptionText } from '../utils';
const StatusBar = ({
statusBarData,
isLoading,
courseId,
openEnableHighlightsModal,
handleVideoSharingOptionChange,
}) => {
const intl = useIntl();
const { config } = useContext(AppContext);
@@ -21,6 +26,8 @@ const StatusBar = ({
highlightsEnabledForMessaging,
checklist,
isSelfPaced,
videoSharingEnabled,
videoSharingOptions,
} = statusBarData;
const {
@@ -36,7 +43,8 @@ const StatusBar = ({
const {
contentHighlights: contentHighlightsUrl,
} = useHelpUrls(['contentHighlights']);
socialSharing: socialSharingUrl,
} = useHelpUrls(['contentHighlights', 'socialSharing']);
if (isLoading) {
// eslint-disable-next-line react/jsx-no-useless-fragment
@@ -95,6 +103,36 @@ const StatusBar = ({
</Hyperlink>
</div>
</div>
{videoSharingEnabled && (
<div
data-testid="video-sharing-wrapper"
className="outline-status-bar__item ml-2"
>
<h5>{intl.formatMessage(messages.videoSharingTitle)}</h5>
<div className="d-flex align-items-end">
<SelectMenu variant="sm btn-outline-primary">
{Object.values(VIDEO_SHARING_OPTIONS).map((option) => (
<MenuItem
key={option}
value={option}
defaultSelected={option === videoSharingOptions}
onClick={() => handleVideoSharingOptionChange(option)}
>
{getVideoSharingOptionText(option, messages, intl)}
</MenuItem>
))}
</SelectMenu>
<Hyperlink
className="small ml-2"
destination={socialSharingUrl}
target="_blank"
showLaunchIcon={false}
>
{intl.formatMessage(messages.videoSharingLink)}
</Hyperlink>
</div>
</div>
)}
</Stack>
);
};
@@ -103,6 +141,7 @@ StatusBar.propTypes = {
courseId: PropTypes.string.isRequired,
isLoading: PropTypes.bool.isRequired,
openEnableHighlightsModal: PropTypes.func.isRequired,
handleVideoSharingOptionChange: PropTypes.func.isRequired,
statusBarData: PropTypes.shape({
courseReleaseDate: PropTypes.string.isRequired,
isSelfPaced: PropTypes.bool.isRequired,
@@ -113,6 +152,8 @@ StatusBar.propTypes = {
completedCourseBestPracticesChecks: PropTypes.number.isRequired,
}),
highlightsEnabledForMessaging: PropTypes.bool.isRequired,
videoSharingEnabled: PropTypes.bool.isRequired,
videoSharingOptions: PropTypes.string.isRequired,
}).isRequired,
};

View File

@@ -7,12 +7,14 @@ import { initializeMockApp } from '@edx/frontend-platform';
import StatusBar from './StatusBar';
import messages from './messages';
import initializeStore from '../../store';
import { VIDEO_SHARING_OPTIONS } from '../constants';
let store;
const mockPathname = '/foo-bar';
const courseId = '123';
const isLoading = false;
const openEnableHighlightsModalMock = jest.fn();
const handleVideoSharingOptionChange = jest.fn();
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
@@ -23,7 +25,8 @@ jest.mock('react-router-dom', () => ({
jest.mock('../../help-urls/hooks', () => ({
useHelpUrls: () => ({
contentHighlights: 'some',
contentHighlights: 'content-highlights-link',
socialSharing: 'social-sharing-link',
}),
}));
@@ -38,6 +41,8 @@ const statusBarData = {
},
highlightsEnabledForMessaging: true,
highlightsDocUrl: 'https://example.com/highlights-doc',
videoSharingEnabled: true,
videoSharingOptions: VIDEO_SHARING_OPTIONS.allOn,
};
const renderComponent = (props) => render(
@@ -47,6 +52,7 @@ const renderComponent = (props) => render(
courseId={courseId}
isLoading={isLoading}
openEnableHighlightsModal={openEnableHighlightsModalMock}
handleVideoSharingOptionChange={handleVideoSharingOptionChange}
statusBarData={statusBarData}
{...props}
/>
@@ -68,7 +74,7 @@ describe('<StatusBar />', () => {
});
it('renders StatusBar component correctly', () => {
const { getByText } = renderComponent();
const { queryByTestId, getByText } = renderComponent();
expect(getByText(messages.startDateTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(statusBarData.courseReleaseDate)).toBeInTheDocument();
@@ -80,8 +86,9 @@ describe('<StatusBar />', () => {
expect(getByText(`2/9 ${messages.checklistCompleted.defaultMessage}`)).toBeInTheDocument();
expect(getByText(messages.highlightEmailsTitle.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.highlightEmailsLink.defaultMessage)).toBeInTheDocument();
expect(getByText(messages.highlightEmailsEnabled.defaultMessage)).toBeInTheDocument();
expect(queryByTestId('video-sharing-wrapper')).toBeInTheDocument();
});
it('renders StatusBar when isSelfPaced is false', () => {
@@ -115,4 +122,15 @@ describe('<StatusBar />', () => {
expect(queryByTestId('outline-status-bar')).not.toBeInTheDocument();
});
it('does not render video sharing dropdown if not enabled', () => {
const { queryByTestId } = renderComponent({
statusBarData: {
...statusBarData,
videoSharingEnabled: false,
},
});
expect(queryByTestId('video-sharing-wrapper')).not.toBeInTheDocument();
});
});

View File

@@ -41,6 +41,26 @@ const messages = defineMessages({
id: 'course-authoring.course-outline.status-bar.highlight-emails.link',
defaultMessage: 'Learn more',
},
videoSharingTitle: {
id: 'course-authoring.course-outline.status-bar.video-sharing.title',
defaultMessage: 'Video Sharing',
},
videoSharingLink: {
id: 'course-authoring.course-outline.status-bar.video-sharing.title',
defaultMessage: 'Learn more',
},
videoSharingPerVideoText: {
id: 'course-authoring.course-outline.status-bar.video-sharing.perVideo.text',
defaultMessage: 'Per Video',
},
videoSharingAllOffText: {
id: 'course-authoring.course-outline.status-bar.video-sharing.allOff.text',
defaultMessage: 'No Videos',
},
videoSharingAllOnText: {
id: 'course-authoring.course-outline.status-bar.video-sharing.allOn.text',
defaultMessage: 'All Videos',
},
});
export default messages;

View File

@@ -4,7 +4,7 @@ import {
EditOutline as EditOutlineIcon,
} from '@edx/paragon/icons';
import { ITEM_BADGE_STATUS, STAFF_ONLY } from './constants';
import { ITEM_BADGE_STATUS, STAFF_ONLY, VIDEO_SHARING_OPTIONS } from './constants';
/**
* Get section status depended on section info
@@ -128,9 +128,28 @@ const scrollToElement = target => {
}
};
/**
* Get video sharing dropdown translated options.
* @param {string} id - option id
* @returns {string} - text to display
*/
const getVideoSharingOptionText = (id, messages, intl) => {
switch (id) {
case VIDEO_SHARING_OPTIONS.perVideo:
return intl.formatMessage(messages.videoSharingPerVideoText);
case VIDEO_SHARING_OPTIONS.allOn:
return intl.formatMessage(messages.videoSharingAllOnText);
case VIDEO_SHARING_OPTIONS.allOff:
return intl.formatMessage(messages.videoSharingAllOffText);
default:
return '';
}
};
export {
getItemStatus,
getItemStatusBadgeContent,
getHighlightsFormValues,
getVideoSharingOptionText,
scrollToElement,
};