Skip to content

Commit

Permalink
Refactoring solution actions (buttons) into one component (container)…
Browse files Browse the repository at this point in the history
… which works for top-level buttons as well as inline button-dropdown.
  • Loading branch information
krulis-martin committed Jun 12, 2023
1 parent 2ca4c66 commit 8b3a184
Show file tree
Hide file tree
Showing 20 changed files with 353 additions and 301 deletions.
19 changes: 4 additions & 15 deletions src/components/Assignments/SolutionsTable/SolutionsTableRow.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -162,18 +161,8 @@ const SolutionsTableRow = ({
</>
)}

{permissionHints && permissionHints.setFlag && (
<AcceptSolutionContainer
id={id}
locale={locale}
captionAsTooltip={compact}
shortLabel={!compact}
size="xs"
/>
)}

{permissionHints && permissionHints.review && (
<ReviewSolutionContainer id={id} locale={locale} captionAsTooltip={compact} size="xs" />
{permissionHints && (permissionHints.setFlag || permissionHints.review) && (
<SolutionActionsContainer id={id} captionAsTooltip={compact} showAllButtons dropdown />
)}

{permissionHints && permissionHints.delete && (
Expand Down
47 changes: 47 additions & 0 deletions src/components/Solutions/SolutionActions/ActionButton.js
Original file line number Diff line number Diff line change
@@ -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 ? (
<Button variant={variant} size={size} onClick={onClick} disabled={pending} confirm={confirm} confirmId={id}>
{pending ? <LoadingIcon gapRight={!captionAsTooltip} /> : <Icon icon={icon} gapRight={!captionAsTooltip} />}
{!captionAsTooltip && label}
</Button>
) : (
<OptionalTooltipWrapper tooltip={label} hide={!captionAsTooltip}>
<Button variant={variant} size={size} onClick={onClick} disabled={pending}>
{pending ? <LoadingIcon gapRight={!captionAsTooltip} /> : <Icon icon={icon} gapRight={!captionAsTooltip} />}
{!captionAsTooltip && shortLabel}
</Button>
</OptionalTooltipWrapper>
);

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;
77 changes: 77 additions & 0 deletions src/components/Solutions/SolutionActions/ActionDropdown.js
Original file line number Diff line number Diff line change
@@ -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 (
<Dropdown as="span">
<Dropdown.Toggle variant="warning" size="xs" ref={target}>
<EditIcon gapRight={!captionAsTooltip} />
{!captionAsTooltip && <FormattedMessage id="generic.update" defaultMessage="Update" />}
</Dropdown.Toggle>

<Dropdown.Menu>
{actions.map(action => (
<Dropdown.Item
key={action.icon}
onClick={action.confirm ? () => setConfirmAction(action) : action.handler}
disabled={action.pending}>
<small>
{action.pending ? (
<LoadingIcon gapRight />
) : (
<Icon icon={action.icon} className={`text-${action.variant || 'success'}`} gapRight />
)}
{action.label}
</small>
</Dropdown.Item>
))}
</Dropdown.Menu>

<Overlay target={target} placement={placement} show={target !== null && confirmAction !== null}>
<Popover id={id}>
<Popover.Title>{confirmAction && confirmAction.confirm}</Popover.Title>
<Popover.Content className="text-center">
<TheButtonGroup>
<Button
onClick={() => {
confirmAction.handler();
setConfirmAction(null);
}}
size="sm"
variant="success">
<SuccessIcon gapRight />
<FormattedMessage id="app.confirm.yes" defaultMessage="Yes" />
</Button>
<Button
onClick={() => {
setConfirmAction(null);
}}
size="sm"
variant="danger">
<CloseIcon gapRight />
<FormattedMessage id="app.confirm.no" defaultMessage="No" />
</Button>
</TheButtonGroup>
</Popover.Content>
</Popover>
</Overlay>
</Dropdown>
);
};

ActionDropdown.propTypes = {
actions: PropTypes.array.isRequired,
placement: PropTypes.string,
id: PropTypes.string.isRequired,
captionAsTooltip: PropTypes.bool,
};

export default ActionDropdown;
168 changes: 168 additions & 0 deletions src/components/Solutions/SolutionActions/SolutionActions.js
Original file line number Diff line number Diff line change
@@ -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: <FormattedMessage id="app.acceptSolution.notAcceptedShort" defaultMessage="Accept" />,
label: <FormattedMessage id="app.acceptSolution.notAccepted" defaultMessage="Accept as Final" />,
icon: ['far', 'check-circle'],
pending: 'acceptPending',
},
unaccept: {
short: <FormattedMessage id="app.acceptSolution.acceptedShort" defaultMessage="Revoke" />,
label: <FormattedMessage id="app.acceptSolution.accepted" defaultMessage="Revoke as Final" />,
icon: ['far', 'circle-xmark'],
variant: 'warning',
pending: 'acceptPending',
},

// review actions
open: {
label: <FormattedMessage id="app.reviewSolutionButtons.open" defaultMessage="Start Review" />,
icon: 'microscope',
variant: 'info',
},
reopen: {
label: <FormattedMessage id="app.reviewSolutionButtons.reopen" defaultMessage="Reopen Review" />,
icon: 'person-digging',
variant: 'warning',
},
openClose: {
label: <FormattedMessage id="app.reviewSolutionButtons.markReviewed" defaultMessage="Mark as Reviewed" />,
icon: 'file-circle-check',
},
close: {
label: <FormattedMessage id="app.reviewSolutionButtons.close" defaultMessage="Close Review" />,
icon: 'boxes-packing',
},
delete: {
label: <FormattedMessage id="app.reviewSolutionButtons.delete" defaultMessage="Erase Review" />,
icon: 'trash',
variant: 'danger',
confirm: (
<FormattedMessage
id="app.reviewSolutionButtons.deleteConfirm"
defaultMessage="All review comments will be erased as well. Do you wish to proceed?"
/>
),
},
};

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 ? (
<ActionDropdown id={id} actions={actions} captionAsTooltip={captionAsTooltip} />
) : (
actions.map(action => (
<ActionButton
key={action.icon}
id={id}
variant={action.variant}
icon={action.icon}
label={action.label}
shortLabel={action.short || action.label}
confirm={action.confirm}
pending={action.pending}
captionAsTooltip={captionAsTooltip}
size={size}
onClick={action.handler}
/>
))
);
};

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));
2 changes: 2 additions & 0 deletions src/components/Solutions/SolutionActions/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import SolutionActions from './SolutionActions';
export default SolutionActions;
2 changes: 0 additions & 2 deletions src/components/Solutions/SourceCodeBox/SourceCodeBox.js
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,6 @@ const SourceCodeBox = ({
parentId = id,
solutionId,
name,
titleSuffix = '',
entryName = null,
download = null,
diffWith = null,
Expand Down Expand Up @@ -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;

0 comments on commit 8b3a184

Please sign in to comment.