From 539d22bf88437b8c041bcee244f30c838a4cef8a Mon Sep 17 00:00:00 2001 From: noah Date: Wed, 6 Dec 2023 18:30:46 -0800 Subject: [PATCH] Proposal creation page cleanup and simulation feature (#1505) * Added ability to simulate proposal manually. * Remove duplicate code in new proposal components. --- packages/i18n/locales/en/translation.json | 3 + .../index.tsx => NewProposal.tsx} | 102 ++- .../NewProposal/NewProposal.stories.tsx | 71 -- .../components/NewProposal/NewProposal.tsx | 612 ------------------ .../common/components/NewProposalMain.tsx | 100 +++ .../common/components/NewProposalPreview.tsx | 106 +++ .../common/hooks/makeUsePublishProposal.ts | 30 + .../adapters/DaoProposalMultiple/types.ts | 5 + .../index.tsx => NewProposal.tsx} | 85 ++- .../NewProposal/NewProposal.stories.tsx | 70 -- .../common/components/NewProposalMain.tsx | 87 +++ .../common/components/NewProposalPreview.tsx | 43 ++ .../common/hooks/makeUsePublishProposal.ts | 21 + .../adapters/DaoProposalSingle/types.ts | 5 + .../components/proposal}/NewProposal.tsx | 384 ++++------- .../NewProposalTitleDescriptionHeader.tsx | 60 ++ .../stateless/components/proposal/index.ts | 2 + .../pages/CreateProposal.stories.tsx | 52 -- packages/utils/error.ts | 7 +- 19 files changed, 755 insertions(+), 1090 deletions(-) rename packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/{NewProposal/index.tsx => NewProposal.tsx} (66%) delete mode 100644 packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal/NewProposal.stories.tsx delete mode 100644 packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal/NewProposal.tsx create mode 100644 packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalMain.tsx create mode 100644 packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalPreview.tsx rename packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/{NewProposal/index.tsx => NewProposal.tsx} (71%) delete mode 100644 packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal/NewProposal.stories.tsx create mode 100644 packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposalMain.tsx create mode 100644 packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposalPreview.tsx rename packages/{stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal => stateless/components/proposal}/NewProposal.tsx (51%) create mode 100644 packages/stateless/components/proposal/NewProposalTitleDescriptionHeader.tsx delete mode 100644 packages/stateless/pages/CreateProposal.stories.tsx diff --git a/packages/i18n/locales/en/translation.json b/packages/i18n/locales/en/translation.json index 2427bac7a6..9c6c80c3a0 100644 --- a/packages/i18n/locales/en/translation.json +++ b/packages/i18n/locales/en/translation.json @@ -169,6 +169,7 @@ "showInstantiateMessage": "Show Instantiate Message", "showQrCode": "Show QR code", "showRawData": "Show raw data", + "simulate": "Simulate", "spend": "Spend", "stake": "Stake", "stakeAllButProposalDeposit": "Stake all but the {{proposalDeposit}} ${{tokenSymbol}} proposal deposit", @@ -361,6 +362,7 @@ "needsVote": "You must select a vote.", "nftCollectionNotChosen": "You must create an NFT collection or enter the address of an existing collection before using this action.", "nftMetadataNotUploaded": "You must upload the NFT metadata before you can mint.", + "noActionsToSimulate": "No actions to simulate.", "noCancellableVestingContracts": "There are no vesting contracts that can be cancelled.", "noClaimsAvailable": "No claims available.", "noCw20Tokens": "There are no CW20 tokens displayed in the treasury.", @@ -1159,6 +1161,7 @@ "proposalClosed": "Closed successfully.", "proposalCreatedCompleteCompensationCycle": "Proposal published. Complete the compensation cycle by saving the proposal ID.", "proposalExecuted": "Executed. Refreshing page...", + "proposalSimulation": "Proposal simulated successfully. If executed right now, it would not fail.", "ratingsSubmitted": "Ratings submitted.", "restaked": "Restaked successfully.", "saved": "Saved successfully.", diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal/index.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal.tsx similarity index 66% rename from packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal/index.tsx rename to packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal.tsx index 2cfca54c5e..c58d94867f 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal/index.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal.tsx @@ -1,11 +1,15 @@ import { FlagOutlined, Timelapse } from '@mui/icons-material' -import { useState } from 'react' +import { useCallback, useState } from 'react' +import { useFormContext } from 'react-hook-form' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' -import { useRecoilCallback, useRecoilValue } from 'recoil' +import { useRecoilCallback, useRecoilValueLoadable } from 'recoil' import { DaoCoreV2Selectors, blocksPerYearSelector } from '@dao-dao/state' import { + NewProposalTitleDescriptionHeader, + NewProposal as StatelessNewProposal, + NewProposalProps as StatelessNewProposalProps, useCachedLoadable, useChain, useDaoInfoContext, @@ -15,24 +19,26 @@ import { IProposalModuleAdapterCommonOptions, } from '@dao-dao/types' import { + MAX_NUM_PROPOSAL_CHOICES, + convertActionsToMessages, convertExpirationToDate, dateToWdhms, processError, } from '@dao-dao/utils' -import { useLoadedActionsAndCategories } from '../../../../../../actions' -import { EntityDisplay } from '../../../../../../components/EntityDisplay' -import { SuspenseLoader } from '../../../../../../components/SuspenseLoader' -import { useMembership, useWallet } from '../../../../../../hooks' -import { proposalSelector } from '../../../contracts/DaoProposalMultiple.recoil' -import { makeGetProposalInfo } from '../../../functions' +import { useLoadedActionsAndCategories } from '../../../../../actions' +import { useMembership, useWallet } from '../../../../../hooks' +import { proposalSelector } from '../../contracts/DaoProposalMultiple.recoil' +import { makeGetProposalInfo } from '../../functions' import { NewProposalData, NewProposalForm, + SimulateProposal, UsePublishProposal, -} from '../../../types' -import { useProcessQ } from '../../hooks' -import { NewProposal as StatelessNewProposal } from './NewProposal' +} from '../../types' +import { useProcessQ } from '../hooks' +import { NewProposalMain } from './NewProposalMain' +import { NewProposalPreview } from './NewProposalPreview' export type NewProposalProps = BaseNewProposalProps & { options: IProposalModuleAdapterCommonOptions @@ -54,9 +60,12 @@ export const NewProposal = ({ isActive, activeThreshold, } = useDaoInfoContext() - const { isWalletConnected, getStargateClient } = useWallet() + const { isWalletConnecting, isWalletConnected, getStargateClient } = + useWallet() - const { loadedActions, categories } = useLoadedActionsAndCategories() + const { watch } = useFormContext() + const proposalTitle = watch('title') + const choices = watch('choices') ?? [] const { isMember = false, loading: membershipLoading } = useMembership({ coreAddress, @@ -80,19 +89,33 @@ export const NewProposal = ({ const processQ = useProcessQ() - const blocksPerYear = useRecoilValue( + const blocksPerYearLoadable = useRecoilValueLoadable( blocksPerYearSelector({ chainId, }) ) const { + simulateProposal: _simulateProposal, publishProposal, anyoneCanPropose, depositUnsatisfied, simulationBypassExpiration, } = usePublishProposal() + const [simulating, setSimulating] = useState(false) + const simulateProposal: SimulateProposal = useCallback( + async (...params) => { + setSimulating(true) + try { + await _simulateProposal(...params) + } finally { + setSimulating(false) + } + }, + [_simulateProposal] + ) + const createProposal = useRecoilCallback( ({ snapshot }) => async (newProposalData: NewProposalData) => { @@ -101,6 +124,12 @@ export const NewProposal = ({ return } + if (blocksPerYearLoadable.state !== 'hasValue') { + toast.error(t('error.loadingData')) + return + } + const blocksPerYear = blocksPerYearLoadable.contents + setLoading(true) try { const { proposalNumber, proposalId } = await publishProposal( @@ -193,7 +222,7 @@ export const NewProposal = ({ t, publishProposal, options, - blocksPerYear, + blocksPerYearLoadable, getStargateClient, chainId, processQ, @@ -204,16 +233,45 @@ export const NewProposal = ({ ] ) + const { loadedActions } = useLoadedActionsAndCategories() + + const getProposalDataFromFormData: StatelessNewProposalProps< + NewProposalForm, + NewProposalData + >['getProposalDataFromFormData'] = ({ title, description, choices }) => ({ + title, + description, + choices: { + options: choices.map((option) => ({ + title: option.title, + description: option.description, + msgs: convertActionsToMessages(loadedActions, option.actionData), + })), + }, + }) + return ( - activeThreshold={activeThreshold} + additionalSubmitError={ + choices.length < 2 + ? t('error.tooFewChoices') + : choices.length > MAX_NUM_PROPOSAL_CHOICES + ? t('error.tooManyChoices', { + count: MAX_NUM_PROPOSAL_CHOICES, + }) + : undefined + } anyoneCanPropose={anyoneCanPropose} - categories={categories} connected={isWalletConnected} + content={{ + Header: NewProposalTitleDescriptionHeader, + Main: NewProposalMain, + Preview: NewProposalPreview, + }} createProposal={createProposal} depositUnsatisfied={depositUnsatisfied} + getProposalDataFromFormData={getProposalDataFromFormData} isActive={isActive} isMember={ membershipLoading @@ -221,8 +279,10 @@ export const NewProposal = ({ : { loading: false, data: isMember } } isPaused={isPaused} - loadedActions={loadedActions} - loading={loading} + isWalletConnecting={isWalletConnecting} + loading={loading || simulating} + proposalTitle={proposalTitle} + simulateProposal={simulateProposal} simulationBypassExpiration={simulationBypassExpiration} {...props} /> diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal/NewProposal.stories.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal/NewProposal.stories.tsx deleted file mode 100644 index 58731532ca..0000000000 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal/NewProposal.stories.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react' -import { FormProvider, useForm } from 'react-hook-form' - -import { ProposalModuleSelectorProps } from '@dao-dao/stateless' -import { Default as ProposalModuleSelectorStory } from '@dao-dao/stateless/components/proposal/ProposalModuleSelector.stories' -import { - DaoPageWrapperDecorator, - WalletProviderDecorator, -} from '@dao-dao/storybook/decorators' - -import { useLoadedActionsAndCategories } from '../../../../../../actions' -import { SuspenseLoader } from '../../../../../../components/SuspenseLoader' -import { NewProposalForm } from '../../../types' -import { NewProposal } from './NewProposal' - -export default { - title: - 'DAO DAO / packages / proposal-module-adapter / adapters / DaoProposalMultiple / common / components / NewProposal', - component: NewProposal, - decorators: [DaoPageWrapperDecorator, WalletProviderDecorator], -} as ComponentMeta - -const Template: ComponentStory = (args) => { - const { loadedActions, categories } = useLoadedActionsAndCategories() - - const formMethods = useForm({ - mode: 'onChange', - defaultValues: { - title: '', - description: '', - }, - }) - - return ( - - - - ) -} - -export const Default = Template.bind({}) -Default.args = { - createProposal: async (data) => { - console.log(data) - alert('submit') - }, - loading: false, - isPaused: false, - isActive: true, - activeThreshold: null, - isMember: { loading: false, data: true }, - depositUnsatisfied: false, - connected: true, - drafts: [], - proposalModuleSelector: ( - - ), - SuspenseLoader, -} -Default.parameters = { - design: { - type: 'figma', - url: 'https://www.figma.com/file/ZnQ4SMv8UUgKDZsR5YjVGH/Dao-2.0?node-id=985%3A46068', - }, -} diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal/NewProposal.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal/NewProposal.tsx deleted file mode 100644 index f49109656d..0000000000 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal/NewProposal.tsx +++ /dev/null @@ -1,612 +0,0 @@ -import { - Add, - Block, - Circle, - Close, - GavelRounded, - Visibility, - VisibilityOff, -} from '@mui/icons-material' -import clsx from 'clsx' -import Fuse from 'fuse.js' -import { ComponentType, useCallback, useState } from 'react' -import { - SubmitErrorHandler, - SubmitHandler, - useFieldArray, - useFormContext, -} from 'react-hook-form' -import { useTranslation } from 'react-i18next' -import TimeAgo from 'react-timeago' - -import { - Button, - FilterableItem, - FilterableItemPopup, - IconButton, - InputErrorMessage, - ProposalContentDisplay, - TextAreaInput, - TextInput, - Tooltip, -} from '@dao-dao/stateless' -import { - ActionCategoryWithLabel, - ActiveThreshold, - BaseNewProposalProps, - LoadedActions, - LoadingData, - StatefulEntityDisplayProps, - SuspenseLoaderProps, -} from '@dao-dao/types' -import { MultipleChoiceOptionType } from '@dao-dao/types/contracts/DaoProposalMultiple' -import { - MAX_NUM_PROPOSAL_CHOICES, - convertActionsToMessages, - formatDateTime, - formatPercentOf100, - formatTime, - processError, - validateRequired, -} from '@dao-dao/utils' - -import { useWallet, useWalletInfo } from '../../../../../../hooks' -import { - MULTIPLE_CHOICE_OPTION_COLORS, - MultipleChoiceOptionEditor, -} from '../../../components/MultipleChoiceOptionEditor' -import { MultipleChoiceOptionViewer } from '../../../components/MultipleChoiceOptionViewer' -import { NewProposalData, NewProposalForm } from '../../../types' - -enum ProposeSubmitValue { - Preview = 'Preview', - Submit = 'Submit', -} - -export interface NewProposalProps - extends Pick< - BaseNewProposalProps, - | 'draft' - | 'saveDraft' - | 'drafts' - | 'loadDraft' - | 'unloadDraft' - | 'draftSaving' - | 'deleteDraft' - | 'proposalModuleSelector' - > { - createProposal: (newProposalData: NewProposalData) => Promise - loading: boolean - isPaused: boolean - isActive: boolean - activeThreshold: ActiveThreshold | null - isMember: LoadingData - anyoneCanPropose: boolean - depositUnsatisfied: boolean - connected: boolean - categories: ActionCategoryWithLabel[] - loadedActions: LoadedActions - simulationBypassExpiration?: Date - SuspenseLoader: ComponentType - EntityDisplay: ComponentType -} - -export const NewProposal = ({ - createProposal, - loading, - isPaused, - isActive, - activeThreshold, - isMember, - anyoneCanPropose, - depositUnsatisfied, - connected, - categories, - loadedActions, - draft, - saveDraft, - drafts, - loadDraft, - unloadDraft, - draftSaving, - deleteDraft, - simulationBypassExpiration, - proposalModuleSelector, - SuspenseLoader, - EntityDisplay, -}: NewProposalProps) => { - const { t } = useTranslation() - - // Unpack here because we use these at the top level as well as inside of - // nested components. - const { - register, - control, - handleSubmit, - watch, - formState: { errors }, - } = useFormContext() - - const [showPreview, setShowPreview] = useState(false) - const [showSubmitErrorNote, setShowSubmitErrorNote] = useState(false) - const [submitError, setSubmitError] = useState('') - - const { isWalletConnecting } = useWallet() - const { walletAddress = '', walletProfileData } = useWalletInfo() - - const proposalDescription = watch('description') - const proposalTitle = watch('title') - - const { - fields: multipleChoiceFields, - append: addOption, - remove: removeOption, - } = useFieldArray({ - control, - name: 'choices', - shouldUnregister: true, - }) - const choices = watch('choices') ?? [] - - const onSubmitForm: SubmitHandler = useCallback( - ({ ...proposalFormData }, event) => { - setShowSubmitErrorNote(false) - setSubmitError('') - - const nativeEvent = event?.nativeEvent as SubmitEvent - const submitterValue = (nativeEvent?.submitter as HTMLInputElement)?.value - - // Preview toggled in onClick handler. - if (submitterValue === ProposeSubmitValue.Preview) { - return - } - - let options - try { - options = proposalFormData.choices.map((option) => ({ - title: option.title, - description: option.description, - msgs: convertActionsToMessages(loadedActions, option.actionData), - })) - } catch (err) { - console.error(err) - setSubmitError( - processError(err, { - forceCapture: false, - }) - ) - return - } - - createProposal({ - title: proposalFormData.title, - description: proposalFormData.description, - choices: { options }, - }) - }, - [createProposal, loadedActions] - ) - - const onSubmitError: SubmitErrorHandler = useCallback(() => { - setShowSubmitErrorNote(true) - setSubmitError('') - }, [setShowSubmitErrorNote]) - - const proposalName = watch('title') - - return ( -
-
-
-

- {t('form.proposalsName')} -

- -
- - -
-
-
-

- {t('form.description')} - - {/* eslint-disable-next-line i18next/no-literal-string */} - {' – '} - {t('info.supportsMarkdownFormat')} - -

- -
- - -
-
-
- - {proposalModuleSelector} - - {choices.length > 0 && ( -
- {multipleChoiceFields.map(({ id }, index) => ( - removeOption(index)} - titleFieldName={`choices.${index}.title`} - /> - ))} -
- )} - -
-
- addOption({ - title: '', - description: '', - actionData: [], - }) - } - > - - - - -

