aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-web
diff options
context:
space:
mode:
Diffstat (limited to 'server/sonar-web')
-rw-r--r--server/sonar-web/src/main/js/api/news.ts70
-rw-r--r--server/sonar-web/src/main/js/api/user-settings.ts41
-rw-r--r--server/sonar-web/src/main/js/app/components/embed-docs-modal/EmbedDocsPopupHelper.tsx6
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css65
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx2
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/GlobalNavNotifications.tsx152
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx2
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavNotifications-test.tsx121
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap1
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavNotifications-test.tsx.snap85
-rw-r--r--server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap2
-rw-r--r--server/sonar-web/src/main/js/app/types.d.ts9
-rw-r--r--server/sonar-web/src/main/js/components/icons-components/NotificationIcon.tsx52
-rw-r--r--server/sonar-web/src/main/js/store/rootReducer.ts4
-rw-r--r--server/sonar-web/src/main/js/store/users.ts87
15 files changed, 672 insertions, 27 deletions
diff --git a/server/sonar-web/src/main/js/api/news.ts b/server/sonar-web/src/main/js/api/news.ts
index 6e246198027..4b11b157763 100644
--- a/server/sonar-web/src/main/js/api/news.ts
+++ b/server/sonar-web/src/main/js/api/news.ts
@@ -30,10 +30,48 @@ export interface PrismicNews {
uid: string;
}
+interface PrismicResult {
+ data: {
+ notification: string;
+ publication_date: string;
+ body: PrismicResultFeature[];
+ };
+}
+
+interface PrismicResultFeature {
+ items: Array<{
+ category: {
+ data: {
+ color: string;
+ name: string;
+ };
+ };
+ }>;
+ primary: {
+ description: string;
+ read_more_link: {
+ url?: string;
+ };
+ };
+}
+
+export interface PrismicFeatureNews {
+ notification: string;
+ publicationDate: string;
+ features: Array<{
+ categories: Array<{
+ color: string;
+ name: string;
+ }>;
+ description: string;
+ readMore?: string;
+ }>;
+}
+
const PRISMIC_API_URL = 'https://sonarsource.cdn.prismic.io/api/v2';
export function fetchPrismicRefs() {
- return getCorsJSON(PRISMIC_API_URL).then((response: { refs: Array<PrismicRef> }) => {
+ return getCorsJSON(PRISMIC_API_URL).then((response: { refs: PrismicRef[] }) => {
const master = response && response.refs.find(ref => ref.id === 'master');
if (!master) {
return Promise.reject('No master ref found');
@@ -58,5 +96,33 @@ export function fetchPrismicNews(data: {
pageSize: data.ps || 1,
q,
ref: data.ref
- }).then(({ results }: { results: Array<PrismicNews> }) => results);
+ }).then(({ results }: { results: PrismicNews[] }) => results);
+}
+
+export function fetchPrismicFeatureNews(data: {
+ accessToken: string;
+ ps?: number;
+ ref: string;
+}): Promise<PrismicFeatureNews[]> {
+ const q = ['[[at(document.type, "sc_product_news")]]'];
+ 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',
+ 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
+ }))
+ };
+ });
+ });
}
diff --git a/server/sonar-web/src/main/js/api/user-settings.ts b/server/sonar-web/src/main/js/api/user-settings.ts
new file mode 100644
index 00000000000..f28c9fbbe07
--- /dev/null
+++ b/server/sonar-web/src/main/js/api/user-settings.ts
@@ -0,0 +1,41 @@
+/*
+ * 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);
+}
diff --git a/server/sonar-web/src/main/js/app/components/embed-docs-modal/EmbedDocsPopupHelper.tsx b/server/sonar-web/src/main/js/app/components/embed-docs-modal/EmbedDocsPopupHelper.tsx
index d02c95f18d7..9e040463027 100644
--- a/server/sonar-web/src/main/js/app/components/embed-docs-modal/EmbedDocsPopupHelper.tsx
+++ b/server/sonar-web/src/main/js/app/components/embed-docs-modal/EmbedDocsPopupHelper.tsx
@@ -78,7 +78,11 @@ export default class EmbedDocsPopupHelper extends React.PureComponent<{}, State>
onRequestClose={this.closeHelp}
open={this.state.helpOpen}
overlay={<EmbedDocsPopup onClose={this.closeHelp} />}>
- <a className="navbar-help" href="#" onClick={this.handleClick} title={translate('help')}>
+ <a
+ className="navbar-help navbar-icon"
+ href="#"
+ onClick={this.handleClick}
+ title={translate('help')}>
<HelpIcon />
</a>
</Toggler>
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css
index bdad4103902..c4eb2f4b698 100644
--- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css
+++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css
@@ -50,8 +50,7 @@
border: none !important;
}
-.navbar-help,
-.navbar-plus {
+.navbar-icon {
display: inline-block;
height: var(--globalNavHeight);
padding: calc(var(--globalNavHeight) - var(--globalNavContentHeight)) 12px !important;
@@ -98,6 +97,68 @@
.global-navbar-menu-right {
flex: 1;
justify-content: flex-end;
+ 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 {
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx
index ef1d79fe8ce..0c69b523c1a 100644
--- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx
+++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx
@@ -22,6 +22,7 @@ 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 Search from '../../search/Search';
import EmbedDocsPopupHelper from '../../embed-docs-modal/EmbedDocsPopupHelper';
@@ -57,6 +58,7 @@ export class GlobalNav extends React.PureComponent<Props> {
<GlobalNavMenu {...this.props} />
<ul className="global-navbar-menu global-navbar-menu-right">
+ {isSonarCloud() && <GlobalNavNotifications />}
{isSonarCloud() && <GlobalNavExplore location={this.props.location} />}
<EmbedDocsPopupHelper />
<Search appState={appState} currentUser={currentUser} />
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
new file mode 100644
index 00000000000..7b00dccc4d8
--- /dev/null
+++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavNotifications.tsx
@@ -0,0 +1,152 @@
+/*
+ * 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);
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx
index 336d8734b38..a83f279d6e4 100644
--- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx
+++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx
@@ -176,7 +176,7 @@ export class GlobalNavPlus extends React.PureComponent<Props & WithRouterProps,
}
tagName="li">
<a
- className="navbar-plus"
+ className="navbar-icon navbar-plus"
href="#"
title={
isSonarCloud()
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
new file mode 100644
index 00000000000..b39aeeb1b92
--- /dev/null
+++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavNotifications-test.tsx
@@ -0,0 +1,121 @@
+/*
+ * 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}
+ />
+ );
+}
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap
index 2f92e6871af..b99134f2ced 100644
--- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap
+++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap
@@ -30,6 +30,7 @@ exports[`should render for SonarCloud 1`] = `
<ul
className="global-navbar-menu global-navbar-menu-right"
>
+ <Connect(GlobalNavNotifications) />
<GlobalNavExplore
location={
Object {
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
new file mode 100644
index 00000000000..1b7be1cc599
--- /dev/null
+++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavNotifications-test.tsx.snap
@@ -0,0 +1,85 @@
+// 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/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap
index 98968ea1224..e68d3ebe6ed 100644
--- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap
+++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap
@@ -20,7 +20,7 @@ exports[`render 1`] = `
tagName="li"
>
<a
- className="navbar-plus"
+ className="navbar-icon navbar-plus"
href="#"
title="my_account.create_new_project_portfolio_or_application"
>
diff --git a/server/sonar-web/src/main/js/app/types.d.ts b/server/sonar-web/src/main/js/app/types.d.ts
index 16cae8e6cee..ba8ad007586 100644
--- a/server/sonar-web/src/main/js/app/types.d.ts
+++ b/server/sonar-web/src/main/js/app/types.d.ts
@@ -209,6 +209,15 @@ declare namespace T {
showOnboardingTutorial?: boolean;
}
+ export type CurrentUserSettings = { [key in CurrentUserSettingNames]?: string };
+
+ export interface CurrentUserSettingData {
+ key: CurrentUserSettingNames;
+ value: string;
+ }
+
+ type CurrentUserSettingNames = 'notificationsOptOut' | 'notificationsReadDate';
+
export interface CustomMeasure {
createdAt?: string;
description?: string;
diff --git a/server/sonar-web/src/main/js/components/icons-components/NotificationIcon.tsx b/server/sonar-web/src/main/js/components/icons-components/NotificationIcon.tsx
new file mode 100644
index 00000000000..9985f1462f9
--- /dev/null
+++ b/server/sonar-web/src/main/js/components/icons-components/NotificationIcon.tsx
@@ -0,0 +1,52 @@
+/*
+ * 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 Icon, { IconProps } from './Icon';
+import { blue } from '../../app/theme';
+
+interface Props extends IconProps {
+ hasUnread?: boolean;
+}
+
+export default function NotificationIcon({
+ className,
+ fill = 'currentColor',
+ hasUnread,
+ size
+}: Props) {
+ return (
+ <Icon className={className} size={size}>
+ {hasUnread ? (
+ <>
+ <path
+ d="M8 1a.875.875 0 0 0-.875.875v.57c-2.009.418-3.498 2.118-3.498 4.242 0 2.798-.987 3.652-1.516 4.22a.856.856 0 0 0-.236.593.875.875 0 0 0 .877.875h10.496a.875.875 0 0 0 .877-.875.854.854 0 0 0-.236-.594c-.497-.534-1.388-1.342-1.494-3.76a2.814 2.814 0 0 1-.768.108A2.814 2.814 0 0 1 8.814 4.44a2.814 2.814 0 0 1 .665-1.818 4.543 4.543 0 0 0-.604-.178v-.57A.875.875 0 0 0 8 1zM6.25 13.25a1.75 1.75 0 0 0 3.5 0h-3.5z"
+ style={{ fill }}
+ />
+ <circle cx="11.627" cy="4.441" r="2" style={{ fill: blue }} />
+ </>
+ ) : (
+ <path
+ d="M8 15a1.75 1.75 0 0 0 1.75-1.75h-3.5c0 .967.784 1.75 1.75 1.75zm5.89-4.094c-.529-.567-1.517-1.421-1.517-4.218 0-2.125-1.49-3.826-3.499-4.243v-.57a.875.875 0 1 0-1.748 0v.57c-2.01.417-3.499 2.118-3.499 4.243 0 2.797-.988 3.65-1.517 4.218a.854.854 0 0 0-.235.594.876.876 0 0 0 .878.875h10.494a.876.876 0 0 0 .878-.875.853.853 0 0 0-.235-.594z"
+ style={{ fill }}
+ />
+ )}
+ </Icon>
+ );
+}
diff --git a/server/sonar-web/src/main/js/store/rootReducer.ts b/server/sonar-web/src/main/js/store/rootReducer.ts
index b048b4b5d69..d6e72a6552b 100644
--- a/server/sonar-web/src/main/js/store/rootReducer.ts
+++ b/server/sonar-web/src/main/js/store/rootReducer.ts
@@ -65,6 +65,10 @@ export function getLanguages(state: Store) {
return fromLanguages.getLanguages(state.languages);
}
+export function getCurrentUserSettings(state: Store) {
+ return fromUsers.getCurrentUserSettings(state.users);
+}
+
export function getCurrentUser(state: Store) {
return fromUsers.getCurrentUser(state.users);
}
diff --git a/server/sonar-web/src/main/js/store/users.ts b/server/sonar-web/src/main/js/store/users.ts
index 8e1054686cb..f86c4c28bff 100644
--- a/server/sonar-web/src/main/js/store/users.ts
+++ b/server/sonar-web/src/main/js/store/users.ts
@@ -21,14 +21,54 @@ 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';
+const enum Actions {
+ ReceiveCurrentUser = 'RECEIVE_CURRENT_USER',
+ ReceiveCurrentUserSettings = 'RECEIVE_CURRENT_USER_SETTINGS',
+ SkipOnboardingAction = 'SKIP_ONBOARDING',
+ SetHomePageAction = 'SET_HOMEPAGE'
+}
+
+type Action =
+ | ActionType<typeof receiveCurrentUser, Actions.ReceiveCurrentUser>
+ | ActionType<typeof receiveCurrentUserSettings, Actions.ReceiveCurrentUserSettings>
+ | ActionType<typeof setHomePageAction, Actions.SetHomePageAction>
+ | ActionType<typeof skipOnboardingAction, Actions.SkipOnboardingAction>;
+
+export interface State {
+ usersByLogin: { [login: string]: any };
+ userLogins: string[];
+ currentUser: T.CurrentUser;
+ currentUserSettings: T.CurrentUserSettings;
+}
+
export function receiveCurrentUser(user: T.CurrentUser) {
- return { type: 'RECEIVE_CURRENT_USER', user };
+ 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: 'SKIP_ONBOARDING' };
+ return { type: Actions.SkipOnboardingAction };
}
export function skipOnboarding() {
@@ -39,7 +79,7 @@ export function skipOnboarding() {
}
function setHomePageAction(homepage: T.HomePage) {
- return { type: 'SET_HOMEPAGE', homepage };
+ return { type: Actions.SetHomePageAction, homepage };
}
export function setHomePage(homepage: T.HomePage) {
@@ -53,19 +93,8 @@ export function setHomePage(homepage: T.HomePage) {
};
}
-type Action =
- | ActionType<typeof receiveCurrentUser, 'RECEIVE_CURRENT_USER'>
- | ActionType<typeof skipOnboardingAction, 'SKIP_ONBOARDING'>
- | ActionType<typeof setHomePageAction, 'SET_HOMEPAGE'>;
-
-export interface State {
- usersByLogin: { [login: string]: any };
- userLogins: string[];
- currentUser: T.CurrentUser;
-}
-
function usersByLogin(state: State['usersByLogin'] = {}, action: Action): State['usersByLogin'] {
- if (action.type === 'RECEIVE_CURRENT_USER' && isLoggedIn(action.user)) {
+ if (action.type === Actions.ReceiveCurrentUser && isLoggedIn(action.user)) {
return { ...state, [action.user.login]: action.user };
} else {
return state;
@@ -73,7 +102,7 @@ function usersByLogin(state: State['usersByLogin'] = {}, action: Action): State[
}
function userLogins(state: State['userLogins'] = [], action: Action): State['userLogins'] {
- if (action.type === 'RECEIVE_CURRENT_USER' && isLoggedIn(action.user)) {
+ if (action.type === Actions.ReceiveCurrentUser && isLoggedIn(action.user)) {
return uniq([...state, action.user.login]);
} else {
return state;
@@ -84,24 +113,42 @@ function currentUser(
state: State['currentUser'] = { isLoggedIn: false },
action: Action
): State['currentUser'] {
- if (action.type === 'RECEIVE_CURRENT_USER') {
+ if (action.type === Actions.ReceiveCurrentUser) {
return action.user;
}
- if (action.type === 'SKIP_ONBOARDING' && isLoggedIn(state)) {
+ if (action.type === Actions.SkipOnboardingAction && isLoggedIn(state)) {
return { ...state, showOnboardingTutorial: false } as T.LoggedInUser;
}
- if (action.type === 'SET_HOMEPAGE' && isLoggedIn(state)) {
+ if (action.type === Actions.SetHomePageAction && isLoggedIn(state)) {
return { ...state, homepage: action.homepage } as T.LoggedInUser;
}
return state;
}
-export default combineReducers({ usersByLogin, userLogins, currentUser });
+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;
+ }
+ return state;
+}
+
+export default combineReducers({ usersByLogin, userLogins, currentUser, currentUserSettings });
export function getCurrentUser(state: State) {
return state.currentUser;
}
+export function getCurrentUserSettings(state: State) {
+ return state.currentUserSettings;
+}
+
export function getUserByLogin(state: State, login: string) {
return state.usersByLogin[login];
}