]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-10030 Improve project notifications management
authorWouter Admiraal <45544358+wouter-admiraal-sonarsource@users.noreply.github.com>
Tue, 17 Sep 2019 09:56:13 +0000 (11:56 +0200)
committerSonarTech <sonartech@sonarsource.com>
Wed, 18 Sep 2019 07:51:48 +0000 (09:51 +0200)
19 files changed:
server/sonar-web/src/main/js/app/styles/init/misc.css
server/sonar-web/src/main/js/app/types.d.ts
server/sonar-web/src/main/js/apps/account/account.css
server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx
server/sonar-web/src/main/js/apps/account/notifications/NotificationsContainer.tsx [deleted file]
server/sonar-web/src/main/js/apps/account/notifications/ProjectModal.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/ProjectNotifications.tsx
server/sonar-web/src/main/js/apps/account/notifications/Projects.tsx
server/sonar-web/src/main/js/apps/account/notifications/__tests__/Notifications-test.tsx
server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectModal-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/ProjectNotifications-test.tsx
server/sonar-web/src/main/js/apps/account/notifications/__tests__/Projects-test.tsx
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Notifications-test.tsx.snap
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectModal-test.tsx.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/ProjectNotifications-test.tsx.snap
server/sonar-web/src/main/js/apps/account/notifications/__tests__/__snapshots__/Projects-test.tsx.snap
server/sonar-web/src/main/js/apps/account/notifications/types.ts [deleted file]
server/sonar-web/src/main/js/apps/account/routes.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 7a424f4ee38dd776d1ac7b429a790ad83059aad3..31650acaf14be542bc03900788684deb1d41485f 100644 (file)
@@ -49,6 +49,10 @@ th.hide-overflow {
   margin-top: -1px;
 }
 
+.nudged-down {
+  margin-top: 1px;
+}
+
 .spacer {
   margin: 8px !important;
 }
index 8bfd5c55adcf08bd5b9e6945fdec6f631b5ca4ac..47e5d1f61009adb351a99ce80abecfae9dffef37 100644 (file)
@@ -507,6 +507,11 @@ declare namespace T {
     type: string;
   }
 
+  export interface NotificationProject {
+    project: string;
+    projectName: string;
+  }
+
   export interface OrganizationActions {
     admin?: boolean;
     delete?: boolean;
index 02d8c556796476e85eb0721305eea822edb9d11d..eab942f9e5e4c113273b726bcd608f707c6ef4b7 100644 (file)
   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);
+}
index f903ee919425e2ad1ac46a798dcfe40d8d21951f..a2d26135adab9ebe9533289e3e9f327b06006756 100644 (file)
  * 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 (file)
index 1d50e89..0000000
+++ /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 (file)
index 0000000..01b1d06
--- /dev/null
@@ -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>
+    );
+  }
+}
index 16d6b5e9ea828696fe948ed7947e9472039880d6..d2ab10811fd687926c81b06d076c168f930a98bf 100644 (file)
  * 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>
+  );
 }
index 663ad924912b978e5ddb3e3d7982d07219948927..57eebfa2a8981fc00aa3723ad4736f9a570556f0 100644 (file)
  * 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>
     );
index 0aebefa4d3de12abd385a5aa7f0fd44a02a52d2e..82d02d028f62419b59866952ea631a34eb594282 100644 (file)
@@ -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 (file)
index 0000000..26931f2
--- /dev/null
@@ -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} />
+  );
+}
index 936160c18f6b072b221107cffe6746bd83b883cc..e2a8ebca5618b513b5a9f0f6ad9020d64399f84a 100644 (file)
@@ -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}
+    />
+  );
+}
index e0234bd92d9ebc0c24b870ee23cf5f098e669a46..e397e80f8450be46302b20da72c427a508c78ef3 100644 (file)
  */
 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}
index a24b8456f1d11c26bfac1d17fbeefd2423670375..5b6472500bfbc657f853c0d35d485e04dbba0e2f 100644 (file)
@@ -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 (file)
index 0000000..e9f2bb6
--- /dev/null
@@ -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>
+`;
index 11b25e6dd9dfaab7dcf6909a0624add4a9ac374a..07ae17de22bccce48a4197494749f4f23c856750 100644 (file)
 // 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>
 `;
index 72fd77b67c3049ac29fc7d627cbb731cc17ec211..529f67f2132f8e81705653593370f0647d7bc2c9 100644 (file)
 // 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 (file)
index 26d312e..0000000
+++ /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;
-}
index 9ecf83909afa055d64f326e9dd73a2b22227510d..a6bcf8ed63854e5092d94420bc2cdca8971e301e 100644 (file)
@@ -36,7 +36,7 @@ const routes = [
       },
       {
         path: 'notifications',
-        component: lazyLoad(() => import('./notifications/NotificationsContainer'))
+        component: lazyLoad(() => import('./notifications/Notifications'))
       },
       {
         path: 'organizations',
index 9bb347d45f2578d59ebb3070e90826fbaa986ce8..bd95abe8e39e362264ea6c31fbec69b1a6764c2a 100644 (file)
@@ -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