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": [ "public/app/features/plugins/admin/components/PluginDetailsSignature.tsx:5381": [
[0, 0, 0, "Use data-testid for E2E selectors instead of aria-label", "0"] [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": [ "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.", "0"],
[0, 0, 0, "Styles should be written using objects.", "1"], [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": [ "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.", "0"],
[0, 0, 0, "Do not use any type assertions.", "1"], [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"]
], ],
"public/app/features/plugins/admin/state/actions.ts:5381": [ "public/app/features/plugins/admin/state/actions.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"] [0, 0, 0, "Do not use any type assertions.", "0"]

View File

@ -1,5 +1,6 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import React, { HTMLAttributes } from 'react'; import React, { HTMLAttributes } from 'react';
import Skeleton from 'react-loading-skeleton';
import tinycolor from 'tinycolor2'; import tinycolor from 'tinycolor2';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
@ -18,7 +19,7 @@ export interface BadgeProps extends HTMLAttributes<HTMLDivElement> {
tooltip?: string; 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 styles = useStyles2(getStyles, color);
const badge = ( const badge = (
<div className={cx(styles.wrapper, className)} {...otherProps}> <div className={cx(styles.wrapper, className)} {...otherProps}>
@ -35,8 +36,27 @@ export const Badge = React.memo<BadgeProps>(({ icon, color, text, tooltip, class
badge 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) => { const getStyles = (theme: GrafanaTheme2, color: BadgeColor) => {
let sourceColor = theme.visualization.getColorByName(color); let sourceColor = theme.visualization.getColorByName(color);

View File

@ -11,16 +11,19 @@ import { PluginListItem } from './PluginListItem';
interface Props { interface Props {
plugins: CatalogPlugin[]; plugins: CatalogPlugin[];
displayMode: PluginListDisplayMode; displayMode: PluginListDisplayMode;
isLoading?: boolean;
} }
export const PluginList = ({ plugins, displayMode }: Props) => { export const PluginList = ({ plugins, displayMode, isLoading }: Props) => {
const isList = displayMode === PluginListDisplayMode.List; const isList = displayMode === PluginListDisplayMode.List;
const { pathname } = useLocation(); const { pathname } = useLocation();
const pathName = config.appSubUrl + (pathname.endsWith('/') ? pathname.slice(0, -1) : pathname); const pathName = config.appSubUrl + (pathname.endsWith('/') ? pathname.slice(0, -1) : pathname);
return ( return (
<Grid gap={3} {...(isList ? { columns: 1 } : { minColumnWidth: 34 })} data-testid="plugin-list"> <Grid gap={3} {...(isList ? { columns: 1 } : { minColumnWidth: 34 })} data-testid="plugin-list">
{plugins.map((plugin) => ( {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} /> <PluginListItem key={plugin.id} plugin={plugin} pathName={pathName} displayMode={displayMode} />
))} ))}
</Grid> </Grid>

View File

@ -1,8 +1,9 @@
import { css, cx } from '@emotion/css'; import { css, cx } from '@emotion/css';
import React from 'react'; import React from 'react';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data'; 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'; 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 // Styles shared between the different type of list items
export const getStyles = (theme: GrafanaTheme2) => { export const getStyles = (theme: GrafanaTheme2) => {
return { return {
container: css` container: css({
display: grid; display: 'grid',
grid-template-columns: ${LOGO_SIZE} 1fr ${theme.spacing(3)}; gridTemplateColumns: `${LOGO_SIZE} 1fr ${theme.spacing(3)}`,
grid-template-rows: auto; gridTemplateRows: 'auto',
gap: ${theme.spacing(2)}; gap: theme.spacing(2),
grid-auto-flow: row; gridAutoFlow: 'row',
background: ${theme.colors.background.secondary}; background: theme.colors.background.secondary,
border-radius: ${theme.shape.radius.default}; borderRadius: theme.shape.radius.default,
padding: ${theme.spacing(3)}; padding: theme.spacing(3),
transition: ${theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], { transition: theme.transitions.create(['background-color', 'box-shadow', 'border-color', 'color'], {
duration: theme.transitions.duration.short, duration: theme.transitions.duration.short,
})}; }),
&:hover { '&:hover': {
background: ${theme.colors.emphasize(theme.colors.background.secondary, 0.03)}; background: theme.colors.emphasize(theme.colors.background.secondary, 0.03),
} },
`, }),
list: css` list: css({
row-gap: 0px; rowGap: 0,
> img { '> img': {
align-self: start; alignSelf: 'start',
} },
> .plugin-content { '> .plugin-content': {
min-height: 0px; minHeight: 0,
grid-area: 2 / 2 / 4 / 3; gridArea: '2 / 2 / 4 / 3',
> p { '> p': {
margin: ${theme.spacing(0, 0, 0.5, 0)}; margin: theme.spacing(0, 0, 0.5, 0),
} },
} },
> .plugin-name { '> .plugin-name': {
align-self: center; alignSelf: 'center',
grid-area: 1 / 2 / 2 / 3; gridArea: '1 / 2 / 2 / 3',
} },
`, }),
pluginType: css` pluginType: css({
grid-area: 1 / 3 / 2 / 4; gridArea: '1 / 3 / 2 / 4',
color: ${theme.colors.text.secondary}; color: theme.colors.text.secondary,
`, }),
pluginLogo: css` pluginLogo: css({
grid-area: 1 / 1 / 3 / 2; gridArea: '1 / 1 / 3 / 2',
max-width: 100%; maxWidth: '100%',
align-self: center; alignSelf: 'center',
object-fit: contain; objectFit: 'contain',
`, }),
content: css` content: css({
grid-area: 3 / 1 / 4 / 3; gridArea: '3 / 1 / 4 / 3',
color: ${theme.colors.text.secondary}; color: theme.colors.text.secondary,
`, }),
name: css` name: css({
grid-area: 1 / 2 / 3 / 3; gridArea: '1 / 2 / 3 / 3',
align-self: center; alignSelf: 'center',
font-size: ${theme.typography.h4.fontSize}; fontSize: theme.typography.h4.fontSize,
color: ${theme.colors.text.primary}; color: theme.colors.text.primary,
margin: 0; margin: 0,
`, }),
}; };
}; };

