From 0fe3fbe947c1d0d031bdaa93d9e1661fe55e5622 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 6 Sep 2024 09:07:50 +0300 Subject: [PATCH 01/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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/17] 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 7dbc8a487af3479e28f02d18bec13254ef222468 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Tue, 29 Oct 2024 18:21:05 +0200 Subject: [PATCH 11/17] WIP: rework incident reports filters by entry and observer --- .../filtering/components/ActiveFilters.tsx | 15 +- .../IncidentReportsLocationTypeFilter.tsx | 34 ++ web/src/features/filtering/filtering-enums.ts | 14 +- .../IncidentReportsByObserverTable.tsx | 4 +- .../IncidentReportsFiltersByEntry.tsx | 349 +----------------- .../IncidentReportsFiltersByObserver.tsx | 125 +------ .../IncidentReportsTab/IncidentReportsTab.tsx | 4 +- .../responses/models/search-params.ts | 21 +- 8 files changed, 93 insertions(+), 473 deletions(-) create mode 100644 web/src/features/filtering/components/IncidentReportsLocationTypeFilter.tsx diff --git a/web/src/features/filtering/components/ActiveFilters.tsx b/web/src/features/filtering/components/ActiveFilters.tsx index 13fe5ffb1..2286f4dda 100644 --- a/web/src/features/filtering/components/ActiveFilters.tsx +++ b/web/src/features/filtering/components/ActiveFilters.tsx @@ -2,18 +2,17 @@ import { DateTimeFormat } from '@/common/formats'; import { FilterBadge } from '@/components/ui/badge'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; import { useFormSubmissionsFilters } from '@/features/responses/hooks/form-submissions-queries'; -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 { isNotNilOrWhitespace, toBoolean } from '@/lib/utils'; import { mapFormSubmissionFollowUpStatus, mapIncidentCategory, + mapIncidentReportLocationType, mapQuickReportFollowUpStatus, - mapQuickReportLocationType, } from '@/features/responses/utils/helpers'; -import { QuickReportFollowUpStatus } from '@/common/types'; +import { isNotNilOrWhitespace, toBoolean } from '@/lib/utils'; +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'; interface ActiveFilterProps { filterId: string; @@ -59,12 +58,14 @@ const FILTER_LABELS = new Map([ [FILTER_KEY.QuickReportIncidentCategory, FILTER_LABEL.QuickReportIncidentCategory], [FILTER_KEY.QuickReportFollowUpStatus, FILTER_LABEL.QuickReportFollowUpStatus], [FILTER_KEY.HasQuickReports, FILTER_LABEL.HasQuickReports], + [FILTER_KEY.IncidentReportLocationType, FILTER_LABEL.IncidentReportLocationType], ]); const FILTER_VALUE_LOCALIZATORS = new Map string>([ [FILTER_KEY.QuickReportFollowUpStatus, mapQuickReportFollowUpStatus], [FILTER_KEY.FormSubmissionFollowUpStatus, mapFormSubmissionFollowUpStatus], [FILTER_KEY.QuickReportIncidentCategory, mapIncidentCategory], + [FILTER_KEY.IncidentReportLocationType, mapIncidentReportLocationType], ]); const ActiveFilter: FC = ({ filterId, value, isArray }) => { diff --git a/web/src/features/filtering/components/IncidentReportsLocationTypeFilter.tsx b/web/src/features/filtering/components/IncidentReportsLocationTypeFilter.tsx new file mode 100644 index 000000000..ba283d491 --- /dev/null +++ b/web/src/features/filtering/components/IncidentReportsLocationTypeFilter.tsx @@ -0,0 +1,34 @@ +import { SelectFilter, SelectFilterOption } from '@/features/filtering/components/SelectFilter'; +import { FILTER_KEY } from '@/features/filtering/filtering-enums'; +import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; +import { IncidentReportLocationType } from '@/features/responses/models/incident-report'; +import { mapIncidentReportLocationType } from '@/features/responses/utils/helpers'; +import { FC } from 'react'; + +export const IncidentReportsLocationTypeFilter: FC = () => { + const { queryParams, navigateHandler } = useFilteringContainer(); + + const onChange = (value: string) => { + navigateHandler({ [FILTER_KEY.IncidentReportLocationType]: value }); + }; + const options: SelectFilterOption[] = [ + { + value: IncidentReportLocationType.PollingStation, + label: mapIncidentReportLocationType(IncidentReportLocationType.PollingStation), + }, + + { + value: IncidentReportLocationType.OtherLocation, + label: mapIncidentReportLocationType(IncidentReportLocationType.OtherLocation), + }, + ]; + + return ( + + ); +}; diff --git a/web/src/features/filtering/filtering-enums.ts b/web/src/features/filtering/filtering-enums.ts index 13703a5ad..85ad8f60a 100644 --- a/web/src/features/filtering/filtering-enums.ts +++ b/web/src/features/filtering/filtering-enums.ts @@ -26,9 +26,10 @@ export const enum FILTER_KEY { ToDate = 'submissionsToDate', SearchText = 'searchText', FormIsCompleted = 'formIsCompleted', - QuickReportIncidentCategory ='incidentCategory', - QuickReportFollowUpStatus ='quickReportFollowUpStatus', - HasQuickReports ='hasQuickReports', + QuickReportIncidentCategory = 'incidentCategory', + QuickReportFollowUpStatus = 'quickReportFollowUpStatus', + HasQuickReports = 'hasQuickReports', + IncidentReportLocationType = 'incidentReportLocationType', } export const enum FILTER_LABEL { @@ -53,7 +54,8 @@ export const enum FILTER_LABEL { ToDate = 'To Date', SearchText = 'Search text', FormCompleted = 'Form completed', - QuickReportIncidentCategory ='Incident category', - QuickReportFollowUpStatus ='Quick report follow up status', - HasQuickReports ='Has quick reports', + QuickReportIncidentCategory = 'Incident category', + QuickReportFollowUpStatus = 'Quick report follow up status', + HasQuickReports = 'Has quick reports', + IncidentReportLocationType = 'Location type', } diff --git a/web/src/features/responses/components/IncidentReportsByObserverTable/IncidentReportsByObserverTable.tsx b/web/src/features/responses/components/IncidentReportsByObserverTable/IncidentReportsByObserverTable.tsx index e0c61f73d..104a61453 100644 --- a/web/src/features/responses/components/IncidentReportsByObserverTable/IncidentReportsByObserverTable.tsx +++ b/web/src/features/responses/components/IncidentReportsByObserverTable/IncidentReportsByObserverTable.tsx @@ -20,14 +20,14 @@ export function IncidentReportsByObserverTable({ searchText }: IncidentReportsBy const navigate = routeApi.useNavigate(); const search = routeApi.useSearch(); const debouncedSearch = useDebounce(search, 300); - const currentElectionRoundId = useCurrentElectionRoundStore(s => s.currentElectionRoundId); + const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); const columnsVisibility = useIncidentReportsByObserverColumns(); const queryParams = useMemo(() => { const params = [ ['followUpStatus', debouncedSearch.followUpStatus], ['searchText', searchText], - ['tagsFilter', debouncedSearch.tagsFilter], + ['tagsFilter', debouncedSearch.tags], ].filter(([_, value]) => value); return Object.fromEntries(params) as FormSubmissionsSearchParams; diff --git a/web/src/features/responses/components/IncidentReportsFiltersByEntry/IncidentReportsFiltersByEntry.tsx b/web/src/features/responses/components/IncidentReportsFiltersByEntry/IncidentReportsFiltersByEntry.tsx index 70e7c714c..c1f33b578 100644 --- a/web/src/features/responses/components/IncidentReportsFiltersByEntry/IncidentReportsFiltersByEntry.tsx +++ b/web/src/features/responses/components/IncidentReportsFiltersByEntry/IncidentReportsFiltersByEntry.tsx @@ -1,338 +1,25 @@ -import { useSetPrevSearch } from '@/common/prev-search-store'; -import { FunctionComponent, IncidentReportFollowUpStatus, QuestionsAnswered } from '@/common/types'; +import { FunctionComponent } 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 { FILTER_KEY } from '@/features/filtering/filtering-enums'; -import { Route } from '@/routes/responses'; -import { useNavigate } from '@tanstack/react-router'; -import { useCallback, useState } from 'react'; -import { IncidentReportLocationType } from '../../models/incident-report'; -import type { FormSubmissionsSearchParams } from '../../models/search-params'; -import { - mapIncidentReportFollowUpStatus, - mapIncidentReportLocationType, - mapQuestionsAnswered, -} from '../../utils/helpers'; -import { ResetFiltersButton } from '../ResetFiltersButton/ResetFiltersButton'; -import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; +import { FilteringContainer } from '@/features/filtering/components/FilteringContainer'; +import { FormSubmissionsCompletionFilter } from '@/features/filtering/components/FormSubmissionsCompletionFilter'; +import { FormSubmissionsFlaggedAnswersFilter } from '@/features/filtering/components/FormSubmissionsFlaggedAnswersFilter'; +import { FormSubmissionsFollowUpFilter } from '@/features/filtering/components/FormSubmissionsFollowUpFilter'; +import { FormSubmissionsMediaFilesFilter } from '@/features/filtering/components/FormSubmissionsMediaFilesFilter'; +import { FormSubmissionsQuestionNotesFilter } from '@/features/filtering/components/FormSubmissionsQuestionNotesFilter'; +import { FormSubmissionsQuestionsAnsweredFilter } from '@/features/filtering/components/FormSubmissionsQuestionsAnsweredFilter'; +import { IncidentReportsLocationTypeFilter } from '@/features/filtering/components/IncidentReportsLocationTypeFilter'; export function IncidentReportsFiltersByEntry(): FunctionComponent { - const navigate = useNavigate({ from: '/responses' }); - const search = Route.useSearch(); - const setPrevSearch = useSetPrevSearch(); - const { filteringIsActive } = useFilteringContainer(); - const [isFiltering, setIsFiltering] = useState(filteringIsActive); - - const navigateHandler = useCallback( - (search: Record) => { - void navigate({ - // @ts-ignore - search: (prev) => { - const newSearch: Record = { - ...prev, - ...search, - }; - setPrevSearch(newSearch); - return newSearch; - }, - }); - }, - [navigate, setPrevSearch] - ); - - const onClearFilter = useCallback( - (filter: keyof FormSubmissionsSearchParams | (keyof FormSubmissionsSearchParams)[]) => () => { - const filters = Array.isArray(filter) - ? Object.fromEntries(filter.map((key) => [key, undefined])) - : { [filter]: undefined }; - navigateHandler(filters); - }, - [navigateHandler] - ); - return ( - <> - - - - - - - - - - - - - - - - + + + + + + + + - - - - {isFiltering && ( -
- {search.incidentReportLocationType && ( - - )} - - {search.incidentReportFollowUpStatus && ( - - )} - - {search.formIsCompleted && ( - - )} - - {search.hasFlaggedAnswers && ( - - )} - - {search.level1Filter && ( - - )} - - {search.level2Filter && ( - - )} - - {search.level3Filter && ( - - )} - - {search.level4Filter && ( - - )} - - {search.level5Filter && ( - - )} - - {search.pollingStationNumberFilter && ( - - )} - - {search.questionsAnswered && ( - - )} - - {search.hasNotes && ( - - )} - - {search.formIsCompleted && ( - - )} -
- )} - +
); } diff --git a/web/src/features/responses/components/IncidentReportsFiltersByObserver/IncidentReportsFiltersByObserver.tsx b/web/src/features/responses/components/IncidentReportsFiltersByObserver/IncidentReportsFiltersByObserver.tsx index b08c01545..de397f4f6 100644 --- a/web/src/features/responses/components/IncidentReportsFiltersByObserver/IncidentReportsFiltersByObserver.tsx +++ b/web/src/features/responses/components/IncidentReportsFiltersByObserver/IncidentReportsFiltersByObserver.tsx @@ -1,124 +1,15 @@ -import { IncidentReportFollowUpStatus, type FunctionComponent } from '@/common/types'; -import { FilterBadge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { - DropdownMenu, - DropdownMenuCheckboxItem, - DropdownMenuContent, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { useCurrentElectionRoundStore } from '@/context/election-round.store'; -import { FILTER_KEY } from '@/features/filtering/filtering-enums'; -import { useMonitoringObserversTags } from '@/hooks/tags-queries'; -import { Route } from '@/routes/responses'; -import { ChevronDownIcon } from '@heroicons/react/24/outline'; -import { useNavigate } from '@tanstack/react-router'; -import { useCallback, useState } from 'react'; -import type { FormSubmissionsSearchParams } from '../../models/search-params'; -import { mapIncidentReportFollowUpStatus } from '../../utils/helpers'; -import { ResetFiltersButton } from '../ResetFiltersButton/ResetFiltersButton'; -import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; +import { type FunctionComponent } from '@/common/types'; +import { FilteringContainer } from '@/features/filtering/components/FilteringContainer'; +import { FormSubmissionsFollowUpFilter } from '@/features/filtering/components/FormSubmissionsFollowUpFilter'; +import { MonitoringObserverTagsSelect } from '@/features/monitoring-observers/filtering/MonitoringObserverTagsSelect'; export function IncidentReportsFiltersByObserver(): FunctionComponent { - const navigate = useNavigate({ from: '/responses' }); - const search = Route.useSearch(); - const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); - const { filteringIsActive } = useFilteringContainer(); - const [isFiltering, setIsFiltering] = useState(filteringIsActive); - - const { data: tags } = useMonitoringObserversTags(currentElectionRoundId); - - const onTagsFilterChange = useCallback( - (tag: string) => () => { - void navigate({ - // @ts-ignore - search: (prev: FormSubmissionsSearchParams) => { - const prevTagsFilter = prev.tagsFilter ?? []; - const newTags = prevTagsFilter.includes(tag) - ? prevTagsFilter.filter((t) => t !== tag) - : [...prevTagsFilter, tag]; - - return { ...prev, tagsFilter: newTags.length > 0 ? newTags : undefined }; - }, - }); - }, - [navigate] - ); - - const onFollowUpFilterChange = useCallback( - (followUpStatus: string) => { - void navigate({ - // @ts-ignore - search: (prev: FormSubmissionsSearchParams) => { - return { ...prev, incidentReportFollowUpStatus: followUpStatus !== 'ALL' ? followUpStatus : undefined }; - }, - }); - }, - [navigate] - ); - return ( <> - - - - - - - - - {tags?.map((tag) => ( - - {tag} - - ))} - - - - - - {isFiltering && ( -
- {search.incidentReportFollowUpStatus && ( - onFollowUpFilterChange('ALL')} - /> - )} - {search.tagsFilter?.map((tag) => ( - - ))} -
- )} + + + + ); } diff --git a/web/src/features/responses/components/IncidentReportsTab/IncidentReportsTab.tsx b/web/src/features/responses/components/IncidentReportsTab/IncidentReportsTab.tsx index 5d09aee63..33d38d593 100644 --- a/web/src/features/responses/components/IncidentReportsTab/IncidentReportsTab.tsx +++ b/web/src/features/responses/components/IncidentReportsTab/IncidentReportsTab.tsx @@ -63,7 +63,7 @@ export default function IncidentReportsTab(): FunctionComponent { ['level4Filter', search.level4Filter], ['level5Filter', search.level5Filter], ['pollingStationNumberFilter', search.pollingStationNumberFilter], - ['followUpStatus', search.incidentReportFollowUpStatus], + ['followUpStatus', search.followUpStatus], ['locationType', search.incidentReportLocationType], ].filter(([_, value]) => value); @@ -128,7 +128,7 @@ export default function IncidentReportsTab(): FunctionComponent { {isFiltering && ( -
+
{byFilter === 'byEntry' && } {byFilter === 'byObserver' && } {byFilter === 'byForm' && } diff --git a/web/src/features/responses/models/search-params.ts b/web/src/features/responses/models/search-params.ts index 5e904fc80..e4af7f128 100644 --- a/web/src/features/responses/models/search-params.ts +++ b/web/src/features/responses/models/search-params.ts @@ -7,12 +7,15 @@ import { QuickReportFollowUpStatus, } from '@/common/types'; import { z } from 'zod'; -import { IncidentCategory, QuickReportLocationType } from './quick-report'; import { IncidentReportLocationType } from './incident-report'; +import { IncidentCategory, QuickReportLocationType } from './quick-report'; export const ResponsesPageSearchParamsSchema = z.object({ viewBy: z.enum(['byEntry', 'byObserver', 'byForm']).catch('byEntry').default('byEntry'), - tab: z.enum(['form-answers', 'quick-reports','citizen-reports','incident-reports']).catch('form-answers').optional(), + tab: z + .enum(['form-answers', 'quick-reports', 'citizen-reports', 'incident-reports']) + .catch('form-answers') + .optional(), }); export const FormSubmissionsSearchParamsSchema = ResponsesPageSearchParamsSchema.merge( @@ -27,7 +30,7 @@ export const FormSubmissionsSearchParamsSchema = ResponsesPageSearchParamsSchema pollingStationNumberFilter: z.string().catch('').optional(), hasFlaggedAnswers: z.string().catch('').optional(), monitoringObserverId: z.string().catch('').optional(), - tagsFilter: z.array(z.string()).optional().catch([]).optional(), + tags: z.array(z.string()).optional().catch([]).optional(), followUpStatus: z.nativeEnum(FormSubmissionFollowUpStatus).optional(), quickReportFollowUpStatus: z.nativeEnum(QuickReportFollowUpStatus).optional(), citizenReportFollowUpStatus: z.nativeEnum(CitizenReportFollowUpStatus).optional(), @@ -45,7 +48,8 @@ export const FormSubmissionsSearchParamsSchema = ResponsesPageSearchParamsSchema submissionsFromDate: z.coerce.date().optional(), submissionsToDate: z.coerce.date().optional(), - })); + }) +); export type FormSubmissionsSearchParams = z.infer; @@ -64,16 +68,17 @@ export const QuickReportsSearchParamsSchema = z.object({ export type QuickReportsSearchParams = z.infer; export const CitizenReportsSearchParamsSchema = z.object({ - citizenReportFollowUpStatus: z - .nativeEnum(CitizenReportFollowUpStatus) - .optional(), + citizenReportFollowUpStatus: z.nativeEnum(CitizenReportFollowUpStatus).optional(), }); export type CitizenReportsSearchParams = z.infer; export const IncidentReportsSearchParamsSchema = z.object({ viewBy: z.enum(['byEntry', 'byObserver', 'byForm']).catch('byEntry').default('byEntry'), - tab: z.enum(['form-answers', 'quick-reports', 'citizen-reports', 'incident-reports']).catch('form-answers').optional(), + tab: z + .enum(['form-answers', 'quick-reports', 'citizen-reports', 'incident-reports']) + .catch('form-answers') + .optional(), searchText: z.string().catch('').optional(), level1Filter: z.string().catch('').optional(), level2Filter: z.string().catch('').optional(), From 37f57c615ceb257c9945dcb1990e94373b0d5439 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Tue, 29 Oct 2024 18:43:54 +0200 Subject: [PATCH 12/17] WIP: remove redundant separate tag filter for form submissions --- .../features/filtering/components/ActiveFilters.tsx | 1 - web/src/features/filtering/filtering-enums.ts | 4 +--- .../filtering/MonitoringObserverTagsSelect.tsx | 10 ++-------- .../FormSubmissionsAggregatedByFormTable.tsx | 4 ++-- .../FormSubmissionsByEntryTable.tsx | 2 +- .../FormSubmissionsByObserverTable.tsx | 2 +- .../FormSubmissionsFiltersByEntry.tsx | 2 +- .../FormSubmissionsFiltersByForm.tsx | 3 +-- .../FormSubmissionsFiltersByObserver.tsx | 2 +- .../FormSubmissionsTab/FormSubmissionsTab.tsx | 2 +- 10 files changed, 11 insertions(+), 21 deletions(-) diff --git a/web/src/features/filtering/components/ActiveFilters.tsx b/web/src/features/filtering/components/ActiveFilters.tsx index 2286f4dda..81a964228 100644 --- a/web/src/features/filtering/components/ActiveFilters.tsx +++ b/web/src/features/filtering/components/ActiveFilters.tsx @@ -47,7 +47,6 @@ 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.PollingStationNumber, FILTER_LABEL.PollingStationNumber], [FILTER_KEY.FormId, FILTER_LABEL.FormId], [FILTER_KEY.FormStatusFilter, FILTER_LABEL.FormStatus], diff --git a/web/src/features/filtering/filtering-enums.ts b/web/src/features/filtering/filtering-enums.ts index 85ad8f60a..944bf4328 100644 --- a/web/src/features/filtering/filtering-enums.ts +++ b/web/src/features/filtering/filtering-enums.ts @@ -16,7 +16,6 @@ export const enum FILTER_KEY { LocationL4 = 'level4Filter', LocationL5 = 'level5Filter', PollingStationNumber = 'pollingStationNumberFilter', - FormSubmissionsMonitoringObserverTags = 'tagsFilter', ViewBy = 'viewBy', Tab = 'tab', FormId = 'formId', @@ -34,7 +33,7 @@ export const enum FILTER_KEY { export const enum FILTER_LABEL { MonitoringObserverStatus = 'Observer status', - MonitoringObserverTags = 'Tags', + MonitoringObserverTags = 'Observer tags', FormTypeFilter = 'Form type', HasFlaggedAnswers = 'Flagged answers', FollowUpStatus = 'Follow-up status', @@ -46,7 +45,6 @@ export const enum FILTER_LABEL { LocationL4 = 'Location - L4', LocationL5 = 'Location - L5', PollingStationNumber = 'Polling station number', - FormSubmissionsMonitoringObserverTags = 'Observer tags', MediaFiles = 'Has attachments', FormId = 'Form', FormStatus = 'Form status', diff --git a/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx b/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx index 1091b64db..04d9cd5c9 100644 --- a/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx +++ b/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx @@ -6,14 +6,8 @@ import { useMonitoringObserversTags } from '@/hooks/tags-queries'; import { FC } from 'react'; -interface MonitoringObserverTagsSelectProps { - isFilteringFormSubmissions?: boolean; -} - -export const MonitoringObserverTagsSelect: FC = ({ isFilteringFormSubmissions }) => { - const COMPONENT_FILTER_KEY = isFilteringFormSubmissions - ? FILTER_KEY.FormSubmissionsMonitoringObserverTags - : FILTER_KEY.MonitoringObserverTags; +export const MonitoringObserverTagsSelect: FC = () => { + const COMPONENT_FILTER_KEY = FILTER_KEY.MonitoringObserverTags; const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); const { data: tags } = useMonitoringObserversTags(currentElectionRoundId); diff --git a/web/src/features/responses/components/FormSubmissionsAggregatedByFormTable/FormSubmissionsAggregatedByFormTable.tsx b/web/src/features/responses/components/FormSubmissionsAggregatedByFormTable/FormSubmissionsAggregatedByFormTable.tsx index a7bfa4e17..15127df37 100644 --- a/web/src/features/responses/components/FormSubmissionsAggregatedByFormTable/FormSubmissionsAggregatedByFormTable.tsx +++ b/web/src/features/responses/components/FormSubmissionsAggregatedByFormTable/FormSubmissionsAggregatedByFormTable.tsx @@ -42,7 +42,7 @@ export function FormSubmissionsAggregatedByFormTable({ questionsAnswered: search.questionsAnswered, hasNotes: search.hasNotes, hasAttachments: search.hasAttachments, - tagsFilter: search.tagsFilter, + tagsFilter: search.tags, submissionsFromDate: search.submissionsFromDate, submissionsToDate: search.submissionsToDate, formIsCompleted: search.formIsCompleted, @@ -66,7 +66,7 @@ export function FormSubmissionsAggregatedByFormTable({ ['questionsAnswered', search.questionsAnswered], ['hasNotes', search.hasNotes], ['hasAttachments', search.hasAttachments], - ['tagsFilter', search.tagsFilter], + ['tagsFilter', search.tags], ['formId', search.formId], ['fromDateFilter', search.submissionsFromDate?.toISOString()], ['toDateFilter', search.submissionsToDate?.toISOString()], diff --git a/web/src/features/responses/components/FormSubmissionsByEntryTable/FormSubmissionsByEntryTable.tsx b/web/src/features/responses/components/FormSubmissionsByEntryTable/FormSubmissionsByEntryTable.tsx index 252dd7c8f..8d374bb77 100644 --- a/web/src/features/responses/components/FormSubmissionsByEntryTable/FormSubmissionsByEntryTable.tsx +++ b/web/src/features/responses/components/FormSubmissionsByEntryTable/FormSubmissionsByEntryTable.tsx @@ -38,7 +38,7 @@ export function FormSubmissionsByEntryTable({ searchText }: FormSubmissionsByEnt ['questionsAnswered', debouncedSearch.questionsAnswered], ['hasNotes', debouncedSearch.hasNotes], ['hasAttachments', debouncedSearch.hasAttachments], - ['tagsFilter', debouncedSearch.tagsFilter], + ['tagsFilter', debouncedSearch.tags], ['formId', debouncedSearch.formId], ['fromDateFilter', debouncedSearch.submissionsFromDate?.toISOString()], ['toDateFilter', debouncedSearch.submissionsToDate?.toISOString()], diff --git a/web/src/features/responses/components/FormSubmissionsByObserverTable/FormSubmissionsByObserverTable.tsx b/web/src/features/responses/components/FormSubmissionsByObserverTable/FormSubmissionsByObserverTable.tsx index 7de75d124..76947a6fb 100644 --- a/web/src/features/responses/components/FormSubmissionsByObserverTable/FormSubmissionsByObserverTable.tsx +++ b/web/src/features/responses/components/FormSubmissionsByObserverTable/FormSubmissionsByObserverTable.tsx @@ -26,7 +26,7 @@ export function FormSubmissionsByObserverTable({ searchText }: FormSubmissionsBy const params = [ ['followUpStatus', debouncedSearch.followUpStatus], ['searchText', searchText], - ['tagsFilter', debouncedSearch.tagsFilter], + ['tagsFilter', debouncedSearch.tags], ['hasFlaggedAnswers', debouncedSearch.hasFlaggedAnswers], ].filter(([_, value]) => value); diff --git a/web/src/features/responses/components/FormSubmissionsFiltersByEntry/FormSubmissionsFiltersByEntry.tsx b/web/src/features/responses/components/FormSubmissionsFiltersByEntry/FormSubmissionsFiltersByEntry.tsx index b0883dbed..14e1542fc 100644 --- a/web/src/features/responses/components/FormSubmissionsFiltersByEntry/FormSubmissionsFiltersByEntry.tsx +++ b/web/src/features/responses/components/FormSubmissionsFiltersByEntry/FormSubmissionsFiltersByEntry.tsx @@ -24,7 +24,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 d693e2453..90a0e19bc 100644 --- a/web/src/features/responses/components/FormSubmissionsFiltersByForm/FormSubmissionsFiltersByForm.tsx +++ b/web/src/features/responses/components/FormSubmissionsFiltersByForm/FormSubmissionsFiltersByForm.tsx @@ -6,7 +6,6 @@ import { FormSubmissionsFormFilter } from '@/features/filtering/components/FormS import { FormSubmissionsFromDateFilter } from '@/features/filtering/components/FormSubmissionsFromDateFilter'; import { FormSubmissionsQuestionsAnsweredFilter } from '@/features/filtering/components/FormSubmissionsQuestionsAnsweredFilter'; import { FormSubmissionsToDateFilter } from '@/features/filtering/components/FormSubmissionsToDateFilter'; -import { FormTypeFilter } from '@/features/filtering/components/FormTypeFilter'; import { MonitoringObserverTagsSelect } from '@/features/monitoring-observers/filtering/MonitoringObserverTagsSelect'; import { FC } from 'react'; import { FormSubmissionsFlaggedAnswersFilter } from '../../../filtering/components/FormSubmissionsFlaggedAnswersFilter'; @@ -23,7 +22,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 0ce055d2c..e0d3669a9 100644 --- a/web/src/features/responses/components/FormSubmissionsFiltersByObserver/FormSubmissionsFiltersByObserver.tsx +++ b/web/src/features/responses/components/FormSubmissionsFiltersByObserver/FormSubmissionsFiltersByObserver.tsx @@ -8,7 +8,7 @@ export function FormSubmissionsFiltersByObserver(): FunctionComponent { return ( - + ); diff --git a/web/src/features/responses/components/FormSubmissionsTab/FormSubmissionsTab.tsx b/web/src/features/responses/components/FormSubmissionsTab/FormSubmissionsTab.tsx index 31bfe1c0b..3d0a2f545 100644 --- a/web/src/features/responses/components/FormSubmissionsTab/FormSubmissionsTab.tsx +++ b/web/src/features/responses/components/FormSubmissionsTab/FormSubmissionsTab.tsx @@ -70,7 +70,7 @@ export default function FormSubmissionsTab(): FunctionComponent { ['questionsAnswered', search.questionsAnswered], ['hasNotes', search.hasNotes], ['hasAttachments', search.hasAttachments], - ['tagsFilter', search.tagsFilter], + ['tagsFilter', search.tags], ['formId', search.formId], ['fromDateFilter', search.submissionsFromDate?.toISOString()], ['toDateFilter', search.submissionsToDate?.toISOString()], From 90aa5f97fcf96e1a99cfc9f9d1c91682351cb9d6 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Wed, 30 Oct 2024 10:29:16 +0200 Subject: [PATCH 13/17] WIP: add filtering icon component --- .../filtering/components/FilteringIcon.tsx | 20 ++ .../filtering/hooks/useFilteringContainer.ts | 15 +- .../forms/components/Dashboard/Dashboard.tsx | 14 +- .../MonitoringObserversList.tsx | 17 +- .../components/Dashboard/Dashboard.tsx | 177 +++++++++--------- .../FormSubmissionsTab/FormSubmissionsTab.tsx | 16 +- .../IncidentReportsTab/IncidentReportsTab.tsx | 19 +- .../QuickReportsTab/QuickReportsTab.tsx | 23 +-- 8 files changed, 148 insertions(+), 153 deletions(-) create mode 100644 web/src/features/filtering/components/FilteringIcon.tsx diff --git a/web/src/features/filtering/components/FilteringIcon.tsx b/web/src/features/filtering/components/FilteringIcon.tsx new file mode 100644 index 000000000..6de702ca1 --- /dev/null +++ b/web/src/features/filtering/components/FilteringIcon.tsx @@ -0,0 +1,20 @@ +import { FunnelIcon } from '@heroicons/react/24/outline'; +import { FC, SetStateAction } from 'react'; + +interface FilteringIconProps { + filteringIsExpanded: boolean; + setFilteringIsExpanded: (value: SetStateAction) => void; +} + +export const FilteringIcon: FC = ({ filteringIsExpanded, setFilteringIsExpanded }) => { + return ( + { + setFilteringIsExpanded((prev) => !prev); + }} + /> + ); +}; diff --git a/web/src/features/filtering/hooks/useFilteringContainer.ts b/web/src/features/filtering/hooks/useFilteringContainer.ts index 85d05f2cf..b4ed6ed7c 100644 --- a/web/src/features/filtering/hooks/useFilteringContainer.ts +++ b/web/src/features/filtering/hooks/useFilteringContainer.ts @@ -1,6 +1,6 @@ import { useSetPrevSearch } from '@/common/prev-search-store'; import { useNavigate, useSearch } from '@tanstack/react-router'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { HIDDEN_FILTERS } from '../components/ActiveFilters'; import { FILTER_KEY } from '../filtering-enums'; @@ -14,12 +14,13 @@ export function useFilteringContainer() { const setPrevSearch = useSetPrevSearch(); const filteringIsActive = useMemo(() => { - return Object.entries(queryParams) .filter(([key, _]) => !HIDDEN_FILTERS.includes(key)) .some(([_, value]) => !!value); }, [queryParams]); + const [filteringIsExpanded, setFilteringIsExpanded] = useState(filteringIsActive ?? false); + const navigateHandler = useCallback( (search: Record) => { void navigate({ @@ -44,5 +45,13 @@ export function useFilteringContainer() { setPrevSearch(filterObject(queryParams, HIDDEN_FILTERS)); }; - return { queryParams, filteringIsActive, navigate, navigateHandler, resetFilters }; + return { + queryParams, + filteringIsActive, + filteringIsExpanded, + setFilteringIsExpanded, + navigate, + navigateHandler, + resetFilters, + }; } diff --git a/web/src/features/forms/components/Dashboard/Dashboard.tsx b/web/src/features/forms/components/Dashboard/Dashboard.tsx index 9a0386a67..54b80b27d 100644 --- a/web/src/features/forms/components/Dashboard/Dashboard.tsx +++ b/web/src/features/forms/components/Dashboard/Dashboard.tsx @@ -44,13 +44,14 @@ import { formsKeys, useForms } from '../../queries'; import AddTranslationsDialog, { useAddTranslationsDialog } from './AddTranslationsDialog'; import CreateForm from './CreateForm'; import { FormFilters } from './FormFilters/FormFilters'; +import { FilteringIcon } from '@/features/filtering/components/FilteringIcon'; export default function FormsDashboard(): ReactElement { const navigate = useNavigate(); const search = Route.useSearch(); const debouncedSearch = useDebounce(search, 300); const [searchText, setSearchText] = useState(''); - const { filteringIsActive } = useFilteringContainer(); + const { filteringIsExpanded, setFilteringIsExpanded } = useFilteringContainer(); const queryParams = useMemo(() => { const params = [ @@ -347,7 +348,6 @@ export default function FormsDashboard(): ReactElement { return defaultColumns; }, [currentElectionRoundId, isMonitoringNgoForCitizenReporting]); - const [isFiltering, setIsFiltering] = useState(filteringIsActive); const handleSearchInput = (ev: React.FormEvent) => { setSearchText(ev.currentTarget.value); @@ -526,18 +526,12 @@ export default function FormsDashboard(): ReactElement {
<> - { - setIsFiltering((prev) => !prev); - }} - /> +
- {isFiltering && } + {filteringIsExpanded && } ) => { setSearchText(ev.currentTarget.value); @@ -208,11 +208,6 @@ function MonitoringObserversList() { }, }); - const changeIsFiltering = () => { - setFiltersExpanded((prev) => { - return !prev; - }); - }; function handleResendInviteToObserver(id?: string): void { setMonitoringObserverId(id); @@ -339,16 +334,12 @@ function MonitoringObserversList() {
<> - +
- {filtersExpanded && } + {filteringIsExpanded && } , Error> { +function usePollingStations( + electionRoundId: string, + queryParams: DataTableParameters +): UseQueryResult, Error> { return useQuery({ queryKey: ['pollingStations', electionRoundId, queryParams], queryFn: async () => { @@ -36,7 +40,8 @@ function usePollingStations(electionRoundId: string, queryParams: DataTableParam }; const searchParams = buildURLSearchParams(params); - const response = await authApi.get>(`/election-rounds/${electionRoundId}/polling-stations:list`, + const response = await authApi.get>( + `/election-rounds/${electionRoundId}/polling-stations:list`, { params: searchParams, } @@ -48,13 +53,15 @@ function usePollingStations(electionRoundId: string, queryParams: DataTableParam return response.data; }, - enabled: !!electionRoundId + enabled: !!electionRoundId, }); } export const pollingStationColDefs: ColumnDef[] = [ { - header: ({ column }) => , + header: ({ column }) => ( + + ), accessorKey: 'level1', enableSorting: true, enableGlobalFilter: true, @@ -62,14 +69,12 @@ export const pollingStationColDefs: ColumnDef[] = [ row: { original: { level1 }, }, - }) => ( -

- {level1} -

- ), + }) =>

{level1}

, }, { - header: ({ column }) => , + header: ({ column }) => ( + + ), accessorKey: 'level2', enableSorting: true, enableGlobalFilter: true, @@ -77,14 +82,12 @@ export const pollingStationColDefs: ColumnDef[] = [ row: { original: { level2 }, }, - }) => ( -

- {level2} -

- ), + }) =>

{level2}

, }, { - header: ({ column }) => , + header: ({ column }) => ( + + ), accessorKey: 'level3', enableSorting: true, enableGlobalFilter: true, @@ -92,14 +95,12 @@ export const pollingStationColDefs: ColumnDef[] = [ row: { original: { level3 }, }, - }) => ( -

- {level3} -

- ), + }) =>

{level3}

, }, { - header: ({ column }) => , + header: ({ column }) => ( + + ), accessorKey: 'level4', enableSorting: true, enableGlobalFilter: true, @@ -107,14 +108,12 @@ export const pollingStationColDefs: ColumnDef[] = [ row: { original: { level4 }, }, - }) => ( -

- {level4} -

- ), + }) =>

{level4}

, }, { - header: ({ column }) => , + header: ({ column }) => ( + + ), accessorKey: 'level5', enableSorting: true, enableGlobalFilter: true, @@ -122,14 +121,12 @@ export const pollingStationColDefs: ColumnDef[] = [ row: { original: { level5 }, }, - }) => ( -

- {level5} -

- ), + }) =>

{level5}

, }, { - header: ({ column }) => , + header: ({ column }) => ( + + ), accessorKey: 'number', enableSorting: true, enableGlobalFilter: true, @@ -137,14 +134,15 @@ export const pollingStationColDefs: ColumnDef[] = [ row: { original: { number }, }, - }) => ( -

- {number} -

- ), + }) =>

{number}

, }, { - header: ({ column }) => , + header: ({ column }) => ( + + ), accessorKey: 'address', enableSorting: true, enableGlobalFilter: true, @@ -152,14 +150,12 @@ export const pollingStationColDefs: ColumnDef[] = [ row: { original: { address }, }, - }) => ( -

- {address} -

- ), + }) =>

