diff --git a/client/app/visualizations/table/Editor/ColumnEditor.jsx b/client/app/visualizations/table/Editor/ColumnEditor.jsx
new file mode 100644
index 0000000000..a79d1918f8
--- /dev/null
+++ b/client/app/visualizations/table/Editor/ColumnEditor.jsx
@@ -0,0 +1,104 @@
+import { map, keys } from 'lodash';
+import React from 'react';
+import { useDebouncedCallback } from 'use-debounce';
+import PropTypes from 'prop-types';
+import * as Grid from 'antd/lib/grid';
+import Input from 'antd/lib/input';
+import Radio from 'antd/lib/radio';
+import Checkbox from 'antd/lib/checkbox';
+import Select from 'antd/lib/select';
+import Icon from 'antd/lib/icon';
+import Tooltip from 'antd/lib/tooltip';
+
+import ColumnTypes from '../columns';
+
+export default function ColumnEditor({ column, onChange }) {
+ function handleChange(changes) {
+ onChange({ ...column, ...changes });
+ }
+
+ const [handleChangeDebounced] = useDebouncedCallback(handleChange, 200);
+
+ const AdditionalOptions = ColumnTypes[column.displayAs].Editor || null;
+
+ return (
+
+
+
+ handleChangeDebounced({ title: event.target.value })}
+ />
+
+
+ handleChange({ alignContent: event.target.value })}
+ >
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {AdditionalOptions &&
}
+
+ );
+}
+
+ColumnEditor.propTypes = {
+ column: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ title: PropTypes.string,
+ visible: PropTypes.bool,
+ alignContent: PropTypes.oneOf(['left', 'center', 'right']),
+ displayAs: PropTypes.oneOf(keys(ColumnTypes)),
+ }).isRequired,
+ onChange: PropTypes.func,
+};
+
+ColumnEditor.defaultProps = {
+ onChange: () => {},
+};
diff --git a/client/app/visualizations/table/Editor/ColumnsSettings.jsx b/client/app/visualizations/table/Editor/ColumnsSettings.jsx
new file mode 100644
index 0000000000..0e9e7ff9e1
--- /dev/null
+++ b/client/app/visualizations/table/Editor/ColumnsSettings.jsx
@@ -0,0 +1,78 @@
+import { map } from 'lodash';
+import React from 'react';
+import Collapse from 'antd/lib/collapse';
+import Icon from 'antd/lib/icon';
+import Tooltip from 'antd/lib/tooltip';
+import Typography from 'antd/lib/typography';
+import { sortableElement } from 'react-sortable-hoc';
+import { SortableContainer, DragHandle } from '@/components/sortable';
+import { EditorPropTypes } from '@/visualizations';
+
+import ColumnEditor from './ColumnEditor';
+
+const { Text } = Typography;
+
+const SortableItem = sortableElement(Collapse.Panel);
+
+export default function ColumnsSettings({ options, onOptionsChange }) {
+ function handleColumnChange(newColumn, event) {
+ if (event) {
+ event.stopPropagation();
+ }
+ const columns = map(options.columns, c => (c.name === newColumn.name ? newColumn : c));
+ onOptionsChange({ columns });
+ }
+
+ function handleColumnsReorder({ oldIndex, newIndex }) {
+ const columns = [...options.columns];
+ columns.splice(newIndex, 0, ...columns.splice(oldIndex, 1));
+ onOptionsChange({ columns });
+ }
+
+ return (
+ container.firstChild}
+ onSortEnd={handleColumnsReorder}
+ containerProps={{
+ className: 'table-visualization-editor-columns',
+ }}
+ >
+
+ {map(options.columns, (column, index) => (
+
+
+
+ {column.name}
+ {(column.title !== '') && (column.title !== column.name) && (
+ ({column.title})
+ )}
+
+
+ )}
+ extra={(
+
+ handleColumnChange({ ...column, visible: !column.visible }, event)}
+ />
+
+ )}
+ >
+
+
+ ))}
+
+
+ );
+}
+
+ColumnsSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/table/Editor/ColumnsSettings.test.js b/client/app/visualizations/table/Editor/ColumnsSettings.test.js
new file mode 100644
index 0000000000..2115eb32da
--- /dev/null
+++ b/client/app/visualizations/table/Editor/ColumnsSettings.test.js
@@ -0,0 +1,67 @@
+import React from 'react';
+import enzyme from 'enzyme';
+
+import getOptions from '../getOptions';
+import ColumnsSettings from './ColumnsSettings';
+
+function findByTestID(wrapper, testId) {
+ return wrapper.find(`[data-test="${testId}"]`);
+}
+
+function mount(options, done) {
+ const data = {
+ columns: [{ name: 'a', type: 'string' }],
+ rows: [{ a: 'test' }],
+ };
+ options = getOptions(options, data);
+ return enzyme.mount((
+ {
+ expect(changedOptions).toMatchSnapshot();
+ done();
+ }}
+ />
+ ));
+}
+
+describe('Visualizations -> Table -> Editor -> Columns Settings', () => {
+ test('Toggles column visibility', (done) => {
+ const el = mount({}, done);
+
+ findByTestID(el, 'Table.Column.a.Visibility').first().simulate('click');
+ });
+
+ test('Changes column title', (done) => {
+ const el = mount({}, done);
+ findByTestID(el, 'Table.Column.a.Name').first().simulate('click'); // expand settings
+
+ findByTestID(el, 'Table.Column.a.Title').first().simulate('change', { target: { value: 'test' } });
+ });
+
+ test('Changes column alignment', (done) => {
+ const el = mount({}, done);
+ findByTestID(el, 'Table.Column.a.Name').first().simulate('click'); // expand settings
+
+ findByTestID(el, 'Table.Column.a.AlignRight').first().find('input')
+ .simulate('change', { target: { checked: true } });
+ });
+
+ test('Enables search by column data', (done) => {
+ const el = mount({}, done);
+ findByTestID(el, 'Table.Column.a.Name').first().simulate('click'); // expand settings
+
+ findByTestID(el, 'Table.Column.a.UseForSearch').first().find('input')
+ .simulate('change', { target: { checked: true } });
+ });
+
+ test('Changes column display type', (done) => {
+ const el = mount({}, done);
+ findByTestID(el, 'Table.Column.a.Name').first().simulate('click'); // expand settings
+
+ findByTestID(el, 'Table.Column.a.DisplayAs').first().simulate('click');
+ findByTestID(el, 'Table.Column.a.DisplayAs.number').first().simulate('click');
+ });
+});
diff --git a/client/app/visualizations/table/Editor/GridSettings.jsx b/client/app/visualizations/table/Editor/GridSettings.jsx
new file mode 100644
index 0000000000..30ccd58da1
--- /dev/null
+++ b/client/app/visualizations/table/Editor/GridSettings.jsx
@@ -0,0 +1,27 @@
+import { map } from 'lodash';
+import React from 'react';
+import Select from 'antd/lib/select';
+import { EditorPropTypes } from '@/visualizations';
+
+const ALLOWED_ITEM_PER_PAGE = [5, 10, 15, 20, 25, 50, 100, 150, 200, 250];
+
+export default function GridSettings({ options, onOptionsChange }) {
+ return (
+
+
+
+
+ );
+}
+
+GridSettings.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/table/Editor/GridSettings.test.js b/client/app/visualizations/table/Editor/GridSettings.test.js
new file mode 100644
index 0000000000..1eb6209a04
--- /dev/null
+++ b/client/app/visualizations/table/Editor/GridSettings.test.js
@@ -0,0 +1,36 @@
+import React from 'react';
+import enzyme from 'enzyme';
+
+import getOptions from '../getOptions';
+import GridSettings from './GridSettings';
+
+function findByTestID(wrapper, testId) {
+ return wrapper.find(`[data-test="${testId}"]`);
+}
+
+function mount(options, done) {
+ const data = { columns: [], rows: [] };
+ options = getOptions(options, data);
+ return enzyme.mount((
+ {
+ expect(changedOptions).toMatchSnapshot();
+ done();
+ }}
+ />
+ ));
+}
+
+describe('Visualizations -> Table -> Editor -> Grid Settings', () => {
+ test('Changes items per page', (done) => {
+ const el = mount({
+ itemsPerPage: 25,
+ }, done);
+
+ findByTestID(el, 'Table.ItemsPerPage').first().simulate('click');
+ findByTestID(el, 'Table.ItemsPerPage.100').first().simulate('click');
+ });
+});
diff --git a/client/app/visualizations/table/Editor/__snapshots__/ColumnsSettings.test.js.snap b/client/app/visualizations/table/Editor/__snapshots__/ColumnsSettings.test.js.snap
new file mode 100644
index 0000000000..669b2e57e0
--- /dev/null
+++ b/client/app/visualizations/table/Editor/__snapshots__/ColumnsSettings.test.js.snap
@@ -0,0 +1,166 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Visualizations -> Table -> Editor -> Columns Settings Changes column alignment 1`] = `
+Object {
+ "columns": Array [
+ Object {
+ "alignContent": "right",
+ "allowHTML": true,
+ "allowSearch": false,
+ "booleanValues": Array [
+ "false",
+ "true",
+ ],
+ "dateTimeFormat": undefined,
+ "displayAs": "string",
+ "highlightLinks": false,
+ "imageHeight": "",
+ "imageTitleTemplate": "{{ @ }}",
+ "imageUrlTemplate": "{{ @ }}",
+ "imageWidth": "",
+ "linkOpenInNewTab": true,
+ "linkTextTemplate": "{{ @ }}",
+ "linkTitleTemplate": "{{ @ }}",
+ "linkUrlTemplate": "{{ @ }}",
+ "name": "a",
+ "numberFormat": undefined,
+ "order": 100000,
+ "title": "a",
+ "type": "string",
+ "visible": true,
+ },
+ ],
+}
+`;
+
+exports[`Visualizations -> Table -> Editor -> Columns Settings Changes column display type 1`] = `
+Object {
+ "columns": Array [
+ Object {
+ "alignContent": "left",
+ "allowHTML": true,
+ "allowSearch": false,
+ "booleanValues": Array [
+ "false",
+ "true",
+ ],
+ "dateTimeFormat": undefined,
+ "displayAs": "number",
+ "highlightLinks": false,
+ "imageHeight": "",
+ "imageTitleTemplate": "{{ @ }}",
+ "imageUrlTemplate": "{{ @ }}",
+ "imageWidth": "",
+ "linkOpenInNewTab": true,
+ "linkTextTemplate": "{{ @ }}",
+ "linkTitleTemplate": "{{ @ }}",
+ "linkUrlTemplate": "{{ @ }}",
+ "name": "a",
+ "numberFormat": undefined,
+ "order": 100000,
+ "title": "a",
+ "type": "string",
+ "visible": true,
+ },
+ ],
+}
+`;
+
+exports[`Visualizations -> Table -> Editor -> Columns Settings Changes column title 1`] = `
+Object {
+ "columns": Array [
+ Object {
+ "alignContent": "left",
+ "allowHTML": true,
+ "allowSearch": false,
+ "booleanValues": Array [
+ "false",
+ "true",
+ ],
+ "dateTimeFormat": undefined,
+ "displayAs": "string",
+ "highlightLinks": false,
+ "imageHeight": "",
+ "imageTitleTemplate": "{{ @ }}",
+ "imageUrlTemplate": "{{ @ }}",
+ "imageWidth": "",
+ "linkOpenInNewTab": true,
+ "linkTextTemplate": "{{ @ }}",
+ "linkTitleTemplate": "{{ @ }}",
+ "linkUrlTemplate": "{{ @ }}",
+ "name": "a",
+ "numberFormat": undefined,
+ "order": 100000,
+ "title": "test",
+ "type": "string",
+ "visible": true,
+ },
+ ],
+}
+`;
+
+exports[`Visualizations -> Table -> Editor -> Columns Settings Enables search by column data 1`] = `
+Object {
+ "columns": Array [
+ Object {
+ "alignContent": "left",
+ "allowHTML": true,
+ "allowSearch": true,
+ "booleanValues": Array [
+ "false",
+ "true",
+ ],
+ "dateTimeFormat": undefined,
+ "displayAs": "string",
+ "highlightLinks": false,
+ "imageHeight": "",
+ "imageTitleTemplate": "{{ @ }}",
+ "imageUrlTemplate": "{{ @ }}",
+ "imageWidth": "",
+ "linkOpenInNewTab": true,
+ "linkTextTemplate": "{{ @ }}",
+ "linkTitleTemplate": "{{ @ }}",
+ "linkUrlTemplate": "{{ @ }}",
+ "name": "a",
+ "numberFormat": undefined,
+ "order": 100000,
+ "title": "a",
+ "type": "string",
+ "visible": true,
+ },
+ ],
+}
+`;
+
+exports[`Visualizations -> Table -> Editor -> Columns Settings Toggles column visibility 1`] = `
+Object {
+ "columns": Array [
+ Object {
+ "alignContent": "left",
+ "allowHTML": true,
+ "allowSearch": false,
+ "booleanValues": Array [
+ "false",
+ "true",
+ ],
+ "dateTimeFormat": undefined,
+ "displayAs": "string",
+ "highlightLinks": false,
+ "imageHeight": "",
+ "imageTitleTemplate": "{{ @ }}",
+ "imageUrlTemplate": "{{ @ }}",
+ "imageWidth": "",
+ "linkOpenInNewTab": true,
+ "linkTextTemplate": "{{ @ }}",
+ "linkTitleTemplate": "{{ @ }}",
+ "linkUrlTemplate": "{{ @ }}",
+ "name": "a",
+ "numberFormat": undefined,
+ "order": 100000,
+ "title": "a",
+ "type": "string",
+ "visible": false,
+ },
+ ],
+}
+`;
diff --git a/client/app/visualizations/table/Editor/__snapshots__/GridSettings.test.js.snap b/client/app/visualizations/table/Editor/__snapshots__/GridSettings.test.js.snap
new file mode 100644
index 0000000000..ce088a680c
--- /dev/null
+++ b/client/app/visualizations/table/Editor/__snapshots__/GridSettings.test.js.snap
@@ -0,0 +1,7 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Visualizations -> Table -> Editor -> Grid Settings Changes items per page 1`] = `
+Object {
+ "itemsPerPage": 100,
+}
+`;
diff --git a/client/app/visualizations/table/Editor/editor.less b/client/app/visualizations/table/Editor/editor.less
new file mode 100644
index 0000000000..2290298c64
--- /dev/null
+++ b/client/app/visualizations/table/Editor/editor.less
@@ -0,0 +1,37 @@
+.table-visualization-editor-columns {
+ .ant-collapse {
+ background: transparent;
+ }
+
+ .ant-collapse-item {
+ background: #ffffff;
+
+ .drag-handle {
+ height: 20px;
+ margin-left: -16px;
+ padding: 0 16px;
+ }
+ }
+
+ .table-editor-columns-dragged-item {
+ z-index: 1;
+ }
+}
+
+.table-visualization-editor-column {
+ padding-left: 6px;
+
+ .table-visualization-editor-column-align-content {
+ display: flex;
+ align-items: stretch;
+ justify-content: stretch;
+
+ .ant-radio-button-wrapper {
+ flex-grow: 1;
+ text-align: center;
+ // fit height
+ height: 35px;
+ line-height: 33px;
+ }
+ }
+}
diff --git a/client/app/visualizations/table/Editor/index.jsx b/client/app/visualizations/table/Editor/index.jsx
new file mode 100644
index 0000000000..e390cee96c
--- /dev/null
+++ b/client/app/visualizations/table/Editor/index.jsx
@@ -0,0 +1,30 @@
+import { merge } from 'lodash';
+import React from 'react';
+import Tabs from 'antd/lib/tabs';
+import { EditorPropTypes } from '@/visualizations';
+
+import ColumnsSettings from './ColumnsSettings';
+import GridSettings from './GridSettings';
+
+import './editor.less';
+
+export default function index(props) {
+ const { options, onOptionsChange } = props;
+
+ const optionsChanged = (newOptions) => {
+ onOptionsChange(merge({}, options, newOptions));
+ };
+
+ return (
+
+ Columns}>
+
+
+ Grid}>
+
+
+
+ );
+}
+
+index.propTypes = EditorPropTypes;
diff --git a/client/app/visualizations/table/columns/__snapshots__/boolean.test.js.snap b/client/app/visualizations/table/columns/__snapshots__/boolean.test.js.snap
new file mode 100644
index 0000000000..69ea30bc85
--- /dev/null
+++ b/client/app/visualizations/table/columns/__snapshots__/boolean.test.js.snap
@@ -0,0 +1,19 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Visualizations -> Table -> Columns -> Boolean Editor Changes value for FALSE 1`] = `
+Object {
+ "booleanValues": Array [
+ "no",
+ "true",
+ ],
+}
+`;
+
+exports[`Visualizations -> Table -> Columns -> Boolean Editor Changes value for TRUE 1`] = `
+Object {
+ "booleanValues": Array [
+ "false",
+ "yes",
+ ],
+}
+`;
diff --git a/client/app/visualizations/table/columns/__snapshots__/datetime.test.js.snap b/client/app/visualizations/table/columns/__snapshots__/datetime.test.js.snap
new file mode 100644
index 0000000000..49a0097972
--- /dev/null
+++ b/client/app/visualizations/table/columns/__snapshots__/datetime.test.js.snap
@@ -0,0 +1,7 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Visualizations -> Table -> Columns -> Date/Time Editor Changes format 1`] = `
+Object {
+ "dateTimeFormat": "YYYY/MM/DD HH:ss",
+}
+`;
diff --git a/client/app/visualizations/table/columns/__snapshots__/image.test.js.snap b/client/app/visualizations/table/columns/__snapshots__/image.test.js.snap
new file mode 100644
index 0000000000..8fa4e90d37
--- /dev/null
+++ b/client/app/visualizations/table/columns/__snapshots__/image.test.js.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Visualizations -> Table -> Columns -> Image Editor Changes URL template 1`] = `
+Object {
+ "imageUrlTemplate": "http://{{ @ }}.jpeg",
+}
+`;
+
+exports[`Visualizations -> Table -> Columns -> Image Editor Changes height 1`] = `
+Object {
+ "imageHeight": "300",
+}
+`;
+
+exports[`Visualizations -> Table -> Columns -> Image Editor Changes title template 1`] = `
+Object {
+ "imageTitleTemplate": "Image {{ @ }}",
+}
+`;
+
+exports[`Visualizations -> Table -> Columns -> Image Editor Changes width 1`] = `
+Object {
+ "imageWidth": "400",
+}
+`;
diff --git a/client/app/visualizations/table/columns/__snapshots__/link.test.js.snap b/client/app/visualizations/table/columns/__snapshots__/link.test.js.snap
new file mode 100644
index 0000000000..9c335b26cd
--- /dev/null
+++ b/client/app/visualizations/table/columns/__snapshots__/link.test.js.snap
@@ -0,0 +1,25 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Visualizations -> Table -> Columns -> Link Editor Changes URL template 1`] = `
+Object {
+ "linkUrlTemplate": "http://{{ @ }}/index.html",
+}
+`;
+
+exports[`Visualizations -> Table -> Columns -> Link Editor Changes text template 1`] = `
+Object {
+ "linkTextTemplate": "Text of {{ @ }}",
+}
+`;
+
+exports[`Visualizations -> Table -> Columns -> Link Editor Changes title template 1`] = `
+Object {
+ "linkTitleTemplate": "Title of {{ @ }}",
+}
+`;
+
+exports[`Visualizations -> Table -> Columns -> Link Editor Makes link open in new tab 1`] = `
+Object {
+ "linkOpenInNewTab": true,
+}
+`;
diff --git a/client/app/visualizations/table/columns/__snapshots__/number.test.js.snap b/client/app/visualizations/table/columns/__snapshots__/number.test.js.snap
new file mode 100644
index 0000000000..1553913e9e
--- /dev/null
+++ b/client/app/visualizations/table/columns/__snapshots__/number.test.js.snap
@@ -0,0 +1,7 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Visualizations -> Table -> Columns -> Number Editor Changes format 1`] = `
+Object {
+ "numberFormat": "0.00%",
+}
+`;
diff --git a/client/app/visualizations/table/columns/__snapshots__/text.test.js.snap b/client/app/visualizations/table/columns/__snapshots__/text.test.js.snap
new file mode 100644
index 0000000000..f306cff81b
--- /dev/null
+++ b/client/app/visualizations/table/columns/__snapshots__/text.test.js.snap
@@ -0,0 +1,13 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Visualizations -> Table -> Columns -> Text Editor Enables HTML content 1`] = `
+Object {
+ "allowHTML": true,
+}
+`;
+
+exports[`Visualizations -> Table -> Columns -> Text Editor Enables highlight links option 1`] = `
+Object {
+ "highlightLinks": true,
+}
+`;
diff --git a/client/app/visualizations/table/columns/boolean.js b/client/app/visualizations/table/columns/boolean.js
deleted file mode 100644
index c096dd91ec..0000000000
--- a/client/app/visualizations/table/columns/boolean.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable react/prop-types */
-import { createBooleanFormatter } from '@/lib/value-format';
-
-export default function initBooleanColumn(column) {
- const format = createBooleanFormatter(column.booleanValues);
-
- function prepareData(row) {
- return {
- text: format(row[column.name]),
- };
- }
-
- function BooleanColumn({ row }) {
- const { text } = prepareData(row);
- return text;
- }
-
- BooleanColumn.prepareData = prepareData;
-
- return BooleanColumn;
-}
-
-initBooleanColumn.friendlyName = 'Boolean';
diff --git a/client/app/visualizations/table/columns/boolean.jsx b/client/app/visualizations/table/columns/boolean.jsx
new file mode 100644
index 0000000000..04eeadb505
--- /dev/null
+++ b/client/app/visualizations/table/columns/boolean.jsx
@@ -0,0 +1,77 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useDebouncedCallback } from 'use-debounce';
+import Input from 'antd/lib/input';
+import { createBooleanFormatter } from '@/lib/value-format';
+
+function Editor({ column, onChange }) {
+ function handleChange(index, value) {
+ const booleanValues = [...column.booleanValues];
+ booleanValues.splice(index, 1, value);
+ onChange({ booleanValues });
+ }
+
+ const [handleChangeDebounced] = useDebouncedCallback(handleChange, 200);
+
+ return (
+
+
+
+
+ handleChangeDebounced(0, event.target.value)}
+ />
+
+
+
+
+
+
+ handleChangeDebounced(1, event.target.value)}
+ />
+
+
+
+ );
+}
+
+Editor.propTypes = {
+ column: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ booleanValues: PropTypes.arrayOf(PropTypes.string),
+ }).isRequired,
+ onChange: PropTypes.func.isRequired,
+};
+
+export default function initBooleanColumn(column) {
+ const format = createBooleanFormatter(column.booleanValues);
+
+ function prepareData(row) {
+ return {
+ text: format(row[column.name]),
+ };
+ }
+
+ function BooleanColumn({ row }) { // eslint-disable-line react/prop-types
+ const { text } = prepareData(row);
+ return text;
+ }
+
+ BooleanColumn.prepareData = prepareData;
+
+ return BooleanColumn;
+}
+
+initBooleanColumn.friendlyName = 'Boolean';
+initBooleanColumn.Editor = Editor;
diff --git a/client/app/visualizations/table/columns/boolean.test.js b/client/app/visualizations/table/columns/boolean.test.js
new file mode 100644
index 0000000000..47596970e3
--- /dev/null
+++ b/client/app/visualizations/table/columns/boolean.test.js
@@ -0,0 +1,45 @@
+import React from 'react';
+import enzyme from 'enzyme';
+
+import Column from './boolean';
+
+function findByTestID(wrapper, testId) {
+ return wrapper.find(`[data-test="${testId}"]`);
+}
+
+function mount(column, done) {
+ return enzyme.mount((
+ {
+ expect(changedColumn).toMatchSnapshot();
+ done();
+ }}
+ />
+ ));
+}
+
+describe('Visualizations -> Table -> Columns -> Boolean', () => {
+ describe('Editor', () => {
+ test('Changes value for FALSE', (done) => {
+ const el = mount({
+ name: 'a',
+ booleanValues: ['false', 'true'],
+ }, done);
+
+ findByTestID(el, 'Table.ColumnEditor.Boolean.False').first().find('input')
+ .simulate('change', { target: { value: 'no' } });
+ });
+
+ test('Changes value for TRUE', (done) => {
+ const el = mount({
+ name: 'a',
+ booleanValues: ['false', 'true'],
+ }, done);
+
+ findByTestID(el, 'Table.ColumnEditor.Boolean.True').first().find('input')
+ .simulate('change', { target: { value: 'yes' } });
+ });
+ });
+});
diff --git a/client/app/visualizations/table/columns/datetime.js b/client/app/visualizations/table/columns/datetime.js
deleted file mode 100644
index 6aca69bd57..0000000000
--- a/client/app/visualizations/table/columns/datetime.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable react/prop-types */
-import { createDateTimeFormatter } from '@/lib/value-format';
-
-export default function initDateTimeColumn(column) {
- const format = createDateTimeFormatter(column.dateTimeFormat);
-
- function prepareData(row) {
- return {
- text: format(row[column.name]),
- };
- }
-
- function DateTimeColumn({ row }) {
- const { text } = prepareData(row);
- return text;
- }
-
- DateTimeColumn.prepareData = prepareData;
-
- return DateTimeColumn;
-}
-
-initDateTimeColumn.friendlyName = 'Date/Time';
diff --git a/client/app/visualizations/table/columns/datetime.jsx b/client/app/visualizations/table/columns/datetime.jsx
new file mode 100644
index 0000000000..b8fe5b6e50
--- /dev/null
+++ b/client/app/visualizations/table/columns/datetime.jsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useDebouncedCallback } from 'use-debounce';
+import Input from 'antd/lib/input';
+import Popover from 'antd/lib/popover';
+import Icon from 'antd/lib/icon';
+import { createDateTimeFormatter } from '@/lib/value-format';
+
+function Editor({ column, onChange }) {
+ const [onChangeDebounced] = useDebouncedCallback(onChange, 200);
+
+ return (
+
+
+
+
onChangeDebounced({ dateTimeFormat: event.target.value })}
+ />
+
+
+ );
+}
+
+Editor.propTypes = {
+ column: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ dateTimeFormat: PropTypes.string,
+ }).isRequired,
+ onChange: PropTypes.func.isRequired,
+};
+
+export default function initDateTimeColumn(column) {
+ const format = createDateTimeFormatter(column.dateTimeFormat);
+
+ function prepareData(row) {
+ return {
+ text: format(row[column.name]),
+ };
+ }
+
+ function DateTimeColumn({ row }) { // eslint-disable-line react/prop-types
+ const { text } = prepareData(row);
+ return text;
+ }
+
+ DateTimeColumn.prepareData = prepareData;
+
+ return DateTimeColumn;
+}
+
+initDateTimeColumn.friendlyName = 'Date/Time';
+initDateTimeColumn.Editor = Editor;
diff --git a/client/app/visualizations/table/columns/datetime.test.js b/client/app/visualizations/table/columns/datetime.test.js
new file mode 100644
index 0000000000..f3e2c903f7
--- /dev/null
+++ b/client/app/visualizations/table/columns/datetime.test.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import enzyme from 'enzyme';
+
+import Column from './datetime';
+
+function findByTestID(wrapper, testId) {
+ return wrapper.find(`[data-test="${testId}"]`);
+}
+
+function mount(column, done) {
+ return enzyme.mount((
+ {
+ expect(changedColumn).toMatchSnapshot();
+ done();
+ }}
+ />
+ ));
+}
+
+describe('Visualizations -> Table -> Columns -> Date/Time', () => {
+ describe('Editor', () => {
+ test('Changes format', (done) => {
+ const el = mount({
+ name: 'a',
+ dateTimeFormat: 'YYYY-MM-DD HH:mm:ss',
+ }, done);
+
+ findByTestID(el, 'Table.ColumnEditor.DateTime.Format').first().find('input')
+ .simulate('change', { target: { value: 'YYYY/MM/DD HH:ss' } });
+ });
+ });
+});
diff --git a/client/app/visualizations/table/columns/image.js b/client/app/visualizations/table/columns/image.js
deleted file mode 100644
index ef7db34acc..0000000000
--- a/client/app/visualizations/table/columns/image.js
+++ /dev/null
@@ -1,46 +0,0 @@
-/* eslint-disable react/prop-types */
-import { extend, trim } from 'lodash';
-import React from 'react';
-import { formatSimpleTemplate } from '@/lib/value-format';
-
-export default function initImageColumn(column) {
- function prepareData(row) {
- row = extend({ '@': row[column.name] }, row);
-
- const src = trim(formatSimpleTemplate(column.imageUrlTemplate, row));
- if (src === '') {
- return {};
- }
-
- const width = parseInt(formatSimpleTemplate(column.imageWidth, row), 10);
- const height = parseInt(formatSimpleTemplate(column.imageHeight, row), 10);
- const title = trim(formatSimpleTemplate(column.imageTitleTemplate, row));
-
- const result = { src };
-
- if (Number.isFinite(width) && (width > 0)) {
- result.width = width;
- }
- if (Number.isFinite(height) && (height > 0)) {
- result.height = height;
- }
- if (title !== '') {
- result.text = title; // `text` is used for search
- result.title = title;
- result.alt = title;
- }
-
- return result;
- }
-
- function ImageColumn({ row }) {
- const { text, ...props } = prepareData(row);
- return ;
- }
-
- ImageColumn.prepareData = prepareData;
-
- return ImageColumn;
-}
-
-initImageColumn.friendlyName = 'Image';
diff --git a/client/app/visualizations/table/columns/image.jsx b/client/app/visualizations/table/columns/image.jsx
new file mode 100644
index 0000000000..127621ca94
--- /dev/null
+++ b/client/app/visualizations/table/columns/image.jsx
@@ -0,0 +1,138 @@
+import { extend, trim } from 'lodash';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useDebouncedCallback } from 'use-debounce';
+import Input from 'antd/lib/input';
+import Popover from 'antd/lib/popover';
+import Icon from 'antd/lib/icon';
+import { formatSimpleTemplate } from '@/lib/value-format';
+
+function Editor({ column, onChange }) {
+ const [onChangeDebounced] = useDebouncedCallback(onChange, 200);
+
+ return (
+
+
+
+ onChangeDebounced({ imageUrlTemplate: event.target.value })}
+ />
+
+
+
+
+
+
+ onChangeDebounced({ imageTitleTemplate: event.target.value })}
+ />
+
+
+
+
+ All columns can be referenced using {'{{ column_name }}'}
syntax.
+ Use {'{{ @ }}'}
to reference current (this) column.
+ This syntax is applicable to URL, Title and Size options.
+
+ )}
+ placement="topLeft"
+ arrowPointAtCenter
+ >
+
+ Format specs
+
+
+
+
+ );
+}
+
+Editor.propTypes = {
+ column: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ imageUrlTemplate: PropTypes.string,
+ imageWidth: PropTypes.string,
+ imageHeight: PropTypes.string,
+ imageTitleTemplate: PropTypes.string,
+ }).isRequired,
+ onChange: PropTypes.func.isRequired,
+};
+
+export default function initImageColumn(column) {
+ function prepareData(row) {
+ row = extend({ '@': row[column.name] }, row);
+
+ const src = trim(formatSimpleTemplate(column.imageUrlTemplate, row));
+ if (src === '') {
+ return {};
+ }
+
+ const width = parseInt(formatSimpleTemplate(column.imageWidth, row), 10);
+ const height = parseInt(formatSimpleTemplate(column.imageHeight, row), 10);
+ const title = trim(formatSimpleTemplate(column.imageTitleTemplate, row));
+
+ const result = { src };
+
+ if (Number.isFinite(width) && (width > 0)) {
+ result.width = width;
+ }
+ if (Number.isFinite(height) && (height > 0)) {
+ result.height = height;
+ }
+ if (title !== '') {
+ result.text = title; // `text` is used for search
+ result.title = title;
+ result.alt = title;
+ }
+
+ return result;
+ }
+
+ function ImageColumn({ row }) { // eslint-disable-line react/prop-types
+ const { text, ...props } = prepareData(row);
+ return ;
+ }
+
+ ImageColumn.prepareData = prepareData;
+
+ return ImageColumn;
+}
+
+initImageColumn.friendlyName = 'Image';
+initImageColumn.Editor = Editor;
diff --git a/client/app/visualizations/table/columns/image.test.js b/client/app/visualizations/table/columns/image.test.js
new file mode 100644
index 0000000000..e2ba0dc41c
--- /dev/null
+++ b/client/app/visualizations/table/columns/image.test.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import enzyme from 'enzyme';
+
+import Column from './image';
+
+function findByTestID(wrapper, testId) {
+ return wrapper.find(`[data-test="${testId}"]`);
+}
+
+function mount(column, done) {
+ return enzyme.mount((
+ {
+ expect(changedColumn).toMatchSnapshot();
+ done();
+ }}
+ />
+ ));
+}
+
+describe('Visualizations -> Table -> Columns -> Image', () => {
+ describe('Editor', () => {
+ test('Changes URL template', (done) => {
+ const el = mount({
+ name: 'a',
+ imageUrlTemplate: '{{ @ }}',
+ }, done);
+
+ findByTestID(el, 'Table.ColumnEditor.Image.UrlTemplate').first().find('input')
+ .simulate('change', { target: { value: 'http://{{ @ }}.jpeg' } });
+ });
+
+ test('Changes width', (done) => {
+ const el = mount({
+ name: 'a',
+ imageWidth: null,
+ }, done);
+
+ findByTestID(el, 'Table.ColumnEditor.Image.Width').first().find('input')
+ .simulate('change', { target: { value: '400' } });
+ });
+
+ test('Changes height', (done) => {
+ const el = mount({
+ name: 'a',
+ imageHeight: null,
+ }, done);
+
+ findByTestID(el, 'Table.ColumnEditor.Image.Height').first().find('input')
+ .simulate('change', { target: { value: '300' } });
+ });
+
+ test('Changes title template', (done) => {
+ const el = mount({
+ name: 'a',
+ imageUrlTemplate: '{{ @ }}',
+ }, done);
+
+ findByTestID(el, 'Table.ColumnEditor.Image.TitleTemplate').first().find('input')
+ .simulate('change', { target: { value: 'Image {{ @ }}' } });
+ });
+ });
+});
diff --git a/client/app/visualizations/table/columns/index.js b/client/app/visualizations/table/columns/index.js
new file mode 100644
index 0000000000..f2475e378a
--- /dev/null
+++ b/client/app/visualizations/table/columns/index.js
@@ -0,0 +1,18 @@
+import initTextColumn from './text';
+import initNumberColumn from './number';
+import initDateTimeColumn from './datetime';
+import initBooleanColumn from './boolean';
+import initLinkColumn from './link';
+import initImageColumn from './image';
+import initJsonColumn from './json';
+
+// this map should contain all possible values for `column.displayAs` property
+export default {
+ string: initTextColumn,
+ number: initNumberColumn,
+ datetime: initDateTimeColumn,
+ boolean: initBooleanColumn,
+ link: initLinkColumn,
+ image: initImageColumn,
+ json: initJsonColumn,
+};
diff --git a/client/app/visualizations/table/columns/json.js b/client/app/visualizations/table/columns/json.jsx
similarity index 93%
rename from client/app/visualizations/table/columns/json.js
rename to client/app/visualizations/table/columns/json.jsx
index c92d589e96..6b5a37427f 100644
--- a/client/app/visualizations/table/columns/json.js
+++ b/client/app/visualizations/table/columns/json.jsx
@@ -1,4 +1,3 @@
-/* eslint-disable react/prop-types */
import { isString, isUndefined } from 'lodash';
import React from 'react';
import JsonViewInteractive from '@/components/json-view-interactive/JsonViewInteractive';
@@ -17,7 +16,7 @@ export default function initJsonColumn(column) {
return { text, value: undefined };
}
- function JsonColumn({ row }) {
+ function JsonColumn({ row }) { // eslint-disable-line react/prop-types
const { text, value } = prepareData(row);
if (isUndefined(value)) {
return {'' + text}
;
diff --git a/client/app/visualizations/table/columns/link.js b/client/app/visualizations/table/columns/link.js
deleted file mode 100644
index dffc06342d..0000000000
--- a/client/app/visualizations/table/columns/link.js
+++ /dev/null
@@ -1,43 +0,0 @@
-/* eslint-disable react/prop-types */
-import { extend, trim } from 'lodash';
-import React from 'react';
-import { formatSimpleTemplate } from '@/lib/value-format';
-
-export default function initLinkColumn(column) {
- function prepareData(row) {
- row = extend({ '@': row[column.name] }, row);
-
- const href = trim(formatSimpleTemplate(column.linkUrlTemplate, row));
- if (href === '') {
- return {};
- }
-
- const title = trim(formatSimpleTemplate(column.linkTitleTemplate, row));
- const text = trim(formatSimpleTemplate(column.linkTextTemplate, row));
-
- const result = {
- href,
- text: text !== '' ? text : href,
- };
-
- if (title !== '') {
- result.title = title;
- }
- if (column.linkOpenInNewTab) {
- result.target = '_blank';
- }
-
- return result;
- }
-
- function LinkColumn({ row }) {
- const { text, ...props } = prepareData(row);
- return {text};
- }
-
- LinkColumn.prepareData = prepareData;
-
- return LinkColumn;
-}
-
-initLinkColumn.friendlyName = 'Link';
diff --git a/client/app/visualizations/table/columns/link.jsx b/client/app/visualizations/table/columns/link.jsx
new file mode 100644
index 0000000000..22af268134
--- /dev/null
+++ b/client/app/visualizations/table/columns/link.jsx
@@ -0,0 +1,128 @@
+import { extend, trim } from 'lodash';
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useDebouncedCallback } from 'use-debounce';
+import Input from 'antd/lib/input';
+import Checkbox from 'antd/lib/checkbox';
+import Popover from 'antd/lib/popover';
+import Icon from 'antd/lib/icon';
+import { formatSimpleTemplate } from '@/lib/value-format';
+
+function Editor({ column, onChange }) {
+ const [onChangeDebounced] = useDebouncedCallback(onChange, 200);
+
+ return (
+
+
+
+ onChangeDebounced({ linkUrlTemplate: event.target.value })}
+ />
+
+
+
+
+ onChangeDebounced({ linkTextTemplate: event.target.value })}
+ />
+
+
+
+
+ onChangeDebounced({ linkTitleTemplate: event.target.value })}
+ />
+
+
+
+
+
+
+
+
+ All columns can be referenced using {'{{ column_name }}'}
syntax.
+ Use {'{{ @ }}'}
to reference current (this) column.
+ This syntax is applicable to URL, Text and Title options.
+
+ )}
+ placement="topLeft"
+ arrowPointAtCenter
+ >
+
+ Format specs
+
+
+
+
+ );
+}
+
+Editor.propTypes = {
+ column: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ linkUrlTemplate: PropTypes.string,
+ linkTextTemplate: PropTypes.string,
+ linkTitleTemplate: PropTypes.string,
+ linkOpenInNewTab: PropTypes.bool,
+ }).isRequired,
+ onChange: PropTypes.func.isRequired,
+};
+
+export default function initLinkColumn(column) {
+ function prepareData(row) {
+ row = extend({ '@': row[column.name] }, row);
+
+ const href = trim(formatSimpleTemplate(column.linkUrlTemplate, row));
+ if (href === '') {
+ return {};
+ }
+
+ const title = trim(formatSimpleTemplate(column.linkTitleTemplate, row));
+ const text = trim(formatSimpleTemplate(column.linkTextTemplate, row));
+
+ const result = {
+ href,
+ text: text !== '' ? text : href,
+ };
+
+ if (title !== '') {
+ result.title = title;
+ }
+ if (column.linkOpenInNewTab) {
+ result.target = '_blank';
+ }
+
+ return result;
+ }
+
+ function LinkColumn({ row }) { // eslint-disable-line react/prop-types
+ const { text, ...props } = prepareData(row);
+ return {text};
+ }
+
+ LinkColumn.prepareData = prepareData;
+
+ return LinkColumn;
+}
+
+initLinkColumn.friendlyName = 'Link';
+initLinkColumn.Editor = Editor;
diff --git a/client/app/visualizations/table/columns/link.test.js b/client/app/visualizations/table/columns/link.test.js
new file mode 100644
index 0000000000..55b3e9da2a
--- /dev/null
+++ b/client/app/visualizations/table/columns/link.test.js
@@ -0,0 +1,65 @@
+import React from 'react';
+import enzyme from 'enzyme';
+
+import Column from './link';
+
+function findByTestID(wrapper, testId) {
+ return wrapper.find(`[data-test="${testId}"]`);
+}
+
+function mount(column, done) {
+ return enzyme.mount((
+ {
+ expect(changedColumn).toMatchSnapshot();
+ done();
+ }}
+ />
+ ));
+}
+
+describe('Visualizations -> Table -> Columns -> Link', () => {
+ describe('Editor', () => {
+ test('Changes URL template', (done) => {
+ const el = mount({
+ name: 'a',
+ linkUrlTemplate: '{{ @ }}',
+ }, done);
+
+ findByTestID(el, 'Table.ColumnEditor.Link.UrlTemplate').first().find('input')
+ .simulate('change', { target: { value: 'http://{{ @ }}/index.html' } });
+ });
+
+ test('Changes text template', (done) => {
+ const el = mount({
+ name: 'a',
+ linkTextTemplate: '{{ @ }}',
+ }, done);
+
+ findByTestID(el, 'Table.ColumnEditor.Link.TextTemplate').first().find('input')
+ .simulate('change', { target: { value: 'Text of {{ @ }}' } });
+ });
+
+ test('Changes title template', (done) => {
+ const el = mount({
+ name: 'a',
+ linkTitleTemplate: '{{ @ }}',
+ }, done);
+
+ findByTestID(el, 'Table.ColumnEditor.Link.TitleTemplate').first().find('input')
+ .simulate('change', { target: { value: 'Title of {{ @ }}' } });
+ });
+
+ test('Makes link open in new tab ', (done) => {
+ const el = mount({
+ name: 'a',
+ linkOpenInNewTab: false,
+ }, done);
+
+ findByTestID(el, 'Table.ColumnEditor.Link.OpenInNewTab').first().find('input')
+ .simulate('change', { target: { checked: true } });
+ });
+ });
+});
diff --git a/client/app/visualizations/table/columns/number.js b/client/app/visualizations/table/columns/number.js
deleted file mode 100644
index 869d4c80ae..0000000000
--- a/client/app/visualizations/table/columns/number.js
+++ /dev/null
@@ -1,23 +0,0 @@
-/* eslint-disable react/prop-types */
-import { createNumberFormatter } from '@/lib/value-format';
-
-export default function initNumberColumn(column) {
- const format = createNumberFormatter(column.numberFormat);
-
- function prepareData(row) {
- return {
- text: format(row[column.name]),
- };
- }
-
- function NumberColumn({ row }) {
- const { text } = prepareData(row);
- return text;
- }
-
- NumberColumn.prepareData = prepareData;
-
- return NumberColumn;
-}
-
-initNumberColumn.friendlyName = 'Number';
diff --git a/client/app/visualizations/table/columns/number.jsx b/client/app/visualizations/table/columns/number.jsx
new file mode 100644
index 0000000000..4f3f83e947
--- /dev/null
+++ b/client/app/visualizations/table/columns/number.jsx
@@ -0,0 +1,67 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import { useDebouncedCallback } from 'use-debounce';
+import Input from 'antd/lib/input';
+import Popover from 'antd/lib/popover';
+import Icon from 'antd/lib/icon';
+import { createNumberFormatter } from '@/lib/value-format';
+
+function Editor({ column, onChange }) {
+ const [onChangeDebounced] = useDebouncedCallback(onChange, 200);
+
+ return (
+
+
+
+
onChangeDebounced({ numberFormat: event.target.value })}
+ />
+
+
+ );
+}
+
+Editor.propTypes = {
+ column: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ numberFormat: PropTypes.string,
+ }).isRequired,
+ onChange: PropTypes.func.isRequired,
+};
+
+export default function initNumberColumn(column) {
+ const format = createNumberFormatter(column.numberFormat);
+
+ function prepareData(row) {
+ return {
+ text: format(row[column.name]),
+ };
+ }
+
+ function NumberColumn({ row }) { // eslint-disable-line react/prop-types
+ const { text } = prepareData(row);
+ return text;
+ }
+
+ NumberColumn.prepareData = prepareData;
+
+ return NumberColumn;
+}
+
+initNumberColumn.friendlyName = 'Number';
+initNumberColumn.Editor = Editor;
diff --git a/client/app/visualizations/table/columns/number.test.js b/client/app/visualizations/table/columns/number.test.js
new file mode 100644
index 0000000000..d7c5e4d85f
--- /dev/null
+++ b/client/app/visualizations/table/columns/number.test.js
@@ -0,0 +1,35 @@
+import React from 'react';
+import enzyme from 'enzyme';
+
+import Column from './number';
+
+function findByTestID(wrapper, testId) {
+ return wrapper.find(`[data-test="${testId}"]`);
+}
+
+function mount(column, done) {
+ return enzyme.mount((
+ {
+ expect(changedColumn).toMatchSnapshot();
+ done();
+ }}
+ />
+ ));
+}
+
+describe('Visualizations -> Table -> Columns -> Number', () => {
+ describe('Editor', () => {
+ test('Changes format', (done) => {
+ const el = mount({
+ name: 'a',
+ numberFormat: '0[.]0000',
+ }, done);
+
+ findByTestID(el, 'Table.ColumnEditor.Number.Format').first().find('input')
+ .simulate('change', { target: { value: '0.00%' } });
+ });
+ });
+});
diff --git a/client/app/visualizations/table/columns/text.js b/client/app/visualizations/table/columns/text.js
deleted file mode 100644
index 2ee99f3edc..0000000000
--- a/client/app/visualizations/table/columns/text.js
+++ /dev/null
@@ -1,25 +0,0 @@
-/* eslint-disable react/prop-types */
-import React from 'react';
-import HtmlContent from '@/components/HtmlContent';
-import { createTextFormatter } from '@/lib/value-format';
-
-export default function initTextColumn(column) {
- const format = createTextFormatter(column.allowHTML && column.highlightLinks);
-
- function prepareData(row) {
- return {
- text: format(row[column.name]),
- };
- }
-
- function TextColumn({ row }) {
- const { text } = prepareData(row);
- return column.allowHTML ? {text} : text;
- }
-
- TextColumn.prepareData = prepareData;
-
- return TextColumn;
-}
-
-initTextColumn.friendlyName = 'Text';
diff --git a/client/app/visualizations/table/columns/text.jsx b/client/app/visualizations/table/columns/text.jsx
new file mode 100644
index 0000000000..0e47f1ab8c
--- /dev/null
+++ b/client/app/visualizations/table/columns/text.jsx
@@ -0,0 +1,68 @@
+import React from 'react';
+import PropTypes from 'prop-types';
+import Checkbox from 'antd/lib/checkbox';
+import HtmlContent from '@/components/HtmlContent';
+import { createTextFormatter } from '@/lib/value-format';
+
+function Editor({ column, onChange }) {
+ return (
+
+
+
+
+
+ {column.allowHTML && (
+
+
+
+ )}
+
+ );
+}
+
+Editor.propTypes = {
+ column: PropTypes.shape({
+ name: PropTypes.string.isRequired,
+ allowHTML: PropTypes.bool,
+ highlightLinks: PropTypes.bool,
+ }).isRequired,
+ onChange: PropTypes.func.isRequired,
+};
+
+export default function initTextColumn(column) {
+ const format = createTextFormatter(column.allowHTML && column.highlightLinks);
+
+ function prepareData(row) {
+ return {
+ text: format(row[column.name]),
+ };
+ }
+
+ function TextColumn({ row }) { // eslint-disable-line react/prop-types
+ const { text } = prepareData(row);
+ return column.allowHTML ? {text} : text;
+ }
+
+ TextColumn.prepareData = prepareData;
+
+ return TextColumn;
+}
+
+initTextColumn.friendlyName = 'Text';
+initTextColumn.Editor = Editor;
diff --git a/client/app/visualizations/table/columns/text.test.js b/client/app/visualizations/table/columns/text.test.js
new file mode 100644
index 0000000000..47b690a484
--- /dev/null
+++ b/client/app/visualizations/table/columns/text.test.js
@@ -0,0 +1,47 @@
+import React from 'react';
+import enzyme from 'enzyme';
+
+import Column from './text';
+
+function findByTestID(wrapper, testId) {
+ return wrapper.find(`[data-test="${testId}"]`);
+}
+
+function mount(column, done) {
+ return enzyme.mount((
+ {
+ expect(changedColumn).toMatchSnapshot();
+ done();
+ }}
+ />
+ ));
+}
+
+describe('Visualizations -> Table -> Columns -> Text', () => {
+ describe('Editor', () => {
+ test('Enables HTML content', (done) => {
+ const el = mount({
+ name: 'a',
+ allowHTML: false,
+ highlightLinks: false,
+ }, done);
+
+ findByTestID(el, 'Table.ColumnEditor.Text.AllowHTML').first().find('input')
+ .simulate('change', { target: { checked: true } });
+ });
+
+ test('Enables highlight links option', (done) => {
+ const el = mount({
+ name: 'a',
+ allowHTML: true,
+ highlightLinks: false,
+ }, done);
+
+ findByTestID(el, 'Table.ColumnEditor.Text.HighlightLinks').first().find('input')
+ .simulate('change', { target: { checked: true } });
+ });
+ });
+});
diff --git a/client/app/visualizations/table/getOptions.js b/client/app/visualizations/table/getOptions.js
new file mode 100644
index 0000000000..9a5d2da263
--- /dev/null
+++ b/client/app/visualizations/table/getOptions.js
@@ -0,0 +1,111 @@
+import _ from 'lodash';
+import { getColumnCleanName } from '@/services/query-result';
+import { clientConfig } from '@/services/auth';
+
+const DEFAULT_OPTIONS = {
+ itemsPerPage: 25,
+};
+
+function getColumnContentAlignment(type) {
+ return ['integer', 'float', 'boolean', 'date', 'datetime'].indexOf(type) >= 0 ? 'right' : 'left';
+}
+
+function getDefaultColumnsOptions(columns) {
+ const displayAs = {
+ integer: 'number',
+ float: 'number',
+ boolean: 'boolean',
+ date: 'datetime',
+ datetime: 'datetime',
+ };
+
+ return _.map(columns, (col, index) => ({
+ name: col.name,
+ type: col.type,
+ displayAs: displayAs[col.type] || 'string',
+ visible: true,
+ order: 100000 + index,
+ title: getColumnCleanName(col.name),
+ allowSearch: false,
+ alignContent: getColumnContentAlignment(col.type),
+ // `string` cell options
+ allowHTML: true,
+ highlightLinks: false,
+ }));
+}
+
+function getDefaultFormatOptions(column) {
+ const dateTimeFormat = {
+ date: clientConfig.dateFormat || 'DD/MM/YYYY',
+ datetime: clientConfig.dateTimeFormat || 'DD/MM/YYYY HH:mm',
+ };
+ const numberFormat = {
+ integer: clientConfig.integerFormat || '0,0',
+ float: clientConfig.floatFormat || '0,0.00',
+ };
+ return {
+ dateTimeFormat: dateTimeFormat[column.type],
+ numberFormat: numberFormat[column.type],
+ booleanValues: clientConfig.booleanValues || ['false', 'true'],
+ // `image` cell options
+ imageUrlTemplate: '{{ @ }}',
+ imageTitleTemplate: '{{ @ }}',
+ imageWidth: '',
+ imageHeight: '',
+ // `link` cell options
+ linkUrlTemplate: '{{ @ }}',
+ linkTextTemplate: '{{ @ }}',
+ linkTitleTemplate: '{{ @ }}',
+ linkOpenInNewTab: true,
+ };
+}
+
+function wereColumnsReordered(queryColumns, visualizationColumns) {
+ queryColumns = _.map(queryColumns, col => col.name);
+ visualizationColumns = _.map(visualizationColumns, col => col.name);
+
+ // Some columns may be removed - so skip them (but keep original order)
+ visualizationColumns = _.filter(visualizationColumns, col => _.includes(queryColumns, col));
+ // Pick query columns that were previously saved with viz (but keep order too)
+ queryColumns = _.filter(queryColumns, col => _.includes(visualizationColumns, col));
+
+ // Both array now have the same size as they both contains only common columns
+ // (in fact, it was an intersection, that kept order of items on both arrays).
+ // Now check for equality item-by-item; if common columns are in the same order -
+ // they were not reordered in editor
+ for (let i = 0; i < queryColumns.length; i += 1) {
+ if (visualizationColumns[i] !== queryColumns[i]) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function getColumnsOptions(columns, visualizationColumns) {
+ const options = getDefaultColumnsOptions(columns);
+
+ if ((wereColumnsReordered(columns, visualizationColumns))) {
+ visualizationColumns = _.fromPairs(_.map(
+ visualizationColumns,
+ (col, index) => [col.name, _.extend({}, col, { order: index })],
+ ));
+ } else {
+ visualizationColumns = _.fromPairs(_.map(
+ visualizationColumns,
+ col => [col.name, _.omit(col, 'order')],
+ ));
+ }
+
+ _.each(options, col => _.extend(col, visualizationColumns[col.name]));
+
+ return _.sortBy(options, 'order');
+}
+
+export default function getOptions(options, { columns }) {
+ options = { ...DEFAULT_OPTIONS, ...options };
+ options.columns = _.map(
+ getColumnsOptions(columns, options.columns),
+ col => ({ ...getDefaultFormatOptions(col), ...col }),
+ );
+ return options;
+}
diff --git a/client/app/visualizations/table/index.js b/client/app/visualizations/table/index.js
index 6a8b5dfc99..56d7b0c2db 100644
--- a/client/app/visualizations/table/index.js
+++ b/client/app/visualizations/table/index.js
@@ -1,166 +1,21 @@
-import _ from 'lodash';
-import { angular2react } from 'angular2react';
-import { getColumnCleanName } from '@/services/query-result';
-import { clientConfig } from '@/services/auth';
import { registerVisualization } from '@/visualizations';
-import editorTemplate from './table-editor.html';
-import './table-editor.less';
+import getOptions from './getOptions';
import Renderer from './Renderer';
-import { ColumnTypes } from './utils';
-
-const ALLOWED_ITEM_PER_PAGE = [5, 10, 15, 20, 25, 50, 100, 150, 200, 250];
-
-const DEFAULT_OPTIONS = {
- itemsPerPage: 25,
-};
-
-function getColumnContentAlignment(type) {
- return ['integer', 'float', 'boolean', 'date', 'datetime'].indexOf(type) >= 0 ? 'right' : 'left';
-}
-
-function getDefaultColumnsOptions(columns) {
- const displayAs = {
- integer: 'number',
- float: 'number',
- boolean: 'boolean',
- date: 'datetime',
- datetime: 'datetime',
- };
-
- return _.map(columns, (col, index) => ({
- name: col.name,
- type: col.type,
- displayAs: displayAs[col.type] || 'string',
- visible: true,
- order: 100000 + index,
- title: getColumnCleanName(col.name),
- allowSearch: false,
- alignContent: getColumnContentAlignment(col.type),
- // `string` cell options
- allowHTML: true,
- highlightLinks: false,
- }));
-}
-
-function getDefaultFormatOptions(column) {
- const dateTimeFormat = {
- date: clientConfig.dateFormat || 'DD/MM/YYYY',
- datetime: clientConfig.dateTimeFormat || 'DD/MM/YYYY HH:mm',
- };
- const numberFormat = {
- integer: clientConfig.integerFormat || '0,0',
- float: clientConfig.floatFormat || '0,0.00',
- };
- return {
- dateTimeFormat: dateTimeFormat[column.type],
- numberFormat: numberFormat[column.type],
- booleanValues: clientConfig.booleanValues || ['false', 'true'],
- // `image` cell options
- imageUrlTemplate: '{{ @ }}',
- imageTitleTemplate: '{{ @ }}',
- imageWidth: '',
- imageHeight: '',
- // `link` cell options
- linkUrlTemplate: '{{ @ }}',
- linkTextTemplate: '{{ @ }}',
- linkTitleTemplate: '{{ @ }}',
- linkOpenInNewTab: true,
- };
-}
-
-function wereColumnsReordered(queryColumns, visualizationColumns) {
- queryColumns = _.map(queryColumns, col => col.name);
- visualizationColumns = _.map(visualizationColumns, col => col.name);
-
- // Some columns may be removed - so skip them (but keep original order)
- visualizationColumns = _.filter(visualizationColumns, col => _.includes(queryColumns, col));
- // Pick query columns that were previously saved with viz (but keep order too)
- queryColumns = _.filter(queryColumns, col => _.includes(visualizationColumns, col));
-
- // Both array now have the same size as they both contains only common columns
- // (in fact, it was an intersection, that kept order of items on both arrays).
- // Now check for equality item-by-item; if common columns are in the same order -
- // they were not reordered in editor
- for (let i = 0; i < queryColumns.length; i += 1) {
- if (visualizationColumns[i] !== queryColumns[i]) {
- return true;
- }
- }
- return false;
-}
-
-function getColumnsOptions(columns, visualizationColumns) {
- const options = getDefaultColumnsOptions(columns);
-
- if ((wereColumnsReordered(columns, visualizationColumns))) {
- visualizationColumns = _.fromPairs(_.map(
- visualizationColumns,
- (col, index) => [col.name, _.extend({}, col, { order: index })],
- ));
- } else {
- visualizationColumns = _.fromPairs(_.map(
- visualizationColumns,
- col => [col.name, _.omit(col, 'order')],
- ));
- }
-
- _.each(options, col => _.extend(col, visualizationColumns[col.name]));
-
- return _.sortBy(options, 'order');
-}
-
-const GridEditor = {
- bindings: {
- data: '<',
- options: '<',
- onOptionsChange: '<',
- },
- template: editorTemplate,
- controller($scope) {
- this.allowedItemsPerPage = ALLOWED_ITEM_PER_PAGE;
- this.displayAsOptions = _.map(ColumnTypes, ({ friendlyName: name }, value) => ({ name, value }));
-
- this.currentTab = 'columns';
- this.setCurrentTab = (tab) => {
- this.currentTab = tab;
- };
-
- $scope.$watch('$ctrl.options', (options) => {
- this.onOptionsChange(options);
- }, true);
-
- this.templateHint = `
- All columns can be referenced using {{ column_name }}
syntax.
- Use {{ @ }}
to reference current (this) column.
- This syntax is applicable to URL, Title and Size options.
- `;
- },
-};
-
-export default function init(ngModule) {
- ngModule.component('gridEditor', GridEditor);
-
- ngModule.run(($injector) => {
- registerVisualization({
- type: 'TABLE',
- name: 'Table',
- getOptions: (options, { columns }) => {
- options = { ...DEFAULT_OPTIONS, ...options };
- options.columns = _.map(
- getColumnsOptions(columns, options.columns),
- col => ({ ...getDefaultFormatOptions(col), ...col }),
- );
- return options;
- },
- Renderer,
- Editor: angular2react('gridEditor', GridEditor, $injector),
-
- autoHeight: true,
- defaultRows: 14,
- defaultColumns: 3,
- minColumns: 2,
- });
+import Editor from './Editor';
+
+export default function init() {
+ registerVisualization({
+ type: 'TABLE',
+ name: 'Table',
+ getOptions,
+ Renderer,
+ Editor,
+
+ autoHeight: true,
+ defaultRows: 14,
+ defaultColumns: 3,
+ minColumns: 2,
});
}
diff --git a/client/app/visualizations/table/table-editor.html b/client/app/visualizations/table/table-editor.html
deleted file mode 100644
index 46add78a8b..0000000000
--- a/client/app/visualizations/table/table-editor.html
+++ /dev/null
@@ -1,173 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/client/app/visualizations/table/table-editor.less b/client/app/visualizations/table/table-editor.less
deleted file mode 100644
index 79f4c43ae1..0000000000
--- a/client/app/visualizations/table/table-editor.less
+++ /dev/null
@@ -1,43 +0,0 @@
-.table-editor-container {
- .btn-group.btn-group-justified {
- display: flex;
- align-items: stretch;
- justify-content: stretch;
-
- .btn {
- flex-grow: 1;
- }
-
- .btn-xs {
- padding: 4px;
- }
- }
-
- .table-editor-query-columns {
- display: flex;
- flex-direction: row;
- justify-content: flex-start;
- align-items: stretch;
- overflow: auto;
-
- > div {
- min-width: 200px;
- width: 200px;
- padding: 0 10px;
- border-right: 1px solid #f0f0f0;
- cursor: move;
-
- &:last-child {
- border-right: none;
- }
- }
-
- .table-editor-column-header {
- background: rgba(102, 136, 153, 0.05);
- padding: 10px;
- margin-left: -10px;
- margin-right: -10px;
- border-bottom: 1px solid #f0f0f0;
- }
- }
-}
diff --git a/client/app/visualizations/table/utils.js b/client/app/visualizations/table/utils.js
index 7c335af4e4..9d4a433085 100644
--- a/client/app/visualizations/table/utils.js
+++ b/client/app/visualizations/table/utils.js
@@ -3,25 +3,7 @@ import React from 'react';
import cx from 'classnames';
import Icon from 'antd/lib/icon';
import Tooltip from 'antd/lib/tooltip';
-
-import initTextColumn from './columns/text';
-import initNumberColumn from './columns/number';
-import initDateTimeColumn from './columns/datetime';
-import initBooleanColumn from './columns/boolean';
-import initLinkColumn from './columns/link';
-import initImageColumn from './columns/image';
-import initJsonColumn from './columns/json';
-
-// this map should contain all possible values for `column.displayAs` property
-export const ColumnTypes = {
- string: initTextColumn,
- number: initNumberColumn,
- datetime: initDateTimeColumn,
- boolean: initBooleanColumn,
- link: initLinkColumn,
- image: initImageColumn,
- json: initJsonColumn,
-};
+import ColumnTypes from './columns';
function nextOrderByDirection(direction) {
switch (direction) {