mirror of
https://github.com/valitydev/redash.git
synced 2024-11-06 17:15:17 +00:00
Table viz: custom cell rendering; JSON cell renderer
This commit is contained in:
parent
8fc3af7bb9
commit
015d0c6edd
16
client/app/components/dynamic-table/default-cell/index.js
Normal file
16
client/app/components/dynamic-table/default-cell/index.js
Normal 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);
|
||||
},
|
||||
}));
|
||||
}
|
@ -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>
|
25
client/app/components/dynamic-table/dynamic-table-row.js
Normal file
25
client/app/components/dynamic-table/dynamic-table-row.js
Normal 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);
|
||||
});
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
}
|
@ -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>
|
||||
|
@ -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) {
|
||||
|
41
client/app/components/dynamic-table/json-cell/index.js
Normal file
41
client/app/components/dynamic-table/json-cell/index.js
Normal 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);
|
||||
}
|
||||
});
|
||||
},
|
||||
}));
|
||||
}
|
@ -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('…')
|
||||
.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);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
||||
}
|
@ -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>
|
@ -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';
|
||||
|
@ -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) {
|
||||
|
Loading…
Reference in New Issue
Block a user