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