* 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 sidebartags/7.6
@@ -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 | |||
} | |||
})); | |||
} |
@@ -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); | |||
} |
@@ -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); | |||
} |
@@ -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; |
@@ -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); |
@@ -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); |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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" |
@@ -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> | |||
</> | |||
); | |||
} | |||
} |
@@ -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> | |||
); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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} | |||
/> | |||
); | |||
} |
@@ -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} |
@@ -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> | |||
`; |
@@ -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; | |||
} |
@@ -54,6 +54,8 @@ module.exports = { | |||
leakColorHover: '#f0e7c4', | |||
leakBorderColor: '#eae3c7', | |||
globalNavBarBg: '#262626', | |||
snippetFontColor: '#f0f0f0', | |||
// alerts |
@@ -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 { |
@@ -475,7 +475,7 @@ | |||
} | |||
.copy-paste-link .close { | |||
color: black; | |||
color: #000; | |||
border-bottom: 0; | |||
height: 100%; | |||
display: inline-block; |
@@ -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) { |
@@ -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) { |
@@ -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? | |||
#------------------------------------------------------------------------------ | |||
# |