]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-22300 SONAR-23528 Fix 11y issues on Projects page & fetch measures by chunks master
authorstanislavh <stanislav.honcharov@sonarsource.com>
Tue, 19 Nov 2024 13:16:05 +0000 (14:16 +0100)
committersonartech <sonartech@sonarsource.com>
Thu, 21 Nov 2024 20:02:48 +0000 (20:02 +0000)
19 files changed:
server/sonar-web/src/main/js/apps/issues/sidebar/ListStyleFacet.tsx
server/sonar-web/src/main/js/apps/projects/components/AllProjects.tsx
server/sonar-web/src/main/js/apps/projects/components/EmptyFavoriteSearch.tsx
server/sonar-web/src/main/js/apps/projects/components/EmptyInstance.tsx
server/sonar-web/src/main/js/apps/projects/components/FavoriteProjectsContainer.tsx
server/sonar-web/src/main/js/apps/projects/components/NoFavoriteProjects.tsx
server/sonar-web/src/main/js/apps/projects/components/ProjectsList.tsx
server/sonar-web/src/main/js/apps/projects/components/__tests__/AllProjects-test.tsx
server/sonar-web/src/main/js/apps/projects/components/project-card/ProjectCard.tsx
server/sonar-web/src/main/js/apps/projects/components/project-card/__tests__/ProjectCard-test.tsx
server/sonar-web/src/main/js/apps/projects/utils.ts
server/sonar-web/src/main/js/components/common/EmptySearch.tsx
server/sonar-web/src/main/js/components/controls/Favorite.tsx
server/sonar-web/src/main/js/components/controls/__tests__/Favorite-test.tsx
server/sonar-web/src/main/js/helpers/react-query.ts
server/sonar-web/src/main/js/queries/favorites.ts [new file with mode: 0644]
server/sonar-web/src/main/js/queries/measures.ts
server/sonar-web/src/main/js/queries/projects.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 141a5683c2386919109cfc51ade6cfbb0d2be303..9f331a529f4cbfc2fef312d0506abbc17d46dff4 100644 (file)
@@ -500,7 +500,7 @@ export class ListStyleFacet<S> extends React.Component<Props<S>, State<S>> {
           <span className="it__search-navigator-facet-list">
             {this.renderSearch()}
 
-            <output aria-live="polite">
+            <output aria-live={query ? 'polite' : 'off'}>
               {showList ? this.renderList() : this.renderSearchResults()}
             </output>
 
index 0dbbf52e078c1ea74c9b6e0b26caf6247f352bea..653e43d27f1124a73f90a0f9a3f92fb76ea39730 100644 (file)
 
 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<Project, 'measures'>[];
-  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<Props, State> {
-  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<Props, State> {
       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 = () => (
     <SideBarStyle>
       <ScreenPositionHelper className="sw-z-filterbar">
         {({ top }) => (
@@ -250,15 +230,13 @@ export class AllProjects extends React.PureComponent<Props, State> {
               />
 
               <PageSidebar
-                applicationsEnabled={this.props.appState.qualifiers.includes(
-                  ComponentQualifier.Application,
-                )}
-                facets={this.state.facets}
-                loadSearchResultCount={this.loadSearchResultCount}
-                onClearAll={this.handleClearAll}
-                onQueryChange={this.updateLocationQuery}
-                query={this.state.query}
-                view={this.getView()}
+                applicationsEnabled={appState.qualifiers.includes(ComponentQualifier.Application)}
+                facets={facets}
+                loadSearchResultCount={loadSearchResultCount}
+                onClearAll={handleClearAll}
+                onQueryChange={updateLocationQuery}
+                query={parsedQuery}
+                view={queryView}
               />
             </div>
           </section>
@@ -267,122 +245,99 @@ export class AllProjects extends React.PureComponent<Props, State> {
     </SideBarStyle>
   );
 
-  renderHeader = () => (
+  const renderHeader = () => (
     <PageHeaderWrapper className="sw-w-full">
       <PageHeader
-        currentUser={this.props.currentUser}
-        onPerspectiveChange={this.handlePerspectiveChange}
-        onQueryChange={this.updateLocationQuery}
-        onSortChange={this.handleSortChange}
-        query={this.state.query}
-        selectedSort={this.getSort()}
-        total={this.state.total}
-        view={this.getView()}
+        currentUser={currentUser}
+        onPerspectiveChange={handlePerspectiveChange}
+        onQueryChange={updateLocationQuery}
+        onSortChange={handleSortChange}
+        query={parsedQuery}
+        selectedSort={querySort}
+        total={paging?.total}
+        view={queryView}
       />
     </PageHeaderWrapper>
   );
 
-  renderMain = () => {
-    if (this.state.loading && this.state.projects === undefined) {
-      return <Spinner />;
-    }
-
+  const renderMain = () => {
+    const isFiltered = hasFilterParams(parsedQuery);
     return (
       <div className="it__layout-page-main-inner it__projects-list sw-h-full">
-        {this.state.projects && (
+        <div aria-live="polite" aria-busy={isLoading}>
+          <Spinner isLoading={isLoading}>
+            {readyProjects.length === 0 && isFiltered && isFavorite && (
+              <EmptyFavoriteSearch query={parsedQuery} />
+            )}
+            {readyProjects.length === 0 && isFiltered && !isFavorite && <EmptySearch />}
+            {readyProjects.length === 0 && !isFiltered && isFavorite && <NoFavoriteProjects />}
+            {readyProjects.length === 0 && !isFiltered && !isFavorite && <EmptyInstance />}
+            {readyProjects.length > 0 && (
+              <span className="sw-sr-only">
+                {intl.formatMessage({ id: 'projects.x_projects_found' }, { count: paging?.total })}
+              </span>
+            )}
+          </Spinner>
+        </div>
+        {readyProjects.length > 0 && (
           <ProjectsList
-            cardType={this.getView()}
-            currentUser={this.props.currentUser}
-            handleFavorite={this.handleFavorite}
-            isFavorite={this.props.isFavorite}
-            isFiltered={hasFilterParams(this.state.query)}
-            loading={this.state.loading}
-            loadMore={this.fetchMoreProjects}
-            projects={this.state.projects}
-            query={this.state.query}
-            total={this.state.total}
+            cardType={queryView}
+            isFavorite={isFavorite}
+            isFiltered={hasFilterParams(parsedQuery)}
+            loading={isFetchingNextPage || measuresForLastChunkAreLoading}
+            loadMore={fetchNextPage}
+            measures={measures}
+            projects={readyProjects}
+            query={parsedQuery}
+            total={paging?.total}
           />
         )}
       </div>
     );
   };
 
-  render() {
-    return (
-      <StyledWrapper id="projects-page">
-        <Helmet defer={false} title={translate('projects.page')} />
-
-        <Heading as="h1" className="sw-sr-only">
-          {translate('projects.page')}
-        </Heading>
-
-        <LargeCenteredLayout>
-          <PageContentFontWrapper className="sw-flex sw-w-full sw-typo-lg">
-            {this.renderSide()}
-
-            <main className="sw-flex sw-flex-col sw-box-border sw-min-w-0 sw-pl-12 sw-pt-6 sw-flex-1">
-              <A11ySkipTarget anchor="projects_main" />
-
-              <Heading as="h2" className="sw-sr-only">
-                {translate('list_of_projects')}
-              </Heading>
+  return (
+    <StyledWrapper id="projects-page">
+      <Helmet defer={false} title={translate('projects.page')} />
 
-              {this.renderHeader()}
+      <Heading as="h1" className="sw-sr-only">
+        {translate('projects.page')}
+      </Heading>
 
-              {this.renderMain()}
-            </main>
-          </PageContentFontWrapper>
-        </LargeCenteredLayout>
-      </StyledWrapper>
-    );
-  }
-}
+      <LargeCenteredLayout>
+        <PageContentFontWrapper className="sw-flex sw-w-full sw-typo-lg">
+          {renderSide()}
 
-function getStorageOptions() {
-  const options: {
-    sort?: string;
-    view?: string;
-  } = {};
+          <main className="sw-flex sw-flex-col sw-box-border sw-min-w-0 sw-pl-12 sw-pt-6 sw-flex-1">
+            <A11ySkipTarget anchor="projects_main" />
 
-  if (get(LS_PROJECTS_SORT) !== null) {
-    options.sort = get(LS_PROJECTS_SORT) ?? undefined;
-  }
+            <Heading as="h2" className="sw-sr-only">
+              {translate('list_of_projects')}
+            </Heading>
 
-  if (get(LS_PROJECTS_VIEW) !== null) {
-    options.view = get(LS_PROJECTS_VIEW) ?? undefined;
-  }
+            {renderHeader()}
 
-  return options;
+            {renderMain()}
+          </main>
+        </PageContentFontWrapper>
+      </LargeCenteredLayout>
+    </StyledWrapper>
+  );
 }
 
-function AllProjectsWrapper(props: Readonly<Omit<Props, 'isStandardMode'>>) {
-  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 (
-    <Spinner isLoading={isLoading}>
-      <AllProjects {...props} isStandardMode={isStandardMode ?? false} />
-    </Spinner>
-  );
+    return <Component {...props} />;
+  };
 }
 
-export default withRouter(withCurrentUserContext(withAppStateContext(AllProjectsWrapper)));
+export default withRedirectWrapper(AllProjects);
 
 const StyledWrapper = styled.div`
   background-color: ${themeColor('backgroundPrimary')};
index e0cb4d660b0ae07927b2b631bef0cde36ced0ccf..23bad03f6a854a1e64813b45cfb8d545754561b7 100644 (file)
@@ -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 (
-    <div aria-live="assertive" className="sw-py-8 sw-text-center">
+    <div className="sw-flex sw-flex-col sw-items-center sw-py-8">
       <FishVisual />
-      <Highlight as="h3" className="sw-typo-lg-semibold sw-mt-6">
+      <Text isHighlighted size={TextSize.Large} className="sw-mt-6">
         {translate('no_results_search.favorites')}
-      </Highlight>
+      </Text>
       <div className="sw-my-4 sw-typo-default">
         <FormattedMessage
           defaultMessage={translate('no_results_search.favorites.2')}
           id="no_results_search.favorites.2"
           values={{
             url: (
-              <StandoutLink
+              <Link
                 to={{
                   pathname: '/projects',
                   search: queryToSearchString(query as Dict<string | undefined | number>),
                 }}
               >
                 {translate('all')}
-              </StandoutLink>
+              </Link>
             ),
           }}
         />
index 4ac18fdaca28d90403d0feaaa62aadf9ee90ed25..d1bfdda60cedec2bf4c2b7b5eee088164f04968f 100644 (file)
  * 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 (
-    <div className="sw-text-center sw-py-8">
+    <div className="sw-flex sw-flex-col sw-items-center sw-py-8">
       <FishVisual />
-      <Highlight as="h3" className="sw-typo-lg-semibold sw-mt-6">
+      <Text isHighlighted size={TextSize.Large} className="sw-mt-6">
         {showNewProjectButton
           ? translate('projects.no_projects.empty_instance.new_project')
           : translate('projects.no_projects.empty_instance')}
-      </Highlight>
+      </Text>
       {showNewProjectButton && (
-        <div>
+        <>
           <p className="sw-mt-2 sw-typo-default">
             {translate('projects.no_projects.empty_instance.how_to_add_projects')}
           </p>
@@ -59,10 +55,8 @@ export function EmptyInstance(props: EmptyInstanceProps) {
           >
             {translate('my_account.create_new.TRK')}
           </Button>
-        </div>
+        </>
       )}
     </div>
   );
 }
-
-export default withRouter(EmptyInstance);
index 35d025789cb996d6b29492cc72bb8e32eb3a0120..4be4f9bf7cabfc67cf91daa973912eb7824bcae9 100644 (file)
@@ -20,6 +20,6 @@
 
 import AllProjects from './AllProjects';
 
-export default function FavoriteProjectsContainer(props: any) {
-  return <AllProjects isFavorite {...props} />;
+export default function FavoriteProjectsContainer() {
+  return <AllProjects isFavorite />;
 }
index b197fab45c505825bea96c7a35c20840c77fc9c4..8e47c44a1507d61b872ba958a562e843fa57ccc4 100644 (file)
  * 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 (
-    <div className="sw-py-8 sw-text-center">
-      <Highlight as="h3" className="sw-mb-2 sw-typo-lg-semibold">
+    <div className="sw-flex sw-flex-col sw-items-center sw-py-8">
+      <Text isHighlighted size={TextSize.Large} className="sw-mb-2">
         {translate('projects.no_favorite_projects')}
-      </Highlight>
-
-      <div>
-        <p className="sw-mt-2 sw-typo-default">
-          {translate('projects.no_favorite_projects.engagement')}
-        </p>
-        <p className="sw-mt-6">
-          <StandoutLink className="sw-mt-6 sw-typo-semibold" to="/projects/all">
-            {translate('projects.explore_projects')}
-          </StandoutLink>
-        </p>
-      </div>
+      </Text>
+      <p className="sw-mt-2 sw-typo-default">
+        {translate('projects.no_favorite_projects.engagement')}
+      </p>
+      <p className="sw-mt-6">
+        <Link className="sw-mt-6 sw-typo-semibold" to="/projects/all">
+          {translate('projects.explore_projects')}
+        </Link>
+      </p>
     </div>
   );
 }
index b940df906c4d0a2be0124abe4ae09efb162bbe18..07bff689a379ab5351f575abf4743d4726f944b2 100644 (file)
  * 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<Project, 'measures'>[];
   query: Query;
   total?: number;
 }
 
 export default function ProjectsList(props: Readonly<Props>) {
-  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<Props>) {
       >
         <div className="sw-h-full" role="gridcell">
           <ProjectCard
-            currentUser={currentUser}
-            handleFavorite={handleFavorite}
             key={project.key}
             project={{ ...project, measures: componentMeasures }}
             type={cardType}
@@ -125,41 +97,32 @@ export default function ProjectsList(props: Readonly<Props>) {
     );
   };
 
-  if (projects.length === 0) {
-    if (isFiltered) {
-      return isFavorite ? <EmptyFavoriteSearch query={query} /> : <EmptySearch />;
-    }
-    return isFavorite ? <NoFavoriteProjects /> : <EmptyInstance currentUser={currentUser} />;
-  }
-
   return (
-    <Spinner isLoading={loading || measuresLoading}>
-      <AutoSizer>
-        {({ height, width }) => (
-          <List
-            aria-label={translate('project_plural')}
-            height={height}
-            overscanRowCount={2}
-            rowCount={projects.length + 1}
-            rowHeight={({ index }) => {
-              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}
-          />
-        )}
-      </AutoSizer>
-    </Spinner>
+    <AutoSizer>
+      {({ height, width }) => (
+        <List
+          aria-label={translate('project_plural')}
+          height={height}
+          overscanRowCount={2}
+          rowCount={projects.length + 1}
+          rowHeight={({ index }) => {
+            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}
+        />
+      )}
+    </AutoSizer>
   );
 }
index b677013b682d173e3db10a4a2352b6556a1c31fb..035a73a761b0ae7aad7114d60cbf9df562e463a5 100644 (file)
@@ -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 () => {
index 93f608d26cd9d21e8294d01b9989c02ca220afe4..8dd3f3d0e6d00f62a062cf5a61f48723e0ad7f96 100644 (file)
@@ -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 && (
-          <Link className="sw-ml-2 sw-typo-semibold" to={getProjectUrl(key)}>
+          <Link
+            aria-label={intl.formatMessage(
+              { id: 'projects.configure_analysis_for_x' },
+              { project: project.name },
+            )}
+            className="sw-ml-2 sw-typo-semibold"
+            to={getProjectUrl(key)}
+          >
             {translate('projects.configure_analysis')}
           </Link>
         )}
@@ -275,7 +277,7 @@ function renderSecondLine(
 }
 
 export default function ProjectCard(props: Readonly<Props>) {
-  const { currentUser, type, project } = props;
+  const { type, project } = props;
   const isNewCode = type === 'leak';
 
   return (
@@ -285,11 +287,11 @@ export default function ProjectCard(props: Readonly<Props>) {
       )}
       data-key={project.key}
     >
-      {renderFirstLine(project, props.handleFavorite, isNewCode)}
+      {renderFirstLine(project, isNewCode)}
 
       <SubnavigationFlowSeparator className="sw-my-3" />
 
-      {renderSecondLine(currentUser, project, isNewCode)}
+      <SecondLine project={project} isNewCode={isNewCode} />
     </ProjectCardWrapper>
   );
 }
index 93ae42ebbd2cd288bcbb5e008037c11c80c1c500..bf6153357c7c02ced1f3b8f0165fc251b3071569 100644 (file)
@@ -355,7 +355,5 @@ describe('upgrade scenario (awaiting scan)', () => {
 });
 
 function renderProjectCard(project: Project, user: CurrentUser = USER_LOGGED_OUT, type?: string) {
-  renderComponent(
-    <ProjectCard currentUser={user} handleFavorite={jest.fn()} project={project} type={type} />,
-  );
+  renderComponent(<ProjectCard project={project} type={type} />, '', { currentUser: user });
 }
index 3f0630b43fa27d1dec7ada0194175df8f89f2686..89fa2260e100849d04f211268051723db43d6d54 100644 (file)
@@ -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<string | undefined> = {
   new_maintainability: 'new_software_quality_maintainability_rating',
 };
 
-function getFacetsMap(facets: Facet[], isStandardMode: boolean) {
+export function getFacetsMap(facets: Facet[], isStandardMode: boolean) {
   const map: Dict<Dict<number>> = {};
 
   facets.forEach((facet) => {
index 72c579af28b2647a77f088d5b248f529985267bc..2837dc86af4cc951c4cbe9508557d4b22be535b0 100644 (file)
  * 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 (
-    <div aria-live="assertive" className="sw-text-center sw-py-8">
+    <div className="sw-flex sw-flex-col sw-items-center sw-py-8">
       <FishVisual />
-      <Highlight as="h3" className="sw-typo-lg-semibold sw-mt-6">
+      <Text isHighlighted size={TextSize.Large} className="sw-mt-6">
         {translate('no_results_search')}
-      </Highlight>
+      </Text>
       <p className="sw-typo-default sw-mt-2">{translate('no_results_search.2')}</p>
     </div>
   );
index 6564a6714daaeed4a22883659241a6812ef68f08..7df3737daa8d2f813d456779f472b81126142375 100644 (file)
@@ -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<Props, State> {
-  mounted = false;
-  buttonNode?: HTMLElement | null;
-
-  constructor(props: Props) {
-    super(props);
-
-    this.state = {
-      favorite: props.favorite,
-    };
-  }
+export default function Favorite(props: Readonly<Props>) {
+  const {
+    className,
+    componentName,
+    qualifier,
+    favorite: favoriteP,
+    component,
+    handleFavorite,
+  } = props;
+  const buttonRef = React.useRef<HTMLButtonElement>(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 (
-      <FavoriteButton
-        className={className}
-        overlay={overlay}
-        toggleFavorite={this.toggleFavorite}
-        tooltip={Tooltip}
-        favorite={favorite}
-        innerRef={(node: HTMLElement | null) => (this.buttonNode = node)}
-      />
-    );
-  }
+  return (
+    <FavoriteButton
+      className={className}
+      overlay={overlay}
+      toggleFavorite={toggleFavorite}
+      tooltip={Tooltip}
+      favorite={favorite}
+      innerRef={buttonRef}
+    />
+  );
 }
index 4a9146f24541911ed91ac097e990245312bae7a3..a1889a3374d1860b342a80a8016a4cf725e74927 100644 (file)
@@ -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<Favorite['props']> = {}) {
+function renderFavorite(props: Partial<FCProps<typeof Favorite>> = {}) {
   return renderComponent(
     <Favorite component="foo" favorite qualifier={ComponentQualifier.Project} {...props} />,
   );
index 84f4881536429aa978f914d2ebc56b7396a0672e..7b96eb75eff51425d34f7932166a00b261a437ae 100644 (file)
@@ -42,3 +42,11 @@ export const getNextPageParam = <T extends { page: Paging }>(params: T) =>
 
 export const getPreviousPageParam = <T extends { page: Paging }>(params: T) =>
   params.page.pageIndex === 1 ? undefined : params.page.pageIndex - 1;
+
+export const getNextPagingParam = <T extends { paging: Paging }>(params: T) =>
+  params.paging.total <= params.paging.pageIndex * params.paging.pageSize
+    ? undefined
+    : params.paging.pageIndex + 1;
+
+export const getPreviousPagingParam = <T extends { paging: Paging }>(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 (file)
index 0000000..8fdc97e
--- /dev/null
@@ -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;
+        }),
+      })),
+    };
+  };
+}
index dc34baa06371b9e8eb7a9f637421a71593f18f05..b27dc0743c7d6843ab9c5a97f25eefb7ff99f310 100644 (file)
@@ -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<Measure>(
-              ['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<Measure>(
+                ['measures', 'details', projectKey, 'branchLike', {}, metricKey],
+                measure,
+              );
+            });
           });
-        });
-        return measures;
-      },
-    });
-  },
-);
+          return measures;
+        },
+      }),
+    ),
+  });
+}
 
 export const useMeasuresAndLeakQuery = createQueryHook(
   ({
index 136bacb48f26afe49e67205a376da6e6e0ed43bb..44203dddb8fde4d9190882c3cf887327309692c2 100644 (file)
  * 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,
   });
 });
 
index c9381b53d47595fa1b0c1d67f39cfeef1f0f580e..2a95acd3d556feffb28717d4b911dd1121a25fb8 100644 (file)
@@ -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