From c4e2a351504a92709154992ab1c2963201538d4c Mon Sep 17 00:00:00 2001 From: David Cho-Lerat Date: Mon, 13 Mar 2023 12:35:13 +0100 Subject: [PATCH] Fix some code smells in MMF-3035 --- .../src/components/InteractiveIcon.tsx | 8 ++- .../design-system/src/components/buttons.tsx | 12 ++-- .../design-system/src/helpers/colors.ts | 15 ++++- .../design-system/src/helpers/constants.ts | 3 + .../design-system/src/helpers/index.ts | 2 + .../design-system/src/theme/light.ts | 10 +-- .../components/global-search/GlobalSearch.tsx | 24 +++++-- .../account/profile/UserExternalIdentity.tsx | 3 +- .../components/RuleDetailsMeta.tsx | 3 +- .../src/main/js/apps/coding-rules/routes.tsx | 3 +- .../js/apps/issues/components/ListItem.tsx | 3 +- .../js/apps/projects/__tests__/utils-test.ts | 44 +++++++++---- .../apps/projects/components/AllProjects.tsx | 24 +++++-- .../components/__tests__/AllProjects-test.tsx | 65 ++++++++++++++----- .../__snapshots__/AllProjects-test.tsx.snap | 31 ++++++++- .../src/main/js/apps/projects/utils.ts | 51 ++++++++++++--- .../users/components/UserListItemIdentity.tsx | 3 +- .../main/js/components/charts/TreeMapRect.tsx | 3 +- .../controls/IdentityProviderLink.tsx | 3 +- .../main/js/components/ui/GenericAvatar.tsx | 3 +- .../main/js/helpers/__tests__/colors-test.ts | 50 -------------- .../sonar-web/src/main/js/helpers/colors.ts | 50 -------------- 22 files changed, 239 insertions(+), 174 deletions(-) delete mode 100644 server/sonar-web/src/main/js/helpers/__tests__/colors-test.ts delete mode 100644 server/sonar-web/src/main/js/helpers/colors.ts diff --git a/server/sonar-web/design-system/src/components/InteractiveIcon.tsx b/server/sonar-web/design-system/src/components/InteractiveIcon.tsx index ebd9cb73e9a..03bbe3bdf6e 100644 --- a/server/sonar-web/design-system/src/components/InteractiveIcon.tsx +++ b/server/sonar-web/design-system/src/components/InteractiveIcon.tsx @@ -17,11 +17,13 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import { css } from '@emotion/react'; import styled from '@emotion/styled'; import classNames from 'classnames'; import React from 'react'; import tw from 'twin.macro'; +import { OPACITY_20_PERCENT } from '../helpers/constants'; import { themeBorder, themeColor, themeContrast } from '../helpers/theme'; import { isDefined } from '../helpers/types'; import { ThemedProps } from '../types/theme'; @@ -156,7 +158,7 @@ export const InteractiveIcon: React.FC = styled(Interactiv --color: ${({ currentColor, theme }) => currentColor ? 'currentColor' : themeContrast('interactiveIcon')({ theme })}; --colorHover: ${themeContrast('interactiveIconHover')}; - --focus: ${themeColor('interactiveIconFocus', 0.2)}; + --focus: ${themeColor('interactiveIconFocus', OPACITY_20_PERCENT)}; `; export const DiscreetInteractiveIcon: React.FC = styled(InteractiveIcon)` @@ -168,7 +170,7 @@ export const DestructiveIcon: React.FC = styled(Interactiv --backgroundHover: ${themeColor('destructiveIconHover')}; --color: ${themeContrast('destructiveIcon')}; --colorHover: ${themeContrast('destructiveIconHover')}; - --focus: ${themeColor('destructiveIconFocus', 0.2)}; + --focus: ${themeColor('destructiveIconFocus', OPACITY_20_PERCENT)}; `; export const DismissProductNewsIcon: React.FC = styled(InteractiveIcon)` @@ -176,7 +178,7 @@ export const DismissProductNewsIcon: React.FC = styled(Int --backgroundHover: ${themeColor('productNewsHover')}; --color: ${themeContrast('productNews')}; --colorHover: ${themeContrast('productNewsHover')}; - --focus: ${themeColor('interactiveIconFocus', 0.2)}; + --focus: ${themeColor('interactiveIconFocus', OPACITY_20_PERCENT)}; height: 28px; `; diff --git a/server/sonar-web/design-system/src/components/buttons.tsx b/server/sonar-web/design-system/src/components/buttons.tsx index 442026354ea..ab0fcf9fb0c 100644 --- a/server/sonar-web/design-system/src/components/buttons.tsx +++ b/server/sonar-web/design-system/src/components/buttons.tsx @@ -17,10 +17,12 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import { css } from '@emotion/react'; import styled from '@emotion/styled'; import React from 'react'; import tw from 'twin.macro'; +import { OPACITY_20_PERCENT } from '../helpers/constants'; import { themeBorder, themeColor, themeContrast } from '../helpers/theme'; import { ThemedProps } from '../types/theme'; import { BaseLink, LinkProps } from './Link'; @@ -162,7 +164,7 @@ export const ButtonPrimary: React.FC = styled(Button)` --background: ${themeColor('button')}; --backgroundHover: ${themeColor('buttonHover')}; --color: ${themeContrast('primary')}; - --focus: ${themeColor('button', 0.2)}; + --focus: ${themeColor('button', OPACITY_20_PERCENT)}; --border: ${themeBorder('default', 'transparent')}; `; @@ -170,7 +172,7 @@ export const ButtonSecondary: React.FC = styled(Button)` --background: ${themeColor('buttonSecondary')}; --backgroundHover: ${themeColor('buttonSecondaryHover')}; --color: ${themeContrast('buttonSecondary')}; - --focus: ${themeColor('buttonSecondaryBorder', 0.2)}; + --focus: ${themeColor('buttonSecondaryBorder', OPACITY_20_PERCENT)}; --border: ${themeBorder('default', 'buttonSecondaryBorder')}; `; @@ -178,7 +180,7 @@ export const DangerButtonPrimary: React.FC = styled(Button)` --background: ${themeColor('dangerButton')}; --backgroundHover: ${themeColor('dangerButtonHover')}; --color: ${themeContrast('dangerButton')}; - --focus: ${themeColor('dangerButtonFocus', 0.2)}; + --focus: ${themeColor('dangerButtonFocus', OPACITY_20_PERCENT)}; --border: ${themeBorder('default', 'transparent')}; `; @@ -186,7 +188,7 @@ export const DangerButtonSecondary: React.FC = styled(Button)` --background: ${themeColor('dangerButtonSecondary')}; --backgroundHover: ${themeColor('dangerButtonSecondaryHover')}; --color: ${themeContrast('dangerButtonSecondary')}; - --focus: ${themeColor('dangerButtonSecondaryFocus', 0.2)}; + --focus: ${themeColor('dangerButtonSecondaryFocus', OPACITY_20_PERCENT)}; --border: ${themeBorder('default', 'dangerButtonSecondaryBorder')}; `; @@ -209,7 +211,7 @@ const ThirdPartyButtonStyled: React.FC = styled(Button)` --background: ${themeColor('thirdPartyButton')}; --backgroundHover: ${themeColor('thirdPartyButtonHover')}; --color: ${themeContrast('thirdPartyButton')}; - --focus: ${themeColor('thirdPartyButtonBorder', 0.2)}; + --focus: ${themeColor('thirdPartyButtonBorder', OPACITY_20_PERCENT)}; --border: ${themeBorder('default', 'thirdPartyButtonBorder')}; `; diff --git a/server/sonar-web/design-system/src/helpers/colors.ts b/server/sonar-web/design-system/src/helpers/colors.ts index d0cb5e215ca..1fd0b806052 100644 --- a/server/sonar-web/design-system/src/helpers/colors.ts +++ b/server/sonar-web/design-system/src/helpers/colors.ts @@ -17,33 +17,42 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import { CSSColor } from '../types/theme'; /* eslint-disable no-bitwise, no-mixed-operators */ + export function stringToColor(str: string) { let hash = 0; + for (let i = 0; i < str.length; i++) { hash = str.charCodeAt(i) + ((hash << 5) - hash); } + let color = '#'; + for (let i = 0; i < 3; i++) { const value = (hash >> (i * 8)) & 0xff; - color += ('00' + value.toString(16)).substr(-2); + color += value.toString(16).padStart(2, '0'); } + return color; } export function isDarkColor(color: string) { - color = color.substr(1); + color = color.substring(1); + if (color.length === 3) { // shortcut notation: #f90 color = color[0] + color[0] + color[1] + color[1] + color[2] + color[2]; } - const rgb = parseInt(color.substr(1), 16); + + const rgb = parseInt(color.substring(1), 16); const r = (rgb >> 16) & 0xff; const g = (rgb >> 8) & 0xff; const b = (rgb >> 0) & 0xff; const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; + return luma < 140; } diff --git a/server/sonar-web/design-system/src/helpers/constants.ts b/server/sonar-web/design-system/src/helpers/constants.ts index 68a385c3c1c..344d66b7ac5 100644 --- a/server/sonar-web/design-system/src/helpers/constants.ts +++ b/server/sonar-web/design-system/src/helpers/constants.ts @@ -17,6 +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 { theme } from 'twin.macro'; export const DEFAULT_LOCALE = 'en'; @@ -66,3 +67,5 @@ export const LAYOUT_NOTIFICATIONSBAR_WIDTH = 350; export const CORE_CONCEPTS_WIDTH = 350; export const DARK_THEME_ID = 'dark-theme'; + +export const OPACITY_20_PERCENT = 0.2; diff --git a/server/sonar-web/design-system/src/helpers/index.ts b/server/sonar-web/design-system/src/helpers/index.ts index 764e245473d..427c828d24f 100644 --- a/server/sonar-web/design-system/src/helpers/index.ts +++ b/server/sonar-web/design-system/src/helpers/index.ts @@ -17,5 +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. */ + +export * from './colors'; export * from './constants'; export * from './positioning'; diff --git a/server/sonar-web/design-system/src/theme/light.ts b/server/sonar-web/design-system/src/theme/light.ts index 8b10b339326..56aaefd0d3a 100644 --- a/server/sonar-web/design-system/src/theme/light.ts +++ b/server/sonar-web/design-system/src/theme/light.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 { OPACITY_20_PERCENT } from '../helpers/constants'; import COLORS from './colors'; const primary = { @@ -106,7 +108,7 @@ const lightTheme = { radioHover: COLORS.indigo[50], radioFocus: COLORS.indigo[50], radioFocusBorder: COLORS.indigo[300], - radioFocusOutline: [...COLORS.indigo[300], 0.2], + radioFocusOutline: [...COLORS.indigo[300], OPACITY_20_PERCENT], radioChecked: COLORS.indigo[50], radioDisabled: secondary.default, radioDisabledBackground: secondary.light, @@ -158,7 +160,7 @@ const lightTheme = { toggle: COLORS.white, toggleBorder: secondary.default, toggleHover: secondary.light, - toggleFocus: [...secondary.default, 0.2], + toggleFocus: [...secondary.default, OPACITY_20_PERCENT], // code snippet codeSnippetBackground: COLORS.blueGrey[25], @@ -240,7 +242,7 @@ const lightTheme = { interactiveIconHover: COLORS.indigo[50], interactiveIconFocus: primary.default, bannerIcon: 'transparent', - bannerIconHover: [...COLORS.red[600], 0.2], + bannerIconHover: [...COLORS.red[600], OPACITY_20_PERCENT], bannerIconFocus: danger.default, discreetInteractiveIcon: secondary.dark, destructiveIcon: 'transparent', @@ -673,7 +675,7 @@ const lightTheme = { borders: { default: ['1px', 'solid', ...COLORS.grey[50]], active: ['3px', 'solid', ...primary.light], - focus: ['4px', 'solid', ...secondary.default, 0.2], + focus: ['4px', 'solid', ...secondary.default, OPACITY_20_PERCENT], }, avatar: { diff --git a/server/sonar-web/src/main/js/app/components/global-search/GlobalSearch.tsx b/server/sonar-web/src/main/js/app/components/global-search/GlobalSearch.tsx index 182e8ae11dd..4527bef160e 100644 --- a/server/sonar-web/src/main/js/app/components/global-search/GlobalSearch.tsx +++ b/server/sonar-web/src/main/js/app/components/global-search/GlobalSearch.tsx @@ -17,6 +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 { DropdownMenu, InputSearch, @@ -38,7 +39,6 @@ import { isInput, isShortcut } from '../../../helpers/keyboardEventHelpers'; import { KeyboardKeys } from '../../../helpers/keycodes'; import { translate, translateWithParameters } from '../../../helpers/l10n'; import { getKeyboardShortcutEnabled } from '../../../helpers/preferences'; -import { scrollToElement } from '../../../helpers/scrolling'; import { getComponentOverviewUrl } from '../../../helpers/urls'; import { ComponentQualifier } from '../../../types/component'; import { Dict } from '../../../types/types'; @@ -71,6 +71,7 @@ export class GlobalSearch extends React.PureComponent { super(props); this.nodes = {}; this.search = debounce(this.search, 250); + this.state = { loading: false, more: {}, @@ -119,6 +120,7 @@ export class GlobalSearch extends React.PureComponent { if (!this.state.open && !this.state.query) { this.search(''); } + this.setState({ open: true }); }; @@ -126,6 +128,7 @@ export class GlobalSearch extends React.PureComponent { if (this.input) { this.input.blur(); } + if (clear) { this.setState({ more: {}, @@ -145,6 +148,7 @@ export class GlobalSearch extends React.PureComponent { if (more[qualifier]) { next.push('qualifier###' + qualifier); } + return next; }, []); @@ -158,6 +162,7 @@ export class GlobalSearch extends React.PureComponent { if (query.length === 0 || query.length >= MIN_SEARCH_QUERY_LENGTH) { this.setState({ loading: true }); const recentlyBrowsed = RecentHistory.get().map((component) => component.key); + getSuggestions(query, recentlyBrowsed).then((response) => { // compare `this.state.query` and `query` to handle two request done almost at the same time // in this case only the request that matches the current query should be taken @@ -185,16 +190,19 @@ export class GlobalSearch extends React.PureComponent { searchMore = (qualifier: string) => { const { query } = this.state; + if (query.length === 1) { return; } this.setState({ loading: true, loadingMore: qualifier }); const recentlyBrowsed = RecentHistory.get().map((component) => component.key); + getSuggestions(query, recentlyBrowsed, qualifier).then((response) => { if (this.mounted) { const group = response.results.find((group) => group.q === qualifier); const moreResults = (group ? group.items : []).map((item) => ({ ...item, qualifier })); + this.setState((state) => ({ loading: false, loadingMore: undefined, @@ -205,6 +213,7 @@ export class GlobalSearch extends React.PureComponent { }, selected: moreResults.length > 0 ? moreResults[0].key : state.selected, })); + this.focusInput(); } }, this.stopLoading); @@ -222,6 +231,7 @@ export class GlobalSearch extends React.PureComponent { const index = list.indexOf(selected); return index > 0 ? { selected: list[index - 1] } : null; } + return null; }); }; @@ -233,6 +243,7 @@ export class GlobalSearch extends React.PureComponent { const index = list.indexOf(selected); return index >= 0 && index < list.length - 1 ? { selected: list[index + 1] } : null; } + return null; }); }; @@ -245,7 +256,7 @@ export class GlobalSearch extends React.PureComponent { } if (selected.startsWith('qualifier###')) { - this.searchMore(selected.substr(12)); + this.searchMore(selected.substring('qualifier###'.length)); } else { let qualifier = ComponentQualifier.Project; @@ -266,11 +277,7 @@ export class GlobalSearch extends React.PureComponent { const node = this.nodes[this.state.selected]; if (node && this.node) { - scrollToElement(node, { - topOffset: 30, - bottomOffset: 60, - parent: this.node, - }); + node.scrollIntoView(); } } }; @@ -279,6 +286,7 @@ export class GlobalSearch extends React.PureComponent { if (!getKeyboardShortcutEnabled() || isInput(event) || isShortcut(event)) { return true; } + if (event.key === KeyboardKeys.KeyS) { event.preventDefault(); this.focusInput(); @@ -348,6 +356,7 @@ export class GlobalSearch extends React.PureComponent { render() { const { open, query, results, more, loadingMore, selected, loading } = this.state; + if (!open && !query) { return ( @@ -364,6 +373,7 @@ export class GlobalSearch extends React.PureComponent { } const list = this.getPlainComponentsList(results, more); + const search = (
{ const EXTERNAL_PREFIX = 'external_'; const { ruleDetails } = this.props; const displayedKey = ruleDetails.key.startsWith(EXTERNAL_PREFIX) - ? ruleDetails.key.substr(EXTERNAL_PREFIX.length) + ? ruleDetails.key.substring(EXTERNAL_PREFIX.length) : ruleDetails.key; return {displayedKey}; } diff --git a/server/sonar-web/src/main/js/apps/coding-rules/routes.tsx b/server/sonar-web/src/main/js/apps/coding-rules/routes.tsx index 5ba7f224b13..5a9221be36b 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/routes.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/routes.tsx @@ -17,6 +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 React, { useEffect } from 'react'; import { Route, useLocation, useNavigate } from 'react-router-dom'; import { RawQuery } from '../../types/types'; @@ -44,7 +45,7 @@ function HashEditWrapper() { useEffect(() => { const { hash } = location; if (hash.length > 1) { - const query = parseHash(hash.substr(1)); + const query = parseHash(hash.substring(1)); const normalizedQuery = { ...serializeQuery(parseQuery(query)), open: query.open, diff --git a/server/sonar-web/src/main/js/apps/issues/components/ListItem.tsx b/server/sonar-web/src/main/js/apps/issues/components/ListItem.tsx index 78107922aac..a1fe1c5d19e 100644 --- a/server/sonar-web/src/main/js/apps/issues/components/ListItem.tsx +++ b/server/sonar-web/src/main/js/apps/issues/components/ListItem.tsx @@ -17,6 +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 * as React from 'react'; import Issue from '../../../components/issue/Issue'; import { BranchLike } from '../../../types/branch-like'; @@ -59,7 +60,7 @@ export default class ListItem extends React.PureComponent { const issuesReset = { issues: [] }; if (property.startsWith('tag###')) { - const tag = property.substr(6); + const tag = property.substring('tag###'.length); onFilterChange({ ...issuesReset, tags: [tag] }); } else { switch (property) { diff --git a/server/sonar-web/src/main/js/apps/projects/__tests__/utils-test.ts b/server/sonar-web/src/main/js/apps/projects/__tests__/utils-test.ts index b6c7496acb2..230acd91907 100644 --- a/server/sonar-web/src/main/js/apps/projects/__tests__/utils-test.ts +++ b/server/sonar-web/src/main/js/apps/projects/__tests__/utils-test.ts @@ -17,8 +17,10 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import { searchProjects } from '../../../api/components'; import { mockComponent } from '../../../helpers/mocks/component'; +import { Component } from '../../../types/types'; import * as utils from '../utils'; jest.mock('../../../api/components', () => ({ @@ -55,11 +57,13 @@ describe('parseSorting', () => { }); describe('formatDuration', () => { - const ONE_MINUTE = 60000; + const ONE_SECOND = 1000; + const ONE_MINUTE = 60 * ONE_SECOND; const ONE_HOUR = 60 * ONE_MINUTE; const ONE_DAY = 24 * ONE_HOUR; const ONE_MONTH = 30 * ONE_DAY; const ONE_YEAR = 12 * ONE_MONTH; + it('render years and months only', () => { expect(utils.formatDuration(ONE_YEAR * 4 + ONE_MONTH * 2 + ONE_DAY * 10)).toEqual( 'duration.years.4 duration.months.2 ' @@ -81,13 +85,14 @@ describe('formatDuration', () => { }); it('render less than a minute', () => { - expect(utils.formatDuration(1000)).toEqual('duration.seconds'); + expect(utils.formatDuration(ONE_SECOND)).toEqual('duration.seconds'); }); }); describe('fetchProjects', () => { it('correctly converts the passed arguments to the desired query format', async () => { - await utils.fetchProjects({}, true); + await utils.fetchProjects({ isFavorite: true, query: {} }); + expect(searchProjects).toHaveBeenCalledWith({ f: 'analysisDate,leakPeriodDate', facets: utils.FACETS.join(), @@ -96,7 +101,8 @@ describe('fetchProjects', () => { ps: 50, }); - await utils.fetchProjects({ view: 'leak' }, false, 3); + await utils.fetchProjects({ isFavorite: false, pageIndex: 3, query: { view: 'leak' } }); + expect(searchProjects).toHaveBeenCalledWith({ f: 'analysisDate,leakPeriodDate', facets: utils.LEAK_FACETS.join(), @@ -107,6 +113,7 @@ describe('fetchProjects', () => { it('correctly treats result data', async () => { const components = [mockComponent({ key: 'foo' }), mockComponent({ key: 'bar' })]; + (searchProjects as jest.Mock).mockResolvedValue({ components, facets: [ @@ -121,20 +128,25 @@ describe('fetchProjects', () => { ], paging: { total: 2 }, }); - await utils.fetchProjects({}, true).then((r) => { + + await utils.fetchProjects({ isFavorite: true, query: {} }).then((r) => { expect(r).toEqual({ facets: { new_coverage: { NO_DATA: 0 }, languages: { css: 10, js: 2 }, }, - projects: components.map((component: any) => { - if (component.key === 'foo') { - component.measures = { new_coverage: '10' }; - } else { - component.measures = { languages: '20' }; + projects: components.map( + (component: Component & { measures: { languages?: string; new_coverage?: string } }) => { + // eslint-disable-next-line jest/no-conditional-in-test + if (component.key === 'foo') { + component.measures = { new_coverage: '10' }; + } else { + component.measures = { languages: '20' }; + } + + return component; } - return component; - }), + ), total: 2, }); }); @@ -148,3 +160,11 @@ describe('defineMetrics', () => { expect(utils.defineMetrics({})).toBe(utils.METRICS); }); }); + +describe('convertToSorting', () => { + it('handles asc and desc sort', () => { + expect(utils.convertToSorting({ sort: '-size' })).toStrictEqual({ asc: false, s: 'ncloc' }); + expect(utils.convertToSorting({})).toStrictEqual({ s: undefined }); + expect(utils.convertToSorting({ sort: 'search' })).toStrictEqual({ s: 'query' }); + }); +}); diff --git a/server/sonar-web/src/main/js/apps/projects/components/AllProjects.tsx b/server/sonar-web/src/main/js/apps/projects/components/AllProjects.tsx index 55aa17d9838..536f90a44cb 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/AllProjects.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/AllProjects.tsx @@ -81,7 +81,9 @@ export class AllProjects extends React.PureComponent { handleRequiredAuthentication(); return; } + this.handleQueryChange(); + addSideBarClass(); } @@ -97,8 +99,11 @@ export class AllProjects extends React.PureComponent { } fetchProjects = (query: Query) => { + const { isFavorite } = this.props; + this.setState({ loading: true, query }); - fetchProjects(query, this.props.isFavorite).then((response) => { + + fetchProjects({ isFavorite, query }).then((response) => { if (this.mounted) { this.setState({ facets: response.facets, @@ -112,10 +117,13 @@ export class AllProjects extends React.PureComponent { }; fetchMoreProjects = () => { + const { isFavorite } = this.props; const { pageIndex, projects, query } = this.state; - if (pageIndex && projects && query) { + + if (pageIndex && projects && Object.keys(query).length !== 0) { this.setState({ loading: true }); - fetchProjects(query, this.props.isFavorite, pageIndex + 1).then((response) => { + + fetchProjects({ isFavorite, query, pageIndex: pageIndex + 1 }).then((response) => { if (this.mounted) { this.setState({ loading: false, @@ -127,9 +135,9 @@ export class AllProjects extends React.PureComponent { } }; - getSort = () => this.state.query.sort || 'name'; + getSort = () => this.state.query.sort ?? 'name'; - getView = () => this.state.query.view || 'overall'; + getView = () => this.state.query.view ?? 'overall'; handleClearAll = () => { this.props.router.push({ pathname: this.props.location.pathname }); @@ -147,7 +155,7 @@ export class AllProjects extends React.PureComponent { }); }; - handlePerspectiveChange = ({ view }: { view: string }) => { + handlePerspectiveChange = ({ view }: { view?: string }) => { const query: { view: string | undefined; sort?: string | undefined; @@ -158,6 +166,7 @@ export class AllProjects extends React.PureComponent { if (this.state.query.view === 'leak' || view === 'leak') { if (this.state.query.sort) { const sort = parseSorting(this.state.query.sort); + if (SORTING_SWITCH[sort.sortValue]) { query.sort = (sort.sortDesc ? '-' : '') + SORTING_SWITCH[sort.sortValue]; } @@ -306,12 +315,15 @@ function getStorageOptions() { sort?: string; view?: string; } = {}; + if (get(LS_PROJECTS_SORT)) { options.sort = get(LS_PROJECTS_SORT) || undefined; } + if (get(LS_PROJECTS_VIEW)) { options.view = get(LS_PROJECTS_VIEW) || undefined; } + return options; } diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/AllProjects-test.tsx b/server/sonar-web/src/main/js/apps/projects/components/__tests__/AllProjects-test.tsx index 9be34752c05..88c55dc28d0 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/__tests__/AllProjects-test.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/__tests__/AllProjects-test.tsx @@ -76,8 +76,10 @@ it('renders', () => { it('fetches projects', () => { shallowRender(); - expect(fetchProjects).toHaveBeenLastCalledWith( - { + + expect(fetchProjects).toHaveBeenLastCalledWith({ + isFavorite: false, + query: { coverage: undefined, duplications: undefined, gate: undefined, @@ -97,46 +99,58 @@ it('fetches projects', () => { tags: undefined, view: undefined, }, - false - ); + }); }); it('changes sort', () => { const push = jest.fn(); - const wrapper = shallowRender({}, push); - wrapper.find('PageHeader').prop('onSortChange')('size', false); + const wrapper = shallowRender({ push }); + + wrapper.find('PageHeader').prop<(sort: string, desc: boolean) => void>('onSortChange')( + 'size', + false + ); + expect(push).toHaveBeenLastCalledWith({ pathname: '/projects', query: { sort: 'size' } }); expect(save).toHaveBeenLastCalledWith(LS_PROJECTS_SORT, 'size'); }); it('changes perspective to leak', () => { const push = jest.fn(); - const wrapper = shallowRender({}, push); - wrapper.find('PageHeader').prop('onPerspectiveChange')({ view: 'leak' }); + const wrapper = shallowRender({ push }); + + wrapper.find('PageHeader').prop<({ view }: { view?: string }) => void>('onPerspectiveChange')({ + view: 'leak', + }); + expect(push).toHaveBeenLastCalledWith({ pathname: '/projects', query: { view: 'leak' }, }); + expect(save).toHaveBeenCalledWith(LS_PROJECTS_SORT, undefined); expect(save).toHaveBeenCalledWith(LS_PROJECTS_VIEW, 'leak'); }); it('updates sorting when changing perspective from leak', () => { const push = jest.fn(); - const wrapper = shallowRender({}, push); + const wrapper = shallowRender({ push }); wrapper.setState({ query: { sort: 'new_coverage', view: 'leak' } }); - wrapper.find('PageHeader').prop('onPerspectiveChange')({ + + wrapper.find('PageHeader').prop<({ view }: { view?: string }) => void>('onPerspectiveChange')({ view: undefined, }); + expect(push).toHaveBeenLastCalledWith({ pathname: '/projects', query: { sort: 'coverage', view: undefined }, }); + expect(save).toHaveBeenCalledWith(LS_PROJECTS_SORT, 'coverage'); expect(save).toHaveBeenCalledWith(LS_PROJECTS_VIEW, undefined); }); -it('handles favorite projects', () => { +it('handles updating the favorite status of a project', () => { const wrapper = shallowRender(); expect(wrapper.state('projects')).toMatchSnapshot(); @@ -144,11 +158,28 @@ it('handles favorite projects', () => { expect(wrapper.state('projects')).toMatchSnapshot(); }); -function shallowRender( - props: Partial = {}, - push = jest.fn(), - replace = jest.fn() -) { +it('handles showing favorite projects on load', () => { + const wrapper = shallowRender({ + props: { currentUser: { dismissedNotices: {}, isLoggedIn: false }, isFavorite: true }, + }); + + expect(wrapper.state('projects')).toMatchSnapshot(); + + wrapper.instance().handleFavorite('foo', true); + expect(wrapper.state('projects')).toMatchSnapshot(); +}); + +const defaults = { props: {}, push: () => undefined, replace: () => undefined }; + +function shallowRender({ + props = defaults.props, + push = defaults.push, + replace = defaults.replace, +}: { + props?: Partial; + push?: () => void; + replace?: () => void; +} = defaults) { const wrapper = shallow( ); + wrapper.setState({ loading: false, projects: [ @@ -175,5 +207,6 @@ function shallowRender( ], total: 0, }); + return wrapper; } diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/AllProjects-test.tsx.snap b/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/AllProjects-test.tsx.snap index aefede3dc66..9bad4f35742 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/AllProjects-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/AllProjects-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`handles favorite projects 1`] = ` +exports[`handles showing favorite projects on load 1`] = ` [ { "key": "foo", @@ -13,7 +13,34 @@ exports[`handles favorite projects 1`] = ` ] `; -exports[`handles favorite projects 2`] = ` +exports[`handles showing favorite projects on load 2`] = ` +[ + { + "isFavorite": true, + "key": "foo", + "measures": {}, + "name": "Foo", + "qualifier": "TRK", + "tags": [], + "visibility": "public", + }, +] +`; + +exports[`handles updating the favorite status of a project 1`] = ` +[ + { + "key": "foo", + "measures": {}, + "name": "Foo", + "qualifier": "TRK", + "tags": [], + "visibility": "public", + }, +] +`; + +exports[`handles updating the favorite status of a project 2`] = ` [ { "isFavorite": true, diff --git a/server/sonar-web/src/main/js/apps/projects/utils.ts b/server/sonar-web/src/main/js/apps/projects/utils.ts index 51a7afbe3ff..235018b7a9a 100644 --- a/server/sonar-web/src/main/js/apps/projects/utils.ts +++ b/server/sonar-web/src/main/js/apps/projects/utils.ts @@ -17,6 +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 { invert } from 'lodash'; import { Facet, searchProjects } from '../../api/components'; import { getMeasuresForProjects } from '../../api/measures'; @@ -150,22 +151,33 @@ export const LEAK_FACETS = [ const REVERSED_FACETS = ['coverage', 'new_coverage']; export function localizeSorting(sort?: string): string { - return translate('projects.sort', sort || 'name'); + return translate('projects.sort', sort ?? 'name'); } export function parseSorting(sort: string): { sortValue: string; sortDesc: boolean } { - const desc = sort[0] === '-'; - return { sortValue: desc ? sort.substr(1) : sort, sortDesc: desc }; + const desc = sort.startsWith('-'); + + return { sortValue: desc ? sort.substring(1) : sort, sortDesc: desc }; } -export function fetchProjects(query: Query, isFavorite: boolean, pageIndex = 1) { +export function fetchProjects({ + isFavorite, + query, + pageIndex = 1, +}: { + query: Query; + isFavorite: boolean; + pageIndex?: number; +}) { const ps = PAGE_SIZE; + const data = convertToQueryData(query, isFavorite, { p: pageIndex > 1 ? pageIndex : undefined, ps, facets: defineFacets(query).join(), f: 'analysisDate,leakPeriodDate', }); + return searchProjects(data) .then((response) => Promise.all([fetchProjectMeasures(response.components, query), Promise.resolve(response)]) @@ -183,6 +195,7 @@ export function fetchProjects(query: Query, isFavorite: boolean, pageIndex = 1) componentMeasures[measure.metric] = value; } }); + return { ...component, measures: componentMeasures }; }), total: paging.total, @@ -194,6 +207,7 @@ export function defineMetrics(query: Query): string[] { if (query.view === 'leak') { return LEAK_METRICS; } + return METRICS; } @@ -201,6 +215,7 @@ function defineFacets(query: Query): string[] { if (query.view === 'leak') { return LEAK_FACETS; } + return FACETS; } @@ -212,12 +227,15 @@ function convertToQueryData(query: Query, isFavorite: boolean, defaultData = {}) if (filter) { data.filter = filter; } + if (sort.s) { data.s = sort.s; } + if (sort.asc !== undefined) { data.asc = sort.asc; } + return data; } @@ -228,14 +246,17 @@ export function fetchProjectMeasures(projects: Array<{ key: string }>, query: Qu const projectKeys = projects.map((project) => project.key); const metrics = defineMetrics(query); + return getMeasuresForProjects(projectKeys, metrics); } function mapFacetValues(values: Array<{ val: string; count: number }>) { const map: Dict = {}; + values.forEach((value) => { map[value.val] = value.count; }); + return map; } @@ -266,22 +287,27 @@ const metricToPropertyMap = invert(propertyToMetricMap); function getFacetsMap(facets: Facet[]) { const map: Dict> = {}; + facets.forEach((facet) => { const property = metricToPropertyMap[facet.property]; const { values } = facet; + if (REVERSED_FACETS.includes(property)) { values.reverse(); } + map[property] = mapFacetValues(values); }); + return map; } -function convertToSorting({ sort }: Query): { s?: string; asc?: boolean } { - if (sort && sort[0] === '-') { - return { s: propertyToMetricMap[sort.substr(1)], asc: false }; +export function convertToSorting({ sort }: Query): { s?: string; asc?: boolean } { + if (sort?.startsWith('-')) { + return { s: propertyToMetricMap[sort.substring(1)], asc: false }; } - return { s: propertyToMetricMap[sort || ''] }; + + return { s: propertyToMetricMap[sort ?? ''] }; } const ONE_MINUTE = 60000; @@ -294,15 +320,18 @@ function format(periods: Array<{ value: number; label: string }>) { let result = ''; let count = 0; let lastId = -1; + for (let i = 0; i < periods.length && count < 2; i++) { if (periods[i].value > 0) { count++; + if (lastId < 0 || lastId + 1 === i) { lastId = i; result += translateWithParameters(periods[i].label, periods[i].value) + ' '; } } } + return result; } @@ -310,15 +339,21 @@ export function formatDuration(ms: number) { if (ms < ONE_MINUTE) { return translate('duration.seconds'); } + const years = Math.floor(ms / ONE_YEAR); ms -= years * ONE_YEAR; + const months = Math.floor(ms / ONE_MONTH); ms -= months * ONE_MONTH; + const days = Math.floor(ms / ONE_DAY); ms -= days * ONE_DAY; + const hours = Math.floor(ms / ONE_HOUR); ms -= hours * ONE_HOUR; + const minutes = Math.floor(ms / ONE_MINUTE); + return format([ { value: years, label: 'duration.years' }, { value: months, label: 'duration.months' }, diff --git a/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx b/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx index df385e549ed..6d4d6872ce8 100644 --- a/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx +++ b/server/sonar-web/src/main/js/apps/users/components/UserListItemIdentity.tsx @@ -17,9 +17,10 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + +import { getTextColor } from 'design-system'; import * as React from 'react'; import { colors } from '../../../app/theme'; -import { getTextColor } from '../../../helpers/colors'; import { getBaseUrl } from '../../../helpers/system'; import { IdentityProvider } from '../../../types/types'; import { User } from '../../../types/users'; diff --git a/server/sonar-web/src/main/js/components/charts/TreeMapRect.tsx b/server/sonar-web/src/main/js/components/charts/TreeMapRect.tsx index 67d207a6fe9..af9e761fe73 100644 --- a/server/sonar-web/src/main/js/components/charts/TreeMapRect.tsx +++ b/server/sonar-web/src/main/js/components/charts/TreeMapRect.tsx @@ -17,6 +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 classNames from 'classnames'; import { scaleLinear } from 'd3-scale'; import * as React from 'react'; @@ -119,7 +120,7 @@ export default class TreeMapRect extends React.PureComponent { )} - {this.props.label.substr(this.props.prefix.length)} + {this.props.label.substring(this.props.prefix.length)}
{this.props.value}
diff --git a/server/sonar-web/src/main/js/components/controls/IdentityProviderLink.tsx b/server/sonar-web/src/main/js/components/controls/IdentityProviderLink.tsx index fea5968876b..979aa185378 100644 --- a/server/sonar-web/src/main/js/components/controls/IdentityProviderLink.tsx +++ b/server/sonar-web/src/main/js/components/controls/IdentityProviderLink.tsx @@ -17,9 +17,10 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import classNames from 'classnames'; +import { isDarkColor } from 'design-system'; import * as React from 'react'; -import { isDarkColor } from '../../helpers/colors'; import { getBaseUrl } from '../../helpers/system'; import './IdentityProviderLink.css'; diff --git a/server/sonar-web/src/main/js/components/ui/GenericAvatar.tsx b/server/sonar-web/src/main/js/components/ui/GenericAvatar.tsx index 1dcf18ccc6f..0a2711e12ec 100644 --- a/server/sonar-web/src/main/js/components/ui/GenericAvatar.tsx +++ b/server/sonar-web/src/main/js/components/ui/GenericAvatar.tsx @@ -17,9 +17,10 @@ * along with this program; if not, write to the Free Software Foundation, * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ + import classNames from 'classnames'; +import { getTextColor, stringToColor } from 'design-system'; import * as React from 'react'; -import { getTextColor, stringToColor } from '../../helpers/colors'; interface Props { className?: string; diff --git a/server/sonar-web/src/main/js/helpers/__tests__/colors-test.ts b/server/sonar-web/src/main/js/helpers/__tests__/colors-test.ts deleted file mode 100644 index 84667c740d3..00000000000 --- a/server/sonar-web/src/main/js/helpers/__tests__/colors-test.ts +++ /dev/null @@ -1,50 +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 * as colors from '../colors'; - -describe('#stringToColor', () => { - it('should return a color for a text', () => { - expect(colors.stringToColor('skywalker')).toBe('#97f047'); - }); -}); - -describe('#isDarkColor', () => { - it('should be dark', () => { - expect(colors.isDarkColor('#000000')).toBe(true); - expect(colors.isDarkColor('#222222')).toBe(true); - expect(colors.isDarkColor('#000')).toBe(true); - }); - it('should be light', () => { - expect(colors.isDarkColor('#FFFFFF')).toBe(false); - expect(colors.isDarkColor('#CDCDCD')).toBe(false); - expect(colors.isDarkColor('#FFF')).toBe(false); - }); -}); - -describe('#getTextColor', () => { - it('should return dark color', () => { - expect(colors.getTextColor('#FFF', 'dark', 'light')).toBe('dark'); - expect(colors.getTextColor('#FFF')).toBe('#222'); - }); - it('should return light color', () => { - expect(colors.getTextColor('#000', 'dark', 'light')).toBe('light'); - expect(colors.getTextColor('#000')).toBe('#fff'); - }); -}); diff --git a/server/sonar-web/src/main/js/helpers/colors.ts b/server/sonar-web/src/main/js/helpers/colors.ts deleted file mode 100644 index 932b9c994ba..00000000000 --- a/server/sonar-web/src/main/js/helpers/colors.ts +++ /dev/null @@ -1,50 +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. - */ -/* eslint-disable no-bitwise, no-mixed-operators */ -export function stringToColor(str: string) { - let hash = 0; - for (let i = 0; i < str.length; i++) { - hash = str.charCodeAt(i) + ((hash << 5) - hash); - } - let color = '#'; - for (let i = 0; i < 3; i++) { - const value = (hash >> (i * 8)) & 0xff; - color += ('00' + value.toString(16)).substr(-2); - } - return color; -} - -export function isDarkColor(color: string) { - color = color.substr(1); - if (color.length === 3) { - // shortcut notation: #f90 - color = color[0] + color[0] + color[1] + color[1] + color[2] + color[2]; - } - const rgb = parseInt(color.substr(1), 16); - const r = (rgb >> 16) & 0xff; - const g = (rgb >> 8) & 0xff; - const b = (rgb >> 0) & 0xff; - const luma = 0.2126 * r + 0.7152 * g + 0.0722 * b; - return luma < 140; -} - -export function getTextColor(background: string, dark = '#222', light = '#fff') { - return isDarkColor(background) ? light : dark; -} -- 2.39.5