Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement elections list #6

Merged
merged 13 commits into from
Jun 17, 2024
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "vocdoni-explorer-v3",
"name": "vocdoni-explorer",
"private": true,
"description": "Frontend to explore the Vocdoni voting blockchain",
"license": "unlicense",
Expand All @@ -19,7 +19,7 @@
"@emotion/styled": "^11.10.6",
"@tanstack/react-query": "^5.40.0",
"@vocdoni/chakra-components": "~0.8.1",
"@vocdoni/extended-sdk": "^0.0.1",
"@vocdoni/extended-sdk": "^0.0.2",
"@vocdoni/sdk": "~0.8.1",
"date-fns": "^2.29.3",
"ethers": "^5.7.2",
Expand Down
30 changes: 27 additions & 3 deletions src/components/Organizations/Card.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Box, Card, CardBody, Text } from '@chakra-ui/react'
import { Box, Card, CardBody, Flex, Text } from '@chakra-ui/react'
import { Trans, useTranslation } from 'react-i18next'
import { ReducedTextAndCopy } from '~components/CopyBtn'
import { OrganizationProvider, useOrganization } from '@vocdoni/react-providers'
Expand All @@ -12,12 +12,12 @@ interface IOrganizationCardProps {
const OrganizationCard = ({ id, ...rest }: IOrganizationCardProps) => {
return (
<OrganizationProvider id={id}>
<OrganizationCardContent id={id} {...rest} />
<LargeOrganizationCard id={id} {...rest} />
</OrganizationProvider>
)
}

const OrganizationCardContent = ({ id, electionCount }: IOrganizationCardProps) => {
const LargeOrganizationCard = ({ id, electionCount }: IOrganizationCardProps) => {
const { organization, loading } = useOrganization()
const { t } = useTranslation()

Expand Down Expand Up @@ -53,4 +53,28 @@ const OrganizationCardContent = ({ id, electionCount }: IOrganizationCardProps)
)
}

export const SmallOrganizationCard = ({ id }: IOrganizationCardProps) => {
const { organization } = useOrganization()
const { t } = useTranslation()

const name = organization?.account.name.default || organization?.address

return (
<Flex direction={'row'} alignItems='center' overflow={'scroll'} gap={2}>
<Box w={'25px'}>
<Avatar
mx='auto'
fallbackSrc={'/images/fallback-account-dark.png'}
alt={t('organization.avatar_alt', {
name: organization?.account.name.default || organization?.address,
}).toString()}
/>
</Box>
<ReducedTextAndCopy color={'textAccent1'} size='sm' toCopy={id}>
{name}
</ReducedTextAndCopy>
</Flex>
)
}

export default OrganizationCard
15 changes: 5 additions & 10 deletions src/components/Organizations/OrganizationsList.tsx
Original file line number Diff line number Diff line change
@@ -1,31 +1,26 @@
import { ChangeEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { InputSearch } from '~src/layout/Inputs'
import { useOrganizationCount, useOrganizationList } from '~queries/organizations'
import { generatePath, useNavigate, useParams } from 'react-router-dom'
import OrganizationCard from '~components/Organizations/Card'
import { RoutedPagination } from '~components/Pagination/Pagination'
import { RoutedPaginationProvider } from '~components/Pagination/PaginationProvider'
import { useOrganizationCount, useOrganizationList } from '~queries/organizations'
import { InputSearch } from '~src/layout/Inputs'
import { LoadingCards } from '~src/layout/Loading'
import LoadingError from '~src/layout/LoadingError'
import { organizationsListPath } from '~src/router'
import { debounce } from '~utils/debounce'

export const OrganizationsFilter = () => {
const { t } = useTranslation()
const navigate = useNavigate()

const debouncedSearch = debounce((value) => {
navigate(generatePath(organizationsListPath, { page: '1', query: value as string }))
}, 1000)

return (
<InputSearch
maxW={'300px'}
placeholder={t('organizations.search_by_org_id')}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
debouncedSearch(event.target.value)
onChange={(value: string) => {
navigate(generatePath(organizationsListPath, { page: '0', query: value as string }))
}}
debounceTime={500}
/>
)
}
Expand Down
20 changes: 20 additions & 0 deletions src/components/Organizations/StatusBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import React from 'react'
import { ElectionStatus } from '@vocdoni/sdk'
import { ElectionStatusBadge as ComponentsStatusBadge } from '@vocdoni/chakra-components'

export const ElectionStatusBadge = ({ status }: { status: ElectionStatus }) => {
switch (status) {
case ElectionStatus.ONGOING:
return <ComponentsStatusBadge bg={'#f3fccc'} color={'#74af07'} variant={'vocdoni'} />
case ElectionStatus.RESULTS:
case ElectionStatus.ENDED:
return <ComponentsStatusBadge bg={'#fff3d6'} color={'#db7d24'} variant={'vocdoni'} />
case ElectionStatus.UPCOMING:
return <ComponentsStatusBadge bg={'#d1fdfa'} color={'#1588b9'} variant={'vocdoni'} />
case ElectionStatus.PAUSED:
case ElectionStatus.CANCELED:
return <ComponentsStatusBadge bg={'#fee4d6'} color={'#d62736'} variant={'vocdoni'} />
default:
return <></>
}
}
51 changes: 51 additions & 0 deletions src/components/Process/Card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { Card, CardBody, Flex, HStack } from '@chakra-ui/react'
import { ElectionProvider, OrganizationProvider, useElection } from '@vocdoni/react-providers'
import { InvalidElection, PublishedElection } from '@vocdoni/sdk'
import { ElectionSchedule, ElectionTitle } from '@vocdoni/chakra-components'
import { ElectionStatusBadge } from '~components/Organizations/StatusBadge'
import { SmallOrganizationCard } from '~components/Organizations/Card'

/**
* Show election card information
* @param id If id provided it will fetch the election from the API
* @param election already loaded election info to show
* @constructor
*/
const ElectionCard = ({ id, election }: { id?: string; election?: PublishedElection }) => {
return (
<ElectionProvider id={id} election={election}>
<ElectionCardContent />
</ElectionProvider>
)
}

const ElectionCardContent = () => {
const { election } = useElection()

if (election instanceof InvalidElection || !election) return null

return (
<Card direction={'row'} alignItems='center' overflow={'scroll'} pl={4}>
<CardBody>
<Flex direction={'column'} align={'start'} gap={4}>
<HStack>
<ElectionStatusBadge status={election.status} />
<ElectionSchedule
showRemaining
fontWeight={'normal'}
fontSize={'sm'}
textAlign={'start'}
fontStyle={'normal'}
/>
</HStack>
<ElectionTitle textAlign={'start'} fontWeight={'bold'} wordBreak='break-all' fontSize='lg' />
</Flex>
<OrganizationProvider id={election.organizationId}>
<SmallOrganizationCard id={election.organizationId} />
</OrganizationProvider>
</CardBody>
</Card>
)
}

export default ElectionCard
126 changes: 126 additions & 0 deletions src/components/Process/ProcessList.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { useParams } from 'react-router-dom'
import { RoutedPaginationProvider } from '~components/Pagination/PaginationProvider'
import { RoutedPagination } from '~components/Pagination/Pagination'
import LoadingError from '~src/layout/LoadingError'
import { LoadingCards } from '~src/layout/Loading'
import { useProcessesCount, useProcessList } from '~queries/processes'
import ElectionCard from './Card'
import { processListPath } from '~src/router'
import { Trans, useTranslation } from 'react-i18next'
import useQueryParams from '~src/router/use-query-params'
import { InputSearch } from '~src/layout/Inputs'
import { IElectionListFilter } from '@vocdoni/sdk'
import { Button, Checkbox, Flex } from '@chakra-ui/react'
import { isEmpty } from '~utils/objects'

type FilterQueryParams = {
[K in keyof Omit<IElectionListFilter, 'organizationId'>]: string
}

export const ProcessSearchBox = () => {
const { t } = useTranslation()
const { queryParams, setQueryParams } = useQueryParams<FilterQueryParams>()

return (
<Flex direction={{ base: 'column', lg: 'row' }} align={'center'} justify={'end'} gap={4}>
<Checkbox
onChange={(e) => setQueryParams({ ...queryParams, withResults: e.target.checked ? 'true' : undefined })}
>
<Trans i18nKey='process.show_with_results'>Show only processes with results</Trans>
</Checkbox>
<Flex justify='flex-end'>
<InputSearch
maxW={'300px'}
placeholder={t('process.search_by')}
onChange={(value: string) => {
setQueryParams({ ...queryParams, electionId: value })
}}
debounceTime={500}
/>
</Flex>
</Flex>
)
}

export const ProcessByTypeFilter = () => {
const { t } = useTranslation()
const { queryParams, setQueryParams } = useQueryParams<FilterQueryParams>()

const processStatusFilters = [
{
label: t('process.by_status_all'),
value: undefined,
},
{
label: t('process.by_status_all_active'),
value: 'READY',
},
{
label: t('process.by_status_paused'),
value: 'PAUSED',
},
{
label: t('process.by_status_ended'),
value: 'ENDED',
},
]

return (
<Flex align={'center'} justify={'center'} gap={4} wrap={'wrap'}>
{processStatusFilters.map((filter, i) => (
<Button
flex={{ base: 'none', md: '1' }}
key={i}
onClick={() => setQueryParams({ ...queryParams, status: filter.value })}
>
{filter.label}
</Button>
))}
</Flex>
)
}

export const PaginatedProcessList = () => {
const { page }: { page?: number } = useParams()
const { data: processCount, isLoading: isLoadingCount } = useProcessesCount()
const count = processCount || 0
const { queryParams: processFilters } = useQueryParams<FilterQueryParams>()

// If no filters applied we can calculate the total pages using process total count
let totalPages: number | undefined = undefined
if (isEmpty(processFilters)) {
totalPages = Math.ceil(count / 10)
}

const {
data: processes,
isLoading: isLoadingOrgs,
isError,
error,
} = useProcessList({
page: Number(page || 0),
filters: {
electionId: processFilters.electionId,
// organizationId: processFilters.electionId,
status: processFilters.status as IElectionListFilter['status'],
withResults: processFilters.withResults ? processFilters.withResults === 'true' : undefined,
},
})

const isLoading = isLoadingCount || isLoadingOrgs

if (isLoading) {
return <LoadingCards />
}

if (!processes || processes?.elections.length === 0 || isError) {
return <LoadingError error={error} />
}

return (
<RoutedPaginationProvider totalPages={totalPages} path={processListPath}>
{processes?.elections.map((election, i) => <ElectionCard key={i} election={election} />)}
<RoutedPagination />
</RoutedPaginationProvider>
)
}
4 changes: 2 additions & 2 deletions src/components/TopBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'
import { RxHamburgerMenu } from 'react-icons/rx'
import { generatePath } from 'react-router-dom'
import { VocdoniEnvironment } from '~constants'
import { organizationsListPath } from '~src/router'
import { organizationsListPath, processListPath } from '~src/router'

import logoUrl from '/images/logo-header.png'
import logoStgUrl from '/images/logo-header-stg.png'
Expand Down Expand Up @@ -52,7 +52,7 @@ export const TopBar = () => {
},
{
name: t('links.processes'),
url: '',
url: generatePath(processListPath, { page: null }),
},
{
name: t('links.blocks'),
Expand Down
22 changes: 20 additions & 2 deletions src/layout/Inputs.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,31 @@
import { Input, InputGroup, InputLeftElement, InputProps } from '@chakra-ui/react'
import { BiSearchAlt } from 'react-icons/bi'
import { debounce } from '~utils/debounce'
import { ChangeEvent } from 'react'

export const InputSearch = ({
debounceTime = 0,
onChange,
...props
}: {
debounceTime?: number
onChange?: (event: string) => void
} & Omit<InputProps, 'onChange'>) => {
const debouncedSearch = debounce((value: string) => {
if (onChange) onChange(value)
}, debounceTime)

export const InputSearch = (props: InputProps) => {
return (
<InputGroup>
<InputLeftElement pointerEvents='none'>
<BiSearchAlt color={'lightText'} />
</InputLeftElement>
<Input {...props} />
<Input
{...props}
onChange={(event: ChangeEvent<HTMLInputElement>) => {
debouncedSearch(event.target.value)
}}
/>
</InputGroup>
)
}
10 changes: 7 additions & 3 deletions src/layout/ListPageLayout.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Box, Flex, Heading, Text } from '@chakra-ui/react'
import { Flex, Heading, Text } from '@chakra-ui/react'
import { PropsWithChildren, ReactNode } from 'react'

const ListPageLayout = ({
Expand All @@ -13,14 +13,18 @@ const ListPageLayout = ({
} & PropsWithChildren) => {
return (
<Flex direction={'column'} mt={'40px'} gap={6}>
<Flex direction={{ base: 'column', md: 'row' }} justify={'space-between'}>
<Flex direction={{ base: 'column', md: 'row' }} justify={'space-between'} gap={4}>
<Flex direction={'column'}>
<Heading isTruncated wordBreak='break-word'>
{title}
</Heading>
{subtitle && <Text color={'lighterText'}>{subtitle}</Text>}
</Flex>
{rightComponent && <Box>{rightComponent}</Box>}
{rightComponent && (
<Flex align={'end'} justify={'end'} w={'full'}>
{rightComponent}
</Flex>
)}
</Flex>
{children}
</Flex>
Expand Down
Loading
Loading