Browse Source

Council Election & Council rounds

Oleksandr Korniienko 3 years ago
parent
commit
083c55f9b2

+ 4 - 1
src/components/AppBar/config.ts

@@ -1,3 +1,5 @@
+import { StyleRules } from "@material-ui/core";
+
 export const routes = {
   dashboard: "Dashboard",
   calendar: "Calendar",
@@ -14,9 +16,10 @@ export const routes = {
   faq: "FAQ",
   survey: "Survey",
   issues: "Issues",
+  election: "Election"
 };
 
-export const css = {
+export const css: StyleRules<"appBar" | "appLogo" | "lang" | "navBar" | "navBarLink" | "toolBar"> = {
   appBar: {
     flexDirection: "row",
     backgroundColor: "#000",

+ 1 - 1
src/components/Burners/index.tsx

@@ -36,7 +36,7 @@ class Burners extends React.Component<IProps, IState> {
         <h3>Top Token Burners</h3>
         <>
         { (!burners || burners.length === 0) ? <h4>No records found</h4> :
-          <Table striped bordered hover size="sm">
+          <Table striped bordered hover size="sm" style={{ color: "inherit" }}>
           <thead>
             <tr>
               <th>Wallet</th>

+ 68 - 0
src/components/Councils/ApplicantVotes.tsx

@@ -0,0 +1,68 @@
+import {
+  Badge,
+  Chip,
+  Divider,
+  Grid,
+  Tooltip,
+  Typography,
+} from "@material-ui/core";
+import { IApplicant, IVote } from "../../types";
+import { formatJoy } from "./election-status";
+import { tooltipStyles } from "./styles";
+import { electionStyles } from "./styles";
+
+const ApplicantVotes = (props: { applicant: IApplicant; votes: IVote[] }) => {
+  const classes = electionStyles();
+  const tooltipClasses = tooltipStyles();
+  const applicantVotes = props.votes.filter(
+    (v) => `${v.candidateHandle}` === `${props.applicant.member.handle}`
+  );
+
+  return (
+    <div>
+      {applicantVotes.length === 0 ? (
+        <Typography variant="h6">{`No votes`}</Typography>
+      ) : (
+        <Tooltip
+          className={classes.backersTooltip}
+          classes={tooltipClasses}
+          placement="bottom"
+          id={`overlay-${props.applicant.member.id}`}
+          title={
+            <Grid container spacing={1}>
+              {applicantVotes.map((vote: IVote, index: number) => {
+                return (
+                  <Grid className={classes.backerInfo} item key={index} lg={12}>
+                    <Badge
+                      className={classes.badge}
+                      badgeContent={`${
+                        (vote.voterId as unknown as Array<Number>)[0]
+                      }`}
+                      color="primary"
+                      max={999999}
+                    >
+                      <Typography variant="h6">{`${vote.voterHandle}`}</Typography>
+                    </Badge>
+                    <Divider className={classes.dividerPrimary}/>
+                    <Chip
+                      label={`Stake: ${formatJoy(
+                        Number(vote.newStake) + Number(vote.transferredStake)
+                        )}`}
+                      color="primary"
+                      className={classes.stakeChip}
+                    />
+                    <Divider />
+                  </Grid>
+                );
+              })}
+            </Grid>
+          }
+        >
+          <Typography variant="h6">{`Backers (${applicantVotes.length})`}</Typography>
+        </Tooltip>
+      )}
+    </div>
+  );
+};
+
+export default ApplicantVotes;

+ 41 - 0
src/components/Councils/BackerVote.tsx

@@ -0,0 +1,41 @@
+import { Grid, Typography, Chip, Divider } from "@material-ui/core";
+import { IVote } from "../../types";
+import { formatJoy } from "./election-status";
+import { electionStyles } from "./styles";
+
+const BackerVote = (props: { index: number; vote: IVote }) => {
+  const classes = electionStyles();
+
+  return (
+    <Grid item key={props.index} lg={2} md={4} sm={6} xs={12}>
+      <div className={classes.applicant} key={props.index}>
+        <Typography variant="h6">{`${props.index + 1}. ${
+          props.vote.voterHandle
+        }`}</Typography>
+        <Chip
+          className={classes.stakeChip}
+          label={`Stake: ${formatJoy(
+            Number(props.vote.newStake) + Number(props.vote.transferredStake)
+          )}`}
+          color={"primary"}
+        />
+        <Divider />
+        {props.vote.candidateHandle ? (
+          <Chip
+            className={classes.stakeChip}
+            label={`Candidate: ${props.vote.candidateHandle}`}
+            color={"primary"}
+          />
+        ) : (
+          <Chip
+            className={classes.stakeChip}
+            label={`Not revealed`}
+            color={"default"}
+          />
+        )}
+      </div>
+    </Grid>
+  );
+};
+
+export default BackerVote;

+ 47 - 0
src/components/Councils/CouncilApplicant.tsx

@@ -0,0 +1,47 @@
+import { Grid, Typography, Chip } from "@material-ui/core";
+import { IApplicant, IElectionState } from "../../types";
+import ApplicantVotes from "./ApplicantVotes";
+import { electionStyles } from "./styles";
+import { calculateOtherVotes, formatJoy } from "./election-status";
+
+const CouncilApplicant = (props: { applicant: IApplicant; electionState: IElectionState, index: number }) => {
+  const classes = electionStyles();
+  const othersStake = calculateOtherVotes(
+    props.electionState.votes,
+    props.applicant
+  );
+  return (
+    <Grid item lg={2} md={4} sm={6} xs={12}>
+      <div className={classes.applicant}>
+        <Typography variant="h6">
+          {`${props.index + 1}. ${props.applicant.member.handle}`}
+        </Typography>
+        <Chip
+          className={classes.stakeChip}
+          label={`Total Stake: ${formatJoy(
+            Number(props.applicant.electionStake.new) +
+              Number(props.applicant.electionStake.transferred) +
+              othersStake
+          )}`}
+          color={props.index < props.electionState.councilSize ? "primary" : "default"}
+        />
+        <Chip
+          className={classes.stakeChip}
+          label={`Own Stake: ${formatJoy(
+            Number(props.applicant.electionStake.new) +
+              Number(props.applicant.electionStake.transferred)
+          )}`}
+          color={props.index < props.electionState.councilSize ? "primary" : "default"}
+        />
+        <Chip
+          className={classes.stakeChip}
+          label={`Others Stake: ${formatJoy(othersStake)}`}
+          color={props.index < props.electionState.councilSize ? "primary" : "default"}
+        />
+        <ApplicantVotes votes={props.electionState.votes} applicant={props.applicant} />
+      </div>
+    </Grid>
+  );
+};
+
+export default CouncilApplicant;

+ 47 - 0
src/components/Councils/CouncilRounds.tsx

@@ -0,0 +1,47 @@
+import {
+  Grid,
+  Typography,
+  Accordion,
+  AccordionSummary,
+  AccordionDetails,
+} from "@material-ui/core";
+import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
+import { electionStyles } from "./styles";
+import CouncilSeat from "./CouncilSeat";
+import { CouncilRound, ICouncilRounds, SeatData } from "./types";
+
+const CouncilRounds = (props: { rounds: ICouncilRounds }) => {
+  const classes = electionStyles();
+
+  return (
+    <Grid className={classes.root} item lg={12}>
+      {props.rounds.rounds
+        .filter((r) => r.seats.length > 0)
+        .map((r: CouncilRound, index: number) => {
+          return (
+            <Accordion className={classes.acc} key={index}>
+              <AccordionSummary
+                className={classes.accSummary}
+                expandIcon={<ExpandMoreIcon style={{ color: "#fff" }} />}
+                aria-controls={`${index}-content`}
+                id={`${index}-header`}
+              >
+                <Typography variant="h6">{`Round ${r.round} (end block: ${r.termEndsAt})`}</Typography>
+              </AccordionSummary>
+              <AccordionDetails>
+                <Grid item key={index} lg={12}>
+                  <Grid container spacing={1}>
+                    {r.seats.map((seat: SeatData, index: number) => (
+                      <CouncilSeat seat={seat} key={index} />
+                    ))}
+                  </Grid>
+                </Grid>
+              </AccordionDetails>
+            </Accordion>
+          );
+        })}
+    </Grid>
+  );
+};
+
+export default CouncilRounds;

+ 93 - 0
src/components/Councils/CouncilSeat.tsx

@@ -0,0 +1,93 @@
+import {
+  Grid,
+  Typography,
+  Chip,
+  Badge,
+  Divider,
+  Tooltip,
+} from "@material-ui/core";
+import { useState } from "react";
+import { formatJoy } from "./election-status";
+import { electionStyles, tooltipStyles } from "./styles";
+import { BakerData, SeatData } from "./types";
+
+const CouncilSeat = (props: { seat: SeatData }) => {
+  const [seat] = useState(props.seat);
+  const classes = electionStyles();
+  const tooltipClasses = tooltipStyles();
+
+  return (
+    <Grid item key={seat.member.id} lg={2} md={4} sm={6} xs={12}>
+      <div className={classes.applicant}>
+        <Badge
+          className={classes.badge}
+          badgeContent={seat.member.id}
+          color="primary"
+          max={999999}
+        >
+          <Typography variant="h6">{`${seat.member.handle}`}</Typography>
+        </Badge>
+        <Divider />
+        <Chip
+          label={`Total Stake: ${formatJoy(seat.totalStake)}`}
+          color="primary"
+          className={classes.stakeChip}
+        />
+        <Chip
+          label={`Own Stake: ${formatJoy(seat.ownStake)}`}
+          color="primary"
+          className={classes.stakeChip}
+        />
+        <Chip
+          label={`Backers Stake: ${formatJoy(seat.backersStake)}`}
+          color="primary"
+          className={classes.stakeChip}
+        />
+        <Chip
+          label={`JSGenesis Stake: ${formatJoy(seat.jsgStake)}`}
+          color="primary"
+          className={classes.stakeChip}
+        />
+        {seat.backers.length === 0 ? (
+          <Typography variant="h6">{`No backers`}</Typography>
+        ) : (
+          <Tooltip
+            className={classes.backersTooltip}
+            classes={tooltipClasses}
+            placement="bottom"
+            id={`overlay-${seat.member.handle}`}
+            title={
+              <Grid container spacing={1}>
+                {seat.backers.map((backer: BakerData, index: number) => {
+                  return (
+                    <Grid className={classes.backerInfo} item key={index} lg={12}>
+                      <Badge
+                        className={classes.badge}
+                        badgeContent={backer.member.id}
+                        color="primary"
+                        max={999999}
+                      >
+                        <Typography variant="h6">{`${backer.member.handle}`}</Typography>
+                      </Badge>
+                      <Divider className={classes.dividerPrimary}/>
+                      <Chip
+                        label={`Stake: ${formatJoy(backer.stake)}`}
+                        color="primary"
+                        className={classes.stakeChip}
+                      />
+                      <Divider />
+                    </Grid>
+                  );
+                })}
+              </Grid>
+            }
+          >
+            <Typography variant="h6">{`Backers (${seat.backers.length})`}</Typography>
+          </Tooltip>
+        )}
+      </div>
+    </Grid>
+  );
+};
+
+export default CouncilSeat;

+ 217 - 0
src/components/Councils/Election.tsx

@@ -0,0 +1,217 @@
+import {
+  Grid,
+  Typography,
+  CircularProgress,
+  Chip,
+  Accordion,
+  AccordionSummary,
+  AccordionDetails,
+  Divider,
+} from "@material-ui/core";
+import { Alert } from "@material-ui/lab";
+import ExpandMoreIcon from "@material-ui/icons/ExpandMore";
+import { useEffect, useState } from "react";
+import { ElectionStatus } from "..";
+import { IState, IApplicant, IElectionState, IVote } from "../../types";
+import { calculateOtherVotes, getElectionStatus } from "./election-status";
+import axios from "axios";
+import pako from "pako";
+import { electionStyles } from "./styles";
+import { ICouncilRounds } from "./types";
+import CouncilRounds from "./CouncilRounds";
+import { tasksEndpoint } from "../../config";
+import CouncilApplicant from "./CouncilApplicant";
+import BackerVote from "./BackerVote";
+
+const Election = (props: IState) => {
+  const { status, domain } = props;
+  const classes = electionStyles();
+  const [error, setError] = useState(undefined);
+  const [isLoading, setIsLoading] = useState(false);
+  const [rounds, setRounds] = useState({
+    rounds: [],
+  } as ICouncilRounds);
+  const [electionState, setElectionState] = useState({
+    applicants: [],
+    votes: [],
+    stage: {},
+    councilRound: 0,
+    councilSize: 16,
+  } as IElectionState);
+
+  useEffect(() => {
+    setIsLoading(true);
+    const councilJson = `${tasksEndpoint}/public/council.json.gz`;
+    axios
+      .get(councilJson, {
+        responseType: "arraybuffer",
+      })
+      .then((response) => {
+        try {
+          const binData = new Uint8Array(response.data);
+          const roundsJson = JSON.parse(
+            new TextDecoder().decode(pako.inflate(binData))
+          );
+          setRounds(roundsJson as ICouncilRounds);
+          setIsLoading(false);
+        } catch (err) {
+          setIsLoading(false);
+          console.log(err);
+        }
+      })
+      .catch((e) => {
+        setIsLoading(false);
+        console.log(e);
+      });
+  }, []);
+
+  useEffect(() => {
+    setIsLoading(true);
+    let votes = [] as IVote[];
+    const sortByStake = (a: IApplicant, b: IApplicant) => {
+      const votesA = calculateOtherVotes(votes, a);
+      const votesB = calculateOtherVotes(votes, b);
+      const totalStakeA =
+        Number(a.electionStake.new) +
+        Number(a.electionStake.transferred) +
+        votesA;
+      const totalStakeB =
+        Number(b.electionStake.new) +
+        Number(b.electionStake.transferred) +
+        votesB;
+      return totalStakeA < totalStakeB ? 1 : -1;
+    };
+
+    getElectionStatus().then(
+      (state: IElectionState) => {
+        votes = state.votes;
+        setElectionState({
+          ...state,
+          votes: sortVotes(votes),
+          applicants: state.applicants.sort(sortByStake),
+        });
+        setIsLoading(false);
+        setError(undefined);
+      },
+      (err) => {
+        setError(err);
+        setIsLoading(false);
+      }
+    );
+  }, []);
+
+  const currentStage = () => {
+    const keys = Object.keys(electionState.stage);
+    if (keys.length > 0) {
+      return (
+        <div className={classes.chips}>
+          <Chip label={`ROUND ${electionState.councilRound}`} color="primary" />
+          <Chip label={keys[0].toUpperCase()} color="secondary" />
+        </div>
+      );
+    }
+    return "";
+  };
+
+  const sortVotes = (votes: IVote[]) => {
+    return votes.sort((v1, v2) => {
+      if (
+        `${v1.candidateHandle}` !== "undefined" &&
+        `${v2.candidateHandle}` === "undefined"
+      ) {
+        return -1;
+      } else if (
+        `${v2.candidateHandle}` !== "undefined" &&
+        `${v1.candidateHandle}` === "undefined"
+      ) {
+        return 1;
+      }
+      const v1Stake = Number(v1.newStake) + Number(v1.transferredStake);
+      const v2Stake = Number(v2.newStake) + Number(v2.transferredStake);
+      if (v1Stake === v2Stake) return 0;
+      return v1Stake > v2Stake ? -1 : 1;
+    });
+  };
+
+  return (
+    <Grid className={classes.root} item lg={12}>
+      <Typography variant="h4" className="mb-3">
+        {`Council Election`}
+        {currentStage()}
+        {isLoading && <CircularProgress color="inherit" />}
+      </Typography>
+      {error !== undefined && (
+        <Alert
+          style={{ marginTop: 12 }}
+          onClose={() => setError(undefined)}
+          severity="error"
+        >
+          {`Error loading election status: [${error}]`}
+        </Alert>
+      )}
+      {!isLoading && (
+        <Grid className={classes.grid} item lg={12}>
+          <Accordion className={classes.acc}>
+            <AccordionSummary
+              className={classes.accSummary}
+              expandIcon={<ExpandMoreIcon style={{ color: "#fff" }} />}
+              aria-controls={`election-content`}
+              id={`election-header`}
+            >
+              <Typography variant="h6" className={classes.title}>
+                <ElectionStatus
+                  domain={domain}
+                  block={status.block?.id}
+                  election={status.election}
+                />
+                {`Ongoing Election (${
+                  electionState.applicants.length
+                } applicants${
+                  electionState.votes.length > 0
+                    ? `, ${electionState.votes.length} votes`
+                    : ""
+                }, max ${electionState.councilSize} seats)`}
+              </Typography>
+            </AccordionSummary>
+            <AccordionDetails>
+              <Grid container spacing={1} className={classes.applicants}>
+                <Grid item lg={12} className={classes.applicants}>
+                  <Grid container spacing={1} className={classes.applicants}>
+                    {electionState.applicants.map(
+                      (applicant: IApplicant, index: number) => {
+                        return (
+                          <CouncilApplicant
+                            applicant={applicant}
+                            electionState={electionState}
+                            index={index}
+                            key={index}
+                          />
+                        );
+                      }
+                    )}
+                  </Grid>
+                </Grid>
+                <Grid item lg={12} className={classes.applicants}>
+                  <Typography variant="h5">{`Votes`}</Typography>
+                </Grid>
+                <Grid item lg={12} className={classes.applicants}>
+                  <Grid container spacing={1} className={classes.applicants}>
+                    {electionState.votes.map((vote: IVote, index: number) => {
+                      return (
+                        <BackerVote key={index} index={index} vote={vote} />
+                      );
+                    })}
+                  </Grid>
+                </Grid>
+              </Grid>
+            </AccordionDetails>
+          </Accordion>
+        </Grid>
+      )}
+      <Divider />
+      <CouncilRounds rounds={rounds} />
+    </Grid>
+  );
+};
+
+export default Election;

+ 39 - 0
src/components/Councils/election-status.ts

@@ -0,0 +1,39 @@
+import { IApplicant, IElectionState, IVote } from "../../types";
+import { JoyApi } from "../../joyApi";
+import { PromiseAllObj } from "../ValidatorReport/utils";
+
+const api = new JoyApi();
+
+export async function getElectionStatus(): Promise<IElectionState> {
+  await api.init;
+  return (await PromiseAllObj({
+    applicants: await api.getCouncilApplicants(),
+    stage: await api.stage(),
+    councilRound: await api.councilRound(),
+    councilSize: await api.councilSize(),
+    votes: await api.getVotes(),
+  })) as IElectionState;
+}
+
+export const calculateOtherVotes = (votes: IVote[], applicant: IApplicant) =>
+  votes
+    .filter((v) => `${v.candidateHandle}` === `${applicant.member.handle}`)
+    .reduce((othersStake: Number, vote: IVote) => {
+      return (
+        Number(othersStake) +
+        Number(vote.newStake) +
+        Number(vote.transferredStake)
+      );
+    }, 0);
+
+export const formatJoy = (stake: number): String => {
+  if (stake >= 1000000) {
+    return `${(stake / 1000000).toFixed(4)} MJOY`;
+  }
+
+  if (stake >= 1000) {
+    return `${(stake / 1000).toFixed(4)} kJOY`;
+  }
+
+  return `${stake} JOY`;
+};

+ 100 - 0
src/components/Councils/styles.tsx

@@ -0,0 +1,100 @@
+import { makeStyles, createStyles, Theme } from "@material-ui/core";
+
+export const electionStyles = makeStyles((theme: Theme) =>
+  createStyles({
+    root: {
+      textAlign: "center",
+      backgroundColor: "#000",
+      color: "#fff",
+    },
+    appBar: {
+      flexGrow: 1,
+      backgroundColor: "#4038FF",
+    },
+    title: {
+      textAlign: "left",
+      flexGrow: 1,
+    },
+    acc: {
+      color: "#fff",
+      backgroundColor: "#000",
+    },
+    grid: {
+      textAlign: "center",
+      backgroundColor: "#000",
+      color: "#fff",
+    },
+    heading: {
+      fontSize: theme.typography.pxToRem(15),
+      fontWeight: theme.typography.fontWeightRegular,
+    },
+    applicants: {
+      marginTop: theme.spacing(1),
+      textAlign: "center",
+    },
+    applicant: {
+      padding: 6,
+      border: "1px solid #4038FF",
+      transition: "0.5s",
+      "&:hover": {
+        border: "1px solid #FFF",
+        background: "#fff",
+        color: "#000",
+      },
+    },
+    backdrop: {
+      zIndex: theme.zIndex.drawer + 1,
+      color: "#fff",
+      position: "absolute",
+      width: "100%",
+    },
+    paper: {
+      textAlign: "center",
+      backgroundColor: "#000",
+      color: "#fff",
+      minHeight: 600,
+      maxHeight: 800,
+      overflow: "auto",
+      marginBottom: 4,
+      marginTop: 4,
+    },
+    chips: {
+      display: "flex",
+      justifyContent: "center",
+      "& > *": {
+        margin: theme.spacing(1, 1, 1, 0),
+      },
+    },
+    stakeChip: {
+      margin: theme.spacing(1),
+    },
+    accSummary: {
+      textAlign: "center",
+      color: "#fff",
+      backgroundColor: "#4038FF",
+    },
+    badge: {
+      marginTop: theme.spacing(1),
+    },
+    backersTooltip: {
+      cursor: "pointer",
+    },
+    backerInfo: {
+      textAlign: "center"
+    },
+    dividerPrimary: {
+      backgroundColor: "#4038FF",
+    }
+  })
+);
+
+export const tooltipStyles = makeStyles((theme: Theme) => ({
+  arrow: {
+    color: theme.palette.common.black,
+  },
+  tooltip: {
+    border: "1px solid #fff",
+    backgroundColor: "#4038FF",
+    fontSize: "0.8rem",
+  },
+}));

+ 32 - 0
src/components/Councils/types.tsx

@@ -0,0 +1,32 @@
+import { AccountId } from "@polkadot/types/interfaces";
+
+export interface SeatData {
+    accountId: AccountId;
+    member: MemberData;
+    ownStake: number;
+    backersStake: number;
+    jsgStake: number;
+    totalStake: number;
+    backers: BakerData[];
+  }
+  
+  export interface MemberData {
+    accountId: AccountId;
+    handle: string;
+    id: number;
+  }
+  
+  export interface BakerData {
+    member: MemberData;
+    stake: number;
+  }
+  
+  export interface CouncilRound {
+    round: number;
+    termEndsAt: number;
+    seats: SeatData[];
+  }
+  
+  export interface ICouncilRounds {
+    rounds: CouncilRound[];
+  }

+ 4 - 2
src/components/Dashboard/Council.tsx

@@ -10,6 +10,7 @@ import {
   Toolbar,
   Typography,
   AppBar,
+  GridSize,
 } from "@material-ui/core";
 
 import { Council, Post, ProposalDetail, Status } from "../../types";
@@ -35,8 +36,9 @@ const CouncilGrid = (props: {
   validators: string[];
   status: Status;
   electionPeriods: number[];
+  gridSize: GridSize;
 }) => {
-  const { getMember, councils, domain, posts, proposals, status } = props;
+  const { getMember, councils, domain, posts, proposals, status, gridSize} = props;
   const { council, election } = status;
   const classes = useStyles();
 
@@ -47,7 +49,7 @@ const CouncilGrid = (props: {
     <Grid
       style={{ textAlign: "center", backgroundColor: "#000", color: "#fff" }}
       item
-      lg={6}
+      lg={gridSize}
     >
       <Paper
         style={{

+ 1 - 1
src/components/Dashboard/ElectionStatus.tsx

@@ -53,7 +53,7 @@ const Stage = (props: {
 const Election = (props: {
   block: number;
   domain: string;
-  council: Council;
+  election: ElectionStage;
 }) => {
   const { domain, election, block } = props;
 

+ 1 - 0
src/components/Dashboard/index.tsx

@@ -56,6 +56,7 @@ const Dashboard = (props: IProps) => {
             status={status}
             validators={validators}
             domain={domain}
+            gridSize={6}
           />
           <Forum
             updateForum={props.updateForum}

+ 2 - 1
src/components/Routes/index.tsx

@@ -1,6 +1,6 @@
 import React, { Suspense } from "react";
 import { BrowserRouter, Switch, Route } from "react-router-dom";
-import { AppBar, Spinner } from "..";
+import { AppBar, Election, Spinner } from "..";
 import { IState } from "../../types";
 import IssueTracker from "../IssueTracker";
 
@@ -133,6 +133,7 @@ const Routes = (props: IProps) => {
                 render={(routeprops) => <Burners {...routeprops} {...props} />}
               />
               <Route path="/faq" render={(routeprops) => <FAQ faq={faq} />} />
+              <Route path="/election" render={(routeprops) => <Election {...props}/>} />
               <Route path="/kpi" render={(routeprops) => <KPI faq={faq} />} />
               <Route path="/issues" render={(routeprops) => <IssueTracker/>} />
               <Route path="/survey" render={(routeprops) => <Survey />} />

+ 31 - 23
src/components/Transactions/index.tsx

@@ -50,32 +50,40 @@ class Transactions extends React.Component<IProps, IState> {
         <h3>Transactions</h3>
         <Form>
           <Form.Group className="mb-3" controlId="formBasicEmail">
-            <Form.Control type="address" placeholder="Wallet account(48 character string starting with 5)" onChange={(e) => this.accountTxFilterChanged(e.target.value)} value={address}/>
+            <Form.Control
+              type="address"
+              placeholder="Wallet account(48 character string starting with 5)"
+              onChange={(e) => this.accountTxFilterChanged(e.target.value)}
+              value={address}
+            />
           </Form.Group>
         </Form>
         <>
-        { (!transactions || transactions.length === 0) ? <h4>No records found</h4> :
-          <Table striped bordered hover size="sm">
-            <thead>
-              <tr>
-                <th>tJOY</th>
-                <th>From</th>
-                <th>To</th>
-                <th>Block</th>
-              </tr>
-            </thead>
-            <tbody>
-              {transactions.map(tx => (
-                      <tr key={tx.id}>
-                        <td>{tx.amount}</td>
-                        <td>{tx.from}</td>
-                        <td>{tx.to}</td>
-                        <td>{tx.block}</td>
-                      </tr>
-                    ))}
-            </tbody>
-          </Table>
-        } </>
+          {!transactions || transactions.length === 0 ? (
+            <h4>No records found</h4>
+          ) : (
+            <Table striped bordered hover size="sm" style={{ color: "inherit" }}>
+              <thead>
+                <tr>
+                  <th>tJOY</th>
+                  <th>From</th>
+                  <th>To</th>
+                  <th>Block</th>
+                </tr>
+              </thead>
+              <tbody>
+                {transactions.map((tx) => (
+                  <tr key={tx.id}>
+                    <td>{tx.amount}</td>
+                    <td>{tx.from}</td>
+                    <td>{tx.to}</td>
+                    <td>{tx.block}</td>
+                  </tr>
+                ))}
+              </tbody>
+            </Table>
+          )}{" "}
+        </>
       </div>
     );
   }

+ 1 - 1
src/components/ValidatorReport/get-status.ts

@@ -1,4 +1,4 @@
-import { JoyApi } from "./joyApi";
+import { JoyApi } from "../../joyApi";
 import { PromiseAllObj } from "./utils";
 
 const api = new JoyApi();

+ 0 - 84
src/components/ValidatorReport/joyApi.ts

@@ -1,84 +0,0 @@
-import { ApiPromise, WsProvider } from "@polkadot/api";
-import { types } from "@joystream/types";
-import { AccountId, Hash } from "@polkadot/types/interfaces";
-import { config } from "dotenv";
-import BN from "bn.js";
-import { Option, Vec } from "@polkadot/types";
-import { wsLocation } from "../../config";
-
-config();
-
-export class JoyApi {
-  endpoint: string;
-  isReady: Promise<ApiPromise>;
-  api!: ApiPromise;
-
-  constructor() {
-    this.endpoint = wsLocation;
-    this.isReady = (async () =>
-      await new ApiPromise({ provider: new WsProvider(wsLocation), types })
-        .isReadyOrError)();
-  }
-
-  get init(): Promise<JoyApi> {
-    return this.isReady.then((instance) => {
-      this.api = instance;
-      return this;
-    });
-  }
-
-  async finalizedHash() {
-    return this.api.rpc.chain.getFinalizedHead();
-  }
-
-  async finalizedBlockHeight() {
-    const finalizedHash = await this.finalizedHash();
-    const { number } = await this.api.rpc.chain.getHeader(`${finalizedHash}`);
-    return number.toNumber();
-  }
-
-  async findActiveValidators(
-    hash: Hash,
-    searchPreviousBlocks: boolean
-  ): Promise<AccountId[]> {
-    const block = await this.api.rpc.chain.getBlock(hash);
-
-    let currentBlockNr = block.block.header.number.toNumber();
-    let activeValidators;
-    do {
-      let currentHash = (await this.api.rpc.chain.getBlockHash(
-        currentBlockNr
-      )) as Hash;
-      let allValidators = (await this.api.query.staking.snapshotValidators.at(
-        currentHash
-      )) as Option<Vec<AccountId>>;
-      if (!allValidators.isEmpty) {
-        let max = (
-          await this.api.query.staking.validatorCount.at(currentHash)
-        ).toNumber();
-        activeValidators = Array.from(allValidators.unwrap()).slice(0, max);
-      }
-
-      if (searchPreviousBlocks) {
-        --currentBlockNr;
-      } else {
-        ++currentBlockNr;
-      }
-    } while (activeValidators === undefined);
-    return activeValidators;
-  }
-
-  async validatorsData() {
-    const validators = await this.api.query.session.validators();
-    const era = await this.api.query.staking.currentEra();
-    const totalStake = era.isSome
-      ? await this.api.query.staking.erasTotalStake(era.unwrap())
-      : new BN(0);
-
-    return {
-      count: validators.length,
-      validators: validators.toJSON(),
-      total_stake: totalStake.toNumber(),
-    };
-  }
-}

+ 1 - 0
src/components/index.ts

@@ -32,6 +32,7 @@ export { default as Burners } from "./Burners";
 export { default as Validators } from "./Validators";
 export { default as ValidatorReport } from "./ValidatorReport";
 export { default as FAQ } from "./FAQ";
+export { default as Election } from "./Councils/Election";
 export { default as KPI } from "./KPI";
 export { default as IssueTracker } from "./IssueTracker";
 export { default as Timeline } from "./Timeline";

+ 171 - 160
src/index.css

@@ -2,287 +2,298 @@ html,
 body,
 #root,
 .App {
-  height: 100%;
-  background-color: #000;
-  font-size: 1rem;
+    height: 100%;
+    background-color: #000;
+    font-size: 1rem;
 }
 
 body {
-  margin: 0;
-  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
-    "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
-    sans-serif;
-  -webkit-font-smoothing: antialiased;
-  -moz-osx-font-smoothing: grayscale;
+    margin: 0;
+    font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue", sans-serif;
+    -webkit-font-smoothing: antialiased;
+    -moz-osx-font-smoothing: grayscale;
 }
 
 a,
 a:link,
 a:visited {
-  color: #001414;
-  text-decoration: none !important;
+    color: #001414;
+    text-decoration: none !important;
 }
+
 a:active,
 a:hover {
-  -color: #fff !important;
-  -background: #4038ff;
-  -padding: 1px;
+    -color: #fff !important;
+    -background: #4038ff;
+    -padding: 1px;
 }
 
 select {
-  border-color: red;
+    border-color: red;
 }
 
 .select div:hover {
-  background-color: #4038ff !important;
+    background-color: #4038ff !important;
 }
 
 ::-moz-selection,
 ::selection,
 .select-selected,
 .select-items div:hover {
-  color: white;
-  background-color: #4038ff !important;
+    color: white;
+    background-color: #4038ff !important;
 }
 
 h3 {
-  font-size: 1rem;
+    font-size: 1rem;
 }
 
 .box {
-  color: white;
-  margin: 5px;
-  padding: 15px;
-  background-color: #4038ff;
-  text-align: center;
+    color: white;
+    margin: 5px;
+    padding: 15px;
+    background-color: #4038ff;
+    text-align: center;
 }
+
 .box a:hover,
 .box a:active {
-  color: #fff !important;
-  -background: #4038ff;
+    color: #fff !important;
+    -background: #4038ff;
 }
 
 table td {
-  border-top: none !important;
+    border-top: none !important;
+}
+
+.box .table-hover tbody tr:hover {
+    color: white;
+    cursor: pointer;
 }
 
 .title {
-  position: absolute;
-  top: 0px;
-  left: 0px;
+    position: absolute;
+    top: 0px;
+    left: 0px;
 }
+
 .user {
-  min-width: 75px;
+    min-width: 75px;
 }
 
 .mint-label {
-  min-width: 75px;
+    min-width: 75px;
 }
 
+
 /* calendar */
 
 .rct-sidebar-row {
-  color: #fff;
+    color: #fff;
 }
+
 .rct-item-content {
-  font-size: 0.8em;
+    font-size: 0.8em;
 }
 
+
 /* timeline */
 
 .member-tooltip .tooltip-inner div {
-  width: 350px !important;
+    width: 350px !important;
 }
+
 .member-tooltip .tooltip-inner {
-  padding: 0 !important;
+    padding: 0 !important;
 }
 
 .timeline-container {
-  display: flex;
-  flex-direction: column;
-  position: relative;
-  margin: 40px 0;
+    display: flex;
+    flex-direction: column;
+    position: relative;
+    margin: 40px 0;
 }
 
 .timeline-container::after {
-  background-color: #e17b77;
-  content: "";
-  position: absolute;
-  left: calc(50% - 2px);
-  width: 4px;
-  height: 100%;
+    background-color: #e17b77;
+    content: "";
+    position: absolute;
+    left: calc(50% - 2px);
+    width: 4px;
+    height: 100%;
 }
 
 .timeline-item {
-  display: flex;
-  justify-content: flex-end;
-  padding-right: 30px;
-  position: relative;
-  margin: 10px 0;
-  width: 50%;
+    display: flex;
+    justify-content: flex-end;
+    padding-right: 30px;
+    position: relative;
+    margin: 10px 0;
+    width: 50%;
 }
 
 .timeline-item:nth-child(odd) {
-  align-self: flex-end;
-  justify-content: flex-start;
-  padding-left: 30px;
-  padding-right: 0;
+    align-self: flex-end;
+    justify-content: flex-start;
+    padding-left: 30px;
+    padding-right: 0;
 }
 
 .timeline-item-content {
-  box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
-  border-radius: 5px;
-  background-color: #fff;
-  display: flex;
-  flex-direction: column;
-  align-items: flex-end;
-  padding: 15px;
-  position: relative;
-  width: 400px;
-  max-width: 70%;
-  text-align: right;
-  overflow: hidden;
+    box-shadow: 0 0 5px rgba(0, 0, 0, 0.3);
+    border-radius: 5px;
+    background-color: #fff;
+    display: flex;
+    flex-direction: column;
+    align-items: flex-end;
+    padding: 15px;
+    position: relative;
+    width: 400px;
+    max-width: 70%;
+    text-align: right;
+    overflow: hidden;
 }
 
 .timeline-item-content::after {
-  content: " ";
-  background-color: #fff;
-  box-shadow: 1px -1px 1px rgba(0, 0, 0, 0.2);
-  position: absolute;
-  right: -7.5px;
-  top: calc(50% - 7.5px);
-  transform: rotate(45deg);
-  width: 15px;
-  height: 15px;
+    content: " ";
+    background-color: #fff;
+    box-shadow: 1px -1px 1px rgba(0, 0, 0, 0.2);
+    position: absolute;
+    right: -7.5px;
+    top: calc(50% - 7.5px);
+    transform: rotate(45deg);
+    width: 15px;
+    height: 15px;
 }
 
 .timeline-item:nth-child(odd) .timeline-item-content {
-  text-align: left;
-  align-items: flex-start;
+    text-align: left;
+    align-items: flex-start;
 }
 
 .timeline-item:nth-child(odd) .timeline-item-content::after {
-  right: auto;
-  left: -7.5px;
-  box-shadow: -1px 1px 1px rgba(0, 0, 0, 0.2);
+    right: auto;
+    left: -7.5px;
+    box-shadow: -1px 1px 1px rgba(0, 0, 0, 0.2);
 }
 
 .timeline-item-content .tag {
-  color: #fff;
-  font-size: 12px;
-  font-weight: bold;
-  top: 5px;
-  left: 5px;
-  letter-spacing: 1px;
-  padding: 5px;
-  position: absolute;
-  text-transform: uppercase;
+    color: #fff;
+    font-size: 12px;
+    font-weight: bold;
+    top: 5px;
+    left: 5px;
+    letter-spacing: 1px;
+    padding: 5px;
+    position: absolute;
+    text-transform: uppercase;
 }
 
 .timeline-item:nth-child(odd) .timeline-item-content .tag {
-  left: auto;
-  right: 5px;
+    left: auto;
+    right: 5px;
 }
 
 .timeline-item-content time {
-  color: #777;
-  font-size: 12px;
-  font-weight: bold;
+    color: #777;
+    font-size: 12px;
+    font-weight: bold;
 }
 
 .timeline-item-content p {
-  font-size: 16px;
-  line-height: 24px;
-  margin: 15px 0;
-  max-width: 250px;
+    font-size: 16px;
+    line-height: 24px;
+    margin: 15px 0;
+    max-width: 250px;
 }
+
 .timeline-item-content a {
-  font-size: 14px;
-  font-weight: bold;
+    font-size: 14px;
+    font-weight: bold;
 }
 
 .timeline-item-content a::after {
-  content: " ►";
-  font-size: 12px;
+    content: " ►";
+    font-size: 12px;
 }
 
 .timeline-item-content .circle {
-  background-color: #fff;
-  border: 3px solid #e17b77;
-  border-radius: 50%;
-  position: absolute;
-  top: calc(50% - 10px);
-  right: -40px;
-  width: 20px;
-  height: 20px;
-  z-index: 100;
+    background-color: #fff;
+    border: 3px solid #e17b77;
+    border-radius: 50%;
+    position: absolute;
+    top: calc(50% - 10px);
+    right: -40px;
+    width: 20px;
+    height: 20px;
+    z-index: 100;
 }
 
 .timeline-item:nth-child(odd) .timeline-item-content .circle {
-  right: auto;
-  left: -40px;
+    right: auto;
+    left: -40px;
 }
 
 @media only screen and (max-width: 1023px) {
-  .timeline-item-content {
-    max-width: 100%;
-  }
+    .timeline-item-content {
+        max-width: 100%;
+    }
 }
 
 @media only screen and (max-width: 767px) {
-  .timeline-item-content,
-  .timeline-item:nth-child(odd) .timeline-item-content {
-    padding: 15px 10px;
-    text-align: center;
-    align-items: center;
-  }
-
-  .timeline-item-content .tag {
-    width: calc(100% - 10px);
-    text-align: center;
-  }
-
-  .timeline-item-content time {
-    margin-top: 20px;
-  }
-
-  .timeline-item-content a {
-    text-decoration: underline;
-  }
-
-  .timeline-item-content a::after {
-    display: none;
-  }
+    .timeline-item-content,
+    .timeline-item:nth-child(odd) .timeline-item-content {
+        padding: 15px 10px;
+        text-align: center;
+        align-items: center;
+    }
+    .timeline-item-content .tag {
+        width: calc(100% - 10px);
+        text-align: center;
+    }
+    .timeline-item-content time {
+        margin-top: 20px;
+    }
+    .timeline-item-content a {
+        text-decoration: underline;
+    }
+    .timeline-item-content a::after {
+        display: none;
+    }
 }
 
 .waiting-validators .list-group-item {
-  background-color: #4038ff !important;
+    background-color: #4038ff !important;
 }
 
 .connecting {
-  background: orange;
-  position: fixed;
-  left: 0px;
-  bottom: 0px;
-  padding: 5px;
+    background: orange;
+    position: fixed;
+    left: 0px;
+    bottom: 0px;
+    padding: 5px;
 }
+
 .back {
-  position: absolute;
-  right: 0px;
-  top: 0px;
+    position: absolute;
+    right: 0px;
+    top: 0px;
 }
+
 .left {
-  left: 0px;
+    left: 0px;
 }
+
 .footer {
-  text-align: center;
-  posoition: fixed;
-  bottom: 0px;
-  padding: 5px;
-  width: 100%;
+    text-align: center;
+    posoition: fixed;
+    bottom: 0px;
+    padding: 5px;
+    width: 100%;
 }
+
 .footer-hidden {
-  position: fixed;
-  right: 0px;
-  bottom: 0px;
-}
+    position: fixed;
+    right: 0px;
+    bottom: 0px;
+}

+ 187 - 0
src/joyApi.ts

@@ -0,0 +1,187 @@
+import { ApiPromise, WsProvider } from "@polkadot/api";
+import { types } from "@joystream/types";
+import { AccountId, Hash } from "@polkadot/types/interfaces";
+import { config } from "dotenv";
+import BN from "bn.js";
+import { Option, Vec } from "@polkadot/types";
+import { wsLocation } from "./config";
+import { MemberId } from "@joystream/types/members";
+import { Member, IApplicant, IVote } from "./types";
+import { PromiseAllObj } from "./components/ValidatorReport/utils";
+import { IElectionStake, SealedVote } from "@joystream/types/council";
+
+config();
+
+export class JoyApi {
+  endpoint: string;
+  isReady: Promise<ApiPromise>;
+  api!: ApiPromise;
+
+  constructor() {
+    this.endpoint = wsLocation;
+    this.isReady = (async () =>
+      await new ApiPromise({ provider: new WsProvider(wsLocation), types })
+        .isReadyOrError)();
+  }
+
+  get init(): Promise<JoyApi> {
+    return this.isReady.then((instance) => {
+      this.api = instance;
+      return this;
+    });
+  }
+
+  async finalizedHash() {
+    return this.api.rpc.chain.getFinalizedHead();
+  }
+
+  async councilRound(): Promise<Number> {
+    return Number((await this.api.query.councilElection.round()).toJSON());
+  }
+
+  async councilSize(): Promise<Number> {
+    return Number((await this.api.query.councilElection.councilSize()).toJSON());
+  }
+
+  async termEndsAt(): Promise<Number> {
+    return Number((await this.api.query.council.termEndsAt()).toJSON());
+  }
+
+  async stage(): Promise<{ [key: string]: Number }> {
+    const stage = (await this.api.query.councilElection.stage()).toJSON();
+    return stage as unknown as { [key: string]: Number };
+  }
+
+  async getCouncilApplicants(): Promise<IApplicant[]> {
+    const addresses: AccountId[] = (
+      await this.api.query.councilElection.applicants()
+    ).toJSON() as unknown as AccountId[];
+    const members = await Promise.all(
+      addresses.map(async (address) => {
+        return PromiseAllObj({
+          address: address,
+          memberId: await this.api.query.members.memberIdsByRootAccountId(
+            address as unknown as AccountId
+          ),
+        });
+      })
+    );
+    return (await Promise.all(
+      members.map(async (member) => {
+        const { memberId, address } = member;
+        const id = (memberId as unknown as MemberId[])[0] as MemberId;
+        return PromiseAllObj({
+          member: (await this.api.query.members.membershipById(
+            id
+          )) as unknown as Member,
+          electionStake: (
+            await this.api.query.councilElection.applicantStakes(address)
+          ).toJSON() as unknown as IElectionStake,
+        } as IApplicant);
+      })
+    )) as IApplicant[];
+  }
+
+  async getVotes(): Promise<IVote[]> {
+    const commitments: AccountId[] = (
+      await this.api.query.councilElection.commitments()
+    ).toJSON() as unknown as AccountId[];
+    const votes = await Promise.all(
+      commitments.map(async (hash) => {
+        const vote = (await this.api.query.councilElection.votes(
+          hash
+        )) as unknown as SealedVote;
+        const newStake = vote.stake.new;
+        const transferredStake = vote.stake.transferred;
+        const voterId = await this.api.query.members.memberIdsByRootAccountId(
+          vote.voter
+        );
+        const voterMembership = (await this.api.query.members.membershipById(
+          voterId
+        )) as unknown as Member;
+        const voterHandle = voterMembership.handle;
+        const candidateId = `${vote.vote}`;
+        if (
+          candidateId === "" ||
+          candidateId === null ||
+          candidateId === undefined
+        ) {
+          return {
+            voterHandle: voterHandle,
+            voterId: voterId as unknown as Number,
+            newStake: newStake as unknown as Number,
+            transferredStake: transferredStake as unknown as Number,
+          } as IVote;
+        } else {
+          const voteId = await this.api.query.members.memberIdsByRootAccountId(
+            candidateId
+          );
+          const voteMembership = (await this.api.query.members.membershipById(
+            voteId
+          )) as unknown as Member;
+          const voteHandle = voteMembership.handle;
+          return {
+            voterHandle: voterHandle,
+            voterId: voterId as unknown as Number,
+            candidateHandle: voteHandle,
+            candidateId: voteId as unknown as Number,
+            newStake: newStake as unknown as Number,
+            transferredStake: transferredStake as unknown as Number,
+          } as IVote;
+        }
+      })
+    );
+    return votes;
+  }
+
+  async finalizedBlockHeight() {
+    const finalizedHash = await this.finalizedHash();
+    const { number } = await this.api.rpc.chain.getHeader(`${finalizedHash}`);
+    return number.toNumber();
+  }
+
+  async findActiveValidators(
+    hash: Hash,
+    searchPreviousBlocks: boolean
+  ): Promise<AccountId[]> {
+    const block = await this.api.rpc.chain.getBlock(hash);
+
+    let currentBlockNr = block.block.header.number.toNumber();
+    let activeValidators;
+    do {
+      let currentHash = (await this.api.rpc.chain.getBlockHash(
+        currentBlockNr
+      )) as Hash;
+      let allValidators = (await this.api.query.staking.snapshotValidators.at(
+        currentHash
+      )) as Option<Vec<AccountId>>;
+      if (!allValidators.isEmpty) {
+        let max = (
+          await this.api.query.staking.validatorCount.at(currentHash)
+        ).toNumber();
+        activeValidators = Array.from(allValidators.unwrap()).slice(0, max);
+      }
+
+      if (searchPreviousBlocks) {
+        --currentBlockNr;
+      } else {
+        ++currentBlockNr;
+      }
+    } while (activeValidators === undefined);
+    return activeValidators;
+  }
+
+  async validatorsData() {
+    const validators = await this.api.query.session.validators();
+    const era = await this.api.query.staking.currentEra();
+    const totalStake = era.isSome
+      ? await this.api.query.staking.erasTotalStake(era.unwrap())
+      : new BN(0);
+
+    return {
+      count: validators.length,
+      validators: validators.toJSON(),
+      total_stake: totalStake.toNumber(),
+    };
+  }
+}

+ 30 - 1
src/types.ts

@@ -2,6 +2,7 @@ import { ProposalParameters, VotingResults } from "@joystream/types/proposals";
 import { Nominations } from "@polkadot/types/interfaces";
 import { Option } from "@polkadot/types/codec";
 import { StorageKey } from "@polkadot/types/primitive";
+import { IElectionStake } from "@joystream/types/council";
 
 export interface Api {
   query: any;
@@ -52,11 +53,38 @@ export interface ElectionStage {
   termEndsAt: number;
 }
 
+export interface IApplicant {
+  member: Member;
+  electionStake: IElectionStake;
+}
+
+export interface IElectionState {
+  applicants: IApplicant[];
+  votes: IVote[];
+  stage: { [key: string]: Number };
+  councilRound: Number;
+  councilSize: Number;
+}
+
+export interface IVote {
+  voterHandle: string;
+  voterId: Number;
+  candidateHandle: string | undefined;
+  candidateId: Number | undefined;
+  newStake: Number;
+  transferredStake: Number;
+}
+
 export interface Status {
+  status: Seat[];
+  members: number;
+  proposalPosts: any;
+  councilApplicants: IApplicant[];
+  councilVotes: IVote[];
+  version: number;
   now: number;
   block: Block;
   era: number;
-  block: number;
   connecting: boolean;
   loading: string;
   council?: Council;
@@ -246,6 +274,7 @@ export interface Thread {
 }
 
 export interface Member {
+  rootKey: string;
   account: string;
   handle: string;
   id: number;