aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorWouter Admiraal <45544358+wouter-admiraal-sonarsource@users.noreply.github.com>2019-09-17 11:56:13 +0200
committerSonarTech <sonartech@sonarsource.com>2019-09-18 09:51:48 +0200
commit708344c0051c6d0c0f93b644dc92892a13d7cb89 (patch)
tree6ab3636a1cb0d9e390785ef99b37badedadb6996
parent4f0bd4cb6dcd98f45f140c45cf376686898d4dc3 (diff)
downloadsonarqube-708344c0051c6d0c0f93b644dc92892a13d7cb89.tar.gz
sonarqube-708344c0051c6d0c0f93b644dc92892a13d7cb89.zip
SONAR-10030 Improve project notifications management
-rw-r--r--server/sonar-web/src/main/js/app/styles/init/misc.css4
-rw-r--r--server/sonar-web/src/main/js/app/types.d.ts5
-rw-r--r--server/sonar-web/src/main/js/apps/account/account.css18
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx59
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.tsx29
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/ProjectModal.tsx231
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.tsx72
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx205
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx54
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectModal-test.tsx133
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.tsx61
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx146
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap49
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectModal-test.tsx.snap74
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.tsx.snap246
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap329
-rw-r--r--server/sonar-web/src/main/js/apps/account/notifications/types.ts24
-rw-r--r--server/sonar-web/src/main/js/apps/account/routes.ts2
-rw-r--r--sonar-core/src/main/resources/org/sonar/l10n/core.properties4
19 files changed, 979 insertions, 766 deletions
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<T.AppState, 'organizationsEnabled'>;
- 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<Props, State> {
+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<Props, State> {
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<Props, State> {
};
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<Props, State> {
});
};
+ 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 (
<div className="account-body account-container">
@@ -157,8 +150,8 @@ export class Notifications extends React.PureComponent<Props, State> {
<Projects
addNotification={this.addNotification}
channels={this.state.channels}
- notificationsByProject={notificationsByProject}
- projects={projects}
+ initialProjectNotificationsCount={initialProjectNotificationsCount}
+ notifications={projectNotifications}
removeNotification={this.removeNotification}
types={this.state.perProjectTypes}
/>
@@ -170,8 +163,6 @@ export class Notifications extends React.PureComponent<Props, State> {
}
}
-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<Props, 'fetchOrganizations'>;
-
-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<Props, State> {
+ 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 (
+ <SimpleModal header={header} onClose={closeModal} onSubmit={this.handleSubmit}>
+ {({ onCloseClick, onFormSubmit }) => (
+ <form onSubmit={onFormSubmit}>
+ <header className="modal-head">
+ <h2>{header}</h2>
+ </header>
+ <div className="modal-body">
+ <div className="modal-field abs-width-400">
+ <label>{translate('my_account.set_notifications_for')}</label>
+ <SearchBox
+ autoFocus={true}
+ onChange={this.handleSearch}
+ onKeyDown={this.handleKeyDown}
+ placeholder={translate('search.placeholder')}
+ value={query}
+ />
+
+ {loading && <i className="spinner spacer-left" />}
+
+ {!loading && open && (
+ <div className="position-relative">
+ <DropdownOverlay className="abs-width-400" noPadding={true}>
+ {suggestions && suggestions.length > 0 ? (
+ <ul className="notifications-add-project-search-results">
+ {suggestions.map(suggestion => (
+ <li
+ className={classNames({
+ active: highlighted && highlighted.project === suggestion.project
+ })}
+ key={suggestion.project}
+ onClick={() => this.handleSelect(suggestion)}>
+ {suggestion.projectName}
+ </li>
+ ))}
+ </ul>
+ ) : (
+ <div className="notifications-add-project-no-search-results">
+ {translate('no_results')}
+ </div>
+ )}
+ </DropdownOverlay>
+ </div>
+ )}
+ </div>
+ </div>
+ <footer className="modal-foot">
+ <div>
+ <SubmitButton disabled={selectedProject === undefined}>
+ {translate('add_verb')}
+ </SubmitButton>
+ <ResetButtonLink onClick={onCloseClick}>{translate('cancel')}</ResetButtonLink>
+ </div>
+ </footer>
+ </form>
+ )}
+ </SimpleModal>
+ );
+ }
+}
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<Props> {
- 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<boolean>(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 (
- <table className="form big-spacer-bottom" key={project.key}>
+ return (
+ <BoxedGroupAccordion
+ onClick={toggleExpanded}
+ open={!isCollapsed}
+ title={<h4 className="display-inline-block">{project.projectName}</h4>}>
+ <table className="data zebra notifications-table" key={project.project}>
<thead>
<tr>
- <th>
- <span className="text-normal">
- <Organization organizationKey={project.organization} />
- </span>
- <h4 className="display-inline-block">
- <Link to={getProjectUrl(project.key)}>{project.name}</Link>
- </h4>
- </th>
+ <th aria-label={translate('project')} />
{channels.map(channel => (
<th className="text-center" key={channel}>
<h4>{translate('notification.channel', channel)}</h4>
@@ -79,16 +70,17 @@ export default class ProjectNotifications extends React.PureComponent<Props> {
))}
</tr>
</thead>
+
<NotificationsList
- channels={this.props.channels}
- checkboxId={this.getCheckboxId}
- notifications={this.props.notifications}
- onAdd={this.handleAddNotification}
- onRemove={this.handleRemoveNotification}
+ channels={props.channels}
+ checkboxId={getCheckboxId}
+ notifications={props.notifications}
+ onAdd={handleAddNotification}
+ onRemove={handleRemoveNotification}
project={true}
- types={this.props.types}
+ types={props.types}
/>
</table>
- );
- }
+ </BoxedGroupAccordion>
+ );
}
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<T.Notification[]>;
- 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<Props, State> {
- 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 (
- <span>
- <Organization link={false} organizationKey={option.organization} />
- <strong>{option.label}</strong>
- </span>
- );
+ 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 (
- <section className="boxed-group">
- <h2>{translate('my_profile.per_project_notifications.title')}</h2>
+ <section className="boxed-group" data-test="account__project-notifications">
+ <div className="boxed-group-inner">
+ <div className="page-actions">
+ <Button onClick={this.openModal}>
+ <span data-test="account__add-project-notification">
+ {translate('my_profile.per_project_notifications.add')}
+ </span>
+ </Button>
+ </div>
+
+ <h2>{translate('my_profile.per_project_notifications.title')}</h2>
+ </div>
+
+ {this.state.showModal && (
+ <ProjectModal
+ addedProjects={allProjects}
+ closeModal={this.closeModal}
+ onSubmit={this.handleSubmit}
+ />
+ )}
<div className="boxed-group-inner">
{allProjects.length === 0 && (
<div className="note">{translate('my_account.no_project_notifications')}</div>
)}
- {allProjects.map(project => (
- <ProjectNotifications
- addNotification={this.props.addNotification}
- channels={this.props.channels}
- key={project.key}
- notifications={this.props.notificationsByProject[project.key] || []}
- project={project}
- removeNotification={this.props.removeNotification}
- types={this.props.types}
- />
- ))}
-
- <div className="spacer-top panel bg-muted">
- <span className="text-middle spacer-right">
- {translate('my_account.set_notifications_for')}:
- </span>
- <AsyncSelect
- autoload={false}
- cache={false}
- className="input-super-large"
- loadOptions={this.loadOptions}
- name="new_project"
- onChange={this.handleAddProject}
- optionRenderer={this.renderOption}
- placeholder={translate('my_account.search_project')}
- />
- </div>
+ {allProjects.length > 0 && (
+ <div className="big-spacer-bottom">
+ <SearchBox
+ onChange={this.handleSearch}
+ placeholder={translate('search.search_for_projects')}
+ />
+ </div>
+ )}
+
+ {filteredProjects.map(project => {
+ const collapsed = addedProjects.find(p => p.project === project.project)
+ ? false
+ : shouldBeCollapsed;
+ return (
+ <ProjectNotifications
+ addNotification={this.props.addNotification}
+ channels={this.props.channels}
+ collapsed={collapsed}
+ key={project.project}
+ notifications={notificationsByProject[project.project] || []}
+ project={project}
+ removeNotification={n => this.removeNotification(n, allProjects)}
+ types={this.props.types}
+ />
+ );
+ })}
</div>
</section>
);
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<Function>('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<Notifications['props']>) {
- const wrapper = shallow(
- <Notifications
- appState={{ organizationsEnabled: false }}
- fetchOrganizations={jest.fn()}
- {...props}
- />
- );
+async function shallowRender(props: Partial<Notifications['props']> = {}) {
+ const wrapper = shallow<Notifications>(<Notifications {...props} />);
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<any>).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<ProjectModal>(
+ <ProjectModal addedProjects={[]} closeModal={jest.fn()} onSubmit={jest.fn()} {...props} />
+ );
+}
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(
- <ProjectNotifications
- addNotification={jest.fn()}
- channels={channels}
- notifications={notifications}
- project={{ key: 'foo', name: 'Foo', organization: 'org' }}
- removeNotification={jest.fn()}
- types={types}
- />
- )
- ).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(
- <ProjectNotifications
- addNotification={addNotification}
- channels={channels}
- notifications={notifications}
- project={{ key: 'foo', name: 'Foo', organization: 'org' }}
- removeNotification={removeNotification}
- types={types}
- />
- );
+ const wrapper = shallowRender({ addNotification, removeNotification });
const notificationsList = wrapper.find('NotificationsList');
notificationsList.prop<Function>('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<Function>('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(
+ <ProjectNotifications
+ addNotification={jest.fn()}
+ channels={['channel1', 'channel2']}
+ collapsed={false}
+ notifications={[
+ { ...project, channel: 'channel1', type: 'type1' },
+ { ...project, channel: 'channel1', type: 'type2' },
+ { ...project, channel: 'channel2', type: 'type2' }
+ ]}
+ project={project}
+ removeNotification={jest.fn()}
+ types={['type1', 'type2']}
+ {...props}
+ />
+ );
+}
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<Function>('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<Function>('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<Function>('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<Props>) {
- return shallow(
+function shallowRender(props?: Partial<Projects['props']>) {
+ return shallow<Projects>(
<Projects
addNotification={jest.fn()}
channels={channels}
- notificationsByProject={{}}
- projects={[]}
+ initialProjectNotificationsCount={0}
+ notifications={[]}
removeNotification={jest.fn()}
types={types}
{...props}
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap
index a24b8456f1d..5b6472500bf 100644
--- a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap
+++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap
@@ -26,18 +26,7 @@ exports[`should fetch notifications and render 1`] = `
"channel2",
]
}
- notifications={
- Array [
- Object {
- "channel": "channel1",
- "type": "type-global",
- },
- Object {
- "channel": "channel1",
- "type": "type-common",
- },
- ]
- }
+ notifications={Array []}
removeNotification={[Function]}
types={
Array [
@@ -54,25 +43,29 @@ exports[`should fetch notifications and render 1`] = `
"channel2",
]
}
- notificationsByProject={
- Object {
- "foo": Array [
- Object {
- "channel": "channel2",
- "organization": "org",
- "project": "foo",
- "projectName": "Foo",
- "type": "type-common",
- },
- ],
- }
- }
- projects={
+ initialProjectNotificationsCount={3}
+ notifications={
Array [
Object {
- "key": "foo",
- "name": "Foo",
+ "channel": "channel1",
+ "organization": "org",
+ "project": "foo",
+ "projectName": "Foo",
+ "type": "type-global",
+ },
+ Object {
+ "channel": "channel1",
+ "organization": "org",
+ "project": "bar",
+ "projectName": "Bar",
+ "type": "type-common",
+ },
+ Object {
+ "channel": "channel2",
"organization": "org",
+ "project": "qux",
+ "projectName": "Qux",
+ "type": "type-common",
},
]
}
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectModal-test.tsx.snap b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectModal-test.tsx.snap
new file mode 100644
index 00000000000..e9f2bb6a718
--- /dev/null
+++ b/server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectModal-test.tsx.snap
@@ -0,0 +1,74 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<Modal
+ contentLabel="my_account.set_notifications_for.title"
+ onRequestClose={[MockFunction]}
+>
+ <form
+ onSubmit={[Function]}
+ >
+ <header
+ className="modal-head"
+ >
+ <h2>
+ my_account.set_notifications_for.title
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <div
+ className="modal-field abs-width-400"
+ >
+ <label>
+ my_account.set_notifications_for
+ </label>
+ <SearchBox
+ autoFocus={true}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="search.placeholder"
+ />
+ </div>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <div>
+ <SubmitButton
+ disabled={true}
+ >
+ add_verb
+ </SubmitButton>
+ <ResetButtonLink
+ onClick={[Function]}
+ >
+ cancel
+ </ResetButtonLink>
+ </div>
+ </footer>
+ </form>
+</Modal>
+`;
+
+exports[`should trigger a search correctly 1`] = `
+<ul
+ className="notifications-add-project-search-results"
+>
+ <li
+ className=""
+ key="foo"
+ onClick={[Function]}
+ >
+ Foo
+ </li>
+ <li
+ className=""
+ key="bar"
+ onClick={[Function]}
+ >
+ Bar
+ </li>
+</ul>
+`;
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`] = `
-<table
- className="form big-spacer-bottom"
- key="foo"
+exports[`should render correctly 1`] = `
+<BoxedGroupAccordion
+ onClick={[Function]}
+ open={true}
+ title={
+ <h4
+ className="display-inline-block"
+ >
+ Foo
+ </h4>
+ }
>
- <thead>
- <tr>
- <th>
- <span
- className="text-normal"
+ <table
+ className="data zebra notifications-table"
+ key="foo"
+ >
+ <thead>
+ <tr>
+ <th
+ aria-label="project"
+ />
+ <th
+ className="text-center"
+ key="channel1"
>
- <Connect(Organization)
- organizationKey="org"
- />
- </span>
- <h4
- className="display-inline-block"
+ <h4>
+ notification.channel.channel1
+ </h4>
+ </th>
+ <th
+ className="text-center"
+ key="channel2"
>
- <Link
- onlyActiveOnIndex={false}
- style={Object {}}
- to={
- Object {
- "pathname": "/dashboard",
- "query": Object {
- "branch": undefined,
- "id": "foo",
- },
- }
- }
- >
- Foo
- </Link>
- </h4>
- </th>
- <th
- className="text-center"
- key="channel1"
- >
- <h4>
- notification.channel.channel1
- </h4>
- </th>
- <th
- className="text-center"
- key="channel2"
- >
- <h4>
- notification.channel.channel2
- </h4>
- </th>
- </tr>
- </thead>
- <NotificationsList
- channels={
- Array [
- "channel1",
- "channel2",
- ]
- }
- checkboxId={[Function]}
- notifications={
- Array [
- Object {
- "channel": "channel1",
- "type": "type1",
- },
- Object {
- "channel": "channel1",
- "type": "type2",
- },
- Object {
- "channel": "channel2",
- "type": "type2",
- },
- ]
- }
- onAdd={[Function]}
- onRemove={[Function]}
- project={true}
- types={
- Array [
- "type1",
- "type2",
- ]
- }
- />
-</table>
+ <h4>
+ notification.channel.channel2
+ </h4>
+ </th>
+ </tr>
+ </thead>
+ <NotificationsList
+ channels={
+ Array [
+ "channel1",
+ "channel2",
+ ]
+ }
+ checkboxId={[Function]}
+ notifications={
+ Array [
+ Object {
+ "channel": "channel1",
+ "project": "foo",
+ "projectName": "Foo",
+ "type": "type1",
+ },
+ Object {
+ "channel": "channel1",
+ "project": "foo",
+ "projectName": "Foo",
+ "type": "type2",
+ },
+ Object {
+ "channel": "channel2",
+ "project": "foo",
+ "projectName": "Foo",
+ "type": "type2",
+ },
+ ]
+ }
+ onAdd={[Function]}
+ onRemove={[Function]}
+ project={true}
+ types={
+ Array [
+ "type1",
+ "type2",
+ ]
+ }
+ />
+ </table>
+</BoxedGroupAccordion>
+`;
+
+exports[`should render correctly 2`] = `
+<BoxedGroupAccordion
+ onClick={[Function]}
+ open={false}
+ title={
+ <h4
+ className="display-inline-block"
+ >
+ Foo
+ </h4>
+ }
+>
+ <table
+ className="data zebra notifications-table"
+ key="foo"
+ >
+ <thead>
+ <tr>
+ <th
+ aria-label="project"
+ />
+ <th
+ className="text-center"
+ key="channel1"
+ >
+ <h4>
+ notification.channel.channel1
+ </h4>
+ </th>
+ <th
+ className="text-center"
+ key="channel2"
+ >
+ <h4>
+ notification.channel.channel2
+ </h4>
+ </th>
+ </tr>
+ </thead>
+ <NotificationsList
+ channels={
+ Array [
+ "channel1",
+ "channel2",
+ ]
+ }
+ checkboxId={[Function]}
+ notifications={
+ Array [
+ Object {
+ "channel": "channel1",
+ "project": "foo",
+ "projectName": "Foo",
+ "type": "type1",
+ },
+ Object {
+ "channel": "channel1",
+ "project": "foo",
+ "projectName": "Foo",
+ "type": "type2",
+ },
+ Object {
+ "channel": "channel2",
+ "project": "foo",
+ "projectName": "Foo",
+ "type": "type2",
+ },
+ ]
+ }
+ onAdd={[Function]}
+ onRemove={[Function]}
+ project={true}
+ types={
+ Array [
+ "type1",
+ "type2",
+ ]
+ }
+ />
+ </table>
+</BoxedGroupAccordion>
`;
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`] = `
-<span>
- <Connect(Organization)
- link={false}
- organizationKey="org"
- />
- <strong>
- Qwe
- </strong>
-</span>
-`;
-
exports[`should render projects 1`] = `
<section
className="boxed-group"
+ data-test="account__project-notifications"
>
- <h2>
- my_profile.per_project_notifications.title
- </h2>
<div
className="boxed-group-inner"
>
- <ProjectNotifications
- addNotification={[MockFunction]}
- channels={
- Array [
- "channel1",
- "channel2",
- ]
- }
- key="foo"
- notifications={
- Array [
- Object {
- "channel": "channel1",
- "organization": "org",
- "project": "foo",
- "projectName": "Foo",
- "type": "type1",
- },
- Object {
- "channel": "channel1",
- "organization": "org",
- "project": "foo",
- "projectName": "Foo",
- "type": "type2",
- },
- ]
- }
- project={
- Object {
- "key": "foo",
- "name": "Foo",
- "organization": "org",
- }
- }
- removeNotification={[MockFunction]}
- types={
- Array [
- "type1",
- "type2",
- ]
- }
- />
- <ProjectNotifications
- addNotification={[MockFunction]}
- channels={
- Array [
- "channel1",
- "channel2",
- ]
- }
- key="bar"
- notifications={Array []}
- project={
- Object {
- "key": "bar",
- "name": "Bar",
- "organization": "org",
- }
- }
- removeNotification={[MockFunction]}
- types={
- Array [
- "type1",
- "type2",
- ]
- }
- />
<div
- className="spacer-top panel bg-muted"
+ className="page-actions"
>
- <span
- className="text-middle spacer-right"
+ <Button
+ onClick={[Function]}
>
- my_account.set_notifications_for
- :
- </span>
- <AsyncSelect
- autoload={false}
- cache={false}
- className="input-super-large"
- loadOptions={[Function]}
- name="new_project"
- onChange={[Function]}
- optionRenderer={[Function]}
- placeholder="my_account.search_project"
- />
+ <span
+ data-test="account__add-project-notification"
+ >
+ my_profile.per_project_notifications.add
+ </span>
+ </Button>
</div>
+ <h2>
+ my_profile.per_project_notifications.title
+ </h2>
</div>
-</section>
-`;
-
-exports[`should render projects 2`] = `
-<section
- className="boxed-group"
->
- <h2>
- my_profile.per_project_notifications.title
- </h2>
<div
className="boxed-group-inner"
>
+ <div
+ className="big-spacer-bottom"
+ >
+ <SearchBox
+ onChange={[Function]}
+ placeholder="search.search_for_projects"
+ />
+ </div>
<ProjectNotifications
addNotification={[MockFunction]}
channels={
@@ -131,58 +44,27 @@ exports[`should render projects 2`] = `
"channel2",
]
}
- key="foo"
+ collapsed={false}
+ key="bar"
notifications={
Array [
Object {
"channel": "channel1",
- "organization": "org",
- "project": "foo",
- "projectName": "Foo",
- "type": "type1",
- },
- Object {
- "channel": "channel1",
- "organization": "org",
- "project": "foo",
- "projectName": "Foo",
+ "project": "bar",
+ "projectName": "Bar",
"type": "type2",
},
]
}
project={
Object {
- "key": "foo",
- "name": "Foo",
- "organization": "org",
- }
- }
- removeNotification={[MockFunction]}
- types={
- Array [
- "type1",
- "type2",
- ]
- }
- />
- <ProjectNotifications
- addNotification={[MockFunction]}
- channels={
- Array [
- "channel1",
- "channel2",
- ]
- }
- key="bar"
- notifications={Array []}
- project={
- Object {
- "key": "bar",
- "name": "Bar",
- "organization": "org",
+ "channel": "channel1",
+ "project": "bar",
+ "projectName": "Bar",
+ "type": "type2",
}
}
- removeNotification={[MockFunction]}
+ removeNotification={[Function]}
types={
Array [
"type1",
@@ -198,78 +80,12 @@ exports[`should render projects 2`] = `
"channel2",
]
}
- key="qux"
- notifications={Array []}
- project={
- Object {
- "key": "qux",
- "name": "Qux",
- "organization": "org",
- }
- }
- removeNotification={[MockFunction]}
- types={
- Array [
- "type1",
- "type2",
- ]
- }
- />
- <div
- className="spacer-top panel bg-muted"
- >
- <span
- className="text-middle spacer-right"
- >
- my_account.set_notifications_for
- :
- </span>
- <AsyncSelect
- autoload={false}
- cache={false}
- className="input-super-large"
- loadOptions={[Function]}
- name="new_project"
- onChange={[Function]}
- optionRenderer={[Function]}
- placeholder="my_account.search_project"
- />
- </div>
- </div>
-</section>
-`;
-
-exports[`should render projects 3`] = `
-<section
- className="boxed-group"
->
- <h2>
- my_profile.per_project_notifications.title
- </h2>
- <div
- className="boxed-group-inner"
- >
- <ProjectNotifications
- addNotification={[MockFunction]}
- channels={
- Array [
- "channel1",
- "channel2",
- ]
- }
+ collapsed={false}
key="foo"
notifications={
Array [
Object {
"channel": "channel1",
- "organization": "org",
- "project": "foo",
- "projectName": "Foo",
- "type": "type1",
- },
- Object {
- "channel": "channel1",
- "organization": "org",
"project": "foo",
"projectName": "Foo",
"type": "type2",
@@ -278,12 +94,13 @@ exports[`should render projects 3`] = `
}
project={
Object {
- "key": "foo",
- "name": "Foo",
- "organization": "org",
+ "channel": "channel1",
+ "project": "foo",
+ "projectName": "Foo",
+ "type": "type2",
}
}
- removeNotification={[MockFunction]}
+ removeNotification={[Function]}
types={
Array [
"type1",
@@ -291,82 +108,14 @@ exports[`should render projects 3`] = `
]
}
/>
- <ProjectNotifications
- addNotification={[MockFunction]}
- channels={
- Array [
- "channel1",
- "channel2",
- ]
- }
- key="bar"
- notifications={Array []}
- project={
- Object {
- "key": "bar",
- "name": "Bar",
- "organization": "org",
- }
- }
- removeNotification={[MockFunction]}
- types={
- Array [
- "type1",
- "type2",
- ]
- }
- />
- <ProjectNotifications
- addNotification={[MockFunction]}
- channels={
- Array [
- "channel1",
- "channel2",
- ]
- }
- key="qux"
- notifications={Array []}
- project={
- Object {
- "key": "qux",
- "name": "Qux",
- "organization": "org",
- }
- }
- removeNotification={[MockFunction]}
- types={
- Array [
- "type1",
- "type2",
- ]
- }
- />
- <div
- className="spacer-top panel bg-muted"
- >
- <span
- className="text-middle spacer-right"
- >
- my_account.set_notifications_for
- :
- </span>
- <AsyncSelect
- autoload={false}
- cache={false}
- className="input-super-large"
- loadOptions={[Function]}
- name="new_project"
- onChange={[Function]}
- optionRenderer={[Function]}
- placeholder="my_account.search_project"
- />
- </div>
</div>
</section>
`;
-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