diff --git a/.i18nrc.json b/.i18nrc.json
index 303832b7bfe8e..d3cf8a49b59b3 100644
--- a/.i18nrc.json
+++ b/.i18nrc.json
@@ -21,6 +21,7 @@
"visTypeMetric": "src/legacy/core_plugins/vis_type_metric",
"visTypeVega": "src/legacy/core_plugins/vis_type_vega",
"visTypeTable": "src/legacy/core_plugins/vis_type_table",
+ "newsfeed": "src/plugins/newsfeed",
"regionMap": "src/legacy/core_plugins/region_map",
"statusPage": "src/legacy/core_plugins/status_page",
"tileMap": "src/legacy/core_plugins/tile_map",
diff --git a/docs/setup/settings.asciidoc b/docs/setup/settings.asciidoc
index 7e6c534c13ae2..b8ad311b94f0c 100644
--- a/docs/setup/settings.asciidoc
+++ b/docs/setup/settings.asciidoc
@@ -243,6 +243,10 @@ override this parameter to use their own Tile Map Service. For example:
`ops.interval:`:: *Default: 5000* Set the interval in milliseconds to sample
system and process performance metrics. The minimum value is 100.
+`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.
diff --git a/scripts/functional_tests.js b/scripts/functional_tests.js
index 472545b203a9b..9f4e678c6adf5 100644
--- a/scripts/functional_tests.js
+++ b/scripts/functional_tests.js
@@ -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'),
]);
diff --git a/src/legacy/core_plugins/newsfeed/constants.ts b/src/legacy/core_plugins/newsfeed/constants.ts
new file mode 100644
index 0000000000000..55a0c51c2ac65
--- /dev/null
+++ b/src/legacy/core_plugins/newsfeed/constants.ts
@@ -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';
diff --git a/src/legacy/core_plugins/newsfeed/index.ts b/src/legacy/core_plugins/newsfeed/index.ts
new file mode 100644
index 0000000000000..cf8852be09a1e
--- /dev/null
+++ b/src/legacy/core_plugins/newsfeed/index.ts
@@ -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 {
+ 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);
+}
diff --git a/src/legacy/core_plugins/newsfeed/package.json b/src/legacy/core_plugins/newsfeed/package.json
new file mode 100644
index 0000000000000..d4d753f32b0f9
--- /dev/null
+++ b/src/legacy/core_plugins/newsfeed/package.json
@@ -0,0 +1,4 @@
+{
+ "name": "newsfeed",
+ "version": "kibana"
+}
diff --git a/src/legacy/core_plugins/newsfeed/public/index.scss b/src/legacy/core_plugins/newsfeed/public/index.scss
new file mode 100644
index 0000000000000..a77132379041c
--- /dev/null
+++ b/src/legacy/core_plugins/newsfeed/public/index.scss
@@ -0,0 +1,3 @@
+@import 'src/legacy/ui/public/styles/styling_constants';
+
+@import './np_ready/components/header_alert/_index';
diff --git a/src/legacy/core_plugins/newsfeed/public/np_ready/components/header_alert/_index.scss b/src/legacy/core_plugins/newsfeed/public/np_ready/components/header_alert/_index.scss
new file mode 100644
index 0000000000000..e25dbd25daaf5
--- /dev/null
+++ b/src/legacy/core_plugins/newsfeed/public/np_ready/components/header_alert/_index.scss
@@ -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;
+ }
+}
diff --git a/src/legacy/core_plugins/newsfeed/public/np_ready/components/header_alert/header_alert.tsx b/src/legacy/core_plugins/newsfeed/public/np_ready/components/header_alert/header_alert.tsx
new file mode 100644
index 0000000000000..c3c3e4144fca8
--- /dev/null
+++ b/src/legacy/core_plugins/newsfeed/public/np_ready/components/header_alert/header_alert.tsx
@@ -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 (
+
+ {(dismiss: any) => (
+
+
+
+ {date}
+
+ {badgeContent}
+
+
+
{title}
+
{text}
+
{action}
+
+ )}
+
+ );
+};
+
+EuiHeaderAlert.propTypes = {
+ action: PropTypes.node,
+ className: PropTypes.string,
+ date: PropTypes.node.isRequired,
+ text: PropTypes.node,
+ title: PropTypes.node.isRequired,
+ badge: PropTypes.node,
+};
diff --git a/src/plugins/newsfeed/constants.ts b/src/plugins/newsfeed/constants.ts
new file mode 100644
index 0000000000000..ddcbbb6cb1dbe
--- /dev/null
+++ b/src/plugins/newsfeed/constants.ts
@@ -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';
diff --git a/src/plugins/newsfeed/kibana.json b/src/plugins/newsfeed/kibana.json
new file mode 100644
index 0000000000000..9d49b42424a06
--- /dev/null
+++ b/src/plugins/newsfeed/kibana.json
@@ -0,0 +1,6 @@
+{
+ "id": "newsfeed",
+ "version": "kibana",
+ "server": false,
+ "ui": true
+}
diff --git a/src/plugins/newsfeed/public/components/__snapshots__/empty_news.test.tsx.snap b/src/plugins/newsfeed/public/components/__snapshots__/empty_news.test.tsx.snap
new file mode 100644
index 0000000000000..8764b7664d449
--- /dev/null
+++ b/src/plugins/newsfeed/public/components/__snapshots__/empty_news.test.tsx.snap
@@ -0,0 +1,27 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`empty_news rendering renders the default Empty News 1`] = `
+
+
+
+ }
+ data-test-subj="emptyNewsfeed"
+ iconType="documents"
+ title={
+
+
+
+ }
+ titleSize="s"
+/>
+`;
diff --git a/src/plugins/newsfeed/public/components/__snapshots__/loading_news.test.tsx.snap b/src/plugins/newsfeed/public/components/__snapshots__/loading_news.test.tsx.snap
new file mode 100644
index 0000000000000..2e88b0053535e
--- /dev/null
+++ b/src/plugins/newsfeed/public/components/__snapshots__/loading_news.test.tsx.snap
@@ -0,0 +1,20 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`news_loading rendering renders the default News Loading 1`] = `
+
+
+
+ }
+ title={
+
+ }
+/>
+`;
diff --git a/src/plugins/newsfeed/public/components/empty_news.test.tsx b/src/plugins/newsfeed/public/components/empty_news.test.tsx
new file mode 100644
index 0000000000000..33702df00a583
--- /dev/null
+++ b/src/plugins/newsfeed/public/components/empty_news.test.tsx
@@ -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();
+ expect(toJson(wrapper)).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/plugins/newsfeed/public/components/empty_news.tsx b/src/plugins/newsfeed/public/components/empty_news.tsx
new file mode 100644
index 0000000000000..cec18e0bdec43
--- /dev/null
+++ b/src/plugins/newsfeed/public/components/empty_news.tsx
@@ -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 (
+
+
+
+ }
+ body={
+
+
+
+ }
+ />
+ );
+};
diff --git a/src/plugins/newsfeed/public/components/flyout_list.tsx b/src/plugins/newsfeed/public/components/flyout_list.tsx
new file mode 100644
index 0000000000000..8a99abe18a75f
--- /dev/null
+++ b/src/plugins/newsfeed/public/components/flyout_list.tsx
@@ -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 (
+
+
+
+
+
+
+
+
+
+ {!newsFetchResult ? (
+
+ ) : newsFetchResult.feedItems.length > 0 ? (
+ newsFetchResult.feedItems.map((item: NewsfeedItem) => {
+ return (
+
+ {item.linkText}
+
+
+ }
+ date={item.publishOn.format('DD MMMM YYYY')}
+ badge={{item.badge}}
+ />
+ );
+ })
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+
+ {newsFetchResult ? (
+
+
+
+
+
+ ) : null}
+
+
+
+
+ );
+};
diff --git a/src/plugins/newsfeed/public/components/loading_news.test.tsx b/src/plugins/newsfeed/public/components/loading_news.test.tsx
new file mode 100644
index 0000000000000..ca449b8ee879e
--- /dev/null
+++ b/src/plugins/newsfeed/public/components/loading_news.test.tsx
@@ -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();
+ expect(toJson(wrapper)).toMatchSnapshot();
+ });
+ });
+});
diff --git a/src/plugins/newsfeed/public/components/loading_news.tsx b/src/plugins/newsfeed/public/components/loading_news.tsx
new file mode 100644
index 0000000000000..fcbc7970377d4
--- /dev/null
+++ b/src/plugins/newsfeed/public/components/loading_news.tsx
@@ -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 (
+ }
+ body={
+
+
+
+ }
+ />
+ );
+};
diff --git a/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx b/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx
new file mode 100644
index 0000000000000..da042f0fce7b6
--- /dev/null
+++ b/src/plugins/newsfeed/public/components/newsfeed_header_nav_button.tsx
@@ -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>;
+ newsFetchResult: FetchResult | void | null;
+}
+export const NewsfeedContext = React.createContext({} as INewsfeedContext);
+
+export type NewsfeedApiFetchResult = Rx.Observable;
+
+export interface Props {
+ apiFetchResult: NewsfeedApiFetchResult;
+}
+
+export const NewsfeedNavButton = ({ apiFetchResult }: Props) => {
+ const [showBadge, setShowBadge] = useState(false);
+ const [flyoutVisible, setFlyoutVisible] = useState(false);
+ const [newsFetchResult, setNewsFetchResult] = useState(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 (
+
+
+
+
+ {showBadge ? (
+
+ ▪
+
+ ) : null}
+
+ {flyoutVisible ? : null}
+
+
+ );
+};
diff --git a/src/plugins/newsfeed/public/index.ts b/src/plugins/newsfeed/public/index.ts
new file mode 100644
index 0000000000000..1217de60d9638
--- /dev/null
+++ b/src/plugins/newsfeed/public/index.ts
@@ -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);
+}
diff --git a/src/plugins/newsfeed/public/lib/api.test.ts b/src/plugins/newsfeed/public/lib/api.test.ts
new file mode 100644
index 0000000000000..5324181f33e05
--- /dev/null
+++ b/src/plugins/newsfeed/public/lib/api.test.ts
@@ -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 { mapTo, 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).pipe(mapTo(undefined)); // 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();
+ });
+ });
+ });
+});
diff --git a/src/plugins/newsfeed/public/lib/api.ts b/src/plugins/newsfeed/public/lib/api.ts
new file mode 100644
index 0000000000000..6920dd9b2bccc
--- /dev/null
+++ b/src/plugins/newsfeed/public/lib/api.ts
@@ -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 {
+ 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) {
+ 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 {
+ 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())
+ );
+}
diff --git a/src/plugins/newsfeed/public/plugin.tsx b/src/plugins/newsfeed/public/plugin.tsx
new file mode 100644
index 0000000000000..5ea5e5b324717
--- /dev/null
+++ b/src/plugins/newsfeed/public/plugin.tsx
@@ -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 {
+ 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(
+
+
+ ,
+ targetDomElement
+ );
+ return () => ReactDOM.unmountComponentAtNode(targetDomElement);
+ }
+}
diff --git a/src/plugins/newsfeed/types.ts b/src/plugins/newsfeed/types.ts
new file mode 100644
index 0000000000000..78485c6ee4f59
--- /dev/null
+++ b/src/plugins/newsfeed/types.ts
@@ -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;
+}
diff --git a/tasks/function_test_groups.js b/tasks/function_test_groups.js
index 69b998c4f229b..bbb0981134665 100644
--- a/tasks/function_test_groups.js
+++ b/tasks/function_test_groups.js
@@ -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',
diff --git a/test/common/config.js b/test/common/config.js
index cd29b593cdadb..58161e545bd06 100644
--- a/test/common/config.js
+++ b/test/common/config.js
@@ -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
diff --git a/test/common/fixtures/plugins/newsfeed/index.ts b/test/common/fixtures/plugins/newsfeed/index.ts
new file mode 100644
index 0000000000000..beee9bb5c6069
--- /dev/null
+++ b/test/common/fixtures/plugins/newsfeed/index.ts
@@ -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}`);
+ },
+ });
+}
diff --git a/test/common/fixtures/plugins/newsfeed/newsfeed_simulation.ts b/test/common/fixtures/plugins/newsfeed/newsfeed_simulation.ts
new file mode 100644
index 0000000000000..2a7ea3793324d
--- /dev/null
+++ b/test/common/fixtures/plugins/newsfeed/newsfeed_simulation.ts
@@ -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);
+}
diff --git a/test/common/fixtures/plugins/newsfeed/package.json b/test/common/fixtures/plugins/newsfeed/package.json
new file mode 100644
index 0000000000000..5291b1031b0a9
--- /dev/null
+++ b/test/common/fixtures/plugins/newsfeed/package.json
@@ -0,0 +1,7 @@
+{
+ "name": "newsfeed-fixtures",
+ "version": "0.0.0",
+ "kibana": {
+ "version": "kibana"
+ }
+}
diff --git a/test/functional/apps/home/_newsfeed.ts b/test/functional/apps/home/_newsfeed.ts
new file mode 100644
index 0000000000000..99c6f142f56ab
--- /dev/null
+++ b/test/functional/apps/home/_newsfeed.ts
@@ -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 expect from '@kbn/expect';
+import { FtrProviderContext } from '../../ftr_provider_context';
+
+// eslint-disable-next-line import/no-default-export
+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);
+ });
+ });
+}
diff --git a/test/functional/apps/home/index.js b/test/functional/apps/home/index.js
index 17c93680088cb..f3f564fbd2919 100644
--- a/test/functional/apps/home/index.js
+++ b/test/functional/apps/home/index.js
@@ -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'));
});
diff --git a/test/functional/page_objects/index.ts b/test/functional/page_objects/index.ts
index 1e8c454f42cfe..84562990191d1 100644
--- a/test/functional/page_objects/index.ts
+++ b/test/functional/page_objects/index.ts
@@ -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,
diff --git a/test/functional/page_objects/newsfeed_page.ts b/test/functional/page_objects/newsfeed_page.ts
new file mode 100644
index 0000000000000..24ff21f0b47de
--- /dev/null
+++ b/test/functional/page_objects/newsfeed_page.ts
@@ -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();
+}
diff --git a/test/functional/services/global_nav.ts b/test/functional/services/global_nav.ts
index 164ea999fa279..df3aac67f22a1 100644
--- a/test/functional/services/global_nav.ts
+++ b/test/functional/services/global_nav.ts
@@ -32,6 +32,10 @@ export function GlobalNavProvider({ getService }: FtrProviderContext) {
return await testSubjects.click('headerGlobalNav > logo');
}
+ public async clickNewsfeed(): Promise {
+ return await testSubjects.click('headerGlobalNav > newsfeed');
+ }
+
public async exists(): Promise {
return await testSubjects.exists('headerGlobalNav');
}
diff --git a/test/ui_capabilities/newsfeed_err/config.ts b/test/ui_capabilities/newsfeed_err/config.ts
new file mode 100644
index 0000000000000..1f5f770e8447c
--- /dev/null
+++ b/test/ui_capabilities/newsfeed_err/config.ts
@@ -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',
+ },
+ };
+};
diff --git a/test/ui_capabilities/newsfeed_err/test.ts b/test/ui_capabilities/newsfeed_err/test.ts
new file mode 100644
index 0000000000000..2aa81f34028a0
--- /dev/null
+++ b/test/ui_capabilities/newsfeed_err/test.ts
@@ -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);
+ });
+ });
+}