From: Grégoire Aubert Date: Thu, 13 Dec 2018 17:17:07 +0000 (+0100) Subject: SONARCLOUD-271 Create new Notification sidebar X-Git-Tag: 7.6~229 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=44ab47b5ed95185667169704dd614cd5f8e39c57;p=sonarqube.git SONARCLOUD-271 Create new Notification sidebar * 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 --- diff --git a/server/sonar-web/src/main/js/api/news.ts b/server/sonar-web/src/main/js/api/news.ts index 4b11b157763..724578851c5 100644 --- a/server/sonar-web/src/main/js/api/news.ts +++ b/server/sonar-web/src/main/js/api/news.ts @@ -30,6 +30,13 @@ export interface PrismicNews { uid: string; } +interface PrismicResponse { + page: number; + results: PrismicResult[]; + results_per_page: number; + total_results_size: number; +} + interface PrismicResult { data: { notification: string; @@ -101,28 +108,32 @@ export function fetchPrismicNews(data: { export function fetchPrismicFeatureNews(data: { accessToken: string; + p?: number; ps?: number; ref: string; -}): Promise { - 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 + } + })); } diff --git a/server/sonar-web/src/main/js/api/user-settings.ts b/server/sonar-web/src/main/js/api/user-settings.ts deleted file mode 100644 index f28c9fbbe07..00000000000 --- a/server/sonar-web/src/main/js/api/user-settings.ts +++ /dev/null @@ -1,41 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2018 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -import { getJSON, post } from '../helpers/request'; -import throwGlobalError from '../app/utils/throwGlobalError'; - -export function setUserSetting(data: T.CurrentUserSettingData) { - return post('/api/user_settings/set', data) - .catch(() => Promise.resolve()) // TODO Remove mock. - .catch(throwGlobalError); -} - -export function listUserSettings(): Promise<{ userSettings: T.CurrentUserSettingData[] }> { - return getJSON('/api/user_settings/list') - .catch(() => { - // TODO Remove mock. - return { - userSettings: [ - { key: 'notificationsReadDate', value: '2018-12-01T12:07:19+0000' }, - { key: 'notificationsOptOut', value: 'false' } - ] - }; - }) - .catch(throwGlobalError); -} diff --git a/server/sonar-web/src/main/js/api/users.ts b/server/sonar-web/src/main/js/api/users.ts index 75405a9173f..3a882729733 100644 --- a/server/sonar-web/src/main/js/api/users.ts +++ b/server/sonar-web/src/main/js/api/users.ts @@ -106,3 +106,7 @@ export function skipOnboarding(): Promise { export function setHomePage(homepage: T.HomePage): Promise { return post('/api/users/set_homepage', homepage).catch(throwGlobalError); } + +export function setUserSetting(setting: T.CurrentUserSetting): Promise { + return post('/api/users/set_setting', setting).catch(throwGlobalError); +} 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 c4eb2f4b698..62638ec97ee 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 @@ -19,7 +19,7 @@ */ .navbar-global, .navbar-global .navbar-inner { - background-color: #262626; + background-color: var(--globalNavBarBg); z-index: 421; } @@ -100,67 +100,6 @@ margin-left: calc(5 * var(--gridSize)); } -.navbar-latest-notification { - flex: 0 1 350px; - text-align: right; - overflow: hidden; -} - -.navbar-latest-notification-wrapper { - position: relative; - display: inline-block; - padding: var(--gridSize) 34px var(--gridSize) 50px; - height: 28px; - max-width: 100%; - box-sizing: border-box; - overflow: hidden; - vertical-align: middle; - font-size: var(--smallFontSize); - color: var(--sonarcloudBlack500); - background-color: black; - text-overflow: ellipsis; - white-space: nowrap; - border-radius: 3px; - cursor: pointer; -} - -.navbar-latest-notification-wrapper:hover { - color: var(--sonarcloudBlack300); -} - -.navbar-latest-notification-wrapper .badge { - position: absolute; - height: 18px; - margin-right: var(--gridSize); - left: calc(var(--gridSize) / 2); - top: 5px; - font-size: var(--verySmallFontSize); - text-transform: uppercase; - background-color: var(--lightBlue); - color: var(--darkBlue); -} - -.navbar-latest-notification-wrapper .label { - display: block; - max-width: 300px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.navbar-latest-notification .navbar-icon { - position: absolute; - right: 0; - top: 0; - height: 28px; - padding: 9px var(--gridSize) !important; - border-left: 2px solid #262626; -} - -.navbar-latest-notification .navbar-icon:hover path { - fill: var(--sonarcloudBlack300) !important; -} - .global-navbar-menu-right .navbar-search { flex: 0 1 310px; /* Workaround for SONAR-10971 */ min-width: 0; 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 0c69b523c1a..c7dcae01b7a 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,35 +22,151 @@ import { connect } from 'react-redux'; import GlobalNavBranding, { SonarCloudNavBranding } from './GlobalNavBranding'; import GlobalNavMenu from './GlobalNavMenu'; import GlobalNavExplore from './GlobalNavExplore'; -import GlobalNavNotifications from './GlobalNavNotifications'; import GlobalNavUserContainer from './GlobalNavUserContainer'; +import NotificationsSidebar from '../../notifications/NotificationsSidebar'; +import NavLatestNotification from '../../notifications/NavLatestNotification'; import Search from '../../search/Search'; import EmbedDocsPopupHelper from '../../embed-docs-modal/EmbedDocsPopupHelper'; import * as theme from '../../../theme'; import NavBar from '../../../../components/nav/NavBar'; import { lazyLoad } from '../../../../components/lazyLoad'; -import { getCurrentUser, getAppState, Store } from '../../../../store/rootReducer'; +import { + fetchPrismicRefs, + fetchPrismicFeatureNews, + PrismicFeatureNews +} from '../../../../api/news'; +import { + getCurrentUser, + getCurrentUserSetting, + getAppState, + getGlobalSettingValue, + Store +} from '../../../../store/rootReducer'; import { isSonarCloud } from '../../../../helpers/system'; import { isLoggedIn } from '../../../../helpers/users'; import { OnboardingContext } from '../../OnboardingContext'; +import { setCurrentUserSetting } from '../../../../store/users'; import './GlobalNav.css'; +import { parseDate } from '../../../../helpers/dates'; const GlobalNavPlus = lazyLoad(() => import('./GlobalNavPlus'), 'GlobalNavPlus'); -interface StateProps { +interface Props { + accessToken?: string; appState: Pick; 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 { + 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 { render() { const { appState, currentUser } = this.props; + const { news } = this.state; return ( {isSonarCloud() ? : } @@ -58,7 +174,16 @@ export class GlobalNav extends React.PureComponent {
    - {isSonarCloud() && } + {isSonarCloud() && + news.length > 0 && ( + + )} {isSonarCloud() && } @@ -75,14 +200,44 @@ export class GlobalNav extends React.PureComponent { )}
