chore(homepage): separate out api calls to make homepage load more dynamically (#13500)

* separate out api calls

* add new loading states

* remove consoles

* update tests

* fix types and lint

* make code more robust and add test

* address comments

* address comments

* fix lint
This commit is contained in:
Phillip Kelley-Dotson 2021-03-22 15:10:42 -07:00 committed by GitHub
parent 54b2bda2b0
commit bbc306c64f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 284 additions and 163 deletions

View File

@ -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,14 +43,6 @@ const mockData = {
table: {},
},
],
Edited: [
{
dashboard_title: 'Dashboard_Test',
changed_on_utc: '24 Feb 2014 10:13:14',
url: '/fakeUrl/dashboard',
id: '3',
},
],
Created: [
{
dashboard_title: 'Dashboard_Test',
@ -54,20 +53,48 @@ const mockData = {
],
};
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',
url: '/fakeUrl/dashboard',
id: '3',
},
],
});
describe('ActivityTable', () => {
const activityProps = {
activeChild: 'Edited',
activeChild: 'Created',
activityData: mockData,
setActiveChild: jest.fn(),
user: { userId: '1' },
loading: false,
};
const wrapper = mount(<ActivityTable {...activityProps} />, {
context: { store },
});
let wrapper: ReactWrapper;
beforeAll(async () => {
await waitForComponentToPaint(wrapper);
await act(async () => {
wrapper = mount(
<Provider store={store}>
<ActivityTable {...activityProps} />
</Provider>,
);
});
});
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(
<Provider store={store}>
<ActivityTable {...activityProps} />
</Provider>,
);
expect(wrapper.find('EmptyState')).toExist();
});
});

View File

@ -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);
});
});

View File

@ -56,11 +56,12 @@ export function useListViewResource<D extends object = any>(
infoEnable = true,
defaultCollectionValue: D[] = [],
baseFilters?: FilterValue[], // must be memoized
initialLoadingState = true,
) {
const [state, setState] = useState<ListViewResourceState<D>>({
count: 0,
collection: defaultCollectionValue,
loading: true,
loading: initialLoadingState,
lastFetchDataConfig: null,
permissions: [],
bulkSelectEnabled: false,

View File

@ -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;

View File

@ -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<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 getParams = (filters?: Array<Filters>) => {
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,6 +82,31 @@ export const getRecentAcitivtyObjs = (
value: `${userId}`,
},
],
};
const batch = [
SupersetClient.get({
endpoint: `/api/v1/dashboard/?q=${getParams(filters.edited)}`,
}),
SupersetClient.get({
endpoint: `/api/v1/chart/?q=${getParams(filters.edited)}`,
}),
];
return Promise.all(batch)
.then(([editedCharts, editedDashboards]) => {
const res = {
editedDash: editedDashboards.json?.result.slice(0, 3),
editedChart: editedCharts.json?.result.slice(0, 3),
};
return res;
})
.catch(err => err);
};
export const getUserOwnedObjects = (
userId: string | number,
resource: string,
) => {
const filters = {
created: [
{
col: 'created_by',
@ -95,68 +115,41 @@ export const getRecentAcitivtyObjs = (
},
],
};
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(errMsg =>
addDangerToast(
t('There was an error fetching your recent activity:'),
errMsg,
),
);
}
res.viewed = recentsRes.json;
return res;
},
);
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');

View File

