@@ -30,10 +30,48 @@ export interface PrismicNews { | |||
uid: string; | |||
} | |||
interface PrismicResult { | |||
data: { | |||
notification: string; | |||
publication_date: string; | |||
body: PrismicResultFeature[]; | |||
}; | |||
} | |||
interface PrismicResultFeature { | |||
items: Array<{ | |||
category: { | |||
data: { | |||
color: string; | |||
name: string; | |||
}; | |||
}; | |||
}>; | |||
primary: { | |||
description: string; | |||
read_more_link: { | |||
url?: string; | |||
}; | |||
}; | |||
} | |||
export interface PrismicFeatureNews { | |||
notification: string; | |||
publicationDate: string; | |||
features: Array<{ | |||
categories: Array<{ | |||
color: string; | |||
name: string; | |||
}>; | |||
description: string; | |||
readMore?: string; | |||
}>; | |||
} | |||
const PRISMIC_API_URL = 'https://sonarsource.cdn.prismic.io/api/v2'; | |||
export function fetchPrismicRefs() { | |||
return getCorsJSON(PRISMIC_API_URL).then((response: { refs: Array<PrismicRef> }) => { | |||
return getCorsJSON(PRISMIC_API_URL).then((response: { refs: PrismicRef[] }) => { | |||
const master = response && response.refs.find(ref => ref.id === 'master'); | |||
if (!master) { | |||
return Promise.reject('No master ref found'); | |||
@@ -58,5 +96,33 @@ export function fetchPrismicNews(data: { | |||
pageSize: data.ps || 1, | |||
q, | |||
ref: data.ref | |||
}).then(({ results }: { results: Array<PrismicNews> }) => results); | |||
}).then(({ results }: { results: PrismicNews[] }) => results); | |||
} | |||
export function fetchPrismicFeatureNews(data: { | |||
accessToken: string; | |||
ps?: number; | |||
ref: string; | |||
}): Promise<PrismicFeatureNews[]> { | |||
const q = ['[[at(document.type, "sc_product_news")]]']; | |||
return getCorsJSON(PRISMIC_API_URL + '/documents/search', { | |||
access_token: data.accessToken, | |||
orderings: '[document.first_publication_date desc]', | |||
pageSize: data.ps || 1, | |||
q, | |||
fetchLinks: 'sc_category.color,sc_category.name', | |||
ref: data.ref | |||
}).then(({ results }: { results: PrismicResult[] }) => { | |||
return results.map(result => { | |||
return { | |||
notification: result.data.notification, | |||
publicationDate: result.data.publication_date, | |||
features: result.data.body.map(feature => ({ | |||
categories: feature.items.map(item => item.category.data), | |||
description: feature.primary.description, | |||
readMore: feature.primary.read_more_link.url | |||
})) | |||
}; | |||
}); | |||
}); | |||
} |
@@ -0,0 +1,41 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2018 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import { getJSON, post } from '../helpers/request'; | |||
import throwGlobalError from '../app/utils/throwGlobalError'; | |||
export function setUserSetting(data: T.CurrentUserSettingData) { | |||
return post('/api/user_settings/set', data) | |||
.catch(() => Promise.resolve()) // TODO Remove mock. | |||
.catch(throwGlobalError); | |||
} | |||
export function listUserSettings(): Promise<{ userSettings: T.CurrentUserSettingData[] }> { | |||
return getJSON('/api/user_settings/list') | |||
.catch(() => { | |||
// TODO Remove mock. | |||
return { | |||
userSettings: [ | |||
{ key: 'notificationsReadDate', value: '2018-12-01T12:07:19+0000' }, | |||
{ key: 'notificationsOptOut', value: 'false' } | |||
] | |||
}; | |||
}) | |||
.catch(throwGlobalError); | |||
} |
@@ -78,7 +78,11 @@ export default class EmbedDocsPopupHelper extends React.PureComponent<{}, State> | |||
onRequestClose={this.closeHelp} | |||
open={this.state.helpOpen} | |||
overlay={<EmbedDocsPopup onClose={this.closeHelp} />}> | |||
<a className="navbar-help" href="#" onClick={this.handleClick} title={translate('help')}> | |||
<a | |||
className="navbar-help navbar-icon" | |||
href="#" | |||
onClick={this.handleClick} | |||
title={translate('help')}> | |||
<HelpIcon /> | |||
</a> | |||
</Toggler> |
@@ -50,8 +50,7 @@ | |||
border: none !important; | |||
} | |||
.navbar-help, | |||
.navbar-plus { | |||
.navbar-icon { | |||
display: inline-block; | |||
height: var(--globalNavHeight); | |||
padding: calc(var(--globalNavHeight) - var(--globalNavContentHeight)) 12px !important; | |||
@@ -98,6 +97,68 @@ | |||
.global-navbar-menu-right { | |||
flex: 1; | |||
justify-content: flex-end; | |||
margin-left: calc(5 * var(--gridSize)); | |||
} | |||
.navbar-latest-notification { | |||
flex: 0 1 350px; | |||
text-align: right; | |||
overflow: hidden; | |||
} | |||
.navbar-latest-notification-wrapper { | |||
position: relative; | |||
display: inline-block; | |||
padding: var(--gridSize) 34px var(--gridSize) 50px; | |||
height: 28px; | |||
max-width: 100%; | |||
box-sizing: border-box; | |||
overflow: hidden; | |||
vertical-align: middle; | |||
font-size: var(--smallFontSize); | |||
color: var(--sonarcloudBlack500); | |||
background-color: black; | |||
text-overflow: ellipsis; | |||
white-space: nowrap; | |||
border-radius: 3px; | |||
cursor: pointer; | |||
} | |||
.navbar-latest-notification-wrapper:hover { | |||
color: var(--sonarcloudBlack300); | |||
} | |||
.navbar-latest-notification-wrapper .badge { | |||
position: absolute; | |||
height: 18px; | |||
margin-right: var(--gridSize); | |||
left: calc(var(--gridSize) / 2); | |||
top: 5px; | |||
font-size: var(--verySmallFontSize); | |||
text-transform: uppercase; | |||
background-color: var(--lightBlue); | |||
color: var(--darkBlue); | |||
} | |||
.navbar-latest-notification-wrapper .label { | |||
display: block; | |||
max-width: 300px; | |||
overflow: hidden; | |||
text-overflow: ellipsis; | |||
white-space: nowrap; | |||
} | |||
.navbar-latest-notification .navbar-icon { | |||
position: absolute; | |||
right: 0; | |||
top: 0; | |||
height: 28px; | |||
padding: 9px var(--gridSize) !important; | |||
border-left: 2px solid #262626; | |||
} | |||
.navbar-latest-notification .navbar-icon:hover path { | |||
fill: var(--sonarcloudBlack300) !important; | |||
} | |||
.global-navbar-menu-right .navbar-search { |
@@ -22,6 +22,7 @@ import { connect } from 'react-redux'; | |||
import GlobalNavBranding, { SonarCloudNavBranding } from './GlobalNavBranding'; | |||
import GlobalNavMenu from './GlobalNavMenu'; | |||
import GlobalNavExplore from './GlobalNavExplore'; | |||
import GlobalNavNotifications from './GlobalNavNotifications'; | |||
import GlobalNavUserContainer from './GlobalNavUserContainer'; | |||
import Search from '../../search/Search'; | |||
import EmbedDocsPopupHelper from '../../embed-docs-modal/EmbedDocsPopupHelper'; | |||
@@ -57,6 +58,7 @@ export class GlobalNav extends React.PureComponent<Props> { | |||
<GlobalNavMenu {...this.props} /> | |||
<ul className="global-navbar-menu global-navbar-menu-right"> | |||
{isSonarCloud() && <GlobalNavNotifications />} | |||
{isSonarCloud() && <GlobalNavExplore location={this.props.location} />} | |||
<EmbedDocsPopupHelper /> | |||
<Search appState={appState} currentUser={currentUser} /> |
@@ -0,0 +1,152 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2018 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import { connect } from 'react-redux'; | |||
import ClearIcon from '../../../../components/icons-components/ClearIcon'; | |||
import NotificationIcon from '../../../../components/icons-components/NotificationIcon'; | |||
import { sonarcloudBlack500 } from '../../../theme'; | |||
import { | |||
fetchPrismicRefs, | |||
fetchPrismicFeatureNews, | |||
PrismicFeatureNews | |||
} from '../../../../api/news'; | |||
import { differenceInSeconds, parseDate } from '../../../../helpers/dates'; | |||
import { translate } from '../../../../helpers/l10n'; | |||
import { fetchCurrentUserSettings, setCurrentUserSetting } from '../../../../store/users'; | |||
import { | |||
getGlobalSettingValue, | |||
getCurrentUserSettings, | |||
Store | |||
} from '../../../../store/rootReducer'; | |||
interface Props { | |||
accessToken?: string; | |||
fetchCurrentUserSettings: () => void; | |||
notificationsLastReadDate?: Date; | |||
notificationsOptOut?: boolean; | |||
setCurrentUserSetting: (setting: T.CurrentUserSettingData) => void; | |||
} | |||
interface State { | |||
news: PrismicFeatureNews[]; | |||
ready: boolean; | |||
} | |||
export class GlobalNavNotifications extends React.PureComponent<Props, State> { | |||
mounted = false; | |||
state: State = { news: [], ready: false }; | |||
componentDidMount() { | |||
this.mounted = true; | |||
this.fetchPrismicFeatureNews(); | |||
this.props.fetchCurrentUserSettings(); | |||
} | |||
componentWillUnmount() { | |||
this.mounted = false; | |||
} | |||
checkHasUnread = () => { | |||
const lastNews = this.state.news[0]; | |||
if (!lastNews) { | |||
return false; | |||
} | |||
const { notificationsLastReadDate } = this.props; | |||
return ( | |||
!notificationsLastReadDate || | |||
differenceInSeconds(parseDate(lastNews.publicationDate), notificationsLastReadDate) > 0 | |||
); | |||
}; | |||
fetchPrismicFeatureNews = () => { | |||
const { accessToken } = this.props; | |||
if (accessToken) { | |||
fetchPrismicRefs() | |||
.then(({ ref }) => fetchPrismicFeatureNews({ accessToken, ref, ps: 10 })) | |||
.then( | |||
news => { | |||
if (this.mounted && news) { | |||
this.setState({ ready: true, news }); | |||
} | |||
}, | |||
() => {} | |||
); | |||
} | |||
}; | |||
handleDismiss = (event: React.MouseEvent<HTMLAnchorElement>) => { | |||
event.preventDefault(); | |||
this.props.setCurrentUserSetting({ | |||
key: 'notificationsReadDate', | |||
value: new Date().toISOString() | |||
}); | |||
}; | |||
render() { | |||
if (!this.state.ready) { | |||
return null; | |||
} | |||
const { notificationsOptOut } = this.props; | |||
const lastNews = this.state.news[0]; | |||
const hasUnread = this.checkHasUnread(); | |||
const showNotifications = Boolean(!notificationsOptOut && lastNews && hasUnread); | |||
return ( | |||
<> | |||
{showNotifications && ( | |||
<li className="navbar-latest-notification"> | |||
<div className="navbar-latest-notification-wrapper"> | |||
<span className="badge">{translate('new')}</span> | |||
<span className="label">{lastNews.notification}</span> | |||
<a className="navbar-icon" href="#" onClick={this.handleDismiss}> | |||
<ClearIcon fill={sonarcloudBlack500} size={10} /> | |||
</a> | |||
</div> | |||
</li> | |||
)} | |||
<li> | |||
<a className="navbar-icon"> | |||
<NotificationIcon hasUnread={hasUnread && !notificationsOptOut} /> | |||
</a> | |||
</li> | |||
</> | |||
); | |||
} | |||
} | |||
const mapStateToProps = (state: Store) => { | |||
const accessToken = getGlobalSettingValue(state, 'sonar.prismic.accessToken'); | |||
const userSettings = getCurrentUserSettings(state); | |||
return { | |||
accessToken: accessToken && accessToken.value, | |||
notificationsLastReadDate: userSettings.notificationsReadDate | |||
? parseDate(userSettings.notificationsReadDate) | |||
: undefined, | |||
notificationsOptOut: userSettings.notificationsReadDate === 'true' | |||
}; | |||
}; | |||
const mapDispatchToProps = { fetchCurrentUserSettings, setCurrentUserSetting }; | |||
export default connect( | |||
mapStateToProps, | |||
mapDispatchToProps | |||
)(GlobalNavNotifications); |
@@ -176,7 +176,7 @@ export class GlobalNavPlus extends React.PureComponent<Props & WithRouterProps, | |||
} | |||
tagName="li"> | |||
<a | |||
className="navbar-plus" | |||
className="navbar-icon navbar-plus" | |||
href="#" | |||
title={ | |||
isSonarCloud() |
@@ -0,0 +1,121 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2018 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import { shallow } from 'enzyme'; | |||
import { GlobalNavNotifications } from '../GlobalNavNotifications'; | |||
import { waitAndUpdate } from '../../../../../helpers/testUtils'; | |||
import { | |||
fetchPrismicRefs, | |||
fetchPrismicFeatureNews, | |||
PrismicFeatureNews | |||
} from '../../../../../api/news'; | |||
import { parseDate } from '../../../../../helpers/dates'; | |||
// Solve redux warning issue "No reducer provided for key": | |||
// https://stackoverflow.com/questions/43375079/redux-warning-only-appearing-in-tests | |||
jest.mock('../../../../../store/rootReducer'); | |||
jest.mock('../../../../../api/news', () => { | |||
const prismicResult: PrismicFeatureNews[] = [ | |||
{ | |||
notification: '10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration', | |||
publicationDate: '2018-04-06', | |||
features: [ | |||
{ | |||
categories: [{ color: '#ff0000', name: 'Java' }], | |||
description: '10 new Java rules' | |||
} | |||
] | |||
}, | |||
{ | |||
notification: 'Some other notification', | |||
publicationDate: '2018-04-05', | |||
features: [ | |||
{ | |||
categories: [{ color: '#0000ff', name: 'BitBucket' }], | |||
description: 'BitBucket branch decoration', | |||
readMore: 'http://example.com' | |||
} | |||
] | |||
} | |||
]; | |||
return { | |||
fetchPrismicRefs: jest.fn().mockResolvedValue({ ref: 'master-ref' }), | |||
fetchPrismicFeatureNews: jest.fn().mockResolvedValue(prismicResult) | |||
}; | |||
}); | |||
beforeEach(() => { | |||
(fetchPrismicRefs as jest.Mock).mockClear(); | |||
(fetchPrismicFeatureNews as jest.Mock).mockClear(); | |||
}); | |||
it('should render correctly if there are new features, and the user has not opted out', async () => { | |||
const wrapper = shallowRender(); | |||
expect(wrapper.type()).toBeNull(); | |||
await waitAndUpdate(wrapper); | |||
expect(fetchPrismicRefs).toHaveBeenCalled(); | |||
expect(fetchPrismicFeatureNews).toHaveBeenCalled(); | |||
expect(wrapper).toMatchSnapshot(); | |||
expect(wrapper.find('.navbar-latest-notification')).toHaveLength(1); | |||
}); | |||
it('should render correctly if there are new features, but the user has opted out', async () => { | |||
const wrapper = shallowRender({ notificationsOptOut: true }); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper).toMatchSnapshot(); | |||
expect(wrapper.find('.navbar-latest-notification')).toHaveLength(0); | |||
}); | |||
it('should render correctly if there are no new unread features', async () => { | |||
const wrapper = shallowRender({ | |||
notificationsLastReadDate: parseDate('2018-12-31T12:07:19+0000') | |||
}); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper).toMatchSnapshot(); | |||
expect(wrapper.find('.navbar-latest-notification')).toHaveLength(0); | |||
}); | |||
it('should render correctly if there are no new features', async () => { | |||
(fetchPrismicFeatureNews as jest.Mock<any>).mockResolvedValue([]); | |||
const wrapper = shallowRender(); | |||
await waitAndUpdate(wrapper); | |||
expect(wrapper).toMatchSnapshot(); | |||
expect(wrapper.find('.navbar-latest-notification')).toHaveLength(0); | |||
}); | |||
function shallowRender(props: Partial<GlobalNavNotifications['props']> = {}) { | |||
return shallow( | |||
<GlobalNavNotifications | |||
accessToken="token" | |||
fetchCurrentUserSettings={jest.fn()} | |||
notificationsLastReadDate={parseDate('2018-01-01T12:07:19+0000')} | |||
notificationsOptOut={false} | |||
setCurrentUserSetting={jest.fn()} | |||
{...props} | |||
/> | |||
); | |||
} |
@@ -30,6 +30,7 @@ exports[`should render for SonarCloud 1`] = ` | |||
<ul | |||
className="global-navbar-menu global-navbar-menu-right" | |||
> | |||
<Connect(GlobalNavNotifications) /> | |||
<GlobalNavExplore | |||
location={ | |||
Object { |
@@ -0,0 +1,85 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly if there are new features, and the user has not opted out 1`] = ` | |||
<Fragment> | |||
<li | |||
className="navbar-latest-notification" | |||
> | |||
<div | |||
className="navbar-latest-notification-wrapper" | |||
> | |||
<span | |||
className="badge" | |||
> | |||
new | |||
</span> | |||
<span | |||
className="label" | |||
> | |||
10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration | |||
</span> | |||
<a | |||
className="navbar-icon" | |||
href="#" | |||
onClick={[Function]} | |||
> | |||
<ClearIcon | |||
fill="#8a8c8f" | |||
size={10} | |||
/> | |||
</a> | |||
</div> | |||
</li> | |||
<li> | |||
<a | |||
className="navbar-icon" | |||
> | |||
<NotificationIcon | |||
hasUnread={true} | |||
/> | |||
</a> | |||
</li> | |||
</Fragment> | |||
`; | |||
exports[`should render correctly if there are new features, but the user has opted out 1`] = ` | |||
<Fragment> | |||
<li> | |||
<a | |||
className="navbar-icon" | |||
> | |||
<NotificationIcon | |||
hasUnread={false} | |||
/> | |||
</a> | |||
</li> | |||
</Fragment> | |||
`; | |||
exports[`should render correctly if there are no new features 1`] = ` | |||
<Fragment> | |||
<li> | |||
<a | |||
className="navbar-icon" | |||
> | |||
<NotificationIcon | |||
hasUnread={false} | |||
/> | |||
</a> | |||
</li> | |||
</Fragment> | |||
`; | |||
exports[`should render correctly if there are no new unread features 1`] = ` | |||
<Fragment> | |||
<li> | |||
<a | |||
className="navbar-icon" | |||
> | |||
<NotificationIcon | |||
hasUnread={false} | |||
/> | |||
</a> | |||
</li> | |||
</Fragment> | |||
`; |
@@ -20,7 +20,7 @@ exports[`render 1`] = ` | |||
tagName="li" | |||
> | |||
<a | |||
className="navbar-plus" | |||
className="navbar-icon navbar-plus" | |||
href="#" | |||
title="my_account.create_new_project_portfolio_or_application" | |||
> |
@@ -209,6 +209,15 @@ declare namespace T { | |||
showOnboardingTutorial?: boolean; | |||
} | |||
export type CurrentUserSettings = { [key in CurrentUserSettingNames]?: string }; | |||
export interface CurrentUserSettingData { | |||
key: CurrentUserSettingNames; | |||
value: string; | |||
} | |||
type CurrentUserSettingNames = 'notificationsOptOut' | 'notificationsReadDate'; | |||
export interface CustomMeasure { | |||
createdAt?: string; | |||
description?: string; |
@@ -0,0 +1,52 @@ | |||
/* | |||
* SonarQube | |||
* Copyright (C) 2009-2018 SonarSource SA | |||
* mailto:info AT sonarsource DOT com | |||
* | |||
* This program is free software; you can redistribute it and/or | |||
* modify it under the terms of the GNU Lesser General Public | |||
* License as published by the Free Software Foundation; either | |||
* version 3 of the License, or (at your option) any later version. | |||
* | |||
* This program is distributed in the hope that it will be useful, | |||
* but WITHOUT ANY WARRANTY; without even the implied warranty of | |||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | |||
* Lesser General Public License for more details. | |||
* | |||
* You should have received a copy of the GNU Lesser General Public License | |||
* along with this program; if not, write to the Free Software Foundation, | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import Icon, { IconProps } from './Icon'; | |||
import { blue } from '../../app/theme'; | |||
interface Props extends IconProps { | |||
hasUnread?: boolean; | |||
} | |||
export default function NotificationIcon({ | |||
className, | |||
fill = 'currentColor', | |||
hasUnread, | |||
size | |||
}: Props) { | |||
return ( | |||
<Icon className={className} size={size}> | |||
{hasUnread ? ( | |||
<> | |||
<path | |||
d="M8 1a.875.875 0 0 0-.875.875v.57c-2.009.418-3.498 2.118-3.498 4.242 0 2.798-.987 3.652-1.516 4.22a.856.856 0 0 0-.236.593.875.875 0 0 0 .877.875h10.496a.875.875 0 0 0 .877-.875.854.854 0 0 0-.236-.594c-.497-.534-1.388-1.342-1.494-3.76a2.814 2.814 0 0 1-.768.108A2.814 2.814 0 0 1 8.814 4.44a2.814 2.814 0 0 1 .665-1.818 4.543 4.543 0 0 0-.604-.178v-.57A.875.875 0 0 0 8 1zM6.25 13.25a1.75 1.75 0 0 0 3.5 0h-3.5z" | |||
style={{ fill }} | |||
/> | |||
<circle cx="11.627" cy="4.441" r="2" style={{ fill: blue }} /> | |||
</> | |||
) : ( | |||
<path | |||
d="M8 15a1.75 1.75 0 0 0 1.75-1.75h-3.5c0 .967.784 1.75 1.75 1.75zm5.89-4.094c-.529-.567-1.517-1.421-1.517-4.218 0-2.125-1.49-3.826-3.499-4.243v-.57a.875.875 0 1 0-1.748 0v.57c-2.01.417-3.499 2.118-3.499 4.243 0 2.797-.988 3.65-1.517 4.218a.854.854 0 0 0-.235.594.876.876 0 0 0 .878.875h10.494a.876.876 0 0 0 .878-.875.853.853 0 0 0-.235-.594z" | |||
style={{ fill }} | |||
/> | |||
)} | |||
</Icon> | |||
); | |||
} |
@@ -65,6 +65,10 @@ export function getLanguages(state: Store) { | |||
return fromLanguages.getLanguages(state.languages); | |||
} | |||
export function getCurrentUserSettings(state: Store) { | |||
return fromUsers.getCurrentUserSettings(state.users); | |||
} | |||
export function getCurrentUser(state: Store) { | |||
return fromUsers.getCurrentUser(state.users); | |||
} |
@@ -21,14 +21,54 @@ import { uniq } from 'lodash'; | |||
import { Dispatch, combineReducers } from 'redux'; | |||
import { ActionType } from './utils/actions'; | |||
import * as api from '../api/users'; | |||
import { listUserSettings, setUserSetting } from '../api/user-settings'; | |||
import { isLoggedIn } from '../helpers/users'; | |||
const enum Actions { | |||
ReceiveCurrentUser = 'RECEIVE_CURRENT_USER', | |||
ReceiveCurrentUserSettings = 'RECEIVE_CURRENT_USER_SETTINGS', | |||
SkipOnboardingAction = 'SKIP_ONBOARDING', | |||
SetHomePageAction = 'SET_HOMEPAGE' | |||
} | |||
type Action = | |||
| ActionType<typeof receiveCurrentUser, Actions.ReceiveCurrentUser> | |||
| ActionType<typeof receiveCurrentUserSettings, Actions.ReceiveCurrentUserSettings> | |||
| ActionType<typeof setHomePageAction, Actions.SetHomePageAction> | |||
| ActionType<typeof skipOnboardingAction, Actions.SkipOnboardingAction>; | |||
export interface State { | |||
usersByLogin: { [login: string]: any }; | |||
userLogins: string[]; | |||
currentUser: T.CurrentUser; | |||
currentUserSettings: T.CurrentUserSettings; | |||
} | |||
export function receiveCurrentUser(user: T.CurrentUser) { | |||
return { type: 'RECEIVE_CURRENT_USER', user }; | |||
return { type: Actions.ReceiveCurrentUser, user }; | |||
} | |||
function receiveCurrentUserSettings(userSettings: T.CurrentUserSettingData[]) { | |||
return { type: Actions.ReceiveCurrentUserSettings, userSettings }; | |||
} | |||
export function fetchCurrentUserSettings() { | |||
return (dispatch: Dispatch) => { | |||
listUserSettings().then( | |||
({ userSettings }) => dispatch(receiveCurrentUserSettings(userSettings)), | |||
() => {} | |||
); | |||
}; | |||
} | |||
export function setCurrentUserSetting(setting: T.CurrentUserSettingData) { | |||
return (dispatch: Dispatch) => { | |||
setUserSetting(setting).then(() => dispatch(receiveCurrentUserSettings([setting])), () => {}); | |||
}; | |||
} | |||
function skipOnboardingAction() { | |||
return { type: 'SKIP_ONBOARDING' }; | |||
return { type: Actions.SkipOnboardingAction }; | |||
} | |||
export function skipOnboarding() { | |||
@@ -39,7 +79,7 @@ export function skipOnboarding() { | |||
} | |||
function setHomePageAction(homepage: T.HomePage) { | |||
return { type: 'SET_HOMEPAGE', homepage }; | |||
return { type: Actions.SetHomePageAction, homepage }; | |||
} | |||
export function setHomePage(homepage: T.HomePage) { | |||
@@ -53,19 +93,8 @@ export function setHomePage(homepage: T.HomePage) { | |||
}; | |||
} | |||
type Action = | |||
| ActionType<typeof receiveCurrentUser, 'RECEIVE_CURRENT_USER'> | |||
| ActionType<typeof skipOnboardingAction, 'SKIP_ONBOARDING'> | |||
| ActionType<typeof setHomePageAction, 'SET_HOMEPAGE'>; | |||
export interface State { | |||
usersByLogin: { [login: string]: any }; | |||
userLogins: string[]; | |||
currentUser: T.CurrentUser; | |||
} | |||
function usersByLogin(state: State['usersByLogin'] = {}, action: Action): State['usersByLogin'] { | |||
if (action.type === 'RECEIVE_CURRENT_USER' && isLoggedIn(action.user)) { | |||
if (action.type === Actions.ReceiveCurrentUser && isLoggedIn(action.user)) { | |||
return { ...state, [action.user.login]: action.user }; | |||
} else { | |||
return state; | |||
@@ -73,7 +102,7 @@ function usersByLogin(state: State['usersByLogin'] = {}, action: Action): State[ | |||
} | |||
function userLogins(state: State['userLogins'] = [], action: Action): State['userLogins'] { | |||
if (action.type === 'RECEIVE_CURRENT_USER' && isLoggedIn(action.user)) { | |||
if (action.type === Actions.ReceiveCurrentUser && isLoggedIn(action.user)) { | |||
return uniq([...state, action.user.login]); | |||
} else { | |||
return state; | |||
@@ -84,24 +113,42 @@ function currentUser( | |||
state: State['currentUser'] = { isLoggedIn: false }, | |||
action: Action | |||
): State['currentUser'] { | |||
if (action.type === 'RECEIVE_CURRENT_USER') { | |||
if (action.type === Actions.ReceiveCurrentUser) { | |||
return action.user; | |||
} | |||
if (action.type === 'SKIP_ONBOARDING' && isLoggedIn(state)) { | |||
if (action.type === Actions.SkipOnboardingAction && isLoggedIn(state)) { | |||
return { ...state, showOnboardingTutorial: false } as T.LoggedInUser; | |||
} | |||
if (action.type === 'SET_HOMEPAGE' && isLoggedIn(state)) { | |||
if (action.type === Actions.SetHomePageAction && isLoggedIn(state)) { | |||
return { ...state, homepage: action.homepage } as T.LoggedInUser; | |||
} | |||
return state; | |||
} | |||
export default combineReducers({ usersByLogin, userLogins, currentUser }); | |||
function currentUserSettings( | |||
state: State['currentUserSettings'] = {}, | |||
action: Action | |||
): State['currentUserSettings'] { | |||
if (action.type === Actions.ReceiveCurrentUserSettings) { | |||
const newState = { ...state }; | |||
action.userSettings.forEach((item: T.CurrentUserSettingData) => { | |||
newState[item.key] = item.value; | |||
}); | |||
return newState; | |||
} | |||
return state; | |||
} | |||
export default combineReducers({ usersByLogin, userLogins, currentUser, currentUserSettings }); | |||
export function getCurrentUser(state: State) { | |||
return state.currentUser; | |||
} | |||
export function getCurrentUserSettings(state: State) { | |||
return state.currentUserSettings; | |||
} | |||
export function getUserByLogin(state: State, login: string) { | |||
return state.usersByLogin[login]; | |||
} |
@@ -107,6 +107,7 @@ my_projects=My Projects | |||
name=Name | |||
navigation=Navigation | |||
never=Never | |||
new=New | |||
new_name=New name | |||
none=None | |||
no_tags=No tags |