From 0fe3fbe947c1d0d031bdaa93d9e1661fe55e5622 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 6 Sep 2024 09:07:50 +0300 Subject: [PATCH 01/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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/43] 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 b6964533c083a4a23e071a0f5a949907e1f5d05a Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Sat, 18 Jan 2025 17:20:26 +0200 Subject: [PATCH 11/43] WIP: add search and filtering to NGO list --- .../filtering/components/ActiveFilters.tsx | 10 ++- web/src/features/filtering/filtering-enums.ts | 18 ++-- .../filtering/hooks/useFilteringContainer.ts | 20 ++++- .../ngos/components/Dashboard/Dashboard.tsx | 86 +++++++++++++++++-- .../ngos/components/NGOStatusBadge.tsx | 30 +++++++ .../components/filtering/NGOStatusSelect.tsx | 34 ++++++++ .../components/filtering/NGOsListFilters.tsx | 11 +++ web/src/features/ngos/models/NGO.tsx | 25 ++++-- web/src/routes/ngos/index.tsx | 4 +- 9 files changed, 208 insertions(+), 30 deletions(-) create mode 100644 web/src/features/ngos/components/NGOStatusBadge.tsx create mode 100644 web/src/features/ngos/components/filtering/NGOStatusSelect.tsx create mode 100644 web/src/features/ngos/components/filtering/NGOsListFilters.tsx diff --git a/web/src/features/filtering/components/ActiveFilters.tsx b/web/src/features/filtering/components/ActiveFilters.tsx index a80c7c694..8e3220177 100644 --- a/web/src/features/filtering/components/ActiveFilters.tsx +++ b/web/src/features/filtering/components/ActiveFilters.tsx @@ -1,6 +1,9 @@ +import { useDataSource } from '@/common/data-source-store'; import { DateTimeFormat } from '@/common/formats'; +import { DataSources } from '@/common/types'; import { FilterBadge } from '@/components/ui/badge'; import { useCurrentElectionRoundStore } from '@/context/election-round.store'; +import { useCoalitionDetails } from '@/features/election-event/hooks/coalition-hooks'; import { useFormSubmissionsFilters } from '@/features/responses/hooks/form-submissions-queries'; import { mapFormSubmissionFollowUpStatus, @@ -12,9 +15,6 @@ import { useNavigate } from '@tanstack/react-router'; import { format } from 'date-fns/format'; import { FC, useCallback } from 'react'; import { FILTER_KEY, FILTER_LABEL } from '../filtering-enums'; -import { useDataSource } from '@/common/data-source-store'; -import { useCoalitionDetails } from '@/features/election-event/hooks/coalition-hooks'; -import { DataSources } from '@/common/types'; interface ActiveFilterProps { filterId: string; @@ -61,6 +61,7 @@ const FILTER_LABELS = new Map([ [FILTER_KEY.QuickReportFollowUpStatus, FILTER_LABEL.QuickReportFollowUpStatus], [FILTER_KEY.HasQuickReports, FILTER_LABEL.HasQuickReports], [FILTER_KEY.CoalitionMemberId, FILTER_LABEL.CoalitionMemberId], + [FILTER_KEY.Status, FILTER_LABEL.Status], ]); const FILTER_VALUE_LOCALIZATORS = new Map string>([ @@ -120,7 +121,8 @@ export const ActiveFilters: FC = ({ queryParams }) => { .filter(([filterId, value]) => isNotNilOrWhitespace(value?.toString())) .filter( ([filterId, value]) => - filterId !== FILTER_KEY.CoalitionMemberId || (dataSource === DataSources.Coalition && filterId === FILTER_KEY.CoalitionMemberId) + filterId !== FILTER_KEY.CoalitionMemberId || + (dataSource === DataSources.Coalition && filterId === FILTER_KEY.CoalitionMemberId) ) .map(([filterId, value]) => { let key = ''; diff --git a/web/src/features/filtering/filtering-enums.ts b/web/src/features/filtering/filtering-enums.ts index af28b3715..35e848ac8 100644 --- a/web/src/features/filtering/filtering-enums.ts +++ b/web/src/features/filtering/filtering-enums.ts @@ -25,11 +25,12 @@ export const enum FILTER_KEY { FromDate = 'submissionsFromDate', ToDate = 'submissionsToDate', SearchText = 'searchText', - QuickReportIncidentCategory ='incidentCategory', - QuickReportFollowUpStatus ='quickReportFollowUpStatus', - HasQuickReports ='hasQuickReports', - DataSource ='dataSource', + QuickReportIncidentCategory = 'incidentCategory', + QuickReportFollowUpStatus = 'quickReportFollowUpStatus', + HasQuickReports = 'hasQuickReports', + DataSource = 'dataSource', CoalitionMemberId = 'coalitionMemberId', + Status = 'status', } export const enum FILTER_LABEL { @@ -53,8 +54,9 @@ export const enum FILTER_LABEL { FromDate = 'From date', ToDate = 'To Date', SearchText = 'Search text', - QuickReportIncidentCategory ='Incident category', - QuickReportFollowUpStatus ='Quick report follow up status', - HasQuickReports ='Has quick reports', - CoalitionMemberId = 'NGO' + QuickReportIncidentCategory = 'Incident category', + QuickReportFollowUpStatus = 'Quick report follow up status', + HasQuickReports = 'Has quick reports', + CoalitionMemberId = 'NGO', + Status = 'Status', } diff --git a/web/src/features/filtering/hooks/useFilteringContainer.ts b/web/src/features/filtering/hooks/useFilteringContainer.ts index b03ea6cdb..2e3cb7746 100644 --- a/web/src/features/filtering/hooks/useFilteringContainer.ts +++ b/web/src/features/filtering/hooks/useFilteringContainer.ts @@ -1,6 +1,6 @@ import { useSetPrevSearch } from '@/common/prev-search-store'; import { useNavigate, useSearch } from '@tanstack/react-router'; -import { useCallback, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { HIDDEN_FILTERS } from '../components/ActiveFilters'; import { FILTER_KEY } from '../filtering-enums'; @@ -19,6 +19,12 @@ export function useFilteringContainer() { .some(([_, value]) => !!value); }, [queryParams]); + const [isFilteringContainerVisible, setIsFilteringContainerVisible] = useState(filteringIsActive); + + useEffect(() => { + setIsFilteringContainerVisible(filteringIsActive); + }, [filteringIsActive]); + const navigateHandler = useCallback( (search: Record) => { void navigate({ @@ -37,6 +43,8 @@ export function useFilteringContainer() { [navigate, setPrevSearch] ); + const toggleFilteringContainerVisibility = () => setIsFilteringContainerVisible((prev) => !prev); + const resetFilters = () => { navigate({ search: filterObject(queryParams, HIDDEN_FILTERS), @@ -44,5 +52,13 @@ export function useFilteringContainer() { setPrevSearch(filterObject(queryParams, HIDDEN_FILTERS)); }; - return { queryParams, filteringIsActive, navigate, navigateHandler, resetFilters }; + return { + queryParams, + filteringIsActive, + isFilteringContainerVisible, + toggleFilteringContainerVisibility, + navigate, + navigateHandler, + resetFilters, + }; } diff --git a/web/src/features/ngos/components/Dashboard/Dashboard.tsx b/web/src/features/ngos/components/Dashboard/Dashboard.tsx index 988260b29..e6326f323 100644 --- a/web/src/features/ngos/components/Dashboard/Dashboard.tsx +++ b/web/src/features/ngos/components/Dashboard/Dashboard.tsx @@ -1,18 +1,29 @@ -import { ngoColDefs, type NGO } from '../../models/NGO'; -import { useCallback, type ReactElement } from 'react'; -import { type UseQueryResult, useQuery } from '@tanstack/react-query'; -import type { DataTableParameters, PageResponse } from '@/common/types'; import { authApi } from '@/common/auth-api'; -import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; +import type { DataTableParameters, PageResponse } from '@/common/types'; import Layout from '@/components/layout/Layout'; -import { useNavigate } from '@tanstack/react-router'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { FILTER_KEY } from '@/features/filtering/filtering-enums'; +import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; +import { Route } from '@/routes/ngos'; +import { Cog8ToothIcon, FunnelIcon } from '@heroicons/react/24/outline'; +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; +import { useNavigate, useRouter } from '@tanstack/react-router'; +import { useDebounce } from '@uidotdev/usehooks'; +import { useCallback, useEffect, useMemo, useState, type ReactElement } from 'react'; +import { ngoColDefs, type NGO } from '../../models/NGO'; +import { NGOsListFilters } from '../filtering/NGOsListFilters'; function useNGOs(p: DataTableParameters): UseQueryResult, Error> { + console.log(p); return useQuery({ - queryKey: ['ngos', p.pageNumber, p.pageSize, p.sortColumnName, p.sortOrder], + queryKey: ['ngos', { ...p }], queryFn: async () => { const response = await authApi.get>('/ngos', { params: { + ...p.otherParams, PageNumber: p.pageNumber, PageSize: p.pageSize, SortColumnName: p.sortColumnName, @@ -32,6 +43,37 @@ function useNGOs(p: DataTableParameters): UseQueryResult, Erro export default function NGOsDashboard(): ReactElement { const navigate = useNavigate(); + const router = useRouter(); + const { isFilteringContainerVisible, navigateHandler, toggleFilteringContainerVisibility } = useFilteringContainer(); + + const search = Route.useSearch(); + const [searchText, setSearchText] = useState(search.searchText); + const handleSearchInput = (ev: React.FormEvent) => { + setSearchText(ev.currentTarget.value); + }; + + const debouncedSearch = useDebounce(search, 300); + const debouncedSearchText = useDebounce(searchText, 300); + + useEffect(() => { + navigateHandler({ + [FILTER_KEY.SearchText]: debouncedSearchText, + }); + }, [debouncedSearchText]); + + useEffect(() => { + setSearchText(search.searchText ?? ''); + }, [search.searchText]); + + const queryParams = useMemo(() => { + const params = [ + ['searchText', debouncedSearch.searchText], + ['status', debouncedSearch.status], + ].filter(([_, value]) => value); + + return Object.fromEntries(params); + }, [debouncedSearch]); + const navigateToNgo = useCallback( (ngoId: string) => { void navigate({ to: '/ngos/$ngoId', params: { ngoId } }); @@ -40,8 +82,34 @@ export default function NGOsDashboard(): ReactElement { ); return ( - - + + + +
+ All organizations +
+ + +
+ + + +
+ {isFilteringContainerVisible && } +
+ + useNGOs(params)} + queryParams={queryParams} + onRowClick={navigateToNgo} + /> + +
); } diff --git a/web/src/features/ngos/components/NGOStatusBadge.tsx b/web/src/features/ngos/components/NGOStatusBadge.tsx new file mode 100644 index 000000000..d72d8512f --- /dev/null +++ b/web/src/features/ngos/components/NGOStatusBadge.tsx @@ -0,0 +1,30 @@ +import { Badge } from '@/components/ui/badge'; +import { FC } from 'react'; +import { NGOStatus } from '../models/NGO'; + +interface NGOStatusBadgeProps { + status: NGOStatus; +} + +export const NGOStatusBadge: FC = ({ status }) => { + let className = ''; + + switch (status) { + case NGOStatus.Activated: + className = 'badge-Active'; + break; + + case NGOStatus.Pending: + className = 'badge-Pending'; + break; + + case NGOStatus.Deactivated: + className = 'badge-Suspended'; + break; + + default: + break; + } + + return {status}; +}; diff --git a/web/src/features/ngos/components/filtering/NGOStatusSelect.tsx b/web/src/features/ngos/components/filtering/NGOStatusSelect.tsx new file mode 100644 index 000000000..a5572de82 --- /dev/null +++ b/web/src/features/ngos/components/filtering/NGOStatusSelect.tsx @@ -0,0 +1,34 @@ +import { SelectFilter, SelectFilterOption } from '@/features/filtering/components/SelectFilter'; +import { FILTER_KEY } from '@/features/filtering/filtering-enums'; +import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; +import { FC } from 'react'; +import { NGOStatus } from '../../models/NGO'; + +const ngoStatusOptions: SelectFilterOption[] = [ + { + value: NGOStatus.Activated, + label: NGOStatus.Activated, + }, + + { + value: NGOStatus.Deactivated, + label: NGOStatus.Deactivated, + }, +]; + +export const NGOStatusSelect: FC = () => { + const { queryParams, navigateHandler } = useFilteringContainer(); + + const onStatusChange = (value: string) => { + navigateHandler({ [FILTER_KEY.Status]: value }); + }; + + return ( + + ); +}; diff --git a/web/src/features/ngos/components/filtering/NGOsListFilters.tsx b/web/src/features/ngos/components/filtering/NGOsListFilters.tsx new file mode 100644 index 000000000..b9cad8ef6 --- /dev/null +++ b/web/src/features/ngos/components/filtering/NGOsListFilters.tsx @@ -0,0 +1,11 @@ +import { FilteringContainer } from '@/features/filtering/components/FilteringContainer'; +import { FC } from 'react'; +import { NGOStatusSelect } from './NGOStatusSelect'; + +export const NGOsListFilters: FC = () => { + return ( + + + + ); +}; diff --git a/web/src/features/ngos/models/NGO.tsx b/web/src/features/ngos/models/NGO.tsx index 948542389..a828f3296 100644 --- a/web/src/features/ngos/models/NGO.tsx +++ b/web/src/features/ngos/models/NGO.tsx @@ -1,20 +1,27 @@ /* eslint-disable unicorn/prefer-top-level-await */ -import type { ColumnDef } from '@tanstack/react-table'; -import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import { Button } from '@/components/ui/button'; +import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader'; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader'; +import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import { useNavigate } from '@tanstack/react-router'; +import type { ColumnDef } from '@tanstack/react-table'; +import { NGOStatusBadge } from '../components/NGOStatusBadge'; export interface NGO { id: string; name: string; - status: string; + status: NGOStatus; +} + +export enum NGOStatus { + Activated = 'Activated', + Pending = 'Pending', + Deactivated = 'Deactivated', } export const ngoColDefs: ColumnDef[] = [ @@ -31,11 +38,15 @@ export const ngoColDefs: ColumnDef[] = [ accessorKey: 'status', enableSorting: false, header: ({ column }) => , + cell: ({ + row: { + original: { status }, + }, + }) => , }, { id: 'actions', cell: ({ row }) => { - const navigate = useNavigate(); return ( @@ -48,7 +59,9 @@ export const ngoColDefs: ColumnDef[] = [ - navigate({ to: '/ngos/$ngoId', params: { ngoId: row.original.id } })}>Edit + navigate({ to: '/ngos/$ngoId', params: { ngoId: row.original.id } })}> + Edit + Deactivate Delete diff --git a/web/src/routes/ngos/index.tsx b/web/src/routes/ngos/index.tsx index bee746bde..29b9360d0 100644 --- a/web/src/routes/ngos/index.tsx +++ b/web/src/routes/ngos/index.tsx @@ -1,11 +1,13 @@ import { SortOrder } from '@/common/types'; import NGOsDashboard from '@/features/ngos/components/Dashboard/Dashboard'; +import { NGOStatus } from '@/features/ngos/models/NGO'; import { redirectIfNotAuth } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; import { z } from 'zod'; const ngoRouteSearchSchema = z.object({ - nameFilter: z.string().catch(''), + searchText: z.coerce.string().optional(), + status: z.nativeEnum(NGOStatus).optional(), pageNumber: z.number().catch(1), pageSize: z.number().catch(10), sortColumnName: z.string().catch(''), From e274f04012292a9471b84dc56cb02f6cf0a1ddfc Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Sun, 19 Jan 2025 16:50:39 +0200 Subject: [PATCH 12/43] WIP: add views for NGO Details --- .../ngos/components/Dashboard/Dashboard.tsx | 3 +- .../features/ngos/components/NGOAdmins.tsx | 115 ++++++++++++++++++ .../features/ngos/components/NGODetails.tsx | 78 ++++++++++++ web/src/features/ngos/models/NGO.tsx | 77 +++++++++++- web/src/routeTree.gen.ts | 22 ++-- web/src/routes/ngos/$ngoId.tsx | 34 ------ web/src/routes/ngos/index.tsx | 2 +- web/src/routes/ngos/view/$ngoId.$tab.tsx | 67 ++++++++++ 8 files changed, 349 insertions(+), 49 deletions(-) create mode 100644 web/src/features/ngos/components/NGOAdmins.tsx create mode 100644 web/src/features/ngos/components/NGODetails.tsx delete mode 100644 web/src/routes/ngos/$ngoId.tsx create mode 100644 web/src/routes/ngos/view/$ngoId.$tab.tsx diff --git a/web/src/features/ngos/components/Dashboard/Dashboard.tsx b/web/src/features/ngos/components/Dashboard/Dashboard.tsx index e6326f323..01bbd6cf6 100644 --- a/web/src/features/ngos/components/Dashboard/Dashboard.tsx +++ b/web/src/features/ngos/components/Dashboard/Dashboard.tsx @@ -17,7 +17,6 @@ import { ngoColDefs, type NGO } from '../../models/NGO'; import { NGOsListFilters } from '../filtering/NGOsListFilters'; function useNGOs(p: DataTableParameters): UseQueryResult, Error> { - console.log(p); return useQuery({ queryKey: ['ngos', { ...p }], queryFn: async () => { @@ -76,7 +75,7 @@ export default function NGOsDashboard(): ReactElement { const navigateToNgo = useCallback( (ngoId: string) => { - void navigate({ to: '/ngos/$ngoId', params: { ngoId } }); + void navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId, tab: 'details' } }); }, [navigate] ); diff --git a/web/src/features/ngos/components/NGOAdmins.tsx b/web/src/features/ngos/components/NGOAdmins.tsx new file mode 100644 index 000000000..3e39e5efa --- /dev/null +++ b/web/src/features/ngos/components/NGOAdmins.tsx @@ -0,0 +1,115 @@ +import { authApi } from '@/common/auth-api'; +import type { DataTableParameters, PageResponse } from '@/common/types'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; +import { Input } from '@/components/ui/input'; +import { Separator } from '@/components/ui/separator'; +import { FILTER_KEY } from '@/features/filtering/filtering-enums'; +import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; +import { Route } from '@/routes/ngos/view/$ngoId.$tab'; +import { Cog8ToothIcon, FunnelIcon } from '@heroicons/react/24/outline'; +import { useQuery, type UseQueryResult } from '@tanstack/react-query'; +import { useNavigate } from '@tanstack/react-router'; +import { useDebounce } from '@uidotdev/usehooks'; +import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { NGOAdmin, ngoAdminsColDefs, type NGO } from '../models/NGO'; +import { NGOsListFilters } from './filtering/NGOsListFilters'; + +function useNgoAdmins(ngoId: string, p: DataTableParameters): UseQueryResult, Error> { + return useQuery({ + queryKey: ['ngos', ngoId, 'admins', { ...p }], + queryFn: async () => { + const response = await authApi.get>(`/ngos/${ngoId}/admins`, { + params: { + ...p.otherParams, + PageNumber: p.pageNumber, + PageSize: p.pageSize, + SortColumnName: p.sortColumnName, + SortOrder: p.sortOrder, + }, + }); + + if (response.status !== 200) { + throw new Error('Failed to fetch ngos'); + } + console.log(response.data); + + return response.data; + }, + }); +} + +interface NGOAdminsViewProps { + ngoId: string; +} + +export const NGOAdminsView: FC = ({ ngoId }) => { + const navigate = useNavigate(); + + const { isFilteringContainerVisible, navigateHandler, toggleFilteringContainerVisibility } = useFilteringContainer(); + + const search = Route.useSearch(); + const [searchText, setSearchText] = useState(search.searchText); + const handleSearchInput = (ev: React.FormEvent) => { + setSearchText(ev.currentTarget.value); + }; + + const debouncedSearch = useDebounce(search, 300); + const debouncedSearchText = useDebounce(searchText, 300); + + useEffect(() => { + navigateHandler({ + [FILTER_KEY.SearchText]: debouncedSearchText, + }); + }, [debouncedSearchText]); + + useEffect(() => { + setSearchText(search.searchText ?? ''); + }, [search.searchText]); + + const queryParams = useMemo(() => { + const params = [ + ['searchText', debouncedSearch.searchText], + ['status', debouncedSearch.status], + ].filter(([_, value]) => value); + + return Object.fromEntries(params); + }, [debouncedSearch]); + + const navigateToNgo = useCallback( + (ngoId: string) => { + void navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId, tab: 'details' } }); + }, + [navigate] + ); + + return ( + + +
+ All admins +
+ + +
+ + + +
+ {isFilteringContainerVisible && } +
+ + useNgoAdmins(ngoId, params)} + queryParams={queryParams} + onRowClick={navigateToNgo} + /> + +
+ ); +}; diff --git a/web/src/features/ngos/components/NGODetails.tsx b/web/src/features/ngos/components/NGODetails.tsx new file mode 100644 index 000000000..6dc8832a0 --- /dev/null +++ b/web/src/features/ngos/components/NGODetails.tsx @@ -0,0 +1,78 @@ +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { PencilIcon } from '@heroicons/react/24/outline'; + +import Layout from '@/components/layout/Layout'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Route } from '@/routes/ngos/view/$ngoId.$tab'; +import { useNavigate } from '@tanstack/react-router'; +import { FC } from 'react'; +import { NGO } from '../models/NGO'; +import { NGOAdminsView } from './NGOAdmins'; +import { NGOStatusBadge } from './NGOStatusBadge'; + +interface NGODetailsProps { + data: NGO; +} + +export const NGODetailsView: FC = ({ data }) => { + return ( + + +
+ Organization details + +
+ +
+ +
+

Name

+

{data.name}

+
+ +
+

Status

+ +
+
+
+ ); +}; + +export const NGODetails: FC = ({ data }) => { + const { tab } = Route.useParams(); + const navigate = useNavigate(); + + function handleTabChange(tab: string): void { + navigate({ + params(prev: any) { + return { ...prev, tab }; + }, + }); + } + return ( + + + + Organization details + Admin users + + + + + + + + + + ); +}; diff --git a/web/src/features/ngos/models/NGO.tsx b/web/src/features/ngos/models/NGO.tsx index a828f3296..6d61b0c07 100644 --- a/web/src/features/ngos/models/NGO.tsx +++ b/web/src/features/ngos/models/NGO.tsx @@ -1,4 +1,5 @@ /* eslint-disable unicorn/prefer-top-level-await */ +import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader'; import { @@ -24,6 +25,15 @@ export enum NGOStatus { Deactivated = 'Deactivated', } +export interface NGOAdmin { + id: string; + email: string; + firstName: string; + lastName: string; + + status: NGOStatus; +} + export const ngoColDefs: ColumnDef[] = [ { header: 'ID', @@ -59,7 +69,72 @@ export const ngoColDefs: ColumnDef[] = [ - navigate({ to: '/ngos/$ngoId', params: { ngoId: row.original.id } })}> + + navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId: row.original.id, tab: 'details' } }) + }> + Edit + + Deactivate + Delete + + +
+ ); + }, + }, +]; + +export const ngoAdminsColDefs: ColumnDef[] = [ + { + header: 'ID', + accessorKey: 'id', + }, + { + accessorKey: 'email', + enableSorting: true, + header: ({ column }) => , + }, + { + accessorKey: 'firstName', + enableSorting: true, + header: ({ column }) => , + }, + { + accessorKey: 'lastName', + enableSorting: true, + header: ({ column }) => , + }, + + { + accessorKey: 'status', + enableSorting: false, + header: ({ column }) => , + cell: ({ + row: { + original: { status }, + }, + }) => {status}, + }, + { + id: 'actions', + cell: ({ row }) => { + const navigate = useNavigate(); + + return ( +
+ + + + + + + navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId: row.original.id, tab: 'details' } }) + }> Edit Deactivate diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index f26be3745..dfae29e0b 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -25,7 +25,6 @@ import { Route as AcceptInviteIndexImport } from './routes/accept-invite/index' import { Route as ResetPasswordSuccessImport } from './routes/reset-password/success' import { Route as ObserversObserverIdImport } from './routes/observers/$observerId' import { Route as ObserverGuidesNewImport } from './routes/observer-guides/new' -import { Route as NgosNgoIdImport } from './routes/ngos/$ngoId' import { Route as MonitoringObserversImportImport } from './routes/monitoring-observers/import' import { Route as MonitoringObserversCreateNewMessageImport } from './routes/monitoring-observers/create-new-message' import { Route as MonitoringObserversTabImport } from './routes/monitoring-observers/$tab' @@ -52,6 +51,7 @@ import { Route as CitizenGuidesEditGuideIdImport } from './routes/citizen-guides import { Route as ResponsesIncidentReportsFormIdAggregatedImport } from './routes/responses/incident-reports/$formId.aggregated' import { Route as ResponsesFormSubmissionsFormIdAggregatedImport } from './routes/responses/form-submissions/$formId.aggregated' import { Route as ResponsesCitizenReportsFormIdAggregatedImport } from './routes/responses/citizen-reports/$formId.aggregated' +import { Route as NgosViewNgoIdTabImport } from './routes/ngos/view/$ngoId.$tab' import { Route as MonitoringObserversViewMonitoringObserverIdTabImport } from './routes/monitoring-observers/view/$monitoringObserverId.$tab' import { Route as MonitoringObserversPushMessagesIdViewImport } from './routes/monitoring-observers/push-messages.$id_.view' import { Route as FormsFormIdEditTranslationLanguageCodeImport } from './routes/forms_.$formId.edit-translation.$languageCode' @@ -129,11 +129,6 @@ const ObserverGuidesNewRoute = ObserverGuidesNewImport.update({ getParentRoute: () => rootRoute, } as any) -const NgosNgoIdRoute = NgosNgoIdImport.update({ - path: '/ngos/$ngoId', - getParentRoute: () => rootRoute, -} as any) - const MonitoringObserversImportRoute = MonitoringObserversImportImport.update({ path: '/monitoring-observers/import', getParentRoute: () => rootRoute, @@ -276,6 +271,11 @@ const ResponsesCitizenReportsFormIdAggregatedRoute = getParentRoute: () => rootRoute, } as any) +const NgosViewNgoIdTabRoute = NgosViewNgoIdTabImport.update({ + path: '/ngos/view/$ngoId/$tab', + getParentRoute: () => rootRoute, +} as any) + const MonitoringObserversViewMonitoringObserverIdTabRoute = MonitoringObserversViewMonitoringObserverIdTabImport.update({ path: '/monitoring-observers/view/$monitoringObserverId/$tab', @@ -346,10 +346,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MonitoringObserversImportImport parentRoute: typeof rootRoute } - '/ngos/$ngoId': { - preLoaderRoute: typeof NgosNgoIdImport - parentRoute: typeof rootRoute - } '/observer-guides/new': { preLoaderRoute: typeof ObserverGuidesNewImport parentRoute: typeof rootRoute @@ -474,6 +470,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof MonitoringObserversViewMonitoringObserverIdTabImport parentRoute: typeof rootRoute } + '/ngos/view/$ngoId/$tab': { + preLoaderRoute: typeof NgosViewNgoIdTabImport + parentRoute: typeof rootRoute + } '/responses/citizen-reports/$formId/aggregated': { preLoaderRoute: typeof ResponsesCitizenReportsFormIdAggregatedImport parentRoute: typeof rootRoute @@ -502,7 +502,6 @@ export const routeTree = rootRoute.addChildren([ MonitoringObserversTabRoute, MonitoringObserversCreateNewMessageRoute, MonitoringObserversImportRoute, - NgosNgoIdRoute, ObserverGuidesNewRoute, ObserversObserverIdRoute, ResetPasswordSuccessRoute, @@ -534,6 +533,7 @@ export const routeTree = rootRoute.addChildren([ FormsFormIdEditTranslationLanguageCodeRoute, MonitoringObserversPushMessagesIdViewRoute, MonitoringObserversViewMonitoringObserverIdTabRoute, + NgosViewNgoIdTabRoute, ResponsesCitizenReportsFormIdAggregatedRoute, ResponsesFormSubmissionsFormIdAggregatedRoute, ResponsesIncidentReportsFormIdAggregatedRoute, diff --git a/web/src/routes/ngos/$ngoId.tsx b/web/src/routes/ngos/$ngoId.tsx deleted file mode 100644 index 019bfa402..000000000 --- a/web/src/routes/ngos/$ngoId.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { createFileRoute } from '@tanstack/react-router'; -import { authApi } from '@/common/auth-api'; -import { NGO } from '@/features/ngos/models/NGO'; -import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; -import { redirectIfNotAuth } from '@/lib/utils'; - -export const ngoQueryOptions = (ngoId: string) => - queryOptions({ - queryKey: ['ngos', { ngoId }], - queryFn: async () => { - const response = await authApi.get(`/ngos/${ngoId}`); - - if (response.status !== 200) { - throw new Error('Failed to fetch ngo details'); - } - - return response.data; - }, - }); - -export const Route = createFileRoute('/ngos/$ngoId')({ - beforeLoad: () => { - redirectIfNotAuth(); - }, - component: NgoDetails, - loader: ({ context: { queryClient }, params: { ngoId } }) => queryClient.ensureQueryData(ngoQueryOptions(ngoId)), -}); - -function NgoDetails() { - const { ngoId } = Route.useParams(); - const { data: ngo } = useSuspenseQuery(ngoQueryOptions(ngoId)); - - return
Hello from ngos! {JSON.stringify(ngo, null, 2)}
; -} diff --git a/web/src/routes/ngos/index.tsx b/web/src/routes/ngos/index.tsx index 29b9360d0..e6d71f370 100644 --- a/web/src/routes/ngos/index.tsx +++ b/web/src/routes/ngos/index.tsx @@ -5,7 +5,7 @@ import { redirectIfNotAuth } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; import { z } from 'zod'; -const ngoRouteSearchSchema = z.object({ +export const ngoRouteSearchSchema = z.object({ searchText: z.coerce.string().optional(), status: z.nativeEnum(NGOStatus).optional(), pageNumber: z.number().catch(1), diff --git a/web/src/routes/ngos/view/$ngoId.$tab.tsx b/web/src/routes/ngos/view/$ngoId.$tab.tsx new file mode 100644 index 000000000..df909efa2 --- /dev/null +++ b/web/src/routes/ngos/view/$ngoId.$tab.tsx @@ -0,0 +1,67 @@ +import { authApi } from '@/common/auth-api'; +import { NGODetails } from '@/features/ngos/components/NGODetails'; +import { NGO } from '@/features/ngos/models/NGO'; +import { redirectIfNotAuth } from '@/lib/utils'; +import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; +import { createFileRoute, redirect } from '@tanstack/react-router'; +import { z } from 'zod'; +import { ngoRouteSearchSchema } from '..'; + +export const ngoQueryOptions = (ngoId: string) => + queryOptions({ + queryKey: ['ngos', { ngoId }], + queryFn: async () => { + const response = await authApi.get(`/ngos/${ngoId}`); + + if (response.status !== 200) { + throw new Error('Failed to fetch ngo details'); + } + + return response.data; + }, + }); + +export const NgoAdminsSearchParamsSchema = ngoRouteSearchSchema.partial(); +export type NgoAdminsSearchParams = z.infer; + +export const NgosDetailsdPageSearchParamsSchema = NgoAdminsSearchParamsSchema.merge( + z.object({ + tab: z.enum(['details', 'admins']).catch('details').optional(), + }) +); + +export const Route = createFileRoute('/ngos/view/$ngoId/$tab')({ + beforeLoad: ({ params }) => { + redirectIfNotAuth(); + + const coercedTab = coerceTabSlug(params.tab); + if (params.tab !== coercedTab) { + throw redirect({ + to: `/ngos/view/$ngoId/$tab`, + params: { tab: coercedTab, ngoId: params.ngoId }, + }); + } + }, + component: NgoDetails, + validateSearch: NgosDetailsdPageSearchParamsSchema, + + loader: ({ context: { queryClient }, params: { ngoId } }) => queryClient.ensureQueryData(ngoQueryOptions(ngoId)), +}); + +const coerceTabSlug = (slug: string) => { + if (slug?.toLowerCase()?.trim() === 'details') return 'details'; + if (slug?.toLowerCase()?.trim() === 'admins') return 'admins'; + + return 'details'; +}; + +function NgoDetails() { + const { ngoId } = Route.useParams(); + const { data: ngo } = useSuspenseQuery(ngoQueryOptions(ngoId)); + + return ( +
+ +
+ ); +} From ee6150e4ed20745a6b71186882eb3d0887624c74 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Mon, 20 Jan 2025 13:55:23 +0200 Subject: [PATCH 13/43] WIP: add NGO activation / deactivation --- .../ngos/components/Dashboard/Dashboard.tsx | 108 ++++++++++++----- web/src/features/ngos/hooks/ngos-quries.ts | 111 ++++++++++++++++++ web/src/features/ngos/models/NGO.tsx | 52 -------- web/src/routes/ngos/view/$ngoId.$tab.tsx | 22 +--- 4 files changed, 191 insertions(+), 102 deletions(-) create mode 100644 web/src/features/ngos/hooks/ngos-quries.ts diff --git a/web/src/features/ngos/components/Dashboard/Dashboard.tsx b/web/src/features/ngos/components/Dashboard/Dashboard.tsx index 01bbd6cf6..e48e5e1a3 100644 --- a/web/src/features/ngos/components/Dashboard/Dashboard.tsx +++ b/web/src/features/ngos/components/Dashboard/Dashboard.tsx @@ -1,50 +1,33 @@ -import { authApi } from '@/common/auth-api'; -import type { DataTableParameters, PageResponse } from '@/common/types'; import Layout from '@/components/layout/Layout'; +import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader'; import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; import { FILTER_KEY } from '@/features/filtering/filtering-enums'; import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; import { Route } from '@/routes/ngos'; import { Cog8ToothIcon, FunnelIcon } from '@heroicons/react/24/outline'; -import { useQuery, type UseQueryResult } from '@tanstack/react-query'; -import { useNavigate, useRouter } from '@tanstack/react-router'; +import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; +import { useNavigate } from '@tanstack/react-router'; +import type { ColumnDef } from '@tanstack/react-table'; import { useDebounce } from '@uidotdev/usehooks'; import { useCallback, useEffect, useMemo, useState, type ReactElement } from 'react'; -import { ngoColDefs, type NGO } from '../../models/NGO'; +import { useNGOActivation, useNGODeactivation, useNGOs } from '../../hooks/ngos-quries'; +import { NGO, NGOStatus } from '../../models/NGO'; import { NGOsListFilters } from '../filtering/NGOsListFilters'; - -function useNGOs(p: DataTableParameters): UseQueryResult, Error> { - return useQuery({ - queryKey: ['ngos', { ...p }], - queryFn: async () => { - const response = await authApi.get>('/ngos', { - params: { - ...p.otherParams, - PageNumber: p.pageNumber, - PageSize: p.pageSize, - SortColumnName: p.sortColumnName, - SortOrder: p.sortOrder, - }, - }); - - if (response.status !== 200) { - throw new Error('Failed to fetch ngos'); - } - - return response.data; - }, - }); -} +import { NGOStatusBadge } from '../NGOStatusBadge'; export default function NGOsDashboard(): ReactElement { const navigate = useNavigate(); - - const router = useRouter(); const { isFilteringContainerVisible, navigateHandler, toggleFilteringContainerVisibility } = useFilteringContainer(); - const search = Route.useSearch(); const [searchText, setSearchText] = useState(search.searchText); const handleSearchInput = (ev: React.FormEvent) => { @@ -54,6 +37,9 @@ export default function NGOsDashboard(): ReactElement { const debouncedSearch = useDebounce(search, 300); const debouncedSearchText = useDebounce(searchText, 300); + const { ngoDeactivationMutation } = useNGODeactivation(); + const { ngoActivationMutation } = useNGOActivation(); + useEffect(() => { navigateHandler({ [FILTER_KEY.SearchText]: debouncedSearchText, @@ -80,6 +66,66 @@ export default function NGOsDashboard(): ReactElement { [navigate] ); + const ngoColDefs: ColumnDef[] = [ + { + header: 'ID', + accessorKey: 'id', + }, + { + accessorKey: 'name', + enableSorting: true, + header: ({ column }) => , + }, + { + accessorKey: 'status', + enableSorting: false, + header: ({ column }) => , + cell: ({ + row: { + original: { status }, + }, + }) => , + }, + { + id: 'actions', + cell: ({ row }) => { + const navigate = useNavigate(); + const isNGOActive = row.original.status === NGOStatus.Activated; + + return ( +
+ + + + + + + navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId: row.original.id, tab: 'details' } }) + }> + Edit + + { + e.stopPropagation(); + isNGOActive + ? ngoDeactivationMutation.mutate(row.original.id) + : ngoActivationMutation.mutate(row.original.id); + }}> + {isNGOActive ? 'Deactivate' : 'Activate'} + + Delete + + +
+ ); + }, + }, + ]; + return ( diff --git a/web/src/features/ngos/hooks/ngos-quries.ts b/web/src/features/ngos/hooks/ngos-quries.ts new file mode 100644 index 000000000..38f954c29 --- /dev/null +++ b/web/src/features/ngos/hooks/ngos-quries.ts @@ -0,0 +1,111 @@ +import { authApi } from '@/common/auth-api'; +import { DataTableParameters, PageResponse } from '@/common/types'; +import { toast } from '@/components/ui/use-toast'; +import { queryClient } from '@/main'; +import { queryOptions, useMutation, useQuery, UseQueryResult, useSuspenseQuery } from '@tanstack/react-query'; +import { useRouter } from '@tanstack/react-router'; +import { NGO } from '../models/NGO'; +const ENDPOINT = 'ngos'; + +export const ngosKeys = { + all: () => ['ngos'] as const, + lists: () => [...ngosKeys.all(), 'list'] as const, + list: (params: DataTableParameters) => [...ngosKeys.lists(), { ...params }] as const, + details: () => [...ngosKeys.all(), 'detail'] as const, + detail: (id: string) => [...ngosKeys.details(), id] as const, +}; + +export function useNGOs(p: DataTableParameters): UseQueryResult, Error> { + return useQuery({ + queryKey: ngosKeys.list(p), + queryFn: async () => { + const response = await authApi.get>('/ngos', { + params: { + ...p.otherParams, + PageNumber: p.pageNumber, + PageSize: p.pageSize, + SortColumnName: p.sortColumnName, + SortOrder: p.sortOrder, + }, + }); + + if (response.status !== 200) { + throw new Error('Failed to fetch ngos'); + } + + return response.data; + }, + }); +} + +export const ngoDetailsOptions = (ngoId: string) => + queryOptions({ + queryKey: ngosKeys.detail(ngoId), + queryFn: async () => { + const response = await authApi.get(`/ngos/${ngoId}`); + + if (response.status !== 200) { + throw new Error('Failed to fetch ngo details'); + } + + return response.data; + }, + }); + +export const useNGODetails = (ngoId: string) => useSuspenseQuery(ngoDetailsOptions(ngoId)); + +export const useNGODeactivation = () => { + const router = useRouter(); + const ngoDeactivationMutation = useMutation({ + mutationFn: (ngoId: string) => { + return authApi.post(`${ENDPOINT}/${ngoId}:deactivate`, {}); + }, + + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + router.invalidate(); + + toast({ + title: 'Success', + description: 'NGO was deactivated successfully', + }); + }, + + onError: () => { + toast({ + title: 'Error deactivating NGO', + description: '', + variant: 'destructive', + }); + }, + }); + return { ngoDeactivationMutation }; +}; + +export const useNGOActivation = () => { + const router = useRouter(); + const ngoActivationMutation = useMutation({ + mutationFn: (ngoId: string) => { + return authApi.post(`${ENDPOINT}/${ngoId}:activate`, {}); + }, + + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + router.invalidate(); + + toast({ + title: 'Success', + description: 'NGO was activated successfully', + }); + }, + + onError: () => { + toast({ + title: 'Error activating NGO', + description: '', + variant: 'destructive', + }); + }, + }); + return { ngoActivationMutation }; +}; diff --git a/web/src/features/ngos/models/NGO.tsx b/web/src/features/ngos/models/NGO.tsx index 6d61b0c07..3c8c0f3df 100644 --- a/web/src/features/ngos/models/NGO.tsx +++ b/web/src/features/ngos/models/NGO.tsx @@ -11,7 +11,6 @@ import { import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import { useNavigate } from '@tanstack/react-router'; import type { ColumnDef } from '@tanstack/react-table'; -import { NGOStatusBadge } from '../components/NGOStatusBadge'; export interface NGO { id: string; @@ -34,57 +33,6 @@ export interface NGOAdmin { status: NGOStatus; } -export const ngoColDefs: ColumnDef[] = [ - { - header: 'ID', - accessorKey: 'id', - }, - { - accessorKey: 'name', - enableSorting: true, - header: ({ column }) => , - }, - { - accessorKey: 'status', - enableSorting: false, - header: ({ column }) => , - cell: ({ - row: { - original: { status }, - }, - }) => , - }, - { - id: 'actions', - cell: ({ row }) => { - const navigate = useNavigate(); - - return ( -
- - - - - - - navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId: row.original.id, tab: 'details' } }) - }> - Edit - - Deactivate - Delete - - -
- ); - }, - }, -]; - export const ngoAdminsColDefs: ColumnDef[] = [ { header: 'ID', diff --git a/web/src/routes/ngos/view/$ngoId.$tab.tsx b/web/src/routes/ngos/view/$ngoId.$tab.tsx index df909efa2..9f08871ff 100644 --- a/web/src/routes/ngos/view/$ngoId.$tab.tsx +++ b/web/src/routes/ngos/view/$ngoId.$tab.tsx @@ -1,26 +1,10 @@ -import { authApi } from '@/common/auth-api'; import { NGODetails } from '@/features/ngos/components/NGODetails'; -import { NGO } from '@/features/ngos/models/NGO'; +import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-quries'; import { redirectIfNotAuth } from '@/lib/utils'; -import { queryOptions, useSuspenseQuery } from '@tanstack/react-query'; import { createFileRoute, redirect } from '@tanstack/react-router'; import { z } from 'zod'; import { ngoRouteSearchSchema } from '..'; -export const ngoQueryOptions = (ngoId: string) => - queryOptions({ - queryKey: ['ngos', { ngoId }], - queryFn: async () => { - const response = await authApi.get(`/ngos/${ngoId}`); - - if (response.status !== 200) { - throw new Error('Failed to fetch ngo details'); - } - - return response.data; - }, - }); - export const NgoAdminsSearchParamsSchema = ngoRouteSearchSchema.partial(); export type NgoAdminsSearchParams = z.infer; @@ -45,7 +29,7 @@ export const Route = createFileRoute('/ngos/view/$ngoId/$tab')({ component: NgoDetails, validateSearch: NgosDetailsdPageSearchParamsSchema, - loader: ({ context: { queryClient }, params: { ngoId } }) => queryClient.ensureQueryData(ngoQueryOptions(ngoId)), + loader: ({ context: { queryClient }, params: { ngoId } }) => queryClient.ensureQueryData(ngoDetailsOptions(ngoId)), }); const coerceTabSlug = (slug: string) => { @@ -57,7 +41,7 @@ const coerceTabSlug = (slug: string) => { function NgoDetails() { const { ngoId } = Route.useParams(); - const { data: ngo } = useSuspenseQuery(ngoQueryOptions(ngoId)); + const { data: ngo } = useNGODetails(ngoId); return (
From b3b613be3d9a70e624f20a6b9b0a85ebe4afdd94 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Mon, 20 Jan 2025 15:52:03 +0200 Subject: [PATCH 14/43] WIP: implement NGO delete --- .../ngos/components/Dashboard/Dashboard.tsx | 34 +++++++++++---- web/src/features/ngos/hooks/ngos-quries.ts | 41 ++++++++++++++++--- 2 files changed, 62 insertions(+), 13 deletions(-) diff --git a/web/src/features/ngos/components/Dashboard/Dashboard.tsx b/web/src/features/ngos/components/Dashboard/Dashboard.tsx index e48e5e1a3..ed8cae246 100644 --- a/web/src/features/ngos/components/Dashboard/Dashboard.tsx +++ b/web/src/features/ngos/components/Dashboard/Dashboard.tsx @@ -1,5 +1,6 @@ import Layout from '@/components/layout/Layout'; -import { Button } from '@/components/ui/button'; +import { useConfirm } from '@/components/ui/alert-dialog-provider'; +import { Button, buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader'; import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; @@ -20,13 +21,14 @@ import { useNavigate } from '@tanstack/react-router'; import type { ColumnDef } from '@tanstack/react-table'; import { useDebounce } from '@uidotdev/usehooks'; import { useCallback, useEffect, useMemo, useState, type ReactElement } from 'react'; -import { useNGOActivation, useNGODeactivation, useNGOs } from '../../hooks/ngos-quries'; +import { useActivateNGO, useDeactivateNGO, useDeteleteNGO, useNGOs } from '../../hooks/ngos-quries'; import { NGO, NGOStatus } from '../../models/NGO'; import { NGOsListFilters } from '../filtering/NGOsListFilters'; import { NGOStatusBadge } from '../NGOStatusBadge'; export default function NGOsDashboard(): ReactElement { const navigate = useNavigate(); + const confirm = useConfirm(); const { isFilteringContainerVisible, navigateHandler, toggleFilteringContainerVisibility } = useFilteringContainer(); const search = Route.useSearch(); const [searchText, setSearchText] = useState(search.searchText); @@ -37,8 +39,9 @@ export default function NGOsDashboard(): ReactElement { const debouncedSearch = useDebounce(search, 300); const debouncedSearchText = useDebounce(searchText, 300); - const { ngoDeactivationMutation } = useNGODeactivation(); - const { ngoActivationMutation } = useNGOActivation(); + const { deactivateNgoMutation } = useDeactivateNGO(); + const { activateNgoMutation } = useActivateNGO(); + const { deleteNgoMutation } = useDeteleteNGO(); useEffect(() => { navigateHandler({ @@ -112,12 +115,29 @@ export default function NGOsDashboard(): ReactElement { onClick={(e) => { e.stopPropagation(); isNGOActive - ? ngoDeactivationMutation.mutate(row.original.id) - : ngoActivationMutation.mutate(row.original.id); + ? deactivateNgoMutation.mutate(row.original.id) + : activateNgoMutation.mutate(row.original.id); }}> {isNGOActive ? 'Deactivate' : 'Activate'} - Delete + { + e.stopPropagation(); + if ( + await confirm({ + title: `Delete ${row.original.name}?`, + body: 'This action is permanent and cannot be undone. Once deleted, this organization cannot be retrieved.', + actionButton: 'Delete', + actionButtonClass: buttonVariants({ variant: 'destructive' }), + cancelButton: 'Cancel', + }) + ) { + deleteNgoMutation.mutate(row.original.id); + } + }}> + Delete +
diff --git a/web/src/features/ngos/hooks/ngos-quries.ts b/web/src/features/ngos/hooks/ngos-quries.ts index 38f954c29..1a6cc1219 100644 --- a/web/src/features/ngos/hooks/ngos-quries.ts +++ b/web/src/features/ngos/hooks/ngos-quries.ts @@ -54,9 +54,9 @@ export const ngoDetailsOptions = (ngoId: string) => export const useNGODetails = (ngoId: string) => useSuspenseQuery(ngoDetailsOptions(ngoId)); -export const useNGODeactivation = () => { +export const useDeactivateNGO = () => { const router = useRouter(); - const ngoDeactivationMutation = useMutation({ + const deactivateNgoMutation = useMutation({ mutationFn: (ngoId: string) => { return authApi.post(`${ENDPOINT}/${ngoId}:deactivate`, {}); }, @@ -79,12 +79,12 @@ export const useNGODeactivation = () => { }); }, }); - return { ngoDeactivationMutation }; + return { deactivateNgoMutation }; }; -export const useNGOActivation = () => { +export const useActivateNGO = () => { const router = useRouter(); - const ngoActivationMutation = useMutation({ + const activateNgoMutation = useMutation({ mutationFn: (ngoId: string) => { return authApi.post(`${ENDPOINT}/${ngoId}:activate`, {}); }, @@ -107,5 +107,34 @@ export const useNGOActivation = () => { }); }, }); - return { ngoActivationMutation }; + return { activateNgoMutation }; +}; + +export const useDeteleteNGO = () => { + const router = useRouter(); + const deleteNgoMutation = useMutation({ + mutationFn: (ngoId: string) => { + return authApi.delete(`${ENDPOINT}/${ngoId}`); + }, + + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + router.invalidate(); + + toast({ + title: 'Success', + description: 'NGO was activated successfully', + }); + }, + + onError: (err) => { + console.log(err); + toast({ + title: 'Error deleting NGO', + description: '', + variant: 'destructive', + }); + }, + }); + return { deleteNgoMutation }; }; From a23346e2466f1238a9e3befc7569fe400146be14 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Tue, 21 Jan 2025 13:54:11 +0200 Subject: [PATCH 15/43] WIP: add NGO create modal --- .../ngos/components/CreateNGODialog.tsx | 144 ++++++++++++++++++ .../ngos/components/Dashboard/Dashboard.tsx | 11 ++ web/src/features/ngos/hooks/ngos-quries.ts | 52 ++++++- web/src/features/ngos/models/NGO.tsx | 13 ++ 4 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 web/src/features/ngos/components/CreateNGODialog.tsx diff --git a/web/src/features/ngos/components/CreateNGODialog.tsx b/web/src/features/ngos/components/CreateNGODialog.tsx new file mode 100644 index 000000000..f6c31f6fd --- /dev/null +++ b/web/src/features/ngos/components/CreateNGODialog.tsx @@ -0,0 +1,144 @@ +import { Button } from '@/components/ui/button'; +import { Dialog, DialogClose, DialogContent, DialogFooter, DialogTitle } from '@/components/ui/dialog'; +import { Form, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { toast } from '@/components/ui/use-toast'; +import { useCurrentElectionRoundStore } from '@/context/election-round.store'; +import { InformationCircleIcon } from '@heroicons/react/24/outline'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { useNGOMutations } from '../hooks/ngos-quries'; +import { newNgoSchema, NGOCreationFormData } from '../models/NGO'; + +export interface CreateNGODialogProps { + open: boolean; + onOpenChange: (open: any) => void; +} + +function CreateNGODialog({ open, onOpenChange }: CreateNGODialogProps) { + const { t } = useTranslation('translation', { keyPrefix: 'observers.addObserver' }); + const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); + const { createNgoMutation } = useNGOMutations(); + + const form = useForm({ + resolver: zodResolver(newNgoSchema), + }); + + function onSubmit(values: NGOCreationFormData) { + createNgoMutation.mutate({ + values, + onMutationSuccess: () => { + form.reset({}); + onOpenChange(false); + toast({ + title: 'Success', + description: 'New organization created', + }); + }, + }); + } + + return ( + + { + e.preventDefault(); + }} + onEscapeKeyDown={(e) => { + e.preventDefault(); + }}> + Add organization +
+ + + ( + + Name + + + + )} + /> + +
+
+ +
+
+ Please add a contact person for this organization. This person will automatically become the + organization's first admin. +
+
+ + ( + + {t('firstName')} + + + + )} + /> + + ( + + {t('lastName')} + + + + )} + /> + + ( + + {t('email')} + + + + )} + /> + + ( + + {t('phone')} + + + + )} + /> + + + + + + + + + +
+
+
+ ); +} + +export default CreateNGODialog; diff --git a/web/src/features/ngos/components/Dashboard/Dashboard.tsx b/web/src/features/ngos/components/Dashboard/Dashboard.tsx index ed8cae246..13d636516 100644 --- a/web/src/features/ngos/components/Dashboard/Dashboard.tsx +++ b/web/src/features/ngos/components/Dashboard/Dashboard.tsx @@ -12,6 +12,7 @@ import { } from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; +import { useDialog } from '@/components/ui/use-dialog'; import { FILTER_KEY } from '@/features/filtering/filtering-enums'; import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; import { Route } from '@/routes/ngos'; @@ -20,9 +21,11 @@ import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import { useNavigate } from '@tanstack/react-router'; import type { ColumnDef } from '@tanstack/react-table'; import { useDebounce } from '@uidotdev/usehooks'; +import { Plus } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState, type ReactElement } from 'react'; import { useActivateNGO, useDeactivateNGO, useDeteleteNGO, useNGOs } from '../../hooks/ngos-quries'; import { NGO, NGOStatus } from '../../models/NGO'; +import CreateNGODialog from '../CreateNGODialog'; import { NGOsListFilters } from '../filtering/NGOsListFilters'; import { NGOStatusBadge } from '../NGOStatusBadge'; @@ -31,6 +34,7 @@ export default function NGOsDashboard(): ReactElement { const confirm = useConfirm(); const { isFilteringContainerVisible, navigateHandler, toggleFilteringContainerVisibility } = useFilteringContainer(); const search = Route.useSearch(); + const createNgoDialog = useDialog(); const [searchText, setSearchText] = useState(search.searchText); const handleSearchInput = (ev: React.FormEvent) => { setSearchText(ev.currentTarget.value); @@ -152,6 +156,13 @@ export default function NGOsDashboard(): ReactElement {
All organizations +
+ + +
diff --git a/web/src/features/ngos/hooks/ngos-quries.ts b/web/src/features/ngos/hooks/ngos-quries.ts index 1a6cc1219..556378979 100644 --- a/web/src/features/ngos/hooks/ngos-quries.ts +++ b/web/src/features/ngos/hooks/ngos-quries.ts @@ -4,7 +4,8 @@ import { toast } from '@/components/ui/use-toast'; import { queryClient } from '@/main'; import { queryOptions, useMutation, useQuery, UseQueryResult, useSuspenseQuery } from '@tanstack/react-query'; import { useRouter } from '@tanstack/react-router'; -import { NGO } from '../models/NGO'; +import { AxiosResponse } from 'axios'; +import { NGO, NGOAdminFormData, NGOCreationFormData } from '../models/NGO'; const ENDPOINT = 'ngos'; export const ngosKeys = { @@ -54,6 +55,50 @@ export const ngoDetailsOptions = (ngoId: string) => export const useNGODetails = (ngoId: string) => useSuspenseQuery(ngoDetailsOptions(ngoId)); +export const useNGOMutations = () => { + const createNgoAdminMutation = useMutation({ + mutationFn: ({ + ngoId, + values, + onMutationSuccess, + }: { + ngoId: string; + values: NGOAdminFormData; + onMutationSuccess: () => void; + }) => { + return authApi.post(`${ENDPOINT}/${ngoId}/admins`, values); + }, + + onSuccess: (_, { onMutationSuccess }) => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + onMutationSuccess(); + }, + onError: (err) => { + console.log(err); + }, + }); + + const createNgoMutation = useMutation({ + mutationFn: ({ values, onMutationSuccess }: { values: NGOCreationFormData; onMutationSuccess: () => void }) => { + return authApi.post(`${ENDPOINT}`, { name: values.name }); + }, + + onSuccess: (response: AxiosResponse, { values, onMutationSuccess }) => { + const ngoId = response.data.id; + + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + const { name: _, ...adminValues } = values; + + createNgoAdminMutation.mutate({ ngoId, values: { ...adminValues, password: 'weeetest1234' }, onMutationSuccess }); + }, + onError: (err) => { + console.log(err); + }, + }); + + return { createNgoMutation }; +}; + export const useDeactivateNGO = () => { const router = useRouter(); const deactivateNgoMutation = useMutation({ @@ -123,12 +168,11 @@ export const useDeteleteNGO = () => { toast({ title: 'Success', - description: 'NGO was activated successfully', + description: 'NGO was deleted successfully', }); }, - onError: (err) => { - console.log(err); + onError: () => { toast({ title: 'Error deleting NGO', description: '', diff --git a/web/src/features/ngos/models/NGO.tsx b/web/src/features/ngos/models/NGO.tsx index 3c8c0f3df..5fed1c17e 100644 --- a/web/src/features/ngos/models/NGO.tsx +++ b/web/src/features/ngos/models/NGO.tsx @@ -11,6 +11,7 @@ import { import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import { useNavigate } from '@tanstack/react-router'; import type { ColumnDef } from '@tanstack/react-table'; +import { z } from 'zod'; export interface NGO { id: string; @@ -33,6 +34,18 @@ export interface NGOAdmin { status: NGOStatus; } +export const ngoAdminSchema = z.object({ + firstName: z.string(), + lastName: z.string(), + email: z.string().email(), + phoneNumber: z.string(), +}); + +export const newNgoSchema = ngoAdminSchema.extend({ name: z.string() }); + +export type NGOAdminFormData = z.infer; +export type NGOCreationFormData = z.infer; + export const ngoAdminsColDefs: ColumnDef[] = [ { header: 'ID', From 9cfc757e158ab406044ce8a63b8de6f58193c887 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Wed, 22 Jan 2025 10:58:26 +0200 Subject: [PATCH 16/43] WIP: add modal for adding admins to NGOs --- .../ngos/components/AddNgoAdminDialog.tsx | 121 ++++++++++++++++++ .../ngos/components/CreateNGODialog.tsx | 4 +- .../ngos/components/Dashboard/Dashboard.tsx | 2 +- .../features/ngos/components/NGOAdmins.tsx | 43 ++----- .../{ngos-quries.ts => ngos-queriess.ts} | 53 +++++--- web/src/routes/ngos/view/$ngoId.$tab.tsx | 2 +- 6 files changed, 175 insertions(+), 50 deletions(-) create mode 100644 web/src/features/ngos/components/AddNgoAdminDialog.tsx rename web/src/features/ngos/hooks/{ngos-quries.ts => ngos-queriess.ts} (76%) diff --git a/web/src/features/ngos/components/AddNgoAdminDialog.tsx b/web/src/features/ngos/components/AddNgoAdminDialog.tsx new file mode 100644 index 000000000..d2327dbcf --- /dev/null +++ b/web/src/features/ngos/components/AddNgoAdminDialog.tsx @@ -0,0 +1,121 @@ +import { Button } from '@/components/ui/button'; +import { Dialog, DialogClose, DialogContent, DialogFooter, DialogTitle } from '@/components/ui/dialog'; +import { Form, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { toast } from '@/components/ui/use-toast'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useForm } from 'react-hook-form'; +import { useTranslation } from 'react-i18next'; +import { useNGOMutations } from '../hooks/ngos-queriess'; +import { NGOAdminFormData, ngoAdminSchema } from '../models/NGO'; + +export interface AddNgoAdminDialogProps { + ngoId: string; + open: boolean; + onOpenChange: (open: any) => void; +} + +function AddNgoAdminDialog({ open, onOpenChange, ngoId }: AddNgoAdminDialogProps) { + const { t } = useTranslation('translation', { keyPrefix: 'observers.addObserver' }); + const { createNgoAdminMutation } = useNGOMutations(); + + const form = useForm({ + resolver: zodResolver(ngoAdminSchema), + }); + + function onSubmit(values: NGOAdminFormData) { + createNgoAdminMutation.mutate({ + ngoId, + values: { ...values, password: 'weeetest1234' } as any, + onMutationSuccess: () => { + form.reset({}); + onOpenChange(false); + toast({ + title: 'Success', + description: 'New NGO admin added', + }); + }, + }); + } + + return ( + + { + e.preventDefault(); + }} + onEscapeKeyDown={(e) => { + e.preventDefault(); + }}> + Add NGO admin +
+
+ + ( + + {t('firstName')} + + + + )} + /> + + ( + + {t('lastName')} + + + + )} + /> + + ( + + {t('email')} + + + + )} + /> + + ( + + {t('phone')} + + + + )} + /> + + + + + + + + + +
+
+
+ ); +} + +export default AddNgoAdminDialog; diff --git a/web/src/features/ngos/components/CreateNGODialog.tsx b/web/src/features/ngos/components/CreateNGODialog.tsx index f6c31f6fd..582d39c81 100644 --- a/web/src/features/ngos/components/CreateNGODialog.tsx +++ b/web/src/features/ngos/components/CreateNGODialog.tsx @@ -3,12 +3,11 @@ import { Dialog, DialogClose, DialogContent, DialogFooter, DialogTitle } from '@ import { Form, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { toast } from '@/components/ui/use-toast'; -import { useCurrentElectionRoundStore } from '@/context/election-round.store'; import { InformationCircleIcon } from '@heroicons/react/24/outline'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useNGOMutations } from '../hooks/ngos-quries'; +import { useNGOMutations } from '../hooks/ngos-queriess'; import { newNgoSchema, NGOCreationFormData } from '../models/NGO'; export interface CreateNGODialogProps { @@ -18,7 +17,6 @@ export interface CreateNGODialogProps { function CreateNGODialog({ open, onOpenChange }: CreateNGODialogProps) { const { t } = useTranslation('translation', { keyPrefix: 'observers.addObserver' }); - const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); const { createNgoMutation } = useNGOMutations(); const form = useForm({ diff --git a/web/src/features/ngos/components/Dashboard/Dashboard.tsx b/web/src/features/ngos/components/Dashboard/Dashboard.tsx index 13d636516..2ebebcef1 100644 --- a/web/src/features/ngos/components/Dashboard/Dashboard.tsx +++ b/web/src/features/ngos/components/Dashboard/Dashboard.tsx @@ -23,7 +23,7 @@ import type { ColumnDef } from '@tanstack/react-table'; import { useDebounce } from '@uidotdev/usehooks'; import { Plus } from 'lucide-react'; import { useCallback, useEffect, useMemo, useState, type ReactElement } from 'react'; -import { useActivateNGO, useDeactivateNGO, useDeteleteNGO, useNGOs } from '../../hooks/ngos-quries'; +import { useActivateNGO, useDeactivateNGO, useDeteleteNGO, useNGOs } from '../../hooks/ngos-queriess'; import { NGO, NGOStatus } from '../../models/NGO'; import CreateNGODialog from '../CreateNGODialog'; import { NGOsListFilters } from '../filtering/NGOsListFilters'; diff --git a/web/src/features/ngos/components/NGOAdmins.tsx b/web/src/features/ngos/components/NGOAdmins.tsx index 3e39e5efa..7855a10fb 100644 --- a/web/src/features/ngos/components/NGOAdmins.tsx +++ b/web/src/features/ngos/components/NGOAdmins.tsx @@ -1,43 +1,21 @@ -import { authApi } from '@/common/auth-api'; -import type { DataTableParameters, PageResponse } from '@/common/types'; +import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; +import { useDialog } from '@/components/ui/use-dialog'; import { FILTER_KEY } from '@/features/filtering/filtering-enums'; import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; import { Route } from '@/routes/ngos/view/$ngoId.$tab'; import { Cog8ToothIcon, FunnelIcon } from '@heroicons/react/24/outline'; -import { useQuery, type UseQueryResult } from '@tanstack/react-query'; import { useNavigate } from '@tanstack/react-router'; import { useDebounce } from '@uidotdev/usehooks'; +import { Plus } from 'lucide-react'; import { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { NGOAdmin, ngoAdminsColDefs, type NGO } from '../models/NGO'; +import { ngoAdminsColDefs } from '../models/NGO'; +import AddNgoAdminDialog from './AddNgoAdminDialog'; import { NGOsListFilters } from './filtering/NGOsListFilters'; - -function useNgoAdmins(ngoId: string, p: DataTableParameters): UseQueryResult, Error> { - return useQuery({ - queryKey: ['ngos', ngoId, 'admins', { ...p }], - queryFn: async () => { - const response = await authApi.get>(`/ngos/${ngoId}/admins`, { - params: { - ...p.otherParams, - PageNumber: p.pageNumber, - PageSize: p.pageSize, - SortColumnName: p.sortColumnName, - SortOrder: p.sortOrder, - }, - }); - - if (response.status !== 200) { - throw new Error('Failed to fetch ngos'); - } - console.log(response.data); - - return response.data; - }, - }); -} +import { useNgoAdmins } from '../hooks/ngos-queriess'; interface NGOAdminsViewProps { ngoId: string; @@ -47,7 +25,6 @@ export const NGOAdminsView: FC = ({ ngoId }) => { const navigate = useNavigate(); const { isFilteringContainerVisible, navigateHandler, toggleFilteringContainerVisibility } = useFilteringContainer(); - const search = Route.useSearch(); const [searchText, setSearchText] = useState(search.searchText); const handleSearchInput = (ev: React.FormEvent) => { @@ -56,6 +33,7 @@ export const NGOAdminsView: FC = ({ ngoId }) => { const debouncedSearch = useDebounce(search, 300); const debouncedSearchText = useDebounce(searchText, 300); + const addNgoAdminDialog = useDialog(); useEffect(() => { navigateHandler({ @@ -88,6 +66,13 @@ export const NGOAdminsView: FC = ({ ngoId }) => {
All admins +
+ + +
diff --git a/web/src/features/ngos/hooks/ngos-quries.ts b/web/src/features/ngos/hooks/ngos-queriess.ts similarity index 76% rename from web/src/features/ngos/hooks/ngos-quries.ts rename to web/src/features/ngos/hooks/ngos-queriess.ts index 556378979..a74b3f535 100644 --- a/web/src/features/ngos/hooks/ngos-quries.ts +++ b/web/src/features/ngos/hooks/ngos-queriess.ts @@ -5,15 +5,17 @@ import { queryClient } from '@/main'; import { queryOptions, useMutation, useQuery, UseQueryResult, useSuspenseQuery } from '@tanstack/react-query'; import { useRouter } from '@tanstack/react-router'; import { AxiosResponse } from 'axios'; -import { NGO, NGOAdminFormData, NGOCreationFormData } from '../models/NGO'; +import { NGO, NGOAdmin, NGOAdminFormData, NGOCreationFormData } from '../models/NGO'; const ENDPOINT = 'ngos'; export const ngosKeys = { - all: () => ['ngos'] as const, + all: ( ) => ['ngos'] as const, lists: () => [...ngosKeys.all(), 'list'] as const, list: (params: DataTableParameters) => [...ngosKeys.lists(), { ...params }] as const, details: () => [...ngosKeys.all(), 'detail'] as const, detail: (id: string) => [...ngosKeys.details(), id] as const, + adminsList: (ngoId: string, params: DataTableParameters) => + [...ngosKeys.all(), 'admins', ngoId, { ...params }] as const, }; export function useNGOs(p: DataTableParameters): UseQueryResult, Error> { @@ -35,7 +37,7 @@ export function useNGOs(p: DataTableParameters): UseQueryResult export const useNGODetails = (ngoId: string) => useSuspenseQuery(ngoDetailsOptions(ngoId)); +export function useNgoAdmins(ngoId: string, p: DataTableParameters): UseQueryResult, Error> { + return useQuery({ + queryKey: ngosKeys.adminsList(ngoId, p), + queryFn: async () => { + const response = await authApi.get>(`/ngos/${ngoId}/admins`, { + params: { + ...p.otherParams, + PageNumber: p.pageNumber, + PageSize: p.pageSize, + SortColumnName: p.sortColumnName, + SortOrder: p.sortOrder, + }, + }); + + if (response.status !== 200) { + throw new Error('Failed to fetch ngos'); + } + + return response.data; + }, + }); +} + export const useNGOMutations = () => { const createNgoAdminMutation = useMutation({ - mutationFn: ({ - ngoId, - values, - onMutationSuccess, - }: { - ngoId: string; - values: NGOAdminFormData; - onMutationSuccess: () => void; - }) => { + mutationFn: ({ ngoId, values }: { ngoId: string; values: NGOAdminFormData; onMutationSuccess: () => void }) => { return authApi.post(`${ENDPOINT}/${ngoId}/admins`, values); }, @@ -74,12 +91,12 @@ export const useNGOMutations = () => { onMutationSuccess(); }, onError: (err) => { - console.log(err); + console.error(err); }, }); const createNgoMutation = useMutation({ - mutationFn: ({ values, onMutationSuccess }: { values: NGOCreationFormData; onMutationSuccess: () => void }) => { + mutationFn: ({ values }: { values: NGOCreationFormData; onMutationSuccess: () => void }) => { return authApi.post(`${ENDPOINT}`, { name: values.name }); }, @@ -89,14 +106,18 @@ export const useNGOMutations = () => { queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); const { name: _, ...adminValues } = values; - createNgoAdminMutation.mutate({ ngoId, values: { ...adminValues, password: 'weeetest1234' }, onMutationSuccess }); + createNgoAdminMutation.mutate({ + ngoId, + values: { ...adminValues, password: 'weeetest1234' } as any, + onMutationSuccess, + }); }, onError: (err) => { console.log(err); }, }); - return { createNgoMutation }; + return { createNgoMutation, createNgoAdminMutation }; }; export const useDeactivateNGO = () => { diff --git a/web/src/routes/ngos/view/$ngoId.$tab.tsx b/web/src/routes/ngos/view/$ngoId.$tab.tsx index 9f08871ff..30dbf7656 100644 --- a/web/src/routes/ngos/view/$ngoId.$tab.tsx +++ b/web/src/routes/ngos/view/$ngoId.$tab.tsx @@ -1,5 +1,5 @@ import { NGODetails } from '@/features/ngos/components/NGODetails'; -import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-quries'; +import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queriess'; import { redirectIfNotAuth } from '@/lib/utils'; import { createFileRoute, redirect } from '@tanstack/react-router'; import { z } from 'zod'; From 23b9c0320500808aadbdcdda7f130ff7632751e7 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Wed, 22 Jan 2025 13:57:13 +0200 Subject: [PATCH 17/43] WIP: add CRUD for NGO admins --- .../features/ngos/components/NGOAdmins.tsx | 109 +++++++++++++++++- web/src/features/ngos/hooks/ngos-queriess.ts | 93 ++++++++++++++- web/src/features/ngos/models/NGO.tsx | 82 +------------ 3 files changed, 199 insertions(+), 85 deletions(-) diff --git a/web/src/features/ngos/components/NGOAdmins.tsx b/web/src/features/ngos/components/NGOAdmins.tsx index 7855a10fb..22af39d18 100644 --- a/web/src/features/ngos/components/NGOAdmins.tsx +++ b/web/src/features/ngos/components/NGOAdmins.tsx @@ -1,6 +1,15 @@ -import { Button } from '@/components/ui/button'; +import { useConfirm } from '@/components/ui/alert-dialog-provider'; +import { Badge } from '@/components/ui/badge'; +import { Button, buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader'; import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; import { Input } from '@/components/ui/input'; import { Separator } from '@/components/ui/separator'; import { useDialog } from '@/components/ui/use-dialog'; @@ -8,14 +17,16 @@ import { FILTER_KEY } from '@/features/filtering/filtering-enums'; import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; import { Route } from '@/routes/ngos/view/$ngoId.$tab'; import { Cog8ToothIcon, FunnelIcon } from '@heroicons/react/24/outline'; +import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import { useNavigate } from '@tanstack/react-router'; +import type { ColumnDef } from '@tanstack/react-table'; import { useDebounce } from '@uidotdev/usehooks'; import { Plus } from 'lucide-react'; import { FC, useCallback, useEffect, useMemo, useState } from 'react'; -import { ngoAdminsColDefs } from '../models/NGO'; +import { useNgoAdmins, useNGOMutations } from '../hooks/ngos-queriess'; +import { NGOAdmin, NgoAdminStatus } from '../models/NGO'; import AddNgoAdminDialog from './AddNgoAdminDialog'; import { NGOsListFilters } from './filtering/NGOsListFilters'; -import { useNgoAdmins } from '../hooks/ngos-queriess'; interface NGOAdminsViewProps { ngoId: string; @@ -23,7 +34,7 @@ interface NGOAdminsViewProps { export const NGOAdminsView: FC = ({ ngoId }) => { const navigate = useNavigate(); - + const confirm = useConfirm(); const { isFilteringContainerVisible, navigateHandler, toggleFilteringContainerVisibility } = useFilteringContainer(); const search = Route.useSearch(); const [searchText, setSearchText] = useState(search.searchText); @@ -34,6 +45,7 @@ export const NGOAdminsView: FC = ({ ngoId }) => { const debouncedSearch = useDebounce(search, 300); const debouncedSearchText = useDebounce(searchText, 300); const addNgoAdminDialog = useDialog(); + const { deactivateNgoAdminMutation, activateNgoAdminMutation, deleteNgoAdminMutation } = useNGOMutations(); useEffect(() => { navigateHandler({ @@ -61,6 +73,95 @@ export const NGOAdminsView: FC = ({ ngoId }) => { [navigate] ); + const ngoAdminsColDefs: ColumnDef[] = [ + { + header: 'ID', + accessorKey: 'id', + }, + { + accessorKey: 'email', + enableSorting: true, + header: ({ column }) => , + }, + { + accessorKey: 'firstName', + enableSorting: true, + header: ({ column }) => , + }, + { + accessorKey: 'lastName', + enableSorting: true, + header: ({ column }) => , + }, + + { + accessorKey: 'status', + enableSorting: false, + header: ({ column }) => , + cell: ({ + row: { + original: { status }, + }, + }) => {status}, + }, + { + id: 'actions', + cell: ({ row }) => { + const navigate = useNavigate(); + const adminId = row.original.id; + const isAdminActive = row.original.status === NgoAdminStatus.Active; + + return ( +
+ + + + + + + navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId: row.original.id, tab: 'details' } }) + }> + Edit + + { + e.stopPropagation(); + isAdminActive + ? deactivateNgoAdminMutation.mutate({ ngoId, adminId }) + : activateNgoAdminMutation.mutate({ ngoId, adminId }); + }}> + {!isAdminActive ? 'Activate' : 'Deactivate'} + + { + e.stopPropagation(); + if ( + await confirm({ + title: `Delete ${row.original.firstName} ${row.original.lastName}?`, + body: 'This action is permanent and cannot be undone. Once deleted, this NGO admin cannot be retrieved.', + actionButton: 'Delete', + actionButtonClass: buttonVariants({ variant: 'destructive' }), + cancelButton: 'Cancel', + }) + ) { + deleteNgoAdminMutation.mutate({ ngoId, adminId }); + } + }}> + Delete + + + +
+ ); + }, + }, + ]; + return ( diff --git a/web/src/features/ngos/hooks/ngos-queriess.ts b/web/src/features/ngos/hooks/ngos-queriess.ts index a74b3f535..fe90c0d8e 100644 --- a/web/src/features/ngos/hooks/ngos-queriess.ts +++ b/web/src/features/ngos/hooks/ngos-queriess.ts @@ -9,7 +9,7 @@ import { NGO, NGOAdmin, NGOAdminFormData, NGOCreationFormData } from '../models/ const ENDPOINT = 'ngos'; export const ngosKeys = { - all: ( ) => ['ngos'] as const, + all: () => ['ngos'] as const, lists: () => [...ngosKeys.all(), 'list'] as const, list: (params: DataTableParameters) => [...ngosKeys.lists(), { ...params }] as const, details: () => [...ngosKeys.all(), 'detail'] as const, @@ -37,7 +37,7 @@ export function useNGOs(p: DataTableParameters): UseQueryResult { + const router = useRouter(); const createNgoAdminMutation = useMutation({ mutationFn: ({ ngoId, values }: { ngoId: string; values: NGOAdminFormData; onMutationSuccess: () => void }) => { return authApi.post(`${ENDPOINT}/${ngoId}/admins`, values); @@ -112,12 +113,94 @@ export const useNGOMutations = () => { onMutationSuccess, }); }, - onError: (err) => { - console.log(err); + onError: () => { + toast({ + title: 'Error creating a new NGO', + description: '', + variant: 'destructive', + }); + }, + }); + + const deactivateNgoAdminMutation = useMutation({ + mutationFn: ({ ngoId, adminId }: { ngoId: string; adminId: string }) => { + return authApi.post(`${ENDPOINT}/${ngoId}/admins/${adminId}:deactivate`, {}); + }, + + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + router.invalidate(); + + toast({ + title: 'Success', + description: 'NGO admin was deactivated successfully', + }); + }, + + onError: () => { + toast({ + title: 'Error deactivating the NGO admin', + description: '', + variant: 'destructive', + }); + }, + }); + + const activateNgoAdminMutation = useMutation({ + mutationFn: ({ ngoId, adminId }: { ngoId: string; adminId: string }) => { + return authApi.post(`${ENDPOINT}/${ngoId}/admins/${adminId}:activate`, {}); + }, + + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + router.invalidate(); + + toast({ + title: 'Success', + description: 'NGO admin was activated successfully', + }); + }, + + onError: () => { + toast({ + title: 'Error activating the NGO admin', + description: '', + variant: 'destructive', + }); + }, + }); + + const deleteNgoAdminMutation = useMutation({ + mutationFn: ({ ngoId, adminId }: { ngoId: string; adminId: string }) => { + return authApi.delete(`${ENDPOINT}/${ngoId}/admins/${adminId}`, {}); + }, + + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + router.invalidate(); + + toast({ + title: 'Success', + description: 'NGO admin was deleted successfully', + }); + }, + + onError: () => { + toast({ + title: 'Error deleting NGO admin', + description: '', + variant: 'destructive', + }); }, }); - return { createNgoMutation, createNgoAdminMutation }; + return { + createNgoMutation, + createNgoAdminMutation, + activateNgoAdminMutation, + deactivateNgoAdminMutation, + deleteNgoAdminMutation, + }; }; export const useDeactivateNGO = () => { diff --git a/web/src/features/ngos/models/NGO.tsx b/web/src/features/ngos/models/NGO.tsx index 5fed1c17e..05bbeff73 100644 --- a/web/src/features/ngos/models/NGO.tsx +++ b/web/src/features/ngos/models/NGO.tsx @@ -1,16 +1,4 @@ /* eslint-disable unicorn/prefer-top-level-await */ -import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader'; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@/components/ui/dropdown-menu'; -import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; -import { useNavigate } from '@tanstack/react-router'; -import type { ColumnDef } from '@tanstack/react-table'; import { z } from 'zod'; export interface NGO { @@ -25,13 +13,17 @@ export enum NGOStatus { Deactivated = 'Deactivated', } +export enum NgoAdminStatus { + Active = 'Active', + Deactivated = 'Deactivated', +} + export interface NGOAdmin { id: string; email: string; firstName: string; lastName: string; - - status: NGOStatus; + status: NgoAdminStatus; } export const ngoAdminSchema = z.object({ @@ -45,65 +37,3 @@ export const newNgoSchema = ngoAdminSchema.extend({ name: z.string() }); export type NGOAdminFormData = z.infer; export type NGOCreationFormData = z.infer; - -export const ngoAdminsColDefs: ColumnDef[] = [ - { - header: 'ID', - accessorKey: 'id', - }, - { - accessorKey: 'email', - enableSorting: true, - header: ({ column }) => , - }, - { - accessorKey: 'firstName', - enableSorting: true, - header: ({ column }) => , - }, - { - accessorKey: 'lastName', - enableSorting: true, - header: ({ column }) => , - }, - - { - accessorKey: 'status', - enableSorting: false, - header: ({ column }) => , - cell: ({ - row: { - original: { status }, - }, - }) => {status}, - }, - { - id: 'actions', - cell: ({ row }) => { - const navigate = useNavigate(); - - return ( -
- - - - - - - navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId: row.original.id, tab: 'details' } }) - }> - Edit - - Deactivate - Delete - - -
- ); - }, - }, -]; From 59b797f8d061314fc87bb5df45caa0fc0249293f Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Thu, 23 Jan 2025 17:30:18 +0200 Subject: [PATCH 18/43] WIP: add ngo admin details view --- .../ngos/components/Dashboard/Dashboard.tsx | 4 +- .../features/ngos/components/NGOAdmins.tsx | 35 ++++----- .../features/ngos/components/NGODetails.tsx | 6 +- .../ngos/components/NGOStatusBadge.tsx | 30 -------- .../ngos/components/NgoStatusBadges.tsx | 53 ++++++++++++++ .../{ => admins}/AddNgoAdminDialog.tsx | 4 +- .../components/admins/NgoAdminDetailsView.tsx | 71 +++++++++++++++++++ web/src/features/ngos/hooks/ngos-queriess.ts | 22 +++++- web/src/features/ngos/models/NGO.tsx | 2 +- web/src/features/ngos/models/NgoAdmin.tsx | 25 +++++++ web/src/routeTree.gen.ts | 13 +++- .../ngos/admin.$ngoId.$adminId.view.tsx | 24 +++++++ .../$ngoId.$tab.tsx => view.$ngoId.$tab.tsx} | 2 +- 13 files changed, 227 insertions(+), 64 deletions(-) delete mode 100644 web/src/features/ngos/components/NGOStatusBadge.tsx create mode 100644 web/src/features/ngos/components/NgoStatusBadges.tsx rename web/src/features/ngos/components/{ => admins}/AddNgoAdminDialog.tsx (96%) create mode 100644 web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx create mode 100644 web/src/features/ngos/models/NgoAdmin.tsx create mode 100644 web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx rename web/src/routes/ngos/{view/$ngoId.$tab.tsx => view.$ngoId.$tab.tsx} (97%) diff --git a/web/src/features/ngos/components/Dashboard/Dashboard.tsx b/web/src/features/ngos/components/Dashboard/Dashboard.tsx index 2ebebcef1..752411e8c 100644 --- a/web/src/features/ngos/components/Dashboard/Dashboard.tsx +++ b/web/src/features/ngos/components/Dashboard/Dashboard.tsx @@ -27,7 +27,7 @@ import { useActivateNGO, useDeactivateNGO, useDeteleteNGO, useNGOs } from '../.. import { NGO, NGOStatus } from '../../models/NGO'; import CreateNGODialog from '../CreateNGODialog'; import { NGOsListFilters } from '../filtering/NGOsListFilters'; -import { NGOStatusBadge } from '../NGOStatusBadge'; +import { NgoStatusBadge } from '../NgoStatusBadges'; export default function NGOsDashboard(): ReactElement { const navigate = useNavigate(); @@ -91,7 +91,7 @@ export default function NGOsDashboard(): ReactElement { row: { original: { status }, }, - }) => , + }) => , }, { id: 'actions', diff --git a/web/src/features/ngos/components/NGOAdmins.tsx b/web/src/features/ngos/components/NGOAdmins.tsx index 22af39d18..c56239bb9 100644 --- a/web/src/features/ngos/components/NGOAdmins.tsx +++ b/web/src/features/ngos/components/NGOAdmins.tsx @@ -1,5 +1,4 @@ import { useConfirm } from '@/components/ui/alert-dialog-provider'; -import { Badge } from '@/components/ui/badge'; import { Button, buttonVariants } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader'; @@ -15,18 +14,19 @@ import { Separator } from '@/components/ui/separator'; import { useDialog } from '@/components/ui/use-dialog'; import { FILTER_KEY } from '@/features/filtering/filtering-enums'; import { useFilteringContainer } from '@/features/filtering/hooks/useFilteringContainer'; -import { Route } from '@/routes/ngos/view/$ngoId.$tab'; +import { Route } from '@/routes/ngos/view.$ngoId.$tab'; import { Cog8ToothIcon, FunnelIcon } from '@heroicons/react/24/outline'; import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import { useNavigate } from '@tanstack/react-router'; import type { ColumnDef } from '@tanstack/react-table'; import { useDebounce } from '@uidotdev/usehooks'; import { Plus } from 'lucide-react'; -import { FC, useCallback, useEffect, useMemo, useState } from 'react'; +import { FC, useEffect, useMemo, useState } from 'react'; import { useNgoAdmins, useNGOMutations } from '../hooks/ngos-queriess'; -import { NGOAdmin, NgoAdminStatus } from '../models/NGO'; -import AddNgoAdminDialog from './AddNgoAdminDialog'; +import { NgoAdmin, NgoAdminStatus } from '../models/NGO'; +import AddNgoAdminDialog from './admins/AddNgoAdminDialog'; import { NGOsListFilters } from './filtering/NGOsListFilters'; +import { NgoAdminStatusBadge } from './NgoStatusBadges'; interface NGOAdminsViewProps { ngoId: string; @@ -41,7 +41,6 @@ export const NGOAdminsView: FC = ({ ngoId }) => { const handleSearchInput = (ev: React.FormEvent) => { setSearchText(ev.currentTarget.value); }; - const debouncedSearch = useDebounce(search, 300); const debouncedSearchText = useDebounce(searchText, 300); const addNgoAdminDialog = useDialog(); @@ -66,14 +65,10 @@ export const NGOAdminsView: FC = ({ ngoId }) => { return Object.fromEntries(params); }, [debouncedSearch]); - const navigateToNgo = useCallback( - (ngoId: string) => { - void navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId, tab: 'details' } }); - }, - [navigate] - ); + const navigateToNgoAdmin = (ngoId: string, adminId: string) => + navigate({ to: '/ngos/admin/$ngoId/$adminId/view', params: { ngoId, adminId } }); - const ngoAdminsColDefs: ColumnDef[] = [ + const ngoAdminsColDefs: ColumnDef[] = [ { header: 'ID', accessorKey: 'id', @@ -102,12 +97,13 @@ export const NGOAdminsView: FC = ({ ngoId }) => { row: { original: { status }, }, - }) => {status}, + }) => { + return ; + }, }, { id: 'actions', cell: ({ row }) => { - const navigate = useNavigate(); const adminId = row.original.id; const isAdminActive = row.original.status === NgoAdminStatus.Active; @@ -121,12 +117,7 @@ export const NGOAdminsView: FC = ({ ngoId }) => { - - navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId: row.original.id, tab: 'details' } }) - }> - Edit - + navigateToNgoAdmin(ngoId, row.original.id)}>Edit { e.stopPropagation(); @@ -193,7 +184,7 @@ export const NGOAdminsView: FC = ({ ngoId }) => { columns={ngoAdminsColDefs} useQuery={(params) => useNgoAdmins(ngoId, params)} queryParams={queryParams} - onRowClick={navigateToNgo} + onRowClick={(id) => navigateToNgoAdmin(ngoId, id)} />
diff --git a/web/src/features/ngos/components/NGODetails.tsx b/web/src/features/ngos/components/NGODetails.tsx index 6dc8832a0..8e2d9fb40 100644 --- a/web/src/features/ngos/components/NGODetails.tsx +++ b/web/src/features/ngos/components/NGODetails.tsx @@ -5,12 +5,12 @@ import { PencilIcon } from '@heroicons/react/24/outline'; import Layout from '@/components/layout/Layout'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; -import { Route } from '@/routes/ngos/view/$ngoId.$tab'; +import { Route } from '@/routes/ngos/view.$ngoId.$tab'; import { useNavigate } from '@tanstack/react-router'; import { FC } from 'react'; import { NGO } from '../models/NGO'; import { NGOAdminsView } from './NGOAdmins'; -import { NGOStatusBadge } from './NGOStatusBadge'; +import { NgoStatusBadge } from './NgoStatusBadges'; interface NGODetailsProps { data: NGO; @@ -41,7 +41,7 @@ export const NGODetailsView: FC = ({ data }) => {

Status

- +
diff --git a/web/src/features/ngos/components/NGOStatusBadge.tsx b/web/src/features/ngos/components/NGOStatusBadge.tsx deleted file mode 100644 index d72d8512f..000000000 --- a/web/src/features/ngos/components/NGOStatusBadge.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Badge } from '@/components/ui/badge'; -import { FC } from 'react'; -import { NGOStatus } from '../models/NGO'; - -interface NGOStatusBadgeProps { - status: NGOStatus; -} - -export const NGOStatusBadge: FC = ({ status }) => { - let className = ''; - - switch (status) { - case NGOStatus.Activated: - className = 'badge-Active'; - break; - - case NGOStatus.Pending: - className = 'badge-Pending'; - break; - - case NGOStatus.Deactivated: - className = 'badge-Suspended'; - break; - - default: - break; - } - - return {status}; -}; diff --git a/web/src/features/ngos/components/NgoStatusBadges.tsx b/web/src/features/ngos/components/NgoStatusBadges.tsx new file mode 100644 index 000000000..301a1692f --- /dev/null +++ b/web/src/features/ngos/components/NgoStatusBadges.tsx @@ -0,0 +1,53 @@ +import { Badge } from '@/components/ui/badge'; +import { FC } from 'react'; +import { NgoAdminStatus, NGOStatus } from '../models/NGO'; + +interface NgoStatusBadgeProps { + status: NGOStatus; +} + +export const NgoStatusBadge: FC = ({ status }) => { + let className = ''; + + switch (status) { + case NGOStatus.Activated: + className = 'badge-Active'; + break; + + case NGOStatus.Pending: + className = 'badge-Pending'; + break; + + case NGOStatus.Deactivated: + className = 'badge-Suspended'; + break; + + default: + break; + } + + return {status}; +}; + +interface NgoAdmintatusBadgeProps { + status: NgoAdminStatus; +} + +export const NgoAdminStatusBadge: FC = ({ status }) => { + let className = ''; + + switch (status) { + case NgoAdminStatus.Active: + className = 'badge-Active'; + break; + + case NgoAdminStatus.Deactivated: + className = 'badge-Suspended'; + break; + + default: + break; + } + + return {status}; +}; diff --git a/web/src/features/ngos/components/AddNgoAdminDialog.tsx b/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx similarity index 96% rename from web/src/features/ngos/components/AddNgoAdminDialog.tsx rename to web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx index d2327dbcf..5b510b06d 100644 --- a/web/src/features/ngos/components/AddNgoAdminDialog.tsx +++ b/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx @@ -6,8 +6,8 @@ import { toast } from '@/components/ui/use-toast'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useNGOMutations } from '../hooks/ngos-queriess'; -import { NGOAdminFormData, ngoAdminSchema } from '../models/NGO'; +import { useNGOMutations } from '../../hooks/ngos-queriess'; +import { NGOAdminFormData, ngoAdminSchema } from '../../models/NGO'; export interface AddNgoAdminDialogProps { ngoId: string; diff --git a/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx b/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx new file mode 100644 index 000000000..56eba52cd --- /dev/null +++ b/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx @@ -0,0 +1,71 @@ +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Separator } from '@/components/ui/separator'; +import { PencilIcon } from '@heroicons/react/24/outline'; +import { useNavigate } from '@tanstack/react-router'; + +import { DateTimeFormat } from '@/common/formats'; +import Layout from '@/components/layout/Layout'; +import { format } from 'date-fns'; +import { FC } from 'react'; +import { NgoAdmin } from '../../models/NgoAdmin'; +import { NgoAdminStatusBadge } from '../NgoStatusBadges'; + +interface NgoAdminDetailsViewProps { + ngoAdmin: NgoAdmin; +} + +export const NgoAdminDetailsView: FC = ({ ngoAdmin }) => { + const navigate = useNavigate(); + const displayName = `${ngoAdmin.firstName} ${ngoAdmin.lastName}`; + //TODO: Fix navigate to edit + const navigateToEdit = (): void => { + void navigate({ + //to: '/monitoring-observers/edit/$monitoringObserverId', + params: { monitoringObserverId: ngoAdmin.id }, + }); + }; + + return ( + + + +
+ NGO admin details + +
+ +
+ +
+

Name

+

{displayName}

+
+
+

Email

+

{ngoAdmin.email}

+
+
+

Phone

+

{ngoAdmin.phoneNumber}

+
+ +
+

Last activity

+

+ {' '} + {ngoAdmin.latestActivityAt ? format(ngoAdmin.latestActivityAt, DateTimeFormat) : '-'} +

+
+
+

Status

+ +
+
+
+
+ ); +}; diff --git a/web/src/features/ngos/hooks/ngos-queriess.ts b/web/src/features/ngos/hooks/ngos-queriess.ts index fe90c0d8e..4e462e36e 100644 --- a/web/src/features/ngos/hooks/ngos-queriess.ts +++ b/web/src/features/ngos/hooks/ngos-queriess.ts @@ -5,7 +5,8 @@ import { queryClient } from '@/main'; import { queryOptions, useMutation, useQuery, UseQueryResult, useSuspenseQuery } from '@tanstack/react-query'; import { useRouter } from '@tanstack/react-router'; import { AxiosResponse } from 'axios'; -import { NGO, NGOAdmin, NGOAdminFormData, NGOCreationFormData } from '../models/NGO'; +import { NGO, NGOAdminFormData, NGOCreationFormData } from '../models/NGO'; +import { NgoAdmin, NgoAdminGetRequestParams } from '../models/NgoAdmin'; const ENDPOINT = 'ngos'; export const ngosKeys = { @@ -57,7 +58,7 @@ export const ngoDetailsOptions = (ngoId: string) => export const useNGODetails = (ngoId: string) => useSuspenseQuery(ngoDetailsOptions(ngoId)); -export function useNgoAdmins(ngoId: string, p: DataTableParameters): UseQueryResult, Error> { +export function useNgoAdmins(ngoId: string, p: DataTableParameters): UseQueryResult, Error> { return useQuery({ queryKey: ngosKeys.adminsList(ngoId, p), queryFn: async () => { @@ -286,3 +287,20 @@ export const useDeteleteNGO = () => { }); return { deleteNgoMutation }; }; + +export const ngoAdminDetailsOptions = ({ ngoId, adminId }: NgoAdminGetRequestParams) => + queryOptions({ + queryKey: ngosKeys.detail(ngoId), + queryFn: async () => { + const response = await authApi.get(`/ngos/${ngoId}/admins/${adminId}`); + + if (response.status !== 200) { + throw new Error('Failed to fetch ngo details'); + } + + return response.data; + }, + }); + +export const useNgoAdminDetails = ({ ngoId, adminId }: NgoAdminGetRequestParams) => + useSuspenseQuery(ngoAdminDetailsOptions({ ngoId, adminId })); diff --git a/web/src/features/ngos/models/NGO.tsx b/web/src/features/ngos/models/NGO.tsx index 05bbeff73..64ebd47d5 100644 --- a/web/src/features/ngos/models/NGO.tsx +++ b/web/src/features/ngos/models/NGO.tsx @@ -18,7 +18,7 @@ export enum NgoAdminStatus { Deactivated = 'Deactivated', } -export interface NGOAdmin { +export interface NgoAdmin { id: string; email: string; firstName: string; diff --git a/web/src/features/ngos/models/NgoAdmin.tsx b/web/src/features/ngos/models/NgoAdmin.tsx new file mode 100644 index 000000000..54145316c --- /dev/null +++ b/web/src/features/ngos/models/NgoAdmin.tsx @@ -0,0 +1,25 @@ +import { z } from 'zod'; + +export enum NgoAdminStatus { + Active = 'Active', + Deactivated = 'Deactivated', +} + +export type NgoAdminGetRequestParams = { ngoId: string; adminId: string }; + +export interface NgoAdmin { + id: string; + email: string; + firstName: string; + lastName: string; + status: NgoAdminStatus; + phoneNumber: string; + latestActivityAt: any; +} + +export const ngoAdminSchema = z.object({ + firstName: z.string(), + lastName: z.string(), + email: z.string().email(), + phoneNumber: z.string(), +}); diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index dfae29e0b..fb2f78964 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -51,11 +51,12 @@ import { Route as CitizenGuidesEditGuideIdImport } from './routes/citizen-guides import { Route as ResponsesIncidentReportsFormIdAggregatedImport } from './routes/responses/incident-reports/$formId.aggregated' import { Route as ResponsesFormSubmissionsFormIdAggregatedImport } from './routes/responses/form-submissions/$formId.aggregated' import { Route as ResponsesCitizenReportsFormIdAggregatedImport } from './routes/responses/citizen-reports/$formId.aggregated' -import { Route as NgosViewNgoIdTabImport } from './routes/ngos/view/$ngoId.$tab' +import { Route as NgosViewNgoIdTabImport } from './routes/ngos/view.$ngoId.$tab' import { Route as MonitoringObserversViewMonitoringObserverIdTabImport } from './routes/monitoring-observers/view/$monitoringObserverId.$tab' import { Route as MonitoringObserversPushMessagesIdViewImport } from './routes/monitoring-observers/push-messages.$id_.view' import { Route as FormsFormIdEditTranslationLanguageCodeImport } from './routes/forms_.$formId.edit-translation.$languageCode' import { Route as CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdImport } from './routes/citizen-report-attachments/$electionRoundId.$citizenReportId.$attachmentId' +import { Route as NgosAdminNgoIdAdminIdViewImport } from './routes/ngos/admin.$ngoId.$adminId.view' // Create/Update Routes @@ -302,6 +303,11 @@ const CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdRoute = } as any, ) +const NgosAdminNgoIdAdminIdViewRoute = NgosAdminNgoIdAdminIdViewImport.update({ + path: '/ngos/admin/$ngoId/$adminId/view', + getParentRoute: () => rootRoute, +} as any) + // Populate the FileRoutesByPath interface declare module '@tanstack/react-router' { @@ -486,6 +492,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ResponsesIncidentReportsFormIdAggregatedImport parentRoute: typeof rootRoute } + '/ngos/admin/$ngoId/$adminId/view': { + preLoaderRoute: typeof NgosAdminNgoIdAdminIdViewImport + parentRoute: typeof rootRoute + } } } @@ -537,6 +547,7 @@ export const routeTree = rootRoute.addChildren([ ResponsesCitizenReportsFormIdAggregatedRoute, ResponsesFormSubmissionsFormIdAggregatedRoute, ResponsesIncidentReportsFormIdAggregatedRoute, + NgosAdminNgoIdAdminIdViewRoute, ]) /* prettier-ignore-end */ diff --git a/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx b/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx new file mode 100644 index 000000000..98f9193b4 --- /dev/null +++ b/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx @@ -0,0 +1,24 @@ +import { NgoAdminDetailsView } from '@/features/ngos/components/admins/NgoAdminDetailsView'; +import { ngoDetailsOptions, useNgoAdminDetails } from '@/features/ngos/hooks/ngos-queriess'; +import { redirectIfNotAuth } from '@/lib/utils'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/ngos/admin/$ngoId/$adminId/view')({ + beforeLoad: ({ params }) => { + redirectIfNotAuth(); + }, + component: NgoAdminDetails, + + loader: ({ context: { queryClient }, params: { ngoId } }) => queryClient.ensureQueryData(ngoDetailsOptions(ngoId)), +}); + +function NgoAdminDetails() { + const { ngoId, adminId } = Route.useParams(); + const { data: ngoAdmin } = useNgoAdminDetails({ ngoId, adminId }); + + return ( +
+ +
+ ); +} diff --git a/web/src/routes/ngos/view/$ngoId.$tab.tsx b/web/src/routes/ngos/view.$ngoId.$tab.tsx similarity index 97% rename from web/src/routes/ngos/view/$ngoId.$tab.tsx rename to web/src/routes/ngos/view.$ngoId.$tab.tsx index 30dbf7656..6745292a2 100644 --- a/web/src/routes/ngos/view/$ngoId.$tab.tsx +++ b/web/src/routes/ngos/view.$ngoId.$tab.tsx @@ -3,7 +3,7 @@ import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-que import { redirectIfNotAuth } from '@/lib/utils'; import { createFileRoute, redirect } from '@tanstack/react-router'; import { z } from 'zod'; -import { ngoRouteSearchSchema } from '..'; +import { ngoRouteSearchSchema } from '.'; export const NgoAdminsSearchParamsSchema = ngoRouteSearchSchema.partial(); export type NgoAdminsSearchParams = z.infer; From 14ee4a1c7017fcb87471c519a732863faa607432 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 24 Jan 2025 17:00:17 +0200 Subject: [PATCH 19/43] WIP: fix back buttons --- .../features/ngos/components/NGODetails.tsx | 3 ++- .../ngos/components/NgoExtraComponents.tsx | 20 +++++++++++++++++++ .../components/admins/NgoAdminDetailsView.tsx | 6 ++++-- .../ngos/admin.$ngoId.$adminId.view.tsx | 2 +- 4 files changed, 27 insertions(+), 4 deletions(-) create mode 100644 web/src/features/ngos/components/NgoExtraComponents.tsx diff --git a/web/src/features/ngos/components/NGODetails.tsx b/web/src/features/ngos/components/NGODetails.tsx index 8e2d9fb40..ab984565f 100644 --- a/web/src/features/ngos/components/NGODetails.tsx +++ b/web/src/features/ngos/components/NGODetails.tsx @@ -11,6 +11,7 @@ import { FC } from 'react'; import { NGO } from '../models/NGO'; import { NGOAdminsView } from './NGOAdmins'; import { NgoStatusBadge } from './NgoStatusBadges'; +import { NgoBackButton } from './NgoExtraComponents'; interface NGODetailsProps { data: NGO; @@ -60,7 +61,7 @@ export const NGODetails: FC = ({ data }) => { }); } return ( - + }> Organization details diff --git a/web/src/features/ngos/components/NgoExtraComponents.tsx b/web/src/features/ngos/components/NgoExtraComponents.tsx new file mode 100644 index 000000000..1c10fd1cc --- /dev/null +++ b/web/src/features/ngos/components/NgoExtraComponents.tsx @@ -0,0 +1,20 @@ +import { usePrevSearch } from '@/common/prev-search-store'; +import { BackButtonIcon } from '@/components/layout/Breadcrumbs/BackButton'; +import { Link } from '@tanstack/react-router'; +import { FC } from 'react'; + +interface NgoBackButtonProps { + ngoId?: string; +} + +export const NgoBackButton: FC = ({ ngoId }) => { + const prevSearch = usePrevSearch(); + const destination = ngoId ? '/ngos/view/$ngoId/$tab' : '/ngos'; + const linkParams = ngoId ? { ngoId, tab: 'admins' } : {}; + + return ( + + + + ); +}; diff --git a/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx b/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx index 56eba52cd..70156f809 100644 --- a/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx +++ b/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx @@ -9,13 +9,15 @@ import Layout from '@/components/layout/Layout'; import { format } from 'date-fns'; import { FC } from 'react'; import { NgoAdmin } from '../../models/NgoAdmin'; +import { NgoBackButton } from '../NgoExtraComponents'; import { NgoAdminStatusBadge } from '../NgoStatusBadges'; interface NgoAdminDetailsViewProps { + ngoId: string; ngoAdmin: NgoAdmin; } -export const NgoAdminDetailsView: FC = ({ ngoAdmin }) => { +export const NgoAdminDetailsView: FC = ({ ngoId, ngoAdmin }) => { const navigate = useNavigate(); const displayName = `${ngoAdmin.firstName} ${ngoAdmin.lastName}`; //TODO: Fix navigate to edit @@ -27,7 +29,7 @@ export const NgoAdminDetailsView: FC = ({ ngoAdmin }) }; return ( - + }>
diff --git a/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx b/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx index 98f9193b4..f4210f757 100644 --- a/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx +++ b/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx @@ -18,7 +18,7 @@ function NgoAdminDetails() { return (
- +
); } From a285659bbc5473dcb83df73a117a41d789d25ccd Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Mon, 27 Jan 2025 10:43:25 +0200 Subject: [PATCH 20/43] WIP: fix breadcrumbs --- .../features/ngos/components/NGODetails.tsx | 7 ++- .../ngos/components/NgoExtraComponents.tsx | 43 +++++++++++++++++++ .../components/admins/NgoAdminDetailsView.tsx | 12 ++++-- .../ngos/admin.$ngoId.$adminId.view.tsx | 5 ++- web/src/routes/ngos/index.tsx | 4 +- 5 files changed, 62 insertions(+), 9 deletions(-) diff --git a/web/src/features/ngos/components/NGODetails.tsx b/web/src/features/ngos/components/NGODetails.tsx index ab984565f..eed25ff3e 100644 --- a/web/src/features/ngos/components/NGODetails.tsx +++ b/web/src/features/ngos/components/NGODetails.tsx @@ -10,8 +10,8 @@ import { useNavigate } from '@tanstack/react-router'; import { FC } from 'react'; import { NGO } from '../models/NGO'; import { NGOAdminsView } from './NGOAdmins'; +import { NgoBackButton, NgoBreadcrumbs } from './NgoExtraComponents'; import { NgoStatusBadge } from './NgoStatusBadges'; -import { NgoBackButton } from './NgoExtraComponents'; interface NGODetailsProps { data: NGO; @@ -61,7 +61,10 @@ export const NGODetails: FC = ({ data }) => { }); } return ( - }> + } + breadcrumbs={data && }> Organization details diff --git a/web/src/features/ngos/components/NgoExtraComponents.tsx b/web/src/features/ngos/components/NgoExtraComponents.tsx index 1c10fd1cc..6d2fabf2f 100644 --- a/web/src/features/ngos/components/NgoExtraComponents.tsx +++ b/web/src/features/ngos/components/NgoExtraComponents.tsx @@ -18,3 +18,46 @@ export const NgoBackButton: FC = ({ ngoId }) => { ); }; + +interface NgoBreadcrumbsProps { + ngoData: { + id: string; + name: string; + }; + adminData?: { + id: string; + name: string; + }; + tab?: string; +} + +export const NgoBreadcrumbs: FC = ({ ngoData, adminData, tab }) => { + return ( +
+ + ngos + + + {ngoData.name} + + + {tab ?? 'admins'} + + + {adminData?.name} + +
+ ); +}; diff --git a/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx b/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx index 70156f809..a1bd91406 100644 --- a/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx +++ b/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx @@ -9,15 +9,16 @@ import Layout from '@/components/layout/Layout'; import { format } from 'date-fns'; import { FC } from 'react'; import { NgoAdmin } from '../../models/NgoAdmin'; -import { NgoBackButton } from '../NgoExtraComponents'; +import { NgoBackButton, NgoBreadcrumbs } from '../NgoExtraComponents'; import { NgoAdminStatusBadge } from '../NgoStatusBadges'; interface NgoAdminDetailsViewProps { ngoId: string; + ngoName: string; ngoAdmin: NgoAdmin; } -export const NgoAdminDetailsView: FC = ({ ngoId, ngoAdmin }) => { +export const NgoAdminDetailsView: FC = ({ ngoId, ngoName, ngoAdmin }) => { const navigate = useNavigate(); const displayName = `${ngoAdmin.firstName} ${ngoAdmin.lastName}`; //TODO: Fix navigate to edit @@ -29,7 +30,12 @@ export const NgoAdminDetailsView: FC = ({ ngoId, ngoAd }; return ( - }> + } + breadcrumbs={ + + }>
diff --git a/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx b/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx index f4210f757..cdea7fcdd 100644 --- a/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx +++ b/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx @@ -1,5 +1,5 @@ import { NgoAdminDetailsView } from '@/features/ngos/components/admins/NgoAdminDetailsView'; -import { ngoDetailsOptions, useNgoAdminDetails } from '@/features/ngos/hooks/ngos-queriess'; +import { ngoDetailsOptions, useNgoAdminDetails, useNGODetails } from '@/features/ngos/hooks/ngos-queriess'; import { redirectIfNotAuth } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; @@ -15,10 +15,11 @@ export const Route = createFileRoute('/ngos/admin/$ngoId/$adminId/view')({ function NgoAdminDetails() { const { ngoId, adminId } = Route.useParams(); const { data: ngoAdmin } = useNgoAdminDetails({ ngoId, adminId }); + const { data: ngo } = useNGODetails(ngoId); return (
- +
); } diff --git a/web/src/routes/ngos/index.tsx b/web/src/routes/ngos/index.tsx index e6d71f370..8feabfcbb 100644 --- a/web/src/routes/ngos/index.tsx +++ b/web/src/routes/ngos/index.tsx @@ -2,7 +2,7 @@ import { SortOrder } from '@/common/types'; import NGOsDashboard from '@/features/ngos/components/Dashboard/Dashboard'; import { NGOStatus } from '@/features/ngos/models/NGO'; import { redirectIfNotAuth } from '@/lib/utils'; -import { createFileRoute } from '@tanstack/react-router'; +import { createFileRoute, SearchSchemaInput } from '@tanstack/react-router'; import { z } from 'zod'; export const ngoRouteSearchSchema = z.object({ @@ -19,7 +19,7 @@ export const Route = createFileRoute('/ngos/')({ redirectIfNotAuth(); }, component: Ngos, - validateSearch: ngoRouteSearchSchema, + validateSearch: (search: unknown & SearchSchemaInput) => ngoRouteSearchSchema.parse(search), }); function Ngos() { From a6f1112de8b6ebb61b65e9dc2dfab9aee2024c99 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Mon, 27 Jan 2025 11:26:53 +0200 Subject: [PATCH 21/43] WIP: fix undefined ngo name in breadcrumbs --- .../ngos/components/NgoExtraComponents.tsx | 17 ++++++++++------- .../components/admins/NgoAdminDetailsView.tsx | 1 + .../routes/ngos/admin.$ngoId.$adminId.view.tsx | 16 ++++++++++++++-- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/web/src/features/ngos/components/NgoExtraComponents.tsx b/web/src/features/ngos/components/NgoExtraComponents.tsx index 6d2fabf2f..7974c42b8 100644 --- a/web/src/features/ngos/components/NgoExtraComponents.tsx +++ b/web/src/features/ngos/components/NgoExtraComponents.tsx @@ -32,18 +32,21 @@ interface NgoBreadcrumbsProps { } export const NgoBreadcrumbs: FC = ({ ngoData, adminData, tab }) => { + console.log(ngoData); return (
ngos - - {ngoData.name} - + {ngoData?.name && ( + + {ngoData.name} + + )} = ({ ngoId, ngoNa }); }; + return ( queryClient.ensureQueryData(ngoDetailsOptions(ngoId)), + loader: async ({ context: { queryClient }, params: { ngoId, adminId } }) => { + const ngoDataPromise = queryClient.ensureQueryData(ngoDetailsOptions(ngoId)); + const ngoAdminDataPromise = queryClient.ensureQueryData(ngoAdminDetailsOptions({ ngoId, adminId })); + + const [ngoData, ngoAdminData] = await Promise.all([ngoDataPromise, ngoAdminDataPromise]); + + return { ngoAdminData, ngoData }; + }, }); function NgoAdminDetails() { From 09adafe2c2e430c6d9ba5701bdaf9cd7308e1fe9 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Mon, 27 Jan 2025 15:53:26 +0200 Subject: [PATCH 22/43] WIP: add form for editing NGO admins --- .../features/ngos/components/NGOAdmins.tsx | 20 +- .../ngos/components/admins/EditNgoAdmin.tsx | 201 ++++++++++++++++++ .../components/admins/NgoAdminDetailsView.tsx | 6 +- web/src/features/ngos/hooks/ngos-queriess.ts | 38 +++- web/src/features/ngos/models/NgoAdmin.tsx | 12 ++ web/src/routeTree.gen.ts | 11 + .../ngos/admin.$ngoId.$adminId.edit.tsx | 37 ++++ 7 files changed, 304 insertions(+), 21 deletions(-) create mode 100644 web/src/features/ngos/components/admins/EditNgoAdmin.tsx create mode 100644 web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx diff --git a/web/src/features/ngos/components/NGOAdmins.tsx b/web/src/features/ngos/components/NGOAdmins.tsx index c56239bb9..ef6c62ac6 100644 --- a/web/src/features/ngos/components/NGOAdmins.tsx +++ b/web/src/features/ngos/components/NGOAdmins.tsx @@ -1,5 +1,4 @@ -import { useConfirm } from '@/components/ui/alert-dialog-provider'; -import { Button, buttonVariants } from '@/components/ui/button'; +import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { DataTableColumnHeader } from '@/components/ui/DataTable/DataTableColumnHeader'; import { QueryParamsDataTable } from '@/components/ui/DataTable/QueryParamsDataTable'; @@ -22,7 +21,7 @@ import type { ColumnDef } from '@tanstack/react-table'; import { useDebounce } from '@uidotdev/usehooks'; import { Plus } from 'lucide-react'; import { FC, useEffect, useMemo, useState } from 'react'; -import { useNgoAdmins, useNGOMutations } from '../hooks/ngos-queriess'; +import { useNgoAdminDeleteWithConfirmation, useNgoAdmins, useNGOMutations } from '../hooks/ngos-queriess'; import { NgoAdmin, NgoAdminStatus } from '../models/NGO'; import AddNgoAdminDialog from './admins/AddNgoAdminDialog'; import { NGOsListFilters } from './filtering/NGOsListFilters'; @@ -34,7 +33,7 @@ interface NGOAdminsViewProps { export const NGOAdminsView: FC = ({ ngoId }) => { const navigate = useNavigate(); - const confirm = useConfirm(); + const { deleteNgoAdminWithConfirmation } = useNgoAdminDeleteWithConfirmation(ngoId); const { isFilteringContainerVisible, navigateHandler, toggleFilteringContainerVisibility } = useFilteringContainer(); const search = Route.useSearch(); const [searchText, setSearchText] = useState(search.searchText); @@ -105,6 +104,7 @@ export const NGOAdminsView: FC = ({ ngoId }) => { id: 'actions', cell: ({ row }) => { const adminId = row.original.id; + const adminName = `${row.original.firstName} ${row.original.lastName}`; const isAdminActive = row.original.status === NgoAdminStatus.Active; return ( @@ -131,17 +131,7 @@ export const NGOAdminsView: FC = ({ ngoId }) => { className='text-red-600' onClick={async (e) => { e.stopPropagation(); - if ( - await confirm({ - title: `Delete ${row.original.firstName} ${row.original.lastName}?`, - body: 'This action is permanent and cannot be undone. Once deleted, this NGO admin cannot be retrieved.', - actionButton: 'Delete', - actionButtonClass: buttonVariants({ variant: 'destructive' }), - cancelButton: 'Cancel', - }) - ) { - deleteNgoAdminMutation.mutate({ ngoId, adminId }); - } + await deleteNgoAdminWithConfirmation({ ngoId, adminId, name: adminName }); }}> Delete diff --git a/web/src/features/ngos/components/admins/EditNgoAdmin.tsx b/web/src/features/ngos/components/admins/EditNgoAdmin.tsx new file mode 100644 index 000000000..06fceb85b --- /dev/null +++ b/web/src/features/ngos/components/admins/EditNgoAdmin.tsx @@ -0,0 +1,201 @@ +import Layout from '@/components/layout/Layout'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; +import { Input } from '@/components/ui/input'; +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { Separator } from '@/components/ui/separator'; +import { Route } from '@/routes/ngos/admin.$ngoId.$adminId.edit'; +import { TrashIcon } from '@heroicons/react/24/outline'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { useNavigate } from '@tanstack/react-router'; +import { FC } from 'react'; +import { useForm } from 'react-hook-form'; +import { z } from 'zod'; +import { useNgoAdminDeleteWithConfirmation } from '../../hooks/ngos-queriess'; +import { NgoAdminStatus } from '../../models/NGO'; +import { editNgoAdminSchema, NgoAdmin } from '../../models/NgoAdmin'; +import { NgoBackButton, NgoBreadcrumbs } from '../NgoExtraComponents'; + +interface EditNgoAdminProps { + existingData: NgoAdmin; + id: string; +} + +export const EditNgoAdmin: FC = ({ id, existingData }) => { + const navigate = useNavigate(); + const { ngoId, adminId } = Route.useParams(); + const { deleteNgoAdminWithConfirmation } = useNgoAdminDeleteWithConfirmation(ngoId); + const displayName = `${existingData.firstName} ${existingData.lastName}`; + + const form = useForm>({ + resolver: zodResolver(editNgoAdminSchema), + defaultValues: { + firstName: existingData.firstName, + lastName: existingData.lastName, + email: existingData.email, + phoneNumber: existingData.phoneNumber, + status: existingData.status, + }, + }); + + function onSubmit(values: z.infer) { + // editMutation.mutate({ + // observerId: observer.id, + // obj: values, + // }); + } + + // const deleteMutation = useMutation({ + // mutationFn: (observerId: string) => { + // return authApi.delete(`/observers/${observerId}`); + // }, + // onSuccess: () => { + // navigate({ to: '/observers' }); + // }, + // }); + + // const editMutation = useMutation({ + // mutationFn: ({ observerId, obj }: any) => { + // return authApi.put(`/observers/${observerId}`, obj); + // }, + // }); + + const handleDelete = async () => { + await deleteNgoAdminWithConfirmation({ + ngoId, + adminId, + name: displayName, + onMutationSuccess: () => { + navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId, tab: 'admins' } }); + }, + }); + }; + + return ( + } + breadcrumbs={ + + }> + + +
+ Edit NGO admin +
+ +
+ +
+ + ( + + + First name * + + + + + + + )} + /> + + ( + + + Last name * + + + + + + + )} + /> + ( + + + Email * + + + + + + + )} + /> + ( + + + Phone number * + + + + + + + )} + /> + ( + + + Status * + + + + + + + )} + /> +
+ +
+ + +
+
+ + +
+
+
+ ); +}; diff --git a/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx b/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx index 5ff8b57ff..fc32dead4 100644 --- a/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx +++ b/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx @@ -21,15 +21,13 @@ interface NgoAdminDetailsViewProps { export const NgoAdminDetailsView: FC = ({ ngoId, ngoName, ngoAdmin }) => { const navigate = useNavigate(); const displayName = `${ngoAdmin.firstName} ${ngoAdmin.lastName}`; - //TODO: Fix navigate to edit const navigateToEdit = (): void => { void navigate({ - //to: '/monitoring-observers/edit/$monitoringObserverId', - params: { monitoringObserverId: ngoAdmin.id }, + to: '/ngos/admin/$ngoId/$adminId/edit', + params: { adminId: ngoAdmin.id, ngoId }, }); }; - return ( { }); const deleteNgoAdminMutation = useMutation({ - mutationFn: ({ ngoId, adminId }: { ngoId: string; adminId: string }) => { + mutationFn: ({ ngoId, adminId }: { ngoId: string; adminId: string; onMutationSuccess?: () => void }) => { return authApi.delete(`${ENDPOINT}/${ngoId}/admins/${adminId}`, {}); }, - onSuccess: () => { + onSuccess: (_, { onMutationSuccess }) => { queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); router.invalidate(); @@ -184,6 +186,8 @@ export const useNGOMutations = () => { title: 'Success', description: 'NGO admin was deleted successfully', }); + + if (onMutationSuccess) onMutationSuccess(); }, onError: () => { @@ -304,3 +308,33 @@ export const ngoAdminDetailsOptions = ({ ngoId, adminId }: NgoAdminGetRequestPar export const useNgoAdminDetails = ({ ngoId, adminId }: NgoAdminGetRequestParams) => useSuspenseQuery(ngoAdminDetailsOptions({ ngoId, adminId })); + +export const useNgoAdminDeleteWithConfirmation = (ngoId: string) => { + const confirm = useConfirm(); + const { deleteNgoAdminMutation } = useNGOMutations(); + + const deleteNgoAdminWithConfirmation = async ({ + ngoId, + adminId, + name, + onMutationSuccess, + }: { + ngoId: string; + adminId: string; + name: string; + onMutationSuccess?: () => void; + }) => { + if ( + await confirm({ + title: `Delete ${name}?`, + body: 'This action is permanent and cannot be undone. Once deleted, this NGO admin cannot be retrieved.', + actionButton: 'Delete', + actionButtonClass: buttonVariants({ variant: 'destructive' }), + cancelButton: 'Cancel', + }) + ) { + deleteNgoAdminMutation.mutate({ ngoId, adminId, onMutationSuccess }); + } + }; + return { deleteNgoAdminWithConfirmation }; +}; diff --git a/web/src/features/ngos/models/NgoAdmin.tsx b/web/src/features/ngos/models/NgoAdmin.tsx index 54145316c..1b9ba3415 100644 --- a/web/src/features/ngos/models/NgoAdmin.tsx +++ b/web/src/features/ngos/models/NgoAdmin.tsx @@ -23,3 +23,15 @@ export const ngoAdminSchema = z.object({ email: z.string().email(), phoneNumber: z.string(), }); + +export const editNgoAdminSchema = z.object({ + firstName: z.string().min(2, { + message: 'This field is mandatory', + }), + lastName: z.string().min(2, { + message: 'This field is mandatory', + }), + email: z.string().min(1, { message: 'This field is mandatory' }).email('Email is not valid'), + phoneNumber: z.string().min(1, { message: 'This field is required' }), + status: z.string(), +}); diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index fb2f78964..09007dbc0 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -57,6 +57,7 @@ import { Route as MonitoringObserversPushMessagesIdViewImport } from './routes/m import { Route as FormsFormIdEditTranslationLanguageCodeImport } from './routes/forms_.$formId.edit-translation.$languageCode' import { Route as CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdImport } from './routes/citizen-report-attachments/$electionRoundId.$citizenReportId.$attachmentId' import { Route as NgosAdminNgoIdAdminIdViewImport } from './routes/ngos/admin.$ngoId.$adminId.view' +import { Route as NgosAdminNgoIdAdminIdEditImport } from './routes/ngos/admin.$ngoId.$adminId.edit' // Create/Update Routes @@ -308,6 +309,11 @@ const NgosAdminNgoIdAdminIdViewRoute = NgosAdminNgoIdAdminIdViewImport.update({ getParentRoute: () => rootRoute, } as any) +const NgosAdminNgoIdAdminIdEditRoute = NgosAdminNgoIdAdminIdEditImport.update({ + path: '/ngos/admin/$ngoId/$adminId/edit', + getParentRoute: () => rootRoute, +} as any) + // Populate the FileRoutesByPath interface declare module '@tanstack/react-router' { @@ -492,6 +498,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ResponsesIncidentReportsFormIdAggregatedImport parentRoute: typeof rootRoute } + '/ngos/admin/$ngoId/$adminId/edit': { + preLoaderRoute: typeof NgosAdminNgoIdAdminIdEditImport + parentRoute: typeof rootRoute + } '/ngos/admin/$ngoId/$adminId/view': { preLoaderRoute: typeof NgosAdminNgoIdAdminIdViewImport parentRoute: typeof rootRoute @@ -547,6 +557,7 @@ export const routeTree = rootRoute.addChildren([ ResponsesCitizenReportsFormIdAggregatedRoute, ResponsesFormSubmissionsFormIdAggregatedRoute, ResponsesIncidentReportsFormIdAggregatedRoute, + NgosAdminNgoIdAdminIdEditRoute, NgosAdminNgoIdAdminIdViewRoute, ]) diff --git a/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx b/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx new file mode 100644 index 000000000..9bb52da6e --- /dev/null +++ b/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx @@ -0,0 +1,37 @@ +import { EditNgoAdmin } from '@/features/ngos/components/admins/EditNgoAdmin'; +import { + ngoAdminDetailsOptions, + ngoDetailsOptions, + useNgoAdminDetails, + useNGODetails, +} from '@/features/ngos/hooks/ngos-queriess'; +import { redirectIfNotAuth } from '@/lib/utils'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/ngos/admin/$ngoId/$adminId/edit')({ + beforeLoad: ({ params }) => { + redirectIfNotAuth(); + }, + component: NgoAdminDetails, + + loader: async ({ context: { queryClient }, params: { ngoId, adminId } }) => { + const ngoDataPromise = queryClient.ensureQueryData(ngoDetailsOptions(ngoId)); + const ngoAdminDataPromise = queryClient.ensureQueryData(ngoAdminDetailsOptions({ ngoId, adminId })); + + const [ngoData, ngoAdminData] = await Promise.all([ngoDataPromise, ngoAdminDataPromise]); + + return { ngoAdminData, ngoData }; + }, +}); + +function NgoAdminDetails() { + const { ngoId, adminId } = Route.useParams(); + const { data: ngoAdmin } = useNgoAdminDetails({ ngoId, adminId }); + const { data: ngo } = useNGODetails(ngoId); + + return ( +
+ +
+ ); +} From 23e2608584715334074286dd9a3cefb98a42770d Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Tue, 28 Jan 2025 10:10:08 +0200 Subject: [PATCH 23/43] =?UTF-8?q?WIP=C8=98=20fix=20NGO=20name=20disappeari?= =?UTF-8?q?ng=20from=20breadcrumbs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../features/ngos/components/NgoExtraComponents.tsx | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/web/src/features/ngos/components/NgoExtraComponents.tsx b/web/src/features/ngos/components/NgoExtraComponents.tsx index 7974c42b8..4ae048a5c 100644 --- a/web/src/features/ngos/components/NgoExtraComponents.tsx +++ b/web/src/features/ngos/components/NgoExtraComponents.tsx @@ -1,7 +1,7 @@ import { usePrevSearch } from '@/common/prev-search-store'; import { BackButtonIcon } from '@/components/layout/Breadcrumbs/BackButton'; import { Link } from '@tanstack/react-router'; -import { FC } from 'react'; +import { FC, useEffect, useState } from 'react'; interface NgoBackButtonProps { ngoId?: string; @@ -32,19 +32,24 @@ interface NgoBreadcrumbsProps { } export const NgoBreadcrumbs: FC = ({ ngoData, adminData, tab }) => { - console.log(ngoData); + const [ngoName, setNgoName] = useState(null); + + useEffect(() => { + if (!ngoName && ngoData.name !== undefined) setNgoName(ngoData.name); + }, [ngoData.name]); + return (
ngos - {ngoData?.name && ( + {ngoName && ( - {ngoData.name} + {ngoName} )} Date: Tue, 28 Jan 2025 10:34:44 +0200 Subject: [PATCH 24/43] WIP: add new NGO data to table and details view --- .../ngos/components/Dashboard/Dashboard.tsx | 25 +++++++++++++++++++ .../features/ngos/components/NGODetails.tsx | 15 +++++++++++ web/src/features/ngos/models/NGO.tsx | 3 +++ 3 files changed, 43 insertions(+) diff --git a/web/src/features/ngos/components/Dashboard/Dashboard.tsx b/web/src/features/ngos/components/Dashboard/Dashboard.tsx index 752411e8c..bcff0d0c9 100644 --- a/web/src/features/ngos/components/Dashboard/Dashboard.tsx +++ b/web/src/features/ngos/components/Dashboard/Dashboard.tsx @@ -83,6 +83,30 @@ export default function NGOsDashboard(): ReactElement { enableSorting: true, header: ({ column }) => , }, + + { + accessorKey: 'numberOfNgoAdmins', + enableSorting: true, + header: ({ column }) => , + }, + + { + accessorKey: 'numberOfElectionsMonitoring', + enableSorting: true, + header: ({ column }) => , + }, + + { + accessorKey: 'dateOfLastElection', + enableSorting: false, + header: ({ column }) => , + cell: ({ + row: { + original: { dateOfLastElection }, + }, + }) => dateOfLastElection ?? 'N/A', + }, + { accessorKey: 'status', enableSorting: false, @@ -93,6 +117,7 @@ export default function NGOsDashboard(): ReactElement { }, }) => , }, + { id: 'actions', cell: ({ row }) => { diff --git a/web/src/features/ngos/components/NGODetails.tsx b/web/src/features/ngos/components/NGODetails.tsx index eed25ff3e..ea0e92b6e 100644 --- a/web/src/features/ngos/components/NGODetails.tsx +++ b/web/src/features/ngos/components/NGODetails.tsx @@ -44,6 +44,21 @@ export const NGODetailsView: FC = ({ data }) => {

Status

+ +
+

Admins

+

{data.numberOfNgoAdmins}

+
+ +
+

Election events

+

{data.numberOfElectionsMonitoring}

+
+ +
+

Date of last event

+

{data?.dateOfLastElection ?? 'N/A'}

+
); diff --git a/web/src/features/ngos/models/NGO.tsx b/web/src/features/ngos/models/NGO.tsx index 64ebd47d5..415f0fc19 100644 --- a/web/src/features/ngos/models/NGO.tsx +++ b/web/src/features/ngos/models/NGO.tsx @@ -5,6 +5,9 @@ export interface NGO { id: string; name: string; status: NGOStatus; + numberOfNgoAdmins: number; + numberOfElectionsMonitoring: number; + dateOfLastElection: string; } export enum NGOStatus { From 802ba2f14bcd3cc99b37db67d8a287e37e1b09ed Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Tue, 28 Jan 2025 11:12:41 +0200 Subject: [PATCH 25/43] WIP: add password field for admin creation --- .../ngos/components/CreateNGODialog.tsx | 18 ++++++++++--- .../features/ngos/components/NGOAdmins.tsx | 2 +- .../components/admins/AddNgoAdminDialog.tsx | 20 +++++++++++--- web/src/features/ngos/hooks/ngos-queriess.ts | 10 +++---- web/src/features/ngos/models/NGO.tsx | 26 +++---------------- web/src/features/ngos/models/NgoAdmin.tsx | 3 +++ 6 files changed, 43 insertions(+), 36 deletions(-) diff --git a/web/src/features/ngos/components/CreateNGODialog.tsx b/web/src/features/ngos/components/CreateNGODialog.tsx index 582d39c81..ad9baf754 100644 --- a/web/src/features/ngos/components/CreateNGODialog.tsx +++ b/web/src/features/ngos/components/CreateNGODialog.tsx @@ -8,7 +8,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import { useNGOMutations } from '../hooks/ngos-queriess'; -import { newNgoSchema, NGOCreationFormData } from '../models/NGO'; +import { newNgoSchema, NgoCreationFormData } from '../models/NGO'; export interface CreateNGODialogProps { open: boolean; @@ -19,11 +19,11 @@ function CreateNGODialog({ open, onOpenChange }: CreateNGODialogProps) { const { t } = useTranslation('translation', { keyPrefix: 'observers.addObserver' }); const { createNgoMutation } = useNGOMutations(); - const form = useForm({ + const form = useForm({ resolver: zodResolver(newNgoSchema), }); - function onSubmit(values: NGOCreationFormData) { + function onSubmit(values: NgoCreationFormData) { createNgoMutation.mutate({ values, onMutationSuccess: () => { @@ -121,6 +121,18 @@ function CreateNGODialog({ open, onOpenChange }: CreateNGODialogProps) { )} /> + ( + + Password + + + + )} + /> + +
+ + +
+
+ + + + + + ); +}; diff --git a/web/src/features/ngos/components/NGODetails.tsx b/web/src/features/ngos/components/NGODetails.tsx index 1fa8dfa5f..966bf3209 100644 --- a/web/src/features/ngos/components/NGODetails.tsx +++ b/web/src/features/ngos/components/NGODetails.tsx @@ -18,16 +18,20 @@ interface NGODetailsProps { } export const NGODetailsView: FC = ({ data }) => { + const navigate = useNavigate(); + const navigateToEdit = (): void => { + void navigate({ + to: '/ngos/edit/$ngoId', + params: { ngoId: data.id }, + }); + }; + return (
Organization details - diff --git a/web/src/features/ngos/components/admins/EditNgoAdmin.tsx b/web/src/features/ngos/components/admins/EditNgoAdmin.tsx index bf587d97d..26915e507 100644 --- a/web/src/features/ngos/components/admins/EditNgoAdmin.tsx +++ b/web/src/features/ngos/components/admins/EditNgoAdmin.tsx @@ -42,21 +42,6 @@ export const EditNgoAdmin: FC = ({ id, existingData }) => { editNgoAdminMutation.mutate({ adminId, values }); } - // const deleteMutation = useMutation({ - // mutationFn: (observerId: string) => { - // return authApi.delete(`/observers/${observerId}`); - // }, - // onSuccess: () => { - // navigate({ to: '/observers' }); - // }, - // }); - - // const editMutation = useMutation({ - // mutationFn: ({ observerId, obj }: any) => { - // return authApi.put(`/observers/${observerId}`, obj); - // }, - // }); - const handleDelete = async () => { await deleteNgoAdminWithConfirmation({ adminId, diff --git a/web/src/features/ngos/hooks/ngos-queriess.ts b/web/src/features/ngos/hooks/ngos-queriess.ts index 6a8880071..8c9981882 100644 --- a/web/src/features/ngos/hooks/ngos-queriess.ts +++ b/web/src/features/ngos/hooks/ngos-queriess.ts @@ -5,9 +5,9 @@ import { buttonVariants } from '@/components/ui/button'; import { toast } from '@/components/ui/use-toast'; import { queryClient } from '@/main'; import { queryOptions, useMutation, useQuery, UseQueryResult, useSuspenseQuery } from '@tanstack/react-query'; -import { useRouter } from '@tanstack/react-router'; +import { useNavigate, useRouter } from '@tanstack/react-router'; import { AxiosResponse } from 'axios'; -import { NGO, NgoCreationFormData } from '../models/NGO'; +import { EditNgoFormData, NGO, NgoCreationFormData } from '../models/NGO'; import { useCreateNgoAdmin } from './ngo-admin-queries'; const ENDPOINT = 'ngos'; @@ -92,6 +92,26 @@ export const useCreateNgo = () => { export const useNgoMutations = () => { const router = useRouter(); const confirm = useConfirm(); + const navigate = useNavigate(); + + const editNgoMutation = useMutation({ + mutationFn: ({ ngoId, values }: { ngoId: string; values: EditNgoFormData }) => { + return authApi.put(`${ENDPOINT}/${ngoId}`, values); + }, + + onSuccess: (_, { ngoId }) => { + queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); + router.invalidate(); + navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId: ngoId!, tab: 'details' } }); + }, + onError: () => { + toast({ + title: 'Error editing NGO', + description: '', + variant: 'destructive', + }); + }, + }); const deactivateNgoMutation = useMutation({ mutationFn: (ngoId: string) => { @@ -191,5 +211,5 @@ export const useNgoMutations = () => { } }; - return { deactivateNgoMutation, activateNgoMutation, deleteNgoWithConfirmation }; + return { editNgoMutation, deactivateNgoMutation, activateNgoMutation, deleteNgoWithConfirmation }; }; diff --git a/web/src/features/ngos/models/NGO.tsx b/web/src/features/ngos/models/NGO.tsx index ead036493..df1218662 100644 --- a/web/src/features/ngos/models/NGO.tsx +++ b/web/src/features/ngos/models/NGO.tsx @@ -20,3 +20,11 @@ export enum NGOStatus { export const newNgoSchema = ngoAdminSchema.extend({ name: z.string() }); export type NgoCreationFormData = z.infer; +export const editNgoSchema = z.object({ + name: z.string().min(2, { + message: 'This field is mandatory', + }), + status: z.string(), +}); + +export type EditNgoFormData = z.infer; diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index bd5ffaabb..7d77073f8 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -60,6 +60,7 @@ import { Route as MonitoringObserversViewMonitoringObserverIdTabImport } from '. import { Route as MonitoringObserversPushMessagesIdViewImport } from './routes/monitoring-observers/push-messages.$id_.view' import { Route as FormsFormIdEditTranslationLanguageCodeImport } from './routes/forms_.$formId.edit-translation.$languageCode' import { Route as CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdImport } from './routes/citizen-report-attachments/$electionRoundId.$citizenReportId.$attachmentId' +import { Route as NgosEditNgoIdImport } from './routes/ngos/edit.$ngoId.' import { Route as NgosAdminNgoIdAdminIdViewImport } from './routes/ngos/admin.$ngoId.$adminId.view' import { Route as NgosAdminNgoIdAdminIdEditImport } from './routes/ngos/admin.$ngoId.$adminId.edit' @@ -328,6 +329,11 @@ const CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdRoute = } as any, ) +const NgosEditNgoIdRoute = NgosEditNgoIdImport.update({ + path: '/ngos/edit/$ngoId/', + getParentRoute: () => rootRoute, +} as any) + const NgosAdminNgoIdAdminIdViewRoute = NgosAdminNgoIdAdminIdViewImport.update({ path: '/ngos/admin/$ngoId/$adminId/view', getParentRoute: () => rootRoute, @@ -506,6 +512,10 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ResponsesQuickReportsQuickReportIdImport parentRoute: typeof rootRoute } + '/ngos/edit/$ngoId/': { + preLoaderRoute: typeof NgosEditNgoIdImport + parentRoute: typeof rootRoute + } '/citizen-report-attachments/$electionRoundId/$citizenReportId/$attachmentId': { preLoaderRoute: typeof CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdImport parentRoute: typeof rootRoute @@ -593,6 +603,7 @@ export const routeTree = rootRoute.addChildren([ ResponsesFormSubmissionsSubmissionIdRoute, ResponsesIncidentReportsIncidentReportIdRoute, ResponsesQuickReportsQuickReportIdRoute, + NgosEditNgoIdRoute, CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdRoute, FormsFormIdEditTranslationLanguageCodeRoute, MonitoringObserversPushMessagesIdViewRoute, diff --git a/web/src/routes/ngos/edit.$ngoId..tsx b/web/src/routes/ngos/edit.$ngoId..tsx new file mode 100644 index 000000000..997a3efa7 --- /dev/null +++ b/web/src/routes/ngos/edit.$ngoId..tsx @@ -0,0 +1,24 @@ +import { EditNgo } from '@/features/ngos/components/EditNgo'; +import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queriess'; +import { redirectIfNotAuth } from '@/lib/utils'; +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/ngos/edit/$ngoId/')({ + beforeLoad: ({ params }) => { + redirectIfNotAuth(); + }, + component: EditNgoPage, + + loader: ({ context: { queryClient }, params: { ngoId } }) => queryClient.ensureQueryData(ngoDetailsOptions(ngoId)), +}); + +function EditNgoPage() { + const { ngoId } = Route.useParams(); + const { data: ngo } = useNGODetails(ngoId); + + return ( +
+ +
+ ); +} From 7396f9135814586ceb808668bad8f239c601595e Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Thu, 30 Jan 2025 14:16:58 +0200 Subject: [PATCH 30/43] WIP: create Base zod search schemas for search params --- web/src/common/zod-schemas.ts | 15 +++++++++++++++ web/src/routes/ngos/index.tsx | 11 ++++------- 2 files changed, 19 insertions(+), 7 deletions(-) create mode 100644 web/src/common/zod-schemas.ts diff --git a/web/src/common/zod-schemas.ts b/web/src/common/zod-schemas.ts new file mode 100644 index 000000000..4faa59e0e --- /dev/null +++ b/web/src/common/zod-schemas.ts @@ -0,0 +1,15 @@ +import { z } from 'zod'; +import { SortOrder } from './types'; + +export const PageParametersBaseSchema = z.object({ + pageNumber: z.number().catch(1), + pageSize: z.number().catch(10), +}); + +export const SortParamsBaseSchema = z.object({ + sortColumnName: z.string().catch(''), + searchText: z.coerce.string().optional(), + sortOrder: z.nativeEnum(SortOrder).catch(SortOrder.asc), +}); + +export const DefaultSearchParamsSchema = PageParametersBaseSchema.merge(SortParamsBaseSchema); diff --git a/web/src/routes/ngos/index.tsx b/web/src/routes/ngos/index.tsx index 8feabfcbb..c6d5c65d9 100644 --- a/web/src/routes/ngos/index.tsx +++ b/web/src/routes/ngos/index.tsx @@ -1,19 +1,16 @@ -import { SortOrder } from '@/common/types'; +import { DefaultSearchParamsSchema } from '@/common/zod-schemas'; import NGOsDashboard from '@/features/ngos/components/Dashboard/Dashboard'; import { NGOStatus } from '@/features/ngos/models/NGO'; import { redirectIfNotAuth } from '@/lib/utils'; import { createFileRoute, SearchSchemaInput } from '@tanstack/react-router'; import { z } from 'zod'; -export const ngoRouteSearchSchema = z.object({ - searchText: z.coerce.string().optional(), +const NgosAdditionalSearchParams = z.object({ status: z.nativeEnum(NGOStatus).optional(), - pageNumber: z.number().catch(1), - pageSize: z.number().catch(10), - sortColumnName: z.string().catch(''), - sortOrder: z.enum([SortOrder.asc, SortOrder.desc]).catch(SortOrder.asc), }); +export const ngoRouteSearchSchema = NgosAdditionalSearchParams.merge(DefaultSearchParamsSchema); + export const Route = createFileRoute('/ngos/')({ beforeLoad: () => { redirectIfNotAuth(); From 3e1a759796384e4858190bfd5db49bed060efdf1 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 31 Jan 2025 11:04:51 +0200 Subject: [PATCH 31/43] WIP: remove console.log --- web/src/features/ngos/hooks/ngos-queriess.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/web/src/features/ngos/hooks/ngos-queriess.ts b/web/src/features/ngos/hooks/ngos-queriess.ts index 8c9981882..38e71600f 100644 --- a/web/src/features/ngos/hooks/ngos-queriess.ts +++ b/web/src/features/ngos/hooks/ngos-queriess.ts @@ -22,7 +22,6 @@ export const ngosKeys = { }; export function useNGOs(p: DataTableParameters): UseQueryResult, Error> { - console.log(p); return useQuery({ queryKey: ngosKeys.list(p), queryFn: async () => { From 858f0f70dd7fce0d74fe57d97f3ae81df4760a69 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Fri, 31 Jan 2025 11:07:22 +0200 Subject: [PATCH 32/43] WIP: remove incomplete subtitle --- web/src/features/ngos/components/Dashboard/Dashboard.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/features/ngos/components/Dashboard/Dashboard.tsx b/web/src/features/ngos/components/Dashboard/Dashboard.tsx index 4a5cac470..1443a5abc 100644 --- a/web/src/features/ngos/components/Dashboard/Dashboard.tsx +++ b/web/src/features/ngos/components/Dashboard/Dashboard.tsx @@ -136,7 +136,7 @@ export default function NGOsDashboard(): ReactElement { ]; return ( - +
From c1b713e53548e391f0e998ce97eabbd1d7746c8d Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Tue, 18 Feb 2025 09:14:27 +0200 Subject: [PATCH 33/43] fix missing imports --- web/src/features/filtering/hooks/useFilteringContainer.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/features/filtering/hooks/useFilteringContainer.ts b/web/src/features/filtering/hooks/useFilteringContainer.ts index c74c7b4e6..bdb922550 100644 --- a/web/src/features/filtering/hooks/useFilteringContainer.ts +++ b/web/src/features/filtering/hooks/useFilteringContainer.ts @@ -1,6 +1,6 @@ import { useSetPrevSearch } from '@/common/prev-search-store'; import { useNavigate, useSearch } from '@tanstack/react-router'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { HIDDEN_FILTERS } from '../common'; import { FILTER_KEY } from '../filtering-enums'; From 7cb21914af7abb70ae9fd9ca57c02baa8dc1fecd Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Tue, 18 Feb 2025 13:14:23 +0200 Subject: [PATCH 34/43] WIP: remove ID column and rename ngo queries file --- web/src/features/ngos/components/CreateNGODialog.tsx | 2 +- web/src/features/ngos/components/Dashboard/Dashboard.tsx | 6 +----- web/src/features/ngos/components/EditNgo.tsx | 2 +- web/src/features/ngos/hooks/ngo-admin-queries.ts | 2 +- .../ngos/hooks/{ngos-queriess.ts => ngos-queries.ts} | 0 web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx | 2 +- web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx | 5 +---- web/src/routes/ngos/edit.$ngoId..tsx | 2 +- web/src/routes/ngos/view.$ngoId.$tab.tsx | 2 +- 9 files changed, 8 insertions(+), 15 deletions(-) rename web/src/features/ngos/hooks/{ngos-queriess.ts => ngos-queries.ts} (100%) diff --git a/web/src/features/ngos/components/CreateNGODialog.tsx b/web/src/features/ngos/components/CreateNGODialog.tsx index dc4339642..4f31ebf8f 100644 --- a/web/src/features/ngos/components/CreateNGODialog.tsx +++ b/web/src/features/ngos/components/CreateNGODialog.tsx @@ -7,7 +7,7 @@ import { InformationCircleIcon } from '@heroicons/react/24/outline'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; -import { useCreateNgo } from '../hooks/ngos-queriess'; +import { useCreateNgo } from '../hooks/ngos-queries'; import { newNgoSchema, NgoCreationFormData } from '../models/NGO'; export interface CreateNGODialogProps { diff --git a/web/src/features/ngos/components/Dashboard/Dashboard.tsx b/web/src/features/ngos/components/Dashboard/Dashboard.tsx index 1443a5abc..6f37f74ab 100644 --- a/web/src/features/ngos/components/Dashboard/Dashboard.tsx +++ b/web/src/features/ngos/components/Dashboard/Dashboard.tsx @@ -21,7 +21,7 @@ import { useNavigate } from '@tanstack/react-router'; import type { ColumnDef } from '@tanstack/react-table'; import { Plus } from 'lucide-react'; import { useCallback, type ReactElement } from 'react'; -import { useNgoMutations, useNGOs } from '../../hooks/ngos-queriess'; +import { useNgoMutations, useNGOs } from '../../hooks/ngos-queries'; import { NGO, NGOStatus } from '../../models/NGO'; import CreateNGODialog from '../CreateNGODialog'; import { NGOsListFilters } from '../filtering/NGOsListFilters'; @@ -43,10 +43,6 @@ export default function NGOsDashboard(): ReactElement { ); const ngoColDefs: ColumnDef[] = [ - { - header: 'ID', - accessorKey: 'id', - }, { accessorKey: 'name', enableSorting: true, diff --git a/web/src/features/ngos/components/EditNgo.tsx b/web/src/features/ngos/components/EditNgo.tsx index c9bd7e02f..c3ea3ea6a 100644 --- a/web/src/features/ngos/components/EditNgo.tsx +++ b/web/src/features/ngos/components/EditNgo.tsx @@ -10,7 +10,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useNavigate } from '@tanstack/react-router'; import { FC } from 'react'; import { useForm } from 'react-hook-form'; -import { useNgoMutations } from '../hooks/ngos-queriess'; +import { useNgoMutations } from '../hooks/ngos-queries'; import { EditNgoFormData, editNgoSchema, NGO, NGOStatus } from '../models/NGO'; import { NgoBackButton, NgoBreadcrumbs } from './NgoExtraComponents'; diff --git a/web/src/features/ngos/hooks/ngo-admin-queries.ts b/web/src/features/ngos/hooks/ngo-admin-queries.ts index 6817f8ef0..69b8e77b9 100644 --- a/web/src/features/ngos/hooks/ngo-admin-queries.ts +++ b/web/src/features/ngos/hooks/ngo-admin-queries.ts @@ -13,7 +13,7 @@ import { } from '@tanstack/react-query'; import { useNavigate, useRouter } from '@tanstack/react-router'; import { EditNgoAdminFormData, NgoAdmin, NgoAdminFormData, NgoAdminGetRequestParams } from '../models/NgoAdmin'; -import { ngosKeys } from './ngos-queriess'; +import { ngosKeys } from './ngos-queries'; const getEndpointWithNgoId = (ngoId: string): string => { if (!ngoId) throw new Error('No NGO ID provided'); diff --git a/web/src/features/ngos/hooks/ngos-queriess.ts b/web/src/features/ngos/hooks/ngos-queries.ts similarity index 100% rename from web/src/features/ngos/hooks/ngos-queriess.ts rename to web/src/features/ngos/hooks/ngos-queries.ts diff --git a/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx b/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx index 804699f60..56fcc3f6d 100644 --- a/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx +++ b/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx @@ -1,6 +1,6 @@ import { EditNgoAdmin } from '@/features/ngos/components/admins/EditNgoAdmin'; import { ngoAdminDetailsOptions, useNgoAdminDetails } from '@/features/ngos/hooks/ngo-admin-queries'; -import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queriess'; +import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queries'; import { redirectIfNotAuth } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; diff --git a/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx b/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx index b6075ce4c..e2f2bb8fc 100644 --- a/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx +++ b/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx @@ -1,9 +1,6 @@ import { NgoAdminDetailsView } from '@/features/ngos/components/admins/NgoAdminDetailsView'; import { ngoAdminDetailsOptions, useNgoAdminDetails } from '@/features/ngos/hooks/ngo-admin-queries'; -import { - ngoDetailsOptions, - useNGODetails, -} from '@/features/ngos/hooks/ngos-queriess'; +import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queries'; import { redirectIfNotAuth } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; diff --git a/web/src/routes/ngos/edit.$ngoId..tsx b/web/src/routes/ngos/edit.$ngoId..tsx index 997a3efa7..a9b6eb714 100644 --- a/web/src/routes/ngos/edit.$ngoId..tsx +++ b/web/src/routes/ngos/edit.$ngoId..tsx @@ -1,5 +1,5 @@ import { EditNgo } from '@/features/ngos/components/EditNgo'; -import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queriess'; +import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queries'; import { redirectIfNotAuth } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; diff --git a/web/src/routes/ngos/view.$ngoId.$tab.tsx b/web/src/routes/ngos/view.$ngoId.$tab.tsx index a146730cc..25bbf972d 100644 --- a/web/src/routes/ngos/view.$ngoId.$tab.tsx +++ b/web/src/routes/ngos/view.$ngoId.$tab.tsx @@ -1,5 +1,5 @@ import { NGODetails } from '@/features/ngos/components/NGODetails'; -import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queriess'; +import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queries'; import { redirectIfNotAuth } from '@/lib/utils'; import { createFileRoute, redirect } from '@tanstack/react-router'; import { z } from 'zod'; From 8dfe47da45b4e6c6fc63d52f520c574d70b47a00 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Tue, 18 Feb 2025 13:16:58 +0200 Subject: [PATCH 35/43] WIP: remove zod schema extension --- web/src/features/ngos/models/NGO.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/web/src/features/ngos/models/NGO.tsx b/web/src/features/ngos/models/NGO.tsx index df1218662..514ff6334 100644 --- a/web/src/features/ngos/models/NGO.tsx +++ b/web/src/features/ngos/models/NGO.tsx @@ -1,7 +1,6 @@ /* eslint-disable unicorn/prefer-top-level-await */ import { z } from 'zod'; -import { ngoAdminSchema } from './NgoAdmin'; export interface NGO { id: string; @@ -18,7 +17,15 @@ export enum NGOStatus { Deactivated = 'Deactivated', } -export const newNgoSchema = ngoAdminSchema.extend({ name: z.string() }); +export const newNgoSchema = z.object({ + firstName: z.string(), + lastName: z.string(), + email: z.string().email(), + phoneNumber: z.string(), + password: z.string().min(1, { message: 'This field is required' }), + name: z.string(), +}); + export type NgoCreationFormData = z.infer; export const editNgoSchema = z.object({ name: z.string().min(2, { From 688592df119bf114962ae66f385a3db3d8d415c9 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Tue, 18 Feb 2025 13:21:31 +0200 Subject: [PATCH 36/43] WIP: change auth check for NGO routes to PlatformAdmin --- web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx | 4 ++-- web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx | 4 ++-- web/src/routes/ngos/edit.$ngoId..tsx | 4 ++-- web/src/routes/ngos/index.tsx | 4 ++-- web/src/routes/ngos/view.$ngoId.$tab.tsx | 4 ++-- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx b/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx index 56fcc3f6d..bd6ef5163 100644 --- a/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx +++ b/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx @@ -1,12 +1,12 @@ import { EditNgoAdmin } from '@/features/ngos/components/admins/EditNgoAdmin'; import { ngoAdminDetailsOptions, useNgoAdminDetails } from '@/features/ngos/hooks/ngo-admin-queries'; import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queries'; -import { redirectIfNotAuth } from '@/lib/utils'; +import { redirectIfNotPlatformAdmin } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; export const Route = createFileRoute('/ngos/admin/$ngoId/$adminId/edit')({ beforeLoad: ({ params }) => { - redirectIfNotAuth(); + redirectIfNotPlatformAdmin(); }, component: NgoAdminDetails, diff --git a/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx b/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx index e2f2bb8fc..5e6b3d7e5 100644 --- a/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx +++ b/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx @@ -1,12 +1,12 @@ import { NgoAdminDetailsView } from '@/features/ngos/components/admins/NgoAdminDetailsView'; import { ngoAdminDetailsOptions, useNgoAdminDetails } from '@/features/ngos/hooks/ngo-admin-queries'; import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queries'; -import { redirectIfNotAuth } from '@/lib/utils'; +import { redirectIfNotPlatformAdmin } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; export const Route = createFileRoute('/ngos/admin/$ngoId/$adminId/view')({ beforeLoad: ({ params }) => { - redirectIfNotAuth(); + redirectIfNotPlatformAdmin(); }, component: NgoAdminDetails, diff --git a/web/src/routes/ngos/edit.$ngoId..tsx b/web/src/routes/ngos/edit.$ngoId..tsx index a9b6eb714..cabfdc5b5 100644 --- a/web/src/routes/ngos/edit.$ngoId..tsx +++ b/web/src/routes/ngos/edit.$ngoId..tsx @@ -1,11 +1,11 @@ import { EditNgo } from '@/features/ngos/components/EditNgo'; import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queries'; -import { redirectIfNotAuth } from '@/lib/utils'; +import { redirectIfNotAuth, redirectIfNotPlatformAdmin } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; export const Route = createFileRoute('/ngos/edit/$ngoId/')({ beforeLoad: ({ params }) => { - redirectIfNotAuth(); + redirectIfNotPlatformAdmin(); }, component: EditNgoPage, diff --git a/web/src/routes/ngos/index.tsx b/web/src/routes/ngos/index.tsx index c6d5c65d9..34a312b5d 100644 --- a/web/src/routes/ngos/index.tsx +++ b/web/src/routes/ngos/index.tsx @@ -1,7 +1,7 @@ import { DefaultSearchParamsSchema } from '@/common/zod-schemas'; import NGOsDashboard from '@/features/ngos/components/Dashboard/Dashboard'; import { NGOStatus } from '@/features/ngos/models/NGO'; -import { redirectIfNotAuth } from '@/lib/utils'; +import { redirectIfNotAuth, redirectIfNotPlatformAdmin } from '@/lib/utils'; import { createFileRoute, SearchSchemaInput } from '@tanstack/react-router'; import { z } from 'zod'; @@ -13,7 +13,7 @@ export const ngoRouteSearchSchema = NgosAdditionalSearchParams.merge(DefaultSear export const Route = createFileRoute('/ngos/')({ beforeLoad: () => { - redirectIfNotAuth(); + redirectIfNotPlatformAdmin(); }, component: Ngos, validateSearch: (search: unknown & SearchSchemaInput) => ngoRouteSearchSchema.parse(search), diff --git a/web/src/routes/ngos/view.$ngoId.$tab.tsx b/web/src/routes/ngos/view.$ngoId.$tab.tsx index 25bbf972d..ace5c8a83 100644 --- a/web/src/routes/ngos/view.$ngoId.$tab.tsx +++ b/web/src/routes/ngos/view.$ngoId.$tab.tsx @@ -1,6 +1,6 @@ import { NGODetails } from '@/features/ngos/components/NGODetails'; import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queries'; -import { redirectIfNotAuth } from '@/lib/utils'; +import { redirectIfNotAuth, redirectIfNotPlatformAdmin } from '@/lib/utils'; import { createFileRoute, redirect } from '@tanstack/react-router'; import { z } from 'zod'; import { ngoRouteSearchSchema } from '.'; @@ -16,7 +16,7 @@ export const NgosDetailsdPageSearchParamsSchema = ngoAdminsSearchParamsSchema.me export const Route = createFileRoute('/ngos/view/$ngoId/$tab')({ beforeLoad: ({ params }) => { - redirectIfNotAuth(); + redirectIfNotPlatformAdmin(); const coercedTab = coerceTabSlug(params.tab); if (params.tab !== coercedTab) { From ad9b8c2ada4a9f49e213e86b74d049909834d667 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Tue, 18 Feb 2025 13:23:08 +0200 Subject: [PATCH 37/43] WIP: remove div wrapper --- web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx | 6 +----- web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx | 6 +----- web/src/routes/ngos/edit.$ngoId..tsx | 8 ++------ web/src/routes/ngos/view.$ngoId.$tab.tsx | 8 ++------ 4 files changed, 6 insertions(+), 22 deletions(-) diff --git a/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx b/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx index bd6ef5163..233feff1b 100644 --- a/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx +++ b/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx @@ -25,9 +25,5 @@ function NgoAdminDetails() { const { data: ngoAdmin } = useNgoAdminDetails({ ngoId, adminId }); const { data: ngo } = useNGODetails(ngoId); - return ( -
- -
- ); + return ; } diff --git a/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx b/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx index 5e6b3d7e5..330339c56 100644 --- a/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx +++ b/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx @@ -25,9 +25,5 @@ function NgoAdminDetails() { const { data: ngoAdmin } = useNgoAdminDetails({ ngoId, adminId }); const { data: ngo } = useNGODetails(ngoId); - return ( -
- -
- ); + return ; } diff --git a/web/src/routes/ngos/edit.$ngoId..tsx b/web/src/routes/ngos/edit.$ngoId..tsx index cabfdc5b5..e652ee833 100644 --- a/web/src/routes/ngos/edit.$ngoId..tsx +++ b/web/src/routes/ngos/edit.$ngoId..tsx @@ -1,6 +1,6 @@ import { EditNgo } from '@/features/ngos/components/EditNgo'; import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queries'; -import { redirectIfNotAuth, redirectIfNotPlatformAdmin } from '@/lib/utils'; +import { redirectIfNotPlatformAdmin } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; export const Route = createFileRoute('/ngos/edit/$ngoId/')({ @@ -16,9 +16,5 @@ function EditNgoPage() { const { ngoId } = Route.useParams(); const { data: ngo } = useNGODetails(ngoId); - return ( -
- -
- ); + return ; } diff --git a/web/src/routes/ngos/view.$ngoId.$tab.tsx b/web/src/routes/ngos/view.$ngoId.$tab.tsx index ace5c8a83..5e0502390 100644 --- a/web/src/routes/ngos/view.$ngoId.$tab.tsx +++ b/web/src/routes/ngos/view.$ngoId.$tab.tsx @@ -1,6 +1,6 @@ import { NGODetails } from '@/features/ngos/components/NGODetails'; import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queries'; -import { redirectIfNotAuth, redirectIfNotPlatformAdmin } from '@/lib/utils'; +import { redirectIfNotPlatformAdmin } from '@/lib/utils'; import { createFileRoute, redirect } from '@tanstack/react-router'; import { z } from 'zod'; import { ngoRouteSearchSchema } from '.'; @@ -43,9 +43,5 @@ function NgoDetails() { const { ngoId } = Route.useParams(); const { data: ngo } = useNGODetails(ngoId); - return ( -
- -
- ); + return ; } From cd1cba92b2ed93c447302a650667cb3fae43e827 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Tue, 18 Feb 2025 13:38:03 +0200 Subject: [PATCH 38/43] WIP: remove URL generator function --- .../features/ngos/hooks/ngo-admin-queries.ts | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/web/src/features/ngos/hooks/ngo-admin-queries.ts b/web/src/features/ngos/hooks/ngo-admin-queries.ts index 69b8e77b9..f53af6dfb 100644 --- a/web/src/features/ngos/hooks/ngo-admin-queries.ts +++ b/web/src/features/ngos/hooks/ngo-admin-queries.ts @@ -15,12 +15,6 @@ import { useNavigate, useRouter } from '@tanstack/react-router'; import { EditNgoAdminFormData, NgoAdmin, NgoAdminFormData, NgoAdminGetRequestParams } from '../models/NgoAdmin'; import { ngosKeys } from './ngos-queries'; -const getEndpointWithNgoId = (ngoId: string): string => { - if (!ngoId) throw new Error('No NGO ID provided'); - - return `ngos/${ngoId}/admins`; -}; - export const ngoAdminDetailsOptions = ({ ngoId, adminId }: NgoAdminGetRequestParams) => queryOptions({ queryKey: ngosKeys.detail(ngoId), @@ -63,7 +57,7 @@ export const useCreateNgoAdmin = () => { const queryClient = useQueryClient(); const createNgoAdminMutation = useMutation({ mutationFn: ({ ngoId, values }: { ngoId: string; values: NgoAdminFormData; onMutationSuccess: () => void }) => { - return authApi.post(getEndpointWithNgoId(ngoId), values); + return authApi.post(`/ngos/${ngoId}/admins`, values); }, onSuccess: (_, { onMutationSuccess }) => { @@ -79,7 +73,6 @@ export const useCreateNgoAdmin = () => { }; export const useNgoAdminMutations = (ngoId: string) => { - const DEFAULT_ENDPOINT = getEndpointWithNgoId(ngoId); const queryClient = useQueryClient(); const navigate = useNavigate(); const router = useRouter(); @@ -89,7 +82,7 @@ export const useNgoAdminMutations = (ngoId: string) => { const editNgoAdminMutation = useMutation({ mutationFn: ({ adminId, values }: { adminId: string; values: EditNgoAdminFormData }) => { - return authApi.put(`${DEFAULT_ENDPOINT}/${adminId}`, values); + return authApi.put(`ngos/${ngoId}/admins/${adminId}`, values); }, onSuccess: () => { @@ -108,7 +101,7 @@ export const useNgoAdminMutations = (ngoId: string) => { const deleteNgoAdminMutation = useMutation({ mutationFn: ({ adminId }: { adminId: string; onMutationSuccess?: () => void }) => { - return authApi.delete(`${DEFAULT_ENDPOINT}/${adminId}`, {}); + return authApi.delete(`ngos/${ngoId}/admins/${adminId}`, {}); }, onSuccess: (_, { onMutationSuccess }) => { @@ -134,7 +127,7 @@ export const useNgoAdminMutations = (ngoId: string) => { const deactivateNgoAdminMutation = useMutation({ mutationFn: (adminId: string) => { - return authApi.post(`${DEFAULT_ENDPOINT}/${adminId}:deactivate`, {}); + return authApi.post(`ngos/${ngoId}/admins/${adminId}:deactivate`, {}); }, onSuccess: () => { @@ -158,7 +151,7 @@ export const useNgoAdminMutations = (ngoId: string) => { const activateNgoAdminMutation = useMutation({ mutationFn: (adminId: string) => { - return authApi.post(`${DEFAULT_ENDPOINT}/${adminId}:activate`, {}); + return authApi.post(`ngos/${ngoId}/admins/${adminId}:activate`, {}); }, onSuccess: () => { From 260ee1fb6a418f49cc8e32823072a398b68c296e Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Tue, 18 Feb 2025 13:51:42 +0200 Subject: [PATCH 39/43] WIP: remove NGO admin from NGO creation --- .../ngos/components/CreateNGODialog.tsx | 75 +------------------ web/src/features/ngos/hooks/ngos-queries.ts | 15 +--- web/src/features/ngos/models/NGO.tsx | 5 -- 3 files changed, 4 insertions(+), 91 deletions(-) diff --git a/web/src/features/ngos/components/CreateNGODialog.tsx b/web/src/features/ngos/components/CreateNGODialog.tsx index 4f31ebf8f..e2b393f77 100644 --- a/web/src/features/ngos/components/CreateNGODialog.tsx +++ b/web/src/features/ngos/components/CreateNGODialog.tsx @@ -3,7 +3,6 @@ import { Dialog, DialogClose, DialogContent, DialogFooter, DialogTitle } from '@ import { Form, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; import { toast } from '@/components/ui/use-toast'; -import { InformationCircleIcon } from '@heroicons/react/24/outline'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; @@ -40,7 +39,7 @@ function CreateNGODialog({ open, onOpenChange }: CreateNGODialogProps) { return ( { e.preventDefault(); }} @@ -63,77 +62,7 @@ function CreateNGODialog({ open, onOpenChange }: CreateNGODialogProps) { )} /> -
-
- -
-
- Please add a contact person for this organization. This person will automatically become the - organization's first admin. -
-
- - ( - - {t('firstName')} - - - - )} - /> - - ( - - {t('lastName')} - - - - )} - /> - - ( - - {t('email')} - - - - )} - /> - - ( - - {t('phone')} - - - - )} - /> - - ( - - Password - - - - )} - /> - - + - diff --git a/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx b/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx index e8d871d86..7771fe9cd 100644 --- a/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx +++ b/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx @@ -5,7 +5,6 @@ import { Input } from '@/components/ui/input'; import { toast } from '@/components/ui/use-toast'; import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; -import { useTranslation } from 'react-i18next'; import { useCreateNgoAdmin } from '../../hooks/ngo-admin-queries'; import { NgoAdminFormData, ngoAdminSchema } from '../../models/NgoAdmin'; @@ -16,7 +15,6 @@ export interface AddNgoAdminDialogProps { } function AddNgoAdminDialog({ open, onOpenChange, ngoId }: AddNgoAdminDialogProps) { - const { t } = useTranslation('translation', { keyPrefix: 'observers.addObserver' }); const { createNgoAdminMutation } = useCreateNgoAdmin(); const form = useForm({ @@ -57,8 +55,8 @@ function AddNgoAdminDialog({ open, onOpenChange, ngoId }: AddNgoAdminDialogProps name='firstName' render={({ field, fieldState }) => ( - {t('firstName')} - + First name + )} @@ -69,8 +67,8 @@ function AddNgoAdminDialog({ open, onOpenChange, ngoId }: AddNgoAdminDialogProps name='lastName' render={({ field, fieldState }) => ( - {t('lastName')} - + Last name + )} @@ -81,8 +79,8 @@ function AddNgoAdminDialog({ open, onOpenChange, ngoId }: AddNgoAdminDialogProps name='email' render={({ field, fieldState }) => ( - {t('email')} - + Email + )} @@ -93,8 +91,8 @@ function AddNgoAdminDialog({ open, onOpenChange, ngoId }: AddNgoAdminDialogProps name='phoneNumber' render={({ field, fieldState }) => ( - {t('phone')} - + Phone number + )} From 833f3bd5b29a5a8379d28d051ce0cb3ad4501220 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Tue, 18 Feb 2025 14:05:23 +0200 Subject: [PATCH 41/43] WIP: remove unused var --- web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx b/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx index 233feff1b..991122044 100644 --- a/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx +++ b/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx @@ -1,6 +1,6 @@ import { EditNgoAdmin } from '@/features/ngos/components/admins/EditNgoAdmin'; import { ngoAdminDetailsOptions, useNgoAdminDetails } from '@/features/ngos/hooks/ngo-admin-queries'; -import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queries'; +import { ngoDetailsOptions } from '@/features/ngos/hooks/ngos-queries'; import { redirectIfNotPlatformAdmin } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; @@ -23,7 +23,6 @@ export const Route = createFileRoute('/ngos/admin/$ngoId/$adminId/edit')({ function NgoAdminDetails() { const { ngoId, adminId } = Route.useParams(); const { data: ngoAdmin } = useNgoAdminDetails({ ngoId, adminId }); - const { data: ngo } = useNGODetails(ngoId); return ; } From 75d08cc753f76ea715a4adf948589c66f61cb706 Mon Sep 17 00:00:00 2001 From: imdeaconu Date: Tue, 18 Feb 2025 14:22:40 +0200 Subject: [PATCH 42/43] fix build --- .../ngos/components/Dashboard/Dashboard.tsx | 2 -- .../features/ngos/components/NGODetails.tsx | 2 ++ .../ngos/components/admins/NGOAdmins.tsx | 2 -- .../components/filtering/NGOStatusSelect.tsx | 34 ------------------- .../components/filtering/NGOsListFilters.tsx | 11 ------ web/src/hooks/debounced-search.ts | 1 + 6 files changed, 3 insertions(+), 49 deletions(-) delete mode 100644 web/src/features/ngos/components/filtering/NGOStatusSelect.tsx delete mode 100644 web/src/features/ngos/components/filtering/NGOsListFilters.tsx diff --git a/web/src/features/ngos/components/Dashboard/Dashboard.tsx b/web/src/features/ngos/components/Dashboard/Dashboard.tsx index 6f37f74ab..a7ecab295 100644 --- a/web/src/features/ngos/components/Dashboard/Dashboard.tsx +++ b/web/src/features/ngos/components/Dashboard/Dashboard.tsx @@ -24,7 +24,6 @@ import { useCallback, type ReactElement } from 'react'; import { useNgoMutations, useNGOs } from '../../hooks/ngos-queries'; import { NGO, NGOStatus } from '../../models/NGO'; import CreateNGODialog from '../CreateNGODialog'; -import { NGOsListFilters } from '../filtering/NGOsListFilters'; import { NgoStatusBadge } from '../NgoStatusBadges'; export default function NGOsDashboard(): ReactElement { @@ -156,7 +155,6 @@ export default function NGOsDashboard(): ReactElement { />
- {isFilteringContainerVisible && }
= ({ data }) => { function handleTabChange(tab: string): void { navigate({ + to: '.', + replace: true, params(prev: any) { return { ...prev, tab }; }, diff --git a/web/src/features/ngos/components/admins/NGOAdmins.tsx b/web/src/features/ngos/components/admins/NGOAdmins.tsx index ca05c4832..557acadcc 100644 --- a/web/src/features/ngos/components/admins/NGOAdmins.tsx +++ b/web/src/features/ngos/components/admins/NGOAdmins.tsx @@ -22,7 +22,6 @@ import { Plus } from 'lucide-react'; import { FC } from 'react'; import { useNgoAdminMutations, useNgoAdmins } from '../../hooks/ngo-admin-queries'; import { NgoAdmin, NgoAdminStatus } from '../../models/NgoAdmin'; -import { NGOsListFilters } from '../filtering/NGOsListFilters'; import { NgoAdminStatusBadge } from '../NgoStatusBadges'; import AddNgoAdminDialog from './AddNgoAdminDialog'; @@ -141,7 +140,6 @@ export const NGOAdminsView: FC = ({ ngoId }) => { />
- {isFilteringContainerVisible && }
{ - const { queryParams, navigateHandler } = useFilteringContainer(); - - const onStatusChange = (value: string) => { - navigateHandler({ [FILTER_KEY.Status]: value }); - }; - - return ( - - ); -}; diff --git a/web/src/features/ngos/components/filtering/NGOsListFilters.tsx b/web/src/features/ngos/components/filtering/NGOsListFilters.tsx deleted file mode 100644 index b9cad8ef6..000000000 --- a/web/src/features/ngos/components/filtering/NGOsListFilters.tsx +++ /dev/null @@ -1,11 +0,0 @@ -import { FilteringContainer } from '@/features/filtering/components/FilteringContainer'; -import { FC } from 'react'; -import { NGOStatusSelect } from './NGOStatusSelect'; - -export const NGOsListFilters: FC = () => { - return ( - - - - ); -}; diff --git a/web/src/hooks/debounced-search.ts b/web/src/hooks/debounced-search.ts index fd73e2fc5..738857d17 100644 --- a/web/src/hooks/debounced-search.ts +++ b/web/src/hooks/debounced-search.ts @@ -11,6 +11,7 @@ export const useDebouncedSearch = (routeId: string, schema: ZodSchema) => const { navigateHandler } = useFilteringContainer(); const routerApi = getRouteApi(routeId as any); + //@ts-ignore const search = routerApi.useSearch(); const [searchText, setSearchText] = useState(search.searchText); const handleSearchInput = (ev: React.FormEvent) => { From f6dc67ac3182640eb1fde5cd5a3b99c56e4a9ead Mon Sep 17 00:00:00 2001 From: Ion Dormenco Date: Thu, 20 Feb 2025 13:27:55 +0200 Subject: [PATCH 43/43] bugfixes, improvements and cleanup --- .../Create/Endpoint.cs | 6 +- .../Create/Endpoint.cs | 10 +- .../Update/Endpoint.cs | 1 - .../Update/Request.cs | 2 - .../Endpoints/CreateEndpointTests.cs | 6 +- web/src/common/types.ts | 9 ++ web/src/components/layout/Header/Header.tsx | 19 +-- .../CitizenNotificationMessageForm.tsx | 1 + web/src/features/auth/AcceptInvite.tsx | 1 + web/src/features/auth/ForgotPassword.tsx | 1 + web/src/features/auth/Login.tsx | 1 + web/src/features/auth/ResetPassword.tsx | 1 + .../components/Guides/AddGuideForm.tsx | 1 + .../components/Guides/EditGuideForm.tsx | 1 + .../ElectionRoundForm/ElectionRoundForm.tsx | 1 + .../EditMonitoringObserver.tsx | 1 + .../CreateMonitoringObserverDialog.tsx | 1 + .../PushMessageForm/PushMessageForm.tsx | 1 + .../ngos/components/CreateNGODialog.tsx | 37 ++++- .../ngos/components/Dashboard/Dashboard.tsx | 23 ++- web/src/features/ngos/components/EditNgo.tsx | 80 +++++----- .../features/ngos/components/NGODetails.tsx | 20 ++- .../ngos/components/NgoExtraComponents.tsx | 71 --------- .../ngos/components/NgoStatusBadges.tsx | 55 +++---- .../components/admins/AddNgoAdminDialog.tsx | 75 +++++++++- .../ngos/components/admins/EditNgoAdmin.tsx | 137 ++++++++++-------- .../ngos/components/admins/NGOAdmins.tsx | 28 ++-- .../components/admins/NgoAdminDetailsView.tsx | 27 ++-- .../features/ngos/hooks/ngo-admin-queries.ts | 47 ++++-- web/src/features/ngos/hooks/ngos-queries.ts | 44 ++++-- web/src/features/ngos/models/NGO.tsx | 14 +- web/src/features/ngos/models/NgoAdmin.tsx | 39 +++-- .../components/EditObserver/EditObserver.tsx | 20 ++- web/src/lib/utils.ts | 26 ++++ web/src/locales/en.json | 20 ++- web/src/routeTree.gen.ts | 8 +- .../$ngoId.$adminId.edit.tsx} | 5 +- .../$ngoId.$adminId.view.tsx} | 8 +- web/src/routes/ngos/edit.$ngoId..tsx | 3 +- web/src/styles/tailwind.css | 9 +- 40 files changed, 517 insertions(+), 343 deletions(-) delete mode 100644 web/src/features/ngos/components/NgoExtraComponents.tsx rename web/src/routes/ngos/{admin.$ngoId.$adminId.edit.tsx => admin/$ngoId.$adminId.edit.tsx} (87%) rename web/src/routes/ngos/{admin.$ngoId.$adminId.view.tsx => admin/$ngoId.$adminId.view.tsx} (78%) diff --git a/api/src/Vote.Monitor.Api.Feature.Ngo/Create/Endpoint.cs b/api/src/Vote.Monitor.Api.Feature.Ngo/Create/Endpoint.cs index 4f8de2845..02e86ba44 100644 --- a/api/src/Vote.Monitor.Api.Feature.Ngo/Create/Endpoint.cs +++ b/api/src/Vote.Monitor.Api.Feature.Ngo/Create/Endpoint.cs @@ -4,7 +4,7 @@ namespace Vote.Monitor.Api.Feature.Ngo.Create; public class Endpoint(IRepository repository) : - Endpoint, Conflict>> + Endpoint, ProblemDetails>> { public override void Configure() { @@ -14,7 +14,7 @@ public override void Configure() Policies(PolicyNames.PlatformAdminsOnly); } - public override async Task, Conflict>> ExecuteAsync(Request req, + public override async Task, ProblemDetails>> ExecuteAsync(Request req, CancellationToken ct) { var specification = new GetNgoByNameSpecification(req.Name); @@ -23,7 +23,7 @@ public override async Task, Conflict>> Exec if (hasNgoWithSameName) { AddError(r => r.Name, "A Ngo with same name already exists"); - return TypedResults.Conflict(new ProblemDetails(ValidationFailures)); + return new ProblemDetails(ValidationFailures); } var ngo = new NgoAggregate(req.Name); diff --git a/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Create/Endpoint.cs b/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Create/Endpoint.cs index cb254adb4..7416997f9 100644 --- a/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Create/Endpoint.cs +++ b/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Create/Endpoint.cs @@ -7,7 +7,7 @@ namespace Vote.Monitor.Api.Feature.NgoAdmin.Create; public class Endpoint( UserManager userManager, IRepository repository) - : Endpoint, Conflict>> + : Endpoint, ProblemDetails>> { public override void Configure() { @@ -18,14 +18,14 @@ public override void Configure() Policies(PolicyNames.PlatformAdminsOnly); } - public override async Task, Conflict>> ExecuteAsync(Request req, + public override async Task, ProblemDetails>> ExecuteAsync(Request req, CancellationToken ct) { var user = await userManager.FindByEmailAsync(req.Email); if (user is not null) { - AddError(r => r.Email, "A ngo admin with same login already exists"); - return TypedResults.Conflict(new ProblemDetails(ValidationFailures)); + AddError(r => r.Email, "A user with same login already exists"); + return new ProblemDetails(ValidationFailures); } var applicationUser = @@ -35,7 +35,7 @@ public override async Task, Conflict>> if (!result.Succeeded) { AddError(r => r.Email, result.GetAllErrors()); - return TypedResults.Conflict(new ProblemDetails(ValidationFailures)); + return new ProblemDetails(ValidationFailures); } var ngoAdmin = new NgoAdminAggregate(req.NgoId, applicationUser); diff --git a/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Update/Endpoint.cs b/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Update/Endpoint.cs index bab8bb65d..65b27d518 100644 --- a/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Update/Endpoint.cs +++ b/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Update/Endpoint.cs @@ -29,7 +29,6 @@ public override async Task> Exec } ngoAdmin.ApplicationUser.UpdateDetails(req.FirstName, req.LastName, req.PhoneNumber); - ngoAdmin.ApplicationUser.UpdateStatus(req.Status); var result = await userManager.UpdateAsync(ngoAdmin.ApplicationUser); if (!result.Succeeded) diff --git a/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Update/Request.cs b/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Update/Request.cs index 43adb0a9b..4d6e67682 100644 --- a/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Update/Request.cs +++ b/api/src/Vote.Monitor.Api.Feature.NgoAdmin/Update/Request.cs @@ -7,6 +7,4 @@ public class Request public string FirstName { get; set; } public string LastName { get; set; } public string? PhoneNumber { get; set; } - - public UserStatus Status { get; set; } } diff --git a/api/tests/Vote.Monitor.Api.Feature.Ngo.UnitTests/Endpoints/CreateEndpointTests.cs b/api/tests/Vote.Monitor.Api.Feature.Ngo.UnitTests/Endpoints/CreateEndpointTests.cs index fc48d8059..373ebfcb6 100644 --- a/api/tests/Vote.Monitor.Api.Feature.Ngo.UnitTests/Endpoints/CreateEndpointTests.cs +++ b/api/tests/Vote.Monitor.Api.Feature.Ngo.UnitTests/Endpoints/CreateEndpointTests.cs @@ -29,7 +29,7 @@ await repository .AddAsync(Arg.Is(x => x.Name == ngoName)); result - .Should().BeOfType, Conflict>>()! + .Should().BeOfType, ProblemDetails>>()! .Which! .Result.Should().BeOfType>()! .Which!.Value!.Name.Should().Be(ngoName); @@ -53,8 +53,8 @@ public async Task ShouldReturnConflict_WhenNgoWithSameNameExists() // Assert result - .Should().BeOfType, Conflict>>() + .Should().BeOfType, ProblemDetails>>() .Which - .Result.Should().BeOfType>(); + .Result.Should().BeOfType(); } } diff --git a/web/src/common/types.ts b/web/src/common/types.ts index 2378a3989..2c1f0490b 100644 --- a/web/src/common/types.ts +++ b/web/src/common/types.ts @@ -410,3 +410,12 @@ export interface FormBase { numberOfQuestions: number; languagesTranslationStatus: LanguagesTranslationStatus; } + +export interface ProblemDetails { + type: string; + title: string; + status: number; + detail: string; + instance?: string; + errors?: { name: string; reason: string }[]; // Maps field names to error messages +} diff --git a/web/src/components/layout/Header/Header.tsx b/web/src/components/layout/Header/Header.tsx index ec1673598..318a9e6c0 100644 --- a/web/src/components/layout/Header/Header.tsx +++ b/web/src/components/layout/Header/Header.tsx @@ -47,13 +47,10 @@ const Header = (): FunctionComponent => { const navigate = useNavigate(); const [selectedElectionRound, setSelectedElection] = useState(); const router = useRouter(); - const { - setCurrentElectionRoundId, - currentElectionRoundId, - } = useCurrentElectionRoundStore((s) => s); + const { setCurrentElectionRoundId, currentElectionRoundId } = useCurrentElectionRoundStore((s) => s); const handleSelectElectionRound = async (electionRound?: ElectionEvent): Promise => { - if (electionRound && selectedElectionRound?.id != electionRound.id ) { + if (electionRound && selectedElectionRound?.id != electionRound.id) { setSelectedElection(electionRound); setCurrentElectionRoundId(electionRound.id); @@ -149,7 +146,9 @@ const Header = (): FunctionComponent => {
- {userRole !== 'NgoAdmin'? <> : status === 'pending' ? ( + {userRole !== 'NgoAdmin' ? ( + <> + ) : status === 'pending' ? ( ) : ( @@ -173,9 +172,7 @@ const Header = (): FunctionComponent => { Upcomming elections {activeElections?.map((electionRound) => ( - +
{electionRound?.status === ElectionRoundStatus.NotStarted ? ( @@ -192,9 +189,7 @@ const Header = (): FunctionComponent => { Archived elections {archivedElections?.map((electionRound) => ( - +
diff --git a/web/src/features/CitizenNotifications/CitizenNotificationMessageForm/CitizenNotificationMessageForm.tsx b/web/src/features/CitizenNotifications/CitizenNotificationMessageForm/CitizenNotificationMessageForm.tsx index 4334fdc94..864a6b1de 100644 --- a/web/src/features/CitizenNotifications/CitizenNotificationMessageForm/CitizenNotificationMessageForm.tsx +++ b/web/src/features/CitizenNotifications/CitizenNotificationMessageForm/CitizenNotificationMessageForm.tsx @@ -36,6 +36,7 @@ function CitizenNotificationMessageForm(): FunctionComponent { const form = useForm>({ resolver: zodResolver(createPushMessageSchema), + mode: 'all', defaultValues: { title: '', messageBody: '', diff --git a/web/src/features/auth/AcceptInvite.tsx b/web/src/features/auth/AcceptInvite.tsx index e22c9a801..ae69956a0 100644 --- a/web/src/features/auth/AcceptInvite.tsx +++ b/web/src/features/auth/AcceptInvite.tsx @@ -39,6 +39,7 @@ function AcceptInvite() { const form = useForm>({ resolver: zodResolver(formSchema), + mode: 'all', defaultValues: { password: '', confirmPassword: '', diff --git a/web/src/features/auth/ForgotPassword.tsx b/web/src/features/auth/ForgotPassword.tsx index bce38f61b..ca723d84f 100644 --- a/web/src/features/auth/ForgotPassword.tsx +++ b/web/src/features/auth/ForgotPassword.tsx @@ -27,6 +27,7 @@ interface ForgotPasswordRequest { function ForgotPassword() { const form = useForm>({ resolver: zodResolver(formSchema), + mode: 'all', defaultValues: { email: '', }, diff --git a/web/src/features/auth/Login.tsx b/web/src/features/auth/Login.tsx index 90aec543b..b0e79b9ed 100644 --- a/web/src/features/auth/Login.tsx +++ b/web/src/features/auth/Login.tsx @@ -27,6 +27,7 @@ function Login() { const navigate = useNavigate(); const form = useForm>({ resolver: zodResolver(formSchema), + mode: 'all', defaultValues: { email: '', password: '', diff --git a/web/src/features/auth/ResetPassword.tsx b/web/src/features/auth/ResetPassword.tsx index 4f33f7f19..b580115b3 100644 --- a/web/src/features/auth/ResetPassword.tsx +++ b/web/src/features/auth/ResetPassword.tsx @@ -48,6 +48,7 @@ function ResetPassword(): FunctionComponent { const form = useForm>({ resolver: zodResolver(formSchema), + mode: 'all', defaultValues: { email: '', password: '', diff --git a/web/src/features/election-event/components/Guides/AddGuideForm.tsx b/web/src/features/election-event/components/Guides/AddGuideForm.tsx index 3486a40ab..2067a2bfb 100644 --- a/web/src/features/election-event/components/Guides/AddGuideForm.tsx +++ b/web/src/features/election-event/components/Guides/AddGuideForm.tsx @@ -83,6 +83,7 @@ export default function AddGuideForm({ const form = useForm({ resolver: zodResolver(newGuideFormSchema), + mode: 'all', defaultValues: { guideType: guideType, title: '', diff --git a/web/src/features/election-event/components/Guides/EditGuideForm.tsx b/web/src/features/election-event/components/Guides/EditGuideForm.tsx index 20db2e819..4ea102932 100644 --- a/web/src/features/election-event/components/Guides/EditGuideForm.tsx +++ b/web/src/features/election-event/components/Guides/EditGuideForm.tsx @@ -94,6 +94,7 @@ export default function EditGuideForm({ type EditGuideType = z.infer; const form = useForm({ resolver: zodResolver(editGuideFormSchema), + mode: 'all', defaultValues: { guidePageType: guidePageType, guideType: guideType, diff --git a/web/src/features/election-rounds/components/ElectionRoundForm/ElectionRoundForm.tsx b/web/src/features/election-rounds/components/ElectionRoundForm/ElectionRoundForm.tsx index 7c7830d71..4d113eda1 100644 --- a/web/src/features/election-rounds/components/ElectionRoundForm/ElectionRoundForm.tsx +++ b/web/src/features/election-rounds/components/ElectionRoundForm/ElectionRoundForm.tsx @@ -52,6 +52,7 @@ function ElectionRoundForm({ electionRound, children, onSubmit }: ElectionRoundF const form = useForm({ resolver: zodResolver(electionRoundSchema), + mode: 'all', defaultValues: { countryId: electionRound?.countryId ?? '', title: electionRound?.title ?? '', diff --git a/web/src/features/monitoring-observers/components/EditMonitoringObserver/EditMonitoringObserver.tsx b/web/src/features/monitoring-observers/components/EditMonitoringObserver/EditMonitoringObserver.tsx index 58e1b6c16..76168f232 100644 --- a/web/src/features/monitoring-observers/components/EditMonitoringObserver/EditMonitoringObserver.tsx +++ b/web/src/features/monitoring-observers/components/EditMonitoringObserver/EditMonitoringObserver.tsx @@ -47,6 +47,7 @@ export default function EditObserver() { const form = useForm>({ resolver: zodResolver(editObserverFormSchema), + mode: 'all', defaultValues: { status: monitoringObserver.status, tags: monitoringObserver.tags, diff --git a/web/src/features/monitoring-observers/components/MonitoringObserversList/CreateMonitoringObserverDialog.tsx b/web/src/features/monitoring-observers/components/MonitoringObserversList/CreateMonitoringObserverDialog.tsx index 66d03559a..d03df72e1 100644 --- a/web/src/features/monitoring-observers/components/MonitoringObserversList/CreateMonitoringObserverDialog.tsx +++ b/web/src/features/monitoring-observers/components/MonitoringObserversList/CreateMonitoringObserverDialog.tsx @@ -36,6 +36,7 @@ function CreateMonitoringObserverDialog({ open, onOpenChange }: CreateMonitoring type ObserverFormData = z.infer; const form = useForm({ + mode: 'all', resolver: zodResolver(newObserverSchema), }); diff --git a/web/src/features/monitoring-observers/components/PushMessageForm/PushMessageForm.tsx b/web/src/features/monitoring-observers/components/PushMessageForm/PushMessageForm.tsx index 863a8a9db..b557e795a 100644 --- a/web/src/features/monitoring-observers/components/PushMessageForm/PushMessageForm.tsx +++ b/web/src/features/monitoring-observers/components/PushMessageForm/PushMessageForm.tsx @@ -103,6 +103,7 @@ function PushMessageForm(): FunctionComponent { const form = useForm>({ resolver: zodResolver(createPushMessageSchema), + mode: 'all', defaultValues: { title: '', messageBody: '', diff --git a/web/src/features/ngos/components/CreateNGODialog.tsx b/web/src/features/ngos/components/CreateNGODialog.tsx index 019ad4589..539abf42d 100644 --- a/web/src/features/ngos/components/CreateNGODialog.tsx +++ b/web/src/features/ngos/components/CreateNGODialog.tsx @@ -7,6 +7,7 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { useCreateNgo } from '../hooks/ngos-queries'; import { newNgoSchema, NgoCreationFormData } from '../models/NGO'; +import { useCallback, useEffect } from 'react'; export interface CreateNGODialogProps { open: boolean; @@ -18,24 +19,56 @@ function CreateNGODialog({ open, onOpenChange }: CreateNGODialogProps) { const form = useForm({ resolver: zodResolver(newNgoSchema), + defaultValues: { + name: '', + }, }); + useEffect(() => { + if (form.formState.isSubmitSuccessful) { + form.reset(undefined, { keepValues: true }); + } + }, [form.formState.isSubmitSuccessful]); + + const internalOnOpenChange = useCallback( + (open: boolean) => { + if (!open) { + form.reset({ + name: '', + }); + } + onOpenChange(open); + }, + [onOpenChange] + ); + function onSubmit(values: NgoCreationFormData) { createNgoMutation.mutate({ values, onMutationSuccess: () => { form.reset({}); - onOpenChange(false); + internalOnOpenChange(false); toast({ title: 'Success', description: 'New organization created', }); }, + onMutationError: (error) => { + error?.errors?.forEach((error) => { + form.setError(error.name as keyof NgoCreationFormData, { type: 'custom', message: error.reason }); + }); + + toast({ + title: 'Error adding NGO admin', + description: 'Please contact Platform admins', + variant: 'destructive', + }); + }, }); } return ( - + { diff --git a/web/src/features/ngos/components/Dashboard/Dashboard.tsx b/web/src/features/ngos/components/Dashboard/Dashboard.tsx index a7ecab295..9de3ee33c 100644 --- a/web/src/features/ngos/components/Dashboard/Dashboard.tsx +++ b/web/src/features/ngos/components/Dashboard/Dashboard.tsx @@ -34,13 +34,20 @@ export default function NGOsDashboard(): ReactElement { const { deactivateNgoMutation, activateNgoMutation, deleteNgoWithConfirmation } = useNgoMutations(); - const navigateToNgo = useCallback( + const navigateToViewNgo = useCallback( (ngoId: string) => { void navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId, tab: 'details' } }); }, [navigate] ); + const navigateToEditNgo = useCallback( + (ngoId: string) => { + void navigate({ to: '/ngos/edit/$ngoId', params: { ngoId } }); + }, + [navigate] + ); + const ngoColDefs: ColumnDef[] = [ { accessorKey: 'name', @@ -85,7 +92,6 @@ export default function NGOsDashboard(): ReactElement { { id: 'actions', cell: ({ row }) => { - const navigate = useNavigate(); const isNGOActive = row.original.status === NGOStatus.Activated; return ( @@ -99,9 +105,10 @@ export default function NGOsDashboard(): ReactElement { - navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId: row.original.id, tab: 'details' } }) - }> + onClick={(e) => { + e.stopPropagation(); + navigateToEditNgo(row.original.id); + }}> Edit +
@@ -159,9 +166,9 @@ export default function NGOsDashboard(): ReactElement { useNGOs(params)} + useQuery={useNGOs} queryParams={queryParams} - onRowClick={navigateToNgo} + onRowClick={navigateToViewNgo} /> diff --git a/web/src/features/ngos/components/EditNgo.tsx b/web/src/features/ngos/components/EditNgo.tsx index c3ea3ea6a..15e0b4c53 100644 --- a/web/src/features/ngos/components/EditNgo.tsx +++ b/web/src/features/ngos/components/EditNgo.tsx @@ -1,18 +1,19 @@ +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 { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; import { TrashIcon } from '@heroicons/react/24/outline'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useNavigate } from '@tanstack/react-router'; -import { FC } from 'react'; +import { Link, useBlocker, useNavigate } from '@tanstack/react-router'; +import { FC, useEffect } from 'react'; import { useForm } from 'react-hook-form'; import { useNgoMutations } from '../hooks/ngos-queries'; -import { EditNgoFormData, editNgoSchema, NGO, NGOStatus } from '../models/NGO'; -import { NgoBackButton, NgoBreadcrumbs } from './NgoExtraComponents'; +import { EditNgoFormData, editNgoSchema, NGO } from '../models/NGO'; +import { useConfirm } from '@/components/ui/alert-dialog-provider'; +import { DevTool } from '@hookform/devtools'; interface EditNgoAdminProps { existingData: NGO; @@ -21,17 +22,25 @@ interface EditNgoAdminProps { export const EditNgo: FC = ({ existingData, ngoId }) => { const navigate = useNavigate(); + const confirm = useConfirm(); const { editNgoMutation, deleteNgoWithConfirmation } = useNgoMutations(); const form = useForm({ resolver: zodResolver(editNgoSchema), + mode: 'all', + reValidateMode: 'onChange', defaultValues: { - name: existingData.name, - status: existingData.status, + name: existingData.name ?? '', }, }); + useEffect(() => { + if (form.formState.isSubmitSuccessful) { + form.reset({}, { keepValues: true }); + } + }, [form.formState.isSubmitSuccessful, form.reset]); + function onSubmit(values: EditNgoFormData) { editNgoMutation.mutate({ ngoId, values }); } @@ -41,16 +50,35 @@ export const EditNgo: FC = ({ existingData, ngoId }) => { ngoId, name: existingData.name, onMutationSuccess: () => { - navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId, tab: 'admins' } }); + navigate({ to: '/ngos' }); }, }); }; + useBlocker({ + shouldBlockFn: async () => { + if (!form.formState.isDirty || form.formState.isSubmitting) { + return false; + } + + return !(await confirm({ + title: `Unsaved Changes Detected`, + body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', + actionButton: 'Leave', + cancelButton: 'Stay', + })); + }, + }); + return ( } - breadcrumbs={}> + backButton={ + + + + } + breadcrumbs={<>}>
@@ -64,40 +92,13 @@ export const EditNgo: FC = ({ existingData, ngoId }) => { ( + render={({ field, fieldState }) => ( Name * - - - - - )} - /> - - ( - - - Status * - - - + @@ -121,6 +122,7 @@ export const EditNgo: FC = ({ existingData, ngoId }) => {
+ {/* set up the dev tool */}
diff --git a/web/src/features/ngos/components/NGODetails.tsx b/web/src/features/ngos/components/NGODetails.tsx index 6f290b7c6..50d07ea33 100644 --- a/web/src/features/ngos/components/NGODetails.tsx +++ b/web/src/features/ngos/components/NGODetails.tsx @@ -3,14 +3,14 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { PencilIcon } from '@heroicons/react/24/outline'; +import { BackButtonIcon } from '@/components/layout/Breadcrumbs/BackButton'; import Layout from '@/components/layout/Layout'; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { Route } from '@/routes/ngos/view.$ngoId.$tab'; -import { useNavigate } from '@tanstack/react-router'; +import { Link, useNavigate } from '@tanstack/react-router'; import { FC } from 'react'; import { NGO } from '../models/NGO'; import { NGOAdminsView } from './admins/NGOAdmins'; -import { NgoBackButton, NgoBreadcrumbs } from './NgoExtraComponents'; import { NgoStatusBadge } from './NgoStatusBadges'; interface NGODetailsProps { @@ -19,8 +19,8 @@ interface NGODetailsProps { export const NGODetailsView: FC = ({ data }) => { const navigate = useNavigate(); - const navigateToEdit = (): void => { - void navigate({ + const navigateToEdit = () => { + navigate({ to: '/ngos/edit/$ngoId', params: { ngoId: data.id }, }); @@ -74,8 +74,8 @@ export const NGODetails: FC = ({ data }) => { function handleTabChange(tab: string): void { navigate({ - to: '.', replace: true, + // @ts-ignore params(prev: any) { return { ...prev, tab }; }, @@ -84,10 +84,14 @@ export const NGODetails: FC = ({ data }) => { return ( } - breadcrumbs={data && }> + backButton={ + + + + } + breadcrumbs={<>}> - + Organization details Admin users diff --git a/web/src/features/ngos/components/NgoExtraComponents.tsx b/web/src/features/ngos/components/NgoExtraComponents.tsx deleted file mode 100644 index 4ae048a5c..000000000 --- a/web/src/features/ngos/components/NgoExtraComponents.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { usePrevSearch } from '@/common/prev-search-store'; -import { BackButtonIcon } from '@/components/layout/Breadcrumbs/BackButton'; -import { Link } from '@tanstack/react-router'; -import { FC, useEffect, useState } from 'react'; - -interface NgoBackButtonProps { - ngoId?: string; -} - -export const NgoBackButton: FC = ({ ngoId }) => { - const prevSearch = usePrevSearch(); - const destination = ngoId ? '/ngos/view/$ngoId/$tab' : '/ngos'; - const linkParams = ngoId ? { ngoId, tab: 'admins' } : {}; - - return ( - - - - ); -}; - -interface NgoBreadcrumbsProps { - ngoData: { - id: string; - name: string; - }; - adminData?: { - id: string; - name: string; - }; - tab?: string; -} - -export const NgoBreadcrumbs: FC = ({ ngoData, adminData, tab }) => { - const [ngoName, setNgoName] = useState(null); - - useEffect(() => { - if (!ngoName && ngoData.name !== undefined) setNgoName(ngoData.name); - }, [ngoData.name]); - - return ( -
- - ngos - - {ngoName && ( - - {ngoName} - - )} - - {tab ?? 'admins'} - - - {adminData?.name} - -
- ); -}; diff --git a/web/src/features/ngos/components/NgoStatusBadges.tsx b/web/src/features/ngos/components/NgoStatusBadges.tsx index b715e322d..19b5ed776 100644 --- a/web/src/features/ngos/components/NgoStatusBadges.tsx +++ b/web/src/features/ngos/components/NgoStatusBadges.tsx @@ -1,4 +1,5 @@ import { Badge } from '@/components/ui/badge'; +import { cn, mapNgoAdminStatus, mapNgoStatus } from '@/lib/utils'; import { FC } from 'react'; import { NGOStatus } from '../models/NGO'; import { NgoAdminStatus } from '../models/NgoAdmin'; @@ -8,26 +9,15 @@ interface NgoStatusBadgeProps { } export const NgoStatusBadge: FC = ({ status }) => { - let className = ''; - - switch (status) { - case NGOStatus.Activated: - className = 'badge-Active'; - break; - - case NGOStatus.Pending: - className = 'badge-Pending'; - break; - - case NGOStatus.Deactivated: - className = 'badge-Suspended'; - break; - - default: - break; - } - - return {status}; + return ( + + {mapNgoStatus(status)} + + ); }; interface NgoAdmintatusBadgeProps { @@ -35,20 +25,13 @@ interface NgoAdmintatusBadgeProps { } export const NgoAdminStatusBadge: FC = ({ status }) => { - let className = ''; - - switch (status) { - case NgoAdminStatus.Active: - className = 'badge-Active'; - break; - - case NgoAdminStatus.Deactivated: - className = 'badge-Suspended'; - break; - - default: - break; - } - - return {status}; + return ( + + {mapNgoAdminStatus(status)} + + ); }; diff --git a/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx b/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx index 7771fe9cd..fd97bef1e 100644 --- a/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx +++ b/web/src/features/ngos/components/admins/AddNgoAdminDialog.tsx @@ -7,37 +7,90 @@ import { zodResolver } from '@hookform/resolvers/zod'; import { useForm } from 'react-hook-form'; import { useCreateNgoAdmin } from '../../hooks/ngo-admin-queries'; import { NgoAdminFormData, ngoAdminSchema } from '../../models/NgoAdmin'; +import { useCallback, useEffect } from 'react'; +import { useBlocker } from '@tanstack/react-router'; +import { useConfirm } from '@/components/ui/alert-dialog-provider'; export interface AddNgoAdminDialogProps { ngoId: string; open: boolean; - onOpenChange: (open: any) => void; + onOpenChange: (open: boolean) => void; } function AddNgoAdminDialog({ open, onOpenChange, ngoId }: AddNgoAdminDialogProps) { const { createNgoAdminMutation } = useCreateNgoAdmin(); + const confirm = useConfirm(); const form = useForm({ + mode: 'all', resolver: zodResolver(ngoAdminSchema), }); + useEffect(() => { + if (form.formState.isSubmitSuccessful) { + form.reset(undefined, { keepValues: true }); + } + }, [form.formState.isSubmitSuccessful]); + + const internalOnOpenChange = useCallback( + (open: boolean) => { + if (!open) { + form.reset({ + email: '', + firstName: '', + lastName: '', + password: '', + phoneNumber: '', + }); + } + onOpenChange(open); + }, + [onOpenChange] + ); + + useBlocker({ + shouldBlockFn: async () => { + if (!form.formState.isDirty) { + return false; + } + + return !(await confirm({ + title: `Unsaved Changes Detected`, + body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', + actionButton: 'Leave', + cancelButton: 'Stay', + })); + }, + }); + function onSubmit(values: NgoAdminFormData) { createNgoAdminMutation.mutate({ ngoId, values, onMutationSuccess: () => { form.reset({}); - onOpenChange(false); + internalOnOpenChange(false); toast({ title: 'Success', description: 'New NGO admin added', }); }, + onMutationError: (error) => { + error?.errors?.forEach((error) => { + form.setError(error.name as keyof NgoAdminFormData, { type: 'custom', message: error.reason }); + }); + + toast({ + title: 'Error adding NGO admin', + description: 'Please contact Platform admins', + variant: 'destructive', + }); + }, }); } return ( - + { @@ -55,7 +108,9 @@ function AddNgoAdminDialog({ open, onOpenChange, ngoId }: AddNgoAdminDialogProps name='firstName' render={({ field, fieldState }) => ( - First name + + First name * + @@ -67,7 +122,9 @@ function AddNgoAdminDialog({ open, onOpenChange, ngoId }: AddNgoAdminDialogProps name='lastName' render={({ field, fieldState }) => ( - Last name + + Last name * + @@ -79,7 +136,9 @@ function AddNgoAdminDialog({ open, onOpenChange, ngoId }: AddNgoAdminDialogProps name='email' render={({ field, fieldState }) => ( - Email + + Email * + @@ -103,7 +162,9 @@ function AddNgoAdminDialog({ open, onOpenChange, ngoId }: AddNgoAdminDialogProps name='password' render={({ field, fieldState }) => ( - Password + + Password * + diff --git a/web/src/features/ngos/components/admins/EditNgoAdmin.tsx b/web/src/features/ngos/components/admins/EditNgoAdmin.tsx index 26915e507..a4a56d0b5 100644 --- a/web/src/features/ngos/components/admins/EditNgoAdmin.tsx +++ b/web/src/features/ngos/components/admins/EditNgoAdmin.tsx @@ -1,44 +1,69 @@ +import { BackButtonIcon } from '@/components/layout/Breadcrumbs/BackButton'; import Layout from '@/components/layout/Layout'; +import { useConfirm } from '@/components/ui/alert-dialog-provider'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; import { Input } from '@/components/ui/input'; -import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; import { Separator } from '@/components/ui/separator'; -import { Route } from '@/routes/ngos/admin.$ngoId.$adminId.edit'; +import { Route } from '@/routes/ngos/admin/$ngoId.$adminId.edit'; import { TrashIcon } from '@heroicons/react/24/outline'; import { zodResolver } from '@hookform/resolvers/zod'; -import { useNavigate } from '@tanstack/react-router'; -import { FC } from 'react'; +import { Link, useBlocker, useNavigate } from '@tanstack/react-router'; +import { FC, useEffect, useMemo } from 'react'; import { useForm } from 'react-hook-form'; -import { z } from 'zod'; import { useNgoAdminMutations } from '../../hooks/ngo-admin-queries'; -import { editNgoAdminSchema, NgoAdmin, NgoAdminStatus } from '../../models/NgoAdmin'; -import { NgoBackButton, NgoBreadcrumbs } from '../NgoExtraComponents'; +import { EditNgoAdminFormData, editNgoAdminSchema, NgoAdmin } from '../../models/NgoAdmin'; +import { NgoAdminStatusBadge } from '../NgoStatusBadges'; interface EditNgoAdminProps { - existingData: NgoAdmin; - id: string; + ngoAdmin: NgoAdmin; } -export const EditNgoAdmin: FC = ({ id, existingData }) => { +export const EditNgoAdmin: FC = ({ ngoAdmin }) => { const navigate = useNavigate(); const { ngoId, adminId } = Route.useParams(); - const { editNgoAdminMutation, deleteNgoAdminWithConfirmation } = useNgoAdminMutations(ngoId); - const displayName = `${existingData.firstName} ${existingData.lastName}`; + const confirm = useConfirm(); + + const displayName = useMemo( + () => `${ngoAdmin.firstName} ${ngoAdmin.lastName}`, + [ngoAdmin.firstName, ngoAdmin.lastName] + ); - const form = useForm>({ + const form = useForm({ resolver: zodResolver(editNgoAdminSchema), + mode: 'all', + reValidateMode: 'onChange', defaultValues: { - firstName: existingData.firstName, - lastName: existingData.lastName, - phoneNumber: existingData.phoneNumber, - status: existingData.status, + firstName: ngoAdmin.firstName, + lastName: ngoAdmin.lastName, + phoneNumber: ngoAdmin.phoneNumber, + }, + }); + + useEffect(() => { + if (form.formState.isSubmitSuccessful) { + form.reset({}, { keepValues: true }); + } + }, [form.formState.isSubmitSuccessful, form.reset]); + + useBlocker({ + shouldBlockFn: async () => { + if (!form.formState.isDirty) { + return false; + } + + return !(await confirm({ + title: `Unsaved Changes Detected`, + body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', + actionButton: 'Leave', + cancelButton: 'Stay', + })); }, }); - function onSubmit(values: z.infer) { + function onSubmit(values: EditNgoAdminFormData) { editNgoAdminMutation.mutate({ adminId, values }); } @@ -55,11 +80,13 @@ export const EditNgoAdmin: FC = ({ id, existingData }) => { return ( } - breadcrumbs={ - - }> - + backButton={ + + + + } + breadcrumbs={<>}> +
Edit NGO admin @@ -69,16 +96,20 @@ export const EditNgoAdmin: FC = ({ id, existingData }) => {
+
+

Email

+

{ngoAdmin.email}

+
( + render={({ field, fieldState }) => ( First name * - + @@ -88,13 +119,13 @@ export const EditNgoAdmin: FC = ({ id, existingData }) => { ( + render={({ field, fieldState }) => ( Last name * - + @@ -104,43 +135,22 @@ export const EditNgoAdmin: FC = ({ id, existingData }) => { ( + render={({ field, fieldState }) => ( - - Phone number * - + Phone number - - - - - )} - /> - ( - - - Status * - - - + )} /> + +
+

Status

+ +
+
- + + + + diff --git a/web/src/features/ngos/components/admins/NGOAdmins.tsx b/web/src/features/ngos/components/admins/NGOAdmins.tsx index 557acadcc..0cc7f56db 100644 --- a/web/src/features/ngos/components/admins/NGOAdmins.tsx +++ b/web/src/features/ngos/components/admins/NGOAdmins.tsx @@ -19,7 +19,7 @@ import { EllipsisVerticalIcon } from '@heroicons/react/24/solid'; import { useNavigate } from '@tanstack/react-router'; import type { ColumnDef } from '@tanstack/react-table'; import { Plus } from 'lucide-react'; -import { FC } from 'react'; +import { FC, useCallback } from 'react'; import { useNgoAdminMutations, useNgoAdmins } from '../../hooks/ngo-admin-queries'; import { NgoAdmin, NgoAdminStatus } from '../../models/NgoAdmin'; import { NgoAdminStatusBadge } from '../NgoStatusBadges'; @@ -37,14 +37,17 @@ export const NGOAdminsView: FC = ({ ngoId }) => { const addNgoAdminDialog = useDialog(); const { queryParams, searchText, handleSearchInput } = useDebouncedSearch(Route.id, ngoAdminsSearchParamsSchema); - const navigateToNgoAdmin = (ngoId: string, adminId: string) => - navigate({ to: '/ngos/admin/$ngoId/$adminId/view', params: { ngoId, adminId } }); + const navigateToViewNgoAdmin = useCallback( + (adminId: string) => navigate({ to: '/ngos/admin/$ngoId/$adminId/view', params: { ngoId, adminId } }), + [ngoId] + ); + + const navigateToEditNgoAdmin = useCallback( + (adminId: string) => navigate({ to: '/ngos/admin/$ngoId/$adminId/edit', params: { ngoId, adminId } }), + [ngoId] + ); const ngoAdminsColDefs: ColumnDef[] = [ - { - header: 'ID', - accessorKey: 'id', - }, { accessorKey: 'email', enableSorting: true, @@ -60,6 +63,12 @@ export const NGOAdminsView: FC = ({ ngoId }) => { enableSorting: true, header: ({ column }) => , }, + { + accessorKey: 'phoneNumber', + enableSorting: true, + header: ({ column }) => , + cell: ({ row: { original } }) => original.phoneNumber || '-', + }, { accessorKey: 'status', @@ -90,7 +99,8 @@ export const NGOAdminsView: FC = ({ ngoId }) => { - navigateToNgoAdmin(ngoId, row.original.id)}>Edit + navigateToViewNgoAdmin(adminId)}>View + navigateToEditNgoAdmin(adminId)}>Edit { e.stopPropagation(); @@ -146,7 +156,7 @@ export const NGOAdminsView: FC = ({ ngoId }) => { columns={ngoAdminsColDefs} useQuery={(params) => useNgoAdmins(ngoId, params)} queryParams={queryParams} - onRowClick={(id) => navigateToNgoAdmin(ngoId, id)} + // onRowClick={(id) => navigateToViewNgoAdmin(id)} /> diff --git a/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx b/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx index fc32dead4..083da4bea 100644 --- a/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx +++ b/web/src/features/ngos/components/admins/NgoAdminDetailsView.tsx @@ -2,23 +2,22 @@ import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Separator } from '@/components/ui/separator'; import { PencilIcon } from '@heroicons/react/24/outline'; -import { useNavigate } from '@tanstack/react-router'; +import { Link, useNavigate } from '@tanstack/react-router'; import { DateTimeFormat } from '@/common/formats'; +import { BackButtonIcon } from '@/components/layout/Breadcrumbs/BackButton'; import Layout from '@/components/layout/Layout'; import { format } from 'date-fns'; import { FC } from 'react'; import { NgoAdmin } from '../../models/NgoAdmin'; -import { NgoBackButton, NgoBreadcrumbs } from '../NgoExtraComponents'; import { NgoAdminStatusBadge } from '../NgoStatusBadges'; interface NgoAdminDetailsViewProps { ngoId: string; - ngoName: string; ngoAdmin: NgoAdmin; } -export const NgoAdminDetailsView: FC = ({ ngoId, ngoName, ngoAdmin }) => { +export const NgoAdminDetailsView: FC = ({ ngoId, ngoAdmin }) => { const navigate = useNavigate(); const displayName = `${ngoAdmin.firstName} ${ngoAdmin.lastName}`; const navigateToEdit = (): void => { @@ -31,11 +30,13 @@ export const NgoAdminDetailsView: FC = ({ ngoId, ngoNa return ( } - breadcrumbs={ - - }> - + backButton={ + + + + } + breadcrumbs={<>}> +
NGO admin details @@ -48,8 +49,12 @@ export const NgoAdminDetailsView: FC = ({ ngoId, ngoNa
-

Name

-

{displayName}

+

First name

+

{ngoAdmin.firstName}

+
+
+

Last name

+

{ngoAdmin.lastName}

Email

diff --git a/web/src/features/ngos/hooks/ngo-admin-queries.ts b/web/src/features/ngos/hooks/ngo-admin-queries.ts index f53af6dfb..be7d3b9b7 100644 --- a/web/src/features/ngos/hooks/ngo-admin-queries.ts +++ b/web/src/features/ngos/hooks/ngo-admin-queries.ts @@ -1,5 +1,5 @@ import { authApi } from '@/common/auth-api'; -import { DataTableParameters, PageResponse } from '@/common/types'; +import { DataTableParameters, PageResponse, ProblemDetails } from '@/common/types'; import { useConfirm } from '@/components/ui/alert-dialog-provider'; import { buttonVariants } from '@/components/ui/button'; import { toast } from '@/components/ui/use-toast'; @@ -14,10 +14,12 @@ import { import { useNavigate, useRouter } from '@tanstack/react-router'; import { EditNgoAdminFormData, NgoAdmin, NgoAdminFormData, NgoAdminGetRequestParams } from '../models/NgoAdmin'; import { ngosKeys } from './ngos-queries'; +import axios, { AxiosError } from 'axios'; +const STALE_TIME = 1000 * 10 * 60; // 10 minutes export const ngoAdminDetailsOptions = ({ ngoId, adminId }: NgoAdminGetRequestParams) => queryOptions({ - queryKey: ngosKeys.detail(ngoId), + queryKey: ngosKeys.adminDetails(ngoId, adminId), queryFn: async () => { const response = await authApi.get(`/ngos/${ngoId}/admins/${adminId}`); @@ -27,6 +29,7 @@ export const ngoAdminDetailsOptions = ({ ngoId, adminId }: NgoAdminGetRequestPar return response.data; }, + staleTime: STALE_TIME, }); export const useNgoAdminDetails = ({ ngoId, adminId }: NgoAdminGetRequestParams) => @@ -48,6 +51,7 @@ export function useNgoAdmins(ngoId: string, p: DataTableParameters): UseQueryRes return response.data; }, + staleTime: STALE_TIME, }); } @@ -56,16 +60,35 @@ export function useNgoAdmins(ngoId: string, p: DataTableParameters): UseQueryRes export const useCreateNgoAdmin = () => { const queryClient = useQueryClient(); const createNgoAdminMutation = useMutation({ - mutationFn: ({ ngoId, values }: { ngoId: string; values: NgoAdminFormData; onMutationSuccess: () => void }) => { - return authApi.post(`/ngos/${ngoId}/admins`, values); + mutationFn: ({ + ngoId, + values, + }: { + ngoId: string; + values: NgoAdminFormData; + onMutationSuccess: () => void; + onMutationError: (error?: ProblemDetails) => void; + }) => { + return authApi.post(`/ngos/${ngoId}/admins`, values); }, onSuccess: (_, { onMutationSuccess }) => { queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); onMutationSuccess(); }, - onError: (err) => { - console.error(err); + onError: (error, { onMutationError }) => { + if (axios.isAxiosError(error) && error.response) { + const axiosError = error as AxiosError; + + if (axiosError.response?.status === 400) { + const problemDetails = axiosError.response.data; + return onMutationError(problemDetails); + } + } + + // Handle non-Axios or unexpected errors + console.error('Unexpected error:', error); + onMutationError(); }, }); @@ -82,13 +105,13 @@ export const useNgoAdminMutations = (ngoId: string) => { const editNgoAdminMutation = useMutation({ mutationFn: ({ adminId, values }: { adminId: string; values: EditNgoAdminFormData }) => { - return authApi.put(`ngos/${ngoId}/admins/${adminId}`, values); + return authApi.put(`/ngos/${ngoId}/admins/${adminId}`, values); }, - onSuccess: () => { + onSuccess: (_, { adminId }) => { queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); router.invalidate(); - navigate({ to: '/ngos/view/$ngoId/$tab', params: { ngoId: ngoId!, tab: 'admins' } }); + navigate({ to: '/ngos/admin/$ngoId/$adminId/view', params: { ngoId, adminId } }); }, onError: () => { toast({ @@ -101,7 +124,7 @@ export const useNgoAdminMutations = (ngoId: string) => { const deleteNgoAdminMutation = useMutation({ mutationFn: ({ adminId }: { adminId: string; onMutationSuccess?: () => void }) => { - return authApi.delete(`ngos/${ngoId}/admins/${adminId}`, {}); + return authApi.delete(`/ngos/${ngoId}/admins/${adminId}`, {}); }, onSuccess: (_, { onMutationSuccess }) => { @@ -127,7 +150,7 @@ export const useNgoAdminMutations = (ngoId: string) => { const deactivateNgoAdminMutation = useMutation({ mutationFn: (adminId: string) => { - return authApi.post(`ngos/${ngoId}/admins/${adminId}:deactivate`, {}); + return authApi.post(`/ngos/${ngoId}/admins/${adminId}:deactivate`, {}); }, onSuccess: () => { @@ -151,7 +174,7 @@ export const useNgoAdminMutations = (ngoId: string) => { const activateNgoAdminMutation = useMutation({ mutationFn: (adminId: string) => { - return authApi.post(`ngos/${ngoId}/admins/${adminId}:activate`, {}); + return authApi.post(`/ngos/${ngoId}/admins/${adminId}:activate`, {}); }, onSuccess: () => { diff --git a/web/src/features/ngos/hooks/ngos-queries.ts b/web/src/features/ngos/hooks/ngos-queries.ts index 1fda8757b..5e62ca0e0 100644 --- a/web/src/features/ngos/hooks/ngos-queries.ts +++ b/web/src/features/ngos/hooks/ngos-queries.ts @@ -1,14 +1,15 @@ import { authApi } from '@/common/auth-api'; -import { DataTableParameters, PageResponse } from '@/common/types'; +import { DataTableParameters, PageResponse, ProblemDetails } from '@/common/types'; import { useConfirm } from '@/components/ui/alert-dialog-provider'; import { buttonVariants } from '@/components/ui/button'; import { toast } from '@/components/ui/use-toast'; import { queryClient } from '@/main'; import { queryOptions, useMutation, useQuery, UseQueryResult, useSuspenseQuery } from '@tanstack/react-query'; import { useNavigate, useRouter } from '@tanstack/react-router'; -import { AxiosResponse } from 'axios'; import { EditNgoFormData, NGO, NgoCreationFormData } from '../models/NGO'; -const ENDPOINT = 'ngos'; +import axios, { AxiosError } from 'axios'; + +const STALE_TIME = 1000 * 10 * 60; // 10 minutes export const ngosKeys = { all: () => ['ngos'] as const, @@ -16,6 +17,7 @@ export const ngosKeys = { list: (params: DataTableParameters) => [...ngosKeys.lists(), { ...params }] as const, details: () => [...ngosKeys.all(), 'detail'] as const, detail: (id: string) => [...ngosKeys.details(), id] as const, + adminDetails: (ngoId: string, ngoAdminId: string) => [...ngosKeys.detail(ngoId), 'admins', ngoAdminId] as const, adminsList: (ngoId: string, params: DataTableParameters) => [...ngosKeys.all(), 'admins', ngoId, { ...params }] as const, }; @@ -34,6 +36,7 @@ export function useNGOs(p: DataTableParameters): UseQueryResult return response.data; }, + staleTime: STALE_TIME, }); export const useNGODetails = (ngoId: string) => useSuspenseQuery(ngoDetailsOptions(ngoId)); export const useCreateNgo = () => { const createNgoMutation = useMutation({ - mutationFn: ({ values }: { values: NgoCreationFormData; onMutationSuccess: () => void }) => { - return authApi.post(`${ENDPOINT}`, { name: values.name }); + mutationFn: ({ + values, + }: { + values: NgoCreationFormData; + onMutationSuccess: () => void; + onMutationError: (error?: ProblemDetails) => void; + }) => { + return authApi.post('/ngos', { name: values.name }); }, - onSuccess: (response: AxiosResponse, { values, onMutationSuccess }) => { + onSuccess: (_, { onMutationSuccess }) => { queryClient.invalidateQueries({ queryKey: ngosKeys.all() }); if (onMutationSuccess) onMutationSuccess(); }, - onError: (err) => { + onError: (error, { onMutationError }) => { + if (axios.isAxiosError(error) && error.response) { + const axiosError = error as AxiosError; + + if (axiosError.response?.status === 400) { + const problemDetails = axiosError.response.data; + return onMutationError(problemDetails); + } + } + + // Handle non-Axios or unexpected errors + console.error('Unexpected error:', error); + onMutationError(); toast({ title: 'Error creating a new NGO', description: '', @@ -84,7 +106,7 @@ export const useNgoMutations = () => { const editNgoMutation = useMutation({ mutationFn: ({ ngoId, values }: { ngoId: string; values: EditNgoFormData }) => { - return authApi.put(`${ENDPOINT}/${ngoId}`, values); + return authApi.put(`/ngos/${ngoId}`, values); }, onSuccess: (_, { ngoId }) => { @@ -103,7 +125,7 @@ export const useNgoMutations = () => { const deactivateNgoMutation = useMutation({ mutationFn: (ngoId: string) => { - return authApi.post(`${ENDPOINT}/${ngoId}:deactivate`, {}); + return authApi.post(`/ngos/${ngoId}:deactivate`, {}); }, onSuccess: () => { @@ -127,7 +149,7 @@ export const useNgoMutations = () => { const activateNgoMutation = useMutation({ mutationFn: (ngoId: string) => { - return authApi.post(`${ENDPOINT}/${ngoId}:activate`, {}); + return authApi.post(`/ngos/${ngoId}:activate`, {}); }, onSuccess: () => { @@ -151,7 +173,7 @@ export const useNgoMutations = () => { const deleteNgoMutation = useMutation({ mutationFn: ({ ngoId }: { ngoId: string; onMutationSuccess?: () => void }) => { - return authApi.delete(`${ENDPOINT}/${ngoId}`); + return authApi.delete(`/ngos/${ngoId}`); }, onSuccess: (_, { onMutationSuccess }) => { diff --git a/web/src/features/ngos/models/NGO.tsx b/web/src/features/ngos/models/NGO.tsx index 8a69c4a50..720599337 100644 --- a/web/src/features/ngos/models/NGO.tsx +++ b/web/src/features/ngos/models/NGO.tsx @@ -13,20 +13,22 @@ export interface NGO { export enum NGOStatus { Activated = 'Activated', - Pending = 'Pending', Deactivated = 'Deactivated', } export const newNgoSchema = z.object({ - name: z.string(), + name: z + .string() + .min(2, { message: 'Name must be at least 2 characters.' }) + .max(256, { message: 'Name must not exceed 256 characters.' }), }); export type NgoCreationFormData = z.infer; export const editNgoSchema = z.object({ - name: z.string().min(2, { - message: 'This field is mandatory', - }), - status: z.string(), + name: z + .string() + .min(2, { message: 'Name must be at least 2 characters.' }) + .max(256, { message: 'Name must not exceed 256 characters.' }), }); export type EditNgoFormData = z.infer; diff --git a/web/src/features/ngos/models/NgoAdmin.tsx b/web/src/features/ngos/models/NgoAdmin.tsx index 43665ea38..183c57baf 100644 --- a/web/src/features/ngos/models/NgoAdmin.tsx +++ b/web/src/features/ngos/models/NgoAdmin.tsx @@ -18,22 +18,35 @@ export interface NgoAdmin { } export const ngoAdminSchema = z.object({ - firstName: z.string(), - lastName: z.string(), - email: z.string().email(), - phoneNumber: z.string(), - password: z.string().min(1, { message: 'This field is required' }), + firstName: z + .string() + .min(1, { message: 'First name is required' }) + .max(256, { message: 'First name cannot exceed 256 characters' }), + lastName: z + .string() + .min(1, { message: 'Last name is required' }) + .max(256, { message: 'Last name cannot exceed 256 characters' }), + email: z + .string() + .email({ message: 'Invalid email format' }) + .max(256, { message: 'Email cannot exceed 256 characters' }), + phoneNumber: z.string().max(256, { message: 'Phone number cannot exceed 256 characters' }).optional(), + password: z + .string() + .min(1, { message: 'Password is required' }) + .max(256, { message: 'Password cannot exceed 256 characters' }), }); export const editNgoAdminSchema = z.object({ - firstName: z.string().min(2, { - message: 'This field is mandatory', - }), - lastName: z.string().min(2, { - message: 'This field is mandatory', - }), - phoneNumber: z.string().min(1, { message: 'This field is required' }), - status: z.string(), + firstName: z + .string() + .min(2, { message: 'First name must be at least 2 characters' }) + .max(256, { message: 'First name cannot exceed 256 characters' }), + lastName: z + .string() + .min(2, { message: 'Last name must be at least 2 characters' }) + .max(256, { message: 'Last name cannot exceed 256 characters' }), + phoneNumber: z.coerce.string().max(256, { message: 'Phone number cannot exceed 256 characters' }).optional(), }); export type NgoAdminFormData = z.infer; diff --git a/web/src/features/observers/components/EditObserver/EditObserver.tsx b/web/src/features/observers/components/EditObserver/EditObserver.tsx index fe165e64b..f04b302a8 100644 --- a/web/src/features/observers/components/EditObserver/EditObserver.tsx +++ b/web/src/features/observers/components/EditObserver/EditObserver.tsx @@ -1,5 +1,6 @@ import { authApi } from '@/common/auth-api'; import Layout from '@/components/layout/Layout'; +import { useConfirm } from '@/components/ui/alert-dialog-provider'; import { Button } from '@/components/ui/button'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form'; @@ -10,7 +11,7 @@ import { Route } from '@/routes/observers_.$observerId.edit'; import { TrashIcon } from '@heroicons/react/24/outline'; import { zodResolver } from '@hookform/resolvers/zod'; import { useMutation, useSuspenseQuery } from '@tanstack/react-query'; -import { useNavigate } from '@tanstack/react-router'; +import { useBlocker, useNavigate } from '@tanstack/react-router'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; @@ -19,6 +20,7 @@ export default function EditObserver() { const { observerId } = Route.useParams(); const observerQuery = useSuspenseQuery(observerDetailsQueryOptions(observerId)); const observer = observerQuery.data; + const confirm = useConfirm(); const editObserverFormSchema = z.object({ lastName: z.string().min(1, { @@ -33,6 +35,7 @@ export default function EditObserver() { const form = useForm>({ resolver: zodResolver(editObserverFormSchema), + mode: 'all', defaultValues: { firstName: observer.firstName, lastName: observer.lastName, @@ -41,6 +44,21 @@ export default function EditObserver() { }, }); + useBlocker({ + shouldBlockFn: async () => { + if (!form.formState.isDirty) { + return false; + } + + return !(await confirm({ + title: `Unsaved Changes Detected`, + body: 'You have unsaved changes. If you leave this page, your changes will be lost. Are you sure you want to continue?', + actionButton: 'Leave', + cancelButton: 'Stay', + })); + }, + }); + function onSubmit(values: z.infer) { editMutation.mutate({ observerId: observer.id, diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 84f122275..4de0fae86 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -570,6 +570,8 @@ export function omit(obj: T, key: K): Omit { } import { authApi } from '@/common/auth-api'; +import { NGOStatus } from '@/features/ngos/models/NGO'; +import { NgoAdminStatus } from '@/features/ngos/models/NgoAdmin'; export enum TemplateType { MonitoringObservers = 'monitoring-observers', @@ -612,3 +614,27 @@ export const downloadImportExample = async (templateType: TemplateType) => { window.URL.revokeObjectURL(url); }; + +export function mapNgoStatus(ngoStatus: NGOStatus): string { + switch (ngoStatus) { + case NGOStatus.Activated: + return i18n.t('ngo.status.activated'); + case NGOStatus.Deactivated: + return i18n.t('ngo.status.deactivated'); + + default: + return 'Unknown'; + } +} + +export function mapNgoAdminStatus(ngoAdminStatus: NgoAdminStatus): string { + switch (ngoAdminStatus) { + case NgoAdminStatus.Active: + return i18n.t('ngo-admin.status.active'); + case NgoAdminStatus.Deactivated: + return i18n.t('ngo-admin.status.deactivated'); + + default: + return 'Unknown'; + } +} diff --git a/web/src/locales/en.json b/web/src/locales/en.json index b887599c1..87fa431f6 100644 --- a/web/src/locales/en.json +++ b/web/src/locales/en.json @@ -32,7 +32,7 @@ } }, "app.input.pleaseSpecify": "Please specify", - "electionRound":{ + "electionRound": { "action.create": "Create new election event", "field.country": "Country", "field.englishTitle": "English title", @@ -40,9 +40,9 @@ "field.title": "Title", "placeholder.englishTitle": "Title in English", "placeholder.title": "Title", - "status":{ - "notStarted":"Not started", - "started":"Started", + "status": { + "notStarted": "Not started", + "started": "Started", "archived": "Archived" } }, @@ -448,5 +448,17 @@ "title": "Responses", "subtitle": "View all form answers and other reports submitted by your observers.", "subtitleCoalitionLeader": "View all form answers and other reports submitted by coalition observers." + }, + "ngo": { + "status": { + "activated": "Activated", + "deactivated": "Deactivated" + } + }, + "ngo-admin": { + "status": { + "active": "Active", + "deactivated": "Deactivated" + } } } diff --git a/web/src/routeTree.gen.ts b/web/src/routeTree.gen.ts index 3eef4ea40..4df72d17b 100644 --- a/web/src/routeTree.gen.ts +++ b/web/src/routeTree.gen.ts @@ -71,8 +71,8 @@ import { Route as ElectionRoundsElectionRoundIdPollingStationsImportImport } fro import { Route as ElectionRoundsElectionRoundIdLocationsImportImport } from './routes/election-rounds/$electionRoundId/locations/import' import { Route as CitizenReportAttachmentsElectionRoundIdCitizenReportIdAttachmentIdImport } from './routes/citizen-report-attachments/$electionRoundId.$citizenReportId.$attachmentId' import { Route as NgosEditNgoIdImport } from './routes/ngos/edit.$ngoId.' -import { Route as NgosAdminNgoIdAdminIdViewImport } from './routes/ngos/admin.$ngoId.$adminId.view' -import { Route as NgosAdminNgoIdAdminIdEditImport } from './routes/ngos/admin.$ngoId.$adminId.edit' +import { Route as NgosAdminNgoIdAdminIdViewImport } from './routes/ngos/admin/$ngoId.$adminId.view' +import { Route as NgosAdminNgoIdAdminIdEditImport } from './routes/ngos/admin/$ngoId.$adminId.edit' // Create/Update Routes @@ -1715,10 +1715,10 @@ export const routeTree = rootRoute "filePath": "responses/incident-reports/$formId.aggregated.tsx" }, "/ngos/admin/$ngoId/$adminId/edit": { - "filePath": "ngos/admin.$ngoId.$adminId.edit.tsx" + "filePath": "ngos/admin/$ngoId.$adminId.edit.tsx" }, "/ngos/admin/$ngoId/$adminId/view": { - "filePath": "ngos/admin.$ngoId.$adminId.view.tsx" + "filePath": "ngos/admin/$ngoId.$adminId.view.tsx" } } } diff --git a/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx b/web/src/routes/ngos/admin/$ngoId.$adminId.edit.tsx similarity index 87% rename from web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx rename to web/src/routes/ngos/admin/$ngoId.$adminId.edit.tsx index 991122044..f9dd8f57f 100644 --- a/web/src/routes/ngos/admin.$ngoId.$adminId.edit.tsx +++ b/web/src/routes/ngos/admin/$ngoId.$adminId.edit.tsx @@ -1,11 +1,12 @@ import { EditNgoAdmin } from '@/features/ngos/components/admins/EditNgoAdmin'; import { ngoAdminDetailsOptions, useNgoAdminDetails } from '@/features/ngos/hooks/ngo-admin-queries'; import { ngoDetailsOptions } from '@/features/ngos/hooks/ngos-queries'; -import { redirectIfNotPlatformAdmin } from '@/lib/utils'; +import { redirectIfNotAuth, redirectIfNotPlatformAdmin } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; export const Route = createFileRoute('/ngos/admin/$ngoId/$adminId/edit')({ beforeLoad: ({ params }) => { + redirectIfNotAuth(); redirectIfNotPlatformAdmin(); }, component: NgoAdminDetails, @@ -24,5 +25,5 @@ function NgoAdminDetails() { const { ngoId, adminId } = Route.useParams(); const { data: ngoAdmin } = useNgoAdminDetails({ ngoId, adminId }); - return ; + return ; } diff --git a/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx b/web/src/routes/ngos/admin/$ngoId.$adminId.view.tsx similarity index 78% rename from web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx rename to web/src/routes/ngos/admin/$ngoId.$adminId.view.tsx index 330339c56..b6ddb36e1 100644 --- a/web/src/routes/ngos/admin.$ngoId.$adminId.view.tsx +++ b/web/src/routes/ngos/admin/$ngoId.$adminId.view.tsx @@ -1,11 +1,12 @@ import { NgoAdminDetailsView } from '@/features/ngos/components/admins/NgoAdminDetailsView'; import { ngoAdminDetailsOptions, useNgoAdminDetails } from '@/features/ngos/hooks/ngo-admin-queries'; -import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queries'; -import { redirectIfNotPlatformAdmin } from '@/lib/utils'; +import { ngoDetailsOptions } from '@/features/ngos/hooks/ngos-queries'; +import { redirectIfNotAuth, redirectIfNotPlatformAdmin } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; export const Route = createFileRoute('/ngos/admin/$ngoId/$adminId/view')({ beforeLoad: ({ params }) => { + redirectIfNotAuth(); redirectIfNotPlatformAdmin(); }, component: NgoAdminDetails, @@ -23,7 +24,6 @@ export const Route = createFileRoute('/ngos/admin/$ngoId/$adminId/view')({ function NgoAdminDetails() { const { ngoId, adminId } = Route.useParams(); const { data: ngoAdmin } = useNgoAdminDetails({ ngoId, adminId }); - const { data: ngo } = useNGODetails(ngoId); - return ; + return ; } diff --git a/web/src/routes/ngos/edit.$ngoId..tsx b/web/src/routes/ngos/edit.$ngoId..tsx index e652ee833..21ede6a09 100644 --- a/web/src/routes/ngos/edit.$ngoId..tsx +++ b/web/src/routes/ngos/edit.$ngoId..tsx @@ -1,10 +1,11 @@ import { EditNgo } from '@/features/ngos/components/EditNgo'; import { ngoDetailsOptions, useNGODetails } from '@/features/ngos/hooks/ngos-queries'; -import { redirectIfNotPlatformAdmin } from '@/lib/utils'; +import { redirectIfNotAuth, redirectIfNotPlatformAdmin } from '@/lib/utils'; import { createFileRoute } from '@tanstack/react-router'; export const Route = createFileRoute('/ngos/edit/$ngoId/')({ beforeLoad: ({ params }) => { + redirectIfNotAuth(); redirectIfNotPlatformAdmin(); }, component: EditNgoPage, diff --git a/web/src/styles/tailwind.css b/web/src/styles/tailwind.css index f98f3aae7..0447c541d 100644 --- a/web/src/styles/tailwind.css +++ b/web/src/styles/tailwind.css @@ -66,11 +66,4 @@ .breadcrumbs .crumb:last-child:after { display: none; -} - -.election-text { - max-width: 350px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} +} \ No newline at end of file