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:
parent
98902599ff
commit
d5c5c0d6ac
|
|
@ -4,3 +4,5 @@ export const STATE_BSSTYLE_MAP = {
|
|||
running: 'warning',
|
||||
success: 'success',
|
||||
};
|
||||
|
||||
export const STATUS_OPTIONS = ['success', 'failed', 'running'];
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: [],
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
);
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ const defaultQueryEditor = {
|
|||
dbId: null,
|
||||
};
|
||||
|
||||
// TODO(bkyryliuk): document the object schemas
|
||||
export const initialState = {
|
||||
alerts: [],
|
||||
networkOn: true,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -217,7 +217,7 @@ def init(caravel):
|
|||
'RoleModelView',
|
||||
'Security',
|
||||
'UserDBModelView',
|
||||
'SQL Lab <span class="label label-danger">alpha</span>',
|
||||
'SQL Lab',
|
||||
'AccessRequestsModelView',
|
||||
])
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue