Browse Source

SONAR-21797 Refactor activity app to use react-query

tags/10.5.0.89998
stanislavh 1 month ago
parent
commit
d74481a6c7
21 changed files with 638 additions and 907 deletions
  1. 49
    8
      server/sonar-web/src/main/js/api/mocks/ProjectActivityServiceMock.ts
  2. 53
    37
      server/sonar-web/src/main/js/api/projectActivity.ts
  3. 2
    2
      server/sonar-web/src/main/js/api/time-machine.ts
  4. 30
    1
      server/sonar-web/src/main/js/app/components/componentContext/withComponentContext.tsx
  5. 0
    85
      server/sonar-web/src/main/js/apps/projectActivity/__tests__/__snapshots__/actions-test.ts.snap
  6. 0
    126
      server/sonar-web/src/main/js/apps/projectActivity/__tests__/actions-test.ts
  7. 0
    63
      server/sonar-web/src/main/js/apps/projectActivity/actions.ts
  8. 5
    9
      server/sonar-web/src/main/js/apps/projectActivity/components/Event.tsx
  9. 0
    4
      server/sonar-web/src/main/js/apps/projectActivity/components/Events.tsx
  10. 0
    10
      server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.tsx
  11. 34
    34
      server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.tsx
  12. 100
    394
      server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.tsx
  13. 21
    24
      server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAppRenderer.tsx
  14. 3
    11
      server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityApp-it.tsx
  15. 47
    46
      server/sonar-web/src/main/js/apps/projectActivity/components/forms/AddEventForm.tsx
  16. 41
    49
      server/sonar-web/src/main/js/apps/projectActivity/components/forms/ChangeEventForm.tsx
  17. 4
    2
      server/sonar-web/src/main/js/apps/projectActivity/components/forms/RemoveAnalysisForm.tsx
  18. 6
    2
      server/sonar-web/src/main/js/apps/projectActivity/components/forms/RemoveEventForm.tsx
  19. 30
    0
      server/sonar-web/src/main/js/queries/applications.ts
  20. 44
    0
      server/sonar-web/src/main/js/queries/measures.ts
  21. 169
    0
      server/sonar-web/src/main/js/queries/project-analyses.ts

+ 49
- 8
server/sonar-web/src/main/js/api/mocks/ProjectActivityServiceMock.ts View File

@@ -27,6 +27,7 @@ import {
createEvent,
deleteAnalysis,
deleteEvent,
getAllTimeProjectActivity,
getProjectActivity,
} from '../projectActivity';

