diff --git a/docker-compose.test.yaml b/docker-compose.test.yaml index 00d5739e7..0df431f4d 100644 --- a/docker-compose.test.yaml +++ b/docker-compose.test.yaml @@ -26,7 +26,12 @@ services: retries: 30 backend: - image: ghcr.io/maproulette/maproulette-backend:4.9.0 + # Pinned to `main` rather than a version tag: the dashboard team-list name + # display (teams.spec.ts) depends on the `teamName` field added to the + # memberships API in backend commit 8b445ce0 (2026-08-05), which hasn't + # made it into a versioned release yet (latest is 4.9.5, from 2026-07-16). + # Switch back to a version tag once one is cut past that commit. + image: ghcr.io/maproulette/maproulette-backend:main platform: linux/amd64 # TODO: remove once the image above supports multi-arch init: true depends_on: diff --git a/e2e/task-bundling.spec.ts b/e2e/task-bundling.spec.ts index 683f92c94..7d3ac48c9 100644 --- a/e2e/task-bundling.spec.ts +++ b/e2e/task-bundling.spec.ts @@ -10,6 +10,11 @@ test('a user can lasso-bundle a second task and clear the bundle', async ({ await page.goto(`/tasks/${task.id}`) await expect(page.getByText(`Task #${task.id}`).first()).toBeVisible({ timeout: 15_000 }) + + // Opening a task URL directly no longer auto-claims it (only in-app + // navigation with claimTask=true does); clicking "Map this task" locks it, + // at which point the completion action buttons replace that prompt. + await page.getByRole('button', { name: 'Map this task' }).click() await expect(page.getByRole('button', { name: 'Fixed', exact: true })).toBeVisible({ timeout: 20_000, }) diff --git a/e2e/task-workflow.spec.ts b/e2e/task-workflow.spec.ts index 2a92113d8..ddbf21b39 100644 --- a/e2e/task-workflow.spec.ts +++ b/e2e/task-workflow.spec.ts @@ -15,8 +15,10 @@ test('a user can open a task, view its details, and mark it as fixed', async ({ await expect(page.getByText(`Task #${task.id}`).first()).toBeVisible({ timeout: 15_000 }) await expect(page.getByText('Fix the identified issue.')).toBeVisible({ timeout: 15_000 }) - // The task auto-locks for mapping shortly after the page loads, at which point - // the completion action buttons replace the "Map this task" prompt. + // Opening a task URL directly no longer auto-claims it (only in-app + // navigation with claimTask=true does); clicking "Map this task" locks it, + // at which point the completion action buttons replace that prompt. + await page.getByRole('button', { name: 'Map this task' }).click() const fixedButton = page.getByRole('button', { name: 'Fixed', exact: true }) await expect(fixedButton).toBeVisible({ timeout: 20_000 }) await fixedButton.click() diff --git a/src/api/task/single.test.ts b/src/api/task/single.test.ts index 3bb1b0e75..f19fe79f9 100644 --- a/src/api/task/single.test.ts +++ b/src/api/task/single.test.ts @@ -256,6 +256,56 @@ describe('taskSingle.useUnlockTask', () => { }) }) +describe('taskSingle.useLockTaskBundle', () => { + it('PUTs the bundled task ids as repeated taskIds params and invalidates inBounds tasks', async () => { + const fetchMock = stubFetch( + new Response(JSON.stringify({ lockPrimaryTaskId: 1, lockBundledTasks: [2, 3] }), { + status: 200, + }) + ) + const queryClient = createTestQueryClient() + const invalidateSpy = vi.spyOn(queryClient, 'invalidateQueries') + + const { result } = renderHook(() => taskSingle.useLockTaskBundle(), { + wrapper: queryClientWrapper(queryClient), + }) + + result.current.mutate({ taskId: 1, taskIds: [2, 3] }) + + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + + expect(result.current.data).toEqual({ lockPrimaryTaskId: 1, lockBundledTasks: [2, 3] }) + const [request] = fetchMock.mock.calls[0] as [Request] + expect(request.method).toBe('PUT') + const url = new URL(request.url) + expect(url.pathname).toBe('/api/v2/task/1/lockBundle') + expect(url.searchParams.getAll('taskIds')).toEqual(['2', '3']) + expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['task', 'inBounds'] }) + }) + + it('logs and swallows a failed bundle lock update', async () => { + const { logger } = await import('@/lib/logger') + const loggerErrorSpy = vi.spyOn(logger, 'error').mockImplementation(() => {}) + stubFetch(new Response('conflict', { status: 409 })) + const queryClient = createTestQueryClient() + + const { result } = renderHook(() => taskSingle.useLockTaskBundle(), { + wrapper: queryClientWrapper(queryClient), + }) + + result.current.mutate({ taskId: 1, taskIds: [2] }) + + await waitFor(() => expect(result.current.isError).toBe(true)) + + expect(loggerErrorSpy).toHaveBeenCalledWith( + 'Failed to update bundle lock', + expect.objectContaining({ variables: { taskId: 1, taskIds: [2] } }) + ) + + loggerErrorSpy.mockRestore() + }) +}) + describe('taskSingle.useSkipTask', () => { it('marks a cached task skipped, invalidates aggregates when the status actually changed, and patches the marker', async () => { stubFetch(new Response('', { status: 200 })) diff --git a/src/api/task/single.ts b/src/api/task/single.ts index f4aec03d9..f642dccd6 100644 --- a/src/api/task/single.ts +++ b/src/api/task/single.ts @@ -57,7 +57,7 @@ export const taskSingle = { const queryClient = useQueryClient() return useMutation({ mutationFn: (taskId: number) => - apiRequest.get(`api/v2/task/${taskId}/start`).json(), + apiRequest.get(`api/v2/task/${taskId}/start`).json(), onSuccess: (lockedTask, taskId) => { queryClient.setQueryData(['task', taskId], lockedTask) queryClient.invalidateQueries({ queryKey: ['task', 'history', taskId] }) @@ -70,6 +70,12 @@ export const taskSingle = { }) }, + useRefreshLock: () => + useMutation({ + mutationFn: (taskId: number) => + apiRequest.get(`api/v2/task/${taskId}/refreshLock`).json(), + }), + useUnlockTask: () => { const queryClient = useQueryClient() return useMutation({ @@ -87,6 +93,27 @@ export const taskSingle = { }) }, + useLockTaskBundle: () => { + const queryClient = useQueryClient() + return useMutation({ + mutationFn: ({ taskId, taskIds }: { taskId: number; taskIds: number[] }) => { + const searchParams = new URLSearchParams() + taskIds.forEach((id) => { + searchParams.append('taskIds', String(id)) + }) + return apiRequest + .put(`api/v2/task/${taskId}/lockBundle`, { searchParams }) + .json<{ lockPrimaryTaskId: number; lockBundledTasks: number[] }>() + }, + onError: (error, variables) => { + logger.error('Failed to update bundle lock', { error, variables }) + }, + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['task', 'inBounds'] }) + }, + }) + }, + useSkipTask: () => { const queryClient = useQueryClient() return useMutation({ diff --git a/src/components/Map/TaskMarkers/SpiderMarkers.tsx b/src/components/Map/TaskMarkers/SpiderMarkers.tsx index 1ee489556..eb99a3632 100644 --- a/src/components/Map/TaskMarkers/SpiderMarkers.tsx +++ b/src/components/Map/TaskMarkers/SpiderMarkers.tsx @@ -25,6 +25,7 @@ interface SpiderMarkersProps { selectedTaskId?: number | null activeTaskId?: number | null lassoSelectedTaskIds?: Set + hoveredTaskId?: number | null } /** @@ -38,6 +39,7 @@ export const SpiderMarkers = ({ selectedTaskId, activeTaskId, lassoSelectedTaskIds = new Set(), + hoveredTaskId, }: SpiderMarkersProps) => { const SPIDER_SOURCE_ID = 'spider-lines' const SPIDER_LINES_OUTLINE_ID = 'spider-lines-outline' @@ -56,6 +58,7 @@ export const SpiderMarkers = ({ const isSelected = marker.id === selectedTaskId const isActive = marker.id === activeTaskId const isLassoSelected = lassoSelectedTaskIds.has(marker.id) + const isHovered = marker.id === hoveredTaskId const typeKey = (marker as TaskMarker & { typeKey?: TaskTypeKey | null }).typeKey ?? null return { @@ -69,6 +72,7 @@ export const SpiderMarkers = ({ isSelected, isActive, isLassoSelected, + isHovered, isSpidered: true, ...(typeKey ? { typeKey } : {}), }, @@ -92,6 +96,7 @@ export const SpiderMarkers = ({ selectedTaskId, activeTaskId, lassoSelectedTaskIds, + hoveredTaskId, ]) const spiderLinesGeoJSON = useMemo(() => { @@ -216,6 +221,16 @@ export const SpiderMarkers = ({ 'icon-image': [ 'case', + ['get', 'isHovered'], + [ + 'concat', + 'marker-pin-', + ['to-string', ['get', 'status']], + '-', + ['to-string', ['coalesce', ['get', 'priority'], 1]], + '-hovered', + ], + ['get', 'isPrimary'], [ 'concat', @@ -340,7 +355,13 @@ export const SpiderMarkers = ({ 'icon-size': [ 'case', - ['any', ['get', 'isHighlighted'], ['get', 'isActive'], ['get', 'isSelected']], + [ + 'any', + ['get', 'isHighlighted'], + ['get', 'isActive'], + ['get', 'isSelected'], + ['get', 'isHovered'], + ], 1.4, 1.0, @@ -350,6 +371,8 @@ export const SpiderMarkers = ({ 'icon-ignore-placement': true, 'symbol-sort-key': [ 'case', + ['get', 'isHovered'], + 1300, ['get', 'isPrimary'], 1200, ['all', ['get', 'isHighlighted'], ['get', 'isActive']], diff --git a/src/components/Map/TaskMarkers/clusterLayers.ts b/src/components/Map/TaskMarkers/clusterLayers.ts index 7f5399731..cd8a498d2 100644 --- a/src/components/Map/TaskMarkers/clusterLayers.ts +++ b/src/components/Map/TaskMarkers/clusterLayers.ts @@ -96,6 +96,26 @@ export const unclusteredPointLayer: LayerProps = { 'icon-image': [ 'case', + ['==', ['get', 'isHovered'], true], + [ + 'case', + ['==', ['get', 'isOverlapping'], true], + [ + 'case', + ['>', ['get', 'overlapTaskCount'], 20], + 'marker-overlap-many-hovered', + ['concat', 'marker-overlap-', ['to-string', ['get', 'overlapTaskCount']], '-hovered'], + ], + [ + 'concat', + 'marker-pin-', + ['to-string', ['get', 'status']], + '-', + ['to-string', ['coalesce', ['get', 'priority'], 1]], + '-hovered', + ], + ], + ['==', ['get', 'isOverlapping'], true], [ 'case', @@ -267,6 +287,7 @@ export const unclusteredPointLayer: LayerProps = { ['==', ['get', 'isHighlighted'], true], ['==', ['get', 'isActive'], true], ['==', ['get', 'isSelected'], true], + ['==', ['get', 'isHovered'], true], ], 1.4, @@ -281,6 +302,8 @@ export const unclusteredPointLayer: LayerProps = { 'icon-opacity': [ 'case', + ['==', ['get', 'isHovered'], true], + 1, ['==', ['get', 'isPrimary'], true], 1, ['==', ['get', 'isHighlighted'], true], diff --git a/src/components/Map/TaskMarkers/createMarkerIcons.ts b/src/components/Map/TaskMarkers/createMarkerIcons.ts index 44dc19a4e..ac237ff78 100644 --- a/src/components/Map/TaskMarkers/createMarkerIcons.ts +++ b/src/components/Map/TaskMarkers/createMarkerIcons.ts @@ -188,7 +188,7 @@ export const createMarkerIcons = ( createMarkerIcon(status, color, priority, '#f59e0b') - createMarkerIcon(status, color, priority, '#22c55e', 'hovered') + createMarkerIcon(status, color, priority, '#3b82f6', 'hovered') createMarkerIcon(status, color, priority, '#eab308') @@ -312,7 +312,7 @@ export const createMarkerIcons = ( createOverlapIcon(taskCount, `marker-overlap-${taskCount}-primary`, '#f59e0b', 3) - createOverlapIcon(taskCount, `marker-overlap-${taskCount}-hovered`, '#22c55e', 3) + createOverlapIcon(taskCount, `marker-overlap-${taskCount}-hovered`, '#3b82f6', 3) createOverlapIcon(taskCount, `marker-overlap-${taskCount}-lasso`, '#eab308', 3) @@ -342,7 +342,7 @@ export const createMarkerIcons = ( createOverlapIcon('20+', 'marker-overlap-many-selected', '#8b5cf6', 3) createOverlapIcon('20+', 'marker-overlap-many-bundled', '#22c55e', 3) createOverlapIcon('20+', 'marker-overlap-many-primary', '#f59e0b', 3) - createOverlapIcon('20+', 'marker-overlap-many-hovered', '#22c55e', 3) + createOverlapIcon('20+', 'marker-overlap-many-hovered', '#3b82f6', 3) createOverlapIcon('20+', 'marker-overlap-many-lasso', '#eab308', 3) createDualBorderOverlapIcon('20+', 'marker-overlap-many-bundled-selected', '#8b5cf6', '#22c55e') createDualBorderOverlapIcon('20+', 'marker-overlap-many-primary-selected', '#8b5cf6', '#f59e0b') diff --git a/src/components/Pages/BrowsedChallengePage/ChallengePanel/ChallengeFooter.tsx b/src/components/Pages/BrowsedChallengePage/ChallengePanel/ChallengeFooter.tsx index b6405ee28..2a4dfe3d1 100644 --- a/src/components/Pages/BrowsedChallengePage/ChallengePanel/ChallengeFooter.tsx +++ b/src/components/Pages/BrowsedChallengePage/ChallengePanel/ChallengeFooter.tsx @@ -1,5 +1,4 @@ import { useQueryClient } from '@tanstack/react-query' -import { useNavigate } from '@tanstack/react-router' import { Flag, Map as MapIcon, Play } from 'lucide-react' import { useState } from 'react' import { toast } from 'sonner' @@ -7,6 +6,7 @@ import { api } from '@/api' import { useBrowsedChallengeContext } from '@/components/Pages/BrowsedChallengePage/contexts/BrowsedChallengeContext' import { ChallengePausedNotice } from '@/components/shared/ChallengePausedNotice' import { Button } from '@/components/ui/Button' +import { useNavigateToTask } from '@/hooks/useNavigateToTask' import { useIntl } from '@/i18n' import { logger } from '@/lib/logger' import { useMapToggle } from '../MapToggleContext' @@ -14,7 +14,7 @@ import { ChallengeProgress } from './ChallengeProgress' export const ChallengeFooter = () => { const queryClient = useQueryClient() - const navigate = useNavigate() + const navigateToTask = useNavigateToTask() const { challenge, existingIssue } = useBrowsedChallengeContext() const { showMap, setShowMap } = useMapToggle() const { t } = useIntl() @@ -29,8 +29,7 @@ export const ChallengeFooter = () => { const task = await api.challenge.getRandomTask(challenge.id, queryClient) if (task && task.length > 0) { - const taskId = task[0].id - await navigate({ to: '/tasks/$taskId', params: { taskId: String(taskId) } }) + await navigateToTask(task[0].id) } else { toast.error( t( diff --git a/src/components/Pages/TaskEditPage/TaskActionModal.tsx b/src/components/Pages/TaskEditPage/TaskActionModal.tsx index ebec6aa02..483748e64 100644 --- a/src/components/Pages/TaskEditPage/TaskActionModal.tsx +++ b/src/components/Pages/TaskEditPage/TaskActionModal.tsx @@ -24,12 +24,15 @@ import { SelectValue, } from '@/components/ui/Select' import { Textarea } from '@/components/ui/Textarea' +import { useLockConflict } from '@/hooks/useLockConflict' +import { useNavigateToTask } from '@/hooks/useNavigateToTask' import { useIntl } from '@/i18n' import { getApiErrorMessage } from '@/lib/apiError' import { logger } from '@/lib/logger' import { getStatusLabel } from '@/lib/taskConstants' import type { Task } from '@/types/Task' import { PENDING_BUNDLE_ID, useTaskBundleContext } from './contexts/TaskBundleContext' +import { LockConflictModal } from './TaskActions/LockConflictModal' import { TaskNearbyMap } from './TaskNearbyMap' interface TaskActionModalProps { @@ -48,6 +51,7 @@ export const TaskActionModal = ({ const { t } = useIntl() const queryClient = useQueryClient() const navigate = useNavigate() + const navigateToTask = useNavigateToTask() const commentId = useId() const tagsId = useId() const randomId = useId() @@ -79,6 +83,7 @@ export const TaskActionModal = ({ const updateBundleStatusMutation = api.taskBundle.useUpdateTaskBundleStatus() const createBundleMutation = api.taskBundle.useCreateTaskBundle() const updateBundleMutation = api.taskBundle.useUpdateTaskBundle() + const lockConflict = useLockConflict() const { activeBundle, initialBundle } = useTaskBundleContext() const currentStatus = task.status ?? 0 const currentStatusLabel = @@ -143,7 +148,10 @@ export const TaskActionModal = ({ } if (comment.trim()) { - addTaskCommentMutation.mutate({ taskId: task.id, commentText: comment.trim() }) + addTaskCommentMutation.mutate({ + taskId: task.id, + commentText: comment.trim(), + }) } toast.success( @@ -155,7 +163,7 @@ export const TaskActionModal = ({ ) if (nextTaskType === 'nearby' && selectedNearbyTaskId) { - await navigate({ to: '/tasks/$taskId', params: { taskId: String(selectedNearbyTaskId) } }) + await navigateToTask(selectedNearbyTaskId) } else { toast.info( t('taskEditPage.taskActionModal.toast.loadingNext', undefined, 'Loading next task...') @@ -163,7 +171,7 @@ export const TaskActionModal = ({ try { const randomTasks = await api.challenge.getRandomTask(task.parent, queryClient) if (randomTasks && randomTasks.length > 0) { - await navigate({ to: '/tasks/$taskId', params: { taskId: String(randomTasks[0].id) } }) + await navigateToTask(randomTasks[0].id) } else { toast.info( t( @@ -188,6 +196,11 @@ export const TaskActionModal = ({ onOpenChange(false) } catch (error) { + const isLockConflict = await lockConflict.handleError(error, () => { + void handleSubmit() + }) + if (isLockConflict) return + logger.error('Error updating task', { error: String(error) }) toast.error( (await getApiErrorMessage(error)) ?? @@ -212,174 +225,186 @@ export const TaskActionModal = ({ } return ( - - - - - {t('taskEditPage.taskActionModal.title', undefined, 'Complete Task Action')} - - - {t( - 'taskEditPage.taskActionModal.description', - undefined, - 'Update the task status and optionally add a comment or tags' - )} - - + <> + + + + + {t('taskEditPage.taskActionModal.title', undefined, 'Complete Task Action')} + + + {t( + 'taskEditPage.taskActionModal.description', + undefined, + 'Update the task status and optionally add a comment or tags' + )} + + -
- {/* Status Transition */} -
- -
-
- {currentStatusLabel} +
+ {/* Status Transition */} +
+ +
+
+ {currentStatusLabel} +
+ +
- -
-
- {/* Comment */} -
- -