From 020cb60df7d5e07c49b8bbda91615891fe43b2f2 Mon Sep 17 00:00:00 2001 From: Mayukha Vadari Date: Wed, 15 Nov 2023 16:19:04 -0500 Subject: [PATCH 1/2] feat: add CTID support (#839) Add CTID support to the search and URLs. Fixes #707 Co-authored-by: Caleb Kniffen --- public/locales/en-US/translations.json | 3 +- public/locales/es-ES/translations.json | 1 + public/locales/fr-FR/translations.json | 1 + public/locales/ja-JP/translations.json | 1 + public/locales/ko-KR/translations.json | 1 + src/containers/Header/Search.tsx | 7 + src/containers/Header/test/Search.test.js | 144 ++++++------------ src/containers/Transactions/index.tsx | 45 ++++-- ...ansaction.test.js => Transaction.test.tsx} | 26 ++-- .../test/mock_data/Transaction.json | 3 +- src/containers/Transactions/transaction.scss | 32 +--- src/containers/shared/utils.js | 1 + src/rippled/lib/rippled.js | 17 ++- src/rippled/lib/txSummary/index.js | 1 + 14 files changed, 133 insertions(+), 150 deletions(-) rename src/containers/Transactions/test/{Transaction.test.js => Transaction.test.tsx} (90%) diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 0cd79c0e5..7da7defe4 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -208,7 +208,8 @@ "transaction_empty_title": "No transaction hash supplied", "transaction_empty_hint": "Enter a transaction hash in the search box", "validator_not_found": "Validator not found", - "check_transaction_hash": "Please check your transaction hash", + "check_transaction_hash": "Please check your transaction hash or CTID.", + "wrong_network": "This CTID applies to a different network.", "check_validator_key": "Please check your validator key", "transaction": "Transaction", "success": "Success", diff --git a/public/locales/es-ES/translations.json b/public/locales/es-ES/translations.json index 9201f3a2d..65b3ba3c8 100644 --- a/public/locales/es-ES/translations.json +++ b/public/locales/es-ES/translations.json @@ -205,6 +205,7 @@ "transaction_empty_hint": "Introduce un hash de transacción en la caja de búsqueda", "validator_not_found": "Validador no encontrado", "check_transaction_hash": "Por favor, comprueba tu hash de transacción", + "wrong_network": null, "check_validator_key": "Por favor, comprueba la clave de tu validador", "transaction": "Transacción", "success": "Éxito", diff --git a/public/locales/fr-FR/translations.json b/public/locales/fr-FR/translations.json index e8ab2205f..2d45530da 100644 --- a/public/locales/fr-FR/translations.json +++ b/public/locales/fr-FR/translations.json @@ -207,6 +207,7 @@ "transaction_empty_hint": "Entrez un hash de transaction dans la zone de recherche", "validator_not_found": "Validateur non trouvé", "check_transaction_hash": "Veuillez vérifier le hash de la transaction", + "wrong_network": null, "check_validator_key": "Veuillez vérifier la clé du validateur", "transaction": "Transaction", "success": "Succès", diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json index 85c34d831..a6d1c3d14 100644 --- a/public/locales/ja-JP/translations.json +++ b/public/locales/ja-JP/translations.json @@ -207,6 +207,7 @@ "transaction_empty_hint": "検索欄にトランザクションハッシュを入力してください", "validator_not_found": "バリデータが見つかりません", "check_transaction_hash": "トランザクションのハッシュ値を確認してください", + "wrong_network": null, "check_validator_key": "バリデータのキーを確認してください", "transaction": "トランザクション", "success": "成功", diff --git a/public/locales/ko-KR/translations.json b/public/locales/ko-KR/translations.json index 2f3f2bf58..c17765cb4 100644 --- a/public/locales/ko-KR/translations.json +++ b/public/locales/ko-KR/translations.json @@ -208,6 +208,7 @@ "transaction_empty_hint": "검색창에 트랜잭션 해시를 입력해 주세요", "validator_not_found": "검증자를 찾을 수 없습니다", "check_transaction_hash": "트랜잭션 해시를 확인해 주세요", + "wrong_network": null, "check_validator_key": "검증자 키를 확인해주세요", "transaction": "트랜잭션", "success": "성공", diff --git a/src/containers/Header/Search.tsx b/src/containers/Header/Search.tsx index 0abc75bfd..d5db033a6 100644 --- a/src/containers/Header/Search.tsx +++ b/src/containers/Header/Search.tsx @@ -16,6 +16,7 @@ import { FULL_CURRENCY_REGEX, HASH_REGEX, VALIDATORS_REGEX, + CTID_REGEX, } from '../shared/utils' import './search.scss' import { isValidPayString } from '../../rippled/payString' @@ -109,6 +110,12 @@ const getRoute = async ( path: buildPath(VALIDATOR_ROUTE, { identifier: normalizeAccount(id) }), } } + if (CTID_REGEX.test(id)) { + return { + type: 'transactions', + path: buildPath(TRANSACTION_ROUTE, { identifier: id.toUpperCase() }), + } + } return null } diff --git a/src/containers/Header/test/Search.test.js b/src/containers/Header/test/Search.test.js index 3d073fe20..a03f7698f 100644 --- a/src/containers/Header/test/Search.test.js +++ b/src/containers/Header/test/Search.test.js @@ -22,6 +22,16 @@ describe('Search component', () => { ) } + const oldEnvs = process.env + + beforeEach(() => { + process.env = { ...oldEnvs, VITE_ENVIRONMENT: 'mainnet' } + }) + + afterEach(() => { + process.env = oldEnvs + }) + it('renders without crashing', () => { const wrapper = createWrapper() wrapper.unmount() @@ -53,6 +63,7 @@ describe('Search component', () => { const hash = '59239EA78084F6E2F288473F8AE02F3E6FC92F44BDE59668B5CAE361D3D32838' + const ctid = 'C0BF433500020000' const token1 = 'cny.rJ1adrpGS3xsnQMb9Cw54tWJVFPuSdZHK' const token1VariantPlus = 'cny.rJ1adrpGS3xsnQMb9Cw54tWJVFPuSdZHK' const token1VariantMinus = 'cny-rJ1adrpGS3xsnQMb9Cw54tWJVFPuSdZHK' @@ -74,140 +85,83 @@ describe('Search component', () => { // mock getNFTInfo api to test transactions and nfts const mockAPI = jest.spyOn(rippled, 'getTransaction') + const testValue = async (searchInput, expectedPath) => { + input.instance().value = searchInput + input.simulate('keyDown', { key: 'Enter' }) + await flushPromises() + expect(window.location.pathname).toEqual(expectedPath) + } + input.simulate('keyDown', { key: 'a' }) expect(window.location.pathname).toEqual('/') - input.instance().value = ledgerIndex - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/ledgers/${ledgerIndex}`) + await testValue(ledgerIndex, `/ledgers/${ledgerIndex}`) - input.instance().value = rippleAddress - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/accounts/${rippleAddress}`) + await testValue(rippleAddress, `/accounts/${rippleAddress}`) - input.instance().value = addressWithQuotes - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/accounts/${rippleAddress}`) + await testValue(addressWithQuotes, `/accounts/${rippleAddress}`) - input.instance().value = addressWithSingleQuote - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/accounts/${rippleAddress}`) + await testValue(addressWithSingleQuote, `/accounts/${rippleAddress}`) - input.instance().value = addressWithSpace - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/accounts/${rippleAddress}`) + await testValue(addressWithSpace, `/accounts/${rippleAddress}`) - input.instance().value = rippleXAddress - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/accounts/${rippleXAddress}`) + await testValue(rippleXAddress, `/accounts/${rippleXAddress}`) // Normalize split address format to a X-Address - input.instance().value = rippleSplitAddress - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/accounts/${rippleXAddress}`) + await testValue(rippleSplitAddress, `/accounts/${rippleXAddress}`) - input.instance().value = paystring - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/paystrings/${paystring}`) + await testValue(paystring, `/paystrings/${paystring}`) // Normalize paystrings with @ to $ - input.instance().value = paystringWithAt - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/paystrings/${paystring}`) + await testValue(paystringWithAt, `/paystrings/${paystring}`) // Validator - input.instance().value = validator - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/validators/${validator}`) + await testValue(validator, `/validators/${validator}`) mockAPI.mockImplementation(() => { '123' }) - input.instance().value = hash - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/transactions/${hash}`) + await testValue(hash, `/transactions/${hash}`) - input.instance().value = token1 - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/token/${token1}`) + await testValue(ctid, `/transactions/${ctid}`) + + await testValue(token1, `/token/${token1}`) // testing multiple variants of token format - input.instance().value = token1VariantColon - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/token/${token1}`) - - input.instance().value = token1VariantPlus - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/token/${token1}`) - - input.instance().value = token1VariantMinus - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/token/${token1}`) - - input.instance().value = token2 - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/token/${token2}`) + await testValue(token1VariantColon, `/token/${token1}`) + + await testValue(token1VariantPlus, `/token/${token1}`) + + await testValue(token1VariantMinus, `/token/${token1}`) + + await testValue(token2, `/token/${token2}`) // testing multiple variants of full token format - input.instance().value = token2VariantColon - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/token/${token2}`) + await testValue(token2VariantColon, `/token/${token2}`) - input.instance().value = token2VariantPlus - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/token/${token2}`) + await testValue(token2VariantPlus, `/token/${token2}`) - input.instance().value = token2VariantMinus - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/token/${token2}`) + await testValue(token2VariantMinus, `/token/${token2}`) // Returns a response upon a valid nft_id, redirect to NFT mockAPI.mockImplementation(() => { throw new Error('Tx not found', 404) }) - input.instance().value = nftoken - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/nft/${nftoken}`) + await testValue(nftoken, `/nft/${nftoken}`) - input.instance().value = invalidString - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/search/${invalidString}`) + await testValue(invalidString, `/search/${invalidString}`) // ensure strings are trimmed mockAPI.mockImplementation(() => { '123' }) - input.instance().value = ` ${hash} ` - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/transactions/${hash}`) + await testValue(` ${hash} `, `/transactions/${hash}`) // handle lower case tx hash - input.instance().value = hash.toLowerCase() - input.simulate('keyDown', { key: 'Enter' }) - await flushPromises() - expect(window.location.pathname).toEqual(`/transactions/${hash}`) + await testValue(hash.toLowerCase(), `/transactions/${hash}`) + + // handle lower case ctid + await testValue(ctid.toLowerCase(), `/transactions/${ctid}`) wrapper.unmount() }) diff --git a/src/containers/Transactions/index.tsx b/src/containers/Transactions/index.tsx index b38b8f7da..2f02f2fe2 100644 --- a/src/containers/Transactions/index.tsx +++ b/src/containers/Transactions/index.tsx @@ -7,7 +7,7 @@ import { useWindowSize } from 'usehooks-ts' import NoMatch from '../NoMatch' import { Loader } from '../shared/components/Loader' import { Tabs } from '../shared/components/Tabs' -import { NOT_FOUND, BAD_REQUEST, HASH_REGEX } from '../shared/utils' +import { NOT_FOUND, BAD_REQUEST, HASH_REGEX, CTID_REGEX } from '../shared/utils' import { SimpleTab } from './SimpleTab' import { DetailTab } from './DetailTab' import './transaction.scss' @@ -20,6 +20,8 @@ import { SUCCESSFUL_TRANSACTION } from '../shared/transactionUtils' import { getTransaction } from '../../rippled' import { TRANSACTION_ROUTE } from '../App/routes' +const WRONG_NETWORK = 406 + const ERROR_MESSAGES: Record = {} ERROR_MESSAGES[NOT_FOUND] = { title: 'transaction_not_found', @@ -29,6 +31,10 @@ ERROR_MESSAGES[BAD_REQUEST] = { title: 'invalid_transaction_hash', hints: ['check_transaction_hash'], } +ERROR_MESSAGES[WRONG_NETWORK] = { + title: 'wrong_network', + hints: ['check_transaction_hash'], +} ERROR_MESSAGES.default = { title: 'generic_error', hints: ['not_your_fault'], @@ -48,22 +54,22 @@ export const Transaction = () => { if (identifier === '') { return undefined } - if (!HASH_REGEX.test(identifier)) { - return Promise.reject(BAD_REQUEST) + if (HASH_REGEX.test(identifier) || CTID_REGEX.test(identifier)) { + return getTransaction(identifier, rippledSocket).catch( + (transactionRequestError) => { + const status = transactionRequestError.code + trackException( + `transaction ${identifier} --- ${JSON.stringify( + transactionRequestError.message, + )}`, + ) + + return Promise.reject(status) + }, + ) } - return getTransaction(identifier, rippledSocket).catch( - (transactionRequestError) => { - const status = transactionRequestError.code - trackException( - `transaction ${identifier} --- ${JSON.stringify( - transactionRequestError.message, - )}`, - ) - - return Promise.reject(status) - }, - ) + return Promise.reject(BAD_REQUEST) }, ) const { width } = useWindowSize() @@ -93,9 +99,16 @@ export const Transaction = () => {
{type}
-
+
+
{t('hash')}:
{data?.raw.hash}
+ {data?.raw.tx.ctid && ( +
+
CTID:
+ {data.raw.tx.ctid} +
+ )}
) } diff --git a/src/containers/Transactions/test/Transaction.test.js b/src/containers/Transactions/test/Transaction.test.tsx similarity index 90% rename from src/containers/Transactions/test/Transaction.test.js rename to src/containers/Transactions/test/Transaction.test.tsx index 1169bc417..f907edb34 100644 --- a/src/containers/Transactions/test/Transaction.test.js +++ b/src/containers/Transactions/test/Transaction.test.tsx @@ -8,6 +8,7 @@ import { TxStatus } from '../../shared/components/TxStatus' import { getTransaction } from '../../../rippled' import { Error as RippledError } from '../../../rippled/lib/utils' import { flushPromises, QuickHarness } from '../../test/utils' +import Mock = jest.Mock jest.mock('../../../rippled', () => { const originalModule = jest.requireActual('../../../rippled') @@ -19,10 +20,11 @@ jest.mock('../../../rippled', () => { } }) -const mockedGetTransaction = getTransaction +const mockedGetTransaction: Mock = getTransaction as Mock -global.location = - '/transactions/50BB0CC6EFC4F5EF9954E654D3230D4480DC83907A843C736B28420C7F02F774' +window.location.assign( + '/transactions/50BB0CC6EFC4F5EF9954E654D3230D4480DC83907A843C736B28420C7F02F774', +) describe('Transaction container', () => { const createWrapper = ( @@ -86,7 +88,7 @@ describe('Transaction container', () => { it('renders error page', async () => { mockedGetTransaction.mockImplementation(() => - Promise.reject(Error('transaction not validated', 500)), + Promise.reject(new RippledError('transaction not validated', 500)), ) const wrapper = createWrapper() await flushPromises() @@ -122,13 +124,15 @@ describe('Transaction container', () => { expect(summary.contains(
OfferCreate
)).toBe( true, ) - expect( - summary.contains( -
- {mockTransaction.hash} -
, - ), - ).toBe(true) + + // console.log(wrapper.debug()) + expect(wrapper.find('.txid').length).toBe(2) + expect(wrapper.find('.txid').at(0).text()).toBe( + `hash: ${mockTransaction.hash}`, + ) + expect(wrapper.find('.txid').at(1).text()).toBe( + `CTID: ${mockTransaction.tx.ctid}`, + ) expect(summary.contains()).toBe(true) expect(wrapper.find('.tabs').length).toBe(1) expect(wrapper.find('a.tab').length).toBe(3) diff --git a/src/containers/Transactions/test/mock_data/Transaction.json b/src/containers/Transactions/test/mock_data/Transaction.json index cdef2c590..86c0c85c3 100644 --- a/src/containers/Transactions/test/mock_data/Transaction.json +++ b/src/containers/Transactions/test/mock_data/Transaction.json @@ -19,7 +19,8 @@ "Fee": "12", "SigningPubKey": "03854A934352E510CB095FB37F38FFF962B8A2AAAB2A594CBEC7A91A1AF5B3F29A", "TxnSignature": "304402205D1FFE09B3FE73ACE89C15C8649F717D1B69FF9A298B4F0B4ADAAFF80DE1348A0220251D87705A5AC1DAACCB9A89EE74F9CEF75FD36D7789406BF6032A4FEC69EE84", - "Account": "rPt8rwFrsucmjdKfjwRHGz9iZGxxN2cLYh" + "Account": "rPt8rwFrsucmjdKfjwRHGz9iZGxxN2cLYh", + "ctid": "C238945F00340000" }, "meta": { "TransactionIndex": 52, diff --git a/src/containers/Transactions/transaction.scss b/src/containers/Transactions/transaction.scss index 5cf22d513..e25c300c1 100644 --- a/src/containers/Transactions/transaction.scss +++ b/src/containers/Transactions/transaction.scss @@ -25,46 +25,30 @@ } .summary { - padding: 0 16px; + padding: 0 16px 87px 0; .type { display: inline-block; - padding-bottom: 24px; + margin-bottom: 24px; color: $white; font-size: 42px; @include bold; } - .label { - margin-top: 16px; - margin-bottom: 4px; - color: $black-80; - font-size: 24px; - line-height: 32px; - @include bold; - - @include for-size(tablet-landscape-up) { - font-size: 32px; - line-height: 40px; - } - @include for-size(desktop-up) { - font-size: 40px; - line-height: 48px; + .txid { + .title { + @include semibold; } - @include for-size(big-desktop-up) { - font-size: 40px; - line-height: 48px; - } - } - .hash { + display: flex; overflow: hidden; - padding-bottom: 87px; + margin-top: 8px; color: $black-40; font-size: 12px; letter-spacing: 0px; line-height: 20px; text-overflow: ellipsis; + text-transform: uppercase; white-space: nowrap; @include medium; } diff --git a/src/containers/shared/utils.js b/src/containers/shared/utils.js index f5e583814..9114fdcc2 100644 --- a/src/containers/shared/utils.js +++ b/src/containers/shared/utils.js @@ -29,6 +29,7 @@ export const CURRENCY_REGEX = export const FULL_CURRENCY_REGEX = /^[0-9A-Fa-f]{40}[.:+-]r[rpshnaf39wBUDNEGHJKLM4PQRST7VWXYZ2bcdeCg65jkm8oFqi1tuvAxyz]{27,35}$/ export const VALIDATORS_REGEX = /^n[9H][0-9A-Za-z]{50}$/ +export const CTID_REGEX = /^[cC][0-9A-Za-z]{15}$/ export const PURPLE = '#8884d8' export const GREEN_500 = '#32E685' diff --git a/src/rippled/lib/rippled.js b/src/rippled/lib/rippled.js index 56f38a732..598a57e63 100644 --- a/src/rippled/lib/rippled.js +++ b/src/rippled/lib/rippled.js @@ -1,3 +1,4 @@ +import { CTID_REGEX, HASH_REGEX } from '../../containers/shared/utils' import { formatAmount } from './txSummary/formatAmount' import { Error, XRP_BASE, convertRippleDate } from './utils' @@ -139,10 +140,16 @@ const getLedgerEntry = (rippledSocket, { index }) => { } // get transaction -const getTransaction = (rippledSocket, txHash) => { +const getTransaction = (rippledSocket, txId) => { const params = { command: 'tx', - transaction: txHash, + } + if (HASH_REGEX.test(txId)) { + params.transaction = txId + } else if (CTID_REGEX.test(txId)) { + params.ctid = txId + } else { + throw new Error(`${txId} not a ctid or hash`, 404) } return query(rippledSocket, params).then((resp) => { @@ -154,6 +161,12 @@ const getTransaction = (rippledSocket, txHash) => { throw new Error('invalid transaction hash', 400) } + // TODO: remove the `unknown` option when + // https://github.com/XRPLF/rippled/pull/4738 is in a release + if (resp.error === 'wrongNetwork' || resp.error === 'unknown') { + throw new Error('wrong network for CTID', 406) + } + if (resp.error_message) { throw new Error(resp.error_message, 500) } diff --git a/src/rippled/lib/txSummary/index.js b/src/rippled/lib/txSummary/index.js index b20027a3a..93e1a03ad 100644 --- a/src/rippled/lib/txSummary/index.js +++ b/src/rippled/lib/txSummary/index.js @@ -9,6 +9,7 @@ const getInstructions = (tx, meta) => { const summarizeTransaction = (d, details = false) => ({ hash: d.hash, + ctid: d.ctid, type: d.tx.TransactionType, result: d.meta.TransactionResult, account: d.tx.Account, From a2960371ec189542b4611f8cc7fac0a771788553 Mon Sep 17 00:00:00 2001 From: pdp2121 <71317875+pdp2121@users.noreply.github.com> Date: Wed, 15 Nov 2023 16:44:54 -0500 Subject: [PATCH 2/2] feat: add amendment summary page (#855) ## High Level Overview of Change The page would include: - Information about the amendment - List of voted Yes and No validators (for amendments in voting) - A graph showing distribution of votes broken down by UNL and non-UNL validators (for amendments in voting) This PR would also include search by Amendment Id/Name ### Type of Change - [x] New feature (non-breaking change which adds functionality) ## Before / After ### Amendment in voting (Desktop) ![localhost_3001_amendment_Clawback (1)](https://github.com/ripple/explorer/assets/71317875/81e6bf71-29fa-4556-8234-46dff0810579) ### Amendment in voting (Mobile) ![localhost_3001_amendment_Clawback(iPhone 12 Pro) (1)](https://github.com/ripple/explorer/assets/71317875/b0c21b47-3cbb-492a-88de-5a35b6e9faae) ### Amendment enabled ![Screenshot 2023-10-03 at 7 09 56 PM](https://github.com/ripple/explorer/assets/71317875/74f282ba-d181-4a0d-9f87-ef7f3c3cd772) ### Chart legend Screenshot 2023-10-04 at 11 49 39 AM ### Footnote Screenshot 2023-10-04 at 4 36 09 PM --- public/locales/en-US/translations.json | 21 ++ public/locales/es-ES/translations.json | 25 ++ public/locales/fr-FR/translations.json | 21 ++ public/locales/ja-JP/translations.json | 21 ++ public/locales/ko-KR/translations.json | 21 ++ src/containers/Amendment/BarChartVoting.tsx | 146 +++++++++++ src/containers/Amendment/Simple.tsx | 134 ++++++++++ src/containers/Amendment/Votes.tsx | 132 ++++++++++ src/containers/Amendment/amendment.scss | 135 +++++++++++ src/containers/Amendment/index.tsx | 132 ++++++++++ .../Amendment/test/amendment-summary.test.js | 228 ++++++++++++++++++ .../Amendment/test/mockValidatorsList.json | 41 ++++ .../Amendment/test/mockVotingAmendment.json | 25 ++ src/containers/App/index.tsx | 3 + src/containers/App/routes.ts | 6 + src/containers/Header/Search.tsx | 1 + src/containers/Header/test/Search.test.js | 2 + src/containers/Network/css/barchart.scss | 11 +- src/containers/shared/utils.js | 3 + src/containers/shared/vhsTypes.ts | 22 ++ 20 files changed, 1129 insertions(+), 1 deletion(-) create mode 100644 src/containers/Amendment/BarChartVoting.tsx create mode 100644 src/containers/Amendment/Simple.tsx create mode 100644 src/containers/Amendment/Votes.tsx create mode 100644 src/containers/Amendment/amendment.scss create mode 100644 src/containers/Amendment/index.tsx create mode 100644 src/containers/Amendment/test/amendment-summary.test.js create mode 100644 src/containers/Amendment/test/mockValidatorsList.json create mode 100644 src/containers/Amendment/test/mockVotingAmendment.json diff --git a/public/locales/en-US/translations.json b/public/locales/en-US/translations.json index 7da7defe4..ef0b9da10 100644 --- a/public/locales/en-US/translations.json +++ b/public/locales/en-US/translations.json @@ -485,8 +485,29 @@ "namespace": "Namespace", "api_version": "API Version", "triggered_on": "Triggered On", + "name": "name", + "amendment_id": "Amendment ID", + "introduced_in": "Introduced In", + "threshold": "threshold", + "voting": "Voting", + "yeas": "yeas", + "nays": "nays", + "eta": "eta", + "consensus": "consensus", + "amendment_summary": "Amendment Summary", + "not": "not", + "enabled": "Enabled", + "tx": "tx", + "all": "all", + "yeas_count": "# of Yea Votes: {{yeas_count}}", + "nays_count": "# of Nay Votes: {{nays_count}}", + "no_of_validators": "# of Validators", + "amendment_not_found": "Amendment not found", + "check_amendment_key": "Please check your amendment key", "did_document": "DID Document", "attestation": "Attestation", + "note": "Note", + "indicate_unl": "indicates a validator on an UNL", "transaction_tokens_involved": " and ", "transaction_tokens_swapped": " for " } diff --git a/public/locales/es-ES/translations.json b/public/locales/es-ES/translations.json index 65b3ba3c8..09973fe21 100644 --- a/public/locales/es-ES/translations.json +++ b/public/locales/es-ES/translations.json @@ -481,6 +481,31 @@ "namespace": "Espacio de Nombres", "api_version": "Versión API", "triggered_on": "Activado En", + "name": null, + "amendment_id": null, + "introduced_in": null, + "threshold": null, + "voting": null, + "yeas": null, + "nays": null, + "eta": null, + "consensus": null, + "amendment_summary": null, + "not_enabled": null, + "enabled": null, + "enabled_on": null, + "tx": null, + "yeas_all": null, + "nays_all": null, + "yeas_unl": null, + "nays_unl": null, + "yeas_count": null, + "nays_count": null, + "no_of_validators": null, + "amendment_not_found": null, + "check_amendment_key": null, + "note": null, + "indicate_unl": null, "transaction_tokens_involved": null, "transaction_tokens_swapped": null } diff --git a/public/locales/fr-FR/translations.json b/public/locales/fr-FR/translations.json index 2d45530da..cd7a9ed2d 100644 --- a/public/locales/fr-FR/translations.json +++ b/public/locales/fr-FR/translations.json @@ -484,6 +484,27 @@ "namespace": "Espace de noms", "api_version": "Version API", "triggered_on": "Déclenché Par", + "name": null, + "amendment_id": null, + "introduced_in": null, + "threshold": null, + "voting": null, + "yeas": null, + "nays": null, + "eta": null, + "consensus": null, + "amendment_summary": null, + "enabled": null, + "not": null, + "tx": null, + "all": null, + "yeas_count": null, + "nays_count": null, + "no_of_validators": null, + "amendment_not_found": null, + "check_amendment_key": null, + "note": null, + "indicate_unl": null, "transaction_tokens_involved": null, "transaction_tokens_swapped": null } diff --git a/public/locales/ja-JP/translations.json b/public/locales/ja-JP/translations.json index a6d1c3d14..7f6afe4f3 100644 --- a/public/locales/ja-JP/translations.json +++ b/public/locales/ja-JP/translations.json @@ -483,6 +483,27 @@ "namespace": "ネームスペース", "api_version": "APIバージョン", "triggered_on": null, + "name": null, + "amendment_id": null, + "introduced_in": null, + "threshold": null, + "voting": null, + "yeas": null, + "nays": null, + "eta": null, + "consensus": null, + "amendment_summary": null, + "enabled": null, + "not": null, + "tx": null, + "all": null, + "yeas_count": null, + "nays_count": null, + "no_of_validators": null, + "amendment_not_found": null, + "check_amendment_key": null, + "note": null, + "indicate_unl": null, "transaction_tokens_involved": null, "transaction_tokens_swapped": null } diff --git a/public/locales/ko-KR/translations.json b/public/locales/ko-KR/translations.json index c17765cb4..5c4e1a50d 100644 --- a/public/locales/ko-KR/translations.json +++ b/public/locales/ko-KR/translations.json @@ -481,6 +481,27 @@ "namespace": "Namespace", "api_version": "API 버전", "triggered_on": "Triggered On", + "name": null, + "amendment_id": null, + "introduced_in": null, + "threshold": null, + "voting": null, + "yeas": null, + "nays": null, + "eta": null, + "consensus": null, + "amendment_summary": null, + "enabled": null, + "not": null, + "tx": null, + "all": null, + "yeas_count": null, + "nays_count": null, + "no_of_validators": null, + "amendment_not_found": null, + "check_amendment_key": null, + "note": null, + "indicate_unl": null, "transaction_tokens_involved": null, "transaction_tokens_swapped": null } diff --git a/src/containers/Amendment/BarChartVoting.tsx b/src/containers/Amendment/BarChartVoting.tsx new file mode 100644 index 000000000..86bfa3d6f --- /dev/null +++ b/src/containers/Amendment/BarChartVoting.tsx @@ -0,0 +1,146 @@ +import { useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + BarChart, + Bar, + XAxis, + YAxis, + Tooltip, + TooltipProps, + Label, + ResponsiveContainer, + // Text, + // Cell, +} from 'recharts' +import { + BLACK_600, + GREEN_400, + GREY_0, + GREY_600, + GREY_800, + MAGENTA_500, +} from '../shared/utils' + +interface Props { + data: any +} + +type ValueType = number | string | Array +type NameType = number | string + +const CustomTooltip = ({ + active, + payload, + label, +}: TooltipProps) => { + const { t } = useTranslation() + if (active) { + return ( +
+

{label}

+

+ {t('yeas_count', { + yeas_count: payload ? payload[0].payload.yeas : 0, + })} +

+

+ {t('nays_count', { + nays_count: payload ? payload[0].payload.nays : 0, + })} +

+
+ ) + } + return null +} + +const CustomLegend = () => { + const { t } = useTranslation() + return ( +
+
+
+ + {t('yeas')} +
+
+ + {t('nays')} +
+
+
+ ) +} + +export const BarChartVoting = ({ data }: Props) => { + const { t } = useTranslation() + const [showTooltips, setShowTooltips] = useState(false) + + return ( +
+ + + + + + + setShowTooltips(true)} + onMouseLeave={() => setShowTooltips(false)} + /> + setShowTooltips(true)} + onMouseLeave={() => setShowTooltips(false)} + /> + + + +
+ ) +} diff --git a/src/containers/Amendment/Simple.tsx b/src/containers/Amendment/Simple.tsx new file mode 100644 index 000000000..89c743710 --- /dev/null +++ b/src/containers/Amendment/Simple.tsx @@ -0,0 +1,134 @@ +/* eslint-disable no-nested-ternary -- Disabled for this file */ +import { useTranslation } from 'react-i18next' +import { Link } from 'react-router-dom' +import { TRANSACTION_ROUTE } from '../App/routes' +import { SimpleRow } from '../shared/components/Transaction/SimpleRow' +import { useLanguage } from '../shared/hooks' +import { RouteLink } from '../shared/routing' +import { BREAKPOINTS, localizeDate } from '../shared/utils' +import { AmendmentData, Voter } from '../shared/vhsTypes' + +interface validatorUNL { + signing_key: string + domain: string + unl: string | false +} + +interface SimpleProps { + data: AmendmentData + validators: Array + width: number +} + +const DATE_OPTIONS_AMENDMEND = { + year: 'numeric', + month: 'numeric', + day: 'numeric', + timeZone: 'UTC', +} + +const DEFAULT_EMPTY_VALUE = '--' + +export const Simple = ({ data, validators, width }: SimpleProps) => { + const { t } = useTranslation() + const language = useLanguage() + + const voting = data.voted !== undefined + + const calculateUNLNays = (voted: Voter, all: Array): number => + all.filter((val) => val.unl !== false).length - + voted.validators.filter((val) => val.unl !== false).length + + const renderStatus = () => + voting ? ( +
{`${t('not')} ${t('enabled')}`}
+ ) : ( +
{t('enabled')}
+ ) + + const renderDate = (date: string | null) => + date + ? localizeDate(new Date(date), language, DATE_OPTIONS_AMENDMEND) + : DEFAULT_EMPTY_VALUE + + const renderRowIndex = () => + voting ? ( + <> + {data.voted !== undefined && ( + <> + + {data.voted.validators.length} + + + {validators.length - data.voted.validators.length} + + + { + data.voted.validators.filter((voted) => voted.unl !== false) + .length + } + + + {calculateUNLNays(data.voted, validators)} + + + )} + + {t('voting')} + + + {data.consensus} + + + ) : data.tx_hash ? ( + + + {' '} + {renderDate(data.date)} + + + ) : ( + + {renderDate(data.date)} + + ) + + const rowIndex = renderRowIndex() + + const details = `https://xrpl.org/known-amendments.html#${data.name.toLowerCase()}` + + return ( + <> +
+ {data.name} + {data.id} + + {`v${data.rippled_version}`} + + {voting ? ( + {data.threshold} + ) : ( + data.tx_hash && ( + + + {' '} + {data.tx_hash} + + + ) + )} + + {details} + + {renderStatus()} + {width < BREAKPOINTS.landscape && rowIndex} +
+ {width >= BREAKPOINTS.landscape && ( +
{rowIndex}
+ )} + + ) +} diff --git a/src/containers/Amendment/Votes.tsx b/src/containers/Amendment/Votes.tsx new file mode 100644 index 000000000..deb422a51 --- /dev/null +++ b/src/containers/Amendment/Votes.tsx @@ -0,0 +1,132 @@ +import { useTranslation } from 'react-i18next' +import { AmendmentData } from '../shared/vhsTypes' +import SuccessIcon from '../shared/images/success.svg' +import DomainLink from '../shared/components/DomainLink' +import { RouteLink } from '../shared/routing' +import { VALIDATOR_ROUTE } from '../App/routes' +import { BarChartVoting } from './BarChartVoting' + +interface VotesProps { + data: AmendmentData + validators: Array +} + +interface validatorUNL { + pubkey: string + signing_key: string + domain: string + unl: string | false +} + +function compareValidators(a: validatorUNL, b: validatorUNL) { + if (a.unl === false && b.unl !== false) { + return 1 + } + if (a.unl !== false && b.unl === false) { + return -1 + } + if (a.domain === null && b.domain !== null) { + return 1 + } + if (a.domain !== null && b.domain === null) { + return -1 + } + if (a.domain === null && b.domain === null) { + return a.pubkey.localeCompare(b.pubkey) + } + + // Compare non-null values by the 'name' field + return a.domain.localeCompare(b.domain) +} + +export const Votes = ({ data, validators }: VotesProps) => { + const { t } = useTranslation() + + const voting = data.voted !== undefined + + const renderColumn = (label: string, validatorsList: Array) => ( +
+
{t(label)}
+
+ {validatorsList.map((validator, index) => ( +
+ {index + 1} + + {validator.domain ? ( + + ) : ( + + {validator.pubkey} + + )} + + {validator.unl && ( + + + + )} +
+ ))} +
+
+ ) + + const getNays = () => + validators + .filter( + (validator) => + !data.voted?.validators.some( + (voted) => voted.signing_key === validator.signing_key, + ), + ) + .sort(compareValidators) + + const getYeas = () => + validators + .filter( + (validator) => + data.voted?.validators.some( + (voted) => voted.signing_key === validator.signing_key, + ), + ) + .sort(compareValidators) + + const yeas = getYeas() + const nays = getNays() + + const aggregateVoting = () => [ + { + label: 'UNL', + yeas: yeas.filter((val) => val.unl !== false).length, + nays: nays.filter((val) => val.unl !== false).length, + }, + { + label: 'non-UNL', + yeas: yeas.filter((val) => val.unl === false).length, + nays: nays.filter((val) => val.unl === false).length, + }, + ] + + const aggregate = aggregateVoting() + + return voting ? ( +
+ {aggregate && } +
+ {renderColumn('yeas', yeas)} + {renderColumn('nays', nays)} +
+
+ {t('note')}: + + + + {t('indicate_unl')} +
+
+ ) : null +} diff --git a/src/containers/Amendment/amendment.scss b/src/containers/Amendment/amendment.scss new file mode 100644 index 000000000..c61a086f8 --- /dev/null +++ b/src/containers/Amendment/amendment.scss @@ -0,0 +1,135 @@ +@import '../shared/css/variables'; +@import '../shared/css/table'; + +.amendment-summary { + width: 80%; + max-width: 1200px; + margin: auto; + + .simple-body { + max-width: 1100px; + } + + .type { + display: inline-block; + margin-top: 80px; + margin-bottom: 32px; + color: $white; + font-size: 32px; + + @include for-size(tablet-portrait-up) { + margin-top: 120px; + margin-bottom: 64px; + font-size: 42px; + } + + @include bold; + } + + .rows { + padding-left: 0; + } + + .label { + margin-bottom: 0; + } + + .index { + width: 150px; + } + + .badge { + max-width: fit-content; + margin: 0; + color: $black-100 !important; + text-transform: capitalize; + + &.voting { + background-color: $black-30; + } + + &.enabled { + background-color: $green-60; + } + + &.consensus { + margin-top: 6px; + background-color: $yellow-50; + font-weight: 700 !important; + } + } + + .value { + &.eta { + color: $yellow-50 !important; + } + } + + .note { + .unl { + display: inline-block; + margin: 0 10px; + color: $green-40; + vertical-align: middle; + } + } + + .votes { + max-width: 1200px; + margin: 48px 0; + + .votes-columns { + margin: 48px 0; + + @include for-size(desktop-up) { + display: grid; + gap: 48px; + grid-template-columns: repeat(2, 1fr); + } + + .label { + @include bold; + + margin-bottom: 24px; + font-size: 24px; + text-transform: capitalize; + } + + .votes-column { + min-width: 0; + max-width: 100%; + flex: 1 1 0px; + margin-top: 24px; + + .vals { + background: $black-80; + + .row { + display: flex; + overflow: hidden; + padding: 16px; + border-bottom: 1px solid $black-70; + white-space: nowrap; + + .val { + overflow: hidden; + max-width: fit-content; + flex: 1; + text-overflow: ellipsis; + } + + .unl { + margin-left: 8px; + color: $green-40; + } + + .index { + max-width: 16px; + margin-right: 24px; + } + } + } + } + } + } +} diff --git a/src/containers/Amendment/index.tsx b/src/containers/Amendment/index.tsx new file mode 100644 index 000000000..90cbc2e89 --- /dev/null +++ b/src/containers/Amendment/index.tsx @@ -0,0 +1,132 @@ +import { useContext } from 'react' +import axios from 'axios' +import { useTranslation } from 'react-i18next' +import { useQuery } from 'react-query' +import { useWindowSize } from 'usehooks-ts' +import { useRouteParams } from '../shared/routing' +import { AMENDMENT_ROUTE } from '../App/routes' +import NetworkContext from '../shared/NetworkContext' +import { + FETCH_INTERVAL_ERROR_MILLIS, + FETCH_INTERVAL_VHS_MILLIS, + NOT_FOUND, + SERVER_ERROR, +} from '../shared/utils' +import { Simple } from './Simple' +import { AmendmentData } from '../shared/vhsTypes' +import Log from '../shared/log' +import { Votes } from './Votes' + +import './amendment.scss' +import NoMatch from '../NoMatch' +import { useAnalytics } from '../shared/analytics' +import { Loader } from '../shared/components/Loader' + +export const Amendment = () => { + const network = useContext(NetworkContext) + const { identifier = '' } = useRouteParams(AMENDMENT_ROUTE) + const { width } = useWindowSize() + const { t } = useTranslation() + const { trackException } = useAnalytics() + + const ERROR_MESSAGES = { + [NOT_FOUND]: { + title: 'amendment_not_found', + hints: ['check_amendment_key'], + }, + default: { + title: 'generic_error', + hints: ['not_your_fault'], + }, + } + + const getErrorMessage = (error: keyof typeof ERROR_MESSAGES | null) => + (error && ERROR_MESSAGES[error]) || ERROR_MESSAGES.default + + const { + data, + error, + isLoading: isAmendmentLoading, + } = useQuery( + ['fetchAmendmentData', identifier, network], + async () => fetchAmendmentData(), + { + refetchInterval: (_) => FETCH_INTERVAL_VHS_MILLIS, + refetchOnMount: true, + enabled: !!network, + }, + ) + + const { data: validators, isLoading: isValidatorsLoading } = useQuery( + ['fetchValidatorsData'], + () => fetchValidatorsData(), + { + refetchInterval: (returnedData, _) => + returnedData == null + ? FETCH_INTERVAL_ERROR_MILLIS + : FETCH_INTERVAL_VHS_MILLIS, + refetchOnMount: true, + enabled: process.env.VITE_ENVIRONMENT !== 'custom' || !!network, + }, + ) + + const fetchAmendmentData = async (): Promise => { + const url = `${process.env.VITE_DATA_URL}/amendment/vote/${network}/${identifier}` + return axios + .get(url) + .then((resp) => resp.data.amendment) + .catch((axiosError) => { + const status = + axiosError.response && axiosError.response.status + ? axiosError.response.status + : SERVER_ERROR + trackException(`${url} --- ${JSON.stringify(axiosError)}`) + return Promise.reject(status) + }) + } + + const fetchValidatorsData = () => { + const url = `${process.env.VITE_DATA_URL}/validators/${network}` + + return axios + .get(url) + .then((resp) => resp.data.validators) + .then((vals) => + vals.map((val) => ({ + pubkey: val.validation_public_key, + signing_key: val.signing_key, + domain: val.domain, + unl: val.unl, + })), + ) + .catch((e) => Log.error(e)) + } + + let body + + if (error) { + const message = getErrorMessage(error) + body = + } else if (data?.id && validators.length) { + body = ( + <> +
+
{t('amendment_summary')}
+
+
+ {data && validators && ( + + )} +
+ {data && validators && } + + ) + } + + return ( +
+ {(isValidatorsLoading || isAmendmentLoading) && } + {body} +
+ ) +} diff --git a/src/containers/Amendment/test/amendment-summary.test.js b/src/containers/Amendment/test/amendment-summary.test.js new file mode 100644 index 000000000..c85088fa7 --- /dev/null +++ b/src/containers/Amendment/test/amendment-summary.test.js @@ -0,0 +1,228 @@ +import { mount } from 'enzyme' +import moxios from 'moxios' +import { Route } from 'react-router-dom' +import { Amendment } from '..' +import i18n from '../../../i18n/testConfig' +import NetworkContext from '../../shared/NetworkContext' +import { QuickHarness, flushPromises } from '../../test/utils' +import { AMENDMENT_ROUTE } from '../../App/routes' +import votingAmendment from './mockVotingAmendment.json' +import validators from './mockValidatorsList.json' +import { NOT_FOUND } from '../../shared/utils' + +jest.mock('usehooks-ts', () => ({ + useWindowSize: () => ({ + width: 375, + height: 600, + }), +})) + +const MOCK_IDENTIFIER = votingAmendment.amendment.id + +describe('Amendments Page container', () => { + const createWrapper = () => + mount( + + + } /> + + , + ) + + const oldEnvs = process.env + + const { ResizeObserver } = window + + beforeEach(() => { + moxios.install() + process.env = { ...oldEnvs, VITE_ENVIRONMENT: 'mainnet' } + window.ResizeObserver = jest.fn().mockImplementation(() => ({ + observe: jest.fn(), + unobserve: jest.fn(), + disconnect: jest.fn(), + })) + }) + + afterEach(() => { + moxios.uninstall() + process.env = oldEnvs + window.ResizeObserver = ResizeObserver + }) + + it('renders without crashing', () => { + const wrapper = createWrapper() + wrapper.unmount() + }) + + it('renders all parts for a voting amendment', async (done) => { + moxios.stubRequest( + `${process.env.VITE_DATA_URL}/amendment/vote/main/${MOCK_IDENTIFIER}`, + { + status: 200, + response: votingAmendment, + }, + ) + + moxios.stubRequest(`${process.env.VITE_DATA_URL}/validators/main`, { + status: 200, + response: validators, + }) + + const wrapper = createWrapper() + await flushPromises() + await flushPromises() + await flushPromises() + wrapper.update() + expect(wrapper.find('.amendment-summary .summary .type').length).toBe(1) + + expect( + wrapper + .find('.amendment-summary .simple-body .rows .row') + .at(0) + .find('.value') + .html(), + ).toBe('
mock-name
') + + expect( + wrapper + .find('.amendment-summary .simple-body .rows .row') + .at(1) + .find('.value') + .html(), + ).toBe('
mock-amendment-id
') + + expect( + wrapper + .find('.amendment-summary .simple-body .rows .row') + .at(2) + .find('.value') + .html(), + ).toBe('
v1.12.0
') + + expect( + wrapper + .find('.amendment-summary .simple-body .rows .row') + .at(3) + .find('.value') + .html(), + ).toBe('
3/4
') + + expect( + wrapper + .find('.amendment-summary .simple-body .rows .row') + .at(4) + .find('.value a') + .html(), + ).toBe( + 'https://xrpl.org/known-amendments.html#mock-name', + ) + + expect( + wrapper + .find('.amendment-summary .simple-body .rows .row') + .at(5) + .find('.value .badge') + .html(), + ).toBe('
not enabled
') + + expect( + wrapper + .find('.amendment-summary .simple-body .rows .row') + .at(6) + .find('.value') + .html(), + ).toBe('
2
') + + expect( + wrapper + .find('.amendment-summary .simple-body .rows .row') + .at(7) + .find('.value') + .html(), + ).toBe('
4
') + + expect( + wrapper + .find('.amendment-summary .simple-body .rows .row') + .at(8) + .find('.value') + .html(), + ).toBe('
1
') + + expect( + wrapper + .find('.amendment-summary .simple-body .rows .row') + .at(9) + .find('.value') + .html(), + ).toBe('
3
') + + expect( + wrapper + .find('.amendment-summary .simple-body .rows .row') + .at(10) + .find('.value') + .html(), + ).toBe('
voting
') + + expect( + wrapper + .find('.amendment-summary .simple-body .rows .row') + .at(11) + .find('.value') + .html(), + ).toBe('
25%
') + + expect(wrapper.find('.amendment-summary .barchart').length).toBe(1) + + expect( + wrapper.find('.amendment-summary .votes .votes-columns .votes-column') + .length, + ).toBe(2) + + expect( + wrapper + .find('.amendment-summary .votes .votes-columns .votes-column') + .at(0) + .find('.vals .row').length, + ).toBe(2) + + expect( + wrapper + .find('.amendment-summary .votes .votes-columns .votes-column') + .at(1) + .find('.vals .row').length, + ).toBe(4) + + wrapper.unmount() + done() + }) + + it('renders 404 page on no match', async (done) => { + moxios.stubRequest( + `${process.env.VITE_DATA_URL}/amendment/vote/main/${MOCK_IDENTIFIER}`, + { + status: NOT_FOUND, + response: votingAmendment, + }, + ) + + moxios.stubRequest(`${process.env.VITE_DATA_URL}/validators/main`, { + status: 200, + response: validators, + }) + + const wrapper = createWrapper() + await flushPromises() + await flushPromises() + await flushPromises() + wrapper.update() + + expect(wrapper.find('.no-match').length).toBe(1) + wrapper.unmount() + done() + }) +}) diff --git a/src/containers/Amendment/test/mockValidatorsList.json b/src/containers/Amendment/test/mockValidatorsList.json new file mode 100644 index 000000000..570c579a5 --- /dev/null +++ b/src/containers/Amendment/test/mockValidatorsList.json @@ -0,0 +1,41 @@ +{ + "count": 6, + "validators": [ + { + "validation_public_key": "pub1", + "signing_key": "sign1", + "domain": "domain1.com", + "unl": "vl.ripple.com" + }, + { + "validation_public_key": "pub2", + "signing_key": "sign2", + "domain": "domain2.com", + "unl": false + }, + { + "validation_public_key": "pub3", + "signing_key": "sign3", + "domain": "domain3.com", + "unl": "vl.ripple.com" + }, + { + "validation_public_key": "pub4", + "signing_key": "sign4", + "domain": "domain4.com", + "unl": "vl.ripple.com" + }, + { + "validation_public_key": "pub5", + "signing_key": "sign5", + "domain": "domain5.com", + "unl": false + }, + { + "validation_public_key": "pub6", + "signing_key": "sign6", + "domain": "domain6.com", + "unl": "vl.ripple.com" + } + ] +} diff --git a/src/containers/Amendment/test/mockVotingAmendment.json b/src/containers/Amendment/test/mockVotingAmendment.json new file mode 100644 index 000000000..d498725d2 --- /dev/null +++ b/src/containers/Amendment/test/mockVotingAmendment.json @@ -0,0 +1,25 @@ +{ + "amendment": { + "id": "mock-amendment-id", + "name": "mock-name", + "rippled_version": "1.12.0", + "deprecated": false, + "threshold": "3/4", + "consensus": "25%", + "voted": { + "count": 2, + "validators": [ + { + "signing_key": "sign1", + "ledger_index": "index1", + "unl": "vl.ripple.com" + }, + { + "signing_key": "sign2", + "ledger_index": "82658047", + "unl": false + } + ] + } + } +} diff --git a/src/containers/App/index.tsx b/src/containers/App/index.tsx index 184ff4ab9..1ac0d7466 100644 --- a/src/containers/App/index.tsx +++ b/src/containers/App/index.tsx @@ -23,6 +23,7 @@ import { TOKEN_ROUTE, TRANSACTION_ROUTE, VALIDATOR_ROUTE, + AMENDMENT_ROUTE, } from './routes' import Ledgers from '../Ledgers' import { Ledger } from '../Ledger' @@ -35,6 +36,7 @@ import Token from '../Token' import { NFT } from '../NFT/NFT' import { legacyRedirect } from './legacyRedirects' import { useCustomNetworks } from '../shared/hooks' +import { Amendment } from '../Amendment' export const AppWrapper = () => { const mode = process.env.VITE_ENVIRONMENT @@ -66,6 +68,7 @@ export const AppWrapper = () => { [PAYSTRING_ROUTE, PayString], [TOKEN_ROUTE, Token], [NFT_ROUTE, NFT], + [AMENDMENT_ROUTE, Amendment], ] const redirect = legacyRedirect(basename, location) diff --git a/src/containers/App/routes.ts b/src/containers/App/routes.ts index 71c5419f0..eeee69b0c 100644 --- a/src/containers/App/routes.ts +++ b/src/containers/App/routes.ts @@ -56,3 +56,9 @@ export const VALIDATOR_ROUTE: RouteDefinition<{ }> = { path: `/validators/:identifier/:tab?`, } + +export const AMENDMENT_ROUTE: RouteDefinition<{ + identifier: string +}> = { + path: `/amendment/:identifier`, +} diff --git a/src/containers/Header/Search.tsx b/src/containers/Header/Search.tsx index d5db033a6..136920c78 100644 --- a/src/containers/Header/Search.tsx +++ b/src/containers/Header/Search.tsx @@ -40,6 +40,7 @@ const determineHashType = async (id: string, rippledContext: XrplClient) => { return 'nft' } } + // separator for currency formats const separators = /[.:+-]/ diff --git a/src/containers/Header/test/Search.test.js b/src/containers/Header/test/Search.test.js index a03f7698f..c1bd7423f 100644 --- a/src/containers/Header/test/Search.test.js +++ b/src/containers/Header/test/Search.test.js @@ -1,6 +1,7 @@ import { mount } from 'enzyme' import { I18nextProvider } from 'react-i18next' import { BrowserRouter as Router } from 'react-router-dom' +import moxios from 'moxios' import i18n from '../../../i18n/testConfig' import { Search } from '../Search' import * as rippled from '../../../rippled/lib/rippled' @@ -48,6 +49,7 @@ describe('Search component', () => { }) it('search values', async () => { + moxios.install() const wrapper = createWrapper() const input = wrapper.find('.search input') const ledgerIndex = '123456789' diff --git a/src/containers/Network/css/barchart.scss b/src/containers/Network/css/barchart.scss index 325b314a9..85042300c 100644 --- a/src/containers/Network/css/barchart.scss +++ b/src/containers/Network/css/barchart.scss @@ -48,13 +48,22 @@ background-color: $blue-purple-50; } + &.yea { + background-color: $green-50; + } + + &.nay { + background-color: $magenta-50; + } + width: 16px; height: 16px; border-radius: 4px; } .text { - color: $black-40; + color: $white; + text-transform: capitalize; } } } diff --git a/src/containers/shared/utils.js b/src/containers/shared/utils.js index 9114fdcc2..6aafc1904 100644 --- a/src/containers/shared/utils.js +++ b/src/containers/shared/utils.js @@ -32,6 +32,7 @@ export const VALIDATORS_REGEX = /^n[9H][0-9A-Za-z]{50}$/ export const CTID_REGEX = /^[cC][0-9A-Za-z]{15}$/ export const PURPLE = '#8884d8' +export const GREEN_400 = '#5BEB9D' export const GREEN_500 = '#32E685' export const GREEN_800 = '#1E8A50' export const PURPLE_500 = '#7919FF' @@ -40,6 +41,8 @@ export const GREY_0 = '#FFFFFF' export const GREY_400 = '#A2A2A4' export const GREY_600 = '#656E81' export const GREY_800 = '#383D47' +export const BLACK_600 = '#454549' +export const MAGENTA_500 = '#FF198B' export const BREAKPOINTS = { desktop: 1200, diff --git a/src/containers/shared/vhsTypes.ts b/src/containers/shared/vhsTypes.ts index e12982f9c..e60778572 100644 --- a/src/containers/shared/vhsTypes.ts +++ b/src/containers/shared/vhsTypes.ts @@ -98,3 +98,25 @@ export interface StreamValidator extends ValidatorResponse { pubkey?: string time?: string } + +export interface AmendmentData { + rippled_version: string + id: string + name: string + threshold?: string + consensus?: string + deprecated: boolean + date: string | null + tx_hash?: string + ledger_index?: number + voted?: Voter +} + +export interface Voter { + count: number + validators: Array<{ + signing_key: string + ledger_index: string + unl: string | false + }> +}