Merge pull request #1154 from poanetwork/wsa-async-load-with-redux

Change async load to use a redux store.
pull/1164/head
Felipe Renan 6 years ago committed by GitHub
commit 0a2b016282
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 80
      apps/block_scout_web/assets/__tests__/lib/async_listing_load.js
  2. 268
      apps/block_scout_web/assets/js/lib/async_listing_load.js
  3. 499
      apps/block_scout_web/assets/package-lock.json
  4. 1
      apps/block_scout_web/assets/package.json
  5. 2
      apps/block_scout_web/lib/block_scout_web/templates/address_internal_transaction/index.html.eex
  6. 2
      apps/block_scout_web/lib/block_scout_web/templates/address_token_transfer/index.html.eex
  7. 2
      apps/block_scout_web/lib/block_scout_web/templates/tokens/holder/index.html.eex
  8. 2
      apps/block_scout_web/lib/block_scout_web/templates/tokens/transfer/index.html.eex

@ -0,0 +1,80 @@
import { asyncReducer, asyncInitialState } from '../../js/lib/async_listing_load'
describe('ELEMENTS_LOAD', () => {
test('sets only nextPagePath and ignores other keys', () => {
const state = Object.assign({}, asyncInitialState)
const action = { type: 'ELEMENTS_LOAD', nextPagePath: 'set', foo: 1 }
const output = asyncReducer(state, action)
expect(output.foo).not.toEqual(1)
expect(output.nextPagePath).toEqual('set')
})
})
describe('ADD_ITEM_KEY', () => {
test('sets itemKey to what was passed in the action', () => {
const expectedItemKey = 'expected.Key'
const state = Object.assign({}, asyncInitialState)
const action = { type: 'ADD_ITEM_KEY', itemKey: expectedItemKey }
const output = asyncReducer(state, action)
expect(output.itemKey).toEqual(expectedItemKey)
})
})
describe('START_REQUEST', () => {
test('sets loading status to true', () => {
const state = Object.assign({}, asyncInitialState, { loading: false })
const action = { type: 'START_REQUEST' }
const output = asyncReducer(state, action)
expect(output.loading).toEqual(true)
})
})
describe('REQUEST_ERROR', () => {
test('sets requestError to true', () => {
const state = Object.assign({}, asyncInitialState, { requestError: false })
const action = { type: 'REQUEST_ERROR' }
const output = asyncReducer(state, action)
expect(output.requestError).toEqual(true)
})
})
describe('FINISH_REQUEST', () => {
test('sets loading status to false', () => {
const state = Object.assign({}, asyncInitialState, {
loading: true,
loadingFirstPage: true
})
const action = { type: 'FINISH_REQUEST' }
const output = asyncReducer(state, action)
expect(output.loading).toEqual(false)
expect(output.loadingFirstPage).toEqual(false)
})
})
describe('ITEMS_FETCHED', () => {
test('sets the items to what was passed in the action', () => {
const expectedItems = [1, 2, 3]
const state = Object.assign({}, asyncInitialState)
const action = { type: 'ITEMS_FETCHED', items: expectedItems }
const output = asyncReducer(state, action)
expect(output.items).toEqual(expectedItems)
})
})
describe('NAVIGATE_TO_OLDER', () => {
test('sets beyondPageOne to true', () => {
const state = Object.assign({}, asyncInitialState, { beyondPageOne: false })
const action = { type: 'NAVIGATE_TO_OLDER' }
const output = asyncReducer(state, action)
expect(output.beyondPageOne).toEqual(true)
})
})

