createEvent, | createEvent, | ||||
deleteAnalysis, | deleteAnalysis, | ||||
deleteEvent, | deleteEvent, | ||||
getAllTimeProjectActivity, | |||||
getProjectActivity, | getProjectActivity, | ||||
} from '../projectActivity'; | } from '../projectActivity'; | ||||
this.#analysisList = cloneDeep(defaultAnalysesList); | this.#analysisList = cloneDeep(defaultAnalysesList); | ||||
jest.mocked(getProjectActivity).mockImplementation(this.getActivityHandler); | jest.mocked(getProjectActivity).mockImplementation(this.getActivityHandler); | ||||
jest | |||||
.mocked(getAllTimeProjectActivity) | |||||
.mockImplementation(this.getAllTimeProjectActivityHandler); | |||||
jest.mocked(deleteAnalysis).mockImplementation(this.deleteAnalysisHandler); | jest.mocked(deleteAnalysis).mockImplementation(this.deleteAnalysisHandler); | ||||
jest.mocked(createEvent).mockImplementation(this.createEventHandler); | jest.mocked(createEvent).mockImplementation(this.createEventHandler); | ||||
jest.mocked(changeEvent).mockImplementation(this.changeEventHandler); | jest.mocked(changeEvent).mockImplementation(this.changeEventHandler); | ||||
? this.#analysisList.filter((a) => a.events.some((e) => e.category === category)) | ? this.#analysisList.filter((a) => a.events.some((e) => e.category === category)) | ||||
: this.#analysisList; | : this.#analysisList; | ||||
if (from) { | |||||
if (from !== undefined) { | |||||
const fromTime = parseDate(from).getTime(); | const fromTime = parseDate(from).getTime(); | ||||
analyses = analyses.filter((a) => parseDate(a.date).getTime() >= fromTime); | analyses = analyses.filter((a) => parseDate(a.date).getTime() >= fromTime); | ||||
} | } | ||||
}); | }); | ||||
}; | }; | ||||
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) => { | deleteAnalysisHandler = (analysisKey: string) => { | ||||
const i = this.#analysisList.findIndex(({ key }) => key === analysisKey); | const i = this.#analysisList.findIndex(({ key }) => key === analysisKey); | ||||
if (i === undefined) { | if (i === undefined) { | ||||
return this.reply(undefined); | 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 analysis = this.findAnalysis(analysisKey); | ||||
const key = uniqueId(analysisKey); | const key = uniqueId(analysisKey); | ||||
}); | }); | ||||
}; | }; | ||||
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 [eventIndex, analysisKey] = this.findEvent(eventKey); | ||||
const analysis = this.findAnalysis(analysisKey); | const analysis = this.findAnalysis(analysisKey); | ||||
const event = analysis.events[eventIndex]; | const event = analysis.events[eventIndex]; |
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||||
*/ | */ | ||||
import { throwGlobalError } from '../helpers/error'; | 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 { BranchParameters } from '../types/branch-like'; | ||||
import { | import { | ||||
Analysis, | Analysis, | ||||
STATUS_LIVE_MEASURE_COMPUTE = 'L', | 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); | 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; | analysis: string; | ||||
key: string; | key: string; | ||||
name: string; | name: string; | ||||
description?: string; | 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( | return postJSON('/api/project_analyses/create_event', data).then( | ||||
(r) => r.event, | (r) => r.event, | ||||
throwGlobalError, | throwGlobalError, | ||||
return post('/api/project_analyses/delete_event', { event }).catch(throwGlobalError); | 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( | return postJSON('/api/project_analyses/update_event', data).then( | ||||
(r) => r.event, | (r) => r.event, | ||||
throwGlobalError, | throwGlobalError, |
export function getTimeMachineData( | export function getTimeMachineData( | ||||
data: { | data: { | ||||
component: string; | |||||
component?: string; | |||||
from?: string; | from?: string; | ||||
metrics: string; | metrics: string; | ||||
p?: number; | p?: number; | ||||
export function getAllTimeMachineData( | export function getAllTimeMachineData( | ||||
data: { | data: { | ||||
component: string; | |||||
component?: string; | |||||
metrics: string; | metrics: string; | ||||
from?: string; | from?: string; | ||||
p?: number; | p?: number; |
*/ | */ | ||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { getWrappedDisplayName } from '../../../components/hoc/utils'; | import { getWrappedDisplayName } from '../../../components/hoc/utils'; | ||||
import { ComponentContextShape } from '../../../types/component'; | |||||
import { ComponentContextShape, ComponentQualifier } from '../../../types/component'; | |||||
import { ComponentContext } from './ComponentContext'; | import { ComponentContext } from './ComponentContext'; | ||||
export default function withComponentContext<P extends Partial<ComponentContextShape>>( | export default function withComponentContext<P extends Partial<ComponentContextShape>>( | ||||
export function useComponent() { | export function useComponent() { | ||||
return React.useContext(ComponentContext); | 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; | |||||
} |
// 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", | |||||
} | |||||
`; |
/* | |||||
* 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); | |||||
}); | |||||
}); |
/* | |||||
* 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) }); | |||||
} |
canAdmin?: boolean; | canAdmin?: boolean; | ||||
event: AnalysisEvent; | event: AnalysisEvent; | ||||
isFirst?: boolean; | 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 { analysisKey, event, canAdmin, isFirst } = props; | ||||
const [changing, setChanging] = React.useState(false); | const [changing, setChanging] = React.useState(false); | ||||
const isOther = event.category === ProjectAnalysisEventCategory.Other; | const isOther = event.category === ProjectAnalysisEventCategory.Other; | ||||
const isVersion = event.category === ProjectAnalysisEventCategory.Version; | 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); | const showActions = canAdmin && (canChange || canDelete); | ||||
return ( | return ( | ||||
</div> | </div> | ||||
)} | )} | ||||
{changing && props.onChange && ( | |||||
{changing && ( | |||||
<ChangeEventForm | <ChangeEventForm | ||||
changeEvent={props.onChange} | |||||
event={event} | event={event} | ||||
header={ | header={ | ||||
isVersion | isVersion | ||||
/> | /> | ||||
)} | )} | ||||
{deleting && props.onDelete && ( | |||||
{deleting && ( | |||||
<RemoveEventForm | <RemoveEventForm | ||||
analysisKey={analysisKey} | analysisKey={analysisKey} | ||||
event={event} | event={event} | ||||
: translate('project_activity.remove_custom_event') | : translate('project_activity.remove_custom_event') | ||||
} | } | ||||
onClose={() => setDeleting(false)} | onClose={() => setDeleting(false)} | ||||
onConfirm={props.onDelete} | |||||
removeEventQuestion={translate( | removeEventQuestion={translate( | ||||
`project_activity.${isVersion ? 'remove_version' : 'remove_custom_event'}.question`, | `project_activity.${isVersion ? 'remove_version' : 'remove_custom_event'}.question`, | ||||
)} | )} |
canAdmin?: boolean; | canAdmin?: boolean; | ||||
events: AnalysisEvent[]; | events: AnalysisEvent[]; | ||||
isFirst?: boolean; | isFirst?: boolean; | ||||
onChange?: (event: string, name: string) => Promise<void>; | |||||
onDelete?: (analysis: string, event: string) => Promise<void>; | |||||
} | } | ||||
function Events(props: EventsProps) { | function Events(props: EventsProps) { | ||||
event={event} | event={event} | ||||
isFirst={isFirst} | isFirst={isFirst} | ||||
key={event.key} | key={event.key} | ||||
onChange={props.onChange} | |||||
onDelete={props.onDelete} | |||||
/> | /> | ||||
))} | ))} | ||||
</div> | </div> |
import ProjectActivityAnalysis, { BaselineMarker } from './ProjectActivityAnalysis'; | import ProjectActivityAnalysis, { BaselineMarker } from './ProjectActivityAnalysis'; | ||||
interface Props { | interface Props { | ||||
onAddCustomEvent: (analysis: string, name: string, category?: string) => Promise<void>; | |||||
onAddVersion: (analysis: string, version: string) => Promise<void>; | |||||
analyses: ParsedAnalysis[]; | analyses: ParsedAnalysis[]; | ||||
analysesLoading: boolean; | analysesLoading: boolean; | ||||
canAdmin?: boolean; | canAdmin?: boolean; | ||||
canDeleteAnalyses?: boolean; | canDeleteAnalyses?: boolean; | ||||
onChangeEvent: (event: string, name: string) => Promise<void>; | |||||
onDeleteAnalysis: (analysis: string) => Promise<void>; | |||||
onDeleteEvent: (analysis: string, event: string) => Promise<void>; | |||||
initializing: boolean; | initializing: boolean; | ||||
leakPeriodDate?: Date; | leakPeriodDate?: Date; | ||||
project: { qualifier: string }; | project: { qualifier: string }; | ||||
return ( | return ( | ||||
<ProjectActivityAnalysis | <ProjectActivityAnalysis | ||||
onAddCustomEvent={this.props.onAddCustomEvent} | |||||
onAddVersion={this.props.onAddVersion} | |||||
analysis={analysis} | analysis={analysis} | ||||
canAdmin={this.props.canAdmin} | canAdmin={this.props.canAdmin} | ||||
canCreateVersion={this.props.project.qualifier === ComponentQualifier.Project} | canCreateVersion={this.props.project.qualifier === ComponentQualifier.Project} | ||||
canDeleteAnalyses={this.props.canDeleteAnalyses} | canDeleteAnalyses={this.props.canDeleteAnalyses} | ||||
onChangeEvent={this.props.onChangeEvent} | |||||
onDeleteAnalysis={this.props.onDeleteAnalysis} | |||||
onDeleteEvent={this.props.onDeleteEvent} | |||||
isBaseline={analysis.key === newCodeKey} | isBaseline={analysis.key === newCodeKey} | ||||
isFirst={analysis.key === firstAnalysisKey} | isFirst={analysis.key === firstAnalysisKey} | ||||
key={analysis.key} | key={analysis.key} |
import TimeFormatter from '../../../components/intl/TimeFormatter'; | import TimeFormatter from '../../../components/intl/TimeFormatter'; | ||||
import { parseDate } from '../../../helpers/dates'; | import { parseDate } from '../../../helpers/dates'; | ||||
import { translate, translateWithParameters } from '../../../helpers/l10n'; | import { translate, translateWithParameters } from '../../../helpers/l10n'; | ||||
import { ParsedAnalysis } from '../../../types/project-activity'; | |||||
import { ParsedAnalysis, ProjectAnalysisEventCategory } from '../../../types/project-activity'; | |||||
import Events from './Events'; | import Events from './Events'; | ||||
import AddEventForm from './forms/AddEventForm'; | import AddEventForm from './forms/AddEventForm'; | ||||
import RemoveAnalysisForm from './forms/RemoveAnalysisForm'; | import RemoveAnalysisForm from './forms/RemoveAnalysisForm'; | ||||
export interface ProjectActivityAnalysisProps extends WrappedComponentProps { | export interface ProjectActivityAnalysisProps extends WrappedComponentProps { | ||||
onAddCustomEvent: (analysis: string, name: string, category?: string) => Promise<void>; | |||||
onAddVersion: (analysis: string, version: string) => Promise<void>; | |||||
analysis: ParsedAnalysis; | analysis: ParsedAnalysis; | ||||
canAdmin?: boolean; | canAdmin?: boolean; | ||||
canDeleteAnalyses?: boolean; | canDeleteAnalyses?: boolean; | ||||
canCreateVersion: boolean; | canCreateVersion: boolean; | ||||
onChangeEvent: (event: string, name: string) => Promise<void>; | |||||
onDeleteAnalysis: (analysis: string) => Promise<void>; | |||||
onDeleteEvent: (analysis: string, event: string) => Promise<void>; | |||||
isBaseline: boolean; | isBaseline: boolean; | ||||
isFirst: boolean; | isFirst: boolean; | ||||
selected: boolean; | selected: boolean; | ||||
onUpdateSelectedDate: (date: Date) => void; | onUpdateSelectedDate: (date: Date) => void; | ||||
} | } | ||||
export enum Dialog { | |||||
AddEvent = 'add_event', | |||||
AddVersion = 'add_version', | |||||
RemoveAnalysis = 'remove_analysis', | |||||
} | |||||
function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) { | function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) { | ||||
let node: HTMLLIElement | null = null; | let node: HTMLLIElement | null = null; | ||||
} | } | ||||
}); | }); | ||||
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 parsedDate = parseDate(analysis.date); | ||||
const hasVersion = analysis.events.find((event) => event.category === 'VERSION') != null; | const hasVersion = analysis.events.find((event) => event.category === 'VERSION') != null; | ||||
'project_activity.show_analysis_X_on_graph', | 'project_activity.show_analysis_X_on_graph', | ||||
analysis.buildString ?? formatDate(parsedDate, formatterOption), | analysis.buildString ?? formatDate(parsedDate, formatterOption), | ||||
)} | )} | ||||
onClick={() => props.onUpdateSelectedDate(analysis.date)} | |||||
onClick={() => { | |||||
if (!selected) { | |||||
props.onUpdateSelectedDate(analysis.date); | |||||
} | |||||
}} | |||||
ref={(ref) => (node = ref)} | ref={(ref) => (node = ref)} | ||||
> | > | ||||
<div className="it__project-activity-time"> | <div className="it__project-activity-time"> | ||||
zLevel={PopupZLevel.Absolute} | zLevel={PopupZLevel.Absolute} | ||||
> | > | ||||
{canAddVersion && ( | {canAddVersion && ( | ||||
<ItemButton className="js-add-version" onClick={() => setAddVersionForm(true)}> | |||||
<ItemButton | |||||
className="js-add-version" | |||||
onClick={() => setDialog(Dialog.AddVersion)} | |||||
> | |||||
{translate('project_activity.add_version')} | {translate('project_activity.add_version')} | ||||
</ItemButton> | </ItemButton> | ||||
)} | )} | ||||
{canAddEvent && ( | {canAddEvent && ( | ||||
<ItemButton className="js-add-event" onClick={() => setAddEventForm(true)}> | |||||
<ItemButton className="js-add-event" onClick={() => setDialog(Dialog.AddEvent)}> | |||||
{translate('project_activity.add_custom_event')} | {translate('project_activity.add_custom_event')} | ||||
</ItemButton> | </ItemButton> | ||||
)} | )} | ||||
{canDeleteAnalyses && ( | {canDeleteAnalyses && ( | ||||
<ItemDangerButton | <ItemDangerButton | ||||
className="js-delete-analysis" | className="js-delete-analysis" | ||||
onClick={() => setRemoveAnalysisForm(true)} | |||||
onClick={() => setDialog(Dialog.RemoveAnalysis)} | |||||
> | > | ||||
{translate('project_activity.delete_analysis')} | {translate('project_activity.delete_analysis')} | ||||
</ItemDangerButton> | </ItemDangerButton> | ||||
)} | )} | ||||
</ActionsDropdown> | </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 | <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} | 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> | </div> | ||||
</ClickEventBoundary> | </ClickEventBoundary> | ||||
canAdmin={canAdmin} | canAdmin={canAdmin} | ||||
events={analysis.events} | events={analysis.events} | ||||
isFirst={isFirst} | isFirst={isFirst} | ||||
onChange={props.onChangeEvent} | |||||
onDelete={props.onDeleteEvent} | |||||
/> | /> | ||||
)} | )} | ||||
</ActivityAnalysisListItem> | </ActivityAnalysisListItem> |
* along with this program; if not, write to the Free Software Foundation, | * along with this program; if not, write to the Free Software Foundation, | ||||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | * 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 { | 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 { | import { | ||||
DEFAULT_GRAPH, | DEFAULT_GRAPH, | ||||
getActivityGraph, | getActivityGraph, | ||||
getHistoryMetrics, | getHistoryMetrics, | ||||
isCustomGraph, | isCustomGraph, | ||||
} from '../../../components/activity-graph/utils'; | } 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 { getBranchLikeQuery } from '../../../helpers/branch-like'; | ||||
import { HIDDEN_METRICS } from '../../../helpers/constants'; | import { HIDDEN_METRICS } from '../../../helpers/constants'; | ||||
import { parseDate } from '../../../helpers/dates'; | 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 { 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'; | import ProjectActivityAppRenderer from './ProjectActivityAppRenderer'; | ||||
interface Props extends WithBranchLikesProps { | |||||
component: Component; | |||||
location: Location; | |||||
metrics: Dict<Metric>; | |||||
router: Router; | |||||
} | |||||
export interface State { | export interface State { | ||||
analyses: ParsedAnalysis[]; | analyses: ParsedAnalysis[]; | ||||
analysesLoading: boolean; | analysesLoading: boolean; | ||||
export const PROJECT_ACTIVITY_GRAPH = 'sonar_project_activity.graph'; | 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, | 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( | return Object.values(metrics).filter( | ||||
(metric) => metric.key !== MetricKey.security_hotspots_reviewed, | (metric) => metric.key !== MetricKey.security_hotspots_reviewed, | ||||
); | ); | ||||
(metric) => | (metric) => | ||||
![...HIDDEN_METRICS, MetricKey.security_review_rating].includes(metric.key as MetricKey), | ![...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, | ...newQuery, | ||||
}); | }); | ||||
this.props.router.push({ | |||||
pathname: this.props.location.pathname, | |||||
router.push({ | |||||
pathname, | |||||
query: { | 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 | <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; | const emptyCustomGraph = isCustomGraph(graph) && customGraphs.length <= 0; | ||||
// if there is no filter, but there are saved preferences in the localStorage | // if there is no filter, but there are saved preferences in the localStorage | ||||
React.useEffect(() => { | React.useEffect(() => { | ||||
if (shouldRedirect) { | if (shouldRedirect) { | ||||
const query = parseQuery(searchParams); | |||||
const newQuery = { ...query, graph }; | const newQuery = { ...query, graph }; | ||||
if (isCustomGraph(newQuery.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))), | |||||
); |
import { Helmet } from 'react-helmet-async'; | import { Helmet } from 'react-helmet-async'; | ||||
import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget'; | import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget'; | ||||
import Suggestions from '../../../components/embed-docs-modal/Suggestions'; | import Suggestions from '../../../components/embed-docs-modal/Suggestions'; | ||||
import { parseDate } from '../../../helpers/dates'; | |||||
import { translate } from '../../../helpers/l10n'; | import { translate } from '../../../helpers/l10n'; | ||||
import { ComponentQualifier } from '../../../types/component'; | import { ComponentQualifier } from '../../../types/component'; | ||||
import { MeasureHistory, ParsedAnalysis } from '../../../types/project-activity'; | import { MeasureHistory, ParsedAnalysis } from '../../../types/project-activity'; | ||||
import ProjectActivityPageFilters from './ProjectActivityPageFilters'; | import ProjectActivityPageFilters from './ProjectActivityPageFilters'; | ||||
interface Props { | interface Props { | ||||
onAddCustomEvent: (analysis: string, name: string, category?: string) => Promise<void>; | |||||
onAddVersion: (analysis: string, version: string) => Promise<void>; | |||||
analyses: ParsedAnalysis[]; | analyses: ParsedAnalysis[]; | ||||
analysesLoading: boolean; | analysesLoading: boolean; | ||||
onChangeEvent: (event: string, name: string) => Promise<void>; | |||||
onDeleteAnalysis: (analysis: string) => Promise<void>; | |||||
onDeleteEvent: (analysis: string, event: string) => Promise<void>; | |||||
graphLoading: boolean; | graphLoading: boolean; | ||||
leakPeriodDate?: Date; | leakPeriodDate?: Date; | ||||
initializing: boolean; | initializing: boolean; | ||||
} | } | ||||
export default function ProjectActivityAppRenderer(props: 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 = | 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 ( | return ( | ||||
<main className="sw-p-5" id="project-activity"> | <main className="sw-p-5" id="project-activity"> | ||||
<Suggestions suggestions="project_activity" /> | <Suggestions suggestions="project_activity" /> | ||||
<div className="sw-grid sw-grid-cols-12 sw-gap-x-12"> | <div className="sw-grid sw-grid-cols-12 sw-gap-x-12"> | ||||
<StyledWrapper className="sw-col-span-4 sw-rounded-1"> | <StyledWrapper className="sw-col-span-4 sw-rounded-1"> | ||||
<ProjectActivityAnalysesList | <ProjectActivityAnalysesList | ||||
onAddCustomEvent={props.onAddCustomEvent} | |||||
onAddVersion={props.onAddVersion} | |||||
analyses={analyses} | analyses={analyses} | ||||
analysesLoading={props.analysesLoading} | |||||
analysesLoading={analysesLoading} | |||||
canAdmin={canAdmin} | canAdmin={canAdmin} | ||||
canDeleteAnalyses={canDeleteAnalyses} | canDeleteAnalyses={canDeleteAnalyses} | ||||
onChangeEvent={props.onChangeEvent} | |||||
onDeleteAnalysis={props.onDeleteAnalysis} | |||||
onDeleteEvent={props.onDeleteEvent} | |||||
initializing={props.initializing} | |||||
initializing={initializing} | |||||
leakPeriodDate={leakPeriodDate} | leakPeriodDate={leakPeriodDate} | ||||
project={props.project} | |||||
project={project} | |||||
query={query} | query={query} | ||||
onUpdateQuery={props.onUpdateQuery} | onUpdateQuery={props.onUpdateQuery} | ||||
/> | /> | ||||
<ProjectActivityGraphs | <ProjectActivityGraphs | ||||
analyses={analyses} | analyses={analyses} | ||||
leakPeriodDate={leakPeriodDate} | leakPeriodDate={leakPeriodDate} | ||||
loading={props.graphLoading} | |||||
loading={graphLoading} | |||||
measuresHistory={measuresHistory} | measuresHistory={measuresHistory} | ||||
metrics={props.metrics} | |||||
project={props.project.key} | |||||
metrics={metrics} | |||||
project={project.key} | |||||
query={query} | query={query} | ||||
updateQuery={props.onUpdateQuery} | updateQuery={props.onUpdateQuery} | ||||
/> | /> |
it('should render issues as default graph', async () => { | it('should render issues as default graph', async () => { | ||||
const { ui } = getPageObject(); | const { ui } = getPageObject(); | ||||
renderProjectActivityAppContainer(); | renderProjectActivityAppContainer(); | ||||
await ui.appLoaded(); | |||||
await ui.appLoaded(); | |||||
expect(ui.graphTypeIssues.get()).toBeInTheDocument(); | expect(ui.graphTypeIssues.get()).toBeInTheDocument(); | ||||
expect(ui.graphs.getAll().length).toBe(1); | |||||
}); | }); | ||||
it('should render new code legend for applications', async () => { | it('should render new code legend for applications', async () => { | ||||
}), | }), | ||||
); | ); | ||||
await ui.appLoaded(); | await ui.appLoaded(); | ||||
expect(ui.newCodeLegend.get()).toBeInTheDocument(); | expect(ui.newCodeLegend.get()).toBeInTheDocument(); | ||||
}); | }); | ||||
leakPeriodDate: parseDate('2017-03-01T22:00:00.000Z').toDateString(), | leakPeriodDate: parseDate('2017-03-01T22:00:00.000Z').toDateString(), | ||||
}), | }), | ||||
); | ); | ||||
await ui.appLoaded(); | |||||
await ui.appLoaded(); | |||||
expect(ui.newCodeLegend.get()).toBeInTheDocument(); | expect(ui.newCodeLegend.get()).toBeInTheDocument(); | ||||
}); | }); | ||||
); | ); | ||||
await ui.appLoaded({ doNotWaitForBranch: true }); | await ui.appLoaded({ doNotWaitForBranch: true }); | ||||
expect(ui.newCodeLegend.query()).not.toBeInTheDocument(); | expect(ui.newCodeLegend.query()).not.toBeInTheDocument(); | ||||
}, | }, | ||||
); | ); | ||||
); | ); | ||||
await ui.appLoaded(); | await ui.appLoaded(); | ||||
expect(ui.baseline.get()).toBeInTheDocument(); | expect(ui.baseline.get()).toBeInTheDocument(); | ||||
}); | }); | ||||
); | ); | ||||
await ui.appLoaded(); | await ui.appLoaded(); | ||||
expect(ui.baseline.get()).toBeInTheDocument(); | expect(ui.baseline.get()).toBeInTheDocument(); | ||||
}); | }); | ||||
); | ); | ||||
await ui.appLoaded(); | await ui.appLoaded(); | ||||
expect(ui.baseline.query()).not.toBeInTheDocument(); | expect(ui.baseline.query()).not.toBeInTheDocument(); | ||||
}); | }); | ||||
ui: { | ui: { | ||||
...ui, | ...ui, | ||||
async appLoaded({ doNotWaitForBranch }: { doNotWaitForBranch?: boolean } = {}) { | async appLoaded({ doNotWaitForBranch }: { doNotWaitForBranch?: boolean } = {}) { | ||||
await waitFor(() => { | |||||
expect(ui.loading.query()).not.toBeInTheDocument(); | |||||
}); | |||||
expect(await ui.graphs.findAll()).toHaveLength(1); | |||||
if (!doNotWaitForBranch) { | if (!doNotWaitForBranch) { | ||||
await waitFor(() => { | await waitFor(() => { |
import { ButtonPrimary, InputField, Modal } from 'design-system'; | import { ButtonPrimary, InputField, Modal } from 'design-system'; | ||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { translate } from '../../../../helpers/l10n'; | import { translate } from '../../../../helpers/l10n'; | ||||
import { useCreateEventMutation } from '../../../../queries/project-analyses'; | |||||
import { ParsedAnalysis } from '../../../../types/project-activity'; | import { ParsedAnalysis } from '../../../../types/project-activity'; | ||||
interface Props { | interface Props { | ||||
addEvent: (analysis: string, name: string, category?: string) => Promise<void>; | |||||
category?: string; | |||||
addEventButtonText: string; | addEventButtonText: string; | ||||
analysis: ParsedAnalysis; | analysis: ParsedAnalysis; | ||||
onClose: () => void; | 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(); | 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')} | |||||
/> | |||||
); | |||||
} | } |
import { ButtonPrimary, InputField, Modal } from 'design-system'; | import { ButtonPrimary, InputField, Modal } from 'design-system'; | ||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { translate } from '../../../../helpers/l10n'; | import { translate } from '../../../../helpers/l10n'; | ||||
import { useChangeEventMutation } from '../../../../queries/project-analyses'; | |||||
import { AnalysisEvent } from '../../../../types/project-activity'; | import { AnalysisEvent } from '../../../../types/project-activity'; | ||||
interface Props { | interface Props { | ||||
changeEvent: (event: string, name: string) => Promise<void>; | |||||
event: AnalysisEvent; | event: AnalysisEvent; | ||||
header: string; | header: string; | ||||
onClose: () => void; | 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(); | 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')} | |||||
/> | |||||
); | |||||
} | } |
import { DangerButtonPrimary, Modal } from 'design-system'; | import { DangerButtonPrimary, Modal } from 'design-system'; | ||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { translate } from '../../../../helpers/l10n'; | import { translate } from '../../../../helpers/l10n'; | ||||
import { useDeleteAnalysisMutation } from '../../../../queries/project-analyses'; | |||||
import { ParsedAnalysis } from '../../../../types/project-activity'; | import { ParsedAnalysis } from '../../../../types/project-activity'; | ||||
interface Props { | interface Props { | ||||
analysis: ParsedAnalysis; | analysis: ParsedAnalysis; | ||||
deleteAnalysis: (analysis: string) => Promise<void>; | |||||
onClose: () => 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 ( | return ( | ||||
<Modal | <Modal | ||||
headerTitle={translate('project_activity.delete_analysis')} | headerTitle={translate('project_activity.delete_analysis')} |
import { DangerButtonPrimary, Modal } from 'design-system'; | import { DangerButtonPrimary, Modal } from 'design-system'; | ||||
import * as React from 'react'; | import * as React from 'react'; | ||||
import { translate } from '../../../../helpers/l10n'; | import { translate } from '../../../../helpers/l10n'; | ||||
import { useDeleteEventMutation } from '../../../../queries/project-analyses'; | |||||
import { AnalysisEvent } from '../../../../types/project-activity'; | import { AnalysisEvent } from '../../../../types/project-activity'; | ||||
export interface RemoveEventFormProps { | export interface RemoveEventFormProps { | ||||
header: string; | header: string; | ||||
removeEventQuestion: string; | removeEventQuestion: string; | ||||
onClose: () => void; | onClose: () => void; | ||||
onConfirm: (analysis: string, event: string) => Promise<void>; | |||||
} | } | ||||
export default function RemoveEventForm(props: RemoveEventFormProps) { | export default function RemoveEventForm(props: RemoveEventFormProps) { | ||||
const { analysisKey, event, header, removeEventQuestion } = props; | const { analysisKey, event, header, removeEventQuestion } = props; | ||||
const { mutate: deleteEvent } = useDeleteEventMutation(); | |||||
return ( | return ( | ||||
<Modal | <Modal | ||||
headerTitle={header} | headerTitle={header} | ||||
onClose={props.onClose} | onClose={props.onClose} | ||||
body={<p>{removeEventQuestion}</p>} | body={<p>{removeEventQuestion}</p>} | ||||
primaryButton={ | primaryButton={ | ||||
<DangerButtonPrimary onClick={() => props.onConfirm(analysisKey, event.key)}> | |||||
<DangerButtonPrimary | |||||
onClick={() => deleteEvent({ analysis: analysisKey, event: event.key })} | |||||
> | |||||
{translate('delete')} | {translate('delete')} | ||||
</DangerButtonPrimary> | </DangerButtonPrimary> | ||||
} | } |
/* | |||||
* 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, | |||||
}); | |||||
} |
/* | |||||
* 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, | |||||
}); | |||||
} |
/* | |||||
* 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), | |||||
}; | |||||
}); | |||||
}; |