diff --git a/packages/grafana-ui/src/components/Card/Card.tsx b/packages/grafana-ui/src/components/Card/Card.tsx index 0f34721e031..858d985383f 100644 --- a/packages/grafana-ui/src/components/Card/Card.tsx +++ b/packages/grafana-ui/src/components/Card/Card.tsx @@ -283,22 +283,22 @@ const BaseActions = ({ children, disabled, variant, className }: ActionsProps) = const getActionStyles = (theme: GrafanaTheme2) => ({ actions: css({ + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + gap: theme.spacing(1), gridArea: 'Actions', marginTop: theme.spacing(2), - '& > *': { - marginRight: theme.spacing(1), - }, }), secondaryActions: css({ - display: 'flex', - gridArea: 'Secondary', alignSelf: 'center', color: theme.colors.text.secondary, - marginTtop: theme.spacing(2), - - '& > *': { - marginRight: `${theme.spacing(1)} !important`, - }, + display: 'flex', + flexDirection: 'row', + flexWrap: 'wrap', + gap: theme.spacing(1), + gridArea: 'Secondary', + marginTop: theme.spacing(2), }), }); diff --git a/public/app/core/utils/ConfigProvider.tsx b/public/app/core/utils/ConfigProvider.tsx index 3a8e5c4f99e..9febaaff054 100644 --- a/public/app/core/utils/ConfigProvider.tsx +++ b/public/app/core/utils/ConfigProvider.tsx @@ -23,8 +23,8 @@ export const ThemeProvider = ({ children, value }: { children: React.ReactNode; return ( {children} diff --git a/public/app/features/playlist/PlaylistCard.tsx b/public/app/features/playlist/PlaylistCard.tsx new file mode 100644 index 00000000000..289d509861a --- /dev/null +++ b/public/app/features/playlist/PlaylistCard.tsx @@ -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 ( + + + {playlist.name} + + {({ showModal, hideModal }) => ( + { + showModal(ShareModal, { + playlistUid: playlist.uid, + onDismiss: hideModal, + }); + }} + /> + )} + + + + + {contextSrv.isEditor && ( + <> + + Edit playlist + + + + )} + + + ); +}; + +const PlaylistCardSkeleton = () => { + const skeletonStyles = useStyles2(getSkeletonStyles); + return ( + + + + + + + + {contextSrv.isEditor && ( + <> + + + + )} + + + + ); +}; + +PlaylistCard.Skeleton = PlaylistCardSkeleton; + +function getSkeletonStyles(theme: GrafanaTheme2) { + return { + button: css({ + lineHeight: 1, + }), + }; +} diff --git a/public/app/features/playlist/PlaylistPage.test.tsx b/public/app/features/playlist/PlaylistPage.test.tsx index 347af817756..21312227f6c 100644 --- a/public/app/features/playlist/PlaylistPage.test.tsx +++ b/public/app/features/playlist/PlaylistPage.test.tsx @@ -1,4 +1,4 @@ -import { render, waitFor } from '@testing-library/react'; +import { render, screen } from '@testing-library/react'; import React from 'react'; import { TestProvider } from 'test/helpers/TestProvider'; @@ -21,7 +21,7 @@ jest.mock('app/core/services/context_srv', () => ({ }, })); -function getTestContext() { +function setup() { return render( @@ -37,30 +37,34 @@ describe('PlaylistPage', () => { describe('when mounted without a playlist', () => { it('page should load', () => { fnMock.mockResolvedValue([]); - const { getByText } = getTestContext(); - expect(getByText(/loading/i)).toBeInTheDocument(); + setup(); + expect(screen.getByTestId('playlist-page-list-skeleton')).toBeInTheDocument(); }); + it('then show empty list', async () => { - const { getByText } = getTestContext(); - await waitFor(() => getByText('There are no playlists created yet')); + setup(); + expect(await screen.findByText('There are no playlists created yet')).toBeInTheDocument(); }); + describe('and signed in user is not a viewer', () => { it('then create playlist button should not be disabled', async () => { contextSrv.isEditor = true; - const { getByRole } = getTestContext(); - const createPlaylistButton = await waitFor(() => getByRole('link', { name: /create playlist/i })); + setup(); + const createPlaylistButton = await screen.findByRole('link', { name: /create playlist/i }); expect(createPlaylistButton).not.toHaveStyle('pointer-events: none'); }); }); + describe('and signed in user is a viewer', () => { it('then create playlist button should be disabled', async () => { contextSrv.isEditor = false; - const { getByRole } = getTestContext(); - const createPlaylistButton = await waitFor(() => getByRole('link', { name: /create playlist/i })); + setup(); + const createPlaylistButton = await screen.findByRole('link', { name: /create playlist/i }); expect(createPlaylistButton).toHaveStyle('pointer-events: none'); }); }); }); + describe('when mounted with a playlist', () => { it('page should load', () => { fnMock.mockResolvedValue([ @@ -76,29 +80,31 @@ describe('PlaylistPage', () => { uid: 'playlist-0', }, ]); - const { getByText } = getTestContext(); - expect(getByText(/loading/i)).toBeInTheDocument(); + setup(); + expect(screen.getByTestId('playlist-page-list-skeleton')).toBeInTheDocument(); }); + describe('and signed in user is not a viewer', () => { it('then playlist title and all playlist buttons should appear on the page', async () => { contextSrv.isEditor = true; - const { getByRole, getByText } = getTestContext(); - await waitFor(() => getByText('A test playlist')); - expect(getByRole('link', { name: /New playlist/i })).toBeInTheDocument(); - expect(getByRole('button', { name: /Start playlist/i })).toBeInTheDocument(); - expect(getByRole('link', { name: /Edit playlist/i })).toBeInTheDocument(); - expect(getByRole('button', { name: /Delete playlist/i })).toBeInTheDocument(); + setup(); + expect(await screen.findByText('A test playlist')); + expect(await screen.findByRole('link', { name: /New playlist/i })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /Start playlist/i })).toBeInTheDocument(); + expect(await screen.findByRole('link', { name: /Edit playlist/i })).toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /Delete playlist/i })).toBeInTheDocument(); }); }); + describe('and signed in user is a viewer', () => { it('then playlist title and only start playlist button should appear on the page', async () => { contextSrv.isEditor = false; - const { getByRole, getByText, queryByRole } = getTestContext(); - await waitFor(() => getByText('A test playlist')); - expect(queryByRole('link', { name: /New playlist/i })).not.toBeInTheDocument(); - expect(getByRole('button', { name: /Start playlist/i })).toBeInTheDocument(); - expect(queryByRole('link', { name: /Edit playlist/i })).not.toBeInTheDocument(); - expect(queryByRole('button', { name: /Delete playlist/i })).not.toBeInTheDocument(); + setup(); + expect(await screen.findByText('A test playlist')).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /New playlist/i })).not.toBeInTheDocument(); + expect(await screen.findByRole('button', { name: /Start playlist/i })).toBeInTheDocument(); + expect(screen.queryByRole('link', { name: /Edit playlist/i })).not.toBeInTheDocument(); + expect(screen.queryByRole('button', { name: /Delete playlist/i })).not.toBeInTheDocument(); }); }); }); diff --git a/public/app/features/playlist/PlaylistPage.tsx b/public/app/features/playlist/PlaylistPage.tsx index 6c01ac2dcc8..d8231f0f7ff 100644 --- a/public/app/features/playlist/PlaylistPage.tsx +++ b/public/app/features/playlist/PlaylistPage.tsx @@ -1,11 +1,11 @@ import React, { useMemo, useState } from 'react'; 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 { Page } from 'app/core/components/Page/Page'; 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 { EmptyQueryListBanner } from './EmptyQueryListBanner'; @@ -50,46 +50,51 @@ export const PlaylistPage = () => { /> ); - const showSearch = playlists.length > 0 || searchQuery.length > 0; + const showSearch = allPlaylists.loading || playlists.length > 0 || searchQuery.length > 0; return ( - - - {showSearch && ( - - )} + + New playlist + + ) : undefined + } + navId="dashboards/playlists" + > + + {showSearch && } - {!hasPlaylists && searchQuery ? ( - + {allPlaylists.loading ? ( + ) : ( - + <> + {!hasPlaylists && searchQuery ? ( + + ) : ( + + )} + {!showSearch && emptyListBanner} + {playlistToDelete && ( + + )} + {startPlaylist && setStartPlaylist(undefined)} />} + )} - {!showSearch && emptyListBanner} - {playlistToDelete && ( - - )} - {startPlaylist && setStartPlaylist(undefined)} />} ); diff --git a/public/app/features/playlist/PlaylistPageList.tsx b/public/app/features/playlist/PlaylistPageList.tsx index 514a152626a..74d39603b6f 100644 --- a/public/app/features/playlist/PlaylistPageList.tsx +++ b/public/app/features/playlist/PlaylistPageList.tsx @@ -2,72 +2,47 @@ import { css } from '@emotion/css'; import React from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { Button, Card, LinkButton, ModalsController, 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 { useStyles2 } from '@grafana/ui'; -import { ShareModal } from './ShareModal'; +import { PlaylistCard } from './PlaylistCard'; import { Playlist } from './types'; interface Props { setStartPlaylist: (playlistItem: Playlist) => void; setPlaylistToDelete: (playlistItem: Playlist) => void; - playlists: Playlist[] | undefined; + playlists: Playlist[]; } export const PlaylistPageList = ({ playlists, setStartPlaylist, setPlaylistToDelete }: Props) => { const styles = useStyles2(getStyles); return (
    - {playlists!.map((playlist: Playlist) => ( + {playlists.map((playlist: Playlist) => (
  • - - - {playlist.name} - - {({ showModal, hideModal }) => ( - { - showModal(ShareModal, { - playlistUid: playlist.uid, - onDismiss: hideModal, - }); - }} - /> - )} - - - - - {contextSrv.isEditor && ( - <> - - Edit playlist - - - - )} - - +
  • ))}
); }; +const PlaylistPageListSkeleton = () => { + const styles = useStyles2(getStyles); + return ( +
+ + + +
+ ); +}; + +PlaylistPageList.Skeleton = PlaylistPageListSkeleton; + function getStyles(theme: GrafanaTheme2) { return { list: css({