diff --git a/.husky/pre-push b/.husky/pre-push deleted file mode 100755 index 20d0d06e..00000000 --- a/.husky/pre-push +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -. "$(dirname "$0")/_/husky.sh" - -npm run lint diff --git a/src/containers/ReviewModal/ReviewContent.jsx b/src/containers/ReviewModal/ReviewContent.jsx index b444236f..137b135d 100644 --- a/src/containers/ReviewModal/ReviewContent.jsx +++ b/src/containers/ReviewModal/ReviewContent.jsx @@ -7,6 +7,7 @@ import { Col, Row } from '@edx/paragon'; import { selectors } from 'data/redux'; import { RequestKeys } from 'data/constants/requests'; +import TurnitinDisplay from 'containers/TurnitinDisplay'; import ResponseDisplay from 'containers/ResponseDisplay'; import Rubric from 'containers/Rubric'; import ReviewErrors from './ReviewErrors'; @@ -17,6 +18,11 @@ import ReviewErrors from './ReviewErrors'; export const ReviewContent = ({ isFailed, isLoaded, showRubric }) => (isLoaded || isFailed) && (
+ + + + + {isLoaded && ( diff --git a/src/containers/TurnitinDisplay/components/FileNameCell.jsx b/src/containers/TurnitinDisplay/components/FileNameCell.jsx new file mode 100644 index 00000000..e15e78e1 --- /dev/null +++ b/src/containers/TurnitinDisplay/components/FileNameCell.jsx @@ -0,0 +1,12 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +export const FileNameCell = ({ value }) => ( +
{value.split('.')?.shift()}
+); + +FileNameCell.propTypes = { + value: PropTypes.string.isRequired, +}; + +export default FileNameCell; diff --git a/src/containers/TurnitinDisplay/components/HyperlinkCell.jsx b/src/containers/TurnitinDisplay/components/HyperlinkCell.jsx new file mode 100644 index 00000000..97cec696 --- /dev/null +++ b/src/containers/TurnitinDisplay/components/HyperlinkCell.jsx @@ -0,0 +1,19 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Hyperlink } from '@edx/paragon'; +import { useIntl } from '@edx/frontend-platform/i18n'; +import messages from './messages'; + +export const HyperlinkCell = ({ value }) => { + const intl = useIntl(); + return ( + + {intl.formatMessage(messages.buttonViewerURLTitle)} + + ) +} +HyperlinkCell.propTypes = { + value: PropTypes.string.isRequired, +}; + +export default HyperlinkCell; diff --git a/src/containers/TurnitinDisplay/components/messages.js b/src/containers/TurnitinDisplay/components/messages.js new file mode 100644 index 00000000..64e4a31c --- /dev/null +++ b/src/containers/TurnitinDisplay/components/messages.js @@ -0,0 +1,41 @@ +import { defineMessages } from '@edx/frontend-platform/i18n'; + +const messages = defineMessages({ + fileNameTableHeader: { + id: 'ora-grading.TurnitinDisplay.FileNameCell.fileNameTitle', + defaultMessage: 'Responses', + description: 'Title for files attached to the submission', + }, + URLTableHeader: { + id: 'ora-grading.TurnitinDisplay.FileNameCell.tableNameHeader', + defaultMessage: 'URL', + description: 'Title for the file name column in the table', + }, + buttonViewerURLTitle: { + id: 'ora-grading.TurnitinDisplay.ButtonCell.URLButtonCellTitle', + defaultMessage: 'View in Turnitin', + description: 'Title for the button that opens the viewer in a new tab', + }, + similarityReportsTitle: { + id: 'ora-grading.TurnitinDisplay.SimilarityReportsTitle', + defaultMessage: 'Turnitin Similarity Reports', + description: 'Title for the Turnitin Similarity Reports section', + }, + noSimilarityReports: { + id: 'ora-grading.TurnitinDisplay.NoSimilarityReports', + defaultMessage: 'No Turnitin Similarity Reports to show', + description: 'Message to display when there are no Turnitin Similarity Reports to show', + }, + viewerURLExpired: { + id: 'ora-grading.TurnitinDisplay.ViewerURLExpired', + defaultMessage: 'The Similarity Report URLs have a very short lifespan (less than 1 minute) after which it will no longer be valid. Once a user has been redirected to this URL, they will be given a session that will last for 1 hour. When expired, please refresh the page to get a new URL.', + description: 'Message to display when the viewer URL has expired', + }, + viewerURLExpiredTitle: { + id: 'ora-grading.TurnitinDisplay.ViewerURLExpiredTitle', + defaultMessage: 'URLs expire quickly', + description: 'Title for the warning message when the viewer URL has expired', + }, +}); + +export default messages; diff --git a/src/containers/TurnitinDisplay/index.jsx b/src/containers/TurnitinDisplay/index.jsx new file mode 100644 index 00000000..0f546481 --- /dev/null +++ b/src/containers/TurnitinDisplay/index.jsx @@ -0,0 +1,101 @@ +import React, { useState } from 'react'; +import PropTypes from 'prop-types'; +import { connect } from 'react-redux'; + +import { + Card, Collapsible, Icon, DataTable, Alert, +} from '@edx/paragon'; +import { ArrowDropDown, ArrowDropUp, WarningFilled } from '@edx/paragon/icons'; +import messages from './components/messages'; +import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; + +import FileNameCell from './components/FileNameCell'; +import HyperlinkCell from './components/HyperlinkCell'; + + +/** + * + */ +export const TurnitinDisplay = ({ viewers, intl }) => { + const [isWarningOpen, setIsWarningOpen] = useState(true); + return + {viewers.length ? ( + <> + + +

{intl.formatMessage(messages.similarityReportsTitle)}

+ + + + + + +
+ + setIsWarningOpen(false)} + > + {intl.formatMessage(messages.viewerURLExpiredTitle)} +

{intl.formatMessage(messages.viewerURLExpired)}

+
+
+ + + +
+
+
+ + + + ) : ( +
+ +

{intl.formatMessage(messages.noSimilarityReports)}

+
+ )} +
+}; + + +TurnitinDisplay.defaultProps = { + viewers: [], +}; + + +TurnitinDisplay.propTypes = { + viewers: PropTypes.arrayOf( + PropTypes.shape({ + viewer_url: PropTypes.string.isRequired, + }), + ), + // injected + intl: intlShape.isRequired, +}; + +export const mapStateToProps = (state) => ({ + viewers: selectors.grading.selected.turnitinViewers(state), +}); + +export const mapDispatchToProps = {}; + +export default injectIntl(connect(mapStateToProps, mapDispatchToProps)(TurnitinDisplay)); diff --git a/src/data/constants/requests.js b/src/data/constants/requests.js index eaa4e90a..470164cf 100644 --- a/src/data/constants/requests.js +++ b/src/data/constants/requests.js @@ -17,6 +17,7 @@ export const RequestKeys = StrictDict({ prefetchPrev: 'prefetchPrev', setLock: 'setLock', submitGrade: 'submitGrade', + fetchTurnitinViewers: 'fetchTurnitinViewers', }); export const ErrorCodes = StrictDict({ diff --git a/src/data/redux/grading/reducer.js b/src/data/redux/grading/reducer.js index ee5ff0f5..0efb9af2 100644 --- a/src/data/redux/grading/reducer.js +++ b/src/data/redux/grading/reducer.js @@ -199,6 +199,10 @@ const grading = createSlice({ }, }; }, + loadTurnitinViewers: (state, { payload }) => ({ + ...state, + current: { ...state.current, turnitinViewers: payload }, + }), }, }); diff --git a/src/data/redux/grading/selectors/selected.js b/src/data/redux/grading/selectors/selected.js index 5e5113fd..ee4aeb79 100644 --- a/src/data/redux/grading/selectors/selected.js +++ b/src/data/redux/grading/selectors/selected.js @@ -37,6 +37,11 @@ selected.submissionUUID = createSelector( */ selected.gradeStatus = createSelector([simpleSelectors.current], (current) => current.gradeStatus); +selected.turnitinViewers = createSelector( + [simpleSelectors.current], + (current) => current.turnitinViewers, +); + /** * Returns the lock status for the selected submission * @return {string} lock status diff --git a/src/data/redux/requests/reducer.js b/src/data/redux/requests/reducer.js index 500ee4bb..b586946f 100644 --- a/src/data/redux/requests/reducer.js +++ b/src/data/redux/requests/reducer.js @@ -13,6 +13,7 @@ const initialState = { [RequestKeys.prefetchNext]: { status: RequestStates.inactive }, [RequestKeys.prefetchPrev]: { status: RequestStates.inactive }, [RequestKeys.submitGrade]: { status: RequestStates.inactive }, + [RequestKeys.fetchTurnitinViewers]: { status: RequestStates.inactive }, }; // eslint-disable-next-line no-unused-vars diff --git a/src/data/redux/thunkActions/grading.js b/src/data/redux/thunkActions/grading.js index e0b7e7be..59eace20 100644 --- a/src/data/redux/thunkActions/grading.js +++ b/src/data/redux/thunkActions/grading.js @@ -38,6 +38,7 @@ export const loadSelectionForReview = (submissionUUIDs) => (dispatch) => { dispatch(actions.grading.updateSelection(submissionUUIDs)); dispatch(actions.app.setShowReview(true)); dispatch(module.loadSubmission()); + dispatch(module.loadTurnitinViewers()); }; export const loadSubmission = () => (dispatch, getState) => { @@ -58,6 +59,21 @@ export const loadSubmission = () => (dispatch, getState) => { })); }; +export const loadTurnitinViewers = () => (dispatch, getState) => { + const submissionUUID = selectors.grading.selected.submissionUUID(getState()); + dispatch(requests.fetchTurnitinViewers({ + submissionUUID, courseId: selectors.app.courseId(getState()), + onSuccess: (response) => { + dispatch(actions.grading.loadTurnitinViewers(response)); + }, + onFailure: (error) => { + if (error.response.status === ErrorStatuses.notFound) { + dispatch(actions.grading.loadTurnitinViewers([])); + } + } + })); +}; + /** * Start grading the current submission. * Attempts to lock the submission, and on a success, sets the local grading state to diff --git a/src/data/redux/thunkActions/requests.js b/src/data/redux/thunkActions/requests.js index f1f9f68c..5ad666c9 100644 --- a/src/data/redux/thunkActions/requests.js +++ b/src/data/redux/thunkActions/requests.js @@ -78,6 +78,14 @@ export const fetchSubmission = ({ submissionUUID, ...rest }) => (dispatch) => { })); }; +export const fetchTurnitinViewers = ({ submissionUUID, courseId, ...rest }) => (dispatch) => { + dispatch(module.networkRequest({ + requestKey: RequestKeys.fetchTurnitinViewers, + promise: api.fetchTurnitinViewers(submissionUUID, courseId), + ...rest, + })); +} + /** * Tracked setLock api method. tracked to the `setLock` request key. * @param {string} submissionUUID - target submission id @@ -125,4 +133,5 @@ export default StrictDict({ fetchSubmissionStatus, setLock, submitGrade, + fetchTurnitinViewers, }); diff --git a/src/data/services/lms/api.js b/src/data/services/lms/api.js index bc4c41ce..90f0b797 100644 --- a/src/data/services/lms/api.js +++ b/src/data/services/lms/api.js @@ -54,6 +54,17 @@ const fetchSubmission = (submissionUUID) => get( }), ).then(response => response.data); +/** + * get(':courseId/api/v1/viewer-url/:submissionUUID') + * @return { + * url: + * file_name: + * } + */ +const fetchTurnitinViewers = (submissionUUID, courseId) => get( + stringifyUrl(`${urls.fetchTurnitinViewersUrl()}/${courseId}/api/v1/viewer-url/${submissionUUID}/`) +).then(response => response.data); + /** * get('/api/submission/files', { oraLocation, submissionUUID }) * @return { @@ -139,4 +150,5 @@ export default StrictDict({ updateGrade, unlockSubmission, batchUnlockSubmissions, + fetchTurnitinViewers, }); diff --git a/src/data/services/lms/urls.js b/src/data/services/lms/urls.js index de95808b..d4b51095 100644 --- a/src/data/services/lms/urls.js +++ b/src/data/services/lms/urls.js @@ -13,6 +13,7 @@ const fetchSubmissionStatusUrl = () => `${baseEsgUrl()}submission/status`; const fetchSubmissionLockUrl = () => `${baseEsgUrl()}submission/lock`; const batchUnlockSubmissionsUrl = () => `${baseEsgUrl()}submission/batch/unlock`; const updateSubmissionGradeUrl = () => `${baseEsgUrl()}submission/grade`; +const fetchTurnitinViewersUrl = () => `${baseUrl()}/platform-plugin-turnitin`; const course = (courseId) => `${baseUrl()}/courses/${courseId}`; @@ -30,6 +31,7 @@ export default StrictDict({ fetchSubmissionLockUrl, batchUnlockSubmissionsUrl, updateSubmissionGradeUrl, + fetchTurnitinViewersUrl, baseUrl, course, openResponse,