From 5f5a7c576d970e725d798328ea74e9639f088ec7 Mon Sep 17 00:00:00 2001 From: Maham Akif <113524403+mahamakifdar19@users.noreply.github.com> Date: Tue, 24 Sep 2024 13:13:25 +0500 Subject: [PATCH] feat: add video feedback component to track user feedback via segment events (#1195) Co-authored-by: Maham Akif --- .../microlearning/VideoDetailPage.jsx | 13 +- .../microlearning/VideoFeedbackCard.jsx | 229 ++++++++++++++++++ src/components/microlearning/constants.js | 15 ++ src/components/microlearning/data/utils.js | 1 + .../microlearning/styles/VideoDetailPage.scss | 3 +- .../tests/VideoFeedbackCard.test.jsx | 135 +++++++++++ 6 files changed, 393 insertions(+), 3 deletions(-) create mode 100644 src/components/microlearning/VideoFeedbackCard.jsx create mode 100644 src/components/microlearning/constants.js create mode 100644 src/components/microlearning/tests/VideoFeedbackCard.test.jsx diff --git a/src/components/microlearning/VideoDetailPage.jsx b/src/components/microlearning/VideoDetailPage.jsx index 58e66bcb8b..fd78bd9bfd 100644 --- a/src/components/microlearning/VideoDetailPage.jsx +++ b/src/components/microlearning/VideoDetailPage.jsx @@ -26,6 +26,7 @@ import { hasTruthyValue, isDefinedAndNotNull } from '../../utils/common'; import { getLevelType } from './data/utils'; import { hasActivatedAndCurrentSubscription } from '../search/utils'; import { features } from '../../config'; +import VideoFeedbackCard from './VideoFeedbackCard'; const VideoPlayer = loadable(() => import(/* webpackChunkName: "videojs" */ '../video/VideoPlayer'), { fallback: ( @@ -171,7 +172,7 @@ const VideoDetailPage = () => { )} {isDefinedAndNotNull(courseMetadata.activeCourseRun) && ( -
+

{

)} +
+
+ +
+
); diff --git a/src/components/microlearning/VideoFeedbackCard.jsx b/src/components/microlearning/VideoFeedbackCard.jsx new file mode 100644 index 0000000000..ff414b4f3c --- /dev/null +++ b/src/components/microlearning/VideoFeedbackCard.jsx @@ -0,0 +1,229 @@ +import React, { useState, useEffect } from 'react'; +import PropTypes from 'prop-types'; +import { + Card, Icon, IconButton, ActionRow, Form, Input, Button, +} from '@openedx/paragon'; +import { FormattedMessage, useIntl } from '@edx/frontend-platform/i18n'; +import { + ThumbUpOutline, ThumbUp, ThumbDownOffAlt, ThumbDownAlt, Close, +} from '@openedx/paragon/icons'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import { VIDEO_FEEDBACK_CARD, VIDEO_FEEDBACK_SUBMITTED_LOCALSTORAGE_KEY } from './constants'; + +const VideoFeedbackCard = ({ + videoId, courseRunKey, enterpriseCustomerUuid, videoUsageKey, +}) => { + const intl = useIntl(); + const [response, setResponse] = useState(null); + const [selectedOptions, setSelectedOptions] = useState([]); + const [comments, setComments] = useState(''); + const [showFeedbackCard, setShowFeedbackCard] = useState(true); + const [showFeedbackSubmittedCard, setShowFeedbackSubmittedCard] = useState(false); + const feedbackLocalStorageKey = VIDEO_FEEDBACK_SUBMITTED_LOCALSTORAGE_KEY(videoId); + + // On component mount, check if feedback has been previously submitted + useEffect(() => { + const feedbackSubmitted = localStorage.getItem(feedbackLocalStorageKey); + if (feedbackSubmitted === 'true') { + setShowFeedbackCard(false); + setShowFeedbackSubmittedCard(true); + } + }, [feedbackLocalStorageKey]); + + const handleThumbClick = (feedbackResponse) => { + setResponse(feedbackResponse); + if (feedbackResponse) { + setShowFeedbackCard(false); + setShowFeedbackSubmittedCard(true); + } + sendEnterpriseTrackEvent( + enterpriseCustomerUuid, + 'edx.ui.enterprise.learner_portal.video.feedback.thumb.submitted', + { + videoId, + courseRunKey, + video_usage_key: videoUsageKey, + prompt: VIDEO_FEEDBACK_CARD.prompt, + response: feedbackResponse, + }, + ); + localStorage.setItem(feedbackLocalStorageKey, 'true'); + }; + + const handleSubmitFeedback = () => { + sendEnterpriseTrackEvent( + enterpriseCustomerUuid, + 'edx.ui.enterprise.learner_portal.video.feedback.response.submitted', + { + videoId, + courseRunKey, + video_usage_key: videoUsageKey, + prompt: VIDEO_FEEDBACK_CARD.prompt, + response, + selectedOptions, + comments, + }, + ); + setShowFeedbackSubmittedCard(true); + setShowFeedbackCard(false); + }; + + return ( + <> + {showFeedbackCard && ( + + + + + )} + actions={( + + handleThumbClick(true)} + variant="dark" + className="border rounded-circle border-1 border-light-400 p-3 mr-2" + aria-label="thumbs up" + /> + handleThumbClick(false)} + variant="dark" + className="border rounded-circle border-1 border-light-400 p-3 mr-2" + aria-label="thumbs down" + /> +
+ setShowFeedbackCard(false)} + /> + + )} + size="sm" + /> + {/* Display additional options when the user selects thumbs down (negative feedback)." */} + {response === false && ( + +
+ +
+ + {VIDEO_FEEDBACK_CARD.options.map((option) => ( +
+ { + const { checked } = e.target; + setSelectedOptions((prevOptions) => (checked + ? [...prevOptions, option] + : prevOptions.filter(opt => opt !== option))); + }} + > + + +
+ ))} +
+ setComments(e.target.value)} + /> + +
+ )} + + )} + {showFeedbackSubmittedCard && ( + + + + + )} + actions={( + + { + setShowFeedbackSubmittedCard(false); + }} + /> + + )} + size="sm" + /> + + + + + )} + + ); +}; + +VideoFeedbackCard.propTypes = { + videoId: PropTypes.string.isRequired, + courseRunKey: PropTypes.string.isRequired, + enterpriseCustomerUuid: PropTypes.string.isRequired, + videoUsageKey: PropTypes.string.isRequired, +}; + +export default VideoFeedbackCard; diff --git a/src/components/microlearning/constants.js b/src/components/microlearning/constants.js new file mode 100644 index 0000000000..c4514e8e58 --- /dev/null +++ b/src/components/microlearning/constants.js @@ -0,0 +1,15 @@ +export const VIDEO_FEEDBACK_CARD = { + prompt: 'Was this page helpful?', + additionalDetailsLabel: 'Any additional details? Select all that apply:', + options: [ + 'Videos are hard to find or navigate', + 'Video wasn’t relevant', + 'Video was low quality or confusing', + ], + inputPlaceholder: 'Type comments (optional)', + submitButton: 'Submit feedback', + thankYouMessage: 'Thank you!', + feedbackSentMessage: 'Your feedback has been sent to the edX research team!', +}; + +export const VIDEO_FEEDBACK_SUBMITTED_LOCALSTORAGE_KEY = (videoId) => (`${videoId}-feedbackSubmitted`); diff --git a/src/components/microlearning/data/utils.js b/src/components/microlearning/data/utils.js index 5851c43f85..b8cf51b0af 100644 --- a/src/components/microlearning/data/utils.js +++ b/src/components/microlearning/data/utils.js @@ -18,6 +18,7 @@ export const formatSkills = (skills) => skills?.map(skill => ({ })); export const transformVideoData = (data) => ({ + edxVideoId: data?.edx_video_id, videoUrl: data?.json_metadata?.download_link, courseTitle: data?.title || data?.parent_content_metadata?.title, videoSummary: data?.summary_transcripts?.[0], diff --git a/src/components/microlearning/styles/VideoDetailPage.scss b/src/components/microlearning/styles/VideoDetailPage.scss index dc47148a14..d6d1e61f16 100644 --- a/src/components/microlearning/styles/VideoDetailPage.scss +++ b/src/components/microlearning/styles/VideoDetailPage.scss @@ -3,7 +3,7 @@ gap: 16px; flex: 1 0 0; } - + /* Custom CSS is necessary here because we are using a custom video.js plugin - videojs-vjstranscribe The elements of this plugin need to be customized to cater to the hidden classes and other specific @@ -29,7 +29,6 @@ .video-player-container-with-transcript { display: flex; - padding-bottom: 55px; } .video-js-wrapper { diff --git a/src/components/microlearning/tests/VideoFeedbackCard.test.jsx b/src/components/microlearning/tests/VideoFeedbackCard.test.jsx new file mode 100644 index 0000000000..97f0857da6 --- /dev/null +++ b/src/components/microlearning/tests/VideoFeedbackCard.test.jsx @@ -0,0 +1,135 @@ +import '@testing-library/jest-dom/extend-expect'; +import { screen, waitFor } from '@testing-library/react'; +import { AppContext } from '@edx/frontend-platform/react'; +import { IntlProvider } from '@edx/frontend-platform/i18n'; +import userEvent from '@testing-library/user-event'; +import { sendEnterpriseTrackEvent } from '@edx/frontend-enterprise-utils'; +import { renderWithRouter } from '../../../utils/tests'; +import VideoFeedbackCard from '../VideoFeedbackCard'; +import { VIDEO_FEEDBACK_CARD } from '../constants'; + +jest.mock('@edx/frontend-enterprise-utils', () => ({ + sendEnterpriseTrackEvent: jest.fn(), +})); + +describe('VideoFeedbackCard', () => { + const mockEnterpriseCustomerUuid = 'mock-uuid'; + const mockVideoData = { + videoId: 'test-video-id', + courseRunKey: 'test-course-key', + videoUsageKey: 'test-usage-key', + }; + + const VideoFeedbackCardWrapper = () => ( + + + + + + ); + + beforeEach(() => { + localStorage.clear(); + jest.clearAllMocks(); + }); + + it('renders the feedback prompt', () => { + renderWithRouter(); + + expect(screen.getByText(VIDEO_FEEDBACK_CARD.prompt)).toBeInTheDocument(); + expect(screen.getByLabelText('thumbs up')).toBeInTheDocument(); + expect(screen.getByLabelText('thumbs down')).toBeInTheDocument(); + }); + + it('handles thumb up click and submits feedback', async () => { + renderWithRouter(); + + const thumbUpButton = screen.getByLabelText('thumbs up'); + userEvent.click(thumbUpButton); + + expect(sendEnterpriseTrackEvent).toHaveBeenCalledWith( + mockEnterpriseCustomerUuid, + 'edx.ui.enterprise.learner_portal.video.feedback.thumb.submitted', + { + videoId: mockVideoData.videoId, + courseRunKey: mockVideoData.courseRunKey, + video_usage_key: mockVideoData.videoUsageKey, + prompt: VIDEO_FEEDBACK_CARD.prompt, + response: true, + }, + ); + }); + + it('handles thumb down click and renders additional options', async () => { + renderWithRouter(); + + const thumbDownButton = screen.getByLabelText('thumbs down'); + userEvent.click(thumbDownButton); + + await waitFor(() => { + expect(screen.getByText(VIDEO_FEEDBACK_CARD.additionalDetailsLabel)).toBeInTheDocument(); + }); + + const firstCheckbox = screen.getByRole('checkbox', { + name: VIDEO_FEEDBACK_CARD.options[0], + }); + userEvent.click(firstCheckbox); + expect(firstCheckbox).toBeChecked(); + }); + + it('submits feedback with additional options and comments', async () => { + renderWithRouter(); + + const thumbDownButton = screen.getByLabelText('thumbs down'); + userEvent.click(thumbDownButton); + + const firstCheckbox = screen.getByRole('checkbox', { + name: VIDEO_FEEDBACK_CARD.options[0], + }); + userEvent.click(firstCheckbox); + + const commentInput = screen.getByPlaceholderText(VIDEO_FEEDBACK_CARD.inputPlaceholder); + userEvent.type(commentInput, 'test comment'); + + const submitButton = screen.getByRole('button', { name: 'Submit feedback' }); + userEvent.click(submitButton); + + await waitFor(() => { + expect(sendEnterpriseTrackEvent).toHaveBeenCalledWith( + mockEnterpriseCustomerUuid, + 'edx.ui.enterprise.learner_portal.video.feedback.response.submitted', + expect.objectContaining({ + response: false, + selectedOptions: [VIDEO_FEEDBACK_CARD.options[0]], + comments: 'test comment', + courseRunKey: 'test-course-key', + videoId: 'test-video-id', + video_usage_key: 'test-usage-key', + }), + ); + }); + }); + + it('displays thank you message after feedback submission', async () => { + renderWithRouter(); + + const thumbUpButton = screen.getByLabelText('thumbs up'); + userEvent.click(thumbUpButton); + + await waitFor(() => { + expect(screen.getByText(VIDEO_FEEDBACK_CARD.thankYouMessage)).toBeInTheDocument(); + }); + }); + + it('retrieves feedback submission status from localStorage', () => { + global.localStorage.setItem('test-video-id-feedbackSubmitted', 'true'); + renderWithRouter(); + + expect(screen.getByText(VIDEO_FEEDBACK_CARD.thankYouMessage)).toBeInTheDocument(); + }); +});