diff --git a/apps/block_scout_web/assets/__tests__/pages/block.js b/apps/block_scout_web/assets/__tests__/pages/block.js index 0f50e9cf7f..f6f84976fb 100644 --- a/apps/block_scout_web/assets/__tests__/pages/block.js +++ b/apps/block_scout_web/assets/__tests__/pages/block.js @@ -1,13 +1,111 @@ import { reducer, initialState } from '../../js/pages/block' -test('RECEIVED_NEW_BLOCK', () => { + +test('CHANNEL_DISCONNECTED', () => { + const state = initialState const action = { - type: 'RECEIVED_NEW_BLOCK', - msg: { - blockHtml: "test" - } + type: 'CHANNEL_DISCONNECTED' } - const output = reducer(initialState, action) + const output = reducer(state, action) + + expect(output.channelDisconnected).toBe(true) +}) + +describe('RECEIVED_NEW_BLOCK', () => { + test('receives new block', () => { + const action = { + type: 'RECEIVED_NEW_BLOCK', + msg: { + blockHtml: 'test', + blockNumber: 1 + } + } + const output = reducer(initialState, action) + + expect(output.newBlock).toBe('test') + expect(output.blockNumbers).toEqual([1]) + }) + test('on page 2+', () => { + const state = Object.assign({}, initialState, { + beyondPageOne: true + }) + const action = { + type: 'RECEIVED_NEW_BLOCK', + msgs: [{ + blockHtml: 'test' + }] + } + const output = reducer(state, action) + + expect(output.newBlock).toBe(null) + expect(output.blockNumbers).toEqual([]) + expect(output.skippedBlockNumbers).toEqual([]) + }) + test('inserts place holders if block received out of order', () => { + const state = Object.assign({}, initialState, { + blockNumbers: [2] + }) + const action = { + type: 'RECEIVED_NEW_BLOCK', + msg: { + blockHtml: 'test5', + blockNumber: 5 + } + } + const output = reducer(state, action) + + expect(output.newBlock).toBe('test5') + expect(output.blockNumbers).toEqual([5, 4, 3, 2]) + expect(output.skippedBlockNumbers).toEqual([3, 4]) + }) + test('replaces skipped block', () => { + const state = Object.assign({}, initialState, { + blockNumbers: [5, 4, 3, 2, 1], + skippedBlockNumbers: [1, 3, 4] + }) + const action = { + type: 'RECEIVED_NEW_BLOCK', + msg: { + blockHtml: 'test3', + blockNumber: 3 + } + } + const output = reducer(state, action) + + expect(output.newBlock).toBe('test3') + expect(output.blockNumbers).toEqual([5, 4, 3, 2, 1]) + expect(output.skippedBlockNumbers).toEqual([1, 4]) + }) + test('replaces duplicated block', () => { + const state = Object.assign({}, initialState, { + blockNumbers: [5, 4] + }) + const action = { + type: 'RECEIVED_NEW_BLOCK', + msg: { + blockHtml: 'test5', + blockNumber: 5 + } + } + const output = reducer(state, action) + + expect(output.newBlock).toBe('test5') + expect(output.blockNumbers).toEqual([5, 4]) + }) + test('skips if new block height is lower than lowest on page', () => { + const state = Object.assign({}, initialState, { + blockNumbers: [5, 4, 3, 2] + }) + const action = { + type: 'RECEIVED_NEW_BLOCK', + msg: { + blockHtml: 'test1', + blockNumber: 1 + } + } + const output = reducer(state, action) - expect(output.newBlock).toBe("test") + expect(output.newBlock).toBe(null) + expect(output.blockNumbers).toEqual([5, 4, 3, 2]) + }) }) diff --git a/apps/block_scout_web/assets/__tests__/pages/chain.js b/apps/block_scout_web/assets/__tests__/pages/chain.js index dca639315b..d3bc264f05 100644 --- a/apps/block_scout_web/assets/__tests__/pages/chain.js +++ b/apps/block_scout_web/assets/__tests__/pages/chain.js @@ -15,22 +15,140 @@ test('RECEIVED_NEW_ADDRESS_COUNT', () => { expect(output.addressCount).toEqual('1,000,000') }) -test('RECEIVED_NEW_BLOCK', () => { - const state = Object.assign({}, initialState, { - averageBlockTime: '6 seconds', - newBlock: 'last new block' +describe('RECEIVED_NEW_BLOCK', () => { + test('receives new block', () => { + const state = Object.assign({}, initialState, { + averageBlockTime: '6 seconds', + blockNumbers: [1], + newBlock: 'last new block' + }) + const action = { + type: 'RECEIVED_NEW_BLOCK', + msg: { + averageBlockTime: '5 seconds', + blockNumber: 2, + chainBlockHtml: 'new block' + } + } + const output = reducer(state, action) + + expect(output.averageBlockTime).toEqual('5 seconds') + expect(output.newBlock).toEqual('new block') + expect(output.blockNumbers).toEqual([2, 1]) }) - const action = { - type: 'RECEIVED_NEW_BLOCK', - msg: { + + test('inserts place holders if block received out of order', () => { + const state = Object.assign({}, initialState, { + blockNumbers: [2] + }) + const action = { + type: 'RECEIVED_NEW_BLOCK', + msg: { + averageBlockTime: '5 seconds', + chainBlockHtml: 'test5', + blockNumber: 5 + } + } + const output = reducer(state, action) + + expect(output.averageBlockTime).toEqual('5 seconds') + expect(output.newBlock).toBe('test5') + expect(output.blockNumbers).toEqual([5, 4, 3, 2]) + expect(output.skippedBlockNumbers).toEqual([3, 4]) + }) + test('replaces skipped block', () => { + const state = Object.assign({}, initialState, { + blockNumbers: [4, 3, 2, 1], + skippedBlockNumbers: [1, 2, 3] + }) + const action = { + type: 'RECEIVED_NEW_BLOCK', + msg: { + averageBlockTime: '5 seconds', + chainBlockHtml: 'test2', + blockNumber: 2 + } + } + const output = reducer(state, action) + + expect(output.averageBlockTime).toEqual('5 seconds') + expect(output.newBlock).toBe('test2') + expect(output.blockNumbers).toEqual([4, 3, 2, 1]) + expect(output.skippedBlockNumbers).toEqual([1, 3]) + }) + test('replaces duplicated block', () => { + const state = Object.assign({}, initialState, { + blockNumbers: [5, 4] + }) + const action = { + type: 'RECEIVED_NEW_BLOCK', + msg: { + averageBlockTime: '5 seconds', + chainBlockHtml: 'test5', + blockNumber: 5 + } + } + const output = reducer(state, action) + + expect(output.averageBlockTime).toEqual('5 seconds') + expect(output.newBlock).toBe('test5') + expect(output.blockNumbers).toEqual([5, 4]) + }) + test('skips if new block height is lower than lowest on page', () => { + const state = Object.assign({}, initialState, { averageBlockTime: '5 seconds', - chainBlockHtml: 'new block' + blockNumbers: [5, 4, 3, 2] + }) + const action = { + type: 'RECEIVED_NEW_BLOCK', + msg: { + averageBlockTime: '9 seconds', + chainBlockHtml: 'test1', + blockNumber: 1 + } } - } - const output = reducer(state, action) + const output = reducer(state, action) + + expect(output.averageBlockTime).toEqual('5 seconds') + expect(output.newBlock).toBe(null) + expect(output.blockNumbers).toEqual([5, 4, 3, 2]) + }) + test('only tracks 4 blocks based on page display limit', () => { + const state = Object.assign({}, initialState, { + blockNumbers: [5, 4, 3, 2], + skippedBlockNumbers: [2, 3, 4] + }) + const action = { + type: 'RECEIVED_NEW_BLOCK', + msg: { + chainBlockHtml: 'test6', + blockNumber: 6 + } + } + const output = reducer(state, action) - expect(output.averageBlockTime).toEqual('5 seconds') - expect(output.newBlock).toEqual('new block') + expect(output.newBlock).toBe('test6') + expect(output.blockNumbers).toEqual([6, 5, 4, 3]) + expect(output.skippedBlockNumbers).toEqual([3, 4]) + }) + test('skipped blocks list replaced when another block comes in with +3 blockheight', () => { + const state = Object.assign({}, initialState, { + blockNumbers: [5, 4, 3, 2], + skippedBlockNumbers: [2, 3, 4] + }) + const action = { + type: 'RECEIVED_NEW_BLOCK', + msg: { + chainBlockHtml: 'test10', + blockNumber: 10 + } + } + const output = reducer(state, action) + + expect(output.newBlock).toBe('test10') + expect(output.blockNumbers).toEqual([10, 9, 8, 7]) + expect(output.skippedBlockNumbers).toEqual([7, 8, 9]) + }) }) test('RECEIVED_NEW_EXCHANGE_RATE', () => { diff --git a/apps/block_scout_web/assets/__tests__/pages/transaction.js b/apps/block_scout_web/assets/__tests__/pages/transaction.js index b4ba0f62b7..ea4eb3595f 100644 --- a/apps/block_scout_web/assets/__tests__/pages/transaction.js +++ b/apps/block_scout_web/assets/__tests__/pages/transaction.js @@ -1,5 +1,16 @@ import { reducer, initialState } from '../../js/pages/transaction' +test('CHANNEL_DISCONNECTED', () => { + const state = initialState + const action = { + type: 'CHANNEL_DISCONNECTED' + } + const output = reducer(state, action) + + expect(output.channelDisconnected).toBe(true) + expect(output.batchCountAccumulator).toBe(0) +}) + test('RECEIVED_NEW_BLOCK', () => { const state = { ...initialState, blockNumber: 1 } const action = { diff --git a/apps/block_scout_web/assets/css/components/_animations.scss b/apps/block_scout_web/assets/css/components/_animations.scss index 8318de83dc..b8fb3d1db4 100644 --- a/apps/block_scout_web/assets/css/components/_animations.scss +++ b/apps/block_scout_web/assets/css/components/_animations.scss @@ -8,20 +8,11 @@ 0% { flex-basis: 0%; width: 0%; - opacity: 0; - } - 25% { - opacity: 0; - transform: translateY(10px) scale(0.97); } 50% { flex-basis: 25%; width: 25%; } - 100% { - opacity: 1; - transform: translateY(0) scale(1); - } } @keyframes fade-up { @@ -34,25 +25,7 @@ transform: translateY(10px) scale(0.97); } 50% { - height: 98px; - } - 100% { - opacity: 1; - transform: translateY(0) scale(1); - } -} - -@keyframes fade-up--mobile { - 0% { - height: 0; - opacity: 0; - } - 25% { - opacity: 0; - transform: translateY(10px) scale(0.97); - } - 50% { - height: 202px; + height: 100px; } 100% { opacity: 1; @@ -67,7 +40,7 @@ } 100% { opacity: 0; - transform: scale(0.5); + transform: scale(0.75); } } @@ -80,22 +53,17 @@ max-height: 98px; animation: fade-up-blocks-chain 0.6s cubic-bezier(0.455, 0.03, 0.515, 0.955); - @media (max-width: 767px) { - animation: fade-up--mobile 0.6s cubic-bezier(0.455, 0.03, 0.515, 0.955); + @include media-breakpoint-down(md) { + animation: none; } } .fade-up { will-change: transform, opacity, height; - max-height: 98px; animation: fade-up 0.6s cubic-bezier(0.455, 0.03, 0.515, 0.955); - - @media (max-width: 767px) { - max-height: 202px; - animation: fade-up--mobile 0.6s cubic-bezier(0.455, 0.03, 0.515, 0.955); - } } .shrink-out { - animation: shrink-out 0.45s cubic-bezier(0.55, 0.055, 0.675, 0.19) forwards; + transform-origin: bottom center; + animation: shrink-out 0.3s cubic-bezier(0.55, 0.055, 0.675, 0.19) forwards; } diff --git a/apps/block_scout_web/assets/css/components/_card.scss b/apps/block_scout_web/assets/css/components/_card.scss index 13f7cf925c..d34f461fa7 100644 --- a/apps/block_scout_web/assets/css/components/_card.scss +++ b/apps/block_scout_web/assets/css/components/_card.scss @@ -34,5 +34,5 @@ .card-chain-blocks { height: auto; - @media (max-width: 767px) { height: 595px; } + @include media-breakpoint-down(md) { height: 595px; } } diff --git a/apps/block_scout_web/assets/css/components/_loading-spinner.scss b/apps/block_scout_web/assets/css/components/_loading-spinner.scss index 9ea746441a..a4b02051e6 100644 --- a/apps/block_scout_web/assets/css/components/_loading-spinner.scss +++ b/apps/block_scout_web/assets/css/components/_loading-spinner.scss @@ -1,3 +1,10 @@ +// ATTRIBUTION +// +// Author: Tobias Ahlin (tobiasahlin) +// +// SpinKit - Simple loading spinners animated with CSS. +// https://github.com/tobiasahlin/SpinKit + .loading-spinner { margin: auto 1rem; width: 40px; @@ -38,7 +45,7 @@ .loading-spinner-small { display: inline-block; position: relative; - top: -0.05em; + top: -0.125em; margin: auto 0.5em auto 0; width: 1em; height: 1em; diff --git a/apps/block_scout_web/assets/js/pages/block.js b/apps/block_scout_web/assets/js/pages/block.js index 17cf0eec5a..1ff04f7225 100644 --- a/apps/block_scout_web/assets/js/pages/block.js +++ b/apps/block_scout_web/assets/js/pages/block.js @@ -1,4 +1,5 @@ import $ from 'jquery' +import _ from 'lodash' import URI from 'urijs' import humps from 'humps' import socket from '../socket' @@ -6,16 +7,20 @@ import { updateAllAges } from '../lib/from_now' import { initRedux, prependWithClingBottom } from '../utils' export const initialState = { + blockNumbers: [], beyondPageOne: null, channelDisconnected: false, - newBlock: null + newBlock: null, + replaceBlock: null, + skippedBlockNumbers: [] } export function reducer (state = initialState, action) { switch (action.type) { case 'PAGE_LOAD': { return Object.assign({}, state, { - beyondPageOne: action.beyondPageOne + beyondPageOne: action.beyondPageOne, + blockNumbers: action.blockNumbers }) } case 'CHANNEL_DISCONNECTED': { @@ -24,11 +29,36 @@ export function reducer (state = initialState, action) { }) } case 'RECEIVED_NEW_BLOCK': { - if (state.channelDisconnected) return state + if (state.channelDisconnected || state.beyondPageOne) return state - return Object.assign({}, state, { - newBlock: action.msg.blockHtml - }) + const blockNumber = parseInt(action.msg.blockNumber) + if (_.includes(state.blockNumbers, blockNumber)) { + return Object.assign({}, state, { + newBlock: action.msg.blockHtml, + replaceBlock: blockNumber, + skippedBlockNumbers: _.without(state.skippedBlockNumbers, blockNumber) + }) + } else if (blockNumber < _.last(state.blockNumbers)) { + return state + } else { + let skippedBlockNumbers = state.skippedBlockNumbers.slice(0) + if (blockNumber > state.blockNumbers[0] + 1) { + for (let i = state.blockNumbers[0] + 1; i < blockNumber; i++) { + skippedBlockNumbers.push(i) + } + } + const newBlockNumbers = _.chain([blockNumber]) + .union(skippedBlockNumbers, state.blockNumbers) + .orderBy([], ['desc']) + .value() + + return Object.assign({}, state, { + blockNumbers: newBlockNumbers, + newBlock: action.msg.blockHtml, + replaceBlock: null, + skippedBlockNumbers + }) + } } default: return state @@ -41,7 +71,8 @@ if ($blockListPage.length) { main (store) { const state = store.dispatch({ type: 'PAGE_LOAD', - beyondPageOne: !!humps.camelizeKeys(URI(window.location).query(true)).blockNumber + beyondPageOne: !!humps.camelizeKeys(URI(window.location).query(true)).blockNumber, + blockNumbers: $('[data-selector="block-number"]').map((index, el) => parseInt(el.innerText)).toArray() }) if (!state.beyondPageOne) { const blocksChannel = socket.channel(`blocks:new_block`, {}) @@ -57,10 +88,44 @@ if ($blockListPage.length) { const $blocksList = $('[data-selector="blocks-list"]') if (state.channelDisconnected) $channelDisconnected.show() - if (oldState.newBlock !== state.newBlock) { - prependWithClingBottom($blocksList, state.newBlock) + if (oldState.newBlock !== state.newBlock || (state.replaceBlock && oldState.replaceBlock !== state.replaceBlock)) { + if (state.replaceBlock && oldState.replaceBlock !== state.replaceBlock) { + const $replaceBlock = $(`[data-block-number="${state.replaceBlock}"]`) + $replaceBlock.addClass('shrink-out') + setTimeout(() => $replaceBlock.replaceWith(state.newBlock), 400) + } else { + if (oldState.skippedBlockNumbers !== state.skippedBlockNumbers) { + const newSkippedBlockNumbers = _.difference(state.skippedBlockNumbers, oldState.skippedBlockNumbers) + _.map(newSkippedBlockNumbers, (skippedBlockNumber) => { + prependWithClingBottom($blocksList, placeHolderBlock(skippedBlockNumber)) + }) + } + prependWithClingBottom($blocksList, state.newBlock) + } updateAllAges() } } }) } + +function placeHolderBlock (blockNumber) { + return ` +