mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
Navigation: Add News to top nav (#55466)
* added news feed component * move button to news component * create newsfeed hooks and conver NewsPanel to functional component * added news_feed_enabled and news_feed_url to server settings ini * add default value in defaults.ini * set news drawer settings value to true by default * remove server settings config * use useToggle hook * fix newsitem render * support drawer on mobile screen * use media query utility
This commit is contained in:
parent
5eab922a16
commit
7df49ca230
@ -163,7 +163,14 @@ const getStyles = (theme: GrafanaTheme2) => {
|
||||
.drawer-open .drawer-content-wrapper {
|
||||
box-shadow: ${theme.shadows.z3};
|
||||
}
|
||||
|
||||
z-index: ${theme.zIndex.dropdown};
|
||||
|
||||
${theme.breakpoints.down('sm')} {
|
||||
.drawer-content-wrapper {
|
||||
width: 100% !important;
|
||||
}
|
||||
}
|
||||
`,
|
||||
header: css`
|
||||
background-color: ${theme.colors.background.canvas};
|
||||
|
@ -0,0 +1,28 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import fs from 'fs';
|
||||
import React from 'react';
|
||||
|
||||
import { NewsContainer } from './NewsContainer';
|
||||
|
||||
const setup = () => {
|
||||
const { container } = render(<NewsContainer />);
|
||||
|
||||
return { container };
|
||||
};
|
||||
|
||||
describe('News', () => {
|
||||
const result = fs.readFileSync(`${__dirname}/fixtures/news.xml`, 'utf8');
|
||||
beforeEach(() => {
|
||||
jest.resetAllMocks();
|
||||
window.fetch = jest.fn().mockResolvedValue({ text: () => result });
|
||||
});
|
||||
|
||||
it('should render the drawer when the drawer button is clicked', async () => {
|
||||
setup();
|
||||
|
||||
await userEvent.click(screen.getByRole('button'));
|
||||
expect(screen.getByRole('article')).toBeInTheDocument();
|
||||
expect(screen.getByRole('link')).toHaveAttribute('href', 'https://www.example.net/2022/02/10/something-fake/');
|
||||
});
|
||||
});
|
32
public/app/core/components/AppChrome/News/NewsContainer.tsx
Normal file
32
public/app/core/components/AppChrome/News/NewsContainer.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
import { useToggle } from 'react-use';
|
||||
|
||||
import { Drawer, Icon } from '@grafana/ui';
|
||||
import { DEFAULT_FEED_URL } from 'app/plugins/panel/news/constants';
|
||||
|
||||
import { NewsWrapper } from './NewsWrapper';
|
||||
|
||||
interface NewsContainerProps {
|
||||
buttonCss?: string;
|
||||
}
|
||||
|
||||
export function NewsContainer({ buttonCss }: NewsContainerProps) {
|
||||
const [showNewsDrawer, onToggleShowNewsDrawer] = useToggle(false);
|
||||
|
||||
const onChildClick = () => {
|
||||
onToggleShowNewsDrawer(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<button className={buttonCss} onClick={onChildClick}>
|
||||
<Icon name="rss" size="lg" />
|
||||
</button>
|
||||
{showNewsDrawer && (
|
||||
<Drawer title="Latest from the blog" scrollableContent onClose={onToggleShowNewsDrawer}>
|
||||
<NewsWrapper feedUrl={DEFAULT_FEED_URL} />
|
||||
</Drawer>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
56
public/app/core/components/AppChrome/News/NewsWrapper.tsx
Normal file
56
public/app/core/components/AppChrome/News/NewsWrapper.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { css } from '@emotion/css';
|
||||
import React, { useEffect } from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { LoadingPlaceholder, useStyles2 } from '@grafana/ui';
|
||||
import { News } from 'app/plugins/panel/news/component/News';
|
||||
import { useNewsFeed } from 'app/plugins/panel/news/useNewsFeed';
|
||||
|
||||
interface NewsWrapperProps {
|
||||
feedUrl: string;
|
||||
}
|
||||
export function NewsWrapper({ feedUrl }: NewsWrapperProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const { state, getNews } = useNewsFeed(feedUrl);
|
||||
useEffect(() => {
|
||||
getNews();
|
||||
}, [getNews]);
|
||||
|
||||
if (state.loading || state.error) {
|
||||
return (
|
||||
<div className={styles.innerWrapper}>
|
||||
{state.loading && <LoadingPlaceholder text="Loading..." />}
|
||||
{state.error && state.error.message}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!state.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<AutoSizer>
|
||||
{({ width }) => (
|
||||
<div style={{ width: `${width}px` }}>
|
||||
{state.value.map((_, index) => (
|
||||
<News key={index} index={index} showImage width={width} data={state.value} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</AutoSizer>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => {
|
||||
return {
|
||||
innerWrapper: css`
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
`,
|
||||
};
|
||||
};
|
21
public/app/core/components/AppChrome/News/fixtures/news.xml
Normal file
21
public/app/core/components/AppChrome/News/fixtures/news.xml
Normal file
@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:wfw="http://wellformedweb.org/CommentAPI/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:sy="http://purl.org/rss/1.0/modules/syndication/" xmlns:slash="http://purl.org/rss/1.0/modules/slash/">
|
||||
|
||||
<channel>
|
||||
<title>RSS Feed Example</title>
|
||||
<atom:link href="https://www.example.net/feed" rel="self" type="application/rss+xml" />
|
||||
<link>https://www.example.net</link>
|
||||
<description>A small description of this feed</description>
|
||||
<language>en-US</language>
|
||||
<item>
|
||||
<title>A fake item</title>
|
||||
<link>https://www.example.net/2022/02/10/something-fake/</link>
|
||||
|
||||
<dc:creator>Bill Test</dc:creator>
|
||||
<pubDate>Thu, 10 Feb 2022 16:00:17 +0000</pubDate>
|
||||
<category>Fake</category>
|
||||
|
||||
<description>A description of a fake blog post</description>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
@ -6,6 +6,7 @@ import { Dropdown, Icon, Tooltip, useStyles2 } from '@grafana/ui';
|
||||
import { contextSrv } from 'app/core/core';
|
||||
import { useSelector } from 'app/types';
|
||||
|
||||
import { NewsContainer } from './News/NewsContainer';
|
||||
import { TopNavBarMenu } from './TopBar/TopNavBarMenu';
|
||||
import { TopSearchBarInput } from './TopSearchBarInput';
|
||||
import { TOP_BAR_LEVEL_HEIGHT } from './types';
|
||||
@ -36,11 +37,7 @@ export function TopSearchBar() {
|
||||
</button>
|
||||
</Dropdown>
|
||||
)}
|
||||
<Tooltip placement="bottom" content="Grafana news (todo)">
|
||||
<button className={styles.actionItem}>
|
||||
<Icon name="rss" size="lg" />
|
||||
</button>
|
||||
</Tooltip>
|
||||
<NewsContainer buttonCss={styles.actionItem} />
|
||||
{signInNode && (
|
||||
<Tooltip placement="bottom" content="Sign in">
|
||||
<a className={styles.actionItem} href={signInNode.url} target={signInNode.target}>
|
||||
|
1
public/app/core/components/AppChrome/constants.ts
Normal file
1
public/app/core/components/AppChrome/constants.ts
Normal file
@ -0,0 +1 @@
|
||||
export const NEWS_FEED = 'https://grafana.com/blog/news.xml';
|
@ -1,5 +1,4 @@
|
||||
import { NavModelItem } from '@grafana/data';
|
||||
|
||||
export const TOP_BAR_LEVEL_HEIGHT = 40;
|
||||
|
||||
export interface ToolbarUpdateProps {
|
||||
|
@ -1,185 +1,52 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React, { PureComponent } from 'react';
|
||||
import { Unsubscribable } from 'rxjs';
|
||||
import React, { useEffect } from 'react';
|
||||
|
||||
import { PanelProps, DataFrameView, dateTimeFormat, GrafanaTheme2, textUtil } from '@grafana/data';
|
||||
import { PanelProps } from '@grafana/data';
|
||||
import { RefreshEvent } from '@grafana/runtime';
|
||||
import { CustomScrollbar, stylesFactory } from '@grafana/ui';
|
||||
import config from 'app/core/config';
|
||||
import { CustomScrollbar } from '@grafana/ui';
|
||||
|
||||
import { News } from './component/News';
|
||||
import { DEFAULT_FEED_URL } from './constants';
|
||||
import { loadFeed } from './feed';
|
||||
import { PanelOptions } from './models.gen';
|
||||
import { NewsItem } from './types';
|
||||
import { feedToDataFrame } from './utils';
|
||||
import { useNewsFeed } from './useNewsFeed';
|
||||
|
||||
interface Props extends PanelProps<PanelOptions> {}
|
||||
interface NewsPanelProps extends PanelProps<PanelOptions> {}
|
||||
|
||||
interface State {
|
||||
news?: DataFrameView<NewsItem>;
|
||||
isError?: boolean;
|
||||
export function NewsPanel(props: NewsPanelProps) {
|
||||
const {
|
||||
width,
|
||||
options: { feedUrl = DEFAULT_FEED_URL, showImage },
|
||||
} = props;
|
||||
|
||||
const { state, getNews } = useNewsFeed(feedUrl);
|
||||
|
||||
useEffect(() => {
|
||||
const sub = props.eventBus.subscribe(RefreshEvent, getNews);
|
||||
|
||||
return () => {
|
||||
sub.unsubscribe();
|
||||
};
|
||||
}, [getNews, props.eventBus]);
|
||||
|
||||
useEffect(() => {
|
||||
getNews();
|
||||
}, [getNews]);
|
||||
|
||||
if (state.error) {
|
||||
return <div>Error loading RSS feed.</div>;
|
||||
}
|
||||
if (state.loading) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
if (!state.value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomScrollbar autoHeightMin="100%" autoHeightMax="100%">
|
||||
{state.value.map((_, index) => {
|
||||
return <News key={index} index={index} width={width} showImage={showImage} data={state.value} />;
|
||||
})}
|
||||
</CustomScrollbar>
|
||||
);
|
||||
}
|
||||
|
||||
export class NewsPanel extends PureComponent<Props, State> {
|
||||
private refreshSubscription: Unsubscribable;
|
||||
|
||||
constructor(props: Props) {
|
||||
super(props);
|
||||
this.refreshSubscription = this.props.eventBus.subscribe(RefreshEvent, this.loadChannel.bind(this));
|
||||
this.state = {};
|
||||
}
|
||||
|
||||
componentDidMount(): void {
|
||||
this.loadChannel();
|
||||
}
|
||||
|
||||
componentWillUnmount(): void {
|
||||
this.refreshSubscription.unsubscribe();
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: Props): void {
|
||||
if (this.props.options.feedUrl !== prevProps.options.feedUrl) {
|
||||
this.loadChannel();
|
||||
}
|
||||
}
|
||||
|
||||
async loadChannel() {
|
||||
const { options } = this.props;
|
||||
try {
|
||||
const url = options.feedUrl || DEFAULT_FEED_URL;
|
||||
|
||||
const feed = await loadFeed(url);
|
||||
const frame = feedToDataFrame(feed);
|
||||
|
||||
this.setState({
|
||||
news: new DataFrameView<NewsItem>(frame),
|
||||
isError: false,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Error Loading News', err);
|
||||
|
||||
this.setState({
|
||||
news: undefined,
|
||||
isError: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
const { width } = this.props;
|
||||
const { showImage } = this.props.options;
|
||||
const { isError, news } = this.state;
|
||||
const styles = getStyles(config.theme2);
|
||||
const useWideLayout = width > 600;
|
||||
|
||||
if (isError) {
|
||||
return <div>Error loading RSS feed.</div>;
|
||||
}
|
||||
if (!news) {
|
||||
return <div>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<CustomScrollbar autoHeightMin="100%" autoHeightMax="100%">
|
||||
{news.map((item, index) => {
|
||||
return (
|
||||
<article key={index} className={cx(styles.item, useWideLayout && styles.itemWide)}>
|
||||
{showImage && item.ogImage && (
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href={textUtil.sanitizeUrl(item.link)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cx(styles.socialImage, useWideLayout && styles.socialImageWide)}
|
||||
aria-hidden
|
||||
>
|
||||
<img src={item.ogImage} alt={item.title} />
|
||||
</a>
|
||||
)}
|
||||
<div className={styles.body}>
|
||||
<time className={styles.date} dateTime={dateTimeFormat(item.date, { format: 'MMM DD' })}>
|
||||
{dateTimeFormat(item.date, { format: 'MMM DD' })}{' '}
|
||||
</time>
|
||||
<a
|
||||
className={styles.link}
|
||||
href={textUtil.sanitizeUrl(item.link)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<h3 className={styles.title}>{item.title}</h3>
|
||||
</a>
|
||||
<div className={styles.content} dangerouslySetInnerHTML={{ __html: textUtil.sanitize(item.content) }} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</CustomScrollbar>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
|
||||
container: css`
|
||||
height: 100%;
|
||||
`,
|
||||
item: css`
|
||||
display: flex;
|
||||
padding: ${theme.spacing(1)};
|
||||
position: relative;
|
||||
margin-bottom: 4px;
|
||||
margin-right: ${theme.spacing(1)};
|
||||
border-bottom: 2px solid ${theme.colors.border.weak};
|
||||
background: ${theme.colors.background.primary};
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
`,
|
||||
itemWide: css`
|
||||
flex-direction: row;
|
||||
`,
|
||||
body: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`,
|
||||
socialImage: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
> img {
|
||||
width: 100%;
|
||||
border-radius: ${theme.shape.borderRadius(2)} ${theme.shape.borderRadius(2)} 0 0;
|
||||
}
|
||||
`,
|
||||
socialImageWide: css`
|
||||
margin-right: ${theme.spacing(2)};
|
||||
margin-bottom: 0;
|
||||
> img {
|
||||
width: 250px;
|
||||
border-radius: ${theme.shape.borderRadius()};
|
||||
}
|
||||
`,
|
||||
link: css`
|
||||
color: ${theme.colors.text.link};
|
||||
display: inline-block;
|
||||
|
||||
&:hover {
|
||||
color: ${theme.colors.text.link};
|
||||
text-decoration: underline;
|
||||
}
|
||||
`,
|
||||
title: css`
|
||||
font-size: 16px;
|
||||
margin-bottom: ${theme.spacing(0.5)};
|
||||
`,
|
||||
content: css`
|
||||
p {
|
||||
margin-bottom: 4px;
|
||||
color: ${theme.colors.text};
|
||||
}
|
||||
`,
|
||||
date: css`
|
||||
margin-bottom: ${theme.spacing(0.5)};
|
||||
font-weight: 500;
|
||||
border-radius: 0 0 0 3px;
|
||||
color: ${theme.colors.text.secondary};
|
||||
`,
|
||||
}));
|
||||
|
112
public/app/plugins/panel/news/component/News.tsx
Normal file
112
public/app/plugins/panel/news/component/News.tsx
Normal file
@ -0,0 +1,112 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import React from 'react';
|
||||
|
||||
import { DataFrameView, GrafanaTheme2, textUtil, dateTimeFormat } from '@grafana/data';
|
||||
import { useStyles2 } from '@grafana/ui';
|
||||
|
||||
import { NewsItem } from '../types';
|
||||
|
||||
interface NewsItemProps {
|
||||
width: number;
|
||||
showImage?: boolean;
|
||||
index: number;
|
||||
data: DataFrameView<NewsItem>;
|
||||
}
|
||||
|
||||
export function News({ width, showImage, data, index }: NewsItemProps) {
|
||||
const styles = useStyles2(getStyles);
|
||||
const useWideLayout = width > 600;
|
||||
const newsItem = data.get(index);
|
||||
|
||||
return (
|
||||
<article className={cx(styles.item, useWideLayout && styles.itemWide)}>
|
||||
{showImage && newsItem.ogImage && (
|
||||
<a
|
||||
tabIndex={-1}
|
||||
href={textUtil.sanitizeUrl(newsItem.link)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={cx(styles.socialImage, useWideLayout && styles.socialImageWide)}
|
||||
aria-hidden
|
||||
>
|
||||
<img src={newsItem.ogImage} alt={newsItem.title} />
|
||||
</a>
|
||||
)}
|
||||
<div className={styles.body}>
|
||||
<time className={styles.date} dateTime={dateTimeFormat(newsItem.date, { format: 'MMM DD' })}>
|
||||
{dateTimeFormat(newsItem.date, { format: 'MMM DD' })}{' '}
|
||||
</time>
|
||||
<a className={styles.link} href={textUtil.sanitizeUrl(newsItem.link)} target="_blank" rel="noopener noreferrer">
|
||||
<h3 className={styles.title}>{newsItem.title}</h3>
|
||||
</a>
|
||||
<div className={styles.content} dangerouslySetInnerHTML={{ __html: textUtil.sanitize(newsItem.content) }} />
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
container: css`
|
||||
height: 100%;
|
||||
`,
|
||||
item: css`
|
||||
display: flex;
|
||||
padding: ${theme.spacing(1)};
|
||||
position: relative;
|
||||
margin-bottom: 4px;
|
||||
margin-right: ${theme.spacing(1)};
|
||||
border-bottom: 2px solid ${theme.colors.border.weak};
|
||||
background: ${theme.colors.background.primary};
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
`,
|
||||
itemWide: css`
|
||||
flex-direction: row;
|
||||
`,
|
||||
body: css`
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
`,
|
||||
socialImage: css`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin-bottom: ${theme.spacing(1)};
|
||||
> img {
|
||||
width: 100%;
|
||||
border-radius: ${theme.shape.borderRadius(2)} ${theme.shape.borderRadius(2)} 0 0;
|
||||
}
|
||||
`,
|
||||
socialImageWide: css`
|
||||
margin-right: ${theme.spacing(2)};
|
||||
margin-bottom: 0;
|
||||
> img {
|
||||
width: 250px;
|
||||
border-radius: ${theme.shape.borderRadius()};
|
||||
}
|
||||
`,
|
||||
link: css`
|
||||
color: ${theme.colors.text.link};
|
||||
display: inline-block;
|
||||
|
||||
&:hover {
|
||||
color: ${theme.colors.text.link};
|
||||
text-decoration: underline;
|
||||
}
|
||||
`,
|
||||
title: css`
|
||||
font-size: 16px;
|
||||
margin-bottom: ${theme.spacing(0.5)};
|
||||
`,
|
||||
content: css`
|
||||
p {
|
||||
margin-bottom: 4px;
|
||||
color: ${theme.colors.text};
|
||||
}
|
||||
`,
|
||||
date: css`
|
||||
margin-bottom: ${theme.spacing(0.5)};
|
||||
font-weight: 500;
|
||||
border-radius: 0 0 0 3px;
|
||||
color: ${theme.colors.text.secondary};
|
||||
`,
|
||||
});
|
21
public/app/plugins/panel/news/useNewsFeed.tsx
Normal file
21
public/app/plugins/panel/news/useNewsFeed.tsx
Normal file
@ -0,0 +1,21 @@
|
||||
import { useAsyncFn } from 'react-use';
|
||||
|
||||
import { DataFrameView } from '@grafana/data';
|
||||
|
||||
import { loadFeed } from './feed';
|
||||
import { NewsItem } from './types';
|
||||
import { feedToDataFrame } from './utils';
|
||||
|
||||
export function useNewsFeed(url: string) {
|
||||
const [state, getNews] = useAsyncFn(
|
||||
async () => {
|
||||
const feed = await loadFeed(url);
|
||||
const frame = feedToDataFrame(feed);
|
||||
return new DataFrameView<NewsItem>(frame);
|
||||
},
|
||||
[url],
|
||||
{ loading: true }
|
||||
);
|
||||
|
||||
return { state, getNews };
|
||||
}
|
Loading…
Reference in New Issue
Block a user