diff --git a/CHANGELOG.md b/CHANGELOG.md index d01de6c66..4f18c0f2a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,7 +12,7 @@ changes. ### Added -- +- Handle displaying votes based on bootstrap phase, full governance and security groups [Issue 2316](https://github.com/IntersectMBO/govtool/issues/2316) ### Fixed diff --git a/govtool/frontend/src/components/molecules/GovernanceActionDetailsCardVotes.tsx b/govtool/frontend/src/components/molecules/GovernanceActionDetailsCardVotes.tsx index 985b0c3da..cdb211e7d 100644 --- a/govtool/frontend/src/components/molecules/GovernanceActionDetailsCardVotes.tsx +++ b/govtool/frontend/src/components/molecules/GovernanceActionDetailsCardVotes.tsx @@ -1,10 +1,11 @@ -import { Dispatch, SetStateAction } from "react"; +import { Dispatch, SetStateAction, useCallback } from "react"; import { Box } from "@mui/material"; import { useScreenDimension } from "@hooks"; import { VoteActionForm, VotesSubmitted } from "@molecules"; import { useFeatureFlag } from "@/context"; import { ProposalData, ProposalVote } from "@/models"; +import { SECURITY_RELEVANT_PARAMS_MAP } from "@/consts"; type GovernanceActionCardVotesProps = { setIsVoteSubmitted: Dispatch>; @@ -25,9 +26,18 @@ export const GovernanceActionDetailsCardVotes = ({ isInProgress, proposal, }: GovernanceActionCardVotesProps) => { - const { isVotingOnGovernanceActionEnabled } = useFeatureFlag(); + const { areDRepVoteTotalsDisplayed } = useFeatureFlag(); const { screenWidth } = useScreenDimension(); - + const isSecurityGroup = useCallback( + () => + Object.values(SECURITY_RELEVANT_PARAMS_MAP).some( + (paramKey) => + proposal.protocolParams?.[ + paramKey as keyof typeof proposal.protocolParams + ] !== null, + ), + [proposal.protocolParams], + ); const isModifiedPadding = (isDashboard && screenWidth < 1368) ?? screenWidth < 1100; @@ -39,7 +49,8 @@ export const GovernanceActionDetailsCardVotes = ({ p: `40px ${isModifiedPadding ? "24px" : "80px"}`, }} > - {isVoter && isVotingOnGovernanceActionEnabled(proposal.type) ? ( + {isVoter && + areDRepVoteTotalsDisplayed(proposal.type, isSecurityGroup()) ? ( { + const isSecurityGroup = useCallback( + () => + Object.values(SECURITY_RELEVANT_PARAMS_MAP).some( + (paramKey) => + protocolParams?.[paramKey as keyof typeof protocolParams] !== null, + ), + [protocolParams], + ); + + const { + areDRepVoteTotalsDisplayed, + areSPOVoteTotalsDisplayed, + areCCVoteTotalsDisplayed, + } = useFeatureFlag(); const { t } = useTranslation(); return ( @@ -65,24 +83,30 @@ export const VotesSubmitted = ({ gap: 4.5, }} > - - - + {areDRepVoteTotalsDisplayed(type, isSecurityGroup()) && ( + + )} + {areSPOVoteTotalsDisplayed(type, isSecurityGroup()) && ( + + )} + {areCCVoteTotalsDisplayed(type) && ( + + )} ); @@ -111,6 +135,7 @@ const VotesGroup = ({ flexDirection: "column", gap: "12px", }} + data-testid={`submitted-votes-${type}`} > = { + maxBlockBodySize: "max_block_size", + maxTxSize: "max_tx_size", + maxBlockHeaderSize: "max_bh_size", + maxValueSize: "max_val_size", + maxBlockExecutionUnits: "max_block_ex_mem", + txFeePerByte: "min_fee_a", + txFeeFixed: "min_fee_b", + utxoCostPerByte: "coins_per_utxo_size", + govActionDeposit: "gov_action_deposit", + minFeeRefScriptCostPerByte: "min_fee_ref_script_cost_per_byte", +}; diff --git a/govtool/frontend/src/context/appContext.tsx b/govtool/frontend/src/context/appContext.tsx index 5463ed609..745750b34 100644 --- a/govtool/frontend/src/context/appContext.tsx +++ b/govtool/frontend/src/context/appContext.tsx @@ -23,6 +23,7 @@ type AppContextType = { isAppInitializing: boolean; isMainnet: boolean; isInBootstrapPhase: boolean; + isFullGovernance: boolean; networkName: string; network: string; cExplorerBaseUrl: string; @@ -74,6 +75,7 @@ const AppContextProvider = ({ children }: PropsWithChildren) => { isMainnet: networkMetrics?.networkName === "mainnet", isInBootstrapPhase: epochParams?.protocol_major === BOOTSTRAPPING_PHASE_MAJOR, + isFullGovernance: Number(epochParams?.protocol_major) >= 10, networkName: NETWORK_NAMES[ (networkMetrics?.networkName as keyof typeof NETWORK_NAMES) || diff --git a/govtool/frontend/src/context/featureFlag.test.tsx b/govtool/frontend/src/context/featureFlag.test.tsx new file mode 100644 index 000000000..50af864be --- /dev/null +++ b/govtool/frontend/src/context/featureFlag.test.tsx @@ -0,0 +1,250 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { renderHook } from "@testing-library/react"; +import { FeatureFlagProvider, useFeatureFlag } from "./featureFlag"; +import { GovernanceActionType } from "@/types/governanceAction"; +import { useAppContext } from "./appContext"; + +vi.mock("./appContext"); + +const mockUseAppContext = useAppContext as jest.MockedFunction< + typeof useAppContext +>; + +const mockUseAppContextReturnValue = { + cExplorerBaseUrl: "http://mock.cexplorer", + isAppInitializing: false, + isInBootstrapPhase: false, + isFullGovernance: true, + network: "preview", + networkName: "preview", + isMainnet: false, +}; + +describe("FeatureFlagProvider", () => { + beforeEach(() => { + vi.resetAllMocks(); + mockUseAppContext.mockReturnValue(mockUseAppContextReturnValue); + }); + + it("should enable proposal discussion forum based on environment variable", () => { + import.meta.env.VITE_IS_PROPOSAL_DISCUSSION_FORUM_ENABLED = "true"; + + const { result } = renderHook(() => useFeatureFlag(), { + wrapper: FeatureFlagProvider, + }); + + expect(result.current.isProposalDiscussionForumEnabled).toBe(true); + }); + + it("should disable proposal discussion forum if environment variable is false", () => { + import.meta.env.VITE_IS_PROPOSAL_DISCUSSION_FORUM_ENABLED = "false"; + + const { result } = renderHook(() => useFeatureFlag(), { + wrapper: FeatureFlagProvider, + }); + + expect(result.current.isProposalDiscussionForumEnabled).toBe(false); + }); + + describe("isVotingOnGovernanceActionEnabled", () => { + it("should return true for InfoAction regardless of bootstrap phase", () => { + mockUseAppContext.mockReturnValue({ + ...mockUseAppContextReturnValue, + isAppInitializing: false, + isInBootstrapPhase: true, + isFullGovernance: false, + }); + + const { result } = renderHook(() => useFeatureFlag(), { + wrapper: FeatureFlagProvider, + }); + + expect( + result.current.isVotingOnGovernanceActionEnabled( + GovernanceActionType.InfoAction, + ), + ).toBe(true); + }); + + it("should return false for other actions in bootstrap phase", () => { + mockUseAppContext.mockReturnValue({ + ...mockUseAppContextReturnValue, + isAppInitializing: false, + isInBootstrapPhase: true, + isFullGovernance: false, + }); + + const { result } = renderHook(() => useFeatureFlag(), { + wrapper: FeatureFlagProvider, + }); + + expect( + result.current.isVotingOnGovernanceActionEnabled( + GovernanceActionType.ParameterChange, + ), + ).toBe(false); + }); + }); + + describe("areDRepVoteTotalsDisplayed", () => { + it("should hide DRep vote totals for HardForkInitiation in bootstrap phase", () => { + mockUseAppContext.mockReturnValue({ + ...mockUseAppContextReturnValue, + isAppInitializing: false, + isInBootstrapPhase: true, + isFullGovernance: false, + }); + + const { result } = renderHook(() => useFeatureFlag(), { + wrapper: FeatureFlagProvider, + }); + + expect( + result.current.areDRepVoteTotalsDisplayed( + GovernanceActionType.HardForkInitiation, + ), + ).toBe(false); + }); + + it("should display DRep vote totals for ParameterChange when isSecurityGroup is true in bootstrap phase", () => { + mockUseAppContext.mockReturnValue({ + ...mockUseAppContextReturnValue, + isAppInitializing: false, + isInBootstrapPhase: true, + isFullGovernance: false, + }); + + const { result } = renderHook(() => useFeatureFlag(), { + wrapper: FeatureFlagProvider, + }); + + expect( + result.current.areDRepVoteTotalsDisplayed( + GovernanceActionType.ParameterChange, + true, // isSecurityGroup + ), + ).toBe(true); + }); + + it("should hide DRep vote totals for MotionNoConfidence in full governance", () => { + mockUseAppContext.mockReturnValue({ + ...mockUseAppContextReturnValue, + isAppInitializing: false, + isInBootstrapPhase: false, + isFullGovernance: true, + }); + + const { result } = renderHook(() => useFeatureFlag(), { + wrapper: FeatureFlagProvider, + }); + + expect( + result.current.areDRepVoteTotalsDisplayed( + GovernanceActionType.NoConfidence, + ), + ).toBe(false); + }); + }); + + describe("areSPOVoteTotalsDisplayed", () => { + it("should hide SPO vote totals for ParameterChange in bootstrap phase", () => { + mockUseAppContext.mockReturnValue({ + ...mockUseAppContextReturnValue, + isAppInitializing: false, + isInBootstrapPhase: true, + isFullGovernance: false, + }); + + const { result } = renderHook(() => useFeatureFlag(), { + wrapper: FeatureFlagProvider, + }); + + expect( + result.current.areSPOVoteTotalsDisplayed( + GovernanceActionType.ParameterChange, + false, + ), + ).toBe(false); + }); + + it("should display SPO vote totals for ParameterChange when isSecurityGroup is true in bootstrap phase", () => { + mockUseAppContext.mockReturnValue({ + ...mockUseAppContextReturnValue, + isAppInitializing: false, + isInBootstrapPhase: true, + isFullGovernance: false, + }); + + const { result } = renderHook(() => useFeatureFlag(), { + wrapper: FeatureFlagProvider, + }); + + expect( + result.current.areSPOVoteTotalsDisplayed( + GovernanceActionType.ParameterChange, + true, + ), + ).toBe(false); + }); + + it("should hide SPO vote totals for TreasuryWithdrawals in full governance", () => { + mockUseAppContext.mockReturnValue({ + ...mockUseAppContextReturnValue, + isAppInitializing: false, + isInBootstrapPhase: false, + isFullGovernance: true, + }); + + const { result } = renderHook(() => useFeatureFlag(), { + wrapper: FeatureFlagProvider, + }); + + expect( + result.current.areSPOVoteTotalsDisplayed( + GovernanceActionType.TreasuryWithdrawals, + true, + ), + ).toBe(false); + }); + }); + + describe("areCCVoteTotalsDisplayed", () => { + it("should hide CC vote totals for MotionNoConfidence in full governance", () => { + mockUseAppContext.mockReturnValue({ + ...mockUseAppContextReturnValue, + isAppInitializing: false, + isInBootstrapPhase: false, + isFullGovernance: true, + }); + + const { result } = renderHook(() => useFeatureFlag(), { + wrapper: FeatureFlagProvider, + }); + + expect( + result.current.areCCVoteTotalsDisplayed( + GovernanceActionType.NoConfidence, + ), + ).toBe(false); + }); + + it("should show CC vote totals for other actions in bootstrap phase", () => { + mockUseAppContext.mockReturnValue({ + ...mockUseAppContextReturnValue, + isAppInitializing: false, + isInBootstrapPhase: true, + isFullGovernance: false, + }); + + const { result } = renderHook(() => useFeatureFlag(), { + wrapper: FeatureFlagProvider, + }); + + expect( + result.current.areCCVoteTotalsDisplayed( + GovernanceActionType.HardForkInitiation, + ), + ).toBe(true); + }); + }); +}); diff --git a/govtool/frontend/src/context/featureFlag.tsx b/govtool/frontend/src/context/featureFlag.tsx index f655ca9c9..d008f67f7 100644 --- a/govtool/frontend/src/context/featureFlag.tsx +++ b/govtool/frontend/src/context/featureFlag.tsx @@ -18,11 +18,25 @@ type FeatureFlagContextType = { isVotingOnGovernanceActionEnabled: ( governanceActionType: GovernanceActionType, ) => boolean; + areDRepVoteTotalsDisplayed: ( + governanceActionType: GovernanceActionType, + isSecurityGroup?: boolean, + ) => boolean; + areSPOVoteTotalsDisplayed: ( + governanceActionType: GovernanceActionType, + isSecurityGroup: boolean, + ) => boolean; + areCCVoteTotalsDisplayed: ( + governanceActionType: GovernanceActionType, + ) => boolean; }; const FeatureFlagContext = createContext({ isProposalDiscussionForumEnabled: false, isVotingOnGovernanceActionEnabled: () => false, + areDRepVoteTotalsDisplayed: () => false, + areSPOVoteTotalsDisplayed: () => false, + areCCVoteTotalsDisplayed: () => false, }); /** @@ -31,7 +45,8 @@ const FeatureFlagContext = createContext({ * @param children - The child components to render. */ const FeatureFlagProvider = ({ children }: PropsWithChildren) => { - const { isAppInitializing, isInBootstrapPhase } = useAppContext(); + const { isAppInitializing, isInBootstrapPhase, isFullGovernance } = + useAppContext(); /** * Determines if voting on a governance action is enabled based on the protocol version. @@ -45,12 +60,84 @@ const FeatureFlagProvider = ({ children }: PropsWithChildren) => { [isAppInitializing, isInBootstrapPhase], ); + /** + * Determines if DRep vote totals should be displayed based on governance action type and phase. + * @param governanceActionType - The type of governance action. + * @returns {boolean} Whether DRep vote totals are displayed. + */ + const areDRepVoteTotalsDisplayed = useCallback( + ( + governanceActionType: GovernanceActionType, + isSecurityGroup: boolean = false, + ) => { + if (isInBootstrapPhase) { + return !( + governanceActionType === GovernanceActionType.HardForkInitiation || + (governanceActionType === GovernanceActionType.ParameterChange && + !isSecurityGroup) + ); + } + if (isFullGovernance) { + return ![ + GovernanceActionType.NoConfidence, + GovernanceActionType.NewCommittee, + GovernanceActionType.NewConstitution, + ].includes(governanceActionType); + } + return true; + }, + [isAppInitializing, isInBootstrapPhase, isFullGovernance], + ); + + /** + * Determines if SPO vote totals should be displayed based on governance action type and phase. + * @param governanceActionType - The type of governance action. + * @returns {boolean} Whether SPO vote totals are displayed. + */ + const areSPOVoteTotalsDisplayed = useCallback( + (governanceActionType: GovernanceActionType, isSecurityGroup: boolean) => { + if (isInBootstrapPhase) { + return governanceActionType !== GovernanceActionType.ParameterChange; + } + if (isFullGovernance) { + return !( + governanceActionType === GovernanceActionType.NewConstitution || + governanceActionType === GovernanceActionType.TreasuryWithdrawals || + (governanceActionType === GovernanceActionType.ParameterChange && + !isSecurityGroup) + ); + } + return true; + }, + [isAppInitializing, isInBootstrapPhase, isFullGovernance], + ); + + /** + * Determines if CC vote totals should be displayed based on governance action type and phase. + * @param governanceActionType - The type of governance action. + * @returns {boolean} Whether CC vote totals are displayed. + */ + const areCCVoteTotalsDisplayed = useCallback( + (governanceActionType: GovernanceActionType) => { + if (isFullGovernance) { + return ![ + GovernanceActionType.NoConfidence, + GovernanceActionType.NewCommittee, + ].includes(governanceActionType); + } + return true; + }, + [isAppInitializing, isFullGovernance], + ); const value = useMemo( () => ({ isProposalDiscussionForumEnabled: import.meta.env.VITE_IS_PROPOSAL_DISCUSSION_FORUM_ENABLED === "true" || false, isVotingOnGovernanceActionEnabled, + areDRepVoteTotalsDisplayed, + areSPOVoteTotalsDisplayed, + areCCVoteTotalsDisplayed, }), [isVotingOnGovernanceActionEnabled], ); diff --git a/govtool/frontend/src/models/api.ts b/govtool/frontend/src/models/api.ts index 702aa24d7..095f8c945 100644 --- a/govtool/frontend/src/models/api.ts +++ b/govtool/frontend/src/models/api.ts @@ -157,6 +157,8 @@ export type SubmittedVotesData = { poolYesVotes: number; poolNoVotes: number; poolAbstainVotes: number; + type: GovernanceActionType; + protocolParams: EpochParams | null; }; export type ProposalDataDTO = {