@ -1,85 +1,223 @@
import $ from 'jquery'
import _ from 'lodash'
import URI from 'urijs'
import humps from 'humps'
import listMorph from '../lib/list_morph'
import reduceReducers from 'reduce-reducers'
import { createStore, connectElements } from '../lib/redux_helpers.js'
/**
* This script is a generic function to load list within a tab async. See token transfers tab at Token's page as example.
* This is a generic lib to add pagination with asynchronous page loading. There are two ways of
* activating this in a page.
*
* If the page has no redux associated with, all you need is a markup with the following pattern:
*
* <div data-async-load data-async-listing="firstLoadPath">
* <div data-loading-message> message </div>
* <div data-empty-response-message style="display: none;"> message </div>
* <div data-error-message style="display: none;"> message </div>
* <div data-items></div>
* <a data-next-page-button style="display: none;"> button text </a>
* <div data-loading-button style="display: none;"> loading text </div>
* </div>
*
* the data-async-load is the attribute responsible for binding the store.
*
* If the page has a redux associated with, you need to connect the reducers instead of creating
* the store using the `createStore`. For instance:
*
* To get it working the markup must follow the pattern below:
* // my_page.js
* const initialState = { ... }
* const reducer = (state, action) => { ... }
* const store = createAsyncLoadStore(reducer, initialState, 'item.Key')
*
* <div data-async-listing="path">
* <div data-loading-message> message </div>
* <div data-empty-response-message style="display: none;"> message </div>
* <div data-error-message style="display: none;"> message </div>
* <div data-items></div>
* <a data-next-page-button style="display: none;"> button text </a>
* <div data-loading-button style="display: none;"> loading text </div>
* </div>
* The createAsyncLoadStore function will return a store with asynchronous loading activated. This
* approach will expect the same markup above, except for data-async-load attribute, which is used
* to create a store and it is not necessary for this case.
*
*/
const $element = $('[data-async-listing]')
function asyncListing (element, path) {
const $mainElement = $(element)
const $items = $mainElement.find('[data-items]')
const $loading = $mainElement.find('[data-loading-message]')
const $nextPageButton = $mainElement.find('[data-next-page-button]')
const $loadingButton = $mainElement.find('[data-loading-button]')
const $errorMessage = $mainElement.find('[data-error-message]')
const $emptyResponseMessage = $mainElement.find('[data-empty-response-message]')
$.getJSON(path, {type: 'JSON'})
.done(response => {
if (!response.items || response.items.length === 0) {
$emptyResponseMessage.show()
$items.empty()
} else {
$items.html(response.items)
export const asyncInitialState = {
/* it will consider any query param in the current URI as paging */
beyondPageOne: (URI(window.location).query() !== ''),
/* an array with every html element of the list being shown */
items: [],
/* the key for diffing the elements in the items array */
itemKey: null,
/* represents whether a request is happening or not */
loading: false,
/* if there was an error fetching items */
requestError: false,
/* if it is loading the first page */
loadingFirstPage: true,
/* link to the next page */
nextPagePath: null
}
export function asyncReducer (state = asyncInitialState, action) {
switch (action.type) {
case 'ELEMENTS_LOAD': {
return Object.assign({}, state, { nextPagePath: action.nextPagePath })
}
case 'ADD_ITEM_KEY': {
return Object.assign({}, state, { itemKey: action.itemKey })
}
case 'START_REQUEST': {
return Object.assign({}, state, {
loading: true,
requestError: false
})
}
case 'REQUEST_ERROR': {
return Object.assign({}, state, { requestError: true })
}
case 'FINISH_REQUEST': {
return Object.assign({}, state, {
loading: false,
loadingFirstPage: false
})
}
case 'ITEMS_FETCHED': {
return Object.assign({}, state, {
requestError: false,
items: action.items,
nextPagePath: action.nextPagePath
})
}
case 'NAVIGATE_TO_OLDER': {
history.replaceState({}, null, state.nextPagePath)
return Object.assign({}, state, { beyondPageOne: true })
}
default:
return state
}
}
export const elements = {
'[data-async-listing]': {
load ($el) {
const nextPagePath = $el.data('async-listing')
return { nextPagePath }
}
},
'[data-async-listing] [data-loading-message]': {
render ($el, state) {
if (state.loadingFirstPage) return $el.show()
$el.hide()
}
},
'[data-async-listing] [data-empty-response-message]': {
render ($el, state) {
if (
!state.requestError &&
(!state.loading || !state.loadingFirstPage) &&
state.items.length === 0
) {
return $el.show()
}
if (response.next_page_path) {
$nextPageButton.attr('href', response.next_page_path)
$nextPageButton.show()
} else {
$nextPageButton.hide()
$el.hide()
}
},
'[data-async-listing] [data-error-message]': {
render ($el, state) {
if (state.requestError) return $el.show()
$el.hide()
}
},
'[data-async-listing] [data-items]': {
render ($el, state, oldState) {
if (state.items === oldState.items) return
if (state.itemKey) {
const container = $el[0]
const newElements = _.map(state.items, (item) => $(item)[0])
listMorph(container, newElements, { key: state.itemKey })
return
}
})
.fail(() => $errorMessage.show())
.always(() => {
$loading.hide()
$loadingButton.hide()
})
}
if ($element.length === 1) {
$element.on('click', '[data-next-page-button]', (event) => {
event.preventDefault()
$el.html(state.items)
}
},
'[data-async-listing] [data-next-page-button]': {
render ($el, state) {
if (state.requestError) return $el.hide()
if (!state.nextPagePath) return $el.hide()
if (state.loading) return $el.hide()
const $button = $(event.target)
const path = $button.attr('href')
const $loadingButton = $element.find('[data-loading-button]')
$el.show()
$el.attr('href', state.nextPagePath)
}
},
'[data-async-listing] [data-loading-button]': {
render ($el, state) {
if (!state.loadingFirstPage && state.loading) return $el.show()
// change url to the next page link before loading the next page
history.pushState({}, null, path)
$button.hide()
$loadingButton.show()
$el.hide()
}
}
}
asyncListing($element, path)
})
/**
* Create a store combining the given reducer and initial state with the async reducer.
*
* reducer: The reducer that will be merged with the asyncReducer to add async
* loading capabilities to a page. Any state changes in the reducer passed will be
* applied AFTER the asyncReducer.
*
* initialState: The initial state to be merged with the async state. Any state
* values passed here will overwrite the values on asyncInitialState.
*
* itemKey: it will be added to the state as the key for diffing the elements and
* adding or removing with the correct animation. Check list_morph.js for more informantion.
*/
export function createAsyncLoadStore (reducer, initialState, itemKey) {
const state = _.merge(asyncInitialState, initialState)
const store = createStore(reduceReducers(asyncReducer, reducer, state))
$element.on('click', '[data-error-message]', (event) => {
event.preventDefault()
if (typeof itemKey !== 'undefined') {
store.dispatch({
type: 'ADD_ITEM_KEY',
itemKey
})
}
// event.target had a weird behavior here
// it hid the <a> tag but left the red div showing
const $link = $element.find('[data-error-message]')
const $loading = $element.find('[data-loading-message]')
const path = $element.data('async-listing')
connectElements({store, elements})
firstPageLoad(store)
return store
}
$link.hide()
$loading.show()
function firstPageLoad (store) {
const $element = $('[data-async-listing]')
function loadItems () {
const path = store.getState().nextPagePath
store.dispatch({type: 'START_REQUEST'})
$.getJSON(path, {type: 'JSON'})
.done(response => store.dispatch(Object.assign({type: 'ITEMS_FETCHED'}, humps.camelizeKeys(response))))
.fail(() => store.dispatch({type: 'REQUEST_ERROR'}))
.always(() => store.dispatch({type: 'FINISH_REQUEST'}))
}
loadItems()
asyncListing($element, path)
$element.on('click', '[data-error-message]', (event) => {
event.preventDefault()
loadItems()
})
// force browser to reload when the user goes back a page
$(window).on('popstate', () => location.reload())
$element.on('click', '[data-next-page-button]', (event) => {
event.preventDefault()
loadItems()
store.dispatch({type: 'NAVIGATE_TO_OLDER'})
})
}
asyncListing($element, $element.data('async-listing'))
const $element = $('[data-async-load]')
if ($element.length) {
const store = createStore(asyncReducer)
connectElements({store, elements})
firstPageLoad(store)
}

File diff suppressed because it is too large Load Diff

@ -36,6 +36,7 @@
"phoenix": "file:../../../deps/phoenix",
"phoenix_html": "file:../../../deps/phoenix_html",
"popper.js": "^1.14.3",
"reduce-reducers": "^0.4.3",
"redux": "^4.0.0",
"urijs": "^1.19.1"
},

@ -7,7 +7,7 @@
<%= render BlockScoutWeb.AddressView, "_tabs.html", assigns %>
</div>
<div class="card-body" data-async-listing="<%= @current_path %>">
<div class="card-body" data-async-load data-async-listing="<%= @current_path %>">
<div data-selector="channel-batching-message" style="display: none;">
<div data-selector="reload-button" class="alert alert-info">
<a href="#" class="alert-link"><span data-selector="channel-batching-count"></span> <%= gettext "More internal transactions have come in" %></a>

@ -7,7 +7,7 @@
<%= render BlockScoutWeb.AddressView, "_tabs.html", assigns %>
</div>
<div data-async-listing="<%= @current_path %>" class="card-body">
<div data-async-load data-async-listing="<%= @current_path %>" class="card-body">
<h2 class="card-title">
<span class="text-muted"><%= gettext "Tokens" %></span> / <%= token_name(@token) %>
</h2>

@ -16,7 +16,7 @@
</div>
<!-- Token Holders -->
<div class="card-body" data-async-listing="<%= @current_path %>">
<div class="card-body" data-async-load data-async-listing="<%= @current_path %>">
<h2 class="card-title"><%= gettext "Token Holders" %></h2>
<button data-error-message class="alert alert-danger col-12 text-left" style="display: none;">

@ -15,7 +15,7 @@
<%= render OverviewView, "_tabs.html", assigns %>
</div>
<div class="card-body" data-async-listing="<%= @current_path %>">
<div class="card-body" data-async-load data-async-listing="<%= @current_path %>">
<h2 class="card-title"><%= gettext "Token Transfers" %></h2>
<button data-error-message class="alert alert-danger col-12 text-left" style="display: none;">
<span href="#" class="alert-link"><%= gettext("Something went wrong, click to reload.") %></span>

Loading…
Cancel
Save