From 8cd80a25d6d26a59e977f08b7430d279e78ab0f0 Mon Sep 17 00:00:00 2001 From: Beto Dealmeida Date: Fri, 10 Aug 2018 09:40:21 -0700 Subject: [PATCH] Landing Page (frontend only) (cherry picked from commit 46487c588d76b78aaa83c39aba3c876eb6c674a7) (cherry picked from commit 18e944a382a5cf867020b5d31e90144aa7a3c07e) (cherry picked from commit a8ac3fbe89b738301bd0d9b2f238de40a1826124) (cherry picked from commit 55b7eb6245dc7d877eaf00b7cf0d849ca5d29c2e) Rebase Fix tag endpoint (cherry picked from commit d714792cad0c71af3fabca2cc6c5b741f046a970) (cherry picked from commit 8181c540f76eacd87d779f850d309a3bf101424a) (cherry picked from commit e82c6150517cb1fb91b3ba34e2d4aa1b1f5437a7) --- .../explore/visualizations/bubble.js | 2 +- superset/assets/package-lock.json | 21 ++ superset/assets/package.json | 5 +- .../components/DashboardBuilder_spec.jsx | 5 + .../spec/javascripts/welcome/Welcome_spec.jsx | 18 +- .../components/TemplateParamsEditor.jsx | 5 +- superset/assets/src/components/ObjectTags.css | 213 ++++++++++++ superset/assets/src/components/ObjectTags.jsx | 143 ++++++++ .../components/BuilderComponentPane.jsx | 2 + .../src/dashboard/components/Header.jsx | 27 ++ .../components/gridComponents/Tags.jsx | 321 ++++++++++++++++++ .../components/gridComponents/index.js | 4 + .../components/gridComponents/new/NewTags.jsx | 34 ++ .../dashboard/util/componentIsResizable.js | 12 +- .../src/dashboard/util/componentTypes.js | 2 + .../assets/src/dashboard/util/constants.js | 8 + .../util/getDetailedComponentWidth.js | 4 +- .../assets/src/dashboard/util/isValidChild.js | 6 + .../src/dashboard/util/newComponentFactory.js | 2 + .../dashboard/util/shouldWrapChildInRow.js | 3 + .../explore/components/ExploreChartHeader.jsx | 36 ++ superset/assets/src/modules/utils.js | 56 +++ superset/assets/src/tags.js | 132 +++++++ superset/assets/src/utils/common.js | 11 +- superset/assets/src/welcome/TagsTable.jsx | 105 ++++++ superset/assets/src/welcome/Welcome.jsx | 106 ++++-- superset/config.py | 2 +- superset/views/core.py | 5 + superset/views/tags.py | 133 +++++--- 29 files changed, 1333 insertions(+), 90 deletions(-) create mode 100644 superset/assets/src/components/ObjectTags.css create mode 100644 superset/assets/src/components/ObjectTags.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/Tags.jsx create mode 100644 superset/assets/src/dashboard/components/gridComponents/new/NewTags.jsx create mode 100644 superset/assets/src/tags.js create mode 100644 superset/assets/src/welcome/TagsTable.jsx diff --git a/superset/assets/cypress/integration/explore/visualizations/bubble.js b/superset/assets/cypress/integration/explore/visualizations/bubble.js index 915e97d5a5671..adf115ef869be 100644 --- a/superset/assets/cypress/integration/explore/visualizations/bubble.js +++ b/superset/assets/cypress/integration/explore/visualizations/bubble.js @@ -59,7 +59,7 @@ export default () => describe('Bubble', () => { it('should work', () => { verify(BUBBLE_FORM_DATA); - cy.get('.chart-container svg circle').should('have.length', 208); + cy.get('.chart-container svg circle').should('have.length', 220); }); it('should work with filter', () => { diff --git a/superset/assets/package-lock.json b/superset/assets/package-lock.json index 7bc8fbc8f2dd4..9677a1b23b3cf 100644 --- a/superset/assets/package-lock.json +++ b/superset/assets/package-lock.json @@ -16339,6 +16339,22 @@ } } }, + "react-bootstrap-table-next": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/react-bootstrap-table-next/-/react-bootstrap-table-next-1.4.4.tgz", + "integrity": "sha512-Q3vo2n/i0lVgo+5Xt/DDRuljCaqRlZugRfgLkfBeg3BMt1T/4QkqNjNnelF2ysUoS3lsluuYadY9BtblThvCDg==", + "requires": { + "classnames": "2.2.5", + "underscore": "1.9.1" + }, + "dependencies": { + "classnames": { + "version": "2.2.5", + "resolved": "https://registry.npmjs.org/classnames/-/classnames-2.2.5.tgz", + "integrity": "sha1-+zgB1FNGdknvNgPH1hoCvRKb3m0=" + } + } + }, "react-color": { "version": "2.14.1", "resolved": "https://registry.npmjs.org/react-color/-/react-color-2.14.1.tgz", @@ -16695,6 +16711,11 @@ "refractor": "^2.4.1" } }, + "react-tag-autocomplete": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/react-tag-autocomplete/-/react-tag-autocomplete-5.8.0.tgz", + "integrity": "sha512-1svOlZ6oQWH9ekGxd8sQgwvEg4lpwarcyH0rluWQ290xQ/nY8PNeIvF6VSZwWpTv+1zCkfjzqyO95TnXcwp+YQ==" + }, "react-virtualized": { "version": "9.19.1", "resolved": "https://registry.npmjs.org/react-virtualized/-/react-virtualized-9.19.1.tgz", diff --git a/superset/assets/package.json b/superset/assets/package.json index ba59f9bb7525b..c3c122055cb5f 100644 --- a/superset/assets/package.json +++ b/superset/assets/package.json @@ -100,6 +100,7 @@ "react-bootstrap": "^0.31.5", "react-bootstrap-dialog": "^0.10.0", "react-bootstrap-slider": "2.1.5", + "react-bootstrap-table-next": "^1.4.0", "react-color": "^2.13.8", "react-datetime": "^2.14.0", "react-dnd": "^2.5.4", @@ -118,6 +119,7 @@ "react-split-pane": "^0.1.66", "react-sticky": "^6.0.2", "react-syntax-highlighter": "^7.0.4", + "react-tag-autocomplete": "^5.8.0", "react-virtualized": "9.19.1", "react-virtualized-select": "^3.1.3", "reactable-arc": "0.14.42", @@ -131,7 +133,8 @@ "supercluster": "^4.1.1", "underscore": "^1.8.3", "urijs": "^1.18.10", - "viewport-mercator-project": "^5.0.0" + "viewport-mercator-project": "^5.0.0", + "whatwg-fetch": "^3.0.0" }, "devDependencies": { "@babel/cli": "^7.2.3", diff --git a/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx b/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx index bf24644e140a9..0d851395dab52 100644 --- a/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx +++ b/superset/assets/spec/javascripts/dashboard/components/DashboardBuilder_spec.jsx @@ -45,16 +45,21 @@ const layoutWithTabs = undoableDashboardLayoutWithTabs.present; describe('DashboardBuilder', () => { let favStarStub; + let fetchMock; // mock calls to tagview/tags/${objectType}/${objectId}/ beforeAll(() => { // this is invoked on mount, so we stub it instead of making a request favStarStub = sinon .stub(dashboardStateActions, 'fetchFaveStar') .returns({ type: 'mock-action' }); + fetchMock = jest + .spyOn(window, 'fetch') + .mockImplementation(() => Promise.resolve({})); }); afterAll(() => { favStarStub.restore(); + fetchMock.mockClear(); }); const props = { diff --git a/superset/assets/spec/javascripts/welcome/Welcome_spec.jsx b/superset/assets/spec/javascripts/welcome/Welcome_spec.jsx index 8a18b299955ed..93bfabd9c6871 100644 --- a/superset/assets/spec/javascripts/welcome/Welcome_spec.jsx +++ b/superset/assets/spec/javascripts/welcome/Welcome_spec.jsx @@ -23,16 +23,26 @@ import { shallow } from 'enzyme'; import Welcome from '../../../src/welcome/Welcome'; describe('Welcome', () => { + let fetchMock; // mock calls to /tagview/tags/suggestions/ + + beforeAll(() => { + fetchMock = jest.spyOn(window, 'fetch').mockImplementation(() => Promise.resolve({})); + }); + + afterAll(() => { + fetchMock.mockClear(); + }); + const mockedProps = {}; it('is valid', () => { expect( React.isValidElement(), ).toBe(true); }); - it('renders 4 Tab, Panel, and Row components', () => { + it('renders 4 Tabs, 4 Panels, and 5 Row components', () => { const wrapper = shallow(); - expect(wrapper.find(Tab)).toHaveLength(3); - expect(wrapper.find(Panel)).toHaveLength(3); - expect(wrapper.find(Row)).toHaveLength(3); + expect(wrapper.find(Tab)).toHaveLength(4); + expect(wrapper.find(Panel)).toHaveLength(4); + expect(wrapper.find(Row)).toHaveLength(5); }); }); diff --git a/superset/assets/src/SqlLab/components/TemplateParamsEditor.jsx b/superset/assets/src/SqlLab/components/TemplateParamsEditor.jsx index dc52bece5038f..0533bc584b126 100644 --- a/superset/assets/src/SqlLab/components/TemplateParamsEditor.jsx +++ b/superset/assets/src/SqlLab/components/TemplateParamsEditor.jsx @@ -82,8 +82,9 @@ export default class TemplateParamsEditor extends React.Component { Assign a set of parameters as JSON below (example: {'{"my_table": "foo"}'}), and they become available - in your SQL (example: SELECT * FROM {'{{ my_table }}'} ) - by using  + in your SQL (example: SELECT * FROM {'{{ my_table }}'}) + by using + {' '} + *
+ * + *
+ *
+
diff --git a/superset/assets/src/dashboard/components/gridComponents/Tags.jsx b/superset/assets/src/dashboard/components/gridComponents/Tags.jsx new file mode 100644 index 0000000000000..f122ca3a805a0 --- /dev/null +++ b/superset/assets/src/dashboard/components/gridComponents/Tags.jsx @@ -0,0 +1,321 @@ +/** + * 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 PropTypes from 'prop-types'; +import cx from 'classnames'; +import { ListGroup, ListGroupItem, Panel } from 'react-bootstrap'; +import { BootstrapTable } from 'react-bootstrap-table-next'; +import 'react-bootstrap-table-next/dist/react-bootstrap-table2.min.css'; +import moment from 'moment'; +import { unsafe } from 'reactable-arc'; +import 'whatwg-fetch'; + +import DeleteComponentButton from '../DeleteComponentButton'; +import DragDroppable from '../dnd/DragDroppable'; +import HoverMenu from '../menu/HoverMenu'; +import IconButton from '../IconButton'; +import ResizableContainer from '../resizable/ResizableContainer'; +import SelectControl from '../../../explore/components/controls/SelectControl'; +import WithPopoverMenu from '../menu/WithPopoverMenu'; +import { componentShape } from '../../util/propShapes'; +import { fetchObjects, fetchSuggestions } from '../../../tags'; +import { ROW_TYPE, COLUMN_TYPE } from '../../util/componentTypes'; +import { + GRID_MIN_COLUMN_COUNT, + GRID_MIN_ROW_UNITS, + GRID_BASE_UNIT, + STANDARD_TAGS, + TAGGED_CONTENT_TYPES, +} from '../../util/constants'; + +const HEADER_HEIGHT = 48; + +const propTypes = { + id: PropTypes.string.isRequired, + parentId: PropTypes.string.isRequired, + component: componentShape.isRequired, + parentComponent: componentShape.isRequired, + index: PropTypes.number.isRequired, + depth: PropTypes.number.isRequired, + editMode: PropTypes.bool.isRequired, + + // grid related + availableColumnCount: PropTypes.number.isRequired, + columnWidth: PropTypes.number.isRequired, + onResizeStart: PropTypes.func.isRequired, + onResize: PropTypes.func.isRequired, + onResizeStop: PropTypes.func.isRequired, + + // dnd + deleteComponent: PropTypes.func.isRequired, + handleComponentDrop: PropTypes.func.isRequired, + updateComponents: PropTypes.func.isRequired, +}; + +const defaultProps = {}; + +function linkFormatter(cell, row) { + const url = `${cell}`; + return ( + + {row.name} + + ); +} + +function changedOnFormatter(cell) { + const date = new Date(cell); + return unsafe(moment.utc(date).fromNow()); +} + +class Tags extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + isFocused: false, + isConfiguring: false, + data: [], + tagSuggestions: STANDARD_TAGS, + }; + + this.handleChangeFocus = this.handleChangeFocus.bind(this); + this.handleDeleteComponent = this.handleDeleteComponent.bind(this); + this.toggleConfiguring = this.toggleConfiguring.bind(this); + this.handleUpdateMeta = this.handleUpdateMeta.bind(this); + this.handleChangeTags = this.handleUpdateMeta.bind(this, 'tags'); + this.handleChangeTypes = this.handleUpdateMeta.bind(this, 'types'); + + this.fetchResults = this.fetchResults.bind(this); + this.fetchTagSuggestions = this.fetchTagSuggestions.bind(this); + } + + componentDidMount() { + this.fetchResults(this.props.component); + this.fetchTagSuggestions(); + } + + handleChangeFocus(nextFocus) { + this.setState(() => ({ isFocused: nextFocus })); + } + + handleUpdateMeta(metaKey, nextValue) { + const { updateComponents, component } = this.props; + if (nextValue && component.meta[metaKey] !== nextValue) { + const nextComponent = { + ...component, + meta: { + ...component.meta, + [metaKey]: nextValue, + }, + }; + updateComponents({ [component.id]: nextComponent }); + this.fetchResults(nextComponent); + } + } + + fetchResults(component) { + const tags = component.meta.tags || []; + const types = component.meta.types || TAGGED_CONTENT_TYPES; + fetchObjects({ tags: tags.join(','), types: types.join(',') }, data => + this.setState({ data }), + ); + } + + fetchTagSuggestions() { + fetchSuggestions({ includeTypes: false }, suggestions => { + const tagSuggestions = STANDARD_TAGS.concat( + suggestions.map(tag => tag.name), + ); + this.setState({ tagSuggestions }); + }); + } + + handleDeleteComponent() { + const { deleteComponent, id, parentId } = this.props; + deleteComponent(id, parentId); + } + + toggleConfiguring() { + this.setState({ isConfiguring: !this.state.isConfiguring }); + } + + renderEditMode() { + const { component } = this.props; + return ( + + + + + + + + + + + ); + } + + renderPreviewMode() { + const component = this.props.component; + const height = component.meta.height * GRID_BASE_UNIT - HEADER_HEIGHT; + const columns = [ + { dataField: 'id', text: 'ID', hidden: true }, + { + dataField: 'url', + text: 'Name', + dataFormat: linkFormatter, + width: '50%', + }, + { dataField: 'type', text: 'Type' }, + { dataField: 'creator', dataFormat: unsafe, dataSort: true }, + { + dataField: 'changed_on', + dataFormat: changedOnFormatter, + dataSort: true, + }, + ]; + return ( + + ); + } + + render() { + const { isFocused, isConfiguring } = this.state; + + const { + component, + parentComponent, + index, + depth, + availableColumnCount, + columnWidth, + onResizeStart, + onResize, + onResizeStop, + handleComponentDrop, + editMode, + } = this.props; + + // inherit the size of parent columns + const widthMultiple = + parentComponent.type === COLUMN_TYPE + ? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT + : component.meta.width || GRID_MIN_COLUMN_COUNT; + + const buttonClass = isConfiguring ? 'fa fa-table' : 'fa fa-cog'; + + return ( + + {({ dropIndicatorProps, dragSourceRef }) => ( + +
+ +
+ {isConfiguring + ? this.renderEditMode() + : this.renderPreviewMode()} + {editMode && ( + + + + + )} +
+
+
+ {dropIndicatorProps &&
} + + )} + + ); + } +} + +Tags.propTypes = propTypes; +Tags.defaultProps = defaultProps; + +export default Tags; diff --git a/superset/assets/src/dashboard/components/gridComponents/index.js b/superset/assets/src/dashboard/components/gridComponents/index.js index 44086bbd4b51b..b36324837f945 100644 --- a/superset/assets/src/dashboard/components/gridComponents/index.js +++ b/superset/assets/src/dashboard/components/gridComponents/index.js @@ -25,6 +25,7 @@ import { ROW_TYPE, TAB_TYPE, TABS_TYPE, + TAGS_TYPE, } from '../../util/componentTypes'; import ChartHolder from './ChartHolder'; @@ -35,6 +36,7 @@ import Header from './Header'; import Row from './Row'; import Tab from './Tab'; import Tabs from './Tabs'; +import Tags from './Tags'; export { default as ChartHolder } from './ChartHolder'; export { default as Markdown } from './Markdown'; @@ -44,6 +46,7 @@ export { default as Header } from './Header'; export { default as Row } from './Row'; export { default as Tab } from './Tab'; export { default as Tabs } from './Tabs'; +export { default as Tags } from './Tags'; export default { [CHART_TYPE]: ChartHolder, @@ -54,4 +57,5 @@ export default { [ROW_TYPE]: Row, [TAB_TYPE]: Tab, [TABS_TYPE]: Tabs, + [TAGS_TYPE]: Tags, }; diff --git a/superset/assets/src/dashboard/components/gridComponents/new/NewTags.jsx b/superset/assets/src/dashboard/components/gridComponents/new/NewTags.jsx new file mode 100644 index 0000000000000..4e62513cb36ba --- /dev/null +++ b/superset/assets/src/dashboard/components/gridComponents/new/NewTags.jsx @@ -0,0 +1,34 @@ +/** + * 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 { TAGS_TYPE } from '../../../util/componentTypes'; +import { NEW_TAGS_ID } from '../../../util/constants'; +import DraggableNewComponent from './DraggableNewComponent'; + +export default function DraggableNewTags() { + return ( + + ); +} diff --git a/superset/assets/src/dashboard/util/componentIsResizable.js b/superset/assets/src/dashboard/util/componentIsResizable.js index afff2f96bba7a..f339b19a2bc9c 100644 --- a/superset/assets/src/dashboard/util/componentIsResizable.js +++ b/superset/assets/src/dashboard/util/componentIsResizable.js @@ -16,8 +16,16 @@ * specific language governing permissions and limitations * under the License. */ -import { COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE } from './componentTypes'; +import { + COLUMN_TYPE, + CHART_TYPE, + MARKDOWN_TYPE, + TAGS_TYPE, +} from './componentTypes'; export default function componentIsResizable(entity) { - return [COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE].indexOf(entity.type) > -1; + return ( + [COLUMN_TYPE, CHART_TYPE, MARKDOWN_TYPE, TAGS_TYPE].indexOf(entity.type) > + -1 + ); } diff --git a/superset/assets/src/dashboard/util/componentTypes.js b/superset/assets/src/dashboard/util/componentTypes.js index 177aad05d61f8..be59b49a98c19 100644 --- a/superset/assets/src/dashboard/util/componentTypes.js +++ b/superset/assets/src/dashboard/util/componentTypes.js @@ -28,6 +28,7 @@ export const NEW_COMPONENT_SOURCE_TYPE = 'NEW_COMPONENT_SOURCE'; export const ROW_TYPE = 'ROW'; export const TABS_TYPE = 'TABS'; export const TAB_TYPE = 'TAB'; +export const TAGS_TYPE = 'TAGS'; export default { CHART_TYPE, @@ -42,4 +43,5 @@ export default { ROW_TYPE, TABS_TYPE, TAB_TYPE, + TAGS_TYPE, }; diff --git a/superset/assets/src/dashboard/util/constants.js b/superset/assets/src/dashboard/util/constants.js index 5cce3ae37929a..16e2d2fc775a5 100644 --- a/superset/assets/src/dashboard/util/constants.js +++ b/superset/assets/src/dashboard/util/constants.js @@ -31,6 +31,7 @@ export const NEW_MARKDOWN_ID = 'NEW_MARKDOWN_ID'; export const NEW_ROW_ID = 'NEW_ROW_ID'; export const NEW_TAB_ID = 'NEW_TAB_ID'; export const NEW_TABS_ID = 'NEW_TABS_ID'; +export const NEW_TAGS_ID = 'NEW_TAGS_ID'; // grid constants export const DASHBOARD_ROOT_DEPTH = 0; @@ -59,6 +60,13 @@ export const UNDO_LIMIT = 50; export const SAVE_TYPE_OVERWRITE = 'overwrite'; export const SAVE_TYPE_NEWDASHBOARD = 'newDashboard'; +// objects that can be tagged +export const TAGGED_CONTENT_TYPES = ['dashboard', 'chart', 'query']; +export const STANDARD_TAGS = [ + ['owner:{{ current_user_id() }}', 'Owned by me'], + ['favorited_by:{{ current_user_id() }}', 'Favorited by me'], +]; + // default dashboard layout data size limit // could be overwritten by server-side config export const DASHBOARD_POSITION_DATA_LIMIT = 65535; diff --git a/superset/assets/src/dashboard/util/getDetailedComponentWidth.js b/superset/assets/src/dashboard/util/getDetailedComponentWidth.js index efad55f63cbbe..82e7f86d56a41 100644 --- a/superset/assets/src/dashboard/util/getDetailedComponentWidth.js +++ b/superset/assets/src/dashboard/util/getDetailedComponentWidth.js @@ -23,6 +23,7 @@ import { COLUMN_TYPE, MARKDOWN_TYPE, CHART_TYPE, + TAGS_TYPE, } from './componentTypes'; function getTotalChildWidth({ id, components }) { @@ -85,7 +86,8 @@ export default function getDetailedComponentWidth({ }); } else if ( component.type === MARKDOWN_TYPE || - component.type === CHART_TYPE + component.type === CHART_TYPE || + component.type === TAGS_TYPE ) { result.minimumWidth = GRID_MIN_COLUMN_COUNT; } diff --git a/superset/assets/src/dashboard/util/isValidChild.js b/superset/assets/src/dashboard/util/isValidChild.js index d90dc4ecc89c1..f4f785add6141 100644 --- a/superset/assets/src/dashboard/util/isValidChild.js +++ b/superset/assets/src/dashboard/util/isValidChild.js @@ -43,6 +43,7 @@ import { ROW_TYPE, TABS_TYPE, TAB_TYPE, + TAGS_TYPE, } from './componentTypes'; import { DASHBOARD_ROOT_DEPTH as rootDepth } from './constants'; @@ -68,12 +69,14 @@ const parentMaxDepthLookup = { [HEADER_TYPE]: depthOne, [ROW_TYPE]: depthOne, [TABS_TYPE]: depthOne, + [TAGS_TYPE]: depthOne, }, [ROW_TYPE]: { [CHART_TYPE]: depthFour, [MARKDOWN_TYPE]: depthFour, [COLUMN_TYPE]: depthFour, + [TAGS_TYPE]: depthFour, }, [TABS_TYPE]: { @@ -88,6 +91,7 @@ const parentMaxDepthLookup = { [HEADER_TYPE]: depthTwo, [ROW_TYPE]: depthTwo, [TABS_TYPE]: depthTwo, + [TAGS_TYPE]: depthTwo, }, [COLUMN_TYPE]: { @@ -96,6 +100,7 @@ const parentMaxDepthLookup = { [MARKDOWN_TYPE]: depthFive, [ROW_TYPE]: depthThree, [DIVIDER_TYPE]: depthThree, + [TAGS_TYPE]: depthFive, }, // these have no valid children @@ -103,6 +108,7 @@ const parentMaxDepthLookup = { [DIVIDER_TYPE]: {}, [HEADER_TYPE]: {}, [MARKDOWN_TYPE]: {}, + [TAGS_TYPE]: {}, }; export default function isValidChild({ parentType, childType, parentDepth }) { diff --git a/superset/assets/src/dashboard/util/newComponentFactory.js b/superset/assets/src/dashboard/util/newComponentFactory.js index cd9838f40ec0d..c2ca2f0006bd7 100644 --- a/superset/assets/src/dashboard/util/newComponentFactory.js +++ b/superset/assets/src/dashboard/util/newComponentFactory.js @@ -27,6 +27,7 @@ import { ROW_TYPE, TABS_TYPE, TAB_TYPE, + TAGS_TYPE, } from './componentTypes'; import { @@ -51,6 +52,7 @@ const typeToDefaultMetaData = { [ROW_TYPE]: { background: BACKGROUND_TRANSPARENT }, [TABS_TYPE]: null, [TAB_TYPE]: { text: 'New Tab' }, + [TAGS_TYPE]: { width: 3, height: 30 }, }; function uuid(type) { diff --git a/superset/assets/src/dashboard/util/shouldWrapChildInRow.js b/superset/assets/src/dashboard/util/shouldWrapChildInRow.js index 5f5f6952280fe..6563b8fe5de3a 100644 --- a/superset/assets/src/dashboard/util/shouldWrapChildInRow.js +++ b/superset/assets/src/dashboard/util/shouldWrapChildInRow.js @@ -22,6 +22,7 @@ import { COLUMN_TYPE, MARKDOWN_TYPE, TAB_TYPE, + TAGS_TYPE, } from './componentTypes'; const typeToWrapChildLookup = { @@ -29,12 +30,14 @@ const typeToWrapChildLookup = { [CHART_TYPE]: true, [COLUMN_TYPE]: true, [MARKDOWN_TYPE]: true, + [TAGS_TYPE]: true, }, [TAB_TYPE]: { [CHART_TYPE]: true, [COLUMN_TYPE]: true, [MARKDOWN_TYPE]: true, + [TAGS_TYPE]: true, }, }; diff --git a/superset/assets/src/explore/components/ExploreChartHeader.jsx b/superset/assets/src/explore/components/ExploreChartHeader.jsx index d2f3b983024b0..ffd95012c935c 100644 --- a/superset/assets/src/explore/components/ExploreChartHeader.jsx +++ b/superset/assets/src/explore/components/ExploreChartHeader.jsx @@ -29,6 +29,13 @@ import FaveStar from '../../components/FaveStar'; import TooltipWrapper from '../../components/TooltipWrapper'; import Timer from '../../components/Timer'; import CachedLabel from '../../components/CachedLabel'; +import ObjectTags from '../../components/ObjectTags'; +import { + addTag, + deleteTag, + fetchSuggestions, + fetchTags, +} from '../../tags'; const CHART_STATUS_MAP = { failed: 'danger', @@ -50,6 +57,28 @@ const propTypes = { }; class ExploreChartHeader extends React.PureComponent { + constructor(props) { + super(props); + + this.fetchTags = fetchTags.bind(this, { + objectType: 'chart', + objectId: props.chart.id, + includeTypes: false, + }); + this.fetchSuggestions = fetchSuggestions.bind(this, { + includeTypes: false, + }); + this.deleteTag = deleteTag.bind(this, { + objectType: 'chart', + objectId: props.chart.id, + }); + this.addTag = addTag.bind(this, { + objectType: 'chart', + objectId: props.chart.id, + includeTypes: false, + }); + } + runQuery() { this.props.actions.runQuery(this.props.form_data, true, this.props.timeout, this.props.chart.id); @@ -134,6 +163,13 @@ class ExploreChartHeader extends React.PureComponent { currentFormData={formData} /> } +
{chartSucceeded && queryResponse && ${tooltipTitle}` + + ''; + + d.series.forEach((series, i) => { + const yAxisFormatter = yAxisFormatters[i]; + const value = yAxisFormatter(series.value); + tooltip += "" + + `
` + + `${series.key}` + + `${value}`; + }); + + tooltip += ''; + + return tooltip; + }); +} + +export function getCSRFToken() { + if (document && document.getElementById('csrf_token')) { + return document.getElementById('csrf_token').value; + } + return ''; +} + +export function initJQueryAjax() { + // Works in conjunction with a Flask-WTF token as described here: + // http://flask-wtf.readthedocs.io/en/stable/csrf.html#javascript-requests + const token = getCSRFToken(); + if (token) { + $.ajaxSetup({ + beforeSend(xhr, settings) { + if (!/^(GET|HEAD|OPTIONS|TRACE)$/i.test(settings.type) && !this.crossDomain) { + xhr.setRequestHeader('X-CSRFToken', token); + } + }, + }); + } +} + +export function tryNumify(s) { + // Attempts casting to Number, returns string when failing + const n = Number(s); + if (isNaN(n)) { + return s; + } + return n; +} + export function getParam(name) { /* eslint no-useless-escape: 0 */ const formattedName = name.replace(/[\[]/, '\\[').replace(/[\]]/, '\\]'); diff --git a/superset/assets/src/tags.js b/superset/assets/src/tags.js new file mode 100644 index 0000000000000..6d2f4a4adda06 --- /dev/null +++ b/superset/assets/src/tags.js @@ -0,0 +1,132 @@ +/** + * 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 'whatwg-fetch'; +import { getCSRFToken } from './modules/utils'; + +export function fetchTags(options, callback, error) { + if (options.objectType === undefined || options.objectId === undefined) { + throw new Error('Need to specify objectType and objectId'); + } + const objectType = options.objectType; + const objectId = options.objectId; + const includeTypes = options.includeTypes !== undefined ? options.includeTypes : false; + + const url = `/tagview/tags/${objectType}/${objectId}/`; + window.fetch(url) + .then((response) => { + if (response.ok) { + return response.json(); + } + throw new Error(response.text()); + }) + .then(json => callback( + json.filter(tag => tag.name.indexOf(':') === -1 || includeTypes))) + .catch(text => error(text)); +} + +export function fetchSuggestions(options, callback, error) { + const includeTypes = options.includeTypes !== undefined ? options.includeTypes : false; + window.fetch('/tagview/tags/suggestions/') + .then((response) => { + if (response.ok) { + return response.json(); + } + throw new Error(response.text()); + }) + .then(json => callback( + json.filter(tag => tag.name.indexOf(':') === -1 || includeTypes))) + .catch(text => error(text)); +} + +export function deleteTag(options, tag, callback, error) { + if (options.objectType === undefined || options.objectId === undefined) { + throw new Error('Need to specify objectType and objectId'); + } + const objectType = options.objectType; + const objectId = options.objectId; + + const url = `/tagview/tags/${objectType}/${objectId}/`; + const CSRF_TOKEN = getCSRFToken(); + window.fetch(url, { + body: JSON.stringify([tag]), + headers: { + 'content-type': 'application/json', + 'X-CSRFToken': CSRF_TOKEN, + }, + credentials: 'same-origin', + method: 'DELETE', + }) + .then((response) => { + if (response.ok) { + callback(response.text()); + } else { + error(response.text()); + } + }); +} + +export function addTag(options, tag, callback, error) { + if (options.objectType === undefined || options.objectId === undefined) { + throw new Error('Need to specify objectType and objectId'); + } + const objectType = options.objectType; + const objectId = options.objectId; + const includeTypes = options.includeTypes !== undefined ? options.includeTypes : false; + + if (tag.indexOf(':') !== -1 && !includeTypes) { + return; + } + const url = `/tagview/tags/${objectType}/${objectId}/`; + const CSRF_TOKEN = getCSRFToken(); + window.fetch(url, { + body: JSON.stringify([tag]), + headers: { + 'content-type': 'application/json', + 'X-CSRFToken': CSRF_TOKEN, + }, + credentials: 'same-origin', + method: 'POST', + }) + .then((response) => { + if (response.ok) { + callback(response.text()); + } else { + error(response.text()); + } + }); +} + +export function fetchObjects(options, callback) { + const tags = options.tags !== undefined ? options.tags : ''; + const types = options.types; + + let url = `/tagview/tagged_objects/?tags=${tags}`; + if (types) { + url += `&types=${types}`; + } + const CSRF_TOKEN = getCSRFToken(); + window.fetch(url, { + headers: { + 'X-CSRFToken': CSRF_TOKEN, + }, + credentials: 'same-origin', + }) + .then(response => response.json()) + .then(json => callback(json)); +} diff --git a/superset/assets/src/utils/common.js b/superset/assets/src/utils/common.js index 01f2a867c22e4..d5a8a4d4c91f6 100644 --- a/superset/assets/src/utils/common.js +++ b/superset/assets/src/utils/common.js @@ -16,9 +16,12 @@ * specific language governing permissions and limitations * under the License. */ +/* eslint global-require: 0 */ import { SupersetClient } from '@superset-ui/connection'; +import URI from 'urijs'; import getClientErrorObject from './getClientErrorObject'; + export const LUMINANCE_RED_WEIGHT = 0.2126; export const LUMINANCE_GREEN_WEIGHT = 0.7152; export const LUMINANCE_BLUE_WEIGHT = 0.0722; @@ -76,11 +79,9 @@ export function getShortUrl(longUrl) { } export function supersetURL(rootUrl, getParams = {}) { - const url = new URL(rootUrl, window.location.origin); - for (const k in getParams) { - url.searchParams.set(k, getParams[k]); - } - return url.href; + const parsedUrl = new URI(rootUrl).absoluteTo(window.location.origin); + parsedUrl.search(getParams); + return parsedUrl.href(); } export function isTruthy(obj) { diff --git a/superset/assets/src/welcome/TagsTable.jsx b/superset/assets/src/welcome/TagsTable.jsx new file mode 100644 index 0000000000000..b32af8c03b943 --- /dev/null +++ b/superset/assets/src/welcome/TagsTable.jsx @@ -0,0 +1,105 @@ +/** + * 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 PropTypes from 'prop-types'; +import moment from 'moment'; +import { Table, unsafe } from 'reactable-arc'; +import 'whatwg-fetch'; +import { t } from '@superset-ui/translation'; + +import { fetchObjects } from '../tags'; +import Loading from '../components/Loading'; +import '../../stylesheets/reactable-pagination.css'; + +const propTypes = { + search: PropTypes.string, +}; + +const defaultProps = { + search: '', +}; + +export default class TagsTable extends React.PureComponent { + constructor(props) { + super(props); + this.state = { + objects: false, + }; + this.fetchResults = this.fetchResults.bind(this); + } + componentDidMount() { + this.fetchResults(this.props.search); + } + componentWillReceiveProps(newProps) { + if (this.props.search !== newProps.search) { + this.fetchResults(newProps.search); + } + } + fetchResults(search) { + fetchObjects({ tags: search, types: null }, (data) => { + const objects = { dashboard: [], chart: [], query: [] }; + data.forEach((object) => { + objects[object.type].push(object); + }); + this.setState({ objects }); + }); + } + renderTable(type) { + const data = this.state.objects[type].map(o => ({ + [type]: {o.name}, + creator: unsafe(o.creator), + modified: unsafe(moment.utc(o.changed_on).fromNow()), + })); + return ( + + ); + } + render() { + if (this.state.objects) { + return ( +
+

{t('Dashboards')}

+ {this.renderTable('dashboard')} +
+

{t('Charts')}

+ {this.renderTable('chart')} +
+

{t('Queries')}

+ {this.renderTable('query')} +
+ ); + } + return ; + } +} + +TagsTable.propTypes = propTypes; +TagsTable.defaultProps = defaultProps; diff --git a/superset/assets/src/welcome/Welcome.jsx b/superset/assets/src/welcome/Welcome.jsx index db1a632fd5f36..c83409ff71efc 100644 --- a/superset/assets/src/welcome/Welcome.jsx +++ b/superset/assets/src/welcome/Welcome.jsx @@ -20,9 +20,14 @@ import React from 'react'; import PropTypes from 'prop-types'; import { Panel, Row, Col, Tabs, Tab, FormControl } from 'react-bootstrap'; import { t } from '@superset-ui/translation'; +import URI from 'urijs'; import RecentActivity from '../profile/components/RecentActivity'; import Favorites from '../profile/components/Favorites'; import DashboardTable from './DashboardTable'; +import SelectControl from '../explore/components/controls/SelectControl'; +import TagsTable from './TagsTable'; +import { fetchSuggestions } from '../tags'; +import { STANDARD_TAGS } from '../dashboard/util/constants'; const propTypes = { user: PropTypes.object.isRequired, @@ -31,38 +36,89 @@ const propTypes = { export default class Welcome extends React.PureComponent { constructor(props) { super(props); + + const parsedUrl = new URI(window.location); + const key = parsedUrl.fragment() || 'tags'; + const searchParams = parsedUrl.search(true); + const dashboardSearch = searchParams.search || ''; + const tagSearch = searchParams.tags || 'owner:{{ current_user_id() }}'; this.state = { - search: '', + key, + dashboardSearch, + tagSearch, + tagSuggestions: STANDARD_TAGS, }; - this.onSearchChange = this.onSearchChange.bind(this); + + this.handleSelect = this.handleSelect.bind(this); + this.onDashboardSearchChange = this.onDashboardSearchChange.bind(this); + this.onTagSearchChange = this.onTagSearchChange.bind(this); + } + componentDidMount() { + fetchSuggestions({ includeTypes: false }, (suggestions) => { + const tagSuggestions = [ + ...STANDARD_TAGS, + ...suggestions.map(tag => tag.name), + ]; + this.setState({ tagSuggestions }); + }); + } + onDashboardSearchChange(event) { + const dashboardSearch = event.target.value; + this.setState({ dashboardSearch }, () => this.updateURL('search', dashboardSearch)); + } + onTagSearchChange(tags) { + const tagSearch = tags.join(','); + this.setState({ tagSearch }, () => this.updateURL('tags', tagSearch)); } - onSearchChange(event) { - this.setState({ search: event.target.value }); + updateURL(key, value) { + const parsedUrl = new URI(window.location); + parsedUrl.search(data => ({ ...data, [key]: value })); + window.history.pushState({ value }, value, parsedUrl.href()); + } + handleSelect(key) { + // store selected tab in URL + window.history.pushState({ tab: key }, key, `#${key}`); + + this.setState({ key }); } render() { return (
- - + + -

{t('Dashboards')}

- -

{t('Tags')}

+ + +
+
- + - + + + +

{t('Favorites')}

+ +
+ + + +

{t('Recently Viewed')}

@@ -71,13 +127,23 @@ export default class Welcome extends React.PureComponent { - + -

{t('Favorites')}

+

{t('Dashboards')}

+ + +
- + diff --git a/superset/config.py b/superset/config.py index 2d32f973a8d3c..7aaa11e3c5686 100644 --- a/superset/config.py +++ b/superset/config.py @@ -482,7 +482,7 @@ class CeleryConfig(object): # The id of a template dashboard that should be copied to every new user DASHBOARD_TEMPLATE_ID = None -# A callable that allows altering the database conneciton URL and params +# A callable that allows altering the database connection URL and params # on the fly, at runtime. This allows for things like impersonation or # arbitrary logic. For instance you can wire different users to # use different connection parameters, or pass their email address as the diff --git a/superset/views/core.py b/superset/views/core.py index 95f30e2443397..e6f346c58c1d2 100755 --- a/superset/views/core.py +++ b/superset/views/core.py @@ -95,6 +95,11 @@ def get_database_access_error_msg(database_name): '`all_datasource_access` permission', name=database_name) +def get_datasource_access_error_msg(datasource_name): + return __('This endpoint requires the datasource %(name)s, database or ' + '`all_datasource_access` permission', name=datasource_name) + + def is_owner(obj, user): """ Check if user is owner of the slice """ return obj and user in obj.owners diff --git a/superset/views/tags.py b/superset/views/tags.py index fc34490c097d7..ea161cdce339c 100644 --- a/superset/views/tags.py +++ b/superset/views/tags.py @@ -57,33 +57,6 @@ def process_template(content): return template.render(context) -def get_name(obj): - if obj.Dashboard: - return obj.Dashboard.dashboard_title - elif obj.Slice: - return obj.Slice.slice_name - elif obj.SavedQuery: - return obj.SavedQuery.label - - -def get_creator(obj): - if obj.Dashboard: - return obj.Dashboard.creator() - elif obj.Slice: - return obj.Slice.creator() - elif obj.SavedQuery: - return obj.SavedQuery.creator() - - -def get_attribute(obj, attr): - if obj.Dashboard: - return getattr(obj.Dashboard, attr) - elif obj.Slice: - return getattr(obj.Slice, attr) - elif obj.SavedQuery: - return getattr(obj.SavedQuery, attr) - - class TagView(BaseSupersetView): @has_access_api @@ -91,11 +64,12 @@ class TagView(BaseSupersetView): def suggestions(self): query = ( db.session.query(TaggedObject) - .group_by(TaggedObject.tag_id) + .with_entities(TaggedObject.tag_id, Tag.name) + .group_by(TaggedObject.tag_id, Tag.name) .order_by(func.count().desc()) .all() ) - tags = [{'id': obj.tag.id, 'name': obj.tag.name} for obj in query] + tags = [{'id': id, 'name': name} for id, name in query] return json_success(json.dumps(tags)) @has_access_api @@ -157,60 +131,113 @@ def delete(self, object_type, object_id): @has_access_api @expose('/tagged_objects/', methods=['GET', 'POST']) def tagged_objects(self): + """ query = db.session.query( TaggedObject, superset.models.core.Dashboard, superset.models.core.Slice, SavedQuery, ).join(Tag) + """ - tags = request.args.get('tags') + tags = [ + process_template(tag) + for tag in request.args.get('tags', '').split(',') if tag + ] if not tags: return json_success(json.dumps([])) - tags = [process_template(tag) for tag in tags.split(',')] - query = query.filter(Tag.name.in_(tags)) - # filter types - types = request.args.get('types') - if types: - query = query.filter(TaggedObject.object_type.in_(types.split(','))) + types = [ + type_ + for type_ in request.args.get('types', '').split(',') + if type_ + ] + + results = [] - # get names - query = query.outerjoin( + # dashboards + query = ( + db.session + .query(TaggedObject, superset.models.core.Dashboard) + .join(Tag) + .filter(Tag.name.in_(tags))) + if types: + query = query.filter(TaggedObject.object_type.in_(types)) + dashboards = query.outerjoin( superset.models.core.Dashboard, and_( TaggedObject.object_id == superset.models.core.Dashboard.id, TaggedObject.object_type == ObjectTypes.dashboard, ), - ).outerjoin( + ).all() + results.extend( + { + 'id': obj.Dashboard.id, + 'type': obj.TaggedObject.object_type.name, + 'name': obj.Dashboard.dashboard_title, + 'url': obj.Dashboard.url, + 'changed_on': obj.Dashboard.changed_on, + 'created_by': obj.Dashboard.created_by_fk, + 'creator': obj.Dashboard.creator(), + } for obj in dashboards if obj.Dashboard + ) + + # charts + query = ( + db.session + .query(TaggedObject, superset.models.core.Slice) + .join(Tag) + .filter(Tag.name.in_(tags))) + if types: + query = query.filter(TaggedObject.object_type.in_(types)) + charts = query.outerjoin( superset.models.core.Slice, and_( TaggedObject.object_id == superset.models.core.Slice.id, TaggedObject.object_type == ObjectTypes.chart, ), - ).outerjoin( + ).all() + results.extend( + { + 'id': obj.Slice.id, + 'type': obj.TaggedObject.object_type.name, + 'name': obj.Slice.slice_name, + 'url': obj.Slice.url, + 'changed_on': obj.Slice.changed_on, + 'created_by': obj.Slice.created_by_fk, + 'creator': obj.Slice.creator(), + } for obj in charts if obj.Slice + ) + + # saved queries + query = ( + db.session + .query(TaggedObject, SavedQuery) + .join(Tag) + .filter(Tag.name.in_(tags))) + if types: + query = query.filter(TaggedObject.object_type.in_(types)) + saved_queries = query.outerjoin( SavedQuery, and_( TaggedObject.object_id == SavedQuery.id, TaggedObject.object_type == ObjectTypes.query, ), - ).group_by(TaggedObject.object_id, TaggedObject.object_type) - - objects = [ + ).all() + results.extend( { - 'id': get_attribute(obj, 'id'), + 'id': obj.SavedQuery.id, 'type': obj.TaggedObject.object_type.name, - 'name': get_name(obj), - 'url': get_attribute(obj, 'url'), - 'changed_on': get_attribute(obj, 'changed_on'), - 'created_by': get_attribute(obj, 'created_by_fk'), - 'creator': get_creator(obj), - } - for obj in query if get_attribute(obj, 'id') - ] + 'name': obj.SavedQuery.label, + 'url': obj.SavedQuery.url, + 'changed_on': obj.SavedQuery.changed_on, + 'created_by': obj.SavedQuery.created_by_fk, + 'creator': obj.SavedQuery.creator(), + } for obj in saved_queries if obj.SavedQuery + ) - return json_success(json.dumps(objects, default=utils.core.json_int_dttm_ser)) + return json_success(json.dumps(results, default=utils.core.json_int_dttm_ser)) app.url_map.converters['object_type'] = ObjectTypeConverter