Query Search Page (#1122)

* Query search page under SQL Lab tab

* Modifications based on comments

* Hash

* Added spec and endpoint test with modifications
based on second round comments

* Changed permission menu in https://github.com/airbnb/caravel/pull/1095/files
This commit is contained in:
vera-liu 2016-09-23 16:13:18 -07:00 committed by GitHub
parent 98902599ff
commit d5c5c0d6ac
13 changed files with 345 additions and 144 deletions

View File

@ -4,3 +4,5 @@ export const STATE_BSSTYLE_MAP = {
running: 'warning',
success: 'success',
};
export const STATUS_OPTIONS = ['success', 'failed', 'running'];

View File

@ -0,0 +1,71 @@
import * as Actions from '../actions';
import React from 'react';
import TabbedSqlEditors from './TabbedSqlEditors';
import QueryAutoRefresh from './QueryAutoRefresh';
import QuerySearch from './QuerySearch';
import Alerts from './Alerts';
import { bindActionCreators } from 'redux';
import { connect } from 'react-redux';
class App extends React.Component {
constructor(props) {
super(props);
this.state = {
hash: window.location.hash,
};
}
componentDidMount() {
window.addEventListener('hashchange', this.onHashChanged.bind(this));
}
componentWillUnmount() {
window.removeEventListener('hashchange', this.onHashChanged.bind(this));
}
onHashChanged() {
this.setState({ hash: window.location.hash });
}
render() {
if (this.state.hash) {
return (
<div className="container-fluid">
<div className="row">
<div className="col-md-12">
<QuerySearch />
</div>
</div>
</div>
);
}
return (
<div className="App SqlLab">
<div className="container-fluid">
<QueryAutoRefresh />
<Alerts alerts={this.props.alerts} />
<div className="row">
<div className="col-md-12">
<TabbedSqlEditors />
</div>
</div>
</div>
</div>
);
}
}
App.propTypes = {
alerts: React.PropTypes.array,
};
function mapStateToProps(state) {
return {
alerts: state.alerts,
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(Actions, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(App);

View File

@ -0,0 +1,51 @@
const $ = window.$ = require('jquery');
import React from 'react';
import Select from 'react-select';
class DatabaseSelect extends React.Component {
constructor(props) {
super(props);
this.state = {
databaseLoading: false,
databaseOptions: [],
databaseId: null,
};
}
componentWillMount() {
this.fetchDatabaseOptions();
}
changeDb(db) {
const val = (db) ? db.value : null;
this.setState({ databaseId: val });
this.props.onChange(db);
}
fetchDatabaseOptions() {
this.setState({ databaseLoading: true });
const url = '/databaseasync/api/read?_flt_0_expose_in_sqllab=1';
$.get(url, (data) => {
const options = data.result.map((db) => ({ value: db.id, label: db.database_name }));
this.setState({ databaseOptions: options, databaseLoading: false });
});
}
render() {
return (
<div>
<Select
name="select-db"
placeholder={`Select a database (${this.state.databaseOptions.length})`}
options={this.state.databaseOptions}
value={this.state.databaseId}
isLoading={this.state.databaseLoading}
autosize={false}
onChange={this.changeDb.bind(this)}
/>
</div>
);
}
}
DatabaseSelect.propTypes = {
onChange: React.PropTypes.func,
};
export default DatabaseSelect;

View File

@ -1,73 +1,134 @@
const $ = window.$ = require('jquery');
import React from 'react';
import Select from 'react-select';
import { Button } from 'react-bootstrap';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import * as Actions from '../actions';
import { Button } from 'react-bootstrap';
import Select from 'react-select';
import QueryTable from './QueryTable';
import DatabaseSelect from './DatabaseSelect';
import { STATUS_OPTIONS } from '../common';
class QuerySearch extends React.Component {
constructor(props) {
super(props);
this.state = {
queryText: '',
userLoading: false,
userOptions: [],
databaseId: null,
userId: null,
searchText: null,
status: 'success',
queriesArray: [],
};
}
changeQueryText(value) {
this.setState({ queryText: value });
componentWillMount() {
this.fetchUsers();
this.refreshQueries();
}
onChange(db) {
const val = (db) ? db.value : null;
this.setState({ databaseId: val });
}
insertParams(baseUrl, params) {
return baseUrl + '?' + params.join('&');
}
changeUser(user) {
const val = (user) ? user.value : null;
this.setState({ userId: val });
}
changeStatus(status) {
const val = (status) ? status.value : null;
this.setState({ status: val });
}
changeSearch(event) {
this.setState({ searchText: event.target.value });
}
fetchUsers() {
this.setState({ userLoading: true });
const url = '/users/api/read';
$.getJSON(url, (data, status) => {
if (status === 'success') {
const options = [];
for (let i = 0; i < data.pks.length; i++) {
options.push({ value: data.pks[i], label: data.result[i].username });
}
this.setState({ userOptions: options, userLoading: false });
}
});
}
refreshQueries() {
const params = [
`userId=${this.state.userId}`,
`databaseId=${this.state.databaseId}`,
`searchText=${this.state.searchText}`,
`status=${this.state.status}`,
];
const url = this.insertParams('/caravel/search_queries', params);
$.getJSON(url, (data, status) => {
if (status === 'success') {
const newQueriesArray = [];
for (const id in data) {
newQueriesArray.push(data[id]);
}
this.setState({ queriesArray: newQueriesArray });
}
});
}
search() {
this.refreshQueries(this.props);
}
render() {
const queries = this.props.queries;
return (
<div>
<div className="pane-cell pane-west m-t-5">
<div className="panel panel-default Workspace">
<div className="panel-heading">
<h6>
<i className="fa fa-search" /> Search Queries
</h6>
</div>
<div className="panel-body">
<input type="text" className="form-control" placeholder="Query Text" />
<Select
name="select-user"
placeholder="[User]"
options={['maxime_beauchemin', 'someone else']}
value={'maxime_beauchemin'}
className="m-t-10"
autosize={false}
/>
</div>
<div className="row space-1">
<div className="col-sm-2">
<Select
name="select-user"
placeholder="[User]"
options={this.state.userOptions}
value={this.state.userId}
isLoading={this.state.userLoading}
autosize={false}
onChange={this.changeUser.bind(this)}
/>
</div>
<div className="col-sm-2">
<DatabaseSelect onChange={this.onChange.bind(this)} />
</div>
<div className="col-sm-4">
<input
type="text"
onChange={this.changeSearch.bind(this)}
className="form-control input-sm"
placeholder="Search Results"
/>
</div>
<div className="col-sm-2">
<Select
name="select-state"
placeholder="[Query Status]"
options={STATUS_OPTIONS.map((s) => ({ value: s, label: s }))}
value={this.state.status}
isLoading={false}
autosize={false}
onChange={this.changeStatus.bind(this)}
/>
</div>
<Button bsSize="small" bsStyle="success" onClick={this.search.bind(this)}>
Search
</Button>
</div>
<div className="pane-cell">
<QueryTable
columns={['state', 'started', 'duration', 'rows', 'sql', 'actions']}
queries={queries}
/>
</div>
<Button>Search!</Button>
<QueryTable
columns={[
'state', 'dbId', 'userId',
'progress', 'rows', 'sql',
]}
queries={this.state.queriesArray}
/>
</div>
);
}
}
QuerySearch.propTypes = {
queries: React.PropTypes.array,
};
QuerySearch.defaultProps = {
queries: [],
};
function mapStateToProps(state) {
return {
queries: state.queries,
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(Actions, dispatch),
};
}
export default connect(mapStateToProps, mapDispatchToProps)(QuerySearch);
export default QuerySearch;

View File

@ -123,7 +123,7 @@ QueryTable.propTypes = {
queries: React.PropTypes.array,
};
QueryTable.defaultProps = {
columns: ['state', 'started', 'duration', 'progress', 'rows', 'sql', 'actions'],
columns: ['started', 'duration', 'rows'],
queries: [],
};

View File

@ -8,14 +8,13 @@ import shortid from 'shortid';
import Select from 'react-select';
import { Label, Button } from 'react-bootstrap';
import TableElement from './TableElement';
import DatabaseSelect from './DatabaseSelect';
class SqlEditorTopToolbar extends React.Component {
constructor(props) {
super(props);
this.state = {
databaseLoading: false,
databaseOptions: [],
schemaLoading: false,
schemaOptions: [],
tableLoading: false,
@ -23,10 +22,20 @@ class SqlEditorTopToolbar extends React.Component {
};
}
componentWillMount() {
this.fetchDatabaseOptions();
this.fetchSchemas();
this.fetchTables();
}
onChange(db) {
const val = (db) ? db.value : null;
this.setState({ schemaOptions: [] });
this.props.actions.queryEditorSetDb(this.props.queryEditor, val);
if (!(db)) {
this.setState({ tableOptions: [] });
} else {
this.fetchTables(val, this.props.queryEditor.schema);
this.fetchSchemas(val);
}
}
resetState() {
this.props.actions.resetState();
}
@ -64,37 +73,6 @@ class SqlEditorTopToolbar extends React.Component {
});
}
}
changeDb(db) {
const val = (db) ? db.value : null;
this.setState({ schemaOptions: [] });
this.props.actions.queryEditorSetDb(this.props.queryEditor, val);
if (!(db)) {
this.setState({ tableOptions: [] });
return;
}
this.fetchTables(val, this.props.queryEditor.schema);
this.fetchSchemas(val);
}
fetchDatabaseOptions() {
this.setState({ databaseLoading: true });
const url = (
'/databaseasync/api/read?' +
'_flt_0_expose_in_sqllab=1&' +
'_oc_DatabaseAsync=database_name&' +
'_od_DatabaseAsync=asc'
);
$.get(url, (data) => {
const options = data.result.map((db) => ({ value: db.id, label: db.database_name }));
this.props.actions.setDatabases(data.result);
this.setState({ databaseOptions: options });
this.setState({ databaseLoading: false });
// Auto select if only one option
if (options.length === 1) {
this.changeDb(options[0]);
}
});
}
closePopover(ref) {
this.refs[ref].hide();
}
@ -136,15 +114,7 @@ class SqlEditorTopToolbar extends React.Component {
<div className="clearfix sql-toolbar">
{networkAlert}
<div>
<Select
name="select-db"
placeholder={`Select a database (${this.state.databaseOptions.length})`}
options={this.state.databaseOptions}
value={this.props.queryEditor.dbId}
isLoading={this.state.databaseLoading}
autosize={false}
onChange={this.changeDb.bind(this)}
/>
<DatabaseSelect onChange={this.onChange.bind(this)} />
</div>
<div className="m-t-5">
<Select

View File

@ -4,61 +4,24 @@ require('bootstrap');
import React from 'react';
import { render } from 'react-dom';
import * as Actions from './actions';
import TabbedSqlEditors from './components/TabbedSqlEditors';
import QueryAutoRefresh from './components/QueryAutoRefresh';
import Alerts from './components/Alerts';
import { bindActionCreators, createStore } from 'redux';
import { connect, Provider } from 'react-redux';
import { initialState, sqlLabReducer } from './reducers';
import { enhancer } from '../reduxUtils';
import { createStore } from 'redux';
import { Provider } from 'react-redux';
import App from './components/App';
require('./main.css');
let store = createStore(sqlLabReducer, initialState, enhancer());
// jquery hack to highlight the navbar menu
$('a[href="/caravel/sqllab"]').parent().addClass('active');
const App = function (props) {
return (
<div className="App SqlLab">
<div className="container-fluid">
<QueryAutoRefresh />
<Alerts alerts={props.alerts} />
<div className="row">
<div className="col-md-12">
<TabbedSqlEditors />
</div>
</div>
</div>
</div>
);
};
App.propTypes = {
alerts: React.PropTypes.array,
};
function mapStateToProps(state) {
return {
alerts: state.alerts,
};
}
function mapDispatchToProps(dispatch) {
return {
actions: bindActionCreators(Actions, dispatch),
};
}
const ReduxedApp = connect(mapStateToProps, mapDispatchToProps)(App);
$('a:contains("SQL Lab")').parent().addClass('active');
render(
<Provider store={store}>
<ReduxedApp />
<App />
</Provider>,
document.getElementById('app')
);

View File

@ -13,6 +13,7 @@ const defaultQueryEditor = {
dbId: null,
};
// TODO(bkyryliuk): document the object schemas
export const initialState = {
alerts: [],
networkOn: true,

View File

@ -0,0 +1,30 @@
import React from 'react';
import Select from 'react-select';
import { Button } from 'react-bootstrap';
import QuerySearch from '../../../javascripts/SqlLab/components/QuerySearch';
import { shallow } from 'enzyme';
import { describe, it } from 'mocha';
import { expect } from 'chai';
describe('QuerySearch', () => {
it('should render', () => {
expect(
React.isValidElement(<QuerySearch />)
).to.equal(true);
});
it('should have two Select', () => {
const wrapper = shallow(<QuerySearch />);
expect(wrapper.find(Select)).to.have.length(2);
});
it('should have one input for searchText', () => {
const wrapper = shallow(<QuerySearch />);
expect(wrapper.find('input')).to.have.length(1);
});
it('should have one Button', () => {
const wrapper = shallow(<QuerySearch />);
expect(wrapper.find(Button)).to.have.length(1);
});
});

View File

@ -47,6 +47,9 @@ SQLALCHEMY_DATABASE_URI = 'sqlite:///' + os.path.join(DATA_DIR, 'caravel.db')
# SQLALCHEMY_DATABASE_URI = 'mysql://myapp@localhost/myapp'
# SQLALCHEMY_DATABASE_URI = 'postgresql://root:password@localhost/myapp'
# The limit of queries fetched for query search
QUERY_SEARCH_LIMIT = 1000
# Flask-WTF flag for CSRF
CSRF_ENABLED = True

View File

@ -217,7 +217,7 @@ def init(caravel):
'RoleModelView',
'Security',
'UserDBModelView',
'SQL Lab <span class="label label-danger">alpha</span>',
'SQL Lab',
'AccessRequestsModelView',
])

View File

@ -1,4 +1,3 @@
"""Flask web views for Caravel"""
from __future__ import absolute_import
from __future__ import division
from __future__ import print_function
@ -1864,6 +1863,41 @@ class Caravel(BaseCaravelView):
status=200,
mimetype="application/json")
@has_access
@expose("/search_queries")
@log_this
def search_queries(self):
"""Search for queries."""
query = db.session.query(models.Query)
userId = request.args.get('userId')
databaseId = request.args.get('databaseId')
searchText = request.args.get('searchText')
status = request.args.get('status')
if userId != 'null':
# Filter on db Id
query = query.filter(models.Query.user_id == userId)
if databaseId != 'null':
# Filter on db Id
query = query.filter(models.Query.database_id == databaseId)
if status != 'null':
# Filter on status
query = query.filter(models.Query.status == status)
if searchText != 'null':
# Filter on search text
query = query.filter(models.Query.sql.like('%{}%'.format(searchText)))
sql_queries = query.limit(config.get("QUERY_SEARCH_LIMIT")).all()
dict_queries = {q.client_id: q.to_dict() for q in sql_queries}
return Response(
json.dumps(dict_queries, default=utils.json_int_dttm_ser),
status=200,
mimetype="application/json")
@has_access
@expose("/refresh_datasources/")
def refresh_datasources(self):
@ -1938,9 +1972,15 @@ appbuilder.add_view(
category_icon='')
appbuilder.add_link(
'SQL Lab <span class="label label-danger">alpha</span>',
'SQL Editor',
href='/caravel/sqllab',
icon="fa-flask")
icon="fa-flask",
category='SQL Lab')
appbuilder.add_link(
'Query Search',
href='/caravel/sqllab#search',
icon="fa-flask",
category='SQL Lab')
@app.after_request

View File

@ -69,7 +69,7 @@ class CoreTests(CaravelTestCase):
assert_func('RoleModelView', view_menus)
assert_func('Security', view_menus)
assert_func('UserDBModelView', view_menus)
assert_func('SQL Lab <span class="label label-danger">alpha</span>',
assert_func('SQL Lab',
view_menus)
assert_func('AccessRequestsModelView', view_menus)
@ -670,6 +670,15 @@ class CoreTests(CaravelTestCase):
resp = self.client.get('/caravel/queries/{}'.format(0))
self.assertEquals(403, resp.status_code)
def test_search_query_endpoint(self):
userId = 'userId=null'
databaseId = 'databaseId=null'
searchText = 'searchText=null'
status = 'status=success'
params = [userId, databaseId, searchText, status]
resp = self.client.get('/caravel/search_queries?'+'&'.join(params))
self.assertEquals(200, resp.status_code)
def test_public_user_dashboard_access(self):
# Try access before adding appropriate permissions.
self.revoke_public_access('birth_names')