]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-21482 User notifications page adopts the new UI
authorDavid Cho-Lerat <david.cho-lerat@sonarsource.com>
Tue, 30 Jan 2024 09:00:42 +0000 (10:00 +0100)
committersonartech <sonartech@sonarsource.com>
Tue, 30 Jan 2024 15:02:03 +0000 (15:02 +0000)
server/sonar-web/src/main/js/apps/account/__tests__/Account-it.tsx
server/sonar-web/src/main/js/apps/account/notifications/GlobalNotifications.tsx
server/sonar-web/src/main/js/apps/account/notifications/Notifications.tsx
server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.tsx
server/sonar-web/src/main/js/apps/account/notifications/ProjectModal.tsx
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/security/Security.tsx
server/sonar-web/src/main/js/apps/projectInformation/notifications/ProjectNotifications.tsx
server/sonar-web/src/main/js/apps/projectInformation/notifications/__tests__/ProjectNotifications-test.tsx
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index b9e31528a252f608a700eecd2482c6ac1d47d16b..f39901cffa27cd607c53312d3091e49e53d4b2e2 100644 (file)
@@ -178,10 +178,15 @@ it('should render the top menu', () => {
 
   expect(screen.getByText(name)).toBeInTheDocument();
 
-  expect(screen.getByText('my_account.profile')).toBeInTheDocument();
-  expect(screen.getByText('my_account.security')).toBeInTheDocument();
-  expect(screen.getByText('my_account.notifications')).toBeInTheDocument();
-  expect(screen.getByText('my_account.projects')).toBeInTheDocument();
+  const topMenuNavigationItems = [
+    'my_account.profile',
+    'my_account.security',
+    'my_account.notifications',
+    'my_account.projects',
+  ];
+  topMenuNavigationItems.forEach((itemName) => {
+    expect(byRole('navigation').byRole('link', { name: itemName }).get()).toBeInTheDocument();
+  });
 });
 
 describe('profile page', () => {
@@ -477,10 +482,10 @@ describe('notifications page', () => {
     addButton: byRole('button', { name: 'my_profile.per_project_notifications.add' }),
     addModalButton: byRole('button', { name: 'add_verb' }),
     searchInput: byRole('searchbox', { name: 'search.placeholder' }),
-    sonarQubeProject: byRole('heading', { name: 'SonarQube' }),
+    sonarQubeProject: byRole('link', { name: 'SonarQube' }),
     checkbox: (type: NotificationProjectType) =>
       byRole('checkbox', {
-        name: `notification.dispatcher.descrption_x.notification.dispatcher.${type}.project`,
+        name: `notification.dispatcher.description_x.notification.dispatcher.${type}.project`,
       }),
   };
 
@@ -489,7 +494,7 @@ describe('notifications page', () => {
     noNotificationForProject: byText('my_account.no_project_notifications'),
     checkbox: (type: NotificationGlobalType) =>
       byRole('checkbox', {
-        name: `notification.dispatcher.descrption_x.notification.dispatcher.${type}`,
+        name: `notification.dispatcher.description_x.notification.dispatcher.${type}`,
       }),
   };
 
@@ -536,7 +541,8 @@ describe('notifications page', () => {
 
     await user.click(await projectUI.addButton.find());
     expect(projectUI.addModalButton.get()).toBeDisabled();
-    await user.type(projectUI.searchInput.get(), 'sonar');
+
+    await user.keyboard('sonar');
     // navigate within the two results, choose the first:
     await user.keyboard('[ArrowDown][ArrowDown][ArrowUp][Enter]');
     await user.click(projectUI.addModalButton.get());
@@ -558,10 +564,10 @@ describe('notifications page', () => {
 
     renderAccountApp(mockLoggedInUser(), notificationsPagePath);
 
-    await user.click(
-      await screen.findByRole('button', { name: 'my_profile.per_project_notifications.add' }),
-    );
-    expect(screen.getByLabelText('search.placeholder', { selector: 'input' })).toBeInTheDocument();
+    await user.click(await projectUI.addButton.find());
+
+    expect(screen.getByLabelText('my_account.set_notifications_for.title')).toBeInTheDocument();
+
     await user.keyboard('sonarqube');
 
     await user.click(screen.getByText('SonarQube'));
@@ -574,11 +580,11 @@ describe('notifications page', () => {
     await user.click(screen.getByRole('searchbox'));
     await user.keyboard('bla');
 
-    expect(screen.queryByRole('heading', { name: 'SonarQube' })).not.toBeInTheDocument();
+    expect(projectUI.sonarQubeProject.query()).not.toBeInTheDocument();
 
     await user.keyboard('[Backspace>3/]');
 
-    expect(await screen.findByRole('heading', { name: 'SonarQube' })).toBeInTheDocument();
+    expect(await projectUI.sonarQubeProject.find()).toBeInTheDocument();
   });
 });
 
index 278f28fd03d62e3c19b6b050020f3cc9aba00f87..700f7f6e08a1fd1a9ef577076838b97fc5514584 100644 (file)
@@ -17,6 +17,8 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { PageTitle, Table } from 'design-system';
 import * as React from 'react';
 import { translate } from '../../../helpers/l10n';
 import { Notification, NotificationGlobalType } from '../../../types/notifications';
@@ -25,40 +27,32 @@ import NotificationsList from './NotificationsList';
 interface Props {
   addNotification: (n: Notification) => void;
   channels: string[];
+  header?: React.JSX.Element;
   notifications: Notification[];
   removeNotification: (n: Notification) => void;
   types: NotificationGlobalType[];
 }
 
-export default function GlobalNotifications(props: Props) {
+export default function GlobalNotifications(props: Readonly<Props>) {
   return (
-    <section className="boxed-group">
-      <h2>{translate('my_profile.overall_notifications.title')}</h2>
+    <>
+      <PageTitle className="sw-mb-4" text={translate('my_profile.overall_notifications.title')} />
 
-      <div className="boxed-group-inner">
-        <table className="data zebra">
-          <thead>
-            <tr>
-              <th>{translate('notification.notification')}</th>
-              {props.channels.map((channel) => (
-                <th className="text-center" key={channel}>
-                  <h4>{translate('notification.channel', channel)}</h4>
-                </th>
-              ))}
-            </tr>
-          </thead>
+      {!props.header && (
+        <div className="sw-body-sm-highlight sw-mb-2">{translate('notifications.send_email')}</div>
+      )}
 
-          <NotificationsList
-            channels={props.channels}
-            checkboxId={getCheckboxId}
-            notifications={props.notifications}
-            onAdd={props.addNotification}
-            onRemove={props.removeNotification}
-            types={props.types}
-          />
-        </table>
-      </div>
-    </section>
+      <Table className="sw-w-full" columnCount={2} header={props.header ?? null}>
+        <NotificationsList
+          channels={props.channels}
+          checkboxId={getCheckboxId}
+          notifications={props.notifications}
+          onAdd={props.addNotification}
+          onRemove={props.removeNotification}
+          types={props.types}
+        />
+      </Table>
+    </>
   );
 }
 
index 86f6354b0b4d50fcd6c52a266a9036dbdd5cd661..445202080dab7ba7ac4f0f2c231dbf5916432c73 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 { FlagMessage, GreySeparator, Spinner, Title } from 'design-system';
 import { partition } from 'lodash';
 import * as React from 'react';
 import { Helmet } from 'react-helmet-async';
 import {
-  withNotifications,
   WithNotificationsProps,
+  withNotifications,
 } from '../../../components/hoc/withNotifications';
-import { Alert } from '../../../components/ui/Alert';
-import Spinner from '../../../components/ui/Spinner';
 import { translate } from '../../../helpers/l10n';
 import GlobalNotifications from './GlobalNotifications';
 import Projects from './Projects';
 
-export function Notifications(props: WithNotificationsProps) {
-  const {
-    addNotification,
-    channels,
-    globalTypes,
-    loading,
-    notifications,
-    perProjectTypes,
-    removeNotification,
-  } = props;
-
+export function Notifications({
+  addNotification,
+  channels,
+  globalTypes,
+  loading,
+  notifications,
+  perProjectTypes,
+  removeNotification,
+}: Readonly<WithNotificationsProps>) {
   const [globalNotifications, projectNotifications] = partition(notifications, (n) => !n.project);
 
+  const emailOnly = channels.length === 1 && channels[0] === 'EmailNotificationChannel';
+
+  const header = emailOnly ? undefined : (
+    <tr>
+      <th className="sw-body-sm-highlight">{translate('events')}</th>
+
+      {channels.map((channel) => (
+        <th className="sw-body-sm-highlight sw-text-right" key={channel}>
+          {translate('notification.channel', channel)}
+        </th>
+      ))}
+    </tr>
+  );
+
   return (
-    <div className="account-body account-container">
+    <div className="it__account-body">
       <Helmet defer={false} title={translate('my_account.notifications')} />
-      <Alert variant="info">{translate('notification.dispatcher.information')}</Alert>
+
+      <Title>{translate('my_account.notifications')}</Title>
+
+      <FlagMessage className="sw-my-2" variant="info">
+        {translate('notification.dispatcher.information')}
+      </FlagMessage>
+
       <Spinner loading={loading}>
         {notifications && (
           <>
+            <GreySeparator className="sw-mb-4 sw-mt-6" />
+
             <GlobalNotifications
               addNotification={addNotification}
               channels={channels}
+              header={header}
               notifications={globalNotifications}
               removeNotification={removeNotification}
               types={globalTypes}
             />
+
+            <GreySeparator className="sw-mb-4 sw-mt-6" />
+
             <Projects
               addNotification={addNotification}
               channels={channels}
+              header={header}
               notifications={projectNotifications}
               removeNotification={removeNotification}
               types={perProjectTypes}
index ad05d39d647b8d7bfc49066d2709f5d276c961e3..c1748be30eb21f0af886e70118e1b1df2c0e64f4 100644 (file)
@@ -17,8 +17,9 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+import { CellComponent, Checkbox, TableRowInteractive } from 'design-system';
 import * as React from 'react';
-import Checkbox from '../../../components/controls/Checkbox';
 import { hasMessage, translate, translateWithParameters } from '../../../helpers/l10n';
 import {
   Notification,
@@ -27,63 +28,66 @@ import {
 } from '../../../types/notifications';
 
 interface Props {
-  onAdd: (n: Notification) => void;
-  onRemove: (n: Notification) => void;
   channels: string[];
   checkboxId: (type: string, channel: string) => string;
+  notifications: Notification[];
+  onAdd: (n: Notification) => void;
+  onRemove: (n: Notification) => void;
   project?: boolean;
   types: (NotificationGlobalType | NotificationProjectType)[];
-  notifications: Notification[];
 }
 
-export default class NotificationsList extends React.PureComponent<Props> {
-  isEnabled(type: string, channel: string) {
-    return !!this.props.notifications.find(
+export default function NotificationsList({
+  channels,
+  checkboxId,
+  notifications,
+  onAdd,
+  onRemove,
+  project,
+  types,
+}: Readonly<Props>) {
+  const isEnabled = (type: string, channel: string) =>
+    !!notifications.find(
       (notification) => notification.type === type && notification.channel === channel,
     );
-  }
 
-  handleCheck(type: string, channel: string, checked: boolean) {
+  const handleCheck = (type: string, channel: string, checked: boolean) => {
     if (checked) {
-      this.props.onAdd({ type, channel });
+      onAdd({ type, channel });
     } else {
-      this.props.onRemove({ type, channel });
+      onRemove({ type, channel });
     }
-  }
+  };
 
-  getDispatcherLabel(dispatcher: string) {
+  const getDispatcherLabel = (dispatcher: string) => {
     const globalMessageKey = ['notification.dispatcher', dispatcher];
     const projectMessageKey = [...globalMessageKey, 'project'];
-    const shouldUseProjectMessage = this.props.project && hasMessage(...projectMessageKey);
+    const shouldUseProjectMessage = project && hasMessage(...projectMessageKey);
+
     return shouldUseProjectMessage
       ? translate(...projectMessageKey)
       : translate(...globalMessageKey);
-  }
+  };
 
-  render() {
-    const { channels, checkboxId, types } = this.props;
+  return types.map((type) => (
+    <TableRowInteractive className="sw-h-9" key={type}>
+      <CellComponent className="sw-py-0 sw-border-0">{getDispatcherLabel(type)}</CellComponent>
 
-    return (
-      <tbody>
-        {types.map((type) => (
-          <tr key={type}>
-            <td>{this.getDispatcherLabel(type)}</td>
-            {channels.map((channel) => (
-              <td className="text-center" key={channel}>
-                <Checkbox
-                  label={translateWithParameters(
-                    'notification.dispatcher.descrption_x',
-                    this.getDispatcherLabel(type),
-                  )}
-                  checked={this.isEnabled(type, channel)}
-                  id={checkboxId(type, channel)}
-                  onCheck={(checked) => this.handleCheck(type, channel, checked)}
-                />
-              </td>
-            ))}
-          </tr>
-        ))}
-      </tbody>
-    );
-  }
+      {channels.map((channel) => (
+        <CellComponent className="sw-py-0 sw-border-0" key={channel}>
+          <div className="sw-justify-end sw-flex sw-items-center">
+            <Checkbox
+              checked={isEnabled(type, channel)}
+              id={checkboxId(type, channel)}
+              label={translateWithParameters(
+                'notification.dispatcher.description_x',
+                getDispatcherLabel(type),
+              )}
+              onCheck={(checked) => handleCheck(type, channel, checked)}
+            />
+          </div>
+        </CellComponent>
+      ))}
+    </TableRowInteractive>
+  ));
 }
index 757b75a6d505f4b060564f3cbb2de0aec303dbe7..c2d7778f1c7e09b445af3abae78f3b1e98c201ba 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 classNames from 'classnames';
+
+import {
+  ButtonPrimary,
+  DropdownMenu,
+  InputSearch,
+  ItemButton,
+  Modal,
+  Popup,
+  PopupPlacement,
+  PopupZLevel,
+  Spinner,
+} from 'design-system';
 import * as React from 'react';
 import { getSuggestions } from '../../../api/components';
-import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
-import { DropdownOverlay } from '../../../components/controls/Dropdown';
-import SearchBox from '../../../components/controls/SearchBox';
-import SimpleModal from '../../../components/controls/SimpleModal';
 import { KeyboardKeys } from '../../../helpers/keycodes';
 import { translate } from '../../../helpers/l10n';
+import { ComponentQualifier } from '../../../types/component';
 import { NotificationProject } from '../../../types/notifications';
 
 interface Props {
@@ -38,7 +46,6 @@ interface State {
   highlighted?: NotificationProject;
   loading?: boolean;
   query?: string;
-  open?: boolean;
   selectedProject?: NotificationProject;
   suggestions?: NotificationProject[];
 }
@@ -66,10 +73,12 @@ export default class ProjectModal extends React.PureComponent<Props, State> {
         event.preventDefault();
         this.handleSelectHighlighted();
         break;
+
       case KeyboardKeys.UpArrow:
         event.preventDefault();
         this.handleHighlightPrevious();
         break;
+
       case KeyboardKeys.DownArrow:
         event.preventDefault();
         this.handleHighlightNext();
@@ -79,6 +88,7 @@ export default class ProjectModal extends React.PureComponent<Props, State> {
 
   getCurrentIndex = () => {
     const { highlighted, suggestions } = this.state;
+
     return highlighted && suggestions
       ? suggestions.findIndex((suggestion) => suggestion.project === highlighted.project)
       : -1;
@@ -86,12 +96,14 @@ export default class ProjectModal extends React.PureComponent<Props, State> {
 
   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],
       });
@@ -107,13 +119,10 @@ export default class ProjectModal extends React.PureComponent<Props, State> {
   };
 
   handleSelectHighlighted = () => {
-    const { highlighted, selectedProject } = this.state;
+    const { highlighted } = this.state;
+
     if (highlighted !== undefined) {
-      if (selectedProject !== undefined && highlighted.project === selectedProject.project) {
-        this.handleSubmit();
-      } else {
-        this.handleSelect(highlighted);
-      }
+      this.handleSelect(highlighted);
     }
   };
 
@@ -121,16 +130,20 @@ export default class ProjectModal extends React.PureComponent<Props, State> {
     const { addedProjects } = this.props;
 
     if (query.length < 2) {
-      this.setState({ open: false, query });
+      this.setState({ query, selectedProject: undefined, suggestions: undefined });
+
       return;
     }
 
-    this.setState({ loading: true, query });
-    getSuggestions(query).then(
+    this.setState({ loading: true, query, selectedProject: undefined });
+
+    getSuggestions(query, undefined, ComponentQualifier.Project).then(
       (r) => {
         if (this.mounted) {
           let suggestions = undefined;
-          const projects = r.results.find((domain) => domain.q === 'TRK');
+
+          const projects = r.results.find((domain) => domain.q === ComponentQualifier.Project);
+
           if (projects && projects.items.length > 0) {
             suggestions = projects.items
               .filter((item) => !addedProjects.find((p) => p.project === item.key))
@@ -139,12 +152,13 @@ export default class ProjectModal extends React.PureComponent<Props, State> {
                 projectName: item.name,
               }));
           }
-          this.setState({ loading: false, open: true, suggestions });
+
+          this.setState({ loading: false, suggestions });
         }
       },
       () => {
         if (this.mounted) {
-          this.setState({ loading: false, open: false });
+          this.setState({ loading: false });
         }
       },
     );
@@ -152,14 +166,15 @@ export default class ProjectModal extends React.PureComponent<Props, State> {
 
   handleSelect = (selectedProject: NotificationProject) => {
     this.setState({
-      open: false,
       query: selectedProject.projectName,
       selectedProject,
+      suggestions: undefined,
     });
   };
 
   handleSubmit = () => {
     const { selectedProject } = this.state;
+
     if (selectedProject) {
       this.props.onSubmit(selectedProject);
     }
@@ -167,66 +182,82 @@ export default class ProjectModal extends React.PureComponent<Props, State> {
 
   render() {
     const { closeModal } = this.props;
-    const { highlighted, loading, query, open, selectedProject, suggestions } = this.state;
-    const header = translate('my_account.set_notifications_for.title');
+    const { highlighted, loading, query, selectedProject, suggestions } = this.state;
+
+    const projectSuggestion = (suggestion: NotificationProject) => (
+      <ItemButton
+        className="sw-my-1"
+        key={suggestion.project}
+        onClick={() => this.handleSelect(suggestion)}
+        selected={
+          highlighted?.project === suggestion.project ||
+          selectedProject?.project === suggestion.project
+        }
+      >
+        {suggestion.projectName}
+      </ItemButton>
+    );
+
+    const isSearching = query?.length && !selectedProject;
+
+    const noResults = isSearching ? (
+      <div className="sw-mx-5 sw-my-3">{translate('no_results')}</div>
+    ) : undefined;
+
     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
-                  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>
+      <Modal
+        body={
+          <form id="project-notifications-modal-form" onSubmit={this.handleSubmit}>
+            <Popup
+              allowResizing
+              overlay={
+                isSearching ? (
+                  <DropdownMenu
+                    className="sw-overflow-x-hidden sw-min-w-abs-350"
+                    maxHeight="38rem"
+                    size="auto"
+                  >
+                    <Spinner className="sw-mx-5 sw-my-3" loading={!!loading}>
                       {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 className="sw-py-2">
+                          {suggestions.map((suggestion) => projectSuggestion(suggestion))}
                         </ul>
                       ) : (
-                        <div className="notifications-add-project-no-search-results">
-                          {translate('no_results')}
-                        </div>
+                        noResults
                       )}
-                    </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>
+                    </Spinner>
+                  </DropdownMenu>
+                ) : undefined
+              }
+              placement={PopupPlacement.BottomLeft}
+              zLevel={PopupZLevel.Global}
+            >
+              <InputSearch
+                autoFocus
+                className="sw-my-2"
+                onChange={this.handleSearch}
+                onKeyDown={this.handleKeyDown}
+                placeholder={translate('my_account.set_notifications_for')}
+                searchInputAriaLabel={translate('search_verb')}
+                size="full"
+                value={query}
+              />
+            </Popup>
           </form>
-        )}
-      </SimpleModal>
+        }
+        headerTitle={translate('my_account.set_notifications_for.title')}
+        onClose={closeModal}
+        primaryButton={
+          <ButtonPrimary
+            disabled={selectedProject === undefined}
+            form="project-notifications-modal-form"
+            type="submit"
+          >
+            {translate('add_verb')}
+          </ButtonPrimary>
+        }
+        secondaryButtonLabel={translate('cancel')}
+      />
     );
   }
 }
index 417ec217b62d91c01bacdea33b2bd647001a2ef0..48bfa4f5e8cf14dc442b430e30e147542d7d44d6 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 classNames from 'classnames';
+import { Link, Table } from 'design-system';
 import * as React from 'react';
-import BoxedGroupAccordion from '../../../components/controls/BoxedGroupAccordion';
 import { translate } from '../../../helpers/l10n';
+import { getProjectUrl } from '../../../helpers/urls';
 import {
   Notification,
   NotificationProject,
@@ -30,63 +32,62 @@ import NotificationsList from './NotificationsList';
 interface Props {
   addNotification: (n: Notification) => void;
   channels: string[];
-  collapsed: boolean;
+  header?: React.JSX.Element;
   notifications: Notification[];
   project: NotificationProject;
   removeNotification: (n: Notification) => void;
   types: NotificationProjectType[];
 }
 
-export default function ProjectNotifications(props: Props) {
-  const { collapsed, project, channels } = props;
-  const [isCollapsed, setCollapsed] = React.useState<boolean>(collapsed);
-
+export default function ProjectNotifications({
+  addNotification,
+  channels,
+  header,
+  notifications,
+  project,
+  removeNotification,
+  types,
+}: Readonly<Props>) {
   const getCheckboxId = (type: string, channel: string) => {
-    return `project-notification-${props.project.project}-${type}-${channel}`;
+    return `project-notification-${project.project}-${type}-${channel}`;
   };
 
   const handleAddNotification = ({ channel, type }: { channel: string; type: string }) => {
-    props.addNotification({ ...props.project, channel, type });
+    addNotification({ ...project, channel, type });
   };
 
   const handleRemoveNotification = ({ channel, type }: { channel: string; type: string }) => {
-    props.removeNotification({
-      ...props.project,
+    removeNotification({
+      ...project,
       channel,
       type,
     });
   };
 
-  const toggleExpanded = () => setCollapsed(!isCollapsed);
-
   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 aria-label={translate('project')} />
-            {channels.map((channel) => (
-              <th className="text-center" key={channel}>
-                <h4>{translate('notification.channel', channel)}</h4>
-              </th>
-            ))}
-          </tr>
-        </thead>
+    <div className="sw-my-6">
+      <div className="sw-mb-4">
+        <Link to={getProjectUrl(project.project)}>{project.projectName}</Link>
+      </div>
+      {!header && (
+        <div className="sw-body-sm-highlight sw-mb-2">{translate('notifications.send_email')}</div>
+      )}
 
+      <Table
+        className={classNames('sw-w-full', { 'sw-mt-4': header })}
+        columnCount={2}
+        header={header ?? null}
+      >
         <NotificationsList
-          channels={props.channels}
+          channels={channels}
           checkboxId={getCheckboxId}
-          notifications={props.notifications}
+          notifications={notifications}
           onAdd={handleAddNotification}
           onRemove={handleRemoveNotification}
           project
-          types={props.types}
+          types={types}
         />
-      </table>
-    </BoxedGroupAccordion>
+      </Table>
+    </div>
   );
 }
index bdd1658bf0354296c0f803c8e07bdf593c1d9311..b99d5ef03fd3aaca7f9c1232310df0f486090e9c 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 { ButtonPrimary, InputSearch, Note } from 'design-system';
 import { groupBy, sortBy, uniqBy } from 'lodash';
 import * as React from 'react';
-import { Button } from '../../../components/controls/buttons';
-import SearchBox from '../../../components/controls/SearchBox';
 import { translate } from '../../../helpers/l10n';
 import {
   Notification,
@@ -33,13 +33,12 @@ import ProjectNotifications from './ProjectNotifications';
 export interface Props {
   addNotification: (n: Notification) => void;
   channels: string[];
+  header?: React.JSX.Element;
   notifications: Notification[];
   removeNotification: (n: Notification) => void;
   types: NotificationProjectType[];
 }
 
-const THRESHOLD_COLLAPSED = 3;
-
 interface State {
   addedProjects: NotificationProject[];
   search: string;
@@ -61,7 +60,7 @@ export default class Projects extends React.PureComponent<Props, State> {
   };
 
   filterSearch = (project: NotificationProject, search: string) => {
-    return project.projectName && project.projectName.toLowerCase().includes(search);
+    return project.projectName?.toLowerCase().includes(search);
   };
 
   handleAddProject = (project: NotificationProject) => {
@@ -94,6 +93,7 @@ export default class Projects extends React.PureComponent<Props, State> {
 
   removeNotification = (removed: Notification, allProjects: NotificationProject[]) => {
     const projectToRemove = allProjects.find((p) => p.project === removed.project);
+
     if (projectToRemove) {
       this.handleAddProject(projectToRemove);
     }
@@ -108,25 +108,26 @@ export default class Projects extends React.PureComponent<Props, State> {
     const projects = uniqBy(notifications, ({ project }) => project).filter(
       isNotificationProject,
     ) as 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 = Object.keys(notificationsByProject).length > THRESHOLD_COLLAPSED;
 
     return (
-      <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>
+      <section data-test="account__project-notifications">
+        <div className="sw-flex sw-justify-between">
+          <h2 className="sw-body-md-highlight sw-mb-4">
+            {translate('my_profile.per_project_notifications.title')}
+          </h2>
+
+          <ButtonPrimary onClick={this.openModal}>
+            <span data-test="account__add-project-notification">
+              {translate('my_profile.per_project_notifications.add')}
+            </span>
+          </ButtonPrimary>
         </div>
 
         {this.state.showModal && (
@@ -137,37 +138,32 @@ export default class Projects extends React.PureComponent<Props, State> {
           />
         )}
 
-        <div className="boxed-group-inner">
+        <div>
           {allProjects.length === 0 && (
-            <div className="note">{translate('my_account.no_project_notifications')}</div>
+            <Note>{translate('my_account.no_project_notifications')}</Note>
           )}
 
           {allProjects.length > 0 && (
-            <div className="big-spacer-bottom">
-              <SearchBox
+            <div className="sw-mb-4">
+              <InputSearch
                 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}
-              />
-            );
-          })}
+          {filteredProjects.map((project) => (
+            <ProjectNotifications
+              addNotification={this.props.addNotification}
+              channels={this.props.channels}
+              header={this.props.header}
+              key={project.project}
+              notifications={notificationsByProject[project.project] || []}
+              project={project}
+              removeNotification={(n) => this.removeNotification(n, allProjects)}
+              types={this.props.types}
+            />
+          ))}
         </div>
       </section>
     );
index fa7e912e9a2ec6f760691250ad517f310f97ed7f..b1ea17b4c5e7e64afde8b41e714aead0672d2efb 100644 (file)
@@ -18,7 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import { PageTitle, SubHeading } from 'design-system';
+import { PageTitle } from 'design-system';
 import * as React from 'react';
 import { Helmet } from 'react-helmet-async';
 import { useCurrentLoginUser } from '../../../app/components/current-user/CurrentUserContext';
@@ -35,14 +35,14 @@ export default function Security() {
       <Tokens login={currentUser.login} />
 
       {currentUser.local && (
-        <SubHeading as="section">
+        <>
           <PageTitle
             className="sw-heading-md sw-my-6"
             text={translate('my_profile.password.title')}
           />
 
           <ResetPasswordForm user={currentUser} />
-        </SubHeading>
+        </>
       )}
     </>
   );
index c27c1d9e0acf058943aa8cdc1a5804f6a445b506..f8f6bb8e80f242ac428b1539e6eba9d5af0dfbd3 100644 (file)
@@ -80,7 +80,7 @@ export function ProjectNotifications(props: WithNotificationsProps & Props) {
 
       <Spinner className="sw-mt-6" loading={loading}>
         <h3 id="notifications-update-title" className="sw-mt-6">
-          {translate('project_information.project_notifications.title')}
+          {translate('notifications.send_email')}
         </h3>
         <ul className="sw-list-none sw-mt-4 sw-pl-0">
           {perProjectTypes.map((type) => (
@@ -89,7 +89,7 @@ export function ProjectNotifications(props: WithNotificationsProps & Props) {
                 right
                 className="sw-flex sw-justify-between"
                 label={translateWithParameters(
-                  'notification.dispatcher.descrption_x',
+                  'notification.dispatcher.description_x',
                   getDispatcherLabel(type),
                 )}
                 checked={isEnabled(type, emailChannel)}
index acd8ede2d54390d7340323a88a844de1fc9f7dd0..a6de6c45878adb6057cb64482530b433eb179122 100644 (file)
@@ -49,44 +49,42 @@ it('should render correctly', async () => {
   const user = userEvent.setup();
   renderProjectNotifications();
 
-  expect(
-    await screen.findByText('project_information.project_notifications.title'),
-  ).toBeInTheDocument();
+  expect(await screen.findByText('notifications.send_email')).toBeInTheDocument();
   expect(
     screen.getByLabelText(
-      'notification.dispatcher.descrption_x.notification.dispatcher.NewAlerts.project',
+      'notification.dispatcher.description_x.notification.dispatcher.NewAlerts.project',
     ),
   ).toBeChecked();
 
   expect(
     screen.getByLabelText(
-      'notification.dispatcher.descrption_x.notification.dispatcher.NewIssues.project',
+      'notification.dispatcher.description_x.notification.dispatcher.NewIssues.project',
     ),
   ).not.toBeChecked();
 
   // Toggle New Alerts
   await user.click(
     screen.getByLabelText(
-      'notification.dispatcher.descrption_x.notification.dispatcher.NewAlerts.project',
+      'notification.dispatcher.description_x.notification.dispatcher.NewAlerts.project',
     ),
   );
 
   expect(
     screen.getByLabelText(
-      'notification.dispatcher.descrption_x.notification.dispatcher.NewAlerts.project',
+      'notification.dispatcher.description_x.notification.dispatcher.NewAlerts.project',
     ),
   ).not.toBeChecked();
 
   // Toggle New Issues
   await user.click(
     screen.getByLabelText(
-      'notification.dispatcher.descrption_x.notification.dispatcher.NewIssues.project',
+      'notification.dispatcher.description_x.notification.dispatcher.NewIssues.project',
     ),
   );
 
   expect(
     screen.getByLabelText(
-      'notification.dispatcher.descrption_x.notification.dispatcher.NewIssues.project',
+      'notification.dispatcher.description_x.notification.dispatcher.NewIssues.project',
     ),
   ).toBeChecked();
 });
index 7b41997b9cb8f83725119d96856cd5dc5bda948a..86cd9fa116b3e3a6883e86f698c45814d1e555f9 100644 (file)
@@ -2595,7 +2595,7 @@ notification.dispatcher.NewFalsePositiveIssue=Issues resolved as false positive
 notification.dispatcher.SQ-MyNewIssues=My new issues
 notification.dispatcher.CeReportTaskFailure=Background tasks in failure on my administered projects
 notification.dispatcher.CeReportTaskFailure.project=Background tasks in failure
-notification.dispatcher.descrption_x=Check to receive notification for {0}
+notification.dispatcher.description_x=Check to receive notification for {0}
 
 #------------------------------------------------------------------------------
 #
@@ -2704,6 +2704,8 @@ my_account.preferences.keyboard_shortcuts.description=Some actions can be perfor
 my_account.preferences.keyboard_shortcuts.enabled=Keyboard shortcuts are enabled
 my_account.preferences.keyboard_shortcuts.disabled=Keyboard shortcuts are disabled
 
+notifications.send_email=Send me an email for:
+
 #------------------------------------------------------------------------------
 #
 # PROJECT PROVISIONING
@@ -3787,13 +3789,6 @@ project_dump.failed_import=The last import has failed. Please try once again.
 project_dump.import_form_description=A dump has been found on the file system for this project. You can import it by clicking on the button below.
 project_dump.import_form_description_disabled=Projects cannot be imported. This feature is only available starting from Enterprise Edition.
 
-#------------------------------------------------------------------------------
-#
-# Project Information
-#
-#------------------------------------------------------------------------------
-project_information.project_notifications.title=Send me an email when:
-
 #------------------------------------------------------------------------------
 #
 # SYSTEM