{address}

, }, { - header: ({ column }) => , + header: ({ column }) => ( + + ), accessorKey: 'tags', enableSorting: false, enableGlobalFilter: true, @@ -171,7 +167,6 @@ export const pollingStationColDefs: ColumnDef[] = [ }, ]; - export default function PollingStationsDashboard(): ReactElement { const navigate = useNavigate(); @@ -184,14 +179,6 @@ export default function PollingStationsDashboard(): ReactElement { pollingStationNumberFilter?: string; }; - const [isFiltering, setFiltering] = useState(Object.keys(search).some(k => k === 'level1Filter' || k === 'level2Filter' || k === 'level3Filter' || k === 'level4Filter' || k === 'level5Filter')); - - const changeIsFiltering = () => { - setFiltering((prev) => { - return !prev; - }); - }; - const onClearFilter = useCallback( (filter: string | string[]) => () => { const filters = Array.isArray(filter) @@ -202,9 +189,10 @@ export default function PollingStationsDashboard(): ReactElement { [navigate] ); - const debouncedSearch = useDebounce(search, 300); - const currentElectionRoundId = useCurrentElectionRoundStore(s => s.currentElectionRoundId); + const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); + + const { filteringIsExpanded, setFilteringIsExpanded } = useFilteringContainer(); const queryParams = useMemo(() => { const params = [ @@ -228,49 +216,59 @@ export default function PollingStationsDashboard(): ReactElement {
-
- <> - - +
- {isFiltering && (
+ {filteringIsExpanded && ( +
+ - - - -
)} + +
+ )} {Object.entries(search).length > 0 && (
- - {search.level1Filter && ( - + )} {search.level2Filter && ( - + )} {search.level3Filter && ( - + )} {search.level4Filter && ( - + )} {search.level5Filter && ( @@ -278,11 +276,14 @@ export default function PollingStationsDashboard(): ReactElement { )}
)} - - usePollingStations(currentElectionRoundId, params)} queryParams={queryParams} /> + usePollingStations(currentElectionRoundId, params)} + queryParams={queryParams} + /> ); -} \ No newline at end of file +} diff --git a/web/src/features/responses/components/FormSubmissionsTab/FormSubmissionsTab.tsx b/web/src/features/responses/components/FormSubmissionsTab/FormSubmissionsTab.tsx index 3d0a2f545..db2773bba 100644 --- a/web/src/features/responses/components/FormSubmissionsTab/FormSubmissionsTab.tsx +++ b/web/src/features/responses/components/FormSubmissionsTab/FormSubmissionsTab.tsx @@ -10,7 +10,7 @@ import { } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; -import { ChevronDownIcon, FunnelIcon } from '@heroicons/react/24/outline'; +import { ChevronDownIcon } from '@heroicons/react/24/outline'; import { useDebounce } from '@uidotdev/usehooks'; import { useEffect, useMemo, useState, type ChangeEvent } from 'react'; import { ExportedDataType } from '../../models/data-export'; @@ -28,6 +28,7 @@ import { FormSubmissionsFiltersByEntry } from '../FormSubmissionsFiltersByEntry/ import { FormSubmissionsFiltersByForm } from '../FormSubmissionsFiltersByForm/FormSubmissionsFiltersByForm'; import { FormSubmissionsFiltersByObserver } from '../FormSubmissionsFiltersByObserver/FormSubmissionsFiltersByObserver'; +import { FilteringIcon } from '@/features/filtering/components/FilteringIcon'; import { Route } from '@/routes/responses'; import { useNavigate } from '@tanstack/react-router'; @@ -40,8 +41,7 @@ const viewBy: Record = { export default function FormSubmissionsTab(): FunctionComponent { const navigate = useNavigate(); const search = Route.useSearch(); - const { filteringIsActive, navigateHandler } = useFilteringContainer(); - const [filtersExpanded, setFiltersExpanded] = useState(false); + const { filteringIsExpanded, setFilteringIsExpanded, navigateHandler } = useFilteringContainer(); const { viewBy: byFilter } = search; @@ -133,13 +133,7 @@ export default function FormSubmissionsTab(): FunctionComponent {
<> - { - setFiltersExpanded((prev) => !prev); - }} - /> + @@ -147,7 +141,7 @@ export default function FormSubmissionsTab(): FunctionComponent { - {filtersExpanded && ( + {filteringIsExpanded && ( <> {byFilter === 'byEntry' && } {byFilter === 'byObserver' && } diff --git a/web/src/features/responses/components/IncidentReportsTab/IncidentReportsTab.tsx b/web/src/features/responses/components/IncidentReportsTab/IncidentReportsTab.tsx index 33d38d593..5f4f22a94 100644 --- a/web/src/features/responses/components/IncidentReportsTab/IncidentReportsTab.tsx +++ b/web/src/features/responses/components/IncidentReportsTab/IncidentReportsTab.tsx @@ -10,7 +10,7 @@ import { } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; -import { ChevronDownIcon, FunnelIcon } from '@heroicons/react/24/outline'; +import { ChevronDownIcon } from '@heroicons/react/24/outline'; import { useNavigate } from '@tanstack/react-router'; import { useDebounce } from '@uidotdev/usehooks'; import { useMemo, useState, type ChangeEvent } from 'react'; @@ -19,6 +19,7 @@ import type { IncidentReportsViewBy } from '../../utils/column-visibility-option import { ExportDataButton } from '../ExportDataButton/ExportDataButton'; import { FunctionComponent } from '@/common/types'; +import { FilteringIcon } from '@/features/filtering/components/FilteringIcon'; import { FILTER_KEY } from '@/features/filtering/filtering-enums'; import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; import { Route } from '@/routes/responses'; @@ -39,12 +40,10 @@ const viewBy: Record = { export default function IncidentReportsTab(): FunctionComponent { const navigate = useNavigate(); const search = Route.useSearch(); - const { filteringIsActive } = useFilteringContainer(); + const { filteringIsExpanded, setFilteringIsExpanded } = useFilteringContainer(); const { viewBy: byFilter } = search; - const [isFiltering, setIsFiltering] = useState(filteringIsActive); - const [searchText, setSearchText] = useState(''); const debouncedSearchText = useDebounce(searchText, 300); const setPrevSearch = useSetPrevSearch(); @@ -94,7 +93,7 @@ export default function IncidentReportsTab(): FunctionComponent { onValueChange={(value) => { setPrevSearch({ [FILTER_KEY.ViewBy]: value, [FILTER_KEY.Tab]: 'incident-reports' }); void navigate({ search: { [FILTER_KEY.ViewBy]: value, [FILTER_KEY.Tab]: 'incident-reports' } }); - setIsFiltering(false); + setFilteringIsExpanded(false); }} value={byFilter}> {Object.entries(viewBy).map(([value, label]) => ( @@ -113,13 +112,7 @@ export default function IncidentReportsTab(): FunctionComponent {
<> - { - setIsFiltering((prev) => !prev); - }} - /> + @@ -127,7 +120,7 @@ export default function IncidentReportsTab(): FunctionComponent { - {isFiltering && ( + {filteringIsExpanded && (
{byFilter === 'byEntry' && } {byFilter === 'byObserver' && } diff --git a/web/src/features/responses/components/QuickReportsTab/QuickReportsTab.tsx b/web/src/features/responses/components/QuickReportsTab/QuickReportsTab.tsx index 2f97305cc..765af19c1 100644 --- a/web/src/features/responses/components/QuickReportsTab/QuickReportsTab.tsx +++ b/web/src/features/responses/components/QuickReportsTab/QuickReportsTab.tsx @@ -15,11 +15,12 @@ import { import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; +import { FilteringIcon } from '@/features/filtering/components/FilteringIcon'; import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; -import { Cog8ToothIcon, FunnelIcon } from '@heroicons/react/24/outline'; +import { Cog8ToothIcon } from '@heroicons/react/24/outline'; import { getRouteApi } from '@tanstack/react-router'; import { useDebounce } from '@uidotdev/usehooks'; -import { useCallback, useMemo, useState } from 'react'; +import { useCallback, useMemo } from 'react'; import { useQuickReports } from '../../hooks/quick-reports'; import { ExportedDataType } from '../../models/data-export'; import { IncidentCategoryList, QuickReportLocationType } from '../../models/quick-report'; @@ -40,9 +41,7 @@ export function QuickReportsTab(): FunctionComponent { const columnsVisibility = useQuickReportsColumnsVisibility(); const toggleColumns = useQuickReportsToggleColumn(); - const { filteringIsActive } = useFilteringContainer(); - - const [isFiltering, setIsFiltering] = useState(filteringIsActive); + const { filteringIsExpanded, setFilteringIsExpanded } = useFilteringContainer(); const queryParams = useMemo(() => { const params = [ @@ -99,13 +98,7 @@ export function QuickReportsTab(): FunctionComponent {
- { - setIsFiltering((prev) => !prev); - }} - /> + @@ -131,7 +124,7 @@ export function QuickReportsTab(): FunctionComponent { - {isFiltering && ( + {filteringIsExpanded && (
- + - {isFiltering && ( + {filteringIsExpanded && (
{search.quickReportFollowUpStatus && ( Date: Wed, 30 Oct 2024 11:16:10 +0200 Subject: [PATCH 14/17] add filtering by form ID and use the new filtering system in IncidentReportsFiltersByForm --- .../IncidentReportsFiltersByForm.tsx | 351 ++---------------- .../IncidentReportsTab/IncidentReportsTab.tsx | 1 + 2 files changed, 22 insertions(+), 330 deletions(-) diff --git a/web/src/features/responses/components/IncidentReportsFiltersByForm/IncidentReportsFiltersByForm.tsx b/web/src/features/responses/components/IncidentReportsFiltersByForm/IncidentReportsFiltersByForm.tsx index a7cfefff8..0b808861d 100644 --- a/web/src/features/responses/components/IncidentReportsFiltersByForm/IncidentReportsFiltersByForm.tsx +++ b/web/src/features/responses/components/IncidentReportsFiltersByForm/IncidentReportsFiltersByForm.tsx @@ -1,338 +1,29 @@ -import { useSetPrevSearch } from '@/common/prev-search-store'; -import { FunctionComponent, IncidentReportFollowUpStatus, QuestionsAnswered } from '@/common/types'; +import { FunctionComponent } 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 { FILTER_KEY } from '@/features/filtering/filtering-enums'; -import { Route } from '@/routes/responses'; -import { useNavigate } from '@tanstack/react-router'; -import { useCallback, useState } from 'react'; -import { IncidentReportLocationType } from '../../models/incident-report'; -import type { FormSubmissionsSearchParams } from '../../models/search-params'; -import { - mapIncidentReportFollowUpStatus, - mapIncidentReportLocationType, - mapQuestionsAnswered, -} from '../../utils/helpers'; -import { ResetFiltersButton } from '../ResetFiltersButton/ResetFiltersButton'; -import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; +import { FilteringContainer } from '@/features/filtering/components/FilteringContainer'; +import { FormSubmissionsCompletionFilter } from '@/features/filtering/components/FormSubmissionsCompletionFilter'; +import { FormSubmissionsFlaggedAnswersFilter } from '@/features/filtering/components/FormSubmissionsFlaggedAnswersFilter'; +import { FormSubmissionsFollowUpFilter } from '@/features/filtering/components/FormSubmissionsFollowUpFilter'; +import { FormSubmissionsFormFilter } from '@/features/filtering/components/FormSubmissionsFormFilter'; +import { FormSubmissionsMediaFilesFilter } from '@/features/filtering/components/FormSubmissionsMediaFilesFilter'; +import { FormSubmissionsQuestionNotesFilter } from '@/features/filtering/components/FormSubmissionsQuestionNotesFilter'; +import { FormSubmissionsQuestionsAnsweredFilter } from '@/features/filtering/components/FormSubmissionsQuestionsAnsweredFilter'; +import { IncidentReportsLocationTypeFilter } from '@/features/filtering/components/IncidentReportsLocationTypeFilter'; export function IncidentReportsFiltersByForm(): FunctionComponent { - const navigate = useNavigate({ from: '/responses' }); - const search = Route.useSearch(); - const setPrevSearch = useSetPrevSearch(); - const { filteringIsActive } = useFilteringContainer(); - const [isFiltering, setIsFiltering] = useState(filteringIsActive); - - const navigateHandler = useCallback( - (search: Record) => { - void navigate({ - // @ts-ignore - search: (prev) => { - const newSearch: Record = { - ...prev, - ...search, - }; - setPrevSearch(newSearch); - return newSearch; - }, - }); - }, - [navigate, setPrevSearch] - ); - - const onClearFilter = useCallback( - (filter: keyof FormSubmissionsSearchParams | (keyof FormSubmissionsSearchParams)[]) => () => { - const filters = Array.isArray(filter) - ? Object.fromEntries(filter.map((key) => [key, undefined])) - : { [filter]: undefined }; - navigateHandler(filters); - }, - [navigateHandler] - ); - return ( <> - - - - - - - - - - - - - - - - - - - - - {isFiltering && ( -
- {search.incidentReportLocationType && ( - - )} - - {search.incidentReportFollowUpStatus && ( - - )} - - {search.formIsCompleted && ( - - )} - - {search.hasFlaggedAnswers && ( - - )} - - {search.level1Filter && ( - - )} - - {search.level2Filter && ( - - )} - - {search.level3Filter && ( - - )} - - {search.level4Filter && ( - - )} - - {search.level5Filter && ( - - )} - - {search.pollingStationNumberFilter && ( - - )} - - {search.questionsAnswered && ( - - )} - - {search.hasNotes && ( - - )} - - {search.formIsCompleted && ( - - )} -
- )} + + + + + + + + + + + ); } diff --git a/web/src/features/responses/components/IncidentReportsTab/IncidentReportsTab.tsx b/web/src/features/responses/components/IncidentReportsTab/IncidentReportsTab.tsx index 5f4f22a94..e94986e94 100644 --- a/web/src/features/responses/components/IncidentReportsTab/IncidentReportsTab.tsx +++ b/web/src/features/responses/components/IncidentReportsTab/IncidentReportsTab.tsx @@ -55,6 +55,7 @@ export default function IncidentReportsTab(): FunctionComponent { const queryParams = useMemo(() => { const params = [ ['searchText', searchText], + ['formId', search.formId], ['hasFlaggedAnswers', search.hasFlaggedAnswers], ['level1Filter', search.level1Filter], ['level2Filter', search.level2Filter], From de766f41219da97c84ee210d7762bc3a0256eb36 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Wed, 30 Oct 2024 12:42:10 +0200 Subject: [PATCH 15/17] rework filters and add filtering UI for citizen reports --- .../CitizenReportsAggregatedByFormTable.tsx | 29 +++- .../CitizenReportsByEntryTable.tsx | 12 +- ...CitizenReportsColumnVisibilitySelector.tsx | 40 +++++ .../CitizenReportsFiltersByEntry.tsx | 148 ++++-------------- .../CitizenReportsFiltersByForm.tsx | 23 +++ .../CitizenReportsTab/CitizenReportsTab.tsx | 25 +-- 6 files changed, 142 insertions(+), 135 deletions(-) create mode 100644 web/src/features/responses/components/CitizenReportsColumnVisibilitySelector/CitizenReportsColumnVisibilitySelector.tsx create mode 100644 web/src/features/responses/components/CitizenReportsFiltersByForm/CitizenReportsFiltersByForm.tsx diff --git a/web/src/features/responses/components/CitizenReportsAggregatedByFormTable/CitizenReportsAggregatedByFormTable.tsx b/web/src/features/responses/components/CitizenReportsAggregatedByFormTable/CitizenReportsAggregatedByFormTable.tsx index fee9c48ab..b1b564bd1 100644 --- a/web/src/features/responses/components/CitizenReportsAggregatedByFormTable/CitizenReportsAggregatedByFormTable.tsx +++ b/web/src/features/responses/components/CitizenReportsAggregatedByFormTable/CitizenReportsAggregatedByFormTable.tsx @@ -3,15 +3,40 @@ import { CardContent } from '@/components/ui/card'; import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; import { getRouteApi } from '@tanstack/react-router'; -import { useCallback } from 'react'; +import { useDebounce } from '@uidotdev/usehooks'; +import { useCallback, useMemo } from 'react'; import { useCitizenReportsAggregatedByForm } from '../../hooks/citizen-reports'; +import { FormSubmissionsSearchParams } from '../../models/search-params'; import { citizenReportsAggregatedByFormColumnDefs } from '../../utils/column-defs'; +import { useCitizenReportsColumnsVisibility } from '../../store/column-visibility'; const routeApi = getRouteApi('/responses/'); export function CitizenReportsAggregatedByFormTable(): FunctionComponent { const navigate = routeApi.useNavigate(); const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); + const search = routeApi.useSearch(); + const debouncedSearch = useDebounce(search, 300); + const columnsVisibility = useCitizenReportsColumnsVisibility(); + + const queryParams = useMemo(() => { + const params = [ + ['hasFlaggedAnswers', debouncedSearch.hasFlaggedAnswers], + ['level1Filter', debouncedSearch.level1Filter], + ['level2Filter', debouncedSearch.level2Filter], + ['level3Filter', debouncedSearch.level3Filter], + ['level4Filter', debouncedSearch.level4Filter], + ['level5Filter', debouncedSearch.level5Filter], + ['pollingStationNumberFilter', debouncedSearch.pollingStationNumberFilter], + ['followUpStatus', debouncedSearch.followUpStatus], + ['questionsAnswered', debouncedSearch.questionsAnswered], + ['hasNotes', debouncedSearch.hasNotes], + ['hasAttachments', debouncedSearch.hasAttachments], + ['formId', debouncedSearch.formId], + ].filter(([_, value]) => value); + + return Object.fromEntries(params) as FormSubmissionsSearchParams; + }, [debouncedSearch]); const navigateToAggregatedForm = useCallback( (formId: string) => { @@ -26,6 +51,8 @@ export function CitizenReportsAggregatedByFormTable(): FunctionComponent { columns={citizenReportsAggregatedByFormColumnDefs} useQuery={(params) => useCitizenReportsAggregatedByForm(currentElectionRoundId, params)} onRowClick={navigateToAggregatedForm} + queryParams={queryParams} + columnVisibility={columnsVisibility} /> ); diff --git a/web/src/features/responses/components/CitizenReportsByEntryTable/CitizenReportsByEntryTable.tsx b/web/src/features/responses/components/CitizenReportsByEntryTable/CitizenReportsByEntryTable.tsx index 8aa4dbcf5..77f1a172f 100644 --- a/web/src/features/responses/components/CitizenReportsByEntryTable/CitizenReportsByEntryTable.tsx +++ b/web/src/features/responses/components/CitizenReportsByEntryTable/CitizenReportsByEntryTable.tsx @@ -8,21 +8,20 @@ import { useDebounce } from '@uidotdev/usehooks'; import { useCallback, useMemo } from 'react'; import { useCitizenReports } from '../../hooks/citizen-reports'; import type { FormSubmissionsSearchParams } from '../../models/search-params'; +import { useCitizenReportsColumnsVisibility } from '../../store/column-visibility'; import { citizenReportsByEntryColumnDefs } from '../../utils/column-defs'; -type CitizenReportsByEntryTableProps = { -}; +type CitizenReportsByEntryTableProps = {}; export function CitizenReportsByEntryTable(props: CitizenReportsByEntryTableProps): FunctionComponent { 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 = useCitizenReportsColumnsVisibility(); const queryParams = useMemo(() => { - const params = [ - ['followUpStatus', debouncedSearch.citizenReportFollowUpStatus], - ].filter(([_, value]) => value); + const params = [['followUpStatus', debouncedSearch.citizenReportFollowUpStatus]].filter(([_, value]) => value); return Object.fromEntries(params) as FormSubmissionsSearchParams; }, [debouncedSearch]); @@ -41,6 +40,7 @@ export function CitizenReportsByEntryTable(props: CitizenReportsByEntryTableProp useQuery={(params) => useCitizenReports(currentElectionRoundId, params)} queryParams={queryParams} onRowClick={navigateToCitizenReport} + columnVisibility={columnsVisibility} /> ); diff --git a/web/src/features/responses/components/CitizenReportsColumnVisibilitySelector/CitizenReportsColumnVisibilitySelector.tsx b/web/src/features/responses/components/CitizenReportsColumnVisibilitySelector/CitizenReportsColumnVisibilitySelector.tsx new file mode 100644 index 000000000..3548b05f9 --- /dev/null +++ b/web/src/features/responses/components/CitizenReportsColumnVisibilitySelector/CitizenReportsColumnVisibilitySelector.tsx @@ -0,0 +1,40 @@ +import { FunctionComponent } from '@/common/types'; +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Cog8ToothIcon } from '@heroicons/react/24/outline'; +import { useCitizenReportsColumnsVisibility, useCitizenReportsToggleColumn } from '../../store/column-visibility'; +import { citizenReportsColumnVisibilityOptions } from '../../utils/column-visibility-options'; + +export function CitizenReportsColumnVisibilitySelector(): FunctionComponent { + const columnsVisibility = useCitizenReportsColumnsVisibility(); + const toggleColumn = useCitizenReportsToggleColumn(); + + return ( + + + + + + Table columns + + {citizenReportsColumnVisibilityOptions.map((option) => ( + { + toggleColumn(option.id, checked); + }}> + {option.label} + + ))} + + + ); +} diff --git a/web/src/features/responses/components/CitizenReportsFiltersByEntry/CitizenReportsFiltersByEntry.tsx b/web/src/features/responses/components/CitizenReportsFiltersByEntry/CitizenReportsFiltersByEntry.tsx index a050baf38..52965699b 100644 --- a/web/src/features/responses/components/CitizenReportsFiltersByEntry/CitizenReportsFiltersByEntry.tsx +++ b/web/src/features/responses/components/CitizenReportsFiltersByEntry/CitizenReportsFiltersByEntry.tsx @@ -1,121 +1,31 @@ -import { useSetPrevSearch } from '@/common/prev-search-store'; -import { CitizenReportFollowUpStatus, FunctionComponent } from '@/common/types'; -import { FilterBadge } from '@/components/ui/badge'; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { FILTER_KEY } from '@/features/filtering/filtering-enums'; -import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; -import { Route } from '@/routes/responses'; -import { useNavigate } from '@tanstack/react-router'; -import { useCallback, useState } from 'react'; -import type { FormSubmissionsSearchParams } from '../../models/search-params'; -import { mapCitizenReportFollowUpStatus } from '../../utils/helpers'; -import { ResetFiltersButton } from '../ResetFiltersButton/ResetFiltersButton'; - -export function CitizenReportsFiltersByEntry(): FunctionComponent { - const navigate = useNavigate({ from: '/responses' }); - const search = Route.useSearch(); - const setPrevSearch = useSetPrevSearch(); - const { filteringIsActive } = useFilteringContainer(); - const [isFiltering, setIsFiltering] = useState(filteringIsActive); - - const navigateHandler = useCallback( - (search: Record) => { - void navigate({ - // @ts-ignore - search: (prev) => { - const newSearch: Record = { - ...prev, - ...search, - }; - setPrevSearch(newSearch); - return newSearch; - }, - }); - }, - [navigate, setPrevSearch] - ); - - const onClearFilter = useCallback( - (filter: keyof FormSubmissionsSearchParams | (keyof FormSubmissionsSearchParams)[]) => () => { - const filters = Array.isArray(filter) - ? Object.fromEntries(filter.map((key) => [key, undefined])) - : { [filter]: undefined }; - navigateHandler(filters); - }, - [navigateHandler] - ); - +import { PollingStationsFilters } from '@/components/PollingStationsFilters/PollingStationsFilters'; +import { FilteringContainer } from '@/features/filtering/components/FilteringContainer'; +import { FormSubmissionsCompletionFilter } from '@/features/filtering/components/FormSubmissionsCompletionFilter'; +import { FormTypeFilter } from '@/features/filtering/components/FormTypeFilter'; +import { MonitoringObserverTagsSelect } from '@/features/monitoring-observers/filtering/MonitoringObserverTagsSelect'; +import { FC } from 'react'; +import { FormSubmissionsFlaggedAnswersFilter } from '../../../filtering/components/FormSubmissionsFlaggedAnswersFilter'; +import { FormSubmissionsFollowUpFilter } from '../../../filtering/components/FormSubmissionsFollowUpFilter'; +import { FormSubmissionsFormFilter } from '../../../filtering/components/FormSubmissionsFormFilter'; +import { FormSubmissionsFromDateFilter } from '../../../filtering/components/FormSubmissionsFromDateFilter'; +import { FormSubmissionsMediaFilesFilter } from '../../../filtering/components/FormSubmissionsMediaFilesFilter'; +import { FormSubmissionsQuestionNotesFilter } from '../../../filtering/components/FormSubmissionsQuestionNotesFilter'; +import { FormSubmissionsQuestionsAnsweredFilter } from '../../../filtering/components/FormSubmissionsQuestionsAnsweredFilter'; +import { FormSubmissionsToDateFilter } from '../../../filtering/components/FormSubmissionsToDateFilter'; + +export const CitizenReportsFiltersByEntry: FC = () => { return ( - <> - - - - - - - {isFiltering && ( -
- {search.formTypeFilter && ( - - )} - - {search.citizenReportFollowUpStatus && ( - - )} - - {search.hasFlaggedAnswers && ( - - )} -
- )} - + + + + + + + + + + + + ); -} +}; diff --git a/web/src/features/responses/components/CitizenReportsFiltersByForm/CitizenReportsFiltersByForm.tsx b/web/src/features/responses/components/CitizenReportsFiltersByForm/CitizenReportsFiltersByForm.tsx new file mode 100644 index 000000000..21ce1b5a1 --- /dev/null +++ b/web/src/features/responses/components/CitizenReportsFiltersByForm/CitizenReportsFiltersByForm.tsx @@ -0,0 +1,23 @@ +import { PollingStationsFilters } from '@/components/PollingStationsFilters/PollingStationsFilters'; +import { FilteringContainer } from '@/features/filtering/components/FilteringContainer'; +import { FormSubmissionsFollowUpFilter } from '@/features/filtering/components/FormSubmissionsFollowUpFilter'; +import { FormSubmissionsFormFilter } from '@/features/filtering/components/FormSubmissionsFormFilter'; +import { FormSubmissionsQuestionsAnsweredFilter } from '@/features/filtering/components/FormSubmissionsQuestionsAnsweredFilter'; +import { FC } from 'react'; +import { FormSubmissionsFlaggedAnswersFilter } from '../../../filtering/components/FormSubmissionsFlaggedAnswersFilter'; +import { FormSubmissionsMediaFilesFilter } from '../../../filtering/components/FormSubmissionsMediaFilesFilter'; +import { FormSubmissionsQuestionNotesFilter } from '../../../filtering/components/FormSubmissionsQuestionNotesFilter'; + +export const CitizenReportsFiltersByForm: FC = () => { + return ( + + + + + + + + + + ); +}; diff --git a/web/src/features/responses/components/CitizenReportsTab/CitizenReportsTab.tsx b/web/src/features/responses/components/CitizenReportsTab/CitizenReportsTab.tsx index 62a14162e..26ca36d43 100644 --- a/web/src/features/responses/components/CitizenReportsTab/CitizenReportsTab.tsx +++ b/web/src/features/responses/components/CitizenReportsTab/CitizenReportsTab.tsx @@ -11,17 +11,20 @@ import { import { Separator } from '@/components/ui/separator'; import { ChevronDownIcon } from '@heroicons/react/24/outline'; import { useNavigate } from '@tanstack/react-router'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useMemo } from 'react'; import { ExportedDataType } from '../../models/data-export'; import { ExportDataButton } from '../ExportDataButton/ExportDataButton'; import { FunctionComponent } from '@/common/types'; +import { FilteringIcon } from '@/features/filtering/components/FilteringIcon'; import { FILTER_KEY } from '@/features/filtering/filtering-enums'; import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; import { Route } from '@/routes/responses'; import { CitizenReportsAggregatedByFormTable } from '../CitizenReportsAggregatedByFormTable/CitizenReportsAggregatedByFormTable'; import { CitizenReportsByEntryTable } from '../CitizenReportsByEntryTable/CitizenReportsByEntryTable'; +import { CitizenReportsColumnVisibilitySelector } from '../CitizenReportsColumnVisibilitySelector/CitizenReportsColumnVisibilitySelector'; import { CitizenReportsFiltersByEntry } from '../CitizenReportsFiltersByEntry/CitizenReportsFiltersByEntry'; +import { CitizenReportsFiltersByForm } from '../CitizenReportsFiltersByForm/CitizenReportsFiltersByForm'; const viewBy: Record = { byEntry: 'View by entry', @@ -31,12 +34,10 @@ const viewBy: Record = { export function CitizenReportsTab(): FunctionComponent { const navigate = useNavigate(); const search = Route.useSearch(); - const { filteringIsActive } = useFilteringContainer(); + const { filteringIsExpanded, setFilteringIsExpanded } = useFilteringContainer(); const { viewBy: byFilter } = search; - const [isFiltering, setIsFiltering] = useState(filteringIsActive); - const setPrevSearch = useSetPrevSearch(); useEffect(() => { if (byFilter === 'byObserver') { @@ -57,11 +58,11 @@ export function CitizenReportsTab(): FunctionComponent { return ( -
+
Citizen reports
- + @@ -75,7 +76,7 @@ export function CitizenReportsTab(): FunctionComponent { onValueChange={(value) => { setPrevSearch({ [FILTER_KEY.ViewBy]: value, [FILTER_KEY.Tab]: 'citizen-reports' }); void navigate({ search: { [FILTER_KEY.ViewBy]: value, [FILTER_KEY.Tab]: 'citizen-reports' } }); - setIsFiltering(false); + setFilteringIsExpanded(false); }} value={byFilter}> {Object.entries(viewBy).map(([value, label]) => ( @@ -88,12 +89,18 @@ export function CitizenReportsTab(): FunctionComponent {
+ +
+ + +
- {isFiltering && ( -
+ {filteringIsExpanded && ( +
{byFilter === 'byEntry' && } + {byFilter === 'byForm' && }
)} From 6326e9c385bf5cef78e02d53a0002e8b68af73f5 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Wed, 30 Oct 2024 14:28:50 +0200 Subject: [PATCH 16/17] rework quick reports filters --- ...TypeFilter.tsx => LocationTypeFilters.tsx} | 36 +++- web/src/features/filtering/filtering-enums.ts | 1 + .../IncidentReportsFiltersByEntry.tsx | 2 +- .../IncidentReportsFiltersByForm.tsx | 2 +- .../QuickReportsTab/QuickReportsTab.tsx | 172 ++---------------- 5 files changed, 52 insertions(+), 161 deletions(-) rename web/src/features/filtering/components/{IncidentReportsLocationTypeFilter.tsx => LocationTypeFilters.tsx} (50%) diff --git a/web/src/features/filtering/components/IncidentReportsLocationTypeFilter.tsx b/web/src/features/filtering/components/LocationTypeFilters.tsx similarity index 50% rename from web/src/features/filtering/components/IncidentReportsLocationTypeFilter.tsx rename to web/src/features/filtering/components/LocationTypeFilters.tsx index ba283d491..c19fe3f14 100644 --- a/web/src/features/filtering/components/IncidentReportsLocationTypeFilter.tsx +++ b/web/src/features/filtering/components/LocationTypeFilters.tsx @@ -2,7 +2,8 @@ import { SelectFilter, SelectFilterOption } from '@/features/filtering/component import { FILTER_KEY } from '@/features/filtering/filtering-enums'; import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; import { IncidentReportLocationType } from '@/features/responses/models/incident-report'; -import { mapIncidentReportLocationType } from '@/features/responses/utils/helpers'; +import { QuickReportLocationType } from '@/features/responses/models/quick-report'; +import { mapIncidentReportLocationType, mapQuickReportLocationType } from '@/features/responses/utils/helpers'; import { FC } from 'react'; export const IncidentReportsLocationTypeFilter: FC = () => { @@ -32,3 +33,36 @@ export const IncidentReportsLocationTypeFilter: FC = () => { /> ); }; + +export const QuickReportsLocationTypeFilter: FC = () => { + const { queryParams, navigateHandler } = useFilteringContainer(); + + const onChange = (value: string) => { + navigateHandler({ [FILTER_KEY.QuickReportLocationType]: value }); + }; + const options: SelectFilterOption[] = [ + { + value: QuickReportLocationType.NotRelatedToAPollingStation, + label: mapQuickReportLocationType(QuickReportLocationType.NotRelatedToAPollingStation), + }, + + { + value: QuickReportLocationType.OtherPollingStation, + label: mapQuickReportLocationType(QuickReportLocationType.OtherPollingStation), + }, + + { + value: QuickReportLocationType.VisitedPollingStation, + label: mapQuickReportLocationType(QuickReportLocationType.VisitedPollingStation), + }, + ]; + + return ( + + ); +}; diff --git a/web/src/features/filtering/filtering-enums.ts b/web/src/features/filtering/filtering-enums.ts index 944bf4328..b7acbb8ad 100644 --- a/web/src/features/filtering/filtering-enums.ts +++ b/web/src/features/filtering/filtering-enums.ts @@ -29,6 +29,7 @@ export const enum FILTER_KEY { QuickReportFollowUpStatus = 'quickReportFollowUpStatus', HasQuickReports = 'hasQuickReports', IncidentReportLocationType = 'incidentReportLocationType', + QuickReportLocationType = 'quickReportLocationType', } export const enum FILTER_LABEL { diff --git a/web/src/features/responses/components/IncidentReportsFiltersByEntry/IncidentReportsFiltersByEntry.tsx b/web/src/features/responses/components/IncidentReportsFiltersByEntry/IncidentReportsFiltersByEntry.tsx index c1f33b578..3baa7029c 100644 --- a/web/src/features/responses/components/IncidentReportsFiltersByEntry/IncidentReportsFiltersByEntry.tsx +++ b/web/src/features/responses/components/IncidentReportsFiltersByEntry/IncidentReportsFiltersByEntry.tsx @@ -7,7 +7,7 @@ import { FormSubmissionsFollowUpFilter } from '@/features/filtering/components/F import { FormSubmissionsMediaFilesFilter } from '@/features/filtering/components/FormSubmissionsMediaFilesFilter'; import { FormSubmissionsQuestionNotesFilter } from '@/features/filtering/components/FormSubmissionsQuestionNotesFilter'; import { FormSubmissionsQuestionsAnsweredFilter } from '@/features/filtering/components/FormSubmissionsQuestionsAnsweredFilter'; -import { IncidentReportsLocationTypeFilter } from '@/features/filtering/components/IncidentReportsLocationTypeFilter'; +import { IncidentReportsLocationTypeFilter } from '@/features/filtering/components/LocationTypeFilters'; export function IncidentReportsFiltersByEntry(): FunctionComponent { return ( diff --git a/web/src/features/responses/components/IncidentReportsFiltersByForm/IncidentReportsFiltersByForm.tsx b/web/src/features/responses/components/IncidentReportsFiltersByForm/IncidentReportsFiltersByForm.tsx index 0b808861d..3d437c367 100644 --- a/web/src/features/responses/components/IncidentReportsFiltersByForm/IncidentReportsFiltersByForm.tsx +++ b/web/src/features/responses/components/IncidentReportsFiltersByForm/IncidentReportsFiltersByForm.tsx @@ -8,7 +8,7 @@ import { FormSubmissionsFormFilter } from '@/features/filtering/components/FormS import { FormSubmissionsMediaFilesFilter } from '@/features/filtering/components/FormSubmissionsMediaFilesFilter'; import { FormSubmissionsQuestionNotesFilter } from '@/features/filtering/components/FormSubmissionsQuestionNotesFilter'; import { FormSubmissionsQuestionsAnsweredFilter } from '@/features/filtering/components/FormSubmissionsQuestionsAnsweredFilter'; -import { IncidentReportsLocationTypeFilter } from '@/features/filtering/components/IncidentReportsLocationTypeFilter'; +import { IncidentReportsLocationTypeFilter } from '@/features/filtering/components/LocationTypeFilters'; export function IncidentReportsFiltersByForm(): FunctionComponent { return ( diff --git a/web/src/features/responses/components/QuickReportsTab/QuickReportsTab.tsx b/web/src/features/responses/components/QuickReportsTab/QuickReportsTab.tsx index 765af19c1..df0345b84 100644 --- a/web/src/features/responses/components/QuickReportsTab/QuickReportsTab.tsx +++ b/web/src/features/responses/components/QuickReportsTab/QuickReportsTab.tsx @@ -1,8 +1,7 @@ import { useSetPrevSearch } from '@/common/prev-search-store'; -import { QuickReportFollowUpStatus, type FunctionComponent } from '@/common/types'; +import { type FunctionComponent } from '@/common/types'; import { PollingStationsFilters } from '@/components/PollingStationsFilters/PollingStationsFilters'; import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; -import { FilterBadge } from '@/components/ui/badge'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { DropdownMenu, @@ -12,10 +11,13 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; +import { FilteringContainer } from '@/features/filtering/components/FilteringContainer'; import { FilteringIcon } from '@/features/filtering/components/FilteringIcon'; +import { FormSubmissionsFollowUpFilter } from '@/features/filtering/components/FormSubmissionsFollowUpFilter'; +import { QuickReportsLocationTypeFilter } from '@/features/filtering/components/LocationTypeFilters'; +import { QuickReportsIncidentCategoryFilter } from '@/features/filtering/components/QuickReportsIncidentCategoryFilter'; import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; import { Cog8ToothIcon } from '@heroicons/react/24/outline'; import { getRouteApi } from '@tanstack/react-router'; @@ -23,14 +25,11 @@ import { useDebounce } from '@uidotdev/usehooks'; import { useCallback, useMemo } from 'react'; import { useQuickReports } from '../../hooks/quick-reports'; import { ExportedDataType } from '../../models/data-export'; -import { IncidentCategoryList, QuickReportLocationType } from '../../models/quick-report'; import type { QuickReportsSearchParams } from '../../models/search-params'; import { useQuickReportsColumnsVisibility, useQuickReportsToggleColumn } from '../../store/column-visibility'; import { quickReportsColumnDefs } from '../../utils/column-defs'; import { quickReportsColumnVisibilityOptions } from '../../utils/column-visibility-options'; -import { mapIncidentCategory, mapQuickReportFollowUpStatus, mapQuickReportLocationType } from '../../utils/helpers'; import { ExportDataButton } from '../ExportDataButton/ExportDataButton'; -import { ResetFiltersButton } from '../ResetFiltersButton/ResetFiltersButton'; const routeApi = getRouteApi('/responses/'); @@ -52,7 +51,7 @@ export function QuickReportsTab(): FunctionComponent { ['level4Filter', debouncedSearch.level4Filter], ['level5Filter', debouncedSearch.level5Filter], ['pollingStationNumberFilter', debouncedSearch.pollingStationNumberFilter], - ['quickReportFollowUpStatus', debouncedSearch.quickReportFollowUpStatus], + ['quickReportFollowUpStatus', debouncedSearch.followUpStatus], ['quickReportLocationType', debouncedSearch.quickReportLocationType], ['incidentCategory', debouncedSearch.incidentCategory], ].filter(([_, value]) => value); @@ -125,157 +124,14 @@ export function QuickReportsTab(): FunctionComponent { {filteringIsExpanded && ( -
- - - - - - - - - - {filteringIsExpanded && ( -
- {search.quickReportFollowUpStatus && ( - - )} - {search.quickReportLocationType && ( - - )} - {search.incidentCategory && ( - - )} - {search.level1Filter && ( - - )} - - {search.level2Filter && ( - - )} - - {search.level3Filter && ( - - )} - - {search.level4Filter && ( - - )} - - {search.level5Filter && ( - - )} - - {search.pollingStationNumberFilter && ( - - )} -
- )} -
+ <> + + + + + + + )} From 86b608146d0af0820a01b40cbb2bbafd3293a06f Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Wed, 30 Oct 2024 14:31:54 +0200 Subject: [PATCH 17/17] map quick reports location type in the ActiveFilters component --- web/src/features/filtering/components/ActiveFilters.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/web/src/features/filtering/components/ActiveFilters.tsx b/web/src/features/filtering/components/ActiveFilters.tsx index 81a964228..9342c369c 100644 --- a/web/src/features/filtering/components/ActiveFilters.tsx +++ b/web/src/features/filtering/components/ActiveFilters.tsx @@ -7,6 +7,7 @@ import { mapIncidentCategory, mapIncidentReportLocationType, mapQuickReportFollowUpStatus, + mapQuickReportLocationType, } from '@/features/responses/utils/helpers'; import { isNotNilOrWhitespace, toBoolean } from '@/lib/utils'; import { useNavigate } from '@tanstack/react-router'; @@ -58,6 +59,7 @@ const FILTER_LABELS = new Map([ [FILTER_KEY.QuickReportFollowUpStatus, FILTER_LABEL.QuickReportFollowUpStatus], [FILTER_KEY.HasQuickReports, FILTER_LABEL.HasQuickReports], [FILTER_KEY.IncidentReportLocationType, FILTER_LABEL.IncidentReportLocationType], + [FILTER_KEY.QuickReportLocationType, FILTER_LABEL.IncidentReportLocationType], ]); const FILTER_VALUE_LOCALIZATORS = new Map string>([ @@ -65,6 +67,7 @@ const FILTER_VALUE_LOCALIZATORS = new Map string>([ [FILTER_KEY.FormSubmissionFollowUpStatus, mapFormSubmissionFollowUpStatus], [FILTER_KEY.QuickReportIncidentCategory, mapIncidentCategory], [FILTER_KEY.IncidentReportLocationType, mapIncidentReportLocationType], + [FILTER_KEY.QuickReportLocationType, mapQuickReportLocationType], ]); const ActiveFilter: FC = ({ filterId, value, isArray }) => {