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