refactor: table selector on dataset editor (#10914)

Co-authored-by: Maxime Beauchemin <maximebeauchemin@gmail.com>
This commit is contained in:
Lily Kuang
2020-09-28 11:16:03 -07:00
committed by GitHub
parent e52a399e18
commit e337355162
11 changed files with 1071 additions and 758 deletions

View File

@@ -48,6 +48,7 @@ describe('Datasource control', () => {
});
// create new metric
cy.get('a[role="tab"]').contains('Metrics').click();
cy.get('button').contains('Add Item', { timeout: 10000 }).click();
cy.get('input[value="<new metric>"]').click();
cy.get('input[value="<new metric>"]')
@@ -64,6 +65,7 @@ describe('Datasource control', () => {
// delete metric
cy.get('#datasource_menu').click();
cy.get('a').contains('Edit Datasource').click();
cy.get('a[role="tab"]').contains('Metrics').click();
cy.get(`input[value="${newMetricName}"]`)
.closest('tr')
.find('.fa-trash')

View File

@@ -18,267 +18,273 @@
*/
import React from 'react';
import configureStore from 'redux-mock-store';
import { shallow } from 'enzyme';
import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils';
import sinon from 'sinon';
import fetchMock from 'fetch-mock';
import thunk from 'redux-thunk';
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import DatabaseSelector from 'src/components/DatabaseSelector';
import TableSelector from 'src/components/TableSelector';
import { initialState, tables } from '../sqllab/fixtures';
const mockStore = configureStore([thunk]);
const store = mockStore(initialState);
const FETCH_SCHEMAS_ENDPOINT = 'glob:*/api/v1/database/*/schemas/*';
const GET_TABLE_ENDPOINT = 'glob:*/superset/tables/1/*/*';
const GET_TABLE_NAMES_ENDPOINT = 'glob:*/superset/tables/1/main/*';
const mockedProps = {
clearable: false,
database: { id: 1, database_name: 'main' },
dbId: 1,
formMode: false,
getDbList: sinon.stub(),
handleError: sinon.stub(),
horizontal: false,
onChange: sinon.stub(),
onDbChange: sinon.stub(),
onSchemaChange: sinon.stub(),
onTableChange: sinon.stub(),
sqlLabMode: true,
tableName: '',
tableNameSticky: true,
};
const schemaOptions = {
result: ['main', 'erf', 'superset'],
};
const selectedSchema = { label: 'main', title: 'main', value: 'main' };
const selectedTable = {
label: 'birth_names',
schema: 'main',
title: 'birth_names',
value: 'birth_names',
type: undefined,
};
async function mountAndWait(props = mockedProps) {
const mounted = mount(<TableSelector {...props} />, {
context: { store },
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
});
await waitForComponentToPaint(mounted);
return mounted;
}
describe('TableSelector', () => {
let mockedProps;
const middlewares = [thunk];
const mockStore = configureStore(middlewares);
const store = mockStore(initialState);
let wrapper;
let inst;
beforeEach(() => {
mockedProps = {
dbId: 1,
schema: 'main',
onSchemaChange: sinon.stub(),
onDbChange: sinon.stub(),
getDbList: sinon.stub(),
onTableChange: sinon.stub(),
onChange: sinon.stub(),
tableNameSticky: true,
tableName: '',
database: { id: 1, database_name: 'main' },
horizontal: false,
sqlLabMode: true,
clearable: false,
handleError: sinon.stub(),
};
wrapper = shallow(<TableSelector {...mockedProps} />, {
context: { store },
});
inst = wrapper.instance();
beforeEach(async () => {
fetchMock.reset();
wrapper = await mountAndWait();
});
it('is valid', () => {
expect(React.isValidElement(<TableSelector {...mockedProps} />)).toBe(true);
it('renders', () => {
expect(wrapper.find(TableSelector)).toExist();
expect(wrapper.find(DatabaseSelector)).toExist();
});
describe('onDatabaseChange', () => {
it('should fetch schemas', () => {
sinon.stub(inst, 'fetchSchemas');
inst.onDatabaseChange({ id: 1 });
expect(inst.fetchSchemas.getCall(0).args[0]).toBe(1);
inst.fetchSchemas.restore();
describe('change database', () => {
afterEach(fetchMock.resetHistory);
afterAll(fetchMock.reset);
it('should fetch schemas', async () => {
fetchMock.get(FETCH_SCHEMAS_ENDPOINT, { overwriteRoutes: true });
act(() => {
wrapper.find('[data-test="select-database"]').first().props().onChange({
id: 1,
database_name: 'main',
});
});
await waitForComponentToPaint(wrapper);
expect(fetchMock.calls(FETCH_SCHEMAS_ENDPOINT)).toHaveLength(1);
});
it('should clear tableOptions', () => {
inst.onDatabaseChange();
expect(wrapper.state().tableOptions).toEqual([]);
it('should fetch schema options', async () => {
fetchMock.get(FETCH_SCHEMAS_ENDPOINT, schemaOptions, {
overwriteRoutes: true,
});
act(() => {
wrapper.find('[data-test="select-database"]').first().props().onChange({
id: 1,
database_name: 'main',
});
});
await waitForComponentToPaint(wrapper);
wrapper.update();
expect(fetchMock.calls(FETCH_SCHEMAS_ENDPOINT)).toHaveLength(1);
expect(
wrapper.find('[name="select-schema"]').first().props().options,
).toEqual([
{ value: 'main', label: 'main', title: 'main' },
{ value: 'erf', label: 'erf', title: 'erf' },
{ value: 'superset', label: 'superset', title: 'superset' },
]);
});
it('should clear table options', async () => {
act(() => {
wrapper.find('[data-test="select-database"]').first().props().onChange({
id: 1,
database_name: 'main',
});
});
await waitForComponentToPaint(wrapper);
const props = wrapper.find('[name="async-select-table"]').first().props();
expect(props.isDisabled).toBe(true);
expect(props.value).toEqual(undefined);
});
});
describe('change schema', () => {
beforeEach(async () => {
fetchMock.get(FETCH_SCHEMAS_ENDPOINT, schemaOptions, {
overwriteRoutes: true,
});
});
afterEach(fetchMock.resetHistory);
afterAll(fetchMock.reset);
it('should fetch table', async () => {
fetchMock.get(GET_TABLE_NAMES_ENDPOINT, { overwriteRoutes: true });
act(() => {
wrapper.find('[data-test="select-database"]').first().props().onChange({
id: 1,
database_name: 'main',
});
});
await waitForComponentToPaint(wrapper);
act(() => {
wrapper
.find('[name="select-schema"]')
.first()
.props()
.onChange(selectedSchema);
});
await waitForComponentToPaint(wrapper);
expect(fetchMock.calls(GET_TABLE_NAMES_ENDPOINT)).toHaveLength(1);
});
it('should fetch table options', async () => {
fetchMock.get(GET_TABLE_NAMES_ENDPOINT, tables, {
overwriteRoutes: true,
});
act(() => {
wrapper.find('[data-test="select-database"]').first().props().onChange({
id: 1,
database_name: 'main',
});
});
await waitForComponentToPaint(wrapper);
act(() => {
wrapper
.find('[name="select-schema"]')
.first()
.props()
.onChange(selectedSchema);
});
await waitForComponentToPaint(wrapper);
expect(
wrapper.find('[name="select-schema"]').first().props().value[0],
).toEqual(selectedSchema);
expect(fetchMock.calls(GET_TABLE_NAMES_ENDPOINT)).toHaveLength(1);
const { options } = wrapper.find('[name="select-table"]').first().props();
expect({ options }).toEqual(tables);
});
});
describe('change table', () => {
beforeEach(async () => {
fetchMock.get(GET_TABLE_NAMES_ENDPOINT, tables, {
overwriteRoutes: true,
});
});
it('should change table value', async () => {
act(() => {
wrapper
.find('[name="select-schema"]')
.first()
.props()
.onChange(selectedSchema);
});
await waitForComponentToPaint(wrapper);
act(() => {
wrapper
.find('[name="select-table"]')
.first()
.props()
.onChange(selectedTable);
});
await waitForComponentToPaint(wrapper);
expect(
wrapper.find('[name="select-table"]').first().props().value,
).toEqual('birth_names');
});
it('should call onTableChange with schema from table object', async () => {
act(() => {
wrapper
.find('[name="select-schema"]')
.first()
.props()
.onChange(selectedSchema);
});
await waitForComponentToPaint(wrapper);
act(() => {
wrapper
.find('[name="select-table"]')
.first()
.props()
.onChange(selectedTable);
});
await waitForComponentToPaint(wrapper);
expect(mockedProps.onTableChange.getCall(0).args[0]).toBe('birth_names');
expect(mockedProps.onTableChange.getCall(0).args[1]).toBe('main');
});
});
describe('getTableNamesBySubStr', () => {
const GET_TABLE_NAMES_GLOB = 'glob:*/superset/tables/1/main/*';
afterEach(fetchMock.resetHistory);
afterAll(fetchMock.reset);
it('should handle empty', () =>
inst.getTableNamesBySubStr('').then(data => {
expect(data).toEqual({ options: [] });
return Promise.resolve();
}));
it('should handle table name', () => {
fetchMock.get(GET_TABLE_NAMES_GLOB, tables, { overwriteRoutes: true });
return wrapper
.instance()
.getTableNamesBySubStr('my table')
.then(data => {
expect(fetchMock.calls(GET_TABLE_NAMES_GLOB)).toHaveLength(1);
expect(data).toEqual({
options: [
{
value: 'birth_names',
schema: 'main',
label: 'birth_names',
title: 'birth_names',
},
{
value: 'energy_usage',
schema: 'main',
label: 'energy_usage',
title: 'energy_usage',
},
{
value: 'wb_health_population',
schema: 'main',
label: 'wb_health_population',
title: 'wb_health_population',
},
],
});
return Promise.resolve();
});
});
it('should escape schema and table names', () => {
const GET_TABLE_GLOB = 'glob:*/superset/tables/1/*/*';
wrapper.setProps({ schema: 'slashed/schema' });
fetchMock.get(GET_TABLE_GLOB, tables, { overwriteRoutes: true });
return wrapper
.instance()
.getTableNamesBySubStr('slashed/table')
.then(() => {
expect(fetchMock.lastUrl(GET_TABLE_GLOB)).toContain(
'/slashed%252Fschema/slashed%252Ftable',
);
return Promise.resolve();
});
});
});
describe('fetchTables', () => {
const FETCH_TABLES_GLOB = 'glob:*/superset/tables/1/main/*/*/';
afterEach(fetchMock.resetHistory);
afterAll(fetchMock.reset);
it('should clear table options', () => {
inst.fetchTables(true);
expect(wrapper.state().tableOptions).toEqual([]);
});
it('should fetch table options', () => {
fetchMock.get(FETCH_TABLES_GLOB, tables, { overwriteRoutes: true });
return inst.fetchTables(true, 'birth_names').then(() => {
expect(wrapper.state().tableOptions).toHaveLength(3);
expect(wrapper.state().tableOptions).toEqual([
{
value: 'birth_names',
schema: 'main',
label: 'birth_names',
title: 'birth_names',
},
{
value: 'energy_usage',
schema: 'main',
label: 'energy_usage',
title: 'energy_usage',
},
{
value: 'wb_health_population',
schema: 'main',
label: 'wb_health_population',
title: 'wb_health_population',
},
]);
return Promise.resolve();
it('should handle empty', async () => {
act(() => {
wrapper
.find('[name="async-select-table"]')
.first()
.props()
.loadOptions();
});
await waitForComponentToPaint(wrapper);
const props = wrapper.find('[name="async-select-table"]').first().props();
expect(props.isDisabled).toBe(true);
expect(props.value).toEqual('');
});
// Test needs to be fixed: Github issue #7768
it.skip('should dispatch a danger toast on error', () => {
fetchMock.get(
FETCH_TABLES_GLOB,
{ throws: 'error' },
{ overwriteRoutes: true },
);
wrapper
.instance()
.fetchTables(true, 'birth_names')
.then(() => {
expect(wrapper.state().tableOptions).toEqual([]);
expect(wrapper.state().tableOptions).toHaveLength(0);
expect(mockedProps.handleError.callCount).toBe(1);
return Promise.resolve();
});
});
});
describe('fetchSchemas', () => {
const FETCH_SCHEMAS_GLOB = 'glob:*/api/v1/database/*/schemas/?q=(force:!*)';
afterEach(fetchMock.resetHistory);
afterAll(fetchMock.reset);
it('should fetch schema options', () => {
const schemaOptions = {
result: ['main', 'erf', 'superset'],
};
fetchMock.get(FETCH_SCHEMAS_GLOB, schemaOptions, {
it('should handle table name', async () => {
wrapper.setProps({ schema: 'main' });
fetchMock.get(GET_TABLE_ENDPOINT, tables, {
overwriteRoutes: true,
});
return wrapper
.instance()
.fetchSchemas(1)
.then(() => {
expect(fetchMock.calls(FETCH_SCHEMAS_GLOB)).toHaveLength(1);
expect(wrapper.state().schemaOptions).toHaveLength(3);
});
});
// Test needs to be fixed: Github issue #7768
it.skip('should dispatch a danger toast on error', () => {
const handleErrors = sinon.stub();
expect(handleErrors.callCount).toBe(0);
wrapper.setProps({ handleErrors });
fetchMock.get(
FETCH_SCHEMAS_GLOB,
{ throws: new Error('Bad kitty') },
{ overwriteRoutes: true },
);
wrapper
.instance()
.fetchSchemas(123)
.then(() => {
expect(wrapper.state().schemaOptions).toEqual([]);
expect(handleErrors.callCount).toBe(1);
});
});
});
describe('changeTable', () => {
beforeEach(() => {
sinon.stub(wrapper.instance(), 'fetchTables');
});
afterEach(() => {
wrapper.instance().fetchTables.restore();
});
it('test 1', () => {
wrapper.instance().changeTable({
value: 'birth_names',
schema: 'main',
label: 'birth_names',
title: 'birth_names',
act(() => {
wrapper
.find('[name="async-select-table"]')
.first()
.props()
.loadOptions();
});
expect(wrapper.state().tableName).toBe('birth_names');
await waitForComponentToPaint(wrapper);
expect(fetchMock.calls(GET_TABLE_ENDPOINT)).toHaveLength(1);
});
it('should call onTableChange with schema from table object', () => {
wrapper.setProps({ schema: null });
wrapper.instance().changeTable({
value: 'my_table',
schema: 'other_schema',
label: 'other_schema.my_table',
title: 'other_schema.my_table',
});
expect(mockedProps.onTableChange.getCall(0).args[0]).toBe('my_table');
expect(mockedProps.onTableChange.getCall(0).args[1]).toBe('other_schema');
});
});
it('changeSchema', () => {
sinon.stub(wrapper.instance(), 'fetchTables');
wrapper.instance().changeSchema({ label: 'main', value: 'main' });
expect(wrapper.instance().fetchTables.callCount).toBe(1);
expect(mockedProps.onChange.callCount).toBe(1);
wrapper.instance().changeSchema();
expect(wrapper.instance().fetchTables.callCount).toBe(2);
expect(mockedProps.onChange.callCount).toBe(2);
wrapper.instance().fetchTables.restore();
});
});

View File

@@ -60,12 +60,10 @@ async function mountAndWait(props = mockedProps) {
}
describe('DatasourceModal', () => {
fetchMock.post(SAVE_ENDPOINT, SAVE_PAYLOAD);
const callsP = fetchMock.put(SAVE_ENDPOINT, SAVE_PAYLOAD);
let wrapper;
beforeEach(async () => {
fetchMock.reset();
wrapper = await mountAndWait();
});
@@ -82,6 +80,7 @@ describe('DatasourceModal', () => {
});
it('saves on confirm', async () => {
const callsP = fetchMock.post(SAVE_ENDPOINT, SAVE_PAYLOAD);
act(() => {
wrapper
.find('button[data-test="datasource-modal-save"]')
@@ -94,6 +93,9 @@ describe('DatasourceModal', () => {
okButton.simulate('click');
});
await waitForComponentToPaint(wrapper);
expect(callsP._calls).toHaveLength(2); /* eslint no-underscore-dangle: 0 */
const expected = ['http://localhost/datasource/save/'];
expect(callsP._calls.map(call => call[0])).toEqual(
expected,
); /* eslint no-underscore-dangle: 0 */
});
});

View File

@@ -120,17 +120,18 @@ export default class SqlEditorLeftBar extends React.PureComponent {
return (
<div className="SqlEditorLeftBar">
<TableSelector
database={this.props.database}
dbId={qe.dbId}
schema={qe.schema}
getDbList={this.getDbList}
handleError={this.props.actions.addDangerToast}
onDbChange={this.onDbChange}
onSchemaChange={this.onSchemaChange}
onSchemasLoad={this.onSchemasLoad}
onTablesLoad={this.onTablesLoad}
getDbList={this.getDbList}
onTableChange={this.onTableChange}
onTablesLoad={this.onTablesLoad}
schema={qe.schema}
sqlLabMode
tableNameSticky={false}
database={this.props.database}
handleError={this.props.actions.addDangerToast}
/>
<div className="divider" />
<div className="scrollbar-container">

View File

@@ -0,0 +1,283 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { ReactNode, useEffect, useState } from 'react';
import { styled, SupersetClient, t } from '@superset-ui/core';
import rison from 'rison';
import { Select } from 'src/components/Select';
import Label from 'src/components/Label';
import SupersetAsyncSelect from './AsyncSelect';
import RefreshLabel from './RefreshLabel';
const FieldTitle = styled.p`
color: ${({ theme }) => theme.colors.secondary.light2};
font-size: ${({ theme }) => theme.typography.sizes.s}px;
margin: 20px 0 10px 0;
text-transform: uppercase;
`;
const DatabaseSelectorWrapper = styled.div`
.fa-refresh {
padding-left: 9px;
}
.refresh-col {
display: flex;
align-items: center;
width: 30px;
}
.section {
padding-bottom: 5px;
display: flex;
flex-direction: row;
}
.select {
flex-grow: 1;
}
`;
interface DatabaseSelectorProps {
dbId: number;
formMode?: boolean;
getDbList?: (arg0: any) => {};
getTableList?: (dbId: number, schema: string, force: boolean) => {};
handleError: (msg: string) => void;
isDatabaseSelectEnabled?: boolean;
onDbChange?: (db: any) => void;
onSchemaChange?: (arg0?: any) => {};
onSchemasLoad?: (schemas: Array<object>) => void;
schema?: string;
sqlLabMode?: boolean;
onChange?: ({
dbId,
schema,
}: {
dbId: number;
schema?: string;
tableName?: string;
}) => void;
}
export default function DatabaseSelector({
dbId,
formMode = false,
getDbList,
getTableList,
handleError,
isDatabaseSelectEnabled = true,
onChange,
onDbChange,
onSchemaChange,
onSchemasLoad,
schema,
sqlLabMode = false,
}: DatabaseSelectorProps) {
const [currentDbId, setCurrentDbId] = useState(dbId);
const [currentSchema, setCurrentSchema] = useState<string | undefined>(
schema,
);
const [schemaLoading, setSchemaLoading] = useState(false);
const [schemaOptions, setSchemaOptions] = useState([]);
function fetchSchemas(databaseId: number, forceRefresh = false) {
const actualDbId = databaseId || dbId;
if (actualDbId) {
setSchemaLoading(true);
const queryParams = rison.encode({
force: Boolean(forceRefresh),
});
const endpoint = `/api/v1/database/${actualDbId}/schemas/?q=${queryParams}`;
return SupersetClient.get({ endpoint })
.then(({ json }) => {
const options = json.result.map((s: string) => ({
value: s,
label: s,
title: s,
}));
setSchemaOptions(options);
setSchemaLoading(false);
if (onSchemasLoad) {
onSchemasLoad(options);
}
})
.catch(() => {
setSchemaOptions([]);
setSchemaLoading(false);
handleError(t('Error while fetching schema list'));
});
}
return Promise.resolve();
}
useEffect(() => {
if (currentDbId) {
fetchSchemas(currentDbId);
}
}, [currentDbId]);
function onSelectChange({ dbId, schema }: { dbId: number; schema?: string }) {
setCurrentDbId(dbId);
setCurrentSchema(schema);
if (onChange) {
onChange({ dbId, schema, tableName: undefined });
}
}
function dbMutator(data: any) {
if (getDbList) {
getDbList(data.result);
}
if (data.result.length === 0) {
handleError(t("It seems you don't have access to any database"));
}
return data.result.map((row: any) => ({
...row,
// label is used for the typeahead
label: `${row.backend} ${row.database_name}`,
}));
}
function changeDataBase(db: any, force = false) {
const dbId = db ? db.id : null;
setSchemaOptions([]);
if (onSchemaChange) {
onSchemaChange(null);
}
if (onDbChange) {
onDbChange(db);
}
fetchSchemas(dbId, force);
onSelectChange({ dbId, schema: undefined });
}
function changeSchema(schemaOpt: any, force = false) {
const schema = schemaOpt ? schemaOpt.value : null;
if (onSchemaChange) {
onSchemaChange(schema);
}
setCurrentSchema(schema);
onSelectChange({ dbId: currentDbId, schema });
if (getTableList) {
getTableList(currentDbId, schema, force);
}
}
function renderDatabaseOption(db: any) {
return (
<span title={db.database_name}>
<Label bsStyle="default">{db.backend}</Label>
{db.database_name}
</span>
);
}
function renderSelectRow(select: ReactNode, refreshBtn: ReactNode) {
return (
<div className="section">
<span className="select">{select}</span>
<span className="refresh-col">{refreshBtn}</span>
</div>
);
}
function renderDatabaseSelect() {
const queryParams = rison.encode({
order_columns: 'database_name',
order_direction: 'asc',
page: 0,
page_size: -1,
...(formMode || !sqlLabMode
? {}
: {
filters: [
{
col: 'expose_in_sqllab',
opr: 'eq',
value: true,
},
],
}),
});
return renderSelectRow(
<SupersetAsyncSelect
data-test="select-database"
dataEndpoint={`/api/v1/database/?q=${queryParams}`}
onChange={(db: any) => changeDataBase(db)}
onAsyncError={() =>
handleError(t('Error while fetching database list'))
}
clearable={false}
value={currentDbId}
valueKey="id"
valueRenderer={(db: any) => (
<div>
<span className="text-muted m-r-5">{t('Database:')}</span>
{renderDatabaseOption(db)}
</div>
)}
optionRenderer={renderDatabaseOption}
mutator={dbMutator}
placeholder={t('Select a database')}
autoSelect
isDisabled={!isDatabaseSelectEnabled}
/>,
null,
);
}
function renderSchemaSelect() {
const value = schemaOptions.filter(({ value }) => currentSchema === value);
const refresh = !formMode && (
<RefreshLabel
onClick={() => changeDataBase({ id: dbId }, true)}
tooltipContent={t('Force refresh schema list')}
/>
);
return renderSelectRow(
<Select
name="select-schema"
placeholder={t('Select a schema (%s)', schemaOptions.length)}
options={schemaOptions}
value={value}
valueRenderer={o => (
<div>
<span className="text-muted">{t('Schema:')}</span> {o.label}
</div>
)}
isLoading={schemaLoading}
autosize={false}
onChange={item => changeSchema(item)}
/>,
refresh,
);
}
return (
<DatabaseSelectorWrapper>
{formMode && <FieldTitle>{t('datasource')}</FieldTitle>}
{renderDatabaseSelect()}
{formMode && <FieldTitle>{t('schema')}</FieldTitle>}
{renderSchemaSelect()}
</DatabaseSelectorWrapper>
);
}

View File

@@ -1,446 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { styled, SupersetClient, t } from '@superset-ui/core';
import PropTypes from 'prop-types';
import rison from 'rison';
import { AsyncSelect, CreatableSelect, Select } from 'src/components/Select';
import Label from 'src/components/Label';
import FormLabel from 'src/components/FormLabel';
import SupersetAsyncSelect from './AsyncSelect';
import RefreshLabel from './RefreshLabel';
import './TableSelector.less';
const FieldTitle = styled.p`
color: ${({ theme }) => theme.colors.secondary.light2};
font-size: ${({ theme }) => theme.typography.sizes.s}px;
margin: 20px 0 10px 0;
text-transform: uppercase;
`;
const propTypes = {
dbId: PropTypes.number.isRequired,
schema: PropTypes.string,
onSchemaChange: PropTypes.func,
onDbChange: PropTypes.func,
onSchemasLoad: PropTypes.func,
onTablesLoad: PropTypes.func,
getDbList: PropTypes.func,
onTableChange: PropTypes.func,
tableNameSticky: PropTypes.bool,
tableName: PropTypes.string,
database: PropTypes.object,
sqlLabMode: PropTypes.bool,
formMode: PropTypes.bool,
onChange: PropTypes.func,
clearable: PropTypes.bool,
handleError: PropTypes.func.isRequired,
isDatabaseSelectEnabled: PropTypes.bool,
};
const defaultProps = {
onDbChange: () => {},
onSchemaChange: () => {},
onSchemasLoad: () => {},
onTablesLoad: () => {},
getDbList: () => {},
onTableChange: () => {},
onChange: () => {},
tableNameSticky: true,
sqlLabMode: true,
formMode: false,
clearable: true,
isDatabaseSelectEnabled: true,
};
export default class TableSelector extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
schemaLoading: false,
schemaOptions: [],
tableLoading: false,
tableOptions: [],
dbId: props.dbId,
schema: props.schema,
tableName: props.tableName,
};
this.onDatabaseChange = this.onDatabaseChange.bind(this);
this.onSchemaChange = this.onSchemaChange.bind(this);
this.changeTable = this.changeTable.bind(this);
this.dbMutator = this.dbMutator.bind(this);
this.getTableNamesBySubStr = this.getTableNamesBySubStr.bind(this);
this.onChange = this.onChange.bind(this);
}
componentDidMount() {
if (this.state.dbId) {
this.fetchSchemas(this.state.dbId);
this.fetchTables();
}
}
onChange() {
this.props.onChange({
dbId: this.state.dbId,
schema: this.state.schema,
tableName: this.state.tableName,
});
}
onDatabaseChange(db, force) {
return this.changeDataBase(db, force);
}
onSchemaChange(schemaOpt) {
return this.changeSchema(schemaOpt);
}
getTableNamesBySubStr(substr = 'undefined') {
if (!this.props.dbId || !substr) {
const options = [];
return Promise.resolve({ options });
}
const encodedSchema = encodeURIComponent(this.props.schema);
const encodedSubstr = encodeURIComponent(substr);
return SupersetClient.get({
endpoint: encodeURI(
`/superset/tables/${this.props.dbId}/${encodedSchema}/${encodedSubstr}`,
),
}).then(({ json }) => {
const options = json.options.map(o => ({
value: o.value,
schema: o.schema,
label: o.label,
title: o.title,
type: o.type,
}));
return { options };
});
}
dbMutator(data) {
this.props.getDbList(data.result);
if (data.result.length === 0) {
this.props.handleError(
t("It seems you don't have access to any database"),
);
}
return data.result.map(row => ({
...row,
// label is used for the typeahead
label: `${row.backend} ${row.database_name}`,
}));
}
fetchTables(forceRefresh = false, substr = 'undefined') {
const { dbId, schema } = this.state;
const encodedSchema = encodeURIComponent(schema);
const encodedSubstr = encodeURIComponent(substr);
if (dbId && schema) {
this.setState(() => ({ tableLoading: true, tableOptions: [] }));
const endpoint = encodeURI(
`/superset/tables/${dbId}/${encodedSchema}/${encodedSubstr}/${!!forceRefresh}/`,
);
return SupersetClient.get({ endpoint })
.then(({ json }) => {
const options = json.options.map(o => ({
value: o.value,
schema: o.schema,
label: o.label,
title: o.title,
type: o.type,
}));
this.setState(() => ({
tableLoading: false,
tableOptions: options,
}));
this.props.onTablesLoad(json.options);
})
.catch(() => {
this.setState(() => ({ tableLoading: false, tableOptions: [] }));
this.props.handleError(t('Error while fetching table list'));
});
}
this.setState(() => ({ tableLoading: false, tableOptions: [] }));
return Promise.resolve();
}
fetchSchemas(dbId, forceRefresh = false) {
const actualDbId = dbId || this.props.dbId;
if (actualDbId) {
this.setState({ schemaLoading: true });
const queryParams = rison.encode({
force: Boolean(forceRefresh),
});
const endpoint = `/api/v1/database/${actualDbId}/schemas/?q=${queryParams}`;
return SupersetClient.get({ endpoint })
.then(({ json }) => {
const schemaOptions = json.result.map(s => ({
value: s,
label: s,
title: s,
}));
this.setState({ schemaOptions, schemaLoading: false });
this.props.onSchemasLoad(schemaOptions);
})
.catch(() => {
this.setState({ schemaLoading: false, schemaOptions: [] });
this.props.handleError(t('Error while fetching schema list'));
});
}
return Promise.resolve();
}
changeDataBase(db, force = false) {
const dbId = db ? db.id : null;
this.setState({ schemaOptions: [] });
this.props.onSchemaChange(null);
this.props.onDbChange(db);
this.fetchSchemas(dbId, force);
this.setState(
{ dbId, schema: null, tableName: null, tableOptions: [] },
this.onChange,
);
}
changeSchema(schemaOpt, force = false) {
const schema = schemaOpt ? schemaOpt.value : null;
this.props.onSchemaChange(schema);
this.setState({ schema }, () => {
this.fetchTables(force);
this.onChange();
});
}
changeTable(tableOpt) {
if (!tableOpt) {
this.setState({ tableName: '' });
return;
}
const schemaName = tableOpt.schema;
const tableName = tableOpt.value;
if (this.props.tableNameSticky) {
this.setState({ tableName }, this.onChange);
}
this.props.onTableChange(tableName, schemaName);
}
renderDatabaseOption(db) {
return (
<span title={db.database_name}>
<Label bsStyle="default">{db.backend}</Label>
{db.database_name}
</span>
);
}
renderTableOption(option) {
return (
<span className="TableLabel" title={option.label}>
<span className="m-r-5">
<small className="text-muted">
<i
className={`fa fa-${option.type === 'view' ? 'eye' : 'table'}`}
/>
</small>
</span>
{option.label}
</span>
);
}
renderSelectRow(select, refreshBtn) {
return (
<div className="section">
<span className="select">{select}</span>
<span className="refresh-col">{refreshBtn}</span>
</div>
);
}
renderDatabaseSelect() {
const queryParams = rison.encode({
order_columns: 'database_name',
order_direction: 'asc',
page: 0,
page_size: -1,
...(this.props.formMode
? {}
: {
filters: [
{
col: 'expose_in_sqllab',
opr: 'eq',
value: true,
},
],
}),
});
return this.renderSelectRow(
<SupersetAsyncSelect
dataEndpoint={`/api/v1/database/?q=${queryParams}`}
onChange={this.onDatabaseChange}
onAsyncError={() =>
this.props.handleError(t('Error while fetching database list'))
}
clearable={false}
value={this.state.dbId}
valueKey="id"
valueRenderer={db => (
<div>
<span className="text-muted m-r-5">{t('Database:')}</span>
{this.renderDatabaseOption(db)}
</div>
)}
optionRenderer={this.renderDatabaseOption}
mutator={this.dbMutator}
placeholder={t('Select a database')}
isDisabled={!this.props.isDatabaseSelectEnabled}
autoSelect
/>,
);
}
renderSchema() {
const refresh = !this.props.formMode && (
<RefreshLabel
onClick={() => this.onDatabaseChange({ id: this.props.dbId }, true)}
tooltipContent={t('Force refresh schema list')}
/>
);
return this.renderSelectRow(
<Select
name="select-schema"
placeholder={t('Select a schema (%s)', this.state.schemaOptions.length)}
options={this.state.schemaOptions}
value={this.props.schema}
valueRenderer={o => (
<div>
<span className="text-muted">{t('Schema:')}</span> {o.label}
</div>
)}
isLoading={this.state.schemaLoading}
autosize={false}
onChange={this.onSchemaChange}
/>,
refresh,
);
}
renderTable() {
let tableSelectPlaceholder;
let tableSelectDisabled = false;
if (
this.props.database &&
this.props.database.allow_multi_schema_metadata_fetch
) {
tableSelectPlaceholder = t('Type to search ...');
} else {
tableSelectPlaceholder = t('Select table ');
tableSelectDisabled = true;
}
const options = this.state.tableOptions;
let select = null;
if (this.props.schema && !this.props.formMode) {
select = (
<Select
name="select-table"
isLoading={this.state.tableLoading}
ignoreAccents={false}
placeholder={t('Select table or type table name')}
autosize={false}
onChange={this.changeTable}
options={options}
value={this.state.tableName}
optionRenderer={this.renderTableOption}
/>
);
} else if (this.props.formMode) {
select = (
<CreatableSelect
name="select-table"
isLoading={this.state.tableLoading}
ignoreAccents={false}
placeholder={t('Select table or type table name')}
autosize={false}
onChange={this.changeTable}
options={options}
value={this.state.tableName}
optionRenderer={this.renderTableOption}
/>
);
} else {
select = (
<AsyncSelect
name="async-select-table"
placeholder={tableSelectPlaceholder}
isDisabled={tableSelectDisabled}
autosize={false}
onChange={this.changeTable}
value={this.state.tableName}
loadOptions={this.getTableNamesBySubStr}
optionRenderer={this.renderTableOption}
/>
);
}
const refresh = !this.props.formMode && (
<RefreshLabel
onClick={() => this.changeSchema({ value: this.props.schema }, true)}
tooltipContent={t('Force refresh table list')}
/>
);
return this.renderSelectRow(select, refresh);
}
renderSeeTableLabel() {
return (
<div className="section">
<FormLabel>
{t('See table schema')}{' '}
{this.props.schema && (
<small>
({this.state.tableOptions.length} in <i>{this.props.schema}</i>)
</small>
)}
</FormLabel>
</div>
);
}
render() {
return (
<div className="TableSelector">
{this.props.formMode && <FieldTitle>{t('datasource')}</FieldTitle>}
{this.renderDatabaseSelect()}
{this.props.formMode && <FieldTitle>{t('schema')}</FieldTitle>}
{this.renderSchema()}
{!this.props.formMode && <div className="divider" />}
{this.props.sqlLabMode && this.renderSeeTableLabel()}
{this.props.formMode && <FieldTitle>{t('Table')}</FieldTitle>}
{this.renderTable()}
</div>
);
}
}
TableSelector.propTypes = propTypes;
TableSelector.defaultProps = defaultProps;

View File

@@ -0,0 +1,379 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, {
FunctionComponent,
useEffect,
useState,
ReactNode,
} from 'react';
import { styled, SupersetClient, t } from '@superset-ui/core';
import { AsyncSelect, CreatableSelect, Select } from 'src/components/Select';
import FormLabel from 'src/components/FormLabel';
import DatabaseSelector from './DatabaseSelector';
import RefreshLabel from './RefreshLabel';
const FieldTitle = styled.p`
color: ${({ theme }) => theme.colors.secondary.light2};
font-size: ${({ theme }) => theme.typography.sizes.s}px;
margin: 20px 0 10px 0;
text-transform: uppercase;
`;
const TableSelectorWrapper = styled.div`
.fa-refresh {
padding-left: 9px;
}
.refresh-col {
display: flex;
align-items: center;
width: 30px;
}
.section {
padding-bottom: 5px;
display: flex;
flex-direction: row;
}
.select {
flex-grow: 1;
}
.divider {
border-bottom: 1px solid ${({ theme }) => theme.colors.secondary.light5};
margin: 15px 0;
}
`;
const TableLabel = styled.span`
white-space: nowrap;
`;
interface TableSelectorProps {
clearable?: boolean;
database?: any;
dbId: number;
formMode?: boolean;
getDbList?: (arg0: any) => {};
handleError: (msg: string) => void;
isDatabaseSelectEnabled?: boolean;
onChange?: ({
dbId,
schema,
}: {
dbId: number;
schema?: string;
tableName?: string;
}) => void;
onDbChange?: (db: any) => void;
onSchemaChange?: (arg0?: any) => {};
onSchemasLoad?: () => void;
onTableChange?: (tableName: string, schema: string) => void;
onTablesLoad?: (options: Array<any>) => {};
schema?: string;
sqlLabMode?: boolean;
tableName?: string;
tableNameSticky?: boolean;
}
const TableSelector: FunctionComponent<TableSelectorProps> = ({
database,
dbId,
formMode = false,
getDbList,
handleError,
isDatabaseSelectEnabled = true,
onChange,
onDbChange,
onSchemaChange,
onSchemasLoad,
onTableChange,
onTablesLoad,
schema,
sqlLabMode = true,
tableName,
tableNameSticky = true,
}) => {
const [currentSchema, setCurrentSchema] = useState<string | undefined>(
schema,
);
const [currentTableName, setCurrentTableName] = useState<string | undefined>(
tableName,
);
const [tableLoading, setTableLoading] = useState(false);
const [tableOptions, setTableOptions] = useState([]);
function fetchTables(
databaseId?: number,
schema?: string,
forceRefresh = false,
substr = 'undefined',
) {
const dbSchema = schema || currentSchema;
const actualDbId = databaseId || dbId;
if (actualDbId && dbSchema) {
const encodedSchema = encodeURIComponent(dbSchema);
const encodedSubstr = encodeURIComponent(substr);
setTableLoading(true);
setTableOptions([]);
const endpoint = encodeURI(
`/superset/tables/${actualDbId}/${encodedSchema}/${encodedSubstr}/${!!forceRefresh}/`,
);
return SupersetClient.get({ endpoint })
.then(({ json }) => {
const options = json.options.map((o: any) => ({
value: o.value,
schema: o.schema,
label: o.label,
title: o.title,
type: o.type,
}));
setTableLoading(false);
setTableOptions(options);
if (onTablesLoad) {
onTablesLoad(json.options);
}
})
.catch(() => {
setTableLoading(false);
setTableOptions([]);
handleError(t('Error while fetching table list'));
});
}
setTableLoading(false);
setTableOptions([]);
return Promise.resolve();
}
useEffect(() => {
if (dbId && schema) {
fetchTables();
}
}, [dbId, schema]);
function onSelectionChange({
dbId,
schema,
tableName,
}: {
dbId: number;
schema?: string;
tableName?: string;
}) {
setCurrentTableName(tableName);
setCurrentSchema(schema);
if (onChange) {
onChange({ dbId, schema, tableName });
}
}
function getTableNamesBySubStr(substr = 'undefined') {
if (!dbId || !substr) {
const options: any[] = [];
return Promise.resolve({ options });
}
const encodedSchema = encodeURIComponent(schema || '');
const encodedSubstr = encodeURIComponent(substr);
return SupersetClient.get({
endpoint: encodeURI(
`/superset/tables/${dbId}/${encodedSchema}/${encodedSubstr}`,
),
}).then(({ json }) => {
const options = json.options.map((o: any) => ({
value: o.value,
schema: o.schema,
label: o.label,
title: o.title,
type: o.type,
}));
return { options };
});
}
function changeTable(tableOpt: any) {
if (!tableOpt) {
setCurrentTableName('');
return;
}
const schemaName = tableOpt.schema;
const tableOptTableName = tableOpt.value;
if (tableNameSticky) {
onSelectionChange({
dbId,
schema: schemaName,
tableName: tableOptTableName,
});
}
if (onTableChange) {
onTableChange(tableOptTableName, schemaName);
}
}
function changeSchema(schemaOpt: any, force = false) {
const value = schemaOpt ? schemaOpt.value : null;
if (onSchemaChange) {
onSchemaChange(value);
}
onSelectionChange({
dbId,
schema: value,
tableName: undefined,
});
fetchTables(dbId, currentSchema, force);
}
function renderTableOption(option: any) {
return (
<TableLabel title={option.label}>
<span className="m-r-5">
<small className="text-muted">
<i
className={`fa fa-${option.type === 'view' ? 'eye' : 'table'}`}
/>
</small>
</span>
{option.label}
</TableLabel>
);
}
function renderSelectRow(select: ReactNode, refreshBtn: ReactNode) {
return (
<div className="section">
<span className="select">{select}</span>
<span className="refresh-col">{refreshBtn}</span>
</div>
);
}
function renderDatabaseSelector() {
return (
<DatabaseSelector
dbId={dbId}
formMode={formMode}
getDbList={getDbList}
getTableList={fetchTables}
handleError={handleError}
onChange={onSelectionChange}
onDbChange={onDbChange}
onSchemaChange={onSchemaChange}
onSchemasLoad={onSchemasLoad}
schema={currentSchema}
sqlLabMode={sqlLabMode}
isDatabaseSelectEnabled={isDatabaseSelectEnabled}
/>
);
}
function renderTableSelect() {
let tableSelectPlaceholder;
let tableSelectDisabled = false;
if (database && database.allow_multi_schema_metadata_fetch) {
tableSelectPlaceholder = t('Type to search ...');
} else {
tableSelectPlaceholder = t('Select table ');
tableSelectDisabled = true;
}
const options = tableOptions;
let select = null;
if (currentSchema && !formMode) {
select = (
<Select
name="select-table"
isLoading={tableLoading}
ignoreAccents={false}
placeholder={t('Select table or type table name')}
autosize={false}
onChange={changeTable}
options={options}
// @ts-ignore
value={currentTableName}
optionRenderer={renderTableOption}
/>
);
} else if (formMode) {
select = (
<CreatableSelect
name="select-table"
isLoading={tableLoading}
ignoreAccents={false}
placeholder={t('Select table or type table name')}
autosize={false}
onChange={changeTable}
options={options}
// @ts-ignore
value={currentTableName}
optionRenderer={renderTableOption}
/>
);
} else {
select = (
<AsyncSelect
name="async-select-table"
placeholder={tableSelectPlaceholder}
isDisabled={tableSelectDisabled}
autosize={false}
onChange={changeTable}
// @ts-ignore
value={currentTableName}
loadOptions={getTableNamesBySubStr}
optionRenderer={renderTableOption}
/>
);
}
const refresh = !formMode && (
<RefreshLabel
onClick={() => changeSchema({ value: schema }, true)}
tooltipContent={t('Force refresh table list')}
/>
);
return renderSelectRow(select, refresh);
}
function renderSeeTableLabel() {
return (
<div className="section">
<FormLabel>
{t('See table schema')}{' '}
{schema && (
<small>
{tableOptions.length} in
<i>{schema}</i>
</small>
)}
</FormLabel>
</div>
);
}
return (
<TableSelectorWrapper>
{renderDatabaseSelector()}
{!formMode && <div className="divider" />}
{sqlLabMode && renderSeeTableLabel()}
{formMode && <FieldTitle>{t('Table')}</FieldTitle>}
{renderTableSelect()}
</TableSelectorWrapper>
);
};
export default TableSelector;

View File

@@ -18,15 +18,16 @@
*/
import React from 'react';
import PropTypes from 'prop-types';
import { Alert, Badge, Col, Tabs, Tab, Well } from 'react-bootstrap';
import { Alert, Badge, Col, Radio, Tabs, Tab, Well } from 'react-bootstrap';
import shortid from 'shortid';
import { styled, SupersetClient, t } from '@superset-ui/core';
import Label from 'src/components/Label';
import Button from 'src/components/Button';
import CertifiedIconWithTooltip from 'src/components/CertifiedIconWithTooltip';
import DatabaseSelector from 'src/components/DatabaseSelector';
import Label from 'src/components/Label';
import Loading from 'src/components/Loading';
import TableSelector from 'src/components/TableSelector';
import CertifiedIconWithTooltip from 'src/components/CertifiedIconWithTooltip';
import EditableTitle from 'src/components/EditableTitle';
import getClientErrorObject from 'src/utils/getClientErrorObject';
@@ -74,6 +75,15 @@ const checkboxGenerator = (d, onChange) => (
);
const DATA_TYPES = ['STRING', 'NUMERIC', 'DATETIME'];
const DATASOURCE_TYPES_ARR = [
{ key: 'physical', label: t('Physical (table or view)') },
{ key: 'virtual', label: t('Virtual (SQL)') },
];
const DATASOURCE_TYPES = {};
DATASOURCE_TYPES_ARR.forEach(o => {
DATASOURCE_TYPES[o.key] = o;
});
function CollectionTabTitle({ title, collection }) {
return (
<div>
@@ -278,7 +288,10 @@ class DatasourceEditor extends React.PureComponent {
col => !!col.expression,
),
metadataLoading: false,
activeTabKey: 1,
activeTabKey: 0,
datasourceType: props.datasource.sql
? DATASOURCE_TYPES.virtual.key
: DATASOURCE_TYPES.physical.key,
};
this.onChange = this.onChange.bind(this);
@@ -291,11 +304,19 @@ class DatasourceEditor extends React.PureComponent {
}
onChange() {
const datasource = {
// Emptying SQL if "Physical" radio button is selected
// Currently the logic to know whether the source is
// physical or virtual is based on whether SQL is empty or not.
const { datasourceType, datasource } = this.state;
const sql =
datasourceType === DATASOURCE_TYPES.physical.key ? '' : datasource.sql;
const newDatasource = {
...this.state.datasource,
sql,
columns: [...this.state.databaseColumns, ...this.state.calculatedColumns],
};
this.props.onChange(datasource, this.state.errors);
this.props.onChange(newDatasource, this.state.errors);
}
onDatasourceChange(datasource) {
@@ -310,6 +331,10 @@ class DatasourceEditor extends React.PureComponent {
);
}
onDatasourceTypeChange(datasourceType) {
this.setState({ datasourceType });
}
setColumns(obj) {
this.setState(obj, this.validateAndChange);
}
@@ -466,34 +491,6 @@ class DatasourceEditor extends React.PureComponent {
item={datasource}
onChange={this.onDatasourceChange}
>
{this.state.isSqla && (
<Field
fieldKey="tableSelector"
label={t('Physical Table')}
control={
<TableSelector
dbId={datasource.database.id}
schema={datasource.schema}
tableName={datasource.datasource_name}
onSchemaChange={schema =>
this.onDatasourcePropChange('schema', schema)
}
onTableChange={table =>
this.onDatasourcePropChange('datasource_name', table)
}
sqlLabMode={false}
clearable={false}
handleError={this.props.addDangerToast}
isDatabaseSelectEnabled={false}
/>
}
description={t(
'The pointer to a physical table. Keep in mind that the chart is ' +
'associated to this Superset logical table, and this logical table points ' +
'the physical table referenced here.',
)}
/>
)}
<Field
fieldKey="description"
label={t('Description')}
@@ -559,32 +556,6 @@ class DatasourceEditor extends React.PureComponent {
item={datasource}
onChange={this.onDatasourceChange}
>
{this.state.isSqla && (
<Field
fieldKey="sql"
label={t('SQL')}
description={t(
'When specifying SQL, the dataset acts as a view. ' +
'Superset will use this statement as a subquery while grouping and filtering ' +
'on the generated parent queries.',
)}
control={
<TextAreaControl language="sql" offerEditInModal={false} />
}
/>
)}
{this.state.isDruid && (
<Field
fieldKey="json"
label={t('JSON')}
description={
<div>{t('The JSON metric or post aggregation definition.')}</div>
}
control={
<TextAreaControl language="json" offerEditInModal={false} />
}
/>
)}
<Field
fieldKey="cache_timeout"
label={t('Cache Timeout')}
@@ -645,6 +616,121 @@ class DatasourceEditor extends React.PureComponent {
);
}
renderSourceFieldset() {
const { datasource } = this.state;
return (
<div>
<div className="m-l-10 m-t-20 m-b-10">
{DATASOURCE_TYPES_ARR.map(type => (
<Radio
value={type.key}
inline
onChange={this.onDatasourceTypeChange.bind(this, type.key)}
checked={this.state.datasourceType === type.key}
>
{type.label}
</Radio>
))}
</div>
<hr />
<Fieldset item={datasource} onChange={this.onDatasourceChange} compact>
{this.state.datasourceType === DATASOURCE_TYPES.virtual.key && (
<div>
{this.state.isSqla && (
<>
<Field
fieldKey="databaseSelector"
label={t('virtual')}
control={
<DatabaseSelector
dbId={datasource.database.id}
schema={datasource.schema}
onSchemaChange={schema =>
this.onDatasourcePropChange('schema', schema)
}
onDbChange={database =>
this.onDatasourcePropChange('database', database)
}
formMode={false}
handleError={this.props.addDangerToast}
/>
}
/>
<Field
fieldKey="sql"
label={t('SQL')}
description={t(
'When specifying SQL, the datasource acts as a view. ' +
'Superset will use this statement as a subquery while grouping and filtering ' +
'on the generated parent queries.',
)}
control={
<TextAreaControl
language="sql"
offerEditInModal={false}
minLines={25}
maxLines={25}
/>
}
/>
</>
)}
{this.state.isDruid && (
<Field
fieldKey="json"
label={t('JSON')}
description={
<div>
{t('The JSON metric or post aggregation definition.')}
</div>
}
control={
<TextAreaControl language="json" offerEditInModal={false} />
}
/>
)}
</div>
)}
{this.state.datasourceType === DATASOURCE_TYPES.physical.key && (
<Col md={6}>
{this.state.isSqla && (
<Field
fieldKey="tableSelector"
label={t('Physical')}
control={
<TableSelector
clearable={false}
dbId={datasource.database.id}
handleError={this.props.addDangerToast}
schema={datasource.schema}
sqlLabMode={false}
tableName={datasource.table_name}
onSchemaChange={schema =>
this.onDatasourcePropChange('schema', schema)
}
onDbChange={database =>
this.onDatasourcePropChange('database', database)
}
onTableChange={table => {
this.onDatasourcePropChange('table_name', table);
}}
isDatabaseSelectEnabled={false}
/>
}
description={t(
'The pointer to a physical table (or view). Keep in mind that the chart is ' +
'associated to this Superset logical table, and this logical table points ' +
'the physical table referenced here.',
)}
/>
)}
</Col>
)}
</Fieldset>
</div>
);
}
renderErrors() {
if (this.state.errors.length > 0) {
return (
@@ -800,6 +886,9 @@ class DatasourceEditor extends React.PureComponent {
onSelect={this.handleTabSelect}
defaultActiveKey={activeTabKey}
>
<Tab eventKey={0} title={t('Source')}>
{activeTabKey === 0 && this.renderSourceFieldset()}
</Tab>
<Tab
title={
<CollectionTabTitle

View File

@@ -44,10 +44,8 @@ const StyledIcon = styled(Icon)`
`;
const TableSelectorContainer = styled.div`
.TableSelector {
padding-bottom: 340px;
width: 65%;
}
padding-bottom: 340px;
width: 65%;
`;
const DatasetModal: FunctionComponent<DatasetModalProps> = ({
@@ -59,7 +57,7 @@ const DatasetModal: FunctionComponent<DatasetModalProps> = ({
}) => {
const [currentSchema, setSchema] = useState('');
const [currentTableName, setTableName] = useState('');
const [datasourceId, setDatasourceId] = useState<number | null>(null);
const [datasourceId, setDatasourceId] = useState<number>(0);
const [disableSave, setDisableSave] = useState(true);
const onChange = ({
@@ -128,7 +126,6 @@ const DatasetModal: FunctionComponent<DatasetModalProps> = ({
handleError={addDangerToast}
onChange={onChange}
schema={currentSchema}
sqlLabMode={false}
tableName={currentTableName}
/>
</TableSelectorContainer>

View File

@@ -319,6 +319,10 @@ table.table-no-hover tr:hover {
margin-top: 10px;
}
.m-t-20 {
margin-top: 20px;
}
.m-b-10 {
margin-bottom: 10px;
}

View File

@@ -826,10 +826,6 @@ class TestDatasetApi(SupersetTestCase):
self.login(username="admin")
rv = self.get_assert_metric(uri, "export")
self.assertEqual(rv.status_code, 200)
self.assertEqual(
rv.headers["Content-Disposition"],
generate_download_headers("yaml")["Content-Disposition"],
)
cli_export = export_to_dict(
session=db.session,