PlaylistPage: Skeleton loading state 💀 (#77992)

* playlist skeleton poc

* refactor into PlaylistCard

* make card actions more responsive, update skeleton color to work on secondary background

* don't loop over array

* fix unit tests
This commit is contained in:
Ashley Harrison 2023-11-13 10:32:42 +00:00 committed by GitHub
parent eca45f6492
commit bd85d3e25e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 201 additions and 120 deletions

View File

@ -283,22 +283,22 @@ const BaseActions = ({ children, disabled, variant, className }: ActionsProps) =
const getActionStyles = (theme: GrafanaTheme2) => ({ const getActionStyles = (theme: GrafanaTheme2) => ({
actions: css({ actions: css({
display: 'flex',
flexDirection: 'row',
flexWrap: 'wrap',
gap: theme.spacing(1),
gridArea: 'Actions', gridArea: 'Actions',
marginTop: theme.spacing(2), marginTop: theme.spacing(2),
'& > *': {
marginRight: theme.spacing(1),
},
}), }),
secondaryActions: css({ secondaryActions: css({
display: 'flex',
gridArea: 'Secondary',
alignSelf: 'center', alignSelf: 'center',
color: theme.colors.text.secondary, color: theme.colors.text.secondary,
marginTtop: theme.spacing(2), display: 'flex',
flexDirection: 'row',
'& > *': { flexWrap: 'wrap',
marginRight: `${theme.spacing(1)} !important`, gap: theme.spacing(1),
}, gridArea: 'Secondary',
marginTop: theme.spacing(2),
}), }),
}); });

View File

@ -23,8 +23,8 @@ export const ThemeProvider = ({ children, value }: { children: React.ReactNode;
return ( return (
<ThemeContext.Provider value={theme}> <ThemeContext.Provider value={theme}>
<SkeletonTheme <SkeletonTheme
baseColor={theme.colors.background.secondary} baseColor={theme.colors.emphasize(theme.colors.background.secondary)}
highlightColor={theme.colors.emphasize(theme.colors.background.secondary)} highlightColor={theme.colors.emphasize(theme.colors.background.secondary, 0.1)}
borderRadius={theme.shape.radius.default} borderRadius={theme.shape.radius.default}
> >
{children} {children}

View File

@ -0,0 +1,95 @@
import { css } from '@emotion/css';
import React from 'react';
import Skeleton from 'react-loading-skeleton';
import { GrafanaTheme2 } from '@grafana/data';
import { Button, Card, LinkButton, ModalsController, Stack, useStyles2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { contextSrv } from 'app/core/services/context_srv';
import { DashNavButton } from 'app/features/dashboard/components/DashNav/DashNavButton';
import { ShareModal } from './ShareModal';
import { Playlist } from './types';
interface Props {
setStartPlaylist: (playlistItem: Playlist) => void;
setPlaylistToDelete: (playlistItem: Playlist) => void;
playlist: Playlist;
}
export const PlaylistCard = ({ playlist, setStartPlaylist, setPlaylistToDelete }: Props) => {
return (
<Card>
<Card.Heading>
{playlist.name}
<ModalsController key="button-share">
{({ showModal, hideModal }) => (
<DashNavButton
tooltip={t('playlist-page.card.tooltip', 'Share playlist')}
icon="share-alt"
iconSize="lg"
onClick={() => {
showModal(ShareModal, {
playlistUid: playlist.uid,
onDismiss: hideModal,
});
}}
/>
)}
</ModalsController>
</Card.Heading>
<Card.Actions>
<Button variant="secondary" icon="play" onClick={() => setStartPlaylist(playlist)}>
<Trans i18nKey="playlist-page.card.start">Start playlist</Trans>
</Button>
{contextSrv.isEditor && (
<>
<LinkButton key="edit" variant="secondary" href={`/playlists/edit/${playlist.uid}`} icon="cog">
<Trans i18nKey="playlist-page.card.edit">Edit playlist</Trans>
</LinkButton>
<Button
disabled={false}
onClick={() => setPlaylistToDelete(playlist)}
icon="trash-alt"
variant="destructive"
>
<Trans i18nKey="playlist-page.card.delete">Delete playlist</Trans>
</Button>
</>
)}
</Card.Actions>
</Card>
);
};
const PlaylistCardSkeleton = () => {
const skeletonStyles = useStyles2(getSkeletonStyles);
return (
<Card>
<Card.Heading>
<Skeleton width={140} />
</Card.Heading>
<Card.Actions>
<Stack direction="row">
<Skeleton containerClassName={skeletonStyles.button} width={142} height={32} />
{contextSrv.isEditor && (
<>
<Skeleton containerClassName={skeletonStyles.button} width={135} height={32} />
<Skeleton containerClassName={skeletonStyles.button} width={153} height={32} />
</>
)}
</Stack>
</Card.Actions>
</Card>
);
};
PlaylistCard.Skeleton = PlaylistCardSkeleton;
function getSkeletonStyles(theme: GrafanaTheme2) {
return {
button: css({
lineHeight: 1,
}),
};
}

View File

@ -1,4 +1,4 @@
import { render, waitFor } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import React from 'react'; import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider'; import { TestProvider } from 'test/helpers/TestProvider';
@ -21,7 +21,7 @@ jest.mock('app/core/services/context_srv', () => ({
}, },
})); }));
function getTestContext() { function setup() {
return render( return render(
<TestProvider> <TestProvider>
<PlaylistPage /> <PlaylistPage />
@ -37,30 +37,34 @@ describe('PlaylistPage', () => {
describe('when mounted without a playlist', () => { describe('when mounted without a playlist', () => {
it('page should load', () => { it('page should load', () => {
fnMock.mockResolvedValue([]); fnMock.mockResolvedValue([]);
const { getByText } = getTestContext(); setup();
expect(getByText(/loading/i)).toBeInTheDocument(); expect(screen.getByTestId('playlist-page-list-skeleton')).toBeInTheDocument();
}); });
it('then show empty list', async () => { it('then show empty list', async () => {
const { getByText } = getTestContext(); setup();
await waitFor(() => getByText('There are no playlists created yet')); expect(await screen.findByText('There are no playlists created yet')).toBeInTheDocument();
}); });
describe('and signed in user is not a viewer', () => { describe('and signed in user is not a viewer', () => {
it('then create playlist button should not be disabled', async () => { it('then create playlist button should not be disabled', async () => {
contextSrv.isEditor = true; contextSrv.isEditor = true;
const { getByRole } = getTestContext(); setup();
const createPlaylistButton = await waitFor(() => getByRole('link', { name: /create playlist/i })); const createPlaylistButton = await screen.findByRole('link', { name: /create playlist/i });
expect(createPlaylistButton).not.toHaveStyle('pointer-events: none'); expect(createPlaylistButton).not.toHaveStyle('pointer-events: none');
}); });
}); });
describe('and signed in user is a viewer', () => { describe('and signed in user is a viewer', () => {
it('then create playlist button should be disabled', async () => { it('then create playlist button should be disabled', async () => {
contextSrv.isEditor = false; contextSrv.isEditor = false;
const { getByRole } = getTestContext(); setup();
const createPlaylistButton = await waitFor(() => getByRole('link', { name: /create playlist/i })); const createPlaylistButton = await screen.findByRole('link', { name: /create playlist/i });
expect(createPlaylistButton).toHaveStyle('pointer-events: none'); expect(createPlaylistButton).toHaveStyle('pointer-events: none');
}); });
}); });
}); });
describe('when mounted with a playlist', () => { describe('when mounted with a playlist', () => {
it('page should load', () => { it('page should load', () => {
fnMock.mockResolvedValue([ fnMock.mockResolvedValue([
@ -76,29 +80,31 @@ describe('PlaylistPage', () => {
uid: 'playlist-0', uid: 'playlist-0',
}, },
]); ]);
const { getByText } = getTestContext(); setup();
expect(getByText(/loading/i)).toBeInTheDocument(); expect(screen.getByTestId('playlist-page-list-skeleton')).toBeInTheDocument();
}); });
describe('and signed in user is not a viewer', () => { describe('and signed in user is not a viewer', () => {
it('then playlist title and all playlist buttons should appear on the page', async () => { it('then playlist title and all playlist buttons should appear on the page', async () => {
contextSrv.isEditor = true; contextSrv.isEditor = true;
const { getByRole, getByText } = getTestContext(); setup();
await waitFor(() => getByText('A test playlist')); expect(await screen.findByText('A test playlist'));
expect(getByRole('link', { name: /New playlist/i })).toBeInTheDocument(); expect(await screen.findByRole('link', { name: /New playlist/i })).toBeInTheDocument();
expect(getByRole('button', { name: /Start playlist/i })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: /Start playlist/i })).toBeInTheDocument();
expect(getByRole('link', { name: /Edit playlist/i })).toBeInTheDocument(); expect(await screen.findByRole('link', { name: /Edit playlist/i })).toBeInTheDocument();
expect(getByRole('button', { name: /Delete playlist/i })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: /Delete playlist/i })).toBeInTheDocument();
}); });
}); });
describe('and signed in user is a viewer', () => { describe('and signed in user is a viewer', () => {
it('then playlist title and only start playlist button should appear on the page', async () => { it('then playlist title and only start playlist button should appear on the page', async () => {
contextSrv.isEditor = false; contextSrv.isEditor = false;
const { getByRole, getByText, queryByRole } = getTestContext(); setup();
await waitFor(() => getByText('A test playlist')); expect(await screen.findByText('A test playlist')).toBeInTheDocument();
expect(queryByRole('link', { name: /New playlist/i })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: /New playlist/i })).not.toBeInTheDocument();
expect(getByRole('button', { name: /Start playlist/i })).toBeInTheDocument(); expect(await screen.findByRole('button', { name: /Start playlist/i })).toBeInTheDocument();
expect(queryByRole('link', { name: /Edit playlist/i })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: /Edit playlist/i })).not.toBeInTheDocument();
expect(queryByRole('button', { name: /Delete playlist/i })).not.toBeInTheDocument(); expect(screen.queryByRole('button', { name: /Delete playlist/i })).not.toBeInTheDocument();
}); });
}); });
}); });