{t('button.addNewOption')}

-
- -
-
-
- - - -

{t('title.noneOfTheAbove')}

-
- -

- {t('info.cannotRemoveNoneOption')} -

-
-
- -
-
-

- {t('info.reviewYourProposal')} -

- -
- - - MAX_NUM_PROPOSAL_CHOICES - ? t('error.tooManyChoices', { - count: MAX_NUM_PROPOSAL_CHOICES, - }) - : undefined - } - > - - -
-
- - {!anyoneCanPropose && - !isMember.loading && - !isMember.data && - !isWalletConnecting && ( -

- {t('error.mustBeMemberToCreateProposal')} -

- )} - - {simulationBypassExpiration && ( -

- {t('info.bypassSimulationExplanation')} -

- )} - - {showSubmitErrorNote && ( -

- {t('error.correctErrorsAbove')} -

- )} - - {!!submitError && ( -

- {submitError} -

- )} - - {showPreview && ( -
- -

{t('title.voteOptions')}

- - {choices.map(({ title, description, actionData }, index) => ( - - ))} - - {/* None of the above */} - -
- } - title={proposalTitle} - /> -
- )} - - -
- {draft ? ( - <> -

- {draftSaving - ? t('info.draftSaving') - : t('info.draftSavedAtTime', { - time: formatTime(new Date(draft.lastUpdatedAt)), - })} -

- - - - - - ) : ( - <> - {drafts.length > 0 && !!loadDraft && ( - ({ - key: createdAt, - label: name, - description: ( - <> - {t('title.created')}:{' '} - {formatDateTime(new Date(createdAt))} -
- {t('title.lastUpdated')}:{' '} - {formatDateTime(new Date(lastUpdatedAt))} - - ), - rightNode: ( - - { - // Don't click on item button. - event.stopPropagation() - deleteDraft(index) - }} - variant="ghost" - /> - - ), - }) - )} - onSelect={(_, index) => loadDraft(index)} - searchPlaceholder={t('info.searchDraftPlaceholder')} - trigger={{ - type: 'button', - props: { - variant: 'secondary', - children: t('button.loadDraft'), - }, - }} - /> - )} - - - - - - )} -
-
- ) -} - -const FILTERABLE_KEYS: Fuse.FuseOptionKey[] = ['label'] diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalMain.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalMain.tsx new file mode 100644 index 0000000000..6db56a0e4b --- /dev/null +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalMain.tsx @@ -0,0 +1,100 @@ +import { Add, Block, Circle } from '@mui/icons-material' +import { useFieldArray, useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { useLoadedActionsAndCategories } from '../../../../../actions' +import { SuspenseLoader } from '../../../../../components' +import { + MULTIPLE_CHOICE_OPTION_COLORS, + MultipleChoiceOptionEditor, +} from '../../components/MultipleChoiceOptionEditor' +import { NewProposalForm } from '../../types' + +export const NewProposalMain = () => { + const { t } = useTranslation() + const { + register, + control, + watch, + formState: { errors }, + } = useFormContext() + const { loadedActions, categories } = useLoadedActionsAndCategories() + + const { + fields: multipleChoiceFields, + append: addOption, + remove: removeOption, + } = useFieldArray({ + control, + name: 'choices', + shouldUnregister: true, + }) + + const choices = watch('choices') ?? [] + + return ( + <> + {choices.length > 0 && ( +
+ {multipleChoiceFields.map(({ id }, index) => ( + removeOption(index)} + titleFieldName={`choices.${index}.title`} + /> + ))} +
+ )} + +
+
+ addOption({ + title: '', + description: '', + actionData: [], + }) + } + > + + + + +

{t('button.addNewOption')}

+
+ +
+
+
+ + + +

{t('title.noneOfTheAbove')}

+
+ +

+ {t('info.cannotRemoveNoneOption')} +

+
+
+ + ) +} diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalPreview.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalPreview.tsx new file mode 100644 index 0000000000..0e7dfe7f94 --- /dev/null +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalPreview.tsx @@ -0,0 +1,106 @@ +import { Block, Circle } from '@mui/icons-material' +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { ProposalContentDisplay } from '@dao-dao/stateless' +import { MultipleChoiceOptionType } from '@dao-dao/types/contracts/DaoProposalMultiple' +import { convertActionsToMessages } from '@dao-dao/utils' + +import { useLoadedActionsAndCategories } from '../../../../../actions' +import { EntityDisplay, SuspenseLoader } from '../../../../../components' +import { useWalletInfo } from '../../../../../hooks' +import { MULTIPLE_CHOICE_OPTION_COLORS } from '../../components/MultipleChoiceOptionEditor' +import { MultipleChoiceOptionViewer } from '../../components/MultipleChoiceOptionViewer' +import { NewProposalForm } from '../../types' + +export const NewProposalPreview = () => { + const { t } = useTranslation() + const { watch } = useFormContext() + + const { loadedActions } = useLoadedActionsAndCategories() + const { walletAddress = '', walletProfileData } = useWalletInfo() + + const proposalDescription = watch('description') + const proposalTitle = watch('title') + const choices = watch('choices') ?? [] + + return ( + +

{t('title.voteOptions')}

+ + {choices.map(({ title, description, actionData }, index) => ( + + ))} + + {/* None of the above */} + + + } + title={proposalTitle} + /> + ) +} diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/hooks/makeUsePublishProposal.ts b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/hooks/makeUsePublishProposal.ts index acfd358f73..2f6ac0f658 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/hooks/makeUsePublishProposal.ts +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/hooks/makeUsePublishProposal.ts @@ -1,6 +1,7 @@ import { ExecuteResult } from '@cosmjs/cosmwasm-stargate' import { coins } from '@cosmjs/stargate' import { useCallback, useEffect, useState } from 'react' +import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' import { constSelector, useRecoilValue, useSetRecoilState } from 'recoil' @@ -31,6 +32,7 @@ import { MakeUsePublishProposalOptions, NewProposalData, PublishProposal, + SimulateProposal, UsePublishProposal, } from '../../types' import { anyoneCanProposeSelector } from '../selectors' @@ -182,6 +184,33 @@ export const makeUsePublishProposal = } }, [simulationBypassExpiration]) + const simulateProposal: SimulateProposal = useCallback( + async ({ choices }) => { + try { + if (!choices.options.filter((c) => c.msgs.length > 0)) { + throw new Error(t('error.noActionsToSimulate')) + } + + // Simulate each option's message set separately. If the DAO only has + // 1 $JUNO, and two options both contain spending all 1 $JUNO, the + // simulation will fail because the DAO does not have sufficient + // funds. Combining all the messages into one simulation will function + // like all messages are executed together, but in reality, only one + // choice will be executed. Thus, just make sure each individual set + // of messages is valid together. + await Promise.all( + choices.options.map(({ msgs }) => simulateMsgs(msgs)) + ) + + toast.success(t('success.proposalSimulation')) + } catch (err) { + console.error(err) + toast.error(processError(err, { forceCapture: false })) + } + }, + [simulateMsgs, t] + ) + const publishProposal: PublishProposal = useCallback( async ( { title, description, choices }, @@ -366,6 +395,7 @@ export const makeUsePublishProposal = ) return { + simulateProposal, publishProposal, anyoneCanPropose, depositUnsatisfied, diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/types.ts b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/types.ts index 9024e0c2fd..884ea63de1 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/types.ts +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/types.ts @@ -73,6 +73,10 @@ export interface PublishProposalOptions { failedSimulationBypassSeconds?: number } +export type SimulateProposal = ( + newProposalData: NewProposalData +) => Promise + export type PublishProposal = ( newProposalData: NewProposalData, options?: PublishProposalOptions @@ -87,6 +91,7 @@ export interface MakeUsePublishProposalOptions { } export type UsePublishProposal = () => { + simulateProposal: SimulateProposal publishProposal: PublishProposal anyoneCanPropose: boolean depositUnsatisfied: boolean diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal/index.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal.tsx similarity index 71% rename from packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal/index.tsx rename to packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal.tsx index 9b20200e1f..e2c2f34cbb 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal/index.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal.tsx @@ -1,11 +1,15 @@ import { BookOutlined, FlagOutlined, Timelapse } from '@mui/icons-material' -import { useState } from 'react' +import { useCallback, useState } from 'react' +import { useFormContext } from 'react-hook-form' import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' -import { useRecoilCallback, useRecoilValue } from 'recoil' +import { useRecoilCallback, useRecoilValueLoadable } from 'recoil' import { DaoCoreV2Selectors, blocksPerYearSelector } from '@dao-dao/state' import { + NewProposalTitleDescriptionHeader, + NewProposal as StatelessNewProposal, + NewProposalProps as StatelessNewProposalProps, useCachedLoadable, useChain, useDaoInfoContext, @@ -15,24 +19,25 @@ import { IProposalModuleAdapterCommonOptions, } from '@dao-dao/types' import { + convertActionsToMessages, convertExpirationToDate, dateToWdhms, processError, } from '@dao-dao/utils' -import { useLoadedActionsAndCategories } from '../../../../../../actions' -import { EntityDisplay } from '../../../../../../components/EntityDisplay' -import { SuspenseLoader } from '../../../../../../components/SuspenseLoader' -import { useMembership, useWallet } from '../../../../../../hooks' -import { proposalSelector } from '../../../contracts/DaoProposalSingle.common.recoil' -import { makeGetProposalInfo } from '../../../functions' +import { useLoadedActionsAndCategories } from '../../../../../actions' +import { useMembership, useWallet } from '../../../../../hooks' +import { proposalSelector } from '../../contracts/DaoProposalSingle.common.recoil' +import { makeGetProposalInfo } from '../../functions' import { NewProposalData, NewProposalForm, + SimulateProposal, UsePublishProposal, -} from '../../../types' -import { useProcessTQ } from '../../hooks' -import { NewProposal as StatelessNewProposal } from './NewProposal' +} from '../../types' +import { useProcessTQ } from '../hooks' +import { NewProposalMain } from './NewProposalMain' +import { NewProposalPreview } from './NewProposalPreview' export type NewProposalProps = BaseNewProposalProps & { options: IProposalModuleAdapterCommonOptions @@ -54,9 +59,11 @@ export const NewProposal = ({ isActive, activeThreshold, } = useDaoInfoContext() - const { isWalletConnected, getStargateClient } = useWallet() + const { isWalletConnecting, isWalletConnected, getStargateClient } = + useWallet() - const { loadedActions, categories } = useLoadedActionsAndCategories() + const { watch } = useFormContext() + const proposalTitle = watch('title') const { isMember = false, loading: membershipLoading } = useMembership({ coreAddress, @@ -80,19 +87,33 @@ export const NewProposal = ({ const processTQ = useProcessTQ() - const blocksPerYear = useRecoilValue( + const blocksPerYearLoadable = useRecoilValueLoadable( blocksPerYearSelector({ chainId, }) ) const { + simulateProposal: _simulateProposal, publishProposal, anyoneCanPropose, depositUnsatisfied, simulationBypassExpiration, } = usePublishProposal() + const [simulating, setSimulating] = useState(false) + const simulateProposal: SimulateProposal = useCallback( + async (...params) => { + setSimulating(true) + try { + await _simulateProposal(...params) + } finally { + setSimulating(false) + } + }, + [_simulateProposal] + ) + const createProposal = useRecoilCallback( ({ snapshot }) => async (newProposalData: NewProposalData) => { @@ -101,6 +122,12 @@ export const NewProposal = ({ return } + if (blocksPerYearLoadable.state !== 'hasValue') { + toast.error(t('error.loadingData')) + return + } + const blocksPerYear = blocksPerYearLoadable.contents + setLoading(true) try { const { proposalNumber, proposalId } = await publishProposal( @@ -201,7 +228,7 @@ export const NewProposal = ({ isWalletConnected, publishProposal, options, - blocksPerYear, + blocksPerYearLoadable, getStargateClient, chainId, processTQ, @@ -213,16 +240,30 @@ export const NewProposal = ({ ] ) + const { loadedActions } = useLoadedActionsAndCategories() + + const getProposalDataFromFormData: StatelessNewProposalProps< + NewProposalForm, + NewProposalData + >['getProposalDataFromFormData'] = ({ title, description, actionData }) => ({ + title, + description, + msgs: convertActionsToMessages(loadedActions, actionData), + }) + return ( - activeThreshold={activeThreshold} anyoneCanPropose={anyoneCanPropose} - categories={categories} connected={isWalletConnected} + content={{ + Header: NewProposalTitleDescriptionHeader, + Main: NewProposalMain, + Preview: NewProposalPreview, + }} createProposal={createProposal} depositUnsatisfied={depositUnsatisfied} + getProposalDataFromFormData={getProposalDataFromFormData} isActive={isActive} isMember={ membershipLoading @@ -230,8 +271,10 @@ export const NewProposal = ({ : { loading: false, data: isMember } } isPaused={isPaused} - loadedActions={loadedActions} - loading={loading} + isWalletConnecting={isWalletConnecting} + loading={loading || simulating} + proposalTitle={proposalTitle} + simulateProposal={simulateProposal} simulationBypassExpiration={simulationBypassExpiration} {...props} /> diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal/NewProposal.stories.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal/NewProposal.stories.tsx deleted file mode 100644 index c8a1001b81..0000000000 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal/NewProposal.stories.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react' -import { FormProvider, useForm } from 'react-hook-form' - -import { ProposalModuleSelectorProps } from '@dao-dao/stateless' -import { Default as ProposalModuleSelectorStory } from '@dao-dao/stateless/components/proposal/ProposalModuleSelector.stories' -import { - DaoPageWrapperDecorator, - WalletProviderDecorator, -} from '@dao-dao/storybook/decorators' - -import { useLoadedActionsAndCategories } from '../../../../../../actions' -import { NewProposalForm } from '../../../types' -import { NewProposal } from './NewProposal' - -export default { - title: - 'DAO DAO / packages / stateful / proposal-module-adapter / adapters / DaoProposalSingle / common / components / NewProposal', - component: NewProposal, - decorators: [DaoPageWrapperDecorator, WalletProviderDecorator], -} as ComponentMeta - -const Template: ComponentStory = (args) => { - const { loadedActions, categories } = useLoadedActionsAndCategories() - - const formMethods = useForm({ - mode: 'onChange', - defaultValues: { - title: '', - description: '', - actionData: [], - }, - }) - - return ( - - - - ) -} - -export const Default = Template.bind({}) -Default.args = { - createProposal: async (data) => { - console.log(data) - alert('submit') - }, - loading: false, - isPaused: false, - isActive: true, - activeThreshold: null, - isMember: { loading: false, data: true }, - depositUnsatisfied: false, - connected: true, - drafts: [], - proposalModuleSelector: ( - - ), -} -Default.parameters = { - design: { - type: 'figma', - url: 'https://www.figma.com/file/ZnQ4SMv8UUgKDZsR5YjVGH/Dao-2.0?node-id=985%3A46068', - }, -} diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposalMain.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposalMain.tsx new file mode 100644 index 0000000000..2d9f675ca1 --- /dev/null +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposalMain.tsx @@ -0,0 +1,87 @@ +import { useFieldArray, useFormContext } from 'react-hook-form' +import { v4 as uuidv4 } from 'uuid' + +import { + ActionCategorySelector, + ActionsEditor, + ActionsRenderer, +} from '@dao-dao/stateless' +import { LoadedAction } from '@dao-dao/types' + +import { useLoadedActionsAndCategories } from '../../../../../actions' +import { SuspenseLoader } from '../../../../../components' +import { NewProposalForm } from '../../types' + +export type NewProposalMainProps = { + actionsReadOnlyMode?: boolean +} + +export const NewProposalMain = ({ + actionsReadOnlyMode, +}: NewProposalMainProps) => { + const { loadedActions, categories } = useLoadedActionsAndCategories() + + const { + control, + watch, + formState: { errors }, + } = useFormContext() + + const { append } = useFieldArray({ + name: 'actionData', + control, + shouldUnregister: true, + }) + + const actionData = watch('actionData') || [] + + return ( + <> + {actionsReadOnlyMode ? ( + { + const { category, action } = ( + actionKey ? loadedActions[actionKey] || {} : {} + ) as Partial + + return category && action + ? { + id: index.toString(), + category, + action, + data, + } + : [] + })} + /> + ) : ( + + )} + + {!actionsReadOnlyMode && ( +
+ { + append({ + // See `CategorizedActionKeyAndData` comment in + // `packages/types/actions.ts` for an explanation of why we need + // to append with a unique ID. + _id: uuidv4(), + categoryKey: key, + }) + }} + /> +
+ )} + + ) +} diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposalPreview.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposalPreview.tsx new file mode 100644 index 0000000000..dd1ea83c56 --- /dev/null +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposalPreview.tsx @@ -0,0 +1,43 @@ +import { useFormContext } from 'react-hook-form' + +import { ProposalContentDisplay, RawActionsRenderer } from '@dao-dao/stateless' + +import { useLoadedActionsAndCategories } from '../../../../../actions' +import { EntityDisplay } from '../../../../../components' +import { useWalletInfo } from '../../../../../hooks' +import { NewProposalForm } from '../../types' + +export const NewProposalPreview = () => { + const { loadedActions } = useLoadedActionsAndCategories() + const { watch } = useFormContext() + + const { walletAddress = '', walletProfileData } = useWalletInfo() + + const proposalDescription = watch('description') + const proposalTitle = watch('title') + + const actionData = watch('actionData') || [] + + return ( + + ) : undefined + } + title={proposalTitle} + /> + ) +} diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/hooks/makeUsePublishProposal.ts b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/hooks/makeUsePublishProposal.ts index a4f6dbca46..c89b5c465f 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/hooks/makeUsePublishProposal.ts +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/hooks/makeUsePublishProposal.ts @@ -1,5 +1,6 @@ import { coins } from '@cosmjs/stargate' import { useCallback, useEffect, useState } from 'react' +import toast from 'react-hot-toast' import { useTranslation } from 'react-i18next' import { constSelector, useRecoilValue, useSetRecoilState } from 'recoil' @@ -31,6 +32,7 @@ import { MakeUsePublishProposalOptions, NewProposalData, PublishProposal, + SimulateProposal, UsePublishProposal, } from '../../types' import { anyoneCanProposeSelector } from '../selectors' @@ -183,6 +185,24 @@ export const makeUsePublishProposal = } }, [simulationBypassExpiration]) + const simulateProposal: SimulateProposal = useCallback( + async ({ msgs }) => { + try { + if (!msgs.length) { + throw new Error(t('error.noActionsToSimulate')) + } + + await simulateMsgs(msgs) + + toast.success(t('success.proposalSimulation')) + } catch (err) { + console.error(err) + toast.error(processError(err, { forceCapture: false })) + } + }, + [simulateMsgs, t] + ) + const publishProposal: PublishProposal = useCallback( async ( { title, description, msgs }, @@ -361,6 +381,7 @@ export const makeUsePublishProposal = ) return { + simulateProposal, publishProposal, anyoneCanPropose, depositUnsatisfied, diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/types.ts b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/types.ts index 1f5ad6e8f6..d10146fc70 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/types.ts +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/types.ts @@ -58,6 +58,10 @@ export interface PublishProposalOptions { failedSimulationBypassSeconds?: number } +export type SimulateProposal = ( + newProposalData: NewProposalData +) => Promise + export type PublishProposal = ( newProposalData: NewProposalData, options?: PublishProposalOptions @@ -72,6 +76,7 @@ export interface MakeUsePublishProposalOptions { } export type UsePublishProposal = () => { + simulateProposal: SimulateProposal publishProposal: PublishProposal anyoneCanPropose: boolean depositUnsatisfied: boolean diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal/NewProposal.tsx b/packages/stateless/components/proposal/NewProposal.tsx similarity index 51% rename from packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal/NewProposal.tsx rename to packages/stateless/components/proposal/NewProposal.tsx index 415b9817a8..0c8c11aa42 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal/NewProposal.tsx +++ b/packages/stateless/components/proposal/NewProposal.tsx @@ -1,78 +1,67 @@ import { Close, GavelRounded, + Speed, Visibility, VisibilityOff, } from '@mui/icons-material' import clsx from 'clsx' import Fuse from 'fuse.js' -import { ComponentType, useCallback, useState } from 'react' +import { ComponentType, useEffect, useState } from 'react' import { + FieldValues, SubmitErrorHandler, SubmitHandler, - useFieldArray, + UnpackNestedValue, useFormContext, } from 'react-hook-form' import { useTranslation } from 'react-i18next' import TimeAgo from 'react-timeago' -import { v4 as uuidv4 } from 'uuid' import { - ActionCategorySelector, - ActionsEditor, - ActionsRenderer, - Button, - FilterableItem, - FilterableItemPopup, - IconButton, - InputErrorMessage, - ProposalContentDisplay, - RawActionsRenderer, - TextAreaInput, - TextInput, - Tooltip, -} from '@dao-dao/stateless' -import { - ActionCategoryWithLabel, ActiveThreshold, BaseNewProposalProps, - LoadedAction, - LoadedActions, LoadingData, - StatefulEntityDisplayProps, - SuspenseLoaderProps, } from '@dao-dao/types' import { - convertActionsToMessages, formatDateTime, formatPercentOf100, formatTime, processError, - validateRequired, } from '@dao-dao/utils' -import { useWallet, useWalletInfo } from '../../../../../../hooks' -import { NewProposalData, NewProposalForm } from '../../../types' +import { Button } from '../buttons' +import { IconButton } from '../icon_buttons' +import { FilterableItem, FilterableItemPopup } from '../popup' +import { Tooltip } from '../tooltip' enum ProposeSubmitValue { Preview = 'Preview', Submit = 'Submit', } -export interface NewProposalProps - extends Pick< - BaseNewProposalProps, - | 'draft' - | 'saveDraft' - | 'drafts' - | 'loadDraft' - | 'unloadDraft' - | 'draftSaving' - | 'deleteDraft' - | 'proposalModuleSelector' - | 'actionsReadOnlyMode' - > { - createProposal: (newProposalData: NewProposalData) => Promise +type BaseProps = Omit< + BaseNewProposalProps, + 'onCreateSuccess' +> + +export type NewProposalProps< + FormData extends FieldValues = any, + ProposalData extends unknown = any +> = BaseProps & { + content: { + Header: ComponentType + Main: ComponentType, 'actionsReadOnlyMode'>> + Preview: ComponentType + } + getProposalDataFromFormData: ( + formData: UnpackNestedValue + ) => ProposalData + createProposal: (newProposalData: ProposalData) => Promise + simulateProposal: (newProposalData: ProposalData) => Promise + proposalTitle: string + isWalletConnecting: boolean + additionalSubmitError?: string loading: boolean isPaused: boolean isActive: boolean @@ -81,15 +70,20 @@ export interface NewProposalProps anyoneCanPropose: boolean depositUnsatisfied: boolean connected: boolean - categories: ActionCategoryWithLabel[] - loadedActions: LoadedActions simulationBypassExpiration?: Date - SuspenseLoader: ComponentType - EntityDisplay: ComponentType } -export const NewProposal = ({ +export const NewProposal = < + FormData extends FieldValues = any, + ProposalData extends unknown = any +>({ + content: { Header, Main, Preview }, + getProposalDataFromFormData, createProposal, + simulateProposal, + proposalTitle, + isWalletConnecting, + additionalSubmitError, loading, isPaused, isActive, @@ -98,8 +92,6 @@ export const NewProposal = ({ anyoneCanPropose, depositUnsatisfied, connected, - categories, - loadedActions, draft, saveDraft, drafts, @@ -109,173 +101,84 @@ export const NewProposal = ({ deleteDraft, simulationBypassExpiration, proposalModuleSelector, - SuspenseLoader, - EntityDisplay, actionsReadOnlyMode, -}: NewProposalProps) => { +}: NewProposalProps) => { const { t } = useTranslation() - // Unpack here because we use these at the top level as well as inside of - // nested components. - const { - register, - control, - handleSubmit, - watch, - formState: { errors }, - } = useFormContext() + const { handleSubmit } = useFormContext() const [showPreview, setShowPreview] = useState(false) const [showSubmitErrorNote, setShowSubmitErrorNote] = useState(false) const [submitError, setSubmitError] = useState('') - const { isWalletConnecting } = useWallet() - const { walletAddress = '', walletProfileData } = useWalletInfo() - - const proposalDescription = watch('description') - const proposalTitle = watch('title') - - const { append } = useFieldArray({ - name: 'actionData', - control, - shouldUnregister: true, - }) - - const actionData = watch('actionData') || [] - - const onSubmitForm: SubmitHandler = useCallback( - ({ title, description, actionData }, event) => { - setShowSubmitErrorNote(false) - setSubmitError('') - - const nativeEvent = event?.nativeEvent as SubmitEvent - const submitterValue = (nativeEvent?.submitter as HTMLInputElement)?.value - - // Preview toggled in onClick handler. - if (submitterValue === ProposeSubmitValue.Preview) { - return + const [holdingAltForSimulation, setHoldingAlt] = useState(false) + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Alt') { + setHoldingAlt(true) } - - let msgs - try { - msgs = convertActionsToMessages(loadedActions, actionData) - } catch (err) { - console.error(err) - setSubmitError( - processError(err, { - forceCapture: false, - }) - ) - return + } + const handleKeyUp = (event: KeyboardEvent) => { + if (event.key === 'Alt') { + setHoldingAlt(false) } + } + + document.addEventListener('keydown', handleKeyDown) + document.addEventListener('keyup', handleKeyUp) + return () => { + document.removeEventListener('keydown', handleKeyDown) + document.removeEventListener('keyup', handleKeyUp) + } + }, []) + + const onSubmitForm: SubmitHandler = (formData, event) => { + setShowSubmitErrorNote(false) + setSubmitError('') - createProposal({ - title, - description, - msgs, - }) - }, - [createProposal, loadedActions] - ) - - const onSubmitError: SubmitErrorHandler = useCallback(() => { + const nativeEvent = event?.nativeEvent as SubmitEvent + const submitterValue = (nativeEvent?.submitter as HTMLInputElement)?.value + + // Preview toggled in onClick handler. + if (submitterValue === ProposeSubmitValue.Preview) { + return + } + + let data: ProposalData + try { + data = getProposalDataFromFormData(formData) + } catch (err) { + console.error(err) + setSubmitError( + processError(err, { + forceCapture: false, + }) + ) + return + } + + if (holdingAltForSimulation) { + simulateProposal(data) + } else { + createProposal(data) + } + } + + const onSubmitError: SubmitErrorHandler = () => { setShowSubmitErrorNote(true) setSubmitError('') - }, [setShowSubmitErrorNote]) + } return (
-
-
-

- {t('form.proposalsName')} -

- -
- - -
-
-
-

- {t('form.description')} - - {/* eslint-disable-next-line i18next/no-literal-string */} - {' – '} - {t('info.supportsMarkdownFormat')} - -

- -
- - -
-
-
+
{!actionsReadOnlyMode && proposalModuleSelector} - {actionsReadOnlyMode ? ( - { - const { category, action } = ( - actionKey ? loadedActions[actionKey] || {} : {} - ) as Partial - - return category && action - ? { - id: index.toString(), - category, - action, - data, - } - : [] - })} - /> - ) : ( - - )} - - {!actionsReadOnlyMode && ( -
- { - append({ - // See `CategorizedActionKeyAndData` comment in - // `packages/types/actions.ts` for an explanation of why we need - // to append with a unique ID. - _id: uuidv4(), - categoryKey: key, - }) - }} - /> -
- )} +
@@ -306,7 +209,9 @@ export const NewProposal = ({
@@ -399,26 +320,7 @@ export const NewProposal = ({ {showPreview && (
- - ) : undefined - } - title={proposalTitle} - /> +
)}
diff --git a/packages/stateless/components/proposal/NewProposalTitleDescriptionHeader.tsx b/packages/stateless/components/proposal/NewProposalTitleDescriptionHeader.tsx new file mode 100644 index 0000000000..911e5a5e03 --- /dev/null +++ b/packages/stateless/components/proposal/NewProposalTitleDescriptionHeader.tsx @@ -0,0 +1,60 @@ +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { validateRequired } from '@dao-dao/utils' + +import { InputErrorMessage, TextAreaInput, TextInput } from '../inputs' + +type TitleDescriptionForm = { + title: string + description: string +} + +export const NewProposalTitleDescriptionHeader = () => { + const { t } = useTranslation() + const { + register, + formState: { errors }, + } = useFormContext() + + return ( +
+
+

{t('form.proposalsName')}

+ +
+ + +
+
+
+

+ {t('form.description')} + + {/* eslint-disable-next-line i18next/no-literal-string */} + {' – '} + {t('info.supportsMarkdownFormat')} + +

+ +
+ + +
+
+
+ ) +} diff --git a/packages/stateless/components/proposal/index.ts b/packages/stateless/components/proposal/index.ts index dde3b2fcfe..0874be7d65 100644 --- a/packages/stateless/components/proposal/index.ts +++ b/packages/stateless/components/proposal/index.ts @@ -3,6 +3,8 @@ export * from './GovProposalCreatedModal' export * from './GovProposalStatus' export * from './GovProposalVoteDisplay' export * from './GovProposalWalletVote' +export * from './NewProposal' +export * from './NewProposalTitleDescriptionHeader' export * from './PaginatedProposalVotes' export * from './ProgressBar' export * from './ProposalCard' diff --git a/packages/stateless/pages/CreateProposal.stories.tsx b/packages/stateless/pages/CreateProposal.stories.tsx deleted file mode 100644 index 3eb817c6a3..0000000000 --- a/packages/stateless/pages/CreateProposal.stories.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import { ComponentMeta, ComponentStory } from '@storybook/react' - -import { NewProposalProps } from '@dao-dao/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal/NewProposal' -import { Default as NewProposalStory } from '@dao-dao/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal/NewProposal.stories' -import { - DaoPageWrapperDecorator, - makeDappLayoutDecorator, -} from '@dao-dao/storybook/decorators' - -import { - ProfileNewProposalCard, - ProfileNewProposalCardProps, -} from '../components/profile/ProfileNewProposalCard' -import { Default as ProfileNewProposalCardStory } from '../components/profile/ProfileNewProposalCard.stories' -import { CreateProposal } from './CreateProposal' - -export default { - title: 'DAO DAO / packages / stateless / pages / CreateProposal', - component: CreateProposal, - decorators: [ - // Direct ancestor of rendered story. - DaoPageWrapperDecorator, - makeDappLayoutDecorator(), - ], -} as ComponentMeta - -const Template: ComponentStory = (args) => ( - - } - /> -) - -export const Default = Template.bind({}) -Default.args = { - rightSidebarContent: ( - - ), -} -Default.parameters = { - design: { - type: 'figma', - url: 'https://www.figma.com/file/ZnQ4SMv8UUgKDZsR5YjVGH/Dao-2.0?node-id=985%3A46048', - }, - nextRouter: { - asPath: '/dao/core1', - }, -} diff --git a/packages/utils/error.ts b/packages/utils/error.ts index 878364d33d..c69872a6aa 100644 --- a/packages/utils/error.ts +++ b/packages/utils/error.ts @@ -15,8 +15,11 @@ export const processError = ( extra?: Record transform?: Partial> overrideCapture?: Partial> - // If set to true, will capture error. If set to false, will not capture - // error. If undefined, will use capture map. + /** + * If set to true, will sent error to Sentry. If set to false, will not send + * error to Sentry. If undefined, will use default behavior (reference the + * capture map). + */ forceCapture?: boolean } = {} ): string => {