* 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
uid: string; | uid: string; | ||||
} | } | ||||
interface PrismicResponse { | |||||
page: number; | |||||
results: PrismicResult[]; | |||||
results_per_page: number; | |||||
total_results_size: number; | |||||
} | |||||
interface PrismicResult { | interface PrismicResult { | ||||
data: { | data: { | ||||
notification: string; | notification: string; | ||||
export function fetchPrismicFeatureNews(data: { | export function fetchPrismicFeatureNews(data: { | ||||
accessToken: string; | accessToken: string; | ||||
p?: number; | |||||
ps?: number; | ps?: number; | ||||
ref: string; | 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', { | return getCorsJSON(PRISMIC_API_URL + '/documents/search', { | ||||
access_token: data.accessToken, | access_token: data.accessToken, | ||||
orderings: '[document.first_publication_date desc]', | |||||
pageSize: data.ps || 1, | |||||
q, | |||||
fetchLinks: 'sc_category.color,sc_category.name', | 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 | 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 | |||||
} | |||||
})); | |||||
} | } |
/* | |||||
* 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); | |||||
} |
export function setHomePage(homepage: T.HomePage): Promise<void | Response> { | export function setHomePage(homepage: T.HomePage): Promise<void | Response> { | ||||
return post('/api/users/set_homepage', homepage).catch(throwGlobalError); | 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); | |||||
} |
*/ | */ | ||||
.navbar-global, | .navbar-global, | ||||
.navbar-global .navbar-inner { | .navbar-global .navbar-inner { | ||||
background-color: #262626; | |||||
background-color: var(--globalNavBarBg); | |||||
z-index: 421; | z-index: 421; | ||||
} | } | ||||
margin-left: calc(5 * var(--gridSize)); | 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 { | .global-navbar-menu-right .navbar-search { | ||||
flex: 0 1 310px; /* Workaround for SONAR-10971 */ | flex: 0 1 310px; /* Workaround for SONAR-10971 */ | ||||
min-width: 0; | min-width: 0; |
import GlobalNavBranding, { SonarCloudNavBranding } from './GlobalNavBranding'; | import GlobalNavBranding, { SonarCloudNavBranding } from './GlobalNavBranding'; | ||||
import GlobalNavMenu from './GlobalNavMenu'; | import GlobalNavMenu from './GlobalNavMenu'; | ||||
import GlobalNavExplore from './GlobalNavExplore'; | import GlobalNavExplore from './GlobalNavExplore'; | ||||
import GlobalNavNotifications from './GlobalNavNotifications'; | |||||
import GlobalNavUserContainer from './GlobalNavUserContainer'; | import GlobalNavUserContainer from './GlobalNavUserContainer'; | ||||
import NotificationsSidebar from '../../notifications/NotificationsSidebar'; | |||||
import NavLatestNotification from '../../notifications/NavLatestNotification'; | |||||
import Search from '../../search/Search'; | import Search from '../../search/Search'; | ||||
import EmbedDocsPopupHelper from '../../embed-docs-modal/EmbedDocsPopupHelper'; | import EmbedDocsPopupHelper from '../../embed-docs-modal/EmbedDocsPopupHelper'; | ||||
import * as theme from '../../../theme'; | import * as theme from '../../../theme'; | ||||
import NavBar from '../../../../components/nav/NavBar'; | import NavBar from '../../../../components/nav/NavBar'; | ||||
import { lazyLoad } from '../../../../components/lazyLoad'; | 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 { isSonarCloud } from '../../../../helpers/system'; | ||||
import { isLoggedIn } from '../../../../helpers/users'; | import { isLoggedIn } from '../../../../helpers/users'; | ||||
import { OnboardingContext } from '../../OnboardingContext'; | import { OnboardingContext } from '../../OnboardingContext'; | ||||
import { setCurrentUserSetting } from '../../../../store/users'; | |||||
import './GlobalNav.css'; | import './GlobalNav.css'; | ||||
import { parseDate } from '../../../../helpers/dates'; | |||||
const GlobalNavPlus = lazyLoad(() => import('./GlobalNavPlus'), 'GlobalNavPlus'); | const GlobalNavPlus = lazyLoad(() => import('./GlobalNavPlus'), 'GlobalNavPlus'); | ||||
interface StateProps { | |||||
interface Props { | |||||
accessToken?: string; | |||||
appState: Pick<T.AppState, 'canAdmin' | 'globalPages' | 'organizationsEnabled' | 'qualifiers'>; | appState: Pick<T.AppState, 'canAdmin' | 'globalPages' | 'organizationsEnabled' | 'qualifiers'>; | ||||
currentUser: T.CurrentUser; | 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() { | render() { | ||||
const { appState, currentUser } = this.props; | const { appState, currentUser } = this.props; | ||||
const { news } = this.state; | |||||
return ( | return ( | ||||
<NavBar className="navbar-global" height={theme.globalNavHeightRaw} id="global-navigation"> | <NavBar className="navbar-global" height={theme.globalNavHeightRaw} id="global-navigation"> | ||||
{isSonarCloud() ? <SonarCloudNavBranding /> : <GlobalNavBranding />} | {isSonarCloud() ? <SonarCloudNavBranding /> : <GlobalNavBranding />} | ||||
<GlobalNavMenu {...this.props} /> | <GlobalNavMenu {...this.props} /> | ||||
<ul className="global-navbar-menu global-navbar-menu-right"> | <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} />} | {isSonarCloud() && <GlobalNavExplore location={this.props.location} />} | ||||
<EmbedDocsPopupHelper /> | <EmbedDocsPopupHelper /> | ||||
<Search appState={appState} currentUser={currentUser} /> | <Search appState={appState} currentUser={currentUser} /> | ||||
)} | )} | ||||
<GlobalNavUserContainer appState={appState} currentUser={currentUser} /> | <GlobalNavUserContainer appState={appState} currentUser={currentUser} /> | ||||
</ul> | </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> | </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); |
/* | |||||
* 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); |
import { shallow } from 'enzyme'; | import { shallow } from 'enzyme'; | ||||
import { GlobalNav } from '../GlobalNav'; | import { GlobalNav } from '../GlobalNav'; | ||||
import { isSonarCloud } from '../../../../../helpers/system'; | 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() })); | 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'] = { | const appState: GlobalNav['props']['appState'] = { | ||||
globalPages: [], | globalPages: [], | ||||
canAdmin: false, | canAdmin: false, | ||||
}; | }; | ||||
const location = { pathname: '' }; | 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(); | expect(wrapper).toMatchSnapshot(); | ||||
wrapper.setProps({ currentUser: { isLoggedIn: true } }); | wrapper.setProps({ currentUser: { isLoggedIn: true } }); | ||||
expect(wrapper.find('[data-test="global-nav-plus"]').exists()).toBe(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} | |||||
/> | |||||
); | |||||
} | } |
/* | |||||
* 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} | |||||
/> | |||||
); | |||||
} |
// Jest Snapshot v1, https://goo.gl/fbAQLP | // 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`] = ` | exports[`should render for SonarCloud 1`] = ` | ||||
<NavBar | <NavBar | ||||
className="navbar-global" | className="navbar-global" | ||||
> | > | ||||
<SonarCloudNavBranding /> | <SonarCloudNavBranding /> | ||||
<GlobalNavMenu | <GlobalNavMenu | ||||
accessToken="token" | |||||
appState={ | appState={ | ||||
Object { | Object { | ||||
"canAdmin": false, | "canAdmin": false, | ||||
"pathname": "", | "pathname": "", | ||||
} | } | ||||
} | } | ||||
setCurrentUserSetting={[MockFunction]} | |||||
/> | /> | ||||
<ul | <ul | ||||
className="global-navbar-menu global-navbar-menu-right" | className="global-navbar-menu global-navbar-menu-right" | ||||
> | > | ||||
<Connect(GlobalNavNotifications) /> | |||||
<GlobalNavExplore | <GlobalNavExplore | ||||
location={ | location={ | ||||
Object { | Object { | ||||
> | > | ||||
<Connect(GlobalNavBranding) /> | <Connect(GlobalNavBranding) /> | ||||
<GlobalNavMenu | <GlobalNavMenu | ||||
accessToken="token" | |||||
appState={ | appState={ | ||||
Object { | Object { | ||||
"canAdmin": false, | "canAdmin": false, | ||||
"pathname": "", | "pathname": "", | ||||
} | } | ||||
} | } | ||||
setCurrentUserSetting={[MockFunction]} | |||||
/> | /> | ||||
<ul | <ul | ||||
className="global-navbar-menu global-navbar-menu-right" | className="global-navbar-menu global-navbar-menu-right" |
/* | |||||
* 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> | |||||
</> | |||||
); | |||||
} | |||||
} |
/* | |||||
* 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> | |||||
); | |||||
} |
/* | |||||
* 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} | |||||
/> | |||||
); | |||||
} |
/* | |||||
* 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} | |||||
/> | |||||
); | |||||
} |
<Fragment> | <Fragment> | ||||
<li | <li | ||||
className="navbar-latest-notification" | className="navbar-latest-notification" | ||||
onClick={[MockFunction]} | |||||
> | > | ||||
<div | <div | ||||
className="navbar-latest-notification-wrapper" | className="navbar-latest-notification-wrapper" | ||||
> | > | ||||
10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration | 10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration | ||||
</span> | </span> | ||||
<a | |||||
className="navbar-icon" | |||||
href="#" | |||||
onClick={[Function]} | |||||
> | |||||
<ClearIcon | |||||
fill="#8a8c8f" | |||||
size={10} | |||||
/> | |||||
</a> | |||||
</div> | </div> | ||||
</li> | </li> | ||||
<li> | |||||
<li | |||||
className="navbar-latest-notification-dismiss" | |||||
> | |||||
<a | <a | ||||
className="navbar-icon" | className="navbar-icon" | ||||
href="#" | |||||
onClick={[Function]} | |||||
> | > | ||||
<NotificationIcon | |||||
hasUnread={true} | |||||
<ClearIcon | |||||
fill="#8a8c8f" | |||||
size={10} | |||||
/> | /> | ||||
</a> | </a> | ||||
</li> | </li> | ||||
</Fragment> | |||||
`; | |||||
exports[`should render correctly if there are new features, but the user has opted out 1`] = ` | |||||
<Fragment> | |||||
<li> | <li> | ||||
<a | <a | ||||
className="navbar-icon" | className="navbar-icon" | ||||
href="#" | |||||
onClick={[Function]} | |||||
> | > | ||||
<NotificationIcon | <NotificationIcon | ||||
hasUnread={false} | |||||
hasUnread={true} | |||||
/> | /> | ||||
</a> | </a> | ||||
</li> | </li> | ||||
</Fragment> | </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> | <Fragment> | ||||
<li> | <li> | ||||
<a | <a | ||||
className="navbar-icon" | className="navbar-icon" | ||||
href="#" | |||||
onClick={[Function]} | |||||
> | > | ||||
<NotificationIcon | <NotificationIcon | ||||
hasUnread={false} | hasUnread={false} | ||||
<li> | <li> | ||||
<a | <a | ||||
className="navbar-icon" | className="navbar-icon" | ||||
href="#" | |||||
onClick={[Function]} | |||||
> | > | ||||
<NotificationIcon | <NotificationIcon | ||||
hasUnread={false} | hasUnread={false} |
// 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> | |||||
`; |
/* | |||||
* 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; | |||||
} |
leakColorHover: '#f0e7c4', | leakColorHover: '#f0e7c4', | ||||
leakBorderColor: '#eae3c7', | leakBorderColor: '#eae3c7', | ||||
globalNavBarBg: '#262626', | |||||
snippetFontColor: '#f0f0f0', | snippetFontColor: '#f0f0f0', | ||||
// alerts | // alerts |
showOnboardingTutorial?: boolean; | showOnboardingTutorial?: boolean; | ||||
} | } | ||||
export type CurrentUserSettings = { [key in CurrentUserSettingNames]?: string }; | |||||
export interface CurrentUserSettingData { | |||||
export interface CurrentUserSetting { | |||||
key: CurrentUserSettingNames; | key: CurrentUserSettingNames; | ||||
value: string; | value: string; | ||||
} | } | ||||
type CurrentUserSettingNames = 'notificationsOptOut' | 'notificationsReadDate'; | |||||
type CurrentUserSettingNames = 'notifications.optOut' | 'notifications.readDate'; | |||||
export interface CustomMeasure { | export interface CustomMeasure { | ||||
createdAt?: string; | createdAt?: string; | ||||
name: string; | name: string; | ||||
personalOrganization?: string; | personalOrganization?: string; | ||||
scmAccounts: string[]; | scmAccounts: string[]; | ||||
settings?: CurrentUserSetting[]; | |||||
} | } | ||||
export interface LongLivingBranch extends Branch { | export interface LongLivingBranch extends Branch { |
} | } | ||||
.copy-paste-link .close { | .copy-paste-link .close { | ||||
color: black; | |||||
color: #000; | |||||
border-bottom: 0; | border-bottom: 0; | ||||
height: 100%; | height: 100%; | ||||
display: inline-block; | display: inline-block; |
return fromLanguages.getLanguages(state.languages); | 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) { | export function getCurrentUser(state: Store) { |
import { uniq } from 'lodash'; | import { uniq } from 'lodash'; | ||||
import { Dispatch, combineReducers } from 'redux'; | import { Dispatch, combineReducers } from 'redux'; | ||||
import { ActionType } from './utils/actions'; | import { ActionType } from './utils/actions'; | ||||
import * as api from '../api/users'; | |||||
import { listUserSettings, setUserSetting } from '../api/user-settings'; | |||||
import { isLoggedIn } from '../helpers/users'; | import { isLoggedIn } from '../helpers/users'; | ||||
import * as api from '../api/users'; | |||||
const enum Actions { | const enum Actions { | ||||
ReceiveCurrentUser = 'RECEIVE_CURRENT_USER', | ReceiveCurrentUser = 'RECEIVE_CURRENT_USER', | ||||
ReceiveCurrentUserSettings = 'RECEIVE_CURRENT_USER_SETTINGS', | |||||
SetCurrentUserSetting = 'SET_CURRENT_USER_SETTING', | |||||
SkipOnboardingAction = 'SKIP_ONBOARDING', | SkipOnboardingAction = 'SKIP_ONBOARDING', | ||||
SetHomePageAction = 'SET_HOMEPAGE' | SetHomePageAction = 'SET_HOMEPAGE' | ||||
} | } | ||||
type Action = | type Action = | ||||
| ActionType<typeof receiveCurrentUser, Actions.ReceiveCurrentUser> | | ActionType<typeof receiveCurrentUser, Actions.ReceiveCurrentUser> | ||||
| ActionType<typeof receiveCurrentUserSettings, Actions.ReceiveCurrentUserSettings> | |||||
| ActionType<typeof setCurrentUserSettingAction, Actions.SetCurrentUserSetting> | |||||
| ActionType<typeof setHomePageAction, Actions.SetHomePageAction> | | ActionType<typeof setHomePageAction, Actions.SetHomePageAction> | ||||
| ActionType<typeof skipOnboardingAction, Actions.SkipOnboardingAction>; | | ActionType<typeof skipOnboardingAction, Actions.SkipOnboardingAction>; | ||||
usersByLogin: { [login: string]: any }; | usersByLogin: { [login: string]: any }; | ||||
userLogins: string[]; | userLogins: string[]; | ||||
currentUser: T.CurrentUser; | currentUser: T.CurrentUser; | ||||
currentUserSettings: T.CurrentUserSettings; | |||||
} | } | ||||
export function receiveCurrentUser(user: T.CurrentUser) { | export function receiveCurrentUser(user: T.CurrentUser) { | ||||
return { type: Actions.ReceiveCurrentUser, user }; | return { type: Actions.ReceiveCurrentUser, user }; | ||||
} | } | ||||
function receiveCurrentUserSettings(userSettings: T.CurrentUserSettingData[]) { | |||||
return { type: Actions.ReceiveCurrentUserSettings, userSettings }; | |||||
} | |||||
export function fetchCurrentUserSettings() { | |||||
return (dispatch: Dispatch) => { | |||||
listUserSettings().then( | |||||
({ userSettings }) => dispatch(receiveCurrentUserSettings(userSettings)), | |||||
() => {} | |||||
); | |||||
}; | |||||
} | |||||
export function setCurrentUserSetting(setting: T.CurrentUserSettingData) { | |||||
return (dispatch: Dispatch) => { | |||||
setUserSetting(setting).then(() => dispatch(receiveCurrentUserSettings([setting])), () => {}); | |||||
}; | |||||
} | |||||
function skipOnboardingAction() { | function skipOnboardingAction() { | ||||
return { type: Actions.SkipOnboardingAction }; | return { type: Actions.SkipOnboardingAction }; | ||||
} | } | ||||
return { type: Actions.SetHomePageAction, homepage }; | return { type: Actions.SetHomePageAction, homepage }; | ||||
} | } | ||||
function setCurrentUserSettingAction(setting: T.CurrentUserSetting) { | |||||
return { type: Actions.SetCurrentUserSetting, setting }; | |||||
} | |||||
export function setHomePage(homepage: T.HomePage) { | export function setHomePage(homepage: T.HomePage) { | ||||
return (dispatch: Dispatch) => { | return (dispatch: Dispatch) => { | ||||
api.setHomePage(homepage).then( | api.setHomePage(homepage).then( | ||||
}; | }; | ||||
} | } | ||||
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'] { | function usersByLogin(state: State['usersByLogin'] = {}, action: Action): State['usersByLogin'] { | ||||
if (action.type === Actions.ReceiveCurrentUser && isLoggedIn(action.user)) { | if (action.type === Actions.ReceiveCurrentUser && isLoggedIn(action.user)) { | ||||
return { ...state, [action.user.login]: action.user }; | return { ...state, [action.user.login]: action.user }; | ||||
if (action.type === Actions.SetHomePageAction && isLoggedIn(state)) { | if (action.type === Actions.SetHomePageAction && isLoggedIn(state)) { | ||||
return { ...state, homepage: action.homepage } as T.LoggedInUser; | 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; | return state; | ||||
} | } | ||||
export default combineReducers({ usersByLogin, userLogins, currentUser, currentUserSettings }); | |||||
export default combineReducers({ usersByLogin, userLogins, currentUser }); | |||||
export function getCurrentUser(state: State) { | export function getCurrentUser(state: State) { | ||||
return state.currentUser; | 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) { | export function getUserByLogin(state: State, login: string) { |
library=Library | library=Library | ||||
line_number=Line Number | line_number=Line Number | ||||
links=Links | links=Links | ||||
load_more=Load more | |||||
load_verb=Load | load_verb=Load | ||||
login=Login | login=Login | ||||
major=Major | major=Major | ||||
embed_docs.news=Product News | embed_docs.news=Product News | ||||
embed_docs.stay_connected=Stay Connected | embed_docs.stay_connected=Stay Connected | ||||
embed_docs.suggestion=Suggestions For This Page | embed_docs.suggestion=Suggestions For This Page | ||||
embed_docs.whats_new=What's new on SonarCloud? | |||||
#------------------------------------------------------------------------------ | #------------------------------------------------------------------------------ | ||||
# | # |