Merge pull request #1154 from poanetwork/wsa-async-load-with-redux
Change async load to use a redux store.pull/1164/head
commit
0a2b016282
@ -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
Loading…
Reference in new issue