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

feat(operator): add nominate functionality to operator table #405

Closed
wants to merge 15 commits into from
Closed
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
1 change: 0 additions & 1 deletion .github/workflows/build.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ jobs:
run: |
yarn install
yarn lint:fix
yarn test
- name: Build project 🔧
run: yarn build
ts-lint-and-build-health-check:
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/gh-deploy-main.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ jobs:
steps:
- name: Checkout 🛎️
uses: actions/checkout@v3
with:
node-version: "18"

- name: Install and Build 🔧
run: |
Expand Down
1 change: 1 addition & 0 deletions .github/workflows/gh-deploy-staging.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,5 +21,6 @@ jobs:
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
NETLIFY_SITE_ID: ${{ secrets.NETLIFY_SITE_ID }}
with:
node-version: "18"
args: deploy --dir=client/build
secrets: '["NETLIFY_AUTH_TOKEN", "NETLIFY_SITE_ID"]'
3 changes: 3 additions & 0 deletions client/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
"@nivo/core": "^0.81.0",
"@nivo/line": "^0.81.0",
"@nivo/pie": "^0.81.0",
"@polkadot/api": "^10.11.2",
"@polkadot/extension-dapp": "^0.46.6",
"@polkadot/extension-inject": "^0.46.6",
"@polkadot/keyring": "^11.1.3",
"@polkadot/react-identicon": "^2.11.2",
"@polkadot/util": "^11.1.3",
Expand Down
4 changes: 3 additions & 1 deletion client/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,6 @@
import { Fragment, ReactNode, useEffect } from 'react'
import { Routes, Route, Navigate, useLocation, HashRouter } from 'react-router-dom'
import { Toaster } from 'react-hot-toast'

// common
import { INTERNAL_ROUTES } from 'common/routes'
Expand Down Expand Up @@ -37,7 +39,6 @@ import VoteBlockRewardList from 'Leaderboard/components/VoteBlockRewardList'
import NominatorRewardsList from 'Leaderboard/components/NominatorRewardsList'
import OperatorRewardsList from 'Leaderboard/components/OperatorRewardsList'
import DomainLayout from 'layout/components/DomainLayout'
import { Fragment, ReactNode, useEffect } from 'react'

const createDomainRoutes = () => {
return (
Expand Down Expand Up @@ -143,6 +144,7 @@ const App = () => {
<Route element={<NotResultsFound />} path={INTERNAL_ROUTES.search.empty} />
</Routes>
</UpdateSelectedChainByPath>
<Toaster />
</HashRouter>
)
}
Expand Down
39 changes: 39 additions & 0 deletions client/src/Operator/components/OperatorNominateModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/* eslint-disable jsx-a11y/no-static-element-interactions */
/* eslint-disable jsx-a11y/click-events-have-key-events */
import React, { FC } from 'react'

type Props = {
isOpen: boolean
onClose: () => void
children: React.ReactNode
}

const OperatorNominateModal: FC<Props> = ({ isOpen, onClose, children }) => {
return (
// backdrop
<div
onClick={onClose}
className={`fixed inset-0 flex justify-center items-center transition-colors ${
isOpen ? 'visible bg-black/20 z-20' : 'invisible'
}`}
>
<div
onClick={(e) => e.stopPropagation()}
className={`bg-white rounded-xl shadow p-6 transition-all dark:bg-gradient-to-r dark:from-[#4141B3] dark:via-[#6B5ACF] dark:to-[#896BD2] ${
isOpen ? 'scale-100 opacity-100' : 'scale-125 opacity-0'
}`}
>
<button
onClick={onClose}
className='absolute top-2 right-2 p-1 text-gray-400 hover:bg-[#DE67E4]/75 hover:text-gray-600 dark:text-white dark:hover:bg-[#DE67E4]/75 dark:hover:text-gray-800'
>
X
</button>

{children}
</div>
</div>
)
}

export default OperatorNominateModal
9 changes: 8 additions & 1 deletion client/src/Operator/components/OperatorsList.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FC, useState } from 'react'
import { useErrorHandler } from 'react-error-boundary'
import { useQuery } from '@apollo/client'
import { OperatorOrderByInput } from 'gql/graphql'

// common
import { Pagination, SearchBar, Spinner } from 'common/components'
Expand All @@ -11,13 +12,15 @@ import ExportButton from 'common/components/ExportButton'
// operator
import { QUERY_OPERATOR_CONNECTION_LIST } from 'Operator/query'
import OperatorsTable from 'Operator/components/OperatorsTable'
import OperatorsOrderByDropdown from './OperatorsOrderByDropdown'

