From 101286e51e9876b7b56e7c1ec0b688f9fbc5c401 Mon Sep 17 00:00:00 2001 From: Martin Krulis Date: Mon, 24 Oct 2022 16:47:40 +0200 Subject: [PATCH] Code reviews implemented (review state buttons, adding/editing/removing actions and forms on the source codes page, indicators, messages and notifications). --- recodex-web.spec | 4 +- .../SolutionsTable/SolutionTableRowIcons.js | 29 +- .../SolutionsTable/SolutionsTableRow.js | 56 +- .../SolutionDetail/SolutionDetail.js | 10 +- .../SolutionReviewIcon/SolutionReviewIcon.js | 71 +++ .../Solutions/SolutionReviewIcon/index.js | 1 + .../SolutionStatus/SolutionStatus.js | 52 +- .../Solutions/SourceCodeBox/SourceCodeBox.js | 35 +- .../buttons/AcceptSolution/AcceptSolution.js | 2 +- .../buttons/ReviewSolution/ReviewSolution.js | 134 ++++- .../forms/Fields/MarkdownTextAreaField.js | 6 +- .../forms/Fields/MarkdownTextAreaField.less | 3 +- .../ReviewCommentForm/ReviewCommentForm.js | 150 +++++ .../forms/ReviewCommentForm/index.js | 2 + .../forms/SubmitButton/SubmitButton.js | 10 +- .../SourceCodeViewer/SourceCodeViewer.css | 73 ++- .../SourceCodeViewer/SourceCodeViewer.js | 259 ++++++++- src/components/icons/index.js | 19 +- .../HeaderSystemMessagesDropdown.js | 4 +- src/containers/App/recodex.css | 6 +- .../ReviewSolutionContainer.js | 49 +- .../SourceCodeViewerContainer.js | 4 +- src/locales/cs.json | 41 +- src/locales/en.json | 37 +- src/pages/AssignmentStats/AssignmentStats.js | 15 +- .../GroupUserSolutions/GroupUserSolutions.js | 15 +- src/pages/Solution/Solution.js | 93 +-- .../SolutionSourceCodes.js | 544 +++++++++--------- src/pages/SolutionSourceCodes/functions.js | 143 +++++ src/redux/modules/solutionReviews.js | 79 ++- src/redux/modules/solutions.js | 12 + src/redux/selectors/solutionReviews.js | 6 + src/redux/selectors/solutions.js | 8 +- src/redux/selectors/usersGroups.js | 5 + 34 files changed, 1471 insertions(+), 506 deletions(-) create mode 100644 src/components/Solutions/SolutionReviewIcon/SolutionReviewIcon.js create mode 100644 src/components/Solutions/SolutionReviewIcon/index.js create mode 100644 src/components/forms/ReviewCommentForm/ReviewCommentForm.js create mode 100644 src/components/forms/ReviewCommentForm/index.js create mode 100644 src/pages/SolutionSourceCodes/functions.js diff --git a/recodex-web.spec b/recodex-web.spec index 74b074909..6f84059aa 100644 --- a/recodex-web.spec +++ b/recodex-web.spec @@ -1,8 +1,8 @@ %define name recodex-web %define short_name web-app %define version 2.4.0 -%define unmangled_version 5de194c7ea0249e347c35ca1fe66ffb62e9f83b9 -%define release 1 +%define unmangled_version 4371fe38d12c44840702abed4f865d7b49bfb162 +%define release 2 Summary: ReCodEx web-app component Name: %{name} diff --git a/src/components/Assignments/SolutionsTable/SolutionTableRowIcons.js b/src/components/Assignments/SolutionsTable/SolutionTableRowIcons.js index eefca49fa..737772163 100644 --- a/src/components/Assignments/SolutionsTable/SolutionTableRowIcons.js +++ b/src/components/Assignments/SolutionsTable/SolutionTableRowIcons.js @@ -1,20 +1,19 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; -import { OverlayTrigger, Tooltip } from 'react-bootstrap'; -import Icon from '../../icons'; +import SolutionReviewIcon from '../../Solutions/SolutionReviewIcon'; import AssignmentStatusIcon, { getStatusDesc } from '../Assignment/AssignmentStatusIcon'; import CommentsIcon from './CommentsIcon'; const SolutionTableRowIcons = ({ id, accepted, - reviewed, + review = null, isBestSolution, status, lastSubmission, commentsStats = null, + isReviewer = false, }) => ( <> - {reviewed && ( - - - - }> - - - )} + {review && } @@ -47,7 +33,11 @@ SolutionTableRowIcons.propTypes = { id: PropTypes.string.isRequired, commentsStats: PropTypes.object, accepted: PropTypes.bool.isRequired, - reviewed: PropTypes.bool.isRequired, + review: PropTypes.shape({ + startedAt: PropTypes.number, + closedAt: PropTypes.number, + issues: PropTypes.number, + }), isBestSolution: PropTypes.bool.isRequired, status: PropTypes.string, lastSubmission: PropTypes.shape({ @@ -56,6 +46,7 @@ SolutionTableRowIcons.propTypes = { points: PropTypes.number.isRequired, }), }), + isReviewer: PropTypes.bool, }; export default SolutionTableRowIcons; diff --git a/src/components/Assignments/SolutionsTable/SolutionsTableRow.js b/src/components/Assignments/SolutionsTable/SolutionsTableRow.js index c22e7b01b..40b549407 100644 --- a/src/components/Assignments/SolutionsTable/SolutionsTableRow.js +++ b/src/components/Assignments/SolutionsTable/SolutionsTableRow.js @@ -35,7 +35,7 @@ const SolutionsTableRow = ({ actualPoints, createdAt, accepted = false, - reviewed = false, + review = null, isBestSolution = false, runtimeEnvironment = null, commentsStats = null, @@ -62,15 +62,16 @@ const SolutionsTableRow = ({ const splitOnTwoLines = hasNote && compact; return ( - + onSelect(id) : null}> onSelect(id) : null}> - {attemptIndex}. + })}> + + {attemptIndex}. + @@ -128,13 +130,7 @@ const SolutionsTableRow = ({ )} {showActionButtons && ( - + {permissionHints && permissionHints.viewDetail && ( <> @@ -165,16 +161,17 @@ const SolutionsTableRow = ({ )} {permissionHints && permissionHints.setFlag && ( - <> - - - + + )} + + {permissionHints && permissionHints.review && ( + )} {permissionHints && permissionHints.delete && ( @@ -186,7 +183,12 @@ const SolutionsTableRow = ({ {splitOnTwoLines && ( - + : @@ -218,7 +220,11 @@ SolutionsTableRow.propTypes = { }), createdAt: PropTypes.number.isRequired, accepted: PropTypes.bool, - reviewed: PropTypes.bool, + review: PropTypes.shape({ + startedAt: PropTypes.number, + closedAt: PropTypes.number, + issues: PropTypes.number, + }), isBestSolution: PropTypes.bool, commentsStats: PropTypes.object, runtimeEnvironment: PropTypes.object, diff --git a/src/components/Solutions/SolutionDetail/SolutionDetail.js b/src/components/Solutions/SolutionDetail/SolutionDetail.js index e029cf54e..2f34aeea6 100644 --- a/src/components/Solutions/SolutionDetail/SolutionDetail.js +++ b/src/components/Solutions/SolutionDetail/SolutionDetail.js @@ -67,7 +67,7 @@ class SolutionDetail extends Component { bonusPoints, actualPoints, accepted, - reviewed, + review = null, runtimeEnvironmentId, lastSubmission, permissionHints = EMPTY_OBJ, @@ -117,7 +117,7 @@ class SolutionDetail extends Component { note={note} editNote={editNote} accepted={accepted} - reviewed={reviewed} + review={review} assignment={assignment} actualPoints={actualPoints} overriddenPoints={overriddenPoints} @@ -356,7 +356,11 @@ SolutionDetail.propTypes = { overriddenPoints: PropTypes.number, actualPoints: PropTypes.number, accepted: PropTypes.bool.isRequired, - reviewed: PropTypes.bool.isRequired, + review: PropTypes.shape({ + startedAt: PropTypes.number, + closedAt: PropTypes.number, + issues: PropTypes.number, + }), runtimeEnvironmentId: PropTypes.string, permissionHints: PropTypes.object, }).isRequired, diff --git a/src/components/Solutions/SolutionReviewIcon/SolutionReviewIcon.js b/src/components/Solutions/SolutionReviewIcon/SolutionReviewIcon.js new file mode 100644 index 000000000..a4645ef52 --- /dev/null +++ b/src/components/Solutions/SolutionReviewIcon/SolutionReviewIcon.js @@ -0,0 +1,71 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { OverlayTrigger, Tooltip } from 'react-bootstrap'; + +import { ReviewIcon } from '../../icons'; +import DateTime from '../../widgets/DateTime'; + +const SolutionReviewIcon = ({ id, review, isReviewer = false, placement = 'bottom', className = '', ...props }) => { + if (!review.startedAt) { + return null; + } + + const pendinColorClass = isReviewer ? 'text-danger fa-beat' : 'text-muted'; + const closedColorClass = review.issues > 0 ? 'text-warning' : 'text-success'; + const colorClass = !review.closedAt ? pendinColorClass : `${closedColorClass} half-gray`; + + return ( + + <> + {!review.closedAt ? ( + }} + /> + ) : ( + <> + }} + />{' '} + {review.issues > 0 ? ( + + ) : ( + + )} + + )} + + + }> + + + ); +}; + +SolutionReviewIcon.propTypes = { + id: PropTypes.string.isRequired, + review: PropTypes.shape({ + startedAt: PropTypes.number, + closedAt: PropTypes.number, + issues: PropTypes.number, + }).isRequired, + placement: PropTypes.string, + className: PropTypes.string, + isReviewer: PropTypes.bool, +}; + +export default SolutionReviewIcon; diff --git a/src/components/Solutions/SolutionReviewIcon/index.js b/src/components/Solutions/SolutionReviewIcon/index.js new file mode 100644 index 000000000..fa6da8ccc --- /dev/null +++ b/src/components/Solutions/SolutionReviewIcon/index.js @@ -0,0 +1 @@ +export { default } from './SolutionReviewIcon'; diff --git a/src/components/Solutions/SolutionStatus/SolutionStatus.js b/src/components/Solutions/SolutionStatus/SolutionStatus.js index 537a7dc11..8c359b037 100644 --- a/src/components/Solutions/SolutionStatus/SolutionStatus.js +++ b/src/components/Solutions/SolutionStatus/SolutionStatus.js @@ -21,8 +21,7 @@ import Icon, { NoteIcon, UserIcon, SupervisorIcon, - ReviewedIcon, - SuccessOrFailureIcon, + ReviewIcon, SuccessIcon, FailureIcon, CodeIcon, @@ -40,7 +39,7 @@ const getImportantSolutions = defaultMemoize((solutions, selectedSolutionId) => const selectedIdx = solutions.findIndex(s => s.id === selectedSolutionId); const accepted = solutions.find(s => s.accepted && s.id !== selectedSolutionId) || null; const best = solutions.find(s => s.isBestSolution && s.id !== selectedSolutionId) || null; - let lastReviewed = solutions.filter(s => s.reviewed).shift(); + let lastReviewed = solutions.filter(s => s.review && s.review.closedAt).shift(); lastReviewed = lastReviewed && lastReviewed.id !== selectedSolutionId ? lastReviewed : null; return { selectedIdx, accepted, best, lastReviewed }; }); @@ -90,7 +89,7 @@ class SolutionStatus extends Component { submittedBy, note, accepted, - reviewed, + review = null, runtimeEnvironmentId, runtimeEnvironments, maxPoints, @@ -337,19 +336,45 @@ class SolutionStatus extends Component { - + - : - + {review && review.closedAt ? ( + <> + : + + ) : ( + <> + : + + )} + - + {review && review.startedAt ? ( + + ) : ( + + + + )} + + {review && review.issues > 0 && ( + + ( + + ) + + )} {important.lastReviewed && ( @@ -556,6 +581,7 @@ class SolutionStatus extends Component { selected={id} assignmentSolversLoading={assignmentSolversLoading} assignmentSolver={assignmentSolver} + compact /> @@ -586,7 +612,11 @@ SolutionStatus.propTypes = { submittedBy: PropTypes.string, note: PropTypes.string, accepted: PropTypes.bool.isRequired, - reviewed: PropTypes.bool.isRequired, + review: PropTypes.shape({ + startedAt: PropTypes.number, + closedAt: PropTypes.number, + issues: PropTypes.number, + }), runtimeEnvironmentId: PropTypes.string, runtimeEnvironments: PropTypes.array, maxPoints: PropTypes.number.isRequired, diff --git a/src/components/Solutions/SourceCodeBox/SourceCodeBox.js b/src/components/Solutions/SourceCodeBox/SourceCodeBox.js index 90a848698..162e7a4a4 100644 --- a/src/components/Solutions/SourceCodeBox/SourceCodeBox.js +++ b/src/components/Solutions/SourceCodeBox/SourceCodeBox.js @@ -1,6 +1,6 @@ import React from 'react'; import PropTypes from 'prop-types'; -import { injectIntl, FormattedMessage } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; import { OverlayTrigger, Tooltip } from 'react-bootstrap'; import ReactDiffViewer, { DiffMethod } from 'react-diff-viewer'; import Prism from 'prismjs'; @@ -26,6 +26,7 @@ const diffViewHighlightSyntax = lang => str => const SourceCodeBox = ({ id, parentId = id, + solutionId, name, entryName = null, download = null, @@ -33,7 +34,13 @@ const SourceCodeBox = ({ diffMode = false, fileContentsSelector, adjustDiffMapping = null, - intl: { formatMessage }, + reviewComments = null, + addComment = null, + updateComment = null, + removeComment = null, + authorView = false, + restrictCommentAuthor = null, + reviewClosed = false, }) => { const res = fileContentsSelector(parentId, entryName); return ( @@ -202,7 +209,18 @@ const SourceCodeBox = ({ /> ) : ( - + )} )} @@ -213,6 +231,7 @@ const SourceCodeBox = ({ SourceCodeBox.propTypes = { id: PropTypes.string.isRequired, parentId: PropTypes.string, + solutionId: PropTypes.string.isRequired, name: PropTypes.string.isRequired, entryName: PropTypes.string, download: PropTypes.func, @@ -220,7 +239,13 @@ SourceCodeBox.propTypes = { diffWith: PropTypes.object, diffMode: PropTypes.bool, adjustDiffMapping: PropTypes.func, - intl: PropTypes.shape({ formatMessage: PropTypes.func.isRequired }).isRequired, + reviewComments: PropTypes.array, + addComment: PropTypes.func, + updateComment: PropTypes.func, + removeComment: PropTypes.func, + authorView: PropTypes.bool, + restrictCommentAuthor: PropTypes.string, + reviewClosed: PropTypes.bool, }; -export default injectIntl(SourceCodeBox); +export default SourceCodeBox; diff --git a/src/components/buttons/AcceptSolution/AcceptSolution.js b/src/components/buttons/AcceptSolution/AcceptSolution.js index 1ff2b1edf..ccc455830 100644 --- a/src/components/buttons/AcceptSolution/AcceptSolution.js +++ b/src/components/buttons/AcceptSolution/AcceptSolution.js @@ -34,7 +34,7 @@ const AcceptSolution = ({ size={size} onClick={accepted ? unaccept : accept} disabled={acceptPending}> - + {!captionAsTooltip && label} diff --git a/src/components/buttons/ReviewSolution/ReviewSolution.js b/src/components/buttons/ReviewSolution/ReviewSolution.js index 72a57cbd2..1eccb9412 100644 --- a/src/components/buttons/ReviewSolution/ReviewSolution.js +++ b/src/components/buttons/ReviewSolution/ReviewSolution.js @@ -1,44 +1,128 @@ import React from 'react'; import PropTypes from 'prop-types'; import { FormattedMessage } from 'react-intl'; +import { withRouter } from 'react-router'; + import Button from '../../widgets/TheButton'; import OptionalTooltipWrapper from '../../widgets/OptionalTooltipWrapper'; -import Icon from '../../icons'; +import Icon, { DeleteIcon } from '../../icons'; +import withLinks from '../../../helpers/withLinks'; const ReviewSolution = ({ - reviewed, - reviewPending, - setReviewed, - unsetReviewed, + id, + assignmentId, + review = null, + showAllButtons = false, + updatePending = false, captionAsTooltip = false, size = undefined, + openReview = null, + closeReview = null, + deleteReview = null, + history: { push }, + location: { pathname }, + links: { SOLUTION_SOURCE_CODES_URI_FACTORY }, }) => { - const label = reviewed ? ( - - ) : ( - - ); + const reviewPageUri = SOLUTION_SOURCE_CODES_URI_FACTORY(assignmentId, id); + const isOnReviewPage = pathname === SOLUTION_SOURCE_CODES_URI_FACTORY(assignmentId, id); + + const openLabel = + !review || !review.startedAt ? ( + + ) : ( + + ); + return ( - - - + <> + {openReview && Boolean(!review || !review.startedAt || review.closedAt) && ( + + + + )} + + {closeReview && showAllButtons && Boolean(!review || !review.startedAt) && ( + } + hide={!captionAsTooltip}> + + + )} + + {closeReview && Boolean(review && review.startedAt && !review.closedAt) && ( + } + hide={!captionAsTooltip}> + + + )} + + {deleteReview && showAllButtons && Boolean(review && review.startedAt) && ( + + )} + ); }; ReviewSolution.propTypes = { - reviewed: PropTypes.bool.isRequired, - reviewPending: PropTypes.bool.isRequired, - setReviewed: PropTypes.func.isRequired, - unsetReviewed: PropTypes.func.isRequired, + id: PropTypes.string.isRequired, + assignmentId: PropTypes.string.isRequired, + review: PropTypes.shape({ + startedAt: PropTypes.number, + closedAt: PropTypes.number, + issues: PropTypes.number, + }), + showAllButtons: PropTypes.bool, + updatePending: PropTypes.bool, captionAsTooltip: PropTypes.bool, size: PropTypes.string, + openReview: PropTypes.func, + closeReview: PropTypes.func, + deleteReview: PropTypes.func, + history: PropTypes.shape({ + push: PropTypes.func.isRequired, + }), + location: PropTypes.shape({ + pathname: PropTypes.string.isRequired, + }).isRequired, + links: PropTypes.object.isRequired, }; -export default ReviewSolution; +export default withLinks(withRouter(ReviewSolution)); diff --git a/src/components/forms/Fields/MarkdownTextAreaField.js b/src/components/forms/Fields/MarkdownTextAreaField.js index ac3b1f45a..0a790b84a 100644 --- a/src/components/forms/Fields/MarkdownTextAreaField.js +++ b/src/components/forms/Fields/MarkdownTextAreaField.js @@ -59,10 +59,10 @@ class MarkdownTextAreaField extends Component { {showPreview && ( -
-

+
+
-
+

{value.length === 0 && (

diff --git a/src/components/forms/Fields/MarkdownTextAreaField.less b/src/components/forms/Fields/MarkdownTextAreaField.less index abd88fe66..c8ab13c0b 100644 --- a/src/components/forms/Fields/MarkdownTextAreaField.less +++ b/src/components/forms/Fields/MarkdownTextAreaField.less @@ -1,7 +1,6 @@ .preview { background: #eee; - border-top: 1px solid #ccc; - border-bottom: 1px solid #ccc; + border: 1px solid #ccc; padding: 10px 5px; margin-bottom: 2em; } diff --git a/src/components/forms/ReviewCommentForm/ReviewCommentForm.js b/src/components/forms/ReviewCommentForm/ReviewCommentForm.js new file mode 100644 index 000000000..2a1bce3e9 --- /dev/null +++ b/src/components/forms/ReviewCommentForm/ReviewCommentForm.js @@ -0,0 +1,150 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage, injectIntl } from 'react-intl'; +import { reduxForm, Field } from 'redux-form'; +import { Container, Row, Col, FormLabel } from 'react-bootstrap'; + +import UsersNameContainer from '../../../containers/UsersNameContainer'; +import DateTime from '../../widgets/DateTime'; +import SubmitButton from '../SubmitButton'; +import Callout from '../../widgets/Callout'; +import Explanation from '../../widgets/Explanation'; +import Button, { TheButtonGroup } from '../../widgets/TheButton'; +import { CloseIcon } from '../../icons'; +import { CheckboxField, MarkdownTextAreaField } from '../Fields'; + +const textValidator = text => !text || text.trim() === ''; + +const ReviewCommentForm = ({ + authorId = null, + createdAt = null, + onCancel = null, + showSuppressor = false, + submitting, + handleSubmit, + submitFailed = false, + submitSucceeded = false, + invalid, + intl: { locale }, +}) => ( +

+ + + + {authorId && createdAt && ( + + + + + + + )} + + + {createdAt === null ? ( + + ) : ( + + )} + + +
+ +
+ + + + + + + + + } + /> + + + {showSuppressor && ( + + + + + + + + } + /> + + )} + + + + , + submitting: , + success: , + }} + /> + + {onCancel && ( + + )} + + + +
+ + {submitFailed && ( + + + + )} +
+); + +ReviewCommentForm.propTypes = { + createdAt: PropTypes.number, + authorId: PropTypes.string, + onCancel: PropTypes.func, + showSuppressor: PropTypes.bool, + handleSubmit: PropTypes.func.isRequired, + submitFailed: PropTypes.bool, + submitSucceeded: PropTypes.bool, + submitting: PropTypes.bool, + invalid: PropTypes.bool, + intl: PropTypes.object.isRequired, +}; + +export default reduxForm({ + enableReinitialize: true, + keepDirtyOnReinitialize: false, +})(injectIntl(ReviewCommentForm)); diff --git a/src/components/forms/ReviewCommentForm/index.js b/src/components/forms/ReviewCommentForm/index.js new file mode 100644 index 000000000..2f4902369 --- /dev/null +++ b/src/components/forms/ReviewCommentForm/index.js @@ -0,0 +1,2 @@ +import ReviewCommentForm from './ReviewCommentForm'; +export default ReviewCommentForm; diff --git a/src/components/forms/SubmitButton/SubmitButton.js b/src/components/forms/SubmitButton/SubmitButton.js index 713c6f3a6..2ad9d26a9 100644 --- a/src/components/forms/SubmitButton/SubmitButton.js +++ b/src/components/forms/SubmitButton/SubmitButton.js @@ -110,6 +110,7 @@ class SubmitButton extends Component { noIcons = false, defaultIcon = null, noShadow = false, + size, messages = {}, intl: { formatMessage }, } = this.props; @@ -136,6 +137,7 @@ class SubmitButton extends Component { variant={this.getButtonVariant()} disabled={this.isButtonDisabled()} noShadow={noShadow} + size={size} onClick={this.submit}> {!noIcons && icons[buttonState]} {formattedMessages[buttonState]} @@ -147,7 +149,12 @@ class SubmitButton extends Component { onConfirmed={this.submit} question={confirmQuestion} disabled={!confirmQuestion}> - @@ -176,6 +183,7 @@ SubmitButton.propTypes = { }), confirmQuestion: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), noShadow: PropTypes.bool, + size: PropTypes.string, intl: PropTypes.object, }; diff --git a/src/components/helpers/SourceCodeViewer/SourceCodeViewer.css b/src/components/helpers/SourceCodeViewer/SourceCodeViewer.css index c406958cd..5d1cb48a0 100644 --- a/src/components/helpers/SourceCodeViewer/SourceCodeViewer.css +++ b/src/components/helpers/SourceCodeViewer/SourceCodeViewer.css @@ -26,10 +26,79 @@ background-color: #d0d0d0; } -.sourceCodeViewer span[data-line-style="selected"] { +.sourceCodeViewer span[data-line-active] { background-color: #ff9; } -.sourceCodeViewer span[data-line-style="selected"] .linenumber { +.sourceCodeViewer span[data-line-active] .linenumber { background-color: yellow; } + +.sourceCodeViewer span[data-line-active]:hover, .sourceCodeViewer span[data-line-active]:hover * { + background-color: #e0e088; +} + +.sourceCodeViewer span[data-line-active]:hover .linenumber { + background-color: #dd0; +} + +.sourceCodeViewerComments { + background-color: #f7f7f7; + font-family: var(--font-family-sans-serif); + font-size: 127%; + padding: 0.2em; + white-space: initial; +} + +.sourceCodeViewerComments > div { + border: 1px solid #e0e0e0; + background-color: white; + border-radius: 0.2em; + padding: 0.5em; + position: relative; +} + +.sourceCodeViewerComments > div.issue { + background-color: #fff4f4; +} + +.sourceCodeViewerComments > div.commentForm { + background-color: #fffff0; +} + +.sourceCodeViewerComments > div:nth-of-type(2) { + margin-top: 0.2em; +} + +.sourceCodeViewerComments > div > .icon { + position: absolute; + top: 2.8em; + left: 0.5em; +} + +.sourceCodeViewerComments .actions { + line-height: 20px; + margin-left: 1.5em; + vertical-align: middle; +} + + +/* Markdown overrides */ + +.sourceCodeViewerComments > div > .recodex-markdown-container { + margin-top: 0.5rem; + padding: 0.5rem 0.5rem 0 28px; + border-top: 1px solid #f0f0f0; +} + +.sourceCodeViewerComments > div.issue > .recodex-markdown-container { + border-top-color:#f9e0e0; +} + +.sourceCodeViewerComments > div > .recodex-markdown-container p { + margin-bottom: 0.5rem; +} + +.sourceCodeViewerComments > div > .recodex-markdown-container p:last-child { + margin-bottom: 0.2rem; +} diff --git a/src/components/helpers/SourceCodeViewer/SourceCodeViewer.js b/src/components/helpers/SourceCodeViewer/SourceCodeViewer.js index a6572d551..50b4ab3b2 100644 --- a/src/components/helpers/SourceCodeViewer/SourceCodeViewer.js +++ b/src/components/helpers/SourceCodeViewer/SourceCodeViewer.js @@ -1,54 +1,251 @@ import React from 'react'; import PropTypes from 'prop-types'; +import { OverlayTrigger, Tooltip } from 'react-bootstrap'; +import { FormattedMessage } from 'react-intl'; import { canUseDOM } from 'exenv'; import { Prism as SyntaxHighlighter, createElement } from 'react-syntax-highlighter'; +import classnames from 'classnames'; +import { defaultMemoize } from 'reselect'; import { vs } from 'react-syntax-highlighter/dist/esm/styles/prism'; import 'prismjs/themes/prism.css'; +import UsersNameContainer from '../../../containers/UsersNameContainer'; +import ReviewCommentForm from '../../forms/ReviewCommentForm'; +import Confirm from '../../forms/Confirm'; +import DateTime from '../../widgets/DateTime'; +import Markdown from '../../widgets/Markdown'; +import Icon, { DeleteIcon, EditIcon, LoadingIcon, WarningIcon } from '../../icons'; import { getPrismModeFromExtension } from '../../helpers/syntaxHighlighting'; import { getFileExtensionLC } from '../../../helpers/common'; import './SourceCodeViewer.css'; -const linesRenderer = ({ rows, stylesheet, useInlineStyles }) => { - return rows.map((node, i) => ( - - {createElement({ - node, - stylesheet, - useInlineStyles, - key: `code-segement${i}`, - })} - - )); +const newCommentFormInitialValues = { + text: '', + issue: false, + suppressNotification: false, }; -const linePropsGenerator = lineNumber => ({ - 'data-line': lineNumber, +const groupCommentsByLine = defaultMemoize(comments => { + const res = {}; + + // group by + (comments || []).forEach(comment => { + res[comment.line] = res[comment.line] || []; + res[comment.line].push(comment); + }); + + // make sure each group is in ascending order (by time of creation) + const comparator = (a, b) => a.createdAt - b.createdAt; + Object.keys(res).forEach(line => { + res[line].sort(comparator); + }); + + return res; }); -const SourceCodeViewer = ({ name, content = '' }) => - canUseDOM ? ( - - {content} - - ) : ( - <> - ); +class SourceCodeViewer extends React.Component { + state = { activeLine: null, newComment: null, editComment: null, editInitialValues: null }; + + lineClickHandler = ev => { + ev.stopPropagation(); + window.getSelection()?.removeAllRanges(); + + // opens new comment form if no other form is currently open + const lineNumber = parseInt(ev.target.dataset.line); + if (lineNumber && !isNaN(lineNumber) && !this.state.activeLine) { + this.setState({ activeLine: lineNumber, newComment: lineNumber }); + } + }; + + startEditting = ({ id, line, text, issue }) => { + this.setState({ editComment: id, activeLine: line, newComment: null, editInitialValues: { text, issue } }); + }; + + closeForms = () => this.setState({ activeLine: null, newComment: null, editComment: null, editInitialValues: null }); + + createNewComment = ({ text, issue, suppressNotification = false }) => { + const { name, addComment } = this.props; + text = text.trim(); + return addComment({ + text, + issue, + suppressNotification, + file: name, + line: this.state.newComment, + }).then(this.closeForms); + }; + + editComment = ({ text, issue, suppressNotification = false }) => { + const { updateComment } = this.props; + text = text.trim(); + if (!text || !this.state.editComment) { + this.closeForms(); + return Promise.resove(); + } + + return updateComment({ + id: this.state.editComment, + text, + issue, + suppressNotification, + }).then(this.closeForms); + }; + + renderComment = comment => { + const { authorView = false, updateComment = null, removeComment = null, restrictCommentAuthor = null } = this.props; + return ( +
+ + {comment.issue ? ( + + {authorView ? ( + + ) : ( + + )} + + }> + + + ) : ( + + )} + + + + + + {comment.removing && } + + {updateComment && + !comment.removing && + (!restrictCommentAuthor || restrictCommentAuthor === comment.author) && ( + this.startEditting(comment)} /> + )} + {removeComment && + !comment.removing && + (!restrictCommentAuthor || restrictCommentAuthor === comment.author) && ( + this.props.removeComment(comment.id)} + question={ + + }> + + + )} + + + + + + + +
+ ); + }; + + linesRenderer = ({ rows, stylesheet, useInlineStyles }) => { + const comments = groupCommentsByLine(this.props.comments || []); + + return rows.map((node, i) => { + const lineNumber = i + 1; + return ( + + {createElement({ + node, + stylesheet, + useInlineStyles, + key: `cseg${lineNumber}`, + })} + + {(comments[lineNumber] || (this.state.newComment && this.state.newComment === lineNumber)) && ( +
+ {(comments[lineNumber] || []).map(comment => + this.state.editComment === comment.id ? ( + + ) : ( + {this.renderComment(comment)} + ) + )} + + {this.state.newComment && this.state.newComment === lineNumber && ( + + )} +
+ )} +
+ ); + }); + }; + + linePropsGenerator = lineNumber => ({ + 'data-line': lineNumber, + 'data-line-active': this.state.activeLine && this.state.activeLine === lineNumber ? '1' : undefined, + onDoubleClick: this.props.addComment ? this.lineClickHandler : undefined, + }); + + render() { + const { name, content = '' } = this.props; + return canUseDOM ? ( + + {content} + + ) : ( + <> + ); + } +} SourceCodeViewer.propTypes = { name: PropTypes.string.isRequired, content: PropTypes.string, + solutionId: PropTypes.string.isRequired, + comments: PropTypes.array, + addComment: PropTypes.func, + updateComment: PropTypes.func, + removeComment: PropTypes.func, + authorView: PropTypes.bool, + restrictCommentAuthor: PropTypes.string, + reviewClosed: PropTypes.bool, }; export default SourceCodeViewer; diff --git a/src/components/icons/index.js b/src/components/icons/index.js index 50f03fa5c..3ce0054f5 100644 --- a/src/components/icons/index.js +++ b/src/components/icons/index.js @@ -87,7 +87,12 @@ export const ReferenceSolutionIcon = props => ; export const RefreshIcon = props => ; export const RemoveIcon = props => ; export const ResultsIcon = props => ; -export const ReviewedIcon = props => ; +export const ReviewIcon = ({ review = null, ...props }) => + review && review.closedAt ? ( + 0 ? 'file-circle-exclamation' : 'file-circle-check'} /> + ) : ( + + ); export const SaveIcon = props => ; export const SearchIcon = props => ; export const ServerIcon = props => ; @@ -147,6 +152,10 @@ export const WarningIcon = props => ; export const ZipIcon = props => ; +ArchiveGroupIcon.propTypes = { + archived: PropTypes.bool, +}; + CircleIcon.propTypes = { selected: PropTypes.bool, }; @@ -160,8 +169,12 @@ GroupIcon.propTypes = { archived: PropTypes.bool, }; -ArchiveGroupIcon.propTypes = { - archived: PropTypes.bool, +ReviewIcon.propTypes = { + review: PropTypes.shape({ + startedAt: PropTypes.number, + closedAt: PropTypes.number, + issues: PropTypes.number, + }), }; SortedIcon.propTypes = { diff --git a/src/components/layout/HeaderSystemMessagesDropdown/HeaderSystemMessagesDropdown.js b/src/components/layout/HeaderSystemMessagesDropdown/HeaderSystemMessagesDropdown.js index 2613ef578..5209b6693 100644 --- a/src/components/layout/HeaderSystemMessagesDropdown/HeaderSystemMessagesDropdown.js +++ b/src/components/layout/HeaderSystemMessagesDropdown/HeaderSystemMessagesDropdown.js @@ -4,7 +4,7 @@ import { FormattedMessage } from 'react-intl'; import { Table, Dropdown } from 'react-bootstrap'; import UsersNameContainer from '../../../containers/UsersNameContainer'; -import Icon, { TypedMessageIcon } from '../../icons'; +import { MailIcon, TypedMessageIcon } from '../../icons'; import Markdown from '../../widgets/Markdown'; import DateTime from '../../widgets/DateTime'; import { getLocalizedText } from '../../../helpers/localizedData'; @@ -22,7 +22,7 @@ const HeaderSystemMessagesDropdown = ({ }) => ( - + {systemMessages.length > 0 && ( {systemMessages.length} )} diff --git a/src/containers/App/recodex.css b/src/containers/App/recodex.css index 79c8b799b..027c6ec2d 100644 --- a/src/containers/App/recodex.css +++ b/src/containers/App/recodex.css @@ -202,6 +202,10 @@ th.shrink-col { opacity: 0.8 !important; } +.half-gray { + filter: grayscale(50%); +} + /* * Misc */ @@ -434,7 +438,7 @@ select.form-control.is-invalid, .was-validated select.form-control:invalid { /* border-radius: 50%; } -/* On mouse-over, add a grey background color */ +/* On mouse-over, add a gray background color */ .radio-container label:hover input ~ .radiomark { background-color: #ccc; box-shadow: #fe9 0 0 0.4em 0.1em; diff --git a/src/containers/ReviewSolutionContainer/ReviewSolutionContainer.js b/src/containers/ReviewSolutionContainer/ReviewSolutionContainer.js index 8d17f265d..2a5112303 100644 --- a/src/containers/ReviewSolutionContainer/ReviewSolutionContainer.js +++ b/src/containers/ReviewSolutionContainer/ReviewSolutionContainer.js @@ -1,39 +1,52 @@ import React from 'react'; import PropTypes from 'prop-types'; import { connect } from 'react-redux'; +import ImmutablePropTypes from 'react-immutable-proptypes'; import ReviewSolution from '../../components/buttons/ReviewSolution'; -import { setSolutionFlag } from '../../redux/modules/solutions'; -import { isReviewed, isSetFlagPending } from '../../redux/selectors/solutions'; +import ResourceRenderer from '../../components/helpers/ResourceRenderer'; -const ReviewSolutionContainer = ({ reviewed, reviewPending, setReviewed, unsetReviewed, ...props }) => { +import { setSolutionReviewState, deleteSolutionReview } from '../../redux/modules/solutionReviews'; +import { getSolution } from '../../redux/selectors/solutions'; +import { isSolutionReviewUpdatePending } from '../../redux/selectors/solutionReviews'; + +const ReviewSolutionContainer = ({ id, solution, updatePending, openReview, closeReview, deleteReview, ...props }) => { return ( - + + {solution => ( + + )} + ); }; ReviewSolutionContainer.propTypes = { id: PropTypes.string.isRequired, - reviewed: PropTypes.bool.isRequired, - reviewPending: PropTypes.bool.isRequired, - setReviewed: PropTypes.func.isRequired, - unsetReviewed: PropTypes.func.isRequired, + solution: ImmutablePropTypes.map, + updatePending: PropTypes.bool, + openReview: PropTypes.func.isRequired, + closeReview: PropTypes.func.isRequired, + deleteReview: PropTypes.func.isRequired, }; const mapStateToProps = (state, { id }) => ({ - reviewed: isReviewed(state, id), - reviewPending: isSetFlagPending(state, id, 'reviewed'), + solution: getSolution(state, id), + updatePending: isSolutionReviewUpdatePending(state, id), }); const mapDispatchToProps = (dispatch, { id }) => ({ - setReviewed: () => dispatch(setSolutionFlag(id, 'reviewed', true)), - unsetReviewed: () => dispatch(setSolutionFlag(id, 'reviewed', false)), + openReview: () => dispatch(setSolutionReviewState(id, false)), + closeReview: () => dispatch(setSolutionReviewState(id, true)), + deleteReview: () => dispatch(deleteSolutionReview(id)), }); export default connect(mapStateToProps, mapDispatchToProps)(ReviewSolutionContainer); diff --git a/src/containers/SourceCodeViewerContainer/SourceCodeViewerContainer.js b/src/containers/SourceCodeViewerContainer/SourceCodeViewerContainer.js index 3ead32822..e8550fc9f 100644 --- a/src/containers/SourceCodeViewerContainer/SourceCodeViewerContainer.js +++ b/src/containers/SourceCodeViewerContainer/SourceCodeViewerContainer.js @@ -85,7 +85,7 @@ class SourceCodeViewerContainer extends Component {
- +
@@ -163,7 +163,7 @@ class SourceCodeViewerContainer extends Component { {content.malformedCharacters ? (
{content.content}
) : ( - + )}
diff --git a/src/locales/cs.json b/src/locales/cs.json index 4c8712685..991cf3578 100644 --- a/src/locales/cs.json +++ b/src/locales/cs.json @@ -6,6 +6,7 @@ "app.ExercisePrefixIcons.isLocked": "Úloha je zamčena autorem a nemůže být zadávána", "app.ExercisePrefixIcons.isPrivate": "Úloha je soukromá (viditelná pouze autorovi).", "app.acceptGroupInvitation.acceptAndJoin": "Přijmout pozvání do skupiny", + "app.acceptGroupInvitation.alreadyMember": "Již jste členem odpovídající skupiny.", "app.acceptGroupInvitation.expireAt": "Pozvánka pozbyde platnosti v", "app.acceptGroupInvitation.expired": "Pozvánka již není platná.", "app.acceptGroupInvitation.failed": "Nepodařilo se vás přidat do skupiny. Zkontrolujte platnost pozvánky a zkuste akci opakovat.", @@ -22,8 +23,8 @@ "app.acceptInvitation.title": "Přijmout pozvání", "app.acceptInvitation.userName": "Zvaná osoba", "app.acceptInvitation.welcome": "Gratulujeme, byli jste pozváni do ReCodExu! Níže si můžete vytvořit nový účet dokončením registračního procesu. Vaše profilové údajé již byly předvyplněny a není možné je měnit. Nyní zbývá pouze vytvořit heslo k vašemu novému účtu.", - "app.acceptSolution.accepted": "Zrušit jako finální", - "app.acceptSolution.acceptedShort": "Neakceptovat", + "app.acceptSolution.accepted": "Zrušit akceptaci", + "app.acceptSolution.acceptedShort": "Odakceptovat", "app.acceptSolution.notAccepted": "Akceptovat jako finální", "app.acceptSolution.notAcceptedShort": "Akceptovat", "app.addExerciseTagForm.submit": "Přidat nálepku", @@ -1434,8 +1435,18 @@ "app.resultsTable.cancelOnlyShowMe": "Zobrazit všechny studenty ve skupině", "app.resultsTable.onlyShowMe": "Skrýt všechny kromě mě", "app.resultsTable.total": "Celkem", - "app.reviewedSolution.revoke": "Zrušit revizi", - "app.reviewedSolution.set": "Revidovat", + "app.reviewCommentForm.isIssue": "K vyřešení", + "app.reviewCommentForm.isIssueExplanation": "U komentářů označených jako připomínky k vyřešení se očekává že je autor opraví v následujícím odevzdaném řešení.", + "app.reviewCommentForm.labelEdit": "Upravit existující komentář:", + "app.reviewCommentForm.labelNew": "Vytvořit nový komentář:", + "app.reviewCommentForm.suppressNotification": "Neodesílat oznámení", + "app.reviewCommentForm.suppressNotificationExplanation": "Poté, co byla revize uzavřena, se s každou provedenou změnou posílá oznámení autorovi. Můžete potlačit odeslání oznámení, pokud provádíte pouze drobnou úpravu.", + "app.reviewSolutionButtons.close": "Uzavřít revizi", + "app.reviewSolutionButtons.delete": "Smazat revizi", + "app.reviewSolutionButtons.deleteConfirm": "Všechny komentáře v kódu budou smazány společně s revizí. Opravdu si přejete smazat?", + "app.reviewSolutionButtons.markReviewed": "Označit za revidované", + "app.reviewSolutionButtons.open": "Zahájit revizi", + "app.reviewSolutionButtons.reopen": "Znovu otevřít revizi", "app.roles.description.empoweredSupervisor": "Privilegovaná verze role vedoucího, která navíc přidává možnost vytvářet vlastní pipelines a použít tyto pipelines pro složitější konfigurace úloh.", "app.roles.description.student": "Student je nejméně privilegovanou rolí, která má práva nahlížet pouze do skupin jejichž je členem a v těchto skupinách odevzdávat řešení úloh.", "app.roles.description.superadmin": "Všemohoucí a vševidoucí uživatel, který může bez omezení provést cokoli v instancích, do kterých patří. Podobně jako root v linuxu nebo Q ve Startreku.", @@ -1609,7 +1620,7 @@ "app.solution.explanations.environment": "Název běhového prostředí (tj. programovacího jazyka, kompilačních a běhových nastavení, atd.), které bylo použito pro toto řešení.", "app.solution.explanations.note": "Krátká poznámka od autora úlohy, která může posloužit k rozlišení jednotlivých řešení jendé úlohy. Poznámku vidí i vyučující, takže ji lze i využít k předání informací tímto směrem (nicméně pro delší konverzaci je vhodnější použít komentáře).", "app.solution.explanations.reevaluatedBy": "Tato položka indikuje, že řešení bylo znovu vyhodnoceno, takže zobrazované výsledky se můžou lišit od toho, co si pamatujete. Opětovné vyhodnocení se používá v situacích, kdy dojde k interní chybě nebo se změnily parametry úlohy (zejména při opravě problémů). Tato položka pak uchovává jméno člověka, který je zodpovědný za spuštění opětovného vyhodnocení.", - "app.solution.explanations.reviewed": "Příznak, zda vyučující osobně prohlédl řešení. Může být použit pro zlepšení navigace v historii odevzdaných řešení, zejména pokud se očekává, že studenti odevzdávájí opravené verze úkolů.", + "app.solution.explanations.reviews": "Ukazuje poslední změnu stavu revize řešení. Revize musí být zahájena, než začne vyučující přidávat komentáře ke zdrojovým kódům. Jakmile je revize uzavřena, komentáře se stanou viditelné autorovi řešení. Komentáře se zobrazují na stránce s odevzdanými soubory.", "app.solution.explanations.scoredPoints": "Body udělené tomuto řešení a aktuální bodový limit platný v čase odevzdání řešení. Použijte odkaz \"vysvětlit\" pro zobrazení detailnějšího popisu, jak bylo udělení bodů provedeno.", "app.solution.explanations.submittedAt": "Čas odevzdání řešení do ReCodExu. Tento čas je důležitý zejména pro hodnocení úlohy, protože určuje, zda bylo řešení odevzdáno včas (tj. před termínem) nebo pozdě.", "app.solution.lastReviewed": "poslední revidované", @@ -1623,7 +1634,11 @@ "app.solution.pointsExplainDialog.epsilonExplain": "Epsilon (1e-6) je přičteno, aby kompenzovalo zaokrouhlovací chyby.", "app.solution.pointsExplainDialog.overriddenPoints": "Vedoucí ručně přepsal hodnocení úlohy na {overriddenPoints} {overriddenPoints, plural, one {bod} =2 {body} =3 {body} =4 {body} other {bodů}} (a {bonusPoints} {bonusPoints, plural, one {bonusový bod} =2 {bonusové body} =3 {bonusové body} =4 {bonusové body} other {bonusových bodů}}).", "app.solution.pointsExplainDialog.title": "Vysvětlení hodnotícího procesu", - "app.solution.reviewed": "Revidováno", + "app.solution.reviewClosedAt": "Revidováno v", + "app.solution.reviewIssuesCount": "{issues} {count, plural, one {připomínka} =2 {připomínky} =3 {připomínky} =4 {připomínky} other {připomínek}} k vyřešení", + "app.solution.reviewNotStartedYet": "dosud nezahájeno", + "app.solution.reviewPendingAbout": "Probíhá revize řešení. Prosíme, až dokončíte kontrolu kódu, nezapomeňte revizi uzavřít. Autor řešení nevidí komentáře, dokud revize není uzavřena.", + "app.solution.reviewStartedAt": "Revize zahájena v", "app.solution.scoredPoints": "Výsledné body", "app.solution.solutionAttempt": "Pokus řešení", "app.solution.solutionAttemptValue": "{index} {count, plural, =2 {ze} =3 {ze} =4 {ze} other {z}} {count}", @@ -1642,8 +1657,13 @@ "app.solutionFiles.sizeLimitExceeded": "Celková velikost všech odevzdaných souborů překročila výchozí limit ({limit} KiB).", "app.solutionFiles.title": "Odevzdané soubory", "app.solutionFiles.total": "Celkem:", + "app.solutionReviewIcon.tooltip.closedAt": "Revize byla dokončena v {closed}, komentáře jsou viditelné na stránce s odevzdanými soubory.", + "app.solutionReviewIcon.tooltip.issues": "Recenzent vám zanechal {issues} {issues, plural, one {připomínku} =2 {připomínky} =3 {připomínky} =4 {připomínky} other {připomínek}} k vyřešení. Prosíme, opravte {issues, plural, one {ji} other {je}} v následujícím odevzdaném řešení.", + "app.solutionReviewIcon.tooltip.noIssues": "Nebyly vytvořeny žádné připomínky k vyřešení.", + "app.solutionReviewIcon.tooltip.startedAt": "Revize byla zahájena v {started} a zatím nebyla dokončena.", "app.solutionSourceCodes.adjustMappingTooltip": "Změnit, který soubor z druhého řešení bude porovnán s tímto souborem.", "app.solutionSourceCodes.cancelDiffButton": "Vypnout srovnávací režim", + "app.solutionSourceCodes.codeReviewsAbout": "Zde můžete provést revizi odevzdaných zdrojových souborů a komentovat jednotlivé řádky kódu. Jakmile zahájite revizi, můžete začít přidávat komentáře dvojklikem na požadované řádky kódu. Autor řešení uvidí komentáře, až když revizi uzavřete. Komentáře nejsou viditelné ve srovávacím režimu.", "app.solutionSourceCodes.diffButton": "Porovnat s...", "app.solutionSourceCodes.diffButtonChange": "Porovnat s jiným...", "app.solutionSourceCodes.diffModal.explain": "Jakmile vyberete druhé řešení z tabulky níže, zobrazí se rozdíly odpovídajících souborů v dvousloupcovém pohledu. Aktuální řešení bude zobrazeno nalevo, druhé vybrané řešení napravo.", @@ -1658,6 +1678,11 @@ "app.solutionSourceCodes.mappingModal.resetButton": "Výchozí mapování", "app.solutionSourceCodes.mappingModal.title": "Upravit mapování porovnávaných souborů", "app.solutionSourceCodes.noDiffWithFile": "žádný odpovídající soubor pro porovnání nebyl nalezen", + "app.solutionSourceCodes.reviewClosedAuthorInfoNoIssues": "Vaše řešení bylo revidováno a nejsou k němu vedeny žádné připomínky.", + "app.solutionSourceCodes.reviewClosedAuthorInfoWithIssues": "Vaše řešení bylo revidováno. Máte celkem {issues} {issues, plural, one {připomínku} =2 {připomínky} =3 {připomínky} =4 {připomínky} other {připomínek}} k vyřešení.", + "app.solutionSourceCodes.reviewClosedInfo": "Autor řešení nyní vidí komentáře revize. Komentáře můžete stále upravovat, ale každá změna bude oznámena autorovi formou emailové notifikace. Pokud si přejete udělat zásadní změny, znovu otevřete revizi, proveďte je, a opět revizi zavřete.", + "app.solutionSourceCodes.reviewPendingAbout": "Revize je v tuto chvíli otevřena. Můžete přidávat komentáře dvojklikem na zvolenou řádku kodu.", + "app.solutionSourceCodes.reviewPendingNeedsClosing": "Vezměte prosím na vědomí, že komentáře v kódu nejsou viditelné autorovi řešení, dokud revizi neuzavřete. Při uzavření bude také autorovi odeslán email s oznámením.", "app.solutionSourceCodes.right": "Napravo", "app.solutionSourceCodes.title": "Přehled odevzdaných zdrojových souborů řešení", "app.solutionSourceCodes.titleDiff": "Srovnání zdrojových souborů dvou řešení", @@ -1675,14 +1700,16 @@ "app.solutionsTable.noSolutionsFound": "Zatím nebyla odevzdána žádná řešení.", "app.solutionsTable.note": "Poznámka", "app.solutionsTable.receivedPoints": "Body", - "app.solutionsTable.reviewedTooltip": "Řešení bylo posouzeno vedoucím.", "app.solutionsTable.rowsCount": "Celkem záznamů: {count}", "app.solutionsTable.solutionValidity": "Správnost", "app.solutionsTable.submissionDate": "Datum odevzdání", "app.solutionsTable.submitNewSolution": "Odevzdat nové řešení", "app.solutionsTable.title": "Odevzdaná řešení", + "app.sourceCodeViewer.deleteCommentConfirm": "Opravdu si přejete odstranit tento komentář? Tuto operaci nelze vrátit.", "app.sourceCodeViewer.downloadButton": "Stáhnout soubor", "app.sourceCodeViewer.incompleteWarning": "Zdrojový soubor je příliš velký, a proto zde nemůže být zobrazen. Použijte tlačítko 'Stáhnout soubor', pokud si jej chcete prohlédnout celý.", + "app.sourceCodeViewer.issueTooltip": "Tento komentář je označen jako připomínka k vyřešení, což znamená, že ji autor vyřeší v následujícím odevzdaném řešení.", + "app.sourceCodeViewer.issueTooltipForAuthor": "Tento komentář je označen jako připomínka k vyřešení, což znamená, že ji máte vyřešit v následujícím odevzdaném řešení.", "app.sourceCodeViewer.utf8Warning": "Zdrojový soubor není v platném UTF-8 kódování. Některé znaky proto nemusí být zobrazeny správně. Pokud potřebujete soubor v původním kódování, použijte tlačítko k jeho stažení.", "app.studentsList.gainedPointsOfWithoutBreakingSpaces": "{gained, number} z {total, number}", "app.studentsList.noStudents": "V tomto seznamu nejsou žádní studenti.", diff --git a/src/locales/en.json b/src/locales/en.json index bd4ebd315..02a2efb80 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -6,6 +6,7 @@ "app.ExercisePrefixIcons.isLocked": "Exercise is locked by the author and cannot be assigned.", "app.ExercisePrefixIcons.isPrivate": "Exercise is private (visible only to author).", "app.acceptGroupInvitation.acceptAndJoin": "Accept invitation and join the group", + "app.acceptGroupInvitation.alreadyMember": "You are already a member of the corresponding group.", "app.acceptGroupInvitation.expireAt": "Invitation expires at", "app.acceptGroupInvitation.expired": "The invitation link has expired.", "app.acceptGroupInvitation.failed": "Joining the group failed. Please verify the invitation data and try again.", @@ -1434,8 +1435,18 @@ "app.resultsTable.cancelOnlyShowMe": "Show all students in the group", "app.resultsTable.onlyShowMe": "Hide everyone except for myself", "app.resultsTable.total": "Total", - "app.reviewedSolution.revoke": "Revoke Review", - "app.reviewedSolution.set": "Review", + "app.reviewCommentForm.isIssue": "Issue", + "app.reviewCommentForm.isIssueExplanation": "Comments marked as issues are expected to be addressed and fixed by the author in the next submission.", + "app.reviewCommentForm.labelEdit": "Modify existing commment:", + "app.reviewCommentForm.labelNew": "Create new comment:", + "app.reviewCommentForm.suppressNotification": "Suppress e-mail notification", + "app.reviewCommentForm.suppressNotificationExplanation": "When the review is closed, a notification is sent to the author with every change. You may suppress the notification if the change you are performing is not significant.", + "app.reviewSolutionButtons.close": "Close Review", + "app.reviewSolutionButtons.delete": "Erase Review", + "app.reviewSolutionButtons.deleteConfirm": "All review comments will be erased as well. Do you wish to proceed?", + "app.reviewSolutionButtons.markReviewed": "Mark as Reviewed", + "app.reviewSolutionButtons.open": "Start Review", + "app.reviewSolutionButtons.reopen": "Reopen Review", "app.roles.description.empoweredSupervisor": "A more priviledged version of supervisor who is also capable of creating custom pipelines and configure exercises using these pipelines.", "app.roles.description.student": "Student is the least priviledged user who can see only groups he/she is member of and solve assignments inside these groups.", "app.roles.description.superadmin": "Omnipotent and omniscient user who can do anything in the instances to which he/she belongs to. Similar to root in linux or Q in Startrek.", @@ -1609,7 +1620,7 @@ "app.solution.explanations.environment": "The name of runtime environment (i.e., programming language, compilation settings, runtime setup, etc.) selected for the solution.", "app.solution.explanations.note": "Short note left by the author of the solution that can be used to distinguish between solutions of one assignment. The note is also visible by teachers, so it can be used to pass brief information to them (however, comments are more suitable for elaborate conversations).", "app.solution.explanations.reevaluatedBy": "If present, the solution was re-evaluated so its evaluation results may be different than you remember. Re-evaluation is typically used in case of failures or if the parameters of the exercise needs to be changed (e.g., to fix problems). The field holds the name of person responsible for initiating the re-evaluation.", - "app.solution.explanations.reviewed": "A marker that indicates whether the teacher has personally reviewed the solution. It may help navigate through the history of solutions, especially when the students are expected to make revisions.", + "app.solution.explanations.reviews": "Indicates last change in the review state. The review is started before the teacher can make any comments. When the review is closed, all comments become visible to the author. Review comments are visible at the submitted files page.", "app.solution.explanations.scoredPoints": "Points awarded to this soluion and current points limit that was vaild at the time the solution was uploaded. Click the explanation link for more details about the scoring process.", "app.solution.explanations.submittedAt": "Time when the solution was uploaded to ReCodEx. The time is important for the scoring since it determines whether the solution is on time or late with respect to the deadline(s).", "app.solution.lastReviewed": "last reviewed", @@ -1623,7 +1634,11 @@ "app.solution.pointsExplainDialog.epsilonExplain": "The epsilon (1e-6) is added to compensate for rounding errors.", "app.solution.pointsExplainDialog.overriddenPoints": "A supervisor has manually overridden the points to {overriddenPoints} (and {bonusPoints} {bonusPoints, plural, one {point} other {points}}).", "app.solution.pointsExplainDialog.title": "Explanation of the scoring process", - "app.solution.reviewed": "Reviewed", + "app.solution.reviewClosedAt": "Reviewed at", + "app.solution.reviewIssuesCount": "{issues} {issues, plural, one {issue} other {issues}} to resolve", + "app.solution.reviewNotStartedYet": "not started yet", + "app.solution.reviewPendingAbout": "The solution is currently under review. Please, do not forget to close the review when you are done since the author does not see the comments until the review is closed.", + "app.solution.reviewStartedAt": "Review started at", "app.solution.scoredPoints": "Final score", "app.solution.solutionAttempt": "Solution Attempt", "app.solution.solutionAttemptValue": "{index} of {count}", @@ -1642,8 +1657,13 @@ "app.solutionFiles.sizeLimitExceeded": "The total size of all submitted files exceeds the default solution size limit ({limit} KiB).", "app.solutionFiles.title": "Submitted Files", "app.solutionFiles.total": "Total:", + "app.solutionReviewIcon.tooltip.closedAt": "The review was closed at {closed}, the comments are available at source codes page.", + "app.solutionReviewIcon.tooltip.issues": "The reviewer created {issues} {issues, plural, one {issue} other {issues}}, please, fix {issues, plural, one {it} other {them}} in the next solution.", + "app.solutionReviewIcon.tooltip.noIssues": "No issues were created.", + "app.solutionReviewIcon.tooltip.startedAt": "The review was started at {started} and has not been closed yet.", "app.solutionSourceCodes.adjustMappingTooltip": "Adjust file mappings by selecting which file from the second solution will be compared to this file.", "app.solutionSourceCodes.cancelDiffButton": "Compare mode off", + "app.solutionSourceCodes.codeReviewsAbout": "You may create a code review here and assign comments directly to individual lines of code. When a review is started, you can add comments by double-clicking the associated line of code. The comments will become visible to the author when the review is closed. The reviews are not visible when the compare mode is active.", "app.solutionSourceCodes.diffButton": "Compare with...", "app.solutionSourceCodes.diffButtonChange": "Compare with another...", "app.solutionSourceCodes.diffModal.explain": "When second solution is selected for comparison from the table below, the differences of the corresponding files will be displayed in a two-column views. The current solution will be displayed on the left, the second solution on the right.", @@ -1658,6 +1678,11 @@ "app.solutionSourceCodes.mappingModal.resetButton": "Reset mapping", "app.solutionSourceCodes.mappingModal.title": "Adjust mapping of compared files", "app.solutionSourceCodes.noDiffWithFile": "no corresponding file for the comparison found", + "app.solutionSourceCodes.reviewClosedAuthorInfoNoIssues": "Your solution was reviewed and no issues were reported.", + "app.solutionSourceCodes.reviewClosedAuthorInfoWithIssues": "Your solution was reviewed. You have {issues} {issues, plural, one {issue} other {issues}} to fix.", + "app.solutionSourceCodes.reviewClosedInfo": "The review comments are now visible to the author. You may still edit the review, but each modification will be sent as an email notification to the author. If you wish to make more significant changes, re-open the review, make the modifications, and close it again.", + "app.solutionSourceCodes.reviewPendingAbout": "A review is currently open. You may add comments in the code by double-clicking on the associated line.", + "app.solutionSourceCodes.reviewPendingNeedsClosing": "Please note that the comments in the code are not visible to the author until you close the review. A notification mail will be sent to the author when you close it.", "app.solutionSourceCodes.right": "Right side", "app.solutionSourceCodes.title": "Solution Source Code Files Overview", "app.solutionSourceCodes.titleDiff": "Comparing Source Codes of Two Solutions", @@ -1675,14 +1700,16 @@ "app.solutionsTable.noSolutionsFound": "No solutions were submitted yet.", "app.solutionsTable.note": "Note", "app.solutionsTable.receivedPoints": "Points", - "app.solutionsTable.reviewedTooltip": "The solution has been reviewed by the supervisor.", "app.solutionsTable.rowsCount": "Total records: {count}", "app.solutionsTable.solutionValidity": "Validity", "app.solutionsTable.submissionDate": "Date of submission", "app.solutionsTable.submitNewSolution": "Submit New Solution", "app.solutionsTable.title": "Submitted Solutions", + "app.sourceCodeViewer.deleteCommentConfirm": "Do you really wish to remove this comment? This operation cannot be undone.", "app.sourceCodeViewer.downloadButton": "Download file", "app.sourceCodeViewer.incompleteWarning": "The selected source file is too large. Only a leading part of the file is displayed here. Use the download button to get the whole file.", + "app.sourceCodeViewer.issueTooltip": "This comment is marked as an issue, which means the author is expected to fix it in the next submission.", + "app.sourceCodeViewer.issueTooltipForAuthor": "This comment is marked as an issue, which means you are expected to fix it in your next submission.", "app.sourceCodeViewer.utf8Warning": "The source file is not a valid UTF-8 file. Some characters may be displayed incorrectly. Use the download button to see unaltered source file.", "app.studentsList.gainedPointsOfWithoutBreakingSpaces": "{gained, number} of {total, number}", "app.studentsList.noStudents": "There are no students in this list.", diff --git a/src/pages/AssignmentStats/AssignmentStats.js b/src/pages/AssignmentStats/AssignmentStats.js index 69b11dd26..deb53265a 100644 --- a/src/pages/AssignmentStats/AssignmentStats.js +++ b/src/pages/AssignmentStats/AssignmentStats.js @@ -70,7 +70,8 @@ const prepareTableColumnDescriptors = defaultMemoize((loggedUserId, assignmentId )} {solution.permissionHints && solution.permissionHints.setFlag && ( - <> - - - + + )} + {solution.permissionHints && solution.permissionHints.review && ( + )} {solution.permissionHints && solution.permissionHints.delete && ( @@ -211,7 +212,7 @@ const prepareTableData = defaultMemoize( bonusPoints, actualPoints, accepted, - reviewed, + review, isBestSolution, commentsStats, permissionHints, @@ -220,7 +221,7 @@ const prepareTableData = defaultMemoize( lastSubmission && (lastSubmission.evaluationStatus === 'done' || lastSubmission.evaluationStatus === 'failed'); return { - icon: { id, commentsStats, lastSubmission, accepted, reviewed, isBestSolution }, + icon: { id, commentsStats, lastSubmission, accepted, review, permissionHints, isBestSolution }, user: usersIndex[authorId], date: createdAt, validity: statusEvaluated ? safeGet(lastSubmission, ['evaluation', 'score']) : null, diff --git a/src/pages/GroupUserSolutions/GroupUserSolutions.js b/src/pages/GroupUserSolutions/GroupUserSolutions.js index 5a4f73533..b1deef338 100644 --- a/src/pages/GroupUserSolutions/GroupUserSolutions.js +++ b/src/pages/GroupUserSolutions/GroupUserSolutions.js @@ -79,7 +79,8 @@ const prepareTableColumnDescriptors = defaultMemoize((assignments, groupId, loca )} {solution.permissionHints && solution.permissionHints.setFlag && ( - <> - - - + + )} + {solution.permissionHints && solution.permissionHints.review && ( + )} {solution.permissionHints && solution.permissionHints.delete && ( @@ -228,7 +229,7 @@ const prepareTableData = defaultMemoize( bonusPoints, actualPoints, accepted, - reviewed, + review, isBestSolution, commentsStats, permissionHints, @@ -239,7 +240,7 @@ const prepareTableData = defaultMemoize( const rte = getRuntime(runtimeEnvironmentId); res.push({ - icon: { id, commentsStats, lastSubmission, accepted, reviewed, isBestSolution }, + icon: { id, commentsStats, lastSubmission, accepted, review, permissionHints, isBestSolution }, assignment, date: createdAt, validity: statusEvaluated ? safeGet(lastSubmission, ['evaluation', 'score']) : null, diff --git a/src/pages/Solution/Solution.js b/src/pages/Solution/Solution.js index bf8ab9ec6..954bb6ecc 100644 --- a/src/pages/Solution/Solution.js +++ b/src/pages/Solution/Solution.js @@ -1,6 +1,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; +import { Row, Col } from 'react-bootstrap'; import { connect } from 'react-redux'; import { FormattedMessage, injectIntl } from 'react-intl'; import { defaultMemoize } from 'reselect'; @@ -14,6 +15,7 @@ import ReviewSolutionContainer from '../../containers/ReviewSolutionContainer'; import ResubmitSolutionContainer from '../../containers/ResubmitSolutionContainer'; import FetchManyResourceRenderer from '../../components/helpers/FetchManyResourceRenderer'; import { TheButtonGroup } from '../../components/widgets/TheButton'; +import Callout from '../../components/widgets/Callout'; import { fetchRuntimeEnvironments } from '../../redux/modules/runtimeEnvironments'; import { fetchAssignmentIfNeeded } from '../../redux/modules/assignments'; @@ -125,49 +127,70 @@ class Solution extends Component { canViewUserProfile={hasPermissions(assignment, 'viewAssignmentSolutions')} /> - {(hasPermissions(solution, 'setFlag') || hasPermissions(assignment, 'resubmitSubmissions')) && ( -
- + {(hasPermissions(solution, 'setFlag') || + hasPermissions(solution, 'review') || + hasPermissions(assignment, 'resubmitSubmissions')) && ( + + {hasPermissions(solution, 'setFlag') && ( - <> - + + )} + {hasPermissions(solution, 'review') && ( + - + )} + + + + + {hasPermissions(assignment, 'resubmitSubmissions') && + assignmentHasRuntime(assignment, solution.runtimeEnvironmentId) && ( + <> + + + + )} + {hasPermissions(assignment, 'resubmitSubmissions') && - assignmentHasRuntime(assignment, solution.runtimeEnvironmentId) && ( - <> - + + - - + )} - - - {hasPermissions(assignment, 'resubmitSubmissions') && - !assignmentHasRuntime(assignment, solution.runtimeEnvironmentId) && ( - - - - - )} -
+ + )} + + {hasPermissions(solution, 'review') && + solution.review && + solution.review.startedAt && + !solution.review.closedAt && ( + + + + )} + {runtimes => ( diff --git a/src/pages/SolutionSourceCodes/SolutionSourceCodes.js b/src/pages/SolutionSourceCodes/SolutionSourceCodes.js index fe92d71eb..56f58adf9 100644 --- a/src/pages/SolutionSourceCodes/SolutionSourceCodes.js +++ b/src/pages/SolutionSourceCodes/SolutionSourceCodes.js @@ -26,12 +26,18 @@ import ReviewSolutionContainer from '../../containers/ReviewSolutionContainer'; import SourceCodeBox from '../../components/Solutions/SourceCodeBox'; import RecentlyVisited from '../../components/Solutions/RecentlyVisited'; import { registerSolutionVisit } from '../../components/Solutions/RecentlyVisited/functions'; +import Callout from '../../components/widgets/Callout'; import { fetchRuntimeEnvironments } from '../../redux/modules/runtimeEnvironments'; import { fetchAssignmentIfNeeded } from '../../redux/modules/assignments'; import { fetchSolutionIfNeeded, fetchUsersSolutions } from '../../redux/modules/solutions'; import { fetchAssignmentSolutionFilesIfNeeded } from '../../redux/modules/solutionFiles'; -import { fetchSolutionReviewIfNeeded } from '../../redux/modules/solutionReviews'; +import { + fetchSolutionReviewIfNeeded, + addComment, + updateComment, + removeComment, +} from '../../redux/modules/solutionReviews'; import { download } from '../../redux/modules/files'; import { fetchContentIfNeeded } from '../../redux/modules/filesContent'; import { getSolution } from '../../redux/selectors/solutions'; @@ -44,138 +50,14 @@ import { } from '../../redux/selectors/assignments'; import { getFilesContentSelector } from '../../redux/selectors/files'; import { getLoggedInUserEffectiveRole } from '../../redux/selectors/users'; +import { loggedInUserIdSelector } from '../../redux/selectors/auth'; +import { loggedUserIsPrimaryAdminOfSelector } from '../../redux/selectors/usersGroups'; import { storageGetItem, storageSetItem, storageRemoveItem } from '../../helpers/localStorage'; import withLinks from '../../helpers/withLinks'; import { isSupervisorRole } from '../../components/helpers/usersRoles'; -import { - arrayToObject, - hasPermissions, - getFileExtensionLC, - isEmptyObject, - EMPTY_ARRAY, - EMPTY_OBJ, -} from '../../helpers/common'; - -const nameComparator = (a, b) => a.name.localeCompare(b.name, 'en'); - -/** - * Expand zip entries as regular files with adjusted parameters (name and ID are composed of the zip container and the entry itself). - */ -const preprocessZipEntries = ({ zipEntries, ...file }) => { - if (zipEntries) { - file.zipEntries = zipEntries - .filter(({ name, size }) => !name.endsWith('/') || size !== 0) - .map(({ name, size }) => ({ - entryName: name, - name: `${file.name}#${name}`, - size, - id: `${file.id}/${name}`, - parentId: file.id, - })) - .sort(nameComparator); - } - return file; -}; - -/** - * Preprocess zip entries, consolidate, and sort by names. - */ -const preprocessFiles = defaultMemoize(files => - files - .sort(nameComparator) - .map(preprocessZipEntries) - .reduce((acc, file) => [...acc, ...(file.zipEntries || [file])], []) -); - -/** - * @param {Array} files of the main solution - * @param {Array|null} secondFiles of the second solution to diffWith - * @param {Object} mapping explicit mappings as { firstId: secondId } - * @return {Array} copy of files array where file objects are augmented -- if a file is matched with a second file - * a `diffWith` entry is added into the file object - */ -const associateFilesForDiff = defaultMemoize((files, secondFiles, mapping = EMPTY_OBJ) => { - if (!secondFiles) { - return files; - } - - // create an index {name: file} and extensions index {ext: [ fileNames ]} - const index = {}; - const indexLC = {}; - const extensionsIndex = {}; - const usedSecondFiles = new Set(Object.values(mapping)); - secondFiles - .filter(file => !usedSecondFiles.has(file.id)) - .forEach(file => { - index[file.name] = file; - const nameLC = file.name.toLowerCase(); - indexLC[nameLC] = indexLC[nameLC] || []; - indexLC[nameLC].push(file.name); - const ext = getFileExtensionLC(file.name); - extensionsIndex[ext] = extensionsIndex[ext] || []; - extensionsIndex[ext].push(file.name); - }); - - // prepare a helper function that gets and removes file of given name from index - const getFile = name => { - const file = index[name] || null; - if (file) { - const nameLC = file.name.toLowerCase(); - indexLC[nameLC] = indexLC[nameLC].filter(n => n !== name); - const ext = getFileExtensionLC(name); - extensionsIndex[ext] = extensionsIndex[ext].filter(n => n !== name); - delete index[name]; - } - return file; - }; - - // four stage association -- 1. explicit mapping by IDs, 2. exact file name match, 3. lower-cased name match, 4. extensions match - // any ambiguity is treated as non-passable obstacle - return files - .map(file => { - // explicit mapping - const diffWith = mapping[file.id] && secondFiles.find(f => f.id === mapping[file.id]); - return diffWith ? { ...file, diffWith } : file; - }) - .map(file => { - // exact file name match - const diffWith = !file.diffWith ? getFile(file.name) : null; - return diffWith ? { ...file, diffWith } : file; - }) - .map(file => { - // lowercased file name match - if (!file.diffWith) { - const nameLC = file.name.toLowerCase(); - if (indexLC[nameLC] && indexLC[nameLC].length === 1) { - const diffWith = getFile(indexLC[nameLC].pop()); - return { ...file, diffWith }; - } - } - return file; - }) - .map(file => { - // file extension match - if (!file.diffWith) { - const ext = getFileExtensionLC(file.name); - if (extensionsIndex[ext] && extensionsIndex[ext].length === 1) { - const diffWith = getFile(extensionsIndex[ext].pop()); - return { ...file, diffWith }; - } - } - return file; - }); -}); - -/** - * Helper that computes reverted mapping { secondId: firstId } from the result of associateFilesForDiff. - */ -const getRevertedMapping = defaultMemoize(files => - arrayToObject( - files.filter(({ diffWith }) => Boolean(diffWith)), - ({ diffWith }) => diffWith.id - ) -); +import { hasPermissions, isEmptyObject, EMPTY_ARRAY } from '../../helpers/common'; +import { preprocessFiles, associateFilesForDiff, getRevertedMapping, groupReviewCommentPerFile } from './functions'; const fileNameAndEntry = file => [file.parentId || file.id, file.entryName || null]; @@ -333,8 +215,13 @@ class SolutionSourceCodes extends Component { fileContentsSelector, download, userSolutionsSelector, + loggedUserId, effectiveRole, runtimeEnvironments, + isPrimaryAdminOf, + addComment, + updateComment, + removeComment, match: { params: { solutionId, assignmentId, secondSolutionId }, }, @@ -418,169 +305,266 @@ class SolutionSourceCodes extends Component { /> )} + {isSupervisorRole(effectiveRole) && ( - - - {!diffMode && hasPermissions(solution, 'setFlag') && ( + <> + + + {!diffMode && ( + <> + {hasPermissions(solution, 'setFlag') && ( + + )} + {hasPermissions(solution, 'review') && ( + + + + )} + + )} + + + - - + + + {diffMode && ( + + )} - )} - + + - - - + + )} - {diffMode && ( - + )} - - - + + )} + + )} + + {loggedUserId === solution.authorId && ( + <> + {solution.review && solution.review.closedAt && ( + 0 ? 'warning' : 'success'}> + {solution.review.issues > 0 ? ( + + ) : ( + + )} + + )} + )} - {reviewCommentsRaw => - console.log(reviewCommentsRaw) || ( - - {filesRaw => ( - - {(secondFilesRaw = null) => { - const secondFiles = secondFilesRaw && preprocessFiles(secondFilesRaw); - const files = associateFilesForDiff( - preprocessFiles(filesRaw), - secondFiles, - this.state.diffMappings - ); - const revertedIndex = files && secondFiles && getRevertedMapping(files); - return ( - <> - {files.map(file => ( - - ))} - - {diffMode && secondFiles && ( - - - + {reviewCommentsRaw => ( + + {filesRaw => ( + + {(secondFilesRaw = null) => { + const secondFiles = secondFilesRaw && preprocessFiles(secondFilesRaw); + const files = associateFilesForDiff( + preprocessFiles(filesRaw), + secondFiles, + this.state.diffMappings + ); + const revertedIndex = files && secondFiles && getRevertedMapping(files); + const groupedReviewComments = + !diffMode && hasPermissions(solution, 'viewReview') + ? groupReviewCommentPerFile( + files, + reviewCommentsRaw, + solution.review && solution.review.closedAt, + isSupervisorRole(effectiveRole) + ) + : {}; + const canUpdateComments = + !diffMode && + hasPermissions(solution, 'review') && + solution.review && + solution.review.startedAt; + + return ( + <> + {files.map(file => ( + + ))} + + {diffMode && secondFiles && ( + + + + + + + +
+ + {this.state.mappingDialogOpenFile && this.state.mappingDialogOpenFile.name} + {' '} + +
+ + + {this.state.mappingDialogOpenFile && ( {content}, + }} /> -
-
- -
- - {this.state.mappingDialogOpenFile && this.state.mappingDialogOpenFile.name} - {' '} - -
- - - {this.state.mappingDialogOpenFile && ( + )} + + + + + {secondFiles.map(file => { + const selected = + this.state.mappingDialogDiffWith && + file.id === this.state.mappingDialogDiffWith.id; + return ( + + this.adjustDiffMapping(this.state.mappingDialogOpenFile.id, file.id) + }> + + + + + ); + })} + +
+ + {file.name} + {revertedIndex && revertedIndex[file.id] && ( + <> + + {revertedIndex[file.id].name} + + )} +
+ + {this.state.diffMappings && !isEmptyObject(this.state.diffMappings) && ( +
+ -
- )} -
-
- )} - - ); - }} -
- )} -
- ) - } + +
+ )} + + + )} + + ); + }} + + )} + + )} @@ -658,8 +642,10 @@ SolutionSourceCodes.propTypes = { }).isRequired, assignment: ImmutablePropTypes.map, secondAssignment: ImmutablePropTypes.map, + loggedUserId: PropTypes.string, effectiveRole: PropTypes.string, runtimeEnvironments: PropTypes.array, + isPrimaryAdminOf: PropTypes.func.isRequired, children: PropTypes.element, solution: ImmutablePropTypes.map, secondSolution: ImmutablePropTypes.map, @@ -670,6 +656,9 @@ SolutionSourceCodes.propTypes = { userSolutionsSelector: PropTypes.func.isRequired, loadAsync: PropTypes.func.isRequired, download: PropTypes.func.isRequired, + addComment: PropTypes.func.isRequired, + updateComment: PropTypes.func.isRequired, + removeComment: PropTypes.func.isRequired, intl: PropTypes.object, history: PropTypes.shape({ push: PropTypes.func.isRequired, @@ -705,13 +694,18 @@ export default withLinks( secondSolution && secondSolution.getIn(['data', 'assignmentId']) ? getAssignment(state)(secondSolution.getIn(['data', 'assignmentId'])) : null, + loggedUserId: loggedInUserIdSelector(state), effectiveRole: getLoggedInUserEffectiveRole(state), runtimeEnvironments: assignmentEnvironmentsSelector(state)(assignmentId), + isPrimaryAdminOf: loggedUserIsPrimaryAdminOfSelector(state), }; }, (dispatch, { match: { params } }) => ({ loadAsync: () => SolutionSourceCodes.loadAsync(params, dispatch), download: (id, entry = null) => dispatch(download(id, entry)), + addComment: comment => dispatch(addComment(params.solutionId, comment)), + updateComment: comment => dispatch(updateComment(params.solutionId, comment)), + removeComment: id => dispatch(removeComment(params.solutionId, id)), }) )(injectIntl(withRouter(SolutionSourceCodes))) ); diff --git a/src/pages/SolutionSourceCodes/functions.js b/src/pages/SolutionSourceCodes/functions.js new file mode 100644 index 000000000..eb48d7a7b --- /dev/null +++ b/src/pages/SolutionSourceCodes/functions.js @@ -0,0 +1,143 @@ +import { defaultMemoize } from 'reselect'; +import { arrayToObject, getFileExtensionLC, EMPTY_OBJ } from '../../helpers/common'; + +const nameComparator = (a, b) => a.name.localeCompare(b.name, 'en'); + +/** + * Expand zip entries as regular files with adjusted parameters (name and ID are composed of the zip container and the entry itself). + */ +const preprocessZipEntries = ({ zipEntries, ...file }) => { + if (zipEntries) { + file.zipEntries = zipEntries + .filter(({ name, size }) => !name.endsWith('/') || size !== 0) + .map(({ name, size }) => ({ + entryName: name, + name: `${file.name}#${name}`, + size, + id: `${file.id}/${name}`, + parentId: file.id, + })) + .sort(nameComparator); + } + return file; +}; + +/** + * Preprocess zip entries, consolidate, and sort by names. + */ +export const preprocessFiles = defaultMemoize(files => + files + .sort(nameComparator) + .map(preprocessZipEntries) + .reduce((acc, file) => [...acc, ...(file.zipEntries || [file])], []) +); + +/** + * @param {Array} files of the main solution + * @param {Array|null} secondFiles of the second solution to diffWith + * @param {Object} mapping explicit mappings as { firstId: secondId } + * @return {Array} copy of files array where file objects are augmented -- if a file is matched with a second file + * a `diffWith` entry is added into the file object + */ +export const associateFilesForDiff = defaultMemoize((files, secondFiles, mapping = EMPTY_OBJ) => { + if (!secondFiles) { + return files; + } + + // create an index {name: file} and extensions index {ext: [ fileNames ]} + const index = {}; + const indexLC = {}; + const extensionsIndex = {}; + const usedSecondFiles = new Set(Object.values(mapping)); + secondFiles + .filter(file => !usedSecondFiles.has(file.id)) + .forEach(file => { + index[file.name] = file; + const nameLC = file.name.toLowerCase(); + indexLC[nameLC] = indexLC[nameLC] || []; + indexLC[nameLC].push(file.name); + const ext = getFileExtensionLC(file.name); + extensionsIndex[ext] = extensionsIndex[ext] || []; + extensionsIndex[ext].push(file.name); + }); + + // prepare a helper function that gets and removes file of given name from index + const getFile = name => { + const file = index[name] || null; + if (file) { + const nameLC = file.name.toLowerCase(); + indexLC[nameLC] = indexLC[nameLC].filter(n => n !== name); + const ext = getFileExtensionLC(name); + extensionsIndex[ext] = extensionsIndex[ext].filter(n => n !== name); + delete index[name]; + } + return file; + }; + + // four stage association -- 1. explicit mapping by IDs, 2. exact file name match, 3. lower-cased name match, 4. extensions match + // any ambiguity is treated as non-passable obstacle + return files + .map(file => { + // explicit mapping + const diffWith = mapping[file.id] && secondFiles.find(f => f.id === mapping[file.id]); + return diffWith ? { ...file, diffWith } : file; + }) + .map(file => { + // exact file name match + const diffWith = !file.diffWith ? getFile(file.name) : null; + return diffWith ? { ...file, diffWith } : file; + }) + .map(file => { + // lowercased file name match + if (!file.diffWith) { + const nameLC = file.name.toLowerCase(); + if (indexLC[nameLC] && indexLC[nameLC].length === 1) { + const diffWith = getFile(indexLC[nameLC].pop()); + return { ...file, diffWith }; + } + } + return file; + }) + .map(file => { + // file extension match + if (!file.diffWith) { + const ext = getFileExtensionLC(file.name); + if (extensionsIndex[ext] && extensionsIndex[ext].length === 1) { + const diffWith = getFile(extensionsIndex[ext].pop()); + return { ...file, diffWith }; + } + } + return file; + }); +}); + +/** + * Helper that computes reverted mapping { secondId: firstId } from the result of associateFilesForDiff. + */ +export const getRevertedMapping = defaultMemoize(files => + arrayToObject( + files.filter(({ diffWith }) => Boolean(diffWith)), + ({ diffWith }) => diffWith.id + ) +); + +/** + * Prepare an object, where keys are file names (with #entries) and values are arrays of comments. + */ +export const groupReviewCommentPerFile = defaultMemoize((files, reviews, closed, isSupervisor) => { + const res = { '': [] }; // '' key is reserved for reviews that do not have a matching file + files.forEach(({ name }) => { + res[name] = []; + }); + + if ((!closed && !isSupervisor) || !reviews) { + return res; + } + + reviews.forEach(review => { + const key = res[review.file] ? review.file : ''; + res[key].push(review); + }); + + return res; +}); diff --git a/src/redux/modules/solutionReviews.js b/src/redux/modules/solutionReviews.js index 2d6e0d27f..c0026ff63 100644 --- a/src/redux/modules/solutionReviews.js +++ b/src/redux/modules/solutionReviews.js @@ -1,13 +1,13 @@ -import { handleActions, createAction } from 'redux-actions'; +import { handleActions } from 'redux-actions'; import { fromJS } from 'immutable'; import { createApiAction } from '../middleware/apiMiddleware'; -import factory, { initialState, createRecord, resourceStatus } from '../helpers/resourceManager'; -import { actionTypes as submissionActionTypes } from './submission'; -import { actionTypes as submissionEvaluationActionTypes } from './submissionEvaluations'; -import { getAssignmentSolversLastUpdate } from '../selectors/solutions'; -import { objectFilter } from '../../helpers/common'; - +import factory, { + initialState, + createRecord, + resourceStatus, + createActionsWithPostfixes, +} from '../helpers/resourceManager'; const resourceName = 'solutionReviews'; const apiEndpointFactory = id => `/assignment-solutions/${id}/review`; @@ -20,12 +20,44 @@ const { actions, actionTypes, reduceActions } = factory({ * Actions */ export { actionTypes }; -export const additionalActionTypes = {}; +export const additionalActionTypes = { + // createActionsWithPostfixes generates all 4 constants for async operations + ...createActionsWithPostfixes('ADD_COMMENT', 'recodex/solutionReviews'), + ...createActionsWithPostfixes('UPDATE_COMMENT', 'recodex/solutionReviews'), + ...createActionsWithPostfixes('REMOVE_COMMENT', 'recodex/solutionReviews'), +}; export const fetchSolutionReview = actions.fetchResource; export const fetchSolutionReviewIfNeeded = actions.fetchOneIfNeeded; +export const setSolutionReviewState = (id, close) => actions.updateResource(id, { close }); export const deleteSolutionReview = actions.removeResource; +export const addComment = (solutionId, comment) => + createApiAction({ + type: additionalActionTypes.ADD_COMMENT, + endpoint: `/assignment-solutions/${solutionId}/review-comment`, + method: 'POST', + meta: { solutionId }, + body: comment, + }); + +export const updateComment = (solutionId, { id, ...comment }) => + createApiAction({ + type: additionalActionTypes.UPDATE_COMMENT, + endpoint: `/assignment-solutions/${solutionId}/review-comment/${id}`, + method: 'POST', + meta: { solutionId, id }, + body: comment, + }); + +export const removeComment = (solutionId, id) => + createApiAction({ + type: additionalActionTypes.REMOVE_COMMENT, + endpoint: `/assignment-solutions/${solutionId}/review-comment/${id}`, + method: 'DELETE', + meta: { solutionId, id }, + }); + /** * Reducer */ @@ -34,6 +66,37 @@ const reducer = handleActions( Object.assign({}, reduceActions, { [actionTypes.FETCH_FULFILLED]: (state, { meta: { id }, payload: { reviewComments: data } }) => state.setIn(['resources', id], createRecord({ state: resourceStatus.FULFILLED, data })), + + [actionTypes.UPDATE_PENDING]: (state, { meta: { id } }) => + state.setIn(['resources', id, 'state'], resourceStatus.RELOADING), + + [actionTypes.UPDATE_FULFILLED]: (state, { meta: { id }, payload: { reviewComments: data } }) => + state.setIn(['resources', id], createRecord({ state: resourceStatus.FULFILLED, data })), + + [actionTypes.REMOVE_PENDING]: (state, { meta: { id } }) => + state.setIn(['resources', id, 'state'], resourceStatus.RELOADING), + + [actionTypes.REMOVE_FULFILLED]: (state, { meta: { id } }) => + state.setIn(['resources', id], createRecord({ state: resourceStatus.FULFILLED, data: [] })), + + [additionalActionTypes.ADD_COMMENT_FULFILLED]: (state, { meta: { solutionId }, payload: comment }) => + state.updateIn(['resources', solutionId, 'data'], comments => comments.push(fromJS(comment))), + + [additionalActionTypes.UPDATE_COMMENT_FULFILLED]: (state, { meta: { solutionId, id }, payload: comment }) => + state.updateIn(['resources', solutionId, 'data'], comments => + comments.map(c => (c.get('id') === id ? fromJS(comment) : c)) + ), + + [additionalActionTypes.REMOVE_COMMENT_PENDING]: (state, { meta: { solutionId, id } }) => + state.updateIn(['resources', solutionId, 'data'], comments => + comments.map(c => (c.get('id') === id ? c.set('removing', true) : c)) + ), + [additionalActionTypes.REMOVE_COMMENT_REJECTED]: (state, { meta: { solutionId, id } }) => + state.updateIn(['resources', solutionId, 'data'], comments => + comments.map(c => (c.get('id') === id ? c.set('removing', false) : c)) + ), + [additionalActionTypes.REMOVE_COMMENT_FULFILLED]: (state, { meta: { solutionId, id } }) => + state.updateIn(['resources', solutionId, 'data'], comments => comments.filter(c => c.get('id') !== id)), }), initialState ); diff --git a/src/redux/modules/solutions.js b/src/redux/modules/solutions.js index 77dc54c57..514ee463c 100644 --- a/src/redux/modules/solutions.js +++ b/src/redux/modules/solutions.js @@ -10,6 +10,7 @@ import factory, { } from '../helpers/resourceManager'; import { actionTypes as submissionActionTypes } from './submission'; import { actionTypes as submissionEvaluationActionTypes } from './submissionEvaluations'; +import { actionTypes as reviewsActionTypes } from './solutionReviews'; import { getAssignmentSolversLastUpdate } from '../selectors/solutions'; import { objectFilter } from '../../helpers/common'; @@ -291,6 +292,17 @@ const reducer = handleActions( ? newState.setIn(['resources', solutionId, 'didInvalidate'], true) : newState; }, + + [reviewsActionTypes.FETCH_FULFILLED]: (state, { meta: { id }, payload: { solution: data } }) => + state.setIn(['resources', id], createRecord({ state: resourceStatus.FULFILLED, data })), + + [reviewsActionTypes.UPDATE_FULFILLED]: (state, { meta: { id }, payload: { solution: data } }) => + state.setIn(['resources', id], createRecord({ state: resourceStatus.FULFILLED, data })), + + [reviewsActionTypes.REMOVE_FULFILLED]: (state, { meta: { id } }) => + state.getIn(['resources', id, 'state']) === resourceStatus.FULFILLED + ? state.setIn(['resources', id, 'data', 'review'], null) + : state, }), initialState ); diff --git a/src/redux/selectors/solutionReviews.js b/src/redux/selectors/solutionReviews.js index 28514fd32..b2a291d7b 100644 --- a/src/redux/selectors/solutionReviews.js +++ b/src/redux/selectors/solutionReviews.js @@ -1,4 +1,5 @@ import { createSelector } from 'reselect'; +import { resourceStatus } from '../helpers/resourceManager'; const getSolutionsReviewsResources = state => state.solutionReviews.get('resources'); const getParam = (_, id) => id; @@ -7,3 +8,8 @@ export const getSolutionReviewComments = createSelector( [getSolutionsReviewsResources, getParam], (reviewComments, id) => reviewComments.get(id) ); + +export const isSolutionReviewUpdatePending = createSelector( + [getSolutionsReviewsResources, getParam], + (reviewComments, id) => reviewComments.getIn([id, 'state']) === resourceStatus.RELOADING +); diff --git a/src/redux/selectors/solutions.js b/src/redux/selectors/solutions.js index d74d8eea7..9be93f815 100644 --- a/src/redux/selectors/solutions.js +++ b/src/redux/selectors/solutions.js @@ -5,8 +5,8 @@ import { fetchManyGroupStudentsSolutionsEndpoint, } from '../modules/solutions'; -const getParam = (state, id) => id; -const getParams = (state, ...params) => params; +const getParam = (_, id) => id; +const getParams = (_, ...params) => params; const getSolutionsRaw = state => state.solutions; export const getSolutions = state => getSolutionsRaw(state).get('resources'); @@ -16,10 +16,6 @@ export const isAccepted = createSelector([getSolutions, getParam], (solutions, i solutions.getIn([id, 'data', 'accepted'], false) ); -export const isReviewed = createSelector([getSolutions, getParam], (solutions, id) => - solutions.getIn([id, 'data', 'reviewed'], false) -); - export const isSetFlagPending = createSelector([getSolutions, getParams], (solutions, [id, flag]) => solutions.getIn([id, `pending-set-flag-${flag}`], false) ); diff --git a/src/redux/selectors/usersGroups.js b/src/redux/selectors/usersGroups.js index b16636fa5..9ae9f41a4 100644 --- a/src/redux/selectors/usersGroups.js +++ b/src/redux/selectors/usersGroups.js @@ -133,6 +133,11 @@ export const loggedUserIsObserverOfSelector = loggedUserIsMemberOfSelector('obse export const loggedUserIsSupervisorOfSelector = loggedUserIsMemberOfSelector('supervisors'); export const loggedUserIsAdminOfSelector = loggedUserIsMemberOfSelector('admins'); +export const loggedUserIsPrimaryAdminOfSelector = createSelector( + [loggedInUserIdSelector, groupsSelector], + (userId, groups) => groupId => groups.getIn([groupId, 'data', 'primaryAdminsIds'], EMPTY_LIST).includes(userId) +); + /* * Specialized selectors that we might want to replace/generalize in the future */