From 0fe3fbe947c1d0d031bdaa93d9e1661fe55e5622 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 6 Sep 2024 09:07:50 +0300 Subject: [PATCH 01/21] fix: make dropdown menus scrollable --- web/src/components/ui/dropdown-menu.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/ui/dropdown-menu.tsx b/web/src/components/ui/dropdown-menu.tsx index 04c77e929..8743a25e0 100644 --- a/web/src/components/ui/dropdown-menu.tsx +++ b/web/src/components/ui/dropdown-menu.tsx @@ -60,7 +60,7 @@ const DropdownMenuContent = React.forwardRef< ref={ref} sideOffset={sideOffset} className={cn( - 'z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', + 'z-50 min-w-[8rem] overflow-y-auto max-h-[12rem] rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2', className )} {...props} From 4ca1a44ad5f2dedc56dc11cbc18bed953f35d047 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 6 Sep 2024 13:01:07 +0300 Subject: [PATCH 02/21] fix: truncate overflowing table columns --- web/src/components/ui/DataTable/DataTable.tsx | 19 ++++++++++--------- web/src/components/ui/table.tsx | 6 ++++-- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/web/src/components/ui/DataTable/DataTable.tsx b/web/src/components/ui/DataTable/DataTable.tsx index 8c2e4b953..b2efeb507 100644 --- a/web/src/components/ui/DataTable/DataTable.tsx +++ b/web/src/components/ui/DataTable/DataTable.tsx @@ -1,23 +1,23 @@ +import { SortOrder, type DataTableParameters, type PageResponse } from '@/common/types'; +import { EmptyCollectionIcon } from '@/components/icons/EmptyCollectionIcon'; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; +import type { UseQueryResult } from '@tanstack/react-query'; import { flexRender, getCoreRowModel, + getExpandedRowModel, getSortedRowModel, + useReactTable, + type CellContext, type ColumnDef, type PaginationState, + type Row, type SortingState, - useReactTable, type VisibilityState, - getExpandedRowModel, - type Row, - type CellContext, } from '@tanstack/react-table'; -import { EmptyCollectionIcon } from '@/components/icons/EmptyCollectionIcon'; -import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'; import { useEffect, useState, type ReactElement } from 'react'; -import { SortOrder, type DataTableParameters, type PageResponse } from '@/common/types'; -import { DataTablePagination } from './DataTablePagination'; -import type { UseQueryResult } from '@tanstack/react-query'; import { Skeleton } from '../skeleton'; +import { DataTablePagination } from './DataTablePagination'; export interface RowData { id: string; @@ -237,6 +237,7 @@ export function DataTable( style={{ cursor: onRowClick ? 'pointer' : undefined }}> {row.getVisibleCells().map((cell) => ( diff --git a/web/src/components/ui/table.tsx b/web/src/components/ui/table.tsx index d7832fa43..4490fc994 100644 --- a/web/src/components/ui/table.tsx +++ b/web/src/components/ui/table.tsx @@ -64,7 +64,9 @@ TableHead.displayName = 'TableHead'; const TableCell = React.forwardRef>( ({ className, ...props }, ref) => ( - + + {props.children} + ) ); TableCell.displayName = 'TableCell'; @@ -76,4 +78,4 @@ const TableCaption = React.forwardRef Date: Wed, 11 Sep 2024 19:57:12 +0300 Subject: [PATCH 03/21] Squashed commit of the following: commit 742f25001369978fc4b9d03a851c2f1ef72024a7 Author: imdeaconu Date: Wed Sep 11 19:54:55 2024 +0300 add read notification checkmark commit ea11fa0f637ad2e00cc7a8601c6c6f51fcb64a3f Author: imdeaconu Date: Wed Sep 11 19:54:30 2024 +0300 add read notification column --- .../PushMessageDetails/PushMessageDetails.tsx | 18 ++++-- .../components/PushMessages/PushMessages.tsx | 63 +++++-------------- 2 files changed, 28 insertions(+), 53 deletions(-) diff --git a/web/src/features/monitoring-observers/components/PushMessageDetails/PushMessageDetails.tsx b/web/src/features/monitoring-observers/components/PushMessageDetails/PushMessageDetails.tsx index 8872e6583..49489700a 100644 --- a/web/src/features/monitoring-observers/components/PushMessageDetails/PushMessageDetails.tsx +++ b/web/src/features/monitoring-observers/components/PushMessageDetails/PushMessageDetails.tsx @@ -7,12 +7,13 @@ import { DateTimeFormat } from '@/common/formats'; import type { FunctionComponent } from '@/common/types'; import { NavigateBack } from '@/components/NavigateBack/NavigateBack'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; -import { pushMessageDetailsQueryOptions ,Route} from '@/routes/monitoring-observers/push-messages.$id_.view'; +import { pushMessageDetailsQueryOptions, Route } from '@/routes/monitoring-observers/push-messages.$id_.view'; +import { CheckIcon } from '@heroicons/react/24/outline'; import { useSuspenseQuery } from '@tanstack/react-query'; export default function PushMessageDetails(): FunctionComponent { - const { id } = Route.useParams() - const currentElectionRoundId = useCurrentElectionRoundStore(s => s.currentElectionRoundId); + const { id } = Route.useParams(); + const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); const { data: pushMessage } = useSuspenseQuery(pushMessageDetailsQueryOptions(currentElectionRoundId, id)); return ( @@ -47,9 +48,14 @@ export default function PushMessageDetails(): FunctionComponent {

Total targeted observers {pushMessage?.receivers?.length ?? 0}

