[Newsfeed] UI plugin for Kibana (#49579)

* Added base folder structure for Newsfeed plugin

* Added base folders for lib and component

* Added newsfeed button to navigation controls on the right side

* add getApi() to return api data observable (#49581)

* Added flyout base body and provided EuiHeaderAlert component inside the newsfeed plugin

* Moved newsfeed plugin to OSS and added for the styles purpose new folder for legacy plugin 'newsfeed' with the same id to support this

* Added subscribe on fetch newsfeed change

* Add NewsfeedApiDriver class (#49710)

* add NewsfeedApiDriver class

* fix xpack prefix

* add corner case handling

* Added data binding to the ui

* added EuiHeaderAlert style overrides (#49739)

* Fixed due to comments on PR

* add missing fields to NewsfeedItem and FetchResult

* fix templating of service url

* gracefully handle temporary request failure

* Mapped missing fields for data and badge

* Fixed typos issues

* integrate i18n.getLocale()

* allow service url root to be changed in dev mode

* replace a lot of consts with config

* fix flyout height (#49809)

* Add "error" field to FetchResult: Error | null

* simplify fetch error handling

* Do not store hash for items that are filtered out

* add expireOn in case it is useful to UI

* always use staging url for dev config

* unit test for newsfeed api driver

* simplify modelItems

* Fixed eslint errors

* Fixed label translations

* Add unit test for concatenating the stored hashes with the new

* add newsfeed to i18n.json

* Fixed expression error

* --wip-- [skip ci]

* fix parse error

* fix test

* test(newsfeed): Added testing endpoint which simulates the Elastic Newsfeed for consumption in functional tests

* add tests for getApi()

* add tests for getApi

* Added no news page

* fix fetch not happening after page refresh with sessionStorage primed

* test(newsfeed): Added testing endpoint which simulates the Elastic Newsfeed for consumption in functional tests

* Added loading screen

* Small fixes due to comments

* Fixed issue with stop fetching news on error catch

* test(newsfeed): Configure FTS to point newsfeed to the simulated newsfeed endpoit

* Fixed browser error message: Invariant Violation: [React Intl] Could not find required `intl` object. <IntlProvider> needs to exist in the component ancestry.

* Fixed typo issue in label name

* polish the code changes

* Add simple jest/enzyme tests for the components

* honor utc format

* Filter pre-published items

* Fall back to en

* retry tests

* comment clarfication

* Setup newsfeed service fixture from test/common/config

* Added base functional tests for newsfeed functionality

* valid urlroot is for prod

* add documentation for the supported enabled setting

* more urlRoot

* --wip-- [skip ci]

* add the before for fn

* add ui_capabilties test

* update jest snapshot

* Fixed failing test

* finish newsfeed error functional test

* include ui_capability config

* error case testing in ci group 6

* refactor(newsfeed): moved newsfeed api call so that it is done before its use

* code polish

* enabled newsfeed_err test in CI
This commit is contained in:
Yuliia Naumenko 2019-11-13 07:48:34 -08:00 committed by Tim Sullivan
parent 4c8afa76d3
commit a50dbefb62
36 changed files with 2090 additions and 0 deletions

View file

@ -20,6 +20,7 @@
"kibana_react": "src/legacy/core_plugins/kibana_react",
"kibana-react": "src/plugins/kibana_react",
"navigation": "src/legacy/core_plugins/navigation",
"newsfeed": "src/plugins/newsfeed",
"regionMap": "src/legacy/core_plugins/region_map",
"server": "src/legacy/server",
"statusPage": "src/legacy/core_plugins/status_page",

View file

@ -240,6 +240,10 @@ Kibana reads this url from an external metadata service, but users can still
override this parameter to use their own Tile Map Service. For example:
`"https://tiles.elastic.co/v2/default/{z}/{x}/{y}.png?elastic_tile_service_tos=agree&my_app_name=kibana"`
`newsfeed.enabled:` :: *Default: `true`* Controls whether to enable the newsfeed
system for the Kibana UI notification center. Set to `false` to disable the
newsfeed system.
`path.data:`:: *Default: `data`* The path where Kibana stores persistent data
not saved in Elasticsearch.

View file

@ -23,4 +23,5 @@ require('@kbn/test').runTestsCli([
require.resolve('../test/api_integration/config.js'),
require.resolve('../test/plugin_functional/config.js'),
require.resolve('../test/interpreter_functional/config.js'),
require.resolve('../test/ui_capabilities/newsfeed_err/config.ts'),
]);

View file

@ -0,0 +1,23 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const PLUGIN_ID = 'newsfeed';
export const DEFAULT_SERVICE_URLROOT = 'https://feeds.elastic.co';
export const DEV_SERVICE_URLROOT = 'https://feeds-staging.elastic.co';
export const DEFAULT_SERVICE_PATH = '/kibana/v{VERSION}.json';

View file

@ -0,0 +1,71 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { resolve } from 'path';
import { LegacyPluginApi, LegacyPluginSpec, ArrayOrItem } from 'src/legacy/plugin_discovery/types';
import { Legacy } from 'kibana';
import { NewsfeedPluginInjectedConfig } from '../../../plugins/newsfeed/types';
import {
PLUGIN_ID,
DEFAULT_SERVICE_URLROOT,
DEV_SERVICE_URLROOT,
DEFAULT_SERVICE_PATH,
} from './constants';
// eslint-disable-next-line import/no-default-export
export default function(kibana: LegacyPluginApi): ArrayOrItem<LegacyPluginSpec> {
const pluginSpec: Legacy.PluginSpecOptions = {
id: PLUGIN_ID,
config(Joi: any) {
// NewsfeedPluginInjectedConfig in Joi form
return Joi.object({
enabled: Joi.boolean().default(true),
service: Joi.object({
pathTemplate: Joi.string().default(DEFAULT_SERVICE_PATH),
urlRoot: Joi.when('$prod', {
is: true,
then: Joi.string().default(DEFAULT_SERVICE_URLROOT),
otherwise: Joi.string().default(DEV_SERVICE_URLROOT),
}),
}).default(),
defaultLanguage: Joi.string().default('en'),
mainInterval: Joi.number().default(120 * 1000), // (2min) How often to retry failed fetches, and/or check if newsfeed items need to be refreshed from remote
fetchInterval: Joi.number().default(86400 * 1000), // (1day) How often to fetch remote and reset the last fetched time
}).default();
},
uiExports: {
styleSheetPaths: resolve(__dirname, 'public/index.scss'),
injectDefaultVars(server): NewsfeedPluginInjectedConfig {
const config = server.config();
return {
newsfeed: {
service: {
pathTemplate: config.get('newsfeed.service.pathTemplate') as string,
urlRoot: config.get('newsfeed.service.urlRoot') as string,
},
defaultLanguage: config.get('newsfeed.defaultLanguage') as string,
mainInterval: config.get('newsfeed.mainInterval') as number,
fetchInterval: config.get('newsfeed.fetchInterval') as number,
},
};
},
},
};
return new kibana.Plugin(pluginSpec);
}

View file

@ -0,0 +1,4 @@
{
"name": "newsfeed",
"version": "kibana"
}

View file

@ -0,0 +1,3 @@
@import 'src/legacy/ui/public/styles/styling_constants';
@import './np_ready/components/header_alert/_index';

View file

@ -0,0 +1,27 @@
@import '@elastic/eui/src/components/header/variables';
.kbnNews__flyout {
top: $euiHeaderChildSize + 1px;
height: calc(100% - #{$euiHeaderChildSize});
}
.kbnNewsFeed__headerAlert.euiHeaderAlert {
margin-bottom: $euiSizeL;
padding: 0 $euiSizeS $euiSizeL;
border-bottom: $euiBorderThin;
border-top: none;
.euiHeaderAlert__title {
@include euiTitle('xs');
margin-bottom: $euiSizeS;
}
.euiHeaderAlert__text {
@include euiFontSizeS;
margin-bottom: $euiSize;
}
.euiHeaderAlert__action {
@include euiFontSizeS;
}
}

View file

@ -0,0 +1,76 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import classNames from 'classnames';
import { EuiFlexGroup, EuiFlexItem, EuiI18n } from '@elastic/eui';
interface IEuiHeaderAlertProps {
action: JSX.Element;
className?: string;
date: string;
text: string;
title: string;
badge?: JSX.Element;
rest?: string[];
}
export const EuiHeaderAlert = ({
action,
className,
date,
text,
title,
badge,
...rest
}: IEuiHeaderAlertProps) => {
const classes = classNames('euiHeaderAlert', 'kbnNewsFeed__headerAlert', className);
const badgeContent = badge || null;
return (
<EuiI18n token="euiHeaderAlert.dismiss" default="Dismiss">
{(dismiss: any) => (
<div className={classes} {...rest}>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem>
<div className="euiHeaderAlert__date">{date}</div>
</EuiFlexItem>
<EuiFlexItem grow={false}>{badgeContent}</EuiFlexItem>
</EuiFlexGroup>
<div className="euiHeaderAlert__title">{title}</div>
<div className="euiHeaderAlert__text">{text}</div>
<div className="euiHeaderAlert__action euiLink">{action}</div>
</div>
)}
</EuiI18n>
);
};
EuiHeaderAlert.propTypes = {
action: PropTypes.node,
className: PropTypes.string,
date: PropTypes.node.isRequired,
text: PropTypes.node,
title: PropTypes.node.isRequired,
badge: PropTypes.node,
};

View file

@ -0,0 +1,22 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
export const NEWSFEED_FALLBACK_LANGUAGE = 'en';
export const NEWSFEED_LAST_FETCH_STORAGE_KEY = 'newsfeed.lastfetchtime';
export const NEWSFEED_HASH_SET_STORAGE_KEY = 'newsfeed.hashes';

View file

@ -0,0 +1,6 @@
{
"id": "newsfeed",
"version": "kibana",
"server": false,
"ui": true
}

View file

@ -0,0 +1,27 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`empty_news rendering renders the default Empty News 1`] = `
<EuiEmptyPrompt
body={
<p>
<FormattedMessage
defaultMessage="If your Kibana instance doesnt have internet access, ask your administrator to disable this feature. Otherwise, well keep trying to fetch the news."
id="newsfeed.emptyPrompt.noNewsText"
values={Object {}}
/>
</p>
}
data-test-subj="emptyNewsfeed"
iconType="documents"
title={
<h2>
<FormattedMessage
defaultMessage="No news?"
id="newsfeed.emptyPrompt.noNewsTitle"
values={Object {}}
/>
</h2>
}
titleSize="s"
/>
`;

View file

@ -0,0 +1,20 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`news_loading rendering renders the default News Loading 1`] = `
<EuiEmptyPrompt
body={
<p>
<FormattedMessage
defaultMessage="Getting the latest news..."
id="newsfeed.loadingPrompt.gettingNewsText"
values={Object {}}
/>
</p>
}
title={
<EuiLoadingKibana
size="xl"
/>
}
/>
`;

View file

@ -0,0 +1,32 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as React from 'react';
import { shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import { NewsEmptyPrompt } from './empty_news';
describe('empty_news', () => {
describe('rendering', () => {
it('renders the default Empty News', () => {
const wrapper = shallow(<NewsEmptyPrompt />);
expect(toJson(wrapper)).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,44 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
*    http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied.  See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiEmptyPrompt } from '@elastic/eui';
export const NewsEmptyPrompt = () => {
return (
<EuiEmptyPrompt
iconType="documents"
titleSize="s"
data-test-subj="emptyNewsfeed"
title={
<h2>
<FormattedMessage id="newsfeed.emptyPrompt.noNewsTitle" defaultMessage="No news?" />
</h2>
}
body={
<p>
<FormattedMessage
id="newsfeed.emptyPrompt.noNewsText"
defaultMessage="If your Kibana instance doesnt have internet access, ask your administrator to disable this feature. Otherwise, well keep trying to fetch the news."
/>
</p>
}
/>
);
};

View file

@ -0,0 +1,110 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
*    http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied.  See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback, useContext } from 'react';
import {
EuiIcon,
EuiFlyout,
EuiFlyoutBody,
EuiFlyoutHeader,
EuiTitle,
EuiLink,
EuiFlyoutFooter,
EuiFlexGroup,
EuiFlexItem,
EuiButtonEmpty,
EuiText,
EuiBadge,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiHeaderAlert } from '../../../../legacy/core_plugins/newsfeed/public/np_ready/components/header_alert/header_alert';
import { NewsfeedContext } from './newsfeed_header_nav_button';
import { NewsfeedItem } from '../../types';
import { NewsEmptyPrompt } from './empty_news';
import { NewsLoadingPrompt } from './loading_news';
export const NewsfeedFlyout = () => {
const { newsFetchResult, setFlyoutVisible } = useContext(NewsfeedContext);
const closeFlyout = useCallback(() => setFlyoutVisible(false), [setFlyoutVisible]);
return (
<EuiFlyout
onClose={closeFlyout}
size="s"
aria-labelledby="flyoutSmallTitle"
className="kbnNews__flyout"
data-test-subj="NewsfeedFlyout"
>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="s">
<h2 id="flyoutSmallTitle">
<FormattedMessage id="newsfeed.flyoutList.whatsNewTitle" defaultMessage="What's new" />
</h2>
</EuiTitle>
</EuiFlyoutHeader>
<EuiFlyoutBody className={'kbnNews__flyoutAlerts'}>
{!newsFetchResult ? (
<NewsLoadingPrompt />
) : newsFetchResult.feedItems.length > 0 ? (
newsFetchResult.feedItems.map((item: NewsfeedItem) => {
return (
<EuiHeaderAlert
key={item.hash}
title={item.title}
text={item.description}
data-test-subj="newsHeadAlert"
action={
<EuiLink target="_blank" href={item.linkUrl}>
{item.linkText}
<EuiIcon type="popout" size="s" />
</EuiLink>
}
date={item.publishOn.format('DD MMMM YYYY')}
badge={<EuiBadge color="hollow">{item.badge}</EuiBadge>}
/>
);
})
) : (
<NewsEmptyPrompt />
)}
</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween" alignItems="center">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={closeFlyout} flush="left">
<FormattedMessage id="newsfeed.flyoutList.closeButtonLabel" defaultMessage="Close" />
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
{newsFetchResult ? (
<EuiText color="subdued" size="s">
<p>
<FormattedMessage
id="newsfeed.flyoutList.versionTextLabel"
defaultMessage="{version}"
values={{ version: `Version ${newsFetchResult.kibanaVersion}` }}
/>
</p>
</EuiText>
) : null}
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</EuiFlyout>
);
};

View file

@ -0,0 +1,32 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as React from 'react';
import { shallow } from 'enzyme';
import toJson from 'enzyme-to-json';
import { NewsLoadingPrompt } from './loading_news';
describe('news_loading', () => {
describe('rendering', () => {
it('renders the default News Loading', () => {
const wrapper = shallow(<NewsLoadingPrompt />);
expect(toJson(wrapper)).toMatchSnapshot();
});
});
});

View file

@ -0,0 +1,39 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
*    http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied.  See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { FormattedMessage } from '@kbn/i18n/react';
import { EuiEmptyPrompt } from '@elastic/eui';
import { EuiLoadingKibana } from '@elastic/eui';
export const NewsLoadingPrompt = () => {
return (
<EuiEmptyPrompt
title={<EuiLoadingKibana size="xl" />}
body={
<p>
<FormattedMessage
id="newsfeed.loadingPrompt.gettingNewsText"
defaultMessage="Getting the latest news..."
/>
</p>
}
/>
);
};

View file

@ -0,0 +1,82 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
*    http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied.  See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useState, Fragment, useEffect } from 'react';
import * as Rx from 'rxjs';
import { EuiHeaderSectionItemButton, EuiIcon, EuiNotificationBadge } from '@elastic/eui';
import { NewsfeedFlyout } from './flyout_list';
import { FetchResult } from '../../types';
export interface INewsfeedContext {
setFlyoutVisible: React.Dispatch<React.SetStateAction<boolean>>;
newsFetchResult: FetchResult | void | null;
}
export const NewsfeedContext = React.createContext({} as INewsfeedContext);
export type NewsfeedApiFetchResult = Rx.Observable<void | FetchResult | null>;
export interface Props {
apiFetchResult: NewsfeedApiFetchResult;
}
export const NewsfeedNavButton = ({ apiFetchResult }: Props) => {
const [showBadge, setShowBadge] = useState<boolean>(false);
const [flyoutVisible, setFlyoutVisible] = useState<boolean>(false);
const [newsFetchResult, setNewsFetchResult] = useState<FetchResult | null | void>(null);
useEffect(() => {
function handleStatusChange(fetchResult: FetchResult | void | null) {
if (fetchResult) {
setShowBadge(fetchResult.hasNew);
}
setNewsFetchResult(fetchResult);
}
const subscription = apiFetchResult.subscribe(res => handleStatusChange(res));
return () => subscription.unsubscribe();
}, [apiFetchResult]);
function showFlyout() {
setShowBadge(false);
setFlyoutVisible(!flyoutVisible);
}
return (
<NewsfeedContext.Provider value={{ setFlyoutVisible, newsFetchResult }}>
<Fragment>
<EuiHeaderSectionItemButton
data-test-subj="newsfeed"
aria-controls="keyPadMenu"
aria-expanded={flyoutVisible}
aria-haspopup="true"
aria-label="Newsfeed menu"
onClick={showFlyout}
>
<EuiIcon type="email" size="m" />
{showBadge ? (
<EuiNotificationBadge className="euiHeaderNotification" data-test-subj="showBadgeNews">
&#9642;
</EuiNotificationBadge>
) : null}
</EuiHeaderSectionItemButton>
{flyoutVisible ? <NewsfeedFlyout /> : null}
</Fragment>
</NewsfeedContext.Provider>
);
};

View file

@ -0,0 +1,25 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { PluginInitializerContext } from 'src/core/public';
import { NewsfeedPublicPlugin } from './plugin';
export function plugin(initializerContext: PluginInitializerContext) {
return new NewsfeedPublicPlugin(initializerContext);
}

View file

@ -0,0 +1,701 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { take, tap, toArray } from 'rxjs/operators';
import { interval, race } from 'rxjs';
import sinon, { stub } from 'sinon';
import moment from 'moment';
import { HttpServiceBase } from 'src/core/public';
import { NEWSFEED_HASH_SET_STORAGE_KEY, NEWSFEED_LAST_FETCH_STORAGE_KEY } from '../../constants';
import { ApiItem, NewsfeedItem, NewsfeedPluginInjectedConfig } from '../../types';
import { NewsfeedApiDriver, getApi } from './api';
const localStorageGet = sinon.stub();
const sessionStoragetGet = sinon.stub();
Object.defineProperty(window, 'localStorage', {
value: {
getItem: localStorageGet,
setItem: stub(),
},
writable: true,
});
Object.defineProperty(window, 'sessionStorage', {
value: {
getItem: sessionStoragetGet,
setItem: stub(),
},
writable: true,
});
describe('NewsfeedApiDriver', () => {
const kibanaVersion = 'test_version';
const userLanguage = 'en';
const fetchInterval = 2000;
const getDriver = () => new NewsfeedApiDriver(kibanaVersion, userLanguage, fetchInterval);
afterEach(() => {
sinon.reset();
});
describe('shouldFetch', () => {
it('defaults to true', () => {
const driver = getDriver();
expect(driver.shouldFetch()).toBe(true);
});
it('returns true if last fetch time precedes page load time', () => {
sessionStoragetGet.throws('Wrong key passed!');
sessionStoragetGet.withArgs(NEWSFEED_LAST_FETCH_STORAGE_KEY).returns(322642800000); // 1980-03-23
const driver = getDriver();
expect(driver.shouldFetch()).toBe(true);
});
it('returns false if last fetch time is recent enough', () => {
sessionStoragetGet.throws('Wrong key passed!');
sessionStoragetGet.withArgs(NEWSFEED_LAST_FETCH_STORAGE_KEY).returns(3005017200000); // 2065-03-23
const driver = getDriver();
expect(driver.shouldFetch()).toBe(false);
});
});
describe('updateHashes', () => {
it('returns previous and current storage', () => {
const driver = getDriver();
const items: NewsfeedItem[] = [
{
title: 'Good news, everyone!',
description: 'good item description',
linkText: 'click here',
linkUrl: 'about:blank',
badge: 'test',
publishOn: moment(1572489035150),
expireOn: moment(1572489047858),
hash: 'hash1oneoneoneone',
},
];
expect(driver.updateHashes(items)).toMatchInlineSnapshot(`
Object {
"current": Array [
"hash1oneoneoneone",
],
"previous": Array [],
}
`);
});
it('concatenates the previous hashes with the current', () => {
localStorageGet.throws('Wrong key passed!');
localStorageGet.withArgs(NEWSFEED_HASH_SET_STORAGE_KEY).returns('happyness');
const driver = getDriver();
const items: NewsfeedItem[] = [
{
title: 'Better news, everyone!',
description: 'better item description',
linkText: 'click there',
linkUrl: 'about:blank',
badge: 'concatentated',
publishOn: moment(1572489035150),
expireOn: moment(1572489047858),
hash: 'three33hash',
},
];
expect(driver.updateHashes(items)).toMatchInlineSnapshot(`
Object {
"current": Array [
"happyness",
"three33hash",
],
"previous": Array [
"happyness",
],
}
`);
});
});
it('Validates items for required fields', () => {
const driver = getDriver();
expect(driver.validateItem({})).toBe(false);
expect(
driver.validateItem({
title: 'Gadzooks!',
description: 'gadzooks item description',
linkText: 'click here',
linkUrl: 'about:blank',
badge: 'test',
publishOn: moment(1572489035150),
expireOn: moment(1572489047858),
hash: 'hash2twotwotwotwotwo',
})
).toBe(true);
expect(
driver.validateItem({
title: 'Gadzooks!',
description: 'gadzooks item description',
linkText: 'click here',
linkUrl: 'about:blank',
publishOn: moment(1572489035150),
hash: 'hash2twotwotwotwotwo',
})
).toBe(true);
expect(
driver.validateItem({
title: 'Gadzooks!',
description: 'gadzooks item description',
linkText: 'click here',
linkUrl: 'about:blank',
publishOn: moment(1572489035150),
// hash: 'hash2twotwotwotwotwo', // should fail because this is missing
})
).toBe(false);
});
describe('modelItems', () => {
it('Models empty set with defaults', () => {
const driver = getDriver();
const apiItems: ApiItem[] = [];
expect(driver.modelItems(apiItems)).toMatchInlineSnapshot(`
Object {
"error": null,
"feedItems": Array [],
"hasNew": false,
"kibanaVersion": "test_version",
}
`);
});
it('Selects default language', () => {
const driver = getDriver();
const apiItems: ApiItem[] = [
{
title: {
en: 'speaking English',
es: 'habla Espanol',
},
description: {
en: 'language test',
es: 'idiomas',
},
languages: ['en', 'es'],
link_text: {
en: 'click here',
es: 'aqui',
},
link_url: {
en: 'xyzxyzxyz',
es: 'abcabc',
},
badge: {
en: 'firefighter',
es: 'bombero',
},
publish_on: new Date('2014-10-31T04:23:47Z'),
expire_on: new Date('2049-10-31T04:23:47Z'),
hash: 'abcabc1231123123hash',
},
];
expect(driver.modelItems(apiItems)).toMatchObject({
error: null,
feedItems: [
{
badge: 'firefighter',
description: 'language test',
hash: 'abcabc1231',
linkText: 'click here',
linkUrl: 'xyzxyzxyz',
title: 'speaking English',
},
],
hasNew: true,
kibanaVersion: 'test_version',
});
});
it("Falls back to English when user language isn't present", () => {
// Set Language to French
const driver = new NewsfeedApiDriver(kibanaVersion, 'fr', fetchInterval);
const apiItems: ApiItem[] = [
{
title: {
en: 'speaking English',
fr: 'Le Title',
},
description: {
en: 'not French',
fr: 'Le Description',
},
languages: ['en', 'fr'],
link_text: {
en: 'click here',
fr: 'Le Link Text',
},
link_url: {
en: 'xyzxyzxyz',
fr: 'le_url',
},
badge: {
en: 'firefighter',
fr: 'le_badge',
},
publish_on: new Date('2014-10-31T04:23:47Z'),
expire_on: new Date('2049-10-31T04:23:47Z'),
hash: 'frfrfrfr1231123123hash',
}, // fallback: no
{
title: {
en: 'speaking English',
es: 'habla Espanol',
},
description: {
en: 'not French',
es: 'no Espanol',
},
languages: ['en', 'es'],
link_text: {
en: 'click here',
es: 'aqui',
},
link_url: {
en: 'xyzxyzxyz',
es: 'abcabc',
},
badge: {
en: 'firefighter',
es: 'bombero',
},
publish_on: new Date('2014-10-31T04:23:47Z'),
expire_on: new Date('2049-10-31T04:23:47Z'),
hash: 'enenenen1231123123hash',
}, // fallback: yes
];
expect(driver.modelItems(apiItems)).toMatchObject({
error: null,
feedItems: [
{
badge: 'le_badge',
description: 'Le Description',
hash: 'frfrfrfr12',
linkText: 'Le Link Text',
linkUrl: 'le_url',
title: 'Le Title',
},
{
badge: 'firefighter',
description: 'not French',
hash: 'enenenen12',
linkText: 'click here',
linkUrl: 'xyzxyzxyz',
title: 'speaking English',
},
],
hasNew: true,
kibanaVersion: 'test_version',
});
});
it('Models multiple items into an API FetchResult', () => {
const driver = getDriver();
const apiItems: ApiItem[] = [
{
title: {
en: 'guess what',
},
description: {
en: 'this tests the modelItems function',
},
link_text: {
en: 'click here',
},
link_url: {
en: 'about:blank',
},
publish_on: new Date('2014-10-31T04:23:47Z'),
expire_on: new Date('2049-10-31T04:23:47Z'),
hash: 'abcabc1231123123hash',
},
{
title: {
en: 'guess when',
},
description: {
en: 'this also tests the modelItems function',
},
link_text: {
en: 'click here',
},
link_url: {
en: 'about:blank',
},
badge: {
en: 'hero',
},
publish_on: new Date('2014-10-31T04:23:47Z'),
expire_on: new Date('2049-10-31T04:23:47Z'),
hash: 'defdefdef456456456',
},
];
expect(driver.modelItems(apiItems)).toMatchObject({
error: null,
feedItems: [
{
badge: null,
description: 'this tests the modelItems function',
hash: 'abcabc1231',
linkText: 'click here',
linkUrl: 'about:blank',
title: 'guess what',
},
{
badge: 'hero',
description: 'this also tests the modelItems function',
hash: 'defdefdef4',
linkText: 'click here',
linkUrl: 'about:blank',
title: 'guess when',
},
],
hasNew: true,
kibanaVersion: 'test_version',
});
});
it('Filters expired', () => {
const driver = getDriver();
const apiItems: ApiItem[] = [
{
title: {
en: 'guess what',
},
description: {
en: 'this tests the modelItems function',
},
link_text: {
en: 'click here',
},
link_url: {
en: 'about:blank',
},
publish_on: new Date('2013-10-31T04:23:47Z'),
expire_on: new Date('2014-10-31T04:23:47Z'), // too old
hash: 'abcabc1231123123hash',
},
];
expect(driver.modelItems(apiItems)).toMatchInlineSnapshot(`
Object {
"error": null,
"feedItems": Array [],
"hasNew": false,
"kibanaVersion": "test_version",
}
`);
});
it('Filters pre-published', () => {
const driver = getDriver();
const apiItems: ApiItem[] = [
{
title: {
en: 'guess what',
},
description: {
en: 'this tests the modelItems function',
},
link_text: {
en: 'click here',
},
link_url: {
en: 'about:blank',
},
publish_on: new Date('2055-10-31T04:23:47Z'), // too new
expire_on: new Date('2056-10-31T04:23:47Z'),
hash: 'abcabc1231123123hash',
},
];
expect(driver.modelItems(apiItems)).toMatchInlineSnapshot(`
Object {
"error": null,
"feedItems": Array [],
"hasNew": false,
"kibanaVersion": "test_version",
}
`);
});
});
});
describe('getApi', () => {
const mockHttpGet = jest.fn();
let httpMock = ({
fetch: mockHttpGet,
} as unknown) as HttpServiceBase;
const getHttpMockWithItems = (mockApiItems: ApiItem[]) => (
arg1: string,
arg2: { method: string }
) => {
if (
arg1 === 'http://fakenews.co/kibana-test/v6.8.2.json' &&
arg2.method &&
arg2.method === 'GET'
) {
return Promise.resolve({ items: mockApiItems });
}
return Promise.reject('wrong args!');
};
let configMock: NewsfeedPluginInjectedConfig;
afterEach(() => {
jest.resetAllMocks();
});
beforeEach(() => {
configMock = {
newsfeed: {
service: {
urlRoot: 'http://fakenews.co',
pathTemplate: '/kibana-test/v{VERSION}.json',
},
defaultLanguage: 'en',
mainInterval: 86400000,
fetchInterval: 86400000,
},
};
httpMock = ({
fetch: mockHttpGet,
} as unknown) as HttpServiceBase;
});
it('creates a result', done => {
mockHttpGet.mockImplementationOnce(() => Promise.resolve({ items: [] }));
getApi(httpMock, configMock.newsfeed, '6.8.2').subscribe(result => {
expect(result).toMatchInlineSnapshot(`
Object {
"error": null,
"feedItems": Array [],
"hasNew": false,
"kibanaVersion": "6.8.2",
}
`);
done();
});
});
it('hasNew is true when the service returns hashes not in the cache', done => {
const mockApiItems: ApiItem[] = [
{
title: {
en: 'speaking English',
es: 'habla Espanol',
},
description: {
en: 'language test',
es: 'idiomas',
},
languages: ['en', 'es'],
link_text: {
en: 'click here',
es: 'aqui',
},
link_url: {
en: 'xyzxyzxyz',
es: 'abcabc',
},
badge: {
en: 'firefighter',
es: 'bombero',
},
publish_on: new Date('2014-10-31T04:23:47Z'),
expire_on: new Date('2049-10-31T04:23:47Z'),
hash: 'abcabc1231123123hash',
},
];
mockHttpGet.mockImplementationOnce(getHttpMockWithItems(mockApiItems));
getApi(httpMock, configMock.newsfeed, '6.8.2').subscribe(result => {
expect(result).toMatchInlineSnapshot(`
Object {
"error": null,
"feedItems": Array [
Object {
"badge": "firefighter",
"description": "language test",
"expireOn": "2049-10-31T04:23:47.000Z",
"hash": "abcabc1231",
"linkText": "click here",
"linkUrl": "xyzxyzxyz",
"publishOn": "2014-10-31T04:23:47.000Z",
"title": "speaking English",
},
],
"hasNew": true,
"kibanaVersion": "6.8.2",
}
`);
done();
});
});
it('hasNew is false when service returns hashes that are all stored', done => {
localStorageGet.throws('Wrong key passed!');
localStorageGet.withArgs(NEWSFEED_HASH_SET_STORAGE_KEY).returns('happyness');
const mockApiItems: ApiItem[] = [
{
title: { en: 'hasNew test' },
description: { en: 'test' },
link_text: { en: 'click here' },
link_url: { en: 'xyzxyzxyz' },
badge: { en: 'firefighter' },
publish_on: new Date('2014-10-31T04:23:47Z'),
expire_on: new Date('2049-10-31T04:23:47Z'),
hash: 'happyness',
},
];
mockHttpGet.mockImplementationOnce(getHttpMockWithItems(mockApiItems));
getApi(httpMock, configMock.newsfeed, '6.8.2').subscribe(result => {
expect(result).toMatchInlineSnapshot(`
Object {
"error": null,
"feedItems": Array [
Object {
"badge": "firefighter",
"description": "test",
"expireOn": "2049-10-31T04:23:47.000Z",
"hash": "happyness",
"linkText": "click here",
"linkUrl": "xyzxyzxyz",
"publishOn": "2014-10-31T04:23:47.000Z",
"title": "hasNew test",
},
],
"hasNew": false,
"kibanaVersion": "6.8.2",
}
`);
done();
});
});
it('forwards an error', done => {
mockHttpGet.mockImplementationOnce((arg1, arg2) => Promise.reject('sorry, try again later!'));
getApi(httpMock, configMock.newsfeed, '6.8.2').subscribe(result => {
expect(result).toMatchInlineSnapshot(`
Object {
"error": "sorry, try again later!",
"feedItems": Array [],
"hasNew": false,
"kibanaVersion": "6.8.2",
}
`);
done();
});
});
describe('Retry fetching', () => {
const successItems: ApiItem[] = [
{
title: { en: 'hasNew test' },
description: { en: 'test' },
link_text: { en: 'click here' },
link_url: { en: 'xyzxyzxyz' },
badge: { en: 'firefighter' },
publish_on: new Date('2014-10-31T04:23:47Z'),
expire_on: new Date('2049-10-31T04:23:47Z'),
hash: 'happyness',
},
];
it("retries until fetch doesn't error", done => {
configMock.newsfeed.mainInterval = 10; // fast retry for testing
mockHttpGet
.mockImplementationOnce(() => Promise.reject('Sorry, try again later!'))
.mockImplementationOnce(() => Promise.reject('Sorry, internal server error!'))
.mockImplementationOnce(() => Promise.reject("Sorry, it's too cold to go outside!"))
.mockImplementationOnce(getHttpMockWithItems(successItems));
getApi(httpMock, configMock.newsfeed, '6.8.2')
.pipe(
take(4),
toArray()
)
.subscribe(result => {
expect(result).toMatchInlineSnapshot(`
Array [
Object {
"error": "Sorry, try again later!",
"feedItems": Array [],
"hasNew": false,
"kibanaVersion": "6.8.2",
},
Object {
"error": "Sorry, internal server error!",
"feedItems": Array [],
"hasNew": false,
"kibanaVersion": "6.8.2",
},
Object {
"error": "Sorry, it's too cold to go outside!",
"feedItems": Array [],
"hasNew": false,
"kibanaVersion": "6.8.2",
},
Object {
"error": null,
"feedItems": Array [
Object {
"badge": "firefighter",
"description": "test",
"expireOn": "2049-10-31T04:23:47.000Z",
"hash": "happyness",
"linkText": "click here",
"linkUrl": "xyzxyzxyz",
"publishOn": "2014-10-31T04:23:47.000Z",
"title": "hasNew test",
},
],
"hasNew": false,
"kibanaVersion": "6.8.2",
},
]
`);
done();
});
});
it("doesn't retry if fetch succeeds", done => {
configMock.newsfeed.mainInterval = 10; // fast retry for testing
mockHttpGet.mockImplementation(getHttpMockWithItems(successItems));
const timeout$ = interval(1000); // lets us capture some results after a short time
let timesFetched = 0;
const get$ = getApi(httpMock, configMock.newsfeed, '6.8.2').pipe(
tap(() => {
timesFetched++;
})
);
race(get$, timeout$).subscribe(() => {
expect(timesFetched).toBe(1); // first fetch was successful, so there was no retry
done();
});
});
});
});

View file

@ -0,0 +1,194 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as Rx from 'rxjs';
import moment from 'moment';
import { i18n } from '@kbn/i18n';
import { catchError, filter, mergeMap, tap } from 'rxjs/operators';
import { HttpServiceBase } from 'src/core/public';
import {
NEWSFEED_FALLBACK_LANGUAGE,
NEWSFEED_LAST_FETCH_STORAGE_KEY,
NEWSFEED_HASH_SET_STORAGE_KEY,
} from '../../constants';
import { NewsfeedPluginInjectedConfig, ApiItem, NewsfeedItem, FetchResult } from '../../types';
type ApiConfig = NewsfeedPluginInjectedConfig['newsfeed']['service'];
export class NewsfeedApiDriver {
private readonly loadedTime = moment().utc(); // the date is compared to time in UTC format coming from the service
constructor(
private readonly kibanaVersion: string,
private readonly userLanguage: string,
private readonly fetchInterval: number
) {}
shouldFetch(): boolean {
const lastFetchUtc: string | null = sessionStorage.getItem(NEWSFEED_LAST_FETCH_STORAGE_KEY);
if (lastFetchUtc == null) {
return true;
}
const last = moment(lastFetchUtc, 'x'); // parse as unix ms timestamp (already is UTC)
// does the last fetch time precede the time that the page was loaded?
if (this.loadedTime.diff(last) > 0) {
return true;
}
const now = moment.utc(); // always use UTC to compare timestamps that came from the service
const duration = moment.duration(now.diff(last));
return duration.asMilliseconds() > this.fetchInterval;
}
updateLastFetch() {
sessionStorage.setItem(NEWSFEED_LAST_FETCH_STORAGE_KEY, Date.now().toString());
}
updateHashes(items: NewsfeedItem[]): { previous: string[]; current: string[] } {
// replace localStorage hashes with new hashes
const stored: string | null = localStorage.getItem(NEWSFEED_HASH_SET_STORAGE_KEY);
let old: string[] = [];
if (stored != null) {
old = stored.split(',');
}
const newHashes = items.map(i => i.hash);
const updatedHashes = [...new Set(old.concat(newHashes))];
localStorage.setItem(NEWSFEED_HASH_SET_STORAGE_KEY, updatedHashes.join(','));
return { previous: old, current: updatedHashes };
}
fetchNewsfeedItems(http: HttpServiceBase, config: ApiConfig): Rx.Observable<FetchResult> {
const urlPath = config.pathTemplate.replace('{VERSION}', this.kibanaVersion);
const fullUrl = config.urlRoot + urlPath;
return Rx.from(
http
.fetch(fullUrl, {
method: 'GET',
})
.then(({ items }) => this.modelItems(items))
);
}
validateItem(item: Partial<NewsfeedItem>) {
const hasMissing = [
item.title,
item.description,
item.linkText,
item.linkUrl,
item.publishOn,
item.hash,
].includes(undefined);
return !hasMissing;
}
modelItems(items: ApiItem[]): FetchResult {
const feedItems: NewsfeedItem[] = items.reduce((accum: NewsfeedItem[], it: ApiItem) => {
let chosenLanguage = this.userLanguage;
const {
expire_on: expireOnUtc,
publish_on: publishOnUtc,
languages,
title,
description,
link_text: linkText,
link_url: linkUrl,
badge,
hash,
} = it;
if (moment(expireOnUtc).isBefore(Date.now())) {
return accum; // ignore item if expired
}
if (moment(publishOnUtc).isAfter(Date.now())) {
return accum; // ignore item if publish date hasn't occurred yet (pre-published)
}
if (languages && !languages.includes(chosenLanguage)) {
chosenLanguage = NEWSFEED_FALLBACK_LANGUAGE; // don't remove the item: fallback on a language
}
const tempItem: NewsfeedItem = {
title: title[chosenLanguage],
description: description[chosenLanguage],
linkText: linkText[chosenLanguage],
linkUrl: linkUrl[chosenLanguage],
badge: badge != null ? badge![chosenLanguage] : null,
publishOn: moment(publishOnUtc),
expireOn: moment(expireOnUtc),
hash: hash.slice(0, 10), // optimize for storage and faster parsing
};
if (!this.validateItem(tempItem)) {
return accum; // ignore if title, description, etc is missing
}
return [...accum, tempItem];
}, []);
// calculate hasNew
const { previous, current } = this.updateHashes(feedItems);
const hasNew = current.length > previous.length;
return {
error: null,
kibanaVersion: this.kibanaVersion,
hasNew,
feedItems,
};
}
}
/*
* Creates an Observable to newsfeed items, powered by the main interval
* Computes hasNew value from new item hashes saved in localStorage
*/
export function getApi(
http: HttpServiceBase,
config: NewsfeedPluginInjectedConfig['newsfeed'],
kibanaVersion: string
): Rx.Observable<void | FetchResult> {
const userLanguage = i18n.getLocale() || config.defaultLanguage;
const fetchInterval = config.fetchInterval;
const driver = new NewsfeedApiDriver(kibanaVersion, userLanguage, fetchInterval);
return Rx.timer(0, config.mainInterval).pipe(
filter(() => driver.shouldFetch()),
mergeMap(() =>
driver.fetchNewsfeedItems(http, config.service).pipe(
catchError(err => {
window.console.error(err);
return Rx.of({
error: err,
kibanaVersion,
hasNew: false,
feedItems: [],
});
})
)
),
tap(() => driver.updateLastFetch())
);
}

View file

@ -0,0 +1,76 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import * as Rx from 'rxjs';
import { catchError, takeUntil } from 'rxjs/operators';
import ReactDOM from 'react-dom';
import React from 'react';
import { I18nProvider } from '@kbn/i18n/react';
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public';
import { NewsfeedPluginInjectedConfig } from '../types';
import { NewsfeedNavButton, NewsfeedApiFetchResult } from './components/newsfeed_header_nav_button';
import { getApi } from './lib/api';
export type Setup = void;
export type Start = void;
export class NewsfeedPublicPlugin implements Plugin<Setup, Start> {
private readonly kibanaVersion: string;
private readonly stop$ = new Rx.ReplaySubject(1);
constructor(initializerContext: PluginInitializerContext) {
this.kibanaVersion = initializerContext.env.packageInfo.version;
}
public setup(core: CoreSetup): Setup {}
public start(core: CoreStart): Start {
const api$ = this.fetchNewsfeed(core);
core.chrome.navControls.registerRight({
order: 1000,
mount: target => this.mount(api$, target),
});
}
public stop() {
this.stop$.next();
}
private fetchNewsfeed(core: CoreStart) {
const { http, injectedMetadata } = core;
const config = injectedMetadata.getInjectedVar(
'newsfeed'
) as NewsfeedPluginInjectedConfig['newsfeed'];
return getApi(http, config, this.kibanaVersion).pipe(
takeUntil(this.stop$), // stop the interval when stop method is called
catchError(() => Rx.of(null)) // do not throw error
);
}
private mount(api$: NewsfeedApiFetchResult, targetDomElement: HTMLElement) {
ReactDOM.render(
<I18nProvider>
<NewsfeedNavButton apiFetchResult={api$} />
</I18nProvider>,
targetDomElement
);
return () => ReactDOM.unmountComponentAtNode(targetDomElement);
}
}

View file

@ -0,0 +1,63 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { Moment } from 'moment';
export interface NewsfeedPluginInjectedConfig {
newsfeed: {
service: {
urlRoot: string;
pathTemplate: string;
};
defaultLanguage: string;
mainInterval: number; // how often to check last updated time
fetchInterval: number; // how often to fetch remote service and set last updated
};
}
export interface ApiItem {
hash: string;
expire_on: Date;
publish_on: Date;
title: { [lang: string]: string };
description: { [lang: string]: string };
link_text: { [lang: string]: string };
link_url: { [lang: string]: string };
badge?: { [lang: string]: string } | null;
languages?: string[] | null;
image_url?: null; // not used phase 1
}
export interface NewsfeedItem {
title: string;
description: string;
linkText: string;
linkUrl: string;
badge: string | null;
publishOn: Moment;
expireOn: Moment;
hash: string;
}
export interface FetchResult {
kibanaVersion: string;
hasNew: boolean;
feedItems: NewsfeedItem[];
error: Error | null;
}

View file

@ -41,6 +41,7 @@ export function getFunctionalTestGroupRunConfigs({ kibanaInstallDir } = {}) {
'scripts/functional_tests',
'--include-tag', tag,
'--config', 'test/functional/config.js',
'--config', 'test/ui_capabilities/newsfeed_err/config.ts',
// '--config', 'test/functional/config.firefox.js',
'--bail',
'--debug',

View file

@ -17,6 +17,7 @@
* under the License.
*/
import path from 'path';
import { format as formatUrl } from 'url';
import { OPTIMIZE_BUNDLE_DIR, esTestConfig, kbnTestConfig } from '@kbn/test';
import { services } from './services';
@ -57,6 +58,10 @@ export default function () {
`--kibana.disableWelcomeScreen=true`,
'--telemetry.banner=false',
`--server.maxPayloadBytes=1679958`,
// newsfeed mock service
`--plugin-path=${path.join(__dirname, 'fixtures', 'plugins', 'newsfeed')}`,
`--newsfeed.service.urlRoot=${servers.kibana.protocol}://${servers.kibana.hostname}:${servers.kibana.port}`,
`--newsfeed.service.pathTemplate=/api/_newsfeed-FTS-external-service-simulators/kibana/v{VERSION}.json`,
],
},
services

View file

@ -0,0 +1,33 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Hapi from 'hapi';
import { initPlugin as initNewsfeed } from './newsfeed_simulation';
const NAME = 'newsfeed-FTS-external-service-simulators';
// eslint-disable-next-line import/no-default-export
export default function(kibana: any) {
return new kibana.Plugin({
name: NAME,
init: (server: Hapi.Server) => {
initNewsfeed(server, `/api/_${NAME}`);
},
});
}

View file

@ -0,0 +1,114 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import Hapi from 'hapi';
interface WebhookRequest extends Hapi.Request {
payload: string;
}
export async function initPlugin(server: Hapi.Server, path: string) {
server.route({
method: ['GET'],
path: `${path}/kibana/v{version}.json`,
options: {
cors: {
origin: ['*'],
additionalHeaders: [
'Sec-Fetch-Mode',
'Access-Control-Request-Method',
'Access-Control-Request-Headers',
'cache-control',
'x-requested-with',
'Origin',
'User-Agent',
'DNT',
'content-type',
'kbn-version',
],
},
},
handler: newsfeedHandler,
});
server.route({
method: ['GET'],
path: `${path}/kibana/crash.json`,
options: {
cors: {
origin: ['*'],
additionalHeaders: [
'Sec-Fetch-Mode',
'Access-Control-Request-Method',
'Access-Control-Request-Headers',
'cache-control',
'x-requested-with',
'Origin',
'User-Agent',
'DNT',
'content-type',
'kbn-version',
],
},
},
handler() {
throw new Error('Internal server error');
},
});
}
function newsfeedHandler(request: WebhookRequest, h: any) {
return htmlResponse(h, 200, JSON.stringify(mockNewsfeed(request.params.version)));
}
const mockNewsfeed = (version: string) => ({
items: [
{
title: { en: `You are functionally testing the newsfeed widget with fixtures!` },
description: { en: 'See test/common/fixtures/plugins/newsfeed/newsfeed_simulation' },
link_text: { en: 'Generic feed-viewer could go here' },
link_url: { en: 'https://feeds.elastic.co' },
languages: null,
badge: null,
image_url: null,
publish_on: '2019-06-21T00:00:00',
expire_on: '2019-12-31T00:00:00',
hash: '39ca7d409c7eb25f4c69a5a6a11309b2f5ced7ca3f9b3a0109517126e0fd91ca',
},
{
title: { en: 'Staging too!' },
description: { en: 'Hello world' },
link_text: { en: 'Generic feed-viewer could go here' },
link_url: { en: 'https://feeds-staging.elastic.co' },
languages: null,
badge: null,
image_url: null,
publish_on: '2019-06-21T00:00:00',
expire_on: '2019-12-31T00:00:00',
hash: 'db445c9443eb50ea2eb15f20edf89cf0f7dac2b058b11cafc2c8c288b6e4ce2a',
},
],
});
function htmlResponse(h: any, code: number, text: string) {
return h
.response(text)
.type('application/json')
.code(code);
}

View file

@ -0,0 +1,7 @@
{
"name": "newsfeed-fixtures",
"version": "0.0.0",
"kibana": {
"version": "kibana"
}
}

View file

@ -0,0 +1,62 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function({ getService, getPageObjects }: FtrProviderContext) {
const globalNav = getService('globalNav');
const PageObjects = getPageObjects(['common', 'newsfeed']);
describe('Newsfeed', () => {
before(async () => {
await PageObjects.newsfeed.resetPage();
});
it('has red icon which is a sign of not checked news', async () => {
const hasCheckedNews = await PageObjects.newsfeed.getRedButtonSign();
expect(hasCheckedNews).to.be(true);
});
it('clicking on newsfeed icon should open you newsfeed', async () => {
await globalNav.clickNewsfeed();
const isOpen = await PageObjects.newsfeed.openNewsfeedPanel();
expect(isOpen).to.be(true);
});
it('no red icon, because all news is checked', async () => {
const hasCheckedNews = await PageObjects.newsfeed.getRedButtonSign();
expect(hasCheckedNews).to.be(false);
});
it('shows all news from newsfeed', async () => {
const objects = await PageObjects.newsfeed.getNewsfeedList();
expect(objects).to.eql([
'21 June 2019\nYou are functionally testing the newsfeed widget with fixtures!\nSee test/common/fixtures/plugins/newsfeed/newsfeed_simulation\nGeneric feed-viewer could go here',
'21 June 2019\nStaging too!\nHello world\nGeneric feed-viewer could go here',
]);
});
it('clicking on newsfeed icon should close opened newsfeed', async () => {
await globalNav.clickNewsfeed();
const isOpen = await PageObjects.newsfeed.openNewsfeedPanel();
expect(isOpen).to.be(false);
});
});
}

View file

@ -29,6 +29,7 @@ export default function ({ getService, loadTestFile }) {
loadTestFile(require.resolve('./_navigation'));
loadTestFile(require.resolve('./_home'));
loadTestFile(require.resolve('./_newsfeed'));
loadTestFile(require.resolve('./_add_data'));
loadTestFile(require.resolve('./_sample_data'));
});

View file

@ -35,6 +35,7 @@ import { HeaderPageProvider } from './header_page';
import { HomePageProvider } from './home_page';
// @ts-ignore not TS yet
import { MonitoringPageProvider } from './monitoring_page';
import { NewsfeedPageProvider } from './newsfeed_page';
// @ts-ignore not TS yet
import { PointSeriesPageProvider } from './point_series_page';
// @ts-ignore not TS yet
@ -61,6 +62,7 @@ export const pageObjects = {
header: HeaderPageProvider,
home: HomePageProvider,
monitoring: MonitoringPageProvider,
newsfeed: NewsfeedPageProvider,
pointSeries: PointSeriesPageProvider,
settings: SettingsPageProvider,
share: SharePageProvider,

View file

@ -0,0 +1,73 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FtrProviderContext } from '../ftr_provider_context';
export function NewsfeedPageProvider({ getService, getPageObjects }: FtrProviderContext) {
const log = getService('log');
const retry = getService('retry');
const flyout = getService('flyout');
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['common']);
class NewsfeedPage {
async resetPage() {
await PageObjects.common.navigateToUrl('home');
}
async closeNewsfeedPanel() {
await flyout.ensureClosed('NewsfeedFlyout');
log.debug('clickNewsfeed icon');
await retry.waitFor('newsfeed flyout', async () => {
if (await testSubjects.exists('NewsfeedFlyout')) {
await testSubjects.click('NewsfeedFlyout > euiFlyoutCloseButton');
return false;
}
return true;
});
}
async openNewsfeedPanel() {
log.debug('clickNewsfeed icon');
return await testSubjects.exists('NewsfeedFlyout');
}
async getRedButtonSign() {
return await testSubjects.exists('showBadgeNews');
}
async getNewsfeedList() {
const list = await testSubjects.find('NewsfeedFlyout');
const cells = await list.findAllByCssSelector('[data-test-subj="newsHeadAlert"]');
const objects = [];
for (const cell of cells) {
objects.push(await cell.getVisibleText());
}
return objects;
}
async openNewsfeedEmptyPanel() {
return await testSubjects.exists('emptyNewsfeed');
}
}
return new NewsfeedPage();
}

View file

@ -32,6 +32,10 @@ export function GlobalNavProvider({ getService }: FtrProviderContext) {
return await testSubjects.click('headerGlobalNav > logo');
}
public async clickNewsfeed(): Promise<void> {
return await testSubjects.click('headerGlobalNav > newsfeed');
}
public async exists(): Promise<boolean> {
return await testSubjects.exists('headerGlobalNav');
}

View file

@ -0,0 +1,45 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { FtrConfigProviderContext } from '@kbn/test/types/ftr';
// @ts-ignore untyped module
import getFunctionalConfig from '../../functional/config';
// eslint-disable-next-line import/no-default-export
export default async ({ readConfigFile }: FtrConfigProviderContext) => {
const functionalConfig = await getFunctionalConfig({ readConfigFile });
return {
...functionalConfig,
testFiles: [require.resolve('./test')],
kbnTestServer: {
...functionalConfig.kbnTestServer,
serverArgs: [
...functionalConfig.kbnTestServer.serverArgs,
`--newsfeed.service.pathTemplate=/api/_newsfeed-FTS-external-service-simulators/kibana/crash.json`,
],
},
junit: {
reportName: 'Newsfeed Error Handling',
},
};
};

View file

@ -0,0 +1,60 @@
/*
* Licensed to Elasticsearch B.V. under one or more contributor
* license agreements. See the NOTICE file distributed with
* this work for additional information regarding copyright
* ownership. Elasticsearch B.V. licenses this file to you under
* the Apache License, Version 2.0 (the "License"); you may
* not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../functional/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function uiCapabilitiesTests({ getService, getPageObjects }: FtrProviderContext) {
const globalNav = getService('globalNav');
const PageObjects = getPageObjects(['common', 'newsfeed']);
describe('Newsfeed icon button handle errors', function() {
this.tags('ciGroup6');
before(async () => {
await PageObjects.newsfeed.resetPage();
});
it('clicking on newsfeed icon should open you empty newsfeed', async () => {
await globalNav.clickNewsfeed();
const isOpen = await PageObjects.newsfeed.openNewsfeedPanel();
expect(isOpen).to.be(true);
const hasNewsfeedEmptyPanel = await PageObjects.newsfeed.openNewsfeedEmptyPanel();
expect(hasNewsfeedEmptyPanel).to.be(true);
});
it('no red icon', async () => {
const hasCheckedNews = await PageObjects.newsfeed.getRedButtonSign();
expect(hasCheckedNews).to.be(false);
});
it('shows empty panel due to error response', async () => {
const objects = await PageObjects.newsfeed.getNewsfeedList();
expect(objects).to.eql([]);
});
it('clicking on newsfeed icon should close opened newsfeed', async () => {
await globalNav.clickNewsfeed();
const isOpen = await PageObjects.newsfeed.openNewsfeedPanel();
expect(isOpen).to.be(false);
});
});
}