Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion src/components/HOCs/WithLockedTask/WithLockedTask.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ const WithLockedTask = function (WrappedComponent) {
readOnly: false,
tryingLock: false,
failureDetails: null,
lockedAt: null,
};

lockTask = (task) => {
Expand All @@ -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);

Expand All @@ -84,6 +85,8 @@ const WithLockedTask = function (WrappedComponent) {
setTimeout(() => lockStorage.removeLock(task.id), 1500);
})
.catch(() => null);

this.setState({ lockedAt: null });
};

requestUnlock = (taskId) => {
Expand All @@ -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;
Expand Down Expand Up @@ -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}
Expand Down
48 changes: 43 additions & 5 deletions src/components/TaskPane/Messages.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
202 changes: 151 additions & 51 deletions src/components/TaskPane/TaskPane.jsx
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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 (
<button
type="button"
onClick={onClick}
className="mr-flex mr-items-center mr-text-green-lighter hover:mr-text-current mr-mr-4"
title={title}
aria-label={ariaLabel}
>
{lockedAt && (
<span className="mr-text-xs mr-mr-2">
{isExpired ? (
<FormattedMessage {...messages.taskLockExpiredLabel} />
) : (
`${minutes}:${seconds.toString().padStart(2, "0")}`
)}
</span>
)}
<SvgSymbol sym="locked-icon" viewBox="0 0 20 20" className="mr-w-4 mr-h-4 mr-fill-current" />
</button>
Comment thread
CollinBeczak marked this conversation as resolved.
);
};

export const defaultWorkspaceSetupClassic = function (intl) {
return {
dataModelVersion: 2,
Expand Down Expand Up @@ -127,6 +188,8 @@ export class TaskPane extends Component {
needsResponses: false,
completingTask: false,
unlockRequested: false,
showLockOptionsDialog: false,
extendingLock: false,
};

tryLockingTask = () => {
Expand All @@ -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
*/
Expand Down Expand Up @@ -314,63 +392,41 @@ export class TaskPane extends Component {

{this.props.tryingLock ? (
<BusySpinner inline className="mr-mr-4" />
) : (
) : this.props.taskReadOnly ? (
<Dropdown
className="mr-dropdown--right"
dropdownButton={(dropdown) => (
<button
onClick={dropdown.toggleDropdownVisible}
className="mr-flex mr-items-center mr-text-green-lighter mr-mr-4"
>
{this.props.taskReadOnly ? (
<SvgSymbol
sym="unlocked-icon"
viewBox="0 0 60 60"
className="mr-w-6 mr-h-6 mr-fill-pink-light"
/>
) : (
<SvgSymbol
sym="locked-icon"
viewBox="0 0 20 20"
className="mr-w-4 mr-h-4 mr-fill-current"
/>
)}
<SvgSymbol
sym="unlocked-icon"
viewBox="0 0 60 60"
className="mr-w-6 mr-h-6 mr-fill-pink-light"
/>
</button>
)}
dropdownContent={() =>
this.props.taskReadOnly ? (
<div className="mr-links-green-lighter mr-text-sm mr-flex mr-items-center mr-mt-2">
<span className="mr-flex mr-items-baseline mr-text-pink-light">
<FormattedMessage {...messages.taskReadOnlyLabel} />
</span>
<button
type="button"
className="mr-button mr-button--xsmall mr-ml-3"
onClick={() => this.tryLockingTask()}
>
<FormattedMessage {...messages.taskTryLockLabel} />
</button>
</div>
) : (
<div className="mr-links-green-lighter mr-text-sm mr-flex mr-items-center mr-mt-2">
<span className="mr-flex mr-items-baseline">
<FormattedMessage {...messages.taskLockedLabel} />
</span>
<Link
to={
Number.isFinite(this.props.virtualChallengeId)
? `/browse/virtual/${this.props.virtualChallengeId}`
: `/browse/challenges/${
this.props.task?.parent?.id ?? this.props.task.parent
}`
}
className="mr-button mr-button--xsmall mr-ml-3"
>
<FormattedMessage {...messages.taskUnlockLabel} />
</Link>
</div>
)
}
dropdownContent={() => (
<div className="mr-links-green-lighter mr-text-sm mr-flex mr-items-center mr-mt-2">
<span className="mr-flex mr-items-baseline mr-text-pink-light">
<FormattedMessage {...messages.taskReadOnlyLabel} />
</span>
<button
type="button"
className="mr-button mr-button--xsmall mr-ml-3"
onClick={() => this.tryLockingTask()}
>
<FormattedMessage {...messages.taskTryLockLabel} />
</button>
</div>
)}
/>
) : (
<TaskLockButton
lockedAt={this.props.taskLockedAt}
title={this.props.intl.formatMessage(messages.taskLockCountdownTitle)}
onClick={this.openLockOptionsDialog}
/>
)}

Expand Down Expand Up @@ -532,6 +588,50 @@ export class TaskPane extends Component {
}
/>
)}
{this.state.showLockOptionsDialog && (
<BasicDialog
title={<FormattedMessage {...messages.lockOptionsTitle} />}
prompt={<FormattedMessage {...messages.lockOptionsPrompt} />}
icon="locked-icon"
onClose={() => this.closeLockOptionsDialog()}
controls={
<Fragment>
<button
className="mr-button mr-button--white"
onClick={() => this.closeLockOptionsDialog()}
>
<FormattedMessage {...messages.cancelLabel} />
</button>
{this.state.extendingLock ? (
<div className="mr-ml-4">
<BusySpinner inline />
</div>
) : (
<button
className="mr-button mr-button--green-light mr-ml-4"
onClick={() => this.extendTaskLock()}
>
<FormattedMessage {...messages.extendLockLabel} />
</button>
)}
<button
className="mr-button mr-button--green-light mr-ml-4"
onClick={() => {
this.props.history.push(
Number.isFinite(this.props.virtualChallengeId)
? `/browse/virtual/${this.props.virtualChallengeId}`
: `/browse/challenges/${
this.props.task?.parent?.id ?? this.props.task.parent
}`,
);
}}
>
<FormattedMessage {...messages.taskUnlockLabel} />
</button>
</Fragment>
}
/>
)}
</div>
);
}
Expand Down
Loading