diff --git a/.changeset/fix-spp-proposal-eligibility-unrestricted.md b/.changeset/fix-spp-proposal-eligibility-unrestricted.md new file mode 100644 index 0000000000..3b2ef69590 --- /dev/null +++ b/.changeset/fix-spp-proposal-eligibility-unrestricted.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": patch +--- + +Fix proposal creation eligibility showing "Unrestricted" for eligible wallets on restricted SPP processes. diff --git a/.gitignore b/.gitignore index 433cb514b1..48c8648674 100644 --- a/.gitignore +++ b/.gitignore @@ -66,3 +66,4 @@ next-env.d.ts !.agents/shared/** .playwright-mcp .patches +.omp diff --git a/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts b/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts new file mode 100644 index 0000000000..9c70843623 --- /dev/null +++ b/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts @@ -0,0 +1,200 @@ +import { renderHook } from '@testing-library/react'; +import * as useSimulateProposalModule from '@/modules/governance/hooks/useSimulateProposal'; +import type { IPermissionCheckGuardResult } from '@/modules/governance/types'; +import * as daoService from '@/shared/api/daoService'; +import * as useDaoPluginsModule from '@/shared/hooks/useDaoPlugins'; +import { + generateDao, + generateDaoPlugin, + generateFilterComponentPlugin, + generateReactQueryResultSuccess, +} from '@/shared/testUtils'; +import { pluginRegistryUtils } from '@/shared/utils/pluginRegistryUtils'; +import { + generateSppPluginSettings, + generateSppStage, + generateSppStagePlugin, +} from '../../testUtils'; +import { useSppPermissionCheckProposalCreation } from './useSppPermissionCheckProposalCreation'; + +describe('useSppPermissionCheckProposalCreation', () => { + const useDaoPluginsSpy = jest.spyOn(useDaoPluginsModule, 'useDaoPlugins'); + const useDaoSpy = jest.spyOn(daoService, 'useDao'); + const useSimulateProposalCreationSpy = jest.spyOn( + useSimulateProposalModule, + 'useSimulateProposalCreation', + ); + const getSlotFunctionSpy = jest.spyOn( + pluginRegistryUtils, + 'getSlotFunction', + ); + + afterEach(() => { + useDaoPluginsSpy.mockReset(); + useDaoSpy.mockReset(); + useSimulateProposalCreationSpy.mockReset(); + getSlotFunctionSpy.mockReset(); + }); + + const createTestParams = (guardResults: IPermissionCheckGuardResult[]) => { + const subPluginMetas = guardResults.map((_result, index) => + generateDaoPlugin({ + address: `0x${String(index + 1).padStart(40, '0')}`, + }), + ); + + const sppPlugin = generateDaoPlugin({ + address: `0x${'a'.repeat(40)}`, + settings: generateSppPluginSettings({ + stages: [ + generateSppStage({ + plugins: subPluginMetas.map((meta) => + generateSppStagePlugin({ address: meta.address }), + ), + }), + ], + }), + }); + + useDaoPluginsSpy.mockReturnValue( + subPluginMetas.map((meta) => + generateFilterComponentPlugin({ meta }), + ), + ); + useDaoSpy.mockReturnValue( + generateReactQueryResultSuccess({ data: generateDao() }), + ); + + const slotFunctions = new Map( + subPluginMetas.map((meta, index) => [ + meta.address, + () => guardResults[index], + ]), + ); + getSlotFunctionSpy.mockImplementation( + () => + ((params: { plugin: { address: string } }) => + slotFunctions.get(params.plugin.address)?.()) as never, + ); + + return { daoId: 'dao-test', plugin: sppPlugin }; + }; + + const generateGuardResult = ( + result?: Partial, + ): IPermissionCheckGuardResult => ({ + hasPermission: false, + settings: [], + isLoading: false, + isRestricted: false, + ...result, + }); + + const mockSimulation = (result: { + isLoading: boolean; + isSuccess: boolean; + }) => + useSimulateProposalCreationSpy.mockReturnValue( + result as ReturnType< + typeof useSimulateProposalModule.useSimulateProposalCreation + >, + ); + + it('returns isRestricted true for a restricted process even when the connected wallet can create a proposal', () => { + const settings = [[{ term: 'Members', definition: 'Listed only' }]]; + const guardResult = generateGuardResult({ + isRestricted: true, + settings, + hasPermission: true, + }); + const params = createTestParams([guardResult]); + mockSimulation({ isLoading: false, isSuccess: true }); + + const { result } = renderHook(() => + useSppPermissionCheckProposalCreation( + params as Parameters< + typeof useSppPermissionCheckProposalCreation + >[0], + ), + ); + + expect(result.current.isRestricted).toBeTruthy(); + expect(result.current.settings).toEqual(settings); + }); + + it('returns isRestricted false when no sub-plugin restricts proposal creation', () => { + const params = createTestParams([ + generateGuardResult({ isRestricted: false }), + generateGuardResult({ isRestricted: false }), + ]); + mockSimulation({ isLoading: false, isSuccess: true }); + + const { result } = renderHook(() => + useSppPermissionCheckProposalCreation( + params as Parameters< + typeof useSppPermissionCheckProposalCreation + >[0], + ), + ); + + expect(result.current.isRestricted).toBeFalsy(); + }); + + it('returns isRestricted true when any sub-plugin restricts proposal creation', () => { + const restrictedSettings = [ + [{ term: 'Voting power', definition: '≥10' }], + ]; + const params = createTestParams([ + generateGuardResult({ isRestricted: false }), + generateGuardResult({ + isRestricted: true, + settings: restrictedSettings, + }), + ]); + mockSimulation({ isLoading: false, isSuccess: true }); + + const { result } = renderHook(() => + useSppPermissionCheckProposalCreation( + params as Parameters< + typeof useSppPermissionCheckProposalCreation + >[0], + ), + ); + + expect(result.current.isRestricted).toBeTruthy(); + expect(result.current.settings).toEqual(restrictedSettings); + }); + + it('derives hasPermission from the proposal creation simulation', () => { + const params = createTestParams([ + generateGuardResult({ isRestricted: true }), + ]); + mockSimulation({ isLoading: false, isSuccess: false }); + + const { result } = renderHook(() => + useSppPermissionCheckProposalCreation( + params as Parameters< + typeof useSppPermissionCheckProposalCreation + >[0], + ), + ); + + expect(result.current.hasPermission).toBeFalsy(); + expect(result.current.isRestricted).toBeTruthy(); + }); + + it('returns isLoading true while the simulation is loading', () => { + const params = createTestParams([generateGuardResult()]); + mockSimulation({ isLoading: true, isSuccess: false }); + + const { result } = renderHook(() => + useSppPermissionCheckProposalCreation( + params as Parameters< + typeof useSppPermissionCheckProposalCreation + >[0], + ), + ); + + expect(result.current.isLoading).toBeTruthy(); + }); +}); diff --git a/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.ts b/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.ts index d862d716c2..4c9aa6aedf 100644 --- a/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.ts +++ b/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.ts @@ -71,10 +71,16 @@ export const useSppPermissionCheckProposalCreation = ( result?.isRestricted ? result.settings : [], ); + // The simulation only checks the connected wallet's permission; whether the process itself is restricted comes + // from the sub-plugin creation rules. + const isRestricted = pluginProposalCreationGuardResults.some( + (result) => result?.isRestricted === true, + ); + return { hasPermission: permissionGranted, settings, isLoading, - isRestricted: !permissionGranted, + isRestricted, }; };