diff --git a/src/components/ProposalBuilder/index.tsx b/src/components/ProposalBuilder/index.tsx index 206405d77..c8893b956 100644 --- a/src/components/ProposalBuilder/index.tsx +++ b/src/components/ProposalBuilder/index.tsx @@ -1,5 +1,5 @@ -import { Box, Flex, Grid, GridItem, Icon, Text } from '@chakra-ui/react'; -import { ArrowLeft, SquaresFour } from '@phosphor-icons/react'; +import { Box, Flex, Grid, GridItem, Text } from '@chakra-ui/react'; +import { ArrowLeft } from '@phosphor-icons/react'; import { Formik, FormikProps } from 'formik'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; @@ -265,21 +265,11 @@ export function ProposalBuilder({ gap="0.5rem" > - - - {t('actions', { ns: 'actions' })} - + {t('actions', { ns: 'actions' })} {actions.map((action, index) => { return ( diff --git a/src/components/Roles/RoleDetailsTabs.tsx b/src/components/Roles/RoleDetailsTabs.tsx index f03e4af3a..1dbacea73 100644 --- a/src/components/Roles/RoleDetailsTabs.tsx +++ b/src/components/Roles/RoleDetailsTabs.tsx @@ -63,17 +63,20 @@ function RolesDetailsPayments({ variant="darker" my={4} /> - {sortedPayments.map((payment, index) => ( - - ))} + {sortedPayments.map(payment => { + const thisPaymentIndex = payments?.findIndex(p => p.streamId === payment.streamId); + return ( + + ); + })} ); } diff --git a/src/components/Roles/RolePaymentDetails.tsx b/src/components/Roles/RolePaymentDetails.tsx index 27af8401a..a503352b1 100644 --- a/src/components/Roles/RolePaymentDetails.tsx +++ b/src/components/Roles/RolePaymentDetails.tsx @@ -8,12 +8,11 @@ import { Address, getAddress, Hex } from 'viem'; import { useAccount, usePublicClient } from 'wagmi'; import { DETAILS_BOX_SHADOW, isDemoMode } from '../../constants/common'; import { DAO_ROUTES } from '../../constants/routes'; -import { useFractal } from '../../providers/App/AppProvider'; import { useNetworkConfig } from '../../providers/NetworkConfig/NetworkConfigProvider'; import { useDaoInfoStore } from '../../store/daoInfo/useDaoInfoStore'; import { useRolesStore } from '../../store/roles/useRolesStore'; import { BigIntValuePair } from '../../types'; -import { DEFAULT_DATE_FORMAT, formatCoin, formatUSD } from '../../utils'; +import { DEFAULT_DATE_FORMAT, formatCoin } from '../../utils'; import { ModalType } from '../ui/modals/ModalProvider'; import { useDecentModal } from '../ui/modals/useDecentModal'; @@ -140,9 +139,6 @@ export function RolePaymentDetails({ roleTerms, }: RolePaymentDetailsProps) { const { t } = useTranslation(['roles']); - const { - treasury: { assetsFungible }, - } = useFractal(); const { safe } = useDaoInfoStore(); const { address: connectedAccount } = useAccount(); const { addressPrefix } = useNetworkConfig(); @@ -216,40 +212,23 @@ export function RolePaymentDetails({ } }, [addressPrefix, navigate, safe?.address, withdraw]); - const amountPerWeek = useMemo(() => { - if (!payment.amount?.bigintValue) { - return; - } - - const endTime = payment.endDate.getTime() / 1000; - const startTime = payment.startDate.getTime() / 1000; - const totalSeconds = Math.round(endTime - startTime); // @dev due to milliseconds we need to round it to avoid problems with BigInt - const amountPerSecond = payment.amount.bigintValue / BigInt(totalSeconds); - const secondsInWeek = BigInt(60 * 60 * 24 * 7); - return amountPerSecond * secondsInWeek; - }, [payment]); - - const streamAmountUSD = useMemo(() => { - // @todo add price support for tokens not found in assetsFungible - const foundAsset = assetsFungible.find( - asset => getAddress(asset.tokenAddress) === payment.asset.address, - ); - if (!foundAsset || foundAsset.usdPrice === undefined) { - return; - } - return Number(payment.amount.value) * foundAsset.usdPrice; - }, [payment, assetsFungible]); - const isActiveStream = !payment.isCancelled && Date.now() < payment.endDate.getTime() && !payment.isCancelling; const activeStreamProps = useCallback( - (isTop: boolean) => - isActiveStream + (section: 'top' | 'bottom') => { + const borderTopRadius = section === 'top' ? '0.75rem' : '0'; + const borderBottomRadius = section === 'bottom' ? '0.75rem' : '0'; + const borderBottom = section === 'bottom' ? '1px solid' : 'none'; + + return isActiveStream ? { bg: 'neutral-2', sx: undefined, boxShadow: DETAILS_BOX_SHADOW, + borderTopRadius, + borderBottomRadius, + py: '1rem', } : { sx: { @@ -260,9 +239,13 @@ export function RolePaymentDetails({ bg: 'none', boxShadow: 'none', border: '1px solid', - borderBottom: isTop ? 'none' : '1px solid', + borderBottom, + borderTopRadius, + borderBottomRadius, + py: '1rem', borderColor: 'neutral-4', - }, + }; + }, [isActiveStream], ); @@ -327,35 +310,37 @@ export function RolePaymentDetails({ transitionTimingFunction="ease-out" > - - {payment.amount?.bigintValue - ? formatCoin( - payment.amount.bigintValue, - false, - payment.asset.decimals, - payment.asset.symbol, - ) - : undefined} - + + + + {payment.amount?.bigintValue + ? formatCoin( + payment.amount.bigintValue, + false, + payment.asset.decimals, + payment.asset.symbol, + ) + : undefined} + + {(payment.isCancelled || payment.isCancelling) && ( )} - - - - {payment.asset.symbol ?? t('selectLabel', { ns: 'modals' })} - - + - - - {streamAmountUSD !== undefined ? formatUSD(streamAmountUSD.toString()) : '$ ---'} - - {amountPerWeek !== undefined && ( - - - - {`${formatCoin(amountPerWeek, true, payment.asset.decimals, payment.asset.symbol)} / ${t('week')}`} - - - )} - - + void }) { +export function RoleFormCreateProposal({ close }: { close: () => void }) { const [drawerViewingRole, setDrawerViewingRole] = useState(); const { t } = useTranslation(['modals', 'common', 'proposal']); @@ -71,8 +69,8 @@ export default function RoleFormCreateProposal({ close }: { close: () => void }) const wearer = roleHat.isTermed && !!termedNominee ? termedNominee - : !!roleHat?.wearer - ? getAddress(roleHat.wearer) + : !!roleHat?.resolvedWearer + ? roleHat.resolvedWearer : zeroAddress; return { @@ -153,21 +151,20 @@ export default function RoleFormCreateProposal({ close }: { close: () => void }) field: FieldInputProps; form: FormikProps; }) => ( - - { - setFieldValue('proposalMetadata.title', e.target.value); - setFieldTouched('proposalMetadata.title', true); - }} - testId={field.name} - placeholder="Proposal Title" - isRequired={false} - gridContainerProps={{ - gridTemplateColumns: { base: '1fr', md: '1fr' }, - }} - /> - + { + setFieldValue('proposalMetadata.title', e.target.value); + setFieldTouched('proposalMetadata.title', true); + }} + testId={field.name} + placeholder="Proposal Title" + isRequired + gridContainerProps={{ + gridTemplateColumns: { base: '1fr', md: '1fr' }, + }} + /> )} @@ -180,20 +177,19 @@ export default function RoleFormCreateProposal({ close }: { close: () => void }) field: FieldInputProps; form: FormikProps; }) => ( - - { - setFieldValue('proposalMetadata.description', e.target.value); - setFieldTouched('proposalMetadata.description', true); - }} - isRequired={false} - placeholder={t('proposalDescriptionPlaceholder', { ns: 'proposal' })} - gridContainerProps={{ - gridTemplateColumns: { base: '1fr', md: '1fr' }, - }} - /> - + { + setFieldValue('proposalMetadata.description', e.target.value); + setFieldTouched('proposalMetadata.description', true); + }} + isRequired + placeholder={t('proposalDescriptionPlaceholder', { ns: 'proposal' })} + gridContainerProps={{ + gridTemplateColumns: { base: '1fr', md: '1fr' }, + }} + /> )} @@ -219,17 +215,12 @@ export default function RoleFormCreateProposal({ close }: { close: () => void }) - {t('actions', { ns: 'actions' })} @@ -276,7 +267,11 @@ export default function RoleFormCreateProposal({ close }: { close: () => void }) diff --git a/src/components/Roles/forms/RoleFormPaymentStream.tsx b/src/components/Roles/forms/RoleFormPaymentStream.tsx index 3b62fd39e..7c345c656 100644 --- a/src/components/Roles/forms/RoleFormPaymentStream.tsx +++ b/src/components/Roles/forms/RoleFormPaymentStream.tsx @@ -108,7 +108,7 @@ function FixedDate({ formIndex, disabled }: { formIndex: number; disabled: boole ); } -export default function RoleFormPaymentStream({ formIndex }: { formIndex: number }) { +export function RoleFormPaymentStream({ formIndex }: { formIndex: number }) { const { t } = useTranslation(['roles']); const { values, errors, setFieldValue } = useFormikContext(); const { getPayment } = useRolesStore(); @@ -145,6 +145,29 @@ export default function RoleFormPaymentStream({ formIndex }: { formIndex: number onSubmit: handleConfirmCancelPayment, }); + function PaymentCancelHint() { + return ( + + + + + + {t(payment?.isCancelling ? 'cancellingPaymentInfoMessage' : 'cancelPaymentInfoMessage')} + + + ); + } + return ( <> {canBeCancelled && ( - - - - - {t('cancelPaymentInfoMessage')} - + )} {(canBeCancelled || !streamId) && ( @@ -199,12 +209,13 @@ export default function RoleFormPaymentStream({ formIndex }: { formIndex: number isDisabled={!!roleEditingPaymentsErrors} onClick={() => { setFieldValue('roleEditing.roleEditingPaymentIndex', undefined); + setFieldValue(`roleEditing.payments.${formIndex}.isValidatedAndSaved`, true); }} > {t('save')} )} - {isDevMode() && ( + {isDevMode() && !canBeCancelled && ( + {!!sortedPayments.length && } - {sortedPayments.map((payment, index) => { + {sortedPayments.map(payment => { // @note don't render if form isn't valid if (!payment.amount || !payment.asset || !payment.startDate || !payment.endDate) return null; const canBeCancelled = payment.isCancellable(); + const thisPaymentIndex = payments?.findIndex(p => p.streamId === payment.streamId); return ( setFieldValue('roleEditing.roleEditingPaymentIndex', index) + ? () => setFieldValue('roleEditing.roleEditingPaymentIndex', thisPaymentIndex) : undefined } onCancel={() => { - setFieldValue(`roleEditing.payments.${index}`, { + setFieldValue(`roleEditing.payments.${thisPaymentIndex}`, { ...payment, isCancelling: true, }); diff --git a/src/hooks/schemas/roles/useRolesSchema.ts b/src/hooks/schemas/roles/useRolesSchema.ts index 6eae826ae..7c84314a1 100644 --- a/src/hooks/schemas/roles/useRolesSchema.ts +++ b/src/hooks/schemas/roles/useRolesSchema.ts @@ -3,14 +3,28 @@ import { useTranslation } from 'react-i18next'; import { getAddress } from 'viem'; import * as Yup from 'yup'; import { useFractal } from '../../../providers/App/AppProvider'; -import { - RoleFormValues, - RoleHatFormValue, - SablierPayment, - SablierPaymentFormValues, -} from '../../../types/roles'; +import { RoleFormValues, RoleHatFormValue, SablierPaymentFormValues } from '../../../types/roles'; import { useValidationAddress } from '../common/useValidationAddress'; +// @todo: needs typing +const getPaymentValidationContextData = (cxt: any) => { + // @dev finds the parent asset address from the formik context `from` array + // @dev @todo When Payments form is first open these values become undefined, not sure why + const currentPayment = cxt.from[1]?.value; + + const currentRoleHat = cxt.from[2]?.value; + const formContext = cxt.from[3]?.value; + + if (!currentPayment || !currentRoleHat || !formContext || !currentPayment.asset) return {}; + const parentAssetAddress = getAddress(currentPayment.asset.address); + + return { + currentRoleHat, + parentAssetAddress, + formContextHats: formContext.hats as RoleHatFormValue[], + }; +}; + export const useRolesSchema = () => { const { t } = useTranslation(['roles']); const { @@ -18,7 +32,7 @@ export const useRolesSchema = () => { } = useFractal(); const { addressValidationTest } = useValidationAddress(); - const bigIntValidationSchema = Yup.object().shape({ + const paymentValidationSchema = Yup.object().shape({ value: Yup.string().required(t('roleInfoErrorPaymentAmountRequired')), bigintValue: Yup.mixed() .nullable() @@ -27,13 +41,28 @@ export const useRolesSchema = () => { message: t('roleInfoErrorPaymentInvalidAmount'), test: (value, cxt) => { if (!value || !cxt.from) return false; - // @dev finds the parent asset address from the formik context `from` array - // @dev @todo When Payments form is first open these values become undefined, not sure why - const currentPayment = cxt.from[1]?.value; - const currentRoleHat = cxt.from[2]?.value; - const formContext = cxt.from[3]?.value; - if (!currentPayment || !currentRoleHat || !formContext) return false; - const parentAssetAddress = currentPayment.asset?.address; + + const { parentAssetAddress } = getPaymentValidationContextData(cxt); + + if (!parentAssetAddress) return false; + const asset = assetsFungible.find( + _asset => getAddress(_asset.tokenAddress) === parentAssetAddress, + ); + + if (!asset) return false; + + return true; + }, + }) + .test({ + name: 'Insufficient amount', + message: t('roleInfoErrorPaymentInsufficientAmount'), + test: (value, cxt) => { + if (!value || !cxt.from) return false; + const { parentAssetAddress, currentRoleHat, formContextHats } = + getPaymentValidationContextData(cxt); + + if (!parentAssetAddress) return false; const currentPaymentIndex = currentRoleHat.roleEditingPaymentIndex; // get all current role's payments excluding this one. @@ -43,9 +72,10 @@ export const useRolesSchema = () => { (_payment: SablierPaymentFormValues, index: number) => index !== currentPaymentIndex && !_payment.streamId, ); - const allHatPayments: SablierPaymentFormValues[] = formContext.hats - .filter((hat: RoleHatFormValue) => hat.id === currentRoleHat.id) - .map((hat: RoleHatFormValue) => hat.payments ?? []) + + const allHatPayments: SablierPaymentFormValues[] = formContextHats + .filter(hat => hat.id === currentRoleHat.id) + .map(hat => hat.payments ?? []) .flat(); const totalPendingAmounts = [ @@ -53,7 +83,6 @@ export const useRolesSchema = () => { ...allCurrentRolePayments, ].reduce((prev, curr) => (curr.amount?.bigintValue ?? 0n) + prev, 0n); - if (!parentAssetAddress) return false; const asset = assetsFungible.find( _asset => getAddress(_asset.tokenAddress) === getAddress(parentAssetAddress), ); @@ -77,12 +106,13 @@ export const useRolesSchema = () => { .default(undefined) .nullable() .when({ - is: (payment: SablierPayment) => payment !== undefined, + is: (payment: SablierPaymentFormValues) => + payment !== undefined && !payment.isValidatedAndSaved, then: _paymentSchema => _paymentSchema .shape({ asset: assetValidationSchema, - amount: bigIntValidationSchema, + amount: paymentValidationSchema, startDate: Yup.date().required( t('roleInfoErrorPaymentFixedDateStartDateRequired'), ), @@ -110,7 +140,7 @@ export const useRolesSchema = () => { }), }), ), - [assetValidationSchema, bigIntValidationSchema, t], + [assetValidationSchema, paymentValidationSchema, t], ); const rolesSchema = useMemo( diff --git a/src/i18n/locales/en/breadcrumbs.json b/src/i18n/locales/en/breadcrumbs.json index 64b9da886..891c31d82 100644 --- a/src/i18n/locales/en/breadcrumbs.json +++ b/src/i18n/locales/en/breadcrumbs.json @@ -1,7 +1,7 @@ { "headerTitle": "{{daoName}} {{subject}}", "proposals": "All Proposals", - "proposalNew": "New Proposal", + "proposalNew": "Create Proposal", "nodes": "Organization", "roles": "Roles", "treasury": "Treasury", diff --git a/src/i18n/locales/en/proposal.json b/src/i18n/locales/en/proposal.json index c051bc478..ec0613535 100644 --- a/src/i18n/locales/en/proposal.json +++ b/src/i18n/locales/en/proposal.json @@ -60,10 +60,10 @@ "proposedBy": "Proposed By", "proposalTitle_one": "Proposal to execute {{count}} transaction on {{target}}", "proposalTitle_other": "Proposal to execute {{count}} transactions on {{target}}", - "proposalTitle": "Proposal Title", + "proposalTitle": "Title", "proposalTitleHelper": "A short title for this proposal", "proposalTitlePlaceholder": "Title", - "proposalDescription": "Proposal Description", + "proposalDescription": "Description", "proposalDescriptionHelper": "Add a brief description, markdown supported", "proposalDescriptionPlaceholder": "Description", "proposalAdditionalResources": "Additional Resources", diff --git a/src/i18n/locales/en/roles.json b/src/i18n/locales/en/roles.json index d9863c5ad..229602fc7 100644 --- a/src/i18n/locales/en/roles.json +++ b/src/i18n/locales/en/roles.json @@ -8,7 +8,7 @@ "unassigned": "Unassigned", "wearerPending": "(Pending)", "after": "after", - "roleInfo": "Role Info", + "roleInfo": "General", "editRoles": "Edit Roles", "editRole": "Edit Role", "addRole": "Add Role", @@ -22,8 +22,8 @@ "permissionsProposals": "Proposals", "permissionsProposalsTooltip": "Can create proposals.", "amount": "Amount", - "starting": "Starting", - "ending": "Ending", + "starting": "Start", + "ending": "End", "deleteRole": "Delete Role", "addedHats": "Add {{count}} new role", "addedHats_other": "Add {{count}} new roles", @@ -57,7 +57,7 @@ "years": "Years", "days": "Days", "hours": "Hours", - "cliff": "Cliff Date", + "cliff": "Cliff", "cliffSubTitle": "How long until the assets are claimable?", "activePayments": "Active Payments", "payments": "Payments", @@ -84,6 +84,7 @@ "withdrawPaymentTitle": "Does everything look right?", "roleInfoErrorPaymentAmountRequired": "Amount is required.", "roleInfoErrorPaymentInvalidAmount": "Invalid amount.", + "roleInfoErrorPaymentInsufficientAmount": "Not enough balance.", "roleInfoErrorPaymentFixedDateStartDateRequired": "Start date is required.", "roleInfoErrorPaymentFixedDateEndDateRequired": "End date is required.", "roleInfoErrorPaymentFixedDateEndDateAfterStartDate": "End date must be after start date.", @@ -99,6 +100,8 @@ "confirmCancelPaymentBody": "This action cannot be undone.", "addTermLengths": "Add Term Lengths", "addTermLengthSubTitle": "This action will permanently add term elections to this role.", + "noPaymentsTitle": "This Role has no active payments.", + "noPaymentsSubTitle": "Payment streams for contributor compensation or token vesting can be assigned to Roles. The streams are transferrable, protecting DAO funds in the event a Role is reassigned.", "addTermLengthTitle": "Add a term length to this role?", "termNumber": "Term {{number}}", "currentTerm": "Current Term", diff --git a/src/pages/dao/roles/edit/summary/SafeRolesEditProposalSummaryPage.tsx b/src/pages/dao/roles/edit/summary/SafeRolesEditProposalSummaryPage.tsx index 6452a124f..3ab44699b 100644 --- a/src/pages/dao/roles/edit/summary/SafeRolesEditProposalSummaryPage.tsx +++ b/src/pages/dao/roles/edit/summary/SafeRolesEditProposalSummaryPage.tsx @@ -4,9 +4,9 @@ import { useFormikContext } from 'formik'; import { useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { useNavigate } from 'react-router-dom'; -import RoleFormCreateProposal from '../../../../../components/Roles/forms/RoleFormCreateProposal'; +import { RoleFormCreateProposal } from '../../../../../components/Roles/forms/RoleFormCreateProposal'; import PageHeader from '../../../../../components/ui/page/Header/PageHeader'; -import { SIDEBAR_WIDTH, useHeaderHeight } from '../../../../../constants/common'; +import { SIDEBAR_WIDTH, useFooterHeight, useHeaderHeight } from '../../../../../constants/common'; import { DAO_ROUTES } from '../../../../../constants/routes'; import { useNetworkConfig } from '../../../../../providers/NetworkConfig/NetworkConfigProvider'; import { useDaoInfoStore } from '../../../../../store/daoInfo/useDaoInfoStore'; @@ -22,6 +22,8 @@ export function SafeRolesEditProposalSummaryPage() { const safeAddress = safe?.address; + const footerHeight = useFooterHeight(); + // @dev redirects back to roles edit page if no roles are edited (user refresh) useEffect(() => { const editedRoles = values.hats.filter(hat => !!hat.editedRole); @@ -31,6 +33,7 @@ export function SafeRolesEditProposalSummaryPage() { }, [values.hats, safeAddress, navigate, addressPrefix]); if (!safeAddress) return null; + return ( @@ -78,18 +81,18 @@ export function SafeRolesEditProposalSummaryPage() { top={`calc(1rem + ${headerHeight})`} left={{ base: SIDEBAR_WIDTH, '3xl': `calc(${SIDEBAR_WIDTH} + 9rem)` }} bg="neutral-1" - px="1rem" + px={`${window.innerWidth / 100}rem`} width={{ base: `calc(100% - ${SIDEBAR_WIDTH})`, '3xl': `calc(100% - 9rem - ${SIDEBAR_WIDTH})`, }} - h={`calc(100vh - ${headerHeight})`} + h={`calc(100vh - ${headerHeight} - ${footerHeight})`} > { isStreaming: () => boolean; isCancellable: () => boolean; isCancelling?: boolean; + isValidatedAndSaved?: boolean; } export interface RoleProps { @@ -181,9 +182,10 @@ export interface EditedRole { export interface RoleHatFormValue extends Partial> { id: Hex; + // The user-input field that could either be an address or an ENS name. wearer?: string; // Not a user-input field. - // `resolvedWearer` is auto-populated from the resolved address of `wearer` in case it's an ENS name. + // `resolvedWearer` is dynamically set from the resolved address of `wearer`, in case it's an ENS name. resolvedWearer?: Address; payments?: SablierPaymentFormValues[]; // form specific state