diff --git a/public/locales/ca-CA/translations.json b/public/locales/ca-CA/translations.json index c2e906f3a..de93ec7c8 100644 --- a/public/locales/ca-CA/translations.json +++ b/public/locales/ca-CA/translations.json @@ -756,6 +756,7 @@ "account_page_asset_table_no_lptoken": null, "account_page_asset_table_no_mpt": null, "account_page_asset_table_no_nft": null, + "account_page_permission_delegation": null, "tx_hash": null, "timestamp": null, "amount_in": null, diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 3d900b768..a7a1e3ac0 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -757,6 +757,7 @@ "account_page_asset_table_no_lptoken": "No LP Tokens found", "account_page_asset_table_no_mpt": "No MPTs found", "account_page_asset_table_no_nft": "No NFTs found", + "account_page_permission_delegation": "Permission Delegation", "tx_hash": "Tx Hash", "timestamp": "Timestamp (UTC)", "amount_in": "Amount In", diff --git a/public/locales/es-ES/translations.json b/public/locales/es-ES/translations.json index 1ab61636f..243924847 100644 --- a/public/locales/es-ES/translations.json +++ b/public/locales/es-ES/translations.json @@ -757,6 +757,7 @@ "account_page_asset_table_no_lptoken": null, "account_page_asset_table_no_mpt": null, "account_page_asset_table_no_nft": null, + "account_page_permission_delegation": null, "tx_hash": null, "timestamp": null, "amount_in": null, diff --git a/public/locales/fr-FR/translations.json b/public/locales/fr-FR/translations.json index a4412faa1..7cfde98fb 100644 --- a/public/locales/fr-FR/translations.json +++ b/public/locales/fr-FR/translations.json @@ -757,6 +757,7 @@ "account_page_asset_table_no_lptoken": null, "account_page_asset_table_no_mpt": null, "account_page_asset_table_no_nft": null, + "account_page_permission_delegation": null, "tx_hash": null, "timestamp": null, "amount_in": null, diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json index ab6528bc4..87499c7e3 100644 --- a/public/locales/ja-JP/translations.json +++ b/public/locales/ja-JP/translations.json @@ -757,6 +757,7 @@ "account_page_asset_table_no_lptoken": null, "account_page_asset_table_no_mpt": null, "account_page_asset_table_no_nft": null, + "account_page_permission_delegation": null, "tx_hash": null, "timestamp": null, "amount_in": null, diff --git a/public/locales/ko-KR/translations.json b/public/locales/ko-KR/translations.json index 29e500c2a..2049a2528 100644 --- a/public/locales/ko-KR/translations.json +++ b/public/locales/ko-KR/translations.json @@ -757,6 +757,7 @@ "account_page_asset_table_no_lptoken": null, "account_page_asset_table_no_mpt": null, "account_page_asset_table_no_nft": null, + "account_page_permission_delegation": null, "tx_hash": null, "timestamp": null, "amount_in": null, diff --git a/public/locales/my-MM/translations.json b/public/locales/my-MM/translations.json index 5b62955c2..99d17dd2b 100644 --- a/public/locales/my-MM/translations.json +++ b/public/locales/my-MM/translations.json @@ -757,6 +757,7 @@ "account_page_asset_table_no_lptoken": null, "account_page_asset_table_no_mpt": null, "account_page_asset_table_no_nft": null, + "account_page_permission_delegation": null, "tx_hash": null, "timestamp": null, "amount_in": null, diff --git a/src/containers/Accounts/AccountAsset/index.tsx b/src/containers/Accounts/AccountAsset/index.tsx index e883a1487..cbd3633b6 100644 --- a/src/containers/Accounts/AccountAsset/index.tsx +++ b/src/containers/Accounts/AccountAsset/index.tsx @@ -3,7 +3,7 @@ import './styles.scss' import { useTranslation } from 'react-i18next' import { localizeNumber } from '../../shared/utils' import { useLanguage } from '../../shared/hooks' -import ArrowIcon from '../../shared/images/down_arrow.svg' +import { CollapsibleSection } from '../../shared/components/CollapsibleSection' import { HeldIOUs } from './assetTables/HeldIOUs' import { HeldMPTs } from './assetTables/HeldMPTs' import { HeldLPTokens } from './assetTables/HeldLPTokens' @@ -143,32 +143,14 @@ export default function AccountAsset({ const [heldTab, setHeldTab] = useState('iou') const [issuedTab, setIssuedTab] = useState('iou') - // Collapse state - default to expanded (true means open) - const [heldSectionOpen, setHeldSectionOpen] = useState(true) - const [issuedSectionOpen, setIssuedSectionOpen] = useState(true) - return (
{/* Assets Held */} -
-

- {t('account_page_asset_held_title')} -

- -
-
-
+ {/* Assets Issued */} -
-

- {t('account_page_asset_issued_title')} -

- -
-
-
+
) } diff --git a/src/containers/Accounts/AccountAsset/styles.scss b/src/containers/Accounts/AccountAsset/styles.scss index 7434c1211..98fce409c 100644 --- a/src/containers/Accounts/AccountAsset/styles.scss +++ b/src/containers/Accounts/AccountAsset/styles.scss @@ -4,51 +4,6 @@ padding: 24px 0px; } -.account-asset-title { - @include bold; - - font-size: 20px; -} - -.asset-section-header { - display: flex; - align-items: center; - justify-content: flex-start; - margin: 24px 0 20px; - gap: 2px; - - &:first-child { - margin-top: 0; - } - - .account-asset-title { - margin: 0; - } - - .asset-section-toggle { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 6px; - border: 0; - background: transparent; - color: inherit; - cursor: pointer; - } - - .asset-section-arrow { - display: inline-block; - width: 18px; - height: 18px; - transform-origin: center; - transition: transform 180ms ease; - } - - .asset-section-arrow.open { - transform: rotate(180deg); - } -} - .account-asset-tabs { display: flex; height: 30px; @@ -254,9 +209,3 @@ max-height: 400px; } } - -@media (max-width: $tablet-portrait-upper-boundary) { - .account-asset-title { - font-size: 18px; - } -} diff --git a/src/containers/Accounts/AccountAsset/test/AccountAsset.test.tsx b/src/containers/Accounts/AccountAsset/test/AccountAsset.test.tsx index 1c2c25bbb..ed1b17fc0 100644 --- a/src/containers/Accounts/AccountAsset/test/AccountAsset.test.tsx +++ b/src/containers/Accounts/AccountAsset/test/AccountAsset.test.tsx @@ -362,7 +362,9 @@ describe('AccountAsset Component', () => { }) // Verify all 4 held asset table wrappers and asset tables are rendered - const allSections = container.querySelectorAll('.account-asset-content') + const allSections = container.querySelectorAll( + '.collapsible-section-body', + ) const heldSection = allSections[0] // First section (Held) await waitFor(() => { @@ -394,7 +396,9 @@ describe('AccountAsset Component', () => { }) // Verify all 3 issued asset table wrappers and asset tables are rendered - const allSections = container.querySelectorAll('.account-asset-content') + const allSections = container.querySelectorAll( + '.collapsible-section-body', + ) const issuedSection = allSections[1] // Second section (Issued) await waitFor(() => { diff --git a/src/containers/Accounts/AccountSummary/index.tsx b/src/containers/Accounts/AccountSummary/index.tsx index b045242a3..09154cc2d 100644 --- a/src/containers/Accounts/AccountSummary/index.tsx +++ b/src/containers/Accounts/AccountSummary/index.tsx @@ -1,8 +1,7 @@ -import { useState } from 'react' import { useTranslation } from 'react-i18next' import { useLanguage } from '../../shared/hooks' -import ArrowIcon from '../../shared/images/down_arrow.svg' +import { CollapsibleSection } from '../../shared/components/CollapsibleSection' import Balances from './Balances' import DetailsCard from './DetailsCard' import FlagsCard from './FlagsCard' @@ -21,37 +20,25 @@ export const AccountSummary = ({ }: AccountSummaryProps) => { const { t } = useTranslation() const lang = useLanguage() - const [propertiesOpen, setPropertiesOpen] = useState(false) return (
-
-
-

