From 0fe3fbe947c1d0d031bdaa93d9e1661fe55e5622 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 6 Sep 2024 09:07:50 +0300 Subject: [PATCH 01/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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/33] 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 eee138e3cba252203c47d57213d63861b2cf28cb Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Wed, 9 Oct 2024 12:08:46 +0300 Subject: [PATCH 11/33] WIP: fix language selector --- web/src/components/LanguageSelector/LanguageSelector.tsx | 7 ++++++- web/src/i18n.ts | 8 ++++++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/web/src/components/LanguageSelector/LanguageSelector.tsx b/web/src/components/LanguageSelector/LanguageSelector.tsx index b3a6350bf..c3f4d85a8 100644 --- a/web/src/components/LanguageSelector/LanguageSelector.tsx +++ b/web/src/components/LanguageSelector/LanguageSelector.tsx @@ -1,11 +1,16 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import i18n from '@/i18n'; +import { useRouter } from '@tanstack/react-router'; import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; import { Label } from '../ui/label'; export const LanguageSelector: FC = () => { + const { t, i18n } = useTranslation(); // not passing any namespace will use the defaultNS (by default set to 'translation') + const router = useRouter(); + const changeLanguage = (lng: string) => { i18n.changeLanguage(lng); + router.invalidate(); }; const options = [ diff --git a/web/src/i18n.ts b/web/src/i18n.ts index 882bfdd50..1beed8dd5 100644 --- a/web/src/i18n.ts +++ b/web/src/i18n.ts @@ -16,15 +16,19 @@ const resources = { export type Dict = typeof resources.en; +const DETECTION_OPTIONS = { + order: ['localStorage', 'navigator'], + caches: ['localStorage'], +}; + i18n .use(LanguageDetector) .use(initReactI18next) .init({ debug: true, - lng: 'en', - supportedLngs: ['en', 'ro'], defaultNS: 'translation', contextSeparator: '|', + detection: DETECTION_OPTIONS, interpolation: { escapeValue: false, }, From 505face2e3490a8582f74ea3a2c36ba5db0e2587 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Wed, 9 Oct 2024 13:57:25 +0300 Subject: [PATCH 12/33] =?UTF-8?q?WIP=C8=98=20add=20forrm=20wizard=20start?= =?UTF-8?q?=20page?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../FormBuilder/components/FormBuilder.tsx | 16 + .../components/FormBuilderChoice.tsx | 59 ++++ .../forms/components/Dashboard/Dashboard.tsx | 314 +++++++++++------- web/src/locales/en.json | 19 ++ web/src/locales/ro.json | 6 +- web/src/routeTree.gen.ts | 11 + web/src/routes/election-event/new-form.tsx | 16 + 7 files changed, 323 insertions(+), 118 deletions(-) create mode 100644 web/src/features/election-event/components/FormBuilder/components/FormBuilder.tsx create mode 100644 web/src/features/election-event/components/FormBuilder/components/FormBuilderChoice.tsx create mode 100644 web/src/routes/election-event/new-form.tsx diff --git a/web/src/features/election-event/components/FormBuilder/components/FormBuilder.tsx b/web/src/features/election-event/components/FormBuilder/components/FormBuilder.tsx new file mode 100644 index 000000000..c6df1c8fc --- /dev/null +++ b/web/src/features/election-event/components/FormBuilder/components/FormBuilder.tsx @@ -0,0 +1,16 @@ +import Layout from '@/components/layout/Layout'; +import i18n from '@/i18n'; +import { FC } from 'react'; +import { FormBuilderChoice } from './FormBuilderChoice'; + +export const FormBuilder: FC = () => { + return ( + +
+ + + +
+
+ ); +}; diff --git a/web/src/features/election-event/components/FormBuilder/components/FormBuilderChoice.tsx b/web/src/features/election-event/components/FormBuilder/components/FormBuilderChoice.tsx new file mode 100644 index 000000000..73da6ba2f --- /dev/null +++ b/web/src/features/election-event/components/FormBuilder/components/FormBuilderChoice.tsx @@ -0,0 +1,59 @@ +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { ClipboardDocumentListIcon, DocumentPlusIcon, DocumentTextIcon } from '@heroicons/react/24/outline'; +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +interface FormBuilderChoiceIconProps { + type: FormBuilderChoice; +} + +const FormBuilderChoiceIcon: FC = ({ type }) => { + const classes = 'stroke-purple-900 h-16 w-16 md:h-32 md:w-32'; + switch (type) { + case 'scratch': + return ; + + case 'template': + return ; + + case 'reuse': + return ; + + default: + return <>; + } +}; + +type FormBuilderChoice = 'scratch' | 'template' | 'reuse'; + +interface FormBuilderChoiceProps { + type: FormBuilderChoice; +} +export const FormBuilderChoice: FC = ({ type }) => { + const { t } = useTranslation(); + + const mapTranslatedText = (property: 'title' | 'description' | 'buttonText') => { + return t(`electionEvent.form.${type}.${property}`); + }; + + return ( + + + {mapTranslatedText('title')} + + +
+ +

{mapTranslatedText('description')}

+ +
+
+
+ ); +}; diff --git a/web/src/features/forms/components/Dashboard/Dashboard.tsx b/web/src/features/forms/components/Dashboard/Dashboard.tsx index 644008905..d788cddb7 100644 --- a/web/src/features/forms/components/Dashboard/Dashboard.tsx +++ b/web/src/features/forms/components/Dashboard/Dashboard.tsx @@ -1,7 +1,6 @@ import { authApi } from '@/common/auth-api'; import { DateTimeFormat } from '@/common/formats'; import { ZFormType, ZTranslationStatus } from '@/common/types'; -import CreateDialog from '@/components/dialogs/CreateDialog'; import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader'; import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; import { useConfirm } from '@/components/ui/alert-dialog-provider'; @@ -19,28 +18,33 @@ import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectVa import { Separator } from '@/components/ui/separator'; import { toast } from '@/components/ui/use-toast'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; +import { useLanguages } from '@/hooks/languages'; +import i18n from '@/i18n'; import { cn, mapFormType } from '@/lib/utils'; import { queryClient } from '@/main'; -import { ChevronDownIcon, ChevronUpIcon, Cog8ToothIcon, EllipsisVerticalIcon, FunnelIcon } from '@heroicons/react/24/outline'; +import { + ChevronDownIcon, + ChevronUpIcon, + Cog8ToothIcon, + EllipsisVerticalIcon, + FunnelIcon, +} from '@heroicons/react/24/outline'; import { useMutation } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { ColumnDef, Row } from '@tanstack/react-table'; import { format } from 'date-fns'; -import { X } from 'lucide-react'; +import { PlusIcon, X } from 'lucide-react'; import { useState, type ReactElement } from 'react'; import { FormBase, FormStatus } from '../../models/form'; import { formsKeys, useForms } from '../../queries'; import AddTranslationsDialog, { useAddTranslationsDialog } from './AddTranslationsDialog'; -import CreateForm from './CreateForm'; -import i18n from '@/i18n'; -import { useLanguages } from '@/hooks/languages'; export default function FormsDashboard(): ReactElement { const addTranslationsDialog = useAddTranslationsDialog(); const confirm = useConfirm(); const { data: languages } = useLanguages(); - const currentElectionRoundId = useCurrentElectionRoundStore(s => s.currentElectionRoundId); - const isMonitoringNgoForCitizenReporting = useCurrentElectionRoundStore(s => s.isMonitoringNgoForCitizenReporting); + const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); + const isMonitoringNgoForCitizenReporting = useCurrentElectionRoundStore((s) => s.isMonitoringNgoForCitizenReporting); const formColDefs: ColumnDef[] = [ { @@ -53,9 +57,12 @@ export default function FormsDashboard(): ReactElement { {...{ onClick: row.getToggleExpandedHandler(), style: { cursor: 'pointer' }, - }} - > - {row.getIsExpanded() ? : } + }}> + {row.getIsExpanded() ? ( + + ) : ( + + )} ) : ( '' @@ -63,79 +70,106 @@ export default function FormsDashboard(): ReactElement { {getValue()}
), - enableResizing: false + enableResizing: false, }, { accessorKey: 'code', enableSorting: true, - header: ({ column }) => , + header: ({ column }) => ( + + ), }, { id: 'name', accessorFn: (row, _) => row.name[row.defaultLanguage], enableSorting: false, - header: ({ column }) => , + header: ({ column }) => ( + + ), }, { accessorKey: 'formType', accessorFn: (row, _) => mapFormType(row.formType), enableSorting: false, enableResizing: false, - header: ({ column }) => , + header: ({ column }) => ( + + ), cell: ({ row }) => (row.depth === 0 ? row.original.formType : ''), }, { accessorKey: 'defaultLanguage', enableSorting: false, enableResizing: false, - header: ({ column }) => , + header: ({ column }) => ( + + ), }, { accessorKey: 'numberOfQuestions', enableSorting: false, enableResizing: false, - header: ({ column }) => , + header: ({ column }) => ( + + ), cell: ({ row }) => (row.depth === 0 ? row.original.numberOfQuestions : ''), }, { accessorKey: 'status', enableSorting: false, enableResizing: false, - header: ({ column }) => , + header: ({ column }) => ( + + ), cell: ({ row }) => { const form = row.original; - return row.depth === 0 ? + return row.depth === 0 ? ( {form.status} - : - {form.languagesTranslationStatus[form.defaultLanguage] === ZTranslationStatus.enum.Translated ? 'Translated' : form.languagesTranslationStatus[form.defaultLanguage] === ZTranslationStatus.enum.MissingTranslations ? 'Missing translation' : 'Unknown'} + {form.languagesTranslationStatus[form.defaultLanguage] === ZTranslationStatus.enum.Translated + ? 'Translated' + : form.languagesTranslationStatus[form.defaultLanguage] === ZTranslationStatus.enum.MissingTranslations + ? 'Missing translation' + : 'Unknown'} + ); }, }, { accessorKey: 'lastUpdatedOn', enableSorting: false, enableResizing: false, - header: ({ column }) => , - cell: ({ row }) => ( - row.depth === 0 ? + header: ({ column }) => ( + + ), + cell: ({ row }) => + row.depth === 0 ? (
-

{row.original.lastModifiedOn ? format(row.original.lastModifiedOn, DateTimeFormat) : format(row.original.createdOn, DateTimeFormat)}

+

+ {row.original.lastModifiedOn + ? format(row.original.lastModifiedOn, DateTimeFormat) + : format(row.original.createdOn, DateTimeFormat)}{' '} +

- : <> - ), + ) : ( + <> + ), }, { header: '', @@ -148,67 +182,98 @@ export default function FormsDashboard(): ReactElement { - navigateToForm(row.original.id, row.original.defaultLanguage)}>View - - { - row.depth === 0 ? - 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 - : null - } - { - row.depth === 0 && row.original.status === FormStatus.Published ? - handleObsoleteForm(row.original)}>Obsolete - : null - } - { - row.depth === 0 && row.original.status === FormStatus.Drafted ? - handlePublishForm(row.original)}>Publish - : null - } - { - row.depth === 0 ? - handleDuplicateForm(row.original)}>Duplicate - : null - } - {row.depth === 0 ? - { - if (await confirm({ - title: `Delete form ${row.original.code}?`, - body: row.original.status === FormStatus.Published ? <>Please note that this form is published and may contain associated data. Deleting this form could result in the loss of any submitted answers from your observers. Once deleted, the associated data cannot be retrieved : 'This action is permanent and cannot be undone. Once deleted, this form cannot be retrieved.', - actionButton: 'Delete', - actionButtonClass: buttonVariants({ variant: "destructive" }), - cancelButton: 'Cancel', - })) { - deleteFormMutation.mutate({ electionRoundId: currentElectionRoundId, formId: row.original.id }); - } - }}> + navigateToForm(row.original.id, row.original.defaultLanguage)}> + View + + + {row.depth === 0 ? ( + 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 + + ) : null} + {row.depth === 0 && row.original.status === FormStatus.Published ? ( + handleObsoleteForm(row.original)}>Obsolete + ) : null} + {row.depth === 0 && row.original.status === FormStatus.Drafted ? ( + handlePublishForm(row.original)}>Publish + ) : null} + {row.depth === 0 ? ( + handleDuplicateForm(row.original)}>Duplicate + ) : null} + {row.depth === 0 ? ( + { + if ( + await confirm({ + title: `Delete form ${row.original.code}?`, + body: + row.original.status === FormStatus.Published ? ( + <> + Please note that this form is published and may contain associated data. Deleting this form + could result in the loss of any submitted answers from your observers. Once deleted,{' '} + the associated data cannot be retrieved + + ) : ( + 'This action is permanent and cannot be undone. Once deleted, this form cannot be retrieved.' + ), + actionButton: 'Delete', + actionButtonClass: buttonVariants({ variant: 'destructive' }), + cancelButton: 'Cancel', + }) + ) { + deleteFormMutation.mutate({ electionRoundId: currentElectionRoundId, formId: row.original.id }); + } + }}> Delete form - : - { - const languageCode = row.original.defaultLanguage; - const language = languages?.find(l => languageCode === l.code); - const fullName = language ? `${language.name} / ${language.nativeName}` : ''; - - if (await confirm({ - title: `Delete translation ${fullName}?`, - body: 'This action is permanent and cannot be undone. Once deleted, this translation cannot be retrieved.', - actionButton: 'Delete', - actionButtonClass: buttonVariants({ variant: "destructive" }), - cancelButton: 'Cancel', - })) { - deleteTranslationMutation.mutate({ electionRoundId: currentElectionRoundId, formId: row.original.id, languageCode }); - } - }}>Delete translation} + ) : ( + { + const languageCode = row.original.defaultLanguage; + const language = languages?.find((l) => languageCode === l.code); + const fullName = language ? `${language.name} / ${language.nativeName}` : ''; + + if ( + await confirm({ + title: `Delete translation ${fullName}?`, + body: 'This action is permanent and cannot be undone. Once deleted, this translation cannot be retrieved.', + actionButton: 'Delete', + actionButtonClass: buttonVariants({ variant: 'destructive' }), + cancelButton: 'Cancel', + }) + ) { + deleteTranslationMutation.mutate({ + electionRoundId: currentElectionRoundId, + formId: row.original.id, + languageCode, + }); + } + }}> + Delete translation + + )} - ) - } + + ), + }, ]; const [searchText, setSearchText] = useState(''); @@ -221,15 +286,15 @@ export default function FormsDashboard(): ReactElement { const handleObsoleteForm = (form: FormBase) => { obsoleteFormMutation.mutate({ electionRoundId: currentElectionRoundId, formId: form.id }); - } + }; const handlePublishForm = (form: FormBase) => { publishFormMutation.mutate({ electionRoundId: currentElectionRoundId, formId: form.id }); - } + }; const handleDuplicateForm = (form: FormBase) => { duplicateFormMutation.mutate({ electionRoundId: currentElectionRoundId, formId: form.id }); - } + }; const navigateToForm = (formId: string, languageCode: string) => { navigate({ to: '/forms/$formId/$languageCode', params: { formId, languageCode } }); @@ -245,7 +310,15 @@ export default function FormsDashboard(): ReactElement { const deleteTranslationMutation = useMutation({ mutationKey: formsKeys.all, - mutationFn: ({ electionRoundId, formId, languageCode }: { electionRoundId: string; formId: string; languageCode: string; }) => { + mutationFn: ({ + electionRoundId, + formId, + languageCode, + }: { + electionRoundId: string; + formId: string; + languageCode: string; + }) => { return authApi.delete(`/election-rounds/${electionRoundId}/forms/${formId}/${languageCode}`); }, @@ -280,17 +353,17 @@ export default function FormsDashboard(): ReactElement { toast({ title: 'Error publishing form', description: 'You are missing translations. Please translate all fields and try again', - variant: 'destructive' + variant: 'destructive', }); - return + return; } toast({ title: 'Error publishing form', description: 'Please contact tech support', - variant: 'destructive' + variant: 'destructive', }); - } + }, }); const obsoleteFormMutation = useMutation({ @@ -312,9 +385,9 @@ export default function FormsDashboard(): ReactElement { toast({ title: 'Error obsoleting form', description: 'Please contact tech support', - variant: 'destructive' + variant: 'destructive', }); - } + }, }); const duplicateFormMutation = useMutation({ @@ -336,17 +409,15 @@ export default function FormsDashboard(): ReactElement { toast({ title: 'Error cloning form', description: 'Please contact tech support', - variant: 'destructive' + variant: 'destructive', }); - } + }, }); const deleteFormMutation = useMutation({ mutationKey: formsKeys.all, mutationFn: ({ electionRoundId, formId }: { electionRoundId: string; formId: string }) => { - return authApi.delete( - `/election-rounds/${electionRoundId}/forms/${formId}` - ); + return authApi.delete(`/election-rounds/${electionRoundId}/forms/${formId}`); }, onSuccess: async () => { toast({ @@ -376,12 +447,12 @@ export default function FormsDashboard(): ReactElement { // we need to have subrows only for translations return originalRow.languages - .filter(languageCode => originalRow.defaultLanguage !== languageCode) - .map(languageCode => ({ + .filter((languageCode) => originalRow.defaultLanguage !== languageCode) + .map((languageCode) => ({ ...originalRow, languages: [], code: `${originalRow.code} - ${languageCode}`, - defaultLanguage: languageCode + defaultLanguage: languageCode, })); }; @@ -391,18 +462,19 @@ export default function FormsDashboard(): ReactElement { -
- {i18n.t("electionEvent.observerForms.cardTitle")} -
+
{i18n.t('electionEvent.observerForms.cardTitle')}
- - - +
-
+
+ +
{mapFormType(ZFormType.Values.Opening)} {mapFormType(ZFormType.Values.Voting)} - {mapFormType(ZFormType.Values.ClosingAndCounting)} - {isMonitoringNgoForCitizenReporting && {mapFormType(ZFormType.Values.CitizenReporting)}} - {mapFormType(ZFormType.Values.IncidentReporting)} + + {mapFormType(ZFormType.Values.ClosingAndCounting)} + + {isMonitoringNgoForCitizenReporting && ( + + {mapFormType(ZFormType.Values.CitizenReporting)} + + )} + + {mapFormType(ZFormType.Values.IncidentReporting)} + {mapFormType(ZFormType.Values.Other)} diff --git a/web/src/locales/en.json b/web/src/locales/en.json index 0769f722e..24458a703 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -217,6 +217,25 @@ "level5": "Level 5" }, "resetFilters": "Reset filters" + }, + "form": { + "title": "Create new form", + "subtitle": "Choose how you'd like to start your form.", + "scratch": { + "title": "Start from scratch", + "description": "Create a completely new form", + "buttonText": "Begin new form" + }, + "template": { + "title": "Start from a VM template", + "description": "Choose from available templates", + "buttonText": "Browse templates" + }, + "reuse": { + "title": "Reuse a previous form", + "description": "Reuse or modify a form from a past election event you have monitored", + "buttonText": "Reuse previous form" + } } }, "pagination": { diff --git a/web/src/locales/ro.json b/web/src/locales/ro.json index 0769f722e..d75cbfb43 100644 --- a/web/src/locales/ro.json +++ b/web/src/locales/ro.json @@ -217,12 +217,16 @@ "level5": "Level 5" }, "resetFilters": "Reset filters" + }, + "form": { + "title": "Creează un nou formular", + "subtitle": "Alege modul în care vrei să începi." } }, "pagination": { "dataTable": { "resultsSummary": "Showing {{start}} to {{end}} of {{total}} results", - "perPage": "per page", + "perPage": "per pagină", "goToFirstPage": "Go to first page", "goToPreviousPage": "Go to previous page", "goToNextPage": "Go to next page", diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 20a478450..1d4594f7c 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -31,6 +31,7 @@ import { Route as MonitoringObserversCreateNewMessageImport } from './routes/mon import { Route as MonitoringObserversTabImport } from './routes/monitoring-observers/$tab' import { Route as FormsFormIdImport } from './routes/forms/$formId' import { Route as ElectionRoundsElectionRoundIdImport } from './routes/election-rounds/$electionRoundId' +import { Route as ElectionEventNewFormImport } from './routes/election-event/new-form' import { Route as ElectionEventTabImport } from './routes/election-event/$tab' import { Route as CitizenGuidesNewImport } from './routes/citizen-guides/new' import { Route as AcceptInviteSuccessImport } from './routes/accept-invite/success' @@ -155,6 +156,11 @@ const ElectionRoundsElectionRoundIdRoute = getParentRoute: () => rootRoute, } as any) +const ElectionEventNewFormRoute = ElectionEventNewFormImport.update({ + path: '/election-event/new-form', + getParentRoute: () => rootRoute, +} as any) + const ElectionEventTabRoute = ElectionEventTabImport.update({ path: '/election-event/$tab', getParentRoute: () => rootRoute, @@ -280,6 +286,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ElectionEventTabImport parentRoute: typeof rootRoute } + '/election-event/new-form': { + preLoaderRoute: typeof ElectionEventNewFormImport + parentRoute: typeof rootRoute + } '/election-rounds/$electionRoundId': { preLoaderRoute: typeof ElectionRoundsElectionRoundIdImport parentRoute: typeof rootRoute @@ -430,6 +440,7 @@ export const routeTree = rootRoute.addChildren([ AcceptInviteSuccessRoute, CitizenGuidesNewRoute, ElectionEventTabRoute, + ElectionEventNewFormRoute, ElectionRoundsElectionRoundIdRoute, FormsFormIdRoute, MonitoringObserversTabRoute, diff --git a/web/src/routes/election-event/new-form.tsx b/web/src/routes/election-event/new-form.tsx new file mode 100644 index 000000000..188056cea --- /dev/null +++ b/web/src/routes/election-event/new-form.tsx @@ -0,0 +1,16 @@ +import { FormBuilder } from '@/features/election-event/components/FormBuilder/components/FormBuilder'; +import { PushMessageTargetedObserversSearchParamsSchema } from '@/features/monitoring-observers/models/search-params'; +import { redirectIfNotAuth } from '@/lib/utils'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/election-event/new-form')({ + beforeLoad: () => { + redirectIfNotAuth(); + }, + component: CreateNewForm, + validateSearch: PushMessageTargetedObserversSearchParamsSchema, +}); + +function CreateNewForm() { + return ; +} From 43b09ebb193ce77bf89abcf4d9dcde42938cd180 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Wed, 9 Oct 2024 18:34:56 +0300 Subject: [PATCH 13/33] WIP: update FormBuilder --- .../FormBuilder/components/FormBuilder.tsx | 38 +++++++++++++++---- .../components/FormBuilderChoice.tsx | 14 +++---- 2 files changed, 35 insertions(+), 17 deletions(-) diff --git a/web/src/features/election-event/components/FormBuilder/components/FormBuilder.tsx b/web/src/features/election-event/components/FormBuilder/components/FormBuilder.tsx index c6df1c8fc..15e9c9130 100644 --- a/web/src/features/election-event/components/FormBuilder/components/FormBuilder.tsx +++ b/web/src/features/election-event/components/FormBuilder/components/FormBuilder.tsx @@ -1,16 +1,38 @@ import Layout from '@/components/layout/Layout'; -import i18n from '@/i18n'; -import { FC } from 'react'; +import { FC, useState } from 'react'; +import { useTranslation } from 'react-i18next'; import { FormBuilderChoice } from './FormBuilderChoice'; +enum FormBuilderScreens { + Start, + Scratch, + Template, + Reuse, +} + +interface StartScreenProps { + setCurrentScreen: (screen: FormBuilderScreens) => void; +} + +const StartScreen: FC = ({ setCurrentScreen }) => { + return ( +
+ setCurrentScreen(FormBuilderScreens.Scratch)} /> + + +
+ ); +}; + export const FormBuilder: FC = () => { + const { t } = useTranslation('translation', { keyPrefix: 'electionEvent.form' }); + + const [currentScreen, setCurrentScreen] = useState(FormBuilderScreens.Start); + return ( - -
- - - -
+ + {currentScreen === FormBuilderScreens.Start && } + {currentScreen === FormBuilderScreens.Scratch &&
Scratch screen
}
); }; diff --git a/web/src/features/election-event/components/FormBuilder/components/FormBuilderChoice.tsx b/web/src/features/election-event/components/FormBuilder/components/FormBuilderChoice.tsx index 73da6ba2f..0b977c58d 100644 --- a/web/src/features/election-event/components/FormBuilder/components/FormBuilderChoice.tsx +++ b/web/src/features/election-event/components/FormBuilder/components/FormBuilderChoice.tsx @@ -31,26 +31,22 @@ interface FormBuilderChoiceProps { type: FormBuilderChoice; } export const FormBuilderChoice: FC = ({ type }) => { - const { t } = useTranslation(); - - const mapTranslatedText = (property: 'title' | 'description' | 'buttonText') => { - return t(`electionEvent.form.${type}.${property}`); - }; + const { t } = useTranslation('translation', { keyPrefix: `electionEvent.form.${type}` }); return ( - {mapTranslatedText('title')} + {t('title')}
-

{mapTranslatedText('description')}

+

{t('description')}

From cff20a6659e5025b4e070b340c5b015b94b11286 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Thu, 10 Oct 2024 19:25:57 +0300 Subject: [PATCH 14/33] WIP: add FormBuilder screens and routes --- .../FormBuilder/components/FormBuilder.tsx | 38 ------------- .../components/FormBuilderScreenReuse.tsx | 13 +++++ .../components/FormBuilderScreenScratch.tsx | 15 +++++ ...rChoice.tsx => FormBuilderScreenStart.tsx} | 41 +++++++++++--- .../components/FormBuilderScreenTemplate.tsx | 13 +++++ .../forms/components/Dashboard/Dashboard.tsx | 18 +++--- web/src/routeTree.gen.ts | 55 +++++++++++++++---- web/src/routes/election-event/new-form.tsx | 16 ------ web/src/routes/forms/new.tsx | 14 +++++ web/src/routes/forms/new_.reuse.tsx | 14 +++++ web/src/routes/forms/new_.scratch.tsx | 14 +++++ web/src/routes/forms/new_.template.tsx | 14 +++++ 12 files changed, 185 insertions(+), 80 deletions(-) delete mode 100644 web/src/features/election-event/components/FormBuilder/components/FormBuilder.tsx create mode 100644 web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenReuse.tsx create mode 100644 web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenScratch.tsx rename web/src/features/election-event/components/FormBuilder/components/{FormBuilderChoice.tsx => FormBuilderScreenStart.tsx} (55%) create mode 100644 web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenTemplate.tsx delete mode 100644 web/src/routes/election-event/new-form.tsx create mode 100644 web/src/routes/forms/new.tsx create mode 100644 web/src/routes/forms/new_.reuse.tsx create mode 100644 web/src/routes/forms/new_.scratch.tsx create mode 100644 web/src/routes/forms/new_.template.tsx diff --git a/web/src/features/election-event/components/FormBuilder/components/FormBuilder.tsx b/web/src/features/election-event/components/FormBuilder/components/FormBuilder.tsx deleted file mode 100644 index 15e9c9130..000000000 --- a/web/src/features/election-event/components/FormBuilder/components/FormBuilder.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import Layout from '@/components/layout/Layout'; -import { FC, useState } from 'react'; -import { useTranslation } from 'react-i18next'; -import { FormBuilderChoice } from './FormBuilderChoice'; - -enum FormBuilderScreens { - Start, - Scratch, - Template, - Reuse, -} - -interface StartScreenProps { - setCurrentScreen: (screen: FormBuilderScreens) => void; -} - -const StartScreen: FC = ({ setCurrentScreen }) => { - return ( -
- setCurrentScreen(FormBuilderScreens.Scratch)} /> - - -
- ); -}; - -export const FormBuilder: FC = () => { - const { t } = useTranslation('translation', { keyPrefix: 'electionEvent.form' }); - - const [currentScreen, setCurrentScreen] = useState(FormBuilderScreens.Start); - - return ( - - {currentScreen === FormBuilderScreens.Start && } - {currentScreen === FormBuilderScreens.Scratch &&
Scratch screen
} -
- ); -}; diff --git a/web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenReuse.tsx b/web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenReuse.tsx new file mode 100644 index 000000000..3289d9cc2 --- /dev/null +++ b/web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenReuse.tsx @@ -0,0 +1,13 @@ +import Layout from '@/components/layout/Layout'; +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const FormBuilderScreenReuse: FC = () => { + const { t } = useTranslation('translation', { keyPrefix: 'electionEvent.form' }); + + return ( + +
+
+ ); +}; diff --git a/web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenScratch.tsx b/web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenScratch.tsx new file mode 100644 index 000000000..d6b78e48f --- /dev/null +++ b/web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenScratch.tsx @@ -0,0 +1,15 @@ +import Layout from '@/components/layout/Layout'; +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const FormBuilderScreenScratch: FC = () => { + const { t } = useTranslation('translation', { keyPrefix: 'electionEvent.form' }); + + return ( + +
+ +
+
+ ); +}; diff --git a/web/src/features/election-event/components/FormBuilder/components/FormBuilderChoice.tsx b/web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenStart.tsx similarity index 55% rename from web/src/features/election-event/components/FormBuilder/components/FormBuilderChoice.tsx rename to web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenStart.tsx index 0b977c58d..e2b1d4d3d 100644 --- a/web/src/features/election-event/components/FormBuilder/components/FormBuilderChoice.tsx +++ b/web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenStart.tsx @@ -1,6 +1,9 @@ +import { BackButtonIcon } from '@/components/layout/Breadcrumbs/BackButton'; +import Layout from '@/components/layout/Layout'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ClipboardDocumentListIcon, DocumentPlusIcon, DocumentTextIcon } from '@heroicons/react/24/outline'; +import { Link } from '@tanstack/react-router'; import { FC } from 'react'; import { useTranslation } from 'react-i18next'; @@ -30,7 +33,7 @@ type FormBuilderChoice = 'scratch' | 'template' | 'reuse'; interface FormBuilderChoiceProps { type: FormBuilderChoice; } -export const FormBuilderChoice: FC = ({ type }) => { +const FormBuilderChoice: FC = ({ type }) => { const { t } = useTranslation('translation', { keyPrefix: `electionEvent.form.${type}` }); return ( @@ -42,14 +45,38 @@ export const FormBuilderChoice: FC = ({ type }) => {

{t('description')}

- + + + +
); }; + +export const FormBuilderScreenStart: FC = () => { + const { t } = useTranslation('translation', { keyPrefix: 'electionEvent.form' }); + + return ( + + + + }> +
+ + + +
+
+ ); +}; diff --git a/web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenTemplate.tsx b/web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenTemplate.tsx new file mode 100644 index 000000000..6b49c446a --- /dev/null +++ b/web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenTemplate.tsx @@ -0,0 +1,13 @@ +import Layout from '@/components/layout/Layout'; +import { FC } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const FormBuilderScreenTemplate: FC = () => { + const { t } = useTranslation('translation', { keyPrefix: 'electionEvent.form' }); + + return ( + +
+
+ ); +}; diff --git a/web/src/features/forms/components/Dashboard/Dashboard.tsx b/web/src/features/forms/components/Dashboard/Dashboard.tsx index 35c3d8925..6a9b93cf5 100644 --- a/web/src/features/forms/components/Dashboard/Dashboard.tsx +++ b/web/src/features/forms/components/Dashboard/Dashboard.tsx @@ -30,7 +30,7 @@ import { FunnelIcon, } from '@heroicons/react/24/outline'; import { useMutation } from '@tanstack/react-query'; -import { useNavigate } from '@tanstack/react-router'; +import { Link, useNavigate } from '@tanstack/react-router'; import { ColumnDef, Row } from '@tanstack/react-table'; import { format } from 'date-fns'; import { PlusIcon, X } from 'lucide-react'; @@ -369,7 +369,7 @@ export default function FormsDashboard(): ReactElement { return authApi.post(`/election-rounds/${electionRoundId}/forms/${formId}:obsolete`); }, - onSuccess: (_data, {electionRoundId}) => { + onSuccess: (_data, { electionRoundId }) => { toast({ title: 'Success', description: 'Form obsoleted', @@ -392,7 +392,7 @@ export default function FormsDashboard(): ReactElement { return authApi.post(`/election-rounds/${electionRoundId}/forms/${formId}:duplicate`); }, - onSuccess: (_data, {electionRoundId}) => { + onSuccess: (_data, { electionRoundId }) => { toast({ title: 'Success', description: 'Form duplicated', @@ -414,7 +414,7 @@ export default function FormsDashboard(): ReactElement { mutationFn: ({ electionRoundId, formId }: { electionRoundId: string; formId: string }) => { return authApi.delete(`/election-rounds/${electionRoundId}/forms/${formId}`); }, - onSuccess: async (_data, {electionRoundId}) => { + onSuccess: async (_data, { electionRoundId }) => { toast({ title: 'Success', description: 'Form deleted', @@ -459,10 +459,12 @@ export default function FormsDashboard(): ReactElement {
{i18n.t('electionEvent.observerForms.cardTitle')}
- + + +
diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index ea4b6d7f2..e4e53b9d8 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -29,9 +29,9 @@ import { Route as ObserverGuidesNewImport } from './routes/observer-guides/new' import { Route as NgosNgoIdImport } from './routes/ngos/$ngoId' import { Route as MonitoringObserversCreateNewMessageImport } from './routes/monitoring-observers/create-new-message' import { Route as MonitoringObserversTabImport } from './routes/monitoring-observers/$tab' +import { Route as FormsNewImport } from './routes/forms/new' import { Route as FormsFormIdImport } from './routes/forms/$formId' import { Route as ElectionRoundsElectionRoundIdImport } from './routes/election-rounds/$electionRoundId' -import { Route as ElectionEventNewFormImport } from './routes/election-event/new-form' import { Route as ElectionEventTabImport } from './routes/election-event/$tab' import { Route as CitizenGuidesNewImport } from './routes/citizen-guides/new' import { Route as AcceptInviteSuccessImport } from './routes/accept-invite/success' @@ -45,6 +45,9 @@ import { Route as ObserverGuidesEditGuideIdImport } from './routes/observer-guid import { Route as MonitoringObserversPushMessagesIdImport } from './routes/monitoring-observers/push-messages.$id' import { Route as MonitoringObserversEditMonitoringObserverIdImport } from './routes/monitoring-observers/edit.$monitoringObserverId' import { Route as FormsFormIdEditImport } from './routes/forms_.$formId.edit' +import { Route as FormsNewTemplateImport } from './routes/forms/new_.template' +import { Route as FormsNewScratchImport } from './routes/forms/new_.scratch' +import { Route as FormsNewReuseImport } from './routes/forms/new_.reuse' import { Route as FormsFormIdLanguageCodeImport } from './routes/forms/$formId_.$languageCode' import { Route as CitizenGuidesViewGuideIdImport } from './routes/citizen-guides/view.$guideId' import { Route as CitizenGuidesEditGuideIdImport } from './routes/citizen-guides/edit.$guideId' @@ -147,6 +150,11 @@ const MonitoringObserversTabRoute = MonitoringObserversTabImport.update({ getParentRoute: () => rootRoute, } as any) +const FormsNewRoute = FormsNewImport.update({ + path: '/forms/new', + getParentRoute: () => rootRoute, +} as any) + const FormsFormIdRoute = FormsFormIdImport.update({ path: '/forms/$formId', getParentRoute: () => rootRoute, @@ -158,11 +166,6 @@ const ElectionRoundsElectionRoundIdRoute = getParentRoute: () => rootRoute, } as any) -const ElectionEventNewFormRoute = ElectionEventNewFormImport.update({ - path: '/election-event/new-form', - getParentRoute: () => rootRoute, -} as any) - const ElectionEventTabRoute = ElectionEventTabImport.update({ path: '/election-event/$tab', getParentRoute: () => rootRoute, @@ -233,6 +236,21 @@ const FormsFormIdEditRoute = FormsFormIdEditImport.update({ getParentRoute: () => rootRoute, } as any) +const FormsNewTemplateRoute = FormsNewTemplateImport.update({ + path: '/forms/new/template', + getParentRoute: () => rootRoute, +} as any) + +const FormsNewScratchRoute = FormsNewScratchImport.update({ + path: '/forms/new/scratch', + getParentRoute: () => rootRoute, +} as any) + +const FormsNewReuseRoute = FormsNewReuseImport.update({ + path: '/forms/new/reuse', + getParentRoute: () => rootRoute, +} as any) + const FormsFormIdLanguageCodeRoute = FormsFormIdLanguageCodeImport.update({ path: '/forms/$formId/$languageCode', getParentRoute: () => rootRoute, @@ -298,10 +316,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ElectionEventTabImport parentRoute: typeof rootRoute } - '/election-event/new-form': { - preLoaderRoute: typeof ElectionEventNewFormImport - parentRoute: typeof rootRoute - } '/election-rounds/$electionRoundId': { preLoaderRoute: typeof ElectionRoundsElectionRoundIdImport parentRoute: typeof rootRoute @@ -310,6 +324,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof FormsFormIdImport parentRoute: typeof rootRoute } + '/forms/new': { + preLoaderRoute: typeof FormsNewImport + parentRoute: typeof rootRoute + } '/monitoring-observers/$tab': { preLoaderRoute: typeof MonitoringObserversTabImport parentRoute: typeof rootRoute @@ -390,6 +408,18 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof FormsFormIdLanguageCodeImport parentRoute: typeof rootRoute } + '/forms/new/reuse': { + preLoaderRoute: typeof FormsNewReuseImport + parentRoute: typeof rootRoute + } + '/forms/new/scratch': { + preLoaderRoute: typeof FormsNewScratchImport + parentRoute: typeof rootRoute + } + '/forms/new/template': { + preLoaderRoute: typeof FormsNewTemplateImport + parentRoute: typeof rootRoute + } '/forms/$formId/edit': { preLoaderRoute: typeof FormsFormIdEditImport parentRoute: typeof rootRoute @@ -460,9 +490,9 @@ export const routeTree = rootRoute.addChildren([ AcceptInviteSuccessRoute, CitizenGuidesNewRoute, ElectionEventTabRoute, - ElectionEventNewFormRoute, ElectionRoundsElectionRoundIdRoute, FormsFormIdRoute, + FormsNewRoute, MonitoringObserversTabRoute, MonitoringObserversCreateNewMessageRoute, NgosNgoIdRoute, @@ -483,6 +513,9 @@ export const routeTree = rootRoute.addChildren([ CitizenGuidesEditGuideIdRoute, CitizenGuidesViewGuideIdRoute, FormsFormIdLanguageCodeRoute, + FormsNewReuseRoute, + FormsNewScratchRoute, + FormsNewTemplateRoute, FormsFormIdEditRoute, MonitoringObserversEditMonitoringObserverIdRoute, MonitoringObserversPushMessagesIdRoute, diff --git a/web/src/routes/election-event/new-form.tsx b/web/src/routes/election-event/new-form.tsx deleted file mode 100644 index 188056cea..000000000 --- a/web/src/routes/election-event/new-form.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import { FormBuilder } from '@/features/election-event/components/FormBuilder/components/FormBuilder'; -import { PushMessageTargetedObserversSearchParamsSchema } from '@/features/monitoring-observers/models/search-params'; -import { redirectIfNotAuth } from '@/lib/utils'; -import { createFileRoute } from '@tanstack/react-router'; - -export const Route = createFileRoute('/election-event/new-form')({ - beforeLoad: () => { - redirectIfNotAuth(); - }, - component: CreateNewForm, - validateSearch: PushMessageTargetedObserversSearchParamsSchema, -}); - -function CreateNewForm() { - return ; -} diff --git a/web/src/routes/forms/new.tsx b/web/src/routes/forms/new.tsx new file mode 100644 index 000000000..96b85eedf --- /dev/null +++ b/web/src/routes/forms/new.tsx @@ -0,0 +1,14 @@ +import { FormBuilderScreenStart } from '@/features/election-event/components/FormBuilder/components/FormBuilderScreenStart'; +import { redirectIfNotAuth } from '@/lib/utils'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/forms/new')({ + beforeLoad: () => { + redirectIfNotAuth(); + }, + component: CreateNewForm, +}); + +function CreateNewForm() { + return ; +} diff --git a/web/src/routes/forms/new_.reuse.tsx b/web/src/routes/forms/new_.reuse.tsx new file mode 100644 index 000000000..935f75d98 --- /dev/null +++ b/web/src/routes/forms/new_.reuse.tsx @@ -0,0 +1,14 @@ +import { FormBuilderScreenReuse } from '@/features/election-event/components/FormBuilder/components/FormBuilderScreenReuse'; +import { redirectIfNotAuth } from '@/lib/utils'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/forms/new/reuse')({ + beforeLoad: () => { + redirectIfNotAuth(); + }, + component: CreateNewFormFromOldForm, +}); + +function CreateNewFormFromOldForm() { + return ; +} diff --git a/web/src/routes/forms/new_.scratch.tsx b/web/src/routes/forms/new_.scratch.tsx new file mode 100644 index 000000000..d43bbd9ac --- /dev/null +++ b/web/src/routes/forms/new_.scratch.tsx @@ -0,0 +1,14 @@ +import { FormBuilderScreenScratch } from '@/features/election-event/components/FormBuilder/components/FormBuilderScreenScratch'; +import { redirectIfNotAuth } from '@/lib/utils'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/forms/new/scratch')({ + beforeLoad: () => { + redirectIfNotAuth(); + }, + component: CreateNewFormFromScratch, +}); + +function CreateNewFormFromScratch() { + return ; +} diff --git a/web/src/routes/forms/new_.template.tsx b/web/src/routes/forms/new_.template.tsx new file mode 100644 index 000000000..a79274489 --- /dev/null +++ b/web/src/routes/forms/new_.template.tsx @@ -0,0 +1,14 @@ +import { FormBuilderScreenTemplate } from '@/features/election-event/components/FormBuilder/components/FormBuilderScreenTemplate'; +import { redirectIfNotAuth } from '@/lib/utils'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/forms/new/template')({ + beforeLoad: () => { + redirectIfNotAuth(); + }, + component: CreateNewFormFromTemplate, +}); + +function CreateNewFormFromTemplate() { + return ; +} From 5515de68c0b9398c0f1d4f287f918d0675781a67 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Thu, 10 Oct 2024 19:45:16 +0300 Subject: [PATCH 15/33] WIP: move FormBuilder into the forms directory --- .../FormBuilder/components/FormBuilderScreenReuse.tsx | 0 .../FormBuilder/components/FormBuilderScreenScratch.tsx | 0 .../FormBuilder/components/FormBuilderScreenStart.tsx | 8 ++------ .../FormBuilder/components/FormBuilderScreenTemplate.tsx | 0 .../forms/components/FormBuilder/components/FormPage.tsx | 0 web/src/routes/forms/new.tsx | 2 +- web/src/routes/forms/new_.reuse.tsx | 2 +- web/src/routes/forms/new_.scratch.tsx | 2 +- web/src/routes/forms/new_.template.tsx | 2 +- 9 files changed, 6 insertions(+), 10 deletions(-) rename web/src/features/{election-event => forms}/components/FormBuilder/components/FormBuilderScreenReuse.tsx (100%) rename web/src/features/{election-event => forms}/components/FormBuilder/components/FormBuilderScreenScratch.tsx (100%) rename web/src/features/{election-event => forms}/components/FormBuilder/components/FormBuilderScreenStart.tsx (90%) rename web/src/features/{election-event => forms}/components/FormBuilder/components/FormBuilderScreenTemplate.tsx (100%) create mode 100644 web/src/features/forms/components/FormBuilder/components/FormPage.tsx diff --git a/web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenReuse.tsx b/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenReuse.tsx similarity index 100% rename from web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenReuse.tsx rename to web/src/features/forms/components/FormBuilder/components/FormBuilderScreenReuse.tsx diff --git a/web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenScratch.tsx b/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenScratch.tsx similarity index 100% rename from web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenScratch.tsx rename to web/src/features/forms/components/FormBuilder/components/FormBuilderScreenScratch.tsx diff --git a/web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenStart.tsx b/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenStart.tsx similarity index 90% rename from web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenStart.tsx rename to web/src/features/forms/components/FormBuilder/components/FormBuilderScreenStart.tsx index e2b1d4d3d..bbc18ae6c 100644 --- a/web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenStart.tsx +++ b/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenStart.tsx @@ -1,5 +1,5 @@ -import { BackButtonIcon } from '@/components/layout/Breadcrumbs/BackButton'; import Layout from '@/components/layout/Layout'; +import { NavigateBack } from '@/components/NavigateBack/NavigateBack'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { ClipboardDocumentListIcon, DocumentPlusIcon, DocumentTextIcon } from '@heroicons/react/24/outline'; @@ -67,11 +67,7 @@ export const FormBuilderScreenStart: FC = () => { - - - }> + backButton={}>
diff --git a/web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenTemplate.tsx b/web/src/features/forms/components/FormBuilder/components/FormBuilderScreenTemplate.tsx similarity index 100% rename from web/src/features/election-event/components/FormBuilder/components/FormBuilderScreenTemplate.tsx rename to web/src/features/forms/components/FormBuilder/components/FormBuilderScreenTemplate.tsx diff --git a/web/src/features/forms/components/FormBuilder/components/FormPage.tsx b/web/src/features/forms/components/FormBuilder/components/FormPage.tsx new file mode 100644 index 000000000..e69de29bb diff --git a/web/src/routes/forms/new.tsx b/web/src/routes/forms/new.tsx index 96b85eedf..b53bfaba2 100644 --- a/web/src/routes/forms/new.tsx +++ b/web/src/routes/forms/new.tsx @@ -1,4 +1,4 @@ -import { FormBuilderScreenStart } from '@/features/election-event/components/FormBuilder/components/FormBuilderScreenStart'; +import { FormBuilderScreenStart } from '@/features/forms/components/FormBuilder/components/FormBuilderScreenStart'; import { redirectIfNotAuth } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; diff --git a/web/src/routes/forms/new_.reuse.tsx b/web/src/routes/forms/new_.reuse.tsx index 935f75d98..b6798e68b 100644 --- a/web/src/routes/forms/new_.reuse.tsx +++ b/web/src/routes/forms/new_.reuse.tsx @@ -1,4 +1,4 @@ -import { FormBuilderScreenReuse } from '@/features/election-event/components/FormBuilder/components/FormBuilderScreenReuse'; +import { FormBuilderScreenReuse } from '@/features/forms/components/FormBuilder/components/FormBuilderScreenReuse'; import { redirectIfNotAuth } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; diff --git a/web/src/routes/forms/new_.scratch.tsx b/web/src/routes/forms/new_.scratch.tsx index d43bbd9ac..6d94079be 100644 --- a/web/src/routes/forms/new_.scratch.tsx +++ b/web/src/routes/forms/new_.scratch.tsx @@ -1,4 +1,4 @@ -import { FormBuilderScreenScratch } from '@/features/election-event/components/FormBuilder/components/FormBuilderScreenScratch'; +import { FormBuilderScreenScratch } from '@/features/forms/components/FormBuilder/components/FormBuilderScreenScratch'; import { redirectIfNotAuth } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; diff --git a/web/src/routes/forms/new_.template.tsx b/web/src/routes/forms/new_.template.tsx index a79274489..e03ffa468 100644 --- a/web/src/routes/forms/new_.template.tsx +++ b/web/src/routes/forms/new_.template.tsx @@ -1,4 +1,4 @@ -import { FormBuilderScreenTemplate } from '@/features/election-event/components/FormBuilder/components/FormBuilderScreenTemplate'; +import { FormBuilderScreenTemplate } from '@/features/forms/components/FormBuilder/components/FormBuilderScreenTemplate'; import { redirectIfNotAuth } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; From fa595821884c51e6781ed93608dfa32ea4acb860 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 11 Oct 2024 10:00:30 +0300 Subject: [PATCH 16/33] WIP: add new form page instead of modal --- .../forms/components/Dashboard/CreateForm.tsx | 157 -------------- .../forms/components/Dashboard/Dashboard.tsx | 2 +- .../forms/components/EditForm/EditForm.tsx | 29 +-- .../FormBuilder/components/CreateFormPage.tsx | 203 ++++++++++++++++++ .../components/FormBuilderScreenScratch.tsx | 22 +- .../FormBuilder/components/FormPage.tsx | 0 web/src/routeTree.gen.ts | 22 +- ....edit.tsx => forms_.$formId.edit.$tab.tsx} | 7 +- 8 files changed, 253 insertions(+), 189 deletions(-) delete mode 100644 web/src/features/forms/components/Dashboard/CreateForm.tsx create mode 100644 web/src/features/forms/components/FormBuilder/components/CreateFormPage.tsx delete mode 100644 web/src/features/forms/components/FormBuilder/components/FormPage.tsx rename web/src/routes/{forms_.$formId.edit.tsx => forms_.$formId.edit.$tab.tsx} (77%) diff --git a/web/src/features/forms/components/Dashboard/CreateForm.tsx b/web/src/features/forms/components/Dashboard/CreateForm.tsx deleted file mode 100644 index dc2a091ad..000000000 --- a/web/src/features/forms/components/Dashboard/CreateForm.tsx +++ /dev/null @@ -1,157 +0,0 @@ -import { authApi } from '@/common/auth-api'; -import { TranslatedString, ZFormType } from '@/common/types'; -import { CreateDialogFooter } from '@/components/dialogs/CreateDialog'; -import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; -import { Input } from '@/components/ui/input'; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; -import { Textarea } from '@/components/ui/textarea'; -import { toast } from '@/components/ui/use-toast'; -import LanguageSelect from '@/containers/LanguageSelect'; -import { useCurrentElectionRoundStore } from '@/context/election-round.store'; -import { queryClient } from '@/main'; -import { zodResolver } from '@hookform/resolvers/zod'; -import { useMutation } from '@tanstack/react-query'; -import { useNavigate } from '@tanstack/react-router'; -import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; -import { z } from 'zod'; -import { FormBase, NewFormRequest } from '../../models/form'; -import { formsKeys } from '../../queries'; -import { mapFormType, newTranslatedString } from '@/lib/utils'; - -function CreateForm() { - const { t } = useTranslation(); - const navigate = useNavigate(); - const currentElectionRoundId = useCurrentElectionRoundStore(s => s.currentElectionRoundId); - const isMonitoringNgoForCitizenReporting = useCurrentElectionRoundStore(s => s.isMonitoringNgoForCitizenReporting); - - const newFormFormSchema = z.object({ - code: z.string().nonempty('Form code is required'), - name: z.string().nonempty('Form name is required'), - description: z.string().optional(), - defaultLanguage: z.string().nonempty(), - formType: ZFormType.catch(ZFormType.Values.Opening) - }); - - const form = useForm>({ - resolver: zodResolver(newFormFormSchema) - }); - - function onSubmit(values: z.infer) { - const name = newTranslatedString([values.defaultLanguage], values.defaultLanguage, values.name); - const description = newTranslatedString([values.defaultLanguage], values.defaultLanguage, values.description ?? ''); - - - const newForm: NewFormRequest = { - ...values, - description, - name, - languages: [values.defaultLanguage] - }; - - newFormMutation.mutate({ electionRoundId: currentElectionRoundId, newForm }); - } - - const newFormMutation = useMutation({ - mutationFn: ({ electionRoundId, newForm }: { electionRoundId: string; newForm: NewFormRequest }) => { - - return authApi.post(`/election-rounds/${electionRoundId}/forms`, newForm); - }, - - onSuccess: ({ data: form }) => { - toast({ - title: 'Success', - description: 'Form created', - }); - - queryClient.invalidateQueries({ queryKey: formsKeys.all(currentElectionRoundId) }); - navigate({ to: `/forms/$formId/edit`, params: { formId: form.id } }); - }, - }); - - return ( - - - ( - - {t('form.field.name')} - - - )} /> - - ( - - {t('form.field.code')} - - - - )} /> - - ( - - {t('form.field.formType')} - - - )} - /> - - - ( - - {t('form.field.defaultLanguage')} - - - - )} - /> - ( - - {t('form.field.description')} -