diff --git a/packages/i18n/locales/en/translation.json b/packages/i18n/locales/en/translation.json index 4fb5a3bcd..21671f0ff 100644 --- a/packages/i18n/locales/en/translation.json +++ b/packages/i18n/locales/en/translation.json @@ -1180,6 +1180,7 @@ "inboxEmailTooltip": "Receive inbox notifications in your email.", "initialRewardsFundsDescription": "Reward distribution starts upon execution if initial funds are added.", "installKeplrMobileOrScanQrCode": "If you don't have Keplr Mobile installed, <2>click here to install it or scan the QR code at the bottom with another device.", + "instantVoteTooltip": "Cast a vote when submitting this proposal.", "instantiatePredictableSmartContractActionDescription": "Instantiate a smart contract with a predictable address.", "instantiateSmartContractActionDescription": "Instantiate a smart contract.", "instantiatorAccountTooltip": "This is the account that will be instantiating the smart contract. The contract will see this as the `sender`.", @@ -1876,6 +1877,7 @@ "importMultisig": "Import Multisig", "inactiveDaos": "Inactive DAOs", "initialTokenDistribution": "Initial Token Distribution", + "instantVote": "Instant Vote", "instantiatePredictableSmartContract": "Instantiate Predictable Smart Contract", "instantiateSmartContract": "Instantiate Smart Contract", "lastUpdated": "Last updated", diff --git a/packages/stateful/actions/core/actions/BecomeApprover/index.tsx b/packages/stateful/actions/core/actions/BecomeApprover/index.tsx index 12d30f319..3fe240f59 100644 --- a/packages/stateful/actions/core/actions/BecomeApprover/index.tsx +++ b/packages/stateful/actions/core/actions/BecomeApprover/index.tsx @@ -106,7 +106,7 @@ export class BecomeApproverAction extends ActionBase { constructor(options: ActionOptions) { if ( options.context.type !== ActionContextType.Dao || - !options.context.dao.info.supportedFeatures[Feature.Approval] + !options.context.dao.supports(Feature.Approval) ) { throw new Error('Invalid context for becoming an approver') } diff --git a/packages/stateful/actions/core/actions/EnableApprover/index.tsx b/packages/stateful/actions/core/actions/EnableApprover/index.tsx index b39435a64..baf14ccbf 100644 --- a/packages/stateful/actions/core/actions/EnableApprover/index.tsx +++ b/packages/stateful/actions/core/actions/EnableApprover/index.tsx @@ -60,7 +60,7 @@ export class EnableApproverAction extends ActionBase { // - approver is already enabled if ( options.context.type !== ActionContextType.Dao || - !options.context.dao.info.supportedFeatures[Feature.Approval] + !options.context.dao.supports(Feature.Approval) ) { throw new Error('Invalid context for enabling approver') } diff --git a/packages/stateful/actions/core/actions/EnableMultipleChoice/index.tsx b/packages/stateful/actions/core/actions/EnableMultipleChoice/index.tsx index bfccbd4a1..11dea1bf8 100644 --- a/packages/stateful/actions/core/actions/EnableMultipleChoice/index.tsx +++ b/packages/stateful/actions/core/actions/EnableMultipleChoice/index.tsx @@ -56,9 +56,7 @@ export class EnableMultipleChoiceAction extends ActionBase<{}> { // support approval flow right now and that would be confusing. if ( options.context.type !== ActionContextType.Dao || - !options.context.dao.info.supportedFeatures[ - Feature.MultipleChoiceProposals - ] || + !options.context.dao.supports(Feature.MultipleChoiceProposals) || // Neutron fork SubDAOs don't support multiple choice proposals due to the // timelock/overrule system only being designed for single choice // proposals. diff --git a/packages/stateful/actions/core/actions/ManageStorageItems/index.tsx b/packages/stateful/actions/core/actions/ManageStorageItems/index.tsx index 3d918b466..286151ecc 100644 --- a/packages/stateful/actions/core/actions/ManageStorageItems/index.tsx +++ b/packages/stateful/actions/core/actions/ManageStorageItems/index.tsx @@ -77,9 +77,7 @@ export class ManageStorageItemsAction extends ActionBase matchPriority: -90, }) - this.valueKey = options.context.dao.info.supportedFeatures[ - Feature.StorageItemValueKey - ] + this.valueKey = options.context.dao.supports(Feature.StorageItemValueKey) ? 'value' : 'addr' } diff --git a/packages/stateful/actions/core/actions/ManageSubDaos/index.tsx b/packages/stateful/actions/core/actions/ManageSubDaos/index.tsx index e1a300e89..1d3e6e2a8 100644 --- a/packages/stateful/actions/core/actions/ManageSubDaos/index.tsx +++ b/packages/stateful/actions/core/actions/ManageSubDaos/index.tsx @@ -66,7 +66,7 @@ export class ManageSubDaosAction extends ActionBase { throw new Error('Only DAOs can manage their subDAOs.') } - if (!options.context.dao.info.supportedFeatures[Feature.SubDaos]) { + if (!options.context.dao.supports(Feature.SubDaos)) { throw new Error("This DAO's version doesn't support subDAOs.") } diff --git a/packages/stateful/clients/dao/CreatingDaoPlaceholder.ts b/packages/stateful/clients/dao/CreatingDaoPlaceholder.ts index fff2e25c8..0509f1ebb 100644 --- a/packages/stateful/clients/dao/CreatingDaoPlaceholder.ts +++ b/packages/stateful/clients/dao/CreatingDaoPlaceholder.ts @@ -17,7 +17,7 @@ import { TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, } from '@dao-dao/types/contracts/DaoDaoCore' -import { getChainForChainId, getSupportedFeatures } from '@dao-dao/utils' +import { getChainForChainId } from '@dao-dao/utils' import { DaoBase } from './base' @@ -44,7 +44,6 @@ export class CreatingDaoPlaceholder extends DaoBase { chainId: options.chainId, coreAddress: options.coreAddress, coreVersion: options.coreVersion, - supportedFeatures: getSupportedFeatures(options.coreVersion), votingModuleAddress: '', votingModuleInfo: { contract: '', diff --git a/packages/stateful/clients/dao/base.ts b/packages/stateful/clients/dao/base.ts index b67976658..5127f4b27 100644 --- a/packages/stateful/clients/dao/base.ts +++ b/packages/stateful/clients/dao/base.ts @@ -9,6 +9,7 @@ import { DaoCardLazyData, DaoInfo, DaoSource, + Feature, IDaoBase, IProposalModuleBase, IVotingModuleBase, @@ -17,6 +18,7 @@ import { TotalPowerAtHeightResponse, VotingPowerAtHeightResponse, } from '@dao-dao/types/contracts/DaoDaoCore' +import { isFeatureSupportedByVersion } from '@dao-dao/utils' export abstract class DaoBase implements IDaoBase { constructor(protected readonly queryClient: QueryClient) {} @@ -130,6 +132,13 @@ export abstract class DaoBase implements IDaoBase { return this.info.items['banner'] } + /** + * Check whether or not the DAO supports a given feature. + */ + supports(feature: Feature): boolean { + return isFeatureSupportedByVersion(feature, this.coreVersion) + } + /** * Get the proposal module with the given address. */ diff --git a/packages/stateful/clients/proposal-module/MultipleChoiceProposalModule.secret.ts b/packages/stateful/clients/proposal-module/MultipleChoiceProposalModule.secret.ts index 8d09a8e93..94582579e 100644 --- a/packages/stateful/clients/proposal-module/MultipleChoiceProposalModule.secret.ts +++ b/packages/stateful/clients/proposal-module/MultipleChoiceProposalModule.secret.ts @@ -12,6 +12,7 @@ import { CheckedDepositInfo, Coin, Duration, + Feature, SecretModuleInstantiateInfo, } from '@dao-dao/types' import { @@ -138,12 +139,14 @@ export class SecretMultipleChoiceProposalModule extends ProposalModuleBase< } async propose({ - data, + data: _data, + vote, getSigningClient, sender, funds, }: { data: NewProposalData + vote?: MultipleChoiceVote getSigningClient: () => Promise sender: string funds?: Coin[] @@ -151,6 +154,21 @@ export class SecretMultipleChoiceProposalModule extends ProposalModuleBase< proposalNumber: number proposalId: string }> { + if (vote && !this.supports(Feature.CastVoteOnProposalCreation)) { + throw new Error( + `Casting vote on proposal creation is not supported by version ${this.version}` + ) + } + + const data = { + ..._data, + ...(vote && { + vote: { + vote, + }, + }), + } + const client = await getSigningClient() const permit = await this.dao.getPermit(sender) diff --git a/packages/stateful/clients/proposal-module/MultipleChoiceProposalModule.ts b/packages/stateful/clients/proposal-module/MultipleChoiceProposalModule.ts index 7d9378497..cca9b0518 100644 --- a/packages/stateful/clients/proposal-module/MultipleChoiceProposalModule.ts +++ b/packages/stateful/clients/proposal-module/MultipleChoiceProposalModule.ts @@ -196,12 +196,14 @@ export class MultipleChoiceProposalModule extends ProposalModuleBase< } async propose({ - data, + data: _data, + vote, getSigningClient, sender, funds, }: { data: NewProposalData + vote?: MultipleChoiceVote getSigningClient: () => Promise sender: string funds?: Coin[] @@ -209,6 +211,21 @@ export class MultipleChoiceProposalModule extends ProposalModuleBase< proposalNumber: number proposalId: string }> { + if (vote && !this.supports(Feature.CastVoteOnProposalCreation)) { + throw new Error( + `Casting vote on proposal creation is not supported by version ${this.version}` + ) + } + + const data = { + ..._data, + ...(vote && { + vote: { + vote, + }, + }), + } + const client = await getSigningClient() let proposalNumber: number diff --git a/packages/stateful/clients/proposal-module/SingleChoiceProposalModule.secret.ts b/packages/stateful/clients/proposal-module/SingleChoiceProposalModule.secret.ts index 7f078e36c..2561f21c0 100644 --- a/packages/stateful/clients/proposal-module/SingleChoiceProposalModule.secret.ts +++ b/packages/stateful/clients/proposal-module/SingleChoiceProposalModule.secret.ts @@ -12,6 +12,7 @@ import { CheckedDepositInfo, Coin, Duration, + Feature, SecretModuleInstantiateInfo, } from '@dao-dao/types' import { InstantiateMsg as SecretDaoPreProposeApprovalSingleInstantiateMsg } from '@dao-dao/types/contracts/SecretDaoPreProposeApprovalSingle' @@ -156,12 +157,14 @@ export class SecretSingleChoiceProposalModule extends ProposalModuleBase< } async propose({ - data, + data: _data, + vote, getSigningClient, sender, funds, }: { data: NewProposalData + vote?: Vote getSigningClient: () => Promise sender: string funds?: Coin[] @@ -169,6 +172,21 @@ export class SecretSingleChoiceProposalModule extends ProposalModuleBase< proposalNumber: number proposalId: string }> { + if (vote && !this.supports(Feature.CastVoteOnProposalCreation)) { + throw new Error( + `Casting vote on proposal creation is not supported by version ${this.version}` + ) + } + + const data = { + ..._data, + ...(vote && { + vote: { + vote, + }, + }), + } + const client = await getSigningClient() const permit = await this.dao.getPermit(sender) diff --git a/packages/stateful/clients/proposal-module/SingleChoiceProposalModule.ts b/packages/stateful/clients/proposal-module/SingleChoiceProposalModule.ts index 6dcf15061..e0a25f571 100644 --- a/packages/stateful/clients/proposal-module/SingleChoiceProposalModule.ts +++ b/packages/stateful/clients/proposal-module/SingleChoiceProposalModule.ts @@ -176,12 +176,14 @@ export class SingleChoiceProposalModule extends ProposalModuleBase< } async propose({ - data, + data: _data, + vote, getSigningClient, sender, funds, }: { data: NewProposalData + vote?: Vote getSigningClient: () => Promise sender: string funds?: Coin[] @@ -189,6 +191,21 @@ export class SingleChoiceProposalModule extends ProposalModuleBase< proposalNumber: number proposalId: string }> { + if (vote && !this.supports(Feature.CastVoteOnProposalCreation)) { + throw new Error( + `Casting vote on proposal creation is not supported by version ${this.version}` + ) + } + + const data = { + ..._data, + ...(vote && { + vote: { + vote, + }, + }), + } + const client = await getSigningClient() let proposalNumber: number diff --git a/packages/stateful/clients/proposal-module/base.ts b/packages/stateful/clients/proposal-module/base.ts index efd896c36..48ff0ded4 100644 --- a/packages/stateful/clients/proposal-module/base.ts +++ b/packages/stateful/clients/proposal-module/base.ts @@ -7,11 +7,13 @@ import { Coin, ContractVersion, Duration, + Feature, IDaoBase, IProposalModuleBase, PreProposeModule, ProposalModuleInfo, } from '@dao-dao/types' +import { isFeatureSupportedByVersion } from '@dao-dao/utils' export abstract class ProposalModuleBase< Dao extends IDaoBase = IDaoBase, @@ -85,11 +87,22 @@ export abstract class ProposalModuleBase< return this.info.prePropose } + /** + * Check whether or not the proposal module supports a given feature. + */ + supports(feature: Feature): boolean { + return isFeatureSupportedByVersion(feature, this.version) + } + /** * Make a proposal. */ abstract propose(options: { data: Proposal + /** + * Cast a vote with the proposal. + */ + vote?: Vote getSigningClient: () => Promise sender: string funds?: Coin[] diff --git a/packages/stateful/command/contexts/generic/dao.tsx b/packages/stateful/command/contexts/generic/dao.tsx index 2ac4e3897..d496ba75f 100644 --- a/packages/stateful/command/contexts/generic/dao.tsx +++ b/packages/stateful/command/contexts/generic/dao.tsx @@ -48,9 +48,7 @@ export const makeGenericDaoContext: CommandModalContextMaker<{ const useSections = () => { const { t } = useTranslation() const { getDaoPath, getDaoProposalPath, router } = useDaoNavHelpers() - const { - info: { accounts, supportedFeatures }, - } = useDao() + const dao = useDao() const loadingTabs = useLoadingTabs() const { isFollowing, setFollowing, setUnfollowing, updatingFollowing } = @@ -83,7 +81,7 @@ export const makeGenericDaoContext: CommandModalContextMaker<{ chainId, coreAddress, }), - enabled: !!supportedFeatures[Feature.SubDaos], + enabled: dao.supports(Feature.SubDaos), }, [] ) @@ -152,7 +150,7 @@ export const makeGenericDaoContext: CommandModalContextMaker<{ }), loading: updatingFollowing, }, - ...accounts.map(({ chainId, address, type }, accountIndex) => ({ + ...dao.accounts.map(({ chainId, address, type }, accountIndex) => ({ name: copied === accountIndex ? t('info.copiedChainAddress', { diff --git a/packages/stateful/components/dao/CreateDaoForm.tsx b/packages/stateful/components/dao/CreateDaoForm.tsx index a2bb80232..318c51c07 100644 --- a/packages/stateful/components/dao/CreateDaoForm.tsx +++ b/packages/stateful/components/dao/CreateDaoForm.tsx @@ -900,8 +900,6 @@ export const InnerCreateDaoForm = ({ description, imageUrl: imageUrl || getFallbackImage(coreAddress), parentDao: parentDao || null, - // Unused. - supportedFeatures: {} as any, created: Date.now(), votingModuleAddress: '', votingModuleInfo: { diff --git a/packages/stateful/components/dao/CreateDaoProposal.tsx b/packages/stateful/components/dao/CreateDaoProposal.tsx index a07d7f54b..b6804ffe4 100644 --- a/packages/stateful/components/dao/CreateDaoProposal.tsx +++ b/packages/stateful/components/dao/CreateDaoProposal.tsx @@ -431,7 +431,14 @@ const InnerCreateDaoProposal = ({ className="my-2" matchAdapter={matchProposalModuleAdapter} selected={selectedProposalModule.address} - setSelected={setSelectedProposalModule} + setSelected={(m) => { + // Clear instant vote if switching proposal modules. + if (m.address !== selectedProposalModule.address) { + formMethods.setValue('vote', undefined) + } + + setSelectedProposalModule(m) + }} /> } saveDraft={saveDraft} diff --git a/packages/stateful/components/dao/LazyDaoCard.tsx b/packages/stateful/components/dao/LazyDaoCard.tsx index 596d02618..0cf4df003 100644 --- a/packages/stateful/components/dao/LazyDaoCard.tsx +++ b/packages/stateful/components/dao/LazyDaoCard.tsx @@ -24,8 +24,6 @@ export const LazyDaoCard = (props: LazyDaoCardProps) => { className={clsx('animate-pulse', props.className)} info={{ ...props.info, - // Unused. - supportedFeatures: {} as any, votingModuleAddress: '', votingModuleInfo: { contract: '', @@ -54,8 +52,6 @@ export const LazyDaoCard = (props: LazyDaoCardProps) => { processError(daoInfoQuery.error, { forceCapture: false, }), - // Unused. - supportedFeatures: {} as any, votingModuleAddress: '', votingModuleInfo: { contract: '', diff --git a/packages/stateful/components/dao/tabs/SubDaosTab.tsx b/packages/stateful/components/dao/tabs/SubDaosTab.tsx index 082fe1892..5a48e1fec 100644 --- a/packages/stateful/components/dao/tabs/SubDaosTab.tsx +++ b/packages/stateful/components/dao/tabs/SubDaosTab.tsx @@ -15,11 +15,8 @@ import { ButtonLink } from '../../ButtonLink' import { DaoCard } from '../DaoCard' export const SubDaosTab = () => { - const { - chainId, - coreAddress, - info: { supportedFeatures }, - } = useDao() + const dao = useDao() + const { chainId, coreAddress } = dao const { getDaoPath, getDaoProposalPath } = useDaoNavHelpers() const { isMember = false } = useMembership() @@ -30,7 +27,7 @@ export const SubDaosTab = () => { chainId, coreAddress, }), - enabled: !!supportedFeatures[Feature.SubDaos], + enabled: dao.supports(Feature.SubDaos), }) const upgradeToV2Action = useInitializedActionForKey(ActionKey.UpgradeV1ToV2) diff --git a/packages/stateful/components/pages/DaoDappHome.tsx b/packages/stateful/components/pages/DaoDappHome.tsx index 99b0af3d9..4e72b9eeb 100644 --- a/packages/stateful/components/pages/DaoDappHome.tsx +++ b/packages/stateful/components/pages/DaoDappHome.tsx @@ -154,12 +154,13 @@ export const DaoDappHome = () => { const { t } = useTranslation() const { getDaoProposalPath } = useDaoNavHelpers() + const dao = useDao() const { chainId, coreAddress, name, - info: { contractAdmin, supportedFeatures, parentDao }, - } = useDao() + info: { contractAdmin, parentDao }, + } = dao const { isMember = false } = useMembership() // We won't use this value unless there's a parent, so the undefined DAO @@ -173,7 +174,7 @@ export const DaoDappHome = () => { // parent. isMemberOfParent && // Only v2+ DAOs support SubDAOs. - supportedFeatures[Feature.SubDaos] && + dao.supports(Feature.SubDaos) && // Only show if the parent has not already registered this as a SubDAO. parentDao && !parentDao.registeredSubDao diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal.tsx index 8bf4ba1b8..e91108e46 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposal.tsx @@ -241,6 +241,7 @@ export const NewProposal = ({ title, description, choices, + vote, }) => ({ title, description, @@ -264,6 +265,7 @@ export const NewProposal = ({ })) ), }, + vote, }) return ( 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 index ad1fff5ba..ff55fab99 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalMain.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/common/components/NewProposalMain.tsx @@ -2,7 +2,12 @@ import { Add, Block, Circle } from '@mui/icons-material' import { useFieldArray, useFormContext } from 'react-hook-form' import { useTranslation } from 'react-i18next' +import { ProposalInstantVoter } from '@dao-dao/stateless' +import { Feature } from '@dao-dao/types' + import { SuspenseLoader } from '../../../../../components' +import { useMembership } from '../../../../../hooks' +import { useProposalModuleAdapterCommonOptions } from '../../../../react/context' import { MULTIPLE_CHOICE_OPTION_COLORS, MultipleChoiceOptionEditor, @@ -11,11 +16,13 @@ import { NewProposalForm } from '../../types' export const NewProposalMain = () => { const { t } = useTranslation() - const { control, watch } = useFormContext() + const { control, watch, setValue } = useFormContext() + const { proposalModule } = useProposalModuleAdapterCommonOptions() + const { isMember = false } = useMembership() const { fields: multipleChoiceFields, - append: addOption, + append: _addOption, remove: removeOption, } = useFieldArray({ control, @@ -24,6 +31,17 @@ export const NewProposalMain = () => { }) const choices = watch('choices') ?? [] + const vote = watch('vote') + + const addOption = (...params: Parameters) => { + // If instant vote is set to "None of the Above", which is always + // last, correct it. + if (vote && vote.option_id === choices.length) { + setValue('vote', { option_id: vote.option_id + 1 }) + } + + _addOption(...params) + } return (
@@ -35,7 +53,20 @@ export const NewProposalMain = () => { SuspenseLoader={SuspenseLoader} addOption={addOption} optionIndex={index} - removeOption={() => removeOption(index)} + removeOption={() => { + removeOption(index) + + if (vote) { + // If instant vote is set to this option, remove it. + if (vote.option_id === index) { + setValue('vote', undefined) + } + // If instant vote is set to a latter option, correct it. + else if (vote.option_id > index) { + setValue('vote', { option_id: vote.option_id - 1 }) + } + } + }} /> ))}
@@ -67,7 +98,7 @@ export const NewProposalMain = () => {

{t('button.addNewOption')}

-
+
@@ -81,6 +112,32 @@ export const NewProposalMain = () => {

+ + {isMember && proposalModule.supports(Feature.CastVoteOnProposalCreation) && ( + option.option_id === vote.option_id} + options={[ + ...choices.map(({ title }, index) => ({ + Icon: Circle, + label: title, + value: { option_id: index }, + color: + MULTIPLE_CHOICE_OPTION_COLORS[ + index % MULTIPLE_CHOICE_OPTION_COLORS.length + ], + })), + // "None of the Above" is automatically added. + { + Icon: Block, + label: 'None of the Above', + value: { option_id: choices.length }, + color: 'var(--icon-tertiary)', + }, + ]} + /> + )}
) } 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 69725cc9c..ebcfdd219 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 @@ -7,6 +7,7 @@ import { constSelector, useRecoilValueLoadable } from 'recoil' import { HugeDecimal } from '@dao-dao/math' import { Cw20BaseSelectors, nativeDenomBalanceSelector } from '@dao-dao/state' import { useCachedLoadable } from '@dao-dao/stateless' +import { Feature } from '@dao-dao/types' import { MAX_NUM_PROPOSAL_CHOICES, checkProposalSubmissionPolicy, @@ -193,10 +194,7 @@ export const makeUsePublishProposal = }) const publishProposal: PublishProposal = useCallback( - async ( - { title, description, choices }, - { failedSimulationBypassSeconds = 0 } = {} - ) => { + async (data, { failedSimulationBypassSeconds = 0 } = {}) => { if (!isWalletConnected || !walletAddress) { throw new Error(t('error.logInToContinue')) } @@ -207,11 +205,11 @@ export const makeUsePublishProposal = throw new Error(t('error.notEnoughForDeposit')) } - if (choices.options.length < 2) { + if (data.choices.options.length < 2) { throw new Error(t('error.tooFewChoices')) } - if (choices.options.length > MAX_NUM_PROPOSAL_CHOICES) { + if (data.choices.options.length > MAX_NUM_PROPOSAL_CHOICES) { throw new Error( t('error.tooManyChoices', { count: MAX_NUM_PROPOSAL_CHOICES }) ) @@ -220,7 +218,7 @@ export const makeUsePublishProposal = // Only simulate messages if any exist. Allow proposals without // messages. Also allow bypassing simulation check for a period of time. if ( - choices.options.filter((c) => c.msgs.length > 0) && + data.choices.options.filter((c) => c.msgs.length > 0) && (!simulationBypassExpiration || simulationBypassExpiration < new Date()) ) { @@ -235,7 +233,7 @@ export const makeUsePublishProposal = // 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)) + data.choices.options.map(({ msgs }) => simulateMsgs(msgs)) ) } catch (err) { // If failed simulation bypass duration is set, allow bypassing @@ -310,13 +308,18 @@ export const makeUsePublishProposal = // Recreate form data with just the expected fields to remove any fields // added by other proposal module forms. const proposalData: NewProposalData = { - title, - description, - choices, + title: data.title, + description: data.description, + choices: data.choices, } const response = await proposalModule.propose({ data: proposalData, + vote: + isMember && + proposalModule.supports(Feature.CastVoteOnProposalCreation) + ? data.vote + : undefined, getSigningClient, sender: walletAddress, funds: proposeFunds, @@ -337,6 +340,7 @@ export const makeUsePublishProposal = requiredProposalDeposit, depositInfoCw20TokenAddress, depositInfoNativeTokenDenom, + isMember, getSigningClient, t, simulateMsgs, diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/types.ts b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/types.ts index ba4b9639b..22464da53 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/types.ts +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalMultiple/types.ts @@ -28,12 +28,14 @@ export type NewProposalForm = { title: string description: string choices: MultipleChoiceOptionFormData[] + vote?: MultipleChoiceVote } export type NewProposalData = { title: string description: string choices: MultipleChoiceOptions + vote?: MultipleChoiceVote } export interface PercentOrMajorityValue { diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal.tsx b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal.tsx index e40c3f4ad..feb65271b 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposal.tsx @@ -240,6 +240,7 @@ export const NewProposal = ({ description, actionData, metadata, + vote, }) => ({ title, description: descriptionWithPotentialProposalMetadata( @@ -251,6 +252,7 @@ export const NewProposal = ({ encodeContext, data: actionData, }), + vote, }) return ( 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 index 35eafb678..b2de22c3c 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposalMain.tsx +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/common/components/NewProposalMain.tsx @@ -6,11 +6,16 @@ import { ActionsRenderer, ProposalExecutionMetadataEditor, ProposalExecutionMetadataRenderer, + ProposalInstantVoter, useActionsContext, } from '@dao-dao/stateless' +import { Feature } from '@dao-dao/types' import { convertActionKeysAndDataToActions } from '@dao-dao/utils' import { SuspenseLoader } from '../../../../../components' +import { useMembership } from '../../../../../hooks' +import { useProposalModuleAdapterCommonOptions } from '../../../../react/context' +import { useLoadingVoteOptions } from '../../hooks' import { NewProposalForm } from '../../types' export type NewProposalMainProps = { @@ -22,6 +27,9 @@ export const NewProposalMain = ({ }: NewProposalMainProps) => { const { t } = useTranslation() const { actionMap } = useActionsContext() + const { proposalModule } = useProposalModuleAdapterCommonOptions() + const voteOptions = useLoadingVoteOptions() + const { isMember = false } = useMembership() const { watch, @@ -29,37 +37,50 @@ export const NewProposalMain = ({ } = useFormContext() const actionKeysAndData = watch('actionData') || [] - const metadata = watch('metadata') return ( -
-

{t('title.actions')}

- +
{actionsReadOnlyMode ? ( <> - +
+

{t('title.actions')}

+ + +
- + ) : ( <> - +
+

{t('title.actions')}

- + +
+ + + + {isMember && + proposalModule.supports(Feature.CastVoteOnProposalCreation) && + // Single choice vote options are static and load immediately. + !voteOptions.loading && ( + option === vote} + options={voteOptions.data} + /> + )} )}
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 0508f0e60..f35757d0c 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 @@ -7,6 +7,7 @@ import { constSelector, useRecoilValueLoadable } from 'recoil' import { HugeDecimal } from '@dao-dao/math' import { Cw20BaseSelectors, nativeDenomBalanceSelector } from '@dao-dao/state' import { useCachedLoadable } from '@dao-dao/stateless' +import { Feature } from '@dao-dao/types' import { checkProposalSubmissionPolicy, expirationExpired, @@ -183,10 +184,7 @@ export const makeUsePublishProposal = }) const publishProposal: PublishProposal = useCallback( - async ( - { title, description, msgs }, - { failedSimulationBypassSeconds = 0 } = {} - ) => { + async (data, { failedSimulationBypassSeconds = 0 } = {}) => { if (!isWalletConnected || !walletAddress) { throw new Error(t('error.logInToContinue')) } @@ -200,13 +198,13 @@ export const makeUsePublishProposal = // Only simulate messages if any exist. Allow proposals without // messages. Also allow bypassing simulation check for a period of time. if ( - msgs.length > 0 && + data.msgs.length > 0 && (!simulationBypassExpiration || simulationBypassExpiration < new Date()) ) { try { // Throws error if simulation fails, indicating invalid message. - await simulateMsgs(msgs) + await simulateMsgs(data.msgs) } catch (err) { // If failed simulation bypass duration is set, allow bypassing // simulation check for a period of time. @@ -281,13 +279,18 @@ export const makeUsePublishProposal = // Recreate form data with just the expected fields to remove any fields // added by other proposal module forms. const proposalData: NewProposalData = { - title, - description, - msgs, + title: data.title, + description: data.description, + msgs: data.msgs, } const response = await proposalModule.propose({ data: proposalData, + vote: + isMember && + proposalModule.supports(Feature.CastVoteOnProposalCreation) + ? data.vote + : undefined, getSigningClient, sender: walletAddress, funds: proposeFunds, @@ -311,6 +314,7 @@ export const makeUsePublishProposal = requiredProposalDeposit, depositInfoCw20TokenAddress, depositInfoNativeTokenDenom, + isMember, getSigningClient, t, simulateMsgs, diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/hooks/useLoadingVoteOptions.ts b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/hooks/useLoadingVoteOptions.ts index 9c9b0cca4..9e41ee0b2 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/hooks/useLoadingVoteOptions.ts +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/hooks/useLoadingVoteOptions.ts @@ -8,7 +8,7 @@ import { } from '@dao-dao/types' import { Vote } from '@dao-dao/types/contracts/DaoProposalSingle.common' -import { useProposalModuleAdapterOptions } from '../../../react' +import { useProposalModuleAdapterCommonOptions } from '../../../react/context' export const useLoadingVoteOptions = (): LoadingData< ProposalVoteOption[] @@ -19,7 +19,7 @@ export const useLoadingVoteOptions = (): LoadingData< // approving another proposal, to make it more clear what the actions do. const { proposalModule: { prePropose }, - } = useProposalModuleAdapterOptions() + } = useProposalModuleAdapterCommonOptions() const isPreProposeApproverProposal = prePropose?.type === PreProposeModuleType.Approver diff --git a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/types.ts b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/types.ts index 36142a735..52b321073 100644 --- a/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/types.ts +++ b/packages/stateful/proposal-module-adapter/adapters/DaoProposalSingle/types.ts @@ -10,13 +10,17 @@ import { UnifiedCosmosMsg, } from '@dao-dao/types' import { Proposal } from '@dao-dao/types/contracts/CwProposalSingle.v1' -import { SingleChoiceProposal } from '@dao-dao/types/contracts/DaoProposalSingle.v2' +import { + SingleChoiceProposal, + Vote, +} from '@dao-dao/types/contracts/DaoProposalSingle.v2' export type NewProposalForm = { title: string description: string actionData: ActionKeyAndData[] metadata?: ProposalExecutionMetadata + vote?: Vote } // Converted data from actions into Cosmos messages. @@ -24,6 +28,7 @@ export type NewProposalData = { title: string description: string msgs: UnifiedCosmosMsg[] + vote?: Vote } export type DaoCreationExtraVotingConfig = { diff --git a/packages/stateful/queries/dao.ts b/packages/stateful/queries/dao.ts index 458dc6c25..f1e3c5a11 100644 --- a/packages/stateful/queries/dao.ts +++ b/packages/stateful/queries/dao.ts @@ -21,7 +21,6 @@ import { getDaoInfoForChainId, getFallbackImage, getSupportedChainConfig, - getSupportedFeatures, isConfiguredChainName, isFeatureSupportedByVersion, parseContractVersion, @@ -107,7 +106,6 @@ export const fetchDaoInfo = async ( ]) const coreVersion = parseContractVersion(state.version.version) - const supportedFeatures = getSupportedFeatures(coreVersion) const [ parentDao, @@ -208,7 +206,6 @@ export const fetchDaoInfo = async ( chainId, coreAddress, coreVersion, - supportedFeatures, votingModuleAddress: state.voting_module, votingModuleInfo, proposalModules: proposalModules.sort((a, b) => diff --git a/packages/stateless/components/actions/ActionLibrary.tsx b/packages/stateless/components/actions/ActionLibrary.tsx index 165857e4e..cc4bc648d 100644 --- a/packages/stateless/components/actions/ActionLibrary.tsx +++ b/packages/stateless/components/actions/ActionLibrary.tsx @@ -280,7 +280,7 @@ export const ActionLibrary = ({ return ( ({ parentDao: null, polytoneProxy: null, }, - supportedFeatures: {} as any, votingModuleAddress: '', votingModuleInfo: { contract: '', diff --git a/packages/stateless/components/dao/tabs/SubDaosTab.tsx b/packages/stateless/components/dao/tabs/SubDaosTab.tsx index e30a1e2a8..d542ffb60 100644 --- a/packages/stateless/components/dao/tabs/SubDaosTab.tsx +++ b/packages/stateless/components/dao/tabs/SubDaosTab.tsx @@ -37,16 +37,11 @@ export const SubDaosTab = ({ ButtonLink, }: SubDaosTabProps) => { const { t } = useTranslation() - const { - coreAddress, - coreVersion, - name, - info: { supportedFeatures }, - } = useDao() + const dao = useDao() const { getDaoPath } = useDaoNavHelpers() const subDaosSupported = - coreVersion === ContractVersion.Gov || supportedFeatures[Feature.SubDaos] + dao.coreVersion === ContractVersion.Gov || dao.supports(Feature.SubDaos) return ( <> @@ -61,7 +56,7 @@ export const SubDaosTab = ({ title={ !subDaosSupported ? t('error.daoFeatureUnsupported', { - name, + name: dao.name, feature: t('title.subDaos'), }) : !isMember @@ -72,7 +67,7 @@ export const SubDaosTab = ({ {t('button.newSubDao')} @@ -86,7 +81,7 @@ export const SubDaosTab = ({ Icon={Upgrade} actionNudge={t('info.submitUpgradeProposal')} body={t('error.daoFeatureUnsupported', { - name, + name: dao.name, feature: t('title.subDaos'), })} buttonLabel={t('button.proposeUpgrade')} diff --git a/packages/stateless/components/proposal/NewProposal.tsx b/packages/stateless/components/proposal/NewProposal.tsx index 2910bd152..bd0154bc3 100644 --- a/packages/stateless/components/proposal/NewProposal.tsx +++ b/packages/stateless/components/proposal/NewProposal.tsx @@ -193,7 +193,7 @@ export const NewProposal = < return (
@@ -201,7 +201,7 @@ export const NewProposal = < {!actionsReadOnlyMode && proposalModuleSelector} -
+
diff --git a/packages/stateless/components/proposal/ProposalExecutionMetadataEditor.tsx b/packages/stateless/components/proposal/ProposalExecutionMetadataEditor.tsx index 8fd4709fa..c82f6775f 100644 --- a/packages/stateless/components/proposal/ProposalExecutionMetadataEditor.tsx +++ b/packages/stateless/components/proposal/ProposalExecutionMetadataEditor.tsx @@ -65,15 +65,21 @@ export const ProposalExecutionMetadataEditor = ({ return (
-
- - +
+ + + setValue(`${metadataFieldName}.enabled`, !metadataEnabled) + } + title + />
{metadataEnabled && ( diff --git a/packages/stateless/components/proposal/ProposalInstantVoter.tsx b/packages/stateless/components/proposal/ProposalInstantVoter.tsx new file mode 100644 index 000000000..1d839d849 --- /dev/null +++ b/packages/stateless/components/proposal/ProposalInstantVoter.tsx @@ -0,0 +1,86 @@ +import clsx from 'clsx' +import { useFormContext } from 'react-hook-form' +import { useTranslation } from 'react-i18next' + +import { InputLabel, ProposalVoteButton, Switch } from '@dao-dao/stateless' +import { ProposalVoteOption } from '@dao-dao/types' + +export type ProposalInstantVoterProps = { + /** + * Vote options. + */ + options: ProposalVoteOption[] + /** + * How to determine if a given vote option is selected. + */ + isSelected: (option: Vote, value: Vote) => boolean + /** + * Field name for the vote form field. + */ + fieldName: string + /** + * Optional container class name. + */ + className?: string +} + +/** + * A form for editing proposal execution metadata. It expects to be within a + * form context whose data has a `metadata` field with type + * `ProposalExecutionMetadata`. + */ +export const ProposalInstantVoter = ({ + options, + isSelected, + fieldName, + className, +}: ProposalInstantVoterProps) => { + const { t } = useTranslation() + + const { watch, setValue } = useFormContext<{ + vote: any + }>() + + const voteFieldName = fieldName as 'vote' + const vote = watch('vote') + + const toggle = () => + vote + ? setValue(voteFieldName, undefined) + : setValue(voteFieldName, options[0].value) + + return ( +
+
+ + + +
+ +
+ {options.map((option, index) => ( + + vote && isSelected(option.value, vote) + ? setValue(voteFieldName, undefined) + : setValue(voteFieldName, option.value) + } + option={option} + pressed={!!vote && isSelected(option.value, vote)} + /> + ))} +
+
+ ) +} diff --git a/packages/stateless/components/proposal/index.ts b/packages/stateless/components/proposal/index.ts index 4c9b78c20..9fd12553b 100644 --- a/packages/stateless/components/proposal/index.ts +++ b/packages/stateless/components/proposal/index.ts @@ -13,6 +13,7 @@ export * from './ProposalCrossChainRelayStatus' export * from './ProposalExecutionMetadataEditor' export * from './ProposalExecutionMetadataRenderer' export * from './ProposalIdDisplay' +export * from './ProposalInstantVoter' export * from './ProposalLine' export * from './ProposalList' export * from './ProposalModuleSelector' diff --git a/packages/storybook/decorators/DaoPageWrapperDecorator.tsx b/packages/storybook/decorators/DaoPageWrapperDecorator.tsx index d9a3bae1c..d1532b781 100644 --- a/packages/storybook/decorators/DaoPageWrapperDecorator.tsx +++ b/packages/storybook/decorators/DaoPageWrapperDecorator.tsx @@ -10,13 +10,11 @@ import { PreProposeModuleType, ProposalModuleType, } from '@dao-dao/types' -import { getSupportedFeatures } from '@dao-dao/utils' export const makeDaoInfo = (): DaoInfo => ({ chainId: ChainId.JunoMainnet, coreAddress: 'junoDaoCoreAddress', coreVersion: ContractVersion.V2Alpha, - supportedFeatures: getSupportedFeatures(ContractVersion.V2Alpha), votingModuleAddress: 'votingModuleAddress', votingModuleInfo: { contract: 'crates.io:dao-voting-cw20-staked', diff --git a/packages/types/clients/dao.ts b/packages/types/clients/dao.ts index 828eec60b..93d708d34 100644 --- a/packages/types/clients/dao.ts +++ b/packages/types/clients/dao.ts @@ -8,7 +8,7 @@ import { VotingPowerAtHeightResponse, } from '../contracts/DaoDaoCore' import { DaoInfo, DaoSource } from '../dao' -import { ContractVersion } from '../features' +import { ContractVersion, Feature } from '../features' import { AmountWithTimestamp } from '../token' import { IProposalModuleBase } from './proposal-module' import { IVotingModuleBase } from './voting-module' @@ -96,6 +96,11 @@ export interface IDaoBase { */ init(): Promise + /** + * Check whether or not the DAO supports a given feature. + */ + supports(feature: Feature): boolean + /** * Get the proposal module with the given address. */ diff --git a/packages/types/clients/proposal-module.ts b/packages/types/clients/proposal-module.ts index af3e8af4b..9c8f005e0 100644 --- a/packages/types/clients/proposal-module.ts +++ b/packages/types/clients/proposal-module.ts @@ -4,7 +4,7 @@ import { FetchQueryOptions } from '@tanstack/react-query' import { CheckedDepositInfo, Coin, Duration } from '../contracts/common' import { PreProposeModule, ProposalModuleInfo } from '../dao' -import { ContractVersion } from '../features' +import { ContractVersion, Feature } from '../features' import { IDaoBase } from './dao' export interface IProposalModuleBase< @@ -56,11 +56,20 @@ export interface IProposalModuleBase< */ prePropose: PreProposeModule | null + /** + * Check whether or not the proposal module supports a given feature. + */ + supports(feature: Feature): boolean + /** * Make a proposal. */ propose(options: { data: Proposal + /** + * Cast a vote with the proposal. + */ + vote?: Vote getSigningClient: () => Promise sender: string funds?: Coin[] diff --git a/packages/types/dao.ts b/packages/types/dao.ts index 33749163f..eae2fc6a9 100644 --- a/packages/types/dao.ts +++ b/packages/types/dao.ts @@ -48,7 +48,7 @@ import { Config as NeutronCwdSubdaoTimelockSingleConfig } from './contracts/Neut import { VotingVault } from './contracts/NeutronVotingRegistry' import { InstantiateMsg as SecretDaoDaoCoreInstantiateMsg } from './contracts/SecretDaoDaoCore' import { DaoCreator } from './creators' -import { ContractVersion, SupportedFeatureMap } from './features' +import { ContractVersion } from './features' import { LoadingDataWithError } from './misc' import { ProposalVetoConfig } from './proposal' import { @@ -71,7 +71,6 @@ export type DaoInfo = { chainId: string coreAddress: string coreVersion: ContractVersion - supportedFeatures: SupportedFeatureMap votingModuleAddress: string votingModuleInfo: ContractVersionInfo proposalModules: ProposalModuleInfo[] diff --git a/packages/types/features.ts b/packages/types/features.ts index 9ac77f6ee..9ea29443c 100644 --- a/packages/types/features.ts +++ b/packages/types/features.ts @@ -31,6 +31,8 @@ export enum ContractVersion { V230 = '2.3.0', // https://github.com/DA0-DA0/dao-contracts/releases/tag/v2.4.0 V240 = '2.4.0', + // https://github.com/DA0-DA0/dao-contracts/releases/tag/v2.4.1 + V241 = '2.4.1', // https://github.com/DA0-DA0/dao-contracts/releases/tag/v2.4.2 V242 = '2.4.2', // https://github.com/DA0-DA0/dao-contracts/releases/tag/v2.5.0 @@ -96,6 +98,10 @@ export enum Feature { * Veto was added. */ Veto, + /** + * Cast vote on proposal creation was added. + */ + CastVoteOnProposalCreation, /** * The ability to specify a more granular pre-propose submission policy. */ @@ -106,8 +112,3 @@ export enum Feature { */ UnlimitedNftClaims, } - -/** - * Map each feature to whether or not it is supported. - */ -export type SupportedFeatureMap = Record diff --git a/packages/utils/chain.ts b/packages/utils/chain.ts index cbf3e1110..844ba0349 100644 --- a/packages/utils/chain.ts +++ b/packages/utils/chain.ts @@ -15,11 +15,9 @@ import { ConfiguredChain, ContractVersion, DaoInfo, - Feature, GenericToken, SupportedChain, SupportedChainConfig, - SupportedFeatureMap, TokenType, Validator, } from '@dao-dao/types' @@ -703,13 +701,6 @@ export const getDaoInfoForChainId = ( chainId, coreAddress: mustGetConfiguredChainConfig(chainId).name, coreVersion: ContractVersion.Gov, - supportedFeatures: Object.values(Feature).reduce( - (acc, feature) => ({ - ...acc, - [feature]: false, - }), - {} as SupportedFeatureMap - ), votingModuleAddress: '', votingModuleInfo: { contract: '', diff --git a/packages/utils/features.ts b/packages/utils/features.ts index 6813cd08b..8d66d5651 100644 --- a/packages/utils/features.ts +++ b/packages/utils/features.ts @@ -1,6 +1,6 @@ import semverGte from 'semver/functions/gte' -import { ContractVersion, Feature, SupportedFeatureMap } from '@dao-dao/types' +import { ContractVersion, Feature } from '@dao-dao/types' /** * Checks if a specific feature is supported by a given contract version. @@ -30,6 +30,8 @@ export const isFeatureSupportedByVersion = ( case Feature.Approval: case Feature.Veto: return versionGte(version, ContractVersion.V240) + case Feature.CastVoteOnProposalCreation: + return versionGte(version, ContractVersion.V241) case Feature.GranularSubmissionPolicy: return versionGte(version, ContractVersion.V250) case Feature.UnlimitedNftClaims: @@ -39,25 +41,6 @@ export const isFeatureSupportedByVersion = ( } } -/** - * Pre-computes supported features based on the given contract version. - * - * @param {ContractVersion} version - The contract version to check. - * @return {SupportedFeatureMap} - A map of supported features where the key is - * the feature name and the value is a boolean indicating if the feature is - * supported. - */ -export const getSupportedFeatures = ( - version: ContractVersion -): SupportedFeatureMap => - Object.values(Feature).reduce( - (acc, feature) => ({ - ...acc, - [feature]: isFeatureSupportedByVersion(feature as Feature, version), - }), - {} as SupportedFeatureMap - ) - /** * Checks if a given version is greater than or equal to a specified version. *