Skip to content
Open
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
29 changes: 29 additions & 0 deletions web2.0/src/components/badges/monitoring-observer-status-badge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { MonitoringObserverStatus } from '@/types/monitoring-observer'
import { cn } from '@/lib/utils'
import { Badge } from '../ui/badge'

/**
* The status is written out rather than run through `mapMonitoringObserverStatus`:
* that helper looks up `observers.status.*`, and those keys are not in the
* locale files yet, so it would print the key instead of a word.
*/
export default function MonitoringObserverStatusBadge({
status,
}: {
status: MonitoringObserverStatus
}) {
return (
<Badge
className={cn('w-fit', {
'bg-green-200 text-green-600':
status === MonitoringObserverStatus.Active,
'bg-yellow-200 text-yellow-600':
status === MonitoringObserverStatus.Pending,
'bg-slate-200 text-slate-700':
status === MonitoringObserverStatus.Suspended,
})}
>
{status}
</Badge>
)
}
14 changes: 14 additions & 0 deletions web2.0/src/mutations/monitoring-observers-mutations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import { useMutation } from '@tanstack/react-query'
import { resendMonitoringObserverInvites } from '@/services/api/monitoring-observers/resend-invites.api'

/**
* Resending an invitation changes nothing in the list, so there is no query to
* invalidate — the only feedback is the toast the caller raises.
*/
export const useResendMonitoringObserverInvitesMutation = (
electionRoundId: string
) =>
useMutation({
mutationFn: async (observerIds: string[]) =>
await resendMonitoringObserverInvites(electionRoundId, observerIds),
})
23 changes: 12 additions & 11 deletions web2.0/src/pages/NgoAdmin/MonitoringObservers/Page.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import { useDebounce } from "@/hooks/use-debounce";
import { Route } from "@/routes/(app)/elections/$electionRoundId/observers";
import Table from "./components/Table";
import { useListMonitoringObservers } from "@/queries/monitoring-observers";
import { H1, P } from '@/components/ui/typography'
import Table from './components/Table'

function Page() {
const { electionRoundId } = Route.useParams();
const search = Route.useSearch();
const debouncedSearch = useDebounce(search, 200);
const { data } = useListMonitoringObservers(electionRoundId, debouncedSearch);

return <Table data={data} />;
return (
<div className='flex flex-col gap-6'>
<div>
<H1>Observers</H1>
<P>Everyone monitoring this election round on your behalf</P>
</div>
<Table />
</div>
)
}

export default Page;
export default Page
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import { useResendMonitoringObserverInvitesMutation } from '@/mutations/monitoring-observers-mutations'
import { Route } from '@/routes/(app)/elections/$electionRoundId/observers'
import { toast } from 'sonner'
import { ConfirmDialog } from '@/components/ConfirmDialog'
import { useObservers } from './ObserversProvider'

