From e0e5400340fabd0b92191829b4549f5324215f85 Mon Sep 17 00:00:00 2001 From: Cristian Istrate Date: Wed, 8 Oct 2025 14:18:17 +0300 Subject: [PATCH 1/2] votemonitor-1005 [Hacktoberfest][Web2.0] Implement forms CRUD in new UI --- .../pages/NgoAdmin/GuidesObservers/Page.tsx | 14 +++ .../GuidesObservers/components/Table.tsx | 49 +++++++++ .../components/TableColumns.tsx | 104 ++++++++++++++++++ .../components/TableFilters.tsx | 61 ++++++++++ web2.0/src/queries/guides-observers.ts | 35 ++++++ .../$electionRoundId/guides/index.tsx | 10 +- .../services/api/guides-observers/list.api.ts | 13 +++ web2.0/src/types/guides-observer.ts | 35 ++++++ 8 files changed, 316 insertions(+), 5 deletions(-) create mode 100644 web2.0/src/pages/NgoAdmin/GuidesObservers/Page.tsx create mode 100644 web2.0/src/pages/NgoAdmin/GuidesObservers/components/Table.tsx create mode 100644 web2.0/src/pages/NgoAdmin/GuidesObservers/components/TableColumns.tsx create mode 100644 web2.0/src/pages/NgoAdmin/GuidesObservers/components/TableFilters.tsx create mode 100644 web2.0/src/queries/guides-observers.ts create mode 100644 web2.0/src/services/api/guides-observers/list.api.ts create mode 100644 web2.0/src/types/guides-observer.ts 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..5cb688650 --- /dev/null +++ b/web2.0/src/pages/NgoAdmin/GuidesObservers/Page.tsx @@ -0,0 +1,14 @@ +import {Route} from "@/routes/(app)/elections/$electionRoundId/guides"; +import {useDebounce} from "@/hooks/use-debounce.ts"; +import {useListMonitoringObservers} from "@/queries/monitoring-observers.ts"; +import Table from "@/pages/NgoAdmin/GuidesObservers/components/Table.tsx"; + +export default function Page() { + const { electionRoundId } = Route.useParams(); + const search = Route.useSearch(); + const debouncedSearch = useDebounce(search, 200); + const { data } = useListMonitoringObservers(electionRoundId, debouncedSearch); + + // @ts-ignore + return ; +} \ No newline at end of file diff --git a/web2.0/src/pages/NgoAdmin/GuidesObservers/components/Table.tsx b/web2.0/src/pages/NgoAdmin/GuidesObservers/components/Table.tsx new file mode 100644 index 000000000..6b74ecc12 --- /dev/null +++ b/web2.0/src/pages/NgoAdmin/GuidesObservers/components/Table.tsx @@ -0,0 +1,49 @@ +import type {PageResponse} from "@/types/common.ts"; +import type {GuidesObserverModel} from "@/types/guides-observer.ts"; +import {DataTable} from "@/components/ui/data-table.tsx"; +import {DataTableToolbar} from "@/components/data-table-toolbar.tsx"; +import TableFilters from "@/pages/NgoAdmin/GuidesObservers/components/TableFilters.tsx"; +import {useDataTable} from "@/hooks/use-data-table.ts"; +import {useMemo, useState} from "react"; +import {getGuidesObserversTableColumns} from "@/pages/NgoAdmin/GuidesObservers/components/TableColumns.tsx"; +import type {DataTableRowAction} from "@/types/data-table.ts"; +import { Route } from '@/routes/(app)/elections/$electionRoundId/guides' + +export interface TableProps { + data?: PageResponse; +} + +export default function Table({ data } : TableProps) { + const [rowAction, setRowAction] = useState | null>(null); + + const search = Route.useSearch(); + const navigate = Route.useNavigate(); + + const columns = useMemo( + () => + getGuidesObserversTableColumns({ + setRowAction + }), [setRowAction] + ) + + const { table } = useDataTable({ + tableName: "guides-observer", + data: data?.items || [], + columns, + pageCount: data ? Math.ceil(data.totalCount / data.pageSize) : 0, + initialState: { + sorting: [{ id: "displayName", desc: false }], + columnPinning: { right: ["actions"] }, + }, + getRowId: (originalRow) => originalRow.id, + search, + navigate, + }); + return ( + + + + + + ) +} \ No newline at end of file diff --git a/web2.0/src/pages/NgoAdmin/GuidesObservers/components/TableColumns.tsx b/web2.0/src/pages/NgoAdmin/GuidesObservers/components/TableColumns.tsx new file mode 100644 index 000000000..39dec5259 --- /dev/null +++ b/web2.0/src/pages/NgoAdmin/GuidesObservers/components/TableColumns.tsx @@ -0,0 +1,104 @@ +"use client"; + +import type {DataTableRowAction} from "@/types/data-table"; +import type {ColumnDef} from "@tanstack/react-table"; +import {Ellipsis} from "lucide-react"; +import * as React from "react"; + +import {DataTableColumnHeader} from "@/components/data-table-column-header"; +import {Button} from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type {GuidesObserverModel} from "@/types/guides-observer.ts"; + +interface GetTasksTableColumnsProps { + setRowAction: React.Dispatch< + React.SetStateAction | null> + >; +} + +export function getGuidesObserversTableColumns({ + setRowAction, + }: GetTasksTableColumnsProps): ColumnDef[] { + return [ + { + id: "title", + accessorKey: "title", + header: ({column}) => ( + + ), + cell: ({row}) => ( +
{row.original.title}
+ ), + meta: { + label: "Title", + }, + enableSorting: true, + enableHiding: true, + }, + { + id: "createdOn", + accessorKey: "createdOn", + header: ({column}) => ( + + ), + cell: ({row}) =>
{row.original.createdOn}
, + meta: { + label: "Uploaded On", + }, + enableSorting: true, + enableHiding: true, + }, + { + id: "createdBy", + accessorKey: "createdBy", + header: ({ column }) => ( + + ), + cell: ({ row }) =>
{row.original.createdBy}
, + meta: { + label: "Created By", + }, + enableSorting: true, + enableHiding: true, + }, + { + id: "actions", + cell: function Cell({row}) { + return ( + + + + + + setRowAction({row, variant: "update"})} + > + Edit + + + + setRowAction({row, variant: "delete"})} + > + Delete + + + + ); + }, + size: 40, + }, + ]; +} diff --git a/web2.0/src/pages/NgoAdmin/GuidesObservers/components/TableFilters.tsx b/web2.0/src/pages/NgoAdmin/GuidesObservers/components/TableFilters.tsx new file mode 100644 index 000000000..b0c4f92ba --- /dev/null +++ b/web2.0/src/pages/NgoAdmin/GuidesObservers/components/TableFilters.tsx @@ -0,0 +1,61 @@ +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { getRouteApi } from "@tanstack/react-router"; +import type { Table } from "@tanstack/react-table"; +import { X } from "lucide-react"; +import React from "react"; +import type {GuidesObserverModel} from "@/types/guides-observer.ts"; + +interface DataTableToolbarProps extends React.ComponentProps<"div"> { + table: Table; +} + +const route = getRouteApi( + "/(app)/elections/$electionRoundId/guides/" as const +); + +function TableFilters({ table }: DataTableToolbarProps) { + const search = route.useSearch(); + const navigate = route.useNavigate(); + + const isFiltered = table.getState().columnFilters.length > 0; + + // const onReset = React.useCallback(() => { + // table.resetColumnFilters(); + // }, [table]); + + const onReset = React.useCallback(() => { + console.log("reset"); + }, []); + + return ( +
+ + navigate({ + search: (prev) => ({ ...prev, searchText: event.target.value }), + replace: true, + }) + } + className="h-8 w-40 lg:w-56" + /> + + {isFiltered && ( + + )} +
+ ); +} + +export default TableFilters; diff --git a/web2.0/src/queries/guides-observers.ts b/web2.0/src/queries/guides-observers.ts new file mode 100644 index 000000000..7e9e048f1 --- /dev/null +++ b/web2.0/src/queries/guides-observers.ts @@ -0,0 +1,35 @@ +import { queryOptions, useQuery } from "@tanstack/react-query"; +import type {GuidesObserversSearch} from "@/types/guides-observer.ts"; +import {listGuidesObservers} from "@/services/api/guides-observers/list.api.ts"; + +export const guidesObserversKeys = { + all: (electionRoundId: string) => + ["guides-observers", electionRoundId] as const, + lists: (electionRoundId: string) => + [...guidesObserversKeys.all(electionRoundId), "list"] as const, + list: (electionRoundId: string, search: GuidesObserversSearch) => + [...guidesObserversKeys.lists(electionRoundId), { ...search }] as const, + details: (electionRoundId: string) => + [...guidesObserversKeys.all(electionRoundId), "detail"] as const, + detail: (electionRoundId: string, id: string) => + [...guidesObserversKeys.details(electionRoundId), id] as const, +}; + +const STALE_TIME = 1000 * 60 * 15; // 15 minutes + +export const listGuidesObserversQueryOptions = ( + electionRoundId: string, + search: GuidesObserversSearch +) => + queryOptions({ + queryKey: guidesObserversKeys.list(electionRoundId, search), + queryFn: async () => await listGuidesObservers(electionRoundId, search), + enabled: !!electionRoundId, + staleTime: STALE_TIME, + refetchOnWindowFocus: false, + }); + +export const useListGuidesObservers = ( + electionRoundId: string, + search: GuidesObserversSearch +) => useQuery(listGuidesObserversQueryOptions(electionRoundId, search)); diff --git a/web2.0/src/routes/(app)/elections/$electionRoundId/guides/index.tsx b/web2.0/src/routes/(app)/elections/$electionRoundId/guides/index.tsx index 16135a024..04ce46ccb 100644 --- a/web2.0/src/routes/(app)/elections/$electionRoundId/guides/index.tsx +++ b/web2.0/src/routes/(app)/elections/$electionRoundId/guides/index.tsx @@ -1,11 +1,11 @@ import { createFileRoute } from "@tanstack/react-router"; +import Page from "@/pages/NgoAdmin/GuidesObservers/Page" +import {zodValidator} from "@tanstack/zod-adapter"; +import {guidesObserversSearchSchema} from "@/types/guides-observer.ts"; export const Route = createFileRoute( "/(app)/elections/$electionRoundId/guides/" )({ - component: RouteComponent, + component: Page, + validateSearch: zodValidator(guidesObserversSearchSchema) }); - -function RouteComponent() { - return
Hello "/(app)/elections/$electionRoundId/guides/"!
; -} diff --git a/web2.0/src/services/api/guides-observers/list.api.ts b/web2.0/src/services/api/guides-observers/list.api.ts new file mode 100644 index 000000000..e04291c40 --- /dev/null +++ b/web2.0/src/services/api/guides-observers/list.api.ts @@ -0,0 +1,13 @@ +import { buildURLSearchParams } from "@/lib/utils"; +import API from "@/services/api"; +import type { PageResponse } from "@/types/common"; +import type {GuidesObserverModel, GuidesObserversSearch} from "@/types/guides-observer.ts"; + +export const listGuidesObservers = ( + electionRoundId: string, + search: GuidesObserversSearch +): Promise> => { + return API.get(`election-rounds/${electionRoundId}/observer-guide`, { + params: buildURLSearchParams(search), + }).then((res) => res.data); +}; diff --git a/web2.0/src/types/guides-observer.ts b/web2.0/src/types/guides-observer.ts new file mode 100644 index 000000000..afe15717d --- /dev/null +++ b/web2.0/src/types/guides-observer.ts @@ -0,0 +1,35 @@ +import { z } from "zod"; +import { SortOrder } from "./common"; + +export interface GuidesObserverModel { + id: string; + title: string; + fileName: string; + mimeType: string; + guideType: string; + text: string; + websiteUrl: string; + createdOn: string; + createdBy: string; + presignedUrl: string; + urlValidityInSeconds: string; + filePath: string; + uploadedFileName: string; + isGuideOwner: boolean; +} + +export const guidesObserversSearchSchema = z.object({ + title: z.string().optional(), + fileName: z.string().optional(), + mimeType: z.string().optional(), + guideType: z.string().optional(), + searchText: z.string().optional(), + sortColumnName: z.string().optional(), + sortOrder: z.enum(SortOrder).optional(), + pageNumber: z.number().default(1), + pageSize: z.number().default(25), +}); + +export type GuidesObserversSearch = z.infer< + typeof guidesObserversSearchSchema +>; From 50728515125b93ea565b677ebfb04186c9b6b659 Mon Sep 17 00:00:00 2001 From: Cristian Istrate Date: Wed, 12 Aug 2026 08:13:44 +0300 Subject: [PATCH 2/2] votemonitor-1005 [Hacktoberfest][Web2.0] Implement observer guides CRUD in new UI --- .../mutations/guides-observers-mutations.ts | 57 +++++ .../pages/NgoAdmin/GuidesObservers/Page.tsx | 39 ++- .../components/CreateGuideDialog.tsx | 214 ++++++++++++++++ .../GuidesObservers/components/Dialogs.tsx | 58 +++++ .../components/GuideTypeIcon.tsx | 23 ++ .../components/GuidesProvider.tsx | 60 +++++ .../GuidesObservers/components/RowActions.tsx | 57 +++++ .../GuidesObservers/components/Table.tsx | 177 ++++++++++---- .../components/TableColumns.tsx | 175 ++++++------- .../components/TableFilters.tsx | 111 ++++++--- .../components/UpdateGuideDialog.tsx | 196 +++++++++++++++ .../components/UploadGuideMenu.tsx | 58 +++++ web2.0/src/queries/guides-observers.ts | 58 ++--- .../$electionRoundId/guides/index.tsx | 31 ++- .../api/guides-observers/create.api.ts | 47 ++++ .../api/guides-observers/delete.api.ts | 14 ++ .../services/api/guides-observers/list.api.ts | 26 +- .../api/guides-observers/update.api.ts | 27 +++ web2.0/src/types/guides-observer.ts | 229 +++++++++++++++--- 19 files changed, 1385 insertions(+), 272 deletions(-) create mode 100644 web2.0/src/mutations/guides-observers-mutations.ts create mode 100644 web2.0/src/pages/NgoAdmin/GuidesObservers/components/CreateGuideDialog.tsx create mode 100644 web2.0/src/pages/NgoAdmin/GuidesObservers/components/Dialogs.tsx create mode 100644 web2.0/src/pages/NgoAdmin/GuidesObservers/components/GuideTypeIcon.tsx create mode 100644 web2.0/src/pages/NgoAdmin/GuidesObservers/components/GuidesProvider.tsx create mode 100644 web2.0/src/pages/NgoAdmin/GuidesObservers/components/RowActions.tsx create mode 100644 web2.0/src/pages/NgoAdmin/GuidesObservers/components/UpdateGuideDialog.tsx create mode 100644 web2.0/src/pages/NgoAdmin/GuidesObservers/components/UploadGuideMenu.tsx create mode 100644 web2.0/src/services/api/guides-observers/create.api.ts create mode 100644 web2.0/src/services/api/guides-observers/delete.api.ts create mode 100644 web2.0/src/services/api/guides-observers/update.api.ts 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 index 5cb688650..68c6b997b 100644 --- a/web2.0/src/pages/NgoAdmin/GuidesObservers/Page.tsx +++ b/web2.0/src/pages/NgoAdmin/GuidesObservers/Page.tsx @@ -1,14 +1,29 @@ -import {Route} from "@/routes/(app)/elections/$electionRoundId/guides"; -import {useDebounce} from "@/hooks/use-debounce.ts"; -import {useListMonitoringObservers} from "@/queries/monitoring-observers.ts"; -import Table from "@/pages/NgoAdmin/GuidesObservers/components/Table.tsx"; +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' -export default function Page() { - const { electionRoundId } = Route.useParams(); - const search = Route.useSearch(); - const debouncedSearch = useDebounce(search, 200); - const { data } = useListMonitoringObservers(electionRoundId, debouncedSearch); +function Page() { + const { electionRound } = useCurrentElectionRound() + // Archived election rounds are frozen, so nothing new can be uploaded to them. + const isArchived = electionRound?.status === ElectionRoundStatus.Archived - // @ts-ignore - return
; -} \ No newline at end of file + 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 + +