From 0fe3fbe947c1d0d031bdaa93d9e1661fe55e5622 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 6 Sep 2024 09:07:50 +0300 Subject: [PATCH 01/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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/11] 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 75fbf322c1c7452287e9fef2cd70fa492f1d00fa Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Mon, 28 Oct 2024 11:28:30 +0200 Subject: [PATCH 11/11] fix font size and text aligment --- .../ElectionEventDetails.tsx | 18 ++++----- .../components/Guides/GuidesDashboard.tsx | 4 +- .../forms/components/Dashboard/Dashboard.tsx | 37 +++++++++++-------- .../MonitoringObserversList.tsx | 6 ++- .../components/PushMessages/PushMessages.tsx | 4 +- .../components/Dashboard/Dashboard.tsx | 2 +- .../FormSubmissionsTab/FormSubmissionsTab.tsx | 4 +- .../IncidentReportsTab/IncidentReportsTab.tsx | 6 ++- .../QuickReportsTab/QuickReportsTab.tsx | 6 +-- 9 files changed, 48 insertions(+), 39 deletions(-) diff --git a/web/src/features/election-event/components/ElectionEventDetails/ElectionEventDetails.tsx b/web/src/features/election-event/components/ElectionEventDetails/ElectionEventDetails.tsx index 8bd464553..2933cb1ab 100644 --- a/web/src/features/election-event/components/ElectionEventDetails/ElectionEventDetails.tsx +++ b/web/src/features/election-event/components/ElectionEventDetails/ElectionEventDetails.tsx @@ -3,35 +3,33 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { cn } from '@/lib/utils'; -import { useElectionRoundDetails } from '../../hooks/election-event-hooks'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; import { useTranslation } from 'react-i18next'; +import { useElectionRoundDetails } from '../../hooks/election-event-hooks'; export default function ElectionEventDetails() { const { t } = useTranslation(); - const currentElectionRoundId = useCurrentElectionRoundStore(s => s.currentElectionRoundId); + const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); const { data: electionEvent } = useElectionRoundDetails(currentElectionRoundId); return (
- {t('electionEvent.eventDetails.cardTitle')} + + {t('electionEvent.eventDetails.cardTitle')} +

{t('electionEvent.eventDetails.title')}

-

- {electionEvent?.title} -

+

{electionEvent?.title}

{t('electionEvent.eventDetails.englishTitle')}

-

- {electionEvent?.englishTitle} -

+

{electionEvent?.englishTitle}

{t('electionEvent.eventDetails.country')}

@@ -48,7 +46,7 @@ export default function ElectionEventDetails() { className={cn({ 'text-slate-700 bg-slate-200': electionEvent?.status === 'NotStarted', 'text-green-700 bg-green-200': electionEvent?.status === 'Started', - 'text-yellow-700 bg-yellow-200': electionEvent?.status === 'Archived' + 'text-yellow-700 bg-yellow-200': electionEvent?.status === 'Archived', })}> {electionEvent?.status} diff --git a/web/src/features/election-event/components/Guides/GuidesDashboard.tsx b/web/src/features/election-event/components/Guides/GuidesDashboard.tsx index 1d485a9c2..bca4a45b1 100644 --- a/web/src/features/election-event/components/Guides/GuidesDashboard.tsx +++ b/web/src/features/election-event/components/Guides/GuidesDashboard.tsx @@ -215,8 +215,8 @@ export default function GuidesDashboard({ guidePageType }: GuidesDashboardProps) return ( -
- +
+ {guidePageType === GuidePageType.Observer ? i18n.t('electionEvent.guides.observerGuidesCardTitle') : i18n.t('electionEvent.guides.citizenGuidesCardTitle')} diff --git a/web/src/features/forms/components/Dashboard/Dashboard.tsx b/web/src/features/forms/components/Dashboard/Dashboard.tsx index bd9d661b5..9a0386a67 100644 --- a/web/src/features/forms/components/Dashboard/Dashboard.tsx +++ b/web/src/features/forms/components/Dashboard/Dashboard.tsx @@ -26,7 +26,13 @@ import i18n from '@/i18n'; import { cn, isNotNilOrWhitespace, mapFormType } from '@/lib/utils'; import { queryClient } from '@/main'; import { FormsSearchParams, Route } from '@/routes/election-event/$tab'; -import { ChevronDownIcon, ChevronUpIcon, EllipsisVerticalIcon, FunnelIcon, PhotoIcon } from '@heroicons/react/24/outline'; +import { + ChevronDownIcon, + ChevronUpIcon, + EllipsisVerticalIcon, + FunnelIcon, + PhotoIcon, +} from '@heroicons/react/24/outline'; import { useMutation } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { ColumnDef, createColumnHelper, Row } from '@tanstack/react-table'; @@ -317,18 +323,17 @@ export default function FormsDashboard(): ReactElement { row.depth === 0 ? ( isNotNilOrWhitespace(row.original.icon) ? ( - - - - - - - -
- -
-
-
+ + + + + + + +
+
+
+ ) : ( <> ) @@ -506,8 +511,10 @@ export default function FormsDashboard(): ReactElement { return ( - -
{i18n.t('electionEvent.observerForms.cardTitle')}
+ +
+ {i18n.t('electionEvent.observerForms.cardTitle')} +
diff --git a/web/src/features/monitoring-observers/components/MonitoringObserversList/MonitoringObserversList.tsx b/web/src/features/monitoring-observers/components/MonitoringObserversList/MonitoringObserversList.tsx index c1a498074..1a00ae0db 100644 --- a/web/src/features/monitoring-observers/components/MonitoringObserversList/MonitoringObserversList.tsx +++ b/web/src/features/monitoring-observers/components/MonitoringObserversList/MonitoringObserversList.tsx @@ -251,8 +251,10 @@ function MonitoringObserversList() { return ( -
- Monitoring observers list +
+ + Monitoring observers list +
{!!importErrorsFileId && ( -
- Push messages +
+ Push messages