createEvent,
deleteAnalysis,
deleteEvent,
+ getAllTimeProjectActivity,
getProjectActivity,
} from '../projectActivity';
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);
? 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);
}
});
};
+ 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) {
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);
});
};
- 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];
* 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,
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;
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,
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,
export function getTimeMachineData(
data: {
- component: string;
+ component?: string;
from?: string;
metrics: string;
p?: number;
export function getAllTimeMachineData(
data: {
- component: string;
+ component?: string;
metrics: string;
from?: string;
p?: number;
*/
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>>(
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;
+}
+++ /dev/null
-// 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",
-}
-`;
+++ /dev/null
-/*
- * 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);
- });
-});
+++ /dev/null
-/*
- * 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;
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);
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 (
</div>
)}
- {changing && props.onChange && (
+ {changing && (
<ChangeEventForm
- changeEvent={props.onChange}
event={event}
header={
isVersion
/>
)}
- {deleting && props.onDelete && (
+ {deleting && (
<RemoveEventForm
analysisKey={analysisKey}
event={event}
: translate('project_activity.remove_custom_event')
}
onClose={() => setDeleting(false)}
- onConfirm={props.onDelete}
removeEventQuestion={translate(
`project_activity.${isVersion ? 'remove_version' : 'remove_custom_event'}.question`,
)}
canAdmin?: boolean;
events: AnalysisEvent[];
isFirst?: boolean;
- onChange?: (event: string, name: string) => Promise<void>;
- onDelete?: (analysis: string, event: string) => Promise<void>;
}
function Events(props: EventsProps) {
event={event}
isFirst={isFirst}
key={event.key}
- onChange={props.onChange}
- onDelete={props.onDelete}
/>
))}
</div>
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 };
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}
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;
}
});
- 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;
'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">
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>
)}
{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>
canAdmin={canAdmin}
events={analysis.events}
isFirst={isFirst}
- onChange={props.onChangeEvent}
- onDelete={props.onDeleteEvent}
/>
)}
</ActivityAnalysisListItem>
* 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;
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,
);
(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
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))),
-);
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';
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;
}
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" />
<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}
/>
<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}
/>
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 () => {
}),
);
await ui.appLoaded();
-
expect(ui.newCodeLegend.get()).toBeInTheDocument();
});
leakPeriodDate: parseDate('2017-03-01T22:00:00.000Z').toDateString(),
}),
);
- await ui.appLoaded();
+ await ui.appLoaded();
expect(ui.newCodeLegend.get()).toBeInTheDocument();
});
);
await ui.appLoaded({ doNotWaitForBranch: true });
-
expect(ui.newCodeLegend.query()).not.toBeInTheDocument();
},
);
);
await ui.appLoaded();
-
expect(ui.baseline.get()).toBeInTheDocument();
});
);
await ui.appLoaded();
-
expect(ui.baseline.get()).toBeInTheDocument();
});
);
await ui.appLoaded();
-
expect(ui.baseline.query()).not.toBeInTheDocument();
});
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(() => {
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')}
+ />
+ );
}
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')}
+ />
+ );
}
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')}
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 {
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>
}
--- /dev/null
+/*
+ * 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,
+ });
+}
--- /dev/null
+/*
+ * 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,
+ });
+}
--- /dev/null
+/*
+ * 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),
+ };
+ });
+ };