Skip to content

Commit d5cad64

Browse files
authored
[PlatformAdmin] Add UI for observers CRUD (#891)
* fix: make dropdown menus scrollable * fix: truncate overflowing table columns * Squashed commit of the following: commit 742f250 Author: imdeaconu <imdeaconu@gmail.com> Date: Wed Sep 11 19:54:55 2024 +0300 add read notification checkmark commit ea11fa0 Author: imdeaconu <imdeaconu@gmail.com> Date: Wed Sep 11 19:54:30 2024 +0300 add read notification column * Squashed commit of the following: commit d8833dc Author: imdeaconu <imdeaconu@gmail.com> Date: Fri Sep 13 13:29:31 2024 +0300 WIP: add selector functionality commit 3608c0e Author: imdeaconu <imdeaconu@gmail.com> Date: Fri Sep 13 10:00:05 2024 +0300 WIP: create new tags input * chore: remove unused import * chore: delete duplicated / unused classes * feature: add searching to MonitoringObserversTagFilter * chore: update config files * Revert "[NGO Admin] Rewrite the tag selector component (#675)" This reverts commit 2ad0e90. * Merge branch 'main' of https://github.com/commitglobal/votemonitor into commitglobal-main * WIP: rename prop for alternative filter key in Observer Tags and add it to the Push Message form * WIP: fix push messages receipients query not invalidating after edits * invalidate targeted observers query after a morning observer is added * WIP: add password input * add PasswordSetterDialog * fix types and extract logic for adding backend validation info to forms into a reusable function * WIP: clean up and change card header to match the other pages * WIP: fix event propagation on actions row * WIP: update BackButton to allow for a custom rootRoute * WP: use customizable back button in EditObserver * WIP: add observer delete with confirmation * WIP: add observer activation / deactivation * WIP: update business logic for observer editing * WIPL add search and filtering * WIP: sync filtering container visibility state with the filtering state only on first render * WIP: fix observer detail query key * wip: add set password dialog for observers * wip: add modal for adding observer * WIP: update queries and models * WIP: add validation err from backend to the EditObserverForm * WIP: add breadcrumbs with aliases * WIP: use an internal map for breadcrumbs with custom aliases * WIP: add new columns to table * WIP: make phone number optional for observers * WIP: remove tabs from ObserverDetails
1 parent 7388070 commit d5cad64

File tree

14 files changed

+646
-251
lines changed

14 files changed

+646
-251
lines changed

web/src/components/layout/Breadcrumbs/BackButton.tsx

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import { usePrevSearch } from '@/common/prev-search-store';
2-
import type { FunctionComponent } from '@/common/types';
32
import { Link, useRouter } from '@tanstack/react-router';
43
import { FC } from 'react';
54

5+
import type { LinkProps } from '@tanstack/react-router';
6+
67
export const BackButtonIcon: FC = () => {
78
return (
89
<svg xmlns='http://www.w3.org/2000/svg' width='30' height='30' viewBox='0 0 30 30' fill='none'>
@@ -16,15 +17,21 @@ export const BackButtonIcon: FC = () => {
1617
);
1718
};
1819

19-
const BackButton = (): FunctionComponent => {
20+
type OmitStrings<T, U extends string> = T extends U ? never : T;
21+
22+
interface BackButtonProps {
23+
rootRoute?: OmitStrings<LinkProps['to'], '.' | '..'>;
24+
}
25+
26+
const BackButton: FC<BackButtonProps> = ({ rootRoute }) => {
2027
const { latestLocation } = useRouter();
2128
const prevSearch = usePrevSearch();
2229
const links = latestLocation.pathname.split('/').filter((crumb: string) => crumb !== '');
2330

2431
if (links.length <= 1) return <></>;
2532

2633
return (
27-
<Link title='Go back' search={prevSearch} to='..' preload='intent'>
34+
<Link title='Go back' search={prevSearch} to={rootRoute ?? '..'} preload='intent'>
2835
<BackButtonIcon />
2936
</Link>
3037
);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
import { usePrevSearch } from '@/common/prev-search-store';
2+
import { Link, useRouter } from '@tanstack/react-router';
3+
import { FC } from 'react';
4+
5+
type CustomAlias = {
6+
param: string;
7+
alias: string;
8+
};
9+
10+
interface BreadcrumbsWithAliasesProps {
11+
customAliases: CustomAlias[];
12+
}
13+
14+
const DEFAULT_ALIASES = new Map<string, string>([
15+
['observers', 'Observers'],
16+
['form-templates', 'Form templates'],
17+
]);
18+
19+
const INTERNAL_ALIASES = new Map<string, string>(DEFAULT_ALIASES);
20+
21+
export const BreadcrumbsWithAliases: FC<BreadcrumbsWithAliasesProps> = ({ customAliases }) => {
22+
const { latestLocation } = useRouter();
23+
const prevSearch = usePrevSearch();
24+
25+
customAliases.forEach((aliasData) => INTERNAL_ALIASES.set(aliasData.param, aliasData.alias));
26+
27+
let currentLink: string = '';
28+
29+
const crumbs = latestLocation.pathname
30+
.split('/')
31+
.filter((crumb) => crumb !== '')
32+
.map((crumb) => {
33+
currentLink += `/${crumb}`;
34+
35+
return (
36+
<Link className='crumb' key={crumb} search={prevSearch} to={currentLink} preload='intent'>
37+
{INTERNAL_ALIASES.get(crumb) ?? crumb}
38+
</Link>
39+
);
40+
});
41+
42+
return <>{crumbs.length > 1 ? <div className='breadcrumbs flex flex-row gap-2 mb-4'>{crumbs}</div> : ''}</>;
43+
};

web/src/features/filtering/components/PlatformAdminActiveFilters.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ const FILTER_LABELS = new Map<string, string>([
3636
[FILTER_KEY.FormTemplateTypeFilter, FILTER_LABEL.FormTemplateType],
3737
[FILTER_KEY.CountryIdFilter, FILTER_LABEL.CountryId],
3838
[FILTER_KEY.ElectionRoundStatusFilter, FILTER_LABEL.ElectionRoundStatus],
39+
[FILTER_KEY.ObserverStatus, FILTER_LABEL.ObserverStatus],
3940
]);
4041

4142
const FILTER_VALUE_LOCALIZATORS = new Map<string, (value: any) => string>([

web/src/features/filtering/filtering-enums.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export const enum FILTER_KEY {
3434
CoalitionMemberId = 'coalitionMemberId',
3535
CountryIdFilter = 'countryId',
3636
ElectionRoundStatusFilter = 'electionRoundStatus',
37+
ObserverStatus = 'observerStatus',
3738
}
3839

3940
export const enum FILTER_LABEL {
@@ -65,4 +66,5 @@ export const enum FILTER_LABEL {
6566
CoalitionMemberId = 'NGO',
6667
CountryId = 'Country',
6768
ElectionRoundStatus = 'Election round status',
69+
ObserverStatus = 'Observer status',
6870
}

web/src/features/filtering/hooks/useFilteringContainer.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { useSetPrevSearch } from '@/common/prev-search-store';
22
import { useNavigate, useSearch } from '@tanstack/react-router';
3-
import { useCallback, useEffect, useMemo, useState } from 'react';
3+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
44
import { HIDDEN_FILTERS } from '../common';
55
import { FILTER_KEY } from '../filtering-enums';
66

@@ -11,6 +11,7 @@ function filterObject<T extends object>(obj: T, keysToRemove: FILTER_KEY[]): Par
1111
export function useFilteringContainer() {
1212
const navigate = useNavigate();
1313
const queryParams = useSearch({ strict: false });
14+
const hasRenderedBefore = useRef(false);
1415

1516
const setPrevSearch = useSetPrevSearch();
1617
const filteringIsActive = useMemo(() => {
@@ -22,6 +23,8 @@ export function useFilteringContainer() {
2223
const [isFilteringContainerVisible, setIsFilteringContainerVisible] = useState(filteringIsActive);
2324

2425
useEffect(() => {
26+
if (hasRenderedBefore.current) return;
27+
hasRenderedBefore.current = true;
2528
setIsFilteringContainerVisible(filteringIsActive);
2629
}, [filteringIsActive]);
2730

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
import { Button } from '@/components/ui/button';
2+
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogTitle } from '@/components/ui/dialog';
3+
import { Form, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
4+
import { Input } from '@/components/ui/input';
5+
import { PasswordInput } from '@/components/ui/password-input';
6+
import { zodResolver } from '@hookform/resolvers/zod';
7+
import { useForm } from 'react-hook-form';
8+
import { useTranslation } from 'react-i18next';
9+
import { useObserverMutations } from '../hooks/observers-queries';
10+
import { AddObserverFormData, addObserverFormSchema } from '../models/observer';
11+
12+
export interface CreateObserverDialogProps {
13+
open: boolean;
14+
onOpenChange: (open: any) => void;
15+
}
16+
17+
function CreateObserverDialog({ open, onOpenChange }: CreateObserverDialogProps) {
18+
const { t } = useTranslation('translation', { keyPrefix: 'observers.addObserver' });
19+
const { addObserverMutation } = useObserverMutations();
20+
21+
const form = useForm<AddObserverFormData>({
22+
mode: 'all',
23+
resolver: zodResolver(addObserverFormSchema),
24+
});
25+
26+
function onSubmit(values: AddObserverFormData) {
27+
addObserverMutation.mutate({ values, form, onOpenChange });
28+
}
29+
30+
return (
31+
<Dialog open={open} onOpenChange={onOpenChange} modal={true}>
32+
<DialogContent
33+
className='min-w-[650px] min-h-[350px]'
34+
onInteractOutside={(e) => {
35+
e.preventDefault();
36+
}}
37+
onEscapeKeyDown={(e) => {
38+
e.preventDefault();
39+
}}>
40+
<DialogTitle className='mb-3.5'>{t('title')}</DialogTitle>
41+
<div className='flex flex-col gap-3'>
42+
<Form {...form}>
43+
<form onSubmit={form.handleSubmit(onSubmit)} className='space-y-4'>
44+
<FormField
45+
control={form.control}
46+
name='firstName'
47+
render={({ field, fieldState }) => (
48+
<FormItem>
49+
<FormLabel>{t('firstName')}</FormLabel>
50+
<Input placeholder={t('firstName')} {...field} {...fieldState} />
51+
<FormMessage />
52+
</FormItem>
53+
)}
54+
/>
55+
56+
<FormField
57+
control={form.control}
58+
name='lastName'
59+
render={({ field, fieldState }) => (
60+
<FormItem>
61+
<FormLabel>{t('lastName')}</FormLabel>
62+
<Input placeholder={t('lastName')} {...field} {...fieldState} />
63+
<FormMessage />
64+
</FormItem>
65+
)}
66+
/>
67+
68+
<FormField
69+
control={form.control}
70+
name='email'
71+
render={({ field, fieldState }) => (
72+
<FormItem>
73+
<FormLabel>{t('email')}</FormLabel>
74+
<Input placeholder={t('email')} {...field} {...fieldState} />
75+
<FormMessage />
76+
</FormItem>
77+
)}
78+
/>
79+
80+
<FormField
81+
control={form.control}
82+
name='phoneNumber'
83+
render={({ field, fieldState }) => (
84+
<FormItem>
85+
<FormLabel>{t('phone')}</FormLabel>
86+
<Input placeholder={t('phone')} {...field} {...fieldState} />
87+
<FormMessage />
88+
</FormItem>
89+
)}
90+
/>
91+
92+
<FormField
93+
control={form.control}
94+
name='password'
95+
render={({ field, fieldState }) => (
96+
<FormItem>
97+
<FormLabel>Password</FormLabel>
98+
<PasswordInput placeholder='Password' {...field} {...fieldState} />
99+
100+
<FormMessage />
101+
</FormItem>
102+
)}
103+
/>
104+
105+
<DialogFooter>
106+
<DialogClose asChild>
107+
<Button className='text-purple-900 border border-purple-900 border-input bg-background hover:bg-purple-50 hover:text-purple-600'>
108+
Cancel
109+
</Button>
110+
</DialogClose>
111+
<Button title={t('addBtnText')} type='submit' className='px-6'>
112+
{t('addBtnText')}
113+
</Button>
114+
</DialogFooter>
115+
</form>
116+
</Form>
117+
</div>
118+
</DialogContent>
119+
</Dialog>
120+
);
121+
}
122+
123+
export default CreateObserverDialog;

0 commit comments

Comments
 (0)