Commit 819fd993 authored by Tejesh's avatar Tejesh 🖖
Browse files

ENH: refactor formhandler table. Fixes #62

parent 498381d5
Pipeline #58151 passed with stage
in 2 minutes and 8 seconds
......@@ -568,20 +568,24 @@ The full list of options is below. Simple options can be specified as `data-` at
- `name`: column name. `"*"` is a special column placeholder for "all columns" (options given for `"*"` are applied for all columns)
- `title`: for header display. Defaults to the same value as `name`
- `type`: `text` (default) / `number` / `date`. Data type. Determines filters to be used
- `format`: string / function that returns formatted value.
- `format`: string / function that returns formatted cell display value.
- function accepts an object with these keys:
- `name`: column name
- `value`: cell data value
- `row`: row data
- `index`: row index
- `data`: the dataset from `src`
- 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)
- `editable`: `true` (default) / `false`. When `true`, edit and save buttons appears at end of each row.
- `template`: string template / function that renders the cell.
- function accepts an object with these keys:
- `name`: column name
- `value`: cell data value
- `format`: cell display value
- `format`: formatted cell display value
- `link`: cell link value (if applicable)
- `index`: row index
- `row`: row data
- `data`: the dataset from `src`
- string template can use the above variables
- `sort`: `true` / `false` / operators dict with:
......@@ -591,16 +595,32 @@ The full list of options is below. Simple options can be specified as `data-` at
The default list of operators is based on the auto-detected type of the column.
- `link`: string / function that generates a link for this each cell.
- If no `link:` is specified, clicking on the cell filters by that cell.
- If `link:` is a string, opens a new window with the string URL interpolated as a lodash template with `row` as data.
Example: `"https://example.org/city/<%- row.city >"`
- If `link:` is a function, opens a new window with the URL as `fn(row)`.
Example: `function(row) { return 'https://example.org/city/' + row.city }`
- If `link:` is a function, opens a new window with the URL as `fn(args)`.
- function accepts an object with these keys:
- `name`: column name
- `value`: cell data value
- `format`: formatted cell display value
- `index`: row index
- `row`: row data
- `data`: the dataset from `src`
Example: `function(args) { return 'https://example.org/city/' + args.value }`
- If `link:` is a string, opens a new window with the string URL interpolated as a lodash template with an object (mentioned above) as data.
Example: `"https://example.org/city/<%- value >"`
- `hideable`: `true` (default) / `false`. Show or hide `Hide` option in header dropdown
- `hide`: `true` / `false` (default). Hides the column
- `unique`: TODO: {dict of query parameter and display value} or [list of values] or function?
- `edit`: Shows the edit control. Can be `true` / `false` (default)
- `actions`: A list of objects. If `data-action='delete'` action name is delete, you need not add it to actions
- `actionName`: a function() that gets triggered on clicking the element with `data-action='actionName` attribute. The function has arguments ... //TODO
- `edit`: Shows the Edit control. Can be `true` / `false` (default). Can also pass an object.
- `done`: function that gets called after saving the edited row.
- `add`: Show the Add control. Can be `true` / `false` (default). Can also pass an object.
- `done`: function that gets called after saving the new row.
- `actions`: A list of objects. you need not add it to actions
- `{{action}}`: a function() that gets triggered on clicking the element with `data-action='{{action}}` attribute. The value of `data-action` attribute must match with key `{{action}}` in `actions`.
- function accepts an object with these keys:
- `row`: row data
- `index`: index of the row in the dataset from `src`
Example:
- `highlight_row`: `function(obj) { $(obj.row).addClass('.yellow_color')}`. Either a new column can be defined in `columns:` (example: {`name`: `Additional Col`}) with cell_template having an element with data attribute as `data-action='highlight_row'` or can use an existing column but with custom template that has an element with data attribute as `data-action='highlight_row'`.
- Note: DELETE operation is executed on a row if an element has data attribute `data-action='delete'`. If `delete` action is given in `actions`, the function given for `delete` is executed on click of an element with `data-action='delete'` instead od executing DELETE operation.
- `table`: Shows the table control. Can be:
- `true`: displays a table (default)
- `'grid'`: renders a grid instead of a table
......@@ -619,9 +639,15 @@ The full list of options is below. Simple options can be specified as `data-` at
- `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`
- `icon`: if `table: 'grid'` is used, display an icon:
- `icon: 'fa fa-home fa-3x'` renders a FontAwesome home icon
- `icon: './path/to/image.png'` renders the image specified
- `icon`: if `table: 'grid'` is used, display an icon. string / function that renders the cell.
- function accepts an object with these keys:
- `row`: row data
- `data`: the dataset from `src`
- `index`: index of the row in the dataset from `src`
Example:
- `icon: 'fa fa-home fa-3x'` renders a FontAwesome home icon
- `icon: './path/to/image.png'` renders the image specified
- `icon: function(args) { return args.row['image_link'] }` renders an image with `src` attribute as the value from column name `image_link`
**Advanced**. Each component can have a target which specifies a selector. For
e.g., to render the export button somewhere else, use
......@@ -650,6 +676,7 @@ with a simple input. Available template strings are:
- `rowTemplate`, which can be a string template or function
- function accepts an object with these keys:
- `row`: row data
- `index`: row index
- `data`: the dataset from `src`
- string template can use the above variables
......
......@@ -97,7 +97,7 @@ export function formhandler(js_options) {
var star_col = _.find(options.columns, function (o) { return o['name'] === '*' })
if (star_col) {
var action_header_cols = _.cloneDeep(meta.columns)
_.map(options.columns, function(option_col) {
_.map(options.columns, function (option_col) {
var found = _.find(meta.columns, function (o) { return o['name'] === option_col.name })
if (!found && option_col.name !== '*')
action_header_cols.push(option_col)
......@@ -127,7 +127,7 @@ export function formhandler(js_options) {
$this.data('formhandler', template_data)
_.each(components, function (name) {
compile_template(name, template_data, options, $this, template)
render_template(name, template_data, options, $this, template)
})
if (options.add)
addHandler($this, template_data, options, template)
......@@ -231,7 +231,7 @@ function modalHandler($this) {
})
}
function compile_template(name, data, options, $this, template) {
function render_template(name, data, options, $this, template) {
// Disable components if required. But root component '' is always displayed
if (name && !options[name])
return
......@@ -241,7 +241,6 @@ function compile_template(name, data, options, $this, template) {
if (!name)
target = $this
else {
if (options[name] == 'grid') name = 'table_grid'
// Rest are rendered into .<component-name> under $this
if (options[name] == 'grid') name = 'table_grid'
......@@ -275,28 +274,24 @@ function addHandler($this, template_data, options, template) {
method: 'POST',
dataType: 'json',
data: data
}).done(function () {
template_data.data.unshift(data)
template_data.isAdd = false
render_template('table', template_data, options, $this, template)
if (options.add.done) options.add.done()
}).fail(function (xhr, status, message) {
$this.html(template['error']({
message: message
}))
}).always(function () {
if (options.add.addFunction) options.add.addFunction()
$('.loader', $this).addClass('d-none')
})
// update the current view with out re-render the table
var new_data = {}, key
if (template_data.data[0]) new_data = Object.assign({}, template_data.data[0])
for (key in data) {
new_data[key] = data[key]
}
template_data.data.push(new_data)
compile_template('table', Object.assign({}, template_data, { isAdd: false }), options, $this, template)
} else if (add_btn.html().toLowerCase() == 'add') {
add_btn.html('Save')
// TODO: rerender each cell with <input value='' />
compile_template('table', Object.assign({}, template_data, { isAdd: true }), options, $this, template)
template_data.isAdd = true
render_template('table', template_data, options, $this, template)
$('table tbody input', $this).on('click', function () {
// Attach a class to this row
$(this.parentElement.parentElement).addClass('dirty-row')
......@@ -314,17 +309,17 @@ function editHandler($this, template_data, options, template) {
var dirty_rows = $('.dirty-row')
if (dirty_rows.length > 0)
$('.loader', $this).removeClass('d-none')
var all_ajax = []
$.each(dirty_rows, function (key, dirty_row) {
var data = JSON.parse(dirty_row.getAttribute('data-val'))
var rowIndex = dirty_row.getAttribute('data-row')
for (key in data) {
var dirty_td = $('td input[data-key=' + encodeURIComponent(key) + ']', $(dirty_row))
if (dirty_td.length > 0) {
data[key] = template_data['data'][rowIndex][key] = dirty_td.val()
}
var dirty_td = $('td input[data-key=' + (key.remove_quotes()) + ']', $(dirty_row))
data[key] = template_data['data'][rowIndex][key] = dirty_td.val()
}
$.ajax(options.src, {
all_ajax.push($.ajax(options.src, {
method: 'PUT',
dataType: 'json',
data: data
......@@ -336,13 +331,20 @@ function editHandler($this, template_data, options, template) {
if (options.add.editFunction) options.add.editFunction()
$('.loader', $this).addClass('d-none')
})
)
})
$.when.apply($, all_ajax).then(function () {
$('.loader', $this).addClass('d-none')
if (options.edit.done) options.edit.done()
})
compile_template('table', Object.assign({}, template_data, { isEdit: false }), options, $this, template)
template_data.isEdit = false
render_template('table', template_data, options, $this, template)
} else if (edit_btn.html().toLowerCase() == 'edit') {
edit_btn.html('Save') // TODO: remove hardcoding of name Save
compile_template('table', Object.assign({}, template_data, { isEdit: true }), options, $this, template)
$('table tbody input', $this).on('click', function () {
edit_btn.html('Save') // TODO: remove hardcoding of name Save
template_data.isEdit = true
render_template('table', template_data, options, $this, template)
$('table tbody input', $this).on('input', function () {
// Attach a class to this row
$(this.parentElement.parentElement).addClass('dirty-row')
})
......@@ -350,19 +352,19 @@ function editHandler($this, template_data, options, template) {
})
}
function actionHandler($this, options, template) {
function actionHandler($this, options, template) {
var default_action_callback = {
'delete': function (row, rowNo) {
'delete': function (arg) {
$('.loader', $this).removeClass('d-none')
$.ajax(options.src, {
method: 'DELETE',
dataType: 'json',
data: row
data: arg.row
}).done(function () {
// TODO: show a bootstrap 4 success/failure message
$('.loader', $this).addClass('d-none')
$('.' + $this[0].className + ' tr[data-row="' + rowNo + '"]').hide()
$('.' + $this[0].className + ' tr[data-row="' + arg.rowNo + '"]').hide()
}).fail(function (xhr, status, message) {
$this.html(template['error']({
message: message
......@@ -377,9 +379,14 @@ function actionHandler($this, options, template) {
rowNo = $(this).closest('[data-row]').data('row')
if (options.actions && options.actions[action]) {
options.actions[action](row, rowNo)
options.actions[action]({ row: row, index: rowNo })
} else {
default_action_callback[action](row, rowNo)
default_action_callback[action]({ row: row, index: rowNo })
}
})
}
String.prototype.remove_quotes = function () {
return this.toString().replace(/["']/g, '')
}
......@@ -101,7 +101,7 @@ Each template receives these variables:
<% menu_item = false } %>
<% if (colinfo.hideable) { %>
<a class="dropdown-item urlfilter" href="?_c=-<%- colinfo.name %>" data-mode="add">Hide</a>
<% } %>
<% } %>
</div>
</div>
</th>
......@@ -117,56 +117,56 @@ Each template receives these variables:
</td>
<% } else { %>
<td></td>
<% } %>
<% }) %>
</tr>
<% } %>
<% }) %>
</tr>
<% } %>
<% if (options.rowTemplate) { %>
<% _.each(data, function(row) { %>
<%= typeof options.rowTemplate == 'function' ? options.rowTemplate({row: row, data: data, col: cols}) : _.template(options.rowTemplate)({row: row, data: data, cols: cols}) %>
<% _.each(data, function(row, rowIndex) { %>
<%= typeof options.rowTemplate == 'function' ? options.rowTemplate({row: row, data: data, index: rowIndex}) : _.template(options.rowTemplate)({row: row, data: data, index: rowIndex}) %>
<% }) %>
<% } else {%>
<% _.each(data, function(row, rowIndex) { %>
<tr data-val="<%- JSON.stringify(row) %>" data-row="<%- rowIndex %>">
<% _.each(cols, function(colinfo) { %>
<% var fmt = typeof(colinfo.format),
val = row[colinfo.name],
isEditable = colinfo.editable === undefined ? true : colinfo.editable,
disp = fmt == "function" ?
colinfo.format({name: colinfo.name, value: val, row: row, data:data }) :
val = row[colinfo.name],
isEditable = colinfo.editable === undefined ? true : colinfo.editable,
disp = fmt == "function" ?
colinfo.format({name: colinfo.name, value: val, index: rowIndex, row: row, data:data }) :
fmt === "string" && colinfo.type === "number" ?
numeral(val).format(colinfo.format) :
fmt === "string" && colinfo.type === "date" ?
moment(val).format(colinfo.format):
val,
col_link %>
<% if (!isEdit && 'link' in colinfo) var col_link = typeof colinfo.link == 'function' ? colinfo.link({row: row}) : _.template(colinfo.link)({row: row}) %>
<% if (colinfo.template) { %>
<%= typeof colinfo.template == 'function' ? colinfo.template({value: val, format: disp, link: col_link, data: data}) : _.template(colinfo.template)(({value: val, format: disp, link: col_link, data: data})) %>
<% } else if (col_link) { %>
<td>
<a href="<%- col_link %>" target="_blank">
<%= disp %>
</a>
</td>
<% } else { %>
<% if (isEdit && isEditable) { %>
<td>
<input class="form-control form-control-sm" data-key="<%- colinfo.name %>" value="<%- val %>"
/>
</td>
<% } else { %>
<td>
<a class="urlfilter" href="?<%- colinfo.name %>=<%- val %>&amp;_offset=">
<%= disp %>
</a>
</td>
<% } %>
<% } %>
<% }) %>
</tr>
<% if (!isEdit && 'link' in colinfo) var col_link = typeof colinfo.link == 'function' ? colinfo.link({name: colinfo.name, value: val, format: disp, index: rowIndex, row: row, data: data}) : _.template(colinfo.link)({name: colinfo.name, value: val, format: disp, index: rowIndex, row: row, data: data}) %>
<% if (colinfo.template) { %>
<%= typeof colinfo.template == 'function' ? colinfo.template({name: colinfo.name, value: val, format: disp, link: col_link, index: rowIndex, row: row, data: data}) : _.template(colinfo.template)(({name: colinfo.name, value: val, format: disp, link: col_link, index: rowIndex, row: row, data: data})) %>
<% } else if (col_link) { %>
<td>
<a href="<%- col_link %>" target="_blank">
<%= disp %>
</a>
</td>
<% } else { %>
<% if (isEdit && isEditable) { %>
<td>
<input class="form-control form-control-sm" data-key="<%- colinfo.name %>" value="<%- val %>"
/>
</td>
<% } else { %>
<td>
<a class="urlfilter" href="?<%- colinfo.name %>=<%- val %>&amp;_offset=">
<%= disp %>
</a>
</td>
<% } %>
<% } %>
<% }) %>
</tr>
<% }) %>
<% } %>
<% } %>
</tbody>
</table>
<!-- end -->
......@@ -310,62 +310,58 @@ Each template receives these variables:
</div>
<!-- end -->
<!-- var template_table_grid -->
<%
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
var img = (options.icon) ? options.icon : 'http://icons.iconarchive.com/icons/mazenl77/NX11/256/Folder-Default-icon.png'
<!-- var template_table_grid -->
<%
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, img
%>
<% if (options.rowTemplate) { %>
<% _.each(data, function(row) { %>
<%= typeof options.rowTemplate == 'function' ? options.rowTemplate({row: row, data: data}) : _.template(options.rowTemplate)({row: row, data: data}) %>
<% }) %>
<% } else {%>
<div class="formhandler-grid row">
<% _.each(data, function(row) { %>
<div class="col-sm-3 <%= options.classes || 'formhandler-grid-cell d-inline-block p-3 box-shadow' %>">
<div class="thumbnail">
<% if (img.indexOf('fa ') >= 0) { %>
<i class="<%= img %>"></i>
<% } else { %>
<img class="img img-responsive" src="<%= img %>" />
<% } %>
<% if (options.rowTemplate) { %>
<% _.each(data, function(row, rowIndex) { %>
<%= typeof options.rowTemplate == 'function' ? options.rowTemplate({row: row, index: rowIndex, data: data}) : _.template(options.rowTemplate)({row: row, data: data, index: rowIndex}) %>
<% }) %>
<% } else {%>
<div class="formhandler-grid row">
<% _.each(data, function(row, rowIndex) { %>
<div class="col-sm-3 <%= options.classes || 'formhandler-grid-cell d-inline-block p-3 box-shadow' %>">
<div class="thumbnail">
<% img = options.icon ? ((typeof(options.icon) == 'function' ? options.icon({row: row, data: data, index: rowIndex}) : options.icon)) : 'fa fa-home' %>
<% if (img.indexOf('fa ') >= 0) { %>
<i class="<%= img %>"></i>
<% } else { %>
<img class="img img-responsive" src="<%= img %>"/>
<% } %>
<div class="caption">
<% _.each(cols, function(colinfo) { %>
<% var fmt = typeof(colinfo.format),
val = row[colinfo.name],
disp = fmt == "function" ?
colinfo.format({name: colinfo.name, value: val, row: row, data:data }) :
fmt === "string" && colinfo.type === "number" ?
numeral(val).format(colinfo.format) :
fmt === "string" && colinfo.type === "date" ?
moment(val).format(colinfo.format):
val %>
<div>
<strong>
<%= colinfo.name %>
</strong>:
<% if ('link' in colinfo) {
var col_link = typeof colinfo.link == 'function' ? colinfo.link(val) : _.template(colinfo.link)(row)
%>
<a href="<%- col_link %>" target="_blank">
<%= disp %>
</a>
<% } else { %>
<a class="urlfilter" href="?<%- colinfo.name %>=<%- val %>&amp;_offset=">
<%= disp %>
</a>
<% } %>
</div>
<% }) %>
</div>
</div>
</div>
<% }) %>
</div>
<% } %>
<!-- end -->
<div class="caption">
<% _.each(cols, function(colinfo) { %>
<% var fmt = typeof(colinfo.format),
val = row[colinfo.name],
disp = fmt == "function" ?
colinfo.format({index: rowIndex, name: colinfo.name, value: val, row: row, data:data }) :
fmt === "string" && colinfo.type === "number" ?
numeral(val).format(colinfo.format) :
fmt === "string" && colinfo.type === "date" ?
moment(val).format(colinfo.format):
val %>
<div>
<strong><%= colinfo.name %></strong>:
<% if ('link' in colinfo) {
var col_link = typeof colinfo.link == 'function' ? colinfo.link({row: row, value: val, index: rowIndex, name: colinfo.name, data: data, format: disp}) : _.template(colinfo.link)({row: row, value: val, index: rowIndex, name: colinfo.name, data: data, format: disp})
%>
<a href="<%- col_link %>" target="_blank"><%= disp %></a>
<% } else { %>
<a class="urlfilter" href="?<%- colinfo.name %>=<%- val %>&amp;_offset=">
<%= disp %>
</a>
<% } %>
</div>
<% }) %>
</div>
</div>
</div>
<% }) %>
</div>
<% } %>
<!-- end -->
<!DOCTYPE html>
<html>
<head>
<title>Editable 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="../dist/scale.min.js"></script>
<script src="../node_modules/d3/build/d3.js"></script>
<script src="../node_modules/d3-scale-chromatic/dist/d3-scale-chromatic.js"></script>
<script src="tape.js"></script>
</head>
<body>
<script>
tape.onFinish(function () { window.renderComplete = true })
</script>
<div class="edit-fh"></div>
<script>
tape('$().formhandler() test edit to save rows triggered on "input" event rather "click: event', function (test) {
test.plan(6)
$('.edit-fh').formhandler({
src: '/formhandler-data',
columns: [
{
name: "ID"
},
{
name: "Continent"
},
{
name: 'delete',
template: function(row) {
return "<td><button data-action='delete'><i class='fa fa-trash'></i></button></td>"
}
}
],
edit: {
done: function() {
test.ok(true, "must call twice")
}
},
add: true
}).on('load', function () {
// editing rows with no click must also update values
var init_cell_value = $(".edit-fh table > tbody > tr:nth-child(1) > td:nth-child(1) > a.urlfilter").text().trim()
$('.edit button').click()
// make sure the initial value is Europe
test.equals($(".edit-fh table > tbody > tr:nth-child(1) > td:nth-child(1) > input").val().trim(), init_cell_value)
// modify cell value inside <input>
$(".edit-fh table > tbody > tr:nth-child(1) > td:nth-child(1) > input").val('Edited')
$(".edit-fh table > tbody > tr:nth-child(1) > td:nth-child(1) > input").trigger('input')
test.equals($(".edit-fh table > tbody > tr:nth-child(1) > td:nth-child(1) > input").val().trim(), 'Edited')
// save row
$('.edit button').click()
test.equals($(".edit-fh table > tbody > tr:nth-child(1) > td:nth-child(1) > a.urlfilter").text().trim(), 'Edited')
// reset values
$('.edit button').click()
$(".edit-fh table > tbody > tr:nth-child(1) > td:nth-child(1) > input").val(init_cell_value)
$(".edit-fh table > tbody > tr:nth-child(1) > td:nth-child(1) > input").trigger('input')
$('.edit button').click()
test.equals($(".edit-fh table > tbody > tr:nth-child(1) > td:nth-child(1) > a.urlfilter").text().trim(), init_cell_value)
})
})
</script>
</body>
</html>
......@@ -38,7 +38,10 @@
<script>
tape.onFinish(function () { window.renderComplete = true })
</script>
<div class="row_template1" data-table="grid" data-src="/formhandler-data"></div>
<div class="icon-test" data-table="grid" data-src="/formhandler-data"></div>
<div class="icon-test2" data-table="grid" data-src="/formhandler-data"></div>
<div class="icon-test3" data-table="grid" data-src="/formhandler-data"></div>
<div id="city_table"> </div>
<script>
......@@ -72,117 +75,65 @@
})
})
tape('$().formhandler() renders rowTemplate as string', function (t) {
$('.row_template1').formhandler({
tape('$().formhandler() test icon: supports function', function (t) {
var pageSize = 10
$('.icon-test').formhandler({
columns: [
{
name: 'ID',
format: function(arg) {return arg.row['ID'].toLowerCase() }, // TODO: REVIEW whether to pass row number as second argument?
link: 'https://en.wikipedia.org/wiki/<%= ID %>',