You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2020/10/30 05:00:12 UTC
[incubator-superset] branch master updated: feat: home screen mvp
(#11206)
This is an automated email from the ASF dual-hosted git repository.
rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git
The following commit(s) were added to refs/heads/master by this push:
new f7051ea feat: home screen mvp (#11206)
f7051ea is described below
commit f7051eaaded4e371f8a04f82915969bbaeb3763e
Author: Phillip Kelley-Dotson <pk...@yahoo.com>
AuthorDate: Thu Oct 29 21:59:31 2020 -0700
feat: home screen mvp (#11206)
* step 1: broken stuff!
* first steps
* more adding and slicing
* step 1: broken stuff!
* can now filter dashboards/charts for "Edited" tabs (filter by changed_by o_m)
* more updates
* update recent card
* add icon
* Adding Expand Icon to Collapse component
* more updates
* clean up code
* remove lock file
* remove consoles
* fixing subnav button height shift
* lil' ascii arrows
* update branch
* update test part 1
* remove consoles
* fix typescript
* add images and update emptystate
* add changes
* update chart card
* fix css issues from rebase
* add suggestions
* more changes
* update tests and clear typescript errors
* Update superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx
Co-authored-by: Evan Rusackas <ev...@preset.io>
* update from comments
* more updates..
* fix rebase
* fix pesky type errors
* test fixes
* lint fix
* Update superset-frontend/spec/javascripts/views/CRUD/welcome/Welcome_spec.tsx
Co-authored-by: Evan Rusackas <ev...@preset.io>
* Update superset-frontend/src/views/CRUD/welcome/EmptyState.tsx
Co-authored-by: Evan Rusackas <ev...@preset.io>
* Update superset-frontend/src/components/Menu/SubMenu.tsx
Co-authored-by: Evan Rusackas <ev...@preset.io>
* Update superset-frontend/src/components/ListViewCard/index.tsx
Co-authored-by: ʈᵃᵢ <td...@gmail.com>
* Update superset-frontend/src/components/ListViewCard/index.tsx
Co-authored-by: ʈᵃᵢ <td...@gmail.com>
* add suggestions
* fix lint
* remove unused code
* toast getrecentActivityobjs
* add some suggestions
* remove types for now
* cypress fix
* remove unused type
Co-authored-by: Evan Rusackas <ev...@preset.io>
Co-authored-by: ʈᵃᵢ <td...@gmail.com>
---
superset-frontend/images/empty-charts.png | Bin 0 -> 2274 bytes
superset-frontend/images/empty-dashboard.png | Bin 0 -> 1467 bytes
superset-frontend/images/empty-queries.png | Bin 0 -> 1791 bytes
superset-frontend/images/star-circle.png | Bin 0 -> 2705 bytes
superset-frontend/images/union.png | Bin 0 -> 1694 bytes
.../spec/javascripts/components/SubMenu_spec.jsx | 2 +-
.../views/CRUD/chart/ChartList_spec.jsx | 1 +
.../views/CRUD/welcome/ActivityTable_spec.tsx | 87 ++++++
.../views/CRUD/welcome/ChartTable_spec.tsx | 79 ++++++
.../views/CRUD/welcome/DashboardTable_spec.tsx | 78 ++++--
.../views/CRUD/welcome/EmptyState_spec.tsx | 92 +++++++
.../views/CRUD/welcome/SavedQueries_spec.tsx | 106 ++++++++
.../views/CRUD/welcome/Welcome_spec.tsx | 23 +-
.../src/components/ListViewCard/index.tsx | 60 +++--
superset-frontend/src/components/Menu/SubMenu.tsx | 45 ++--
.../src/views/CRUD/chart/ChartCard.tsx | 138 ++++++++++
.../src/views/CRUD/chart/ChartList.tsx | 144 +++-------
.../src/views/CRUD/dashboard/DashboardCard.tsx | 140 ++++++++++
.../src/views/CRUD/dashboard/DashboardList.tsx | 138 ++--------
superset-frontend/src/views/CRUD/data/common.ts | 2 +-
.../views/CRUD/data/savedquery/SavedQueryList.tsx | 15 +-
superset-frontend/src/views/CRUD/hooks.ts | 85 +++++-
superset-frontend/src/views/CRUD/types.ts | 49 ++++
superset-frontend/src/views/CRUD/utils.tsx | 197 ++++++++++++++
.../src/views/CRUD/welcome/ActivityTable.tsx | 209 ++++++++++++++
.../src/views/CRUD/welcome/ChartTable.tsx | 167 ++++++++++++
.../src/views/CRUD/welcome/DashboardTable.tsx | 299 +++++++++++----------
.../src/views/CRUD/welcome/EmptyState.tsx | 144 ++++++++++
.../src/views/CRUD/welcome/SavedQueries.tsx | 260 ++++++++++++++++++
.../src/views/CRUD/welcome/Welcome.tsx | 177 +++++-------
superset/charts/api.py | 1 +
superset/dashboards/api.py | 1 +
superset/queries/api.py | 3 +
superset/queries/saved_queries/api.py | 2 +-
34 files changed, 2177 insertions(+), 567 deletions(-)
diff --git a/superset-frontend/images/empty-charts.png b/superset-frontend/images/empty-charts.png
new file mode 100644
index 0000000..b814d35
Binary files /dev/null and b/superset-frontend/images/empty-charts.png differ
diff --git a/superset-frontend/images/empty-dashboard.png b/superset-frontend/images/empty-dashboard.png
new file mode 100644
index 0000000..b0d4462
Binary files /dev/null and b/superset-frontend/images/empty-dashboard.png differ
diff --git a/superset-frontend/images/empty-queries.png b/superset-frontend/images/empty-queries.png
new file mode 100644
index 0000000..adc51c0
Binary files /dev/null and b/superset-frontend/images/empty-queries.png differ
diff --git a/superset-frontend/images/star-circle.png b/superset-frontend/images/star-circle.png
new file mode 100644
index 0000000..77fd94d
Binary files /dev/null and b/superset-frontend/images/star-circle.png differ
diff --git a/superset-frontend/images/union.png b/superset-frontend/images/union.png
new file mode 100644
index 0000000..af94c07
Binary files /dev/null and b/superset-frontend/images/union.png differ
diff --git a/superset-frontend/spec/javascripts/components/SubMenu_spec.jsx b/superset-frontend/spec/javascripts/components/SubMenu_spec.jsx
index 54a1b63..ab0020d 100644
--- a/superset-frontend/spec/javascripts/components/SubMenu_spec.jsx
+++ b/superset-frontend/spec/javascripts/components/SubMenu_spec.jsx
@@ -24,7 +24,7 @@ import SubMenu from 'src/components/Menu/SubMenu';
const defaultProps = {
name: 'Title',
- children: [
+ tabs: [
{
name: 'Page1',
label: 'Page1',
diff --git a/superset-frontend/spec/javascripts/views/CRUD/chart/ChartList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/chart/ChartList_spec.jsx
index 3872358..3abedc3 100644
--- a/superset-frontend/spec/javascripts/views/CRUD/chart/ChartList_spec.jsx
+++ b/superset-frontend/spec/javascripts/views/CRUD/chart/ChartList_spec.jsx
@@ -54,6 +54,7 @@ const mockCharts = [...new Array(3)].map((_, i) => ({
fetchMock.get(chartsInfoEndpoint, {
permissions: ['can_list', 'can_edit', 'can_delete'],
});
+
fetchMock.get(chartssOwnersEndpoint, {
result: [],
});
diff --git a/superset-frontend/spec/javascripts/views/CRUD/welcome/ActivityTable_spec.tsx b/superset-frontend/spec/javascripts/views/CRUD/welcome/ActivityTable_spec.tsx
new file mode 100644
index 0000000..eba5d66
--- /dev/null
+++ b/superset-frontend/spec/javascripts/views/CRUD/welcome/ActivityTable_spec.tsx
@@ -0,0 +1,87 @@
+/**
+ * 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 { styledMount as mount } from 'spec/helpers/theming';
+import thunk from 'redux-thunk';
+import fetchMock from 'fetch-mock';
+
+import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
+import configureStore from 'redux-mock-store';
+import ActivityTable from 'src/views/CRUD/welcome/ActivityTable';
+
+const mockStore = configureStore([thunk]);
+const store = mockStore({});
+
+const chartsEndpoint = 'glob:*/api/v1/chart/?*';
+const dashboardEndpoint = 'glob:*/api/v1/dashboard/?*';
+const savedQueryEndpoint = 'glob:*/api/v1/saved_query/?*';
+
+fetchMock.get(chartsEndpoint, {
+ result: [
+ {
+ slice_name: 'ChartyChart',
+ changed_on_utc: '24 Feb 2014 10:13:14',
+ url: '/fakeUrl/explore',
+ id: '4',
+ table: {},
+ },
+ ],
+});
+
+fetchMock.get(dashboardEndpoint, {
+ result: [
+ {
+ dashboard_title: 'Dashboard_Test',
+ changed_on_utc: '24 Feb 2014 10:13:14',
+ url: '/fakeUrl/dashboard',
+ id: '3',
+ },
+ ],
+});
+
+fetchMock.get(savedQueryEndpoint, {
+ result: [],
+});
+
+describe('ActivityTable', () => {
+ const activityProps = {
+ user: {
+ userId: '1',
+ },
+ activityFilter: 'Edited',
+ };
+ const wrapper = mount(<ActivityTable {...activityProps} />, {
+ context: { store },
+ });
+
+ beforeAll(async () => {
+ await waitForComponentToPaint(wrapper);
+ });
+
+ it('the component renders ', () => {
+ expect(wrapper.find(ActivityTable)).toExist();
+ });
+
+ it('calls batch method and renders ListViewCArd', async () => {
+ const chartCall = fetchMock.calls(/chart\/\?q/);
+ const dashboardCall = fetchMock.calls(/dashboard\/\?q/);
+ expect(chartCall).toHaveLength(2);
+ expect(dashboardCall).toHaveLength(2);
+ });
+});
diff --git a/superset-frontend/spec/javascripts/views/CRUD/welcome/ChartTable_spec.tsx b/superset-frontend/spec/javascripts/views/CRUD/welcome/ChartTable_spec.tsx
new file mode 100644
index 0000000..f8cd053
--- /dev/null
+++ b/superset-frontend/spec/javascripts/views/CRUD/welcome/ChartTable_spec.tsx
@@ -0,0 +1,79 @@
+/**
+ * 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 { styledMount as mount } from 'spec/helpers/theming';
+import thunk from 'redux-thunk';
+import fetchMock from 'fetch-mock';
+import configureStore from 'redux-mock-store';
+
+import ChartTable from 'src/views/CRUD/welcome/ChartTable';
+import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
+
+const mockStore = configureStore([thunk]);
+const store = mockStore({});
+
+const chartsEndpoint = 'glob:*/api/v1/chart/?*';
+const chartsInfoEndpoint = 'glob:*/api/v1/chart/_info*';
+
+const mockCharts = [...new Array(3)].map((_, i) => ({
+ changed_on_utc: new Date().toISOString(),
+ created_by: 'super user',
+ id: i,
+ slice_name: `cool chart ${i}`,
+ url: 'url',
+ viz_type: 'bar',
+ datasource_title: `ds${i}`,
+ thumbnail_url: '',
+}));
+
+fetchMock.get(chartsEndpoint, {
+ result: mockCharts,
+});
+
+fetchMock.get(chartsInfoEndpoint, {
+ permissions: ['can_add', 'can_edit', 'can_delete'],
+});
+
+describe('ChartTable', () => {
+ const mockedProps = {
+ user: {
+ userId: '2',
+ },
+ };
+ const wrapper = mount(<ChartTable {...mockedProps} />, {
+ context: { store },
+ });
+ it('it renders', () => {
+ expect(wrapper.find(ChartTable)).toExist();
+ });
+
+ it('fetches chart favorites and renders chart cards ', async () => {
+ expect(fetchMock.calls(chartsEndpoint)).toHaveLength(1);
+ await waitForComponentToPaint(wrapper);
+ expect(wrapper.find('ChartCard')).toExist();
+ });
+
+ it('display EmptyState if there is no data', () => {
+ fetchMock.resetHistory();
+ const wrapper = mount(<ChartTable {...mockedProps} />, {
+ context: { store },
+ });
+ expect(wrapper.find('EmptyState')).toExist();
+ });
+});
diff --git a/superset-frontend/spec/javascripts/views/CRUD/welcome/DashboardTable_spec.tsx b/superset-frontend/spec/javascripts/views/CRUD/welcome/DashboardTable_spec.tsx
index e09b5fe..e7f28f3 100644
--- a/superset-frontend/spec/javascripts/views/CRUD/welcome/DashboardTable_spec.tsx
+++ b/superset-frontend/spec/javascripts/views/CRUD/welcome/DashboardTable_spec.tsx
@@ -17,48 +17,78 @@
* under the License.
*/
import React from 'react';
-import { mount } from 'enzyme';
+import { styledMount as mount } from 'spec/helpers/theming';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import fetchMock from 'fetch-mock';
-import { supersetTheme, ThemeProvider } from '@superset-ui/core';
+import { act } from 'react-dom/test-utils';
-import ListView from 'src/components/ListView';
+import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
+import SubMenu from 'src/components/Menu/SubMenu';
import DashboardTable from 'src/views/CRUD/welcome/DashboardTable';
+import DashboardCard from 'src/views/CRUD/dashboard/DashboardCard';
// store needed for withToasts(DashboardTable)
const mockStore = configureStore([thunk]);
const store = mockStore({});
-const dashboardsEndpoint = 'glob:*/api/v1/dashboard/*';
-const mockDashboards = [{ id: 1, url: 'url', dashboard_title: 'title' }];
+const dashboardsEndpoint = 'glob:*/api/v1/dashboard/?*';
+const dashboardInfoEndpoint = 'glob:*/api/v1/dashboard/_info*';
+const mockDashboards = [
+ {
+ id: 1,
+ url: 'url',
+ dashboard_title: 'title',
+ changed_on_utc: '24 Feb 2014 10:13:14',
+ },
+];
fetchMock.get(dashboardsEndpoint, { result: mockDashboards });
+fetchMock.get(dashboardInfoEndpoint, {
+ permissions: ['can_list', 'can_edit', 'can_delete'],
+});
-function setup() {
- // use mount because data fetching is triggered on mount
- return mount(<DashboardTable />, {
+describe('DashboardTable', () => {
+ const dashboardProps = {
+ dashboardFilter: 'Favorite',
+ user: {
+ userId: '2',
+ },
+ };
+ const wrapper = mount(<DashboardTable {...dashboardProps} />, {
context: { store },
- wrappingComponent: ThemeProvider,
- wrappingComponentProps: { theme: supersetTheme },
});
-}
-describe('DashboardTable', () => {
- beforeEach(fetchMock.resetHistory);
+ beforeAll(async () => {
+ await waitForComponentToPaint(wrapper);
+ });
+
+ it('renders', () => {
+ expect(wrapper.find(DashboardTable)).toExist();
+ });
- it('fetches dashboards and renders a ListView', () => {
- return new Promise(done => {
- const wrapper = setup();
+ it('render a submenu with clickable tabs and buttons', async () => {
+ expect(wrapper.find(SubMenu)).toExist();
+ expect(wrapper.find('MenuItem')).toHaveLength(2);
+ expect(wrapper.find('Button')).toHaveLength(4);
+ act(() => {
+ wrapper.find('MenuItem').at(1).simulate('click');
+ });
+ await waitForComponentToPaint(wrapper);
+ expect(fetchMock.calls(/dashboard\/\?q/)).toHaveLength(1);
+ });
+
+ it('fetches dashboards and renders a card', () => {
+ expect(fetchMock.calls(/dashboard\/\?q/)).toHaveLength(1);
+ wrapper.setState({ dashboards: mockDashboards });
+ expect(wrapper.find(DashboardCard)).toExist();
+ });
- setTimeout(() => {
- expect(fetchMock.calls(dashboardsEndpoint)).toHaveLength(1);
- // there's a delay between response and updating state, so manually set it
- // rather than adding a timeout which could introduce flakiness
- wrapper.setState({ dashboards: mockDashboards });
- expect(wrapper.find(ListView)).toExist();
- done();
- });
+ it('display EmptyState if there is no data', () => {
+ fetchMock.resetHistory();
+ const wrapper = mount(<DashboardTable {...dashboardProps} />, {
+ context: { store },
});
+ expect(wrapper.find('EmptyState')).toExist();
});
});
diff --git a/superset-frontend/spec/javascripts/views/CRUD/welcome/EmptyState_spec.tsx b/superset-frontend/spec/javascripts/views/CRUD/welcome/EmptyState_spec.tsx
new file mode 100644
index 0000000..96ec1ec
--- /dev/null
+++ b/superset-frontend/spec/javascripts/views/CRUD/welcome/EmptyState_spec.tsx
@@ -0,0 +1,92 @@
+/**
+ * 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 { styledMount as mount } from 'spec/helpers/theming';
+import EmptyState from 'src/views/CRUD/welcome/EmptyState';
+
+describe('EmptyState', () => {
+ const variants = [
+ {
+ tab: 'Favorite',
+ tableName: 'DASHBOARDS',
+ },
+ {
+ tab: 'Mine',
+ tableName: 'DASHBOARDS',
+ },
+ {
+ tab: 'Favorite',
+ tableName: 'CHARTS',
+ },
+ {
+ tab: 'Mine',
+ tableName: 'CHARTS',
+ },
+ {
+ tab: 'Favorite',
+ tableName: 'SAVED_QUERIES',
+ },
+ {
+ tab: 'Mine',
+ tableName: 'SAVED_QUEREIS',
+ },
+ ];
+ const recents = [
+ {
+ tab: 'Viewed',
+ tableName: 'RECENTS',
+ },
+ {
+ tab: 'Edited',
+ tableName: 'RECENTS',
+ },
+ {
+ tab: 'Created',
+ tableName: 'RECENTS',
+ },
+ ];
+ variants.forEach(variant => {
+ it(`it renders an ${variant.tab} ${variant.tableName} empty state`, () => {
+ const wrapper = mount(<EmptyState {...variant} />);
+ expect(wrapper).toExist();
+ const textContainer = wrapper.find('.ant-empty-description');
+ expect(textContainer.text()).toEqual(
+ variant.tab === 'Favorite'
+ ? "You don't have any favorites yet!"
+ : `No ${
+ variant.tableName === 'SAVED_QUERIES'
+ ? 'saved queries'
+ : variant.tableName.toLowerCase()
+ } yet`,
+ );
+ expect(wrapper.find('button')).toHaveLength(1);
+ });
+ });
+ recents.forEach(recent => {
+ it(`it renders an ${recent.tab} ${recent.tableName} empty state`, () => {
+ const wrapper = mount(<EmptyState {...recent} />);
+ expect(wrapper).toExist();
+ const textContainer = wrapper.find('.ant-empty-description');
+ expect(wrapper.find('.ant-empty-image').children()).toHaveLength(1);
+ expect(textContainer.text()).toContain(
+ `Recently ${recent.tab.toLowerCase()} charts, dashboards, and saved queries will appear here`,
+ );
+ });
+ });
+});
diff --git a/superset-frontend/spec/javascripts/views/CRUD/welcome/SavedQueries_spec.tsx b/superset-frontend/spec/javascripts/views/CRUD/welcome/SavedQueries_spec.tsx
new file mode 100644
index 0000000..2670482
--- /dev/null
+++ b/superset-frontend/spec/javascripts/views/CRUD/welcome/SavedQueries_spec.tsx
@@ -0,0 +1,106 @@
+/**
+ * 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 thunk from 'redux-thunk';
+import { styledMount as mount } from 'spec/helpers/theming';
+import fetchMock from 'fetch-mock';
+import configureStore from 'redux-mock-store';
+import { act } from 'react-dom/test-utils';
+
+import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
+import SubMenu from 'src/components/Menu/SubMenu';
+import SavedQueries from 'src/views/CRUD/welcome/SavedQueries';
+
+// store needed for withToasts(DashboardTable)
+const mockStore = configureStore([thunk]);
+const store = mockStore({});
+
+const queriesEndpoint = 'glob:*/api/v1/saved_query/?*';
+const savedQueriesInfo = 'glob:*/api/v1/saved_query/_info';
+
+const mockqueries = [...new Array(3)].map((_, i) => ({
+ created_by: {
+ id: i,
+ first_name: `user`,
+ last_name: `${i}`,
+ },
+ created_on: `${i}-2020`,
+ database: {
+ database_name: `db ${i}`,
+ id: i,
+ },
+ changed_on_delta_humanized: '1 day ago',
+ db_id: i,
+ description: `SQL for ${i}`,
+ id: i,
+ label: `query ${i}`,
+ schema: 'public',
+ sql: `SELECT ${i} FROM table`,
+ sql_tables: [
+ {
+ catalog: null,
+ schema: null,
+ table: `${i}`,
+ },
+ ],
+}));
+
+fetchMock.get(queriesEndpoint, {
+ result: mockqueries,
+});
+
+fetchMock.get(savedQueriesInfo, {
+ permissions: ['can_list', 'can_edit', 'can_delete'],
+});
+
+describe('SavedQueries', () => {
+ const savedQueryProps = {
+ user: {
+ userId: '1',
+ },
+ };
+
+ const wrapper = mount(<SavedQueries {...savedQueryProps} />, {
+ context: { store },
+ });
+ beforeAll(async () => {
+ await waitForComponentToPaint(wrapper);
+ });
+
+ it('is valid', () => {
+ expect(wrapper.find(SavedQueries)).toExist();
+ });
+
+ it('it renders a submenu with clickable tables and buttons', async () => {
+ expect(wrapper.find(SubMenu)).toExist();
+ expect(wrapper.find('MenuItem')).toHaveLength(2);
+ expect(wrapper.find('button')).toHaveLength(2);
+ act(() => {
+ wrapper.find('MenuItem').at(1).simulate('click');
+ });
+
+ await waitForComponentToPaint(wrapper);
+ expect(fetchMock.calls(/saved_query\/\?q/)).toHaveLength(1);
+ });
+
+ it('fetches queries favorites and renders listviewcard cards', () => {
+ expect(fetchMock.calls(/saved_query\/\?q/)).toHaveLength(1);
+ expect(wrapper.find('ListViewCard')).toExist();
+ });
+});
diff --git a/superset-frontend/spec/javascripts/views/CRUD/welcome/Welcome_spec.tsx b/superset-frontend/spec/javascripts/views/CRUD/welcome/Welcome_spec.tsx
index bf23ef1..4cd051c 100644
--- a/superset-frontend/spec/javascripts/views/CRUD/welcome/Welcome_spec.tsx
+++ b/superset-frontend/spec/javascripts/views/CRUD/welcome/Welcome_spec.tsx
@@ -17,11 +17,14 @@
* under the License.
*/
import React from 'react';
-import { Panel, Row, Tab } from 'react-bootstrap';
import { shallow } from 'enzyme';
-
+import thunk from 'redux-thunk';
+import configureStore from 'redux-mock-store';
import Welcome from 'src/views/CRUD/welcome/Welcome';
+const mockStore = configureStore([thunk]);
+const store = mockStore({});
+
describe('Welcome', () => {
const mockedProps = {
user: {
@@ -34,13 +37,15 @@ describe('Welcome', () => {
isActive: true,
},
};
- it('is valid', () => {
- expect(React.isValidElement(<Welcome {...mockedProps} />)).toBe(true);
+ const wrapper = shallow(<Welcome {...mockedProps} />, {
+ context: { store },
});
- it('renders 3 Tab, Panel, and Row components', () => {
- const wrapper = shallow(<Welcome {...mockedProps} />);
- expect(wrapper.find(Tab)).toHaveLength(3);
- expect(wrapper.find(Panel)).toHaveLength(3);
- expect(wrapper.find(Row)).toHaveLength(3);
+
+ it('renders', () => {
+ expect(wrapper).toExist();
+ });
+
+ it('renders all panels on the page on page load', () => {
+ expect(wrapper.find('CollapsePanel')).toHaveLength(4);
});
});
diff --git a/superset-frontend/src/components/ListViewCard/index.tsx b/superset-frontend/src/components/ListViewCard/index.tsx
index 4a0cf53..8849b4b 100644
--- a/superset-frontend/src/components/ListViewCard/index.tsx
+++ b/superset-frontend/src/components/ListViewCard/index.tsx
@@ -143,15 +143,20 @@ const paragraphConfig = { rows: 1, width: 150 };
interface CardProps {
title: React.ReactNode;
url?: string;
- imgURL: string;
- imgFallbackURL: string;
+ imgURL?: string;
+ imgFallbackURL?: string;
imgPosition?: BackgroundPosition;
description: string;
loading: boolean;
titleRight?: React.ReactNode;
coverLeft?: React.ReactNode;
coverRight?: React.ReactNode;
- actions: React.ReactNode;
+ actions: React.ReactNode | null;
+ showImg?: boolean;
+ rows?: number | string;
+ avatar?: string;
+ isRecent?: boolean;
+ renderCover?: React.ReactNode | null;
}
function ListViewCard({
@@ -162,35 +167,42 @@ function ListViewCard({
imgFallbackURL,
description,
coverLeft,
+ isRecent,
coverRight,
actions,
+ avatar,
loading,
imgPosition = 'top',
+ renderCover,
}: CardProps) {
return (
<StyledCard
data-test="styled-card"
cover={
- <Cover>
- <a href={url}>
- <div className="gradient-container">
- <ImageLoader
- src={imgURL}
- fallback={imgFallbackURL}
- isLoading={loading}
- position={imgPosition}
- />
- </div>
- </a>
- <CoverFooter className="cover-footer">
- {!loading && coverLeft && (
- <CoverFooterLeft>{coverLeft}</CoverFooterLeft>
- )}
- {!loading && coverRight && (
- <CoverFooterRight>{coverRight}</CoverFooterRight>
- )}
- </CoverFooter>
- </Cover>
+ !isRecent
+ ? renderCover || (
+ <Cover>
+ <a href={url}>
+ <div className="gradient-container">
+ <ImageLoader
+ src={imgURL || ''}
+ fallback={imgFallbackURL || ''}
+ isLoading={loading}
+ position={imgPosition}
+ />
+ </div>
+ </a>
+ <CoverFooter className="cover-footer">
+ {!loading && coverLeft && (
+ <CoverFooterLeft>{coverLeft}</CoverFooterLeft>
+ )}
+ {!loading && coverRight && (
+ <CoverFooterRight>{coverRight}</CoverFooterRight>
+ )}
+ </CoverFooter>
+ </Cover>
+ )
+ : null
}
>
{loading && (
@@ -230,6 +242,8 @@ function ListViewCard({
</>
}
description={description}
+ // @ts-ignore
+ avatar={avatar ? <Icon name={avatar} /> : null}
/>
)}
</StyledCard>
diff --git a/superset-frontend/src/components/Menu/SubMenu.tsx b/superset-frontend/src/components/Menu/SubMenu.tsx
index e17ce7a..bf626f1 100644
--- a/superset-frontend/src/components/Menu/SubMenu.tsx
+++ b/superset-frontend/src/components/Menu/SubMenu.tsx
@@ -53,10 +53,23 @@ const StyledHeader = styled.header`
li.active > a,
li.active > div,
li > a:hover,
+ li > a:focus,
li > div:hover {
- background-color: ${({ theme }) => theme.colors.secondary.light4};
+ background: ${({ theme }) => theme.colors.secondary.light4};
border-bottom: none;
- border-radius: 4px;
+ border-radius: ${({ theme }) => theme.borderRadius}px;
+ margin-bottom: ${({ theme }) => theme.gridUnit * 2}px;
+ }
+ }
+ .navbar-inverse {
+ .navbar-nav {
+ & > .active > a {
+ background: ${({ theme }) => theme.colors.secondary.light4};
+ &:hover,
+ &:focus {
+ background: ${({ theme }) => theme.colors.secondary.light4};
+ }
+ }
}
}
`;
@@ -64,8 +77,9 @@ const StyledHeader = styled.header`
type MenuChild = {
label: string;
name: string;
- url: string;
+ url?: string;
usesRouter?: boolean;
+ onClick?: () => void;
};
export interface ButtonProps {
@@ -83,8 +97,8 @@ export interface ButtonProps {
export interface SubMenuProps {
buttons?: Array<ButtonProps>;
- name: string;
- children?: MenuChild[];
+ name?: string;
+ tabs?: MenuChild[];
activeChild?: MenuChild['name'];
/* If usesRouter is true, a react-router <Link> component will be used instead of href.
* ONLY set usesRouter to true if SubMenu is wrapped in a react-router <Router>;
@@ -108,16 +122,16 @@ const SubMenu: React.FunctionComponent<SubMenuProps> = props => {
<Navbar.Brand>{props.name}</Navbar.Brand>
</Navbar.Header>
<Nav>
- {props.children &&
- props.children.map(child => {
- if ((props.usesRouter || hasHistory) && !!child.usesRouter) {
+ {props.tabs &&
+ props.tabs.map(tab => {
+ if ((props.usesRouter || hasHistory) && !!tab.usesRouter) {
return (
<li
- className={child.name === props.activeChild ? 'active' : ''}
- key={`${child.label}`}
+ className={tab.name === props.activeChild ? 'active' : ''}
+ key={`${tab.label}`}
>
<div>
- <Link to={child.url}>{child.label}</Link>
+ <Link to={tab.url || ''}>{tab.label}</Link>
</div>
</li>
);
@@ -126,11 +140,12 @@ const SubMenu: React.FunctionComponent<SubMenuProps> = props => {
return (
<MenuItem
className="no-router"
- active={child.name === props.activeChild}
- key={`${child.label}`}
- href={child.url}
+ active={tab.name === props.activeChild}
+ key={`${tab.label}`}
+ href={tab.url}
+ onClick={tab.onClick}
>
- {child.label}
+ {tab.label}
</MenuItem>
);
})}
diff --git a/superset-frontend/src/views/CRUD/chart/ChartCard.tsx b/superset-frontend/src/views/CRUD/chart/ChartCard.tsx
new file mode 100644
index 0000000..5dd6bd8
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/chart/ChartCard.tsx
@@ -0,0 +1,138 @@
+/**
+ * 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 { useFavoriteStatus } from 'src/views/CRUD/hooks';
+import { t } from '@superset-ui/core';
+import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
+import Icon from 'src/components/Icon';
+import Chart from 'src/types/Chart';
+
+import ListViewCard from 'src/components/ListViewCard';
+import Label from 'src/components/Label';
+import { Dropdown, Menu } from 'src/common/components';
+import FaveStar from 'src/components/FaveStar';
+import FacePile from 'src/components/FacePile';
+import { handleChartDelete } from '../utils';
+
+const FAVESTAR_BASE_URL = '/superset/favstar/slice';
+
+interface ChartCardProps {
+ chart: Chart;
+ hasPerm: (perm: string) => boolean;
+ openChartEditModal: (chart: Chart) => void;
+ bulkSelectEnabled: boolean;
+ addDangerToast: (msg: string) => void;
+ addSuccessToast: (msg: string) => void;
+ refreshData: () => void;
+ loading: boolean;
+}
+
+export default function ChartCard({
+ chart,
+ hasPerm,
+ openChartEditModal,
+ bulkSelectEnabled,
+ addDangerToast,
+ addSuccessToast,
+ refreshData,
+ loading,
+}: ChartCardProps) {
+ const canEdit = hasPerm('can_edit');
+ const canDelete = hasPerm('can_delete');
+ const [, fetchFaveStar, saveFaveStar, favoriteStatus] = useFavoriteStatus(
+ {},
+ FAVESTAR_BASE_URL,
+ addDangerToast,
+ );
+
+ const menu = (
+ <Menu>
+ {canDelete && (
+ <Menu.Item>
+ <ConfirmStatusChange
+ title={t('Please Confirm')}
+ description={
+ <>
+ {t('Are you sure you want to delete')} <b>{chart.slice_name}</b>
+ ?
+ </>
+ }
+ onConfirm={() =>
+ handleChartDelete(
+ chart,
+ addSuccessToast,
+ addDangerToast,
+ refreshData,
+ )
+ }
+ >
+ {confirmDelete => (
+ <div
+ data-test="chart-list-delete-option"
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ onClick={confirmDelete}
+ >
+ <ListViewCard.MenuIcon name="trash" /> {t('Delete')}
+ </div>
+ )}
+ </ConfirmStatusChange>
+ </Menu.Item>
+ )}
+ {canEdit && (
+ <Menu.Item
+ data-test="chart-list-edit-option"
+ role="button"
+ tabIndex={0}
+ onClick={() => openChartEditModal(chart)}
+ >
+ <ListViewCard.MenuIcon name="edit-alt" /> {t('Edit')}
+ </Menu.Item>
+ )}
+ </Menu>
+ );
+ return (
+ <ListViewCard
+ loading={loading}
+ title={chart.slice_name}
+ url={bulkSelectEnabled ? undefined : chart.url}
+ imgURL={chart.thumbnail_url || ''}
+ imgFallbackURL="/static/assets/images/chart-card-fallback.png"
+ description={t('Last modified %s', chart.changed_on_delta_humanized)}
+ coverLeft={<FacePile users={chart.owners || []} />}
+ coverRight={
+ <Label bsStyle="secondary">{chart.datasource_name_text}</Label>
+ }
+ actions={
+ <ListViewCard.Actions>
+ <FaveStar
+ itemId={chart.id}
+ fetchFaveStar={fetchFaveStar}
+ saveFaveStar={saveFaveStar}
+ isStarred={!!favoriteStatus[chart.id]}
+ />
+ <Dropdown overlay={menu}>
+ <Icon name="more-horiz" />
+ </Dropdown>
+ </ListViewCard.Actions>
+ }
+ />
+ );
+}
diff --git a/superset-frontend/src/views/CRUD/chart/ChartList.tsx b/superset-frontend/src/views/CRUD/chart/ChartList.tsx
index a22c54d..91cb458 100644
--- a/superset-frontend/src/views/CRUD/chart/ChartList.tsx
+++ b/superset-frontend/src/views/CRUD/chart/ChartList.tsx
@@ -17,15 +17,22 @@
* under the License.
*/
import { SupersetClient, getChartMetadataRegistry, t } from '@superset-ui/core';
-import React, { useState, useMemo } from 'react';
+import React, { useMemo } from 'react';
import rison from 'rison';
import { uniqBy } from 'lodash';
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
-import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils';
-import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks';
+import {
+ createFetchRelated,
+ createErrorHandler,
+ handleChartDelete,
+} from 'src/views/CRUD/utils';
+import {
+ useListViewResource,
+ useFavoriteStatus,
+ useChartEditModal,
+} from 'src/views/CRUD/hooks';
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
-import FacePile from 'src/components/FacePile';
import Icon from 'src/components/Icon';
import FaveStar from 'src/components/FaveStar';
import ListView, {
@@ -35,11 +42,9 @@ import ListView, {
} from 'src/components/ListView';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import PropertiesModal from 'src/explore/components/PropertiesModal';
-import Chart, { Slice } from 'src/types/Chart';
-import ListViewCard from 'src/components/ListViewCard';
-import Label from 'src/components/Label';
-import { Dropdown, Menu } from 'src/common/components';
+import Chart from 'src/types/Chart';
import TooltipWrapper from 'src/components/TooltipWrapper';
+import ChartCard from './ChartCard';
const PAGE_SIZE = 25;
const FAVESTAR_BASE_URL = '/superset/favstar/slice';
@@ -105,51 +110,18 @@ function ChartList(props: ChartListProps) {
FAVESTAR_BASE_URL,
props.addDangerToast,
);
- const [
+ const {
sliceCurrentlyEditing,
- setSliceCurrentlyEditing,
- ] = useState<Slice | null>(null);
+ handleChartUpdated,
+ openChartEditModal,
+ closeChartEditModal,
+ } = useChartEditModal(setCharts, charts);
const canCreate = hasPerm('can_add');
const canEdit = hasPerm('can_edit');
const canDelete = hasPerm('can_delete');
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
- function openChartEditModal(chart: Chart) {
- setSliceCurrentlyEditing({
- slice_id: chart.id,
- slice_name: chart.slice_name,
- description: chart.description,
- cache_timeout: chart.cache_timeout,
- });
- }
-
- function closeChartEditModal() {
- setSliceCurrentlyEditing(null);
- }
-
- function handleChartUpdated(edits: Chart) {
- // update the chart in our state with the edited info
- const newCharts = charts.map(chart =>
- chart.id === edits.id ? { ...chart, ...edits } : chart,
- );
- setCharts(newCharts);
- }
-
- function handleChartDelete({ id, slice_name: sliceName }: Chart) {
- SupersetClient.delete({
- endpoint: `/api/v1/chart/${id}`,
- }).then(
- () => {
- refreshData();
- props.addSuccessToast(t('Deleted: %s', sliceName));
- },
- () => {
- props.addDangerToast(t('There was an issue deleting: %s', sliceName));
- },
- );
- }
-
function handleBulkChartDelete(chartsToDelete: Chart[]) {
SupersetClient.delete({
endpoint: `/api/v1/chart/?q=${rison.encode(
@@ -266,7 +238,13 @@ function ChartList(props: ChartListProps) {
},
{
Cell: ({ row: { original } }: any) => {
- const handleDelete = () => handleChartDelete(original);
+ const handleDelete = () =>
+ handleChartDelete(
+ original,
+ props.addSuccessToast,
+ props.addDangerToast,
+ refreshData,
+ );
const openEditModal = () => openChartEditModal(original);
return (
@@ -426,69 +404,17 @@ function ChartList(props: ChartListProps) {
},
];
- function renderCard(chart: Chart & { loading: boolean }) {
- const menu = (
- <Menu>
- {canDelete && (
- <Menu.Item>
- <ConfirmStatusChange
- title={t('Please Confirm')}
- description={
- <>
- {t('Are you sure you want to delete')}{' '}
- <b>{chart.slice_name}</b>?
- </>
- }
- onConfirm={() => handleChartDelete(chart)}
- >
- {confirmDelete => (
- <div
- data-test="chart-list-delete-option"
- role="button"
- tabIndex={0}
- className="action-button"
- onClick={confirmDelete}
- >
- <ListViewCard.MenuIcon name="trash" /> Delete
- </div>
- )}
- </ConfirmStatusChange>
- </Menu.Item>
- )}
- {canEdit && (
- <Menu.Item
- data-test="chart-list-edit-option"
- role="button"
- tabIndex={0}
- onClick={() => openChartEditModal(chart)}
- >
- <ListViewCard.MenuIcon name="edit-alt" /> Edit
- </Menu.Item>
- )}
- </Menu>
- );
-
+ function renderCard(chart: Chart) {
return (
- <ListViewCard
- loading={chart.loading}
- title={chart.slice_name}
- url={bulkSelectEnabled ? undefined : chart.url}
- imgURL={chart.thumbnail_url ?? ''}
- imgFallbackURL="/static/assets/images/chart-card-fallback.png"
- imgPosition="bottom"
- description={t('Last modified %s', chart.changed_on_delta_humanized)}
- coverLeft={<FacePile users={chart.owners || []} />}
- coverRight={
- <Label bsStyle="secondary">{chart.datasource_name_text}</Label>
- }
- actions={
- <ListViewCard.Actions>
- {renderFaveStar(chart.id)}
- <Dropdown data-test="dropdown-options" overlay={menu}>
- <Icon name="more-horiz" />
- </Dropdown>
- </ListViewCard.Actions>
- }
+ <ChartCard
+ chart={chart}
+ hasPerm={hasPerm}
+ openChartEditModal={openChartEditModal}
+ bulkSelectEnabled={bulkSelectEnabled}
+ addDangerToast={props.addDangerToast}
+ addSuccessToast={props.addSuccessToast}
+ refreshData={refreshData}
+ loading={loading}
/>
);
}
diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardCard.tsx b/superset-frontend/src/views/CRUD/dashboard/DashboardCard.tsx
new file mode 100644
index 0000000..f8713d6
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/dashboard/DashboardCard.tsx
@@ -0,0 +1,140 @@
+/**
+ * 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 { t } from '@superset-ui/core';
+import {
+ handleDashboardDelete,
+ handleBulkDashboardExport,
+} from 'src/views/CRUD/utils';
+import { Dropdown, Menu } from 'src/common/components';
+import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
+import ListViewCard from 'src/components/ListViewCard';
+import Icon from 'src/components/Icon';
+import Label from 'src/components/Label';
+import FacePile from 'src/components/FacePile';
+import FaveStar from 'src/components/FaveStar';
+import { DashboardCardProps } from 'src/views/CRUD/types';
+
+import { useFavoriteStatus } from 'src/views/CRUD/hooks';
+
+const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard';
+
+function DashboardCard({
+ dashboard,
+ hasPerm,
+ bulkSelectEnabled,
+ refreshData,
+ addDangerToast,
+ addSuccessToast,
+ openDashboardEditModal,
+}: DashboardCardProps) {
+ const canEdit = hasPerm('can_edit');
+ const canDelete = hasPerm('can_delete');
+ const canExport = hasPerm('can_mulexport');
+ const [, fetchFaveStar, saveFaveStar, favoriteStatus] = useFavoriteStatus(
+ {},
+ FAVESTAR_BASE_URL,
+ addDangerToast,
+ );
+
+ const menu = (
+ <Menu>
+ {canEdit && openDashboardEditModal && (
+ <Menu.Item
+ role="button"
+ tabIndex={0}
+ onClick={() =>
+ openDashboardEditModal && openDashboardEditModal(dashboard)
+ }
+ >
+ <ListViewCard.MenuIcon name="edit-alt" /> Edit
+ </Menu.Item>
+ )}
+ {canExport && (
+ <Menu.Item
+ role="button"
+ tabIndex={0}
+ onClick={() => handleBulkDashboardExport([dashboard])}
+ >
+ <ListViewCard.MenuIcon name="share" /> Export
+ </Menu.Item>
+ )}
+ {canDelete && (
+ <Menu.Item>
+ <ConfirmStatusChange
+ title={t('Please Confirm')}
+ description={
+ <>
+ {t('Are you sure you want to delete')}{' '}
+ <b>{dashboard.dashboard_title}</b>?
+ </>
+ }
+ onConfirm={() =>
+ handleDashboardDelete(
+ dashboard,
+ refreshData,
+ addSuccessToast,
+ addDangerToast,
+ )
+ }
+ >
+ {confirmDelete => (
+ <div
+ role="button"
+ tabIndex={0}
+ className="action-button"
+ onClick={confirmDelete}
+ >
+ <ListViewCard.MenuIcon name="trash" /> Delete
+ </div>
+ )}
+ </ConfirmStatusChange>
+ </Menu.Item>
+ )}
+ </Menu>
+ );
+ return (
+ <ListViewCard
+ loading={dashboard.loading || false}
+ title={dashboard.dashboard_title}
+ titleRight={<Label>{dashboard.published ? 'published' : 'draft'}</Label>}
+ url={bulkSelectEnabled ? undefined : dashboard.url}
+ imgURL={dashboard.thumbnail_url}
+ imgFallbackURL="/static/assets/images/dashboard-card-fallback.png"
+ description={t('Last modified %s', dashboard.changed_on_delta_humanized)}
+ coverLeft={<FacePile users={dashboard.owners || []} />}
+ actions={
+ <ListViewCard.Actions>
+ <FaveStar
+ itemId={dashboard.id}
+ fetchFaveStar={fetchFaveStar}
+ saveFaveStar={saveFaveStar}
+ isStarred={!!favoriteStatus[dashboard.id]}
+ />
+ <Dropdown overlay={menu}>
+ <Icon name="more-horiz" />
+ </Dropdown>
+ </ListViewCard.Actions>
+ }
+ showImg
+ />
+ );
+}
+
+export default DashboardCard;
diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx
index ac4d747..d8acca3 100644
--- a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx
+++ b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx
@@ -20,22 +20,27 @@ import { SupersetClient, t } from '@superset-ui/core';
import React, { useState, useMemo } from 'react';
import rison from 'rison';
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
-import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils';
+import {
+ createFetchRelated,
+ createErrorHandler,
+ handleDashboardDelete,
+ handleBulkDashboardExport,
+} from 'src/views/CRUD/utils';
import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks';
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
-import FacePile from 'src/components/FacePile';
import ListView, { ListViewProps, Filters } from 'src/components/ListView';
import Owner from 'src/types/Owner';
import withToasts from 'src/messageToasts/enhancers/withToasts';
+import FacePile from 'src/components/FacePile';
import Icon from 'src/components/Icon';
-import Label from 'src/components/Label';
import FaveStar from 'src/components/FaveStar';
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
-import ListViewCard from 'src/components/ListViewCard';
-import { Dropdown, Menu } from 'src/common/components';
import TooltipWrapper from 'src/components/TooltipWrapper';
+import Dashboard from 'src/dashboard/containers/Dashboard';
+import DashboardCard from './DashboardCard';
+
const PAGE_SIZE = 25;
const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard';
@@ -81,7 +86,6 @@ function DashboardList(props: DashboardListProps) {
FAVESTAR_BASE_URL,
props.addDangerToast,
);
-
const [dashboardToEdit, setDashboardToEdit] = useState<Dashboard | null>(
null,
);
@@ -119,25 +123,6 @@ function DashboardList(props: DashboardListProps) {
);
}
- function handleDashboardDelete({
- id,
- dashboard_title: dashboardTitle,
- }: Dashboard) {
- return SupersetClient.delete({
- endpoint: `/api/v1/dashboard/${id}`,
- }).then(
- () => {
- refreshData();
- props.addSuccessToast(t('Deleted: %s', dashboardTitle));
- },
- createErrorHandler(errMsg =>
- props.addDangerToast(
- t('There was an issue deleting %s: %s', dashboardTitle, errMsg),
- ),
- ),
- );
- }
-
function handleBulkDashboardDelete(dashboardsToDelete: Dashboard[]) {
return SupersetClient.delete({
endpoint: `/api/v1/dashboard/?q=${rison.encode(
@@ -155,14 +140,6 @@ function DashboardList(props: DashboardListProps) {
);
}
- function handleBulkDashboardExport(dashboardsToExport: Dashboard[]) {
- return window.location.assign(
- `/api/v1/dashboard/export/?q=${rison.encode(
- dashboardsToExport.map(({ id }) => id),
- )}`,
- );
- }
-
function renderFaveStar(id: number) {
return (
<FaveStar
@@ -255,7 +232,13 @@ function DashboardList(props: DashboardListProps) {
},
{
Cell: ({ row: { original } }: any) => {
- const handleDelete = () => handleDashboardDelete(original);
+ const handleDelete = () =>
+ handleDashboardDelete(
+ original,
+ refreshData,
+ props.addSuccessToast,
+ props.addDangerToast,
+ );
const handleEdit = () => openDashboardEditModal(original);
const handleExport = () => handleBulkDashboardExport([original]);
@@ -418,83 +401,18 @@ function DashboardList(props: DashboardListProps) {
},
];
- function renderCard(dashboard: Dashboard & { loading: boolean }) {
- const menu = (
- <Menu>
- {canDelete && (
- <Menu.Item>
- <ConfirmStatusChange
- title={t('Please Confirm')}
- description={
- <>
- {t('Are you sure you want to delete')}{' '}
- <b>{dashboard.dashboard_title}</b>?
- </>
- }
- onConfirm={() => handleDashboardDelete(dashboard)}
- >
- {confirmDelete => (
- <div
- role="button"
- tabIndex={0}
- className="action-button"
- onClick={confirmDelete}
- >
- <ListViewCard.MenuIcon
- data-test="dashboard-list-view-card-trash-icon"
- name="trash"
- />{' '}
- Delete
- </div>
- )}
- </ConfirmStatusChange>
- </Menu.Item>
- )}
- {canExport && (
- <Menu.Item
- role="button"
- tabIndex={0}
- onClick={() => handleBulkDashboardExport([dashboard])}
- >
- <ListViewCard.MenuIcon name="share" /> Export
- </Menu.Item>
- )}
- {canEdit && (
- <Menu.Item
- data-test="dashboard-list-edit-option"
- role="button"
- tabIndex={0}
- onClick={() => openDashboardEditModal(dashboard)}
- >
- <ListViewCard.MenuIcon name="edit-alt" /> Edit
- </Menu.Item>
- )}
- </Menu>
- );
-
+ function renderCard(dashboard: Dashboard) {
return (
- <ListViewCard
- loading={dashboard.loading}
- title={dashboard.dashboard_title}
- titleRight={
- <Label>{dashboard.published ? 'published' : 'draft'}</Label>
- }
- url={bulkSelectEnabled ? undefined : dashboard.url}
- imgURL={dashboard.thumbnail_url}
- imgFallbackURL="/static/assets/images/dashboard-card-fallback.png"
- description={t(
- 'Last modified %s',
- dashboard.changed_on_delta_humanized,
- )}
- coverLeft={<FacePile users={dashboard.owners || []} />}
- actions={
- <ListViewCard.Actions>
- {renderFaveStar(dashboard.id)}
- <Dropdown overlay={menu}>
- <Icon name="more-horiz" />
- </Dropdown>
- </ListViewCard.Actions>
- }
+ <DashboardCard
+ {...{
+ dashboard,
+ hasPerm,
+ bulkSelectEnabled,
+ refreshData,
+ addDangerToast: props.addDangerToast,
+ addSuccessToast: props.addSuccessToast,
+ openDashboardEditModal,
+ }}
/>
);
}
diff --git a/superset-frontend/src/views/CRUD/data/common.ts b/superset-frontend/src/views/CRUD/data/common.ts
index 9b2194a..6fecd21 100644
--- a/superset-frontend/src/views/CRUD/data/common.ts
+++ b/superset-frontend/src/views/CRUD/data/common.ts
@@ -20,7 +20,7 @@ import { t } from '@superset-ui/core';
export const commonMenuData = {
name: t('Data'),
- children: [
+ tabs: [
{
name: 'Datasets',
label: t('Datasets'),
diff --git a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx
index f887af5..bfb4719 100644
--- a/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx
+++ b/superset-frontend/src/views/CRUD/data/savedquery/SavedQueryList.tsx
@@ -39,6 +39,7 @@ import DeleteModal from 'src/components/DeleteModal';
import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
import { IconName } from 'src/components/Icon';
import { commonMenuData } from 'src/views/CRUD/data/common';
+import { SavedQueryObject } from 'src/views/CRUD/types';
import SavedQueryPreviewModal from './SavedQueryPreviewModal';
const PAGE_SIZE = 25;
@@ -48,20 +49,6 @@ interface SavedQueryListProps {
addSuccessToast: (msg: string) => void;
}
-type SavedQueryObject = {
- database: {
- database_name: string;
- id: number;
- };
- db_id: number;
- description?: string;
- id: number;
- label: string;
- schema: string;
- sql: string;
- sql_tables: Array<{ catalog?: string; schema: string; table: string }>;
-};
-
const StyledTableLabel = styled.div`
.count {
margin-left: 5px;
diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts
index 9c2386f..b083bde 100644
--- a/superset-frontend/src/views/CRUD/hooks.ts
+++ b/superset-frontend/src/views/CRUD/hooks.ts
@@ -22,6 +22,7 @@ import { SupersetClient, t } from '@superset-ui/core';
import { createErrorHandler } from 'src/views/CRUD/utils';
import { FetchDataConfig } from 'src/components/ListView';
+import Chart, { Slice } from 'src/types/Chart';
import { FavoriteStatus } from './types';
interface ListViewResourceState<D extends object = any> {
@@ -350,5 +351,87 @@ export function useFavoriteStatus(
);
};
- return [favoriteStatusRef, fetchFaveStar, saveFaveStar] as const;
+ return [
+ favoriteStatusRef,
+ fetchFaveStar,
+ saveFaveStar,
+ favoriteStatus,
+ ] as const;
}
+
+export const useChartEditModal = (
+ setCharts: (charts: Array<Chart>) => void,
+ charts: Array<Chart>,
+) => {
+ const [
+ sliceCurrentlyEditing,
+ setSliceCurrentlyEditing,
+ ] = useState<Slice | null>(null);
+
+ function openChartEditModal(chart: Chart) {
+ setSliceCurrentlyEditing({
+ slice_id: chart.id,
+ slice_name: chart.slice_name,
+ description: chart.description,
+ cache_timeout: chart.cache_timeout,
+ });
+ }
+
+ function closeChartEditModal() {
+ setSliceCurrentlyEditing(null);
+ }
+
+ function handleChartUpdated(edits: Chart) {
+ // update the chart in our state with the edited info
+ const newCharts = charts.map((chart: Chart) =>
+ chart.id === edits.id ? { ...chart, ...edits } : chart,
+ );
+ setCharts(newCharts);
+ }
+
+ return {
+ sliceCurrentlyEditing,
+ handleChartUpdated,
+ openChartEditModal,
+ closeChartEditModal,
+ };
+};
+
+export const copyQueryLink = (
+ id: number,
+ addDangerToast: (arg0: string) => void,
+ addSuccessToast: (arg0: string) => void,
+) => {
+ const selection: Selection | null = document.getSelection();
+
+ if (selection) {
+ selection.removeAllRanges();
+ const range = document.createRange();
+ const span = document.createElement('span');
+ span.textContent = `${window.location.origin}/superset/sqllab?savedQueryId=${id}`;
+ span.style.position = 'fixed';
+ span.style.top = '0';
+ span.style.clip = 'rect(0, 0, 0, 0)';
+ span.style.whiteSpace = 'pre';
+
+ document.body.appendChild(span);
+ range.selectNode(span);
+ selection.addRange(range);
+
+ try {
+ if (!document.execCommand('copy')) {
+ throw new Error(t('Not successful'));
+ }
+ } catch (err) {
+ addDangerToast(t('Sorry, your browser does not support copying.'));
+ }
+
+ document.body.removeChild(span);
+ if (selection.removeRange) {
+ selection.removeRange(range);
+ } else {
+ selection.removeAllRanges();
+ }
+ addSuccessToast(t('Link Copied!'));
+ }
+};
diff --git a/superset-frontend/src/views/CRUD/types.ts b/superset-frontend/src/views/CRUD/types.ts
index 91d88a3..0946247 100644
--- a/superset-frontend/src/views/CRUD/types.ts
+++ b/superset-frontend/src/views/CRUD/types.ts
@@ -16,7 +16,56 @@
* specific language governing permissions and limitations
* under the License.
*/
+import { User } from 'src/types/bootstrapTypes';
+import Owner from 'src/types/Owner';
export type FavoriteStatus = {
[id: number]: boolean;
};
+
+export interface DashboardTableProps {
+ addDangerToast: (message: string) => void;
+ addSuccessToast: (message: string) => void;
+ search: string;
+ user?: User;
+}
+
+export interface Dashboard {
+ changed_by_name: string;
+ changed_by_url: string;
+ changed_on_delta_humanized: string;
+ changed_by: string;
+ dashboard_title: string;
+ slice_name?: string;
+ id: number;
+ published: boolean;
+ url: string;
+ thumbnail_url: string;
+ owners: Owner[];
+ loading?: boolean;
+}
+
+export interface DashboardCardProps {
+ isChart?: boolean;
+ dashboard: Dashboard;
+ hasPerm: (name: string) => boolean;
+ bulkSelectEnabled: boolean;
+ refreshData: () => void;
+ addDangerToast: (msg: string) => void;
+ addSuccessToast: (msg: string) => void;
+ openDashboardEditModal?: (d: Dashboard) => void;
+}
+
+export type SavedQueryObject = {
+ database: {
+ database_name: string;
+ id: number;
+ };
+ db_id: number;
+ description?: string;
+ id: number;
+ label: string;
+ schema: string;
+ sql: string;
+ sql_tables: Array<{ catalog?: string; schema: string; table: string }>;
+};
diff --git a/superset-frontend/src/views/CRUD/utils.tsx b/superset-frontend/src/views/CRUD/utils.tsx
index 213481d..ee7cbeb 100644
--- a/superset-frontend/src/views/CRUD/utils.tsx
+++ b/superset-frontend/src/views/CRUD/utils.tsx
@@ -17,12 +17,16 @@
* under the License.
*/
import {
+ t,
SupersetClient,
SupersetClientResponse,
logging,
+ styled,
} from '@superset-ui/core';
+import Chart from 'src/types/Chart';
import rison from 'rison';
import getClientErrorObject from 'src/utils/getClientErrorObject';
+import { Dashboard } from './types';
const createFetchResourceMethod = (method: string) => (
resource: string,
@@ -53,6 +57,102 @@ const createFetchResourceMethod = (method: string) => (
return [];
};
+export const getRecentAcitivtyObjs = (
+ userId: string | number,
+ recent: string,
+ addDangerToast: (arg0: string, arg1: string) => void,
+) => {
+ const getParams = (filters?: Array<any>) => {
+ const params = {
+ order_column: 'changed_on_delta_humanized',
+ order_direction: 'desc',
+ page: 0,
+ page_size: 3,
+ filters,
+ };
+ if (!filters) delete params.filters;
+ return rison.encode(params);
+ };
+ const filters = {
+ // chart and dashbaord uses same filters
+ // for edited and created
+ edited: [
+ {
+ col: 'changed_by',
+ opr: 'rel_o_m',
+ value: `${userId}`,
+ },
+ ],
+ created: [
+ {
+ col: 'created_by',
+ opr: 'rel_o_m',
+ value: `${userId}`,
+ },
+ ],
+ };
+ const baseBatch = [
+ SupersetClient.get({ endpoint: recent }),
+ SupersetClient.get({
+ endpoint: `/api/v1/dashboard/?q=${getParams(filters.edited)}`,
+ }),
+ SupersetClient.get({
+ endpoint: `/api/v1/chart/?q=${getParams(filters.edited)}`,
+ }),
+ SupersetClient.get({
+ endpoint: `/api/v1/dashboard/?q=${getParams(filters.created)}`,
+ }),
+ SupersetClient.get({
+ endpoint: `/api/v1/chart/?q=${getParams(filters.created)}`,
+ }),
+ SupersetClient.get({
+ endpoint: `/api/v1/saved_query/?q=${getParams(filters.created)}`,
+ }),
+ ];
+ return Promise.all(baseBatch).then(
+ ([
+ recentsRes,
+ editedDash,
+ editedChart,
+ createdByDash,
+ createdByChart,
+ createdByQuery,
+ ]) => {
+ const res: any = {
+ editedDash: editedDash.json?.result.slice(0, 3),
+ editedChart: editedChart.json?.result.slice(0, 3),
+ createdByDash: createdByDash.json?.result.slice(0, 3),
+ createdByChart: createdByChart.json?.result.slice(0, 3),
+ createdByQuery: createdByQuery.json?.result.slice(0, 3),
+ };
+ if (recentsRes.json.length === 0) {
+ const newBatch = [
+ SupersetClient.get({ endpoint: `/api/v1/chart/?q=${getParams()}` }),
+ SupersetClient.get({
+ endpoint: `/api/v1/dashboard/?q=${getParams()}`,
+ }),
+ ];
+ return Promise.all(newBatch)
+ .then(([chartRes, dashboardRes]) => {
+ res.examples = [
+ ...chartRes.json.result,
+ ...dashboardRes.json.result,
+ ];
+ return res;
+ })
+ .catch(e =>
+ addDangerToast(
+ 'There was an error fetching you recent activity:',
+ e,
+ ),
+ );
+ }
+ res.viewed = recentsRes.json;
+ return res;
+ },
+ );
+};
+
export const createFetchRelated = createFetchResourceMethod('related');
export const createFetchDistinct = createFetchResourceMethod('distinct');
@@ -63,3 +163,100 @@ export function createErrorHandler(handleErrorFunc: (errMsg?: string) => void) {
handleErrorFunc(parsedError.message || parsedError.error);
};
}
+
+export function handleChartDelete(
+ { id, slice_name: sliceName }: Chart,
+ addSuccessToast: (arg0: string) => void,
+ addDangerToast: (arg0: string) => void,
+ refreshData: () => void,
+) {
+ SupersetClient.delete({
+ endpoint: `/api/v1/chart/${id}`,
+ }).then(
+ () => {
+ refreshData();
+ addSuccessToast(t('Deleted: %s', sliceName));
+ },
+ () => {
+ addDangerToast(t('There was an issue deleting: %s', sliceName));
+ },
+ );
+}
+
+export function handleBulkDashboardExport(dashboardsToExport: Dashboard[]) {
+ return window.location.assign(
+ `/api/v1/dashboard/export/?q=${rison.encode(
+ dashboardsToExport.map(({ id }) => id),
+ )}`,
+ );
+}
+
+export function handleDashboardDelete(
+ { id, dashboard_title: dashboardTitle }: Dashboard,
+ refreshData: () => void,
+ addSuccessToast: (arg0: string) => void,
+ addDangerToast: (arg0: string) => void,
+) {
+ return SupersetClient.delete({
+ endpoint: `/api/v1/dashboard/${id}`,
+ }).then(
+ () => {
+ refreshData();
+ addSuccessToast(t('Deleted: %s', dashboardTitle));
+ },
+ createErrorHandler(errMsg =>
+ addDangerToast(
+ t('There was an issue deleting %s: %s', dashboardTitle, errMsg),
+ ),
+ ),
+ );
+}
+
+export function createChartDeleteFunction(
+ { id, slice_name: sliceName }: Chart,
+ addSuccessToast: (arg0: string) => void,
+ addDangerToast: (arg0: string) => void,
+ refreshData: () => void,
+) {
+ SupersetClient.delete({
+ endpoint: `/api/v1/chart/${id}`,
+ }).then(
+ () => {
+ refreshData();
+ addSuccessToast(t('Deleted: %s', sliceName));
+ },
+ () => {
+ addDangerToast(t('There was an issue deleting: %s', sliceName));
+ },
+ );
+}
+
+const breakpoints = [576, 768, 992, 1200];
+export const mq = breakpoints.map(bp => `@media (max-width: ${bp}px)`);
+
+export const CardContainer = styled.div`
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(31%, max-content));
+ ${[mq[3]]} {
+ grid-template-columns: repeat(auto-fit, minmax(31%, max-content));
+ }
+
+ ${[mq[2]]} {
+ grid-template-columns: repeat(auto-fit, minmax(48%, max-content));
+ }
+
+ ${[mq[1]]} {
+ grid-template-columns: repeat(auto-fit, minmax(50%, max-content));
+ }
+ grid-gap: ${({ theme }) => theme.gridUnit * 8}px;
+ justify-content: left;
+ padding: ${({ theme }) => theme.gridUnit * 2}px
+ ${({ theme }) => theme.gridUnit * 6}px;
+`;
+
+export const IconContainer = styled.div`
+ svg {
+ vertical-align: -7px;
+ color: ${({ theme }) => theme.colors.primary.dark1};
+ }
+`;
diff --git a/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx b/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx
new file mode 100644
index 0000000..dc44347
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx
@@ -0,0 +1,209 @@
+/**
+ * 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, { useEffect, useState } from 'react';
+import moment from 'antd/node_modules/moment';
+import { styled, t } from '@superset-ui/core';
+
+import ListViewCard from 'src/components/ListViewCard';
+import { addDangerToast } from 'src/messageToasts/actions';
+import SubMenu from 'src/components/Menu/SubMenu';
+import { reject } from 'lodash';
+import { getRecentAcitivtyObjs, mq } from '../utils';
+import EmptyState from './EmptyState';
+
+interface ActivityObjects {
+ action?: string;
+ item_title?: string;
+ slice_name: string;
+ time: string;
+ changed_on_utc: string;
+ url: string;
+ sql: string;
+ dashboard_title: string;
+ label: string;
+ id: string;
+ table: object;
+ item_url: string;
+}
+
+interface ActivityProps {
+ user: {
+ userId: string | number;
+ };
+}
+
+interface ActivityData {
+ Created?: Array<object>;
+ Edited?: Array<object>;
+ Viewed?: Array<object>;
+ Examples?: Array<object>;
+}
+
+const ActivityContainer = styled.div`
+ margin-left: ${({ theme }) => theme.gridUnit * 2}px;
+ margin-top: ${({ theme }) => theme.gridUnit * -4}px;
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(31%, max-content));
+ ${[mq[3]]} {
+ grid-template-columns: repeat(auto-fit, minmax(31%, max-content));
+ }
+ ${[mq[2]]} {
+ grid-template-columns: repeat(auto-fit, minmax(42%, max-content));
+ }
+ ${[mq[1]]} {
+ grid-template-columns: repeat(auto-fit, minmax(63%, max-content));
+ }
+ grid-gap: ${({ theme }) => theme.gridUnit * 8}px;
+ justify-content: left;
+ padding: ${({ theme }) => theme.gridUnit * 2}px
+ ${({ theme }) => theme.gridUnit * 4}px;
+ .ant-card-meta-avatar {
+ margin-top: ${({ theme }) => theme.gridUnit * 3}px;
+ margin-left: ${({ theme }) => theme.gridUnit * 2}px;
+ }
+ .ant-card-meta-title {
+ font-weight: ${({ theme }) => theme.typography.weights.bold};
+ }
+`;
+
+export default function ActivityTable({ user }: ActivityProps) {
+ const [activityData, setActivityData] = useState<ActivityData>({});
+ const [loading, setLoading] = useState(true);
+ const [activeChild, setActiveChild] = useState('Viewed');
+ // this api uses log for data which in some cases can be empty
+ const recent = `/superset/recent_activity/${user.userId}/?limit=5`;
+
+ const getFilterTitle = (e: ActivityObjects) => {
+ if (e.dashboard_title) return e.dashboard_title;
+ if (e.label) return e.label;
+ if (e.url && !e.table) return e.item_title;
+ if (e.item_title) return e.item_title;
+ return e.slice_name;
+ };
+
+ const getIconName = (e: ActivityObjects) => {
+ if (e.sql) return 'sql';
+ if (e.url?.includes('dashboard')) {
+ return 'nav-dashboard';
+ }
+ if (e.url?.includes('explore') || e.item_url?.includes('explore')) {
+ return 'nav-charts';
+ }
+ return '';
+ };
+
+ const tabs = [
+ {
+ name: 'Edited',
+ label: t('Edited'),
+ onClick: () => {
+ setActiveChild('Edited');
+ },
+ },
+ {
+ name: 'Created',
+ label: t('Created'),
+ onClick: () => {
+ setActiveChild('Created');
+ },
+ },
+ ];
+
+ if (activityData.Viewed) {
+ tabs.unshift({
+ name: 'Viewed',
+ label: t('Viewed'),
+ onClick: () => {
+ setActiveChild('Viewed');
+ },
+ });
+ } else {
+ tabs.unshift({
+ name: 'Examples',
+ label: t('Examples'),
+ onClick: () => {
+ setActiveChild('Examples');
+ },
+ });
+ }
+
+ useEffect(() => {
+ getRecentAcitivtyObjs(user.userId, recent, addDangerToast)
+ .then(res => {
+ const data: any = {
+ Created: [
+ ...res.createdByChart,
+ ...res.createdByDash,
+ ...res.createdByQuery,
+ ],
+ Edited: [...res.editedChart, ...res.editedDash],
+ };
+ if (res.viewed) {
+ const filtered = reject(res.viewed, ['item_url', null]).map(r => r);
+ data.Viewed = filtered;
+ setActiveChild('Viewed');
+ } else {
+ data.Examples = res.examples;
+ setActiveChild('Examples');
+ }
+ setActivityData(data);
+ setLoading(false);
+ })
+ .catch(e => {
+ setLoading(false);
+ addDangerToast(
+ `There was an issue fetching your recent Acitivity: ${e}`,
+ );
+ });
+ }, []);
+
+ const renderActivity = () => {
+ return activityData[activeChild].map((e: ActivityObjects) => (
+ <ListViewCard
+ key={`${e.id}`}
+ isRecent
+ loading={loading}
+ url={e.sql ? `/supserset/sqllab?queryId=${e.id}` : e.url}
+ title={getFilterTitle(e)}
+ description={`Last Edited: ${moment(e.changed_on_utc).format(
+ 'MM/DD/YYYY HH:mm:ss',
+ )}`}
+ avatar={getIconName(e)}
+ actions={null}
+ />
+ ));
+ };
+ if (loading) return <>loading ...</>;
+ return (
+ <>
+ <SubMenu
+ activeChild={activeChild}
+ // eslint-disable-next-line react/no-children-prop
+ tabs={tabs}
+ />
+ <>
+ {activityData[activeChild]?.length > 0 ? (
+ <ActivityContainer>{renderActivity()}</ActivityContainer>
+ ) : (
+ <EmptyState tableName="RECENTS" tab={activeChild} />
+ )}
+ </>
+ </>
+ );
+}
diff --git a/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx b/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx
new file mode 100644
index 0000000..9a12dfd
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx
@@ -0,0 +1,167 @@
+/**
+ * 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, { useEffect, useState } from 'react';
+import { t } from '@superset-ui/core';
+import { useListViewResource, useChartEditModal } from 'src/views/CRUD/hooks';
+import withToasts from 'src/messageToasts/enhancers/withToasts';
+import PropertiesModal from 'src/explore/components/PropertiesModal';
+import { User } from 'src/types/bootstrapTypes';
+import Icon from 'src/components/Icon';
+import ChartCard from 'src/views/CRUD/chart/ChartCard';
+import Chart from 'src/types/Chart';
+import SubMenu from 'src/components/Menu/SubMenu';
+import EmptyState from './EmptyState';
+import { CardContainer, IconContainer } from '../utils';
+
+const PAGE_SIZE = 3;
+
+interface ChartTableProps {
+ addDangerToast: (message: string) => void;
+ addSuccessToast: (message: string) => void;
+ search: string;
+ chartFilter?: string;
+ user?: User;
+}
+
+function ChartTable({
+ user,
+ addDangerToast,
+ addSuccessToast,
+}: ChartTableProps) {
+ const {
+ state: { loading, resourceCollection: charts, bulkSelectEnabled },
+ setResourceCollection: setCharts,
+ hasPerm,
+ refreshData,
+ fetchData,
+ } = useListViewResource<Chart>('chart', t('chart'), addDangerToast);
+ const {
+ sliceCurrentlyEditing,
+ openChartEditModal,
+ handleChartUpdated,
+ closeChartEditModal,
+ } = useChartEditModal(setCharts, charts);
+
+ const [chartFilter, setChartFilter] = useState('Mine');
+
+ const getFilters = () => {
+ const filters = [];
+
+ if (chartFilter === 'Mine') {
+ filters.push({
+ id: 'created_by',
+ operator: 'rel_o_m',
+ value: `${user?.userId}`,
+ });
+ } else {
+ filters.push({
+ id: 'id',
+ operator: 'chart_is_fav',
+ value: true,
+ });
+ }
+ return filters;
+ };
+
+ useEffect(() => {
+ fetchData({
+ pageIndex: 0,
+ pageSize: PAGE_SIZE,
+ sortBy: [
+ {
+ id: 'changed_on_delta_humanized',
+ desc: true,
+ },
+ ],
+ filters: getFilters(),
+ });
+ }, [chartFilter]);
+
+ return (
+ <>
+ {sliceCurrentlyEditing && (
+ <PropertiesModal
+ onHide={closeChartEditModal}
+ onSave={handleChartUpdated}
+ show
+ slice={sliceCurrentlyEditing}
+ />
+ )}
+
+ <SubMenu
+ activeChild={chartFilter}
+ // eslint-disable-next-line react/no-children-prop
+ tabs={[
+ {
+ name: 'Favorite',
+ label: t('Favorite'),
+ onClick: () => setChartFilter('Favorite'),
+ },
+ {
+ name: 'Mine',
+ label: t('Mine'),
+ onClick: () => setChartFilter('Mine'),
+ },
+ ]}
+ buttons={[
+ {
+ name: (
+ <IconContainer>
+ <Icon name="plus-small" />
+ {t('Chart')}
+ </IconContainer>
+ ),
+ buttonStyle: 'tertiary',
+ onClick: () => {
+ window.location.href = '/chart/add';
+ },
+ },
+ {
+ name: 'View All »',
+ buttonStyle: 'link',
+ onClick: () => {
+ window.location.href = '/chart/list';
+ },
+ },
+ ]}
+ />
+ {charts?.length ? (
+ <CardContainer>
+ {charts.map(e => (
+ <ChartCard
+ key={`${e.id}`}
+ openChartEditModal={openChartEditModal}
+ loading={loading}
+ chart={e}
+ hasPerm={hasPerm}
+ bulkSelectEnabled={bulkSelectEnabled}
+ refreshData={refreshData}
+ addDangerToast={addDangerToast}
+ addSuccessToast={addSuccessToast}
+ />
+ ))}
+ </CardContainer>
+ ) : (
+ <EmptyState tableName="CHARTS" tab={chartFilter} />
+ )}
+ </>
+ );
+}
+
+export default withToasts(ChartTable);
diff --git a/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx b/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx
index b3990f4..67536cd 100644
--- a/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx
+++ b/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx
@@ -16,169 +16,176 @@
* specific language governing permissions and limitations
* under the License.
*/
-import React from 'react';
-import { t, SupersetClient } from '@superset-ui/core';
-import { debounce } from 'lodash';
-import ListView, { FetchDataConfig } from 'src/components/ListView';
+import React, { useEffect, useState } from 'react';
+import { SupersetClient, t } from '@superset-ui/core';
+import { useListViewResource } from 'src/views/CRUD/hooks';
+import { Dashboard, DashboardTableProps } from 'src/views/CRUD/types';
import withToasts from 'src/messageToasts/enhancers/withToasts';
-import { Dashboard } from 'src/types/bootstrapTypes';
+import PropertiesModal from 'src/dashboard/components/PropertiesModal';
+import DashboardCard from 'src/views/CRUD/dashboard/DashboardCard';
+import SubMenu from 'src/components/Menu/SubMenu';
+import Icon from 'src/components/Icon';
+import EmptyState from './EmptyState';
+import { createErrorHandler, CardContainer, IconContainer } from '../utils';
-const PAGE_SIZE = 25;
+const PAGE_SIZE = 3;
-interface DashboardTableProps {
- addDangerToast: (message: string) => void;
- search?: string;
+export interface FilterValue {
+ col: string;
+ operator: string;
+ value: string | boolean | number | null | undefined;
}
-interface DashboardTableState {
- dashboards: Dashboard[];
- dashboard_count: number;
- loading: boolean;
-}
+function DashboardTable({
+ user,
+ addDangerToast,
+ addSuccessToast,
+}: DashboardTableProps) {
+ const {
+ state: { loading, resourceCollection: dashboards, bulkSelectEnabled },
+ setResourceCollection: setDashboards,
+ hasPerm,
+ refreshData,
+ fetchData,
+ } = useListViewResource<Dashboard>(
+ 'dashboard',
+ t('dashboard'),
+ addDangerToast,
+ );
-class DashboardTable extends React.PureComponent<
- DashboardTableProps,
- DashboardTableState
-> {
- columns = [
- {
- accessor: 'dashboard_title',
- Header: 'Dashboard',
- Cell: ({
- row: {
- original: { url, dashboard_title: dashboardTitle },
- },
- }: {
- row: {
- original: {
- url: string;
- dashboard_title: string;
- };
- };
- }) => <a href={url}>{dashboardTitle}</a>,
- },
- {
- accessor: 'changed_by.first_name',
- Header: 'Modified By',
- Cell: ({
- row: {
- original: { changed_by_name: changedByName, changedByUrl },
- },
- }: {
- row: {
- original: {
- changed_by_name: string;
- changedByUrl: string;
- };
- };
- }) => <a href={changedByUrl}>{changedByName}</a>,
- },
- {
- accessor: 'changed_on_delta_humanized',
- Header: 'Modified',
- Cell: ({
- row: {
- original: { changed_on_delta_humanized: changedOn },
- },
- }: {
- row: {
- original: {
- changed_on_delta_humanized: string;
- };
- };
- }) => <span className="no-wrap">{changedOn}</span>,
- },
- ];
+ const [editModal, setEditModal] = useState<Dashboard>();
+ const [dashboardFilter, setDashboardFilter] = useState('Mine');
- initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
+ const handleDashboardEdit = (edits: Dashboard) => {
+ return SupersetClient.get({
+ endpoint: `/api/v1/dashboard/${edits.id}`,
+ }).then(
+ ({ json = {} }) => {
+ setDashboards(
+ dashboards.map(dashboard => {
+ if (dashboard.id === json.id) {
+ return json.result;
+ }
+ return dashboard;
+ }),
+ );
+ },
+ createErrorHandler(errMsg =>
+ addDangerToast(
+ t('An error occurred while fetching dashboards: %s', errMsg),
+ ),
+ ),
+ );
+ };
- constructor(props: DashboardTableProps) {
- super(props);
- this.state = {
- dashboards: [],
- dashboard_count: 0,
- loading: false,
- };
- }
+ const getFilters = () => {
+ const filters = [];
- componentDidUpdate(prevProps: DashboardTableProps) {
- if (prevProps.search !== this.props.search) {
- this.fetchDataDebounced({
- pageSize: PAGE_SIZE,
- pageIndex: 0,
- sortBy: this.initialSort,
- filters: [],
+ if (dashboardFilter === 'Mine') {
+ filters.push({
+ id: 'owners',
+ operator: 'rel_m_m',
+ value: `${user?.userId}`,
+ });
+ } else {
+ filters.push({
+ id: 'id',
+ operator: 'dashboard_is_fav',
+ value: true,
});
}
+ return filters;
+ };
+ const subMenus = [];
+ if (dashboards.length > 0 && dashboardFilter === 'favorite') {
+ subMenus.push({
+ name: 'Favorite',
+ label: t('Favorite'),
+ onClick: () => setDashboardFilter('Favorite'),
+ });
}
- fetchData = ({ pageIndex, pageSize, sortBy, filters }: FetchDataConfig) => {
- this.setState({ loading: true });
- const filterExps = Object.keys(filters)
- .map(fk => ({
- col: fk,
- opr: filters[fk].filterId,
- value: filters[fk].filterValue,
- }))
- .concat(
- this.props.search
- ? [
- {
- col: 'dashboard_title',
- opr: 'ct',
- value: this.props.search,
- },
- ]
- : [],
- );
-
- const queryParams = JSON.stringify({
- order_column: sortBy[0].id,
- order_direction: sortBy[0].desc ? 'desc' : 'asc',
- page: pageIndex,
- page_size: pageSize,
- ...(filterExps.length ? { filters: filterExps } : {}),
+ useEffect(() => {
+ fetchData({
+ pageIndex: 0,
+ pageSize: PAGE_SIZE,
+ sortBy: [
+ {
+ id: 'changed_on_delta_humanized',
+ desc: true,
+ },
+ ],
+ filters: getFilters(),
});
+ }, [dashboardFilter]);
- return SupersetClient.get({
- endpoint: `/api/v1/dashboard/?q=${queryParams}`,
- })
- .then(({ json }) => {
- this.setState({ dashboards: json.result, dashboard_count: json.count });
- })
- .catch(response => {
- if (response.status === 401) {
- this.props.addDangerToast(
- t(
- "You don't have the necessary permissions to load dashboards. Please contact your administrator.",
+ return (
+ <>
+ <SubMenu
+ activeChild={dashboardFilter}
+ tabs={[
+ {
+ name: 'Favorite',
+ label: t('Favorite'),
+ onClick: () => setDashboardFilter('Favorite'),
+ },
+ {
+ name: 'Mine',
+ label: t('Mine'),
+ onClick: () => setDashboardFilter('Mine'),
+ },
+ ]}
+ buttons={[
+ {
+ name: (
+ <IconContainer>
+ <Icon name="plus-small" /> Dashboard{' '}
+ </IconContainer>
),
- );
- } else {
- this.props.addDangerToast(
- t('An error occurred while fetching Dashboards'),
- );
- }
- })
- .finally(() => this.setState({ loading: false }));
- };
-
- // sort-comp disabled because of conflict with no-use-before-define rule
- // eslint-disable-next-line react/sort-comp
- fetchDataDebounced = debounce(this.fetchData, 200);
-
- render() {
- return (
- <ListView
- columns={this.columns}
- data={this.state.dashboards}
- count={this.state.dashboard_count}
- pageSize={PAGE_SIZE}
- fetchData={this.fetchData}
- loading={this.state.loading}
- initialSort={this.initialSort}
+ buttonStyle: 'tertiary',
+ onClick: () => {
+ window.location.href = '/dashboard/new';
+ },
+ },
+ {
+ name: 'View All »',
+ buttonStyle: 'link',
+ onClick: () => {
+ window.location.href = '/dashboard/list/';
+ },
+ },
+ ]}
/>
- );
- }
+ {editModal && (
+ <PropertiesModal
+ dashboardId={editModal?.id}
+ show
+ onHide={() => setEditModal(undefined)}
+ onSubmit={handleDashboardEdit}
+ />
+ )}
+ {dashboards.length > 0 ? (
+ <CardContainer>
+ {dashboards.map(e => (
+ <DashboardCard
+ {...{
+ dashboard: e,
+ hasPerm,
+ bulkSelectEnabled,
+ refreshData,
+ addDangerToast,
+ addSuccessToast,
+ loading,
+ openDashboardEditModal: dashboard => setEditModal(dashboard),
+ }}
+ />
+ ))}
+ </CardContainer>
+ ) : (
+ <EmptyState tableName="DASHBOARDS" tab={dashboardFilter} />
+ )}
+ </>
+ );
}
export default withToasts(DashboardTable);
diff --git a/superset-frontend/src/views/CRUD/welcome/EmptyState.tsx b/superset-frontend/src/views/CRUD/welcome/EmptyState.tsx
new file mode 100644
index 0000000..f145dfe
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/welcome/EmptyState.tsx
@@ -0,0 +1,144 @@
+/**
+ * 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 Button from 'src/components/Button';
+import { Empty } from 'src/common/components';
+import { t, styled } from '@superset-ui/core';
+import Icon from 'src/components/Icon';
+import { IconContainer } from '../utils';
+
+interface EmptyStateProps {
+ tableName: string;
+ tab?: string;
+}
+
+const ButtonContainer = styled.div`
+ Button {
+ svg {
+ color: ${({ theme }) => theme.colors.grayscale.light5};
+ }
+ }
+`;
+
+export default function EmptyState({ tableName, tab }: EmptyStateProps) {
+ const mineRedirects = {
+ DASHBOARDS: '/dashboard/new',
+ CHARTS: '/chart/add',
+ SAVED_QUERIES: '/superset/sqllab',
+ };
+ const favRedirects = {
+ DASHBOARDS: '/dashboard/list/',
+ CHARTS: '/chart/list',
+ SAVED_QUERIES: '/savedqueryview/list/',
+ };
+ const tableIcon = {
+ RECENTS: 'union.png',
+ DASHBOARDS: 'empty-dashboard.png',
+ CHARTS: 'empty-charts.png',
+ SAVED_QUERIES: 'empty-queries.png',
+ };
+ const mine = (
+ <div>{`No ${
+ tableName === 'SAVED_QUERIES'
+ ? t('saved queries')
+ : t(`${tableName.toLowerCase()}`)
+ } yet`}</div>
+ );
+ const recent = (
+ <div className="no-recents">
+ {(() => {
+ if (tab === 'Viewed') {
+ return t(
+ `Recently viewed charts, dashboards, and saved queries will appear here`,
+ );
+ }
+ if (tab === 'Created') {
+ return t(
+ 'Recently created charts, dashboards, and saved queries will appear here',
+ );
+ }
+ if (tab === 'Examples') {
+ return t(
+ `Recent example charts, dashboards, and saved queries will appear here`,
+ );
+ }
+ if (tab === 'Edited') {
+ return t(
+ `Recently edited charts, dashboards, and saved queries will appear here`,
+ );
+ }
+ return null;
+ })()}
+ </div>
+ );
+ // Mine and Recent Activity(all tabs) tab empty state
+ if (tab === 'Mine' || tableName === 'RECENTS') {
+ return (
+ <Empty
+ image={`/static/assets/images/${tableIcon[tableName]}`}
+ description={tableName === 'RECENTS' ? recent : mine}
+ >
+ {tableName !== 'RECENTS' && (
+ <ButtonContainer>
+ <Button
+ buttonStyle="primary"
+ onClick={() => {
+ window.location = mineRedirects[tableName];
+ }}
+ >
+ <IconContainer>
+ <Icon name="plus-small" />{' '}
+ {tableName === 'SAVED_QUERIES'
+ ? t('SQL QUERY')
+ : t(`${tableName
+ .split('')
+ .slice(0, tableName.length - 1)
+ .join('')}
+ `)}
+ </IconContainer>
+ </Button>
+ </ButtonContainer>
+ )}
+ </Empty>
+ );
+ }
+ // Favorite tab empty state
+ return (
+ <Empty
+ image="/static/assets/images/star-circle.png"
+ description={
+ <div className="no-favorites">
+ {t("You don't have any favorites yet!")}
+ </div>
+ }
+ >
+ <Button
+ buttonStyle="primary"
+ onClick={() => {
+ window.location = favRedirects[tableName];
+ }}
+ >
+ SEE ALL{' '}
+ {tableName === 'SAVED_QUERIES'
+ ? t('SQL LAB QUERIES')
+ : t(`${tableName}`)}
+ </Button>
+ </Empty>
+ );
+}
diff --git a/superset-frontend/src/views/CRUD/welcome/SavedQueries.tsx b/superset-frontend/src/views/CRUD/welcome/SavedQueries.tsx
new file mode 100644
index 0000000..dc0590b
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/welcome/SavedQueries.tsx
@@ -0,0 +1,260 @@
+/**
+ * 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, { useEffect, useState } from 'react';
+import { t, SupersetClient, styled } from '@superset-ui/core';
+import withToasts from 'src/messageToasts/enhancers/withToasts';
+import { Dropdown, Menu } from 'src/common/components';
+import { useListViewResource, copyQueryLink } from 'src/views/CRUD/hooks';
+import ListViewCard from 'src/components/ListViewCard';
+import DeleteModal from 'src/components/DeleteModal';
+import Icon from 'src/components/Icon';
+import SubMenu from 'src/components/Menu/SubMenu';
+import EmptyState from './EmptyState';
+
+import { IconContainer, CardContainer, createErrorHandler } from '../utils';
+
+const PAGE_SIZE = 3;
+
+interface Query {
+ id?: number;
+ sql_tables?: Array<any>;
+ database?: {
+ database_name: string;
+ };
+ rows?: string;
+ description?: string;
+ end_time?: string;
+ label?: string;
+}
+
+interface SavedQueriesProps {
+ user: {
+ userId: string | number;
+ };
+ queryFilter: string;
+ addDangerToast: (arg0: string) => void;
+ addSuccessToast: (arg0: string) => void;
+}
+
+const QueryData = styled.div`
+ display: flex;
+ flex-direction: row;
+ justify-content: flex-start;
+ border-bottom: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
+ .title {
+ font-weight: ${({ theme }) => theme.typography.weights.normal};
+ color: ${({ theme }) => theme.colors.grayscale.light2};
+ }
+ .holder {
+ margin: ${({ theme }) => theme.gridUnit * 2}px;
+ }
+`;
+const SavedQueries = ({
+ user,
+ addDangerToast,
+ addSuccessToast,
+}: SavedQueriesProps) => {
+ const {
+ state: { loading, resourceCollection: queries },
+ hasPerm,
+ fetchData,
+ refreshData,
+ } = useListViewResource<Query>('saved_query', t('query'), addDangerToast);
+ const [queryFilter, setQueryFilter] = useState('Mine');
+ const [queryDeleteModal, setQueryDeleteModal] = useState(false);
+ const [currentlyEdited, setCurrentlyEdited] = useState<Query>({});
+
+ const canEdit = hasPerm('can_edit');
+ const canDelete = hasPerm('can_delete');
+
+ const handleQueryDelete = ({ id, label }: Query) => {
+ SupersetClient.delete({
+ endpoint: `/api/v1/saved_query/${id}`,
+ }).then(
+ () => {
+ refreshData();
+ setQueryDeleteModal(false);
+ addSuccessToast(t('Deleted: %s', label));
+ },
+ createErrorHandler(errMsg =>
+ addDangerToast(t('There was an issue deleting %s: %s', label, errMsg)),
+ ),
+ );
+ };
+
+ const getFilters = () => {
+ const filters = [];
+ if (queryFilter === 'Mine') {
+ filters.push({
+ id: 'created_by',
+ operator: 'rel_o_m',
+ value: `${user?.userId}`,
+ });
+ } else {
+ filters.push({
+ id: 'id',
+ operator: 'saved_query_is_fav',
+ value: true,
+ });
+ }
+ return filters;
+ };
+
+ useEffect(() => {
+ fetchData({
+ pageIndex: 0,
+ pageSize: PAGE_SIZE,
+ sortBy: [
+ {
+ id: 'changed_on_delta_humanized',
+ desc: true,
+ },
+ ],
+ filters: getFilters(),
+ });
+ }, [queryFilter]);
+
+ const renderMenu = (query: Query) => (
+ <Menu>
+ {canEdit && (
+ <Menu.Item
+ onClick={() => {
+ window.location.href = `/superset/sqllab?savedQueryId=${query.id}`;
+ }}
+ >
+ {t('Edit')}
+ </Menu.Item>
+ )}
+ <Menu.Item
+ onClick={() => {
+ if (query.id)
+ copyQueryLink(query.id, addDangerToast, addSuccessToast);
+ }}
+ >
+ {t('Share')}
+ </Menu.Item>
+ {canDelete && (
+ <Menu.Item
+ onClick={() => {
+ setQueryDeleteModal(true);
+ setCurrentlyEdited(query);
+ }}
+ >
+ {t('Delete')}
+ </Menu.Item>
+ )}
+ </Menu>
+ );
+ return (
+ <>
+ {queryDeleteModal && (
+ <DeleteModal
+ description={t(
+ 'This action will permanently delete the saved query.',
+ )}
+ onConfirm={() => {
+ if (queryDeleteModal) {
+ handleQueryDelete(currentlyEdited);
+ }
+ }}
+ onHide={() => {
+ setQueryDeleteModal(false);
+ }}
+ open
+ title={t('Delete Query?')}
+ />
+ )}
+ <SubMenu
+ activeChild={queryFilter}
+ tabs={[
+ {
+ name: 'Favorite',
+ label: t('Favorite'),
+ onClick: () => setQueryFilter('Favorite'),
+ },
+ {
+ name: 'Mine',
+ label: t('Mine'),
+ onClick: () => setQueryFilter('Mine'),
+ },
+ ]}
+ buttons={[
+ {
+ name: (
+ <IconContainer>
+ <Icon name="plus-small" /> SQL Query{' '}
+ </IconContainer>
+ ),
+ buttonStyle: 'tertiary',
+ onClick: () => {
+ window.location.href = '/superset/sqllab';
+ },
+ },
+ {
+ name: 'View All »',
+ buttonStyle: 'link',
+ onClick: () => {
+ window.location.href = '/savedqueryview/list';
+ },
+ },
+ ]}
+ />
+ {queries.length > 0 ? (
+ <CardContainer>
+ {queries.map(q => (
+ <ListViewCard
+ key={`${q.id}`}
+ imgFallbackURL=""
+ imgURL=""
+ url={`/superset/sqllab?savedQueryId=${q.id}`}
+ title={q.label}
+ rows={q.rows}
+ loading={loading}
+ description={t('Last run ', q.end_time)}
+ showImg={false}
+ renderCover={
+ <QueryData>
+ <div className="holder">
+ <div className="title">{t('Tables')}</div>
+ <div>{q?.sql_tables?.length}</div>
+ </div>
+ <div className="holder">
+ <div className="title">{t('Datasource Name')}</div>
+ <div>{q?.sql_tables && q.sql_tables[0]?.table}</div>
+ </div>
+ </QueryData>
+ }
+ actions={
+ <ListViewCard.Actions>
+ <Dropdown overlay={renderMenu(q)}>
+ <Icon name="more-horiz" />
+ </Dropdown>
+ </ListViewCard.Actions>
+ }
+ />
+ ))}
+ </CardContainer>
+ ) : (
+ <EmptyState tableName="SAVED_QUERIES" tab={queryFilter} />
+ )}
+ </>
+ );
+};
+
+export default withToasts(SavedQueries);
diff --git a/superset-frontend/src/views/CRUD/welcome/Welcome.tsx b/superset-frontend/src/views/CRUD/welcome/Welcome.tsx
index 256fe3a..f35effc 100644
--- a/superset-frontend/src/views/CRUD/welcome/Welcome.tsx
+++ b/superset-frontend/src/views/CRUD/welcome/Welcome.tsx
@@ -16,128 +16,79 @@
* specific language governing permissions and limitations
* under the License.
*/
-import React, { useCallback, useState } from 'react';
-import {
- Panel,
- Row,
- Col,
- Tabs,
- Tab,
- FormControl,
- FormControlProps,
-} from 'react-bootstrap';
-import { t } from '@superset-ui/core';
-import { useQueryParam, StringParam, QueryParamConfig } from 'use-query-params';
+import React from 'react';
+import { styled, t } from '@superset-ui/core';
+import { Collapse } from 'src/common/components';
import { User } from 'src/types/bootstrapTypes';
-import RecentActivity from 'src/profile/components/RecentActivity';
-import Favorites from 'src/profile/components/Favorites';
+import { mq } from '../utils';
+import ActivityTable from './ActivityTable';
+import ChartTable from './ChartTable';
+import SavedQueries from './SavedQueries';
import DashboardTable from './DashboardTable';
+const { Panel } = Collapse;
+
interface WelcomeProps {
user: User;
}
-function useSyncQueryState(
- queryParam: string,
- queryParamType: QueryParamConfig<
- string | null | undefined,
- string | undefined
- >,
- defaultState: string,
-): [string, (val: string) => void] {
- const [queryState, setQueryState] = useQueryParam(queryParam, queryParamType);
- const [state, setState] = useState(queryState || defaultState);
-
- const setQueryStateAndState = (val: string) => {
- setQueryState(val);
- setState(val);
- };
-
- return [state, setQueryStateAndState];
-}
+const WelcomeContainer = styled.div`
+ background-color: ${({ theme }) => theme.colors.grayscale.light4};
+ nav {
+ margin-top: -15px;
+ background-color: ${({ theme }) => theme.colors.grayscale.light4};
+ &:after {
+ content: '';
+ display: block;
+ border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
+ margin: 0px ${({ theme }) => theme.gridUnit * 6}px;
+ position: relative;
+ ${[mq[1]]} {
+ margin-top: 5px;
+ margin: 0px 2px;
+ }
+ }
+ .nav.navbar-nav {
+ & > li:nth-child(1),
+ & > li:nth-child(2),
+ & > li:nth-child(3) {
+ margin-top: ${({ theme }) => theme.gridUnit * 2}px;
+ }
+ }
+ button {
+ padding: 3px 21px;
+ }
+ .navbar-right {
+ position: relative;
+ top: 11px;
+ }
+ }
+ .ant-card.ant-card-bordered {
+ border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
+ }
+ .ant-collapse-header {
+ font-weight: ${({ theme }) => theme.typography.weights.normal};
+ font-size: ${({ theme }) => theme.gridUnit * 4}px;
+ }
+`;
export default function Welcome({ user }: WelcomeProps) {
- const [activeTab, setActiveTab] = useSyncQueryState(
- 'activeTab',
- StringParam,
- 'all',
- );
-
- const [searchQuery, setSearchQuery] = useSyncQueryState(
- 'search',
- StringParam,
- '',
- );
-
- const onFormControlChange = useCallback(
- (e: React.FormEvent<FormControl & FormControlProps>) => {
- const { value } = e.currentTarget;
- setSearchQuery((value as string) ?? '');
- },
- [],
- );
-
- const onTabsSelect = useCallback((e: any) => {
- setActiveTab(e as string);
- }, []);
-
return (
- <div className="container welcome">
- <Tabs
- activeKey={activeTab}
- onSelect={onTabsSelect}
- id="uncontrolled-tab-example"
- >
- <Tab eventKey="all" title={t('Dashboards')}>
- <Panel>
- <Panel.Body>
- <Row>
- <Col md={8}>
- <h2>{t('Dashboards')}</h2>
- </Col>
- <Col md={4}>
- <FormControl
- type="text"
- bsSize="sm"
- style={{ marginTop: '25px' }}
- placeholder="Search"
- value={searchQuery}
- onChange={onFormControlChange}
- />
- </Col>
- </Row>
- <hr />
- <DashboardTable search={searchQuery} />
- </Panel.Body>
- </Panel>
- </Tab>
- <Tab eventKey="recent" title={t('Recently Viewed')}>
- <Panel>
- <Panel.Body>
- <Row>
- <Col md={8}>
- <h2>{t('Recently Viewed')}</h2>
- </Col>
- </Row>
- <hr />
- <RecentActivity user={user} />
- </Panel.Body>
- </Panel>
- </Tab>
- <Tab eventKey="favorites" title={t('Favorites')}>
- <Panel>
- <Panel.Body>
- <Row>
- <Col md={8}>
- <h2>{t('Favorites')}</h2>
- </Col>
- </Row>
- <hr />
- <Favorites user={user} />
- </Panel.Body>
- </Panel>
- </Tab>
- </Tabs>
- </div>
+ <WelcomeContainer>
+ <Collapse defaultActiveKey={['1', '2', '3', '4']} ghost>
+ <Panel header={t('Recents')} key="1">
+ <ActivityTable user={user} />
+ </Panel>
+ <Panel header={t('Dashboards')} key="2">
+ <DashboardTable user={user} />
+ </Panel>
+ <Panel header={t('Saved Queries')} key="3">
+ <SavedQueries user={user} />
+ </Panel>
+ <Panel header={t('Charts')} key="4">
+ <ChartTable user={user} />
+ </Panel>
+ </Collapse>
+ </WelcomeContainer>
);
}
diff --git a/superset/charts/api.py b/superset/charts/api.py
index 3ef27d0..6ebe3cb 100644
--- a/superset/charts/api.py
+++ b/superset/charts/api.py
@@ -144,6 +144,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
]
search_columns = [
"created_by",
+ "changed_by",
"datasource_id",
"datasource_name",
"datasource_type",
diff --git a/superset/dashboards/api.py b/superset/dashboards/api.py
index 8f65f35..cba494a 100644
--- a/superset/dashboards/api.py
+++ b/superset/dashboards/api.py
@@ -157,6 +157,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
"owners",
"published",
"slug",
+ "changed_by",
)
search_filters = {
"dashboard_title": [DashboardTitleOrSlugFilter],
diff --git a/superset/queries/api.py b/superset/queries/api.py
index 0f368d3..ce4fca5 100644
--- a/superset/queries/api.py
+++ b/superset/queries/api.py
@@ -42,6 +42,9 @@ class QueryRestApi(BaseSupersetModelRestApi):
"status",
"start_time",
"end_time",
+ "rows",
+ "tmp_table_name",
+ "tracking_url",
]
show_columns = [
"client_id",
diff --git a/superset/queries/saved_queries/api.py b/superset/queries/saved_queries/api.py
index 794ab25..b37fc7d 100644
--- a/superset/queries/saved_queries/api.py
+++ b/superset/queries/saved_queries/api.py
@@ -106,7 +106,7 @@ class SavedQueryRestApi(BaseSupersetModelRestApi):
"last_run_delta_humanized",
]
- search_columns = ["id", "database", "label", "schema"]
+ search_columns = ["id", "database", "label", "schema", "created_by"]
search_filters = {
"id": [SavedQueryFavoriteFilter],
"label": [SavedQueryAllTextFilter],