Commit d2214db7 authored by S Anand's avatar S Anand
Browse files

ENH: Editable FormHandler table. Fixes #35 @abinesh.lal

parent 1f6df7b4
Pipeline #47037 failed with stage
in 1 minute and 54 seconds
......@@ -277,14 +277,18 @@ In the above example, `data-page-size="10"` over-rides `pageSize: 20`.
The full list of options is below. Simple options can be specified as `data-` attributes as well.
- `src`: [FormHandler][formhandler] URL endpoint
- `namespace`: (Optional) If the URL has `?name:key=value`, the filter
`key=value` only applies to formhandlers with namespace as `name`.
Filters without a namespace like `?key=value` will apply to all formhandlers.
- `columns`: comma-separated column names to display, or a list of objects with these keys:
- `name`: column name. `"*"` is a special column placeholder for "all columns"
- `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.
- function(row, data) returns formatted value
- 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:
- `value`: cell data value
......@@ -300,12 +304,15 @@ The full list of options is below. Simple options can be specified as `data-` at
- `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/<%- city >"`
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 }`
- `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
- `table`: Shows the table control. Can be:
- `true`: displays a table (default)
- `'grid'`: renders a grid instead of a table
......
......@@ -6,3 +6,5 @@ url:
handler: FormHandler
kwargs:
url: test/formhandler.csv
xsrf_cookies: false # TODO: enable this and test
id: ID
......@@ -24,7 +24,7 @@ export default [
},
{
input: "index-formhandler",
plugins: [htmlparts('src/formhandler.template.html'), uglify()],
plugins: [htmlparts('src/formhandler.template.html'), process.env.npm_lifecycle_event == 'dev' ? '' : uglify()],
output: { file: "dist/formhandler.min.js", format: "umd", name: "g1" }
},
{
......
......@@ -6,9 +6,11 @@ import { parse } from './url.js'
import { namespace } from './namespace_util.js'
// Render components in this order. The empty component is the root component.
var components = ['', 'table', 'page', 'size', 'count', 'export', 'filters', 'error', 'table_grid']
var components = ['', 'table', 'edit', 'add', 'page', 'size', 'count', 'export', 'filters', 'error', 'table_grid']
var default_options = {
table: true,
edit: false,
add: false,
page: true,
pageSize: 100,
size: true,
......@@ -72,6 +74,7 @@ export function formhandler(js_options) {
// Pre-process options
var options = $.extend({}, default_options, js_options, $this.data())
if (!options.columns)
options.columns = []
else if (typeof options.columns == 'string')
......@@ -89,12 +92,23 @@ export function formhandler(js_options) {
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) {
// If any column name is '*', show all columns
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) {
var found = _.find(meta.columns, function (o) { return o['name'] === option_col.name })
if (!found && option_col.name !== '*')
action_header_cols.push(option_col)
})
action_header_cols = _.map(action_header_cols, function (col) {
var options_col = _.find(options.columns, function (o) { return o['name'] === col.name })
return options_col ? options_col : col
return options_col ? options_col : $.extend({}, star_col, col)
})
}
options.columns = action_header_cols ? action_header_cols : options.columns
// Render all components into respective targets
var template_data = {
......@@ -104,31 +118,19 @@ export function formhandler(js_options) {
options: options,
idcount: 0,
parse: parse,
col_defaults: col_defaults
col_defaults: col_defaults,
isEdit: false,
isAdd: false
}
// 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])
return
var target
// The root '' component is rendered into $this.
if (!name)
target = $this
else {
// Rest are rendered into .<component-name> under $this
if (options[name] == 'grid') name = 'table_grid'
var selector = options[name + 'Target'] || '.' + name
target = $(selector, $this)
// But if they don't exist, treat the selector as a global selecctor
if (target.length == 0)
target = $(selector)
}
target.html(template[name](template_data))
compile_template(name, template_data, options, $this, template)
})
addHandler($this, template_data, options, template)
editHandler($this, template_data, options, template)
}
function render() {
......@@ -141,7 +143,6 @@ export function formhandler(js_options) {
_format: 'json',
_meta: 'y'
}, url_args)
// Show loader
$('.loader', $this).removeClass('d-none')
$.ajax(options.src, {
......@@ -149,7 +150,6 @@ export function formhandler(js_options) {
data: args,
traditional: true,
complete: function () {
// Hide loader
$('.loader', $this).addClass('d-none')
}
}).done(function (data, status, xhr) {
......@@ -173,29 +173,9 @@ export function formhandler(js_options) {
})
}
// 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)
})
modalHandler($this)
actionHandler($this, options, template)
// Re-render every time the URL changes
$(window).on('hashchange', render)
......@@ -205,3 +185,190 @@ export function formhandler(js_options) {
return this
}
function modalHandler($this) {
// 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)
})
}
function compile_template(name, data, options, $this, template) {
// Disable components if required. But root component '' is always displayed
if (name && !options[name])
return
var target
// The root '' component is rendered into $this.
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'
var selector = options[name + 'Target'] || '.' + name
target = $(selector, $this)
// But if they don't exist, treat the selector as a global selecctor
if (target.length == 0)
target = $(selector)
}
target.html(template[name](data))
}
function addHandler($this, template_data, options, template) {
$('.add button', $this)
.on('click', function () {
var add_btn = $('.add button', $this)
if (add_btn.html().toLowerCase() == 'save') {
add_btn.html('Add')
var columns_data = $('.new-row input[data-key]')
$('.loader', $this).removeClass('d-none')
var data = {}
$.each(columns_data, function (key, column) {
data[column.getAttribute('data-key')] = column.value
})
$.ajax(options.src, {
method: 'POST',
dataType: 'json',
data: data,
complete: options.add.addFunction ? options.add.addFunction() : function () {
// Hide loader
$('.loader', $this).addClass('d-none')
}
}).done(function (data, status, xhr) {
// TODO: show a bootstrap 4 success/failure message
$('.loader', $this).addClass('d-none')
}).fail(function (xhr, status, message) {
$this.html(template['error']({
message: message
}))
})
// update the current view with out re-render the table
var new_data = {}
if (template_data.data[0]) new_data = Object.assign({}, template_data.data[0])
for (var 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)
$('table tbody input', $this).on('click', function (event) {
// Attach a class to this row
$(this.parentElement.parentElement).addClass('dirty-row')
})
}
})
}
function editHandler($this, template_data, options, template) {
$('.edit button', $this)
.on('click', function () {
var edit_btn = $('.edit button', $this)
if (edit_btn.html().toLowerCase() == 'save') {
edit_btn.html('Edit') // TODO: remove hardcoding of name Edit
var dirty_rows = $('.dirty-row')
if (dirty_rows.length > 0)
$('.loader', $this).removeClass('d-none')
$.each(dirty_rows, function (key, dirty_row) {
var data = JSON.parse(dirty_row.getAttribute('data-val'))
var rowIndex = dirty_row.getAttribute('data-row')
for (var 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()
}
}
$.ajax(options.src, {
method: 'PUT',
dataType: 'json',
data: data,
complete: options.edit.editFunction ? options.edit.editFunction() : function () {
$('.loader', $this).addClass('d-none')
}
}).done(function (data, status, xhr) {
// TODO: show a bootstrap 4 success/failure message
$('.loader', $this).addClass('d-none')
}).fail(function (xhr, status, message) {
$this.html(template['error']({
message: message
}))
})
})
compile_template('table', Object.assign({}, template_data, { isEdit: false }), 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 (event) {
// Attach a class to this row
$(this.parentElement.parentElement).addClass('dirty-row')
})
}
})
}
function actionHandler($this, options, template) {
var default_action_callback = {
'delete': function (row, rowNo) {
$('.loader', $this).removeClass('d-none')
$.ajax(options.src, {
method: 'DELETE',
dataType: 'json',
data: row
}).done(function (data, status, xhr) {
// TODO: show a bootstrap 4 success/failure message
$('.loader', $this).addClass('d-none')
$('.' + $this[0].className + ' tr[data-row="' + rowNo + '"]').hide()
}).fail(function (xhr, status, message) {
$this.html(template['error']({
message: message
}))
})
}
}
$this.on('click', '[data-action]', function () {
var action = $(this).data('action'),
row = $(this).closest('[data-val]').data('val'),
rowNo = $(this).closest('[data-row]').data('row')
if (options.actions && options.actions[action]) {
options.actions[action](row, rowNo)
} else {
default_action_callback[action](row, rowNo)
}
})
}
......@@ -16,6 +16,8 @@ Each template receives these variables:
<div class="formhandler">
<div class="formhandler-table-header d-flex justify-content-between mb-2">
<div class="d-flex flex-wrap">
<div class="edit mr-2"></div>
<div class="add mr-2"></div>
<div class="count mr-2"></div>
<div class="page mr-2"></div>
<div class="size mr-2"></div>
......@@ -25,7 +27,7 @@ Each template receives these variables:
<div class="export"></div>
</div>
</div>
<div class="<%- (options.table == 'grid') ? 'table_grid' : 'table' %>"></div>
<div class="<%- (options.table == 'grid') ? 'table_grid' : 'table table-responsive' %>"></div>
</div>
<div class="loader pos-cc d-none">
<div class="fa fa-spinner fa-spin fa-3x fa-fw"></div>
......@@ -60,11 +62,8 @@ Each template receives these variables:
cols = cols.filter(function(col) { return col.hide !== true})
var form_id = idcount
%>
<% if(options.rowTemplate) { %>
<% _.each(data, function(row) { %>
<%= typeof options.rowTemplate == 'function' ? options.rowTemplate(row, data) : options.rowTemplate %>
<% }) %>
<% } else {%>
<!-- TODO: Abinesh to not override header row in table -->
<table class="table table-sm table-striped">
<thead>
<% _.each(cols, function(colinfo) {
......@@ -100,35 +99,56 @@ Each template receives these variables:
<% }) %>
</thead>
<tbody>
<% _.each(data, function(row) { %>
<tr>
<% if (isAdd) { %>
<tr class="new-row">
<% _.each(cols, function(colinfo) { %>
<% var fmt = typeof(colinfo.format),
val = row[colinfo.name],
disp = fmt == "function" ?
colinfo.format(row, 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('link' in colinfo) col_link = typeof colinfo.link == 'function' ? colinfo.link(row) : _.template(colinfo.link)(row) %>
<% if(colinfo.template) { %>
<%= typeof colinfo.template == 'function' ? colinfo.template(val, disp, col_link, data) : colinfo.template %>
<% } else if (col_link) { %>
<td><a href="<%- col_link %>" target="_blank"><%= disp %></a></td>
<% } else { %>
<td><a class="urlfilter" href="?<%- colinfo.name %>=<%- val %>&amp;_offset=">
<%= disp %>
</a></td>
<% } %>
<% if (!colinfo.template) { %>
<td><input class="form-control form-control-sm" data-key="<%- colinfo.name %>" value=""/></td>
<% } else { %>
<td></td>
<% } %>
<% }) %>
</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}) %>
<% }) %>
<% } 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({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>
<% }) %>
<% } %>
</tbody>
</table>
<% } %>
<!-- end -->
<!-- var template_page -->
......@@ -143,6 +163,7 @@ Each template receives these variables:
</li>
<% if (lo > 1) { %>
<li class="page-item">
<!-- TODO: Review Shouldn't this be _offset=0 ? -->
<a class="page-link" href="?_offset=">1</a>
</li>
<% if (lo > 2) { %>
......@@ -199,6 +220,18 @@ Each template receives these variables:
<% } %>
<!-- end -->
<!-- var template_edit -->
<button type="button" class="btn btn-primary mr-2 btn-sm edit-btn">
Edit
</button>
<!-- end -->
<!-- var template_add -->
<button type="button" class="btn btn-primary mr-2 btn-sm add-btn">
Add
</button>
<!-- end -->
<!-- var template_export -->
<div class="btn-group btn-group-sm" role="group">
<button id="formhandler-export-<%- idcount++ %>" type="button" class="btn btn-light btn-sm dropdown-toggle" data-toggle="dropdown"
......@@ -265,9 +298,9 @@ Each template receives these variables:
var form_id = idcount
var img = (options.icon) ? options.icon : 'http://icons.iconarchive.com/icons/mazenl77/NX11/256/Folder-Default-icon.png'
%>
<% if(options.rowTemplate) { %>
<% if (options.rowTemplate) { %>
<% _.each(data, function(row) { %>
<%= typeof options.rowTemplate == 'function' ? options.rowTemplate(row, data) : options.rowTemplate %>
<%= typeof options.rowTemplate == 'function' ? options.rowTemplate({row: row, data: data}) : _.template(options.rowTemplate)({row: row, data: data}) %>
<% }) %>
<% } else {%>
<div class="formhandler-grid row">
......
This diff is collapsed.
......@@ -44,48 +44,48 @@
columns: [
{
name: 'ID',
format: function(row) {return row['ID'].toLowerCase() }, // TODO: REVIEW whether to pass row number as second argument?
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 %>',
template: function (value, formatted_value, link, data) {
return "<td>ID " + formatted_value + "</td>"
template: function (arg) {
return "<td>ID " + arg.format + "</td>"
}
},
{
name: 'c1',
template: function (value, formatted_value, link, data) {
template: function (arg) {
var f = d3.scaleLinear()
.domain([0, 100])
.range(['white', 'green'])
return '<td>' + value + '<svg height="20" width="20"><circle r="10" cx="10" cy="10" stroke="black" stroke-width="3" fill="'+ f(value) +'"></circle></svg></td>'
return '<td>' + arg.value + '<svg height="20" width="20"><circle r="10" cx="10" cy="10" stroke="black" stroke-width="3" fill="'+ f(arg.value) +'"></circle></svg></td>'
}
},
{
name: 'c2',
format: function (row) {
format: function (arg) {
var f = d3.scaleLinear()
.domain([0, 100])
.range(['white', 'green'])
return row['c2'] + '<svg height="20" width="20"><circle r="10" cx="10" cy="10" stroke="black" stroke-width="3" fill="' + f(row['c2']) + '"></circle></svg>'
return arg.row['c2'] + '<svg height="20" width="20"><circle r="10" cx="10" cy="10" stroke="black" stroke-width="3" fill="' + f(arg.row['c2']) + '"></circle></svg>'
}
},
{ name: 'Continent' }
],
rowTemplate: function(row) {
rowTemplate: function(arg) {
var template = `<div class="col-sm-3 formhandler-grid-cell d-inline-block p-3 box-shadow">
<div class="thumbnail">
<img class="img img-responsive" src="/default.png"/>
<div class="caption">
<div>
<strong>ID</strong>:`+row['ID']+`
<strong>ID</strong>:`+arg.row['ID']+`
</div>
<div>
<strong>c1</strong>:`+row['c1']+`
<strong>c1</strong>:`+arg.row['c1']+`
</div>
<div>
<strong>c2</strong>:`+row['c2']+`
<strong>c2</strong>:`+arg.row['c2']+`
</div>
<div>
<strong>Continent</strong>:`+row['Continent']+`
<strong>Continent</strong>:`+arg.row['Continent']+`