Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .agents/shared/metrics/hits.jsonl
Original file line number Diff line number Diff line change
Expand Up @@ -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"}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
@@ -1 +1,2 @@
export * from './useAllAllowedActions';
export * from './useAllowedActions';
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export type { IUseAllAllowedActionsParams } from './useAllAllowedActions';
export { useAllAllowedActions } from './useAllAllowedActions';
Original file line number Diff line number Diff line change
@@ -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,
]),
);
});
});
Original file line number Diff line number Diff line change
@@ -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<IAllowedAction>,
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,
};
};
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ export const ActionComposer: React.FC<IActionComposerProps> = (props) => {

const [displayActionComposer, setDisplayActionComposer] = useState(false);
const [onlyShowAuthorizedActions, setOnlyShowAuthorizedActions] = useState(
allowedActions != null,
allowedActions != null && allowedActions.length > 0,
);
const [uploadError, setUploadError] = useState<string | null>(null);
const [isUploadLoading, setIsUploadLoading] = useState(false);
Expand Down
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -22,6 +23,10 @@ describe('<CreateProposalFormActions /> component', () => {
daoService,
'useAllDaoPermissions',
);
const useAllAllowedActionsSpy = jest.spyOn(
executeSelectorsService,
'useAllAllowedActions',
);
const useDialogContextSpy = jest.spyOn(DialogProvider, 'useDialogContext');
const useCreateProposalFormContextSpy = jest.spyOn(
CreateProposalProvider,
Expand All @@ -38,6 +43,13 @@ describe('<CreateProposalFormActions /> component', () => {
data: [],
}) as unknown as ReturnType<typeof daoService.useAllDaoPermissions>,
);
useAllAllowedActionsSpy.mockReturnValue(
generateReactQueryResultSuccess({
data: [],
}) as unknown as ReturnType<
typeof executeSelectorsService.useAllAllowedActions
>,
);
useDialogContextSpy.mockReturnValue(generateDialogContext());
useCreateProposalFormContextSpy.mockReturnValue({
prepareActions: {},
Expand All @@ -49,6 +61,7 @@ describe('<CreateProposalFormActions /> component', () => {
afterEach(() => {
useDaoSpy.mockReset();
useAllDaoPermissionsSpy.mockReset();
useAllAllowedActionsSpy.mockReset();
useDialogContextSpy.mockReset();
useCreateProposalFormContextSpy.mockReset();
getDaoPluginsSpy.mockReset();
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -24,6 +25,12 @@ export interface ICreateProposalFormContext<
* Callback to update the prepare-action maps for the given proposal action type.
*/
addPrepareAction: AddPrepareActionFunction<TAction>;
/**
* 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 =
Expand All @@ -47,5 +54,6 @@ export const useCreateProposalFormContext = <
values.prepareActions as PrepareProposalActionMap<TAction>,
addPrepareAction:
values.addPrepareAction as AddPrepareActionFunction<TAction>,
processPlugin: values.processPlugin,
};
};
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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[];
}

/**
Expand All @@ -36,7 +41,7 @@ export interface IProposalActionsEditorProps {
export const ProposalActionsEditor: React.FC<IProposalActionsEditorProps> = (
props,
) => {
const { daoId, network, excludeActionTypes } = props;
const { daoId, network, excludeActionTypes, allowedActions } = props;

invariant(
daoId != null || network != null,
Expand Down Expand Up @@ -98,6 +103,7 @@ export const ProposalActionsEditor: React.FC<IProposalActionsEditorProps> = (
/>
{showActionComposer ? (
<ActionComposer
allowedActions={allowedActions}
daoId={daoId}
daoPermissions={daoPermissions}
excludeActionTypes={excludeActionTypes}
Expand Down
Loading
Loading