{t('account_page_account_properties')}

- + +
+ + {account.signerList?.signers && ( + + )} +
- {propertiesOpen && ( -
- - {account.signerList?.signers && ( - - )} - -
- )} -
+
) } diff --git a/src/containers/Accounts/AccountSummary/styles.scss b/src/containers/Accounts/AccountSummary/styles.scss index 77723eada..9fff7cdb8 100644 --- a/src/containers/Accounts/AccountSummary/styles.scss +++ b/src/containers/Accounts/AccountSummary/styles.scss @@ -92,44 +92,6 @@ } .properties { - /* layout for header and the chevron toggle */ - .properties-header { - display: flex; - align-items: center; - justify-content: flex-start; - margin: 24px 0; - gap: 2px; - - h3 { - margin: 0; - color: $black-0; - font-size: 20px; - } - - .properties-toggle { - display: inline-flex; - align-items: center; - justify-content: center; - padding: 6px; - border: 0; - background: transparent; - color: inherit; - cursor: pointer; - } - - .properties-arrow { - display: inline-block; - width: 18px; - height: 18px; - transform-origin: center; - transition: transform 180ms ease; - } - - .properties-arrow.open { - transform: rotate(180deg); - } - } - .properties-grid { display: grid; align-items: start; @@ -465,63 +427,40 @@ } } - .properties { - .properties-header { - gap: 8px; - - .properties-toggle { - padding: 4px; - } + .properties .properties-grid { + margin-top: 12px; + gap: 12px; + grid-template-columns: 1fr; - .properties-arrow { - width: 16px; - height: 16px; - } + .flags-card, + .signers-card { + padding: 16px; } - .properties-grid { - margin-top: 12px; - gap: 12px; - grid-template-columns: 1fr; - - .flags-card, - .signers-card { - padding: 16px; - } - - .flags-card { - grid-column: auto; - } + .flags-card { + grid-column: auto; + } - .signers-card { - grid-column: auto; - } + .signers-card { + grid-column: auto; + } - .flags-list, - .signers-list { - max-height: calc(5 * 64px); - padding-inline: 4px; - } + .flags-list, + .signers-list { + max-height: calc(5 * 64px); + padding-inline: 4px; + } - .flag-item, - .signer-item { - padding: 10px; - } + .flag-item, + .signer-item { + padding: 10px; } } } @media (max-width: $tablet-portrait-upper-boundary) { - .properties { - .properties-header { - h3 { - font-size: 18px; - } - } - - .properties-grid .card-header .header-title { - font-size: 18px; - } + .properties .properties-grid .card-header .header-title { + font-size: 18px; } } } diff --git a/src/containers/Accounts/PermissionDelegation/index.tsx b/src/containers/Accounts/PermissionDelegation/index.tsx new file mode 100644 index 000000000..a29024d1c --- /dev/null +++ b/src/containers/Accounts/PermissionDelegation/index.tsx @@ -0,0 +1,84 @@ +import { useContext } from 'react' +import { useTranslation } from 'react-i18next' +import { useQuery } from 'react-query' +import { Account } from '../../shared/components/Account' +import { CollapsibleSection } from '../../shared/components/CollapsibleSection' +import { Loader } from '../../shared/components/Loader' +import { getAccountObjects } from '../../../rippled/lib/rippled' +import SocketContext from '../../shared/SocketContext' +import { shortenAccount } from '../../shared/utils' +import './styles.scss' + +interface DelegateObject { + Account: string + Authorize: string + Permissions: Array<{ + Permission: { + PermissionValue: string + } + }> + LedgerEntryType: string +} + +interface PermissionDelegationProps { + accountId: string +} + +export const PermissionDelegation = ({ + accountId, +}: PermissionDelegationProps) => { + const { t } = useTranslation() + const rippledSocket = useContext(SocketContext) + + const { data, isLoading } = useQuery( + ['accountDelegates', accountId], + () => getAccountObjects(rippledSocket, accountId, 'delegate'), + { enabled: !!accountId }, + ) + + const delegates: DelegateObject[] = data?.account_objects ?? [] + + // Don't render the section if there are no delegations and we're done loading + if (!isLoading && delegates.length === 0) { + return null + } + + return ( +
+ + {isLoading ? ( + + ) : ( +
+ {delegates.map((delegate) => ( +
+
+ +
+
+ {delegate.Permissions.map((perm) => ( + + {perm.Permission.PermissionValue} + + ))} +
+
+ ))} +
+ )} +
+
+ ) +} + +export default PermissionDelegation diff --git a/src/containers/Accounts/PermissionDelegation/styles.scss b/src/containers/Accounts/PermissionDelegation/styles.scss new file mode 100644 index 000000000..cfc2c8e96 --- /dev/null +++ b/src/containers/Accounts/PermissionDelegation/styles.scss @@ -0,0 +1,55 @@ +@use '../../shared/css/variables' as *; + +.permission-delegation { + .delegate-list { + display: flex; + flex-direction: column; + gap: 8px; + } + + .delegate-item { + display: flex; + align-items: center; + padding: 12px 16px; + border-radius: 8px; + background: $black-80; + gap: 16px; + + @media (max-width: $tablet-portrait-upper-boundary) { + flex-direction: column; + align-items: flex-start; + gap: 10px; + } + } + + .delegate-authorize { + flex-shrink: 0; + font-size: 14px; + } + + .delegate-permissions { + display: flex; + flex: 1; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; + + @media (max-width: $tablet-portrait-upper-boundary) { + justify-content: flex-start; + } + } + + .permission-chip { + padding: 4px 10px; + border-radius: 999px; + background: $black-70; + color: $black-0; + font-size: 12px; + letter-spacing: 0.03em; + @include semibold; + } + + .loader { + min-height: 50px; + } +} diff --git a/src/containers/Accounts/PermissionDelegation/test/PermissionDelegation.test.tsx b/src/containers/Accounts/PermissionDelegation/test/PermissionDelegation.test.tsx new file mode 100644 index 000000000..9424061ea --- /dev/null +++ b/src/containers/Accounts/PermissionDelegation/test/PermissionDelegation.test.tsx @@ -0,0 +1,175 @@ +import { + render, + screen, + cleanup, + waitFor, + fireEvent, +} from '@testing-library/react' +import { I18nextProvider } from 'react-i18next' +import { MemoryRouter as Router } from 'react-router' +import { QueryClientProvider } from 'react-query' +import i18n from '../../../../i18n/testConfigEnglish' +import SocketContext from '../../../shared/SocketContext' +import { PermissionDelegation } from '../index' +import { getAccountObjects } from '../../../../rippled/lib/rippled' +import { queryClient } from '../../../shared/QueryClient' +import Mock = jest.Mock + +jest.mock('../../../../rippled/lib/rippled') +jest.mock('../../../../rippled/lib/logger', () => ({ + __esModule: true, + default: () => ({ + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + }), +})) + +const mockedGetAccountObjects = getAccountObjects as Mock + +const mockSocket = {} as any + +const TestWrapper = ({ children }: { children: React.ReactNode }) => ( + + + + + {children} + + + + +) + +const mockDelegateResponse = { + account_objects: [ + { + Account: 'rTestAccount123456789012345678901', + Authorize: 'rN7n7otQDd6FczFgLdlqtyMVrn5f4W01dn', + Permissions: [ + { Permission: { PermissionValue: 'Payment' } }, + { Permission: { PermissionValue: 'TrustSet' } }, + ], + LedgerEntryType: 'Delegate', + }, + { + Account: 'rTestAccount123456789012345678901', + Authorize: 'rPT1Sjq2YGrBMTttX4GZHjKu9dyfzbpAYe', + Permissions: [{ Permission: { PermissionValue: 'OfferCreate' } }], + LedgerEntryType: 'Delegate', + }, + ], +} + +describe('PermissionDelegation component', () => { + beforeEach(() => { + jest.clearAllMocks() + queryClient.clear() + }) + + afterEach(cleanup) + + it('renders nothing when there are no delegations', async () => { + mockedGetAccountObjects.mockResolvedValue({ account_objects: [] }) + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(mockedGetAccountObjects).toHaveBeenCalledWith( + mockSocket, + 'rTestAccount123456789012345678901', + 'delegate', + ) + }) + + await waitFor(() => { + expect(container.querySelector('.permission-delegation')).toBeNull() + }) + }) + + it('renders the section title when delegations exist', async () => { + mockedGetAccountObjects.mockResolvedValue(mockDelegateResponse) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('Permission Delegation')).toBeInTheDocument() + }) + }) + + it('renders all permission rows from delegate objects', async () => { + mockedGetAccountObjects.mockResolvedValue(mockDelegateResponse) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('Payment')).toBeInTheDocument() + }) + + expect(screen.getByText('TrustSet')).toBeInTheDocument() + expect(screen.getByText('OfferCreate')).toBeInTheDocument() + }) + + it('renders with collapsible section and delegate cards', async () => { + mockedGetAccountObjects.mockResolvedValue(mockDelegateResponse) + + const { container } = render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('Payment')).toBeInTheDocument() + }) + + expect(container.querySelector('.permission-delegation')).not.toBeNull() + expect( + container.querySelector('.collapsible-section-header'), + ).not.toBeNull() + expect(container.querySelector('.collapsible-section-title')).not.toBeNull() + expect( + container.querySelector('.collapsible-section-toggle'), + ).not.toBeNull() + expect(container.querySelector('.delegate-list')).not.toBeNull() + expect(container.querySelectorAll('.delegate-item').length).toBe(2) + expect(container.querySelectorAll('.permission-chip').length).toBe(3) + }) + + it('toggles the table visibility when clicking the toggle button', async () => { + mockedGetAccountObjects.mockResolvedValue(mockDelegateResponse) + + render( + + + , + ) + + await waitFor(() => { + expect(screen.getByText('Payment')).toBeInTheDocument() + }) + + expect(screen.getByText('Payment')).toBeVisible() + + const toggleButton = screen.getByLabelText('Toggle permission delegation') + fireEvent.click(toggleButton) + + expect(screen.queryByText('Payment')).toBeNull() + + fireEvent.click(toggleButton) + + expect(screen.getByText('Payment')).toBeInTheDocument() + }) +}) diff --git a/src/containers/Accounts/index.tsx b/src/containers/Accounts/index.tsx index b2c65fe78..d5045011e 100644 --- a/src/containers/Accounts/index.tsx +++ b/src/containers/Accounts/index.tsx @@ -15,6 +15,7 @@ import { AccountSummary } from './AccountSummary' import { useXRPToUSDRate } from '../shared/hooks/useXRPToUSDRate' import AccountAsset from './AccountAsset' import AccountHeader from './AccountHeader' +import { PermissionDelegation } from './PermissionDelegation' export const Accounts = () => { const { trackScreenLoaded, trackException } = useAnalytics() @@ -65,6 +66,7 @@ export const Accounts = () => { {showAccount && ( <> + ({ default: () =>
Account Asset
, })) +jest.mock('../PermissionDelegation', () => ({ + __esModule: true, + PermissionDelegation: () => ( +
Permission Delegation
+ ), +})) + jest.mock('../AccountTransactionTable', () => ({ __esModule: true, AccountTransactionTable: () => ( @@ -72,6 +79,7 @@ describe('Account container', () => { await waitFor(() => { expect(screen.getByTestId('account-header')).toBeInTheDocument() expect(screen.getByTestId('account-summary')).toBeInTheDocument() + expect(screen.getByTestId('permission-delegation')).toBeInTheDocument() expect(screen.getByTestId('account-asset')).toBeInTheDocument() expect( screen.getByTestId('account-transaction-table'), diff --git a/src/containers/shared/components/CollapsibleSection/index.tsx b/src/containers/shared/components/CollapsibleSection/index.tsx new file mode 100644 index 000000000..345db96ad --- /dev/null +++ b/src/containers/shared/components/CollapsibleSection/index.tsx @@ -0,0 +1,56 @@ +import { useState, ReactNode } from 'react' +import ArrowIcon from '../../images/down_arrow.svg' +import './styles.scss' + +interface CollapsibleSectionProps { + title: ReactNode + ariaLabel: string + children: ReactNode + defaultOpen?: boolean + className?: string + keepMounted?: boolean +} + +export const CollapsibleSection = ({ + title, + ariaLabel, + children, + defaultOpen = true, + className, + keepMounted = false, +}: CollapsibleSectionProps) => { + const [isOpen, setIsOpen] = useState(defaultOpen) + + const classes = ['collapsible-section', className].filter(Boolean).join(' ') + + return ( +
+
+

{title}

+ +
+ {keepMounted ? ( +
+ {children} +
+ ) : ( + isOpen &&
{children}
+ )} +
+ ) +} + +export default CollapsibleSection diff --git a/src/containers/shared/components/CollapsibleSection/styles.scss b/src/containers/shared/components/CollapsibleSection/styles.scss new file mode 100644 index 000000000..d9c016db6 --- /dev/null +++ b/src/containers/shared/components/CollapsibleSection/styles.scss @@ -0,0 +1,65 @@ +@use '../../css/variables' as *; + +.collapsible-section-header { + display: flex; + align-items: center; + justify-content: flex-start; + margin: 24px 0 20px; + gap: 2px; + + &:first-child { + margin-top: 0; + } + + .collapsible-section-title { + @include bold; + + margin: 0; + color: $black-0; + font-size: 20px; + } + + .collapsible-section-toggle { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px; + border: 0; + background: transparent; + color: inherit; + cursor: pointer; + } + + .collapsible-section-arrow { + display: inline-block; + width: 18px; + height: 18px; + transform-origin: center; + transition: transform 180ms ease; + } + + .collapsible-section-arrow.open { + transform: rotate(180deg); + } +} + +@media (max-width: $tablet-landscape-upper-boundary) { + .collapsible-section-header { + gap: 8px; + + .collapsible-section-toggle { + padding: 4px; + } + + .collapsible-section-arrow { + width: 16px; + height: 16px; + } + } +} + +@media (max-width: $tablet-portrait-upper-boundary) { + .collapsible-section-header .collapsible-section-title { + font-size: 18px; + } +}