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
57 changes: 57 additions & 0 deletions web2.0/src/mutations/guides-observers-mutations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { useMutation } from '@tanstack/react-query'
import { queryClient } from '@/main'
import { guidesObserversKeys } from '@/queries/guides-observers'
import {
createGuide,
type CreateGuideRequest,
} from '@/services/api/guides-observers/create.api'
import { deleteGuide } from '@/services/api/guides-observers/delete.api'
import {
updateGuide,
type UpdateGuideRequest,
} from '@/services/api/guides-observers/update.api'

/**
* Every mutation invalidates the whole guides namespace of the election round.
* The list is the single source of truth for the table, and the create endpoint
* answers with a partial model while the update one answers with nothing, so
* refetching is cheaper than patching the cache by hand.
*/

export const useCreateGuideMutation = (electionRoundId: string) =>
useMutation({
mutationFn: async (guide: CreateGuideRequest) =>
await createGuide(electionRoundId, guide),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: guidesObserversKeys.all(electionRoundId),
})
},
})

export const useUpdateGuideMutation = (electionRoundId: string) =>
useMutation({
mutationFn: async ({
guideId,
guide,
}: {
guideId: string
guide: UpdateGuideRequest
}) => await updateGuide(electionRoundId, guideId, guide),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: guidesObserversKeys.all(electionRoundId),
})
},
})

export const useDeleteGuideMutation = (electionRoundId: string) =>
useMutation({
mutationFn: async (guideId: string) =>
await deleteGuide(electionRoundId, guideId),
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: guidesObserversKeys.all(electionRoundId),
})
},
})
29 changes: 29 additions & 0 deletions web2.0/src/pages/NgoAdmin/GuidesObservers/Page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useCurrentElectionRound } from '@/contexts/election-round.context'
import { ElectionRoundStatus } from '@/types/election'
import { H1, P } from '@/components/ui/typography'
import { GuidesDialogs } from './components/Dialogs'
import { GuidesProvider } from './components/GuidesProvider'
import GuidesTable from './components/Table'
import { UploadGuideMenu } from './components/UploadGuideMenu'

function Page() {
const { electionRound } = useCurrentElectionRound()
// Archived election rounds are frozen, so nothing new can be uploaded to them.
const isArchived = electionRound?.status === ElectionRoundStatus.Archived

return (
<GuidesProvider>
<div className='flex items-center justify-between'>
<div>
<H1>Observer guides</H1>
<P>Here&apos;s all guides your observers have access to</P>
</div>
<UploadGuideMenu disabled={isArchived} />
</div>
<GuidesTable />
<GuidesDialogs />
</GuidesProvider>
)
}

export default Page
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { useEffect } from 'react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { useCreateGuideMutation } from '@/mutations/guides-observers-mutations'
import { Route } from '@/routes/(app)/elections/$electionRoundId/guides'
import {
createGuideSchema,
GuideType,
type CreateGuideForm,
} from '@/types/guides-observer'
import { toast } from 'sonner'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from '@/components/ui/form'
import { Input } from '@/components/ui/input'
import { Separator } from '@/components/ui/separator'
import { Spinner } from '@/components/ui/spinner'
import { Textarea } from '@/components/ui/textarea'
import { useGuides } from './GuidesProvider'

export function CreateGuideDialog() {
const { open, setOpen, newGuideType } = useGuides()
const { electionRoundId } = Route.useParams()
const createGuideMutation = useCreateGuideMutation(electionRoundId)

// The type is chosen from the upload menu, so the dialog stays closed until
// one has been picked.
const isOpen = open === 'create' && newGuideType !== null

const form = useForm<CreateGuideForm>({
resolver: zodResolver(createGuideSchema),
mode: 'all',
defaultValues: {
guideType: newGuideType ?? GuideType.Document,
title: '',
file: undefined,
websiteUrl: '',
text: '',
},
})

// Start from a blank form on every open, otherwise a cancelled attempt would
// come back with its old values, its errors, and the previously picked type.
useEffect(() => {
if (isOpen && newGuideType) {
form.reset({
guideType: newGuideType,
title: '',
file: undefined,
websiteUrl: '',
text: '',
})
}
}, [form, isOpen, newGuideType])

const onSubmit = (values: CreateGuideForm) => {
createGuideMutation.mutate(
{
title: values.title,
guideType: values.guideType,
file: values.file,
websiteUrl: values.websiteUrl,
text: values.text,
},
{
onSuccess: () => {
setOpen(null)
toast.success('Upload was successful')
},
onError: () => {
toast.error('Error uploading guide', {
description:
'Please try again or contact support if the problem persists.',
})
},
}
)
}

return (
<Dialog
open={isOpen}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
setOpen(null)
}
}}
>
<DialogContent
className='sm:max-w-[650px]'
// A misclick outside the dialog should not throw away a half filled
// form, which is how the same dialog behaves in the current admin app.
onInteractOutside={(event) => event.preventDefault()}
>
<DialogHeader>
<DialogTitle>New guide</DialogTitle>
</DialogHeader>
<Separator />

