Browse Source

feat: filter for voted props (#2173)

* use getSignaturesForAddress

* feat(xc-admin-frontend): add a filter to show voted proposals

* fix rebase

---------

Co-authored-by: Connor Prussin <connor@prussin.net>
guibescos 11 months ago
parent
commit
1e169bd5c4

+ 0 - 89
governance/xc_admin/packages/xc_admin_frontend/components/ProposalStatusFilter.tsx

@@ -1,89 +0,0 @@
-import { Menu, Transition } from '@headlessui/react'
-import { useRouter } from 'next/router'
-import { Fragment, useCallback, useContext, useEffect } from 'react'
-import {
-  DEFAULT_STATUS_FILTER,
-  type ProposalStatusFilter,
-  StatusFilterContext,
-} from '../contexts/StatusFilterContext'
-import Arrow from '@images/icons/down.inline.svg'
-
-const ProposalStatusFilter = () => {
-  const router = useRouter()
-  const { statusFilter, setStatusFilter } = useContext(StatusFilterContext)
-
-  const handleChange = useCallback(
-    (event: any) => {
-      if (event.target.value) {
-        router.query.status = event.target.value
-        setStatusFilter(event.target.value)
-        router.push(
-          {
-            pathname: router.pathname,
-            query: router.query,
-          },
-          undefined,
-          { scroll: false }
-        )
-      }
-    },
-    [setStatusFilter, router]
-  )
-
-  useEffect(() => {
-    router.query && router.query.status
-      ? setStatusFilter(router.query.status as ProposalStatusFilter)
-      : setStatusFilter(DEFAULT_STATUS_FILTER)
-  }, [setStatusFilter, router])
-
-  const statuses: ProposalStatusFilter[] = [
-    'all',
-    'active',
-    'executed',
-    'executeReady',
-    'cancelled',
-    'rejected',
-    'draft',
-    'expired',
-  ]
-
-  return (
-    <Menu as="div" className="relative z-[3] block w-[180px] text-left">
-      {({ open }) => (
-        <>
-          <Menu.Button
-            className={`inline-flex w-full items-center justify-between bg-darkGray2 py-3 px-6 text-sm outline-0`}
-          >
-            <span className="mr-3">{statusFilter}</span>
-            <Arrow className={`${open && 'rotate-180'}`} />
-          </Menu.Button>
-          <Transition
-            as={Fragment}
-            enter="transition ease-out duration-100"
-            enterFrom="transform opacity-0 scale-95"
-            enterTo="transform opacity-100 scale-100"
-            leave="transition ease-in duration-75"
-            leaveFrom="transform opacity-100 scale-100"
-            leaveTo="transform opacity-0 scale-95"
-          >
-            <Menu.Items className="absolute right-0 mt-2 w-full origin-top-right">
-              {statuses.map((s) => (
-                <Menu.Item key={s}>
-                  <button
-                    className={`block w-full bg-darkGray py-3 px-6 text-left text-sm hover:bg-darkGray2`}
-                    value={s}
-                    onClick={handleChange}
-                  >
-                    {s}
-                  </button>
-                </Menu.Item>
-              ))}
-            </Menu.Items>
-          </Transition>
-        </>
-      )}
-    </Menu>
-  )
-}
-
-export default ProposalStatusFilter

+ 63 - 0
governance/xc_admin/packages/xc_admin_frontend/components/Select.tsx

@@ -0,0 +1,63 @@
+import {
+  Field,
+  Label,
+  Listbox,
+  ListboxButton,
+  ListboxOptions,
+  ListboxOption,
+  Transition,
+} from '@headlessui/react'
+import type { ComponentProps } from 'react'
+import Arrow from '@images/icons/down.inline.svg'
+import { Fragment } from 'react'
+
+type OwnProps<T> = {
+  label: string
+  options: readonly T[]
+  value: T
+  onChange: (newValue: T) => void
+}
+
+type Props<T> = Omit<ComponentProps<typeof Listbox>, keyof OwnProps<T>> &
+  OwnProps<T>
+
+export const Select = <T extends string>({
+  options,
+  label,
+  ...props
+}: Props<T>) => (
+  <Field className="flex flex-col gap-1">
+    <Label>{label}</Label>
+    <Listbox as="div" className="relative block w-[180px] text-left" {...props}>
+      {({ open }) => (
+        <>
+          <ListboxButton className="inline-flex w-full items-center justify-between bg-darkGray2 py-3 px-6 text-sm outline-0">
+            <span className="mr-3">{props.value}</span>
+            <Arrow className={`${open && 'rotate-180'}`} />
+          </ListboxButton>
+          <Transition
+            as={Fragment}
+            enter="transition ease-out duration-100"
+            enterFrom="transform opacity-0 scale-95"
+            enterTo="transform opacity-100 scale-100"
+            leave="transition ease-in duration-75"
+            leaveFrom="transform opacity-100 scale-100"
+            leaveTo="transform opacity-0 scale-95"
+          >
+            <ListboxOptions className="absolute right-0 mt-2 w-full origin-top-right z-10">
+              {options.map((option) => (
+                <ListboxOption
+                  key={option}
+                  value={option}
+                  className="block w-full bg-darkGray py-3 px-6 text-left text-sm hover:bg-darkGray2 cursor-pointer"
+                >
+                  {option}
+                </ListboxOption>
+              ))}
+            </ListboxOptions>
+          </Transition>
+        </>
+      )}
+    </Listbox>
+  </Field>
+)

+ 20 - 0
governance/xc_admin/packages/xc_admin_frontend/components/tabs/Proposals/ProposalRow.tsx

@@ -9,6 +9,7 @@ import { StatusTag } from './StatusTag'
 import { getInstructionsSummary, getProposalStatus } from './utils'
 
 import NodeWallet from '@coral-xyz/anchor/dist/cjs/nodewallet'
+import { useWallet } from '@solana/wallet-adapter-react'
 import { AccountMeta, Keypair } from '@solana/web3.js'
 import {
   MultisigParser,
@@ -30,6 +31,7 @@ export const ProposalRow = ({
   const { isLoading: isMultisigLoading, connection } = useMultisigContext()
   const router = useRouter()
   const elementRef = useRef(null)
+  const { publicKey: walletPublicKey } = useWallet()
   const formattedTime = time?.toLocaleString(undefined, {
     year: 'numeric',
     month: 'short',
@@ -191,6 +193,24 @@ export const ProposalRow = ({
               />
             </div>
           )}
+          {walletPublicKey &&
+            proposal.approved.some((vote) => vote.equals(walletPublicKey)) && (
+              <div>
+                <StatusTag proposalStatus="executed" text="You approved" />
+              </div>
+            )}
+          {walletPublicKey &&
+            proposal.rejected.some((vote) => vote.equals(walletPublicKey)) && (
+              <div>
+                <StatusTag proposalStatus="rejected" text="You rejected" />
+              </div>
+            )}
+          {walletPublicKey &&
+            proposal.cancelled.some((vote) => vote.equals(walletPublicKey)) && (
+              <div>
+                <StatusTag proposalStatus="cancelled" text="You cancelled" />
+              </div>
+            )}
           <div>
             <StatusTag proposalStatus={status} />
           </div>

+ 98 - 20
governance/xc_admin/packages/xc_admin_frontend/components/tabs/Proposals/Proposals.tsx

@@ -1,25 +1,50 @@
 import { TransactionAccount } from '@sqds/mesh/lib/types'
 import { useRouter } from 'next/router'
-import { useCallback, useContext, useEffect, useState } from 'react'
+import { useCallback, useContext, useEffect, useState, useMemo } from 'react'
 import { ClusterContext } from '../../../contexts/ClusterContext'
 import { useMultisigContext } from '../../../contexts/MultisigContext'
-import { StatusFilterContext } from '../../../contexts/StatusFilterContext'
+import { PROPOSAL_STATUSES } from './utils'
 import ClusterSwitch from '../../ClusterSwitch'
-import ProposalStatusFilter from '../../ProposalStatusFilter'
 import Loadbar from '../../loaders/Loadbar'
+import { Select } from '../../Select'
+import { useQueryState, parseAsStringLiteral } from 'nuqs'
 
 import { ProposalRow } from './ProposalRow'
 import { getProposalStatus } from './utils'
 import { Proposal } from './Proposal'
+import { useWallet } from '@solana/wallet-adapter-react'
 
 type ProposalType = 'priceFeed' | 'governance'
 
+const VOTE_STATUSES = [
+  'any',
+  'voted',
+  'approved',
+  'rejected',
+  'cancelled',
+  'notVoted',
+] as const
+const DEFAULT_VOTE_STATUS = 'any'
+
+const PROPOSAL_STATUS_FILTERS = ['all', ...PROPOSAL_STATUSES] as const
+const DEFAULT_PROPOSAL_STATUS_FILTER = 'all'
+
 const Proposals = () => {
   const router = useRouter()
   const [currentProposal, setCurrentProposal] = useState<TransactionAccount>()
   const [currentProposalPubkey, setCurrentProposalPubkey] = useState<string>()
+  const [statusFilter, setStatusFilter] = useQueryState(
+    'status',
+    parseAsStringLiteral(PROPOSAL_STATUS_FILTERS).withDefault(
+      DEFAULT_PROPOSAL_STATUS_FILTER
+    )
+  )
+  const [voteStatus, setVoteStatus] = useQueryState(
+    'voteStatus',
+    parseAsStringLiteral(VOTE_STATUSES).withDefault(DEFAULT_VOTE_STATUS)
+  )
   const { cluster } = useContext(ClusterContext)
-  const { statusFilter } = useContext(StatusFilterContext)
+  const { publicKey: walletPublicKey } = useWallet()
 
   const {
     upgradeMultisigAccount,
@@ -40,9 +65,6 @@ const Proposals = () => {
     proposalType === 'priceFeed'
       ? priceFeedMultisigProposals
       : upgradeMultisigProposals
-  const [filteredProposals, setFilteredProposals] = useState<
-    TransactionAccount[]
-  >([])
 
   const handleClickBackToProposals = () => {
     delete router.query.proposal
@@ -103,19 +125,60 @@ const Proposals = () => {
     cluster,
   ])
 
-  useEffect(() => {
-    // filter price feed multisig proposals by status
-    if (statusFilter === 'all') {
-      setFilteredProposals(multisigProposals)
+  const proposalsFilteredByStatus = useMemo(
+    () =>
+      statusFilter === 'all'
+        ? multisigProposals
+        : multisigProposals.filter(
+            (proposal) =>
+              getProposalStatus(proposal, multisigAccount) === statusFilter
+          ),
+    [statusFilter, multisigAccount, multisigProposals]
+  )
+
+  const filteredProposals = useMemo(() => {
+    if (walletPublicKey) {
+      switch (voteStatus) {
+        case 'any':
+          return proposalsFilteredByStatus
+        case 'voted': {
+          return proposalsFilteredByStatus.filter((proposal) =>
+            [
+              ...proposal.approved,
+              ...proposal.rejected,
+              ...proposal.cancelled,
+            ].some((vote) => vote.equals(walletPublicKey))
+          )
+        }
+        case 'approved': {
+          return proposalsFilteredByStatus.filter((proposal) =>
+            proposal.approved.some((vote) => vote.equals(walletPublicKey))
+          )
+        }
+        case 'rejected': {
+          return proposalsFilteredByStatus.filter((proposal) =>
+            proposal.rejected.some((vote) => vote.equals(walletPublicKey))
+          )
+        }
+        case 'cancelled': {
+          return proposalsFilteredByStatus.filter((proposal) =>
+            proposal.cancelled.some((vote) => vote.equals(walletPublicKey))
+          )
+        }
+        case 'notVoted': {
+          return proposalsFilteredByStatus.filter((proposal) =>
+            [
+              ...proposal.approved,
+              ...proposal.rejected,
+              ...proposal.cancelled,
+            ].every((vote) => !vote.equals(walletPublicKey))
+          )
+        }
+      }
     } else {
-      setFilteredProposals(
-        multisigProposals.filter(
-          (proposal) =>
-            getProposalStatus(proposal, multisigAccount) === statusFilter
-        )
-      )
+      return proposalsFilteredByStatus
     }
-  }, [statusFilter, multisigAccount, multisigProposals])
+  }, [proposalsFilteredByStatus, walletPublicKey, voteStatus])
 
   return (
     <div className="relative">
@@ -167,11 +230,26 @@ const Proposals = () => {
                 </div>
               ) : (
                 <>
-                  <div className="flex items-center justify-between pb-4">
+                  <div className="flex items-end md:flex-row-reverse justify-between pb-4">
+                    <div className="flex flex-col md:flex-row md:items-center gap-4 text-sm">
+                      {walletPublicKey && (
+                        <Select
+                          label="Your Vote"
+                          value={voteStatus}
+                          options={VOTE_STATUSES}
+                          onChange={setVoteStatus}
+                        />
+                      )}
+                      <Select
+                        label="Proposal Status"
+                        value={statusFilter}
+                        options={PROPOSAL_STATUS_FILTERS}
+                        onChange={setStatusFilter}
+                      />
+                    </div>
                     <h4 className="h4">
                       Total Proposals: {filteredProposals.length}
                     </h4>
-                    <ProposalStatusFilter />
                   </div>
                   {filteredProposals.length > 0 ? (
                     <div className="flex flex-col">

+ 11 - 9
governance/xc_admin/packages/xc_admin_frontend/components/tabs/Proposals/utils.ts

@@ -10,15 +10,17 @@ import {
   WormholeMultisigInstruction,
 } from '@pythnetwork/xc-admin-common'
 
-export type ProposalStatus =
-  | 'active'
-  | 'executed'
-  | 'cancelled'
-  | 'rejected'
-  | 'expired'
-  | 'executeReady'
-  | 'draft'
-  | 'unkwown'
+export const PROPOSAL_STATUSES = [
+  'active',
+  'executed',
+  'cancelled',
+  'rejected',
+  'expired',
+  'executeReady',
+  'draft',
+  'unkwown',
+] as const
+export type ProposalStatus = typeof PROPOSAL_STATUSES[number]
 
 export const getProposalStatus = (
   proposal: TransactionAccount | undefined,

+ 0 - 34
governance/xc_admin/packages/xc_admin_frontend/contexts/StatusFilterContext.tsx

@@ -1,34 +0,0 @@
-import { ReactNode, createContext, useMemo, useState } from 'react'
-import { ProposalStatus } from '../components/tabs/Proposals/utils'
-
-export const DEFAULT_STATUS_FILTER = 'all'
-
-export type ProposalStatusFilter = 'all' | ProposalStatus
-
-export const StatusFilterContext = createContext<{
-  statusFilter: ProposalStatusFilter
-  setStatusFilter: (_statusFilter: ProposalStatusFilter) => void
-}>({
-  statusFilter: DEFAULT_STATUS_FILTER,
-  setStatusFilter: () => {},
-})
-
-export const StatusFilterProvider = ({ children }: { children: ReactNode }) => {
-  const [statusFilter, setStatusFilter] = useState<ProposalStatusFilter>(
-    DEFAULT_STATUS_FILTER
-  )
-  const contextValue = useMemo(
-    () => ({
-      statusFilter,
-      setStatusFilter: (statusFilter: ProposalStatusFilter) => {
-        setStatusFilter(statusFilter)
-      },
-    }),
-    [statusFilter]
-  )
-  return (
-    <StatusFilterContext.Provider value={contextValue}>
-      {children}
-    </StatusFilterContext.Provider>
-  )
-}

+ 1 - 0
governance/xc_admin/packages/xc_admin_frontend/package.json

@@ -34,6 +34,7 @@
     "message_buffer": "workspace:^",
     "next": "catalog:",
     "next-seo": "^5.15.0",
+    "nuqs": "catalog:",
     "react": "catalog:",
     "react-dom": "catalog:",
     "react-hot-toast": "^2.4.0",

+ 3 - 2
governance/xc_admin/packages/xc_admin_frontend/pages/_app.tsx

@@ -25,6 +25,7 @@ import { Toaster } from 'react-hot-toast'
 import { ClusterProvider } from '../contexts/ClusterContext'
 import SEO from '../next-seo.config'
 import '../styles/globals.css'
+import { NuqsAdapter } from 'nuqs/adapters/next/pages'
 
 const walletConnectConfig: WalletConnectWalletAdapterConfig = {
   network: WalletAdapterNetwork.Mainnet,
@@ -66,7 +67,7 @@ function MyApp({ Component, pageProps }: AppProps) {
   )
 
   return (
-    <>
+    <NuqsAdapter>
       <ConnectionProvider
         endpoint={endpoint || clusterApiUrl(WalletAdapterNetwork.Devnet)}
       >
@@ -94,7 +95,7 @@ function MyApp({ Component, pageProps }: AppProps) {
           </WalletModalProvider>
         </WalletProvider>
       </ConnectionProvider>
-    </>
+    </NuqsAdapter>
   )
 }
 

+ 1 - 6
governance/xc_admin/packages/xc_admin_frontend/pages/index.tsx

@@ -11,7 +11,6 @@ import Proposals from '../components/tabs/Proposals/Proposals'
 import UpdatePermissions from '../components/tabs/UpdatePermissions'
 import { MultisigContextProvider } from '../contexts/MultisigContext'
 import { PythContextProvider } from '../contexts/PythContext'
-import { StatusFilterProvider } from '../contexts/StatusFilterContext'
 import { classNames } from '../utils/classNames'
 import '../mappings/signers.json'
 
@@ -165,11 +164,7 @@ const Home: NextPage<{
           {tabInfoArray[currentTabIndex].queryString ===
             TAB_INFO.UpdatePermissions.queryString && <UpdatePermissions />}
           {tabInfoArray[currentTabIndex].queryString ===
-            TAB_INFO.Proposals.queryString && (
-            <StatusFilterProvider>
-              <Proposals />
-            </StatusFilterProvider>
-          )}
+            TAB_INFO.Proposals.queryString && <Proposals />}
         </MultisigContextProvider>
       </PythContextProvider>
     </Layout>

File diff suppressed because it is too large
+ 423 - 35
pnpm-lock.yaml


Some files were not shown because too many files changed in this diff