+ {isSonarCloud() && + this.state.notificationSidebar && ( + + )}
); } } -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); 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 deleted file mode 100644 index 7b00dccc4d8..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavNotifications.tsx +++ /dev/null @@ -1,152 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2018 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -import * as React from 'react'; -import { connect } from 'react-redux'; -import ClearIcon from '../../../../components/icons-components/ClearIcon'; -import NotificationIcon from '../../../../components/icons-components/NotificationIcon'; -import { sonarcloudBlack500 } from '../../../theme'; -import { - fetchPrismicRefs, - fetchPrismicFeatureNews, - PrismicFeatureNews -} from '../../../../api/news'; -import { differenceInSeconds, parseDate } from '../../../../helpers/dates'; -import { translate } from '../../../../helpers/l10n'; -import { fetchCurrentUserSettings, setCurrentUserSetting } from '../../../../store/users'; -import { - getGlobalSettingValue, - getCurrentUserSettings, - Store -} from '../../../../store/rootReducer'; - -interface Props { - accessToken?: string; - fetchCurrentUserSettings: () => void; - notificationsLastReadDate?: Date; - notificationsOptOut?: boolean; - setCurrentUserSetting: (setting: T.CurrentUserSettingData) => void; -} - -interface State { - news: PrismicFeatureNews[]; - ready: boolean; -} - -export class GlobalNavNotifications extends React.PureComponent { - 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) => { - 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 && ( -
  • -
    - {translate('new')} - {lastNews.notification} - - - -
    -
  • - )} -
  • - - - -
  • - - ); - } -} - -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/__tests__/GlobalNav-test.tsx b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNav-test.tsx index edcf7972084..e59ca5983cf 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNav-test.tsx +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNav-test.tsx @@ -21,9 +21,53 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import { GlobalNav } from '../GlobalNav'; import { isSonarCloud } from '../../../../../helpers/system'; +import { waitAndUpdate, click } from '../../../../../helpers/testUtils'; +import { + fetchPrismicRefs, + fetchPrismicFeatureNews, + PrismicFeatureNews +} from '../../../../../api/news'; jest.mock('../../../../../helpers/system', () => ({ isSonarCloud: jest.fn() })); +// Solve redux warning issue "No reducer provided for key": +// https://stackoverflow.com/questions/43375079/redux-warning-only-appearing-in-tests +jest.mock('../../../../../store/rootReducer'); + +jest.mock('../../../../../api/news', () => { + const prismicResult: PrismicFeatureNews[] = [ + { + notification: '10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration', + publicationDate: '2018-04-06', + features: [ + { + categories: [{ color: '#ff0000', name: 'Java' }], + description: '10 new Java rules' + } + ] + }, + { + notification: 'Some other notification', + publicationDate: '2018-04-05', + features: [ + { + categories: [{ color: '#0000ff', name: 'BitBucket' }], + description: 'BitBucket branch decoration', + readMore: 'http://example.com' + } + ] + } + ]; + + return { + fetchPrismicRefs: jest.fn().mockResolvedValue({ ref: 'master-ref' }), + fetchPrismicFeatureNews: jest.fn().mockResolvedValue({ + news: prismicResult, + paging: { pageIndex: 1, pageSize: 10, total: 2 } + }) + }; +}); + const appState: GlobalNav['props']['appState'] = { globalPages: [], canAdmin: false, @@ -32,20 +76,57 @@ const appState: GlobalNav['props']['appState'] = { }; const location = { pathname: '' }; -it('should render for SonarQube', () => { - runTest(false); +beforeEach(() => { + (fetchPrismicRefs as jest.Mock).mockClear(); + (fetchPrismicFeatureNews as jest.Mock).mockClear(); }); -it('should render for SonarCloud', () => { - runTest(true); +it('should render for SonarQube', async () => { + (isSonarCloud as jest.Mock).mockImplementation(() => false); + + const wrapper = shallowRender(); + + expect(wrapper).toMatchSnapshot(); + wrapper.setProps({ currentUser: { isLoggedIn: true } }); + expect(wrapper.find('[data-test="global-nav-plus"]').exists()).toBe(true); + + await waitAndUpdate(wrapper); + expect(fetchPrismicRefs).not.toBeCalled(); }); -function runTest(mockedIsSonarCloud: boolean) { - (isSonarCloud as jest.Mock).mockImplementation(() => mockedIsSonarCloud); - const wrapper = shallow( - - ); +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 = {}) { + return shallow( + + ); } 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 deleted file mode 100644 index b39aeeb1b92..00000000000 --- a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavNotifications-test.tsx +++ /dev/null @@ -1,121 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2018 SonarSource SA - * mailto:info AT sonarsource DOT com - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU Lesser General Public - * License as published by the Free Software Foundation; either - * version 3 of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU - * Lesser General Public License for more details. - * - * You should have received a copy of the GNU Lesser General Public License - * along with this program; if not, write to the Free Software Foundation, - * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - */ -import * as React from 'react'; -import { shallow } from 'enzyme'; -import { GlobalNavNotifications } from '../GlobalNavNotifications'; -import { waitAndUpdate } from '../../../../../helpers/testUtils'; -import { - fetchPrismicRefs, - fetchPrismicFeatureNews, - PrismicFeatureNews -} from '../../../../../api/news'; -import { parseDate } from '../../../../../helpers/dates'; - -// Solve redux warning issue "No reducer provided for key": -// https://stackoverflow.com/questions/43375079/redux-warning-only-appearing-in-tests -jest.mock('../../../../../store/rootReducer'); - -jest.mock('../../../../../api/news', () => { - const prismicResult: PrismicFeatureNews[] = [ - { - notification: '10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration', - publicationDate: '2018-04-06', - features: [ - { - categories: [{ color: '#ff0000', name: 'Java' }], - description: '10 new Java rules' - } - ] - }, - { - notification: 'Some other notification', - publicationDate: '2018-04-05', - features: [ - { - categories: [{ color: '#0000ff', name: 'BitBucket' }], - description: 'BitBucket branch decoration', - readMore: 'http://example.com' - } - ] - } - ]; - - return { - fetchPrismicRefs: jest.fn().mockResolvedValue({ ref: 'master-ref' }), - fetchPrismicFeatureNews: jest.fn().mockResolvedValue(prismicResult) - }; -}); - -beforeEach(() => { - (fetchPrismicRefs as jest.Mock).mockClear(); - (fetchPrismicFeatureNews as jest.Mock).mockClear(); -}); - -it('should render correctly if there are new features, and the user has not opted out', async () => { - const wrapper = shallowRender(); - expect(wrapper.type()).toBeNull(); - - await waitAndUpdate(wrapper); - expect(fetchPrismicRefs).toHaveBeenCalled(); - expect(fetchPrismicFeatureNews).toHaveBeenCalled(); - expect(wrapper).toMatchSnapshot(); - expect(wrapper.find('.navbar-latest-notification')).toHaveLength(1); -}); - -it('should render correctly if there are new features, but the user has opted out', async () => { - const wrapper = shallowRender({ notificationsOptOut: true }); - - await waitAndUpdate(wrapper); - expect(wrapper).toMatchSnapshot(); - expect(wrapper.find('.navbar-latest-notification')).toHaveLength(0); -}); - -it('should render correctly if there are no new unread features', async () => { - const wrapper = shallowRender({ - notificationsLastReadDate: parseDate('2018-12-31T12:07:19+0000') - }); - - await waitAndUpdate(wrapper); - expect(wrapper).toMatchSnapshot(); - expect(wrapper.find('.navbar-latest-notification')).toHaveLength(0); -}); - -it('should render correctly if there are no new features', async () => { - (fetchPrismicFeatureNews as jest.Mock).mockResolvedValue([]); - - const wrapper = shallowRender(); - - await waitAndUpdate(wrapper); - expect(wrapper).toMatchSnapshot(); - expect(wrapper.find('.navbar-latest-notification')).toHaveLength(0); -}); - -function shallowRender(props: Partial = {}) { - return shallow( - - ); -} 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 b99134f2ced..adf97af0f51 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 @@ -1,5 +1,100 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`should render correctly if there are new features 1`] = ` + + + +
      + + + + + +
    +
    +`; + exports[`should render for SonarCloud 1`] = `
      -
        -
      • -
        - - new - - - 10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration - - - - -
        -
      • -
      • - - - -
      • - -`; - -exports[`should render correctly if there are new features, but the user has opted out 1`] = ` - -
      • - - - -
      • -
        -`; - -exports[`should render correctly if there are no new features 1`] = ` - -
      • - - - -
      • -
        -`; - -exports[`should render correctly if there are no new unread features 1`] = ` - -
      • - - - -
      • -
        -`; diff --git a/server/sonar-web/src/main/js/app/components/notifications/NavLatestNotification.tsx b/server/sonar-web/src/main/js/app/components/notifications/NavLatestNotification.tsx new file mode 100644 index 00000000000..516c72daf28 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/notifications/NavLatestNotification.tsx @@ -0,0 +1,93 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import ClearIcon from '../../../components/icons-components/ClearIcon'; +import NotificationIcon from '../../../components/icons-components/NotificationIcon'; +import { sonarcloudBlack500 } from '../../theme'; +import { PrismicFeatureNews } from '../../../api/news'; +import { differenceInSeconds, parseDate } from '../../../helpers/dates'; +import { translate } from '../../../helpers/l10n'; +import './notifications.css'; + +interface Props { + lastNews: PrismicFeatureNews; + notificationsLastReadDate?: Date; + notificationsOptOut?: boolean; + onClick: () => void; + setCurrentUserSetting: (setting: T.CurrentUserSetting) => void; +} + +export default class NavLatestNotification extends React.PureComponent { + mounted = false; + + checkHasUnread = () => { + const { notificationsLastReadDate, lastNews } = this.props; + return ( + !notificationsLastReadDate || + differenceInSeconds(parseDate(lastNews.publicationDate), notificationsLastReadDate) > 0 + ); + }; + + handleClick = (event: React.MouseEvent) => { + event.preventDefault(); + event.currentTarget.blur(); + this.props.onClick(); + }; + + handleDismiss = (event: React.MouseEvent) => { + 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 && ( + <> +
      • +
        + {translate('new')} + {lastNews.notification} +
        +
      • +
      • + + + +
      • + + )} +
      • + + + +
      • + + ); + } +} diff --git a/server/sonar-web/src/main/js/app/components/notifications/NotificationsSidebar.tsx b/server/sonar-web/src/main/js/app/components/notifications/NotificationsSidebar.tsx new file mode 100644 index 00000000000..f267574cde8 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/notifications/NotificationsSidebar.tsx @@ -0,0 +1,136 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import * as classNames from 'classnames'; +import ClearIcon from '../../../components/icons-components/ClearIcon'; +import DateFormatter from '../../../components/intl/DateFormatter'; +import DeferredSpinner from '../../../components/common/DeferredSpinner'; +import Modal from '../../../components/controls/Modal'; +import { PrismicFeatureNews } from '../../../api/news'; +import { differenceInSeconds, parseDate } from '../../../helpers/dates'; +import { translate } from '../../../helpers/l10n'; + +export interface Props { + fetchMoreFeatureNews: () => void; + loading: boolean; + loadingMore: boolean; + news: PrismicFeatureNews[]; + onClose: () => void; + notificationsLastReadDate?: Date; + paging?: T.Paging; +} + +export default function NotificationsSidebar(props: Props) { + const { loading, loadingMore, news, notificationsLastReadDate, paging } = props; + return ( + +
        +
        +

        {translate('embed_docs.whats_new')}

        + + + +
        +
        + {loading ? ( +
        + +
        + ) : ( + news.map((slice, index) => ( + + )) + )} +
        + {!loading && + paging && + paging.total > news.length && ( +
        +
        + + {translate('show_more')} + + {loadingMore && ( + + )} +
        +
        + )} +
        +
        + ); +} + +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 ( +
        +

        + +

        + {notification.features.map((feature, index) => ( + + ))} +
        + ); +} + +interface FeatureProps { + feature: PrismicFeatureNews['features'][0]; +} + +export function Feature({ feature }: FeatureProps) { + return ( +
        +
          + {feature.categories.map(category => ( +
        • + {category.name} +
        • + ))} +
        + {feature.description} + {feature.readMore && ( + + {translate('learn_more')} + + )} +
        + ); +} diff --git a/server/sonar-web/src/main/js/app/components/notifications/__tests__/NavLatestNotification-test.tsx b/server/sonar-web/src/main/js/app/components/notifications/__tests__/NavLatestNotification-test.tsx new file mode 100644 index 00000000000..9f447402b93 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/notifications/__tests__/NavLatestNotification-test.tsx @@ -0,0 +1,67 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import NavLatestNotification from '../NavLatestNotification'; +import { PrismicFeatureNews } from '../../../../api/news'; +import { parseDate } from '../../../../helpers/dates'; + +it('should render correctly if there are new features, and the user has not opted out', () => { + const wrapper = shallowRender(); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.find('.navbar-latest-notification')).toHaveLength(1); +}); + +it('should render correctly if there are new features, but the user has opted out', () => { + const wrapper = shallowRender({ notificationsOptOut: true }); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.find('.navbar-latest-notification')).toHaveLength(0); +}); + +it('should render correctly if there are no new unread features', () => { + const wrapper = shallowRender({ + notificationsLastReadDate: parseDate('2018-12-31T12:07:19+0000') + }); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.find('.navbar-latest-notification')).toHaveLength(0); +}); + +function shallowRender(props: Partial = {}) { + 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( + + ); +} diff --git a/server/sonar-web/src/main/js/app/components/notifications/__tests__/NotificationsSidebar-test.tsx b/server/sonar-web/src/main/js/app/components/notifications/__tests__/NotificationsSidebar-test.tsx new file mode 100644 index 00000000000..bad9979f0e1 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/notifications/__tests__/NotificationsSidebar-test.tsx @@ -0,0 +1,116 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import NotificationsSidebar, { + Props, + isUnread, + Notification, + Feature +} from '../NotificationsSidebar'; +import { parseDate } from '../../../../helpers/dates'; + +const news: Props['news'] = [ + { + notification: '10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration', + publicationDate: '2018-04-06', + features: [ + { + categories: [{ color: '#ff0000', name: 'Java' }, { color: '#00ff00', name: 'Rules' }], + description: '10 new Java rules' + }, + { + categories: [{ color: '#0000ff', name: 'BitBucket' }], + description: 'BitBucket branch decoration', + readMore: 'http://example.com' + } + ] + }, + { + notification: 'Some other notification', + publicationDate: '2018-04-05', + features: [ + { + categories: [{ color: '#0000ff', name: 'BitBucket' }], + description: 'BitBucket branch decoration', + readMore: 'http://example.com' + } + ] + } +]; + +describe('#NotificationSidebar', () => { + it('should render correctly if there are new features', () => { + const wrapper = shallowRender({ loading: true }); + expect(wrapper).toMatchSnapshot(); + wrapper.setProps({ loading: false }); + expect(wrapper).toMatchSnapshot(); + expect(wrapper.find('Notification')).toHaveLength(2); + }); + + it('should render correctly if there are no new unread features', () => { + const wrapper = shallowRender({ + notificationsLastReadDate: parseDate('2018-12-31') + }); + expect(wrapper.find('Notification')).toHaveLength(2); + expect(wrapper.find('Notification[unread=true]')).toHaveLength(0); + }); +}); + +describe('#isUnread', () => { + it('should be unread', () => { + expect(isUnread(0, '2018-12-14', undefined)).toBe(true); + expect(isUnread(1, '2018-12-14', parseDate('2018-12-12'))).toBe(true); + }); + + it('should be read', () => { + expect(isUnread(0, '2018-12-16', parseDate('2018-12-16'))).toBe(false); + expect(isUnread(1, '2018-12-15', undefined)).toBe(false); + }); +}); + +describe('#Notification', () => { + it('should render correctly', () => { + expect(shallow()).toMatchSnapshot(); + expect(shallow()).toMatchSnapshot(); + }); +}); + +describe('#Feature', () => { + it('should render correctly', () => { + expect(shallow()).toMatchSnapshot(); + expect(shallow()).toMatchSnapshot(); + }); +}); + +function shallowRender(props: Partial = {}) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NavLatestNotification-test.tsx.snap b/server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NavLatestNotification-test.tsx.snap new file mode 100644 index 00000000000..5e2bc920858 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NavLatestNotification-test.tsx.snap @@ -0,0 +1,82 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly if there are new features, and the user has not opted out 1`] = ` + +
      • +
        + + new + + + 10 Java rules, Github checks, Security Hotspots, BitBucket branch decoration + +
        +
      • +
      • + + + +
      • +
      • + + + +
      • +
        +`; + +exports[`should render correctly if there are new features, but the user has opted out 1`] = ` + +
      • + + + +
      • +
        +`; + +exports[`should render correctly if there are no new unread features 1`] = ` + +
      • + + + +
      • +
        +`; diff --git a/server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NotificationsSidebar-test.tsx.snap b/server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NotificationsSidebar-test.tsx.snap new file mode 100644 index 00000000000..a832b1e93c1 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/notifications/__tests__/__snapshots__/NotificationsSidebar-test.tsx.snap @@ -0,0 +1,261 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`#Feature should render correctly 1`] = ` +
        +
          +
        • + BitBucket +
        • +
        + + BitBucket branch decoration + + + learn_more + +
        +`; + +exports[`#Feature should render correctly 2`] = ` +
        +
          +
        • + Java +
        • +
        • + Rules +
        • +
        + + 10 new Java rules + +
        +`; + +exports[`#Notification should render correctly 1`] = ` +
        +

        + +

        + +
        +`; + +exports[`#Notification should render correctly 2`] = ` +
        +

        + +

        + +
        +`; + +exports[`#NotificationSidebar should render correctly if there are new features 1`] = ` + +
        +
        +

        + embed_docs.whats_new +

        + + + +
        +
        +
        + +
        +
        +
        +
        +`; + +exports[`#NotificationSidebar should render correctly if there are new features 2`] = ` + +
        +
        +

        + embed_docs.whats_new +

        + + + +
        +
        + + +
        + +
        +
        +`; diff --git a/server/sonar-web/src/main/js/app/components/notifications/notifications.css b/server/sonar-web/src/main/js/app/components/notifications/notifications.css new file mode 100644 index 00000000000..57220f85528 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/notifications/notifications.css @@ -0,0 +1,184 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +.navbar-latest-notification { + flex: 0 1 380px; + text-align: right; + overflow: hidden; +} + +.navbar-latest-notification-wrapper { + position: relative; + display: inline-block; + padding: var(--gridSize) 34px var(--gridSize) 50px; + height: 28px; + max-width: 100%; + box-sizing: border-box; + overflow: hidden; + vertical-align: middle; + font-size: var(--smallFontSize); + color: var(--sonarcloudBlack500); + background-color: black; + text-overflow: ellipsis; + white-space: nowrap; + border-radius: 3px; + cursor: pointer; +} + +.navbar-latest-notification-wrapper:hover { + color: var(--sonarcloudBlack300); +} + +.navbar-latest-notification-wrapper .badge { + position: absolute; + height: 18px; + margin-right: var(--gridSize); + left: calc(var(--gridSize) / 2); + top: 5px; + font-size: var(--verySmallFontSize); + text-transform: uppercase; + background-color: var(--lightBlue); + color: var(--darkBlue); +} + +.navbar-latest-notification-wrapper .label { + display: block; + max-width: 330px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.navbar-latest-notification .navbar-icon { + position: absolute; + right: 0; + top: 0; + height: 28px; + padding: 9px var(--gridSize) !important; + border-left: 2px solid #262626; +} + +.navbar-latest-notification .navbar-icon:hover path { + fill: var(--sonarcloudBlack300) !important; +} + +.notifications-sidebar { + position: fixed; + top: 0; + right: 0; + bottom: 0; + width: 400px; + display: flex; + flex-direction: column; + + background: var(--sonarcloudBlack200); + + z-index: 900; +} + +.notifications-sidebar-top { + position: relative; + padding: calc(2 * var(--gridSize)); + + border-bottom: 1px solid var(--sonarcloudBlack250); + + background-color: var(--sonarcloudBlack100); +} + +.notifications-sidebar-top h3 { + font-weight: normal; + font-size: var(--bigFontSize); +} + +.notifications-sidebar-top .close { + position: absolute; + top: 16px; + right: 16px; + + border: 0; + + color: var(--sonarcloudBlack500); +} + +.notifications-sidebar-content { + flex: 1 1; + overflow-y: scroll; +} + +.notifications-sidebar-footer { + padding-top: var(--gridSize); + border-top: 1px solid var(--sonarcloudBlack250); + flex: 0 0 40px; +} + +.notifications-sidebar-slice h4 { + padding: calc(2 * var(--gridSize)) calc(2 * var(--gridSize)) calc(var(--gridSize) / 2) + calc(2 * var(--gridSize)); + + background-color: var(--sonarcloudBlack200); + + font-weight: normal; + font-size: var(--smallFontSize); + text-align: right; + color: var(--sonarcloudBlack500); +} + +.notifications-sidebar-slice .feature:last-of-type { + border-bottom: 1px solid var(--sonarcloudBlack250); +} + +.notifications-sidebar-slice .feature { + padding: calc(2 * var(--gridSize)); + + background-color: var(--sonarcloudBlack100); + + border-top: 1px solid var(--sonarcloudBlack250); + + overflow: hidden; +} + +.notifications-sidebar-slice.unread .feature { + background-color: #e6f6ff; + + border-color: #cee4f2; +} + +.notifications-sidebar-slice .learn-more { + clear: both; + float: right; + margin-top: var(--gridSize); +} + +.notifications-sidebar-slice .categories { + margin-bottom: 8px; +} + +.notifications-sidebar-slice .categories li { + display: inline-block; + padding: 4px; + margin-right: 8px; + + border-radius: 3px; + + font-size: 8px; + text-transform: uppercase; + color: white; + letter-spacing: 1px; +} diff --git a/server/sonar-web/src/main/js/app/theme.js b/server/sonar-web/src/main/js/app/theme.js index 8f81591e034..8545f935c80 100644 --- a/server/sonar-web/src/main/js/app/theme.js +++ b/server/sonar-web/src/main/js/app/theme.js @@ -54,6 +54,8 @@ module.exports = { leakColorHover: '#f0e7c4', leakBorderColor: '#eae3c7', + globalNavBarBg: '#262626', + snippetFontColor: '#f0f0f0', // alerts 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 ba8ad007586..6922707daf6 100644 --- a/server/sonar-web/src/main/js/app/types.d.ts +++ b/server/sonar-web/src/main/js/app/types.d.ts @@ -209,14 +209,12 @@ declare namespace T { showOnboardingTutorial?: boolean; } - export type CurrentUserSettings = { [key in CurrentUserSettingNames]?: string }; - - export interface CurrentUserSettingData { + export interface CurrentUserSetting { key: CurrentUserSettingNames; value: string; } - type CurrentUserSettingNames = 'notificationsOptOut' | 'notificationsReadDate'; + type CurrentUserSettingNames = 'notifications.optOut' | 'notifications.readDate'; export interface CustomMeasure { createdAt?: string; @@ -424,6 +422,7 @@ declare namespace T { name: string; personalOrganization?: string; scmAccounts: string[]; + settings?: CurrentUserSetting[]; } export interface LongLivingBranch extends Branch { diff --git a/server/sonar-web/src/main/js/apps/overview/styles.css b/server/sonar-web/src/main/js/apps/overview/styles.css index 2ded3d250d6..864b0e1fe00 100644 --- a/server/sonar-web/src/main/js/apps/overview/styles.css +++ b/server/sonar-web/src/main/js/apps/overview/styles.css @@ -475,7 +475,7 @@ } .copy-paste-link .close { - color: black; + color: #000; border-bottom: 0; height: 100%; display: inline-block; diff --git a/server/sonar-web/src/main/js/store/rootReducer.ts b/server/sonar-web/src/main/js/store/rootReducer.ts index d6e72a6552b..567f1572d71 100644 --- a/server/sonar-web/src/main/js/store/rootReducer.ts +++ b/server/sonar-web/src/main/js/store/rootReducer.ts @@ -65,8 +65,8 @@ export function getLanguages(state: Store) { return fromLanguages.getLanguages(state.languages); } -export function getCurrentUserSettings(state: Store) { - return fromUsers.getCurrentUserSettings(state.users); +export function getCurrentUserSetting(state: Store, key: T.CurrentUserSettingNames) { + return fromUsers.getCurrentUserSetting(state.users, key); } export function getCurrentUser(state: Store) { diff --git a/server/sonar-web/src/main/js/store/users.ts b/server/sonar-web/src/main/js/store/users.ts index f86c4c28bff..6e70f6f0ef7 100644 --- a/server/sonar-web/src/main/js/store/users.ts +++ b/server/sonar-web/src/main/js/store/users.ts @@ -20,20 +20,19 @@ import { uniq } from 'lodash'; import { Dispatch, combineReducers } from 'redux'; import { ActionType } from './utils/actions'; -import * as api from '../api/users'; -import { listUserSettings, setUserSetting } from '../api/user-settings'; import { isLoggedIn } from '../helpers/users'; +import * as api from '../api/users'; const enum Actions { ReceiveCurrentUser = 'RECEIVE_CURRENT_USER', - ReceiveCurrentUserSettings = 'RECEIVE_CURRENT_USER_SETTINGS', + SetCurrentUserSetting = 'SET_CURRENT_USER_SETTING', SkipOnboardingAction = 'SKIP_ONBOARDING', SetHomePageAction = 'SET_HOMEPAGE' } type Action = | ActionType - | ActionType + | ActionType | ActionType | ActionType; @@ -41,32 +40,12 @@ export interface State { usersByLogin: { [login: string]: any }; userLogins: string[]; currentUser: T.CurrentUser; - currentUserSettings: T.CurrentUserSettings; } export function receiveCurrentUser(user: T.CurrentUser) { return { type: Actions.ReceiveCurrentUser, user }; } -function receiveCurrentUserSettings(userSettings: T.CurrentUserSettingData[]) { - return { type: Actions.ReceiveCurrentUserSettings, userSettings }; -} - -export function fetchCurrentUserSettings() { - return (dispatch: Dispatch) => { - listUserSettings().then( - ({ userSettings }) => dispatch(receiveCurrentUserSettings(userSettings)), - () => {} - ); - }; -} - -export function setCurrentUserSetting(setting: T.CurrentUserSettingData) { - return (dispatch: Dispatch) => { - setUserSetting(setting).then(() => dispatch(receiveCurrentUserSettings([setting])), () => {}); - }; -} - function skipOnboardingAction() { return { type: Actions.SkipOnboardingAction }; } @@ -82,6 +61,10 @@ function setHomePageAction(homepage: T.HomePage) { return { type: Actions.SetHomePageAction, homepage }; } +function setCurrentUserSettingAction(setting: T.CurrentUserSetting) { + return { type: Actions.SetCurrentUserSetting, setting }; +} + export function setHomePage(homepage: T.HomePage) { return (dispatch: Dispatch) => { api.setHomePage(homepage).then( @@ -93,6 +76,19 @@ export function setHomePage(homepage: T.HomePage) { }; } +export function setCurrentUserSetting(setting: T.CurrentUserSetting) { + return (dispatch: Dispatch, getState: () => { users: State }) => { + const oldSetting = getCurrentUserSetting(getState().users, setting.key); + dispatch(setCurrentUserSettingAction(setting)); + api.setUserSetting(setting).then( + () => {}, + () => { + dispatch(setCurrentUserSettingAction({ ...setting, value: oldSetting || '' })); + } + ); + }; +} + function usersByLogin(state: State['usersByLogin'] = {}, action: Action): State['usersByLogin'] { if (action.type === Actions.ReceiveCurrentUser && isLoggedIn(action.user)) { return { ...state, [action.user.login]: action.user }; @@ -122,31 +118,36 @@ function currentUser( if (action.type === Actions.SetHomePageAction && isLoggedIn(state)) { return { ...state, homepage: action.homepage } as T.LoggedInUser; } - return state; -} - -function currentUserSettings( - state: State['currentUserSettings'] = {}, - action: Action -): State['currentUserSettings'] { - if (action.type === Actions.ReceiveCurrentUserSettings) { - const newState = { ...state }; - action.userSettings.forEach((item: T.CurrentUserSettingData) => { - newState[item.key] = item.value; - }); - return newState; + if (action.type === Actions.SetCurrentUserSetting && isLoggedIn(state)) { + let settings: T.CurrentUserSetting[]; + if (state.settings) { + settings = [...state.settings]; + const index = settings.findIndex(setting => setting.key === action.setting.key); + if (index === -1) { + settings.push(action.setting); + } else { + settings[index] = action.setting; + } + } else { + settings = [action.setting]; + } + return { ...state, settings } as T.LoggedInUser; } return state; } -export default combineReducers({ usersByLogin, userLogins, currentUser, currentUserSettings }); +export default combineReducers({ usersByLogin, userLogins, currentUser }); export function getCurrentUser(state: State) { return state.currentUser; } -export function getCurrentUserSettings(state: State) { - return state.currentUserSettings; +export function getCurrentUserSetting(state: State, key: T.CurrentUserSettingNames) { + let setting; + if (isLoggedIn(state.currentUser) && state.currentUser.settings) { + setting = state.currentUser.settings.find(setting => setting.key === key); + } + return setting && setting.value; } export function getUserByLogin(state: State, login: string) { 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 5b5f129dc92..eae4d016b97 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -87,6 +87,7 @@ learn_more=Learn More library=Library line_number=Line Number links=Links +load_more=Load more load_verb=Load login=Login major=Major @@ -2694,6 +2695,7 @@ embed_docs.latest_blog=Latest blog embed_docs.news=Product News embed_docs.stay_connected=Stay Connected embed_docs.suggestion=Suggestions For This Page +embed_docs.whats_new=What's new on SonarCloud? #------------------------------------------------------------------------------ #