From 530e96fe2f5d493d04f8c997488f389754caab59 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 14 Jul 2026 17:21:14 +0200 Subject: [PATCH 01/55] feat(APP-942): finish permissions graph view --- .gitignore | 1 + apps/app/src/assets/locales/en.json | 25 + .../executeSelectorConditionSlot.tsx | 24 +- .../membershipConditionSlot.tsx | 13 +- .../components/permissionsGraph/index.ts | 4 + .../permissionsGraph/permissionsGraph.tsx | 569 ++++++++++++++++++ .../components/permissionsList/index.ts | 1 + .../permissionsList/permissionsList.test.tsx | 189 ++---- .../permissionsList/permissionsList.tsx | 285 ++------- .../constants/permissionsPreviewData.ts | 24 +- .../hooks/usePermissionsData/index.ts | 6 + .../usePermissionsData.test.ts | 120 ++++ .../usePermissionsData/usePermissionsData.ts | 172 ++++++ .../daoPermissionsPageClient.tsx | 131 +++- apps/app/src/modules/settings/types/index.ts | 6 + .../modules/settings/types/permissionGraph.ts | 26 + .../buildPermissionGraph.test.ts | 133 ++++ .../buildPermissionGraph.ts | 123 ++++ .../utils/buildPermissionGraph/index.ts | 4 + .../api/daoService/domain/daoPermission.ts | 19 + 20 files changed, 1469 insertions(+), 406 deletions(-) create mode 100644 apps/app/src/modules/settings/components/permissionsGraph/index.ts create mode 100644 apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx create mode 100644 apps/app/src/modules/settings/hooks/usePermissionsData/index.ts create mode 100644 apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts create mode 100644 apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts create mode 100644 apps/app/src/modules/settings/types/permissionGraph.ts create mode 100644 apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.test.ts create mode 100644 apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts create mode 100644 apps/app/src/modules/settings/utils/buildPermissionGraph/index.ts diff --git a/.gitignore b/.gitignore index 5e9ed8eea5..cee846e4ac 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,7 @@ next-env.d.ts .vscode .zed .tempor +.omp/ .agents/** !.agents/shared/ !.agents/shared/** diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index 60aa554adf..c23b3f57dc 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -3544,6 +3544,29 @@ "view": { "list": "List", "graph": "Graph" + }, + "graphView": { + "mode": { + "incoming": "To DAO", + "outgoing": "From DAO", + "other": "Other" + }, + "node": { + "dao": "Primary DAO", + "linkedDao": "Linked DAO", + "plugin": "Aragon OSx Plugin", + "actor": "Any address" + }, + "edge": { + "condition": "if {{condition}}" + }, + "detail": { + "close": "Close" + }, + "empty": { + "heading": "No permissions", + "description": "This view has no indexed permissions to visualize." + } } }, "permissionsList": { @@ -3582,10 +3605,12 @@ "executeSelectorConditionSlot": { "description": "The ExecuteSelectorCondition authorizes the caller to execute actions only on explicitly authorized contract functions.", "allowedActions": "Allowed actions", + "anySelector": "Any selector", "noActions": "No allowed actions" }, "membershipConditionSlot": { "memberOfMultisig": "Member of multisig", + "minApprovals": "Minimum approvals", "true": "True", "false": "False" }, diff --git a/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx index 7f9e9afe42..5e1790661a 100644 --- a/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx +++ b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx @@ -8,19 +8,26 @@ import { stringUtils } from '@/shared/utils/stringUtils'; const EMPTY_VALUE = '—'; interface IAllowedAction { - selector: string; + selector: string | null; target: string; } -const toStringList = (value: unknown): string[] => +const toSelectorList = (value: unknown): Array => + Array.isArray(value) + ? value.filter((item): item is string | null => + item === null ? true : stringUtils.isNonEmptyString(item), + ) + : []; + +const toTargetList = (value: unknown): string[] => Array.isArray(value) ? value.filter(stringUtils.isNonEmptyString) : []; const toAllowedActions = ( selectors: unknown, targets: unknown, ): IAllowedAction[] => { - const selectorList = toStringList(selectors); - const targetList = toStringList(targets); + const selectorList = toSelectorList(selectors); + const targetList = toTargetList(targets); return selectorList.map((selector, index) => ({ selector, @@ -46,8 +53,13 @@ export const ExecuteSelectorConditionSlot: React.FC = ( {allowedActions.map((action) => ( {action.target === EMPTY_VALUE ? EMPTY_VALUE diff --git a/apps/app/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.tsx b/apps/app/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.tsx index 408a8726e3..dd01a4cb05 100644 --- a/apps/app/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.tsx +++ b/apps/app/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.tsx @@ -11,10 +11,12 @@ import { useTranslations } from '@/shared/components/translationsProvider'; * "Member of multisig". */ export const MembershipConditionSlot: React.FC = (props) => { - const { onlyListed } = props; + const { onlyListed, minApprovals } = props; const { t } = useTranslations(); const isMemberGated = onlyListed === true; + const approvalsLabel = + typeof minApprovals === 'number' ? minApprovals.toString() : undefined; return ( @@ -34,6 +36,15 @@ export const MembershipConditionSlot: React.FC = (props) => { /> + {approvalsLabel != null && ( + + {approvalsLabel} + + )} ); }; diff --git a/apps/app/src/modules/settings/components/permissionsGraph/index.ts b/apps/app/src/modules/settings/components/permissionsGraph/index.ts new file mode 100644 index 0000000000..255b4f0ba3 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/index.ts @@ -0,0 +1,4 @@ +export { + type IPermissionsGraphProps, + PermissionsGraph, +} from './permissionsGraph'; diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx new file mode 100644 index 0000000000..6995db7333 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx @@ -0,0 +1,569 @@ +'use client'; + +import { + Avatar, + addressUtils, + Button, + CardEmptyState, + DaoAvatar, + DefinitionList, + IconType, + StateSkeletonBar, + Tag, + Toggle, + ToggleGroup, +} from '@aragon/gov-ui-kit'; +import classNames from 'classnames'; +import { useMemo, useState } from 'react'; +import type { IDao, IDaoPlugin } from '@/shared/api/daoService'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; +import { PluginSingleComponent } from '@/shared/components/pluginSingleComponent'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { SettingsSlotId } from '../../constants/moduleSlots'; +import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; +import type { + IPermissionGraph, + IPermissionGraphEdge, + IPermissionGraphNode, + IPermissionRow, +} from '../../types'; +import { buildPermissionGraph } from '../../utils/buildPermissionGraph'; +import { conditionTypeUtils } from '../../utils/conditionTypeUtils'; +import type { IPermissionAccountRef } from '../../utils/permissionEntityUtils'; +import { NoConditionSlot } from '../noConditionSlot'; + +export interface IPermissionsGraphProps { + rows: IPermissionRow[]; + dao?: IDao; + daoPlugins?: IFilterComponentPlugin[]; + accountRefs: IPermissionAccountRef[]; + isLoading: boolean; + activeAccountAddress?: string; +} + +type GraphMode = 'incoming' | 'outgoing' | 'other'; + +interface IGraphPoint { + x: number; + y: number; +} + +const GRAPH_WIDTH = 1000; +const GRAPH_HEIGHT = 560; +const ANCHOR_POINT: IGraphPoint = { x: 500, y: 260 }; +const SIDE_TOP = 96; +const SIDE_BOTTOM = 464; +const NODE_WIDTH = 176; +const EMPTY_NODE_TEXT = '—'; + +const modeValues: GraphMode[] = ['incoming', 'outgoing', 'other']; + +const distributeY = (index: number, total: number): number => { + if (total <= 1) { + return ANCHOR_POINT.y; + } + + return SIDE_TOP + (index * (SIDE_BOTTOM - SIDE_TOP)) / (total - 1); +}; + +const getModeEdges = ( + graph: IPermissionGraph, + mode: GraphMode, + anchorId: string, +): IPermissionGraphEdge[] => { + if (mode === 'incoming') { + return graph.edges.filter((edge) => edge.target === anchorId); + } + + if (mode === 'outgoing') { + return graph.edges.filter((edge) => edge.source === anchorId); + } + + return graph.edges.filter( + (edge) => edge.source !== anchorId && edge.target !== anchorId, + ); +}; + +const buildPositions = ( + nodes: IPermissionGraphNode[], + edges: IPermissionGraphEdge[], + mode: GraphMode, + anchorId: string, +): Map => { + const positions = new Map(); + + if (mode !== 'other') { + positions.set(anchorId, ANCHOR_POINT); + const sideIds = Array.from( + new Set( + edges.map((edge) => + mode === 'incoming' ? edge.source : edge.target, + ), + ), + ); + const x = mode === 'incoming' ? 200 : 800; + + sideIds.forEach((id, index) => { + positions.set(id, { + x, + y: distributeY(index, sideIds.length), + }); + }); + + return positions; + } + + const sourceIds = Array.from(new Set(edges.map((edge) => edge.source))); + const targetIds = Array.from(new Set(edges.map((edge) => edge.target))); + + sourceIds.forEach((id, index) => { + positions.set(id, { x: 260, y: distributeY(index, sourceIds.length) }); + }); + targetIds.forEach((id, index) => { + positions.set(id, { x: 740, y: distributeY(index, targetIds.length) }); + }); + + for (const node of nodes) { + if (!positions.has(node.id)) { + positions.set(node.id, ANCHOR_POINT); + } + } + + return positions; +}; + +export const PermissionsGraph: React.FC = (props) => { + const { + rows, + dao, + daoPlugins, + accountRefs, + isLoading, + activeAccountAddress, + } = props; + + const { t } = useTranslations(); + + const [mode, setMode] = useState('incoming'); + const [hoveredEdgeId, setHoveredEdgeId] = useState(); + const [selectedEdgeId, setSelectedEdgeId] = useState(); + + const graph = useMemo(() => { + if (dao == null) { + return { nodes: [], edges: [] }; + } + + return buildPermissionGraph({ rows, dao, daoPlugins, accountRefs }); + }, [rows, dao, daoPlugins, accountRefs]); + + const anchorId = (activeAccountAddress ?? dao?.address ?? '').toLowerCase(); + const modeEdges = useMemo( + () => getModeEdges(graph, mode, anchorId), + [graph, mode, anchorId], + ); + const visibleNodeIds = new Set( + modeEdges.flatMap((edge) => [edge.source, edge.target]), + ); + const visibleNodes = graph.nodes.filter((node) => + visibleNodeIds.has(node.id), + ); + const positions = buildPositions(visibleNodes, modeEdges, mode, anchorId); + const selectedEdge = graph.edges.find((edge) => edge.id === selectedEdgeId); + + const handleModeChange = (value?: string | string[]) => { + if (modeValues.includes(value as GraphMode)) { + setMode(value as GraphMode); + setSelectedEdgeId(undefined); + setHoveredEdgeId(undefined); + } + }; + + if (isLoading || dao == null) { + return ; + } + + if (graph.edges.length === 0 || modeEdges.length === 0) { + return ( +
+ + +
+ ); + } + + return ( +
+ +
+ + + {visibleNodes.map((node) => { + const point = positions.get(node.id); + + if (point == null) { + return null; + } + + return ( + + ); + })} + + {modeEdges.map((edge, index) => { + const source = positions.get(edge.source); + const target = positions.get(edge.target); + + if (source == null || target == null) { + return null; + } + + const midpoint = { + x: (source.x + target.x) / 2, + y: (source.y + target.y) / 2 + (index % 3) * 18 - 18, + }; + const isSelected = selectedEdgeId === edge.id; + const isHovered = hoveredEdgeId === edge.id; + + return ( + + ); + })} + + {selectedEdge != null && ( + setSelectedEdgeId(undefined)} + /> + )} +
+
+ ); +}; + +interface IGraphModeToggleProps { + mode: GraphMode; + onModeChange: (value?: string | string[]) => void; +} + +const GraphModeToggle: React.FC = ({ + mode, + onModeChange, +}) => { + const { t } = useTranslations(); + + return ( + + + + + + ); +}; + +interface IGraphNodeProps { + node: IPermissionGraphNode; + point: IGraphPoint; + selectedEdge?: IPermissionGraphEdge; +} + +const GraphNode: React.FC = ({ + node, + point, + selectedEdge, +}) => { + const { t } = useTranslations(); + const isDaoKind = node.kind === 'dao' || node.kind === 'linkedDao'; + const isAffected = + selectedEdge != null && + (selectedEdge.source === node.id || selectedEdge.target === node.id); + const isDimmed = selectedEdge != null && !isAffected; + + return ( +
+
+

+ {node.label || EMPTY_NODE_TEXT} +

+

+ {t( + `app.settings.daoPermissionsPage.graphView.node.${node.kind}`, + )} +

+
+ {isDaoKind && ( + + )} + {node.kind === 'plugin' && node.tag != null && ( + + )} + {node.kind === 'actor' && } +
+ ); +}; + +interface IPermissionDetailPanelProps { + edge: IPermissionGraphEdge; + nodes: IPermissionGraphNode[]; + onClose: () => void; +} + +const PermissionDetailPanel: React.FC = ({ + edge, + nodes, + onClose, +}) => { + const { t } = useTranslations(); + const { row } = edge; + const who = nodes.find((node) => node.id === edge.source); + const where = nodes.find((node) => node.id === edge.target); + const hasCondition = !addressUtils.isAddressEqual( + row.conditionAddress, + ALLOW_FLAG, + ); + const conditionType = conditionTypeUtils.resolveConditionType( + row.conditionAddress, + row.condition, + ); + + const isWhoAnyAddress = addressUtils.isAddressEqual( + row.whoAddress, + ANY_ADDR, + ); + const isWhereAnyAddress = addressUtils.isAddressEqual( + row.whereAddress, + ANY_ADDR, + ); + + return ( +
+
+
+

+ {edge.permissionName} +

+ {edge.conditionLabel != null && ( +

+ {t( + 'app.settings.daoPermissionsPage.graphView.edge.condition', + { condition: edge.conditionLabel }, + )} +

+ )} +
+
+
+ + + {isWhoAnyAddress + ? who?.label + : addressUtils.truncateAddress(row.whoAddress)} + + + {isWhereAnyAddress + ? where?.label + : addressUtils.truncateAddress(row.whereAddress)} + + + {addressUtils.truncateHash(row.permissionId)} + + +
+

+ {t('app.settings.permissionsList.condition.heading')} +

+ +
+
+
+ ); +}; + +const PermissionsGraphSkeleton: React.FC = () => ( +
+ + + +
+); diff --git a/apps/app/src/modules/settings/components/permissionsList/index.ts b/apps/app/src/modules/settings/components/permissionsList/index.ts index f047f20338..cc5f1e5f0b 100644 --- a/apps/app/src/modules/settings/components/permissionsList/index.ts +++ b/apps/app/src/modules/settings/components/permissionsList/index.ts @@ -1,4 +1,5 @@ export { + getPermissionRowKey, type IPermissionsListProps, PermissionsList, } from './permissionsList'; diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx index 7fb9aa8ae0..4ecc8232be 100644 --- a/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx @@ -1,23 +1,13 @@ import { GukModulesProvider } from '@aragon/gov-ui-kit'; import { render, screen } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import * as daoService from '@/shared/api/daoService'; -import { - type IDao, - type ILinkedAccountSummary, - Network, -} from '@/shared/api/daoService'; -import * as featureFlagsProvider from '@/shared/components/featureFlagsProvider'; -import * as useDaoPluginsModule from '@/shared/hooks/useDaoPlugins'; -import { - generateDao, - generateDaoMetrics, - generateReactQueryResultSuccess, -} from '@/shared/testUtils'; import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; import { initialiseConditionRegistry } from '../../initConditionRegistry'; import type { IPermissionRow } from '../../types'; -import { PermissionsList } from './permissionsList'; +import { + getPermissionRowKey, + type IPermissionsListProps, + PermissionsList, +} from './permissionsList'; const ROOT_PERMISSION_ID = '0x815fe80e4b37c8582a3b773d1d7071f983eacfd56b5965db654f3087c25ada33'; @@ -25,64 +15,31 @@ const EXECUTE_PERMISSION_ID = '0xbf04b4486c9663d805744005c3da000eda93de6e3308a4a7a812eb565327b78d'; describe(' component', () => { - const useDaoSpy = jest.spyOn(daoService, 'useDao'); - const useAllDaoPermissionsSpy = jest.spyOn( - daoService, - 'useAllDaoPermissions', - ); - const useDaoPluginsSpy = jest.spyOn(useDaoPluginsModule, 'useDaoPlugins'); - const useFeatureFlagsSpy = jest.spyOn( - featureFlagsProvider, - 'useFeatureFlags', - ); + beforeAll(() => { + initialiseConditionRegistry(); + }); - const setFeatureFlags = (linkedAccountEnabled: boolean) => { - useFeatureFlagsSpy.mockReturnValue({ - isEnabled: (key) => key === 'linkedAccount' && linkedAccountEnabled, - } as ReturnType); - }; + const createTestComponent = (props?: Partial) => { + const completeProps: IPermissionsListProps = { + rows: [], + accountRefs: [], + daoPlugins: [], + chainId: undefined, + isLoading: false, + expandedRows: [], + onExpandedRowsChange: jest.fn(), + ...props, + }; - const setDao = (dao?: Partial) => { - useDaoSpy.mockReturnValue( - generateReactQueryResultSuccess({ - data: generateDao(dao), - }) as ReturnType, + return ( + + + ); }; - const setPermissions = ( - result: Partial>, - ) => { - useAllDaoPermissionsSpy.mockReturnValue({ - data: [], - isLoading: false, - error: null, - refetch: jest.fn(), - ...result, - } as ReturnType); - }; - - beforeEach(() => { - setFeatureFlags(false); - setDao(); - setPermissions({ data: [], isLoading: false }); - useDaoPluginsSpy.mockReturnValue([]); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - const createTestComponent = (props?: { daoId?: string }) => ( - - - - ); - it('renders a skeleton while the permissions are loading', () => { - setPermissions({ data: [], isLoading: true }); - - render(createTestComponent()); + render(createTestComponent({ isLoading: true })); expect( screen.getByTestId('permissions-list-skeleton'), @@ -92,10 +49,8 @@ describe(' component', () => { ).not.toBeInTheDocument(); }); - it('renders the empty state when the account has no permissions', () => { - setPermissions({ data: [], isLoading: false }); - - render(createTestComponent()); + it('renders the empty state when there are no permissions', () => { + render(createTestComponent({ rows: [] })); expect( screen.getByText(/permissionsList.empty.heading/), @@ -121,9 +76,8 @@ describe(' component', () => { condition: { conditionType: 'voting-power' }, }, ]; - setPermissions({ data: rows, isLoading: false }); - render(createTestComponent()); + render(createTestComponent({ rows })); expect(screen.getByText('ROOT_PERMISSION')).toBeInTheDocument(); expect(screen.getByText('EXECUTE_PERMISSION')).toBeInTheDocument(); @@ -134,7 +88,7 @@ describe(' component', () => { ).toBeInTheDocument(); }); - it('renders the collapsed CONDITION cell with the resolved label or a dash', () => { + it('renders the collapsed condition cell with the resolved label or a dash', () => { const rows: IPermissionRow[] = [ { permissionId: ROOT_PERMISSION_ID, @@ -150,17 +104,14 @@ describe(' component', () => { condition: { conditionType: 'voting-power' }, }, ]; - setPermissions({ data: rows, isLoading: false }); - render(createTestComponent()); + render(createTestComponent({ rows })); expect(screen.getByText('VotingPower')).toBeInTheDocument(); expect(screen.getByText('-')).toBeInTheDocument(); }); - it('renders both the Details and Condition lists when a row is expanded', async () => { - initialiseConditionRegistry(); - const user = userEvent.setup(); + it('renders both the Details and Condition lists for an expanded row', async () => { const rows: IPermissionRow[] = [ { permissionId: EXECUTE_PERMISSION_ID, @@ -174,12 +125,12 @@ describe(' component', () => { }, }, ]; - setPermissions({ data: rows, isLoading: false }); - render(createTestComponent()); - - await user.click( - screen.getByRole('button', { name: /permissionsList.expandAll/ }), + render( + createTestComponent({ + rows, + expandedRows: [getPermissionRowKey(rows[0])], + }), ); expect( @@ -189,14 +140,12 @@ describe(' component', () => { screen.getByText(/permissionsList.condition.heading/), ).toBeInTheDocument(); expect( - screen.getByText(/votingPowerConditionSlot.token/), + await screen.findByText(/votingPowerConditionSlot.token/), ).toBeInTheDocument(); - // 1e18 base units formatted with the default 18 decimals. - expect(screen.getByText('1')).toBeInTheDocument(); + expect(await screen.findByText('1')).toBeInTheDocument(); }); - it('routes the condition cell to the fallback slot when expanded', async () => { - const user = userEvent.setup(); + it('routes the condition cell to the fallback slot for an expanded row', () => { const rows: IPermissionRow[] = [ { permissionId: ROOT_PERMISSION_ID, @@ -205,66 +154,14 @@ describe(' component', () => { conditionAddress: ALLOW_FLAG, }, ]; - setPermissions({ data: rows, isLoading: false }); - - render(createTestComponent()); - - await user.click( - screen.getByRole('button', { name: /permissionsList.expandAll/ }), - ); - - expect(screen.getByText(/noConditionSlot.heading/)).toBeInTheDocument(); - }); - - it('re-queries permissions with the selected linked account params on tab switch', async () => { - const user = userEvent.setup(); - const linkedAccount: ILinkedAccountSummary = { - id: 'linked-1', - address: '0xLinkedAddress', - network: Network.POLYGON_MAINNET, - name: 'Linked Treasury', - description: '', - ens: null, - subdomain: null, - avatar: null, - metrics: generateDaoMetrics(), - links: [], - blockTimestamp: 0, - transactionHash: '', - }; - setFeatureFlags(true); - setDao({ - id: 'main-dao', - address: '0xMainAddress', - network: Network.ETHEREUM_MAINNET, - name: 'Main DAO', - linkedAccounts: [linkedAccount], - }); - - render(createTestComponent({ daoId: 'main-dao' })); - expect(useAllDaoPermissionsSpy).toHaveBeenLastCalledWith( - expect.objectContaining({ - urlParams: { - network: Network.ETHEREUM_MAINNET, - daoAddress: '0xMainAddress', - }, + render( + createTestComponent({ + rows, + expandedRows: [getPermissionRowKey(rows[0])], }), - expect.anything(), - ); - - await user.click( - screen.getByRole('radio', { name: 'Linked Treasury' }), ); - expect(useAllDaoPermissionsSpy).toHaveBeenLastCalledWith( - expect.objectContaining({ - urlParams: { - network: Network.POLYGON_MAINNET, - daoAddress: '0xLinkedAddress', - }, - }), - expect.anything(), - ); + expect(screen.getByText(/noConditionSlot.heading/)).toBeInTheDocument(); }); }); diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx index f7a68de62a..9b5305339a 100644 --- a/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx @@ -3,7 +3,6 @@ import { Accordion, addressUtils, - Button, CardEmptyState, ChainEntityType, DaoAvatar, @@ -11,31 +10,15 @@ import { Link, StateSkeletonBar, Tag, - Toggle, - ToggleGroup, useBlockExplorer, } from '@aragon/gov-ui-kit'; -import type { ReactNode } from 'react'; -import { useMemo, useState } from 'react'; -import { - type Network, - useAllDaoPermissions, - useDao, -} from '@/shared/api/daoService'; -import { useFeatureFlags } from '@/shared/components/featureFlagsProvider'; +import type { IDaoPlugin } from '@/shared/api/daoService'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; import { PluginSingleComponent } from '@/shared/components/pluginSingleComponent'; import { useTranslations } from '@/shared/components/translationsProvider'; -import { networkDefinitions } from '@/shared/constants/networkDefinitions'; -import { useDaoPlugins } from '@/shared/hooks/useDaoPlugins'; -import { ipfsUtils } from '@/shared/utils/ipfsUtils'; import { permissionNameUtils } from '@/shared/utils/permissionNameUtils'; import { SettingsSlotId } from '../../constants/moduleSlots'; import { ALLOW_FLAG } from '../../constants/permissionSentinels'; -import { - permissionsPreviewAccounts, - permissionsPreviewPlugins, -} from '../../constants/permissionsPreviewData'; -import { PermissionsPreviewRef } from '../../constants/permissionsPreviewRefs'; import type { IPermissionRow } from '../../types'; import { conditionTypeUtils } from '../../utils/conditionTypeUtils'; import { @@ -45,23 +28,16 @@ import { } from '../../utils/permissionEntityUtils'; import { NoConditionSlot } from '../noConditionSlot'; -export interface IPermissionsListProps { - /** - * ID of the DAO to display permissions for. - */ - daoId: string; - /** - * View switcher (list/graph toggle) rendered on the right of the filter row. - */ - viewSwitcher?: ReactNode; -} +type DaoPlugins = IFilterComponentPlugin[] | undefined; -interface IPermissionsAccount { - id: string; - name: string; - network: Network; - daoAddress: string; - avatarSrc?: string; +export interface IPermissionsListProps { + rows: IPermissionRow[]; + accountRefs: IPermissionAccountRef[]; + daoPlugins?: DaoPlugins; + chainId?: number; + isLoading: boolean; + expandedRows: string[]; + onExpandedRowsChange: (rows: string[]) => void; } const SKELETON_ROW_KEYS = [ @@ -71,220 +47,61 @@ const SKELETON_ROW_KEYS = [ 'skeleton-4', ]; -const getRowKey = (row: IPermissionRow): string => +export const getPermissionRowKey = (row: IPermissionRow): string => `${row.permissionId}-${row.whoAddress}-${row.whereAddress}`; export const PermissionsList: React.FC = (props) => { - const { daoId, viewSwitcher } = props; + const { + rows, + accountRefs, + daoPlugins, + chainId, + isLoading, + expandedRows, + onExpandedRowsChange, + } = props; const { t } = useTranslations(); - const { isEnabled } = useFeatureFlags(); - - // The `useMocks` flag drives both the preview permission rows and the - // self-contained "Patito DAO" identity they resolve against. - const isPreview = isEnabled('useMocks'); - - const { data: dao } = useDao({ urlParams: { id: daoId } }); - const realDaoPlugins = useDaoPlugins({ - daoId, - includeLinkedAccounts: true, - }); - const daoPlugins = isPreview ? permissionsPreviewPlugins : realDaoPlugins; - - const realAccounts = useMemo(() => { - if (dao == null) { - return []; - } - - const mainAccount: IPermissionsAccount = { - id: dao.id, - name: dao.name, - network: dao.network, - daoAddress: dao.address, - avatarSrc: ipfsUtils.cidToSrc(dao.avatar), - }; - - const linkedAccounts = dao.linkedAccounts ?? []; - const showLinkedAccounts = - isEnabled('linkedAccount') && linkedAccounts.length > 0; - - if (!showLinkedAccounts) { - return [mainAccount]; - } - - return [ - mainAccount, - ...linkedAccounts.map((account) => ({ - id: account.id, - name: account.name, - network: account.network, - daoAddress: account.address, - avatarSrc: ipfsUtils.cidToSrc(account.avatar), - })), - ]; - }, [dao, isEnabled]); - - const accounts = isPreview ? permissionsPreviewAccounts : realAccounts; - - const [selectedAccountId, setSelectedAccountId] = useState(); - const activeAccountId = selectedAccountId ?? accounts[0]?.id; - - const handleAccountChange = (value: string | string[] | undefined) => { - if (typeof value === 'string') { - setSelectedAccountId(value); - } - }; - const activeAccount = - accounts.find((account) => account.id === activeAccountId) ?? - accounts[0]; - - const accountRefs = useMemo( - () => - accounts.map((account) => ({ - address: account.daoAddress, - name: account.name, - avatarSrc: account.avatarSrc, - })), - [accounts], - ); - - const { data, isLoading } = useAllDaoPermissions( - { - urlParams: { - network: activeAccount?.network as Network, - daoAddress: activeAccount?.daoAddress ?? '', - }, - }, - { enabled: activeAccount != null }, - ); - // NOTE: the optional `condition` field is supplied by the preview mock until - // APP-953 formalizes it on the permissions response; cast at this boundary. - const rows = useMemo(() => { - const rawRows = (data ?? []) as IPermissionRow[]; - const pluginAddresses = (daoPlugins ?? []).map( - (plugin) => plugin.meta.address, - ); - - const linkedAddress = accounts.find( - (account) => account.id !== activeAccount?.id, - )?.daoAddress; - - // Swap preview markers for the viewed DAO's real addresses so the sample - // rows resolve to names/tags/avatars. No-op for real backend data. - const refMap = new Map([ - [ - PermissionsPreviewRef.self.toLowerCase(), - activeAccount?.daoAddress, - ], - [PermissionsPreviewRef.linked.toLowerCase(), linkedAddress], - [PermissionsPreviewRef.plugin0.toLowerCase(), pluginAddresses[0]], - [PermissionsPreviewRef.plugin1.toLowerCase(), pluginAddresses[1]], - ]); - const resolveRef = (address: string): string => - refMap.get(address.toLowerCase()) ?? address; - - return rawRows.map((row) => ({ - ...row, - whoAddress: resolveRef(row.whoAddress), - whereAddress: resolveRef(row.whereAddress), - })); - }, [data, daoPlugins, activeAccount, accounts]); - - const [expandedRows, setExpandedRows] = useState([]); - const allExpanded = rows.length > 0 && expandedRows.length === rows.length; - - const handleToggleAll = () => { - setExpandedRows(allExpanded ? [] : rows.map(getRowKey)); - }; - - const chainId = activeAccount - ? networkDefinitions[activeAccount.network].id - : undefined; - - const renderBody = () => { - if (isLoading) { - return ; - } - - if (rows.length === 0) { - return ( - - ); - } + if (isLoading) { + return ; + } + if (rows.length === 0) { return ( -
- - setExpandedRows(value ?? [])} - value={expandedRows} - > - {rows.map((row) => ( - - ))} - -
+ ); - }; - - const showAccountSelector = accounts.length > 1; - const showExpandAll = !isLoading && rows.length > 0; + } return ( -
-
- {showAccountSelector && ( - - {accounts.map((account) => ( - - ))} - - )} -
- {showExpandAll && ( - - )} - {viewSwitcher} -
-
- {renderBody()} +
+ + onExpandedRowsChange(value ?? [])} + value={expandedRows} + > + {rows.map((row) => ( + + ))} +
); }; -type DaoPlugins = ReturnType; - interface IPermissionsListRowProps { row: IPermissionRow; rowKey: string; diff --git a/apps/app/src/modules/settings/constants/permissionsPreviewData.ts b/apps/app/src/modules/settings/constants/permissionsPreviewData.ts index 2138d8cc08..38ea1c8a11 100644 --- a/apps/app/src/modules/settings/constants/permissionsPreviewData.ts +++ b/apps/app/src/modules/settings/constants/permissionsPreviewData.ts @@ -1,4 +1,5 @@ import { + type IDao, type IDaoPlugin, Network, PluginInterfaceType, @@ -29,6 +30,8 @@ const patitoDaoAddress = '0xf204245b0B05E9A0780761E326552A569c1D6ceb'; const patitoDeveloperDaoAddress = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F'; const coreAddress = '0x1eC50000000000000000000000000000000e8145'; const foundersAddress = '0xf00d00000000000000000000000000000000e845'; +const patitoDaoAvatar = '/patitoDao.png'; +const patitoDeveloperDaoAvatar = '/patitoDeveloperDao.png'; export const permissionsPreviewAccounts: IPermissionsPreviewAccount[] = [ { @@ -36,17 +39,34 @@ export const permissionsPreviewAccounts: IPermissionsPreviewAccount[] = [ name: 'Patito DAO', network: Network.ETHEREUM_MAINNET, daoAddress: patitoDaoAddress, - avatarSrc: '/patitoDao.png', + avatarSrc: patitoDaoAvatar, }, { id: 'patito-developer-dao', name: 'Patito Developer DAO', network: Network.ETHEREUM_MAINNET, daoAddress: patitoDeveloperDaoAddress, - avatarSrc: '/patitoDeveloperDao.png', + avatarSrc: patitoDeveloperDaoAvatar, }, ]; +export const permissionsPreviewDao = { + id: 'patito-dao', + name: 'Patito DAO', + network: Network.ETHEREUM_MAINNET, + address: patitoDaoAddress, + avatar: patitoDaoAvatar, + linkedAccounts: [ + { + id: 'patito-developer-dao', + name: 'Patito Developer DAO', + network: Network.ETHEREUM_MAINNET, + address: patitoDeveloperDaoAddress, + avatar: patitoDeveloperDaoAvatar, + }, + ], +} as unknown as IDao; + export const permissionsPreviewPlugins: IFilterComponentPlugin[] = [ { id: 'core', diff --git a/apps/app/src/modules/settings/hooks/usePermissionsData/index.ts b/apps/app/src/modules/settings/hooks/usePermissionsData/index.ts new file mode 100644 index 0000000000..e344ad57da --- /dev/null +++ b/apps/app/src/modules/settings/hooks/usePermissionsData/index.ts @@ -0,0 +1,6 @@ +export { + type IPermissionsDataAccount, + type IUsePermissionsDataParams, + type IUsePermissionsDataResult, + usePermissionsData, +} from './usePermissionsData'; diff --git a/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts b/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts new file mode 100644 index 0000000000..b3c8060b96 --- /dev/null +++ b/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts @@ -0,0 +1,120 @@ +import { act, renderHook } from '@testing-library/react'; +import * as daoService from '@/shared/api/daoService'; +import { type IDao, Network } from '@/shared/api/daoService'; +import * as featureFlagsProvider from '@/shared/components/featureFlagsProvider'; +import * as useDaoPluginsModule from '@/shared/hooks/useDaoPlugins'; +import { + generateDao, + generateDaoMetrics, + generateReactQueryResultSuccess, +} from '@/shared/testUtils'; +import { permissionsPreviewAccounts } from '../../constants/permissionsPreviewData'; +import { usePermissionsData } from './usePermissionsData'; + +describe('usePermissionsData hook', () => { + const useDaoSpy = jest.spyOn(daoService, 'useDao'); + const useAllDaoPermissionsSpy = jest.spyOn( + daoService, + 'useAllDaoPermissions', + ); + const useDaoPluginsSpy = jest.spyOn(useDaoPluginsModule, 'useDaoPlugins'); + const useFeatureFlagsSpy = jest.spyOn( + featureFlagsProvider, + 'useFeatureFlags', + ); + + const setFeatureFlags = (enabled: Record) => { + useFeatureFlagsSpy.mockReturnValue({ + isEnabled: (key: string) => enabled[key] ?? false, + } as ReturnType); + }; + + const setDao = (dao?: Partial) => { + useDaoSpy.mockReturnValue( + generateReactQueryResultSuccess({ + data: generateDao(dao), + }) as ReturnType, + ); + }; + + beforeEach(() => { + setFeatureFlags({}); + setDao(); + useAllDaoPermissionsSpy.mockReturnValue({ + data: [], + isLoading: false, + error: null, + refetch: jest.fn(), + } as ReturnType); + useDaoPluginsSpy.mockReturnValue([]); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('re-queries permissions with the selected account params on switch', () => { + setFeatureFlags({ linkedAccount: true }); + setDao({ + id: 'main-dao', + address: '0xMainAddress', + network: Network.ETHEREUM_MAINNET, + name: 'Main DAO', + linkedAccounts: [ + { + id: 'linked-1', + address: '0xLinkedAddress', + network: Network.POLYGON_MAINNET, + name: 'Linked Treasury', + description: '', + ens: null, + subdomain: null, + avatar: null, + metrics: generateDaoMetrics(), + links: [], + blockTimestamp: 0, + transactionHash: '', + }, + ], + }); + + const { result } = renderHook(() => + usePermissionsData({ daoId: 'main-dao' }), + ); + + expect(useAllDaoPermissionsSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + urlParams: { + network: Network.ETHEREUM_MAINNET, + daoAddress: '0xMainAddress', + }, + }), + expect.anything(), + ); + + act(() => { + result.current.setSelectedAccountId('linked-1'); + }); + + expect(useAllDaoPermissionsSpy).toHaveBeenLastCalledWith( + expect.objectContaining({ + urlParams: { + network: Network.POLYGON_MAINNET, + daoAddress: '0xLinkedAddress', + }, + }), + expect.anything(), + ); + }); + + it('uses the Patito preview identity when the mocks flag is on', () => { + setFeatureFlags({ useMocks: true }); + + const { result } = renderHook(() => + usePermissionsData({ daoId: 'any-dao' }), + ); + + expect(result.current.accounts).toEqual(permissionsPreviewAccounts); + expect(result.current.dao?.name).toBe('Patito DAO'); + }); +}); diff --git a/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts b/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts new file mode 100644 index 0000000000..00124f38ae --- /dev/null +++ b/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts @@ -0,0 +1,172 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { + type IDao, + type IDaoPlugin, + type Network, + useAllDaoPermissions, + useDao, +} from '@/shared/api/daoService'; +import { useFeatureFlags } from '@/shared/components/featureFlagsProvider'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; +import { useDaoPlugins } from '@/shared/hooks/useDaoPlugins'; +import { ipfsUtils } from '@/shared/utils/ipfsUtils'; +import { + permissionsPreviewAccounts, + permissionsPreviewDao, + permissionsPreviewPlugins, +} from '../../constants/permissionsPreviewData'; +import { PermissionsPreviewRef } from '../../constants/permissionsPreviewRefs'; +import type { IPermissionRow } from '../../types'; +import type { IPermissionAccountRef } from '../../utils/permissionEntityUtils'; + +export interface IPermissionsDataAccount { + id: string; + name: string; + network: Network; + daoAddress: string; + avatarSrc?: string; +} + +export interface IUsePermissionsDataParams { + daoId: string; +} + +export interface IUsePermissionsDataResult { + dao?: IDao; + accounts: IPermissionsDataAccount[]; + activeAccountId?: string; + setSelectedAccountId: (id: string) => void; + activeAccount?: IPermissionsDataAccount; + accountRefs: IPermissionAccountRef[]; + daoPlugins?: IFilterComponentPlugin[]; + rows: IPermissionRow[]; + chainId?: number; + isLoading: boolean; +} + +export const usePermissionsData = ( + params: IUsePermissionsDataParams, +): IUsePermissionsDataResult => { + const { daoId } = params; + + const { isEnabled } = useFeatureFlags(); + const isPreview = isEnabled('useMocks'); + + const { data: realDao } = useDao({ urlParams: { id: daoId } }); + const realDaoPlugins = useDaoPlugins({ + daoId, + includeLinkedAccounts: true, + }); + + const dao = isPreview ? permissionsPreviewDao : realDao; + const daoPlugins = isPreview ? permissionsPreviewPlugins : realDaoPlugins; + + const realAccounts = useMemo(() => { + if (realDao == null) { + return []; + } + + const mainAccount: IPermissionsDataAccount = { + id: realDao.id, + name: realDao.name, + network: realDao.network, + daoAddress: realDao.address, + avatarSrc: ipfsUtils.cidToSrc(realDao.avatar), + }; + + const linkedAccounts = realDao.linkedAccounts ?? []; + const showLinkedAccounts = + isEnabled('linkedAccount') && linkedAccounts.length > 0; + + if (!showLinkedAccounts) { + return [mainAccount]; + } + + return [ + mainAccount, + ...linkedAccounts.map((account) => ({ + id: account.id, + name: account.name, + network: account.network, + daoAddress: account.address, + avatarSrc: ipfsUtils.cidToSrc(account.avatar), + })), + ]; + }, [realDao, isEnabled]); + + const accounts = isPreview ? permissionsPreviewAccounts : realAccounts; + + const [selectedAccountId, setSelectedAccountId] = useState(); + const activeAccountId = selectedAccountId ?? accounts[0]?.id; + const activeAccount = + accounts.find((account) => account.id === activeAccountId) ?? + accounts[0]; + + const accountRefs = useMemo( + () => + accounts.map((account) => ({ + address: account.daoAddress, + name: account.name, + avatarSrc: account.avatarSrc, + })), + [accounts], + ); + + const { data, isLoading } = useAllDaoPermissions( + { + urlParams: { + network: activeAccount?.network as Network, + daoAddress: activeAccount?.daoAddress ?? '', + }, + }, + { enabled: activeAccount != null }, + ); + + const rows = useMemo(() => { + const rawRows = (data ?? []) as IPermissionRow[]; + const pluginAddresses = (daoPlugins ?? []).map( + (plugin) => plugin.meta.address, + ); + const linkedAddress = accounts.find( + (account) => account.id !== activeAccount?.id, + )?.daoAddress; + + const refMap = new Map([ + [ + PermissionsPreviewRef.self.toLowerCase(), + activeAccount?.daoAddress, + ], + [PermissionsPreviewRef.linked.toLowerCase(), linkedAddress], + [PermissionsPreviewRef.plugin0.toLowerCase(), pluginAddresses[0]], + [PermissionsPreviewRef.plugin1.toLowerCase(), pluginAddresses[1]], + ]); + const resolveRef = (address: string): string => + refMap.get(address.toLowerCase()) ?? address; + + return rawRows.map((row) => ({ + ...row, + whoAddress: resolveRef(row.whoAddress), + whereAddress: resolveRef(row.whereAddress), + })); + }, [data, daoPlugins, accounts, activeAccount]); + + const chainId = activeAccount + ? networkDefinitions[activeAccount.network].id + : undefined; + + return { + dao, + accounts, + activeAccountId, + setSelectedAccountId, + activeAccount, + accountRefs, + daoPlugins, + rows, + chainId, + isLoading, + }; +}; diff --git a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx index 1b2ce98dfe..c06ce01272 100644 --- a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx +++ b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx @@ -1,12 +1,18 @@ 'use client'; -import { Toggle, ToggleGroup } from '@aragon/gov-ui-kit'; +import { Button, Toggle, ToggleGroup } from '@aragon/gov-ui-kit'; import { useState } from 'react'; import { useDao } from '@/shared/api/daoService'; import { Page } from '@/shared/components/page'; import { useTranslations } from '@/shared/components/translationsProvider'; +import { useFilterUrlParam } from '@/shared/hooks/useFilterUrlParam'; import { daoUtils } from '@/shared/utils/daoUtils'; -import { PermissionsList } from '../../components/permissionsList'; +import { PermissionsGraph } from '../../components/permissionsGraph'; +import { + getPermissionRowKey, + PermissionsList, +} from '../../components/permissionsList'; +import { usePermissionsData } from '../../hooks/usePermissionsData'; export interface IDaoPermissionsPageClientProps { /** @@ -15,7 +21,14 @@ export interface IDaoPermissionsPageClientProps { daoId: string; } -type PermissionsView = 'list' | 'graph'; +export const permissionsViewParam = 'permissionsview'; + +enum PermissionsView { + LIST = 'list', + GRAPH = 'graph', +} + +const permissionsViews = Object.values(PermissionsView); export const DaoPermissionsPageClient: React.FC< IDaoPermissionsPageClientProps @@ -26,15 +39,47 @@ export const DaoPermissionsPageClient: React.FC< const { data: dao } = useDao({ urlParams: { id: daoId } }); - // Graph view is out of scope for now (T05 shell); only the list view is wired up. - const [view, setView] = useState('list'); + const { + dao: permissionsDao, + accounts, + activeAccountId, + activeAccount, + setSelectedAccountId, + accountRefs, + daoPlugins, + rows, + chainId, + isLoading, + } = usePermissionsData({ daoId }); + + const [view, setView] = useFilterUrlParam({ + name: permissionsViewParam, + fallbackValue: PermissionsView.LIST, + validValues: permissionsViews, + enableUrlUpdate: true, + }); - const handleViewChange = (value: string | string[] | undefined) => { - if (value === 'list' || value === 'graph') { + const [expandedRows, setExpandedRows] = useState([]); + + const handleViewChange = (value?: string | string[]) => { + if (typeof value === 'string' && value) { setView(value); } }; + const handleAccountChange = (value?: string | string[]) => { + if (typeof value === 'string' && value) { + setSelectedAccountId(value); + setExpandedRows([]); + } + }; + + const allExpanded = rows.length > 0 && expandedRows.length === rows.length; + + const handleToggleAll = () => { + setExpandedRows(allExpanded ? [] : rows.map(getPermissionRowKey)); + }; + const pageBreadcrumbs = [ { href: daoUtils.getDaoUrl(dao, 'settings'), @@ -49,6 +94,10 @@ export const DaoPermissionsPageClient: React.FC< }, ]; + const isListView = view === PermissionsView.LIST; + const showAccountSelector = accounts.length > 1; + const showExpandAll = isListView && !isLoading && rows.length > 0; + return ( <> - {view === 'list' && ( - +
+ {showAccountSelector && ( + + {accounts.map((account) => ( + + ))} + + )} +
+ {showExpandAll && ( + + )} - } - /> - )} +
+
+ {isListView ? ( + + ) : ( + + )} +
diff --git a/apps/app/src/modules/settings/types/index.ts b/apps/app/src/modules/settings/types/index.ts index d6aa45df8e..8e50dbe6e7 100644 --- a/apps/app/src/modules/settings/types/index.ts +++ b/apps/app/src/modules/settings/types/index.ts @@ -2,6 +2,12 @@ export type { IBuildPreparePluginUpdateDataParams } from './buildPreparePluginUp export type { IDaoPolicyDetailsPageParams } from './daoPolicyDetailsPageParams'; export type { IDaoProcessDetailsPageParams } from './daoProcessDetailsPageParams'; export type { IGetUninstallHelpersParams } from './getUninstallHelpersParams'; +export type { + IPermissionGraph, + IPermissionGraphEdge, + IPermissionGraphNode, + PermissionNodeKind, +} from './permissionGraph'; export type { IConditionData, IPermissionRow } from './permissionRow'; export type { IPluginToFormDataParams } from './pluginToFormDataParams'; export type { IUseGovernanceSettingsParams } from './useGovernanceSettingsParams'; diff --git a/apps/app/src/modules/settings/types/permissionGraph.ts b/apps/app/src/modules/settings/types/permissionGraph.ts new file mode 100644 index 0000000000..7fa4e6ad6c --- /dev/null +++ b/apps/app/src/modules/settings/types/permissionGraph.ts @@ -0,0 +1,26 @@ +import type { IPermissionRow } from './permissionRow'; + +export type PermissionNodeKind = 'dao' | 'linkedDao' | 'plugin' | 'actor'; + +export interface IPermissionGraphNode { + id: string; + kind: PermissionNodeKind; + label: string; + tag?: string; + avatarSrc?: string; + address: string; +} + +export interface IPermissionGraphEdge { + id: string; + source: string; + target: string; + permissionName: string; + conditionLabel?: string; + row: IPermissionRow; +} + +export interface IPermissionGraph { + nodes: IPermissionGraphNode[]; + edges: IPermissionGraphEdge[]; +} diff --git a/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.test.ts b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.test.ts new file mode 100644 index 0000000000..09a86ebc35 --- /dev/null +++ b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.test.ts @@ -0,0 +1,133 @@ +import type { IDaoPlugin } from '@/shared/api/daoService'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; +import { + generateDao, + generateFilterComponentPlugin, + generateLinkedAccount, +} from '@/shared/testUtils/generators'; +import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; +import type { IPermissionRow } from '../../types'; +import { buildPermissionGraph } from './buildPermissionGraph'; + +const ROOT_PERMISSION_ID = + '0x815fe80e4b37c8582a3b773d1d7071f983eacfd56b5965db654f3087c25ada33'; +const EXECUTE_PERMISSION_ID = + '0xbf04b4486c9663d805744005c3da000eda93de6e3308a4a7a812eb565327b78d'; + +const daoAddress = '0x1F2e3D4C5b6A70819283746556473829100AbCdE'; +const pluginAddress = '0xA1b2C3d4E5F60718293A4b5C6d7E8f9001234567'; +const linkedDaoAddress = '0xdEAD000000000000000042069420694206942069'; +const conditionAddress = '0xC0Ffee254729296a45a3885639AC7E10F9d54979'; + +const daoPlugins = [ + generateFilterComponentPlugin({ + meta: { + address: pluginAddress, + name: 'Founders', + interfaceType: 'multisig', + } as IDaoPlugin, + }), +] satisfies IFilterComponentPlugin[]; + +const dao = generateDao({ + address: daoAddress, + name: 'Patito DAO', + avatar: 'https://patito.png', + linkedAccounts: [ + generateLinkedAccount({ + address: linkedDaoAddress, + name: 'Patito Developer DAO', + avatar: 'https://patito-dev.png', + }), + ], +}); + +const accountRefs = [ + { + address: daoAddress, + name: 'Patito DAO', + avatarSrc: 'https://patito.png', + }, + { + address: linkedDaoAddress, + name: 'Patito Developer DAO', + avatarSrc: 'https://patito-dev.png', + }, +]; + +const buildRow = (partial: Partial): IPermissionRow => ({ + permissionId: EXECUTE_PERMISSION_ID, + whoAddress: pluginAddress, + whereAddress: daoAddress, + conditionAddress: ALLOW_FLAG, + ...partial, +}); + +describe('buildPermissionGraph', () => { + it('classifies DAO, linked DAO, plugin, and actor nodes', () => { + const graph = buildPermissionGraph({ + rows: [ + buildRow({ whoAddress: pluginAddress }), + buildRow({ + permissionId: ROOT_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: linkedDaoAddress, + }), + ], + dao, + daoPlugins, + accountRefs, + }); + + expect(graph.nodes.find((node) => node.kind === 'dao')).toMatchObject({ + label: 'Patito DAO', + avatarSrc: 'https://patito.png', + }); + expect( + graph.nodes.find((node) => node.kind === 'linkedDao'), + ).toMatchObject({ + label: 'Patito Developer DAO', + avatarSrc: 'https://patito-dev.png', + }); + expect( + graph.nodes.find((node) => node.kind === 'plugin'), + ).toMatchObject({ label: 'Founders', tag: 'MULTISIG' }); + expect(graph.nodes.find((node) => node.kind === 'actor')).toMatchObject( + { label: 'Anyone' }, + ); + }); + + it('creates who-to-where edges with resolved permission and condition labels', () => { + const row = buildRow({ + conditionAddress, + condition: { conditionType: 'voting-power' }, + }); + + const graph = buildPermissionGraph({ + rows: [row], + dao, + daoPlugins, + accountRefs, + }); + + expect(graph.edges).toHaveLength(1); + expect(graph.edges[0]).toMatchObject({ + source: pluginAddress.toLowerCase(), + target: daoAddress.toLowerCase(), + permissionName: 'EXECUTE_PERMISSION', + conditionLabel: 'VotingPower', + row, + }); + }); + + it('omits condition labels for unconditional grants', () => { + const graph = buildPermissionGraph({ + rows: [buildRow({ conditionAddress: ALLOW_FLAG })], + dao, + daoPlugins, + accountRefs, + }); + + expect(graph.edges[0].conditionLabel).toBeUndefined(); + }); +}); diff --git a/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts new file mode 100644 index 0000000000..9fb20d3a00 --- /dev/null +++ b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts @@ -0,0 +1,123 @@ +import { addressUtils } from '@aragon/gov-ui-kit'; +import type { IDao, IDaoPlugin } from '@/shared/api/daoService'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; +import { permissionNameUtils } from '@/shared/utils/permissionNameUtils'; +import type { + IPermissionGraph, + IPermissionGraphEdge, + IPermissionGraphNode, + IPermissionRow, +} from '../../types'; +import { conditionTypeUtils } from '../conditionTypeUtils'; +import { + type IPermissionAccountRef, + permissionEntityUtils, +} from '../permissionEntityUtils'; + +const NO_CONDITION_LABEL = '-'; + +export interface IBuildPermissionGraphParams { + rows: IPermissionRow[]; + dao: IDao; + daoPlugins?: IFilterComponentPlugin[]; + accountRefs?: IPermissionAccountRef[]; +} + +const resolveNode = ( + address: string, + dao: IDao, + daoPlugins?: IFilterComponentPlugin[], + accountRefs?: IPermissionAccountRef[], +): IPermissionGraphNode => { + const id = address.toLowerCase(); + + if (addressUtils.isAddressEqual(address, dao.address)) { + const account = accountRefs?.find((item) => + addressUtils.isAddressEqual(item.address, address), + ); + + return { + id, + kind: 'dao', + label: account?.name ?? dao.name, + avatarSrc: account?.avatarSrc ?? dao.avatar ?? undefined, + address, + }; + } + + const linkedAccount = accountRefs?.find((item) => + addressUtils.isAddressEqual(item.address, address), + ); + + if (linkedAccount != null) { + return { + id, + kind: 'linkedDao', + label: linkedAccount.name, + avatarSrc: linkedAccount.avatarSrc, + address, + }; + } + + const entity = permissionEntityUtils.resolvePermissionEntity(address, { + daoPlugins, + accounts: accountRefs, + }); + + if (entity.type === 'plugin') { + return { + id, + kind: 'plugin', + label: entity.label, + tag: entity.tag, + address, + }; + } + + return { id, kind: 'actor', label: entity.label, address }; +}; + +const resolveEdge = (row: IPermissionRow): IPermissionGraphEdge => { + const conditionType = conditionTypeUtils.resolveConditionType( + row.conditionAddress, + row.condition, + ); + const conditionLabel = conditionTypeUtils.getConditionLabel(conditionType); + + return { + id: `${row.permissionId}-${row.whoAddress.toLowerCase()}-${row.whereAddress.toLowerCase()}`, + source: row.whoAddress.toLowerCase(), + target: row.whereAddress.toLowerCase(), + permissionName: permissionNameUtils.getPermissionName(row.permissionId), + conditionLabel: + conditionLabel === NO_CONDITION_LABEL ? undefined : conditionLabel, + row, + }; +}; + +export const buildPermissionGraph = ( + params: IBuildPermissionGraphParams, +): IPermissionGraph => { + const { rows, dao, daoPlugins, accountRefs } = params; + const nodesById = new Map(); + + const ensureNode = (address: string): void => { + const id = address.toLowerCase(); + + if (!nodesById.has(id)) { + nodesById.set( + id, + resolveNode(address, dao, daoPlugins, accountRefs), + ); + } + }; + + const edges = rows.map((row) => { + ensureNode(row.whoAddress); + ensureNode(row.whereAddress); + + return resolveEdge(row); + }); + + return { nodes: [...nodesById.values()], edges }; +}; diff --git a/apps/app/src/modules/settings/utils/buildPermissionGraph/index.ts b/apps/app/src/modules/settings/utils/buildPermissionGraph/index.ts new file mode 100644 index 0000000000..bfc863f2c9 --- /dev/null +++ b/apps/app/src/modules/settings/utils/buildPermissionGraph/index.ts @@ -0,0 +1,4 @@ +export { + buildPermissionGraph, + type IBuildPermissionGraphParams, +} from './buildPermissionGraph'; diff --git a/apps/app/src/shared/api/daoService/domain/daoPermission.ts b/apps/app/src/shared/api/daoService/domain/daoPermission.ts index f988b20dd1..c16a0968d4 100644 --- a/apps/app/src/shared/api/daoService/domain/daoPermission.ts +++ b/apps/app/src/shared/api/daoService/domain/daoPermission.ts @@ -1,3 +1,18 @@ +export interface IDaoPermissionCondition { + /** + * Backend condition discriminator, e.g. `voting-power`, `membership`, + * `execute-selector`, or `unknown`. + */ + conditionType: string; + token?: string; + minVotingPower?: string; + onlyListed?: boolean; + minApprovals?: number; + selectors?: Array; + targets?: string[]; + [key: string]: unknown; +} + export interface IDaoPermission { /** * Pemission ID. keccak256 hash of a permission string. @@ -17,4 +32,8 @@ export interface IDaoPermission { * `IPermissionCondition` contract implementation to be used. */ conditionAddress: string; + /** + * Enriched condition details returned by the backend when available. + */ + condition?: IDaoPermissionCondition; } From bca64b94b9d43420f09361ac8e8026c59bd38edc Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Wed, 15 Jul 2026 11:00:24 +0200 Subject: [PATCH 02/55] feat(APP-942): finish permissions graph view --- apps/app/package.json | 2 + apps/app/src/assets/locales/en.json | 10 +- apps/app/src/backendApiMocks.ts | 3 +- .../domain/allowedAction.ts | 8 + .../components/daoHierarchy/daoHierarchy.tsx | 7 +- .../daoSettingsInfo/daoSettingsInfo.test.tsx | 29 - .../daoSettingsInfo/daoSettingsInfo.tsx | 31 +- .../executeSelectorConditionSlot.test.tsx | 8 +- .../executeSelectorConditionSlot.tsx | 231 ++++++- .../components/permissionsGraph/index.ts | 1 + .../permissionsGraph/permissionGraphEdge.tsx | 65 ++ .../permissionsGraph/permissionGraphNode.tsx | 207 ++++++ .../permissionsGraph/permissionsGraph.tsx | 637 ++++++------------ .../permissionsGraphCanvas.tsx | 624 +++++++++++++++++ .../permissionsList/permissionsList.test.tsx | 38 ++ .../permissionsList/permissionsList.tsx | 64 +- .../settings/constants/permissionsMocks.ts | 118 ---- .../constants/permissionsPreviewData.ts | 97 --- .../constants/permissionsPreviewRefs.ts | 21 - .../usePermissionsData.test.ts | 20 +- .../usePermissionsData/usePermissionsData.ts | 40 +- .../daoPermissionsPage/daoPermissionsPage.tsx | 6 - .../daoPermissionsPageClient.tsx | 111 ++- .../conditionTypeUtils.test.ts | 4 +- .../conditionTypeUtils/conditionTypeUtils.ts | 25 +- .../utils/conditionTypeUtils/index.ts | 6 +- .../utils/permissionGraphLayout/index.ts | 5 + .../permissionGraphLayout.ts | 74 ++ .../api/daoService/domain/daoPermission.ts | 6 + pnpm-lock.yaml | 162 +++++ 30 files changed, 1826 insertions(+), 834 deletions(-) create mode 100644 apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx create mode 100644 apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx create mode 100644 apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx delete mode 100644 apps/app/src/modules/settings/constants/permissionsMocks.ts delete mode 100644 apps/app/src/modules/settings/constants/permissionsPreviewData.ts delete mode 100644 apps/app/src/modules/settings/constants/permissionsPreviewRefs.ts create mode 100644 apps/app/src/modules/settings/utils/permissionGraphLayout/index.ts create mode 100644 apps/app/src/modules/settings/utils/permissionGraphLayout/permissionGraphLayout.ts diff --git a/apps/app/package.json b/apps/app/package.json index ee529afa60..d9b4f7ee2d 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -34,6 +34,7 @@ "@aragon/assistant-chat": "workspace:*", "@aragon/assistant-contracts": "workspace:*", "@aragon/gov-ui-kit": "catalog:", + "@dagrejs/dagre": "^3.0.0", "@floating-ui/react": "^0.27.19", "@number-flow/react": "^0.6.0", "@radix-ui/react-dialog": "catalog:", @@ -49,6 +50,7 @@ "@vercel/speed-insights": "^2.0.0", "@walletconnect/core": "^2.23.9", "@walletconnect/utils": "^2.23.9", + "@xyflow/react": "^12.11.2", "classnames": "catalog:", "deepmerge-ts": "^7.1.5", "framer-motion": "^12.40.0", diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index c23b3f57dc..09df1228f0 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -3555,7 +3555,9 @@ "dao": "Primary DAO", "linkedDao": "Linked DAO", "plugin": "Aragon OSx Plugin", - "actor": "Any address" + "actor": "Any address", + "who": "Who", + "where": "Where" }, "edge": { "condition": "if {{condition}}" @@ -3585,7 +3587,7 @@ "noCondition": "No condition" }, "condition": { - "heading": "Condition detail" + "heading": "Condition" }, "expandAll": "Expand all", "collapseAll": "Collapse all", @@ -3598,6 +3600,10 @@ "heading": "No condition", "description": "Functions guarded by this permission can be called by the granted address directly." }, + "unrecognizedConditionSlot": { + "heading": "Unrecognized condition", + "description": "This permission references a condition contract, but its condition data could not be resolved." + }, "votingPowerConditionSlot": { "token": "Token", "minVotingPower": "Minimum voting power" diff --git a/apps/app/src/backendApiMocks.ts b/apps/app/src/backendApiMocks.ts index b59494841b..1ce3b10fe0 100644 --- a/apps/app/src/backendApiMocks.ts +++ b/apps/app/src/backendApiMocks.ts @@ -1,4 +1,3 @@ -import { permissionsMocks } from './modules/settings/constants/permissionsMocks'; import type { IBackendApiMock } from './shared/types'; -export const backendApiMocks: IBackendApiMock[] = [...permissionsMocks]; +export const backendApiMocks: IBackendApiMock[] = []; 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 d5233a8d53..6dad6d5246 100644 --- a/apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts +++ b/apps/app/src/modules/governance/api/executeSelectorsService/domain/allowedAction.ts @@ -1,6 +1,14 @@ import type { IAllowedActionDecoded } from './allowedActionDecoded'; export interface IAllowedAction { + /** + * Unique backend identifier of the allowed-action event. + */ + id: string; + /** + * Address of the condition contract that gates this action. + */ + conditionAddress: string; /** * Selector of the allowed action. `null` means native transfer. */ diff --git a/apps/app/src/modules/settings/components/daoHierarchy/daoHierarchy.tsx b/apps/app/src/modules/settings/components/daoHierarchy/daoHierarchy.tsx index 35015ddd27..7894ce3625 100644 --- a/apps/app/src/modules/settings/components/daoHierarchy/daoHierarchy.tsx +++ b/apps/app/src/modules/settings/components/daoHierarchy/daoHierarchy.tsx @@ -12,7 +12,6 @@ import { } from '@aragon/gov-ui-kit'; import type { IDao, ILinkedAccountSummary } from '@/shared/api/daoService'; import { DaoTypeTag } from '@/shared/components/daoTypeTag'; -import { useFeatureFlags } from '@/shared/components/featureFlagsProvider'; import { ResourceLink } from '@/shared/components/resourceLink'; import { useTranslations } from '@/shared/components/translationsProvider'; import { networkDefinitions } from '@/shared/constants/networkDefinitions'; @@ -157,8 +156,6 @@ const DaoInfo: React.FC = ({ dao, permissionsHref }) => { export const DaoHierarchy: React.FC = (props) => { const { dao, currentDaoId } = props; - const { isEnabled } = useFeatureFlags(); - const isViewingMainDao = dao.id === currentDaoId; const hasLinkedAccounts = dao.linkedAccounts != null && dao.linkedAccounts.length > 0; @@ -166,9 +163,7 @@ export const DaoHierarchy: React.FC = (props) => { const getDaoAvatar = (d: IDao | ILinkedAccountSummary) => ipfsUtils.cidToSrc(d.avatar); - const permissionsHref = isEnabled('permissionsPage') - ? daoUtils.getDaoUrl(dao, 'settings/permissions') - : undefined; + const permissionsHref = daoUtils.getDaoUrl(dao, 'settings/permissions'); // If viewing main DAO with linked accounts, show accordion structure if (isViewingMainDao && hasLinkedAccounts) { diff --git a/apps/app/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.test.tsx b/apps/app/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.test.tsx index 66ad0579e2..603e1197ba 100644 --- a/apps/app/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.test.tsx +++ b/apps/app/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.test.tsx @@ -2,7 +2,6 @@ import type * as GovUiKit from '@aragon/gov-ui-kit'; import { GukModulesProvider } from '@aragon/gov-ui-kit'; import { render, screen } from '@testing-library/react'; import { Network } from '@/shared/api/daoService'; -import * as featureFlagsProvider from '@/shared/components/featureFlagsProvider'; import { generateDao } from '@/shared/testUtils'; import { ipfsUtils } from '@/shared/utils/ipfsUtils'; import { DaoSettingsInfo, type IDaoSettingsInfoProps } from './daoSettingsInfo'; @@ -15,25 +14,6 @@ jest.mock('@aragon/gov-ui-kit', () => ({ })); describe(' component', () => { - const useFeatureFlagsSpy = jest.spyOn( - featureFlagsProvider, - 'useFeatureFlags', - ); - - const setPermissionsPageEnabled = (enabled: boolean) => { - useFeatureFlagsSpy.mockReturnValue({ - isEnabled: (key) => key === 'permissionsPage' && enabled, - } as ReturnType); - }; - - beforeEach(() => { - setPermissionsPageEnabled(true); - }); - - afterEach(() => { - useFeatureFlagsSpy.mockReset(); - }); - const createTestComponent = (props?: Partial) => { const completeProps: IDaoSettingsInfoProps = { dao: generateDao(), @@ -99,15 +79,6 @@ describe(' component', () => { ); }); - it('does not render the permissions link when the flag is disabled', () => { - setPermissionsPageEnabled(false); - render(createTestComponent()); - - expect( - screen.queryByText(/daoSettingsInfo.permissionsLink/), - ).not.toBeInTheDocument(); - }); - it('renders the correct definition values of the dao', () => { const dao = generateDao({ name: 'Some DAO', diff --git a/apps/app/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.tsx b/apps/app/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.tsx index ceb370f370..6d6ba08bd8 100644 --- a/apps/app/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.tsx +++ b/apps/app/src/modules/settings/components/daoSettingsInfo/daoSettingsInfo.tsx @@ -8,7 +8,6 @@ import { Tag, } from '@aragon/gov-ui-kit'; import type { IDao } from '@/shared/api/daoService'; -import { useFeatureFlags } from '@/shared/components/featureFlagsProvider'; import { ResourceLink } from '@/shared/components/resourceLink'; import { useTranslations } from '@/shared/components/translationsProvider'; import { networkDefinitions } from '@/shared/constants/networkDefinitions'; @@ -26,7 +25,6 @@ export interface IDaoSettingsInfoProps { export const DaoSettingsInfo: React.FC = (props) => { const { dao } = props; const { t } = useTranslations(); - const { isEnabled } = useFeatureFlags(); const daoAvatar = ipfsUtils.cidToSrc(dao.avatar); @@ -112,23 +110,18 @@ export const DaoSettingsInfo: React.FC = (props) => { )} - {isEnabled('permissionsPage') && ( - - {t('app.settings.daoSettingsInfo.permissionsLink')} - - )} + + {t('app.settings.daoSettingsInfo.permissionsLink')} + ); diff --git a/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx index 5f018eb8d2..b7d77bab70 100644 --- a/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx +++ b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx @@ -31,9 +31,9 @@ describe(' component', () => { expect( screen.getByText(/executeSelectorConditionSlot.description/), ).toBeInTheDocument(); - expect(screen.getByText('0xa9059cbb')).toBeInTheDocument(); + expect(screen.getAllByText('0xa9059cbb')).toHaveLength(2); expect(screen.getByText('0x0bA4…a2e5')).toBeInTheDocument(); - expect(screen.getByText('0x23b872dd')).toBeInTheDocument(); + expect(screen.getAllByText('0x23b872dd')).toHaveLength(2); expect(screen.getByText('0xDe0B…7BAe')).toBeInTheDocument(); }); @@ -50,7 +50,7 @@ describe(' component', () => { createTestComponent({ selectors: ['0xaaaaaaaa', 42, null, ''] }), ); - expect(screen.getByText('0xaaaaaaaa')).toBeInTheDocument(); + expect(screen.getAllByText('0xaaaaaaaa')).toHaveLength(2); expect( screen.queryByText(/executeSelectorConditionSlot.noActions/), ).not.toBeInTheDocument(); @@ -59,7 +59,7 @@ describe(' component', () => { it('renders a placeholder target when no matching target is provided', () => { render(createTestComponent({ selectors: ['0xaaaaaaaa'], targets: [] })); - expect(screen.getByText('0xaaaaaaaa')).toBeInTheDocument(); + expect(screen.getAllByText('0xaaaaaaaa')).toHaveLength(2); expect(screen.getByText('—')).toBeInTheDocument(); }); }); diff --git a/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx index 5e1790661a..3ac05465d8 100644 --- a/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx +++ b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx @@ -1,13 +1,37 @@ 'use client'; -import { addressUtils, DefinitionList } from '@aragon/gov-ui-kit'; +import { + addressUtils, + ChainEntityType, + Link, + StateSkeletonBar, + useBlockExplorer, +} from '@aragon/gov-ui-kit'; +import type { IAllowedAction } from '@/modules/governance/api/executeSelectorsService'; +import { useAllowedActions } from '@/modules/governance/api/executeSelectorsService'; import type { IConditionData } from '@/modules/settings/types'; +import type { Network } from '@/shared/api/daoService'; import { useTranslations } from '@/shared/components/translationsProvider'; import { stringUtils } from '@/shared/utils/stringUtils'; const EMPTY_VALUE = '—'; -interface IAllowedAction { +interface IRawAllowedAction { + selector: string | null; + target: string; +} + +interface IExecuteSelectorConditionSlotProps extends IConditionData { + chainId?: number; + conditionAddress?: string; + network?: Network; + pluginAddress?: string; +} + +interface IAllowedActionView { + contractName?: string; + functionName?: string; + id: string; selector: string | null; target: string; } @@ -25,7 +49,7 @@ const toTargetList = (value: unknown): string[] => const toAllowedActions = ( selectors: unknown, targets: unknown, -): IAllowedAction[] => { +): IRawAllowedAction[] => { const selectorList = toSelectorList(selectors); const targetList = toTargetList(targets); @@ -35,38 +59,197 @@ const toAllowedActions = ( })); }; +const hasDecodedAllowedAction = ( + action: IAllowedAction, + rawActions: IRawAllowedAction[], + conditionAddress?: string, +) => { + const matchesCondition = + conditionAddress == null || + addressUtils.isAddressEqual(action.conditionAddress, conditionAddress); + + if (!matchesCondition) { + return false; + } + + if (rawActions.length === 0) { + return true; + } + + return rawActions.some( + (rawAction) => + rawAction.selector === action.selector && + addressUtils.isAddressEqual(rawAction.target, action.target), + ); +}; + +const AllowedActionsList: React.FC<{ + actions: IAllowedActionView[]; + chainId?: number; +}> = ({ actions, chainId }) => { + const { t } = useTranslations(); + const { buildEntityUrl } = useBlockExplorer({ chainId }); + + return ( +
+ {actions.map((action) => ( +
+
+ + {action.functionName ?? + action.selector ?? + t( + 'app.settings.executeSelectorConditionSlot.anySelector', + )} + + {action.selector != null && ( + + {action.selector} + + )} +
+
+ + {action.contractName ?? + t( + 'app.settings.executeSelectorConditionSlot.unknownContract', + )} + + {action.target === EMPTY_VALUE ? ( + {EMPTY_VALUE} + ) : ( + + {addressUtils.truncateAddress(action.target)} + + )} +
+
+ ))} +
+ ); +}; + +const AllowedActionsSkeleton: React.FC = () => ( +
+ + + +
+); + +interface IDecodedAllowedActionsListProps { + chainId?: number; + conditionAddress?: string; + network: Network; + pluginAddress: string; + rawAllowedActions: IRawAllowedAction[]; +} + +const DecodedAllowedActionsList: React.FC = ({ + chainId, + conditionAddress, + network, + pluginAddress, + rawAllowedActions, +}) => { + const { data, isLoading } = useAllowedActions({ + urlParams: { network, pluginAddress }, + queryParams: { pageSize: 50 }, + }); + const decodedAllowedActions = + data?.pages + .flatMap((page) => page.data) + .filter((action) => + hasDecodedAllowedAction( + action, + rawAllowedActions, + conditionAddress, + ), + ) ?? []; + const decodedAllowedActionViews = decodedAllowedActions.map((action) => ({ + contractName: action.decoded.contractName, + functionName: action.decoded.functionName, + id: action.id, + selector: action.selector, + target: action.target, + })); + + if (isLoading) { + return ; + } + + if (decodedAllowedActionViews.length > 0) { + return ( + + ); + } + + return ( + + ); +}; + +const toAllowedActionViews = ( + actions: IRawAllowedAction[], +): IAllowedActionView[] => + actions.map((action, index) => ({ + ...action, + id: `${action.selector ?? 'any'}-${action.target}-${index}`, + functionName: action.selector ?? undefined, + })); + export const ExecuteSelectorConditionSlot: React.FC = ( props, ) => { - const { selectors, targets } = props; + const { + selectors, + targets, + chainId, + conditionAddress, + network, + pluginAddress, + } = props as IExecuteSelectorConditionSlotProps; const { t } = useTranslations(); - const allowedActions = toAllowedActions(selectors, targets); - const hasAllowedActions = allowedActions.length > 0; + const rawAllowedActions = toAllowedActions(selectors, targets); + const hasRawAllowedActions = rawAllowedActions.length > 0; + const shouldFetchDecodedActions = + network != null && pluginAddress != null && hasRawAllowedActions; return (

{t('app.settings.executeSelectorConditionSlot.description')}

- {hasAllowedActions ? ( - - {allowedActions.map((action) => ( - - {action.target === EMPTY_VALUE - ? EMPTY_VALUE - : addressUtils.truncateAddress(action.target)} - - ))} - + {shouldFetchDecodedActions ? ( + + ) : hasRawAllowedActions ? ( + ) : (

{t('app.settings.executeSelectorConditionSlot.noActions')} diff --git a/apps/app/src/modules/settings/components/permissionsGraph/index.ts b/apps/app/src/modules/settings/components/permissionsGraph/index.ts index 255b4f0ba3..3a714c1418 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/index.ts +++ b/apps/app/src/modules/settings/components/permissionsGraph/index.ts @@ -2,3 +2,4 @@ export { type IPermissionsGraphProps, PermissionsGraph, } from './permissionsGraph'; +export type { GraphMode } from './permissionsGraphCanvas'; diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx new file mode 100644 index 0000000000..46be64a320 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx @@ -0,0 +1,65 @@ +import { + BaseEdge, + type Edge, + type EdgeProps, + getSmoothStepPath, +} from '@xyflow/react'; + +export interface IPermissionEdgeEntry { + edgeId: string; + permissionName: string; + conditionLabel?: string; + selected?: boolean; +} + +export type PermissionEdgeVisualKind = + | 'self' + | 'incoming' + | 'outgoing' + | 'other'; + +export interface IPermissionEdgeData { + excludeFromLayout?: boolean; + selfTargetId?: string; + visualKind: PermissionEdgeVisualKind; + [key: string]: unknown; +} + +export type IPermissionFlowEdge = Edge; + +export const PermissionGraphEdge: React.FC> = ({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + markerStart, + markerEnd, + style, + data, +}) => { + const visualKind = data?.visualKind ?? 'other'; + const [edgePath] = + visualKind === 'self' + ? [`M ${sourceX} ${sourceY} L ${targetX} ${targetY}`] + : getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + borderRadius: 16, + }); + + return ( + + ); +}; diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx new file mode 100644 index 0000000000..a168943325 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx @@ -0,0 +1,207 @@ +import { Avatar, DaoAvatar, Tag } from '@aragon/gov-ui-kit'; +import { Handle, type Node, type NodeProps, Position } from '@xyflow/react'; +import classNames from 'classnames'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import type { IPermissionGraphNode, PermissionNodeKind } from '../../types'; +import type { IPermissionEdgeEntry } from './permissionGraphEdge'; + +export type PermissionNodeSelectionRole = 'who' | 'where'; + +export interface IPermissionNodeData extends IPermissionGraphNode { + selectionRole?: PermissionNodeSelectionRole; + dimmed?: boolean; + sourcePosition?: Position; + targetPosition?: Position; + [key: string]: unknown; +} + +export type IPermissionFlowNode = Node; + +export interface IPermissionStackNodeData { + permissions: IPermissionEdgeEntry[]; + active?: boolean; + dimmed?: boolean; + sourcePosition?: Position; + targetPosition?: Position; + onSelect?: (edgeId: string) => void; + [key: string]: unknown; +} + +export type IPermissionStackFlowNode = Node< + IPermissionStackNodeData, + 'permissionStack' +>; + +export const PERMISSION_GRAPH_HANDLE = { + sourceTop: 'source-top', + sourceRight: 'source-right', + sourceBottom: 'source-bottom', + sourceLeft: 'source-left', + targetTop: 'target-top', + targetRight: 'target-right', + targetBottom: 'target-bottom', + targetLeft: 'target-left', +} as const; + +const SOURCE_HANDLES = [ + { id: PERMISSION_GRAPH_HANDLE.sourceTop, position: Position.Top }, + { id: PERMISSION_GRAPH_HANDLE.sourceRight, position: Position.Right }, + { id: PERMISSION_GRAPH_HANDLE.sourceBottom, position: Position.Bottom }, + { id: PERMISSION_GRAPH_HANDLE.sourceLeft, position: Position.Left }, +]; + +const TARGET_HANDLES = [ + { id: PERMISSION_GRAPH_HANDLE.targetTop, position: Position.Top }, + { id: PERMISSION_GRAPH_HANDLE.targetRight, position: Position.Right }, + { id: PERMISSION_GRAPH_HANDLE.targetBottom, position: Position.Bottom }, + { id: PERMISSION_GRAPH_HANDLE.targetLeft, position: Position.Left }, +]; + +const HiddenHandles = () => ( + <> + {TARGET_HANDLES.map((handle) => ( + + ))} + {SOURCE_HANDLES.map((handle) => ( + + ))} + +); + +const SUBTITLE_KEY: Record = { + dao: 'app.settings.daoPermissionsPage.graphView.node.dao', + linkedDao: 'app.settings.daoPermissionsPage.graphView.node.linkedDao', + plugin: 'app.settings.daoPermissionsPage.graphView.node.plugin', + actor: 'app.settings.daoPermissionsPage.graphView.node.actor', +}; + +const SELECTION_LABEL_KEY: Record = { + who: 'app.settings.daoPermissionsPage.graphView.node.who', + where: 'app.settings.daoPermissionsPage.graphView.node.where', +}; + +export const PermissionGraphNode: React.FC> = ({ + data, +}) => { + const { t } = useTranslations(); + const { kind, label, tag, avatarSrc, selectionRole, dimmed } = data; + const isDaoKind = kind === 'dao' || kind === 'linkedDao'; + const isSelected = selectionRole != null; + + return ( +

+ {isSelected && ( + + {t(SELECTION_LABEL_KEY[selectionRole])} + + )} +
+ +
+ {label} + + {t(SUBTITLE_KEY[kind])} + +
+ {isDaoKind && ( + + )} + {kind === 'plugin' && tag != null && ( + + )} + {kind === 'actor' && } +
+
+ ); +}; + +export const PermissionStackNode: React.FC< + NodeProps +> = ({ data }) => { + const { t } = useTranslations(); + const { permissions, active, dimmed, onSelect } = data; + + return ( +
+ + {permissions.map((permission) => { + const isSelected = active && permission.selected === true; + + return ( + + ); + })} +
+ ); +}; diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx index 6995db7333..26ac581b4b 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx @@ -1,36 +1,39 @@ 'use client'; import { - Avatar, addressUtils, Button, CardEmptyState, - DaoAvatar, DefinitionList, IconType, StateSkeletonBar, - Tag, Toggle, ToggleGroup, } from '@aragon/gov-ui-kit'; -import classNames from 'classnames'; -import { useMemo, useState } from 'react'; +import '@xyflow/react/dist/style.css'; +import { ReactFlowProvider } from '@xyflow/react'; +import { useMemo, useRef, useState } from 'react'; import type { IDao, IDaoPlugin } from '@/shared/api/daoService'; import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; import { PluginSingleComponent } from '@/shared/components/pluginSingleComponent'; import { useTranslations } from '@/shared/components/translationsProvider'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; import { SettingsSlotId } from '../../constants/moduleSlots'; import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; import type { IPermissionGraph, IPermissionGraphEdge, - IPermissionGraphNode, IPermissionRow, } from '../../types'; import { buildPermissionGraph } from '../../utils/buildPermissionGraph'; import { conditionTypeUtils } from '../../utils/conditionTypeUtils'; import type { IPermissionAccountRef } from '../../utils/permissionEntityUtils'; import { NoConditionSlot } from '../noConditionSlot'; +import { + type GraphMode, + PermissionsGraphCanvas, + useModeEdges, +} from './permissionsGraphCanvas'; export interface IPermissionsGraphProps { rows: IPermissionRow[]; @@ -39,99 +42,9 @@ export interface IPermissionsGraphProps { accountRefs: IPermissionAccountRef[]; isLoading: boolean; activeAccountAddress?: string; + mode: GraphMode; } -type GraphMode = 'incoming' | 'outgoing' | 'other'; - -interface IGraphPoint { - x: number; - y: number; -} - -const GRAPH_WIDTH = 1000; -const GRAPH_HEIGHT = 560; -const ANCHOR_POINT: IGraphPoint = { x: 500, y: 260 }; -const SIDE_TOP = 96; -const SIDE_BOTTOM = 464; -const NODE_WIDTH = 176; -const EMPTY_NODE_TEXT = '—'; - -const modeValues: GraphMode[] = ['incoming', 'outgoing', 'other']; - -const distributeY = (index: number, total: number): number => { - if (total <= 1) { - return ANCHOR_POINT.y; - } - - return SIDE_TOP + (index * (SIDE_BOTTOM - SIDE_TOP)) / (total - 1); -}; - -const getModeEdges = ( - graph: IPermissionGraph, - mode: GraphMode, - anchorId: string, -): IPermissionGraphEdge[] => { - if (mode === 'incoming') { - return graph.edges.filter((edge) => edge.target === anchorId); - } - - if (mode === 'outgoing') { - return graph.edges.filter((edge) => edge.source === anchorId); - } - - return graph.edges.filter( - (edge) => edge.source !== anchorId && edge.target !== anchorId, - ); -}; - -const buildPositions = ( - nodes: IPermissionGraphNode[], - edges: IPermissionGraphEdge[], - mode: GraphMode, - anchorId: string, -): Map => { - const positions = new Map(); - - if (mode !== 'other') { - positions.set(anchorId, ANCHOR_POINT); - const sideIds = Array.from( - new Set( - edges.map((edge) => - mode === 'incoming' ? edge.source : edge.target, - ), - ), - ); - const x = mode === 'incoming' ? 200 : 800; - - sideIds.forEach((id, index) => { - positions.set(id, { - x, - y: distributeY(index, sideIds.length), - }); - }); - - return positions; - } - - const sourceIds = Array.from(new Set(edges.map((edge) => edge.source))); - const targetIds = Array.from(new Set(edges.map((edge) => edge.target))); - - sourceIds.forEach((id, index) => { - positions.set(id, { x: 260, y: distributeY(index, sourceIds.length) }); - }); - targetIds.forEach((id, index) => { - positions.set(id, { x: 740, y: distributeY(index, targetIds.length) }); - }); - - for (const node of nodes) { - if (!positions.has(node.id)) { - positions.set(node.id, ANCHOR_POINT); - } - } - - return positions; -}; - export const PermissionsGraph: React.FC = (props) => { const { rows, @@ -140,12 +53,11 @@ export const PermissionsGraph: React.FC = (props) => { accountRefs, isLoading, activeAccountAddress, + mode, } = props; const { t } = useTranslations(); - const [mode, setMode] = useState('incoming'); - const [hoveredEdgeId, setHoveredEdgeId] = useState(); const [selectedEdgeId, setSelectedEdgeId] = useState(); const graph = useMemo(() => { @@ -157,307 +69,63 @@ export const PermissionsGraph: React.FC = (props) => { }, [rows, dao, daoPlugins, accountRefs]); const anchorId = (activeAccountAddress ?? dao?.address ?? '').toLowerCase(); - const modeEdges = useMemo( - () => getModeEdges(graph, mode, anchorId), - [graph, mode, anchorId], - ); - const visibleNodeIds = new Set( - modeEdges.flatMap((edge) => [edge.source, edge.target]), - ); - const visibleNodes = graph.nodes.filter((node) => - visibleNodeIds.has(node.id), - ); - const positions = buildPositions(visibleNodes, modeEdges, mode, anchorId); + const modeEdges = useModeEdges(graph, mode, anchorId); const selectedEdge = graph.edges.find((edge) => edge.id === selectedEdgeId); - const handleModeChange = (value?: string | string[]) => { - if (modeValues.includes(value as GraphMode)) { - setMode(value as GraphMode); - setSelectedEdgeId(undefined); - setHoveredEdgeId(undefined); - } - }; - if (isLoading || dao == null) { return ; } if (graph.edges.length === 0 || modeEdges.length === 0) { return ( -
- - -
- ); - } - - return ( -
- -
- - - {visibleNodes.map((node) => { - const point = positions.get(node.id); - - if (point == null) { - return null; - } - - return ( - - ); - })} - - {modeEdges.map((edge, index) => { - const source = positions.get(edge.source); - const target = positions.get(edge.target); - - if (source == null || target == null) { - return null; - } - - const midpoint = { - x: (source.x + target.x) / 2, - y: (source.y + target.y) / 2 + (index % 3) * 18 - 18, - }; - const isSelected = selectedEdgeId === edge.id; - const isHovered = hoveredEdgeId === edge.id; - - return ( - - ); - })} - - {selectedEdge != null && ( - setSelectedEdgeId(undefined)} - /> + -
- ); -}; - -interface IGraphModeToggleProps { - mode: GraphMode; - onModeChange: (value?: string | string[]) => void; -} - -const GraphModeToggle: React.FC = ({ - mode, - onModeChange, -}) => { - const { t } = useTranslations(); - - return ( - - - - - - ); -}; - -interface IGraphNodeProps { - node: IPermissionGraphNode; - point: IGraphPoint; - selectedEdge?: IPermissionGraphEdge; -} - -const GraphNode: React.FC = ({ - node, - point, - selectedEdge, -}) => { - const { t } = useTranslations(); - const isDaoKind = node.kind === 'dao' || node.kind === 'linkedDao'; - const isAffected = - selectedEdge != null && - (selectedEdge.source === node.id || selectedEdge.target === node.id); - const isDimmed = selectedEdge != null && !isAffected; + ); + } return ( -
-
-

- {node.label || EMPTY_NODE_TEXT} -

-

- {t( - `app.settings.daoPermissionsPage.graphView.node.${node.kind}`, - )} -

-
- {isDaoKind && ( - + + + + {selectedEdge != null && ( + setSelectedEdgeId(undefined)} /> )} - {node.kind === 'plugin' && node.tag != null && ( - - )} - {node.kind === 'actor' && }
); }; interface IPermissionDetailPanelProps { + chainId?: number; edge: IPermissionGraphEdge; - nodes: IPermissionGraphNode[]; + network?: IDao['network']; + nodes: IPermissionGraph['nodes']; onClose: () => void; } const PermissionDetailPanel: React.FC = ({ + chainId, edge, + network, nodes, onClose, }) => { @@ -482,10 +150,104 @@ const PermissionDetailPanel: React.FC = ({ row.whereAddress, ANY_ADDR, ); + const panelRef = useRef(null); + const dragOffsetRef = useRef<{ x: number; y: number } | undefined>( + undefined, + ); + const [position, setPosition] = useState({ x: 16, y: 80 }); + const [isDragging, setIsDragging] = useState(false); + const [activeTab, setActiveTab] = useState<'permission' | 'condition'>( + 'permission', + ); + + const clampPosition = (next: { x: number; y: number }) => { + const panel = panelRef.current; + const container = panel?.parentElement; + + if (panel == null || container == null) { + return next; + } + + const margin = 16; + const maxX = Math.max( + margin, + container.clientWidth - panel.offsetWidth - margin, + ); + const maxY = Math.max( + margin, + container.clientHeight - panel.offsetHeight - margin, + ); + + return { + x: Math.min(Math.max(next.x, margin), maxX), + y: Math.min(Math.max(next.y, margin), maxY), + }; + }; + + const handleDragStart = (event: React.PointerEvent) => { + const panel = panelRef.current; + + if (panel == null) { + return; + } + + const panelRect = panel.getBoundingClientRect(); + dragOffsetRef.current = { + x: event.clientX - panelRect.left, + y: event.clientY - panelRect.top, + }; + setIsDragging(true); + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const handleDragMove = (event: React.PointerEvent) => { + if (!isDragging || dragOffsetRef.current == null) { + return; + } + + const container = panelRef.current?.parentElement; + + if (container == null) { + return; + } + + const containerRect = container.getBoundingClientRect(); + const nextPosition = { + x: event.clientX - containerRect.left - dragOffsetRef.current.x, + y: event.clientY - containerRect.top - dragOffsetRef.current.y, + }; + + setPosition(clampPosition(nextPosition)); + }; + + const handleDragEnd = (event: React.PointerEvent) => { + dragOffsetRef.current = undefined; + setIsDragging(false); + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; + + const handleTabChange = (value?: string | string[]) => { + if (value === 'permission' || value === 'condition') { + setActiveTab(value); + } + }; return ( -
-
+
+

{edge.permissionName} @@ -499,59 +261,92 @@ const PermissionDetailPanel: React.FC = ({

)}
-
-
- - - {isWhoAnyAddress - ? who?.label - : addressUtils.truncateAddress(row.whoAddress)} - - - {isWhereAnyAddress - ? where?.label - : addressUtils.truncateAddress(row.whereAddress)} - - event.stopPropagation()}> +
+
+
+

+ {t('app.settings.permissionsList.details.heading')}

+ + + + +
+ {activeTab === 'permission' ? ( + + + {isWhoAnyAddress + ? who?.label + : addressUtils.truncateAddress(row.whoAddress)} + + + {isWhereAnyAddress + ? where?.label + : addressUtils.truncateAddress( + row.whereAddress, + )} + + + {addressUtils.truncateHash(row.permissionId)} + + + ) : ( -
+ )}
); diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx new file mode 100644 index 0000000000..f6b21a5e86 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx @@ -0,0 +1,624 @@ +'use client'; + +import { + Background, + Controls, + type Edge, + MarkerType, + type Node, + Position, + ReactFlow, + useEdgesState, + useNodesInitialized, + useNodesState, + useReactFlow, +} from '@xyflow/react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { + IPermissionGraph, + IPermissionGraphEdge, + IPermissionGraphNode, +} from '../../types'; +import { + getLayoutedElements, + type PermissionGraphDirection, +} from '../../utils/permissionGraphLayout'; +import { + type IPermissionEdgeData, + type IPermissionEdgeEntry, + type PermissionEdgeVisualKind, + PermissionGraphEdge, +} from './permissionGraphEdge'; +import { + PERMISSION_GRAPH_HANDLE, + PermissionGraphNode, + PermissionStackNode, +} from './permissionGraphNode'; + +export type GraphMode = 'incoming' | 'outgoing' | 'other'; + +const nodeTypes = { + permission: PermissionGraphNode, + permissionStack: PermissionStackNode, +}; +const edgeTypes = { permission: PermissionGraphEdge }; + +const MIN_ZOOM = 0.2; +const MAX_ZOOM = 2.5; +const FIT_BOUNDS_OPTIONS = { padding: 0.08, duration: 250 }; +const UNPOSITIONED = { x: 0, y: 0 }; +const SELECTED_EDGE_Z_INDEX = 20; +const EDGE_ORIGIN_MARKER_NEUTRAL = 'permission-origin-dot-neutral'; +const EDGE_ORIGIN_MARKER_ACTIVE = 'permission-origin-dot-active'; +const SELF_STACK_GAP = 48; +const FALLBACK_NODE_WIDTH = 256; +const FALLBACK_NODE_HEIGHT = 72; +const FALLBACK_STACK_WIDTH = 240; +const STACK_ROW_HEIGHT = 20; +const STACK_CONDITION_ROW_HEIGHT = 34; +const STACK_ROW_GAP = 2; + +const getModeEdges = ( + graph: IPermissionGraph, + mode: GraphMode, + anchorId: string, +): IPermissionGraphEdge[] => { + if (mode === 'incoming') { + return graph.edges.filter((edge) => edge.target === anchorId); + } + + if (mode === 'outgoing') { + return graph.edges.filter((edge) => edge.source === anchorId); + } + + return graph.edges.filter( + (edge) => edge.source !== anchorId && edge.target !== anchorId, + ); +}; + +const getLayoutDirection = (mode: GraphMode): PermissionGraphDirection => { + if (mode === 'incoming') { + return 'BT'; + } + + if (mode === 'outgoing') { + return 'TB'; + } + + return 'LR'; +}; + +const getLayoutSpacing = ( + mode: GraphMode, +): { nodesep: number; ranksep: number } => { + if (mode === 'other') { + return { nodesep: 80, ranksep: 145 }; + } + + return { nodesep: 60, ranksep: 170 }; +}; + +const getHandlePositions = ( + mode: GraphMode, +): { sourcePosition: Position; targetPosition: Position } => { + if (mode === 'incoming') { + return { + sourcePosition: Position.Top, + targetPosition: Position.Bottom, + }; + } + + if (mode === 'outgoing') { + return { + sourcePosition: Position.Bottom, + targetPosition: Position.Top, + }; + } + + return { + sourcePosition: Position.Right, + targetPosition: Position.Left, + }; +}; + +const getEdgeHandles = (mode: GraphMode) => { + if (mode === 'incoming') { + return { + originSource: PERMISSION_GRAPH_HANDLE.sourceTop, + stackTarget: PERMISSION_GRAPH_HANDLE.targetBottom, + stackSource: PERMISSION_GRAPH_HANDLE.sourceTop, + targetTarget: PERMISSION_GRAPH_HANDLE.targetBottom, + }; + } + + if (mode === 'outgoing') { + return { + originSource: PERMISSION_GRAPH_HANDLE.sourceBottom, + stackTarget: PERMISSION_GRAPH_HANDLE.targetTop, + stackSource: PERMISSION_GRAPH_HANDLE.sourceBottom, + targetTarget: PERMISSION_GRAPH_HANDLE.targetTop, + }; + } + + return { + originSource: PERMISSION_GRAPH_HANDLE.sourceRight, + stackTarget: PERMISSION_GRAPH_HANDLE.targetLeft, + stackSource: PERMISSION_GRAPH_HANDLE.sourceRight, + targetTarget: PERMISSION_GRAPH_HANDLE.targetLeft, + }; +}; + +const getStackPermissions = (node: Node): IPermissionEdgeEntry[] => + Array.isArray(node.data?.permissions) + ? (node.data.permissions as IPermissionEdgeEntry[]) + : []; + +const getFallbackNodeSize = (node: Node) => { + if (node.type !== 'permissionStack') { + return { width: FALLBACK_NODE_WIDTH, height: FALLBACK_NODE_HEIGHT }; + } + + const permissions = getStackPermissions(node); + const rowHeight = permissions.reduce( + (height, permission) => + height + + (permission.conditionLabel == null + ? STACK_ROW_HEIGHT + : STACK_CONDITION_ROW_HEIGHT), + 0, + ); + const rowGap = Math.max(permissions.length - 1, 0) * STACK_ROW_GAP; + + return { + width: FALLBACK_STACK_WIDTH, + height: Math.max(rowHeight + rowGap, STACK_ROW_HEIGHT), + }; +}; + +const getNodeRect = (node: Node) => { + const fallback = getFallbackNodeSize(node); + + return { + x: node.position.x, + y: node.position.y, + width: node.measured?.width ?? fallback.width, + height: node.measured?.height ?? fallback.height, + }; +}; + +const getGraphBounds = (nodes: Node[]) => { + const rects = nodes.map(getNodeRect); + + const minX = Math.min(...rects.map((rect) => rect.x)); + const minY = Math.min(...rects.map((rect) => rect.y)); + const maxX = Math.max(...rects.map((rect) => rect.x + rect.width)); + const maxY = Math.max(...rects.map((rect) => rect.y + rect.height)); + + return { + x: minX, + y: minY, + width: maxX - minX, + height: maxY - minY, + }; +}; + +const positionSelfStacks = (nodes: Node[]): Node[] => { + const nodeById = new Map(nodes.map((node) => [node.id, node])); + + return nodes.map((node) => { + const selfTargetId = node.data?.selfTargetId; + + if ( + node.type !== 'permissionStack' || + typeof selfTargetId !== 'string' + ) { + return node; + } + + const targetNode = nodeById.get(selfTargetId); + + if (targetNode == null) { + return node; + } + + const targetRect = getNodeRect(targetNode); + const stackRect = getNodeRect(node); + + return { + ...node, + position: { + x: + targetNode.position.x + + targetRect.width / 2 - + stackRect.width / 2, + y: targetNode.position.y - stackRect.height - SELF_STACK_GAP, + }, + }; + }); +}; + +const edgeBaseStyle = { + stroke: 'var(--color-neutral-300)', + strokeWidth: 1.4, +}; + +const edgeActiveStyle = { + stroke: 'var(--color-primary-400)', + strokeWidth: 2, +}; + +const getEdgeVisualKind = ( + source: string, + target: string, + mode: GraphMode, +): PermissionEdgeVisualKind => { + if (source === target) { + return 'self'; + } + + if (mode === 'incoming') { + return 'incoming'; + } + + if (mode === 'outgoing') { + return 'outgoing'; + } + + return 'other'; +}; + +const getOriginMarker = (active: boolean) => + active ? EDGE_ORIGIN_MARKER_ACTIVE : EDGE_ORIGIN_MARKER_NEUTRAL; + +const getEdgeMarker = (active: boolean) => ({ + type: MarkerType.ArrowClosed, + color: active ? 'var(--color-primary-400)' : 'var(--color-neutral-300)', + width: 18, + height: 18, +}); + +const getEdgeStyle = (active: boolean) => + active ? edgeActiveStyle : edgeBaseStyle; + +interface IBuildFlowElementsParams { + graph: IPermissionGraph; + modeEdges: IPermissionGraphEdge[]; + mode: GraphMode; + selectedEdgeId?: string; + onSelectEdge: (edgeId: string) => void; +} + +const buildFlowElements = ({ + graph, + modeEdges, + mode, + selectedEdgeId, + onSelectEdge, +}: IBuildFlowElementsParams): { nodes: Node[]; edges: Edge[] } => { + const visibleNodeIds = new Set( + modeEdges.flatMap((edge) => [edge.source, edge.target]), + ); + const selectedEdge = + selectedEdgeId != null + ? modeEdges.find((edge) => edge.id === selectedEdgeId) + : undefined; + + const handlePositions = getHandlePositions(mode); + const nodes: Node[] = graph.nodes + .filter((node) => visibleNodeIds.has(node.id)) + .map((node: IPermissionGraphNode) => { + const selectionRole = + selectedEdge?.source === node.id + ? 'who' + : selectedEdge?.target === node.id + ? 'where' + : undefined; + + return { + draggable: false, + id: node.id, + type: 'permission', + position: UNPOSITIONED, + data: { + ...node, + ...handlePositions, + selectionRole, + dimmed: selectedEdge != null && selectionRole == null, + }, + }; + }); + + const pairKey = (source: string, target: string) => `${source}-${target}`; + const groups = new Map< + string, + { source: string; target: string; entries: IPermissionEdgeEntry[] } + >(); + + for (const edge of modeEdges) { + const key = pairKey(edge.source, edge.target); + const group = groups.get(key) ?? { + source: edge.source, + target: edge.target, + entries: [], + }; + + group.entries.push({ + edgeId: edge.id, + permissionName: edge.permissionName, + conditionLabel: edge.conditionLabel, + selected: selectedEdgeId === edge.id, + }); + groups.set(key, group); + } + + const stackNodes: Node[] = []; + const edges: Edge[] = []; + const edgeHandles = getEdgeHandles(mode); + + for (const group of groups.values()) { + const active = group.entries.some((entry) => entry.selected === true); + const dimmed = selectedEdge != null && !active; + const visualKind = getEdgeVisualKind(group.source, group.target, mode); + const stackId = `permission-stack-${pairKey(group.source, group.target)}`; + const isSelfEdge = visualKind === 'self'; + const edgeData = { + visualKind, + ...(isSelfEdge ? { selfTargetId: group.target } : {}), + } satisfies IPermissionEdgeData; + + stackNodes.push({ + id: stackId, + type: 'permissionStack', + position: UNPOSITIONED, + draggable: false, + sourcePosition: handlePositions.sourcePosition, + targetPosition: handlePositions.targetPosition, + data: { + permissions: group.entries, + active, + dimmed, + ...handlePositions, + ...(isSelfEdge ? { selfTargetId: group.target } : {}), + onSelect: onSelectEdge, + }, + }); + + if (isSelfEdge) { + edges.push({ + id: `${stackId}-self`, + source: stackId, + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceBottom, + target: group.target, + targetHandle: PERMISSION_GRAPH_HANDLE.targetTop, + type: 'permission', + animated: active, + markerEnd: getEdgeMarker(active), + style: getEdgeStyle(active), + zIndex: active ? SELECTED_EDGE_Z_INDEX : undefined, + data: { + ...edgeData, + excludeFromLayout: true, + }, + }); + + continue; + } + + edges.push({ + id: `${stackId}-origin`, + source: group.source, + sourceHandle: edgeHandles.originSource, + target: stackId, + targetHandle: edgeHandles.stackTarget, + type: 'permission', + animated: active, + markerStart: getOriginMarker(active), + style: getEdgeStyle(active), + zIndex: active ? SELECTED_EDGE_Z_INDEX : undefined, + data: edgeData, + }); + + edges.push({ + id: `${stackId}-target`, + source: stackId, + sourceHandle: edgeHandles.stackSource, + target: group.target, + targetHandle: edgeHandles.targetTarget, + type: 'permission', + animated: active, + markerEnd: getEdgeMarker(active), + style: getEdgeStyle(active), + zIndex: active ? SELECTED_EDGE_Z_INDEX : undefined, + data: edgeData, + }); + } + + return { nodes: [...nodes, ...stackNodes], edges }; +}; + +export interface IPermissionsGraphCanvasProps { + graph: IPermissionGraph; + mode: GraphMode; + anchorId: string; + selectedEdgeId?: string; + onSelectedEdgeChange: (edgeId?: string) => void; +} + +export const useModeEdges = ( + graph: IPermissionGraph, + mode: GraphMode, + anchorId: string, +): IPermissionGraphEdge[] => + useMemo(() => getModeEdges(graph, mode, anchorId), [graph, mode, anchorId]); + +export const PermissionsGraphCanvas: React.FC = ({ + graph, + mode, + anchorId, + selectedEdgeId, + onSelectedEdgeChange, +}) => { + const modeEdges = useModeEdges(graph, mode, anchorId); + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + const { fitBounds, getNodes } = useReactFlow(); + const nodesInitialized = useNodesInitialized(); + const layoutSignature = useRef(''); + const [layoutVersion, setLayoutVersion] = useState(0); + const graphBounds = useRef | undefined>( + undefined, + ); + + const selectEdge = useCallback( + (edgeId: string) => + onSelectedEdgeChange( + selectedEdgeId === edgeId ? undefined : edgeId, + ), + [onSelectedEdgeChange, selectedEdgeId], + ); + + useEffect(() => { + const currentNodes = getNodes(); + const previousPositions = new Map( + currentNodes.map((node) => [node.id, node.position]), + ); + const { nodes: nextNodes, edges: nextEdges } = buildFlowElements({ + graph, + modeEdges, + mode, + selectedEdgeId, + onSelectEdge: selectEdge, + }); + + setNodes( + nextNodes.map((node) => ({ + ...node, + position: previousPositions.get(node.id) ?? node.position, + })), + ); + setEdges(nextEdges); + }, [ + graph, + modeEdges, + mode, + selectedEdgeId, + selectEdge, + getNodes, + setNodes, + setEdges, + ]); + + useEffect(() => { + if (!nodesInitialized || nodes.length === 0) { + return; + } + + const topologySignature = [ + mode, + nodes.map((node) => node.id).join('|'), + edges.map((edge) => `${edge.source}->${edge.target}`).join('|'), + ].join('::'); + + if (layoutSignature.current === topologySignature) { + return; + } + + const currentNodes = getNodes(); + const { nodes: rawLayoutedNodes } = getLayoutedElements( + currentNodes, + edges, + { + direction: getLayoutDirection(mode), + ...getLayoutSpacing(mode), + }, + ); + const layoutedNodes = positionSelfStacks(rawLayoutedNodes); + + layoutSignature.current = topologySignature; + graphBounds.current = getGraphBounds(layoutedNodes); + setNodes(layoutedNodes); + setEdges(edges); + setLayoutVersion((version) => version + 1); + }, [nodesInitialized, nodes, mode, edges, getNodes, setNodes, setEdges]); + + useEffect(() => { + if (layoutVersion === 0 || graphBounds.current == null) { + return; + } + + const frame = requestAnimationFrame(() => { + void fitBounds(graphBounds.current!, FIT_BOUNDS_OPTIONS); + }); + + return () => cancelAnimationFrame(frame); + }, [fitBounds, layoutVersion]); + + return ( + onSelectedEdgeChange(undefined)} + proOptions={{ hideAttribution: true }} + > + + + { + if (graphBounds.current != null) { + void fitBounds(graphBounds.current, FIT_BOUNDS_OPTIONS); + } + }} + showInteractive={false} + /> + + ); +}; diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx index 4ecc8232be..e921f83b20 100644 --- a/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx @@ -111,6 +111,21 @@ describe(' component', () => { expect(screen.getByText('-')).toBeInTheDocument(); }); + it('renders unresolved condition labels explicitly', () => { + const rows: IPermissionRow[] = [ + { + permissionId: EXECUTE_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: '0xC0Ffee254729296a45a3885639AC7E10F9d54979', + }, + ]; + + render(createTestComponent({ rows })); + + expect(screen.getByText('Unrecognized condition')).toBeInTheDocument(); + }); + it('renders both the Details and Condition lists for an expanded row', async () => { const rows: IPermissionRow[] = [ { @@ -164,4 +179,27 @@ describe(' component', () => { expect(screen.getByText(/noConditionSlot.heading/)).toBeInTheDocument(); }); + + it('renders an unresolved condition detail for expanded unknown conditions', () => { + const rows: IPermissionRow[] = [ + { + permissionId: EXECUTE_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: '0xC0Ffee254729296a45a3885639AC7E10F9d54979', + }, + ]; + + render( + createTestComponent({ + rows, + expandedRows: [getPermissionRowKey(rows[0])], + }), + ); + + expect(screen.getAllByText('Unrecognized condition')).toHaveLength(2); + expect( + screen.queryByText(/noConditionSlot.heading/), + ).not.toBeInTheDocument(); + }); }); diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx index 9b5305339a..69ee3332c6 100644 --- a/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx @@ -9,10 +9,11 @@ import { DefinitionList, Link, StateSkeletonBar, + StateSkeletonCircular, Tag, useBlockExplorer, } from '@aragon/gov-ui-kit'; -import type { IDaoPlugin } from '@/shared/api/daoService'; +import type { IDaoPlugin, Network } from '@/shared/api/daoService'; import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; import { PluginSingleComponent } from '@/shared/components/pluginSingleComponent'; import { useTranslations } from '@/shared/components/translationsProvider'; @@ -20,7 +21,10 @@ import { permissionNameUtils } from '@/shared/utils/permissionNameUtils'; import { SettingsSlotId } from '../../constants/moduleSlots'; import { ALLOW_FLAG } from '../../constants/permissionSentinels'; import type { IPermissionRow } from '../../types'; -import { conditionTypeUtils } from '../../utils/conditionTypeUtils'; +import { + conditionTypeUtils, + UNKNOWN_CONDITION, +} from '../../utils/conditionTypeUtils'; import { type IPermissionAccountRef, type IPermissionEntity, @@ -93,6 +97,7 @@ export const PermissionsList: React.FC = (props) => { chainId={chainId} daoPlugins={daoPlugins} key={getPermissionRowKey(row)} + network={row.network} row={row} rowKey={getPermissionRowKey(row)} /> @@ -108,6 +113,7 @@ interface IPermissionsListRowProps { daoPlugins: DaoPlugins; accounts: IPermissionAccountRef[]; chainId?: number; + network?: Network; } interface IPermissionEntityCellProps { @@ -202,8 +208,23 @@ const PermissionDetailValue: React.FC = ({
); +const UnrecognizedConditionSlot: React.FC = () => { + const { t } = useTranslations(); + + return ( + + ); +}; + const PermissionsListRow: React.FC = (props) => { - const { row, rowKey, daoPlugins, accounts, chainId } = props; + const { row, rowKey, daoPlugins, accounts, chainId, network } = props; const { t } = useTranslations(); @@ -224,6 +245,8 @@ const PermissionsListRow: React.FC = (props) => { row.condition, ); const conditionLabel = conditionTypeUtils.getConditionLabel(conditionType); + const hasConditionLabel = conditionLabel !== '-'; + const hasUnrecognizedCondition = conditionType === UNKNOWN_CONDITION; const hasCondition = !addressUtils.isAddressEqual( row.conditionAddress, @@ -243,7 +266,7 @@ const PermissionsListRow: React.FC = (props) => { {permissionName} - {hasCondition ? ( + {hasConditionLabel ? ( ) : ( @@ -318,12 +341,20 @@ const PermissionsListRow: React.FC = (props) => { 'app.settings.permissionsList.condition.heading', )}

- + {hasUnrecognizedCondition ? ( + + ) : ( + + )}
@@ -363,13 +394,16 @@ const PermissionsListSkeleton: React.FC = () => ( {SKELETON_ROW_KEYS.map((rowKey) => (
- - - - +
+ + + + +
+
))} diff --git a/apps/app/src/modules/settings/constants/permissionsMocks.ts b/apps/app/src/modules/settings/constants/permissionsMocks.ts deleted file mode 100644 index c866ec55b2..0000000000 --- a/apps/app/src/modules/settings/constants/permissionsMocks.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { IPaginatedResponse } from '@/shared/api/aragonBackendService'; -import type { IDaoPermission } from '@/shared/api/daoService'; -import type { IBackendApiMock } from '@/shared/types'; -import { permissionNameUtils } from '@/shared/utils/permissionNameUtils'; -import type { IPermissionRow } from '../types'; -import { ALLOW_FLAG, ANY_ADDR } from './permissionSentinels'; -import { PermissionsPreviewRef } from './permissionsPreviewRefs'; - -// Permission ids are derived from their names so the dictionary stays in a single -// place ({@link permissionNameUtils}) instead of duplicating raw keccak256 hashes. -const ROOT_PERMISSION_ID = - permissionNameUtils.getPermissionId('ROOT_PERMISSION'); -const EXECUTE_PERMISSION_ID = - permissionNameUtils.getPermissionId('EXECUTE_PERMISSION'); -const CREATE_PROPOSAL_PERMISSION_ID = permissionNameUtils.getPermissionId( - 'CREATE_PROPOSAL_PERMISSION', -); - -const tokenAddress = '0x0bA45A8b5d5575935B8158a88C631E9F9C95a2e5'; -const gaugeVoterAddress = '0x1234567890AbcdEF1234567890aBcdef12345678'; -const votingConditionAddress = '0xC0Ffee254729296a45a3885639AC7E10F9d54979'; -const selectorConditionAddress = '0xDe0B295669a9FD93d5F28D9Ec85E40f4cb697BAe'; -const membershipConditionAddress = '0xaB5801a7D398351b8bE11C439e05C5B3259aeC9B'; - -// `who` / `where` reference the viewed DAO ({@link PermissionsPreviewRef.self}), -// its first linked DAO and its installed plugins so the sample rows resolve to -// real names, tags and avatars for whichever DAO is previewed (see -// {@link PermissionsPreviewRef}). The scenarios mirror the permissions-page -// Figma (self→self root, self→linked root, plugin execute, anyone-creates). -const permissions: Array = [ - { - // The DAO holds root permission over itself, unconditionally. - permissionId: ROOT_PERMISSION_ID, - whoAddress: PermissionsPreviewRef.self, - whereAddress: PermissionsPreviewRef.self, - conditionAddress: ALLOW_FLAG, - }, - { - // The DAO holds root permission over its linked DAO, unconditionally. - permissionId: ROOT_PERMISSION_ID, - whoAddress: PermissionsPreviewRef.self, - whereAddress: PermissionsPreviewRef.linked, - conditionAddress: ALLOW_FLAG, - }, - { - // The first plugin may execute on the DAO, gated by an execute-selector - // condition over a handful of functions. - permissionId: EXECUTE_PERMISSION_ID, - whoAddress: PermissionsPreviewRef.plugin0, - whereAddress: PermissionsPreviewRef.self, - conditionAddress: selectorConditionAddress, - condition: { - conditionType: 'execute-selector', - selectors: ['0x3f4ba83f', '0x40e58ee5', '0x8456cb59'], - targets: [gaugeVoterAddress, gaugeVoterAddress, gaugeVoterAddress], - }, - }, - { - // The second plugin may execute on the DAO, unconditionally. - permissionId: EXECUTE_PERMISSION_ID, - whoAddress: PermissionsPreviewRef.plugin1, - whereAddress: PermissionsPreviewRef.self, - conditionAddress: ALLOW_FLAG, - }, - { - // Anyone may create a proposal on the first plugin, gated by voting power. - permissionId: CREATE_PROPOSAL_PERMISSION_ID, - whoAddress: ANY_ADDR, - whereAddress: PermissionsPreviewRef.plugin0, - conditionAddress: votingConditionAddress, - condition: { - conditionType: 'voting-power', - token: tokenAddress, - // Raw base-unit value; renders formatted once token decimals are wired. - minVotingPower: '1000000000000000000', - }, - }, - { - // Anyone may create a proposal on the second plugin, gated by multisig - // membership. - permissionId: CREATE_PROPOSAL_PERMISSION_ID, - whoAddress: ANY_ADDR, - whereAddress: PermissionsPreviewRef.plugin1, - conditionAddress: membershipConditionAddress, - condition: { - conditionType: 'membership', - onlyListed: true, - }, - }, -]; - -const permissionsResponse: IPaginatedResponse = - { - metadata: { - page: 1, - pageSize: permissions.length, - totalPages: 1, - totalRecords: permissions.length, - }, - data: permissions, - }; - -/** - * Preview-mode mock for `GET /permissions/:network/:daoAddress`. Covers the - * condition scenarios shown in the permissions-page Figma (no-condition, - * execute-selector, voting-power, membership) so the permissions UI can be - * exercised without a live backend. - */ -export const permissionsMocks: IBackendApiMock[] = [ - { - // Scoped to the exact `/permissions/:network/:daoAddress` endpoint so - // the interceptor can't replace unrelated requests that merely contain - // the word "permissions" in their path. - url: /\/permissions\/[\w-]+\/0x[a-fA-F0-9]{40}(?:$|[/?])/, - type: 'replace', - data: permissionsResponse, - }, -]; diff --git a/apps/app/src/modules/settings/constants/permissionsPreviewData.ts b/apps/app/src/modules/settings/constants/permissionsPreviewData.ts deleted file mode 100644 index 38ea1c8a11..0000000000 --- a/apps/app/src/modules/settings/constants/permissionsPreviewData.ts +++ /dev/null @@ -1,97 +0,0 @@ -import { - type IDao, - type IDaoPlugin, - Network, - PluginInterfaceType, -} from '@/shared/api/daoService'; -import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; - -/** - * Self-contained "Patito DAO" preview identity used alongside - * {@link permissionsMocks} when the `useMocks` flag is on, so the permissions - * page renders the exact scenario from the Figma design (Patito DAO + a linked - * Patito Developer DAO, with `Core` (SPP) and `Founders` (MULTISIG) plugins) - * regardless of which DAO is opened. Ignored entirely for live data. - */ - -/** - * Minimal account shape the permissions list needs to build its tabs and resolve - * `who` / `where` entities. - */ -export interface IPermissionsPreviewAccount { - id: string; - name: string; - network: Network; - daoAddress: string; - avatarSrc?: string; -} - -const patitoDaoAddress = '0xf204245b0B05E9A0780761E326552A569c1D6ceb'; -const patitoDeveloperDaoAddress = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F'; -const coreAddress = '0x1eC50000000000000000000000000000000e8145'; -const foundersAddress = '0xf00d00000000000000000000000000000000e845'; -const patitoDaoAvatar = '/patitoDao.png'; -const patitoDeveloperDaoAvatar = '/patitoDeveloperDao.png'; - -export const permissionsPreviewAccounts: IPermissionsPreviewAccount[] = [ - { - id: 'patito-dao', - name: 'Patito DAO', - network: Network.ETHEREUM_MAINNET, - daoAddress: patitoDaoAddress, - avatarSrc: patitoDaoAvatar, - }, - { - id: 'patito-developer-dao', - name: 'Patito Developer DAO', - network: Network.ETHEREUM_MAINNET, - daoAddress: patitoDeveloperDaoAddress, - avatarSrc: patitoDeveloperDaoAvatar, - }, -]; - -export const permissionsPreviewDao = { - id: 'patito-dao', - name: 'Patito DAO', - network: Network.ETHEREUM_MAINNET, - address: patitoDaoAddress, - avatar: patitoDaoAvatar, - linkedAccounts: [ - { - id: 'patito-developer-dao', - name: 'Patito Developer DAO', - network: Network.ETHEREUM_MAINNET, - address: patitoDeveloperDaoAddress, - avatar: patitoDeveloperDaoAvatar, - }, - ], -} as unknown as IDao; - -export const permissionsPreviewPlugins: IFilterComponentPlugin[] = [ - { - id: 'core', - uniqueId: `${coreAddress}-spp`, - label: 'Core', - meta: { - address: coreAddress, - name: 'Core', - interfaceType: PluginInterfaceType.SPP, - release: '1', - build: '3', - } as IDaoPlugin, - props: {}, - }, - { - id: 'founders', - uniqueId: `${foundersAddress}-multisig`, - label: 'Founders', - meta: { - address: foundersAddress, - name: 'Founders', - interfaceType: PluginInterfaceType.MULTISIG, - release: '1', - build: '4', - } as IDaoPlugin, - props: {}, - }, -]; diff --git a/apps/app/src/modules/settings/constants/permissionsPreviewRefs.ts b/apps/app/src/modules/settings/constants/permissionsPreviewRefs.ts deleted file mode 100644 index beb7ab9b02..0000000000 --- a/apps/app/src/modules/settings/constants/permissionsPreviewRefs.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Preview-only address markers used by {@link permissionsMocks}. - * - * The permissions list swaps these markers for the viewed DAO's own address and - * its installed plugin addresses at render time, so the sample rows resolve to - * real names, tags and avatars for whichever DAO is being previewed instead of - * hard-coding a specific DAO. Real backend responses never contain these values, - * so the swap is a no-op for live data. - */ -const previewRef = (suffix: string): string => `0x${suffix.padStart(40, '0')}`; - -export const PermissionsPreviewRef = { - /** Resolves to the active account's DAO address. */ - self: previewRef('5e1f'), - /** Resolves to the active account's first linked DAO. */ - linked: previewRef('11a0'), - /** Resolves to the first installed plugin of the active account. */ - plugin0: previewRef('9100'), - /** Resolves to the second installed plugin of the active account. */ - plugin1: previewRef('9101'), -} as const; diff --git a/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts b/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts index b3c8060b96..fb2173d95a 100644 --- a/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts +++ b/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts @@ -8,7 +8,6 @@ import { generateDaoMetrics, generateReactQueryResultSuccess, } from '@/shared/testUtils'; -import { permissionsPreviewAccounts } from '../../constants/permissionsPreviewData'; import { usePermissionsData } from './usePermissionsData'; describe('usePermissionsData hook', () => { @@ -107,14 +106,25 @@ describe('usePermissionsData hook', () => { ); }); - it('uses the Patito preview identity when the mocks flag is on', () => { + it('uses backend dao data when the mocks flag is on', () => { setFeatureFlags({ useMocks: true }); + setDao({ + id: 'main-dao', + address: '0xMainAddress', + network: Network.ETHEREUM_MAINNET, + name: 'Main DAO', + }); const { result } = renderHook(() => - usePermissionsData({ daoId: 'any-dao' }), + usePermissionsData({ daoId: 'main-dao' }), ); - expect(result.current.accounts).toEqual(permissionsPreviewAccounts); - expect(result.current.dao?.name).toBe('Patito DAO'); + expect(result.current.accounts).toEqual([ + expect.objectContaining({ + daoAddress: '0xMainAddress', + name: 'Main DAO', + }), + ]); + expect(result.current.dao?.name).toBe('Main DAO'); }); }); diff --git a/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts b/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts index 00124f38ae..cf639ea34a 100644 --- a/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts +++ b/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts @@ -13,12 +13,6 @@ import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterCom import { networkDefinitions } from '@/shared/constants/networkDefinitions'; import { useDaoPlugins } from '@/shared/hooks/useDaoPlugins'; import { ipfsUtils } from '@/shared/utils/ipfsUtils'; -import { - permissionsPreviewAccounts, - permissionsPreviewDao, - permissionsPreviewPlugins, -} from '../../constants/permissionsPreviewData'; -import { PermissionsPreviewRef } from '../../constants/permissionsPreviewRefs'; import type { IPermissionRow } from '../../types'; import type { IPermissionAccountRef } from '../../utils/permissionEntityUtils'; @@ -53,7 +47,6 @@ export const usePermissionsData = ( const { daoId } = params; const { isEnabled } = useFeatureFlags(); - const isPreview = isEnabled('useMocks'); const { data: realDao } = useDao({ urlParams: { id: daoId } }); const realDaoPlugins = useDaoPlugins({ @@ -61,8 +54,8 @@ export const usePermissionsData = ( includeLinkedAccounts: true, }); - const dao = isPreview ? permissionsPreviewDao : realDao; - const daoPlugins = isPreview ? permissionsPreviewPlugins : realDaoPlugins; + const dao = realDao; + const daoPlugins = realDaoPlugins; const realAccounts = useMemo(() => { if (realDao == null) { @@ -97,7 +90,7 @@ export const usePermissionsData = ( ]; }, [realDao, isEnabled]); - const accounts = isPreview ? permissionsPreviewAccounts : realAccounts; + const accounts = realAccounts; const [selectedAccountId, setSelectedAccountId] = useState(); const activeAccountId = selectedAccountId ?? accounts[0]?.id; @@ -127,31 +120,8 @@ export const usePermissionsData = ( const rows = useMemo(() => { const rawRows = (data ?? []) as IPermissionRow[]; - const pluginAddresses = (daoPlugins ?? []).map( - (plugin) => plugin.meta.address, - ); - const linkedAddress = accounts.find( - (account) => account.id !== activeAccount?.id, - )?.daoAddress; - - const refMap = new Map([ - [ - PermissionsPreviewRef.self.toLowerCase(), - activeAccount?.daoAddress, - ], - [PermissionsPreviewRef.linked.toLowerCase(), linkedAddress], - [PermissionsPreviewRef.plugin0.toLowerCase(), pluginAddresses[0]], - [PermissionsPreviewRef.plugin1.toLowerCase(), pluginAddresses[1]], - ]); - const resolveRef = (address: string): string => - refMap.get(address.toLowerCase()) ?? address; - - return rawRows.map((row) => ({ - ...row, - whoAddress: resolveRef(row.whoAddress), - whereAddress: resolveRef(row.whereAddress), - })); - }, [data, daoPlugins, accounts, activeAccount]); + return rawRows; + }, [data]); const chainId = activeAccount ? networkDefinitions[activeAccount.network].id diff --git a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPage.tsx b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPage.tsx index c5bbb707e6..52f5e59c9e 100644 --- a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPage.tsx +++ b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPage.tsx @@ -1,6 +1,4 @@ -import { notFound } from 'next/navigation-original'; import { Page } from '@/shared/components/page'; -import { featureFlags } from '@/shared/featureFlags'; import type { IDaoPageParams } from '@/shared/types'; import { daoUtils } from '@/shared/utils/daoUtils'; import { networkUtils } from '@/shared/utils/networkUtils'; @@ -24,10 +22,6 @@ export const DaoPermissionsPage: React.FC = async ( return null; } - if (!(await featureFlags.isEnabled('permissionsPage'))) { - notFound(); - } - const daoId = await daoUtils.resolveDaoId(daoPageParams); return ( diff --git a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx index c06ce01272..2d06dc4c86 100644 --- a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx +++ b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx @@ -1,13 +1,16 @@ 'use client'; import { Button, Toggle, ToggleGroup } from '@aragon/gov-ui-kit'; -import { useState } from 'react'; +import { useMemo, useState } from 'react'; import { useDao } from '@/shared/api/daoService'; import { Page } from '@/shared/components/page'; import { useTranslations } from '@/shared/components/translationsProvider'; import { useFilterUrlParam } from '@/shared/hooks/useFilterUrlParam'; import { daoUtils } from '@/shared/utils/daoUtils'; -import { PermissionsGraph } from '../../components/permissionsGraph'; +import { + type GraphMode, + PermissionsGraph, +} from '../../components/permissionsGraph'; import { getPermissionRowKey, PermissionsList, @@ -30,6 +33,35 @@ enum PermissionsView { const permissionsViews = Object.values(PermissionsView); +const graphModes: GraphMode[] = ['incoming', 'outgoing', 'other']; + +const filterRowsByMode = ( + rows: ReturnType['rows'], + mode: GraphMode, + activeAccountAddress?: string, +) => { + const activeAddress = activeAccountAddress?.toLowerCase(); + + if (activeAddress == null) { + return rows; + } + + return rows.filter((row) => { + const whoAddress = row.whoAddress.toLowerCase(); + const whereAddress = row.whereAddress.toLowerCase(); + + if (mode === 'incoming') { + return whereAddress === activeAddress; + } + + if (mode === 'outgoing') { + return whoAddress === activeAddress; + } + + return whoAddress !== activeAddress && whereAddress !== activeAddress; + }); +}; + export const DaoPermissionsPageClient: React.FC< IDaoPermissionsPageClientProps > = (props) => { @@ -59,6 +91,7 @@ export const DaoPermissionsPageClient: React.FC< enableUrlUpdate: true, }); + const [mode, setMode] = useState('incoming'); const [expandedRows, setExpandedRows] = useState([]); const handleViewChange = (value?: string | string[]) => { @@ -74,10 +107,25 @@ export const DaoPermissionsPageClient: React.FC< } }; - const allExpanded = rows.length > 0 && expandedRows.length === rows.length; + const handleModeChange = (value?: string | string[]) => { + if (graphModes.includes(value as GraphMode)) { + setMode(value as GraphMode); + setExpandedRows([]); + } + }; + + const filteredRows = useMemo( + () => filterRowsByMode(rows, mode, activeAccount?.daoAddress), + [rows, mode, activeAccount?.daoAddress], + ); + + const allExpanded = + filteredRows.length > 0 && expandedRows.length === filteredRows.length; const handleToggleAll = () => { - setExpandedRows(allExpanded ? [] : rows.map(getPermissionRowKey)); + setExpandedRows( + allExpanded ? [] : filteredRows.map(getPermissionRowKey), + ); }; const pageBreadcrumbs = [ @@ -96,7 +144,7 @@ export const DaoPermissionsPageClient: React.FC< const isListView = view === PermissionsView.LIST; const showAccountSelector = accounts.length > 1; - const showExpandAll = isListView && !isLoading && rows.length > 0; + const showExpandAll = isListView && !isLoading && filteredRows.length > 0; return ( <> @@ -111,21 +159,47 @@ export const DaoPermissionsPageClient: React.FC<
- {showAccountSelector && ( +
+ {showAccountSelector && ( + + {accounts.map((account) => ( + + ))} + + )} - {accounts.map((account) => ( - - ))} + + + - )} +
{showExpandAll && (
diff --git a/apps/app/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.test.ts b/apps/app/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.test.ts index 44d12f0fb3..d90e30e219 100644 --- a/apps/app/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.test.ts +++ b/apps/app/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.test.ts @@ -61,9 +61,9 @@ describe('conditionType Utils', () => { expected: '-', }, { - description: 'maps "unknown" to the empty placeholder', + description: 'maps "unknown" to the unresolved placeholder', conditionType: 'unknown', - expected: '-', + expected: 'Unrecognized condition', }, { description: 'maps an empty string to the empty placeholder', diff --git a/apps/app/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.ts b/apps/app/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.ts index 89fc594a37..afeb6a40a7 100644 --- a/apps/app/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.ts +++ b/apps/app/src/modules/settings/utils/conditionTypeUtils/conditionTypeUtils.ts @@ -6,20 +6,26 @@ import type { IConditionData } from '../../types'; * Discriminator returned when a permission is granted unconditionally * (condition equals {@link ALLOW_FLAG}). */ -const NO_CONDITION = 'none'; +export const NO_CONDITION = 'none'; /** * Discriminator returned when the condition type cannot be resolved from the * payload (absent condition data or an empty/unrecognised `conditionType`). */ -const UNKNOWN_CONDITION = 'unknown'; +export const UNKNOWN_CONDITION = 'unknown'; /** * Placeholder rendered for conditions that have no human-readable label - * (unconditional grants and unresolvable condition types). + * (unconditional grants and empty condition types). */ const NO_LABEL = '-'; +/** + * Placeholder rendered when the permission references a condition address but + * the condition payload could not be resolved or recognised. + */ +const UNKNOWN_LABEL = 'Unrecognized condition'; + /** * Explicit display labels for the known condition types. Any other non-empty * type falls back to a Pascal-cased rendering of its discriminator. @@ -65,7 +71,8 @@ class ConditionTypeUtils { * Resolves a human-readable label for a condition type, used by the * collapsed permission row's CONDITION cell. * - * - `'none'` / `'unknown'` -> {@link NO_LABEL} (`'-'`). + * - `'none'` / empty -> {@link NO_LABEL} (`'-'`). + * - `'unknown'` -> {@link UNKNOWN_LABEL} (`'Unrecognized condition'`). * - a known type -> its explicit label (e.g. `'voting-power'` -> * `'VotingPower'`). * - any other non-empty type -> a Pascal-cased fallback (e.g. @@ -75,14 +82,14 @@ class ConditionTypeUtils { * @returns The display label for the condition type. */ getConditionLabel = (conditionType: string): string => { - if ( - conditionType === NO_CONDITION || - conditionType === UNKNOWN_CONDITION || - conditionType.length === 0 - ) { + if (conditionType === NO_CONDITION || conditionType.length === 0) { return NO_LABEL; } + if (conditionType === UNKNOWN_CONDITION) { + return UNKNOWN_LABEL; + } + return ( CONDITION_LABELS[conditionType] ?? stringUtils.toPascalCase(conditionType) diff --git a/apps/app/src/modules/settings/utils/conditionTypeUtils/index.ts b/apps/app/src/modules/settings/utils/conditionTypeUtils/index.ts index 11ba8f604d..9ae13b562e 100644 --- a/apps/app/src/modules/settings/utils/conditionTypeUtils/index.ts +++ b/apps/app/src/modules/settings/utils/conditionTypeUtils/index.ts @@ -1 +1,5 @@ -export { conditionTypeUtils } from './conditionTypeUtils'; +export { + conditionTypeUtils, + NO_CONDITION, + UNKNOWN_CONDITION, +} from './conditionTypeUtils'; diff --git a/apps/app/src/modules/settings/utils/permissionGraphLayout/index.ts b/apps/app/src/modules/settings/utils/permissionGraphLayout/index.ts new file mode 100644 index 0000000000..247c6c74ae --- /dev/null +++ b/apps/app/src/modules/settings/utils/permissionGraphLayout/index.ts @@ -0,0 +1,5 @@ +export { + getLayoutedElements, + type IGetLayoutedElementsOptions, + type PermissionGraphDirection, +} from './permissionGraphLayout'; diff --git a/apps/app/src/modules/settings/utils/permissionGraphLayout/permissionGraphLayout.ts b/apps/app/src/modules/settings/utils/permissionGraphLayout/permissionGraphLayout.ts new file mode 100644 index 0000000000..f86cf93c8b --- /dev/null +++ b/apps/app/src/modules/settings/utils/permissionGraphLayout/permissionGraphLayout.ts @@ -0,0 +1,74 @@ +import dagre from '@dagrejs/dagre'; +import type { Edge, Node } from '@xyflow/react'; + +export type PermissionGraphDirection = 'TB' | 'BT' | 'LR' | 'RL'; + +export interface IGetLayoutedElementsOptions { + direction?: PermissionGraphDirection; + nodesep?: number; + ranksep?: number; +} + +const DEFAULT_NODE_WIDTH = 220; +const DEFAULT_NODE_HEIGHT = 72; +const DEFAULT_STACK_NODE_WIDTH = 180; +const DEFAULT_STACK_NODE_HEIGHT = 40; +const DEFAULT_NODE_SEP = 140; +const DEFAULT_RANK_SEP = 260; + +const getNodeSize = (node: Node): { width: number; height: number } => { + if (node.type === 'permissionStack') { + return { + width: node.measured?.width ?? DEFAULT_STACK_NODE_WIDTH, + height: node.measured?.height ?? DEFAULT_STACK_NODE_HEIGHT, + }; + } + + return { + width: node.measured?.width ?? DEFAULT_NODE_WIDTH, + height: node.measured?.height ?? DEFAULT_NODE_HEIGHT, + }; +}; + +export const getLayoutedElements = ( + nodes: Node[], + edges: Edge[], + options: IGetLayoutedElementsOptions = {}, +): { nodes: Node[]; edges: Edge[] } => { + const { + direction = 'LR', + nodesep = DEFAULT_NODE_SEP, + ranksep = DEFAULT_RANK_SEP, + } = options; + + const graph = new dagre.graphlib.Graph(); + graph.setGraph({ rankdir: direction, nodesep, ranksep }); + graph.setDefaultEdgeLabel(() => ({})); + + for (const node of nodes) { + graph.setNode(node.id, getNodeSize(node)); + } + + for (const edge of edges) { + if (edge.data?.excludeFromLayout === true) { + continue; + } + + graph.setEdge(edge.source, edge.target); + } + + dagre.layout(graph); + + return { + edges, + nodes: nodes.map((node) => { + const { x, y } = graph.node(node.id); + const { width, height } = getNodeSize(node); + + return { + ...node, + position: { x: x - width / 2, y: y - height / 2 }, + }; + }), + }; +}; diff --git a/apps/app/src/shared/api/daoService/domain/daoPermission.ts b/apps/app/src/shared/api/daoService/domain/daoPermission.ts index c16a0968d4..15a61ec53f 100644 --- a/apps/app/src/shared/api/daoService/domain/daoPermission.ts +++ b/apps/app/src/shared/api/daoService/domain/daoPermission.ts @@ -1,3 +1,5 @@ +import type { Network } from './enum'; + export interface IDaoPermissionCondition { /** * Backend condition discriminator, e.g. `voting-power`, `membership`, @@ -36,4 +38,8 @@ export interface IDaoPermission { * Enriched condition details returned by the backend when available. */ condition?: IDaoPermissionCondition; + /** + * Network of the DAO permission event. + */ + network?: Network; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1466eed490..c1ad5d13b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -227,6 +227,9 @@ importers: '@aragon/gov-ui-kit': specifier: 'catalog:' version: 2.9.0(@emotion/is-prop-valid@1.4.0)(@floating-ui/dom@1.7.6)(@tailwindcss/typography@0.5.20(tailwindcss@4.3.1))(@tanstack/react-query@5.101.0(react@19.2.7))(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.7(react@19.2.7))(react-hook-form@7.79.0(react@19.2.7))(react@19.2.7)(tailwindcss@4.3.1)(viem@2.53.1(typescript@5.9.3)(zod@3.25.76))(wagmi@3.6.17(@coinbase/wallet-sdk@4.3.6(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(typescript@5.9.3)(use-sync-external-store@1.6.0(react@19.2.7))(zod@3.25.76))(@safe-global/safe-apps-provider@0.18.6(typescript@5.9.3)(zod@3.25.76))(@safe-global/safe-apps-sdk@9.1.0(typescript@5.9.3)(zod@3.25.76))(@tanstack/query-core@5.101.0)(@tanstack/react-query@5.101.0(react@19.2.7))(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(typescript@5.9.3)(viem@2.53.1(typescript@5.9.3)(zod@3.25.76))) + '@dagrejs/dagre': + specifier: ^3.0.0 + version: 3.0.0 '@floating-ui/react': specifier: ^0.27.19 version: 0.27.19(react-dom@19.2.7(react@19.2.7))(react@19.2.7) @@ -272,6 +275,9 @@ importers: '@walletconnect/utils': specifier: ^2.23.9 version: 2.23.9(@upstash/redis@1.38.0)(@vercel/blob@2.5.0)(typescript@5.9.3)(zod@3.25.76) + '@xyflow/react': + specifier: ^12.11.2 + version: 12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7) classnames: specifier: 'catalog:' version: 2.5.1 @@ -1098,6 +1104,12 @@ packages: resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==} engines: {node: '>=20.19.0'} + '@dagrejs/dagre@3.0.0': + resolution: {integrity: sha512-ZzhnTy1rfuoew9Ez3EIw4L2znPGnYYhfn8vc9c4oB8iw6QAsszbiU0vRhlxWPFnmmNSFAkrYeF1PhM5m4lAN0Q==} + + '@dagrejs/graphlib@4.0.1': + resolution: {integrity: sha512-IvcV6FduIIAmLwnH+yun+QtV36SC7mERqa86aClNqmMN09WhmPPYU8ckHrZBozErf+UvHPWOTJYaGYiIcs0DgA==} + '@depay/solana-web3.js@1.98.3': resolution: {integrity: sha512-wxr+2gpjKRZ1eVBLhQYJxImDsRukk0DvCsEElkTMyybP+7SamWRs48o3DYE6VLEgQJFZgOoUec3t5FM5s1J1ww==} @@ -3966,6 +3978,9 @@ packages: '@types/d3-color@3.1.3': resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + '@types/d3-drag@3.0.7': + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + '@types/d3-ease@3.0.2': resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} @@ -3978,6 +3993,9 @@ packages: '@types/d3-scale@4.0.9': resolution: {integrity: sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==} + '@types/d3-selection@3.0.11': + resolution: {integrity: sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==} + '@types/d3-shape@3.1.8': resolution: {integrity: sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==} @@ -3987,6 +4005,12 @@ packages: '@types/d3-timer@3.0.2': resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + '@types/d3-transition@3.0.9': + resolution: {integrity: sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==} + + '@types/d3-zoom@3.0.8': + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + '@types/estree@1.0.9': resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} @@ -4589,6 +4613,22 @@ packages: '@xtuc/long@4.2.2': resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} + '@xyflow/react@12.11.2': + resolution: {integrity: sha512-eLAlDWJfWnQEhJwGMjlWdAXO9eYllKpliUmPQlAmOLxz6mExXuzMVDUKLMquixgkrtmMFFtug3jGKmYYld12cA==} + peerDependencies: + '@types/react': '>=17' + '@types/react-dom': '>=17' + react: '>=17' + react-dom: '>=17' + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@xyflow/system@0.0.79': + resolution: {integrity: sha512-czLyOh91NF0hIzbNzwi8I6GlqG23BHh2435OddfI6uiaLH3xdrdygO93gqgH1Bv9mhy8XPFQJOBn1FTq4LvEWA==} + '@yarnpkg/parsers@3.0.3': resolution: {integrity: sha512-mQZgUSgFurUtA07ceMjxrWkYz8QtDuYkvPlu0ZqncgjopQ0t6CNEo/OSealkmnagSUx8ZD5ewvezUwUuMqutQg==} engines: {node: '>=18.12.0'} @@ -5061,6 +5101,9 @@ packages: cjs-module-lexer@2.2.0: resolution: {integrity: sha512-4bHTS2YuzUvtoLjdy+98ykbNB5jS0+07EvFNXerqZQJ89F7DI6ET7OQo/HJuW6K0aVsKA9hj9/RVb2kQVOrPDQ==} + classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + classnames@2.5.1: resolution: {integrity: sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==} @@ -5218,6 +5261,14 @@ packages: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} + d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + + d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + d3-ease@3.0.1: resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} engines: {node: '>=12'} @@ -5238,6 +5289,10 @@ packages: resolution: {integrity: sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==} engines: {node: '>=12'} + d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + d3-shape@3.2.0: resolution: {integrity: sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==} engines: {node: '>=12'} @@ -5254,6 +5309,16 @@ packages: resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} engines: {node: '>=12'} + d3-transition@3.0.1: + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + + d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + data-uri-to-buffer@6.0.2: resolution: {integrity: sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw==} engines: {node: '>= 14'} @@ -8485,6 +8550,21 @@ packages: zod@4.4.3: resolution: {integrity: sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==} + zustand@4.5.7: + resolution: {integrity: sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + zustand@5.0.0: resolution: {integrity: sha512-LE+VcmbartOPM+auOjCCLQOsQ05zUTp8RkgwRzefUk+2jISdMMFnxvyTjA4YNWr5ZGXYbVsEMZosttuxUBkojQ==} engines: {node: '>=12.20.0'} @@ -9279,6 +9359,12 @@ snapshots: '@csstools/css-tokenizer@4.0.0': {} + '@dagrejs/dagre@3.0.0': + dependencies: + '@dagrejs/graphlib': 4.0.1 + + '@dagrejs/graphlib@4.0.1': {} + '@depay/solana-web3.js@1.98.3': dependencies: bs58: 5.0.0 @@ -12694,6 +12780,10 @@ snapshots: '@types/d3-color@3.1.3': {} + '@types/d3-drag@3.0.7': + dependencies: + '@types/d3-selection': 3.0.11 + '@types/d3-ease@3.0.2': {} '@types/d3-interpolate@3.0.4': @@ -12706,6 +12796,8 @@ snapshots: dependencies: '@types/d3-time': 3.0.4 + '@types/d3-selection@3.0.11': {} + '@types/d3-shape@3.1.8': dependencies: '@types/d3-path': 3.1.1 @@ -12714,6 +12806,15 @@ snapshots: '@types/d3-timer@3.0.2': {} + '@types/d3-transition@3.0.9': + dependencies: + '@types/d3-selection': 3.0.11 + + '@types/d3-zoom@3.0.8': + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/estree@1.0.9': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -13870,6 +13971,31 @@ snapshots: '@xtuc/long@4.2.2': {} + '@xyflow/react@12.11.2(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(immer@11.1.8)(react-dom@19.2.7(react@19.2.7))(react@19.2.7)': + dependencies: + '@xyflow/system': 0.0.79 + classcat: 5.0.5 + react: 19.2.7 + react-dom: 19.2.7(react@19.2.7) + zustand: 4.5.7(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + '@types/react-dom': 19.2.3(@types/react@19.2.17) + transitivePeerDependencies: + - immer + + '@xyflow/system@0.0.79': + dependencies: + '@types/d3-drag': 3.0.7 + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.11 + '@types/d3-transition': 3.0.9 + '@types/d3-zoom': 3.0.8 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + '@yarnpkg/parsers@3.0.3': dependencies: js-yaml: 3.14.2 @@ -14308,6 +14434,8 @@ snapshots: cjs-module-lexer@2.2.0: {} + classcat@5.0.5: {} + classnames@2.5.1: {} cli-cursor@5.0.0: @@ -14443,6 +14571,13 @@ snapshots: d3-color@3.1.0: {} + d3-dispatch@3.0.1: {} + + d3-drag@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + d3-ease@3.0.1: {} d3-format@3.1.2: {} @@ -14461,6 +14596,8 @@ snapshots: d3-time: 3.1.0 d3-time-format: 4.1.0 + d3-selection@3.0.0: {} + d3-shape@3.2.0: dependencies: d3-path: 3.1.0 @@ -14475,6 +14612,23 @@ snapshots: d3-timer@3.0.1: {} + d3-transition@3.0.1(d3-selection@3.0.0): + dependencies: + d3-color: 3.1.0 + d3-dispatch: 3.0.1 + d3-ease: 3.0.1 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-timer: 3.0.1 + + d3-zoom@3.0.0: + dependencies: + d3-dispatch: 3.0.1 + d3-drag: 3.0.0 + d3-interpolate: 3.0.1 + d3-selection: 3.0.0 + d3-transition: 3.0.1(d3-selection@3.0.0) + data-uri-to-buffer@6.0.2: {} data-urls@5.0.0: @@ -18104,6 +18258,14 @@ snapshots: zod@4.4.3: {} + zustand@4.5.7(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7): + dependencies: + use-sync-external-store: 1.6.0(react@19.2.7) + optionalDependencies: + '@types/react': 19.2.17 + immer: 11.1.8 + react: 19.2.7 + zustand@5.0.0(@types/react@19.2.17)(immer@11.1.8)(react@19.2.7)(use-sync-external-store@1.4.0(react@19.2.7)): optionalDependencies: '@types/react': 19.2.17 From 28692f6c8c097024e1b02c6b7f8ff17c5b827fda Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Wed, 15 Jul 2026 11:13:57 +0200 Subject: [PATCH 03/55] test(APP-942): cover permissions graph data paths --- .../executeSelectorConditionSlot.test.tsx | 96 ++++++++++++++++++ .../permissionGraphLayout.test.ts | 99 +++++++++++++++++++ 2 files changed, 195 insertions(+) create mode 100644 apps/app/src/modules/settings/utils/permissionGraphLayout/permissionGraphLayout.test.ts diff --git a/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx index b7d77bab70..7cbf96e968 100644 --- a/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx +++ b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx @@ -1,9 +1,20 @@ import { GukModulesProvider } from '@aragon/gov-ui-kit'; import { render, screen } from '@testing-library/react'; +import { + type IAllowedAction, + useAllowedActions, +} from '@/modules/governance/api/executeSelectorsService'; import type { IConditionData } from '@/modules/settings/types'; +import { Network } from '@/shared/api/daoService'; import { ExecuteSelectorConditionSlot } from './executeSelectorConditionSlot'; +jest.mock('@/modules/governance/api/executeSelectorsService', () => ({ + ...jest.requireActual('@/modules/governance/api/executeSelectorsService'), + useAllowedActions: jest.fn(), +})); + describe(' component', () => { + const useAllowedActionsMock = jest.mocked(useAllowedActions); const createTestComponent = (props?: Partial) => { const completeProps: IConditionData = { conditionType: 'execute-selector', @@ -17,6 +28,35 @@ describe(' component', () => { ); }; + beforeEach(() => { + useAllowedActionsMock.mockReset(); + }); + + const mockAllowedActions = ( + data: Array< + Partial & { + conditionAddress: string; + decoded: IAllowedAction['decoded']; + id: string; + selector: string; + target: string; + } + >, + ) => { + useAllowedActionsMock.mockReturnValue({ + data: { + pages: [ + { + data, + metadata: { totalRecords: data.length }, + }, + ], + pageParams: [], + }, + isLoading: false, + } as unknown as ReturnType); + }; + it('renders the description and the selectors mapped to their truncated targets', () => { render( createTestComponent({ @@ -37,6 +77,62 @@ describe(' component', () => { expect(screen.getByText('0xDe0B…7BAe')).toBeInTheDocument(); }); + it('renders decoded actions resolved from the backend for matching selectors and condition', () => { + const conditionAddress = '0xC0Ffee254729296a45a3885639AC7E10F9d54979'; + const matchingTarget = '0x0bA45A8b5d5575935B8158a88C631E9F9C95a2e5'; + const unrelatedTarget = '0xDe0B295669a9FD93d5F28D9Ec85E40f4cb697BAe'; + + mockAllowedActions([ + { + conditionAddress, + decoded: { + contractName: 'AddressGaugeVoter', + functionName: 'pause', + inputs: [], + }, + id: 'decoded-pause', + selector: '0x8456cb59', + target: matchingTarget, + }, + { + conditionAddress: '0xdEAD000000000000000042069420694206942069', + decoded: { + contractName: 'AddressGaugeVoter', + functionName: 'unrelated', + inputs: [], + }, + id: 'decoded-unrelated', + selector: '0x8456cb59', + target: unrelatedTarget, + }, + ]); + + render( + createTestComponent({ + chainId: 42_161, + conditionAddress, + network: Network.ARBITRUM_MAINNET, + pluginAddress: '0x1234567890123456789012345678901234567890', + selectors: ['0x8456cb59'], + targets: [matchingTarget], + }), + ); + + expect(useAllowedActionsMock).toHaveBeenCalledWith({ + queryParams: { pageSize: 50 }, + urlParams: { + network: Network.ARBITRUM_MAINNET, + pluginAddress: '0x1234567890123456789012345678901234567890', + }, + }); + expect(screen.getByText('pause')).toBeInTheDocument(); + expect(screen.getByText('0x8456cb59')).toBeInTheDocument(); + expect(screen.getByText('AddressGaugeVoter')).toBeInTheDocument(); + expect(screen.getByText('0x0bA4…a2e5')).toBeInTheDocument(); + expect(screen.queryByText('unrelated')).not.toBeInTheDocument(); + expect(screen.queryByText('0xDe0B…7BAe')).not.toBeInTheDocument(); + }); + it('shows the no allowed actions fallback when selectors are absent', () => { render(createTestComponent({ selectors: undefined })); diff --git a/apps/app/src/modules/settings/utils/permissionGraphLayout/permissionGraphLayout.test.ts b/apps/app/src/modules/settings/utils/permissionGraphLayout/permissionGraphLayout.test.ts new file mode 100644 index 0000000000..b552594f99 --- /dev/null +++ b/apps/app/src/modules/settings/utils/permissionGraphLayout/permissionGraphLayout.test.ts @@ -0,0 +1,99 @@ +import type { Edge, Node } from '@xyflow/react'; +import { getLayoutedElements } from './permissionGraphLayout'; + +if (globalThis.structuredClone == null) { + Object.defineProperty(globalThis, 'structuredClone', { + value: (value: T): T => JSON.parse(JSON.stringify(value)) as T, + }); +} + +const buildNode = (id: string, partial?: Partial): Node => ({ + id, + data: {}, + position: { x: 0, y: 0 }, + ...partial, +}); + +describe('getLayoutedElements', () => { + it('converts dagre center coordinates to React Flow top-left coordinates', () => { + const measuredNode = buildNode('dao', { + measured: { width: 240, height: 96 }, + }); + + const { nodes } = getLayoutedElements([measuredNode], []); + + expect(nodes[0]).toMatchObject({ position: { x: 0, y: 0 } }); + expect(nodes[0]).not.toBe(measuredNode); + }); + + it('uses permission stack dimensions when placing stack nodes between endpoints', () => { + const nodes = [ + buildNode('who', { measured: { width: 240, height: 96 } }), + buildNode('stack', { + type: 'permissionStack', + measured: { width: 120, height: 32 }, + }), + buildNode('where', { measured: { width: 240, height: 96 } }), + ]; + const edges: Edge[] = [ + { id: 'who-stack', source: 'who', target: 'stack' }, + { id: 'stack-where', source: 'stack', target: 'where' }, + ]; + + const { nodes: layoutedNodes } = getLayoutedElements(nodes, edges, { + direction: 'LR', + ranksep: 100, + }); + const nodeById = new Map(layoutedNodes.map((node) => [node.id, node])); + const who = nodeById.get('who')!; + const stack = nodeById.get('stack')!; + const where = nodeById.get('where')!; + const whoCenterX = who.position.x + 120; + const stackCenterX = stack.position.x + 60; + const whereCenterX = where.position.x + 120; + + expect(whoCenterX).toBeLessThan(stackCenterX); + expect(stackCenterX).toBeLessThan(whereCenterX); + }); + + it('skips edges marked as excluded from layout', () => { + const nodes = [ + buildNode('dao', { measured: { width: 240, height: 96 } }), + buildNode('self-stack', { + type: 'permissionStack', + measured: { width: 120, height: 32 }, + }), + ]; + const excludedEdge = { + id: 'self-stack-dao', + source: 'self-stack', + target: 'dao', + data: { excludeFromLayout: true }, + } satisfies Edge; + + const { nodes: excludedLayoutNodes, edges } = getLayoutedElements( + nodes, + [excludedEdge], + { direction: 'TB' }, + ); + const { nodes: includedLayoutNodes } = getLayoutedElements( + nodes, + [{ ...excludedEdge, data: {} }], + { direction: 'TB' }, + ); + const excludedNodeById = new Map( + excludedLayoutNodes.map((node) => [node.id, node]), + ); + const includedNodeById = new Map( + includedLayoutNodes.map((node) => [node.id, node]), + ); + + expect(edges).toEqual([excludedEdge]); + expect(excludedNodeById.get('dao')!.position.y).toBeLessThan( + excludedNodeById.get('self-stack')!.position.y, + ); + expect(includedNodeById.get('dao')!.position.y).toBeGreaterThan( + includedNodeById.get('self-stack')!.position.y, + ); + }); +}); From b96f3248919bb1ca4f6d28cc0726af1362e45a30 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Wed, 15 Jul 2026 11:40:06 +0200 Subject: [PATCH 04/55] chore(APP-942): add changeset --- .changeset/app-942-permissions-graph-view.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/app-942-permissions-graph-view.md diff --git a/.changeset/app-942-permissions-graph-view.md b/.changeset/app-942-permissions-graph-view.md new file mode 100644 index 0000000000..f7f1a3e5b6 --- /dev/null +++ b/.changeset/app-942-permissions-graph-view.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": minor +--- + +Add the DAO permissions graph view backed by real permissions data From d784af2b2f0c22cc66b8cfc8392c8a5a775f36b2 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Wed, 15 Jul 2026 12:17:50 +0200 Subject: [PATCH 05/55] fix(APP-942): show unrecognized graph conditions --- .../permissionsGraph/permissionsGraph.tsx | 9 +++++++- .../permissionsList/permissionsList.tsx | 16 +------------- .../unrecognizedConditionSlot/index.ts | 1 + .../unrecognizedConditionSlot.test.tsx | 22 +++++++++++++++++++ .../unrecognizedConditionSlot.tsx | 19 ++++++++++++++++ 5 files changed, 51 insertions(+), 16 deletions(-) create mode 100644 apps/app/src/modules/settings/components/unrecognizedConditionSlot/index.ts create mode 100644 apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.test.tsx create mode 100644 apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.tsx diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx index 26ac581b4b..39a27507a8 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx @@ -26,9 +26,13 @@ import type { IPermissionRow, } from '../../types'; import { buildPermissionGraph } from '../../utils/buildPermissionGraph'; -import { conditionTypeUtils } from '../../utils/conditionTypeUtils'; +import { + conditionTypeUtils, + UNKNOWN_CONDITION, +} from '../../utils/conditionTypeUtils'; import type { IPermissionAccountRef } from '../../utils/permissionEntityUtils'; import { NoConditionSlot } from '../noConditionSlot'; +import { UnrecognizedConditionSlot } from '../unrecognizedConditionSlot'; import { type GraphMode, PermissionsGraphCanvas, @@ -141,6 +145,7 @@ const PermissionDetailPanel: React.FC = ({ row.conditionAddress, row.condition, ); + const hasUnrecognizedCondition = conditionType === UNKNOWN_CONDITION; const isWhoAnyAddress = addressUtils.isAddressEqual( row.whoAddress, @@ -335,6 +340,8 @@ const PermissionDetailPanel: React.FC = ({ {addressUtils.truncateHash(row.permissionId)} + ) : hasUnrecognizedCondition ? ( + ) : ( [] | undefined; @@ -208,21 +209,6 @@ const PermissionDetailValue: React.FC = ({
); -const UnrecognizedConditionSlot: React.FC = () => { - const { t } = useTranslations(); - - return ( - - ); -}; - const PermissionsListRow: React.FC = (props) => { const { row, rowKey, daoPlugins, accounts, chainId, network } = props; diff --git a/apps/app/src/modules/settings/components/unrecognizedConditionSlot/index.ts b/apps/app/src/modules/settings/components/unrecognizedConditionSlot/index.ts new file mode 100644 index 0000000000..853e89abf3 --- /dev/null +++ b/apps/app/src/modules/settings/components/unrecognizedConditionSlot/index.ts @@ -0,0 +1 @@ +export { UnrecognizedConditionSlot } from './unrecognizedConditionSlot'; diff --git a/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.test.tsx b/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.test.tsx new file mode 100644 index 0000000000..60c55cdb87 --- /dev/null +++ b/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.test.tsx @@ -0,0 +1,22 @@ +import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import { UnrecognizedConditionSlot } from './unrecognizedConditionSlot'; + +describe(' component', () => { + const createTestComponent = () => ( + + + + ); + + it('renders the unrecognized condition heading and description copy', () => { + render(createTestComponent()); + + expect( + screen.getByText(/unrecognizedConditionSlot.heading/), + ).toBeInTheDocument(); + expect( + screen.getByText(/unrecognizedConditionSlot.description/), + ).toBeInTheDocument(); + }); +}); diff --git a/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.tsx b/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.tsx new file mode 100644 index 0000000000..49f8e8489d --- /dev/null +++ b/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.tsx @@ -0,0 +1,19 @@ +'use client'; + +import { CardEmptyState } from '@aragon/gov-ui-kit'; +import { useTranslations } from '@/shared/components/translationsProvider'; + +export const UnrecognizedConditionSlot: React.FC = () => { + const { t } = useTranslations(); + + return ( + + ); +}; From 751552aeace227050c91c38663f41d26e7ed2464 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Wed, 15 Jul 2026 17:50:21 +0200 Subject: [PATCH 06/55] fix(APP-942): address permissions review feedback --- .../allowedActionsList.tsx | 74 +++++ .../decodedAllowedActionsList.tsx | 79 +++++ .../executeSelectorConditionSlot.tsx | 213 +------------- .../executeSelectorConditionSlotUtils.ts | 74 +++++ .../membershipConditionSlot.tsx | 13 +- .../permissionDetailPanel.tsx | 267 +++++++++++++++++ .../permissionsGraph/permissionsGraph.tsx | 270 +----------------- .../permissionsList/permissionsList.tsx | 5 +- .../unrecognizedConditionSlot.test.tsx | 26 +- .../unrecognizedConditionSlot.tsx | 62 +++- ...SppPermissionCheckProposalCreation.test.ts | 13 +- 11 files changed, 602 insertions(+), 494 deletions(-) create mode 100644 apps/app/src/modules/settings/components/executeSelectorConditionSlot/allowedActionsList.tsx create mode 100644 apps/app/src/modules/settings/components/executeSelectorConditionSlot/decodedAllowedActionsList.tsx create mode 100644 apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlotUtils.ts create mode 100644 apps/app/src/modules/settings/components/permissionsGraph/permissionDetailPanel.tsx diff --git a/apps/app/src/modules/settings/components/executeSelectorConditionSlot/allowedActionsList.tsx b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/allowedActionsList.tsx new file mode 100644 index 0000000000..fdec016df7 --- /dev/null +++ b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/allowedActionsList.tsx @@ -0,0 +1,74 @@ +'use client'; + +import { + addressUtils, + ChainEntityType, + Link, + useBlockExplorer, +} from '@aragon/gov-ui-kit'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import type { IAllowedActionView } from './executeSelectorConditionSlotUtils'; +import { EMPTY_ALLOWED_ACTION_VALUE } from './executeSelectorConditionSlotUtils'; + +export interface IAllowedActionsListProps { + actions: IAllowedActionView[]; + chainId?: number; +} + +export const AllowedActionsList: React.FC = ({ + actions, + chainId, +}) => { + const { t } = useTranslations(); + const { buildEntityUrl } = useBlockExplorer({ chainId }); + + return ( +
+ {actions.map((action) => ( +
+
+ + {action.functionName ?? + action.selector ?? + t( + 'app.settings.executeSelectorConditionSlot.anySelector', + )} + + {action.selector != null && ( + + {action.selector} + + )} +
+
+ + {action.contractName ?? + t( + 'app.settings.executeSelectorConditionSlot.unknownContract', + )} + + {action.target === EMPTY_ALLOWED_ACTION_VALUE ? ( + + {EMPTY_ALLOWED_ACTION_VALUE} + + ) : ( + + {addressUtils.truncateAddress(action.target)} + + )} +
+
+ ))} +
+ ); +}; diff --git a/apps/app/src/modules/settings/components/executeSelectorConditionSlot/decodedAllowedActionsList.tsx b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/decodedAllowedActionsList.tsx new file mode 100644 index 0000000000..82274d6adb --- /dev/null +++ b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/decodedAllowedActionsList.tsx @@ -0,0 +1,79 @@ +'use client'; + +import { StateSkeletonBar } from '@aragon/gov-ui-kit'; +import { useAllowedActions } from '@/modules/governance/api/executeSelectorsService'; +import type { Network } from '@/shared/api/daoService'; +import { AllowedActionsList } from './allowedActionsList'; +import { + hasDecodedAllowedAction, + type IRawAllowedAction, + toAllowedActionViews, +} from './executeSelectorConditionSlotUtils'; + +interface IDecodedAllowedActionsListProps { + chainId?: number; + conditionAddress?: string; + network: Network; + pluginAddress: string; + rawAllowedActions: IRawAllowedAction[]; +} + +const AllowedActionsSkeleton: React.FC = () => ( +
+ + + +
+); + +export const DecodedAllowedActionsList: React.FC< + IDecodedAllowedActionsListProps +> = ({ + chainId, + conditionAddress, + network, + pluginAddress, + rawAllowedActions, +}) => { + const { data, isLoading } = useAllowedActions({ + urlParams: { network, pluginAddress }, + queryParams: { pageSize: 50 }, + }); + const decodedAllowedActions = + data?.pages + .flatMap((page) => page.data) + .filter((action) => + hasDecodedAllowedAction( + action, + rawAllowedActions, + conditionAddress, + ), + ) ?? []; + const decodedAllowedActionViews = decodedAllowedActions.map((action) => ({ + contractName: action.decoded.contractName, + functionName: action.decoded.functionName, + id: action.id, + selector: action.selector, + target: action.target, + })); + + if (isLoading) { + return ; + } + + if (decodedAllowedActionViews.length > 0) { + return ( + + ); + } + + return ( + + ); +}; diff --git a/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx index 3ac05465d8..4a3cc35525 100644 --- a/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx +++ b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx @@ -1,25 +1,14 @@ 'use client'; -import { - addressUtils, - ChainEntityType, - Link, - StateSkeletonBar, - useBlockExplorer, -} from '@aragon/gov-ui-kit'; -import type { IAllowedAction } from '@/modules/governance/api/executeSelectorsService'; -import { useAllowedActions } from '@/modules/governance/api/executeSelectorsService'; import type { IConditionData } from '@/modules/settings/types'; import type { Network } from '@/shared/api/daoService'; import { useTranslations } from '@/shared/components/translationsProvider'; -import { stringUtils } from '@/shared/utils/stringUtils'; - -const EMPTY_VALUE = '—'; - -interface IRawAllowedAction { - selector: string | null; - target: string; -} +import { AllowedActionsList } from './allowedActionsList'; +import { DecodedAllowedActionsList } from './decodedAllowedActionsList'; +import { + toAllowedActions, + toAllowedActionViews, +} from './executeSelectorConditionSlotUtils'; interface IExecuteSelectorConditionSlotProps extends IConditionData { chainId?: number; @@ -28,192 +17,6 @@ interface IExecuteSelectorConditionSlotProps extends IConditionData { pluginAddress?: string; } -interface IAllowedActionView { - contractName?: string; - functionName?: string; - id: string; - selector: string | null; - target: string; -} - -const toSelectorList = (value: unknown): Array => - Array.isArray(value) - ? value.filter((item): item is string | null => - item === null ? true : stringUtils.isNonEmptyString(item), - ) - : []; - -const toTargetList = (value: unknown): string[] => - Array.isArray(value) ? value.filter(stringUtils.isNonEmptyString) : []; - -const toAllowedActions = ( - selectors: unknown, - targets: unknown, -): IRawAllowedAction[] => { - const selectorList = toSelectorList(selectors); - const targetList = toTargetList(targets); - - return selectorList.map((selector, index) => ({ - selector, - target: targetList[index] ?? EMPTY_VALUE, - })); -}; - -const hasDecodedAllowedAction = ( - action: IAllowedAction, - rawActions: IRawAllowedAction[], - conditionAddress?: string, -) => { - const matchesCondition = - conditionAddress == null || - addressUtils.isAddressEqual(action.conditionAddress, conditionAddress); - - if (!matchesCondition) { - return false; - } - - if (rawActions.length === 0) { - return true; - } - - return rawActions.some( - (rawAction) => - rawAction.selector === action.selector && - addressUtils.isAddressEqual(rawAction.target, action.target), - ); -}; - -const AllowedActionsList: React.FC<{ - actions: IAllowedActionView[]; - chainId?: number; -}> = ({ actions, chainId }) => { - const { t } = useTranslations(); - const { buildEntityUrl } = useBlockExplorer({ chainId }); - - return ( -
- {actions.map((action) => ( -
-
- - {action.functionName ?? - action.selector ?? - t( - 'app.settings.executeSelectorConditionSlot.anySelector', - )} - - {action.selector != null && ( - - {action.selector} - - )} -
-
- - {action.contractName ?? - t( - 'app.settings.executeSelectorConditionSlot.unknownContract', - )} - - {action.target === EMPTY_VALUE ? ( - {EMPTY_VALUE} - ) : ( - - {addressUtils.truncateAddress(action.target)} - - )} -
-
- ))} -
- ); -}; - -const AllowedActionsSkeleton: React.FC = () => ( -
- - - -
-); - -interface IDecodedAllowedActionsListProps { - chainId?: number; - conditionAddress?: string; - network: Network; - pluginAddress: string; - rawAllowedActions: IRawAllowedAction[]; -} - -const DecodedAllowedActionsList: React.FC = ({ - chainId, - conditionAddress, - network, - pluginAddress, - rawAllowedActions, -}) => { - const { data, isLoading } = useAllowedActions({ - urlParams: { network, pluginAddress }, - queryParams: { pageSize: 50 }, - }); - const decodedAllowedActions = - data?.pages - .flatMap((page) => page.data) - .filter((action) => - hasDecodedAllowedAction( - action, - rawAllowedActions, - conditionAddress, - ), - ) ?? []; - const decodedAllowedActionViews = decodedAllowedActions.map((action) => ({ - contractName: action.decoded.contractName, - functionName: action.decoded.functionName, - id: action.id, - selector: action.selector, - target: action.target, - })); - - if (isLoading) { - return ; - } - - if (decodedAllowedActionViews.length > 0) { - return ( - - ); - } - - return ( - - ); -}; - -const toAllowedActionViews = ( - actions: IRawAllowedAction[], -): IAllowedActionView[] => - actions.map((action, index) => ({ - ...action, - id: `${action.selector ?? 'any'}-${action.target}-${index}`, - functionName: action.selector ?? undefined, - })); - export const ExecuteSelectorConditionSlot: React.FC = ( props, ) => { @@ -229,7 +32,7 @@ export const ExecuteSelectorConditionSlot: React.FC = ( const rawAllowedActions = toAllowedActions(selectors, targets); const hasRawAllowedActions = rawAllowedActions.length > 0; - const shouldFetchDecodedActions = + const shouldShowDecodedActions = network != null && pluginAddress != null && hasRawAllowedActions; return ( @@ -237,7 +40,7 @@ export const ExecuteSelectorConditionSlot: React.FC = (

{t('app.settings.executeSelectorConditionSlot.description')}

- {shouldFetchDecodedActions ? ( + {shouldShowDecodedActions ? ( => + Array.isArray(value) + ? value.filter((item): item is string | null => + item === null ? true : stringUtils.isNonEmptyString(item), + ) + : []; + +const toTargetList = (value: unknown): string[] => + Array.isArray(value) ? value.filter(stringUtils.isNonEmptyString) : []; + +export const toAllowedActions = ( + selectors: unknown, + targets: unknown, +): IRawAllowedAction[] => { + const selectorList = toSelectorList(selectors); + const targetList = toTargetList(targets); + + return selectorList.map((selector, index) => ({ + selector, + target: targetList[index] ?? EMPTY_ALLOWED_ACTION_VALUE, + })); +}; + +export const toAllowedActionViews = ( + actions: IRawAllowedAction[], +): IAllowedActionView[] => + actions.map((action, index) => ({ + ...action, + id: `${action.selector ?? 'any'}-${action.target}-${index}`, + functionName: action.selector ?? undefined, + })); + +export const hasDecodedAllowedAction = ( + action: IAllowedAction, + rawActions: IRawAllowedAction[], + conditionAddress?: string, +) => { + const matchesCondition = + conditionAddress == null || + addressUtils.isAddressEqual(action.conditionAddress, conditionAddress); + + if (!matchesCondition) { + return false; + } + + if (rawActions.length === 0) { + return true; + } + + return rawActions.some( + (rawAction) => + rawAction.selector === action.selector && + addressUtils.isAddressEqual(rawAction.target, action.target), + ); +}; diff --git a/apps/app/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.tsx b/apps/app/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.tsx index dd01a4cb05..5da3161a7f 100644 --- a/apps/app/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.tsx +++ b/apps/app/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.tsx @@ -4,6 +4,11 @@ import { DefinitionList, Tag } from '@aragon/gov-ui-kit'; import type { IConditionData } from '@/modules/settings/types'; import { useTranslations } from '@/shared/components/translationsProvider'; +interface IMembershipConditionData extends IConditionData { + minApprovals?: number; + onlyListed?: boolean; +} + /** * Renders the detail for a multisig `membership` condition (ListedCheckCondition). * The condition only gates proposal creation to listed members when the multisig @@ -11,12 +16,10 @@ import { useTranslations } from '@/shared/components/translationsProvider'; * "Member of multisig". */ export const MembershipConditionSlot: React.FC = (props) => { - const { onlyListed, minApprovals } = props; + const { onlyListed, minApprovals } = props as IMembershipConditionData; const { t } = useTranslations(); const isMemberGated = onlyListed === true; - const approvalsLabel = - typeof minApprovals === 'number' ? minApprovals.toString() : undefined; return ( @@ -36,13 +39,13 @@ export const MembershipConditionSlot: React.FC = (props) => { /> - {approvalsLabel != null && ( + {minApprovals != null && ( - {approvalsLabel} + {minApprovals} )} diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionDetailPanel.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionDetailPanel.tsx new file mode 100644 index 0000000000..e16c3beceb --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionDetailPanel.tsx @@ -0,0 +1,267 @@ +'use client'; + +import { + addressUtils, + Button, + DefinitionList, + IconType, + Toggle, + ToggleGroup, +} from '@aragon/gov-ui-kit'; +import { useRef, useState } from 'react'; +import type { IDao } from '@/shared/api/daoService'; +import { PluginSingleComponent } from '@/shared/components/pluginSingleComponent'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { SettingsSlotId } from '../../constants/moduleSlots'; +import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; +import type { IPermissionGraph, IPermissionGraphEdge } from '../../types'; +import { + conditionTypeUtils, + UNKNOWN_CONDITION, +} from '../../utils/conditionTypeUtils'; +import { NoConditionSlot } from '../noConditionSlot'; +import { UnrecognizedConditionSlot } from '../unrecognizedConditionSlot'; + +export interface IPermissionDetailPanelProps { + chainId?: number; + edge: IPermissionGraphEdge; + network?: IDao['network']; + nodes: IPermissionGraph['nodes']; + onClose: () => void; +} + +export const PermissionDetailPanel: React.FC = ({ + chainId, + edge, + network, + nodes, + onClose, +}) => { + const { t } = useTranslations(); + const { row } = edge; + const who = nodes.find((node) => node.id === edge.source); + const where = nodes.find((node) => node.id === edge.target); + const hasCondition = !addressUtils.isAddressEqual( + row.conditionAddress, + ALLOW_FLAG, + ); + const conditionType = conditionTypeUtils.resolveConditionType( + row.conditionAddress, + row.condition, + ); + const hasUnrecognizedCondition = conditionType === UNKNOWN_CONDITION; + + const isWhoAnyAddress = addressUtils.isAddressEqual( + row.whoAddress, + ANY_ADDR, + ); + const isWhereAnyAddress = addressUtils.isAddressEqual( + row.whereAddress, + ANY_ADDR, + ); + const panelRef = useRef(null); + const dragOffsetRef = useRef<{ x: number; y: number } | undefined>( + undefined, + ); + const [position, setPosition] = useState({ x: 16, y: 80 }); + const [isDragging, setIsDragging] = useState(false); + const [activeTab, setActiveTab] = useState<'permission' | 'condition'>( + 'permission', + ); + + const clampPosition = (next: { x: number; y: number }) => { + const panel = panelRef.current; + const container = panel?.parentElement; + + if (panel == null || container == null) { + return next; + } + + const margin = 16; + const maxX = Math.max( + margin, + container.clientWidth - panel.offsetWidth - margin, + ); + const maxY = Math.max( + margin, + container.clientHeight - panel.offsetHeight - margin, + ); + + return { + x: Math.min(Math.max(next.x, margin), maxX), + y: Math.min(Math.max(next.y, margin), maxY), + }; + }; + + const handleDragStart = (event: React.PointerEvent) => { + const panel = panelRef.current; + + if (panel == null) { + return; + } + + const panelRect = panel.getBoundingClientRect(); + dragOffsetRef.current = { + x: event.clientX - panelRect.left, + y: event.clientY - panelRect.top, + }; + setIsDragging(true); + event.currentTarget.setPointerCapture(event.pointerId); + }; + + const handleDragMove = (event: React.PointerEvent) => { + if (!isDragging || dragOffsetRef.current == null) { + return; + } + + const container = panelRef.current?.parentElement; + + if (container == null) { + return; + } + + const containerRect = container.getBoundingClientRect(); + const nextPosition = { + x: event.clientX - containerRect.left - dragOffsetRef.current.x, + y: event.clientY - containerRect.top - dragOffsetRef.current.y, + }; + + setPosition(clampPosition(nextPosition)); + }; + + const handleDragEnd = (event: React.PointerEvent) => { + dragOffsetRef.current = undefined; + setIsDragging(false); + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }; + + const handleTabChange = (value?: string | string[]) => { + if (value === 'permission' || value === 'condition') { + setActiveTab(value); + } + }; + + return ( +
+
+
+

+ {edge.permissionName} +

+ {edge.conditionLabel != null && ( +

+ {t( + 'app.settings.daoPermissionsPage.graphView.edge.condition', + { condition: edge.conditionLabel }, + )} +

+ )} +
+
event.stopPropagation()}> +
+
+
+
+

+ {t('app.settings.permissionsList.details.heading')} +

+ + + + +
+ {activeTab === 'permission' ? ( + + + {isWhoAnyAddress + ? who?.label + : addressUtils.truncateAddress(row.whoAddress)} + + + {isWhereAnyAddress + ? where?.label + : addressUtils.truncateAddress( + row.whereAddress, + )} + + + {addressUtils.truncateHash(row.permissionId)} + + + ) : hasUnrecognizedCondition ? ( + + ) : ( + + )} +
+
+ ); +}; diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx index 39a27507a8..7a20c8d50d 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx @@ -1,38 +1,17 @@ 'use client'; -import { - addressUtils, - Button, - CardEmptyState, - DefinitionList, - IconType, - StateSkeletonBar, - Toggle, - ToggleGroup, -} from '@aragon/gov-ui-kit'; +import { CardEmptyState, StateSkeletonBar } from '@aragon/gov-ui-kit'; import '@xyflow/react/dist/style.css'; import { ReactFlowProvider } from '@xyflow/react'; -import { useMemo, useRef, useState } from 'react'; +import { useMemo, useState } from 'react'; import type { IDao, IDaoPlugin } from '@/shared/api/daoService'; import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; -import { PluginSingleComponent } from '@/shared/components/pluginSingleComponent'; import { useTranslations } from '@/shared/components/translationsProvider'; import { networkDefinitions } from '@/shared/constants/networkDefinitions'; -import { SettingsSlotId } from '../../constants/moduleSlots'; -import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; -import type { - IPermissionGraph, - IPermissionGraphEdge, - IPermissionRow, -} from '../../types'; +import type { IPermissionRow } from '../../types'; import { buildPermissionGraph } from '../../utils/buildPermissionGraph'; -import { - conditionTypeUtils, - UNKNOWN_CONDITION, -} from '../../utils/conditionTypeUtils'; import type { IPermissionAccountRef } from '../../utils/permissionEntityUtils'; -import { NoConditionSlot } from '../noConditionSlot'; -import { UnrecognizedConditionSlot } from '../unrecognizedConditionSlot'; +import { PermissionDetailPanel } from './permissionDetailPanel'; import { type GraphMode, PermissionsGraphCanvas, @@ -118,247 +97,6 @@ export const PermissionsGraph: React.FC = (props) => { ); }; -interface IPermissionDetailPanelProps { - chainId?: number; - edge: IPermissionGraphEdge; - network?: IDao['network']; - nodes: IPermissionGraph['nodes']; - onClose: () => void; -} - -const PermissionDetailPanel: React.FC = ({ - chainId, - edge, - network, - nodes, - onClose, -}) => { - const { t } = useTranslations(); - const { row } = edge; - const who = nodes.find((node) => node.id === edge.source); - const where = nodes.find((node) => node.id === edge.target); - const hasCondition = !addressUtils.isAddressEqual( - row.conditionAddress, - ALLOW_FLAG, - ); - const conditionType = conditionTypeUtils.resolveConditionType( - row.conditionAddress, - row.condition, - ); - const hasUnrecognizedCondition = conditionType === UNKNOWN_CONDITION; - - const isWhoAnyAddress = addressUtils.isAddressEqual( - row.whoAddress, - ANY_ADDR, - ); - const isWhereAnyAddress = addressUtils.isAddressEqual( - row.whereAddress, - ANY_ADDR, - ); - const panelRef = useRef(null); - const dragOffsetRef = useRef<{ x: number; y: number } | undefined>( - undefined, - ); - const [position, setPosition] = useState({ x: 16, y: 80 }); - const [isDragging, setIsDragging] = useState(false); - const [activeTab, setActiveTab] = useState<'permission' | 'condition'>( - 'permission', - ); - - const clampPosition = (next: { x: number; y: number }) => { - const panel = panelRef.current; - const container = panel?.parentElement; - - if (panel == null || container == null) { - return next; - } - - const margin = 16; - const maxX = Math.max( - margin, - container.clientWidth - panel.offsetWidth - margin, - ); - const maxY = Math.max( - margin, - container.clientHeight - panel.offsetHeight - margin, - ); - - return { - x: Math.min(Math.max(next.x, margin), maxX), - y: Math.min(Math.max(next.y, margin), maxY), - }; - }; - - const handleDragStart = (event: React.PointerEvent) => { - const panel = panelRef.current; - - if (panel == null) { - return; - } - - const panelRect = panel.getBoundingClientRect(); - dragOffsetRef.current = { - x: event.clientX - panelRect.left, - y: event.clientY - panelRect.top, - }; - setIsDragging(true); - event.currentTarget.setPointerCapture(event.pointerId); - }; - - const handleDragMove = (event: React.PointerEvent) => { - if (!isDragging || dragOffsetRef.current == null) { - return; - } - - const container = panelRef.current?.parentElement; - - if (container == null) { - return; - } - - const containerRect = container.getBoundingClientRect(); - const nextPosition = { - x: event.clientX - containerRect.left - dragOffsetRef.current.x, - y: event.clientY - containerRect.top - dragOffsetRef.current.y, - }; - - setPosition(clampPosition(nextPosition)); - }; - - const handleDragEnd = (event: React.PointerEvent) => { - dragOffsetRef.current = undefined; - setIsDragging(false); - - if (event.currentTarget.hasPointerCapture(event.pointerId)) { - event.currentTarget.releasePointerCapture(event.pointerId); - } - }; - - const handleTabChange = (value?: string | string[]) => { - if (value === 'permission' || value === 'condition') { - setActiveTab(value); - } - }; - - return ( -
-
-
-

- {edge.permissionName} -

- {edge.conditionLabel != null && ( -

- {t( - 'app.settings.daoPermissionsPage.graphView.edge.condition', - { condition: edge.conditionLabel }, - )} -

- )} -
-
event.stopPropagation()}> -
-
-
-
-

- {t('app.settings.permissionsList.details.heading')} -

- - - - -
- {activeTab === 'permission' ? ( - - - {isWhoAnyAddress - ? who?.label - : addressUtils.truncateAddress(row.whoAddress)} - - - {isWhereAnyAddress - ? where?.label - : addressUtils.truncateAddress( - row.whereAddress, - )} - - - {addressUtils.truncateHash(row.permissionId)} - - - ) : hasUnrecognizedCondition ? ( - - ) : ( - - )} -
-
- ); -}; - const PermissionsGraphSkeleton: React.FC = () => (
= (props) => { )}

{hasUnrecognizedCondition ? ( - + ) : ( component', () => { - const createTestComponent = () => ( + const conditionAddress = '0x1234567890abcdef1234567890abcdef12345678'; + + const createTestComponent = ( + props?: ComponentProps, + ) => ( - + ); @@ -19,4 +24,21 @@ describe(' component', () => { screen.getByText(/unrecognizedConditionSlot.description/), ).toBeInTheDocument(); }); + + it('renders the unrecognized condition address with explorer access', () => { + render(createTestComponent({ chainId: 1, conditionAddress })); + + expect( + screen.getByText(/permissionsList.details.condition/), + ).toBeInTheDocument(); + + const conditionLink = screen.getByRole('link', { + name: /0x1234.*5678/i, + }); + + expect(conditionLink).toHaveAttribute( + 'href', + expect.stringContaining(conditionAddress), + ); + }); }); diff --git a/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.tsx b/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.tsx index 49f8e8489d..1a4982e6b9 100644 --- a/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.tsx +++ b/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.tsx @@ -1,19 +1,63 @@ 'use client'; -import { CardEmptyState } from '@aragon/gov-ui-kit'; +import { + addressUtils, + CardEmptyState, + ChainEntityType, + DefinitionList, + Link, + useBlockExplorer, +} from '@aragon/gov-ui-kit'; import { useTranslations } from '@/shared/components/translationsProvider'; -export const UnrecognizedConditionSlot: React.FC = () => { +export interface IUnrecognizedConditionSlotProps { + chainId?: number; + conditionAddress?: string; +} + +export const UnrecognizedConditionSlot: React.FC< + IUnrecognizedConditionSlotProps +> = (props) => { + const { chainId, conditionAddress } = props; const { t } = useTranslations(); + const { buildEntityUrl } = useBlockExplorer({ chainId }); + + const conditionUrl = + conditionAddress != null + ? buildEntityUrl({ + type: ChainEntityType.ADDRESS, + id: conditionAddress, + }) + : undefined; return ( - + + {conditionAddress != null && ( + + + + {addressUtils.truncateAddress(conditionAddress)} + + + )} - heading={t('app.settings.unrecognizedConditionSlot.heading')} - isStacked={false} - objectIllustration={{ object: 'SETTINGS' }} - /> +
); }; diff --git a/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts b/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts index 399645ea2e..8509d985d1 100644 --- a/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts +++ b/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts @@ -91,8 +91,9 @@ describe('useSppPermissionCheckProposalCreation', () => { }); const mockSimulation = (result: { + isError: boolean; isLoading: boolean; - isSuccess: boolean; + result?: 'success' | 'failure'; }) => useSimulateProposalCreationSpy.mockReturnValue({ isError: false, @@ -109,7 +110,7 @@ describe('useSppPermissionCheckProposalCreation', () => { hasPermission: true, }); const params = createTestParams([guardResult]); - mockSimulation({ isLoading: false, isSuccess: true }); + mockSimulation({ isError: false, isLoading: false, result: 'success' }); const { result } = renderHook(() => useSppPermissionCheckProposalCreation( @@ -128,7 +129,7 @@ describe('useSppPermissionCheckProposalCreation', () => { generateGuardResult({ isRestricted: false }), generateGuardResult({ isRestricted: false }), ]); - mockSimulation({ isLoading: false, isSuccess: true }); + mockSimulation({ isError: false, isLoading: false, result: 'success' }); const { result } = renderHook(() => useSppPermissionCheckProposalCreation( @@ -152,7 +153,7 @@ describe('useSppPermissionCheckProposalCreation', () => { settings: restrictedSettings, }), ]); - mockSimulation({ isLoading: false, isSuccess: true }); + mockSimulation({ isError: false, isLoading: false, result: 'success' }); const { result } = renderHook(() => useSppPermissionCheckProposalCreation( @@ -170,7 +171,7 @@ describe('useSppPermissionCheckProposalCreation', () => { const params = createTestParams([ generateGuardResult({ isRestricted: true }), ]); - mockSimulation({ isLoading: false, isSuccess: false }); + mockSimulation({ isError: false, isLoading: false, result: 'failure' }); const { result } = renderHook(() => useSppPermissionCheckProposalCreation( @@ -186,7 +187,7 @@ describe('useSppPermissionCheckProposalCreation', () => { it('returns isLoading true while the simulation is loading', () => { const params = createTestParams([generateGuardResult()]); - mockSimulation({ isLoading: true, isSuccess: false }); + mockSimulation({ isError: false, isLoading: true }); const { result } = renderHook(() => useSppPermissionCheckProposalCreation( From 716b1434b78d854084a99be3fce446f1f8f300c2 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Wed, 15 Jul 2026 18:28:31 +0200 Subject: [PATCH 07/55] feat(APP-942): refine permissions graph interactions --- apps/app/src/assets/locales/en.json | 6 +- .../permissionsGraph/permissionGraphEdge.tsx | 1 + .../permissionsGraph/permissionGraphNode.tsx | 41 +++- .../permissionNodeDetailPanel.tsx | 94 ++++++++ .../permissionsGraph/permissionsGraph.tsx | 19 +- .../permissionsGraphCanvas.tsx | 204 +++++++++++------- .../modules/settings/types/permissionGraph.ts | 1 + .../buildPermissionGraph.test.ts | 1 + .../buildPermissionGraph.ts | 3 + .../permissionEntityUtils.test.ts | 8 +- .../permissionEntityUtils.ts | 3 +- .../permissionGraphLayout.ts | 2 +- .../permissionNameUtils.test.ts | 35 +++ .../permissionNameUtils.ts | 23 ++ 14 files changed, 353 insertions(+), 88 deletions(-) create mode 100644 apps/app/src/modules/settings/components/permissionsGraph/permissionNodeDetailPanel.tsx diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index 09df1228f0..c931b8e232 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -3555,7 +3555,7 @@ "dao": "Primary DAO", "linkedDao": "Linked DAO", "plugin": "Aragon OSx Plugin", - "actor": "Any address", + "actor": "Unresolved address", "who": "Who", "where": "Where" }, @@ -3563,7 +3563,9 @@ "condition": "if {{condition}}" }, "detail": { - "close": "Close" + "address": "Address", + "close": "Close", + "type": "Type" }, "empty": { "heading": "No permissions", diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx index 46be64a320..a6aab51db7 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx @@ -7,6 +7,7 @@ import { export interface IPermissionEdgeEntry { edgeId: string; + permissionDisplayName: string; permissionName: string; conditionLabel?: string; selected?: boolean; diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx index a168943325..173f4a06ca 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx @@ -1,4 +1,10 @@ -import { Avatar, DaoAvatar, Tag } from '@aragon/gov-ui-kit'; +import { + Avatar, + addressUtils, + Clipboard, + DaoAvatar, + Tag, +} from '@aragon/gov-ui-kit'; import { Handle, type Node, type NodeProps, Position } from '@xyflow/react'; import classNames from 'classnames'; import { useTranslations } from '@/shared/components/translationsProvider'; @@ -9,6 +15,7 @@ export type PermissionNodeSelectionRole = 'who' | 'where'; export interface IPermissionNodeData extends IPermissionGraphNode { selectionRole?: PermissionNodeSelectionRole; + active?: boolean; dimmed?: boolean; sourcePosition?: Position; targetPosition?: Position; @@ -96,9 +103,18 @@ export const PermissionGraphNode: React.FC> = ({ data, }) => { const { t } = useTranslations(); - const { kind, label, tag, avatarSrc, selectionRole, dimmed } = data; + const { + address, + kind, + label, + tag, + avatarSrc, + selectionRole, + active, + dimmed, + } = data; const isDaoKind = kind === 'dao' || kind === 'linkedDao'; - const isSelected = selectionRole != null; + const isSelected = selectionRole != null || active === true; return (
> = ({ dimmed === true && 'opacity-30', )} > - {isSelected && ( + {selectionRole != null && ( {t(SELECTION_LABEL_KEY[selectionRole])} )}
@@ -126,6 +142,13 @@ export const PermissionGraphNode: React.FC> = ({ {t(SUBTITLE_KEY[kind])} + + + + {addressUtils.truncateAddress(address)} + + +
{isDaoKind && ( - {permission.permissionName} + {permission.permissionDisplayName} {permission.conditionLabel != null && ( )} + + {permission.permissionName} + ); })} diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionNodeDetailPanel.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionNodeDetailPanel.tsx new file mode 100644 index 0000000000..71f54a9865 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionNodeDetailPanel.tsx @@ -0,0 +1,94 @@ +'use client'; + +import { + addressUtils, + Button, + ChainEntityType, + DefinitionList, + IconType, + Link, + Tag, + useBlockExplorer, +} from '@aragon/gov-ui-kit'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import type { IPermissionGraphNode, PermissionNodeKind } from '../../types'; + +const NODE_TYPE_KEY: Record = { + dao: 'app.settings.daoPermissionsPage.graphView.node.dao', + linkedDao: 'app.settings.daoPermissionsPage.graphView.node.linkedDao', + plugin: 'app.settings.daoPermissionsPage.graphView.node.plugin', + actor: 'app.settings.daoPermissionsPage.graphView.node.actor', +}; + +export interface IPermissionNodeDetailPanelProps { + chainId?: number; + node: IPermissionGraphNode; + onClose: () => void; +} + +export const PermissionNodeDetailPanel: React.FC< + IPermissionNodeDetailPanelProps +> = (props) => { + const { chainId, node, onClose } = props; + const { t } = useTranslations(); + const { buildEntityUrl } = useBlockExplorer({ chainId }); + + const explorerUrl = buildEntityUrl({ + type: ChainEntityType.ADDRESS, + id: node.address, + }); + + return ( +
+
+
+
+

+ {node.label} +

+ {node.tag != null && ( + + )} +
+

+ {t(NODE_TYPE_KEY[node.kind])} +

+
+
+
+ + + {t(NODE_TYPE_KEY[node.kind])} + + + + {addressUtils.truncateAddress(node.address)} + + + +
+
+ ); +}; diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx index 7a20c8d50d..f535389738 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx @@ -12,6 +12,7 @@ import type { IPermissionRow } from '../../types'; import { buildPermissionGraph } from '../../utils/buildPermissionGraph'; import type { IPermissionAccountRef } from '../../utils/permissionEntityUtils'; import { PermissionDetailPanel } from './permissionDetailPanel'; +import { PermissionNodeDetailPanel } from './permissionNodeDetailPanel'; import { type GraphMode, PermissionsGraphCanvas, @@ -42,6 +43,7 @@ export const PermissionsGraph: React.FC = (props) => { const { t } = useTranslations(); const [selectedEdgeId, setSelectedEdgeId] = useState(); + const [selectedNodeId, setSelectedNodeId] = useState(); const graph = useMemo(() => { if (dao == null) { @@ -53,7 +55,13 @@ export const PermissionsGraph: React.FC = (props) => { const anchorId = (activeAccountAddress ?? dao?.address ?? '').toLowerCase(); const modeEdges = useModeEdges(graph, mode, anchorId); - const selectedEdge = graph.edges.find((edge) => edge.id === selectedEdgeId); + const visibleNodeIds = new Set( + modeEdges.flatMap((edge) => [edge.source, edge.target]), + ); + const selectedEdge = modeEdges.find((edge) => edge.id === selectedEdgeId); + const selectedNode = graph.nodes.find( + (node) => node.id === selectedNodeId && visibleNodeIds.has(node.id), + ); if (isLoading || dao == null) { return ; @@ -81,7 +89,9 @@ export const PermissionsGraph: React.FC = (props) => { graph={graph} mode={mode} onSelectedEdgeChange={setSelectedEdgeId} + onSelectedNodeChange={setSelectedNodeId} selectedEdgeId={selectedEdgeId} + selectedNodeId={selectedNodeId} /> {selectedEdge != null && ( @@ -93,6 +103,13 @@ export const PermissionsGraph: React.FC = (props) => { onClose={() => setSelectedEdgeId(undefined)} /> )} + {selectedNode != null && ( + setSelectedNodeId(undefined)} + /> + )}
); }; diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx index f6b21a5e86..e4ef4d0ec4 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx @@ -4,6 +4,7 @@ import { Background, Controls, type Edge, + getViewportForBounds, MarkerType, type Node, Position, @@ -44,15 +45,17 @@ const nodeTypes = { const edgeTypes = { permission: PermissionGraphEdge }; const MIN_ZOOM = 0.2; +const READABLE_FIT_MIN_ZOOM = 0.45; const MAX_ZOOM = 2.5; -const FIT_BOUNDS_OPTIONS = { padding: 0.08, duration: 250 }; +const FIT_PADDING = 0.08; +const FIT_DURATION = 250; const UNPOSITIONED = { x: 0, y: 0 }; const SELECTED_EDGE_Z_INDEX = 20; const EDGE_ORIGIN_MARKER_NEUTRAL = 'permission-origin-dot-neutral'; const EDGE_ORIGIN_MARKER_ACTIVE = 'permission-origin-dot-active'; const SELF_STACK_GAP = 48; const FALLBACK_NODE_WIDTH = 256; -const FALLBACK_NODE_HEIGHT = 72; +const FALLBACK_NODE_HEIGHT = 92; const FALLBACK_STACK_WIDTH = 240; const STACK_ROW_HEIGHT = 20; const STACK_CONDITION_ROW_HEIGHT = 34; @@ -285,6 +288,7 @@ interface IBuildFlowElementsParams { modeEdges: IPermissionGraphEdge[]; mode: GraphMode; selectedEdgeId?: string; + selectedNodeId?: string; onSelectEdge: (edgeId: string) => void; } @@ -293,6 +297,7 @@ const buildFlowElements = ({ modeEdges, mode, selectedEdgeId, + selectedNodeId, onSelectEdge, }: IBuildFlowElementsParams): { nodes: Node[]; edges: Edge[] } => { const visibleNodeIds = new Set( @@ -313,6 +318,7 @@ const buildFlowElements = ({ : selectedEdge?.target === node.id ? 'where' : undefined; + const isSelectedNode = selectedNodeId === node.id; return { draggable: false, @@ -323,7 +329,10 @@ const buildFlowElements = ({ ...node, ...handlePositions, selectionRole, - dimmed: selectedEdge != null && selectionRole == null, + active: isSelectedNode, + dimmed: + (selectedEdge != null && selectionRole == null) || + (selectedNodeId != null && !isSelectedNode), }, }; }); @@ -344,6 +353,7 @@ const buildFlowElements = ({ group.entries.push({ edgeId: edge.id, + permissionDisplayName: edge.permissionDisplayName, permissionName: edge.permissionName, conditionLabel: edge.conditionLabel, selected: selectedEdgeId === edge.id, @@ -357,7 +367,13 @@ const buildFlowElements = ({ for (const group of groups.values()) { const active = group.entries.some((entry) => entry.selected === true); - const dimmed = selectedEdge != null && !active; + const isConnectedToSelectedNode = + selectedNodeId != null && + (group.source === selectedNodeId || + group.target === selectedNodeId); + const dimmed = + (selectedEdge != null && !active) || + (selectedNodeId != null && !isConnectedToSelectedNode); const visualKind = getEdgeVisualKind(group.source, group.target, mode); const stackId = `permission-stack-${pairKey(group.source, group.target)}`; const isSelfEdge = visualKind === 'self'; @@ -441,7 +457,9 @@ export interface IPermissionsGraphCanvasProps { mode: GraphMode; anchorId: string; selectedEdgeId?: string; + selectedNodeId?: string; onSelectedEdgeChange: (edgeId?: string) => void; + onSelectedNodeChange: (nodeId?: string) => void; } export const useModeEdges = ( @@ -456,27 +474,52 @@ export const PermissionsGraphCanvas: React.FC = ({ mode, anchorId, selectedEdgeId, + selectedNodeId, onSelectedEdgeChange, + onSelectedNodeChange, }) => { const modeEdges = useModeEdges(graph, mode, anchorId); const [nodes, setNodes, onNodesChange] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); - const { fitBounds, getNodes } = useReactFlow(); + const { getNodes, setViewport } = useReactFlow(); const nodesInitialized = useNodesInitialized(); const layoutSignature = useRef(''); + const containerRef = useRef(null); const [layoutVersion, setLayoutVersion] = useState(0); const graphBounds = useRef | undefined>( undefined, ); const selectEdge = useCallback( - (edgeId: string) => + (edgeId: string) => { + onSelectedNodeChange(undefined); onSelectedEdgeChange( selectedEdgeId === edgeId ? undefined : edgeId, - ), - [onSelectedEdgeChange, selectedEdgeId], + ); + }, + [onSelectedEdgeChange, onSelectedNodeChange, selectedEdgeId], ); + const fitReadableBounds = useCallback(() => { + const bounds = graphBounds.current; + const container = containerRef.current; + + if (bounds == null || container == null) { + return; + } + + const viewport = getViewportForBounds( + bounds, + container.clientWidth, + container.clientHeight, + READABLE_FIT_MIN_ZOOM, + MAX_ZOOM, + FIT_PADDING, + ); + + void setViewport(viewport, { duration: FIT_DURATION }); + }, [setViewport]); + useEffect(() => { const currentNodes = getNodes(); const previousPositions = new Map( @@ -487,6 +530,7 @@ export const PermissionsGraphCanvas: React.FC = ({ modeEdges, mode, selectedEdgeId, + selectedNodeId, onSelectEdge: selectEdge, }); @@ -502,6 +546,7 @@ export const PermissionsGraphCanvas: React.FC = ({ modeEdges, mode, selectedEdgeId, + selectedNodeId, selectEdge, getNodes, setNodes, @@ -547,78 +592,89 @@ export const PermissionsGraphCanvas: React.FC = ({ } const frame = requestAnimationFrame(() => { - void fitBounds(graphBounds.current!, FIT_BOUNDS_OPTIONS); + fitReadableBounds(); }); return () => cancelAnimationFrame(frame); - }, [fitBounds, layoutVersion]); + }, [fitReadableBounds, layoutVersion]); return ( - onSelectedEdgeChange(undefined)} - proOptions={{ hideAttribution: true }} - > - - - { - if (graphBounds.current != null) { - void fitBounds(graphBounds.current, FIT_BOUNDS_OPTIONS); +
+ { + if (node.type !== 'permission') { + return; } + + onSelectedEdgeChange(undefined); + onSelectedNodeChange( + selectedNodeId === node.id ? undefined : node.id, + ); }} - showInteractive={false} - /> - + onNodesChange={onNodesChange} + onPaneClick={() => { + onSelectedEdgeChange(undefined); + onSelectedNodeChange(undefined); + }} + proOptions={{ hideAttribution: true }} + > + + + + +
); }; diff --git a/apps/app/src/modules/settings/types/permissionGraph.ts b/apps/app/src/modules/settings/types/permissionGraph.ts index 7fa4e6ad6c..58e071a84b 100644 --- a/apps/app/src/modules/settings/types/permissionGraph.ts +++ b/apps/app/src/modules/settings/types/permissionGraph.ts @@ -16,6 +16,7 @@ export interface IPermissionGraphEdge { source: string; target: string; permissionName: string; + permissionDisplayName: string; conditionLabel?: string; row: IPermissionRow; } diff --git a/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.test.ts b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.test.ts index 09a86ebc35..786628bd02 100644 --- a/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.test.ts +++ b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.test.ts @@ -115,6 +115,7 @@ describe('buildPermissionGraph', () => { source: pluginAddress.toLowerCase(), target: daoAddress.toLowerCase(), permissionName: 'EXECUTE_PERMISSION', + permissionDisplayName: 'Execute', conditionLabel: 'VotingPower', row, }); diff --git a/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts index 9fb20d3a00..093dc26ed0 100644 --- a/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts +++ b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts @@ -89,6 +89,9 @@ const resolveEdge = (row: IPermissionRow): IPermissionGraphEdge => { source: row.whoAddress.toLowerCase(), target: row.whereAddress.toLowerCase(), permissionName: permissionNameUtils.getPermissionName(row.permissionId), + permissionDisplayName: permissionNameUtils.getPermissionDisplayName( + row.permissionId, + ), conditionLabel: conditionLabel === NO_CONDITION_LABEL ? undefined : conditionLabel, row, diff --git a/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts b/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts index 22b4614953..7f32b30453 100644 --- a/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts +++ b/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts @@ -87,10 +87,11 @@ describe('permissionEntity Utils', () => { }, { description: - 'falls back to a truncated address for unknown addresses', + 'falls back to an unresolved label for unknown addresses', address: unknownAddress, expected: { - label: addressUtils.truncateAddress(unknownAddress), + label: 'Unknown address', + detailName: addressUtils.truncateAddress(unknownAddress), isSentinel: false, tag: undefined, type: 'address', @@ -107,6 +108,9 @@ describe('permissionEntity Utils', () => { expect(result.tag).toEqual(expected.tag); expect(result.isSentinel).toEqual(expected.isSentinel); expect(result.type).toEqual(expected.type); + if (expected.detailName != null) { + expect(result.detailName).toEqual(expected.detailName); + } expect(result.address).toEqual(address); }); diff --git a/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts b/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts index 149e1b84d6..b6aedb5e54 100644 --- a/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts +++ b/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts @@ -145,10 +145,11 @@ class PermissionEntityUtils { } return { - label: addressUtils.truncateAddress(address), + label: 'Unknown address', address, isSentinel: false, type: 'address', + detailName: addressUtils.truncateAddress(address), }; }; diff --git a/apps/app/src/modules/settings/utils/permissionGraphLayout/permissionGraphLayout.ts b/apps/app/src/modules/settings/utils/permissionGraphLayout/permissionGraphLayout.ts index f86cf93c8b..268f7a2147 100644 --- a/apps/app/src/modules/settings/utils/permissionGraphLayout/permissionGraphLayout.ts +++ b/apps/app/src/modules/settings/utils/permissionGraphLayout/permissionGraphLayout.ts @@ -10,7 +10,7 @@ export interface IGetLayoutedElementsOptions { } const DEFAULT_NODE_WIDTH = 220; -const DEFAULT_NODE_HEIGHT = 72; +const DEFAULT_NODE_HEIGHT = 92; const DEFAULT_STACK_NODE_WIDTH = 180; const DEFAULT_STACK_NODE_HEIGHT = 40; const DEFAULT_NODE_SEP = 140; diff --git a/apps/app/src/shared/utils/permissionNameUtils/permissionNameUtils.test.ts b/apps/app/src/shared/utils/permissionNameUtils/permissionNameUtils.test.ts index 6b1e3256ee..48adbd22b7 100644 --- a/apps/app/src/shared/utils/permissionNameUtils/permissionNameUtils.test.ts +++ b/apps/app/src/shared/utils/permissionNameUtils/permissionNameUtils.test.ts @@ -92,4 +92,39 @@ describe('permissionNameUtils', () => { ); }); }); + + describe('getPermissionDisplayName', () => { + it.each([ + { + permissionName: 'SET_METADATA_PERMISSION', + expected: 'Set metadata', + }, + { + permissionName: 'EXECUTE_PERMISSION', + expected: 'Execute', + }, + { + permissionName: 'SWEEPER_ROLE', + expected: 'Sweeper', + }, + ])('formats $permissionName for graph display', ({ + permissionName, + expected, + }) => { + expect( + permissionNameUtils.getPermissionDisplayName( + permissionNameUtils.getPermissionId(permissionName), + ), + ).toEqual(expected); + }); + + it('keeps unknown permission hashes unchanged', () => { + const permissionId = + '0x0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef'; + + expect( + permissionNameUtils.getPermissionDisplayName(permissionId), + ).toEqual('0x01234567…89abcdef'); + }); + }); }); diff --git a/apps/app/src/shared/utils/permissionNameUtils/permissionNameUtils.ts b/apps/app/src/shared/utils/permissionNameUtils/permissionNameUtils.ts index 05c232f338..7000c4d8ec 100644 --- a/apps/app/src/shared/utils/permissionNameUtils/permissionNameUtils.ts +++ b/apps/app/src/shared/utils/permissionNameUtils/permissionNameUtils.ts @@ -138,6 +138,29 @@ class PermissionNameUtils { return addressUtils.truncateHash(this.normaliseHash(permissionId)); }; + /** + * Converts a resolved raw permission name to compact title case for dense UI + * surfaces. Unknown hashes are returned unchanged. + */ + getPermissionDisplayName = (permissionId: string): string => { + const permissionName = this.getPermissionName(permissionId); + + if (!permissionName.includes('_')) { + return permissionName; + } + + const displayName = permissionName + .replace(/_(PERMISSION|ROLE)$/u, '') + .split('_') + .filter(Boolean) + .map((word) => word.toLowerCase()) + .join(' '); + + return displayName.length > 0 + ? displayName.charAt(0).toUpperCase() + displayName.slice(1) + : permissionName; + }; + /** * Returns the keccak256 permission-id hash for a raw permission name. Inverse * of {@link getPermissionName}; the {@link permissionNames} list is the single From 10b82fcc77381d9857578ed6b8bb5b262d683bdc Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Wed, 15 Jul 2026 19:24:36 +0200 Subject: [PATCH 08/55] fix(APP-942): polish graph node details --- .../components/permissionsGraph/permissionGraphNode.tsx | 9 ++++++--- .../permissionsGraph/permissionNodeDetailPanel.tsx | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx index 173f4a06ca..85507823e2 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx @@ -131,9 +131,11 @@ export const PermissionGraphNode: React.FC> = ({
@@ -187,6 +189,7 @@ export const PermissionStackNode: React.FC<
diff --git a/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.tsx b/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.tsx index 1a4982e6b9..c884fbe8f4 100644 --- a/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.tsx +++ b/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.tsx @@ -2,9 +2,10 @@ import { addressUtils, - CardEmptyState, ChainEntityType, - DefinitionList, + Clipboard, + Icon, + IconType, Link, useBlockExplorer, } from '@aragon/gov-ui-kit'; @@ -31,32 +32,43 @@ export const UnrecognizedConditionSlot: React.FC< : undefined; return ( -
- - {conditionAddress != null && ( - - +
+
+

+ {t('app.settings.unrecognizedConditionSlot.heading')} +

+

+ {t( + 'app.settings.unrecognizedConditionSlot.description', )} - > - - {addressUtils.truncateAddress(conditionAddress)} - - - +

+
+
+ +
+
+ {conditionAddress != null && ( +
+ + {t('app.settings.permissionsList.details.condition')} + +
+ + + {addressUtils.truncateAddress(conditionAddress)} + + +
+
)}
); From 16aa8e0c52ef404e6967407ea42a42e1d6e0263f Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Thu, 16 Jul 2026 00:52:43 +0200 Subject: [PATCH 10/55] fix(APP-942): simplify graph address cards --- apps/app/src/assets/locales/en.json | 2 +- .../permissionsGraph/permissionGraphNode.tsx | 26 +----------- .../permissionNodeDetailPanel.tsx | 42 +++++++++++-------- 3 files changed, 28 insertions(+), 42 deletions(-) diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index c931b8e232..64d0fa8ada 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -3555,7 +3555,7 @@ "dao": "Primary DAO", "linkedDao": "Linked DAO", "plugin": "Aragon OSx Plugin", - "actor": "Unresolved address", + "actor": "Address", "who": "Who", "where": "Where" }, diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx index 85507823e2..3ba46fbcaf 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx @@ -1,10 +1,4 @@ -import { - Avatar, - addressUtils, - Clipboard, - DaoAvatar, - Tag, -} from '@aragon/gov-ui-kit'; +import { Avatar, DaoAvatar, Tag } from '@aragon/gov-ui-kit'; import { Handle, type Node, type NodeProps, Position } from '@xyflow/react'; import classNames from 'classnames'; import { useTranslations } from '@/shared/components/translationsProvider'; @@ -103,16 +97,7 @@ export const PermissionGraphNode: React.FC> = ({ data, }) => { const { t } = useTranslations(); - const { - address, - kind, - label, - tag, - avatarSrc, - selectionRole, - active, - dimmed, - } = data; + const { kind, label, tag, avatarSrc, selectionRole, active, dimmed } = data; const isDaoKind = kind === 'dao' || kind === 'linkedDao'; const isSelected = selectionRole != null || active === true; @@ -144,13 +129,6 @@ export const PermissionGraphNode: React.FC> = ({ {t(SUBTITLE_KEY[kind])} - - - - {addressUtils.truncateAddress(address)} - - -
{isDaoKind && ( = { @@ -39,11 +40,16 @@ export const PermissionNodeDetailPanel: React.FC< ); const [position, setPosition] = useState({ x: 16, y: 16 }); const [isDragging, setIsDragging] = useState(false); + const isSentinelAddress = + addressUtils.isAddressEqual(node.address, ANY_ADDR) || + addressUtils.isAddressEqual(node.address, ALLOW_FLAG); - const explorerUrl = buildEntityUrl({ - type: ChainEntityType.ADDRESS, - id: node.address, - }); + const explorerUrl = isSentinelAddress + ? undefined + : buildEntityUrl({ + type: ChainEntityType.ADDRESS, + id: node.address, + }); const clampPosition = (next: { x: number; y: number }) => { const panel = panelRef.current; @@ -161,20 +167,22 @@ export const PermissionNodeDetailPanel: React.FC< > {t(NODE_TYPE_KEY[node.kind])} - - - {addressUtils.truncateAddress(node.address)} - - + + {addressUtils.truncateAddress(node.address)} + + + )} From 77bea0f464b966bbf4ecb05cc105663d211c0bbd Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Fri, 17 Jul 2026 01:53:35 +0200 Subject: [PATCH 11/55] fix(APP-942): filter noisy permission rows --- apps/app/src/assets/locales/en.json | 4 + .../daoPermissionsPageClient.tsx | 55 +++++++- .../utils/permissionRowFilters/index.ts | 4 + .../permissionRowFilters.test.ts | 126 ++++++++++++++++++ .../permissionRowFilters.ts | 118 ++++++++++++++++ .../shared/api/daoService/domain/daoPlugin.ts | 5 + 6 files changed, 310 insertions(+), 2 deletions(-) create mode 100644 apps/app/src/modules/settings/utils/permissionRowFilters/index.ts create mode 100644 apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts create mode 100644 apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index 64d0fa8ada..2d055c6ba9 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -3545,6 +3545,10 @@ "list": "List", "graph": "Graph" }, + "filters": { + "showDaoPermissions": "Show DAO-granted permissions", + "showSubpluginPermissions": "Show subplugin permissions" + }, "graphView": { "mode": { "incoming": "To DAO", diff --git a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx index 2d06dc4c86..d2f64641c6 100644 --- a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx +++ b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx @@ -1,6 +1,6 @@ 'use client'; -import { Button, Toggle, ToggleGroup } from '@aragon/gov-ui-kit'; +import { Button, Switch, Toggle, ToggleGroup } from '@aragon/gov-ui-kit'; import { useMemo, useState } from 'react'; import { useDao } from '@/shared/api/daoService'; import { Page } from '@/shared/components/page'; @@ -16,6 +16,7 @@ import { PermissionsList, } from '../../components/permissionsList'; import { usePermissionsData } from '../../hooks/usePermissionsData'; +import { filterPermissionRows } from '../../utils/permissionRowFilters'; export interface IDaoPermissionsPageClientProps { /** @@ -92,6 +93,9 @@ export const DaoPermissionsPageClient: React.FC< }); const [mode, setMode] = useState('incoming'); + const [showDaoPermissions, setShowDaoPermissions] = useState(false); + const [showSubpluginPermissions, setShowSubpluginPermissions] = + useState(false); const [expandedRows, setExpandedRows] = useState([]); const handleViewChange = (value?: string | string[]) => { @@ -114,11 +118,38 @@ export const DaoPermissionsPageClient: React.FC< } }; - const filteredRows = useMemo( + const handleShowDaoPermissionsChange = (checked: boolean) => { + setShowDaoPermissions(checked); + setExpandedRows([]); + }; + + const handleShowSubpluginPermissionsChange = (checked: boolean) => { + setShowSubpluginPermissions(checked); + setExpandedRows([]); + }; + + const modeRows = useMemo( () => filterRowsByMode(rows, mode, activeAccount?.daoAddress), [rows, mode, activeAccount?.daoAddress], ); + const filteredRows = useMemo( + () => + filterPermissionRows(modeRows, { + activeAccountAddress: activeAccount?.daoAddress, + daoPlugins, + showDaoPermissions, + showSubpluginPermissions, + }), + [ + activeAccount?.daoAddress, + daoPlugins, + modeRows, + showDaoPermissions, + showSubpluginPermissions, + ], + ); + const allExpanded = filteredRows.length > 0 && expandedRows.length === filteredRows.length; @@ -199,6 +230,26 @@ export const DaoPermissionsPageClient: React.FC< value="other" /> +
+ + +
{showExpandAll && ( diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/index.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/index.ts new file mode 100644 index 0000000000..a68e156c18 --- /dev/null +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/index.ts @@ -0,0 +1,4 @@ +export { + filterPermissionRows, + type IPermissionRowFilters, +} from './permissionRowFilters'; diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts new file mode 100644 index 0000000000..0c47587dcd --- /dev/null +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts @@ -0,0 +1,126 @@ +import type { IDaoPlugin } from '@/shared/api/daoService'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; +import { generateFilterComponentPlugin } from '@/shared/testUtils/generators'; +import type { IPermissionRow } from '../../types'; +import { filterPermissionRows } from './permissionRowFilters'; + +const daoAddress = '0x1111111111111111111111111111111111111111'; +const pluginAddress = '0x2222222222222222222222222222222222222222'; +const subpluginAddress = '0x3333333333333333333333333333333333333333'; +const targetAddress = '0x4444444444444444444444444444444444444444'; +const parentPluginAddress = '0x5555555555555555555555555555555555555555'; + +const buildRow = (partial: Partial): IPermissionRow => ({ + permissionId: 'permission-id', + whoAddress: pluginAddress, + whereAddress: targetAddress, + conditionAddress: '0x0000000000000000000000000000000000000002', + ...partial, +}); + +const buildPlugin = ( + meta: Partial, +): IFilterComponentPlugin => + generateFilterComponentPlugin({ + meta: { + address: pluginAddress, + interfaceType: 'unknown', + release: '0', + build: '0', + isProcess: false, + isBody: false, + isSubPlugin: false, + settings: {}, + blockTimestamp: 0, + transactionHash: '0x0', + slug: 'plugin', + ...meta, + } as IDaoPlugin, + }); + +describe('filterPermissionRows', () => { + it('hides permissions granted to the active DAO by default', () => { + const rows = [ + buildRow({ whoAddress: daoAddress }), + buildRow({ whoAddress: pluginAddress }), + ]; + + const result = filterPermissionRows(rows, { + activeAccountAddress: daoAddress, + daoPlugins: [], + showDaoPermissions: false, + showSubpluginPermissions: true, + }); + + expect(result).toEqual([rows[1]]); + }); + + it('keeps DAO-granted permissions when enabled', () => { + const rows = [buildRow({ whoAddress: daoAddress })]; + + const result = filterPermissionRows(rows, { + activeAccountAddress: daoAddress, + daoPlugins: [], + showDaoPermissions: true, + showSubpluginPermissions: true, + }); + + expect(result).toEqual(rows); + }); + + it('hides rows touching installed subplugins by default', () => { + const rows = [ + buildRow({ whoAddress: subpluginAddress }), + buildRow({ whoAddress: pluginAddress }), + ]; + const daoPlugins = [ + buildPlugin({ + address: subpluginAddress, + isSubPlugin: true, + parentPlugin: parentPluginAddress, + }), + ]; + + const result = filterPermissionRows(rows, { + activeAccountAddress: daoAddress, + daoPlugins, + showDaoPermissions: true, + showSubpluginPermissions: false, + }); + + expect(result).toEqual([rows[1]]); + }); + + it('hides rows with backend parent metadata by default', () => { + const rowWithParent = { + ...buildRow({ whoAddress: subpluginAddress }), + where: { hasParent: true }, + } as IPermissionRow; + const rows = [rowWithParent, buildRow({ whoAddress: pluginAddress })]; + + const result = filterPermissionRows(rows, { + activeAccountAddress: daoAddress, + daoPlugins: [], + showDaoPermissions: true, + showSubpluginPermissions: false, + }); + + expect(result).toEqual([rows[1]]); + }); + + it('keeps subplugin rows when enabled', () => { + const rows = [buildRow({ whoAddress: subpluginAddress })]; + const daoPlugins = [ + buildPlugin({ address: subpluginAddress, isSubPlugin: true }), + ]; + + const result = filterPermissionRows(rows, { + activeAccountAddress: daoAddress, + daoPlugins, + showDaoPermissions: true, + showSubpluginPermissions: true, + }); + + expect(result).toEqual(rows); + }); +}); diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts new file mode 100644 index 0000000000..caf68ef90f --- /dev/null +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts @@ -0,0 +1,118 @@ +import { addressUtils } from '@aragon/gov-ui-kit'; +import type { IDaoPlugin } from '@/shared/api/daoService'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; +import type { IPermissionRow } from '../../types'; + +export interface IPermissionRowFilters { + /** + * Address of the DAO / linked account currently being inspected. + */ + activeAccountAddress?: string; + /** + * Installed plugin metadata used to identify current subplugins. + */ + daoPlugins?: IFilterComponentPlugin[]; + /** + * When false, rows where the active DAO is the permission holder are hidden. + */ + showDaoPermissions: boolean; + /** + * When false, rows touching a subplugin are hidden. + */ + showSubpluginPermissions: boolean; +} + +type PermissionRowWithParentMetadata = IPermissionRow & { + hasParent?: boolean; + whoHasParent?: boolean; + whereHasParent?: boolean; + who?: { hasParent?: boolean }; + where?: { hasParent?: boolean }; + whoEntity?: { hasParent?: boolean }; + whereEntity?: { hasParent?: boolean }; +}; + +const hasParentFlag = (value?: boolean): boolean => value === true; + +const hasRowParentMetadata = (row: IPermissionRow): boolean => { + const rowWithMetadata = row as PermissionRowWithParentMetadata; + + return ( + hasParentFlag(rowWithMetadata.hasParent) || + hasParentFlag(rowWithMetadata.whoHasParent) || + hasParentFlag(rowWithMetadata.whereHasParent) || + hasParentFlag(rowWithMetadata.who?.hasParent) || + hasParentFlag(rowWithMetadata.where?.hasParent) || + hasParentFlag(rowWithMetadata.whoEntity?.hasParent) || + hasParentFlag(rowWithMetadata.whereEntity?.hasParent) + ); +}; + +const isSubplugin = (plugin: IFilterComponentPlugin): boolean => { + const { meta } = plugin; + + return ( + meta.isSubPlugin === true || + meta.hasParent === true || + meta.parentPlugin != null + ); +}; + +const rowTouchesSubplugin = ( + row: IPermissionRow, + daoPlugins?: IFilterComponentPlugin[], +): boolean => { + if (hasRowParentMetadata(row)) { + return true; + } + + return ( + daoPlugins + ?.filter(isSubplugin) + .some( + (plugin) => + addressUtils.isAddressEqual( + plugin.meta.address, + row.whoAddress, + ) || + addressUtils.isAddressEqual( + plugin.meta.address, + row.whereAddress, + ), + ) ?? false + ); +}; + +const isDaoGrantedPermission = ( + row: IPermissionRow, + activeAccountAddress?: string, +): boolean => + activeAccountAddress != null && + addressUtils.isAddressEqual(row.whoAddress, activeAccountAddress); + +export const filterPermissionRows = ( + rows: IPermissionRow[], + filters: IPermissionRowFilters, +): IPermissionRow[] => { + const { + activeAccountAddress, + daoPlugins, + showDaoPermissions, + showSubpluginPermissions, + } = filters; + + return rows.filter((row) => { + if ( + !showDaoPermissions && + isDaoGrantedPermission(row, activeAccountAddress) + ) { + return false; + } + + if (!showSubpluginPermissions && rowTouchesSubplugin(row, daoPlugins)) { + return false; + } + + return true; + }); +}; diff --git a/apps/app/src/shared/api/daoService/domain/daoPlugin.ts b/apps/app/src/shared/api/daoService/domain/daoPlugin.ts index 8c77537de3..52a2955565 100644 --- a/apps/app/src/shared/api/daoService/domain/daoPlugin.ts +++ b/apps/app/src/shared/api/daoService/domain/daoPlugin.ts @@ -58,6 +58,11 @@ export interface IDaoPlugin< * Defines if the plugin is installed on the DAO as a sub / child plugin. */ isSubPlugin: boolean; + /** + * Backend parent relationship flag used to identify sub / child plugins, + * including cases where richer plugin metadata is not available. + */ + hasParent?: boolean; /** * Settings of the DAO plugin. */ From 157fddf29e8e862c09485a2723e6116fcaec61b1 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Fri, 17 Jul 2026 02:02:20 +0200 Subject: [PATCH 12/55] fix(APP-942): align subplugin filter with backend fields --- .../permissionRowFilters.test.ts | 19 ++++-- .../permissionRowFilters.ts | 67 +++++-------------- .../shared/api/daoService/domain/daoPlugin.ts | 5 -- 3 files changed, 27 insertions(+), 64 deletions(-) diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts index 0c47587dcd..4a976ff7c4 100644 --- a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts @@ -91,16 +91,21 @@ describe('filterPermissionRows', () => { expect(result).toEqual([rows[1]]); }); - it('hides rows with backend parent metadata by default', () => { - const rowWithParent = { - ...buildRow({ whoAddress: subpluginAddress }), - where: { hasParent: true }, - } as IPermissionRow; - const rows = [rowWithParent, buildRow({ whoAddress: pluginAddress })]; + it('hides rows touching plugins with a parent plugin by default', () => { + const rows = [ + buildRow({ whereAddress: subpluginAddress }), + buildRow({ whoAddress: pluginAddress }), + ]; + const daoPlugins = [ + buildPlugin({ + address: subpluginAddress, + parentPlugin: parentPluginAddress, + }), + ]; const result = filterPermissionRows(rows, { activeAccountAddress: daoAddress, - daoPlugins: [], + daoPlugins, showDaoPermissions: true, showSubpluginPermissions: false, }); diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts index caf68ef90f..652a2be196 100644 --- a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts @@ -22,66 +22,29 @@ export interface IPermissionRowFilters { showSubpluginPermissions: boolean; } -type PermissionRowWithParentMetadata = IPermissionRow & { - hasParent?: boolean; - whoHasParent?: boolean; - whereHasParent?: boolean; - who?: { hasParent?: boolean }; - where?: { hasParent?: boolean }; - whoEntity?: { hasParent?: boolean }; - whereEntity?: { hasParent?: boolean }; -}; - -const hasParentFlag = (value?: boolean): boolean => value === true; - -const hasRowParentMetadata = (row: IPermissionRow): boolean => { - const rowWithMetadata = row as PermissionRowWithParentMetadata; - - return ( - hasParentFlag(rowWithMetadata.hasParent) || - hasParentFlag(rowWithMetadata.whoHasParent) || - hasParentFlag(rowWithMetadata.whereHasParent) || - hasParentFlag(rowWithMetadata.who?.hasParent) || - hasParentFlag(rowWithMetadata.where?.hasParent) || - hasParentFlag(rowWithMetadata.whoEntity?.hasParent) || - hasParentFlag(rowWithMetadata.whereEntity?.hasParent) - ); -}; - const isSubplugin = (plugin: IFilterComponentPlugin): boolean => { const { meta } = plugin; - return ( - meta.isSubPlugin === true || - meta.hasParent === true || - meta.parentPlugin != null - ); + return meta.isSubPlugin === true || meta.parentPlugin != null; }; const rowTouchesSubplugin = ( row: IPermissionRow, daoPlugins?: IFilterComponentPlugin[], -): boolean => { - if (hasRowParentMetadata(row)) { - return true; - } - - return ( - daoPlugins - ?.filter(isSubplugin) - .some( - (plugin) => - addressUtils.isAddressEqual( - plugin.meta.address, - row.whoAddress, - ) || - addressUtils.isAddressEqual( - plugin.meta.address, - row.whereAddress, - ), - ) ?? false - ); -}; +): boolean => + daoPlugins + ?.filter(isSubplugin) + .some( + (plugin) => + addressUtils.isAddressEqual( + plugin.meta.address, + row.whoAddress, + ) || + addressUtils.isAddressEqual( + plugin.meta.address, + row.whereAddress, + ), + ) ?? false; const isDaoGrantedPermission = ( row: IPermissionRow, diff --git a/apps/app/src/shared/api/daoService/domain/daoPlugin.ts b/apps/app/src/shared/api/daoService/domain/daoPlugin.ts index 52a2955565..8c77537de3 100644 --- a/apps/app/src/shared/api/daoService/domain/daoPlugin.ts +++ b/apps/app/src/shared/api/daoService/domain/daoPlugin.ts @@ -58,11 +58,6 @@ export interface IDaoPlugin< * Defines if the plugin is installed on the DAO as a sub / child plugin. */ isSubPlugin: boolean; - /** - * Backend parent relationship flag used to identify sub / child plugins, - * including cases where richer plugin metadata is not available. - */ - hasParent?: boolean; /** * Settings of the DAO plugin. */ From 5e758c566b87a39e623f457646b0cbcff8f3e1f1 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Fri, 17 Jul 2026 02:19:22 +0200 Subject: [PATCH 13/55] fix(APP-942): detect subplugin permission addresses --- .../usePermissionsData.test.ts | 12 +++++++ .../usePermissionsData/usePermissionsData.ts | 1 + .../permissionRowFilters.test.ts | 22 ++++++++++++ .../permissionRowFilters.ts | 36 ++++++++++++------- .../shared/api/daoService/domain/daoPlugin.ts | 15 ++++++++ 5 files changed, 73 insertions(+), 13 deletions(-) diff --git a/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts b/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts index fb2173d95a..0f14b9fa18 100644 --- a/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts +++ b/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.test.ts @@ -127,4 +127,16 @@ describe('usePermissionsData hook', () => { ]); expect(result.current.dao?.name).toBe('Main DAO'); }); + + it('loads subplugins for permission graph filtering', () => { + renderHook(() => usePermissionsData({ daoId: 'main-dao' })); + + expect(useDaoPluginsSpy).toHaveBeenCalledWith( + expect.objectContaining({ + daoId: 'main-dao', + includeLinkedAccounts: true, + includeSubPlugins: true, + }), + ); + }); }); diff --git a/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts b/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts index cf639ea34a..168dd23aae 100644 --- a/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts +++ b/apps/app/src/modules/settings/hooks/usePermissionsData/usePermissionsData.ts @@ -51,6 +51,7 @@ export const usePermissionsData = ( const { data: realDao } = useDao({ urlParams: { id: daoId } }); const realDaoPlugins = useDaoPlugins({ daoId, + includeSubPlugins: true, includeLinkedAccounts: true, }); diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts index 4a976ff7c4..d58a948983 100644 --- a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts @@ -113,6 +113,28 @@ describe('filterPermissionRows', () => { expect(result).toEqual([rows[1]]); }); + it('hides rows touching addresses listed by a parent plugin subPlugins field', () => { + const rows = [ + buildRow({ whoAddress: subpluginAddress }), + buildRow({ whoAddress: pluginAddress }), + ]; + const daoPlugins = [ + buildPlugin({ + address: parentPluginAddress, + subPlugins: [{ addresses: [subpluginAddress] }], + }), + ]; + + const result = filterPermissionRows(rows, { + activeAccountAddress: daoAddress, + daoPlugins, + showDaoPermissions: true, + showSubpluginPermissions: false, + }); + + expect(result).toEqual([rows[1]]); + }); + it('keeps subplugin rows when enabled', () => { const rows = [buildRow({ whoAddress: subpluginAddress })]; const daoPlugins = [ diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts index 652a2be196..193b7d141e 100644 --- a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts @@ -28,23 +28,33 @@ const isSubplugin = (plugin: IFilterComponentPlugin): boolean => { return meta.isSubPlugin === true || meta.parentPlugin != null; }; +const isSubpluginAddress = ( + address: string, + daoPlugins?: IFilterComponentPlugin[], +): boolean => + daoPlugins?.some((plugin) => { + const { meta } = plugin; + + if ( + isSubplugin(plugin) && + addressUtils.isAddressEqual(meta.address, address) + ) { + return true; + } + + return meta.subPlugins?.some((subPlugin) => + subPlugin.addresses.some((subPluginAddress) => + addressUtils.isAddressEqual(subPluginAddress, address), + ), + ); + }) ?? false; + const rowTouchesSubplugin = ( row: IPermissionRow, daoPlugins?: IFilterComponentPlugin[], ): boolean => - daoPlugins - ?.filter(isSubplugin) - .some( - (plugin) => - addressUtils.isAddressEqual( - plugin.meta.address, - row.whoAddress, - ) || - addressUtils.isAddressEqual( - plugin.meta.address, - row.whereAddress, - ), - ) ?? false; + isSubpluginAddress(row.whoAddress, daoPlugins) || + isSubpluginAddress(row.whereAddress, daoPlugins); const isDaoGrantedPermission = ( row: IPermissionRow, diff --git a/apps/app/src/shared/api/daoService/domain/daoPlugin.ts b/apps/app/src/shared/api/daoService/domain/daoPlugin.ts index 8c77537de3..d57032b2f2 100644 --- a/apps/app/src/shared/api/daoService/domain/daoPlugin.ts +++ b/apps/app/src/shared/api/daoService/domain/daoPlugin.ts @@ -2,6 +2,17 @@ import type { PluginInterfaceType } from './enum'; import type { IPluginSettings } from './pluginSettings'; import type { IResource } from './resource'; +export interface IDaoSubPlugin { + /** + * Addresses of the sub / child plugins used by a parent plugin. + */ + addresses: string[]; + /** + * Stage index where this subplugin group is configured, when applicable. + */ + stageIndex?: number; +} + export interface IDaoPlugin< TSettings extends IPluginSettings = IPluginSettings, > { @@ -66,6 +77,10 @@ export interface IDaoPlugin< * Address of the parent plugin's smart contract. */ parentPlugin?: string; + /** + * Sub / child plugin addresses configured by this plugin. + */ + subPlugins?: IDaoSubPlugin[]; /** * Block timestamp when the plugin was created. */ From 9fddbd5d71d0238c5cbb7cad1259806c95653107 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Fri, 17 Jul 2026 02:27:37 +0200 Subject: [PATCH 14/55] fix(APP-942): simplify permission graph modes --- apps/app/src/assets/locales/en.json | 2 +- .../pages/daoPermissionsPage/daoPermissionsPageClient.tsx | 8 +------- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index 2d055c6ba9..81c8ab7f37 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -3551,7 +3551,7 @@ }, "graphView": { "mode": { - "incoming": "To DAO", + "incoming": "Granted", "outgoing": "From DAO", "other": "Other" }, diff --git a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx index d2f64641c6..da8b33b137 100644 --- a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx +++ b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx @@ -34,7 +34,7 @@ enum PermissionsView { const permissionsViews = Object.values(PermissionsView); -const graphModes: GraphMode[] = ['incoming', 'outgoing', 'other']; +const graphModes: GraphMode[] = ['incoming', 'other']; const filterRowsByMode = ( rows: ReturnType['rows'], @@ -217,12 +217,6 @@ export const DaoPermissionsPageClient: React.FC< )} value="incoming" /> - Date: Fri, 17 Jul 2026 02:40:50 +0200 Subject: [PATCH 15/55] fix(APP-942): disable inactive permission filters --- apps/app/src/assets/locales/en.json | 2 +- .../daoPermissionsPageClient.tsx | 139 +++++++++++++----- .../daoPermissionsPageClientUtils.test.ts | 55 +++++++ .../daoPermissionsPageClientUtils.ts | 33 +++++ 4 files changed, 190 insertions(+), 39 deletions(-) create mode 100644 apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClientUtils.test.ts create mode 100644 apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClientUtils.ts diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index 81c8ab7f37..3fef9756a4 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -3547,7 +3547,7 @@ }, "filters": { "showDaoPermissions": "Show DAO-granted permissions", - "showSubpluginPermissions": "Show subplugin permissions" + "showSubpluginPermissions": "Show subplugin/residual permissions" }, "graphView": { "mode": { diff --git a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx index da8b33b137..c776a51ea2 100644 --- a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx +++ b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx @@ -1,22 +1,24 @@ 'use client'; import { Button, Switch, Toggle, ToggleGroup } from '@aragon/gov-ui-kit'; -import { useMemo, useState } from 'react'; +import { useEffect, useMemo, useState } from 'react'; import { useDao } from '@/shared/api/daoService'; import { Page } from '@/shared/components/page'; import { useTranslations } from '@/shared/components/translationsProvider'; import { useFilterUrlParam } from '@/shared/hooks/useFilterUrlParam'; import { daoUtils } from '@/shared/utils/daoUtils'; -import { - type GraphMode, - PermissionsGraph, -} from '../../components/permissionsGraph'; +import { PermissionsGraph } from '../../components/permissionsGraph'; import { getPermissionRowKey, PermissionsList, } from '../../components/permissionsList'; import { usePermissionsData } from '../../hooks/usePermissionsData'; import { filterPermissionRows } from '../../utils/permissionRowFilters'; +import { + type DisplayGraphMode, + filterRowsByMode, + graphModes, +} from './daoPermissionsPageClientUtils'; export interface IDaoPermissionsPageClientProps { /** @@ -32,36 +34,17 @@ enum PermissionsView { GRAPH = 'graph', } -const permissionsViews = Object.values(PermissionsView); - -const graphModes: GraphMode[] = ['incoming', 'other']; - -const filterRowsByMode = ( - rows: ReturnType['rows'], - mode: GraphMode, - activeAccountAddress?: string, -) => { - const activeAddress = activeAccountAddress?.toLowerCase(); - - if (activeAddress == null) { - return rows; - } +type PermissionRows = ReturnType['rows']; - return rows.filter((row) => { - const whoAddress = row.whoAddress.toLowerCase(); - const whereAddress = row.whereAddress.toLowerCase(); - - if (mode === 'incoming') { - return whereAddress === activeAddress; - } +const permissionsViews = Object.values(PermissionsView); - if (mode === 'outgoing') { - return whoAddress === activeAddress; - } +const getPermissionRowsSignature = (rows: PermissionRows): string => + rows.map(getPermissionRowKey).sort().join('|'); - return whoAddress !== activeAddress && whereAddress !== activeAddress; - }); -}; +const arePermissionRowsEqual = ( + a: PermissionRows, + b: PermissionRows, +): boolean => getPermissionRowsSignature(a) === getPermissionRowsSignature(b); export const DaoPermissionsPageClient: React.FC< IDaoPermissionsPageClientProps @@ -92,7 +75,7 @@ export const DaoPermissionsPageClient: React.FC< enableUrlUpdate: true, }); - const [mode, setMode] = useState('incoming'); + const [mode, setMode] = useState('incoming'); const [showDaoPermissions, setShowDaoPermissions] = useState(false); const [showSubpluginPermissions, setShowSubpluginPermissions] = useState(false); @@ -112,8 +95,8 @@ export const DaoPermissionsPageClient: React.FC< }; const handleModeChange = (value?: string | string[]) => { - if (graphModes.includes(value as GraphMode)) { - setMode(value as GraphMode); + if (graphModes.includes(value as DisplayGraphMode)) { + setMode(value as DisplayGraphMode); setExpandedRows([]); } }; @@ -128,11 +111,20 @@ export const DaoPermissionsPageClient: React.FC< setExpandedRows([]); }; - const modeRows = useMemo( - () => filterRowsByMode(rows, mode, activeAccount?.daoAddress), - [rows, mode, activeAccount?.daoAddress], + const modeRowsByMode = useMemo>( + () => ({ + incoming: filterRowsByMode( + rows, + 'incoming', + activeAccount?.daoAddress, + ), + other: filterRowsByMode(rows, 'other', activeAccount?.daoAddress), + }), + [rows, activeAccount?.daoAddress], ); + const modeRows = modeRowsByMode[mode]; + const filteredRows = useMemo( () => filterPermissionRows(modeRows, { @@ -150,6 +142,63 @@ export const DaoPermissionsPageClient: React.FC< ], ); + const showDaoPermissionsToggleDisabled = useMemo( + () => + arePermissionRowsEqual( + filteredRows, + filterPermissionRows(modeRows, { + activeAccountAddress: activeAccount?.daoAddress, + daoPlugins, + showDaoPermissions: !showDaoPermissions, + showSubpluginPermissions, + }), + ), + [ + activeAccount?.daoAddress, + daoPlugins, + filteredRows, + modeRows, + showDaoPermissions, + showSubpluginPermissions, + ], + ); + + const showSubpluginPermissionsToggleDisabled = useMemo( + () => + arePermissionRowsEqual( + filteredRows, + filterPermissionRows(modeRows, { + activeAccountAddress: activeAccount?.daoAddress, + daoPlugins, + showDaoPermissions, + showSubpluginPermissions: !showSubpluginPermissions, + }), + ), + [ + activeAccount?.daoAddress, + daoPlugins, + filteredRows, + modeRows, + showDaoPermissions, + showSubpluginPermissions, + ], + ); + + useEffect(() => { + if (isLoading || modeRowsByMode[mode].length > 0) { + return; + } + + const nextMode = graphModes.find( + (graphMode) => modeRowsByMode[graphMode].length > 0, + ); + + if (nextMode != null) { + setMode(nextMode); + setExpandedRows([]); + } + }, [isLoading, mode, modeRowsByMode]); + const allExpanded = filteredRows.length > 0 && expandedRows.length === filteredRows.length; @@ -212,12 +261,18 @@ export const DaoPermissionsPageClient: React.FC< value={mode} > ): IPermissionRow => ({ + permissionId: 'permission-id', + whoAddress: pluginAddress, + whereAddress: activeDaoAddress, + conditionAddress: '0x0000000000000000000000000000000000000000', + ...partial, +}); + +describe('daoPermissionsPageClientUtils', () => { + describe('filterRowsByMode', () => { + it('returns permissions granted on the active DAO for granted mode', () => { + const grantedRow = buildRow({ whereAddress: activeDaoAddress }); + const otherRow = buildRow({ whereAddress: externalAddress }); + + const result = filterRowsByMode( + [grantedRow, otherRow], + 'incoming', + activeDaoAddress, + ); + + expect(result).toEqual([grantedRow]); + }); + + it('keeps old from-DAO and unrelated relationships in other mode', () => { + const grantedRow = buildRow({ + whoAddress: pluginAddress, + whereAddress: activeDaoAddress, + }); + const oldFromDaoRow = buildRow({ + whoAddress: activeDaoAddress, + whereAddress: externalAddress, + }); + const unrelatedRow = buildRow({ + whoAddress: otherAddress, + whereAddress: externalAddress, + }); + + const result = filterRowsByMode( + [grantedRow, oldFromDaoRow, unrelatedRow], + 'other', + activeDaoAddress, + ); + + expect(result).toEqual([oldFromDaoRow, unrelatedRow]); + }); + }); +}); diff --git a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClientUtils.ts b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClientUtils.ts new file mode 100644 index 0000000000..4c2f497dcf --- /dev/null +++ b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClientUtils.ts @@ -0,0 +1,33 @@ +import type { GraphMode } from '../../components/permissionsGraph'; +import type { IPermissionRow } from '../../types'; + +export type DisplayGraphMode = Extract; + +export const graphModes: DisplayGraphMode[] = ['incoming', 'other']; + +export const filterRowsByMode = ( + rows: IPermissionRow[], + mode: GraphMode, + activeAccountAddress?: string, +) => { + const activeAddress = activeAccountAddress?.toLowerCase(); + + if (activeAddress == null) { + return rows; + } + + return rows.filter((row) => { + const whoAddress = row.whoAddress.toLowerCase(); + const whereAddress = row.whereAddress.toLowerCase(); + + if (mode === 'incoming') { + return whereAddress === activeAddress; + } + + if (mode === 'outgoing') { + return whoAddress === activeAddress; + } + + return whereAddress !== activeAddress; + }); +}; From 8744b1c2c516ae9b913bfe2ac961b662d7836e3a Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Fri, 17 Jul 2026 02:46:58 +0200 Subject: [PATCH 16/55] fix(APP-942): align other graph layout --- .../permissionsGraphCanvas.test.ts | 53 ++++++++++++++++++ .../permissionsGraphCanvas.tsx | 55 +++++-------------- 2 files changed, 67 insertions(+), 41 deletions(-) create mode 100644 apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts new file mode 100644 index 0000000000..5b185f1090 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts @@ -0,0 +1,53 @@ +import type { IPermissionGraph, IPermissionGraphEdge } from '../../types'; +import { getModeEdges } from './permissionsGraphCanvas'; + +const anchorId = '0x1111111111111111111111111111111111111111'; +const pluginId = '0x2222222222222222222222222222222222222222'; +const externalId = '0x3333333333333333333333333333333333333333'; +const otherId = '0x4444444444444444444444444444444444444444'; + +const buildEdge = ( + id: string, + partial: Pick, +): IPermissionGraphEdge => ({ + id, + permissionDisplayName: 'Permission', + permissionName: 'PERMISSION', + row: { + permissionId: 'permission-id', + whoAddress: partial.source, + whereAddress: partial.target, + conditionAddress: '0x0000000000000000000000000000000000000000', + }, + ...partial, +}); + +const buildGraph = (edges: IPermissionGraphEdge[]): IPermissionGraph => ({ + nodes: [], + edges, +}); + +describe('getModeEdges', () => { + it('keeps old from-DAO and unrelated edges in other mode', () => { + const grantedEdge = buildEdge('granted', { + source: pluginId, + target: anchorId, + }); + const oldFromDaoEdge = buildEdge('old-from-dao', { + source: anchorId, + target: externalId, + }); + const unrelatedEdge = buildEdge('unrelated', { + source: otherId, + target: externalId, + }); + + const result = getModeEdges( + buildGraph([grantedEdge, oldFromDaoEdge, unrelatedEdge]), + 'other', + anchorId, + ); + + expect(result).toEqual([oldFromDaoEdge, unrelatedEdge]); + }); +}); diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx index e4ef4d0ec4..95c2e77de5 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx @@ -61,7 +61,7 @@ const STACK_ROW_HEIGHT = 20; const STACK_CONDITION_ROW_HEIGHT = 34; const STACK_ROW_GAP = 2; -const getModeEdges = ( +export const getModeEdges = ( graph: IPermissionGraph, mode: GraphMode, anchorId: string, @@ -74,9 +74,7 @@ const getModeEdges = ( return graph.edges.filter((edge) => edge.source === anchorId); } - return graph.edges.filter( - (edge) => edge.source !== anchorId && edge.target !== anchorId, - ); + return graph.edges.filter((edge) => edge.target !== anchorId); }; const getLayoutDirection = (mode: GraphMode): PermissionGraphDirection => { @@ -84,22 +82,13 @@ const getLayoutDirection = (mode: GraphMode): PermissionGraphDirection => { return 'BT'; } - if (mode === 'outgoing') { - return 'TB'; - } - - return 'LR'; + return 'TB'; }; -const getLayoutSpacing = ( - mode: GraphMode, -): { nodesep: number; ranksep: number } => { - if (mode === 'other') { - return { nodesep: 80, ranksep: 145 }; - } - - return { nodesep: 60, ranksep: 170 }; -}; +const getLayoutSpacing = (): { nodesep: number; ranksep: number } => ({ + nodesep: 60, + ranksep: 170, +}); const getHandlePositions = ( mode: GraphMode, @@ -111,16 +100,9 @@ const getHandlePositions = ( }; } - if (mode === 'outgoing') { - return { - sourcePosition: Position.Bottom, - targetPosition: Position.Top, - }; - } - return { - sourcePosition: Position.Right, - targetPosition: Position.Left, + sourcePosition: Position.Bottom, + targetPosition: Position.Top, }; }; @@ -134,20 +116,11 @@ const getEdgeHandles = (mode: GraphMode) => { }; } - if (mode === 'outgoing') { - return { - originSource: PERMISSION_GRAPH_HANDLE.sourceBottom, - stackTarget: PERMISSION_GRAPH_HANDLE.targetTop, - stackSource: PERMISSION_GRAPH_HANDLE.sourceBottom, - targetTarget: PERMISSION_GRAPH_HANDLE.targetTop, - }; - } - return { - originSource: PERMISSION_GRAPH_HANDLE.sourceRight, - stackTarget: PERMISSION_GRAPH_HANDLE.targetLeft, - stackSource: PERMISSION_GRAPH_HANDLE.sourceRight, - targetTarget: PERMISSION_GRAPH_HANDLE.targetLeft, + originSource: PERMISSION_GRAPH_HANDLE.sourceBottom, + stackTarget: PERMISSION_GRAPH_HANDLE.targetTop, + stackSource: PERMISSION_GRAPH_HANDLE.sourceBottom, + targetTarget: PERMISSION_GRAPH_HANDLE.targetTop, }; }; @@ -574,7 +547,7 @@ export const PermissionsGraphCanvas: React.FC = ({ edges, { direction: getLayoutDirection(mode), - ...getLayoutSpacing(mode), + ...getLayoutSpacing(), }, ); const layoutedNodes = positionSelfStacks(rawLayoutedNodes); From 9f9355217946b65ff82df1c53d7e44a259215110 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Fri, 17 Jul 2026 02:52:13 +0200 Subject: [PATCH 17/55] fix(APP-942): preserve permission graph controls in URL --- .../permissionsGraph/permissionGraphEdge.tsx | 24 +++++------ .../daoPermissionsPageClient.tsx | 40 +++++++++++++++---- 2 files changed, 45 insertions(+), 19 deletions(-) diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx index a6aab51db7..d28c4dc21d 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx @@ -28,6 +28,9 @@ export interface IPermissionEdgeData { export type IPermissionFlowEdge = Edge; +const getEdgeBorderRadius = (visualKind: PermissionEdgeVisualKind) => + visualKind === 'self' ? 0 : 16; + export const PermissionGraphEdge: React.FC> = ({ sourceX, sourceY, @@ -41,18 +44,15 @@ export const PermissionGraphEdge: React.FC> = ({ data, }) => { const visualKind = data?.visualKind ?? 'other'; - const [edgePath] = - visualKind === 'self' - ? [`M ${sourceX} ${sourceY} L ${targetX} ${targetY}`] - : getSmoothStepPath({ - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - borderRadius: 16, - }); + const [edgePath] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + borderRadius: getEdgeBorderRadius(visualKind), + }); return ( ['rows']; +const booleanParamValues = ['false', 'true']; const permissionsViews = Object.values(PermissionsView); const getPermissionRowsSignature = (rows: PermissionRows): string => @@ -75,12 +79,34 @@ export const DaoPermissionsPageClient: React.FC< enableUrlUpdate: true, }); - const [mode, setMode] = useState('incoming'); - const [showDaoPermissions, setShowDaoPermissions] = useState(false); - const [showSubpluginPermissions, setShowSubpluginPermissions] = - useState(false); + const [modeParam, setMode] = useFilterUrlParam({ + name: permissionsModeParam, + fallbackValue: 'incoming', + validValues: graphModes, + enableUrlUpdate: true, + }); + + const [showDaoPermissionsParam, setShowDaoPermissions] = useFilterUrlParam({ + name: permissionsDaoParam, + fallbackValue: 'false', + validValues: booleanParamValues, + enableUrlUpdate: true, + }); + + const [showSubpluginPermissionsParam, setShowSubpluginPermissions] = + useFilterUrlParam({ + name: permissionsSubpluginsParam, + fallbackValue: 'false', + validValues: booleanParamValues, + enableUrlUpdate: true, + }); + const [expandedRows, setExpandedRows] = useState([]); + const mode = (modeParam ?? 'incoming') as DisplayGraphMode; + const showDaoPermissions = showDaoPermissionsParam === 'true'; + const showSubpluginPermissions = showSubpluginPermissionsParam === 'true'; + const handleViewChange = (value?: string | string[]) => { if (typeof value === 'string' && value) { setView(value); @@ -102,12 +128,12 @@ export const DaoPermissionsPageClient: React.FC< }; const handleShowDaoPermissionsChange = (checked: boolean) => { - setShowDaoPermissions(checked); + setShowDaoPermissions(String(checked)); setExpandedRows([]); }; const handleShowSubpluginPermissionsChange = (checked: boolean) => { - setShowSubpluginPermissions(checked); + setShowSubpluginPermissions(String(checked)); setExpandedRows([]); }; @@ -197,7 +223,7 @@ export const DaoPermissionsPageClient: React.FC< setMode(nextMode); setExpandedRows([]); } - }, [isLoading, mode, modeRowsByMode]); + }, [isLoading, mode, modeRowsByMode, setMode]); const allExpanded = filteredRows.length > 0 && expandedRows.length === filteredRows.length; From c1121df1b94e94e36ceb760c2e3596cab6b092cb Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Fri, 17 Jul 2026 02:58:41 +0200 Subject: [PATCH 18/55] fix(APP-942): straighten self permission edges --- .../permissionsGraph/permissionGraphEdge.tsx | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx index d28c4dc21d..2008733125 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx @@ -3,6 +3,7 @@ import { type Edge, type EdgeProps, getSmoothStepPath, + getStraightPath, } from '@xyflow/react'; export interface IPermissionEdgeEntry { @@ -28,9 +29,6 @@ export interface IPermissionEdgeData { export type IPermissionFlowEdge = Edge; -const getEdgeBorderRadius = (visualKind: PermissionEdgeVisualKind) => - visualKind === 'self' ? 0 : 16; - export const PermissionGraphEdge: React.FC> = ({ sourceX, sourceY, @@ -44,15 +42,18 @@ export const PermissionGraphEdge: React.FC> = ({ data, }) => { const visualKind = data?.visualKind ?? 'other'; - const [edgePath] = getSmoothStepPath({ - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - borderRadius: getEdgeBorderRadius(visualKind), - }); + const [edgePath] = + visualKind === 'self' + ? getStraightPath({ sourceX, sourceY, targetX, targetY }) + : getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + borderRadius: 16, + }); return ( Date: Fri, 17 Jul 2026 10:59:37 +0200 Subject: [PATCH 19/55] fix(APP-942): align permission stack handles --- .../components/permissionsGraph/permissionGraphNode.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx index 3ba46fbcaf..135087c47b 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx @@ -155,7 +155,7 @@ export const PermissionStackNode: React.FC< return (
From 56f228c824d30277deaa00cdd8d591dfcbf473df Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Fri, 17 Jul 2026 11:29:55 +0200 Subject: [PATCH 20/55] fix(APP-942): resolve rebase type check --- .../useSppPermissionCheckProposalCreation.test.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts b/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts index 8509d985d1..ae31bc5937 100644 --- a/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts +++ b/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts @@ -96,7 +96,6 @@ describe('useSppPermissionCheckProposalCreation', () => { result?: 'success' | 'failure'; }) => useSimulateProposalCreationSpy.mockReturnValue({ - isError: false, ...result, } as ReturnType< typeof useSimulateProposalModule.useSimulateProposalCreation From c170830ea18af234f069211063a00b29c41792f4 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Fri, 24 Jul 2026 00:49:13 +0200 Subject: [PATCH 21/55] feat(APP-1003): iterate on graph view from permissions feedback Refinement pass over the permissions graph (APP-942 finished in #1238). - Consolidate the view into a single graph screen driven by toggles; remove the Granted/Other tabs - Treat each permission as a distinct node instead of merging under a shared "anyone" header - Standardize resource-link display and show the permission ID above the name - Replace explicit "no condition"/"no description" labels with a dash placeholder - Add graph full-screen mode with Escape-to-exit - Add informational tooltips to the Who / subplugin filter toggles - Move DAO self-permission stacks south of the DAO node and align the stack handles - Filter uninstalled/stale plugins out of the graph data --- ...view.md => app-1003-iterate-graph-view.md} | 0 apps/app/src/assets/locales/en.json | 22 +- .../noConditionSlot/noConditionSlot.test.tsx | 17 +- .../noConditionSlot/noConditionSlot.tsx | 22 +- .../components/permissionsGraph/index.ts | 1 - .../permissionGraphNode.test.tsx | 44 ++++ .../permissionsGraph/permissionGraphNode.tsx | 3 - .../permissionsGraph.test.tsx | 99 +++++++++ .../permissionsGraph/permissionsGraph.tsx | 88 ++++++-- .../permissionsGraphCanvas.test.ts | 76 ++++++- .../permissionsGraphCanvas.tsx | 151 +++++--------- .../permissionsList/permissionsList.test.tsx | 50 ++++- .../permissionsList/permissionsList.tsx | 8 +- .../usePermissionsData.test.ts | 75 ++++++- .../usePermissionsData/usePermissionsData.ts | 23 ++- .../daoPermissionsPageClient.test.tsx | 195 ++++++++++++++++++ .../daoPermissionsPageClient.tsx | 188 ++++++++--------- .../daoPermissionsPageClientUtils.test.ts | 55 ----- .../daoPermissionsPageClientUtils.ts | 33 --- .../buildPermissionGraph.test.ts | 29 +++ .../buildPermissionGraph.ts | 10 +- .../permissionRowFilters.test.ts | 22 +- .../permissionRowFilters.ts | 15 +- 23 files changed, 842 insertions(+), 384 deletions(-) rename .changeset/{app-942-permissions-graph-view.md => app-1003-iterate-graph-view.md} (100%) create mode 100644 apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.test.tsx create mode 100644 apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.test.tsx create mode 100644 apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.test.tsx delete mode 100644 apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClientUtils.test.ts delete mode 100644 apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClientUtils.ts diff --git a/.changeset/app-942-permissions-graph-view.md b/.changeset/app-1003-iterate-graph-view.md similarity index 100% rename from .changeset/app-942-permissions-graph-view.md rename to .changeset/app-1003-iterate-graph-view.md diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index 3fef9756a4..f68be4c9bd 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -3547,14 +3547,13 @@ }, "filters": { "showDaoPermissions": "Show DAO-granted permissions", - "showSubpluginPermissions": "Show subplugin/residual permissions" + "showDaoPermissionsTooltip": "Permissions where the selected DAO appears under Who, meaning it can call another contract.", + "showDaoPermissionsTooltipLabel": "About DAO-granted permissions", + "showSubpluginPermissions": "Show subplugin/residual permissions", + "showSubpluginPermissionsTooltip": "Permissions involving a subplugin or a contract outside the selected DAO.", + "showSubpluginPermissionsTooltipLabel": "About subplugin/residual permissions" }, "graphView": { - "mode": { - "incoming": "Granted", - "outgoing": "From DAO", - "other": "Other" - }, "node": { "dao": "Primary DAO", "linkedDao": "Linked DAO", @@ -3574,6 +3573,10 @@ "empty": { "heading": "No permissions", "description": "This view has no indexed permissions to visualize." + }, + "fullscreen": { + "open": "Expand graph", + "close": "Exit full screen" } } }, @@ -3589,8 +3592,7 @@ "who": "Who", "where": "Where", "permission": "Permission", - "condition": "Condition", - "noCondition": "No condition" + "condition": "Condition" }, "condition": { "heading": "Condition" @@ -3602,10 +3604,6 @@ "description": "This account has no indexed permissions." } }, - "noConditionSlot": { - "heading": "No condition", - "description": "Functions guarded by this permission can be called by the granted address directly." - }, "unrecognizedConditionSlot": { "heading": "Unrecognized condition", "description": "This permission references a condition contract, but its condition data could not be resolved." diff --git a/apps/app/src/modules/settings/components/noConditionSlot/noConditionSlot.test.tsx b/apps/app/src/modules/settings/components/noConditionSlot/noConditionSlot.test.tsx index 5bb9e5176b..3108ba57fc 100644 --- a/apps/app/src/modules/settings/components/noConditionSlot/noConditionSlot.test.tsx +++ b/apps/app/src/modules/settings/components/noConditionSlot/noConditionSlot.test.tsx @@ -1,20 +1,13 @@ -import { GukModulesProvider } from '@aragon/gov-ui-kit'; import { render, screen } from '@testing-library/react'; import { NoConditionSlot } from './noConditionSlot'; describe(' component', () => { - const createTestComponent = () => ( - - - - ); + it('renders only a compact dash placeholder', () => { + render(); - it('renders the no condition heading and description copy', () => { - render(createTestComponent()); - - expect(screen.getByText(/noConditionSlot.heading/)).toBeInTheDocument(); expect( - screen.getByText(/noConditionSlot.description/), - ).toBeInTheDocument(); + screen.getByTestId('no-condition-placeholder'), + ).toHaveTextContent('-'); + expect(screen.queryByText(/noConditionSlot/)).not.toBeInTheDocument(); }); }); diff --git a/apps/app/src/modules/settings/components/noConditionSlot/noConditionSlot.tsx b/apps/app/src/modules/settings/components/noConditionSlot/noConditionSlot.tsx index 5890410b76..e0e1275f68 100644 --- a/apps/app/src/modules/settings/components/noConditionSlot/noConditionSlot.tsx +++ b/apps/app/src/modules/settings/components/noConditionSlot/noConditionSlot.tsx @@ -1,19 +1,7 @@ 'use client'; -import { CardEmptyState } from '@aragon/gov-ui-kit'; -import { useTranslations } from '@/shared/components/translationsProvider'; - -// Rendered as the PERMISSION_CONDITION fallback, so it must tolerate (and -// ignore) any condition payload props forwarded by the slot. -export const NoConditionSlot: React.FC = () => { - const { t } = useTranslations(); - - return ( - - ); -}; +export const NoConditionSlot: React.FC = () => ( + + - + +); diff --git a/apps/app/src/modules/settings/components/permissionsGraph/index.ts b/apps/app/src/modules/settings/components/permissionsGraph/index.ts index 3a714c1418..255b4f0ba3 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/index.ts +++ b/apps/app/src/modules/settings/components/permissionsGraph/index.ts @@ -2,4 +2,3 @@ export { type IPermissionsGraphProps, PermissionsGraph, } from './permissionsGraph'; -export type { GraphMode } from './permissionsGraphCanvas'; diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.test.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.test.tsx new file mode 100644 index 0000000000..fcec826f0a --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.test.tsx @@ -0,0 +1,44 @@ +import { render } from '@testing-library/react'; +import { ReactFlowProvider } from '@xyflow/react'; +import type { ComponentProps } from 'react'; +import { PermissionStackNode } from './permissionGraphNode'; + +jest.mock('@/shared/components/translationsProvider', () => ({ + useTranslations: () => ({ + t: (key: string, params?: Record) => + params?.condition ?? key, + }), +})); + +const renderStackNode = () => { + const props = { + data: { + permissions: [ + { + edgeId: 'edge-id', + permissionName: 'EXECUTE_PERMISSION', + permissionDisplayName: 'Execute', + }, + ], + }, + } as unknown as ComponentProps; + + return render( + + + , + ); +}; + +describe(' component', () => { + it('keeps compact stack nodes while preserving the full permission ID as title', () => { + const { container } = renderStackNode(); + const button = container.querySelector('button')!; + const visibleLabels = [ + ...button.querySelectorAll('span:not(.sr-only)'), + ].map((element) => element.textContent); + + expect(visibleLabels).toEqual(['Execute']); + expect(button).toHaveAttribute('title', 'EXECUTE_PERMISSION'); + }); +}); diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx index 135087c47b..6bff815a88 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx @@ -204,9 +204,6 @@ export const PermissionStackNode: React.FC< )} )} - - {permission.permissionName} - ); })} diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.test.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.test.tsx new file mode 100644 index 0000000000..bc80311df5 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.test.tsx @@ -0,0 +1,99 @@ +jest.mock('@xyflow/react/dist/style.css', () => ({})); + +import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { Network } from '@/shared/api/daoService'; +import { generateDao } from '@/shared/testUtils'; +import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; +import type { IPermissionRow } from '../../types'; +import { PermissionsGraph } from './permissionsGraph'; + +jest.mock('./permissionsGraphCanvas', () => ({ + getVisibleEdges: (graph: { edges: unknown[] }) => graph.edges, + PermissionsGraphCanvas: () => ( +
+ ), +})); + +jest.mock('@/shared/components/translationsProvider', () => ({ + useTranslations: () => ({ + t: (key: string) => + ({ + 'app.settings.daoPermissionsPage.graphView.fullscreen.open': + 'Expand graph', + 'app.settings.daoPermissionsPage.graphView.fullscreen.close': + 'Exit full screen', + })[key] ?? key, + }), +})); + +const dao = generateDao({ + address: '0x1111111111111111111111111111111111111111', + network: Network.ETHEREUM_MAINNET, + name: 'Test DAO', +}); + +const row: IPermissionRow = { + permissionId: 'permission-id', + whoAddress: ANY_ADDR, + whereAddress: dao.address, + conditionAddress: ALLOW_FLAG, +}; + +const createTestComponent = () => ( + + + +); + +describe(' component', () => { + it('toggles the graph container into the rich-text-style full-screen view', () => { + render(createTestComponent()); + + const container = screen.getByTestId('permissions-graph-container'); + const expandButton = screen.getByRole('button', { + name: 'Expand graph', + }); + + expect(container).not.toHaveClass('fixed'); + + fireEvent.click(expandButton); + + expect(container).toHaveClass('fixed', 'top-0', 'left-0', 'h-screen'); + expect( + screen.getByRole('button', { name: 'Exit full screen' }), + ).toBeInTheDocument(); + + fireEvent.click( + screen.getByRole('button', { name: 'Exit full screen' }), + ); + + expect(container).not.toHaveClass('fixed'); + expect( + screen.getByRole('button', { name: 'Expand graph' }), + ).toBeInTheDocument(); + }); + + it('closes full-screen mode on Escape before other key handlers run', () => { + const handleEscape = jest.fn(); + document.addEventListener('keydown', handleEscape); + render(createTestComponent()); + + const container = screen.getByTestId('permissions-graph-container'); + fireEvent.click(screen.getByRole('button', { name: 'Expand graph' })); + + fireEvent.keyDown(document, { key: 'Escape' }); + + expect(container).not.toHaveClass('fixed'); + expect(handleEscape).not.toHaveBeenCalled(); + + document.removeEventListener('keydown', handleEscape); + }); +}); diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx index f535389738..1d16f99a90 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx @@ -1,9 +1,15 @@ 'use client'; -import { CardEmptyState, StateSkeletonBar } from '@aragon/gov-ui-kit'; +import { + Button, + CardEmptyState, + IconType, + StateSkeletonBar, +} from '@aragon/gov-ui-kit'; import '@xyflow/react/dist/style.css'; import { ReactFlowProvider } from '@xyflow/react'; -import { useMemo, useState } from 'react'; +import classNames from 'classnames'; +import { useEffect, useMemo, useState } from 'react'; import type { IDao, IDaoPlugin } from '@/shared/api/daoService'; import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; import { useTranslations } from '@/shared/components/translationsProvider'; @@ -14,9 +20,8 @@ import type { IPermissionAccountRef } from '../../utils/permissionEntityUtils'; import { PermissionDetailPanel } from './permissionDetailPanel'; import { PermissionNodeDetailPanel } from './permissionNodeDetailPanel'; import { - type GraphMode, + getVisibleEdges, PermissionsGraphCanvas, - useModeEdges, } from './permissionsGraphCanvas'; export interface IPermissionsGraphProps { @@ -26,7 +31,6 @@ export interface IPermissionsGraphProps { accountRefs: IPermissionAccountRef[]; isLoading: boolean; activeAccountAddress?: string; - mode: GraphMode; } export const PermissionsGraph: React.FC = (props) => { @@ -37,13 +41,49 @@ export const PermissionsGraph: React.FC = (props) => { accountRefs, isLoading, activeAccountAddress, - mode, } = props; const { t } = useTranslations(); const [selectedEdgeId, setSelectedEdgeId] = useState(); const [selectedNodeId, setSelectedNodeId] = useState(); + const [isFullScreen, setIsFullScreen] = useState(false); + + useEffect(() => { + if (!isFullScreen) { + return; + } + + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = 'hidden'; + + return () => { + document.body.style.overflow = previousOverflow; + }; + }, [isFullScreen]); + + useEffect(() => { + if (!isFullScreen) { + return; + } + + const handleEscape = (event: KeyboardEvent) => { + if (event.key !== 'Escape') { + return; + } + + event.preventDefault(); + event.stopPropagation(); + setIsFullScreen(false); + }; + window.addEventListener('keydown', handleEscape, { capture: true }); + + return () => { + window.removeEventListener('keydown', handleEscape, { + capture: true, + }); + }; + }, [isFullScreen]); const graph = useMemo(() => { if (dao == null) { @@ -54,11 +94,13 @@ export const PermissionsGraph: React.FC = (props) => { }, [rows, dao, daoPlugins, accountRefs]); const anchorId = (activeAccountAddress ?? dao?.address ?? '').toLowerCase(); - const modeEdges = useModeEdges(graph, mode, anchorId); + const visibleEdges = getVisibleEdges(graph); const visibleNodeIds = new Set( - modeEdges.flatMap((edge) => [edge.source, edge.target]), + visibleEdges.flatMap((edge) => [edge.source, edge.target]), + ); + const selectedEdge = visibleEdges.find( + (edge) => edge.id === selectedEdgeId, ); - const selectedEdge = modeEdges.find((edge) => edge.id === selectedEdgeId); const selectedNode = graph.nodes.find( (node) => node.id === selectedNodeId && visibleNodeIds.has(node.id), ); @@ -67,7 +109,7 @@ export const PermissionsGraph: React.FC = (props) => { return ; } - if (graph.edges.length === 0 || modeEdges.length === 0) { + if (graph.edges.length === 0 || visibleEdges.length === 0) { return ( = (props) => { } return ( -
- +
+ + + )} +
+
+
+ + + + + +
+
-
-
-
- {showExpandAll && ( - - )} - - - - + triggerAsChild={true} + > + + + + +
{isListView ? ( @@ -387,7 +362,6 @@ export const DaoPermissionsPageClient: React.FC< dao={permissionsDao} daoPlugins={daoPlugins} isLoading={isLoading} - mode={mode} rows={filteredRows} /> )} diff --git a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClientUtils.test.ts b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClientUtils.test.ts deleted file mode 100644 index 2464fded13..0000000000 --- a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClientUtils.test.ts +++ /dev/null @@ -1,55 +0,0 @@ -import type { IPermissionRow } from '../../types'; -import { filterRowsByMode } from './daoPermissionsPageClientUtils'; - -const activeDaoAddress = '0x1111111111111111111111111111111111111111'; -const pluginAddress = '0x2222222222222222222222222222222222222222'; -const externalAddress = '0x3333333333333333333333333333333333333333'; -const otherAddress = '0x4444444444444444444444444444444444444444'; - -const buildRow = (partial: Partial): IPermissionRow => ({ - permissionId: 'permission-id', - whoAddress: pluginAddress, - whereAddress: activeDaoAddress, - conditionAddress: '0x0000000000000000000000000000000000000000', - ...partial, -}); - -describe('daoPermissionsPageClientUtils', () => { - describe('filterRowsByMode', () => { - it('returns permissions granted on the active DAO for granted mode', () => { - const grantedRow = buildRow({ whereAddress: activeDaoAddress }); - const otherRow = buildRow({ whereAddress: externalAddress }); - - const result = filterRowsByMode( - [grantedRow, otherRow], - 'incoming', - activeDaoAddress, - ); - - expect(result).toEqual([grantedRow]); - }); - - it('keeps old from-DAO and unrelated relationships in other mode', () => { - const grantedRow = buildRow({ - whoAddress: pluginAddress, - whereAddress: activeDaoAddress, - }); - const oldFromDaoRow = buildRow({ - whoAddress: activeDaoAddress, - whereAddress: externalAddress, - }); - const unrelatedRow = buildRow({ - whoAddress: otherAddress, - whereAddress: externalAddress, - }); - - const result = filterRowsByMode( - [grantedRow, oldFromDaoRow, unrelatedRow], - 'other', - activeDaoAddress, - ); - - expect(result).toEqual([oldFromDaoRow, unrelatedRow]); - }); - }); -}); diff --git a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClientUtils.ts b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClientUtils.ts deleted file mode 100644 index 4c2f497dcf..0000000000 --- a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClientUtils.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { GraphMode } from '../../components/permissionsGraph'; -import type { IPermissionRow } from '../../types'; - -export type DisplayGraphMode = Extract; - -export const graphModes: DisplayGraphMode[] = ['incoming', 'other']; - -export const filterRowsByMode = ( - rows: IPermissionRow[], - mode: GraphMode, - activeAccountAddress?: string, -) => { - const activeAddress = activeAccountAddress?.toLowerCase(); - - if (activeAddress == null) { - return rows; - } - - return rows.filter((row) => { - const whoAddress = row.whoAddress.toLowerCase(); - const whereAddress = row.whereAddress.toLowerCase(); - - if (mode === 'incoming') { - return whereAddress === activeAddress; - } - - if (mode === 'outgoing') { - return whoAddress === activeAddress; - } - - return whereAddress !== activeAddress; - }); -}; diff --git a/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.test.ts b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.test.ts index 786628bd02..c15795d12e 100644 --- a/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.test.ts +++ b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.test.ts @@ -131,4 +131,33 @@ describe('buildPermissionGraph', () => { expect(graph.edges[0].conditionLabel).toBeUndefined(); }); + + it('keeps conditional permissions with the same endpoints distinct', () => { + const votingPowerCondition = + '0xC0Ffee254729296a45a3885639AC7E10F9d54979'; + const membershipCondition = + '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF'; + const rows = [ + buildRow({ conditionAddress: votingPowerCondition }), + buildRow({ conditionAddress: membershipCondition }), + ]; + + const graph = buildPermissionGraph({ + rows, + dao, + daoPlugins, + accountRefs, + }); + + expect(graph.edges).toHaveLength(2); + expect(new Set(graph.edges.map((edge) => edge.id)).size).toBe(2); + expect(graph.edges.map((edge) => edge.source)).toEqual([ + pluginAddress.toLowerCase(), + pluginAddress.toLowerCase(), + ]); + expect(graph.edges.map((edge) => edge.target)).toEqual([ + daoAddress.toLowerCase(), + daoAddress.toLowerCase(), + ]); + }); }); diff --git a/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts index 093dc26ed0..a234b9ec20 100644 --- a/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts +++ b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts @@ -84,10 +84,14 @@ const resolveEdge = (row: IPermissionRow): IPermissionGraphEdge => { ); const conditionLabel = conditionTypeUtils.getConditionLabel(conditionType); + const whoAddress = row.whoAddress.toLowerCase(); + const whereAddress = row.whereAddress.toLowerCase(); + const conditionAddress = row.conditionAddress.toLowerCase(); + return { - id: `${row.permissionId}-${row.whoAddress.toLowerCase()}-${row.whereAddress.toLowerCase()}`, - source: row.whoAddress.toLowerCase(), - target: row.whereAddress.toLowerCase(), + id: `${row.permissionId}-${whoAddress}-${whereAddress}-${conditionAddress}`, + source: whoAddress, + target: whereAddress, permissionName: permissionNameUtils.getPermissionName(row.permissionId), permissionDisplayName: permissionNameUtils.getPermissionDisplayName( row.permissionId, diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts index d58a948983..3e2fcd5a0b 100644 --- a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts @@ -71,7 +71,7 @@ describe('filterPermissionRows', () => { it('hides rows touching installed subplugins by default', () => { const rows = [ buildRow({ whoAddress: subpluginAddress }), - buildRow({ whoAddress: pluginAddress }), + buildRow({ whoAddress: pluginAddress, whereAddress: daoAddress }), ]; const daoPlugins = [ buildPlugin({ @@ -94,7 +94,7 @@ describe('filterPermissionRows', () => { it('hides rows touching plugins with a parent plugin by default', () => { const rows = [ buildRow({ whereAddress: subpluginAddress }), - buildRow({ whoAddress: pluginAddress }), + buildRow({ whoAddress: pluginAddress, whereAddress: daoAddress }), ]; const daoPlugins = [ buildPlugin({ @@ -116,7 +116,7 @@ describe('filterPermissionRows', () => { it('hides rows touching addresses listed by a parent plugin subPlugins field', () => { const rows = [ buildRow({ whoAddress: subpluginAddress }), - buildRow({ whoAddress: pluginAddress }), + buildRow({ whoAddress: pluginAddress, whereAddress: daoAddress }), ]; const daoPlugins = [ buildPlugin({ @@ -135,6 +135,22 @@ describe('filterPermissionRows', () => { expect(result).toEqual([rows[1]]); }); + it('hides residual rows when subplugin/residual permissions are disabled', () => { + const rows = [ + buildRow({ whereAddress: daoAddress }), + buildRow({ whereAddress: targetAddress }), + ]; + + const result = filterPermissionRows(rows, { + activeAccountAddress: daoAddress, + daoPlugins: [], + showDaoPermissions: true, + showSubpluginPermissions: false, + }); + + expect(result).toEqual([rows[0]]); + }); + it('keeps subplugin rows when enabled', () => { const rows = [buildRow({ whoAddress: subpluginAddress })]; const daoPlugins = [ diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts index 193b7d141e..6f085d418c 100644 --- a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts @@ -17,7 +17,7 @@ export interface IPermissionRowFilters { */ showDaoPermissions: boolean; /** - * When false, rows touching a subplugin are hidden. + * When false, rows touching a subplugin or not targeting the active DAO are hidden. */ showSubpluginPermissions: boolean; } @@ -63,6 +63,13 @@ const isDaoGrantedPermission = ( activeAccountAddress != null && addressUtils.isAddressEqual(row.whoAddress, activeAccountAddress); +const isResidualPermission = ( + row: IPermissionRow, + activeAccountAddress?: string, +): boolean => + activeAccountAddress != null && + !addressUtils.isAddressEqual(row.whereAddress, activeAccountAddress); + export const filterPermissionRows = ( rows: IPermissionRow[], filters: IPermissionRowFilters, @@ -82,7 +89,11 @@ export const filterPermissionRows = ( return false; } - if (!showSubpluginPermissions && rowTouchesSubplugin(row, daoPlugins)) { + if ( + !showSubpluginPermissions && + (rowTouchesSubplugin(row, daoPlugins) || + isResidualPermission(row, activeAccountAddress)) + ) { return false; } From e7d48be8eba4966bd98a1f2ba549abc8ef06724b Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Fri, 24 Jul 2026 12:36:23 +0200 Subject: [PATCH 22/55] fix(APP-1003): align spp simulation mock shape --- .../useSppPermissionCheckProposalCreation.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts b/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts index daf8e3f682..a02513a56b 100644 --- a/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts +++ b/apps/app/src/plugins/sppPlugin/hooks/useSppPermissionCheckProposalCreation/useSppPermissionCheckProposalCreation.test.ts @@ -241,7 +241,7 @@ describe('useSppPermissionCheckProposalCreation', () => { const params = createSafeTestParams({ proposalCreationConditionAddress: `0x${'c'.repeat(40)}`, }); - mockSimulation({ isLoading: false, isSuccess: true }); + mockSimulation({ isError: false, isLoading: false, result: 'success' }); const { result } = renderHook(() => useSppPermissionCheckProposalCreation( @@ -271,7 +271,7 @@ describe('useSppPermissionCheckProposalCreation', () => { const params = createSafeTestParams({ proposalCreationConditionAddress: undefined, }); - mockSimulation({ isLoading: false, isSuccess: true }); + mockSimulation({ isError: false, isLoading: false, result: 'success' }); const { result } = renderHook(() => useSppPermissionCheckProposalCreation( @@ -290,7 +290,7 @@ describe('useSppPermissionCheckProposalCreation', () => { brandId: VotingBodyBrandIdentity.EOA, proposalCreationConditionAddress: `0x${'c'.repeat(40)}`, }); - mockSimulation({ isLoading: false, isSuccess: true }); + mockSimulation({ isError: false, isLoading: false, result: 'success' }); const { result } = renderHook(() => useSppPermissionCheckProposalCreation( @@ -353,7 +353,7 @@ describe('useSppPermissionCheckProposalCreation', () => { slotParams.pluginId === internalMeta.interfaceType ? () => internalGuardResult : undefined) as never); - mockSimulation({ isLoading: false, isSuccess: true }); + mockSimulation({ isError: false, isLoading: false, result: 'success' }); const params = { daoId: 'dao-test', plugin: sppPlugin }; const { result } = renderHook(() => From ac7f94f6f6ad6ceb54f63152aa1cdd3c675a521d Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Mon, 27 Jul 2026 20:01:00 +0200 Subject: [PATCH 23/55] Refine permissions graph frontend copy --- apps/app/src/assets/locales/en.json | 10 ++-- .../permissionsList/permissionsList.test.tsx | 24 ++++++++++ .../permissionsList/permissionsList.tsx | 47 +++++++++++++++++-- .../daoPermissionsPageClient.test.tsx | 15 +++--- 4 files changed, 83 insertions(+), 13 deletions(-) diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index f798e75c0c..e927f14af3 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -3563,9 +3563,9 @@ "showDaoPermissions": "Show DAO-granted permissions", "showDaoPermissionsTooltip": "Permissions where the selected DAO appears under Who, meaning it can call another contract.", "showDaoPermissionsTooltipLabel": "About DAO-granted permissions", - "showSubpluginPermissions": "Show subplugin/residual permissions", - "showSubpluginPermissionsTooltip": "Permissions involving a subplugin or a contract outside the selected DAO.", - "showSubpluginPermissionsTooltipLabel": "About subplugin/residual permissions" + "showSubpluginPermissions": "Show advanced permission layer", + "showSubpluginPermissionsTooltip": "Includes process internals, condition contracts, external actors, and unresolved permission rows from the DAO permission table.", + "showSubpluginPermissionsTooltipLabel": "About advanced permission layer" }, "graphView": { "node": { @@ -3598,6 +3598,10 @@ "header": { "who": "Who", "where": "Where", + "whoTooltip": "The actor that receives the permission and can call the target contract.", + "whoTooltipLabel": "About Who", + "whereTooltip": "The contract where the permission applies.", + "whereTooltipLabel": "About Where", "permission": "Permission", "condition": "Condition" }, diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx index cfe505e61d..9d80964481 100644 --- a/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx @@ -88,6 +88,30 @@ describe(' component', () => { ).toBeInTheDocument(); }); + it('renders informational help for the Who and Where headers', () => { + const rows: IPermissionRow[] = [ + { + permissionId: ROOT_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: ALLOW_FLAG, + }, + ]; + + render(createTestComponent({ rows })); + + expect( + screen.getByRole('img', { + name: /permissionsList.header.whoTooltip/, + }), + ).toBeInTheDocument(); + expect( + screen.getByRole('img', { + name: /permissionsList.header.whereTooltip/, + }), + ).toBeInTheDocument(); + }); + it('renders the collapsed condition cell with the resolved label or a dash', () => { const rows: IPermissionRow[] = [ { diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx index 93ab84158c..f820db17eb 100644 --- a/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx @@ -7,10 +7,13 @@ import { ChainEntityType, DaoAvatar, DefinitionList, + Icon, + IconType, Link, StateSkeletonBar, StateSkeletonCircular, Tag, + Tooltip, useBlockExplorer, } from '@aragon/gov-ui-kit'; import type { IDaoPlugin, Network } from '@/shared/api/daoService'; @@ -351,6 +354,36 @@ const PermissionsListRow: React.FC = (props) => { ); }; +interface IPermissionsListHeaderLabelProps { + labelKey: string; + tooltipKey: string; + tooltipLabelKey: string; +} + +const PermissionsListHeaderLabel: React.FC = ( + props, +) => { + const { labelKey, tooltipKey, tooltipLabelKey } = props; + const { t } = useTranslations(); + const label = t(labelKey); + const tooltip = t(tooltipKey); + + return ( + + {label} + + + + + + + ); +}; + const PermissionsListHeader: React.FC = () => { const { t } = useTranslations(); @@ -358,10 +391,16 @@ const PermissionsListHeader: React.FC = () => {
- {t('app.settings.permissionsList.header.who')} - - {t('app.settings.permissionsList.header.where')} - + + {t('app.settings.permissionsList.header.permission')} diff --git a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.test.tsx b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.test.tsx index b706be42cc..8fbcbf5a5b 100644 --- a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.test.tsx +++ b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.test.tsx @@ -56,15 +56,15 @@ jest.mock('@/shared/components/translationsProvider', () => ({ 'app.settings.daoPermissionsPage.filters.showDaoPermissions': 'Show DAO-granted permissions', 'app.settings.daoPermissionsPage.filters.showSubpluginPermissions': - 'Show subplugin/residual permissions', + 'Show advanced permission layer', 'app.settings.daoPermissionsPage.filters.showDaoPermissionsTooltipLabel': 'About DAO-granted permissions', 'app.settings.daoPermissionsPage.filters.showDaoPermissionsTooltip': 'Permissions where the selected DAO appears under Who, meaning it can call another contract.', 'app.settings.daoPermissionsPage.filters.showSubpluginPermissionsTooltipLabel': - 'About subplugin/residual permissions', + 'About advanced permission layer', 'app.settings.daoPermissionsPage.filters.showSubpluginPermissionsTooltip': - 'Permissions involving a subplugin or a contract outside the selected DAO.', + 'Includes process internals, condition contracts, external actors, and unresolved permission rows from the DAO permission table.', 'app.settings.daoPermissionsPage.view.graph': 'Graph', 'app.settings.daoPermissionsPage.view.list': 'List', })[key] ?? key, @@ -165,8 +165,11 @@ describe(' component', () => { screen.getByText('Show DAO-granted permissions'), ).toBeInTheDocument(); expect( - screen.getByText('Show subplugin/residual permissions'), + screen.getByText('Show advanced permission layer'), ).toBeInTheDocument(); + expect( + screen.queryByText('Show subplugin/residual permissions'), + ).not.toBeInTheDocument(); expect( screen.queryByRole('button', { name: 'About DAO-granted permissions', @@ -174,7 +177,7 @@ describe(' component', () => { ).not.toBeInTheDocument(); expect( screen.queryByRole('button', { - name: 'About subplugin/residual permissions', + name: 'About advanced permission layer', }), ).not.toBeInTheDocument(); expect( @@ -184,7 +187,7 @@ describe(' component', () => { ).toBeInTheDocument(); expect( screen.getByRole('img', { - name: /Permissions involving a subplugin/, + name: /Includes process internals, condition contracts/, }), ).toBeInTheDocument(); expect(screen.getByTestId('permissions-graph')).toHaveAttribute( From b76f6856e09d5baeeacd9a8ddc15b30b5f342e78 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Mon, 27 Jul 2026 20:42:36 +0200 Subject: [PATCH 24/55] Rename supporting permissions switch --- apps/app/src/assets/locales/en.json | 6 +++--- .../daoPermissionsPage/daoPermissionsPageClient.test.tsx | 8 ++++---- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index e927f14af3..a78ac9ebb4 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -3563,9 +3563,9 @@ "showDaoPermissions": "Show DAO-granted permissions", "showDaoPermissionsTooltip": "Permissions where the selected DAO appears under Who, meaning it can call another contract.", "showDaoPermissionsTooltipLabel": "About DAO-granted permissions", - "showSubpluginPermissions": "Show advanced permission layer", - "showSubpluginPermissionsTooltip": "Includes process internals, condition contracts, external actors, and unresolved permission rows from the DAO permission table.", - "showSubpluginPermissionsTooltipLabel": "About advanced permission layer" + "showSubpluginPermissions": "Show supporting permissions", + "showSubpluginPermissionsTooltip": "Includes process internals, condition contracts, external actors, and other permission rows that support the main governance view.", + "showSubpluginPermissionsTooltipLabel": "About supporting permissions" }, "graphView": { "node": { diff --git a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.test.tsx b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.test.tsx index 8fbcbf5a5b..935238a45d 100644 --- a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.test.tsx +++ b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.test.tsx @@ -56,13 +56,13 @@ jest.mock('@/shared/components/translationsProvider', () => ({ 'app.settings.daoPermissionsPage.filters.showDaoPermissions': 'Show DAO-granted permissions', 'app.settings.daoPermissionsPage.filters.showSubpluginPermissions': - 'Show advanced permission layer', + 'Show supporting permissions', 'app.settings.daoPermissionsPage.filters.showDaoPermissionsTooltipLabel': 'About DAO-granted permissions', 'app.settings.daoPermissionsPage.filters.showDaoPermissionsTooltip': 'Permissions where the selected DAO appears under Who, meaning it can call another contract.', 'app.settings.daoPermissionsPage.filters.showSubpluginPermissionsTooltipLabel': - 'About advanced permission layer', + 'About supporting permissions', 'app.settings.daoPermissionsPage.filters.showSubpluginPermissionsTooltip': 'Includes process internals, condition contracts, external actors, and unresolved permission rows from the DAO permission table.', 'app.settings.daoPermissionsPage.view.graph': 'Graph', @@ -165,7 +165,7 @@ describe(' component', () => { screen.getByText('Show DAO-granted permissions'), ).toBeInTheDocument(); expect( - screen.getByText('Show advanced permission layer'), + screen.getByText('Show supporting permissions'), ).toBeInTheDocument(); expect( screen.queryByText('Show subplugin/residual permissions'), @@ -177,7 +177,7 @@ describe(' component', () => { ).not.toBeInTheDocument(); expect( screen.queryByRole('button', { - name: 'About advanced permission layer', + name: 'About supporting permissions', }), ).not.toBeInTheDocument(); expect( From 0cd32eb64f31c062a7a952fbc4afb51a9c9dda99 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 28 Jul 2026 10:36:32 +0200 Subject: [PATCH 25/55] Prefer enriched permission entities --- .../permissionDetailPanel.tsx | 9 ++- .../permissionsList/permissionsList.test.tsx | 23 ++++++ .../permissionsList/permissionsList.tsx | 35 ++++---- .../modules/settings/types/permissionGraph.ts | 3 + .../buildPermissionGraph.test.ts | 29 +++++++ .../buildPermissionGraph.ts | 38 ++++++--- .../permissionEntityUtils.test.ts | 24 ++++++ .../permissionEntityUtils.ts | 80 +++++++++++++++++-- .../permissionRowFilters.test.ts | 25 ++++++ .../permissionRowFilters.ts | 24 +++++- .../api/daoService/domain/daoPermission.ts | 38 ++++++++- .../src/shared/api/daoService/domain/index.ts | 6 +- 12 files changed, 296 insertions(+), 38 deletions(-) diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionDetailPanel.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionDetailPanel.tsx index 1121eff022..0c6488ae5d 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionDetailPanel.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionDetailPanel.tsx @@ -39,14 +39,15 @@ export const PermissionDetailPanel: React.FC = ({ }) => { const { t } = useTranslations(); const { row } = edge; + const conditionAddress = row.conditionAddress ?? ALLOW_FLAG; const who = nodes.find((node) => node.id === edge.source); const where = nodes.find((node) => node.id === edge.target); const hasCondition = !addressUtils.isAddressEqual( - row.conditionAddress, + conditionAddress, ALLOW_FLAG, ); const conditionType = conditionTypeUtils.resolveConditionType( - row.conditionAddress, + conditionAddress, row.condition, ); const hasUnrecognizedCondition = conditionType === UNKNOWN_CONDITION; @@ -247,12 +248,12 @@ export const PermissionDetailPanel: React.FC = ({ ) : hasUnrecognizedCondition ? ( ) : ( component', () => { ).toBeInTheDocument(); }); + it('renders backend-enriched entity labels without plugin lookup', () => { + const rows: IPermissionRow[] = [ + { + permissionId: ROOT_PERMISSION_ID, + whoAddress: '0x2222222222222222222222222222222222222222', + whereAddress: ALLOW_FLAG, + conditionAddress: ALLOW_FLAG, + who: { + address: '0x2222222222222222222222222222222222222222', + interfaceType: 'spp', + label: 'Backend SPP', + layer: 'topLevelPlugin', + status: 'installed', + }, + }, + ]; + + render(createTestComponent({ rows })); + + expect(screen.getByText('Backend SPP')).toBeInTheDocument(); + expect(screen.getByText('SPP')).toBeInTheDocument(); + }); + it('renders informational help for the Who and Where headers', () => { const rows: IPermissionRow[] = [ { diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx index f820db17eb..0054e716b2 100644 --- a/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx @@ -55,8 +55,11 @@ const SKELETON_ROW_KEYS = [ 'skeleton-4', ]; -export const getPermissionRowKey = (row: IPermissionRow): string => - `${row.permissionId}-${row.whoAddress.toLowerCase()}-${row.whereAddress.toLowerCase()}-${row.conditionAddress.toLowerCase()}`; +export const getPermissionRowKey = (row: IPermissionRow): string => { + const conditionAddress = row.conditionAddress ?? ALLOW_FLAG; + + return `${row.permissionId}-${row.whoAddress.toLowerCase()}-${row.whereAddress.toLowerCase()}-${conditionAddress.toLowerCase()}`; +}; export const PermissionsList: React.FC = (props) => { const { @@ -218,19 +221,23 @@ const PermissionsListRow: React.FC = (props) => { const { t } = useTranslations(); const resolveOptions = { daoPlugins, accounts }; - const who = permissionEntityUtils.resolvePermissionEntity( - row.whoAddress, - resolveOptions, - ); + const who = permissionEntityUtils.resolvePermissionEntity(row.whoAddress, { + ...resolveOptions, + entity: row.who, + }); const where = permissionEntityUtils.resolvePermissionEntity( row.whereAddress, - resolveOptions, + { + ...resolveOptions, + entity: row.where, + }, ); const permissionName = permissionNameUtils.getPermissionName( row.permissionId, ); + const conditionAddress = row.conditionAddress ?? ALLOW_FLAG; const conditionType = conditionTypeUtils.resolveConditionType( - row.conditionAddress, + conditionAddress, row.condition, ); const conditionLabel = conditionTypeUtils.getConditionLabel(conditionType); @@ -238,11 +245,11 @@ const PermissionsListRow: React.FC = (props) => { const hasUnrecognizedCondition = conditionType === UNKNOWN_CONDITION; const hasCondition = !addressUtils.isAddressEqual( - row.conditionAddress, + conditionAddress, ALLOW_FLAG, ); const conditionDetail = hasCondition - ? addressUtils.truncateAddress(row.conditionAddress) + ? addressUtils.truncateAddress(conditionAddress) : undefined; return ( @@ -309,9 +316,7 @@ const PermissionsListRow: React.FC = (props) => { = (props) => { {hasUnrecognizedCondition ? ( ) : ( { ); }); + it('uses backend entity metadata without installed plugin lookup', () => { + const row = buildRow({ + whoAddress: pluginAddress, + who: { + address: pluginAddress, + interfaceType: 'spp', + label: 'Backend Process', + layer: 'topLevelPlugin', + status: 'installed', + }, + }); + + const graph = buildPermissionGraph({ + rows: [row], + dao, + accountRefs, + }); + + expect( + graph.nodes.find((node) => node.id === pluginAddress.toLowerCase()), + ).toMatchObject({ + kind: 'plugin', + label: 'Backend Process', + tag: 'SPP', + layer: 'topLevelPlugin', + status: 'installed', + }); + }); + it('creates who-to-where edges with resolved permission and condition labels', () => { const row = buildRow({ conditionAddress, diff --git a/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts index a234b9ec20..ec64beb906 100644 --- a/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts +++ b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts @@ -1,7 +1,12 @@ import { addressUtils } from '@aragon/gov-ui-kit'; -import type { IDao, IDaoPlugin } from '@/shared/api/daoService'; +import type { + IDao, + IDaoPlugin, + IPermissionEntityRef, +} from '@/shared/api/daoService'; import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; import { permissionNameUtils } from '@/shared/utils/permissionNameUtils'; +import { ALLOW_FLAG } from '../../constants/permissionSentinels'; import type { IPermissionGraph, IPermissionGraphEdge, @@ -28,6 +33,7 @@ const resolveNode = ( dao: IDao, daoPlugins?: IFilterComponentPlugin[], accountRefs?: IPermissionAccountRef[], + enrichedEntity?: IPermissionEntityRef, ): IPermissionGraphNode => { const id = address.toLowerCase(); @@ -62,6 +68,7 @@ const resolveNode = ( const entity = permissionEntityUtils.resolvePermissionEntity(address, { daoPlugins, accounts: accountRefs, + entity: enrichedEntity, }); if (entity.type === 'plugin') { @@ -70,26 +77,36 @@ const resolveNode = ( kind: 'plugin', label: entity.label, tag: entity.tag, + layer: entity.layer, + status: entity.status, address, }; } - return { id, kind: 'actor', label: entity.label, address }; + return { + id, + kind: 'actor', + label: entity.label, + layer: entity.layer, + status: entity.status, + address, + }; }; const resolveEdge = (row: IPermissionRow): IPermissionGraphEdge => { + const conditionAddress = row.conditionAddress ?? ALLOW_FLAG; const conditionType = conditionTypeUtils.resolveConditionType( - row.conditionAddress, + conditionAddress, row.condition, ); const conditionLabel = conditionTypeUtils.getConditionLabel(conditionType); const whoAddress = row.whoAddress.toLowerCase(); const whereAddress = row.whereAddress.toLowerCase(); - const conditionAddress = row.conditionAddress.toLowerCase(); + const conditionNodeAddress = conditionAddress.toLowerCase(); return { - id: `${row.permissionId}-${whoAddress}-${whereAddress}-${conditionAddress}`, + id: `${row.permissionId}-${whoAddress}-${whereAddress}-${conditionNodeAddress}`, source: whoAddress, target: whereAddress, permissionName: permissionNameUtils.getPermissionName(row.permissionId), @@ -108,20 +125,23 @@ export const buildPermissionGraph = ( const { rows, dao, daoPlugins, accountRefs } = params; const nodesById = new Map(); - const ensureNode = (address: string): void => { + const ensureNode = ( + address: string, + entity?: IPermissionEntityRef, + ): void => { const id = address.toLowerCase(); if (!nodesById.has(id)) { nodesById.set( id, - resolveNode(address, dao, daoPlugins, accountRefs), + resolveNode(address, dao, daoPlugins, accountRefs, entity), ); } }; const edges = rows.map((row) => { - ensureNode(row.whoAddress); - ensureNode(row.whereAddress); + ensureNode(row.whoAddress, row.who); + ensureNode(row.whereAddress, row.where); return resolveEdge(row); }); diff --git a/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts b/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts index 7f32b30453..425384124a 100644 --- a/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts +++ b/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.test.ts @@ -122,5 +122,29 @@ describe('permissionEntity Utils', () => { expect(result.detailName).toEqual('Multisig v1.2'); }); + + it('prefers backend-enriched entity metadata over local fallbacks', () => { + const result = permissionEntityUtils.resolvePermissionEntity( + unknownAddress, + { + entity: { + address: unknownAddress, + interfaceType: 'tokenVoting', + label: 'Historical Token Voting', + layer: 'historicalPlugin', + status: 'uninstalled', + }, + }, + ); + + expect(result).toMatchObject({ + label: 'Historical Token Voting', + tag: 'TOKENVOTING', + type: 'plugin', + detailName: 'Historical Token Voting', + layer: 'historicalPlugin', + status: 'uninstalled', + }); + }); }); }); diff --git a/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts b/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts index b6aedb5e54..956f8fde31 100644 --- a/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts +++ b/apps/app/src/modules/settings/utils/permissionEntityUtils/permissionEntityUtils.ts @@ -1,5 +1,5 @@ import { addressUtils } from '@aragon/gov-ui-kit'; -import type { IDaoPlugin } from '@/shared/api/daoService'; +import type { IDaoPlugin, IPermissionEntityRef } from '@/shared/api/daoService'; import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; import { daoUtils } from '@/shared/utils/daoUtils'; import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; @@ -36,6 +36,14 @@ export interface IPermissionEntity { * placeholder circle). */ type: PermissionEntityType; + /** + * Backend entity layer, when supplied by the permissions endpoint. + */ + layer?: IPermissionEntityRef['layer']; + /** + * Backend lifecycle status, when supplied by the permissions endpoint. + */ + status?: IPermissionEntityRef['status']; /** * Avatar source for DAO / linked-account entities. */ @@ -70,6 +78,11 @@ interface IResolvePermissionEntityOptions { * DAO names and avatars. */ accounts?: IPermissionAccountRef[]; + /** + * Backend-enriched entity metadata returned with the permission row. When + * present, this is the source of truth for labels and layers. + */ + entity?: IPermissionEntityRef; } class PermissionEntityUtils { @@ -79,16 +92,17 @@ class PermissionEntityUtils { * Resolution order: * 1. {@link ANY_ADDR} sentinel -> "Anyone". * 2. {@link ALLOW_FLAG} sentinel -> "Any Address". - * 3. A matching installed DAO plugin -> the plugin name plus its interface + * 3. Backend-enriched permission entity metadata. + * 4. A matching installed DAO plugin -> the plugin name plus its interface * type as an uppercase tag (e.g. `MULTISIG`) and a `name vX.Y` detail. - * 4. A matching DAO / linked account -> the account name and avatar. - * 5. Otherwise -> the truncated address. + * 5. A matching DAO / linked account -> the account name and avatar. + * 6. Otherwise -> the truncated address. */ resolvePermissionEntity = ( address: string, options: IResolvePermissionEntityOptions = {}, ): IPermissionEntity => { - const { daoPlugins, accounts } = options; + const { daoPlugins, accounts, entity } = options; if (this.isAddressEqual(address, ANY_ADDR)) { return { @@ -108,6 +122,10 @@ class PermissionEntityUtils { }; } + if (entity != null) { + return this.resolveBackendEntity(address, entity); + } + const matchedPlugin = daoPlugins?.find((plugin) => this.isAddressEqual(plugin.meta.address, address), ); @@ -153,6 +171,58 @@ class PermissionEntityUtils { }; }; + private resolveBackendEntity = ( + address: string, + entity: IPermissionEntityRef, + ): IPermissionEntity => { + const label = + entity.label ?? + (entity.layer === 'contract' + ? 'Unresolved contract' + : 'Unknown address'); + const tag = entity.interfaceType?.toUpperCase(); + + if (entity.layer === 'dao') { + return { + label, + address, + isSentinel: false, + type: 'dao', + avatarSrc: entity.avatarSrc, + detailName: label, + layer: entity.layer, + status: entity.status, + }; + } + + if ( + entity.layer === 'topLevelPlugin' || + entity.layer === 'processInternal' || + entity.layer === 'historicalPlugin' + ) { + return { + label, + tag, + address, + isSentinel: false, + type: 'plugin', + detailName: entity.parentPluginName ?? label, + layer: entity.layer, + status: entity.status, + }; + } + + return { + label, + address, + isSentinel: false, + type: 'address', + detailName: addressUtils.truncateAddress(address), + layer: entity.layer, + status: entity.status, + }; + }; + private formatPluginDetail = (plugin: IDaoPlugin): string => { const name = daoUtils.getPluginName(plugin); const { release, build } = plugin; diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts index 3e2fcd5a0b..e356d463fe 100644 --- a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts @@ -135,6 +135,31 @@ describe('filterPermissionRows', () => { expect(result).toEqual([rows[1]]); }); + it('hides backend-classified supporting permission rows by default', () => { + const rows = [ + buildRow({ + whereAddress: daoAddress, + who: { + address: subpluginAddress, + label: 'Process internal', + layer: 'processInternal', + parentPluginAddress, + }, + whoAddress: subpluginAddress, + }), + buildRow({ whoAddress: pluginAddress, whereAddress: daoAddress }), + ]; + + const result = filterPermissionRows(rows, { + activeAccountAddress: daoAddress, + daoPlugins: [], + showDaoPermissions: true, + showSubpluginPermissions: false, + }); + + expect(result).toEqual([rows[1]]); + }); + it('hides residual rows when subplugin/residual permissions are disabled', () => { const rows = [ buildRow({ whereAddress: daoAddress }), diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts index 6f085d418c..09635ed198 100644 --- a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts @@ -1,5 +1,8 @@ import { addressUtils } from '@aragon/gov-ui-kit'; -import type { IDaoPlugin } from '@/shared/api/daoService'; +import type { + IDaoPlugin, + PermissionEntityLayer, +} from '@/shared/api/daoService'; import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; import type { IPermissionRow } from '../../types'; @@ -22,6 +25,18 @@ export interface IPermissionRowFilters { showSubpluginPermissions: boolean; } +const SUPPORTING_PERMISSION_LAYERS = new Set([ + 'processInternal', + 'condition', + 'externalActor', + 'historicalPlugin', + 'contract', + 'unknown', +]); + +const isSupportingPermissionLayer = (layer?: PermissionEntityLayer): boolean => + layer != null && SUPPORTING_PERMISSION_LAYERS.has(layer); + const isSubplugin = (plugin: IFilterComponentPlugin): boolean => { const { meta } = plugin; @@ -49,10 +64,13 @@ const isSubpluginAddress = ( ); }) ?? false; -const rowTouchesSubplugin = ( +const rowTouchesSupportingPermission = ( row: IPermissionRow, daoPlugins?: IFilterComponentPlugin[], ): boolean => + isSupportingPermissionLayer(row.who?.layer) || + isSupportingPermissionLayer(row.where?.layer) || + row.conditionEntity?.layer === 'condition' || isSubpluginAddress(row.whoAddress, daoPlugins) || isSubpluginAddress(row.whereAddress, daoPlugins); @@ -91,7 +109,7 @@ export const filterPermissionRows = ( if ( !showSubpluginPermissions && - (rowTouchesSubplugin(row, daoPlugins) || + (rowTouchesSupportingPermission(row, daoPlugins) || isResidualPermission(row, activeAccountAddress)) ) { return false; diff --git a/apps/app/src/shared/api/daoService/domain/daoPermission.ts b/apps/app/src/shared/api/daoService/domain/daoPermission.ts index 15a61ec53f..c608d58d57 100644 --- a/apps/app/src/shared/api/daoService/domain/daoPermission.ts +++ b/apps/app/src/shared/api/daoService/domain/daoPermission.ts @@ -15,6 +15,30 @@ export interface IDaoPermissionCondition { [key: string]: unknown; } +export type PermissionEntityLayer = + | 'dao' + | 'topLevelPlugin' + | 'processInternal' + | 'condition' + | 'externalActor' + | 'historicalPlugin' + | 'contract' + | 'unknown'; + +export interface IPermissionEntityRef { + address: string; + layer: PermissionEntityLayer; + label?: string; + interfaceType?: string; + status?: 'installed' | 'uninstalled' | 'historical' | 'unknown'; + parentPluginAddress?: string; + parentPluginName?: string; + parentInterfaceType?: string; + stageIndex?: number; + role?: 'who' | 'where' | 'condition'; + avatarSrc?: string; +} + export interface IDaoPermission { /** * Pemission ID. keccak256 hash of a permission string. @@ -33,11 +57,23 @@ export interface IDaoPermission { * The address `ALLOW_FLAG` for regular permissions or, alternatively, the * `IPermissionCondition` contract implementation to be used. */ - conditionAddress: string; + conditionAddress?: string; /** * Enriched condition details returned by the backend when available. */ condition?: IDaoPermissionCondition; + /** + * Backend-enriched display metadata for the permission actor. + */ + who?: IPermissionEntityRef; + /** + * Backend-enriched display metadata for the permission target. + */ + where?: IPermissionEntityRef; + /** + * Backend-enriched display metadata for the permission condition contract. + */ + conditionEntity?: IPermissionEntityRef; /** * Network of the DAO permission event. */ diff --git a/apps/app/src/shared/api/daoService/domain/index.ts b/apps/app/src/shared/api/daoService/domain/index.ts index b8cf0f3ac1..9f60b821e0 100644 --- a/apps/app/src/shared/api/daoService/domain/index.ts +++ b/apps/app/src/shared/api/daoService/domain/index.ts @@ -1,7 +1,11 @@ export type { IAddressInfo } from './addressInfo'; export type { IDao, ILinkedAccountSummary } from './dao'; export type { IDaoMetrics } from './daoMetrics'; -export type { IDaoPermission } from './daoPermission'; +export type { + IDaoPermission, + IPermissionEntityRef, + PermissionEntityLayer, +} from './daoPermission'; export type { IDaoPlugin } from './daoPlugin'; export { type IDaoPolicy, From 9df90e6b3fb9f4a78b4b649a3d997ce7631bbf0a Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 28 Jul 2026 12:57:40 +0200 Subject: [PATCH 26/55] Mark historical graph plugins --- apps/app/src/assets/locales/en.json | 2 + .../permissionGraphNode.test.tsx | 56 ++++++++++++++++++- .../permissionsGraph/permissionGraphNode.tsx | 17 +++++- 3 files changed, 72 insertions(+), 3 deletions(-) diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index a78ac9ebb4..3bbc2c658f 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -3572,6 +3572,8 @@ "dao": "Primary DAO", "linkedDao": "Linked DAO", "plugin": "Aragon OSx Plugin", + "historicalPlugin": "Historical plugin", + "uninstalledPlugin": "Uninstalled plugin", "actor": "Address", "who": "Who", "where": "Where" diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.test.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.test.tsx index fcec826f0a..389b699ec6 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.test.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.test.tsx @@ -1,7 +1,10 @@ -import { render } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { ReactFlowProvider } from '@xyflow/react'; import type { ComponentProps } from 'react'; -import { PermissionStackNode } from './permissionGraphNode'; +import { + PermissionGraphNode, + PermissionStackNode, +} from './permissionGraphNode'; jest.mock('@/shared/components/translationsProvider', () => ({ useTranslations: () => ({ @@ -30,6 +33,55 @@ const renderStackNode = () => { ); }; +const renderGraphNode = ( + data: Partial['data']> = {}, +) => { + const props = { + data: { + id: 'plugin-node', + kind: 'plugin', + label: 'Token Voting', + tag: 'TOKENVOTING', + address: '0x8888888888888888888888888888888888888888', + ...data, + }, + } as unknown as ComponentProps; + + return render( + + + , + ); +}; + +describe(' component', () => { + it('marks uninstalled plugin nodes as historical', () => { + renderGraphNode({ + layer: 'historicalPlugin', + status: 'uninstalled', + }); + + expect( + screen.getByText( + 'app.settings.daoPermissionsPage.graphView.node.uninstalledPlugin', + ), + ).toBeInTheDocument(); + }); + + it('marks historical plugin nodes separately from installed plugins', () => { + renderGraphNode({ + layer: 'historicalPlugin', + status: 'historical', + }); + + expect( + screen.getByText( + 'app.settings.daoPermissionsPage.graphView.node.historicalPlugin', + ), + ).toBeInTheDocument(); + }); +}); + describe(' component', () => { it('keeps compact stack nodes while preserving the full permission ID as title', () => { const { container } = renderStackNode(); diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx index 6bff815a88..fd9aea4037 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx @@ -93,6 +93,20 @@ const SELECTION_LABEL_KEY: Record = { where: 'app.settings.daoPermissionsPage.graphView.node.where', }; +const getSubtitleKey = (data: IPermissionNodeData): string => { + if (data.kind === 'plugin') { + if (data.status === 'uninstalled') { + return 'app.settings.daoPermissionsPage.graphView.node.uninstalledPlugin'; + } + + if (data.status === 'historical' || data.layer === 'historicalPlugin') { + return 'app.settings.daoPermissionsPage.graphView.node.historicalPlugin'; + } + } + + return SUBTITLE_KEY[data.kind]; +}; + export const PermissionGraphNode: React.FC> = ({ data, }) => { @@ -100,6 +114,7 @@ export const PermissionGraphNode: React.FC> = ({ const { kind, label, tag, avatarSrc, selectionRole, active, dimmed } = data; const isDaoKind = kind === 'dao' || kind === 'linkedDao'; const isSelected = selectionRole != null || active === true; + const subtitleKey = getSubtitleKey(data); return (
> = ({
{label} - {t(SUBTITLE_KEY[kind])} + {t(subtitleKey)}
{isDaoKind && ( From 690304b50ffadae86ecbfda44d8806c97cf5f706 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 28 Jul 2026 15:02:36 +0200 Subject: [PATCH 27/55] Restore permissions graph trident layout --- .changeset/app-1003-iterate-graph-view.md | 2 +- .../permissionsGraph/permissionsGraph.tsx | 1 + .../permissionsGraphCanvas.test.ts | 49 ++++++++++ .../permissionsGraphCanvas.tsx | 92 +++++++++++++++--- .../permissionRowFilters.test.ts | 97 +++++++++++++++++++ .../permissionRowFilters.ts | 4 +- 6 files changed, 226 insertions(+), 19 deletions(-) diff --git a/.changeset/app-1003-iterate-graph-view.md b/.changeset/app-1003-iterate-graph-view.md index f7f1a3e5b6..f326f34fc0 100644 --- a/.changeset/app-1003-iterate-graph-view.md +++ b/.changeset/app-1003-iterate-graph-view.md @@ -2,4 +2,4 @@ "@aragon/app": minor --- -Add the DAO permissions graph view backed by real permissions data +Add the DAO permissions graph view backed by real permissions data, including filter handling for enriched condition metadata, DAO-connected plugin edges, and the restored incoming-permission trident layout. diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx index 1d16f99a90..99543a2389 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx @@ -135,6 +135,7 @@ export const PermissionsGraph: React.FC = (props) => { > { }); }); +describe('getLayoutDirection', () => { + it('places the DAO above plugin actors for incoming-only graphs', () => { + const result = getLayoutDirection( + [buildEdge('incoming', { source: pluginId, target: anchorId })], + anchorId, + ); + + expect(result).toBe('BT'); + }); + + it('keeps top-to-bottom layout when DAO-granted rows are visible', () => { + const result = getLayoutDirection( + [buildEdge('outgoing', { source: anchorId, target: pluginId })], + anchorId, + ); + + expect(result).toBe('TB'); + }); +}); + +describe('buildFlowElements', () => { + it('uses incoming handles when only plugin-to-DAO rows are visible', () => { + const incomingEdge = buildEdge('incoming', { + source: pluginId, + target: anchorId, + }); + const { edges } = buildFlowElements({ + anchorId, + graph: buildGraph([incomingEdge]), + onSelectEdge: jest.fn(), + visibleEdges: [incomingEdge], + }); + + const originEdge = edges.find((edge) => edge.id.endsWith('-origin')); + const targetEdge = edges.find((edge) => edge.id.endsWith('-target')); + + expect(originEdge).toMatchObject({ + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceTop, + targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, + }); + expect(targetEdge).toMatchObject({ + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceTop, + targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, + }); + }); +}); + describe('positionSelfStacks', () => { it('places core DAO self-permission stacks south of the DAO node', () => { const daoY = 100; @@ -103,6 +151,7 @@ describe('positionSelfStacks', () => { const result = buildFlowElements({ graph, + anchorId, visibleEdges: graph.edges, onSelectEdge: jest.fn(), }); diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx index 372ccdc607..1c8ddf17ef 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx @@ -58,32 +58,76 @@ const FALLBACK_STACK_WIDTH = 240; const STACK_ROW_HEIGHT = 20; const STACK_CONDITION_ROW_HEIGHT = 34; const STACK_ROW_GAP = 2; +type PermissionGraphFlow = 'incoming' | 'outgoing'; export const getVisibleEdges = ( graph: IPermissionGraph, ): IPermissionGraphEdge[] => graph.edges; -const getLayoutDirection = (): PermissionGraphDirection => 'TB'; +export const getGraphFlow = ( + visibleEdges: IPermissionGraphEdge[], + anchorId: string, +): PermissionGraphFlow => { + const nonSelfEdges = visibleEdges.filter( + (edge) => edge.source !== edge.target, + ); + const hasIncomingEdges = nonSelfEdges.some( + (edge) => edge.target === anchorId, + ); + const hasOutgoingEdges = nonSelfEdges.some( + (edge) => edge.source === anchorId, + ); + + return hasIncomingEdges && !hasOutgoingEdges ? 'incoming' : 'outgoing'; +}; + +export const getLayoutDirection = ( + visibleEdges: IPermissionGraphEdge[], + anchorId: string, +): PermissionGraphDirection => + getGraphFlow(visibleEdges, anchorId) === 'incoming' ? 'BT' : 'TB'; const getLayoutSpacing = (): { nodesep: number; ranksep: number } => ({ nodesep: 60, ranksep: 170, }); -const getHandlePositions = (): { +const getHandlePositions = ( + flow: PermissionGraphFlow, +): { sourcePosition: Position; targetPosition: Position; -} => ({ - sourcePosition: Position.Bottom, - targetPosition: Position.Top, -}); +} => { + if (flow === 'incoming') { + return { + sourcePosition: Position.Top, + targetPosition: Position.Bottom, + }; + } -const getEdgeHandles = () => ({ - originSource: PERMISSION_GRAPH_HANDLE.sourceBottom, - stackTarget: PERMISSION_GRAPH_HANDLE.targetTop, - stackSource: PERMISSION_GRAPH_HANDLE.sourceBottom, - targetTarget: PERMISSION_GRAPH_HANDLE.targetTop, -}); + return { + sourcePosition: Position.Bottom, + targetPosition: Position.Top, + }; +}; + +const getEdgeHandles = (flow: PermissionGraphFlow) => { + if (flow === 'incoming') { + return { + originSource: PERMISSION_GRAPH_HANDLE.sourceTop, + stackTarget: PERMISSION_GRAPH_HANDLE.targetBottom, + stackSource: PERMISSION_GRAPH_HANDLE.sourceTop, + targetTarget: PERMISSION_GRAPH_HANDLE.targetBottom, + }; + } + + return { + originSource: PERMISSION_GRAPH_HANDLE.sourceBottom, + stackTarget: PERMISSION_GRAPH_HANDLE.targetTop, + stackSource: PERMISSION_GRAPH_HANDLE.sourceBottom, + targetTarget: PERMISSION_GRAPH_HANDLE.targetTop, + }; +}; const getStackPermissions = (node: Node): IPermissionEdgeEntry[] => Array.isArray(node.data?.permissions) @@ -208,6 +252,7 @@ const getEdgeStyle = (active: boolean) => interface IBuildFlowElementsParams { graph: IPermissionGraph; visibleEdges: IPermissionGraphEdge[]; + anchorId: string; selectedEdgeId?: string; selectedNodeId?: string; onSelectEdge: (edgeId: string) => void; @@ -216,6 +261,7 @@ interface IBuildFlowElementsParams { export const buildFlowElements = ({ graph, visibleEdges, + anchorId, selectedEdgeId, selectedNodeId, onSelectEdge, @@ -230,7 +276,8 @@ export const buildFlowElements = ({ const graphNodeById = new Map(graph.nodes.map((node) => [node.id, node])); - const handlePositions = getHandlePositions(); + const graphFlow = getGraphFlow(visibleEdges, anchorId); + const handlePositions = getHandlePositions(graphFlow); const nodes: Node[] = graph.nodes .filter((node) => visibleNodeIds.has(node.id)) .map((node: IPermissionGraphNode) => { @@ -285,7 +332,7 @@ export const buildFlowElements = ({ const stackNodes: Node[] = []; const edges: Edge[] = []; - const edgeHandles = getEdgeHandles(); + const edgeHandles = getEdgeHandles(graphFlow); for (const group of groups.values()) { const active = group.entries.some((entry) => entry.selected === true); @@ -381,6 +428,7 @@ export const buildFlowElements = ({ }; export interface IPermissionsGraphCanvasProps { + anchorId: string; graph: IPermissionGraph; selectedEdgeId?: string; selectedNodeId?: string; @@ -389,6 +437,7 @@ export interface IPermissionsGraphCanvasProps { } export const PermissionsGraphCanvas: React.FC = ({ + anchorId, graph, selectedEdgeId, selectedNodeId, @@ -444,6 +493,7 @@ export const PermissionsGraphCanvas: React.FC = ({ ); const { nodes: nextNodes, edges: nextEdges } = buildFlowElements({ graph, + anchorId, visibleEdges, selectedEdgeId, selectedNodeId, @@ -459,6 +509,7 @@ export const PermissionsGraphCanvas: React.FC = ({ setEdges(nextEdges); }, [ graph, + anchorId, visibleEdges, selectedEdgeId, selectedNodeId, @@ -487,7 +538,7 @@ export const PermissionsGraphCanvas: React.FC = ({ currentNodes, edges, { - direction: getLayoutDirection(), + direction: getLayoutDirection(visibleEdges, anchorId), ...getLayoutSpacing(), }, ); @@ -498,7 +549,16 @@ export const PermissionsGraphCanvas: React.FC = ({ setNodes(layoutedNodes); setEdges(edges); setLayoutVersion((version) => version + 1); - }, [nodesInitialized, nodes, edges, getNodes, setNodes, setEdges]); + }, [ + anchorId, + nodesInitialized, + nodes, + edges, + visibleEdges, + getNodes, + setNodes, + setEdges, + ]); useEffect(() => { if (layoutVersion === 0 || graphBounds.current == null) { diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts index e356d463fe..dd952545ca 100644 --- a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts @@ -1,6 +1,7 @@ import type { IDaoPlugin } from '@/shared/api/daoService'; import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; import { generateFilterComponentPlugin } from '@/shared/testUtils/generators'; +import { ALLOW_FLAG } from '../../constants/permissionSentinels'; import type { IPermissionRow } from '../../types'; import { filterPermissionRows } from './permissionRowFilters'; @@ -160,6 +161,102 @@ describe('filterPermissionRows', () => { expect(result).toEqual([rows[1]]); }); + it('keeps ALLOW_FLAG rows when backend sends a condition entity', () => { + const rows = [ + buildRow({ + conditionAddress: ALLOW_FLAG, + conditionEntity: { + address: ALLOW_FLAG, + label: 'Allow flag', + layer: 'condition', + }, + whereAddress: daoAddress, + }), + ]; + + const result = filterPermissionRows(rows, { + activeAccountAddress: daoAddress, + daoPlugins: [], + showDaoPermissions: true, + showSubpluginPermissions: false, + }); + + expect(result).toEqual(rows); + }); + + it('keeps real condition-contract rows when endpoints are primary entities', () => { + const conditionAddress = '0x6666666666666666666666666666666666666666'; + const rows = [ + buildRow({ + conditionAddress, + conditionEntity: { + address: conditionAddress, + label: 'Condition contract', + layer: 'condition', + status: 'installed', + }, + whereAddress: daoAddress, + }), + ]; + + const result = filterPermissionRows(rows, { + activeAccountAddress: daoAddress, + daoPlugins: [], + showDaoPermissions: true, + showSubpluginPermissions: false, + }); + + expect(result).toEqual(rows); + }); + + it('treats missing condition addresses as unconditional rows', () => { + const rows = [ + buildRow({ + conditionAddress: undefined, + conditionEntity: { + address: ALLOW_FLAG, + label: 'Allow flag', + layer: 'condition', + }, + whereAddress: daoAddress, + }), + ]; + + const result = filterPermissionRows(rows, { + activeAccountAddress: daoAddress, + daoPlugins: [], + showDaoPermissions: true, + showSubpluginPermissions: false, + }); + + expect(result).toEqual(rows); + }); + + it('keeps DAO-granted outgoing rows when DAO permissions are enabled', () => { + const rows = [ + buildRow({ + whoAddress: daoAddress, + where: { + address: pluginAddress, + label: 'Core Governance', + layer: 'topLevelPlugin', + status: 'installed', + }, + whereAddress: pluginAddress, + }), + buildRow({ whereAddress: daoAddress }), + ]; + + const result = filterPermissionRows(rows, { + activeAccountAddress: daoAddress, + daoPlugins: [], + showDaoPermissions: true, + showSubpluginPermissions: false, + }); + + expect(result).toEqual(rows); + }); + it('hides residual rows when subplugin/residual permissions are disabled', () => { const rows = [ buildRow({ whereAddress: daoAddress }), diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts index 09635ed198..bfc139bccd 100644 --- a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts @@ -20,7 +20,7 @@ export interface IPermissionRowFilters { */ showDaoPermissions: boolean; /** - * When false, rows touching a subplugin or not targeting the active DAO are hidden. + * When false, rows touching a supporting entity or disconnected from the active DAO are hidden. */ showSubpluginPermissions: boolean; } @@ -70,7 +70,6 @@ const rowTouchesSupportingPermission = ( ): boolean => isSupportingPermissionLayer(row.who?.layer) || isSupportingPermissionLayer(row.where?.layer) || - row.conditionEntity?.layer === 'condition' || isSubpluginAddress(row.whoAddress, daoPlugins) || isSubpluginAddress(row.whereAddress, daoPlugins); @@ -86,6 +85,7 @@ const isResidualPermission = ( activeAccountAddress?: string, ): boolean => activeAccountAddress != null && + !addressUtils.isAddressEqual(row.whoAddress, activeAccountAddress) && !addressUtils.isAddressEqual(row.whereAddress, activeAccountAddress); export const filterPermissionRows = ( From c4364a5df78d35d6e3ab1bf742fc258d61986d6f Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 28 Jul 2026 16:54:52 +0200 Subject: [PATCH 28/55] Filter inactive permission graph plugins --- .changeset/app-1003-iterate-graph-view.md | 2 +- .../permissionsGraphCanvas.test.ts | 7 ++- .../permissionsGraphCanvas.tsx | 6 +-- .../permissionRowFilters.test.ts | 44 +++++++++++++++++++ .../permissionRowFilters.ts | 16 +++++++ 5 files changed, 65 insertions(+), 10 deletions(-) diff --git a/.changeset/app-1003-iterate-graph-view.md b/.changeset/app-1003-iterate-graph-view.md index f326f34fc0..bc34906a99 100644 --- a/.changeset/app-1003-iterate-graph-view.md +++ b/.changeset/app-1003-iterate-graph-view.md @@ -2,4 +2,4 @@ "@aragon/app": minor --- -Add the DAO permissions graph view backed by real permissions data, including filter handling for enriched condition metadata, DAO-connected plugin edges, and the restored incoming-permission trident layout. +Add the DAO permissions graph view backed by real permissions data, including filter handling for enriched condition metadata, inactive plugin cleanup, DAO-connected plugin edges, restored DAO self-permission placement, and the incoming-permission trident layout. diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts index 6e1b5a1e6e..0f05b7e8ca 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts @@ -105,14 +105,13 @@ describe('buildFlowElements', () => { }); describe('positionSelfStacks', () => { - it('places core DAO self-permission stacks south of the DAO node', () => { + it('places core DAO self-permission stacks above the DAO node', () => { const daoY = 100; - const daoHeight = 92; const daoNode = { id: anchorId, type: 'permission', data: { kind: 'dao' }, - measured: { width: 220, height: daoHeight }, + measured: { width: 220, height: 92 }, position: { x: 100, y: daoY }, } as Node; const stackNode = { @@ -128,7 +127,7 @@ describe('positionSelfStacks', () => { (node) => node.id === stackNode.id, )!; - expect(positionedStack.position.y).toBeGreaterThan(daoY + daoHeight); + expect(positionedStack.position.y).toBeLessThan(daoY); }); it('connects core DAO self-permission stacks to the DAO bottom handle', () => { diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx index 1c8ddf17ef..c5433e8502 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx @@ -204,8 +204,6 @@ export const positionSelfStacks = (nodes: Node[]): Node[] => { const targetRect = getNodeRect(targetNode); const stackRect = getNodeRect(node); - const isCoreDaoSelfStack = targetNode.data?.kind === 'dao'; - return { ...node, position: { @@ -213,9 +211,7 @@ export const positionSelfStacks = (nodes: Node[]): Node[] => { targetNode.position.x + targetRect.width / 2 - stackRect.width / 2, - y: isCoreDaoSelfStack - ? targetNode.position.y + targetRect.height + SELF_STACK_GAP - : targetNode.position.y - stackRect.height - SELF_STACK_GAP, + y: targetNode.position.y - stackRect.height - SELF_STACK_GAP, }, }; }); diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts index dd952545ca..d230fef1ea 100644 --- a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.test.ts @@ -257,6 +257,50 @@ describe('filterPermissionRows', () => { expect(result).toEqual(rows); }); + it('hides inactive plugin endpoint rows even when supporting permissions are enabled', () => { + const rows = [ + buildRow({ + whereAddress: daoAddress, + who: { + address: pluginAddress, + label: 'Historical Core Governance DEPRECATED', + layer: 'historicalPlugin', + status: 'uninstalled', + }, + whoAddress: pluginAddress, + }), + buildRow({ + whereAddress: daoAddress, + who: { + address: parentPluginAddress, + label: 'Unknown status plugin', + layer: 'topLevelPlugin', + status: 'unknown', + }, + whoAddress: parentPluginAddress, + }), + buildRow({ + whereAddress: daoAddress, + who: { + address: targetAddress, + label: 'Core Governance', + layer: 'topLevelPlugin', + status: 'installed', + }, + whoAddress: targetAddress, + }), + ]; + + const result = filterPermissionRows(rows, { + activeAccountAddress: daoAddress, + daoPlugins: [], + showDaoPermissions: true, + showSubpluginPermissions: true, + }); + + expect(result).toEqual([rows[1], rows[2]]); + }); + it('hides residual rows when subplugin/residual permissions are disabled', () => { const rows = [ buildRow({ whereAddress: daoAddress }), diff --git a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts index bfc139bccd..26a7b30bad 100644 --- a/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts +++ b/apps/app/src/modules/settings/utils/permissionRowFilters/permissionRowFilters.ts @@ -37,6 +37,18 @@ const SUPPORTING_PERMISSION_LAYERS = new Set([ const isSupportingPermissionLayer = (layer?: PermissionEntityLayer): boolean => layer != null && SUPPORTING_PERMISSION_LAYERS.has(layer); +const INACTIVE_PLUGIN_STATUSES = new Set(['uninstalled', 'historical']); + +const isInactivePluginEndpoint = (row: IPermissionRow): boolean => { + const endpointEntities = [row.who, row.where]; + + return endpointEntities.some( + (entity) => + entity?.status != null && + INACTIVE_PLUGIN_STATUSES.has(entity.status), + ); +}; + const isSubplugin = (plugin: IFilterComponentPlugin): boolean => { const { meta } = plugin; @@ -100,6 +112,10 @@ export const filterPermissionRows = ( } = filters; return rows.filter((row) => { + if (isInactivePluginEndpoint(row)) { + return false; + } + if ( !showDaoPermissions && isDaoGrantedPermission(row, activeAccountAddress) From 624681cc331f98ddf3356bd1a991f72431ee2a6a Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 28 Jul 2026 17:19:51 +0200 Subject: [PATCH 29/55] Refine permission graph routing --- .changeset/app-1003-iterate-graph-view.md | 2 +- .../permissionGraphEdge.test.ts | 34 +++++ .../permissionsGraph/permissionGraphEdge.tsx | 64 +++++++-- .../permissionsGraphCanvas.test.ts | 117 +++++++++++++++- .../permissionsGraphCanvas.tsx | 130 ++++++++++++++++-- 5 files changed, 317 insertions(+), 30 deletions(-) create mode 100644 apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.test.ts diff --git a/.changeset/app-1003-iterate-graph-view.md b/.changeset/app-1003-iterate-graph-view.md index bc34906a99..d6131fb571 100644 --- a/.changeset/app-1003-iterate-graph-view.md +++ b/.changeset/app-1003-iterate-graph-view.md @@ -2,4 +2,4 @@ "@aragon/app": minor --- -Add the DAO permissions graph view backed by real permissions data, including filter handling for enriched condition metadata, inactive plugin cleanup, DAO-connected plugin edges, restored DAO self-permission placement, and the incoming-permission trident layout. +Add the DAO permissions graph view backed by real permissions data, including filter handling for enriched condition metadata, inactive plugin cleanup, DAO-connected plugin edges, restored DAO self-permission placement, the incoming-permission trident layout, and adaptive graph routing/fit behavior. diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.test.ts b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.test.ts new file mode 100644 index 0000000000..e5c4131ce3 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.test.ts @@ -0,0 +1,34 @@ +import { getStraightPath, Position } from '@xyflow/react'; +import { getPermissionEdgePath } from './permissionGraphEdge'; + +describe('getPermissionEdgePath', () => { + const coordinates = { + sourceX: 0, + sourceY: 0, + targetX: 300, + targetY: 120, + }; + + it('keeps incoming permission tridents orthogonal without curve segments', () => { + const path = getPermissionEdgePath({ + ...coordinates, + sourcePosition: Position.Top, + targetPosition: Position.Bottom, + visualKind: 'incoming', + }); + + expect(path).toMatch(/^M/); + expect(path).not.toContain('Q'); + }); + + it('uses the direct shortest path for supporting and mixed graph edges', () => { + const [straightPath] = getStraightPath(coordinates); + + expect( + getPermissionEdgePath({ + ...coordinates, + visualKind: 'other', + }), + ).toBe(straightPath); + }); +}); diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx index 2008733125..0eb5e5e685 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx @@ -4,6 +4,7 @@ import { type EdgeProps, getSmoothStepPath, getStraightPath, + type Position, } from '@xyflow/react'; export interface IPermissionEdgeEntry { @@ -29,31 +30,68 @@ export interface IPermissionEdgeData { export type IPermissionFlowEdge = Edge; -export const PermissionGraphEdge: React.FC> = ({ +interface IGetPermissionEdgePathParams { + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + sourcePosition?: Position; + targetPosition?: Position; + visualKind: PermissionEdgeVisualKind; +} +const DEGENERATE_CURVE_REGEX = /Q (-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?) \1,\2/g; + +const removeDegenerateCurves = (path: string): string => + path.replaceAll(DEGENERATE_CURVE_REGEX, ''); + +export const getPermissionEdgePath = ({ sourceX, sourceY, targetX, targetY, sourcePosition, targetPosition, + visualKind, +}: IGetPermissionEdgePathParams): string => { + if (visualKind === 'incoming') { + const [path] = getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + borderRadius: 0, + }); + + return removeDegenerateCurves(path); + } + + return getStraightPath({ sourceX, sourceY, targetX, targetY })[0]; +}; + +export const PermissionGraphEdge: React.FC> = ({ + sourceX, + sourceY, + targetX, + targetY, markerStart, markerEnd, style, + sourcePosition, + targetPosition, data, }) => { const visualKind = data?.visualKind ?? 'other'; - const [edgePath] = - visualKind === 'self' - ? getStraightPath({ sourceX, sourceY, targetX, targetY }) - : getSmoothStepPath({ - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - borderRadius: 16, - }); + const edgePath = getPermissionEdgePath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + visualKind, + }); return ( { }); }); +describe('getFitViewMinZoom', () => { + it('uses the readable zoom for compact graphs', () => { + expect( + getFitViewMinZoom( + { width: 800, height: 400 }, + { width: 1200, height: 640 }, + ), + ).toBe(0.45); + }); + + it('allows full fit zoom for wide supporting graphs', () => { + expect( + getFitViewMinZoom( + { width: 5000, height: 800 }, + { width: 1200, height: 640 }, + ), + ).toBe(0.2); + }); +}); + describe('buildFlowElements', () => { it('uses incoming handles when only plugin-to-DAO rows are visible', () => { const incomingEdge = buildEdge('incoming', { @@ -130,7 +152,7 @@ describe('positionSelfStacks', () => { expect(positionedStack.position.y).toBeLessThan(daoY); }); - it('connects core DAO self-permission stacks to the DAO bottom handle', () => { + it('connects core DAO self-permission stacks from stack bottom to DAO top', () => { const graph: IPermissionGraph = { nodes: [ { @@ -156,6 +178,97 @@ describe('positionSelfStacks', () => { }); expect(result.edges[0]).toMatchObject({ + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceBottom, + targetHandle: PERMISSION_GRAPH_HANDLE.targetTop, + }); + }); +}); + +describe('alignEdgesWithNodePositions', () => { + const buildFlowNode = ( + id: string, + position: Node['position'], + measured: NonNullable, + ): Node => ({ + id, + data: {}, + measured, + position, + }); + + it('uses side handles for horizontal edges', () => { + const edges: Edge[] = [ + { + id: 'edge', + source: 'source', + target: 'target', + }, + ]; + const nodes = [ + buildFlowNode('source', { x: 0, y: 0 }, { width: 100, height: 80 }), + buildFlowNode( + 'target', + { x: 300, y: 0 }, + { width: 100, height: 80 }, + ), + ]; + + const result = alignEdgesWithNodePositions(nodes, edges); + + expect(result[0]).toMatchObject({ + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceRight, + targetHandle: PERMISSION_GRAPH_HANDLE.targetLeft, + }); + }); + + it('uses vertical handles when the target is below the source', () => { + const edges: Edge[] = [ + { + id: 'edge', + source: 'source', + target: 'target', + }, + ]; + const nodes = [ + buildFlowNode('source', { x: 0, y: 0 }, { width: 100, height: 40 }), + buildFlowNode( + 'target', + { x: 0, y: 200 }, + { width: 100, height: 80 }, + ), + ]; + + const result = alignEdgesWithNodePositions(nodes, edges); + + expect(result[0]).toMatchObject({ + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceBottom, + targetHandle: PERMISSION_GRAPH_HANDLE.targetTop, + }); + }); + + it('preserves incoming trident handles during post-layout alignment', () => { + const edges: Edge[] = [ + { + id: 'incoming', + source: 'source', + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceTop, + target: 'target', + targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, + data: { visualKind: 'incoming' }, + }, + ]; + const nodes = [ + buildFlowNode('source', { x: 0, y: 0 }, { width: 100, height: 80 }), + buildFlowNode( + 'target', + { x: 300, y: 0 }, + { width: 100, height: 80 }, + ), + ]; + + const result = alignEdgesWithNodePositions(nodes, edges); + + expect(result[0]).toMatchObject({ sourceHandle: PERMISSION_GRAPH_HANDLE.sourceTop, targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, }); diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx index c5433e8502..29bf7b5f15 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx @@ -167,6 +167,75 @@ const getNodeRect = (node: Node) => { }; }; +const getNodeCenter = (node: Node): { x: number; y: number } => { + const rect = getNodeRect(node); + + return { + x: rect.x + rect.width / 2, + y: rect.y + rect.height / 2, + }; +}; + +const getFacingHandles = ( + sourceNode: Node, + targetNode: Node, +): { + sourceHandle: string; + targetHandle: string; +} => { + const sourceCenter = getNodeCenter(sourceNode); + const targetCenter = getNodeCenter(targetNode); + const deltaX = targetCenter.x - sourceCenter.x; + const deltaY = targetCenter.y - sourceCenter.y; + + if (Math.abs(deltaX) > Math.abs(deltaY)) { + return deltaX > 0 + ? { + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceRight, + targetHandle: PERMISSION_GRAPH_HANDLE.targetLeft, + } + : { + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceLeft, + targetHandle: PERMISSION_GRAPH_HANDLE.targetRight, + }; + } + + return deltaY > 0 + ? { + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceBottom, + targetHandle: PERMISSION_GRAPH_HANDLE.targetTop, + } + : { + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceTop, + targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, + }; +}; + +export const alignEdgesWithNodePositions = ( + nodes: Node[], + edges: Edge[], +): Edge[] => { + const nodeById = new Map(nodes.map((node) => [node.id, node])); + + return edges.map((edge) => { + if (edge.data?.visualKind === 'incoming') { + return edge; + } + + const sourceNode = nodeById.get(edge.source); + const targetNode = nodeById.get(edge.target); + + if (sourceNode == null || targetNode == null) { + return edge; + } + + return { + ...edge, + ...getFacingHandles(sourceNode, targetNode), + }; + }); +}; + const getGraphBounds = (nodes: Node[]) => { const rects = nodes.map(getNodeRect); @@ -183,6 +252,24 @@ const getGraphBounds = (nodes: Node[]) => { }; }; +interface IFitViewRect { + width: number; + height: number; +} + +export const getFitViewMinZoom = ( + bounds: IFitViewRect, + container: IFitViewRect, +): number => { + const widthFitZoom = container.width / bounds.width; + const heightFitZoom = container.height / bounds.height; + const requiredFitZoom = Math.min(widthFitZoom, heightFitZoom); + + return requiredFitZoom < READABLE_FIT_MIN_ZOOM + ? MIN_ZOOM + : READABLE_FIT_MIN_ZOOM; +}; + export const positionSelfStacks = (nodes: Node[]): Node[] => { const nodeById = new Map(nodes.map((node) => [node.id, node])); @@ -230,7 +317,22 @@ const edgeActiveStyle = { const getEdgeVisualKind = ( source: string, target: string, -): PermissionEdgeVisualKind => (source === target ? 'self' : 'other'); + anchorId: string, +): PermissionEdgeVisualKind => { + if (source === target) { + return 'self'; + } + + if (target === anchorId) { + return 'incoming'; + } + + if (source === anchorId) { + return 'outgoing'; + } + + return 'other'; +}; const getOriginMarker = (active: boolean) => active ? EDGE_ORIGIN_MARKER_ACTIVE : EDGE_ORIGIN_MARKER_NEUTRAL; @@ -270,8 +372,6 @@ export const buildFlowElements = ({ ? visibleEdges.find((edge) => edge.id === selectedEdgeId) : undefined; - const graphNodeById = new Map(graph.nodes.map((node) => [node.id, node])); - const graphFlow = getGraphFlow(visibleEdges, anchorId); const handlePositions = getHandlePositions(graphFlow); const nodes: Node[] = graph.nodes @@ -339,11 +439,13 @@ export const buildFlowElements = ({ const dimmed = (selectedEdge != null && !active) || (selectedNodeId != null && !isConnectedToSelectedNode); - const visualKind = getEdgeVisualKind(group.source, group.target); + const visualKind = getEdgeVisualKind( + group.source, + group.target, + anchorId, + ); const stackId = `permission-stack-${pairKey(group.source, group.target)}`; const isSelfEdge = visualKind === 'self'; - const isCoreDaoSelfEdge = - isSelfEdge && graphNodeById.get(group.target)?.kind === 'dao'; const edgeData = { visualKind, ...(isSelfEdge ? { selfTargetId: group.target } : {}), @@ -370,13 +472,9 @@ export const buildFlowElements = ({ edges.push({ id: `${stackId}-self`, source: stackId, - sourceHandle: isCoreDaoSelfEdge - ? PERMISSION_GRAPH_HANDLE.sourceTop - : PERMISSION_GRAPH_HANDLE.sourceBottom, + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceBottom, target: group.target, - targetHandle: isCoreDaoSelfEdge - ? PERMISSION_GRAPH_HANDLE.targetBottom - : PERMISSION_GRAPH_HANDLE.targetTop, + targetHandle: PERMISSION_GRAPH_HANDLE.targetTop, type: 'permission', animated: active, markerEnd: getEdgeMarker(active), @@ -474,7 +572,10 @@ export const PermissionsGraphCanvas: React.FC = ({ bounds, container.clientWidth, container.clientHeight, - READABLE_FIT_MIN_ZOOM, + getFitViewMinZoom(bounds, { + width: container.clientWidth, + height: container.clientHeight, + }), MAX_ZOOM, FIT_PADDING, ); @@ -539,11 +640,12 @@ export const PermissionsGraphCanvas: React.FC = ({ }, ); const layoutedNodes = positionSelfStacks(rawLayoutedNodes); + const alignedEdges = alignEdgesWithNodePositions(layoutedNodes, edges); layoutSignature.current = topologySignature; graphBounds.current = getGraphBounds(layoutedNodes); setNodes(layoutedNodes); - setEdges(edges); + setEdges(alignedEdges); setLayoutVersion((version) => version + 1); }, [ anchorId, From 4333ac579b3446b6fda47a536d11a200fa7a565b Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Wed, 29 Jul 2026 19:12:57 +0200 Subject: [PATCH 30/55] Fix permissions graph routing feedback --- apps/app/src/assets/locales/en.json | 18 +- .../permissionDetailPanel.tsx | 233 +++++----- .../permissionGraphEdge.test.ts | 40 +- .../permissionsGraph/permissionGraphEdge.tsx | 39 +- .../permissionGraphNode.test.tsx | 37 ++ .../permissionsGraph/permissionGraphNode.tsx | 38 +- .../permissionNodeDetailPanel.tsx | 66 +-- .../permissionsGraphCanvas.test.ts | 421 +++++++++++++++++- .../permissionsGraphCanvas.tsx | 321 ++++++++++--- .../permissionsList/permissionsList.test.tsx | 130 +++++- .../permissionsList/permissionsList.tsx | 95 +++- .../daoPermissionsPageClient.test.tsx | 130 ++++-- .../daoPermissionsPageClient.tsx | 65 +-- .../modules/settings/types/permissionGraph.ts | 1 + .../buildPermissionGraph.test.ts | 52 +++ .../buildPermissionGraph.ts | 21 +- .../permissionEntityUtils.test.ts | 104 +++++ .../permissionEntityUtils.ts | 66 ++- .../permissionGraphLayout.ts | 11 +- .../permissionRowFilters.test.ts | 101 +++++ .../permissionRowFilters.ts | 62 ++- .../api/daoService/domain/daoPermission.ts | 11 + 22 files changed, 1716 insertions(+), 346 deletions(-) diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index 3bbc2c658f..5c1dc2344c 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -3560,12 +3560,12 @@ "graph": "Graph" }, "filters": { - "showDaoPermissions": "Show DAO-granted permissions", - "showDaoPermissionsTooltip": "Permissions where the selected DAO appears under Who, meaning it can call another contract.", - "showDaoPermissionsTooltipLabel": "About DAO-granted permissions", - "showSubpluginPermissions": "Show supporting permissions", - "showSubpluginPermissionsTooltip": "Includes process internals, condition contracts, external actors, and other permission rows that support the main governance view.", - "showSubpluginPermissionsTooltipLabel": "About supporting permissions" + "hideDaoPermissions": "Hide permissions granted to DAO", + "hideDaoPermissionsTooltip": "Hides permissions where the selected DAO appears under Who, including DAO-managed internal contracts such as clocks.", + "hideDaoPermissionsTooltipLabel": "About permissions granted to DAO", + "hideGoverningBodyPermissions": "Hide permissions on governing bodies", + "hideGoverningBodyPermissionsTooltip": "Hides permissions to or from installed governing bodies and rows not connected to the selected DAO.", + "hideGoverningBodyPermissionsTooltipLabel": "About governing body permissions" }, "graphView": { "node": { @@ -3584,7 +3584,11 @@ "detail": { "address": "Address", "close": "Close", - "type": "Type" + "type": "Type", + "anyone": { + "title": "Open to anyone", + "description": "Any wallet or contract can act here — these permission flows have no on-chain requirement for who calls them." + } }, "empty": { "heading": "No permissions", diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionDetailPanel.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionDetailPanel.tsx index 0c6488ae5d..1eaf5195e4 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionDetailPanel.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionDetailPanel.tsx @@ -14,7 +14,11 @@ import { PluginSingleComponent } from '@/shared/components/pluginSingleComponent import { useTranslations } from '@/shared/components/translationsProvider'; import { SettingsSlotId } from '../../constants/moduleSlots'; import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; -import type { IPermissionGraph, IPermissionGraphEdge } from '../../types'; +import type { + IPermissionGraph, + IPermissionGraphEdge, + IPermissionRow, +} from '../../types'; import { conditionTypeUtils, UNKNOWN_CONDITION, @@ -30,18 +34,30 @@ export interface IPermissionDetailPanelProps { onClose: () => void; } -export const PermissionDetailPanel: React.FC = ({ - chainId, - edge, - network, - nodes, - onClose, -}) => { +interface IPermissionDetailEntity { + address: string; + label?: string; +} + +export interface IPermissionDetailContentProps { + chainId?: number; + className?: string; + network?: IDao['network']; + permissionName: string; + row: IPermissionRow; + who?: IPermissionDetailEntity; + where?: IPermissionDetailEntity; +} + +type PermissionDetailsTab = 'permission' | 'condition'; + +export const PermissionDetailContent: React.FC< + IPermissionDetailContentProps +> = ({ chainId, className, network, permissionName, row, who, where }) => { const { t } = useTranslations(); - const { row } = edge; + const [activeTab, setActiveTab] = + useState('permission'); const conditionAddress = row.conditionAddress ?? ALLOW_FLAG; - const who = nodes.find((node) => node.id === edge.source); - const where = nodes.find((node) => node.id === edge.target); const hasCondition = !addressUtils.isAddressEqual( conditionAddress, ALLOW_FLAG, @@ -60,15 +76,109 @@ export const PermissionDetailPanel: React.FC = ({ row.whereAddress, ANY_ADDR, ); + + const handleTabChange = (value?: string | string[]) => { + if (value === 'permission' || value === 'condition') { + setActiveTab(value); + } + }; + + return ( +
+
+

+ {t('app.settings.permissionsList.details.heading')} +

+ + + + +
+ {activeTab === 'permission' ? ( + + + {isWhoAnyAddress + ? who?.label + : addressUtils.truncateAddress(row.whoAddress)} + + + {isWhereAnyAddress + ? where?.label + : addressUtils.truncateAddress(row.whereAddress)} + + + {addressUtils.truncateHash(row.permissionId)} + + + ) : hasUnrecognizedCondition ? ( + + ) : ( + + )} +
+ ); +}; + +export const PermissionDetailPanel: React.FC = ({ + chainId, + edge, + network, + nodes, + onClose, +}) => { + const { t } = useTranslations(); + const { row } = edge; + const who = nodes.find((node) => node.id === edge.source); + const where = nodes.find((node) => node.id === edge.target); const panelRef = useRef(null); const dragOffsetRef = useRef<{ x: number; y: number } | undefined>( undefined, ); const [position, setPosition] = useState({ x: 16, y: 16 }); const [isDragging, setIsDragging] = useState(false); - const [activeTab, setActiveTab] = useState<'permission' | 'condition'>( - 'permission', - ); const clampPosition = (next: { x: number; y: number }) => { const panel = panelRef.current; @@ -139,12 +249,6 @@ export const PermissionDetailPanel: React.FC = ({ } }; - const handleTabChange = (value?: string | string[]) => { - if (value === 'permission' || value === 'condition') { - setActiveTab(value); - } - }; - return (
= ({ />
-
-
-

- {t('app.settings.permissionsList.details.heading')} -

- - - - -
- {activeTab === 'permission' ? ( - - - {isWhoAnyAddress - ? who?.label - : addressUtils.truncateAddress(row.whoAddress)} - - - {isWhereAnyAddress - ? where?.label - : addressUtils.truncateAddress( - row.whereAddress, - )} - - - {addressUtils.truncateHash(row.permissionId)} - - - ) : hasUnrecognizedCondition ? ( - - ) : ( - - )} -
+
); }; diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.test.ts b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.test.ts index e5c4131ce3..31fe80199b 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.test.ts +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.test.ts @@ -1,4 +1,4 @@ -import { getStraightPath, Position } from '@xyflow/react'; +import { getSmoothStepPath, getStraightPath, Position } from '@xyflow/react'; import { getPermissionEdgePath } from './permissionGraphEdge'; describe('getPermissionEdgePath', () => { @@ -9,19 +9,45 @@ describe('getPermissionEdgePath', () => { targetY: 120, }; - it('keeps incoming permission tridents orthogonal without curve segments', () => { - const path = getPermissionEdgePath({ + it('keeps incoming permission tridents on the curved smooth-step path', () => { + const [smoothStepPath] = getSmoothStepPath({ ...coordinates, sourcePosition: Position.Top, targetPosition: Position.Bottom, - visualKind: 'incoming', + borderRadius: 12, + offset: 28, }); - expect(path).toMatch(/^M/); - expect(path).not.toContain('Q'); + expect( + getPermissionEdgePath({ + ...coordinates, + sourcePosition: Position.Top, + targetPosition: Position.Bottom, + visualKind: 'incoming', + }), + ).toBe(smoothStepPath); + }); + + it('uses curved side-aware paths for supporting and mixed graph edges', () => { + const [smoothStepPath] = getSmoothStepPath({ + ...coordinates, + sourcePosition: Position.Right, + targetPosition: Position.Left, + borderRadius: 12, + offset: 28, + }); + + expect( + getPermissionEdgePath({ + ...coordinates, + sourcePosition: Position.Right, + targetPosition: Position.Left, + visualKind: 'other', + }), + ).toBe(smoothStepPath); }); - it('uses the direct shortest path for supporting and mixed graph edges', () => { + it('uses the direct path when handle positions are unavailable', () => { const [straightPath] = getStraightPath(coordinates); expect( diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx index 0eb5e5e685..a0b932676a 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx @@ -7,6 +7,9 @@ import { type Position, } from '@xyflow/react'; +const EDGE_PATH_BORDER_RADIUS = 12; +const EDGE_PATH_OFFSET = 28; + export interface IPermissionEdgeEntry { edgeId: string; permissionDisplayName: string; @@ -20,11 +23,17 @@ export type PermissionEdgeVisualKind = | 'incoming' | 'outgoing' | 'other'; +export type PermissionStackConnection = 'origin' | 'target'; export interface IPermissionEdgeData { excludeFromLayout?: boolean; selfTargetId?: string; + lockHandles?: boolean; + layoutSource?: string; + layoutTarget?: string; visualKind: PermissionEdgeVisualKind; + permissionStackId?: string; + stackConnection?: PermissionStackConnection; [key: string]: unknown; } @@ -39,10 +48,6 @@ interface IGetPermissionEdgePathParams { targetPosition?: Position; visualKind: PermissionEdgeVisualKind; } -const DEGENERATE_CURVE_REGEX = /Q (-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?) \1,\2/g; - -const removeDegenerateCurves = (path: string): string => - path.replaceAll(DEGENERATE_CURVE_REGEX, ''); export const getPermissionEdgePath = ({ sourceX, @@ -51,23 +56,21 @@ export const getPermissionEdgePath = ({ targetY, sourcePosition, targetPosition, - visualKind, }: IGetPermissionEdgePathParams): string => { - if (visualKind === 'incoming') { - const [path] = getSmoothStepPath({ - sourceX, - sourceY, - targetX, - targetY, - sourcePosition, - targetPosition, - borderRadius: 0, - }); - - return removeDegenerateCurves(path); + if (sourcePosition == null || targetPosition == null) { + return getStraightPath({ sourceX, sourceY, targetX, targetY })[0]; } - return getStraightPath({ sourceX, sourceY, targetX, targetY })[0]; + return getSmoothStepPath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + borderRadius: EDGE_PATH_BORDER_RADIUS, + offset: EDGE_PATH_OFFSET, + })[0]; }; export const PermissionGraphEdge: React.FC> = ({ diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.test.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.test.tsx index 389b699ec6..d49e0b9613 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.test.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.test.tsx @@ -80,6 +80,35 @@ describe(' component', () => { ), ).toBeInTheDocument(); }); + + it('renders internal process bodies as plugin cards with their type tag', () => { + renderGraphNode({ + layer: 'processInternal', + label: 'Token Voting', + tag: 'TOKENVOTING', + }); + + expect(screen.getByText('Token Voting')).toBeInTheDocument(); + expect(screen.getByText('TOKENVOTING')).toBeInTheDocument(); + expect( + screen.getByText( + 'app.settings.daoPermissionsPage.graphView.node.plugin', + ), + ).toBeInTheDocument(); + }); + + it('renders Safe-branded process bodies with the Safe avatar instead of a redundant tag', () => { + const { container } = renderGraphNode({ + brandId: 'safe', + layer: 'processInternal', + label: 'Safe', + tag: 'SAFE', + }); + + expect(screen.getByText('Safe')).toBeInTheDocument(); + expect(screen.queryByText('SAFE')).not.toBeInTheDocument(); + expect(container.textContent).not.toContain('SAFE'); + }); }); describe(' component', () => { @@ -93,4 +122,12 @@ describe(' component', () => { expect(visibleLabels).toEqual(['Execute']); expect(button).toHaveAttribute('title', 'EXECUTE_PERMISSION'); }); + + it('keeps the routed stack node box fitted to the visible permission pills', () => { + const { container } = renderStackNode(); + const stackNode = container.firstElementChild; + + expect(stackNode).toHaveClass('w-fit'); + expect(stackNode).not.toHaveClass('w-60'); + }); }); diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx index fd9aea4037..446f0ea04f 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx @@ -1,6 +1,7 @@ import { Avatar, DaoAvatar, Tag } from '@aragon/gov-ui-kit'; import { Handle, type Node, type NodeProps, Position } from '@xyflow/react'; import classNames from 'classnames'; +import safeWallet from '@/assets/images/safeWallet.png'; import { useTranslations } from '@/shared/components/translationsProvider'; import type { IPermissionGraphNode, PermissionNodeKind } from '../../types'; import type { IPermissionEdgeEntry } from './permissionGraphEdge'; @@ -19,6 +20,9 @@ export interface IPermissionNodeData extends IPermissionGraphNode { export type IPermissionFlowNode = Node; export interface IPermissionStackNodeData { + sourceId?: string; + targetId?: string; + visualKind?: PermissionNodeKind | string; permissions: IPermissionEdgeEntry[]; active?: boolean; dimmed?: boolean; @@ -93,7 +97,14 @@ const SELECTION_LABEL_KEY: Record = { where: 'app.settings.daoPermissionsPage.graphView.node.where', }; -const getSubtitleKey = (data: IPermissionNodeData): string => { +type PermissionNodeTypeInput = Pick< + IPermissionGraphNode, + 'kind' | 'layer' | 'status' +>; + +export const getPermissionNodeTypeKey = ( + data: PermissionNodeTypeInput, +): string => { if (data.kind === 'plugin') { if (data.status === 'uninstalled') { return 'app.settings.daoPermissionsPage.graphView.node.uninstalledPlugin'; @@ -111,10 +122,20 @@ export const PermissionGraphNode: React.FC> = ({ data, }) => { const { t } = useTranslations(); - const { kind, label, tag, avatarSrc, selectionRole, active, dimmed } = data; + const { + kind, + label, + tag, + avatarSrc, + brandId, + selectionRole, + active, + dimmed, + } = data; const isDaoKind = kind === 'dao' || kind === 'linkedDao'; + const isSafeBody = kind === 'plugin' && brandId === 'safe'; const isSelected = selectionRole != null || active === true; - const subtitleKey = getSubtitleKey(data); + const subtitleKey = getPermissionNodeTypeKey(data); return (
> = ({ src={avatarSrc ?? undefined} /> )} - {kind === 'plugin' && tag != null && ( + {isSafeBody && ( + + )} + {kind === 'plugin' && !isSafeBody && tag != null && ( )} {kind === 'actor' && } @@ -170,7 +198,7 @@ export const PermissionStackNode: React.FC< return (
diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionNodeDetailPanel.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionNodeDetailPanel.tsx index 81228cc2fa..8f5a4545a5 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionNodeDetailPanel.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionNodeDetailPanel.tsx @@ -1,6 +1,7 @@ 'use client'; import { + AlertCard, addressUtils, Button, ChainEntityType, @@ -13,14 +14,8 @@ import { import { useRef, useState } from 'react'; import { useTranslations } from '@/shared/components/translationsProvider'; import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; -import type { IPermissionGraphNode, PermissionNodeKind } from '../../types'; - -const NODE_TYPE_KEY: Record = { - dao: 'app.settings.daoPermissionsPage.graphView.node.dao', - linkedDao: 'app.settings.daoPermissionsPage.graphView.node.linkedDao', - plugin: 'app.settings.daoPermissionsPage.graphView.node.plugin', - actor: 'app.settings.daoPermissionsPage.graphView.node.actor', -}; +import type { IPermissionGraphNode } from '../../types'; +import { getPermissionNodeTypeKey } from './permissionGraphNode'; export interface IPermissionNodeDetailPanelProps { chainId?: number; @@ -43,6 +38,10 @@ export const PermissionNodeDetailPanel: React.FC< const isSentinelAddress = addressUtils.isAddressEqual(node.address, ANY_ADDR) || addressUtils.isAddressEqual(node.address, ALLOW_FLAG); + const isAnyoneSentinel = addressUtils.isAddressEqual( + node.address, + ANY_ADDR, + ); const explorerUrl = isSentinelAddress ? undefined @@ -143,7 +142,7 @@ export const PermissionNodeDetailPanel: React.FC< )}

- {t(NODE_TYPE_KEY[node.kind])} + {t(getPermissionNodeTypeKey(node))}

event.stopPropagation()}> @@ -159,31 +158,44 @@ export const PermissionNodeDetailPanel: React.FC<
- - - {t(NODE_TYPE_KEY[node.kind])} - - {!isSentinelAddress && ( + {t( + 'app.settings.daoPermissionsPage.graphView.detail.anyone.description', + )} + + ) : ( + - - {addressUtils.truncateAddress(node.address)} - + {t(getPermissionNodeTypeKey(node))} - )} - + {!isSentinelAddress && ( + + + {addressUtils.truncateAddress(node.address)} + + + )} + + )}
); diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts index d7ce8f131d..0d474ef05f 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts @@ -1,15 +1,27 @@ import type { Edge, Node } from '@xyflow/react'; -import type { IPermissionGraph, IPermissionGraphEdge } from '../../types'; +import type { + IPermissionGraph, + IPermissionGraphEdge, + IPermissionGraphNode, +} from '../../types'; +import { getLayoutedElements } from '../../utils/permissionGraphLayout'; import { PERMISSION_GRAPH_HANDLE } from './permissionGraphNode'; import { alignEdgesWithNodePositions, buildFlowElements, getFitViewMinZoom, getLayoutDirection, + getLayoutSignature, getVisibleEdges, positionSelfStacks, } from './permissionsGraphCanvas'; +if (globalThis.structuredClone == null) { + Object.defineProperty(globalThis, 'structuredClone', { + value: (value: T): T => JSON.parse(JSON.stringify(value)) as T, + }); +} + const anchorId = '0x1111111111111111111111111111111111111111'; const pluginId = '0x2222222222222222222222222222222222222222'; const externalId = '0x3333333333333333333333333333333333333333'; @@ -17,7 +29,13 @@ const otherId = '0x4444444444444444444444444444444444444444'; const buildEdge = ( id: string, - partial: Pick, + partial: Pick & + Partial< + Pick< + IPermissionGraphEdge, + 'permissionDisplayName' | 'permissionName' + > + >, ): IPermissionGraphEdge => ({ id, permissionDisplayName: 'Permission', @@ -31,8 +49,11 @@ const buildEdge = ( ...partial, }); -const buildGraph = (edges: IPermissionGraphEdge[]): IPermissionGraph => ({ - nodes: [], +const buildGraph = ( + edges: IPermissionGraphEdge[], + nodes: IPermissionGraphNode[] = [], +): IPermissionGraph => ({ + nodes, edges, }); @@ -77,6 +98,48 @@ describe('getLayoutDirection', () => { expect(result).toBe('TB'); }); + + it('keeps active-contract execute views on the stable top-to-bottom layout direction', () => { + const result = getLayoutDirection( + [ + buildEdge('execute', { + source: pluginId, + target: anchorId, + permissionName: 'EXECUTE_PERMISSION', + permissionDisplayName: 'Execute', + }), + ], + pluginId, + ); + + expect(result).toBe('TB'); + }); +}); + +describe('getLayoutSignature', () => { + it('changes when React Flow replaces fallback dimensions with measured node sizes', () => { + const edges: Edge[] = [ + { id: 'edge', source: 'source', target: 'target' }, + ]; + const fallbackNodes = [ + { id: 'source', data: {}, position: { x: 0, y: 0 } }, + { id: 'target', data: {}, position: { x: 0, y: 0 } }, + ] as Node[]; + const measuredNodes = [ + { + ...fallbackNodes[0], + measured: { width: 240, height: 92 }, + }, + { + ...fallbackNodes[1], + measured: { width: 320, height: 120 }, + }, + ] as Node[]; + + expect(getLayoutSignature(fallbackNodes, edges)).not.toBe( + getLayoutSignature(measuredNodes, edges), + ); + }); }); describe('getFitViewMinZoom', () => { @@ -124,6 +187,185 @@ describe('buildFlowElements', () => { targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, }); }); + + it('keeps plugin-to-DAO edges on incoming handles in mixed graphs', () => { + const incomingEdge = buildEdge('incoming', { + source: pluginId, + target: anchorId, + }); + const outgoingEdge = buildEdge('outgoing', { + source: anchorId, + target: pluginId, + }); + const { edges } = buildFlowElements({ + anchorId, + graph: buildGraph([incomingEdge, outgoingEdge]), + onSelectEdge: jest.fn(), + visibleEdges: [incomingEdge, outgoingEdge], + }); + + const incomingOriginEdge = edges.find( + (edge) => + edge.id === `permission-stack-${pluginId}-${anchorId}-origin`, + ); + const incomingTargetEdge = edges.find( + (edge) => + edge.id === `permission-stack-${pluginId}-${anchorId}-target`, + ); + + expect(incomingOriginEdge).toMatchObject({ + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceTop, + targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, + }); + expect(incomingTargetEdge).toMatchObject({ + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceTop, + targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, + }); + }); + + it('keeps execute permissions on bottom-to-top handles even when the active contract is the who', () => { + const executeEdge = buildEdge('execute', { + source: pluginId, + target: anchorId, + permissionName: 'EXECUTE_PERMISSION', + permissionDisplayName: 'Execute', + }); + const { edges } = buildFlowElements({ + anchorId: pluginId, + graph: buildGraph([executeEdge]), + onSelectEdge: jest.fn(), + visibleEdges: [executeEdge], + }); + + const originEdge = edges.find((edge) => edge.id.endsWith('-origin')); + const targetEdge = edges.find((edge) => edge.id.endsWith('-target')); + + expect(originEdge).toMatchObject({ + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceTop, + targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, + }); + expect(targetEdge).toMatchObject({ + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceTop, + targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, + }); + }); + it('keeps the DAO above contracts when execute and DAO-granted permissions are both visible', () => { + const executeEdge = buildEdge('execute', { + source: pluginId, + target: anchorId, + permissionName: 'EXECUTE_PERMISSION', + permissionDisplayName: 'Execute', + }); + const daoGrantedEdge = buildEdge('dao-granted', { + source: anchorId, + target: pluginId, + permissionName: 'SET_METADATA_PERMISSION', + permissionDisplayName: 'Set metadata', + }); + const graph = buildGraph( + [executeEdge, daoGrantedEdge], + [ + { + id: anchorId, + kind: 'dao', + label: 'DAO', + address: anchorId, + }, + { + id: pluginId, + kind: 'plugin', + label: 'Contract', + address: pluginId, + }, + ], + ); + const { nodes, edges } = buildFlowElements({ + anchorId, + graph, + onSelectEdge: jest.fn(), + visibleEdges: graph.edges, + }); + const executeStackId = `permission-stack-${pluginId}-${anchorId}`; + const daoGrantedStackId = `permission-stack-${anchorId}-${pluginId}`; + + expect( + edges.find((edge) => edge.id === `${executeStackId}-origin`)?.data, + ).toMatchObject({ + layoutSource: pluginId, + layoutTarget: executeStackId, + }); + expect( + edges.find((edge) => edge.id === `${executeStackId}-target`)?.data, + ).toMatchObject({ + layoutSource: executeStackId, + layoutTarget: anchorId, + }); + expect( + edges.find((edge) => edge.id === `${daoGrantedStackId}-origin`) + ?.data, + ).toMatchObject({ + layoutSource: pluginId, + layoutTarget: daoGrantedStackId, + }); + expect( + edges.find((edge) => edge.id === `${daoGrantedStackId}-target`) + ?.data, + ).toMatchObject({ + layoutSource: daoGrantedStackId, + layoutTarget: anchorId, + }); + + const { nodes: layoutedNodes } = getLayoutedElements(nodes, edges, { + direction: getLayoutDirection(graph.edges, anchorId), + }); + const positionedNodes = positionSelfStacks(layoutedNodes); + const nodeById = new Map( + positionedNodes.map((node) => [node.id, node]), + ); + + expect(nodeById.get(anchorId)!.position.y).toBeLessThan( + nodeById.get(executeStackId)!.position.y, + ); + expect(nodeById.get(executeStackId)!.position.y).toBeLessThan( + nodeById.get(pluginId)!.position.y, + ); + }); + + it('shows origin dots and target arrows only on the selected permission', () => { + const edge = buildEdge('perm', { + source: pluginId, + target: anchorId, + }); + const params = { + anchorId, + graph: buildGraph([edge]), + onSelectEdge: jest.fn(), + visibleEdges: [edge], + }; + + const unselected = buildFlowElements(params); + expect( + unselected.edges.find((item) => item.id.endsWith('-origin')) + ?.markerStart, + ).toBeUndefined(); + expect( + unselected.edges.find((item) => item.id.endsWith('-target')) + ?.markerEnd, + ).toBeUndefined(); + + const selected = buildFlowElements({ + ...params, + selectedEdgeId: 'perm', + }); + expect( + selected.edges.find((item) => item.id.endsWith('-origin')) + ?.markerStart, + ).toBeDefined(); + expect( + selected.edges.find((item) => item.id.endsWith('-target')) + ?.markerEnd, + ).toBeDefined(); + }); }); describe('positionSelfStacks', () => { @@ -196,7 +438,7 @@ describe('alignEdgesWithNodePositions', () => { position, }); - it('uses side handles for horizontal edges', () => { + it('keeps horizontal edges on top/bottom handles', () => { const edges: Edge[] = [ { id: 'edge', @@ -216,8 +458,8 @@ describe('alignEdgesWithNodePositions', () => { const result = alignEdgesWithNodePositions(nodes, edges); expect(result[0]).toMatchObject({ - sourceHandle: PERMISSION_GRAPH_HANDLE.sourceRight, - targetHandle: PERMISSION_GRAPH_HANDLE.targetLeft, + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceBottom, + targetHandle: PERMISSION_GRAPH_HANDLE.targetTop, }); }); @@ -246,7 +488,7 @@ describe('alignEdgesWithNodePositions', () => { }); }); - it('preserves incoming trident handles during post-layout alignment', () => { + it('preserves locked incoming trident handles during post-layout alignment', () => { const edges: Edge[] = [ { id: 'incoming', @@ -254,7 +496,7 @@ describe('alignEdgesWithNodePositions', () => { sourceHandle: PERMISSION_GRAPH_HANDLE.sourceTop, target: 'target', targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, - data: { visualKind: 'incoming' }, + data: { visualKind: 'incoming', lockHandles: true }, }, ]; const nodes = [ @@ -273,4 +515,165 @@ describe('alignEdgesWithNodePositions', () => { targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, }); }); + + it('realigns mixed incoming edges from final node positions', () => { + const edges: Edge[] = [ + { + id: 'incoming', + source: 'source', + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceTop, + target: 'target', + targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, + data: { visualKind: 'incoming' }, + }, + ]; + const nodes = [ + buildFlowNode('source', { x: 0, y: 0 }, { width: 100, height: 80 }), + buildFlowNode( + 'target', + { x: 0, y: 200 }, + { width: 100, height: 80 }, + ), + ]; + + const result = alignEdgesWithNodePositions(nodes, edges); + + expect(result[0]).toMatchObject({ + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceBottom, + targetHandle: PERMISSION_GRAPH_HANDLE.targetTop, + }); + }); + + it('keeps origin dots and target arrows on opposite stack sides', () => { + const edges: Edge[] = [ + { + id: 'stack-origin', + source: 'source', + target: 'stack', + data: { + permissionStackId: 'stack', + stackConnection: 'origin', + visualKind: 'incoming', + }, + }, + { + id: 'stack-target', + source: 'stack', + target: 'target', + data: { + permissionStackId: 'stack', + stackConnection: 'target', + visualKind: 'incoming', + }, + }, + ]; + const nodes = [ + buildFlowNode('stack', { x: 0, y: 0 }, { width: 100, height: 40 }), + buildFlowNode( + 'source', + { x: 0, y: 200 }, + { width: 100, height: 80 }, + ), + buildFlowNode( + 'target', + { x: 0, y: 200 }, + { width: 100, height: 80 }, + ), + ]; + + const result = alignEdgesWithNodePositions(nodes, edges); + + expect(result[0]).toMatchObject({ + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceTop, + targetHandle: PERMISSION_GRAPH_HANDLE.targetTop, + }); + expect(result[1]).toMatchObject({ + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceBottom, + targetHandle: PERMISSION_GRAPH_HANDLE.targetTop, + }); + }); + + it('separates dot starts from arrow ends on the same entity handle', () => { + const edges: Edge[] = [ + { + id: 'dot-start', + source: 'entity', + target: 'dot-target', + markerStart: 'dot', + }, + { + id: 'arrow-end', + source: 'arrow-source', + target: 'entity', + markerEnd: 'arrow', + }, + ]; + const nodes = [ + buildFlowNode( + 'dot-target', + { x: 0, y: -200 }, + { width: 100, height: 80 }, + ), + buildFlowNode( + 'arrow-source', + { x: 0, y: -200 }, + { width: 100, height: 80 }, + ), + buildFlowNode('entity', { x: 0, y: 0 }, { width: 100, height: 80 }), + ]; + + const result = alignEdgesWithNodePositions(nodes, edges); + + expect(result[0]).toMatchObject({ + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceBottom, + }); + expect(result[1]).toMatchObject({ + targetHandle: PERMISSION_GRAPH_HANDLE.targetTop, + }); + }); + + it('separates source dots from locked target arrows on the same contract node', () => { + const edges: Edge[] = [ + { + id: 'selected-who-path', + source: 'contract', + target: 'permission-stack', + markerStart: 'dot', + }, + { + id: 'locked-where-path', + source: 'other-stack', + target: 'contract', + targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, + markerEnd: 'arrow', + data: { visualKind: 'incoming', lockHandles: true }, + }, + ]; + const nodes = [ + buildFlowNode( + 'permission-stack', + { x: 0, y: 200 }, + { width: 100, height: 40 }, + ), + buildFlowNode( + 'other-stack', + { x: 0, y: 200 }, + { width: 100, height: 40 }, + ), + buildFlowNode( + 'contract', + { x: 0, y: 0 }, + { width: 100, height: 80 }, + ), + ]; + + const result = alignEdgesWithNodePositions(nodes, edges); + + expect(result[0]).toMatchObject({ + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceTop, + }); + expect(result[1]).toMatchObject({ + targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, + }); + }); }); diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx index 29bf7b5f15..8f79187f41 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx @@ -54,11 +54,12 @@ const EDGE_ORIGIN_MARKER_ACTIVE = 'permission-origin-dot-active'; const SELF_STACK_GAP = 48; const FALLBACK_NODE_WIDTH = 256; const FALLBACK_NODE_HEIGHT = 92; -const FALLBACK_STACK_WIDTH = 240; +const FALLBACK_STACK_WIDTH = 160; const STACK_ROW_HEIGHT = 20; const STACK_CONDITION_ROW_HEIGHT = 34; const STACK_ROW_GAP = 2; type PermissionGraphFlow = 'incoming' | 'outgoing'; +const EXECUTE_PERMISSION_NAME = 'EXECUTE_PERMISSION'; export const getVisibleEdges = ( graph: IPermissionGraph, @@ -74,11 +75,8 @@ export const getGraphFlow = ( const hasIncomingEdges = nonSelfEdges.some( (edge) => edge.target === anchorId, ); - const hasOutgoingEdges = nonSelfEdges.some( - (edge) => edge.source === anchorId, - ); - return hasIncomingEdges && !hasOutgoingEdges ? 'incoming' : 'outgoing'; + return hasIncomingEdges ? 'incoming' : 'outgoing'; }; export const getLayoutDirection = ( @@ -88,10 +86,21 @@ export const getLayoutDirection = ( getGraphFlow(visibleEdges, anchorId) === 'incoming' ? 'BT' : 'TB'; const getLayoutSpacing = (): { nodesep: number; ranksep: number } => ({ - nodesep: 60, - ranksep: 170, + nodesep: 96, + ranksep: 220, }); +export const getLayoutSignature = (nodes: Node[], edges: Edge[]): string => + [ + nodes + .map( + (node) => + `${node.id}:${node.measured?.width ?? 0}x${node.measured?.height ?? 0}`, + ) + .join('|'), + edges.map((edge) => `${edge.source}->${edge.target}`).join('|'), + ].join('::'); + const getHandlePositions = ( flow: PermissionGraphFlow, ): { @@ -129,6 +138,20 @@ const getEdgeHandles = (flow: PermissionGraphFlow) => { }; }; +const getEdgeFlow = ( + visualKind: PermissionEdgeVisualKind, + defaultFlow: PermissionGraphFlow, + usesBottomToTopHierarchy = false, +): PermissionGraphFlow => { + if (usesBottomToTopHierarchy) { + return 'incoming'; + } + + return visualKind === 'incoming' || visualKind === 'outgoing' + ? visualKind + : defaultFlow; +}; + const getStackPermissions = (node: Node): IPermissionEdgeEntry[] => Array.isArray(node.data?.permissions) ? (node.data.permissions as IPermissionEdgeEntry[]) @@ -185,22 +208,9 @@ const getFacingHandles = ( } => { const sourceCenter = getNodeCenter(sourceNode); const targetCenter = getNodeCenter(targetNode); - const deltaX = targetCenter.x - sourceCenter.x; - const deltaY = targetCenter.y - sourceCenter.y; - - if (Math.abs(deltaX) > Math.abs(deltaY)) { - return deltaX > 0 - ? { - sourceHandle: PERMISSION_GRAPH_HANDLE.sourceRight, - targetHandle: PERMISSION_GRAPH_HANDLE.targetLeft, - } - : { - sourceHandle: PERMISSION_GRAPH_HANDLE.sourceLeft, - targetHandle: PERMISSION_GRAPH_HANDLE.targetRight, - }; - } + const targetIsBelowOrLevel = targetCenter.y >= sourceCenter.y; - return deltaY > 0 + return targetIsBelowOrLevel ? { sourceHandle: PERMISSION_GRAPH_HANDLE.sourceBottom, targetHandle: PERMISSION_GRAPH_HANDLE.targetTop, @@ -211,14 +221,162 @@ const getFacingHandles = ( }; }; +type PermissionGraphHandleSide = 'top' | 'right' | 'bottom' | 'left'; +const SOURCE_HANDLE_BY_SIDE: Record = { + top: PERMISSION_GRAPH_HANDLE.sourceTop, + right: PERMISSION_GRAPH_HANDLE.sourceRight, + bottom: PERMISSION_GRAPH_HANDLE.sourceBottom, + left: PERMISSION_GRAPH_HANDLE.sourceLeft, +}; + +const TARGET_HANDLE_BY_SIDE: Record = { + top: PERMISSION_GRAPH_HANDLE.targetTop, + right: PERMISSION_GRAPH_HANDLE.targetRight, + bottom: PERMISSION_GRAPH_HANDLE.targetBottom, + left: PERMISSION_GRAPH_HANDLE.targetLeft, +}; + +const HANDLE_SIDE_BY_ID: Record = { + [PERMISSION_GRAPH_HANDLE.sourceTop]: 'top', + [PERMISSION_GRAPH_HANDLE.sourceRight]: 'right', + [PERMISSION_GRAPH_HANDLE.sourceBottom]: 'bottom', + [PERMISSION_GRAPH_HANDLE.sourceLeft]: 'left', + [PERMISSION_GRAPH_HANDLE.targetTop]: 'top', + [PERMISSION_GRAPH_HANDLE.targetRight]: 'right', + [PERMISSION_GRAPH_HANDLE.targetBottom]: 'bottom', + [PERMISSION_GRAPH_HANDLE.targetLeft]: 'left', +}; + +const OPPOSITE_HANDLE_SIDE: Record< + PermissionGraphHandleSide, + PermissionGraphHandleSide +> = { + top: 'bottom', + right: 'left', + bottom: 'top', + left: 'right', +}; + +const getHandleSide = ( + handleId: string | null | undefined, +): PermissionGraphHandleSide | undefined => + handleId == null ? undefined : HANDLE_SIDE_BY_ID[handleId]; + +const enforceOppositeStackSides = (edges: Edge[]): Edge[] => { + const nextEdges = [...edges]; + const edgesByStackId = new Map< + string, + { + origin?: { edge: Edge; index: number }; + target?: { edge: Edge; index: number }; + } + >(); + + for (const [index, edge] of edges.entries()) { + const stackId = edge.data?.permissionStackId; + const stackConnection = edge.data?.stackConnection; + + if ( + typeof stackId !== 'string' || + (stackConnection !== 'origin' && stackConnection !== 'target') + ) { + continue; + } + + const group = edgesByStackId.get(stackId) ?? {}; + group[stackConnection] = { edge, index }; + edgesByStackId.set(stackId, group); + } + + for (const group of edgesByStackId.values()) { + if ( + group.origin == null || + group.target == null || + group.origin.edge.data?.lockHandles === true || + group.target.edge.data?.lockHandles === true + ) { + continue; + } + + const targetStackSide = getHandleSide(group.target.edge.sourceHandle); + const originStackSide = getHandleSide(group.origin.edge.targetHandle); + + if ( + targetStackSide == null || + originStackSide == null || + targetStackSide !== originStackSide + ) { + continue; + } + + const nextOriginStackSide = OPPOSITE_HANDLE_SIDE[targetStackSide]; + nextEdges[group.origin.index] = { + ...group.origin.edge, + targetHandle: TARGET_HANDLE_BY_SIDE[nextOriginStackSide], + }; + } + + return nextEdges; +}; + +const getOppositeVerticalSourceSide = ( + sourceSide: PermissionGraphHandleSide, +): PermissionGraphHandleSide => (sourceSide === 'top' ? 'bottom' : 'top'); + +const enforceNodeMarkerHandleSeparation = (edges: Edge[]): Edge[] => { + const arrowEndSidesByNode = new Map< + string, + Set + >(); + + for (const edge of edges) { + if (edge.markerEnd == null) { + continue; + } + + const targetSide = getHandleSide(edge.targetHandle); + + if (targetSide == null) { + continue; + } + + const nodeSides = arrowEndSidesByNode.get(edge.target) ?? new Set(); + nodeSides.add(targetSide); + arrowEndSidesByNode.set(edge.target, nodeSides); + } + + return edges.map((edge) => { + if (edge.markerStart == null) { + return edge; + } + + const sourceSide = getHandleSide(edge.sourceHandle); + const arrowEndSides = arrowEndSidesByNode.get(edge.source); + + if ( + sourceSide == null || + arrowEndSides == null || + !arrowEndSides.has(sourceSide) + ) { + return edge; + } + + const nextSourceSide = getOppositeVerticalSourceSide(sourceSide); + return { + ...edge, + sourceHandle: SOURCE_HANDLE_BY_SIDE[nextSourceSide], + }; + }); +}; + export const alignEdgesWithNodePositions = ( nodes: Node[], edges: Edge[], ): Edge[] => { const nodeById = new Map(nodes.map((node) => [node.id, node])); - return edges.map((edge) => { - if (edge.data?.visualKind === 'incoming') { + const alignedEdges = edges.map((edge) => { + if (edge.data?.lockHandles === true) { return edge; } @@ -234,6 +392,10 @@ export const alignEdgesWithNodePositions = ( ...getFacingHandles(sourceNode, targetNode), }; }); + + return enforceNodeMarkerHandleSeparation( + enforceOppositeStackSides(alignedEdges), + ); }; const getGraphBounds = (nodes: Node[]) => { @@ -274,33 +436,37 @@ export const positionSelfStacks = (nodes: Node[]): Node[] => { const nodeById = new Map(nodes.map((node) => [node.id, node])); return nodes.map((node) => { - const selfTargetId = node.data?.selfTargetId; - - if ( - node.type !== 'permissionStack' || - typeof selfTargetId !== 'string' - ) { + if (node.type !== 'permissionStack') { return node; } - const targetNode = nodeById.get(selfTargetId); + const stackRect = getNodeRect(node); + const selfTargetId = node.data?.selfTargetId; - if (targetNode == null) { - return node; + if (typeof selfTargetId === 'string') { + const targetNode = nodeById.get(selfTargetId); + + if (targetNode == null) { + return node; + } + + const targetRect = getNodeRect(targetNode); + return { + ...node, + position: { + x: + targetNode.position.x + + targetRect.width / 2 - + stackRect.width / 2, + y: + targetNode.position.y - + stackRect.height - + SELF_STACK_GAP, + }, + }; } - const targetRect = getNodeRect(targetNode); - const stackRect = getNodeRect(node); - return { - ...node, - position: { - x: - targetNode.position.x + - targetRect.width / 2 - - stackRect.width / 2, - y: targetNode.position.y - stackRect.height - SELF_STACK_GAP, - }, - }; + return node; }); }; @@ -428,7 +594,11 @@ export const buildFlowElements = ({ const stackNodes: Node[] = []; const edges: Edge[] = []; - const edgeHandles = getEdgeHandles(graphFlow); + const daoNodeIds = new Set( + graph.nodes + .filter((node) => node.kind === 'dao') + .map((node) => node.id), + ); for (const group of groups.values()) { const active = group.entries.some((entry) => entry.selected === true); @@ -446,11 +616,37 @@ export const buildFlowElements = ({ ); const stackId = `permission-stack-${pairKey(group.source, group.target)}`; const isSelfEdge = visualKind === 'self'; + const usesBottomToTopHierarchy = group.entries.some( + (entry) => entry.permissionName === EXECUTE_PERMISSION_NAME, + ); + const edgeHandles = getEdgeHandles( + getEdgeFlow(visualKind, graphFlow, usesBottomToTopHierarchy), + ); const edgeData = { visualKind, + ...(visualKind === 'incoming' && graphFlow === 'incoming' + ? { lockHandles: true } + : {}), ...(isSelfEdge ? { selfTargetId: group.target } : {}), } satisfies IPermissionEdgeData; - + const sourceIsDao = daoNodeIds.has(group.source); + const targetIsDao = daoNodeIds.has(group.target); + const usesDaoHierarchy = sourceIsDao !== targetIsDao; + const daoLayoutNode = sourceIsDao ? group.source : group.target; + const contractLayoutNode = sourceIsDao ? group.target : group.source; + const layoutStartsAtDao = graphFlow === 'outgoing'; + const layoutSourceNode = layoutStartsAtDao + ? daoLayoutNode + : contractLayoutNode; + const layoutTargetNode = layoutStartsAtDao + ? contractLayoutNode + : daoLayoutNode; + const originLayoutData = usesDaoHierarchy + ? { layoutSource: layoutSourceNode, layoutTarget: stackId } + : {}; + const targetLayoutData = usesDaoHierarchy + ? { layoutSource: stackId, layoutTarget: layoutTargetNode } + : {}; stackNodes.push({ id: stackId, type: 'permissionStack', @@ -463,6 +659,9 @@ export const buildFlowElements = ({ active, dimmed, ...handlePositions, + sourceId: group.source, + targetId: group.target, + visualKind, ...(isSelfEdge ? { selfTargetId: group.target } : {}), onSelect: onSelectEdge, }, @@ -477,7 +676,7 @@ export const buildFlowElements = ({ targetHandle: PERMISSION_GRAPH_HANDLE.targetTop, type: 'permission', animated: active, - markerEnd: getEdgeMarker(active), + markerEnd: active ? getEdgeMarker(true) : undefined, style: getEdgeStyle(active), zIndex: active ? SELECTED_EDGE_Z_INDEX : undefined, data: { @@ -497,10 +696,15 @@ export const buildFlowElements = ({ targetHandle: edgeHandles.stackTarget, type: 'permission', animated: active, - markerStart: getOriginMarker(active), + markerStart: active ? getOriginMarker(true) : undefined, style: getEdgeStyle(active), zIndex: active ? SELECTED_EDGE_Z_INDEX : undefined, - data: edgeData, + data: { + ...edgeData, + permissionStackId: stackId, + stackConnection: 'origin', + ...originLayoutData, + }, }); edges.push({ @@ -511,10 +715,15 @@ export const buildFlowElements = ({ targetHandle: edgeHandles.targetTarget, type: 'permission', animated: active, - markerEnd: getEdgeMarker(active), + markerEnd: active ? getEdgeMarker(true) : undefined, style: getEdgeStyle(active), zIndex: active ? SELECTED_EDGE_Z_INDEX : undefined, - data: edgeData, + data: { + ...edgeData, + permissionStackId: stackId, + stackConnection: 'target', + ...targetLayoutData, + }, }); } @@ -621,16 +830,12 @@ export const PermissionsGraphCanvas: React.FC = ({ return; } - const topologySignature = [ - nodes.map((node) => node.id).join('|'), - edges.map((edge) => `${edge.source}->${edge.target}`).join('|'), - ].join('::'); + const currentNodes = getNodes(); + const topologySignature = getLayoutSignature(currentNodes, edges); if (layoutSignature.current === topologySignature) { return; } - - const currentNodes = getNodes(); const { nodes: rawLayoutedNodes } = getLayoutedElements( currentNodes, edges, diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx index a6232ff218..009d7496e6 100644 --- a/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx @@ -1,5 +1,7 @@ import { GukModulesProvider } from '@aragon/gov-ui-kit'; -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen, within } from '@testing-library/react'; +import { PluginInterfaceType } from '@/shared/api/daoService'; +import { generateDaoPlugin } from '@/shared/testUtils'; import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; import { initialiseConditionRegistry } from '../../initConditionRegistry'; import type { IPermissionRow } from '../../types'; @@ -13,6 +15,9 @@ const ROOT_PERMISSION_ID = '0x815fe80e4b37c8582a3b773d1d7071f983eacfd56b5965db654f3087c25ada33'; const EXECUTE_PERMISSION_ID = '0xbf04b4486c9663d805744005c3da000eda93de6e3308a4a7a812eb565327b78d'; +const SET_TRUSTED_FORWARDER_PERMISSION_ID = + '0x06d294bc8cbad2e393408b20dd019a772661f60b8d633e56761157cb1ec85f8c'; +const SPP_PLUGIN_ADDRESS = '0x26A696269116cAaB99626Cc793CeA24bbCec7528'; describe(' component', () => { beforeAll(() => { @@ -38,6 +43,18 @@ describe(' component', () => { ); }; + const getMobileList = (container: HTMLElement) => { + const mobileList = container.querySelector( + '[class~="md:hidden"]', + ); + + if (mobileList == null) { + throw new Error('Mobile permissions list not found'); + } + + return mobileList; + }; + it('renders a skeleton while the permissions are loading', () => { render(createTestComponent({ isLoading: true })); @@ -79,13 +96,17 @@ describe(' component', () => { render(createTestComponent({ rows })); - expect(screen.getByText('ROOT_PERMISSION')).toBeInTheDocument(); - expect(screen.getByText('EXECUTE_PERMISSION')).toBeInTheDocument(); + expect(screen.getAllByText('ROOT_PERMISSION').length).toBeGreaterThan( + 0, + ); + expect( + screen.getAllByText('EXECUTE_PERMISSION').length, + ).toBeGreaterThan(0); expect(screen.getAllByText('Anyone').length).toBeGreaterThan(0); expect(screen.getAllByText('Any Address').length).toBeGreaterThan(0); expect( - screen.getByText(/permissionsList.header.condition/), - ).toBeInTheDocument(); + screen.getAllByText(/permissionsList.header.condition/).length, + ).toBeGreaterThan(0); }); it('renders backend-enriched entity labels without plugin lookup', () => { @@ -107,8 +128,8 @@ describe(' component', () => { render(createTestComponent({ rows })); - expect(screen.getByText('Backend SPP')).toBeInTheDocument(); - expect(screen.getByText('SPP')).toBeInTheDocument(); + expect(screen.getAllByText('Backend SPP').length).toBeGreaterThan(0); + expect(screen.getAllByText('SPP').length).toBeGreaterThan(0); }); it('renders informational help for the Who and Where headers', () => { @@ -154,8 +175,8 @@ describe(' component', () => { render(createTestComponent({ rows })); - expect(screen.getByText('VotingPower')).toBeInTheDocument(); - expect(screen.getByText('-')).toBeInTheDocument(); + expect(screen.getAllByText('VotingPower').length).toBeGreaterThan(0); + expect(screen.getAllByText('-').length).toBeGreaterThan(0); }); it('keys rows by condition address so distinct conditions do not collide', () => { @@ -187,7 +208,88 @@ describe(' component', () => { render(createTestComponent({ rows })); - expect(screen.getByText('Unrecognized condition')).toBeInTheDocument(); + const conditionLabels = screen.getAllByText('Unrecognized condition'); + expect(conditionLabels.length).toBeGreaterThan(0); + expect(conditionLabels[0].parentElement).toHaveClass( + 'max-w-full', + '[&>p]:truncate', + ); + }); + + it('renders mobile cards with graph-style detail controls and static permission content', () => { + const rows: IPermissionRow[] = [ + { + permissionId: SET_TRUSTED_FORWARDER_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: SPP_PLUGIN_ADDRESS, + conditionAddress: ALLOW_FLAG, + }, + ]; + + const { container } = render( + createTestComponent({ + rows, + daoPlugins: [ + { + id: 'spp', + uniqueId: 'spp-1', + label: 'Polling', + meta: generateDaoPlugin({ + name: 'Polling', + address: SPP_PLUGIN_ADDRESS, + interfaceType: PluginInterfaceType.SPP, + }), + props: {}, + }, + ], + }), + ); + const mobileList = getMobileList(container); + + expect( + within(mobileList).getByText(/permissionsList.details.heading/), + ).toBeInTheDocument(); + expect( + within(mobileList).getByRole('radio', { + name: /permissionsList.details.permission/, + }), + ).toBeInTheDocument(); + expect( + within(mobileList).getByRole('radio', { + name: /permissionsList.details.condition/, + }), + ).toBeDisabled(); + expect( + within(mobileList).getByText('SET_TRUSTED_FORWARDER_PERMISSION'), + ).toBeInTheDocument(); + expect( + within(mobileList).getAllByText('Anyone').length, + ).toBeGreaterThan(0); + expect(within(mobileList).getByText('Polling')).toBeInTheDocument(); + }); + + it('switches mobile cards from permission details to condition details', () => { + const rows: IPermissionRow[] = [ + { + permissionId: EXECUTE_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: '0xC0Ffee254729296a45a3885639AC7E10F9d54979', + }, + ]; + + const { container } = render(createTestComponent({ rows })); + const mobileList = getMobileList(container); + + fireEvent.click( + within(mobileList).getByRole('radio', { + name: /permissionsList.details.condition/, + }), + ); + + expect( + within(mobileList).getByText(/unrecognizedConditionSlot.heading/), + ).toBeInTheDocument(); }); it('renders both the Details and Condition lists for an expanded row', async () => { @@ -213,8 +315,8 @@ describe(' component', () => { ); expect( - screen.getByText(/permissionsList.details.heading/), - ).toBeInTheDocument(); + screen.getAllByText(/permissionsList.details.heading/).length, + ).toBeGreaterThan(0); expect( screen.getByText(/permissionsList.condition.heading/), ).toBeInTheDocument(); @@ -288,7 +390,9 @@ describe(' component', () => { }), ); - expect(screen.getAllByText('Unrecognized condition')).toHaveLength(2); + expect( + screen.getAllByText('Unrecognized condition').length, + ).toBeGreaterThanOrEqual(2); expect(screen.queryByText(/noConditionSlot/)).not.toBeInTheDocument(); }); }); diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx index 0054e716b2..fc1efca3c9 100644 --- a/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx @@ -34,6 +34,7 @@ import { permissionEntityUtils, } from '../../utils/permissionEntityUtils'; import { NoConditionSlot } from '../noConditionSlot'; +import { PermissionDetailContent } from '../permissionsGraph/permissionDetailPanel'; import { UnrecognizedConditionSlot } from '../unrecognizedConditionSlot'; type DaoPlugins = IFilterComponentPlugin[] | undefined; @@ -92,14 +93,9 @@ export const PermissionsList: React.FC = (props) => { return (
- - onExpandedRowsChange(value ?? [])} - value={expandedRows} - > +
{rows.map((row) => ( - = (props) => { rowKey={getPermissionRowKey(row)} /> ))} - +
+
+ + onExpandedRowsChange(value ?? [])} + value={expandedRows} + > + {rows.map((row) => ( + + ))} + +
); }; @@ -177,6 +193,9 @@ const PermissionEntityDetail: React.FC = ({ type: ChainEntityType.ADDRESS, id: entity.address, }); + const truncatedAddress = addressUtils.truncateAddress(entity.address); + const detailName = + entity.detailName !== truncatedAddress ? entity.detailName : undefined; return (
@@ -185,12 +204,10 @@ const PermissionEntityDetail: React.FC = ({ href={explorerUrl} isExternal={explorerUrl != null} > - {addressUtils.truncateAddress(entity.address)} + {truncatedAddress} - {entity.detailName != null && ( - - {entity.detailName} - + {detailName != null && ( + {detailName} )}
); @@ -205,16 +222,51 @@ const PermissionDetailValue: React.FC = ({ primary, secondary, }) => ( -
+
{primary} {secondary != null && ( - + {secondary} )}
); +const PermissionsListMobileCard: React.FC = ( + props, +) => { + const { row, daoPlugins, accounts, chainId, network } = props; + + const resolveOptions = { daoPlugins, accounts }; + const who = permissionEntityUtils.resolvePermissionEntity(row.whoAddress, { + ...resolveOptions, + entity: row.who, + }); + const where = permissionEntityUtils.resolvePermissionEntity( + row.whereAddress, + { + ...resolveOptions, + entity: row.where, + }, + ); + const permissionName = permissionNameUtils.getPermissionName( + row.permissionId, + ); + + return ( +
+ +
+ ); +}; + const PermissionsListRow: React.FC = (props) => { const { row, rowKey, daoPlugins, accounts, chainId, network } = props; @@ -258,12 +310,15 @@ const PermissionsListRow: React.FC = (props) => {
- + {permissionName} - + {hasConditionLabel ? ( - + ) : ( {conditionLabel} @@ -393,7 +448,7 @@ const PermissionsListHeader: React.FC = () => { const { t } = useTranslations(); return ( -
+
( className="flex items-center justify-between gap-x-4 rounded-xl border border-neutral-100 bg-neutral-0 px-4 py-3 md:gap-x-6 md:px-6 md:py-5" key={rowKey} > -
+
diff --git a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.test.tsx b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.test.tsx index 935238a45d..a9f149b164 100644 --- a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.test.tsx +++ b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.test.tsx @@ -31,6 +31,8 @@ jest.mock('../../components/permissionsList', () => ({ ), })); +let mockFilterParamValues: Record; + jest.mock('@/shared/hooks/useFilterUrlParam', () => ({ useFilterUrlParam: ({ name, @@ -38,35 +40,29 @@ jest.mock('@/shared/hooks/useFilterUrlParam', () => ({ }: { name: string; fallbackValue: string; - }) => { - const valueByParam: Record = { - permissionsview: 'graph', - permissionsdao: 'true', - permissionssubplugins: 'true', - }; - - return [valueByParam[name] ?? fallbackValue, jest.fn()]; - }, + }) => [mockFilterParamValues[name] ?? fallbackValue, jest.fn()] as const, })); jest.mock('@/shared/components/translationsProvider', () => ({ useTranslations: () => ({ t: (key: string) => ({ - 'app.settings.daoPermissionsPage.filters.showDaoPermissions': - 'Show DAO-granted permissions', - 'app.settings.daoPermissionsPage.filters.showSubpluginPermissions': - 'Show supporting permissions', - 'app.settings.daoPermissionsPage.filters.showDaoPermissionsTooltipLabel': - 'About DAO-granted permissions', - 'app.settings.daoPermissionsPage.filters.showDaoPermissionsTooltip': - 'Permissions where the selected DAO appears under Who, meaning it can call another contract.', - 'app.settings.daoPermissionsPage.filters.showSubpluginPermissionsTooltipLabel': - 'About supporting permissions', - 'app.settings.daoPermissionsPage.filters.showSubpluginPermissionsTooltip': - 'Includes process internals, condition contracts, external actors, and unresolved permission rows from the DAO permission table.', + 'app.settings.daoPermissionsPage.filters.hideDaoPermissions': + 'Hide permissions granted to DAO', + 'app.settings.daoPermissionsPage.filters.hideGoverningBodyPermissions': + 'Hide permissions on governing bodies', + 'app.settings.daoPermissionsPage.filters.hideDaoPermissionsTooltipLabel': + 'About permissions granted to DAO', + 'app.settings.daoPermissionsPage.filters.hideDaoPermissionsTooltip': + 'Hides permissions where the selected DAO appears under Who, including DAO-managed internal contracts such as clocks.', + 'app.settings.daoPermissionsPage.filters.hideGoverningBodyPermissionsTooltipLabel': + 'About governing body permissions', + 'app.settings.daoPermissionsPage.filters.hideGoverningBodyPermissionsTooltip': + 'Hides permissions to or from installed governing bodies and rows not connected to the selected DAO.', 'app.settings.daoPermissionsPage.view.graph': 'Graph', 'app.settings.daoPermissionsPage.view.list': 'List', + 'app.settings.permissionsList.expandAll': 'Expand all', + 'app.settings.permissionsList.collapseAll': 'Collapse all', })[key] ?? key, }), })); @@ -92,6 +88,8 @@ describe(' component', () => { ); beforeEach(() => { + mockFilterParamValues = { permissionsview: 'graph' }; + const dao = generateDao({ address: activeDaoAddress, id: 'dao-id', @@ -109,7 +107,7 @@ describe(' component', () => { }), buildRow({ whoAddress: otherAddress, - whereAddress: externalAddress, + whereAddress: pluginAddress, }), ]; @@ -162,37 +160,107 @@ describe(' component', () => { screen.queryByRole('button', { name: 'Other' }), ).not.toBeInTheDocument(); expect( - screen.getByText('Show DAO-granted permissions'), + screen.getByText('Hide permissions granted to DAO'), ).toBeInTheDocument(); expect( - screen.getByText('Show supporting permissions'), + screen.getByText('Hide permissions on governing bodies'), ).toBeInTheDocument(); - expect( - screen.queryByText('Show subplugin/residual permissions'), - ).not.toBeInTheDocument(); + expect(screen.queryByText('DAO as caller')).not.toBeInTheDocument(); + expect(screen.queryByText('Subplugin paths')).not.toBeInTheDocument(); expect( screen.queryByRole('button', { - name: 'About DAO-granted permissions', + name: 'About permissions granted to DAO', }), ).not.toBeInTheDocument(); expect( screen.queryByRole('button', { - name: 'About supporting permissions', + name: 'About governing body permissions', }), ).not.toBeInTheDocument(); expect( screen.getByRole('img', { - name: /Permissions where the selected DAO appears under Who/, + name: /including DAO-managed internal contracts such as clocks/, }), ).toBeInTheDocument(); expect( screen.getByRole('img', { - name: /Includes process internals, condition contracts/, + name: /Hides permissions to or from installed governing bodies/, }), ).toBeInTheDocument(); + expect(screen.getByTestId('permissions-graph')).toHaveAttribute( + 'data-row-count', + '1', + ); + }); + + it('hides the list expand control below the desktop breakpoint', () => { + mockFilterParamValues = { permissionsview: 'list' }; + + render( + + + , + ); + + expect(screen.getByRole('button', { name: 'Expand all' })).toHaveClass( + 'hidden', + 'md:inline-flex', + ); + }); + + it('shows noisy permission groups when hide switches are off', () => { + mockFilterParamValues = { + permissionshidedaogrants: 'false', + permissionshidegoverningbodypaths: 'false', + permissionsview: 'graph', + }; + + render( + + + , + ); + expect(screen.getByTestId('permissions-graph')).toHaveAttribute( 'data-row-count', '3', ); }); + + it('ignores stale positive show params when deriving hide defaults', () => { + mockFilterParamValues = { + permissionsdao: 'false', + permissionssubplugins: 'false', + permissionsview: 'graph', + }; + + render( + + + , + ); + expect(screen.getByTestId('permissions-graph')).toHaveAttribute( + 'data-row-count', + '1', + ); + }); + + it('ignores stale hide params from the previous preview', () => { + mockFilterParamValues = { + permissionshidedao: 'false', + permissionshidegoverningbodies: 'false', + permissionsview: 'graph', + }; + + render( + + + , + ); + + expect(screen.getByTestId('permissions-graph')).toHaveAttribute( + 'data-row-count', + '1', + ); + }); }); diff --git a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx index 7d451aca09..2fad42f7ee 100644 --- a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx +++ b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx @@ -31,8 +31,9 @@ export interface IDaoPermissionsPageClientProps { } export const permissionsViewParam = 'permissionsview'; -export const permissionsDaoParam = 'permissionsdao'; -export const permissionsSubpluginsParam = 'permissionssubplugins'; +export const permissionsHideDaoParam = 'permissionshidedaogrants'; +export const permissionsHideGoverningBodiesParam = + 'permissionshidegoverningbodypaths'; enum PermissionsView { LIST = 'list', @@ -81,25 +82,28 @@ export const DaoPermissionsPageClient: React.FC< enableUrlUpdate: true, }); - const [showDaoPermissionsParam, setShowDaoPermissions] = useFilterUrlParam({ - name: permissionsDaoParam, - fallbackValue: 'false', + const [hideDaoPermissionsParam, setHideDaoPermissions] = useFilterUrlParam({ + name: permissionsHideDaoParam, + fallbackValue: 'true', validValues: booleanParamValues, enableUrlUpdate: true, }); - const [showSubpluginPermissionsParam, setShowSubpluginPermissions] = + const [hideGoverningBodyPermissionsParam, setHideGoverningBodyPermissions] = useFilterUrlParam({ - name: permissionsSubpluginsParam, - fallbackValue: 'false', + name: permissionsHideGoverningBodiesParam, + fallbackValue: 'true', validValues: booleanParamValues, enableUrlUpdate: true, }); const [expandedRows, setExpandedRows] = useState([]); - const showDaoPermissions = showDaoPermissionsParam === 'true'; - const showSubpluginPermissions = showSubpluginPermissionsParam === 'true'; + const hideDaoPermissions = hideDaoPermissionsParam === 'true'; + const hideGoverningBodyPermissions = + hideGoverningBodyPermissionsParam === 'true'; + const showDaoPermissions = !hideDaoPermissions; + const showSubpluginPermissions = !hideGoverningBodyPermissions; const handleViewChange = (value?: string | string[]) => { if (typeof value === 'string' && value) { @@ -114,13 +118,13 @@ export const DaoPermissionsPageClient: React.FC< } }; - const handleShowDaoPermissionsChange = (checked: boolean) => { - setShowDaoPermissions(String(checked)); + const handleHideDaoPermissionsChange = (checked: boolean) => { + setHideDaoPermissions(String(checked)); setExpandedRows([]); }; - const handleShowSubpluginPermissionsChange = (checked: boolean) => { - setShowSubpluginPermissions(String(checked)); + const handleHideGoverningBodyPermissionsChange = (checked: boolean) => { + setHideGoverningBodyPermissions(String(checked)); setExpandedRows([]); }; @@ -141,7 +145,7 @@ export const DaoPermissionsPageClient: React.FC< ], ); - const showDaoPermissionsToggleDisabled = useMemo( + const hideDaoPermissionsToggleDisabled = useMemo( () => arePermissionRowsEqual( filteredRows, @@ -162,7 +166,7 @@ export const DaoPermissionsPageClient: React.FC< ], ); - const showSubpluginPermissionsToggleDisabled = useMemo( + const hideGoverningBodyPermissionsToggleDisabled = useMemo( () => arePermissionRowsEqual( filteredRows, @@ -259,6 +263,7 @@ export const DaoPermissionsPageClient: React.FC< {showExpandAll && (
); diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts index 0d474ef05f..4f66f9f605 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts @@ -249,6 +249,75 @@ describe('buildFlowElements', () => { targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, }); }); + + it('keeps proposal creator nodes below their governing body target', () => { + const creatorId = 'proposal-creator-anyone-core'; + const executeEdge = buildEdge('execute', { + source: pluginId, + target: anchorId, + permissionName: 'EXECUTE_PERMISSION', + permissionDisplayName: 'Execute', + }); + const createProposalEdge = buildEdge('create-proposal', { + source: creatorId, + target: pluginId, + permissionName: 'CREATE_PROPOSAL_PERMISSION', + permissionDisplayName: 'Create proposal', + }); + const graph = buildGraph( + [executeEdge, createProposalEdge], + [ + { + id: anchorId, + kind: 'dao', + label: 'DAO', + address: anchorId, + }, + { + id: pluginId, + kind: 'plugin', + label: 'Core Governance', + address: pluginId, + }, + { + id: creatorId, + kind: 'actor', + label: 'Anyone', + address: externalId, + }, + ], + ); + const { nodes, edges } = buildFlowElements({ + anchorId, + graph, + onSelectEdge: jest.fn(), + visibleEdges: graph.edges, + }); + const createProposalStackId = `permission-stack-${creatorId}-${pluginId}`; + + expect( + nodes.find((node) => node.id === createProposalStackId)?.data, + ).toMatchObject({ + sourceId: creatorId, + targetId: pluginId, + }); + + const { nodes: layoutedNodes } = getLayoutedElements(nodes, edges, { + direction: getLayoutDirection(graph.edges, anchorId), + }); + const positionedNodes = positionSelfStacks(layoutedNodes); + const nodeById = new Map( + positionedNodes.map((node) => [node.id, node]), + ); + + expect(nodeById.get(pluginId)!.position.y).toBeLessThan( + nodeById.get(createProposalStackId)!.position.y, + ); + expect(nodeById.get(createProposalStackId)!.position.y).toBeLessThan( + nodeById.get(creatorId)!.position.y, + ); + }); + it('keeps the DAO above contracts when execute and DAO-granted permissions are both visible', () => { const executeEdge = buildEdge('execute', { source: pluginId, diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx index 999dfe021b..2176b43bd7 100644 --- a/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx @@ -132,6 +132,30 @@ describe(' component', () => { expect(screen.getAllByText('SPP').length).toBeGreaterThan(0); }); + it('renders the Safe logo instead of a SAFE tag for Safe bodies', () => { + const rows: IPermissionRow[] = [ + { + permissionId: EXECUTE_PERMISSION_ID, + whoAddress: '0x3333333333333333333333333333333333333333', + whereAddress: ALLOW_FLAG, + conditionAddress: ALLOW_FLAG, + who: { + address: '0x3333333333333333333333333333333333333333', + brandId: 'safe', + label: 'Safe', + layer: 'processInternal', + }, + }, + ]; + + render(createTestComponent({ rows })); + + expect(screen.getAllByLabelText('Safe account').length).toBeGreaterThan( + 0, + ); + expect(screen.queryByText('SAFE')).not.toBeInTheDocument(); + }); + it('renders informational help for the Who and Where headers', () => { const rows: IPermissionRow[] = [ { diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx index 156204ad85..ba4db884d5 100644 --- a/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx @@ -2,6 +2,7 @@ import { Accordion, + Avatar, addressUtils, CardEmptyState, ChainEntityType, @@ -16,6 +17,7 @@ import { Tooltip, useBlockExplorer, } from '@aragon/gov-ui-kit'; +import safeWallet from '@/assets/images/safeWallet.png'; import type { IDaoPlugin, Network } from '@/shared/api/daoService'; import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; import { PluginSingleComponent } from '@/shared/components/pluginSingleComponent'; @@ -151,13 +153,20 @@ const PermissionEntityCell: React.FC = ({ {entity.type === 'dao' && ( )} - {entity.type === 'plugin' && entity.tag != null && ( - + {entity.brandId === 'safe' && ( + + + )} + {entity.type === 'plugin' && + entity.brandId !== 'safe' && + entity.tag != null && ( + + )} {entity.type === 'sentinel' && (
diff --git a/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts index aeb33cb475..a7c39e44f9 100644 --- a/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts +++ b/apps/app/src/modules/settings/utils/buildPermissionGraph/buildPermissionGraph.ts @@ -41,13 +41,14 @@ export interface IBuildPermissionGraphParams { accountRefs?: IPermissionAccountRef[]; } +type IResolveNodeContext = Omit; + const resolveNode = ( address: string, - dao: IDao, - daoPlugins?: IFilterComponentPlugin[], - accountRefs?: IPermissionAccountRef[], + context: IResolveNodeContext, enrichedEntity?: IPermissionEntityRef, ): IPermissionGraphNode => { + const { dao, daoPlugins, accountRefs } = context; const id = address.toLowerCase(); if (addressUtils.isAddressEqual(address, dao.address)) { @@ -120,26 +121,26 @@ const isProposalCreatorRow = (row: IPermissionRow): boolean => permissionNameUtils.getPermissionName(row.permissionId) === CREATE_PROPOSAL_PERMISSION_NAME && isGoverningBodyTarget(row); -const isOpenProposalCreatorRow = (row: IPermissionRow): boolean => - isProposalCreatorRow(row) && - addressUtils.isAddressEqual(row.whoAddress, ANY_ADDR); - const getOpenProposalTargets = (rows: IPermissionRow[]): Set => new Set( rows - .filter(isOpenProposalCreatorRow) + .filter( + (row) => + isProposalCreatorRow(row) && + addressUtils.isAddressEqual(row.whoAddress, ANY_ADDR), + ) .map((row) => row.whereAddress.toLowerCase()), ); // An open "Anyone" proposal grant on a governing body trumps every more-specific // proposal-creation eligibility on that same body (token thresholds, multisig // members, SPP stage bodies). Those grants are real, but redundant to show once -// anyone can propose, so they are dropped from the graph. -const isSubsumedProposalCreatorRow = ( +// anyone can propose, so they are dropped from the graph. Only call with rows +// already known to be proposal-creator rows. +const isSubsumedByOpenGrant = ( row: IPermissionRow, openProposalTargets: Set, ): boolean => - isProposalCreatorRow(row) && !addressUtils.isAddressEqual(row.whoAddress, ANY_ADDR) && openProposalTargets.has(row.whereAddress.toLowerCase()); @@ -159,36 +160,25 @@ const getProposalCreatorNodeId = (row: IPermissionRow): string => { const resolveProposalCreatorNode = ( row: IPermissionRow, - dao: IDao, - daoPlugins?: IFilterComponentPlugin[], - accountRefs?: IPermissionAccountRef[], + context: IResolveNodeContext, ): IPermissionGraphNode => { - const baseNode = resolveNode( - row.whoAddress, - dao, - daoPlugins, - accountRefs, - row.who, - ); - const interfaceType = row.who?.interfaceType?.toLowerCase(); - const isSafeEntity = baseNode.brandId === 'safe'; - const isMultisigMember = !isSafeEntity && interfaceType === 'multisig'; - - if (isMultisigMember) { - return { - id: getProposalCreatorNodeId(row), - kind: 'actor', - label: `Members of ${baseNode.label}`, - layer: baseNode.layer, - status: baseNode.status, - brandId: baseNode.brandId, - address: row.whoAddress, - }; + const baseNode = resolveNode(row.whoAddress, context, row.who); + const id = getProposalCreatorNodeId(row); + const isMultisigMembers = + baseNode.brandId !== 'safe' && + row.who?.interfaceType?.toLowerCase() === 'multisig'; + + if (!isMultisigMembers) { + return { ...baseNode, id }; } return { - ...baseNode, - id: getProposalCreatorNodeId(row), + id, + kind: 'actor', + label: `Members of ${baseNode.label}`, + layer: baseNode.layer, + status: baseNode.status, + brandId: baseNode.brandId, address: row.whoAddress, }; }; @@ -226,7 +216,7 @@ const resolveEdge = ( export const buildPermissionGraph = ( params: IBuildPermissionGraphParams, ): IPermissionGraph => { - const { rows, dao, daoPlugins, accountRefs } = params; + const { rows, ...context } = params; const nodesById = new Map(); const ensureNode = ( @@ -236,10 +226,7 @@ export const buildPermissionGraph = ( const id = address.toLowerCase(); if (!nodesById.has(id)) { - nodesById.set( - id, - resolveNode(address, dao, daoPlugins, accountRefs, entity), - ); + nodesById.set(id, resolveNode(address, context, entity)); } }; @@ -250,33 +237,25 @@ export const buildPermissionGraph = ( ); const openProposalTargets = getOpenProposalTargets(graphRows); - const edges = graphRows - .filter( - (row) => !isSubsumedProposalCreatorRow(row, openProposalTargets), - ) - .map((row) => { - const isProposalCreator = isProposalCreatorRow(row); - - if (isProposalCreator) { - const creatorNode = resolveProposalCreatorNode( - row, - dao, - daoPlugins, - accountRefs, - ); - nodesById.set(creatorNode.id, creatorNode); - ensureNode(row.whereAddress, row.where); - - return resolveEdge(row, { - sourceId: creatorNode.id, - }); - } + const edges: IPermissionGraphEdge[] = []; + for (const row of graphRows) { + if (!isProposalCreatorRow(row)) { ensureNode(row.whoAddress, row.who); ensureNode(row.whereAddress, row.where); + edges.push(resolveEdge(row)); + continue; + } - return resolveEdge(row); - }); + if (isSubsumedByOpenGrant(row, openProposalTargets)) { + continue; + } + + const creatorNode = resolveProposalCreatorNode(row, context); + nodesById.set(creatorNode.id, creatorNode); + ensureNode(row.whereAddress, row.where); + edges.push(resolveEdge(row, { sourceId: creatorNode.id })); + } return { nodes: [...nodesById.values()], edges }; }; From 91e7e6b0279b6f41e55a5b920ae1b48489c52b50 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Fri, 31 Jul 2026 12:32:34 +0200 Subject: [PATCH 35/55] fix(APP-942): Render condition details address-first with explorer link everywhere --- .../permissionDetailPanel.tsx | 23 +++++ .../permissionsList/permissionsList.test.tsx | 9 +- .../permissionsList/permissionsList.tsx | 89 ++++++++++++------- 3 files changed, 84 insertions(+), 37 deletions(-) diff --git a/apps/app/src/modules/settings/components/permissionsGraph/permissionDetailPanel.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionDetailPanel.tsx index da406e9181..5164f12ab6 100644 --- a/apps/app/src/modules/settings/components/permissionsGraph/permissionDetailPanel.tsx +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionDetailPanel.tsx @@ -82,6 +82,7 @@ export const PermissionDetailContent: React.FC< conditionAddress, row.condition, ); + const conditionLabel = conditionTypeUtils.getConditionLabel(conditionType); const hasUnrecognizedCondition = conditionType === UNKNOWN_CONDITION; const isWhoAnyAddress = addressUtils.isAddressEqual( @@ -181,6 +182,28 @@ export const PermissionDetailContent: React.FC< > {addressUtils.truncateHash(row.permissionId)} + + {hasCondition + ? addressUtils.truncateAddress(conditionAddress) + : conditionLabel} + ) : hasUnrecognizedCondition ? ( component', () => { render(createTestComponent({ rows })); const conditionLabels = screen.getAllByText('Unrecognized condition'); - expect(conditionLabels.length).toBeGreaterThan(0); - expect(conditionLabels[0].parentElement).toHaveClass( - 'max-w-full', - '[&>p]:truncate', - ); + const conditionTag = conditionLabels + .map((label) => label.parentElement) + .find((parent) => parent?.closest('button') != null); + expect(conditionTag).toHaveClass('max-w-full', '[&>p]:truncate'); }); it('renders mobile cards with graph-style chrome and hides toggles without a condition', () => { diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx index d1caa0b835..19c42b10c1 100644 --- a/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx @@ -173,6 +173,39 @@ const PermissionEntityCell: React.FC = ({ ); +interface IPermissionAddressListItemProps { + address: string; + term: string; + name?: string; + chainId?: number; +} + +const PermissionAddressListItem: React.FC = ({ + address, + term, + name, + chainId, +}) => { + const { buildEntityUrl } = useBlockExplorer({ chainId }); + const truncatedAddress = addressUtils.truncateAddress(address); + const explorerUrl = buildEntityUrl({ + type: ChainEntityType.ADDRESS, + id: address, + }); + const description = name !== truncatedAddress ? name : undefined; + + return ( + + {truncatedAddress} + + ); +}; + interface IPermissionEntityListItemProps { entity: IPermissionEntity; term: string; @@ -184,14 +217,11 @@ const PermissionEntityListItem: React.FC = ({ term, chainId, }) => { - const { buildEntityUrl } = useBlockExplorer({ chainId }); - const truncatedAddress = addressUtils.truncateAddress(entity.address); - if (entity.isSentinel) { return ( {entity.label} @@ -199,22 +229,13 @@ const PermissionEntityListItem: React.FC = ({ ); } - const explorerUrl = buildEntityUrl({ - type: ChainEntityType.ADDRESS, - id: entity.address, - }); - const description = - entity.detailName !== truncatedAddress ? entity.detailName : undefined; - return ( - - {truncatedAddress} - + /> ); }; @@ -296,9 +317,6 @@ const PermissionsListRow: React.FC = (props) => { conditionAddress, ALLOW_FLAG, ); - const conditionDetail = hasCondition - ? addressUtils.truncateAddress(conditionAddress) - : undefined; return ( @@ -353,17 +371,24 @@ const PermissionsListRow: React.FC = (props) => { > {addressUtils.truncateHash(row.permissionId)} - - {conditionLabel} - + {hasCondition ? ( + + ) : ( + + {conditionLabel} + + )}
From 78adf0476e1a122ed34755f960c1986e16926953 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Fri, 31 Jul 2026 13:00:20 +0200 Subject: [PATCH 36/55] refactor(APP-942): Extract shared draggable panel, info tooltip, and condition display --- .../components/permissionInfoTooltip/index.ts | 2 + .../permissionInfoTooltip.tsx | 39 ++++++ .../permissionDetailPanel.tsx | 113 +++-------------- .../permissionNodeDetailPanel.tsx | 85 +------------ .../permissionsGraph/useDraggablePanel.ts | 115 ++++++++++++++++++ .../permissionsList/permissionsList.tsx | 57 +++------ .../daoPermissionsPageClient.tsx | 57 ++------- .../buildPermissionGraph.ts | 14 +-- .../conditionTypeUtils/conditionTypeUtils.ts | 40 +++++- .../utils/conditionTypeUtils/index.ts | 1 + .../utils/permissionRowFilters/index.ts | 3 +- .../permissionRowFilters.ts | 8 +- 12 files changed, 252 insertions(+), 282 deletions(-) create mode 100644 apps/app/src/modules/settings/components/permissionInfoTooltip/index.ts create mode 100644 apps/app/src/modules/settings/components/permissionInfoTooltip/permissionInfoTooltip.tsx create mode 100644 apps/app/src/modules/settings/components/permissionsGraph/useDraggablePanel.ts diff --git a/apps/app/src/modules/settings/components/permissionInfoTooltip/index.ts b/apps/app/src/modules/settings/components/permissionInfoTooltip/index.ts new file mode 100644 index 0000000000..ef0f2ee1ff --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionInfoTooltip/index.ts @@ -0,0 +1,2 @@ +export type { IPermissionInfoTooltipProps } from './permissionInfoTooltip'; +export { PermissionInfoTooltip } from './permissionInfoTooltip'; diff --git a/apps/app/src/modules/settings/components/permissionInfoTooltip/permissionInfoTooltip.tsx b/apps/app/src/modules/settings/components/permissionInfoTooltip/permissionInfoTooltip.tsx new file mode 100644 index 0000000000..40d76b94f7 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionInfoTooltip/permissionInfoTooltip.tsx @@ -0,0 +1,39 @@ +import { Icon, IconType, Tooltip } from '@aragon/gov-ui-kit'; +import { useTranslations } from '@/shared/components/translationsProvider'; + +export interface IPermissionInfoTooltipProps { + /** + * Locale key for the tooltip body. + */ + tooltipKey: string; + /** + * Locale key for the tooltip's accessible label prefix (rendered into the + * `aria-label` as `: `). + */ + tooltipLabelKey: string; +} + +/** + * Shared info-icon tooltip used by the permissions page filter switches and + * the list column-header labels. Renders the kit `INFO` icon inside the kit + * `Tooltip`, with an `aria-label` of the form `