diff --git a/src/components/HOCs/WithLockedTask/WithLockedTask.jsx b/src/components/HOCs/WithLockedTask/WithLockedTask.jsx index a7e72987e..6fd9b6c67 100644 --- a/src/components/HOCs/WithLockedTask/WithLockedTask.jsx +++ b/src/components/HOCs/WithLockedTask/WithLockedTask.jsx @@ -44,6 +44,7 @@ const WithLockedTask = function (WrappedComponent) { readOnly: false, tryingLock: false, failureDetails: null, + lockedAt: null, }; lockTask = (task) => { @@ -59,7 +60,7 @@ const WithLockedTask = function (WrappedComponent) { this.setState({ readOnly: false }); } - this.setState({ tryingLock: false }); + this.setState({ tryingLock: false, lockedAt: Date.now() }); lockStorage.setLock(task.id); @@ -84,6 +85,8 @@ const WithLockedTask = function (WrappedComponent) { setTimeout(() => lockStorage.removeLock(task.id), 1500); }) .catch(() => null); + + this.setState({ lockedAt: null }); }; requestUnlock = (taskId) => { @@ -105,6 +108,8 @@ const WithLockedTask = function (WrappedComponent) { this.setState({ readOnly: false, failureDetails: null }); } + this.setState({ lockedAt: Date.now() }); + lockStorage.setLock(task.id); return true; @@ -166,6 +171,7 @@ const WithLockedTask = function (WrappedComponent) { taskReadOnly={this.state.readOnly} tryingLock={this.state.tryingLock} lockFailureDetails={this.state.failureDetails} + taskLockedAt={this.state.lockedAt} tryLocking={this.lockTask} unlockTask={this.unlockTask} refreshTaskLock={this.refreshTaskLock} diff --git a/src/components/TaskPane/Messages.js b/src/components/TaskPane/Messages.js index ce936e442..fcb3b4373 100644 --- a/src/components/TaskPane/Messages.js +++ b/src/components/TaskPane/Messages.js @@ -29,11 +29,6 @@ export default defineMessages({ defaultMessage: "Favorite Challenge", }, - taskLockedLabel: { - id: "ReviewTaskPane.indicators.locked.label", - defaultMessage: "Task locked", - }, - taskReadOnlyLabel: { id: "Task.pane.indicators.readOnly.label", defaultMessage: "Read-only Preview", @@ -49,6 +44,49 @@ export default defineMessages({ defaultMessage: "Try locking", }, + taskLockCountdownTitle: { + id: "Task.pane.indicators.lockCountdown.title", + defaultMessage: "Time remaining until this task's lock expires", + }, + + taskLockCountdownAriaLabel: { + id: "Task.pane.indicators.lockCountdown.ariaLabel", + defaultMessage: "Task is locked; {time} remaining. Activate to extend or release.", + }, + + taskLockExpiredAriaLabel: { + id: "Task.pane.indicators.lockCountdown.expiredAriaLabel", + defaultMessage: "Task lock has expired. Activate to extend or release.", + }, + + taskLockExpiredLabel: { + id: "Task.pane.indicators.lockCountdown.expiredLabel", + defaultMessage: "Expired", + }, + + lockOptionsTitle: { + id: "Task.pane.lockOptionsDialog.title", + defaultMessage: "Task Lock", + }, + + lockOptionsPrompt: { + id: "Task.pane.lockOptionsDialog.prompt", + defaultMessage: + "You have this task locked, which prevents other mappers from working on it at the " + + "same time. Extending the lock will refresh it back to a full hour. Unlocking will " + + "release the task so others can work on it.", + }, + + extendLockLabel: { + id: "Task.pane.controls.extendLock.label", + defaultMessage: "Extend Lock", + }, + + cancelLabel: { + id: "Admin.EditProject.controls.cancel.label", + defaultMessage: "Cancel", + }, + previewTaskLabel: { id: "Task.pane.controls.preview.label", defaultMessage: "Preview Task", diff --git a/src/components/TaskPane/TaskPane.jsx b/src/components/TaskPane/TaskPane.jsx index 5558cdb5e..1c075273f 100644 --- a/src/components/TaskPane/TaskPane.jsx +++ b/src/components/TaskPane/TaskPane.jsx @@ -1,11 +1,10 @@ import classNames from "classnames"; import _findIndex from "lodash/findIndex"; import PropTypes from "prop-types"; -import { Component, Fragment } from "react"; +import { Component, Fragment, useEffect, useState } from "react"; import { CopyToClipboard } from "react-copy-to-clipboard"; -import { FormattedMessage, injectIntl } from "react-intl"; +import { FormattedMessage, injectIntl, useIntl } from "react-intl"; import { Redirect } from "react-router"; -import { Link } from "react-router-dom"; import AsManager from "../../interactions/User/AsManager"; import { isCompletionStatus } from "../../services/Task/TaskStatus/TaskStatus"; import { WidgetDataTarget, generateWidgetId, widgetDescriptor } from "../../services/Widget/Widget"; @@ -42,6 +41,68 @@ const WIDGET_WORKSPACE_NAME = "taskCompletion"; // How frequently the task lock should be refreshed const LOCK_REFRESH_INTERVAL = 600000; // 10 minutes +// How long a task lock lasts before it expires server-side. Mirrors the +// backend's default `maproulette.task.lock.expiry` setting, which isn't +// currently exposed to the frontend via the API. +const TASK_LOCK_DURATION = 3600000; // 1 hour + +/** + * A single button combining the lock icon with a live countdown of how much + * time remains before the current user's lock on the task expires. Clicking + * it opens a dialog letting the user extend or release the lock. + */ +const TaskLockButton = ({ lockedAt, title, onClick }) => { + const intl = useIntl(); + const remainingTime = () => + lockedAt ? Math.max(0, lockedAt + TASK_LOCK_DURATION - Date.now()) : 0; + const [remainingMs, setRemainingMs] = useState(remainingTime()); + + useEffect(() => { + if (!lockedAt) { + return; + } + + setRemainingMs(remainingTime()); + const intervalId = setInterval(() => setRemainingMs(remainingTime()), 1000); + return () => clearInterval(intervalId); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lockedAt]); + + const totalSeconds = Math.floor(remainingMs / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + const isExpired = lockedAt && totalSeconds <= 0; + + const ariaLabel = !lockedAt + ? title + : isExpired + ? intl.formatMessage(messages.taskLockExpiredAriaLabel) + : intl.formatMessage(messages.taskLockCountdownAriaLabel, { + time: `${minutes}:${seconds.toString().padStart(2, "0")}`, + }); + + return ( + + ); +}; + export const defaultWorkspaceSetupClassic = function (intl) { return { dataModelVersion: 2, @@ -127,6 +188,8 @@ export class TaskPane extends Component { needsResponses: false, completingTask: false, unlockRequested: false, + showLockOptionsDialog: false, + extendingLock: false, }; tryLockingTask = () => { @@ -139,6 +202,21 @@ export class TaskPane extends Component { this.setState({ showLockFailureDialog: false }); }; + openLockOptionsDialog = () => { + this.setState({ showLockOptionsDialog: true }); + }; + + closeLockOptionsDialog = () => { + this.setState({ showLockOptionsDialog: false }); + }; + + extendTaskLock = () => { + this.setState({ extendingLock: true }); + this.props.refreshTaskLock(this.props.task).then(() => { + this.setState({ extendingLock: false, showLockOptionsDialog: false }); + }); + }; + /** * Clear the lock-refresh timer if one is set */ @@ -314,7 +392,7 @@ export class TaskPane extends Component { {this.props.tryingLock ? ( - ) : ( + ) : this.props.taskReadOnly ? ( ( @@ -322,55 +400,33 @@ export class TaskPane extends Component { onClick={dropdown.toggleDropdownVisible} className="mr-flex mr-items-center mr-text-green-lighter mr-mr-4" > - {this.props.taskReadOnly ? ( - - ) : ( - - )} + )} - dropdownContent={() => - this.props.taskReadOnly ? ( -
- - - - -
- ) : ( -
- - - - - - -
- ) - } + dropdownContent={() => ( +
+ + + + +
+ )} + /> + ) : ( + )} @@ -532,6 +588,50 @@ export class TaskPane extends Component { } /> )} + {this.state.showLockOptionsDialog && ( + } + prompt={} + icon="locked-icon" + onClose={() => this.closeLockOptionsDialog()} + controls={ + + + {this.state.extendingLock ? ( +
+ +
+ ) : ( + + )} + +
+ } + /> + )} ); }