]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-21797 Refactor activity app to use react-query
authorstanislavh <stanislav.honcharov@sonarsource.com>
Thu, 14 Mar 2024 10:31:06 +0000 (11:31 +0100)
committersonartech <sonartech@sonarsource.com>
Mon, 25 Mar 2024 20:02:41 +0000 (20:02 +0000)
21 files changed:
server/sonar-web/src/main/js/api/mocks/ProjectActivityServiceMock.ts
server/sonar-web/src/main/js/api/projectActivity.ts
server/sonar-web/src/main/js/api/time-machine.ts
server/sonar-web/src/main/js/app/components/componentContext/withComponentContext.tsx
server/sonar-web/src/main/js/apps/projectActivity/__tests__/__snapshots__/actions-test.ts.snap [deleted file]
server/sonar-web/src/main/js/apps/projectActivity/__tests__/actions-test.ts [deleted file]
server/sonar-web/src/main/js/apps/projectActivity/actions.ts [deleted file]
server/sonar-web/src/main/js/apps/projectActivity/components/Event.tsx
server/sonar-web/src/main/js/apps/projectActivity/components/Events.tsx
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.tsx
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.tsx
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.tsx
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAppRenderer.tsx
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityApp-it.tsx
server/sonar-web/src/main/js/apps/projectActivity/components/forms/AddEventForm.tsx
server/sonar-web/src/main/js/apps/projectActivity/components/forms/ChangeEventForm.tsx
server/sonar-web/src/main/js/apps/projectActivity/components/forms/RemoveAnalysisForm.tsx
server/sonar-web/src/main/js/apps/projectActivity/components/forms/RemoveEventForm.tsx
server/sonar-web/src/main/js/queries/applications.ts [new file with mode: 0644]
server/sonar-web/src/main/js/queries/measures.ts [new file with mode: 0644]
server/sonar-web/src/main/js/queries/project-analyses.ts [new file with mode: 0644]

index a83ad08e7685cef56f9280aac6cab1bd9ea3429c..aaea03df35dc23c9b7f08500bdcfbb234590b9df 100644 (file)
@@ -27,6 +27,7 @@ import {
   createEvent,
   deleteAnalysis,
   deleteEvent,
+  getAllTimeProjectActivity,
   getProjectActivity,
 } from '../projectActivity';
 
