* Refactor userSettings store, use currentUser instead: Settings will now be stored on the currently logged in user, and will no
longer live on its own.
* Only show latest feature news as unread: If there's no notificationsLastReadDate prop, only show the latest
feature news as unread, instead of all of them.
* Use Modal component to render the nofitications sidebar
uid: string;
}
+interface PrismicResponse {
+ page: number;
+ results: PrismicResult[];
+ results_per_page: number;
+ total_results_size: number;
+}
+
interface PrismicResult {
data: {
notification: string;
export function fetchPrismicFeatureNews(data: {
accessToken: string;
+ p?: number;
ps?: number;
ref: string;
-}): Promise<PrismicFeatureNews[]> {
- const q = ['[[at(document.type, "sc_product_news")]]'];
+}): Promise<{ news: PrismicFeatureNews[]; paging: T.Paging }> {
return getCorsJSON(PRISMIC_API_URL + '/documents/search', {
access_token: data.accessToken,
- orderings: '[document.first_publication_date desc]',
- pageSize: data.ps || 1,
- q,
fetchLinks: 'sc_category.color,sc_category.name',
+ orderings: '[my.sc_product_news.publication_date desc]',
+ page: data.p || 1,
+ pageSize: data.ps || 1,
+ q: ['[[at(document.type, "sc_product_news")]]'],
ref: data.ref
- }).then(({ results }: { results: PrismicResult[] }) => {
- return results.map(result => {
- return {
- notification: result.data.notification,
- publicationDate: result.data.publication_date,
- features: result.data.body.map(feature => ({
- categories: feature.items.map(item => item.category.data),
- description: feature.primary.description,
- readMore: feature.primary.read_more_link.url
- }))
- };
- });
- });
+ }).then(({ page, results, results_per_page, total_results_size }: PrismicResponse) => ({
+ news: results.map(result => ({
+ notification: result.data.notification,
+ publicationDate: result.data.publication_date,
+ features: result.data.body.map(feature => ({
+ categories: feature.items.map(item => item.category.data).filter(Boolean),
+ description: feature.primary.description,
+ readMore: feature.primary.read_more_link.url
+ }))
+ })),
+ paging: {
+ pageIndex: page,
+ pageSize: results_per_page,
+ total: total_results_size
+ }
+ }));
}
+++ /dev/null
-/*
- * 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> {
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-inner {
- background-color: #262626;
+ background-color: var(--globalNavBarBg);
z-index: 421;
}
margin-left: calc(5 * var(--gridSize));
}
-.navbar-latest-notification {
- flex: 0 1 350px;
- text-align: right;
- overflow: hidden;
-}
-
-.navbar-latest-notification-wrapper {
- position: relative;
- display: inline-block;
- padding: var(--gridSize) 34px var(--gridSize) 50px;
- height: 28px;
- max-width: 100%;
- box-sizing: border-box;
- overflow: hidden;
- vertical-align: middle;
- font-size: var(--smallFontSize);
- color: var(--sonarcloudBlack500);
- background-color: black;
- text-overflow: ellipsis;
- white-space: nowrap;
- border-radius: 3px;
- cursor: pointer;
-}
-
-.navbar-latest-notification-wrapper:hover {
- color: var(--sonarcloudBlack300);
-}
-
-.navbar-latest-notification-wrapper .badge {
- position: absolute;
- height: 18px;
- margin-right: var(--gridSize);
- left: calc(var(--gridSize) / 2);
- top: 5px;
- font-size: var(--verySmallFontSize);
- text-transform: uppercase;
- background-color: var(--lightBlue);
- color: var(--darkBlue);
-}
-
-.navbar-latest-notification-wrapper .label {
- display: block;
- max-width: 300px;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
-}
-
-.navbar-latest-notification .navbar-icon {
- position: absolute;
- right: 0;
- top: 0;
- height: 28px;
- padding: 9px var(--gridSize) !important;
- border-left: 2px solid #262626;
-}
-
-.navbar-latest-notification .navbar-icon:hover path {
- fill: var(--sonarcloudBlack300) !important;
-}
-
.global-navbar-menu-right .navbar-search {
flex: 0 1 310px; /* Workaround for SONAR-10971 */
min-width: 0;
import GlobalNavBranding, { SonarCloudNavBranding } from './GlobalNavBranding';
import GlobalNavMenu from './GlobalNavMenu';
import GlobalNavExplore from './GlobalNavExplore';
-import GlobalNavNotifications from './GlobalNavNotifications';
import GlobalNavUserContainer from './GlobalNavUserContainer';
+import NotificationsSidebar from '../../notifications/NotificationsSidebar';
+import NavLatestNotification from '../../notifications/NavLatestNotification';
import Search from '../../search/Search';
import EmbedDocsPopupHelper from '../../embed-docs-modal/EmbedDocsPopupHelper';
import * as theme from '../../../theme';
import NavBar from '../../../../components/nav/NavBar';
import { lazyLoad } from '../../../../components/lazyLoad';
-import { getCurrentUser, getAppState, Store } from '../../../../store/rootReducer';
+import {
+ fetchPrismicRefs,
+ fetchPrismicFeatureNews,
+ PrismicFeatureNews
+} from '../../../../api/news';
+import {
+ getCurrentUser,
+ getCurrentUserSetting,
+ getAppState,
+ getGlobalSettingValue,
+ Store
+} from '../../../../store/rootReducer';
import { isSonarCloud } from '../../../../helpers/system';
import { isLoggedIn } from '../../../../helpers/users';
import { OnboardingContext } from '../../OnboardingContext';
+import { setCurrentUserSetting } from '../../../../store/users';
import './GlobalNav.css';
+import { parseDate } from '../../../../helpers/dates';
const GlobalNavPlus = lazyLoad(() => import('./GlobalNavPlus'), 'GlobalNavPlus');
-interface StateProps {
+interface Props {
+ accessToken?: string;
appState: Pick<T.AppState, 'canAdmin' | 'globalPages' | 'organizationsEnabled' | 'qualifiers'>;
currentUser: T.CurrentUser;
+ location: { pathname: string };
+ notificationsLastReadDate?: Date;
+ notificationsOptOut?: boolean;
+ setCurrentUserSetting: (setting: T.CurrentUserSetting) => void;
}
-interface OwnProps {
- location: { pathname: string };
+interface State {
+ notificationSidebar?: boolean;
+ loadingNews: boolean;
+ loadingMoreNews: boolean;
+ news: PrismicFeatureNews[];
+ newsPaging?: T.Paging;
+ newsRef?: string;
}
-type Props = StateProps & OwnProps;
+const PAGE_SIZE = 5;
+
+export class GlobalNav extends React.PureComponent<Props, State> {
+ mounted = false;
+ state: State = {
+ loadingNews: false,
+ loadingMoreNews: false,
+ news: [],
+ notificationSidebar: false
+ };
+
+ componentDidMount() {
+ this.mounted = true;
+ if (isSonarCloud()) {
+ this.fetchFeatureNews();
+ }
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ fetchFeatureNews = () => {
+ const { accessToken } = this.props;
+ if (accessToken) {
+ this.setState({ loadingNews: true });
+ fetchPrismicRefs()
+ .then(({ ref }) => {
+ if (this.mounted) {
+ this.setState({ newsRef: ref });
+ }
+ return ref;
+ })
+ .then(ref => fetchPrismicFeatureNews({ accessToken, ref, ps: PAGE_SIZE }))
+ .then(
+ ({ news, paging }) => {
+ if (this.mounted) {
+ this.setState({
+ loadingNews: false,
+ news,
+ newsPaging: paging
+ });
+ }
+ },
+ () => {
+ if (this.mounted) {
+ this.setState({ loadingNews: false });
+ }
+ }
+ );
+ }
+ };
+
+ fetchMoreFeatureNews = () => {
+ const { accessToken } = this.props;
+ const { newsPaging, newsRef } = this.state;
+ if (accessToken && newsPaging && newsRef) {
+ this.setState({ loadingMoreNews: true });
+ fetchPrismicFeatureNews({
+ accessToken,
+ ref: newsRef,
+ p: newsPaging.pageIndex + 1,
+ ps: PAGE_SIZE
+ }).then(
+ ({ news, paging }) => {
+ if (this.mounted) {
+ this.setState(state => ({
+ loadingMoreNews: false,
+ news: [...state.news, ...news],
+ newsPaging: paging
+ }));
+ }
+ },
+ () => {
+ if (this.mounted) {
+ this.setState({ loadingMoreNews: false });
+ }
+ }
+ );
+ }
+ };
+
+ handleOpenNotificationSidebar = () => {
+ this.setState({ notificationSidebar: true });
+ this.fetchFeatureNews();
+ };
+
+ handleCloseNotificationSidebar = () => {
+ this.setState({ notificationSidebar: false });
+ const lastNews = this.state.news[0];
+ const readDate = lastNews ? parseDate(lastNews.publicationDate).getTime() : Date.now();
+ this.props.setCurrentUserSetting({ key: 'notifications.readDate', value: readDate.toString() });
+ };
-export class GlobalNav extends React.PureComponent<Props> {
render() {
const { appState, currentUser } = this.props;
+ const { news } = this.state;
return (
<NavBar className="navbar-global" height={theme.globalNavHeightRaw} id="global-navigation">
{isSonarCloud() ? <SonarCloudNavBranding /> : <GlobalNavBranding />}
<GlobalNavMenu {...this.props} />
<ul className="global-navbar-menu global-navbar-menu-right">
- {isSonarCloud() && <GlobalNavNotifications />}
+ {isSonarCloud() &&
+ news.length > 0 && (
+ <NavLatestNotification
+ lastNews={news[0]}
+ notificationsLastReadDate={this.props.notificationsLastReadDate}
+ notificationsOptOut={this.props.notificationsOptOut}
+ onClick={this.handleOpenNotificationSidebar}
+ setCurrentUserSetting={this.props.setCurrentUserSetting}
+ />
+ )}
{isSonarCloud() && <GlobalNavExplore location={this.props.location} />}
<EmbedDocsPopupHelper />
<Search appState={appState} currentUser={currentUser} />
)}
<GlobalNavUserContainer appState={appState} currentUser={currentUser} />
</ul>
+ {isSonarCloud() &&
+ this.state.notificationSidebar && (
+ <NotificationsSidebar
+ fetchMoreFeatureNews={this.fetchMoreFeatureNews}
+ loading={this.state.loadingNews}
+ loadingMore={this.state.loadingMoreNews}
+ news={news}
+ notificationsLastReadDate={this.props.notificationsLastReadDate}
+ onClose={this.handleCloseNotificationSidebar}
+ paging={this.state.newsPaging}
+ />
+ )}
</NavBar>
);
}
}
-const mapStateToProps = (state: Store): StateProps => ({
- currentUser: getCurrentUser(state),
- appState: getAppState(state)
-});
+const mapStateToProps = (state: Store) => {
+ const accessToken = getGlobalSettingValue(state, 'sonar.prismic.accessToken');
+ const notificationsLastReadDate = getCurrentUserSetting(state, 'notifications.readDate');
+ const notificationsOptOut = getCurrentUserSetting(state, 'notifications.optOut') === 'true';
+
+ return {
+ currentUser: getCurrentUser(state),
+ appState: getAppState(state),
+ accessToken: accessToken && accessToken.value,
+ notificationsLastReadDate: notificationsLastReadDate
+ ? parseDate(Number(notificationsLastReadDate))
+ : undefined,
+ notificationsOptOut
+ };
+};
+
+const mapDispatchToProps = {
+ setCurrentUserSetting
+};
-export default connect(mapStateToProps)(GlobalNav);
+export default connect(
+ mapStateToProps,
+ mapDispatchToProps
+)(GlobalNav);
+++ /dev/null
-/*
- * 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 { GlobalNav } from '../GlobalNav';
import { isSonarCloud } from '../../../../../helpers/system';
+import { waitAndUpdate, click } from '../../../../../helpers/testUtils';
+import {
+ fetchPrismicRefs,
+ fetchPrismicFeatureNews,
+ PrismicFeatureNews
+} from '../../../../../api/news';
jest.mock('../../../../../helpers/system', () => ({ isSonarCloud: jest.fn() }));
+// Solve redux warning issue "No reducer provided for key":
+// https://stackoverflow.com/questions/43375079/redux-warning-only-appearing-in-tests
+jest.mock('../../../../../store/rootReducer');
+
+jest.mock('../../../../../api/news', () => {
+ const prismicResult: PrismicFeatureNews[] = [
+ {
+ notification: '10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration',
+ publicationDate: '2018-04-06',
+ features: [
+ {
+ categories: [{ color: '#ff0000', name: 'Java' }],
+ description: '10 new Java rules'
+ }
+ ]
+ },
+ {
+ notification: 'Some other notification',
+ publicationDate: '2018-04-05',
+ features: [
+ {
+ categories: [{ color: '#0000ff', name: 'BitBucket' }],
+ description: 'BitBucket branch decoration',
+ readMore: 'http://example.com'
+ }
+ ]
+ }
+ ];
+
+ return {
+ fetchPrismicRefs: jest.fn().mockResolvedValue({ ref: 'master-ref' }),
+ fetchPrismicFeatureNews: jest.fn().mockResolvedValue({
+ news: prismicResult,
+ paging: { pageIndex: 1, pageSize: 10, total: 2 }
+ })
+ };
+});
+
const appState: GlobalNav['props']['appState'] = {
globalPages: [],
canAdmin: false,
};
const location = { pathname: '' };
-it('should render for SonarQube', () => {
- runTest(false);
+beforeEach(() => {
+ (fetchPrismicRefs as jest.Mock).mockClear();
+ (fetchPrismicFeatureNews as jest.Mock).mockClear();
});
-it('should render for SonarCloud', () => {
- runTest(true);
+it('should render for SonarQube', async () => {
+ (isSonarCloud as jest.Mock).mockImplementation(() => false);
+
+ const wrapper = shallowRender();
+
+ expect(wrapper).toMatchSnapshot();
+ wrapper.setProps({ currentUser: { isLoggedIn: true } });
+ expect(wrapper.find('[data-test="global-nav-plus"]').exists()).toBe(true);
+
+ await waitAndUpdate(wrapper);
+ expect(fetchPrismicRefs).not.toBeCalled();
});
-function runTest(mockedIsSonarCloud: boolean) {
- (isSonarCloud as jest.Mock).mockImplementation(() => mockedIsSonarCloud);
- const wrapper = shallow(
- <GlobalNav appState={appState} currentUser={{ isLoggedIn: false }} location={location} />
- );
+it('should render for SonarCloud', () => {
+ (isSonarCloud as jest.Mock).mockImplementation(() => true);
+
+ const wrapper = shallowRender();
+
expect(wrapper).toMatchSnapshot();
wrapper.setProps({ currentUser: { isLoggedIn: true } });
expect(wrapper.find('[data-test="global-nav-plus"]').exists()).toBe(true);
+});
+
+it('should render correctly if there are new features', async () => {
+ (isSonarCloud as jest.Mock).mockImplementation(() => true);
+
+ const wrapper = shallowRender();
+
+ await waitAndUpdate(wrapper);
+ expect(fetchPrismicRefs).toHaveBeenCalled();
+ expect(fetchPrismicFeatureNews).toHaveBeenCalled();
+ expect(wrapper).toMatchSnapshot();
+ expect(wrapper.find('NavLatestNotification').exists()).toBe(true);
+ click(wrapper.find('NavLatestNotification'));
+ expect(wrapper.find('NotificationsSidebar').exists()).toBe(true);
+});
+
+function shallowRender(props: Partial<GlobalNav['props']> = {}) {
+ return shallow(
+ <GlobalNav
+ accessToken="token"
+ appState={appState}
+ currentUser={{ isLoggedIn: false }}
+ location={location}
+ setCurrentUserSetting={jest.fn()}
+ {...props}
+ />
+ );
}
+++ /dev/null
-/*
- * 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
+exports[`should render correctly if there are new features 1`] = `
+<NavBar
+ className="navbar-global"
+ height={48}
+ id="global-navigation"
+>
+ <SonarCloudNavBranding />
+ <GlobalNavMenu
+ accessToken="token"
+ appState={
+ Object {
+ "canAdmin": false,
+ "globalPages": Array [],
+ "organizationsEnabled": false,
+ "qualifiers": Array [],
+ }
+ }
+ currentUser={
+ Object {
+ "isLoggedIn": false,
+ }
+ }
+ location={
+ Object {
+ "pathname": "",
+ }
+ }
+ setCurrentUserSetting={[MockFunction]}
+ />
+ <ul
+ className="global-navbar-menu global-navbar-menu-right"
+ >
+ <NavLatestNotification
+ lastNews={
+ Object {
+ "features": Array [
+ Object {
+ "categories": Array [
+ Object {
+ "color": "#ff0000",
+ "name": "Java",
+ },
+ ],
+ "description": "10 new Java rules",
+ },
+ ],
+ "notification": "10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration",
+ "publicationDate": "2018-04-06",
+ }
+ }
+ onClick={[Function]}
+ setCurrentUserSetting={[MockFunction]}
+ />
+ <GlobalNavExplore
+ location={
+ Object {
+ "pathname": "",
+ }
+ }
+ />
+ <EmbedDocsPopupHelper />
+ <withRouter(Search)
+ appState={
+ Object {
+ "canAdmin": false,
+ "globalPages": Array [],
+ "organizationsEnabled": false,
+ "qualifiers": Array [],
+ }
+ }
+ currentUser={
+ Object {
+ "isLoggedIn": false,
+ }
+ }
+ />
+ <Connect(withRouter(GlobalNavUser))
+ appState={
+ Object {
+ "canAdmin": false,
+ "globalPages": Array [],
+ "organizationsEnabled": false,
+ "qualifiers": Array [],
+ }
+ }
+ currentUser={
+ Object {
+ "isLoggedIn": false,
+ }
+ }
+ />
+ </ul>
+</NavBar>
+`;
+
exports[`should render for SonarCloud 1`] = `
<NavBar
className="navbar-global"
>
<SonarCloudNavBranding />
<GlobalNavMenu
+ accessToken="token"
appState={
Object {
"canAdmin": false,
"pathname": "",
}
}
+ setCurrentUserSetting={[MockFunction]}
/>
<ul
className="global-navbar-menu global-navbar-menu-right"
>
- <Connect(GlobalNavNotifications) />
<GlobalNavExplore
location={
Object {
>
<Connect(GlobalNavBranding) />
<GlobalNavMenu
+ accessToken="token"
appState={
Object {
"canAdmin": false,
"pathname": "",
}
}
+ setCurrentUserSetting={[MockFunction]}
/>
<ul
className="global-navbar-menu global-navbar-menu-right"
+++ /dev/null
-// 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>
-`;
--- /dev/null
+/*
+ * 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>
+ </>
+ );
+ }
+}
--- /dev/null
+/*
+ * 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>
+ );
+}
--- /dev/null
+/*
+ * 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}
+ />
+ );
+}
--- /dev/null
+/*
+ * 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}
+ />
+ );
+}
--- /dev/null
+// 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"
+ onClick={[MockFunction]}
+ >
+ <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>
+ </div>
+ </li>
+ <li
+ className="navbar-latest-notification-dismiss"
+ >
+ <a
+ className="navbar-icon"
+ href="#"
+ onClick={[Function]}
+ >
+ <ClearIcon
+ fill="#8a8c8f"
+ size={10}
+ />
+ </a>
+ </li>
+ <li>
+ <a
+ className="navbar-icon"
+ href="#"
+ onClick={[Function]}
+ >
+ <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"
+ href="#"
+ onClick={[Function]}
+ >
+ <NotificationIcon
+ hasUnread={false}
+ />
+ </a>
+ </li>
+</Fragment>
+`;
+
+exports[`should render correctly if there are no new unread features 1`] = `
+<Fragment>
+ <li>
+ <a
+ className="navbar-icon"
+ href="#"
+ onClick={[Function]}
+ >
+ <NotificationIcon
+ hasUnread={false}
+ />
+ </a>
+ </li>
+</Fragment>
+`;
--- /dev/null
+// 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>
+`;
--- /dev/null
+/*
+ * 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',
leakBorderColor: '#eae3c7',
+ globalNavBarBg: '#262626',
+
snippetFontColor: '#f0f0f0',
// alerts
showOnboardingTutorial?: boolean;
}
- export type CurrentUserSettings = { [key in CurrentUserSettingNames]?: string };
-
- export interface CurrentUserSettingData {
+ export interface CurrentUserSetting {
key: CurrentUserSettingNames;
value: string;
}
- type CurrentUserSettingNames = 'notificationsOptOut' | 'notificationsReadDate';
+ type CurrentUserSettingNames = 'notifications.optOut' | 'notifications.readDate';
export interface CustomMeasure {
createdAt?: string;
name: string;
personalOrganization?: string;
scmAccounts: string[];
+ settings?: CurrentUserSetting[];
}
export interface LongLivingBranch extends Branch {
}
.copy-paste-link .close {
- color: black;
+ color: #000;
border-bottom: 0;
height: 100%;
display: inline-block;
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) {
import { uniq } from 'lodash';
import { Dispatch, combineReducers } from 'redux';
import { ActionType } from './utils/actions';
-import * as api from '../api/users';
-import { listUserSettings, setUserSetting } from '../api/user-settings';
import { isLoggedIn } from '../helpers/users';
+import * as api from '../api/users';
const enum Actions {
ReceiveCurrentUser = 'RECEIVE_CURRENT_USER',
- ReceiveCurrentUserSettings = 'RECEIVE_CURRENT_USER_SETTINGS',
+ SetCurrentUserSetting = 'SET_CURRENT_USER_SETTING',
SkipOnboardingAction = 'SKIP_ONBOARDING',
SetHomePageAction = 'SET_HOMEPAGE'
}
type Action =
| ActionType<typeof receiveCurrentUser, Actions.ReceiveCurrentUser>
- | ActionType<typeof receiveCurrentUserSettings, Actions.ReceiveCurrentUserSettings>
+ | ActionType<typeof setCurrentUserSettingAction, Actions.SetCurrentUserSetting>
| ActionType<typeof setHomePageAction, Actions.SetHomePageAction>
| ActionType<typeof skipOnboardingAction, Actions.SkipOnboardingAction>;
usersByLogin: { [login: string]: any };
userLogins: string[];
currentUser: T.CurrentUser;
- currentUserSettings: T.CurrentUserSettings;
}
export function receiveCurrentUser(user: T.CurrentUser) {
return { type: Actions.ReceiveCurrentUser, user };
}
-function receiveCurrentUserSettings(userSettings: T.CurrentUserSettingData[]) {
- return { type: Actions.ReceiveCurrentUserSettings, userSettings };
-}
-
-export function fetchCurrentUserSettings() {
- return (dispatch: Dispatch) => {
- listUserSettings().then(
- ({ userSettings }) => dispatch(receiveCurrentUserSettings(userSettings)),
- () => {}
- );
- };
-}
-
-export function setCurrentUserSetting(setting: T.CurrentUserSettingData) {
- return (dispatch: Dispatch) => {
- setUserSetting(setting).then(() => dispatch(receiveCurrentUserSettings([setting])), () => {});
- };
-}
-
function skipOnboardingAction() {
return { type: Actions.SkipOnboardingAction };
}
return { type: Actions.SetHomePageAction, homepage };
}
+function setCurrentUserSettingAction(setting: T.CurrentUserSetting) {
+ return { type: Actions.SetCurrentUserSetting, setting };
+}
+
export function setHomePage(homepage: T.HomePage) {
return (dispatch: Dispatch) => {
api.setHomePage(homepage).then(
};
}
+export function setCurrentUserSetting(setting: T.CurrentUserSetting) {
+ return (dispatch: Dispatch, getState: () => { users: State }) => {
+ const oldSetting = getCurrentUserSetting(getState().users, setting.key);
+ dispatch(setCurrentUserSettingAction(setting));
+ api.setUserSetting(setting).then(
+ () => {},
+ () => {
+ dispatch(setCurrentUserSettingAction({ ...setting, value: oldSetting || '' }));
+ }
+ );
+ };
+}
+
function usersByLogin(state: State['usersByLogin'] = {}, action: Action): State['usersByLogin'] {
if (action.type === Actions.ReceiveCurrentUser && isLoggedIn(action.user)) {
return { ...state, [action.user.login]: action.user };
if (action.type === Actions.SetHomePageAction && isLoggedIn(state)) {
return { ...state, homepage: action.homepage } as T.LoggedInUser;
}
- return state;
-}
-
-function currentUserSettings(
- state: State['currentUserSettings'] = {},
- action: Action
-): State['currentUserSettings'] {
- if (action.type === Actions.ReceiveCurrentUserSettings) {
- const newState = { ...state };
- action.userSettings.forEach((item: T.CurrentUserSettingData) => {
- newState[item.key] = item.value;
- });
- return newState;
+ if (action.type === Actions.SetCurrentUserSetting && isLoggedIn(state)) {
+ let settings: T.CurrentUserSetting[];
+ if (state.settings) {
+ settings = [...state.settings];
+ const index = settings.findIndex(setting => setting.key === action.setting.key);
+ if (index === -1) {
+ settings.push(action.setting);
+ } else {
+ settings[index] = action.setting;
+ }
+ } else {
+ settings = [action.setting];
+ }
+ return { ...state, settings } as T.LoggedInUser;
}
return state;
}
-export default combineReducers({ usersByLogin, userLogins, currentUser, currentUserSettings });
+export default combineReducers({ usersByLogin, userLogins, currentUser });
export function getCurrentUser(state: State) {
return state.currentUser;
}
-export function getCurrentUserSettings(state: State) {
- return state.currentUserSettings;
+export function getCurrentUserSetting(state: State, key: T.CurrentUserSettingNames) {
+ let setting;
+ if (isLoggedIn(state.currentUser) && state.currentUser.settings) {
+ setting = state.currentUser.settings.find(setting => setting.key === key);
+ }
+ return setting && setting.value;
}
export function getUserByLogin(state: State, login: string) {
library=Library
line_number=Line Number
links=Links
+load_more=Load more
load_verb=Load
login=Login
major=Major
embed_docs.news=Product News
embed_docs.stay_connected=Stay Connected
embed_docs.suggestion=Suggestions For This Page
+embed_docs.whats_new=What's new on SonarCloud?
#------------------------------------------------------------------------------
#