diff --git a/web/src/components/layout/Breadcrumbs/BackButton.tsx b/web/src/components/layout/Breadcrumbs/BackButton.tsx index 029ec104f..41bf29035 100644 --- a/web/src/components/layout/Breadcrumbs/BackButton.tsx +++ b/web/src/components/layout/Breadcrumbs/BackButton.tsx @@ -1,29 +1,32 @@ import { usePrevSearch } from '@/common/prev-search-store'; import type { FunctionComponent } from '@/common/types'; import { Link, useRouter } from '@tanstack/react-router'; +import { FC } from 'react'; + +export const BackButtonIcon: FC = () => { + return ( + + + + ); +}; const BackButton = (): FunctionComponent => { const { latestLocation } = useRouter(); const prevSearch = usePrevSearch(); const links = latestLocation.pathname.split('/').filter((crumb: string) => crumb !== ''); + if (links.length <= 1) return <>; + return ( - <> - {links.length > 1 ? ( - - - - - - ) : ( - '' - )} - + + + ); }; diff --git a/web/src/components/ui/badge.tsx b/web/src/components/ui/badge.tsx index 8484f6dcf..eaab177ba 100644 --- a/web/src/components/ui/badge.tsx +++ b/web/src/components/ui/badge.tsx @@ -1,8 +1,8 @@ -import { cva, type VariantProps } from 'class-variance-authority'; -import type * as React from 'react'; import type { FunctionComponent } from '@/common/types'; import { cn } from '@/lib/utils'; import { XMarkIcon } from '@heroicons/react/24/outline'; +import { cva, type VariantProps } from 'class-variance-authority'; +import type * as React from 'react'; const badgeVariants = cva( 'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2', @@ -10,10 +10,11 @@ const badgeVariants = cva( variants: { variant: { default: 'border-transparent bg-primary text-primary-foreground', - secondary: 'border border-input bg-background hover:bg-purple-50 text-purple-900 border-purple-900 hover:text-accent-foreground', + secondary: + 'border border-input bg-background hover:bg-purple-50 text-purple-900 border-purple-900 hover:text-accent-foreground', destructive: 'border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80', outline: - 'border border-input bg-purple-100 hover:bg-purple-50 text-purple-900 border-purple-900 hover:text-accent-foreground', + 'border border-input bg-purple-100 hover:bg-purple-50 text-purple-900 border-purple-900 hover:text-accent-foreground', }, }, defaultVariants: { @@ -37,11 +38,11 @@ function FilterBadge({ label, onClear }: FilterBadgeProps): FunctionComponent { return ( {label} - ); } -export { Badge, FilterBadge, badgeVariants }; +export { Badge, badgeVariants, FilterBadge }; diff --git a/web/src/features/filtering/components/ActiveFilters.tsx b/web/src/features/filtering/components/ActiveFilters.tsx new file mode 100644 index 000000000..753772c1f --- /dev/null +++ b/web/src/features/filtering/components/ActiveFilters.tsx @@ -0,0 +1,69 @@ +import { FilterBadge } from '@/components/ui/badge'; +import { useNavigate } from '@tanstack/react-router'; +import { FC, useCallback } from 'react'; +import { FILTER_KEY, FILTER_LABEL } from '../filtering-enums'; + +interface ActiveFilterProps { + filterId: string; + value: string; + isArray?: boolean; +} + +type SearchParams = { + [key: string]: any; +}; + +const HIDDEN_FILTERS = [FILTER_KEY.PageSize, FILTER_KEY.PageNumber]; +const FILTER_LABELS = new Map([ + [FILTER_KEY.MonitoringObserverStatus, FILTER_LABEL.MonitoringObserverStatus], + [FILTER_KEY.MonitoringObserverTags, FILTER_LABEL.MonitoringObserverTags], +]); + +const ActiveFilter: FC = ({ filterId, value, isArray }) => { + const label = FILTER_LABELS.get(filterId) ?? filterId; + + const navigate = useNavigate(); + const onClearFilter = useCallback( + (filter: string, value?: string) => () => { + if (!isArray) { + return navigate({ search: (prev) => ({ ...prev, [filter]: undefined }) }); + } + return navigate({ + search: (prev: SearchParams) => ({ + ...prev, + [filter]: prev[filter]?.filter((item: string) => item !== value), // Remove the value from the array + }), + }); + }, + [navigate] + ); + return ; +}; + +interface ActiveFiltersProps { + queryParams: any; +} + +export const ActiveFilters: FC = ({ queryParams }) => { + return ( +
+ {Object.keys(queryParams).map((filterId) => { + let key = ''; + const value = queryParams[filterId]; + const isArray = Array.isArray(value); + + if (HIDDEN_FILTERS.includes(filterId)) return; + + if (!isArray) { + key = `active-filter-${filterId}`; + return ; + } + return value.map((item) => { + key = `active-filter-${filterId}-${item}`; + + return ; + }); + })} +
+ ); +}; diff --git a/web/src/features/filtering/components/FilteringContainer.tsx b/web/src/features/filtering/components/FilteringContainer.tsx new file mode 100644 index 000000000..d6487c581 --- /dev/null +++ b/web/src/features/filtering/components/FilteringContainer.tsx @@ -0,0 +1,21 @@ +import { Button } from '@/components/ui/button'; +import { FC, ReactNode } from 'react'; +import { useFilteringContainer } from '../hooks/useFilteringContainer'; +import { ActiveFilters } from './ActiveFilters'; + +interface FilteringContainerProps { + children?: ReactNode; +} + +export const FilteringContainer: FC = ({ children }) => { + const { filteringIsActive, queryParams, resetFilters } = useFilteringContainer(); + return ( +
+ {children} + + {filteringIsActive && } +
+ ); +}; diff --git a/web/src/features/filtering/components/SelectFilter.tsx b/web/src/features/filtering/components/SelectFilter.tsx new file mode 100644 index 000000000..ad7d0119d --- /dev/null +++ b/web/src/features/filtering/components/SelectFilter.tsx @@ -0,0 +1,39 @@ +import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'; +import { FC } from 'react'; + +export type SelectFilterOption = { + value: string; + label: string; +}; + +interface SelectFilterProps { + placeholder: string; + value: string; + options: SelectFilterOption[]; + onChange: (value: string) => void; +} + +export const SelectFilter: FC = (props) => { + const { placeholder, value, options, onChange } = props; + + const selectId = crypto.randomUUID(); + + return ( + + ); +}; diff --git a/web/src/features/filtering/filtering-enums.ts b/web/src/features/filtering/filtering-enums.ts new file mode 100644 index 000000000..ce834be93 --- /dev/null +++ b/web/src/features/filtering/filtering-enums.ts @@ -0,0 +1,11 @@ +export const enum FILTER_KEY { + PageSize = 'pageSize', + PageNumber = 'pageNumber', + MonitoringObserverStatus = 'monitoringObserverStatus', + MonitoringObserverTags = 'tags', +} + +export const enum FILTER_LABEL { + MonitoringObserverStatus = 'Observer status', + MonitoringObserverTags = 'Tags', +} diff --git a/web/src/features/filtering/hooks/useFilteringContainer.ts b/web/src/features/filtering/hooks/useFilteringContainer.ts new file mode 100644 index 000000000..bd7c5fbfa --- /dev/null +++ b/web/src/features/filtering/hooks/useFilteringContainer.ts @@ -0,0 +1,34 @@ +import { useSetPrevSearch } from '@/common/prev-search-store'; +import { useNavigate, useSearch } from '@tanstack/react-router'; +import { useCallback } from 'react'; + +export function useFilteringContainer() { + const navigate = useNavigate(); + const queryParams = useSearch({ strict: false }); + const setPrevSearch = useSetPrevSearch(); + const filteringIsActive = Object.keys(queryParams).some((key) => key !== 'tab' && key !== 'viewBy'); + + const navigateHandler = useCallback( + (search: Record) => { + void navigate({ + // @ts-ignore + search: (prev) => { + const newSearch: Record = { + ...prev, + ...search, + }; + setPrevSearch(newSearch); + return newSearch; + }, + }); + }, + [navigate, setPrevSearch] + ); + + const resetFilters = () => { + navigate({}); + setPrevSearch({}); + }; + + return { queryParams, filteringIsActive, navigate, navigateHandler, resetFilters }; +} diff --git a/web/src/features/monitoring-observers/components/EditMonitoringObserver/EditMonitoringObserver.tsx b/web/src/features/monitoring-observers/components/EditMonitoringObserver/EditMonitoringObserver.tsx index 47f99eca6..7e95eaf47 100644 --- a/web/src/features/monitoring-observers/components/EditMonitoringObserver/EditMonitoringObserver.tsx +++ b/web/src/features/monitoring-observers/components/EditMonitoringObserver/EditMonitoringObserver.tsx @@ -18,15 +18,18 @@ import { useNavigate, useRouter } from '@tanstack/react-router'; import { useForm } from 'react-hook-form'; import { z } from 'zod'; import { UpdateMonitoringObserverRequest } from '../../models/monitoring-observer'; +import { MonitorObserverBackButton } from '../MonitoringObserverBackButton'; export default function EditObserver() { const navigate = useNavigate(); const router = useRouter(); const queryClient = useQueryClient(); - const currentElectionRoundId = useCurrentElectionRoundStore(s => s.currentElectionRoundId); + const currentElectionRoundId = useCurrentElectionRoundStore((s) => s.currentElectionRoundId); const { monitoringObserverId } = Route.useParams(); - const monitoringObserverQuery = useSuspenseQuery(monitoringObserverDetailsQueryOptions(currentElectionRoundId, monitoringObserverId)); + const monitoringObserverQuery = useSuspenseQuery( + monitoringObserverDetailsQueryOptions(currentElectionRoundId, monitoringObserverId) + ); const monitoringObserver = monitoringObserverQuery.data; const { data: availableTags } = useMonitoringObserversTags(currentElectionRoundId); @@ -61,11 +64,17 @@ export default function EditObserver() { phoneNumber: values.phoneNumber, }; - editMutation.mutate({electionRoundId: currentElectionRoundId, request}); + editMutation.mutate({ electionRoundId: currentElectionRoundId, request }); } const editMutation = useMutation({ - mutationFn: ({ electionRoundId, request }: { electionRoundId: string; request: UpdateMonitoringObserverRequest }) => { + mutationFn: ({ + electionRoundId, + request, + }: { + electionRoundId: string; + request: UpdateMonitoringObserverRequest; + }) => { return authApi.post( `/election-rounds/${electionRoundId}/monitoring-observers/${monitoringObserver.id}`, request @@ -81,12 +90,17 @@ export default function EditObserver() { queryClient.invalidateQueries({ queryKey: ['monitoring-observers'] }); queryClient.invalidateQueries({ queryKey: ['tags'] }); - navigate({ to: '/monitoring-observers/view/$monitoringObserverId/$tab', params: { monitoringObserverId: monitoringObserver.id, tab: 'details' } }) + navigate({ + to: '/monitoring-observers/view/$monitoringObserverId/$tab', + params: { monitoringObserverId: monitoringObserver.id, tab: 'details' }, + }); }, }); return ( - + }>
@@ -148,10 +162,10 @@ export default function EditObserver() { Tags !field.value.includes(tag)) ?? []} + options={availableTags?.filter((tag) => !field.value.includes(tag)) ?? []} defaultValue={field.value} onValueChange={field.onChange} - placeholder="Observer tags" + placeholder='Observer tags' /> @@ -190,7 +204,12 @@ export default function EditObserver() { - resendInvitationsMutation.mutate({ electionRoundId: currentElectionRoundId, monitoringObserverId })} - {...confirmResendInvitesDialog.dialogProps} /> + onConfirm={() => + resendInvitationsMutation.mutate({ electionRoundId: currentElectionRoundId, monitoringObserverId }) + } + {...confirmResendInvitesDialog.dialogProps} + />
@@ -356,66 +362,7 @@ function MonitoringObserversList() { - {isFiltering ? ( -
- - - -
- Tags -
-
- - {tags?.map((tag) => ( - toggleTagsFilter(tag)} - key={tag}> - {tag} - - ))} - -
- -
- {statusFilter && ( - handleStatusFilter('')} - className='rounded-full cursor-pointer py-1 px-4 bg-purple-100 text-sm text-purple-900 font-medium flex items-center gap-2'> - Observer status: {statusFilter} - - - )} - - {tagsFilter.map((tag) => ( - toggleTagsFilter(tag)} - className='rounded-full cursor-pointer py-1 px-4 bg-purple-100 text-sm text-purple-900 font-medium flex items-center gap-2'> - Tags: {tag} - - - ))} -
-
- ) : ( - '' - )} + {isFiltering && }
{ + const { queryParams, navigateHandler } = useFilteringContainer(); + + const onStatusChange = (value: string) => { + navigateHandler({ [FILTER_KEY.MonitoringObserverStatus]: value }); + }; + + return ( + + ); +}; diff --git a/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx b/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx new file mode 100644 index 000000000..730de3494 --- /dev/null +++ b/web/src/features/monitoring-observers/filtering/MonitoringObserverTagsSelect.tsx @@ -0,0 +1,46 @@ +import { + DropdownMenu, + DropdownMenuCheckboxItem, + DropdownMenuContent, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +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'; + +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 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 }); + }; + + return ( + + +
+ Observer tags +
+
+ + {tags?.map((tag) => ( + toggleTagsFilter(tag)} + key={tag}> + {tag} + + ))} + +
+ ); +}; diff --git a/web/src/features/monitoring-observers/filtering/MonitoringObserversListFilters.tsx b/web/src/features/monitoring-observers/filtering/MonitoringObserversListFilters.tsx new file mode 100644 index 000000000..463d4b46f --- /dev/null +++ b/web/src/features/monitoring-observers/filtering/MonitoringObserversListFilters.tsx @@ -0,0 +1,13 @@ +import { FilteringContainer } from '@/features/filtering/components/FilteringContainer'; +import { MonitoringObserverStatusSelect } from '@/features/monitoring-observers/filtering/MonitoringObserverStatusSelect'; +import { MonitoringObserverTagsSelect } from '@/features/monitoring-observers/filtering/MonitoringObserverTagsSelect'; +import { FC } from 'react'; + +export const MonitoringObserversListFilters: FC = () => { + return ( + + + + + ); +}; diff --git a/web/src/features/responses/components/ResetFiltersButton/ResetFiltersButton.tsx b/web/src/features/responses/components/ResetFiltersButton/ResetFiltersButton.tsx index 5c5ccf4f5..d886db5e7 100644 --- a/web/src/features/responses/components/ResetFiltersButton/ResetFiltersButton.tsx +++ b/web/src/features/responses/components/ResetFiltersButton/ResetFiltersButton.tsx @@ -1,7 +1,7 @@ -import { useNavigate } from '@tanstack/react-router'; +import { useSetPrevSearch } from '@/common/prev-search-store'; import type { FunctionComponent } from '@/common/types'; import { Button } from '@/components/ui/button'; -import { useSetPrevSearch } from '@/common/prev-search-store'; +import { useNavigate } from '@tanstack/react-router'; type ResetFiltersButtonProps = { disabled: boolean;