From 5c2a5647814f0b87a17689030bfb9172a11ab649 Mon Sep 17 00:00:00 2001 From: Martin Krulis Date: Mon, 12 Jun 2023 13:03:18 +0200 Subject: [PATCH] Refactoring solution actions (buttons) into one component (container) which works for top-level buttons as well as inline button-dropdown. --- .../SolutionsTable/SolutionsTableRow.js | 19 +- .../Solutions/SolutionActions/ActionButton.js | 47 +++++ .../SolutionActions/ActionDropdown.js | 77 ++++++++ .../SolutionActions/SolutionActions.js | 168 ++++++++++++++++++ .../Solutions/SolutionActions/index.js | 2 + .../Solutions/SourceCodeBox/SourceCodeBox.js | 2 - .../buttons/AcceptSolution/AcceptSolution.js | 54 ------ .../buttons/AcceptSolution/index.js | 2 - .../buttons/ReviewSolution/ReviewSolution.js | 128 ------------- .../buttons/ReviewSolution/index.js | 2 - .../EditAssignmentForm/InterpolationDialog.js | 2 +- .../AcceptedSolutionContainer.js | 33 ---- .../AcceptSolutionContainer/index.js | 2 - .../ReviewSolutionContainer/index.js | 2 - .../SolutionActionsContainer.js} | 40 +++-- .../SolutionActionsContainer/index.js | 2 + src/pages/AssignmentStats/AssignmentStats.js | 12 +- .../GroupUserSolutions/GroupUserSolutions.js | 24 ++- src/pages/Solution/Solution.js | 14 +- .../SolutionSourceCodes.js | 22 +-- 20 files changed, 353 insertions(+), 301 deletions(-) create mode 100644 src/components/Solutions/SolutionActions/ActionButton.js create mode 100644 src/components/Solutions/SolutionActions/ActionDropdown.js create mode 100644 src/components/Solutions/SolutionActions/SolutionActions.js create mode 100644 src/components/Solutions/SolutionActions/index.js delete mode 100644 src/components/buttons/AcceptSolution/AcceptSolution.js delete mode 100644 src/components/buttons/AcceptSolution/index.js delete mode 100644 src/components/buttons/ReviewSolution/ReviewSolution.js delete mode 100644 src/components/buttons/ReviewSolution/index.js delete mode 100644 src/containers/AcceptSolutionContainer/AcceptedSolutionContainer.js delete mode 100644 src/containers/AcceptSolutionContainer/index.js delete mode 100644 src/containers/ReviewSolutionContainer/index.js rename src/containers/{ReviewSolutionContainer/ReviewSolutionContainer.js => SolutionActionsContainer/SolutionActionsContainer.js} (51%) create mode 100644 src/containers/SolutionActionsContainer/index.js diff --git a/src/components/Assignments/SolutionsTable/SolutionsTableRow.js b/src/components/Assignments/SolutionsTable/SolutionsTableRow.js index 84cf2b7cc..209d8d542 100644 --- a/src/components/Assignments/SolutionsTable/SolutionsTableRow.js +++ b/src/components/Assignments/SolutionsTable/SolutionsTableRow.js @@ -7,9 +7,8 @@ import classnames from 'classnames'; import Points from './Points'; import EnvironmentsListItem from '../../helpers/EnvironmentsList/EnvironmentsListItem'; -import DeleteSolutionButtonContainer from '../../../containers/DeleteSolutionButtonContainer/DeleteSolutionButtonContainer'; -import AcceptSolutionContainer from '../../../containers/AcceptSolutionContainer'; -import ReviewSolutionContainer from '../../../containers/ReviewSolutionContainer'; +import DeleteSolutionButtonContainer from '../../../containers/DeleteSolutionButtonContainer'; +import SolutionActionsContainer from '../../../containers/SolutionActionsContainer'; import { DetailIcon, CodeFileIcon } from '../../icons'; import DateTime from '../../widgets/DateTime'; @@ -162,18 +161,8 @@ const SolutionsTableRow = ({ )} - {permissionHints && permissionHints.setFlag && ( - - )} - - {permissionHints && permissionHints.review && ( - + {permissionHints && (permissionHints.setFlag || permissionHints.review) && ( + )} {permissionHints && permissionHints.delete && ( diff --git a/src/components/Solutions/SolutionActions/ActionButton.js b/src/components/Solutions/SolutionActions/ActionButton.js new file mode 100644 index 000000000..0bb2b9e82 --- /dev/null +++ b/src/components/Solutions/SolutionActions/ActionButton.js @@ -0,0 +1,47 @@ +import React from 'react'; +import PropTypes from 'prop-types'; + +import Button from '../../widgets/TheButton'; +import OptionalTooltipWrapper from '../../widgets/OptionalTooltipWrapper'; +import Icon, { LoadingIcon } from '../../icons'; + +const ActionButton = ({ + id, + variant = 'success', + icon, + label, + shortLabel = label, + confirm, + pending, + captionAsTooltip, + size, + onClick, +}) => + confirm ? ( + + ) : ( + + + + ); + +ActionButton.propTypes = { + id: PropTypes.string.isRequired, + variant: PropTypes.string, + label: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired, + shortLabel: PropTypes.oneOfType([PropTypes.string, PropTypes.element]), + icon: PropTypes.oneOfType([PropTypes.string, PropTypes.array]).isRequired, + confirm: PropTypes.string, + pending: PropTypes.bool, + captionAsTooltip: PropTypes.bool, + size: PropTypes.string, + onClick: PropTypes.func.isRequired, +}; + +export default ActionButton; diff --git a/src/components/Solutions/SolutionActions/ActionDropdown.js b/src/components/Solutions/SolutionActions/ActionDropdown.js new file mode 100644 index 000000000..b4cce628d --- /dev/null +++ b/src/components/Solutions/SolutionActions/ActionDropdown.js @@ -0,0 +1,77 @@ +import React, { useState, useRef } from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { Dropdown, Overlay, Popover } from 'react-bootstrap'; + +import Button, { TheButtonGroup } from '../../widgets/TheButton'; +import Icon, { CloseIcon, EditIcon, LoadingIcon, SuccessIcon } from '../../icons'; + +const ActionDropdown = ({ actions, placement = 'bottom', id, captionAsTooltip }) => { + const [confirmAction, setConfirmAction] = useState(null); + const target = useRef(null); + + return ( + + + + {!captionAsTooltip && } + + + + {actions.map(action => ( + setConfirmAction(action) : action.handler} + disabled={action.pending}> + + {action.pending ? ( + + ) : ( + + )} + {action.label} + + + ))} + + + + + {confirmAction && confirmAction.confirm} + + + + + + + + + + ); +}; + +ActionDropdown.propTypes = { + actions: PropTypes.array.isRequired, + placement: PropTypes.string, + id: PropTypes.string.isRequired, + captionAsTooltip: PropTypes.bool, +}; + +export default ActionDropdown; diff --git a/src/components/Solutions/SolutionActions/SolutionActions.js b/src/components/Solutions/SolutionActions/SolutionActions.js new file mode 100644 index 000000000..7ff830078 --- /dev/null +++ b/src/components/Solutions/SolutionActions/SolutionActions.js @@ -0,0 +1,168 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { FormattedMessage } from 'react-intl'; +import { withRouter } from 'react-router'; + +import withLinks from '../../../helpers/withLinks'; + +import ActionButton from './ActionButton'; +import ActionDropdown from './ActionDropdown'; + +/** + * Action templates containing basic parameters: label, short (label), icon (name), variant (success if missing), + * and confirm (confirm yes/no message for a popover; no confirmation required if missing) + */ +const actionsTemplates = { + accept: { + short: , + label: , + icon: ['far', 'check-circle'], + pending: 'acceptPending', + }, + unaccept: { + short: , + label: , + icon: ['far', 'circle-xmark'], + variant: 'warning', + pending: 'acceptPending', + }, + + // review actions + open: { + label: , + icon: 'microscope', + variant: 'info', + }, + reopen: { + label: , + icon: 'person-digging', + variant: 'warning', + }, + openClose: { + label: , + icon: 'file-circle-check', + }, + close: { + label: , + icon: 'boxes-packing', + }, + delete: { + label: , + icon: 'trash', + variant: 'danger', + confirm: ( + + ), + }, +}; + +const knownActions = ['accept', 'unaccept', 'open', 'reopen', 'openClose', 'close', 'delete']; + +const SolutionActions = ({ + id, + solution, + acceptPending = false, + showAllButtons = false, + updatePending = false, + captionAsTooltip = false, + size = undefined, + dropdown = false, + setAccepted = null, + setReviewState = null, + deleteReview = null, + history: { push }, + location: { pathname }, + links: { SOLUTION_SOURCE_CODES_URI_FACTORY }, +}) => { + const review = solution && solution.review; + const assignmentId = solution && solution.assignmentId; + const accepted = solution && solution.accepted; + const permissionHints = solution && solution.permissionHints; + if (!permissionHints.setFlag) { + setAccepted = null; + } + if (!permissionHints.review) { + setReviewState = deleteReview = null; + } + + const reviewPageUri = SOLUTION_SOURCE_CODES_URI_FACTORY(assignmentId, id); + const isOnReviewPage = pathname === SOLUTION_SOURCE_CODES_URI_FACTORY(assignmentId, id); + + const actionHandlers = { + accept: !accepted && setAccepted && (() => setAccepted(true)), + unaccept: accepted && setAccepted && (() => setAccepted(false)), + open: setReviewState && (!review || !review.startedAt) && (() => setReviewState(false)), + reopen: setReviewState && review && review.closedAt && (() => setReviewState(false)), + openClose: setReviewState && (!review || !review.startedAt) && showAllButtons && (() => setReviewState(true)), + close: setReviewState && review && review.startedAt && !review.closedAt && (() => setReviewState(true)), + delete: showAllButtons && review && review.startedAt && deleteReview, + }; + + if (!isOnReviewPage) { + actionHandlers.open = actionHandlers.open && (() => actionHandlers.open().then(() => push(reviewPageUri))); + actionHandlers.reopen = actionHandlers.reopen && (() => actionHandlers.reopen().then(() => push(reviewPageUri))); + } + + const actions = knownActions + .filter(a => actionHandlers[a]) + .map(a => ({ + ...actionsTemplates[a], + handler: actionHandlers[a], + pending: actionsTemplates[a].pending === 'acceptPending' ? acceptPending : updatePending, + })); + + return dropdown ? ( + + ) : ( + actions.map(action => ( + + )) + ); +}; + +SolutionActions.propTypes = { + id: PropTypes.string.isRequired, + solution: PropTypes.shape({ + accepted: PropTypes.bool, + assignmentId: PropTypes.string.isRequired, + review: PropTypes.shape({ + startedAt: PropTypes.number, + closedAt: PropTypes.number, + issues: PropTypes.number, + }), + permissionHints: PropTypes.object, + }), + showAllButtons: PropTypes.bool, + acceptPending: PropTypes.bool, + updatePending: PropTypes.bool, + captionAsTooltip: PropTypes.bool, + size: PropTypes.string, + dropdown: PropTypes.bool, + setAccepted: PropTypes.func, + setReviewState: 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 withLinks(withRouter(SolutionActions)); diff --git a/src/components/Solutions/SolutionActions/index.js b/src/components/Solutions/SolutionActions/index.js new file mode 100644 index 000000000..ca166dd8e --- /dev/null +++ b/src/components/Solutions/SolutionActions/index.js @@ -0,0 +1,2 @@ +import SolutionActions from './SolutionActions'; +export default SolutionActions; diff --git a/src/components/Solutions/SourceCodeBox/SourceCodeBox.js b/src/components/Solutions/SourceCodeBox/SourceCodeBox.js index 70589be82..c5b06dd5e 100644 --- a/src/components/Solutions/SourceCodeBox/SourceCodeBox.js +++ b/src/components/Solutions/SourceCodeBox/SourceCodeBox.js @@ -30,7 +30,6 @@ const SourceCodeBox = ({ parentId = id, solutionId, name, - titleSuffix = '', entryName = null, download = null, diffWith = null, @@ -255,7 +254,6 @@ SourceCodeBox.propTypes = { reviewClosed: PropTypes.bool, collapsable: PropTypes.bool, isOpen: PropTypes.bool, - titleSuffix: PropTypes.oneOfType([PropTypes.string, PropTypes.element]).isRequired, }; export default SourceCodeBox; diff --git a/src/components/buttons/AcceptSolution/AcceptSolution.js b/src/components/buttons/AcceptSolution/AcceptSolution.js deleted file mode 100644 index ccc455830..000000000 --- a/src/components/buttons/AcceptSolution/AcceptSolution.js +++ /dev/null @@ -1,54 +0,0 @@ -import React from 'react'; -import PropTypes from 'prop-types'; -import { FormattedMessage } from 'react-intl'; -import Button from '../../widgets/TheButton'; -import OptionalTooltipWrapper from '../../widgets/OptionalTooltipWrapper'; -import Icon from '../../icons'; - -const AcceptSolution = ({ - accepted, - acceptPending, - accept, - unaccept, - shortLabel = false, - captionAsTooltip = false, - size = undefined, -}) => { - const label = - accepted === true ? ( - shortLabel ? ( - - ) : ( - - ) - ) : shortLabel ? ( - - ) : ( - - ); - - return ( - - - - ); -}; - -AcceptSolution.propTypes = { - accepted: PropTypes.bool.isRequired, - acceptPending: PropTypes.bool.isRequired, - accept: PropTypes.func.isRequired, - unaccept: PropTypes.func.isRequired, - shortLabel: PropTypes.bool, - captionAsTooltip: PropTypes.bool, - size: PropTypes.string, -}; - -export default AcceptSolution; diff --git a/src/components/buttons/AcceptSolution/index.js b/src/components/buttons/AcceptSolution/index.js deleted file mode 100644 index ae55e9f4b..000000000 --- a/src/components/buttons/AcceptSolution/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import AcceptSolution from './AcceptSolution'; -export default AcceptSolution; diff --git a/src/components/buttons/ReviewSolution/ReviewSolution.js b/src/components/buttons/ReviewSolution/ReviewSolution.js deleted file mode 100644 index 1eccb9412..000000000 --- a/src/components/buttons/ReviewSolution/ReviewSolution.js +++ /dev/null @@ -1,128 +0,0 @@ -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, { DeleteIcon } from '../../icons'; -import withLinks from '../../../helpers/withLinks'; - -const ReviewSolution = ({ - 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 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 = { - 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 withLinks(withRouter(ReviewSolution)); diff --git a/src/components/buttons/ReviewSolution/index.js b/src/components/buttons/ReviewSolution/index.js deleted file mode 100644 index d0583c209..000000000 --- a/src/components/buttons/ReviewSolution/index.js +++ /dev/null @@ -1,2 +0,0 @@ -import ReviewSolution from './ReviewSolution'; -export default ReviewSolution; diff --git a/src/components/forms/EditAssignmentForm/InterpolationDialog.js b/src/components/forms/EditAssignmentForm/InterpolationDialog.js index 37881adb8..2e05c1f65 100644 --- a/src/components/forms/EditAssignmentForm/InterpolationDialog.js +++ b/src/components/forms/EditAssignmentForm/InterpolationDialog.js @@ -152,7 +152,7 @@ const InterpolationDialog = ({ }} disabled={!interval}> - + + <> + + + - - )} - {solution.permissionHints && solution.permissionHints.setFlag && ( - + )} - {solution.permissionHints && solution.permissionHints.review && ( - + {solution.permissionHints && (solution.permissionHints.setFlag || solution.permissionHints.review) && ( + )} {solution.permissionHints && solution.permissionHints.delete && ( diff --git a/src/pages/Solution/Solution.js b/src/pages/Solution/Solution.js index 9d10097c0..03f27d74a 100644 --- a/src/pages/Solution/Solution.js +++ b/src/pages/Solution/Solution.js @@ -10,8 +10,7 @@ import Page from '../../components/layout/Page'; import { AssignmentSolutionNavigation } from '../../components/layout/Navigation'; import ResourceRenderer from '../../components/helpers/ResourceRenderer'; import SolutionDetail, { FailedSubmissionDetail } from '../../components/Solutions/SolutionDetail'; -import AcceptSolutionContainer from '../../containers/AcceptSolutionContainer'; -import ReviewSolutionContainer from '../../containers/ReviewSolutionContainer'; +import SolutionActionsContainer from '../../containers/SolutionActionsContainer'; import ResubmitSolutionContainer from '../../containers/ResubmitSolutionContainer'; import FetchManyResourceRenderer from '../../components/helpers/FetchManyResourceRenderer'; import { TheButtonGroup } from '../../components/widgets/TheButton'; @@ -142,14 +141,9 @@ class Solution extends Component { hasPermissions(assignment, 'resubmitSubmissions')) && ( - {hasPermissions(solution, 'setFlag') && ( - - )} - {hasPermissions(solution, 'review') && ( - - - - )} + + + diff --git a/src/pages/SolutionSourceCodes/SolutionSourceCodes.js b/src/pages/SolutionSourceCodes/SolutionSourceCodes.js index 6c0603fe8..8fbcbabfb 100644 --- a/src/pages/SolutionSourceCodes/SolutionSourceCodes.js +++ b/src/pages/SolutionSourceCodes/SolutionSourceCodes.js @@ -2,7 +2,7 @@ import React, { Component } from 'react'; import PropTypes from 'prop-types'; import ImmutablePropTypes from 'react-immutable-proptypes'; import { connect } from 'react-redux'; -import { FormattedMessage, injectIntl } from 'react-intl'; +import { FormattedMessage } from 'react-intl'; import { Row, Col, Modal, Tabs, Tab, Table } from 'react-bootstrap'; import { defaultMemoize } from 'reselect'; import { withRouter } from 'react-router'; @@ -22,8 +22,7 @@ import { StopIcon, SwapIcon, } from '../../components/icons'; -import AcceptSolutionContainer from '../../containers/AcceptSolutionContainer'; -import ReviewSolutionContainer from '../../containers/ReviewSolutionContainer'; +import SolutionActionsContainer from '../../containers/SolutionActionsContainer'; import SourceCodeBox from '../../components/Solutions/SourceCodeBox'; import RecentlyVisited from '../../components/Solutions/RecentlyVisited'; import { registerSolutionVisit } from '../../components/Solutions/RecentlyVisited/functions'; @@ -226,7 +225,6 @@ class SolutionSourceCodes extends Component { match: { params: { solutionId, assignmentId, secondSolutionId }, }, - intl: { locale }, } = this.props; const diffMode = @@ -326,16 +324,9 @@ class SolutionSourceCodes extends Component { {!diffMode && ( - <> - {hasPermissions(solution, 'setFlag') && ( - - )} - {hasPermissions(solution, 'review') && ( - - - - )} - + + + )} @@ -674,7 +665,6 @@ SolutionSourceCodes.propTypes = { addComment: PropTypes.func.isRequired, updateComment: PropTypes.func.isRequired, removeComment: PropTypes.func.isRequired, - intl: PropTypes.object, history: PropTypes.shape({ push: PropTypes.func.isRequired, replace: PropTypes.func.isRequired, @@ -722,5 +712,5 @@ export default withLinks( updateComment: comment => dispatch(updateComment(params.solutionId, comment)), removeComment: id => dispatch(removeComment(params.solutionId, id)), }) - )(injectIntl(withRouter(SolutionSourceCodes))) + )(withRouter(SolutionSourceCodes)) );