@ -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<Array<ActivityData>>();
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 (
<CardStyles
onClick={() => {
window.location.href = url;
}}
key={url}
>
<ListViewCard
loading={loading}
cover={<></>}
url={url}
title={getEntityTitle(entity)}
description={lastActionOn}
avatar={getEntityIconName(entity)}
actions={null}
/>
</CardStyles>
);
});
if (loading) return <Loading position="inline" />;
(activeChild !== 'Edited' ? activityData[activeChild] : editedObjs).map(
(entity: ActivityObject) => {
const url = getEntityUrl(entity);
const lastActionOn = getEntityLastActionOn(entity);
return (
<CardStyles
onClick={() => {
window.location.href = url;
}}
key={url}
>
<ListViewCard
cover={<></>}
url={url}
title={getEntityTitle(entity)}
description={lastActionOn}
avatar={getEntityIconName(entity)}
actions={null}
/>
</CardStyles>
);
},
);
if (loadingState && !editedObjs) {
return <Loading position="inline" />;
}
return (
<>
<SubMenu
@ -229,7 +240,8 @@ export default function ActivityTable({
tabs={tabs}
/>
<>
{activityData[activeChild]?.length > 0 ? (
{activityData[activeChild]?.length > 0 ||
(activeChild === 'Edited' && editedObjs && editedObjs.length > 0) ? (
<ActivityContainer>{renderActivity()}</ActivityContainer>
) : (
<EmptyState tableName="RECENTS" tab={activeChild} />

View File

@ -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 <Loading position="inline" />;
return (
<ErrorBoundary>
{sliceCurrentlyEditing && (

View File

@ -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 <Loading position="inline" />;
return (
<>
<SubMenu

View File

@ -22,6 +22,7 @@ import SyntaxHighlighter from 'react-syntax-highlighter/dist/cjs/light';
import sql from 'react-syntax-highlighter/dist/cjs/languages/hljs/sql';
import github from 'react-syntax-highlighter/dist/cjs/styles/hljs/github';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import Loading from 'src/components/Loading';
import { Dropdown, Menu } from 'src/common/components';
import { useListViewResource, copyQueryLink } from 'src/views/CRUD/hooks';
import ListViewCard from 'src/components/ListViewCard';
@ -111,7 +112,7 @@ const SavedQueries = ({
mine,
}: SavedQueriesProps) => {
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 = ({
)}
</Menu>
);
if (loading) return <Loading position="inline" />;
return (
<>
{queryDeleteModal && (

View File

@ -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<object>;
Viewed?: Array<object>;
Examples?: Array<object>;
myChart?: Array<object>;
myDash?: Array<object>;
myQuery?: Array<object>;
}
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<ActivityData>({});
const [loading, setLoading] = useState(true);
const [activityData, setActivityData] = useState<ActivityData | null>(null);
const [chartData, setChartData] = useState<Array<object> | null>(null);
const [queryData, setQueryData] = useState<Array<object> | null>(null);
const [dashboardData, setDashboardData] = useState<Array<object> | 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 (
<WelcomeContainer>
<Collapse defaultActiveKey={['1', '2', '3', '4']} ghost bigger>
<Collapse.Panel header={t('Recents')} key="1">
<ActivityTable
user={user}
activeChild={activeChild}
setActiveChild={setActiveChild}
loading={loading}
activityData={activityData}
/>
{activityData && (activityData.Viewed || activityData.Examples) ? (
<ActivityTable
user={user}
activeChild={activeChild}
setActiveChild={setActiveChild}
activityData={activityData}
/>
) : (
<Loading position="inline" />
)}
</Collapse.Panel>
<Collapse.Panel header={t('Dashboards')} key="2">
{loading ? (
{!dashboardData ? (
<Loading position="inline" />
) : (
<DashboardTable
user={user}
mine={activityData.myDash}
isLoading={loading}
/>
<DashboardTable user={user} mine={dashboardData} />
)}
</Collapse.Panel>
<Collapse.Panel header={t('Saved queries')} key="3">
{loading ? (
{!queryData ? (
<Loading position="inline" />
) : (
<SavedQueries user={user} mine={activityData.myQuery} />
<SavedQueries user={user} mine={queryData} />
)}
</Collapse.Panel>
<Collapse.Panel header={t('Charts')} key="4">
{loading ? (
{!chartData ? (
<Loading position="inline" />
) : (
<ChartTable user={user} mine={activityData.myChart} />
<ChartTable user={user} mine={chartData} />
)}
</Collapse.Panel>
</Collapse>