Commit 32cbc888 authored by S Anand's avatar S Anand
Browse files

ENH: FormHandler format, error handling, etc. @tejesh.papineni

parent 957d68f7
......@@ -107,57 +107,66 @@ This activates all `.urlfilter` classes as below:
## $.formhandler
An interactive table component for [FormHandler][formhandler] data.
```html
<div class="formhandler" src="formhandler-url"></div>
<div class="formhandler" data-src="formhandler-url" data-page-size="10"></div>
<script>
$('.target').formhandler()
$('.formhandler').formhandler({
pageSize: 20
})
</script>
```
### $.formhandler attributes
Options can passed via an options dict, and over-ridden using `data-` attributes.
In the above example, `data-page-size="10"` over-rides `pageSize: 20`.
- `data-src`: [FormHandler](https://learn.gramener.com/guide/formhandler/) URL endpoint
- `data-columns="col1, col2, ..."`: comma-separated column names to display (spaces stripped)
- `data-page-size="100"`: number of rows per page
- `data-size-values="10, 20, 50, 100"`: comma-separated number of page size values
- `data-export-formats="csv, xlsx, html"`: comma-separated export formats
[formhandler]: https://learn.gramener.com/guide/formhandler/
### $.formhandler options
Data attribute defaults can be set via the options. For example,
`data-page-size` defaults to `pageSize`, and `data-columns` to `columns`.
The full list of options is below. Simple options can be specified as `data-` attributes as well.
- `src`: [FormHandler][formhandler] URL endpoint
- `columns`: comma-separated column names to display, or a list of objects with these keys:
- `name`: column name
- `name`: column name. `"*"` is a special column placeholder for "all columns"
- `title`: display name of the column. Defaults to the same value as `name`
- `format`: function to format the value. Defaults sensibly based on type
- `sort`: `true` / `false` / options dict with:
- `show`: `true` (default) / `false`
- `operators`: `{'': 'Sort ascending', '-': 'Sort descending'}` (default)
- `filters`: `true` (default) / `false` / options dict with:
- `show`: `true` (default) / `false`
- `operators`: `{'', 'Equals...', '!', 'Does not equal...', ...}`.
- `type`: `text` (default) / `number` / `date`. Determines filters to be used
- `format`: string / function that changes the cell contents.
- functions are applied to the value and the return value is used
- strings specify a numeral.js format if the value is a number (you must include numeral.js)
- strings specify a moment.js format if the value is a date (you must include moment.js)
- `sort`: `true` / `false` / operators dict with:
- `{'': 'Sort ascending', '-': 'Sort descending'}` (default)
- `filters`: `true` (default) / `false` / operators dict with:
- `{'', 'Equals...', '!', 'Does not equal...', ...}`.
The default list of operators is based on the auto-detected type of the column.
- `unique`: TODO: {dict of query parameter and display value} or [list of values] or function?
- `hideable`: `true` (default) / `false`
- `unique`: TODO: {dict of query parameter and display value} or [list of values] or function?
- `table`: Shows the table control. Can be `true` (default) / `false`
- `count`: Shows the number of rows. Can be `true` (default) / `false`
- `page`: Shows the page control. Can be `true` (default) / `false`.
- `pageSize`: page size. Defaults to 100
- `size`: Shows the page size control. Can be `true` (default) / `false`
- `sizeValues`: Allowed page size values. Defaults to `[10, 20, 50, 100, 500, 1000]`
- `export`: Shows the export control. Can be `true` (default) / `false`
- `exportFormats`: {xlsx: 'Excel'}
- `search`: Shows the search control. Can be `true` (default) / `false`
- `filters`: Shows the applied filters control. Can be `true` (default) / `false`
- `loading`: TODO: loading indicator
- `namespace`: ''. URL prefix / namespace, if there are multiple tables on the same page
- `urltarget`: '#'. Allows '#' / ''
- `transform`: an optional function() that modifies data. It accepts a dict that has keys:
- `data`: the FormHandler data
- `meta`: the FormHandler metadata from the `FH-*` HTTP headers
- `args`: the URL query parameters passed to the FormHandler
- `options`: the options applicable to the FormHandler
- returns a dict with modified values of `data` and `meta`
**Advanced**. Each component can have a target which specifies a selector. For
e.g., to render the export button somewhere else, use
`data-export-target=".navbar-export"`. This replaces the `.navbar-export`
contents with the export button. (It searches within the table container for `.navbar-export` first, and if not found, searches everywhere.) Available targets are:
contents with the export button. (It searches within the table container for
`.navbar-export` first, and if not found, searches everywhere.) Available
targets are:
- `tableTarget`
- `countTarget`
- `pageTarget`
- `sizeTarget`
- `exportTarget`
......@@ -168,13 +177,29 @@ contents with the export button. (It searches within the table container for `.n
`data-search-template="<input type='search'>"` will replace the search template
with a simple input. Available template strings are:
- `tableTemplate`
- `countTemplate`
- `pageTemplate`
- `sizeTemplate`
- `exportTemplate`
- `filtersTemplate`
- `searchTemplate`
## $.formhandler examples
Features to be implemented:
- Loading indicator
- Full text search
- URL prefix / namespace, if there are multiple tables on the same page
- URL targets other than '#', e.g. pushState
### $.formhandler events
- `load` is fired on the source when the template is rendered. Attributes:
- `formdata`: the FormHandler data
- `meta`: the FormHandler metadata
- `args`: the URL query parameters passed to the request
- `options`: applied options to the FormHandler
### $.formhandler examples
Add a simple table using the FormHandler at `./data` that shows specific columns
with a page size of 10 rows, and does not show the export filter.
......@@ -191,8 +216,6 @@ with a page size of 10 rows, and does not show the export filter.
</script>
```
TODO: FormHandler on-done hook
## $.template
......
......@@ -12,6 +12,7 @@
"scripts": {
"lint": "eslint index.js src",
"build": "rimraf dist && json2module package.json > src/package.js && rollup -c",
"dev": "rimraf dist && json2module package.json > src/package.js && rollup -c -w",
"pretest": "npm run build && browserify -s tape -r tape -o test/tape.js",
"server": "npm run pretest && npm run lint && node test/server.js",
"test": "npm run lint && tape test/test-*.js | faucet && node test/server.js run | tap-merge | faucet",
......@@ -24,16 +25,20 @@
"eslint": "^4",
"express": "4",
"faucet": "^0.0.1",
"font-awesome": "4",
"html-minifier": "3",
"jquery": "3",
"json2module": "0.0",
"leaflet": "1",
"moment": "2",
"numeral": "2",
"popper.js": "1",
"puppeteer": "0.13",
"rimraf": "2",
"rollup": "0.52",
"rollup-plugin-uglify": "2",
"rollup-pluginutils": "2",
"rollup-watch": "4",
"tap-merge": "0.3",
"tape": "4",
"topojson": "3",
......
......@@ -5,13 +5,14 @@ import * as default_templates from './formhandler.template.html'
import { parse } from './url.js'
// Render components in this order. The empty component is the root component.
var components = ['', 'table', 'page', 'size', 'export', 'search', 'filters']
var components = ['', 'table', 'page', 'size', 'count', 'export', 'filters', 'error']
var default_options = {
table: true,
page: true,
pageSize: 100,
size: true,
sizeValues: [10, 20, 50, 100, 500, 1000],
count: true,
export: true,
exportFormats: {
xlsx: 'Excel',
......@@ -19,11 +20,38 @@ var default_options = {
json: 'JSON',
html: 'HTML'
},
search: true,
filters: true
}
var meta_headers = ['filters', 'ignored', 'excluded', 'sort', 'offset', 'limit', 'count']
var default_filters = {
text: { '': 'Equals...', '!': 'Does not equal...', '~': 'Contains...', '!~': 'Does not contain...' },
number: { '': 'Equals...', '!': 'Does not equal...', '<': 'Less than...', '>': 'Greater than...' },
date: { '': 'Equals...', '!': 'Does not equal...', '<': 'Before...', '>': 'After...' }
}
// Set default values for column specifications
// function col_defaults(colinfo, data) {
function col_defaults(colinfo) {
// Sort defaults
if (!('sort' in colinfo) || colinfo.sort === true)
colinfo.sort = { '': 'Sort ascending', '-': 'Sort descending' }
else if (typeof colinfo.sort != 'object')
colinfo.sort = {}
// Type defaults
colinfo.type = colinfo.type || 'text'
// Filters defaults
if (!('filters' in colinfo) || (colinfo.filters === true))
colinfo.filters = default_filters[colinfo.type]
// Hide defaults
if (!('hideable' in colinfo))
colinfo.hideable = true
}
export function formhandler(js_options) {
if (!js_options)
js_options = {}
......@@ -39,7 +67,6 @@ export function formhandler(js_options) {
// Pre-process options
var options = $.extend({}, default_options, js_options, $this.data())
options.src = $this.attr('src')
if (!options.columns)
options.columns = []
else if (typeof options.columns == 'string')
......@@ -53,13 +80,25 @@ export function formhandler(js_options) {
})
function draw_table(data, args, meta) {
// Add metadata
meta.rows = data.length,
meta.columns = data.length ? _.map(data[0], function (val, col) { return { name: col } }) : []
// Assumption: Column name will not be '*'
if (options.columns.some(function (o) { return o['name'] === '*' }))
options.columns = _.map(meta.columns, function(col){
var options_col = _.find(options.columns, function (o) { return o['name'] === col.name })
return options_col ? options_col : col
})
// Render all components into respective targets
var template_data = {
data: data, meta: meta, args: args, options: options, idcount: 0, parse: parse
data: data, meta: meta, args: args, options: options, idcount: 0, parse: parse,
col_defaults: col_defaults,
}
// Store template_data in $this
$this.data('formhandler', template_data)
_.each(components, function(name) {
// Disable components if required. But root component '' is always displayed
if (name && !options[name])
......@@ -83,7 +122,6 @@ export function formhandler(js_options) {
function render() {
var url_args = parse(location.hash.replace(/^#/, '')).searchList
// TODO: Show a loading indicator
// Create arguments passed to the FormHandler. Override with the user URL args
var args = _.extend({
c: options.columns.map(function(d) { return d.name }),
......@@ -91,10 +129,17 @@ export function formhandler(js_options) {
_format: 'json',
_meta: 'y'
}, url_args)
// Show loader
$('.loader', $this).removeClass('d-none')
$.ajax(options.src, {
dataType: 'json',
data: args,
traditional: true
traditional: true,
complete: function () {
// Hide loader
$('.loader', $this).addClass('d-none')
}
}).done(function(data, status, xhr) {
var meta = {}
_.each(meta_headers, function(header) {
......@@ -102,15 +147,49 @@ export function formhandler(js_options) {
if (val !== null)
meta[header] = JSON.parse(val)
})
// TODO: Stop loading indicator
if (typeof options.transform == 'function') {
var result = options.transform({data: data, meta: meta, options: options, args: args}) || {}
data = 'data' in result ? result.data : data
meta = 'meta' in result ? result.meta : meta
}
draw_table(data, args, meta)
}).fail(function() {
// TODO
$this.trigger({ type: 'load', formdata: data, meta: meta, args: args, options: options })
}).fail(function(xhr, status, message) {
$this.html(template['error']({
message: message
}))
})
}
// Handle modal dialog
$this
.on('shown.bs.modal', '.formhandler-table-modal', function (e) {
var $el = $(e.relatedTarget)
var template_data = $this.data('formhandler')
var op = $el.data('op')
var col = $el.closest('[data-col]').data('col')
var val = ''
// If there is a value, show it, and allow user to remove the filter
if (template_data.args[col + op]) {
val = template_data.args[col + op].join(',')
$('.remove-action', this).attr('href', '?' + col + op + '=').show()
} else
$('.remove-action', this).hide()
$('input', this).val(val).attr('name', col + op).focus()
$('label', this).text($el.text())
})
.on('submit', 'form', function (e) {
e.preventDefault()
var filter = parse('?' + $(this).serialize()).searchKey
$(this).closest('.formhandler-table-modal').modal('hide')
window.location.hash = '#' + parse(location.hash.replace(/^#/, '')).update(filter)
})
// Re-render every time the URL changes
$(window).on('hashchange', render)
// Initialize
render()
})
return this
}
......@@ -12,39 +12,82 @@ Each template receives these variables:
<!-- This is the root template that renders all other components on this page -->
<!-- var template_ -->
<div class="formhandler">
<div class="formhandler-table-header d-flex justify-content-between">
<div class="d-flex">
<div class="page mb-2 mr-2"></div>
<div class="size mb-2 mr-2"></div>
</div>
<div class="d-flex">
<div class="filters mb-2"></div>
<div class="export mb-2"></div>
<div class="position-relative">
<div class="formhandler">
<div class="formhandler-table-header d-flex justify-content-between mb-2">
<div class="d-flex flex-wrap">
<div class="count mr-2"></div>
<div class="page mr-2"></div>
<div class="size mr-2"></div>
</div>
<div class="d-flex">
<div class="filters"></div>
<div class="export"></div>
</div>
</div>
<div class="table"></div>
</div>
<div class="loader pos-cc d-none">
<div class="fa fa-spinner fa-spin fa-3x fa-fw"></div>
<span class="sr-only">Loading...</span>
</div>
<div class="table"></div>
</div>
<div class="modal formhandler-table-modal" id="fh-modal-<%- idcount %>" tabindex="-1" role="dialog" aria-labelledby="fh-label-<%- idcount %>" aria-hidden="true">
<div class="modal-dialog modal-sm" role="document">
<div class="modal-content">
<form class="formhandler-table-modal-form modal-body">
<label id="fh-label-<%- idcount %>" for="formhandler-table-modal-value">Value</label>
<p>
<input class="form-control" name="filter_input">
</p>
<div>
<button type="button" class="btn btn-sm btn-secondary mr-1" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-sm btn-primary mr-1">Apply filter</button>
<a class="btn btn-sm btn-danger remove-action urlfilter" data-dismiss="modal" data-target="#" href="#">Remove filter</button>
</div>
</form>
</div><!-- .modal-content -->
</div><!-- .modal-dialog -->
</div><!-- .modal -->
<!-- end -->
<!-- var template_table -->
<% var filtered_cols = args['_c'] && args['_c'].length != options.columns.length ? options.columns.filter(function(col) {return args['_c'].indexOf('-'+col.name) < 0}) : options.columns %>
<% var cols = options.columns.length ? filtered_cols : meta.columns %>
<%
var filtered_cols = args['_c'] && args['_c'].length != options.columns.length ?
options.columns.filter(function(col) { return args['_c'].indexOf('-' + col.name) < 0 }) :
options.columns
var cols = options.columns.length ? filtered_cols : meta.columns
var form_id = idcount
%>
<table class="table table-sm table-striped">
<thead>
<% _.each(cols, function(colinfo) { %>
<% var sort_asc = args['_sort'] == colinfo.name,
sort_desc = args['_sort'] == '-' + colinfo.name %>
<th class="<%- sort_asc ? 'table-primary' : sort_desc ? 'table-danger' : '' %>">
<% _.each(cols, function(colinfo) {
col_defaults(colinfo, data)
var menu_item = false
var col_id = idcount++
%>
<th class="<%- args['_sort'] == colinfo.name ? 'table-primary' : args['_sort'] == '-' + colinfo.name ? 'table-danger' : '' %>" data-col="<%- colinfo.name %>">
<div class="dropdown">
<a href="#" class="dropdown-toggle text-nowrap" id="dropdownMenuButton-<%- idcount++ %>" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<a href="#" class="dropdown-toggle text-nowrap" id="fh-dd-<%- col_id %>" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false">
<%- colinfo.title || colinfo.name %>
</a>
<div class="dropdown-menu" data-col="<% colinfo.name %>" aria-labelledby="dropdownMenuButton-<%- idcount %>">
<a class="dropdown-item urlfilter <%- sort_asc ? 'active': '' %>" href="?_sort=<%- sort_asc ? '' : colinfo.name %>">Sort ascending</a>
<a class="dropdown-item urlfilter <%- sort_desc ? 'active': '' %>" href="?_sort=<%- sort_desc ? '' : '-' + colinfo.name %>">Sort descending</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item urlfilter" href="?_c=-<%- colinfo.name %>" data-mode="add">Hide</a>
<div class="dropdown-menu" aria-labelledby="fh-dd-<%- col_id %>">
<% _.each(colinfo.sort, function(title, op) { menu_item = true
var active = args['_sort'] == op + colinfo.name %>
<a class="dropdown-item urlfilter <%- active ? 'active': '' %>" href="?_sort=<%- active ? '' : op + colinfo.name %>">
<%- title %>
</a>
<% }) %>
<% if (menu_item) { %><div class="dropdown-divider"></div><% menu_item = false } %>
<% _.each(colinfo.filters, function(title, op) { menu_item = true %>
<a class="dropdown-item <%= colinfo.name + op in args ? 'active' : '' %>" href="#" data-op="<%- op %>" data-toggle="modal" data-target="#fh-modal-<%- form_id %>">
<%- title %>
</a>
<% }) %>
<% if (menu_item) { %><div class="dropdown-divider"></div><% menu_item = false } %>
<% if (colinfo.hideable) { %>
<a class="dropdown-item urlfilter" href="?_c=-<%- colinfo.name %>" data-mode="add">Hide</a>
<% } %>
</div>
</div>
</th>
......@@ -55,9 +98,16 @@ Each template receives these variables:
<tr>
<% _.each(cols, function(colinfo) { %>
<td>
<a class="urlfilter" href="?<%- colinfo.name %>=<%- row[colinfo.name] %>&amp;_offset=">
<%- row[colinfo.name] %>
</a>
<% var fmt = typeof(colinfo.format),
val = row[colinfo.name],
disp = fmt == "function" ?
colinfo.format(val) :
fmt === "string" && colinfo.type === "number" ?
numeral(val).format(colinfo.format) :
fmt === "string" && colinfo.type === "date" ?
moment(val).format(colinfo.format):
val %>
<a class="urlfilter" href="?<%- colinfo.name %>=<%- val %>&amp;_offset="><%- disp %></a>
</td>
<% }) %>
</tr>
......@@ -111,19 +161,27 @@ Each template receives these variables:
<!-- end -->
<!-- var template_size -->
<div class="btn-group btn-group-sm" role="group">
<button id="formhandler-size-<%- idcount++ %>" type="button" class="btn btn-light btn-sm dropdown-toggle" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<%- meta.limit %> rows
</button>
<div class="dropdown-menu" aria-labelledby="formhandler-size-<%- idcount %>">
<% _.each(options.sizeValues, function(size) { %>
<a class="dropdown-item <%- meta.limit == size ? 'active' : '' %> urlfilter" href="?_limit=<%- size %>">
<%- size %>
</a>
<% }) %>
<% if (meta.limit) { %>
<div class="btn-group btn-group-sm" role="group">
<button id="formhandler-size-<%- idcount++ %>" type="button" class="btn btn-light btn-sm dropdown-toggle" data-toggle="dropdown"
aria-haspopup="true" aria-expanded="false">
<%- meta.limit %> rows
</button>
<div class="dropdown-menu" aria-labelledby="formhandler-size-<%- idcount %>">
<% _.each(options.sizeValues, function(size) { %>
<a class="dropdown-item <%- meta.limit == size ? 'active' : '' %> urlfilter" href="?_limit=<%- size %>">
<%- size %>
</a>
<% }) %>
</div>
</div>
</div>
<% } %>
<!-- end -->
<!-- var template_count -->
<% if ('count' in meta) { %>
<span class="btn btn-sm btn-light"><%- meta.count %> rows</span>
<% } %>
<!-- end -->
<!-- var template_export -->
......@@ -168,13 +226,17 @@ Each template receives these variables:
<% } %>
<% }) %>
<% qparts = qparts.toString() %>
<% if (qparts != '?') { %>
<% if (qparts && qparts != '?') { %>
<a href="?<%- qparts.slice(1) %>"
class="badge badge-pill badge-danger urlfilter"
data-mode="del"
title="Clear all filters">
×
</a>
title="Clear all filters">×</a>
<% } %>
</div>
<!-- end -->
<!-- var template_error -->
<div class="alert alert-warning" role="alert">
<p class="text-center"><%- message %> </p>
</div>
<!-- end -->
[
{
"x": 1
"y": 2
},
{
"x": 3,
"y": 4
}
]
[
{"x": 1, "y": 2},
{"x": 3, "y": 4}
]
[
{
"Amount": "$3,500",
"Date": "2013-02-08"
},
{
"Amount": "1000",
"Date": "2018/01/09"
}
]
<%= data.join(' ') %>
<%= data.join(' ') %>
......@@ -3,10 +3,26 @@
<head>
<title>formhandler tests</title>
<link rel="stylesheet" href="../node_modules/bootstrap/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="../node_modules/font-awesome/css/font-awesome.min.css">
<style>
.position-relative {
position: relative;
}
.pos-cc {
position: absolute;
top: 45%;
left: 45%;
}
.d-none {
display: none !important;
}
</style>
<script src="../node_modules/jquery/dist/jquery.min.js"></script>
<script src="../node_modules/popper.js/dist/umd/popper.min.js"></script>
<script src="../node_modules/bootstrap/dist/js/bootstrap.min.js"></script>
<script src="../node_modules/lodash/lodash.min.js"></script>
<script src="../node_modules/moment/min/moment.min.js"></script>
<script src="../node_modules/numeral/min/numeral.min.js"></script>
<script src="../dist/formhandler.min.js"></script>
<script src="tape.js"></script>
......@@ -16,17 +32,225 @@
tape.onFinish(function () { window.renderComplete = true })
</script>
<div class="row">
<div class="col">
<div class="formhandler" src="../formhandler-data" data-page-size="10"></div>
</div>
</div>
<div class="fh1" data-src="formhandler.json"></div>
<div class="fh2"></div>
<div class="fh3"></div>
<div class="fh4" data-table="false" data-count="false" data-page="false" data-size="false" data-export="false" data-filters="false"></div>
<div class="fh5" data-src="formhandler.json"></div>
<div class="fh6" data-src="formhandler.json"></div>
<div class="fh7" data-src="formhandler.json"></div>
<div class="fh8" data-src="formhandler.json"></div>
<div class="fh9" data-src="formhandler.json"></div>
<div class="fh10"></div>
<div class="fh11"></div>
<div class="fh12"></div>
<div class="fh13"></div>
<div class="formhandler" data-src="formhandler.json" data-page-size="10"></div>
<script>
tape('$().formhandler() exists', function(t) {
tape('$().formhandler() basic example works', function(t) {
$('.formhandler').formhandler({
pageSize: 20
}).on('load', function(e) {
t.equals(e.options.pageSize, 10)
// TODO: Review: if _limit is given in url, e.args._limit is [10] not 10
t.equals(e.args._limit, 10)
t.ok(e.formdata.length > 0)
t.equals($('.formhandler table tbody tr').length, e.formdata.length)