]> source.dussan.org Git - sonarqube.git/commitdiff
SONARCLOUD-271 Create new Notification sidebar
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Thu, 13 Dec 2018 17:17:07 +0000 (18:17 +0100)
committersonartech <sonartech@sonarsource.com>
Thu, 20 Dec 2018 10:41:51 +0000 (11:41 +0100)
* Refactor userSettings store, use currentUser instead: Settings will now be stored on the currently logged in user, and will no
longer live on its own.
* Only show latest feature news as unread: If there's no notificationsLastReadDate prop, only show the latest
feature news as unread, instead of all of them.
* Use Modal component to render the nofitications sidebar

23 files changed:
server/sonar-web/src/main/js/api/news.ts
server/sonar-web/src/main/js/api/user-settings.ts [deleted file]
server/sonar-web/src/main/js/api/users.ts
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavNotifications.tsx [deleted file]
server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNav-test.tsx
server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavNotifications-test.tsx [deleted file]
server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap
server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavNotifications-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/app/components/notifications/NavLatestNotification.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/notifications/NotificationsSidebar.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/notifications/__tests__/NavLatestNotification-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/notifications/__tests__/NotificationsSidebar-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NavLatestNotification-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NotificationsSidebar-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/app/components/notifications/notifications.css [new file with mode: 0644]
server/sonar-web/src/main/js/app/theme.js
server/sonar-web/src/main/js/app/types.d.ts
server/sonar-web/src/main/js/apps/overview/styles.css
server/sonar-web/src/main/js/store/rootReducer.ts
server/sonar-web/src/main/js/store/users.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 4b11b157763c096c44baaad05d02c3c1aa71a4b2..724578851c5ce213f51a2c8ba5c69e01a0111c06 100644 (file)
@@ -30,6 +30,13 @@ export interface PrismicNews {
   uid: string;
 }
 
