diff --git a/.agents/shared/metrics/hits.jsonl b/.agents/shared/metrics/hits.jsonl index 7ccb201a3b..79d80107bc 100644 --- a/.agents/shared/metrics/hits.jsonl +++ b/.agents/shared/metrics/hits.jsonl @@ -157,3 +157,6 @@ {"ts":"2026-08-04T10:56:30.749Z","tool":"Edit","file":"apps/app/src/shared/api/daoService/domain/pluginSettings.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":3,"adapter":"claude"} {"ts":"2026-08-04T07:16:05.722Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedActionDecoded.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} {"ts":"2026-08-04T07:16:07.165Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":2,"adapter":"claude"} +{"ts":"2026-08-04T12:34:00.962Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":3,"adapter":"claude"} +{"ts":"2026-08-04T12:34:05.191Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":1,"adapter":"claude"} +{"ts":"2026-08-04T12:34:06.974Z","tool":"Edit","file":"apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts","rule":"query-and-cache","bytes":2385,"elapsed_ms":7,"adapter":"claude"} diff --git a/apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts b/apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts index b01ccf31c0..20eac2d320 100644 --- a/apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts +++ b/apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts @@ -26,4 +26,9 @@ export interface IAllowedAction { * Whether the action is allowed or not. Should always be `true` for allowed actions. */ isAllowed: true; + /** + * Chain ID for which given allowed action is relevant. In general, value + * should be back-filled, but if not set, consider it as a chain id of the DAO. + */ + chainId?: number; } diff --git a/apps/app/src/modules/governance/api/executeSelectorsService/queries/index.ts b/apps/app/src/modules/governance/api/executeSelectorsService/queries/index.ts index 9c880945b1..3ce5862a79 100644 --- a/apps/app/src/modules/governance/api/executeSelectorsService/queries/index.ts +++ b/apps/app/src/modules/governance/api/executeSelectorsService/queries/index.ts @@ -1 +1,2 @@ +export * from './useAllAllowedActions'; export * from './useAllowedActions'; diff --git a/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/index.ts b/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/index.ts new file mode 100644 index 0000000000..74ca9a3ff3 --- /dev/null +++ b/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/index.ts @@ -0,0 +1,2 @@ +export type { IUseAllAllowedActionsParams } from './useAllAllowedActions'; +export { useAllAllowedActions } from './useAllAllowedActions'; diff --git a/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.test.ts b/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.test.ts new file mode 100644 index 0000000000..14faec0553 --- /dev/null +++ b/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.test.ts @@ -0,0 +1,85 @@ +import { renderHook, waitFor } from '@testing-library/react'; +import { generateAllowedAction } from '@/modules/governance/testUtils'; +import { Network } from '@/shared/api/daoService'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; +import { + generatePaginatedResponse, + ReactQueryWrapper, +} from '@/shared/testUtils'; +import { executeSelectorsService } from '../../executeSelectorsService'; +import { useAllAllowedActions } from './useAllAllowedActions'; + +describe('useAllAllowedActions query', () => { + const getAllowedActionsSpy = jest.spyOn( + executeSelectorsService, + 'getAllowedActions', + ); + + // The DAO is on Ethereum for every test, the requested chain is what varies. + const daoNetwork = Network.ETHEREUM_MAINNET; + const daoChainId = networkDefinitions[daoNetwork].id; + const otherChainId = networkDefinitions[Network.BASE_MAINNET].id; + + const daoChainAction = generateAllowedAction({ + target: '0xdaochain', + chainId: daoChainId, + }); + const otherChainAction = generateAllowedAction({ + target: '0xotherchain', + chainId: otherChainId, + }); + const noChainAction = generateAllowedAction({ target: '0xnochain' }); + + beforeEach(() => { + getAllowedActionsSpy.mockResolvedValue( + generatePaginatedResponse({ + data: [daoChainAction, otherChainAction, noChainAction], + }), + ); + }); + + afterEach(() => { + getAllowedActionsSpy.mockReset(); + }); + + const renderAllowedActionsHook = (chainId?: number) => + renderHook( + () => + useAllAllowedActions({ + urlParams: { network: daoNetwork, pluginAddress: '0x123' }, + chainId, + }), + { wrapper: ReactQueryWrapper }, + ); + + it('returns the actions of the specified chain and the actions without a chain ID when the specified chain is the DAO chain', async () => { + const { result } = renderAllowedActionsHook(daoChainId); + + await waitFor(() => + expect(result.current.data).toEqual([ + daoChainAction, + noChainAction, + ]), + ); + }); + + it('filters out the actions without a chain ID when the specified chain is not the DAO chain', async () => { + const { result } = renderAllowedActionsHook(otherChainId); + + await waitFor(() => + expect(result.current.data).toEqual([otherChainAction]), + ); + }); + + it('returns the actions of all chains when no chain ID is specified', async () => { + const { result } = renderAllowedActionsHook(); + + await waitFor(() => + expect(result.current.data).toEqual([ + daoChainAction, + otherChainAction, + noChainAction, + ]), + ); + }); +}); diff --git a/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts b/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts new file mode 100644 index 0000000000..644e377b8e --- /dev/null +++ b/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllAllowedActions/useAllAllowedActions.ts @@ -0,0 +1,100 @@ +'use client'; + +import { useEffect, useMemo } from 'react'; +import type { IPaginatedResponse } from '@/shared/api/aragonBackendService'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; +import type { InfiniteQueryOptions } from '@/shared/types'; +import type { IAllowedAction } from '../../domain'; +import type { + IGetAllowedActionsParams, + IGetAllowedActionsQueryParams, +} from '../../executeSelectorsService.api'; +import { useAllowedActions } from '../useAllowedActions'; + +/** + * Parameters of the useAllAllowedActions hook. + */ +export type IUseAllAllowedActionsParams = Omit< + IGetAllowedActionsParams, + 'queryParams' +> & { + /** + * Query parameters of the request, the page size is set by the hook. + */ + queryParams?: IGetAllowedActionsQueryParams; + /** + * Chain ID to return the allowed actions for, the actions of all chains are returned when not + * set. Actions without a chain ID are only relevant for the chain of the DAO, therefore they are + * only returned when this matches the DAO chain. + */ + chainId?: number; +}; + +/** + * Hook that fetches all allowed actions of a plugin by automatically loading all pages. Only returns + * the actions relevant for the specified chain when a chain ID is set. + */ +export const useAllAllowedActions = ( + params: IUseAllAllowedActionsParams, + options?: InfiniteQueryOptions< + IPaginatedResponse, + IGetAllowedActionsParams + >, +) => { + // Keep the chain ID out of the request params to avoid fetching the same actions once per chain. + const { chainId, ...requestParams } = params; + + const { + data, + isLoading, + error, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + refetch, + } = useAllowedActions( + { + ...requestParams, + queryParams: { ...requestParams.queryParams, pageSize: 50 }, + }, + options, + ); + + // While auto-paginating, `isLoading` only tracks the first page and `data` + // defaults to an empty array, so neither can express "the full action set + // is not ready yet". + const isFetchingAll = isLoading || hasNextPage || isFetchingNextPage; + + useEffect(() => { + if (hasNextPage && !isFetchingNextPage) { + void fetchNextPage(); + } + }, [hasNextPage, fetchNextPage, isFetchingNextPage]); + + const daoChainId = networkDefinitions[params.urlParams.network].id; + + const allAllowedActions = useMemo(() => { + if (isFetchingAll || error) { + return undefined; + } + + const actions = data?.pages.flatMap((page) => page.data) ?? []; + + if (chainId == null) { + return actions; + } + + // The chain ID of an action is not guaranteed to be back-filled, an action without it is + // considered to be on the DAO chain. + return actions.filter( + (action) => (action.chainId ?? daoChainId) === chainId, + ); + }, [data, isFetchingAll, error, chainId, daoChainId]); + + return { + data: allAllowedActions, + isLoading: isFetchingAll, + error, + refetch, + }; +}; diff --git a/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllowedActions/useAllowedActions.ts b/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllowedActions/useAllowedActions.ts index 902a0d8c1e..5ce517e043 100644 --- a/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllowedActions/useAllowedActions.ts +++ b/apps/app/src/modules/governance/api/executeSelectorsService/queries/useAllowedActions/useAllowedActions.ts @@ -1,9 +1,9 @@ import { useInfiniteQuery } from '@tanstack/react-query'; +import type { IPaginatedResponse } from '@/shared/api/aragonBackendService'; import type { InfiniteQueryOptions, SharedInfiniteQueryOptions, } from '@/shared/types'; -import type { IPaginatedResponse } from '../../../../../../shared/api/aragonBackendService'; import type { IAllowedAction } from '../../domain'; import { executeSelectorsService } from '../../executeSelectorsService'; import type { IGetAllowedActionsParams } from '../../executeSelectorsService.api'; @@ -21,7 +21,8 @@ export const allowedActionsOptions = ( > => ({ queryKey: executeSelectorsServiceKeys.allowedActions(params), initialPageParam: params, - queryFn: () => executeSelectorsService.getAllowedActions(params), + queryFn: ({ pageParam }) => + executeSelectorsService.getAllowedActions(pageParam), getNextPageParam: executeSelectorsService.getNextPageParams, ...options, }); diff --git a/apps/app/src/modules/governance/components/actionComposer/actionComposer/actionComposer.tsx b/apps/app/src/modules/governance/components/actionComposer/actionComposer/actionComposer.tsx index ad6861d0e2..dee1c442b1 100644 --- a/apps/app/src/modules/governance/components/actionComposer/actionComposer/actionComposer.tsx +++ b/apps/app/src/modules/governance/components/actionComposer/actionComposer/actionComposer.tsx @@ -130,7 +130,7 @@ export const ActionComposer: React.FC = (props) => { const [displayActionComposer, setDisplayActionComposer] = useState(false); const [onlyShowAuthorizedActions, setOnlyShowAuthorizedActions] = useState( - allowedActions != null, + allowedActions != null && allowedActions.length > 0, ); const [uploadError, setUploadError] = useState(null); const [isUploadLoading, setIsUploadLoading] = useState(false); diff --git a/apps/app/src/modules/governance/components/createProposalForm/createProposalFormActions/createProposalFormActions.test.tsx b/apps/app/src/modules/governance/components/createProposalForm/createProposalFormActions/createProposalFormActions.test.tsx index 57884fb1d6..f9270975b7 100644 --- a/apps/app/src/modules/governance/components/createProposalForm/createProposalFormActions/createProposalFormActions.test.tsx +++ b/apps/app/src/modules/governance/components/createProposalForm/createProposalFormActions/createProposalFormActions.test.tsx @@ -1,5 +1,6 @@ import { GukModulesProvider } from '@aragon/gov-ui-kit'; import { render, screen } from '@testing-library/react'; +import * as executeSelectorsService from '@/modules/governance/api/executeSelectorsService'; import * as daoService from '@/shared/api/daoService'; import * as DialogProvider from '@/shared/components/dialogProvider'; import { @@ -22,6 +23,10 @@ describe(' component', () => { daoService, 'useAllDaoPermissions', ); + const useAllAllowedActionsSpy = jest.spyOn( + executeSelectorsService, + 'useAllAllowedActions', + ); const useDialogContextSpy = jest.spyOn(DialogProvider, 'useDialogContext'); const useCreateProposalFormContextSpy = jest.spyOn( CreateProposalProvider, @@ -38,6 +43,13 @@ describe(' component', () => { data: [], }) as unknown as ReturnType, ); + useAllAllowedActionsSpy.mockReturnValue( + generateReactQueryResultSuccess({ + data: [], + }) as unknown as ReturnType< + typeof executeSelectorsService.useAllAllowedActions + >, + ); useDialogContextSpy.mockReturnValue(generateDialogContext()); useCreateProposalFormContextSpy.mockReturnValue({ prepareActions: {}, @@ -49,6 +61,7 @@ describe(' component', () => { afterEach(() => { useDaoSpy.mockReset(); useAllDaoPermissionsSpy.mockReset(); + useAllAllowedActionsSpy.mockReset(); useDialogContextSpy.mockReset(); useCreateProposalFormContextSpy.mockReset(); getDaoPluginsSpy.mockReset(); diff --git a/apps/app/src/modules/governance/components/createProposalForm/createProposalFormActions/createProposalFormActions.tsx b/apps/app/src/modules/governance/components/createProposalForm/createProposalFormActions/createProposalFormActions.tsx index 7ef19bc3a0..2c37f91aef 100644 --- a/apps/app/src/modules/governance/components/createProposalForm/createProposalFormActions/createProposalFormActions.tsx +++ b/apps/app/src/modules/governance/components/createProposalForm/createProposalFormActions/createProposalFormActions.tsx @@ -1,7 +1,7 @@ import { addressUtils, type ProposalActionComponent } from '@aragon/gov-ui-kit'; import { useCallback, useState } from 'react'; import { useFormContext } from 'react-hook-form'; -import { useAllowedActions } from '@/modules/governance/api/executeSelectorsService'; +import { useAllAllowedActions } from '@/modules/governance/api/executeSelectorsService'; import type { IProposalAction } from '@/modules/governance/api/governanceService'; import { useAllDaoPermissions, useDao } from '@/shared/api/daoService'; import { useTranslations } from '@/shared/components/translationsProvider'; @@ -75,21 +75,14 @@ export const CreateProposalFormActions: React.FC< getArrayControls, } = useProposalActionsField(); - const { data: allowedActionsData } = useAllowedActions( - { - urlParams: { network: dao!.network, pluginAddress }, - queryParams: { pageSize: 50 }, - }, - { enabled: hasConditionalPermissions }, - ); + const { data: allowedActions } = useAllAllowedActions({ + urlParams: { network: dao!.network, pluginAddress }, + chainId, + }); const { data: daoPermissions } = useAllDaoPermissions({ urlParams: { network: dao!.network, daoAddress: targetDaoAddress }, }); - const allowedActions = allowedActionsData?.pages.flatMap( - (page) => page.data, - ); - const [isDownloadPinning, setIsDownloadPinning] = useState(false); const [hasDownloadPinErrors, setHasDownloadPinErrors] = useState(false); diff --git a/apps/app/src/modules/governance/components/createProposalForm/createProposalFormProvider/createProposalFormProvider.tsx b/apps/app/src/modules/governance/components/createProposalForm/createProposalFormProvider/createProposalFormProvider.tsx index ab6392b7f3..cb8710e80b 100644 --- a/apps/app/src/modules/governance/components/createProposalForm/createProposalFormProvider/createProposalFormProvider.tsx +++ b/apps/app/src/modules/governance/components/createProposalForm/createProposalFormProvider/createProposalFormProvider.tsx @@ -4,6 +4,7 @@ import type { PrepareProposalActionFunction, PrepareProposalActionMap, } from '@/modules/governance/dialogs/publishProposalDialog'; +import type { IDaoPlugin } from '@/shared/api/daoService'; type AddPrepareActionFunction< TAction extends IProposalCreateAction = IProposalCreateAction, @@ -24,6 +25,12 @@ export interface ICreateProposalFormContext< * Callback to update the prepare-action maps for the given proposal action type. */ addPrepareAction: AddPrepareActionFunction; + /** + * Plugin creating the proposal, used by the action components needing the context of the process + * they are composed for. Undefined when the actions are composed outside of the create-proposal + * flow, e.g. by the execute-actions form or by the nested actions dialog. + */ + processPlugin?: IDaoPlugin; } const createProposalFormContext = @@ -47,5 +54,6 @@ export const useCreateProposalFormContext = < values.prepareActions as PrepareProposalActionMap, addPrepareAction: values.addPrepareAction as AddPrepareActionFunction, + processPlugin: values.processPlugin, }; }; diff --git a/apps/app/src/modules/governance/components/proposalActionsEditor/proposalActionsEditor.tsx b/apps/app/src/modules/governance/components/proposalActionsEditor/proposalActionsEditor.tsx index b3302d3978..c83af84a77 100644 --- a/apps/app/src/modules/governance/components/proposalActionsEditor/proposalActionsEditor.tsx +++ b/apps/app/src/modules/governance/components/proposalActionsEditor/proposalActionsEditor.tsx @@ -1,4 +1,5 @@ import { invariant, type ProposalActionComponent } from '@aragon/gov-ui-kit'; +import type { IAllowedAction } from '@/modules/governance/api/executeSelectorsService'; import { type Network, useAllDaoPermissions, @@ -26,6 +27,10 @@ export interface IProposalActionsEditorProps { * Action types to hide from the action composer, e.g. to stop an action from being nested into itself. */ excludeActionTypes?: string[]; + /** + * Actions the composer restricts its offering to. Leave undefined to offer every action. + */ + allowedActions?: IAllowedAction[]; } /** @@ -36,7 +41,7 @@ export interface IProposalActionsEditorProps { export const ProposalActionsEditor: React.FC = ( props, ) => { - const { daoId, network, excludeActionTypes } = props; + const { daoId, network, excludeActionTypes, allowedActions } = props; invariant( daoId != null || network != null, @@ -98,6 +103,7 @@ export const ProposalActionsEditor: React.FC = ( /> {showActionComposer ? ( process -> create proposal -> actions (action1, action2WithNstedActions, action 3) + * Nested actions could be cross-chain, so it is basically configured for another DAO on another chain. + * Host DAO's process can define allowed actions also for other chains. */ - daoId?: string; + hostDaoId: string; /** - * Alternative to `daoId` if the intention is to use component outside DAO context. + * Address of the process plugin restricting the actions that can be composed. When omitted no allowed + * actions are fetched and every action is offered by the composer. */ - network?: Network; + processPluginAddress?: string; + /** + * Network the nested actions are composed for, defaults to the network of the DAO. Set it when + * the actions are executed on another chain than the DAO, e.g. when they are forwarded to a + * cross-chain controller. + */ + crossChainNetwork?: Network; /** * Actions to seed the isolated dialog form with, used to edit a previously composed selection. */ diff --git a/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.test.tsx b/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.test.tsx index 72d26ffe8e..7d8c102d75 100644 --- a/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.test.tsx +++ b/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.test.tsx @@ -3,17 +3,20 @@ import userEvent from '@testing-library/user-event'; import { useFormContext } from 'react-hook-form'; import * as daoService from '@/shared/api/daoService'; import * as dialogProvider from '@/shared/components/dialogProvider'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; import { generateDao, generateDialogContext, + generateReactQueryResultSuccess, generateReactQueryResultSuccessWithData, } from '@/shared/testUtils'; import { monitoringUtils } from '@/shared/utils/monitoringUtils'; +import * as executeSelectorsService from '../../api/executeSelectorsService'; import type { IProposalActionData } from '../../components/createProposalForm'; import type { IProposalActionsEditorProps } from '../../components/proposalActionsEditor'; import * as proposalActionsEditorModule from '../../components/proposalActionsEditor'; import { GovernanceDialogId } from '../../constants/governanceDialogId'; -import { generateProposalAction } from '../../testUtils'; +import { generateAllowedAction, generateProposalAction } from '../../testUtils'; import { proposalActionPreparationUtils } from '../../utils/proposalActionPreparationUtils'; import { proposalActionsImportExportUtils } from '../../utils/proposalActionsImportExportUtils'; import { NestedActionsDialog } from './nestedActionsDialog'; @@ -111,6 +114,9 @@ const ProposalActionsEditorStub: React.FC = ( data-action-dao-ids={JSON.stringify( getValues('actions').map((action) => action.daoId), )} + data-allowed-action-targets={JSON.stringify( + props.allowedActions?.map((action) => action.target), + )} data-dao-id={props.daoId} data-exclude-action-types={JSON.stringify(props.excludeActionTypes)} data-testid="actions-editor" @@ -138,8 +144,19 @@ describe(' component', () => { ); const useDaoSpy = jest.spyOn(daoService, 'useDao'); const logErrorSpy = jest.spyOn(monitoringUtils, 'logError'); + const useAllAllowedActionsSpy = jest.spyOn( + executeSelectorsService, + 'useAllAllowedActions', + ); beforeEach(() => { + useAllAllowedActionsSpy.mockReturnValue( + generateReactQueryResultSuccess({ + data: [], + }) as unknown as ReturnType< + typeof executeSelectorsService.useAllAllowedActions + >, + ); useDialogContextSpy.mockReturnValue(generateDialogContext()); useDaoSpy.mockReturnValue( generateReactQueryResultSuccessWithData( @@ -161,13 +178,14 @@ describe(' component', () => { decodeActionsSpy.mockReset(); proposalActionsEditorSpy.mockReset(); logErrorSpy.mockReset(); + useAllAllowedActionsSpy.mockReset(); }); const createTestComponent = ( params?: Partial, ) => { const completeParams: INestedActionsDialogParams = { - daoId: DAO_ID, + hostDaoId: DAO_ID, initialActions: [], onSubmit: jest.fn(), ...params, @@ -201,6 +219,45 @@ describe(' component', () => { expect(decodeActionsSpy).not.toHaveBeenCalled(); }); + it('fetches the allowed actions on the plugin network for the chain the actions are composed for', () => { + createTestComponent({ + processPluginAddress: '0xplugin', + crossChainNetwork: daoService.Network.BASE_MAINNET, + }); + + expect(useAllAllowedActionsSpy).toHaveBeenLastCalledWith( + { + urlParams: { network: DAO.network, pluginAddress: '0xplugin' }, + chainId: networkDefinitions[daoService.Network.BASE_MAINNET].id, + }, + { enabled: true }, + ); + }); + + it('forwards the allowed actions of the plugin to the editor', () => { + useAllAllowedActionsSpy.mockReturnValue( + generateReactQueryResultSuccess({ + data: [generateAllowedAction({ target: '0xallowed' })], + }) as unknown as ReturnType< + typeof executeSelectorsService.useAllAllowedActions + >, + ); + + createTestComponent({ processPluginAddress: '0xplugin' }); + + expect( + screen.getByTestId('actions-editor').dataset.allowedActionTargets, + ).toEqual(JSON.stringify(['0xallowed'])); + }); + + it('offers every action when no plugin restricts them', () => { + createTestComponent(); + + expect( + screen.getByTestId('actions-editor').dataset.allowedActionTargets, + ).toBeUndefined(); + }); + it('decodes the initial actions before seeding the form when none of them carry input data', async () => { const rawAction = generateRawActionData({ to: '0xraw', diff --git a/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.tsx b/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.tsx index 5896a62735..b7bdf8358b 100644 --- a/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.tsx +++ b/apps/app/src/modules/governance/dialogs/nestedActionsDialog/nestedActionsDialog.tsx @@ -3,10 +3,12 @@ import { AlertInline, Dialog, invariant } from '@aragon/gov-ui-kit'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; -import { useDao } from '@/shared/api/daoService'; +import { type Network, useDao } from '@/shared/api/daoService'; import { useDialogContext } from '@/shared/components/dialogProvider'; import { useTranslations } from '@/shared/components/translationsProvider'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; import { monitoringUtils } from '@/shared/utils/monitoringUtils'; +import { useAllAllowedActions } from '../../api/executeSelectorsService'; import type { IProposalActionData } from '../../components/createProposalForm'; import { CreateProposalFormProvider } from '../../components/createProposalForm'; import { ProposalActionsEditor } from '../../components/proposalActionsEditor'; @@ -37,17 +39,44 @@ export const NestedActionsDialog: React.FC = ( 'NestedActionsDialog: required parameters must be set.', ); - const { daoId, network, initialActions, excludeActionTypes, onSubmit } = - location.params; + const { + hostDaoId, + processPluginAddress, + crossChainNetwork, + initialActions, + excludeActionTypes, + onSubmit, + } = location.params; const { t } = useTranslations(); const { close } = useDialogContext(); - const { data: dao } = useDao( - { urlParams: { id: daoId ?? '' } }, - { enabled: daoId != null }, + const { data: hostDao } = useDao({ urlParams: { id: hostDaoId } }); + + const resolvedNetwork = crossChainNetwork ?? hostDao?.network; + + const shouldFetchAllowedActions = + hostDao != null && processPluginAddress != null; + const composerChainId = + resolvedNetwork != null + ? networkDefinitions[resolvedNetwork].id + : undefined; + + const { data: processAllowedActions } = useAllAllowedActions( + { + urlParams: { + network: hostDao?.network as Network, + pluginAddress: processPluginAddress ?? '', + }, + chainId: composerChainId, + }, + { enabled: shouldFetchAllowedActions }, ); - const resolvedNetwork = network ?? dao?.network; + // The composer treats a defined list as "only offer the authorized actions", so it must stay + // undefined if we don't fetch actions + const allowedActions = shouldFetchAllowedActions + ? processAllowedActions + : undefined; const [prepareActions, setPrepareActions] = useState({}); @@ -89,7 +118,7 @@ export const NestedActionsDialog: React.FC = ( return; } - if (daoId != null && dao == null) { + if (hostDao == null) { return; } @@ -110,22 +139,25 @@ export const NestedActionsDialog: React.FC = ( data, })), resolvedNetwork, - dao, + crossChainNetwork ? undefined : hostDao, ); reset({ - actions: daoId - ? decodedActions.map( + actions: crossChainNetwork + ? decodedActions + : decodedActions.map( (action) => - ({ ...action, daoId }) as IProposalActionData, - ) - : decodedActions, + ({ + ...action, + daoId: hostDaoId, + }) as IProposalActionData, + ), }); } catch (error) { monitoringUtils.logError(error, { context: { - daoId, - network, + hostDaoId, + crossChainNetwork, message: 'Failed to decode the nested proposal actions', }, }); @@ -137,9 +169,9 @@ export const NestedActionsDialog: React.FC = ( void decodeInitialActions(); }, [ - dao, - daoId, - network, + hostDao, + hostDaoId, + crossChainNetwork, resolvedNetwork, initialActions, requiresDecoding, @@ -170,8 +202,8 @@ export const NestedActionsDialog: React.FC = ( } catch (error) { monitoringUtils.logError(error, { context: { - daoId, - network, + hostDaoId, + crossChainNetwork, message: 'Failed to prepare the nested proposal actions', }, }); @@ -201,9 +233,10 @@ export const NestedActionsDialog: React.FC = ( /> ) : ( )} {hasDecodeError && ( diff --git a/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx b/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx index 98d7fc2a90..00111f990e 100644 --- a/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx +++ b/apps/app/src/modules/governance/pages/createProposalPage/createProposalPageClient.tsx @@ -73,8 +73,8 @@ export const CreateProposalPageClient: React.FC< ); const contextValues = useMemo( - () => ({ prepareActions, addPrepareAction }), - [prepareActions, addPrepareAction], + () => ({ prepareActions, addPrepareAction, processPlugin: plugin }), + [prepareActions, addPrepareAction, plugin], ); const handleFormSubmit = (values: ICreateProposalFormData) => { diff --git a/apps/app/src/modules/governance/testUtils/generators/allowedAction.ts b/apps/app/src/modules/governance/testUtils/generators/allowedAction.ts new file mode 100644 index 0000000000..d7733b6d08 --- /dev/null +++ b/apps/app/src/modules/governance/testUtils/generators/allowedAction.ts @@ -0,0 +1,12 @@ +import type { IAllowedAction } from '../../api/executeSelectorsService'; + +export const generateAllowedAction = ( + action?: Partial, +): IAllowedAction => ({ + selector: '0x12345678', + target: '0x123', + isAllowed: true, + id: 'test-id', + conditionAddress: '0xCondition', + ...action, +}); diff --git a/apps/app/src/modules/governance/testUtils/generators/index.ts b/apps/app/src/modules/governance/testUtils/generators/index.ts index f18eb2bdd4..a4f882a3ca 100644 --- a/apps/app/src/modules/governance/testUtils/generators/index.ts +++ b/apps/app/src/modules/governance/testUtils/generators/index.ts @@ -1,3 +1,4 @@ +export * from './allowedAction'; export * from './createProposalFormData'; export * from './member'; export * from './memberMetrics'; diff --git a/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerForwardMessageAction.tsx b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerForwardMessageAction.tsx index 435854df6d..8740a787af 100644 --- a/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerForwardMessageAction.tsx +++ b/apps/app/src/plugins/crossChainControllerPlugin/components/crossChainControllerActions/crossChainControllerForwardMessageAction/crossChainControllerForwardMessageAction.tsx @@ -17,6 +17,7 @@ import { useFormContext } from 'react-hook-form'; import { encodeAbiParameters, encodeFunctionData, type Hex } from 'viem'; import type { IProposalAction } from '@/modules/governance/api/governanceService'; import type { IProposalActionData } from '@/modules/governance/components/createProposalForm'; +import { useCreateProposalFormContext } from '@/modules/governance/components/createProposalForm'; import { GovernanceDialogId } from '@/modules/governance/constants/governanceDialogId'; import type { INestedActionsDialogParams } from '@/modules/governance/dialogs/nestedActionsDialog'; import { useDialogContext } from '@/shared/components/dialogProvider'; @@ -89,6 +90,9 @@ export const CrossChainControllerForwardMessageAction: React.FC< const { setValue } = useFormContext(); const { chainId: daoChainId } = useDaoChain({ daoId }); + // The nested actions are part of the proposal, so they are restricted by the process creating it. + const { processPlugin } = useCreateProposalFormContext(); + const actionFieldName = `actions.[${index.toString()}]`; useFormField, typeof actionFieldName>( actionFieldName, @@ -163,7 +167,9 @@ export const CrossChainControllerForwardMessageAction: React.FC< ); const params: INestedActionsDialogParams = { - network: destinationNetwork, + hostDaoId: daoId, + processPluginAddress: processPlugin?.address, + crossChainNetwork: destinationNetwork, initialActions: nestedActions, // Prevent showing nested forward actions. excludeActionTypes: [