diff --git a/superset-frontend/spec/javascripts/views/CRUD/welcome/ActivityTable_spec.tsx b/superset-frontend/spec/javascripts/views/CRUD/welcome/ActivityTable_spec.tsx index f15ddd337ae61..b011eac45a3ee 100644 --- a/superset-frontend/spec/javascripts/views/CRUD/welcome/ActivityTable_spec.tsx +++ b/superset-frontend/spec/javascripts/views/CRUD/welcome/ActivityTable_spec.tsx @@ -18,6 +18,10 @@ */ import React from 'react'; import { styledMount as mount } from 'spec/helpers/theming'; +import { act } from 'react-dom/test-utils'; +import { ReactWrapper } from 'enzyme'; +import { Provider } from 'react-redux'; +import fetchMock from 'fetch-mock'; import thunk from 'redux-thunk'; import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint'; import configureStore from 'redux-mock-store'; @@ -26,6 +30,9 @@ import ActivityTable from 'src/views/CRUD/welcome/ActivityTable'; const mockStore = configureStore([thunk]); const store = mockStore({}); +const chartsEndpoint = 'glob:*/api/v1/chart/?*'; +const dashboardsEndpoint = 'glob:*/api/v1/dashboard/?*'; + const mockData = { Viewed: [ { @@ -36,7 +43,7 @@ const mockData = { table: {}, }, ], - Edited: [ + Created: [ { dashboard_title: 'Dashboard_Test', changed_on_utc: '24 Feb 2014 10:13:14', @@ -44,7 +51,22 @@ const mockData = { id: '3', }, ], - Created: [ +}; + +fetchMock.get(chartsEndpoint, { + result: [ + { + slice_name: 'ChartyChart', + changed_on_utc: '24 Feb 2014 10:13:14', + url: '/fakeUrl/explore', + id: '4', + table: {}, + }, + ], +}); + +fetchMock.get(dashboardsEndpoint, { + result: [ { dashboard_title: 'Dashboard_Test', changed_on_utc: '24 Feb 2014 10:13:14', @@ -52,22 +74,27 @@ const mockData = { id: '3', }, ], -}; +}); describe('ActivityTable', () => { const activityProps = { - activeChild: 'Edited', + activeChild: 'Created', activityData: mockData, setActiveChild: jest.fn(), user: { userId: '1' }, loading: false, }; - const wrapper = mount(, { - context: { store }, - }); + + let wrapper: ReactWrapper; beforeAll(async () => { - await waitForComponentToPaint(wrapper); + await act(async () => { + wrapper = mount( + + + , + ); + }); }); it('the component renders', () => { @@ -79,4 +106,32 @@ describe('ActivityTable', () => { it('renders ActivityCards', async () => { expect(wrapper.find('ListViewCard')).toExist(); }); + it('calls the getEdited batch call when edited tab is clicked', async () => { + act(() => { + const handler = wrapper.find('li.no-router a').at(1).prop('onClick'); + if (handler) { + handler({} as any); + } + }); + await waitForComponentToPaint(wrapper); + const dashboardCall = fetchMock.calls(/dashboard\/\?q/); + const chartCall = fetchMock.calls(/chart\/\?q/); + expect(chartCall).toHaveLength(1); + expect(dashboardCall).toHaveLength(1); + }); + it('show empty state if there is data', () => { + const activityProps = { + activeChild: 'Created', + activityData: {}, + setActiveChild: jest.fn(), + user: { userId: '1' }, + loading: false, + }; + const wrapper = mount( + + + , + ); + expect(wrapper.find('EmptyState')).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 53ba3575ad858..a58df6bcf51b5 100644 --- a/superset-frontend/spec/javascripts/views/CRUD/welcome/Welcome_spec.tsx +++ b/superset-frontend/spec/javascripts/views/CRUD/welcome/Welcome_spec.tsx @@ -67,7 +67,10 @@ fetchMock.get(savedQueryEndpoint, { result: [], }); -fetchMock.get(recentActivityEndpoint, {}); +fetchMock.get(recentActivityEndpoint, { + Created: [], + Viewed: [], +}); fetchMock.get(chartInfoEndpoint, { permissions: [], @@ -122,10 +125,14 @@ describe('Welcome', () => { expect(wrapper.find('CollapsePanel')).toHaveLength(8); }); - it('calls batch method on page load', () => { + it('calls api methods in parallel on page load', () => { const chartCall = fetchMock.calls(/chart\/\?q/); + const savedQueryCall = fetchMock.calls(/saved_query\/\?q/); + const recentCall = fetchMock.calls(/superset\/recent_activity\/*/); const dashboardCall = fetchMock.calls(/dashboard\/\?q/); - expect(chartCall).toHaveLength(2); - expect(dashboardCall).toHaveLength(2); + expect(chartCall).toHaveLength(1); + expect(recentCall).toHaveLength(1); + expect(savedQueryCall).toHaveLength(1); + expect(dashboardCall).toHaveLength(1); }); }); diff --git a/superset-frontend/src/views/CRUD/hooks.ts b/superset-frontend/src/views/CRUD/hooks.ts index 1545c82406f94..fb16713c6d1c7 100644 --- a/superset-frontend/src/views/CRUD/hooks.ts +++ b/superset-frontend/src/views/CRUD/hooks.ts @@ -56,11 +56,12 @@ export function useListViewResource( infoEnable = true, defaultCollectionValue: D[] = [], baseFilters?: FilterValue[], // must be memoized + initialLoadingState = true, ) { const [state, setState] = useState>({ count: 0, collection: defaultCollectionValue, - loading: true, + loading: initialLoadingState, lastFetchDataConfig: null, permissions: [], bulkSelectEnabled: false, diff --git a/superset-frontend/src/views/CRUD/types.ts b/superset-frontend/src/views/CRUD/types.ts index b9908c756b480..4de3055c1dc65 100644 --- a/superset-frontend/src/views/CRUD/types.ts +++ b/superset-frontend/src/views/CRUD/types.ts @@ -23,6 +23,12 @@ export type FavoriteStatus = { [id: number]: boolean; }; +export type Filters = { + col: string; + opr: string; + value: string; +}; + export interface DashboardTableProps { addDangerToast: (message: string) => void; addSuccessToast: (message: string) => void; diff --git a/superset-frontend/src/views/CRUD/utils.tsx b/superset-frontend/src/views/CRUD/utils.tsx index c60c1413f1709..e70416149f70c 100644 --- a/superset-frontend/src/views/CRUD/utils.tsx +++ b/superset-frontend/src/views/CRUD/utils.tsx @@ -27,7 +27,7 @@ import Chart from 'src/types/Chart'; import rison from 'rison'; import { getClientErrorObject } from 'src/utils/getClientErrorObject'; import { FetchDataConfig } from 'src/components/ListView'; -import { Dashboard } from './types'; +import { Dashboard, Filters } from './types'; const createFetchResourceMethod = (method: string) => ( resource: string, @@ -61,25 +61,20 @@ const createFetchResourceMethod = (method: string) => ( return []; }; -export const getRecentAcitivtyObjs = ( - userId: string | number, - recent: string, - addDangerToast: (arg1: string, arg2: any) => any, -) => { - const getParams = (filters?: Array) => { - 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 getParams = (filters?: Array) => { + 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); +}; + +export const getEditedObjects = (userId: string | number) => { const filters = { - // chart and dashbaord uses same filters - // for edited and created edited: [ { col: 'changed_by', @@ -87,76 +82,74 @@ export const getRecentAcitivtyObjs = ( value: `${userId}`, }, ], - created: [ - { - col: 'created_by', - opr: 'rel_o_m', - value: `${userId}`, - }, - ], }; - const baseBatch = [ - SupersetClient.get({ endpoint: recent }), + const batch = [ 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), + return Promise.all(batch) + .then(([editedCharts, editedDashboards]) => { + const res = { + editedDash: editedDashboards.json?.result.slice(0, 3), + editedChart: editedCharts.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(errMsg => - addDangerToast( - t('There was an error fetching your recent activity:'), - errMsg, - ), - ); - } - res.viewed = recentsRes.json; return res; - }, - ); + }) + .catch(err => err); +}; + +export const getUserOwnedObjects = ( + userId: string | number, + resource: string, +) => { + const filters = { + created: [ + { + col: 'created_by', + opr: 'rel_o_m', + value: `${userId}`, + }, + ], + }; + return SupersetClient.get({ + endpoint: `/api/v1/${resource}/?q=${getParams(filters.created)}`, + }).then(res => res.json?.result); }; +export const getRecentAcitivtyObjs = ( + userId: string | number, + recent: string, + addDangerToast: (arg1: string, arg2: any) => any, +) => + SupersetClient.get({ endpoint: recent }).then(recentsRes => { + const res: any = {}; + 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(errMsg => + addDangerToast( + t('There was an error fetching your recent activity:'), + errMsg, + ), + ); + } + res.viewed = recentsRes.json; + return res; + }); + export const createFetchRelated = createFetchResourceMethod('related'); export const createFetchDistinct = createFetchResourceMethod('distinct'); diff --git a/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx b/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx index b52dfe2cd8561..7cea906aa7d9c 100644 --- a/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx +++ b/superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import React from 'react'; +import React, { useState } from 'react'; import moment from 'moment'; import { styled, t } from '@superset-ui/core'; @@ -25,7 +25,7 @@ import ListViewCard from 'src/components/ListViewCard'; import SubMenu from 'src/components/Menu/SubMenu'; import { Chart } from 'src/types/Chart'; import { Dashboard, SavedQueryObject } from 'src/views/CRUD/types'; -import { mq, CardStyles } from 'src/views/CRUD/utils'; +import { mq, CardStyles, getEditedObjects } from 'src/views/CRUD/utils'; import { ActivityData } from './Welcome'; import EmptyState from './EmptyState'; @@ -66,7 +66,6 @@ interface ActivityProps { }; activeChild: string; setActiveChild: (arg0: string) => void; - loading: boolean; activityData: ActivityData; } @@ -156,17 +155,27 @@ const getEntityLastActionOn = (entity: ActivityObject) => { }; export default function ActivityTable({ - loading, activeChild, setActiveChild, activityData, + user, }: ActivityProps) { + const [editedObjs, setEditedObjs] = useState>(); + const [loadingState, setLoadingState] = useState(false); + const getEditedCards = () => { + setLoadingState(true); + getEditedObjects(user.userId).then(r => { + setEditedObjs([...r.editedChart, ...r.editedDash]); + setLoadingState(false); + }); + }; const tabs = [ { name: 'Edited', label: t('Edited'), onClick: () => { setActiveChild('Edited'); + getEditedCards(); }, }, { @@ -197,30 +206,32 @@ export default function ActivityTable({ } const renderActivity = () => - activityData[activeChild].map((entity: ActivityObject) => { - const url = getEntityUrl(entity); - const lastActionOn = getEntityLastActionOn(entity); - return ( - { - window.location.href = url; - }} - key={url} - > - } - url={url} - title={getEntityTitle(entity)} - description={lastActionOn} - avatar={getEntityIconName(entity)} - actions={null} - /> - - ); - }); - - if (loading) return ; + (activeChild !== 'Edited' ? activityData[activeChild] : editedObjs).map( + (entity: ActivityObject) => { + const url = getEntityUrl(entity); + const lastActionOn = getEntityLastActionOn(entity); + return ( + { + window.location.href = url; + }} + key={url} + > + } + url={url} + title={getEntityTitle(entity)} + description={lastActionOn} + avatar={getEntityIconName(entity)} + actions={null} + /> + + ); + }, + ); + if (loadingState && !editedObjs) { + return ; + } return ( <> <> - {activityData[activeChild]?.length > 0 ? ( + {activityData[activeChild]?.length > 0 || + (activeChild === 'Edited' && editedObjs && editedObjs.length > 0) ? ( {renderActivity()} ) : ( diff --git a/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx b/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx index 8eebf1f631e6c..5eb0b294d3803 100644 --- a/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx +++ b/superset-frontend/src/views/CRUD/welcome/ChartTable.tsx @@ -29,6 +29,7 @@ import PropertiesModal from 'src/explore/components/PropertiesModal'; import { User } from 'src/types/bootstrapTypes'; import ChartCard from 'src/views/CRUD/chart/ChartCard'; import Chart from 'src/types/Chart'; +import Loading from 'src/components/Loading'; import ErrorBoundary from 'src/components/ErrorBoundary'; import SubMenu from 'src/components/Menu/SubMenu'; import EmptyState from './EmptyState'; @@ -53,7 +54,7 @@ function ChartTable({ }: ChartTableProps) { const history = useHistory(); const { - state: { resourceCollection: charts, bulkSelectEnabled }, + state: { loading, resourceCollection: charts, bulkSelectEnabled }, setResourceCollection: setCharts, hasPerm, refreshData, @@ -64,7 +65,10 @@ function ChartTable({ addDangerToast, true, mine, + [], + false, ); + const chartIds = useMemo(() => charts.map(c => c.id), [charts]); const [saveFavoriteStatus, favoriteStatus] = useFavoriteStatus( 'chart', @@ -112,6 +116,7 @@ function ChartTable({ filters: getFilters(filter), }); + if (loading) return ; return ( {sliceCurrentlyEditing && ( diff --git a/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx b/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx index 5b8f933ba9e74..27a41d4dc766a 100644 --- a/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx +++ b/superset-frontend/src/views/CRUD/welcome/DashboardTable.tsx @@ -22,6 +22,7 @@ import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks'; import { Dashboard, DashboardTableProps } from 'src/views/CRUD/types'; import { useHistory } from 'react-router-dom'; import withToasts from 'src/messageToasts/enhancers/withToasts'; +import Loading from 'src/components/Loading'; import PropertiesModal from 'src/dashboard/components/PropertiesModal'; import DashboardCard from 'src/views/CRUD/dashboard/DashboardCard'; import SubMenu from 'src/components/Menu/SubMenu'; @@ -55,6 +56,8 @@ function DashboardTable({ addDangerToast, true, mine, + [], + false, ); const dashboardIds = useMemo(() => dashboards.map(c => c.id), [dashboards]); const [saveFavoriteStatus, favoriteStatus] = useFavoriteStatus( @@ -125,6 +128,7 @@ function DashboardTable({ filters: getFilters(filter), }); + if (loading) return ; return ( <> { const { - state: { resourceCollection: queries }, + state: { loading, resourceCollection: queries }, hasPerm, fetchData, refreshData, @@ -121,6 +122,8 @@ const SavedQueries = ({ addDangerToast, true, mine, + [], + false, ); const [queryFilter, setQueryFilter] = useState('Mine'); const [queryDeleteModal, setQueryDeleteModal] = useState(false); @@ -227,6 +230,8 @@ const SavedQueries = ({ )} ); + + if (loading) return ; return ( <> {queryDeleteModal && ( diff --git a/superset-frontend/src/views/CRUD/welcome/Welcome.tsx b/superset-frontend/src/views/CRUD/welcome/Welcome.tsx index 50fbe6fad1c35..0d1f3ba663f51 100644 --- a/superset-frontend/src/views/CRUD/welcome/Welcome.tsx +++ b/superset-frontend/src/views/CRUD/welcome/Welcome.tsx @@ -27,6 +27,7 @@ import { createErrorHandler, getRecentAcitivtyObjs, mq, + getUserOwnedObjects, } from 'src/views/CRUD/utils'; import ActivityTable from './ActivityTable'; @@ -44,9 +45,6 @@ export interface ActivityData { Edited?: Array; Viewed?: Array; Examples?: Array; - myChart?: Array; - myDash?: Array; - myQuery?: Array; } const WelcomeContainer = styled.div` @@ -88,22 +86,17 @@ const WelcomeContainer = styled.div` function Welcome({ user, addDangerToast }: WelcomeProps) { const recent = `/superset/recent_activity/${user.userId}/?limit=6`; const [activeChild, setActiveChild] = useState('Viewed'); - const [activityData, setActivityData] = useState({}); - const [loading, setLoading] = useState(true); + const [activityData, setActivityData] = useState(null); + const [chartData, setChartData] = useState | null>(null); + const [queryData, setQueryData] = useState | null>(null); + const [dashboardData, setDashboardData] = useState | null>( + null, + ); + useEffect(() => { getRecentAcitivtyObjs(user.userId, recent, addDangerToast) .then(res => { - const data: any = { - Created: [ - ...res.createdByChart, - ...res.createdByDash, - ...res.createdByQuery, - ], - myChart: res.createdByChart, - myDash: res.createdByDash, - myQuery: res.createdByQuery, - Edited: [...res.editedChart, ...res.editedDash], - }; + const data: ActivityData | null = {}; if (res.viewed) { const filtered = reject(res.viewed, ['item_url', null]).map(r => r); data.Viewed = filtered; @@ -112,54 +105,94 @@ function Welcome({ user, addDangerToast }: WelcomeProps) { data.Examples = res.examples; setActiveChild('Examples'); } - setActivityData(data); - setLoading(false); + setActivityData(activityData => ({ ...activityData, ...data })); }) .catch( createErrorHandler((errMsg: unknown) => { - setLoading(false); + setActivityData(activityData => ({ ...activityData, Viewed: [] })); addDangerToast( t('There was an issue fetching your recent activity: %s', errMsg), ); }), ); + + // Sets other activity data in parallel with recents api call + const id = user.userId; + getUserOwnedObjects(id, 'dashboard') + .then(r => { + setDashboardData(r); + }) + .catch((err: unknown) => { + setDashboardData([]); + addDangerToast( + t('There was an issues fetching your dashboards: %s', err), + ); + }); + getUserOwnedObjects(id, 'chart') + .then(r => { + setChartData(r); + }) + .catch((err: unknown) => { + setChartData([]); + addDangerToast(t('There was an issues fetching your chart: %s', err)); + }); + getUserOwnedObjects(id, 'saved_query') + .then(r => { + setQueryData(r); + }) + .catch((err: unknown) => { + setQueryData([]); + addDangerToast( + t('There was an issues fetching your saved queries: %s', err), + ); + }); }, []); + useEffect(() => { + setActivityData(activityData => ({ + ...activityData, + Created: [ + ...(chartData || []), + ...(dashboardData || []), + ...(queryData || []), + ], + })); + }, [chartData, queryData, dashboardData]); + return ( - + {activityData && (activityData.Viewed || activityData.Examples) ? ( + + ) : ( + + )} - {loading ? ( + {!dashboardData ? ( ) : ( - + )} - {loading ? ( + {!queryData ? ( ) : ( - + )} - {loading ? ( + {!chartData ? ( ) : ( - + )}