diff --git a/src/components/common/ColonyActionsTable/ColonyActionsTable.tsx b/src/components/common/ColonyActionsTable/ColonyActionsTable.tsx index e24442cd0a9..4f5082277fb 100644 --- a/src/components/common/ColonyActionsTable/ColonyActionsTable.tsx +++ b/src/components/common/ColonyActionsTable/ColonyActionsTable.tsx @@ -1,12 +1,12 @@ -import React, { type FC, useEffect } from 'react'; +import React, { type FC } from 'react'; -import { useActionSidebarContext } from '~context/ActionSidebarContext/ActionSidebarContext.ts'; import { type ColonyAction } from '~types/graphql.ts'; import { formatText } from '~utils/intl.ts'; import Table from '~v5/common/Table/index.ts'; import TableHeader from '~v5/common/TableHeader/TableHeader.tsx'; import { useActionsTableProps } from './hooks/useActionsTableProps.tsx'; +import { useHandleRedoAction } from './hooks/useHandleRedoAction.ts'; import ActionsTableFilters from './partials/ActionsTableFilters/index.ts'; import { type ColonyActionsTableProps } from './types.ts'; @@ -21,24 +21,8 @@ const ColonyActionsTable: FC = ({ rest, actionProps.setSelectedAction, ); - const { - actionSidebarToggle: [, { toggleOn: toggleActionSidebarOn }], - } = useActionSidebarContext(); - - useEffect(() => { - if (actionProps.defaultValues && actionProps.selectedAction) { - toggleActionSidebarOn({ ...actionProps.defaultValues }); - - setTimeout(() => { - actionProps.setSelectedAction(undefined); - }, 50); - } - }, [ - actionProps.defaultValues, - toggleActionSidebarOn, - actionProps.selectedAction, - actionProps, - ]); + + useHandleRedoAction({ actionProps }); return ( <> diff --git a/src/components/common/ColonyActionsTable/RecentActivityTable.tsx b/src/components/common/ColonyActionsTable/RecentActivityTable.tsx index cc1be8995f8..53a3b2b75df 100644 --- a/src/components/common/ColonyActionsTable/RecentActivityTable.tsx +++ b/src/components/common/ColonyActionsTable/RecentActivityTable.tsx @@ -6,6 +6,7 @@ import { type ColonyAction } from '~types/graphql.ts'; import Table from '~v5/common/Table/index.ts'; import { useActionsTableProps } from './hooks/useActionsTableProps.tsx'; +import { useHandleRedoAction } from './hooks/useHandleRedoAction.ts'; import { type ColonyActionsTableProps } from './types.ts'; const displayName = 'common.RecentActivityTable'; @@ -34,6 +35,8 @@ const RecentActivityTable: FC = ({ actionProps.setSelectedAction, ); + useHandleRedoAction({ actionProps }); + return ( {...tableProps} diff --git a/src/components/common/ColonyActionsTable/hooks/useActionsTableProps.tsx b/src/components/common/ColonyActionsTable/hooks/useActionsTableProps.tsx index 0e80a8a5a72..46f7981690d 100644 --- a/src/components/common/ColonyActionsTable/hooks/useActionsTableProps.tsx +++ b/src/components/common/ColonyActionsTable/hooks/useActionsTableProps.tsx @@ -1,44 +1,27 @@ -import { - ArrowSquareOut, - FilePlus, - ShareNetwork, - Binoculars, - Repeat, -} from '@phosphor-icons/react'; +import { Binoculars } from '@phosphor-icons/react'; import clsx from 'clsx'; import React from 'react'; -import { generatePath, useNavigate } from 'react-router-dom'; -import { APP_URL, DEFAULT_NETWORK_INFO } from '~constants'; import { useActionSidebarContext } from '~context/ActionSidebarContext/ActionSidebarContext.ts'; -import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; import { useMobile } from '~hooks/index.ts'; -import { type ActivityFeedColonyAction } from '~hooks/useActivityFeed/types.ts'; -import { - COLONY_ACTIVITY_ROUTE, - COLONY_HOME_ROUTE, - TX_SEARCH_PARAM, -} from '~routes'; -import TransactionLink from '~shared/TransactionLink/index.ts'; import { type ColonyAction } from '~types/graphql.ts'; -import { formatText } from '~utils/intl.ts'; import { merge } from '~utils/lodash.ts'; import EmptyContent from '~v5/common/EmptyContent/index.ts'; import { MEATBALL_MENU_COLUMN_ID } from '~v5/common/Table/consts.ts'; import { type TableProps } from '~v5/common/Table/types.ts'; import { useFiltersContext } from '../FiltersContext/FiltersContext.ts'; -import MeatballMenuCopyItem from '../partials/MeatballMenuCopyItem/MeatballMenuCopyItem.tsx'; import { type ColonyActionsTableProps } from '../types.ts'; import useActionsTableData from './useActionsTableData.ts'; import useColonyActionsTableColumns from './useColonyActionsTableColumns.tsx'; +import { useGetMenuProps } from './useGetMenuProps.tsx'; import useRenderRowLink from './useRenderRowLink.tsx'; import useRenderSubComponent from './useRenderSubComponent.tsx'; export const useActionsTableProps = ( props: Omit, - setAction: (actionHash: string) => void, + setAction: ColonyActionsTableProps['actionProps']['setSelectedAction'], ) => { const { className, @@ -53,8 +36,8 @@ export const useActionsTableProps = ( const { searchFilter, selectedFiltersCount } = useFiltersContext(); const { - data, - loading, + data: colonyActions, + loading: colonyActionsLoading, loadingMotionStates, goToNextPage, goToPreviousPage, @@ -67,7 +50,7 @@ export const useActionsTableProps = ( } = useActionsTableData(pageSize); const columns = useColonyActionsTableColumns({ - loading, + loading: colonyActionsLoading, loadingMotionStates, refetchMotionStates, showUserAvatar, @@ -75,78 +58,27 @@ export const useActionsTableProps = ( const { actionSidebarToggle: [, { toggleOn: toggleActionSidebarOn }], } = useActionSidebarContext(); - const navigate = useNavigate(); - const { - colony: { name: colonyName }, - } = useColonyContext(); - const getMenuProps: TableProps['getMenuProps'] = ({ - original: { transactionHash }, - }) => ({ - disabled: loading, - dropdownPlacementProps: { - withAutoTopPlacement: true, - top: 10, - }, - items: [ - { - key: '1', - label: formatText({ id: 'activityFeedTable.menu.view' }), - icon: FilePlus, - onClick: () => { - navigate( - `${window.location.pathname}?${TX_SEARCH_PARAM}=${transactionHash}`, - { - replace: true, - }, - ); - }, - }, - { - key: '2', - label: ( - - {formatText( - { id: 'activityFeedTable.menu.viewOnNetwork' }, - { - blockExplorerName: DEFAULT_NETWORK_INFO.blockExplorerName, - }, - )} - - ), - icon: ArrowSquareOut, - }, - { - key: '3', - label: formatText({ id: 'activityFeedTable.menu.share' }), - renderItemWrapper: (itemWrapperProps, children) => ( - - {children} - - ), - icon: ShareNetwork, - onClick: () => false, - }, - { - key: '4', - label: formatText({ id: 'completedAction.redoAction' }), - icon: Repeat, - onClick: () => setAction(transactionHash), - }, - ], + + const getMenuProps = useGetMenuProps({ + setAction, + colonyActions, + colonyActionsLoading, }); - const isMobile = useMobile(); - const renderRowLink = useRenderRowLink(loading, isRecentActivityVariant); + + const renderRowLink = useRenderRowLink( + colonyActionsLoading, + isRecentActivityVariant, + ); + const renderSubComponent = useRenderSubComponent({ loadingMotionStates, - loading, + loading: colonyActionsLoading, refetchMotionStates, getMenuProps, }); + + const isMobile = useMobile(); + const tableProps = merge( { className: clsx( @@ -155,7 +87,8 @@ export const useActionsTableProps = ( { 'sm:[&_td]:h-[66px]': isRecentActivityVariant, 'sm:[&_td]:h-[70px]': !isRecentActivityVariant, - 'sm:[&_tr:hover]:bg-gray-25': data.length > 0 && !loading, + 'sm:[&_tr:hover]:bg-gray-25': + colonyActions.length > 0 && !colonyActionsLoading, }, ), enableSortingRemoval: false, @@ -178,7 +111,7 @@ export const useActionsTableProps = ( pageSize, }, }, - additionalPaginationButtonsContent: loading + additionalPaginationButtonsContent: colonyActionsLoading ? undefined : additionalPaginationButtonsContent, onSortingChange: setSorting, @@ -186,14 +119,14 @@ export const useActionsTableProps = ( meatBallMenuStaticSize: isRecentActivityVariant ? '2rem' : '3rem', getMenuProps, columns, - data, + data: colonyActions, manualPagination: true, - canNextPage: hasNextPage || loading, + canNextPage: hasNextPage || colonyActionsLoading, canPreviousPage: hasPrevPage, showTotalPagesNumber, nextPage: goToNextPage, previousPage: goToPreviousPage, - paginationDisabled: loading, + paginationDisabled: colonyActionsLoading, getRowCanExpand: () => isMobile, emptyContent: ( { + const [redoEnabledActionsMap, setRedoEnabledActionsMap] = useState< + Record + >({}); + + const [getHistoricRoles] = useGetColonyHistoricRoleRolesLazyQuery({ + fetchPolicy: 'cache-and-network', + }); + + // I'm using simple stringification to help the useEffect hook with shallow comparison and avoid unnecessary rerenders + const stringifiedColonyActions = JSON.stringify(colonyActions); + + useEffect(() => { + const buildRedoEnabledActionsMap = async () => { + const originalColonyActions = JSON.parse( + stringifiedColonyActions, + ) as typeof colonyActions; + + if (colonyActionsLoading || originalColonyActions.length === 0) { + return; + } + + const updatedActionsMap: typeof redoEnabledActionsMap = {}; + const promises: Promise[] = []; + + originalColonyActions.forEach((colonyAction) => { + switch ( + colonyAction.type as ColonyActionType | ExtendedColonyActionType + ) { + case ColonyActionType.SetUserRoles: + case ColonyActionType.SetUserRolesMotion: + case ColonyActionType.SetUserRolesMultisig: { + const { + blockNumber, + colonyAddress, + fromDomain, + recipientAddress, + rolesAreMultiSig, + roles, + motionData, + multiSigData, + } = colonyAction; + + const promise = async () => { + const result = await getHistoricRoles({ + variables: { + id: getHistoricRolesDatabaseId({ + blockNumber, + colonyAddress, + nativeId: fromDomain?.nativeId, + recipientAddress, + isMultiSig: rolesAreMultiSig, + }), + }, + }); + + const dbPermissionsNew = transformActionRolesToColonyRoles( + result?.data?.getColonyHistoricRole || roles, + ); + + const isMotion = !!motionData; + const isMultiSig = !!multiSigData; + + const shouldShowRedoItem = + !!dbPermissionsNew.length || + (isMotion && !motionData?.isFinalized) || + (isMultiSig && !multiSigData?.isExecuted); + + updatedActionsMap[colonyAction.transactionHash] = + shouldShowRedoItem; + }; + + promises.push(promise()); + break; + } + + case ColonyActionType.AddVerifiedMembers: + case ColonyActionType.AddVerifiedMembersMotion: + case ColonyActionType.AddVerifiedMembersMultisig: + case ColonyActionType.CreateDecisionMotion: + case ColonyActionType.ColonyEdit: + case ColonyActionType.ColonyEditMotion: + case ColonyActionType.ColonyEditMultisig: + case ColonyActionType.CreateDomain: + case ColonyActionType.CreateDomainMotion: + case ColonyActionType.CreateDomainMultisig: + case ColonyActionType.EditDomain: + case ColonyActionType.EditDomainMotion: + case ColonyActionType.EditDomainMultisig: + case ColonyActionType.RemoveVerifiedMembers: + case ColonyActionType.RemoveVerifiedMembersMotion: + case ColonyActionType.RemoveVerifiedMembersMultisig: + case ColonyActionType.UnlockToken: + case ColonyActionType.UnlockTokenMotion: + case ColonyActionType.UnlockTokenMultisig: + case ColonyActionType.VersionUpgrade: + case ColonyActionType.VersionUpgradeMotion: + case ColonyActionType.VersionUpgradeMultisig: + case ExtendedColonyActionType.UpdateColonyObjective: + case ExtendedColonyActionType.UpdateColonyObjectiveMotion: + case ExtendedColonyActionType.UpdateColonyObjectiveMultisig: + updatedActionsMap[colonyAction.transactionHash] = false; + break; + + default: + updatedActionsMap[colonyAction.transactionHash] = true; + } + }); + + await Promise.all(promises); + + setRedoEnabledActionsMap(updatedActionsMap); + }; + + buildRedoEnabledActionsMap(); + }, [colonyActionsLoading, getHistoricRoles, stringifiedColonyActions]); + + return redoEnabledActionsMap; +}; diff --git a/src/components/common/ColonyActionsTable/hooks/useGetMenuProps.tsx b/src/components/common/ColonyActionsTable/hooks/useGetMenuProps.tsx new file mode 100644 index 00000000000..4a2c44d05c7 --- /dev/null +++ b/src/components/common/ColonyActionsTable/hooks/useGetMenuProps.tsx @@ -0,0 +1,113 @@ +import { + ArrowSquareOut, + FilePlus, + ShareNetwork, + Repeat, +} from '@phosphor-icons/react'; +import React from 'react'; +import { generatePath, useNavigate } from 'react-router-dom'; + +import { APP_URL, DEFAULT_NETWORK_INFO } from '~constants'; +import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; +import { type ActivityFeedColonyAction } from '~hooks/useActivityFeed/types.ts'; +import { + COLONY_ACTIVITY_ROUTE, + COLONY_HOME_ROUTE, + TX_SEARCH_PARAM, +} from '~routes'; +import TransactionLink from '~shared/TransactionLink/index.ts'; +import { formatText } from '~utils/intl.ts'; +import { type TableProps } from '~v5/common/Table/types.ts'; + +import MeatballMenuCopyItem from '../partials/MeatballMenuCopyItem/index.ts'; +import { type ColonyActionsTableProps } from '../types.ts'; + +import { useBuildRedoEnabledActionsMap } from './useBuildRedoEnabledActionsMap.ts'; + +export const useGetMenuProps = ({ + colonyActionsLoading, + setAction, + colonyActions, +}: { + colonyActionsLoading: boolean; + setAction: ColonyActionsTableProps['actionProps']['setSelectedAction']; + colonyActions: ActivityFeedColonyAction[]; +}) => { + const navigate = useNavigate(); + + const { + colony: { name: colonyName }, + } = useColonyContext(); + + const redoEnabledActionsMap = useBuildRedoEnabledActionsMap({ + colonyActions, + colonyActionsLoading, + }); + + const getMenuProps: TableProps['getMenuProps'] = ({ + original: colonyAction, + }) => { + const { transactionHash } = colonyAction; + + return { + disabled: colonyActionsLoading, + items: [ + { + key: '1', + label: formatText({ id: 'activityFeedTable.menu.view' }), + icon: FilePlus, + onClick: () => { + navigate( + `${window.location.pathname}?${TX_SEARCH_PARAM}=${transactionHash}`, + { + replace: true, + }, + ); + }, + }, + { + key: '2', + label: ( + + {formatText( + { id: 'activityFeedTable.menu.viewOnNetwork' }, + { + blockExplorerName: DEFAULT_NETWORK_INFO.blockExplorerName, + }, + )} + + ), + icon: ArrowSquareOut, + }, + { + key: '3', + label: formatText({ id: 'activityFeedTable.menu.share' }), + renderItemWrapper: (itemWrapperProps, children) => ( + + {children} + + ), + icon: ShareNetwork, + onClick: () => false, + }, + ...(redoEnabledActionsMap[colonyAction.transactionHash] + ? [ + { + key: '4', + label: formatText({ id: 'completedAction.redoAction' }), + icon: Repeat, + onClick: () => setAction(transactionHash), + }, + ] + : []), + ], + }; + }; + + return getMenuProps; +}; diff --git a/src/components/common/ColonyActionsTable/hooks/useHandleRedoAction.ts b/src/components/common/ColonyActionsTable/hooks/useHandleRedoAction.ts new file mode 100644 index 00000000000..076c9241a0e --- /dev/null +++ b/src/components/common/ColonyActionsTable/hooks/useHandleRedoAction.ts @@ -0,0 +1,30 @@ +import { useEffect } from 'react'; + +import { useActionSidebarContext } from '~context/ActionSidebarContext/ActionSidebarContext.ts'; + +import { type ColonyActionsTableProps } from '../types.ts'; + +export const useHandleRedoAction = ({ + actionProps, +}: { + actionProps: ColonyActionsTableProps['actionProps']; +}) => { + const { + actionSidebarToggle: [, { toggleOn: toggleActionSidebarOn }], + } = useActionSidebarContext(); + + useEffect(() => { + if (actionProps.defaultValues && actionProps.selectedAction) { + toggleActionSidebarOn({ ...actionProps.defaultValues }); + + setTimeout(() => { + actionProps.setSelectedAction(undefined); + }, 50); + } + }, [ + actionProps.defaultValues, + toggleActionSidebarOn, + actionProps.selectedAction, + actionProps, + ]); +}; diff --git a/src/components/v5/common/ActionSidebar/hooks/useGetActionData.ts b/src/components/v5/common/ActionSidebar/hooks/useGetActionData.ts index 0ee4b5cb063..ff90cdcbd4a 100644 --- a/src/components/v5/common/ActionSidebar/hooks/useGetActionData.ts +++ b/src/components/v5/common/ActionSidebar/hooks/useGetActionData.ts @@ -3,6 +3,7 @@ import { BigNumber } from 'ethers'; import moveDecimal from 'move-decimal-point'; import { useMemo } from 'react'; +import { DEFAULT_TOKEN_DECIMALS } from '~constants'; import { Action } from '~constants/actions.ts'; import { getRole, UserRole } from '~constants/permissions.ts'; import { ColonyActionType } from '~gql'; @@ -14,6 +15,7 @@ import { getExtendedActionType } from '~utils/colonyActions.ts'; import { convertToDecimal } from '~utils/convertToDecimal.ts'; import { convertPeriodToHours } from '~utils/extensions.ts'; import { + getNumeralTokenAmount, getSelectedToken, getTokenDecimalsWithFallback, } from '~utils/tokens.ts'; @@ -27,6 +29,7 @@ import { TOKEN_FIELD_NAME, } from '../consts.ts'; import { AVAILABLE_PERMISSIONS } from '../partials/forms/ManagePermissionsForm/consts.ts'; +import { ModificationOption } from '../partials/forms/ManageReputationForm/consts.ts'; import { calculatePercentageValue } from '../partials/forms/SplitPaymentForm/partials/SplitPaymentRecipientsField/utils.ts'; import useGetColonyAction from './useGetColonyAction.ts'; @@ -42,6 +45,7 @@ const useGetActionData = (transactionId: string | undefined) => { startPollingForAction, stopPollingForAction, } = useGetColonyAction(transactionId); + const { expenditure, loadingExpenditure } = useGetExpenditureData( action?.expenditureId, ); @@ -346,6 +350,38 @@ const useGetActionData = (transactionId: string | undefined) => { ...repeatableFields, }; } + case ColonyActionType.EmitDomainReputationReward: + case ColonyActionType.EmitDomainReputationRewardMotion: + case ColonyActionType.EmitDomainReputationRewardMultisig: + case ColonyActionType.EmitDomainReputationPenalty: + case ColonyActionType.EmitDomainReputationPenaltyMotion: + case ColonyActionType.EmitDomainReputationPenaltyMultisig: { + const isSmite = [ + ColonyActionType.EmitDomainReputationPenalty, + ColonyActionType.EmitDomainReputationPenaltyMotion, + ColonyActionType.EmitDomainReputationPenaltyMultisig, + ].includes(action.type); + + const positiveAmountValue = BigNumber.from(amount || '0') + .abs() + .toString(); + + const formattedAmount = getNumeralTokenAmount( + positiveAmountValue, + DEFAULT_TOKEN_DECIMALS, + ); + + return { + [ACTION_TYPE_FIELD_NAME]: Action.ManageReputation, + member: recipientAddress, + modification: isSmite + ? ModificationOption.RemoveReputation + : ModificationOption.AwardReputation, + [TEAM_FIELD_NAME]: fromDomain?.nativeId, + [AMOUNT_FIELD_NAME]: formattedAmount, + ...repeatableFields, + }; + } default: return undefined; } diff --git a/src/components/v5/common/ActionSidebar/partials/ActionSidebarDescription/partials/ManageReputationDescription.tsx b/src/components/v5/common/ActionSidebar/partials/ActionSidebarDescription/partials/ManageReputationDescription.tsx index 576ab1106a9..ea1401ab1f1 100644 --- a/src/components/v5/common/ActionSidebar/partials/ActionSidebarDescription/partials/ManageReputationDescription.tsx +++ b/src/components/v5/common/ActionSidebar/partials/ActionSidebarDescription/partials/ManageReputationDescription.tsx @@ -8,6 +8,7 @@ import useUserByAddress from '~hooks/useUserByAddress.ts'; import Numeral from '~shared/Numeral/Numeral.tsx'; import { formatText } from '~utils/intl.ts'; import { toFinite } from '~utils/lodash.ts'; +import { getSafeStringifiedNumber } from '~utils/numbers.ts'; import { formatReputationChange } from '~utils/reputation.ts'; import { splitWalletAddress } from '~utils/splitWalletAddress.ts'; import { getTokenDecimalsWithFallback } from '~utils/tokens.ts'; @@ -63,7 +64,7 @@ const ManageReputationDescription: FC = () => { getTokenDecimalsWithFallback(nativeToken.decimals), ), reputationChangeNumeral: amount ? ( - + ) : ( formatText({ id: 'actionSidebar.metadataDescription.anAmount', diff --git a/src/components/v5/common/ActionSidebar/partials/forms/ManageReputationForm/partials/ManageReputationTable/hooks.ts b/src/components/v5/common/ActionSidebar/partials/forms/ManageReputationForm/partials/ManageReputationTable/hooks.ts index bb5501b938b..05dfb79869d 100644 --- a/src/components/v5/common/ActionSidebar/partials/forms/ManageReputationForm/partials/ManageReputationTable/hooks.ts +++ b/src/components/v5/common/ActionSidebar/partials/forms/ManageReputationForm/partials/ManageReputationTable/hooks.ts @@ -7,6 +7,7 @@ import { useWatch } from 'react-hook-form'; import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; import useUserReputation from '~hooks/useUserReputation.ts'; import { getInputTextWidth } from '~utils/elements.ts'; +import { getSafeStringifiedNumber } from '~utils/numbers.ts'; import { calculatePercentageReputation } from '~utils/reputation.ts'; import { getFormattedTokenValue, @@ -85,7 +86,7 @@ export const useReputationFields = () => { const amountValueCalculated = BigNumber.from( moveDecimal( - amount || '0', + getSafeStringifiedNumber(amount), getTokenDecimalsWithFallback(nativeToken.decimals), ), ).toString(); diff --git a/src/components/v5/common/ActionSidebar/partials/forms/ManageReputationForm/utils.ts b/src/components/v5/common/ActionSidebar/partials/forms/ManageReputationForm/utils.ts index e62c71220f3..e4346057708 100644 --- a/src/components/v5/common/ActionSidebar/partials/forms/ManageReputationForm/utils.ts +++ b/src/components/v5/common/ActionSidebar/partials/forms/ManageReputationForm/utils.ts @@ -8,6 +8,7 @@ import { type ManageReputationMotionPayload } from '~redux/sagas/motions/manageR import { DecisionMethod } from '~types/actions.ts'; import { type Colony } from '~types/graphql.ts'; import { getMotionPayload } from '~utils/motions.ts'; +import { getSafeStringifiedNumber } from '~utils/numbers.ts'; import { getTokenDecimalsWithFallback } from '~utils/tokens.ts'; import { ModificationOption } from './consts.ts'; @@ -99,6 +100,9 @@ export const moreThanZeroAmountValidation = ( const { nativeToken } = colony; return BigNumber.from( - moveDecimal(value, getTokenDecimalsWithFallback(nativeToken.decimals)), + moveDecimal( + getSafeStringifiedNumber(value), + getTokenDecimalsWithFallback(nativeToken.decimals), + ), ).gt(0); }; diff --git a/src/components/v5/common/CompletedAction/partials/ManageReputation/ManageReputation.tsx b/src/components/v5/common/CompletedAction/partials/ManageReputation/ManageReputation.tsx index f2228bffe06..185f4aef14c 100644 --- a/src/components/v5/common/CompletedAction/partials/ManageReputation/ManageReputation.tsx +++ b/src/components/v5/common/CompletedAction/partials/ManageReputation/ManageReputation.tsx @@ -9,6 +9,7 @@ import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; import { ColonyActionType } from '~gql'; import Numeral from '~shared/Numeral/Numeral.tsx'; import { formatText } from '~utils/intl.ts'; +import { getSafeStringifiedNumber } from '~utils/numbers.ts'; import { formatReputationChange } from '~utils/reputation.ts'; import { splitWalletAddress } from '~utils/splitWalletAddress.ts'; import { @@ -78,7 +79,7 @@ const ManageReputation: FC = ({ action }) => { ColonyActionType.EmitDomainReputationPenaltyMultisig, ].includes(action.type); - const positiveAmountValue = BigNumber.from(amount || '0') + const positiveAmountValue = BigNumber.from(getSafeStringifiedNumber(amount)) .abs() .toString(); diff --git a/src/components/v5/common/CompletedAction/partials/SetUserRoles/SetUserRoles.tsx b/src/components/v5/common/CompletedAction/partials/SetUserRoles/SetUserRoles.tsx index 2a6a5ecd528..003b7a88ddf 100644 --- a/src/components/v5/common/CompletedAction/partials/SetUserRoles/SetUserRoles.tsx +++ b/src/components/v5/common/CompletedAction/partials/SetUserRoles/SetUserRoles.tsx @@ -1,4 +1,3 @@ -import { ColonyRole } from '@colony/colony-js'; import { ShieldStar, Signature, UserFocus } from '@phosphor-icons/react'; import React from 'react'; @@ -6,12 +5,7 @@ import { ADDRESS_ZERO } from '~constants'; import { Action } from '~constants/actions.ts'; import { getRole } from '~constants/permissions.ts'; import { useColonyContext } from '~context/ColonyContext/ColonyContext.ts'; -import { - ColonyActionType, - useGetColonyHistoricRoleRolesQuery, - type GetColonyHistoricRoleRolesQuery, - type ColonyActionRoles, -} from '~gql'; +import { ColonyActionType, useGetColonyHistoricRoleRolesQuery } from '~gql'; import { getUserRolesForDomain } from '~transformers'; import { Authority } from '~types/authority.ts'; import { type ColonyAction } from '~types/graphql.ts'; @@ -47,38 +41,14 @@ import { TeamFromRow, } from '../rows/index.ts'; +import { transformActionRolesToColonyRoles } from './utils.ts'; + const displayName = 'v5.common.CompletedAction.partials.SetUserRoles'; interface Props { action: ColonyAction; } -const transformActionRolesToColonyRoles = ( - roles: - | GetColonyHistoricRoleRolesQuery['getColonyHistoricRole'] - | ColonyActionRoles, -): ColonyRole[] => { - if (!roles) return []; - - const roleKeys = Object.keys(roles); - - const colonyRoles: ColonyRole[] = roleKeys - .filter((key) => roles[key]) - .map((key) => { - const match = key.match(/role_(\d+)/); // Extract the role number - if (match && match[1]) { - const roleIndex = parseInt(match[1], 10); - if (roleIndex in ColonyRole) { - return roleIndex; - } - } - return null; - }) - .filter((role): role is ColonyRole => role !== null); - - return colonyRoles; -}; - const SetUserRoles = ({ action }: Props) => { const { colony: { roles: rolesInColony }, diff --git a/src/components/v5/common/CompletedAction/partials/SetUserRoles/utils.ts b/src/components/v5/common/CompletedAction/partials/SetUserRoles/utils.ts new file mode 100644 index 00000000000..a367f8a40e6 --- /dev/null +++ b/src/components/v5/common/CompletedAction/partials/SetUserRoles/utils.ts @@ -0,0 +1,32 @@ +import { ColonyRole } from '@colony/colony-js'; + +import { + type GetColonyHistoricRoleRolesQuery, + type ColonyActionRoles, +} from '~gql'; + +export const transformActionRolesToColonyRoles = ( + roles: + | GetColonyHistoricRoleRolesQuery['getColonyHistoricRole'] + | ColonyActionRoles, +): ColonyRole[] => { + if (!roles) return []; + + const roleKeys = Object.keys(roles); + + const colonyRoles: ColonyRole[] = roleKeys + .filter((key) => roles[key] !== null) + .map((key) => { + const match = key.match(/role_(\d+)/); // Extract the role number + if (match && match[1]) { + const roleIndex = parseInt(match[1], 10); + if (roleIndex in ColonyRole) { + return roleIndex; + } + } + return null; + }) + .filter((role): role is ColonyRole => role !== null); + + return colonyRoles; +}; diff --git a/src/utils/numbers.ts b/src/utils/numbers.ts index 37940667d04..da00ec2573b 100644 --- a/src/utils/numbers.ts +++ b/src/utils/numbers.ts @@ -52,3 +52,50 @@ export const adjustPercentagesTo100 = ( // Convert back to percentages with the specified number of decimals return roundedValues.map((value) => value / multiplier); }; + +/** + * Safely converts a given value to a numeric string. If the input value is `null`, `undefined`, + * or an invalid number, a fallback value is used. If the provided fallback value is also invalid, + * it defaults to "0". + * + * @param {string | number | null | undefined} value - The input value to convert. + * If it's a string, commas are removed before validation. + * @param {string | number} [fallbackValue='0'] - The fallback value to use if the input value is + * invalid. If this fallback is not a valid number, it defaults to "0". + * @returns {string} A numeric string representing the input value or a valid fallback. + * + * @example + * getSafeStringifiedNumber("20,000") // "20000" + * getSafeStringifiedNumber("abc", 100) // "100" + * getSafeStringifiedNumber(null, "invalid") // "0" + * getSafeStringifiedNumber(undefined) // "0" + * getSafeStringifiedNumber(NaN, 50) // "50" + */ +export const getSafeStringifiedNumber = ( + value: string | number | null | undefined, + fallbackValue: string | number = '0', +): string => { + // Ensure the fallback value is a valid number + const safeFallback = !Number.isNaN(Number(fallbackValue)) + ? String(fallbackValue) + : '0'; + + if (value === null || value === undefined) { + // If value is null or undefined, use the safe fallback + return safeFallback; + } + + if (typeof value === 'string') { + // Remove commas and check if the result is a valid number + const numericString = value.replace(/,/g, ''); + return !Number.isNaN(Number(numericString)) ? numericString : safeFallback; + } + + if (typeof value === 'number') { + // Check if the number is NaN + return Number.isNaN(value) ? safeFallback : String(value); + } + + // Default case if the type is unexpected + return safeFallback; +};