Browse Source

SONARCLOUD-271 Create new Notification sidebar

* 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
tags/7.6
Grégoire Aubert 5 years ago
parent
commit
44ab47b5ed
22 changed files with 1316 additions and 484 deletions
  1. 29
    18
      server/sonar-web/src/main/js/api/news.ts
  2. 0
    41
      server/sonar-web/src/main/js/api/user-settings.ts
  3. 4
    0
      server/sonar-web/src/main/js/api/users.ts
  4. 1
    62
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css
  5. 168
    13
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx
  6. 0
    152
      server/sonar-web/src/main/js/app/components/nav/global/GlobalNavNotifications.tsx
  7. 90
    9
      server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNav-test.tsx
  8. 0
    121
      server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavNotifications-test.tsx
  9. 99
    1
      server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap
  10. 93
    0
      server/sonar-web/src/main/js/app/components/notifications/NavLatestNotification.tsx
  11. 136
    0
      server/sonar-web/src/main/js/app/components/notifications/NotificationsSidebar.tsx
  12. 67
    0
      server/sonar-web/src/main/js/app/components/notifications/__tests__/NavLatestNotification-test.tsx
  13. 116
    0
      server/sonar-web/src/main/js/app/components/notifications/__tests__/NotificationsSidebar-test.tsx
  14. 17
    20
      server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NavLatestNotification-test.tsx.snap
  15. 261
    0
      server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NotificationsSidebar-test.tsx.snap
  16. 184
    0
      server/sonar-web/src/main/js/app/components/notifications/notifications.css
  17. 2
    0
      server/sonar-web/src/main/js/app/theme.js
  18. 3
    4
      server/sonar-web/src/main/js/app/types.d.ts
  19. 1
    1
      server/sonar-web/src/main/js/apps/overview/styles.css
  20. 2
    2
      server/sonar-web/src/main/js/store/rootReducer.ts
  21. 41
    40
      server/sonar-web/src/main/js/store/users.ts
  22. 2
    0
      sonar-core/src/main/resources/org/sonar/l10n/core.properties

+ 29
- 18
server/sonar-web/src/main/js/api/news.ts View 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
}
}));
}

+ 0
- 41
server/sonar-web/src/main/js/api/user-settings.ts View File

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

+ 4
- 0
server/sonar-web/src/main/js/api/users.ts View 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);
}

+ 1
- 62
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css View File

@@ -19,7 +19,7 @@
*/
.navbar-global,
.navbar-global .navbar-inner {
background-color: #262626;
background-color: var(--globalNavBarBg);
z-index: 421;
}

@@ -100,67 +100,6 @@
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;

+ 168
- 13
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.tsx View 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);

+ 0
- 152
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavNotifications.tsx View File

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

+ 90
- 9
server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNav-test.tsx View 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}
/>
);
}

+ 0
- 121
server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavNotifications-test.tsx View File

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

+ 99
- 1
server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNav-test.tsx.snap View 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"

+ 93
- 0
server/sonar-web/src/main/js/app/components/notifications/NavLatestNotification.tsx View File

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

+ 136
- 0
server/sonar-web/src/main/js/app/components/notifications/NotificationsSidebar.tsx View File

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

+ 67
- 0
server/sonar-web/src/main/js/app/components/notifications/__tests__/NavLatestNotification-test.tsx View File

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

+ 116
- 0
server/sonar-web/src/main/js/app/components/notifications/__tests__/NotificationsSidebar-test.tsx View File

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

server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavNotifications-test.tsx.snap → server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NavLatestNotification-test.tsx.snap View File

@@ -4,6 +4,7 @@ exports[`should render correctly if there are new features, and the user has not
<Fragment>
<li
className="navbar-latest-notification"
onClick={[MockFunction]}
>
<div
className="navbar-latest-notification-wrapper"
@@ -18,49 +19,43 @@ exports[`should render correctly if there are new features, and the user has not
>
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>
<li
className="navbar-latest-notification-dismiss"
>
<a
className="navbar-icon"
href="#"
onClick={[Function]}
>
<NotificationIcon
hasUnread={true}
<ClearIcon
fill="#8a8c8f"
size={10}
/>
</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}
hasUnread={true}
/>
</a>
</li>
</Fragment>
`;

exports[`should render correctly if there are no new features 1`] = `
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}
@@ -75,6 +70,8 @@ exports[`should render correctly if there are no new unread features 1`] = `
<li>
<a
className="navbar-icon"
href="#"
onClick={[Function]}
>
<NotificationIcon
hasUnread={false}

+ 261
- 0
server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NotificationsSidebar-test.tsx.snap View File

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

+ 184
- 0
server/sonar-web/src/main/js/app/components/notifications/notifications.css View File

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

+ 2
- 0
server/sonar-web/src/main/js/app/theme.js View File

@@ -54,6 +54,8 @@ module.exports = {
leakColorHover: '#f0e7c4',
leakBorderColor: '#eae3c7',

globalNavBarBg: '#262626',

snippetFontColor: '#f0f0f0',

// alerts

+ 3
- 4
server/sonar-web/src/main/js/app/types.d.ts View 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 {

+ 1
- 1
server/sonar-web/src/main/js/apps/overview/styles.css View File

@@ -475,7 +475,7 @@
}

.copy-paste-link .close {
color: black;
color: #000;
border-bottom: 0;
height: 100%;
display: inline-block;

+ 2
- 2
server/sonar-web/src/main/js/store/rootReducer.ts View 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) {

+ 41
- 40
server/sonar-web/src/main/js/store/users.ts View File

@@ -20,20 +20,19 @@
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) {

+ 2
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties View 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?

#------------------------------------------------------------------------------
#

Loading…
Cancel
Save