View File

@ -8,6 +8,5 @@ type PluginLogoProps = {
}; };
export function PluginLogo({ alt, className, src, height }: PluginLogoProps): React.ReactElement { 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} />; 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 { SelectableValue, GrafanaTheme2, PluginType } from '@grafana/data';
import { locationSearchToObject } from '@grafana/runtime'; 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 { Page } from 'app/core/components/Page/Page';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { getNavModel } from 'app/core/selectors/navModel'; import { getNavModel } from 'app/core/selectors/navModel';
@ -159,16 +159,7 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
</HorizontalGroup> </HorizontalGroup>
</HorizontalGroup> </HorizontalGroup>
<div className={styles.listWrap}> <div className={styles.listWrap}>
{isLoading ? ( <PluginList plugins={plugins} displayMode={displayMode} isLoading={isLoading} />
<LoadingPlaceholder
className={css`
margin-bottom: 0;
`}
text="Loading results"
/>
) : (
<PluginList plugins={plugins} displayMode={displayMode} />
)}
</div> </div>
</Page.Contents> </Page.Contents>
</Page> </Page>
@ -176,17 +167,17 @@ export default function Browse({ route }: GrafanaRouteComponentProps): ReactElem
} }
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (theme: GrafanaTheme2) => ({
actionBar: css` actionBar: css({
${theme.breakpoints.up('xl')} { [theme.breakpoints.up('xl')]: {
margin-left: auto; marginLeft: 'auto',
} },
`, }),
listWrap: css` listWrap: css({
margin-top: ${theme.spacing(2)}; marginTop: theme.spacing(2),
`, }),
displayAs: css` displayAs: css({
svg { svg: {
margin-right: 0; marginRight: 0,
} },
`, }),
}); });