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 @@ -8,3 +8,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"
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
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
@@ -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,72 @@
import { useEffect, useState } 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 { UserSettingsFieldExtension } from '@/types/Plugin'
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 { getUserSettingsFields } = usePluginContext()
const [fields, setFields] = useState<UserSettingsFieldExtension[]>([])

useEffect(() => {
let cancelled = false

void getUserSettingsFields().then((results) => {
if (cancelled) return
setFields(results)

for (const pluginField of results) {
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>]
)
}
}
})

return () => {
cancelled = true
}
}, [getUserSettingsFields, 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>
)}
/>
)
})}
</>
)
}
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()
60 changes: 50 additions & 10 deletions src/components/Pages/SettingsPage/UserSettingsForm/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,35 @@ import { zodResolver } from '@hookform/resolvers/zod'
import { useForm } from 'react-hook-form'
import { toast } from 'sonner'
import type { z } from 'zod'
import { api } from '@/api'
import { FieldGroup } from '@/components/ui/Field'
import { Form } from '@/components/ui/Form'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/Tabs'
import type { User } from '@/types/User'
import type { User, UserSettings } from '@/types/User'
import { ApiSettings } from './ApiSettings'
import { formSchema } from './formSchema'
import { GeneralSettings } from './GeneralSettings'
import { NotificationsSettings } from './NotificationsSettings'
import { PluginSettings } from './PluginSettings'
import { PluginUserSettingsFields } from './PluginUserSettingsFields'

const CORE_SETTINGS_KEYS = new Set([
'defaultEditor',
'defaultBasemap',
'defaultBasemapId',
'locale',
'email',
'emailOptIn',
'leaderboardOptOut',
'allowFollowing',
'theme',
'seeTagFixSuggestions',
'disableTaskConfirm',
])

export const UserSettingsForm = ({ user }: { user: User }) => {
const updateSettingsMutation = api.user.useUpdateUserSettings()

const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
Expand All @@ -24,14 +42,34 @@ export const UserSettingsForm = ({ user }: { user: User }) => {
},
})

const onSubmit = async () => {
return new Promise((resolve) => {
setTimeout(() => {
resolve(true)
}, 1000)
}).then(() => {
toast('User settings updated')
})
const onSubmit = async (values: z.infer<typeof formSchema>) => {
try {
const pluginSettings: Record<string, unknown> = {}
for (const [key, value] of Object.entries(values)) {
if (!CORE_SETTINGS_KEYS.has(key)) {
pluginSettings[key] = value
}
}

await updateSettingsMutation.mutateAsync({
userId: user.id,
settings: {
...user.settings,
defaultEditor: values.defaultEditor,
defaultBasemap:
typeof values.defaultBasemap === 'number'
? values.defaultBasemap
: user.settings.defaultBasemap,
defaultBasemapId: values.defaultBasemapId,
locale: values.locale,
email: values.email,
...(pluginSettings as UserSettings),
},
})
toast.success('User settings updated')
} catch {
toast.error('Failed to update user settings')
}
}

return (
Expand All @@ -48,7 +86,9 @@ export const UserSettingsForm = ({ user }: { user: User }) => {
<div className="rounded-lg bg-zinc-50 p-4 lg:p-6 dark:bg-slate-900">
<FieldGroup>
<TabsContent value="general">
<GeneralSettings form={form} />
<GeneralSettings form={form}>
<PluginUserSettingsFields form={form} settings={user.settings} />
</GeneralSettings>
</TabsContent>
<TabsContent value="notifications">
<NotificationsSettings form={form} />
Expand Down
Loading
Loading