+interface PrismicResponse {
+  page: number;
+  results: PrismicResult[];
+  results_per_page: number;
+  total_results_size: number;
+}
+
 interface PrismicResult {
   data: {
     notification: string;
@@ -101,28 +108,32 @@ export function fetchPrismicNews(data: {
 
 export function fetchPrismicFeatureNews(data: {
   accessToken: string;
+  p?: number;
   ps?: number;
   ref: string;
-}): Promise<PrismicFeatureNews[]> {
-  const q = ['[[at(document.type, "sc_product_news")]]'];
+}): Promise<{ news: PrismicFeatureNews[]; paging: T.Paging }> {
   return getCorsJSON(PRISMIC_API_URL + '/documents/search', {
     access_token: data.accessToken,
-    orderings: '[document.first_publication_date desc]',
-    pageSize: data.ps || 1,
-    q,
     fetchLinks: 'sc_category.color,sc_category.name',
+    orderings: '[my.sc_product_news.publication_date desc]',
+    page: data.p || 1,
+    pageSize: data.ps || 1,
+    q: ['[[at(document.type, "sc_product_news")]]'],
     ref: data.ref
-  }).then(({ results }: { results: PrismicResult[] }) => {
-    return results.map(result => {
-      return {
-        notification: result.data.notification,
-        publicationDate: result.data.publication_date,
-        features: result.data.body.map(feature => ({
-          categories: feature.items.map(item => item.category.data),
-          description: feature.primary.description,
-          readMore: feature.primary.read_more_link.url
-        }))
-      };
-    });
-  });
+  }).then(({ page, results, results_per_page, total_results_size }: PrismicResponse) => ({
+    news: results.map(result => ({
+      notification: result.data.notification,
+      publicationDate: result.data.publication_date,
+      features: result.data.body.map(feature => ({
+        categories: feature.items.map(item => item.category.data).filter(Boolean),
+        description: feature.primary.description,
+        readMore: feature.primary.read_more_link.url
+      }))
+    })),
+    paging: {
+      pageIndex: page,
+      pageSize: results_per_page,
+      total: total_results_size
+    }
+  }));
 }
diff --git a/server/sonar-web/src/main/js/api/user-settings.ts b/server/sonar-web/src/main/js/api/user-settings.ts
deleted file mode 100644 (file)
index f28c9fb..0000000
+++ /dev/null
@@ -1,41 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import { getJSON, post } from '../helpers/request';
-import throwGlobalError from '../app/utils/throwGlobalError';
-
-export function setUserSetting(data: T.CurrentUserSettingData) {
-  return post('/api/user_settings/set', data)
-    .catch(() => Promise.resolve()) // TODO Remove mock.
-    .catch(throwGlobalError);
-}
-
-export function listUserSettings(): Promise<{ userSettings: T.CurrentUserSettingData[] }> {
-  return getJSON('/api/user_settings/list')
-    .catch(() => {
-      // TODO Remove mock.
-      return {
-        userSettings: [
-          { key: 'notificationsReadDate', value: '2018-12-01T12:07:19+0000' },
-          { key: 'notificationsOptOut', value: 'false' }
-        ]
-      };
-    })
-    .catch(throwGlobalError);
-}
index 75405a9173f9becb032b48215d18d66d6900362e..3a882729733d63c84c1503cad456287650d2799a 100644 (file)
@@ -106,3 +106,7 @@ export function skipOnboarding(): Promise<void | Response> {
 export function setHomePage(homepage: T.HomePage): Promise<void | Response> {
   return post('/api/users/set_homepage', homepage).catch(throwGlobalError);
 }
+
+export function setUserSetting(setting: T.CurrentUserSetting): Promise<void | Response> {
+  return post('/api/users/set_setting', setting).catch(throwGlobalError);
+}
index c4eb2f4b6985f0ab31e89ed9c25160a8dc47d402..62638ec97ee9cdcab8b48b9bf766544474b8315e 100644 (file)
@@ -19,7 +19,7 @@
  */
 .navbar-global,
 .navbar-global .navbar-inner {
-  background-color: #262626;
+  background-color: var(--globalNavBarBg);
   z-index: 421;
 }
 
   margin-left: calc(5 * var(--gridSize));
 }
 
-.navbar-latest-notification {
-  flex: 0 1 350px;
-  text-align: right;
-  overflow: hidden;
-}
-
-.navbar-latest-notification-wrapper {
-  position: relative;
-  display: inline-block;
-  padding: var(--gridSize) 34px var(--gridSize) 50px;
-  height: 28px;
-  max-width: 100%;
-  box-sizing: border-box;
-  overflow: hidden;
-  vertical-align: middle;
-  font-size: var(--smallFontSize);
-  color: var(--sonarcloudBlack500);
-  background-color: black;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-  border-radius: 3px;
-  cursor: pointer;
-}
-
-.navbar-latest-notification-wrapper:hover {
-  color: var(--sonarcloudBlack300);
-}
-
-.navbar-latest-notification-wrapper .badge {
-  position: absolute;
-  height: 18px;
-  margin-right: var(--gridSize);
-  left: calc(var(--gridSize) / 2);
-  top: 5px;
-  font-size: var(--verySmallFontSize);
-  text-transform: uppercase;
-  background-color: var(--lightBlue);
-  color: var(--darkBlue);
-}
-
-.navbar-latest-notification-wrapper .label {
-  display: block;
-  max-width: 300px;
-  overflow: hidden;
-  text-overflow: ellipsis;
-  white-space: nowrap;
-}
-
-.navbar-latest-notification .navbar-icon {
-  position: absolute;
-  right: 0;
-  top: 0;
-  height: 28px;
-  padding: 9px var(--gridSize) !important;
-  border-left: 2px solid #262626;
-}
-
-.navbar-latest-notification .navbar-icon:hover path {
-  fill: var(--sonarcloudBlack300) !important;
-}
-
 .global-navbar-menu-right .navbar-search {
   flex: 0 1 310px; /* Workaround for SONAR-10971 */
   min-width: 0;
index 0c69b523c1a79f864e1f2fb297ce57c528dbfab7..c7dcae01b7a43eb5ad4563c323008a73f975dcd3 100644 (file)
@@ -22,35 +22,151 @@ import { connect } from 'react-redux';
 import GlobalNavBranding, { SonarCloudNavBranding } from './GlobalNavBranding';
 import GlobalNavMenu from './GlobalNavMenu';
 import GlobalNavExplore from './GlobalNavExplore';
-import GlobalNavNotifications from './GlobalNavNotifications';
 import GlobalNavUserContainer from './GlobalNavUserContainer';
+import NotificationsSidebar from '../../notifications/NotificationsSidebar';
+import NavLatestNotification from '../../notifications/NavLatestNotification';
 import Search from '../../search/Search';
 import EmbedDocsPopupHelper from '../../embed-docs-modal/EmbedDocsPopupHelper';
 import * as theme from '../../../theme';
 import NavBar from '../../../../components/nav/NavBar';
 import { lazyLoad } from '../../../../components/lazyLoad';
-import { getCurrentUser, getAppState, Store } from '../../../../store/rootReducer';
+import {
+  fetchPrismicRefs,
+  fetchPrismicFeatureNews,
+  PrismicFeatureNews
+} from '../../../../api/news';
+import {
+  getCurrentUser,
+  getCurrentUserSetting,
+  getAppState,
+  getGlobalSettingValue,
+  Store
+} from '../../../../store/rootReducer';
 import { isSonarCloud } from '../../../../helpers/system';
 import { isLoggedIn } from '../../../../helpers/users';
 import { OnboardingContext } from '../../OnboardingContext';
+import { setCurrentUserSetting } from '../../../../store/users';
 import './GlobalNav.css';
+import { parseDate } from '../../../../helpers/dates';
 
 const GlobalNavPlus = lazyLoad(() => import('./GlobalNavPlus'), 'GlobalNavPlus');
 
-interface StateProps {
+interface Props {
+  accessToken?: string;
   appState: Pick<T.AppState, 'canAdmin' | 'globalPages' | 'organizationsEnabled' | 'qualifiers'>;
   currentUser: T.CurrentUser;
+  location: { pathname: string };
+  notificationsLastReadDate?: Date;
+  notificationsOptOut?: boolean;
+  setCurrentUserSetting: (setting: T.CurrentUserSetting) => void;
 }
 
-interface OwnProps {
-  location: { pathname: string };
+interface State {
+  notificationSidebar?: boolean;
+  loadingNews: boolean;
+  loadingMoreNews: boolean;
+  news: PrismicFeatureNews[];
+  newsPaging?: T.Paging;
+  newsRef?: string;
 }
 
-type Props = StateProps & OwnProps;
+const PAGE_SIZE = 5;
+
+export class GlobalNav extends React.PureComponent<Props, State> {
+  mounted = false;
+  state: State = {
+    loadingNews: false,
+    loadingMoreNews: false,
+    news: [],
+    notificationSidebar: false
+  };
+
+  componentDidMount() {
+    this.mounted = true;
+    if (isSonarCloud()) {
+      this.fetchFeatureNews();
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+  }
+
+  fetchFeatureNews = () => {
+    const { accessToken } = this.props;
+    if (accessToken) {
+      this.setState({ loadingNews: true });
+      fetchPrismicRefs()
+        .then(({ ref }) => {
+          if (this.mounted) {
+            this.setState({ newsRef: ref });
+          }
+          return ref;
+        })
+        .then(ref => fetchPrismicFeatureNews({ accessToken, ref, ps: PAGE_SIZE }))
+        .then(
+          ({ news, paging }) => {
+            if (this.mounted) {
+              this.setState({
+                loadingNews: false,
+                news,
+                newsPaging: paging
+              });
+            }
+          },
+          () => {
+            if (this.mounted) {
+              this.setState({ loadingNews: false });
+            }
+          }
+        );
+    }
+  };
+
+  fetchMoreFeatureNews = () => {
+    const { accessToken } = this.props;
+    const { newsPaging, newsRef } = this.state;
+    if (accessToken && newsPaging && newsRef) {
+      this.setState({ loadingMoreNews: true });
+      fetchPrismicFeatureNews({
+        accessToken,
+        ref: newsRef,
+        p: newsPaging.pageIndex + 1,
+        ps: PAGE_SIZE
+      }).then(
+        ({ news, paging }) => {
+          if (this.mounted) {
+            this.setState(state => ({
+              loadingMoreNews: false,
+              news: [...state.news, ...news],
+              newsPaging: paging
+            }));
+          }
+        },
+        () => {
+          if (this.mounted) {
+            this.setState({ loadingMoreNews: false });
+          }
+        }
+      );
+    }
+  };
+
+  handleOpenNotificationSidebar = () => {
+    this.setState({ notificationSidebar: true });
+    this.fetchFeatureNews();
+  };
+
+  handleCloseNotificationSidebar = () => {
+    this.setState({ notificationSidebar: false });
+    const lastNews = this.state.news[0];
+    const readDate = lastNews ? parseDate(lastNews.publicationDate).getTime() : Date.now();
+    this.props.setCurrentUserSetting({ key: 'notifications.readDate', value: readDate.toString() });
+  };
 
-export class GlobalNav extends React.PureComponent<Props> {
   render() {
     const { appState, currentUser } = this.props;
+    const { news } = this.state;
     return (
       <NavBar className="navbar-global" height={theme.globalNavHeightRaw} id="global-navigation">
         {isSonarCloud() ? <SonarCloudNavBranding /> : <GlobalNavBranding />}
@@ -58,7 +174,16 @@ export class GlobalNav extends React.PureComponent<Props> {
         <GlobalNavMenu {...this.props} />
 
         <ul className="global-navbar-menu global-navbar-menu-right">
-          {isSonarCloud() && <GlobalNavNotifications />}
+          {isSonarCloud() &&
+            news.length > 0 && (
+              <NavLatestNotification
+                lastNews={news[0]}
+                notificationsLastReadDate={this.props.notificationsLastReadDate}
+                notificationsOptOut={this.props.notificationsOptOut}
+                onClick={this.handleOpenNotificationSidebar}
+                setCurrentUserSetting={this.props.setCurrentUserSetting}
+              />
+            )}
           {isSonarCloud() && <GlobalNavExplore location={this.props.location} />}
           <EmbedDocsPopupHelper />
           <Search appState={appState} currentUser={currentUser} />
@@ -75,14 +200,44 @@ export class GlobalNav extends React.PureComponent<Props> {
           )}
           <GlobalNavUserContainer appState={appState} currentUser={currentUser} />
         </ul>
+        {isSonarCloud() &&
+          this.state.notificationSidebar && (
+            <NotificationsSidebar
+              fetchMoreFeatureNews={this.fetchMoreFeatureNews}
+              loading={this.state.loadingNews}
+              loadingMore={this.state.loadingMoreNews}
+              news={news}
+              notificationsLastReadDate={this.props.notificationsLastReadDate}
+              onClose={this.handleCloseNotificationSidebar}
+              paging={this.state.newsPaging}
+            />
+          )}
       </NavBar>
     );
   }
 }
 
-const mapStateToProps = (state: Store): StateProps => ({
-  currentUser: getCurrentUser(state),
-  appState: getAppState(state)
-});
+const mapStateToProps = (state: Store) => {
+  const accessToken = getGlobalSettingValue(state, 'sonar.prismic.accessToken');
+  const notificationsLastReadDate = getCurrentUserSetting(state, 'notifications.readDate');
+  const notificationsOptOut = getCurrentUserSetting(state, 'notifications.optOut') === 'true';
+
+  return {
+    currentUser: getCurrentUser(state),
+    appState: getAppState(state),
+    accessToken: accessToken && accessToken.value,
+    notificationsLastReadDate: notificationsLastReadDate
+      ? parseDate(Number(notificationsLastReadDate))
+      : undefined,
+    notificationsOptOut
+  };
+};
+
+const mapDispatchToProps = {
+  setCurrentUserSetting
+};
 
-export default connect(mapStateToProps)(GlobalNav);
+export default connect(
+  mapStateToProps,
+  mapDispatchToProps
+)(GlobalNav);
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavNotifications.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavNotifications.tsx
deleted file mode 100644 (file)
index 7b00dcc..0000000
+++ /dev/null
@@ -1,152 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import { connect } from 'react-redux';
-import ClearIcon from '../../../../components/icons-components/ClearIcon';
-import NotificationIcon from '../../../../components/icons-components/NotificationIcon';
-import { sonarcloudBlack500 } from '../../../theme';
-import {
-  fetchPrismicRefs,
-  fetchPrismicFeatureNews,
-  PrismicFeatureNews
-} from '../../../../api/news';
-import { differenceInSeconds, parseDate } from '../../../../helpers/dates';
-import { translate } from '../../../../helpers/l10n';
-import { fetchCurrentUserSettings, setCurrentUserSetting } from '../../../../store/users';
-import {
-  getGlobalSettingValue,
-  getCurrentUserSettings,
-  Store
-} from '../../../../store/rootReducer';
-
-interface Props {
-  accessToken?: string;
-  fetchCurrentUserSettings: () => void;
-  notificationsLastReadDate?: Date;
-  notificationsOptOut?: boolean;
-  setCurrentUserSetting: (setting: T.CurrentUserSettingData) => void;
-}
-
-interface State {
-  news: PrismicFeatureNews[];
-  ready: boolean;
-}
-
-export class GlobalNavNotifications extends React.PureComponent<Props, State> {
-  mounted = false;
-  state: State = { news: [], ready: false };
-
-  componentDidMount() {
-    this.mounted = true;
-    this.fetchPrismicFeatureNews();
-    this.props.fetchCurrentUserSettings();
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  checkHasUnread = () => {
-    const lastNews = this.state.news[0];
-    if (!lastNews) {
-      return false;
-    }
-
-    const { notificationsLastReadDate } = this.props;
-    return (
-      !notificationsLastReadDate ||
-      differenceInSeconds(parseDate(lastNews.publicationDate), notificationsLastReadDate) > 0
-    );
-  };
-
-  fetchPrismicFeatureNews = () => {
-    const { accessToken } = this.props;
-    if (accessToken) {
-      fetchPrismicRefs()
-        .then(({ ref }) => fetchPrismicFeatureNews({ accessToken, ref, ps: 10 }))
-        .then(
-          news => {
-            if (this.mounted && news) {
-              this.setState({ ready: true, news });
-            }
-          },
-          () => {}
-        );
-    }
-  };
-
-  handleDismiss = (event: React.MouseEvent<HTMLAnchorElement>) => {
-    event.preventDefault();
-    this.props.setCurrentUserSetting({
-      key: 'notificationsReadDate',
-      value: new Date().toISOString()
-    });
-  };
-
-  render() {
-    if (!this.state.ready) {
-      return null;
-    }
-
-    const { notificationsOptOut } = this.props;
-    const lastNews = this.state.news[0];
-    const hasUnread = this.checkHasUnread();
-    const showNotifications = Boolean(!notificationsOptOut && lastNews && hasUnread);
-    return (
-      <>
-        {showNotifications && (
-          <li className="navbar-latest-notification">
-            <div className="navbar-latest-notification-wrapper">
-              <span className="badge">{translate('new')}</span>
-              <span className="label">{lastNews.notification}</span>
-              <a className="navbar-icon" href="#" onClick={this.handleDismiss}>
-                <ClearIcon fill={sonarcloudBlack500} size={10} />
-              </a>
-            </div>
-          </li>
-        )}
-        <li>
-          <a className="navbar-icon">
-            <NotificationIcon hasUnread={hasUnread && !notificationsOptOut} />
-          </a>
-        </li>
-      </>
-    );
-  }
-}
-
-const mapStateToProps = (state: Store) => {
-  const accessToken = getGlobalSettingValue(state, 'sonar.prismic.accessToken');
-  const userSettings = getCurrentUserSettings(state);
-  return {
-    accessToken: accessToken && accessToken.value,
-    notificationsLastReadDate: userSettings.notificationsReadDate
-      ? parseDate(userSettings.notificationsReadDate)
-      : undefined,
-    notificationsOptOut: userSettings.notificationsReadDate === 'true'
-  };
-};
-
-const mapDispatchToProps = { fetchCurrentUserSettings, setCurrentUserSetting };
-
-export default connect(
-  mapStateToProps,
-  mapDispatchToProps
-)(GlobalNavNotifications);
index edcf79720848db4fefe3b36f27f264682f4648f1..e59ca5983cfa0c815aca8dc6119497e888f5972a 100644 (file)
@@ -21,9 +21,53 @@ import * as React from 'react';
 import { shallow } from 'enzyme';
 import { GlobalNav } from '../GlobalNav';
 import { isSonarCloud } from '../../../../../helpers/system';
+import { waitAndUpdate, click } from '../../../../../helpers/testUtils';
+import {
+  fetchPrismicRefs,
+  fetchPrismicFeatureNews,
+  PrismicFeatureNews
+} from '../../../../../api/news';
 
 jest.mock('../../../../../helpers/system', () => ({ isSonarCloud: jest.fn() }));
 
+// Solve redux warning issue "No reducer provided for key":
+// https://stackoverflow.com/questions/43375079/redux-warning-only-appearing-in-tests
+jest.mock('../../../../../store/rootReducer');
+
+jest.mock('../../../../../api/news', () => {
+  const prismicResult: PrismicFeatureNews[] = [
+    {
+      notification: '10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration',
+      publicationDate: '2018-04-06',
+      features: [
+        {
+          categories: [{ color: '#ff0000', name: 'Java' }],
+          description: '10 new Java rules'
+        }
+      ]
+    },
+    {
+      notification: 'Some other notification',
+      publicationDate: '2018-04-05',
+      features: [
+        {
+          categories: [{ color: '#0000ff', name: 'BitBucket' }],
+          description: 'BitBucket branch decoration',
+          readMore: 'http://example.com'
+        }
+      ]
+    }
+  ];
+
+  return {
+    fetchPrismicRefs: jest.fn().mockResolvedValue({ ref: 'master-ref' }),
+    fetchPrismicFeatureNews: jest.fn().mockResolvedValue({
+      news: prismicResult,
+      paging: { pageIndex: 1, pageSize: 10, total: 2 }
+    })
+  };
+});
+
 const appState: GlobalNav['props']['appState'] = {
   globalPages: [],
   canAdmin: false,
@@ -32,20 +76,57 @@ const appState: GlobalNav['props']['appState'] = {
 };
 const location = { pathname: '' };
 
-it('should render for SonarQube', () => {
-  runTest(false);
+beforeEach(() => {
+  (fetchPrismicRefs as jest.Mock).mockClear();
+  (fetchPrismicFeatureNews as jest.Mock).mockClear();
 });
 
-it('should render for SonarCloud', () => {
-  runTest(true);
+it('should render for SonarQube', async () => {
+  (isSonarCloud as jest.Mock).mockImplementation(() => false);
+
+  const wrapper = shallowRender();
+
+  expect(wrapper).toMatchSnapshot();
+  wrapper.setProps({ currentUser: { isLoggedIn: true } });
+  expect(wrapper.find('[data-test="global-nav-plus"]').exists()).toBe(true);
+
+  await waitAndUpdate(wrapper);
+  expect(fetchPrismicRefs).not.toBeCalled();
 });
 
-function runTest(mockedIsSonarCloud: boolean) {
-  (isSonarCloud as jest.Mock).mockImplementation(() => mockedIsSonarCloud);
-  const wrapper = shallow(
-    <GlobalNav appState={appState} currentUser={{ isLoggedIn: false }} location={location} />
-  );
+it('should render for SonarCloud', () => {
+  (isSonarCloud as jest.Mock).mockImplementation(() => true);
+
+  const wrapper = shallowRender();
+
   expect(wrapper).toMatchSnapshot();
   wrapper.setProps({ currentUser: { isLoggedIn: true } });
   expect(wrapper.find('[data-test="global-nav-plus"]').exists()).toBe(true);
+});
+
+it('should render correctly if there are new features', async () => {
+  (isSonarCloud as jest.Mock).mockImplementation(() => true);
+
+  const wrapper = shallowRender();
+
+  await waitAndUpdate(wrapper);
+  expect(fetchPrismicRefs).toHaveBeenCalled();
+  expect(fetchPrismicFeatureNews).toHaveBeenCalled();
+  expect(wrapper).toMatchSnapshot();
+  expect(wrapper.find('NavLatestNotification').exists()).toBe(true);
+  click(wrapper.find('NavLatestNotification'));
+  expect(wrapper.find('NotificationsSidebar').exists()).toBe(true);
+});
+
+function shallowRender(props: Partial<GlobalNav['props']> = {}) {
+  return shallow(
+    <GlobalNav
+      accessToken="token"
+      appState={appState}
+      currentUser={{ isLoggedIn: false }}
+      location={location}
+      setCurrentUserSetting={jest.fn()}
+      {...props}
+    />
+  );
 }
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavNotifications-test.tsx b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavNotifications-test.tsx
deleted file mode 100644 (file)
index b39aeeb..0000000
+++ /dev/null
@@ -1,121 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2018 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
- */
-import * as React from 'react';
-import { shallow } from 'enzyme';
-import { GlobalNavNotifications } from '../GlobalNavNotifications';
-import { waitAndUpdate } from '../../../../../helpers/testUtils';
-import {
-  fetchPrismicRefs,
-  fetchPrismicFeatureNews,
-  PrismicFeatureNews
-} from '../../../../../api/news';
-import { parseDate } from '../../../../../helpers/dates';
-
-// Solve redux warning issue "No reducer provided for key":
-// https://stackoverflow.com/questions/43375079/redux-warning-only-appearing-in-tests
-jest.mock('../../../../../store/rootReducer');
-
-jest.mock('../../../../../api/news', () => {
-  const prismicResult: PrismicFeatureNews[] = [
-    {
-      notification: '10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration',
-      publicationDate: '2018-04-06',
-      features: [
-        {
-          categories: [{ color: '#ff0000', name: 'Java' }],
-          description: '10 new Java rules'
-        }
-      ]
-    },
-    {
-      notification: 'Some other notification',
-      publicationDate: '2018-04-05',
-      features: [
-        {
-          categories: [{ color: '#0000ff', name: 'BitBucket' }],
-          description: 'BitBucket branch decoration',
-          readMore: 'http://example.com'
-        }
-      ]
-    }
-  ];
-
-  return {
-    fetchPrismicRefs: jest.fn().mockResolvedValue({ ref: 'master-ref' }),
-    fetchPrismicFeatureNews: jest.fn().mockResolvedValue(prismicResult)
-  };
-});
-
-beforeEach(() => {
-  (fetchPrismicRefs as jest.Mock).mockClear();
-  (fetchPrismicFeatureNews as jest.Mock).mockClear();
-});
-
-it('should render correctly if there are new features, and the user has not opted out', async () => {
-  const wrapper = shallowRender();
-  expect(wrapper.type()).toBeNull();
-
-  await waitAndUpdate(wrapper);
-  expect(fetchPrismicRefs).toHaveBeenCalled();
-  expect(fetchPrismicFeatureNews).toHaveBeenCalled();
-  expect(wrapper).toMatchSnapshot();
-  expect(wrapper.find('.navbar-latest-notification')).toHaveLength(1);
-});
-
-it('should render correctly if there are new features, but the user has opted out', async () => {
-  const wrapper = shallowRender({ notificationsOptOut: true });
-
-  await waitAndUpdate(wrapper);
-  expect(wrapper).toMatchSnapshot();
-  expect(wrapper.find('.navbar-latest-notification')).toHaveLength(0);
-});
-
-it('should render correctly if there are no new unread features', async () => {
-  const wrapper = shallowRender({
-    notificationsLastReadDate: parseDate('2018-12-31T12:07:19+0000')
-  });
-
-  await waitAndUpdate(wrapper);
-  expect(wrapper).toMatchSnapshot();
-  expect(wrapper.find('.navbar-latest-notification')).toHaveLength(0);
-});
-
-it('should render correctly if there are no new features', async () => {
-  (fetchPrismicFeatureNews as jest.Mock<any>).mockResolvedValue([]);
-
-  const wrapper = shallowRender();
-
-  await waitAndUpdate(wrapper);
-  expect(wrapper).toMatchSnapshot();
-  expect(wrapper.find('.navbar-latest-notification')).toHaveLength(0);
-});
-
-function shallowRender(props: Partial<GlobalNavNotifications['props']> = {}) {
-  return shallow(
-    <GlobalNavNotifications
-      accessToken="token"
-      fetchCurrentUserSettings={jest.fn()}
-      notificationsLastReadDate={parseDate('2018-01-01T12:07:19+0000')}
-      notificationsOptOut={false}
-      setCurrentUserSetting={jest.fn()}
-      {...props}
-    />
-  );
-}
index b99134f2cedeabe38f6b2c6b04a7a994608e2f5b..adf97af0f51af6436b39ef7482e4e39232cd546c 100644 (file)
@@ -1,5 +1,100 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
+exports[`should render correctly if there are new features 1`] = `
+<NavBar
+  className="navbar-global"
+  height={48}
+  id="global-navigation"
+>
+  <SonarCloudNavBranding />
+  <GlobalNavMenu
+    accessToken="token"
+    appState={
+      Object {
+        "canAdmin": false,
+        "globalPages": Array [],
+        "organizationsEnabled": false,
+        "qualifiers": Array [],
+      }
+    }
+    currentUser={
+      Object {
+        "isLoggedIn": false,
+      }
+    }
+    location={
+      Object {
+        "pathname": "",
+      }
+    }
+    setCurrentUserSetting={[MockFunction]}
+  />
+  <ul
+    className="global-navbar-menu global-navbar-menu-right"
+  >
+    <NavLatestNotification
+      lastNews={
+        Object {
+          "features": Array [
+            Object {
+              "categories": Array [
+                Object {
+                  "color": "#ff0000",
+                  "name": "Java",
+                },
+              ],
+              "description": "10 new Java rules",
+            },
+          ],
+          "notification": "10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration",
+          "publicationDate": "2018-04-06",
+        }
+      }
+      onClick={[Function]}
+      setCurrentUserSetting={[MockFunction]}
+    />
+    <GlobalNavExplore
+      location={
+        Object {
+          "pathname": "",
+        }
+      }
+    />
+    <EmbedDocsPopupHelper />
+    <withRouter(Search)
+      appState={
+        Object {
+          "canAdmin": false,
+          "globalPages": Array [],
+          "organizationsEnabled": false,
+          "qualifiers": Array [],
+        }
+      }
+      currentUser={
+        Object {
+          "isLoggedIn": false,
+        }
+      }
+    />
+    <Connect(withRouter(GlobalNavUser))
+      appState={
+        Object {
+          "canAdmin": false,
+          "globalPages": Array [],
+          "organizationsEnabled": false,
+          "qualifiers": Array [],
+        }
+      }
+      currentUser={
+        Object {
+          "isLoggedIn": false,
+        }
+      }
+    />
+  </ul>
+</NavBar>
+`;
+
 exports[`should render for SonarCloud 1`] = `
 <NavBar
   className="navbar-global"
@@ -8,6 +103,7 @@ exports[`should render for SonarCloud 1`] = `
 >
   <SonarCloudNavBranding />
   <GlobalNavMenu
+    accessToken="token"
     appState={
       Object {
         "canAdmin": false,
@@ -26,11 +122,11 @@ exports[`should render for SonarCloud 1`] = `
         "pathname": "",
       }
     }
+    setCurrentUserSetting={[MockFunction]}
   />
   <ul
     className="global-navbar-menu global-navbar-menu-right"
   >
-    <Connect(GlobalNavNotifications) />
     <GlobalNavExplore
       location={
         Object {
@@ -81,6 +177,7 @@ exports[`should render for SonarQube 1`] = `
 >
   <Connect(GlobalNavBranding) />
   <GlobalNavMenu
+    accessToken="token"
     appState={
       Object {
         "canAdmin": false,
@@ -99,6 +196,7 @@ exports[`should render for SonarQube 1`] = `
         "pathname": "",
       }
     }
+    setCurrentUserSetting={[MockFunction]}
   />
   <ul
     className="global-navbar-menu global-navbar-menu-right"
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavNotifications-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavNotifications-test.tsx.snap
deleted file mode 100644 (file)
index 1b7be1c..0000000
+++ /dev/null
@@ -1,85 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`should render correctly if there are new features, and the user has not opted out 1`] = `
-<Fragment>
-  <li
-    className="navbar-latest-notification"
-  >
-    <div
-      className="navbar-latest-notification-wrapper"
-    >
-      <span
-        className="badge"
-      >
-        new
-      </span>
-      <span
-        className="label"
-      >
-        10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration
-      </span>
-      <a
-        className="navbar-icon"
-        href="#"
-        onClick={[Function]}
-      >
-        <ClearIcon
-          fill="#8a8c8f"
-          size={10}
-        />
-      </a>
-    </div>
-  </li>
-  <li>
-    <a
-      className="navbar-icon"
-    >
-      <NotificationIcon
-        hasUnread={true}
-      />
-    </a>
-  </li>
-</Fragment>
-`;
-
-exports[`should render correctly if there are new features, but the user has opted out 1`] = `
-<Fragment>
-  <li>
-    <a
-      className="navbar-icon"
-    >
-      <NotificationIcon
-        hasUnread={false}
-      />
-    </a>
-  </li>
-</Fragment>
-`;
-
-exports[`should render correctly if there are no new features 1`] = `
-<Fragment>
-  <li>
-    <a
-      className="navbar-icon"
-    >
-      <NotificationIcon
-        hasUnread={false}
-      />
-    </a>
-  </li>
-</Fragment>
-`;
-
-exports[`should render correctly if there are no new unread features 1`] = `
-<Fragment>
-  <li>
-    <a
-      className="navbar-icon"
-    >
-      <NotificationIcon
-        hasUnread={false}
-      />
-    </a>
-  </li>
-</Fragment>
-`;
diff --git a/server/sonar-web/src/main/js/app/components/notifications/NavLatestNotification.tsx b/server/sonar-web/src/main/js/app/components/notifications/NavLatestNotification.tsx
new file mode 100644 (file)
index 0000000..516c72d
--- /dev/null
@@ -0,0 +1,93 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import ClearIcon from '../../../components/icons-components/ClearIcon';
+import NotificationIcon from '../../../components/icons-components/NotificationIcon';
+import { sonarcloudBlack500 } from '../../theme';
+import { PrismicFeatureNews } from '../../../api/news';
+import { differenceInSeconds, parseDate } from '../../../helpers/dates';
+import { translate } from '../../../helpers/l10n';
+import './notifications.css';
+
+interface Props {
+  lastNews: PrismicFeatureNews;
+  notificationsLastReadDate?: Date;
+  notificationsOptOut?: boolean;
+  onClick: () => void;
+  setCurrentUserSetting: (setting: T.CurrentUserSetting) => void;
+}
+
+export default class NavLatestNotification extends React.PureComponent<Props> {
+  mounted = false;
+
+  checkHasUnread = () => {
+    const { notificationsLastReadDate, lastNews } = this.props;
+    return (
+      !notificationsLastReadDate ||
+      differenceInSeconds(parseDate(lastNews.publicationDate), notificationsLastReadDate) > 0
+    );
+  };
+
+  handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
+    event.preventDefault();
+    event.currentTarget.blur();
+    this.props.onClick();
+  };
+
+  handleDismiss = (event: React.MouseEvent<HTMLAnchorElement>) => {
+    event.preventDefault();
+    event.stopPropagation();
+
+    this.props.setCurrentUserSetting({
+      key: 'notifications.readDate',
+      value: Date.now().toString()
+    });
+  };
+
+  render() {
+    const { notificationsOptOut, lastNews } = this.props;
+    const hasUnread = this.checkHasUnread();
+    const showNotifications = Boolean(!notificationsOptOut && lastNews && hasUnread);
+    return (
+      <>
+        {showNotifications && (
+          <>
+            <li className="navbar-latest-notification" onClick={this.props.onClick}>
+              <div className="navbar-latest-notification-wrapper">
+                <span className="badge">{translate('new')}</span>
+                <span className="label">{lastNews.notification}</span>
+              </div>
+            </li>
+            <li className="navbar-latest-notification-dismiss">
+              <a className="navbar-icon" href="#" onClick={this.handleDismiss}>
+                <ClearIcon fill={sonarcloudBlack500} size={10} />
+              </a>
+            </li>
+          </>
+        )}
+        <li>
+          <a className="navbar-icon" href="#" onClick={this.handleClick}>
+            <NotificationIcon hasUnread={hasUnread && !notificationsOptOut} />
+          </a>
+        </li>
+      </>
+    );
+  }
+}
diff --git a/server/sonar-web/src/main/js/app/components/notifications/NotificationsSidebar.tsx b/server/sonar-web/src/main/js/app/components/notifications/NotificationsSidebar.tsx
new file mode 100644 (file)
index 0000000..f267574
--- /dev/null
@@ -0,0 +1,136 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import * as classNames from 'classnames';
+import ClearIcon from '../../../components/icons-components/ClearIcon';
+import DateFormatter from '../../../components/intl/DateFormatter';
+import DeferredSpinner from '../../../components/common/DeferredSpinner';
+import Modal from '../../../components/controls/Modal';
+import { PrismicFeatureNews } from '../../../api/news';
+import { differenceInSeconds, parseDate } from '../../../helpers/dates';
+import { translate } from '../../../helpers/l10n';
+
+export interface Props {
+  fetchMoreFeatureNews: () => void;
+  loading: boolean;
+  loadingMore: boolean;
+  news: PrismicFeatureNews[];
+  onClose: () => void;
+  notificationsLastReadDate?: Date;
+  paging?: T.Paging;
+}
+
+export default function NotificationsSidebar(props: Props) {
+  const { loading, loadingMore, news, notificationsLastReadDate, paging } = props;
+  return (
+    <Modal onRequestClose={props.onClose}>
+      <div className="notifications-sidebar">
+        <div className="notifications-sidebar-top">
+          <h3>{translate('embed_docs.whats_new')}</h3>
+          <a className="close" href="#" onClick={props.onClose}>
+            <ClearIcon />
+          </a>
+        </div>
+        <div className="notifications-sidebar-content">
+          {loading ? (
+            <div className="text-center">
+              <DeferredSpinner className="big-spacer-top" timeout={200} />
+            </div>
+          ) : (
+            news.map((slice, index) => (
+              <Notification
+                key={slice.publicationDate}
+                notification={slice}
+                unread={isUnread(index, slice.publicationDate, notificationsLastReadDate)}
+              />
+            ))
+          )}
+        </div>
+        {!loading &&
+          paging &&
+          paging.total > news.length && (
+            <div className="notifications-sidebar-footer">
+              <div className="spacer-top note text-center">
+                <a className="spacer-left" href="#" onClick={props.fetchMoreFeatureNews}>
+                  {translate('show_more')}
+                </a>
+                {loadingMore && (
+                  <DeferredSpinner className="vertical-bottom spacer-left position-absolute" />
+                )}
+              </div>
+            </div>
+          )}
+      </div>
+    </Modal>
+  );
+}
+
+export function isUnread(index: number, notificationDate: string, lastReadDate?: Date) {
+  return !lastReadDate
+    ? index < 1
+    : differenceInSeconds(parseDate(notificationDate), lastReadDate) > 0;
+}
+
+interface NotificationProps {
+  notification: PrismicFeatureNews;
+  unread: boolean;
+}
+
+export function Notification({ notification, unread }: NotificationProps) {
+  const publicationDate = parseDate(notification.publicationDate);
+  return (
+    <div className={classNames('notifications-sidebar-slice', { unread })}>
+      <h4>
+        <DateFormatter date={publicationDate} long={false} />
+      </h4>
+      {notification.features.map((feature, index) => (
+        <Feature feature={feature} key={index} />
+      ))}
+    </div>
+  );
+}
+
+interface FeatureProps {
+  feature: PrismicFeatureNews['features'][0];
+}
+
+export function Feature({ feature }: FeatureProps) {
+  return (
+    <div className="feature">
+      <ul className="categories">
+        {feature.categories.map(category => (
+          <li key={category.name} style={{ backgroundColor: category.color }}>
+            {category.name}
+          </li>
+        ))}
+      </ul>
+      <span>{feature.description}</span>
+      {feature.readMore && (
+        <a
+          className="learn-more"
+          href={feature.readMore}
+          rel="noopener noreferrer nofollow"
+          target="_blank">
+          {translate('learn_more')}
+        </a>
+      )}
+    </div>
+  );
+}
diff --git a/server/sonar-web/src/main/js/app/components/notifications/__tests__/NavLatestNotification-test.tsx b/server/sonar-web/src/main/js/app/components/notifications/__tests__/NavLatestNotification-test.tsx
new file mode 100644 (file)
index 0000000..9f44740
--- /dev/null
@@ -0,0 +1,67 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import NavLatestNotification from '../NavLatestNotification';
+import { PrismicFeatureNews } from '../../../../api/news';
+import { parseDate } from '../../../../helpers/dates';
+
+it('should render correctly if there are new features, and the user has not opted out', () => {
+  const wrapper = shallowRender();
+  expect(wrapper).toMatchSnapshot();
+  expect(wrapper.find('.navbar-latest-notification')).toHaveLength(1);
+});
+
+it('should render correctly if there are new features, but the user has opted out', () => {
+  const wrapper = shallowRender({ notificationsOptOut: true });
+  expect(wrapper).toMatchSnapshot();
+  expect(wrapper.find('.navbar-latest-notification')).toHaveLength(0);
+});
+
+it('should render correctly if there are no new unread features', () => {
+  const wrapper = shallowRender({
+    notificationsLastReadDate: parseDate('2018-12-31T12:07:19+0000')
+  });
+  expect(wrapper).toMatchSnapshot();
+  expect(wrapper.find('.navbar-latest-notification')).toHaveLength(0);
+});
+
+function shallowRender(props: Partial<NavLatestNotification['props']> = {}) {
+  const lastNews: PrismicFeatureNews = {
+    notification: '10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration',
+    publicationDate: '2018-04-06',
+    features: [
+      {
+        categories: [{ color: '#ff0000', name: 'Java' }],
+        description: '10 new Java rules'
+      }
+    ]
+  };
+  return shallow(
+    <NavLatestNotification
+      lastNews={lastNews}
+      notificationsLastReadDate={parseDate('2018-01-01T12:07:19+0000')}
+      notificationsOptOut={false}
+      onClick={jest.fn()}
+      setCurrentUserSetting={jest.fn()}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/app/components/notifications/__tests__/NotificationsSidebar-test.tsx b/server/sonar-web/src/main/js/app/components/notifications/__tests__/NotificationsSidebar-test.tsx
new file mode 100644 (file)
index 0000000..bad9979
--- /dev/null
@@ -0,0 +1,116 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+import * as React from 'react';
+import { shallow } from 'enzyme';
+import NotificationsSidebar, {
+  Props,
+  isUnread,
+  Notification,
+  Feature
+} from '../NotificationsSidebar';
+import { parseDate } from '../../../../helpers/dates';
+
+const news: Props['news'] = [
+  {
+    notification: '10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration',
+    publicationDate: '2018-04-06',
+    features: [
+      {
+        categories: [{ color: '#ff0000', name: 'Java' }, { color: '#00ff00', name: 'Rules' }],
+        description: '10 new Java rules'
+      },
+      {
+        categories: [{ color: '#0000ff', name: 'BitBucket' }],
+        description: 'BitBucket branch decoration',
+        readMore: 'http://example.com'
+      }
+    ]
+  },
+  {
+    notification: 'Some other notification',
+    publicationDate: '2018-04-05',
+    features: [
+      {
+        categories: [{ color: '#0000ff', name: 'BitBucket' }],
+        description: 'BitBucket branch decoration',
+        readMore: 'http://example.com'
+      }
+    ]
+  }
+];
+
+describe('#NotificationSidebar', () => {
+  it('should render correctly if there are new features', () => {
+    const wrapper = shallowRender({ loading: true });
+    expect(wrapper).toMatchSnapshot();
+    wrapper.setProps({ loading: false });
+    expect(wrapper).toMatchSnapshot();
+    expect(wrapper.find('Notification')).toHaveLength(2);
+  });
+
+  it('should render correctly if there are no new unread features', () => {
+    const wrapper = shallowRender({
+      notificationsLastReadDate: parseDate('2018-12-31')
+    });
+    expect(wrapper.find('Notification')).toHaveLength(2);
+    expect(wrapper.find('Notification[unread=true]')).toHaveLength(0);
+  });
+});
+
+describe('#isUnread', () => {
+  it('should be unread', () => {
+    expect(isUnread(0, '2018-12-14', undefined)).toBe(true);
+    expect(isUnread(1, '2018-12-14', parseDate('2018-12-12'))).toBe(true);
+  });
+
+  it('should be read', () => {
+    expect(isUnread(0, '2018-12-16', parseDate('2018-12-16'))).toBe(false);
+    expect(isUnread(1, '2018-12-15', undefined)).toBe(false);
+  });
+});
+
+describe('#Notification', () => {
+  it('should render correctly', () => {
+    expect(shallow(<Notification notification={news[1]} unread={false} />)).toMatchSnapshot();
+    expect(shallow(<Notification notification={news[1]} unread={true} />)).toMatchSnapshot();
+  });
+});
+
+describe('#Feature', () => {
+  it('should render correctly', () => {
+    expect(shallow(<Feature feature={news[1].features[0]} />)).toMatchSnapshot();
+    expect(shallow(<Feature feature={news[0].features[0]} />)).toMatchSnapshot();
+  });
+});
+
+function shallowRender(props: Partial<Props> = {}) {
+  return shallow(
+    <NotificationsSidebar
+      fetchMoreFeatureNews={jest.fn()}
+      loading={false}
+      loadingMore={false}
+      news={news}
+      notificationsLastReadDate={parseDate('2018-01-01')}
+      onClose={jest.fn()}
+      paging={{ pageIndex: 1, pageSize: 10, total: 20 }}
+      {...props}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NavLatestNotification-test.tsx.snap b/server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NavLatestNotification-test.tsx.snap
new file mode 100644 (file)
index 0000000..5e2bc92
--- /dev/null
@@ -0,0 +1,82 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly if there are new features, and the user has not opted out 1`] = `
+<Fragment>
+  <li
+    className="navbar-latest-notification"
+    onClick={[MockFunction]}
+  >
+    <div
+      className="navbar-latest-notification-wrapper"
+    >
+      <span
+        className="badge"
+      >
+        new
+      </span>
+      <span
+        className="label"
+      >
+        10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration
+      </span>
+    </div>
+  </li>
+  <li
+    className="navbar-latest-notification-dismiss"
+  >
+    <a
+      className="navbar-icon"
+      href="#"
+      onClick={[Function]}
+    >
+      <ClearIcon
+        fill="#8a8c8f"
+        size={10}
+      />
+    </a>
+  </li>
+  <li>
+    <a
+      className="navbar-icon"
+      href="#"
+      onClick={[Function]}
+    >
+      <NotificationIcon
+        hasUnread={true}
+      />
+    </a>
+  </li>
+</Fragment>
+`;
+
+exports[`should render correctly if there are new features, but the user has opted out 1`] = `
+<Fragment>
+  <li>
+    <a
+      className="navbar-icon"
+      href="#"
+      onClick={[Function]}
+    >
+      <NotificationIcon
+        hasUnread={false}
+      />
+    </a>
+  </li>
+</Fragment>
+`;
+
+exports[`should render correctly if there are no new unread features 1`] = `
+<Fragment>
+  <li>
+    <a
+      className="navbar-icon"
+      href="#"
+      onClick={[Function]}
+    >
+      <NotificationIcon
+        hasUnread={false}
+      />
+    </a>
+  </li>
+</Fragment>
+`;
diff --git a/server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NotificationsSidebar-test.tsx.snap b/server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NotificationsSidebar-test.tsx.snap
new file mode 100644 (file)
index 0000000..a832b1e
--- /dev/null
@@ -0,0 +1,261 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`#Feature should render correctly 1`] = `
+<div
+  className="feature"
+>
+  <ul
+    className="categories"
+  >
+    <li
+      key="BitBucket"
+      style={
+        Object {
+          "backgroundColor": "#0000ff",
+        }
+      }
+    >
+      BitBucket
+    </li>
+  </ul>
+  <span>
+    BitBucket branch decoration
+  </span>
+  <a
+    className="learn-more"
+    href="http://example.com"
+    rel="noopener noreferrer nofollow"
+    target="_blank"
+  >
+    learn_more
+  </a>
+</div>
+`;
+
+exports[`#Feature should render correctly 2`] = `
+<div
+  className="feature"
+>
+  <ul
+    className="categories"
+  >
+    <li
+      key="Java"
+      style={
+        Object {
+          "backgroundColor": "#ff0000",
+        }
+      }
+    >
+      Java
+    </li>
+    <li
+      key="Rules"
+      style={
+        Object {
+          "backgroundColor": "#00ff00",
+        }
+      }
+    >
+      Rules
+    </li>
+  </ul>
+  <span>
+    10 new Java rules
+  </span>
+</div>
+`;
+
+exports[`#Notification should render correctly 1`] = `
+<div
+  className="notifications-sidebar-slice"
+>
+  <h4>
+    <DateFormatter
+      date={2018-04-04T22:00:00.000Z}
+      long={false}
+    />
+  </h4>
+  <Feature
+    feature={
+      Object {
+        "categories": Array [
+          Object {
+            "color": "#0000ff",
+            "name": "BitBucket",
+          },
+        ],
+        "description": "BitBucket branch decoration",
+        "readMore": "http://example.com",
+      }
+    }
+    key="0"
+  />
+</div>
+`;
+
+exports[`#Notification should render correctly 2`] = `
+<div
+  className="notifications-sidebar-slice unread"
+>
+  <h4>
+    <DateFormatter
+      date={2018-04-04T22:00:00.000Z}
+      long={false}
+    />
+  </h4>
+  <Feature
+    feature={
+      Object {
+        "categories": Array [
+          Object {
+            "color": "#0000ff",
+            "name": "BitBucket",
+          },
+        ],
+        "description": "BitBucket branch decoration",
+        "readMore": "http://example.com",
+      }
+    }
+    key="0"
+  />
+</div>
+`;
+
+exports[`#NotificationSidebar should render correctly if there are new features 1`] = `
+<Modal
+  onRequestClose={[MockFunction]}
+>
+  <div
+    className="notifications-sidebar"
+  >
+    <div
+      className="notifications-sidebar-top"
+    >
+      <h3>
+        embed_docs.whats_new
+      </h3>
+      <a
+        className="close"
+        href="#"
+        onClick={[MockFunction]}
+      >
+        <ClearIcon />
+      </a>
+    </div>
+    <div
+      className="notifications-sidebar-content"
+    >
+      <div
+        className="text-center"
+      >
+        <DeferredSpinner
+          className="big-spacer-top"
+          timeout={200}
+        />
+      </div>
+    </div>
+  </div>
+</Modal>
+`;
+
+exports[`#NotificationSidebar should render correctly if there are new features 2`] = `
+<Modal
+  onRequestClose={[MockFunction]}
+>
+  <div
+    className="notifications-sidebar"
+  >
+    <div
+      className="notifications-sidebar-top"
+    >
+      <h3>
+        embed_docs.whats_new
+      </h3>
+      <a
+        className="close"
+        href="#"
+        onClick={[MockFunction]}
+      >
+        <ClearIcon />
+      </a>
+    </div>
+    <div
+      className="notifications-sidebar-content"
+    >
+      <Notification
+        key="2018-04-06"
+        notification={
+          Object {
+            "features": Array [
+              Object {
+                "categories": Array [
+                  Object {
+                    "color": "#ff0000",
+                    "name": "Java",
+                  },
+                  Object {
+                    "color": "#00ff00",
+                    "name": "Rules",
+                  },
+                ],
+                "description": "10 new Java rules",
+              },
+              Object {
+                "categories": Array [
+                  Object {
+                    "color": "#0000ff",
+                    "name": "BitBucket",
+                  },
+                ],
+                "description": "BitBucket branch decoration",
+                "readMore": "http://example.com",
+              },
+            ],
+            "notification": "10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration",
+            "publicationDate": "2018-04-06",
+          }
+        }
+        unread={true}
+      />
+      <Notification
+        key="2018-04-05"
+        notification={
+          Object {
+            "features": Array [
+              Object {
+                "categories": Array [
+                  Object {
+                    "color": "#0000ff",
+                    "name": "BitBucket",
+                  },
+                ],
+                "description": "BitBucket branch decoration",
+                "readMore": "http://example.com",
+              },
+            ],
+            "notification": "Some other notification",
+            "publicationDate": "2018-04-05",
+          }
+        }
+        unread={true}
+      />
+    </div>
+    <div
+      className="notifications-sidebar-footer"
+    >
+      <div
+        className="spacer-top note text-center"
+      >
+        <a
+          className="spacer-left"
+          href="#"
+          onClick={[MockFunction]}
+        >
+          show_more
+        </a>
+      </div>
+    </div>
+  </div>
+</Modal>
+`;
diff --git a/server/sonar-web/src/main/js/app/components/notifications/notifications.css b/server/sonar-web/src/main/js/app/components/notifications/notifications.css
new file mode 100644 (file)
index 0000000..57220f8
--- /dev/null
@@ -0,0 +1,184 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2018 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
+ */
+
+.navbar-latest-notification {
+  flex: 0 1 380px;
+  text-align: right;
+  overflow: hidden;
+}
+
+.navbar-latest-notification-wrapper {
+  position: relative;
+  display: inline-block;
+  padding: var(--gridSize) 34px var(--gridSize) 50px;
+  height: 28px;
+  max-width: 100%;
+  box-sizing: border-box;
+  overflow: hidden;
+  vertical-align: middle;
+  font-size: var(--smallFontSize);
+  color: var(--sonarcloudBlack500);
+  background-color: black;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  border-radius: 3px;
+  cursor: pointer;
+}
+
+.navbar-latest-notification-wrapper:hover {
+  color: var(--sonarcloudBlack300);
+}
+
+.navbar-latest-notification-wrapper .badge {
+  position: absolute;
+  height: 18px;
+  margin-right: var(--gridSize);
+  left: calc(var(--gridSize) / 2);
+  top: 5px;
+  font-size: var(--verySmallFontSize);
+  text-transform: uppercase;
+  background-color: var(--lightBlue);
+  color: var(--darkBlue);
+}
+
+.navbar-latest-notification-wrapper .label {
+  display: block;
+  max-width: 330px;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.navbar-latest-notification .navbar-icon {
+  position: absolute;
+  right: 0;
+  top: 0;
+  height: 28px;
+  padding: 9px var(--gridSize) !important;
+  border-left: 2px solid #262626;
+}
+
+.navbar-latest-notification .navbar-icon:hover path {
+  fill: var(--sonarcloudBlack300) !important;
+}
+
+.notifications-sidebar {
+  position: fixed;
+  top: 0;
+  right: 0;
+  bottom: 0;
+  width: 400px;
+  display: flex;
+  flex-direction: column;
+
+  background: var(--sonarcloudBlack200);
+
+  z-index: 900;
+}
+
+.notifications-sidebar-top {
+  position: relative;
+  padding: calc(2 * var(--gridSize));
+
+  border-bottom: 1px solid var(--sonarcloudBlack250);
+
+  background-color: var(--sonarcloudBlack100);
+}
+
+.notifications-sidebar-top h3 {
+  font-weight: normal;
+  font-size: var(--bigFontSize);
+}
+
+.notifications-sidebar-top .close {
+  position: absolute;
+  top: 16px;
+  right: 16px;
+
+  border: 0;
+
+  color: var(--sonarcloudBlack500);
+}
+
+.notifications-sidebar-content {
+  flex: 1 1;
+  overflow-y: scroll;
+}
+
+.notifications-sidebar-footer {
+  padding-top: var(--gridSize);
+  border-top: 1px solid var(--sonarcloudBlack250);
+  flex: 0 0 40px;
+}
+
+.notifications-sidebar-slice h4 {
+  padding: calc(2 * var(--gridSize)) calc(2 * var(--gridSize)) calc(var(--gridSize) / 2)
+    calc(2 * var(--gridSize));
+
+  background-color: var(--sonarcloudBlack200);
+
+  font-weight: normal;
+  font-size: var(--smallFontSize);
+  text-align: right;
+  color: var(--sonarcloudBlack500);
+}
+
+.notifications-sidebar-slice .feature:last-of-type {
+  border-bottom: 1px solid var(--sonarcloudBlack250);
+}
+
+.notifications-sidebar-slice .feature {
+  padding: calc(2 * var(--gridSize));
+
+  background-color: var(--sonarcloudBlack100);
+
+  border-top: 1px solid var(--sonarcloudBlack250);
+
+  overflow: hidden;
+}
+
+.notifications-sidebar-slice.unread .feature {
+  background-color: #e6f6ff;
+
+  border-color: #cee4f2;
+}
+
+.notifications-sidebar-slice .learn-more {
+  clear: both;
+  float: right;
+  margin-top: var(--gridSize);
+}
+
+.notifications-sidebar-slice .categories {
+  margin-bottom: 8px;
+}
+
+.notifications-sidebar-slice .categories li {
+  display: inline-block;
+  padding: 4px;
+  margin-right: 8px;
+
+  border-radius: 3px;
+
+  font-size: 8px;
+  text-transform: uppercase;
+  color: white;
+  letter-spacing: 1px;
+}
index 8f81591e0342f831d82bd3f434ea4405066a2640..8545f935c804ad4a0d8e822a7577bc79ebe31629 100644 (file)
@@ -54,6 +54,8 @@ module.exports = {
   leakColorHover: '#f0e7c4',
   leakBorderColor: '#eae3c7',
 
+  globalNavBarBg: '#262626',
+
   snippetFontColor: '#f0f0f0',
 
   // alerts
index ba8ad00758672755be2371eca8ee711d6e553a84..6922707daf65729dd7f60b054f84689e28c160ee 100644 (file)
@@ -209,14 +209,12 @@ declare namespace T {
     showOnboardingTutorial?: boolean;
   }
 
-  export type CurrentUserSettings = { [key in CurrentUserSettingNames]?: string };
-
-  export interface CurrentUserSettingData {
+  export interface CurrentUserSetting {
     key: CurrentUserSettingNames;
     value: string;
   }
 
-  type CurrentUserSettingNames = 'notificationsOptOut' | 'notificationsReadDate';
+  type CurrentUserSettingNames = 'notifications.optOut' | 'notifications.readDate';
 
   export interface CustomMeasure {
     createdAt?: string;
@@ -424,6 +422,7 @@ declare namespace T {
     name: string;
     personalOrganization?: string;
     scmAccounts: string[];
+    settings?: CurrentUserSetting[];
   }
 
   export interface LongLivingBranch extends Branch {
index 2ded3d250d6f95d91cc699c9c5f9f8d31f91830c..864b0e1fe00886be46bf862c4f0280b851290bdd 100644 (file)
 }
 
 .copy-paste-link .close {
-  color: black;
+  color: #000;
   border-bottom: 0;
   height: 100%;
   display: inline-block;
index d6e72a6552ba852ef8043ab4f250f4b76a3d5fb1..567f1572d716e3ccd8ae3725c78dfbee16471f3e 100644 (file)
@@ -65,8 +65,8 @@ export function getLanguages(state: Store) {
   return fromLanguages.getLanguages(state.languages);
 }
 
-export function getCurrentUserSettings(state: Store) {
-  return fromUsers.getCurrentUserSettings(state.users);
+export function getCurrentUserSetting(state: Store, key: T.CurrentUserSettingNames) {
+  return fromUsers.getCurrentUserSetting(state.users, key);
 }
 
 export function getCurrentUser(state: Store) {
index f86c4c28bff3a1e5852418c8b524dd54db4b9c28..6e70f6f0ef7b0af0be5eaac83a14382d07fd0465 100644 (file)
 import { uniq } from 'lodash';
 import { Dispatch, combineReducers } from 'redux';
 import { ActionType } from './utils/actions';
-import * as api from '../api/users';
-import { listUserSettings, setUserSetting } from '../api/user-settings';
 import { isLoggedIn } from '../helpers/users';
+import * as api from '../api/users';
 
 const enum Actions {
   ReceiveCurrentUser = 'RECEIVE_CURRENT_USER',
-  ReceiveCurrentUserSettings = 'RECEIVE_CURRENT_USER_SETTINGS',
+  SetCurrentUserSetting = 'SET_CURRENT_USER_SETTING',
   SkipOnboardingAction = 'SKIP_ONBOARDING',
   SetHomePageAction = 'SET_HOMEPAGE'
 }
 
 type Action =
   | ActionType<typeof receiveCurrentUser, Actions.ReceiveCurrentUser>
-  | ActionType<typeof receiveCurrentUserSettings, Actions.ReceiveCurrentUserSettings>
+  | ActionType<typeof setCurrentUserSettingAction, Actions.SetCurrentUserSetting>
   | ActionType<typeof setHomePageAction, Actions.SetHomePageAction>
   | ActionType<typeof skipOnboardingAction, Actions.SkipOnboardingAction>;
 
@@ -41,32 +40,12 @@ export interface State {
   usersByLogin: { [login: string]: any };
   userLogins: string[];
   currentUser: T.CurrentUser;
-  currentUserSettings: T.CurrentUserSettings;
 }
 
 export function receiveCurrentUser(user: T.CurrentUser) {
   return { type: Actions.ReceiveCurrentUser, user };
 }
 
-function receiveCurrentUserSettings(userSettings: T.CurrentUserSettingData[]) {
-  return { type: Actions.ReceiveCurrentUserSettings, userSettings };
-}
-
-export function fetchCurrentUserSettings() {
-  return (dispatch: Dispatch) => {
-    listUserSettings().then(
-      ({ userSettings }) => dispatch(receiveCurrentUserSettings(userSettings)),
-      () => {}
-    );
-  };
-}
-
-export function setCurrentUserSetting(setting: T.CurrentUserSettingData) {
-  return (dispatch: Dispatch) => {
-    setUserSetting(setting).then(() => dispatch(receiveCurrentUserSettings([setting])), () => {});
-  };
-}
-
 function skipOnboardingAction() {
   return { type: Actions.SkipOnboardingAction };
 }
@@ -82,6 +61,10 @@ function setHomePageAction(homepage: T.HomePage) {
   return { type: Actions.SetHomePageAction, homepage };
 }
 
+function setCurrentUserSettingAction(setting: T.CurrentUserSetting) {
+  return { type: Actions.SetCurrentUserSetting, setting };
+}
+
 export function setHomePage(homepage: T.HomePage) {
   return (dispatch: Dispatch) => {
     api.setHomePage(homepage).then(
@@ -93,6 +76,19 @@ export function setHomePage(homepage: T.HomePage) {
   };
 }
 
+export function setCurrentUserSetting(setting: T.CurrentUserSetting) {
+  return (dispatch: Dispatch, getState: () => { users: State }) => {
+    const oldSetting = getCurrentUserSetting(getState().users, setting.key);
+    dispatch(setCurrentUserSettingAction(setting));
+    api.setUserSetting(setting).then(
+      () => {},
+      () => {
+        dispatch(setCurrentUserSettingAction({ ...setting, value: oldSetting || '' }));
+      }
+    );
+  };
+}
+
 function usersByLogin(state: State['usersByLogin'] = {}, action: Action): State['usersByLogin'] {
   if (action.type === Actions.ReceiveCurrentUser && isLoggedIn(action.user)) {
     return { ...state, [action.user.login]: action.user };
@@ -122,31 +118,36 @@ function currentUser(
   if (action.type === Actions.SetHomePageAction && isLoggedIn(state)) {
     return { ...state, homepage: action.homepage } as T.LoggedInUser;
   }
-  return state;
-}
-
-function currentUserSettings(
-  state: State['currentUserSettings'] = {},
-  action: Action
-): State['currentUserSettings'] {
-  if (action.type === Actions.ReceiveCurrentUserSettings) {
-    const newState = { ...state };
-    action.userSettings.forEach((item: T.CurrentUserSettingData) => {
-      newState[item.key] = item.value;
-    });
-    return newState;
+  if (action.type === Actions.SetCurrentUserSetting && isLoggedIn(state)) {
+    let settings: T.CurrentUserSetting[];
+    if (state.settings) {
+      settings = [...state.settings];
+      const index = settings.findIndex(setting => setting.key === action.setting.key);
+      if (index === -1) {
+        settings.push(action.setting);
+      } else {
+        settings[index] = action.setting;
+      }
+    } else {
+      settings = [action.setting];
+    }
+    return { ...state, settings } as T.LoggedInUser;
   }
   return state;
 }
 
-export default combineReducers({ usersByLogin, userLogins, currentUser, currentUserSettings });
+export default combineReducers({ usersByLogin, userLogins, currentUser });
 
 export function getCurrentUser(state: State) {
   return state.currentUser;
 }
 
-export function getCurrentUserSettings(state: State) {
-  return state.currentUserSettings;
+export function getCurrentUserSetting(state: State, key: T.CurrentUserSettingNames) {
+  let setting;
+  if (isLoggedIn(state.currentUser) && state.currentUser.settings) {
+    setting = state.currentUser.settings.find(setting => setting.key === key);
+  }
+  return setting && setting.value;
 }
 
 export function getUserByLogin(state: State, login: string) {
index 5b5f129dc92834a58574d473ab0cb32f894e712b..eae4d016b97e115dd93bf7fc5ce978339a88203b 100644 (file)
@@ -87,6 +87,7 @@ learn_more=Learn More
 library=Library
 line_number=Line Number
 links=Links
+load_more=Load more
 load_verb=Load
 login=Login
 major=Major
@@ -2694,6 +2695,7 @@ embed_docs.latest_blog=Latest blog
 embed_docs.news=Product News
 embed_docs.stay_connected=Stay Connected
 embed_docs.suggestion=Suggestions For This Page
+embed_docs.whats_new=What's new on SonarCloud?
 
 #------------------------------------------------------------------------------
 #