From 8f5ede4daeaaaa56ec7747fbf14a3c527b4c3194 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Mon, 3 Aug 2026 10:37:39 +0200 Subject: [PATCH 01/10] feat(APP-1003): Add responsive permissions list with condition slots --- apps/app/src/assets/locales/en.json | 85 +-- .../allowedActionsList.tsx | 74 +++ .../allowedActionsSkeleton.tsx | 11 + .../decodedAllowedActionsList.tsx | 71 ++ .../executeSelectorConditionSlot.test.tsx | 140 +++- .../executeSelectorConditionSlot.tsx | 84 ++- .../executeSelectorConditionSlotUtils.ts | 74 +++ .../membershipConditionSlot.test.tsx | 6 +- .../membershipConditionSlot.tsx | 17 +- .../noConditionSlot/noConditionSlot.test.tsx | 17 +- .../noConditionSlot/noConditionSlot.tsx | 22 +- .../components/permissionCondition/index.ts | 4 + .../permissionCondition.test.tsx | 76 +++ .../permissionCondition.tsx | 34 + .../components/permissionEntityIcons/index.ts | 2 + .../membersAvatarIcon.tsx | 15 + .../safeAccountAvatar.tsx | 17 + .../components/permissionInfoTooltip/index.ts | 2 + .../permissionInfoTooltip.tsx | 39 ++ .../components/permissionsList/index.ts | 1 + .../permissionAddressListItem.tsx | 36 ++ .../permissionsList/permissionDetailsList.tsx | 68 ++ .../permissionsList/permissionEntityCell.tsx | 43 ++ .../permissionEntityListItem.tsx | 42 ++ .../permissionsList/permissionsList.test.tsx | 562 ++++++++++------ .../permissionsList/permissionsList.tsx | 604 ++---------------- .../permissionsList/permissionsListHeader.tsx | 38 ++ .../permissionsListHeaderLabel.tsx | 24 + .../permissionsList/permissionsListRow.tsx | 161 +++++ .../permissionsListSkeleton.tsx | 32 + .../unrecognizedConditionSlot/index.ts | 1 + .../unrecognizedConditionSlot.test.tsx | 45 ++ .../unrecognizedConditionSlot.tsx | 50 ++ .../votingPowerConditionSlot.test.tsx | 33 +- .../votingPowerConditionSlot.tsx | 39 +- .../modules/settings/constants/moduleSlots.ts | 2 +- .../settings/constants/permissionsMocks.ts | 118 ---- .../constants/permissionsPreviewData.ts | 77 --- .../constants/permissionsPreviewRefs.ts | 21 - .../settings/initConditionRegistry.test.ts | 20 +- .../modules/settings/initConditionRegistry.ts | 24 +- .../daoPermissionsPage/daoPermissionsPage.tsx | 6 - .../daoPermissionsPageClient.tsx | 238 ++++++- apps/app/src/modules/settings/types/index.ts | 1 - .../modules/settings/types/permissionRow.ts | 31 - .../api/daoService/domain/daoPermission.ts | 2 +- 46 files changed, 1953 insertions(+), 1156 deletions(-) create mode 100644 apps/app/src/modules/settings/components/executeSelectorConditionSlot/allowedActionsList.tsx create mode 100644 apps/app/src/modules/settings/components/executeSelectorConditionSlot/allowedActionsSkeleton.tsx create mode 100644 apps/app/src/modules/settings/components/executeSelectorConditionSlot/decodedAllowedActionsList.tsx create mode 100644 apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlotUtils.ts create mode 100644 apps/app/src/modules/settings/components/permissionCondition/index.ts create mode 100644 apps/app/src/modules/settings/components/permissionCondition/permissionCondition.test.tsx create mode 100644 apps/app/src/modules/settings/components/permissionCondition/permissionCondition.tsx create mode 100644 apps/app/src/modules/settings/components/permissionEntityIcons/index.ts create mode 100644 apps/app/src/modules/settings/components/permissionEntityIcons/membersAvatarIcon.tsx create mode 100644 apps/app/src/modules/settings/components/permissionEntityIcons/safeAccountAvatar.tsx create mode 100644 apps/app/src/modules/settings/components/permissionInfoTooltip/index.ts create mode 100644 apps/app/src/modules/settings/components/permissionInfoTooltip/permissionInfoTooltip.tsx create mode 100644 apps/app/src/modules/settings/components/permissionsList/permissionAddressListItem.tsx create mode 100644 apps/app/src/modules/settings/components/permissionsList/permissionDetailsList.tsx create mode 100644 apps/app/src/modules/settings/components/permissionsList/permissionEntityCell.tsx create mode 100644 apps/app/src/modules/settings/components/permissionsList/permissionEntityListItem.tsx create mode 100644 apps/app/src/modules/settings/components/permissionsList/permissionsListHeader.tsx create mode 100644 apps/app/src/modules/settings/components/permissionsList/permissionsListHeaderLabel.tsx create mode 100644 apps/app/src/modules/settings/components/permissionsList/permissionsListRow.tsx create mode 100644 apps/app/src/modules/settings/components/permissionsList/permissionsListSkeleton.tsx create mode 100644 apps/app/src/modules/settings/components/unrecognizedConditionSlot/index.ts create mode 100644 apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.test.tsx create mode 100644 apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.tsx delete mode 100644 apps/app/src/modules/settings/constants/permissionsMocks.ts delete mode 100644 apps/app/src/modules/settings/constants/permissionsPreviewData.ts delete mode 100644 apps/app/src/modules/settings/constants/permissionsPreviewRefs.ts delete mode 100644 apps/app/src/modules/settings/types/permissionRow.ts diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index b158688a90..81646842bc 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -1432,31 +1432,6 @@ } }, "daos": { - "alchemix": { - "alchemixSubmitVote": { - "alsoVote": { - "description": "Cast the same option with the voting power delegated to you in the same transaction.", - "label": "Also vote with your voting power" - }, - "buttons": { - "override": "Override vote" - }, - "delegateNotVoted": "Hasn't voted yet · delegated your {{amount}} {{symbol}}", - "delegateOverridden": "No longer votes with your {{amount}} {{symbol}}", - "delegateTag": "Your delegate", - "delegateVoted": "Voted with your {{amount}} {{symbol}}", - "delegateVotedOverridden": "Voted without your {{amount}} {{symbol}}", - "options": { - "alreadyCounted": "Your tokens already count toward {{option}}", - "currentVote": "Your current vote" - }, - "position": { - "overrode": "Voting with {{amount}} {{symbol}} · overrode {{delegate}}", - "voted": "Voting with {{amount}} {{symbol}}" - }, - "youTag": "You" - } - }, "aragonDemo": { "aragonDemoPageHeader": { "cta": "Create proposal", @@ -3494,10 +3469,6 @@ "approveYesDescription": ", I approve", "label": "Do you want to {{label}} this proposal?", "no": "No", - "object": "Object", - "objectionDescription": ", I object", - "objectionHelpText": "During the objection phase you can only vote \"No\" to object to the proposal.", - "objectionLabel": "object to", "vetoLabel": "veto", "vetoNoDescription": ", I don't veto", "vetoYesDescription": ", I veto", @@ -3505,7 +3476,6 @@ }, "voteDescription": { "approve": "to approve", - "objection": "to object", "veto": "to veto" } }, @@ -3602,12 +3572,56 @@ "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 subplugin permissions", + "hideGoverningBodyPermissionsTooltip": "Hides permissions whose target (Where) is identified as a subplugin.", + "hideGoverningBodyPermissionsTooltipLabel": "About subplugin 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" }, @@ -3616,11 +3630,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", @@ -3629,10 +3642,6 @@ "description": "This account has no indexed permissions." } }, - "noConditionSlot": { - "heading": "No condition", - "description": "Functions guarded by this permission can be called by the granted address directly." - }, "votingPowerConditionSlot": { "token": "Token", "minVotingPower": "Minimum voting power" @@ -3640,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/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/allowedActionsSkeleton.tsx b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/allowedActionsSkeleton.tsx new file mode 100644 index 0000000000..1cd0e7e293 --- /dev/null +++ b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/allowedActionsSkeleton.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { StateSkeletonBar } from '@aragon/gov-ui-kit'; + +export const AllowedActionsSkeleton: React.FC = () => ( +
+ + + +
+); 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..b11136a306 --- /dev/null +++ b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/decodedAllowedActionsList.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useAllowedActions } from '@/modules/governance/api/executeSelectorsService'; +import type { Network } from '@/shared/api/daoService'; +import { AllowedActionsList } from './allowedActionsList'; +import { AllowedActionsSkeleton } from './allowedActionsSkeleton'; +import { + hasDecodedAllowedAction, + type IRawAllowedAction, + toAllowedActionViews, +} from './executeSelectorConditionSlotUtils'; + +interface IDecodedAllowedActionsListProps { + chainId?: number; + conditionAddress?: string; + network: Network; + pluginAddress: string; + rawAllowedActions: IRawAllowedAction[]; +} + +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..3efde8b6c8 100644 --- a/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx +++ b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.test.tsx @@ -1,11 +1,30 @@ import { GukModulesProvider } from '@aragon/gov-ui-kit'; import { render, screen } from '@testing-library/react'; -import type { IConditionData } from '@/modules/settings/types'; +import { + type IAllowedAction, + useAllowedActions, +} from '@/modules/governance/api/executeSelectorsService'; +import { type IDaoPermissionCondition, 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 createTestComponent = (props?: Partial) => { - const completeProps: IConditionData = { + const useAllowedActionsMock = jest.mocked(useAllowedActions); + type ExecuteSelectorConditionProps = IDaoPermissionCondition & { + chainId?: number; + conditionAddress?: string; + network?: Network; + pluginAddress?: string; + }; + + const createTestComponent = ( + props?: Partial, + ) => { + const completeProps: ExecuteSelectorConditionProps = { conditionType: 'execute-selector', ...props, }; @@ -17,6 +36,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 +79,89 @@ 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('falls back to raw actions when the backend has no decoded match', () => { + const target = '0x0bA45A8b5d5575935B8158a88C631E9F9C95a2e5'; + + mockAllowedActions([]); + + render( + createTestComponent({ + conditionAddress: '0xC0Ffee254729296a45a3885639AC7E10F9d54979', + network: Network.ARBITRUM_MAINNET, + pluginAddress: '0x1234567890123456789012345678901234567890', + selectors: ['0xaaaaaaaa'], + targets: [target], + }), + ); + + expect(useAllowedActionsMock).toHaveBeenCalled(); + expect(screen.getAllByText('0xaaaaaaaa')).toHaveLength(2); + expect(screen.getByText('0x0bA4…a2e5')).toBeInTheDocument(); + expect(screen.queryByText('AddressGaugeVoter')).not.toBeInTheDocument(); + }); + it('shows the no allowed actions fallback when selectors are absent', () => { render(createTestComponent({ selectors: undefined })); @@ -50,7 +175,8 @@ describe(' component', () => { createTestComponent({ selectors: ['0xaaaaaaaa', 42, null, ''] }), ); - expect(screen.getByText('0xaaaaaaaa')).toBeInTheDocument(); + expect(screen.getAllByText('0xaaaaaaaa')).toHaveLength(2); + expect(screen.queryByText('42')).not.toBeInTheDocument(); expect( screen.queryByText(/executeSelectorConditionSlot.noActions/), ).not.toBeInTheDocument(); @@ -59,7 +185,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..03f42b2f67 100644 --- a/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx +++ b/apps/app/src/modules/settings/components/executeSelectorConditionSlot/executeSelectorConditionSlot.tsx @@ -1,60 +1,56 @@ 'use client'; -import { addressUtils, DefinitionList } from '@aragon/gov-ui-kit'; -import type { IConditionData } from '@/modules/settings/types'; +import type { IDaoPermissionCondition, 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 IDaoPermissionCondition { + 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; +export const ExecuteSelectorConditionSlot: React.FC< + IExecuteSelectorConditionSlotProps +> = ({ + selectors, + targets, + chainId, + conditionAddress, + network, + pluginAddress, +}) => { 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.test.tsx b/apps/app/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.test.tsx index 7fa42cdaa8..fc96240df8 100644 --- a/apps/app/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.test.tsx +++ b/apps/app/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.test.tsx @@ -1,11 +1,11 @@ import { GukModulesProvider } from '@aragon/gov-ui-kit'; import { render, screen } from '@testing-library/react'; -import type { IConditionData } from '@/modules/settings/types'; +import type { IDaoPermissionCondition } from '@/shared/api/daoService'; import { MembershipConditionSlot } from './membershipConditionSlot'; describe(' component', () => { - const createTestComponent = (props?: Partial) => { - const completeProps: IConditionData = { + const createTestComponent = (props?: Partial) => { + const completeProps: IDaoPermissionCondition = { conditionType: 'membership', ...props, }; diff --git a/apps/app/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.tsx b/apps/app/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.tsx index 408a8726e3..c81b4d0da9 100644 --- a/apps/app/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.tsx +++ b/apps/app/src/modules/settings/components/membershipConditionSlot/membershipConditionSlot.tsx @@ -1,7 +1,7 @@ 'use client'; import { DefinitionList, Tag } from '@aragon/gov-ui-kit'; -import type { IConditionData } from '@/modules/settings/types'; +import type { IDaoPermissionCondition } from '@/shared/api/daoService'; import { useTranslations } from '@/shared/components/translationsProvider'; /** @@ -10,8 +10,10 @@ import { useTranslations } from '@/shared/components/translationsProvider'; * `onlyListed` setting is enabled, so that boolean is surfaced as * "Member of multisig". */ -export const MembershipConditionSlot: React.FC = (props) => { - const { onlyListed } = props; +export const MembershipConditionSlot: React.FC = ({ + onlyListed, + minApprovals, +}) => { const { t } = useTranslations(); const isMemberGated = onlyListed === true; @@ -34,6 +36,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/permissionCondition/index.ts b/apps/app/src/modules/settings/components/permissionCondition/index.ts new file mode 100644 index 0000000000..56dada0e83 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionCondition/index.ts @@ -0,0 +1,4 @@ +export { + type IPermissionConditionProps, + PermissionCondition, +} from './permissionCondition'; diff --git a/apps/app/src/modules/settings/components/permissionCondition/permissionCondition.test.tsx b/apps/app/src/modules/settings/components/permissionCondition/permissionCondition.test.tsx new file mode 100644 index 0000000000..abf06e8c23 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionCondition/permissionCondition.test.tsx @@ -0,0 +1,76 @@ +import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import type { IDaoPermission } from '@/shared/api/daoService'; +import { generateDaoPermission } from '@/shared/testUtils'; +import { ALLOW_FLAG } from '../../constants/permissionSentinels'; +import { initialiseConditionRegistry } from '../../initConditionRegistry'; +import { PermissionCondition } from './permissionCondition'; + +const conditionAddress = '0xC0Ffee254729296a45a3885639AC7E10F9d54979'; + +const buildRow = (partial?: Partial): IDaoPermission => + generateDaoPermission({ + conditionAddress: ALLOW_FLAG, + condition: undefined, + conditionEntity: undefined, + network: undefined, + who: undefined, + where: undefined, + ...partial, + }); + +describe(' component', () => { + beforeAll(() => { + initialiseConditionRegistry(); + }); + + // The registry is the sole dispatch authority; these four cases pin every outcome of the + // resolved condition type: sentinel, registered, unresolvable, and present-but-unregistered. + it.each([ + { + name: 'no condition (ALLOW_FLAG) to the no-condition slot', + row: buildRow(), + expectedTestId: 'no-condition-placeholder', + }, + { + name: 'a registered condition type to its slot', + row: buildRow({ + conditionAddress, + condition: { + conditionType: 'voting-power', + token: '0x0bA45A8b5d5575935B8158a88C631E9F9C95a2e5', + minVotingPower: '1000000000000000000', + }, + }), + expectedText: /votingPowerConditionSlot.token/, + }, + { + name: 'an unresolvable payload to the unrecognized slot', + row: buildRow({ conditionAddress }), + expectedTestId: 'unrecognized-condition', + }, + { + name: 'a present-but-unregistered type to the unrecognized fallback', + row: buildRow({ + conditionAddress, + condition: { conditionType: 'merkle-claim' }, + }), + expectedTestId: 'unrecognized-condition', + }, + ])('dispatches $name', async ({ row, expectedTestId, expectedText }) => { + render( + + + , + ); + + if (expectedTestId != null) { + expect( + await screen.findByTestId(expectedTestId), + ).toBeInTheDocument(); + } + if (expectedText != null) { + expect(await screen.findByText(expectedText)).toBeInTheDocument(); + } + }); +}); diff --git a/apps/app/src/modules/settings/components/permissionCondition/permissionCondition.tsx b/apps/app/src/modules/settings/components/permissionCondition/permissionCondition.tsx new file mode 100644 index 0000000000..9632de7634 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionCondition/permissionCondition.tsx @@ -0,0 +1,34 @@ +'use client'; + +import type { IDaoPermission, Network } from '@/shared/api/daoService'; +import { PluginSingleComponent } from '@/shared/components/pluginSingleComponent'; +import { SettingsSlotId } from '../../constants/moduleSlots'; +import { conditionTypeUtils } from '../../utils/conditionTypeUtils'; +import { UnrecognizedConditionSlot } from '../unrecognizedConditionSlot'; + +export interface IPermissionConditionProps { + chainId?: number; + network?: Network; + row: IDaoPermission; +} + +export const PermissionCondition: React.FC = ({ + chainId, + network, + row, +}) => { + const { address, type } = conditionTypeUtils.resolveConditionDisplay(row); + + return ( + + ); +}; 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..1a91e7d63d --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionEntityIcons/index.ts @@ -0,0 +1,2 @@ +export { MembersAvatarIcon } from './membersAvatarIcon'; +export { SafeAccountAvatar } from './safeAccountAvatar'; diff --git a/apps/app/src/modules/settings/components/permissionEntityIcons/membersAvatarIcon.tsx b/apps/app/src/modules/settings/components/permissionEntityIcons/membersAvatarIcon.tsx new file mode 100644 index 0000000000..452bfabfde --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionEntityIcons/membersAvatarIcon.tsx @@ -0,0 +1,15 @@ +import { AvatarIcon, IconType } from '@aragon/gov-ui-kit'; + +/** + * Members icon displayed for the ANY_ADDR ("Anyone") permission sentinel. + */ +export const MembersAvatarIcon: React.FC = () => ( + +); diff --git a/apps/app/src/modules/settings/components/permissionEntityIcons/safeAccountAvatar.tsx b/apps/app/src/modules/settings/components/permissionEntityIcons/safeAccountAvatar.tsx new file mode 100644 index 0000000000..54d6939590 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionEntityIcons/safeAccountAvatar.tsx @@ -0,0 +1,17 @@ +import { Avatar } from '@aragon/gov-ui-kit'; +import { brandedExternals } from '@/plugins/sppPlugin/constants/sppPluginBrandedExternals'; +import { PermissionEntityBrandId } from '@/shared/api/daoService'; + +/** + * Safe-branded avatar displayed next to entities resolved with `brandId: 'safe'`. + */ +export const SafeAccountAvatar: React.FC = () => ( + } + size="sm" + src={brandedExternals[PermissionEntityBrandId.SAFE]?.logo} + /> +); 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 `

- - setExpandedRows(value ?? [])} - value={expandedRows} - > - {rows.map((row) => ( - - ))} - -
- ); - }; - - const showAccountSelector = accounts.length > 1; - const showExpandAll = !isLoading && rows.length > 0; - - return ( -
-
- {showAccountSelector && ( - - {accounts.map((account) => ( - - ))} - + - {showExpandAll && ( - - )} - {viewSwitcher} -
-
- {renderBody()} -
- ); -}; - -type DaoPlugins = ReturnType; - -interface IPermissionsListRowProps { - row: IPermissionRow; - rowKey: string; - daoPlugins: DaoPlugins; - accounts: IPermissionAccountRef[]; - chainId?: number; -} - -interface IPermissionEntityCellProps { - entity: IPermissionEntity; -} - -const PermissionEntityCell: React.FC = ({ - entity, -}) => ( - - {entity.label} - {entity.type === 'dao' && ( - - )} - {entity.type === 'plugin' && entity.tag != null && ( - - )} - {entity.type === 'sentinel' && ( - -); - -interface IPermissionEntityDetailProps { - entity: IPermissionEntity; - chainId?: number; -} - -const PermissionEntityDetail: React.FC = ({ - entity, - chainId, -}) => { - const { buildEntityUrl } = useBlockExplorer({ chainId }); - - if (entity.isSentinel) { - return ( -
- {entity.label} - - {addressUtils.truncateAddress(entity.address)} - -
); } - const explorerUrl = buildEntityUrl({ - type: ChainEntityType.ADDRESS, - id: entity.address, - }); + const sortedRows = rows + .map((row) => ({ + row, + whereLabel: permissionEntityUtils.resolvePermissionEntity( + row.whereAddress, + { daoPlugins, accounts: accountRefs, entity: row.where }, + ).label, + })) + .sort((first, second) => + first.whereLabel.localeCompare(second.whereLabel, undefined, { + sensitivity: 'base', + }), + ) + .map((entry) => entry.row); return ( -
- + + + onExpandedRowsChange(value ?? []) + } + value={expandedRows} > - {addressUtils.truncateAddress(entity.address)} - - {entity.detailName != null && ( - - {entity.detailName} - - )} -
- ); -}; - -interface IPermissionDetailValueProps { - primary: string; - secondary?: string; -} - -const PermissionDetailValue: React.FC = ({ - primary, - secondary, -}) => ( -
- {primary} - {secondary != null && ( - - {secondary} - - )} -
-); - -const PermissionsListRow: React.FC = (props) => { - const { row, rowKey, daoPlugins, accounts, chainId } = props; - - const { t } = useTranslations(); - - const resolveOptions = { daoPlugins, accounts }; - const who = permissionEntityUtils.resolvePermissionEntity( - row.whoAddress, - resolveOptions, - ); - const where = permissionEntityUtils.resolvePermissionEntity( - row.whereAddress, - resolveOptions, - ); - const permissionName = permissionNameUtils.getPermissionName( - row.permissionId, - ); - const conditionType = conditionTypeUtils.resolveConditionType( - row.conditionAddress, - row.condition, - ); - const conditionLabel = conditionTypeUtils.getConditionLabel(conditionType); - - const hasCondition = !addressUtils.isAddressEqual( - row.conditionAddress, - ALLOW_FLAG, - ); - const conditionDetail = hasCondition - ? addressUtils.truncateAddress(row.conditionAddress) - : t('app.settings.permissionsList.details.noCondition'); - - return ( - - -
- - - - {permissionName} - - - {hasCondition ? ( - - ) : ( - - {conditionLabel} - - )} - -
-
- -
-
-

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

- - - - - - - - - - - - - - -
-
-

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

- -
-
-
-
- ); -}; - -const PermissionsListHeader: React.FC = () => { - const { t } = useTranslations(); - - return ( -
-
-
- {t('app.settings.permissionsList.header.who')} - - {t('app.settings.permissionsList.header.where')} - - - {t('app.settings.permissionsList.header.permission')} - - - {t('app.settings.permissionsList.header.condition')} - -
-
+ {sortedRows.map((row) => ( + + ))} +
); }; - -const PermissionsListSkeleton: React.FC = () => ( -
- - {SKELETON_ROW_KEYS.map((rowKey) => ( -
- - - - -
- ))} -
-); diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionsListHeader.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionsListHeader.tsx new file mode 100644 index 0000000000..da6d1c4017 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsListHeader.tsx @@ -0,0 +1,38 @@ +import { Icon, IconType } from '@aragon/gov-ui-kit'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { PermissionsListHeaderLabel } from './permissionsListHeaderLabel'; + +export const PermissionsListHeader: React.FC = () => { + const { t } = useTranslations(); + + return ( +
+
+
+ + + + {t('app.settings.permissionsList.header.permission')} + + + {t('app.settings.permissionsList.header.condition')} + +
+ +
+
+ ); +}; diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionsListHeaderLabel.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionsListHeaderLabel.tsx new file mode 100644 index 0000000000..0df3fbdf57 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsListHeaderLabel.tsx @@ -0,0 +1,24 @@ +import { useTranslations } from '@/shared/components/translationsProvider'; +import { PermissionInfoTooltip } from '../permissionInfoTooltip'; + +interface IPermissionsListHeaderLabelProps { + labelKey: string; + tooltipKey: string; + tooltipLabelKey: string; +} + +export const PermissionsListHeaderLabel: React.FC< + IPermissionsListHeaderLabelProps +> = ({ labelKey, tooltipKey, tooltipLabelKey }) => { + const { t } = useTranslations(); + + return ( + + {t(labelKey)} + + + ); +}; diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionsListRow.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionsListRow.tsx new file mode 100644 index 0000000000..4fa5cc6811 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsListRow.tsx @@ -0,0 +1,161 @@ +import { Accordion, Toggle, ToggleGroup } from '@aragon/gov-ui-kit'; +import { useState } from 'react'; +import type { + IDaoPermission, + IDaoPlugin, + Network, +} from '@/shared/api/daoService'; +import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; +import { useTranslations } from '@/shared/components/translationsProvider'; +import { permissionNameUtils } from '@/shared/utils/permissionNameUtils'; +import { conditionTypeUtils } from '../../utils/conditionTypeUtils'; +import type { IPermissionAccountRef } from '../../utils/permissionEntityUtils'; +import { permissionEntityUtils } from '../../utils/permissionEntityUtils'; +import { PermissionCondition } from '../permissionCondition'; +import { PermissionDetailsList } from './permissionDetailsList'; +import { PermissionEntityCell } from './permissionEntityCell'; + +export interface IPermissionsListRowProps { + row: IDaoPermission; + rowKey: string; + daoPlugins?: IFilterComponentPlugin[]; + accounts: IPermissionAccountRef[]; + chainId?: number; + network?: Network; +} + +type PermissionCardTab = 'details' | 'condition'; + +export const PermissionsListRow: React.FC = ( + props, +) => { + const { row, rowKey, daoPlugins, accounts, chainId, network } = props; + const { t } = useTranslations(); + const [activeTab, setActiveTab] = useState('details'); + const resolveOptions = { daoPlugins, accounts }; + const who = permissionEntityUtils.resolvePermissionEntity(row.whoAddress, { + ...resolveOptions, + entity: row.who, + }); + const where = permissionEntityUtils.resolvePermissionEntity( + row.whereAddress, + { + ...resolveOptions, + entity: row.where, + }, + ); + const permissionName = permissionNameUtils.getPermissionName( + row.permissionId, + ); + const { + label: conditionLabel, + hasCondition, + isUnrecognized, + } = conditionTypeUtils.resolveConditionDisplay(row); + const hasConditionBreakdown = hasCondition && !isUnrecognized; + + const handleTabChange = (value?: string | string[]) => { + if (value === 'details' || value === 'condition') { + setActiveTab(value); + } + }; + + const selectedTab = hasConditionBreakdown ? activeTab : 'details'; + + return ( + <> +
+
+

+ {permissionName} +

+ {hasConditionBreakdown && ( + + + + + )} +
+ {selectedTab === 'details' ? ( + + ) : ( + + )} +
+ + +
+
+ +
+
+ +
+ + {permissionName} + + + {conditionLabel} + +
+
+ +
+
+

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

+ +
+
+ {hasConditionBreakdown && ( + <> +

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

+ + + )} +
+
+
+
+ + ); +}; diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionsListSkeleton.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionsListSkeleton.tsx new file mode 100644 index 0000000000..f4a6819d31 --- /dev/null +++ b/apps/app/src/modules/settings/components/permissionsList/permissionsListSkeleton.tsx @@ -0,0 +1,32 @@ +import { StateSkeletonBar, StateSkeletonCircular } from '@aragon/gov-ui-kit'; +import { PermissionsListHeader } from './permissionsListHeader'; + +const SKELETON_ROW_KEYS = [ + 'skeleton-1', + 'skeleton-2', + 'skeleton-3', + 'skeleton-4', +]; + +export const PermissionsListSkeleton: React.FC = () => ( +
+ + {SKELETON_ROW_KEYS.map((rowKey) => ( +
+
+ + + + +
+ +
+ ))} +
+); diff --git a/apps/app/src/modules/settings/components/unrecognizedConditionSlot/index.ts b/apps/app/src/modules/settings/components/unrecognizedConditionSlot/index.ts new file mode 100644 index 0000000000..853e89abf3 --- /dev/null +++ b/apps/app/src/modules/settings/components/unrecognizedConditionSlot/index.ts @@ -0,0 +1 @@ +export { UnrecognizedConditionSlot } from './unrecognizedConditionSlot'; diff --git a/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.test.tsx b/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.test.tsx new file mode 100644 index 0000000000..0b0e697b8a --- /dev/null +++ b/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.test.tsx @@ -0,0 +1,45 @@ +import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { render, screen } from '@testing-library/react'; +import type { ComponentProps } from 'react'; +import { UnrecognizedConditionSlot } from './unrecognizedConditionSlot'; + +describe(' component', () => { + const conditionAddress = '0x1234567890abcdef1234567890abcdef12345678'; + + const createTestComponent = ( + props?: ComponentProps, + ) => ( + + + + ); + + it('renders a compact placeholder when the condition address is missing', () => { + render(createTestComponent()); + + expect(screen.getByTestId('unrecognized-condition')).toHaveTextContent( + '-', + ); + expect(screen.queryByRole('link')).not.toBeInTheDocument(); + }); + + it('renders the unrecognized condition address with explorer access', () => { + render(createTestComponent({ chainId: 1, conditionAddress })); + + expect( + screen.getByTestId('unrecognized-condition'), + ).toBeInTheDocument(); + expect( + screen.getByText(/permissionsList.details.condition/), + ).toBeInTheDocument(); + + const conditionLink = screen.getByRole('link', { + name: /0x1234.*5678/i, + }); + + expect(conditionLink).toHaveAttribute( + 'href', + expect.stringContaining(conditionAddress), + ); + }); +}); diff --git a/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.tsx b/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.tsx new file mode 100644 index 0000000000..73175f9b6a --- /dev/null +++ b/apps/app/src/modules/settings/components/unrecognizedConditionSlot/unrecognizedConditionSlot.tsx @@ -0,0 +1,50 @@ +'use client'; + +import { + addressUtils, + ChainEntityType, + DefinitionList, + useBlockExplorer, +} from '@aragon/gov-ui-kit'; +import { useTranslations } from '@/shared/components/translationsProvider'; + +export interface IUnrecognizedConditionSlotProps { + chainId?: number; + conditionAddress?: string; +} + +export const UnrecognizedConditionSlot: React.FC< + IUnrecognizedConditionSlotProps +> = (props) => { + const { chainId, conditionAddress } = props; + const { t } = useTranslations(); + const { buildEntityUrl } = useBlockExplorer({ chainId }); + + const conditionUrl = + conditionAddress != null + ? buildEntityUrl({ + type: ChainEntityType.ADDRESS, + id: conditionAddress, + }) + : undefined; + + return ( +
+ + + {conditionAddress != null + ? addressUtils.truncateAddress(conditionAddress) + : '-'} + + +
+ ); +}; diff --git a/apps/app/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.test.tsx b/apps/app/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.test.tsx index a1a72f06b1..7110916c68 100644 --- a/apps/app/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.test.tsx +++ b/apps/app/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.test.tsx @@ -1,11 +1,17 @@ -import { GukModulesProvider } from '@aragon/gov-ui-kit'; +import { GukModulesProvider, IconType } from '@aragon/gov-ui-kit'; import { render, screen } from '@testing-library/react'; -import type { IConditionData } from '@/modules/settings/types'; +import type { IDaoPermissionCondition } from '@/shared/api/daoService'; import { VotingPowerConditionSlot } from './votingPowerConditionSlot'; describe(' component', () => { - const createTestComponent = (props?: Partial) => { - const completeProps: IConditionData = { + type VotingPowerConditionProps = IDaoPermissionCondition & { + chainId?: number; + }; + + const createTestComponent = ( + props?: Partial, + ) => { + const completeProps: VotingPowerConditionProps = { conditionType: 'voting-power', ...props, }; @@ -36,6 +42,25 @@ describe(' component', () => { expect(screen.getByText('1')).toBeInTheDocument(); }); + it('links a valid token address to its explorer and exposes copy', () => { + const token = '0x0bA45A8b5d5575935B8158a88C631E9F9C95a2e5'; + + render(createTestComponent({ chainId: 1, token })); + + const tokenLink = screen.getByText('0x0bA4…a2e5').closest('a'); + expect(tokenLink?.getAttribute('href')).toContain(`/address/${token}`); + expect(screen.getByTestId(IconType.COPY)).toBeInTheDocument(); + }); + + it('does not link or copy malformed token payloads', () => { + const { container } = render( + createTestComponent({ chainId: 1, token: 'not-an-address' }), + ); + + expect(container.querySelector('a')).toBeNull(); + expect(screen.queryByTestId(IconType.COPY)).not.toBeInTheDocument(); + }); + it('falls back to a placeholder when payload fields are absent', () => { render( createTestComponent({ diff --git a/apps/app/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.tsx b/apps/app/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.tsx index e4b3ca65b2..251e32bf62 100644 --- a/apps/app/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.tsx +++ b/apps/app/src/modules/settings/components/votingPowerConditionSlot/votingPowerConditionSlot.tsx @@ -2,11 +2,13 @@ import { addressUtils, + ChainEntityType, DefinitionList, formatterUtils, + useBlockExplorer, } from '@aragon/gov-ui-kit'; import { formatUnits } from 'viem'; -import type { IConditionData } from '@/modules/settings/types'; +import type { IDaoPermissionCondition } from '@/shared/api/daoService'; import { useTranslations } from '@/shared/components/translationsProvider'; import { stringUtils } from '@/shared/utils/stringUtils'; @@ -17,6 +19,10 @@ const EMPTY_VALUE = '—'; // by convention, so default to that for now. const DEFAULT_TOKEN_DECIMALS = 18; +interface IVotingPowerConditionSlotProps extends IDaoPermissionCondition { + chainId?: number; +} + const formatMinVotingPower = (value: unknown): string => { if ( typeof value !== 'string' && @@ -39,18 +45,39 @@ const formatMinVotingPower = (value: unknown): string => { } }; -export const VotingPowerConditionSlot: React.FC = (props) => { - const { token, minVotingPower } = props; +export const VotingPowerConditionSlot: React.FC< + IVotingPowerConditionSlotProps +> = (props) => { + const { chainId, token, minVotingPower } = props; const { t } = useTranslations(); + const { buildEntityUrl } = useBlockExplorer({ chainId }); - const tokenLabel = stringUtils.isNonEmptyString(token) - ? addressUtils.truncateAddress(token) - : EMPTY_VALUE; + const tokenAddress = + stringUtils.isNonEmptyString(token) && addressUtils.isAddress(token) + ? token + : undefined; + const tokenLabel = + tokenAddress != null + ? addressUtils.truncateAddress(tokenAddress) + : EMPTY_VALUE; + const tokenUrl = + tokenAddress != null + ? buildEntityUrl({ + type: ChainEntityType.ADDRESS, + id: tokenAddress, + }) + : undefined; const minVotingPowerLabel = formatMinVotingPower(minVotingPower); return ( {tokenLabel} diff --git a/apps/app/src/modules/settings/constants/moduleSlots.ts b/apps/app/src/modules/settings/constants/moduleSlots.ts index 66889f5cdf..e893d9c943 100644 --- a/apps/app/src/modules/settings/constants/moduleSlots.ts +++ b/apps/app/src/modules/settings/constants/moduleSlots.ts @@ -3,5 +3,5 @@ export enum SettingsSlotId { SETTINGS_BUILD_PREPARE_PLUGIN_UPDATE_DATA = 'SETTINGS_BUILD_PREPARE_PLUGIN_UPDATE_DATA', SETTINGS_GET_UNINSTALL_HELPERS = 'SETTINGS_GET_UNINSTALL_HELPERS', SETTINGS_PLUGIN_TO_FORM_DATA = 'SETTINGS_PLUGIN_TO_FORM_DATA', - PERMISSION_CONDITION = 'PERMISSION_CONDITION', + SETTINGS_PERMISSION_CONDITION = 'SETTINGS_PERMISSION_CONDITION', } diff --git a/apps/app/src/modules/settings/constants/permissionsMocks.ts b/apps/app/src/modules/settings/constants/permissionsMocks.ts deleted file mode 100644 index c866ec55b2..0000000000 --- a/apps/app/src/modules/settings/constants/permissionsMocks.ts +++ /dev/null @@ -1,118 +0,0 @@ -import type { IPaginatedResponse } from '@/shared/api/aragonBackendService'; -import type { IDaoPermission } from '@/shared/api/daoService'; -import type { IBackendApiMock } from '@/shared/types'; -import { permissionNameUtils } from '@/shared/utils/permissionNameUtils'; -import type { IPermissionRow } from '../types'; -import { ALLOW_FLAG, ANY_ADDR } from './permissionSentinels'; -import { PermissionsPreviewRef } from './permissionsPreviewRefs'; - -// Permission ids are derived from their names so the dictionary stays in a single -// place ({@link permissionNameUtils}) instead of duplicating raw keccak256 hashes. -const ROOT_PERMISSION_ID = - permissionNameUtils.getPermissionId('ROOT_PERMISSION'); -const EXECUTE_PERMISSION_ID = - permissionNameUtils.getPermissionId('EXECUTE_PERMISSION'); -const CREATE_PROPOSAL_PERMISSION_ID = permissionNameUtils.getPermissionId( - 'CREATE_PROPOSAL_PERMISSION', -); - -const tokenAddress = '0x0bA45A8b5d5575935B8158a88C631E9F9C95a2e5'; -const gaugeVoterAddress = '0x1234567890AbcdEF1234567890aBcdef12345678'; -const votingConditionAddress = '0xC0Ffee254729296a45a3885639AC7E10F9d54979'; -const selectorConditionAddress = '0xDe0B295669a9FD93d5F28D9Ec85E40f4cb697BAe'; -const membershipConditionAddress = '0xaB5801a7D398351b8bE11C439e05C5B3259aeC9B'; - -// `who` / `where` reference the viewed DAO ({@link PermissionsPreviewRef.self}), -// its first linked DAO and its installed plugins so the sample rows resolve to -// real names, tags and avatars for whichever DAO is previewed (see -// {@link PermissionsPreviewRef}). The scenarios mirror the permissions-page -// Figma (self→self root, self→linked root, plugin execute, anyone-creates). -const permissions: Array = [ - { - // The DAO holds root permission over itself, unconditionally. - permissionId: ROOT_PERMISSION_ID, - whoAddress: PermissionsPreviewRef.self, - whereAddress: PermissionsPreviewRef.self, - conditionAddress: ALLOW_FLAG, - }, - { - // The DAO holds root permission over its linked DAO, unconditionally. - permissionId: ROOT_PERMISSION_ID, - whoAddress: PermissionsPreviewRef.self, - whereAddress: PermissionsPreviewRef.linked, - conditionAddress: ALLOW_FLAG, - }, - { - // The first plugin may execute on the DAO, gated by an execute-selector - // condition over a handful of functions. - permissionId: EXECUTE_PERMISSION_ID, - whoAddress: PermissionsPreviewRef.plugin0, - whereAddress: PermissionsPreviewRef.self, - conditionAddress: selectorConditionAddress, - condition: { - conditionType: 'execute-selector', - selectors: ['0x3f4ba83f', '0x40e58ee5', '0x8456cb59'], - targets: [gaugeVoterAddress, gaugeVoterAddress, gaugeVoterAddress], - }, - }, - { - // The second plugin may execute on the DAO, unconditionally. - permissionId: EXECUTE_PERMISSION_ID, - whoAddress: PermissionsPreviewRef.plugin1, - whereAddress: PermissionsPreviewRef.self, - conditionAddress: ALLOW_FLAG, - }, - { - // Anyone may create a proposal on the first plugin, gated by voting power. - permissionId: CREATE_PROPOSAL_PERMISSION_ID, - whoAddress: ANY_ADDR, - whereAddress: PermissionsPreviewRef.plugin0, - conditionAddress: votingConditionAddress, - condition: { - conditionType: 'voting-power', - token: tokenAddress, - // Raw base-unit value; renders formatted once token decimals are wired. - minVotingPower: '1000000000000000000', - }, - }, - { - // Anyone may create a proposal on the second plugin, gated by multisig - // membership. - permissionId: CREATE_PROPOSAL_PERMISSION_ID, - whoAddress: ANY_ADDR, - whereAddress: PermissionsPreviewRef.plugin1, - conditionAddress: membershipConditionAddress, - condition: { - conditionType: 'membership', - onlyListed: true, - }, - }, -]; - -const permissionsResponse: IPaginatedResponse = - { - metadata: { - page: 1, - pageSize: permissions.length, - totalPages: 1, - totalRecords: permissions.length, - }, - data: permissions, - }; - -/** - * Preview-mode mock for `GET /permissions/:network/:daoAddress`. Covers the - * condition scenarios shown in the permissions-page Figma (no-condition, - * execute-selector, voting-power, membership) so the permissions UI can be - * exercised without a live backend. - */ -export const permissionsMocks: IBackendApiMock[] = [ - { - // Scoped to the exact `/permissions/:network/:daoAddress` endpoint so - // the interceptor can't replace unrelated requests that merely contain - // the word "permissions" in their path. - url: /\/permissions\/[\w-]+\/0x[a-fA-F0-9]{40}(?:$|[/?])/, - type: 'replace', - data: permissionsResponse, - }, -]; diff --git a/apps/app/src/modules/settings/constants/permissionsPreviewData.ts b/apps/app/src/modules/settings/constants/permissionsPreviewData.ts deleted file mode 100644 index 2138d8cc08..0000000000 --- a/apps/app/src/modules/settings/constants/permissionsPreviewData.ts +++ /dev/null @@ -1,77 +0,0 @@ -import { - type IDaoPlugin, - Network, - PluginInterfaceType, -} from '@/shared/api/daoService'; -import type { IFilterComponentPlugin } from '@/shared/components/pluginFilterComponent'; - -/** - * Self-contained "Patito DAO" preview identity used alongside - * {@link permissionsMocks} when the `useMocks` flag is on, so the permissions - * page renders the exact scenario from the Figma design (Patito DAO + a linked - * Patito Developer DAO, with `Core` (SPP) and `Founders` (MULTISIG) plugins) - * regardless of which DAO is opened. Ignored entirely for live data. - */ - -/** - * Minimal account shape the permissions list needs to build its tabs and resolve - * `who` / `where` entities. - */ -export interface IPermissionsPreviewAccount { - id: string; - name: string; - network: Network; - daoAddress: string; - avatarSrc?: string; -} - -const patitoDaoAddress = '0xf204245b0B05E9A0780761E326552A569c1D6ceb'; -const patitoDeveloperDaoAddress = '0x71C7656EC7ab88b098defB751B7401B5f6d8976F'; -const coreAddress = '0x1eC50000000000000000000000000000000e8145'; -const foundersAddress = '0xf00d00000000000000000000000000000000e845'; - -export const permissionsPreviewAccounts: IPermissionsPreviewAccount[] = [ - { - id: 'patito-dao', - name: 'Patito DAO', - network: Network.ETHEREUM_MAINNET, - daoAddress: patitoDaoAddress, - avatarSrc: '/patitoDao.png', - }, - { - id: 'patito-developer-dao', - name: 'Patito Developer DAO', - network: Network.ETHEREUM_MAINNET, - daoAddress: patitoDeveloperDaoAddress, - avatarSrc: '/patitoDeveloperDao.png', - }, -]; - -export const permissionsPreviewPlugins: IFilterComponentPlugin[] = [ - { - id: 'core', - uniqueId: `${coreAddress}-spp`, - label: 'Core', - meta: { - address: coreAddress, - name: 'Core', - interfaceType: PluginInterfaceType.SPP, - release: '1', - build: '3', - } as IDaoPlugin, - props: {}, - }, - { - id: 'founders', - uniqueId: `${foundersAddress}-multisig`, - label: 'Founders', - meta: { - address: foundersAddress, - name: 'Founders', - interfaceType: PluginInterfaceType.MULTISIG, - release: '1', - build: '4', - } as IDaoPlugin, - props: {}, - }, -]; diff --git a/apps/app/src/modules/settings/constants/permissionsPreviewRefs.ts b/apps/app/src/modules/settings/constants/permissionsPreviewRefs.ts deleted file mode 100644 index beb7ab9b02..0000000000 --- a/apps/app/src/modules/settings/constants/permissionsPreviewRefs.ts +++ /dev/null @@ -1,21 +0,0 @@ -/** - * Preview-only address markers used by {@link permissionsMocks}. - * - * The permissions list swaps these markers for the viewed DAO's own address and - * its installed plugin addresses at render time, so the sample rows resolve to - * real names, tags and avatars for whichever DAO is being previewed instead of - * hard-coding a specific DAO. Real backend responses never contain these values, - * so the swap is a no-op for live data. - */ -const previewRef = (suffix: string): string => `0x${suffix.padStart(40, '0')}`; - -export const PermissionsPreviewRef = { - /** Resolves to the active account's DAO address. */ - self: previewRef('5e1f'), - /** Resolves to the active account's first linked DAO. */ - linked: previewRef('11a0'), - /** Resolves to the first installed plugin of the active account. */ - plugin0: previewRef('9100'), - /** Resolves to the second installed plugin of the active account. */ - plugin1: previewRef('9101'), -} as const; diff --git a/apps/app/src/modules/settings/initConditionRegistry.test.ts b/apps/app/src/modules/settings/initConditionRegistry.test.ts index 5edae60a45..5e81e8ed30 100644 --- a/apps/app/src/modules/settings/initConditionRegistry.test.ts +++ b/apps/app/src/modules/settings/initConditionRegistry.test.ts @@ -3,6 +3,7 @@ import { pluginRegistryUtils } from '@/shared/utils/pluginRegistryUtils'; import { ExecuteSelectorConditionSlot } from './components/executeSelectorConditionSlot'; import { MembershipConditionSlot } from './components/membershipConditionSlot'; import { NoConditionSlot } from './components/noConditionSlot'; +import { UnrecognizedConditionSlot } from './components/unrecognizedConditionSlot'; import { VotingPowerConditionSlot } from './components/votingPowerConditionSlot'; import { initialiseConditionRegistry } from './initConditionRegistry'; @@ -18,30 +19,17 @@ describe('initialiseConditionRegistry', () => { component: ExecuteSelectorConditionSlot, }, { pluginId: 'membership', component: MembershipConditionSlot }, + { pluginId: 'unknown', component: UnrecognizedConditionSlot }, + { pluginId: 'none', component: NoConditionSlot }, ])('resolves the $pluginId condition component from the slot', ({ pluginId, component, }) => { const resolved = pluginRegistryUtils.getSlotComponent({ - slotId: SettingsSlotId.PERMISSION_CONDITION, + slotId: SettingsSlotId.SETTINGS_PERMISSION_CONDITION, pluginId, }); expect(resolved).toBe(component); }); - - it.each([ - { pluginId: 'none' }, - { pluginId: 'unknown' }, - ])('does not resolve a component for the unregistered $pluginId condition type', ({ - pluginId, - }) => { - const resolved = pluginRegistryUtils.getSlotComponent({ - slotId: SettingsSlotId.PERMISSION_CONDITION, - pluginId, - }); - - expect(resolved).toBeUndefined(); - expect(resolved).not.toBe(NoConditionSlot); - }); }); diff --git a/apps/app/src/modules/settings/initConditionRegistry.ts b/apps/app/src/modules/settings/initConditionRegistry.ts index 798409b7c8..edb4854455 100644 --- a/apps/app/src/modules/settings/initConditionRegistry.ts +++ b/apps/app/src/modules/settings/initConditionRegistry.ts @@ -1,15 +1,19 @@ import { SettingsSlotId } from '@/modules/settings/constants/moduleSlots'; +import type { IDaoPermissionCondition } from '@/shared/api/daoService'; import { pluginRegistryUtils } from '@/shared/utils/pluginRegistryUtils'; import { ExecuteSelectorConditionSlot } from './components/executeSelectorConditionSlot'; import { MembershipConditionSlot } from './components/membershipConditionSlot'; +import { NoConditionSlot } from './components/noConditionSlot'; +import { UnrecognizedConditionSlot } from './components/unrecognizedConditionSlot'; import { VotingPowerConditionSlot } from './components/votingPowerConditionSlot'; +import { NO_CONDITION, UNKNOWN_CONDITION } from './utils/conditionTypeUtils'; /** * Registers the permission-condition slot components against the - * {@link SettingsSlotId.PERMISSION_CONDITION} slot. + * {@link SettingsSlotId.SETTINGS_PERMISSION_CONDITION} slot. * * Condition registration is standalone: the synthetic `conditionType` - * discriminator (see {@link IConditionData}) is used as the `pluginId`, so each + * discriminator (see {@link IDaoPermissionCondition}) is used as the `pluginId`, so each * resolved condition payload renders its matching component without coupling to * any real governance plugin. This module is the sole location for condition * registrations. @@ -17,18 +21,28 @@ import { VotingPowerConditionSlot } from './components/votingPowerConditionSlot' export const initialiseConditionRegistry = () => { pluginRegistryUtils .registerSlotComponent({ - slotId: SettingsSlotId.PERMISSION_CONDITION, + slotId: SettingsSlotId.SETTINGS_PERMISSION_CONDITION, pluginId: 'voting-power', component: VotingPowerConditionSlot, }) .registerSlotComponent({ - slotId: SettingsSlotId.PERMISSION_CONDITION, + slotId: SettingsSlotId.SETTINGS_PERMISSION_CONDITION, pluginId: 'execute-selector', component: ExecuteSelectorConditionSlot, }) .registerSlotComponent({ - slotId: SettingsSlotId.PERMISSION_CONDITION, + slotId: SettingsSlotId.SETTINGS_PERMISSION_CONDITION, pluginId: 'membership', component: MembershipConditionSlot, + }) + .registerSlotComponent({ + slotId: SettingsSlotId.SETTINGS_PERMISSION_CONDITION, + pluginId: UNKNOWN_CONDITION, + component: UnrecognizedConditionSlot, + }) + .registerSlotComponent({ + slotId: SettingsSlotId.SETTINGS_PERMISSION_CONDITION, + pluginId: NO_CONDITION, + component: NoConditionSlot, }); }; diff --git a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPage.tsx b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPage.tsx index c5bbb707e6..52f5e59c9e 100644 --- a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPage.tsx +++ b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPage.tsx @@ -1,6 +1,4 @@ -import { notFound } from 'next/navigation-original'; import { Page } from '@/shared/components/page'; -import { featureFlags } from '@/shared/featureFlags'; import type { IDaoPageParams } from '@/shared/types'; import { daoUtils } from '@/shared/utils/daoUtils'; import { networkUtils } from '@/shared/utils/networkUtils'; @@ -24,10 +22,6 @@ export const DaoPermissionsPage: React.FC = async ( return null; } - if (!(await featureFlags.isEnabled('permissionsPage'))) { - notFound(); - } - const daoId = await daoUtils.resolveDaoId(daoPageParams); return ( diff --git a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx index 1b2ce98dfe..ee84dbee78 100644 --- a/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx +++ b/apps/app/src/modules/settings/pages/daoPermissionsPage/daoPermissionsPageClient.tsx @@ -1,12 +1,21 @@ 'use client'; -import { Toggle, ToggleGroup } from '@aragon/gov-ui-kit'; -import { useState } from 'react'; -import { useDao } from '@/shared/api/daoService'; +import { Button, Switch, Toggle, ToggleGroup } from '@aragon/gov-ui-kit'; +import { useMemo, useState } from 'react'; import { Page } from '@/shared/components/page'; import { useTranslations } from '@/shared/components/translationsProvider'; +import { useFilterUrlParam } from '@/shared/hooks/useFilterUrlParam'; import { daoUtils } from '@/shared/utils/daoUtils'; -import { PermissionsList } from '../../components/permissionsList'; +import { PermissionInfoTooltip } from '../../components/permissionInfoTooltip'; +import { + getPermissionRowKey, + PermissionsList, +} from '../../components/permissionsList'; +import { usePermissionsData } from '../../hooks/usePermissionsData'; +import { + filterPermissionRows, + getPermissionRowToggleAvailability, +} from '../../utils/permissionRowFilters'; export interface IDaoPermissionsPageClientProps { /** @@ -15,7 +24,11 @@ export interface IDaoPermissionsPageClientProps { daoId: string; } -type PermissionsView = 'list' | 'graph'; +export const permissionsHideDaoParam = 'permissionshidedaogrants'; +export const permissionsHideGoverningBodiesParam = + 'permissionshidegoverningbodypaths'; + +const booleanParamValues = ['false', 'true']; export const DaoPermissionsPageClient: React.FC< IDaoPermissionsPageClientProps @@ -24,20 +37,115 @@ export const DaoPermissionsPageClient: React.FC< const { t } = useTranslations(); - const { data: dao } = useDao({ urlParams: { id: daoId } }); + const { + dao: permissionsDao, + accounts, + activeAccountId, + activeAccount, + setSelectedAccountId, + accountRefs, + daoPlugins, + rows, + chainId, + isLoading, + error, + } = usePermissionsData({ daoId }); + + const [hideDaoPermissionsParam, setHideDaoPermissions] = useFilterUrlParam({ + name: permissionsHideDaoParam, + fallbackValue: 'true', + validValues: booleanParamValues, + enableUrlUpdate: true, + }); + + const [hideGoverningBodyPermissionsParam, setHideGoverningBodyPermissions] = + useFilterUrlParam({ + name: permissionsHideGoverningBodiesParam, + fallbackValue: 'true', + validValues: booleanParamValues, + enableUrlUpdate: true, + }); - // Graph view is out of scope for now (T05 shell); only the list view is wired up. - const [view, setView] = useState('list'); + const [expandedRows, setExpandedRows] = useState([]); - const handleViewChange = (value: string | string[] | undefined) => { - if (value === 'list' || value === 'graph') { - setView(value); + const hideDaoPermissions = hideDaoPermissionsParam === 'true'; + const hideGoverningBodyPermissions = + hideGoverningBodyPermissionsParam === 'true'; + const showDaoPermissions = !hideDaoPermissions; + const showSubpluginPermissions = !hideGoverningBodyPermissions; + + const handleAccountChange = (value?: string | string[]) => { + if (typeof value === 'string' && value) { + setSelectedAccountId(value); + setExpandedRows([]); } }; + const handleHideDaoPermissionsChange = (checked: boolean) => { + setHideDaoPermissions(String(checked)); + setExpandedRows([]); + }; + + const handleHideGoverningBodyPermissionsChange = (checked: boolean) => { + setHideGoverningBodyPermissions(String(checked)); + setExpandedRows([]); + }; + + const filteredRows = useMemo( + () => + filterPermissionRows(rows, { + activeAccountAddress: activeAccount?.daoAddress, + daoPlugins, + showDaoPermissions, + showSubpluginPermissions, + }), + [ + activeAccount?.daoAddress, + daoPlugins, + rows, + showDaoPermissions, + showSubpluginPermissions, + ], + ); + + const permissionToggleAvailability = useMemo( + () => + getPermissionRowToggleAvailability(rows, { + activeAccountAddress: activeAccount?.daoAddress, + daoPlugins, + showDaoPermissions, + showSubpluginPermissions, + }), + [ + activeAccount?.daoAddress, + daoPlugins, + rows, + showDaoPermissions, + showSubpluginPermissions, + ], + ); + + const hideDaoPermissionsToggleDisabled = + !permissionToggleAvailability.daoPermissions; + const hideGoverningBodyPermissionsToggleDisabled = + !permissionToggleAvailability.subpluginPermissions; + + if (error != null) { + return ; + } + + const allExpanded = + filteredRows.length > 0 && expandedRows.length === filteredRows.length; + + const handleToggleAll = () => { + setExpandedRows( + allExpanded ? [] : filteredRows.map(getPermissionRowKey), + ); + }; + const pageBreadcrumbs = [ { - href: daoUtils.getDaoUrl(dao, 'settings'), + href: daoUtils.getDaoUrl(permissionsDao, 'settings'), label: t( 'app.settings.daoPermissionsPage.header.breadcrumb.settings', ), @@ -49,6 +157,9 @@ export const DaoPermissionsPageClient: React.FC< }, ]; + const showAccountSelector = accounts.length > 1; + const showExpandAll = !isLoading && filteredRows.length > 0; + return ( <> - {view === 'list' && ( - - +
+
+ {showAccountSelector && ( + + {accounts.map((account) => ( + + ))} + + )} + {showExpandAll && ( + + )} +
+
+
+ + - +
+ - - } + +
+
+
+ - )} +
diff --git a/apps/app/src/modules/settings/types/index.ts b/apps/app/src/modules/settings/types/index.ts index 8e50dbe6e7..cb71673df4 100644 --- a/apps/app/src/modules/settings/types/index.ts +++ b/apps/app/src/modules/settings/types/index.ts @@ -8,6 +8,5 @@ export type { IPermissionGraphNode, PermissionNodeKind, } from './permissionGraph'; -export type { IConditionData, IPermissionRow } from './permissionRow'; export type { IPluginToFormDataParams } from './pluginToFormDataParams'; export type { IUseGovernanceSettingsParams } from './useGovernanceSettingsParams'; diff --git a/apps/app/src/modules/settings/types/permissionRow.ts b/apps/app/src/modules/settings/types/permissionRow.ts deleted file mode 100644 index 67e4694b67..0000000000 --- a/apps/app/src/modules/settings/types/permissionRow.ts +++ /dev/null @@ -1,31 +0,0 @@ -import type { IDaoPermission } from '@/shared/api/daoService'; - -/** - * UI-side representation of a permission's resolved condition. - * - * `conditionType` is read straight off the payload. Additional, - * condition-specific fields may be present, hence the open-ended index - * signature. - */ -export interface IConditionData { - /** - * The resolved condition type discriminator (e.g. `'voting-power'`). An - * empty or absent value is treated as `'unknown'` by the resolver. - */ - conditionType: string; - [key: string]: unknown; -} - -/** - * UI-only view model for a single permission row. Extends the domain - * {@link IDaoPermission} with an optional resolved {@link IConditionData}. - * - * The domain type is never mutated; condition resolution happens at the UI - * layer. - */ -export type IPermissionRow = IDaoPermission & { - /** - * Resolved condition payload for the permission, when available. - */ - condition?: IConditionData; -}; diff --git a/apps/app/src/shared/api/daoService/domain/daoPermission.ts b/apps/app/src/shared/api/daoService/domain/daoPermission.ts index 93de022c54..959dffdcca 100644 --- a/apps/app/src/shared/api/daoService/domain/daoPermission.ts +++ b/apps/app/src/shared/api/daoService/domain/daoPermission.ts @@ -124,7 +124,7 @@ export interface IDaoPermission { * The address `ALLOW_FLAG` for regular permissions or, alternatively, the * `IPermissionCondition` contract implementation to be used. */ - conditionAddress: string; + conditionAddress?: string; /** * Enriched condition details returned by the backend when available. */ From 85edb3aa1e1e8c5a0b8ab47ee02b77856b15d3e3 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Mon, 3 Aug 2026 11:45:01 +0200 Subject: [PATCH 02/10] chore: Add empty changeset for stack layer 03 --- .changeset/app-1003-03-list-empty.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/app-1003-03-list-empty.md diff --git a/.changeset/app-1003-03-list-empty.md b/.changeset/app-1003-03-list-empty.md new file mode 100644 index 0000000000..0776339d5f --- /dev/null +++ b/.changeset/app-1003-03-list-empty.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": patch +--- + +No user-facing changes — internal layer of the APP-1003 permissions viewer stack. From 765ea044fd34e06a2029fcf31d28bec1768a2581 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Mon, 3 Aug 2026 11:53:36 +0200 Subject: [PATCH 03/10] chore: Make stack layer changeset truly empty --- .changeset/app-1003-03-list-empty.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/.changeset/app-1003-03-list-empty.md b/.changeset/app-1003-03-list-empty.md index 0776339d5f..ec380ec43f 100644 --- a/.changeset/app-1003-03-list-empty.md +++ b/.changeset/app-1003-03-list-empty.md @@ -1,5 +1,3 @@ --- -"@aragon/app": patch --- -No user-facing changes — internal layer of the APP-1003 permissions viewer stack. From c1aaa6cb7293b19ec092893bbfe6098e9b587caf Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Mon, 3 Aug 2026 11:54:33 +0200 Subject: [PATCH 04/10] chore: Make stack layer changeset truly empty --- .changeset/app-1003-03-list-empty.md | 1 - 1 file changed, 1 deletion(-) diff --git a/.changeset/app-1003-03-list-empty.md b/.changeset/app-1003-03-list-empty.md index ec380ec43f..a845151cc8 100644 --- a/.changeset/app-1003-03-list-empty.md +++ b/.changeset/app-1003-03-list-empty.md @@ -1,3 +1,2 @@ --- --- - From 1fb62e16c9b2473986f6a4acb4f983a0f8794ce6 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Mon, 3 Aug 2026 12:01:12 +0200 Subject: [PATCH 05/10] chore: Remove generated slot-docs system from the list layer --- .github/workflows/app-slot-docs.yml | 26 - apps/app/docs/slots/_metrics.md | 47 -- apps/app/docs/slots/application-slots.md | 97 ---- apps/app/docs/slots/create-dao-slots.md | 16 - apps/app/docs/slots/governance-slots.md | 54 --- apps/app/docs/slots/overview.md | 22 +- apps/app/docs/slots/settings-slots.md | 16 - apps/app/package.json | 1 - apps/app/scripts/generateSlotDocs.mjs | 584 ----------------------- 9 files changed, 9 insertions(+), 854 deletions(-) delete mode 100644 .github/workflows/app-slot-docs.yml delete mode 100644 apps/app/docs/slots/_metrics.md delete mode 100644 apps/app/docs/slots/application-slots.md delete mode 100644 apps/app/docs/slots/create-dao-slots.md delete mode 100644 apps/app/docs/slots/governance-slots.md delete mode 100644 apps/app/docs/slots/settings-slots.md delete mode 100644 apps/app/scripts/generateSlotDocs.mjs diff --git a/.github/workflows/app-slot-docs.yml b/.github/workflows/app-slot-docs.yml deleted file mode 100644 index 5f6b648384..0000000000 --- a/.github/workflows/app-slot-docs.yml +++ /dev/null @@ -1,26 +0,0 @@ -# Validates that generated slot documentation is up to date with source code. -# Triggers on PRs that touch slot enums, plugin registrations, or the docs themselves. - -name: App Slot Docs - -on: - pull_request: - paths: - - 'apps/app/src/modules/*/constants/moduleSlots.ts' - - 'apps/app/src/modules/*/constants/moduleDaoSlots.ts' - - 'apps/app/src/plugins/*/index.ts' - - 'apps/app/docs/slots/**' - - 'apps/app/scripts/generateSlotDocs.mjs' - -permissions: - contents: read - -jobs: - check: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v7.0.1 - - name: Validate slot docs are up to date - run: node scripts/generateSlotDocs.mjs --check - working-directory: apps/app diff --git a/apps/app/docs/slots/_metrics.md b/apps/app/docs/slots/_metrics.md deleted file mode 100644 index dbe30267d3..0000000000 --- a/apps/app/docs/slots/_metrics.md +++ /dev/null @@ -1,47 +0,0 @@ - - -# Slot System Metrics - -> Auto-generated snapshot of the slot system state. Run `pnpm generate:slot-docs` (from `apps/app`) to refresh. - -## Summary - -| Metric | Value | -|---|---| -| Total slots | 34 | -| Registered plugins | 8 | -| Unregistered slots | 4 | - -## Slots Per Module - -| Module | Enum | Count | -|---|---|---| -| Governance | `GovernanceSlotId` | 20 | -| Settings | `SettingsSlotId` | 5 | -| Create DAO | `CreateDaoSlotId` | 5 | -| Application | `ApplicationSlotId` | 1 | -| Dashboard (DAO-level) | `DashboardDaoSlotId` | 1 | -| Capital Flow (DAO-level) | `CapitalFlowDaoSlotId` | 2 | - -## Plugin Coverage Matrix - -| Plugin | Governance | Settings | Create DAO | Application | Total | -|---|---|---|---|---|---| -| Token Voting | 17 | 4 | 5 | - | 26 | -| Multisig | 14 | 3 | 5 | - | 22 | -| Lock To Vote | 16 | 4 | 5 | - | 25 | -| Admin | 5 | 1 | - | - | 6 | -| SPP | 7 | 2 | - | - | 9 | -| Capital Distributor | - | - | - | 1 | 1 | -| Gauge Voter | - | - | - | 1 | 1 | -| Condition registry | - | 3 | - | - | 3 | - -## Unregistered Slots - -These slots are defined in enums but no plugin currently registers for them: - -- `GOVERNANCE_DAO_PROPOSAL_LIST` -- `DASHBOARD_DAO_HEADER` -- `CAPITAL_DISTRIBUTOR_MEMBERS_FILE_DOWNLOAD` -- `CAPITAL_DISTRIBUTOR_VOTING_ESCROW_ADDRESS` - diff --git a/apps/app/docs/slots/application-slots.md b/apps/app/docs/slots/application-slots.md deleted file mode 100644 index c29c086e25..0000000000 --- a/apps/app/docs/slots/application-slots.md +++ /dev/null @@ -1,97 +0,0 @@ - - -# Application & DAO-Level Slots - -## Application Module Slots - -**Enum:** `ApplicationSlotId` -**Location:** `src/modules/application/constants/moduleSlots.ts` - -| Slot ID | Type | Description | Registered By | -|---|---|---|---| -| `APPLICATION_PLUGIN_PAGE` | Component | Renders a plugin-specific page at a custom URL path | Capital Distributor, Gauge Voter | - -### How Plugin Pages Work - -Unlike other slots, `APPLICATION_PLUGIN_PAGE` uses **dynamic slot IDs** constructed from URL segments: - -```typescript -// Registration (in plugin index.ts) -pluginRegistryUtils.registerSlotComponent({ - slotId: pluginRegistryUtils.getPageSlotId( - ApplicationSlotId.APPLICATION_PLUGIN_PAGE, - [CapitalDistributorPluginPages.REWARDS], // "rewards" - ), - // Resulting slot ID: "APPLICATION_PLUGIN_PAGE-rewards" - pluginId: capitalDistributorPlugin.id, - component: CapitalDistributorRewardsPage, -}); -``` - -The `DaoPluginPage` server component (`src/modules/application/pages/daoPluginPage/daoPluginPage.tsx`) resolves these -at render time by iterating over the DAO's installed plugins and checking which one has a registered component for the -current URL segments. - -### Plugin Navigation Links - -Plugins can add navigation links to the DAO sidebar using `pageLinksLeft` and `pageLinksRight` on their `IPluginInfo` -definition: - -```typescript -export const myPlugin: IPluginInfo = { - // ... other fields - pageLinksRight: (baseUrl, context) => [ - { - label: 'app.plugins.myPlugin.meta.link.dashboard', - link: `${baseUrl}/dashboard`, - icon: IconType.APP_PROPOSALS, - lgHidden: context === 'dialog', - }, - ], -}; -``` - -### Current Plugin Pages - -| Plugin | Page Slug | Description | -|---|---|---| -| Capital Distributor | `/rewards` | Token reward distribution management | -| Gauge Voter | `/gauges` | Gauge voting interface | - -## DAO-Level Slots - -DAO-level slots are different from plugin-level slots. They are scoped to **specific DAO instances** rather than plugin -types, and are registered from `src/daos/{daoName}/index.ts` instead of `src/plugins/`. - -Use these when a particular DAO needs custom UI that isn't tied to its plugin type. - -### Dashboard DAO Slots - -**Enum:** `DashboardDaoSlotId` -**Location:** `src/modules/dashboard/constants/moduleDaoSlots.ts` - -| Slot ID | Type | Description | Registered By | -|---|---|---|---| -| `DASHBOARD_DAO_HEADER` | - | Custom header component for a specific DAO dashboard | - | - -### Capital Flow DAO Slots - -**Enum:** `CapitalFlowDaoSlotId` -**Location:** `src/modules/capitalFlow/constants/moduleDaoSlots.ts` - -| Slot ID | Type | Description | Registered By | -|---|---|---|---| -| `CAPITAL_DISTRIBUTOR_MEMBERS_FILE_DOWNLOAD` | - | Custom member file download UI for reward distribution | - | -| `CAPITAL_DISTRIBUTOR_VOTING_ESCROW_ADDRESS` | - | Returns the voting escrow contract address for a specific DAO | - | - -### How DAO Customizations Are Initialized - -DAO-specific registrations are called from `src/daos/index.ts` and run as part of `initPluginRegistry()` alongside -plugin and policy initialization: - -``` -initPluginRegistry() -├── initialisePlugins() # All plugin slot registrations -├── initialisePolicyPluginRegistry() # Capital flow policy resolvers -└── initialiseDaos() # DAO-specific slot overrides -``` diff --git a/apps/app/docs/slots/create-dao-slots.md b/apps/app/docs/slots/create-dao-slots.md deleted file mode 100644 index 7b036bde75..0000000000 --- a/apps/app/docs/slots/create-dao-slots.md +++ /dev/null @@ -1,16 +0,0 @@ - - -# Create DAO Module Slots - -**Enum:** `CreateDaoSlotId` -**Location:** `src/modules/createDao/constants/moduleSlots.ts` - -The create DAO module defines 5 slots used during the DAO creation wizard to configure plugins. - -| Slot ID | Type | Description | Registered By | -|---|---|---|---| -| `CREATE_DAO_BUILD_PREPARE_PLUGIN_INSTALL_DATA` | Function | Builds the transaction data for installing the plugin during DAO creation | Token Voting, Multisig, Lock To Vote | -| `CREATE_DAO_PROCESS_BODY_READ_FIELD` | Component | Renders read-only fields for the governance body configuration review step | Token Voting, Multisig, Lock To Vote | -| `CREATE_DAO_PROPOSAL_CREATION_SETTINGS` | Component | Renders the proposal creation settings form during DAO setup | Token Voting, Multisig, Lock To Vote | -| `CREATE_DAO_SETUP_MEMBERSHIP` | Component | Renders the membership configuration step (who can participate) | Token Voting, Multisig, Lock To Vote | -| `CREATE_DAO_SETUP_GOVERNANCE` | Component | Renders the governance configuration step (voting parameters, thresholds) | Token Voting, Multisig, Lock To Vote | diff --git a/apps/app/docs/slots/governance-slots.md b/apps/app/docs/slots/governance-slots.md deleted file mode 100644 index 75754f2cbe..0000000000 --- a/apps/app/docs/slots/governance-slots.md +++ /dev/null @@ -1,54 +0,0 @@ - - -# Governance Module Slots - -**Enum:** `GovernanceSlotId` -**Location:** `src/modules/governance/constants/moduleSlots.ts` - -The governance module has the largest slot surface area with 20 slots covering members, proposals, voting, and -permissions. - -## Members - -| Slot ID | Type | Description | Registered By | -|---|---|---|---| -| `GOVERNANCE_DAO_MEMBER_LIST` | Component | Renders the member list for a DAO | Token Voting, Lock To Vote | -| `GOVERNANCE_MEMBER_PANEL` | Component | Renders the member detail panel | Token Voting, Lock To Vote | -| `GOVERNANCE_MEMBER_STATS` | Function | Returns member statistics (total members, voting power, etc.) | Token Voting, Lock To Vote | - -## Proposals - -| Slot ID | Type | Description | Registered By | -|---|---|---|---| -| `GOVERNANCE_DAO_PROPOSAL_LIST` | - | Renders the full proposal list for a DAO | - | -| `GOVERNANCE_DAO_PROPOSAL_LIST_ITEM` | Component | Renders a custom proposal list item | SPP | -| `GOVERNANCE_PROCESS_PROPOSAL_STATUS` | Function | Calculates the current status of a proposal | Token Voting, Multisig, Lock To Vote, Admin, SPP | -| `GOVERNANCE_PROCESS_PROPOSAL_SUCCEEDED` | Function | Determines if a proposal has succeeded (reached threshold) | Token Voting, Multisig, Lock To Vote | -| `GOVERNANCE_CREATE_PROPOSAL_SETTINGS_FORM` | Component | Renders plugin-specific settings in the create proposal form | Token Voting, Multisig, Lock To Vote, SPP | -| `GOVERNANCE_BUILD_CREATE_PROPOSAL_DATA` | Function | Builds the transaction data for creating a proposal | Token Voting, Multisig, Lock To Vote, Admin, SPP | - -## Voting - -| Slot ID | Type | Description | Registered By | -|---|---|---|---| -| `GOVERNANCE_PROPOSAL_VOTING_BREAKDOWN` | Component | Renders the voting breakdown (for/against/abstain) | Token Voting, Multisig, Lock To Vote | -| `GOVERNANCE_VOTE_LIST` | Component | Renders the list of individual votes on a proposal | Token Voting, Multisig, Lock To Vote | -| `GOVERNANCE_SUBMIT_VOTE` | Component | Renders the vote submission UI | Token Voting, Multisig, Lock To Vote | -| `GOVERNANCE_BUILD_VOTE_DATA` | Function | Builds the transaction data for submitting a vote | Token Voting, Multisig, Lock To Vote | -| `GOVERNANCE_PROPOSAL_VOTING_TERMINAL` | Component | Renders the voting terminal (used by admin and SPP plugins) | Admin, SPP | -| `GOVERNANCE_PROPOSAL_VOTING_MULTI_BODY_SUMMARY` | Component | Renders a summary for multi-body voting | Token Voting, Multisig, Lock To Vote | - -## Actions - -| Slot ID | Type | Description | Registered By | -|---|---|---|---| -| `GOVERNANCE_PLUGIN_ACTIONS` | Function | Returns plugin-specific proposal actions | Token Voting, Multisig, Lock To Vote, SPP | -| `GOVERNANCE_PLUGIN_NORMALIZE_ACTIONS` | Function | Normalizes plugin-specific actions for display | Token Voting, Multisig, Lock To Vote | - -## Permissions - -| Slot ID | Type | Description | Registered By | -|---|---|---|---| -| `GOVERNANCE_PERMISSION_CHECK_PROPOSAL_CREATION` | Function | Checks if the connected wallet can create proposals | Token Voting, Multisig, Lock To Vote, Admin, SPP | -| `GOVERNANCE_PERMISSION_CHECK_VOTE_SUBMISSION` | Function | Checks if the connected wallet can submit votes | Token Voting, Multisig, Lock To Vote | -| `GOVERNANCE_EXECUTE_CHECK_VERSION_SUPPORTED` | Function | Checks if the plugin version supports proposal execution | Token Voting, Multisig, Admin | diff --git a/apps/app/docs/slots/overview.md b/apps/app/docs/slots/overview.md index e05e759c01..40e98b5218 100644 --- a/apps/app/docs/slots/overview.md +++ b/apps/app/docs/slots/overview.md @@ -28,23 +28,19 @@ ApplicationSlotId ... |---|---| | Use an existing slot in a page or component | [Consuming Slots](./consuming-slots.md) | | Create a new slot or register a new plugin | [Creating Slots](./creating-slots.md) | -| See all governance slots | [Governance Slots Reference](./governance-slots.md) | -| See all settings slots | [Settings Slots Reference](./settings-slots.md) | -| See all create DAO slots | [Create DAO Slots Reference](./create-dao-slots.md) | -| See application & DAO-level slots | [Application Slots Reference](./application-slots.md) | ## Slot Inventory -| Module | Enum | Slots | Reference | -|---|---|---|---| -| Governance | `GovernanceSlotId` | 20 | [governance-slots.md](./governance-slots.md) | -| Settings | `SettingsSlotId` | 4 | [settings-slots.md](./settings-slots.md) | -| Create DAO | `CreateDaoSlotId` | 5 | [create-dao-slots.md](./create-dao-slots.md) | -| Application | `ApplicationSlotId` | 1 | [application-slots.md](./application-slots.md) | -| Dashboard (DAO-level) | `DashboardDaoSlotId` | 1 | [application-slots.md](./application-slots.md#dao-level-slots) | -| Capital Flow (DAO-level) | `CapitalFlowDaoSlotId` | 2 | [application-slots.md](./application-slots.md#dao-level-slots) | +Slot enums are the source of truth — each module defines its slots in code: -**Total: 33 slots** across 6 enums. See [metrics](./_metrics.md) for the full coverage matrix. +| Module | Enum | Source | +|---|---|---| +| Governance | `GovernanceSlotId` | `src/modules/governance/constants/moduleSlots.ts` | +| Settings | `SettingsSlotId` | `src/modules/settings/constants/moduleSlots.ts` | +| Create DAO | `CreateDaoSlotId` | `src/modules/createDao/constants/moduleSlots.ts` | +| Application | `ApplicationSlotId` | `src/modules/application/constants/moduleSlots.ts` | +| Dashboard (DAO-level) | `DashboardDaoSlotId` | `src/modules/dashboard/constants/moduleDaoSlots.ts` | +| Capital Flow (DAO-level) | `CapitalFlowDaoSlotId` | `src/modules/capitalFlow/constants/moduleDaoSlots.ts` | ## Registered Plugins diff --git a/apps/app/docs/slots/settings-slots.md b/apps/app/docs/slots/settings-slots.md deleted file mode 100644 index 55c2230b57..0000000000 --- a/apps/app/docs/slots/settings-slots.md +++ /dev/null @@ -1,16 +0,0 @@ - - -# Settings Module Slots - -**Enum:** `SettingsSlotId` -**Location:** `src/modules/settings/constants/moduleSlots.ts` - -The settings module defines 5 slots for plugin-specific governance settings, updates, and uninstallation. - -| Slot ID | Type | Description | Registered By | -|---|---|---|---| -| `SETTINGS_GOVERNANCE_SETTINGS_HOOK` | Function | Returns the current governance settings for a plugin | Token Voting, Multisig, Lock To Vote, Admin, SPP | -| `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 | diff --git a/apps/app/package.json b/apps/app/package.json index 21e6a6d2d9..5c3d290a9f 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -18,7 +18,6 @@ "lint": "biome check --write --no-errors-on-unmatched --files-ignore-unknown=true", "lint:check": "biome check --no-errors-on-unmatched --files-ignore-unknown=true", "type-check": "tsc --noEmit", - "generate:slot-docs": "node scripts/generateSlotDocs.mjs", "setup": "./scripts/setupEnv.sh", "e2e": "playwright test --config e2e/playwright.config.ts", "e2e:ui": "playwright test --config e2e/playwright.config.ts --ui", diff --git a/apps/app/scripts/generateSlotDocs.mjs b/apps/app/scripts/generateSlotDocs.mjs deleted file mode 100644 index 87269aafaf..0000000000 --- a/apps/app/scripts/generateSlotDocs.mjs +++ /dev/null @@ -1,584 +0,0 @@ -/** - * Generates slot reference documentation from source code. - * - * Reads enum definitions from moduleSlots.ts / moduleDaoSlots.ts files and - * plugin registrations from plugin index.ts files, then produces: - * - docs/slots/governance-slots.md - * - docs/slots/settings-slots.md - * - docs/slots/create-dao-slots.md - * - docs/slots/application-slots.md - * - docs/slots/_metrics.md - * - * Usage: - * node scripts/generateSlotDocs.mjs # generate files - * node scripts/generateSlotDocs.mjs --check # exit 1 if files would change (CI mode) - */ - -import { readFileSync, writeFileSync } from 'node:fs'; -import { join, resolve } from 'node:path'; - -const ROOT = resolve(import.meta.dirname, '..'); -const DOCS_DIR = join(ROOT, 'docs/slots'); -const CHECK_MODE = process.argv.includes('--check'); - -// --------------------------------------------------------------------------- -// 1. Source definitions -// --------------------------------------------------------------------------- - -const SLOT_ENUM_SOURCES = [ - { - file: 'src/modules/governance/constants/moduleSlots.ts', - enumName: 'GovernanceSlotId', - module: 'Governance', - }, - { - file: 'src/modules/settings/constants/moduleSlots.ts', - enumName: 'SettingsSlotId', - module: 'Settings', - }, - { - file: 'src/modules/createDao/constants/moduleSlots.ts', - enumName: 'CreateDaoSlotId', - module: 'Create DAO', - }, - { - file: 'src/modules/application/constants/moduleSlots.ts', - enumName: 'ApplicationSlotId', - module: 'Application', - }, - { - file: 'src/modules/dashboard/constants/moduleDaoSlots.ts', - enumName: 'DashboardDaoSlotId', - module: 'Dashboard (DAO-level)', - }, - { - file: 'src/modules/capitalFlow/constants/moduleDaoSlots.ts', - enumName: 'CapitalFlowDaoSlotId', - module: 'Capital Flow (DAO-level)', - }, -]; - -const PLUGIN_SOURCES = [ - { - file: 'src/plugins/tokenPlugin/index.ts', - name: 'tokenPlugin', - label: 'Token Voting', - }, - { - file: 'src/plugins/multisigPlugin/index.ts', - name: 'multisigPlugin', - label: 'Multisig', - }, - { - file: 'src/plugins/lockToVotePlugin/index.ts', - name: 'lockToVotePlugin', - label: 'Lock To Vote', - }, - { - file: 'src/plugins/adminPlugin/index.ts', - name: 'adminPlugin', - label: 'Admin', - }, - { file: 'src/plugins/sppPlugin/index.ts', name: 'sppPlugin', label: 'SPP' }, - { - file: 'src/plugins/capitalDistributorPlugin/index.ts', - name: 'capitalDistributorPlugin', - label: 'Capital Distributor', - }, - { - file: 'src/plugins/gaugeVoterPlugin/index.ts', - name: 'gaugeVoterPlugin', - label: 'Gauge Voter', - }, - // Standalone condition registry (conditions are contracts, not plugins). - { - file: 'src/modules/settings/initConditionRegistry.ts', - name: 'conditionRegistry', - label: 'Condition registry', - }, -]; - -// Human-readable descriptions for slot IDs. Falls back to the ID itself if not listed. -const SLOT_DESCRIPTIONS = { - // Governance — Members - GOVERNANCE_DAO_MEMBER_LIST: 'Renders the member list for a DAO', - GOVERNANCE_MEMBER_PANEL: 'Renders the member detail panel', - GOVERNANCE_MEMBER_STATS: - 'Returns member statistics (total members, voting power, etc.)', - // Governance — Proposals - GOVERNANCE_DAO_PROPOSAL_LIST: 'Renders the full proposal list for a DAO', - GOVERNANCE_DAO_PROPOSAL_LIST_ITEM: 'Renders a custom proposal list item', - GOVERNANCE_PROCESS_PROPOSAL_STATUS: - 'Calculates the current status of a proposal', - GOVERNANCE_PROCESS_PROPOSAL_SUCCEEDED: - 'Determines if a proposal has succeeded (reached threshold)', - GOVERNANCE_CREATE_PROPOSAL_SETTINGS_FORM: - 'Renders plugin-specific settings in the create proposal form', - GOVERNANCE_BUILD_CREATE_PROPOSAL_DATA: - 'Builds the transaction data for creating a proposal', - // Governance — Voting - GOVERNANCE_PROPOSAL_VOTING_BREAKDOWN: - 'Renders the voting breakdown (for/against/abstain)', - GOVERNANCE_VOTE_LIST: 'Renders the list of individual votes on a proposal', - GOVERNANCE_SUBMIT_VOTE: 'Renders the vote submission UI', - GOVERNANCE_BUILD_VOTE_DATA: - 'Builds the transaction data for submitting a vote', - GOVERNANCE_PROPOSAL_VOTING_TERMINAL: - 'Renders the voting terminal (used by admin and SPP plugins)', - GOVERNANCE_PROPOSAL_VOTING_MULTI_BODY_SUMMARY: - 'Renders a summary for multi-body voting', - // Governance — Actions - GOVERNANCE_PLUGIN_ACTIONS: 'Returns plugin-specific proposal actions', - GOVERNANCE_PLUGIN_NORMALIZE_ACTIONS: - 'Normalizes plugin-specific actions for display', - // Governance — Permissions - GOVERNANCE_PERMISSION_CHECK_PROPOSAL_CREATION: - 'Checks if the connected wallet can create proposals', - GOVERNANCE_PERMISSION_CHECK_VOTE_SUBMISSION: - 'Checks if the connected wallet can submit votes', - GOVERNANCE_EXECUTE_CHECK_VERSION_SUPPORTED: - 'Checks if the plugin version supports proposal execution', - // Settings - SETTINGS_GOVERNANCE_SETTINGS_HOOK: - 'Returns the current governance settings for a plugin', - SETTINGS_BUILD_PREPARE_PLUGIN_UPDATE_DATA: - 'Builds the transaction data for preparing a plugin update', - SETTINGS_PLUGIN_TO_FORM_DATA: - 'Converts plugin settings to form-compatible data for the settings editor', - SETTINGS_GET_UNINSTALL_HELPERS: - 'Returns helper data needed for uninstalling the plugin', - PERMISSION_CONDITION: - 'Renders the condition-specific detail for a permission grant (routed by condition type)', - // Create DAO - CREATE_DAO_BUILD_PREPARE_PLUGIN_INSTALL_DATA: - 'Builds the transaction data for installing the plugin during DAO creation', - CREATE_DAO_PROCESS_BODY_READ_FIELD: - 'Renders read-only fields for the governance body configuration review step', - CREATE_DAO_PROPOSAL_CREATION_SETTINGS: - 'Renders the proposal creation settings form during DAO setup', - CREATE_DAO_SETUP_MEMBERSHIP: - 'Renders the membership configuration step (who can participate)', - CREATE_DAO_SETUP_GOVERNANCE: - 'Renders the governance configuration step (voting parameters, thresholds)', - // Application - APPLICATION_PLUGIN_PAGE: - 'Renders a plugin-specific page at a custom URL path', - // Dashboard DAO - DASHBOARD_DAO_HEADER: - 'Custom header component for a specific DAO dashboard', - // Capital Flow DAO - CAPITAL_DISTRIBUTOR_MEMBERS_FILE_DOWNLOAD: - 'Custom member file download UI for reward distribution', - CAPITAL_DISTRIBUTOR_VOTING_ESCROW_ADDRESS: - 'Returns the voting escrow contract address for a specific DAO', -}; - -// Governance slot groupings for organized output -const GOVERNANCE_GROUPS = [ - { - heading: 'Members', - slots: [ - 'GOVERNANCE_DAO_MEMBER_LIST', - 'GOVERNANCE_MEMBER_PANEL', - 'GOVERNANCE_MEMBER_STATS', - ], - }, - { - heading: 'Proposals', - slots: [ - 'GOVERNANCE_DAO_PROPOSAL_LIST', - 'GOVERNANCE_DAO_PROPOSAL_LIST_ITEM', - 'GOVERNANCE_PROCESS_PROPOSAL_STATUS', - 'GOVERNANCE_PROCESS_PROPOSAL_SUCCEEDED', - 'GOVERNANCE_CREATE_PROPOSAL_SETTINGS_FORM', - 'GOVERNANCE_BUILD_CREATE_PROPOSAL_DATA', - ], - }, - { - heading: 'Voting', - slots: [ - 'GOVERNANCE_PROPOSAL_VOTING_BREAKDOWN', - 'GOVERNANCE_VOTE_LIST', - 'GOVERNANCE_SUBMIT_VOTE', - 'GOVERNANCE_BUILD_VOTE_DATA', - 'GOVERNANCE_PROPOSAL_VOTING_TERMINAL', - 'GOVERNANCE_PROPOSAL_VOTING_MULTI_BODY_SUMMARY', - ], - }, - { - heading: 'Actions', - slots: [ - 'GOVERNANCE_PLUGIN_ACTIONS', - 'GOVERNANCE_PLUGIN_NORMALIZE_ACTIONS', - ], - }, - { - heading: 'Permissions', - slots: [ - 'GOVERNANCE_PERMISSION_CHECK_PROPOSAL_CREATION', - 'GOVERNANCE_PERMISSION_CHECK_VOTE_SUBMISSION', - 'GOVERNANCE_EXECUTE_CHECK_VERSION_SUPPORTED', - ], - }, -]; - -// --------------------------------------------------------------------------- -// 2. Parsers -// --------------------------------------------------------------------------- - -function extractSlotIds(filePath) { - const content = readFileSync(join(ROOT, filePath), 'utf-8'); - const ids = []; - for (const match of content.matchAll(/^\s+(\w+)\s*=/gm)) { - ids.push(match[1]); - } - return ids; -} - -function extractRegistrations(filePath) { - const content = readFileSync(join(ROOT, filePath), 'utf-8'); - const registrations = []; - - // Match registerSlotComponent and registerSlotFunction calls - const pattern = - /\.registerSlot(Component|Function)\(\{[^}]*slotId:\s*[\w.]*\.(\w+)/g; - for (const match of content.matchAll(pattern)) { - registrations.push({ type: match[1].toLowerCase(), slotId: match[2] }); - } - - // Also match getPageSlotId patterns for dynamic slots - const pagePattern = /getPageSlotId\(\s*\w+\.(\w+)/g; - for (const match of content.matchAll(pagePattern)) { - registrations.push({ type: 'component', slotId: match[1] }); - } - - return registrations; -} - -// --------------------------------------------------------------------------- -// 3. Build data model -// --------------------------------------------------------------------------- - -// Parse all enums -const slotsByModule = {}; -for (const source of SLOT_ENUM_SOURCES) { - slotsByModule[source.module] = { - ...source, - slotIds: extractSlotIds(source.file), - }; -} - -// Parse all plugin registrations -const pluginRegistrations = {}; -for (const plugin of PLUGIN_SOURCES) { - pluginRegistrations[plugin.name] = { - ...plugin, - registrations: extractRegistrations(plugin.file), - }; -} - -// Build a map: slotId -> { type, registeredBy[] } -const slotRegistry = {}; -for (const source of Object.values(slotsByModule)) { - for (const id of source.slotIds) { - slotRegistry[id] = { registeredBy: [], types: new Set() }; - } -} - -for (const [, plugin] of Object.entries(pluginRegistrations)) { - for (const reg of plugin.registrations) { - if (slotRegistry[reg.slotId]) { - slotRegistry[reg.slotId].registeredBy.push(plugin.label); - slotRegistry[reg.slotId].types.add(reg.type); - } - } -} - -// --------------------------------------------------------------------------- -// 4. Generators -// --------------------------------------------------------------------------- - -const GENERATED_HEADER = - '\n\n'; - -function slotType(id) { - const entry = slotRegistry[id]; - if (!entry || entry.types.size === 0) { - return '-'; - } - const types = [...entry.types].map( - (t) => t.charAt(0).toUpperCase() + t.slice(1), - ); - return types.join(', '); -} - -function slotRegisteredBy(id) { - const entry = slotRegistry[id]; - if (!entry || entry.registeredBy.length === 0) { - return '-'; - } - return [...new Set(entry.registeredBy)].join(', '); -} - -function slotDescription(id) { - return SLOT_DESCRIPTIONS[id] ?? id; -} - -function generateTable(slotIds) { - const rows = slotIds.map( - (id) => - `| \`${id}\` | ${slotType(id)} | ${slotDescription(id)} | ${slotRegisteredBy(id)} |`, - ); - return `| Slot ID | Type | Description | Registered By |\n|---|---|---|---|\n${rows.join('\n')}`; -} - -// --- governance-slots.md --- -function generateGovernanceSlots() { - const gov = slotsByModule['Governance']; - let md = GENERATED_HEADER; - md += '# Governance Module Slots\n\n'; - md += `**Enum:** \`${gov.enumName}\`\n`; - md += `**Location:** \`${gov.file}\`\n\n`; - md += `The governance module has the largest slot surface area with ${gov.slotIds.length} slots covering members, proposals, voting, and\npermissions.\n`; - - for (const group of GOVERNANCE_GROUPS) { - md += `\n## ${group.heading}\n\n`; - md += generateTable(group.slots); - md += '\n'; - } - - return md; -} - -// --- settings-slots.md --- -function generateSettingsSlots() { - const settings = slotsByModule['Settings']; - let md = GENERATED_HEADER; - md += '# Settings Module Slots\n\n'; - md += `**Enum:** \`${settings.enumName}\`\n`; - md += `**Location:** \`${settings.file}\`\n\n`; - md += `The settings module defines ${settings.slotIds.length} slots for plugin-specific governance settings, updates, and uninstallation.\n\n`; - md += generateTable(settings.slotIds); - md += '\n'; - return md; -} - -// --- create-dao-slots.md --- -function generateCreateDaoSlots() { - const createDao = slotsByModule['Create DAO']; - let md = GENERATED_HEADER; - md += '# Create DAO Module Slots\n\n'; - md += `**Enum:** \`${createDao.enumName}\`\n`; - md += `**Location:** \`${createDao.file}\`\n\n`; - md += `The create DAO module defines ${createDao.slotIds.length} slots used during the DAO creation wizard to configure plugins.\n\n`; - md += generateTable(createDao.slotIds); - md += '\n'; - return md; -} - -// --- application-slots.md --- -function generateApplicationSlots() { - const app = slotsByModule['Application']; - const dashboard = slotsByModule['Dashboard (DAO-level)']; - const capitalFlow = slotsByModule['Capital Flow (DAO-level)']; - - let md = GENERATED_HEADER; - md += '# Application & DAO-Level Slots\n\n'; - - md += '## Application Module Slots\n\n'; - md += `**Enum:** \`${app.enumName}\`\n`; - md += `**Location:** \`${app.file}\`\n\n`; - md += generateTable(app.slotIds); - md += '\n\n'; - - md += '### How Plugin Pages Work\n\n'; - md += - 'Unlike other slots, `APPLICATION_PLUGIN_PAGE` uses **dynamic slot IDs** constructed from URL segments:\n\n'; - md += '```typescript\n'; - md += '// Registration (in plugin index.ts)\n'; - md += 'pluginRegistryUtils.registerSlotComponent({\n'; - md += ' slotId: pluginRegistryUtils.getPageSlotId(\n'; - md += ' ApplicationSlotId.APPLICATION_PLUGIN_PAGE,\n'; - md += ` [CapitalDistributorPluginPages.REWARDS], // "rewards"\n`; - md += ' ),\n'; - md += ` // Resulting slot ID: "APPLICATION_PLUGIN_PAGE-rewards"\n`; - md += ' pluginId: capitalDistributorPlugin.id,\n'; - md += ' component: CapitalDistributorRewardsPage,\n'; - md += '});\n'; - md += '```\n\n'; - - md += - 'The `DaoPluginPage` server component (`src/modules/application/pages/daoPluginPage/daoPluginPage.tsx`) resolves these\n'; - md += `at render time by iterating over the DAO's installed plugins and checking which one has a registered component for the\ncurrent URL segments.\n\n`; - - md += '### Plugin Navigation Links\n\n'; - md += - 'Plugins can add navigation links to the DAO sidebar using `pageLinksLeft` and `pageLinksRight` on their `IPluginInfo`\ndefinition:\n\n'; - md += '```typescript\n'; - md += 'export const myPlugin: IPluginInfo = {\n'; - md += ' // ... other fields\n'; - md += ' pageLinksRight: (baseUrl, context) => [\n'; - md += ' {\n'; - md += ` label: 'app.plugins.myPlugin.meta.link.dashboard',\n`; - // biome-ignore lint/suspicious/noTemplateCurlyInString: intentional code example in markdown - md += ' link: `${baseUrl}/dashboard`,\n'; - md += ' icon: IconType.APP_PROPOSALS,\n'; - md += ` lgHidden: context === 'dialog',\n`; - md += ' },\n'; - md += ' ],\n'; - md += '};\n'; - md += '```\n\n'; - - md += '### Current Plugin Pages\n\n'; - md += '| Plugin | Page Slug | Description |\n|---|---|---|\n'; - md += - '| Capital Distributor | `/rewards` | Token reward distribution management |\n'; - md += '| Gauge Voter | `/gauges` | Gauge voting interface |\n\n'; - - md += '## DAO-Level Slots\n\n'; - md += - 'DAO-level slots are different from plugin-level slots. They are scoped to **specific DAO instances** rather than plugin\n'; - md += - 'types, and are registered from `src/daos/{daoName}/index.ts` instead of `src/plugins/`.\n\n'; - md += `Use these when a particular DAO needs custom UI that isn't tied to its plugin type.\n\n`; - - md += '### Dashboard DAO Slots\n\n'; - md += `**Enum:** \`${dashboard.enumName}\`\n`; - md += `**Location:** \`${dashboard.file}\`\n\n`; - md += generateTable(dashboard.slotIds); - md += '\n\n'; - - md += '### Capital Flow DAO Slots\n\n'; - md += `**Enum:** \`${capitalFlow.enumName}\`\n`; - md += `**Location:** \`${capitalFlow.file}\`\n\n`; - md += generateTable(capitalFlow.slotIds); - md += '\n\n'; - - md += '### How DAO Customizations Are Initialized\n\n'; - md += - 'DAO-specific registrations are called from `src/daos/index.ts` and run as part of `initPluginRegistry()` alongside\nplugin and policy initialization:\n\n'; - md += '```\n'; - md += 'initPluginRegistry()\n'; - md += '├── initialisePlugins() # All plugin slot registrations\n'; - md += - '├── initialisePolicyPluginRegistry() # Capital flow policy resolvers\n'; - md += '└── initialiseDaos() # DAO-specific slot overrides\n'; - md += '```\n'; - - return md; -} - -// --- _metrics.md --- -function generateMetrics() { - const totalSlots = Object.keys(slotRegistry).length; - const unregistered = Object.entries(slotRegistry) - .filter(([, v]) => v.registeredBy.length === 0) - .map(([id]) => id); - - let md = GENERATED_HEADER; - md += '# Slot System Metrics\n\n'; - md += - '> Auto-generated snapshot of the slot system state. Run `pnpm generate:slot-docs` (from `apps/app`) to refresh.\n\n'; - - // Summary - md += '## Summary\n\n'; - md += '| Metric | Value |\n|---|---|\n'; - md += `| Total slots | ${totalSlots} |\n`; - md += `| Registered plugins | ${PLUGIN_SOURCES.length} |\n`; - md += `| Unregistered slots | ${unregistered.length} |\n\n`; - - // Slots per module - md += '## Slots Per Module\n\n'; - md += '| Module | Enum | Count |\n|---|---|---|\n'; - for (const source of Object.values(slotsByModule)) { - md += `| ${source.module} | \`${source.enumName}\` | ${source.slotIds.length} |\n`; - } - md += '\n'; - - // Plugin coverage matrix - md += '## Plugin Coverage Matrix\n\n'; - const modules = ['Governance', 'Settings', 'Create DAO', 'Application']; - md += `| Plugin | ${modules.join(' | ')} | Total |\n`; - md += `|---|${modules.map(() => '---').join('|')}|---|\n`; - - for (const plugin of PLUGIN_SOURCES) { - const counts = {}; - let total = 0; - for (const mod of modules) { - counts[mod] = 0; - } - for (const reg of pluginRegistrations[plugin.name].registrations) { - for (const [mod, source] of Object.entries(slotsByModule)) { - if (source.slotIds.includes(reg.slotId)) { - const mappedMod = - modules.find((m) => mod.startsWith(m)) ?? mod; - if (counts[mappedMod] !== undefined) { - counts[mappedMod]++; - } - total++; - } - } - } - const cells = modules.map((m) => - counts[m] > 0 ? String(counts[m]) : '-', - ); - md += `| ${plugin.label} | ${cells.join(' | ')} | ${total} |\n`; - } - md += '\n'; - - // Unregistered slots - if (unregistered.length > 0) { - md += '## Unregistered Slots\n\n'; - md += - 'These slots are defined in enums but no plugin currently registers for them:\n\n'; - for (const id of unregistered) { - md += `- \`${id}\`\n`; - } - md += '\n'; - } - - return md; -} - -// --------------------------------------------------------------------------- -// 5. Write or check -// --------------------------------------------------------------------------- - -const outputs = [ - { file: 'governance-slots.md', content: generateGovernanceSlots() }, - { file: 'settings-slots.md', content: generateSettingsSlots() }, - { file: 'create-dao-slots.md', content: generateCreateDaoSlots() }, - { file: 'application-slots.md', content: generateApplicationSlots() }, - { file: '_metrics.md', content: generateMetrics() }, -]; - -let stale = false; - -for (const { file, content } of outputs) { - const filePath = join(DOCS_DIR, file); - - if (CHECK_MODE) { - let existing = ''; - try { - existing = readFileSync(filePath, 'utf-8'); - } catch { - // File doesn't exist — counts as stale - } - if (existing !== content) { - console.error(`Out of date: docs/slots/${file}`); - stale = true; - } - } else { - writeFileSync(filePath, content); - console.log(`Generated: docs/slots/${file}`); - } -} - -if (CHECK_MODE && stale) { - console.error( - '\nSlot docs are out of date. Run from apps/app: pnpm generate:slot-docs', - ); - process.exit(1); -} else if (CHECK_MODE) { - console.log('Slot docs are up to date.'); -} From 738f6190e043c1cc70508705e487ffa1904b0290 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Mon, 3 Aug 2026 12:08:39 +0200 Subject: [PATCH 06/10] fix: Use none-bump changeset for stack layer --- .changeset/app-1003-03-list-empty.md | 2 -- .changeset/app-1003-03-list-nopublish.md | 5 +++++ 2 files changed, 5 insertions(+), 2 deletions(-) delete mode 100644 .changeset/app-1003-03-list-empty.md create mode 100644 .changeset/app-1003-03-list-nopublish.md diff --git a/.changeset/app-1003-03-list-empty.md b/.changeset/app-1003-03-list-empty.md deleted file mode 100644 index a845151cc8..0000000000 --- a/.changeset/app-1003-03-list-empty.md +++ /dev/null @@ -1,2 +0,0 @@ ---- ---- diff --git a/.changeset/app-1003-03-list-nopublish.md b/.changeset/app-1003-03-list-nopublish.md new file mode 100644 index 0000000000..216ca578d5 --- /dev/null +++ b/.changeset/app-1003-03-list-nopublish.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": none +--- + +Internal layer of the APP-1003 permissions viewer stack — no user-facing changes. From 37ccf7853835642b66c36b8ef0e35b312513deed Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Mon, 3 Aug 2026 12:20:44 +0200 Subject: [PATCH 07/10] fix: Clean none-bump changeset for 03-list --- .changeset/app-1003-03-list-nopublish.md | 5 ----- .changeset/app-1003-03-list.md | 5 +++++ 2 files changed, 5 insertions(+), 5 deletions(-) delete mode 100644 .changeset/app-1003-03-list-nopublish.md create mode 100644 .changeset/app-1003-03-list.md diff --git a/.changeset/app-1003-03-list-nopublish.md b/.changeset/app-1003-03-list-nopublish.md deleted file mode 100644 index 216ca578d5..0000000000 --- a/.changeset/app-1003-03-list-nopublish.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"@aragon/app": none ---- - -Internal layer of the APP-1003 permissions viewer stack — no user-facing changes. diff --git a/.changeset/app-1003-03-list.md b/.changeset/app-1003-03-list.md new file mode 100644 index 0000000000..8ab0720bf7 --- /dev/null +++ b/.changeset/app-1003-03-list.md @@ -0,0 +1,5 @@ +--- +"@aragon/app": none +--- + +Internal stack layer — no user-facing changes. From 9603365441d5c3ec4b0d484830c394d9fe4fee83 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Tue, 4 Aug 2026 13:33:12 +0200 Subject: [PATCH 08/10] fix: Propagate permission brand rename to list layer --- .../components/permissionEntityIcons/safeAccountAvatar.tsx | 4 ++-- .../components/permissionsList/permissionEntityCell.tsx | 6 +++--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/app/src/modules/settings/components/permissionEntityIcons/safeAccountAvatar.tsx b/apps/app/src/modules/settings/components/permissionEntityIcons/safeAccountAvatar.tsx index 54d6939590..8d1758184f 100644 --- a/apps/app/src/modules/settings/components/permissionEntityIcons/safeAccountAvatar.tsx +++ b/apps/app/src/modules/settings/components/permissionEntityIcons/safeAccountAvatar.tsx @@ -1,6 +1,6 @@ import { Avatar } from '@aragon/gov-ui-kit'; import { brandedExternals } from '@/plugins/sppPlugin/constants/sppPluginBrandedExternals'; -import { PermissionEntityBrandId } from '@/shared/api/daoService'; +import { PermissionEntityExternalBrandId } from '@/shared/api/daoService'; /** * Safe-branded avatar displayed next to entities resolved with `brandId: 'safe'`. @@ -12,6 +12,6 @@ export const SafeAccountAvatar: React.FC = () => ( className="shrink-0" fallback={} size="sm" - src={brandedExternals[PermissionEntityBrandId.SAFE]?.logo} + src={brandedExternals[PermissionEntityExternalBrandId.SAFE]?.logo} /> ); diff --git a/apps/app/src/modules/settings/components/permissionsList/permissionEntityCell.tsx b/apps/app/src/modules/settings/components/permissionsList/permissionEntityCell.tsx index d07f758cdd..390c2ae0dc 100644 --- a/apps/app/src/modules/settings/components/permissionsList/permissionEntityCell.tsx +++ b/apps/app/src/modules/settings/components/permissionsList/permissionEntityCell.tsx @@ -1,5 +1,5 @@ import { Avatar, addressUtils, DaoAvatar, Tag } from '@aragon/gov-ui-kit'; -import { PermissionEntityBrandId } from '@/shared/api/daoService'; +import { PermissionEntityExternalBrandId } from '@/shared/api/daoService'; import { ANY_ADDR } from '../../constants/permissionSentinels'; import type { IPermissionEntity } from '../../utils/permissionEntityUtils'; import { MembersAvatarIcon, SafeAccountAvatar } from '../permissionEntityIcons'; @@ -16,11 +16,11 @@ export const PermissionEntityCell: React.FC = ({ {entity.type === 'dao' && ( )} - {entity.brandId === PermissionEntityBrandId.SAFE && ( + {entity.brandId === PermissionEntityExternalBrandId.SAFE && ( )} {entity.type === 'plugin' && - entity.brandId !== PermissionEntityBrandId.SAFE && + entity.brandId !== PermissionEntityExternalBrandId.SAFE && entity.tag != null && ( Date: Tue, 4 Aug 2026 19:33:02 +0200 Subject: [PATCH 09/10] fix(APP-1003): Restore accidentally removed alchemix translations --- apps/app/src/assets/locales/en.json | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index 81646842bc..b3f37ac3d0 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -1432,6 +1432,31 @@ } }, "daos": { + "alchemix": { + "alchemixSubmitVote": { + "alsoVote": { + "description": "Cast the same option with the voting power delegated to you in the same transaction.", + "label": "Also vote with your voting power" + }, + "buttons": { + "override": "Override vote" + }, + "delegateNotVoted": "Hasn't voted yet · delegated your {{amount}} {{symbol}}", + "delegateOverridden": "No longer votes with your {{amount}} {{symbol}}", + "delegateTag": "Your delegate", + "delegateVoted": "Voted with your {{amount}} {{symbol}}", + "delegateVotedOverridden": "Voted without your {{amount}} {{symbol}}", + "options": { + "alreadyCounted": "Your tokens already count toward {{option}}", + "currentVote": "Your current vote" + }, + "position": { + "overrode": "Voting with {{amount}} {{symbol}} · overrode {{delegate}}", + "voted": "Voting with {{amount}} {{symbol}}" + }, + "youTag": "You" + } + }, "aragonDemo": { "aragonDemoPageHeader": { "cta": "Create proposal", From f516b33be986dfac06d59e3ed1167af081d68be0 Mon Sep 17 00:00:00 2001 From: Kevin Davis Date: Wed, 5 Aug 2026 02:53:22 +0200 Subject: [PATCH 10/10] fix(APP-1003): Restore accidentally removed token objection translations --- apps/app/src/assets/locales/en.json | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/apps/app/src/assets/locales/en.json b/apps/app/src/assets/locales/en.json index b3f37ac3d0..4b40bb3c3b 100644 --- a/apps/app/src/assets/locales/en.json +++ b/apps/app/src/assets/locales/en.json @@ -3494,6 +3494,10 @@ "approveYesDescription": ", I approve", "label": "Do you want to {{label}} this proposal?", "no": "No", + "object": "Object", + "objectionDescription": ", I object", + "objectionHelpText": "During the objection phase you can only vote \"No\" to object to the proposal.", + "objectionLabel": "object to", "vetoLabel": "veto", "vetoNoDescription": ", I don't veto", "vetoYesDescription": ", I veto", @@ -3501,6 +3505,7 @@ }, "voteDescription": { "approve": "to approve", + "objection": "to object", "veto": "to veto" } },