Skip to content
Open
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
7 changes: 6 additions & 1 deletion docker-compose.test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
5 changes: 5 additions & 0 deletions e2e/task-bundling.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
})
Expand Down
6 changes: 4 additions & 2 deletions e2e/task-workflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
50 changes: 50 additions & 0 deletions src/api/task/single.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }))
Expand Down
29 changes: 28 additions & 1 deletion src/api/task/single.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export const taskSingle = {
const queryClient = useQueryClient()
return useMutation({
mutationFn: (taskId: number) =>
apiRequest.get(`api/v2/task/${taskId}/start`).json<TaskGetResponse>(),
apiRequest.get(`api/v2/task/${taskId}/start`).json<TaskStartResponse>(),
onSuccess: (lockedTask, taskId) => {
queryClient.setQueryData<TaskGetResponse>(['task', taskId], lockedTask)
queryClient.invalidateQueries({ queryKey: ['task', 'history', taskId] })
Expand All @@ -70,6 +70,12 @@ export const taskSingle = {
})
},

useRefreshLock: () =>
useMutation({
mutationFn: (taskId: number) =>
apiRequest.get(`api/v2/task/${taskId}/refreshLock`).json<TaskStartResponse>(),
}),

useUnlockTask: () => {
const queryClient = useQueryClient()
return useMutation({
Expand All @@ -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({
Expand Down
25 changes: 24 additions & 1 deletion src/components/Map/TaskMarkers/SpiderMarkers.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ interface SpiderMarkersProps {
selectedTaskId?: number | null
activeTaskId?: number | null
lassoSelectedTaskIds?: Set<number>
hoveredTaskId?: number | null
}

/**
Expand All @@ -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'
Expand All @@ -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 {
Expand All @@ -69,6 +72,7 @@ export const SpiderMarkers = ({
isSelected,
isActive,
isLassoSelected,
isHovered,
isSpidered: true,
...(typeKey ? { typeKey } : {}),
},
Expand All @@ -92,6 +96,7 @@ export const SpiderMarkers = ({
selectedTaskId,
activeTaskId,
lassoSelectedTaskIds,
hoveredTaskId,
])

const spiderLinesGeoJSON = useMemo(() => {
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -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,
Expand All @@ -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']],
Expand Down
23 changes: 23 additions & 0 deletions src/components/Map/TaskMarkers/clusterLayers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -267,6 +287,7 @@ export const unclusteredPointLayer: LayerProps = {
['==', ['get', 'isHighlighted'], true],
['==', ['get', 'isActive'], true],
['==', ['get', 'isSelected'], true],
['==', ['get', 'isHovered'], true],
],
1.4,

Expand All @@ -281,6 +302,8 @@ export const unclusteredPointLayer: LayerProps = {
'icon-opacity': [
'case',

['==', ['get', 'isHovered'], true],
1,
['==', ['get', 'isPrimary'], true],
1,
['==', ['get', 'isHighlighted'], true],
Expand Down
6 changes: 3 additions & 3 deletions src/components/Map/TaskMarkers/createMarkerIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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')

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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')
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
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'
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'
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()
Expand All @@ -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(
Expand Down
Loading
Loading