Implement a React-based table editor (#5186)

* A React table editor

* addressing comments

* Fix SelectAsyncControl error on clear

* fix tests

* more corrections

* Removed <strong>
This commit is contained in:
Maxime Beauchemin
2018-08-06 15:30:13 -07:00
committed by GitHub
parent aa14bac5c7
commit 68ba63fcd9
55 changed files with 1919 additions and 356 deletions

View File

@@ -0,0 +1,34 @@
import React from 'react';
import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha';
import { shallow } from 'enzyme';
import CollectionTable from '../../../src/CRUD/CollectionTable';
import mockDatasource from '../../fixtures/mockDatasource';
const props = {
collection: mockDatasource['7__table'].columns,
tableColumns: ['column_name', 'type', 'groupby'],
};
describe('CollectionTable', () => {
let wrapper;
let el;
beforeEach(() => {
el = <CollectionTable {...props} />;
wrapper = shallow(el);
});
it('is valid', () => {
expect(React.isValidElement(el)).to.equal(true);
});
it('renders a table', () => {
const length = mockDatasource['7__table'].columns.length;
expect(wrapper.find('table')).to.have.lengthOf(1);
expect(wrapper.find('tbody tr.row')).to.have.lengthOf(length);
});
});

View File

@@ -9,7 +9,7 @@ import DashboardBuilder from '../../../../src/dashboard/containers/DashboardBuil
// mock data
import chartQueries, { sliceId as chartId } from '../fixtures/mockChartQueries';
import datasources from '../fixtures/mockDatasource';
import datasources from '../../../fixtures/mockDatasource';
import dashboardInfo from '../fixtures/mockDashboardInfo';
import { dashboardLayout } from '../fixtures/mockDashboardLayout';
import dashboardState from '../fixtures/mockDashboardState';

View File

@@ -8,7 +8,7 @@ import Chart from '../../../../../src/dashboard/components/gridComponents/Chart'
import SliceHeader from '../../../../../src/dashboard/components/SliceHeader';
import ChartContainer from '../../../../../src/chart/ChartContainer';
import mockDatasource from '../../fixtures/mockDatasource';
import mockDatasource from '../../../../fixtures/mockDatasource';
import {
sliceEntitiesForChart as sliceEntities,
sliceId,

View File

@@ -1,4 +1,4 @@
import { datasourceId } from './mockDatasource';
import { datasourceId } from '../../../fixtures/mockDatasource';
export const sliceId = 18;

View File

@@ -1,206 +0,0 @@
export const id = 7;
export const datasourceId = `${id}__table`;
export default {
[datasourceId]: {
verbose_map: {
count: 'COUNT(*)',
__timestamp: 'Time',
sum__sum_girls: 'sum__sum_girls',
name: 'name',
avg__sum_girls: 'avg__sum_girls',
gender: 'gender',
sum_girls: 'sum_girls',
ds: 'ds',
sum__sum_boys: 'sum__sum_boys',
state: 'state',
num: 'num',
sum__num: 'sum__num',
sum_boys: 'sum_boys',
avg__num: 'avg__num',
avg__sum_boys: 'avg__sum_boys',
},
gb_cols: [['gender', 'gender'], ['name', 'name'], ['state', 'state']],
metrics: [
{
expression: 'SUM(birth_names.num)',
warning_text: null,
verbose_name: 'sum__num',
metric_name: 'sum__num',
description: null,
},
{
expression: 'AVG(birth_names.num)',
warning_text: null,
verbose_name: 'avg__num',
metric_name: 'avg__num',
description: null,
},
{
expression: 'SUM(birth_names.sum_boys)',
warning_text: null,
verbose_name: 'sum__sum_boys',
metric_name: 'sum__sum_boys',
description: null,
},
{
expression: 'AVG(birth_names.sum_boys)',
warning_text: null,
verbose_name: 'avg__sum_boys',
metric_name: 'avg__sum_boys',
description: null,
},
{
expression: 'SUM(birth_names.sum_girls)',
warning_text: null,
verbose_name: 'sum__sum_girls',
metric_name: 'sum__sum_girls',
description: null,
},
{
expression: 'AVG(birth_names.sum_girls)',
warning_text: null,
verbose_name: 'avg__sum_girls',
metric_name: 'avg__sum_girls',
description: null,
},
{
expression: 'COUNT(*)',
warning_text: null,
verbose_name: 'COUNT(*)',
metric_name: 'count',
description: null,
},
],
column_formats: {},
columns: [
{
type: 'DATETIME',
description: null,
filterable: false,
verbose_name: null,
is_dttm: true,
expression: '',
groupby: false,
column_name: 'ds',
},
{
type: 'VARCHAR(16)',
description: null,
filterable: true,
verbose_name: null,
is_dttm: false,
expression: '',
groupby: true,
column_name: 'gender',
},
{
type: 'VARCHAR(255)',
description: null,
filterable: true,
verbose_name: null,
is_dttm: false,
expression: '',
groupby: true,
column_name: 'name',
},
{
type: 'BIGINT',
description: null,
filterable: false,
verbose_name: null,
is_dttm: false,
expression: '',
groupby: false,
column_name: 'num',
},
{
type: 'VARCHAR(10)',
description: null,
filterable: true,
verbose_name: null,
is_dttm: false,
expression: '',
groupby: true,
column_name: 'state',
},
{
type: 'BIGINT',
description: null,
filterable: false,
verbose_name: null,
is_dttm: false,
expression: '',
groupby: false,
column_name: 'sum_boys',
},
{
type: 'BIGINT',
description: null,
filterable: false,
verbose_name: null,
is_dttm: false,
expression: '',
groupby: false,
column_name: 'sum_girls',
},
],
id,
granularity_sqla: [['ds', 'ds']],
name: 'birth_names',
database: {
allow_multi_schema_metadata_fetch: null,
name: 'main',
backend: 'sqlite',
},
time_grain_sqla: [
[null, 'Time Column'],
['PT1H', 'hour'],
['P1D', 'day'],
['P1W', 'week'],
['P1M', 'month'],
],
filterable_cols: [
['gender', 'gender'],
['name', 'name'],
['state', 'state'],
],
all_cols: [
['ds', 'ds'],
['gender', 'gender'],
['name', 'name'],
['num', 'num'],
['state', 'state'],
['sum_boys', 'sum_boys'],
['sum_girls', 'sum_girls'],
],
filter_select: true,
order_by_choices: [
['["ds", true]', 'ds [asc]'],
['["ds", false]', 'ds [desc]'],
['["gender", true]', 'gender [asc]'],
['["gender", false]', 'gender [desc]'],
['["name", true]', 'name [asc]'],
['["name", false]', 'name [desc]'],
['["num", true]', 'num [asc]'],
['["num", false]', 'num [desc]'],
['["state", true]', 'state [asc]'],
['["state", false]', 'state [desc]'],
['["sum_boys", true]', 'sum_boys [asc]'],
['["sum_boys", false]', 'sum_boys [desc]'],
['["sum_girls", true]', 'sum_girls [asc]'],
['["sum_girls", false]', 'sum_girls [desc]'],
],
metrics_combo: [
['count', 'COUNT(*)'],
['avg__num', 'avg__num'],
['avg__sum_boys', 'avg__sum_boys'],
['avg__sum_girls', 'avg__sum_girls'],
['sum__num', 'sum__num'],
['sum__sum_boys', 'sum__sum_boys'],
['sum__sum_girls', 'sum__sum_girls'],
],
type: 'table',
edit_url: '/tablemodelview/edit/7',
},
};

View File

@@ -1,5 +1,5 @@
import { sliceId as id } from './mockChartQueries';
import { datasourceId } from './mockDatasource';
import { datasourceId } from '../../../fixtures/mockDatasource';
export const sliceId = id;

View File

@@ -3,7 +3,7 @@ import { dashboardLayout } from './mockDashboardLayout';
import dashboardInfo from './mockDashboardInfo';
import dashboardState from './mockDashboardState';
import messageToasts from '../../messageToasts/mockMessageToasts';
import datasources from './mockDatasource';
import datasources from '../../../fixtures/mockDatasource';
import sliceEntities from './mockSliceEntities';
export default {

View File

@@ -0,0 +1,73 @@
import React from 'react';
import { Tabs } from 'react-bootstrap';
import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha';
import { shallow } from 'enzyme';
import configureStore from 'redux-mock-store';
import $ from 'jquery';
import sinon from 'sinon';
import DatasourceEditor from '../../../src/datasource/DatasourceEditor';
import mockDatasource from '../../fixtures/mockDatasource';
const props = {
datasource: mockDatasource['7__table'],
addSuccessToast: () => {},
addDangerToast: () => {},
onChange: sinon.spy(),
};
const extraColumn = {
column_name: 'new_column',
type: 'VARCHAR(10)',
description: null,
filterable: true,
verbose_name: null,
is_dttm: false,
expression: '',
groupby: true,
};
describe('DatasourceEditor', () => {
const mockStore = configureStore([]);
const store = mockStore({});
let wrapper;
let el;
let ajaxStub;
let inst;
beforeEach(() => {
ajaxStub = sinon.stub($, 'ajax');
el = <DatasourceEditor {...props} />;
wrapper = shallow(el, { context: { store } }).dive();
inst = wrapper.instance();
});
afterEach(() => {
ajaxStub.restore();
});
it('is valid', () => {
expect(React.isValidElement(el)).to.equal(true);
});
it('renders Tabs', () => {
expect(wrapper.find(Tabs)).to.have.lengthOf(1);
});
it('makes an async request', () => {
wrapper.setState({ activeTabKey: 2 });
const syncButton = wrapper.find('.sync-from-source');
expect(syncButton).to.have.lengthOf(1);
syncButton.simulate('click');
expect(ajaxStub.calledOnce).to.equal(true);
});
it('merges columns', () => {
const numCols = props.datasource.columns.length;
expect(inst.state.databaseColumns.length).to.equal(numCols);
inst.mergeColumns([extraColumn]);
expect(inst.state.databaseColumns.length).to.equal(numCols + 1);
});
});

View File

@@ -0,0 +1,59 @@
import React from 'react';
import { Modal } from 'react-bootstrap';
import { expect } from 'chai';
import { describe, it, beforeEach } from 'mocha';
import configureStore from 'redux-mock-store';
import { shallow } from 'enzyme';
import $ from 'jquery';
import sinon from 'sinon';
import DatasourceModal from '../../../src/datasource/DatasourceModal';
import DatasourceEditor from '../../../src/datasource/DatasourceEditor';
import mockDatasource from '../../fixtures/mockDatasource';
const props = {
datasource: mockDatasource['7__table'],
addSuccessToast: () => {},
addDangerToast: () => {},
onChange: sinon.spy(),
show: true,
onHide: () => {},
};
describe('DatasourceModal', () => {
const mockStore = configureStore([]);
const store = mockStore({});
let wrapper;
let el;
let ajaxStub;
let inst;
beforeEach(() => {
ajaxStub = sinon.stub($, 'ajax');
el = <DatasourceModal {...props} />;
wrapper = shallow(el, { context: { store } }).dive();
inst = wrapper.instance();
});
afterEach(() => {
ajaxStub.restore();
});
it('is valid', () => {
expect(React.isValidElement(el)).to.equal(true);
});
it('renders a Modal', () => {
expect(wrapper.find(Modal)).to.have.lengthOf(1);
});
it('renders a DatasourceEditor', () => {
expect(wrapper.find(DatasourceEditor)).to.have.lengthOf(1);
});
it('saves on confirm', () => {
inst.onConfirmSave();
expect(ajaxStub.calledOnce).to.equal(true);
});
});

View File

@@ -4,7 +4,7 @@ import configureStore from 'redux-mock-store';
import { expect } from 'chai';
import { describe, it } from 'mocha';
import { shallow } from 'enzyme';
import { Modal } from 'react-bootstrap';
import DatasourceModal from '../../../../src/datasource/DatasourceModal';
import DatasourceControl from '../../../../src/explore/components/controls/DatasourceControl';
const defaultProps = {
@@ -35,6 +35,6 @@ describe('DatasourceControl', () => {
it('renders a Modal', () => {
const wrapper = setup();
expect(wrapper.find(Modal)).to.have.lengthOf(1);
expect(wrapper.find(DatasourceModal)).to.have.lengthOf(1);
});
});

View File

@@ -1,12 +1,9 @@
/* eslint-disable no-unused-expressions */
import { it, describe } from 'mocha';
import { expect } from 'chai';
import sinon from 'sinon';
import $ from 'jquery';
import * as chartActions from '../../../src/chart/chartAction';
import * as actions from '../../../src/explore/actions/exploreActions';
import { defaultState } from '../../../src/explore/store';
import exploreReducer from '../../../src/explore/reducers/exploreReducer';
import * as actions from '../../../src/explore/actions/exploreActions';
describe('reducers', () => {
it('sets correct control value given a key and value', () => {
@@ -20,65 +17,3 @@ describe('reducers', () => {
expect(newState.controls.show_legend.value).to.equal(true);
});
});
describe('fetching actions', () => {
let dispatch;
let request;
let ajaxStub;
beforeEach(() => {
dispatch = sinon.spy();
ajaxStub = sinon.stub($, 'ajax');
});
afterEach(() => {
ajaxStub.restore();
});
describe('fetchDatasourceMetadata', () => {
const datasourceKey = '1__table';
const makeRequest = (alsoTriggerQuery = false) => {
request = actions.fetchDatasourceMetadata(datasourceKey, alsoTriggerQuery);
request(dispatch);
};
it('calls fetchDatasourceStarted', () => {
makeRequest();
expect(dispatch.args[0][0].type).to.equal(actions.FETCH_DATASOURCE_STARTED);
});
it('makes the ajax request', () => {
makeRequest();
expect(ajaxStub.calledOnce).to.be.true;
});
it('calls correct url', () => {
const url = `/superset/fetch_datasource_metadata?datasourceKey=${datasourceKey}`;
makeRequest();
expect(ajaxStub.getCall(0).args[0].url).to.equal(url);
});
it('calls correct actions on error', () => {
ajaxStub.yieldsTo('error', { responseJSON: { error: 'error text' } });
makeRequest();
expect(dispatch.callCount).to.equal(2);
expect(dispatch.getCall(1).args[0].type).to.equal(actions.FETCH_DATASOURCE_FAILED);
});
it('calls correct actions on success', () => {
ajaxStub.yieldsTo('success', { data: '' });
makeRequest();
expect(dispatch.callCount).to.equal(4);
expect(dispatch.getCall(1).args[0].type).to.equal(actions.SET_DATASOURCE);
expect(dispatch.getCall(2).args[0].type).to.equal(actions.FETCH_DATASOURCE_SUCCEEDED);
expect(dispatch.getCall(3).args[0].type).to.equal(actions.RESET_FIELDS);
});
it('triggers query if flag is set', () => {
ajaxStub.yieldsTo('success', { data: '' });
makeRequest(true);
expect(dispatch.callCount).to.equal(5);
expect(dispatch.getCall(4).args[0].type).to.equal(chartActions.TRIGGER_QUERY);
});
});
});

View File

@@ -4,7 +4,7 @@ import { describe, it } from 'mocha';
import { expect } from 'chai';
import { user } from './fixtures';
import CreatedContent from '../../../src/profile/components/CreatedContent';
import TableLoader from '../../../src/profile/components/TableLoader';
import TableLoader from '../../../src/components/TableLoader';
describe('CreatedContent', () => {

View File

@@ -5,7 +5,7 @@ import { expect } from 'chai';
import { user } from './fixtures';
import Favorites from '../../../src/profile/components/Favorites';
import TableLoader from '../../../src/profile/components/TableLoader';
import TableLoader from '../../../src/components/TableLoader';
describe('Favorites', () => {
const mockedProps = {

View File

@@ -5,7 +5,7 @@ import { expect } from 'chai';
import { user } from './fixtures';
import RecentActivity from '../../../src/profile/components/RecentActivity';
import TableLoader from '../../../src/profile/components/TableLoader';
import TableLoader from '../../../src/components/TableLoader';
describe('RecentActivity', () => {

View File

@@ -9,10 +9,6 @@ import { table, defaultQueryEditor, databases, tables } from './fixtures';
import SqlEditorLeftBar from '../../../src/SqlLab/components/SqlEditorLeftBar';
import TableElement from '../../../src/SqlLab/components/TableElement';
global.notify = {
error: () => {},
};
describe('SqlEditorLeftBar', () => {
const mockedProps = {
actions: {