diff --git a/.changeset/app-1003-iterate-graph-view.md b/.changeset/app-1003-iterate-graph-view.md new file mode 100644 index 0000000000..dff95f51b4 --- /dev/null +++ b/.changeset/app-1003-iterate-graph-view.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": minor +--- + +Add the DAO settings permissions page with list and graph views of the DAO's on-chain permissions, filterable by the hide-DAO-grants and hide-governing-body toggles. diff --git a/.gitignore b/.gitignore index f8440ba3e4..64ddaec2fe 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/docs/slots/settings-slots.md b/apps/app/docs/slots/settings-slots.md index 55c2230b57..0a0517125f 100644 --- a/apps/app/docs/slots/settings-slots.md +++ b/apps/app/docs/slots/settings-slots.md @@ -13,4 +13,4 @@ The settings module defines 5 slots for plugin-specific governance settings, upd | `SETTINGS_BUILD_PREPARE_PLUGIN_UPDATE_DATA` | Function | Builds the transaction data for preparing a plugin update | Token Voting, Multisig, Lock To Vote | | `SETTINGS_GET_UNINSTALL_HELPERS` | Function | Returns helper data needed for uninstalling the plugin | Token Voting, Multisig, Lock To Vote, SPP | | `SETTINGS_PLUGIN_TO_FORM_DATA` | Function | Converts plugin settings to form-compatible data for the settings editor | Token Voting, Lock To Vote | -| `PERMISSION_CONDITION` | Component | Renders the condition-specific detail for a permission grant (routed by condition type) | Condition registry | +| `SETTINGS_PERMISSION_CONDITION` | Component | SETTINGS_PERMISSION_CONDITION | Condition registry | diff --git a/apps/app/package.json b/apps/app/package.json index 21e6a6d2d9..68156d5bd5 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.20", "@number-flow/react": "^0.6.1", "@plausible-analytics/tracker": "^0.4.5", @@ -50,6 +51,7 @@ "@vercel/speed-insights": "^2.0.0", "@walletconnect/core": "^2.23.10", "@walletconnect/utils": "^2.23.10", + "@xyflow/react": "^12.11.2", "classnames": "catalog:", "deepmerge-ts": "^7.1.5", "framer-motion": "^12.42.2", diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index 79f15a8ed4..d70a157f2b 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -3560,15 +3560,64 @@ "title": "Permissions", "description": "All permissions configured in this DAO — who has access, to which contract, and under what conditions." }, + "error": { + "title": "Unable to load permissions", + "description": "We couldn't load this DAO's permissions. Please try again later.", + "action": "Back to settings" + }, "view": { "list": "List", "graph": "Graph" + }, + "filters": { + "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 whose target is a governing body — a plugin nested inside another (subplugin).", + "hideGoverningBodyPermissionsTooltipLabel": "About governing body permissions" + }, + "graphView": { + "node": { + "dao": "Primary DAO", + "linkedDao": "Linked DAO", + "plugin": "Aragon OSx Plugin", + "historicalPlugin": "Historical plugin", + "uninstalledPlugin": "Uninstalled plugin", + "actor": "Address", + "who": "Who", + "where": "Where" + }, + "edge": { + "condition": "if {{condition}}" + }, + "detail": { + "address": "Address", + "close": "Close", + "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", + "description": "This view has no indexed permissions to visualize." + }, + "fullscreen": { + "open": "Expand graph", + "close": "Exit full screen" + } } }, "permissionsList": { "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" }, @@ -3577,11 +3626,10 @@ "who": "Who", "where": "Where", "permission": "Permission", - "condition": "Condition", - "noCondition": "No condition" + "condition": "Condition" }, "condition": { - "heading": "Condition detail" + "heading": "Condition" }, "expandAll": "Expand all", "collapseAll": "Collapse all", @@ -3590,9 +3638,9 @@ "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." }, "votingPowerConditionSlot": { "token": "Token", @@ -3601,10 +3649,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/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/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.test.tsx b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx index 5f018eb8d2..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({ @@ -31,12 +71,68 @@ 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(); }); + 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 })); @@ -50,7 +146,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 +155,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 7f9e9afe42..4a3cc35525 100644 --- a/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx +++ b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx @@ -1,60 +1,58 @@ 'use client'; -import { addressUtils, DefinitionList } from '@aragon/gov-ui-kit'; 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 { - selector: string; - target: string; +import { AllowedActionsList } from './allowedActionsList'; +import { DecodedAllowedActionsList } from './decodedAllowedActionsList'; +import { + toAllowedActions, + toAllowedActionViews, +} from './executeSelectorConditionSlotUtils'; + +interface IExecuteSelectorConditionSlotProps extends IConditionData { + chainId?: number; + conditionAddress?: string; + network?: Network; + pluginAddress?: string; } -const toStringList = (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); - - return selectorList.map((selector, index) => ({ - selector, - target: targetList[index] ?? EMPTY_VALUE, - })); -}; - 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 shouldShowDecodedActions = + 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)} - - ))} - + {shouldShowDecodedActions ? ( + + ) : hasRawAllowedActions ? ( + ) : (

{t('app.settings.executeSelectorConditionSlot.noActions')} diff --git a/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlotUtils.ts b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlotUtils.ts new file mode 100644 index 0000000000..ca881f47ec --- /dev/null +++ b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlotUtils.ts @@ -0,0 +1,74 @@ +import { addressUtils } from '@aragon/gov-ui-kit'; +import type { IAllowedAction } from '@/modules/governance/api/executeSelectorsService'; +import { stringUtils } from '@/shared/utils/stringUtils'; + +export const EMPTY_ALLOWED_ACTION_VALUE = '—'; + +export interface IRawAllowedAction { + selector: string | null; + target: string; +} + +export 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) : []; + +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 408a8726e3..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,7 +16,7 @@ import { useTranslations } from '@/shared/components/translationsProvider'; * "Member of multisig". */ export const MembershipConditionSlot: React.FC = (props) => { - const { onlyListed } = props; + const { onlyListed, minApprovals } = props as IMembershipConditionData; const { t } = useTranslations(); const isMemberGated = onlyListed === true; @@ -34,6 +39,15 @@ export const MembershipConditionSlot: React.FC = (props) => { /> + {minApprovals != null && ( + + {minApprovals} + + )} ); }; 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/permissionEntityIcons/index.ts b/apps/app/src/modules/settings/components/permissionEntityIcons/index.ts new file mode 100644 index 0000000000..6b157bcd7b --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionEntityIcons/index.ts @@ -0,0 +1,4 @@ +export { + MembersAvatarIcon, + SafeAccountAvatar, +} from './permissionEntityIcons'; diff --git a/apps/app/src/modules/settings/components/permissionEntityIcons/permissionEntityIcons.tsx b/apps/app/src/modules/settings/components/permissionEntityIcons/permissionEntityIcons.tsx new file mode 100644 index 0000000000..f8b29b7eeb --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionEntityIcons/permissionEntityIcons.tsx @@ -0,0 +1,20 @@ +import { Avatar, AvatarIcon, IconType } from '@aragon/gov-ui-kit'; +import safeWallet from '@/assets/images/safeWallet.png'; + +/** + * Safe-branded avatar displayed next to entities resolved with `brandId: 'safe'`. + */ +export const SafeAccountAvatar: React.FC = () => ( + + + +); + +/** + * Members icon displayed for the ANY_ADDR ("Anyone") permission sentinel. + */ +export const MembersAvatarIcon: React.FC = () => ( + + + +); 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 `

+
+

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

+ {hasCondition && ( + + + + + )} +
+ {selectedTab === 'permission' ? ( + + + {isWhoAnyAddress + ? who?.label + : addressUtils.truncateAddress(row.whoAddress)} + + + {isWhereAnyAddress + ? where?.label + : addressUtils.truncateAddress(row.whereAddress)} + + + {addressUtils.truncateHash(row.permissionId)} + + + {hasCondition + ? addressUtils.truncateAddress(conditionAddress) + : conditionLabel} + + + ) : hasUnrecognizedCondition ? ( + + ) : ( + + )} +
+ ); +}; + +export const PermissionDetailCard: React.FC = ({ + chainId, + className, + conditionLabel, + contentClassName, + headerClassName, + headerProps, + network, + onClose, + permissionName, + rootRef, + row, + style, + where, + who, +}) => { + const { t } = useTranslations(); + + return ( +
+
+
+

+ {permissionName} +

+ {conditionLabel != null && ( +

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

+ )} +
+ {onClose != null && ( +
event.stopPropagation()}> +
+ )} +
+ +
+ ); +}; + +export const PermissionDetailPanel: React.FC = ({ + chainId, + edge, + network, + nodes, + onClose, +}) => { + const { row } = edge; + const who = nodes.find((node) => node.id === edge.source); + const where = nodes.find((node) => node.id === edge.target); + const { panelRef, headerProps, style } = useDraggablePanel(); + + return ( + + ); +}; 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..31fe80199b --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.test.ts @@ -0,0 +1,60 @@ +import { getSmoothStepPath, 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 on the curved smooth-step path', () => { + const [smoothStepPath] = getSmoothStepPath({ + ...coordinates, + sourcePosition: Position.Top, + targetPosition: Position.Bottom, + borderRadius: 12, + offset: 28, + }); + + 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 path when handle positions are unavailable', () => { + 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 new file mode 100644 index 0000000000..a0b932676a --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphEdge.tsx @@ -0,0 +1,108 @@ +import { + BaseEdge, + type Edge, + type EdgeProps, + getSmoothStepPath, + getStraightPath, + type Position, +} from '@xyflow/react'; + +const EDGE_PATH_BORDER_RADIUS = 12; +const EDGE_PATH_OFFSET = 28; + +export interface IPermissionEdgeEntry { + edgeId: string; + permissionDisplayName: string; + permissionName: string; + conditionLabel?: string; + selected?: boolean; +} + +export type PermissionEdgeVisualKind = + | 'self' + | '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; +} + +export type IPermissionFlowEdge = Edge; + +interface IGetPermissionEdgePathParams { + sourceX: number; + sourceY: number; + targetX: number; + targetY: number; + sourcePosition?: Position; + targetPosition?: Position; + visualKind: PermissionEdgeVisualKind; +} + +export const getPermissionEdgePath = ({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, +}: IGetPermissionEdgePathParams): string => { + if (sourcePosition == null || targetPosition == null) { + 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> = ({ + sourceX, + sourceY, + targetX, + targetY, + markerStart, + markerEnd, + style, + sourcePosition, + targetPosition, + data, +}) => { + const visualKind = data?.visualKind ?? 'other'; + const edgePath = getPermissionEdgePath({ + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + visualKind, + }); + + return ( + + ); +}; 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..1ebc8e67e8 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.test.tsx @@ -0,0 +1,158 @@ +import { render, screen } from '@testing-library/react'; +import { ReactFlowProvider } from '@xyflow/react'; +import type { ComponentProps } from 'react'; +import { ANY_ADDR } from '../../constants/permissionSentinels'; +import { + PermissionGraphNode, + 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( + + + , + ); +}; + +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(); + }); + + 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'); + }); + + it('renders Anyone actor nodes with the primary members icon', () => { + renderGraphNode({ + address: ANY_ADDR, + kind: 'actor', + label: 'Anyone', + tag: undefined, + }); + + expect(screen.getByText('Anyone')).toBeInTheDocument(); + expect(screen.getByLabelText('Members')).toBeInTheDocument(); + }); + + it('renders Safe-branded actor nodes with the Safe avatar', () => { + renderGraphNode({ + brandId: 'safe', + kind: 'actor', + label: 'Safe', + tag: undefined, + }); + + expect(screen.getByText('Safe')).toBeInTheDocument(); + expect(screen.getByLabelText('Safe account')).toBeInTheDocument(); + }); +}); + +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'); + }); + + 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 new file mode 100644 index 0000000000..3fdcef6718 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionGraphNode.tsx @@ -0,0 +1,256 @@ +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 { ANY_ADDR } from '../../constants/permissionSentinels'; +import type { IPermissionGraphNode, PermissionNodeKind } from '../../types'; +import { MembersAvatarIcon, SafeAccountAvatar } from '../permissionEntityIcons'; +import type { IPermissionEdgeEntry } from './permissionGraphEdge'; + +export type PermissionNodeSelectionRole = 'who' | 'where'; + +export interface IPermissionNodeData extends IPermissionGraphNode { + selectionRole?: PermissionNodeSelectionRole; + active?: boolean; + dimmed?: boolean; + sourcePosition?: Position; + targetPosition?: Position; + [key: string]: unknown; +} + +export type IPermissionFlowNode = Node; + +export interface IPermissionStackNodeData { + sourceId?: string; + targetId?: string; + visualKind?: PermissionNodeKind | string; + 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', +}; + +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'; + } + + 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, +}) => { + const { t } = useTranslations(); + const { + kind, + label, + tag, + avatarSrc, + brandId, + address, + selectionRole, + active, + dimmed, + } = data; + const isDaoKind = kind === 'dao' || kind === 'linkedDao'; + const isSafeBody = brandId === 'safe'; + const isAnyoneActor = + kind === 'actor' && address.toLowerCase() === ANY_ADDR.toLowerCase(); + const isSelected = selectionRole != null || active === true; + const subtitleKey = getPermissionNodeTypeKey(data); + + return ( +
+ {selectionRole != null && ( + + {t(SELECTION_LABEL_KEY[selectionRole])} + + )} +
+ +
+ {label} + + {t(subtitleKey)} + +
+ {isDaoKind && ( + + )} + {isSafeBody && } + {kind === 'plugin' && !isSafeBody && tag != null && ( + + )} + {isAnyoneActor && !isSafeBody && } + {kind === 'actor' && !isSafeBody && !isAnyoneActor && ( + + )} +
+
+ ); +}; + +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/permissionNodeDetailPanel.tsx b/apps/app/src/modules/settings/components/permissionsGraph/permissionNodeDetailPanel.tsx new file mode 100644 index 0000000000..a2c439ecc9 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionNodeDetailPanel.tsx @@ -0,0 +1,119 @@ +'use client'; + +import { + AlertCard, + addressUtils, + Button, + ChainEntityType, + DefinitionList, + IconType, + Tag, + useBlockExplorer, +} from '@aragon/gov-ui-kit'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; +import type { IPermissionGraphNode } from '../../types'; +import { getPermissionNodeTypeKey } from './permissionGraphNode'; +import { useDraggablePanel } from './useDraggablePanel'; + +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 { panelRef, headerProps, style } = useDraggablePanel(); + 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 + : buildEntityUrl({ + type: ChainEntityType.ADDRESS, + id: node.address, + }); + + return ( +
+
+
+
+

+ {node.label} +

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

+ {t(getPermissionNodeTypeKey(node))} +

+
+
event.stopPropagation()}> +
+
+
+ {isAnyoneSentinel ? ( + + {t( + 'app.settings.daoPermissionsPage.graphView.detail.anyone.description', + )} + + ) : ( + + + {t(getPermissionNodeTypeKey(node))} + + {!isSentinelAddress && ( + + {addressUtils.truncateAddress(node.address)} + + )} + + )} +
+
+ ); +}; 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..3c3ed18a27 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.test.tsx @@ -0,0 +1,98 @@ +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', () => ({ + 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 new file mode 100644 index 0000000000..49af4c8559 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraph.tsx @@ -0,0 +1,184 @@ +'use client'; + +import { + Button, + CardEmptyState, + IconType, + StateSkeletonBar, +} from '@aragon/gov-ui-kit'; +import '@xyflow/react/dist/style.css'; +import { ReactFlowProvider } from '@xyflow/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'; +import { networkDefinitions } from '@/shared/constants/networkDefinitions'; +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 { PermissionsGraphCanvas } from './permissionsGraphCanvas'; + +export interface IPermissionsGraphProps { + rows: IPermissionRow[]; + dao?: IDao; + daoPlugins?: IFilterComponentPlugin[]; + accountRefs: IPermissionAccountRef[]; + isLoading: boolean; + activeAccountAddress?: string; +} + +export const PermissionsGraph: React.FC = (props) => { + const { + rows, + dao, + daoPlugins, + accountRefs, + isLoading, + activeAccountAddress, + } = 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) { + return { nodes: [], edges: [] }; + } + + return buildPermissionGraph({ rows, dao, daoPlugins, accountRefs }); + }, [rows, dao, daoPlugins, accountRefs]); + + const anchorId = (activeAccountAddress ?? dao?.address ?? '').toLowerCase(); + const visibleEdges = graph.edges; + const visibleNodeIds = new Set( + visibleEdges.flatMap((edge) => [edge.source, edge.target]), + ); + const selectedEdge = visibleEdges.find( + (edge) => edge.id === selectedEdgeId, + ); + const selectedNode = graph.nodes.find( + (node) => node.id === selectedNodeId && visibleNodeIds.has(node.id), + ); + + if (isLoading || dao == null) { + return ; + } + + if (graph.edges.length === 0 || visibleEdges.length === 0) { + return ( + + ); + } + + return ( +
+ + + +
+ ); +}; + +const PermissionsGraphSkeleton: React.FC = () => ( +
+ + + +
+); 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..10c196d225 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.test.ts @@ -0,0 +1,723 @@ +import type { Edge, Node } from '@xyflow/react'; +import type { + IPermissionGraph, + IPermissionGraphEdge, + IPermissionGraphNode, +} from '../../types'; +import { getLayoutedElements } from '../../utils/permissionGraphLayout'; +import { PERMISSION_GRAPH_HANDLE } from './permissionGraphNode'; +import { + alignEdgesWithNodePositions, + buildFlowElements, + getFitViewMinZoom, + getLayoutDirection, + getLayoutSignature, + 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'; + +const buildEdge = ( + id: string, + partial: Pick & + Partial< + Pick< + IPermissionGraphEdge, + 'permissionDisplayName' | 'permissionName' + > + >, +): IPermissionGraphEdge => ({ + id, + permissionDisplayName: 'Permission', + permissionName: 'PERMISSION', + row: { + permissionId: 'permission-id', + whoAddress: partial.source, + whereAddress: partial.target, + conditionAddress: '0x0000000000000000000000000000000000000000', + }, + ...partial, +}); + +const buildGraph = ( + edges: IPermissionGraphEdge[], + nodes: IPermissionGraphNode[] = [], +): IPermissionGraph => ({ + nodes, + edges, +}); + +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'); + }); + + 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', () => { + 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', { + 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, + }); + }); + + 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 proposal creator nodes below their governing body target', () => { + const creatorId = 'governing-body-actor-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, + 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', () => { + it('places core DAO self-permission stacks above the DAO node', () => { + const daoY = 100; + const daoNode = { + id: anchorId, + type: 'permission', + data: { kind: 'dao' }, + measured: { width: 220, height: 92 }, + position: { x: 100, y: daoY }, + } as Node; + const stackNode = { + id: 'permission-stack-dao-self', + type: 'permissionStack', + data: { selfTargetId: anchorId }, + measured: { width: 180, height: 40 }, + position: { x: 0, y: 0 }, + } as Node; + + const result = positionSelfStacks([daoNode, stackNode]); + const positionedStack = result.find( + (node) => node.id === stackNode.id, + )!; + + expect(positionedStack.position.y).toBeLessThan(daoY); + }); + + it('connects core DAO self-permission stacks from stack bottom to DAO top', () => { + const graph: IPermissionGraph = { + nodes: [ + { + id: anchorId, + kind: 'dao', + label: 'DAO', + address: anchorId, + }, + ], + edges: [ + buildEdge('dao-self', { + source: anchorId, + target: anchorId, + }), + ], + }; + + const result = buildFlowElements({ + graph, + anchorId, + visibleEdges: graph.edges, + onSelectEdge: jest.fn(), + }); + + 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('keeps horizontal edges on top/bottom handles', () => { + 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.sourceBottom, + targetHandle: PERMISSION_GRAPH_HANDLE.targetTop, + }); + }); + + 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 locked 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', lockHandles: true }, + }, + ]; + 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, + }); + }); + + 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 new file mode 100644 index 0000000000..3bd4c047d7 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/permissionsGraphCanvas.tsx @@ -0,0 +1,955 @@ +'use client'; + +import { + Background, + Controls, + type Edge, + getViewportForBounds, + MarkerType, + type Node, + Position, + ReactFlow, + useEdgesState, + useNodesInitialized, + useNodesState, + useReactFlow, +} from '@xyflow/react'; +import { useCallback, useEffect, useRef, useState } from 'react'; +import { permissionTransactionUtils } from '@/shared/utils/permissionTransactionUtils'; +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'; + +const nodeTypes = { + permission: PermissionGraphNode, + permissionStack: PermissionStackNode, +}; +const edgeTypes = { permission: PermissionGraphEdge }; + +const MIN_ZOOM = 0.2; +const READABLE_FIT_MIN_ZOOM = 0.45; +const MAX_ZOOM = 2.5; +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 = 92; +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 = + permissionTransactionUtils.permissionIds.executePermission; + +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, + ); + + return hasIncomingEdges ? 'incoming' : 'outgoing'; +}; + +export const getLayoutDirection = ( + visibleEdges: IPermissionGraphEdge[], + anchorId: string, +): PermissionGraphDirection => + getGraphFlow(visibleEdges, anchorId) === 'incoming' ? 'BT' : 'TB'; + +const getLayoutSpacing = (): { nodesep: number; ranksep: number } => ({ + 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, +): { + sourcePosition: Position; + targetPosition: Position; +} => { + if (flow === 'incoming') { + return { + sourcePosition: Position.Top, + targetPosition: Position.Bottom, + }; + } + + 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 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[]) + : []; + +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 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 targetIsBelowOrLevel = targetCenter.y >= sourceCenter.y; + + return targetIsBelowOrLevel + ? { + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceBottom, + targetHandle: PERMISSION_GRAPH_HANDLE.targetTop, + } + : { + sourceHandle: PERMISSION_GRAPH_HANDLE.sourceTop, + targetHandle: PERMISSION_GRAPH_HANDLE.targetBottom, + }; +}; + +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])); + + const alignedEdges = edges.map((edge) => { + if (edge.data?.lockHandles === true) { + 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), + }; + }); + + return enforceNodeMarkerHandleSeparation( + enforceOppositeStackSides(alignedEdges), + ); +}; + +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, + }; +}; + +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])); + + return nodes.map((node) => { + if (node.type !== 'permissionStack') { + return node; + } + + const stackRect = getNodeRect(node); + const selfTargetId = node.data?.selfTargetId; + + 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, + }, + }; + } + + return node; + }); +}; + +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, + 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; + +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; + visibleEdges: IPermissionGraphEdge[]; + anchorId: string; + selectedEdgeId?: string; + selectedNodeId?: string; + onSelectEdge: (edgeId: string) => void; +} + +export const buildFlowElements = ({ + graph, + visibleEdges, + anchorId, + selectedEdgeId, + selectedNodeId, + onSelectEdge, +}: IBuildFlowElementsParams): { nodes: Node[]; edges: Edge[] } => { + const visibleNodeIds = new Set( + visibleEdges.flatMap((edge) => [edge.source, edge.target]), + ); + const selectedEdge = + selectedEdgeId != null + ? visibleEdges.find((edge) => edge.id === selectedEdgeId) + : undefined; + + const graphFlow = getGraphFlow(visibleEdges, anchorId); + const handlePositions = getHandlePositions(graphFlow); + 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; + const isSelectedNode = selectedNodeId === node.id; + + return { + draggable: false, + id: node.id, + type: 'permission', + position: UNPOSITIONED, + data: { + ...node, + ...handlePositions, + selectionRole, + active: isSelectedNode, + dimmed: + (selectedEdge != null && selectionRole == null) || + (selectedNodeId != null && !isSelectedNode), + }, + }; + }); + + const pairKey = (source: string, target: string) => `${source}-${target}`; + const groups = new Map< + string, + { source: string; target: string; entries: IPermissionEdgeEntry[] } + >(); + + for (const edge of visibleEdges) { + 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, + permissionDisplayName: edge.permissionDisplayName, + permissionName: edge.permissionName, + conditionLabel: edge.conditionLabel, + selected: selectedEdgeId === edge.id, + }); + groups.set(key, group); + } + + const stackNodes: Node[] = []; + const edges: Edge[] = []; + 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); + 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, + anchorId, + ); + 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', + position: UNPOSITIONED, + draggable: false, + sourcePosition: handlePositions.sourcePosition, + targetPosition: handlePositions.targetPosition, + data: { + permissions: group.entries, + active, + dimmed, + ...handlePositions, + sourceId: group.source, + targetId: group.target, + visualKind, + ...(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: active ? getEdgeMarker(true) : undefined, + 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: active ? getOriginMarker(true) : undefined, + style: getEdgeStyle(active), + zIndex: active ? SELECTED_EDGE_Z_INDEX : undefined, + data: { + ...edgeData, + permissionStackId: stackId, + stackConnection: 'origin', + ...originLayoutData, + }, + }); + + edges.push({ + id: `${stackId}-target`, + source: stackId, + sourceHandle: edgeHandles.stackSource, + target: group.target, + targetHandle: edgeHandles.targetTarget, + type: 'permission', + animated: active, + markerEnd: active ? getEdgeMarker(true) : undefined, + style: getEdgeStyle(active), + zIndex: active ? SELECTED_EDGE_Z_INDEX : undefined, + data: { + ...edgeData, + permissionStackId: stackId, + stackConnection: 'target', + ...targetLayoutData, + }, + }); + } + + return { nodes: [...nodes, ...stackNodes], edges }; +}; + +export interface IPermissionsGraphCanvasProps { + anchorId: string; + graph: IPermissionGraph; + selectedEdgeId?: string; + selectedNodeId?: string; + onSelectedEdgeChange: (edgeId?: string) => void; + onSelectedNodeChange: (nodeId?: string) => void; +} + +export const PermissionsGraphCanvas: React.FC = ({ + anchorId, + graph, + selectedEdgeId, + selectedNodeId, + onSelectedEdgeChange, + onSelectedNodeChange, +}) => { + const visibleEdges = graph.edges; + const [nodes, setNodes, onNodesChange] = useNodesState([]); + const [edges, setEdges, onEdgesChange] = useEdgesState([]); + 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) => { + onSelectedNodeChange(undefined); + onSelectedEdgeChange( + selectedEdgeId === edgeId ? undefined : edgeId, + ); + }, + [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, + getFitViewMinZoom(bounds, { + width: container.clientWidth, + height: container.clientHeight, + }), + MAX_ZOOM, + FIT_PADDING, + ); + + void setViewport(viewport, { duration: FIT_DURATION }); + }, [setViewport]); + + useEffect(() => { + const currentNodes = getNodes(); + const previousPositions = new Map( + currentNodes.map((node) => [node.id, node.position]), + ); + const { nodes: nextNodes, edges: nextEdges } = buildFlowElements({ + graph, + anchorId, + visibleEdges, + selectedEdgeId, + selectedNodeId, + onSelectEdge: selectEdge, + }); + + setNodes( + nextNodes.map((node) => ({ + ...node, + position: previousPositions.get(node.id) ?? node.position, + })), + ); + setEdges(nextEdges); + }, [ + graph, + anchorId, + visibleEdges, + selectedEdgeId, + selectedNodeId, + selectEdge, + getNodes, + setNodes, + setEdges, + ]); + + useEffect(() => { + if (!nodesInitialized || nodes.length === 0) { + return; + } + + const currentNodes = getNodes(); + const topologySignature = getLayoutSignature(currentNodes, edges); + + if (layoutSignature.current === topologySignature) { + return; + } + const { nodes: rawLayoutedNodes } = getLayoutedElements( + currentNodes, + edges, + { + direction: getLayoutDirection(visibleEdges, anchorId), + ...getLayoutSpacing(), + }, + ); + const layoutedNodes = positionSelfStacks(rawLayoutedNodes); + const alignedEdges = alignEdgesWithNodePositions(layoutedNodes, edges); + + layoutSignature.current = topologySignature; + graphBounds.current = getGraphBounds(layoutedNodes); + setNodes(layoutedNodes); + setEdges(alignedEdges); + setLayoutVersion((version) => version + 1); + }, [ + anchorId, + nodesInitialized, + nodes, + edges, + visibleEdges, + getNodes, + setNodes, + setEdges, + ]); + + useEffect(() => { + if (layoutVersion === 0 || graphBounds.current == null) { + return; + } + + const frame = requestAnimationFrame(() => { + fitReadableBounds(); + }); + + return () => cancelAnimationFrame(frame); + }, [fitReadableBounds, layoutVersion]); + + return ( +
+ { + if (node.type !== 'permission') { + return; + } + + onSelectedEdgeChange(undefined); + onSelectedNodeChange( + selectedNodeId === node.id ? undefined : node.id, + ); + }} + onNodesChange={onNodesChange} + onPaneClick={() => { + onSelectedEdgeChange(undefined); + onSelectedNodeChange(undefined); + }} + proOptions={{ hideAttribution: true }} + > + + + + +
+ ); +}; diff --git a/apps/app/src/modules/settings/components/permissionsGraph/useDraggablePanel.ts b/apps/app/src/modules/settings/components/permissionsGraph/useDraggablePanel.ts new file mode 100644 index 0000000000..4ec8d712c0 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsGraph/useDraggablePanel.ts @@ -0,0 +1,115 @@ +import type React from 'react'; +import { useCallback, useRef, useState } from 'react'; + +interface IPosition { + x: number; + y: number; +} + +const INITIAL_POSITION: IPosition = { x: 16, y: 16 }; +const PANEL_MARGIN = 16; + +/** + * Drags an absolute-positioned overlay panel within its parent container. + * + * Returns the refs, position, and pointer handlers both panels need — pulled out + * so the permission-detail and node-detail panels share one implementation. + * The parent container is `panelRef.current?.parentElement`. + */ +export const useDraggablePanel = () => { + const panelRef = useRef(null); + const dragOffsetRef = useRef(undefined); + const [position, setPosition] = useState(INITIAL_POSITION); + const [isDragging, setIsDragging] = useState(false); + + const clampPosition = useCallback((next: IPosition): IPosition => { + const panel = panelRef.current; + const container = panel?.parentElement; + + if (panel == null || container == null) { + return next; + } + + const maxX = Math.max( + PANEL_MARGIN, + container.clientWidth - panel.offsetWidth - PANEL_MARGIN, + ); + const maxY = Math.max( + PANEL_MARGIN, + container.clientHeight - panel.offsetHeight - PANEL_MARGIN, + ); + + return { + x: Math.min(Math.max(next.x, PANEL_MARGIN), maxX), + y: Math.min(Math.max(next.y, PANEL_MARGIN), maxY), + }; + }, []); + + const handleDragStart = useCallback( + (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 = useCallback( + (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)); + }, + [clampPosition, isDragging], + ); + + const handleDragEnd = useCallback( + (event: React.PointerEvent) => { + dragOffsetRef.current = undefined; + setIsDragging(false); + + if (event.currentTarget.hasPointerCapture(event.pointerId)) { + event.currentTarget.releasePointerCapture(event.pointerId); + } + }, + [], + ); + + const headerProps: React.HTMLAttributes = { + onPointerCancel: handleDragEnd, + onPointerDown: handleDragStart, + onPointerMove: handleDragMove, + onPointerUp: handleDragEnd, + }; + + return { + panelRef, + position, + headerProps, + style: { left: position.x, top: position.y } as React.CSSProperties, + }; +}; 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..5f371c7e86 100644 --- a/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsList.test.tsx @@ -1,88 +1,62 @@ 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 { 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'; -import { PermissionsList } from './permissionsList'; +import { + getPermissionRowKey, + type IPermissionsListProps, + PermissionsList, +} from './permissionsList'; const ROOT_PERMISSION_ID = '0x815fe80e4b37c8582a3b773d1d7071f983eacfd56b5965db654f3087c25ada33'; const EXECUTE_PERMISSION_ID = '0xbf04b4486c9663d805744005c3da000eda93de6e3308a4a7a812eb565327b78d'; +const SET_TRUSTED_FORWARDER_PERMISSION_ID = + '0x06d294bc8cbad2e393408b20dd019a772661f60b8d633e56761157cb1ec85f8c'; +const SPP_PLUGIN_ADDRESS = '0x26A696269116cAaB99626Cc793CeA24bbCec7528'; 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', - ); - - const setFeatureFlags = (linkedAccountEnabled: boolean) => { - useFeatureFlagsSpy.mockReturnValue({ - isEnabled: (key) => key === 'linkedAccount' && linkedAccountEnabled, - } as ReturnType); - }; - - const setDao = (dao?: Partial) => { - useDaoSpy.mockReturnValue( - generateReactQueryResultSuccess({ - data: generateDao(dao), - }) as ReturnType, - ); - }; + beforeAll(() => { + initialiseConditionRegistry(); + }); - const setPermissions = ( - result: Partial>, - ) => { - useAllDaoPermissionsSpy.mockReturnValue({ - data: [], + const createTestComponent = (props?: Partial) => { + const completeProps: IPermissionsListProps = { + rows: [], + accountRefs: [], + daoPlugins: [], + chainId: undefined, isLoading: false, - error: null, - refetch: jest.fn(), - ...result, - } as ReturnType); + expandedRows: [], + onExpandedRowsChange: jest.fn(), + ...props, + }; + + return ( + + + + ); }; - beforeEach(() => { - setFeatureFlags(false); - setDao(); - setPermissions({ data: [], isLoading: false }); - useDaoPluginsSpy.mockReturnValue([]); - }); + const getMobileList = (container: HTMLElement) => { + const mobileList = container.querySelector( + '[class~="md:hidden"]', + ); - afterEach(() => { - jest.clearAllMocks(); - }); + if (mobileList == null) { + throw new Error('Mobile permissions list not found'); + } - const createTestComponent = (props?: { daoId?: string }) => ( - - - - ); + return mobileList; + }; 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 +66,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,20 +93,110 @@ 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(); + 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/), + screen.getAllByText(/permissionsList.header.condition/).length, + ).toBeGreaterThan(0); + }); + + 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.getAllByText('Backend SPP').length).toBeGreaterThan(0); + 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 the members icon for Anyone entities', () => { + const rows: IPermissionRow[] = [ + { + permissionId: ROOT_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: ALLOW_FLAG, + }, + ]; + + render(createTestComponent({ rows })); + + expect(screen.getAllByText('Anyone').length).toBeGreaterThan(0); + expect(screen.getAllByLabelText('Members').length).toBeGreaterThan(0); + }); + + 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', () => { + it('renders the collapsed condition cell with the resolved label or a dash', () => { const rows: IPermissionRow[] = [ { permissionId: ROOT_PERMISSION_ID, @@ -150,17 +212,144 @@ 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(); + expect(screen.getAllByText('VotingPower').length).toBeGreaterThan(0); + expect(screen.getAllByText('-').length).toBeGreaterThan(0); }); - it('renders both the Details and Condition lists when a row is expanded', async () => { - initialiseConditionRegistry(); - const user = userEvent.setup(); + it('keys rows by condition address so distinct conditions do not collide', () => { + const baseRow: IPermissionRow = { + permissionId: EXECUTE_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: '0xC0Ffee254729296a45a3885639AC7E10F9d54979', + }; + const matchingRuleRow: IPermissionRow = { + ...baseRow, + conditionAddress: '0xDeaDbeefdEAdbeefdEadbEEFdeadbeEFdEaDbeeF', + }; + + expect(getPermissionRowKey(baseRow)).not.toEqual( + getPermissionRowKey(matchingRuleRow), + ); + }); + + it('renders unresolved condition labels explicitly', () => { + const rows: IPermissionRow[] = [ + { + permissionId: EXECUTE_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: '0xC0Ffee254729296a45a3885639AC7E10F9d54979', + }, + ]; + + render(createTestComponent({ rows })); + + const conditionLabels = screen.getAllByText('Unrecognized condition'); + 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', () => { + 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).getAllByText('SET_TRUSTED_FORWARDER_PERMISSION') + .length, + ).toBeGreaterThanOrEqual(2); + expect( + within(mobileList).queryByRole('radio', { + name: /permissionsList.details.permission/, + }), + ).not.toBeInTheDocument(); + expect( + within(mobileList).queryByRole('radio', { + name: /permissionsList.details.condition/, + }), + ).not.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 the graph-popout condition summary on conditioned mobile cards', () => { + const rows: IPermissionRow[] = [ + { + permissionId: EXECUTE_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: '0xC0Ffee254729296a45a3885639AC7E10F9d54979', + }, + ]; + + const { container } = render(createTestComponent({ rows })); + const mobileList = getMobileList(container); + + expect( + within(mobileList).getByText( + /daoPermissionsPage\.graphView\.edge\.condition/, + ), + ).toBeInTheDocument(); + }); + + it('renders both the Details and Condition lists for an expanded row', async () => { const rows: IPermissionRow[] = [ { permissionId: EXECUTE_PERMISSION_ID, @@ -174,29 +363,27 @@ 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( - screen.getByText(/permissionsList.details.heading/), - ).toBeInTheDocument(); + screen.getAllByText(/permissionsList.details.heading/).length, + ).toBeGreaterThan(0); expect( 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 +392,64 @@ describe(' component', () => { conditionAddress: ALLOW_FLAG, }, ]; - setPermissions({ data: rows, isLoading: false }); - - render(createTestComponent()); - await user.click( - screen.getByRole('button', { name: /permissionsList.expandAll/ }), + render( + createTestComponent({ + rows, + expandedRows: [getPermissionRowKey(rows[0])], + }), ); - expect(screen.getByText(/noConditionSlot.heading/)).toBeInTheDocument(); + expect( + screen.getByTestId('no-condition-placeholder'), + ).toHaveTextContent('-'); }); - 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', - }, + it('does not render an explicit no-condition detail label', () => { + const rows: IPermissionRow[] = [ + { + permissionId: ROOT_PERMISSION_ID, + whoAddress: ANY_ADDR, + whereAddress: ALLOW_FLAG, + conditionAddress: ALLOW_FLAG, + }, + ]; + + render( + createTestComponent({ + rows, + expandedRows: [getPermissionRowKey(rows[0])], }), - expect.anything(), ); - await user.click( - screen.getByRole('radio', { name: 'Linked Treasury' }), - ); + expect( + screen.queryByText(/permissionsList.details.noCondition/), + ).not.toBeInTheDocument(); + expect( + screen.getByTestId('no-condition-placeholder'), + ).toHaveTextContent('-'); + }); - expect(useAllDaoPermissionsSpy).toHaveBeenLastCalledWith( - expect.objectContaining({ - urlParams: { - network: Network.POLYGON_MAINNET, - daoAddress: '0xLinkedAddress', - }, + 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.anything(), ); + + 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 f7a68de62a..59efd538f5 100644 --- a/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsList.tsx @@ -3,65 +3,48 @@ import { Accordion, addressUtils, - Button, CardEmptyState, ChainEntityType, DaoAvatar, DefinitionList, - Link, StateSkeletonBar, + StateSkeletonCircular, 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, Network } 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 { ALLOW_FLAG, ANY_ADDR } from '../../constants/permissionSentinels'; import type { IPermissionRow } from '../../types'; -import { conditionTypeUtils } from '../../utils/conditionTypeUtils'; +import { + conditionTypeUtils, + type IConditionDisplay, +} from '../../utils/conditionTypeUtils'; import { type IPermissionAccountRef, type IPermissionEntity, permissionEntityUtils, } from '../../utils/permissionEntityUtils'; import { NoConditionSlot } from '../noConditionSlot'; +import { MembersAvatarIcon, SafeAccountAvatar } from '../permissionEntityIcons'; +import { PermissionInfoTooltip } from '../permissionInfoTooltip'; +import { PermissionDetailCard } from '../permissionsGraph/permissionDetailPanel'; +import { UnrecognizedConditionSlot } from '../unrecognizedConditionSlot'; -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,160 +54,61 @@ const SKELETON_ROW_KEYS = [ 'skeleton-4', ]; -const getRowKey = (row: IPermissionRow): string => - `${row.permissionId}-${row.whoAddress}-${row.whereAddress}`; +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 { 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 }, - ); + if (isLoading) { + return ; + } - // 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, + if (rows.length === 0) { + return ( + ); + } - 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 ( - - ); - } - - return ( -
+ return ( +
+
+ {rows.map((row) => ( + + ))} +
+
setExpandedRows(value ?? [])} + onValueChange={(value) => onExpandedRowsChange(value ?? [])} value={expandedRows} > {rows.map((row) => ( @@ -232,67 +116,59 @@ export const PermissionsList: React.FC = (props) => { accounts={accountRefs} chainId={chainId} daoPlugins={daoPlugins} - key={getRowKey(row)} + key={getPermissionRowKey(row)} + network={row.network} row={row} - rowKey={getRowKey(row)} + rowKey={getPermissionRowKey(row)} /> ))}
- ); - }; - - const showAccountSelector = accounts.length > 1; - const showExpandAll = !isLoading && rows.length > 0; - - return ( -
-
- {showAccountSelector && ( - - {accounts.map((account) => ( - - ))} - - )} -
- {showExpandAll && ( - - )} - {viewSwitcher} -
-
- {renderBody()}
); }; -type DaoPlugins = ReturnType; - interface IPermissionsListRowProps { row: IPermissionRow; rowKey: string; daoPlugins: DaoPlugins; accounts: IPermissionAccountRef[]; chainId?: number; + network?: Network; +} + +interface IResolvedPermissionRow { + who: IPermissionEntity; + where: IPermissionEntity; + permissionName: string; + condition: IConditionDisplay; } +// Resolves the display entities a permission row needs, shared by the desktop +// accordion row and the mobile card so who/where/condition are derived once. +const resolvePermissionRow = ( + row: IPermissionRow, + options: { daoPlugins: DaoPlugins; accounts: IPermissionAccountRef[] }, +): IResolvedPermissionRow => { + const resolveOptions = { + daoPlugins: options.daoPlugins, + accounts: options.accounts, + }; + + return { + who: permissionEntityUtils.resolvePermissionEntity(row.whoAddress, { + ...resolveOptions, + entity: row.who, + }), + where: permissionEntityUtils.resolvePermissionEntity(row.whereAddress, { + ...resolveOptions, + entity: row.where, + }), + permissionName: permissionNameUtils.getPermissionName(row.permissionId), + condition: conditionTypeUtils.resolveConditionDisplay(row), + }; +}; + interface IPermissionEntityCellProps { entity: IPermissionEntity; } @@ -305,116 +181,135 @@ const PermissionEntityCell: React.FC = ({ {entity.type === 'dao' && ( )} - {entity.type === 'plugin' && entity.tag != null && ( - - )} - {entity.type === 'sentinel' && ( -