feat: Add proposal creation interface (#1799)

* Make address input panel more generalized

* Add TextInput component

* Allow adjusting AppBody maxwidth

* Add create proposal layout page

* Fix style consistency

* create proposal ui goes brrr

* various fixes

* minor tweaks and bug fixes

* merge conflict clean up + bug fix

* i18n

* always show create proposal button

* adjust proposal title top margin and show ButtonError styling

* use button disable instead of button error when form is not filled

* revert mistaken css change

* default params for address input panel

* Refactor & fixes

- Add missing i18n
- Refactor how some styled-components is done
- ButtonError is now disabled when proposal is true in create proposal interface
- Edit copywriting
- Minor styling adjustments

* Fixed create proposal padding on medium screen

* refactor

* refactor pt. 2

* single column styling

* change AddressInputPanel font color

* Design adjustments

* change route for create proposal

* Add autonomous proposal CTA

* cta text edit

* Add link to docs for custom action

* small cleanup

* work with latest governor

Co-authored-by: Noah Zinsmeister <noahwz@gmail.com>
This commit is contained in:
Scott Moses Sunarto 2021-06-23 02:51:32 +07:00 committed by GitHub
parent c2fe17615f
commit bb542ef0fb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 939 additions and 40 deletions

@ -40,7 +40,7 @@ const Input = styled.input<{ error?: boolean }>`
width: 0; width: 0;
background-color: ${({ theme }) => theme.bg1}; background-color: ${({ theme }) => theme.bg1};
transition: color 300ms ${({ error }) => (error ? 'step-end' : 'step-start')}; transition: color 300ms ${({ error }) => (error ? 'step-end' : 'step-start')};
color: ${({ error, theme }) => (error ? theme.red1 : theme.primary1)}; color: ${({ error, theme }) => (error ? theme.red1 : theme.text1)};
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
font-weight: 500; font-weight: 500;
@ -67,10 +67,16 @@ const Input = styled.input<{ error?: boolean }>`
export default function AddressInputPanel({ export default function AddressInputPanel({
id, id,
className = 'recipient-address-input',
label = 'Recipient',
placeholder,
value, value,
onChange, onChange,
}: { }: {
id?: string id?: string
className?: string
label?: string
placeholder?: string
// the typed string value // the typed string value
value: string value: string
// triggers whenever the typed value changes // triggers whenever the typed value changes
@ -99,7 +105,7 @@ export default function AddressInputPanel({
<AutoColumn gap="md"> <AutoColumn gap="md">
<RowBetween> <RowBetween>
<TYPE.black color={theme.text2} fontWeight={500} fontSize={14}> <TYPE.black color={theme.text2} fontWeight={500} fontSize={14}>
Recipient {label}
</TYPE.black> </TYPE.black>
{address && chainId && ( {address && chainId && (
<ExternalLink <ExternalLink
@ -111,13 +117,13 @@ export default function AddressInputPanel({
)} )}
</RowBetween> </RowBetween>
<Input <Input
className="recipient-address-input" className={className}
type="text" type="text"
autoComplete="off" autoComplete="off"
autoCorrect="off" autoCorrect="off"
autoCapitalize="off" autoCapitalize="off"
spellCheck="false" spellCheck="false"
placeholder="Wallet Address or ENS name" placeholder={placeholder ?? 'Wallet Address or ENS name'}
error={error} error={error}
pattern="^(0x[a-fA-F0-9]{40})$" pattern="^(0x[a-fA-F0-9]{40})$"
onChange={handleInput} onChange={handleInput}

@ -160,6 +160,8 @@ interface CurrencyInputPanelProps {
priceImpact?: Percent priceImpact?: Percent
id: string id: string
showCommonBases?: boolean showCommonBases?: boolean
showCurrencyAmount?: boolean
disableNonToken?: boolean
renderBalance?: (amount: CurrencyAmount<Currency>) => ReactNode renderBalance?: (amount: CurrencyAmount<Currency>) => ReactNode
locked?: boolean locked?: boolean
} }
@ -174,6 +176,8 @@ export default function CurrencyInputPanel({
otherCurrency, otherCurrency,
id, id,
showCommonBases, showCommonBases,
showCurrencyAmount,
disableNonToken,
renderBalance, renderBalance,
fiatValue, fiatValue,
priceImpact, priceImpact,
@ -298,6 +302,8 @@ export default function CurrencyInputPanel({
selectedCurrency={currency} selectedCurrency={currency}
otherSelectedCurrency={otherCurrency} otherSelectedCurrency={otherCurrency}
showCommonBases={showCommonBases} showCommonBases={showCommonBases}
showCurrencyAmount={showCurrencyAmount}
disableNonToken={disableNonToken}
/> />
)} )}
</InputPanel> </InputPanel>

@ -6,7 +6,7 @@ import { NavLink, Link as HistoryLink, useLocation } from 'react-router-dom'
import { Percent } from '@uniswap/sdk-core' import { Percent } from '@uniswap/sdk-core'
import { ArrowLeft } from 'react-feather' import { ArrowLeft } from 'react-feather'
import { RowBetween } from '../Row' import Row, { RowBetween } from '../Row'
import SettingsTab from '../Settings' import SettingsTab from '../Settings'
import { useAppDispatch } from 'state/hooks' import { useAppDispatch } from 'state/hooks'
@ -137,3 +137,16 @@ export function AddRemoveTabs({
</Tabs> </Tabs>
) )
} }
export function CreateProposalTabs() {
return (
<Tabs>
<Row style={{ padding: '1rem 1rem 0 1rem' }}>
<HistoryLink to="/vote">
<StyledArrowLeft />
</HistoryLink>
<ActiveText style={{ marginLeft: 'auto', marginRight: 'auto' }}>Create Proposal</ActiveText>
</Row>
</Tabs>
)
}

@ -104,12 +104,14 @@ function CurrencyRow({
isSelected, isSelected,
otherSelected, otherSelected,
style, style,
showCurrencyAmount,
}: { }: {
currency: Currency currency: Currency
onSelect: () => void onSelect: () => void
isSelected: boolean isSelected: boolean
otherSelected: boolean otherSelected: boolean
style: CSSProperties style: CSSProperties
showCurrencyAmount?: boolean
}) { }) {
const { account } = useActiveWeb3React() const { account } = useActiveWeb3React()
const key = currencyKey(currency) const key = currencyKey(currency)
@ -141,9 +143,11 @@ function CurrencyRow({
</TYPE.darkGray> </TYPE.darkGray>
</Column> </Column>
<TokenTags currency={currency} /> <TokenTags currency={currency} />
<RowFixed style={{ justifySelf: 'flex-end' }}> {showCurrencyAmount && (
{balance ? <Balance balance={balance} /> : account ? <Loader /> : null} <RowFixed style={{ justifySelf: 'flex-end' }}>
</RowFixed> {balance ? <Balance balance={balance} /> : account ? <Loader /> : null}
</RowFixed>
)}
</MenuItem> </MenuItem>
) )
} }
@ -189,6 +193,7 @@ export default function CurrencyList({
fixedListRef, fixedListRef,
showImportView, showImportView,
setImportToken, setImportToken,
showCurrencyAmount,
}: { }: {
height: number height: number
currencies: Currency[] currencies: Currency[]
@ -199,6 +204,7 @@ export default function CurrencyList({
fixedListRef?: MutableRefObject<FixedSizeList | undefined> fixedListRef?: MutableRefObject<FixedSizeList | undefined>
showImportView: () => void showImportView: () => void
setImportToken: (token: Token) => void setImportToken: (token: Token) => void
showCurrencyAmount?: boolean
}) { }) {
const itemData: (Currency | BreakLine)[] = useMemo(() => { const itemData: (Currency | BreakLine)[] = useMemo(() => {
if (otherListTokens && otherListTokens?.length > 0) { if (otherListTokens && otherListTokens?.length > 0) {
@ -237,13 +243,22 @@ export default function CurrencyList({
isSelected={isSelected} isSelected={isSelected}
onSelect={handleSelect} onSelect={handleSelect}
otherSelected={otherSelected} otherSelected={otherSelected}
showCurrencyAmount={showCurrencyAmount}
/> />
) )
} else { } else {
return null return null
} }
}, },
[currencies.length, onCurrencySelect, otherCurrency, selectedCurrency, setImportToken, showImportView] [
currencies.length,
onCurrencySelect,
otherCurrency,
selectedCurrency,
setImportToken,
showImportView,
showCurrencyAmount,
]
) )
const itemKey = useCallback((index: number, data: typeof itemData) => { const itemKey = useCallback((index: number, data: typeof itemData) => {

@ -48,6 +48,8 @@ interface CurrencySearchProps {
onCurrencySelect: (currency: Currency) => void onCurrencySelect: (currency: Currency) => void
otherSelectedCurrency?: Currency | null otherSelectedCurrency?: Currency | null
showCommonBases?: boolean showCommonBases?: boolean
showCurrencyAmount?: boolean
disableNonToken?: boolean
showManageView: () => void showManageView: () => void
showImportView: () => void showImportView: () => void
setImportToken: (token: Token) => void setImportToken: (token: Token) => void
@ -58,6 +60,8 @@ export function CurrencySearch({
onCurrencySelect, onCurrencySelect,
otherSelectedCurrency, otherSelectedCurrency,
showCommonBases, showCommonBases,
showCurrencyAmount,
disableNonToken,
onDismiss, onDismiss,
isOpen, isOpen,
showManageView, showManageView,
@ -203,7 +207,7 @@ export function CurrencySearch({
{({ height }) => ( {({ height }) => (
<CurrencyList <CurrencyList
height={height} height={height}
currencies={filteredSortedTokensWithETH} currencies={disableNonToken ? filteredSortedTokens : filteredSortedTokensWithETH}
otherListTokens={filteredInactiveTokens} otherListTokens={filteredInactiveTokens}
onCurrencySelect={handleCurrencySelect} onCurrencySelect={handleCurrencySelect}
otherCurrency={otherSelectedCurrency} otherCurrency={otherSelectedCurrency}
@ -211,6 +215,7 @@ export function CurrencySearch({
fixedListRef={fixedList} fixedListRef={fixedList}
showImportView={showImportView} showImportView={showImportView}
setImportToken={setImportToken} setImportToken={setImportToken}
showCurrencyAmount={showCurrencyAmount}
/> />
)} )}
</AutoSizer> </AutoSizer>

@ -17,6 +17,8 @@ interface CurrencySearchModalProps {
onCurrencySelect: (currency: Currency) => void onCurrencySelect: (currency: Currency) => void
otherSelectedCurrency?: Currency | null otherSelectedCurrency?: Currency | null
showCommonBases?: boolean showCommonBases?: boolean
showCurrencyAmount?: boolean
disableNonToken?: boolean
} }
export enum CurrencyModalView { export enum CurrencyModalView {
@ -33,6 +35,8 @@ export default function CurrencySearchModal({
selectedCurrency, selectedCurrency,
otherSelectedCurrency, otherSelectedCurrency,
showCommonBases = false, showCommonBases = false,
showCurrencyAmount = true,
disableNonToken = false,
}: CurrencySearchModalProps) { }: CurrencySearchModalProps) {
const [modalView, setModalView] = useState<CurrencyModalView>(CurrencyModalView.manage) const [modalView, setModalView] = useState<CurrencyModalView>(CurrencyModalView.manage)
const lastOpen = useLast(isOpen) const lastOpen = useLast(isOpen)
@ -74,6 +78,8 @@ export default function CurrencySearchModal({
selectedCurrency={selectedCurrency} selectedCurrency={selectedCurrency}
otherSelectedCurrency={otherSelectedCurrency} otherSelectedCurrency={otherSelectedCurrency}
showCommonBases={showCommonBases} showCommonBases={showCommonBases}
showCurrencyAmount={showCurrencyAmount}
disableNonToken={disableNonToken}
showImportView={() => setModalView(CurrencyModalView.importToken)} showImportView={() => setModalView(CurrencyModalView.importToken)}
setImportToken={setImportToken} setImportToken={setImportToken}
showManageView={() => setModalView(CurrencyModalView.manage)} showManageView={() => setModalView(CurrencyModalView.manage)}

@ -0,0 +1,146 @@
import React, { memo, useCallback, useRef } from 'react'
import styled from 'styled-components'
const Input = styled.input<{ error?: boolean; fontSize?: string }>`
font-size: ${({ fontSize }) => fontSize || '1.25rem'};
outline: none;
border: none;
flex: 1 1 auto;
width: 0;
background-color: ${({ theme }) => theme.bg1};
transition: color 300ms ${({ error }) => (error ? 'step-end' : 'step-start')};
color: ${({ error, theme }) => (error ? theme.red1 : theme.text1)};
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
width: 100%;
padding: 0px;
-webkit-appearance: textfield;
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-outer-spin-button,
::-webkit-inner-spin-button {
-webkit-appearance: none;
}
::placeholder {
color: ${({ theme }) => theme.text4};
}
`
const TextAreaInput = styled.textarea<{ error?: boolean; fontSize?: string }>`
font-size: ${({ fontSize }) => fontSize || '1.25rem'};
outline: none;
border: none;
flex: 1 1 auto;
width: 0;
resize: none;
background-color: ${({ theme }) => theme.bg1};
transition: color 300ms ${({ error }) => (error ? 'step-end' : 'step-start')};
color: ${({ error, theme }) => (error ? theme.red1 : theme.text1)};
overflow: hidden;
text-overflow: ellipsis;
font-weight: 500;
width: 100%;
line-height: 1.2;
padding: 0px;
-webkit-appearance: textfield;
::-webkit-search-decoration {
-webkit-appearance: none;
}
::-webkit-outer-spin-button,
::-webkit-inner-spin-button {
-webkit-appearance: none;
}
::placeholder {
color: ${({ theme }) => theme.text4};
}
`
export const TextInput = ({
className,
value,
onUserInput,
placeholder,
fontSize,
}: {
className?: string
value: string
onUserInput: (value: string) => void
placeholder: string
fontSize: string
}) => {
const handleInput = useCallback(
(event) => {
onUserInput(event.target.value)
},
[onUserInput]
)
return (
<div className={className}>
<Input
type="text"
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
placeholder={placeholder || ''}
onChange={handleInput}
value={value}
fontSize={fontSize}
/>
</div>
)
}
export const ResizingTextArea = memo(
({
className,
value,
onUserInput,
placeholder,
fontSize,
}: {
className?: string
value: string
onUserInput: (value: string) => void
placeholder: string
fontSize: string
}) => {
const inputRef = useRef<HTMLTextAreaElement>(document.createElement('textarea'))
const handleInput = useCallback(
(event) => {
inputRef.current.style.height = 'auto'
inputRef.current.style.height = inputRef.current.scrollHeight + 'px'
onUserInput(event.target.value)
},
[onUserInput]
)
return (
<TextAreaInput
style={{ height: 'auto', minHeight: '500px' }}
className={className}
autoComplete="off"
autoCorrect="off"
autoCapitalize="off"
spellCheck="false"
placeholder={placeholder || ''}
onChange={handleInput}
value={value}
fontSize={fontSize}
ref={inputRef}
/>
)
}
)
ResizingTextArea.displayName = 'ResizingTextArea'

@ -29,6 +29,7 @@ import { RedirectDuplicateTokenIdsV2 } from './AddLiquidityV2/redirects'
import { PositionPage } from './Pool/PositionPage' import { PositionPage } from './Pool/PositionPage'
import AddLiquidity from './AddLiquidity' import AddLiquidity from './AddLiquidity'
import ApeModeQueryParamReader from 'hooks/useApeModeQueryParamReader' import ApeModeQueryParamReader from 'hooks/useApeModeQueryParamReader'
import CreateProposal from './CreateProposal'
const AppWrapper = styled.div` const AppWrapper = styled.div`
display: flex; display: flex;
@ -40,14 +41,13 @@ const BodyWrapper = styled.div`
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100%; width: 100%;
padding-top: 120px; padding: 120px 16px 0px 16px;
align-items: center; align-items: center;
flex: 1; flex: 1;
z-index: 1; z-index: 1;
${({ theme }) => theme.mediaWidth.upToSmall` ${({ theme }) => theme.mediaWidth.upToSmall`
padding: 16px; padding: 6rem 16px 16px 16px;
padding-top: 6rem;
`}; `};
` `
@ -122,6 +122,7 @@ export default function App() {
<Route exact strict path="/migrate/v2" component={MigrateV2} /> <Route exact strict path="/migrate/v2" component={MigrateV2} />
<Route exact strict path="/migrate/v2/:address" component={MigrateV2Pair} /> <Route exact strict path="/migrate/v2/:address" component={MigrateV2Pair} />
<Route exact strict path="/create-proposal" component={CreateProposal} />
<Route component={RedirectPathToSwapOnly} /> <Route component={RedirectPathToSwapOnly} />
</Switch> </Switch>
</Web3ReactManager> </Web3ReactManager>

@ -1,10 +1,10 @@
import React from 'react' import React from 'react'
import styled from 'styled-components/macro' import styled from 'styled-components/macro'
export const BodyWrapper = styled.div<{ margin?: string }>` export const BodyWrapper = styled.div<{ margin?: string; maxWidth?: string }>`
position: relative; position: relative;
margin-top: ${({ margin }) => margin ?? '0px'}; margin-top: ${({ margin }) => margin ?? '0px'};
max-width: 480px; max-width: ${({ maxWidth }) => maxWidth ?? '480px'};
width: 100%; width: 100%;
background: ${({ theme }) => theme.bg0}; background: ${({ theme }) => theme.bg0};
box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.01), 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 16px 24px rgba(0, 0, 0, 0.04), box-shadow: 0px 0px 1px rgba(0, 0, 0, 0.01), 0px 4px 8px rgba(0, 0, 0, 0.04), 0px 16px 24px rgba(0, 0, 0, 0.04),

@ -0,0 +1,83 @@
import React from 'react'
import AddressInputPanel from 'components/AddressInputPanel'
import CurrencyInputPanel from 'components/CurrencyInputPanel'
import styled from 'styled-components'
import { ProposalAction } from './ProposalActionSelector'
import { Currency } from '@uniswap/sdk-core'
enum ProposalActionDetailField {
ADDRESS,
CURRENCY,
}
const ProposalActionDetailContainer = styled.div`
margin-top: 10px;
display: grid;
grid-template-columns: repeat(1, 1fr);
grid-gap: 10px;
`
export const ProposalActionDetail = ({
className,
proposalAction,
currency,
amount,
toAddress,
onCurrencySelect,
onAmountInput,
onToAddressInput,
}: {
className?: string
proposalAction: ProposalAction
currency: Currency | undefined
amount: string
toAddress: string
onCurrencySelect: (currency: Currency) => void
onAmountInput: (amount: string) => void
onToAddressInput: (address: string) => void
}) => {
const proposalActionsData = {
[ProposalAction.TRANSFER_TOKEN]: [
{
type: ProposalActionDetailField.ADDRESS,
label: 'To',
},
{
type: ProposalActionDetailField.CURRENCY,
},
],
[ProposalAction.APPROVE_TOKEN]: [
{
type: ProposalActionDetailField.ADDRESS,
label: 'To',
},
{
type: ProposalActionDetailField.CURRENCY,
},
],
}
return (
<ProposalActionDetailContainer className={className}>
{proposalActionsData[proposalAction].map((field, i) =>
field.type === ProposalActionDetailField.ADDRESS ? (
<AddressInputPanel key={i} label={field.label} value={toAddress} onChange={onToAddressInput} />
) : field.type === ProposalActionDetailField.CURRENCY ? (
<CurrencyInputPanel
key={i}
value={amount}
currency={currency}
onUserInput={(amount: string) => onAmountInput(amount)}
onCurrencySelect={(currency: Currency) => onCurrencySelect(currency)}
showMaxButton={false}
showCommonBases={false}
showCurrencyAmount={false}
disableNonToken={true}
hideBalance={true}
id="currency-input"
/>
) : null
)}
</ProposalActionDetailContainer>
)
}

@ -0,0 +1,129 @@
import React, { useCallback } from 'react'
import styled from 'styled-components'
import { Text } from 'rebass'
import { CloseIcon } from 'theme'
import { Trans } from '@lingui/macro'
import Column from 'components/Column'
import Modal from 'components/Modal'
import { RowBetween } from 'components/Row'
import { MenuItem, PaddedColumn, Separator } from 'components/SearchModal/styleds'
import { ButtonDropdown } from 'components/Button'
export enum ProposalAction {
TRANSFER_TOKEN = 'Transfer Token',
APPROVE_TOKEN = 'Approve Token',
}
interface ProposalActionSelectorModalProps {
isOpen: boolean
onDismiss: () => void
onProposalActionSelect: (proposalAction: ProposalAction) => void
}
const ContentWrapper = styled(Column)`
width: 100%;
flex: 1 1;
position: relative;
`
const ActionSelectorHeader = styled.div`
font-size: 14px;
font-weight: 500;
color: ${({ theme }) => theme.text2};
`
const ActionDropdown = styled(ButtonDropdown)`
padding: 0px;
background-color: transparent;
color: ${({ theme }) => theme.text1}
font-size: 1.25rem;
:hover,
:active,
:focus {
outline: 0px;
box-shadow: none;
background-color: transparent;
}
`
const ProposalActionSelectorFlex = styled.div`
margin-top: 10px;
display: flex;
flex-flow: column nowrap;
border-radius: 20px;
border: 1px solid ${({ theme }) => theme.bg2};
background-color: ${({ theme }) => theme.bg1};
`
const ProposalActionSelectorContainer = styled.div`
flex: 1;
padding: 1rem;
display: grid;
grid-auto-rows: auto;
grid-row-gap: 10px;
`
export const ProposalActionSelector = ({
className,
onClick,
proposalAction,
}: {
className?: string
onClick: () => void
proposalAction: ProposalAction
}) => {
return (
<ProposalActionSelectorFlex>
<ProposalActionSelectorContainer className={className}>
<ActionSelectorHeader>
<Trans>Proposed Action</Trans>
</ActionSelectorHeader>
<ActionDropdown onClick={onClick}>{proposalAction}</ActionDropdown>
</ProposalActionSelectorContainer>
</ProposalActionSelectorFlex>
)
}
export function ProposalActionSelectorModal({
isOpen,
onDismiss,
onProposalActionSelect,
}: ProposalActionSelectorModalProps) {
const handleProposalActionSelect = useCallback(
(proposalAction: ProposalAction) => {
onProposalActionSelect(proposalAction)
onDismiss()
},
[onDismiss, onProposalActionSelect]
)
return (
<Modal isOpen={isOpen} onDismiss={onDismiss}>
<ContentWrapper>
<PaddedColumn gap="16px">
<RowBetween>
<Text fontWeight={500} fontSize={16}>
<Trans>Select an action</Trans>
</Text>
<CloseIcon onClick={onDismiss} />
</RowBetween>
</PaddedColumn>
<Separator />
<MenuItem onClick={() => handleProposalActionSelect(ProposalAction.TRANSFER_TOKEN)}>
<Column>
<Text fontWeight={500}>
<Trans>Transfer Token</Trans>
</Text>
</Column>
</MenuItem>
<MenuItem onClick={() => handleProposalActionSelect(ProposalAction.APPROVE_TOKEN)}>
<Column>
<Text fontWeight={500}>
<Trans>Approve Token</Trans>
</Text>
</Column>
</MenuItem>
</ContentWrapper>
</Modal>
)
}

@ -0,0 +1,63 @@
import React, { memo } from 'react'
import styled from 'styled-components'
import { Text } from 'rebass'
import { ResizingTextArea, TextInput } from 'components/TextInput'
import { t, Trans } from '@lingui/macro'
const ProposalEditorHeader = styled(Text)`
font-size: 14px;
font-weight: 500;
color: ${({ theme }) => theme.text2};
`
const ProposalTitle = memo(styled(TextInput)`
margin-top: 10.5px;
margin-bottom: 7.5px;
`)
const ProposalEditorContainer = styled.div`
margin-top: 10px;
padding: 0.75rem 1rem 0.75rem 1rem;
border-radius: 20px;
border: 1px solid ${({ theme }) => theme.bg2};
background-color: ${({ theme }) => theme.bg1};
`
export const ProposalEditor = ({
className,
title,
body,
onTitleInput,
onBodyInput,
}: {
className?: string
title: string
body: string
onTitleInput: (title: string) => void
onBodyInput: (body: string) => void
}) => {
const bodyPlaceholder = `## Summary
Insert your summary here
## Methodology
Insert your methodology here
## Conclusion
Insert your conclusion here
`
return (
<ProposalEditorContainer className={className}>
<ProposalEditorHeader>
<Trans>Proposal</Trans>
</ProposalEditorHeader>
<ProposalTitle value={title} onUserInput={onTitleInput} placeholder={t`Proposal Title`} fontSize="1.25rem" />
<hr />
<ResizingTextArea value={body} onUserInput={onBodyInput} placeholder={bodyPlaceholder} fontSize="1rem" />
</ProposalEditorContainer>
)
}

@ -0,0 +1,57 @@
import React, { useContext } from 'react'
import { ThemeContext } from 'styled-components'
import { Text } from 'rebass'
import { ExternalLink, TYPE } from 'theme'
import { ButtonPrimary } from 'components/Button'
import { AutoColumn } from 'components/Column'
import Modal from 'components/Modal'
import { LoadingView, SubmittedView } from 'components/ModalViews'
import { ExplorerDataType, getExplorerLink } from 'utils/getExplorerLink'
import { Link } from 'react-router-dom'
import { Trans } from '@lingui/macro'
export const ProposalSubmissionModal = ({
isOpen,
hash,
onDismiss,
}: {
isOpen: boolean
hash: string | undefined
onDismiss: () => void
}) => {
const theme = useContext(ThemeContext)
return (
<Modal isOpen={isOpen} onDismiss={onDismiss}>
{!hash ? (
<LoadingView onDismiss={onDismiss}>
<AutoColumn gap="12px" justify={'center'}>
<TYPE.largeHeader>
<Trans>Submitting Proposal</Trans>
</TYPE.largeHeader>
</AutoColumn>
</LoadingView>
) : (
<SubmittedView onDismiss={onDismiss} hash={hash}>
<AutoColumn gap="12px" justify={'center'}>
<Text fontWeight={500} fontSize={20} textAlign="center">
<Trans>Proposal Submitted</Trans>
</Text>
{hash && (
<ExternalLink href={getExplorerLink(1, hash, ExplorerDataType.TRANSACTION)}>
<Text fontWeight={500} fontSize={14} color={theme.primary1}>
<Trans>View on Etherscan</Trans>
</Text>
</ExternalLink>
)}
<ButtonPrimary as={Link} to="/vote" onClick={onDismiss} style={{ margin: '20px 0 0 0' }}>
<Text fontWeight={500} fontSize={20}>
<Trans>Return</Trans>
</Text>
</ButtonPrimary>
</AutoColumn>
</SubmittedView>
)}
</Modal>
)
}

@ -0,0 +1,285 @@
import React, { useCallback, useMemo, useState } from 'react'
import JSBI from 'jsbi'
import styled from 'styled-components'
import { utils } from 'ethers'
import { ExternalLink, TYPE } from 'theme'
import { Currency, CurrencyAmount, Token } from '@uniswap/sdk-core'
import { UNI } from '../../constants/tokens'
import AppBody from '../AppBody'
import { CreateProposalTabs } from '../../components/NavigationTabs'
import { ButtonError } from 'components/Button'
import { AutoColumn } from 'components/Column'
import { BlueCard } from 'components/Card'
import { Wrapper } from 'pages/Pool/styleds'
import { ProposalAction, ProposalActionSelector, ProposalActionSelectorModal } from './ProposalActionSelector'
import { ProposalEditor } from './ProposalEditor'
import { ProposalActionDetail } from './ProposalActionDetail'
import { ProposalSubmissionModal } from './ProposalSubmissionModal'
import { useActiveWeb3React } from 'hooks/web3'
import {
CreateProposalData,
ProposalState,
useCreateProposalCallback,
useLatestProposalId,
useProposalData,
useProposalThreshold,
useUserVotes,
} from 'state/governance/hooks'
import { Trans } from '@lingui/macro'
import { tryParseAmount } from 'state/swap/hooks'
import { getAddress } from '@ethersproject/address'
const CreateProposalButton = ({
proposalThreshold,
hasActiveOrPendingProposal,
hasEnoughVote,
isFormInvalid,
handleCreateProposal,
}: {
proposalThreshold?: CurrencyAmount<Token>
hasActiveOrPendingProposal: boolean
hasEnoughVote: boolean
isFormInvalid: boolean
handleCreateProposal: () => void
}) => {
const formattedProposalThreshold = proposalThreshold
? JSBI.divide(
proposalThreshold.quotient,
JSBI.exponentiate(JSBI.BigInt(10), JSBI.BigInt(proposalThreshold.currency.decimals))
).toLocaleString()
: undefined
return (
<ButtonError
style={{ marginTop: '18px' }}
error={hasActiveOrPendingProposal || !hasEnoughVote}
disabled={isFormInvalid || hasActiveOrPendingProposal || !hasEnoughVote}
onClick={handleCreateProposal}
>
{hasActiveOrPendingProposal ? (
<Trans>You already have an active or pending proposal</Trans>
) : !hasEnoughVote ? (
<>
{formattedProposalThreshold ? (
<Trans>You must have {formattedProposalThreshold} votes to submit a proposal</Trans>
) : (
<Trans>You don&apos;t have enough votes to submit a proposal</Trans>
)}
</>
) : (
<Trans>Create Proposal</Trans>
)}
</ButtonError>
)
}
const CreateProposalWrapper = styled(Wrapper)`
display: flex;
flex-flow: column wrap;
`
const AutonomousProposalCTA = styled.div`
text-align: center;
margin-top: 10px;
`
export default function CreateProposal() {
const { account, chainId } = useActiveWeb3React()
const latestProposalId = useLatestProposalId(account ?? '0x0000000000000000000000000000000000000000') ?? '0'
const latestProposalData = useProposalData(0, latestProposalId)
const availableVotes: CurrencyAmount<Token> | undefined = useUserVotes()
const proposalThreshold: CurrencyAmount<Token> | undefined = useProposalThreshold()
const [modalOpen, setModalOpen] = useState(false)
const [hash, setHash] = useState<string | undefined>()
const [attempting, setAttempting] = useState(false)
const [proposalAction, setProposalAction] = useState(ProposalAction.TRANSFER_TOKEN)
const [toAddressValue, setToAddressValue] = useState('')
const [currencyValue, setCurrencyValue] = useState<Currency>(UNI[chainId ?? 1])
const [amountValue, setAmountValue] = useState('')
const [titleValue, setTitleValue] = useState('')
const [bodyValue, setBodyValue] = useState('')
const handleActionSelectorClick = useCallback(() => {
setModalOpen(true)
}, [setModalOpen])
const handleActionChange = useCallback(
(proposalAction: ProposalAction) => {
setProposalAction(proposalAction)
},
[setProposalAction]
)
const handleDismissActionSelector = useCallback(() => {
setModalOpen(false)
}, [setModalOpen])
const handleDismissSubmissionModal = useCallback(() => {
setHash(undefined)
setAttempting(false)
}, [setHash, setAttempting])
const handleToAddressInput = useCallback(
(toAddress: string) => {
setToAddressValue(toAddress)
},
[setToAddressValue]
)
const handleCurrencySelect = useCallback(
(currency: Currency) => {
setCurrencyValue(currency)
},
[setCurrencyValue]
)
const handleAmountInput = useCallback(
(amount: string) => {
setAmountValue(amount)
},
[setAmountValue]
)
const handleTitleInput = useCallback(
(title: string) => {
setTitleValue(title)
},
[setTitleValue]
)
const handleBodyInput = useCallback(
(body: string) => {
setBodyValue(body)
},
[setBodyValue]
)
const isFormInvalid = useMemo(
() =>
Boolean(
!proposalAction ||
!utils.isAddress(toAddressValue) ||
!currencyValue?.isToken ||
amountValue === '' ||
titleValue === '' ||
bodyValue === ''
),
[proposalAction, toAddressValue, currencyValue, amountValue, titleValue, bodyValue]
)
const hasEnoughVote = Boolean(
availableVotes && proposalThreshold && JSBI.greaterThanOrEqual(availableVotes.quotient, proposalThreshold.quotient)
)
const createProposalCallback = useCreateProposalCallback()
const handleCreateProposal = async () => {
setAttempting(true)
const createProposalData: CreateProposalData = {} as CreateProposalData
if (!createProposalCallback || !proposalAction || !currencyValue.isToken) return
const tokenAmount = tryParseAmount(amountValue, currencyValue)
if (!tokenAmount) return
createProposalData.targets = [currencyValue.address]
createProposalData.values = ['0']
createProposalData.description = `# ${titleValue}
${bodyValue}
`
let types: string[][]
let values: string[][]
switch (proposalAction) {
case ProposalAction.TRANSFER_TOKEN: {
types = [['address', 'uint256']]
values = [[getAddress(toAddressValue), tokenAmount.quotient.toString()]]
createProposalData.signatures = [`transfer(${types[0].join(',')})`]
break
}
case ProposalAction.APPROVE_TOKEN: {
types = [['address', 'uint256']]
values = [[getAddress(toAddressValue), tokenAmount.quotient.toString()]]
createProposalData.signatures = [`approve(${types[0].join(',')})`]
break
}
}
createProposalData.calldatas = []
for (let i = 0; i < createProposalData.signatures.length; i++) {
createProposalData.calldatas[i] = utils.defaultAbiCoder.encode(types[i], values[i])
}
const hash = await createProposalCallback(createProposalData ?? undefined)?.catch(() => {
setAttempting(false)
})
if (hash) setHash(hash)
}
return (
<AppBody {...{ maxWidth: '800px' }}>
<CreateProposalTabs />
<CreateProposalWrapper>
<BlueCard>
<AutoColumn gap="10px">
<TYPE.link fontWeight={400} color={'primaryText1'}>
<Trans>
<strong>Tip:</strong> Select an action and describe your proposal for the community. The proposal cannot
be modified after submission, so please verify all information before submitting. The voting period will
begin immediately and last for 7 days. To propose a custom action,{' '}
<ExternalLink href="https://uniswap.org/docs/v2/governance/governance-reference/#propose">
read the docs
</ExternalLink>
.
</Trans>
</TYPE.link>
</AutoColumn>
</BlueCard>
<ProposalActionSelector onClick={handleActionSelectorClick} proposalAction={proposalAction} />
<ProposalActionDetail
proposalAction={proposalAction}
currency={currencyValue}
amount={amountValue}
toAddress={toAddressValue}
onCurrencySelect={handleCurrencySelect}
onAmountInput={handleAmountInput}
onToAddressInput={handleToAddressInput}
/>
<ProposalEditor
title={titleValue}
body={bodyValue}
onTitleInput={handleTitleInput}
onBodyInput={handleBodyInput}
/>
<CreateProposalButton
proposalThreshold={proposalThreshold}
hasActiveOrPendingProposal={
latestProposalData?.status === ProposalState.Active || latestProposalData?.status === ProposalState.Pending
}
hasEnoughVote={hasEnoughVote}
isFormInvalid={isFormInvalid}
handleCreateProposal={handleCreateProposal}
/>
{!hasEnoughVote ? (
<AutonomousProposalCTA>
Dont have 2.5M votes? Anyone can create an autonomous proposal using{' '}
<ExternalLink href="https://fish.vote">fish.vote</ExternalLink>
</AutonomousProposalCTA>
) : null}
</CreateProposalWrapper>
<ProposalActionSelectorModal
isOpen={modalOpen}
onDismiss={handleDismissActionSelector}
onProposalActionSelect={(proposalAction: ProposalAction) => handleActionChange(proposalAction)}
/>
<ProposalSubmissionModal isOpen={attempting} hash={hash} onDismiss={handleDismissSubmissionModal} />
</AppBody>
)
}

@ -4,7 +4,7 @@ import styled from 'styled-components/macro'
import { SwitchLocaleLink } from '../../components/SwitchLocaleLink' import { SwitchLocaleLink } from '../../components/SwitchLocaleLink'
import { UNI } from '../../constants/tokens' import { UNI } from '../../constants/tokens'
import { ExternalLink, TYPE } from '../../theme' import { ExternalLink, TYPE } from '../../theme'
import { RowBetween, RowFixed } from '../../components/Row' import { AutoRow, RowBetween, RowFixed } from '../../components/Row'
import { Link } from 'react-router-dom' import { Link } from 'react-router-dom'
import { getExplorerLink, ExplorerDataType } from '../../utils/getExplorerLink' import { getExplorerLink, ExplorerDataType } from '../../utils/getExplorerLink'
import { ProposalStatus } from './styled' import { ProposalStatus } from './styled'
@ -182,34 +182,44 @@ export default function Vote() {
<TYPE.mediumHeader style={{ margin: '0.5rem 0.5rem 0.5rem 0', flexShrink: 0 }}> <TYPE.mediumHeader style={{ margin: '0.5rem 0.5rem 0.5rem 0', flexShrink: 0 }}>
<Trans>Proposals</Trans> <Trans>Proposals</Trans>
</TYPE.mediumHeader> </TYPE.mediumHeader>
{(!allProposals || allProposals.length === 0) && !availableVotes && <Loader />} <AutoRow gap="6px" justify="flex-end">
{showUnlockVoting ? ( {(!allProposals || allProposals.length === 0) && !availableVotes && <Loader />}
{showUnlockVoting ? (
<ButtonPrimary
style={{ width: 'fit-content' }}
padding="8px"
borderRadius="8px"
onClick={toggleDelegateModal}
>
<Trans>Unlock Voting</Trans>
</ButtonPrimary>
) : availableVotes && JSBI.notEqual(JSBI.BigInt(0), availableVotes?.quotient) ? (
<TYPE.body fontWeight={500} mr="6px">
<Trans>
<FormattedCurrencyAmount currencyAmount={availableVotes} /> Votes
</Trans>
</TYPE.body>
) : uniBalance &&
userDelegatee &&
userDelegatee !== ZERO_ADDRESS &&
JSBI.notEqual(JSBI.BigInt(0), uniBalance?.quotient) ? (
<TYPE.body fontWeight={500} mr="6px">
<Trans>
<FormattedCurrencyAmount currencyAmount={uniBalance} /> Votes
</Trans>
</TYPE.body>
) : (
''
)}
<ButtonPrimary <ButtonPrimary
style={{ width: 'fit-content' }} as={Link}
to="/create-proposal"
style={{ width: 'fit-content', borderRadius: '8px' }}
padding="8px" padding="8px"
borderRadius="8px"
onClick={toggleDelegateModal}
> >
<Trans>Unlock Voting</Trans> <Trans>Create Proposal</Trans>
</ButtonPrimary> </ButtonPrimary>
) : availableVotes && JSBI.notEqual(JSBI.BigInt(0), availableVotes?.quotient) ? ( </AutoRow>
<TYPE.body fontWeight={500} mr="6px">
<Trans>
<FormattedCurrencyAmount currencyAmount={availableVotes} /> Votes
</Trans>
</TYPE.body>
) : uniBalance &&
userDelegatee &&
userDelegatee !== ZERO_ADDRESS &&
JSBI.notEqual(JSBI.BigInt(0), uniBalance?.quotient) ? (
<TYPE.body fontWeight={500} mr="6px">
<Trans>
<FormattedCurrencyAmount currencyAmount={uniBalance} /> Votes
</Trans>
</TYPE.body>
) : (
''
)}
</WrapSmall> </WrapSmall>
{!showUnlockVoting && ( {!showUnlockVoting && (
<RowBetween> <RowBetween>

@ -14,6 +14,7 @@ import { UNISWAP_GRANTS_START_BLOCK } from '../../constants/proposals'
import { UNI } from '../../constants/tokens' import { UNI } from '../../constants/tokens'
import { useMultipleContractMultipleData, useSingleCallResult } from '../multicall/hooks' import { useMultipleContractMultipleData, useSingleCallResult } from '../multicall/hooks'
import { useTransactionAdder } from '../transactions/hooks' import { useTransactionAdder } from '../transactions/hooks'
import { t } from '@lingui/macro'
interface ProposalDetail { interface ProposalDetail {
target: string target: string
@ -35,6 +36,14 @@ export interface ProposalData {
governorIndex: number // index in the governance address array for which this proposal pertains governorIndex: number // index in the governance address array for which this proposal pertains
} }
export interface CreateProposalData {
targets: string[]
values: string[]
signatures: string[]
calldatas: string[]
description: string
}
export enum ProposalState { export enum ProposalState {
Undetermined = -1, Undetermined = -1,
Pending, Pending,
@ -325,3 +334,68 @@ export function useVoteCallback(): {
) )
return { voteCallback } return { voteCallback }
} }
export function useCreateProposalCallback(): (
createProposalData: CreateProposalData | undefined
) => undefined | Promise<string> {
const { account } = useActiveWeb3React()
const govContracts = useGovernanceContracts()
const latestGovernanceContract = govContracts ? govContracts[0] : null
const addTransaction = useTransactionAdder()
const createProposalCallback = useCallback(
(createProposalData: CreateProposalData | undefined) => {
if (!account || !latestGovernanceContract || !createProposalData) return undefined
const args = [
createProposalData.targets,
createProposalData.values,
createProposalData.signatures,
createProposalData.calldatas,
createProposalData.description,
]
return latestGovernanceContract.estimateGas.propose(...args).then((estimatedGasLimit) => {
return latestGovernanceContract
.propose(...args, { gasLimit: calculateGasMargin(estimatedGasLimit) })
.then((response: TransactionResponse) => {
addTransaction(response, {
summary: t`Submitted new proposal`,
})
return response.hash
})
})
},
[account, addTransaction, latestGovernanceContract]
)
return createProposalCallback
}
export function useLatestProposalId(address: string): string | undefined {
const govContracts = useGovernanceContracts()
const latestGovernanceContract = govContracts ? govContracts[0] : null
const res = useSingleCallResult(latestGovernanceContract, 'latestProposalIds', [address])
if (res?.result?.[0]) {
return (res.result[0] as BigNumber).toString()
}
return undefined
}
export function useProposalThreshold(): CurrencyAmount<Token> | undefined {
const { chainId } = useActiveWeb3React()
const govContracts = useGovernanceContracts()
const latestGovernanceContract = govContracts ? govContracts[0] : null
const res = useSingleCallResult(latestGovernanceContract, 'proposalThreshold')
const uni = chainId ? UNI[chainId] : undefined
if (res?.result?.[0] && uni) {
return CurrencyAmount.fromRawAmount(uni, res.result[0])
}
return undefined
}