]> source.dussan.org Git - sonarqube.git/commitdiff
SGB-117 Remove optimistic update to avoid out of order notifications update
authorMathieu Suen <mathieu.suen@sonarsource.com>
Fri, 27 Sep 2024 11:16:42 +0000 (13:16 +0200)
committersonartech <sonartech@sonarsource.com>
Mon, 30 Sep 2024 20:02:46 +0000 (20:02 +0000)
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 [deleted file]
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/projectInformation/notifications/ProjectNotifications.tsx
server/sonar-web/src/main/js/apps/projectInformation/notifications/__tests__/ProjectNotifications-test.tsx [deleted file]
server/sonar-web/src/main/js/components/hoc/withNotifications.tsx [deleted file]
server/sonar-web/src/main/js/components/notifications/NotificationsList.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/queries/notifications.ts [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 741e133ade14fe0f75861115578a0f8379798ebe..89191506cd84c5fca738af4bba178200d16ec776 100644 (file)
  */
 
 import { Heading } from '@sonarsource/echoes-react';
-import { Table } from 'design-system';
 import * as React from 'react';
+import NotificationsList from '../../../components/notifications/NotificationsList';
 import { translate } from '../../../helpers/l10n';
-import { Notification, NotificationGlobalType } from '../../../types/notifications';
-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: Readonly<Props>) {
+export default function GlobalNotifications() {
   return (
     <>
       <Heading as="h2" hasMarginBottom>
         {translate('my_profile.overall_notifications.title')}
       </Heading>
 
-      {!props.header && (
-        <div className="sw-typo-semibold sw-mb-2">{translate('notifications.send_email')}</div>
-      )}
-
-      <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>
+      <NotificationsList />
     </>
   );
 }
-
-function getCheckboxId(type: string, channel: string) {
-  return `global-notification-${type}-${channel}`;
-}
index 4d272bb374d9f5920e81080312b2d3b68fbd8437..4cc70e2d1f06f54358ed627fe428e091ffb17bbb 100644 (file)
 
 import { Heading, Spinner } from '@sonarsource/echoes-react';
 import { FlagMessage, GreySeparator } from 'design-system';
-import { isEmpty, partition } from 'lodash';
 import * as React from 'react';
 import { Helmet } from 'react-helmet-async';
-import {
-  WithNotificationsProps,
-  withNotifications,
-} from '../../../components/hoc/withNotifications';
 import { translate } from '../../../helpers/l10n';
+import { useNotificationsQuery } from '../../../queries/notifications';
 import GlobalNotifications from './GlobalNotifications';
 import Projects from './Projects';
 
-export function Notifications({
-  addNotification,
-  channels,
-  globalTypes,
-  loading,
-  notifications,
-  perProjectTypes,
-  removeNotification,
-}: WithNotificationsProps) {
-  const [globalNotifications, projectNotifications] = partition(notifications, (n) =>
-    isEmpty(n.project),
-  );
-
-  const emailOnly = channels.length === 1 && channels[0] === 'EmailNotificationChannel';
+export default function Notifications() {
+  const { data: notificationResponse, isLoading } = useNotificationsQuery();
+  const { notifications } = notificationResponse || {
+    channels: [],
+    globalTypes: [],
+    perProjectTypes: [],
+    notifications: [],
+  };
 
-  const header = emailOnly ? undefined : (
-    <tr>
-      <th className="sw-typo-semibold">{translate('events')}</th>
-
-      {channels.map((channel) => (
-        <th className="sw-typo-semibold sw-text-right" key={channel}>
-          {translate('notification.channel', channel)}
-        </th>
-      ))}
-    </tr>
-  );
+  const projectNotifications = notifications.filter((n) => n.project !== undefined);
 
   return (
     <div className="it__account-body">
@@ -70,35 +50,19 @@ export function Notifications({
         {translate('notification.dispatcher.information')}
       </FlagMessage>
 
-      <Spinner isLoading={loading}>
+      <Spinner isLoading={isLoading}>
         {notifications && (
           <>
             <GreySeparator className="sw-mb-4 sw-mt-6" />
 
-            <GlobalNotifications
-              addNotification={addNotification}
-              channels={channels}
-              header={header}
-              notifications={globalNotifications}
-              removeNotification={removeNotification}
-              types={globalTypes}
-            />
+            <GlobalNotifications />
 
             <GreySeparator className="sw-mb-4 sw-mt-6" />
 
-            <Projects
-              addNotification={addNotification}
-              channels={channels}
-              header={header}
-              notifications={projectNotifications}
-              removeNotification={removeNotification}
-              types={perProjectTypes}
-            />
+            <Projects notifications={projectNotifications} />
           </>
         )}
       </Spinner>
     </div>
   );
 }
-
-export default withNotifications(Notifications);
diff --git a/server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.tsx b/server/sonar-web/src/main/js/apps/account/notifications/NotificationsList.tsx
deleted file mode 100644 (file)
index 946ac70..0000000
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2024 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 { Checkbox } from '@sonarsource/echoes-react';
-import { CellComponent, TableRowInteractive } from 'design-system';
-import * as React from 'react';
-import { hasMessage, translate, translateWithParameters } from '../../../helpers/l10n';
-import {
-  Notification,
-  NotificationGlobalType,
-  NotificationProjectType,
-} from '../../../types/notifications';
-
-interface Props {
-  channels: string[];
-  checkboxId: (type: string, channel: string) => string;
-  notifications: Notification[];
-  onAdd: (n: Notification) => void;
-  onRemove: (n: Notification) => void;
-  project?: boolean;
-  types: (NotificationGlobalType | NotificationProjectType)[];
-}
-
-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,
-    );
-
-  const handleCheck = (type: string, channel: string, checked: boolean) => {
-    if (checked) {
-      onAdd({ type, channel });
-    } else {
-      onRemove({ type, channel });
-    }
-  };
-
-  const getDispatcherLabel = (dispatcher: string) => {
-    const globalMessageKey = ['notification.dispatcher', dispatcher];
-    const projectMessageKey = [...globalMessageKey, 'project'];
-    const shouldUseProjectMessage = project && hasMessage(...projectMessageKey);
-
-    return shouldUseProjectMessage
-      ? translate(...projectMessageKey)
-      : translate(...globalMessageKey);
-  };
-
-  return types.map((type) => (
-    <TableRowInteractive className="sw-h-9" key={type}>
-      <CellComponent className="sw-py-0 sw-border-0">{getDispatcherLabel(type)}</CellComponent>
-
-      {channels.map((channel) => (
-        <CellComponent className="sw-py-0 sw-border-0" key={channel}>
-          <div className="sw-justify-end sw-flex sw-items-center">
-            <Checkbox
-              ariaLabel={translateWithParameters(
-                'notification.dispatcher.description_x',
-                getDispatcherLabel(type),
-              )}
-              checked={isEnabled(type, channel)}
-              id={checkboxId(type, channel)}
-              onCheck={(checked) => handleCheck(type, channel, checked as boolean)}
-            />
-          </div>
-        </CellComponent>
-      ))}
-    </TableRowInteractive>
-  ));
-}
index ac4a396384a658ea39cf802d1998e19623212815..ba0b340bc6d118675508e1144eb86c6d617e6eda 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 { Link } from 'design-system';
 import * as React from 'react';
-import { translate } from '../../../helpers/l10n';
+import NotificationsList from '../../../components/notifications/NotificationsList';
 import { getProjectUrl } from '../../../helpers/urls';
-import {
-  Notification,
-  NotificationProject,
-  NotificationProjectType,
-} from '../../../types/notifications';
-import NotificationsList from './NotificationsList';
+import { NotificationProject } from '../../../types/notifications';
 
 interface Props {
-  addNotification: (n: Notification) => void;
-  channels: string[];
-  header?: React.JSX.Element;
-  notifications: Notification[];
   project: NotificationProject;
-  removeNotification: (n: Notification) => void;
-  types: NotificationProjectType[];
 }
 
-export default function ProjectNotifications({
-  addNotification,
-  channels,
-  header,
-  notifications,
-  project,
-  removeNotification,
-  types,
-}: Readonly<Props>) {
-  const getCheckboxId = (type: string, channel: string) => {
-    return `project-notification-${project.project}-${type}-${channel}`;
-  };
-
-  const handleAddNotification = ({ channel, type }: { channel: string; type: string }) => {
-    addNotification({ ...project, channel, type });
-  };
-
-  const handleRemoveNotification = ({ channel, type }: { channel: string; type: string }) => {
-    removeNotification({
-      ...project,
-      channel,
-      type,
-    });
-  };
-
+export default function ProjectNotifications({ project }: Readonly<Props>) {
   return (
     <div className="sw-my-6">
       <div className="sw-mb-4">
         <Link to={getProjectUrl(project.project)}>{project.projectName}</Link>
       </div>
-      {!header && (
-        <div className="sw-typo-semibold 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={channels}
-          checkboxId={getCheckboxId}
-          notifications={notifications}
-          onAdd={handleAddNotification}
-          onRemove={handleRemoveNotification}
-          project
-          types={types}
-        />
-      </Table>
+      <NotificationsList project={project.project} />
     </div>
   );
 }
index c18e1181ea5dd944d71c6a1c45f6b7156f54e6f2..5833f1fd3001859f1a7dbb8b9f21f41a2649277b 100644 (file)
 
 import { Button, ButtonVariety, Heading } from '@sonarsource/echoes-react';
 import { InputSearch, Note } from 'design-system';
-import { groupBy, sortBy, uniqBy } from 'lodash';
+import { sortBy, uniqBy } from 'lodash';
 import * as React from 'react';
 import { translate } from '../../../helpers/l10n';
-import {
-  Notification,
-  NotificationProject,
-  NotificationProjectType,
-} from '../../../types/notifications';
+import { Notification, NotificationProject } from '../../../types/notifications';
 import ProjectModal from './ProjectModal';
 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[];
 }
 
 interface State {
@@ -92,16 +83,6 @@ export default class Projects extends React.PureComponent<Props, State> {
     this.setState({ showModal: true });
   };
 
-  removeNotification = (removed: Notification, allProjects: NotificationProject[]) => {
-    const projectToRemove = allProjects.find((p) => p.project === removed.project);
-
-    if (projectToRemove) {
-      this.handleAddProject(projectToRemove);
-    }
-
-    this.props.removeNotification(removed);
-  };
-
   render() {
     const { notifications } = this.props;
     const { addedProjects, search } = this.state;
@@ -110,7 +91,6 @@ export default class Projects extends React.PureComponent<Props, State> {
       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) =>
@@ -154,16 +134,7 @@ export default class Projects extends React.PureComponent<Props, State> {
           )}
 
           {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}
-            />
+            <ProjectNotifications key={project.project} project={project} />
           ))}
         </div>
       </section>
index 1eacae6639585f9a12eaa1aa694d7dd31823161f..97619cf7b7d5c33ffbe6ce5a9b578ea9b6a3630c 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 { Spinner } from '@sonarsource/echoes-react';
-import { Checkbox, FlagMessage, SubTitle } from 'design-system';
+import { FlagMessage, SubTitle } from 'design-system';
 import * as React from 'react';
-import {
-  WithNotificationsProps,
-  withNotifications,
-} from '../../../components/hoc/withNotifications';
-import { hasMessage, translate, translateWithParameters } from '../../../helpers/l10n';
-import { NotificationProjectType } from '../../../types/notifications';
+import NotificationsList from '../../../components/notifications/NotificationsList';
+import { translate } from '../../../helpers/l10n';
 import { Component } from '../../../types/types';
 
 interface Props {
   component: Component;
 }
 
-export function ProjectNotifications(props: WithNotificationsProps & Props) {
-  const { channels, component, loading, notifications, perProjectTypes } = props;
-
-  const handleCheck = (type: NotificationProjectType, channel: string, checked: boolean) => {
-    if (checked) {
-      props.addNotification({ project: component.key, channel, type });
-    } else {
-      props.removeNotification({
-        project: component.key,
-        channel,
-        type,
-      });
-    }
-  };
-
-  const getCheckboxId = (type: string, channel: string) => {
-    return `project-notification-${component.key}-${type}-${channel}`;
-  };
-
-  const getDispatcherLabel = (dispatcher: string) => {
-    const globalMessageKey = ['notification.dispatcher', dispatcher];
-    const projectMessageKey = [...globalMessageKey, 'project'];
-    const shouldUseProjectMessage = hasMessage(...projectMessageKey);
-    return shouldUseProjectMessage
-      ? translate(...projectMessageKey)
-      : translate(...globalMessageKey);
-  };
-
-  const isEnabled = (type: string, channel: string) => {
-    return !!notifications.find(
-      (notification) =>
-        notification.type === type &&
-        notification.channel === channel &&
-        notification.project === component.key,
-    );
-  };
-
-  const emailChannel = channels[0];
+export default function ProjectNotifications(props: Props) {
+  const { component } = props;
 
   return (
     <form aria-labelledby="notifications-update-title">
@@ -79,32 +38,7 @@ export function ProjectNotifications(props: WithNotificationsProps & Props) {
         {translate('notification.dispatcher.information')}
       </FlagMessage>
 
-      <Spinner className="sw-mt-6" isLoading={loading}>
-        <h3 id="notifications-update-title" className="sw-mt-6">
-          {translate('notifications.send_email')}
-        </h3>
-        <ul className="sw-list-none sw-mt-4 sw-pl-0">
-          {perProjectTypes.map((type) => (
-            <li className="sw-pl-0 sw-p-2" key={type}>
-              <Checkbox
-                right
-                className="sw-flex sw-justify-between"
-                label={translateWithParameters(
-                  'notification.dispatcher.description_x',
-                  getDispatcherLabel(type),
-                )}
-                checked={isEnabled(type, emailChannel)}
-                id={getCheckboxId(type, emailChannel)}
-                onCheck={(checked: boolean) => handleCheck(type, emailChannel, checked)}
-              >
-                {getDispatcherLabel(type)}
-              </Checkbox>
-            </li>
-          ))}
-        </ul>
-      </Spinner>
+      <NotificationsList className="sw-mt-6" project={component.key} />
     </form>
   );
 }
-
-export default withNotifications(ProjectNotifications);
diff --git a/server/sonar-web/src/main/js/apps/projectInformation/notifications/__tests__/ProjectNotifications-test.tsx b/server/sonar-web/src/main/js/apps/projectInformation/notifications/__tests__/ProjectNotifications-test.tsx
deleted file mode 100644 (file)
index a6de6c4..0000000
+++ /dev/null
@@ -1,96 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2024 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 { screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import * as React from 'react';
-import { getNotifications } from '../../../../api/notifications';
-import { mockComponent } from '../../../../helpers/mocks/component';
-import { mockNotification } from '../../../../helpers/testMocks';
-import { renderComponent } from '../../../../helpers/testReactTestingUtils';
-import { NotificationGlobalType, NotificationProjectType } from '../../../../types/notifications';
-import ProjectNotifications from '../ProjectNotifications';
-
-jest.mock('../../../../api/notifications', () => ({
-  addNotification: jest.fn().mockResolvedValue(undefined),
-  removeNotification: jest.fn().mockResolvedValue(undefined),
-  getNotifications: jest.fn(),
-}));
-
-beforeAll(() => {
-  jest.mocked(getNotifications).mockResolvedValue({
-    channels: ['channel1'],
-    globalTypes: [NotificationGlobalType.MyNewIssues],
-    notifications: [
-      mockNotification({}),
-      mockNotification({ type: NotificationProjectType.NewAlerts }),
-    ],
-    perProjectTypes: [NotificationProjectType.NewAlerts, NotificationProjectType.NewIssues],
-  });
-});
-
-it('should render correctly', async () => {
-  const user = userEvent.setup();
-  renderProjectNotifications();
-
-  expect(await screen.findByText('notifications.send_email')).toBeInTheDocument();
-  expect(
-    screen.getByLabelText(
-      'notification.dispatcher.description_x.notification.dispatcher.NewAlerts.project',
-    ),
-  ).toBeChecked();
-
-  expect(
-    screen.getByLabelText(
-      'notification.dispatcher.description_x.notification.dispatcher.NewIssues.project',
-    ),
-  ).not.toBeChecked();
-
-  // Toggle New Alerts
-  await user.click(
-    screen.getByLabelText(
-      'notification.dispatcher.description_x.notification.dispatcher.NewAlerts.project',
-    ),
-  );
-
-  expect(
-    screen.getByLabelText(
-      'notification.dispatcher.description_x.notification.dispatcher.NewAlerts.project',
-    ),
-  ).not.toBeChecked();
-
-  // Toggle New Issues
-  await user.click(
-    screen.getByLabelText(
-      'notification.dispatcher.description_x.notification.dispatcher.NewIssues.project',
-    ),
-  );
-
-  expect(
-    screen.getByLabelText(
-      'notification.dispatcher.description_x.notification.dispatcher.NewIssues.project',
-    ),
-  ).toBeChecked();
-});
-
-function renderProjectNotifications() {
-  return renderComponent(
-    <ProjectNotifications component={mockComponent({ key: 'foo', name: 'Foo' })} />,
-  );
-}
diff --git a/server/sonar-web/src/main/js/components/hoc/withNotifications.tsx b/server/sonar-web/src/main/js/components/hoc/withNotifications.tsx
deleted file mode 100644 (file)
index e1472c9..0000000
+++ /dev/null
@@ -1,153 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2024 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 { uniqWith } from 'lodash';
-import * as React from 'react';
-import { getWrappedDisplayName } from '~sonar-aligned/components/hoc/utils';
-import { addNotification, getNotifications, removeNotification } from '../../api/notifications';
-import {
-  Notification,
-  NotificationGlobalType,
-  NotificationProjectType,
-} from '../../types/notifications';
-
-interface State {
-  channels: string[];
-  globalTypes: NotificationGlobalType[];
-  loading: boolean;
-  notifications: Notification[];
-  perProjectTypes: NotificationProjectType[];
-}
-
-export interface WithNotificationsProps {
-  addNotification: (added: Notification) => void;
-  channels: string[];
-  globalTypes: NotificationGlobalType[];
-  loading: boolean;
-  notifications: Notification[];
-  perProjectTypes: NotificationProjectType[];
-  removeNotification: (removed: Notification) => void;
-}
-
-export function withNotifications<P>(
-  WrappedComponent: React.ComponentType<React.PropsWithChildren<P & WithNotificationsProps>>,
-) {
-  class Wrapper extends React.Component<P, State> {
-    mounted = false;
-    static displayName = getWrappedDisplayName(WrappedComponent, 'withNotifications');
-
-    state: State = {
-      channels: [],
-      globalTypes: [],
-      loading: true,
-      notifications: [],
-      perProjectTypes: [],
-    };
-
-    componentDidMount() {
-      this.mounted = true;
-      this.fetchNotifications();
-    }
-
-    componentWillUnmount() {
-      this.mounted = false;
-    }
-
-    fetchNotifications = () => {
-      getNotifications().then(
-        (response) => {
-          if (this.mounted) {
-            this.setState({
-              channels: response.channels,
-              globalTypes: response.globalTypes,
-              loading: false,
-              notifications: response.notifications,
-              perProjectTypes: response.perProjectTypes,
-            });
-          }
-        },
-        () => {
-          if (this.mounted) {
-            this.setState({ loading: false });
-          }
-        },
-      );
-    };
-
-    addNotificationToState = (added: Notification) => {
-      this.setState((state) => {
-        const notifications = uniqWith([...state.notifications, added], this.areNotificationsEqual);
-        return { notifications };
-      });
-    };
-
-    removeNotificationFromState = (removed: Notification) => {
-      this.setState((state) => {
-        const notifications = state.notifications.filter(
-          (notification) => !this.areNotificationsEqual(notification, removed),
-        );
-        return { notifications };
-      });
-    };
-
-    addNotification = (added: Notification) => {
-      // optimistic update
-      this.addNotificationToState(added);
-
-      // recreate `data` to omit `projectName` and `organization` from `Notification`
-      const data = { channel: added.channel, project: added.project, type: added.type };
-      addNotification(data).catch(() => {
-        this.removeNotificationFromState(added);
-      });
-    };
-
-    removeNotification = (removed: Notification) => {
-      // optimistic update
-      this.removeNotificationFromState(removed);
-
-      // recreate `data` to omit `projectName` and `organization` from `Notification`
-      const data = { channel: removed.channel, project: removed.project, type: removed.type };
-      removeNotification(data).catch(() => {
-        this.addNotificationToState(removed);
-      });
-    };
-
-    areNotificationsEqual = (a: Notification, b: Notification) => {
-      return a.channel === b.channel && a.type === b.type && a.project === b.project;
-    };
-
-    render() {
-      const { channels, globalTypes, loading, notifications, perProjectTypes } = this.state;
-      return (
-        <WrappedComponent
-          {...this.props}
-          addNotification={this.addNotification}
-          channels={channels}
-          globalTypes={globalTypes}
-          loading={loading}
-          notifications={notifications}
-          perProjectTypes={perProjectTypes}
-          removeNotification={this.removeNotification}
-        />
-      );
-    }
-  }
-
-  return Wrapper;
-}
diff --git a/server/sonar-web/src/main/js/components/notifications/NotificationsList.tsx b/server/sonar-web/src/main/js/components/notifications/NotificationsList.tsx
new file mode 100644 (file)
index 0000000..f1d3f9f
--- /dev/null
@@ -0,0 +1,132 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { Checkbox, Spinner } from '@sonarsource/echoes-react';
+import classNames from 'classnames';
+import { CellComponent, Table, TableRowInteractive } from 'design-system';
+import * as React from 'react';
+import { useCallback } from 'react';
+import { FormattedMessage } from 'react-intl';
+import { hasMessage, translate, translateWithParameters } from '../../helpers/l10n';
+import {
+  useAddNotificationMutation,
+  useNotificationsQuery,
+  useRemoveNotificationMutation,
+} from '../../queries/notifications';
+
+interface Props {
+  className?: string;
+  project?: string;
+}
+
+function getDispatcherLabel(dispatcher: string, project?: string) {
+  const globalMessageKey = ['notification.dispatcher', dispatcher];
+  const projectMessageKey = [...globalMessageKey, 'project'];
+  const shouldUseProjectMessage = project !== undefined && hasMessage(...projectMessageKey);
+
+  return shouldUseProjectMessage ? translate(...projectMessageKey) : translate(...globalMessageKey);
+}
+
+export default function NotificationsList({ project, className = '' }: Readonly<Props>) {
+  const { data, isLoading } = useNotificationsQuery();
+  const { mutate: add, isPending: isPendingAdd } = useAddNotificationMutation();
+  const { mutate: remove, isPending: isPendingRemove } = useRemoveNotificationMutation();
+  const types = (project ? data?.perProjectTypes : data?.globalTypes) || [];
+  const channels = data?.channels || [];
+
+  const checkboxId = useCallback(
+    (type: string, channel: string) => {
+      return project === undefined
+        ? `global-notification-${type}-${channel}`
+        : `project-notification-${project}-${type}-${channel}`;
+    },
+    [project],
+  );
+
+  const isEnabled = useCallback(
+    (type: string, channel: string) =>
+      !!data?.notifications.find(
+        (notification) =>
+          notification.type === type &&
+          notification.channel === channel &&
+          notification.project === project,
+      ),
+    [data?.notifications, project],
+  );
+
+  const handleCheck = useCallback(
+    (type: string, channel: string, checked: boolean) => {
+      if (checked) {
+        add({ type, channel, project });
+      } else {
+        remove({ type, channel, project });
+      }
+    },
+    [add, project, remove],
+  );
+
+  return (
+    <Spinner isLoading={isLoading}>
+      <Table
+        className={classNames('sw-w-full', className)}
+        columnCount={channels.length + 1}
+        header={
+          <tr>
+            <th className="sw-typo-semibold">{translate('notification.for')}</th>
+
+            {channels.map((channel) => (
+              <th className="sw-typo-semibold sw-text-right" key={channel}>
+                <FormattedMessage
+                  id="notification.by"
+                  values={{ channel: <FormattedMessage id={`notification.channel.${channel}`} /> }}
+                />
+              </th>
+            ))}
+          </tr>
+        }
+      >
+        {types.map((type) => (
+          <TableRowInteractive className="sw-h-9" key={type}>
+            <CellComponent className="sw-py-0 sw-border-0">
+              {getDispatcherLabel(type, project)}
+            </CellComponent>
+
+            {channels.map((channel) => (
+              <CellComponent className="sw-py-0 sw-border-0" key={channel}>
+                <div className="sw-justify-end sw-flex sw-items-center">
+                  <Checkbox
+                    ariaLabel={translateWithParameters(
+                      'notification.dispatcher.description_x',
+                      getDispatcherLabel(type, project),
+                    )}
+                    isDisabled={isPendingRemove || isPendingAdd}
+                    checked={isEnabled(type, channel)}
+                    id={checkboxId(type, channel)}
+                    onCheck={(checked) => handleCheck(type, channel, checked as boolean)}
+                  />
+                </div>
+              </CellComponent>
+            ))}
+          </TableRowInteractive>
+        ))}
+      </Table>
+    </Spinner>
+  );
+}
diff --git a/server/sonar-web/src/main/js/queries/notifications.ts b/server/sonar-web/src/main/js/queries/notifications.ts
new file mode 100644 (file)
index 0000000..8fc7ef9
--- /dev/null
@@ -0,0 +1,82 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 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 { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query';
+import { uniqWith } from 'lodash';
+import { addNotification, getNotifications, removeNotification } from '../api/notifications';
+import { Notification } from '../types/notifications';
+import { createQueryHook, StaleTime } from './common';
+
+const KEY_PREFIX = 'notifications';
+
+const notificationQuery = queryOptions({
+  queryKey: [KEY_PREFIX],
+  queryFn: () => getNotifications(),
+  staleTime: StaleTime.NEVER,
+});
+
+function areNotificationsEqual(a: Notification, b: Notification) {
+  return a.channel === b.channel && a.type === b.type && a.project === b.project;
+}
+
+export const useNotificationsQuery = createQueryHook(() => notificationQuery);
+
+export function useAddNotificationMutation() {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: addNotification,
+    onSuccess: (_, { channel, type, project }) => {
+      queryClient.setQueryData(notificationQuery.queryKey, (previous) => {
+        if (previous === undefined) {
+          return previous;
+        }
+        return {
+          ...previous,
+          notifications: uniqWith(
+            [...previous.notifications, { channel, type, project }],
+            areNotificationsEqual,
+          ),
+        };
+      });
+      queryClient.invalidateQueries({ queryKey: [KEY_PREFIX] });
+    },
+  });
+}
+
+export function useRemoveNotificationMutation() {
+  const queryClient = useQueryClient();
+  return useMutation({
+    mutationFn: removeNotification,
+    onSuccess: (_, removed) => {
+      queryClient.setQueryData(notificationQuery.queryKey, (previous) => {
+        if (previous === undefined) {
+          return previous;
+        }
+        return {
+          ...previous,
+          notifications: previous.notifications.filter(
+            (notification) => !areNotificationsEqual(notification, removed),
+          ),
+        };
+      });
+      queryClient.invalidateQueries({ queryKey: [KEY_PREFIX] });
+    },
+  });
+}
index 7462a83f91f2c6d3bd7eea5ca9bb424e1aa9064f..a7384ba9f022b18b61e0b7351381b34360c23dc6 100644 (file)
@@ -2835,6 +2835,8 @@ email_notification.state.value_should_be_valid_email=A valid email address is re
 #
 #------------------------------------------------------------------------------
 notification.notification=Notification
+notification.for=Notify me for
+notification.by=by {channel}
 notification.channel.EmailNotificationChannel=Email
 notification.dispatcher.information=A notification is never sent to the author of the event.
 notification.dispatcher.ChangesOnMyIssue=Changes in issues/hotspots assigned to me
@@ -2960,7 +2962,6 @@ my_account.preferences=Preferences
 my_account.preferences.keyboard_shortcuts=Enable Keyboard Shortcuts
 my_account.preferences.keyboard_shortcuts.description=Some actions can be performed using keyboard shortcuts. If you do not want to use these shortcuts, you can disable them here (this won't disable navigation shortcuts, which include the arrows, escape, and enter keys). For a list of available keyboard shortcuts, use the question mark shortcut (hit {questionMark} on your keyboard).
 
-notifications.send_email=Send me an email for:
 
 #------------------------------------------------------------------------------
 #