From: Wouter Admiraal <45544358+wouter-admiraal-sonarsource@users.noreply.github.com> Date: Tue, 17 Sep 2019 09:56:13 +0000 (+0200) Subject: SONAR-10030 Improve project notifications management X-Git-Tag: 8.0~123 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=708344c0051c6d0c0f93b644dc92892a13d7cb89;p=sonarqube.git SONAR-10030 Improve project notifications management --- diff --git a/server/sonar-web/src/main/js/app/styles/init/misc.css b/server/sonar-web/src/main/js/app/styles/init/misc.css index 7a424f4ee38..31650acaf14 100644 --- a/server/sonar-web/src/main/js/app/styles/init/misc.css +++ b/server/sonar-web/src/main/js/app/styles/init/misc.css @@ -49,6 +49,10 @@ th.hide-overflow { margin-top: -1px; } +.nudged-down { + margin-top: 1px; +} + .spacer { margin: 8px !important; } 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 8bfd5c55adc..47e5d1f6100 100644 --- a/server/sonar-web/src/main/js/app/types.d.ts +++ b/server/sonar-web/src/main/js/app/types.d.ts @@ -507,6 +507,11 @@ declare namespace T { type: string; } + export interface NotificationProject { + project: string; + projectName: string; + } + export interface OrganizationActions { admin?: boolean; delete?: boolean; diff --git a/server/sonar-web/src/main/js/apps/account/account.css b/server/sonar-web/src/main/js/apps/account/account.css index 02d8c556796..eab942f9e5e 100644 --- a/server/sonar-web/src/main/js/apps/account/account.css +++ b/server/sonar-web/src/main/js/apps/account/account.css @@ -231,3 +231,21 @@ margin-top: 30px; text-align: center; } + +.notifications-table { + margin-top: calc(-2 * var(--gridSize)); +} + +.notifications-add-project-no-search-results { + padding: var(--gridSize); +} + +.notifications-add-project-search-results li { + padding: var(--gridSize); + cursor: pointer; +} + +.notifications-add-project-search-results li:hover, +.notifications-add-project-search-results li.active { + background-color: var(--barBackgroundColor); +} diff --git a/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx b/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx index f903ee91942..a2d26135ada 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx +++ b/server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx @@ -17,36 +17,31 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { groupBy, partition, uniq, uniqBy, uniqWith } from 'lodash'; +import { partition, uniqWith } from 'lodash'; import * as React from 'react'; import Helmet from 'react-helmet'; import { Alert } from 'sonar-ui-common/components/ui/Alert'; import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner'; import { translate } from 'sonar-ui-common/helpers/l10n'; import * as api from '../../../api/notifications'; -import { withAppState } from '../../../components/hoc/withAppState'; import GlobalNotifications from './GlobalNotifications'; import Projects from './Projects'; -import { NotificationProject } from './types'; - -export interface Props { - appState: Pick; - fetchOrganizations: (organizations: string[]) => void; -} interface State { channels: string[]; globalTypes: string[]; + initialProjectNotificationsCount: number; loading: boolean; notifications: T.Notification[]; perProjectTypes: string[]; } -export class Notifications extends React.PureComponent { +export default class Notifications extends React.PureComponent<{}, State> { mounted = false; state: State = { channels: [], globalTypes: [], + initialProjectNotificationsCount: 0, loading: true, notifications: [], perProjectTypes: [] @@ -65,16 +60,13 @@ export class Notifications extends React.PureComponent { api.getNotifications().then( response => { if (this.mounted) { - if (this.props.appState.organizationsEnabled) { - const organizations = uniq(response.notifications - .filter(n => n.organization) - .map(n => n.organization) as string[]); - this.props.fetchOrganizations(organizations); - } + const { notifications } = response; + const { projectNotifications } = this.getNotificationUpdates(notifications); this.setState({ channels: response.channels, globalTypes: response.globalTypes, + initialProjectNotificationsCount: projectNotifications.length, loading: false, notifications: response.notifications, perProjectTypes: response.perProjectTypes @@ -90,9 +82,10 @@ export class Notifications extends React.PureComponent { }; addNotificationToState = (added: T.Notification) => { - this.setState(state => ({ - notifications: uniqWith([...state.notifications, added], areNotificationsEqual) - })); + this.setState(state => { + const notifications = uniqWith([...state.notifications, added], areNotificationsEqual); + return { notifications }; + }); }; removeNotificationFromState = (removed: T.Notification) => { @@ -125,20 +118,20 @@ export class Notifications extends React.PureComponent { }); }; + getNotificationUpdates = (notifications: T.Notification[]) => { + const [globalNotifications, projectNotifications] = partition(notifications, n => !n.project); + + return { + globalNotifications, + projectNotifications + }; + }; + render() { - const [globalNotifications, projectNotifications] = partition( - this.state.notifications, - n => !n.project + const { initialProjectNotificationsCount, notifications } = this.state; + const { globalNotifications, projectNotifications } = this.getNotificationUpdates( + notifications ); - const projects = uniqBy( - projectNotifications.map(n => ({ - key: n.project, - name: n.projectName, - organization: n.organization - })) as NotificationProject[], - project => project.key - ); - const notificationsByProject = groupBy(projectNotifications, n => n.project); return (
@@ -157,8 +150,8 @@ export class Notifications extends React.PureComponent { @@ -170,8 +163,6 @@ export class Notifications extends React.PureComponent { } } -export default withAppState(Notifications); - function areNotificationsEqual(a: T.Notification, b: T.Notification) { return a.channel === b.channel && a.type === b.type && a.project === b.project; } diff --git a/server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.tsx b/server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.tsx deleted file mode 100644 index 1d50e89dde2..00000000000 --- a/server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2019 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 { connect } from 'react-redux'; -import { fetchOrganizations } from '../../../store/rootActions'; -import Notifications, { Props } from './Notifications'; - -const mapDispatchToProps = { fetchOrganizations } as Pick; - -export default connect( - null, - mapDispatchToProps -)(Notifications); diff --git a/server/sonar-web/src/main/js/apps/account/notifications/ProjectModal.tsx b/server/sonar-web/src/main/js/apps/account/notifications/ProjectModal.tsx new file mode 100644 index 00000000000..01b1d06f023 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/notifications/ProjectModal.tsx @@ -0,0 +1,231 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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 classNames from 'classnames'; +import { debounce } from 'lodash'; +import * as React from 'react'; +import { ResetButtonLink, SubmitButton } from 'sonar-ui-common/components/controls/buttons'; +import { DropdownOverlay } from 'sonar-ui-common/components/controls/Dropdown'; +import SearchBox from 'sonar-ui-common/components/controls/SearchBox'; +import SimpleModal from 'sonar-ui-common/components/controls/SimpleModal'; +import { translate } from 'sonar-ui-common/helpers/l10n'; +import { getSuggestions } from '../../../api/components'; + +interface Props { + addedProjects: T.NotificationProject[]; + closeModal: VoidFunction; + onSubmit: (project: T.NotificationProject) => void; +} + +interface State { + highlighted?: T.NotificationProject; + loading?: boolean; + query?: string; + open?: boolean; + selectedProject?: T.NotificationProject; + suggestions?: T.NotificationProject[]; +} + +export default class ProjectModal extends React.PureComponent { + mounted = false; + state: State; + + constructor(props: Props) { + super(props); + this.state = {}; + this.handleSearch = debounce(this.handleSearch, 250); + } + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + handleKeyDown = (event: React.KeyboardEvent) => { + switch (event.keyCode) { + case 13: + event.preventDefault(); + this.handleSelectHighlighted(); + break; + case 38: + event.preventDefault(); + this.handleHighlightPrevious(); + break; + case 40: + event.preventDefault(); + this.handleHighlightNext(); + break; + } + }; + + getCurrentIndex = () => { + const { highlighted, suggestions } = this.state; + return highlighted && suggestions + ? suggestions.findIndex(suggestion => suggestion.project === highlighted.project) + : -1; + }; + + highlightIndex = (index: number) => { + const { suggestions } = this.state; + if (suggestions && suggestions.length > 0) { + if (index < 0) { + index = suggestions.length - 1; + } else if (index >= suggestions.length) { + index = 0; + } + this.setState({ + highlighted: suggestions[index] + }); + } + }; + + handleHighlightPrevious = () => { + this.highlightIndex(this.getCurrentIndex() - 1); + }; + + handleHighlightNext = () => { + this.highlightIndex(this.getCurrentIndex() + 1); + }; + + handleSelectHighlighted = () => { + const { highlighted, selectedProject } = this.state; + if (highlighted !== undefined) { + if (selectedProject !== undefined && highlighted.project === selectedProject.project) { + this.handleSubmit(); + } else { + this.handleSelect(highlighted); + } + } + }; + + handleSearch = (query: string) => { + const { addedProjects } = this.props; + + if (query.length < 2) { + this.setState({ open: false, query }); + return Promise.resolve([]); + } + + this.setState({ loading: true, query }); + return getSuggestions(query).then( + r => { + if (this.mounted) { + let suggestions = undefined; + const projects = r.results.find(domain => domain.q === 'TRK'); + if (projects && projects.items.length > 0) { + suggestions = projects.items + .filter(item => !addedProjects.find(p => p.project === item.key)) + .map(item => ({ + project: item.key, + projectName: item.name + })); + } + this.setState({ loading: false, open: true, suggestions }); + } + }, + () => { + if (this.mounted) { + this.setState({ loading: false, open: false }); + } + } + ); + }; + + handleSelect = (selectedProject: T.NotificationProject) => { + this.setState({ + open: false, + query: selectedProject.projectName, + selectedProject + }); + }; + + handleSubmit = () => { + const { selectedProject } = this.state; + if (selectedProject) { + this.props.onSubmit(selectedProject); + } + }; + + render() { + const { closeModal } = this.props; + const { highlighted, loading, query, open, selectedProject, suggestions } = this.state; + const header = translate('my_account.set_notifications_for.title'); + return ( + + {({ onCloseClick, onFormSubmit }) => ( +
+
+

{header}

+
+
+
+ + + + {loading && } + + {!loading && open && ( +
+ + {suggestions && suggestions.length > 0 ? ( +
    + {suggestions.map(suggestion => ( +
  • this.handleSelect(suggestion)}> + {suggestion.projectName} +
  • + ))} +
+ ) : ( +
+ {translate('no_results')} +
+ )} +
+
+ )} +
+
+
+
+ + {translate('add_verb')} + + {translate('cancel')} +
+
+ + )} +
+ ); + } +} diff --git a/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.tsx b/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.tsx index 16d6b5e9ea8..d2ab10811fd 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.tsx +++ b/server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.tsx @@ -18,60 +18,51 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { Link } from 'react-router'; +import BoxedGroupAccordion from 'sonar-ui-common/components/controls/BoxedGroupAccordion'; import { translate } from 'sonar-ui-common/helpers/l10n'; -import Organization from '../../../components/shared/Organization'; -import { getProjectUrl } from '../../../helpers/urls'; import NotificationsList from './NotificationsList'; -import { NotificationProject } from './types'; interface Props { addNotification: (n: T.Notification) => void; channels: string[]; + collapsed: boolean; notifications: T.Notification[]; - project: NotificationProject; + project: T.NotificationProject; removeNotification: (n: T.Notification) => void; types: string[]; } -export default class ProjectNotifications extends React.PureComponent { - getCheckboxId = (type: string, channel: string) => { - return `project-notification-${this.props.project.key}-${type}-${channel}`; +export default function ProjectNotifications(props: Props) { + const { collapsed, project, channels } = props; + const [isCollapsed, setCollapsed] = React.useState(collapsed); + + const getCheckboxId = (type: string, channel: string) => { + return `project-notification-${props.project.project}-${type}-${channel}`; }; - handleAddNotification = ({ channel, type }: { channel: string; type: string }) => { - this.props.addNotification({ - channel, - type, - project: this.props.project.key, - projectName: this.props.project.name, - organization: this.props.project.organization - }); + const handleAddNotification = ({ channel, type }: { channel: string; type: string }) => { + props.addNotification({ ...props.project, channel, type }); }; - handleRemoveNotification = ({ channel, type }: { channel: string; type: string }) => { - this.props.removeNotification({ + const handleRemoveNotification = ({ channel, type }: { channel: string; type: string }) => { + props.removeNotification({ + ...props.project, channel, - type, - project: this.props.project.key + type }); }; - render() { - const { project, channels } = this.props; + const toggleExpanded = () => setCollapsed(!isCollapsed); - return ( - + return ( + {project.projectName}}> +
- + +
- - - -

- {project.name} -

-
{channels.map(channel => (

{translate('notification.channel', channel)}

@@ -79,16 +70,17 @@ export default class ProjectNotifications extends React.PureComponent { ))}
- ); - } + + ); } diff --git a/server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx b/server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx index 663ad924912..57eebfa2a89 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx +++ b/server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx @@ -17,130 +17,153 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { differenceWith } from 'lodash'; +import { groupBy, sortBy, uniqBy } from 'lodash'; import * as React from 'react'; -import { AsyncSelect } from 'sonar-ui-common/components/controls/Select'; +import { Button } from 'sonar-ui-common/components/controls/buttons'; +import SearchBox from 'sonar-ui-common/components/controls/SearchBox'; import { translate } from 'sonar-ui-common/helpers/l10n'; -import { getSuggestions } from '../../../api/components'; -import Organization from '../../../components/shared/Organization'; +import ProjectModal from './ProjectModal'; import ProjectNotifications from './ProjectNotifications'; -import { NotificationProject } from './types'; export interface Props { addNotification: (n: T.Notification) => void; channels: string[]; - notificationsByProject: T.Dict; - projects: NotificationProject[]; + initialProjectNotificationsCount: number; + notifications: T.Notification[]; removeNotification: (n: T.Notification) => void; types: string[]; } +const THRESHOLD_COLLAPSED = 3; + interface State { - addedProjects: NotificationProject[]; + addedProjects: T.NotificationProject[]; + search: string; + showModal: boolean; +} + +function isNotificationProject(project: { + project?: string; + projectName?: string; +}): project is T.NotificationProject { + return project.project !== undefined && project.projectName !== undefined; } export default class Projects extends React.PureComponent { - state: State = { addedProjects: [] }; - - componentWillReceiveProps(nextProps: Props) { - // remove all projects from `this.state.addedProjects` - // that already exist in `nextProps.projects` - this.setState(state => ({ - addedProjects: differenceWith( - state.addedProjects, - Object.keys(nextProps.projects), - (stateProject, propsProjectKey) => stateProject.key !== propsProjectKey - ) - })); - } + state: State = { + addedProjects: [], + search: '', + showModal: false + }; + + filterSearch = (project: T.NotificationProject, search: string) => { + return project.projectName && project.projectName.toLowerCase().includes(search); + }; - loadOptions = (query: string) => { - if (query.length < 2) { - return Promise.resolve({ options: [] }); + handleAddProject = (project: T.NotificationProject) => { + this.setState(state => { + return { + addedProjects: [...state.addedProjects, project] + }; + }); + }; + + handleSearch = (search = '') => { + this.setState({ search: search.toLowerCase() }); + }; + + handleSubmit = (selectedProject: T.NotificationProject) => { + if (selectedProject) { + this.handleAddProject(selectedProject); } - return getSuggestions(query) - .then(r => { - const projects = r.results.find(domain => domain.q === 'TRK'); - return projects ? projects.items : []; - }) - .then(projects => { - return projects - .filter( - project => - !this.props.projects.find(p => p.key === project.key) && - !this.state.addedProjects.find(p => p.key === project.key) - ) - .map(project => ({ - value: project.key, - label: project.name, - organization: project.organization - })); - }) - .then(options => { - return { options }; - }); + this.closeModal(); }; - handleAddProject = (selected: { label: string; organization: string; value: string }) => { - const project = { - key: selected.value, - name: selected.label, - organization: selected.organization - }; - this.setState(state => ({ - addedProjects: [...state.addedProjects, project] - })); + closeModal = () => { + this.setState({ showModal: false }); }; - renderOption = (option: { label: string; organization: string; value: string }) => { - return ( - - - {option.label} - - ); + openModal = () => { + this.setState({ showModal: true }); + }; + + removeNotification = (removed: T.Notification, allProjects: T.NotificationProject[]) => { + const projectToRemove = allProjects.find(p => p.project === removed.project); + if (projectToRemove) { + this.handleAddProject(projectToRemove); + } + + this.props.removeNotification(removed); }; render() { - const allProjects = [...this.props.projects, ...this.state.addedProjects]; + const { initialProjectNotificationsCount, notifications } = this.props; + const { addedProjects, search } = this.state; + + const projects = uniqBy(notifications, project => project.project).filter( + isNotificationProject + ) as T.NotificationProject[]; + const notificationsByProject = groupBy(notifications, n => n.project); + const allProjects = uniqBy([...addedProjects, ...projects], project => project.project); + const filteredProjects = sortBy(allProjects, 'projectName').filter(p => + this.filterSearch(p, search) + ); + const shouldBeCollapsed = initialProjectNotificationsCount > THRESHOLD_COLLAPSED; return ( -
-

{translate('my_profile.per_project_notifications.title')}

+
+
+
+ +
+ +

{translate('my_profile.per_project_notifications.title')}

+
+ + {this.state.showModal && ( + + )}
{allProjects.length === 0 && (
{translate('my_account.no_project_notifications')}
)} - {allProjects.map(project => ( - - ))} - -
- - {translate('my_account.set_notifications_for')}: - - -
+ {allProjects.length > 0 && ( +
+ +
+ )} + + {filteredProjects.map(project => { + const collapsed = addedProjects.find(p => p.project === project.project) + ? false + : shouldBeCollapsed; + return ( + this.removeNotification(n, allProjects)} + types={this.props.types} + /> + ); + })}
); diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx index 0aebefa4d3d..82d02d028f6 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx @@ -21,7 +21,7 @@ import { shallow } from 'enzyme'; import * as React from 'react'; import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; -import { Notifications } from '../Notifications'; +import Notifications from '../Notifications'; jest.mock('../../../../api/notifications', () => ({ addNotification: jest.fn(() => Promise.resolve()), @@ -30,14 +30,26 @@ jest.mock('../../../../api/notifications', () => ({ channels: ['channel1', 'channel2'], globalTypes: ['type-global', 'type-common'], notifications: [ - { channel: 'channel1', type: 'type-global' }, - { channel: 'channel1', type: 'type-common' }, { - channel: 'channel2', - type: 'type-common', + channel: 'channel1', + type: 'type-global', project: 'foo', projectName: 'Foo', organization: 'org' + }, + { + channel: 'channel1', + type: 'type-common', + project: 'bar', + projectName: 'Bar', + organization: 'org' + }, + { + channel: 'channel2', + type: 'type-common', + project: 'qux', + projectName: 'Qux', + organization: 'org' } ], perProjectTypes: ['type-common'] @@ -74,12 +86,16 @@ it('should add global notification', async () => { }); it('should remove project notification', async () => { - const notification = { channel: 'channel2', project: 'foo', type: 'type-common' }; + const notification = { + channel: 'channel2', + type: 'type-common', + project: 'qux' + }; const wrapper = await shallowRender(); expect(wrapper.state('notifications')).toContainEqual({ ...notification, organization: 'org', - projectName: 'Foo' + projectName: 'Qux' }); wrapper.find('Projects').prop('removeNotification')(notification); // `state` must be immediately updated @@ -87,28 +103,8 @@ it('should remove project notification', async () => { expect(removeNotification).toBeCalledWith(notification); }); -it('should NOT fetch organizations', async () => { - const fetchOrganizations = jest.fn(); - await shallowRender({ fetchOrganizations }); - expect(getNotifications).toBeCalled(); - expect(fetchOrganizations).not.toBeCalled(); -}); - -it('should fetch organizations', async () => { - const fetchOrganizations = jest.fn(); - await shallowRender({ appState: { organizationsEnabled: true }, fetchOrganizations }); - expect(getNotifications).toBeCalled(); - expect(fetchOrganizations).toBeCalledWith(['org']); -}); - -async function shallowRender(props?: Partial) { - const wrapper = shallow( - - ); +async function shallowRender(props: Partial = {}) { + const wrapper = shallow(); await waitAndUpdate(wrapper); return wrapper; } diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectModal-test.tsx b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectModal-test.tsx new file mode 100644 index 00000000000..26931f215e7 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectModal-test.tsx @@ -0,0 +1,133 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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 { shallow } from 'enzyme'; +import * as React from 'react'; +import { change, elementKeydown, submit, waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import { getSuggestions } from '../../../../api/components'; +import ProjectModal from '../ProjectModal'; + +jest.mock('../../../../api/components', () => ({ + getSuggestions: jest.fn().mockResolvedValue({ + organizations: [{ key: 'org', name: 'Org' }], + results: [ + { + q: 'TRK', + items: [{ key: 'foo', name: 'Foo' }, { key: 'bar', name: 'Bar' }] + }, + // this file should be ignored + { q: 'FIL', items: [{ key: 'foo:file.js', name: 'file.js' }] } + ] + }) +})); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +it('should render correctly', () => { + expect(shallowRender().dive()).toMatchSnapshot(); +}); + +it('should trigger a search correctly', async () => { + const wrapper = shallowRender(); + change(wrapper.dive().find('SearchBox'), 'foo'); + expect(getSuggestions).toBeCalledWith('foo'); + await waitAndUpdate(wrapper); + expect(wrapper.dive().find('.notifications-add-project-search-results')).toMatchSnapshot(); +}); + +it('should return an empty list when I search non-existent elements', async () => { + (getSuggestions as jest.Mock).mockResolvedValue({ + results: [ + { q: 'FIL', items: [], more: 0 }, + { q: 'TRK', items: [], more: 0 }, + { q: 'UTS', items: [], more: 0 } + ], + organizations: [], + projects: [] + }); + + const wrapper = shallowRender(); + change(wrapper.dive().find('SearchBox'), 'Supercalifragilisticexpialidocious'); + await waitAndUpdate(wrapper); + expect( + wrapper + .dive() + .find('.notifications-add-project-no-search-results') + .exists() + ).toBe(true); +}); + +it('should handle submit', async () => { + const selectedProject = { + projectName: 'Foo', + project: 'foo' + }; + const onSubmit = jest.fn(); + const wrapper = shallowRender({ onSubmit }); + wrapper.setState({ + selectedProject + }); + submit(wrapper.dive().find('form')); + await waitAndUpdate(wrapper); + expect(onSubmit).toHaveBeenCalledWith(selectedProject); +}); + +it('should handle up and down keys', async () => { + const foo = { project: 'foo', projectName: 'Foo' }; + const bar = { project: 'bar', projectName: 'Bar' }; + const onSubmit = jest.fn(); + const wrapper = shallowRender({ onSubmit }); + wrapper.setState({ + open: true, + suggestions: [foo, bar] + }); + await waitAndUpdate(wrapper); + + // Down. + elementKeydown(wrapper.dive().find('SearchBox'), 40); + expect(wrapper.state('highlighted')).toEqual(foo); + elementKeydown(wrapper.dive().find('SearchBox'), 40); + expect(wrapper.state('highlighted')).toEqual(bar); + elementKeydown(wrapper.dive().find('SearchBox'), 40); + expect(wrapper.state('highlighted')).toEqual(foo); + + // Up. + elementKeydown(wrapper.dive().find('SearchBox'), 38); + expect(wrapper.state('highlighted')).toEqual(bar); + elementKeydown(wrapper.dive().find('SearchBox'), 38); + expect(wrapper.state('highlighted')).toEqual(foo); + elementKeydown(wrapper.dive().find('SearchBox'), 38); + expect(wrapper.state('highlighted')).toEqual(bar); + + // Enter. + elementKeydown(wrapper.dive().find('SearchBox'), 13); + expect(wrapper.state('selectedProject')).toEqual(bar); + expect(onSubmit).not.toHaveBeenCalled(); + elementKeydown(wrapper.dive().find('SearchBox'), 13); + expect(onSubmit).toHaveBeenCalledWith(bar); +}); + +function shallowRender(props = {}) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.tsx b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.tsx index 936160c18f6..e2a8ebca561 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.tsx +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.tsx @@ -21,48 +21,20 @@ import { shallow } from 'enzyme'; import * as React from 'react'; import ProjectNotifications from '../ProjectNotifications'; -const channels = ['channel1', 'channel2']; -const types = ['type1', 'type2']; -const notifications = [ - { channel: 'channel1', type: 'type1' }, - { channel: 'channel1', type: 'type2' }, - { channel: 'channel2', type: 'type2' } -]; - -it('should match snapshot', () => { - expect( - shallow( - - ) - ).toMatchSnapshot(); +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); + expect(shallowRender({ collapsed: true })).toMatchSnapshot(); }); it('should call `addNotification` and `removeNotification`', () => { const addNotification = jest.fn(); const removeNotification = jest.fn(); - const wrapper = shallow( - - ); + const wrapper = shallowRender({ addNotification, removeNotification }); const notificationsList = wrapper.find('NotificationsList'); notificationsList.prop('onAdd')({ channel: 'channel2', type: 'type1' }); expect(addNotification).toHaveBeenCalledWith({ channel: 'channel2', - organization: 'org', project: 'foo', projectName: 'Foo', type: 'type1' @@ -73,7 +45,28 @@ it('should call `addNotification` and `removeNotification`', () => { notificationsList.prop('onRemove')({ channel: 'channel1', type: 'type1' }); expect(removeNotification).toHaveBeenCalledWith({ channel: 'channel1', - type: 'type1', - project: 'foo' + project: 'foo', + projectName: 'Foo', + type: 'type1' }); }); + +function shallowRender(props = {}) { + const project = { project: 'foo', projectName: 'Foo' }; + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx index e0234bd92d9..e397e80f845 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx @@ -19,113 +19,99 @@ */ import { shallow } from 'enzyme'; import * as React from 'react'; -import Projects, { Props } from '../Projects'; +import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils'; +import Projects from '../Projects'; jest.mock('../../../../api/components', () => ({ - getSuggestions: jest.fn(() => - Promise.resolve({ - results: [ - { - q: 'TRK', - items: [ - { key: 'foo', name: 'Foo', organization: 'org' }, - { key: 'bar', name: 'Bar', organization: 'org' } - ] - }, - // this file should be ignored - { q: 'FIL', items: [{ key: 'foo:file.js', name: 'file.js', organization: 'org' }] } - ] - }) - ) + getSuggestions: jest.fn().mockResolvedValue({ + results: [ + { + q: 'TRK', + items: [{ key: 'foo', name: 'Foo' }, { key: 'bar', name: 'Bar' }] + }, + // this file should be ignored + { q: 'FIL', items: [{ key: 'foo:file.js', name: 'file.js' }] } + ] + }) })); const channels = ['channel1', 'channel2']; const types = ['type1', 'type2']; -const projectFoo = { key: 'foo', name: 'Foo', organization: 'org' }; -const projectBar = { key: 'bar', name: 'Bar', organization: 'org' }; -const projects = [projectFoo, projectBar]; - -const newProject = { key: 'qux', name: 'Qux', organization: 'org' }; +const projectFoo = { project: 'foo', projectName: 'Foo' }; +const projectBar = { project: 'bar', projectName: 'Bar' }; +const extraProps = { + channel: 'channel1', + type: 'type2' +}; +const projects = [{ ...projectFoo, ...extraProps }, { ...projectBar, ...extraProps }]; it('should render projects', () => { const wrapper = shallowRender({ - notificationsByProject: { - foo: [ - { - channel: 'channel1', - organization: 'org', - project: 'foo', - projectName: 'Foo', - type: 'type1' - }, - { - channel: 'channel1', - organization: 'org', - project: 'foo', - projectName: 'Foo', - type: 'type2' - } - ] - }, - projects + notifications: projects }); - expect(wrapper).toMatchSnapshot(); - - // let's add a new project - wrapper.setState({ addedProjects: [newProject] }); - expect(wrapper).toMatchSnapshot(); - // let's say we saved it, so it's passed back in `props` - wrapper.setProps({ projects: [...projects, newProject] }); expect(wrapper).toMatchSnapshot(); expect(wrapper.state()).toMatchSnapshot(); }); -it('should search projects', () => { - const wrapper = shallowRender({ projects: [projectBar] }); - const loadOptions = wrapper.find('AsyncSelect').prop('loadOptions'); - expect(loadOptions('')).resolves.toEqual({ options: [] }); - // should not contain `projectBar` - expect(loadOptions('more than two symbols')).resolves.toEqual({ - options: [{ label: 'Foo', organization: 'org', value: 'foo' }] - }); -}); - -it('should add project', () => { +it('should handle project addition', () => { const wrapper = shallowRender(); - expect(wrapper.state('addedProjects')).toEqual([]); - wrapper.find('AsyncSelect').prop('onChange')({ - label: 'Qwe', - organization: 'org', - value: 'qwe' - }); + const { handleAddProject } = wrapper.instance(); + + handleAddProject(projectFoo); + expect(wrapper.state('addedProjects')).toEqual([ - { key: 'qwe', name: 'Qwe', organization: 'org' } + { + project: 'foo', + projectName: 'Foo' + } ]); }); -it('should render option', () => { +it('should handle search', () => { const wrapper = shallowRender(); - const optionRenderer = wrapper.find('AsyncSelect').prop('optionRenderer'); - expect( - shallow( - optionRenderer({ - label: 'Qwe', - organization: 'org', - value: 'qwe' - }) - ) - ).toMatchSnapshot(); + const { handleAddProject, handleSearch } = wrapper.instance(); + + handleAddProject(projectFoo); + handleAddProject(projectBar); + + handleSearch('Bar'); + expect(wrapper.state('search')).toBe('bar'); + expect(wrapper.find('ProjectNotifications')).toHaveLength(1); +}); + +it('should handle submit from modal', async () => { + const wrapper = shallowRender(); + wrapper.instance().handleAddProject = jest.fn(); + const { handleAddProject, handleSubmit } = wrapper.instance(); + + handleSubmit(projectFoo); + await waitAndUpdate(wrapper); + + expect(handleAddProject).toHaveBeenCalledWith(projectFoo); +}); + +it('should toggle modal', () => { + const wrapper = shallowRender(); + const { closeModal, openModal } = wrapper.instance(); + + expect(wrapper.state('showModal')).toBe(false); + + openModal(); + expect(wrapper.state('showModal')).toBe(true); + + closeModal(); + expect(wrapper.state('showModal')).toBe(false); }); -function shallowRender(props?: Partial) { - return shallow( +function shallowRender(props?: Partial) { + return shallow( +
+
+

+ my_account.set_notifications_for.title +

+
+
+
+ + +
+
+
+
+ + add_verb + + + cancel + +
+
+
+ +`; + +exports[`should trigger a search correctly 1`] = ` +
    +
  • + Foo +
  • +
  • + Bar +
  • +
+`; diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.tsx.snap index 11b25e6dd9d..07ae17de22b 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.tsx.snap @@ -1,91 +1,167 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should match snapshot 1`] = ` - + Foo + + } > - - - + + + - - - - - -
- +
+ - - -

+ notification.channel.channel1 +

+
- - Foo - - - -

- notification.channel.channel1 -

-
-

- notification.channel.channel2 -

-
+

+ notification.channel.channel2 +

+ + + + + + +`; + +exports[`should render correctly 2`] = ` + + Foo + + } +> + + + + + + + + +
+ +

+ notification.channel.channel1 +

+
+

+ notification.channel.channel2 +

+
+
`; diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap index 72fd77b67c3..529f67f2132 100644 --- a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap @@ -1,128 +1,41 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should render option 1`] = ` - - - - Qwe - - -`; - exports[`should render projects 1`] = `
-

- my_profile.per_project_notifications.title -

- -
- - my_account.set_notifications_for - : - - + + my_profile.per_project_notifications.add + +
+

+ my_profile.per_project_notifications.title +

-
-`; - -exports[`should render projects 2`] = ` -
-

- my_profile.per_project_notifications.title -

+
+ +
- -
- - my_account.set_notifications_for - : - - -
-
-
-`; - -exports[`should render projects 3`] = ` -
-

- my_profile.per_project_notifications.title -

-
- - - -
- - my_account.set_notifications_for - : - - -
`; -exports[`should render projects 4`] = ` +exports[`should render projects 2`] = ` Object { "addedProjects": Array [], + "search": "", + "showModal": false, } `; diff --git a/server/sonar-web/src/main/js/apps/account/notifications/types.ts b/server/sonar-web/src/main/js/apps/account/notifications/types.ts deleted file mode 100644 index 26d312e9360..00000000000 --- a/server/sonar-web/src/main/js/apps/account/notifications/types.ts +++ /dev/null @@ -1,24 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2019 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. - */ -export interface NotificationProject { - key: string; - name: string; - organization: string; -} diff --git a/server/sonar-web/src/main/js/apps/account/routes.ts b/server/sonar-web/src/main/js/apps/account/routes.ts index 9ecf83909af..a6bcf8ed638 100644 --- a/server/sonar-web/src/main/js/apps/account/routes.ts +++ b/server/sonar-web/src/main/js/apps/account/routes.ts @@ -36,7 +36,7 @@ const routes = [ }, { path: 'notifications', - component: lazyLoad(() => import('./notifications/NotificationsContainer')) + component: lazyLoad(() => import('./notifications/Notifications')) }, { path: 'organizations', 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 9bb347d45f2..bd95abe8e39 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -1539,6 +1539,7 @@ my_profile.overall_notifications.title=Overall notifications my_profile.sonarcloud_feature_notifications.title=SonarCloud new feature notifications my_profile.sonarcloud_feature_notifications.description=Display a notification in the header when new features are deployed my_profile.per_project_notifications.title=Notifications per project +my_profile.per_project_notifications.add=Add a project my_profile.warning_message=This is a definitive action. No account recovery will be possible. my_account.page=My Account @@ -1558,7 +1559,8 @@ my_account.organizations.description=Those organizations are the ones you are me my_account.organizations.no_results=You are not a member of any organizations yet. my_account.create_organization=Create Organization my_account.search_project=Search Project -my_account.set_notifications_for=Set notifications for +my_account.set_notifications_for=Search a project by name +my_account.set_notifications_for.title=Add a project my_account.create_new_portfolio_application=Create new portfolio / application my_account.create_new.TRK=Create new project my_account.create_new.VW=Create new portfolio