Table viz: custom cell rendering; JSON cell renderer

This commit is contained in:
Levko Kravets 2017-12-01 13:38:34 +02:00
parent 8fc3af7bb9
commit 015d0c6edd
11 changed files with 406 additions and 10 deletions

View File

@ -0,0 +1,16 @@
import template from './template.html';
export default function init(ngModule) {
ngModule.directive('dynamicTableDefaultCell', $sanitize => ({
template,
restrict: 'E',
replace: true,
scope: {
column: '=',
value: '=',
},
link: ($scope) => {
$scope.sanitize = value => $sanitize(value);
},
}));
}

View File

@ -0,0 +1,4 @@
<td ng-class="'content-align-' + column.alignContent">
<div ng-if="column.allowHTML" ng-bind-html="sanitize(column.formatFunction(value))"></div>
<div ng-if="!column.allowHTML" ng-bind="column.formatFunction(value)"></div>
</td>

View File

@ -0,0 +1,25 @@
import { isFunction } from 'underscore';
export default function init(ngModule) {
ngModule.directive('dynamicTableRow', () => ({
template: '',
// AngularJS has a strange love to table-related tags, therefore
// we should use this directive as an attribute
restrict: 'A',
replace: false,
scope: {
columns: '=',
row: '=',
render: '=',
},
link: ($scope, $element) => {
$scope.$watch('render', () => {
if (isFunction($scope.render)) {
$scope.render($scope, (clonedElement) => {
$element.empty().append(clonedElement);
});
}
});
},
}));
}

View File

@ -10,12 +10,10 @@
</tr>
</thead>
<tbody>
<tr ng-repeat="row in $ctrl.rowsToDisplay">
<td ng-repeat="column in $ctrl.columns" ng-class="'content-align-' + column.alignContent">
<div ng-if="column.allowHTML" ng-bind-html="$ctrl.sanitize(column.formatFunction(row[column.name]))"></div>
<div ng-if="!column.allowHTML" ng-bind="$ctrl.sanitize(column.formatFunction(row[column.name]))"></div>
</td>
</tr>
<tr ng-repeat="row in $ctrl.rowsToDisplay"
dynamic-table-row columns="$ctrl.columns" row="row"
render="$ctrl.renderSingleRow"
></tr>
</tbody>
</table>
</div>

View File

