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) {