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}`}