/** The confirmation for the row actions, mounted once beside the table. */
export function ObserversDialogs() {
const { open, setOpen, currentRow } = useObservers()
const { electionRoundId } = Route.useParams()

const resendMutation =
useResendMonitoringObserverInvitesMutation(electionRoundId)

if (!currentRow) {
return null
}

const handleResendInvite = () => {
// The endpoint takes a list because it also serves bulk resending; a single
// row passes an array of one.
resendMutation.mutate([currentRow.id], {
onSuccess: () => {
setOpen(null)
toast.success('Invitation sent')
},
onError: () => {
toast.error('Failed to send the invitation', {
description:
'Please try again or contact support if the problem persists.',
})
},
})
}

return (
<ConfirmDialog
open={open === 'resendInvite'}
onOpenChange={(isOpen) => {
if (!isOpen) {
setOpen(null)
}
}}
handleConfirm={handleResendInvite}
isLoading={resendMutation.isPending}
className='max-w-md'
title='Resend invitation email'
desc={`A new invitation email will be sent to ${currentRow.email}.`}
confirmText='Send'
/>
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import React, { useState } from 'react'
import type { MonitoringObserverModel } from '@/types/monitoring-observer'
import useDialogState from '@/hooks/use-dialog-state'

type ObserversDialogType = 'resendInvite'

type ObserversContextType = {
open: ObserversDialogType | null
setOpen: (str: ObserversDialogType | null) => void
/** The observer the confirmation dialog acts upon. */
currentRow: MonitoringObserverModel | null
setCurrentRow: React.Dispatch<
React.SetStateAction<MonitoringObserverModel | null>
>
}

const ObserversContext = React.createContext<ObserversContextType | null>(null)

/**
* Holds which confirmation is open and on which observer, so the row menus and
* the dialogs read the same state instead of each row owning its own copy.
*/
export function ObserversProvider({ children }: { children: React.ReactNode }) {
const [open, setOpen] = useDialogState<ObserversDialogType>(null)
const [currentRow, setCurrentRow] = useState<MonitoringObserverModel | null>(
null
)

return (
<ObserversContext value={{ open, setOpen, currentRow, setCurrentRow }}>
{children}
</ObserversContext>
)
}

// eslint-disable-next-line react-refresh/only-export-components
export const useObservers = () => {
const observersContext = React.useContext(ObserversContext)

if (!observersContext) {
throw new Error('useObservers has to be used within <ObserversContext>')
}

return observersContext
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import { Link } from '@tanstack/react-router'
import { useCurrentElectionRound } from '@/contexts/election-round.context'
import { Route } from '@/routes/(app)/elections/$electionRoundId/observers'
import { ElectionRoundStatus } from '@/types/election'
import {
MonitoringObserverStatus,
type MonitoringObserverModel,
} from '@/types/monitoring-observer'
import { Ellipsis } from 'lucide-react'
import { Button } from '@/components/ui/button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { useObservers } from './ObserversProvider'

type MonitoringObserverRowActionsProps = {
observer: MonitoringObserverModel
}

export function MonitoringObserverRowActions({
observer,
}: MonitoringObserverRowActionsProps) {
const { electionRoundId } = Route.useParams()
const { electionRound } = useCurrentElectionRound()
const { setOpen, setCurrentRow } = useObservers()

// An archived round is frozen, so nothing about its observers can change.
const isArchived = electionRound?.status === ElectionRoundStatus.Archived

return (
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
variant='ghost'
className='data-[state=open]:bg-muted flex h-8 w-8 p-0'
>
<Ellipsis className='h-4 w-4' />
<span className='sr-only'>Open menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align='end' className='w-[200px]'>
<DropdownMenuItem asChild>
<Link
to='/elections/$electionRoundId/observers/$observerId'
params={{ electionRoundId, observerId: observer.id }}
>
View
</Link>
</DropdownMenuItem>

{/* Editing an observer needs a page that does not exist yet in this app,
so the entry keeps its place in the menu but stays inert. */}
<DropdownMenuItem disabled>Edit</DropdownMenuItem>

{/* Only an observer who has not accepted yet has an invitation worth
sending again. */}
<DropdownMenuItem
disabled={
isArchived || observer.status !== MonitoringObserverStatus.Pending
}
onClick={() => {
setCurrentRow(observer)
setOpen('resendInvite')
}}
>
Resend invitation email
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)
}
54 changes: 29 additions & 25 deletions web2.0/src/pages/NgoAdmin/MonitoringObservers/components/Table.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,54 @@
import React from 'react'
import { useSuspenseListMonitoringObservers } from '@/queries/monitoring-observers'
import { Route } from '@/routes/(app)/elections/$electionRoundId/observers'
import type { PageResponse } from '@/types/common'
import type { DataTableRowAction } from '@/types/data-table'
import type { MonitoringObserverModel } from '@/types/monitoring-observer'
import { useDataTable } from '@/hooks/use-data-table'
import { DataTable } from '@/components/ui/data-table'
import { DataTableToolbar } from '@/components/data-table/data-table-toolbar'
import { ObserversDialogs } from './Dialogs'
import { ObserversProvider } from './ObserversProvider'
import { getMonitoringObserversTableColumns } from './TableColumns'
import TableFilters from './TableFilters'

export interface TableProps {
data?: PageResponse<MonitoringObserverModel>
}
function Table({ data }: TableProps) {
const [rowAction, setRowAction] =
React.useState<DataTableRowAction<MonitoringObserverModel> | null>(null)

function Table() {
const { electionRoundId } = Route.useParams()
const search = Route.useSearch()
const navigate = Route.useNavigate()
const columns = React.useMemo(
() =>
getMonitoringObserversTableColumns({
setRowAction,
}),
[setRowAction]
)

const { data } = useSuspenseListMonitoringObservers(electionRoundId, search)

const columns = React.useMemo(() => getMonitoringObserversTableColumns(), [])

const { table } = useDataTable({
tableName: 'monitoring-observers',
data: data?.items || [],
data: data.items,
columns,
pageCount: data ? Math.ceil(data.totalCount / data.pageSize) : 0,
pageCount:
data.pageSize > 0 ? Math.ceil(data.totalCount / data.pageSize) : 0,
initialState: {
sorting: [{ id: 'displayName', desc: false }],
// Keeps the menu reachable when the table scrolls sideways.
columnPinning: { right: ['actions'] },
},
getRowId: (originalRow) => originalRow.id,
// The search params are named `pageNumber`/`pageSize`, and the hook writes
// `page` unless told otherwise. Without this the page control would put a
// key in the url that `validateSearch` drops, so paging silently did nothing.
pagination: { pageKey: 'pageNumber', pageSizeKey: 'pageSize' },
// Searching is handled by the toolbar's own `searchText` param, not by the
// table's built-in global filter.
globalFilter: { enabled: false },
search,
navigate,
})

return (
<DataTable table={table}>
<DataTableToolbar table={table}>
<TableFilters table={table} />
</DataTableToolbar>
</DataTable>
<ObserversProvider>
<DataTable table={table}>
<DataTableToolbar table={table}>
<TableFilters />
</DataTableToolbar>
</DataTable>
<ObserversDialogs />
</ObserversProvider>
)
}

Expand Down
Loading