diff --git a/superset-frontend/images/empty-charts.png b/superset-frontend/images/empty-charts.png new file mode 100644 index 000000000000..b814d3574961 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 000000000000..b0d44626d759 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 000000000000..adc51c0814b8 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 000000000000..77fd94d0fe1c 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 000000000000..af94c0793e91 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 54a1b6388eee..ab0020d3c7d1 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 38723587ff65..3abedc37c35d 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 000000000000..eba5d66862e7 --- /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(, { + 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 000000000000..f8cd0531eba4 --- /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(, { + 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(, { + 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 e09b5fea0ff3..e7f28f3f9cd1 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(, { +describe('DashboardTable', () => { + const dashboardProps = { + dashboardFilter: 'Favorite', + user: { + userId: '2', + }, + }; + const wrapper = mount(, { 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(, { + 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 000000000000..96ec1ec51472 --- /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(); + 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(); + 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 000000000000..2670482d7b9f --- /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(, { + 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 bf23ef11afac..4cd051c947ed 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()).toBe(true); + const wrapper = shallow(, { + context: { store }, }); - it('renders 3 Tab, Panel, and Row components', () => { - const wrapper = shallow(); - 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 4a0cf53ab50f..8849b4b001d4 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 ( - -
- -
-
- - {!loading && coverLeft && ( - {coverLeft} - )} - {!loading && coverRight && ( - {coverRight} - )} - - + !isRecent + ? renderCover || ( + + +
+ +
+
+ + {!loading && coverLeft && ( + {coverLeft} + )} + {!loading && coverRight && ( + {coverRight} + )} + +
+ ) + : null } > {loading && ( @@ -230,6 +242,8 @@ function ListViewCard({ } description={description} + // @ts-ignore + avatar={avatar ? : null} /> )}
diff --git a/superset-frontend/src/components/Menu/SubMenu.tsx b/superset-frontend/src/components/Menu/SubMenu.tsx index e17ce7a2a6ac..bf626f1c52ac 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; - name: string; - children?: MenuChild[]; + name?: string; + tabs?: MenuChild[]; activeChild?: MenuChild['name']; /* If usesRouter is true, a react-router component will be used instead of href. * ONLY set usesRouter to true if SubMenu is wrapped in a react-router ; @@ -108,16 +122,16 @@ const SubMenu: React.FunctionComponent = props => { {props.name}