Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 19 additions & 16 deletions web/src/components/layout/Breadcrumbs/BackButton.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<svg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30' fill='none'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M19.0607 7.93934C19.6464 8.52513 19.6464 9.47487 19.0607 10.0607L14.1213 15L19.0607 19.9393C19.6464 20.5251 19.6464 21.4749 19.0607 22.0607C18.4749 22.6464 17.5251 22.6464 16.9393 22.0607L10.9393 16.0607C10.3536 15.4749 10.3536 14.5251 10.9393 13.9393L16.9393 7.93934C17.5251 7.35355 18.4749 7.35355 19.0607 7.93934Z'
fill='#7833B3'
/>
</svg>
);
};

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 ? (
<Link search={prevSearch} to='../' preload='intent'>
<svg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30' fill='none'>
<path
fillRule='evenodd'
clipRule='evenodd'
d='M19.0607 7.93934C19.6464 8.52513 19.6464 9.47487 19.0607 10.0607L14.1213 15L19.0607 19.9393C19.6464 20.5251 19.6464 21.4749 19.0607 22.0607C18.4749 22.6464 17.5251 22.6464 16.9393 22.0607L10.9393 16.0607C10.3536 15.4749 10.3536 14.5251 10.9393 13.9393L16.9393 7.93934C17.5251 7.35355 18.4749 7.35355 19.0607 7.93934Z'
fill='#7833B3'
/>
</svg>
</Link>
) : (
''
)}
</>
<Link title='Go back' search={prevSearch} to='../' preload='intent'>
<BackButtonIcon />
</Link>
);
};

Expand Down
13 changes: 7 additions & 6 deletions web/src/components/ui/badge.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,20 @@
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',
{
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: {
Expand All @@ -37,11 +38,11 @@ function FilterBadge({ label, onClear }: FilterBadgeProps): FunctionComponent {
return (
<Badge className='bg-purple-50 text-purple-600 font-medium hover:bg-purple-100 py-2 text-sm gap-2'>
<span>{label}</span>
<button onClick={onClear} type='button'>
<button title='Remove filter' onClick={onClear}>
<XMarkIcon className='w-4' />
</button>
</Badge>
);
}

export { Badge, FilterBadge, badgeVariants };
export { Badge, badgeVariants, FilterBadge };
69 changes: 69 additions & 0 deletions web/src/features/filtering/components/ActiveFilters.tsx
Original file line number Diff line number Diff line change
@@ -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<string, string>([
[FILTER_KEY.MonitoringObserverStatus, FILTER_LABEL.MonitoringObserverStatus],
[FILTER_KEY.MonitoringObserverTags, FILTER_LABEL.MonitoringObserverTags],
]);

const ActiveFilter: FC<ActiveFilterProps> = ({ 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 <FilterBadge label={`${label}: ${value}`} onClear={onClearFilter(filterId, value)} />;
};

interface ActiveFiltersProps {
queryParams: any;
}

export const ActiveFilters: FC<ActiveFiltersProps> = ({ queryParams }) => {
return (
<div className='flex flex-wrap gap-2 col-span-full'>
{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 <ActiveFilter key={key} filterId={filterId} value={value} />;
}
return value.map((item) => {
key = `active-filter-${filterId}-${item}`;

return <ActiveFilter key={key} filterId={filterId} value={item as string} isArray />;
});
})}
</div>
);
};
21 changes: 21 additions & 0 deletions web/src/features/filtering/components/FilteringContainer.tsx
Original file line number Diff line number Diff line change
@@ -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<FilteringContainerProps> = ({ children }) => {
const { filteringIsActive, queryParams, resetFilters } = useFilteringContainer();
return (
<div className='grid items-center grid-cols-6 gap-4'>
{children}
<Button title='Reset filters' disabled={!filteringIsActive} onClick={resetFilters} variant='ghost-primary'>
Reset filters
</Button>
{filteringIsActive && <ActiveFilters queryParams={queryParams} />}
</div>
);
};
39 changes: 39 additions & 0 deletions web/src/features/filtering/components/SelectFilter.tsx
Original file line number Diff line number Diff line change
@@ -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<SelectFilterProps> = (props) => {
const { placeholder, value, options, onChange } = props;

const selectId = crypto.randomUUID();

return (
<Select value={value ?? ''} onValueChange={onChange}>
<SelectTrigger className='w-[180px]'>
<SelectValue placeholder={placeholder} />
</SelectTrigger>
<SelectContent>
<SelectGroup>
{options.map((option) => {
return (
<SelectItem key={`select-${selectId}-${option.value}`} value={option.value}>
{option.label}
</SelectItem>
);
})}
</SelectGroup>
</SelectContent>
</Select>
);
};
11 changes: 11 additions & 0 deletions web/src/features/filtering/filtering-enums.ts
Original file line number Diff line number Diff line change
@@ -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',
}
34 changes: 34 additions & 0 deletions web/src/features/filtering/hooks/useFilteringContainer.ts
Original file line number Diff line number Diff line change
@@ -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<string, any | undefined>) => {
void navigate({
// @ts-ignore
search: (prev) => {
const newSearch: Record<string, string | undefined | string[] | number> = {
...prev,
...search,
};
setPrevSearch(newSearch);
return newSearch;
},
});
},
[navigate, setPrevSearch]
);

const resetFilters = () => {
navigate({});
setPrevSearch({});
};

return { queryParams, filteringIsActive, navigate, navigateHandler, resetFilters };
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<void>(
`/election-rounds/${electionRoundId}/monitoring-observers/${monitoringObserver.id}`,
request
Expand All @@ -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 (
<Layout title={`Edit ${monitoringObserver.firstName} ${monitoringObserver.lastName}`}>
<Layout
title={`Edit ${monitoringObserver.firstName} ${monitoringObserver.lastName}`}
backButton={<MonitorObserverBackButton />}>
<Card className='w-[800px] pt-0'>
<CardHeader className='flex flex-column gap-2'>
<div className='flex flex-row justify-between items-center'>
Expand Down Expand Up @@ -148,10 +162,10 @@ export default function EditObserver() {
<FormLabel className='text-left'>Tags</FormLabel>
<FormControl>
<TagsSelectFormField
options={availableTags?.filter(tag => !field.value.includes(tag)) ?? []}
options={availableTags?.filter((tag) => !field.value.includes(tag)) ?? []}
defaultValue={field.value}
onValueChange={field.onChange}
placeholder="Observer tags"
placeholder='Observer tags'
/>
</FormControl>
<FormMessage />
Expand Down Expand Up @@ -190,7 +204,12 @@ export default function EditObserver() {
<Button
variant='outline'
type='button'
onClick={() => { void navigate({ to: '/monitoring-observers/view/$monitoringObserverId/$tab', params: { monitoringObserverId: monitoringObserver.id, tab: 'details' } }) }}>
onClick={() => {
void navigate({
to: '/monitoring-observers/view/$monitoringObserverId/$tab',
params: { monitoringObserverId: monitoringObserver.id, tab: 'details' },
});
}}>
Cancel
</Button>
<Button type='submit' className='px-6'>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { usePrevSearch } from '@/common/prev-search-store';
import { BackButtonIcon } from '@/components/layout/Breadcrumbs/BackButton';
import { Link } from '@tanstack/react-router';
import { FC } from 'react';

export const MonitorObserverBackButton: FC = () => {
const prevSearch = usePrevSearch();
return (
<Link
title='Go back'
search={prevSearch}
to='/monitoring-observers/$tab/'
params={{ tab: 'list' }}
preload='intent'>
<BackButtonIcon />
</Link>
);
};
Loading