mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
PluginList: Add plugin list loading skeleton (#79012)
* add plugin skeleton * lineHeight: 1 instead of 0
This commit is contained in:
parent
5efa85e634
commit
7cdddb2790
@ -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"]
|
||||||
|
@ -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);
|
||||||
|
@ -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>
|
||||||
|
@ -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,
|
||||||
`,
|
}),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -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} />;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
}
|
},
|
||||||
`,
|
}),
|
||||||
});
|
});
|
||||||
|
Loading…
Reference in New Issue
Block a user