Browse Source

SONAR-20223 Apply new spinner for pages already migrated to MIUI

tags/10.4.0.87286
Viktor Vorona 5 months ago
parent
commit
d66ad5b063
23 changed files with 20 additions and 1062 deletions
  1. 1
    2
      server/sonar-web/src/main/js/apps/background-tasks/components/BackgroundTasksApp.tsx
  2. 1
    2
      server/sonar-web/src/main/js/apps/code/components/Search.tsx
  3. 1
    1
      server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
  4. 1
    1
      server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx
  5. 5
    4
      server/sonar-web/src/main/js/apps/permission-templates/components/Header.tsx
  6. 2
    3
      server/sonar-web/src/main/js/apps/projectInformation/notifications/ProjectNotifications.tsx
  7. 1
    1
      server/sonar-web/src/main/js/apps/projects/components/AllProjects.tsx
  8. 1
    1
      server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotOpenInIdeButton.tsx
  9. 1
    1
      server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSidebarHeader.tsx
  10. 1
    2
      server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx
  11. 1
    1
      server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx
  12. 1
    1
      server/sonar-web/src/main/js/apps/settings/components/NewCodeDefinition.tsx
  13. 2
    10
      server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx
  14. 1
    1
      server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx
  15. 0
    37
      server/sonar-web/src/main/js/components/facet/FacetBox.tsx
  16. 0
    137
      server/sonar-web/src/main/js/components/facet/FacetHeader.tsx
  17. 0
    78
      server/sonar-web/src/main/js/components/facet/FacetItem.tsx
  18. 0
    41
      server/sonar-web/src/main/js/components/facet/FacetItemsList.tsx
  19. 0
    448
      server/sonar-web/src/main/js/components/facet/ListStyleFacet.tsx
  20. 0
    77
      server/sonar-web/src/main/js/components/facet/ListStyleFacetFooter.tsx
  21. 0
    35
      server/sonar-web/src/main/js/components/facet/MultipleSelectionHint.css
  22. 0
    52
      server/sonar-web/src/main/js/components/facet/MultipleSelectionHint.tsx
  23. 0
    126
      server/sonar-web/src/main/js/components/facet/__tests__/Facet-it.tsx

+ 1
- 2
server/sonar-web/src/main/js/apps/background-tasks/components/BackgroundTasksApp.tsx View File

@@ -17,7 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { LargeCenteredLayout, PageContentFontWrapper } from 'design-system';
import { LargeCenteredLayout, PageContentFontWrapper, Spinner } from 'design-system';
import { debounce } from 'lodash';
import * as React from 'react';
import { Helmet } from 'react-helmet-async';
@@ -32,7 +32,6 @@ import withComponentContext from '../../../app/components/componentContext/withC
import ListFooter from '../../../components/controls/ListFooter';
import Suggestions from '../../../components/embed-docs-modal/Suggestions';
import { Location, Router, withRouter } from '../../../components/hoc/withRouter';
import Spinner from '../../../components/ui/Spinner';
import { toShortISO8601String } from '../../../helpers/dates';
import { translate } from '../../../helpers/l10n';
import { parseAsDate } from '../../../helpers/query';

+ 1
- 2
server/sonar-web/src/main/js/apps/code/components/Search.tsx View File

@@ -18,12 +18,11 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import classNames from 'classnames';
import { InputSearch, ToggleButton } from 'design-system';
import { InputSearch, Spinner, ToggleButton } from 'design-system';
import { isEmpty, omit } from 'lodash';
import * as React from 'react';
import { getTree } from '../../../api/components';
import { Location, Router, withRouter } from '../../../components/hoc/withRouter';
import Spinner from '../../../components/ui/Spinner';
import { getBranchLikeQuery } from '../../../helpers/branch-like';
import { KeyboardKeys } from '../../../helpers/keycodes';
import { translate } from '../../../helpers/l10n';

+ 1
- 1
server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx View File

