You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by vi...@apache.org on 2020/09/11 12:07:02 UTC

[incubator-superset] 04/34: feat(listview): skeleton loading states for table and card collections (#10606)

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

villebro pushed a commit to branch 0.38
in repository https://gitbox.apache.org/repos/asf/incubator-superset.git

commit cfd86918ff345ce1b9579daf504f8ca3b2a1acbe
Author: ʈᵃᵢ <td...@gmail.com>
AuthorDate: Fri Aug 21 10:32:37 2020 -0700

    feat(listview): skeleton loading states for table and card collections (#10606)
---
 superset-frontend/.eslintrc.js                     | 21 ++++++
 superset-frontend/package-lock.json                | 50 +++++++++-----
 superset-frontend/package.json                     |  3 +-
 superset-frontend/spec/.eslintrc                   |  7 +-
 .../views/CRUD/chart/ChartList_spec.jsx            |  4 ++
 .../views/CRUD/dashboard/DashboardList_spec.jsx    |  4 ++
 .../src/common/components/{index.js => index.ts}   | 13 ++++
 .../src/components/Label/Label.test.tsx            |  2 -
 .../src/components/ListView/CardCollection.tsx     | 53 +++++++++-----
 .../src/components/ListView/ListView.tsx           | 15 ++--
 .../src/components/ListView/TableCollection.tsx    | 65 ++++++++++++------
 .../components/ListViewCard/ImageLoader.test.jsx   | 73 ++++++++++++++++++++
 .../src/components/ListViewCard/ImageLoader.tsx    | 64 +++++++++++++++++
 .../ListViewCard/ListViewCard.stories.tsx          | 20 +++++-
 .../components/ListViewCard/ListViewCard.test.jsx  | 69 +++++++++++++++++++
 .../src/components/ListViewCard/index.tsx          | 80 ++++++++++++++++------
 .../src/views/CRUD/chart/ChartList.tsx             |  3 +-
 .../src/views/CRUD/dashboard/DashboardList.tsx     |  5 +-
 18 files changed, 456 insertions(+), 95 deletions(-)

diff --git a/superset-frontend/.eslintrc.js b/superset-frontend/.eslintrc.js
index 546ac6c..15c183c 100644
--- a/superset-frontend/.eslintrc.js
+++ b/superset-frontend/.eslintrc.js
@@ -131,6 +131,27 @@ module.exports = {
         ],
       },
     },