const OperatorsList: FC = () => {
const [currentPage, setCurrentPage] = useState(0)
const [lastCursor, setLastCursor] = useState<string | undefined>(undefined)
const [orderBy, setOrderBy] = useState<OperatorOrderByInput[]>([OperatorOrderByInput.IdAsc])

const { data, error, loading } = useQuery(QUERY_OPERATOR_CONNECTION_LIST, {
variables: { first: PAGE_SIZE, after: lastCursor },
variables: { first: PAGE_SIZE, after: lastCursor, orderBy: orderBy },
pollInterval: 6000,
})

Expand Down Expand Up @@ -62,6 +65,10 @@ const OperatorsList: FC = () => {
</div>
<div className='w-full flex justify-between mt-5'>
<div className='text-[#282929] text-base font-medium dark:text-white'>{`Operators (${totalLabel})`}</div>
<div className='flex gap-4 items-center'>
<div className='text-[#282929] text-base font-medium dark:text-white'>Order By</div>
<OperatorsOrderByDropdown setOrderBy={setOrderBy} orderBy={orderBy} />
</div>
</div>
<div className='w-full flex flex-col mt-5 sm:mt-0'>
<OperatorsTable operators={operatorsConnection} />
Expand Down
72 changes: 72 additions & 0 deletions client/src/Operator/components/OperatorsOrderByDropdown.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { FC, Fragment } from 'react'
import { Listbox, Transition } from '@headlessui/react'
import { CheckIcon, ChevronDownIcon } from '@heroicons/react/20/solid'

// common
import { OperatorOrderByInput } from 'gql/graphql'

type Props = {
orderBy: OperatorOrderByInput[]
setOrderBy: React.Dispatch<React.SetStateAction<OperatorOrderByInput[]>>
}

const OperatorsOrderByDropdown: FC<Props> = ({ setOrderBy, orderBy }) => {
const dropdownValues = Object.values(OperatorOrderByInput)

const handleOrderBy = (value: OperatorOrderByInput[]) => {
setOrderBy(value)
}

return (
<Listbox value={orderBy} onChange={handleOrderBy}>
<div className='relative'>
<Listbox.Button className='font-["Montserrat"] relative w-full cursor-default rounded-full bg-white py-2 pl-3 pr-10 text-left shadow-md focus:outline-none focus-visible:border-indigo-500 focus-visible:ring-2 focus-visible:ring-white focus-visible:ring-opacity-75 focus-visible:ring-offset-2 focus-visible:ring-offset-orange-300 sm:text-sm dark:bg-gradient-to-r from-[#EA71F9] to-[#4D397A] dark:text-white'>
<div className='flex items-center justify-center'>
<span className='hidden sm:block truncate w-5 text-sm md:w-full '>{orderBy}</span>
<span className='pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2'>
<ChevronDownIcon
className='h-5 w-5 text-gray-400 ui-open:rotate-180 ui-open:transform dark:text-[#DE67E4]'
aria-hidden='true'
/>
</span>
</div>
</Listbox.Button>
<Transition
as={Fragment}
leave='transition ease-in duration-100'
leaveFrom='opacity-100'
leaveTo='opacity-0'
>
<Listbox.Options className='absolute mt-1 max-h-60 w-auto md:w-full overflow-auto rounded-md bg-white py-2 text-base shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none sm:text-sm dark:bg-[#1E254E] dark:text-white'>
{dropdownValues.map((order, chainIdx) => (
<Listbox.Option
key={chainIdx}
className={({ active }) =>
`relative cursor-default select-none py-2 pl-4 text-gray-900 md:pl-10 pr-4 dark:text-white ${
active && 'bg-gray-100 dark:bg-[#2A345E]'
}`
}
value={order}
>
{({ selected }) => (
<>
<span className={`block truncate ${selected ? 'font-medium' : 'font-normal'}`}>
{order}
</span>
{selected ? (
<span className='absolute inset-y-0 left-0 flex items-center pl-3 text-[#37D058]'>
<CheckIcon className='h-5 w-5 hidden md:block' aria-hidden='true' />
</span>
) : null}
</>
)}
</Listbox.Option>
))}
</Listbox.Options>
</Transition>
</div>
</Listbox>
)
}

export default OperatorsOrderByDropdown
134 changes: 131 additions & 3 deletions client/src/Operator/components/OperatorsTable.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { FC } from 'react'
import { FC, useState } from 'react'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import { Operator } from 'gql/graphql'
import toast from 'react-hot-toast'
import SubspaceIcon from 'common/icons/subspaceIcon.png'

// common
import { Table, Column } from 'common/components'
Expand All @@ -13,6 +15,8 @@ import OperatorsListCard from 'Operator/components/OperatorsListCard'
import { Link } from 'react-router-dom'
import { INTERNAL_ROUTES } from 'common/routes'
import useDomains from 'common/hooks/useDomains'
import OperatorNominateModal from './OperatorNominateModal'
import { ethers } from 'ethers'

dayjs.extend(relativeTime)

Expand All @@ -22,11 +26,73 @@ interface Props {

const OperatorsTable: FC<Props> = ({ operators }) => {
const isDesktop = useMediaQuery('(min-width: 640px)')

const { selectedChain, selectedDomain } = useDomains()
const [isOpen, setIsOpen] = useState(false)
const [operator, setSelectedOperator] = useState<Operator>()
const [amount, setAmount] = useState(0)
const { selectedChain, selectedDomain, api, selectedAccount, injectedExtension } = useDomains()

const chain = selectedChain.urls.page

const handleNominate = async (operator) => {
if (!api || !selectedAccount || !injectedExtension) {
toast.error('No wallet connected or no address available', {
position: 'bottom-center',
})
return
}

setSelectedOperator(operator)
setAmount(bigNumberToNumber(operator.minimumNominatorStake))
setIsOpen(true)
}

const handleSubmit = async (event) => {
event.preventDefault()
if (!api || !selectedAccount || !injectedExtension || !operator) {
return toast.error('No wallet connected or no address available', {
position: 'bottom-center',
})
}

if (amount <= 0) {
return toast.error('Amount must be greater than 0', {
position: 'bottom-center',
})
}

const nominatorMinStake = bigNumberToNumber(operator.minimumNominatorStake)

const isNominator = operator?.nominators?.find(
(nominator) => nominator.id === selectedAccount?.address,
)

if (!isNominator && amount < nominatorMinStake) {
return toast.error('Amount is less than minimum stake', {
position: 'bottom-center',
})
}

const amountInWei = ethers.parseUnits(amount.toString(), 'ether')

try {
const hash = await api.tx.domains
.nominateOperator(operator.id, amountInWei)
.signAndSend(selectedAccount.address, {
signer: injectedExtension.signer,
})

toast.success(`Tx sent ${hash}`, {
position: 'bottom-center',
})

setIsOpen(false)
} catch (err) {
toast.error(`Something went wrong ${err}`, {
position: 'bottom-center',
})
}
}

// methods
const generateColumns = (operators: Operator[]): Column[] => [
{
Expand Down Expand Up @@ -92,6 +158,19 @@ const OperatorsTable: FC<Props> = ({ operators }) => {
title: 'Status',
cells: operators.map(({ status, id }) => <div key={`${id}-operator-status`}>{status}</div>),
},
{
title: 'Nominate',
cells: operators.map((operator) => (
<div key={`${operator.id}-operator-created`}>
<button
onClick={() => handleNominate(operator)}
className='flex items-center justify-center text-sm font-medium text-white'
>
<img src={SubspaceIcon} alt='icon' className='h-10 w-10' />
</button>
</div>
)),
},
]

// constants
Expand All @@ -108,6 +187,55 @@ const OperatorsTable: FC<Props> = ({ operators }) => {
id='operators-list'
/>
</div>
<OperatorNominateModal isOpen={isOpen} onClose={() => setIsOpen(false)}>
<div className='text-center w-80'>
<div className='w-full p-10 mx-auto my-4'>
<h3 className='font-medium text-[#241235] text-base break-all dark:text-white'>
Nominate Operator
</h3>

<div className='w-full mt-4 flex flex-col gap-6'>
<div className='flex justify-between'>
<div className='flex flex-col justify-between gap-2 text-start'>
<div className='text-[#241235] text-sm dark:text-white'>Operator </div>
<div className='text-[#857EC2] text-sm dark:text-white/75'>{operator?.id}</div>
</div>
<div className='flex flex-col justify-between gap-2 text-start'>
<div className='text-[#241235] text-sm dark:text-white'>Domain </div>
<div className='text-[#857EC2] text-sm dark:text-white/75'>
{operator?.currentDomainId === 0 ? 'Subspace' : 'Nova'}
</div>
</div>
</div>

<div className='flex justify-between text-start'>
<div className='text-[#857EC2] text-xs dark:text-white/75'>Min Amount</div>
<div className='text-[#857EC2] text-xs dark:text-white/75'>{`${bigNumberToNumber(
operator ? operator.minimumNominatorStake : 0,
)} tSSC`}</div>
</div>

<form className='flex gap-6 items-center' onSubmit={(event) => handleSubmit(event)}>
<div className='flex flex-col gap-4 items-center'>
<input
className='form-control rounded-lg w-36'
value={amount}
type='number'
placeholder='Amount'
onChange={(e) => setAmount(Number(e.target.value))}
/>
</div>
<button
type='submit'
className='p-2 btn btn-primary text-white font-medium bg-[#DE67E4] rounded-md hover:bg-[#D64FC5]'
>
Submit
</button>
</form>
</div>
</div>
</div>
</OperatorNominateModal>
</div>
) : (
<div className='w-full'>
Expand Down
4 changes: 2 additions & 2 deletions client/src/Operator/query.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { gql } from '@apollo/client'

export const QUERY_OPERATOR_CONNECTION_LIST = gql`
query OperatorsConnection($first: Int!, $after: String) {
operatorsConnection(orderBy: id_ASC, first: $first, after: $after) {
query OperatorsConnection($first: Int!, $after: String, $orderBy: [OperatorOrderByInput!]!) {
operatorsConnection(orderBy: $orderBy, first: $first, after: $after) {
edges {
node {
id
Expand Down
Loading
Loading