From 2ec3a144ca695dab18d431254b7f942309b73f05 Mon Sep 17 00:00:00 2001 From: stanislavh Date: Tue, 19 Nov 2024 14:16:05 +0100 Subject: [PATCH] SONAR-22300 SONAR-23528 Fix 11y issues on Projects page & fetch measures by chunks --- .../js/apps/issues/sidebar/ListStyleFacet.tsx | 2 +- .../apps/projects/components/AllProjects.tsx | 473 ++++++++---------- .../components/EmptyFavoriteSearch.tsx | 13 +- .../projects/components/EmptyInstance.tsx | 32 +- .../components/FavoriteProjectsContainer.tsx | 4 +- .../components/NoFavoriteProjects.tsx | 27 +- .../apps/projects/components/ProjectsList.tsx | 95 ++-- .../components/__tests__/AllProjects-test.tsx | 5 +- .../components/project-card/ProjectCard.tsx | 40 +- .../__tests__/ProjectCard-test.tsx | 4 +- .../src/main/js/apps/projects/utils.ts | 4 +- .../main/js/components/common/EmptySearch.tsx | 9 +- .../main/js/components/controls/Favorite.tsx | 106 ++-- .../controls/__tests__/Favorite-test.tsx | 3 +- .../src/main/js/helpers/react-query.ts | 8 + .../src/main/js/queries/favorites.ts | 81 +++ .../sonar-web/src/main/js/queries/measures.ts | 62 ++- .../sonar-web/src/main/js/queries/projects.ts | 65 ++- .../resources/org/sonar/l10n/core.properties | 2 + 19 files changed, 542 insertions(+), 493 deletions(-) create mode 100644 server/sonar-web/src/main/js/queries/favorites.ts diff --git a/server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacet.tsx b/server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacet.tsx index 141a5683c23..9f331a529f4 100644 --- a/server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacet.tsx +++ b/server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacet.tsx @@ -500,7 +500,7 @@ export class ListStyleFacet extends React.Component, State> { {this.renderSearch()} - + {showList ? this.renderList() : this.renderSearchResults()} 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 0dbbf52e078..653e43d27f1 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 @@ -20,10 +20,10 @@ import styled from '@emotion/styled'; import { Heading, Spinner } from '@sonarsource/echoes-react'; -import { keyBy, mapValues, omitBy, pick } from 'lodash'; -import * as React from 'react'; +import { chunk, keyBy, last, mapValues, omitBy, pick } from 'lodash'; +import { useEffect, useMemo } from 'react'; import { Helmet } from 'react-helmet-async'; -import { useSearchParams } from 'react-router-dom'; +import { useIntl } from 'react-intl'; import { LAYOUT_FOOTER_HEIGHT, LargeCenteredLayout, @@ -32,126 +32,166 @@ import { themeColor, } from '~design-system'; import A11ySkipTarget from '~sonar-aligned/components/a11y/A11ySkipTarget'; -import { withRouter } from '~sonar-aligned/components/hoc/withRouter'; +import { useLocation, useRouter } from '~sonar-aligned/components/hoc/withRouter'; import { ComponentQualifier } from '~sonar-aligned/types/component'; -import { Location, RawQuery, Router } from '~sonar-aligned/types/router'; +import { RawQuery } from '~sonar-aligned/types/router'; import { searchProjects } from '../../../api/components'; -import withAppStateContext from '../../../app/components/app-state/withAppStateContext'; -import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext'; +import { useAppState } from '../../../app/components/app-state/withAppStateContext'; +import { useCurrentUser } from '../../../app/components/current-user/CurrentUserContext'; +import EmptySearch from '../../../components/common/EmptySearch'; import ScreenPositionHelper from '../../../components/common/ScreenPositionHelper'; import '../../../components/search-navigator.css'; import handleRequiredAuthentication from '../../../helpers/handleRequiredAuthentication'; import { translate } from '../../../helpers/l10n'; -import { get, save } from '../../../helpers/storage'; import { isDefined } from '../../../helpers/types'; +import useLocalStorage from '../../../hooks/useLocalStorage'; +import { useMeasuresForProjectsQuery } from '../../../queries/measures'; +import { + PROJECTS_PAGE_SIZE, + useMyScannableProjectsQuery, + useProjectsQuery, +} from '../../../queries/projects'; import { useStandardExperienceMode } from '../../../queries/settings'; -import { AppState } from '../../../types/appstate'; -import { CurrentUser, isLoggedIn } from '../../../types/users'; -import { Query, hasFilterParams, parseUrlQuery } from '../query'; +import { isLoggedIn } from '../../../types/users'; +import { hasFilterParams, parseUrlQuery } from '../query'; import '../styles.css'; -import { Facets, Project } from '../types'; -import { SORTING_SWITCH, convertToQueryData, fetchProjects, parseSorting } from '../utils'; +import { + SORTING_SWITCH, + convertToQueryData, + defineMetrics, + getFacetsMap, + parseSorting, +} from '../utils'; +import EmptyFavoriteSearch from './EmptyFavoriteSearch'; +import EmptyInstance from './EmptyInstance'; +import NoFavoriteProjects from './NoFavoriteProjects'; import PageHeader from './PageHeader'; import PageSidebar from './PageSidebar'; import ProjectsList from './ProjectsList'; -interface Props { - appState: AppState; - currentUser: CurrentUser; - isFavorite: boolean; - isStandardMode: boolean; - location: Location; - router: Router; -} - -interface State { - facets?: Facets; - loading: boolean; - pageIndex?: number; - projects?: Omit[]; - query: Query; - total?: number; -} - export const LS_PROJECTS_SORT = 'sonarqube.projects.sort'; export const LS_PROJECTS_VIEW = 'sonarqube.projects.view'; -export class AllProjects extends React.PureComponent { - mounted = false; +function AllProjects({ isFavorite }: Readonly<{ isFavorite: boolean }>) { + const appState = useAppState(); + const { currentUser } = useCurrentUser(); + const router = useRouter(); + const intl = useIntl(); + const { query, pathname } = useLocation(); + const parsedQuery = parseUrlQuery(query); + const querySort = parsedQuery.sort ?? 'name'; + const queryView = parsedQuery.view ?? 'overall'; + const [projectsSort, setProjectsSort] = useLocalStorage(LS_PROJECTS_SORT); + const [projectsView, setProjectsView] = useLocalStorage(LS_PROJECTS_VIEW); + const { data: isStandardMode = false, isLoading: loadingMode } = useStandardExperienceMode(); + + const { + data: projectPages, + isLoading: loadingProjects, + isFetchingNextPage, + fetchNextPage, + } = useProjectsQuery( + { + isFavorite, + query: parsedQuery, + isStandardMode, + }, + { refetchOnMount: 'always' }, + ); + const { data: { projects: scannableProjects = [] } = {}, isLoading: loadingScannableProjects } = + useMyScannableProjectsQuery(); + const { projects, facets, paging } = useMemo( + () => ({ + projects: + projectPages?.pages + .flatMap((page) => page.components) + .map((project) => ({ + ...project, + isScannable: scannableProjects.find((p) => p.key === project.key) !== undefined, + })) ?? [], + facets: getFacetsMap( + projectPages?.pages[projectPages?.pages.length - 1]?.facets ?? [], + isStandardMode, + ), + paging: projectPages?.pages[projectPages?.pages.length - 1]?.paging, + }), + [projectPages, scannableProjects, isStandardMode], + ); - constructor(props: Props) { - super(props); - this.state = { loading: true, query: {} }; - } + // Fetch measures by using chunks of 50 + const measureQueries = useMeasuresForProjectsQuery({ + projectKeys: projects.map((p) => p.key), + metricKeys: defineMetrics(parsedQuery), + }); + const measuresForLastChunkAreLoading = Boolean(last(measureQueries)?.isLoading); + const measures = measureQueries + .map((q) => q.data) + .flat() + .filter(isDefined); + + // When measures for latest page are loading, we don't want to show them + const readyProjects = useMemo(() => { + if (measuresForLastChunkAreLoading) { + return chunk(projects, PROJECTS_PAGE_SIZE).slice(0, -1).flat(); + } - componentDidMount() { - this.mounted = true; + return projects; + }, [projects, measuresForLastChunkAreLoading]); - if (this.props.isFavorite && !isLoggedIn(this.props.currentUser)) { - handleRequiredAuthentication(); - return; - } + const isLoading = + loadingMode || + loadingProjects || + loadingScannableProjects || + Boolean(measureQueries[0]?.isLoading); - this.handleQueryChange(); - } + // Set sort and view from LS if not present in URL + useEffect(() => { + const hasViewParams = parsedQuery.view ?? parsedQuery.sort; + const hasSavedOptions = projectsSort ?? projectsView; - componentDidUpdate(prevProps: Props) { - if (prevProps.location.query !== this.props.location.query) { - this.handleQueryChange(); - } - } - - componentWillUnmount() { - this.mounted = false; - } - - fetchMoreProjects = () => { - const { isFavorite, isStandardMode } = this.props; - const { pageIndex, projects, query } = this.state; - - if (isDefined(pageIndex) && pageIndex !== 0 && projects && Object.keys(query).length !== 0) { - this.setState({ loading: true }); - - fetchProjects({ isFavorite, query, pageIndex: pageIndex + 1, isStandardMode }).then( - (response) => { - if (this.mounted) { - this.setState({ - loading: false, - pageIndex: pageIndex + 1, - projects: [...projects, ...response.projects], - }); - } - }, - this.stopLoading, - ); + if (hasViewParams === undefined && hasSavedOptions) { + router.replace({ pathname, query: { ...query, sort: projectsSort, view: projectsView } }); } - }; + }, [projectsSort, projectsView, router, parsedQuery, query, pathname]); + + /* + * Needs refactoring to query + */ + const loadSearchResultCount = (property: string, values: string[]) => { + const data = convertToQueryData( + { ...parsedQuery, [property]: values }, + isFavorite, + isStandardMode, + { + ps: 1, + facets: property, + }, + ); - getSort = () => this.state.query.sort ?? 'name'; + return searchProjects(data).then(({ facets }) => { + const values = facets.find((facet) => facet.property === property)?.values ?? []; - getView = () => this.state.query.view ?? 'overall'; + return mapValues(keyBy(values, 'val'), 'count'); + }); + }; - handleClearAll = () => { - const { pathname, query } = this.props.location; + const updateLocationQuery = (newQuery: RawQuery) => { + const nextQuery = omitBy({ ...query, ...newQuery }, (x) => !x); + router.push({ pathname, query: nextQuery }); + }; + const handleClearAll = () => { const queryWithoutFilters = pick(query, ['view', 'sort']); - - this.props.router.push({ pathname, query: queryWithoutFilters }); + router.push({ pathname, query: queryWithoutFilters }); }; - handleFavorite = (key: string, isFavorite: boolean) => { - this.setState(({ projects }) => { - if (!projects) { - return null; - } - - return { - projects: projects.map((p) => (p.key === key ? { ...p, isFavorite } : p)), - }; - }); + const handleSortChange = (sort: string, desc: boolean) => { + const asString = (desc ? '-' : '') + sort; + updateLocationQuery({ sort: asString }); + setProjectsSort(asString); }; - handlePerspectiveChange = ({ view }: { view?: string }) => { + const handlePerspectiveChange = ({ view }: { view?: string }) => { const query: { sort?: string; view: string | undefined; @@ -159,81 +199,21 @@ export class AllProjects extends React.PureComponent { view: view === 'overall' ? undefined : view, }; - if (this.state.query.view === 'leak' || view === 'leak') { - if (isDefined(this.state.query.sort)) { - const sort = parseSorting(this.state.query.sort); + if (isDefined(parsedQuery.sort)) { + const sort = parseSorting(parsedQuery.sort); - if (isDefined(SORTING_SWITCH[sort.sortValue])) { - query.sort = (sort.sortDesc ? '-' : '') + SORTING_SWITCH[sort.sortValue]; - } + if (isDefined(SORTING_SWITCH[sort.sortValue])) { + query.sort = (sort.sortDesc ? '-' : '') + SORTING_SWITCH[sort.sortValue]; } - - this.props.router.push({ pathname: this.props.location.pathname, query }); - } else { - this.updateLocationQuery(query); } - save(LS_PROJECTS_SORT, query.sort); - save(LS_PROJECTS_VIEW, query.view); - }; - - handleQueryChange() { - const { isFavorite, isStandardMode } = this.props; - - const queryRaw = this.props.location.query; - const query = parseUrlQuery(queryRaw); - - this.setState({ loading: true, query }); - - fetchProjects({ isFavorite, query, isStandardMode }).then((response) => { - // We ignore the request if the query changed since the time it was initiated - // If that happened, another query will be initiated anyway - if (this.mounted && queryRaw === this.props.location.query) { - this.setState({ - facets: response.facets, - loading: false, - pageIndex: 1, - projects: response.projects, - total: response.total, - }); - } - }, this.stopLoading); - } + router.push({ pathname, query }); - handleSortChange = (sort: string, desc: boolean) => { - const asString = (desc ? '-' : '') + sort; - this.updateLocationQuery({ sort: asString }); - save(LS_PROJECTS_SORT, asString); + setProjectsSort(query.sort); + setProjectsView(query.view); }; - loadSearchResultCount = (property: string, values: string[]) => { - const { isFavorite, isStandardMode } = this.props; - const { query = {} } = this.state; - - const data = convertToQueryData({ ...query, [property]: values }, isFavorite, isStandardMode, { - ps: 1, - facets: property, - }); - - return searchProjects(data).then(({ facets }) => { - const values = facets.find((facet) => facet.property === property)?.values ?? []; - - return mapValues(keyBy(values, 'val'), 'count'); - }); - }; - - stopLoading = () => { - if (this.mounted) { - this.setState({ loading: false }); - } - }; - - updateLocationQuery = (newQuery: RawQuery) => { - const query = omitBy({ ...this.props.location.query, ...newQuery }, (x) => !x); - this.props.router.push({ pathname: this.props.location.pathname, query }); - }; - - renderSide = () => ( + const renderSide = () => ( {({ top }) => ( @@ -250,15 +230,13 @@ export class AllProjects extends React.PureComponent { /> @@ -267,122 +245,99 @@ export class AllProjects extends React.PureComponent { ); - renderHeader = () => ( + const renderHeader = () => ( ); - renderMain = () => { - if (this.state.loading && this.state.projects === undefined) { - return ; - } - + const renderMain = () => { + const isFiltered = hasFilterParams(parsedQuery); return (
- {this.state.projects && ( +
+ + {readyProjects.length === 0 && isFiltered && isFavorite && ( + + )} + {readyProjects.length === 0 && isFiltered && !isFavorite && } + {readyProjects.length === 0 && !isFiltered && isFavorite && } + {readyProjects.length === 0 && !isFiltered && !isFavorite && } + {readyProjects.length > 0 && ( + + {intl.formatMessage({ id: 'projects.x_projects_found' }, { count: paging?.total })} + + )} + +
+ {readyProjects.length > 0 && ( )}
); }; - render() { - return ( - - - - - {translate('projects.page')} - - - - - {this.renderSide()} - -
- - - - {translate('list_of_projects')} - + return ( + + - {this.renderHeader()} + + {translate('projects.page')} + - {this.renderMain()} -
-
-
-
- ); - } -} + + + {renderSide()} -function getStorageOptions() { - const options: { - sort?: string; - view?: string; - } = {}; +
+ - if (get(LS_PROJECTS_SORT) !== null) { - options.sort = get(LS_PROJECTS_SORT) ?? undefined; - } + + {translate('list_of_projects')} + - if (get(LS_PROJECTS_VIEW) !== null) { - options.view = get(LS_PROJECTS_VIEW) ?? undefined; - } + {renderHeader()} - return options; + {renderMain()} +
+
+
+ + ); } -function AllProjectsWrapper(props: Readonly>) { - const [searchParams, setSearchParams] = useSearchParams(); - const savedOptions = getStorageOptions(); - const { data: isStandardMode, isLoading } = useStandardExperienceMode(); - - React.useEffect( - () => { - const hasViewParams = searchParams.get('sort') ?? searchParams.get('view'); - const hasSavedOptions = savedOptions.sort ?? savedOptions.view; - - if (!isDefined(hasViewParams) && isDefined(hasSavedOptions)) { - setSearchParams(savedOptions); - } - }, - // eslint-disable-next-line react-hooks/exhaustive-deps - [ - /* Run once on mount only */ - ], - ); +function withRedirectWrapper(Component: React.ComponentType<{ isFavorite: boolean }>) { + return function Wrapper(props: Readonly<{ isFavorite: boolean }>) { + const { currentUser } = useCurrentUser(); + if (props.isFavorite && !isLoggedIn(currentUser)) { + handleRequiredAuthentication(); + return null; + } - return ( - - - - ); + return ; + }; } -export default withRouter(withCurrentUserContext(withAppStateContext(AllProjectsWrapper))); +export default withRedirectWrapper(AllProjects); const StyledWrapper = styled.div` background-color: ${themeColor('backgroundPrimary')}; diff --git a/server/sonar-web/src/main/js/apps/projects/components/EmptyFavoriteSearch.tsx b/server/sonar-web/src/main/js/apps/projects/components/EmptyFavoriteSearch.tsx index e0cb4d660b0..23bad03f6a8 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/EmptyFavoriteSearch.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/EmptyFavoriteSearch.tsx @@ -18,8 +18,9 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ +import { Link, Text, TextSize } from '@sonarsource/echoes-react'; import { FormattedMessage } from 'react-intl'; -import { FishVisual, Highlight, StandoutLink } from '~design-system'; +import { FishVisual } from '~design-system'; import { queryToSearchString } from '~sonar-aligned/helpers/urls'; import { translate } from '../../../helpers/l10n'; import { Dict } from '../../../types/types'; @@ -27,25 +28,25 @@ import { Query } from '../query'; export default function EmptyFavoriteSearch({ query }: { query: Query }) { return ( -
+
- + {translate('no_results_search.favorites')} - +
), }} > {translate('all')} - + ), }} /> diff --git a/server/sonar-web/src/main/js/apps/projects/components/EmptyInstance.tsx b/server/sonar-web/src/main/js/apps/projects/components/EmptyInstance.tsx index 4ac18fdaca2..d1bfdda60ce 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/EmptyInstance.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/EmptyInstance.tsx @@ -18,35 +18,31 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { Button, ButtonVariety } from '@sonarsource/echoes-react'; -import { FishVisual, Highlight } from '~design-system'; -import { withRouter } from '~sonar-aligned/components/hoc/withRouter'; -import { Router } from '~sonar-aligned/types/router'; +import { Button, ButtonVariety, Text, TextSize } from '@sonarsource/echoes-react'; +import { FishVisual } from '~design-system'; +import { useRouter } from '~sonar-aligned/components/hoc/withRouter'; +import { useCurrentUser } from '../../../app/components/current-user/CurrentUserContext'; import { translate } from '../../../helpers/l10n'; import { hasGlobalPermission } from '../../../helpers/users'; import { Permissions } from '../../../types/permissions'; -import { CurrentUser, isLoggedIn } from '../../../types/users'; +import { isLoggedIn } from '../../../types/users'; -export interface EmptyInstanceProps { - currentUser: CurrentUser; - router: Router; -} - -export function EmptyInstance(props: EmptyInstanceProps) { - const { currentUser, router } = props; +export default function EmptyInstance() { + const { currentUser } = useCurrentUser(); + const router = useRouter(); const showNewProjectButton = isLoggedIn(currentUser) && hasGlobalPermission(currentUser, Permissions.ProjectCreation); return ( -
+
- + {showNewProjectButton ? translate('projects.no_projects.empty_instance.new_project') : translate('projects.no_projects.empty_instance')} - + {showNewProjectButton && ( -
+ <>

{translate('projects.no_projects.empty_instance.how_to_add_projects')}

@@ -59,10 +55,8 @@ export function EmptyInstance(props: EmptyInstanceProps) { > {translate('my_account.create_new.TRK')} -
+ )}
); } - -export default withRouter(EmptyInstance); diff --git a/server/sonar-web/src/main/js/apps/projects/components/FavoriteProjectsContainer.tsx b/server/sonar-web/src/main/js/apps/projects/components/FavoriteProjectsContainer.tsx index 35d025789cb..4be4f9bf7ca 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/FavoriteProjectsContainer.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/FavoriteProjectsContainer.tsx @@ -20,6 +20,6 @@ import AllProjects from './AllProjects'; -export default function FavoriteProjectsContainer(props: any) { - return ; +export default function FavoriteProjectsContainer() { + return ; } diff --git a/server/sonar-web/src/main/js/apps/projects/components/NoFavoriteProjects.tsx b/server/sonar-web/src/main/js/apps/projects/components/NoFavoriteProjects.tsx index b197fab45c5..8e47c44a150 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/NoFavoriteProjects.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/NoFavoriteProjects.tsx @@ -18,26 +18,23 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { Highlight, StandoutLink } from '~design-system'; +import { Link, Text, TextSize } from '@sonarsource/echoes-react'; import { translate } from '../../../helpers/l10n'; export default function NoFavoriteProjects() { return ( -
- +
+ {translate('projects.no_favorite_projects')} - - -
-

- {translate('projects.no_favorite_projects.engagement')} -

-

- - {translate('projects.explore_projects')} - -

-
+
+

+ {translate('projects.no_favorite_projects.engagement')} +

+

+ + {translate('projects.explore_projects')} + +

); } diff --git a/server/sonar-web/src/main/js/apps/projects/components/ProjectsList.tsx b/server/sonar-web/src/main/js/apps/projects/components/ProjectsList.tsx index b940df906c4..07bff689a37 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/ProjectsList.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/ProjectsList.tsx @@ -18,22 +18,15 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { Spinner } from '@sonarsource/echoes-react'; import classNames from 'classnames'; import { AutoSizer } from 'react-virtualized/dist/commonjs/AutoSizer'; import { List, ListRowProps } from 'react-virtualized/dist/commonjs/List'; -import EmptySearch from '../../../components/common/EmptySearch'; import ListFooter from '../../../components/controls/ListFooter'; import { translate } from '../../../helpers/l10n'; import { isDiffMetric } from '../../../helpers/measures'; -import { useMeasuresForProjectsQuery } from '../../../queries/measures'; -import { CurrentUser } from '../../../types/users'; +import { MeasuresForProjects } from '../../../types/measures'; import { Query } from '../query'; import { Project } from '../types'; -import { defineMetrics } from '../utils'; -import EmptyFavoriteSearch from './EmptyFavoriteSearch'; -import EmptyInstance from './EmptyInstance'; -import NoFavoriteProjects from './NoFavoriteProjects'; import ProjectCard from './project-card/ProjectCard'; const PROJECT_CARD_HEIGHT = 181; @@ -42,37 +35,18 @@ const PROJECT_LIST_FOOTER_HEIGHT = 90; interface Props { cardType?: string; - currentUser: CurrentUser; - handleFavorite: (component: string, isFavorite: boolean) => void; isFavorite: boolean; isFiltered: boolean; loadMore: () => void; loading: boolean; + measures: MeasuresForProjects[]; projects: Omit[]; query: Query; total?: number; } export default function ProjectsList(props: Readonly) { - const { - currentUser, - isFavorite, - handleFavorite, - cardType, - isFiltered, - query, - loading, - projects, - total, - loadMore, - } = props; - const { data: measures, isLoading: measuresLoading } = useMeasuresForProjectsQuery( - { - projectKeys: projects.map((p) => p.key), - metricKeys: defineMetrics(query), - }, - { enabled: projects.length > 0 }, - ); + const { cardType, measures, loading, projects, total, loadMore } = props; const renderRow = ({ index, key, style }: ListRowProps) => { if (index === projects.length) { @@ -114,8 +88,6 @@ export default function ProjectsList(props: Readonly) { >
) { ); }; - if (projects.length === 0) { - if (isFiltered) { - return isFavorite ? : ; - } - return isFavorite ? : ; - } - return ( - - - {({ height, width }) => ( - { - if (index === 0) { - // first card, double top and bottom margin - return PROJECT_CARD_HEIGHT + PROJECT_CARD_MARGIN * 2; - } - if (index === projects.length) { - // Footer card, no margin - return PROJECT_LIST_FOOTER_HEIGHT; - } - // all other cards, only bottom margin - return PROJECT_CARD_HEIGHT + PROJECT_CARD_MARGIN; - }} - rowRenderer={renderRow} - style={{ outline: 'none' }} - tabIndex={-1} - width={width} - /> - )} - - + + {({ height, width }) => ( + { + if (index === 0) { + // first card, double top and bottom margin + return PROJECT_CARD_HEIGHT + PROJECT_CARD_MARGIN * 2; + } + if (index === projects.length) { + // Footer card, no margin + return PROJECT_LIST_FOOTER_HEIGHT; + } + // all other cards, only bottom margin + return PROJECT_CARD_HEIGHT + PROJECT_CARD_MARGIN; + }} + rowRenderer={renderRow} + style={{ outline: 'none' }} + tabIndex={-1} + width={width} + /> + )} + ); } 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 b677013b682..035a73a761b 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 @@ -87,6 +87,7 @@ it('changes sort and perspective', async () => { await user.click(screen.getByText('projects.sorting.size')); const projects = await ui.projects.findAll(); + expect(save).toHaveBeenCalledWith(LS_PROJECTS_SORT, '"size"'); expect(await within(projects[0]).findByRole('link')).toHaveTextContent( 'sonarlint-omnisharp-dotnet', @@ -101,9 +102,9 @@ it('changes sort and perspective', async () => { 20, ); - expect(save).toHaveBeenCalledWith(LS_PROJECTS_VIEW, 'leak'); + expect(save).toHaveBeenCalledWith(LS_PROJECTS_VIEW, '"leak"'); // sort should also be updated - expect(save).toHaveBeenCalledWith(LS_PROJECTS_SORT, MetricKey.new_lines); + expect(save).toHaveBeenCalledWith(LS_PROJECTS_SORT, `"${MetricKey.new_lines}"`); }); it('handles showing favorite projects on load', async () => { diff --git a/server/sonar-web/src/main/js/apps/projects/components/project-card/ProjectCard.tsx b/server/sonar-web/src/main/js/apps/projects/components/project-card/ProjectCard.tsx index 93f608d26cd..8dd3f3d0e6d 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/project-card/ProjectCard.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/project-card/ProjectCard.tsx @@ -22,7 +22,7 @@ import styled from '@emotion/styled'; import { Link, LinkStandalone } from '@sonarsource/echoes-react'; import classNames from 'classnames'; import { isEmpty } from 'lodash'; -import { FormattedMessage } from 'react-intl'; +import { FormattedMessage, useIntl } from 'react-intl'; import { Badge, Card, @@ -42,6 +42,7 @@ import { Status } from '~sonar-aligned/types/common'; import { ComponentQualifier } from '~sonar-aligned/types/component'; import { MetricKey, MetricType } from '~sonar-aligned/types/metrics'; import ChangeInCalculation from '../../../../app/components/ChangeInCalculationPill'; +import { useCurrentUser } from '../../../../app/components/current-user/CurrentUserContext'; import Favorite from '../../../../components/controls/Favorite'; import Tooltip from '../../../../components/controls/Tooltip'; import DateFromNow from '../../../../components/intl/DateFromNow'; @@ -49,23 +50,17 @@ import DateTimeFormatter from '../../../../components/intl/DateTimeFormatter'; import { translate, translateWithParameters } from '../../../../helpers/l10n'; import { isDefined } from '../../../../helpers/types'; import { getProjectUrl } from '../../../../helpers/urls'; -import { CurrentUser, isLoggedIn } from '../../../../types/users'; +import { isLoggedIn } from '../../../../types/users'; import { Project } from '../../types'; import ProjectCardLanguages from './ProjectCardLanguages'; import ProjectCardMeasures from './ProjectCardMeasures'; interface Props { - currentUser: CurrentUser; - handleFavorite: (component: string, isFavorite: boolean) => void; project: Project; type?: string; } -function renderFirstLine( - project: Props['project'], - handleFavorite: Props['handleFavorite'], - isNewCode: boolean, -) { +function renderFirstLine(project: Props['project'], isNewCode: boolean) { const { analysisDate, isFavorite, key, measures, name, qualifier, tags, visibility } = project; const noSoftwareQualityMetrics = [ MetricKey.software_quality_reliability_issues, @@ -95,7 +90,6 @@ function renderFirstLine( component={key} componentName={name} favorite={isFavorite} - handleFavorite={handleFavorite} qualifier={qualifier} /> )} @@ -236,12 +230,13 @@ function renderFirstLine( ); } -function renderSecondLine( - currentUser: Props['currentUser'], - project: Props['project'], - isNewCode: boolean, -) { +function SecondLine({ + project, + isNewCode, +}: Readonly<{ isNewCode: boolean; project: Props['project'] }>) { const { analysisDate, key, leakPeriodDate, measures, qualifier, isScannable } = project; + const intl = useIntl(); + const { currentUser } = useCurrentUser(); if (!isEmpty(analysisDate) && (!isNewCode || !isEmpty(leakPeriodDate))) { return ( @@ -266,7 +261,14 @@ function renderSecondLine( isEmpty(analysisDate) && isLoggedIn(currentUser) && isScannable && ( - + {translate('projects.configure_analysis')} )} @@ -275,7 +277,7 @@ function renderSecondLine( } export default function ProjectCard(props: Readonly) { - const { currentUser, type, project } = props; + const { type, project } = props; const isNewCode = type === 'leak'; return ( @@ -285,11 +287,11 @@ export default function ProjectCard(props: Readonly) { )} data-key={project.key} > - {renderFirstLine(project, props.handleFavorite, isNewCode)} + {renderFirstLine(project, isNewCode)} - {renderSecondLine(currentUser, project, isNewCode)} + ); } diff --git a/server/sonar-web/src/main/js/apps/projects/components/project-card/__tests__/ProjectCard-test.tsx b/server/sonar-web/src/main/js/apps/projects/components/project-card/__tests__/ProjectCard-test.tsx index 93ae42ebbd2..bf6153357c7 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/project-card/__tests__/ProjectCard-test.tsx +++ b/server/sonar-web/src/main/js/apps/projects/components/project-card/__tests__/ProjectCard-test.tsx @@ -355,7 +355,5 @@ describe('upgrade scenario (awaiting scan)', () => { }); function renderProjectCard(project: Project, user: CurrentUser = USER_LOGGED_OUT, type?: string) { - renderComponent( - , - ); + renderComponent(, '', { currentUser: user }); } 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 3f0630b43fa..89fa2260e10 100644 --- a/server/sonar-web/src/main/js/apps/projects/utils.ts +++ b/server/sonar-web/src/main/js/apps/projects/utils.ts @@ -245,7 +245,7 @@ export function defineMetrics(query: Query): string[] { return METRICS; } -function defineFacets(query: Query, isStandardMode: boolean): string[] { +export function defineFacets(query: Query, isStandardMode: boolean): string[] { if (query.view === 'leak') { return isStandardMode ? LEGACY_LEAK_FACETS : LEAK_FACETS; } @@ -322,7 +322,7 @@ export const propertyToMetricMap: Dict = { new_maintainability: 'new_software_quality_maintainability_rating', }; -function getFacetsMap(facets: Facet[], isStandardMode: boolean) { +export function getFacetsMap(facets: Facet[], isStandardMode: boolean) { const map: Dict> = {}; facets.forEach((facet) => { diff --git a/server/sonar-web/src/main/js/components/common/EmptySearch.tsx b/server/sonar-web/src/main/js/components/common/EmptySearch.tsx index 72c579af28b..2837dc86af4 100644 --- a/server/sonar-web/src/main/js/components/common/EmptySearch.tsx +++ b/server/sonar-web/src/main/js/components/common/EmptySearch.tsx @@ -18,16 +18,17 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { FishVisual, Highlight } from '~design-system'; +import { Text, TextSize } from '@sonarsource/echoes-react'; +import { FishVisual } from '~design-system'; import { translate } from '../../helpers/l10n'; export default function EmptySearch() { return ( -
+
- + {translate('no_results_search')} - +

{translate('no_results_search.2')}

); diff --git a/server/sonar-web/src/main/js/components/controls/Favorite.tsx b/server/sonar-web/src/main/js/components/controls/Favorite.tsx index 6564a6714da..7df3737daa8 100644 --- a/server/sonar-web/src/main/js/components/controls/Favorite.tsx +++ b/server/sonar-web/src/main/js/components/controls/Favorite.tsx @@ -20,8 +20,8 @@ import * as React from 'react'; import { FavoriteButton } from '~design-system'; -import { addFavorite, removeFavorite } from '../../api/favorites'; import { translate, translateWithParameters } from '../../helpers/l10n'; +import { useToggleFavoriteMutation } from '../../queries/favorites'; import Tooltip from './Tooltip'; interface Props { @@ -33,72 +33,52 @@ interface Props { qualifier: string; } -interface State { - favorite: boolean; -} - -export default class Favorite extends React.PureComponent { - mounted = false; - buttonNode?: HTMLElement | null; - - constructor(props: Props) { - super(props); - - this.state = { - favorite: props.favorite, - }; - } +export default function Favorite(props: Readonly) { + const { + className, + componentName, + qualifier, + favorite: favoriteP, + component, + handleFavorite, + } = props; + const buttonRef = React.useRef(null); + // local state of favorite is only needed in case of portfolios, as they are not migrated to query yet + const [favorite, setFavorite] = React.useState(favoriteP); + const { mutate } = useToggleFavoriteMutation(); - componentDidMount() { - this.mounted = true; - } + const toggleFavorite = () => { + const newFavorite = !favorite; - componentDidUpdate(_prevProps: Props, prevState: State) { - if (prevState.favorite !== this.props.favorite) { - this.setState({ favorite: this.props.favorite }); - } - } - - componentWillUnmount() { - this.mounted = false; - } - - toggleFavorite = () => { - const newFavorite = !this.state.favorite; - const apiMethod = newFavorite ? addFavorite : removeFavorite; - - return apiMethod(this.props.component).then(() => { - if (this.mounted) { - this.setState({ favorite: newFavorite }, () => { - if (this.props.handleFavorite) { - this.props.handleFavorite(this.props.component, newFavorite); - } - if (this.buttonNode) { - this.buttonNode.focus(); - } - }); - } - }); + return mutate( + { component, addToFavorites: newFavorite }, + { + onSuccess: () => { + setFavorite(newFavorite); + handleFavorite?.(component, newFavorite); + buttonRef.current?.focus(); + }, + }, + ); }; - render() { - const { className, componentName, qualifier } = this.props; - const { favorite } = this.state; + const actionName = favorite ? 'remove' : 'add'; + const overlay = componentName + ? translateWithParameters(`favorite.action.${qualifier}.${actionName}_x`, componentName) + : translate('favorite.action', qualifier, actionName); - const actionName = favorite ? 'remove' : 'add'; - const overlay = componentName - ? translateWithParameters(`favorite.action.${qualifier}.${actionName}_x`, componentName) - : translate('favorite.action', qualifier, actionName); + React.useEffect(() => { + setFavorite(favoriteP); + }, [favoriteP]); - return ( - (this.buttonNode = node)} - /> - ); - } + return ( + + ); } diff --git a/server/sonar-web/src/main/js/components/controls/__tests__/Favorite-test.tsx b/server/sonar-web/src/main/js/components/controls/__tests__/Favorite-test.tsx index 4a9146f2454..a1889a3374d 100644 --- a/server/sonar-web/src/main/js/components/controls/__tests__/Favorite-test.tsx +++ b/server/sonar-web/src/main/js/components/controls/__tests__/Favorite-test.tsx @@ -24,6 +24,7 @@ import { setImmediate } from 'timers'; import { ComponentQualifier } from '~sonar-aligned/types/component'; import { addFavorite, removeFavorite } from '../../../api/favorites'; import { renderComponent } from '../../../helpers/testReactTestingUtils'; +import { FCProps } from '../../../types/misc'; import Favorite from '../Favorite'; jest.mock('../../../api/favorites', () => ({ @@ -66,7 +67,7 @@ it('correctly calls handleFavorite if passed', async () => { expect(handleFavorite).toHaveBeenCalledWith('foo', true); }); -function renderFavorite(props: Partial = {}) { +function renderFavorite(props: Partial> = {}) { return renderComponent( , ); diff --git a/server/sonar-web/src/main/js/helpers/react-query.ts b/server/sonar-web/src/main/js/helpers/react-query.ts index 84f48815364..7b96eb75eff 100644 --- a/server/sonar-web/src/main/js/helpers/react-query.ts +++ b/server/sonar-web/src/main/js/helpers/react-query.ts @@ -42,3 +42,11 @@ export const getNextPageParam = (params: T) => export const getPreviousPageParam = (params: T) => params.page.pageIndex === 1 ? undefined : params.page.pageIndex - 1; + +export const getNextPagingParam = (params: T) => + params.paging.total <= params.paging.pageIndex * params.paging.pageSize + ? undefined + : params.paging.pageIndex + 1; + +export const getPreviousPagingParam = (params: T) => + params.paging.pageIndex === 1 ? undefined : params.paging.pageIndex - 1; diff --git a/server/sonar-web/src/main/js/queries/favorites.ts b/server/sonar-web/src/main/js/queries/favorites.ts new file mode 100644 index 00000000000..8fdc97eb9cc --- /dev/null +++ b/server/sonar-web/src/main/js/queries/favorites.ts @@ -0,0 +1,81 @@ +/* + * SonarQube + * Copyright (C) 2009-2024 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +import { InfiniteData, useMutation, useQueryClient } from '@tanstack/react-query'; +import { ComponentRaw } from '../api/components'; +import { addFavorite, removeFavorite } from '../api/favorites'; + +export function useToggleFavoriteMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ component, addToFavorites }: { addToFavorites: boolean; component: string }) => + addToFavorites ? addFavorite(component) : removeFavorite(component), + onSuccess: (_, { component, addToFavorites }) => { + // Update the list cache to reflect the new favorite status + queryClient.setQueriesData( + { queryKey: ['project', 'list'] }, + getProjectsFavoritesHandler(component, addToFavorites), + ); + queryClient.invalidateQueries({ + queryKey: ['project', 'list'], + refetchType: 'none', + }); + + // Silently update component details cache + queryClient.setQueryData( + ['project', 'details', component], + (oldData: { components: ComponentRaw[] }) => { + if (!oldData) { + return oldData; + } + return { + ...oldData, + components: [{ ...oldData.components[0], isFavorite: addToFavorites }], + }; + }, + ); + }, + }); +} + +function getProjectsFavoritesHandler(projectKey: string, addedToFavorites: boolean) { + return (oldData: InfiniteData<{ components: ComponentRaw[] }>) => { + if (!oldData) { + return oldData; + } + + return { + ...oldData, + pages: oldData.pages.map((page) => ({ + ...page, + components: page.components.map((component) => { + if (component.key === projectKey) { + return { + ...component, + isFavorite: addedToFavorites, + }; + } + + return component; + }), + })), + }; + }; +} diff --git a/server/sonar-web/src/main/js/queries/measures.ts b/server/sonar-web/src/main/js/queries/measures.ts index dc34baa0637..b27dc0743c7 100644 --- a/server/sonar-web/src/main/js/queries/measures.ts +++ b/server/sonar-web/src/main/js/queries/measures.ts @@ -22,9 +22,10 @@ import { infiniteQueryOptions, QueryClient, queryOptions, + useQueries, useQueryClient, } from '@tanstack/react-query'; -import { groupBy, isUndefined, omitBy } from 'lodash'; +import { chunk, groupBy, isUndefined, omitBy } from 'lodash'; import { BranchParameters } from '~sonar-aligned/types/branch-like'; import { getComponentTree } from '../api/components'; import { @@ -37,7 +38,8 @@ import { getNextPageParam, getPreviousPageParam } from '../helpers/react-query'; import { getBranchLikeQuery } from '../sonar-aligned/helpers/branch-like'; import { BranchLike } from '../types/branch-like'; import { Measure } from '../types/types'; -import { createInfiniteQueryHook, createQueryHook } from './common'; +import { createInfiniteQueryHook, createQueryHook, StaleTime } from './common'; +import { PROJECTS_PAGE_SIZE } from './projects'; export const invalidateMeasuresByComponentKey = ( componentKey: string, @@ -209,31 +211,39 @@ export const useComponentTreeQuery = createInfiniteQueryHook( }, ); -export const useMeasuresForProjectsQuery = createQueryHook( - ({ projectKeys, metricKeys }: { metricKeys: string[]; projectKeys: string[] }) => { - const queryClient = useQueryClient(); - - return queryOptions({ - queryKey: ['measures', 'list', 'projects', projectKeys, metricKeys], - queryFn: async () => { - const measures = await getMeasuresForProjects(projectKeys, metricKeys); - const measuresMapByProjectKey = groupBy(measures, 'component'); - projectKeys.forEach((projectKey) => { - const measuresForProject = measuresMapByProjectKey[projectKey] ?? []; - const measuresMapByMetricKey = groupBy(measuresForProject, 'metric'); - metricKeys.forEach((metricKey) => { - const measure = measuresMapByMetricKey[metricKey]?.[0] ?? null; - queryClient.setQueryData( - ['measures', 'details', projectKey, 'branchLike', {}, metricKey], - measure, - ); +export function useMeasuresForProjectsQuery({ + projectKeys, + metricKeys, +}: { + metricKeys: string[]; + projectKeys: string[]; +}) { + const queryClient = useQueryClient(); + return useQueries({ + queries: chunk(projectKeys, PROJECTS_PAGE_SIZE).map((projectsChunk) => + queryOptions({ + queryKey: ['measures', 'list', 'projects', projectsChunk, metricKeys], + staleTime: StaleTime.SHORT, + queryFn: async () => { + const measures = await getMeasuresForProjects(projectsChunk, metricKeys); + const measuresMapByProjectKey = groupBy(measures, 'component'); + projectsChunk.forEach((projectKey) => { + const measuresForProject = measuresMapByProjectKey[projectKey] ?? []; + const measuresMapByMetricKey = groupBy(measuresForProject, 'metric'); + metricKeys.forEach((metricKey) => { + const measure = measuresMapByMetricKey[metricKey]?.[0] ?? null; + queryClient.setQueryData( + ['measures', 'details', projectKey, 'branchLike', {}, metricKey], + measure, + ); + }); }); - }); - return measures; - }, - }); - }, -); + return measures; + }, + }), + ), + }); +} export const useMeasuresAndLeakQuery = createQueryHook( ({ diff --git a/server/sonar-web/src/main/js/queries/projects.ts b/server/sonar-web/src/main/js/queries/projects.ts index 136bacb48f2..44203dddb8f 100644 --- a/server/sonar-web/src/main/js/queries/projects.ts +++ b/server/sonar-web/src/main/js/queries/projects.ts @@ -18,16 +18,71 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ -import { queryOptions, useMutation, useQueryClient } from '@tanstack/react-query'; -import { searchProjects } from '../api/components'; +import { + infiniteQueryOptions, + queryOptions, + useMutation, + useQueryClient, +} from '@tanstack/react-query'; +import { getScannableProjects, searchProjects } from '../api/components'; import { deleteProject } from '../api/project-management'; -import { createQueryHook } from './common'; +import { Query } from '../apps/projects/query'; +import { convertToQueryData, defineFacets } from '../apps/projects/utils'; +import { getNextPagingParam } from '../helpers/react-query'; +import { createInfiniteQueryHook, createQueryHook, StaleTime } from './common'; import { invalidateMeasuresByComponentKey } from './measures'; +export const PROJECTS_PAGE_SIZE = 50; + +export const useProjectsQuery = createInfiniteQueryHook( + ({ + isFavorite, + query, + pageIndex = 1, + isStandardMode, + }: { + isFavorite: boolean; + isStandardMode: boolean; + pageIndex?: number; + query: Query; + }) => { + const queryClient = useQueryClient(); + const data = convertToQueryData(query, isFavorite, isStandardMode, { + ps: PROJECTS_PAGE_SIZE, + facets: defineFacets(query, isStandardMode).join(), + f: 'analysisDate,leakPeriodDate', + }); + + return infiniteQueryOptions({ + queryKey: ['project', 'list', data] as const, + queryFn: ({ pageParam: pageIndex }) => { + return searchProjects({ ...data, p: pageIndex }).then((response) => { + response.components.forEach((project) => { + queryClient.setQueryData(['project', 'details', project.key], project); + }); + return response; + }); + }, + staleTime: StaleTime.LONG, + getNextPageParam: getNextPagingParam, + getPreviousPageParam: getNextPagingParam, + initialPageParam: pageIndex, + }); + }, +); + export const useProjectQuery = createQueryHook((key: string) => { return queryOptions({ - queryKey: ['project', key], - queryFn: ({ queryKey: [, key] }) => searchProjects({ filter: `query=${key}` }), + queryKey: ['project', 'details', key], + queryFn: ({ queryKey: [_1, _2, key] }) => searchProjects({ filter: `query=${key}` }), + }); +}); + +export const useMyScannableProjectsQuery = createQueryHook(() => { + return queryOptions({ + queryKey: ['project', 'my-scannable'], + queryFn: () => getScannableProjects(), + staleTime: StaleTime.NEVER, }); }); diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index c9381b53d47..2a95acd3d55 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -1333,10 +1333,12 @@ projects.no_new_code_period.TRK=Project has no new code data yet. projects.no_new_code_period.APP=Application has no new code data yet. projects.new_code_period_x=New code: last {0} projects.configure_analysis=Configure analysis +projects.configure_analysis_for_x=Configure analysis for project {project} projects.last_analysis_on_x=Last analysis: {date} projects.search=Search by project name or key projects.perspective=Perspective projects.skip_to_filters=Skip to project filters +projects.x_projects_found={count} projects found projects.sort_by=Sort by projects.sort_ascending=Result sorted in ascending order projects.sort_descending=Result sorted in descending order -- 2.39.5