@@ -26,6 +26,7 @@ import {
LAYOUT_FOOTER_HEIGHT,
LargeCenteredLayout,
PageContentFontWrapper,
Spinner,
ToggleButton,
themeBorder,
themeColor,
@@ -51,7 +52,6 @@ import { Location, Router, withRouter } from '../../../components/hoc/withRouter
import IssueTabViewer from '../../../components/rules/IssueTabViewer';
import '../../../components/search-navigator.css';
import { DEFAULT_ISSUES_QUERY } from '../../../components/shared/utils';
import Spinner from '../../../components/ui/Spinner';
import {
fillBranchLike,
getBranchLikeQuery,

+ 1
- 1
server/sonar-web/src/main/js/apps/issues/crossComponentSourceViewer/CrossComponentSourceViewer.tsx View File

@@ -17,6 +17,7 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { Spinner } from 'design-system';
import { findLastIndex, keyBy } from 'lodash';
import * as React from 'react';
import { getComponentForSourceViewer, getDuplications, getSources } from '../../../api/components';
@@ -33,7 +34,6 @@ import {
issuesByComponentAndLine,
} from '../../../components/SourceViewer/helpers/indexing';
import { Alert } from '../../../components/ui/Alert';
import Spinner from '../../../components/ui/Spinner';
import { WorkspaceContext } from '../../../components/workspace/context';
import { getBranchLikeQuery } from '../../../helpers/branch-like';
import { throwGlobalError } from '../../../helpers/error';

+ 5
- 4
server/sonar-web/src/main/js/apps/permission-templates/components/Header.tsx View File

@@ -17,11 +17,10 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { ButtonPrimary, FlagMessage, Title } from 'design-system';
import { ButtonPrimary, FlagMessage, Spinner, Title } from 'design-system';
import React, { useState } from 'react';
import { createPermissionTemplate } from '../../../api/permissions';
import { Router, withRouter } from '../../../components/hoc/withRouter';
import Spinner from '../../../components/ui/Spinner';
import { throwGlobalError } from '../../../helpers/error';
import { translate } from '../../../helpers/l10n';
import { useGithubProvisioningEnabledQuery } from '../../../queries/identity-provider/github';
@@ -60,8 +59,10 @@ function Header(props: Props) {
<header>
<div id="project-permissions-header">
<div className="sw-flex sw-justify-between">
<Title>{translate('permission_templates.page')}</Title>
<Spinner loading={!ready} />
<div className="sw-flex sw-gap-3">
<Title>{translate('permission_templates.page')}</Title>
<Spinner className="sw-mt-2" loading={!ready} />
</div>

<ButtonPrimary onClick={() => setCreateModal(true)}>{translate('create')}</ButtonPrimary>
</div>

+ 2
- 3
server/sonar-web/src/main/js/apps/projectInformation/notifications/ProjectNotifications.tsx View File

@@ -17,13 +17,12 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import { Checkbox, FlagMessage, SubTitle } from 'design-system';
import { Checkbox, FlagMessage, Spinner, SubTitle } from 'design-system';
import * as React from 'react';
import {
WithNotificationsProps,
withNotifications,
} from '../../../components/hoc/withNotifications';
import Spinner from '../../../components/ui/Spinner';
import { hasMessage, translate, translateWithParameters } from '../../../helpers/l10n';
import { NotificationProjectType } from '../../../types/notifications';
import { Component } from '../../../types/types';
@@ -79,7 +78,7 @@ export function ProjectNotifications(props: WithNotificationsProps & Props) {
{translate('notification.dispatcher.information')}
</FlagMessage>

<Spinner loading={loading}>
<Spinner className="sw-mt-6" loading={loading}>
<h3 id="notifications-update-title" className="sw-mt-6">
{translate('project_information.project_notifications.title')}
</h3>

+ 1
- 1
server/sonar-web/src/main/js/apps/projects/components/AllProjects.tsx View File

@@ -21,6 +21,7 @@ import styled from '@emotion/styled';
import {
LargeCenteredLayout,
PageContentFontWrapper,
Spinner,
themeBorder,
themeColor,
} from 'design-system';
@@ -36,7 +37,6 @@ import ScreenPositionHelper from '../../../components/common/ScreenPositionHelpe
import Suggestions from '../../../components/embed-docs-modal/Suggestions';
import { Location, Router, withRouter } from '../../../components/hoc/withRouter';
import '../../../components/search-navigator.css';
import Spinner from '../../../components/ui/Spinner';
import handleRequiredAuthentication from '../../../helpers/handleRequiredAuthentication';
import { translate } from '../../../helpers/l10n';
import { addSideBarClass, removeSideBarClass } from '../../../helpers/pages';

+ 1
- 1
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotOpenInIdeButton.tsx View File

@@ -24,9 +24,9 @@ import {
ItemButton,
PopupPlacement,
PopupZLevel,
Spinner,
} from 'design-system';
import * as React from 'react';
import Spinner from '../../../components/ui/Spinner';
import { addGlobalErrorMessage, addGlobalSuccessMessage } from '../../../helpers/globalMessages';
import { translate } from '../../../helpers/l10n';
import { openHotspot, probeSonarLintServers } from '../../../helpers/sonarlint';

+ 1
- 1
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSidebarHeader.tsx View File

@@ -28,6 +28,7 @@ import {
ItemDivider,
ItemHeader,
PopupZLevel,
Spinner,
} from 'design-system';
import * as React from 'react';
import withComponentContext from '../../../app/components/componentContext/withComponentContext';
@@ -35,7 +36,6 @@ import withCurrentUserContext from '../../../app/components/current-user/withCur
import HelpTooltip from '../../../components/controls/HelpTooltip';
import Tooltip from '../../../components/controls/Tooltip';
import Measure from '../../../components/measure/Measure';
import Spinner from '../../../components/ui/Spinner';
import { PopupPlacement } from '../../../components/ui/popups';
import { isBranch } from '../../../helpers/branch-like';
import { translate } from '../../../helpers/l10n';

+ 1
- 2
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotSnippetContainerRenderer.tsx View File

@@ -19,9 +19,8 @@
*/
import { withTheme } from '@emotion/react';
import styled from '@emotion/styled';
import { themeColor } from 'design-system';
import { Spinner, themeColor } from 'design-system';
import * as React from 'react';
import Spinner from '../../../components/ui/Spinner';
import { translate } from '../../../helpers/l10n';
import { Hotspot } from '../../../types/security-hotspots';
import {

+ 1
- 1
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotViewerRenderer.tsx View File

@@ -19,13 +19,13 @@
*/
import * as React from 'react';
import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext';
import Spinner from '../../../components/ui/Spinner';
import { fillBranchLike } from '../../../helpers/branch-like';
import { Standards } from '../../../types/security';
import { Hotspot, HotspotStatusOption } from '../../../types/security-hotspots';
import { Component } from '../../../types/types';
import { HotspotHeader } from './HotspotHeader';

import { Spinner } from 'design-system';
import { CurrentUser } from '../../../types/users';
import { RuleDescriptionSection } from '../../coding-rules/rule';
import HotspotReviewHistoryAndComments from './HotspotReviewHistoryAndComments';

+ 1
- 1
server/sonar-web/src/main/js/apps/settings/components/NewCodeDefinition.tsx View File

@@ -18,6 +18,7 @@
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import classNames from 'classnames';
import { Spinner } from 'design-system';
import React, { useCallback, useEffect } from 'react';
import { FormattedMessage } from 'react-intl';
import DocLink from '../../../components/common/DocLink';
@@ -25,7 +26,6 @@ import { ResetButtonLink, SubmitButton } from '../../../components/controls/butt
import NewCodeDefinitionDaysOption from '../../../components/new-code-definition/NewCodeDefinitionDaysOption';
import NewCodeDefinitionPreviousVersionOption from '../../../components/new-code-definition/NewCodeDefinitionPreviousVersionOption';
import { NewCodeDefinitionLevels } from '../../../components/new-code-definition/utils';
import Spinner from '../../../components/ui/Spinner';
import { translate } from '../../../helpers/l10n';
import {
getNumberOfDaysDefaultValue,

+ 2
- 10
server/sonar-web/src/main/js/apps/users/components/TokensForm.tsx View File

@@ -24,6 +24,7 @@ import {
GreySeparator,
InputField,
InputSelect,
Spinner,
Table,
TableRow,
} from 'design-system';
@@ -32,7 +33,6 @@ import * as React from 'react';
import { getScannableProjects } from '../../../api/components';
import withCurrentUserContext from '../../../app/components/current-user/withCurrentUserContext';
import { LabelValueSelectOption } from '../../../components/controls/Select';
import Spinner from '../../../components/ui/Spinner';
import { translate } from '../../../helpers/l10n';
import {
EXPIRATION_OPTIONS,
@@ -181,14 +181,6 @@ export function TokensForm(props: Readonly<Props>) {
setNewTokenExpiration(newTokenExpiration?.value as TokenExpiration);
};

const customSpinner = (
<tr>
<td>
<i className="spinner" />
</td>
</tr>
);

const tableHeader = (
<TableRow>
<ContentCell>{translate('name')}</ContentCell>
@@ -299,7 +291,7 @@ export function TokensForm(props: Readonly<Props>) {
header={tableHeader}
noHeaderTopBorder
>
<Spinner customSpinner={customSpinner} loading={!!loading}>
<Spinner loading={loading}>
{tokens && tokens.length <= 0 ? (
<TableRow>
<ContentCell className="sw-body-lg" colSpan={7}>

+ 1
- 1
server/sonar-web/src/main/js/apps/users/components/TokensFormItem.tsx View File

@@ -24,6 +24,7 @@ import {
ContentCell,
DangerButtonSecondary,
FlagWarningIcon,
Spinner,
TableRow,
themeColor,
} from 'design-system';
@@ -32,7 +33,6 @@ import { FormattedMessage } from 'react-intl';
import ConfirmButton from '../../../components/controls/ConfirmButton';
import DateFormatter from '../../../components/intl/DateFormatter';
import DateFromNow from '../../../components/intl/DateFromNow';
import Spinner from '../../../components/ui/Spinner';
import { translate, translateWithParameters } from '../../../helpers/l10n';
import { useRevokeTokenMutation } from '../../../queries/users';
import { UserToken } from '../../../types/token';

+ 0
- 37
server/sonar-web/src/main/js/components/facet/FacetBox.tsx View File

@@ -1,37 +0,0 @@
/*
* 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 classNames from 'classnames';
import * as React from 'react';

export interface FacetBoxProps {
className?: string;
children: React.ReactNode;
property: string;
}

export default function FacetBox(props: FacetBoxProps) {
const { children, className, property } = props;

return (
<div className={classNames('search-navigator-facet-box', className)} data-property={property}>
{children}
</div>
);
}

+ 0
- 137
server/sonar-web/src/main/js/components/facet/FacetHeader.tsx View File

@@ -1,137 +0,0 @@
/*
* 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 classNames from 'classnames';
import * as React from 'react';
import HelpTooltip from '../../components/controls/HelpTooltip';
import { Button, ButtonLink } from '../../components/controls/buttons';
import OpenCloseIcon from '../../components/icons/OpenCloseIcon';
import { translate, translateWithParameters } from '../../helpers/l10n';
import Tooltip from '../controls/Tooltip';
import Spinner from '../ui/Spinner';

interface Props {
children?: React.ReactNode;
fetching?: boolean;
helper?: string;
disabled?: boolean;
disabledHelper?: string;
name: string;
id: string;
onClear?: () => void;
onClick?: () => void;
open: boolean;
values?: string[];
}

export default class FacetHeader extends React.PureComponent<Props> {
handleClick = (event: React.SyntheticEvent<HTMLButtonElement>) => {
event.preventDefault();
event.nativeEvent.preventDefault();
if (this.props.onClick) {
this.props.onClick();
}
};

renderHelper() {
if (!this.props.helper) {
return null;
}
return <HelpTooltip className="spacer-left" overlay={this.props.helper} />;
}

renderValueIndicator() {
const { values } = this.props;
if (!values || !values.length) {
return null;
}
const value =
values.length === 1 ? values[0] : translateWithParameters('x_selected', values.length);
return (
<span className="badge text-ellipsis" title={value}>
{value}
</span>
);
}

render() {
const { disabled, values, disabledHelper, name, open, children, fetching, id } = this.props;
const showClearButton = values != null && values.length > 0 && this.props.onClear != null;
const header = disabled ? (
<Tooltip overlay={disabledHelper}>
<ButtonLink className="disabled" aria-disabled aria-label={`${name}, ${disabledHelper}`}>
{name}
</ButtonLink>
</Tooltip>
) : (
name
);
return (
<div
className={classNames('search-navigator-facet-header-wrapper display-flex-center', {
'expandable-header': this.props.onClick,
})}
>
{this.props.onClick ? (
<span className="search-navigator-facet-header display-flex-center">
<button
className="button-link display-flex-center"
type="button"
onClick={this.handleClick}
aria-expanded={open}
tabIndex={0}
id={id}
>
<OpenCloseIcon className="little-spacer-right" open={open} />
{header}
</button>
{this.renderHelper()}
</span>
) : (
<span className="search-navigator-facet-header display-flex-center" id={id}>
{header}
{this.renderHelper()}
</span>
)}

{children}

<span className="search-navigator-facet-header-value spacer-left spacer-right ">
{this.renderValueIndicator()}
</span>

{fetching && (
<span className="little-spacer-right">
<Spinner />
</span>
)}

{showClearButton && (
<Button
className="search-navigator-facet-header-button button-small button-red"
aria-label={translateWithParameters('clear_x_filter', name)}
onClick={this.props.onClear}
>
{translate('clear')}
</Button>
)}
</div>
);
}
}

+ 0
- 78
server/sonar-web/src/main/js/components/facet/FacetItem.tsx View File

@@ -1,78 +0,0 @@
/*
* 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 classNames from 'classnames';
import * as React from 'react';

export interface Props {
active?: boolean;
className?: string;
halfWidth?: boolean;
name: React.ReactNode;
onClick: (x: string, multiple?: boolean) => void;
stat?: React.ReactNode;
/** Textual version of `name` */
tooltip?: string;
value: string;
}

export default class FacetItem extends React.PureComponent<Props> {
static defaultProps = {
halfWidth: false,
};

handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
event.preventDefault();
this.props.onClick(this.props.value, event.ctrlKey || event.metaKey);
};

renderValue() {
if (this.props.stat == null) {
return null;
}

return <span className="facet-stat">{this.props.stat}</span>;
}

render() {
const { name, halfWidth, active, value, tooltip } = this.props;

const className = classNames('search-navigator-facet button-link', this.props.className, {
active,
});

return (
<span role="listitem" className={classNames({ 'search-navigator-facet-half': halfWidth })}>
<button
aria-checked={active}
className={className}
data-facet={value}
onClick={this.handleClick}
tabIndex={0}
title={tooltip}
role="checkbox"
type="button"
>
<span className="facet-name">{name}</span>
{this.renderValue()}
</button>
</span>
);
}
}

+ 0
- 41
server/sonar-web/src/main/js/components/facet/FacetItemsList.tsx View File

@@ -1,41 +0,0 @@
/*
* 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 * as React from 'react';

export type FacetItemsListProps =
| {
children?: React.ReactNode;
labelledby: string;
label?: never;
}
| {
children?: React.ReactNode;
labelledby?: never;
label: string;
};

export default function FacetItemsList({ children, labelledby, label }: FacetItemsListProps) {
const props = labelledby ? { 'aria-labelledby': labelledby } : { 'aria-label': label };
return (
<div className="search-navigator-facet-list" role="list" {...props}>
{children}
</div>
);
}

+ 0
- 448
server/sonar-web/src/main/js/components/facet/ListStyleFacet.tsx View File

@@ -1,448 +0,0 @@
/*
* 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 classNames from 'classnames';
import { sortBy, without } from 'lodash';
import * as React from 'react';
import ListFooter from '../../components/controls/ListFooter';
import SearchBox from '../../components/controls/SearchBox';
import { Alert } from '../../components/ui/Alert';
import { translate } from '../../helpers/l10n';
import { formatMeasure } from '../../helpers/measures';
import { queriesEqual } from '../../helpers/query';
import { Dict, Paging, RawQuery } from '../../types/types';
import FacetBox from './FacetBox';
import FacetHeader from './FacetHeader';
import FacetItem from './FacetItem';
import FacetItemsList from './FacetItemsList';
import ListStyleFacetFooter from './ListStyleFacetFooter';
import MultipleSelectionHint from './MultipleSelectionHint';

interface SearchResponse<S> {
maxResults?: boolean;
results: S[];
paging?: Paging;
}

export interface Props<S> {
className?: string;
disabled?: boolean;
disabledHelper?: string;
facetHeader: string;
fetching: boolean;
getFacetItemText: (item: string) => string;
getSearchResultKey: (result: S) => string;
getSearchResultText: (result: S) => string;
loadSearchResultCount?: (result: S[]) => Promise<Dict<number>>;
maxInitialItems: number;
maxItems: number;
minSearchLength: number;
onChange: (changes: Dict<string | string[]>) => void;
onClear?: () => void;
onItemClick?: (itemValue: string, multiple: boolean) => void;
onSearch: (query: string, page?: number) => Promise<SearchResponse<S>>;
onToggle: (property: string) => void;
open: boolean;
property: string;
query?: RawQuery;
renderFacetItem: (item: string) => React.ReactNode;
renderSearchResult: (result: S, query: string) => React.ReactNode;
searchPlaceholder: string;
getSortedItems?: () => string[];
stats: Dict<number> | undefined;
values: string[];
showMoreAriaLabel?: string;
showLessAriaLabel?: string;
}

interface State<S> {
autoFocus: boolean;
query: string;
searching: boolean;
searchMaxResults?: boolean;
searchPaging?: Paging;
searchResults?: S[];
searchResultsCounts: Dict<number>;
showFullList: boolean;
}

export default class ListStyleFacet<S> extends React.Component<Props<S>, State<S>> {
mounted = false;

static defaultProps = {
maxInitialItems: 15,
maxItems: 100,
minSearchLength: 2,
};

state: State<S> = {
autoFocus: false,
query: '',
searching: false,
searchResultsCounts: {},
showFullList: false,
};

componentDidMount() {
this.mounted = true;
}

componentDidUpdate(prevProps: Props<S>) {
if (!prevProps.open && this.props.open) {
// focus search field *only* if it was manually open
this.setState({ autoFocus: true });
} else if (
(prevProps.open && !this.props.open) ||
!queriesEqual(prevProps.query || {}, this.props.query || {})
) {
// reset state when closing the facet, or when query changes
this.setState({
query: '',
searchMaxResults: undefined,
searchResults: undefined,
searching: false,
searchResultsCounts: {},
showFullList: false,
});
} else if (
prevProps.stats !== this.props.stats &&
Object.keys(this.props.stats || {}).length < this.props.maxInitialItems
) {
// show limited list if `stats` changed and there are less than 15 items
this.setState({ showFullList: false });
}
}

componentWillUnmount() {
this.mounted = false;
}

handleItemClick = (itemValue: string, multiple: boolean) => {
if (this.props.onItemClick) {
this.props.onItemClick(itemValue, multiple);
} else {
const { values } = this.props;
if (multiple) {
const newValue = sortBy(
values.includes(itemValue) ? without(values, itemValue) : [...values, itemValue],
);
this.props.onChange({ [this.props.property]: newValue });
} else {
this.props.onChange({
[this.props.property]: values.includes(itemValue) && values.length < 2 ? [] : [itemValue],
});
}
}
};

handleHeaderClick = () => {
this.props.onToggle(this.props.property);
};

handleClear = () => {
if (this.props.onClear) {
this.props.onClear();
} else {
this.props.onChange({ [this.props.property]: [] });
}
};

stopSearching = () => {
if (this.mounted) {
this.setState({ searching: false });
}
};

search = (query: string) => {
if (query.length >= this.props.minSearchLength) {
this.setState({ query, searching: true });
this.props
.onSearch(query)
.then(this.loadCountsForSearchResults)
.then(({ maxResults, paging, results, stats }) => {
if (this.mounted) {
this.setState((state) => ({
searching: false,
searchMaxResults: maxResults,
searchResults: results,
searchPaging: paging,
searchResultsCounts: { ...state.searchResultsCounts, ...stats },
}));
}
})
.catch(this.stopSearching);
} else {
this.setState({ query, searching: false, searchResults: [] });
}
};

searchMore = () => {
const { query, searchPaging, searchResults } = this.state;
if (query && searchResults && searchPaging) {
this.setState({ searching: true });
this.props
.onSearch(query, searchPaging.pageIndex + 1)
.then(this.loadCountsForSearchResults)
.then(({ paging, results, stats }) => {
if (this.mounted) {
this.setState((state) => ({
searching: false,
searchResults: [...searchResults, ...results],
searchPaging: paging,
searchResultsCounts: { ...state.searchResultsCounts, ...stats },
}));
}
})
.catch(this.stopSearching);
}
};

loadCountsForSearchResults = (response: SearchResponse<S>) => {
const { loadSearchResultCount = () => Promise.resolve({}) } = this.props;
const resultsToLoad = response.results.filter((result) => {
const key = this.props.getSearchResultKey(result);
return this.getStat(key) === undefined && this.state.searchResultsCounts[key] === undefined;
});
if (resultsToLoad.length > 0) {
return loadSearchResultCount(resultsToLoad).then((stats) => ({ ...response, stats }));
} else {
return { ...response, stats: {} };
}
};

getStat(item: string) {
const { stats } = this.props;
return stats && stats[item] !== undefined ? stats && stats[item] : undefined;
}

getFacetHeaderId = (property: string) => {
return `facet_${property}`;
};

showFullList = () => {
this.setState({ showFullList: true });
};

hideFullList = () => {
this.setState({ showFullList: false });
};

renderList() {
const {
maxInitialItems,
maxItems,
property,
stats,
showMoreAriaLabel,
showLessAriaLabel,
values,
} = this.props;

if (!stats) {
return null;
}

const sortedItems = this.props.getSortedItems
? this.props.getSortedItems()
: sortBy(
Object.keys(stats),
(key) => -stats[key],
(key) => this.props.getFacetItemText(key),
);

const limitedList = this.state.showFullList
? sortedItems
: sortedItems.slice(0, maxInitialItems);

// make sure all selected items are displayed
const selectedBelowLimit = this.state.showFullList
? []
: sortedItems.slice(maxInitialItems).filter((item) => values.includes(item));

const mightHaveMoreResults = sortedItems.length >= maxItems;

return (
<>
<FacetItemsList labelledby={this.getFacetHeaderId(property)}>
{limitedList.map((item) => (
<FacetItem
active={this.props.values.includes(item)}
key={item}
name={this.props.renderFacetItem(item)}
onClick={this.handleItemClick}
stat={formatFacetStat(this.getStat(item))}
tooltip={this.props.getFacetItemText(item)}
value={item}
/>
))}
</FacetItemsList>
{selectedBelowLimit.length > 0 && (
<>
<div className="note spacer-bottom text-center">⋯</div>
<FacetItemsList labelledby={this.getFacetHeaderId(property)}>
{selectedBelowLimit.map((item) => (
<FacetItem
active
key={item}
name={this.props.renderFacetItem(item)}
onClick={this.handleItemClick}
stat={formatFacetStat(this.getStat(item))}
tooltip={this.props.getFacetItemText(item)}
value={item}
/>
))}
</FacetItemsList>
</>
)}
<ListStyleFacetFooter
count={limitedList.length + selectedBelowLimit.length}
showLess={this.state.showFullList ? this.hideFullList : undefined}
showMore={this.showFullList}
total={sortedItems.length}
showMoreAriaLabel={showMoreAriaLabel}
showLessAriaLabel={showLessAriaLabel}
/>
{mightHaveMoreResults && this.state.showFullList && (
<Alert className="spacer-top" variant="warning">
{translate('facet_might_have_more_results')}
</Alert>
)}
</>
);
}

renderSearch() {
return (
<SearchBox
autoFocus={this.state.autoFocus}
className="little-spacer-top spacer-bottom"
loading={this.state.searching}
minLength={this.props.minSearchLength}
onChange={this.search}
placeholder={this.props.searchPlaceholder}
value={this.state.query}
/>
);
}

renderSearchResults() {
const { property, showMoreAriaLabel } = this.props;
const { searching, searchMaxResults, searchResults, searchPaging } = this.state;

if (!searching && (!searchResults || !searchResults.length)) {
return <div className="note spacer-bottom">{translate('no_results')}</div>;
}

if (!searchResults) {
// initial search
return null;
}

return (
<>
<FacetItemsList labelledby={this.getFacetHeaderId(property)}>
{searchResults.map((result) => this.renderSearchResult(result))}
</FacetItemsList>
{searchMaxResults && (
<Alert className="spacer-top" variant="warning">
{translate('facet_might_have_more_results')}
</Alert>
)}
{searchPaging && (
<ListFooter
className="spacer-bottom"
count={searchResults.length}
loadMore={this.searchMore}
ready={!searching}
total={searchPaging.total}
loadMoreAriaLabel={showMoreAriaLabel}
/>
)}
</>
);
}

renderSearchResult(result: S) {
const key = this.props.getSearchResultKey(result);
const active = this.props.values.includes(key);
const stat = this.getStat(key) || this.state.searchResultsCounts[key];
return (
<FacetItem
active={active}
key={key}
name={this.props.renderSearchResult(result, this.state.query)}
onClick={this.handleItemClick}
stat={formatFacetStat(stat)}
tooltip={this.props.getSearchResultText(result)}
value={key}
/>
);
}

render() {
const {
className,
disabled,
disabledHelper,
facetHeader,
fetching,
open,
property,
stats = {},
values: propsValues,
} = this.props;
const { query, searching, searchResults } = this.state;
const values = propsValues.map((item) => this.props.getFacetItemText(item));
const loadingResults =
query !== '' && searching && (searchResults === undefined || searchResults.length === 0);
const showList = !query || loadingResults;
return (
<FacetBox
className={classNames(className, {
'search-navigator-facet-box-forbidden': disabled,
})}
property={property}
>
<FacetHeader
fetching={fetching}
name={facetHeader}
disabled={disabled}
id={this.getFacetHeaderId(property)}
disabledHelper={disabledHelper}
onClear={this.handleClear}
onClick={disabled ? undefined : this.handleHeaderClick}
open={open && !disabled}
values={values}
/>

{open && !disabled && (
<>
{this.renderSearch()}
{showList ? this.renderList() : this.renderSearchResults()}
<MultipleSelectionHint options={Object.keys(stats).length} values={values.length} />
</>
)}
</FacetBox>
);
}
}

function formatFacetStat(stat: number | undefined) {
return stat && formatMeasure(stat, 'SHORT_INT');
}

+ 0
- 77
server/sonar-web/src/main/js/components/facet/ListStyleFacetFooter.tsx View File

@@ -1,77 +0,0 @@
/*
* 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 * as React from 'react';
import { translate, translateWithParameters } from '../../helpers/l10n';
import { formatMeasure } from '../../helpers/measures';
import { MetricType } from '../../types/metrics';
import { ButtonLink } from '../controls/buttons';

interface Props {
count: number;
showMore: () => void;
showLess?: () => void;
total: number;
showMoreAriaLabel?: string;
showLessAriaLabel?: string;
}

export default class ListStyleFacetFooter extends React.PureComponent<Props> {
handleShowMoreClick = () => {
this.props.showMore();
};

handleShowLessClick = () => {
if (this.props.showLess) {
this.props.showLess();
}
};

render() {
const { count, total, showMoreAriaLabel, showLessAriaLabel } = this.props;
const hasMore = total > count;
const allShown = Boolean(total && total === count);

return (
<div className="note spacer-top spacer-bottom text-center">
{translateWithParameters('x_show', formatMeasure(count, MetricType.Integer))}

{hasMore && (
<ButtonLink
className="spacer-left"
aria-label={showMoreAriaLabel}
onClick={this.handleShowMoreClick}
>
{translate('show_more')}
</ButtonLink>
)}

{this.props.showLess && allShown && (
<ButtonLink
className="spacer-left"
aria-label={showLessAriaLabel}
onClick={this.handleShowLessClick}
>
{translate('show_less')}
</ButtonLink>
)}
</div>
);
}
}

+ 0
- 35
server/sonar-web/src/main/js/components/facet/MultipleSelectionHint.css View File

@@ -1,35 +0,0 @@
/*
* 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.
*/
.multiple-selection-hint {
margin-top: var(--gridSize);
margin-bottom: var(--gridSize);
text-align: center;
}

.multiple-selection-hint-inner {
display: inline-block;
height: var(--controlHeight);
line-height: var(--controlHeight);
border-radius: var(--controlHeight);
background-color: var(--barBorderColor);
text-align: center;
padding: 0 var(--gridSize);
font-size: var(--smallFontSize);
}

+ 0
- 52
server/sonar-web/src/main/js/components/facet/MultipleSelectionHint.tsx View File

@@ -1,52 +0,0 @@
/*
* 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 * as React from 'react';
import { translate } from '../../helpers/l10n';
import './MultipleSelectionHint.css';

export interface MultipleSelectionHintProps {
options: number;
values: number;
}

const MAX_OPTIONS = 2;

export default function MultipleSelectionHint({ options, values }: MultipleSelectionHintProps) {
// do not render if nothing is selected or there are less than 2 possible options
if (values === 0 || options < MAX_OPTIONS) {
return null;
}

return (
<div className="multiple-selection-hint">
<div className="multiple-selection-hint-inner">
{translate(
isOnMac()
? 'shortcuts.section.global.facets.multiselection.mac'
: 'shortcuts.section.global.facets.multiselection',
)}
</div>
</div>
);
}

function isOnMac() {
return navigator.userAgent.indexOf('Mac OS X') !== -1;
}

+ 0
- 126
server/sonar-web/src/main/js/components/facet/__tests__/Facet-it.tsx View File

@@ -1,126 +0,0 @@
/*
* 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 { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { renderComponent } from '../../../helpers/testReactTestingUtils';
import FacetBox, { FacetBoxProps } from '../FacetBox';
import FacetHeader from '../FacetHeader';
import FacetItem from '../FacetItem';
import FacetItemsList from '../FacetItemsList';

it('should render and function correctly', async () => {
const user = userEvent.setup();
const onFacetClick = jest.fn();
renderFacet(undefined, undefined, { onClick: onFacetClick });

// Start closed.
let facetHeader = screen.getByRole('button', { name: 'foo', expanded: false });
expect(facetHeader).toBeInTheDocument();
expect(screen.queryByText('Foo/Bar')).not.toBeInTheDocument();

// Expand.
await user.click(facetHeader);
facetHeader = screen.getByRole('button', { name: 'foo', expanded: true });
expect(facetHeader).toBeInTheDocument();
expect(screen.getByText('Foo/Bar')).toBeInTheDocument();

// Interact with facets.
const facet1 = screen.getByRole('checkbox', { name: 'Foo/Bar 10' });
expect(facet1).toHaveClass('active');
await user.click(facet1);
expect(onFacetClick).toHaveBeenCalledWith('bar', false);

const facet2 = screen.getByRole('checkbox', { name: 'Foo/Baz' });
expect(facet2).not.toHaveClass('active');

// Collapse again.
await user.click(facetHeader);
expect(screen.getByRole('button', { name: 'foo', expanded: false })).toBeInTheDocument();
expect(screen.queryByText('Foo/Bar')).not.toBeInTheDocument();
});

it('should correctly render a header with helper text', async () => {
renderFacet(undefined, { helper: 'Help text' });
await expect(screen.getByRole('img', { description: 'Help text' })).toHaveATooltipWithContent(
'Help text',
);
});

it('should correctly render a header with value data', async () => {
const user = userEvent.setup();
renderFacet(undefined, { values: ['value 1'] });
expect(screen.getByText('value 1')).toBeInTheDocument();
await user.click(screen.getByRole('button', { name: 'clear_x_filter.foo' }));
expect(screen.queryByText('value 1')).not.toBeInTheDocument();
});

it('should correctly render a disabled header', () => {
renderFacet(undefined, { onClick: undefined });
expect(screen.queryByRole('checkbox', { name: 'foo' })).not.toBeInTheDocument();
});

function renderFacet(
facetBoxProps: Partial<FacetBoxProps> = {},
facetHeaderProps: Partial<FacetHeader['props']> = {},
facetItemProps: Partial<FacetItem['props']> = {},
) {
function Facet() {
const [open, setOpen] = React.useState(facetHeaderProps.open ?? false);
const [values, setValues] = React.useState(facetHeaderProps.values ?? undefined);

const property = 'foo';
const headerId = `facet_${property}`;

return (
<FacetBox property={property} {...facetBoxProps}>
<FacetHeader
id={headerId}
name="foo"
onClick={() => setOpen(!open)}
onClear={() => setValues(undefined)}
{...{ ...facetHeaderProps, open, values }}
/>

{open && (
<FacetItemsList labelledby={headerId}>
<FacetItem
active
name="Foo/Bar"
onClick={jest.fn()}
value="bar"
stat={10}
{...facetItemProps}
/>
<FacetItem
active={false}
name="Foo/Baz"
onClick={jest.fn()}
value="baz"
{...facetItemProps}
/>
</FacetItemsList>
)}
</FacetBox>
);
}

return renderComponent(<Facet />);
}

Loading…
Cancel
Save