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