From 015d0c6eddab6e8ddb88c2e36a4dc5f245466aae Mon Sep 17 00:00:00 2001 From: Levko Kravets Date: Fri, 1 Dec 2017 13:38:34 +0200 Subject: [PATCH] Table viz: custom cell rendering; JSON cell renderer --- .../dynamic-table/default-cell/index.js | 16 ++ .../dynamic-table/default-cell/template.html | 4 + .../dynamic-table/dynamic-table-row.js | 25 +++ .../dynamic-table/dynamic-table.html | 10 +- client/app/components/dynamic-table/index.js | 36 +++- .../dynamic-table/json-cell/index.js | 41 ++++ .../json-cell/json-view-interactive.js | 180 ++++++++++++++++++ .../json-cell/json-view-interactive.less | 97 ++++++++++ .../dynamic-table/json-cell/template.html | 4 + client/app/visualizations/table/index.js | 1 + webpack.config.js | 2 + 11 files changed, 406 insertions(+), 10 deletions(-) create mode 100644 client/app/components/dynamic-table/default-cell/index.js create mode 100644 client/app/components/dynamic-table/default-cell/template.html create mode 100644 client/app/components/dynamic-table/dynamic-table-row.js create mode 100644 client/app/components/dynamic-table/json-cell/index.js create mode 100644 client/app/components/dynamic-table/json-cell/json-view-interactive.js create mode 100644 client/app/components/dynamic-table/json-cell/json-view-interactive.less create mode 100644 client/app/components/dynamic-table/json-cell/template.html diff --git a/client/app/components/dynamic-table/default-cell/index.js b/client/app/components/dynamic-table/default-cell/index.js new file mode 100644 index 00000000..0417da0c --- /dev/null +++ b/client/app/components/dynamic-table/default-cell/index.js @@ -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); + }, + })); +} diff --git a/client/app/components/dynamic-table/default-cell/template.html b/client/app/components/dynamic-table/default-cell/template.html new file mode 100644 index 00000000..65c11731 --- /dev/null +++ b/client/app/components/dynamic-table/default-cell/template.html @@ -0,0 +1,4 @@ + +
+
+ diff --git a/client/app/components/dynamic-table/dynamic-table-row.js b/client/app/components/dynamic-table/dynamic-table-row.js new file mode 100644 index 00000000..870e73ad --- /dev/null +++ b/client/app/components/dynamic-table/dynamic-table-row.js @@ -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); + }); + } + }); + }, + })); +} diff --git a/client/app/components/dynamic-table/dynamic-table.html b/client/app/components/dynamic-table/dynamic-table.html index 7a7edfc5..1fbc2d72 100644 --- a/client/app/components/dynamic-table/dynamic-table.html +++ b/client/app/components/dynamic-table/dynamic-table.html @@ -10,12 +10,10 @@ - - -
-
- - + diff --git a/client/app/components/dynamic-table/index.js b/client/app/components/dynamic-table/index.js index 8c78888b..5ef1b053 100644 --- a/client/app/components/dynamic-table/index.js +++ b/client/app/components/dynamic-table/index.js @@ -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 ` + + `; + default: + return ` + + `; + } + }).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) { diff --git a/client/app/components/dynamic-table/json-cell/index.js b/client/app/components/dynamic-table/json-cell/index.js new file mode 100644 index 00000000..69c5ee44 --- /dev/null +++ b/client/app/components/dynamic-table/json-cell/index.js @@ -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); + } + }); + }, + })); +} diff --git a/client/app/components/dynamic-table/json-cell/json-view-interactive.js b/client/app/components/dynamic-table/json-cell/json-view-interactive.js new file mode 100644 index 00000000..935c9767 --- /dev/null +++ b/client/app/components/dynamic-table/json-cell/json-view-interactive.js @@ -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 = $('').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 = $('').addClass('jvi-item').appendTo(block); + + const toggle = $('').addClass('jvi-toggle').appendTo(nestedBlock); + + if (renderKeys) { + const keyWrapper = $('').addClass('jvi-object-key').appendTo(nestedBlock); + // eslint-disable-next-line no-use-before-define + renderString(keyWrapper, key); + $('').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 $('').addClass('jvi-punctuation').text(',').appendTo($element); +} + +function renderEllipsis($element) { + const result = $('') + .addClass('jvi-punctuation jvi-ellipsis') + .html('…') + .appendTo($element) + .on('click', () => { + result.parents('.jvi-item').eq(0).find('.jvi-toggle').trigger('click'); + }); + return result; +} + +function renderPrimitive($element, value, comma) { + $('').addClass('jvi-value jvi-primitive').text('' + value).appendTo($element); + if (comma) { + renderComma($element); + } + return null; +} + +function renderString($element, value, comma) { + $('').addClass('jvi-punctuation jvi-string').text('"').appendTo($element); + $('').addClass('jvi-value jvi-string').text(value).appendTo($element); + $('').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; + + $('').addClass('jvi-punctuation').text('[').appendTo($element); + if (count > 0) { + const ellipsis = renderEllipsis($element); + const block = $('').addClass('jvi-block hidden').appendTo($element); + result = createRenderNestedBlock(block, ellipsis, values, false); + } + $('').addClass('jvi-punctuation').text(']').appendTo($element); + + if (comma) { + renderComma($element); + } + + if (count > 0) { + const comment = $('').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; + + $('').addClass('jvi-punctuation').text('{').appendTo($element); + if (count > 0) { + const ellipsis = renderEllipsis($element); + const block = $('').addClass('jvi-block hidden').appendTo($element); + result = createRenderNestedBlock(block, ellipsis, value, true); + } + $('').addClass('jvi-punctuation').text('}').appendTo($element); + + if (comma) { + renderComma($element); + } + + if (count > 0) { + const comment = $('').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 = $('').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 = $('').addClass('jvi-item').appendTo(container); + const toggle = $('').addClass('jvi-toggle').appendTo(block); + const toggleBlockFn = renderValue(block, value); + initToggle(toggle, toggleBlockFn); + } +} diff --git a/client/app/components/dynamic-table/json-cell/json-view-interactive.less b/client/app/components/dynamic-table/json-cell/json-view-interactive.less new file mode 100644 index 00000000..82e7e043 --- /dev/null +++ b/client/app/components/dynamic-table/json-cell/json-view-interactive.less @@ -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; + } +} diff --git a/client/app/components/dynamic-table/json-cell/template.html b/client/app/components/dynamic-table/json-cell/template.html new file mode 100644 index 00000000..89280a3e --- /dev/null +++ b/client/app/components/dynamic-table/json-cell/template.html @@ -0,0 +1,4 @@ + +
{{ value }}
+
+ diff --git a/client/app/visualizations/table/index.js b/client/app/visualizations/table/index.js index 6cdc899b..4f131dbe 100644 --- a/client/app/visualizations/table/index.js +++ b/client/app/visualizations/table/index.js @@ -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'; diff --git a/webpack.config.js b/webpack.config.js index 51ddf414..454db050 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -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) {