diff --git a/.nvmrc b/.nvmrc index 6f7f377bf5..3f430af82b 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -v16 +v18 diff --git a/centrifuge-app/src/assets/images/aave-token-logo.svg b/centrifuge-app/src/assets/images/aave-token-logo.svg new file mode 100644 index 0000000000..d784fbe0a4 --- /dev/null +++ b/centrifuge-app/src/assets/images/aave-token-logo.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/centrifuge-app/src/components/DataTable.tsx b/centrifuge-app/src/components/DataTable.tsx index d9bdfaec2d..caa3f9a0dd 100644 --- a/centrifuge-app/src/components/DataTable.tsx +++ b/centrifuge-app/src/components/DataTable.tsx @@ -1,10 +1,27 @@ -import { Grid, IconChevronDown, IconChevronUp, Shelf, Stack, Text } from '@centrifuge/fabric' +import { + Box, + Checkbox, + Divider, + Grid, + IconChevronDown, + IconChevronUp, + IconFilter, + Menu, + Popover, + Shelf, + Stack, + Text, + Tooltip, +} from '@centrifuge/fabric' import css from '@styled-system/css' import BN from 'bn.js' import * as React from 'react' import { Link, LinkProps } from 'react-router-dom' import styled from 'styled-components' import { useElementScrollSize } from '../utils/useElementScrollSize' +import { FiltersState } from '../utils/useFilters' +import { FilterButton } from './FilterButton' +import { QuickAction } from './QuickAction' type GroupedProps = { groupIndex?: number @@ -254,8 +271,110 @@ export function SortableTableHeader({ ) } +export function FilterableTableHeader({ + filterKey: key, + label, + options, + filters, + tooltip, +}: { + filterKey: string + label: string + options: string[] | Record + filters: FiltersState + tooltip?: string +}) { + const optionKeys = Array.isArray(options) ? options : Object.keys(options) + const form = React.useRef(null) + + function handleChange() { + if (!form.current) return + const formData = new FormData(form.current) + const entries = formData.getAll(key) as string[] + filters.setFilter(key, entries) + } + + function deselectAll() { + filters.setFilter(key, []) + } + + function selectAll() { + filters.setFilter(key, optionKeys) + } + const state = filters.getState() + const selectedOptions = state[key] as Set | undefined + + return ( + + { + return ( + + {tooltip ? ( + + + {label} + + + + ) : ( + + {label} + + + )} + + ) + }} + renderContent={(props, ref) => ( + + + + + + Filter {label} by: + + {optionKeys.map((option, index) => { + const label = Array.isArray(options) ? option : options[option] + const checked = filters.hasFilter(key, option) + + return ( + + ) + })} + + + + + {selectedOptions?.size === optionKeys.length ? ( + deselectAll()}> + Deselect all + + ) : ( + selectAll()}> + Select all + + )} + + + + )} + /> + + ) +} + const StyledHeader = styled(Shelf)` - color: ${({ theme }) => theme.colors.textSecondary}; + color: ${({ theme }) => theme.colors.textPrimary}; cursor: pointer; appearance: none; border: none; diff --git a/centrifuge-app/src/components/DebugFlags/config.ts b/centrifuge-app/src/components/DebugFlags/config.ts index 714a67508f..f62aee2939 100644 --- a/centrifuge-app/src/components/DebugFlags/config.ts +++ b/centrifuge-app/src/components/DebugFlags/config.ts @@ -47,6 +47,7 @@ export type Key = | 'showPodAccountCreation' | 'convertEvmAddress' | 'showPortfolio' + | 'showPrime' | 'poolCreationType' export const flagsConfig: Record = { @@ -121,6 +122,11 @@ export const flagsConfig: Record = { default: false, alwaysShow: true, }, + showPrime: { + type: 'checkbox', + default: false, + alwaysShow: true, + }, poolCreationType: { type: 'select', default: config.poolCreationType || 'immediate', diff --git a/centrifuge-app/src/components/LayoutBase/BasePadding.tsx b/centrifuge-app/src/components/LayoutBase/BasePadding.tsx index 05e1dbe02a..9c9ef1b4b6 100644 --- a/centrifuge-app/src/components/LayoutBase/BasePadding.tsx +++ b/centrifuge-app/src/components/LayoutBase/BasePadding.tsx @@ -7,7 +7,7 @@ type BaseSectionProps = BoxProps & { export function BasePadding({ children, ...boxProps }: BaseSectionProps) { return ( - + {children} ) diff --git a/centrifuge-app/src/components/LayoutBase/LayoutSection.tsx b/centrifuge-app/src/components/LayoutBase/LayoutSection.tsx new file mode 100644 index 0000000000..237a2a1ff2 --- /dev/null +++ b/centrifuge-app/src/components/LayoutBase/LayoutSection.tsx @@ -0,0 +1,43 @@ +import { Box, BoxProps, Shelf, Stack, Text } from '@centrifuge/fabric' +import * as React from 'react' +import { BasePadding } from './BasePadding' + +type Props = { + title?: React.ReactNode + titleAddition?: React.ReactNode + subtitle?: string + headerRight?: React.ReactNode + children: React.ReactNode +} & BoxProps + +export function LayoutSection({ title, titleAddition, subtitle, headerRight, children, ...boxProps }: Props) { + return ( + + {(title || titleAddition || subtitle || headerRight) && ( + + + {(title || titleAddition) && ( + + {title && ( + + {title} + + )} + + {titleAddition} + + + )} + {subtitle && ( + + {subtitle} + + )} + + {headerRight} + + )} + {children} + + ) +} diff --git a/centrifuge-app/src/components/Menu/index.tsx b/centrifuge-app/src/components/Menu/index.tsx index 89f774b5a8..4fd89bd618 100644 --- a/centrifuge-app/src/components/Menu/index.tsx +++ b/centrifuge-app/src/components/Menu/index.tsx @@ -1,6 +1,7 @@ import { Box, IconClock, + IconGlobe, IconInvestments, IconNft, IconPieChart, @@ -24,7 +25,7 @@ export function Menu() { const pools = usePoolsThatAnyConnectedAddressHasPermissionsFor() || [] const isLarge = useIsAboveBreakpoint('L') const address = useAddress('substrate') - const { showPortfolio } = useDebugFlags() + const { showPortfolio, showPrime } = useDebugFlags() return ( )} + {showPrime && ( + + + Prime + + )} {showPortfolio && address && ( diff --git a/centrifuge-app/src/components/Root.tsx b/centrifuge-app/src/components/Root.tsx index 99c61dfb53..41736f4bd8 100644 --- a/centrifuge-app/src/components/Root.tsx +++ b/centrifuge-app/src/components/Root.tsx @@ -169,6 +169,8 @@ const PoolDetailPage = React.lazy(() => import('../pages/Pool')) const PortfolioPage = React.lazy(() => import('../pages/Portfolio')) const TransactionHistoryPage = React.lazy(() => import('../pages/Portfolio/TransactionHistory')) const TokenOverviewPage = React.lazy(() => import('../pages/Tokens')) +const PrimePage = React.lazy(() => import('../pages/Prime')) +const PrimeDetailPage = React.lazy(() => import('../pages/Prime/Detail')) function Routes() { return ( @@ -218,6 +220,12 @@ function Routes() { + + + + + + diff --git a/centrifuge-app/src/components/styles.ts b/centrifuge-app/src/components/styles.ts index 2203668ef8..7617c1d354 100644 --- a/centrifuge-app/src/components/styles.ts +++ b/centrifuge-app/src/components/styles.ts @@ -7,8 +7,8 @@ export const buttonActionStyles = css` background-color: transparent; border-radius: ${({ theme }) => theme.radii.tooltip}px; + &:hover, &:focus-visible { - outline: ${({ theme }) => `2px solid ${theme.colors.textSelected}`}; - outline-offset: 4px; + color: ${({ theme }) => theme.colors.textInteractiveHover}; } ` diff --git a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx index e4e37b2d2d..7f57730056 100644 --- a/centrifuge-app/src/pages/IssuerCreatePool/index.tsx +++ b/centrifuge-app/src/pages/IssuerCreatePool/index.tsx @@ -209,7 +209,6 @@ function CreatePoolForm() { ) => { const [transferToMultisig, aoProxy, adminProxy, , , , , , { adminMultisig }] = args const multisigAddr = adminMultisig && createKeyMulti(adminMultisig.signers, adminMultisig.threshold) - console.log('adminMultisig', multisigAddr) const poolArgs = args.slice(2) as any return combineLatest([ cent.getApi(), diff --git a/centrifuge-app/src/pages/Prime/Detail.tsx b/centrifuge-app/src/pages/Prime/Detail.tsx new file mode 100644 index 0000000000..670153f93d --- /dev/null +++ b/centrifuge-app/src/pages/Prime/Detail.tsx @@ -0,0 +1,15 @@ +import { useParams } from 'react-router' +import { LayoutBase } from '../../components/LayoutBase' + +export default function PrimeDetailPage() { + return ( + + + + ) +} + +function PrimeDetail() { + const { dao } = useParams<{ dao: string }>() + return
Prime detail, {dao}
+} diff --git a/centrifuge-app/src/pages/Prime/index.tsx b/centrifuge-app/src/pages/Prime/index.tsx new file mode 100644 index 0000000000..0dd310a991 --- /dev/null +++ b/centrifuge-app/src/pages/Prime/index.tsx @@ -0,0 +1,209 @@ +import { Price } from '@centrifuge/centrifuge-js' +import { Network, useCentrifuge, useCentrifugeUtils, useGetNetworkName } from '@centrifuge/centrifuge-react' +import { AnchorButton, Box, IconExternalLink, Shelf, Text, TextWithPlaceholder } from '@centrifuge/fabric' +import { useQuery } from 'react-query' +import { firstValueFrom } from 'rxjs' +import aaveLogo from '../../assets/images/aave-token-logo.svg' +import { Column, DataTable, FilterableTableHeader, SortableTableHeader } from '../../components/DataTable' +import { LayoutBase } from '../../components/LayoutBase' +import { LayoutSection } from '../../components/LayoutBase/LayoutSection' +import { formatDate } from '../../utils/date' +import { formatBalance, formatPercentage } from '../../utils/formatting' +import { useFilters } from '../../utils/useFilters' +import { useSubquery } from '../../utils/useSubquery' + +type DAO = { + slug: string + name: string + network: Network + address: string + icon: string +} + +const DAOs: DAO[] = [ + { + slug: 'aave', + name: 'Aave', + network: 'centrifuge', + address: 'kALNreUp6oBmtfG87fe7MakWR8BnmQ4SmKjjfG27iVd3nuTue', + icon: aaveLogo, + }, +] + +export default function PrimePage() { + return ( + + + + ) +} + +function Prime() { + return ( + <> + + Centrifuge Prime + + + Centrifuge Prime was built to meet the needs of large decentralized organizations and protocols. Through + Centrifuge Prime, DeFi native organizations can integrate with the largest financial markets in the world + and take advantage of real yields from real economic activity - all onchain. Assets tailored to your needs, + processes adapted to your governance, and all through decentralized rails. + + + + + Go to website + + + + + + ) +} + +type Row = DAO & { value?: number; profit?: number; networkName: string; firstInvestment?: Date } + +function DaoPortfoliosTable() { + const utils = useCentrifugeUtils() + const cent = useCentrifuge() + const getNetworkName = useGetNetworkName() + + const daos = DAOs.map((dao) => ({ + ...dao, + address: utils.formatAddress( + typeof dao.network === 'number' ? utils.evmToSubstrateAddress(dao.address, dao.network) : dao.address + ), + })) + + // TODO: Update to use new portfolio Runtime API + const { data, isLoading: isPortfoliosLoading } = useQuery(['daoPortfolios', daos.map((dao) => dao.address)], () => + Promise.all(daos.map((dao) => firstValueFrom(cent.pools.getBalances([dao.address])))) + ) + + const { data: subData, isLoading: isSubqueryLoading } = useSubquery( + `query ($accounts: [String!]) { + accounts( + filter: {id: {in: $accounts}} + ) { + nodes { + id + investorTransactions { + nodes { + timestamp + tokenPrice + tranche { + tokenPrice + trancheId + } + } + } + } + } + }`, + { + accounts: daos.map((dao) => dao.address), + } + ) + + const mapped: Row[] = daos.map((dao, i) => { + const investTxs = subData?.accounts.nodes.find((n: any) => n.id === dao.address)?.investorTransactions.nodes + const trancheBalances = + data?.[i].tranches && Object.fromEntries(data[i].tranches.map((t) => [t.trancheId, t.balance.toFloat()])) + const yields = + trancheBalances && + Object.keys(trancheBalances).map((tid) => { + const firstTx = investTxs?.find((tx: any) => tx.tranche.trancheId === tid) + const initialTokenPrice = firstTx && new Price(firstTx.tokenPrice).toFloat() + const tokenPrice = firstTx && new Price(firstTx.tranche.tokenPrice).toFloat() + const profit = tokenPrice / initialTokenPrice - 1 + return [tid, profit] as const + }) + const totalValue = trancheBalances && Object.values(trancheBalances)?.reduce((acc, balance) => acc + balance, 0) + const weightedYield = + yields && + totalValue && + yields.reduce((acc, [tid, profit]) => acc + profit * trancheBalances![tid], 0) / totalValue + + return { + ...dao, + value: totalValue ?? 0, + profit: weightedYield ? weightedYield * 100 : 0, + networkName: getNetworkName(dao.network), + firstInvestment: investTxs?.[0] && new Date(investTxs[0].timestamp), + } + }) + + const uniqueNetworks = [...new Set(daos.map((dao) => dao.network))] + const filters = useFilters({ data: mapped }) + + const columns: Column[] = [ + { + align: 'left', + header: 'DAO', + cell: (row: Row) => ( + + + {row.name} + + ), + }, + { + align: 'left', + header: ( + [chain, getNetworkName(chain)]))} + /> + ), + cell: (row: Row) => {row.networkName}, + }, + { + header: , + cell: (row: Row) => ( + + {row.value != null && formatBalance(row.value, 'USD')} + + ), + sortKey: 'value', + }, + { + header: , + cell: (row: Row) => ( + + {row.profit != null && formatPercentage(row.profit)} + + ), + sortKey: 'profit', + }, + { + align: 'left', + header: 'First investment', + cell: (row: Row) => ( + + {row.firstInvestment ? formatDate(row.firstInvestment) : '-'} + + ), + width: '5fr', + }, + ] + + return ( + + `/prime/${row.slug}`} + /> + + ) +} diff --git a/centrifuge-app/src/utils/useFilters.ts b/centrifuge-app/src/utils/useFilters.ts new file mode 100644 index 0000000000..5d2c23f127 --- /dev/null +++ b/centrifuge-app/src/utils/useFilters.ts @@ -0,0 +1,72 @@ +import { useEventCallback } from '@centrifuge/fabric' +import get from 'lodash/get' +import * as React from 'react' +import { useHistory, useLocation } from 'react-router' + +export type PaginationProps = { + key?: string // In case more than one table on the page uses search params for filters + data?: T[] + useSearchParams?: boolean +} + +export function useFilters({ key: prefix = 'f_', data = [], useSearchParams = true }: PaginationProps = {}) { + const history = useHistory() + const { search } = useLocation() + const [params, setParams] = React.useState(() => new URLSearchParams(useSearchParams ? search : undefined)) + const state: Record> = {} + + for (const [prefixedKey, value] of params.entries()) { + if (!prefixedKey.startsWith(prefix)) continue + const key = prefixedKey.slice(prefix.length) + if (state[key]) { + state[key].add(value) + } else { + state[key] = new Set([value]) + } + } + + const setFilter = useEventCallback((key: string, value: string[]) => { + setParams((prev) => { + const params = new URLSearchParams(prev) + params.delete(prefix + key) + value.forEach((value, i) => { + if (i === 0) { + params.set(prefix + key, value) + } else { + params.append(prefix + key, value) + } + }) + return params + }) + }) + + const hasFilter = useEventCallback((key: string, value: string) => { + return !!state[key]?.has(String(value)) + }) + + const getState = useGetLatest(state) + + const entries = Object.entries(state) + const filtered = data.filter((entry) => entries.every(([key, set]) => set.has(String(get(entry, key))))) + + React.useEffect(() => { + history.replace({ search: params.toString() }) + }, [params, history]) + + return { + setFilter, + hasFilter, + data: filtered, + state, + getState, + } +} + +export type FiltersState = ReturnType + +function useGetLatest(obj: T): () => T { + const ref = React.useRef(obj) + ref.current = obj + + return React.useState(() => () => ref.current)[0] +} diff --git a/centrifuge-app/src/utils/useSubquery.ts b/centrifuge-app/src/utils/useSubquery.ts new file mode 100644 index 0000000000..ed5eaf5fb5 --- /dev/null +++ b/centrifuge-app/src/utils/useSubquery.ts @@ -0,0 +1,10 @@ +import { useCentrifuge } from '@centrifuge/centrifuge-react' +import { useQuery } from 'react-query' +import { firstValueFrom } from 'rxjs' + +export function useSubquery(query: string, variables?: object) { + const cent = useCentrifuge() + return useQuery(['subquery', query, variables], () => + firstValueFrom(cent.getSubqueryObservable(query, variables, false)) + ) +} diff --git a/fabric/src/components/FileUpload/index.tsx b/fabric/src/components/FileUpload/index.tsx index 297d718bec..ad403277eb 100644 --- a/fabric/src/components/FileUpload/index.tsx +++ b/fabric/src/components/FileUpload/index.tsx @@ -5,7 +5,7 @@ import IconAlertCircle from '../../icon/IconAlertCircle' import IconSpinner from '../../icon/IconSpinner' import IconUpload from '../../icon/IconUpload' import IconX from '../../icon/IconX' -import useControlledState from '../../utils/useControlledState' +import { useControlledState } from '../../utils/useControlledState' import { Box } from '../Box' import { Button } from '../Button' import { Flex } from '../Flex' diff --git a/fabric/src/components/ImageUpload/index.tsx b/fabric/src/components/ImageUpload/index.tsx index d4d4f8861e..cceac9b8f5 100644 --- a/fabric/src/components/ImageUpload/index.tsx +++ b/fabric/src/components/ImageUpload/index.tsx @@ -4,7 +4,7 @@ import { ResponsiveValue } from 'styled-system' import IconUpload from '../../icon/IconUpload' import IconX from '../../icon/IconX' import { Size } from '../../utils/types' -import useControlledState from '../../utils/useControlledState' +import { useControlledState } from '../../utils/useControlledState' import { Box } from '../Box' import { Button } from '../Button' import { Flex } from '../Flex' diff --git a/fabric/src/components/InteractiveCard/index.tsx b/fabric/src/components/InteractiveCard/index.tsx index 341776c061..b2aeaacb15 100644 --- a/fabric/src/components/InteractiveCard/index.tsx +++ b/fabric/src/components/InteractiveCard/index.tsx @@ -1,7 +1,7 @@ import * as React from 'react' import styled, { useTheme } from 'styled-components' import { IconChevronRight } from '../../icon' -import useControlledState from '../../utils/useControlledState' +import { useControlledState } from '../../utils/useControlledState' import { Box } from '../Box' import { VisualButton } from '../Button' import { Card, CardProps } from '../Card' diff --git a/fabric/src/components/Pagination/usePagination.ts b/fabric/src/components/Pagination/usePagination.ts index 8936f8d404..3c80f3192c 100644 --- a/fabric/src/components/Pagination/usePagination.ts +++ b/fabric/src/components/Pagination/usePagination.ts @@ -1,6 +1,6 @@ import * as React from 'react' -import useControlledState from '../../utils/useControlledState' -import useEventCallback from '../../utils/useEventCallback' +import { useControlledState } from '../../utils/useControlledState' +import { useEventCallback } from '../../utils/useEventCallback' export type PaginationProps = { manual?: boolean diff --git a/fabric/src/index.ts b/fabric/src/index.ts index d8811ce946..303a1a3029 100644 --- a/fabric/src/index.ts +++ b/fabric/src/index.ts @@ -46,4 +46,5 @@ export * from './components/Tooltip' export * from './icon/index' export * from './theme' export { mapResponsive, toPx } from './utils/styled' -export { default as useControlledState } from './utils/useControlledState' +export { useControlledState } from './utils/useControlledState' +export { useEventCallback } from './utils/useEventCallback' diff --git a/fabric/src/utils/useControlledState.ts b/fabric/src/utils/useControlledState.ts index 6903a82590..fee802bcfa 100644 --- a/fabric/src/utils/useControlledState.ts +++ b/fabric/src/utils/useControlledState.ts @@ -1,7 +1,7 @@ import { Dispatch, SetStateAction, useState } from 'react' -import useEventCallback from './useEventCallback' +import { useEventCallback } from './useEventCallback' -function useControlledState | Dispatch>>( +export function useControlledState | Dispatch>>( initialUncontrolledValue: T, externalValue?: T, setExternalValue?: D @@ -17,5 +17,3 @@ function useControlledState | Dispatch>>( return [value, setValue as any] } - -export default useControlledState diff --git a/fabric/src/utils/useEventCallback.ts b/fabric/src/utils/useEventCallback.ts index 12904fff7c..e6a1344768 100644 --- a/fabric/src/utils/useEventCallback.ts +++ b/fabric/src/utils/useEventCallback.ts @@ -2,7 +2,7 @@ import { useCallback, useLayoutEffect, useRef } from 'react' type Calback = (...args: any[]) => any -function useEventCallback(callback: T) { +export function useEventCallback(callback: T) { const ref = useRef((() => { throw new Error('Cannot call an event handler while rendering.') }) as any) @@ -16,5 +16,3 @@ function useEventCallback(callback: T) { return fn(...args) }, []) } - -export default useEventCallback