From 1267cf9507a742ea9c10478a9fe1a30e6d9e0bb8 Mon Sep 17 00:00:00 2001 From: MK BeefCake <69617937+mkbeefcake@users.noreply.github.com> Date: Tue, 19 Dec 2023 08:36:25 -0800 Subject: [PATCH] Add an election progress bar (#4556) * Fix 4006: ElectionProgressBar.tsx * Fix 4006: adjusting the tooltip's arrow to progress * Fix 4006 : fix lint issue * Fix 4006 : Fixed test.tsx * Fix 4006 : Updted the progress bar description by following figma * Fix 4006: updated the code by Thesan's suggestion * Updated progress bar to remove idle block and fix comment issue * Added storybook and removed idle section since idlePeriod == 1 * adjust sample value for currentBlock in stories.tsx * moved the idle period to the first * Updated the changes for UI * Updated the suggestion * refactored stage parameters calculation * Refactoerd description update code * Merge dev into branch * Fixed the issue after merged dev * yarn lint:fix * Updated the code by following Thesan's * added useCouncilPeriodInformation * updated the election.tsx * updated the code * lint issue * Remove `remainingPeriod` check * Updated for responsive layout * updated responsive card & added genersis block timestamp * updated date calculation --------- Co-authored-by: Mark Gui Co-authored-by: Theophile Sandoz --- .../app/pages/Election/Election.stories.tsx | 163 +++++++ .../ui/src/app/pages/Election/Election.tsx | 42 +- .../components/ElectionProgressBar.tsx | 445 ++++++++++++++++++ .../components/ElectionProgressCardItem.tsx | 107 +++++ .../components/statistics/StatisticItem.tsx | 6 +- .../hooks/useCouncilPeriodInformation.ts | 44 ++ .../ui/test/council/pages/Election.test.tsx | 9 +- 7 files changed, 789 insertions(+), 27 deletions(-) create mode 100644 packages/ui/src/app/pages/Election/Election.stories.tsx create mode 100644 packages/ui/src/app/pages/Election/components/ElectionProgressBar.tsx create mode 100644 packages/ui/src/app/pages/Election/components/ElectionProgressCardItem.tsx create mode 100644 packages/ui/src/council/hooks/useCouncilPeriodInformation.ts diff --git a/packages/ui/src/app/pages/Election/Election.stories.tsx b/packages/ui/src/app/pages/Election/Election.stories.tsx new file mode 100644 index 0000000000..207f581c6d --- /dev/null +++ b/packages/ui/src/app/pages/Election/Election.stories.tsx @@ -0,0 +1,163 @@ +import { Meta, StoryContext, StoryObj } from '@storybook/react' +import { FC } from 'react' + +import { GetCurrentElectionDocument } from '@/council/queries' +import { joy } from '@/mocks/helpers' +import { MocksParameters } from '@/mocks/providers' + +import { Election } from './Election' + +type Args = { + electionStage: 'announcing' | 'revealing' | 'voting' | 'inactive' + remainingPeriod: number +} + +type Story = StoryObj> + +export default { + title: 'Pages/Election/Election', + component: Election, + argTypes: { + electionStage: { + control: { type: 'radio' }, + options: ['inactive', 'announcing', 'voting', 'revealing'], + }, + }, + args: { + electionStage: 'announcing', + remainingPeriod: 10000, + }, + parameters: { + currentBlock: 480_2561, + idlePeriodDuration: 14400, + budgetRefillPeriod: 14400, + announcingPeriodDuration: 129600, + voteStageDuration: 43200, + revealStageDuration: 43200, + mocks: ({ args, parameters }: StoryContext): MocksParameters => { + const councilStageDuration = + parameters[args.electionStage === 'inactive' ? 'idlePeriodDuration' : 'announcingPeriodDuration'] + return { + chain: { + consts: { + council: { + councilSize: 3, + idlePeriodDuration: parameters.idlePeriodDuration, + announcingPeriodDuration: parameters.announcingPeriodDuration, + budgetRefillPeriod: parameters.budgetRefillPeriod, + minCandidateStake: joy(166_666.666666), + }, + referendum: { + voteStageDuration: parameters.voteStageDuration, + revealStageDuration: parameters.revealStageDuration, + minimumStake: joy(166.666666666), + }, + }, + rpc: { + chain: { + subscribeNewHeads: { + number: parameters.currentBlock, + }, + }, + }, + query: { + council: { + stage: { + stage: { + isIdle: args.electionStage === 'inactive', + isAnnouncing: args.electionStage === 'announcing', + }, + changedAt: Math.max(0, parameters.currentBlock - councilStageDuration + args.remainingPeriod), + }, + }, + referendum: { + stage: { + isVoting: args.electionStage === 'voting', + isRevealing: args.electionStage === 'revealing', + asVoting: { + started: Math.max(0, parameters.currentBlock - parameters.voteStageDuration + args.remainingPeriod), + }, + asRevealing: { + started: Math.max(0, parameters.currentBlock - parameters.revealStageDuration + args.remainingPeriod), + }, + }, + }, + }, + }, + gql: { + queries: [ + { + query: GetCurrentElectionDocument, + data: { + electionRounds: [ + { + __typename: 'ElectionRound', + cycleId: 23, + candidates: [ + { + id: '0000003s', + member: { + id: '0', + name: 'Jennifer_123', + rootAccount: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + controllerAccount: '5GrwvaEF5zXb26Fz9rcQpDWS57CtERHpNehXCPcNoHGKutQY', + handle: 'Jennifer_123', + isVerified: true, + isFoundingMember: true, + isCouncilMember: false, + roles: [ + { + __typename: 'Worker', + id: 'membershipWorkingGroup-0', + createdAt: '2021', + isLead: true, + group: { + __typename: 'WorkingGroup', + name: 'Jennifer_123', + }, + }, + ], + boundAccounts: [], + inviteCount: 0, + createdAt: '', + metadata: { + name: 'Jennifer_123', + }, + }, + noteMetadata: { + header: 'Jennifer_123', + bannerUri: + 'https://upload.wikimedia.org/wikipedia/commons/b/be/Bliss_location%2C_Sonoma_Valley_in_2006.jpg', + bulletPoints: [ + 'Amet minim mollit non deserunt ullamco est sit liqua dolor', + 'Amet minim mollit non deserunt ullamco est sit liqua dolor', + 'Amet minim mollit non deserunt ullamco est sit liqua dolor Amet minim mollit non deserunt ullamco est sit liqua dolor Amet minim mollit non deserunt ullamco est sit liqua dolor Amet minim mollit non deserunt ullamco est sit liqua dolor', + ], + description: 'Test member', + }, + stake: '16660000000000', + stakingAccountId: 'j4Sba211111111', + status: 'ACTIVE', + votesReceived: [], + }, + ], + }, + ], + }, + }, + ], + }, + } + }, + }, +} satisfies Meta + +export const Announcing: Story = { + args: { electionStage: 'announcing' }, +} +export const Voting: Story = { + args: { electionStage: 'voting' }, +} +export const Revealing: Story = { + args: { electionStage: 'revealing' }, +} diff --git a/packages/ui/src/app/pages/Election/Election.tsx b/packages/ui/src/app/pages/Election/Election.tsx index 19f01235c9..78434d7e57 100644 --- a/packages/ui/src/app/pages/Election/Election.tsx +++ b/packages/ui/src/app/pages/Election/Election.tsx @@ -1,5 +1,6 @@ import React, { useEffect } from 'react' import { useHistory } from 'react-router-dom' +import styled from 'styled-components' import { PageHeaderWithButtons, PageHeaderWrapper, PageLayout } from '@/app/components/PageLayout' import { ButtonsGroup, CopyButtonTemplate } from '@/common/components/buttons' @@ -7,10 +8,10 @@ import { LinkIcon } from '@/common/components/icons' import { Loading } from '@/common/components/Loading' import { MainPanel } from '@/common/components/page/PageContent' import { PageTitle } from '@/common/components/page/PageTitle' -import { BlockDurationStatistics, StatisticItem, Statistics } from '@/common/components/statistics' +import { StatisticItem, Statistics } from '@/common/components/statistics' import { TextHuge } from '@/common/components/typography' -import { camelCaseToText } from '@/common/helpers' import { useRefetchQueries } from '@/common/hooks/useRefetchQueries' +import { useResponsive } from '@/common/hooks/useResponsive' import { MILLISECONDS_PER_BLOCK } from '@/common/model/formatters' import { getUrl } from '@/common/utils/getUrl' import { AnnounceCandidacyButton } from '@/council/components/election/announcing/AnnounceCandidacyButton' @@ -21,11 +22,11 @@ import { RevealingStage } from '@/council/components/election/revealing/Revealin import { VotingStage } from '@/council/components/election/voting/VotingStage' import { ElectionRoutes } from '@/council/constants' import { useCandidatePreviewViaUrlParameter } from '@/council/hooks/useCandidatePreviewViaUrlParameter' -import { useCouncilRemainingPeriod } from '@/council/hooks/useCouncilRemainingPeriod' import { useCurrentElection } from '@/council/hooks/useCurrentElection' import { useElectionStage } from '@/council/hooks/useElectionStage' import { Election as ElectionType } from '@/council/types/Election' +import { ElectionProgressBar } from './components/ElectionProgressBar' import { ElectionTabs } from './components/ElectionTabs' const displayElectionRound = (election: ElectionType | undefined): string => { @@ -37,10 +38,10 @@ const displayElectionRound = (election: ElectionType | undefined): string => { } export const Election = () => { + const { size } = useResponsive() const { isLoading: isLoadingElection, election } = useCurrentElection() const { isLoading: isLoadingElectionStage, stage: electionStage } = useElectionStage() - const remainingPeriod = useCouncilRemainingPeriod() const history = useHistory() useCandidatePreviewViaUrlParameter() @@ -87,29 +88,21 @@ export const Election = () => { const main = ( - + - {camelCaseToText(electionStage)} Period - - - {displayElectionRound(election)} - + + {electionStage === 'announcing' && ( )} @@ -120,3 +113,12 @@ export const Election = () => { return } + +const StyledStatistics = styled(Statistics)<{ size: string }>` + grid-template-columns: ${({ size }) => + size === 'xxs' + ? 'repeat(auto-fit, minmax(238px, 1fr))' + : size === 'xs' + ? 'repeat(auto-fit, minmax(400px, 1fr))' + : '200px minmax(400px, 1fr)'}; +` diff --git a/packages/ui/src/app/pages/Election/components/ElectionProgressBar.tsx b/packages/ui/src/app/pages/Election/components/ElectionProgressBar.tsx new file mode 100644 index 0000000000..85d1bf2185 --- /dev/null +++ b/packages/ui/src/app/pages/Election/components/ElectionProgressBar.tsx @@ -0,0 +1,445 @@ +import { clamp } from 'lodash' +import React, { useState, useEffect, useMemo } from 'react' +import ReactDOM from 'react-dom' +import { usePopper } from 'react-popper' +import styled from 'styled-components' + +import { List, ListItem } from '@/common/components/List' +import { ProgressBar, ProgressBarProps } from '@/common/components/Progress' +import { + MultiStatisticItem, + StatisticItemSpacedContent, + StatisticLabel, + StatisticItemProps, +} from '@/common/components/statistics' +import { TooltipText } from '@/common/components/Tooltip' +import { TextSmall } from '@/common/components/typography' +import { DurationValue } from '@/common/components/typography/DurationValue' +import { AN_HOUR, A_DAY, A_MINUTE, BorderRad, Colors, Transitions, ZIndex } from '@/common/constants' +import { useResponsive } from '@/common/hooks/useResponsive' +import { splitDuration, MILLISECONDS_PER_BLOCK } from '@/common/model/formatters' +import { useCouncilConstants } from '@/council/hooks/useCouncilConstants' +import { useCouncilPeriodInformation } from '@/council/hooks/useCouncilPeriodInformation' + +import { ElectionProgressCardItem } from './ElectionProgressCardItem' + +interface ElectionProgressBarProps extends StatisticItemProps { + electionStage: string +} + +const blockDurationToMs = (blockDuration: number) => blockDuration * MILLISECONDS_PER_BLOCK +const blockToDate = (duration: number) => { + const now = Date.now() + const msDuration = blockDurationToMs(duration) + const date = new Date(now + msDuration) + return `${date.toLocaleString('en-gb', { timeZone: 'Europe/Paris', dateStyle: 'short', timeStyle: 'short' })} CET` +} +const blockDurationToDays = (blockDuration: number) => Math.floor(blockDurationToMs(blockDuration) / A_DAY) + +const Duration = ({ duration }: { duration: number }) => { + const format = splitDuration([ + [A_DAY, 'd'], + [AN_HOUR, 'h'], + [A_MINUTE, 'm'], + ]) + + const formatDurationDate = (duration: number): [string | number, string][] => { + if (duration * MILLISECONDS_PER_BLOCK < A_MINUTE) { + return [['< 1', 'm']] + } + return format(duration * MILLISECONDS_PER_BLOCK) + } + + return +} + +export const ElectionProgressBar = (props: ElectionProgressBarProps) => { + const { size } = useResponsive() + + const periodInformation = useCouncilPeriodInformation() + const currentBlock = periodInformation?.currentBlock ?? 0 + const remainingPeriod = periodInformation?.remainingPeriod ?? 0 + + const endDayOfStage = useMemo(() => , [remainingPeriod]) + + const [stageDescription, setStageDescription] = useState(props.electionStage) + const [verbIndicator, setVerbIndicator] = useState('ends in') + const [daysIndicator, setDaysIndicator] = useState(endDayOfStage) + const [selectedToolbarStage, setSelectedToolbarStage] = useState('') + + useEffect(() => { + if (selectedToolbarStage === '') updateDescription(selectedToolbarStage, false) + else updateDescription(selectedToolbarStage, true) + }, [endDayOfStage]) + + let announcingDays = 0 + let votingDays = 0 + let revealingDays = 0 + let inactiveDays = 0 + + let progressBarAttr = '1fr 14fr 3fr 3fr' + + const constants = useCouncilConstants() + + const [inactiveEndBlock, announcingEndBlock, votingEndBlock, revealingEndBlock] = periodInformation?.periodEnds ?? [] + const endDates = useMemo( + () => periodInformation?.periodEnds.map((block) => blockToDate(block - currentBlock)), + [periodInformation?.currentStage] + ) + const [inactiveEndDay, announcingEndDay, votingEndDay, revealingEndDay] = endDates ?? [] + + const progresses = periodInformation?.periodEnds.map((end, index) => { + const start = periodInformation.periodStarts[index] + return clamp(((currentBlock - start) * 100) / (end - start), 0, 100) + }) + const [inactiveProgress, announcingProgress, votingProgress, revealingProgress] = progresses ?? [] + const remainDays = periodInformation && blockDurationToDays(revealingEndBlock - currentBlock) + + if ( + constants?.election.votingPeriod != undefined && + constants?.election.revealingPeriod != undefined && + constants?.idlePeriod != undefined + ) { + // calculate the days per each stage + announcingDays = blockDurationToDays(constants?.announcingPeriod) + votingDays = blockDurationToDays(constants?.election.votingPeriod) + revealingDays = blockDurationToDays(constants?.election.revealingPeriod) + inactiveDays = blockDurationToDays(constants?.idlePeriod) + + // calculate progress bar layout + progressBarAttr = `${inactiveDays > 0 ? inactiveDays : 1}fr ${announcingDays > 0 ? announcingDays : 1}fr ${ + votingDays > 0 ? votingDays : 1 + }fr ${revealingDays > 0 ? revealingDays : 1}fr` + } + + const updateDescription = (selectedStage: string, choose: boolean) => { + if (choose == false || props.electionStage === selectedStage) { + setStageDescription(props.electionStage === 'inactive' ? 'The Round' : props.electionStage) + setVerbIndicator(props.electionStage === 'inactive' ? 'begins in' : 'ends in') + setDaysIndicator(endDayOfStage) + setSelectedToolbarStage('') + return + } + + const stages = ['inactive', 'announcing', 'voting', 'revealing'] + const electionPos = stages.indexOf(props.electionStage) + const selectedPos = stages.indexOf(selectedStage) + + const endOfStageArray = [ + [ + '', + endDayOfStage, + , + , + ], + [inactiveEndDay, '', endDayOfStage, ], + [inactiveEndDay, announcingEndDay, '', endDayOfStage], + [inactiveEndDay, announcingEndDay, votingEndDay, ''], + ] + + setStageDescription(selectedStage) + setSelectedToolbarStage(selectedStage) + setVerbIndicator(selectedPos > electionPos ? 'begins in' : 'ended on') + setDaysIndicator(endOfStageArray[electionPos][selectedPos]) + } + + return ( + + + + + {stageDescription} + + {verbIndicator} + {daysIndicator} + + {remainDays} days until next round + + + + + + + + + {(size === 'xxs' || size === 'xs') && ( + <> + Stages + + + + + + + + + + + + + + + + )} + + ) +} + +interface TooltipProgressBarProps extends ProgressBarProps { + barType: string + tooltipText?: string + isCurrent: boolean + updateDesc: (electionStage: string, inout: boolean) => void +} + +const TooltipProgressBar = (props: TooltipProgressBarProps) => { + const [referenceElementRef, setReferenceElementRef] = useState(null) + const [popperElementRef, setPopperElementRef] = useState(null) + const [isTooltipActive, setTooltipActive] = useState(false) + const [barHeight, setBarHeight] = useState<'small' | 'big' | 'medium'>('small') + const [arrowPos, setArrowPos] = useState() + + const placement = props.end === 0 || props.end === 1 ? 'bottom' : props.end < 0.5 ? 'bottom-start' : 'bottom-end' + const { styles, attributes } = usePopper(referenceElementRef, popperElementRef, { + placement: placement, + modifiers: [ + { + name: 'offset', + options: { + offset: [0, 5], // [skidding, distance] + }, + }, + ], + }) + + useEffect(() => { + if (referenceElementRef) { + const barWidth = referenceElementRef.clientWidth + + if (placement === 'bottom-start') { + setArrowPos(Math.floor(props.end * barWidth)) + } else if (placement === 'bottom-end') { + setArrowPos(Math.floor(barWidth - props.end * barWidth)) + } else { + setArrowPos(Math.floor(barWidth)) + } + } + }, [referenceElementRef, props.end]) + + const mouseIsOver = () => { + setBarHeight('medium') + setTooltipActive(true) + props.updateDesc(props.barType, true) + } + const mouseLeft = () => { + setBarHeight('small') + setTooltipActive(false) + props.updateDesc(props.barType, false) + } + + const tooltipHandlers = { + onClick: (event: React.MouseEvent) => { + event.stopPropagation() + setTooltipActive(true) + }, + onFocus: mouseIsOver, + onBlur: mouseLeft, + onPointerEnter: mouseIsOver, + onPointerLeave: mouseLeft, + } + const popUpHandlers = { + onPointerEnter: mouseIsOver, + onPointerLeave: mouseLeft, + } + + return ( + <> + + {isTooltipActive && + ReactDOM.createPortal( + + {props?.tooltipText} + , + document.body + )} + + ) +} + +const CustomTooltipPopupContainer = styled.div<{ isTooltipActive?: boolean; forBig?: boolean; arrowPos?: number }>` + display: flex; + flex-direction: column; + align-items: flex-start; + position: absolute; + width: max-content; + min-width: 160px; + max-width: 304px; + padding: 16px 24px; + border: 1px solid ${Colors.Black[900]}; + background-color: ${Colors.Black[700]}; + border-radius: ${BorderRad.m}; + opacity: ${({ isTooltipActive }) => (isTooltipActive ? '1' : '0')}; + transition: opacity ${Transitions.duration} ease; + z-index: ${ZIndex.tooltip}; + + &:after { + content: ''; + position: absolute; + width: 8px; + height: 8px; + background-color: ${Colors.Black[700]}; + border: 1px solid ${Colors.Black[900]}; + transform: rotate(45deg); + z-index: 1; + } + + &:before { + content: ''; + position: absolute; + left: -8px; + top: -8px; + width: calc(100% + 16px); + height: calc(100% + 16px); + z-index: -1; + } + + &[data-popper-placement^='top'] { + &:after { + bottom: -4px; + clip-path: polygon(100% 0, 100% 100%, 0 100%); + } + } + &[data-popper-placement^='bottom'] { + &:after { + top: -4px; + clip-path: polygon(100% 0, 0 0, 0 100%); + } + } + &[data-popper-reference-hidden='true'] { + visibility: hidden; + pointer-events: none; + } + &[data-popper-placement='bottom'] { + &:after { + left: 50%; + } + } + &[data-popper-placement='top-start']:after, + &[data-popper-placement='bottom-start']:after { + left: ${({ arrowPos }) => (arrowPos ? `${arrowPos + 12}px` : '19px')}; + } + &[data-popper-placement='top-end']:after, + &[data-popper-placement='bottom-end']:after { + right: ${({ arrowPos }) => (arrowPos ? `${arrowPos + 12}px` : '19px')}; + } + &[data-popper-placement='top-start'] { + inset: ${({ forBig }) => (forBig ? 'auto auto 5px -13px !important' : 'auto auto 4px -16px !important')}; + } + &[data-popper-placement='top-end'] { + inset: ${({ forBig }) => (forBig ? 'auto -12px 5px auto !important' : 'auto -16px 4px auto !important')}; + } + &[data-popper-placement='bottom-start'] { + inset: ${({ forBig }) => (forBig ? '5px auto auto -13px !important' : '4px auto auto -16px !important')}; + } + &[data-popper-placement='bottom-end'] { + inset: ${({ forBig }) => (forBig ? '5px -12px auto auto !important' : '4px -16px auto auto !important')}; + } +` +const StatisticBigLabel = styled.div<{ strong?: boolean }>` + font-size: 20px; + line-height: 28px; + margin-right: 6px; + display: inline-block; + font-weight: 700; + color: ${({ strong }) => (strong ? `${Colors.Black[900]}` : `${Colors.Black[400]}`)}; +` +const ProgressBarLayout = styled.div<{ layout?: string }>` + display: grid; + grid-template-columns: ${({ layout }) => (layout ? layout : '1fr 14fr 3fr 3fr')}; + gap: 4px; + width: 100%; + max-width: 100%; + margin-top: 8px; + place-items: center; + height: 20px; +` +const StatisticMidLabel = styled(TextSmall)` + color: ${Colors.Black[500]}; + text-transform: uppercase; + font-size: 10px; + font-weight: 700; + padding: 16px 4px 8px 4px; +` diff --git a/packages/ui/src/app/pages/Election/components/ElectionProgressCardItem.tsx b/packages/ui/src/app/pages/Election/components/ElectionProgressCardItem.tsx new file mode 100644 index 0000000000..75a4d1062e --- /dev/null +++ b/packages/ui/src/app/pages/Election/components/ElectionProgressCardItem.tsx @@ -0,0 +1,107 @@ +import React, { useState } from 'react' +import styled from 'styled-components' + +import { DropDownButton, DropDownToggle } from '@/common/components/buttons/DropDownToggle' +import { TableListItemAsLinkHover } from '@/common/components/List' +import { Skeleton } from '@/common/components/Skeleton' +import { BorderRad, Colors, Transitions } from '@/common/constants' + +interface ElectionProgressCardItemProps { + progress: number + text: string + title: string +} + +interface CircleProgressBarProps { + progress: number +} + +const CircleProgressBar = ({ progress }: CircleProgressBarProps) => { + return ( + + + + + ) +} + +export const ElectionProgressCardItem = ({ title, progress, text }: ElectionProgressCardItemProps) => { + const [isDropped, setDropped] = useState(false) + + return ( + + setDropped(!isDropped)}> + + + {title} + + setDropped(!isDropped)} isDropped={isDropped} /> + + + {text} + + + ) +} + +const ProgressCardItemWrapper = styled.div` + display: flex; + flex-direction: column; + width: 100%; + border: 1px solid ${Colors.Black[100]}; + border-radius: ${BorderRad.s}; + cursor: pointer; + transition: ${Transitions.all}; + + ${TableListItemAsLinkHover} +` + +const ProgressCardItemWrap = styled.div` + display: grid; + grid-template-columns: auto 40px; + grid-template-rows: 1fr; + justify-content: space-between; + justify-items: center; + align-items: center; + width: 100%; + height: 54px; + padding-left: 16px; + padding-right: 4px; + margin-left: -1px; + + ${Skeleton} { + min-width: 100%; + height: 1.2rem; + } +` +const ProgressCardItemHeaderWrap = styled.div` + display: flex; + align-items: center; + justify-content: start; + padding: 4px; + gap: 12px; +` + +const StatisticBigLabel = styled.div<{ strong?: boolean }>` + font-size: ${({ strong }) => (strong ? '20px' : '18px')}; + line-height: ${({ strong }) => (strong ? '28px' : '24px')}; + margin-right: 6px; + display: inline-block; + font-weight: ${({ strong }) => (strong ? '700' : '300')}; + color: ${({ strong }) => (strong ? `${Colors.Black[600]}` : `${Colors.Black[400]}`)}; +` + +const StyledDropDown = styled(DropDownToggle)` + row-gap: 16px; + padding: 0px 16px 16px 16px; +` diff --git a/packages/ui/src/common/components/statistics/StatisticItem.tsx b/packages/ui/src/common/components/statistics/StatisticItem.tsx index 6c69bca7e3..2ec4c462da 100644 --- a/packages/ui/src/common/components/statistics/StatisticItem.tsx +++ b/packages/ui/src/common/components/statistics/StatisticItem.tsx @@ -60,10 +60,12 @@ export const StatsContent = styled.div<{ inline?: boolean }>` `} ` -export const StatisticItemSpacedContent = styled.div` +export const StatisticItemSpacedContent = styled.div<{ size?: string }>` display: flex; + flex-direction: ${({ size }) => (size === 'xxs' || size === 'xs' ? 'column' : 'row')}; justify-content: space-between; - align-items: center; + row-gap: 8px; + align-items: ${({ size }) => (size === 'xxs' || size === 'xs' ? 'start' : 'center')}; width: 100%; ` diff --git a/packages/ui/src/council/hooks/useCouncilPeriodInformation.ts b/packages/ui/src/council/hooks/useCouncilPeriodInformation.ts new file mode 100644 index 0000000000..797c3cd004 --- /dev/null +++ b/packages/ui/src/council/hooks/useCouncilPeriodInformation.ts @@ -0,0 +1,44 @@ +import { sum } from 'lodash' +import { map, combineLatest } from 'rxjs' + +import { useApi } from '@/api/hooks/useApi' +import { useObservable } from '@/common/hooks/useObservable' + +import { electionStageObservable } from './useElectionStage' + +export const useCouncilPeriodInformation = () => { + const { api } = useApi() + + return useObservable(() => { + if (!api) return + + const periodData = electionStageObservable(api).pipe( + map(({ stage, changedAt: start }) => { + const durations = [ + api.consts.council.idlePeriodDuration, + api.consts.council.announcingPeriodDuration, + api.consts.referendum.voteStageDuration, + api.consts.referendum.revealStageDuration, + ].map(Number) + const position = ['inactive', 'announcing', 'voting', 'revealing'].indexOf(stage) + const periodStarts = durations.map((_, index) => + index < position + ? start - sum(durations.slice(index, position)) + : start + sum(durations.slice(position, index)) + ) + const periodEnds = periodStarts.map((periodStart, index) => periodStart + durations[index]) + + return { stage, periodStarts, periodEnds, currentStage: stage, currentStageIndex: position } + }) + ) + + const currentBlock = api.rpc.chain.subscribeNewHeads().pipe(map(({ number }) => number.toNumber())) + + return combineLatest([periodData, currentBlock]).pipe( + map(([periodData, currentBlock]) => { + const periodEnd = periodData.periodEnds[periodData.currentStageIndex] + return { ...periodData, currentBlock: currentBlock, remainingPeriod: Math.max(0, periodEnd - currentBlock) } + }) + ) + }, [api?.isConnected]) +} diff --git a/packages/ui/test/council/pages/Election.test.tsx b/packages/ui/test/council/pages/Election.test.tsx index 5b9392cd4b..660826fa7c 100644 --- a/packages/ui/test/council/pages/Election.test.tsx +++ b/packages/ui/test/council/pages/Election.test.tsx @@ -107,7 +107,7 @@ describe('UI: Election page', () => { const { queryByText } = await renderComponent() - expect(queryByText('Stage')).not.toBeNull() + expect(queryByText('Round')).not.toBeNull() }) describe('Active', () => { @@ -119,7 +119,7 @@ describe('UI: Election page', () => { it('Displays stage and round', async () => { const { queryByText } = await renderComponent() - expect(queryByText('Announcing Period')).toBeInTheDocument() + expect(queryByText(/Election Progress/i)).toBeInTheDocument() }) describe('Tabs', () => { @@ -185,7 +185,7 @@ describe('UI: Election page', () => { it('Displays stage', async () => { const { queryByText } = await renderComponent() - expect(queryByText(/Voting period/i)).not.toBeNull() + expect(queryByText(/Election Progress/i)).not.toBeNull() }) it('No accounts', async () => { @@ -276,8 +276,7 @@ describe('UI: Election page', () => { it('Displays stage, remaining length, and no election round', async () => { const { queryByText } = await renderComponent() - expect(queryByText(/Revealing period/i)).toBeInTheDocument() - expect(queryByText(/period remaining length/i)?.parentElement?.nextElementSibling).toHaveTextContent('< 1 min') + expect(queryByText(/Election Progress/i)).toBeInTheDocument() expect(loaderSelector(true)).toHaveLength(1) })