@orange-marmalade/paginate-this
v4.0.0-beta-19
Published
Display, paginate, sort, filter, and update items from the server. paginate-this is a complete list management library for react applications.
Downloads
65
Maintainers
Readme
paginate-this
This is a complete list management library for React. You can manage as many lists as you want simultaneously.
Here are a few of the things you can do to a list with paginate-this
:
- Paginate
- Sort
- Filter
- Update individual items
- Remove items
- Share state across different components
We provide a set of premade components including both simple and robust pagination controls, sort links, and data tables. We also make it ridiculously easy to write your own components and configure and extend default behavior by composing actions.
Demo
http://daffy-teeth.surge.sh/
Extended Documentation
Installation
npm i --save @orange-marmalade/paginate-this
Dependencies
The current version of this package includes the following peer dependencies:
"peerDependencies": {
"immutable": "^3.7.6",
"react": "^0.14.8 || ^15.1.0"
},
Additionally, if you are using the premade paginate-this
components, you'll want to include the font-awesome
stylesheet
as described later in this document.
Example Usage
A minimal example:
import React from 'react'
import { initializeStore, paginate, Paginator, DataTable } from '@orange-marmalade/paginate-this'
import api from 'ROOT/api'
function fetchRecipes(pageInfo) {
return () => api.recipes.index(pageInfo.query)
}
initializeStore()
paginate({ listId: 'recipeGrid', fetch: fetchRecipes })
const headers = {
return [{
field: 'name',
text: 'Name'
}, {
field: 'created_at',
text: 'Created At'
}, {
field: 'boil_time',
sortable: false,
text: 'Boil Time'
}]
}
export default function Recipes() {
return (
<section>
<Paginator listId="recipeGrid" />
<DataTable listId="recipeGrid" headers={headers} />
</section>
)
}
Read on for details, including how to make sure that the example above will work for your API settings.
Detailed Usage
paginate-this
is intended to be flexible so that it can be used in many ways without much fuss. We provide premade components,
but our library is broken down into small, exposed pieces that allow you to easily override default settings, abstract core functionality, and create your own components.
We also allow easy integration with redux.
At a high level, setting up paginate-this
involves the following steps:
- Tell us about your API Settings
- Tell us about the list you're managing
- Render components, whether premade or custom
API Settings
The URL's we choose to fetch data from the server can look quite different from each other. Your API might use URL's that look like this:
https://www.example.com/v1/users?page=6&page_size=15&sort=name&direction=asc
Or, they might look like this:
https://www.example.com/v1/users?page=6&limit=25&sort=name&reverse=true
Fortunately, paginate-this
does not discriminate. You can tell us about your API conventions, and we will follow them:
import { configurePageParams } from '@orange-marmalade/paginate-this'
configurePageParams({
perPage: 'results_per_page',
sortOrder: 'sort_reverse',
sortReverse: true // Means that a boolean will be used to indicate sort direction.
})
For more details on this, see the configuration section.
Standalone Setup (no redux)
When paginate-this
was violet-paginator
, it was built to be used with redux.
Since then, it's been refactored to work just fine without it. We do this by maintaining or own data store and one-way-data-flow
with a design that's modeled after redux
. We still think that redux
will help you get the most out of paginate-this
, but
a major goal of this rewrite was to reduce depenendencies, and decoupling from redux
was a major step in that direction.
Declaring a list
Tell us about your list. It needs a unique identifier, and a function that fetches data from the server:
import { paginate } from '@orange-marmalade/paginate-this'
import api from 'ROOT/api'
function fetchRecipes(pageInfo) {
return () => api.recipes.index(pageInfo.query)
}
paginate({ listId: 'recipeGrid', fetch: fetchRecipes })
By using the paginate
function, you are telling paginate-this
to manage the list in its internal data store.
The paginate
function also allows many additional configuration options, which you can read about here.
Initializing the store
Before you can render components or fire off some actions, call initializeStore()
so that paginate-this
can prepare
its data store:
import { initializeStore } from '@orange-marmalade/paginate-this'
initializeStore()
Optional debugging
Calling debug()
before initializing the store signals paginate-this
to print useful info to the console.
import { initializeStore, debug } from '@orange-marmalade/paginate-this'
debug(process.env.NODE_ENV === 'development')
initializeStore()
Redux setup
Integrating paginate-this
with redux
is easy, and the setup is almost identical to the standalone setup.
Creating a reducer
This is similar to declaring a list in the standalone setup, with one difference: instead of the paginate
function,
you'll use createPaginator
, which will return a reducer for you to add to your store.
import { createPaginator } from '@orange-marmalade/paginate-this'
import { combineReducers } from 'redux'
import users from './users/reducer'
import { fetch } from './recipes/actions'
export default combineReducers({
users,
recipes: createPaginator({
listId: 'recipes',
fetch
})
})
The createPaginator
function allows the same additional configuration options as paginate
, which you can read about here.
Async middleware
If you already use redux-thunk, you're good to go. If for any reason you don't want the additional
dependency, we provide a virtually identical piece of middleware called jelly
, which you'll need to add with the applyMiddleware
function
from redux
:
import { createStore, applyMiddleware } from 'redux'
import { middleware } from '@orange-marmalade/paginate-this'
import reducers from './reducers'
const store = createStore(reducers, applyMiddleware(middleware.jelly))
Injecting the redux store
Last step. In order to let redux
communicate with paginate
this, we need three essential pieces of the redux
store, which you provide with
the initializeStore
function. All together now (with a little bell on it):
import { compose, createStore, applyMiddleware } from 'redux'
import { initializeStore, configurePageParams, middleware } from '@orange-marmalade/paginate-this'
import reducers from './reducers'
const devtools = window.devToolsExtension ? window.devToolsExtension() : f => f
const store = createStore(
reducers,
compose(applyMiddleware(middleware.jelly), devtools)
)
configurePageParams({
perPage: 'results_per_page',
sortOrder: 'sort_reverse',
sortReverse: true
})
initializeStore(store)
Optional logging middleware
We expose another piece of middleware called spill
which prints useful info to the console. This is the same middleware that runs in standalone
mode when using debug(true)
.
import { createStore, applyMiddleware } from 'redux'
import { initializeStore, middleware } from '@orange-marmalade/paginate-this'
import reducers from './reducers'
const store = createStore(
reducers,
applyMiddleware(middleware.jelly, middleware.spill)
)
initializeStore(store)
This prints the action and resulting state to the console similar to redux-devtools, which we recommend
using instead of spill
if you're setting up paginate-this
in redux
mode.
Rendering components
The component API is the same whether using redux
or not. All components require a listId
property
in order to find your list within the store. Some require additional props. Examples:
import React from 'react'
import { Paginator, DataTable } from '@orange-marmalade/paginate-this'
const headers = {
return [{
field: 'name',
text: 'Name'
}, {
field: 'created_at',
text: 'Created At'
}, {
field: 'boil_time',
sortable: false,
text: 'Boil Time'
}]
}
export default function Recipes() {
return (
<section>
<Paginator listId="recipeGrid" />
<DataTable listId="recipeGrid" headers={headers} />
</section>
)
}
Configuration
paginate-this
aims to make client-server communication painless. For us, usability means:
- We know how to read data from your server.
- We will provide you with the correctly formatted parameters that you need to send to your server.
Because different backends will use different property names for pagination and sorting, we make this fully configurable. Example config:
import { configurePageParams } from '@orange-marmalade/paginate-this'
configurePageParams({
perPage: 'results_per_page',
sortOrder: 'sort_reverse',
sortReverse: true // Means that a boolean will be used to indicate sort direction.
})
An example URL with this configuration:
https://brewed-dev.herokuapp.com/v1/recipes?page=6&results_per_page=15&sort=name&sort_reverse=true
Another example config:
configurePageParams({
perPage: 'page_size',
sortOrder: 'direction'
})
And a corresponding example URL:
https://www.example.com/v1/users?page=6&page_size=15&sort=name&direction=asc
The complete list of configuration options and their defaults can be found in the pageInfoTranslator:
Property Name | Default Value | Description
---|:---:|:---
page | 'page'
| The page number being requested
perPage | 'pageSize'
| The page size being requested
sort | 'sort'
| The field to sort by when requesting a page
sortOrder | 'sortOrder'
| The sort direction for the requested page
sortReverse | false
| Use a boolean to indicate sort direction
totalCount | 'total_count'
| The name of the property on the server response that indicates total record count
results | 'results'
| The name of the property on the server that contains the page of results
id | 'id'
| The name of the property on the record to be used as the unique identifer
Styling
No default styling is provided for these components. The markup is simple and very few css classes are used, so styling in your favorite framework should be simple. In some cases, we do make use of classes from Font Awesome. You can include this stylesheet in any way you wish, or you can choose to style the classes yourself.
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/4.7.0/css/font-awesome.min.css">
Customizing the DataTable component
By default, the DataTable
will simply display the raw values from the data that correspond to the headers that
are specified. However, each header can be supplied with a format
function, which can return a simple value, some markup,
or a full-fledged react component. Example:
const activeColumn = recipe => {
const icon = recipe.active ? 'check' : 'ban'
return (
<FontAwesome name={icon} />
)
}
const headers = [{
field: 'active',
sortable: false,
text: 'Active',
format: activeColumn
}, {
...
}]
Advanced usage
What you've read up until now gives you everything you need to sort, filter, and paginate records using our premade components. The following
sections demonstrate more advanced usage of paginate-this
, focusing on two main areas:
- Inline updating -
paginate-this
allows you to add controls to your rows that will send requests to update the corresponding item on the server, and then update the item in place in the data table. - Custom components - We provide decorators that allow you to build custom components, in case you don't like our datatables & controls.
Inline updating is implemented through a technique that we call composing actions.
Composing Actions
paginate-this
uses the flux pattern of dispatching actions to the store and then updating the views that depend on the state.
To give you complete control of the pagination state, we expose these actions in two different ways. In standalone mode, import
the actionFactory function to generate a set of actions specific to your list. redux
users will want to use the composables function.
The most common use case for composing actions would be updating an item within the list, which we'll demonstrate by example.
actionFactory
As an example, consider a datatable where one column has a checkbox that's supposed to mark an item as active or inactive.
Assuming that you have a listId
of 'recipeGrid'
, you could write an action creator like this to update the record on the server
and then toggle the active state of the corresponding recipe within the list:
import api from 'ROOT/api'
import { actionFactory } from '@orange-marmalade/paginate-this'
const pageActions = actionFactory('recipeGrid')
export function toggleActive(recipe) {
const data = {
active: !recipe.get('active')
}
return pageActions.updateAsync(
recipe.get('id'),
data,
api.recipes.update(data)
)
}
At this point you could attach your composed action to a custom checkbox in the datatable like so:
import React, { PropTypes } from 'react'
import { DataTable, Paginator } from '@orange-marmalade/paginate-this'
import * as actions from './actions'
export function Recipes({ toggle }) {
const headers = {
return [{
field: 'name',
text: 'Name'
}, {
field: 'active',
sortable: false,
text: 'Active?',
format: recipe => (
<input
type="checkbox"
checked={!!recipe.active}
onChange={() => toggle(recipe)}
/>
)
}]
}
return (
<section>
<Paginator listId="recipeGrid" />
<DataTable listId="recipeGrid" headers={headers} />
</section>
)
}
export default props => <Recipes {...props} toggle={actions.toggleActive} />
composables
If you're using redux
, composing your actions doesn't really change much. The main difference is that with redux
,
actions are usually injected into components through the connect
function, which binds them to the dispatch. The actions
you get from actionFactory
are already bound to the dispatch, so we also provide their unbound counterparts via the composables
function.
import api from 'ROOT/api'
import { composables } from '@orange-marmalade/paginate-this'
const pageActions = composables({ listId: 'recipeGrid' })
export function toggleActive(recipe) {
const data = {
active: !recipe.get('active')
}
return pageActions.updateAsync(
recipe.get('id'),
data,
api.recipes.update(data)
)
}
Now you can bring this action creator into your connected component using connect
and mapDispatchToProps
:
import React, { PropTypes } from 'react'
import { DataTable, Paginator } from '@orange-marmalade/paginate-this'
import * as actions from './actions'
export function Recipes({ toggle }) {
...
}
export default connect(
undefined,
{ toggle: toggleActive }
)(Recipes)
Building Custom Components
We understand that every product team could potentially want something different, and our premade components sometimes just won't fit that mold. We want to make it painless to write your own components, so to accomplish that, we made sure that it was every bit as painless to write ours. The best way to see how to build a custom component is to look at some of the simpler premade components. For example, here's a link that retrieves the next page of records:
import React, { PropTypes } from 'react'
import { flip } from './decorators'
export function Next({ pageActions, hasNextPage }) {
return (
<button type="button" disabled={!hasNextPage} onClick={pageActions.next}>
<i className="fa fa-chevron-right" />
</button>
)
}
Next.propTypes = {
pageActions: PropTypes.shape({
next: PropTypes.func.isRequired
}).isRequired,
hasNextPage: PropTypes.bool
}
export default flip(Next)
And here's a button that can sort our list in either direction by a given field name:
import React, { PropTypes } from 'react'
import { sort as decorate } from './decorators'
export function ColumnHeader({ pageActions, field, text, sort, sortReverse, sortable=true }) {
if (!sortable) {
return <span>{text}</span>
}
const sortByField = () =>
pageActions.sort(field, !sortReverse)
const arrow = sort === field && (
sortReverse ? 'sort-desc' : 'sort-asc'
)
const icon = arrow || 'sort'
return (
<button onClick={sortByField}>
{text}
<i className={`fa fa-${icon}`} />
</button>
)
}
ColumnHeader.propTypes = {
sort: PropTypes.string,
sortReverse: PropTypes.bool,
pageActions: PropTypes.object,
field: PropTypes.string.isRequired,
text: PropTypes.string.isRequired,
sortable: PropTypes.bool
}
export default decorate(ColumnHeader)
These components are simple and small enough to be written as pure functions rather than classes, and you should be able
to accomplish the same. As you might have guessed, we expose the flip
and sorter
functions that are being called as the default export
for our components, and those functions decorate your components with props that allow you to read and update the pagination state.
The only prop that callers need to supply to these components is
a listId
, and one or two additional props in some cases. Simply import our decorators into your custom component:
import { decorators } from '@orange-marmalade/paginate-this'
and you are ready to roll your own:
// Supports 'previous' and 'next' links
export defaut decorators.flip(MyFlipperComponent)
// Supports full pagination controls
export default decorators.paginate(MyPaginationComponent)
// Supports grids/datatables
export default decorators.tabulate(MyDataGridComponent)
// Supprts controls for changing the page size
export default decorators.stretch(MyPageSizeDropdown)
// Supports a control for sorting the list by the field name
export default decorators.sort(MySortLink)
// The kitchen sink! Injects properties from all decorators
export default decorators.paginateThis(MyPaginatedGridComponent)
For more on using decorators or creating your own, check the docs on decorators.
Contributing
If you wish to contribute, please create a fork and submit a pull request, which will be reviewed as soon as humanly possible. A couple of key points:
- Don't check in any changes to the
lib
folder. When we are ready to publish a new version, we will do a build and commit thelib
changes and the new version number. - Add tests for your feature, and make sure all existing tests still pass and that the code passes lint (described further below).
Testing
This package is tested with mocha. The project uses CI through Travis which includes running tests, linting, and code coverage. Please make sure to write tests for any new pull requests. Code coverage will block the PR if your code is not sufficiently covered.
TBD
- Need to move & rework existing documentation