This is an automated email from the ASF dual-hosted git repository. ccwilliams pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/incubator-superset.git
The following commit(s) were added to refs/heads/master by this push: new 90809f6 [sqllab] more robust copy to clipboard (#6180) 90809f6 is described below commit 90809f6d39010aa889ae6bbc5b1ec2fe4afdc903 Author: Chris Williams <willias...@users.noreply.github.com> AuthorDate: Wed Oct 24 19:41:32 2018 -0700 [sqllab] more robust copy to clipboard (#6180) * [sqllab] implement more robust share query button * [sqllab] fix Hotkeys react warnings * [CopyToClipboard] don't bind in render functions * [explorer] fix short url button position * [sqllab] remove share query item in tab dropdown menu * [sqllab] reference new ShareSqlLabQuery component * [sqllab][share query robustness] clean up * [sqllab] fix tests * [sqllab][share query] add unit tests --- .../javascripts/sqllab/CopyQueryTabUrl_spec.jsx | 15 ---- .../javascripts/sqllab/ShareSqlLabQuery_spec.jsx | 91 ++++++++++++++++++++++ .../src/SqlLab/components/CopyQueryTabUrl.jsx | 60 -------------- .../assets/src/SqlLab/components/ShareQuery.jsx | 22 ------ .../src/SqlLab/components/ShareSqlLabQuery.jsx | 80 +++++++++++++++++++ .../assets/src/SqlLab/components/SqlEditor.jsx | 4 +- .../src/SqlLab/components/TabbedSqlEditors.jsx | 4 +- superset/assets/src/components/CopyToClipboard.jsx | 13 +--- superset/assets/src/components/Hotkeys.jsx | 4 +- .../assets/src/components/URLShortLinkButton.jsx | 1 + 10 files changed, 181 insertions(+), 113 deletions(-) diff --git a/superset/assets/spec/javascripts/sqllab/CopyQueryTabUrl_spec.jsx b/superset/assets/spec/javascripts/sqllab/CopyQueryTabUrl_spec.jsx deleted file mode 100644 index dd23016..0000000 --- a/superset/assets/spec/javascripts/sqllab/CopyQueryTabUrl_spec.jsx +++ /dev/null @@ -1,15 +0,0 @@ -import React from 'react'; -import { initialState } from './fixtures'; - -import CopyQueryTabUrl from '../../../src/SqlLab/components/CopyQueryTabUrl'; - -describe('CopyQueryTabUrl', () => { - const mockedProps = { - queryEditor: initialState.sqlLab.queryEditors[0], - }; - it('is valid with props', () => { - expect( - React.isValidElement(<CopyQueryTabUrl {...mockedProps} />), - ).toBe(true); - }); -}); diff --git a/superset/assets/spec/javascripts/sqllab/ShareSqlLabQuery_spec.jsx b/superset/assets/spec/javascripts/sqllab/ShareSqlLabQuery_spec.jsx new file mode 100644 index 0000000..4746a0b --- /dev/null +++ b/superset/assets/spec/javascripts/sqllab/ShareSqlLabQuery_spec.jsx @@ -0,0 +1,91 @@ +import React from 'react'; +import configureStore from 'redux-mock-store'; +import thunk from 'redux-thunk'; +import { OverlayTrigger } from 'react-bootstrap'; +import fetchMock from 'fetch-mock'; +import { shallow } from 'enzyme'; + +import * as utils from '../../../src/utils/common'; +import Button from '../../../src/components/Button'; +import ShareSqlLabQuery from '../../../src/SqlLab/components/ShareSqlLabQuery'; + +const mockStore = configureStore([thunk]); +const store = mockStore(); + +describe('ShareSqlLabQuery', () => { + const storeQueryUrl = 'glob:*/kv/store/'; + const storeQueryMockId = '123'; + + beforeEach(() => { + fetchMock.post(storeQueryUrl, () => ({ id: storeQueryMockId }), { + overwriteRoutes: true, + }); + fetchMock.resetHistory(); + }); + + afterAll(fetchMock.reset); + + const defaultProps = { + queryEditor: { + dbId: 0, + title: 'query title', + schema: 'query_schema', + autorun: false, + sql: 'SELECT * FROM ...', + }, + }; + + function setup(overrideProps) { + const wrapper = shallow(<ShareSqlLabQuery {...defaultProps} {...overrideProps} />, { + context: { store }, + }).dive(); // wrapped in withToasts HOC + + return wrapper; + } + + it('renders an OverlayTrigger with Button', () => { + const wrapper = setup(); + const trigger = wrapper.find(OverlayTrigger); + const button = trigger.find(Button); + + expect(trigger).toHaveLength(1); + expect(button).toHaveLength(1); + }); + + it('calls storeQuery() with the query when getCopyUrl() is called and saves the url', () => { + expect.assertions(4); + const storeQuerySpy = jest.spyOn(utils, 'storeQuery'); + + const wrapper = setup(); + const instance = wrapper.instance(); + + return instance.getCopyUrl().then(() => { + expect(storeQuerySpy.mock.calls).toHaveLength(1); + expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1); + expect(storeQuerySpy.mock.calls[0][0]).toMatchObject(defaultProps.queryEditor); + expect(instance.state.shortUrl).toContain(storeQueryMockId); + + return Promise.resolve(); + }); + }); + + it('dispatches an error toast upon fetching failure', () => { + expect.assertions(3); + const error = 'error'; + const addDangerToastSpy = jest.fn(); + fetchMock.post(storeQueryUrl, { throws: error }, { overwriteRoutes: true }); + const wrapper = setup(); + wrapper.setProps({ addDangerToast: addDangerToastSpy }); + + return wrapper + .instance() + .getCopyUrl() + .then(() => { + expect(fetchMock.calls(storeQueryUrl)).toHaveLength(1); + expect(addDangerToastSpy.mock.calls).toHaveLength(1); + expect(addDangerToastSpy.mock.calls[0][0]).toBe(error); + + return Promise.resolve(); + }); + }); +}); diff --git a/superset/assets/src/SqlLab/components/CopyQueryTabUrl.jsx b/superset/assets/src/SqlLab/components/CopyQueryTabUrl.jsx deleted file mode 100644 index 7042268..0000000 --- a/superset/assets/src/SqlLab/components/CopyQueryTabUrl.jsx +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint no-alert: 0 */ -import React from 'react'; -import PropTypes from 'prop-types'; -import CopyToClipboard from '../../components/CopyToClipboard'; -import { storeQuery } from '../../utils/common'; -import { t } from '../../locales'; - -const propTypes = { - queryEditor: PropTypes.object.isRequired, -}; - -export default class CopyQueryTabUrl extends React.PureComponent { - constructor(props) { - super(props); - this.getUrl = this.getUrl.bind(this); - } - - getUrl(callback) { - const qe = this.props.queryEditor; - const sharedQuery = { - dbId: qe.dbId, - title: qe.title, - schema: qe.schema, - autorun: qe.autorun, - sql: qe.sql, - }; - - // the fetch call to get a url is async, but execCommand('copy') must be sync - // get around this with 2 timeouts. calling a timeout from within a timeout is not considered - // a short-lived, user-initiated sync event - let url; - storeQuery(sharedQuery).then((shareUrl) => { url = shareUrl; }); - const longTimeout = setTimeout(() => { if (url) callback(url); }, 750); - setTimeout(() => { - if (url) { - callback(url); - clearTimeout(longTimeout); - } - }, 150); - - } - - render() { - return ( - <CopyToClipboard - inMenu - copyNode={ - <div> - <i className="fa fa-clipboard" /> <span>{t('share query')}</span> - </div> - } - tooltipText={t('copy URL to clipboard')} - shouldShowText={false} - getText={this.getUrl} - /> - ); - } -} - -CopyQueryTabUrl.propTypes = propTypes; diff --git a/superset/assets/src/SqlLab/components/ShareQuery.jsx b/superset/assets/src/SqlLab/components/ShareQuery.jsx deleted file mode 100644 index 48e8330..0000000 --- a/superset/assets/src/SqlLab/components/ShareQuery.jsx +++ /dev/null @@ -1,22 +0,0 @@ -import React from 'react'; - -import CopyToClipboard from '../../components/CopyToClipboard'; -import CopyQueryTabUrl from './CopyQueryTabUrl'; -import Button from '../../components/Button'; -import { t } from '../../locales'; - -export default class ShareQuery extends CopyQueryTabUrl { - render() { - return ( - <CopyToClipboard - copyNode={( - <Button bsSize="small" className="toggleSave"> - <i className="fa fa-clipboard" /> {t('Share Query')} - </Button> - )} - tooltipText={t('copy URL to clipboard')} - shouldShowText={false} - getText={this.getUrl} - />); - } -} diff --git a/superset/assets/src/SqlLab/components/ShareSqlLabQuery.jsx b/superset/assets/src/SqlLab/components/ShareSqlLabQuery.jsx new file mode 100644 index 0000000..55f8c88 --- /dev/null +++ b/superset/assets/src/SqlLab/components/ShareSqlLabQuery.jsx @@ -0,0 +1,80 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import { Popover, OverlayTrigger } from 'react-bootstrap'; + +import Button from '../../components/Button'; +import CopyToClipboard from '../../components/CopyToClipboard'; +import { storeQuery } from '../../utils/common'; +import { getClientErrorObject } from '../../modules/utils'; +import { t } from '../../locales'; +import withToasts from '../../messageToasts/enhancers/withToasts'; + +const propTypes = { + queryEditor: PropTypes.shape({ + dbId: PropTypes.number, + title: PropTypes.string, + schema: PropTypes.string, + autorun: PropTypes.bool, + sql: PropTypes.string, + }).isRequired, + addDangerToast: PropTypes.func.isRequired, +}; + +class ShareSqlLabQuery extends React.Component { + constructor(props) { + super(props); + this.state = { + shortUrl: 'Loading ...', + showOverlay: false, + }; + this.getCopyUrl = this.getCopyUrl.bind(this); + } + + getCopyUrl() { + const { dbId, title, schema, autorun, sql } = this.props.queryEditor; + const sharedQuery = { dbId, title, schema, autorun, sql }; + + return storeQuery(sharedQuery) + .then((shortUrl) => { + this.setState({ shortUrl }); + }) + .catch((response) => { + getClientErrorObject(response).then(({ error }) => { + this.props.addDangerToast(error); + this.setState({ shortUrl: t('Error') }); + }); + }); + } + + renderPopover() { + return ( + <Popover id="sqllab-shareurl-popover"> + <CopyToClipboard + text={this.state.shortUrl || 'Loading ...'} + copyNode={<i className="fa fa-clipboard" title={t('Copy to clipboard')} />} + /> + </Popover> + ); + } + + render() { + return ( + <OverlayTrigger + trigger="click" + placement="top" + onEnter={this.getCopyUrl} + rootClose + shouldUpdatePosition + overlay={this.renderPopover()} + > + <Button bsSize="small" className="toggleSave"> + <i className="fa fa-clipboard" /> {t('Share Query')} + </Button> + </OverlayTrigger> + ); + } +} + +ShareSqlLabQuery.propTypes = propTypes; + +export default withToasts(ShareSqlLabQuery); diff --git a/superset/assets/src/SqlLab/components/SqlEditor.jsx b/superset/assets/src/SqlLab/components/SqlEditor.jsx index 3dd9d48..7f82e02 100644 --- a/superset/assets/src/SqlLab/components/SqlEditor.jsx +++ b/superset/assets/src/SqlLab/components/SqlEditor.jsx @@ -19,7 +19,7 @@ import Button from '../../components/Button'; import TemplateParamsEditor from './TemplateParamsEditor'; import SouthPane from './SouthPane'; import SaveQuery from './SaveQuery'; -import ShareQuery from './ShareQuery'; +import ShareSqlLabQuery from './ShareSqlLabQuery'; import Timer from '../../components/Timer'; import Hotkeys from '../../components/Hotkeys'; import SqlEditorLeftBar from './SqlEditorLeftBar'; @@ -235,7 +235,7 @@ class SqlEditor extends React.PureComponent { /> </span> <span className="m-r-5"> - <ShareQuery queryEditor={qe} /> + <ShareSqlLabQuery queryEditor={qe} /> </span> {ctasControls} <span className="m-l-5"> diff --git a/superset/assets/src/SqlLab/components/TabbedSqlEditors.jsx b/superset/assets/src/SqlLab/components/TabbedSqlEditors.jsx index e693295..9bf3978 100644 --- a/superset/assets/src/SqlLab/components/TabbedSqlEditors.jsx +++ b/superset/assets/src/SqlLab/components/TabbedSqlEditors.jsx @@ -7,7 +7,6 @@ import URI from 'urijs'; import * as Actions from '../actions'; import SqlEditor from './SqlEditor'; -import CopyQueryTabUrl from './CopyQueryTabUrl'; import { areArraysShallowEqual } from '../../reduxUtils'; import { t } from '../../locales'; import TabStatusIcon from './TabStatusIcon'; @@ -189,8 +188,7 @@ class TabbedSqlEditors extends React.PureComponent { </div> {t('Rename tab')} </MenuItem> - {qe && <CopyQueryTabUrl queryEditor={qe} />} - <MenuItem eventKey="4" onClick={this.toggleLeftBar.bind(this)}> + <MenuItem eventKey="3" onClick={this.toggleLeftBar.bind(this)}> <div className="icon-container"> <i className="fa fa-cogs" /> </div> diff --git a/superset/assets/src/components/CopyToClipboard.jsx b/superset/assets/src/components/CopyToClipboard.jsx index 9514b18..c2b9c57 100644 --- a/superset/assets/src/components/CopyToClipboard.jsx +++ b/superset/assets/src/components/CopyToClipboard.jsx @@ -32,6 +32,7 @@ export default class CopyToClipboard extends React.Component { this.copyToClipboard = this.copyToClipboard.bind(this); this.resetTooltipText = this.resetTooltipText.bind(this); this.onMouseOut = this.onMouseOut.bind(this); + this.onClick = this.onClick.bind(this); } onMouseOut() { @@ -105,7 +106,7 @@ export default class CopyToClipboard extends React.Component { overlay={this.renderTooltip()} trigger={['hover']} bsStyle="link" - onClick={this.onClick.bind(this)} + onClick={this.onClick} onMouseOut={this.onMouseOut} > {this.props.copyNode} @@ -119,7 +120,7 @@ export default class CopyToClipboard extends React.Component { <OverlayTrigger placement="top" overlay={this.renderTooltip()} trigger={['hover']}> <MenuItem> <span - onClick={this.onClick.bind(this)} + onClick={this.onClick} onMouseOut={this.onMouseOut} > {this.props.copyNode} @@ -138,13 +139,7 @@ export default class CopyToClipboard extends React.Component { } render() { - let html; - if (this.props.inMenu) { - html = this.renderInMenu(); - } else { - html = this.renderLink(); - } - return html; + return this.props.inMenu ? this.renderInMenu() : this.renderLink(); } } diff --git a/superset/assets/src/components/Hotkeys.jsx b/superset/assets/src/components/Hotkeys.jsx index fbecee8..97d8c95 100644 --- a/superset/assets/src/components/Hotkeys.jsx +++ b/superset/assets/src/components/Hotkeys.jsx @@ -26,7 +26,7 @@ export default class Hotkeys extends React.PureComponent { const { header, hotkeys } = this.props; return ( - <Popover title={header} style={{ width: '300px' }}> + <Popover id="hotkey-popover" title={header} style={{ width: '300px' }}> <table className="table table-condensed"> <thead> <tr> @@ -36,7 +36,7 @@ export default class Hotkeys extends React.PureComponent { </thead> <tbody> {hotkeys.map(({ key, descr }) => ( - <tr> + <tr key={key}> <td><code>{key}</code></td> <td>{descr}</td> </tr> diff --git a/superset/assets/src/components/URLShortLinkButton.jsx b/superset/assets/src/components/URLShortLinkButton.jsx index b0570c0..47fd332 100644 --- a/superset/assets/src/components/URLShortLinkButton.jsx +++ b/superset/assets/src/components/URLShortLinkButton.jsx @@ -54,6 +54,7 @@ class URLShortLinkButton extends React.Component { <OverlayTrigger trigger="click" rootClose + shouldUpdatePosition placement="left" onEnter={this.getCopyUrl} overlay={this.renderPopover()}