diff --git a/app/actions/sessions.js b/app/actions/sessions.js index 2ab66acff..970b22f55 100644 --- a/app/actions/sessions.js +++ b/app/actions/sessions.js @@ -9,6 +9,7 @@ export const mergeTabMap = createAction('MERGE_TAB_MAP'); export const setTab = createAction('SET_TAB'); export const setTable = createAction('SET_TABLE'); export const setIndex = createAction('SET_INDEX'); +export const setScheduledQueries = createAction('SET_SCHEDULED_QUERIES'); export const mergeConnections = createAction('MERGE_CONNECTIONS'); export const updateConnection = createAction('UPDATE_CREDENTIAL'); export const deleteConnection = createAction('DELETE_CREDENTIAL'); @@ -137,6 +138,19 @@ export function editConnections(connectionObject, connectionId) { ); } +export function getScheduledQueries() { + return dispatch => { + return dispatch(apiThunk( + 'queries', + 'GET', + 'scheduledQueriesRequest' + )).then((json => { + dispatch(setScheduledQueries(json)); + return json; + })); + }; +} + export function connect(connectionId) { return apiThunk( `connections/${connectionId}/connect`, diff --git a/app/components/Settings/Settings.react.js b/app/components/Settings/Settings.react.js index 11d272546..bf6c05221 100644 --- a/app/components/Settings/Settings.react.js +++ b/app/components/Settings/Settings.react.js @@ -1,6 +1,6 @@ import React, {Component} from 'react'; import PropTypes from 'prop-types'; -import {contains, dissoc, flip, head, hasIn, isEmpty, keys, merge, propOr, reduce} from 'ramda'; +import {contains, dissoc, flip, head, hasIn, isEmpty, keys, merge, propEq, propOr, reduce} from 'ramda'; import {connect} from 'react-redux'; import ReactToolTip from 'react-tooltip'; import classnames from 'classnames'; @@ -13,10 +13,10 @@ import DialectSelector from './DialectSelector/DialectSelector.react'; import ConnectButton from './ConnectButton/ConnectButton.react'; import Preview from './Preview/Preview.react'; import {Link} from '../Link.react'; +import Scheduler from './scheduler.jsx'; import {DIALECTS, FAQ, PREVIEW_QUERY, SQL_DIALECTS_USING_EDITOR} from '../../constants/constants.js'; import {isElectron, isOnPrem} from '../../utils/utils'; - class Settings extends Component { constructor(props) { super(props); @@ -169,6 +169,7 @@ class Settings extends Component { getApacheDrillS3Keys, getElasticsearchMappings, getTables, + getScheduledQueries, getS3Keys, getSettings, initialize, @@ -181,7 +182,8 @@ class Settings extends Component { s3KeysRequest, selectedTab, selectedIndex, - tablesRequest + tablesRequest, + scheduledQueriesRequest } = this.props; if (connectionsRequest && !connectionsRequest.status) { initialize(); @@ -199,6 +201,10 @@ class Settings extends Component { getSettings(); } + if (connectRequest.status === 200 && !scheduledQueriesRequest.status) { + getScheduledQueries(); + } + const connectionObject = connections[selectedTab] || {}; if (contains(connectionObject.dialect, SQL_DIALECTS_USING_EDITOR)) { if (connectRequest.status === 200 && !tablesRequest.status) { @@ -268,6 +274,8 @@ class Settings extends Component { setIndex, setTable, selectedTable, + selectedScheduledQueries, + getScheduledQueries, selectedIndex, setTab, tablesRequest, @@ -327,6 +335,7 @@ class Settings extends Component { Connection Query + Schedule {isOnPrem() || @@ -378,6 +387,10 @@ class Settings extends Component { )} + + + + {isOnPrem() || {this.props.connectRequest.status === 200 ? (
@@ -497,6 +510,8 @@ function mapStateToProps(state) { deleteConnectionsRequests, previewTableRequests, tablesRequests, + scheduledQueries, + scheduledQueriesRequest, elasticsearchMappingsRequests, selectedTables, selectedIndecies, @@ -513,6 +528,7 @@ function mapStateToProps(state) { const connectionsHaveBeenSaved = Boolean(selectedConnectionId); const selectedTable = selectedTables[selectedConnectionId] || null; const selectedIndex = selectedIndecies[selectedConnectionId] || null; + const selectedScheduledQueries = scheduledQueries.filter(propEq('connectionId', selectedConnectionId)); let previewTableRequest = {}; if (previewTableRequests[selectedConnectionId] && @@ -533,6 +549,7 @@ function mapStateToProps(state) { deleteConnectionsRequest: deleteConnectionsRequests[selectedConnectionId] || {}, previewTableRequest, tablesRequest: tablesRequests[selectedConnectionId] || {}, + scheduledQueriesRequest: scheduledQueriesRequest || {}, elasticsearchMappingsRequest: elasticsearchMappingsRequests[selectedConnectionId] || {}, s3KeysRequest: s3KeysRequests[selectedConnectionId] || {}, apacheDrillStorageRequest: apacheDrillStorageRequests[selectedConnectionId] || {}, @@ -546,6 +563,7 @@ function mapStateToProps(state) { schemaRequest: schemaRequests[selectedConnectionId], queryRequest: queryRequests[selectedConnectionId], selectedTable, + selectedScheduledQueries, selectedIndex, selectedConnectionId, settingsRequest, @@ -586,6 +604,9 @@ function mergeProps(stateProps, dispatchProps, ownProps) { function boundGetTables() { return dispatch(Actions.getTables(selectedConnectionId)); } + function boundGetScheduledQueries() { + return dispatch(Actions.getScheduledQueries(selectedConnectionId)); + } function boundGetElasticsearchMappings() { return dispatch(Actions.getElasticsearchMappings(selectedConnectionId)); } @@ -679,6 +700,7 @@ function mergeProps(stateProps, dispatchProps, ownProps) { setConnectionNeedToBeSaved: boundSetConnectionNeedToBeSaved, updateConnection: boundUpdateConnection, getTables: boundGetTables, + getScheduledQueries: boundGetScheduledQueries, getElasticsearchMappings: boundGetElasticsearchMappings, setTable: boundSetTable, setIndex: boundSetIndex, @@ -733,6 +755,12 @@ Settings.propTypes = { s3KeysRequest: PropTypes.object, selectedTab: PropTypes.string, selectedIndex: PropTypes.any, + selectedScheduledQueries: PropTypes.arrayOf(PropTypes.shape({ + query: PropTypes.string, + refreshInterval: PropTypes.number + })), + scheduledQueriesRequest: PropTypes.object, + getScheduledQueries: PropTypes.func, tablesRequest: PropTypes.object, deleteTab: PropTypes.func, getSqlSchema: PropTypes.func, diff --git a/app/components/Settings/scheduler.css b/app/components/Settings/scheduler.css new file mode 100644 index 000000000..1671700e8 --- /dev/null +++ b/app/components/Settings/scheduler.css @@ -0,0 +1,52 @@ +/* SQL token highlighting */ +.default .hljs-keyword, +.default .hljs-selector-tag { + color: #ab63fa; +} + +.default .hljs-number, +.default .hljs-meta, +.default .hljs-built_in, +.default .hljs-builtin-name, +.default .hljs-literal, +.default .hljs-type, +.default .hljs-params { + color: #00cc96; +} + +.default .hljs-string, +.default .hljs-symbol, +.default .hljs-bullet { + color: #119DFF; +} + +.default .hljs { + color: #585260; +} + +.bold .hljs-keyword, +.bold .hljs-selector-tag { + font-weight: bold; +} + +/* custom ReactDataGrid styles for scheduled queries table */ +.scheduler-table .react-grid-Row--odd .react-grid-Cell { + background: #fff; +} + +.scheduler-table .react-grid-Row--odd:hover .react-grid-Cell { + background-color: #f9f9f9; +} + +.scheduler-table .react-grid-Row { + border-top: 1px solid #c8d4e3; + cursor: pointer; +} + +.scheduler-table .react-grid-Row:first-child { + border-top: none; +} + +.sql-preview pre { + white-space: pre-wrap; +} diff --git a/app/components/Settings/scheduler.jsx b/app/components/Settings/scheduler.jsx new file mode 100644 index 000000000..fa4bdfdd6 --- /dev/null +++ b/app/components/Settings/scheduler.jsx @@ -0,0 +1,305 @@ +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; + +import ReactDataGrid from 'react-data-grid'; +import ms from 'ms'; +import matchSorter from 'match-sorter'; +import Highlight from 'react-highlight'; + +import { Link } from '../Link.react.js'; +import { Row, Column } from '../layout.jsx'; +import Modal from '../modal.jsx'; +import { plotlyUrl } from '../../utils/utils.js'; + +import './scheduler.css'; + +const NO_OP = () => {}; + +export const SQL = props => ( + {props.children} +); +SQL.propTypes = { + children: PropTypes.string, + className: PropTypes.string +}; + +class QueryFormatter extends React.Component { + static propTypes = { + /* + * Object passed by `react-data-grid` to each row. Here the value + * is an object containg the required `query` string. + */ + value: PropTypes.shape({ + query: PropTypes.string.isRequired + }) + }; + + render() { + const query = this.props.value; + return ( + + + {query.query} + + + ); + } +} + +class IntervalFormatter extends React.Component { + static propTypes = { + /* + * Object passed by `react-data-grid` to each row. Here the value + * is an object containg the required `refreshInterval`. + */ + value: PropTypes.shape({ + refreshInterval: PropTypes.number.isRequired + }) + }; + + render() { + const run = this.props.value; + return ( + + + + {`Runs every ${ms(run.refreshInterval * 1000, { + long: true + })}`} + + + + ); + } +} + +const rowStyle = { + justifyContent: 'flex-start', + borderBottom: '1px solid rgba(0, 0, 0, 0.05)', + padding: '16px 0px' +}; +const boxStyle = { boxSizing: 'border-box', width: '50%' }; + +export const SchedulerPreview = props => { + let content; + if (!props.query) { + content = null; + } else { + const [account, gridId] = props.query.fid.split(':'); + const link = `${plotlyUrl()}/~${account}/${gridId}`; + content = ( + + +
+ {props.query.query} +
+ +
+ + +
Query
+
+ {props.query.query} +
+
+ +
Update Frequency
+ + Runs every{' '} + + {ms(props.query.refreshInterval * 1000, { + long: true + })} + + +
+ +
Live Dataset
+ + {link} + +
+
+
+ ); + } + + return ( + + {content} + + ); +}; + +SchedulerPreview.propTypes = { + onCloseBtnClick: PropTypes.func, + query: PropTypes.object +}; + +function mapRows(rows) { + return rows.map(r => ({ + query: r, + run: r + })); +} + +class Scheduler extends Component { + constructor(props) { + super(props); + this.state = { + search: '', + selectedQuery: null + }; + this.columns = [ + { + key: 'query', + name: 'Query', + filterable: true, + formatter: QueryFormatter + }, + { + key: 'run', + name: 'Interval', + filterable: true, + formatter: IntervalFormatter + } + ]; + + this.handleSearchChange = this.handleSearchChange.bind(this); + this.getRows = this.getRows.bind(this); + this.rowGetter = this.rowGetter.bind(this); + this.openPreview = this.openPreview.bind(this); + this.closePreview = this.closePreview.bind(this); + } + + handleSearchChange(e) { + this.setState({ search: e.target.value }); + } + + getRows() { + return mapRows( + matchSorter(this.props.queries, this.state.search, { + keys: ['query'] + }) + ); + } + + rowGetter(i) { + return this.getRows()[i]; + } + + openPreview(i, query) { + this.setState({ selectedQuery: query.query }); + } + + closePreview() { + this.setState({ selectedQuery: null }); + } + + render() { + const rows = this.getRows(); + + return ( + + + + + + + + + {rows.length} queries + + + + + + + + + + + + + + + + + ); + } +} + +Scheduler.defaultProps = { + queries: [], + refreshQueries: NO_OP +}; + +Scheduler.propTypes = { + queries: PropTypes.arrayOf( + PropTypes.shape({ + query: PropTypes.string.isRequired, + refreshInterval: PropTypes.number.isRequired, + fid: PropTypes.string.isRequired + }).isRequired + ), + refreshQueries: PropTypes.func.isRequired +}; + +export default Scheduler; diff --git a/app/components/layout.jsx b/app/components/layout.jsx new file mode 100644 index 000000000..4e7721961 --- /dev/null +++ b/app/components/layout.jsx @@ -0,0 +1,39 @@ +/** + * This module contains a collection of helpful primitive components + * for layout purposes. + */ +import React from 'react'; +import PropTypes from 'prop-types'; + +export const Row = props => ( +
+ {props.children} +
+); + +Row.propTypes = { + children: PropTypes.node, + style: PropTypes.object +}; + +export const Column = props => ( + +); + +Column.propTypes = { + style: PropTypes.object +}; diff --git a/app/components/modal.jsx b/app/components/modal.jsx new file mode 100644 index 000000000..31e08723a --- /dev/null +++ b/app/components/modal.jsx @@ -0,0 +1,44 @@ +import React, {Component} from 'react'; +import PropTypes from 'prop-types'; + +import enhanceWithClickOutside from 'react-click-outside'; + +const Modal = props => { + const EnhancedClass = enhanceWithClickOutside( + class extends Component { + handleClickOutside() { + props.onClickAway(); + } + render() { + return props.children; + } + } + ); + + return props.open ? +
+ {props.children} +
: null; +}; + +Modal.propTypes = { + children: PropTypes.node, + onClickAway: PropTypes.func, + open: PropTypes.bool +}; + +export default Modal; diff --git a/app/reducers/index.js b/app/reducers/index.js index 4fd6f5f87..7b4cd88dd 100644 --- a/app/reducers/index.js +++ b/app/reducers/index.js @@ -151,6 +151,7 @@ export const elasticsearchMappingsRequests = createApiReducer('elasticsearchMapp export const previewTableRequests = createApiReducer('previewTableRequest'); export const s3KeysRequests = createApiReducer('s3KeysRequests'); export const tablesRequests = createApiReducer('tablesRequests'); +export const scheduledQueriesRequest = createApiReducer('scheduledQueriesRequest'); export const schemaRequests = createApiReducer('schemaRequests'); export const queryRequests = createApiReducer('queryRequests'); @@ -179,6 +180,13 @@ function selectedTables(state = {}, action) { return state; } +function scheduledQueries(state = [], action) { + if (action.type === 'SET_SCHEDULED_QUERIES') { + return action.payload; + } + return state; +} + // object for each tab that tells us if the credentials have been modified since last save to disk function connectionsNeedToBeSaved(state = {}, action) { if (action.type === 'SET_CONNECTIONS_NEED_TO_BE_SAVED') { @@ -244,6 +252,8 @@ const rootReducer = combineReducers({ createCertsRequest, selectedTab, selectedTables, + scheduledQueries, + scheduledQueriesRequest, selectedIndecies, settingsRequest, connectRequests, diff --git a/package.json b/package.json index 4d778e2c7..99d8bcafa 100644 --- a/package.json +++ b/package.json @@ -187,8 +187,10 @@ "immutable": "^3.8.2", "jest": "^22.3.0", "json-loader": "^0.5.4", + "match-sorter": "^2.2.3", "minimist": "^1.2.0", "mkdirp": "^0.5.1", + "ms": "^2.1.1", "nock": "^9.1.5", "node-fetch": "^1.7.2", "node-impala": "^2.0.4", @@ -198,11 +200,13 @@ "ramda": "^0.21.0", "react": "^16.2.0", "react-chart-editor": "^0.13.0", + "react-click-outside": "^3.0.1", "react-codemirror2": "^4.2.1", "react-cookies": "^0.1.0", "react-data-grid": "^3.0.11", "react-data-grid-addons": "^3.0.11", "react-dom": "^16.2.0", + "react-highlight": "^0.12.0", "react-immutable-proptypes": "^2.1.0", "react-plotly.js": "^2.1.0", "react-redux": "^5.0.7", @@ -228,6 +232,7 @@ "selenium-webdriver": "^3.6.0", "sinon": "^4.3.0", "style-loader": "^0.19.0", + "tohash": "^1.0.2", "webpack": "^3.8.1", "yamljs": "^0.3.0" }, diff --git a/test/app/components/Settings/scheduler.test.jsx b/test/app/components/Settings/scheduler.test.jsx new file mode 100644 index 000000000..25ac89ef9 --- /dev/null +++ b/test/app/components/Settings/scheduler.test.jsx @@ -0,0 +1,84 @@ +import React from 'react'; +import sinon from 'sinon'; +import { mount, configure } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; + +import Scheduler, { + SchedulerPreview, + SQL +} from '../../../../app/components/Settings/scheduler.jsx'; +import Modal from '../../../../app/components/modal.jsx'; + +const mockQueries = [ + { + query: 'SELECT * FROM foods;', + refreshInterval: 5000, + fid: 'test:1' + }, + { + query: 'SELECT color, price FROM foods;', + refreshInterval: 10000, + fid: 'test:2' + } +]; + +describe('Scheduler Test', () => { + beforeAll(() => { + configure({ adapter: new Adapter() }); + }); + + it('should have no rows if not passed any queries', () => { + const component = mount(); + expect(component.instance().getRows().length).toBe(0); + }); + + it('should have correct number of rows when passed queries', () => { + const component = mount(); + expect(component.instance().getRows().length).toBe(2); + }); + + it('should filter rows based on search', () => { + const component = mount(); + + // set search to only match one mock query + component.setState({ search: 'color' }); + + expect(component.instance().getRows().length).toBe(1); + }); + + it('clicking refresh button calls refreshQueries prop', () => { + const spy = sinon.spy(); + const component = mount(); + + expect(spy.called).toBe(false); + + // click refresh button + component.find('.refresh-button').simulate('click'); + + expect(spy.callCount).toBe(1); + }); + + it('should open and close modal with correct query', () => { + const component = mount(); + + expect(component.find(Modal).prop('open')).toBe(false); + + // set selected query + component.setState({ selectedQuery: mockQueries[0] }); + + expect(component.find(Modal).prop('open')).toBe(true); + + const modalSqlElements = component + .find(SchedulerPreview) + .find(SQL); + + modalSqlElements.forEach((element) => { + expect(element.text()).toBe('SELECT * FROM foods;'); + }); + + // clear selected query + component.setState({ selectedQuery: null }); + + expect(component.find(Modal).prop('open')).toBe(false); + }); +}); diff --git a/yarn.lock b/yarn.lock index 88bd58813..70dacbd5e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2955,6 +2955,10 @@ devtron@^1.3.0: highlight.js "^9.3.0" humanize-plus "^1.8.1" +diacritic@0.0.2: + version "0.0.2" + resolved "https://registry.yarnpkg.com/diacritic/-/diacritic-0.0.2.tgz#fc2a887b5a5bc0a0a854fb614c7c2f209061ee04" + diff@3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/diff/-/diff-3.2.0.tgz#c9ce393a4b7cbd0b058a725c93df299027868ff9" @@ -5048,7 +5052,7 @@ he@1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/he/-/he-1.1.1.tgz#93410fd21b009735151f8868c2f271f3427e23fd" -highlight.js@^9.3.0: +highlight.js@^9.11.0, highlight.js@^9.3.0: version "9.12.0" resolved "https://registry.yarnpkg.com/highlight.js/-/highlight.js-9.12.0.tgz#e6d9dbe57cbefe60751f02af336195870c90c01e" @@ -5087,6 +5091,10 @@ hoek@4.x.x: version "4.2.0" resolved "https://registry.yarnpkg.com/hoek/-/hoek-4.2.0.tgz#72d9d0754f7fe25ca2d01ad8f8f9a9449a89526d" +hoist-non-react-statics@^2.1.1: + version "2.5.5" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.5.tgz#c5903cf409c0dfd908f388e619d86b9c1174cb47" + hoist-non-react-statics@^2.3.0, hoist-non-react-statics@^2.3.1, hoist-non-react-statics@^2.5.0: version "2.5.0" resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-2.5.0.tgz#d2ca2dfc19c5a91c5a6615ce8e564ef0347e2a40" @@ -6508,6 +6516,12 @@ mat4-recompose@^1.0.3: dependencies: gl-mat4 "^1.0.1" +match-sorter@^2.2.3: + version "2.2.3" + resolved "https://registry.yarnpkg.com/match-sorter/-/match-sorter-2.2.3.tgz#5fc142821fed543f352fb7edce2603970919d225" + dependencies: + diacritic "0.0.2" + material-colors@^1.2.1: version "1.2.5" resolved "https://registry.yarnpkg.com/material-colors/-/material-colors-1.2.5.tgz#5292593e6754cb1bcc2b98030e4e0d6a3afc9ea1" @@ -6732,6 +6746,10 @@ ms@2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" +ms@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" + multi-stage-sourcemap@^0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/multi-stage-sourcemap/-/multi-stage-sourcemap-0.2.1.tgz#b09fc8586eaa17f81d575c4ad02e0f7a3f6b1105" @@ -8315,6 +8333,12 @@ react-chart-editor@^0.13.0: react-tabs "^2.2.1" tinycolor2 "^1.4.1" +react-click-outside@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/react-click-outside/-/react-click-outside-3.0.1.tgz#6e77e84d2f17afaaac26dbad743cbbf909f5e24c" + dependencies: + hoist-non-react-statics "^2.1.1" + react-codemirror2@^4.2.1: version "4.2.1" resolved "https://registry.yarnpkg.com/react-codemirror2/-/react-codemirror2-4.2.1.tgz#4ad3c5c60ebbcb34880f961721b51527324ec021" @@ -8381,6 +8405,12 @@ react-dom@^16.2.0: classnames "^2.2.5" prop-types "^15.6.0" +react-highlight@^0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/react-highlight/-/react-highlight-0.12.0.tgz#34de986a0bfdf228904d0c269b69538d95b35802" + dependencies: + highlight.js "^9.11.0" + react-immutable-proptypes@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/react-immutable-proptypes/-/react-immutable-proptypes-2.1.0.tgz#023d6f39bb15c97c071e9e60d00d136eac5fa0b4" @@ -10294,6 +10324,10 @@ to-px@^1.0.1: dependencies: parse-unit "^1.0.1" +tohash@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/tohash/-/tohash-1.0.2.tgz#9e66e497da0cfd77ba85f9663065adf2d8c99981" + topojson-client@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/topojson-client/-/topojson-client-2.1.0.tgz#ff9f7bf38991185e0b4284c2b06ae834f0eac6c8"