diff --git a/superset-frontend/cypress-base/cypress/integration/sqllab/tabs.test.js b/superset-frontend/cypress-base/cypress/integration/sqllab/tabs.test.js
index ebcbadff7e..9a0e8f4df6 100644
--- a/superset-frontend/cypress-base/cypress/integration/sqllab/tabs.test.js
+++ b/superset-frontend/cypress-base/cypress/integration/sqllab/tabs.test.js
@@ -27,15 +27,15 @@ describe('SqlLab query tabs', () => {
cy.get('[data-test="sql-editor-tabs"]').then(tabList => {
const initialTabCount = tabList.length;
// add tab
- cy.get('[data-test="add-tab-icon"]').click();
+ cy.get('[data-test="add-tab-icon"]').first().click();
// wait until we find the new tab
cy.get('[data-test="sql-editor-tabs"]')
.children()
- .eq(initialTabCount - 1)
+ .eq(0)
.contains(`Untitled Query ${initialTabCount + 1}`);
cy.get('[data-test="sql-editor-tabs"]')
.children()
- .eq(initialTabCount)
+ .eq(0)
.contains(`Untitled Query ${initialTabCount + 2}`);
});
});
@@ -47,9 +47,12 @@ describe('SqlLab query tabs', () => {
const initialTabCount = tabListA.length;
// open the tab dropdown to remove
- cy.get('[data-test="dropdown-toggle-button"]').click({
- force: true,
- });
+ cy.get('[data-test="dropdown-toggle-button"]')
+ .children()
+ .first()
+ .click({
+ force: true,
+ });
// first item is close
cy.get('[data-test="close-tab-menu-option"]').click();
diff --git a/superset-frontend/spec/javascripts/profile/App_spec.tsx b/superset-frontend/spec/javascripts/profile/App_spec.tsx
index 13b53b8b76..e45b1999be 100644
--- a/superset-frontend/spec/javascripts/profile/App_spec.tsx
+++ b/superset-frontend/spec/javascripts/profile/App_spec.tsx
@@ -17,9 +17,10 @@
* under the License.
*/
import React from 'react';
-import { Col, Row, Tab } from 'react-bootstrap';
+import { Col, Row } from 'react-bootstrap';
import { shallow } from 'enzyme';
import App from 'src/profile/components/App';
+import Tabs from 'src/common/components/Tabs';
import { user } from './fixtures';
@@ -39,6 +40,6 @@ describe('App', () => {
it('renders 4 Tabs', () => {
const wrapper = shallow();
- expect(wrapper.find(Tab)).toHaveLength(4);
+ expect(wrapper.find(Tabs.TabPane)).toHaveLength(4);
});
});
diff --git a/superset-frontend/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx b/superset-frontend/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx
index 2d6823222c..2832b32a10 100644
--- a/superset-frontend/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx
+++ b/superset-frontend/spec/javascripts/sqllab/TabbedSqlEditors_spec.jsx
@@ -20,10 +20,10 @@ import React from 'react';
import configureStore from 'redux-mock-store';
import thunk from 'redux-thunk';
import URI from 'urijs';
-import { Tab } from 'react-bootstrap';
import { shallow, mount } from 'enzyme';
import sinon from 'sinon';
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
+import { EditableTabs } from 'src/common/components/Tabs';
import TabbedSqlEditors from 'src/SqlLab/components/TabbedSqlEditors';
import SqlEditor from 'src/SqlLab/components/SqlEditor';
@@ -206,36 +206,36 @@ describe('TabbedSqlEditors', () => {
},
};
wrapper = getWrapper();
- sinon.spy(wrapper.instance(), 'newQueryEditor');
sinon.stub(wrapper.instance().props.actions, 'switchQueryEditor');
- wrapper.instance().handleSelect('add_tab', mockEvent);
- expect(wrapper.instance().newQueryEditor.callCount).toBe(1);
-
// cannot switch to current tab, switchQueryEditor never gets called
wrapper.instance().handleSelect('dfsadfs', mockEvent);
expect(
wrapper.instance().props.actions.switchQueryEditor.callCount,
).toEqual(0);
+ });
+ it('should handle add tab', () => {
+ wrapper = getWrapper();
+ sinon.spy(wrapper.instance(), 'newQueryEditor');
+
+ wrapper.instance().handleEdit('1', 'add');
+ expect(wrapper.instance().newQueryEditor.callCount).toBe(1);
wrapper.instance().newQueryEditor.restore();
});
it('should render', () => {
wrapper = getWrapper();
wrapper.setState({ hideLeftBar: true });
- const firstTab = wrapper.find(Tab).first();
- expect(firstTab.props().eventKey).toContain(
+ const firstTab = wrapper.find(EditableTabs.TabPane).first();
+ expect(firstTab.props()['data-key']).toContain(
initialState.sqlLab.queryEditors[0].id,
);
expect(firstTab.find(SqlEditor)).toHaveLength(1);
-
- const lastTab = wrapper.find(Tab).last();
- expect(lastTab.props().eventKey).toContain('add_tab');
});
it('should disable new tab when offline', () => {
wrapper = getWrapper();
- expect(wrapper.find(Tab).last().props().disabled).toBe(false);
+ expect(wrapper.find(EditableTabs).props().hideAdd).toBe(false);
wrapper.setProps({ offline: true });
- expect(wrapper.find(Tab).last().props().disabled).toBe(true);
+ expect(wrapper.find(EditableTabs).props().hideAdd).toBe(true);
});
});
diff --git a/superset-frontend/src/SqlLab/components/SouthPane.jsx b/superset-frontend/src/SqlLab/components/SouthPane.jsx
index 2b2b27052c..526508a2d4 100644
--- a/superset-frontend/src/SqlLab/components/SouthPane.jsx
+++ b/superset-frontend/src/SqlLab/components/SouthPane.jsx
@@ -19,7 +19,8 @@
import React from 'react';
import PropTypes from 'prop-types';
import shortid from 'shortid';
-import { Alert, Tab, Tabs } from 'react-bootstrap';
+import { Alert } from 'react-bootstrap';
+import Tabs from 'src/common/components/Tabs';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import { t } from '@superset-ui/core';
@@ -140,9 +141,8 @@ export class SouthPane extends React.PureComponent {
);
}
const dataPreviewTabs = props.dataPreviewQueries.map(query => (
-
-
+
));
return (
-
+
{results}
-
-
+
+
-
+
{dataPreviewTabs}
diff --git a/superset-frontend/src/SqlLab/components/TabbedSqlEditors.jsx b/superset-frontend/src/SqlLab/components/TabbedSqlEditors.jsx
index 8ee59d5541..5461645476 100644
--- a/superset-frontend/src/SqlLab/components/TabbedSqlEditors.jsx
+++ b/superset-frontend/src/SqlLab/components/TabbedSqlEditors.jsx
@@ -18,18 +18,19 @@
*/
import React from 'react';
import PropTypes from 'prop-types';
-import { MenuItem, DropdownButton, Tab, Tabs } from 'react-bootstrap';
+import { EditableTabs } from 'src/common/components/Tabs';
+import { Dropdown } from 'src/common/components/Dropdown';
+import { Menu } from 'src/common/components';
import { connect } from 'react-redux';
import { bindActionCreators } from 'redux';
import URI from 'urijs';
-import { t } from '@superset-ui/core';
+import { styled, t } from '@superset-ui/core';
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
+import { areArraysShallowEqual } from 'src/reduxUtils';
import * as Actions from '../actions/sqlLab';
import SqlEditor from './SqlEditor';
-import { areArraysShallowEqual } from '../../reduxUtils';
import TabStatusIcon from './TabStatusIcon';
-import Icon from '../../components/Icon';
const propTypes = {
actions: PropTypes.object.isRequired,
@@ -57,6 +58,10 @@ const defaultProps = {
let queryCount = 1;
+const TabTitle = styled.span`
+ margin-right: ${({ theme }) => theme.gridUnit * 2}px;
+`;
+
class TabbedSqlEditors extends React.PureComponent {
constructor(props) {
super(props);
@@ -74,6 +79,8 @@ class TabbedSqlEditors extends React.PureComponent {
this,
);
this.duplicateQueryEditor = this.duplicateQueryEditor.bind(this);
+ this.handleSelect = this.handleSelect.bind(this);
+ this.handleEdit = this.handleEdit.bind(this);
}
componentDidMount() {
@@ -253,17 +260,23 @@ class TabbedSqlEditors extends React.PureComponent {
}
handleSelect(key) {
- if (key === 'add_tab') {
+ const qeid = this.props.tabHistory[this.props.tabHistory.length - 1];
+ if (key !== qeid) {
+ const queryEditor = this.props.queryEditors.find(qe => qe.id === key);
+ this.props.actions.switchQueryEditor(
+ queryEditor,
+ this.props.displayLimit,
+ );
+ }
+ }
+
+ handleEdit(key, action) {
+ if (action === 'remove') {
+ const qe = this.props.queryEditors.find(qe => qe.id === key);
+ this.removeQueryEditor(qe);
+ }
+ if (action === 'add') {
this.newQueryEditor();
- } else {
- const qeid = this.props.tabHistory[this.props.tabHistory.length - 1];
- if (key !== qeid) {
- const queryEditor = this.props.queryEditors.find(qe => qe.id === key);
- this.props.actions.switchQueryEditor(
- queryEditor,
- this.props.displayLimit,
- );
- }
}
}
@@ -286,10 +299,7 @@ class TabbedSqlEditors extends React.PureComponent {
}
render() {
- const editors = this.props.queryEditors.map((qe, i) => {
- const isSelected =
- this.activeQueryEditor() && this.activeQueryEditor().id === qe.id;
-
+ const editors = this.props.queryEditors.map(qe => {
let latestQuery;
if (qe.latestQueryId) {
latestQuery = this.props.queries[qe.latestQueryId];
@@ -300,123 +310,97 @@ class TabbedSqlEditors extends React.PureComponent {
}
const state = latestQuery ? latestQuery.state : '';
- const title = (
- <>
- {qe.title} {' '}
-
+ this.removeQueryEditor(qe)}
- />
- >
+ data-test="close-tab-menu-option"
+ >
+
+
+
+ {t('Close tab')}
+
+ this.renameTab(qe)}>
+
+
+
+ {t('Rename tab')}
+
+
+
+
+
+ {this.state.hideLeftBar ? t('Expand tool bar') : t('Hide tool bar')}
+
+ this.removeAllOtherQueryEditors(qe)}
+ >
+
+
+
+ {t('Close all other tabs')}
+
+ this.duplicateQueryEditor(qe)}>
+
+
+
+ {t('Duplicate tab')}
+
+
);
- const tabTitle = (
+
+ const tabHeader = (
<>
- {isSelected && (
-
-
-
-
-
-
-
- )}
- {title}
+
+
+
+ {qe.title} {' '}
>
);
return (
-
- {isSelected && (
- xt.queryEditorId === qe.id,
- )}
- queryEditor={qe}
- editorQueries={this.state.queriesArray}
- dataPreviewQueries={this.state.dataPreviewQueries}
- latestQuery={latestQuery}
- database={database}
- actions={this.props.actions}
- hideLeftBar={this.state.hideLeftBar}
- defaultQueryLimit={this.props.defaultQueryLimit}
- maxRow={this.props.maxRow}
- displayLimit={this.props.displayLimit}
- saveQueryWarning={this.props.saveQueryWarning}
- scheduleQueryWarning={this.props.scheduleQueryWarning}
- />
- )}
-
+
+ xt.queryEditorId === qe.id)}
+ queryEditor={qe}
+ editorQueries={this.state.queriesArray}
+ dataPreviewQueries={this.state.dataPreviewQueries}
+ latestQuery={latestQuery}
+ database={database}
+ actions={this.props.actions}
+ hideLeftBar={this.state.hideLeftBar}
+ defaultQueryLimit={this.props.defaultQueryLimit}
+ maxRow={this.props.maxRow}
+ displayLimit={this.props.displayLimit}
+ saveQueryWarning={this.props.saveQueryWarning}
+ scheduleQueryWarning={this.props.scheduleQueryWarning}
+ />
+
);
});
+
return (
- }
>
{editors}
-
-
-
-
- }
- className="addEditorTab"
- eventKey="add_tab"
- disabled={this.props.offline}
- />
-
+
);
}
}
diff --git a/superset-frontend/src/common/components/Dropdown.tsx b/superset-frontend/src/common/components/Dropdown.tsx
new file mode 100644
index 0000000000..18ac168129
--- /dev/null
+++ b/superset-frontend/src/common/components/Dropdown.tsx
@@ -0,0 +1,78 @@
+/**
+ * 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 { Dropdown as AntdDropdown } from 'src/common/components';
+import { css } from '@emotion/core';
+import { styled } from '@superset-ui/core';
+
+const dotStyle = css`
+ width: 3px;
+ height: 3px;
+ border-radius: 1.5px;
+ background-color: #bababa;
+`;
+
+const MenuDots = styled.div`
+ ${dotStyle};
+ font-weight: ${({ theme }) => theme.typography.weights.normal};
+ display: inline-flex;
+
+ &:hover {
+ background-color: ${({ theme }) => theme.colors.primary.base};
+
+ &::before,
+ &::after {
+ background-color: ${({ theme }) => theme.colors.primary.base};
+ }
+ }
+
+ &::before,
+ &::after {
+ position: absolute;
+ content: ' ';
+ ${dotStyle};
+ }
+
+ &::before {
+ transform: translateY(-${({ theme }) => theme.gridUnit}px);
+ }
+
+ &::after {
+ transform: translateY(${({ theme }) => theme.gridUnit}px);
+ }
+`;
+
+const MenuDotsWrapper = styled.div`
+ display: flex;
+ align-items: center;
+ padding: ${({ theme }) => theme.gridUnit * 2}px;
+ padding-left: ${({ theme }) => theme.gridUnit}px;
+`;
+
+interface DropdownProps {
+ overlay: React.ReactElement;
+}
+
+export const Dropdown = ({ overlay, ...rest }: DropdownProps) => (
+
+
+
+
+
+);
diff --git a/superset-frontend/src/common/components/Modal.tsx b/superset-frontend/src/common/components/Modal.tsx
index 9e97f6f2d8..9634cc3c07 100644
--- a/superset-frontend/src/common/components/Modal.tsx
+++ b/superset-frontend/src/common/components/Modal.tsx
@@ -78,6 +78,11 @@ const StyledModal = styled(BaseModal)`
margin-left: 8px;
}
}
+
+ // styling for Tabs component
+ .ant-tabs {
+ margin-top: -18px;
+ }
`;
export default function Modal({
diff --git a/superset-frontend/src/common/components/Tabs.tsx b/superset-frontend/src/common/components/Tabs.tsx
index 932e444b1d..9346d0aa4c 100644
--- a/superset-frontend/src/common/components/Tabs.tsx
+++ b/superset-frontend/src/common/components/Tabs.tsx
@@ -16,27 +16,46 @@
* specific language governing permissions and limitations
* under the License.
*/
+import React from 'react';
import { styled } from '@superset-ui/core';
import { Tabs as AntdTabs } from 'src/common/components';
+import { css } from '@emotion/core';
+import Icon from '../../components/Icon';
-const StyledTabs = styled(AntdTabs)`
- margin-top: -18px;
+interface TabsProps {
+ fullWidth?: boolean;
+}
- .ant-tabs-nav-list {
- width: 100%;
- }
+const notForwardedProps = ['fullWidth'];
+const StyledTabs = styled(AntdTabs, {
+ shouldForwardProp: prop => !notForwardedProps.includes(prop),
+})`
.ant-tabs-tab {
flex: 1 1 auto;
- width: 0;
&.ant-tabs-tab-active .ant-tabs-tab-btn {
color: inherit;
}
}
+ ${({ fullWidth }) =>
+ fullWidth &&
+ css`
+ .ant-tabs-nav-list {
+ width: 100%;
+ }
+
+ .ant-tabs-tab {
+ width: 0;
+ }
+ `};
+
.ant-tabs-tab-btn {
+ display: flex;
flex: 1 1 auto;
+ align-items: center;
+ justify-content: center;
font-size: ${({ theme }) => theme.typography.sizes.s}px;
text-align: center;
text-transform: uppercase;
@@ -59,4 +78,48 @@ const Tabs = Object.assign(StyledTabs, {
TabPane: StyledTabPane,
});
+Tabs.defaultProps = {
+ fullWidth: true,
+};
+
+const StyledEditableTabs = styled(StyledTabs)`
+ .ant-tabs-content-holder {
+ background: white;
+ }
+
+ & > .ant-tabs-nav {
+ margin-bottom: 0;
+ }
+
+ .ant-tabs-tab-remove {
+ padding-top: 0;
+ padding-bottom: 0;
+ height: ${({ theme }) => theme.gridUnit * 6}px;
+ }
+
+ ${({ fullWidth }) =>
+ fullWidth &&
+ css`
+ .ant-tabs-nav-list {
+ width: 100%;
+ }
+ `}
+`;
+
+const EditableTabs = Object.assign(StyledEditableTabs, {
+ TabPane: StyledTabPane,
+});
+
+EditableTabs.defaultProps = {
+ type: 'editable-card',
+ fullWidth: false,
+};
+
+EditableTabs.TabPane.defaultProps = {
+ closeIcon: (
+
+ ),
+};
+
export default Tabs;
+export { EditableTabs };
diff --git a/superset-frontend/src/common/components/common.stories.tsx b/superset-frontend/src/common/components/common.stories.tsx
index 3ed541d26a..6b564d026f 100644
--- a/superset-frontend/src/common/components/common.stories.tsx
+++ b/superset-frontend/src/common/components/common.stories.tsx
@@ -20,7 +20,9 @@ import React from 'react';
import { action } from '@storybook/addon-actions';
import { withKnobs, boolean } from '@storybook/addon-knobs';
import Modal from './Modal';
-import Tabs from './Tabs';
+import Tabs, { EditableTabs } from './Tabs';
+import { Menu } from '.';
+import { Dropdown } from './Dropdown';
export default {
title: 'Common Components',
@@ -42,7 +44,11 @@ export const StyledModal = () => (
);
export const StyledTabs = () => (
-
+
(
);
+
+export const StyledEditableTabs = () => (
+
+
+ Tab 1 Content!
+
+
+ Tab 2 Content!
+
+
+);
+
+export const TabsWithDropdownMenu = () => (
+
+
+
+ Item 1
+ Item 2
+
+ }
+ />
+ Tab with dropdown menu
+ >
+ }
+ key="1"
+ disabled={boolean('Tab 1 Disabled', false)}
+ >
+ Tab 1 Content!
+
+
+);
diff --git a/superset-frontend/src/profile/components/App.tsx b/superset-frontend/src/profile/components/App.tsx
index 0d18199c11..8642dd78e1 100644
--- a/superset-frontend/src/profile/components/App.tsx
+++ b/superset-frontend/src/profile/components/App.tsx
@@ -17,7 +17,8 @@
* under the License.
*/
import React from 'react';
-import { Col, Row, Tabs, Tab, Panel } from 'react-bootstrap';
+import { Col, Row, Panel } from 'react-bootstrap';
+import Tabs from 'src/common/components/Tabs';
import { t } from '@superset-ui/core';
import Favorites from './Favorites';
@@ -39,10 +40,10 @@ export default function App({ user }: AppProps) {
-
-
+
{t('Favorites')}
@@ -53,10 +54,10 @@ export default function App({ user }: AppProps) {
-
-
+
{t('Created Content')}
@@ -67,10 +68,10 @@ export default function App({ user }: AppProps) {
-
-
+
{t('Recent Activity')}
@@ -81,10 +82,10 @@ export default function App({ user }: AppProps) {
-
-
+
{t('Security & Access')}
@@ -95,7 +96,7 @@ export default function App({ user }: AppProps) {
-
+