diff --git a/src/courseware/course/sequence/Sequence.jsx b/src/courseware/course/sequence/Sequence.jsx index 0c0b33e82d..4a3f648e0f 100644 --- a/src/courseware/course/sequence/Sequence.jsx +++ b/src/courseware/course/sequence/Sequence.jsx @@ -27,6 +27,7 @@ import messages from './messages'; import HiddenAfterDue from './hidden-after-due'; import { SequenceNavigation, UnitNavigation } from './sequence-navigation'; import SequenceContent from './SequenceContent'; +import FeedbackWidget from './Unit/feedback-widget'; const Sequence = ({ unitId, @@ -37,7 +38,11 @@ const Sequence = ({ previousSequenceHandler, }) => { const intl = useIntl(); - const course = useModel('coursewareMeta', courseId); + const { + canAccessProctoredExams, + license, + wholeCourseTranslationEnabled, + } = useModel('coursewareMeta', courseId); const { isStaff, originalUserIsStaff, @@ -49,7 +54,8 @@ const Sequence = ({ const shouldDisplayNotificationTriggerInSequence = useWindowSize().width < breakpoints.small.minWidth; const enableNewSidebar = getConfig().ENABLE_NEW_SIDEBAR; - const handleNext = () => { + const handleNext = (position) => { + logEvent('edx.ui.lms.sequence.next_selected', position); const nextIndex = sequence.unitIds.indexOf(unitId) + 1; if (nextIndex < sequence.unitIds.length) { const newUnitId = sequence.unitIds[nextIndex]; @@ -59,7 +65,8 @@ const Sequence = ({ } }; - const handlePrevious = () => { + const handlePrevious = (position) => { + logEvent('edx.ui.lms.sequence.previous_selected', position); const previousIndex = sequence.unitIds.indexOf(unitId) - 1; if (previousIndex >= 0) { const newUnitId = sequence.unitIds[previousIndex]; @@ -144,55 +151,60 @@ const Sequence = ({ const gated = sequence && sequence.gatedContent !== undefined && sequence.gatedContent.gated; const defaultContent = ( -
-
- { - logEvent('edx.ui.lms.sequence.next_selected', 'top'); - handleNext(); - }} - onNavigate={(destinationUnitId) => { - logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId); - handleNavigate(destinationUnitId); - }} - previousHandler={() => { - logEvent('edx.ui.lms.sequence.previous_selected', 'top'); - handlePrevious(); - }} - /> - {shouldDisplayNotificationTriggerInSequence && ( - enableNewSidebar === 'true' ? : - )} - -
- - {unitHasLoaded && ( - +
+
+ { - logEvent('edx.ui.lms.sequence.previous_selected', 'bottom'); - handlePrevious(); + className="mb-4" + nextHandler={() => { + handleNext('top'); }} - onClickNext={() => { - logEvent('edx.ui.lms.sequence.next_selected', 'bottom'); - handleNext(); + onNavigate={(destinationUnitId) => { + logEvent('edx.ui.lms.sequence.tab_selected', 'top', destinationUnitId); + handleNavigate(destinationUnitId); + }} + previousHandler={() => { + handlePrevious('top'); }} /> + {shouldDisplayNotificationTriggerInSequence && ( + enableNewSidebar === 'true' ? : )} + +
+ + {unitHasLoaded && ( + { + handlePrevious('bottom'); + }} + onClickNext={() => { + handleNext('bottom'); + }} + /> + )} +
+ {enableNewSidebar === 'true' ? : }
- {enableNewSidebar === 'true' ? : } -
+ { + wholeCourseTranslationEnabled && ( +
+ +
+ ) + } + ); if (sequenceStatus === 'loaded') { @@ -203,11 +215,11 @@ const Sequence = ({ courseId={courseId} isStaff={isStaff} originalUserIsStaff={originalUserIsStaff} - canAccessProctoredExams={course.canAccessProctoredExams} + canAccessProctoredExams={canAccessProctoredExams} > {defaultContent} - +
); } diff --git a/src/courseware/course/sequence/Unit/feedback-widget/__snapshots__/index.test.jsx.snap b/src/courseware/course/sequence/Unit/feedback-widget/__snapshots__/index.test.jsx.snap new file mode 100644 index 0000000000..5ec61637f9 --- /dev/null +++ b/src/courseware/course/sequence/Unit/feedback-widget/__snapshots__/index.test.jsx.snap @@ -0,0 +1,52 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders 1`] = ` +
+
+ + Rate this page translation + +
+ + +
+
+ | +
+
+ +
+
+
+
+`; diff --git a/src/courseware/course/sequence/Unit/feedback-widget/index.jsx b/src/courseware/course/sequence/Unit/feedback-widget/index.jsx new file mode 100644 index 0000000000..d5172835c3 --- /dev/null +++ b/src/courseware/course/sequence/Unit/feedback-widget/index.jsx @@ -0,0 +1,99 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import { ActionRow, IconButton, Icon } from '@edx/paragon'; +import { Close, ThumbUpOutline, ThumbDownOffAlt } from '@edx/paragon/icons'; + +import './index.scss'; +import messages from './messages'; +import useFeedbackWidget from './useFeedbackWidget'; + +const FeedbackWidget = ({ + courseId, + languageCode, + unitId, + userId, +}) => { + const { formatMessage } = useIntl(); + const { + closeFeedbackWidget, + sendFeedback, + showFeedbackWidget, + showGratitudeText, + } = useFeedbackWidget({ + courseId, + languageCode, + unitId, + userId, + }); + return ( + (showFeedbackWidget || showGratitudeText) && ( +
+ { + showFeedbackWidget && ( +
+ + {formatMessage(messages.rateTranslationText)} + +
+ + +
+
+ | +
+
+ +
+
+
+ ) + } + { + showGratitudeText && ( +
+ + {formatMessage(messages.gratitudeText)} + +
+ ) + } +
+ ) + ); +}; + +FeedbackWidget.propTypes = { + courseId: PropTypes.string.isRequired, + languageCode: PropTypes.string.isRequired, + userId: PropTypes.string.isRequired, + unitId: PropTypes.string.isRequired, +}; + +FeedbackWidget.defaultProps = {}; + +export default FeedbackWidget; diff --git a/src/courseware/course/sequence/Unit/feedback-widget/index.scss b/src/courseware/course/sequence/Unit/feedback-widget/index.scss new file mode 100644 index 0000000000..9c88934bfa --- /dev/null +++ b/src/courseware/course/sequence/Unit/feedback-widget/index.scss @@ -0,0 +1,4 @@ +.action-row-divider { + font-size: 31px; + font-weight: 100; +} \ No newline at end of file diff --git a/src/courseware/course/sequence/Unit/feedback-widget/index.test.jsx b/src/courseware/course/sequence/Unit/feedback-widget/index.test.jsx new file mode 100644 index 0000000000..2fc51fb2af --- /dev/null +++ b/src/courseware/course/sequence/Unit/feedback-widget/index.test.jsx @@ -0,0 +1,51 @@ +import { shallow } from '@edx/react-unit-test-utils'; + +import FeedbackWidget from './index'; + +jest.mock('@edx/paragon', () => jest.requireActual('@edx/react-unit-test-utils').mockComponents({ + ActionRow: { + Spacer: 'Spacer', + }, + IconButton: 'IconButton', + Icon: 'Icon', +})); +jest.mock('@edx/paragon/icons', () => ({ + Close: 'Close', + ThumbUpOutline: 'ThumbUpOutline', + ThumbDownOffAlt: 'ThumbDownOffAlt', +})); +jest.mock('./useFeedbackWidget', () => () => ({ + closeFeedbackWidget: jest.fn().mockName('closeFeedbackWidget'), + openFeedbackWidget: jest.fn().mockName('openFeedbackWidget'), + sendFeedback: jest.fn().mockName('sendFeedback'), + showFeedbackWidget: true, + showGratitudeText: false, +})); +jest.mock('@edx/frontend-platform/i18n', () => { + const i18n = jest.requireActual('@edx/frontend-platform/i18n'); + const { formatMessage } = jest.requireActual('@edx/react-unit-test-utils'); + // this provide consistent for the test on different platform/timezone + const formatDate = jest.fn(date => new Date(date).toISOString()).mockName('useIntl.formatDate'); + return { + ...i18n, + useIntl: jest.fn(() => ({ + formatMessage, + formatDate, + })), + defineMessages: m => m, + FormattedMessage: () => 'FormattedMessage', + }; +}); + +describe('', () => { + const props = { + courseId: 'course-v1:edX+DemoX+Demo_Course', + languageCode: 'es', + unitId: 'block-v1:edX+DemoX+Demo_Course+type@vertical+block@37b72b3915204b70acb00c55b604b563', + userId: '123', + }; + it('renders', () => { + const wrapper = shallow(); + expect(wrapper.snapshot).toMatchSnapshot(); + }); +}); diff --git a/src/courseware/course/sequence/Unit/feedback-widget/messages.js b/src/courseware/course/sequence/Unit/feedback-widget/messages.js new file mode 100644 index 0000000000..f84f836a86 --- /dev/null +++ b/src/courseware/course/sequence/Unit/feedback-widget/messages.js @@ -0,0 +1,16 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + rateTranslationText: { + id: 'feedbackWidget.rateTranslationText', + defaultMessage: 'Rate this page translation', + description: 'Title for the feedback widget action row.', + }, + gratitudeText: { + id: 'feedbackWidget.gratitudeText', + defaultMessage: 'Thank you! Your feedback matters.', + description: 'Title for secondary action row.', + }, +}); + +export default messages; diff --git a/src/courseware/course/sequence/Unit/feedback-widget/useFeedbackWidget.js b/src/courseware/course/sequence/Unit/feedback-widget/useFeedbackWidget.js new file mode 100644 index 0000000000..cdce79935f --- /dev/null +++ b/src/courseware/course/sequence/Unit/feedback-widget/useFeedbackWidget.js @@ -0,0 +1,38 @@ +import { useCallback, useState } from 'react'; + +const useFeedbackWidget = () => { + const [showFeedbackWidget, setShowFeedbackWidget] = useState(true); + const [showGratitudeText, setShowGratitudeText] = useState(false); + + const closeFeedbackWidget = useCallback(() => { + setShowFeedbackWidget(false); + }, [setShowFeedbackWidget]); + + const openFeedbackWidget = useCallback(() => { + setShowFeedbackWidget(true); + }, [setShowFeedbackWidget]); + + const openGratitudeText = useCallback(() => { + setShowGratitudeText(true); + setTimeout(() => { + setShowGratitudeText(false); + }, 3000); + }, [setShowGratitudeText]); + + const sendFeedback = useCallback(() => { + // Create feedback + closeFeedbackWidget(); + openGratitudeText(); + }, [closeFeedbackWidget, openGratitudeText]); + + return { + closeFeedbackWidget, + openFeedbackWidget, + openGratitudeText, + sendFeedback, + showFeedbackWidget, + showGratitudeText, + }; +}; + +export default useFeedbackWidget; diff --git a/src/courseware/course/sequence/Unit/feedback-widget/useFeedbackWidget.test.js b/src/courseware/course/sequence/Unit/feedback-widget/useFeedbackWidget.test.js new file mode 100644 index 0000000000..7990937453 --- /dev/null +++ b/src/courseware/course/sequence/Unit/feedback-widget/useFeedbackWidget.test.js @@ -0,0 +1,59 @@ +import { renderHook, act } from '@testing-library/react-hooks'; + +import useFeedbackWidget from './useFeedbackWidget'; + +describe('useFeedbackWidget', () => { + test('closeFeedbackWidget behavior', () => { + const { result } = renderHook(() => useFeedbackWidget()); + + expect(result.current.showFeedbackWidget).toBe(true); + act(() => { + result.current.closeFeedbackWidget(); + }); + expect(result.current.showFeedbackWidget).toBe(false); + }); + + test('openFeedbackWidget behavior', () => { + const { result } = renderHook(() => useFeedbackWidget()); + + act(() => { + result.current.closeFeedbackWidget(); + }); + expect(result.current.showFeedbackWidget).toBe(false); + act(() => { + result.current.openFeedbackWidget(); + }); + expect(result.current.showFeedbackWidget).toBe(true); + }); + + test('openGratitudeText behavior', async () => { + const { result, waitFor } = renderHook(() => useFeedbackWidget()); + + expect(result.current.showGratitudeText).toBe(false); + act(() => { + result.current.openGratitudeText(); + }); + expect(result.current.showGratitudeText).toBe(true); + // Wait for 3 seconds to hide the gratitude text + waitFor(() => { + expect(result.current.showGratitudeText).toBe(false); + }, { timeout: 3000 }); + }); + + test('sendFeedback behavior', () => { + const { result, waitFor } = renderHook(() => useFeedbackWidget()); + + expect(result.current.showFeedbackWidget).toBe(true); + expect(result.current.showGratitudeText).toBe(false); + act(() => { + result.current.sendFeedback(); + }); + expect(result.current.showFeedbackWidget).toBe(false); + expect(result.current.showGratitudeText).toBe(true); + + // Wait for 3 seconds to hide the gratitude text + waitFor(() => { + expect(result.current.showGratitudeText).toBe(false); + }, { timeout: 3000 }); + }); +});