diff --git a/apps/explorer-e2e/src/integration/asset.cy.js b/apps/explorer-e2e/src/integration/asset.cy.js index 4838c1699..90e9da744 100644 --- a/apps/explorer-e2e/src/integration/asset.cy.js +++ b/apps/explorer-e2e/src/integration/asset.cy.js @@ -1,20 +1,148 @@ -import '../support/common.functions'; +context('Asset page', { tags: '@regression' }, function () { + before('gather system asset information', function () { + cy.get_asset_information().as('assetsInfo'); + }); -context('Asset page', function () { describe('Verify elements on page', function () { const assetsNavigation = 'a[href="/assets"]'; const assetHeader = '[data-testid="asset-header"]'; + const jsonSection = '.language-json'; - it('Assets page is displayed', function () { + before('Navigate to assets page', function () { cy.visit('/'); cy.get(assetsNavigation).click(); - cy.common_validate_blocks_data_displayed(assetHeader); + + // Check we have enough enough assets + const assetNames = Object.keys(this.assetsInfo); + assert.isAtLeast( + assetNames.length, + 5, + 'Ensuring we have at least 5 assets to test' + ); }); - it('Assets page displayed in mobile', function () { + it('should be able to see assets page sections', function () { + const assetNames = Object.keys(this.assetsInfo); + assetNames.forEach((assetName) => { + cy.get(assetHeader) + .contains(assetName) + .should('be.visible') + .next() + .within(() => { + cy.get(jsonSection).should('not.be.empty'); + }); + }); + }); + + it('should be able to see all asset details displayed in JSON', function () { + const assetNames = Object.keys(this.assetsInfo); + assetNames.forEach((assetName) => { + cy.get(assetHeader) + .contains(assetName) + .next() + .within(() => { + cy.get(jsonSection) + .invoke('text') + .convert_string_json_to_js_object() + .then((assetsListedInJson) => { + const assetInfo = this.assetsInfo[assetName]; + + assert.equal(assetsListedInJson.name, assetInfo.name); + assert.equal(assetsListedInJson.id, assetInfo.id); + assert.equal(assetsListedInJson.decimals, assetInfo.decimals); + assert.equal(assetsListedInJson.symbol, assetInfo.symbol); + assert.equal( + assetsListedInJson.source.__typename, + assetInfo.source.__typename + ); + + if (assetInfo.source.__typename == 'ERC20') { + assert.equal( + assetsListedInJson.source.contractAddress, + assetInfo.source.contractAddress + ); + } + + if (assetInfo.source.__typename == 'BuiltinAsset') { + assert.equal( + assetsListedInJson.source.maxFaucetAmountMint, + assetInfo.source.maxFaucetAmountMint + ); + } + + let knownAssetTypes = ['BuiltinAsset', 'ERC20']; + assert.include( + knownAssetTypes, + assetInfo.source.__typename, + `Checking that current asset type of ${assetInfo.source.__typename} / + is one of: ${knownAssetTypes}: / + If fail then we need to add extra tests for un-encountered asset types` + ); + }); + }); + }); + }); + + it('should be able to switch assets between light and dark mode', function () { + const whiteThemeSelectedMenuOptionColor = 'rgb(255, 7, 127)'; + const whiteThemeJsonFieldBackColor = 'rgb(255, 255, 255)'; + const whiteThemeSideMenuBackgroundColor = 'rgb(255, 255, 255)'; + const darkThemeSelectedMenuOptionColor = 'rgb(215, 251, 80)'; + const darkThemeJsonFieldBackColor = 'rgb(38, 38, 38)'; + const darkThemeSideMenuBackgroundColor = 'rgb(0, 0, 0)'; + const themeSwitcher = '[data-testid="theme-switcher"]'; + const jsonFields = '.hljs'; + const sideMenuBackground = '.absolute'; + + // Engage dark mode if not allready set + cy.get(sideMenuBackground) + .should('have.css', 'background-color') + .then((background_color) => { + if (background_color.includes(whiteThemeSideMenuBackgroundColor)) + cy.get(themeSwitcher).click(); + }); + + // Engage white mode + cy.get(themeSwitcher).click(); + + // White Mode + cy.get(assetsNavigation) + .should('have.css', 'background-color') + .and('include', whiteThemeSelectedMenuOptionColor); + cy.get(jsonFields) + .should('have.css', 'background-color') + .and('include', whiteThemeJsonFieldBackColor); + cy.get(sideMenuBackground) + .should('have.css', 'background-color') + .and('include', whiteThemeSideMenuBackgroundColor); + + // Dark Mode + cy.get(themeSwitcher).click(); + cy.get(assetsNavigation) + .should('have.css', 'background-color') + .and('include', darkThemeSelectedMenuOptionColor); + cy.get(jsonFields) + .should('have.css', 'background-color') + .and('include', darkThemeJsonFieldBackColor); + cy.get(sideMenuBackground) + .should('have.css', 'background-color') + .and('include', darkThemeSideMenuBackgroundColor); + }); + + it('should be able to see assets page displayed in mobile', function () { cy.common_switch_to_mobile_and_click_toggle(); cy.get(assetsNavigation).click(); - cy.common_validate_blocks_data_displayed(assetHeader); + + const assetNames = Object.keys(this.assetsInfo); + assetNames.forEach((assetName) => { + cy.get(assetHeader) + .contains(assetName) + .should('be.visible') + .next() + .within(() => { + cy.get(jsonSection).should('not.be.empty'); + }); + }); }); }); }); diff --git a/apps/explorer-e2e/src/integration/network.cy.js b/apps/explorer-e2e/src/integration/network.cy.js index 4898bc6bb..0e8f06af3 100644 --- a/apps/explorer-e2e/src/integration/network.cy.js +++ b/apps/explorer-e2e/src/integration/network.cy.js @@ -1,35 +1,267 @@ -import '../support/common.functions'; - -context('Network parameters page', function () { - before('visit token home page', function () { - cy.visit('/'); +context('Network parameters page', { tags: '@smoke' }, function () { + before('navigate to network parameter page', function () { + cy.fixture('net_parameter_format_lookup').as('networkParameterFormat'); }); describe('Verify elements on page', function () { const networkParametersNavigation = 'a[href="/network-parameters"]'; + const networkParametersHeader = '[data-testid="network-param-header"]'; + const tableRows = '[data-testid="key-value-table-row"]'; - beforeEach('Navigate to network parameter page', function () { + before('navigate to network parameter page', function () { + cy.visit('/'); cy.get(networkParametersNavigation).click(); }); - it('Network paremeter page is displayed', function () { - verifyNetworkParametersPageDisplayed(); + it('should show network parameter heading at top of page', function () { + cy.get(networkParametersHeader) + .should('have.text', 'Network Parameters') + .and('be.visible'); }); - it('Network parameter page displayed on mobile', function () { + it('should list each of the network parameters available', function () { + cy.get_network_parameters().then((network_parameters) => { + const numberOfNetworkParametersInSystem = + Object.keys(network_parameters).length; + cy.get(tableRows).should( + 'have.length', + numberOfNetworkParametersInSystem + ); + }); + }); + + it('should list each network parameter displayed with json - in the correct format', function () { + cy.get_network_parameters().then((network_parameters) => { + network_parameters = Object.entries(network_parameters); + network_parameters.forEach((network_parameter) => { + const parameterName = network_parameter[0]; + const parameterValue = network_parameter[1]; + if (this.networkParameterFormat.json.includes(parameterName)) { + cy.get(tableRows) + .contains(parameterName) + .should('be.visible') + .next() + .invoke('text') + .convert_string_json_to_js_object() + .then((jsonOnPage) => { + cy.wrap(parameterValue, { log: false }) + .convert_string_json_to_js_object() + .then((jsonInSystem) => + assert.deepEqual( + jsonOnPage, + jsonInSystem, + `Checking ${parameterName} has the correct value of ${jsonInSystem}` + ) + ); + }); + } + }); + }); + }); + + it('should list each network parameter displayed as a percentage - in the correct format', function () { + cy.get_network_parameters().then((network_parameters) => { + network_parameters = Object.entries(network_parameters); + network_parameters.forEach((network_parameter) => { + const parameterName = network_parameter[0]; + const parameterValue = network_parameter[1]; + if (this.networkParameterFormat.percentage.includes(parameterName)) { + const formattedPercentageParameter = + (parseFloat(parameterValue) * 100).toFixed(0) + '%'; + cy.get(tableRows) + .contains(parameterName) + .should('be.visible') + .next() + .invoke('text') + .then((parameterValueOnPage) => { + assert.equal( + parameterValueOnPage, + formattedPercentageParameter, + `Checking ${parameterName} has the correct value of ${formattedPercentageParameter}` + ); + cy.contains(parameterValueOnPage).should('be.visible'); + }); + } + }); + }); + }); + + it('should list each network parameter displayed as an id - in the correct format', function () { + cy.get_network_parameters().then((network_parameters) => { + network_parameters = Object.entries(network_parameters); + network_parameters.forEach((network_parameter) => { + const parameterName = network_parameter[0]; + const parameterValue = network_parameter[1]; + if (this.networkParameterFormat.id.includes(parameterName)) { + cy.get(tableRows) + .contains(parameterName) + .should('be.visible') + .next() + .should('contain', parameterValue) + .and('be.visible') + .invoke('text'); + } + }); + }); + }); + + it('should list each network parameter displayed as a date - in the correct format', function () { + cy.get_network_parameters().then((network_parameters) => { + network_parameters = Object.entries(network_parameters); + network_parameters.forEach((network_parameter) => { + const parameterName = network_parameter[0]; + const parameterValue = network_parameter[1]; + if (this.networkParameterFormat.date.includes(parameterName)) { + cy.get(tableRows) + .contains(parameterName) + .should('be.visible') + .next() + .should('contain', parameterValue) + .and('be.visible'); + } + }); + }); + }); + + it('should list each network parameter displayed as a duration - in the correct format', function () { + cy.get_network_parameters().then((network_parameters) => { + network_parameters = Object.entries(network_parameters); + network_parameters.forEach((network_parameter) => { + const parameterName = network_parameter[0]; + const parameterValue = network_parameter[1]; + if (this.networkParameterFormat.duration.includes(parameterName)) { + cy.get(tableRows) + .contains(parameterName) + .should('be.visible') + .next() + .should('contain', parameterValue) + .and('be.visible'); + } + }); + }); + }); + + it('should list each network parameter displayed as a currency value with four decimals - in the correct format', function () { + cy.get_network_parameters().then((network_parameters) => { + network_parameters = Object.entries(network_parameters); + network_parameters.forEach((network_parameter) => { + const parameterName = network_parameter[0]; + const parameterValue = network_parameter[1]; + if (this.networkParameterFormat.fiveDecimal.includes(parameterName)) { + cy.convert_number_to_four_decimal(parameterValue) + .add_commas_to_number_if_large_enough() + .then((parameterValueFormatted) => { + cy.get(tableRows) + .contains(parameterName) + .should('be.visible') + .next() + .invoke('text') + .then((parameterValueOnPage) => { + assert.equal( + parameterValueOnPage, + parameterValueFormatted, + `Checking ${parameterName} has the correct value of ${parameterValueFormatted}` + ); + cy.contains(parameterValueOnPage).should('be.visible'); + }); + }); + } + }); + }); + }); + + it('should list each network parameter displayed as a currency value with eighteen decimals - in the correct format', function () { + cy.get_network_parameters().then((network_parameters) => { + network_parameters = Object.entries(network_parameters); + network_parameters.forEach((network_parameter) => { + const parameterName = network_parameter[0]; + const parameterValue = network_parameter[1]; + if ( + this.networkParameterFormat.eighteenDecimal.includes(parameterName) + ) { + cy.convert_number_to_eighteen_decimal(parameterValue) + .add_commas_to_number_if_large_enough() + .then((parameterValueFormatted) => { + cy.get(tableRows) + .contains(parameterName) + .should('be.visible') + .next() + .invoke('text') + .then((parameterValueOnPage) => { + assert.equal( + parameterValueOnPage, + parameterValueFormatted, + `Checking ${parameterName} has the correct value of ${parameterValueFormatted}` + ); + cy.contains(parameterValueOnPage).should('be.visible'); + }); + }); + } + }); + }); + }); + + it('should be able to switch network parameter page - between light and dark mode', function () { + const whiteThemeSelectedMenuOptionColor = 'rgb(255, 7, 127)'; + const whiteThemeJsonFieldBackColor = 'rgb(255, 255, 255)'; + const whiteThemeSideMenuBackgroundColor = 'rgb(255, 255, 255)'; + const darkThemeSelectedMenuOptionColor = 'rgb(215, 251, 80)'; + const darkThemeJsonFieldBackColor = 'rgb(38, 38, 38)'; + const darkThemeSideMenuBackgroundColor = 'rgb(0, 0, 0)'; + const themeSwitcher = '[data-testid="theme-switcher"]'; + const jsonFields = '.hljs'; + const sideMenuBackground = '.absolute'; + + // Engage dark mode if not allready set + cy.get(sideMenuBackground) + .should('have.css', 'background-color') + .then((background_color) => { + if (background_color.includes(whiteThemeSideMenuBackgroundColor)) + cy.get(themeSwitcher).click(); + }); + + // Engage white mode + cy.get(themeSwitcher).click(); + + // White Mode + cy.get(networkParametersNavigation) + .should('have.css', 'background-color') + .and('include', whiteThemeSelectedMenuOptionColor); + cy.get(jsonFields) + .should('have.css', 'background-color') + .and('include', whiteThemeJsonFieldBackColor); + cy.get(sideMenuBackground) + .should('have.css', 'background-color') + .and('include', whiteThemeSideMenuBackgroundColor); + + // Dark Mode + cy.get(themeSwitcher).click(); + cy.get(networkParametersNavigation) + .should('have.css', 'background-color') + .and('include', darkThemeSelectedMenuOptionColor); + cy.get(jsonFields) + .should('have.css', 'background-color') + .and('include', darkThemeJsonFieldBackColor); + cy.get(sideMenuBackground) + .should('have.css', 'background-color') + .and('include', darkThemeSideMenuBackgroundColor); + }); + + it('should be able to see network parameters - on mobile', function () { cy.common_switch_to_mobile_and_click_toggle(); cy.get(networkParametersNavigation).click(); - verifyNetworkParametersPageDisplayed(); + cy.get_network_parameters().then((network_parameters) => { + network_parameters = Object.entries(network_parameters); + network_parameters.forEach((network_parameter) => { + const parameterName = network_parameter[0]; + cy.get(tableRows) + .contains(parameterName) + .should('be.visible') + .next() + .should('not.be.empty') + .and('be.visible'); + }); + }); }); }); - - function verifyNetworkParametersPageDisplayed() { - cy.get('[data-testid="network-param-header"]').should( - 'have.text', - 'Network Parameters' - ); - cy.common_verify_json_parameters(18); - cy.common_verify_json_string_values(6); - cy.common_verify_json_int_values(7); - } }); diff --git a/apps/explorer-e2e/src/integration/parties.cy.js b/apps/explorer-e2e/src/integration/parties.cy.js new file mode 100644 index 000000000..21a85e2b6 --- /dev/null +++ b/apps/explorer-e2e/src/integration/parties.cy.js @@ -0,0 +1,264 @@ +const vegaWalletPublicKey = Cypress.env('vegaWalletPublicKey'); +const partiesMenuHeader = 'a[href="/parties"]'; +const partiesSearchBox = '[data-testid="party-input"]'; +const partiesSearchAction = '[data-testid="go-submit"]'; +const partiesJsonSection = '[data-testid="parties-json"]'; +const txTimeout = Cypress.env('txTimeout'); + +let assetData = { + fUSDC: { id: 'fUSDC', name: 'USDC (fake)', amount: '10' }, + fBTC: { id: 'fBTC', name: 'BTC (fake)', amount: '6' }, + fEURO: { id: 'fEURO', name: 'EURO (fake)', amount: '8' }, + fDAI: { id: 'fDAI', name: 'DAI (fake)', amount: '2' }, +}; + +const assetsInTest = Object.keys(assetData); + +context('Parties page', { tags: '@regression' }, function () { + before('send-faucet assets to connected vega wallet', function () { + cy.vega_wallet_import(); + assetsInTest.forEach((asset) => { + cy.vega_wallet_receive_fauceted_asset( + assetData[asset].name, + assetData[asset].amount, + vegaWalletPublicKey + ); + }); + cy.visit('/'); + }); + + describe('Verify parties page content', function () { + before('navigate to parties page and search for party', function () { + cy.get(partiesMenuHeader).click(); + // Deliberate slow entry of party id/key - enabling transactions to sync + cy.get(partiesSearchBox).type(vegaWalletPublicKey, { delay: 120 }); + cy.get(partiesSearchAction).click(); + cy.get_connected_parties_accounts().as('party_accounts'); + + // Ensure balance of each party asset is correct + cy.get('@party_accounts').then((accounts) => { + assetsInTest.forEach((asset) => { + cy.get_asset_decimals(assetData[asset].id).then((assetDecimals) => { + assert.equal( + accounts[assetData[asset].id].balance, + assetData[asset].amount + assetDecimals, + `Checking ${assetData[asset].id} faucet was successfull` + ); + }); + }); + }); + }); + + it('should see party address id - having searched', function () { + cy.getByTestId('parties-header') + .siblings() + .contains(vegaWalletPublicKey.substring(0, 14)) + .should('be.visible'); + }); + + it('should see each asset and balance - within asset data section', function () { + assetsInTest.forEach((asset) => { + cy.contains(assetData[asset].name, txTimeout).should('be.visible'); + cy.contains(assetData[asset].name) + .siblings() + .contains(assetData[asset].id) + .should('be.visible'); + cy.contains(assetData[asset].name, txTimeout) + .parent() + .siblings() + .within(() => { + cy.get_asset_decimals(asset).then((assetDecimals) => { + cy.contains_exactly( + assetData[asset].amount + '.' + assetDecimals + ).should('be.visible'); + }); + }); + }); + }); + + it( + 'should be able to copy the party address id', + { browser: 'chrome' }, + function () { + cy.monitor_clipboard().as('clipboard'); + cy.getByTestId('parties-header') + .next() + .within(() => { + cy.get('button').click(); + }); + cy.get('@clipboard') + .get_copied_text_from_clipboard() + .should('equal', vegaWalletPublicKey); + } + ); + + it( + 'should be able to copy an asset id', + { browser: 'chrome' }, + function () { + cy.monitor_clipboard().as('clipboard'); + + cy.contains(assetData.fDAI.name, txTimeout).should('be.visible'); + cy.contains(assetData.fDAI.name) + .parent() + .parent() + .siblings() + .within(() => { + cy.get('[data-state="closed"]').last().click({ force: true }); + }); + + cy.get('@clipboard') + .get_copied_text_from_clipboard() + .should('equal', assetData.fDAI.id); + } + ); + + it('should be able to see JSON of each asset containing correct balance and decimals', function () { + cy.get(partiesJsonSection).should('be.visible'); + + assetsInTest.forEach((assetInTest) => { + cy.get(partiesJsonSection) + .invoke('text') + .convert_string_json_to_js_object() + .get_party_accounts_data_from_js_object() + .then((accountsListedInJson) => { + cy.get_asset_information().then((assetsInfo) => { + const assetInfo = + assetsInfo[accountsListedInJson[assetInTest].asset.name]; + + assert.equal( + accountsListedInJson[assetInTest].asset.name, + assetInfo.name + ); + assert.equal( + accountsListedInJson[assetInTest].asset.id, + assetInfo.id + ); + assert.equal( + accountsListedInJson[assetInTest].asset.decimals, + assetInfo.decimals + ); + assert.equal( + accountsListedInJson[assetInTest].asset.symbol, + assetInfo.symbol + ); + assert.equal( + accountsListedInJson[assetInTest].asset.source.__typename, + assetInfo.source.__typename + ); + cy.get_asset_decimals(assetInTest).then((assetDecimals) => { + assert.equal( + accountsListedInJson[assetInTest].balance, + assetData[assetInTest].amount + assetDecimals + ); + }); + }); + }); + }); + }); + + it('should be able to switch parties page between light and dark mode', function () { + const whiteThemeSelectedMenuOptionColor = 'rgb(255, 7, 127)'; + const whiteThemeJsonFieldBackColor = 'rgb(255, 255, 255)'; + const whiteThemeSideMenuBackgroundColor = 'rgb(255, 255, 255)'; + const darkThemeSelectedMenuOptionColor = 'rgb(215, 251, 80)'; + const darkThemeJsonFieldBackColor = 'rgb(38, 38, 38)'; + const darkThemeSideMenuBackgroundColor = 'rgb(0, 0, 0)'; + const themeSwitcher = '[data-testid="theme-switcher"]'; + const jsonFields = '.hljs'; + const sideMenuBackground = '.absolute'; + + // Engage dark mode if not allready set + cy.get(sideMenuBackground) + .should('have.css', 'background-color') + .then((background_color) => { + if (background_color.includes(whiteThemeSideMenuBackgroundColor)) + cy.get(themeSwitcher).click(); + }); + + // Engage white mode + cy.get(themeSwitcher).click(); + + // White Mode + cy.get(partiesMenuHeader) + .should('have.css', 'background-color') + .and('include', whiteThemeSelectedMenuOptionColor); + cy.get(jsonFields) + .should('have.css', 'background-color') + .and('include', whiteThemeJsonFieldBackColor); + cy.get(sideMenuBackground) + .should('have.css', 'background-color') + .and('include', whiteThemeSideMenuBackgroundColor); + + // Dark Mode + cy.get(themeSwitcher).click(); + cy.get(partiesMenuHeader) + .should('have.css', 'background-color') + .and('include', darkThemeSelectedMenuOptionColor); + cy.get(jsonFields) + .should('have.css', 'background-color') + .and('include', darkThemeJsonFieldBackColor); + cy.get(sideMenuBackground) + .should('have.css', 'background-color') + .and('include', darkThemeSideMenuBackgroundColor); + }); + + after( + 'teardown environment to prevent test data bleeding into other tests', + function () { + if (Cypress.env('TEARDOWN_NETWORK_AFTER_FLOWS')) { + cy.restart_vegacapsule_network(); + } + } + ); + + Cypress.Commands.add('get_asset_decimals', (assetID) => { + cy.get_asset_information().then((assetsInfo) => { + const assetDecimals = assetsInfo[assetData[assetID].name].decimals; + let decimals = ''; + for (let i = 0; i < assetDecimals; i++) decimals += '0'; + return decimals; + }); + }); + + Cypress.Commands.add('get_connected_parties_accounts', () => { + const mutation = + '{partiesConnection {edges{node{accountsConnection{edges{node{\ + balance type asset {id symbol decimals}}}}}}}}'; + cy.request({ + method: 'POST', + url: `http://localhost:3028/query`, + body: { + query: mutation, + }, + headers: { 'content-type': 'application/json' }, + }) + .its( + `body.data.partiesConnection.edges.1.node.accountsConnection.edges` + ) + .then(function (response) { + let accounts = {}; + response.forEach((account) => { + accounts[account.node.asset.id] = account.node; + }); + return accounts; + }); + }); + + Cypress.Commands.add( + 'get_party_accounts_data_from_js_object', + { prevSubject: true }, + (jsObject) => { + const accounts = jsObject.party.accounts.reduce(function ( + account, + entry + ) { + account[entry.asset.id] = entry; + return account; + }, + {}); + return accounts; + } + ); + }); +}); diff --git a/apps/explorer-e2e/src/integration/validator.cy.js b/apps/explorer-e2e/src/integration/validator.cy.js index 1529670b4..f4f4ca3ac 100644 --- a/apps/explorer-e2e/src/integration/validator.cy.js +++ b/apps/explorer-e2e/src/integration/validator.cy.js @@ -1,27 +1,300 @@ -import '../support/common.functions'; +context('Validator page', { tags: '@smoke' }, function () { + const validatorMenuHeading = 'a[href="/validators"]'; + const tendermintDataHeader = '[data-testid="tendermint-header"]'; + const vegaDataHeader = '[data-testid="vega-header"]'; + const jsonSection = '.language-json'; + + before('Visit validators page and obtain data', function () { + cy.visit('/'); + cy.get(validatorMenuHeading).click(); + cy.get_validators().as('validators'); + cy.get_nodes().as('nodes'); + }); -context('Validator page', function () { describe('Verify elements on page', function () { - const validatorNavigation = 'a[href="/validators"]'; - - it('Validator page is displayed', function () { - cy.visit('/'); - cy.get(validatorNavigation).click(); - validateValidatorsDisplayed(); - }); - - it('Validator page is displayed on mobile', function () { - cy.common_switch_to_mobile_and_click_toggle(); - cy.get(validatorNavigation).click(); - validateValidatorsDisplayed(); - }); - - function validateValidatorsDisplayed() { - cy.get('[data-testid="tendermint-header"]').should( - 'have.text', - 'Tendermint data' + before('Ensure at least two validators are present', function () { + assert.isAtLeast( + this.validators.length, + 2, + 'Ensuring at least two validators exist' ); - cy.get('[data-testid="tendermint-data"]').should('not.be.empty'); - } + }); + + it('should be able to see validator page sections', function () { + cy.get(vegaDataHeader) + .contains('Vega data') + .and('is.visible') + .next() + .within(() => { + cy.get(jsonSection).should('not.be.empty'); + }); + + cy.get(tendermintDataHeader) + .contains('Tendermint data') + .and('is.visible') + .next() + .within(() => { + cy.get(jsonSection).should('not.be.empty'); + }); + }); + + it('should be able to see relevant validator information in tendermint section', function () { + cy.get(tendermintDataHeader) + .contains('Tendermint data') + .next() + .within(() => { + cy.get(jsonSection) + .invoke('text') + .convert_string_json_to_js_object() + .then((validatorsInJson) => { + this.validators.forEach((validator, index) => { + const validatorInJson = + validatorsInJson.result.validators[index]; + + assert.equal( + validatorInJson.address, + validator.address, + `Checking that validator address shown in json matches system data` + ); + cy.contains(validator.address).should('be.visible'); + + assert.equal( + validatorInJson.pub_key.type, + validator.pub_key.type, + `Checking that validator public key type shown in json matches system data` + ); + cy.contains(validator.pub_key.type).should('be.visible'); + + assert.equal( + validatorInJson.pub_key.value, + validator.pub_key.value, + `Checking that validator public key value shown in json matches system data` + ); + cy.contains(validator.pub_key.value).should('be.visible'); + + assert.equal( + validatorInJson.voting_power, + validator.voting_power, + `Checking that validator voting power in json matches system data` + ); + cy.contains(validator.voting_power).should('be.visible'); + + // Proposer priority can change frequently mid test + // Therefore only checking the field name is present. + cy.contains('proposer_priority').should('be.visible'); + }); + }); + }); + }); + + it('should be able to see relevant node information in vega data section', function () { + cy.get(vegaDataHeader) + .contains('Vega data') + .next() + .within(() => { + cy.get(jsonSection) + .invoke('text') + .convert_string_json_to_js_object() + .then((nodesInJson) => { + this.nodes.forEach((node, index) => { + const nodeInJson = nodesInJson.nodes[index]; + + // Vegacapsule shows no info or null for following fields: + // name, infoURL, avatarUrl, location, epoch data + // Therefore, these values remain unchecked. + + assert.equal( + nodeInJson.__typename, + node.__typename, + `Checking that node __typename shown in json matches system data` + ); + cy.contains(node.__typename).should('be.visible'); + + assert.equal( + nodeInJson.id, + node.id, + `Checking that node id shown in json matches system data` + ); + cy.contains(node.id).should('be.visible'); + + assert.equal( + nodeInJson.pubkey, + node.pubkey, + `Checking that node pubkey shown in json matches system data` + ); + cy.contains(node.pubkey).should('be.visible'); + + assert.equal( + nodeInJson.tmPubkey, + node.tmPubkey, + `Checking that node tmPubkey shown in json matches system data` + ); + cy.contains(node.tmPubkey).should('be.visible'); + + assert.equal( + nodeInJson.ethereumAddress, + node.ethereumAddress, + `Checking that node ethereumAddress shown in json matches system data` + ); + cy.contains(node.ethereumAddress).should('be.visible'); + + assert.equal( + nodeInJson.stakedByOperator, + node.stakedByOperator, + `Checking that node stakedByOperator value shown in json matches system data` + ); + cy.contains(node.stakedByOperator).should('be.visible'); + + assert.equal( + nodeInJson.stakedByDelegates, + node.stakedByDelegates, + `Checking that node stakedByDelegates value shown in json matches system data` + ); + cy.contains(node.stakedByDelegates).should('be.visible'); + + assert.equal( + nodeInJson.stakedTotal, + node.stakedTotal, + `Checking that node stakedTotal shown in json matches system data` + ); + cy.contains(node.stakedTotal).should('be.visible'); + + assert.equal( + nodeInJson.pendingStake, + node.pendingStake, + `Checking that node pendingStake shown in json matches system data` + ); + cy.contains(node.pendingStake).should('be.visible'); + + assert.equal( + nodeInJson.status, + node.status, + `Checking that node status shown in json matches system data` + ); + cy.contains(node.status).should('be.visible'); + }); + }); + }); + }); + + it('should be able to see validator page displayed on mobile', function () { + cy.common_switch_to_mobile_and_click_toggle(); + cy.get(validatorMenuHeading).click(); + cy.get(vegaDataHeader) + .contains('Vega data') + .and('is.visible') + .next() + .within(() => { + cy.get(jsonSection).should('not.be.empty'); + }); + + cy.get(tendermintDataHeader) + .contains('Tendermint data') + .and('is.visible') + .next() + .within(() => { + cy.get(jsonSection).should('not.be.empty'); + }); + + cy.get(tendermintDataHeader) + .contains('Tendermint data') + .next() + .within(() => { + this.validators.forEach((validator) => { + cy.contains(validator.address).should('be.visible'); + cy.contains(validator.pub_key.type).should('be.visible'); + cy.contains(validator.pub_key.value).should('be.visible'); + cy.contains(validator.voting_power).should('be.visible'); + // Proposer priority can change frequently mid test + // Therefore only checking the field name is present. + cy.contains('proposer_priority').should('be.visible'); + }); + }); + }); + + it('should be able to switch validator page between light and dark mode', function () { + const whiteThemeSelectedMenuOptionColor = 'rgb(255, 7, 127)'; + const whiteThemeJsonFieldBackColor = 'rgb(255, 255, 255)'; + const whiteThemeSideMenuBackgroundColor = 'rgb(255, 255, 255)'; + const darkThemeSelectedMenuOptionColor = 'rgb(215, 251, 80)'; + const darkThemeJsonFieldBackColor = 'rgb(38, 38, 38)'; + const darkThemeSideMenuBackgroundColor = 'rgb(0, 0, 0)'; + const themeSwitcher = '[data-testid="theme-switcher"]'; + const jsonFields = '.hljs'; + const sideMenuBackground = '.absolute'; + + // Engage dark mode if not allready set + cy.get(sideMenuBackground) + .should('have.css', 'background-color') + .then((background_color) => { + if (background_color.includes(whiteThemeSideMenuBackgroundColor)) + cy.get(themeSwitcher).click(); + }); + + // Engage white mode + cy.get(themeSwitcher).click(); + + // White Mode + cy.get(validatorMenuHeading) + .should('have.css', 'background-color') + .and('include', whiteThemeSelectedMenuOptionColor); + cy.get(jsonFields) + .should('have.css', 'background-color') + .and('include', whiteThemeJsonFieldBackColor); + cy.get(sideMenuBackground) + .should('have.css', 'background-color') + .and('include', whiteThemeSideMenuBackgroundColor); + + // Dark Mode + cy.get(themeSwitcher).click(); + cy.get(validatorMenuHeading) + .should('have.css', 'background-color') + .and('include', darkThemeSelectedMenuOptionColor); + cy.get(jsonFields) + .should('have.css', 'background-color') + .and('include', darkThemeJsonFieldBackColor); + cy.get(sideMenuBackground) + .should('have.css', 'background-color') + .and('include', darkThemeSideMenuBackgroundColor); + }); + + Cypress.Commands.add('get_validators', () => { + cy.request({ + method: 'GET', + url: `http://localhost:26617/validators`, + headers: { 'content-type': 'application/json' }, + }) + .its(`body.result.validators`) + .then(function (response) { + let validators = []; + response.forEach((account, index) => { + validators[index] = account; + }); + return validators; + }); + }); + + Cypress.Commands.add('get_nodes', () => { + const mutation = + '{nodes { id name infoUrl avatarUrl pubkey tmPubkey ethereumAddress \ + location stakedByOperator stakedByDelegates stakedTotal pendingStake \ + epochData { total offline online __typename } status name __typename}}'; + cy.request({ + method: 'POST', + url: `http://localhost:3028/query`, + body: { + query: mutation, + }, + headers: { 'content-type': 'application/json' }, + }) + .its(`body.data.nodes`) + .then(function (response) { + let nodes = []; + response.forEach((node) => { + nodes.push(node); + }); + return nodes; + }); + }); }); }); diff --git a/apps/explorer/.env b/apps/explorer/.env index a38d60492..3533b45b9 100644 --- a/apps/explorer/.env +++ b/apps/explorer/.env @@ -2,9 +2,11 @@ NX_CHAIN_EXPLORER_URL=https://explorer.vega.trading/.netlify/functions/chain-exp NX_TENDERMINT_URL=https://n01.stagnet3.vega.xyz/tm NX_TENDERMINT_WEBSOCKET_URL=wss://n01.stagnet3.vega.xyz/tm/websocket NX_VEGA_CONFIG_URL=https://static.vega.xyz/assets/stagnet3-network.json -NX_VEGA_NETWORKS='{"TESTNET":"https://explorer.fairground.wtf","MAINNET":"https://explorer.vega.xyz"}' +NX_VEGA_NETWORKS={\"MAINNET"\:\"https://explorer.vega.xyz"\,\"TESTNET\":\"https://explorer.fairground.wtf\"} NX_VEGA_ENV=STAGNET3 NX_GITHUB_FEEDBACK_URL=https://github.com/vegaprotocol/feedback/discussions +NX_HOSTED_WALLET_URL=https://wallet.testnet.vega.xyz +NX_BLOCK_EXPLORER= # App flags NX_EXPLORER_ASSETS=1 @@ -15,4 +17,4 @@ NX_EXPLORER_PARTIES=1 NX_EXPLORER_VALIDATORS=1 NX_EXPLORER_MARKETS=1 NX_EXPLORER_ORACLES=1 -NX_EXPLORER_TXS_LIST=1 +NX_EXPLORER_TXS_LIST=0 diff --git a/apps/explorer/.env.capsule b/apps/explorer/.env.capsule index f360272cc..b062a7419 100644 --- a/apps/explorer/.env.capsule +++ b/apps/explorer/.env.capsule @@ -3,7 +3,7 @@ NX_CHAIN_EXPLORER_URL=https://explorer.vega.trading/.netlify/functions/chain-exp NX_TENDERMINT_URL=http://localhost:26617 NX_TENDERMINT_WEBSOCKET_URL=wss://localhost:26617/websocket NX_VEGA_URL=http://localhost:3028/query -NX_VEGA_NETWORKS='{"TESTNET":"https://explorer.fairground.wtf","MAINNET":"https://explorer.vega.xyz"}' +NX_VEGA_NETWORKS={\"MAINNET"\:\"https://explorer.vega.xyz"\,\"TESTNET\":\"https://explorer.fairground.wtf\"} NX_VEGA_ENV=CUSTOM NX_GITHUB_FEEDBACK_URL=https://github.com/vegaprotocol/feedback/discussions diff --git a/apps/explorer/.env.devnet b/apps/explorer/.env.devnet index 0953fb115..76c07de0c 100644 --- a/apps/explorer/.env.devnet +++ b/apps/explorer/.env.devnet @@ -4,6 +4,7 @@ NX_TENDERMINT_URL=https://n04.d.vega.xyz/tm NX_TENDERMINT_WEBSOCKET_URL=wss://n04.d.vega.xyz/tm/websocket NX_VEGA_CONFIG_URL=https://static.vega.xyz/assets/devnet-network.json NX_VEGA_URL=https://api.n04.d.vega.xyz/graphql -NX_VEGA_NETWORKS='{"TESTNET":"https://explorer.fairground.wtf","MAINNET":"https://explorer.vega.xyz"}' +NX_VEGA_NETWORKS={\"MAINNET"\:\"https://explorer.vega.xyz"\,\"TESTNET\":\"https://explorer.fairground.wtf\"} NX_VEGA_ENV=DEVNET NX_GITHUB_FEEDBACK_URL=https://github.com/vegaprotocol/feedback/discussions +NX_BLOCK_EXPLORER=https://be.devnet1.vega.xyz/rest diff --git a/apps/explorer/.env.mainnet b/apps/explorer/.env.mainnet index 13281de02..a5eac8b75 100644 --- a/apps/explorer/.env.mainnet +++ b/apps/explorer/.env.mainnet @@ -4,6 +4,7 @@ NX_TENDERMINT_URL=https://mainnet-observer-proxy01.ops.vega.xyz/ NX_TENDERMINT_WEBSOCKET_URL=wss://mainnet-observer-proxy01.ops.vega.xyz/websocket NX_VEGA_CONFIG_URL=https://static.vega.xyz/assets/mainnet-network.json NX_VEGA_URL=https://api.vega.xyz/query -NX_VEGA_NETWORKS='{"TESTNET":"https://explorer.fairground.wtf","MAINNET":"https://explorer.vega.xyz"}' +NX_VEGA_NETWORKS={\"MAINNET"\:\"https://explorer.vega.xyz"\,\"TESTNET\":\"https://explorer.fairground.wtf\"} NX_VEGA_ENV=MAINNET NX_GITHUB_FEEDBACK_URL=https://github.com/vegaprotocol/feedback/discussions +NX_BLOCK_EXPLORER= diff --git a/apps/explorer/.env.sandbox b/apps/explorer/.env.sandbox new file mode 100644 index 000000000..01a51fdf2 --- /dev/null +++ b/apps/explorer/.env.sandbox @@ -0,0 +1,11 @@ +# App configuration variables +NX_VEGA_URL=https://api.sandbox.vega.xyz/graphql +NX_VEGA_ENV=SANDBOX +NX_VEGA_CONFIG_URL=https://static.vega.xyz/assets/sandbox-network.json +NX_VEGA_EXPLORER_URL=https://sandbox.explorer.vega.xyz +NX_VEGA_DOCS_URL=https://docs.vega.xyz/docs/testnet +NX_ETHEREUM_PROVIDER_URL=https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8 +NX_ETHERSCAN_URL=https://sepolia.etherscan.io +NX_VEGA_NETWORKS={\"MAINNET"\:\"https://explorer.vega.xyz"\,\"TESTNET\":\"https://explorer.fairground.wtf\"} +NX_TENDERMINT_URL=https://tm.n01.sandbox.vega.xyz +NX_TENDERMINT_WEBSOCKET_URL=wss://tm.n01.sandbox.vega.xyz/websocket diff --git a/apps/explorer/.env.stagnet1 b/apps/explorer/.env.stagnet1 new file mode 100644 index 000000000..9051f3a38 --- /dev/null +++ b/apps/explorer/.env.stagnet1 @@ -0,0 +1,13 @@ +NX_ETHEREUM_PROVIDER_URL=https://sepolia.infura.io/v3/4f846e79e13f44d1b51bbd7ed9edefb8 +NX_ETHERSCAN_URL=https://sepolia.etherscan.io +NX_HOSTED_WALLET_URL=https://wallet.testnet.vega.xyz +NX_VEGA_CONFIG_URL=https://static.vega.xyz/assets/stagnet1-network.json +NX_VEGA_ENV=STAGNET1 +NX_VEGA_EXPLORER_URL=https://stagnet1.explorer.vega.xyz +NX_VEGA_NETWORKS={\"MAINNET"\:\"https://explorer.vega.xyz"\,\"TESTNET\":\"https://explorer.fairground.wtf\"} +NX_VEGA_TOKEN_URL=https://stagnet1.token.vega.xyz +NX_VEGA_URL=https://api.n00.stagnet1.vega.xyz/graphql +NX_VEGA_WALLET_URL=http://localhost:1789 +NX_TENDERMINT_URL=https://tm.n01.stagnet1.vega.xyz +NX_TENDERMINT_WEBSOCKET_URL=wss://tm.n01.stagnet1.vega.xyz/websocket +NX_BLOCK_EXPLORER=https://be.stagnet1.vega.xyz/rest diff --git a/apps/explorer/.env.stagnet3 b/apps/explorer/.env.stagnet3 index 3cb62f060..b8d197cdd 100644 --- a/apps/explorer/.env.stagnet3 +++ b/apps/explorer/.env.stagnet3 @@ -4,6 +4,7 @@ NX_TENDERMINT_URL=https://n01.stagnet3.vega.xyz/tm NX_TENDERMINT_WEBSOCKET_URL=wss://n01.stagnet3.vega.xyz/tm/websocket NX_VEGA_CONFIG_URL=https://static.vega.xyz/assets/stagnet3-network.json NX_VEGA_URL=https://api.n01.stagnet3.vega.xyz/graphql -NX_VEGA_NETWORKS='{"TESTNET":"https://explorer.fairground.wtf","MAINNET":"https://explorer.vega.xyz"}' +NX_VEGA_NETWORKS={\"MAINNET"\:\"https://explorer.vega.xyz"\,\"TESTNET\":\"https://explorer.fairground.wtf\"} NX_VEGA_ENV=STAGNET3 NX_GITHUB_FEEDBACK_URL=https://github.com/vegaprotocol/feedback/discussions +NX_BLOCK_EXPLORER=https://blockexplorer.n01.stagnet3.vega.xyz/rest diff --git a/apps/explorer/.env.testnet b/apps/explorer/.env.testnet index e90d0804c..1f96564b7 100644 --- a/apps/explorer/.env.testnet +++ b/apps/explorer/.env.testnet @@ -1,8 +1,10 @@ # App configuration variables NX_CHAIN_EXPLORER_URL=https://explorer.vega.trading/.netlify/functions/chain-explorer-api NX_TENDERMINT_URL=https://tm.n07.testnet.vega.xyz +NX_BLOCK_EXPLORER=https://be.testnet.vega.xyz/rest NX_TENDERMINT_WEBSOCKET_URL=wss://tm.n07.testnet.vega.xyz/websocket NX_VEGA_CONFIG_URL=https://static.vega.xyz/assets/testnet-network.json -NX_VEGA_NETWORKS='{"TESTNET":"https://explorer.fairground.wtf","MAINNET":"https://explorer.vega.xyz"}' +NX_VEGA_NETWORKS={\"MAINNET"\:\"https://explorer.vega.xyz"\,\"TESTNET\":\"https://explorer.fairground.wtf\"} NX_VEGA_ENV=TESTNET NX_GITHUB_FEEDBACK_URL=https://github.com/vegaprotocol/feedback/discussions +NX_VEGA_URL=https://api.n09.testnet.vega.xyz/graphql diff --git a/apps/explorer/.env.vegacapsule b/apps/explorer/.env.vegacapsule index cc49be5cb..a0b854c9a 100644 --- a/apps/explorer/.env.vegacapsule +++ b/apps/explorer/.env.vegacapsule @@ -5,3 +5,4 @@ NX_TENDERMINT_WEBSOCKET_URL=wss://localhost:26607/websocket NX_VEGA_URL=http://localhost:3003/query NX_VEGA_ENV=CUSTOM NX_GITHUB_FEEDBACK_URL=https://github.com/vegaprotocol/feedback/discussions +NX_BLOCK_EXPLORER= diff --git a/apps/explorer/.eslintrc.json b/apps/explorer/.eslintrc.json index de2a9b877..c236a78b5 100644 --- a/apps/explorer/.eslintrc.json +++ b/apps/explorer/.eslintrc.json @@ -14,5 +14,8 @@ "files": ["*.js", "*.jsx"], "rules": {} } - ] + ], + "env": { + "jest": true + } } diff --git a/apps/explorer/src/app/app.tsx b/apps/explorer/src/app/app.tsx index 2b21f5f55..fd5493640 100644 --- a/apps/explorer/src/app/app.tsx +++ b/apps/explorer/src/app/app.tsx @@ -45,7 +45,7 @@ function App() { menuOpen && 'h-[100vh] overflow-hidden' } antialiased m-0 bg-white dark:bg-black text-black dark:text-white`} > -
+
({ const renderComponent = () => ( -
+
); diff --git a/apps/explorer/src/app/components/header/header.tsx b/apps/explorer/src/app/components/header/header.tsx index c70cdca18..ecdb0c288 100644 --- a/apps/explorer/src/app/components/header/header.tsx +++ b/apps/explorer/src/app/components/header/header.tsx @@ -24,14 +24,15 @@ export const Header = ({ 'md:col-span-2', 'grid grid-rows-2 md:grid-rows-1 grid-cols-[1fr_auto] md:grid-cols-[auto_1fr_auto] items-center', 'p-4 gap-2 md:gap-4', - 'border-b border-neutral-700 dark:border-neutral-300' + 'border-b border-neutral-700 dark:border-neutral-300 bg-black', + 'dark text-white' ); return (

{t('Vega Explorer')} @@ -41,13 +42,13 @@ export const Header = ({

- +
); }; diff --git a/apps/explorer/src/app/components/info-block/index.ts b/apps/explorer/src/app/components/info-block/index.ts new file mode 100644 index 000000000..6ab368349 --- /dev/null +++ b/apps/explorer/src/app/components/info-block/index.ts @@ -0,0 +1 @@ +export * from './info-block'; diff --git a/apps/explorer/src/app/components/info-block/info-block.tsx b/apps/explorer/src/app/components/info-block/info-block.tsx new file mode 100644 index 000000000..4705dbe7c --- /dev/null +++ b/apps/explorer/src/app/components/info-block/info-block.tsx @@ -0,0 +1,29 @@ +import { Icon, Tooltip } from '@vegaprotocol/ui-toolkit'; +import React from 'react'; + +export interface InfoBlockProps { + title: string; + subtitle: string; + tooltipInfo: string; +} + +export const InfoBlock = ({ title, subtitle, tooltipInfo }: InfoBlockProps) => { + return ( +
+

{title}

+

+ {subtitle} + {tooltipInfo ? ( + + + + + + ) : undefined} +

+
+ ); +}; diff --git a/apps/explorer/src/app/components/info-panel/index.ts b/apps/explorer/src/app/components/info-panel/index.ts new file mode 100644 index 000000000..2a59f7622 --- /dev/null +++ b/apps/explorer/src/app/components/info-panel/index.ts @@ -0,0 +1 @@ +export * from './info-panel'; diff --git a/apps/explorer/src/app/components/info-panel/info-panel.tsx b/apps/explorer/src/app/components/info-panel/info-panel.tsx new file mode 100644 index 000000000..be3cc8f0a --- /dev/null +++ b/apps/explorer/src/app/components/info-panel/info-panel.tsx @@ -0,0 +1,81 @@ +import React, { useMemo } from 'react'; +import type { ReactNode } from 'react'; +import { Panel } from '../panel'; +import { + CopyWithTooltip, + Icon, + Intent, + Lozenge, + Tooltip, +} from '@vegaprotocol/ui-toolkit'; +import { TruncateInline } from '../truncate/truncate'; +import { useScreenDimensions } from '@vegaprotocol/react-helpers'; + +interface InfoPanelProps { + children?: ReactNode | ReactNode[]; + title: string; + id: string; + type?: string; + copy?: boolean; +} + +export const InfoPanel = ({ + children, + title, + id, + type, + copy = true, +}: InfoPanelProps) => { + const { screenSize } = useScreenDimensions(); + const isTruncated = useMemo( + () => ['xs', 'sm', 'md', 'lg'].includes(screenSize), + [screenSize] + ); + const isDesktop = useMemo(() => ['lg'].includes(screenSize), [screenSize]); + const visibleChars = useMemo(() => (isDesktop ? 20 : 10), [isDesktop]); + return ( + +
+
+
+

{title}

+ {isTruncated ? ( + + ) : ( +

+ {id} +

+ )} + + {type && ( + + + {type} + + + )} +
+
{children}
+
+ {copy && ( + + + + )} +
+
+ ); +}; diff --git a/apps/explorer/src/app/components/nested-data-list/index.ts b/apps/explorer/src/app/components/nested-data-list/index.ts new file mode 100644 index 000000000..7defce8bb --- /dev/null +++ b/apps/explorer/src/app/components/nested-data-list/index.ts @@ -0,0 +1 @@ +export * from './nested-data-list'; diff --git a/apps/explorer/src/app/components/nested-data-list/nested-data-list.spec.tsx b/apps/explorer/src/app/components/nested-data-list/nested-data-list.spec.tsx new file mode 100644 index 000000000..ccdb41af1 --- /dev/null +++ b/apps/explorer/src/app/components/nested-data-list/nested-data-list.spec.tsx @@ -0,0 +1,110 @@ +import { render, waitFor } from '@testing-library/react'; +import { + BORDER_COLOURS, + NestedDataList, + sortNestedDataByChildren, +} from './nested-data-list'; +import userEvent from '@testing-library/user-event'; + +const mockData = { + nonce: '5980890939790185837', + validatorHeartbeat: { + nodeId: 'e07d2cd299659590c16ec1cc1c69936ad747083c379ea6b6cfeaa6e22c8af0cb', + ethereumSignature: { + value: + '8b157cb43a716ad541065f643e38cd92d7b1857c7beeb7d81e878be4f9a48f5a346be973e478eba3c18907888678625ba6700b086f65406d5ce0af0cae1d419300', + algo: 'eth', + version: 0, + }, + vegaSignature: { + value: + 'abffba6ddf07b3a732214dd780c35c94f7f0aeb9c5e9ce7d1a3ee641926a6ac568a2ecf0160c859633d327712c7a1b1590db4245d40646559a1ab2cc44d6fa01', + algo: 'vega/ed25519', + version: 0, + }, + }, + blockHeight: '174534', +}; + +describe('NestedDataList', () => { + it('should display the parent as a button', () => { + const tree = render(); + const { getAllByRole } = tree; + expect(getAllByRole('button')[0]).toHaveTextContent('Validator Heartbeat'); + }); + + it('should display the children when a row is clicked', async () => { + const tree = render(); + const user = userEvent.setup(); + const { getAllByRole } = tree; + + const parent = getAllByRole('listitem', { name: 'Validator Heartbeat' }); + const nestedContainer = parent[0].querySelector('[aria-hidden]'); + const expandBtn = parent[0].querySelector('button'); + expect(nestedContainer).toHaveAttribute('aria-hidden', 'true'); + await user.click(expandBtn as HTMLButtonElement); + await waitFor(() => nestedContainer); + expect(nestedContainer).toHaveAttribute('aria-hidden', 'false'); + }); + + it('add border to the title of the parent', () => { + const tree = render(); + const { getAllByRole } = tree; + const parent = getAllByRole('listitem', { name: 'Validator Heartbeat' }); + expect(parent[0]).toHaveClass('pl-4 border-l-4'); + }); + + it('add a border the children with the same colour', () => { + const tree = render(); + const { getAllByRole } = tree; + const parent = getAllByRole('listitem', { name: 'Validator Heartbeat' }); + expect(parent[0].querySelector('li')).toHaveClass('pl-4 border-l-4 pt-2'); + }); + + it('should repeat the border colours in the correct order', () => { + const colourMockData = { + t0: { + t1: { + t2: { + t3: { + t4: { + t5: { + t6: { + t7: { + t8: { + hello: 'world', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }; + const tree = render(); + const { getByTestId } = tree; + + for (let i = 0; i < 8; i++) { + const item = getByTestId(`T${i}`); + const expected = BORDER_COLOURS.dark[i % 5]; + expect(item.style.borderColor.toUpperCase()).toBe(expected); + } + }); + + it('should sort the data by values with children', () => { + const mockData = { + nonce: '5980890939790185837', + validatorHeartbeat: { + nodeId: + 'e07d2cd299659590c16ec1cc1c69936ad747083c379ea6b6cfeaa6e22c8af0cb', + someArray: ['0', '1', '2'], + }, + blockHeight: '174534', + }; + const expected = ['nonce', 'blockHeight', 'validatorHeartbeat']; + const result = sortNestedDataByChildren(mockData); + expect(result).toStrictEqual(expected); + }); +}); diff --git a/apps/explorer/src/app/components/nested-data-list/nested-data-list.tsx b/apps/explorer/src/app/components/nested-data-list/nested-data-list.tsx new file mode 100644 index 000000000..a302721f3 --- /dev/null +++ b/apps/explorer/src/app/components/nested-data-list/nested-data-list.tsx @@ -0,0 +1,178 @@ +import React, { useCallback, useContext, useMemo, useState } from 'react'; +import classNames from 'classnames'; +import isObject from 'lodash/isObject'; +import { ThemeContext } from '@vegaprotocol/react-helpers'; +import { Icon } from '@vegaprotocol/ui-toolkit'; +import { IconNames } from '@blueprintjs/icons'; +import { VegaColours } from '@vegaprotocol/tailwindcss-config'; +import isArray from 'lodash/isArray'; + +export type UnknownObject = Record; +export type UnknownArray = unknown[]; + +interface NestedDataListProps { + data: UnknownObject | UnknownArray; + level?: number; +} + +interface NestedDataListItemProps { + label: string; + value: unknown; + index: number; +} + +export const BORDER_COLOURS = { + dark: [ + VegaColours.pink.dark, + VegaColours.purple.dark, + VegaColours.green.dark, + VegaColours.blue.dark, + VegaColours.yellow.dark, + ], + light: [ + VegaColours.pink.DEFAULT, + VegaColours.purple.DEFAULT, + VegaColours.green.DEFAULT, + VegaColours.blue.DEFAULT, + VegaColours.yellow.DEFAULT, + ], +}; + +const camelToTitle = (text: string) => { + const result = text.replace(/([A-Z])/g, ' $1'); + return result.charAt(0).toUpperCase() + result.slice(1); +}; + +const getBorderColour = (index: number, theme: keyof typeof BORDER_COLOURS) => { + const colours = BORDER_COLOURS[theme]; + const length = colours.length; + const modulo = index % length; + return colours[modulo]; +}; + +const NestedDataListItem = ({ + label, + value, + index, +}: NestedDataListItemProps) => { + const [isCollapsed, setCollapsed] = useState(true); + const toggleVisible = useCallback( + (event: React.MouseEvent) => { + event.stopPropagation(); + setCollapsed(!isCollapsed); + }, + [isCollapsed] + ); + const hasChildren = isObject(value) && !!Object.keys(value).length; + const title = useMemo(() => camelToTitle(label), [label]); + const theme = useContext(ThemeContext); + const currentLevelBorder = useMemo( + () => getBorderColour(index, theme), + [index, theme] + ); + const nextLevelBorder = useMemo( + () => getBorderColour(index + 1, theme), + [index, theme] + ); + const isArr = isArray(value); + + const listItemClasses = classNames('pl-4 border-l-4', { + 'pt-10 last:pb-0': hasChildren, + 'first:pt-0': hasChildren && !index, + 'pt-2': !hasChildren && index, + }); + + const titleClasses = classNames({ + 'text-xl pl-4 border-l-4 font-alpha': hasChildren, + 'text-base font-medium whitespace-nowrap': !hasChildren, + }); + + return ( +
  • +
    +

    + {hasChildren ? ( + + ) : ( + {title}: + )} +

    + {!hasChildren && ( + + {JSON.stringify(value, null, ' ')} + + )} +
    + {hasChildren && ( +
    + +
    + )} +
  • + ); +}; + +export const sortNestedDataByChildren = (data: UnknownObject | UnknownArray) => + Object.keys(data) + .filter((key) => key) + .sort((a, b) => { + const isArr = isArray(data); + const valA = isArr ? data[+a] : data[a]; + const valB = isArr ? data[+b] : data[b]; + const hasChildrenA = isObject(valA) && !!Object.keys(valA).length; + const hasChildrenB = isObject(valB) && !!Object.keys(valB).length; + + if (hasChildrenA && !hasChildrenB) { + return 1; + } + + if (!hasChildrenA && hasChildrenB) { + return -1; + } + + return 0; + }); + +export const NestedDataList = ({ data, level = 0 }: NestedDataListProps) => { + const nestedItems = useMemo(() => { + const sortedData = sortNestedDataByChildren(data); + const isArr = isArray(data); + + if (sortedData.length) { + return sortedData.map((key) => ( + + )); + } + + return null; + }, [data, level]); + + return
      {nestedItems}
    ; +}; diff --git a/apps/explorer/src/app/components/page-header/index.ts b/apps/explorer/src/app/components/page-header/index.ts new file mode 100644 index 000000000..0a4d17c36 --- /dev/null +++ b/apps/explorer/src/app/components/page-header/index.ts @@ -0,0 +1 @@ +export * from './page-header'; diff --git a/apps/explorer/src/app/components/page-header/page-header.tsx b/apps/explorer/src/app/components/page-header/page-header.tsx new file mode 100644 index 000000000..48b23af49 --- /dev/null +++ b/apps/explorer/src/app/components/page-header/page-header.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import type { ReactNode } from 'react'; +import { TruncateInline } from '../truncate/truncate'; +import { CopyWithTooltip, Icon } from '@vegaprotocol/ui-toolkit'; + +interface PageHeaderProps { + title: string; + truncateStart?: number; + truncateEnd?: number; + copy?: boolean; + prefix?: string | ReactNode; + className?: string; +} + +export const PageHeader = ({ + prefix, + title, + truncateStart, + truncateEnd, + copy = false, + className, +}: PageHeaderProps) => { + const titleClasses = 'text-4xl xl:text-5xl uppercase font-alpha'; + return ( +
    + {prefix} +
    +

    + {truncateStart && truncateEnd ? ( + + ) : ( + title + )} +

    + {copy && ( + + + + )} +
    +
    + ); +}; diff --git a/apps/explorer/src/app/components/panel/index.ts b/apps/explorer/src/app/components/panel/index.ts new file mode 100644 index 000000000..56e72b21f --- /dev/null +++ b/apps/explorer/src/app/components/panel/index.ts @@ -0,0 +1 @@ +export * from './panel'; diff --git a/apps/explorer/src/app/components/panel/panel.tsx b/apps/explorer/src/app/components/panel/panel.tsx new file mode 100644 index 000000000..9bab0c695 --- /dev/null +++ b/apps/explorer/src/app/components/panel/panel.tsx @@ -0,0 +1,17 @@ +import classNames from 'classnames'; +import type { ReactNode } from 'react'; + +interface PanelProps { + children: ReactNode; + className?: string; +} +export const Panel = ({ children, className }: PanelProps) => ( +
    + {children} +
    +); diff --git a/apps/explorer/src/app/components/route-title/index.tsx b/apps/explorer/src/app/components/route-title/index.tsx index 2be66771f..cccf9a4c1 100644 --- a/apps/explorer/src/app/components/route-title/index.tsx +++ b/apps/explorer/src/app/components/route-title/index.tsx @@ -14,7 +14,7 @@ export const RouteTitle = ({ }: RouteTitleProps) => { const classes = classnames( 'font-alpha', - 'text-2xl', + 'text-4xl', 'uppercase', 'mb-8', className diff --git a/apps/explorer/src/app/components/search/detect-search.spec.ts b/apps/explorer/src/app/components/search/detect-search.spec.ts new file mode 100644 index 000000000..1a321013b --- /dev/null +++ b/apps/explorer/src/app/components/search/detect-search.spec.ts @@ -0,0 +1,189 @@ +import { + detectTypeByFetching, + detectTypeFromQuery, + getSearchType, + isBlock, + isHexadecimal, + isNetworkParty, + isNonHex, + SearchTypes, + toHex, + toNonHex, +} from './detect-search'; +import { DATA_SOURCES } from '../../config'; + +global.fetch = jest.fn(); + +describe('Detect Search', () => { + it("should detect that it's a hexadecimal", () => { + const expected = true; + const testString = + '0x073ceaab59e5f2dd0561dec4883e7ee5bc7165cd4de34717a3ab8f2cbe3007f9'; + const actual = isHexadecimal(testString); + expect(actual).toBe(expected); + }); + + it("should detect that it's not hexadecimal", () => { + const expected = true; + const testString = + '073ceaab59e5f2dd0561dec4883e7ee5bc7165cd4de34717a3ab8f2cbe3007f9'; + const actual = isNonHex(testString); + expect(actual).toBe(expected); + }); + + it("should detect that it's a network party", () => { + const expected = true; + const testString = 'network'; + const actual = isNetworkParty(testString); + expect(actual).toBe(expected); + }); + + it("should detect that it's a block", () => { + const expected = true; + const testString = '3188'; + const actual = isBlock(testString); + expect(actual).toBe(expected); + }); + + it('should convert from non-hex to hex', () => { + const expected = '0x123'; + const testString = '123'; + const actual = toHex(testString); + expect(actual).toBe(expected); + }); + + it('should convert from hex to non-hex', () => { + const expected = '123'; + const testString = '0x123'; + const actual = toNonHex(testString); + expect(actual).toBe(expected); + }); + + it("should detect type client side from query if it's a hexadecimal", () => { + const expected = [SearchTypes.Party, SearchTypes.Transaction]; + const testString = + '0x4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F'; + const actual = detectTypeFromQuery(testString); + expect(actual).toStrictEqual(expected); + }); + + it("should detect type client side from query if it's a non hex", () => { + const expected = [SearchTypes.Party, SearchTypes.Transaction]; + const testString = + '4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F'; + const actual = detectTypeFromQuery(testString); + expect(actual).toStrictEqual(expected); + }); + + it("should detect type client side from query if it's a network party", () => { + const expected = [SearchTypes.Party]; + const testString = 'network'; + const actual = detectTypeFromQuery(testString); + expect(actual).toStrictEqual(expected); + }); + + it("should detect type client side from query if it's a block (number)", () => { + const expected = [SearchTypes.Block]; + const testString = '23432'; + const actual = detectTypeFromQuery(testString); + expect(actual).toStrictEqual(expected); + }); + + it("detectTypeByFetching should call fetch with non-hex query it's a transaction", async () => { + const query = '0xabc'; + const type = SearchTypes.Transaction; + // @ts-ignore issue related to polyfill + fetch.mockImplementation( + jest.fn(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + transaction: { + hash: query, + }, + }), + }) + ) + ); + const result = await detectTypeByFetching(query); + expect(fetch).toHaveBeenCalledWith( + `${DATA_SOURCES.blockExplorerUrl}/transactions/${toNonHex(query)}` + ); + expect(result).toBe(type); + }); + + it("detectTypeByFetching should call fetch with non-hex query it's a party", async () => { + const query = 'abc'; + const type = SearchTypes.Party; + // @ts-ignore issue related to polyfill + fetch.mockImplementation( + jest.fn(() => + Promise.resolve({ + ok: false, + }) + ) + ); + const result = await detectTypeByFetching(query); + expect(result).toBe(type); + }); + + it('getSearchType should return party from fetch response', async () => { + const query = + '0x4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F'; + const expected = SearchTypes.Party; + // @ts-ignore issue related to polyfill + fetch.mockImplementation( + jest.fn(() => + Promise.resolve({ + ok: false, + }) + ) + ); + const result = await getSearchType(query); + expect(result).toBe(expected); + }); + + it('getSearchType should return transaction from fetch response', async () => { + const query = + '4624293CFE3D8B67A0AB448BAFF8FBCF1A1B770D9D5F263761D3D6CBEA94D97F'; + const expected = SearchTypes.Transaction; + // @ts-ignore issue related to polyfill + fetch.mockImplementation( + jest.fn(() => + Promise.resolve({ + ok: true, + json: () => + Promise.resolve({ + transaction: { + hash: query, + }, + }), + }) + ) + ); + const result = await getSearchType(query); + expect(result).toBe(expected); + }); + + it('getSearchType should return undefined from transaction response', async () => { + const query = 'u'; + const expected = undefined; + const result = await getSearchType(query); + expect(result).toBe(expected); + }); + + it('getSearchType should return block if query is number', async () => { + const query = '123'; + const expected = SearchTypes.Block; + const result = await getSearchType(query); + expect(result).toBe(expected); + }); + + it('getSearchType should return party if query is network', async () => { + const query = 'network'; + const expected = SearchTypes.Party; + const result = await getSearchType(query); + expect(result).toBe(expected); + }); +}); diff --git a/apps/explorer/src/app/components/search/detect-search.ts b/apps/explorer/src/app/components/search/detect-search.ts new file mode 100644 index 000000000..19297f1bf --- /dev/null +++ b/apps/explorer/src/app/components/search/detect-search.ts @@ -0,0 +1,143 @@ +import { DATA_SOURCES } from '../../config'; +import type { BlockExplorerTransaction } from '../../routes/types/block-explorer-response'; + +export enum SearchTypes { + Transaction = 'transaction', + Party = 'party', + Block = 'block', + Order = 'order', +} + +export const TX_LENGTH = 64; + +export const isHexadecimal = (search: string) => + search.startsWith('0x') && search.length === 2 + TX_LENGTH; + +export const isNonHex = (search: string) => + !search.startsWith('0x') && search.length === TX_LENGTH; + +export const isBlock = (search: string) => !Number.isNaN(Number(search)); + +export const isNetworkParty = (search: string) => search === 'network'; + +export const toHex = (query: string) => + isHexadecimal(query) ? query : `0x${query}`; + +export const toNonHex = (query: string) => + isNonHex(query) ? query : `${query.replace('0x', '')}`; + +export const detectTypeFromQuery = ( + query: string +): SearchTypes[] | undefined => { + const i = query.toLowerCase(); + + if (isHexadecimal(i) || isNonHex(i)) { + return [SearchTypes.Party, SearchTypes.Transaction]; + } else if (isNetworkParty(i)) { + return [SearchTypes.Party]; + } else if (isBlock(i)) { + return [SearchTypes.Block]; + } + + return undefined; +}; + +export const detectTypeByFetching = async ( + query: string +): Promise => { + const hash = toNonHex(query); + const request = await fetch( + `${DATA_SOURCES.blockExplorerUrl}/transactions/${hash}` + ); + + if (request?.ok) { + const body: BlockExplorerTransaction = await request.json(); + + if (body?.transaction) { + return SearchTypes.Transaction; + } + } + + return SearchTypes.Party; +}; + +// Code commented out because the current solution to detect a hex is temporary (by process of elimination) +// export const detectTypeByFetching = async ( +// query: string, +// type: SearchTypes +// ): Promise => { +// const TYPES = [SearchTypes.Party, SearchTypes.Transaction]; +// +// if (!TYPES.includes(type)) { +// throw new Error('Search type provided not recognised'); +// } +// +// if (type === SearchTypes.Transaction) { +// const hash = toNonHex(query); +// const request = await fetch( +// `${DATA_SOURCES.blockExplorerUrl}/transactions/${hash}` +// ); +// +// if (request?.ok) { +// const body: BlockExplorerTransaction = await request.json(); +// +// if (body?.transaction) { +// return SearchTypes.Transaction; +// } +// } +// } else if (type === SearchTypes.Party) { +// const party = toNonHex(query); +// +// const request = await fetch( +// `${DATA_SOURCES.blockExplorerUrl}/transactions?limit=1&filters[tx.submitter]=${party}` +// ); +// +// if (request.ok) { +// const body: BlockExplorerTransactions = await request.json(); +// +// if (body?.transactions?.length) { +// return SearchTypes.Party; +// } +// } +// } +// +// return undefined; +// }; + +// export const getSearchType = async ( +// query: string +// ): Promise => { +// const searchTypes = detectTypeFromQuery(query); +// const hasResults = searchTypes?.length; +// +// if (hasResults) { +// if (hasResults > 1) { +// const promises = searchTypes.map((type) => +// detectTypeByFetching(query, type) +// ); +// const results = await Promise.all(promises); +// return results.find((result) => result !== undefined); +// } +// +// return searchTypes[0]; +// } +// +// return undefined; +// }; + +export const getSearchType = async ( + query: string +): Promise => { + const searchTypes = detectTypeFromQuery(query); + const hasResults = searchTypes?.length; + + if (hasResults) { + if (hasResults > 1) { + return await detectTypeByFetching(query); + } + + return searchTypes[0]; + } + + return undefined; +}; diff --git a/apps/explorer/src/app/components/search/search.spec.tsx b/apps/explorer/src/app/components/search/search.spec.tsx index 98cc1ef91..4f2eeff25 100644 --- a/apps/explorer/src/app/components/search/search.spec.tsx +++ b/apps/explorer/src/app/components/search/search.spec.tsx @@ -2,14 +2,23 @@ import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import { Search } from './search'; import { MemoryRouter } from 'react-router-dom'; import { Routes } from '../../routes/route-names'; +import { SearchTypes, getSearchType } from './detect-search'; const mockedNavigate = jest.fn(); +const mockGetSearchType = getSearchType as jest.MockedFunction< + typeof getSearchType +>; jest.mock('react-router-dom', () => ({ ...jest.requireActual('react-router-dom'), useNavigate: () => mockedNavigate, })); +jest.mock('./detect-search', () => ({ + ...jest.requireActual('./detect-search'), + getSearchType: jest.fn(), +})); + beforeEach(() => { mockedNavigate.mockClear(); }); @@ -39,9 +48,10 @@ describe('Search', () => { fireEvent.click(button); expect(await screen.findByTestId('search-error')).toHaveTextContent( - "Something doesn't look right" + 'Transaction type is not recognised' ); }); + it('should render error if no input is given', async () => { render(renderComponent()); const { button } = getInputs(); @@ -49,47 +59,14 @@ describe('Search', () => { fireEvent.click(button); expect(await screen.findByTestId('search-error')).toHaveTextContent( - 'Search required' - ); - }); - - it('should render error if transaction is not hex', async () => { - render(renderComponent()); - const { button, input } = getInputs(); - fireEvent.change(input, { - target: { - value: - '0x123456789012345678901234567890123456789012345678901234567890123Q', - }, - }); - - fireEvent.click(button); - - expect(await screen.findByTestId('search-error')).toHaveTextContent( - 'Transaction is not hexadecimal' - ); - }); - - it('should render error if transaction is not hex and does not have leading 0x', async () => { - render(renderComponent()); - const { button, input } = getInputs(); - fireEvent.change(input, { - target: { - value: - '123456789012345678901234567890123456789012345678901234567890123Q', - }, - }); - - fireEvent.click(button); - - expect(await screen.findByTestId('search-error')).toHaveTextContent( - 'Transaction is not hexadecimal' + 'Search query required' ); }); it('should redirect to transactions page', async () => { render(renderComponent()); const { button, input } = getInputs(); + mockGetSearchType.mockResolvedValue(SearchTypes.Transaction); fireEvent.change(input, { target: { value: @@ -108,6 +85,7 @@ describe('Search', () => { it('should redirect to transactions page without proceeding 0x', async () => { render(renderComponent()); const { button, input } = getInputs(); + mockGetSearchType.mockResolvedValue(SearchTypes.Transaction); fireEvent.change(input, { target: { value: @@ -123,9 +101,48 @@ describe('Search', () => { }); }); + it('should redirect to parties page', async () => { + render(renderComponent()); + const { button, input } = getInputs(); + mockGetSearchType.mockResolvedValue(SearchTypes.Party); + fireEvent.change(input, { + target: { + value: + '0x1234567890123456789012345678901234567890123456789012345678901234', + }, + }); + + fireEvent.click(button); + await waitFor(() => { + expect(mockedNavigate).toBeCalledWith( + `${Routes.PARTIES}/0x1234567890123456789012345678901234567890123456789012345678901234` + ); + }); + }); + + it('should redirect to parties page without proceeding 0x', async () => { + render(renderComponent()); + const { button, input } = getInputs(); + mockGetSearchType.mockResolvedValue(SearchTypes.Party); + fireEvent.change(input, { + target: { + value: + '1234567890123456789012345678901234567890123456789012345678901234', + }, + }); + + fireEvent.click(button); + await waitFor(() => { + expect(mockedNavigate).toBeCalledWith( + `${Routes.PARTIES}/0x1234567890123456789012345678901234567890123456789012345678901234` + ); + }); + }); + it('should redirect to blocks page if passed a number', async () => { render(renderComponent()); const { button, input } = getInputs(); + mockGetSearchType.mockResolvedValue(SearchTypes.Block); fireEvent.change(input, { target: { value: '123', diff --git a/apps/explorer/src/app/components/search/search.tsx b/apps/explorer/src/app/components/search/search.tsx index 9609a11a8..cb1043bc4 100644 --- a/apps/explorer/src/app/components/search/search.tsx +++ b/apps/explorer/src/app/components/search/search.tsx @@ -1,55 +1,54 @@ +import React, { useCallback, useState } from 'react'; import { t } from '@vegaprotocol/react-helpers'; -import { Input, InputError, Button } from '@vegaprotocol/ui-toolkit'; -import React from 'react'; +import { Button, Input, InputError } from '@vegaprotocol/ui-toolkit'; import { useForm } from 'react-hook-form'; import { useNavigate } from 'react-router-dom'; +import { getSearchType, SearchTypes, toHex } from './detect-search'; import { Routes } from '../../routes/route-names'; -const TX_LENGTH = 64; - interface FormFields { search: string; } -const isPrependedTransaction = (search: string) => - search.startsWith('0x') && search.length === 2 + TX_LENGTH; - -const isTransaction = (search: string) => - !search.startsWith('0x') && search.length === TX_LENGTH; - -const isBlock = (search: string) => !Number.isNaN(Number(search)); - export const Search = () => { const { register, handleSubmit } = useForm(); const navigate = useNavigate(); - const [error, setError] = React.useState(null); - const onSubmit = React.useCallback( - (fields: FormFields) => { + const [error, setError] = useState(null); + + const onSubmit = useCallback( + async (fields: FormFields) => { setError(null); - const search = fields.search; - if (!search) { - setError(new Error(t('Search required'))); - } else if (isPrependedTransaction(search)) { - if (Number.isNaN(Number(search))) { - setError(new Error(t('Transaction is not hexadecimal'))); - } else { - navigate(`${Routes.TX}/${search}`); - } - } else if (isTransaction(search)) { - if (Number.isNaN(Number(`0x${search}`))) { - setError(new Error(t('Transaction is not hexadecimal'))); - } else { - navigate(`${Routes.TX}/0x${search}`); - } - } else if (isBlock(search)) { - navigate(`${Routes.BLOCKS}/${Number(search)}`); - } else { - setError(new Error(t("Something doesn't look right"))); + const query = fields.search; + + if (!query) { + return setError(new Error(t('Search query required'))); } + + const result = await getSearchType(query); + const urlAsHex = toHex(query); + const unrecognisedError = new Error( + t('Transaction type is not recognised') + ); + + if (result) { + switch (result) { + case SearchTypes.Party: + return navigate(`${Routes.PARTIES}/${urlAsHex}`); + case SearchTypes.Transaction: + return navigate(`${Routes.TX}/${urlAsHex}`); + case SearchTypes.Block: + return navigate(`${Routes.BLOCKS}/${Number(query)}`); + default: + return setError(unrecognisedError); + } + } + + return setError(unrecognisedError); }, [navigate] ); + return (
    { {t('Search by block number or transaction hash')}
    - - {error?.message && ( -
    - - {error.message} - -
    - )} +
    + + {error?.message && ( +
    + + {error.message} + +
    + )} +
    diff --git a/apps/explorer/src/app/components/truncate/truncated-link.tsx b/apps/explorer/src/app/components/truncate/truncated-link.tsx index 2d19699a7..4b28dff95 100644 --- a/apps/explorer/src/app/components/truncate/truncated-link.tsx +++ b/apps/explorer/src/app/components/truncate/truncated-link.tsx @@ -20,7 +20,7 @@ export const TruncatedLink = ({ text={text} startChars={startChars} endChars={endChars} - className="font-mono font-bold underline" + className="underline uppercase underline-offset-2" /> ); diff --git a/apps/explorer/src/app/components/txs/__generated__/TxsStats.ts b/apps/explorer/src/app/components/txs/__generated__/TxsStats.ts new file mode 100644 index 000000000..49acdb638 --- /dev/null +++ b/apps/explorer/src/app/components/txs/__generated__/TxsStats.ts @@ -0,0 +1,35 @@ +/* tslint:disable */ +/* eslint-disable */ +// @generated +// This file was automatically generated and should not be edited. + +// ==================================================== +// GraphQL query operation: TxsStats +// ==================================================== + +export interface TxsStats_statistics { + __typename: "Statistics"; + /** + * Average number of orders added per blocks + */ + averageOrdersPerBlock: string; + /** + * Number of orders per seconds + */ + ordersPerSecond: string; + /** + * Number of transaction processed per block + */ + txPerBlock: string; + /** + * Number of the trades per seconds + */ + tradesPerSecond: string; +} + +export interface TxsStats { + /** + * Get statistics about the Vega node + */ + statistics: TxsStats_statistics; +} diff --git a/apps/explorer/src/app/components/txs/index.tsx b/apps/explorer/src/app/components/txs/index.tsx index 74e28c13c..438d8cf0c 100644 --- a/apps/explorer/src/app/components/txs/index.tsx +++ b/apps/explorer/src/app/components/txs/index.tsx @@ -3,3 +3,4 @@ export { BlockTxsData } from './block-txs-data'; export { TxOrderType } from './tx-order-type'; export { TxsInfiniteList } from './txs-infinite-list'; export { TxsInfiniteListItem } from './txs-infinite-list-item'; +export { TxsStatsInfo } from './txs-stats-info'; diff --git a/apps/explorer/src/app/components/txs/tx-order-type.tsx b/apps/explorer/src/app/components/txs/tx-order-type.tsx index 71d5caaac..5c851255d 100644 --- a/apps/explorer/src/app/components/txs/tx-order-type.tsx +++ b/apps/explorer/src/app/components/txs/tx-order-type.tsx @@ -1,5 +1,3 @@ -import { Lozenge, Intent } from '@vegaprotocol/ui-toolkit'; - interface TxOrderTypeProps { orderType: string; className?: string; @@ -34,10 +32,13 @@ const displayString: StringMap = { ValidatorHeartbeat: 'Validator Heartbeat', }; -export const TxOrderType = ({ orderType, className }: TxOrderTypeProps) => { +export const TxOrderType = ({ orderType }: TxOrderTypeProps) => { return ( - +
    {displayString[orderType] || orderType} - +
    ); }; diff --git a/apps/explorer/src/app/components/txs/txs-infinite-list-item.spec.tsx b/apps/explorer/src/app/components/txs/txs-infinite-list-item.spec.tsx index 2673042e9..9ce8bb834 100644 --- a/apps/explorer/src/app/components/txs/txs-infinite-list-item.spec.tsx +++ b/apps/explorer/src/app/components/txs/txs-infinite-list-item.spec.tsx @@ -1,92 +1,89 @@ import { TxsInfiniteListItem } from './txs-infinite-list-item'; -import { render, screen, fireEvent, act } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; describe('Txs infinite list item', () => { - it('should display "missing vital data" if "Type" data missing', () => { + it('should display "missing vital data" if "type" data missing', () => { render( ); expect(screen.getByText('Missing vital data')).toBeInTheDocument(); }); - it('should display "missing vital data" if "Command" data missing', () => { + it('should display "missing vital data" if "hash" data missing', () => { render( ); expect(screen.getByText('Missing vital data')).toBeInTheDocument(); }); - it('should display "missing vital data" if "Pubkey" data missing', () => { + it('should display "missing vital data" if "submitter" data missing', () => { render( ); expect(screen.getByText('Missing vital data')).toBeInTheDocument(); }); - it('should display "missing vital data" if "TxHash" data missing', () => { + it('should display "missing vital data" if "block" data missing', () => { render( + ); + expect(screen.getByText('Missing vital data')).toBeInTheDocument(); + }); + + it('should display "missing vital data" if "index" data missing', () => { + render( + ); expect(screen.getByText('Missing vital data')).toBeInTheDocument(); }); it('renders data correctly', () => { - const testCommandData = JSON.stringify({ - test: 'test of command data', - }); - render( ); expect(screen.getByTestId('tx-hash')).toHaveTextContent('testTxHash'); expect(screen.getByTestId('pub-key')).toHaveTextContent('testPubKey'); - expect(screen.getByTestId('type')).toHaveTextContent('testType'); - const button = screen.getByTestId('command-details'); - act(() => { - fireEvent.click(button); - }); - expect(screen.getByText('"test of command data"')).toBeInTheDocument(); + expect(screen.getByTestId('tx-type')).toHaveTextContent('testType'); + expect(screen.getByTestId('tx-block')).toHaveTextContent('1'); + expect(screen.getByTestId('tx-index')).toHaveTextContent('1'); }); }); diff --git a/apps/explorer/src/app/components/txs/txs-infinite-list-item.tsx b/apps/explorer/src/app/components/txs/txs-infinite-list-item.tsx index 17c545efe..6214b7a8e 100644 --- a/apps/explorer/src/app/components/txs/txs-infinite-list-item.tsx +++ b/apps/explorer/src/app/components/txs/txs-infinite-list-item.tsx @@ -1,69 +1,79 @@ -import React, { useState } from 'react'; -import { - Dialog, - Icon, - Intent, - SyntaxHighlighter, -} from '@vegaprotocol/ui-toolkit'; +import React from 'react'; import { TruncatedLink } from '../truncate/truncated-link'; import { Routes } from '../../routes/route-names'; import { TxOrderType } from './tx-order-type'; -import type { ChainExplorerTxResponse } from '../../routes/types/chain-explorer-response'; +import type { BlockExplorerTransactionResult } from '../../routes/types/block-explorer-response'; +import { toHex } from '../search/detect-search'; const TRUNCATE_LENGTH = 14; export const TxsInfiniteListItem = ({ - TxHash, - PubKey, - Type, - Command, -}: ChainExplorerTxResponse) => { - const [open, setOpen] = useState(false); - - if (!TxHash || !PubKey || !Type || !Command) { + hash, + submitter, + type, + block, + index, +}: Partial) => { + if ( + !hash || + !submitter || + !type || + block === undefined || + index === undefined + ) { return
    Missing vital data
    ; } return (
    -
    +
    + ID:  -
    -
    -
    - - - setOpen(false)} - intent={Intent.None} - > - - + By:  + +
    +
    + +
    +
    + Block:  + +
    +
    + Index:  + {index}
    ); diff --git a/apps/explorer/src/app/components/txs/txs-infinite-list.spec.tsx b/apps/explorer/src/app/components/txs/txs-infinite-list.spec.tsx index 97e100cd5..b86c1fcfa 100644 --- a/apps/explorer/src/app/components/txs/txs-infinite-list.spec.tsx +++ b/apps/explorer/src/app/components/txs/txs-infinite-list.spec.tsx @@ -1,18 +1,34 @@ import { TxsInfiniteList } from './txs-infinite-list'; import { render, screen, fireEvent, act } from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; +import type { BlockExplorerTransactionResult } from '../../routes/types/block-explorer-response'; -const generateTxs = (number: number) => { +const generateTxs = (number: number): BlockExplorerTransactionResult[] => { return Array.from(Array(number)).map((_) => ({ - Type: 'ChainEvent', - Command: - '{"txId":"0xc8941ac4ea989988cb8f72e8fdab2e2009376fd17619491439d36b519d27bc93","nonce":"1494","stakingEvent":{"index":"263","block":"14805346","stakeDeposited":{"ethereumAddress":"0x2e5fe63e5d49c26998cf4bfa9b64de1cf9ae7ef2","vegaPublicKey":"657c2a8a5867c43c831e24820b7544e2fdcc1cf610cfe0ece940fe78137400fd","amount":"38471116086510047870875","blockTime":"1652968806"}}}', - Sig: 'fe7624ab742c492cf1e667e79de4777992aca8e093c8707e1f22685c3125c6082cd21b85cd966a61ad4ca0cca2f8bed3082565caa5915bc3b2f78c1ae35cac0b', - PubKey: - '0x7d69327393cdfaaae50e5e215feca65273eafabfb38f32b8124e66298af346d5', - Nonce: 18296387398179254000, - TxHash: - '0x9C753FA6325F7A40D9C4FA5C25E24476C54613E12B1FA2DD841E3BB00D088B77', + block: '87901', + index: 2, + hash: '0F8B98DA0923A50786B852D9CA11E051CACC4C733E1DB93D535C7D81DBD10F6F', + submitter: + '4b782482f587d291e8614219eb9a5ee9280fa2c58982dee71d976782a9be1964', + type: 'Submit Order', + code: 0, + cursor: '87901.2', + command: { + nonce: '4214037379192575529', + blockHeight: '87898', + orderSubmission: { + marketId: + 'b4d0a070f5cc73a7d53b23d6f63f8cb52e937ed65d2469a3af4cc1e80e155fcf', + price: '14525946', + size: '54', + side: 'SIDE_SELL', + timeInForce: 'TIME_IN_FORCE_GTT', + expiresAt: '1664966445481288736', + type: 'TYPE_LIMIT', + reference: 'traderbot', + peggedOrder: null, + }, + }, })); }; @@ -41,13 +57,15 @@ describe('Txs infinite list', () => { error={Error('test error!')} /> ); - expect(screen.getByText('Error: test error!')).toBeInTheDocument(); + expect( + screen.getByText('Cannot fetch transaction: Error: test error!') + ).toBeInTheDocument(); }); it('item renders data of n length into list of n length', () => { // Provided the number of items doesn't exceed the 30 it initially // desires, all txs will initially render - const txs = generateTxs(10); + const txs = generateTxs(7); render( { screen .getByTestId('infinite-scroll-wrapper') .querySelectorAll('.txs-infinite-list-item') - ).toHaveLength(10); + ).toHaveLength(7); }); it('tries to load more items when required to initially fill the list', () => { @@ -108,7 +126,7 @@ describe('Txs infinite list', () => { }); it('loads more items is called when scrolled', () => { - const txs = generateTxs(20); + const txs = generateTxs(14); const callback = jest.fn(); render( @@ -125,7 +143,7 @@ describe('Txs infinite list', () => { act(() => { fireEvent.scroll(screen.getByTestId('infinite-scroll-wrapper'), { - target: { scrollY: 600 }, + target: { scrollY: 2000 }, }); }); diff --git a/apps/explorer/src/app/components/txs/txs-infinite-list.tsx b/apps/explorer/src/app/components/txs/txs-infinite-list.tsx index 5a7bc9182..a5378b765 100644 --- a/apps/explorer/src/app/components/txs/txs-infinite-list.tsx +++ b/apps/explorer/src/app/components/txs/txs-infinite-list.tsx @@ -1,21 +1,21 @@ import React from 'react'; import { FixedSizeList as List } from 'react-window'; import InfiniteLoader from 'react-window-infinite-loader'; -import { t } from '@vegaprotocol/react-helpers'; +import { t, useScreenDimensions } from '@vegaprotocol/react-helpers'; import { TxsInfiniteListItem } from './txs-infinite-list-item'; -import type { ChainExplorerTxResponse } from '../../routes/types/chain-explorer-response'; +import type { BlockExplorerTransactionResult } from '../../routes/types/block-explorer-response'; interface TxsInfiniteListProps { hasMoreTxs: boolean; areTxsLoading: boolean | undefined; - txs: ChainExplorerTxResponse[] | undefined; + txs: BlockExplorerTransactionResult[] | undefined; loadMoreTxs: () => void; error: Error | undefined; className?: string; } interface ItemProps { - index: ChainExplorerTxResponse; + index: BlockExplorerTransactionResult; style: React.CSSProperties; isLoading: boolean; error: Error | undefined; @@ -27,19 +27,19 @@ const NOOP = () => {}; const Item = ({ index, style, isLoading, error }: ItemProps) => { let content; if (error) { - content = t(`${error}`); + content = t(`Cannot fetch transaction: ${error}`); } else if (isLoading) { content = t('Loading...'); } else { - const { TxHash, PubKey, Type, Command, Sig, Nonce } = index; + const { hash, submitter, type, command, block, index: blockIndex } = index; content = ( ); } @@ -55,6 +55,9 @@ export const TxsInfiniteList = ({ error, className, }: TxsInfiniteListProps) => { + const { screenSize } = useScreenDimensions(); + const isStacked = ['xs', 'sm', 'md', 'lg'].includes(screenSize); + if (!txs) { return
    No items
    ; } @@ -71,10 +74,15 @@ export const TxsInfiniteList = ({ return (
    -
    -
    Txn hash
    -
    Party
    -
    Type
    +
    +
    + Transaction   + ID +
    +
    Submitted By
    +
    Type
    +
    Block
    +
    Index
    { + const { data, startPolling, stopPolling } = useQuery(STATS_QUERY); + + useEffect(() => { + startPolling(1000); + return () => stopPolling(); + }); + + const gridStyles = + 'grid grid-rows-2 gap-4 grid-cols-2 xl:gap-8 xl:grid-rows-1 xl:grid-cols-4'; + + return ( + +
    + {TXS_STATS_MAP.map((field) => ( + + ))} +
    +
    + ); +}; diff --git a/apps/explorer/src/app/config/env.ts b/apps/explorer/src/app/config/env.ts index c0682bac1..55c18bc4f 100644 --- a/apps/explorer/src/app/config/env.ts +++ b/apps/explorer/src/app/config/env.ts @@ -13,6 +13,7 @@ export const ENV = { dsn: windowOrDefault('NX_EXPLORER_SENTRY_DSN'), dataSources: { chainExplorerUrl: windowOrDefault('NX_CHAIN_EXPLORER_URL'), + blockExplorerUrl: windowOrDefault('NX_BLOCK_EXPLORER'), tendermintUrl: windowOrDefault('NX_TENDERMINT_URL'), tendermintWebsocketUrl: windowOrDefault('NX_TENDERMINT_WEBSOCKET_URL'), }, diff --git a/apps/explorer/src/app/hooks/use-txs-data.ts b/apps/explorer/src/app/hooks/use-txs-data.ts new file mode 100644 index 000000000..17ba1c11c --- /dev/null +++ b/apps/explorer/src/app/hooks/use-txs-data.ts @@ -0,0 +1,91 @@ +import { useCallback, useEffect, useState } from 'react'; +import { useFetch } from '@vegaprotocol/react-helpers'; +import type { + BlockExplorerTransactionResult, + BlockExplorerTransactions, +} from '../routes/types/block-explorer-response'; +import { DATA_SOURCES } from '../config'; + +export interface TxsStateProps { + txsData: BlockExplorerTransactionResult[]; + hasMoreTxs: boolean; + lastCursor: string; +} + +export interface IUseTxsData { + limit?: number; + filters?: string; +} + +interface IGetTxsDataUrl { + limit?: string; + filters?: string; +} + +export const getTxsDataUrl = ({ limit, filters }: IGetTxsDataUrl) => { + const url = new URL(`${DATA_SOURCES.blockExplorerUrl}/transactions`); + + if (limit) { + url.searchParams.append('limit', limit); + } + + if (filters) { + url.searchParams.append('filters', filters); + } + + return url; +}; + +export const useTxsData = ({ limit, filters }: IUseTxsData) => { + const [{ txsData, hasMoreTxs, lastCursor }, setTxsState] = + useState({ + txsData: [], + hasMoreTxs: true, + lastCursor: '', + }); + + const url = getTxsDataUrl({ limit: limit?.toString(), filters }); + + const { + state: { data, error, loading }, + refetch, + } = useFetch(url.href, {}, false); + + useEffect(() => { + if (data?.transactions?.length) { + setTxsState((prev) => ({ + txsData: [...prev.txsData, ...data.transactions], + hasMoreTxs: true, + lastCursor: + data.transactions[data.transactions.length - 1].cursor || '', + })); + } + }, [setTxsState, data?.transactions]); + + const loadTxs = useCallback(() => { + return refetch({ + limit: limit, + before: lastCursor, + }); + }, [lastCursor, limit, refetch]); + + const refreshTxs = useCallback(async () => { + setTxsState((prev) => ({ + ...prev, + lastCursor: '', + hasMoreTxs: true, + txsData: [], + })); + }, [setTxsState]); + + return { + data, + loading, + error, + txsData, + hasMoreTxs, + lastCursor, + refreshTxs, + loadTxs, + }; +}; diff --git a/apps/explorer/src/app/routes/parties/id/index.tsx b/apps/explorer/src/app/routes/parties/id/index.tsx index d133c5e9c..2e1642122 100644 --- a/apps/explorer/src/app/routes/parties/id/index.tsx +++ b/apps/explorer/src/app/routes/parties/id/index.tsx @@ -1,17 +1,27 @@ import { useQuery } from '@apollo/client'; import { gql } from '@apollo/client'; -import { t, useFetch } from '@vegaprotocol/react-helpers'; -import React from 'react'; +import { + t, + useFetch, + addDecimalsFormatNumber, + useScreenDimensions, +} from '@vegaprotocol/react-helpers'; +import React, { useMemo } from 'react'; import { useParams } from 'react-router-dom'; -import { RouteTitle } from '../../../components/route-title'; import { SubHeading } from '../../../components/sub-heading'; -import { SyntaxHighlighter } from '@vegaprotocol/ui-toolkit'; +import { SyntaxHighlighter, AsyncRenderer } from '@vegaprotocol/ui-toolkit'; +import { Panel } from '../../../components/panel'; +import { InfoPanel } from '../../../components/info-panel'; +import { toNonHex } from '../../../components/search/detect-search'; import { DATA_SOURCES } from '../../../config'; -import type { TendermintSearchTransactionResponse } from '../tendermint-transaction-response'; import type { PartyAssetsQuery, PartyAssetsQueryVariables, } from './__generated__/PartyAssetsQuery'; +import type { TendermintSearchTransactionResponse } from '../tendermint-transaction-response'; +import { useTxsData } from '../../../hooks/use-txs-data'; +import { TxsInfiniteList } from '../../../components/txs'; +import { PageHeader } from '../../../components/page-header'; const PARTY_ASSETS_QUERY = gql` query PartyAssetsQuery($partyId: ID!) { @@ -50,13 +60,19 @@ const PARTY_ASSETS_QUERY = gql` const Party = () => { const { party } = useParams<{ party: string }>(); + const partyId = party ? toNonHex(party) : ''; + const { isMobile } = useScreenDimensions(); + const visibleChars = useMemo(() => (isMobile ? 10 : 14), [isMobile]); + const filters = `filters[tx.submitter]=${partyId}`; + const { hasMoreTxs, loadTxs, error, txsData, loading } = useTxsData({ + limit: 10, + filters, + }); const { state: { data: partyData }, } = useFetch( - `${ - DATA_SOURCES.tendermintUrl - }/tx_search?query="tx.submitter='${party?.replace('0x', '')}'"` + `${DATA_SOURCES.tendermintUrl}/tx_search?query="tx.submitter='${partyId}'"` ); const { data } = useQuery( @@ -64,18 +80,104 @@ const Party = () => { { // Don't cache data for this query, party information can move quite quickly fetchPolicy: 'network-only', - variables: { partyId: party?.replace('0x', '') || '' }, + variables: { partyId }, skip: !party, } ); + const header = data?.party?.id ? ( + + ) : ( + +

    No party found for key {party}

    +
    + ); + + const accounts = ( +
    + {data?.party?.accounts?.length ? ( + data.party.accounts.map((account) => { + return ( + +
    +
    +
    + {t('Balance')} ({account.asset.symbol}) +
    +
    + {addDecimalsFormatNumber( + account.balance, + account.asset.decimals + )} +
    +
    +
    +
    + ); + }) + ) : ( + +

    No Data

    +
    + )} +
    + ); + + const staking = ( +
    + {data?.party?.stake?.currentStakeAvailable ? ( + + ) : ( + +

    Nothing staked for {party}

    +
    + )} +
    + ); + return (
    - {t('Party')} +

    + {t('Party')} +

    {data ? ( <> + {header} {t('Asset data')} - + {accounts} + {t('Staking')} + {staking} + {t('JSON')} +
    + +
    + + {t('Transactions')} + + ) : null} diff --git a/apps/explorer/src/app/routes/router-config.tsx b/apps/explorer/src/app/routes/router-config.tsx index b8602ff31..68a5e9183 100644 --- a/apps/explorer/src/app/routes/router-config.tsx +++ b/apps/explorer/src/app/routes/router-config.tsx @@ -13,7 +13,7 @@ import Genesis from './genesis'; import { Block } from './blocks/id'; import { Blocks } from './blocks/home'; import { Tx } from './txs/id'; -import { TxsHome, TxsHomeFallback } from './txs/home'; +import { TxsList } from './txs/home'; import { PendingTxs } from './pending'; import flags from '../config/flags'; import { t } from '@vegaprotocol/react-helpers'; @@ -141,7 +141,7 @@ const routerConfig = [ }, { index: true, - element: flags.txsList ? : , + element: , }, ], }, diff --git a/apps/explorer/src/app/routes/txs/home/index.tsx b/apps/explorer/src/app/routes/txs/home/index.tsx index 17bad9184..24ee1d750 100644 --- a/apps/explorer/src/app/routes/txs/home/index.tsx +++ b/apps/explorer/src/app/routes/txs/home/index.tsx @@ -1,86 +1,19 @@ -import { DATA_SOURCES } from '../../../config'; -import { useCallback, useState, useMemo } from 'react'; -import { t, useFetch } from '@vegaprotocol/react-helpers'; +import { t } from '@vegaprotocol/react-helpers'; import { RouteTitle } from '../../../components/route-title'; import { BlocksRefetch } from '../../../components/blocks'; -import { JumpToBlock } from '../../../components/jump-to-block'; -import { TxsInfiniteList } from '../../../components/txs'; -import { AsyncRenderer } from '@vegaprotocol/ui-toolkit'; -import type { ChainExplorerTxResponse } from '../../types/chain-explorer-response'; -import type { TendermintBlockchainResponse } from '../../blocks/tendermint-blockchain-response'; +import { TxsInfiniteList, TxsStatsInfo } from '../../../components/txs'; +import { useTxsData } from '../../../hooks/use-txs-data'; -interface TxsProps { - latestBlockHeight: string; -} - -interface TxsStateProps { - txsData: ChainExplorerTxResponse[]; - hasMoreTxs: boolean; - nextPage: number; -} - -const Txs = ({ latestBlockHeight }: TxsProps) => { - const [{ txsData, hasMoreTxs, nextPage }, setTxsState] = - useState({ - txsData: [], - hasMoreTxs: true, - nextPage: 1, - }); - - const reusedBodyParams = useMemo( - () => ({ - node_url: DATA_SOURCES.tendermintUrl, - transaction_height: parseInt(latestBlockHeight), - page_size: 30, - }), - [latestBlockHeight] - ); - - const { - state: { error, loading }, - refetch, - } = useFetch( - DATA_SOURCES.chainExplorerUrl, - { - method: 'POST', - body: JSON.stringify(reusedBodyParams), - }, - false - ); - - const loadTxs = useCallback(async () => { - const data = await refetch( - undefined, - JSON.stringify({ - ...reusedBodyParams, - page_number: nextPage, - }) - ); - - if (data) { - setTxsState((prev) => ({ - ...prev, - nextPage: prev.nextPage + 1, - hasMoreTxs: true, - txsData: [...prev.txsData, ...(data as ChainExplorerTxResponse[])], - })); - } - }, [nextPage, refetch, reusedBodyParams]); +const BE_TXS_PER_REQUEST = 20; +export const TxsList = () => { + const { hasMoreTxs, loadTxs, error, txsData, refreshTxs, loading } = + useTxsData({ limit: BE_TXS_PER_REQUEST }); return ( -
    +
    {t('Transactions')} - - refetch( - undefined, - JSON.stringify({ - ...reusedBodyParams, - page_number: 1, - }) - ) - } - /> + + { error={error} className="mb-28" /> -
    ); }; - -export const TxsHome = () => { - const { - state: { data, error, loading }, - } = useFetch( - `${DATA_SOURCES.tendermintUrl}/blockchain` - ); - - return ( - ( - - )} - /> - ); -}; - -export const TxsHomeFallback = () => ( - <> - {t('Transactions')} -
    - The transactions list is currently disabled. Please use the search bar to - discover transaction data -
    - -); diff --git a/apps/explorer/src/app/routes/txs/id/index.tsx b/apps/explorer/src/app/routes/txs/id/index.tsx index 74345bc80..27a3b4910 100644 --- a/apps/explorer/src/app/routes/txs/id/index.tsx +++ b/apps/explorer/src/app/routes/txs/id/index.tsx @@ -1,49 +1,59 @@ import React from 'react'; -import { useParams } from 'react-router-dom'; +import { Link, useParams } from 'react-router-dom'; import { useFetch } from '@vegaprotocol/react-helpers'; -import type { TendermintTransactionResponse } from '../tendermint-transaction-response.d'; -import type { ChainExplorerTxResponse } from '../../types/chain-explorer-response'; import { DATA_SOURCES } from '../../../config'; -import { RouteTitle } from '../../../components/route-title'; import { RenderFetched } from '../../../components/render-fetched'; import { TxContent } from './tx-content'; import { TxDetails } from './tx-details'; -import { t } from '@vegaprotocol/react-helpers'; +import type { BlockExplorerTransaction } from '../../../routes/types/block-explorer-response'; +import { toNonHex } from '../../../components/search/detect-search'; +import { PageHeader } from '../../../components/page-header'; +import { Routes } from '../../../routes/route-names'; +import { IconNames } from '@blueprintjs/icons'; +import { Icon } from '@vegaprotocol/ui-toolkit'; const Tx = () => { const { txHash } = useParams<{ txHash: string }>(); + const hash = txHash ? toNonHex(txHash) : ''; const { - state: { data: tTxData, loading: tTxLoading, error: tTxError }, - } = useFetch( - `${DATA_SOURCES.tendermintUrl}/tx?hash=${txHash}` + state: { data, loading: tTxLoading, error: tTxError }, + } = useFetch( + `${DATA_SOURCES.blockExplorerUrl}/transactions/${toNonHex(hash)}` ); - const { - state: { data: ceTxData, loading: ceTxLoading, error: ceTxError }, - } = useFetch(DATA_SOURCES.chainExplorerUrl, { - method: 'POST', - body: JSON.stringify({ - tx_hash: txHash, - node_url: `${DATA_SOURCES.tendermintUrl}/`, - }), - }); - return (
    - {t('Transaction details')} + + + All Transactions + + + - - + <> + -

    {t('Transaction content')}

    - - + +
    ); diff --git a/apps/explorer/src/app/routes/txs/id/tx-content.tsx b/apps/explorer/src/app/routes/txs/id/tx-content.tsx index 80b98736d..9a5820f7d 100644 --- a/apps/explorer/src/app/routes/txs/id/tx-content.tsx +++ b/apps/explorer/src/app/routes/txs/id/tx-content.tsx @@ -1,21 +1,14 @@ import { t } from '@vegaprotocol/react-helpers'; import { StatusMessage } from '../../../components/status-message'; -import { SyntaxHighlighter } from '@vegaprotocol/ui-toolkit'; -import { - TableWithTbody, - TableCell, - TableHeader, - TableRow, -} from '../../../components/table'; -import { TxOrderType } from '../../../components/txs'; -import type { ChainExplorerTxResponse } from '../../types/chain-explorer-response'; +import { NestedDataList } from '../../../components/nested-data-list'; +import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response'; interface TxContentProps { - data: ChainExplorerTxResponse | undefined; + data: BlockExplorerTransactionResult | undefined; } export const TxContent = ({ data }: TxContentProps) => { - if (!data?.Command) { + if (!data?.command) { return ( {t('Could not retrieve transaction content')} @@ -23,21 +16,5 @@ export const TxContent = ({ data }: TxContentProps) => { ); } - return ( - <> - - - - {t('Type')} - - - - - - - -

    {t('Decoded transaction content')}

    - - - ); + return ; }; diff --git a/apps/explorer/src/app/routes/txs/id/tx-details.spec.tsx b/apps/explorer/src/app/routes/txs/id/tx-details.spec.tsx index 5a5328b14..5f8481ac8 100644 --- a/apps/explorer/src/app/routes/txs/id/tx-details.spec.tsx +++ b/apps/explorer/src/app/routes/txs/id/tx-details.spec.tsx @@ -1,44 +1,30 @@ import { BrowserRouter as Router } from 'react-router-dom'; import { render, screen } from '@testing-library/react'; -import { truncateByChars } from '@vegaprotocol/react-helpers'; -import { TxDetails, txDetailsTruncateLength } from './tx-details'; -import type { Result } from '../tendermint-transaction-response.d'; +import { TxDetails } from './tx-details'; +import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response'; const pubKey = 'test'; const hash = '7416753A30622A9E24A06F0172D6C33A95186B36806D96345C6DC5A23FA3F283'; const height = '52987'; -const tx = - 'ClMIpeWjmKnn77FNEPedA5J9QhJAOGI3YjQzMWNlMmNhNzc4MWMzMTQ1M2IyYjc0MWYwMTJlNzQ1MzBhZDhjMDgzODVkMWQ1YjRiY2VkMTJiNDc1MhKTAQqAATM5NDFlNmExMzQ3MGVhNTlhNGExNmQzMjRiYzlkZjI5YWZkMzYxMDRiZjQ5MzEwZWMxM2ZiOTMxNTM2NGY3ZjU2ZTQyOTJmYTAyZDlhNTBlZDc0OWE0ZjExMzJiNjM2ZTZmMzQ3YzQ2NjdkYmM5OThmYzcyZjYzYzQxMzU4ZTAzEgx2ZWdhL2VkMjU1MTkYAYB9AtI+QDA5MzFhOGZkOGNjOTM1NDU4ZjQ3MGU0MzVhMDU0MTQzODdjZWE2ZjMyOWQ2NDhiZTg5NGZjZDQ0YmQ1MTdhMmI='; -const txData = { +const txData: BlockExplorerTransactionResult = { hash, - height, - tx, + block: height, index: 0, - tx_result: { - code: 0, - data: null, - log: '', - info: '', - events: [], - gas_wanted: '0', - gas_used: '0', - codespace: '', - }, + submitter: pubKey, + code: 0, + cursor: `${height}.0`, + type: 'type', + command: {}, }; -const renderComponent = (txData: Result) => ( +const renderComponent = (txData: BlockExplorerTransactionResult) => ( ); describe('Transaction details', () => { - it('Renders the tx hash', () => { - render(renderComponent(txData)); - expect(screen.getByText(hash)).toBeInTheDocument(); - }); - it('Renders the pubKey', () => { render(renderComponent(txData)); expect(screen.getByText(pubKey)).toBeInTheDocument(); @@ -48,18 +34,4 @@ describe('Transaction details', () => { render(renderComponent(txData)); expect(screen.getByText(height)).toBeInTheDocument(); }); - - it('Renders the truncated tx text', () => { - render(renderComponent(txData)); - expect( - screen.getByText( - truncateByChars(tx, txDetailsTruncateLength, txDetailsTruncateLength) - ) - ).toBeInTheDocument(); - }); - - it('Renders a copy button', () => { - render(renderComponent(txData)); - expect(screen.getByTestId('copy-tx-to-clipboard')).toBeInTheDocument(); - }); }); diff --git a/apps/explorer/src/app/routes/txs/id/tx-details.tsx b/apps/explorer/src/app/routes/txs/id/tx-details.tsx index 8c5c44cc8..3127ee16e 100644 --- a/apps/explorer/src/app/routes/txs/id/tx-details.tsx +++ b/apps/explorer/src/app/routes/txs/id/tx-details.tsx @@ -1,18 +1,12 @@ import { Routes } from '../../route-names'; -import { ButtonLink, CopyWithTooltip } from '@vegaprotocol/ui-toolkit'; -import { - TableWithTbody, - TableCell, - TableHeader, - TableRow, -} from '../../../components/table'; -import { TruncateInline } from '../../../components/truncate/truncate'; import { t } from '@vegaprotocol/react-helpers'; -import { HighlightedLink } from '../../../components/highlighted-link'; -import type { Result } from '../tendermint-transaction-response.d'; +import type { BlockExplorerTransactionResult } from '../../../routes/types/block-explorer-response'; +import React from 'react'; +import { TruncateInline } from '../../../components/truncate/truncate'; +import { Link } from 'react-router-dom'; interface TxDetailsProps { - txData: Result | undefined; + txData: BlockExplorerTransactionResult | undefined; pubKey: string | undefined; className?: string; } @@ -21,54 +15,35 @@ export const txDetailsTruncateLength = 30; export const TxDetails = ({ txData, pubKey, className }: TxDetailsProps) => { if (!txData) { - return <>{t('Awaiting Tendermint transaction details')}; + return <>{t('Awaiting Block Explorer transaction details')}; } + const truncatedSubmitter = ( + + ); + return ( - - - {t('Hash')} - - {txData.hash} - - - - - {t('Submitted by')} - - - - - - - {t('Block')} - - - - - - {t('Encoded txn')} - +

    + {txData.type} by{' '} + - - - - - - - + {truncatedSubmitter} + +

    +

    + Block{' '} + + {txData.block} + + {', '} + Index {txData.index} +

    +
    ); }; diff --git a/apps/explorer/src/app/routes/types/block-explorer-response.d.ts b/apps/explorer/src/app/routes/types/block-explorer-response.d.ts new file mode 100644 index 000000000..7682a644c --- /dev/null +++ b/apps/explorer/src/app/routes/types/block-explorer-response.d.ts @@ -0,0 +1,18 @@ +export interface BlockExplorerTransactionResult { + block: string; + index: number; + hash: string; + submitter: string; + type: string; + code: number; + cursor: string; + command: Record; +} + +export interface BlockExplorerTransactions { + transactions: BlockExplorerTransactionResult[]; +} + +export interface BlockExplorerTransaction { + transaction: BlockExplorerTransactionResult; +} diff --git a/apps/explorer/tailwind.config.js b/apps/explorer/tailwind.config.js index c0da3e3cb..22267c3d4 100644 --- a/apps/explorer/tailwind.config.js +++ b/apps/explorer/tailwind.config.js @@ -1,6 +1,9 @@ const { join } = require('path'); const { createGlobPatternsForDependencies } = require('@nrwl/next/tailwind'); const theme = require('../../libs/tailwindcss-config/src/theme'); +const { + VegaColours, +} = require('../../libs/tailwindcss-config/src/vega-colours'); const vegaCustomClasses = require('../../libs/tailwindcss-config/src/vega-custom-classes'); module.exports = { @@ -11,7 +14,12 @@ module.exports = { ], darkMode: 'class', theme: { - extend: theme, + extend: { + ...theme, + colors: { + vega: VegaColours, + }, + }, }, plugins: [vegaCustomClasses], }; diff --git a/libs/tailwindcss-config/src/index.js b/libs/tailwindcss-config/src/index.js index aea0e9677..da1dd36ba 100644 --- a/libs/tailwindcss-config/src/index.js +++ b/libs/tailwindcss-config/src/index.js @@ -1,9 +1,11 @@ const theme = require('./theme'); const themelite = require('./theme-lite'); const vegaCustomClasses = require('./vega-custom-classes'); +const { VegaColours } = require('./vega-colours'); module.exports = { theme, themelite, plugins: [vegaCustomClasses], + VegaColours, }; diff --git a/libs/tailwindcss-config/src/vega-colours.js b/libs/tailwindcss-config/src/vega-colours.js new file mode 100644 index 000000000..88d987e05 --- /dev/null +++ b/libs/tailwindcss-config/src/vega-colours.js @@ -0,0 +1,38 @@ +const VegaColours = { + yellow: { + DEFAULT: '#D7FB50', + dark: '#9BE106', + }, + pink: { + DEFAULT: '#FF077F', + dark: '#CF0064', + }, + green: { + DEFAULT: '#00F780', + dark: '#00D46E', + }, + blue: { + DEFAULT: '#0075FF', + dark: '#0046CD', + }, + purple: { + DEFAULT: '#8028FF', + dark: '#5D0CD2', + }, + dark: { + 100: '#161616', + 200: '#404040', + 300: '#8B8B8B', + 400: '#C0C0C0', + }, + light: { + 100: '#F0F0F0', + 200: '#D2D2D2', + 300: '#A7A7A7', + 400: '#626262', + }, +}; + +module.exports = { + VegaColours, +};