From c6e6a7f98a61e0fbe282dd0400e9a3edfba87fbf Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Tue, 14 Jul 2026 15:08:38 -0500 Subject: [PATCH 1/3] Add task lock countdown and extend lock functionality in TaskPane - Introduced a TaskLockButton component to display a countdown timer for task lock expiration. - Added functionality to extend task locks via a dialog, allowing users to manage their task locks more effectively. - Updated state management in WithLockedTask to track lock timestamps and reflect changes in the UI. - Removed deprecated task locked label from Messages.js and added new messages related to task lock options. --- .../HOCs/WithLockedTask/WithLockedTask.jsx | 8 +- src/components/TaskPane/Messages.js | 33 ++- src/components/TaskPane/TaskPane.jsx | 188 +++++++++++++----- 3 files changed, 173 insertions(+), 56 deletions(-) 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..f3f83216d 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,34 @@ export default defineMessages({ defaultMessage: "Try locking", }, + taskLockCountdownTitle: { + id: "Task.pane.indicators.lockCountdown.title", + defaultMessage: "Time remaining until this task's lock expires", + }, + + 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..405bae21b 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 { 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,56 @@ 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 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; + + return ( + + ); +}; + export const defaultWorkspaceSetupClassic = function (intl) { return { dataModelVersion: 2, @@ -127,6 +176,8 @@ export class TaskPane extends Component { needsResponses: false, completingTask: false, unlockRequested: false, + showLockOptionsDialog: false, + extendingLock: false, }; tryLockingTask = () => { @@ -139,6 +190,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 +380,7 @@ export class TaskPane extends Component { {this.props.tryingLock ? ( - ) : ( + ) : this.props.taskReadOnly ? ( ( @@ -322,55 +388,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 +576,50 @@ export class TaskPane extends Component { } /> )} + {this.state.showLockOptionsDialog && ( + } + prompt={} + icon="locked-icon" + onClose={() => this.closeLockOptionsDialog()} + controls={ + + + {this.state.extendingLock ? ( +
+ +
+ ) : ( + + )} + +
+ } + /> + )} ); } From 17685c4636fc082d0a10d49c10eb5a399c7d9270 Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Tue, 14 Jul 2026 15:10:28 -0500 Subject: [PATCH 2/3] run formatting --- src/components/TaskPane/TaskPane.jsx | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/components/TaskPane/TaskPane.jsx b/src/components/TaskPane/TaskPane.jsx index 405bae21b..058942bae 100644 --- a/src/components/TaskPane/TaskPane.jsx +++ b/src/components/TaskPane/TaskPane.jsx @@ -52,7 +52,8 @@ const TASK_LOCK_DURATION = 3600000; // 1 hour * it opens a dialog letting the user extend or release the lock. */ const TaskLockButton = ({ lockedAt, title, onClick }) => { - const remainingTime = () => (lockedAt ? Math.max(0, lockedAt + TASK_LOCK_DURATION - Date.now()) : 0); + const remainingTime = () => + lockedAt ? Math.max(0, lockedAt + TASK_LOCK_DURATION - Date.now()) : 0; const [remainingMs, setRemainingMs] = useState(remainingTime()); useEffect(() => { @@ -82,11 +83,7 @@ const TaskLockButton = ({ lockedAt, title, onClick }) => { {minutes}:{seconds.toString().padStart(2, "0")} )} - + ); }; From 35e24e8ea3f472567e3f71c4e76d5959dca7d415 Mon Sep 17 00:00:00 2001 From: Collin Beczak Date: Thu, 16 Jul 2026 11:46:32 -0500 Subject: [PATCH 3/3] Add ARIA labels and expired state handling for task lock countdown - Introduced new ARIA labels for task lock countdown and expired states to improve accessibility. - Updated TaskLockButton component to display appropriate labels based on task lock status. - Enhanced UI feedback by showing "Expired" label when the task lock has expired. --- src/components/TaskPane/Messages.js | 15 +++++++++++++++ src/components/TaskPane/TaskPane.jsx | 19 +++++++++++++++++-- 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/components/TaskPane/Messages.js b/src/components/TaskPane/Messages.js index f3f83216d..fcb3b4373 100644 --- a/src/components/TaskPane/Messages.js +++ b/src/components/TaskPane/Messages.js @@ -49,6 +49,21 @@ export default defineMessages({ 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", diff --git a/src/components/TaskPane/TaskPane.jsx b/src/components/TaskPane/TaskPane.jsx index 058942bae..1c075273f 100644 --- a/src/components/TaskPane/TaskPane.jsx +++ b/src/components/TaskPane/TaskPane.jsx @@ -3,7 +3,7 @@ import _findIndex from "lodash/findIndex"; import PropTypes from "prop-types"; 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 AsManager from "../../interactions/User/AsManager"; import { isCompletionStatus } from "../../services/Task/TaskStatus/TaskStatus"; @@ -52,6 +52,7 @@ const TASK_LOCK_DURATION = 3600000; // 1 hour * 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()); @@ -70,6 +71,15 @@ const TaskLockButton = ({ lockedAt, title, onClick }) => { 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 (