[Explore] Altered Slice Tag (#3668)

* Added altered tag to explore slice view and fixes #3616

* unit tests

* Moved getDiffs logic into AlteredSliceTag

* code style fixs
This commit is contained in:
Jeff Niu
2017-11-10 21:33:31 -08:00
committed by Maxime Beauchemin
parent 83e6807fa0
commit 4d48d5d854
6 changed files with 510 additions and 4 deletions

View File

@@ -0,0 +1,145 @@
import React from 'react';
import PropTypes from 'prop-types';
import { Table, Tr, Td, Thead, Th } from 'reactable';
import { isEqual, isEmpty } from 'underscore';
import TooltipWrapper from './TooltipWrapper';
import { controls } from '../explore/stores/controls';
import ModalTrigger from './ModalTrigger';
import { t } from '../locales';
const propTypes = {
origFormData: PropTypes.object.isRequired,
currentFormData: PropTypes.object.isRequired,
};
export default class AlteredSliceTag extends React.Component {
constructor(props) {
super(props);
const diffs = this.getDiffs(props);
this.state = { diffs, hasDiffs: !isEmpty(diffs) };
}
componentWillReceiveProps(newProps) {
// Update differences if need be
if (isEqual(this.props, newProps)) {
return;
}
const diffs = this.getDiffs(newProps);
this.setState({ diffs, hasDiffs: !isEmpty(diffs) });
}
getDiffs(props) {
// Returns all properties that differ in the
// current form data and the saved form data
const ofd = props.origFormData;
const cfd = props.currentFormData;
const fdKeys = Object.keys(cfd);
const diffs = {};
for (const fdKey of fdKeys) {
// Ignore values that are undefined/nonexisting in either
if (!ofd[fdKey] && !cfd[fdKey]) {
continue;
}
if (!isEqual(ofd[fdKey], cfd[fdKey])) {
diffs[fdKey] = { before: ofd[fdKey], after: cfd[fdKey] };
}
}
return diffs;
}
formatValue(value, key) {
// Format display value based on the control type
// or the value type
if (value === undefined) {
return 'N/A';
} else if (value === null) {
return 'null';
} else if (controls[key] && controls[key].type === 'FilterControl') {
if (!value.length) {
return '[]';
}
return value.map((v) => {
const filterVal = v.val.constructor === Array ? `[${v.val.join(', ')}]` : v.val;
return `${v.col} ${v.op} ${filterVal}`;
}).join(', ');
} else if (controls[key] && controls[key].type === 'BoundsControl') {
return `Min: ${value[0]}, Max: ${value[1]}`;
} else if (controls[key] && controls[key].type === 'CollectionControl') {
return value.map(v => JSON.stringify(v)).join(', ');
} else if (typeof value === 'boolean') {
return value ? 'true' : 'false';
} else if (value.constructor === Array) {
return value.length ? value.join(', ') : '[]';
} else if (typeof value === 'string' || typeof value === 'number') {
return value;
}
return JSON.stringify(value);
}
renderRows() {
const diffs = this.state.diffs;
const rows = [];
for (const key in diffs) {
rows.push(
<Tr key={key}>
<Td column="control" data={(controls[key] && controls[key].label) || key} />
<Td column="before">{this.formatValue(diffs[key].before, key)}</Td>
<Td column="after">{this.formatValue(diffs[key].after, key)}</Td>
</Tr>,
);
}
return rows;
}
renderModalBody() {
return (
<Table className="table" sortable>
<Thead>
<Th column="control">Control</Th>
<Th column="before">Before</Th>
<Th column="after">After</Th>
</Thead>
{this.renderRows()}
</Table>
);
}
renderTriggerNode() {
return (
<TooltipWrapper
label="difference"
tooltip={t('Click to see difference')}
>
<span
className="label label-warning m-l-5"
style={{ fontSize: '12px' }}
>
{t('Altered')}
</span>
</TooltipWrapper>
);
}
render() {
// Return nothing if there are no differences
if (!this.state.hasDiffs) {
return null;
}
// Render the label-warning 'Altered' tag which the user may
// click to open a modal containing a table summarizing the
// differences in the slice
return (
<ModalTrigger
animation
triggerNode={this.renderTriggerNode()}
modalTitle={t('Slice changes')}
bsSize="large"
modalBody={this.renderModalBody()}
/>
);
}
}
AlteredSliceTag.propTypes = propTypes;

View File

@@ -4,6 +4,7 @@ import PropTypes from 'prop-types';
import { chartPropType } from '../../chart/chartReducer';
import ExploreActionButtons from './ExploreActionButtons';
import EditableTitle from '../../components/EditableTitle';
import AlteredSliceTag from '../../components/AlteredSliceTag';
import FaveStar from '../../components/FaveStar';
import TooltipWrapper from '../../components/TooltipWrapper';
import Timer from '../../components/Timer';
@@ -54,6 +55,13 @@ class ExploreChartHeader extends React.PureComponent {
});
}
renderAlteredTag() {
const origFormData = (this.props.slice && this.props.slice.form_data) || {};
const currentFormData = this.props.form_data;
const tagProps = { origFormData, currentFormData };
return (<AlteredSliceTag {...tagProps} />);
}
renderChartTitle() {
let title;
if (this.props.slice) {
@@ -106,6 +114,8 @@ class ExploreChartHeader extends React.PureComponent {
</span>
}
{this.renderAlteredTag()}
<div className="pull-right">
{this.props.chart.chartStatus === 'success' &&
queryResponse &&

View File

@@ -96,6 +96,7 @@
"srcdoc-polyfill": "^1.0.0",
"supercluster": "https://github.com/georgeke/supercluster/tarball/ac3492737e7ce98e07af679623aad452373bbc40",
"urijs": "^1.18.10",
"underscore": "^1.8.3",
"viewport-mercator-project": "^2.1.0"
},
"devDependencies": {

View File

@@ -0,0 +1,235 @@
import React from 'react';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
import { Table, Thead, Td, Th, Tr } from 'reactable';
import AlteredSliceTag from '../../../javascripts/components/AlteredSliceTag';
import ModalTrigger from '../../../javascripts/components/ModalTrigger';
import TooltipWrapper from '../../../javascripts/components/TooltipWrapper';
const defaultProps = {
origFormData: {
filters: [{ col: 'a', op: '==', val: 'hello' }],
y_axis_bounds: [10, 20],
column_collection: [{ 1: 'a', b: ['6', 'g'] }],
bool: false,
alpha: undefined,
gucci: [1, 2, 3, 4],
never: 5,
ever: { a: 'b', c: 'd' },
},
currentFormData: {
filters: [{ col: 'b', op: 'in', val: ['hello', 'my', 'name'] }],
y_axis_bounds: [15, 16],
column_collection: [{ 1: 'a', b: [9, '15'], t: 'gggg' }],
bool: true,
alpha: null,
gucci: ['a', 'b', 'c', 'd'],
never: 10,
ever: { x: 'y', z: 'z' },
},
};
const expectedDiffs = {
filters: {
before: [{ col: 'a', op: '==', val: 'hello' }],
after: [{ col: 'b', op: 'in', val: ['hello', 'my', 'name'] }],
},
y_axis_bounds: {
before: [10, 20],
after: [15, 16],
},
column_collection: {
before: [{ 1: 'a', b: ['6', 'g'] }],
after: [{ 1: 'a', b: [9, '15'], t: 'gggg' }],
},
bool: {
before: false,
after: true,
},
gucci: {
before: [1, 2, 3, 4],
after: ['a', 'b', 'c', 'd'],
},
never: {
before: 5,
after: 10,
},
ever: {
before: { a: 'b', c: 'd' },
after: { x: 'y', z: 'z' },
},
};
describe('AlteredSliceTag', () => {
let wrapper;
let props;
beforeEach(() => {
props = Object.assign({}, defaultProps);
wrapper = shallow(<AlteredSliceTag {...props} />);
});
it('correctly determines form data differences', () => {
const diffs = wrapper.instance().getDiffs(props);
expect(diffs).to.deep.equal(expectedDiffs);
expect(wrapper.instance().state.diffs).to.deep.equal(expectedDiffs);
expect(wrapper.instance().state.hasDiffs).to.equal(true);
});
it('does not run when there are no differences', () => {
props = {
origFormData: props.origFormData,
currentFormData: props.origFormData,
};
wrapper = shallow(<AlteredSliceTag {...props} />);
expect(wrapper.instance().state.diffs).to.deep.equal({});
expect(wrapper.instance().state.hasDiffs).to.equal(false);
expect(wrapper.instance().render()).to.equal(null);
});
it('sets new diffs when receiving new props', () => {
const newProps = {
currentFormData: Object.assign({}, props.currentFormData),
origFormData: Object.assign({}, props.origFormData),
};
newProps.currentFormData.beta = 10;
wrapper = shallow(<AlteredSliceTag {...props} />);
wrapper.instance().componentWillReceiveProps(newProps);
const newDiffs = wrapper.instance().state.diffs;
const expectedBeta = { before: undefined, after: 10 };
expect(newDiffs.beta).to.deep.equal(expectedBeta);
});
it('does not set new state when props are the same', () => {
const currentDiff = wrapper.instance().state.diffs;
wrapper.instance().componentWillReceiveProps(props);
// Check equal references
expect(wrapper.instance().state.diffs).to.equal(currentDiff);
});
it('renders a ModalTrigger', () => {
expect(wrapper.find(ModalTrigger)).to.have.lengthOf(1);
});
describe('renderTriggerNode', () => {
it('renders a TooltipWrapper', () => {
const triggerNode = shallow(<div>{wrapper.instance().renderTriggerNode()}</div>);
expect(triggerNode.find(TooltipWrapper)).to.have.lengthOf(1);
});
});
describe('renderModalBody', () => {
it('renders a Table', () => {
const modalBody = shallow(<div>{wrapper.instance().renderModalBody()}</div>);
expect(modalBody.find(Table)).to.have.lengthOf(1);
});
it('renders a Thead', () => {
const modalBody = shallow(<div>{wrapper.instance().renderModalBody()}</div>);
expect(modalBody.find(Thead)).to.have.lengthOf(1);
});
it('renders Th', () => {
const modalBody = shallow(<div>{wrapper.instance().renderModalBody()}</div>);
const th = modalBody.find(Th);
expect(th).to.have.lengthOf(3);
['control', 'before', 'after'].forEach((v, i) => {
expect(th.get(i).props.column).to.equal(v);
});
});
it('renders the correct number of Tr', () => {
const modalBody = shallow(<div>{wrapper.instance().renderModalBody()}</div>);
const tr = modalBody.find(Tr);
expect(tr).to.have.lengthOf(7);
});
it('renders the correct number of Td', () => {
const modalBody = shallow(<div>{wrapper.instance().renderModalBody()}</div>);
const td = modalBody.find(Td);
expect(td).to.have.lengthOf(21);
['control', 'before', 'after'].forEach((v, i) => {
expect(td.get(i).props.column).to.equal(v);
});
});
});
describe('renderRows', () => {
it('returns an array of rows with one Tr and three Td', () => {
const rows = wrapper.instance().renderRows();
expect(rows).to.have.lengthOf(7);
const fakeRow = shallow(<div>{rows[0]}</div>);
expect(fakeRow.find(Tr)).to.have.lengthOf(1);
expect(fakeRow.find(Td)).to.have.lengthOf(3);
});
});
describe('formatValue', () => {
it('returns "N/A" for undefined values', () => {
expect(wrapper.instance().formatValue(undefined, 'b')).to.equal('N/A');
});
it('returns "null" for null values', () => {
expect(wrapper.instance().formatValue(null, 'b')).to.equal('null');
});
it('returns "Max" and "Min" for BoundsControl', () => {
expect(wrapper.instance().formatValue([5, 6], 'y_axis_bounds')).to.equal(
'Min: 5, Max: 6',
);
});
it('returns stringified objects for CollectionControl', () => {
const value = [{ 1: 2, alpha: 'bravo' }, { sent: 'imental', w0ke: 5 }];
const expected = '{"1":2,"alpha":"bravo"}, {"sent":"imental","w0ke":5}';
expect(wrapper.instance().formatValue(value, 'column_collection')).to.equal(expected);
});
it('returns boolean values as string', () => {
expect(wrapper.instance().formatValue(true, 'b')).to.equal('true');
expect(wrapper.instance().formatValue(false, 'b')).to.equal('false');
});
it('returns Array joined by commas', () => {
const value = [5, 6, 7, 8, 'hello', 'goodbye'];
const expected = '5, 6, 7, 8, hello, goodbye';
expect(wrapper.instance().formatValue(value)).to.equal(expected);
});
it('stringifies objects', () => {
const value = { 1: 2, alpha: 'bravo' };
const expected = '{"1":2,"alpha":"bravo"}';
expect(wrapper.instance().formatValue(value)).to.equal(expected);
});
it('does nothing to strings and numbers', () => {
expect(wrapper.instance().formatValue(5)).to.equal(5);
expect(wrapper.instance().formatValue('hello')).to.equal('hello');
});
it('returns "[]" for empty filters', () => {
expect(wrapper.instance().formatValue([], 'filters')).to.equal('[]');
});
it('correctly formats filters with array values', () => {
const filters = [
{ col: 'a', op: 'in', val: ['1', 'g', '7', 'ho'] },
{ col: 'b', op: 'not in', val: ['hu', 'ho', 'ha'] },
];
const expected = 'a in [1, g, 7, ho], b not in [hu, ho, ha]';
expect(wrapper.instance().formatValue(filters, 'filters')).to.equal(expected);
});
it('correctly formats filters with string values', () => {
const filters = [
{ col: 'a', op: '==', val: 'gucci' },
{ col: 'b', op: 'LIKE', val: 'moshi moshi' },
];
const expected = 'a == gucci, b LIKE moshi moshi';
expect(wrapper.instance().formatValue(filters, 'filters')).to.equal(expected);
});
});
});

View File

@@ -684,10 +684,40 @@ def merge_extra_filters(form_data):
'__time_origin': 'druid_time_origin',
'__granularity': 'granularity',
}
# Grab list of existing filters 'keyed' on the column and operator
def get_filter_key(f):
return f['col'] + '__' + f['op']
existing_filters = {}
for existing in form_data['filters']:
existing_filters[get_filter_key(existing)] = existing['val']
for filtr in form_data['extra_filters']:
if date_options.get(filtr['col']): # merge date options
# Pull out time filters/options and merge into form data
if date_options.get(filtr['col']):
if filtr.get('val'):
form_data[date_options[filtr['col']]] = filtr['val']
else:
form_data['filters'] += [filtr] # merge col filters
elif filtr['val'] and len(filtr['val']):
# Merge column filters
filter_key = get_filter_key(filtr)
if filter_key in existing_filters:
# Check if the filter already exists
if isinstance(filtr['val'], list):
if isinstance(existing_filters[filter_key], list):
# Add filters for unequal lists
# order doesn't matter
if (
sorted(existing_filters[filter_key]) !=
sorted(filtr['val'])
):
form_data['filters'] += [filtr]
else:
form_data['filters'] += [filtr]
else:
# Do not add filter if same value already exists
if filtr['val'] != existing_filters[filter_key]:
form_data['filters'] += [filtr]
else:
# Filter not found, add it
form_data['filters'] += [filtr]
# Remove extra filters from the form data since no longer needed
del form_data['extra_filters']

View File

@@ -110,6 +110,89 @@ class UtilsTestCase(unittest.TestCase):
merge_extra_filters(form_data)
self.assertEquals(form_data, expected)
def test_merge_extra_filters_ignores_empty_filters(self):
form_data = {'extra_filters': [
{'col': 'a', 'op': 'in', 'val': ''},
{'col': 'B', 'op': '==', 'val': []},
]}
expected = {'filters': []}
merge_extra_filters(form_data)
self.assertEquals(form_data, expected)
def test_merge_extra_filters_ignores_equal_filters(self):
form_data = {
'extra_filters': [
{'col': 'a', 'op': 'in', 'val': 'someval'},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2']},
],
'filters': [
{'col': 'a', 'op': 'in', 'val': 'someval'},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2']},
],
}
expected = {'filters': [
{'col': 'a', 'op': 'in', 'val': 'someval'},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2']},
]}
merge_extra_filters(form_data)
self.assertEquals(form_data, expected)
def test_merge_extra_filters_merges_different_val_types(self):
form_data = {
'extra_filters': [
{'col': 'a', 'op': 'in', 'val': ['g1', 'g2']},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2']},
],
'filters': [
{'col': 'a', 'op': 'in', 'val': 'someval'},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2']},
],
}
expected = {'filters': [
{'col': 'a', 'op': 'in', 'val': 'someval'},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2']},
{'col': 'a', 'op': 'in', 'val': ['g1', 'g2']},
]}
merge_extra_filters(form_data)
self.assertEquals(form_data, expected)
form_data = {
'extra_filters': [
{'col': 'a', 'op': 'in', 'val': 'someval'},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2']},
],
'filters': [
{'col': 'a', 'op': 'in', 'val': ['g1', 'g2']},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2']},
],
}
expected = {'filters': [
{'col': 'a', 'op': 'in', 'val': ['g1', 'g2']},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2']},
{'col': 'a', 'op': 'in', 'val': 'someval'},
]}
merge_extra_filters(form_data)
self.assertEquals(form_data, expected)
def test_merge_extra_filters_adds_unequal_lists(self):
form_data = {
'extra_filters': [
{'col': 'a', 'op': 'in', 'val': ['g1', 'g2', 'g3']},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2', 'c3']},
],
'filters': [
{'col': 'a', 'op': 'in', 'val': ['g1', 'g2']},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2']},
],
}
expected = {'filters': [
{'col': 'a', 'op': 'in', 'val': ['g1', 'g2']},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2']},
{'col': 'a', 'op': 'in', 'val': ['g1', 'g2', 'g3']},
{'col': 'B', 'op': '==', 'val': ['c1', 'c2', 'c3']},
]}
merge_extra_filters(form_data)
self.assertEquals(form_data, expected)
def test_datetime_f(self):
self.assertEquals(
datetime_f(datetime(1990, 9, 21, 19, 11, 19, 626096)),
@@ -119,7 +202,9 @@ class UtilsTestCase(unittest.TestCase):
self.assertEquals(datetime_f(None), '<nobr>None</nobr>')
iso = datetime.now().isoformat()[:10].split('-')
[a, b, c] = [int(v) for v in iso]
self.assertEquals(datetime_f(datetime(a, b, c)), '<nobr>00:00:00</nobr>')
self.assertEquals(
datetime_f(datetime(a, b, c)), '<nobr>00:00:00</nobr>',
)
def test_json_encoded_obj(self):
obj = {'a': 5, 'b': ['a', 'g', 5]}