fix: dashboard performance (#28609)

Co-authored-by: Elizabeth Thompson <eschutho@gmail.com>
Co-authored-by: Joe Li <joe@preset.io>
This commit is contained in:
Daniel Vaz Gaspar 2024-05-28 21:09:05 +01:00 committed by GitHub
parent f9d2451b23
commit 87110ebce4
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 338 additions and 14 deletions

View File

@ -45,7 +45,7 @@ dependencies = [
"cryptography>=42.0.4, <43.0.0",
"deprecation>=2.1.0, <2.2.0",
"flask>=2.2.5, <3.0.0",
"flask-appbuilder>=4.4.1, <5.0.0",
"flask-appbuilder>=4.5.0, <5.0.0",
"flask-caching>=2.1.0, <3",
"flask-compress>=1.13, <2.0",
"flask-talisman>=1.0.0, <2.0",

View File

@ -15,8 +15,6 @@ apispec[yaml]==6.3.0
# via flask-appbuilder
apsw==3.46.0.0
# via shillelagh
async-timeout==4.0.3
# via redis
attrs==23.2.0
# via
# cattrs
@ -93,8 +91,6 @@ dnspython==2.6.1
# via email-validator
email-validator==2.1.1
# via flask-appbuilder
exceptiongroup==1.2.1
# via cattrs
flask==2.3.3
# via
# apache-superset
@ -109,7 +105,7 @@ flask==2.3.3
# flask-session
# flask-sqlalchemy
# flask-wtf
flask-appbuilder==4.4.1
flask-appbuilder==4.5.0
# via apache-superset
flask-babel==2.0.0
# via flask-appbuilder
@ -360,7 +356,6 @@ typing-extensions==4.12.0
# via
# alembic
# apache-superset
# cattrs
# flask-limiter
# limits
# shillelagh

View File

@ -10,6 +10,8 @@
# via
# -r requirements/base.in
# -r requirements/development.in
appnope==0.1.4
# via ipython
astroid==3.1.0
# via pylint
boto3==1.34.112
@ -175,7 +177,9 @@ protobuf==4.23.0
psycopg2-binary==2.9.6
# via apache-superset
pure-sasl==0.6.2
# via thrift-sasl
# via
# pyhive
# thrift-sasl
pydata-google-auth==1.7.0
# via pandas-gbq
pydruid==0.6.9
@ -184,7 +188,7 @@ pyee==11.0.1
# via playwright
pyfakefs==5.3.5
# via apache-superset
pyhive[presto]==0.7.0
pyhive[hive_pure_sasl]==0.7.0
# via apache-superset
pyinstrument==4.4.0
# via apache-superset
@ -228,10 +232,9 @@ tableschema==1.20.10
thrift==0.16.0
# via
# apache-superset
# pyhive
# thrift-sasl
thrift-sasl==0.4.3
# via apache-superset
tomli==2.0.1
# via
# build
# coverage

View File

@ -0,0 +1,158 @@
/**
* 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 { MemoryRouter } from 'react-router-dom';
import { FeatureFlag, SupersetClient } from '@superset-ui/core';
import * as uiCore from '@superset-ui/core';
import { render, screen, waitFor } from 'spec/helpers/testing-library';
import DashboardCard from './DashboardCard';
const mockDashboard = {
id: 1,
dashboard_title: 'Sample Dashboard',
certified_by: 'John Doe',
certification_details: 'Certified on 2022-01-01',
published: true,
url: '/dashboard/1',
thumbnail_url: '/thumbnails/1.png',
changed_on_delta_humanized: '2 days ago',
owners: [
{ id: 1, name: 'Alice', first_name: 'Alice', last_name: 'Doe' },
{ id: 2, name: 'Bob', first_name: 'Bob', last_name: 'Smith' },
],
changed_by_name: 'John Doe',
changed_by: 'john.doe@example.com',
};
const mockHasPerm = jest.fn().mockReturnValue(true);
const mockOpenDashboardEditModal = jest.fn();
const mockSaveFavoriteStatus = jest.fn();
const mockHandleBulkDashboardExport = jest.fn();
const mockOnDelete = jest.fn();
let isFeatureEnabledMock: jest.MockInstance<boolean, [feature: FeatureFlag]>;
beforeAll(() => {
isFeatureEnabledMock = jest
.spyOn(uiCore, 'isFeatureEnabled')
.mockImplementation(() => true);
});
afterAll(() => {
// @ts-ignore
isFeatureEnabledMock.mockClear();
});
beforeEach(() => {
render(
<MemoryRouter>
<DashboardCard
dashboard={mockDashboard}
hasPerm={mockHasPerm}
bulkSelectEnabled={false}
loading={false}
openDashboardEditModal={mockOpenDashboardEditModal}
saveFavoriteStatus={mockSaveFavoriteStatus}
favoriteStatus={false}
handleBulkDashboardExport={mockHandleBulkDashboardExport}
onDelete={mockOnDelete}
/>
</MemoryRouter>,
);
});
it('Renders the dashboard title', () => {
const titleElement = screen.getByText('Sample Dashboard');
expect(titleElement).toBeInTheDocument();
});
it('Renders the certification details', () => {
const certificationDetailsElement = screen.getByLabelText(/certified/i);
expect(certificationDetailsElement).toBeInTheDocument();
});
it('Renders the published status', () => {
const publishedElement = screen.getByText(/published/i);
expect(publishedElement).toBeInTheDocument();
});
it('Renders the modified date', () => {
const modifiedDateElement = screen.getByText('Modified 2 days ago');
expect(modifiedDateElement).toBeInTheDocument();
});
it('should fetch thumbnail when dashboard has no thumbnail URL and feature flag is enabled', async () => {
const mockGet = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
response: new Response(
JSON.stringify({ thumbnail_url: '/new-thumbnail.png' }),
),
json: () => Promise.resolve({ thumbnail_url: '/new-thumbnail.png' }),
});
const { rerender } = render(
<DashboardCard
dashboard={{
id: 1,
thumbnail_url: '',
changed_by_name: '',
changed_by: '',
dashboard_title: '',
published: false,
url: '',
owners: [],
}}
hasPerm={() => true}
bulkSelectEnabled={false}
loading={false}
saveFavoriteStatus={() => {}}
favoriteStatus={false}
handleBulkDashboardExport={() => {}}
onDelete={() => {}}
/>,
);
await waitFor(() => {
expect(mockGet).toHaveBeenCalledWith({
endpoint: '/api/v1/dashboard/1',
});
});
rerender(
<DashboardCard
dashboard={{
id: 1,
thumbnail_url: '/new-thumbnail.png',
changed_by_name: '',
changed_by: '',
dashboard_title: '',
published: false,
url: '',
owners: [],
}}
hasPerm={() => true}
bulkSelectEnabled={false}
loading={false}
saveFavoriteStatus={() => {}}
favoriteStatus={false}
handleBulkDashboardExport={() => {}}
onDelete={() => {}}
/>,
);
mockGet.mockRestore();
});

View File

@ -16,9 +16,15 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import React, { useEffect, useState } from 'react';
import { Link, useHistory } from 'react-router-dom';
import { isFeatureEnabled, FeatureFlag, t, useTheme } from '@superset-ui/core';
import {
isFeatureEnabled,
FeatureFlag,
t,
useTheme,
SupersetClient,
} from '@superset-ui/core';
import { CardStyles } from 'src/views/CRUD/utils';
import { AntdDropdown } from 'src/components';
import { Menu } from 'src/components/Menu';
@ -62,6 +68,35 @@ function DashboardCard({
const canExport = hasPerm('can_export');
const theme = useTheme();
const [thumbnailUrl, setThumbnailUrl] = useState<string | null>(null);
const [fetchingThumbnail, setFetchingThumbnail] = useState<boolean>(false);
useEffect(() => {
// fetch thumbnail only if it's not already fetched
if (
!fetchingThumbnail &&
dashboard.id &&
(thumbnailUrl === undefined || thumbnailUrl === null) &&
isFeatureEnabled(FeatureFlag.Thumbnails)
) {
// fetch thumbnail
if (dashboard.thumbnail_url) {
// set to empty string if null so that we don't
// keep fetching the thumbnail
setThumbnailUrl(dashboard.thumbnail_url || '');
return;
}
setFetchingThumbnail(true);
SupersetClient.get({
endpoint: `/api/v1/dashboard/${dashboard.id}`,
}).then(({ json = {} }) => {
setThumbnailUrl(json.thumbnail_url || '');
setFetchingThumbnail(false);
});
}
}, [dashboard, thumbnailUrl]);
const menu = (
<Menu>
{canEdit && openDashboardEditModal && (

View File

@ -162,7 +162,7 @@ describe('DashboardList', () => {
const callsD = fetchMock.calls(/dashboard\/\?q/);
expect(callsD).toHaveLength(1);
expect(callsD[0][0]).toMatchInlineSnapshot(
`"http://localhost/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25)"`,
`"http://localhost/api/v1/dashboard/?q=(order_column:changed_on_delta_humanized,order_direction:desc,page:0,page_size:25,select_columns:!(id,dashboard_title,published,url,slug,changed_by,changed_on_delta_humanized,owners.id,owners.first_name,owners.last_name,owners,tags.id,tags.name,tags.type,status,certified_by,certification_details,changed_on))"`,
);
});

View File

@ -111,6 +111,27 @@ const Actions = styled.div`
color: ${({ theme }) => theme.colors.grayscale.base};
`;
const DASHBOARD_COLUMNS_TO_FETCH = [
'id',
'dashboard_title',
'published',
'url',
'slug',
'changed_by',
'changed_on_delta_humanized',
'owners.id',
'owners.first_name',
'owners.last_name',
'owners',
'tags.id',
'tags.name',
'tags.type',
'status',
'certified_by',
'certification_details',
'changed_on',
];
function DashboardList(props: DashboardListProps) {
const { addDangerToast, addSuccessToast, user } = props;
@ -135,6 +156,11 @@ function DashboardList(props: DashboardListProps) {
'dashboard',
t('dashboard'),
addDangerToast,
undefined,
undefined,
undefined,
undefined,
DASHBOARD_COLUMNS_TO_FETCH,
);
const dashboardIds = useMemo(() => dashboards.map(d => d.id), [dashboards]);
const [saveFavoriteStatus, favoriteStatus] = useFavoriteStatus(

View File

@ -0,0 +1,105 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { renderHook } from '@testing-library/react-hooks';
import { JsonResponse, SupersetClient } from '@superset-ui/core';
import { useListViewResource } from './hooks';
describe('useListViewResource', () => {
afterEach(() => {
jest.restoreAllMocks();
});
it('should fetch data with correct query parameters', async () => {
const pageIndex = 0; // Declare and initialize the pageIndex variable
const pageSize = 10; // Declare and initialize the pageSize variable
const baseFilters = [{ id: 'status', operator: 'equals', value: 'active' }];
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {
result: {
dashboard_title: 'New Title',
slug: '/new',
json_metadata: '{"something":"foo"}',
owners: [{ id: 1, first_name: 'Al', last_name: 'Pacino' }],
roles: [],
},
},
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource('example', 'Example', jest.fn()),
);
result.current.fetchData({
pageIndex,
pageSize,
sortBy: [{ id: 'foo' }], // Change the type of sortBy from string to SortColumn[]
filters: baseFilters,
});
expect(fetchSpy).toHaveBeenNthCalledWith(2, {
endpoint:
'/api/v1/example/?q=(filters:!((col:status,opr:equals,value:active)),order_column:foo,order_direction:asc,page:0,page_size:10)',
});
});
it('should pass the selectColumns to the fetch call', async () => {
const pageIndex = 0; // Declare and initialize the pageIndex variable
const pageSize = 10; // Declare and initialize the pageSize variable
const baseFilters = [{ id: 'status', operator: 'equals', value: 'active' }];
const selectColumns = ['id', 'name'];
const fetchSpy = jest.spyOn(SupersetClient, 'get').mockResolvedValue({
json: {
result: {
dashboard_title: 'New Title',
slug: '/new',
json_metadata: '{"something":"foo"}',
owners: [{ id: 1, first_name: 'Al', last_name: 'Pacino' }],
roles: [],
},
},
} as unknown as JsonResponse);
const { result } = renderHook(() =>
useListViewResource(
'example',
'Example',
jest.fn(),
undefined,
undefined,
undefined,
undefined,
selectColumns,
),
);
result.current.fetchData({
pageIndex,
pageSize,
sortBy: [{ id: 'foo' }], // Change the type of sortBy from string to SortColumn[]
filters: baseFilters,
});
expect(fetchSpy).toHaveBeenNthCalledWith(2, {
endpoint:
'/api/v1/example/?q=(filters:!((col:status,opr:equals,value:active)),order_column:foo,order_direction:asc,page:0,page_size:10,select_columns:!(id,name))',
});
});
});

View File

@ -77,6 +77,7 @@ export function useListViewResource<D extends object = any>(
defaultCollectionValue: D[] = [],
baseFilters?: FilterValue[], // must be memoized
initialLoadingState = true,
selectColumns?: string[],
) {
const [state, setState] = useState<ListViewResourceState<D>>({
count: 0,
@ -162,6 +163,7 @@ export function useListViewResource<D extends object = any>(
page: pageIndex,
page_size: pageSize,
...(filterExps.length ? { filters: filterExps } : {}),
...(selectColumns?.length ? { select_columns: selectColumns } : {}),
});
return SupersetClient.get({