This is an automated email from the ASF dual-hosted git repository.

elizabeth pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new 87110ebce4 fix: dashboard performance (#28609)
87110ebce4 is described below

commit 87110ebce45a6b75891e30c876dd50e4e8c5dbab
Author: Daniel Vaz Gaspar <danielvazgas...@gmail.com>
AuthorDate: Tue May 28 21:09:05 2024 +0100

    fix: dashboard performance (#28609)
    
    Co-authored-by: Elizabeth Thompson <eschu...@gmail.com>
    Co-authored-by: Joe Li <j...@preset.io>
---
 pyproject.toml                                     |   2 +-
 requirements/base.txt                              |   7 +-
 requirements/development.txt                       |  11 +-
 .../src/features/dashboards/DashboardCard.test.tsx | 158 +++++++++++++++++++++
 .../src/features/dashboards/DashboardCard.tsx      |  39 ++++-
 .../src/pages/DashboardList/DashboardList.test.jsx |   2 +-
 .../src/pages/DashboardList/index.tsx              |  26 ++++
 superset-frontend/src/views/CRUD/hooks.test.tsx    | 105 ++++++++++++++
 superset-frontend/src/views/CRUD/hooks.ts          |   2 +
 9 files changed, 338 insertions(+), 14 deletions(-)

diff --git a/pyproject.toml b/pyproject.toml
index af6a15dbac..2e20fae77b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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",
diff --git a/requirements/base.txt b/requirements/base.txt
index 6a68e50524..1b19d3a920 100644
--- a/requirements/base.txt
+++ b/requirements/base.txt
@@ -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
diff --git a/requirements/development.txt b/requirements/development.txt
index 5fdc102ea0..5b99fd81b6 100644
--- a/requirements/development.txt
+++ b/requirements/development.txt
@@ -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
diff --git a/superset-frontend/src/features/dashboards/DashboardCard.test.tsx 
b/superset-frontend/src/features/dashboards/DashboardCard.test.tsx
new file mode 100644
index 0000000000..f1080c0e1f
--- /dev/null
+++ b/superset-frontend/src/features/dashboards/DashboardCard.test.tsx
@@ -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....@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();
+});
diff --git a/superset-frontend/src/features/dashboards/DashboardCard.tsx 
b/superset-frontend/src/features/dashboards/DashboardCard.tsx
index e977c62f52..a494a92d3c 100644
--- a/superset-frontend/src/features/dashboards/DashboardCard.tsx
+++ b/superset-frontend/src/features/dashboards/DashboardCard.tsx
@@ -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 && (
diff --git a/superset-frontend/src/pages/DashboardList/DashboardList.test.jsx 
b/superset-frontend/src/pages/DashboardList/DashboardList.test.jsx
index fe307ee791..8ba60ca285 100644
--- a/superset-frontend/src/pages/DashboardList/DashboardList.test.jsx
+++ b/superset-frontend/src/pages/DashboardList/DashboardList.test.jsx
@@ -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))"`,
     );
   });
 
diff --git a/superset-frontend/src/pages/DashboardList/index.tsx 
b/superset-frontend/src/pages/DashboardList/index.tsx
index 2e5a5a2cd8..2c80e094f3 100644
--- a/superset-frontend/src/pages/DashboardList/index.tsx
+++ b/superset-frontend/src/pages/DashboardList/index.tsx
@@ -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(
diff --git a/superset-frontend/src/views/CRUD/hooks.test.tsx 
b/superset-frontend/src/views/CRUD/hooks.test.tsx
new file mode 100644
index 0000000000..94b03fd72a
--- /dev/null
+++ b/superset-frontend/src/views/CRUD/hooks.test.tsx
@@ -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))',
+    });
+  });
+});
diff --git a/superset-frontend/src/views/CRUD/hooks.ts 
b/superset-frontend/src/views/CRUD/hooks.ts
index 129734fc8b..6fa167d934 100644
--- a/superset-frontend/src/views/CRUD/hooks.ts
+++ b/superset-frontend/src/views/CRUD/hooks.ts
@@ -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({

Reply via email to