PluginList: Add plugin list loading skeleton (#79012)

* add plugin skeleton

* lineHeight: 1 instead of 0
This commit is contained in:
Ashley Harrison 2023-12-05 16:39:23 +00:00 committed by GitHub
parent 5efa85e634
commit 7cdddb2790
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 137 additions and 98 deletions

View File

@ -4258,14 +4258,6 @@ exports[`better eslint`] = {
"public/app/features/plugins/admin/components/PluginDetailsSignature.tsx:5381": [
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"]
],
"public/app/features/plugins/admin/components/PluginListItem.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"]
],
"public/app/features/plugins/admin/components/PluginSignatureDetailsBadge.tsx:5381": [
[0, 0, 0, "Styles should be written using objects.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"],
@ -4292,11 +4284,7 @@ exports[`better eslint`] = {
],
"public/app/features/plugins/admin/pages/Browse.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"],
[0, 0, 0, "Styles should be written using objects.", "2"],
[0, 0, 0, "Styles should be written using objects.", "3"],
[0, 0, 0, "Styles should be written using objects.", "4"],
[0, 0, 0, "Styles should be written using objects.", "5"]
[0, 0, 0, "Do not use any type assertions.", "1"]
],
"public/app/features/plugins/admin/state/actions.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]

View File

@ -1,5 +1,6 @@
import { css, cx } from '@emotion/css';
import React, { HTMLAttributes } from 'react';
import Skeleton from 'react-loading-skeleton';
import tinycolor from 'tinycolor2';
import { GrafanaTheme2 } from '@grafana/data';
@ -18,7 +19,7 @@ export interface BadgeProps extends HTMLAttributes<HTMLDivElement> {
tooltip?: string;
}
export const Badge = React.memo<BadgeProps>(({ icon, color, text, tooltip, className, ...otherProps }) => {
const BadgeComponent = React.memo<BadgeProps>(({ icon, color, text, tooltip, className, ...otherProps }) => {
const styles = useStyles2(getStyles, color);
const badge = (
<div className={cx(styles.wrapper, className)} {...otherProps}>
@ -35,8 +36,27 @@ export const Badge = React.memo<BadgeProps>(({ icon, color, text, tooltip, class
badge
);
});
BadgeComponent.displayName = 'Badge';
Badge.displayName = 'Badge';
const BadgeSkeleton = () => {
const styles = useStyles2(getSkeletonStyles);
return <Skeleton width={60} height={22} containerClassName={styles.container} />;
};
interface BadgeWithSkeleton extends React.NamedExoticComponent<BadgeProps> {
Skeleton: typeof BadgeSkeleton;
}
export const Badge: BadgeWithSkeleton = Object.assign(BadgeComponent, { Skeleton: BadgeSkeleton });
Badge.Skeleton = BadgeSkeleton;
const getSkeletonStyles = () => ({
container: css({
lineHeight: 1,
}),
});
const getStyles = (theme: GrafanaTheme2, color: BadgeColor) => {
let sourceColor = theme.visualization.getColorByName(color);

View File

@ -11,18 +11,21 @@ import { PluginListItem } from './PluginListItem';
interface Props {
plugins: CatalogPlugin[];
displayMode: PluginListDisplayMode;
isLoading?: boolean;
}
export const PluginList = ({ plugins, displayMode }: Props) => {
export const PluginList = ({ plugins, displayMode, isLoading }: Props) => {
const isList = displayMode === PluginListDisplayMode.List;
const { pathname } = useLocation();
const pathName = config.appSubUrl + (pathname.endsWith('/') ? pathname.slice(0, -1) : pathname);
return (
<Grid gap={3} {...(isList ? { columns: 1 } : { minColumnWidth: 34 })} data-testid="plugin-list">
{plugins.map((plugin) => (
<PluginListItem key={plugin.id} plugin={plugin} pathName={pathName} displayMode={displayMode} />
))}
{isLoading
? new Array(50).fill(null).map((_, index) => <PluginListItem.Skeleton key={index} displayMode={displayMode} />)
: plugins.map((plugin) => (
<PluginListItem key={plugin.id} plugin={plugin} pathName={pathName} displayMode={displayMode} />
))}
</Grid>
);
};

View File

@ -1,8 +1,9 @@
import { css, cx } from '@emotion/css';
import React from 'react';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
import { Icon, useStyles2 } from '@grafana/ui';
import { Badge, Icon, Stack, useStyles2 } from '@grafana/ui';
import { CatalogPlugin, PluginIconName, PluginListDisplayMode } from '../types';
@ -36,67 +37,104 @@ export function PluginListItem({ plugin, pathName, displayMode = PluginListDispl
);
}
const PluginListItemSkeleton = ({ displayMode = PluginListDisplayMode.Grid }: Pick<Props, 'displayMode'>) => {
const styles = useStyles2(getStyles);
const isList = displayMode === PluginListDisplayMode.List;
return (
<div className={cx(styles.container, { [styles.list]: isList })}>
<Skeleton
containerClassName={cx(
styles.pluginLogo,
css({
lineHeight: 1,
})
)}
width={LOGO_SIZE}
height={LOGO_SIZE}
/>
<h2 className={styles.name}>
<Skeleton width={100} />
</h2>
<div className={styles.content}>
<p>
<Skeleton width={120} />
</p>
<Stack direction="row">
<Badge.Skeleton />
<Badge.Skeleton />
</Stack>
</div>
<div className={styles.pluginType}>
<Skeleton width={16} height={16} />
</div>
</div>
);
};
PluginListItem.Skeleton = PluginListItemSkeleton;
// Styles shared between the different type of list items
export const getStyles = (theme: GrafanaTheme2) => {
return {
container: css`
display: grid;
grid-template-columns: ${LOGO_SIZE} 1fr ${theme.spacing(3)};
grid-template-rows: auto;
gap: ${theme.spacing(2)};
grid-auto-flow: row;
background: ${theme.colors.background.secondary};
border-radius: ${theme.shape.radius.default};
padding: ${theme.spacing(3)};
transition: ${theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], {
container: css({
display: 'grid',
gridTemplateColumns: `${LOGO_SIZE} 1fr ${theme.spacing(3)}`,
gridTemplateRows: 'auto',
gap: theme.spacing(2),
gridAutoFlow: 'row',
background: theme.colors.background.secondary,
borderRadius: theme.shape.radius.default,
padding: theme.spacing(3),
transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], {
duration: theme.transitions.duration.short,
})};
}),
&:hover {
background: ${theme.colors.emphasize(theme.colors.background.secondary, 0.03)};
}
`,
list: css`
row-gap: 0px;
'&:hover': {
background: theme.colors.emphasize(theme.colors.background.secondary, 0.03),
},
}),
list: css({
rowGap: 0,
> img {
align-self: start;
}
'> img': {
alignSelf: 'start',
},
> .plugin-content {
min-height: 0px;
grid-area: 2 / 2 / 4 / 3;
'> .plugin-content': {
minHeight: 0,
gridArea: '2 / 2 / 4 / 3',
> p {
margin: ${theme.spacing(0, 0, 0.5, 0)};
}
}
'> p': {
margin: theme.spacing(0, 0, 0.5, 0),
},
},
> .plugin-name {
align-self: center;
grid-area: 1 / 2 / 2 / 3;
}
`,
pluginType: css`
grid-area: 1 / 3 / 2 / 4;
color: ${theme.colors.text.secondary};
`,
pluginLogo: css`
grid-area: 1 / 1 / 3 / 2;
max-width: 100%;
align-self: center;
object-fit: contain;
`,
content: css`
grid-area: 3 / 1 / 4 / 3;
color: ${theme.colors.text.secondary};
`,
name: css`
grid-area: 1 / 2 / 3 / 3;
align-self: center;
font-size: ${theme.typography.h4.fontSize};
color: ${theme.colors.text.primary};
margin: 0;
`,
'> .plugin-name': {
alignSelf: 'center',
gridArea: '1 / 2 / 2 / 3',
},
}),
pluginType: css({
gridArea: '1 / 3 / 2 / 4',
color: theme.colors.text.secondary,
}),
pluginLogo: css({
gridArea: '1 / 1 / 3 / 2',
maxWidth: '100%',
alignSelf: 'center',
objectFit: 'contain',
}),
content: css({
gridArea: '3 / 1 / 4 / 3',
color: theme.colors.text.secondary,
}),
name: css({
gridArea: '1 / 2 / 3 / 3',
alignSelf: 'center',
fontSize: theme.typography.h4.fontSize,
color: theme.colors.text.primary,
margin: 0,
}),
};
};

View File

@ -8,6 +8,5 @@ type PluginLogoProps = {
};
export function PluginLogo({ alt, className, src, height }: PluginLogoProps): React.ReactElement {
// @ts-ignore - react doesn't know about loading attr.
return <img src={src} className={className} alt={alt} loading="lazy" height={height} />;
}

View File

@ -4,7 +4,7 @@ import { useLocation } from 'react-router-dom';
import { SelectableValue, GrafanaTheme2, PluginType } from '@grafana/data';
import { locationSearchToObject } from '@grafana/runtime';
import { LoadingPlaceholder, Select, RadioButtonGroup, useStyles2, Tooltip, Field } from '@grafana/ui';
import { Select, RadioButtonGroup, useStyles2, Tooltip, Field } from '@grafana/ui';
import { Page } from 'app/core/components/Page/Page';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { getNavModel } from 'app/core/selectors/navModel';
@ -159,16 +159,7 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
</HorizontalGroup>
</HorizontalGroup>
<div className={styles.listWrap}>
{isLoading ? (
<LoadingPlaceholder
className={css`
margin-bottom: 0;
`}
text="Loading results"
/>
) : (
<PluginList plugins={plugins} displayMode={displayMode} />
)}
<PluginList plugins={plugins} displayMode={displayMode} isLoading={isLoading} />
</div>
</Page.Contents>
</Page>
@ -176,17 +167,17 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
}
const getStyles = (theme: GrafanaTheme2) => ({
actionBar: css`
${theme.breakpoints.up('xl')} {
margin-left: auto;
}
`,
listWrap: css`
margin-top: ${theme.spacing(2)};
`,
displayAs: css`
svg {
margin-right: 0;
}
`,
actionBar: css({
[theme.breakpoints.up('xl')]: {
marginLeft: 'auto',
},
}),
listWrap: css({
marginTop: theme.spacing(2),
}),
displayAs: css({
svg: {
marginRight: 0,
},
}),
});