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:
Leo 2022-09-30 15:11:24 +02:00 committed by GitHub
parent 5eab922a16
commit 7df49ca230
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 324 additions and 183 deletions

View File

@ -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};

View File

@ -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/');
});
});

View 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>
)}
</>
);
}

View 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;
`,
};
};

View 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>

View File

@ -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}>

View File

@ -0,0 +1 @@
export const NEWS_FEED = 'https://grafana.com/blog/news.xml';

View File

@ -1,5 +1,4 @@
import { NavModelItem } from '@grafana/data';
export const TOP_BAR_LEVEL_HEIGHT = 40;
export interface ToolbarUpdateProps {

View File

@ -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};
`,
}));

View 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};
`,
});

View 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 };
}