feat(listview): skeleton loading states for table and card collections (#10606)

This commit is contained in:
ʈᵃᵢ
2020-08-21 10:32:37 -07:00
committed by GitHub
parent 878f06d133
commit b86c0e5727
18 changed files with 456 additions and 95 deletions

View File

@@ -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: [

View File

@@ -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",

View File

@@ -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",

View File

@@ -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"
}

View File

@@ -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} />, {

View File

@@ -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} />, {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 ?? ''}

View File

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