@@ -83,6 +84,9 @@ export class ProjectActivityServiceMock {
this.#analysisList = cloneDeep(defaultAnalysesList);

jest.mocked(getProjectActivity).mockImplementation(this.getActivityHandler);
jest
.mocked(getAllTimeProjectActivity)
.mockImplementation(this.getAllTimeProjectActivityHandler);
jest.mocked(deleteAnalysis).mockImplementation(this.deleteAnalysisHandler);
jest.mocked(createEvent).mockImplementation(this.createEventHandler);
jest.mocked(changeEvent).mockImplementation(this.changeEventHandler);
@@ -121,7 +125,7 @@ export class ProjectActivityServiceMock {
? this.#analysisList.filter((a) => a.events.some((e) => e.category === category))
: this.#analysisList;

if (from) {
if (from !== undefined) {
const fromTime = parseDate(from).getTime();
analyses = analyses.filter((a) => parseDate(a.date).getTime() >= fromTime);
}
@@ -134,6 +138,36 @@ export class ProjectActivityServiceMock {
});
};

getAllTimeProjectActivityHandler = (
data: {
project: string;
statuses?: string;
category?: string;
from?: string;
p?: number;
ps?: number;
} & BranchParameters,
) => {
const { project, p = DEFAULT_PAGE, category, from } = data;

if (project === UNKNOWN_PROJECT) {
throw new Error(`Could not find project "${UNKNOWN_PROJECT}"`);
}

let analyses = category
? this.#analysisList.filter((a) => a.events.some((e) => e.category === category))
: this.#analysisList;

if (from !== undefined) {
const fromTime = parseDate(from).getTime();
analyses = analyses.filter((a) => parseDate(a.date).getTime() >= fromTime);
}
return this.reply({
paging: { pageSize: PAGE_SIZE, total: this.#analysisList.length, pageIndex: p },
analyses: this.#analysisList,
});
};

deleteAnalysisHandler = (analysisKey: string) => {
const i = this.#analysisList.findIndex(({ key }) => key === analysisKey);
if (i === undefined) {
@@ -143,12 +177,18 @@ export class ProjectActivityServiceMock {
return this.reply(undefined);
};

createEventHandler = (
analysisKey: string,
name: string,
category = ProjectAnalysisEventCategory.Other,
description?: string,
) => {
createEventHandler = (data: {
analysis: string;
name: string;
category?: ProjectAnalysisEventCategory;
description?: string;
}) => {
const {
analysis: analysisKey,
name,
category = ProjectAnalysisEventCategory.Other,
description,
} = data;
const analysis = this.findAnalysis(analysisKey);

const key = uniqueId(analysisKey);
@@ -163,7 +203,8 @@ export class ProjectActivityServiceMock {
});
};

changeEventHandler = (eventKey: string, name: string, description?: string) => {
changeEventHandler = (data: { event: string; name: string; description?: string }) => {
const { event: eventKey, name, description } = data;
const [eventIndex, analysisKey] = this.findEvent(eventKey);
const analysis = this.findAnalysis(analysisKey);
const event = analysis.events[eventIndex];

+ 53
- 37
server/sonar-web/src/main/js/api/projectActivity.ts View File

@@ -18,7 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { throwGlobalError } from '../helpers/error';
import { getJSON, post, postJSON, RequestData } from '../helpers/request';
import { getJSON, post, postJSON } from '../helpers/request';
import { BranchParameters } from '../types/branch-like';
import {
Analysis,
@@ -33,20 +33,50 @@ export enum ProjectActivityStatuses {
STATUS_LIVE_MEASURE_COMPUTE = 'L',
}

export function getProjectActivity(
data: {
project: string;
statuses?: string;
category?: string;
from?: string;
p?: number;
ps?: number;
} & BranchParameters,
): Promise<{ analyses: Analysis[]; paging: Paging }> {
export type ProjectActivityParams = {
project?: string;
statuses?: string;
category?: string;
from?: string;
p?: number;
ps?: number;
} & BranchParameters;

export interface ProjectActivityResponse {
analyses: Analysis[];
paging: Paging;
}

export function getProjectActivity(data: ProjectActivityParams): Promise<ProjectActivityResponse> {
return getJSON('/api/project_analyses/search', data).catch(throwGlobalError);
}

interface CreateEventResponse {
const PROJECT_ACTIVITY_PAGE_SIZE = 500;

export function getAllTimeProjectActivity(
data: ProjectActivityParams,
prev?: ProjectActivityResponse,
): Promise<ProjectActivityResponse> {
return getProjectActivity({ ...data, ps: data.ps ?? PROJECT_ACTIVITY_PAGE_SIZE }).then((r) => {
const result = prev
? {
analyses: prev.analyses.concat(r.analyses),
paging: r.paging,
}
: r;

if (result.paging.pageIndex * result.paging.pageSize >= result.paging.total) {
return result;
}

return getAllTimeProjectActivity(
{ ...data, ps: data.ps ?? PROJECT_ACTIVITY_PAGE_SIZE, p: result.paging.pageIndex + 1 },
result,
);
});
}

export interface CreateEventResponse {
analysis: string;
key: string;
name: string;
@@ -54,19 +84,12 @@ interface CreateEventResponse {
description?: string;
}

export function createEvent(
analysis: string,
name: string,
category?: string,
description?: string,
): Promise<CreateEventResponse> {
const data: RequestData = { analysis, name };
if (category) {
data.category = category;
}
if (description) {
data.description = description;
}
export function createEvent(data: {
analysis: string;
name: string;
category?: string;
description?: string;
}): Promise<CreateEventResponse> {
return postJSON('/api/project_analyses/create_event', data).then(
(r) => r.event,
throwGlobalError,
@@ -77,18 +100,11 @@ export function deleteEvent(event: string): Promise<void | Response> {
return post('/api/project_analyses/delete_event', { event }).catch(throwGlobalError);
}

export function changeEvent(
event: string,
name?: string,
description?: string,
): Promise<CreateEventResponse> {
const data: RequestData = { event };
if (name) {
data.name = name;
}
if (description) {
data.description = description;
}
export function changeEvent(data: {
event: string;
name?: string;
description?: string;
}): Promise<CreateEventResponse> {
return postJSON('/api/project_analyses/update_event', data).then(
(r) => r.event,
throwGlobalError,

+ 2
- 2
server/sonar-web/src/main/js/api/time-machine.ts View File

@@ -33,7 +33,7 @@ export interface TimeMachineResponse {

export function getTimeMachineData(
data: {
component: string;
component?: string;
from?: string;
metrics: string;
p?: number;
@@ -46,7 +46,7 @@ export function getTimeMachineData(

export function getAllTimeMachineData(
data: {
component: string;
component?: string;
metrics: string;
from?: string;
p?: number;

+ 30
- 1
server/sonar-web/src/main/js/app/components/componentContext/withComponentContext.tsx View File

@@ -19,7 +19,7 @@
*/
import * as React from 'react';
import { getWrappedDisplayName } from '../../../components/hoc/utils';
import { ComponentContextShape } from '../../../types/component';
import { ComponentContextShape, ComponentQualifier } from '../../../types/component';
import { ComponentContext } from './ComponentContext';

export default function withComponentContext<P extends Partial<ComponentContextShape>>(
@@ -43,3 +43,32 @@ export default function withComponentContext<P extends Partial<ComponentContextS
export function useComponent() {
return React.useContext(ComponentContext);
}

export function useTopLevelComponentKey() {
const { component } = useComponent();

const componentKey = React.useMemo(() => {
if (!component) {
return undefined;
}

let current = component.breadcrumbs.length - 1;

while (
current > 0 &&
!(
[
ComponentQualifier.Project,
ComponentQualifier.Portfolio,
ComponentQualifier.Application,
] as string[]
).includes(component.breadcrumbs[current].qualifier)
) {
current--;
}

return component.breadcrumbs[current].key;
}, [component]);

return componentKey;
}

+ 0
- 85
server/sonar-web/src/main/js/apps/projectActivity/__tests__/__snapshots__/actions-test.ts.snap View File

@@ -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",
}
`;

+ 0
- 126
server/sonar-web/src/main/js/apps/projectActivity/__tests__/actions-test.ts View File

@@ -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);
});
});

+ 0
- 63
server/sonar-web/src/main/js/apps/projectActivity/actions.ts View File

@@ -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) });
}

+ 5
- 9
server/sonar-web/src/main/js/apps/projectActivity/components/Event.tsx View File

@@ -31,11 +31,9 @@ export interface EventProps {
canAdmin?: boolean;
event: AnalysisEvent;
isFirst?: boolean;
onChange?: (event: string, name: string) => Promise<void>;
onDelete?: (analysisKey: string, event: string) => Promise<void>;
}

function Event(props: EventProps) {
function Event(props: Readonly<EventProps>) {
const { analysisKey, event, canAdmin, isFirst } = props;

const [changing, setChanging] = React.useState(false);
@@ -43,8 +41,8 @@ function Event(props: EventProps) {

const isOther = event.category === ProjectAnalysisEventCategory.Other;
const isVersion = event.category === ProjectAnalysisEventCategory.Version;
const canChange = (isOther || isVersion) && props.onChange;
const canDelete = (isOther || (isVersion && !isFirst)) && props.onDelete;
const canChange = isOther || isVersion;
const canDelete = isOther || (isVersion && !isFirst);
const showActions = canAdmin && (canChange || canDelete);

return (
@@ -80,9 +78,8 @@ function Event(props: EventProps) {
</div>
)}

{changing && props.onChange && (
{changing && (
<ChangeEventForm
changeEvent={props.onChange}
event={event}
header={
isVersion
@@ -93,7 +90,7 @@ function Event(props: EventProps) {
/>
)}

{deleting && props.onDelete && (
{deleting && (
<RemoveEventForm
analysisKey={analysisKey}
event={event}
@@ -103,7 +100,6 @@ function Event(props: EventProps) {
: translate('project_activity.remove_custom_event')
}
onClose={() => setDeleting(false)}
onConfirm={props.onDelete}
removeEventQuestion={translate(
`project_activity.${isVersion ? 'remove_version' : 'remove_custom_event'}.question`,
)}

+ 0
- 4
server/sonar-web/src/main/js/apps/projectActivity/components/Events.tsx View File

@@ -27,8 +27,6 @@ export interface EventsProps {
canAdmin?: boolean;
events: AnalysisEvent[];
isFirst?: boolean;
onChange?: (event: string, name: string) => Promise<void>;
onDelete?: (analysis: string, event: string) => Promise<void>;
}

function Events(props: EventsProps) {
@@ -61,8 +59,6 @@ function Events(props: EventsProps) {
event={event}
isFirst={isFirst}
key={event.key}
onChange={props.onChange}
onDelete={props.onDelete}
/>
))}
</div>

+ 0
- 10
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.tsx View File

@@ -33,15 +33,10 @@ import { AnalysesByDay, Query, activityQueryChanged, getAnalysesByVersionByDay }
import ProjectActivityAnalysis, { BaselineMarker } from './ProjectActivityAnalysis';

interface Props {
onAddCustomEvent: (analysis: string, name: string, category?: string) => Promise<void>;
onAddVersion: (analysis: string, version: string) => Promise<void>;
analyses: ParsedAnalysis[];
analysesLoading: boolean;
canAdmin?: boolean;
canDeleteAnalyses?: boolean;
onChangeEvent: (event: string, name: string) => Promise<void>;
onDeleteAnalysis: (analysis: string) => Promise<void>;
onDeleteEvent: (analysis: string, event: string) => Promise<void>;
initializing: boolean;
leakPeriodDate?: Date;
project: { qualifier: string };
@@ -110,15 +105,10 @@ export default class ProjectActivityAnalysesList extends React.PureComponent<Pro

return (
<ProjectActivityAnalysis
onAddCustomEvent={this.props.onAddCustomEvent}
onAddVersion={this.props.onAddVersion}
analysis={analysis}
canAdmin={this.props.canAdmin}
canCreateVersion={this.props.project.qualifier === ComponentQualifier.Project}
canDeleteAnalyses={this.props.canDeleteAnalyses}
onChangeEvent={this.props.onChangeEvent}
onDeleteAnalysis={this.props.onDeleteAnalysis}
onDeleteEvent={this.props.onDeleteEvent}
isBaseline={analysis.key === newCodeKey}
isFirst={analysis.key === firstAnalysisKey}
key={analysis.key}

+ 34
- 34
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.tsx View File

@@ -37,27 +37,28 @@ import { formatterOption } from '../../../components/intl/DateTimeFormatter';
import TimeFormatter from '../../../components/intl/TimeFormatter';
import { parseDate } from '../../../helpers/dates';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { ParsedAnalysis } from '../../../types/project-activity';
import { ParsedAnalysis, ProjectAnalysisEventCategory } from '../../../types/project-activity';
import Events from './Events';
import AddEventForm from './forms/AddEventForm';
import RemoveAnalysisForm from './forms/RemoveAnalysisForm';

export interface ProjectActivityAnalysisProps extends WrappedComponentProps {
onAddCustomEvent: (analysis: string, name: string, category?: string) => Promise<void>;
onAddVersion: (analysis: string, version: string) => Promise<void>;
analysis: ParsedAnalysis;
canAdmin?: boolean;
canDeleteAnalyses?: boolean;
canCreateVersion: boolean;
onChangeEvent: (event: string, name: string) => Promise<void>;
onDeleteAnalysis: (analysis: string) => Promise<void>;
onDeleteEvent: (analysis: string, event: string) => Promise<void>;
isBaseline: boolean;
isFirst: boolean;
selected: boolean;
onUpdateSelectedDate: (date: Date) => void;
}

export enum Dialog {
AddEvent = 'add_event',
AddVersion = 'add_version',
RemoveAnalysis = 'remove_analysis',
}

function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) {
let node: HTMLLIElement | null = null;

@@ -77,9 +78,8 @@ function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) {
}
});

const [addEventForm, setAddEventForm] = React.useState(false);
const [addVersionForm, setAddVersionForm] = React.useState(false);
const [removeAnalysisForm, setRemoveAnalysisForm] = React.useState(false);
const [dialog, setDialog] = React.useState<Dialog | undefined>();
const closeDialog = () => setDialog(undefined);

const parsedDate = parseDate(analysis.date);
const hasVersion = analysis.events.find((event) => event.category === 'VERSION') != null;
@@ -113,7 +113,11 @@ function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) {
'project_activity.show_analysis_X_on_graph',
analysis.buildString ?? formatDate(parsedDate, formatterOption),
)}
onClick={() => props.onUpdateSelectedDate(analysis.date)}
onClick={() => {
if (!selected) {
props.onUpdateSelectedDate(analysis.date);
}
}}
ref={(ref) => (node = ref)}
>
<div className="it__project-activity-time">
@@ -139,12 +143,15 @@ function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) {
zLevel={PopupZLevel.Absolute}
>
{canAddVersion && (
<ItemButton className="js-add-version" onClick={() => setAddVersionForm(true)}>
<ItemButton
className="js-add-version"
onClick={() => setDialog(Dialog.AddVersion)}
>
{translate('project_activity.add_version')}
</ItemButton>
)}
{canAddEvent && (
<ItemButton className="js-add-event" onClick={() => setAddEventForm(true)}>
<ItemButton className="js-add-event" onClick={() => setDialog(Dialog.AddEvent)}>
{translate('project_activity.add_custom_event')}
</ItemButton>
)}
@@ -152,37 +159,32 @@ function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) {
{canDeleteAnalyses && (
<ItemDangerButton
className="js-delete-analysis"
onClick={() => setRemoveAnalysisForm(true)}
onClick={() => setDialog(Dialog.RemoveAnalysis)}
>
{translate('project_activity.delete_analysis')}
</ItemDangerButton>
)}
</ActionsDropdown>

{addVersionForm && (
<AddEventForm
addEvent={props.onAddVersion}
addEventButtonText="project_activity.add_version"
analysis={analysis}
onClose={() => setAddVersionForm(false)}
/>
)}

{addEventForm && (
{[Dialog.AddEvent, Dialog.AddVersion].includes(dialog as Dialog) && (
<AddEventForm
addEvent={props.onAddCustomEvent}
addEventButtonText="project_activity.add_custom_event"
category={
dialog === Dialog.AddVersion
? ProjectAnalysisEventCategory.Version
: undefined
}
addEventButtonText={
dialog === Dialog.AddVersion
? 'project_activity.add_version'
: 'project_activity.add_custom_event'
}
analysis={analysis}
onClose={() => setAddEventForm(false)}
onClose={closeDialog}
/>
)}

{removeAnalysisForm && (
<RemoveAnalysisForm
analysis={analysis}
deleteAnalysis={props.onDeleteAnalysis}
onClose={() => setRemoveAnalysisForm(false)}
/>
{dialog === 'remove_analysis' && (
<RemoveAnalysisForm analysis={analysis} onClose={closeDialog} />
)}
</div>
</ClickEventBoundary>
@@ -194,8 +196,6 @@ function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) {
canAdmin={canAdmin}
events={analysis.events}
isFirst={isFirst}
onChange={props.onChangeEvent}
onDelete={props.onDeleteEvent}
/>
)}
</ActivityAnalysisListItem>

+ 100
- 394
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityApp.tsx View File

@@ -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))),
);

+ 21
- 24
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAppRenderer.tsx View File

@@ -28,7 +28,6 @@ import * as React from 'react';
import { Helmet } from 'react-helmet-async';
import A11ySkipTarget from '../../../components/a11y/A11ySkipTarget';
import Suggestions from '../../../components/embed-docs-modal/Suggestions';
import { parseDate } from '../../../helpers/dates';
import { translate } from '../../../helpers/l10n';
import { ComponentQualifier } from '../../../types/component';
import { MeasureHistory, ParsedAnalysis } from '../../../types/project-activity';
@@ -39,13 +38,8 @@ import ProjectActivityGraphs from './ProjectActivityGraphs';
import ProjectActivityPageFilters from './ProjectActivityPageFilters';

interface Props {
onAddCustomEvent: (analysis: string, name: string, category?: string) => Promise<void>;
onAddVersion: (analysis: string, version: string) => Promise<void>;
analyses: ParsedAnalysis[];
analysesLoading: boolean;
onChangeEvent: (event: string, name: string) => Promise<void>;
onDeleteAnalysis: (analysis: string) => Promise<void>;
onDeleteEvent: (analysis: string, event: string) => Promise<void>;
graphLoading: boolean;
leakPeriodDate?: Date;
initializing: boolean;
@@ -57,14 +51,22 @@ interface Props {
}

export default function ProjectActivityAppRenderer(props: Props) {
const { analyses, measuresHistory, query } = props;
const { configuration } = props.project;
const {
analyses,
measuresHistory,
query,
leakPeriodDate,
analysesLoading,
initializing,
graphLoading,
metrics,
project,
} = props;
const { configuration, qualifier } = props.project;
const canAdmin =
(props.project.qualifier === ComponentQualifier.Project ||
props.project.qualifier === ComponentQualifier.Application) &&
(configuration ? configuration.showHistory : false);
const canDeleteAnalyses = configuration ? configuration.showHistory : false;
const leakPeriodDate = props.leakPeriodDate ? parseDate(props.leakPeriodDate) : undefined;
(qualifier === ComponentQualifier.Project || qualifier === ComponentQualifier.Application) &&
configuration?.showHistory;
const canDeleteAnalyses = configuration?.showHistory;
return (
<main className="sw-p-5" id="project-activity">
<Suggestions suggestions="project_activity" />
@@ -84,18 +86,13 @@ export default function ProjectActivityAppRenderer(props: Props) {
<div className="sw-grid sw-grid-cols-12 sw-gap-x-12">
<StyledWrapper className="sw-col-span-4 sw-rounded-1">
<ProjectActivityAnalysesList
onAddCustomEvent={props.onAddCustomEvent}
onAddVersion={props.onAddVersion}
analyses={analyses}
analysesLoading={props.analysesLoading}
analysesLoading={analysesLoading}
canAdmin={canAdmin}
canDeleteAnalyses={canDeleteAnalyses}
onChangeEvent={props.onChangeEvent}
onDeleteAnalysis={props.onDeleteAnalysis}
onDeleteEvent={props.onDeleteEvent}
initializing={props.initializing}
initializing={initializing}
leakPeriodDate={leakPeriodDate}
project={props.project}
project={project}
query={query}
onUpdateQuery={props.onUpdateQuery}
/>
@@ -104,10 +101,10 @@ export default function ProjectActivityAppRenderer(props: Props) {
<ProjectActivityGraphs
analyses={analyses}
leakPeriodDate={leakPeriodDate}
loading={props.graphLoading}
loading={graphLoading}
measuresHistory={measuresHistory}
metrics={props.metrics}
project={props.project.key}
metrics={metrics}
project={project.key}
query={query}
updateQuery={props.onUpdateQuery}
/>

+ 3
- 11
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/ProjectActivityApp-it.tsx View File

@@ -101,10 +101,9 @@ describe('rendering', () => {
it('should render issues as default graph', async () => {
const { ui } = getPageObject();
renderProjectActivityAppContainer();
await ui.appLoaded();

await ui.appLoaded();
expect(ui.graphTypeIssues.get()).toBeInTheDocument();
expect(ui.graphs.getAll().length).toBe(1);
});

it('should render new code legend for applications', async () => {
@@ -119,7 +118,6 @@ describe('rendering', () => {
}),
);
await ui.appLoaded();

expect(ui.newCodeLegend.get()).toBeInTheDocument();
});

@@ -135,8 +133,8 @@ describe('rendering', () => {
leakPeriodDate: parseDate('2017-03-01T22:00:00.000Z').toDateString(),
}),
);
await ui.appLoaded();

await ui.appLoaded();
expect(ui.newCodeLegend.get()).toBeInTheDocument();
});

@@ -153,7 +151,6 @@ describe('rendering', () => {
);

await ui.appLoaded({ doNotWaitForBranch: true });

expect(ui.newCodeLegend.query()).not.toBeInTheDocument();
},
);
@@ -171,7 +168,6 @@ describe('rendering', () => {
);

await ui.appLoaded();

expect(ui.baseline.get()).toBeInTheDocument();
});

@@ -188,7 +184,6 @@ describe('rendering', () => {
);

await ui.appLoaded();

expect(ui.baseline.get()).toBeInTheDocument();
});

@@ -205,7 +200,6 @@ describe('rendering', () => {
);

await ui.appLoaded();

expect(ui.baseline.query()).not.toBeInTheDocument();
});

@@ -562,9 +556,7 @@ function getPageObject() {
ui: {
...ui,
async appLoaded({ doNotWaitForBranch }: { doNotWaitForBranch?: boolean } = {}) {
await waitFor(() => {
expect(ui.loading.query()).not.toBeInTheDocument();
});
expect(await ui.graphs.findAll()).toHaveLength(1);

if (!doNotWaitForBranch) {
await waitFor(() => {

+ 47
- 46
server/sonar-web/src/main/js/apps/projectActivity/components/forms/AddEventForm.tsx View File

@@ -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')}
/>
);
}

+ 41
- 49
server/sonar-web/src/main/js/apps/projectActivity/components/forms/ChangeEventForm.tsx View File

@@ -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')}
/>
);
}

+ 4
- 2
server/sonar-web/src/main/js/apps/projectActivity/components/forms/RemoveAnalysisForm.tsx View File

@@ -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')}

+ 6
- 2
server/sonar-web/src/main/js/apps/projectActivity/components/forms/RemoveEventForm.tsx View File

@@ -20,6 +20,7 @@
import { DangerButtonPrimary, Modal } from 'design-system';
import * as React from 'react';
import { translate } from '../../../../helpers/l10n';
import { useDeleteEventMutation } from '../../../../queries/project-analyses';
import { AnalysisEvent } from '../../../../types/project-activity';

export interface RemoveEventFormProps {
@@ -28,18 +29,21 @@ export interface RemoveEventFormProps {
header: string;
removeEventQuestion: string;
onClose: () => void;
onConfirm: (analysis: string, event: string) => Promise<void>;
}

export default function RemoveEventForm(props: RemoveEventFormProps) {
const { analysisKey, event, header, removeEventQuestion } = props;

const { mutate: deleteEvent } = useDeleteEventMutation();
return (
<Modal
headerTitle={header}
onClose={props.onClose}
body={<p>{removeEventQuestion}</p>}
primaryButton={
<DangerButtonPrimary onClick={() => props.onConfirm(analysisKey, event.key)}>
<DangerButtonPrimary
onClick={() => deleteEvent({ analysis: analysisKey, event: event.key })}
>
{translate('delete')}
</DangerButtonPrimary>
}

+ 30
- 0
server/sonar-web/src/main/js/queries/applications.ts View File

@@ -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,
});
}

+ 44
- 0
server/sonar-web/src/main/js/queries/measures.ts View File

@@ -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,
});
}

+ 169
- 0
server/sonar-web/src/main/js/queries/project-analyses.ts View File

@@ -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),
};
});
};

Loading…
Cancel
Save