{pushMessage?.receivers?.map((receiver) => ( -

- {receiver.name} -

+
+

+ {receiver.name} +

+ {receiver.hasReadNotification && ( + + )} +
))}
diff --git a/web/src/features/monitoring-observers/components/PushMessages/PushMessages.tsx b/web/src/features/monitoring-observers/components/PushMessages/PushMessages.tsx index 4df312a30..0cc9372fc 100644 --- a/web/src/features/monitoring-observers/components/PushMessages/PushMessages.tsx +++ b/web/src/features/monitoring-observers/components/PushMessages/PushMessages.tsx @@ -3,19 +3,19 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader'; import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; import { Separator } from '@/components/ui/separator'; +import { ChevronRightIcon } from '@heroicons/react/24/outline'; import { Link, useNavigate } from '@tanstack/react-router'; import type { CellContext, ColumnDef } from '@tanstack/react-table'; import { Plus } from 'lucide-react'; -import { ChevronRightIcon } from '@heroicons/react/24/outline'; -import { usePushMessages } from '../../hooks/push-messages-queries'; -import { format } from 'date-fns'; -import type { PushMessageModel } from '../../models/push-message'; -import { useCallback } from 'react'; import { DateTimeFormat } from '@/common/formats'; -import type { TableCellProps } from '@/components/ui/DataTable/DataTable'; import type { FunctionComponent } from '@/common/types'; +import type { TableCellProps } from '@/components/ui/DataTable/DataTable'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; +import { format } from 'date-fns'; +import { useCallback } from 'react'; +import { usePushMessages } from '../../hooks/push-messages-queries'; +import type { PushMessageModel } from '../../models/push-message'; function PushMessages(): FunctionComponent { const pushMessagesColDefs: ColumnDef[] = [ @@ -24,67 +24,37 @@ function PushMessages(): FunctionComponent { accessorKey: 'sentAt', enableSorting: false, enableGlobalFilter: false, - cell: ({ row }) =>
{format(row.original.sentAt, DateTimeFormat)}
+ cell: ({ row }) =>
{format(row.original.sentAt, DateTimeFormat)}
, }, { header: ({ column }) => , accessorKey: 'sender', enableSorting: false, enableGlobalFilter: false, - cell: ({ - row: { - original: { sender }, - }, - }) => ( -

- {sender} -

- ), }, { header: ({ column }) => , accessorKey: 'numberOfTargetedObservers', enableSorting: false, enableGlobalFilter: false, - cell: ({ - row: { - original: { numberOfTargetedObservers }, - }, - }) => ( -

- {numberOfTargetedObservers} -

- ), + }, + { + header: ({ column }) => , + accessorKey: 'numberOfReadNotifications', + enableSorting: false, + enableGlobalFilter: false, }, { header: ({ column }) => , accessorKey: 'title', enableSorting: false, enableGlobalFilter: false, - cell: ({ - row: { - original: { title }, - }, - }) => ( -

- {title} -

- ), }, { header: ({ column }) => , accessorKey: 'body', enableSorting: false, enableGlobalFilter: false, - cell: ({ - row: { - original: { body }, - }, - }) => ( -

- {body} -

- ), }, { header: '', @@ -105,15 +75,14 @@ function PushMessages(): FunctionComponent { const getCellProps = (context: CellContext): TableCellProps | void => { if (context.column.id === 'body' || context.column.id === 'title') { - return { className: 'truncate hover:text-clip', - } + }; } - } + }; const navigate = useNavigate(); - const currentElectionRoundId = useCurrentElectionRoundStore(s => s.currentElectionRoundId); + const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); const navigateToPushMessage = useCallback( (id: string) => { From 0facf6510d14b3946c23807c299655248c810335 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 13 Sep 2024 13:38:18 +0300 Subject: [PATCH 04/21] Squashed commit of the following: commit d8833dcf5669c257a28ed0bd58f5085385f2b53f Author: imdeaconu Date: Fri Sep 13 13:29:31 2024 +0300 WIP: add selector functionality commit 3608c0e7d3d79a26037f8b7961d50f019b924406 Author: imdeaconu Date: Fri Sep 13 10:00:05 2024 +0300 WIP: create new tags input --- web/src/components/ui/tag-selector.tsx | 304 +++++++++---------------- 1 file changed, 107 insertions(+), 197 deletions(-) diff --git a/web/src/components/ui/tag-selector.tsx b/web/src/components/ui/tag-selector.tsx index c65e07302..33c3a9296 100644 --- a/web/src/components/ui/tag-selector.tsx +++ b/web/src/components/ui/tag-selector.tsx @@ -1,32 +1,13 @@ -import { cva, type VariantProps } from "class-variance-authority"; -import { - ChevronDown, - XIcon -} from "lucide-react"; -import * as React from "react"; +import { cn, getTagColor } from '@/lib/utils'; +import { Combobox, Popover } from '@headlessui/react'; +import { ChevronDown, Search, XIcon } from 'lucide-react'; +import { FC, useEffect, useRef, useState } from 'react'; +import { Badge } from './badge'; +import { Input } from './input'; +import { Separator } from './separator'; +import { CommandItem } from './command'; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { - Command, - CommandEmpty, - CommandGroup, - CommandInput, - CommandItem, - CommandList, - CommandSeparator, -} from "@/components/ui/command"; -import { - Popover, - PopoverContent, - PopoverTrigger, -} from "@/components/ui/popover"; -import { Separator } from "@/components/ui/separator"; -import { cn, getTagColor } from "@/lib/utils"; - - -interface TagsSelectFormFieldProps - extends React.ButtonHTMLAttributes { +interface TagsSelectFormFieldProps extends React.ButtonHTMLAttributes { asChild?: boolean; options: string[]; defaultValue?: string[]; @@ -36,79 +17,56 @@ interface TagsSelectFormFieldProps onValueChange: (value: string[]) => void; } -const TagsSelectFormField = React.forwardRef< - HTMLButtonElement, - TagsSelectFormFieldProps ->( - ( - { - className, - asChild = false, - options, - defaultValue, - onValueChange, - disabled, - placeholder, - ...props - }, - ref - ) => { - const [selectedValues, setSelectedValues] = React.useState( - defaultValue || [] - ); - const selectedValuesSet = React.useRef(new Set(selectedValues)); - const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); - const [search, setSearch] = React.useState('') +const TagsSelectFormField: FC = (props) => { + const { options, defaultValue, placeholder, onValueChange } = props; + const [selectedValues, setSelectedValues] = useState(defaultValue || []); + const [query, setQuery] = useState(''); + const searchRef = useRef(null); + const hasSelectedValues = selectedValues.length > 0; + + useEffect(() => { + const valuesSet = new Set(selectedValues); + onValueChange(Array.from(valuesSet)); + }, [selectedValues]); - React.useEffect(() => { - setSelectedValues(defaultValue || []); - selectedValuesSet.current = new Set(defaultValue); - }, [defaultValue]); + const handleInputKeyDown = (event: any) => { + if (event.key !== 'Enter') return; + setQuery(''); + }; + const toggleOption = (value: string) => { + const currentTag = selectedValues.find((t) => t.toLocaleLowerCase() === value.trim().toLocaleLowerCase()); - const handleInputKeyDown = (event: any) => { - if (event.key === "Enter") { - if(search){ - toggleOption(search) - } - } - }; + if (currentTag) setSelectedValues(selectedValues.filter((v) => v !== value.trim())); + else setSelectedValues([...selectedValues, value.trim()]); + }; - const toggleOption = (value: string) => { - const currentTag = selectedValues.find(t => t.toLocaleLowerCase() === value.trim().toLocaleLowerCase()); + const filteredOptions = + query === '' + ? options + : options.filter((option) => { + return option.toLowerCase().includes(query.toLowerCase()); + }); - if (currentTag) { - selectedValuesSet.current.delete(currentTag); - setSelectedValues(selectedValues.filter((v) => v !== value.trim())); - } else { - selectedValuesSet.current.add(value.trim()); - setSelectedValues([...selectedValues, value.trim()]); - } - - onValueChange(Array.from(selectedValuesSet.current)); - }; + const comboboxClasses = cn( + "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[focus]:bg-accent data-[focus]:text-accent-foreground data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50 cursor-pointer" + ); - return ( - - - - - setIsPopoverOpen(false)} - > - - - - {/* Press enter to create this tag. */} - - {options.map((option) => { - return ( - toggleOption(option)} - style={{ - pointerEvents: "auto", - opacity: 1, + +
+ {hasSelectedValues && ( + <> + { + setSelectedValues([]); + onValueChange([]); + event.stopPropagation(); }} - className="cursor-pointer" - > - {option} - - ); - })} - - - -
- {selectedValues.length > 0 && ( - <> - { - setSelectedValues([]); - selectedValuesSet.current.clear(); - onValueChange([]); - }} - style={{ - pointerEvents: "auto", - opacity: 1, - }} - className="flex-1 justify-center cursor-pointer" - > - Clear - - - - )} - - setIsPopoverOpen(false)} - style={{ - pointerEvents: "auto", - opacity: 1, - }} - className="flex-1 justify-center cursor-pointer" - > - Close - -
-
- - - - - ); - } -); + /> + + + )} + + + +
+ + + +
+ + + setQuery(event.target.value)} + onKeyDown={handleInputKeyDown} + /> +
+ + + {query.length > 0 && ( + + Create "{query}" + + )} -TagsSelectFormField.displayName = "TagsSelectFormField"; + {filteredOptions.map((option) => ( + + {option} + + ))} + + +
+ + )} +
+ + ); +}; export default TagsSelectFormField; From 67f681d128e767e5d9cd5e2d43a7db7fa1cff59e Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 13 Sep 2024 14:00:35 +0300 Subject: [PATCH 05/21] chore: remove unused import --- web/src/components/ui/tag-selector.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/web/src/components/ui/tag-selector.tsx b/web/src/components/ui/tag-selector.tsx index 33c3a9296..aff394da6 100644 --- a/web/src/components/ui/tag-selector.tsx +++ b/web/src/components/ui/tag-selector.tsx @@ -5,7 +5,6 @@ import { FC, useEffect, useRef, useState } from 'react'; import { Badge } from './badge'; import { Input } from './input'; import { Separator } from './separator'; -import { CommandItem } from './command'; interface TagsSelectFormFieldProps extends React.ButtonHTMLAttributes { asChild?: boolean; @@ -125,7 +124,6 @@ const TagsSelectFormField: FC = (props) => { ))} - )} From 8d73252c776795f71dea15ba1382c3a67da30223 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Mon, 16 Sep 2024 18:55:29 +0300 Subject: [PATCH 06/21] chore: delete duplicated / unused classes --- web/src/components/ui/tag-selector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/ui/tag-selector.tsx b/web/src/components/ui/tag-selector.tsx index aff394da6..37e6c48ca 100644 --- a/web/src/components/ui/tag-selector.tsx +++ b/web/src/components/ui/tag-selector.tsx @@ -97,7 +97,7 @@ const TagsSelectFormField: FC = (props) => { - +
From abb7c018c0e6b02f56e9584b81bbe49b34e18540 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Thu, 19 Sep 2024 09:13:49 +0300 Subject: [PATCH 07/21] feature: add searching to MonitoringObserversTagFilter --- .../MonitoringObserverTagsSelect.tsx | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx b/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx index 730de3494..e48a838f6 100644 --- a/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx +++ b/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx @@ -4,17 +4,29 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; +import { Input } from '@/components/ui/input'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; import { FILTER_KEY } from '@/features/filtering/filtering-enums'; import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; import { useMonitoringObserversTags } from '@/hooks/tags-queries'; -import { FC } from 'react'; +import { FC, useState } from 'react'; export const MonitoringObserverTagsSelect: FC = () => { const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); const { data: tags } = useMonitoringObserversTags(currentElectionRoundId); const { queryParams, navigateHandler } = useFilteringContainer(); const currentTags = (queryParams as any)?.[FILTER_KEY.MonitoringObserverTags] ?? []; + const currentTagsSet = new Set(currentTags); + const [query, setQuery] = useState(''); + + const filteredTags = + query === '' + ? tags?.filter((tag) => !currentTagsSet.has(tag)) + : tags + ?.filter((tag) => !currentTagsSet.has(tag)) + .filter((option) => { + return option.toLowerCase().includes(query.toLowerCase()); + }); const toggleTagsFilter = (tag: string) => { if (!currentTags.includes(tag)) return navigateHandler({ tags: [...currentTags, tag] }); @@ -25,14 +37,20 @@ export const MonitoringObserverTagsSelect: FC = () => { }; return ( - + setQuery('')}>
Observer tags
- {tags?.map((tag) => ( + setQuery(e.target.value)} + onKeyDown={(e: React.KeyboardEvent) => e.stopPropagation()} + /> + {filteredTags?.map((tag) => ( toggleTagsFilter(tag)} From c9fcd3e78a389bef6fbbe0ea5a7dcb845aab772f Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 20 Sep 2024 12:09:48 +0300 Subject: [PATCH 08/21] chore: update config files --- .env.example | 16 ++++++++++++++-- .../Clients/NgoAdmin/INgoAdminApi.cs | 2 +- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/.env.example b/.env.example index 98033c702..086def63d 100644 --- a/.env.example +++ b/.env.example @@ -5,5 +5,17 @@ AuthFeatureConfig__JWTConfig__TokenSigningKey=SecretKeyOfDoomThatMustBeAMinimumN Domain__DbConnectionConfig__Server=postgresql-local Domain__DbConnectionConfig__Port=5432 Domain__DbConnectionConfig__Database=vote-monitor -Domain__DbConnectionConfig__UserId=${POSTGRES_USER} -Domain__DbConnectionConfig__Password=${POSTGRES_PASSWORD} \ No newline at end of file +Domain__DbConnectionConfig__UserId=postgres +Domain__DbConnectionConfig__Password=docker +Seeders__PlatformAdminSeeder__FirstName=John +Seeders__PlatformAdminSeeder__LastName=Doe +Seeders__PlatformAdminSeeder__Email=john.doe@example.com +Seeders__PlatformAdminSeeder__PhoneNumber=1234567890 +Seeders__PlatformAdminSeeder__Password=password123 +DashboardAuth__Username=admin +DashboardAuth__Password=admin +Core__HangfireConnectionConfig__Server=postgresql-local +Core__HangfireConnectionConfig__Port=5432 +Core__HangfireConnectionConfig__Database=vote-monitor +Core__HangfireConnectionConfig__UserId=postgres +Core__HangfireConnectionConfig__Password=docker \ No newline at end of file diff --git a/utils/SubmissionsFaker/Clients/NgoAdmin/INgoAdminApi.cs b/utils/SubmissionsFaker/Clients/NgoAdmin/INgoAdminApi.cs index 55de3ed3f..cb80b9ee6 100644 --- a/utils/SubmissionsFaker/Clients/NgoAdmin/INgoAdminApi.cs +++ b/utils/SubmissionsFaker/Clients/NgoAdmin/INgoAdminApi.cs @@ -16,7 +16,7 @@ Task UpdateForm([AliasAs("electionRoundId")] string electionRoundId, [Body] UpdateForm form, [Authorize] string token); - [Put("/api/election-rounds/{electionRoundId}/forms/{id}:publish")] + [Post("/api/election-rounds/{electionRoundId}/forms/{id}:publish")] Task PublishForm([AliasAs("electionRoundId")] string electionRoundId, [AliasAs("id")] string id, [Authorize] string token); From 333ba49ba89ab2ef8bbabec71c1fc0af3c0ebecd Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Mon, 23 Sep 2024 09:25:55 +0300 Subject: [PATCH 09/21] Revert "[NGO Admin] Rewrite the tag selector component (#675)" This reverts commit 2ad0e909be5117b4d5deb369015c428321c23dea. --- web/src/components/ui/tag-selector.tsx | 302 ++++++++++++++++--------- 1 file changed, 197 insertions(+), 105 deletions(-) diff --git a/web/src/components/ui/tag-selector.tsx b/web/src/components/ui/tag-selector.tsx index 37e6c48ca..c65e07302 100644 --- a/web/src/components/ui/tag-selector.tsx +++ b/web/src/components/ui/tag-selector.tsx @@ -1,12 +1,32 @@ -import { cn, getTagColor } from '@/lib/utils'; -import { Combobox, Popover } from '@headlessui/react'; -import { ChevronDown, Search, XIcon } from 'lucide-react'; -import { FC, useEffect, useRef, useState } from 'react'; -import { Badge } from './badge'; -import { Input } from './input'; -import { Separator } from './separator'; +import { cva, type VariantProps } from "class-variance-authority"; +import { + ChevronDown, + XIcon +} from "lucide-react"; +import * as React from "react"; -interface TagsSelectFormFieldProps extends React.ButtonHTMLAttributes { +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { Separator } from "@/components/ui/separator"; +import { cn, getTagColor } from "@/lib/utils"; + + +interface TagsSelectFormFieldProps + extends React.ButtonHTMLAttributes { asChild?: boolean; options: string[]; defaultValue?: string[]; @@ -16,56 +36,79 @@ interface TagsSelectFormFieldProps extends React.ButtonHTMLAttributes void; } -const TagsSelectFormField: FC = (props) => { - const { options, defaultValue, placeholder, onValueChange } = props; - const [selectedValues, setSelectedValues] = useState(defaultValue || []); - const [query, setQuery] = useState(''); - const searchRef = useRef(null); - const hasSelectedValues = selectedValues.length > 0; - - useEffect(() => { - const valuesSet = new Set(selectedValues); - onValueChange(Array.from(valuesSet)); - }, [selectedValues]); +const TagsSelectFormField = React.forwardRef< + HTMLButtonElement, + TagsSelectFormFieldProps +>( + ( + { + className, + asChild = false, + options, + defaultValue, + onValueChange, + disabled, + placeholder, + ...props + }, + ref + ) => { + const [selectedValues, setSelectedValues] = React.useState( + defaultValue || [] + ); + const selectedValuesSet = React.useRef(new Set(selectedValues)); + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + const [search, setSearch] = React.useState('') - const handleInputKeyDown = (event: any) => { - if (event.key !== 'Enter') return; - setQuery(''); - }; - const toggleOption = (value: string) => { - const currentTag = selectedValues.find((t) => t.toLocaleLowerCase() === value.trim().toLocaleLowerCase()); + React.useEffect(() => { + setSelectedValues(defaultValue || []); + selectedValuesSet.current = new Set(defaultValue); + }, [defaultValue]); - if (currentTag) setSelectedValues(selectedValues.filter((v) => v !== value.trim())); - else setSelectedValues([...selectedValues, value.trim()]); - }; + const handleInputKeyDown = (event: any) => { + if (event.key === "Enter") { + if(search){ + toggleOption(search) + } + } + }; - const filteredOptions = - query === '' - ? options - : options.filter((option) => { - return option.toLowerCase().includes(query.toLowerCase()); - }); + const toggleOption = (value: string) => { + const currentTag = selectedValues.find(t => t.toLocaleLowerCase() === value.trim().toLocaleLowerCase()); - const comboboxClasses = cn( - "relative flex cursor-default select-none items-center rounded-sm px-2 py-1.5 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[focus]:bg-accent data-[focus]:text-accent-foreground data-[disabled='true']:pointer-events-none data-[disabled='true']:opacity-50 cursor-pointer" - ); + if (currentTag) { + selectedValuesSet.current.delete(currentTag); + setSelectedValues(selectedValues.filter((v) => v !== value.trim())); + } else { + selectedValuesSet.current.add(value.trim()); + setSelectedValues([...selectedValues, value.trim()]); + } + + onValueChange(Array.from(selectedValuesSet.current)); + }; - return ( - setSelectedValues(value)} multiple> - - {({ open }) => ( - <> - -
- {!hasSelectedValues ? ( - {placeholder} - ) : ( - selectedValues.map((value) => { + return ( + + + + + setIsPopoverOpen(false)} + > + + + + {/* Press enter to create this tag. */} + + {options.map((option) => { + return ( + toggleOption(option)} + style={{ + pointerEvents: "auto", + opacity: 1, + }} + className="cursor-pointer" + > + {option} + + ); + })} + + + +
+ {selectedValues.length > 0 && ( + <> + { + setSelectedValues([]); + selectedValuesSet.current.clear(); + onValueChange([]); + }} + style={{ + pointerEvents: "auto", + opacity: 1, + }} + className="flex-1 justify-center cursor-pointer" + > + Clear + + + + )} + + setIsPopoverOpen(false)} + style={{ + pointerEvents: "auto", + opacity: 1, + }} + className="flex-1 justify-center cursor-pointer" + > + Close + +
+
+
+
+
- - ); -}; + ); + } +); + +TagsSelectFormField.displayName = "TagsSelectFormField"; export default TagsSelectFormField; From eea4faaa8848da6f9407ba53e99352f819222877 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Thu, 26 Sep 2024 15:27:30 +0300 Subject: [PATCH 10/21] Merge branch 'main' of https://github.com/commitglobal/votemonitor into commitglobal-main --- .../Feature.Citizen.Guides/Delete/Endpoint.cs | 1 - api/src/Feature.Forms/Update/Endpoint.cs | 6 + .../FetchLevels/Endpoint.cs | 13 - .../List/Request.cs | 3 + .../ListPollingStationsSpecification.cs | 1 + .../Vote.Monitor.Api/Vote.Monitor.Api.csproj | 1 + .../Entities/FormBase/AnswersHelpers.cs | 24 -- .../Entities/FormBase/BaseForm.cs | 53 ++- .../CitizenGuideConfiguration.cs | 1 - .../Endpoints/CreateEndpointTests.cs | 14 +- .../Validators/CreateValidatorTests.cs | 1 - .../Endpoints/UpsertEndpointTests.cs | 2 + .../Fakes/Aggregates/FormAggregateFaker.cs | 2 +- terraform/locals.tf | 4 +- web/src/common/types.ts | 38 +- .../PollingStationsFilters.tsx | 78 ++-- .../translate/TranslateQuestionFactory.tsx | 55 +-- web/src/components/ui/DataTable/DataTable.tsx | 3 + web/src/components/ui/date-picker.tsx | 52 +++ .../ui/multiple-select-dropdown.tsx | 231 +++++++++++ web/src/components/ui/tag-selector.tsx | 30 +- .../components/Dashboard/Dashboard.tsx | 8 +- .../filtering/components/ActiveFilters.tsx | 3 +- .../filtering/components/SelectFilter.tsx | 11 + web/src/features/filtering/filtering-enums.ts | 3 + .../forms/components/Dashboard/Dashboard.tsx | 6 +- .../MonitoringObserverTagsSelect.tsx | 52 +-- .../components/Dashboard/Dashboard.tsx | 16 +- .../FormSubmissionsByEntryTable.tsx | 1 + .../FormSubmissionsByEntryTable.tsx | 19 +- .../FormSubmissionsTab/FormSubmissionsTab.tsx | 8 +- .../FormsFiltersByEntry.tsx | 84 +++- .../QuickReportsTab/QuickReportsTab.tsx | 1 + .../responses/models/search-params.ts | 37 +- .../features/responses/utils/column-defs.tsx | 16 + .../utils/column-visibility-options.tsx | 9 +- web/src/features/responses/utils/helpers.ts | 35 +- web/src/hooks/locations-levels.ts | 2 +- web/src/lib/utils.ts | 390 ++++++++++-------- web/src/locales/en.json | 1 + 40 files changed, 872 insertions(+), 443 deletions(-) create mode 100644 web/src/components/ui/date-picker.tsx create mode 100644 web/src/components/ui/multiple-select-dropdown.tsx diff --git a/api/src/Feature.Citizen.Guides/Delete/Endpoint.cs b/api/src/Feature.Citizen.Guides/Delete/Endpoint.cs index 9e63dd823..c80b712e4 100644 --- a/api/src/Feature.Citizen.Guides/Delete/Endpoint.cs +++ b/api/src/Feature.Citizen.Guides/Delete/Endpoint.cs @@ -1,5 +1,4 @@ using Authorization.Policies.Requirements; -using Feature.Citizen.Guides.Specifications; using Microsoft.AspNetCore.Authorization; using Microsoft.EntityFrameworkCore; using Vote.Monitor.Domain; diff --git a/api/src/Feature.Forms/Update/Endpoint.cs b/api/src/Feature.Forms/Update/Endpoint.cs index 22f578243..86faacb38 100644 --- a/api/src/Feature.Forms/Update/Endpoint.cs +++ b/api/src/Feature.Forms/Update/Endpoint.cs @@ -2,6 +2,7 @@ using Authorization.Policies.Requirements; using Feature.Forms.Specifications; using Microsoft.AspNetCore.Authorization; +using Vote.Monitor.Domain.Entities.FormAggregate; using Vote.Monitor.Domain.Entities.MonitoringNgoAggregate; using Vote.Monitor.Form.Module.Mappers; @@ -37,6 +38,11 @@ public override async Task> ExecuteAsync(Request re return TypedResults.NotFound(); } + if (form.Status == FormStatus.Published) + { + ThrowError(x=>x.Id, "Cannot edit published form"); + } + var questions = req.Questions .Select(QuestionsMapper.ToEntity) .ToList() diff --git a/api/src/Vote.Monitor.Api.Feature.PollingStation/FetchLevels/Endpoint.cs b/api/src/Vote.Monitor.Api.Feature.PollingStation/FetchLevels/Endpoint.cs index 9940bca56..e989e1b2e 100644 --- a/api/src/Vote.Monitor.Api.Feature.PollingStation/FetchLevels/Endpoint.cs +++ b/api/src/Vote.Monitor.Api.Feature.PollingStation/FetchLevels/Endpoint.cs @@ -42,7 +42,6 @@ public override async Task, NotFound>> ExecuteAsync(Request x.Level3, x.Level4, x.Level5, - x.Number }) .Distinct() .ToListAsync(cancellationToken: ct); @@ -108,18 +107,6 @@ public override async Task, NotFound>> ExecuteAsync(Request Depth = 5 }); } - - if (!string.IsNullOrWhiteSpace(ps.Number)) - { - var numberLevelKey = BuildKey(ps.Level1, ps.Level2, ps.Level3, ps.Level4, ps.Level5, ps.Number); - parentNode = cache.GetOrCreate(numberLevelKey, () => new LevelNode - { - Id = ++id, - Name = ps.Number, - ParentId = parentNode.Id, - Depth = 6 - }); - } } return new Response diff --git a/api/src/Vote.Monitor.Api.Feature.PollingStation/List/Request.cs b/api/src/Vote.Monitor.Api.Feature.PollingStation/List/Request.cs index 4684cb544..4973eacf6 100644 --- a/api/src/Vote.Monitor.Api.Feature.PollingStation/List/Request.cs +++ b/api/src/Vote.Monitor.Api.Feature.PollingStation/List/Request.cs @@ -22,4 +22,7 @@ public class Request : BaseSortPaginatedRequest [QueryParam] public string? Level5Filter { get; set; } + + [QueryParam] + public string? PollingStationNumberFilter { get; set; } } diff --git a/api/src/Vote.Monitor.Api.Feature.PollingStation/Specifications/ListPollingStationsSpecification.cs b/api/src/Vote.Monitor.Api.Feature.PollingStation/Specifications/ListPollingStationsSpecification.cs index 6e5f3578c..db9820332 100644 --- a/api/src/Vote.Monitor.Api.Feature.PollingStation/Specifications/ListPollingStationsSpecification.cs +++ b/api/src/Vote.Monitor.Api.Feature.PollingStation/Specifications/ListPollingStationsSpecification.cs @@ -14,6 +14,7 @@ public ListPollingStationsSpecification(List.Request request) .Where(x => x.Level3 == request.Level3Filter, !string.IsNullOrWhiteSpace(request.Level3Filter)) .Where(x => x.Level4 == request.Level4Filter, !string.IsNullOrWhiteSpace(request.Level4Filter)) .Where(x => x.Level5 == request.Level5Filter, !string.IsNullOrWhiteSpace(request.Level5Filter)) + .Where(x => x.Number == request.PollingStationNumberFilter, !string.IsNullOrWhiteSpace(request.PollingStationNumberFilter)) .ApplyOrdering(request) .Paginate(request) .AsNoTracking(); diff --git a/api/src/Vote.Monitor.Api/Vote.Monitor.Api.csproj b/api/src/Vote.Monitor.Api/Vote.Monitor.Api.csproj index 16d5bac90..cf396368b 100644 --- a/api/src/Vote.Monitor.Api/Vote.Monitor.Api.csproj +++ b/api/src/Vote.Monitor.Api/Vote.Monitor.Api.csproj @@ -39,6 +39,7 @@ + diff --git a/api/src/Vote.Monitor.Domain/Entities/FormBase/AnswersHelpers.cs b/api/src/Vote.Monitor.Domain/Entities/FormBase/AnswersHelpers.cs index c7c568797..496321713 100644 --- a/api/src/Vote.Monitor.Domain/Entities/FormBase/AnswersHelpers.cs +++ b/api/src/Vote.Monitor.Domain/Entities/FormBase/AnswersHelpers.cs @@ -1,4 +1,3 @@ -using Vote.Monitor.Core.Models; using Vote.Monitor.Domain.Entities.FormAnswerBase.Answers; using Vote.Monitor.Domain.Entities.FormBase.Questions; @@ -6,29 +5,6 @@ namespace Vote.Monitor.Domain.Entities.FormBase; public class AnswersHelpers { - public static LanguagesTranslationStatus ComputeLanguagesTranslationStatus(IEnumerable questions, - string defaultLanguage, IEnumerable languages) - { - var questionsArray = questions.ToArray(); - var languagesArray = languages.ToArray(); - - var languagesTranslationStatus = new LanguagesTranslationStatus(); - - foreach (var languageCode in languagesArray) - { - var status = - questionsArray.Any(x => - x.GetTranslationStatus(defaultLanguage, languageCode) == TranslationStatus.MissingTranslations) - ? TranslationStatus.MissingTranslations - : TranslationStatus.Translated; - - languagesTranslationStatus.AddOrUpdateTranslationStatus(languageCode, status); - } - - return languagesTranslationStatus; - } - - public static int CountNumberOfFlaggedAnswers(IEnumerable questions, IEnumerable answers) { var questionsArray = questions.ToArray(); diff --git a/api/src/Vote.Monitor.Domain/Entities/FormBase/BaseForm.cs b/api/src/Vote.Monitor.Domain/Entities/FormBase/BaseForm.cs index 9a8638464..98ba9fb48 100644 --- a/api/src/Vote.Monitor.Domain/Entities/FormBase/BaseForm.cs +++ b/api/src/Vote.Monitor.Domain/Entities/FormBase/BaseForm.cs @@ -63,8 +63,7 @@ protected BaseForm( Status = FormStatus.Drafted; Questions = questions.ToList().AsReadOnly(); NumberOfQuestions = Questions.Count; - LanguagesTranslationStatus = - AnswersHelpers.ComputeLanguagesTranslationStatus(Questions, defaultLanguage, Languages); + LanguagesTranslationStatus = ComputeLanguagesTranslationStatus(); } [JsonConstructor] @@ -133,8 +132,7 @@ public void UpdateDetails(string code, Languages = languages.ToArray(); Questions = questions.ToList().AsReadOnly(); NumberOfQuestions = Questions.Count; - LanguagesTranslationStatus = - AnswersHelpers.ComputeLanguagesTranslationStatus(Questions, defaultLanguage, Languages); + LanguagesTranslationStatus = ComputeLanguagesTranslationStatus(); } private T BaseFillIn(T submission, List answers, Action clearAnswers, @@ -214,8 +212,7 @@ public void AddTranslations(string[] languageCodes) } } - LanguagesTranslationStatus = - AnswersHelpers.ComputeLanguagesTranslationStatus(Questions, DefaultLanguage, Languages); + LanguagesTranslationStatus = ComputeLanguagesTranslationStatus(); } public bool HasTranslation(string languageCode) @@ -232,8 +229,7 @@ public void SetDefaultLanguage(string languageCode) DefaultLanguage = languageCode; - LanguagesTranslationStatus = - AnswersHelpers.ComputeLanguagesTranslationStatus(Questions, DefaultLanguage, Languages); + LanguagesTranslationStatus = ComputeLanguagesTranslationStatus(); } public void RemoveTranslation(string languageCode) @@ -260,7 +256,46 @@ public void RemoveTranslation(string languageCode) } LanguagesTranslationStatus = - AnswersHelpers.ComputeLanguagesTranslationStatus(Questions, DefaultLanguage, Languages); + ComputeLanguagesTranslationStatus(); + } + + + private LanguagesTranslationStatus ComputeLanguagesTranslationStatus() + { + var languagesTranslationStatus = new LanguagesTranslationStatus(); + + foreach (var languageCode in Languages) + { + if (Name != null && (!Name.ContainsKey(languageCode) || string.IsNullOrWhiteSpace(Name[languageCode]))) + { + languagesTranslationStatus.AddOrUpdateTranslationStatus(languageCode, + TranslationStatus.MissingTranslations); + continue; + } + + if (Description != null) + { + if (Description.ContainsKey(DefaultLanguage) && + !string.IsNullOrWhiteSpace(Description[DefaultLanguage]) && + (!Description.ContainsKey(languageCode) || + string.IsNullOrWhiteSpace(Description[languageCode]))) + { + languagesTranslationStatus.AddOrUpdateTranslationStatus(languageCode, + TranslationStatus.MissingTranslations); + continue; + } + } + + var status = + Questions.Any(x => + x.GetTranslationStatus(DefaultLanguage, languageCode) == TranslationStatus.MissingTranslations) + ? TranslationStatus.MissingTranslations + : TranslationStatus.Translated; + + languagesTranslationStatus.AddOrUpdateTranslationStatus(languageCode, status); + } + + return languagesTranslationStatus; } protected BaseForm() diff --git a/api/src/Vote.Monitor.Domain/EntitiesConfiguration/CitizenGuideConfiguration.cs b/api/src/Vote.Monitor.Domain/EntitiesConfiguration/CitizenGuideConfiguration.cs index d77452656..da4a08ec0 100644 --- a/api/src/Vote.Monitor.Domain/EntitiesConfiguration/CitizenGuideConfiguration.cs +++ b/api/src/Vote.Monitor.Domain/EntitiesConfiguration/CitizenGuideConfiguration.cs @@ -1,6 +1,5 @@ using Microsoft.EntityFrameworkCore.Metadata.Builders; using Vote.Monitor.Domain.Entities.CitizenGuideAggregate; -using Vote.Monitor.Domain.Entities.ObserverGuideAggregate; namespace Vote.Monitor.Domain.EntitiesConfiguration; diff --git a/api/tests/Feature.Forms.UnitTests/Endpoints/CreateEndpointTests.cs b/api/tests/Feature.Forms.UnitTests/Endpoints/CreateEndpointTests.cs index b171c8523..a35e7e916 100644 --- a/api/tests/Feature.Forms.UnitTests/Endpoints/CreateEndpointTests.cs +++ b/api/tests/Feature.Forms.UnitTests/Endpoints/CreateEndpointTests.cs @@ -49,7 +49,7 @@ public async Task ShouldReturnNotFound_WhenUserIsNotAuthorized() public async Task ShouldUpdateFormVersion_WhenValidRequest() { // Arrange - var form = new TranslatedString { [LanguagesList.RO.Iso1] = "UniqueName" }; + var formName = new TranslatedString { [LanguagesList.RO.Iso1] = "UniqueName" }; _monitoringNgoRepository .FirstOrDefaultAsync(Arg.Any()) @@ -59,8 +59,9 @@ public async Task ShouldUpdateFormVersion_WhenValidRequest() var request = new Create.Request { NgoId = _monitoringNgo.NgoId, - Name = form, + Name = formName, Code = "a code", + DefaultLanguage = LanguagesList.RO.Iso1, Languages = [LanguagesList.RO.Iso1] }; @@ -77,7 +78,7 @@ await _monitoringNgoRepository public async Task ShouldReturnOkWithFormModel_WhenNoConflict() { // Arrange - var form = new TranslatedString { [LanguagesList.RO.Iso1] = "UniqueName" }; + var formName = new TranslatedString { [LanguagesList.RO.Iso1] = "UniqueName" }; _monitoringNgoRepository .FirstOrDefaultAsync(Arg.Any()) .Returns(_monitoringNgo); @@ -86,8 +87,9 @@ public async Task ShouldReturnOkWithFormModel_WhenNoConflict() var request = new Create.Request { NgoId = _monitoringNgo.NgoId, - Name = form, + Name = formName, Code = "a code", + DefaultLanguage = LanguagesList.RO.Iso1, Languages = [LanguagesList.RO.Iso1] }; var result = await _endpoint.ExecuteAsync(request, default); @@ -95,12 +97,12 @@ public async Task ShouldReturnOkWithFormModel_WhenNoConflict() // Assert await _repository .Received(1) - .AddAsync(Arg.Is
(x => x.Name == form)); + .AddAsync(Arg.Is(x => x.Name == formName)); result .Should().BeOfType, NotFound>>()! .Which! .Result.Should().BeOfType>()! - .Which!.Value!.Name.Should().BeEquivalentTo(form); + .Which!.Value!.Name.Should().BeEquivalentTo(formName); } } diff --git a/api/tests/Feature.ObserverGuide.UnitTests/Validators/CreateValidatorTests.cs b/api/tests/Feature.ObserverGuide.UnitTests/Validators/CreateValidatorTests.cs index dc5c1bcd8..637743288 100644 --- a/api/tests/Feature.ObserverGuide.UnitTests/Validators/CreateValidatorTests.cs +++ b/api/tests/Feature.ObserverGuide.UnitTests/Validators/CreateValidatorTests.cs @@ -1,5 +1,4 @@ using FluentValidation.TestHelper; -using Vote.Monitor.Domain.Entities.CitizenGuideAggregate; using Vote.Monitor.Domain.Entities.ObserverGuideAggregate; using Vote.Monitor.TestUtils.Fakes; diff --git a/api/tests/Feature.PollingStation.Information.Form.UnitTests/Endpoints/UpsertEndpointTests.cs b/api/tests/Feature.PollingStation.Information.Form.UnitTests/Endpoints/UpsertEndpointTests.cs index 1d58e7faf..08e929317 100644 --- a/api/tests/Feature.PollingStation.Information.Form.UnitTests/Endpoints/UpsertEndpointTests.cs +++ b/api/tests/Feature.PollingStation.Information.Form.UnitTests/Endpoints/UpsertEndpointTests.cs @@ -43,6 +43,7 @@ public async Task ShouldUpdatePollingStationInformationForm_WhenPollingStationIn var request = new Upsert.Request { ElectionRoundId = Guid.NewGuid(), + DefaultLanguage = LanguagesList.RO.Iso1, Languages = languages, Questions = [ new NumberQuestionRequest @@ -111,6 +112,7 @@ public async Task ShouldCreatePollingStationInformation_WhenPollingStationInform { ElectionRoundId = electionRoundId, Languages = languages, + DefaultLanguage = LanguagesList.RO.Iso1, Questions = [ new NumberQuestionRequest { diff --git a/api/tests/Vote.Monitor.TestUtils/Fakes/Aggregates/FormAggregateFaker.cs b/api/tests/Vote.Monitor.TestUtils/Fakes/Aggregates/FormAggregateFaker.cs index 26aef326b..c9a5d50f8 100644 --- a/api/tests/Vote.Monitor.TestUtils/Fakes/Aggregates/FormAggregateFaker.cs +++ b/api/tests/Vote.Monitor.TestUtils/Fakes/Aggregates/FormAggregateFaker.cs @@ -55,7 +55,7 @@ public FormAggregateFaker(ElectionRoundAggregate? electionRound = null, CustomInstantiator(_ => { - var form = Form.Create(electionRound, monitoringNgo, FormType.ClosingAndCounting, "C1", new TranslatedString(), new TranslatedString(), + var form = Form.Create(electionRound, monitoringNgo, FormType.ClosingAndCounting, "C1", translatedStringFaker.Generate(), translatedStringFaker.Generate(), languages.First(), languages, questions); if (status == FormStatus.Obsolete) diff --git a/terraform/locals.tf b/terraform/locals.tf index 4648609d3..7c0a71b12 100644 --- a/terraform/locals.tf +++ b/terraform/locals.tf @@ -5,12 +5,12 @@ locals { images = { api = { image = "commitglobal/votemonitor" - tag = "0.2.20" + tag = "0.2.21" } hangfire = { image = "commitglobal/votemonitor-hangfire" - tag = "0.2.20" + tag = "0.2.21" } } diff --git a/web/src/common/types.ts b/web/src/common/types.ts index c939874d9..ce5e73c3d 100644 --- a/web/src/common/types.ts +++ b/web/src/common/types.ts @@ -49,15 +49,17 @@ export enum QuestionType { RatingQuestionType = 'ratingQuestion', } -export const ZDisplayLogicCondition = z.enum(["Equals", - "NotEquals", - "LessThan", - "LessEqual", - "GreaterThan", - "GreaterEqual", - "Includes"]); - -export type DisplayLogicCondition = z.infer +export const ZDisplayLogicCondition = z.enum([ + 'Equals', + 'NotEquals', + 'LessThan', + 'LessEqual', + 'GreaterThan', + 'GreaterEqual', + 'Includes', +]); + +export type DisplayLogicCondition = z.infer; export interface DisplayLogic { parentQuestionId: string; @@ -150,7 +152,7 @@ export type NumberAnswer = z.infer; export const DateAnswerSchema = BaseAnswerSchema.extend({ $answerType: z.literal(AnswerType.DateAnswerType), - date: z.string().datetime({ offset: true } ).optional(), + date: z.string().datetime({ offset: true }).optional(), }); export type DateAnswer = z.infer; @@ -205,17 +207,17 @@ export enum FollowUpStatus { NeedsFollowUp = 'NeedsFollowUp', Resolved = 'Resolved', } + +export enum QuestionsAnswered { + None = 'None', + Some = 'Some', + All = 'All', +} export type HistogramData = { [bucket: string]: number; }; - -export const ZFormType = z.enum(["PSI", - "Opening", - "Voting", - "ClosingAndCounting", - "CitizenReporting", - "Other"]); +export const ZFormType = z.enum(['PSI', 'Opening', 'Voting', 'ClosingAndCounting', 'CitizenReporting', 'Other']); export type FormType = z.infer; @@ -223,4 +225,4 @@ export const ZTranslationStatus = z.enum(['Translated', 'MissingTranslations']); export type TranslationStatus = z.infer; const ZLanguagesTranslationStatus = z.record(z.string(), ZTranslationStatus); -export type LanguagesTranslationStatus = z.infer; \ No newline at end of file +export type LanguagesTranslationStatus = z.infer; diff --git a/web/src/components/PollingStationsFilters/PollingStationsFilters.tsx b/web/src/components/PollingStationsFilters/PollingStationsFilters.tsx index f1a63d421..d81406696 100644 --- a/web/src/components/PollingStationsFilters/PollingStationsFilters.tsx +++ b/web/src/components/PollingStationsFilters/PollingStationsFilters.tsx @@ -5,6 +5,7 @@ import { useCurrentElectionRoundStore } from '@/context/election-round.store'; import { usePollingStationsLocationLevels } from '@/hooks/polling-stations-levels'; import { useNavigate, useSearch } from '@tanstack/react-router'; import { useCallback, useMemo } from 'react'; +import { Input } from '../ui/input'; export function PollingStationsFilters(): FunctionComponent { const navigate = useNavigate(); @@ -61,30 +62,20 @@ export function PollingStationsFilters(): FunctionComponent { [data, selectedLevel4Node?.id] ); - const filteredPollingStationNumbers = useMemo(() => { - const parentId = - selectedLevel5Node?.id ?? - selectedLevel4Node?.id ?? - selectedLevel3Node?.id ?? - selectedLevel2Node?.id ?? - selectedLevel1Node?.id; - - return data?.[6]?.filter((n) => !!n.name && n.parentId === parentId).sort((a, b) => { - const numA = Number(a.name); - const numB = Number(b.name); - - // If both are valid numbers, compare numerically - if (!isNaN(numA) && !isNaN(numB)) { - return numA - numB; - } - - // If one is numeric and the other is not, place numeric first - if (!isNaN(numA)) return -1; - if (!isNaN(numB)) return 1; - - // If both are non-numeric, compare them as strings - return a.name.localeCompare(b.name); - }); + const isFinalNode = useMemo(() => { + if (data === undefined) return false; + + if (selectedLevel5Node) return true; + if (selectedLevel4Node) + return data[5] === undefined || !data[5].some((node) => node.parentId === selectedLevel4Node.id); + if (selectedLevel3Node) + return data[4] === undefined || !data[4].some((node) => node.parentId === selectedLevel3Node?.id); + if (selectedLevel2Node) + return data[3] === undefined || !data[3].some((node) => node.parentId === selectedLevel2Node?.id); + if (selectedLevel1Node) + return data[2] === undefined || !data[2].some((node) => node.parentId === selectedLevel1Node?.id); + + return false; }, [ data, selectedLevel1Node?.id, @@ -119,11 +110,11 @@ export function PollingStationsFilters(): FunctionComponent { level3Filter: undefined, level4Filter: undefined, level5Filter: undefined, - pollingStationNumberFilter: undefined + pollingStationNumberFilter: undefined, }); }} value={search.level1Filter ?? ''}> - + @@ -145,11 +136,11 @@ export function PollingStationsFilters(): FunctionComponent { level3Filter: undefined, level4Filter: undefined, level5Filter: undefined, - pollingStationNumberFilter: undefined + pollingStationNumberFilter: undefined, }); }} value={search.level2Filter ?? ''}> - + @@ -174,7 +165,7 @@ export function PollingStationsFilters(): FunctionComponent { }); }} value={search.level3Filter ?? ''}> - + @@ -194,7 +185,7 @@ export function PollingStationsFilters(): FunctionComponent { navigateHandler({ level4Filter: value, level5Filter: undefined, pollingStationNumberFilter: undefined }); }} value={search.level4Filter ?? ''}> - + @@ -214,7 +205,7 @@ export function PollingStationsFilters(): FunctionComponent { navigateHandler({ level5Filter: value, pollingStationNumberFilter: undefined }); }} value={search.level5Filter ?? ''}> - + @@ -228,25 +219,14 @@ export function PollingStationsFilters(): FunctionComponent { - { + navigateHandler({ pollingStationNumberFilter: e.target.value }); }} - value={search.pollingStationNumberFilter ?? ''}> - - - - - - {filteredPollingStationNumbers?.map((node) => ( - - {node.name} - - ))} - - - + value={search.pollingStationNumberFilter ?? ''} + /> ); } diff --git a/web/src/components/questionsEditor/translate/TranslateQuestionFactory.tsx b/web/src/components/questionsEditor/translate/TranslateQuestionFactory.tsx index ddf32f5f9..a655092d8 100644 --- a/web/src/components/questionsEditor/translate/TranslateQuestionFactory.tsx +++ b/web/src/components/questionsEditor/translate/TranslateQuestionFactory.tsx @@ -100,11 +100,8 @@ export default function TranslateQuestionFactory({ } }} className='flex-1 border rounded-r-lg border-slate-200'> - +
-
{IconComponent && (
@@ -112,21 +109,19 @@ export default function TranslateQuestionFactory({
)}

- {isNilOrWhitespace(question.text[languageCode]) ? getQuestionTypeName(question.$questionType) : question.text[languageCode]} + {isNilOrWhitespace(question.text[languageCode]) + ? getQuestionTypeName(question.$questionType) + : question.text[languageCode]}

- {(!questionState.invalid ? ( -
- - Translated. -
- ) : ( -
- - Missing translations. -
- ))} - +
+ + {questionState.invalid ? 'Missing translations.' : 'Translated.'} +
@@ -142,10 +137,13 @@ export default function TranslateQuestionFactory({ {...fieldState} value={field.value[languageCode]} placeholder={field.value[defaultLanguageCode]} - onChange={event => field.onChange({ - ...field.value, - [languageCode]: event.target.value - })} /> + onChange={(event) => + field.onChange({ + ...field.value, + [languageCode]: event.target.value, + }) + } + /> @@ -164,10 +162,13 @@ export default function TranslateQuestionFactory({ {...fieldState} value={field.value[languageCode]} placeholder={field.value[defaultLanguageCode]} - onChange={event => field.onChange({ - ...field.value, - [languageCode]: event.target.value - })} /> + onChange={(event) => + field.onChange({ + ...field.value, + [languageCode]: event.target.value, + }) + } + /> @@ -186,7 +187,8 @@ export default function TranslateQuestionFactory({ )} - {(question.$questionType === QuestionType.MultiSelectQuestionType || question.$questionType === QuestionType.SingleSelectQuestionType) && ( + {(question.$questionType === QuestionType.MultiSelectQuestionType || + question.$questionType === QuestionType.SingleSelectQuestionType) && ( )} @@ -206,6 +208,5 @@ export default function TranslateQuestionFactory({
- ); } diff --git a/web/src/components/ui/DataTable/DataTable.tsx b/web/src/components/ui/DataTable/DataTable.tsx index 6c58c734b..3d53bb04e 100644 --- a/web/src/components/ui/DataTable/DataTable.tsx +++ b/web/src/components/ui/DataTable/DataTable.tsx @@ -204,6 +204,9 @@ export function DataTable( pagination, columnVisibility, }, + defaultColumn: { + size: 165, + }, }); return ( diff --git a/web/src/components/ui/date-picker.tsx b/web/src/components/ui/date-picker.tsx new file mode 100644 index 000000000..d25da860e --- /dev/null +++ b/web/src/components/ui/date-picker.tsx @@ -0,0 +1,52 @@ +import { addDays, format } from 'date-fns'; +import { Calendar as CalendarIcon } from 'lucide-react'; +import * as React from 'react'; +import { DateRange } from 'react-day-picker'; + +import { Button } from '@/components/ui/button'; +import { Calendar } from '@/components/ui/calendar'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; + +export function DatePickerWithRange({ className }: React.HTMLAttributes) { + const [date, setDate] = React.useState({ + from: new Date(2022, 0, 20), + to: addDays(new Date(2022, 0, 20), 20), + }); + + return ( +
+ + + + + + + + +
+ ); +} diff --git a/web/src/components/ui/multiple-select-dropdown.tsx b/web/src/components/ui/multiple-select-dropdown.tsx new file mode 100644 index 000000000..591bd3ed2 --- /dev/null +++ b/web/src/components/ui/multiple-select-dropdown.tsx @@ -0,0 +1,231 @@ +import { CheckIcon, ChevronDown, XIcon } from 'lucide-react'; +import * as React from 'react'; + +import { Button } from '@/components/ui/button'; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from '@/components/ui/command'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { Separator } from '@/components/ui/separator'; +import { cn } from '@/lib/utils'; + +/** + * Props for MultiSelect component + */ +interface MultiSelectDropdownProps extends React.ButtonHTMLAttributes { + /** + * An array of option objects to be displayed in the multi-select component. + * Each option object has a label, value, and an optional icon. + */ + options: { + /** The text to display for the option. */ + label: string; + /** The unique value associated with the option. */ + value: string; + }[]; + + /** + * Callback function triggered when the selected values change. + * Receives an array of the new selected values. + */ + onValueChange: (value: string[]) => void; + + /** The default selected values when the component mounts. */ + defaultValue?: string[]; + + /** + * Placeholder text to be displayed when no values are selected. + * Optional, defaults to "Select options". + */ + placeholder?: string; + + /** + * The modality of the popover. When set to true, interaction with outside elements + * will be disabled and only popover content will be visible to screen readers. + * Optional, defaults to false. + */ + modalPopover?: boolean; + + /** + * If true, renders the multi-select component as a child of another component. + * Optional, defaults to false. + */ + asChild?: boolean; + + /** + * Additional class names to apply custom styles to the multi-select component. + * Optional, can be used to add custom styles. + */ + className?: string; + + /** + * Additional class names to apply custom styles to the multi-select component. + * Optional, can be used to add custom styles. + */ + selectionDisplay: React.ReactNode; +} + +export const MultiSelectDropdown = React.forwardRef( + ( + { + options, + onValueChange, + defaultValue = [], + placeholder = 'Select options', + modalPopover = false, + asChild = false, + className, + selectionDisplay, + ...props + }, + ref + ) => { + const [selectedValues, setSelectedValues] = React.useState(defaultValue); + const [isPopoverOpen, setIsPopoverOpen] = React.useState(false); + + const handleInputKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + setIsPopoverOpen(true); + } else if (event.key === 'Backspace' && !event.currentTarget.value) { + const newSelectedValues = [...selectedValues]; + newSelectedValues.pop(); + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + } + }; + + const toggleOption = (option: string) => { + const newSelectedValues = selectedValues.includes(option) + ? selectedValues.filter((value) => value !== option) + : [...selectedValues, option]; + setSelectedValues(newSelectedValues); + onValueChange(newSelectedValues); + }; + + const handleClear = () => { + setSelectedValues([]); + onValueChange([]); + }; + + const handleTogglePopover = () => { + setIsPopoverOpen((prev) => !prev); + }; + + const toggleAll = () => { + if (selectedValues.length === options.length) { + handleClear(); + } else { + const allValues = options.map((option) => option.value); + setSelectedValues(allValues); + onValueChange(allValues); + } + }; + + React.useEffect(() => { + setSelectedValues(defaultValue); + }, [defaultValue]); + + return ( + + + + + setIsPopoverOpen(false)}> + + + + No results found. + + +
+ +
+ (Select All) +
+ {options.map((option) => { + const isSelected = selectedValues.includes(option.value); + return ( + toggleOption(option.value)} + className='cursor-pointer'> +
+ +
+ {option.label} +
+ ); + })} +
+ + +
+ {selectedValues.length > 0 && ( + <> + + Clear + + + + )} + setIsPopoverOpen(false)} + className='justify-center flex-1 max-w-full cursor-pointer'> + Close + +
+
+
+
+
+
+ ); + } +); + +MultiSelectDropdown.displayName = 'MultiSelectDropdown'; diff --git a/web/src/components/ui/tag-selector.tsx b/web/src/components/ui/tag-selector.tsx index c65e07302..4a37ab636 100644 --- a/web/src/components/ui/tag-selector.tsx +++ b/web/src/components/ui/tag-selector.tsx @@ -1,4 +1,3 @@ -import { cva, type VariantProps } from "class-variance-authority"; import { ChevronDown, XIcon @@ -9,12 +8,11 @@ import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Command, - CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList, - CommandSeparator, + CommandSeparator } from "@/components/ui/command"; import { Popover, @@ -22,7 +20,7 @@ import { PopoverTrigger, } from "@/components/ui/popover"; import { Separator } from "@/components/ui/separator"; -import { cn, getTagColor } from "@/lib/utils"; +import { getTagColor } from "@/lib/utils"; interface TagsSelectFormFieldProps @@ -83,7 +81,7 @@ const TagsSelectFormField = React.forwardRef< selectedValuesSet.current.add(value.trim()); setSelectedValues([...selectedValues, value.trim()]); } - + onValueChange(Array.from(selectedValuesSet.current)); }; @@ -94,21 +92,21 @@ const TagsSelectFormField = React.forwardRef< ref={ref} {...props} onClick={() => setIsPopoverOpen(!isPopoverOpen)} - className="flex w-full p-1 rounded-md border min-h-10 h-auto items-center justify-between bg-inherit hover:bg-card" + className="flex items-center justify-between w-full h-auto p-1 border rounded-md min-h-10 bg-inherit hover:bg-card" > {selectedValues.length > 0 ? ( -
+
{selectedValues.map((value) => { return ( {value} { event.stopPropagation(); toggleOption(value); @@ -130,17 +128,17 @@ const TagsSelectFormField = React.forwardRef< />
) : (
- + {placeholder} - +
)} @@ -155,7 +153,7 @@ const TagsSelectFormField = React.forwardRef< placeholder="Search..." onKeyDown={handleInputKeyDown} value={search} - onValueChange={setSearch} + onValueChange={setSearch} /> {/* Press enter to create this tag. */} @@ -191,13 +189,13 @@ const TagsSelectFormField = React.forwardRef< pointerEvents: "auto", opacity: 1, }} - className="flex-1 justify-center cursor-pointer" + className="justify-center flex-1 cursor-pointer" > Clear )} @@ -208,7 +206,7 @@ const TagsSelectFormField = React.forwardRef< pointerEvents: "auto", opacity: 1, }} - className="flex-1 justify-center cursor-pointer" + className="justify-center flex-1 cursor-pointer" > Close diff --git a/web/src/features/election-event/components/Dashboard/Dashboard.tsx b/web/src/features/election-event/components/Dashboard/Dashboard.tsx index e13e79111..e6052dcab 100644 --- a/web/src/features/election-event/components/Dashboard/Dashboard.tsx +++ b/web/src/features/election-event/components/Dashboard/Dashboard.tsx @@ -43,9 +43,13 @@ export default function ElectionEventDashboard(): ReactElement { })}> {t('electionEvent.eventDetails.tabTitle')} {t('electionEvent.pollingStations.tabTitle')} - {t('electionEvent.locations.tabTitle')} + {isMonitoringNgoForCitizenReporting && ( + {t('electionEvent.locations.tabTitle')} + )} {t('electionEvent.guides.observerGuidesTabTitle')} - {t('electionEvent.guides.citizenGuidesTabTitle')} + {isMonitoringNgoForCitizenReporting && ( + {t('electionEvent.guides.citizenGuidesTabTitle')} + )} {t('electionEvent.observerForms.tabTitle')} diff --git a/web/src/features/filtering/components/ActiveFilters.tsx b/web/src/features/filtering/components/ActiveFilters.tsx index 753772c1f..deb88e5fb 100644 --- a/web/src/features/filtering/components/ActiveFilters.tsx +++ b/web/src/features/filtering/components/ActiveFilters.tsx @@ -13,10 +13,11 @@ type SearchParams = { [key: string]: any; }; -const HIDDEN_FILTERS = [FILTER_KEY.PageSize, FILTER_KEY.PageNumber]; +const HIDDEN_FILTERS = [FILTER_KEY.PageSize, FILTER_KEY.PageNumber, FILTER_KEY.ViewBy]; const FILTER_LABELS = new Map([ [FILTER_KEY.MonitoringObserverStatus, FILTER_LABEL.MonitoringObserverStatus], [FILTER_KEY.MonitoringObserverTags, FILTER_LABEL.MonitoringObserverTags], + [FILTER_KEY.FormTypeFilter, FILTER_LABEL.FormTypeFilter], ]); const ActiveFilter: FC = ({ filterId, value, isArray }) => { diff --git a/web/src/features/filtering/components/SelectFilter.tsx b/web/src/features/filtering/components/SelectFilter.tsx index ad7d0119d..a4ad5a8ef 100644 --- a/web/src/features/filtering/components/SelectFilter.tsx +++ b/web/src/features/filtering/components/SelectFilter.tsx @@ -37,3 +37,14 @@ export const SelectFilter: FC = (props) => { ); }; + +interface BinarySelectFilterProps extends Omit {} + +export const BinarySelectFilter: FC = (props) => { + const options: SelectFilterOption[] = [ + { value: 'Yes', label: 'Yes' }, + { value: 'No', label: 'No' }, + ]; + + return ; +}; diff --git a/web/src/features/filtering/filtering-enums.ts b/web/src/features/filtering/filtering-enums.ts index ce834be93..87d6c19d3 100644 --- a/web/src/features/filtering/filtering-enums.ts +++ b/web/src/features/filtering/filtering-enums.ts @@ -3,9 +3,12 @@ export const enum FILTER_KEY { PageNumber = 'pageNumber', MonitoringObserverStatus = 'monitoringObserverStatus', MonitoringObserverTags = 'tags', + FormTypeFilter = 'formTypeFilter', + ViewBy = 'viewBy', } export const enum FILTER_LABEL { MonitoringObserverStatus = 'Observer status', MonitoringObserverTags = 'Tags', + FormTypeFilter = 'Form type', } diff --git a/web/src/features/forms/components/Dashboard/Dashboard.tsx b/web/src/features/forms/components/Dashboard/Dashboard.tsx index aaf0cf5c8..4dafc1a90 100644 --- a/web/src/features/forms/components/Dashboard/Dashboard.tsx +++ b/web/src/features/forms/components/Dashboard/Dashboard.tsx @@ -152,13 +152,13 @@ export default function FormsDashboard(): ReactElement { { row.depth === 0 ? - navigateToEdit(row.original.id)}>Edit - : navigateToEditTranslation(row.original.id, row.original.defaultLanguage)}>Edit + navigateToEdit(row.original.id)}>Edit + : navigateToEditTranslation(row.original.id, row.original.defaultLanguage)}>Edit } { row.depth === 0 ? - addTranslationsDialog.trigger(row.original.id, row.original.languages)}>Add translations + addTranslationsDialog.trigger(row.original.id, row.original.languages)}>Add translations : null } { diff --git a/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx b/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx index e48a838f6..4fd94b5e5 100644 --- a/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx +++ b/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx @@ -1,10 +1,4 @@ -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { Input } from '@/components/ui/input'; +import { MultiSelectDropdown } from '@/components/ui/multiple-select-dropdown'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; import { FILTER_KEY } from '@/features/filtering/filtering-enums'; import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; @@ -28,37 +22,25 @@ export const MonitoringObserverTagsSelect: FC = () => { return option.toLowerCase().includes(query.toLowerCase()); }); - const toggleTagsFilter = (tag: string) => { - if (!currentTags.includes(tag)) return navigateHandler({ tags: [...currentTags, tag] }); - - const filteredTags = currentTags.filter((tagText: string) => tagText !== tag); - - return navigateHandler({ [FILTER_KEY.MonitoringObserverTags]: filteredTags }); + const toggleTagsFilter = (tags: string[]) => { + return navigateHandler({ [FILTER_KEY.MonitoringObserverTags]: tags }); }; return ( - setQuery('')}> - -
- Observer tags + ({ label: tag, value: tag })) ?? []} + onValueChange={toggleTagsFilter} + placeholder='Observer tags' + defaultValue={currentTags} + className='text-slate-700' + selectionDisplay={ +
+ Observer tags + {currentTags && currentTags.length && ( + {currentTags.length} + )}
- - - setQuery(e.target.value)} - onKeyDown={(e: React.KeyboardEvent) => e.stopPropagation()} - /> - {filteredTags?.map((tag) => ( - toggleTagsFilter(tag)} - key={tag}> - {tag} - - ))} - - + } + /> ); }; diff --git a/web/src/features/polling-stations/components/Dashboard/Dashboard.tsx b/web/src/features/polling-stations/components/Dashboard/Dashboard.tsx index a151a77b6..ec0176fda 100644 --- a/web/src/features/polling-stations/components/Dashboard/Dashboard.tsx +++ b/web/src/features/polling-stations/components/Dashboard/Dashboard.tsx @@ -181,6 +181,7 @@ export default function PollingStationsDashboard(): ReactElement { level3Filter?: string; level4Filter?: string; level5Filter?: string; + pollingStationNumberFilter?: string; }; const [isFiltering, setFiltering] = useState(Object.keys(search).some(k => k === 'level1Filter' || k === 'level2Filter' || k === 'level3Filter' || k === 'level4Filter' || k === 'level5Filter')); @@ -212,6 +213,7 @@ export default function PollingStationsDashboard(): ReactElement { ['level3Filter', debouncedSearch.level3Filter], ['level4Filter', debouncedSearch.level4Filter], ['level5Filter', debouncedSearch.level5Filter], + ['pollingStationNumberFilter', debouncedSearch.pollingStationNumberFilter], ].filter(([_, value]) => value); return Object.fromEntries(params); @@ -219,17 +221,17 @@ export default function PollingStationsDashboard(): ReactElement { return ( - -
+ +
{i18n.t('electionEvent.pollingStations.cardTitle')} -
+
-
+
<>
- {isFiltering && (
+ {isFiltering && (
@@ -252,7 +254,7 @@ export default function PollingStationsDashboard(): ReactElement {
)} {Object.entries(search).length > 0 && ( -
+
{search.level1Filter && ( @@ -278,7 +280,7 @@ export default function PollingStationsDashboard(): ReactElement { )} - + usePollingStations(currentElectionRoundId, params)} queryParams={queryParams} /> diff --git a/web/src/features/responses/components/FormSubmissionsByEntryTable copy/FormSubmissionsByEntryTable.tsx b/web/src/features/responses/components/FormSubmissionsByEntryTable copy/FormSubmissionsByEntryTable.tsx index fbc8c19b8..f27622bb6 100644 --- a/web/src/features/responses/components/FormSubmissionsByEntryTable copy/FormSubmissionsByEntryTable.tsx +++ b/web/src/features/responses/components/FormSubmissionsByEntryTable copy/FormSubmissionsByEntryTable.tsx @@ -34,6 +34,7 @@ export function FormSubmissionsByEntryTable({ searchText }: FormsTableByEntryPro ['level3Filter', debouncedSearch.level3Filter], ['level4Filter', debouncedSearch.level4Filter], ['level5Filter', debouncedSearch.level5Filter], + ['pollingStationNumberFilter', debouncedSearch.pollingStationNumberFilter], ['followUpStatus', debouncedSearch.followUpStatus], ].filter(([_, value]) => value); diff --git a/web/src/features/responses/components/FormSubmissionsByEntryTable/FormSubmissionsByEntryTable.tsx b/web/src/features/responses/components/FormSubmissionsByEntryTable/FormSubmissionsByEntryTable.tsx index 602bd81e8..fa27ccd9b 100644 --- a/web/src/features/responses/components/FormSubmissionsByEntryTable/FormSubmissionsByEntryTable.tsx +++ b/web/src/features/responses/components/FormSubmissionsByEntryTable/FormSubmissionsByEntryTable.tsx @@ -1,16 +1,15 @@ -import { useNavigate } from '@tanstack/react-router'; -import type { VisibilityState } from '@tanstack/react-table'; -import { useDebounce } from '@uidotdev/usehooks'; -import { useCallback, useMemo } from 'react'; import type { FunctionComponent } from '@/common/types'; import { CardContent } from '@/components/ui/card'; import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; +import { useCurrentElectionRoundStore } from '@/context/election-round.store'; +import { Route } from '@/routes/responses'; +import { useNavigate } from '@tanstack/react-router'; +import { useDebounce } from '@uidotdev/usehooks'; +import { useCallback, useMemo } from 'react'; import { useFormSubmissionsByEntry } from '../../hooks/form-submissions-queries'; import type { FormSubmissionsSearchParams } from '../../models/search-params'; -import { formSubmissionsByEntryColumnDefs } from '../../utils/column-defs'; -import { Route } from '@/routes/responses'; import { useFormSubmissionsByEntryColumns } from '../../store/column-visibility'; -import { useCurrentElectionRoundStore } from '@/context/election-round.store'; +import { formSubmissionsByEntryColumnDefs } from '../../utils/column-defs'; type FormSubmissionsByEntryTableProps = { searchText: string; @@ -20,7 +19,7 @@ export function FormSubmissionsByEntryTable({ searchText }: FormSubmissionsByEnt const navigate = useNavigate(); const search = Route.useSearch(); const debouncedSearch = useDebounce(search, 300); - const currentElectionRoundId = useCurrentElectionRoundStore(s => s.currentElectionRoundId); + const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); const columnsVisibility = useFormSubmissionsByEntryColumns(); @@ -34,7 +33,11 @@ export function FormSubmissionsByEntryTable({ searchText }: FormSubmissionsByEnt ['level3Filter', debouncedSearch.level3Filter], ['level4Filter', debouncedSearch.level4Filter], ['level5Filter', debouncedSearch.level5Filter], + ['pollingStationNumberFilter', debouncedSearch.pollingStationNumberFilter], ['followUpStatus', debouncedSearch.followUpStatus], + ['questionsAnswered', debouncedSearch.questionsAnswered], + ['hasNotes', debouncedSearch.hasNotes], + ['hasAttachments', debouncedSearch.hasAttachments], ].filter(([_, value]) => value); return Object.fromEntries(params) as FormSubmissionsSearchParams; diff --git a/web/src/features/responses/components/FormSubmissionsTab/FormSubmissionsTab.tsx b/web/src/features/responses/components/FormSubmissionsTab/FormSubmissionsTab.tsx index 9ef35a0d1..ec5525a54 100644 --- a/web/src/features/responses/components/FormSubmissionsTab/FormSubmissionsTab.tsx +++ b/web/src/features/responses/components/FormSubmissionsTab/FormSubmissionsTab.tsx @@ -20,11 +20,11 @@ import { ColumnsVisibilitySelector } from '../ColumnsVisibilitySelector/ColumnsV import { ExportDataButton } from '../ExportDataButton/ExportDataButton'; import { FormsFiltersByEntry } from '../FormsFiltersByEntry/FormsFiltersByEntry'; import { FormsFiltersByObserver } from '../FormsFiltersByObserver/FormsFiltersByObserver'; -import { FormSubmissionsByEntryTable } from '../FormSubmissionsByEntryTable/FormSubmissionsByEntryTable'; -import { FormSubmissionsAggregatedByFormTable } from '../FormSubmissionsAggregatedByFormTable/FormSubmissionsAggregatedByFormTable'; import { FormsTableByObserver } from '../FormsTableByObserver/FormsTableByObserver'; +import { FormSubmissionsAggregatedByFormTable } from '../FormSubmissionsAggregatedByFormTable/FormSubmissionsAggregatedByFormTable'; +import { FormSubmissionsByEntryTable } from '../FormSubmissionsByEntryTable/FormSubmissionsByEntryTable'; -import { FunctionComponent } from "@/common/types"; +import { FunctionComponent } from '@/common/types'; const routeApi = getRouteApi('/responses/'); @@ -125,4 +125,4 @@ export default function FormSubmissionsTab(): FunctionComponent { {byFilter === 'byForm' && } ); -} \ No newline at end of file +} diff --git a/web/src/features/responses/components/FormsFiltersByEntry/FormsFiltersByEntry.tsx b/web/src/features/responses/components/FormsFiltersByEntry/FormsFiltersByEntry.tsx index f292e9931..e3f31c33b 100644 --- a/web/src/features/responses/components/FormsFiltersByEntry/FormsFiltersByEntry.tsx +++ b/web/src/features/responses/components/FormsFiltersByEntry/FormsFiltersByEntry.tsx @@ -1,15 +1,15 @@ import { useSetPrevSearch } from '@/common/prev-search-store'; -import { FollowUpStatus, FunctionComponent, ZFormType } from '@/common/types'; +import { FollowUpStatus, FunctionComponent, QuestionsAnswered, ZFormType } from '@/common/types'; import { PollingStationsFilters } from '@/components/PollingStationsFilters/PollingStationsFilters'; import { FilterBadge } from '@/components/ui/badge'; import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { mapFormType } from '@/lib/utils'; import { Route } from '@/routes/responses'; import { useNavigate } from '@tanstack/react-router'; import { useCallback } from 'react'; import type { FormSubmissionsSearchParams } from '../../models/search-params'; -import { mapFollowUpStatus } from '../../utils/helpers'; +import { mapFollowUpStatus, mapQuestionsAnswered } from '../../utils/helpers'; import { ResetFiltersButton } from '../ResetFiltersButton/ResetFiltersButton'; -import { mapFormType } from '@/lib/utils'; export function FormsFiltersByEntry(): FunctionComponent { const navigate = useNavigate({ from: '/responses/' }); @@ -66,6 +66,10 @@ export function FormsFiltersByEntry(): FunctionComponent { {mapFormType(ZFormType.Values.ClosingAndCounting)} + + + {mapFormType(ZFormType.Values.PSI)} + {mapFormType(ZFormType.Values.Other)} @@ -110,6 +114,64 @@ export function FormsFiltersByEntry(): FunctionComponent { + + + + + + + @@ -129,7 +191,7 @@ export function FormsFiltersByEntry(): FunctionComponent { {search.hasFlaggedAnswers && ( )} @@ -188,6 +250,20 @@ export function FormsFiltersByEntry(): FunctionComponent { onClear={onClearFilter('pollingStationNumberFilter')} /> )} + + {search.questionsAnswered && ( + + )} + + {search.hasNotes && ( + + )}
)} diff --git a/web/src/features/responses/components/QuickReportsTab/QuickReportsTab.tsx b/web/src/features/responses/components/QuickReportsTab/QuickReportsTab.tsx index 6d202470f..541a85e83 100644 --- a/web/src/features/responses/components/QuickReportsTab/QuickReportsTab.tsx +++ b/web/src/features/responses/components/QuickReportsTab/QuickReportsTab.tsx @@ -51,6 +51,7 @@ export function QuickReportsTab(): FunctionComponent { ['level3Filter', debouncedSearch.level3Filter], ['level4Filter', debouncedSearch.level4Filter], ['level5Filter', debouncedSearch.level5Filter], + ['pollingStationNumberFilter', debouncedSearch.pollingStationNumberFilter], ['followUpStatus', debouncedSearch.followUpStatus], ['quickReportLocationType', debouncedSearch.quickReportLocationType], ].filter(([_, value]) => value); diff --git a/web/src/features/responses/models/search-params.ts b/web/src/features/responses/models/search-params.ts index 9520d182a..b8aa7280f 100644 --- a/web/src/features/responses/models/search-params.ts +++ b/web/src/features/responses/models/search-params.ts @@ -1,7 +1,7 @@ /* eslint-disable unicorn/prefer-top-level-await */ +import { FollowUpStatus, QuestionsAnswered } from '@/common/types'; import { z } from 'zod'; import { QuickReportLocationType } from './quick-report'; -import { FollowUpStatus } from '@/common/types'; export const FormSubmissionsSearchParamsSchema = z.object({ viewBy: z.enum(['byEntry', 'byObserver', 'byForm']).catch('byEntry').default('byEntry'), @@ -13,12 +13,23 @@ export const FormSubmissionsSearchParamsSchema = z.object({ level3Filter: z.string().catch('').optional(), level4Filter: z.string().catch('').optional(), level5Filter: z.string().catch('').optional(), - pollingStationNumberFilter: z.string().catch('').optional(), + pollingStationNumberFilter: z.string().catch('').optional(), hasFlaggedAnswers: z.string().catch('').optional(), monitoringObserverId: z.string().catch('').optional(), tagsFilter: z.array(z.string()).optional().catch([]).optional(), - followUpStatus: z.enum([FollowUpStatus.NeedsFollowUp, FollowUpStatus.Resolved, FollowUpStatus.NotApplicable]).optional(), - quickReportLocationType: z.enum([QuickReportLocationType.NotRelatedToAPollingStation, QuickReportLocationType.OtherPollingStation, QuickReportLocationType.VisitedPollingStation]).optional() + followUpStatus: z + .enum([FollowUpStatus.NeedsFollowUp, FollowUpStatus.Resolved, FollowUpStatus.NotApplicable]) + .optional(), + quickReportLocationType: z + .enum([ + QuickReportLocationType.NotRelatedToAPollingStation, + QuickReportLocationType.OtherPollingStation, + QuickReportLocationType.VisitedPollingStation, + ]) + .optional(), + questionsAnswered: z.enum([QuestionsAnswered.None, QuestionsAnswered.Some, QuestionsAnswered.All]).optional(), + hasNotes: z.string().catch('').optional(), + hasAttachments: z.string().catch('').optional(), }); export type FormSubmissionsSearchParams = z.infer; @@ -30,16 +41,24 @@ export const QuickReportsSearchParamsSchema = z.object({ level4Filter: z.string().catch('').optional(), level5Filter: z.string().catch('').optional(), pollingStationNumberFilter: z.string().catch('').optional(), - followUpStatus: z.enum([FollowUpStatus.NeedsFollowUp, FollowUpStatus.Resolved, FollowUpStatus.NotApplicable]).optional(), - quickReportLocationType: z.enum([QuickReportLocationType.NotRelatedToAPollingStation, QuickReportLocationType.OtherPollingStation, QuickReportLocationType.VisitedPollingStation]).optional(), + followUpStatus: z + .enum([FollowUpStatus.NeedsFollowUp, FollowUpStatus.Resolved, FollowUpStatus.NotApplicable]) + .optional(), + quickReportLocationType: z + .enum([ + QuickReportLocationType.NotRelatedToAPollingStation, + QuickReportLocationType.OtherPollingStation, + QuickReportLocationType.VisitedPollingStation, + ]) + .optional(), }); export type QuickReportsSearchParams = z.infer; - - export const CitizenReportsSearchParamsSchema = z.object({ - followUpStatus: z.enum([FollowUpStatus.NeedsFollowUp, FollowUpStatus.Resolved, FollowUpStatus.NotApplicable]).optional(), + followUpStatus: z + .enum([FollowUpStatus.NeedsFollowUp, FollowUpStatus.Resolved, FollowUpStatus.NotApplicable]) + .optional(), }); export type CitizenReportsSearchParams = z.infer; diff --git a/web/src/features/responses/utils/column-defs.tsx b/web/src/features/responses/utils/column-defs.tsx index d806c85e0..3c2a47e39 100644 --- a/web/src/features/responses/utils/column-defs.tsx +++ b/web/src/features/responses/utils/column-defs.tsx @@ -25,6 +25,13 @@ import type { QuestionExtraData } from '../types'; import { mapQuickReportLocationType } from './helpers'; export const formSubmissionsByEntryColumnDefs: ColumnDef[] = [ + { + header: ({ column }) => , + accessorKey: 'submissionId', + enableSorting: true, + enableGlobalFilter: true, + }, + { header: ({ column }) => , accessorKey: 'timeSubmitted', @@ -32,6 +39,7 @@ export const formSubmissionsByEntryColumnDefs: ColumnDef
{format(row.original.timeSubmitted, DateTimeFormat)}
, }, + { header: ({ column }) => , accessorKey: 'formCode', @@ -44,6 +52,14 @@ export const formSubmissionsByEntryColumnDefs: ColumnDef , + accessorKey: 'formDefaultLanguage', + enableSorting: true, + enableGlobalFilter: true, + }, + { header: ({ column }) => , accessorKey: 'number', diff --git a/web/src/features/responses/utils/column-visibility-options.tsx b/web/src/features/responses/utils/column-visibility-options.tsx index 6c26c4bfc..a9b9854bb 100644 --- a/web/src/features/responses/utils/column-visibility-options.tsx +++ b/web/src/features/responses/utils/column-visibility-options.tsx @@ -63,14 +63,16 @@ export const formSubmissionsByObserverColumns: VisibilityState = { export const formSubmissionsDefaultColumns: Record = { byEntry: formSubmissionsByEntryDefaultColumns, byObserver: formSubmissionsByObserverDefaultColumns, - byForm: formSubmissionsByFormDefaultColumns + byForm: formSubmissionsByFormDefaultColumns, }; export type ColumnOption = { id: string; label: string; enableHiding: boolean }; const byEntryColumnVisibilityOptions: ColumnOption[] = [ + { id: 'submissionId', label: 'Entry ID', enableHiding: true }, { id: 'timeSubmitted', label: 'Time submitted', enableHiding: true }, { id: 'formCode', label: 'Form code', enableHiding: true }, + { id: 'formDefaultLanguage', label: 'Language', enableHiding: true }, { id: 'formType', label: 'Form type', enableHiding: true }, { id: 'level1', label: 'Location - L1', enableHiding: true }, { id: 'level2', label: 'Location - L2', enableHiding: true }, @@ -106,7 +108,6 @@ const byFormColumnVisibilityOptions: ColumnOption[] = [ { id: 'numberOfMediaFiles', label: 'Media files', enableHiding: true }, ]; - export const forObserverColumnVisibilityOptions: ColumnOption[] = [ { id: 'timeSubmitted', label: 'Time submitted', enableHiding: true }, { id: 'formCode', label: 'Form code', enableHiding: true }, @@ -126,7 +127,7 @@ export const forObserverColumnVisibilityOptions: ColumnOption[] = [ export const columnVisibilityOptions: Record = { byEntry: byEntryColumnVisibilityOptions, byObserver: byObserverColumnVisibilityOptions, - byForm: byFormColumnVisibilityOptions + byForm: byFormColumnVisibilityOptions, }; export const quickReportsColumnVisibilityOptions: ColumnOption[] = [ @@ -174,8 +175,6 @@ export const citizenReportsColumnVisibilityOptions: ColumnOption[] = [ { id: 'followUpStatus', label: 'Follow-up status', enableHiding: true }, ]; - - export const citizenReportsDefaultColumns: VisibilityState = { submissionId: false, timeSubmitted: true, diff --git a/web/src/features/responses/utils/helpers.ts b/web/src/features/responses/utils/helpers.ts index c33f24988..b6a3a4756 100644 --- a/web/src/features/responses/utils/helpers.ts +++ b/web/src/features/responses/utils/helpers.ts @@ -1,19 +1,26 @@ -import { FollowUpStatus } from "@/common/types"; -import { QuickReportLocationType } from "../models/quick-report"; +import { FollowUpStatus, QuestionsAnswered } from '@/common/types'; +import { QuickReportLocationType } from '../models/quick-report'; export function mapQuickReportLocationType(locationType: QuickReportLocationType): string { - if (locationType === QuickReportLocationType.NotRelatedToAPollingStation) return 'Not Related To A Polling Station'; - if (locationType === QuickReportLocationType.OtherPollingStation) return 'Other Polling Station'; - if (locationType === QuickReportLocationType.VisitedPollingStation) return 'Visited Polling Station'; - - return 'Unknown'; - }; + if (locationType === QuickReportLocationType.NotRelatedToAPollingStation) return 'Not Related To A Polling Station'; + if (locationType === QuickReportLocationType.OtherPollingStation) return 'Other Polling Station'; + if (locationType === QuickReportLocationType.VisitedPollingStation) return 'Visited Polling Station'; + return 'Unknown'; +} export function mapFollowUpStatus(followUpStatus: FollowUpStatus): string { - if (followUpStatus === FollowUpStatus.NotApplicable) return 'Not Applicable'; - if (followUpStatus === FollowUpStatus.NeedsFollowUp) return 'Needs Follow-up'; - if (followUpStatus === FollowUpStatus.Resolved) return 'Resolved'; - - return 'Unknown'; - }; \ No newline at end of file + if (followUpStatus === FollowUpStatus.NotApplicable) return 'Not Applicable'; + if (followUpStatus === FollowUpStatus.NeedsFollowUp) return 'Needs Follow-up'; + if (followUpStatus === FollowUpStatus.Resolved) return 'Resolved'; + + return 'Unknown'; +} + +export function mapQuestionsAnswered(questionsAnswered: QuestionsAnswered): string { + if (questionsAnswered === QuestionsAnswered.None) return 'None'; + if (questionsAnswered === QuestionsAnswered.Some) return 'Some'; + if (questionsAnswered === QuestionsAnswered.All) return 'All'; + + return 'Unknown'; +} diff --git a/web/src/hooks/locations-levels.ts b/web/src/hooks/locations-levels.ts index c895927c0..a913fd3c6 100644 --- a/web/src/hooks/locations-levels.ts +++ b/web/src/hooks/locations-levels.ts @@ -12,7 +12,7 @@ export function useLocationsLevels(electionRoundId: string): UseLocationsLevelsR queryFn: async () => { const response = await authApi.get( - `/election-rounds/${electionRoundId}/locations:fetchLevels` + `/election-rounds/${electionRoundId}/locations:fetchAll` ); return response.data.nodes.reduce>( diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 35cf5dd1e..520180f49 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -18,164 +18,164 @@ export function valueOrDefault(value: number | null | undefined, fallbackValue: // https://colorhunt.co/palettes/pastel const colors = [ - "#618264", - "#79ac78", - "#b0d9b1", - "#d0e7d2", - "#ecee81", - "#8ddfcb", - "#82a0d8", - "#edb7ed", - "#ef9595", - "#efb495", - "#efd595", - "#ebef95", - "#94a684", - "#aec3ae", - "#e4e4d0", - "#ffeef4", - "#fff3da", - "#dfccfb", - "#d0bfff", - "#beadfa", - "#96b6c5", - "#adc4ce", - "#eee0c9", - "#f1f0e8", - "#c8e4b2", - "#9ed2be", - "#7eaa92", - "#ffd9b7", - "#ffc6ac", - "#fff6dc", - "#c4c1a4", - "#9e9fa5", - "#faf3f0", - "#d4e2d4", - "#ffcacc", - "#dbc4f0", - "#a1ccd1", - "#f4f2de", - "#e9b384", - "#7c9d96", - "#aac8a7", - "#c3edc0", - "#e9ffc2", - "#fdffae", - "#ff9b9b", - "#ffd6a5", - "#fffec4", - "#cbffa9", - "#f1c27b", - "#ffd89c", - "#a2cdb0", - "#85a389", - "#a0c49d", - "#c4d7b2", - "#e1ecc8", - "#f7ffe5", - "#c2dedc", - "#ece5c7", - "#cdc2ae", - "#116a7b", - "#9babb8", - "#eee3cb", - "#d7c0ae", - "#967e76", - "#f2d8d8", - "#5c8984", - "#545b77", - "#374259", - "#f9f5f6", - "#f8e8ee", - "#fdcedf", - "#f2bed1", - "#c4dfdf", - "#d2e9e9", - "#e3f4f4", - "#f8f6f4", - "#f5f0bb", - "#dbdfaa", - "#b3c890", - "#73a9ad", - "#537188", - "#cbb279", - "#e1d4bb", - "#eeeeee", - "#8294c4", - "#acb1d6", - "#dbdfea", - "#ffead2", - "#bfccb5", - "#7c96ab", - "#b7b7b7", - "#edc6b1", - "#fdf4f5", - "#e8a0bf", - "#ba90c6", - "#c0dbea", - "#ddffbb", - "#c7e9b0", - "#b3c99c", - "#a4bc92", - "#b2a4ff", - "#ffb4b4", - "#ffdeb4", - "#fdf7c3", - "#fff2cc", - "#ffd966", - "#f4b183", - "#dfa67b", - "#d5b4b4", - "#e4d0d0", - "#f5ebeb", - "#bbd6b8", - "#aec2b6", - "#94af9f", - "#dbe4c6", - "#ccd5ae", - "#e9edc9", - "#fefae0", - "#faedcd", - "#a86464", - "#b3e5be", - "#f5ffc9", - "#f7c8e0", - "#dfffd8", - "#b4e4ff", - "#95bdff", - "#b9f3e4", - "#ea8fea", - "#ffaacf", - "#f6e6c2", - "#b5f1cc", - "#e5fdd1", - "#c9f4aa", - "#fcc2fc", - "#6096b4", - "#93bfcf", - "#bdcdd6", - "#eee9da", - "#a7727d", - "#eddbc7", - "#f8ead8", - "#f9f5e7", - "#aae3e2", - "#d9acf5", - "#ffcefe", - "#fdebed", - "#b9f3fc", - "#aee2ff", - "#93c6e7", - "#fedeff", - "#7286d3", - "#8ea7e9", - "#e5e0ff", - "#fff2f2", - "#eac7c7", - "#a0c3d2", - "#f7f5eb", - "#eae0da", + '#618264', + '#79ac78', + '#b0d9b1', + '#d0e7d2', + '#ecee81', + '#8ddfcb', + '#82a0d8', + '#edb7ed', + '#ef9595', + '#efb495', + '#efd595', + '#ebef95', + '#94a684', + '#aec3ae', + '#e4e4d0', + '#ffeef4', + '#fff3da', + '#dfccfb', + '#d0bfff', + '#beadfa', + '#96b6c5', + '#adc4ce', + '#eee0c9', + '#f1f0e8', + '#c8e4b2', + '#9ed2be', + '#7eaa92', + '#ffd9b7', + '#ffc6ac', + '#fff6dc', + '#c4c1a4', + '#9e9fa5', + '#faf3f0', + '#d4e2d4', + '#ffcacc', + '#dbc4f0', + '#a1ccd1', + '#f4f2de', + '#e9b384', + '#7c9d96', + '#aac8a7', + '#c3edc0', + '#e9ffc2', + '#fdffae', + '#ff9b9b', + '#ffd6a5', + '#fffec4', + '#cbffa9', + '#f1c27b', + '#ffd89c', + '#a2cdb0', + '#85a389', + '#a0c49d', + '#c4d7b2', + '#e1ecc8', + '#f7ffe5', + '#c2dedc', + '#ece5c7', + '#cdc2ae', + '#116a7b', + '#9babb8', + '#eee3cb', + '#d7c0ae', + '#967e76', + '#f2d8d8', + '#5c8984', + '#545b77', + '#374259', + '#f9f5f6', + '#f8e8ee', + '#fdcedf', + '#f2bed1', + '#c4dfdf', + '#d2e9e9', + '#e3f4f4', + '#f8f6f4', + '#f5f0bb', + '#dbdfaa', + '#b3c890', + '#73a9ad', + '#537188', + '#cbb279', + '#e1d4bb', + '#eeeeee', + '#8294c4', + '#acb1d6', + '#dbdfea', + '#ffead2', + '#bfccb5', + '#7c96ab', + '#b7b7b7', + '#edc6b1', + '#fdf4f5', + '#e8a0bf', + '#ba90c6', + '#c0dbea', + '#ddffbb', + '#c7e9b0', + '#b3c99c', + '#a4bc92', + '#b2a4ff', + '#ffb4b4', + '#ffdeb4', + '#fdf7c3', + '#fff2cc', + '#ffd966', + '#f4b183', + '#dfa67b', + '#d5b4b4', + '#e4d0d0', + '#f5ebeb', + '#bbd6b8', + '#aec2b6', + '#94af9f', + '#dbe4c6', + '#ccd5ae', + '#e9edc9', + '#fefae0', + '#faedcd', + '#a86464', + '#b3e5be', + '#f5ffc9', + '#f7c8e0', + '#dfffd8', + '#b4e4ff', + '#95bdff', + '#b9f3e4', + '#ea8fea', + '#ffaacf', + '#f6e6c2', + '#b5f1cc', + '#e5fdd1', + '#c9f4aa', + '#fcc2fc', + '#6096b4', + '#93bfcf', + '#bdcdd6', + '#eee9da', + '#a7727d', + '#eddbc7', + '#f8ead8', + '#f9f5e7', + '#aae3e2', + '#d9acf5', + '#ffcefe', + '#fdebed', + '#b9f3fc', + '#aee2ff', + '#93c6e7', + '#fedeff', + '#7286d3', + '#8ea7e9', + '#e5e0ff', + '#fff2f2', + '#eac7c7', + '#a0c3d2', + '#f7f5eb', + '#eae0da', ]; export function getTagColor(tag: string) { @@ -246,19 +246,19 @@ export function ratingScaleToNumber(scale: RatingScaleType): number { } export function buildURLSearchParams(data: any) { - const params = new URLSearchParams() + const params = new URLSearchParams(); Object.entries(data).forEach(([key, value]) => { if (Array.isArray(value)) { // @ts-ignore - value.forEach(value => params.append(key, value.toString())) + value.forEach((value) => params.append(key, value.toString())); } else { // @ts-ignore - params.append(key, value.toString()) + params.append(key, value.toString()); } }); - return params + return params; } export function round(value: number, decimals: number): number { @@ -276,7 +276,6 @@ export const isNotNilOrWhitespace = (input?: string | null) => (input?.trim()?.l export const isNilOrWhitespace = (input?: string | null) => (input?.trim()?.length || 0) === 0; - export function takewhile(arr: T[], predicate: (value: T) => boolean): T[] { const result: T[] = []; for (let i = 0; i < arr.length; i++) { @@ -290,17 +289,23 @@ export function takewhile(arr: T[], predicate: (value: T) => boolean): T[] { export function mapFormType(formType: FormType): string { switch (formType) { - case ZFormType.Values.Opening: return i18n.t('formType.opening'); - case ZFormType.Values.Voting: return i18n.t('formType.voting'); - case ZFormType.Values.ClosingAndCounting: return i18n.t('formType.closingAndCounting'); - case ZFormType.Values.CitizenReporting: return i18n.t('formType.citizenReporting'); - case ZFormType.Values.Other: return i18n.t('formType.other'); - default: return "Unknown"; + case ZFormType.Values.Opening: + return i18n.t('formType.opening'); + case ZFormType.Values.Voting: + return i18n.t('formType.voting'); + case ZFormType.Values.ClosingAndCounting: + return i18n.t('formType.closingAndCounting'); + case ZFormType.Values.CitizenReporting: + return i18n.t('formType.citizenReporting'); + case ZFormType.Values.PSI: + return i18n.t('formType.psi'); + case ZFormType.Values.Other: + return i18n.t('formType.other'); + default: + return 'Unknown'; } } - - /** * Creates a new Translated String containing all available languages * @param availableLanguages available translations list @@ -308,9 +313,13 @@ export function mapFormType(formType: FormType): string { * @param value value to set for required languageCode * @returns new instance of @see {@link TranslatedString} */ -export const newTranslatedString = (availableLanguages: string[], languageCode: string, value: string = ''): TranslatedString => { +export const newTranslatedString = ( + availableLanguages: string[], + languageCode: string, + value: string = '' +): TranslatedString => { const translatedString: TranslatedString = {}; - availableLanguages.forEach(language => { + availableLanguages.forEach((language) => { translatedString[language] = ''; }); @@ -327,16 +336,19 @@ export const newTranslatedString = (availableLanguages: string[], languageCode: */ export const emptyTranslatedString = (availableLanguages: string[], value: string = ''): TranslatedString => { const translatedString: TranslatedString = {}; - availableLanguages.forEach(language => { + availableLanguages.forEach((language) => { translatedString[language] = value; }); - return translatedString; }; - -export const updateTranslationString = (translatedString: TranslatedString | undefined, availableLanguages: string[], languageCode: string, value: string): TranslatedString => { +export const updateTranslationString = ( + translatedString: TranslatedString | undefined, + availableLanguages: string[], + languageCode: string, + value: string +): TranslatedString => { if (translatedString === undefined) { translatedString = newTranslatedString(availableLanguages, languageCode); } @@ -354,7 +366,12 @@ export const updateTranslationString = (translatedString: TranslatedString | und * @param defaultValue default value * @returns new instance of @see {@link TranslatedString} */ -export const cloneTranslation = (translatedString: TranslatedString | undefined, fromLanguageCode: string, toLanguageCode: string, defaultValue: string = ''): TranslatedString | undefined => { +export const cloneTranslation = ( + translatedString: TranslatedString | undefined, + fromLanguageCode: string, + toLanguageCode: string, + defaultValue: string = '' +): TranslatedString | undefined => { if (translatedString) { translatedString[toLanguageCode] = translatedString[fromLanguageCode] ?? defaultValue; } @@ -370,7 +387,12 @@ export const cloneTranslation = (translatedString: TranslatedString | undefined, * @param defaultValue default value * @returns new instance of @see {@link TranslatedString} */ -export const changeLanguageCode = (translatedString: TranslatedString | undefined, fromLanguageCode: string, toLanguageCode: string, defaultValue: string = ''): TranslatedString => { +export const changeLanguageCode = ( + translatedString: TranslatedString | undefined, + fromLanguageCode: string, + toLanguageCode: string, + defaultValue: string = '' +): TranslatedString => { if (translatedString === undefined) { return {}; } @@ -380,9 +402,9 @@ export const changeLanguageCode = (translatedString: TranslatedString | undefine return { ...translatedString, - [toLanguageCode]: text ?? defaultValue + [toLanguageCode]: text ?? defaultValue, }; -} +}; /** * Gets translation from a translated string. @@ -392,7 +414,11 @@ export const changeLanguageCode = (translatedString: TranslatedString | undefine * @param value value to set for required languageCode * @returns translation or a default value */ -export const getTranslationOrDefault = (translatedString: TranslatedString | undefined, languageCode: string, value: string = ''): string => { +export const getTranslationOrDefault = ( + translatedString: TranslatedString | undefined, + languageCode: string, + value: string = '' +): string => { if (translatedString === undefined) { return value; } diff --git a/web/src/locales/en.json b/web/src/locales/en.json index c2dbbe861..46f4ee841 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -105,6 +105,7 @@ "opening": "Opening", "voting": "Voting", "closingAndCounting": "Closing And Counting", + "psi": "Closing And Counting", "other": "Other", "citizenReporting": "Citizen reporting" }, From 740f04b83ec6133da43aa766680821af5d95396d Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 14 Feb 2025 13:14:54 +0200 Subject: [PATCH 11/21] WIP: rename prop for alternative filter key in Observer Tags and add it to the Push Message form --- .../filtering/components/ActiveFilters.tsx | 11 +++++----- web/src/features/filtering/filtering-enums.ts | 20 +++++++++---------- .../PushMessageForm/PushMessageForm.tsx | 6 ++---- .../MonitoringObserverTagsSelect.tsx | 10 +++++----- .../FormSubmissionsFiltersByEntry.tsx | 8 ++++---- .../FormSubmissionsFiltersByForm.tsx | 8 ++++---- .../FormSubmissionsFiltersByObserver.tsx | 4 ++-- 7 files changed, 33 insertions(+), 34 deletions(-) diff --git a/web/src/features/filtering/components/ActiveFilters.tsx b/web/src/features/filtering/components/ActiveFilters.tsx index a80c7c694..e67516f61 100644 --- a/web/src/features/filtering/components/ActiveFilters.tsx +++ b/web/src/features/filtering/components/ActiveFilters.tsx @@ -1,6 +1,9 @@ +import { useDataSource } from '@/common/data-source-store'; import { DateTimeFormat } from '@/common/formats'; +import { DataSources } from '@/common/types'; import { FilterBadge } from '@/components/ui/badge'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; +import { useCoalitionDetails } from '@/features/election-event/hooks/coalition-hooks'; import { useFormSubmissionsFilters } from '@/features/responses/hooks/form-submissions-queries'; import { mapFormSubmissionFollowUpStatus, @@ -12,9 +15,6 @@ import { useNavigate } from '@tanstack/react-router'; import { format } from 'date-fns/format'; import { FC, useCallback } from 'react'; import { FILTER_KEY, FILTER_LABEL } from '../filtering-enums'; -import { useDataSource } from '@/common/data-source-store'; -import { useCoalitionDetails } from '@/features/election-event/hooks/coalition-hooks'; -import { DataSources } from '@/common/types'; interface ActiveFilterProps { filterId: string; @@ -50,7 +50,7 @@ const FILTER_LABELS = new Map([ [FILTER_KEY.LocationL3, FILTER_LABEL.LocationL3], [FILTER_KEY.LocationL4, FILTER_LABEL.LocationL4], [FILTER_KEY.LocationL5, FILTER_LABEL.LocationL5], - [FILTER_KEY.FormSubmissionsMonitoringObserverTags, FILTER_LABEL.FormSubmissionsMonitoringObserverTags], + [FILTER_KEY.TagsFilter, FILTER_LABEL.TagsFilter], [FILTER_KEY.PollingStationNumber, FILTER_LABEL.PollingStationNumber], [FILTER_KEY.FormId, FILTER_LABEL.FormId], [FILTER_KEY.FormStatusFilter, FILTER_LABEL.FormStatus], @@ -120,7 +120,8 @@ export const ActiveFilters: FC = ({ queryParams }) => { .filter(([filterId, value]) => isNotNilOrWhitespace(value?.toString())) .filter( ([filterId, value]) => - filterId !== FILTER_KEY.CoalitionMemberId || (dataSource === DataSources.Coalition && filterId === FILTER_KEY.CoalitionMemberId) + filterId !== FILTER_KEY.CoalitionMemberId || + (dataSource === DataSources.Coalition && filterId === FILTER_KEY.CoalitionMemberId) ) .map(([filterId, value]) => { let key = ''; diff --git a/web/src/features/filtering/filtering-enums.ts b/web/src/features/filtering/filtering-enums.ts index af28b3715..37082dad0 100644 --- a/web/src/features/filtering/filtering-enums.ts +++ b/web/src/features/filtering/filtering-enums.ts @@ -16,7 +16,7 @@ export const enum FILTER_KEY { LocationL4 = 'level4Filter', LocationL5 = 'level5Filter', PollingStationNumber = 'pollingStationNumberFilter', - FormSubmissionsMonitoringObserverTags = 'tagsFilter', + TagsFilter = 'tagsFilter', ViewBy = 'viewBy', Tab = 'tab', FormId = 'formId', @@ -25,10 +25,10 @@ export const enum FILTER_KEY { FromDate = 'submissionsFromDate', ToDate = 'submissionsToDate', SearchText = 'searchText', - QuickReportIncidentCategory ='incidentCategory', - QuickReportFollowUpStatus ='quickReportFollowUpStatus', - HasQuickReports ='hasQuickReports', - DataSource ='dataSource', + QuickReportIncidentCategory = 'incidentCategory', + QuickReportFollowUpStatus = 'quickReportFollowUpStatus', + HasQuickReports = 'hasQuickReports', + DataSource = 'dataSource', CoalitionMemberId = 'coalitionMemberId', } @@ -46,15 +46,15 @@ export const enum FILTER_LABEL { LocationL4 = 'Location - L4', LocationL5 = 'Location - L5', PollingStationNumber = 'Polling station number', - FormSubmissionsMonitoringObserverTags = 'Observer tags', + TagsFilter = 'Observer tags', MediaFiles = 'Has attachments', FormId = 'Form', FormStatus = 'Form status', FromDate = 'From date', ToDate = 'To Date', SearchText = 'Search text', - QuickReportIncidentCategory ='Incident category', - QuickReportFollowUpStatus ='Quick report follow up status', - HasQuickReports ='Has quick reports', - CoalitionMemberId = 'NGO' + QuickReportIncidentCategory = 'Incident category', + QuickReportFollowUpStatus = 'Quick report follow up status', + HasQuickReports = 'Has quick reports', + CoalitionMemberId = 'NGO', } diff --git a/web/src/features/monitoring-observers/components/PushMessageForm/PushMessageForm.tsx b/web/src/features/monitoring-observers/components/PushMessageForm/PushMessageForm.tsx index 9e9ddb740..7f4508cf9 100644 --- a/web/src/features/monitoring-observers/components/PushMessageForm/PushMessageForm.tsx +++ b/web/src/features/monitoring-observers/components/PushMessageForm/PushMessageForm.tsx @@ -40,9 +40,7 @@ import { targetedMonitoringObserverColDefs } from '../../utils/column-defs'; const createPushMessageSchema = z.object({ title: z.string().min(1, { message: 'Your message must have a title before sending.' }), - messageBody: z - .string() - .min(1, { message: 'Your message must have a detailed description before sending.' }) + messageBody: z.string().min(1, { message: 'Your message must have a detailed description before sending.' }), }); function PushMessageForm(): FunctionComponent { @@ -201,7 +199,7 @@ function PushMessageForm(): FunctionComponent { value={searchText} placeholder='Search' /> - + diff --git a/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx b/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx index 3534eb42a..828346797 100644 --- a/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx +++ b/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx @@ -7,13 +7,13 @@ import { useMonitoringObserversTags } from '@/hooks/tags-queries'; import { FC } from 'react'; interface MonitoringObserverTagsSelectProps { - isFilteringFormSubmissions?: boolean; + isUsingAlternativeFilteringKey?: boolean; } -export const MonitoringObserverTagsSelect: FC = ({ isFilteringFormSubmissions }) => { - const COMPONENT_FILTER_KEY = isFilteringFormSubmissions - ? FILTER_KEY.FormSubmissionsMonitoringObserverTags - : FILTER_KEY.MonitoringObserverTags; +export const MonitoringObserverTagsSelect: FC = ({ + isUsingAlternativeFilteringKey, +}) => { + const COMPONENT_FILTER_KEY = isUsingAlternativeFilteringKey ? FILTER_KEY.TagsFilter : FILTER_KEY.MonitoringObserverTags; const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); const { data: tags } = useMonitoringObserversTags(currentElectionRoundId); diff --git a/web/src/features/responses/components/FormSubmissionsFiltersByEntry/FormSubmissionsFiltersByEntry.tsx b/web/src/features/responses/components/FormSubmissionsFiltersByEntry/FormSubmissionsFiltersByEntry.tsx index 67f9a326e..c67be4463 100644 --- a/web/src/features/responses/components/FormSubmissionsFiltersByEntry/FormSubmissionsFiltersByEntry.tsx +++ b/web/src/features/responses/components/FormSubmissionsFiltersByEntry/FormSubmissionsFiltersByEntry.tsx @@ -1,4 +1,7 @@ +import { useDataSource } from '@/common/data-source-store'; +import { DataSources } from '@/common/types'; import { PollingStationsFilters } from '@/components/PollingStationsFilters/PollingStationsFilters'; +import { CoalitionMemberFilter } from '@/features/filtering/components/CoalitionMemberFilter'; import { FilteringContainer } from '@/features/filtering/components/FilteringContainer'; import { FormTypeFilter } from '@/features/filtering/components/FormTypeFilter'; import { MonitoringObserverTagsSelect } from '@/features/monitoring-observers/filtering/MonitoringObserverTagsSelect'; @@ -11,9 +14,6 @@ import { FormSubmissionsMediaFilesFilter } from '../../../filtering/components/F import { FormSubmissionsQuestionNotesFilter } from '../../../filtering/components/FormSubmissionsQuestionNotesFilter'; import { FormSubmissionsQuestionsAnsweredFilter } from '../../../filtering/components/FormSubmissionsQuestionsAnsweredFilter'; import { FormSubmissionsToDateFilter } from '../../../filtering/components/FormSubmissionsToDateFilter'; -import { useDataSource } from '@/common/data-source-store'; -import { DataSources } from '@/common/types'; -import { CoalitionMemberFilter } from '@/features/filtering/components/CoalitionMemberFilter'; export const FormSubmissionsFiltersByEntry: FC = () => { const dataSource = useDataSource(); @@ -28,7 +28,7 @@ export const FormSubmissionsFiltersByEntry: FC = () => { - + diff --git a/web/src/features/responses/components/FormSubmissionsFiltersByForm/FormSubmissionsFiltersByForm.tsx b/web/src/features/responses/components/FormSubmissionsFiltersByForm/FormSubmissionsFiltersByForm.tsx index ebd76db30..cc9f0b57a 100644 --- a/web/src/features/responses/components/FormSubmissionsFiltersByForm/FormSubmissionsFiltersByForm.tsx +++ b/web/src/features/responses/components/FormSubmissionsFiltersByForm/FormSubmissionsFiltersByForm.tsx @@ -1,4 +1,7 @@ +import { useDataSource } from '@/common/data-source-store'; +import { DataSources } from '@/common/types'; import { PollingStationsFilters } from '@/components/PollingStationsFilters/PollingStationsFilters'; +import { CoalitionMemberFilter } from '@/features/filtering/components/CoalitionMemberFilter'; import { FilteringContainer } from '@/features/filtering/components/FilteringContainer'; import { FormSubmissionsFollowUpFilter } from '@/features/filtering/components/FormSubmissionsFollowUpFilter'; import { FormSubmissionsFormFilter } from '@/features/filtering/components/FormSubmissionsFormFilter'; @@ -11,9 +14,6 @@ import { FC } from 'react'; import { FormSubmissionsFlaggedAnswersFilter } from '../../../filtering/components/FormSubmissionsFlaggedAnswersFilter'; import { FormSubmissionsMediaFilesFilter } from '../../../filtering/components/FormSubmissionsMediaFilesFilter'; import { FormSubmissionsQuestionNotesFilter } from '../../../filtering/components/FormSubmissionsQuestionNotesFilter'; -import { useDataSource } from '@/common/data-source-store'; -import { DataSources } from '@/common/types'; -import { CoalitionMemberFilter } from '@/features/filtering/components/CoalitionMemberFilter'; export const FormSubmissionsFiltersByForm: FC = () => { const dataSource = useDataSource(); @@ -28,7 +28,7 @@ export const FormSubmissionsFiltersByForm: FC = () => { - + diff --git a/web/src/features/responses/components/FormSubmissionsFiltersByObserver/FormSubmissionsFiltersByObserver.tsx b/web/src/features/responses/components/FormSubmissionsFiltersByObserver/FormSubmissionsFiltersByObserver.tsx index 7e79da6a5..76f6758ac 100644 --- a/web/src/features/responses/components/FormSubmissionsFiltersByObserver/FormSubmissionsFiltersByObserver.tsx +++ b/web/src/features/responses/components/FormSubmissionsFiltersByObserver/FormSubmissionsFiltersByObserver.tsx @@ -8,12 +8,12 @@ import { FormSubmissionsFollowUpFilter } from '../../../filtering/components/For export function FormSubmissionsFiltersByObserver(): FunctionComponent { const dataSource = useDataSource(); - + return ( {dataSource === DataSources.Coalition ? : null} - + ); From fff3a05948a09ba682388058ad36ea8e88e587f9 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 14 Feb 2025 13:44:43 +0200 Subject: [PATCH 12/21] WIP: fix push messages receipients query not invalidating after edits --- .../EditMonitoringObserver/EditMonitoringObserver.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/src/features/monitoring-observers/components/EditMonitoringObserver/EditMonitoringObserver.tsx b/web/src/features/monitoring-observers/components/EditMonitoringObserver/EditMonitoringObserver.tsx index 6c889ba17..c12fc3bc5 100644 --- a/web/src/features/monitoring-observers/components/EditMonitoringObserver/EditMonitoringObserver.tsx +++ b/web/src/features/monitoring-observers/components/EditMonitoringObserver/EditMonitoringObserver.tsx @@ -17,6 +17,7 @@ import { useNavigate, useRouter } from '@tanstack/react-router'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { monitoringObserversKeys } from '../../hooks/monitoring-observers-queries'; +import { targetedObserversKeys } from '../../hooks/push-messages-queries'; import { MonitoringObserverStatus, UpdateMonitoringObserverRequest } from '../../models/monitoring-observer'; import { MonitorObserverBackButton } from '../MonitoringObserverBackButton'; @@ -87,6 +88,7 @@ export default function EditObserver() { }); router.invalidate(); queryClient.invalidateQueries({ queryKey: monitoringObserversKeys.all(electionRoundId) }); + queryClient.invalidateQueries({ queryKey: targetedObserversKeys.all(electionRoundId) }); navigate({ to: '/monitoring-observers/view/$monitoringObserverId/$tab', From a3771c5597e58c012de9538660a377ef9bbbfcb4 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 14 Feb 2025 13:55:35 +0200 Subject: [PATCH 13/21] invalidate targeted observers query after a morning observer is added --- .../MonitoringObserversList/CreateMonitoringObserverDialog.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/src/features/monitoring-observers/components/MonitoringObserversList/CreateMonitoringObserverDialog.tsx b/web/src/features/monitoring-observers/components/MonitoringObserversList/CreateMonitoringObserverDialog.tsx index 79e61078d..66d03559a 100644 --- a/web/src/features/monitoring-observers/components/MonitoringObserversList/CreateMonitoringObserverDialog.tsx +++ b/web/src/features/monitoring-observers/components/MonitoringObserversList/CreateMonitoringObserverDialog.tsx @@ -13,6 +13,7 @@ import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { z } from 'zod'; import { monitoringObserversKeys } from '../../hooks/monitoring-observers-queries'; +import { targetedObserversKeys } from '../../hooks/push-messages-queries'; export interface CreateMonitoringObserverDialogProps { open: boolean; @@ -54,6 +55,8 @@ function CreateMonitoringObserverDialog({ open, onOpenChange }: CreateMonitoring }); queryClient.invalidateQueries({ queryKey: monitoringObserversKeys.all(electionRoundId) }); + queryClient.invalidateQueries({ queryKey: targetedObserversKeys.all(electionRoundId) }); + form.reset({}); onOpenChange(false); }, From ab72b0f4fb06cb56eecd066636cf24eec2f7a6c1 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 14 Mar 2025 11:26:45 +0200 Subject: [PATCH 14/21] WIP: add sentry package, config and JWT parser --- web/.env.example | 3 +- web/package.json | 1 + web/pnpm-lock.yaml | 68 +++++++++++++++++++++++++++++++++++++++++ web/src/common/types.ts | 11 ++++++- web/src/lib/sentry.ts | 27 ++++++++++++++++ web/src/main.tsx | 10 ++++-- 6 files changed, 115 insertions(+), 5 deletions(-) create mode 100644 web/src/lib/sentry.ts diff --git a/web/.env.example b/web/.env.example index b1bd5bd75..35e261d38 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1 +1,2 @@ -VITE_API_URL=https://localhost:7123/api/ \ No newline at end of file +VITE_API_URL=https://localhost:7123/api/ +VITE_SENTRY_DSN=your-url-here \ No newline at end of file diff --git a/web/package.json b/web/package.json index eae4b7e56..1a67c749e 100644 --- a/web/package.json +++ b/web/package.json @@ -39,6 +39,7 @@ "@radix-ui/react-toast": "^1.2.6", "@radix-ui/react-toggle": "^1.1.2", "@radix-ui/react-tooltip": "^1.1.8", + "@sentry/react": "^9.5.0", "@tailwindcss/forms": "^0.5.10", "@tanstack/react-query": "^5.66.0", "@tanstack/react-query-devtools": "^5.66.0", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index d0d188254..5b3bc67b2 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -71,6 +71,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.1.8 version: 1.1.8(@types/react-dom@18.3.5(@types/react@18.3.18))(@types/react@18.3.18)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@sentry/react': + specifier: ^9.5.0 + version: 9.5.0(react@18.3.1) '@tailwindcss/forms': specifier: ^0.5.10 version: 0.5.10(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3))) @@ -1844,6 +1847,36 @@ packages: cpu: [x64] os: [win32] + '@sentry-internal/browser-utils@9.5.0': + resolution: {integrity: sha512-AE9jgeI5+KyGvLR0vf1I6sesi0NZXZe6pDlZNXyg+pWZB2vkE9dksE8ZsoU+YiD9zjUqazgPcVyb3O0VvmaCGw==} + engines: {node: '>=18'} + + '@sentry-internal/feedback@9.5.0': + resolution: {integrity: sha512-p+yOTufEYHP1RLwkD+aZwpCNS4/2l6t4uHgphjYrEC2U/U2mtZQh+EvlBAt0wY/eiKC4/acPNrF5yFD/4A7a0A==} + engines: {node: '>=18'} + + '@sentry-internal/replay-canvas@9.5.0': + resolution: {integrity: sha512-W7MS7/9Z8uP2i0pbndxqz2VcGlFPc7Bv6gCoxRdGIWUWSBS9rsRbryO0sM0PwwuHt2mQtWMqwjYykcR441RBRA==} + engines: {node: '>=18'} + + '@sentry-internal/replay@9.5.0': + resolution: {integrity: sha512-fBBNimElAnu865HT3MJ6xH2P26KvkZvAYt+yRrWr+x5zS5KvjBYUPsSI+F0FTE14XmLW9q7DlNUl5iAZhXSy3g==} + engines: {node: '>=18'} + + '@sentry/browser@9.5.0': + resolution: {integrity: sha512-HYSPW8GjknuYykJgOialKFyWg7ldmrbD1AKTIhksqdsNXLER07YeVWFAbe+xSYa1ZwwC8/s6vQJP9ZOoH1BaVg==} + engines: {node: '>=18'} + + '@sentry/core@9.5.0': + resolution: {integrity: sha512-NMqyFdyg26ECAfnibAPKT8vvAt4zXp4R7dYtQnwJKhEJEVkgAshcNYeJ2D95ZLMVOqlqhTtTPnw1vqf+v9ePZg==} + engines: {node: '>=18'} + + '@sentry/react@9.5.0': + resolution: {integrity: sha512-ixOlKuMxWKSK73u41vY2wQNkQpZJo4fwRkA6r4oy745ldcwhGlOy/TMACdotbHCn4ULC86rVZN5r49mH6SV5+w==} + engines: {node: '>=18'} + peerDependencies: + react: ^16.14.0 || 17.x || 18.x || 19.x + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -6808,6 +6841,41 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.34.7': optional: true + '@sentry-internal/browser-utils@9.5.0': + dependencies: + '@sentry/core': 9.5.0 + + '@sentry-internal/feedback@9.5.0': + dependencies: + '@sentry/core': 9.5.0 + + '@sentry-internal/replay-canvas@9.5.0': + dependencies: + '@sentry-internal/replay': 9.5.0 + '@sentry/core': 9.5.0 + + '@sentry-internal/replay@9.5.0': + dependencies: + '@sentry-internal/browser-utils': 9.5.0 + '@sentry/core': 9.5.0 + + '@sentry/browser@9.5.0': + dependencies: + '@sentry-internal/browser-utils': 9.5.0 + '@sentry-internal/feedback': 9.5.0 + '@sentry-internal/replay': 9.5.0 + '@sentry-internal/replay-canvas': 9.5.0 + '@sentry/core': 9.5.0 + + '@sentry/core@9.5.0': {} + + '@sentry/react@9.5.0(react@18.3.1)': + dependencies: + '@sentry/browser': 9.5.0 + '@sentry/core': 9.5.0 + hoist-non-react-statics: 3.3.2 + react: 18.3.1 + '@sinclair/typebox@0.27.8': {} '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.26.9)': diff --git a/web/src/common/types.ts b/web/src/common/types.ts index e8d79fe5a..f31ff2869 100644 --- a/web/src/common/types.ts +++ b/web/src/common/types.ts @@ -194,8 +194,17 @@ export type LevelNode = { parentId: number; }; +export enum JWT_CLAIMS { + EMAIL = 'http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress', + ROLE = 'http://schemas.microsoft.com/ws/2008/06/identity/claims/role', + USER_ID = 'user-id', + USER_ROLE = 'user-role', +} + export type UserPayload = { - 'user-role': string; + [JWT_CLAIMS.USER_ID]: string; + [JWT_CLAIMS.USER_ROLE]: string; + [JWT_CLAIMS.EMAIL]: string; }; export enum FormSubmissionFollowUpStatus { diff --git a/web/src/lib/sentry.ts b/web/src/lib/sentry.ts new file mode 100644 index 000000000..a42644b9d --- /dev/null +++ b/web/src/lib/sentry.ts @@ -0,0 +1,27 @@ +import { JWT_CLAIMS } from '@/common/types'; +import * as Sentry from '@sentry/react'; +import { parseJwt } from './utils'; + +export const SENTRY_INIT_OPTIONS: Sentry.BrowserOptions = { + dsn: import.meta.env['VITE_SENTRY_DSN'], + debug: import.meta.env.DEV, + environment: import.meta.env.MODE, + tracesSampleRate: import.meta.env.PROD ? 0.2 : 0, + enabled: import.meta.env.PROD, + integrations: [], +}; + +export const parseAndSetUserInSentry = (token: string) => { + try { + const decodedToken = parseJwt(token); + + Sentry.setUser({ + email: decodedToken[JWT_CLAIMS.EMAIL], + role: decodedToken[JWT_CLAIMS.USER_ROLE], + userId: decodedToken[JWT_CLAIMS.USER_ID], + }); + } catch (error) { + Sentry.captureException(error); + console.error('Error decoding token:', error); + } +}; diff --git a/web/src/main.tsx b/web/src/main.tsx index a63517493..a07b3d958 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -1,16 +1,18 @@ +import * as Sentry from '@sentry/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { createRouter, ErrorComponent, RouterProvider } from '@tanstack/react-router'; import React, { useContext } from 'react'; import ReactDOM from 'react-dom/client'; import { I18nextProvider } from 'react-i18next'; import { AlertDialogProvider } from './components/ui/alert-dialog-provider.tsx'; +import { TanStackReactQueryDevelopmentTools } from './components/utils/development-tools/TanStackReactQueryDevelopmentTools.tsx'; +import { TanStackRouterDevelopmentTools } from './components/utils/development-tools/TanStackRouterDevelopmentTools.tsx'; import AuthContextProvider, { AuthContext } from './context/auth.context'; +import { CurrentElectionRoundContext, CurrentElectionRoundStoreProvider } from './context/election-round.store.tsx'; import i18n from './i18n'; +import { SENTRY_INIT_OPTIONS } from './lib/sentry.ts'; import { routeTree } from './routeTree.gen.ts'; import './styles/tailwind.css'; -import { CurrentElectionRoundContext, CurrentElectionRoundStoreProvider } from './context/election-round.store.tsx'; -import { TanStackReactQueryDevelopmentTools } from './components/utils/development-tools/TanStackReactQueryDevelopmentTools.tsx'; -import { TanStackRouterDevelopmentTools } from './components/utils/development-tools/TanStackRouterDevelopmentTools.tsx'; export const queryClient = new QueryClient({ defaultOptions: { @@ -40,6 +42,8 @@ declare module '@tanstack/react-router' { } } +Sentry.init(SENTRY_INIT_OPTIONS); + function App() { const authContext = useContext(AuthContext); const currentElectionRoundContext = useContext(CurrentElectionRoundContext); From edd52df6f7d14bfb1e0533a9842f582605109f27 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 14 Mar 2025 12:56:24 +0200 Subject: [PATCH 15/21] WIP: update sentry config for testing purposes --- web/src/lib/sentry.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/web/src/lib/sentry.ts b/web/src/lib/sentry.ts index a42644b9d..4d1fcbe0f 100644 --- a/web/src/lib/sentry.ts +++ b/web/src/lib/sentry.ts @@ -7,7 +7,8 @@ export const SENTRY_INIT_OPTIONS: Sentry.BrowserOptions = { debug: import.meta.env.DEV, environment: import.meta.env.MODE, tracesSampleRate: import.meta.env.PROD ? 0.2 : 0, - enabled: import.meta.env.PROD, + enabled: !import.meta.env.PROD, + tracePropagationTargets: ['localhost'], integrations: [], }; From d653f52be0c8a517e8ef72563045a8b16430e103 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 14 Mar 2025 12:57:02 +0200 Subject: [PATCH 16/21] WIP: add Sentry logging to AuthContext --- web/src/context/auth.context.tsx | 42 ++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 15 deletions(-) diff --git a/web/src/context/auth.context.tsx b/web/src/context/auth.context.tsx index 2cfc44b3b..555ea5be4 100644 --- a/web/src/context/auth.context.tsx +++ b/web/src/context/auth.context.tsx @@ -1,8 +1,9 @@ import { ILoginResponse, LoginDTO, authApi } from '@/common/auth-api'; import { useToast } from '@/components/ui/use-toast'; +import { parseAndSetUserInSentry } from '@/lib/sentry'; import { parseJwt } from '@/lib/utils'; +import * as Sentry from '@sentry/react'; import { createContext, useEffect, useState } from 'react'; - export type AuthContextType = { signIn: (user: LoginDTO) => Promise; signOut: () => void; @@ -33,16 +34,19 @@ const AuthContextProvider = ({ children }: React.PropsWithChildren) => { const { toast } = useToast(); useEffect(() => { - const token = localStorage.getItem('token'); - setIsAuthenticated(!!token); - setIsLoading(false); - if (token) { - setToken(token); - const role = parseJwt(token)[`user-role`]; + try { + const token = localStorage.getItem('token'); + setIsAuthenticated(!!token); + setIsLoading(false); + if (token) { + setToken(token); + const role = parseJwt(token)[`user-role`]; - setUserRole(role); - setIsPlatformAdmin(role === 'PlatformAdmin'); - } + setUserRole(role); + setIsPlatformAdmin(role === 'PlatformAdmin'); + parseAndSetUserInSentry(token); + } + } catch (error) {} }, []); const signIn = async (user: LoginDTO): Promise => { @@ -53,6 +57,7 @@ const AuthContextProvider = ({ children }: React.PropsWithChildren) => { setToken(response.data.token); setUserRole(response.data.role); setIsPlatformAdmin(response.data.role === 'PlatformAdmin'); + parseAndSetUserInSentry(response.data.token); return true; } catch (error: any) { @@ -63,16 +68,23 @@ const AuthContextProvider = ({ children }: React.PropsWithChildren) => { variant: 'destructive', }); } + Sentry.captureException(error); return false; } }; const signOut = (): void => { - localStorage.removeItem('token'); - setIsAuthenticated(false); - setToken(''); - setUserRole(''); - setIsPlatformAdmin(false); + try { + localStorage.removeItem('token'); + setIsAuthenticated(false); + setToken(''); + setUserRole(''); + setIsPlatformAdmin(false); + Sentry.setUser(null); + } catch (error) { + Sentry.captureMessage(`Logout error`); + Sentry.captureException(error); + } }; return ( From 8ac63a9fb83a25ecca20e628624fa531e6903bc4 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 14 Mar 2025 16:27:34 +0200 Subject: [PATCH 17/21] WIP: add sentry vite plugin to upload source maps --- web/.env.example | 5 +- web/package.json | 1 + web/pnpm-lock.yaml | 222 +++++++++++++++++++++++++++++++++++++++++++++ web/vite.config.ts | 83 ++++++++++------- 4 files changed, 279 insertions(+), 32 deletions(-) diff --git a/web/.env.example b/web/.env.example index 35e261d38..a1691b4df 100644 --- a/web/.env.example +++ b/web/.env.example @@ -1,2 +1,5 @@ VITE_API_URL=https://localhost:7123/api/ -VITE_SENTRY_DSN=your-url-here \ No newline at end of file +VITE_SENTRY_DSN= +VITE_SENTRY_ORG= +VITE_SENTRY_PROJECT= +VITE_SENTRY_AUTH_TOKEN= \ No newline at end of file diff --git a/web/package.json b/web/package.json index 1a67c749e..0c514f443 100644 --- a/web/package.json +++ b/web/package.json @@ -103,6 +103,7 @@ "@heroicons/react": "^2.2.0", "@hookform/devtools": "^4.3.3", "@playwright/test": "^1.50.1", + "@sentry/vite-plugin": "^3.2.2", "@tailwindcss/typography": "^0.5.16", "@tanstack/eslint-plugin-query": "^5.66.1", "@tanstack/react-table-devtools": "^8.21.2", diff --git a/web/pnpm-lock.yaml b/web/pnpm-lock.yaml index 5b3bc67b2..96b413b00 100644 --- a/web/pnpm-lock.yaml +++ b/web/pnpm-lock.yaml @@ -258,6 +258,9 @@ importers: '@playwright/test': specifier: ^1.50.1 version: 1.50.1 + '@sentry/vite-plugin': + specifier: ^3.2.2 + version: 3.2.2 '@tailwindcss/typography': specifier: ^0.5.16 version: 0.5.16(tailwindcss@3.4.17(ts-node@10.9.2(@swc/core@1.10.16(@swc/helpers@0.5.15))(@types/node@20.5.1)(typescript@5.7.3))) @@ -1863,10 +1866,64 @@ packages: resolution: {integrity: sha512-fBBNimElAnu865HT3MJ6xH2P26KvkZvAYt+yRrWr+x5zS5KvjBYUPsSI+F0FTE14XmLW9q7DlNUl5iAZhXSy3g==} engines: {node: '>=18'} + '@sentry/babel-plugin-component-annotate@3.2.2': + resolution: {integrity: sha512-D+SKQ266ra/wo87s9+UI/rKQi3qhGPCR8eSCDe0VJudhjHsqyNU+JJ5lnIGCgmZaWFTXgdBP/gdr1Iz1zqGs4Q==} + engines: {node: '>= 14'} + '@sentry/browser@9.5.0': resolution: {integrity: sha512-HYSPW8GjknuYykJgOialKFyWg7ldmrbD1AKTIhksqdsNXLER07YeVWFAbe+xSYa1ZwwC8/s6vQJP9ZOoH1BaVg==} engines: {node: '>=18'} + '@sentry/bundler-plugin-core@3.2.2': + resolution: {integrity: sha512-YGrtmqQ2jMixccX2slVG/Lw7pCGJL3DGB3clmY9mO8QBEBIN3/gEANiHJVWwRidpUOS/0b7yVVGAdwZ87oPwTg==} + engines: {node: '>= 14'} + + '@sentry/cli-darwin@2.42.2': + resolution: {integrity: sha512-GtJSuxER7Vrp1IpxdUyRZzcckzMnb4N5KTW7sbTwUiwqARRo+wxS+gczYrS8tdgtmXs5XYhzhs+t4d52ITHMIg==} + engines: {node: '>=10'} + os: [darwin] + + '@sentry/cli-linux-arm64@2.42.2': + resolution: {integrity: sha512-BOxzI7sgEU5Dhq3o4SblFXdE9zScpz6EXc5Zwr1UDZvzgXZGosUtKVc7d1LmkrHP8Q2o18HcDWtF3WvJRb5Zpw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux, freebsd] + + '@sentry/cli-linux-arm@2.42.2': + resolution: {integrity: sha512-7udCw+YL9lwq+9eL3WLspvnuG+k5Icg92YE7zsteTzWLwgPVzaxeZD2f8hwhsu+wmL+jNqbpCRmktPteh3i2mg==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux, freebsd] + + '@sentry/cli-linux-i686@2.42.2': + resolution: {integrity: sha512-Sw/dQp5ZPvKnq3/y7wIJyxTUJYPGoTX/YeMbDs8BzDlu9to2LWV3K3r7hE7W1Lpbaw4tSquUHiQjP5QHCOS7aQ==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [linux, freebsd] + + '@sentry/cli-linux-x64@2.42.2': + resolution: {integrity: sha512-mU4zUspAal6TIwlNLBV5oq6yYqiENnCWSxtSQVzWs0Jyq97wtqGNG9U+QrnwjJZ+ta/hvye9fvL2X25D/RxHQw==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux, freebsd] + + '@sentry/cli-win32-i686@2.42.2': + resolution: {integrity: sha512-iHvFHPGqgJMNqXJoQpqttfsv2GI3cGodeTq4aoVLU/BT3+hXzbV0x1VpvvEhncJkDgDicJpFLM8sEPHb3b8abw==} + engines: {node: '>=10'} + cpu: [x86, ia32] + os: [win32] + + '@sentry/cli-win32-x64@2.42.2': + resolution: {integrity: sha512-vPPGHjYoaGmfrU7xhfFxG7qlTBacroz5NdT+0FmDn6692D8IvpNXl1K+eV3Kag44ipJBBeR8g1HRJyx/F/9ACw==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + + '@sentry/cli@2.42.2': + resolution: {integrity: sha512-spb7S/RUumCGyiSTg8DlrCX4bivCNmU/A1hcfkwuciTFGu8l5CDc2I6jJWWZw8/0enDGxuj5XujgXvU5tr4bxg==} + engines: {node: '>= 10'} + hasBin: true + '@sentry/core@9.5.0': resolution: {integrity: sha512-NMqyFdyg26ECAfnibAPKT8vvAt4zXp4R7dYtQnwJKhEJEVkgAshcNYeJ2D95ZLMVOqlqhTtTPnw1vqf+v9ePZg==} engines: {node: '>=18'} @@ -1877,6 +1934,10 @@ packages: peerDependencies: react: ^16.14.0 || 17.x || 18.x || 19.x + '@sentry/vite-plugin@3.2.2': + resolution: {integrity: sha512-WSkHOhZszMrIE9zmx2l4JhMnMlZmN/yAoHyf59pwFLIMctuZak6lNPbTbIFkFHDzIJ9Nut5RAVsw1qjmWc1PTA==} + engines: {node: '>= 14'} + '@sinclair/typebox@0.27.8': resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} @@ -3137,6 +3198,10 @@ packages: resolution: {integrity: sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q==} engines: {node: '>=8'} + dotenv@16.4.7: + resolution: {integrity: sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==} + engines: {node: '>=12'} + dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} @@ -3466,6 +3531,10 @@ packages: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported + glob@9.3.5: + resolution: {integrity: sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q==} + engines: {node: '>=16 || 14 >=14.17'} + global-dirs@0.1.1: resolution: {integrity: sha512-NknMLn7F2J7aflwFOlGdNIuCDpN3VGoSoB+aap3KABFWbHVn1TCgFC+np23J8W2BiZbjfEw3BFBycSMv1AFblg==} engines: {node: '>=4'} @@ -3990,6 +4059,10 @@ packages: magic-string@0.30.17: resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + magic-string@0.30.8: + resolution: {integrity: sha512-ISQTe55T2ao7XtlAStud6qwYPZjE4GK1S/BeVPus4jrq6JuOnQ00YKQC581RWhR122W7msZV263KzVeLoqidyQ==} + engines: {node: '>=12'} + make-dir@2.1.0: resolution: {integrity: sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==} engines: {node: '>=6'} @@ -4066,6 +4139,10 @@ packages: minimatch@3.1.2: resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + minimatch@8.0.4: + resolution: {integrity: sha512-W0Wvr9HyFXZRGIDgCicunpQ299OKXs9RgZfaukz4qAW/pJhcpUfupc9c+OObPOFueNy8VSrZgEmDtk6Kh4WzDA==} + engines: {node: '>=16 || 14 >=14.17'} + minimatch@9.0.3: resolution: {integrity: sha512-RHiac9mvaRw0x3AYRgDC1CxAP7HTcNrrECeA8YYJeWnpo+2Q5CegtZjaotWTWxDG3UeGA1coE05iH1mPjT/2mg==} engines: {node: '>=16 || 14 >=14.17'} @@ -4081,6 +4158,10 @@ packages: minimist@1.2.8: resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@4.2.8: + resolution: {integrity: sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ==} + engines: {node: '>=8'} + minipass@7.1.2: resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} engines: {node: '>=16 || 14 >=14.17'} @@ -4113,6 +4194,15 @@ packages: no-case@3.0.4: resolution: {integrity: sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-releases@2.0.19: resolution: {integrity: sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==} @@ -4375,6 +4465,10 @@ packages: resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + progress@2.0.3: + resolution: {integrity: sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==} + engines: {node: '>=0.4.0'} + prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} @@ -4999,6 +5093,9 @@ packages: resolution: {integrity: sha512-Loo5UUvLD9ScZ6jh8beX1T6sO1w2/MpCRpEP7V280GKMVUQ0Jzar2U3UJPsrdbziLEMMhu3Ujnq//rhiFuIeag==} engines: {node: '>=6'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tr46@4.1.1: resolution: {integrity: sha512-2lv/66T7e5yNyhAAC4NaKe5nVavzuGJQVVtRYLyQ2OI8tsJ61PMLlelehb0wi2Hx6+hT/OJUWZcw8MjlSRnxvw==} engines: {node: '>=14'} @@ -5120,6 +5217,9 @@ packages: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} + unplugin@1.0.1: + resolution: {integrity: sha512-aqrHaVBWW1JVKBHmGo33T5TxeL0qWzfvjWokObHA9bYmN7eNDkwOxmLjhioHl9878qDFMAaT51XNroRyuz7WxA==} + unplugin@2.2.0: resolution: {integrity: sha512-m1ekpSwuOT5hxkJeZGRxO7gXbXT3gF26NjQ7GdVHoLoF8/nopLcd/QfPigpCy7i51oFHiRJg/CyHhj4vs2+KGw==} engines: {node: '>=18.12.0'} @@ -5310,10 +5410,20 @@ packages: resolution: {integrity: sha512-d+BFHzbiCx6zGfz0HyQ6Rg69w9k19nviJspaj4yNscGjrHu94sVP+aRm75yEbCh+r2/yR+7q6hux9LVtbuTGBw==} engines: {node: '>=14'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + webidl-conversions@7.0.0: resolution: {integrity: sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==} engines: {node: '>=12'} + webpack-sources@3.2.3: + resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} + engines: {node: '>=10.13.0'} + + webpack-virtual-modules@0.5.0: + resolution: {integrity: sha512-kyDivFZ7ZM0BVOUteVbDFhlRt7Ah/CSPwJdi8hBpkK7QLumUqdLtVfm/PX/hkcnrvr0i77fO5+TjZ94Pe+C9iw==} + webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} @@ -5329,6 +5439,9 @@ packages: resolution: {integrity: sha512-Ed/LrqB8EPlGxjS+TrsXcpUond1mhccS3pchLhzSgPCnTimUCKj3IZE75pAs5m6heB2U2TMerKFUXheyHY+VDQ==} engines: {node: '>=14'} + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -6859,6 +6972,8 @@ snapshots: '@sentry-internal/browser-utils': 9.5.0 '@sentry/core': 9.5.0 + '@sentry/babel-plugin-component-annotate@3.2.2': {} + '@sentry/browser@9.5.0': dependencies: '@sentry-internal/browser-utils': 9.5.0 @@ -6867,6 +6982,60 @@ snapshots: '@sentry-internal/replay-canvas': 9.5.0 '@sentry/core': 9.5.0 + '@sentry/bundler-plugin-core@3.2.2': + dependencies: + '@babel/core': 7.26.9 + '@sentry/babel-plugin-component-annotate': 3.2.2 + '@sentry/cli': 2.42.2 + dotenv: 16.4.7 + find-up: 5.0.0 + glob: 9.3.5 + magic-string: 0.30.8 + unplugin: 1.0.1 + transitivePeerDependencies: + - encoding + - supports-color + + '@sentry/cli-darwin@2.42.2': + optional: true + + '@sentry/cli-linux-arm64@2.42.2': + optional: true + + '@sentry/cli-linux-arm@2.42.2': + optional: true + + '@sentry/cli-linux-i686@2.42.2': + optional: true + + '@sentry/cli-linux-x64@2.42.2': + optional: true + + '@sentry/cli-win32-i686@2.42.2': + optional: true + + '@sentry/cli-win32-x64@2.42.2': + optional: true + + '@sentry/cli@2.42.2': + dependencies: + https-proxy-agent: 5.0.1 + node-fetch: 2.7.0 + progress: 2.0.3 + proxy-from-env: 1.1.0 + which: 2.0.2 + optionalDependencies: + '@sentry/cli-darwin': 2.42.2 + '@sentry/cli-linux-arm': 2.42.2 + '@sentry/cli-linux-arm64': 2.42.2 + '@sentry/cli-linux-i686': 2.42.2 + '@sentry/cli-linux-x64': 2.42.2 + '@sentry/cli-win32-i686': 2.42.2 + '@sentry/cli-win32-x64': 2.42.2 + transitivePeerDependencies: + - encoding + - supports-color + '@sentry/core@9.5.0': {} '@sentry/react@9.5.0(react@18.3.1)': @@ -6876,6 +7045,14 @@ snapshots: hoist-non-react-statics: 3.3.2 react: 18.3.1 + '@sentry/vite-plugin@3.2.2': + dependencies: + '@sentry/bundler-plugin-core': 3.2.2 + unplugin: 1.0.1 + transitivePeerDependencies: + - encoding + - supports-color + '@sinclair/typebox@0.27.8': {} '@svgr/babel-plugin-add-jsx-attribute@8.0.0(@babel/core@7.26.9)': @@ -8280,6 +8457,8 @@ snapshots: dependencies: is-obj: 2.0.0 + dotenv@16.4.7: {} + dunder-proto@1.0.1: dependencies: call-bind-apply-helpers: 1.0.2 @@ -8818,6 +8997,13 @@ snapshots: once: 1.4.0 path-is-absolute: 1.0.1 + glob@9.3.5: + dependencies: + fs.realpath: 1.0.0 + minimatch: 8.0.4 + minipass: 4.2.8 + path-scurry: 1.11.1 + global-dirs@0.1.1: dependencies: ini: 1.3.8 @@ -9356,6 +9542,10 @@ snapshots: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 + magic-string@0.30.8: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + make-dir@2.1.0: dependencies: pify: 4.0.1 @@ -9429,6 +9619,10 @@ snapshots: dependencies: brace-expansion: 1.1.11 + minimatch@8.0.4: + dependencies: + brace-expansion: 2.0.1 + minimatch@9.0.3: dependencies: brace-expansion: 2.0.1 @@ -9445,6 +9639,8 @@ snapshots: minimist@1.2.8: {} + minipass@4.2.8: {} + minipass@7.1.2: {} mlly@1.7.4: @@ -9479,6 +9675,10 @@ snapshots: lower-case: 2.0.2 tslib: 2.8.1 + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-releases@2.0.19: {} normalize-package-data@2.5.0: @@ -9727,6 +9927,8 @@ snapshots: ansi-styles: 5.2.0 react-is: 18.3.1 + progress@2.0.3: {} + prop-types@15.8.1: dependencies: loose-envify: 1.4.0 @@ -10479,6 +10681,8 @@ snapshots: universalify: 0.2.0 url-parse: 1.5.10 + tr46@0.0.3: {} + tr46@4.1.1: dependencies: punycode: 2.3.1 @@ -10602,6 +10806,13 @@ snapshots: universalify@2.0.1: {} + unplugin@1.0.1: + dependencies: + acorn: 8.14.0 + chokidar: 3.6.0 + webpack-sources: 3.2.3 + webpack-virtual-modules: 0.5.0 + unplugin@2.2.0: dependencies: acorn: 8.14.0 @@ -10775,8 +10986,14 @@ snapshots: dependencies: xml-name-validator: 4.0.0 + webidl-conversions@3.0.1: {} + webidl-conversions@7.0.0: {} + webpack-sources@3.2.3: {} + + webpack-virtual-modules@0.5.0: {} + webpack-virtual-modules@0.6.2: {} whatwg-encoding@2.0.0: @@ -10790,6 +11007,11 @@ snapshots: tr46: 4.1.1 webidl-conversions: 7.0.0 + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 diff --git a/web/vite.config.ts b/web/vite.config.ts index 772b2742b..b4e3c8f38 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,33 +1,54 @@ -import { TanStackRouterVite } from "@tanstack/router-plugin/vite" -import react from "@vitejs/plugin-react-swc"; -import path from "node:path"; -import { normalizePath } from "vite"; -import { viteStaticCopy } from "vite-plugin-static-copy"; -import { defineConfig } from "vitest/config"; - +import { sentryVitePlugin } from '@sentry/vite-plugin'; +import { TanStackRouterVite } from '@tanstack/router-plugin/vite'; +import react from '@vitejs/plugin-react-swc'; +import path from 'node:path'; +import { loadEnv, normalizePath } from 'vite'; +import { viteStaticCopy } from 'vite-plugin-static-copy'; +import { defineConfig } from 'vitest/config'; // https://vitejs.dev/config/ -export default defineConfig({ - plugins: [react(), TanStackRouterVite(), - viteStaticCopy({ - targets: [ - { - src: normalizePath(path.resolve('./src/assets/locales')), - dest: normalizePath(path.resolve('./dist')) - } - ] - })], - server: { - host: true, - strictPort: true, - }, - resolve: { - alias: { - '@': path.resolve(__dirname, './src'), + +export default defineConfig(({ mode }) => { + // Load env file based on `mode` in the current working directory. + // Set the third parameter to '' to load all env regardless of the + // `VITE_` prefix. + const env = loadEnv(mode, process.cwd(), ''); + return { + // vite config + + build: { + sourcemap: true, + }, + plugins: [ + react(), + TanStackRouterVite(), + viteStaticCopy({ + targets: [ + { + src: normalizePath(path.resolve('./src/assets/locales')), + dest: normalizePath(path.resolve('./dist')), + }, + ], + }), + sentryVitePlugin({ + org: env.VITE_SENTRY_ORG, + project: env.VITE_SENTRY_PROJECT, + authToken: env.VITE_SENTRY_AUTH_TOKEN, + sourcemaps: { filesToDeleteAfterUpload: ['dist/assets/**.js.map'] }, + }), + ], + server: { + host: true, + strictPort: true, + }, + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + test: { + environment: 'jsdom', + setupFiles: ['./vitest.setup.ts'], + css: true, }, - }, - test: { - environment: "jsdom", - setupFiles: ["./vitest.setup.ts"], - css: true, - }, -}); \ No newline at end of file + }; +}); From c8ba193bf04788625c87d4121a194ebc5379c3e3 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Mon, 17 Mar 2025 11:24:55 +0200 Subject: [PATCH 18/21] WIP: add tracing integrations --- web/src/lib/sentry.ts | 20 +++++++++----------- web/src/main.tsx | 12 ++++++++++-- 2 files changed, 19 insertions(+), 13 deletions(-) diff --git a/web/src/lib/sentry.ts b/web/src/lib/sentry.ts index 4d1fcbe0f..d8b1c9416 100644 --- a/web/src/lib/sentry.ts +++ b/web/src/lib/sentry.ts @@ -1,17 +1,8 @@ -import { JWT_CLAIMS } from '@/common/types'; +import { JWT_CLAIMS, ProblemDetails } from '@/common/types'; import * as Sentry from '@sentry/react'; +import { AxiosError } from 'axios'; import { parseJwt } from './utils'; -export const SENTRY_INIT_OPTIONS: Sentry.BrowserOptions = { - dsn: import.meta.env['VITE_SENTRY_DSN'], - debug: import.meta.env.DEV, - environment: import.meta.env.MODE, - tracesSampleRate: import.meta.env.PROD ? 0.2 : 0, - enabled: !import.meta.env.PROD, - tracePropagationTargets: ['localhost'], - integrations: [], -}; - export const parseAndSetUserInSentry = (token: string) => { try { const decodedToken = parseJwt(token); @@ -26,3 +17,10 @@ export const parseAndSetUserInSentry = (token: string) => { console.error('Error decoding token:', error); } }; + +type ReportedError = Error | AxiosError; + +export const sendErrorToSentry = (error: ReportedError, message: string) => { + Sentry.captureMessage(message, 'error'); + Sentry.captureException(error); +}; diff --git a/web/src/main.tsx b/web/src/main.tsx index a07b3d958..df971cac1 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -10,7 +10,6 @@ import { TanStackRouterDevelopmentTools } from './components/utils/development-t import AuthContextProvider, { AuthContext } from './context/auth.context'; import { CurrentElectionRoundContext, CurrentElectionRoundStoreProvider } from './context/election-round.store.tsx'; import i18n from './i18n'; -import { SENTRY_INIT_OPTIONS } from './lib/sentry.ts'; import { routeTree } from './routeTree.gen.ts'; import './styles/tailwind.css'; @@ -42,7 +41,16 @@ declare module '@tanstack/react-router' { } } -Sentry.init(SENTRY_INIT_OPTIONS); +Sentry.init({ + dsn: import.meta.env['VITE_SENTRY_DSN'], + debug: import.meta.env.DEV, + environment: import.meta.env.MODE, + tracesSampleRate: import.meta.env.PROD ? 0.2 : 0, + enabled: !import.meta.env.PROD, + tracePropagationTargets: ['localhost'], + integrations: [Sentry.browserTracingIntegration(), Sentry.tanstackRouterBrowserTracingIntegration(router)], + normalizeDepth: 5, +}); function App() { const authContext = useContext(AuthContext); From 02e0251aab96af54e3c2669eaee46301d2e53d93 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Mon, 17 Mar 2025 16:01:48 +0200 Subject: [PATCH 19/21] WIP: send errors to sentry --- web/src/common/types.ts | 3 +++ .../LocationsDashboard/LocationsDashboard.tsx | 21 ++++++++++++------- .../components/LocationsDashboard/hooks.tsx | 10 ++++----- .../usePasswordSetterDialog.ts | 5 ++++- .../CreatePollingStationDialog.tsx | 9 +++++--- .../PollingStationsDashboard.tsx | 21 ++++++++++++------- .../PollingStationsDashboard/hooks.tsx | 10 ++++----- web/src/features/auth/AcceptInvite.tsx | 18 +++++++++------- .../components/Guides/AddGuideForm.tsx | 11 ++++++---- .../components/Guides/EditGuideForm.tsx | 10 +++++---- .../components/Guides/GuidesDashboard.tsx | 15 +++++++------ .../CreateElectionRoundDialog.tsx | 8 +++++-- .../ElectionRoundEdit/ElectionRoundEdit.tsx | 12 ++++++++--- .../components/Dashboard/Dashboard.tsx | 21 ++++++++++++------- .../FormTemplateEdit/FormTemplateEdit.tsx | 10 ++++++--- .../FormTemplateNew/FormTemplateNew.tsx | 3 ++- web/src/lib/sentry.ts | 12 ++++++----- 17 files changed, 129 insertions(+), 70 deletions(-) diff --git a/web/src/common/types.ts b/web/src/common/types.ts index f31ff2869..6226ec9b3 100644 --- a/web/src/common/types.ts +++ b/web/src/common/types.ts @@ -1,5 +1,7 @@ import { isNilOrWhitespace, isNotNilOrWhitespace } from '@/lib/utils'; +import { AxiosError } from 'axios'; import { z } from 'zod'; +import { ProblemDetails } from './types'; export type FunctionComponent = React.ReactElement | null; @@ -426,3 +428,4 @@ export interface ProblemDetails { instance?: string; errors?: { name: string; reason: string }[]; // Maps field names to error messages } +export type ReportedError = Error | AxiosError; diff --git a/web/src/components/LocationsDashboard/LocationsDashboard.tsx b/web/src/components/LocationsDashboard/LocationsDashboard.tsx index 4016fb55b..b60e820a8 100644 --- a/web/src/components/LocationsDashboard/LocationsDashboard.tsx +++ b/web/src/components/LocationsDashboard/LocationsDashboard.tsx @@ -1,7 +1,7 @@ import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; import { ColumnDef } from '@tanstack/react-table'; -import { ElectionRoundStatus, type Location } from '@/common/types'; +import { ElectionRoundStatus, ReportedError, type Location } from '@/common/types'; import { LocationsFilters } from '@/components/LocationsFilters/LocationsFilters'; import { FilterBadge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -14,6 +14,7 @@ import { ExportDataButton } from '@/features/responses/components/ExportDataButt import { ExportedDataType } from '@/features/responses/models/data-export'; import { locationsKeys } from '@/hooks/locations-levels'; import i18n from '@/i18n'; +import { sendErrorToSentry } from '@/lib/sentry'; import { queryClient } from '@/main'; import { ArrowUpTrayIcon, FunnelIcon } from '@heroicons/react/24/outline'; import { Link, useNavigate, useRouter, useSearch } from '@tanstack/react-router'; @@ -49,11 +50,14 @@ export default function LocationsDashboard(): ReactElement { description: 'Location deleted', }); }, - onError: () => + onError: (error: ReportedError) => { + const title = 'Error occured when deleting location'; + sendErrorToSentry({ error, title }); toast({ - title: 'Error occured when deleting location', + title, variant: 'destructive', - }), + }); + }, }), [currentElectionRoundId, deleteLocationMutation] ); @@ -73,11 +77,14 @@ export default function LocationsDashboard(): ReactElement { description: 'Location updated', }); }, - onError: () => + onError: (error: ReportedError) => { + const title = 'Error occured when updating location'; + sendErrorToSentry({ error, title }); toast({ - title: 'Error occured when updating location', + title, variant: 'destructive', - }), + }); + }, }), [currentElectionRoundId, updateLocationMutation] ); diff --git a/web/src/components/LocationsDashboard/hooks.tsx b/web/src/components/LocationsDashboard/hooks.tsx index 29bf4db1d..3d7586c4b 100644 --- a/web/src/components/LocationsDashboard/hooks.tsx +++ b/web/src/components/LocationsDashboard/hooks.tsx @@ -1,5 +1,5 @@ import { authApi } from '@/common/auth-api'; -import { DataTableParameters, PageResponse, Location } from '@/common/types'; +import { DataTableParameters, Location, PageResponse, ReportedError } from '@/common/types'; import { locationsKeys } from '@/hooks/locations-levels'; import { buildURLSearchParams } from '@/lib/utils'; import { useMutation, useQuery, UseQueryResult } from '@tanstack/react-query'; @@ -48,14 +48,14 @@ export function useUpdateLocationMutation() { locationId: string; location: Location; onSuccess?: () => void; - onError?: () => void; + onError?: (error: ReportedError) => void; }) => { return authApi.put(`/election-rounds/${electionRoundId}/locations/${locationId}`, location); }, onSuccess: (_, { onSuccess }) => onSuccess?.(), - onError: (_, { onError }) => onError?.(), + onError: (error: ReportedError, { onError }) => onError?.(error), }); } @@ -68,13 +68,13 @@ export function useDeleteLocationMutation() { electionRoundId: string; locationId: string; onSuccess?: () => void; - onError?: () => void; + onError?: (error: ReportedError) => void; }) => { return authApi.delete(`/election-rounds/${electionRoundId}/locations/${locationId}`); }, onSuccess: (_, { onSuccess }) => onSuccess?.(), - onError: (_, { onError }) => onError?.(), + onError: (error: ReportedError, { onError }) => onError?.(error), }); } diff --git a/web/src/components/PasswordSetterDialog/usePasswordSetterDialog.ts b/web/src/components/PasswordSetterDialog/usePasswordSetterDialog.ts index 3e16b3a31..df314d076 100644 --- a/web/src/components/PasswordSetterDialog/usePasswordSetterDialog.ts +++ b/web/src/components/PasswordSetterDialog/usePasswordSetterDialog.ts @@ -1,6 +1,7 @@ import { authApi } from '@/common/auth-api'; import { addFormValidationErrorsFromBackend } from '@/common/form-backend-validation'; import { ProblemDetails } from '@/common/types'; +import { sendErrorToSentry } from '@/lib/sentry'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation } from '@tanstack/react-query'; import { AxiosError } from 'axios'; @@ -77,9 +78,11 @@ export const usePasswordSetterDialog = () => { }, onError: (error: AxiosError) => { + const title = 'Error setting password'; addFormValidationErrorsFromBackend(form, error); + sendErrorToSentry({ error, title }); toast({ - title: 'Error setting password', + title, description: 'Please contact Platform admins', variant: 'destructive', }); diff --git a/web/src/components/PollingStationsDashboard/CreatePollingStationDialog.tsx b/web/src/components/PollingStationsDashboard/CreatePollingStationDialog.tsx index c41ef1367..e76ac5eb4 100644 --- a/web/src/components/PollingStationsDashboard/CreatePollingStationDialog.tsx +++ b/web/src/components/PollingStationsDashboard/CreatePollingStationDialog.tsx @@ -1,5 +1,5 @@ import { authApi } from '@/common/auth-api'; -import { importPollingStationSchema } from '@/common/types'; +import { importPollingStationSchema, ReportedError } from '@/common/types'; import { Button } from '@/components/ui/button'; import { Dialog, DialogClose, DialogContent, DialogFooter, DialogTitle } from '@/components/ui/dialog'; import { Form, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; @@ -8,6 +8,7 @@ import { toast } from '@/components/ui/use-toast'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; import { ImportPollingStationRow } from '@/features/polling-stations/PollingStationsImport/PollingStationsImport'; import { pollingStationsKeys } from '@/hooks/polling-stations-levels'; +import { sendErrorToSentry } from '@/lib/sentry'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { useForm } from 'react-hook-form'; @@ -45,9 +46,11 @@ function CreatePollingStationDialog({ open, onOpenChange }: CreatePollingStation form.reset({}); onOpenChange(false); }, - onError: (err) => { + onError: (error: ReportedError) => { + const title = t('addPollingStation.onError'); + sendErrorToSentry({ error, title }); toast({ - title: t('addPollingStation.onError'), + title, description: 'Please contact tech support', variant: 'destructive', }); diff --git a/web/src/components/PollingStationsDashboard/PollingStationsDashboard.tsx b/web/src/components/PollingStationsDashboard/PollingStationsDashboard.tsx index 20ecf18d0..339eeacb7 100644 --- a/web/src/components/PollingStationsDashboard/PollingStationsDashboard.tsx +++ b/web/src/components/PollingStationsDashboard/PollingStationsDashboard.tsx @@ -1,7 +1,7 @@ import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; import { ColumnDef } from '@tanstack/react-table'; -import { ElectionRoundStatus, type PollingStation } from '@/common/types'; +import { ElectionRoundStatus, ReportedError, type PollingStation } from '@/common/types'; import { PollingStationsFilters } from '@/components/PollingStationsFilters/PollingStationsFilters'; import { FilterBadge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; @@ -14,6 +14,7 @@ import { ExportDataButton } from '@/features/responses/components/ExportDataButt import { ExportedDataType } from '@/features/responses/models/data-export'; import { pollingStationsKeys } from '@/hooks/polling-stations-levels'; import i18n from '@/i18n'; +import { sendErrorToSentry } from '@/lib/sentry'; import { queryClient } from '@/main'; import { ArrowUpTrayIcon, FunnelIcon } from '@heroicons/react/24/outline'; import { Link, useNavigate, useRouter, useSearch } from '@tanstack/react-router'; @@ -52,11 +53,14 @@ export default function PollingStationsDashboard(): ReactElement { description: 'Polling station deleted', }); }, - onError: () => + onError: (error: ReportedError) => { + const title = 'Error occured when deleting polling station'; + sendErrorToSentry({ error, title }); toast({ - title: 'Error occured when deleting polling station', + title, variant: 'destructive', - }), + }); + }, }), [currentElectionRoundId, deletePollingStationMutation] ); @@ -76,11 +80,14 @@ export default function PollingStationsDashboard(): ReactElement { description: 'Polling station updated', }); }, - onError: () => + onError: (error: ReportedError) => { + const title = 'Error occured when updating polling station'; + sendErrorToSentry({ error, title }); toast({ - title: 'Error occured when updating polling station', + title, variant: 'destructive', - }), + }); + }, }), [currentElectionRoundId, updatePollingStationMutation] ); diff --git a/web/src/components/PollingStationsDashboard/hooks.tsx b/web/src/components/PollingStationsDashboard/hooks.tsx index 4571604c7..fac5dc266 100644 --- a/web/src/components/PollingStationsDashboard/hooks.tsx +++ b/web/src/components/PollingStationsDashboard/hooks.tsx @@ -1,5 +1,5 @@ import { authApi } from '@/common/auth-api'; -import { DataTableParameters, PageResponse, PollingStation } from '@/common/types'; +import { DataTableParameters, PageResponse, PollingStation, ReportedError } from '@/common/types'; import { pollingStationsKeys } from '@/hooks/polling-stations-levels'; import { buildURLSearchParams } from '@/lib/utils'; import { useMutation, useQuery, UseQueryResult } from '@tanstack/react-query'; @@ -51,7 +51,7 @@ export function useUpdatePollingStationMutation() { pollingStationId: string; pollingStation: PollingStation; onSuccess?: () => void; - onError?: () => void; + onError?: (error: ReportedError) => void; }) => { return authApi.put( `/election-rounds/${electionRoundId}/polling-stations/${pollingStationId}`, @@ -61,7 +61,7 @@ export function useUpdatePollingStationMutation() { onSuccess: (_, { onSuccess }) => onSuccess?.(), - onError: (_, { onError }) => onError?.(), + onError: (error: ReportedError, { onError }) => onError?.(error), }); } @@ -74,13 +74,13 @@ export function useDeletePollingStationMutation() { electionRoundId: string; pollingStationId: string; onSuccess?: () => void; - onError?: () => void; + onError?: (error: ReportedError) => void; }) => { return authApi.delete(`/election-rounds/${electionRoundId}/polling-stations/${pollingStationId}`); }, onSuccess: (_, { onSuccess }) => onSuccess?.(), - onError: (_, { onError }) => onError?.(), + onError: (error: ReportedError, { onError }) => onError?.(error), }); } diff --git a/web/src/features/auth/AcceptInvite.tsx b/web/src/features/auth/AcceptInvite.tsx index ae69956a0..1274cd87f 100644 --- a/web/src/features/auth/AcceptInvite.tsx +++ b/web/src/features/auth/AcceptInvite.tsx @@ -2,16 +2,18 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; +import { noAuthApi } from '@/common/no-auth-api'; +import { ReportedError } from '@/common/types'; +import Logo from '@/components/layout/Header/Logo'; import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; -import { useNavigate } from '@tanstack/react-router'; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card'; -import Logo from '@/components/layout/Header/Logo'; +import { toast } from '@/components/ui/use-toast'; +import { sendErrorToSentry } from '@/lib/sentry'; import { Route as AcceptInviteRoute } from '@/routes/accept-invite/index'; import { useMutation } from '@tanstack/react-query'; -import { noAuthApi } from '@/common/no-auth-api'; -import { toast } from '@/components/ui/use-toast'; +import { useNavigate } from '@tanstack/react-router'; const formSchema = z .object({ @@ -60,9 +62,11 @@ function AcceptInvite() { navigate({ to: '/accept-invite/success' }); }, - onError: () => { + onError: (error: ReportedError) => { + const title = 'Error accepting invite'; + sendErrorToSentry({ error, title }); toast({ - title: 'Error accepting invite', + title, description: 'Please contact tech support', variant: 'destructive', }); diff --git a/web/src/features/election-event/components/Guides/AddGuideForm.tsx b/web/src/features/election-event/components/Guides/AddGuideForm.tsx index 2067a2bfb..ea04aa507 100644 --- a/web/src/features/election-event/components/Guides/AddGuideForm.tsx +++ b/web/src/features/election-event/components/Guides/AddGuideForm.tsx @@ -1,5 +1,5 @@ import { authApi } from '@/common/auth-api'; -import { FunctionComponent } from '@/common/types'; +import { FunctionComponent, ReportedError } from '@/common/types'; import { RichTextEditor } from '@/components/rich-text-editor'; import { useConfirm } from '@/components/ui/alert-dialog-provider'; import { FileUploader } from '@/components/ui/file-uploader'; @@ -7,7 +7,8 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from ' import { Input } from '@/components/ui/input'; import { toast } from '@/components/ui/use-toast'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; -import { isNilOrWhitespace, isNotNilOrWhitespace } from '@/lib/utils'; +import { sendErrorToSentry } from '@/lib/sentry'; +import { isNilOrWhitespace } from '@/lib/utils'; import { queryClient } from '@/main'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation } from '@tanstack/react-query'; @@ -145,12 +146,14 @@ export default function AddGuideForm({ }); }, - onError: () => { + onError: (error: ReportedError) => { + const title = 'Error uploading citizen guide'; toast({ - title: 'Error uploading citizen guide', + title, description: 'Please contact Platform admins', variant: 'destructive', }); + sendErrorToSentry({ error, title }); onError?.(); }, diff --git a/web/src/features/election-event/components/Guides/EditGuideForm.tsx b/web/src/features/election-event/components/Guides/EditGuideForm.tsx index 4ea102932..7daf293f8 100644 --- a/web/src/features/election-event/components/Guides/EditGuideForm.tsx +++ b/web/src/features/election-event/components/Guides/EditGuideForm.tsx @@ -1,11 +1,12 @@ import { authApi } from '@/common/auth-api'; -import { FunctionComponent } from '@/common/types'; +import { FunctionComponent, ReportedError } from '@/common/types'; import { RichTextEditor } from '@/components/rich-text-editor'; import { useConfirm } from '@/components/ui/alert-dialog-provider'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { useToast } from '@/components/ui/use-toast'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; +import { sendErrorToSentry } from '@/lib/sentry'; import { isNilOrWhitespace, isNotNilOrWhitespace } from '@/lib/utils'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useQueryClient, useSuspenseQuery } from '@tanstack/react-query'; @@ -136,11 +137,12 @@ export default function EditGuideForm({ }); }, - onError: () => { + onError: (error: ReportedError) => { onError?.(); - + const title = 'Error updating guide'; + sendErrorToSentry({ error, title }); toast({ - title: 'Error updating guide', + title, description: 'Please contact Platform admins', variant: 'destructive', }); diff --git a/web/src/features/election-event/components/Guides/GuidesDashboard.tsx b/web/src/features/election-event/components/Guides/GuidesDashboard.tsx index ee9dba0fb..c3c4a75fa 100644 --- a/web/src/features/election-event/components/Guides/GuidesDashboard.tsx +++ b/web/src/features/election-event/components/Guides/GuidesDashboard.tsx @@ -16,24 +16,25 @@ import { format } from 'date-fns'; import { authApi } from '@/common/auth-api'; import { DateTimeFormat } from '@/common/formats'; +import { ElectionRoundStatus, ReportedError } from '@/common/types'; import { useConfirm } from '@/components/ui/alert-dialog-provider'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import { toast } from '@/components/ui/use-toast'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; import i18n from '@/i18n'; +import { sendErrorToSentry } from '@/lib/sentry'; import { queryClient } from '@/main'; import { useMutation } from '@tanstack/react-query'; import { Link, useNavigate } from '@tanstack/react-router'; import { ChevronDown } from 'lucide-react'; import { useMemo, useState } from 'react'; import { citizenGuidesKeys, useCitizenGuides } from '../../hooks/citizen-guides-hooks'; +import { useElectionRoundDetails } from '../../hooks/election-event-hooks'; import { observerGuidesKeys, useObserverGuides } from '../../hooks/observer-guides-hooks'; import { GuideModel, GuidePageType, GuideType } from '../../models/guide'; import AddGuideDialog from './AddGuideDialog'; -import EditGuideDialog from './EditGuideDialog'; -import { useElectionRoundDetails } from '../../hooks/election-event-hooks'; -import { ElectionRoundStatus } from '@/common/types'; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; import EditGuideAccessDialog, { useEditGuideAccessDialog } from './EditGuideAccessDialog'; +import EditGuideDialog from './EditGuideDialog'; export interface GuidesDashboardProps { guidePageType: GuidePageType; @@ -95,9 +96,11 @@ export default function GuidesDashboard({ guidePageType }: GuidesDashboardProps) }); }, - onError: () => { + onError: (error: ReportedError) => { + const title = 'Error deleting guide'; + sendErrorToSentry({ error, title }); toast({ - title: 'Error deleting guide', + title, description: 'Please contact Platform admins', variant: 'destructive', }); diff --git a/web/src/features/election-rounds/components/CreateElectionRoundDialog/CreateElectionRoundDialog.tsx b/web/src/features/election-rounds/components/CreateElectionRoundDialog/CreateElectionRoundDialog.tsx index 03e49d353..1c733cb98 100644 --- a/web/src/features/election-rounds/components/CreateElectionRoundDialog/CreateElectionRoundDialog.tsx +++ b/web/src/features/election-rounds/components/CreateElectionRoundDialog/CreateElectionRoundDialog.tsx @@ -4,6 +4,7 @@ import { Button } from '@/components/ui/button'; import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'; import { Separator } from '@/components/ui/separator'; import { toast } from '@/components/ui/use-toast'; +import { sendErrorToSentry } from '@/lib/sentry'; import { queryClient } from '@/main'; import { useMutation } from '@tanstack/react-query'; import { useRouter } from '@tanstack/react-router'; @@ -11,6 +12,7 @@ import { format } from 'date-fns/format'; import { ElectionRoundModel } from '../../models/types'; import { electionRoundKeys } from '../../queries'; import ElectionRoundForm, { ElectionRoundRequest } from '../ElectionRoundForm/ElectionRoundForm'; +import { ReportedError } from '@/common/types'; export interface ElectionRoundFormProps { open: boolean; @@ -43,9 +45,11 @@ function CreateElectionRoundDialog({ open, onOpenChange }: ElectionRoundFormProp }); }, - onError: () => { + onError: (error: ReportedError) => { + const title = 'Error creating election round'; + sendErrorToSentry({ error, title }); toast({ - title: 'Error creating election round', + title, description: 'Please contact Platform admins', variant: 'destructive', }); diff --git a/web/src/features/election-rounds/components/ElectionRoundEdit/ElectionRoundEdit.tsx b/web/src/features/election-rounds/components/ElectionRoundEdit/ElectionRoundEdit.tsx index 20765027d..662a9ed93 100644 --- a/web/src/features/election-rounds/components/ElectionRoundEdit/ElectionRoundEdit.tsx +++ b/web/src/features/election-rounds/components/ElectionRoundEdit/ElectionRoundEdit.tsx @@ -1,10 +1,12 @@ import { authApi } from '@/common/auth-api'; import { DateOnlyFormat } from '@/common/formats'; +import { ReportedError } from '@/common/types'; import Layout from '@/components/layout/Layout'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { toast } from '@/components/ui/use-toast'; +import { sendErrorToSentry } from '@/lib/sentry'; import { queryClient } from '@/main'; import { Route } from '@/routes/election-rounds/$electionRoundId/edit'; import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; @@ -46,9 +48,11 @@ function ElectionRoundEdit() { }); }, - onError: () => { + onError: (error: ReportedError) => { + const title = 'Error creating election round'; + sendErrorToSentry({ error, title }); toast({ - title: 'Error creating election round', + title, description: 'Please contact Platform admins', variant: 'destructive', }); @@ -71,7 +75,9 @@ function ElectionRoundEdit() {
- +