View File

@ -1,11 +1,11 @@
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { useAsync } from 'react-use'; import { useAsync } from 'react-use';
import { ConfirmModal } from '@grafana/ui'; import { ConfirmModal, LinkButton } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA'; import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { Page } from 'app/core/components/Page/Page'; import { Page } from 'app/core/components/Page/Page';
import PageActionBar from 'app/core/components/PageActionBar/PageActionBar'; import PageActionBar from 'app/core/components/PageActionBar/PageActionBar';
import { t } from 'app/core/internationalization'; import { Trans, t } from 'app/core/internationalization';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { EmptyQueryListBanner } from './EmptyQueryListBanner'; import { EmptyQueryListBanner } from './EmptyQueryListBanner';
@ -50,23 +50,26 @@ export const PlaylistPage = () => {
/> />
); );
const showSearch = playlists.length > 0 || searchQuery.length > 0; const showSearch = allPlaylists.loading || playlists.length > 0 || searchQuery.length > 0;
return ( return (
<Page navId="dashboards/playlists"> <Page
<Page.Contents isLoading={allPlaylists.loading}> actions={
{showSearch && ( contextSrv.isEditor ? (
<PageActionBar <LinkButton href="/playlists/new">
searchQuery={searchQuery} <Trans i18nKey="playlist-page.create-button.title">New playlist</Trans>
linkButton={ </LinkButton>
contextSrv.isEditor ) : undefined
? { title: t('playlist-page.create-button.title', 'New playlist'), href: '/playlists/new' }
: undefined
} }
setSearchQuery={setSearchQuery} navId="dashboards/playlists"
/> >
)} <Page.Contents>
{showSearch && <PageActionBar searchQuery={searchQuery} setSearchQuery={setSearchQuery} />}
{allPlaylists.loading ? (
<PlaylistPageList.Skeleton />
) : (
<>
{!hasPlaylists && searchQuery ? ( {!hasPlaylists && searchQuery ? (
<EmptyQueryListBanner /> <EmptyQueryListBanner />
) : ( ) : (
@ -90,6 +93,8 @@ export const PlaylistPage = () => {
/> />
)} )}
{startPlaylist && <StartModal playlist={startPlaylist} onDismiss={() => setStartPlaylist(undefined)} />} {startPlaylist && <StartModal playlist={startPlaylist} onDismiss={() => setStartPlaylist(undefined)} />}
</>
)}
</Page.Contents> </Page.Contents>
</Page> </Page>
); );

View File

@ -2,72 +2,47 @@ import { css } from '@emotion/css';
import React from 'react'; import React from 'react';
import { GrafanaTheme2 } from '@grafana/data'; import { GrafanaTheme2 } from '@grafana/data';
import { Button, Card, LinkButton, ModalsController, useStyles2 } from '@grafana/ui'; import { useStyles2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { contextSrv } from 'app/core/services/context_srv';
import { DashNavButton } from 'app/features/dashboard/components/DashNav/DashNavButton';
import { ShareModal } from './ShareModal'; import { PlaylistCard } from './PlaylistCard';
import { Playlist } from './types'; import { Playlist } from './types';
interface Props { interface Props {
setStartPlaylist: (playlistItem: Playlist) => void; setStartPlaylist: (playlistItem: Playlist) => void;
setPlaylistToDelete: (playlistItem: Playlist) => void; setPlaylistToDelete: (playlistItem: Playlist) => void;
playlists: Playlist[] | undefined; playlists: Playlist[];
} }
export const PlaylistPageList = ({ playlists, setStartPlaylist, setPlaylistToDelete }: Props) => { export const PlaylistPageList = ({ playlists, setStartPlaylist, setPlaylistToDelete }: Props) => {
const styles = useStyles2(getStyles); const styles = useStyles2(getStyles);
return ( return (
<ul className={styles.list}> <ul className={styles.list}>
{playlists!.map((playlist: Playlist) => ( {playlists.map((playlist: Playlist) => (
<li className={styles.listItem} key={playlist.uid}> <li className={styles.listItem} key={playlist.uid}>
<Card> <PlaylistCard
<Card.Heading> playlist={playlist}
{playlist.name} setStartPlaylist={setStartPlaylist}
<ModalsController key="button-share"> setPlaylistToDelete={setPlaylistToDelete}
{({ showModal, hideModal }) => (
<DashNavButton
tooltip={t('playlist-page.card.tooltip', 'Share playlist')}
icon="share-alt"
iconSize="lg"
onClick={() => {
showModal(ShareModal, {
playlistUid: playlist.uid,
onDismiss: hideModal,
});
}}
/> />
)}
</ModalsController>
</Card.Heading>
<Card.Actions>
<Button variant="secondary" icon="play" onClick={() => setStartPlaylist(playlist)}>
<Trans i18nKey="playlist-page.card.start">Start playlist</Trans>
</Button>
{contextSrv.isEditor && (
<>
<LinkButton key="edit" variant="secondary" href={`/playlists/edit/${playlist.uid}`} icon="cog">
<Trans i18nKey="playlist-page.card.edit">Edit playlist</Trans>
</LinkButton>
<Button
disabled={false}
onClick={() => setPlaylistToDelete(playlist)}
icon="trash-alt"
variant="destructive"
>
<Trans i18nKey="playlist-page.card.delete">Delete playlist</Trans>
</Button>
</>
)}
</Card.Actions>
</Card>
</li> </li>
))} ))}
</ul> </ul>
); );
}; };
const PlaylistPageListSkeleton = () => {
const styles = useStyles2(getStyles);
return (
<div data-testid="playlist-page-list-skeleton" className={styles.list}>
<PlaylistCard.Skeleton />
<PlaylistCard.Skeleton />
<PlaylistCard.Skeleton />
</div>
);
};
PlaylistPageList.Skeleton = PlaylistPageListSkeleton;
function getStyles(theme: GrafanaTheme2) { function getStyles(theme: GrafanaTheme2) {
return { return {
list: css({ list: css({