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
2 changes: 2 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,5 @@ VITE_SERVER_API_KEY=""
# (e.g. https://mpr.lt/c/<challengeId>/t/<taskId>). If unset,
# short links will be omitted from changeset comments.
VITE_SHORT_URL="https://mpr.lt"
# Comma-separated URLs of plugin bundles to auto-load at login (e.g. maproulette-review)
# VITE_DEPLOYMENT_PLUGIN_URLS="http://localhost:4201/maprouletteReviewPlugin.js"
21 changes: 18 additions & 3 deletions e2e/task-workflow.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,22 @@ test('a user can open a task, view its details, and mark it as fixed', async ({
// The map view appends a `#zoom/lat/lng` hash once it settles, so match the
// path rather than requiring an exact end-of-string.
await expect(page).toHaveURL(new RegExp(`/challenge/${challenge.id}(#|$)`), { timeout: 20_000 })
await expect(page.getByRole('heading', { name: challenge.name })).toBeVisible({
timeout: 15_000,
})

// Finishing the last (or first) task can award several achievements at once
// (e.g. First Fix + Challenge Champion + Closer). Each opens CongratulateModal
// for ~8s, and Radix Dialog aria-hides the rest of the page — including the
// challenge heading — until the queue is cleared. Dismiss one-at-a-time;
// the next achievement may open immediately so we don't wait for "hidden".
const challengeHeading = page.getByRole('heading', { name: challenge.name })
await expect(async () => {
if (
await page
.getByRole('dialog')
.isVisible()
.catch(() => false)
) {
await page.keyboard.press('Escape')
}
await expect(challengeHeading).toBeVisible({ timeout: 500 })
}).toPass({ timeout: 25_000 })
})
7 changes: 2 additions & 5 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,15 @@
<body>
<div id="app" tabindex="-1"></div>
<script type="module">
// Load runtime config into window.env, then start the app
// Load runtime config, expose host React for plugins, then boot the app
window.env = await fetch('/env.json').then((res) => res.json());
await import('/src/main.tsx');
</script>
<script type="module">
// Expose React globallt for plugins after the main app loads
import React from 'react';
import ReactDOM from 'react-dom';
import * as jsxRuntime from 'react/jsx-runtime';
window.React = React;
window.ReactDOM = ReactDOM;
window.jsxRuntime = jsxRuntime;
await import('/src/main.tsx');
</script>
</body>

Expand Down
4 changes: 2 additions & 2 deletions src/api/osm.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,8 @@ afterEach(() => {
vi.stubGlobal('DOMParser', new Window().DOMParser)
})

const OSM_SERVER = 'https://www.openstreetmap.org'
const OSM_API_SERVER = 'https://api.openstreetmap.org'
const OSM_SERVER = window.env.VITE_OSM_SERVER || 'https://www.openstreetmap.org'
const OSM_API_SERVER = window.env.VITE_OSM_API_SERVER || 'https://api.openstreetmap.org'

function stubFetch(implementation: (uri: string) => Promise<Response> | Response) {
const fetchMock = vi.fn(async (uri: string) => implementation(uri))
Expand Down
16 changes: 10 additions & 6 deletions src/api/task/single.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -418,13 +418,13 @@ describe('taskSingle.useUpdateTask', () => {
})

describe('taskSingle.useUpdateTaskStatus', () => {
it('builds a query string with tags and requestReview, posts a comment, and falls back to a GET when the PUT has no JSON body', async () => {
it('builds a query string with tags and opaque plugin queryParams, posts a comment, and falls back to a GET when the PUT has no JSON body', async () => {
const finalTask = makeTask({ id: 1, parent: 10, status: 2 })
const fetchMock = stubRoutedFetch((request) => {
const url = new URL(request.url)
if (request.method === 'PUT' && url.pathname === '/api/v2/task/1/2') {
expect(url.searchParams.get('tags')).toBe('a,b')
expect(url.searchParams.get('requestReview')).toBe('true')
expect(url.searchParams.get('pluginFlag')).toBe('true')
return new Response(null, { status: 204 })
}
if (request.method === 'POST' && url.pathname === '/api/v2/task/1/comment') {
Expand Down Expand Up @@ -452,7 +452,7 @@ describe('taskSingle.useUpdateTaskStatus', () => {
result.current.mutate({
taskId: 1,
status: 2,
options: { tags: ['a', 'b'], requestReview: true, comment: 'looks good' },
options: { tags: ['a', 'b'], queryParams: { pluginFlag: true }, comment: 'looks good' },
})

await waitFor(() => expect(result.current.isSuccess).toBe(true))
Expand Down Expand Up @@ -494,11 +494,11 @@ describe('taskSingle.useUpdateTaskStatus', () => {
expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ['challenge', 5] })
})

it('sets requestReview=false explicitly, and skips the marker/aggregate patch when the updated task has no parent', async () => {
it('forwards opaque plugin queryParams, and skips the marker/aggregate patch when the updated task has no parent', async () => {
const finalTask = makeTask({ id: 3, parent: 0, status: 3 })
const fetchMock = stubRoutedFetch((request) => {
const url = new URL(request.url)
expect(url.searchParams.get('requestReview')).toBe('false')
expect(url.searchParams.get('pluginFlag')).toBe('false')
expect(url.searchParams.has('tags')).toBe(false)
return new Response(JSON.stringify(finalTask), {
status: 200,
Expand All @@ -513,7 +513,11 @@ describe('taskSingle.useUpdateTaskStatus', () => {
wrapper: queryClientWrapper(queryClient),
})

result.current.mutate({ taskId: 3, status: 3, options: { requestReview: false } })
result.current.mutate({
taskId: 3,
status: 3,
options: { queryParams: { pluginFlag: false } },
})

await waitFor(() => expect(result.current.isSuccess).toBe(true))

Expand Down
11 changes: 8 additions & 3 deletions src/api/task/single.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,17 +151,22 @@ export const taskSingle = {
status: number
options?: {
tags?: string[]
requestReview?: boolean
comment?: string
/** Opaque query params contributed by plugins */
queryParams?: Record<string, string | boolean | number | undefined | null>
}
}) => {
// Build query string manually
const params = new URLSearchParams()
if (options?.tags && options.tags.length > 0) {
params.set('tags', options.tags.join(','))
}
if (options?.requestReview !== undefined) {
params.set('requestReview', options.requestReview.toString())
if (options?.queryParams) {
for (const [key, value] of Object.entries(options.queryParams)) {
if (value !== undefined && value !== null) {
params.set(key, String(value))
}
}
}

const queryString = params.toString()
Expand Down
10 changes: 10 additions & 0 deletions src/api/taskBundle/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,16 +106,26 @@ export const taskBundleQueries = {
primaryId,
status,
tags,
queryParams,
}: {
bundleId: number
primaryId: number
status: number
tags?: string[]
/** Opaque query params contributed by plugins */
queryParams?: Record<string, string | boolean | number | undefined | null>
}) => {
const searchParams: Record<string, string> = { primaryId: String(primaryId) }
if (tags && tags.length > 0) {
searchParams.tags = tags.join(',')
}
if (queryParams) {
for (const [key, value] of Object.entries(queryParams)) {
if (value !== undefined && value !== null) {
searchParams[key] = String(value)
}
}
}
await apiRequest.put(`api/v2/taskBundle/${bundleId}/${status}`, { searchParams })
},
onSuccess: (_data, variables) => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { toast } from 'sonner'
import { api } from '@/api'
import { useBrowsedChallengeContext } from '@/components/Pages/BrowsedChallengePage/contexts/BrowsedChallengeContext'
import { Button } from '@/components/ui/Button'
import { usePluginContext } from '@/contexts/PluginContext'
import { useIntl } from '@/i18n'
import { logger } from '@/lib/logger'
import { useMapToggle } from '../MapToggleContext'
Expand All @@ -14,7 +15,8 @@ import { ChallengeProgress } from './ChallengeProgress'
export const ChallengeFooter = () => {
const queryClient = useQueryClient()
const navigate = useNavigate()
const { challenge, existingIssue } = useBrowsedChallengeContext()
const { challenge, existingIssue, user } = useBrowsedChallengeContext()
const { challengeFooterExtensions } = usePluginContext()
const { showMap, setShowMap } = useMapToggle()
const { t } = useIntl()

Expand Down Expand Up @@ -49,8 +51,8 @@ export const ChallengeFooter = () => {
}
}

return (
<div className="shrink-0 rounded-b-xl border-zinc-200/50 border-t bg-white px-6 py-6 dark:border-slate-700/50 dark:bg-slate-800">
const mapContent = (
<>
<ChallengeProgress />

{existingIssue && (
Expand Down Expand Up @@ -91,6 +93,17 @@ export const ChallengeFooter = () => {
: t('browsedChallengePage.footer.startChallenge', undefined, 'Start Challenge')}
</Button>
</div>
</>
)
const FooterExtension = challengeFooterExtensions[0]?.component

return (
<div className="shrink-0 rounded-b-xl border-zinc-200/50 border-t bg-white px-6 py-6 dark:border-slate-700/50 dark:bg-slate-800">
{FooterExtension ? (
<FooterExtension challenge={challenge} user={user} mapContent={mapContent} />
) : (
mapContent
)}
<div className="mt-6 md:hidden">
<Button
onClick={() => setShowMap(!showMap)}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import type { ReactNode } from 'react'
import type { UseFormReturn } from 'react-hook-form'
import type { z } from 'zod'
import { FieldDescription, FieldGroup, FieldLegend, FieldSet } from '@/components/ui/Field'
Expand All @@ -21,7 +22,13 @@ import { baseMapOptions, editorOptions, localeOptions } from '@/data/account.jso
import { FieldSubmit } from './FieldSubmit'
import type { formSchema } from './formSchema'

export const GeneralSettings = ({ form }: { form: UseFormReturn<z.infer<typeof formSchema>> }) => {
export const GeneralSettings = ({
form,
children,
}: {
form: UseFormReturn<z.infer<typeof formSchema>>
children?: ReactNode
}) => {
return (
<FieldSet>
<FieldLegend>General</FieldLegend>
Expand Down Expand Up @@ -127,6 +134,7 @@ export const GeneralSettings = ({ form }: { form: UseFormReturn<z.infer<typeof f
</FormItem>
)}
/>
{children}
</FieldGroup>
<FieldSubmit isSubmitting={form.formState.isSubmitting} />
</FieldSet>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { useEffect } from 'react'
import type { FieldPath, UseFormReturn } from 'react-hook-form'
import type { z } from 'zod'
import { FormField, FormItem, FormMessage } from '@/components/ui/Form'
import { usePluginContext } from '@/contexts/PluginContext'
import type { UserSettings } from '@/types/User'
import type { formSchema } from './formSchema'

type SettingsFormValues = z.infer<typeof formSchema>

/** Renders plugin-owned settings inputs bound to the shared Account form. */
export const PluginUserSettingsFields = ({
form,
settings,
}: {
form: UseFormReturn<SettingsFormValues>
settings: UserSettings
}) => {
const { userSettingsFields: fields } = usePluginContext()

useEffect(() => {
for (const pluginField of fields) {
const settingsRecord = settings as Record<string, unknown>
if (settingsRecord[pluginField.name] !== undefined) {
form.setValue(
pluginField.name as FieldPath<SettingsFormValues>,
settingsRecord[pluginField.name] as SettingsFormValues[FieldPath<SettingsFormValues>]
)
}
}
}, [fields, form, settings])

if (fields.length === 0) return null

return (
<>
{fields.map((pluginField) => {
const FieldComponent = pluginField.component
return (
<FormField
key={pluginField.id}
control={form.control}
name={pluginField.name as FieldPath<SettingsFormValues>}
render={({ field }) => (
<FormItem>
<FieldComponent
value={field.value}
onChange={field.onChange}
disabled={form.formState.isSubmitting}
/>
<FormMessage />
</FormItem>
)}
/>
)
})}
</>
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,6 @@ const validInput = {
email: 'user@example.com',
emailOptIn: true,
leaderboardOptOut: false,
needsReview: 2,
isReviewer: true,
allowFollowing: true,
theme: 1,
seeTagFixSuggestions: true,
Expand Down Expand Up @@ -97,14 +95,22 @@ describe('formSchema', () => {
expect(result.success).toBe(false)
})

it('rejects a negative needsReview value', () => {
const result = formSchema.safeParse({ ...validInput, needsReview: -1 })
expect(result.success).toBe(false)
it('accepts opaque plugin settings fields via loose schema', () => {
const result = formSchema.safeParse({
...validInput,
pluginNumber: -1,
pluginEnabled: true,
pluginNote: 'anything',
})
expect(result.success).toBe(true)
})

it('accepts a needsReview value of zero', () => {
const result = formSchema.safeParse({ ...validInput, needsReview: 0 })
it('preserves opaque plugin settings fields in the parse output', () => {
const result = formSchema.safeParse({ ...validInput, pluginNumber: 0 })
expect(result.success).toBe(true)
if (result.success) {
expect(result.data.pluginNumber).toBe(0)
}
})

it('rejects a theme value below the minimum', () => {
Expand Down
55 changes: 28 additions & 27 deletions src/components/Pages/SettingsPage/UserSettingsForm/formSchema.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,31 @@
import z from 'zod'
import { baseMapOptions, editorOptions, localeOptions } from '@/data/account.json'

export const formSchema = z.object({
defaultEditor: z
.number()
.refine((val) => editorOptions.some((option) => option.value === val), {
message: 'Invalid editor option',
})
.optional(),
defaultBasemap: z.refine((val) => baseMapOptions.some((option) => option.value === val), {
message: 'Invalid basemap option',
}),
defaultBasemapId: z.string().optional(),
locale: z
.string()
.refine((val) => localeOptions.some((option) => option.value === val), {
message: 'Invalid language option',
})
.optional(),
email: z.email().optional().or(z.literal('')),
emailOptIn: z.boolean().optional(),
leaderboardOptOut: z.boolean().optional(),
needsReview: z.number().min(0).optional(),
isReviewer: z.boolean().optional(),
allowFollowing: z.boolean().optional(),
theme: z.number().min(0).max(2).optional(),
seeTagFixSuggestions: z.boolean().optional(),
disableTaskConfirm: z.boolean().optional(),
})
export const formSchema = z
.object({
defaultEditor: z
.number()
.refine((val) => editorOptions.some((option) => option.value === val), {
message: 'Invalid editor option',
})
.optional(),
defaultBasemap: z.refine((val) => baseMapOptions.some((option) => option.value === val), {
message: 'Invalid basemap option',
}),
defaultBasemapId: z.string().optional(),
locale: z
.string()
.refine((val) => localeOptions.some((option) => option.value === val), {
message: 'Invalid language option',
})
.optional(),
email: z.email().optional().or(z.literal('')),
emailOptIn: z.boolean().optional(),
leaderboardOptOut: z.boolean().optional(),
allowFollowing: z.boolean().optional(),
theme: z.number().min(0).max(2).optional(),
seeTagFixSuggestions: z.boolean().optional(),
disableTaskConfirm: z.boolean().optional(),
})
// Allow plugin-contributed settings fields (e.g. from getUserSettingsFields)
.loose()
Loading