diff --git a/web2.0/src/mutations/guides-observers-mutations.ts b/web2.0/src/mutations/guides-observers-mutations.ts new file mode 100644 index 000000000..82912b296 --- /dev/null +++ b/web2.0/src/mutations/guides-observers-mutations.ts @@ -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), + }) + }, + }) diff --git a/web2.0/src/pages/NgoAdmin/GuidesObservers/Page.tsx b/web2.0/src/pages/NgoAdmin/GuidesObservers/Page.tsx new file mode 100644 index 000000000..68c6b997b --- /dev/null +++ b/web2.0/src/pages/NgoAdmin/GuidesObservers/Page.tsx @@ -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 ( + +
+
+

Observer guides

+

Here's all guides your observers have access to

+
+ +
+ + +
+ ) +} + +export default Page diff --git a/web2.0/src/pages/NgoAdmin/GuidesObservers/components/CreateGuideDialog.tsx b/web2.0/src/pages/NgoAdmin/GuidesObservers/components/CreateGuideDialog.tsx new file mode 100644 index 000000000..a2d6ca005 --- /dev/null +++ b/web2.0/src/pages/NgoAdmin/GuidesObservers/components/CreateGuideDialog.tsx @@ -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({ + 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 ( + { + if (!nextOpen) { + setOpen(null) + } + }} + > + event.preventDefault()} + > + + New guide + + + +
+ + ( + + Title + + + + + + )} + /> + + {newGuideType === GuideType.Document && ( + ( + + Guide + + onChange(event.target.files?.[0])} + disabled={createGuideMutation.isPending} + /> + + Up to 50 MB. + + + )} + /> + )} + + {newGuideType === GuideType.Website && ( + ( + + Guide url + + + + + + )} + /> + )} + + {newGuideType === GuideType.Text && ( + ( + + Text + +