@@ -83,6 +84,9 @@ export class ProjectActivityServiceMock {
     this.#analysisList = cloneDeep(defaultAnalysesList);
 
     jest.mocked(getProjectActivity).mockImplementation(this.getActivityHandler);
+    jest
+      .mocked(getAllTimeProjectActivity)
+      .mockImplementation(this.getAllTimeProjectActivityHandler);
     jest.mocked(deleteAnalysis).mockImplementation(this.deleteAnalysisHandler);
     jest.mocked(createEvent).mockImplementation(this.createEventHandler);
     jest.mocked(changeEvent).mockImplementation(this.changeEventHandler);
@@ -121,7 +125,7 @@ export class ProjectActivityServiceMock {
       ? this.#analysisList.filter((a) => a.events.some((e) => e.category === category))
       : this.#analysisList;
 
-    if (from) {
+    if (from !== undefined) {
       const fromTime = parseDate(from).getTime();
       analyses = analyses.filter((a) => parseDate(a.date).getTime() >= fromTime);
     }
@@ -134,6 +138,36 @@ export class ProjectActivityServiceMock {
     });
   };
 
+  getAllTimeProjectActivityHandler = (
+    data: {
+      project: string;
+      statuses?: string;
+      category?: string;
+      from?: string;
+      p?: number;
+      ps?: number;
+    } & BranchParameters,
+  ) => {
+    const { project, p = DEFAULT_PAGE, category, from } = data;
+
+    if (project === UNKNOWN_PROJECT) {
+      throw new Error(`Could not find project "${UNKNOWN_PROJECT}"`);
+    }
+
+    let analyses = category
+      ? this.#analysisList.filter((a) => a.events.some((e) => e.category === category))
+      : this.#analysisList;
+
+    if (from !== undefined) {
+      const fromTime = parseDate(from).getTime();
+      analyses = analyses.filter((a) => parseDate(a.date).getTime() >= fromTime);
+    }
+    return this.reply({
+      paging: { pageSize: PAGE_SIZE, total: this.#analysisList.length, pageIndex: p },
+      analyses: this.#analysisList,
+    });
+  };
+
   deleteAnalysisHandler = (analysisKey: string) => {
     const i = this.#analysisList.findIndex(({ key }) => key === analysisKey);
     if (i === undefined) {
@@ -143,12 +177,18 @@ export class ProjectActivityServiceMock {
     return this.reply(undefined);
   };
 
-  createEventHandler = (
-    analysisKey: string,
-    name: string,
-    category = ProjectAnalysisEventCategory.Other,
-    description?: string,
-  ) => {
+  createEventHandler = (data: {
+    analysis: string;
+    name: string;
+    category?: ProjectAnalysisEventCategory;
+    description?: string;
+  }) => {
+    const {
+      analysis: analysisKey,
+      name,
+      category = ProjectAnalysisEventCategory.Other,
+      description,
+    } = data;
     const analysis = this.findAnalysis(analysisKey);
 
     const key = uniqueId(analysisKey);
@@ -163,7 +203,8 @@ export class ProjectActivityServiceMock {
     });
   };
 
-  changeEventHandler = (eventKey: string, name: string, description?: string) => {
+  changeEventHandler = (data: { event: string; name: string; description?: string }) => {
+    const { event: eventKey, name, description } = data;
     const [eventIndex, analysisKey] = this.findEvent(eventKey);
     const analysis = this.findAnalysis(analysisKey);
     const event = analysis.events[eventIndex];
index ddf70fb61dc175b897f56a754f8d2d5326dd8808..f9c1a1f7d63b6885ff7881e4223e44f733a393a4 100644 (file)
@@ -18,7 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { throwGlobalError } from '../helpers/error';
-import { getJSON, post, postJSON, RequestData } from '../helpers/request';
+import { getJSON, post, postJSON } from '../helpers/request';
 import { BranchParameters } from '../types/branch-like';
 import {
   Analysis,
@@ -33,20 +33,50 @@ export enum ProjectActivityStatuses {
   STATUS_LIVE_MEASURE_COMPUTE = 'L',
 }
 
-export function getProjectActivity(
-  data: {
-    project: string;
-    statuses?: string;
-    category?: string;
-    from?: string;
-    p?: number;
-    ps?: number;
-  } & BranchParameters,
-): Promise<{ analyses: Analysis[]; paging: Paging }> {
+export type ProjectActivityParams = {
+  project?: string;
+  statuses?: string;
+  category?: string;
+  from?: string;
+  p?: number;
+  ps?: number;
+} & BranchParameters;
+
+export interface ProjectActivityResponse {
+  analyses: Analysis[];
+  paging: Paging;
+}
+
+export function getProjectActivity(data: ProjectActivityParams): Promise<ProjectActivityResponse> {
   return getJSON('/api/project_analyses/search', data).catch(throwGlobalError);
 }
 
-interface CreateEventResponse {
+const PROJECT_ACTIVITY_PAGE_SIZE = 500;
+
+export function getAllTimeProjectActivity(
+  data: ProjectActivityParams,
+  prev?: ProjectActivityResponse,
+): Promise<ProjectActivityResponse> {
+  return getProjectActivity({ ...data, ps: data.ps ?? PROJECT_ACTIVITY_PAGE_SIZE }).then((r) => {
+    const result = prev
+      ? {
+          analyses: prev.analyses.concat(r.analyses),
+          paging: r.paging,
+        }
+      : r;
+
+    if (result.paging.pageIndex * result.paging.pageSize >= result.paging.total) {
+      return result;
+    }
+
+    return getAllTimeProjectActivity(
+      { ...data, ps: data.ps ?? PROJECT_ACTIVITY_PAGE_SIZE, p: result.paging.pageIndex + 1 },
+      result,
+    );
+  });
+}
+
+export interface CreateEventResponse {
   analysis: string;
   key: string;
   name: string;
@@ -54,19 +84,12 @@ interface CreateEventResponse {
   description?: string;
 }
 
-export function createEvent(
-  analysis: string,
-  name: string,
-  category?: string,
-  description?: string,
-): Promise<CreateEventResponse> {
-  const data: RequestData = { analysis, name };
-  if (category) {
-    data.category = category;
-  }
-  if (description) {
-    data.description = description;
-  }
+export function createEvent(data: {
+  analysis: string;
+  name: string;
+  category?: string;
+  description?: string;
+}): Promise<CreateEventResponse> {
   return postJSON('/api/project_analyses/create_event', data).then(
     (r) => r.event,
     throwGlobalError,
@@ -77,18 +100,11 @@ export function deleteEvent(event: string): Promise<void | Response> {
   return post('/api/project_analyses/delete_event', { event }).catch(throwGlobalError);
 }
 
-export function changeEvent(
-  event: string,
-  name?: string,
-  description?: string,
-): Promise<CreateEventResponse> {
-  const data: RequestData = { event };
-  if (name) {
-    data.name = name;
-  }
-  if (description) {
-    data.description = description;
-  }
+export function changeEvent(data: {
+  event: string;
+  name?: string;
+  description?: string;
+}): Promise<CreateEventResponse> {
   return postJSON('/api/project_analyses/update_event', data).then(
     (r) => r.event,
     throwGlobalError,
index 684045c715630f49a468c48bb4c6947c7cefde7f..746f8b9e56cd42436ec4fca646a0790d7b270d5d 100644 (file)
@@ -33,7 +33,7 @@ export interface TimeMachineResponse {
 
 export function getTimeMachineData(
   data: {
-    component: string;
+    component?: string;
     from?: string;
     metrics: string;
     p?: number;
@@ -46,7 +46,7 @@ export function getTimeMachineData(
 
 export function getAllTimeMachineData(
   data: {
-    component: string;
+    component?: string;
     metrics: string;
     from?: string;
     p?: number;
index 90cc157cdaced071713a7cef5ec0c1b9d607894f..5fc508076e6ca633551f8ad63ea9c88340e6e20b 100644 (file)
@@ -19,7 +19,7 @@
  */
 import * as React from 'react';
 import { getWrappedDisplayName } from '../../../components/hoc/utils';
-import { ComponentContextShape } from '../../../types/component';
+import { ComponentContextShape, ComponentQualifier } from '../../../types/component';
 import { ComponentContext } from './ComponentContext';
 
 export default function withComponentContext<P extends Partial<ComponentContextShape>>(
@@ -43,3 +43,32 @@ export default function withComponentContext<P extends Partial<ComponentContextS
 export function useComponent() {
   return React.useContext(ComponentContext);
 }
+
+export function useTopLevelComponentKey() {
+  const { component } = useComponent();
+
+  const componentKey = React.useMemo(() => {
+    if (!component) {
+      return undefined;
+    }
+
+    let current = component.breadcrumbs.length - 1;
+
+    while (
+      current > 0 &&
+      !(
+        [
+          ComponentQualifier.Project,
+          ComponentQualifier.Portfolio,
+          ComponentQualifier.Application,
+        ] as string[]
+      ).includes(component.breadcrumbs[current].qualifier)
+    ) {
+      current--;
+    }
+
+    return component.breadcrumbs[current].key;
+  }, [component]);
+
+  return componentKey;
+}
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/__tests__/__snapshots__/actions-test.ts.snap b/server/sonar-web/src/main/js/apps/projectActivity/__tests__/__snapshots__/actions-test.ts.snap
deleted file mode 100644 (file)
index 27cba95..0000000
+++ /dev/null
@@ -1,85 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`addCustomEvent should correctly add a custom event 1`] = `
-{
-  "date": 2016-10-27T10:21:15.000Z,
-  "events": [
-    {
-      "category": "OTHER",
-      "key": "Enew",
-      "name": "Foo",
-    },
-  ],
-  "key": "A2",
-}
-`;
-
-exports[`changeEvent should correctly update an event 1`] = `
-{
-  "date": 2016-10-27T14:33:50.000Z,
-  "events": [
-    {
-      "category": "VERSION",
-      "key": "E1",
-      "name": "changed",
-    },
-  ],
-  "key": "A1",
-}
-`;
-
-exports[`deleteAnalysis should correctly delete an analyses 1`] = `
-[
-  {
-    "date": 2016-10-27T10:21:15.000Z,
-    "events": [],
-    "key": "A2",
-  },
-  {
-    "date": 2016-10-26T10:17:29.000Z,
-    "events": [
-      {
-        "category": "OTHER",
-        "key": "E2",
-        "name": "foo",
-      },
-      {
-        "category": "OTHER",
-        "key": "E3",
-        "name": "foo",
-      },
-    ],
-    "key": "A3",
-  },
-]
-`;
-
-exports[`deleteEvent should correctly remove an event 1`] = `
-{
-  "date": 2016-10-27T14:33:50.000Z,
-  "events": [],
-  "key": "A1",
-}
-`;
-
-exports[`deleteEvent should correctly remove an event 2`] = `
-{
-  "date": 2016-10-27T10:21:15.000Z,
-  "events": [],
-  "key": "A2",
-}
-`;
-
-exports[`deleteEvent should correctly remove an event 3`] = `
-{
-  "date": 2016-10-26T10:17:29.000Z,
-  "events": [
-    {
-      "category": "OTHER",
-      "key": "E3",
-      "name": "foo",
-    },
-  ],
-  "key": "A3",
-}
-`;
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/__tests__/actions-test.ts b/server/sonar-web/src/main/js/apps/projectActivity/__tests__/actions-test.ts
deleted file mode 100644 (file)
index dbcb2b5..0000000
+++ /dev/null
@@ -1,126 +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 { DEFAULT_GRAPH } from '../../../components/activity-graph/utils';
-import { parseDate } from '../../../helpers/dates';
-import { ProjectAnalysisEventCategory } from '../../../types/project-activity';
-import * as actions from '../actions';
-
-const ANALYSES = [
-  {
-    key: 'A1',
-    date: parseDate('2016-10-27T16:33:50+0200'),
-    events: [
-      {
-        key: 'E1',
-        category: ProjectAnalysisEventCategory.Version,
-        name: '6.5-SNAPSHOT',
-      },
-    ],
-  },
-  {
-    key: 'A2',
-    date: parseDate('2016-10-27T12:21:15+0200'),
-    events: [],
-  },
-  {
-    key: 'A3',
-    date: parseDate('2016-10-26T12:17:29+0200'),
-    events: [
-      {
-        key: 'E2',
-        category: ProjectAnalysisEventCategory.Other,
-        name: 'foo',
-      },
-      {
-        key: 'E3',
-        category: ProjectAnalysisEventCategory.Other,
-        name: 'foo',
-      },
-    ],
-  },
-];
-
-const newEvent = {
-  key: 'Enew',
-  name: 'Foo',
-  category: ProjectAnalysisEventCategory.Other,
-};
-
-const emptyState = {
-  analyses: [],
-  analysesLoading: false,
-  graphLoading: false,
-  initialized: true,
-  measuresHistory: [],
-  measures: [],
-  metrics: [],
-  query: { category: '', graph: DEFAULT_GRAPH, project: '', customMetrics: [] },
-};
-
-const state = { ...emptyState, analyses: ANALYSES };
-
-it('should never throw when there is no analyses', () => {
-  expect(actions.addCustomEvent('A1', newEvent)(emptyState).analyses).toHaveLength(0);
-  expect(actions.deleteEvent('A1', 'Enew')(emptyState).analyses).toHaveLength(0);
-  expect(actions.changeEvent('A1', newEvent)(emptyState).analyses).toHaveLength(0);
-  expect(actions.deleteAnalysis('Anew')(emptyState).analyses).toHaveLength(0);
-});
-
-describe('addCustomEvent', () => {
-  it('should correctly add a custom event', () => {
-    expect(actions.addCustomEvent('A2', newEvent)(state).analyses[1]).toMatchSnapshot();
-    expect(actions.addCustomEvent('A1', newEvent)(state).analyses[0].events).toContain(newEvent);
-  });
-});
-
-describe('deleteEvent', () => {
-  it('should correctly remove an event', () => {
-    expect(actions.deleteEvent('A1', 'E1')(state).analyses[0]).toMatchSnapshot();
-    expect(actions.deleteEvent('A2', 'E1')(state).analyses[1]).toMatchSnapshot();
-    expect(actions.deleteEvent('A3', 'E2')(state).analyses[2]).toMatchSnapshot();
-  });
-});
-
-describe('changeEvent', () => {
-  it('should correctly update an event', () => {
-    expect(
-      actions.changeEvent('A1', {
-        key: 'E1',
-        name: 'changed',
-        category: ProjectAnalysisEventCategory.Version,
-      })(state).analyses[0],
-    ).toMatchSnapshot();
-    expect(
-      actions.changeEvent('A2', {
-        key: 'E2',
-        name: 'foo',
-        category: ProjectAnalysisEventCategory.Version,
-      })(state).analyses[1].events,
-    ).toHaveLength(0);
-  });
-});
-
-describe('deleteAnalysis', () => {
-  it('should correctly delete an analyses', () => {
-    expect(actions.deleteAnalysis('A1')(state).analyses).toMatchSnapshot();
-    expect(actions.deleteAnalysis('A5')(state).analyses).toHaveLength(3);
-    expect(actions.deleteAnalysis('A2')(state).analyses).toHaveLength(2);
-  });
-});
diff --git a/server/sonar-web/src/main/js/apps/projectActivity/actions.ts b/server/sonar-web/src/main/js/apps/projectActivity/actions.ts
deleted file mode 100644 (file)
index 7856300..0000000
+++ /dev/null
@@ -1,63 +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 { AnalysisEvent } from '../../types/project-activity';
-import { State } from './components/ProjectActivityApp';
-
-export function addCustomEvent(analysis: string, event: AnalysisEvent) {
-  return (state: State) => ({
-    analyses: state.analyses.map((item) => {
-      if (item.key !== analysis) {
-        return item;
-      }
-      return { ...item, events: [...item.events, event] };
-    }),
-  });
-}
-
-export function deleteEvent(analysis: string, event: string) {
-  return (state: State) => ({
-    analyses: state.analyses.map((item) => {
-      if (item.key !== analysis) {
-        return item;
-      }
-      return { ...item, events: item.events.filter((eventItem) => eventItem.key !== event) };
-    }),
-  });
-}
-
-export function changeEvent(analysis: string, event: AnalysisEvent) {
-  return (state: State) => ({
-    analyses: state.analyses.map((item) => {
-      if (item.key !== analysis) {
-        return item;
-      }
-      return {
-        ...item,
-        events: item.events.map((eventItem) =>
-          eventItem.key === event.key ? { ...eventItem, ...event } : eventItem,
-        ),
-      };
-    }),
-  });
-}
-
-export function deleteAnalysis(analysis: string) {
-  return (state: State) => ({ analyses: state.analyses.filter((item) => item.key !== analysis) });
-}
index 1e0451c928660f9573d60db30b13d05caa5b3458..3dac2d41bdeef9446459f3b56c3cf8b2ccbd1e58 100644 (file)
@@ -31,11 +31,9 @@ export interface EventProps {
   canAdmin?: boolean;
   event: AnalysisEvent;
   isFirst?: boolean;
-  onChange?: (event: string, name: string) => Promise<void>;
-  onDelete?: (analysisKey: string, event: string) => Promise<void>;
 }
 
-function Event(props: EventProps) {
+function Event(props: Readonly<EventProps>) {
   const { analysisKey, event, canAdmin, isFirst } = props;
 
   const [changing, setChanging] = React.useState(false);
@@ -43,8 +41,8 @@ function Event(props: EventProps) {
 
   const isOther = event.category === ProjectAnalysisEventCategory.Other;
   const isVersion = event.category === ProjectAnalysisEventCategory.Version;
-  const canChange = (isOther || isVersion) && props.onChange;
-  const canDelete = (isOther || (isVersion && !isFirst)) && props.onDelete;
+  const canChange = isOther || isVersion;
+  const canDelete = isOther || (isVersion && !isFirst);
   const showActions = canAdmin && (canChange || canDelete);
 
   return (
@@ -80,9 +78,8 @@ function Event(props: EventProps) {
         </div>
       )}
 
-      {changing && props.onChange && (
+      {changing && (
         <ChangeEventForm
-          changeEvent={props.onChange}
           event={event}
           header={
             isVersion
@@ -93,7 +90,7 @@ function Event(props: EventProps) {
         />
       )}
 
-      {deleting && props.onDelete && (
+      {deleting && (
         <RemoveEventForm
           analysisKey={analysisKey}
           event={event}
@@ -103,7 +100,6 @@ function Event(props: EventProps) {
               : translate('project_activity.remove_custom_event')
           }
           onClose={() => setDeleting(false)}
-          onConfirm={props.onDelete}
           removeEventQuestion={translate(
             `project_activity.${isVersion ? 'remove_version' : 'remove_custom_event'}.question`,
           )}
index ab1c6f7ced069d68f6fa7a11789b7187088722f0..bd7940fa931c17ac544c5c2e5b5f95e7f44c13c4 100644 (file)
@@ -27,8 +27,6 @@ export interface EventsProps {
   canAdmin?: boolean;
   events: AnalysisEvent[];
   isFirst?: boolean;
-  onChange?: (event: string, name: string) => Promise<void>;
-  onDelete?: (analysis: string, event: string) => Promise<void>;
 }
 
 function Events(props: EventsProps) {
@@ -61,8 +59,6 @@ function Events(props: EventsProps) {
           event={event}
           isFirst={isFirst}
           key={event.key}
-          onChange={props.onChange}
-          onDelete={props.onDelete}
         />
       ))}
     </div>
index 5e4a734910d59a1ea1807045cfdb3d1a8dac8ca1..1a41029936bc2e15ac56829847d711126b215794 100644 (file)
@@ -33,15 +33,10 @@ import { AnalysesByDay, Query, activityQueryChanged, getAnalysesByVersionByDay }
 import ProjectActivityAnalysis, { BaselineMarker } from './ProjectActivityAnalysis';
 
 interface Props {
-  onAddCustomEvent: (analysis: string, name: string, category?: string) => Promise<void>;
-  onAddVersion: (analysis: string, version: string) => Promise<void>;
   analyses: ParsedAnalysis[];
   analysesLoading: boolean;
   canAdmin?: boolean;
   canDeleteAnalyses?: boolean;
-  onChangeEvent: (event: string, name: string) => Promise<void>;
-  onDeleteAnalysis: (analysis: string) => Promise<void>;
-  onDeleteEvent: (analysis: string, event: string) => Promise<void>;
   initializing: boolean;
   leakPeriodDate?: Date;
   project: { qualifier: string };
@@ -110,15 +105,10 @@ export default class ProjectActivityAnalysesList extends React.PureComponent<Pro
 
     return (
       <ProjectActivityAnalysis
-        onAddCustomEvent={this.props.onAddCustomEvent}
-        onAddVersion={this.props.onAddVersion}
         analysis={analysis}
         canAdmin={this.props.canAdmin}
         canCreateVersion={this.props.project.qualifier === ComponentQualifier.Project}
         canDeleteAnalyses={this.props.canDeleteAnalyses}
-        onChangeEvent={this.props.onChangeEvent}
-        onDeleteAnalysis={this.props.onDeleteAnalysis}
-        onDeleteEvent={this.props.onDeleteEvent}
         isBaseline={analysis.key === newCodeKey}
         isFirst={analysis.key === firstAnalysisKey}
         key={analysis.key}
index 7d7744d5a82fb7cf5b3a1f92c0da3924da8c077e..27223214eeefa1a96a030dd4759381bb4fb849dc 100644 (file)
@@ -37,27 +37,28 @@ import { formatterOption } from '../../../components/intl/DateTimeFormatter';
 import TimeFormatter from '../../../components/intl/TimeFormatter';
 import { parseDate } from '../../../helpers/dates';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { ParsedAnalysis } from '../../../types/project-activity';
+import { ParsedAnalysis, ProjectAnalysisEventCategory } from '../../../types/project-activity';
 import Events from './Events';
 import AddEventForm from './forms/AddEventForm';
 import RemoveAnalysisForm from './forms/RemoveAnalysisForm';
 
 export interface ProjectActivityAnalysisProps extends WrappedComponentProps {
-  onAddCustomEvent: (analysis: string, name: string, category?: string) => Promise<void>;
-  onAddVersion: (analysis: string, version: string) => Promise<void>;
   analysis: ParsedAnalysis;
   canAdmin?: boolean;
   canDeleteAnalyses?: boolean;
   canCreateVersion: boolean;
-  onChangeEvent: (event: string, name: string) => Promise<void>;
-  onDeleteAnalysis: (analysis: string) => Promise<void>;
-  onDeleteEvent: (analysis: string, event: string) => Promise<void>;
   isBaseline: boolean;
   isFirst: boolean;
   selected: boolean;
   onUpdateSelectedDate: (date: Date) => void;
 }
 
+export enum Dialog {
+  AddEvent = 'add_event',
+  AddVersion = 'add_version',
+  RemoveAnalysis = 'remove_analysis',
+}
+
 function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) {
   let node: HTMLLIElement | null = null;
 
@@ -77,9 +78,8 @@ function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) {
     }
   });
 
-  const [addEventForm, setAddEventForm] = React.useState(false);
-  const [addVersionForm, setAddVersionForm] = React.useState(false);
-  const [removeAnalysisForm, setRemoveAnalysisForm] = React.useState(false);
+  const [dialog, setDialog] = React.useState<Dialog | undefined>();
+  const closeDialog = () => setDialog(undefined);
 
   const parsedDate = parseDate(analysis.date);
   const hasVersion = analysis.events.find((event) => event.category === 'VERSION') != null;
@@ -113,7 +113,11 @@ function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) {
             'project_activity.show_analysis_X_on_graph',
             analysis.buildString ?? formatDate(parsedDate, formatterOption),
           )}
-          onClick={() => props.onUpdateSelectedDate(analysis.date)}
+          onClick={() => {
+            if (!selected) {
+              props.onUpdateSelectedDate(analysis.date);
+            }
+          }}
           ref={(ref) => (node = ref)}
         >
           <div className="it__project-activity-time">
@@ -139,12 +143,15 @@ function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) {
                   zLevel={PopupZLevel.Absolute}
                 >
                   {canAddVersion && (
-                    <ItemButton className="js-add-version" onClick={() => setAddVersionForm(true)}>
+                    <ItemButton
+                      className="js-add-version"
+                      onClick={() => setDialog(Dialog.AddVersion)}
+                    >
                       {translate('project_activity.add_version')}
                     </ItemButton>
                   )}
                   {canAddEvent && (
-                    <ItemButton className="js-add-event" onClick={() => setAddEventForm(true)}>
+                    <ItemButton className="js-add-event" onClick={() => setDialog(Dialog.AddEvent)}>
                       {translate('project_activity.add_custom_event')}
                     </ItemButton>
                   )}
@@ -152,37 +159,32 @@ function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) {
                   {canDeleteAnalyses && (
                     <ItemDangerButton
                       className="js-delete-analysis"
-                      onClick={() => setRemoveAnalysisForm(true)}
+                      onClick={() => setDialog(Dialog.RemoveAnalysis)}
                     >
                       {translate('project_activity.delete_analysis')}
                     </ItemDangerButton>
                   )}
                 </ActionsDropdown>
 
-                {addVersionForm && (
-                  <AddEventForm
-                    addEvent={props.onAddVersion}
-                    addEventButtonText="project_activity.add_version"
-                    analysis={analysis}
-                    onClose={() => setAddVersionForm(false)}
-                  />
-                )}
-
-                {addEventForm && (
+                {[Dialog.AddEvent, Dialog.AddVersion].includes(dialog as Dialog) && (
                   <AddEventForm
-                    addEvent={props.onAddCustomEvent}
-                    addEventButtonText="project_activity.add_custom_event"
+                    category={
+                      dialog === Dialog.AddVersion
+                        ? ProjectAnalysisEventCategory.Version
+                        : undefined
+                    }
+                    addEventButtonText={
+                      dialog === Dialog.AddVersion
+                        ? 'project_activity.add_version'
+                        : 'project_activity.add_custom_event'
+                    }
                     analysis={analysis}
-                    onClose={() => setAddEventForm(false)}
+                    onClose={closeDialog}
                   />
                 )}
 
-                {removeAnalysisForm && (
-                  <RemoveAnalysisForm
-                    analysis={analysis}
-                    deleteAnalysis={props.onDeleteAnalysis}
-                    onClose={() => setRemoveAnalysisForm(false)}
-                  />
+                {dialog === 'remove_analysis' && (
+                  <RemoveAnalysisForm analysis={analysis} onClose={closeDialog} />
                 )}
               </div>
             </ClickEventBoundary>
@@ -194,8 +196,6 @@ function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) {
               canAdmin={canAdmin}
               events={analysis.events}
               isFirst={isFirst}
-              onChange={props.onChangeEvent}
-              onDelete={props.onDeleteEvent}
             />
           )}
         </ActivityAnalysisListItem>
index 92422ac772673ae4e586b7bd40f4021dd2192e16..b8f83ead011d6b9b9a89abd80a94f2d08704ab77 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 * as React from 'react';
-import { useSearchParams } from 'react-router-dom';
-import { getApplicationLeak } from '../../../api/application';
+import React from 'react';
 import {
-  ProjectActivityStatuses,
-  changeEvent,
-  createEvent,
-  deleteAnalysis,
-  deleteEvent,
-  getProjectActivity,
-} from '../../../api/projectActivity';
-import { getAllTimeMachineData } from '../../../api/time-machine';
-import withComponentContext from '../../../app/components/componentContext/withComponentContext';
-import withMetricsContext from '../../../app/components/metrics/withMetricsContext';
+  useComponent,
+  useTopLevelComponentKey,
+} from '../../../app/components/componentContext/withComponentContext';
+import { useMetrics } from '../../../app/components/metrics/withMetricsContext';
 import {
   DEFAULT_GRAPH,
   getActivityGraph,
   getHistoryMetrics,
   isCustomGraph,
 } from '../../../components/activity-graph/utils';
-import { Location, Router, withRouter } from '../../../components/hoc/withRouter';
+import { useLocation, useRouter } from '../../../components/hoc/withRouter';
 import { getBranchLikeQuery } from '../../../helpers/branch-like';
 import { HIDDEN_METRICS } from '../../../helpers/constants';
 import { parseDate } from '../../../helpers/dates';
-import { serializeStringArray } from '../../../helpers/query';
-import { WithBranchLikesProps, withBranchLikes } from '../../../queries/branch';
-import {
-  ComponentQualifier,
-  isApplication,
-  isPortfolioLike,
-  isProject,
-} from '../../../types/component';
+import useApplicationLeakQuery from '../../../queries/applications';
+import { useBranchesQuery } from '../../../queries/branch';
+import { useAllMeasuresHistoryQuery } from '../../../queries/measures';
+import { useAllProjectAnalysesQuery } from '../../../queries/project-analyses';
+import { isApplication, isPortfolioLike, isProject } from '../../../types/component';
 import { MetricKey } from '../../../types/metrics';
-import {
-  GraphType,
-  MeasureHistory,
-  ParsedAnalysis,
-  ProjectAnalysisEventCategory,
-} from '../../../types/project-activity';
-import { Component, Dict, Metric, Paging, RawQuery } from '../../../types/types';
-import * as actions from '../actions';
-import {
-  Query,
-  customMetricsChanged,
-  parseQuery,
-  serializeQuery,
-  serializeUrlQuery,
-} from '../utils';
+import { MeasureHistory, ParsedAnalysis } from '../../../types/project-activity';
+import { Query, parseQuery, serializeUrlQuery } from '../utils';
 import ProjectActivityAppRenderer from './ProjectActivityAppRenderer';
 
-interface Props extends WithBranchLikesProps {
-  component: Component;
-  location: Location;
-  metrics: Dict<Metric>;
-  router: Router;
-}
-
 export interface State {
   analyses: ParsedAnalysis[];
   analysesLoading: boolean;
@@ -87,233 +55,58 @@ export interface State {
 
 export const PROJECT_ACTIVITY_GRAPH = 'sonar_project_activity.graph';
 
-const ACTIVITY_PAGE_SIZE_FIRST_BATCH = 100;
-const ACTIVITY_PAGE_SIZE = 500;
-
-class ProjectActivityApp extends React.PureComponent<Props, State> {
-  mounted = false;
-
-  constructor(props: Props) {
-    super(props);
-
-    this.state = {
-      analyses: [],
-      analysesLoading: false,
-      graphLoading: true,
-      initialized: false,
-      measuresHistory: [],
-      query: parseQuery(props.location.query),
-    };
-  }
-
-  componentDidMount() {
-    this.mounted = true;
-
-    if (this.isBranchReady()) {
-      this.firstLoadData(this.state.query, this.props.component);
-    }
-  }
-
-  componentDidUpdate(prevProps: Props) {
-    const unparsedQuery = this.props.location.query;
-
-    const hasQueryChanged = prevProps.location.query !== unparsedQuery;
-
-    const wasBranchJustFetched = !!prevProps.isFetchingBranch && !this.props.isFetchingBranch;
-
-    if (this.isBranchReady() && (hasQueryChanged || wasBranchJustFetched)) {
-      const query = parseQuery(unparsedQuery);
-
-      if (
-        query.graph !== this.state.query.graph ||
-        customMetricsChanged(this.state.query, query) ||
-        wasBranchJustFetched
-      ) {
-        if (this.state.initialized) {
-          this.updateGraphData(query.graph || DEFAULT_GRAPH, query.customMetrics);
-        } else {
-          this.firstLoadData(query, this.props.component);
-        }
-      }
-
-      this.setState({ query });
-    }
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-  }
-
-  isBranchReady = () =>
-    isPortfolioLike(this.props.component.qualifier) ||
-    (this.props.branchLike !== undefined && !this.props.isFetchingBranch);
-
-  handleAddCustomEvent = (analysisKey: string, name: string, category?: string) => {
-    return createEvent(analysisKey, name, category).then(({ analysis, ...event }) => {
-      if (this.mounted) {
-        this.setState(actions.addCustomEvent(analysis, event));
-      }
-    });
-  };
-
-  handleAddVersion = (analysis: string, version: string) => {
-    return this.handleAddCustomEvent(analysis, version, ProjectAnalysisEventCategory.Version);
-  };
-
-  handleChangeEvent = (eventKey: string, name: string) => {
-    return changeEvent(eventKey, name).then(({ analysis, ...event }) => {
-      if (this.mounted) {
-        this.setState(actions.changeEvent(analysis, event));
-      }
-    });
-  };
-
-  handleDeleteAnalysis = (analysis: string) => {
-    return deleteAnalysis(analysis).then(() => {
-      if (this.mounted) {
-        this.updateGraphData(
-          this.state.query.graph || DEFAULT_GRAPH,
-          this.state.query.customMetrics,
-        );
-
-        this.setState(actions.deleteAnalysis(analysis));
-      }
-    });
-  };
-
-  handleDeleteEvent = (analysis: string, event: string) => {
-    return deleteEvent(event).then(() => {
-      if (this.mounted) {
-        this.setState(actions.deleteEvent(analysis, event));
-      }
-    });
-  };
-
-  fetchActivity = (
-    project: string,
-    statuses: ProjectActivityStatuses[],
-    p: number,
-    ps: number,
-    additional?: RawQuery,
-  ) => {
-    const parameters = {
-      project,
-      statuses: serializeStringArray(statuses),
-      p,
-      ps,
-      ...getBranchLikeQuery(this.props.branchLike),
-    };
-
-    return getProjectActivity({ ...additional, ...parameters }).then(({ analyses, paging }) => ({
-      analyses: analyses.map((analysis) => ({
-        ...analysis,
-        date: parseDate(analysis.date),
-      })) as ParsedAnalysis[],
-
-      paging,
-    }));
-  };
-
-  fetchMeasuresHistory = (metrics: string[]): Promise<MeasureHistory[]> => {
-    if (metrics.length <= 0) {
-      return Promise.resolve([]);
-    }
-
-    return getAllTimeMachineData({
-      component: this.props.component.key,
-      metrics: metrics.join(),
-      ...getBranchLikeQuery(this.props.branchLike),
-    }).then(({ measures }) =>
-      measures.map((measure) => ({
+export function ProjectActivityApp() {
+  const { query, pathname } = useLocation();
+  const parsedQuery = parseQuery(query);
+  const router = useRouter();
+  const { component } = useComponent();
+  const metrics = useMetrics();
+  const { data: { branchLike } = {}, isFetching: isFetchingBranch } = useBranchesQuery(component);
+  const enabled =
+    component?.key !== undefined &&
+    (isPortfolioLike(component?.qualifier) || (Boolean(branchLike) && !isFetchingBranch));
+
+  const componentKey = useTopLevelComponentKey();
+  const { data: appLeaks } = useApplicationLeakQuery(
+    componentKey ?? '',
+    isApplication(component?.qualifier),
+  );
+
+  const { data: analysesData, isLoading: isLoadingAnalyses } = useAllProjectAnalysesQuery(enabled);
+
+  const { data: historyData, isLoading: isLoadingHistory } = useAllMeasuresHistoryQuery(
+    componentKey,
+    getBranchLikeQuery(branchLike),
+    getHistoryMetrics(query.graph || DEFAULT_GRAPH, parsedQuery.customMetrics).join(','),
+    enabled,
+  );
+
+  const analyses = React.useMemo(() => analysesData ?? [], [analysesData]);
+
+  const measuresHistory = React.useMemo(
+    () =>
+      historyData?.measures?.map((measure) => ({
         metric: measure.metric,
-
-        history: measure.history.map((analysis) => ({
-          date: parseDate(analysis.date),
-          value: analysis.value,
+        history: measure.history.map((historyItem) => ({
+          date: parseDate(historyItem.date),
+          value: historyItem.value,
         })),
-      })),
-    );
-  };
-
-  fetchAllActivities = (topLevelComponent: string) => {
-    this.setState({ analysesLoading: true });
-
-    this.loadAllActivities(topLevelComponent).then(
-      ({ analyses }) => {
-        if (this.mounted) {
-          this.setState({
-            analyses,
-            analysesLoading: false,
-          });
-        }
-      },
-      () => {
-        if (this.mounted) {
-          this.setState({ analysesLoading: false });
-        }
-      },
-    );
-  };
-
-  loadAllActivities = (
-    project: string,
-    prevResult?: { analyses: ParsedAnalysis[]; paging: Paging },
-  ): Promise<{ analyses: ParsedAnalysis[]; paging: Paging }> => {
-    if (
-      prevResult &&
-      prevResult.paging.pageIndex * prevResult.paging.pageSize >= prevResult.paging.total
-    ) {
-      return Promise.resolve(prevResult);
-    }
-
-    const nextPage = prevResult ? prevResult.paging.pageIndex + 1 : 1;
-
-    return this.fetchActivity(
-      project,
-      [
-        ProjectActivityStatuses.STATUS_PROCESSED,
-        ProjectActivityStatuses.STATUS_LIVE_MEASURE_COMPUTE,
-      ],
-      nextPage,
-      ACTIVITY_PAGE_SIZE,
-    ).then((result) => {
-      if (!prevResult) {
-        return this.loadAllActivities(project, result);
-      }
-
-      return this.loadAllActivities(project, {
-        analyses: prevResult.analyses.concat(result.analyses),
-        paging: result.paging,
-      });
-    });
-  };
-
-  getTopLevelComponent = (component: Component) => {
-    let current = component.breadcrumbs.length - 1;
-
-    while (
-      current > 0 &&
-      !(
-        [
-          ComponentQualifier.Project,
-          ComponentQualifier.Portfolio,
-          ComponentQualifier.Application,
-        ] as string[]
-      ).includes(component.breadcrumbs[current].qualifier)
-    ) {
-      current--;
+      })) ?? [],
+    [historyData],
+  );
+
+  const leakPeriodDate = React.useMemo(() => {
+    if (appLeaks?.[0]) {
+      return parseDate(appLeaks[0].date);
+    } else if (isProject(component?.qualifier) && component?.leakPeriodDate !== undefined) {
+      return parseDate(component.leakPeriodDate);
     }
 
-    return component.breadcrumbs[current].key;
-  };
-
-  filterMetrics = () => {
-    const {
-      component: { qualifier },
-      metrics,
-    } = this.props;
+    return undefined;
+  }, [appLeaks, component?.leakPeriodDate, component?.qualifier]);
 
-    if (isPortfolioLike(qualifier)) {
+  const filteredMetrics = React.useMemo(() => {
+    if (isPortfolioLike(component?.qualifier)) {
       return Object.values(metrics).filter(
         (metric) => metric.key !== MetricKey.security_hotspots_reviewed,
       );
@@ -323,138 +116,57 @@ class ProjectActivityApp extends React.PureComponent<Props, State> {
       (metric) =>
         ![...HIDDEN_METRICS, MetricKey.security_review_rating].includes(metric.key as MetricKey),
     );
-  };
-
-  async firstLoadData(query: Query, component: Component) {
-    const graphMetrics = getHistoryMetrics(query.graph || DEFAULT_GRAPH, query.customMetrics);
-    const topLevelComponent = this.getTopLevelComponent(component);
-
-    try {
-      const [{ analyses }, measuresHistory, leaks] = await Promise.all([
-        this.fetchActivity(
-          topLevelComponent,
-          [
-            ProjectActivityStatuses.STATUS_PROCESSED,
-            ProjectActivityStatuses.STATUS_LIVE_MEASURE_COMPUTE,
-          ],
-          1,
-          ACTIVITY_PAGE_SIZE_FIRST_BATCH,
-          serializeQuery(query),
-        ),
+  }, [component?.qualifier, metrics]);
 
-        this.fetchMeasuresHistory(graphMetrics),
-
-        component.qualifier === ComponentQualifier.Application
-          ? // eslint-disable-next-line local-rules/no-api-imports
-            getApplicationLeak(component.key)
-          : undefined,
-      ]);
-
-      if (this.mounted) {
-        let leakPeriodDate;
-
-        if (isApplication(component.qualifier) && leaks?.length) {
-          [leakPeriodDate] = leaks
-            .map((leak) => parseDate(leak.date))
-            .sort((d1, d2) => d2.getTime() - d1.getTime());
-        } else if (isProject(component.qualifier) && component.leakPeriodDate) {
-          leakPeriodDate = parseDate(component.leakPeriodDate);
-        }
-
-        this.setState({
-          analyses,
-          graphLoading: false,
-          initialized: true,
-          leakPeriodDate,
-          measuresHistory,
-        });
-
-        this.fetchAllActivities(topLevelComponent);
-      }
-    } catch (error) {
-      if (this.mounted) {
-        this.setState({ initialized: true, graphLoading: false });
-      }
-    }
-  }
-
-  updateGraphData = (graph: GraphType, customMetrics: string[]) => {
-    const graphMetrics = getHistoryMetrics(graph, customMetrics);
-    this.setState({ graphLoading: true });
-
-    this.fetchMeasuresHistory(graphMetrics).then(
-      (measuresHistory) => {
-        if (this.mounted) {
-          this.setState({ graphLoading: false, measuresHistory });
-        }
-      },
-      () => {
-        if (this.mounted) {
-          this.setState({ graphLoading: false, measuresHistory: [] });
-        }
-      },
-    );
-  };
-
-  handleUpdateQuery = (newQuery: Query) => {
-    const query = serializeUrlQuery({
-      ...this.state.query,
+  const handleUpdateQuery = (newQuery: Query) => {
+    const q = serializeUrlQuery({
+      ...parsedQuery,
       ...newQuery,
     });
 
-    this.props.router.push({
-      pathname: this.props.location.pathname,
+    router.push({
+      pathname,
       query: {
-        ...query,
-        ...getBranchLikeQuery(this.props.branchLike),
-        id: this.props.component.key,
+        ...q,
+        ...getBranchLikeQuery(branchLike),
+        id: component?.key,
       },
     });
   };
 
-  render() {
-    const metrics = this.filterMetrics();
-
-    return (
+  return (
+    component && (
       <ProjectActivityAppRenderer
-        onAddCustomEvent={this.handleAddCustomEvent}
-        onAddVersion={this.handleAddVersion}
-        analyses={this.state.analyses}
-        analysesLoading={this.state.analysesLoading}
-        onChangeEvent={this.handleChangeEvent}
-        onDeleteAnalysis={this.handleDeleteAnalysis}
-        onDeleteEvent={this.handleDeleteEvent}
-        graphLoading={!this.state.initialized || this.state.graphLoading}
-        leakPeriodDate={this.state.leakPeriodDate}
-        initializing={!this.state.initialized}
-        measuresHistory={this.state.measuresHistory}
-        metrics={metrics}
-        project={this.props.component}
-        query={this.state.query}
-        onUpdateQuery={this.handleUpdateQuery}
+        analyses={analyses}
+        analysesLoading={isLoadingAnalyses}
+        graphLoading={isLoadingHistory}
+        leakPeriodDate={leakPeriodDate}
+        initializing={isLoadingAnalyses || isLoadingHistory}
+        measuresHistory={measuresHistory}
+        metrics={filteredMetrics}
+        project={component}
+        onUpdateQuery={handleUpdateQuery}
+        query={parsedQuery}
       />
-    );
-  }
+    )
+  );
 }
 
-const isFiltered = (searchParams: URLSearchParams) => {
-  let filtered = false;
+export default function RedirectWrapper() {
+  const { query } = useLocation();
+  const { component } = useComponent();
+  const router = useRouter();
 
-  searchParams.forEach((value, key) => {
-    if (key !== 'id' && value !== '') {
-      filtered = true;
+  const filtered = React.useMemo(() => {
+    for (const key in query) {
+      if (key !== 'id' && query[key] !== '') {
+        return true;
+      }
     }
-  });
+    return false;
+  }, [query]);
 
-  return filtered;
-};
-
-function RedirectWrapper(props: Props) {
-  const [searchParams, setSearchParams] = useSearchParams();
-
-  const filtered = isFiltered(searchParams);
-
-  const { graph, customGraphs } = getActivityGraph(PROJECT_ACTIVITY_GRAPH, props.component.key);
+  const { graph, customGraphs } = getActivityGraph(PROJECT_ACTIVITY_GRAPH, component?.key ?? '');
   const emptyCustomGraph = isCustomGraph(graph) && customGraphs.length <= 0;
 
   // if there is no filter, but there are saved preferences in the localStorage
@@ -463,21 +175,15 @@ function RedirectWrapper(props: Props) {
 
   React.useEffect(() => {
     if (shouldRedirect) {
-      const query = parseQuery(searchParams);
       const newQuery = { ...query, graph };
 
       if (isCustomGraph(newQuery.graph)) {
-        searchParams.set('custom_metrics', customGraphs.join(','));
+        router.replace({ query: { ...newQuery, custom_metrics: customGraphs.join(',') } });
+      } else {
+        router.replace({ query: newQuery });
       }
-
-      searchParams.set('graph', graph);
-      setSearchParams(searchParams, { replace: true });
     }
-  }, [customGraphs, graph, searchParams, setSearchParams, shouldRedirect]);
+  }, [shouldRedirect, router, query, graph, customGraphs]);
 
-  return shouldRedirect ? null : <ProjectActivityApp {...props} />;
+  return shouldRedirect ? null : <ProjectActivityApp />;
 }
-
-export default withComponentContext(
-  withRouter(withMetricsContext(withBranchLikes(RedirectWrapper))),
-);
index b83866e4a1d4010d724fae3f2934007f7d04c1e7..4c83ecca72a25ed4c8418e502f46cbd838e0206f 100644 (file)
@@ -28,7 +28,6 @@ import * as React from 'react';
 import { Helmet } from 'react-helmet-async';
 import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget';
 import Suggestions from '../../../components/embed-docs-modal/Suggestions';
-import { parseDate } from '../../../helpers/dates';
 import { translate } from '../../../helpers/l10n';
 import { ComponentQualifier } from '../../../types/component';
 import { MeasureHistory, ParsedAnalysis } from '../../../types/project-activity';
@@ -39,13 +38,8 @@ import ProjectActivityGraphs from './ProjectActivityGraphs';
 import ProjectActivityPageFilters from './ProjectActivityPageFilters';
 
 interface Props {
-  onAddCustomEvent: (analysis: string, name: string, category?: string) => Promise<void>;
-  onAddVersion: (analysis: string, version: string) => Promise<void>;
   analyses: ParsedAnalysis[];
   analysesLoading: boolean;
-  onChangeEvent: (event: string, name: string) => Promise<void>;
-  onDeleteAnalysis: (analysis: string) => Promise<void>;
-  onDeleteEvent: (analysis: string, event: string) => Promise<void>;
   graphLoading: boolean;
   leakPeriodDate?: Date;
   initializing: boolean;
@@ -57,14 +51,22 @@ interface Props {
 }
 
 export default function ProjectActivityAppRenderer(props: Props) {
-  const { analyses, measuresHistory, query } = props;
-  const { configuration } = props.project;
+  const {
+    analyses,
+    measuresHistory,
+    query,
+    leakPeriodDate,
+    analysesLoading,
+    initializing,
+    graphLoading,
+    metrics,
+    project,
+  } = props;
+  const { configuration, qualifier } = props.project;
   const canAdmin =
-    (props.project.qualifier === ComponentQualifier.Project ||
-      props.project.qualifier === ComponentQualifier.Application) &&
-    (configuration ? configuration.showHistory : false);
-  const canDeleteAnalyses = configuration ? configuration.showHistory : false;
-  const leakPeriodDate = props.leakPeriodDate ? parseDate(props.leakPeriodDate) : undefined;
+    (qualifier === ComponentQualifier.Project || qualifier === ComponentQualifier.Application) &&
+    configuration?.showHistory;
+  const canDeleteAnalyses = configuration?.showHistory;
   return (
     <main className="sw-p-5" id="project-activity">
       <Suggestions suggestions="project_activity" />
@@ -84,18 +86,13 @@ export default function ProjectActivityAppRenderer(props: Props) {
           <div className="sw-grid sw-grid-cols-12 sw-gap-x-12">
             <StyledWrapper className="sw-col-span-4 sw-rounded-1">
               <ProjectActivityAnalysesList
-                onAddCustomEvent={props.onAddCustomEvent}
-                onAddVersion={props.onAddVersion}
                 analyses={analyses}
-                analysesLoading={props.analysesLoading}
+                analysesLoading={analysesLoading}
                 canAdmin={canAdmin}
                 canDeleteAnalyses={canDeleteAnalyses}
-                onChangeEvent={props.onChangeEvent}
-                onDeleteAnalysis={props.onDeleteAnalysis}
-                onDeleteEvent={props.onDeleteEvent}
-                initializing={props.initializing}
+                initializing={initializing}
                 leakPeriodDate={leakPeriodDate}
-                project={props.project}
+                project={project}
                 query={query}
                 onUpdateQuery={props.onUpdateQuery}
               />
@@ -104,10 +101,10 @@ export default function ProjectActivityAppRenderer(props: Props) {
               <ProjectActivityGraphs
                 analyses={analyses}
                 leakPeriodDate={leakPeriodDate}
-                loading={props.graphLoading}
+                loading={graphLoading}
                 measuresHistory={measuresHistory}
-                metrics={props.metrics}
-                project={props.project.key}
+                metrics={metrics}
+                project={project.key}
                 query={query}
                 updateQuery={props.onUpdateQuery}
               />
index 1141b833d0b0b3e2c9c767fb21b5251981eae6d7..8666e5ef0bf2070aea053bf5e9d549ede86f98d7 100644 (file)
@@ -101,10 +101,9 @@ describe('rendering', () => {
   it('should render issues as default graph', async () => {
     const { ui } = getPageObject();
     renderProjectActivityAppContainer();
-    await ui.appLoaded();
 
+    await ui.appLoaded();
     expect(ui.graphTypeIssues.get()).toBeInTheDocument();
-    expect(ui.graphs.getAll().length).toBe(1);
   });
 
   it('should render new code legend for applications', async () => {
@@ -119,7 +118,6 @@ describe('rendering', () => {
       }),
     );
     await ui.appLoaded();
-
     expect(ui.newCodeLegend.get()).toBeInTheDocument();
   });
 
@@ -135,8 +133,8 @@ describe('rendering', () => {
         leakPeriodDate: parseDate('2017-03-01T22:00:00.000Z').toDateString(),
       }),
     );
-    await ui.appLoaded();
 
+    await ui.appLoaded();
     expect(ui.newCodeLegend.get()).toBeInTheDocument();
   });
 
@@ -153,7 +151,6 @@ describe('rendering', () => {
       );
 
       await ui.appLoaded({ doNotWaitForBranch: true });
-
       expect(ui.newCodeLegend.query()).not.toBeInTheDocument();
     },
   );
@@ -171,7 +168,6 @@ describe('rendering', () => {
     );
 
     await ui.appLoaded();
-
     expect(ui.baseline.get()).toBeInTheDocument();
   });
 
@@ -188,7 +184,6 @@ describe('rendering', () => {
     );
 
     await ui.appLoaded();
-
     expect(ui.baseline.get()).toBeInTheDocument();
   });
 
@@ -205,7 +200,6 @@ describe('rendering', () => {
     );
 
     await ui.appLoaded();
-
     expect(ui.baseline.query()).not.toBeInTheDocument();
   });
 
@@ -562,9 +556,7 @@ function getPageObject() {
     ui: {
       ...ui,
       async appLoaded({ doNotWaitForBranch }: { doNotWaitForBranch?: boolean } = {}) {
-        await waitFor(() => {
-          expect(ui.loading.query()).not.toBeInTheDocument();
-        });
+        expect(await ui.graphs.findAll()).toHaveLength(1);
 
         if (!doNotWaitForBranch) {
           await waitFor(() => {
index 45ce9e2d0d42a1bc6c15b7799412a15cbf516593..d47cf48b690226b49ae1699d46adedc8a9f88b7e 100644 (file)
 import { ButtonPrimary, InputField, Modal } from 'design-system';
 import * as React from 'react';
 import { translate } from '../../../../helpers/l10n';
+import { useCreateEventMutation } from '../../../../queries/project-analyses';
 import { ParsedAnalysis } from '../../../../types/project-activity';
 
 interface Props {
-  addEvent: (analysis: string, name: string, category?: string) => Promise<void>;
+  category?: string;
   addEventButtonText: string;
   analysis: ParsedAnalysis;
   onClose: () => void;
 }
 
-interface State {
-  name: string;
-}
-
-export default class AddEventForm extends React.PureComponent<Props, State> {
-  state: State = { name: '' };
+export default function AddEventForm(props: Readonly<Props>) {
+  const { addEventButtonText, onClose, analysis, category } = props;
+  const [name, setName] = React.useState('');
+  const { mutate: createEvent } = useCreateEventMutation(onClose);
 
-  handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
-    this.setState({ name: event.target.value });
+  const handleNameChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    setName(event.target.value);
   };
 
-  handleSubmit = (e: React.MouseEvent<HTMLButtonElement>) => {
+  const handleSubmit = (e: React.MouseEvent<HTMLButtonElement>) => {
     e.preventDefault();
-    this.props.addEvent(this.props.analysis.key, this.state.name);
-    this.props.onClose();
+    const data: Parameters<typeof createEvent>[0] = { analysis: analysis.key, name };
+
+    if (category !== undefined) {
+      data.category = category;
+    }
+    createEvent(data);
   };
 
-  render() {
-    return (
-      <Modal
-        headerTitle={translate(this.props.addEventButtonText)}
-        onClose={this.props.onClose}
-        body={
-          <form id="add-event-form">
-            <label htmlFor="name">{translate('name')}</label>
-            <InputField
-              id="name"
-              className="sw-my-2"
-              autoFocus
-              onChange={this.handleNameChange}
-              type="text"
-              value={this.state.name}
-              size="full"
-            />
-          </form>
-        }
-        primaryButton={
-          <ButtonPrimary
-            id="add-event-submit"
-            form="add-event-form"
-            type="submit"
-            disabled={!this.state.name}
-            onClick={this.handleSubmit}
-          >
-            {translate('save')}
-          </ButtonPrimary>
-        }
-        secondaryButtonLabel={translate('cancel')}
-      />
-    );
-  }
+  return (
+    <Modal
+      headerTitle={translate(addEventButtonText)}
+      onClose={onClose}
+      body={
+        <form id="add-event-form">
+          <label htmlFor="name">{translate('name')}</label>
+          <InputField
+            id="name"
+            className="sw-my-2"
+            autoFocus
+            onChange={handleNameChange}
+            type="text"
+            value={name}
+            size="full"
+          />
+        </form>
+      }
+      primaryButton={
+        <ButtonPrimary
+          id="add-event-submit"
+          form="add-event-form"
+          type="submit"
+          disabled={name === ''}
+          onClick={handleSubmit}
+        >
+          {translate('save')}
+        </ButtonPrimary>
+      }
+      secondaryButtonLabel={translate('cancel')}
+    />
+  );
 }
index 7c2041260c4797845f043846c9f21f758ef10aa5..14dfbe7a6745209cfdb89dd614ad0616fb6423b7 100644 (file)
 import { ButtonPrimary, InputField, Modal } from 'design-system';
 import * as React from 'react';
 import { translate } from '../../../../helpers/l10n';
+import { useChangeEventMutation } from '../../../../queries/project-analyses';
 import { AnalysisEvent } from '../../../../types/project-activity';
 
 interface Props {
-  changeEvent: (event: string, name: string) => Promise<void>;
   event: AnalysisEvent;
   header: string;
   onClose: () => void;
 }
 
-interface State {
-  name: string;
-}
+export default function ChangeEventForm(props: Readonly<Props>) {
+  const { event, header, onClose } = props;
+  const [name, setName] = React.useState(event.name);
 
-export default class ChangeEventForm extends React.PureComponent<Props, State> {
-  constructor(props: Props) {
-    super(props);
-    this.state = { name: props.event.name };
-  }
+  const { mutate: changeEvent } = useChangeEventMutation(onClose);
 
-  changeInput = (event: React.ChangeEvent<HTMLInputElement>) => {
-    this.setState({ name: event.target.value });
+  const changeInput = (event: React.ChangeEvent<HTMLInputElement>) => {
+    setName(event.target.value);
   };
 
-  handleSubmit = (e: React.MouseEvent<HTMLButtonElement>) => {
+  const handleSubmit = (e: React.MouseEvent<HTMLButtonElement>) => {
     e.preventDefault();
-    this.props.changeEvent(this.props.event.key, this.state.name);
-    this.props.onClose();
+    changeEvent({ event: event.key, name });
   };
 
-  render() {
-    const { name } = this.state;
-    return (
-      <Modal
-        headerTitle={this.props.header}
-        onClose={this.props.onClose}
-        body={
-          <form id="change-event-form">
-            <label htmlFor="name">{translate('name')}</label>
-            <InputField
-              id="name"
-              className="sw-my-2"
-              autoFocus
-              onChange={this.changeInput}
-              type="text"
-              value={name}
-              size="full"
-            />
-          </form>
-        }
-        primaryButton={
-          <ButtonPrimary
-            id="change-event-submit"
-            form="change-event-form"
-            type="submit"
-            disabled={!name || name === this.props.event.name}
-            onClick={this.handleSubmit}
-          >
-            {translate('change_verb')}
-          </ButtonPrimary>
-        }
-        secondaryButtonLabel={translate('cancel')}
-      />
-    );
-  }
+  return (
+    <Modal
+      headerTitle={header}
+      onClose={onClose}
+      body={
+        <form id="change-event-form">
+          <label htmlFor="name">{translate('name')}</label>
+          <InputField
+            id="name"
+            className="sw-my-2"
+            autoFocus
+            onChange={changeInput}
+            type="text"
+            value={name}
+            size="full"
+          />
+        </form>
+      }
+      primaryButton={
+        <ButtonPrimary
+          id="change-event-submit"
+          form="change-event-form"
+          type="submit"
+          disabled={name === '' || name === event.name}
+          onClick={handleSubmit}
+        >
+          {translate('change_verb')}
+        </ButtonPrimary>
+      }
+      secondaryButtonLabel={translate('cancel')}
+    />
+  );
 }
index a3ced3004aa068131d191793db8b038d8ba4e06e..b04a7426464168d370b07da5db58fdd72007b889 100644 (file)
 import { DangerButtonPrimary, Modal } from 'design-system';
 import * as React from 'react';
 import { translate } from '../../../../helpers/l10n';
+import { useDeleteAnalysisMutation } from '../../../../queries/project-analyses';
 import { ParsedAnalysis } from '../../../../types/project-activity';
 
 interface Props {
   analysis: ParsedAnalysis;
-  deleteAnalysis: (analysis: string) => Promise<void>;
   onClose: () => void;
 }
 
-export default function RemoveAnalysisForm({ analysis, deleteAnalysis, onClose }: Props) {
+export default function RemoveAnalysisForm({ analysis, onClose }: Readonly<Props>) {
+  const { mutate: deleteAnalysis } = useDeleteAnalysisMutation(onClose);
+
   return (
     <Modal
       headerTitle={translate('project_activity.delete_analysis')}
index 6a8e8cdb6eb7978c39d1c8499baf8516a85e63b3..50c36f74f1aefa10ac62cce04ee8c9d579368bf9 100644 (file)
@@ -20,6 +20,7 @@
 import { DangerButtonPrimary, Modal } from 'design-system';
 import * as React from 'react';
 import { translate } from '../../../../helpers/l10n';
+import { useDeleteEventMutation } from '../../../../queries/project-analyses';
 import { AnalysisEvent } from '../../../../types/project-activity';
 
 export interface RemoveEventFormProps {
@@ -28,18 +29,21 @@ export interface RemoveEventFormProps {
   header: string;
   removeEventQuestion: string;
   onClose: () => void;
-  onConfirm: (analysis: string, event: string) => Promise<void>;
 }
 
 export default function RemoveEventForm(props: RemoveEventFormProps) {
   const { analysisKey, event, header, removeEventQuestion } = props;
+
+  const { mutate: deleteEvent } = useDeleteEventMutation();
   return (
     <Modal
       headerTitle={header}
       onClose={props.onClose}
       body={<p>{removeEventQuestion}</p>}
       primaryButton={
-        <DangerButtonPrimary onClick={() => props.onConfirm(analysisKey, event.key)}>
+        <DangerButtonPrimary
+          onClick={() => deleteEvent({ analysis: analysisKey, event: event.key })}
+        >
           {translate('delete')}
         </DangerButtonPrimary>
       }
diff --git a/server/sonar-web/src/main/js/queries/applications.ts b/server/sonar-web/src/main/js/queries/applications.ts
new file mode 100644 (file)
index 0000000..7d3a73a
--- /dev/null
@@ -0,0 +1,30 @@
+/*
+ * 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 { useQuery } from '@tanstack/react-query';
+import { getApplicationLeak } from '../api/application';
+
+export default function useApplicationLeakQuery(application: string, enabled = true) {
+  return useQuery({
+    queryKey: ['application', 'leak', application],
+    queryFn: () => getApplicationLeak(application),
+    enabled,
+  });
+}
diff --git a/server/sonar-web/src/main/js/queries/measures.ts b/server/sonar-web/src/main/js/queries/measures.ts
new file mode 100644 (file)
index 0000000..60aa82a
--- /dev/null
@@ -0,0 +1,44 @@
+/*
+ * 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 { useQuery } from '@tanstack/react-query';
+import { getAllTimeMachineData } from '../api/time-machine';
+import { BranchParameters } from '../types/branch-like';
+
+export function useAllMeasuresHistoryQuery(
+  component: string | undefined,
+  branchParams: BranchParameters,
+  metrics: string,
+  enabled = true,
+) {
+  return useQuery({
+    queryKey: ['measures', 'history', component, branchParams, metrics],
+    queryFn: () => {
+      if (metrics.length <= 0) {
+        return Promise.resolve({
+          measures: [],
+          paging: { pageIndex: 1, pageSize: 1, total: 0 },
+        });
+      }
+      return getAllTimeMachineData({ component, metrics, ...branchParams, p: 1 });
+    },
+    enabled,
+  });
+}
diff --git a/server/sonar-web/src/main/js/queries/project-analyses.ts b/server/sonar-web/src/main/js/queries/project-analyses.ts
new file mode 100644 (file)
index 0000000..7365b15
--- /dev/null
@@ -0,0 +1,169 @@
+/*
+ * 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 { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
+import {
+  CreateEventResponse,
+  ProjectActivityStatuses,
+  changeEvent,
+  createEvent,
+  deleteAnalysis,
+  deleteEvent,
+  getAllTimeProjectActivity,
+} from '../api/projectActivity';
+import {
+  useComponent,
+  useTopLevelComponentKey,
+} from '../app/components/componentContext/withComponentContext';
+import { getBranchLikeQuery } from '../helpers/branch-like';
+import { parseDate } from '../helpers/dates';
+import { serializeStringArray } from '../helpers/query';
+import { BranchParameters } from '../types/branch-like';
+import { ParsedAnalysis } from '../types/project-activity';
+import { useBranchesQuery } from './branch';
+
+const ACTIVITY_PAGE_SIZE = 500;
+
+function useProjectActivityQueryKey() {
+  const { component } = useComponent();
+  const componentKey = useTopLevelComponentKey();
+  const { data: { branchLike } = {} } = useBranchesQuery(component);
+  const branchParams = getBranchLikeQuery(branchLike);
+
+  return ['activity', 'list', componentKey, branchParams] as [
+    string,
+    string,
+    string | undefined,
+    BranchParameters,
+  ];
+}
+
+export function useAllProjectAnalysesQuery(enabled = true) {
+  const queryKey = useProjectActivityQueryKey();
+  return useQuery({
+    queryKey,
+    queryFn: ({ queryKey: [_0, _1, project, branchParams] }) =>
+      getAllTimeProjectActivity({
+        ...branchParams,
+        project,
+        statuses: serializeStringArray([
+          ProjectActivityStatuses.STATUS_PROCESSED,
+          ProjectActivityStatuses.STATUS_LIVE_MEASURE_COMPUTE,
+        ]),
+        p: 1,
+        ps: ACTIVITY_PAGE_SIZE,
+      }).then(({ analyses }) =>
+        analyses.map((analysis) => ({
+          ...analysis,
+          date: parseDate(analysis.date),
+        })),
+      ),
+    enabled,
+  });
+}
+
+export function useDeleteAnalysisMutation(successCb?: () => void) {
+  const queryClient = useQueryClient();
+  const queryKey = useProjectActivityQueryKey();
+
+  return useMutation({
+    mutationFn: (analysis: string) => deleteAnalysis(analysis),
+    onSuccess: (_, analysis) => {
+      queryClient.setQueryData(queryKey, (oldData: ParsedAnalysis[]) =>
+        oldData.filter((a) => a.key !== analysis),
+      );
+      queryClient.invalidateQueries({ queryKey: ['measures', 'history', queryKey[2]] });
+      successCb?.();
+    },
+  });
+}
+
+export function useCreateEventMutation(successCb?: () => void) {
+  const queryClient = useQueryClient();
+  const queryKey = useProjectActivityQueryKey();
+
+  return useMutation({
+    mutationFn: (data: Parameters<typeof createEvent>[0]) => createEvent(data),
+    onSuccess: (event) => {
+      queryClient.setQueryData(queryKey, (oldData: ParsedAnalysis[]) => {
+        return oldData.map((analysis) => {
+          if (analysis.key !== event.analysis) {
+            return analysis;
+          }
+          return { ...analysis, events: [...analysis.events, event] };
+        });
+      });
+      successCb?.();
+    },
+  });
+}
+
+export function useChangeEventMutation(successCb?: () => void) {
+  const queryClient = useQueryClient();
+  const queryKey = useProjectActivityQueryKey();
+
+  return useMutation({
+    mutationFn: (data: Parameters<typeof changeEvent>[0]) => changeEvent(data),
+    onSuccess: (event) => {
+      queryClient.setQueryData(queryKey, updateQueryDataOnChangeEvent(event));
+      successCb?.();
+    },
+  });
+}
+
+const updateQueryDataOnChangeEvent =
+  (event: CreateEventResponse) => (oldData: ParsedAnalysis[]) => {
+    return oldData.map((a) => {
+      if (a.key !== event.analysis) {
+        return a;
+      }
+      return {
+        ...a,
+        events: a.events.map((e) => (e.key === event.key ? event : e)),
+      };
+    });
+  };
+
+export function useDeleteEventMutation(successCb?: () => void) {
+  const queryClient = useQueryClient();
+  const queryKey = useProjectActivityQueryKey();
+
+  return useMutation({
+    mutationFn: ({ event }: { analysis: string; event: string }) => deleteEvent(event),
+    onSuccess: (_, variables) => {
+      queryClient.setQueryData(queryKey, updateQueryDataOnDeleteEvent(variables));
+      successCb?.();
+    },
+  });
+}
+
+const updateQueryDataOnDeleteEvent =
+  ({ analysis, event }: { analysis: string; event: string }) =>
+  (oldData: ParsedAnalysis[]) => {
+    return oldData.map((a) => {
+      if (a.key !== analysis) {
+        return a;
+      }
+      return {
+        ...a,
+        events: a.events.filter((ev) => ev.key !== event),
+      };
+    });
+  };