@ -1,4 +1,4 @@
import { find, filter } from 'underscore';
import { find, filter, map } from 'underscore';
import template from './dynamic-table.html';
import './dynamic-table.less';
@ -38,16 +38,45 @@ function validateItemsPerPage(value, defaultValue) {
return value > 0 ? value : defaultValue;
}
function DynamicTable($sanitize) {
function DynamicTable($compile) {
'ngInject';
this.itemsPerPage = validateItemsPerPage(this.itemsPerPage);
this.currentPage = 1;
this.columns = [];
this.rows = [];
this.sortedRows = [];
this.rowsToDisplay = [];
this.orderBy = [];
// Optimized rendering
// Instead of using two nested `ng-repeat`s by rows and columns,
// we'll create a template for row (and update it when columns changed),
// compile it, and then use `ng-repeat` by rows and bind this template
// to each row's scope. The goal is to reduce amount of scopes and watchers
// from `count(rows) * count(cols)` to `count(rows)`. The major disadvantage
// is that cell markup should be specified here instead of template.
function createRowRenderTemplate(columns) {
const rowTemplate = map(columns, (column, index) => {
switch (column.displayAs) {
case 'json':
return `
<dynamic-table-json-cell column="columns[${index}]"
value="row[columns[${index}].name]"></dynamic-table-json-cell>
`;
default:
return `
<dynamic-table-default-cell column="columns[${index}]"
value="row[columns[${index}].name]"></dynamic-table-default-cell>
`;
}
}).join('');
return $compile(rowTemplate);
}
this.renderSingleRow = null;
this.onColumnHeaderClick = (column) => {
const orderBy = find(this.orderBy, item => item.name === column.name);
if (orderBy) {
@ -76,6 +105,7 @@ function DynamicTable($sanitize) {
if (changes.columns) {
this.columns = changes.columns.currentValue;
this.orderBy = [];
this.renderSingleRow = createRowRenderTemplate(this.columns);
}
if (changes.rows) {
@ -92,8 +122,6 @@ function DynamicTable($sanitize) {
this.rowsToDisplay = getRowsForPage(this.sortedRows, this.currentPage, this.itemsPerPage);
};
this.sanitize = value => $sanitize(value);
this.sortIcon = (column) => {
const orderBy = find(this.orderBy, item => item.name === column.name);
if (orderBy) {

View File

@ -0,0 +1,41 @@
import { isUndefined, isString } from 'underscore';
import renderJsonView from './json-view-interactive';
import template from './template.html';
const MAX_JSON_SIZE = 10000;
export default function init(ngModule) {
ngModule.directive('dynamicTableJsonCell', () => ({
template,
restrict: 'E',
replace: true,
scope: {
column: '=',
value: '=',
},
link: ($scope, $element) => {
const container = $element.find('.json-cell-valid');
$scope.isValid = false;
$scope.parsedValue = null;
$scope.$watch('value', () => {
$scope.parsedValue = null;
$scope.isValid = false;
if (isString($scope.value) && ($scope.value.length <= MAX_JSON_SIZE)) {
try {
$scope.parsedValue = JSON.parse($scope.value);
$scope.isValid = !isUndefined($scope.parsedValue);
} catch (e) {
$scope.parsedValue = null;
}
}
container.empty();
if ($scope.isValid) {
renderJsonView(container, $scope.parsedValue);
}
});
},
}));
}

View File

@ -0,0 +1,180 @@
import { isFunction, isArray, isObject, isString, isNumber, each, keys } from 'underscore';
import $ from 'jquery';
import './json-view-interactive.less';
function getCountComment(count) {
return ' // ' + count + ' ' + (count === 1 ? 'item' : 'items');
}
function initToggle(toggle, toggleBlockFn) {
if (isFunction(toggleBlockFn)) {
let visible = false;
const icon = $('<i>').addClass('fa fa-caret-right').appendTo(toggle.empty());
toggleBlockFn(visible);
toggle.on('click', () => {
visible = !visible;
icon.toggleClass('fa-caret-right fa-caret-down');
toggleBlockFn(visible);
});
} else {
toggle.addClass('hidden');
}
}
function createRenderNestedBlock(block, ellipsis, values, renderKeys) {
return (show) => {
if (show) {
ellipsis.addClass('hidden');
block.removeClass('hidden').empty();
each(values, (val, key) => {
const nestedBlock = $('<span>').addClass('jvi-item').appendTo(block);
const toggle = $('<span>').addClass('jvi-toggle').appendTo(nestedBlock);
if (renderKeys) {
const keyWrapper = $('<span>').addClass('jvi-object-key').appendTo(nestedBlock);
// eslint-disable-next-line no-use-before-define
renderString(keyWrapper, key);
$('<span>').addClass('jvi-punctuation').text(': ').appendTo(nestedBlock);
}
// eslint-disable-next-line no-use-before-define
const toggleBlockFn = renderValue(nestedBlock, val, true);
initToggle(toggle, toggleBlockFn);
});
} else {
block.addClass('hidden').empty();
ellipsis.removeClass('hidden');
}
};
}
function renderComma($element) {
return $('<span>').addClass('jvi-punctuation').text(',').appendTo($element);
}
function renderEllipsis($element) {
const result = $('<span>')
.addClass('jvi-punctuation jvi-ellipsis')
.html('&hellip;')
.appendTo($element)
.on('click', () => {
result.parents('.jvi-item').eq(0).find('.jvi-toggle').trigger('click');
});
return result;
}
function renderPrimitive($element, value, comma) {
$('<span>').addClass('jvi-value jvi-primitive').text('' + value).appendTo($element);
if (comma) {
renderComma($element);
}
return null;
}
function renderString($element, value, comma) {
$('<span>').addClass('jvi-punctuation jvi-string').text('"').appendTo($element);
$('<span>').addClass('jvi-value jvi-string').text(value).appendTo($element);
$('<span>').addClass('jvi-punctuation jvi-string').text('"').appendTo($element);
if (comma) {
renderComma($element);
}
return null;
}
function renderArray($element, values, comma) {
const count = values.length;
let result = null;
$('<span>').addClass('jvi-punctuation').text('[').appendTo($element);
if (count > 0) {
const ellipsis = renderEllipsis($element);
const block = $('<span>').addClass('jvi-block hidden').appendTo($element);
result = createRenderNestedBlock(block, ellipsis, values, false);
}
$('<span>').addClass('jvi-punctuation').text(']').appendTo($element);
if (comma) {
renderComma($element);
}
if (count > 0) {
const comment = $('<span>').addClass('jvi-comment').text(getCountComment(count))
.appendTo($element);
const prevResult = result;
result = (show) => {
if (show) {
comment.addClass('hidden');
} else {
comment.removeClass('hidden');
}
if (prevResult) {
prevResult(show);
}
};
}
return result;
}
function renderObject($element, value, comma) {
const count = keys(value).length;
let result = null;
$('<span>').addClass('jvi-punctuation').text('{').appendTo($element);
if (count > 0) {
const ellipsis = renderEllipsis($element);
const block = $('<span>').addClass('jvi-block hidden').appendTo($element);
result = createRenderNestedBlock(block, ellipsis, value, true);
}
$('<span>').addClass('jvi-punctuation').text('}').appendTo($element);
if (comma) {
renderComma($element);
}
if (count > 0) {
const comment = $('<span>').addClass('jvi-comment').text(getCountComment(count))
.appendTo($element);
const prevResult = result;
result = (show) => {
if (show) {
comment.addClass('hidden');
} else {
comment.removeClass('hidden');
}
if (prevResult) {
prevResult(show);
}
};
}
return result;
}
function renderValue($element, value, comma) {
$element = $('<span>').appendTo($element);
if (
(value === null) || (value === false) || (value === true) ||
(isNumber(value) && isFinite(value))
) {
return renderPrimitive($element, value, comma);
} else if (isString(value)) {
return renderString($element, value, comma);
} else if (isArray(value)) {
return renderArray($element, value, comma);
} else if (isObject(value)) {
return renderObject($element, value, comma);
}
}
export default function renderJsonView(container, value) {
if (container instanceof $) {
const block = $('<span>').addClass('jvi-item').appendTo(container);
const toggle = $('<span>').addClass('jvi-toggle').appendTo(block);
const toggleBlockFn = renderValue(block, value);
initToggle(toggle, toggleBlockFn);
}
}

View File

@ -0,0 +1,97 @@
@import (reference) "~bootstrap/less/variables.less";
@jvi-gutter: 20px;
@jvi-spacing: 6px;
.jvi-block {
display: block;
border-left: 1px dotted @table-border-color;
margin: 0 0 0 2px;
font-family: @font-family-monospace;
&.hidden {
display: none;
}
}
.jvi-item {
display: block;
position: relative;
padding: 0 0 0 @jvi-gutter;
.jvi-item {
margin: @jvi-spacing / 2 0;
}
}
.jvi-toggle {
position: absolute;
left: 0;
top: 0;
width: @jvi-gutter;
height: @jvi-gutter;
line-height: @jvi-gutter;
text-align: center;
cursor: pointer;
z-index: 1;
color: @text-color;
opacity: 0.5;
&:hover {
opacity: 0.8;
}
i {
vertical-align: middle;
}
&.hidden {
display: none;
}
}
.jvi-punctuation {
color: @text-color;
&.jvi-string {
color: @state-success-text;
}
&.hidden {
display: none;
}
}
.jvi-ellipsis {
padding: 0 @jvi-spacing;
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.jvi-value {
color: @state-success-text;
&.jvi-primitive {
color: @state-warning-text;
}
}
.jvi-object-key {
.jvi-value, .jvi-punctuation {
color: @brand-primary;
}
}
.jvi-comment {
color: @text-muted;
font-style: italic;
margin: 0 0 0 @jvi-spacing;
opacity: 0.5;
&.hidden {
display: none;
}
}

View File

@ -0,0 +1,4 @@
<td>
<div ng-if="!isValid" class="json-cell-invalid">{{ value }}</div>
<div ng-show="isValid" class="json-cell-valid"></div>
</td>

View File

@ -130,6 +130,7 @@ function GridEditor(clientConfig) {
{ name: 'Number', value: 'number' },
{ name: 'Date/Time', value: 'datetime' },
{ name: 'Boolean', value: 'boolean' },
{ name: 'JSON', value: 'json' },
];
$scope.currentTab = 'grid';

View File

@ -28,6 +28,8 @@ const config = {
new webpack.DefinePlugin({
ON_TEST: process.env.NODE_ENV === 'test'
}),
// Enforce angular to use jQuery instead of jqLite
new webpack.ProvidePlugin({'window.jQuery': 'jquery'}),
new webpack.optimize.CommonsChunkPlugin({
name: 'vendor',
minChunks: function (module, count) {