@@ -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]; |
@@ -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, |
@@ -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; |
@@ -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; | |||
} |
@@ -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", | |||
} | |||
`; |
@@ -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); | |||
}); | |||
}); |
@@ -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) }); | |||
} |
@@ -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`, | |||
)} |
@@ -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> |
@@ -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} |
@@ -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> |
@@ -17,64 +17,32 @@ | |||
* 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))), | |||
); |
@@ -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} | |||
/> |
@@ -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(() => { |
@@ -20,64 +20,65 @@ | |||
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')} | |||
/> | |||
); | |||
} |
@@ -20,68 +20,60 @@ | |||
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')} | |||
/> | |||
); | |||
} |
@@ -20,15 +20,17 @@ | |||
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')} |
@@ -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> | |||
} |
@@ -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, | |||
}); | |||
} |
@@ -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, | |||
}); | |||
} |
@@ -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), | |||
}; | |||
}); | |||
}; |