From: Wouter Admiraal Date: Tue, 28 Feb 2023 11:32:55 +0000 (+0100) Subject: SONAR-18431 Migrate activity tests to RTL X-Git-Tag: 10.0.0.68432~177 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=a6c5e13026e8967d85d8f9914c801e8edfdeedbe;p=sonarqube.git SONAR-18431 Migrate activity tests to RTL --- diff --git a/server/sonar-web/src/main/js/api/mocks/ProjectActivityServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/ProjectActivityServiceMock.ts index 5cc18a19e2b..9905b4ab639 100644 --- a/server/sonar-web/src/main/js/api/mocks/ProjectActivityServiceMock.ts +++ b/server/sonar-web/src/main/js/api/mocks/ProjectActivityServiceMock.ts @@ -17,9 +17,11 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { cloneDeep, uniqueId } from 'lodash'; +import { chunk, cloneDeep, uniqueId } from 'lodash'; +import { parseDate } from '../../helpers/dates'; import { mockAnalysis, mockAnalysisEvent } from '../../helpers/mocks/project-activity'; -import { Analysis } from '../../types/project-activity'; +import { BranchParameters } from '../../types/branch-like'; +import { Analysis, ProjectAnalysisEventCategory } from '../../types/project-activity'; import { changeEvent, createEvent, @@ -28,81 +30,121 @@ import { getProjectActivity, } from '../projectActivity'; -export class ProjectActivityServiceMock { - readOnlyAnalysisList: Analysis[]; - analysisList: Analysis[]; - - constructor(analyses?: Analysis[]) { - this.readOnlyAnalysisList = analyses || [ - mockAnalysis({ - key: 'AXJMbIUGPAOIsUIE3eNT', - date: '2017-03-03T09:36:01+0100', - projectVersion: '1.1', - buildString: '1.1.0.2', - events: [ - mockAnalysisEvent({ category: 'VERSION', key: 'IsUIEAXJMbIUGPAO3eND', name: '1.1' }), - ], - }), - mockAnalysis({ - key: 'AXJMbIUGPAOIsUIE3eND', - date: '2017-03-02T09:36:01+0100', - projectVersion: '1.1', - buildString: '1.1.0.1', - }), - mockAnalysis({ - key: 'AXJMbIUGPAOIsUIE3eNE', - date: '2017-03-01T10:36:01+0100', - projectVersion: '1.0', - buildString: '1.0.0.2', - events: [ - mockAnalysisEvent({ category: 'VERSION', key: 'IUGPAOAXJMbIsUIE3eNE', name: '1.0' }), - ], +const PAGE_SIZE = 10; +const DEFAULT_PAGE = 0; +const UNKNOWN_PROJECT = 'unknown'; + +const defaultAnalysesList = [ + mockAnalysis({ + key: 'AXJMbIUGPAOIsUIE3eNT', + date: parseDate('2017-03-03T22:00:00.000Z').toDateString(), + projectVersion: '1.1', + buildString: '1.1.0.2', + events: [ + mockAnalysisEvent({ + category: ProjectAnalysisEventCategory.Version, + key: 'IsUIEAXJMbIUGPAO3eND', + name: '1.1', }), - mockAnalysis({ - key: 'AXJMbIUGPAOIsUIE3eNC', - date: '2017-03-01T09:36:01+0100', - projectVersion: '1.0', - buildString: '1.0.0.1', + ], + }), + mockAnalysis({ + key: 'AXJMbIUGPAOIsUIE3eND', + date: parseDate('2017-03-02T22:00:00.000Z').toDateString(), + projectVersion: '1.1', + buildString: '1.1.0.1', + }), + mockAnalysis({ + key: 'AXJMbIUGPAOIsUIE3eNE', + date: parseDate('2017-03-01T22:00:00.000Z').toDateString(), + projectVersion: '1.0', + events: [ + mockAnalysisEvent({ + category: ProjectAnalysisEventCategory.Version, + key: 'IUGPAOAXJMbIsUIE3eNE', + name: '1.0', }), - ]; + ], + }), + mockAnalysis({ + key: 'AXJMbIUGPAOIsUIE3eNC', + date: parseDate('2017-02-28T22:00:00.000Z').toDateString(), + projectVersion: '1.0', + buildString: '1.0.0.1', + }), +]; + +export class ProjectActivityServiceMock { + #analysisList: Analysis[]; - this.analysisList = cloneDeep(this.readOnlyAnalysisList); + constructor() { + this.#analysisList = cloneDeep(defaultAnalysesList); - (getProjectActivity as jest.Mock).mockImplementation(this.getActivityHandler); - (deleteAnalysis as jest.Mock).mockImplementation(this.deleteAnalysisHandler); - (createEvent as jest.Mock).mockImplementation(this.createEventHandler); - (changeEvent as jest.Mock).mockImplementation(this.changeEventHandler); - (deleteEvent as jest.Mock).mockImplementation(this.deleteEventHandler); + jest.mocked(getProjectActivity).mockImplementation(this.getActivityHandler); + jest.mocked(deleteAnalysis).mockImplementation(this.deleteAnalysisHandler); + jest.mocked(createEvent).mockImplementation(this.createEventHandler); + jest.mocked(changeEvent).mockImplementation(this.changeEventHandler); + jest.mocked(deleteEvent).mockImplementation(this.deleteEventHandler); } reset = () => { - this.analysisList = cloneDeep(this.readOnlyAnalysisList); + this.#analysisList = cloneDeep(defaultAnalysesList); + }; + + getAnalysesList = () => { + return this.#analysisList; + }; + + setAnalysesList = (analyses: Analysis[]) => { + this.#analysisList = analyses; }; - getActivityHandler = () => { + getActivityHandler = ( + data: { + project: string; + statuses?: string; + category?: string; + from?: string; + p?: number; + ps?: number; + } & BranchParameters + ) => { + const { project, ps = PAGE_SIZE, 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) { + const fromTime = parseDate(from).getTime(); + analyses = analyses.filter((a) => parseDate(a.date).getTime() >= fromTime); + } + + const analysesChunked = chunk(analyses, ps); + return this.reply({ - analyses: this.analysisList, - paging: { - pageIndex: 1, - pageSize: 100, - total: this.analysisList.length, - }, + paging: { pageSize: ps, total: analyses.length, pageIndex: p }, + analyses: analysesChunked[p - 1] ?? [], }); }; deleteAnalysisHandler = (analysisKey: string) => { - const i = this.analysisList.findIndex(({ key }) => key === analysisKey); - if (i !== undefined) { - this.analysisList.splice(i, 1); - return this.reply(); + const i = this.#analysisList.findIndex(({ key }) => key === analysisKey); + if (i === undefined) { + throw new Error(`Could not find analysis with key: ${analysisKey}`); } - throw new Error(`Could not find analysis with key: ${analysisKey}`); + this.#analysisList.splice(i, 1); + return this.reply(undefined); }; createEventHandler = ( analysisKey: string, name: string, - category = 'OTHER', + category = ProjectAnalysisEventCategory.Other, description?: string ) => { const analysis = this.findAnalysis(analysisKey); @@ -136,12 +178,12 @@ export class ProjectActivityServiceMock { analysis.events.splice(eventIndex, 1); - return this.reply(); + return this.reply(undefined); }; findEvent = (eventKey: string): [number, string] => { let analysisKey; - const eventIndex = this.analysisList.reduce((acc, { key, events }) => { + const eventIndex = this.#analysisList.reduce((acc, { key, events }) => { if (acc === undefined) { const i = events.findIndex(({ key }) => key === eventKey); if (i > -1) { @@ -161,7 +203,7 @@ export class ProjectActivityServiceMock { }; findAnalysis = (analysisKey: string) => { - const analysis = this.analysisList.find(({ key }) => key === analysisKey); + const analysis = this.#analysisList.find(({ key }) => key === analysisKey); if (analysis !== undefined) { return analysis; @@ -170,7 +212,7 @@ export class ProjectActivityServiceMock { throw new Error(`Could not find analysis with key: ${analysisKey}`); }; - reply(response?: T): Promise { - return Promise.resolve(response ? cloneDeep(response) : undefined); + reply(response: T): Promise { + return Promise.resolve(cloneDeep(response)); } } diff --git a/server/sonar-web/src/main/js/api/mocks/TimeMachineServiceMock.ts b/server/sonar-web/src/main/js/api/mocks/TimeMachineServiceMock.ts new file mode 100644 index 00000000000..cda4ebdd0d0 --- /dev/null +++ b/server/sonar-web/src/main/js/api/mocks/TimeMachineServiceMock.ts @@ -0,0 +1,121 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 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 { chunk, cloneDeep, times } from 'lodash'; +import { parseDate } from '../../helpers/dates'; +import { mockHistoryItem, mockMeasureHistory } from '../../helpers/mocks/project-activity'; +import { BranchParameters } from '../../types/branch-like'; +import { MetricKey } from '../../types/metrics'; +import { MeasureHistory } from '../../types/project-activity'; +import { getAllTimeMachineData, getTimeMachineData, TimeMachineResponse } from '../time-machine'; + +const PAGE_SIZE = 10; +const DEFAULT_PAGE = 0; +const HISTORY_COUNT = 10; +const START_DATE = '2016-01-01T00:00:00.000Z'; + +const defaultMeasureHistory = [ + MetricKey.bugs, + MetricKey.code_smells, + MetricKey.confirmed_issues, + MetricKey.vulnerabilities, + MetricKey.blocker_violations, + MetricKey.lines_to_cover, + MetricKey.uncovered_lines, + MetricKey.security_hotspots_reviewed, + MetricKey.coverage, + MetricKey.duplicated_lines_density, + MetricKey.test_success_density, +].map((metric) => { + return mockMeasureHistory({ + metric, + history: times(HISTORY_COUNT, (i) => { + const date = parseDate(START_DATE); + date.setDate(date.getDate() + i); + return mockHistoryItem({ value: i.toString(), date }); + }), + }); +}); + +export class TimeMachineServiceMock { + #measureHistory: MeasureHistory[]; + + constructor() { + this.#measureHistory = cloneDeep(defaultMeasureHistory); + + jest.mocked(getTimeMachineData).mockImplementation(this.handleGetTimeMachineData); + jest.mocked(getAllTimeMachineData).mockImplementation(this.handleGetAllTimeMachineData); + } + + handleGetTimeMachineData = ( + data: { + component: string; + from?: string; + metrics: string; + p?: number; + ps?: number; + to?: string; + } & BranchParameters + ) => { + const { ps = PAGE_SIZE, p = DEFAULT_PAGE } = data; + + const measureHistoryChunked = chunk(this.#measureHistory, ps); + + return this.reply({ + paging: { pageSize: ps, total: this.#measureHistory.length, pageIndex: p }, + measures: measureHistoryChunked[p - 1] ? this.map(measureHistoryChunked[p - 1]) : [], + }); + }; + + handleGetAllTimeMachineData = ( + data: { + component: string; + metrics: string; + from?: string; + p?: number; + to?: string; + } & BranchParameters, + _prev?: TimeMachineResponse + ) => { + const { p = DEFAULT_PAGE } = data; + return this.reply({ + paging: { pageSize: PAGE_SIZE, total: this.#measureHistory.length, pageIndex: p }, + measures: this.map(this.#measureHistory), + }); + }; + + setMeasureHistory = (list: MeasureHistory[]) => { + this.#measureHistory = list; + }; + + map = (list: MeasureHistory[]) => { + return list.map((item) => ({ + ...item, + history: item.history.map((h) => ({ ...h, date: h.date.toDateString() })), + })); + }; + + reset = () => { + this.#measureHistory = cloneDeep(defaultMeasureHistory); + }; + + reply(response: T): Promise { + return Promise.resolve(cloneDeep(response)); + } +} diff --git a/server/sonar-web/src/main/js/api/projectActivity.ts b/server/sonar-web/src/main/js/api/projectActivity.ts index a9164852f13..41414bf129a 100644 --- a/server/sonar-web/src/main/js/api/projectActivity.ts +++ b/server/sonar-web/src/main/js/api/projectActivity.ts @@ -20,7 +20,11 @@ import { throwGlobalError } from '../helpers/error'; import { getJSON, post, postJSON, RequestData } from '../helpers/request'; import { BranchParameters } from '../types/branch-like'; -import { Analysis } from '../types/project-activity'; +import { + Analysis, + ApplicationAnalysisEventCategory, + ProjectAnalysisEventCategory, +} from '../types/project-activity'; import { Paging } from '../types/types'; export enum ProjectActivityStatuses { @@ -46,7 +50,7 @@ interface CreateEventResponse { analysis: string; key: string; name: string; - category: string; + category: ProjectAnalysisEventCategory | ApplicationAnalysisEventCategory; description?: string; } diff --git a/server/sonar-web/src/main/js/apps/overview/branches/ActivityPanel.tsx b/server/sonar-web/src/main/js/apps/overview/branches/ActivityPanel.tsx index fcd003814d9..41edf6d1698 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/ActivityPanel.tsx +++ b/server/sonar-web/src/main/js/apps/overview/branches/ActivityPanel.tsx @@ -95,7 +95,7 @@ export function ActivityPanel(props: ActivityPanelProps) {
- + { @@ -33,8 +34,8 @@ function shallowRender(props: Partial = {}) { { expect( - shallow() + shallow( + + ) ).toMatchSnapshot(); }); it('should render a version correctly', () => { expect( - shallow() + shallow( + + ) ).toMatchSnapshot(); }); it('should render rich quality gate event', () => { const event: AnalysisEvent = { - category: 'QUALITY_GATE', + category: ProjectAnalysisEventCategory.QualityGate, key: 'foo1234', name: '', qualityGate: { diff --git a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/ActivityPanel-test.tsx.snap b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/ActivityPanel-test.tsx.snap index 609f69f5ca9..5ed1dbc5711 100644 --- a/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/ActivityPanel-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/overview/branches/__tests__/__snapshots__/ActivityPanel-test.tsx.snap @@ -34,7 +34,7 @@ exports[`should render correctly 1`] = ` }, ] } - updateGraph={[MockFunction]} + onUpdateGraph={[MockFunction]} /> { describe('changeEvent', () => { it('should correctly update an event', () => { expect( - actions.changeEvent('A1', { key: 'E1', name: 'changed', category: 'VERSION' })(state) - .analyses[0] + actions.changeEvent('A1', { + key: 'E1', + name: 'changed', + category: ProjectAnalysisEventCategory.Version, + })(state).analyses[0] ).toMatchSnapshot(); expect( - actions.changeEvent('A2', { key: 'E2', name: 'foo', category: 'VERSION' })(state).analyses[1] - .events + actions.changeEvent('A2', { + key: 'E2', + name: 'foo', + category: ProjectAnalysisEventCategory.Version, + })(state).analyses[1].events ).toHaveLength(0); }); }); diff --git a/server/sonar-web/src/main/js/apps/projectActivity/__tests__/utils-test.ts b/server/sonar-web/src/main/js/apps/projectActivity/__tests__/utils-test.ts index 49df8ecb0b3..8b802d8fd3a 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/__tests__/utils-test.ts +++ b/server/sonar-web/src/main/js/apps/projectActivity/__tests__/utils-test.ts @@ -19,7 +19,7 @@ */ import { DEFAULT_GRAPH } from '../../../components/activity-graph/utils'; import * as dates from '../../../helpers/dates'; -import { GraphType } from '../../../types/project-activity'; +import { GraphType, ProjectAnalysisEventCategory } from '../../../types/project-activity'; import * as utils from '../utils'; jest.mock('date-fns', () => { @@ -38,13 +38,21 @@ const ANALYSES = [ { key: 'AVyMjlK1HjR_PLDzRbB9', date: dates.parseDate('2017-06-09T13:06:10.000Z'), - events: [{ key: 'AVyM9oI1HjR_PLDzRciU', category: 'VERSION', name: '1.1-SNAPSHOT' }], + events: [ + { + key: 'AVyM9oI1HjR_PLDzRciU', + category: ProjectAnalysisEventCategory.Version, + name: '1.1-SNAPSHOT', + }, + ], }, { key: 'AVyM9n3cHjR_PLDzRciT', date: dates.parseDate('2017-06-09T11:12:27.000Z'), events: [] }, { key: 'AVyMjlK1HjR_PLDzRbB9', date: dates.parseDate('2017-06-09T11:12:27.000Z'), - events: [{ key: 'AVyM9oI1HjR_PLDzRciU', category: 'VERSION', name: '1.1' }], + events: [ + { key: 'AVyM9oI1HjR_PLDzRciU', category: ProjectAnalysisEventCategory.Version, name: '1.1' }, + ], }, { key: 'AVxZtCpH7841nF4RNEMI', @@ -52,7 +60,7 @@ const ANALYSES = [ events: [ { key: 'AVxZtC-N7841nF4RNEMJ', - category: 'QUALITY_PROFILE', + category: ProjectAnalysisEventCategory.QualityProfile, name: 'Changes in "Default - SonarSource conventions" (Java)', }, ], @@ -62,10 +70,10 @@ const ANALYSES = [ key: 'AVwQF7kwl-nNFgFWOJ3V', date: dates.parseDate('2017-05-16T07:09:59.000Z'), events: [ - { key: 'AVyM9oI1HjR_PLDzRciU', category: 'VERSION', name: '1.0' }, + { key: 'AVyM9oI1HjR_PLDzRciU', category: ProjectAnalysisEventCategory.Version, name: '1.0' }, { key: 'AVwQF7zXl-nNFgFWOJ3W', - category: 'QUALITY_PROFILE', + category: ProjectAnalysisEventCategory.QualityProfile, name: 'Changes in "Default - SonarSource conventions" (Java)', }, ], diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/Event.tsx b/server/sonar-web/src/main/js/apps/projectActivity/components/Event.tsx index 1a0071ddc03..19bff70ecf1 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/Event.tsx +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/Event.tsx @@ -21,7 +21,7 @@ import * as React from 'react'; import EventInner from '../../../components/activity-graph/EventInner'; import { DeleteButton, EditButton } from '../../../components/controls/buttons'; import { translate } from '../../../helpers/l10n'; -import { AnalysisEvent } from '../../../types/project-activity'; +import { AnalysisEvent, ProjectAnalysisEventCategory } from '../../../types/project-activity'; import ChangeEventForm from './forms/ChangeEventForm'; import RemoveEventForm from './forms/RemoveEventForm'; @@ -34,14 +34,14 @@ export interface EventProps { onDelete?: (analysisKey: string, event: string) => Promise; } -export function Event(props: EventProps) { +function Event(props: EventProps) { const { analysisKey, event, canAdmin, isFirst } = props; const [changing, setChanging] = React.useState(false); const [deleting, setDeleting] = React.useState(false); - const isOther = event.category === 'OTHER'; - const isVersion = event.category === 'VERSION'; + 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 showActions = canAdmin && (canChange || canDelete); diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/Events.tsx b/server/sonar-web/src/main/js/apps/projectActivity/components/Events.tsx index 4310574a1a0..470ef7ccc80 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/Events.tsx +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/Events.tsx @@ -19,7 +19,7 @@ */ import { sortBy } from 'lodash'; import * as React from 'react'; -import { AnalysisEvent } from '../../../types/project-activity'; +import { AnalysisEvent, ProjectAnalysisEventCategory } from '../../../types/project-activity'; import Event from './Event'; export interface EventsProps { @@ -31,13 +31,13 @@ export interface EventsProps { onDelete?: (analysis: string, event: string) => Promise; } -export function Events(props: EventsProps) { +function Events(props: EventsProps) { const { analysisKey, canAdmin, events, isFirst } = props; const sortedEvents = sortBy( events, // versions last - (event) => (event.category === 'VERSION' ? 1 : 0), + (event) => (event.category === ProjectAnalysisEventCategory.Version ? 1 : 0), // then the rest sorted by category 'category' ); diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.tsx b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.tsx index e523374a060..130b24c6cfb 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.tsx +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysesList.tsx @@ -19,7 +19,6 @@ */ import classNames from 'classnames'; import { isEqual } from 'date-fns'; -import { throttle } from 'lodash'; import * as React from 'react'; import Tooltip from '../../../components/controls/Tooltip'; import DateFormatter from '../../../components/intl/DateFormatter'; @@ -31,103 +30,35 @@ import { activityQueryChanged, getAnalysesByVersionByDay, Query } from '../utils import ProjectActivityAnalysis from './ProjectActivityAnalysis'; interface Props { - addCustomEvent: (analysis: string, name: string, category?: string) => Promise; - addVersion: (analysis: string, version: string) => Promise; + onAddCustomEvent: (analysis: string, name: string, category?: string) => Promise; + onAddVersion: (analysis: string, version: string) => Promise; analyses: ParsedAnalysis[]; analysesLoading: boolean; canAdmin?: boolean; canDeleteAnalyses?: boolean; - changeEvent: (event: string, name: string) => Promise; - deleteAnalysis: (analysis: string) => Promise; - deleteEvent: (analysis: string, event: string) => Promise; + onChangeEvent: (event: string, name: string) => Promise; + onDeleteAnalysis: (analysis: string) => Promise; + onDeleteEvent: (analysis: string, event: string) => Promise; initializing: boolean; leakPeriodDate?: Date; project: { qualifier: string }; query: Query; - updateQuery: (changes: Partial) => void; + onUpdateQuery: (changes: Partial) => void; } -const LIST_MARGIN_TOP = 36; +const LIST_MARGIN_TOP = 24; export default class ProjectActivityAnalysesList extends React.PureComponent { - analyses?: HTMLCollectionOf; - badges?: HTMLCollectionOf; scrollContainer?: HTMLUListElement | null; - constructor(props: Props) { - super(props); - this.handleScroll = throttle(this.handleScroll, 20); - } - - componentDidMount() { - this.badges = document.getElementsByClassName( - 'project-activity-version-badge' - ) as HTMLCollectionOf; - this.analyses = document.getElementsByClassName( - 'project-activity-analysis' - ) as HTMLCollectionOf; - } - componentDidUpdate(prevProps: Props) { - if (!this.scrollContainer) { - return; - } - if (activityQueryChanged(prevProps.query, this.props.query)) { - this.resetScrollTop(0, true); + if (this.scrollContainer && activityQueryChanged(prevProps.query, this.props.query)) { + this.scrollContainer.scrollTop = 0; } } - handleScroll = () => this.updateStickyBadges(true); - - resetScrollTop = (newScrollTop: number, forceBadgeAlignement?: boolean) => { - if (this.scrollContainer) { - this.scrollContainer.scrollTop = newScrollTop; - } - if (this.badges) { - for (let i = 1; i < this.badges.length; i++) { - this.badges[i].removeAttribute('originOffsetTop'); - this.badges[i].classList.remove('sticky'); - } - } - this.updateStickyBadges(forceBadgeAlignement); - }; - - updateStickyBadges = (forceBadgeAlignement?: boolean) => { - if (!this.scrollContainer || !this.badges) { - return; - } - - const { scrollTop } = this.scrollContainer; - if (scrollTop == null) { - return; - } - - let newScrollTop; - for (let i = 1; i < this.badges.length; i++) { - const badge = this.badges[i]; - let originOffsetTop = badge.getAttribute('originOffsetTop'); - if (originOffsetTop == null) { - // Set the originOffsetTop attribute, to avoid using getBoundingClientRect - originOffsetTop = String(badge.offsetTop); - badge.setAttribute('originOffsetTop', originOffsetTop); - } - if (Number(originOffsetTop) < scrollTop + 18 + i * 2) { - if (forceBadgeAlignement && !badge.classList.contains('sticky')) { - newScrollTop = originOffsetTop; - } - badge.classList.add('sticky'); - } else { - badge.classList.remove('sticky'); - } - } - - if (forceBadgeAlignement && newScrollTop != null) { - this.scrollContainer.scrollTop = Number(newScrollTop) - 6; - } - }; - - updateSelectedDate = (date: Date) => { - this.props.updateQuery({ selectedDate: date }); + handleUpdateSelectedDate = (date: Date) => { + this.props.onUpdateQuery({ selectedDate: date }); }; shouldRenderBaselineMarker(analysis: ParsedAnalysis): boolean { @@ -143,20 +74,20 @@ export default class ProjectActivityAnalysesList extends React.PureComponent ); } @@ -183,7 +114,6 @@ export default class ProjectActivityAnalysesList extends React.PureComponent (this.scrollContainer = element)} style={{ marginTop: diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.tsx b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.tsx index c7b97ff4f7c..7a0a99fed67 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.tsx +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityAnalysis.tsx @@ -38,22 +38,22 @@ import AddEventForm from './forms/AddEventForm'; import RemoveAnalysisForm from './forms/RemoveAnalysisForm'; export interface ProjectActivityAnalysisProps extends WrappedComponentProps { - addCustomEvent: (analysis: string, name: string, category?: string) => Promise; - addVersion: (analysis: string, version: string) => Promise; + onAddCustomEvent: (analysis: string, name: string, category?: string) => Promise; + onAddVersion: (analysis: string, version: string) => Promise; analysis: ParsedAnalysis; canAdmin?: boolean; canDeleteAnalyses?: boolean; canCreateVersion: boolean; - changeEvent: (event: string, name: string) => Promise; - deleteAnalysis: (analysis: string) => Promise; - deleteEvent: (analysis: string, event: string) => Promise; + onChangeEvent: (event: string, name: string) => Promise; + onDeleteAnalysis: (analysis: string) => Promise; + onDeleteEvent: (analysis: string, event: string) => Promise; isBaseline: boolean; isFirst: boolean; selected: boolean; - updateSelectedDate: (date: Date) => void; + onUpdateSelectedDate: (date: Date) => void; } -export function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) { +function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) { let node: HTMLLIElement | null = null; const { @@ -89,7 +89,7 @@ export function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) { className={classNames('project-activity-analysis bordered-top bordered-bottom', { selected, })} - onClick={() => props.updateSelectedDate(analysis.date)} + onClick={() => props.onUpdateSelectedDate(analysis.date)} ref={(ref) => (node = ref)} >
@@ -102,7 +102,7 @@ export function ProjectActivityAnalysis(props: ProjectActivityAnalysisProps) { 'project_activity.show_analysis_X_on_graph', analysis.buildString || formatDate(parsedDate, formatterOption) )} - onClick={() => props.updateSelectedDate(analysis.date)} + onClick={() => props.onUpdateSelectedDate(analysis.date)} >
); diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityPageFilters.tsx b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityPageFilters.tsx index df69b89b786..c934d236443 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityPageFilters.tsx +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityPageFilters.tsx @@ -20,9 +20,13 @@ import * as React from 'react'; import Select from '../../../components/controls/Select'; import { translate } from '../../../helpers/l10n'; -import { ComponentQualifier } from '../../../types/component'; +import { ComponentQualifier, isPortfolioLike } from '../../../types/component'; +import { + ApplicationAnalysisEventCategory, + ProjectAnalysisEventCategory, +} from '../../../types/project-activity'; import { Component } from '../../../types/types'; -import { APPLICATION_EVENT_TYPES, EVENT_TYPES, Query } from '../utils'; +import { Query } from '../utils'; import ProjectActivityDateInput from './ProjectActivityDateInput'; interface ProjectActivityPageFiltersProps { @@ -37,7 +41,9 @@ export default function ProjectActivityPageFilters(props: ProjectActivityPageFil const { project, category, from, to, updateQuery } = props; const isApp = project.qualifier === ComponentQualifier.Application; - const eventTypes = isApp ? APPLICATION_EVENT_TYPES : EVENT_TYPES; + const eventTypes = isApp + ? Object.values(ApplicationAnalysisEventCategory) + : Object.values(ProjectAnalysisEventCategory); const options = eventTypes.map((category) => ({ label: translate('event.category', category), value: category, @@ -52,14 +58,15 @@ export default function ProjectActivityPageFilters(props: ProjectActivityPageFil return (
- {!([ComponentQualifier.Portfolio, ComponentQualifier.SubPortfolio] as string[]).includes( - project.qualifier - ) && ( + {!isPortfolioLike(project.qualifier) && (
-
- -
-`; diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/AddEventForm-test.tsx b/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/AddEventForm-test.tsx deleted file mode 100644 index 0d2e0d5bd86..00000000000 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/AddEventForm-test.tsx +++ /dev/null @@ -1,41 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2023 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 { shallow } from 'enzyme'; -import * as React from 'react'; -import AddEventForm from '../AddEventForm'; - -it('should render correctly', () => { - expect( - shallow( - - ) - ).toMatchSnapshot(); -}); diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/ChangeEventForm-test.tsx b/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/ChangeEventForm-test.tsx deleted file mode 100644 index 5d222624f90..00000000000 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/ChangeEventForm-test.tsx +++ /dev/null @@ -1,35 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2023 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 { shallow } from 'enzyme'; -import * as React from 'react'; -import ChangeEventForm from '../ChangeEventForm'; - -it('should render correctly', () => { - expect( - shallow( - - ) - ).toMatchSnapshot(); -}); diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/RemoveEventForm-test.tsx b/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/RemoveEventForm-test.tsx deleted file mode 100644 index cf712eb8a17..00000000000 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/RemoveEventForm-test.tsx +++ /dev/null @@ -1,56 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2023 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 { shallow } from 'enzyme'; -import * as React from 'react'; -import ConfirmModal from '../../../../../components/controls/ConfirmModal'; -import { mockAnalysisEvent } from '../../../../../helpers/mocks/project-activity'; -import RemoveEventForm, { RemoveEventFormProps } from '../RemoveEventForm'; - -it('should render correctly', () => { - expect(shallowRender()).toMatchSnapshot(); -}); - -it('should correctly confirm', () => { - const onConfirm = jest.fn(); - const wrapper = shallowRender({ onConfirm }); - wrapper.find(ConfirmModal).prop('onConfirm')(); - expect(onConfirm).toHaveBeenCalledWith('foo', 'bar'); -}); - -it('should correctly cancel', () => { - const onClose = jest.fn(); - const wrapper = shallowRender({ onClose }); - wrapper.find(ConfirmModal).prop('onClose')(); - expect(onClose).toHaveBeenCalled(); -}); - -function shallowRender(props: Partial = {}) { - return shallow( - - ); -} diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/__snapshots__/AddEventForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/__snapshots__/AddEventForm-test.tsx.snap deleted file mode 100644 index 41f6352fb27..00000000000 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/__snapshots__/AddEventForm-test.tsx.snap +++ /dev/null @@ -1,29 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render correctly 1`] = ` - -
- - -
-
-`; diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/__snapshots__/ChangeEventForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/__snapshots__/ChangeEventForm-test.tsx.snap deleted file mode 100644 index 8b00505b13c..00000000000 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/__snapshots__/ChangeEventForm-test.tsx.snap +++ /dev/null @@ -1,29 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render correctly 1`] = ` - -
- - -
-
-`; diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/__snapshots__/RemoveEventForm-test.tsx.snap b/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/__snapshots__/RemoveEventForm-test.tsx.snap deleted file mode 100644 index 8d69aad167c..00000000000 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/forms/__tests__/__snapshots__/RemoveEventForm-test.tsx.snap +++ /dev/null @@ -1,13 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`should render correctly 1`] = ` - - Remove foo? - -`; diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/projectActivity.css b/server/sonar-web/src/main/js/apps/projectActivity/components/projectActivity.css index ec678f869d8..fabdaf7335b 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/projectActivity.css +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/projectActivity.css @@ -55,8 +55,7 @@ overflow: auto; flex-grow: 1; flex-shrink: 0; - padding: calc(2 * var(--gridSize)) calc(2 * var(--gridSize)) calc(2 * var(--gridSize)) - calc(1.5 * var(--gridSize)); + padding: 0 calc(2 * var(--gridSize)) calc(2 * var(--gridSize)) calc(1.5 * var(--gridSize)); } .project-activity-day { @@ -140,24 +139,20 @@ } .project-activity-version-badge { - margin-left: calc(-1.5 * var(--gridSize)); - padding-top: var(--gridSize); - padding-bottom: var(--gridSize); - background-color: white; -} - -.project-activity-version-badge.sticky, -.project-activity-version-badge.first { - position: absolute; - top: 0; + position: sticky; + top: calc(-3 * var(--gridSize)); left: calc(1.5 * var(--gridSize)); right: calc(2 * var(--gridSize)); + margin-left: calc(-1.5 * var(--gridSize)); + background-color: white; padding-top: calc(3 * var(--gridSize)); + padding-bottom: var(--gridSize); z-index: var(--belowNormalZIndex); } -.project-activity-version-badge.sticky + .project-activity-days-list { - padding-top: 36px; +.project-activity-version-badge.first { + top: 0; + padding-top: 0; } .project-activity-version-badge .analysis-version { diff --git a/server/sonar-web/src/main/js/apps/projectActivity/utils.ts b/server/sonar-web/src/main/js/apps/projectActivity/utils.ts index 67cc97cb103..c6d689e2a41 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/utils.ts +++ b/server/sonar-web/src/main/js/apps/projectActivity/utils.ts @@ -43,9 +43,6 @@ export interface Query { to?: Date; } -export const EVENT_TYPES = ['VERSION', 'QUALITY_GATE', 'QUALITY_PROFILE', 'OTHER']; -export const APPLICATION_EVENT_TYPES = ['QUALITY_GATE', 'DEFINITION_CHANGE', 'OTHER']; - export function activityQueryChanged(prevQuery: Query, nextQuery: Query) { return prevQuery.category !== nextQuery.category || datesQueryChanged(prevQuery, nextQuery); } @@ -62,10 +59,6 @@ export function historyQueryChanged(prevQuery: Query, nextQuery: Query) { return prevQuery.graph !== nextQuery.graph; } -export function selectedDateQueryChanged(prevQuery: Query, nextQuery: Query) { - return !isEqual(prevQuery.selectedDate, nextQuery.selectedDate); -} - interface AnalysesByDay { byDay: Dict; version: string | null; diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/BranchAnalysisList-test.tsx b/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/BranchAnalysisList-test.tsx index 588c9b4409e..1bd39ffd4b0 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/BranchAnalysisList-test.tsx +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/BranchAnalysisList-test.tsx @@ -24,6 +24,7 @@ import { getProjectActivity } from '../../../../api/projectActivity'; import { toShortNotSoISOString } from '../../../../helpers/dates'; import { mockAnalysis, mockAnalysisEvent } from '../../../../helpers/mocks/project-activity'; import { waitAndUpdate } from '../../../../helpers/testUtils'; +import { ProjectAnalysisEventCategory } from '../../../../types/project-activity'; import BranchAnalysisList from '../BranchAnalysisList'; jest.mock('date-fns', () => { @@ -70,7 +71,10 @@ it('should render correctly', async () => { date: '2017-03-02T08:36:01', events: [ mockAnalysisEvent(), - mockAnalysisEvent({ category: 'VERSION', qualityGate: undefined }), + mockAnalysisEvent({ + category: ProjectAnalysisEventCategory.Version, + qualityGate: undefined, + }), ], projectVersion: '4.1', }), diff --git a/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/BranchAnalysisListRenderer-test.tsx b/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/BranchAnalysisListRenderer-test.tsx index c3b12396834..1dc6e061f7d 100644 --- a/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/BranchAnalysisListRenderer-test.tsx +++ b/server/sonar-web/src/main/js/apps/projectBaseline/components/__tests__/BranchAnalysisListRenderer-test.tsx @@ -20,6 +20,7 @@ import { shallow } from 'enzyme'; import * as React from 'react'; import { mockAnalysisEvent, mockParsedAnalysis } from '../../../../helpers/mocks/project-activity'; +import { ProjectAnalysisEventCategory } from '../../../../types/project-activity'; import BranchAnalysisListRenderer, { BranchAnalysisListRendererProps, } from '../BranchAnalysisListRenderer'; @@ -58,7 +59,7 @@ const analyses = [ date: new Date('2017-03-02T08:36:01Z'), events: [ mockAnalysisEvent(), - mockAnalysisEvent({ category: 'VERSION', qualityGate: undefined }), + mockAnalysisEvent({ category: ProjectAnalysisEventCategory.Version, qualityGate: undefined }), ], projectVersion: '4.1', }), diff --git a/server/sonar-web/src/main/js/components/activity-graph/AddGraphMetric.tsx b/server/sonar-web/src/main/js/components/activity-graph/AddGraphMetric.tsx index e0996f4a6fc..00529d77f7e 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/AddGraphMetric.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/AddGraphMetric.tsx @@ -17,7 +17,7 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { find, sortBy } from 'lodash'; +import { sortBy } from 'lodash'; import * as React from 'react'; import { Button } from '../../components/controls/buttons'; import Dropdown from '../../components/controls/Dropdown'; @@ -28,10 +28,10 @@ import { Metric } from '../../types/types'; import AddGraphMetricPopup from './AddGraphMetricPopup'; interface Props { - addMetric: (metric: string) => void; metrics: Metric[]; metricsTypeFilter?: string[]; - removeMetric: (metric: string) => void; + onAddMetric: (metric: string) => void; + onRemoveMetric: (metric: string) => void; selectedMetrics: string[]; } @@ -77,13 +77,14 @@ export default class AddGraphMetric extends React.PureComponent { .map((metric) => metric.key); }; - getSelectedMetricsElements = (metrics: Metric[], selectedMetrics?: string[]) => { - const selected = selectedMetrics || this.props.selectedMetrics; - return metrics.filter((metric) => selected.includes(metric.key)).map((metric) => metric.key); + getSelectedMetricsElements = (metrics: Metric[], selectedMetrics: string[]) => { + return metrics + .filter((metric) => selectedMetrics.includes(metric.key)) + .map((metric) => metric.key); }; getLocalizedMetricNameFromKey = (key: string) => { - const metric = find(this.props.metrics, { key }); + const metric = this.props.metrics.find((m) => m.key === key); return metric === undefined ? key : getLocalizedMetricName(metric); }; @@ -93,7 +94,7 @@ export default class AddGraphMetric extends React.PureComponent { }; onSelect = (metric: string) => { - this.props.addMetric(metric); + this.props.onAddMetric(metric); this.setState((state) => { return { selectedMetrics: sortBy([...state.selectedMetrics, metric]), @@ -103,7 +104,7 @@ export default class AddGraphMetric extends React.PureComponent { }; onUnselect = (metric: string) => { - this.props.removeMetric(metric); + this.props.onRemoveMetric(metric); this.setState((state) => { return { metrics: sortBy([...state.metrics, metric]), diff --git a/server/sonar-web/src/main/js/components/activity-graph/DefinitionChangeEventInner.tsx b/server/sonar-web/src/main/js/components/activity-graph/DefinitionChangeEventInner.tsx index 345168e6a0b..9723fb1fa6e 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/DefinitionChangeEventInner.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/DefinitionChangeEventInner.tsx @@ -24,9 +24,14 @@ import { translate } from '../../helpers/l10n'; import { limitComponentName } from '../../helpers/path'; import { getProjectUrl } from '../../helpers/urls'; import { BranchLike } from '../../types/branch-like'; -import { AnalysisEvent } from '../../types/project-activity'; +import { + AnalysisEvent, + ApplicationAnalysisEventCategory, + DefinitionChangeType, +} from '../../types/project-activity'; import Link from '../common/Link'; import { ButtonLink } from '../controls/buttons'; +import ClickEventBoundary from '../controls/ClickEventBoundary'; import BranchIcon from '../icons/BranchIcon'; import DropdownIcon from '../icons/DropdownIcon'; @@ -34,7 +39,10 @@ export type DefinitionChangeEvent = AnalysisEvent & Required>; export function isDefinitionChangeEvent(event: AnalysisEvent): event is DefinitionChangeEvent { - return event.category === 'DEFINITION_CHANGE' && event.definitionChange !== undefined; + return ( + event.category === ApplicationAnalysisEventCategory.DefinitionChange && + event.definitionChange !== undefined + ); } interface Props { @@ -47,25 +55,21 @@ interface State { expanded: boolean; } +const NAME_MAX_LENGTH = 28; + export class DefinitionChangeEventInner extends React.PureComponent { state: State = { expanded: false }; - stopPropagation = (event: React.MouseEvent) => { - event.stopPropagation(); - }; - toggleProjectsList = () => { this.setState((state) => ({ expanded: !state.expanded })); }; renderProjectLink = (project: { key: string; name: string }, branch: string | undefined) => ( - - {limitComponentName(project.name, 28)} - + + + {limitComponentName(project.name, NAME_MAX_LENGTH)} + + ); renderBranch = (branch = translate('branches.main_branch')) => ( @@ -76,7 +80,7 @@ export class DefinitionChangeEventInner extends React.PureComponent - -
- ); - } else if (project.changeType === 'REMOVED') { - const message = mainBranch - ? 'event.definition_change.removed' - : 'event.definition_change.branch_removed'; - return ( -
+ switch (project.changeType) { + case DefinitionChangeType.Added: { + const message = mainBranch + ? 'event.definition_change.added' + : 'event.definition_change.branch_added'; + return ( +
+ +
+ ); + } + + case DefinitionChangeType.Removed: { + const message = mainBranch + ? 'event.definition_change.removed' + : 'event.definition_change.branch_removed'; + return ( +
+ +
+ ); + } + + case DefinitionChangeType.BranchChanged: + return ( -
- ); - } else if (project.changeType === 'BRANCH_CHANGED') { - return ( - - ); + ); } - return null; } render() { diff --git a/server/sonar-web/src/main/js/components/activity-graph/EventInner.tsx b/server/sonar-web/src/main/js/components/activity-graph/EventInner.tsx index 7b26effe7a4..c792a734874 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/EventInner.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/EventInner.tsx @@ -42,9 +42,8 @@ export default function EventInner({ event, readonly }: EventInnerProps) { ); } - return ( - + {translate('event.category', event.category)}: diff --git a/server/sonar-web/src/main/js/components/activity-graph/GraphsHeader.tsx b/server/sonar-web/src/main/js/components/activity-graph/GraphsHeader.tsx index eb32a50ad72..7065b9c17b0 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/GraphsHeader.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/GraphsHeader.tsx @@ -28,35 +28,29 @@ import './styles.css'; import { getGraphTypes, isCustomGraph } from './utils'; interface Props { - addCustomMetric?: (metric: string) => void; + onAddCustomMetric?: (metric: string) => void; className?: string; - removeCustomMetric?: (metric: string) => void; + onRemoveCustomMetric?: (metric: string) => void; graph: GraphType; metrics: Metric[]; metricsTypeFilter?: string[]; selectedMetrics?: string[]; - updateGraph: (graphType: string) => void; + onUpdateGraph: (graphType: string) => void; } export default class GraphsHeader extends React.PureComponent { handleGraphChange = (option: { value: string }) => { if (option.value !== this.props.graph) { - this.props.updateGraph(option.value); + this.props.onUpdateGraph(option.value); } }; render() { - const { - addCustomMetric, - className, - graph, - metrics, - metricsTypeFilter, - removeCustomMetric, - selectedMetrics = [], - } = this.props; + const { className, graph, metrics, metricsTypeFilter, selectedMetrics = [] } = this.props; - const types = getGraphTypes(addCustomMetric === undefined || removeCustomMetric === undefined); + const types = getGraphTypes( + this.props.onAddCustomMetric === undefined || this.props.onRemoveCustomMetric === undefined + ); const selectOptions = types.map((type) => ({ label: translate('project_activity.graphs', type), @@ -80,13 +74,13 @@ export default class GraphsHeader extends React.PureComponent { />
{isCustomGraph(graph) && - addCustomMetric !== undefined && - removeCustomMetric !== undefined && ( + this.props.onAddCustomMetric !== undefined && + this.props.onRemoveCustomMetric !== undefined && ( )} diff --git a/server/sonar-web/src/main/js/components/activity-graph/GraphsZoom.tsx b/server/sonar-web/src/main/js/components/activity-graph/GraphsZoom.tsx index 53bdf13d621..72213529e3b 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/GraphsZoom.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/GraphsZoom.tsx @@ -31,11 +31,20 @@ interface GraphsZoomProps { metricsType: string; series: Serie[]; showAreas?: boolean; - updateGraphZoom: (from?: Date, to?: Date) => void; + onUpdateGraphZoom: (from?: Date, to?: Date) => void; } +const ZOOM_TIMELINE_PADDING_TOP = 0; +const ZOOM_TIMELINE_PADDING_RIGHT = 10; +const ZOOM_TIMELINE_PADDING_BOTTOM = 18; +const ZOOM_TIMELINE_PADDING_LEFT = 60; +const ZOOM_TIMELINE_HEIGHT = 64; + export default function GraphsZoom(props: GraphsZoomProps) { - if (props.loading || !hasHistoryData(props.series)) { + const { loading, series, graphEndDate, leakPeriodDate, metricsType, showAreas, graphStartDate } = + props; + + if (loading || !hasHistoryData(series)) { return null; } @@ -45,15 +54,20 @@ export default function GraphsZoom(props: GraphsZoomProps) { {({ width }) => ( )} diff --git a/server/sonar-web/src/main/js/components/activity-graph/RichQualityGateEventInner.tsx b/server/sonar-web/src/main/js/components/activity-graph/RichQualityGateEventInner.tsx index 907a5dc3a2f..919005598fe 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/RichQualityGateEventInner.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/RichQualityGateEventInner.tsx @@ -24,6 +24,7 @@ import { getProjectUrl } from '../../helpers/urls'; import { AnalysisEvent } from '../../types/project-activity'; import Link from '../common/Link'; import { ResetButtonLink } from '../controls/buttons'; +import ClickEventBoundary from '../controls/ClickEventBoundary'; import DropdownIcon from '../icons/DropdownIcon'; import Level from '../ui/Level'; @@ -45,10 +46,6 @@ interface State { export class RichQualityGateEventInner extends React.PureComponent { state: State = { expanded: false }; - stopPropagation = (event: React.MouseEvent) => { - event.stopPropagation(); - }; - toggleProjectsList = () => { this.setState((state) => ({ expanded: !state.expanded })); }; @@ -93,15 +90,13 @@ export class RichQualityGateEventInner extends React.PureComponent small={true} />
- - - {project.name} - - + + + + {project.name} + + +
))} diff --git a/server/sonar-web/src/main/js/components/activity-graph/__tests__/ActivityGraph-it.tsx b/server/sonar-web/src/main/js/components/activity-graph/__tests__/ActivityGraph-it.tsx index 4b9ac8eb468..c967173eb99 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/__tests__/ActivityGraph-it.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/__tests__/ActivityGraph-it.tsx @@ -19,7 +19,6 @@ */ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; -import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; import { times } from 'lodash'; import * as React from 'react'; import selectEvent from 'react-select-event'; @@ -40,42 +39,77 @@ const MAX_SERIES_PER_GRAPH = 3; const HISTORY_COUNT = 10; const START_DATE = '2016-01-01T00:00:00+0200'; -it('should render correctly when loading', async () => { - renderActivityGraph({ loading: true }); - expect(await screen.findByText('loading')).toBeInTheDocument(); +describe('rendering', () => { + it('should render correctly when loading', async () => { + renderActivityGraph({ loading: true }); + expect(await screen.findByText('loading')).toBeInTheDocument(); + }); + + it('should show the correct legend items', async () => { + const { ui, user } = getPageObject(); + renderActivityGraph(); + + // Static legend items, which aren't interactive. + expect(ui.legendRemoveMetricBtn(MetricKey.bugs).query()).not.toBeInTheDocument(); + expect(ui.getLegendItem(MetricKey.bugs)).toBeInTheDocument(); + + // Switch to custom graph. + await ui.changeGraphType(GraphType.custom); + await ui.openAddMetrics(); + await ui.clickOnMetric(MetricKey.bugs); + await ui.clickOnMetric(MetricKey.test_failures); + await user.keyboard('{Escape}'); + + // These legend items are interactive (interaction tested below). + expect(ui.legendRemoveMetricBtn(MetricKey.bugs).get()).toBeInTheDocument(); + expect(ui.legendRemoveMetricBtn(MetricKey.test_failures).get()).toBeInTheDocument(); + + // Shows warning for metrics with no data. + const li = ui.getLegendItem(MetricKey.test_failures); + // eslint-disable-next-line jest/no-conditional-in-test + if (li) { + li.focus(); + } + expect(ui.noDataWarningTooltip.get()).toBeInTheDocument(); + }); }); -it('should show the correct legend items', async () => { - const user = userEvent.setup(); - const ui = getPageObject(user); - renderActivityGraph(); +describe('data table modal', () => { + it('shows the same data in a table', async () => { + const { ui } = getPageObject(); + renderActivityGraph(); + + await ui.openDataTable(); + expect(ui.dataTable.get()).toBeInTheDocument(); + expect(ui.dataTableColHeaders.getAll()).toHaveLength(5); + expect(ui.dataTableRows.getAll()).toHaveLength(HISTORY_COUNT + 1); - // Static legend items, which aren't interactive. - expect(ui.legendRemoveMetricBtn(MetricKey.bugs).query()).not.toBeInTheDocument(); - expect(ui.getLegendItem(MetricKey.bugs)).toBeInTheDocument(); + // Change graph type and dates, check table updates correctly. + await ui.closeDataTable(); + await ui.changeGraphType(GraphType.coverage); - // Switch to custom graph. - await ui.changeGraphType(GraphType.custom); - await ui.openAddMetrics(); - await ui.clickOnMetric(MetricKey.bugs); - await ui.clickOnMetric(MetricKey.test_failures); - await user.keyboard('{Escape}'); - - // These legend items are interactive (interaction tested below). - expect(ui.legendRemoveMetricBtn(MetricKey.bugs).get()).toBeInTheDocument(); - expect(ui.legendRemoveMetricBtn(MetricKey.test_failures).get()).toBeInTheDocument(); - - // Shows warning for metrics with no data. - const li = ui.getLegendItem(MetricKey.test_failures); - // eslint-disable-next-line jest/no-conditional-in-test - if (li) { - li.focus(); - } - expect(ui.noDataWarningTooltip.get()).toBeInTheDocument(); + await ui.openDataTable(); + expect(ui.dataTable.get()).toBeInTheDocument(); + expect(ui.dataTableColHeaders.getAll()).toHaveLength(4); + expect(ui.dataTableRows.getAll()).toHaveLength(HISTORY_COUNT + 1); + }); + + it('shows the same data in a table when filtered by date', async () => { + const { ui } = getPageObject(); + renderActivityGraph({ + graphStartDate: parseDate('2017-01-01'), + graphEndDate: parseDate('2019-01-01'), + }); + + await ui.openDataTable(); + expect(ui.dataTable.get()).toBeInTheDocument(); + expect(ui.dataTableColHeaders.getAll()).toHaveLength(5); + expect(ui.dataTableRows.getAll()).toHaveLength(2); + }); }); it('should correctly handle adding/removing custom metrics', async () => { - const ui = getPageObject(userEvent.setup()); + const { ui } = getPageObject(); renderActivityGraph(); // Change graph type to "Custom". @@ -132,41 +166,8 @@ it('should correctly handle adding/removing custom metrics', async () => { expect(ui.noDataText.get()).toBeInTheDocument(); }); -describe('data table modal', () => { - it('shows the same data in a table', async () => { - const ui = getPageObject(userEvent.setup()); - renderActivityGraph(); - - await ui.openDataTable(); - expect(ui.dataTable.get()).toBeInTheDocument(); - expect(ui.dataTableColHeaders.getAll()).toHaveLength(5); - expect(ui.dataTableRows.getAll()).toHaveLength(HISTORY_COUNT + 1); - - // Change graph type and dates, check table updates correctly. - await ui.closeDataTable(); - await ui.changeGraphType(GraphType.coverage); - - await ui.openDataTable(); - expect(ui.dataTable.get()).toBeInTheDocument(); - expect(ui.dataTableColHeaders.getAll()).toHaveLength(4); - expect(ui.dataTableRows.getAll()).toHaveLength(HISTORY_COUNT + 1); - }); - - it('shows the same data in a table when filtered by date', async () => { - const ui = getPageObject(userEvent.setup()); - renderActivityGraph({ - graphStartDate: parseDate('2017-01-01'), - graphEndDate: parseDate('2019-01-01'), - }); - - await ui.openDataTable(); - expect(ui.dataTable.get()).toBeInTheDocument(); - expect(ui.dataTableColHeaders.getAll()).toHaveLength(5); - expect(ui.dataTableRows.getAll()).toHaveLength(2); - }); -}); - -function getPageObject(user: UserEvent) { +function getPageObject() { + const user = userEvent.setup(); const ui = { // Graph types. graphTypeSelect: byLabelText('project_activity.graphs.choose_type'), @@ -195,11 +196,6 @@ function getPageObject(user: UserEvent) { graphs: byLabelText('project_activity.graphs.explanation_x', { exact: false }), noDataText: byText('project_activity.graphs.custom.no_history'), - // Date filters. - fromDateInput: byLabelText('from_date'), - toDateInput: byLabelText('to_date'), - submitDatesBtn: byRole('button', { name: 'Submit dates' }), - // Data in table. openInTableBtn: byRole('button', { name: 'project_activity.graphs.open_in_table' }), closeDataTableBtn: byRole('button', { name: 'close' }), @@ -212,27 +208,30 @@ function getPageObject(user: UserEvent) { }; return { - ...ui, - async changeGraphType(type: GraphType) { - await selectEvent.select(ui.graphTypeSelect.get(), [`project_activity.graphs.${type}`]); - }, - async openAddMetrics() { - await user.click(ui.addMetricBtn.get()); - }, - async searchForMetric(text: string) { - await user.type(ui.filterMetrics.get(), text); - }, - async clickOnMetric(name: MetricKey) { - await user.click(screen.getByRole('checkbox', { name })); - }, - async removeMetric(metric: MetricKey) { - await user.click(ui.legendRemoveMetricBtn(metric).get()); - }, - async openDataTable() { - await user.click(ui.openInTableBtn.get()); - }, - async closeDataTable() { - await user.click(ui.closeDataTableBtn.get()); + user, + ui: { + ...ui, + async changeGraphType(type: GraphType) { + await selectEvent.select(ui.graphTypeSelect.get(), [`project_activity.graphs.${type}`]); + }, + async openAddMetrics() { + await user.click(ui.addMetricBtn.get()); + }, + async searchForMetric(text: string) { + await user.type(ui.filterMetrics.get(), text); + }, + async clickOnMetric(name: MetricKey) { + await user.click(screen.getByRole('checkbox', { name })); + }, + async removeMetric(metric: MetricKey) { + await user.click(ui.legendRemoveMetricBtn(metric).get()); + }, + async openDataTable() { + await user.click(ui.openInTableBtn.get()); + }, + async closeDataTable() { + await user.click(ui.closeDataTableBtn.get()); + }, }, }; } @@ -244,11 +243,6 @@ function renderActivityGraph( function ActivityGraph() { const [selectedMetrics, setSelectedMetrics] = React.useState([]); const [graph, setGraph] = React.useState(graphsHistoryProps.graph || GraphType.issues); - const [selectedDate, setSelectedDate] = React.useState( - graphsHistoryProps.selectedDate - ); - const [fromDate, setFromDate] = React.useState(undefined); - const [toDate, setToDate] = React.useState(undefined); const measuresHistory: MeasureHistory[] = []; const metrics: Metric[] = []; @@ -327,40 +321,26 @@ function renderActivityGraph( setGraph(graphType as GraphType); }; - const updateSelectedDate = (date?: Date) => { - setSelectedDate(date); - }; - - const updateFromToDates = (from?: Date, to?: Date) => { - setFromDate(from); - setToDate(to); - }; - return ( <> diff --git a/server/sonar-web/src/main/js/components/activity-graph/__tests__/DataTableModal-it.tsx b/server/sonar-web/src/main/js/components/activity-graph/__tests__/DataTableModal-it.tsx index 424fd922781..b5b234a9275 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/__tests__/DataTableModal-it.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/__tests__/DataTableModal-it.tsx @@ -30,7 +30,11 @@ import { import { mockMetric } from '../../../helpers/testMocks'; import { renderComponent } from '../../../helpers/testReactTestingUtils'; import { MetricKey } from '../../../types/metrics'; -import { GraphType, MeasureHistory } from '../../../types/project-activity'; +import { + GraphType, + MeasureHistory, + ProjectAnalysisEventCategory, +} from '../../../types/project-activity'; import { Metric } from '../../../types/types'; import DataTableModal, { DataTableModalProps, MAX_DATA_TABLE_ROWS } from '../DataTableModal'; import { generateSeries, getDisplayedHistoryMetrics } from '../utils'; @@ -47,7 +51,9 @@ it('should render correctly if there are events', () => { analyses: [ mockParsedAnalysis({ date: parseDate('2016-01-01T00:00:00+0200'), - events: [mockAnalysisEvent({ key: '1', category: 'QUALITY_GATE' })], + events: [ + mockAnalysisEvent({ key: '1', category: ProjectAnalysisEventCategory.QualityGate }), + ], }), ], }); diff --git a/server/sonar-web/src/main/js/components/activity-graph/__tests__/EventInner-it.tsx b/server/sonar-web/src/main/js/components/activity-graph/__tests__/EventInner-it.tsx index 899768d86d8..daa09bcfc8f 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/__tests__/EventInner-it.tsx +++ b/server/sonar-web/src/main/js/components/activity-graph/__tests__/EventInner-it.tsx @@ -20,9 +20,19 @@ import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import * as React from 'react'; +import { Route } from 'react-router-dom'; import { byRole, byText } from 'testing-library-selector'; +import { isMainBranch } from '../../../helpers/branch-like'; +import { mockBranch, mockMainBranch } from '../../../helpers/mocks/branch-like'; import { mockAnalysisEvent } from '../../../helpers/mocks/project-activity'; -import { renderComponent } from '../../../helpers/testReactTestingUtils'; +import { renderAppWithComponentContext } from '../../../helpers/testReactTestingUtils'; +import { BranchLike } from '../../../types/branch-like'; +import { ComponentContextShape } from '../../../types/component'; +import { + ApplicationAnalysisEventCategory, + DefinitionChangeType, + ProjectAnalysisEventCategory, +} from '../../../types/project-activity'; import EventInner, { EventInnerProps } from '../EventInner'; const ui = { @@ -31,8 +41,14 @@ const ui = { projectLink: (name: string) => byRole('link', { name }), definitionChangeLabel: byText('event.category.DEFINITION_CHANGE', { exact: false }), - projectAddedTxt: byText('event.definition_change.added'), - projectRemovedTxt: byText('event.definition_change.removed'), + projectAddedTxt: (branch: BranchLike) => + isMainBranch(branch) + ? byText('event.definition_change.added') + : byText('event.definition_change.branch_added'), + projectRemovedTxt: (branch: BranchLike) => + isMainBranch(branch) + ? byText('event.definition_change.removed') + : byText('event.definition_change.branch_removed'), branchReplacedTxt: byText('event.definition_change.branch_replaced'), qualityGateLabel: byText('event.category.QUALITY_GATE', { exact: false }), @@ -42,17 +58,81 @@ const ui = { }; describe('DEFINITION_CHANGE events', () => { - it('should render correctly for "DEFINITION_CHANGE" events', async () => { + it.each([mockMainBranch(), mockBranch()])( + 'should render correctly for "ADDED" events', + async (branchLike: BranchLike) => { + const user = userEvent.setup(); + renderEventInner( + { + event: mockAnalysisEvent({ + category: ApplicationAnalysisEventCategory.DefinitionChange, + definitionChange: { + projects: [ + { + changeType: DefinitionChangeType.Added, + key: 'foo', + name: 'Foo', + branch: 'master-foo', + }, + ], + }, + }), + }, + { branchLike } + ); + + expect(ui.definitionChangeLabel.get()).toBeInTheDocument(); + + await user.click(ui.showMoreBtn.get()); + + expect(ui.projectAddedTxt(branchLike).get()).toBeInTheDocument(); + expect(ui.projectLink('Foo').get()).toBeInTheDocument(); + expect(screen.getByText('master-foo')).toBeInTheDocument(); + } + ); + + it.each([mockMainBranch(), mockBranch()])( + 'should render correctly for "REMOVED" events', + async (branchLike: BranchLike) => { + const user = userEvent.setup(); + renderEventInner( + { + event: mockAnalysisEvent({ + category: ApplicationAnalysisEventCategory.DefinitionChange, + definitionChange: { + projects: [ + { + changeType: DefinitionChangeType.Removed, + key: 'bar', + name: 'Bar', + branch: 'master-bar', + }, + ], + }, + }), + }, + { branchLike } + ); + + expect(ui.definitionChangeLabel.get()).toBeInTheDocument(); + + await user.click(ui.showMoreBtn.get()); + + expect(ui.projectRemovedTxt(branchLike).get()).toBeInTheDocument(); + expect(ui.projectLink('Bar').get()).toBeInTheDocument(); + expect(screen.getByText('master-bar')).toBeInTheDocument(); + } + ); + + it('should render correctly for "BRANCH_CHANGED" events', async () => { const user = userEvent.setup(); renderEventInner({ event: mockAnalysisEvent({ - category: 'DEFINITION_CHANGE', + category: ApplicationAnalysisEventCategory.DefinitionChange, definitionChange: { projects: [ - { changeType: 'ADDED', key: 'foo', name: 'Foo', branch: 'master-foo' }, - { changeType: 'REMOVED', key: 'bar', name: 'Bar', branch: 'master-bar' }, { - changeType: 'BRANCH_CHANGED', + changeType: DefinitionChangeType.BranchChanged, key: 'baz', name: 'Baz', oldBranch: 'old-branch', @@ -67,17 +147,6 @@ describe('DEFINITION_CHANGE events', () => { await user.click(ui.showMoreBtn.get()); - // ADDED. - expect(ui.projectAddedTxt.get()).toBeInTheDocument(); - expect(ui.projectLink('Foo').get()).toBeInTheDocument(); - expect(screen.getByText('master-foo')).toBeInTheDocument(); - - // REMOVED. - expect(ui.projectRemovedTxt.get()).toBeInTheDocument(); - expect(ui.projectLink('Bar').get()).toBeInTheDocument(); - expect(screen.getByText('master-bar')).toBeInTheDocument(); - - // BRANCH_CHANGED expect(ui.branchReplacedTxt.get()).toBeInTheDocument(); expect(ui.projectLink('Baz').get()).toBeInTheDocument(); expect(screen.getByText('old-branch')).toBeInTheDocument(); @@ -89,7 +158,7 @@ describe('QUALITY_GATE events', () => { it('should render correctly for simple "QUALITY_GATE" events', () => { renderEventInner({ event: mockAnalysisEvent({ - category: 'QUALITY_GATE', + category: ProjectAnalysisEventCategory.QualityGate, qualityGate: { status: 'ERROR', stillFailing: false, failing: [] }, }), }); @@ -100,7 +169,7 @@ describe('QUALITY_GATE events', () => { it('should render correctly for "still failing" "QUALITY_GATE" events', () => { renderEventInner({ event: mockAnalysisEvent({ - category: 'QUALITY_GATE', + category: ProjectAnalysisEventCategory.QualityGate, qualityGate: { status: 'ERROR', stillFailing: true, failing: [] }, }), }); @@ -113,7 +182,7 @@ describe('QUALITY_GATE events', () => { const user = userEvent.setup(); renderEventInner({ event: mockAnalysisEvent({ - category: 'QUALITY_GATE', + category: ProjectAnalysisEventCategory.QualityGate, qualityGate: { status: 'ERROR', stillFailing: true, @@ -149,7 +218,7 @@ describe('VERSION events', () => { it('should render correctly', () => { renderEventInner({ event: mockAnalysisEvent({ - category: 'VERSION', + category: ProjectAnalysisEventCategory.Version, name: '1.0', }), }); @@ -159,6 +228,14 @@ describe('VERSION events', () => { }); }); -function renderEventInner(props: Partial = {}) { - return renderComponent(); +function renderEventInner( + props: Partial = {}, + componentContext: Partial = {} +) { + return renderAppWithComponentContext( + '/', + () => } />, + {}, + componentContext + ); } diff --git a/server/sonar-web/src/main/js/components/activity-graph/__tests__/__snapshots__/utils-test.ts.snap b/server/sonar-web/src/main/js/components/activity-graph/__tests__/__snapshots__/utils-test.ts.snap index dbd75cd496b..0c670cffbc5 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/__tests__/__snapshots__/utils-test.ts.snap +++ b/server/sonar-web/src/main/js/components/activity-graph/__tests__/__snapshots__/utils-test.ts.snap @@ -56,7 +56,7 @@ exports[`generateSeries should correctly generate the series 1`] = ` }, ], "name": "lines_to_cover", - "translatedName": "Line to Cover", + "translatedName": "lines_to_cover", "type": "PERCENT", }, ] diff --git a/server/sonar-web/src/main/js/components/activity-graph/__tests__/utils-test.ts b/server/sonar-web/src/main/js/components/activity-graph/__tests__/utils-test.ts index a9d311461ee..22e86d7bdde 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/__tests__/utils-test.ts +++ b/server/sonar-web/src/main/js/components/activity-graph/__tests__/utils-test.ts @@ -18,8 +18,11 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as dates from '../../../helpers/dates'; -import { MetricKey } from '../../../types/metrics'; -import { GraphType, Serie } from '../../../types/project-activity'; +import { mockMeasureHistory, mockSerie } from '../../../helpers/mocks/project-activity'; +import { get, save } from '../../../helpers/storage'; +import { mockMetric } from '../../../helpers/testMocks'; +import { MetricKey, MetricType } from '../../../types/metrics'; +import { GraphType } from '../../../types/project-activity'; import * as utils from '../utils'; jest.mock('date-fns', () => { @@ -34,37 +37,36 @@ jest.mock('date-fns', () => { }; }); +jest.mock('../../../helpers/storage', () => ({ + save: jest.fn(), + get: jest.fn(), +})); + const HISTORY = [ - { + mockMeasureHistory({ metric: MetricKey.lines_to_cover, history: [ { date: dates.parseDate('2017-04-27T08:21:32.000Z'), value: '100' }, { date: dates.parseDate('2017-04-30T23:06:24.000Z'), value: '100' }, ], - }, - { + }), + mockMeasureHistory({ metric: MetricKey.uncovered_lines, history: [ { date: dates.parseDate('2017-04-27T08:21:32.000Z'), value: '12' }, { date: dates.parseDate('2017-04-30T23:06:24.000Z'), value: '50' }, ], - }, + }), ]; const METRICS = [ - { id: '1', key: MetricKey.uncovered_lines, name: 'Uncovered Lines', type: 'INT' }, - { id: '2', key: MetricKey.lines_to_cover, name: 'Line to Cover', type: 'PERCENT' }, + mockMetric({ key: MetricKey.uncovered_lines, type: MetricType.Integer }), + mockMetric({ key: MetricKey.lines_to_cover, type: MetricType.Percent }), ]; -const SERIE: Serie = { - data: [ - { x: dates.parseDate('2017-04-27T08:21:32.000Z'), y: 2 }, - { x: dates.parseDate('2017-04-28T08:21:32.000Z'), y: 2 }, - ], - name: 'foo', - translatedName: 'Foo', - type: 'PERCENT', -}; +const SERIE = mockSerie({ + type: MetricType.Percent, +}); describe('generateCoveredLinesMetric', () => { it('should correctly generate covered lines metric', () => { @@ -129,46 +131,25 @@ describe('getHistoryMetrics', () => { describe('hasHistoryData', () => { it('should correctly detect if there is history data', () => { + expect(utils.hasHistoryData([mockSerie()])).toBe(true); expect( utils.hasHistoryData([ - { - name: 'foo', - translatedName: 'foo', - type: 'INT', - data: [ - { x: dates.parseDate('2017-04-27T08:21:32.000Z'), y: 2 }, - { x: dates.parseDate('2017-04-30T23:06:24.000Z'), y: 2 }, - ], - }, - ]) - ).toBe(true); - expect( - utils.hasHistoryData([ - { - name: 'foo', - translatedName: 'foo', - type: 'INT', + mockSerie({ data: [], - }, - { + }), + mockSerie({ name: 'bar', translatedName: 'bar', - type: 'INT', - data: [ - { x: dates.parseDate('2017-04-27T08:21:32.000Z'), y: 2 }, - { x: dates.parseDate('2017-04-30T23:06:24.000Z'), y: 2 }, - ], - }, + }), ]) ).toBe(true); expect( utils.hasHistoryData([ - { + mockSerie({ name: 'bar', translatedName: 'bar', - type: 'INT', data: [{ x: dates.parseDate('2017-04-27T08:21:32.000Z'), y: 2 }], - }, + }), ]) ).toBe(false); }); @@ -190,8 +171,8 @@ describe('hasDataValues', () => { describe('getSeriesMetricType', () => { it('should return the correct type', () => { - expect(utils.getSeriesMetricType([SERIE])).toBe('PERCENT'); - expect(utils.getSeriesMetricType([])).toBe('INT'); + expect(utils.getSeriesMetricType([SERIE])).toBe(MetricType.Percent); + expect(utils.getSeriesMetricType([])).toBe(MetricType.Integer); }); }); @@ -201,3 +182,65 @@ describe('hasHistoryDataValue', () => { expect(utils.hasHistoryDataValue([])).toBe(false); }); }); + +describe('saveActivityGraph', () => { + it('should correctly store data for standard graph types', () => { + utils.saveActivityGraph('foo', 'bar', GraphType.issues); + expect(save).toHaveBeenCalledWith('foo', GraphType.issues, 'bar'); + }); + + it.each([undefined, [MetricKey.bugs, MetricKey.alert_status]])( + 'should correctly store data for custom graph types', + (metrics) => { + utils.saveActivityGraph('foo', 'bar', GraphType.custom, metrics); + expect(save).toHaveBeenCalledWith('foo', GraphType.custom, 'bar'); + // eslint-disable-next-line jest/no-conditional-in-test + expect(save).toHaveBeenCalledWith('foo.custom', metrics ? metrics.join(',') : '', 'bar'); + } + ); +}); + +describe('getActivityGraph', () => { + it('should correctly retrieve data for standard graph types', () => { + jest.mocked(get).mockImplementation((key) => { + // eslint-disable-next-line jest/no-conditional-in-test + if (key.includes('.custom')) { + return null; + } + return GraphType.coverage; + }); + + expect(utils.getActivityGraph('foo', 'bar')).toEqual({ + graph: GraphType.coverage, + customGraphs: [], + }); + }); + + it.each([null, 'bugs,code_smells'])( + 'should correctly retrieve data for custom graph types', + (data) => { + jest.mocked(get).mockImplementation((key) => { + // eslint-disable-next-line jest/no-conditional-in-test + if (key.includes('.custom')) { + return data; + } + return GraphType.custom; + }); + + expect(utils.getActivityGraph('foo', 'bar')).toEqual({ + graph: GraphType.custom, + // eslint-disable-next-line jest/no-conditional-in-test + customGraphs: data ? [MetricKey.bugs, MetricKey.code_smells] : [], + }); + } + ); + + it('should correctly retrieve data for unknown graphs', () => { + jest.mocked(get).mockReturnValue(null); + + expect(utils.getActivityGraph('foo', 'bar')).toEqual({ + graph: GraphType.issues, + customGraphs: [], + }); + }); +}); diff --git a/server/sonar-web/src/main/js/components/activity-graph/utils.ts b/server/sonar-web/src/main/js/components/activity-graph/utils.ts index 5821dd7c3e4..2938a44f927 100644 --- a/server/sonar-web/src/main/js/components/activity-graph/utils.ts +++ b/server/sonar-web/src/main/js/components/activity-graph/utils.ts @@ -21,7 +21,7 @@ import { chunk, flatMap, groupBy, sortBy } from 'lodash'; import { getLocalizedMetricName, translate } from '../../helpers/l10n'; import { localizeMetric } from '../../helpers/measures'; import { get, save } from '../../helpers/storage'; -import { MetricKey } from '../../types/metrics'; +import { MetricKey, MetricType } from '../../types/metrics'; import { GraphType, MeasureHistory, ParsedAnalysis, Serie } from '../../types/project-activity'; import { Dict, Metric } from '../../types/types'; @@ -64,7 +64,7 @@ export function hasHistoryData(series: Serie[]) { } export function getSeriesMetricType(series: Serie[]) { - return series.length > 0 ? series[0].type : 'INT'; + return series.length > 0 ? series[0].type : MetricType.Integer; } export function getDisplayedHistoryMetrics(graph: GraphType, customMetrics: string[]) { @@ -89,7 +89,7 @@ export function splitSeriesInGraphs(series: Serie[], maxGraph: number, maxSeries export function generateCoveredLinesMetric( uncoveredLines: MeasureHistory, measuresHistory: MeasureHistory[] -) { +): Serie { const linesToCover = measuresHistory.find( (measure) => measure.metric === MetricKey.lines_to_cover ); @@ -102,14 +102,14 @@ export function generateCoveredLinesMetric( : [], name: 'covered_lines', translatedName: translate('project_activity.custom_metric.covered_lines'), - type: 'INT', + type: MetricType.Integer, }; } export function generateSeries( measuresHistory: MeasureHistory[], graph: GraphType, - metrics: Metric[] | Dict, + metrics: Metric[], displayedMetrics: string[] ): Serie[] { if (displayedMetrics.length <= 0 || measuresHistory === undefined) { @@ -126,11 +126,11 @@ export function generateSeries( return { data: measure.history.map((analysis) => ({ x: analysis.date, - y: metric && metric.type === 'LEVEL' ? analysis.value : Number(analysis.value), + y: metric && metric.type === MetricType.Level ? analysis.value : Number(analysis.value), })), name: measure.metric, translatedName: metric ? getLocalizedMetricName(metric) : localizeMetric(measure.metric), - type: metric ? metric.type : 'INT', + type: metric ? metric.type : MetricType.Integer, }; }), (serie) => @@ -171,9 +171,6 @@ export function getAnalysisEventsForDate(analyses: ParsedAnalysis[], date?: Date return []; } -function findMetric(key: string, metrics: Metric[] | Dict) { - if (Array.isArray(metrics)) { - return metrics.find((metric) => metric.key === key); - } - return metrics[key]; +function findMetric(key: string, metrics: Metric[]) { + return metrics.find((metric) => metric.key === key); } diff --git a/server/sonar-web/src/main/js/components/controls/ConfirmModal.tsx b/server/sonar-web/src/main/js/components/controls/ConfirmModal.tsx index f129754140b..2cc3432dfac 100644 --- a/server/sonar-web/src/main/js/components/controls/ConfirmModal.tsx +++ b/server/sonar-web/src/main/js/components/controls/ConfirmModal.tsx @@ -54,9 +54,16 @@ export default class ConfirmModal extends React.PureComponent { const result = this.props.onConfirm(this.props.confirmData); if (result) { - return result.then(this.props.onClose, () => { - /* noop */ - }); + return result.then( + () => { + if (this.mounted) { + this.props.onClose(); + } + }, + () => { + /* noop */ + } + ); } this.props.onClose(); return undefined; diff --git a/server/sonar-web/src/main/js/helpers/mocks/project-activity.ts b/server/sonar-web/src/main/js/helpers/mocks/project-activity.ts index 98d47a05bb5..c9faf55b54b 100644 --- a/server/sonar-web/src/main/js/helpers/mocks/project-activity.ts +++ b/server/sonar-web/src/main/js/helpers/mocks/project-activity.ts @@ -17,12 +17,15 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { MetricKey, MetricType } from '../../types/metrics'; import { Analysis, AnalysisEvent, HistoryItem, MeasureHistory, ParsedAnalysis, + ProjectAnalysisEventCategory, + Serie, } from '../../types/project-activity'; import { parseDate } from '../dates'; @@ -38,7 +41,7 @@ export function mockAnalysis(overrides: Partial = {}): Analysis { export function mockParsedAnalysis(overrides: Partial = {}): ParsedAnalysis { return { - date: new Date('2017-03-01T09:37:01+0100'), + date: parseDate('2017-03-01T09:37:01+0100'), events: [], key: 'foo', projectVersion: '1.0', @@ -48,7 +51,7 @@ export function mockParsedAnalysis(overrides: Partial = {}): Par export function mockAnalysisEvent(overrides: Partial = {}): AnalysisEvent { return { - category: 'QUALITY_GATE', + category: ProjectAnalysisEventCategory.QualityGate, key: 'E11', description: 'Lorem ipsum dolor sit amet', name: 'Lorem ipsum', @@ -74,7 +77,7 @@ export function mockAnalysisEvent(overrides: Partial = {}): Analy export function mockMeasureHistory(overrides: Partial = {}): MeasureHistory { return { - metric: 'code_smells', + metric: MetricKey.code_smells, history: [ mockHistoryItem(), mockHistoryItem({ date: parseDate('2018-10-27T12:21:15+0200'), value: '1749' }), @@ -91,3 +94,16 @@ export function mockHistoryItem(overrides: Partial = {}): HistoryIt ...overrides, }; } + +export function mockSerie(overrides: Partial = {}): Serie { + return { + data: [ + { x: parseDate('2017-04-27T08:21:32.000Z'), y: 2 }, + { x: parseDate('2017-04-30T23:06:24.000Z'), y: 2 }, + ], + name: 'foo', + translatedName: 'foo', + type: MetricType.Integer, + ...overrides, + }; +} diff --git a/server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx b/server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx index d3a32551d7b..1e132aaf840 100644 --- a/server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx +++ b/server/sonar-web/src/main/js/helpers/testReactTestingUtils.tsx @@ -17,7 +17,8 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { render, RenderResult } from '@testing-library/react'; +import { fireEvent, render, RenderResult } from '@testing-library/react'; +import { UserEvent } from '@testing-library/user-event/dist/types/setup/setup'; import { omit } from 'lodash'; import * as React from 'react'; import { HelmetProvider } from 'react-helmet-async'; @@ -202,3 +203,45 @@ function renderRoutedApp( ); } + +/* eslint-disable testing-library/no-node-access */ +export function dateInputEvent(user: UserEvent) { + return { + async openDatePicker(element: HTMLElement) { + await user.click(element); + }, + async pickDate(element: HTMLElement, date: Date) { + await user.click(element); + + const monthSelect = + element.parentNode?.querySelector('select[name="months"]'); + if (!monthSelect) { + throw new Error('Could not find the month selector of the date picker element'); + } + + const yearSelect = + element.parentNode?.querySelector('select[name="years"]'); + if (!yearSelect) { + throw new Error('Could not find the year selector of the date picker element'); + } + + fireEvent.change(monthSelect, { target: { value: date.getMonth() } }); + fireEvent.change(yearSelect, { target: { value: date.getFullYear() } }); + + const dayButtons = + element.parentNode?.querySelectorAll('button[name="day"]'); + if (!dayButtons) { + throw new Error('Could not find the day buttons of the date picker element'); + } + const dayButton = Array.from(dayButtons).find( + (button) => Number(button.textContent) === date.getDate() + ); + if (!dayButton) { + throw new Error(`Could not find the button for day ${date.getDate()}`); + } + + await user.click(dayButton); + }, + }; +} +/* eslint-enable testing-library/no-node-access */ diff --git a/server/sonar-web/src/main/js/types/project-activity.ts b/server/sonar-web/src/main/js/types/project-activity.ts index a71d16fb212..f3255f3d861 100644 --- a/server/sonar-web/src/main/js/types/project-activity.ts +++ b/server/sonar-web/src/main/js/types/project-activity.ts @@ -17,6 +17,8 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { Status } from './types'; + interface BaseAnalysis { buildString?: string; detectedCI?: string; @@ -35,19 +37,19 @@ export interface ParsedAnalysis extends BaseAnalysis { } export interface AnalysisEvent { - category: string; + category: ProjectAnalysisEventCategory | ApplicationAnalysisEventCategory; description?: string; key: string; name: string; qualityGate?: { failing: Array<{ branch: string; key: string; name: string }>; - status: string; + status: Status; stillFailing: boolean; }; definitionChange?: { projects: Array<{ branch?: string; - changeType: string; + changeType: DefinitionChangeType; key: string; name: string; newBranch?: string; @@ -63,6 +65,25 @@ export enum GraphType { custom = 'custom', } +export enum ProjectAnalysisEventCategory { + Version = 'VERSION', + QualityGate = 'QUALITY_GATE', + QualityProfile = 'QUALITY_PROFILE', + Other = 'OTHER', +} + +export enum ApplicationAnalysisEventCategory { + QualityGate = 'QUALITY_GATE', + DefinitionChange = 'DEFINITION_CHANGE', + Other = 'OTHER', +} + +export enum DefinitionChangeType { + Added = 'ADDED', + Removed = 'REMOVED', + BranchChanged = 'BRANCH_CHANGED', +} + export interface HistoryItem { date: Date; value?: string;