<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>
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,
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;
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 }) => (
/>
<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>
</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')};
* 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';
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>
),
}}
/>
* 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>
>
{translate('my_account.create_new.TRK')}
</Button>
- </div>
+ </>
)}
</div>
);
}
-
-export default withRouter(EmptyInstance);
import AllProjects from './AllProjects';
-export default function FavoriteProjectsContainer(props: any) {
- return <AllProjects isFavorite {...props} />;
+export default function FavoriteProjectsContainer() {
+ return <AllProjects isFavorite />;
}
* 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>
);
}
* 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;
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) {
>
<div className="sw-h-full" role="gridcell">
<ProjectCard
- currentUser={currentUser}
- handleFavorite={handleFavorite}
key={project.key}
project={{ ...project, measures: componentMeasures }}
type={cardType}
);
};
- 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>
);
}
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',
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 () => {
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,
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';
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,
component={key}
componentName={name}
favorite={isFavorite}
- handleFavorite={handleFavorite}
qualifier={qualifier}
/>
)}
);
}
-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 (
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>
)}
}
export default function ProjectCard(props: Readonly<Props>) {
- const { currentUser, type, project } = props;
+ const { type, project } = props;
const isNewCode = type === 'leak';
return (
)}
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>
);
}
});
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 });
}
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;
}
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) => {
* 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>
);
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 {
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}
+ />
+ );
}
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', () => ({
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} />,
);
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;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2024 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+
+import { 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;
+ }),
+ })),
+ };
+ };
+}
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 {
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,
},
);
-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(
({
* 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,
});
});
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