<Form {...form}>
<form
id='create-guide-form'
onSubmit={form.handleSubmit(onSubmit)}
className='flex w-full flex-col gap-4'
>
<FormField
control={form.control}
name='title'
render={({ field }) => (
<FormItem>
<FormLabel>Title</FormLabel>
<FormControl>
<Input placeholder='Title' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>

{newGuideType === GuideType.Document && (
<FormField
control={form.control}
name='file'
// `value` is left out on purpose: a file input cannot be given
// one, only read from.
render={({ field: { name, onBlur, onChange, ref } }) => (
<FormItem>
<FormLabel>Guide</FormLabel>
<FormControl>
<Input
type='file'
name={name}
ref={ref}
onBlur={onBlur}
onChange={(event) => onChange(event.target.files?.[0])}
disabled={createGuideMutation.isPending}
/>
</FormControl>
<FormDescription>Up to 50 MB.</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}

{newGuideType === GuideType.Website && (
<FormField
control={form.control}
name='websiteUrl'
render={({ field }) => (
<FormItem>
<FormLabel>Guide url</FormLabel>
<FormControl>
<Input placeholder='https://' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}

{newGuideType === GuideType.Text && (
<FormField
control={form.control}
name='text'
render={({ field }) => (
<FormItem>
<FormLabel>Text</FormLabel>
<FormControl>
<Textarea className='min-h-48' {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
</form>
</Form>

<DialogFooter>
<Button
variant='outline'
onClick={() => setOpen(null)}
disabled={createGuideMutation.isPending}
>
Cancel
</Button>
<Button
type='submit'
form='create-guide-form'
disabled={createGuideMutation.isPending}
>
{createGuideMutation.isPending && <Spinner className='mr-2' />}
Upload guide
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
58 changes: 58 additions & 0 deletions web2.0/src/pages/NgoAdmin/GuidesObservers/components/Dialogs.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { useDeleteGuideMutation } from '@/mutations/guides-observers-mutations'
import { Route } from '@/routes/(app)/elections/$electionRoundId/guides'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { CreateGuideDialog } from './CreateGuideDialog'
import { useGuides } from './GuidesProvider'
import { UpdateGuideDialog } from './UpdateGuideDialog'

/** Every dialog of the guides page, mounted once next to the table. */
export function GuidesDialogs() {
const { open, setOpen, currentRow } = useGuides()
const { electionRoundId } = Route.useParams()
const deleteGuideMutation = useDeleteGuideMutation(electionRoundId)

const handleDelete = () => {
if (!currentRow) {
return
}

deleteGuideMutation.mutate(currentRow.id, {
onSuccess: () => {
setOpen(null)
toast.success('Delete was successful')
},
onError: () => {
toast.error('Error deleting guide', {
description:
'Please try again or contact support if the problem persists.',
})
},
})
}

return (
<>
<CreateGuideDialog />
<UpdateGuideDialog />

{currentRow && (
<ConfirmDialog
destructive
open={open === 'delete'}
onOpenChange={(isOpen) => {
if (!isOpen) {
setOpen(null)
}
}}
handleConfirm={handleDelete}
isLoading={deleteGuideMutation.isPending}
className='max-w-md'
title={`Delete ${currentRow.title} ?`}
desc='Are you sure you want to delete this guide? This action cannot be undone.'
confirmText='Delete'
/>
)}
</>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { GuideType } from '@/types/guides-observer'
import { FileText, Link2, Paperclip } from 'lucide-react'
import { cn } from '@/lib/utils'

/** One icon per guide type, shared by the table and the upload menu. */
const guideTypeIcons = {
[GuideType.Document]: Paperclip,
[GuideType.Website]: Link2,
[GuideType.Text]: FileText,
}

type GuideTypeIconProps = {
guideType: GuideType
className?: string
}

export function GuideTypeIcon({ guideType, className }: GuideTypeIconProps) {
// Guides created before a new type is added would render nothing, so fall
// back to the plain text icon.
const Icon = guideTypeIcons[guideType] ?? FileText

return <Icon className={cn('h-4 w-4 opacity-50', className)} />
}
Loading