+    {
+      files: [
+        'src/**/*.test.ts',
+        'src/**/*.test.tsx',
+        'src/**/*.test.js',
+        'src/**/*.test.jsx',
+      ],
+      plugins: ['jest', 'no-only-tests'],
+      env: {
+        'jest/globals': true,
+      },
+      extends: ['plugin:jest/recommended'],
+      rules: {
+        'import/no-extraneous-dependencies': [
+          'error',
+          { devDependencies: true },
+        ],
+        'jest/consistent-test-it': 'error',
+        'no-only-tests/no-only-tests': 'error',
+      },
+    },
   ],
   rules: {
     camelcase: [
diff --git a/superset-frontend/package-lock.json b/superset-frontend/package-lock.json
index 43bb6e3..e3512e9 100644
--- a/superset-frontend/package-lock.json
+++ b/superset-frontend/package-lock.json
@@ -20142,9 +20142,9 @@
       },
       "dependencies": {
         "core-js": {
-          "version": "2.6.0",
-          "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.0.tgz",
-          "integrity": "sha512-kLRC6ncVpuEW/1kwrOXYX6KQASCVtrh1gQr/UiaVgFlf9WE5Vp+lNe5+h3LuMr5PAucWnnEXwH0nQHRH/gpGtw==",
+          "version": "2.6.11",
+          "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz",
+          "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==",
           "dev": true
         },
         "regenerator-runtime": {
@@ -25861,15 +25861,25 @@
       }
     },
     "fetch-mock": {
-      "version": "7.2.5",
-      "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-7.2.5.tgz",
-      "integrity": "sha512-ZdlNxw2xFE2VuGikqWYBcshbfMtWM0k7zWevYgjrFuTiJ1+S7+xjRMxDG1cy45xkpEcqzZAAeqL+uDL5qLZV7g==",
+      "version": "7.7.3",
+      "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-7.7.3.tgz",
+      "integrity": "sha512-I4OkK90JFQnjH8/n3HDtWxH/I6D1wrxoAM2ri+nb444jpuH3RTcgvXx2el+G20KO873W727/66T7QhOvFxNHPg==",
       "dev": true,
       "requires": {
         "babel-polyfill": "^6.26.0",
+        "core-js": "^2.6.9",
         "glob-to-regexp": "^0.4.0",
+        "lodash.isequal": "^4.5.0",
         "path-to-regexp": "^2.2.1",
         "whatwg-url": "^6.5.0"
+      },
+      "dependencies": {
+        "core-js": {
+          "version": "2.6.11",
+          "resolved": "https://registry.npmjs.org/core-js/-/core-js-2.6.11.tgz",
+          "integrity": "sha512-5wjnpaT/3dV+XB4borEsnAYQchn00XSgTAWKDkEqv+K8KevjbzmofK6hfJ9TZIlpj2N0xQpazy7PiRQiWHqzWg==",
+          "dev": true
+        }
       }
     },
     "fetch-retry": {
@@ -26564,9 +26574,9 @@
       }
     },
     "glob-to-regexp": {
-      "version": "0.4.0",
-      "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.0.tgz",
-      "integrity": "sha512-fyPCII4vn9Gvjq2U/oDAfP433aiE64cyP/CJjRJcpVGjqqNdioUYn9+r0cSzT1XPwmGAHuTT7iv+rQT8u/YHKQ==",
+      "version": "0.4.1",
+      "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz",
+      "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==",
       "dev": true
     },
     "global": {
@@ -28307,6 +28317,17 @@
       "requires": {
         "node-fetch": "^1.0.1",
         "whatwg-fetch": ">=0.10.0"
+      },
+      "dependencies": {
+        "node-fetch": {
+          "version": "1.7.3",
+          "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz",
+          "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==",
+          "requires": {
+            "encoding": "^0.1.11",
+            "is-stream": "^1.0.1"
+          }
+        }
       }
     },
     "isstream": {
@@ -33828,13 +33849,10 @@
       }
     },
     "node-fetch": {
-      "version": "1.7.3",
-      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-1.7.3.tgz",
-      "integrity": "sha512-NhZ4CsKx7cYm2vSrBAr2PvFOe6sWDf0UYLRqA6svUYg7+/TSfVAu49jYC4BvQ4Sms9SZgdqGBgroqfDhJdTyKQ==",
-      "requires": {
-        "encoding": "^0.1.11",
-        "is-stream": "^1.0.1"
-      }
+      "version": "2.6.0",
+      "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.0.tgz",
+      "integrity": "sha512-8dG4H5ujfvFiqDmVu9fQ5bOHUC15JMjMY/Zumv26oOvvVJjM67KF8koCWIabKQ1GJIa9r2mMZscBq/TbdOcmNA==",
+      "dev": true
     },
     "node-forge": {
       "version": "0.9.0",
diff --git a/superset-frontend/package.json b/superset-frontend/package.json
index 28f0cb8..cb00328 100644
--- a/superset-frontend/package.json
+++ b/superset-frontend/package.json
@@ -255,7 +255,7 @@
     "eslint-plugin-prettier": "^3.1.3",
     "eslint-plugin-react": "^7.16.0",
     "exports-loader": "^0.7.0",
-    "fetch-mock": "^7.0.0-alpha.6",
+    "fetch-mock": "^7.7.3",
     "file-loader": "^6.0.0",
     "fork-ts-checker-webpack-plugin": "^0.4.9",
     "ignore-styles": "^5.0.1",
@@ -267,6 +267,7 @@
     "less": "^3.9.0",
     "less-loader": "^5.0.0",
     "mini-css-extract-plugin": "^0.4.0",
+    "node-fetch": "^2.6.0",
     "optimize-css-assets-webpack-plugin": "^5.0.1",
     "po2json": "^0.4.5",
     "prettier": "^2.0.5",
diff --git a/superset-frontend/spec/.eslintrc b/superset-frontend/spec/.eslintrc
index 28c59ef..8689bb8 100644
--- a/superset-frontend/spec/.eslintrc
+++ b/superset-frontend/spec/.eslintrc
@@ -1,14 +1,11 @@
 {
-  "plugins": [
-    "jest",
-    "no-only-tests"
-  ],
+  "plugins": ["jest", "no-only-tests"],
   "env": {
     "jest/globals": true
   },
   "extends": ["plugin:jest/recommended"],
   "rules": {
-    "import/no-extraneous-dependencies": ["error", {"devDependencies": true}],
+    "import/no-extraneous-dependencies": ["error", { "devDependencies": true }],
     "jest/consistent-test-it": "error",
     "no-only-tests/no-only-tests": "error"
   }
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 213ac39..d640116 100644
--- a/superset-frontend/spec/javascripts/views/CRUD/chart/ChartList_spec.jsx
+++ b/superset-frontend/spec/javascripts/views/CRUD/chart/ChartList_spec.jsx
@@ -47,6 +47,7 @@ const mockCharts = [...new Array(3)].map((_, i) => ({
   url: 'url',
   viz_type: 'bar',
   datasource_name: `ds${i}`,
+  thumbnail_url: '/thumbnail',
 }));
 
 fetchMock.get(chartsInfoEndpoint, {
@@ -70,6 +71,9 @@ fetchMock.get(chartsDtasourcesEndpoint, {
   count: 0,
 });
 
+global.URL.createObjectURL = jest.fn();
+fetchMock.get('/thumbnail', { body: new Blob(), sendAsJson: false });
+
 describe('ChartList', () => {
   const mockedProps = {};
   const wrapper = mount(<ChartList {...mockedProps} />, {
diff --git a/superset-frontend/spec/javascripts/views/CRUD/dashboard/DashboardList_spec.jsx b/superset-frontend/spec/javascripts/views/CRUD/dashboard/DashboardList_spec.jsx
index 37d5ca2..7eb634c 100644
--- a/superset-frontend/spec/javascripts/views/CRUD/dashboard/DashboardList_spec.jsx
+++ b/superset-frontend/spec/javascripts/views/CRUD/dashboard/DashboardList_spec.jsx
@@ -48,6 +48,7 @@ const mockDashboards = [...new Array(3)].map((_, i) => ({
   changed_on_utc: new Date().toISOString(),
   changed_on_delta_humanized: '5 minutes ago',
   owners: [{ first_name: 'admin', last_name: 'admin_user' }],
+  thumbnail_url: '/thumbnail',
 }));
 
 fetchMock.get(dashboardsInfoEndpoint, {
@@ -61,6 +62,9 @@ fetchMock.get(dashboardsEndpoint, {
   dashboard_count: 3,
 });
 
+global.URL.createObjectURL = jest.fn();
+fetchMock.get('/thumbnail', { body: new Blob(), sendAsJson: false });
+
 describe('DashboardList', () => {
   const mockedProps = {};
   const wrapper = mount(<DashboardList {...mockedProps} />, {
diff --git a/superset-frontend/src/common/components/index.js b/superset-frontend/src/common/components/index.ts
similarity index 82%
rename from superset-frontend/src/common/components/index.js
rename to superset-frontend/src/common/components/index.ts
index 3b4d245..cb37127 100644
--- a/superset-frontend/src/common/components/index.js
+++ b/superset-frontend/src/common/components/index.ts
@@ -16,6 +16,8 @@
  * specific language governing permissions and limitations
  * under the License.
  */
+import styled from '@superset-ui/style';
+import { Skeleton } from 'antd';
 
 /*
   Antd is exported from here so we can override components with Emotion as needed.
@@ -23,4 +25,15 @@
   For documentation, see https://ant.design/components/overview/
  */
 /* eslint no-restricted-imports: 0 */
+
 export * from 'antd';
+
+export const ThinSkeleton = styled(Skeleton)`
+  h3 {
+    margin: ${({ theme }) => theme.gridUnit}px 0;
+  }
+
+  ul {
+    margin-bottom: 0;
+  }
+`;
diff --git a/superset-frontend/src/components/Label/Label.test.tsx b/superset-frontend/src/components/Label/Label.test.tsx
index 9227727..1535d4c 100644
--- a/superset-frontend/src/components/Label/Label.test.tsx
+++ b/superset-frontend/src/components/Label/Label.test.tsx
@@ -17,9 +17,7 @@
  * under the License.
  */
 
-/* global jest */
 import React from 'react';
-/* eslint-disable-next-line import/no-extraneous-dependencies */
 import { ReactWrapper } from 'enzyme';
 import { styledMount as mount } from 'spec/helpers/theming';
 import Label from '.';
diff --git a/superset-frontend/src/components/ListView/CardCollection.tsx b/superset-frontend/src/components/ListView/CardCollection.tsx
index 065f8c3..49e4955 100644
--- a/superset-frontend/src/components/ListView/CardCollection.tsx
+++ b/superset-frontend/src/components/ListView/CardCollection.tsx
@@ -17,8 +17,9 @@
  * under the License.
  */
 import React from 'react';
-import { TableInstance } from 'react-table';
+import { TableInstance, Row } from 'react-table';
 import styled from '@superset-ui/style';
+import cx from 'classnames';
 
 interface CardCollectionProps {
   bulkSelectEnabled?: boolean;
@@ -42,6 +43,9 @@ const CardWrapper = styled.div`
   &.card-selected {
     border: 2px solid ${({ theme }) => theme.colors.primary.base};
   }
+  &.bulk-select {
+    cursor: pointer;
+  }
 `;
 
 export default function CardCollection({
@@ -51,32 +55,43 @@ export default function CardCollection({
   renderCard,
   rows,
 }: CardCollectionProps) {
-  function handleClick(event: React.FormEvent, onClick: any) {
+  function handleClick(
+    event: React.MouseEvent<HTMLDivElement, MouseEvent>,
+    toggleRowSelected: Row['toggleRowSelected'],
+  ) {
     if (bulkSelectEnabled) {
       event.preventDefault();
       event.stopPropagation();
-      onClick();
+      toggleRowSelected();
     }
   }
 
+  if (!renderCard) return null;
   return (
     <CardContainer>
-      {rows.map(row => {
-        if (!renderCard) return null;
-        prepareRow(row);
-        return (
-          <CardWrapper
-            className={
-              row.isSelected && bulkSelectEnabled ? 'card-selected' : ''
-            }
-            key={row.id}
-            onClick={e => handleClick(e, row.toggleRowSelected())}
-            role="none"
-          >
-            {renderCard({ ...row.original, loading })}
-          </CardWrapper>
-        );
-      })}
+      {loading &&
+        rows.length === 0 &&
+        [...new Array(25)].map((e, i) => {
+          return <div key={i}>{renderCard({ loading })}</div>;
+        })}
+      {rows.length > 0 &&
+        rows.map(row => {
+          if (!renderCard) return null;
+          prepareRow(row);
+          return (
+            <CardWrapper
+              className={cx({
+                'card-selected': bulkSelectEnabled && row.isSelected,
+                'bulk-select': bulkSelectEnabled,
+              })}
+              key={row.id}
+              onClick={e => handleClick(e, row.toggleRowSelected)}
+              role="none"
+            >
+              {renderCard({ ...row.original, loading })}
+            </CardWrapper>
+          );
+        })}
     </CardContainer>
   );
 }
diff --git a/superset-frontend/src/components/ListView/ListView.tsx b/superset-frontend/src/components/ListView/ListView.tsx
index edcaa61..9e92c77 100644
--- a/superset-frontend/src/components/ListView/ListView.tsx
+++ b/superset-frontend/src/components/ListView/ListView.tsx
@@ -339,11 +339,13 @@ const ListView: FunctionComponent<ListViewProps> = ({
               prepareRow={prepareRow}
               headerGroups={headerGroups}
               rows={rows}
+              columns={columns}
               loading={loading}
             />
           )}
         </div>
       </div>
+
       <div className="pagination-container">
         <Pagination
           totalPages={pageCount || 0}
@@ -352,12 +354,13 @@ const ListView: FunctionComponent<ListViewProps> = ({
           hideFirstAndLastPageLinks
         />
         <div className="row-count-container">
-          {t(
-            '%s-%s of %s',
-            pageSize * pageIndex + (rows.length && 1),
-            pageSize * pageIndex + rows.length,
-            count,
-          )}
+          {!loading &&
+            t(
+              '%s-%s of %s',
+              pageSize * pageIndex + (rows.length && 1),
+              pageSize * pageIndex + rows.length,
+              count,
+            )}
         </div>
       </div>
     </ListViewStyles>
diff --git a/superset-frontend/src/components/ListView/TableCollection.tsx b/superset-frontend/src/components/ListView/TableCollection.tsx
index 7fd37f3..71680b8 100644
--- a/superset-frontend/src/components/ListView/TableCollection.tsx
+++ b/superset-frontend/src/components/ListView/TableCollection.tsx
@@ -28,6 +28,7 @@ interface TableCollectionProps {
   prepareRow: TableInstance['prepareRow'];
   headerGroups: TableInstance['headerGroups'];
   rows: TableInstance['rows'];
+  columns: TableInstance['column'][];
   loading: boolean;
 }
 
@@ -195,6 +196,7 @@ export default function TableCollection({
   getTableBodyProps,
   prepareRow,
   headerGroups,
+  columns,
   rows,
   loading,
 }: TableCollectionProps) {
@@ -231,37 +233,60 @@ export default function TableCollection({
         ))}
       </thead>
       <tbody {...getTableBodyProps()}>
-        {rows.map(row => {
-          prepareRow(row);
-          return (
-            <tr
-              {...row.getRowProps()}
-              className={cx('table-row', {
-                'table-row-selected': row.isSelected,
-              })}
-            >
-              {row.cells.map(cell => {
-                if (cell.column.hidden) return null;
-
-                const columnCellProps = cell.column.cellProps || {};
+        {loading &&
+          rows.length === 0 &&
+          [...new Array(25)].map((_, i) => (
+            <tr key={i}>
+              {columns.map((column, i2) => {
+                if (column.hidden) return null;
                 return (
                   <td
+                    key={i2}
                     className={cx('table-cell', {
                       'table-cell-loader': loading,
-                      [cell.column.size || '']: cell.column.size,
+                      [column.size || '']: column.size,
                     })}
-                    {...cell.getCellProps()}
-                    {...columnCellProps}
                   >
-                    <span className={cx({ 'loading-bar': loading })}>
-                      <span>{cell.render('Cell')}</span>
+                    <span className="loading-bar">
+                      <span>LOADING</span>
                     </span>
                   </td>
                 );
               })}
             </tr>
-          );
-        })}
+          ))}
+        {rows.length > 0 &&
+          rows.map(row => {
+            prepareRow(row);
+            return (
+              <tr
+                {...row.getRowProps()}
+                className={cx('table-row', {
+                  'table-row-selected': row.isSelected,
+                })}
+              >
+                {row.cells.map(cell => {
+                  if (cell.column.hidden) return null;
+
+                  const columnCellProps = cell.column.cellProps || {};
+                  return (
+                    <td
+                      className={cx('table-cell', {
+                        'table-cell-loader': loading,
+                        [cell.column.size || '']: cell.column.size,
+                      })}
+                      {...cell.getCellProps()}
+                      {...columnCellProps}
+                    >
+                      <span className={cx({ 'loading-bar': loading })}>
+                        <span>{cell.render('Cell')}</span>
+                      </span>
+                    </td>
+                  );
+                })}
+              </tr>
+            );
+          })}
       </tbody>
     </Table>
   );
diff --git a/superset-frontend/src/components/ListViewCard/ImageLoader.test.jsx b/superset-frontend/src/components/ListViewCard/ImageLoader.test.jsx
new file mode 100644
index 0000000..9d898d4
--- /dev/null
+++ b/superset-frontend/src/components/ListViewCard/ImageLoader.test.jsx
@@ -0,0 +1,73 @@
+/**
+ * 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 fetchMock from 'fetch-mock';
+
+import ImageLoader from 'src/components/ListViewCard/ImageLoader';
+import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
+
+global.URL.createObjectURL = jest.fn(() => '/local_url');
+const blob = new Blob([], { type: 'image/png' });
+
+fetchMock.get(
+  '/thumbnail',
+  { body: blob, headers: { 'Content-Type': 'image/png' } },
+  {
+    sendAsJson: false,
+  },
+);
+
+describe('ListViewCard', () => {
+  const defaultProps = {
+    src: '/thumbnail',
+    fallback: '/fallback',
+  };
+
+  const factory = (extraProps = {}) => {
+    const props = { ...defaultProps, ...extraProps };
+    return mount(<ImageLoader {...props} />);
+  };
+
+  afterEach(fetchMock.resetHistory);
+
+  it('is a valid element', async () => {
+    const wrapper = factory();
+    await waitForComponentToPaint(wrapper);
+    expect(wrapper.find(ImageLoader)).toExist();
+  });
+
+  it('fetches loads the image in the background', async () => {
+    const wrapper = factory();
+    expect(wrapper.find('img').props().src).toBe('/fallback');
+    await waitForComponentToPaint(wrapper);
+    expect(fetchMock.calls(/thumbnail/)).toHaveLength(1);
+    expect(global.URL.createObjectURL).toHaveBeenCalled();
+    expect(wrapper.find('img').props().src).toBe('/local_url');
+  });
+
+  it('displays fallback image when response is not an image', async () => {
+    fetchMock.once('/thumbnail2', {});
+    const wrapper = factory({ src: '/thumbnail2' });
+    expect(wrapper.find('img').props().src).toBe('/fallback');
+    await waitForComponentToPaint(wrapper);
+    expect(fetchMock.calls(/thumbnail2/)).toHaveLength(1);
+    expect(wrapper.find('img').props().src).toBe('/fallback');
+  });
+});
diff --git a/superset-frontend/src/components/ListViewCard/ImageLoader.tsx b/superset-frontend/src/components/ListViewCard/ImageLoader.tsx
new file mode 100644
index 0000000..a4c859e
--- /dev/null
+++ b/superset-frontend/src/components/ListViewCard/ImageLoader.tsx
@@ -0,0 +1,64 @@
+/**
+ * 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, { useEffect } from 'react';
+
+interface ImageLoaderProps
+  extends React.DetailedHTMLProps<
+    React.ImgHTMLAttributes<HTMLImageElement>,
+    HTMLImageElement
+  > {
+  fallback: string;
+  src: string;
+  isLoading: boolean;
+}
+
+export default function ImageLoader({
+  src,
+  fallback,
+  alt,
+  isLoading,
+  ...rest
+}: ImageLoaderProps) {
+  const [imgSrc, setImgSrc] = React.useState<string>(fallback);
+
+  useEffect(() => {
+    if (src) {
+      fetch(src)
+        .then(response => response.blob())
+        .then(blob => {
+          if (/image/.test(blob.type)) {
+            const imgURL = URL.createObjectURL(blob);
+            setImgSrc(imgURL);
+          }
+        })
+        .catch(e => {
+          console.error(e); // eslint-disable-line no-console
+          setImgSrc(fallback);
+        });
+    }
+
+    return () => {
+      // theres a very brief period where isLoading is false and this component is about to unmount
+      // where the stale imgSrc is briefly rendered. Setting imgSrc to fallback smoothes the transition.
+      setImgSrc(fallback);
+    };
+  }, [src, fallback]);
+
+  return <img alt={alt || ''} src={isLoading ? fallback : imgSrc} {...rest} />;
+}
diff --git a/superset-frontend/src/components/ListViewCard/ListViewCard.stories.tsx b/superset-frontend/src/components/ListViewCard/ListViewCard.stories.tsx
index 07881f6..c25eeb7 100644
--- a/superset-frontend/src/components/ListViewCard/ListViewCard.stories.tsx
+++ b/superset-frontend/src/components/ListViewCard/ListViewCard.stories.tsx
@@ -18,7 +18,7 @@
  */
 import React from 'react';
 import { action } from '@storybook/addon-actions';
-import { withKnobs, boolean } from '@storybook/addon-knobs';
+import { withKnobs, boolean, select, text } from '@storybook/addon-knobs';
 import DashboardImg from 'images/dashboard-card-fallback.png';
 import ChartImg from 'images/chart-card-fallback.png';
 import { Dropdown, Menu } from 'src/common/components';
@@ -32,13 +32,27 @@ export default {
   decorators: [withKnobs],
 };
 
+const imgFallbackKnob = {
+  label: 'Fallback/Loading Image',
+  options: {
+    Dashboard: DashboardImg,
+    Chart: ChartImg,
+  },
+  defaultValue: DashboardImg,
+};
+
 export const SupersetListViewCard = () => {
   return (
     <ListViewCard
       title="Superset Card Title"
+      loading={boolean('loading', false)}
       url="/superset/dashboard/births/"
-      imgURL={DashboardImg}
-      imgFallbackURL={ChartImg}
+      imgURL={text('imgURL', 'https://picsum.photos/800/600')}
+      imgFallbackURL={select(
+        imgFallbackKnob.label,
+        imgFallbackKnob.options,
+        imgFallbackKnob.defaultValue,
+      )}
       description="Lorem ipsum dolor sit amet, consectetur adipiscing elit..."
       coverLeft="Left Section"
       coverRight="Right Section"
diff --git a/superset-frontend/src/components/ListViewCard/ListViewCard.test.jsx b/superset-frontend/src/components/ListViewCard/ListViewCard.test.jsx
new file mode 100644
index 0000000..8387657
--- /dev/null
+++ b/superset-frontend/src/components/ListViewCard/ListViewCard.test.jsx
@@ -0,0 +1,69 @@
+/**
+ * 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 fetchMock from 'fetch-mock';
+
+import ListViewCard from 'src/components/ListViewCard';
+import ImageLoader from 'src/components/ListViewCard/ImageLoader';
+import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
+
+global.URL.createObjectURL = jest.fn(() => '/local_url');
+fetchMock.get('/thumbnail', { body: new Blob(), sendAsJson: false });
+
+describe('ListViewCard', () => {
+  const defaultProps = {
+    title: 'Card Title',
+    loading: false,
+    url: '/card-url',
+    imgURL: '/thumbnail',
+    imgFallbackURL: '/fallback',
+    description: 'Card Description',
+    coverLeft: 'Left Text',
+    coverRight: 'Right Text',
+    actions: (
+      <ListViewCard.Actions>
+        <div>Action 1</div>
+        <div>Action 2</div>
+      </ListViewCard.Actions>
+    ),
+  };
+
+  let wrapper;
+  const factory = (extraProps = {}) => {
+    const props = { ...defaultProps, ...extraProps };
+    return mount(<ListViewCard {...props} />);
+  };
+  beforeEach(async () => {
+    wrapper = factory();
+    await waitForComponentToPaint(wrapper);
+  });
+
+  it('is a valid element', () => {
+    expect(wrapper.find(ListViewCard)).toExist();
+  });
+
+  it('renders Actions', () => {
+    expect(wrapper.find(ListViewCard.Actions)).toExist();
+  });
+
+  it('renders and ImageLoader', () => {
+    expect(wrapper.find(ImageLoader)).toExist();
+  });
+});
diff --git a/superset-frontend/src/components/ListViewCard/index.tsx b/superset-frontend/src/components/ListViewCard/index.tsx
index f6c5af8..a668487 100644
--- a/superset-frontend/src/components/ListViewCard/index.tsx
+++ b/superset-frontend/src/components/ListViewCard/index.tsx
@@ -19,7 +19,8 @@
 import React from 'react';
 import styled from '@superset-ui/style';
 import Icon from 'src/components/Icon';
-import { Card } from 'src/common/components';
+import { Card, Skeleton, ThinSkeleton } from 'src/common/components';
+import ImageLoader from './ImageLoader';
 
 const MenuIcon = styled(Icon)`
   width: ${({ theme }) => theme.gridUnit * 4}px;
@@ -82,7 +83,7 @@ const GradientContainer = styled.div`
     );
   }
 `;
-const CardCoverImg = styled.img`
+const CardCoverImg = styled(ImageLoader)`
   display: block;
   object-fit: cover;
   width: 459px;
@@ -132,12 +133,22 @@ const CoverFooterRight = styled.div`
   text-overflow: ellipsis;
 `;
 
+const SkeletonTitle = styled(Skeleton.Input)`
+  width: ${({ theme }) => Math.trunc(theme.gridUnit * 62.5)}px;
+`;
+
+const SkeletonActions = styled(Skeleton.Button)`
+  width: ${({ theme }) => theme.gridUnit * 10}px;
+`;
+
+const paragraphConfig = { rows: 1, width: 150 };
 interface CardProps {
   title: React.ReactNode;
-  url: string | undefined;
+  url?: string;
   imgURL: string;
   imgFallbackURL: string;
   description: string;
+  loading: boolean;
   titleRight?: React.ReactNode;
   coverLeft?: React.ReactNode;
   coverRight?: React.ReactNode;
@@ -154,6 +165,7 @@ function ListViewCard({
   coverLeft,
   coverRight,
   actions,
+  loading,
 }: CardProps) {
   return (
     <StyledCard
@@ -163,31 +175,59 @@ function ListViewCard({
             <GradientContainer>
               <CardCoverImg
                 src={imgURL}
-                onError={e => {
-                  e.currentTarget.src = imgFallbackURL;
-                }}
+                fallback={imgFallbackURL}
+                isLoading={loading}
               />
             </GradientContainer>
           </a>
           <CoverFooter className="cover-footer">
-            {coverLeft && <CoverFooterLeft>{coverLeft}</CoverFooterLeft>}
-            {coverRight && <CoverFooterRight>{coverRight}</CoverFooterRight>}
+            {!loading && coverLeft && (
+              <CoverFooterLeft>{coverLeft}</CoverFooterLeft>
+            )}
+            {!loading && coverRight && (
+              <CoverFooterRight>{coverRight}</CoverFooterRight>
+            )}
           </CoverFooter>
         </Cover>
       }
     >
-      <Card.Meta
-        title={
-          <>
-            <TitleContainer>
-              <TitleLink href={url}>{title}</TitleLink>
-              {titleRight && <div className="title-right"> {titleRight}</div>}
-              <div className="card-actions">{actions}</div>
-            </TitleContainer>
-          </>
-        }
-        description={description}
-      />
+      {loading && (
+        <Card.Meta
+          title={
+            <>
+              <TitleContainer>
+                <SkeletonTitle active size="small" />
+                <div className="card-actions">
+                  <Skeleton.Button active shape="circle" />{' '}
+                  <SkeletonActions active />
+                </div>
+              </TitleContainer>
+            </>
+          }
+          description={
+            <ThinSkeleton
+              round
+              active
+              title={false}
+              paragraph={paragraphConfig}
+            />
+          }
+        />
+      )}
+      {!loading && (
+        <Card.Meta
+          title={
+            <>
+              <TitleContainer>
+                <TitleLink href={url}>{title}</TitleLink>
+                {titleRight && <div className="title-right"> {titleRight}</div>}
+                <div className="card-actions">{actions}</div>
+              </TitleContainer>
+            </>
+          }
+          description={description}
+        />
+      )}
     </StyledCard>
   );
 }
diff --git a/superset-frontend/src/views/CRUD/chart/ChartList.tsx b/superset-frontend/src/views/CRUD/chart/ChartList.tsx
index 5b2de1b..fb8d905 100644
--- a/superset-frontend/src/views/CRUD/chart/ChartList.tsx
+++ b/superset-frontend/src/views/CRUD/chart/ChartList.tsx
@@ -482,7 +482,7 @@ class ChartList extends React.PureComponent<Props, State> {
       });
   };
 
-  renderCard = (props: Chart) => {
+  renderCard = (props: Chart & { loading: boolean }) => {
     const menu = (
       <Menu>
         {this.canDelete && (
@@ -524,6 +524,7 @@ class ChartList extends React.PureComponent<Props, State> {
 
     return (
       <ListViewCard
+        loading={props.loading}
         title={props.slice_name}
         url={this.state.bulkSelectEnabled ? undefined : props.url}
         imgURL={props.thumbnail_url ?? ''}
diff --git a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx
index 6e6e467..437edbb 100644
--- a/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx
+++ b/superset-frontend/src/views/CRUD/dashboard/DashboardList.tsx
@@ -477,7 +477,7 @@ class DashboardList extends React.PureComponent<Props, State> {
     );
   }
 
-  renderCard = (props: Dashboard) => {
+  renderCard = (props: Dashboard & { loading: boolean }) => {
     const menu = (
       <Menu>
         {this.canDelete && (
@@ -529,12 +529,13 @@ class DashboardList extends React.PureComponent<Props, State> {
     return (
       <ListViewCard
         title={props.dashboard_title}
+        loading={props.loading}
         titleRight={<Label>{props.published ? 'published' : 'draft'}</Label>}
         url={this.state.bulkSelectEnabled ? undefined : props.url}
         imgURL={props.thumbnail_url}
         imgFallbackURL="/static/assets/images/dashboard-card-fallback.png"
         description={t('Last modified %s', props.changed_on_delta_humanized)}
-        coverLeft={props.owners.slice(0, 5).map(owner => (
+        coverLeft={(props.owners || []).slice(0, 5).map(owner => (
           <AvatarIcon
             key={owner.id}
             uniqueKey={`${owner.username}-${props.id}`}