aboutsummaryrefslogtreecommitdiffstats
path: root/server/sonar-ui-common/components/controls
diff options
context:
space:
mode:
authorPhilippe Perrin <philippe.perrin@sonarsource.com>2021-07-15 15:55:30 +0200
committersonartech <sonartech@sonarsource.com>2021-07-21 20:03:01 +0000
commita57b5dd4d8ba48dd55a80111f1c6db54cb36c63d (patch)
treef382d2c7b08747e2b7d1cea7fa87550bac3882df /server/sonar-ui-common/components/controls
parent1dff5a612667788aed965d14945767b277a2b313 (diff)
downloadsonarqube-a57b5dd4d8ba48dd55a80111f1c6db54cb36c63d.tar.gz
sonarqube-a57b5dd4d8ba48dd55a80111f1c6db54cb36c63d.zip
SONAR-14617 Embed the sonar-ui-common library
Diffstat (limited to 'server/sonar-ui-common/components/controls')
-rw-r--r--server/sonar-ui-common/components/controls/ActionsDropdown.tsx138
-rw-r--r--server/sonar-ui-common/components/controls/BackButton.tsx72
-rw-r--r--server/sonar-ui-common/components/controls/BoxedGroupAccordion.tsx78
-rw-r--r--server/sonar-ui-common/components/controls/BoxedTabs.tsx91
-rw-r--r--server/sonar-ui-common/components/controls/Checkbox.css92
-rw-r--r--server/sonar-ui-common/components/controls/Checkbox.tsx97
-rw-r--r--server/sonar-ui-common/components/controls/ClickEventBoundary.tsx35
-rw-r--r--server/sonar-ui-common/components/controls/ConfirmButton.tsx58
-rw-r--r--server/sonar-ui-common/components/controls/ConfirmModal.tsx108
-rw-r--r--server/sonar-ui-common/components/controls/DocumentClickHandler.tsx53
-rw-r--r--server/sonar-ui-common/components/controls/Dropdown.css34
-rw-r--r--server/sonar-ui-common/components/controls/Dropdown.tsx160
-rw-r--r--server/sonar-ui-common/components/controls/EscKeydownHandler.tsx48
-rw-r--r--server/sonar-ui-common/components/controls/FavoriteButton.tsx53
-rw-r--r--server/sonar-ui-common/components/controls/GlobalMessages.tsx124
-rw-r--r--server/sonar-ui-common/components/controls/HelpTooltip.css31
-rw-r--r--server/sonar-ui-common/components/controls/HelpTooltip.tsx66
-rw-r--r--server/sonar-ui-common/components/controls/IdentityProviderLink.css67
-rw-r--r--server/sonar-ui-common/components/controls/IdentityProviderLink.tsx63
-rw-r--r--server/sonar-ui-common/components/controls/InputValidationField.tsx52
-rw-r--r--server/sonar-ui-common/components/controls/ListFooter.tsx73
-rw-r--r--server/sonar-ui-common/components/controls/Modal.css211
-rw-r--r--server/sonar-ui-common/components/controls/Modal.tsx76
-rw-r--r--server/sonar-ui-common/components/controls/ModalButton.tsx80
-rw-r--r--server/sonar-ui-common/components/controls/ModalValidationField.tsx49
-rw-r--r--server/sonar-ui-common/components/controls/OutsideClickHandler.tsx64
-rw-r--r--server/sonar-ui-common/components/controls/Radio.css77
-rw-r--r--server/sonar-ui-common/components/controls/Radio.tsx56
-rw-r--r--server/sonar-ui-common/components/controls/RadioCard.css134
-rw-r--r--server/sonar-ui-common/components/controls/RadioCard.tsx92
-rw-r--r--server/sonar-ui-common/components/controls/RadioToggle.css73
-rw-r--r--server/sonar-ui-common/components/controls/RadioToggle.tsx74
-rw-r--r--server/sonar-ui-common/components/controls/ReloadButton.tsx63
-rw-r--r--server/sonar-ui-common/components/controls/ScreenPositionFixer.tsx117
-rw-r--r--server/sonar-ui-common/components/controls/SearchBox.css104
-rw-r--r--server/sonar-ui-common/components/controls/SearchBox.tsx176
-rw-r--r--server/sonar-ui-common/components/controls/SearchSelect.tsx154
-rw-r--r--server/sonar-ui-common/components/controls/Select.css477
-rw-r--r--server/sonar-ui-common/components/controls/Select.tsx72
-rw-r--r--server/sonar-ui-common/components/controls/SelectList.css60
-rw-r--r--server/sonar-ui-common/components/controls/SelectList.tsx189
-rw-r--r--server/sonar-ui-common/components/controls/SelectListListContainer.tsx129
-rw-r--r--server/sonar-ui-common/components/controls/SelectListListElement.tsx76
-rw-r--r--server/sonar-ui-common/components/controls/SimpleModal.tsx101
-rw-r--r--server/sonar-ui-common/components/controls/Tabs.css60
-rw-r--r--server/sonar-ui-common/components/controls/Tabs.tsx77
-rw-r--r--server/sonar-ui-common/components/controls/Toggle.css82
-rw-r--r--server/sonar-ui-common/components/controls/Toggle.tsx60
-rw-r--r--server/sonar-ui-common/components/controls/Toggler.tsx73
-rw-r--r--server/sonar-ui-common/components/controls/Tooltip.css134
-rw-r--r--server/sonar-ui-common/components/controls/Tooltip.tsx407
-rw-r--r--server/sonar-ui-common/components/controls/ValidationForm.tsx72
-rw-r--r--server/sonar-ui-common/components/controls/ValidationInput.tsx61
-rw-r--r--server/sonar-ui-common/components/controls/ValidationModal.tsx83
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/ActionsDropdown-test.tsx92
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/BackButton-test.tsx48
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/BoxedGroupAccordion-test.tsx52
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/BoxedTabs-test.tsx66
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/Checkbox-test.tsx115
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/ClickEventBoundary-test.tsx49
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/ConfirmButton-test.tsx44
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/ConfirmModal-test.tsx60
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/Dropdown-test.tsx111
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/EscKeydownHandler-test.tsx47
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/FavoriteButton-test.tsx54
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/GlobalMessages-test.tsx64
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/HelpTooltip-test.tsx53
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/IdentityProviderLink-test.tsx43
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/InputValidationField-test.tsx44
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/ListFooter-test.tsx57
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/ModalButton-test.tsx38
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/ModalValidationField-test.tsx48
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/Radio-test.tsx52
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/RadioCard-test.tsx59
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/RadioToggle-test.tsx94
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/ReloadButton-test.tsx49
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/ScreenPositionFixer-test.tsx93
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/SearchBox-test.tsx90
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/SearchSelect-test.tsx50
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/SelectList-test.tsx148
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/SelectListListContainer-test.tsx44
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/SelectListListElement-test.tsx47
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/SimpleModal-test.tsx65
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/Tabs-test.tsx81
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/Toggle-test.tsx44
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/Toggler-test.tsx48
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/Tooltip-test.tsx112
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/ValidationForm-test.tsx47
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/ValidationInput-test.tsx73
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/ValidationModal-test.tsx68
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/ActionsDropdown-test.tsx.snap143
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/BackButton-test.tsx.snap46
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/BoxedGroupAccordion-test.tsx.snap26
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/BoxedTabs-test.tsx.snap178
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/Checkbox-test.tsx.snap22
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/ClickEventBoundary-test.tsx.snap14
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/ConfirmButton-test.tsx.snap20
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/ConfirmModal-test.tsx.snap81
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/EscKeydownHandler-test.tsx.snap7
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/FavoriteButton-test.tsx.snap65
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/GlobalMessages-test.tsx.snap212
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/HelpTooltip-test.tsx.snap53
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/IdentityProviderLink-test.tsx.snap21
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/InputValidationField-test.tsx.snap13
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/ListFooter-test.tsx.snap85
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/ModalValidationField-test.tsx.snap73
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/Radio-test.tsx.snap29
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/RadioCard-test.tsx.snap172
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/RadioToggle-test.tsx.snap92
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/ReloadButton-test.tsx.snap46
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/SearchBox-test.tsx.snap37
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/SearchSelect-test.tsx.snap17
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/SelectList-test.tsx.snap558
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap61
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/SelectListListElement-test.tsx.snap41
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/SimpleModal-test.tsx.snap52
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/Tabs-test.tsx.snap52
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/Toggle-test.tsx.snap55
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/Toggler-test.tsx.snap58
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/Tooltip-test.tsx.snap40
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/ValidationForm-test.tsx.snap19
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/ValidationInput-test.tsx.snap98
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/ValidationModal-test.tsx.snap110
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/buttons-test.tsx.snap31
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/__snapshots__/clipboard-test.tsx.snap52
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/buttons-test.tsx77
-rw-r--r--server/sonar-ui-common/components/controls/__tests__/clipboard-test.tsx102
-rw-r--r--server/sonar-ui-common/components/controls/buttons.css322
-rw-r--r--server/sonar-ui-common/components/controls/buttons.tsx182
-rw-r--r--server/sonar-ui-common/components/controls/clipboard.tsx149
130 files changed, 11266 insertions, 0 deletions
diff --git a/server/sonar-ui-common/components/controls/ActionsDropdown.tsx b/server/sonar-ui-common/components/controls/ActionsDropdown.tsx
new file mode 100644
index 00000000000..06f01d629b0
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/ActionsDropdown.tsx
@@ -0,0 +1,138 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import { LocationDescriptor } from 'history';
+import * as React from 'react';
+import { Link } from 'react-router';
+import { translate } from '../../helpers/l10n';
+import DropdownIcon from '../icons/DropdownIcon';
+import SettingsIcon from '../icons/SettingsIcon';
+import { PopupPlacement } from '../ui/popups';
+import { Button } from './buttons';
+import { ClipboardBase } from './clipboard';
+import Dropdown from './Dropdown';
+import Tooltip from './Tooltip';
+
+export interface ActionsDropdownProps {
+ className?: string;
+ children: React.ReactNode;
+ onOpen?: () => void;
+ overlayPlacement?: PopupPlacement;
+ small?: boolean;
+ toggleClassName?: string;
+}
+
+export default function ActionsDropdown(props: ActionsDropdownProps) {
+ const { children, className, overlayPlacement, small, toggleClassName } = props;
+ return (
+ <Dropdown
+ className={className}
+ onOpen={props.onOpen}
+ overlay={<ul className="menu">{children}</ul>}
+ overlayPlacement={overlayPlacement}>
+ <Button
+ className={classNames('dropdown-toggle', toggleClassName, {
+ 'button-small': small,
+ })}>
+ <SettingsIcon size={small ? 12 : 14} />
+ <DropdownIcon className="little-spacer-left" />
+ </Button>
+ </Dropdown>
+ );
+}
+
+interface ItemProps {
+ className?: string;
+ children: React.ReactNode;
+ /** used to pass a string to copy to clipboard */
+ copyValue?: string;
+ destructive?: boolean;
+ /** used to pass a name of downloaded file */
+ download?: string;
+ id?: string;
+ onClick?: () => void;
+ to?: LocationDescriptor;
+}
+
+export class ActionsDropdownItem extends React.PureComponent<ItemProps> {
+ handleClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ event.currentTarget.blur();
+ if (this.props.onClick) {
+ this.props.onClick();
+ }
+ };
+
+ render() {
+ const className = classNames(this.props.className, { 'text-danger': this.props.destructive });
+
+ if (this.props.download && typeof this.props.to === 'string') {
+ return (
+ <li>
+ <a
+ className={className}
+ download={this.props.download}
+ href={this.props.to}
+ id={this.props.id}>
+ {this.props.children}
+ </a>
+ </li>
+ );
+ }
+
+ if (this.props.to) {
+ return (
+ <li>
+ <Link className={className} id={this.props.id} to={this.props.to}>
+ {this.props.children}
+ </Link>
+ </li>
+ );
+ }
+
+ if (this.props.copyValue) {
+ return (
+ <ClipboardBase>
+ {({ setCopyButton, copySuccess }) => (
+ <Tooltip overlay={translate('copied_action')} visible={copySuccess}>
+ <li data-clipboard-text={this.props.copyValue} ref={setCopyButton}>
+ <a className={className} href="#" id={this.props.id} onClick={this.handleClick}>
+ {this.props.children}
+ </a>
+ </li>
+ </Tooltip>
+ )}
+ </ClipboardBase>
+ );
+ }
+
+ return (
+ <li>
+ <a className={className} href="#" id={this.props.id} onClick={this.handleClick}>
+ {this.props.children}
+ </a>
+ </li>
+ );
+ }
+}
+
+export function ActionsDropdownDivider() {
+ return <li className="divider" />;
+}
diff --git a/server/sonar-ui-common/components/controls/BackButton.tsx b/server/sonar-ui-common/components/controls/BackButton.tsx
new file mode 100644
index 00000000000..3c747be6910
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/BackButton.tsx
@@ -0,0 +1,72 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import { translate } from '../../helpers/l10n';
+import { ThemeConsumer } from '../theme';
+import Tooltip from './Tooltip';
+
+interface Props {
+ className?: string;
+ disabled?: boolean;
+ onClick: () => void;
+ tooltip?: string;
+}
+
+export default class BackButton extends React.PureComponent<Props> {
+ handleClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ event.currentTarget.blur();
+ if (!this.props.disabled) {
+ this.props.onClick();
+ }
+ };
+
+ renderIcon = () => (
+ <ThemeConsumer>
+ {(theme) => (
+ <svg height="24" viewBox="0 0 21 24" width="21">
+ <path
+ d="M3.845 12.9992l5.993 5.993.052.056c.049.061.093.122.129.191.082.159.121.339.111.518-.006.102-.028.203-.064.298-.149.39-.537.652-.954.644-.102-.002-.204-.019-.301-.052-.148-.05-.273-.135-.387-.241l-8.407-8.407 8.407-8.407.056-.052c.061-.048.121-.092.19-.128.116-.06.237-.091.366-.108.076-.004.075-.004.153-.003.155.015.3.052.437.129.088.051.169.115.239.19.246.266.33.656.214.999-.051.149-.135.273-.241.387l-5.983 5.984c5.287-.044 10.577-.206 15.859.013.073.009.091.009.163.027.187.047.359.15.49.292.075.081.136.175.18.276.044.101.072.209.081.319.032.391-.175.775-.521.962-.097.052-.202.089-.311.107-.073.012-.091.01-.165.013H3.845z"
+ fill={this.props.disabled ? theme.colors.disableGrayText : theme.colors.secondFontColor}
+ />
+ </svg>
+ )}
+ </ThemeConsumer>
+ );
+
+ render() {
+ const { tooltip = translate('issues.return_to_list') } = this.props;
+ return (
+ <Tooltip overlay={tooltip}>
+ <a
+ className={classNames(
+ 'link-no-underline',
+ { 'cursor-not-allowed': this.props.disabled },
+ this.props.className
+ )}
+ href="#"
+ onClick={this.handleClick}>
+ {this.renderIcon()}
+ </a>
+ </Tooltip>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/BoxedGroupAccordion.tsx b/server/sonar-ui-common/components/controls/BoxedGroupAccordion.tsx
new file mode 100644
index 00000000000..5677b2292e6
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/BoxedGroupAccordion.tsx
@@ -0,0 +1,78 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import OpenCloseIcon from '../icons/OpenCloseIcon';
+
+interface Props {
+ children: React.ReactNode;
+ className?: string;
+ data?: string;
+ onClick: (data?: string) => void;
+ open: boolean;
+ renderHeader?: () => React.ReactNode;
+ title: React.ReactNode;
+}
+
+interface State {
+ hoveringInner: boolean;
+}
+
+export default class BoxedGroupAccordion extends React.PureComponent<Props, State> {
+ state: State = { hoveringInner: false };
+
+ handleClick = () => {
+ this.props.onClick(this.props.data);
+ };
+
+ onDetailEnter = () => {
+ this.setState({ hoveringInner: true });
+ };
+
+ onDetailLeave = () => {
+ this.setState({ hoveringInner: false });
+ };
+
+ render() {
+ const { className, open, renderHeader, title } = this.props;
+ return (
+ <div
+ className={classNames('boxed-group boxed-group-accordion', className, {
+ 'no-hover': this.state.hoveringInner,
+ })}>
+ <div className="boxed-group-header" onClick={this.handleClick} role="listitem">
+ <span className="boxed-group-accordion-title">
+ <OpenCloseIcon className="little-spacer-right" open={open} />
+ {title}
+ </span>
+ {renderHeader && renderHeader()}
+ </div>
+ {open && (
+ <div
+ className="boxed-group-inner"
+ onMouseEnter={this.onDetailEnter}
+ onMouseLeave={this.onDetailLeave}>
+ {this.props.children}
+ </div>
+ )}
+ </div>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/BoxedTabs.tsx b/server/sonar-ui-common/components/controls/BoxedTabs.tsx
new file mode 100644
index 00000000000..043c62d800d
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/BoxedTabs.tsx
@@ -0,0 +1,91 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { styled, themeColor, ThemedProps, themeSize } from '../theme';
+
+export interface BoxedTabsProps<K> {
+ className?: string;
+ onSelect: (key: K) => void;
+ selected?: K;
+ tabs: Array<{ key: K; label: React.ReactNode }>;
+}
+
+const TabContainer = styled.div`
+ display: flex;
+ flex-direction: row;
+`;
+
+const baseBorder = ({ theme }: ThemedProps) => `1px solid ${theme.colors.barBorderColor}`;
+
+const highlightHoverMixin = ({ theme }: ThemedProps) => `
+ &:hover {
+ background-color: ${theme.colors.barBackgroundColorHighlight};
+ }
+`;
+
+const StyledTab = styled.button<{ active: boolean }>`
+ position: relative;
+ background-color: ${(props) => (props.active ? 'white' : props.theme.colors.barBackgroundColor)};
+ border-top: ${baseBorder};
+ border-left: ${baseBorder};
+ border-right: none;
+ border-bottom: none;
+ margin-bottom: -1px;
+ min-width: 128px;
+ min-height: 56px;
+ ${(props) => !props.active && 'cursor: pointer;'}
+ outline: 0;
+ padding: calc(2 * ${themeSize('gridSize')});
+
+ ${(props) => (!props.active ? highlightHoverMixin : null)}
+
+ &:last-child {
+ border-right: ${baseBorder};
+ }
+`;
+
+const ActiveBorder = styled.div<{ active: boolean }>`
+ display: ${(props) => (props.active ? 'block' : 'none')};
+ background-color: ${themeColor('blue')};
+ height: 3px;
+ width: 100%;
+ position: absolute;
+ left: 0;
+ top: -1px;
+`;
+
+export default function BoxedTabs<K>(props: BoxedTabsProps<K>) {
+ const { className, tabs, selected } = props;
+
+ return (
+ <TabContainer className={className}>
+ {tabs.map(({ key, label }, i) => (
+ <StyledTab
+ active={selected === key}
+ key={i}
+ onClick={() => selected !== key && props.onSelect(key)}
+ type="button">
+ <ActiveBorder active={selected === key} />
+ {label}
+ </StyledTab>
+ ))}
+ </TabContainer>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/Checkbox.css b/server/sonar-ui-common/components/controls/Checkbox.css
new file mode 100644
index 00000000000..ab709bb19cd
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Checkbox.css
@@ -0,0 +1,92 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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.
+ */
+
+.icon-checkbox {
+ display: inline-block;
+ line-height: 1;
+ vertical-align: top;
+ padding: 1px 2px;
+ box-sizing: border-box;
+}
+
+a.icon-checkbox {
+ border-bottom: none;
+}
+
+.icon-checkbox:focus {
+ outline: none;
+}
+
+.icon-checkbox:before {
+ content: ' ';
+ display: inline-block;
+ width: 10px;
+ height: 10px;
+ border: 1px solid var(--darkBlue);
+ border-radius: 2px;
+ transition: border-color 0.2s ease, background-color 0.2s ease, background-image 0.2s ease,
+ box-shadow 0.4s ease;
+}
+
+.icon-checkbox:not(.icon-checkbox-disabled):focus:before,
+.link-checkbox:not(.disabled):focus:focus .icon-checkbox:before {
+ box-shadow: 0 0 0 3px rgba(35, 106, 151, 0.25);
+}
+
+.icon-checkbox-checked:before {
+ background-color: var(--blue);
+ background-image: url('data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2014%2014%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cpath%20d%3D%22M12%204.665c0%20.172-.06.318-.18.438l-5.55%205.55c-.12.12-.266.18-.438.18s-.318-.06-.438-.18L2.18%207.438C2.06%207.317%202%207.17%202%207s.06-.318.18-.44l.878-.876c.12-.12.267-.18.44-.18.17%200%20.317.06.437.18l1.897%201.903%204.233-4.24c.12-.12.266-.18.438-.18s.32.06.44.18l.876.88c.12.12.18.265.18.438z%22%20fill%3D%22%23fff%22%20fill-rule%3D%22nonzero%22%2F%3E%3C%2Fsvg%3E');
+ border-color: var(--blue);
+}
+
+.icon-checkbox-checked.icon-checkbox-single:before {
+ background-image: url('data:image/svg+xml,%3Csvg%20viewBox%3D%220%200%2014%2014%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cpath%20d%3D%22M10%204.698C10%204.312%209.688%204%209.302%204H4.698C4.312%204%204%204.312%204%204.698v4.604c0%20.386.312.698.698.698h4.604c.386%200%20.698-.312.698-.698V4.698z%22%20fill%3D%22%23fff%22%2F%3E%3C%2Fsvg%3E');
+}
+
+.icon-checkbox-disabled:before {
+ border: 1px solid var(--disableGrayText);
+ cursor: not-allowed;
+}
+
+.icon-checkbox-disabled.icon-checkbox-checked:before {
+ background-color: var(--disableGrayText);
+}
+
+.icon-checkbox-invisible {
+ visibility: hidden;
+}
+
+.link-checkbox {
+ color: inherit;
+ border-bottom: none;
+}
+
+.link-checkbox.disabled,
+.link-checkbox.disabled:hover,
+.link-checkbox.disabled label {
+ color: var(--secondFontColor);
+ cursor: not-allowed;
+}
+
+.link-checkbox:hover,
+.link-checkbox:active,
+.link-checkbox:focus {
+ color: inherit;
+}
diff --git a/server/sonar-ui-common/components/controls/Checkbox.tsx b/server/sonar-ui-common/components/controls/Checkbox.tsx
new file mode 100644
index 00000000000..307dc1c263f
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Checkbox.tsx
@@ -0,0 +1,97 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import DeferredSpinner from '../ui/DeferredSpinner';
+import './Checkbox.css';
+
+interface Props {
+ checked: boolean;
+ disabled?: boolean;
+ children?: React.ReactNode;
+ className?: string;
+ id?: string;
+ loading?: boolean;
+ onCheck: (checked: boolean, id?: string) => void;
+ right?: boolean;
+ thirdState?: boolean;
+ title?: string;
+}
+
+export default class Checkbox extends React.PureComponent<Props> {
+ static defaultProps = {
+ thirdState: false,
+ };
+
+ handleClick = (event: React.SyntheticEvent<HTMLElement>) => {
+ event.preventDefault();
+ event.currentTarget.blur();
+ if (!this.props.disabled) {
+ this.props.onCheck(!this.props.checked, this.props.id);
+ }
+ };
+
+ render() {
+ const { checked, children, disabled, id, loading, right, thirdState, title } = this.props;
+ const className = classNames('icon-checkbox', {
+ 'icon-checkbox-checked': checked,
+ 'icon-checkbox-single': thirdState,
+ 'icon-checkbox-disabled': disabled,
+ });
+
+ if (children) {
+ return (
+ <a
+ aria-checked={checked}
+ className={classNames('link-checkbox', this.props.className, {
+ note: disabled,
+ disabled,
+ })}
+ href="#"
+ id={id}
+ onClick={this.handleClick}
+ role="checkbox"
+ title={title}>
+ {right && children}
+ <DeferredSpinner loading={Boolean(loading)}>
+ <i className={className} />
+ </DeferredSpinner>
+ {!right && children}
+ </a>
+ );
+ }
+
+ if (loading) {
+ return <DeferredSpinner />;
+ }
+
+ return (
+ <a
+ aria-checked={checked}
+ className={classNames(className, this.props.className)}
+ href="#"
+ id={id}
+ onClick={this.handleClick}
+ role="checkbox"
+ title={title}
+ />
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/ClickEventBoundary.tsx b/server/sonar-ui-common/components/controls/ClickEventBoundary.tsx
new file mode 100644
index 00000000000..96a25e4c5c8
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/ClickEventBoundary.tsx
@@ -0,0 +1,35 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 interface ClickEventBoundaryProps {
+ children: React.ReactElement;
+}
+
+export default function ClickEventBoundary({ children }: ClickEventBoundaryProps) {
+ return React.cloneElement(children, {
+ onClick: (e: React.SyntheticEvent<MouseEvent>) => {
+ e.stopPropagation();
+ if (typeof children.props.onClick === 'function') {
+ children.props.onClick(e);
+ }
+ },
+ });
+}
diff --git a/server/sonar-ui-common/components/controls/ConfirmButton.tsx b/server/sonar-ui-common/components/controls/ConfirmButton.tsx
new file mode 100644
index 00000000000..737b18e2f3e
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/ConfirmButton.tsx
@@ -0,0 +1,58 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 ConfirmModal, { ConfirmModalProps } from './ConfirmModal';
+import ModalButton, { ChildrenProps, ModalProps } from './ModalButton';
+
+interface Props<T> extends ConfirmModalProps<T> {
+ children: (props: ChildrenProps) => React.ReactNode;
+ modalBody: React.ReactNode;
+ modalHeader: string;
+ modalHeaderDescription?: React.ReactNode;
+}
+
+interface State {
+ modal: boolean;
+}
+
+export default class ConfirmButton<T> extends React.PureComponent<Props<T>, State> {
+ renderConfirmModal = ({ onClose }: ModalProps) => {
+ const {
+ children,
+ modalBody,
+ modalHeader,
+ modalHeaderDescription,
+ ...confirmModalProps
+ } = this.props;
+ return (
+ <ConfirmModal
+ header={modalHeader}
+ headerDescription={modalHeaderDescription}
+ onClose={onClose}
+ {...confirmModalProps}>
+ {modalBody}
+ </ConfirmModal>
+ );
+ };
+
+ render() {
+ return <ModalButton modal={this.renderConfirmModal}>{this.props.children}</ModalButton>;
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/ConfirmModal.tsx b/server/sonar-ui-common/components/controls/ConfirmModal.tsx
new file mode 100644
index 00000000000..295d4f882ca
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/ConfirmModal.tsx
@@ -0,0 +1,108 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 DeferredSpinner from '../ui/DeferredSpinner';
+import { ResetButtonLink, SubmitButton } from './buttons';
+import ClickEventBoundary from './ClickEventBoundary';
+import { ModalProps } from './Modal';
+import SimpleModal, { ChildrenProps } from './SimpleModal';
+
+export interface ConfirmModalProps<T> extends ModalProps {
+ cancelButtonText?: string;
+ confirmButtonText: string;
+ confirmData?: T;
+ confirmDisable?: boolean;
+ isDestructive?: boolean;
+ onConfirm: (data?: T) => void | Promise<void | Response>;
+}
+
+interface Props<T> extends ConfirmModalProps<T> {
+ header: string;
+ headerDescription?: React.ReactNode;
+ onClose: () => void;
+}
+
+export default class ConfirmModal<T = string> extends React.PureComponent<Props<T>> {
+ mounted = false;
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ handleSubmit = () => {
+ const result = this.props.onConfirm(this.props.confirmData);
+ if (result) {
+ return result.then(this.props.onClose, () => {});
+ } else {
+ this.props.onClose();
+ return undefined;
+ }
+ };
+
+ renderModalContent = ({ onCloseClick, onFormSubmit, submitting }: ChildrenProps) => {
+ const {
+ children,
+ confirmButtonText,
+ confirmDisable,
+ header,
+ headerDescription,
+ isDestructive,
+ cancelButtonText = translate('cancel'),
+ } = this.props;
+ return (
+ <ClickEventBoundary>
+ <form onSubmit={onFormSubmit}>
+ <header className="modal-head">
+ <h2>{header}</h2>
+ {headerDescription}
+ </header>
+ <div className="modal-body">{children}</div>
+ <footer className="modal-foot">
+ <DeferredSpinner className="spacer-right" loading={submitting} />
+ <SubmitButton
+ autoFocus={true}
+ className={isDestructive ? 'button-red' : undefined}
+ disabled={submitting || confirmDisable}>
+ {confirmButtonText}
+ </SubmitButton>
+ <ResetButtonLink disabled={submitting} onClick={onCloseClick}>
+ {cancelButtonText}
+ </ResetButtonLink>
+ </footer>
+ </form>
+ </ClickEventBoundary>
+ );
+ };
+
+ render() {
+ const { header, onClose, noBackdrop, size } = this.props;
+ const modalProps = { header, onClose, noBackdrop, size };
+ return (
+ <SimpleModal onSubmit={this.handleSubmit} {...modalProps}>
+ {this.renderModalContent}
+ </SimpleModal>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/DocumentClickHandler.tsx b/server/sonar-ui-common/components/controls/DocumentClickHandler.tsx
new file mode 100644
index 00000000000..a6bb044b2a8
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/DocumentClickHandler.tsx
@@ -0,0 +1,53 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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';
+
+interface Props {
+ children: React.ReactNode;
+ onClick: () => void;
+}
+
+export default class DocumentClickHandler extends React.Component<Props> {
+ componentDidMount() {
+ setTimeout(() => {
+ this.addClickHandler();
+ }, 0);
+ }
+
+ componentWillUnmount() {
+ this.removeClickHandler();
+ }
+
+ addClickHandler = () => {
+ document.addEventListener('click', this.handleDocumentClick);
+ };
+
+ removeClickHandler = () => {
+ document.removeEventListener('click', this.handleDocumentClick);
+ };
+
+ handleDocumentClick = () => {
+ this.props.onClick();
+ };
+
+ render() {
+ return this.props.children;
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/Dropdown.css b/server/sonar-ui-common/components/controls/Dropdown.css
new file mode 100644
index 00000000000..783ed430e86
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Dropdown.css
@@ -0,0 +1,34 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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.
+ */
+.dropdown {
+ position: relative;
+ display: inline-block;
+ vertical-align: middle;
+}
+
+.dropdown-bottom-hint {
+ line-height: 16px;
+ margin-bottom: -5px;
+ padding: 5px 10px;
+ border-top: 1px solid var(--barBorderColor);
+ background-color: var(--barBackgroundColor);
+ color: var(--secondFontColor);
+ font-size: 11px;
+}
diff --git a/server/sonar-ui-common/components/controls/Dropdown.tsx b/server/sonar-ui-common/components/controls/Dropdown.tsx
new file mode 100644
index 00000000000..a39be19c82e
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Dropdown.tsx
@@ -0,0 +1,160 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import { Popup, PopupPlacement } from '../ui/popups';
+import './Dropdown.css';
+import ScreenPositionFixer from './ScreenPositionFixer';
+import Toggler from './Toggler';
+
+interface OnClickCallback {
+ (event?: React.SyntheticEvent<HTMLElement>): void;
+}
+
+interface RenderProps {
+ closeDropdown: () => void;
+ onToggleClick: OnClickCallback;
+ open: boolean;
+}
+
+interface Props {
+ children:
+ | ((renderProps: RenderProps) => JSX.Element)
+ | React.ReactElement<{ onClick: OnClickCallback }>;
+ className?: string;
+ closeOnClick?: boolean;
+ closeOnClickOutside?: boolean;
+ onOpen?: () => void;
+ overlay: React.ReactNode;
+ overlayPlacement?: PopupPlacement;
+ noOverlayPadding?: boolean;
+ tagName?: string;
+}
+
+interface State {
+ open: boolean;
+}
+
+export default class Dropdown extends React.PureComponent<Props, State> {
+ state: State = { open: false };
+
+ componentDidUpdate(_: Props, prevState: State) {
+ if (!prevState.open && this.state.open && this.props.onOpen) {
+ this.props.onOpen();
+ }
+ }
+
+ closeDropdown = () => this.setState({ open: false });
+
+ handleToggleClick = (event?: React.SyntheticEvent<HTMLElement>) => {
+ if (event) {
+ event.preventDefault();
+ event.currentTarget.blur();
+ }
+ this.setState((state) => ({ open: !state.open }));
+ };
+
+ render() {
+ const a11yAttrs = {
+ 'aria-expanded': String(this.state.open),
+ 'aria-haspopup': 'true',
+ };
+
+ const child = React.isValidElement(this.props.children)
+ ? React.cloneElement(this.props.children, { onClick: this.handleToggleClick, ...a11yAttrs })
+ : this.props.children({
+ closeDropdown: this.closeDropdown,
+ onToggleClick: this.handleToggleClick,
+ open: this.state.open,
+ });
+
+ const { closeOnClick = true, closeOnClickOutside = false } = this.props;
+
+ const toggler = (
+ <Toggler
+ closeOnClick={closeOnClick}
+ closeOnClickOutside={closeOnClickOutside}
+ onRequestClose={this.closeDropdown}
+ open={this.state.open}
+ overlay={
+ <DropdownOverlay
+ noPadding={this.props.noOverlayPadding}
+ placement={this.props.overlayPlacement}>
+ {this.props.overlay}
+ </DropdownOverlay>
+ }>
+ {child}
+ </Toggler>
+ );
+
+ return React.createElement(
+ this.props.tagName || 'div',
+ { className: classNames('dropdown', this.props.className) },
+ toggler
+ );
+ }
+}
+
+interface OverlayProps {
+ className?: string;
+ children: React.ReactNode;
+ noPadding?: boolean;
+ placement?: PopupPlacement;
+}
+
+// TODO use the same styling for <Select />
+// TODO use the same styling for <DateInput />
+
+export class DropdownOverlay extends React.Component<OverlayProps> {
+ get placement() {
+ return this.props.placement || PopupPlacement.Bottom;
+ }
+
+ renderPopup = (leftFix?: number, topFix?: number) => (
+ <Popup
+ arrowStyle={
+ leftFix !== undefined && topFix !== undefined
+ ? { transform: `translate(${-leftFix}px, ${-topFix}px)` }
+ : undefined
+ }
+ className={this.props.className}
+ noPadding={this.props.noPadding}
+ placement={this.placement}
+ style={
+ leftFix !== undefined && topFix !== undefined
+ ? { marginLeft: `calc(50% + ${leftFix}px)` }
+ : undefined
+ }>
+ {this.props.children}
+ </Popup>
+ );
+
+ render() {
+ if (this.placement === PopupPlacement.Bottom) {
+ return (
+ <ScreenPositionFixer>
+ {({ leftFix, topFix }) => this.renderPopup(leftFix, topFix)}
+ </ScreenPositionFixer>
+ );
+ } else {
+ return this.renderPopup();
+ }
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/EscKeydownHandler.tsx b/server/sonar-ui-common/components/controls/EscKeydownHandler.tsx
new file mode 100644
index 00000000000..b8c324c76f5
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/EscKeydownHandler.tsx
@@ -0,0 +1,48 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { KeyCodes } from '../../helpers/keycodes';
+
+interface Props {
+ children: React.ReactNode;
+ onKeydown: () => void;
+}
+
+export default class EscKeydownHandler extends React.Component<Props> {
+ componentDidMount() {
+ setTimeout(() => {
+ document.addEventListener('keydown', this.handleKeyDown, false);
+ }, 0);
+ }
+
+ componentWillUnmount() {
+ document.removeEventListener('keydown', this.handleKeyDown, false);
+ }
+
+ handleKeyDown = (event: KeyboardEvent) => {
+ if (event.keyCode === KeyCodes.Escape) {
+ this.props.onKeydown();
+ }
+ };
+
+ render() {
+ return this.props.children;
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/FavoriteButton.tsx b/server/sonar-ui-common/components/controls/FavoriteButton.tsx
new file mode 100644
index 00000000000..1b6dbf2ba2b
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/FavoriteButton.tsx
@@ -0,0 +1,53 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import { translate } from '../../helpers/l10n';
+import FavoriteIcon from '../icons/FavoriteIcon';
+import { ButtonLink } from './buttons';
+import Tooltip from './Tooltip';
+
+export interface Props {
+ className?: string;
+ favorite: boolean;
+ qualifier: string;
+ toggleFavorite: () => void;
+}
+
+export default class FavoriteButton extends React.PureComponent<Props> {
+ render() {
+ const { className, favorite, qualifier, toggleFavorite } = this.props;
+ const tooltip = favorite
+ ? translate('favorite.current', qualifier)
+ : translate('favorite.check', qualifier);
+ const ariaLabel = translate('favorite.action', favorite ? 'remove' : 'add');
+
+ return (
+ <Tooltip overlay={tooltip}>
+ <ButtonLink
+ aria-label={ariaLabel}
+ className={classNames('favorite-link', 'link-no-underline', className)}
+ onClick={toggleFavorite}>
+ <FavoriteIcon favorite={favorite} />
+ </ButtonLink>
+ </Tooltip>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/GlobalMessages.tsx b/server/sonar-ui-common/components/controls/GlobalMessages.tsx
new file mode 100644
index 00000000000..7c51ae50308
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/GlobalMessages.tsx
@@ -0,0 +1,124 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { keyframes } from '@emotion/core';
+import * as React from 'react';
+import { cutLongWords } from '../../helpers/path';
+import { styled, themeGet, themeSize } from '../theme';
+import { ClearButton } from './buttons';
+
+interface Message {
+ id: string;
+ level: 'ERROR' | 'SUCCESS';
+ message: string;
+}
+
+export interface GlobalMessagesProps {
+ closeGlobalMessage: (id: string) => void;
+ messages: Message[];
+}
+
+export default function GlobalMessages({ closeGlobalMessage, messages }: GlobalMessagesProps) {
+ if (messages.length === 0) {
+ return null;
+ }
+
+ return (
+ <MessagesContainer>
+ {messages.map((message) => (
+ <GlobalMessage closeGlobalMessage={closeGlobalMessage} key={message.id} message={message} />
+ ))}
+ </MessagesContainer>
+ );
+}
+
+const MessagesContainer = styled.div`
+ position: fixed;
+ z-index: ${themeGet('zIndexes', 'processContainerZIndex')};
+ top: 0;
+ left: 50%;
+ width: 350px;
+ margin-left: -175px;
+`;
+
+export class GlobalMessage extends React.PureComponent<{
+ closeGlobalMessage: (id: string) => void;
+ message: Message;
+}> {
+ handleClose = () => {
+ this.props.closeGlobalMessage(this.props.message.id);
+ };
+
+ render() {
+ const { message } = this.props;
+ return (
+ <Message
+ data-test={`global-message__${message.level}`}
+ level={message.level}
+ role={message.level === 'SUCCESS' ? 'status' : 'alert'}>
+ {cutLongWords(message.message)}
+ <CloseButton
+ className="button-small"
+ color="#fff"
+ level={message.level}
+ onClick={this.handleClose}
+ />
+ </Message>
+ );
+ }
+}
+
+const appearAnim = keyframes`
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+`;
+
+const Message = styled.div<Pick<Message, 'level'>>`
+ position: relative;
+ padding: 0 30px 0 10px;
+ line-height: ${themeSize('controlHeight')};
+ border-radius: 0 0 3px 3px;
+ box-sizing: border-box;
+ color: #ffffff;
+ background-color: ${({ level, theme }) =>
+ level === 'SUCCESS' ? theme.colors.green : theme.colors.red};
+ text-align: center;
+ opacity: 0;
+ animation: ${appearAnim} 0.2s ease forwards;
+
+ & + & {
+ margin-top: calc(${themeSize('gridSize')} / 2);
+ border-radius: 3px;
+ }
+`;
+
+const CloseButton = styled(ClearButton)<Pick<Message, 'level'>>`
+ position: absolute;
+ top: calc(${themeSize('gridSize')} / 4);
+ right: calc(${themeSize('gridSize')} / 4);
+
+ &:hover svg,
+ &:focus svg {
+ color: ${({ level, theme }) => (level === 'SUCCESS' ? theme.colors.green : theme.colors.red)};
+ }
+`;
diff --git a/server/sonar-ui-common/components/controls/HelpTooltip.css b/server/sonar-ui-common/components/controls/HelpTooltip.css
new file mode 100644
index 00000000000..bafc621852d
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/HelpTooltip.css
@@ -0,0 +1,31 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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.
+ */
+.help-tooltip {
+ display: inline-flex;
+ align-items: center;
+ vertical-align: middle;
+}
+
+.help-toolip-link {
+ display: block;
+ width: 12px;
+ height: 12px;
+ border: none;
+}
diff --git a/server/sonar-ui-common/components/controls/HelpTooltip.tsx b/server/sonar-ui-common/components/controls/HelpTooltip.tsx
new file mode 100644
index 00000000000..edf4ec65cf8
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/HelpTooltip.tsx
@@ -0,0 +1,66 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import HelpIcon from '../icons/HelpIcon';
+import { IconProps } from '../icons/Icon';
+import { ThemeConsumer } from '../theme';
+import './HelpTooltip.css';
+import Tooltip, { Placement } from './Tooltip';
+
+interface Props extends Pick<IconProps, 'size'> {
+ className?: string;
+ children?: React.ReactNode;
+ onShow?: () => void;
+ overlay: React.ReactNode;
+ placement?: Placement;
+}
+
+export default function HelpTooltip({ size = 12, ...props }: Props) {
+ return (
+ <div className={classNames('help-tooltip', props.className)}>
+ <Tooltip
+ mouseLeaveDelay={0.25}
+ onShow={props.onShow}
+ overlay={props.overlay}
+ placement={props.placement}>
+ <span className="display-inline-flex-center">
+ {props.children || (
+ <ThemeConsumer>
+ {(theme) => <HelpIcon fill={theme.colors.gray71} size={size} />}
+ </ThemeConsumer>
+ )}
+ </span>
+ </Tooltip>
+ </div>
+ );
+}
+
+export function DarkHelpTooltip({ size = 12, ...props }: Omit<Props, 'children'>) {
+ return (
+ <HelpTooltip {...props}>
+ <ThemeConsumer>
+ {({ colors }) => (
+ <HelpIcon fill={colors.transparentBlack} fillInner={colors.white} size={size} />
+ )}
+ </ThemeConsumer>
+ </HelpTooltip>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/IdentityProviderLink.css b/server/sonar-ui-common/components/controls/IdentityProviderLink.css
new file mode 100644
index 00000000000..6aff2f38126
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/IdentityProviderLink.css
@@ -0,0 +1,67 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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.
+ */
+a.identity-provider-link {
+ display: block;
+ width: auto;
+ line-height: 22px;
+ padding: var(--gridSize) calc(1.5 * var(--gridSize));
+ border: 1px solid rgba(0, 0, 0, 0.15);
+ border-radius: 2px;
+ box-sizing: border-box;
+ background-color: var(--darkBlue);
+ color: #fff;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+a.identity-provider-link.small {
+ line-height: 14px;
+ padding: calc(var(--gridSize) / 2) var(--gridSize);
+}
+
+a.identity-provider-link:hover,
+a.identity-provider-link:focus {
+ box-shadow: inset 0 0 0 100px rgba(255, 255, 255, 0.1);
+}
+
+a.identity-provider-link.dark-text {
+ color: var(--secondFontColor);
+}
+
+a.identity-provider-link.dark-text:hover,
+a.identity-provider-link.dark-text:focus {
+ box-shadow: inset 0 0 0 100px rgba(0, 0, 0, 0.1);
+}
+
+a.identity-provider-link > img {
+ padding-right: calc(1.5 * var(--gridSize));
+}
+
+a.identity-provider-link.small > img {
+ padding-right: var(--gridSize);
+}
+
+a.identity-provider-link > span::before {
+ content: '';
+ opacity: 0.4;
+ border-left: 1px var(--gray71) solid;
+ margin-right: calc(1.5 * var(--gridSize));
+}
diff --git a/server/sonar-ui-common/components/controls/IdentityProviderLink.tsx b/server/sonar-ui-common/components/controls/IdentityProviderLink.tsx
new file mode 100644
index 00000000000..bfcb25f2570
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/IdentityProviderLink.tsx
@@ -0,0 +1,63 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import { isDarkColor } from '../../helpers/colors';
+import { getBaseUrl } from '../../helpers/urls';
+import './IdentityProviderLink.css';
+
+interface Props {
+ backgroundColor: string;
+ children: React.ReactNode;
+ className?: string;
+ iconPath: string;
+ name: string;
+ onClick?: () => void;
+ small?: boolean;
+ url: string | undefined;
+}
+
+export default function IdentityProviderLink({
+ backgroundColor,
+ children,
+ className,
+ iconPath,
+ name,
+ onClick,
+ small,
+ url,
+}: Props) {
+ const size = small ? 14 : 20;
+
+ return (
+ <a
+ className={classNames(
+ 'identity-provider-link',
+ { 'dark-text': !isDarkColor(backgroundColor), small },
+ className
+ )}
+ href={url}
+ onClick={onClick}
+ style={{ backgroundColor }}>
+ <img alt={name} height={size} src={getBaseUrl() + iconPath} width={size} />
+ {children}
+ </a>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/InputValidationField.tsx b/server/sonar-ui-common/components/controls/InputValidationField.tsx
new file mode 100644
index 00000000000..e02baceab2d
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/InputValidationField.tsx
@@ -0,0 +1,52 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import ModalValidationField from './ModalValidationField';
+
+interface Props {
+ autoFocus?: boolean;
+ className?: string;
+ description?: string;
+ dirty: boolean;
+ disabled: boolean;
+ error: string | undefined;
+ id?: string;
+ label?: React.ReactNode;
+ name: string;
+ onBlur: (event: React.FocusEvent<any>) => void;
+ onChange: (event: React.ChangeEvent<any>) => void;
+ placeholder?: string;
+ touched: boolean | undefined;
+ type?: string;
+ value: string;
+}
+
+export default function InputValidationField({ className, ...props }: Props) {
+ const { description, dirty, error, label, touched, ...inputProps } = props;
+ const modalValidationProps = { description, dirty, error, label, touched };
+ return (
+ <ModalValidationField {...modalValidationProps}>
+ {({ className: validationClassName }) => (
+ <input className={classNames(className, validationClassName)} {...inputProps} />
+ )}
+ </ModalValidationField>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/ListFooter.tsx b/server/sonar-ui-common/components/controls/ListFooter.tsx
new file mode 100644
index 00000000000..04387913c46
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/ListFooter.tsx
@@ -0,0 +1,73 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import { translate, translateWithParameters } from '../../helpers/l10n';
+import { formatMeasure } from '../../helpers/measures';
+import DeferredSpinner from '../ui/DeferredSpinner';
+import { Button } from './buttons';
+
+export interface ListFooterProps {
+ count: number;
+ className?: string;
+ loading?: boolean;
+ loadMore?: () => void;
+ needReload?: boolean;
+ reload?: () => void;
+ ready?: boolean;
+ total?: number;
+}
+
+export default function ListFooter(props: ListFooterProps) {
+ const { className, count, loading, needReload, total, ready = true } = props;
+ const hasMore = total && total > count;
+
+ let button;
+ if (needReload && props.reload) {
+ button = (
+ <Button className="spacer-left" data-test="reload" disabled={loading} onClick={props.reload}>
+ {translate('reload')}
+ </Button>
+ );
+ } else if (hasMore && props.loadMore) {
+ button = (
+ <Button
+ className="spacer-left"
+ disabled={loading}
+ data-test="show-more"
+ onClick={props.loadMore}>
+ {translate('show_more')}
+ </Button>
+ );
+ }
+
+ return (
+ <footer
+ className={classNames('spacer-top note text-center', { 'new-loading': !ready }, className)}>
+ {translateWithParameters(
+ 'x_of_y_shown',
+ formatMeasure(count, 'INT', null),
+ formatMeasure(total, 'INT', null)
+ )}
+ {button}
+ {loading && <DeferredSpinner className="text-bottom spacer-left position-absolute" />}
+ </footer>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/Modal.css b/server/sonar-ui-common/components/controls/Modal.css
new file mode 100644
index 00000000000..a225712a219
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Modal.css
@@ -0,0 +1,211 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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.
+ */
+.modal,
+.ReactModal__Content {
+ position: fixed;
+ z-index: var(--modalZIndex);
+ top: 0;
+ left: 50%;
+ margin-left: -270px;
+ width: 540px;
+ background-color: #fff;
+ opacity: 0;
+ transition: all 0.2s ease;
+ border-radius: 3px;
+}
+
+.modal:focus,
+.ReactModal__Content:focus {
+ outline: none;
+}
+
+.modal.in,
+.ReactModal__Content--after-open {
+ top: 15%;
+ opacity: 1;
+}
+
+.modal-small {
+ width: 450px;
+ margin-left: -225px;
+}
+
+.modal-medium {
+ width: 830px;
+ margin-left: -415px;
+}
+
+.modal-large {
+ width: calc(100% - 40px);
+ max-width: 1280px;
+ min-width: 1040px;
+ margin-left: 0;
+ transform: translateX(-50%);
+}
+
+.modal-overlay,
+.ReactModal__Overlay {
+ position: fixed;
+ z-index: var(--modalOverlayZIndex);
+ top: 0;
+ bottom: 0;
+ left: 0;
+ right: 0;
+ background-color: rgba(0, 0, 0, 0.7);
+ opacity: 0;
+ transition: all 0.2s ease;
+}
+
+.modal-overlay.in,
+.ReactModal__Overlay--after-open {
+ opacity: 1;
+}
+
+.modal-no-backdrop {
+ background-color: transparent;
+}
+
+.modal-open,
+.ReactModal__Body--open {
+ overflow: hidden;
+ margin-right: var(--sbw);
+}
+
+.modal-head {
+ padding: calc(4 * var(--gridSize));
+ padding-bottom: 0;
+}
+
+.modal-head h1,
+.modal-head h2 {
+ margin: 0;
+ font-size: var(--bigFontSize);
+ font-weight: bold;
+ line-height: normal;
+ overflow-wrap: break-word;
+}
+
+.modal-body {
+ padding: var(--pagePadding) calc(4 * var(--gridSize));
+}
+
+.modal-container {
+ max-height: 60vh;
+ box-sizing: border-box;
+ overflow-y: auto;
+ border-top: 1px solid var(--barBorderColor);
+ margin-top: var(--pagePadding);
+ padding-right: calc(4 * var(--gridSize));
+}
+
+.modal-container > :last-child {
+ margin-bottom: var(--pagePadding);
+}
+
+.modal-field,
+.modal-validation-field {
+ clear: both;
+ display: block;
+ padding: 0;
+ margin-bottom: calc(var(--gridSize) * 2);
+}
+
+.modal-field label,
+.modal-validation-field label {
+ display: block;
+ font-weight: bold;
+ padding-bottom: calc(var(--gridSize) / 2);
+}
+
+.modal-field a.icon-checkbox,
+.modal-field input,
+.modal-field select,
+.modal-field textarea,
+.modal-field .Select {
+ margin-right: 5px;
+}
+
+.modal-field a.icon-checkbox {
+ height: 24px;
+}
+
+.modal-field input[type='radio'],
+.modal-field input[type='checkbox'] {
+ margin-top: 5px;
+ margin-bottom: 4px;
+}
+
+.modal-field > .icon-checkbox {
+ padding-top: 6px;
+ padding-right: 8px;
+}
+
+.modal-field input[type='text'],
+.modal-field input[type='email'],
+.modal-field input[type='password'],
+.modal-field textarea,
+.modal-field select,
+.modal-field .Select {
+ width: 100%;
+}
+
+.modal-validation-field input,
+.modal-validation-field textarea,
+.modal-validation-field .Select {
+ margin-right: var(--gridSize);
+ margin-bottom: 2px;
+ width: calc(100% - 3 * var(--gridSize));
+}
+
+.modal-field textarea,
+.modal-validation-field textarea {
+ max-width: 100%;
+ min-width: 100%;
+ max-height: 50vh;
+ min-height: var(--controlHeight);
+}
+.modal-validation-field input:not(.is-invalid),
+.modal-validation-field .Select:not(.is-invalid) {
+ margin-bottom: calc(var(--tinyControlHeight) + 2px);
+}
+
+.modal-field-description {
+ line-height: 1.4;
+ color: var(--secondFontColor);
+ font-size: var(--smallFontSize);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ margin-top: 2px;
+}
+
+.modal-foot {
+ padding: var(--pagePadding) calc(4 * var(--gridSize));
+ border-top: 1px solid var(--barBorderColor);
+ background-color: var(--barBackgroundColor);
+ border-radius: 3px;
+ text-align: right;
+}
+
+.modal-foot button,
+.modal-foot .button,
+.modal-foot input[type='submit'],
+.modal-foot input[type='button'] {
+ margin-left: var(--gridSize);
+}
diff --git a/server/sonar-ui-common/components/controls/Modal.tsx b/server/sonar-ui-common/components/controls/Modal.tsx
new file mode 100644
index 00000000000..91acdeab832
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Modal.tsx
@@ -0,0 +1,76 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import * as ReactModal from 'react-modal';
+import { getReactDomContainerSelector } from '../../helpers/init';
+import './Modal.css';
+
+ReactModal.setAppElement(getReactDomContainerSelector());
+
+export interface ModalProps {
+ children: React.ReactNode;
+ size?: 'small' | 'medium' | 'large';
+ noBackdrop?: boolean;
+}
+
+interface Props extends ModalProps {
+ /* String or object className to be applied to the modal content. */
+ className?: string;
+
+ /* String or object className to be applied to the overlay. */
+ overlayClassName?: string;
+
+ /* Function that will be run after the modal has opened. */
+ onAfterOpen?(): void;
+
+ /* Function that will be run after the modal has closed. */
+ onAfterClose?(): void;
+
+ /* Function that will be run when the modal is requested to be closed, prior to actually closing. */
+ onRequestClose?(event: React.MouseEvent | React.KeyboardEvent): void;
+
+ /* Boolean indicating if the modal should be focused after render */
+ shouldFocusAfterRender?: boolean;
+
+ /* Boolean indicating if the overlay should close the modal. Defaults to true. */
+ shouldCloseOnOverlayClick?: boolean;
+
+ /* Boolean indicating if pressing the esc key should close the modal */
+ shouldCloseOnEsc?: boolean;
+
+ /* String indicating how the content container should be announced to screenreaders. */
+ contentLabel: string;
+}
+
+export default function Modal(props: Props) {
+ return (
+ <ReactModal
+ className={classNames('modal', {
+ 'modal-small': props.size === 'small',
+ 'modal-medium': props.size === 'medium',
+ 'modal-large': props.size === 'large',
+ })}
+ isOpen={true}
+ overlayClassName={classNames('modal-overlay', { 'modal-no-backdrop': props.noBackdrop })}
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/ModalButton.tsx b/server/sonar-ui-common/components/controls/ModalButton.tsx
new file mode 100644
index 00000000000..44e43f5f026
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/ModalButton.tsx
@@ -0,0 +1,80 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 interface ChildrenProps {
+ onClick: () => void;
+ onFormSubmit: (event: React.FormEvent<HTMLFormElement>) => void;
+}
+
+export interface ModalProps {
+ onClose: () => void;
+}
+
+export interface Props {
+ children: (props: ChildrenProps) => React.ReactNode;
+ modal: (props: ModalProps) => React.ReactNode;
+}
+
+interface State {
+ modal: boolean;
+}
+
+export default class ModalButton extends React.PureComponent<Props, State> {
+ mounted = false;
+ state: State = { modal: false };
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ handleButtonClick = () => {
+ this.setState({ modal: true });
+ };
+
+ handleFormSubmit = (event?: React.FormEvent<HTMLFormElement>) => {
+ if (event) {
+ event.preventDefault();
+ }
+ this.setState({ modal: true });
+ };
+
+ handleCloseModal = () => {
+ if (this.mounted) {
+ this.setState({ modal: false });
+ }
+ };
+
+ render() {
+ return (
+ <>
+ {this.props.children({
+ onClick: this.handleButtonClick,
+ onFormSubmit: this.handleFormSubmit,
+ })}
+ {this.state.modal && this.props.modal({ onClose: this.handleCloseModal })}
+ </>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/ModalValidationField.tsx b/server/sonar-ui-common/components/controls/ModalValidationField.tsx
new file mode 100644
index 00000000000..c5eeff11227
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/ModalValidationField.tsx
@@ -0,0 +1,49 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import AlertErrorIcon from '../icons/AlertErrorIcon';
+import AlertSuccessIcon from '../icons/AlertSuccessIcon';
+
+interface Props {
+ children: (props: { className?: string }) => React.ReactNode;
+ description?: string;
+ dirty: boolean;
+ error: string | undefined;
+ label?: React.ReactNode;
+ touched: boolean | undefined;
+}
+
+export default function ModalValidationField(props: Props) {
+ const { description, dirty, error } = props;
+
+ const isValid = dirty && props.touched && error === undefined;
+ const showError = dirty && props.touched && error !== undefined;
+ return (
+ <div className="modal-validation-field">
+ {props.label}
+ {props.children({ className: classNames({ 'is-invalid': showError, 'is-valid': isValid }) })}
+ {showError && <AlertErrorIcon className="little-spacer-top" />}
+ {isValid && <AlertSuccessIcon className="little-spacer-top" />}
+ {showError && <p className="text-danger">{error}</p>}
+ {description && <div className="modal-field-description">{description}</div>}
+ </div>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/OutsideClickHandler.tsx b/server/sonar-ui-common/components/controls/OutsideClickHandler.tsx
new file mode 100644
index 00000000000..c7c86ba1a2b
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/OutsideClickHandler.tsx
@@ -0,0 +1,64 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { findDOMNode } from 'react-dom';
+
+interface Props {
+ children: React.ReactNode;
+ onClickOutside: () => void;
+}
+
+export default class OutsideClickHandler extends React.Component<Props> {
+ mounted = false;
+
+ componentDidMount() {
+ this.mounted = true;
+ setTimeout(() => {
+ this.addClickHandler();
+ }, 0);
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ this.removeClickHandler();
+ }
+
+ addClickHandler = () => {
+ window.addEventListener('click', this.handleWindowClick);
+ };
+
+ removeClickHandler = () => {
+ window.removeEventListener('click', this.handleWindowClick);
+ };
+
+ handleWindowClick = (event: MouseEvent) => {
+ if (this.mounted) {
+ // eslint-disable-next-line react/no-find-dom-node
+ const node = findDOMNode(this);
+ if (!node || !node.contains(event.target as Node)) {
+ this.props.onClickOutside();
+ }
+ }
+ };
+
+ render() {
+ return this.props.children;
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/Radio.css b/server/sonar-ui-common/components/controls/Radio.css
new file mode 100644
index 00000000000..172c89ca507
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Radio.css
@@ -0,0 +1,77 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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.
+ */
+
+.icon-radio {
+ position: relative;
+ display: inline-block;
+ vertical-align: top;
+ width: 14px;
+ height: 14px;
+ margin: 1px;
+ border: 1px solid var(--gray80);
+ border-radius: 12px;
+ box-sizing: border-box;
+ transition: border-color 0.3s ease;
+ flex-shrink: 0;
+}
+
+.icon-radio:after {
+ position: absolute;
+ top: 2px;
+ left: 2px;
+ display: block;
+ width: 8px;
+ height: 8px;
+ border-radius: 8px;
+ background-color: var(--darkBlue);
+ content: '';
+ opacity: 0;
+ transition: opacity 0.3s ease;
+}
+
+.link-radio .icon-radio.is-checked:after {
+ opacity: 1;
+}
+
+.link-radio {
+ color: inherit;
+ border-bottom: none;
+}
+
+.link-radio:not(.disabled):hover,
+.link-radio:not(.disabled):active,
+.link-radio:not(.disabled):focus {
+ color: inherit;
+}
+
+.link-radio:not(.disabled):hover > .icon-radio {
+ border-color: var(--blue);
+}
+
+.link-radio.disabled,
+.link-radio.disabled:hover,
+.link-radio.disabled label {
+ color: var(--disableGrayText);
+ cursor: not-allowed;
+}
+
+.link-radio.disabled .icon-radio:after {
+ background-color: var(--disableGrayBg);
+}
diff --git a/server/sonar-ui-common/components/controls/Radio.tsx b/server/sonar-ui-common/components/controls/Radio.tsx
new file mode 100644
index 00000000000..1095a30f078
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Radio.tsx
@@ -0,0 +1,56 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import './Radio.css';
+
+interface Props {
+ checked: boolean;
+ className?: string;
+ disabled?: boolean;
+ onCheck: (value: string) => void;
+ value: string;
+}
+
+export default class Radio extends React.PureComponent<Props> {
+ handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+
+ if (!this.props.disabled) {
+ this.props.onCheck(this.props.value);
+ }
+ };
+
+ render() {
+ const { className, checked, children, disabled } = this.props;
+
+ return (
+ <a
+ aria-checked={checked}
+ className={classNames('display-inline-flex-center link-radio', className, { disabled })}
+ href="#"
+ onClick={this.handleClick}
+ role="radio">
+ <i className={classNames('icon-radio', 'spacer-right', { 'is-checked': checked })} />
+ {children}
+ </a>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/RadioCard.css b/server/sonar-ui-common/components/controls/RadioCard.css
new file mode 100644
index 00000000000..4afbef07bd9
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/RadioCard.css
@@ -0,0 +1,134 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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.
+ */
+.radio-card {
+ display: flex;
+ flex-direction: column;
+ width: 450px;
+ min-height: 210px;
+ background-color: #fff;
+ border: solid 1px var(--barBorderColor);
+ border-radius: 3px;
+ box-sizing: border-box;
+ margin-right: calc(2 * var(--gridSize));
+ transition: all 0.2s ease;
+}
+
+.radio-card.animated {
+ height: 0;
+ border-width: 0;
+ overflow: hidden;
+}
+
+.radio-card.animated.open {
+ height: 210px;
+ border-width: 1px;
+}
+
+.radio-card.highlight {
+ box-shadow: var(--defaultShadow);
+}
+
+.radio-card:last-child {
+ margin-right: 0;
+}
+
+.radio-card:focus {
+ outline: none;
+}
+
+.radio-card-vertical {
+ width: 100%;
+ min-height: auto;
+}
+
+.radio-card-actionable {
+ cursor: pointer;
+}
+
+.radio-card-actionable:not(.disabled):hover {
+ box-shadow: var(--defaultShadow);
+ transform: translateY(-2px);
+}
+
+.radio-card-actionable.selected {
+ border-color: var(--darkBlue);
+}
+
+/*
+ * Disabled transform property because it moves the element to a new stacking context
+ * creating z-index conflicts with other components.
+ * This is a problem with a vertical list of RadioCards where a select might be above another RadioCard
+ */
+.radio-card-actionable.radio-card-vertical:not(.disabled):hover {
+ box-shadow: none;
+ transform: none;
+}
+
+.radio-card-actionable.radio-card-vertical:not(.selected):not(.disabled):hover {
+ border-color: var(--lightBlue);
+}
+
+.radio-card-actionable.selected .radio-card-recommended {
+ border: solid 1px var(--darkBlue);
+ border-top: none;
+}
+
+.radio-card-actionable.disabled {
+ cursor: not-allowed;
+ background-color: var(--disableGrayBg);
+ border-color: var(--disableGrayBorder);
+}
+
+.radio-card-actionable.disabled h2,
+.radio-card-actionable.disabled ul {
+ color: var(--disableGrayText);
+}
+
+.radio-card-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: calc(2 * var(--gridSize)) calc(2 * var(--gridSize)) 0;
+}
+
+.radio-card-body {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ padding: 0 calc(2 * var(--gridSize)) calc(2 * var(--gridSize));
+}
+
+.radio-card-body .alert {
+ margin-bottom: 0;
+}
+
+.radio-card-recommended {
+ position: relative;
+ padding: 6px calc(var(--gridSize) * 2);
+ left: -1px;
+ bottom: -1px;
+ width: 450px;
+ color: #fff;
+ background-color: var(--blue);
+ border-radius: 0 0 3px 3px;
+ box-sizing: border-box;
+ font-size: var(--smallFontSize);
+}
diff --git a/server/sonar-ui-common/components/controls/RadioCard.tsx b/server/sonar-ui-common/components/controls/RadioCard.tsx
new file mode 100644
index 00000000000..e6501995d27
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/RadioCard.tsx
@@ -0,0 +1,92 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { translate } from '../../helpers/l10n';
+import RecommendedIcon from '../icons/RecommendedIcon';
+import './Radio.css';
+import './RadioCard.css';
+
+export interface RadioCardProps {
+ className?: string;
+ disabled?: boolean;
+ onClick?: () => void;
+ selected?: boolean;
+}
+
+interface Props extends RadioCardProps {
+ children: React.ReactNode;
+ recommended?: string;
+ title: React.ReactNode;
+ titleInfo?: React.ReactNode;
+ vertical?: boolean;
+}
+
+export default function RadioCard(props: Props) {
+ const {
+ className,
+ disabled,
+ onClick,
+ recommended,
+ selected,
+ titleInfo,
+ vertical = false,
+ } = props;
+ const isActionable = Boolean(onClick);
+ return (
+ <div
+ aria-checked={selected}
+ className={classNames(
+ 'radio-card',
+ {
+ 'radio-card-actionable': isActionable,
+ 'radio-card-vertical': vertical,
+ disabled,
+ selected,
+ },
+ className
+ )}
+ onClick={isActionable && !disabled ? onClick : undefined}
+ role="radio"
+ tabIndex={0}>
+ <h2 className="radio-card-header big-spacer-bottom">
+ <span className="display-flex-center link-radio">
+ {isActionable && (
+ <i className={classNames('icon-radio', 'spacer-right', { 'is-checked': selected })} />
+ )}
+ {props.title}
+ </span>
+ {titleInfo}
+ </h2>
+ <div className="radio-card-body">{props.children}</div>
+ {recommended && (
+ <div className="radio-card-recommended">
+ <RecommendedIcon className="spacer-right" />
+ <FormattedMessage
+ defaultMessage={recommended}
+ id={recommended}
+ values={{ recommended: <strong>{translate('recommended')}</strong> }}
+ />
+ </div>
+ )}
+ </div>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/RadioToggle.css b/server/sonar-ui-common/components/controls/RadioToggle.css
new file mode 100644
index 00000000000..747c4a44d39
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/RadioToggle.css
@@ -0,0 +1,73 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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.
+ */
+.radio-toggle {
+ display: inline-block;
+ vertical-align: middle;
+ font-size: 0;
+ white-space: nowrap;
+}
+
+.radio-toggle > li {
+ display: inline-block;
+ vertical-align: middle;
+ font-size: var(--smallFontSize);
+}
+
+.radio-toggle > li:first-child > label {
+ border-top-left-radius: 2px;
+ border-bottom-left-radius: 2px;
+}
+
+.radio-toggle > li:last-child > label {
+ border-top-right-radius: 2px;
+ border-bottom-right-radius: 2px;
+}
+
+.radio-toggle > li + li > label {
+ border-left: none;
+}
+
+.radio-toggle > li > label {
+ display: inline-block;
+ padding: 0 12px;
+ margin: 0;
+ border: 1px solid var(--darkBlue);
+ color: var(--darkBlue);
+ height: calc(var(--controlHeight) - 2px);
+ line-height: calc(var(--controlHeight) - 2px);
+ cursor: pointer;
+ font-weight: 600;
+}
+
+.radio-toggle input[type='radio'] {
+ display: none;
+}
+
+.radio-toggle input[type='radio']:checked + label {
+ background-color: var(--darkBlue);
+ color: #fff;
+}
+
+.radio-toggle input[type='radio']:disabled + label {
+ color: var(--disableGrayText);
+ border-color: var(--disableGrayBorder);
+ background: var(--disableGrayBg);
+ cursor: not-allowed;
+}
diff --git a/server/sonar-ui-common/components/controls/RadioToggle.tsx b/server/sonar-ui-common/components/controls/RadioToggle.tsx
new file mode 100644
index 00000000000..1ec9ed3e0c3
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/RadioToggle.tsx
@@ -0,0 +1,74 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import './RadioToggle.css';
+import Tooltip from './Tooltip';
+
+type ToggleValueType = string | number | boolean;
+interface Option {
+ disabled?: boolean;
+ label: string;
+ tooltip?: string;
+ value: ToggleValueType;
+}
+
+interface Props {
+ className?: string;
+ name: string;
+ onCheck: (value: ToggleValueType) => void;
+ options: Option[];
+ value?: ToggleValueType;
+}
+
+export default class RadioToggle extends React.PureComponent<Props> {
+ static defaultProps = {
+ disabled: false,
+ value: null,
+ };
+
+ renderOption = (option: Option) => {
+ const checked = option.value === this.props.value;
+ const htmlId = this.props.name + '__' + option.value;
+ return (
+ <li key={option.value.toString()}>
+ <input
+ checked={checked}
+ disabled={option.disabled}
+ id={htmlId}
+ name={this.props.name}
+ onChange={() => this.props.onCheck(option.value)}
+ type="radio"
+ />
+ <Tooltip overlay={option.tooltip || undefined}>
+ <label htmlFor={htmlId}>{option.label}</label>
+ </Tooltip>
+ </li>
+ );
+ };
+
+ render() {
+ return (
+ <ul className={classNames('radio-toggle', this.props.className)}>
+ {this.props.options.map(this.renderOption)}
+ </ul>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/ReloadButton.tsx b/server/sonar-ui-common/components/controls/ReloadButton.tsx
new file mode 100644
index 00000000000..69d3bb3702a
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/ReloadButton.tsx
@@ -0,0 +1,63 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import { translate } from '../../helpers/l10n';
+import { ThemeConsumer } from '../theme';
+import Tooltip from './Tooltip';
+
+interface Props {
+ className?: string;
+ tooltip?: string;
+ onClick: () => void;
+}
+
+export default class ReloadButton extends React.PureComponent<Props> {
+ handleClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ event.currentTarget.blur();
+ this.props.onClick();
+ };
+
+ render() {
+ const { tooltip = translate('reload') } = this.props;
+ return (
+ <Tooltip overlay={tooltip}>
+ <a
+ className={classNames('link-no-underline', this.props.className)}
+ href="#"
+ onClick={this.handleClick}>
+ {
+ <ThemeConsumer>
+ {(theme) => (
+ <svg height="24" viewBox="0 0 18 24" width="18">
+ <path
+ d="M16.6454 8.1084c-.3-.5-.9-.7-1.4-.4-.5.3-.7.9-.4 1.4.9 1.6 1.1 3.4.6 5.1-.5 1.7-1.7 3.2-3.2 4-3.3 1.8-7.4.6-9.1-2.7-1.8-3.1-.8-6.9 2.1-8.8v3.3h2v-7h-7v2h3.9c-3.7 2.5-5 7.5-2.8 11.4 1.6 3 4.6 4.6 7.7 4.6 1.4 0 2.8-.3 4.2-1.1 2-1.1 3.5-3 4.2-5.2.6-2.2.3-4.6-.8-6.6z"
+ fill={theme.colors.secondFontColor}
+ />
+ </svg>
+ )}
+ </ThemeConsumer>
+ }
+ </a>
+ </Tooltip>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/ScreenPositionFixer.tsx b/server/sonar-ui-common/components/controls/ScreenPositionFixer.tsx
new file mode 100644
index 00000000000..69c00f8404e
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/ScreenPositionFixer.tsx
@@ -0,0 +1,117 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { throttle } from 'lodash';
+import * as React from 'react';
+import { findDOMNode } from 'react-dom';
+import { Theme, withTheme } from '../theme';
+
+interface Props {
+ /**
+ * First time `children` are rendered with `undefined` fixes to measure the offset.
+ * Second time it renders with the computed fixes.
+ */
+ children: (props: Fixes) => React.ReactNode;
+
+ /**
+ * Use this flag to force re-positioning.
+ * Use cases:
+ * - when you need to measure `children` size first
+ * - when you load content asynchronously
+ */
+ ready?: boolean;
+ theme: Theme;
+}
+
+interface Fixes {
+ leftFix?: number;
+ topFix?: number;
+}
+
+export class ScreenPositionFixer extends React.Component<Props, Fixes> {
+ throttledPosition: () => void;
+
+ constructor(props: Props) {
+ super(props);
+ this.state = {};
+ this.throttledPosition = throttle(this.position, 50);
+ }
+
+ componentDidMount() {
+ this.addEventListeners();
+ this.position();
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ if (!prevProps.ready && this.props.ready) {
+ this.position();
+ } else if (prevProps.ready && !this.props.ready) {
+ this.reset();
+ }
+ }
+
+ componentWillUnmount() {
+ this.removeEventListeners();
+ }
+
+ addEventListeners = () => {
+ window.addEventListener('resize', this.throttledPosition);
+ };
+
+ removeEventListeners = () => {
+ window.removeEventListener('resize', this.throttledPosition);
+ };
+
+ reset = () => {
+ this.setState({ leftFix: undefined, topFix: undefined });
+ };
+
+ position = () => {
+ const edgeMargin = 0.5 * this.props.theme.rawSizes.grid;
+
+ // eslint-disable-next-line react/no-find-dom-node
+ const node = findDOMNode(this);
+ if (node && node instanceof Element) {
+ const { width, height, left, top } = node.getBoundingClientRect();
+ const { clientHeight, clientWidth } = document.documentElement;
+
+ let leftFix = 0;
+ if (left < edgeMargin) {
+ leftFix = edgeMargin - left;
+ } else if (left + width > clientWidth - edgeMargin) {
+ leftFix = clientWidth - edgeMargin - left - width;
+ }
+
+ let topFix = 0;
+ if (top < edgeMargin) {
+ topFix = edgeMargin - top;
+ } else if (top + height > clientHeight - edgeMargin) {
+ topFix = clientHeight - edgeMargin - top - height;
+ }
+
+ this.setState({ leftFix, topFix });
+ }
+ };
+
+ render() {
+ return this.props.children(this.state);
+ }
+}
+
+export default withTheme(ScreenPositionFixer);
diff --git a/server/sonar-ui-common/components/controls/SearchBox.css b/server/sonar-ui-common/components/controls/SearchBox.css
new file mode 100644
index 00000000000..412058a9792
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/SearchBox.css
@@ -0,0 +1,104 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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.
+ */
+.search-box {
+ position: relative;
+ display: inline-block;
+ vertical-align: middle;
+ font-size: 0;
+ white-space: nowrap;
+}
+
+.search-box,
+.search-box-input {
+ width: 100%;
+ max-width: 300px;
+}
+
+.search-box-input {
+ /* for magnifier icon */
+ padding-left: var(--controlHeight) !important;
+ /* for clear button */
+ padding-right: var(--controlHeight) !important;
+ font-size: var(--baseFontSize);
+}
+
+.search-box-input::-webkit-search-decoration,
+.search-box-input::-webkit-search-cancel-button,
+.search-box-input::-webkit-search-results-button,
+.search-box-input::-webkit-search-results-decoration {
+ -webkit-appearance: none;
+ display: none;
+}
+
+.search-box-input::-ms-clear,
+.search-box-input::-ms-reveal {
+ display: none;
+ width: 0;
+ height: 0;
+}
+
+.search-box-note {
+ position: absolute;
+ top: 1px;
+ left: 40px;
+ right: var(--controlHeight);
+ line-height: var(--controlHeight);
+ color: var(--secondFontColor);
+ font-size: var(--smallFontSize);
+ text-align: right;
+ text-overflow: ellipsis;
+ overflow: hidden;
+ white-space: nowrap;
+ pointer-events: none;
+}
+
+.search-box-input:focus ~ .search-box-magnifier {
+ color: var(--blue);
+}
+
+.search-box-magnifier {
+ position: absolute;
+ top: 4px;
+ left: 4px;
+ color: var(--gray60);
+ transition: color 0.3s ease;
+}
+
+.search-box > .deferred-spinner {
+ position: absolute;
+ top: 4px;
+ left: 5px;
+}
+
+.search-box-clear {
+ position: absolute;
+ top: 4px;
+ right: 4px;
+}
+
+.search-box-input-note {
+ position: absolute;
+ top: 100%;
+ left: 0;
+ line-height: 1;
+ color: var(--secondFontColor);
+ font-size: var(--smallFontSize);
+ white-space: nowrap;
+}
diff --git a/server/sonar-ui-common/components/controls/SearchBox.tsx b/server/sonar-ui-common/components/controls/SearchBox.tsx
new file mode 100644
index 00000000000..69272bb5721
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/SearchBox.tsx
@@ -0,0 +1,176 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import { Cancelable, debounce } from 'lodash';
+import * as React from 'react';
+import { translate, translateWithParameters } from '../../helpers/l10n';
+import SearchIcon from '../icons/SearchIcon';
+import DeferredSpinner from '../ui/DeferredSpinner';
+import { ClearButton } from './buttons';
+import './SearchBox.css';
+
+interface Props {
+ autoFocus?: boolean;
+ className?: string;
+ id?: string;
+ innerRef?: (node: HTMLInputElement | null) => void;
+ loading?: boolean;
+ maxLength?: number;
+ minLength?: number;
+ onChange: (value: string) => void;
+ onClick?: React.MouseEventHandler<HTMLInputElement>;
+ onFocus?: React.FocusEventHandler<HTMLInputElement>;
+ onKeyDown?: React.KeyboardEventHandler<HTMLInputElement>;
+ placeholder: string;
+ value?: string;
+}
+
+interface State {
+ value: string;
+}
+
+const DEFAULT_MAX_LENGTH = 100;
+
+export default class SearchBox extends React.PureComponent<Props, State> {
+ debouncedOnChange: ((query: string) => void) & Cancelable;
+ input?: HTMLInputElement | null;
+
+ constructor(props: Props) {
+ super(props);
+ this.state = { value: props.value || '' };
+ this.debouncedOnChange = debounce(this.props.onChange, 250);
+ }
+
+ componentDidUpdate(prevProps: Props) {
+ if (
+ // input is controlled
+ this.props.value !== undefined &&
+ // parent is aware of last change
+ // can happen when previous value was less than min length
+ this.state.value === prevProps.value &&
+ this.state.value !== this.props.value
+ ) {
+ this.setState({ value: this.props.value });
+ }
+ }
+
+ changeValue = (value: string, debounced = true) => {
+ const { minLength } = this.props;
+ if (value.length === 0) {
+ // immediately notify when value is empty
+ this.props.onChange('');
+ // and cancel scheduled callback
+ this.debouncedOnChange.cancel();
+ } else if (!minLength || minLength <= value.length) {
+ if (debounced) {
+ this.debouncedOnChange(value);
+ } else {
+ this.props.onChange(value);
+ }
+ }
+ };
+
+ handleInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
+ const { value } = event.currentTarget;
+ this.setState({ value });
+ this.changeValue(value);
+ };
+
+ handleInputKeyDown = (event: React.KeyboardEvent<HTMLInputElement>) => {
+ if (event.keyCode === 27) {
+ // escape
+ event.preventDefault();
+ this.handleResetClick();
+ }
+ if (this.props.onKeyDown) {
+ this.props.onKeyDown(event);
+ }
+ };
+
+ handleResetClick = () => {
+ this.changeValue('', false);
+ if (this.props.value === undefined || this.props.value === '') {
+ this.setState({ value: '' });
+ }
+ if (this.input) {
+ this.input.focus();
+ }
+ };
+
+ ref = (node: HTMLInputElement | null) => {
+ this.input = node;
+ if (this.props.innerRef) {
+ this.props.innerRef(node);
+ }
+ };
+
+ render() {
+ const { loading, minLength, maxLength = DEFAULT_MAX_LENGTH } = this.props;
+ const { value } = this.state;
+
+ const inputClassName = classNames('search-box-input', {
+ touched: value.length > 0 && (!minLength || minLength > value.length),
+ });
+
+ const tooShort = minLength !== undefined && value.length > 0 && value.length < minLength;
+
+ return (
+ <div
+ className={classNames('search-box', this.props.className)}
+ id={this.props.id}
+ title={tooShort ? translateWithParameters('select2.tooShort', minLength!) : ''}>
+ <input
+ aria-label={translate('search_verb')}
+ autoComplete="off"
+ autoFocus={this.props.autoFocus}
+ className={inputClassName}
+ maxLength={maxLength}
+ onChange={this.handleInputChange}
+ onClick={this.props.onClick}
+ onFocus={this.props.onFocus}
+ onKeyDown={this.handleInputKeyDown}
+ placeholder={this.props.placeholder}
+ ref={this.ref}
+ type="search"
+ value={value}
+ />
+
+ <DeferredSpinner loading={loading !== undefined ? loading : false}>
+ <SearchIcon className="search-box-magnifier" />
+ </DeferredSpinner>
+
+ {value && (
+ <ClearButton
+ aria-label={translate('clear')}
+ className="button-tiny search-box-clear"
+ iconProps={{ size: 12 }}
+ onClick={this.handleResetClick}
+ />
+ )}
+
+ {tooShort && (
+ <span className="search-box-note">
+ {translateWithParameters('select2.tooShort', minLength!)}
+ </span>
+ )}
+ </div>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/SearchSelect.tsx b/server/sonar-ui-common/components/controls/SearchSelect.tsx
new file mode 100644
index 00000000000..c446f35e7d1
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/SearchSelect.tsx
@@ -0,0 +1,154 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { debounce } from 'lodash';
+import * as React from 'react';
+import { translate, translateWithParameters } from '../../helpers/l10n';
+import Select, { Creatable } from './Select';
+
+interface Props<T> {
+ autofocus?: boolean;
+ canCreate?: boolean;
+ className?: string;
+ clearable?: boolean;
+ defaultOptions?: T[];
+ minimumQueryLength?: number;
+ multi?: boolean;
+ onSearch: (query: string) => Promise<T[]>;
+ onSelect?: (option: T) => void;
+ onMultiSelect?: (options: T[]) => void;
+ promptTextCreator?: (label: string) => string;
+ renderOption?: (option: T) => JSX.Element;
+ resetOnBlur?: boolean;
+ value?: T | T[];
+}
+
+interface State<T> {
+ loading: boolean;
+ options: T[];
+ query: string;
+}
+
+export default class SearchSelect<T extends { value: string }> extends React.PureComponent<
+ Props<T>,
+ State<T>
+> {
+ mounted = false;
+
+ constructor(props: Props<T>) {
+ super(props);
+ this.state = { loading: false, options: props.defaultOptions || [], query: '' };
+ this.handleSearch = debounce(this.handleSearch, 250);
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ get autofocus() {
+ return this.props.autofocus !== undefined ? this.props.autofocus : true;
+ }
+
+ get minimumQueryLength() {
+ return this.props.minimumQueryLength !== undefined ? this.props.minimumQueryLength : 2;
+ }
+
+ get resetOnBlur() {
+ return this.props.resetOnBlur !== undefined ? this.props.resetOnBlur : true;
+ }
+
+ handleSearch = (query: string) => {
+ // Ignore the result if the query changed
+ const currentQuery = query;
+ this.props.onSearch(currentQuery).then(
+ (options) => {
+ if (this.mounted) {
+ this.setState((state) => ({
+ loading: false,
+ options: state.query === currentQuery ? options : state.options,
+ }));
+ }
+ },
+ () => {
+ if (this.mounted) {
+ this.setState({ loading: false });
+ }
+ }
+ );
+ };
+
+ handleChange = (option: T | T[]) => {
+ if (Array.isArray(option)) {
+ if (this.props.onMultiSelect) {
+ this.props.onMultiSelect(option);
+ }
+ } else if (this.props.onSelect) {
+ this.props.onSelect(option);
+ }
+ };
+
+ handleInputChange = (query: string) => {
+ if (query.length >= this.minimumQueryLength) {
+ this.setState({ loading: true, query });
+ this.handleSearch(query);
+ } else {
+ // `onInputChange` is called with an empty string after a user selects a value
+ // in this case we shouldn't reset `options`, because it also resets select value :(
+ const options = (query.length === 0 && this.props.defaultOptions) || [];
+ this.setState({ options, query });
+ }
+ };
+
+ // disable internal filtering
+ handleFilterOption = () => true;
+
+ render() {
+ const Component = this.props.canCreate ? Creatable : Select;
+ return (
+ <Component
+ autoFocus={this.autofocus}
+ className={this.props.className}
+ clearable={this.props.clearable}
+ escapeClearsValue={false}
+ filterOption={this.handleFilterOption}
+ isLoading={this.state.loading}
+ multi={this.props.multi}
+ noResultsText={
+ this.state.query.length < this.minimumQueryLength
+ ? translateWithParameters('select2.tooShort', this.minimumQueryLength)
+ : translate('select2.noMatches')
+ }
+ onBlurResetsInput={this.resetOnBlur}
+ onChange={this.handleChange}
+ onInputChange={this.handleInputChange}
+ optionRenderer={this.props.renderOption}
+ options={this.state.options}
+ placeholder={translate('search_verb')}
+ promptTextCreator={this.props.promptTextCreator}
+ searchable={true}
+ value={this.props.value}
+ valueRenderer={this.props.renderOption}
+ />
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/Select.css b/server/sonar-ui-common/components/controls/Select.css
new file mode 100644
index 00000000000..db74eb89d39
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Select.css
@@ -0,0 +1,477 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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.
+ */
+.Select {
+ position: relative;
+ display: inline-block;
+ vertical-align: middle;
+ font-size: var(--smallFontSize);
+ text-align: left;
+}
+
+.Select,
+.Select div,
+.Select input,
+.Select span {
+ box-sizing: border-box;
+}
+
+.Select.is-disabled > .Select-control {
+ background-color: var(--disableGrayBg) !important;
+ border-color: var(--disableGrayBorder) !important;
+}
+
+.Select.is-disabled > .Select-control:hover {
+ box-shadow: none !important;
+}
+
+.Select.is-disabled .Select-arrow-zone {
+ cursor: not-allowed !important;
+ pointer-events: none !important;
+}
+
+.Select.is-disabled .Select-placeholder,
+.Select.is-disabled .Select-value {
+ color: var(--disableGrayText) !important;
+}
+
+.Select-control {
+ position: relative;
+ display: table;
+ width: 100%;
+ height: var(--controlHeight);
+ line-height: calc(var(--controlHeight) - 2px);
+ border: 1px solid var(--gray80);
+ border-collapse: separate;
+ border-radius: 2px;
+ background-color: #fff;
+ color: var(--baseFontColor);
+ cursor: default;
+ outline: none;
+ overflow: hidden;
+}
+
+.is-searchable.is-open > .Select-control {
+ cursor: text;
+}
+
+.is-open > .Select-control {
+ border-bottom-right-radius: 0;
+ border-bottom-left-radius: 0;
+ background: #fff;
+}
+
+.is-open > .Select-control > .Select-arrow {
+ border-color: transparent transparent #999;
+ border-width: 0 5px 5px;
+}
+
+.is-searchable.is-focused:not(.is-open) > .Select-control {
+ cursor: text;
+}
+
+.is-focused:not(.is-open) > .Select-control {
+ border-color: var(--blue);
+}
+
+.Select-placeholder {
+ color: var(--secondFontColor);
+}
+
+.Select-placeholder,
+:not(.Select--multi) > .Select-control .Select-value {
+ bottom: 0;
+ left: 0;
+ line-height: 23px;
+ padding-left: 8px;
+ padding-right: 24px;
+ position: absolute;
+ right: 0;
+ top: 0;
+ max-width: 100%;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.Select-value [class^='icon-'] {
+ padding-top: 5px;
+}
+
+.Select-value svg,
+.Select-value img {
+ padding-top: 3px;
+}
+
+.Select-option svg,
+.Select-option img,
+.Select-option [class^='icon-'] {
+ padding-top: 2px;
+}
+
+.has-value:not(.Select--multi) > .Select-control > .Select-value .Select-value-label,
+.has-value.is-pseudo-focused:not(.Select--multi)
+ > .Select-control
+ > .Select-value
+ .Select-value-label {
+ color: var(--baseFontColor);
+}
+
+.has-value:not(.Select--multi) > .Select-control > .Select-value a.Select-value-label,
+.has-value.is-pseudo-focused:not(.Select--multi)
+ > .Select-control
+ > .Select-value
+ a.Select-value-label {
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.has-value:not(.Select--multi) > .Select-control > .Select-value a.Select-value-label:hover,
+.has-value.is-pseudo-focused:not(.Select--multi)
+ > .Select-control
+ > .Select-value
+ a.Select-value-label:hover,
+.has-value:not(.Select--multi) > .Select-control > .Select-value a.Select-value-label:focus,
+.has-value.is-pseudo-focused:not(.Select--multi)
+ > .Select-control
+ > .Select-value
+ a.Select-value-label:focus {
+ color: #007eff;
+ outline: none;
+ text-decoration: underline;
+}
+
+.Select-input {
+ vertical-align: top;
+ height: 22px;
+ padding-left: 8px;
+ padding-right: 8px;
+ outline: none;
+}
+
+.Select-input > input {
+ background: none transparent;
+ border: 0 none;
+ cursor: default;
+ display: inline-block;
+ font-family: inherit;
+ font-size: var(--smallFontSize);
+ height: 22px;
+ margin: 0;
+ outline: none;
+ padding: 0;
+ box-shadow: none;
+ -webkit-appearance: none;
+}
+
+.is-focused .Select-input > input {
+ cursor: text;
+}
+
+.has-value.is-pseudo-focused .Select-input {
+ opacity: 0;
+}
+
+.Select-control:not(.is-searchable) > .Select-input {
+ outline: none;
+}
+
+.Select-loading-zone {
+ cursor: pointer;
+ display: table-cell;
+ position: relative;
+ text-align: center;
+ vertical-align: middle;
+ width: 16px;
+}
+
+.Select-loading {
+ -webkit-animation: Select-animation-spin 400ms infinite linear;
+ -o-animation: Select-animation-spin 400ms infinite linear;
+ animation: Select-animation-spin 400ms infinite linear;
+ width: 16px;
+ height: 16px;
+ box-sizing: border-box;
+ border-radius: 50%;
+ border: 2px solid #ccc;
+ border-right-color: var(--baseFontColor);
+ display: inline-block;
+ position: relative;
+ vertical-align: middle;
+}
+
+.Select-clear-zone {
+ -webkit-animation: Select-animation-fadeIn 200ms;
+ -o-animation: Select-animation-fadeIn 200ms;
+ animation: Select-animation-fadeIn 200ms;
+ color: #999;
+ cursor: pointer;
+ display: table-cell;
+ position: relative;
+ text-align: center;
+ vertical-align: middle;
+ width: 16px;
+ height: 16px;
+ padding-right: 4px;
+}
+
+.Select-clear-zone:hover .Select-clear {
+ background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PCFET0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj48c3ZnIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIHZpZXdCb3g9IjAgMCAxNCAxNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4bWw6c3BhY2U9InByZXNlcnZlIiBzdHlsZT0iZmlsbC1ydWxlOmV2ZW5vZGQ7Y2xpcC1ydWxlOmV2ZW5vZGQ7c3Ryb2tlLWxpbmVqb2luOnJvdW5kO3N0cm9rZS1taXRlcmxpbWl0OjEuNDE0MjE7Ij4gICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMC4wMjM0Mzc1LDAsMCwwLjAyMzQzNzUsLTUuMDE1NjIsLTUuMDE1NjIpIj4gICAgICAgIDxwYXRoIGQ9Ik04MTAsMjc0TDU3Miw1MTJMODEwLDc1MEw3NTAsODEwTDUxMiw1NzJMMjc0LDgxMEwyMTQsNzUwTDQ1Miw1MTJMMjE0LDI3NEwyNzQsMjE0TDUxMiw0NTJMNzUwLDIxNEw4MTAsMjc0WiIgc3R5bGU9ImZpbGw6cmdiKDIzMSwyMCw1Nik7ZmlsbC1ydWxlOm5vbnplcm87Ii8+ICAgIDwvZz48L3N2Zz4=);
+}
+
+.Select-clear {
+ display: block;
+ width: 9px;
+ height: 9px;
+ background-image: url(data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiIHN0YW5kYWxvbmU9Im5vIj8+PCFET0NUWVBFIHN2ZyBQVUJMSUMgIi0vL1czQy8vRFREIFNWRyAxLjEvL0VOIiAiaHR0cDovL3d3dy53My5vcmcvR3JhcGhpY3MvU1ZHLzEuMS9EVEQvc3ZnMTEuZHRkIj48c3ZnIHdpZHRoPSIxMDAlIiBoZWlnaHQ9IjEwMCUiIHZpZXdCb3g9IjAgMCAxNCAxNCIgdmVyc2lvbj0iMS4xIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHhtbG5zOnhsaW5rPSJodHRwOi8vd3d3LnczLm9yZy8xOTk5L3hsaW5rIiB4bWw6c3BhY2U9InByZXNlcnZlIiBzdHlsZT0iZmlsbC1ydWxlOmV2ZW5vZGQ7Y2xpcC1ydWxlOmV2ZW5vZGQ7c3Ryb2tlLWxpbmVqb2luOnJvdW5kO3N0cm9rZS1taXRlcmxpbWl0OjEuNDE0MjE7Ij4gICAgPGcgdHJhbnNmb3JtPSJtYXRyaXgoMC4wMjM0Mzc1LDAsMCwwLjAyMzQzNzUsLTUuMDE1NjIsLTUuMDE1NjIpIj4gICAgICAgIDxwYXRoIGQ9Ik04MTAsMjc0TDU3Miw1MTJMODEwLDc1MEw3NTAsODEwTDUxMiw1NzJMMjc0LDgxMEwyMTQsNzUwTDQ1Miw1MTJMMjE0LDI3NEwyNzQsMjE0TDUxMiw0NTJMNzUwLDIxNEw4MTAsMjc0WiIgc3R5bGU9ImZpbGw6cmdiKDE1MywxNTMsMTUzKTtmaWxsLXJ1bGU6bm9uemVybzsiLz4gICAgPC9nPjwvc3ZnPg==);
+ background-size: 9px 9px;
+ text-indent: -9999px;
+}
+
+.Select--multi .Select-clear-zone {
+ width: 17px;
+}
+
+.Select-arrow-zone {
+ cursor: pointer;
+ display: table-cell;
+ position: relative;
+ text-align: center;
+ vertical-align: middle;
+ width: 20px;
+ padding-right: 5px;
+}
+
+.Select-arrow {
+ border-color: #999 transparent transparent;
+ border-style: solid;
+ border-width: 4px 4px 2px;
+ display: inline-block;
+ height: 0;
+ width: 0;
+}
+
+.is-open .Select-arrow,
+.Select-arrow-zone:hover > .Select-arrow {
+ border-top-color: #666;
+}
+
+@-webkit-keyframes Select-animation-fadeIn {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes Select-animation-fadeIn {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+.Select-menu-outer {
+ border-bottom-right-radius: 4px;
+ border-bottom-left-radius: 4px;
+ background-color: #fff;
+ border: 1px solid #ccc;
+ border-top-color: var(--barBorderColor);
+ box-sizing: border-box;
+ margin-top: -1px;
+ max-height: 200px;
+ position: absolute;
+ top: 100%;
+ width: 100%;
+ z-index: var(--dropdownMenuZIndex);
+ -webkit-overflow-scrolling: touch;
+ box-shadow: var(--defaultShadow);
+}
+
+.Select-menu {
+ max-height: 198px;
+ padding: 5px 0;
+ overflow-y: auto;
+}
+
+.Select-option {
+ display: block;
+ line-height: 20px;
+ padding: 0 8px;
+ box-sizing: border-box;
+ color: var(--baseFontColor);
+ font-size: var(--smallFontSize);
+ cursor: pointer;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+.Select-option:last-child {
+ border-bottom-right-radius: 2px;
+ border-bottom-left-radius: 2px;
+}
+
+.Select-option.is-focused {
+ background-color: var(--barBackgroundColor);
+}
+
+.Select-option.is-disabled {
+ cursor: default;
+ opacity: 0.4;
+ font-style: italic;
+}
+
+.Select-noresults {
+ box-sizing: border-box;
+ color: var(--gray60);
+ cursor: default;
+ display: block;
+ padding: 8px 10px;
+}
+
+.Select--multi .Select-value {
+ background-color: rgba(0, 126, 255, 0.08);
+ border-radius: 2px;
+ border: 1px solid rgba(0, 126, 255, 0.24);
+ color: var(--baseFontColor);
+ display: inline-block;
+ font-size: var(--smallFontSize);
+ line-height: 14px;
+ margin: 1px 4px 1px 1px;
+ vertical-align: top;
+}
+
+.Select-value-label {
+ font-size: var(--smallFontSize);
+}
+
+.is-searchable.is-open .Select-value-label {
+ opacity: 0.5;
+}
+
+.Select-big .Select-control {
+ padding-top: 4px;
+ padding-bottom: 4px;
+}
+
+.Select-big .Select-placeholder {
+ margin-top: 4px;
+ margin-bottom: 4px;
+}
+
+.Select-big .Select-value-label {
+ display: inline-block;
+ margin-top: 7px;
+ line-height: 16px;
+}
+
+.Select-big .Select-option {
+ padding: 7px 8px;
+ line-height: 16px;
+}
+
+.Select-big img,
+.Select-big svg {
+ padding-top: 0;
+}
+
+.Select--multi .Select-value-icon,
+.Select--multi .Select-value-label {
+ display: inline-block;
+ vertical-align: middle;
+}
+
+.Select--multi .Select-value-label {
+ display: inline-block;
+ max-width: 200px;
+ border-bottom-right-radius: 2px;
+ border-top-right-radius: 2px;
+ cursor: default;
+ padding: 2px 5px;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.Select--multi a.Select-value-label {
+ color: #007eff;
+ cursor: pointer;
+ text-decoration: none;
+}
+
+.Select--multi a.Select-value-label:hover {
+ text-decoration: underline;
+}
+
+.Select--multi .Select-value-icon {
+ cursor: pointer;
+ border-bottom-left-radius: 2px;
+ border-top-left-radius: 2px;
+ border-right: 1px solid rgba(0, 126, 255, 0.24);
+ padding: 1px 5px;
+}
+
+.Select--multi .Select-value-icon:hover,
+.Select--multi .Select-value-icon:focus {
+ background-color: rgba(0, 113, 230, 0.08);
+ color: #0071e6;
+}
+
+.Select--multi .Select-value-icon:active {
+ background-color: rgba(0, 126, 255, 0.24);
+}
+
+.Select--multi.is-disabled .Select-value {
+ background-color: #fcfcfc;
+ border: 1px solid #e3e3e3;
+ color: var(--baseFontColor);
+}
+
+.Select--multi.is-disabled .Select-value-icon {
+ cursor: not-allowed;
+ border-right: 1px solid #e3e3e3;
+}
+
+.Select--multi.is-disabled .Select-value-icon:hover,
+.Select--multi.is-disabled .Select-value-icon:focus,
+.Select--multi.is-disabled .Select-value-icon:active {
+ background-color: #fcfcfc;
+}
+
+.Select-aria-only {
+ display: none;
+}
+
+@keyframes Select-animation-spin {
+ to {
+ transform: rotate(1turn);
+ }
+}
+
+@-webkit-keyframes Select-animation-spin {
+ to {
+ -webkit-transform: rotate(1turn);
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/Select.tsx b/server/sonar-ui-common/components/controls/Select.tsx
new file mode 100644
index 00000000000..be4c37bd2da
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Select.tsx
@@ -0,0 +1,72 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 {
+ defaultFilterOptions as reactSelectDefaultFilterOptions,
+ ReactAsyncSelectProps,
+ ReactCreatableSelectProps,
+ ReactSelectProps,
+} from 'react-select';
+import { lazyLoadComponent } from '../lazyLoadComponent';
+import { ClearButton } from './buttons';
+import './Select.css';
+
+declare module 'react-select' {
+ export function defaultFilterOptions(...args: any[]): any;
+}
+
+const ReactSelectLib = import('react-select');
+const ReactSelect = lazyLoadComponent(() => ReactSelectLib);
+const ReactCreatable = lazyLoadComponent(() =>
+ ReactSelectLib.then((lib) => ({ default: lib.Creatable }))
+);
+const ReactAsync = lazyLoadComponent(() => ReactSelectLib.then((lib) => ({ default: lib.Async })));
+
+function renderInput() {
+ return <ClearButton className="button-tiny spacer-left text-middle" iconProps={{ size: 12 }} />;
+}
+
+interface WithInnerRef {
+ innerRef?: (element: React.Component) => void;
+}
+
+export default function Select({ innerRef, ...props }: WithInnerRef & ReactSelectProps) {
+ // TODO try to define good defaults, if any
+ // ReactSelect doesn't declare `clearRenderer` prop
+ const ReactSelectAny = ReactSelect as any;
+ // hide the "x" icon when select is empty
+ const clearable = props.clearable ? Boolean(props.value) : false;
+ return (
+ <ReactSelectAny {...props} clearable={clearable} clearRenderer={renderInput} ref={innerRef} />
+ );
+}
+
+export const defaultFilterOptions = reactSelectDefaultFilterOptions;
+
+export function Creatable(props: ReactCreatableSelectProps) {
+ // ReactSelect doesn't declare `clearRenderer` prop
+ const ReactCreatableAny = ReactCreatable as any;
+ return <ReactCreatableAny {...props} clearRenderer={renderInput} />;
+}
+
+// TODO figure out why `ref` prop is incompatible
+export function AsyncSelect(props: ReactAsyncSelectProps & { ref?: any }) {
+ return <ReactAsync {...props} />;
+}
diff --git a/server/sonar-ui-common/components/controls/SelectList.css b/server/sonar-ui-common/components/controls/SelectList.css
new file mode 100644
index 00000000000..4a2fcc1c3a1
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/SelectList.css
@@ -0,0 +1,60 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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.
+ */
+
+.select-list-container {
+ min-width: 500px;
+ box-sizing: border-box;
+}
+
+.select-list-control {
+ margin-bottom: 10px;
+ box-sizing: border-box;
+}
+
+.select-list-list-container {
+ border: 1px solid #bfbfbf;
+ box-sizing: border-box;
+ height: 400px;
+ overflow: auto;
+}
+
+.select-list-list-checkbox {
+ display: flex !important;
+ align-items: center;
+}
+
+.select-list-list-checkbox i {
+ display: inline-block;
+ vertical-align: middle;
+ margin-right: 10px;
+}
+
+.select-list-list-disabled {
+ cursor: not-allowed;
+}
+
+.select-list-list-disabled > a {
+ pointer-events: none;
+}
+
+.select-list-list-item {
+ display: inline-block;
+ vertical-align: middle;
+}
diff --git a/server/sonar-ui-common/components/controls/SelectList.tsx b/server/sonar-ui-common/components/controls/SelectList.tsx
new file mode 100644
index 00000000000..9baa6d843f0
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/SelectList.tsx
@@ -0,0 +1,189 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 ListFooter from './ListFooter';
+import RadioToggle from './RadioToggle';
+import SearchBox from './SearchBox';
+import './SelectList.css';
+import SelectListListContainer from './SelectListListContainer';
+
+export enum SelectListFilter {
+ All = 'all',
+ Selected = 'selected',
+ Unselected = 'deselected',
+}
+
+interface Props {
+ allowBulkSelection?: boolean;
+ elements: string[];
+ elementsTotalCount?: number;
+ disabledElements?: string[];
+ labelSelected?: string;
+ labelUnselected?: string;
+ labelAll?: string;
+ needToReload?: boolean;
+ onSearch: (searchParams: SelectListSearchParams) => Promise<void>;
+ onSelect: (element: string) => Promise<void>;
+ onUnselect: (element: string) => Promise<void>;
+ pageSize?: number;
+ readOnly?: boolean;
+ renderElement: (element: string) => React.ReactNode;
+ selectedElements: string[];
+ withPaging?: boolean;
+}
+
+export interface SelectListSearchParams {
+ filter: SelectListFilter;
+ page?: number;
+ pageSize?: number;
+ query: string;
+}
+
+interface State {
+ lastSearchParams: SelectListSearchParams;
+ loading: boolean;
+}
+
+const DEFAULT_PAGE_SIZE = 100;
+
+export default class SelectList extends React.PureComponent<Props, State> {
+ mounted = false;
+
+ constructor(props: Props) {
+ super(props);
+
+ this.state = {
+ lastSearchParams: {
+ filter: SelectListFilter.Selected,
+ page: 1,
+ pageSize: props.pageSize ? props.pageSize : DEFAULT_PAGE_SIZE,
+ query: '',
+ },
+ loading: false,
+ };
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ this.search({});
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ stopLoading = () => {
+ if (this.mounted) {
+ this.setState({ loading: false });
+ }
+ };
+
+ getFilter = () =>
+ this.state.lastSearchParams.query === ''
+ ? this.state.lastSearchParams.filter
+ : SelectListFilter.All;
+
+ search = (searchParams: Partial<SelectListSearchParams>) =>
+ this.setState(
+ (prevState) => ({
+ loading: true,
+ lastSearchParams: { ...prevState.lastSearchParams, ...searchParams },
+ }),
+ () =>
+ this.props
+ .onSearch({
+ filter: this.getFilter(),
+ page: this.props.withPaging ? this.state.lastSearchParams.page : undefined,
+ pageSize: this.props.withPaging ? this.state.lastSearchParams.pageSize : undefined,
+ query: this.state.lastSearchParams.query,
+ })
+ .then(this.stopLoading)
+ .catch(this.stopLoading)
+ );
+
+ changeFilter = (filter: SelectListFilter) => this.search({ filter, page: 1 });
+
+ handleQueryChange = (query: string) => this.search({ page: 1, query });
+
+ onLoadMore = () =>
+ this.search({
+ page:
+ this.state.lastSearchParams.page != null ? this.state.lastSearchParams.page + 1 : undefined,
+ });
+
+ onReload = () => this.search({ page: 1 });
+
+ render() {
+ const {
+ labelSelected = translate('selected'),
+ labelUnselected = translate('unselected'),
+ labelAll = translate('all'),
+ } = this.props;
+ const { filter } = this.state.lastSearchParams;
+
+ const disabled = this.state.lastSearchParams.query !== '';
+
+ return (
+ <div className="select-list">
+ <div className="display-flex-center">
+ <RadioToggle
+ className="select-list-filter spacer-right"
+ name="filter"
+ onCheck={this.changeFilter}
+ options={[
+ { disabled, label: labelSelected, value: SelectListFilter.Selected },
+ { disabled, label: labelUnselected, value: SelectListFilter.Unselected },
+ { disabled, label: labelAll, value: SelectListFilter.All },
+ ]}
+ value={filter}
+ />
+ <SearchBox
+ autoFocus={true}
+ loading={this.state.loading}
+ onChange={this.handleQueryChange}
+ placeholder={translate('search_verb')}
+ value={this.state.lastSearchParams.query}
+ />
+ </div>
+ <SelectListListContainer
+ allowBulkSelection={this.props.allowBulkSelection}
+ disabledElements={this.props.disabledElements || []}
+ elements={this.props.elements}
+ filter={this.getFilter()}
+ onSelect={this.props.onSelect}
+ onUnselect={this.props.onUnselect}
+ readOnly={this.props.readOnly}
+ renderElement={this.props.renderElement}
+ selectedElements={this.props.selectedElements}
+ />
+ {!!this.props.elementsTotalCount && (
+ <ListFooter
+ count={this.props.elements.length}
+ loadMore={this.onLoadMore}
+ needReload={this.props.needToReload}
+ reload={this.onReload}
+ total={this.props.elementsTotalCount}
+ />
+ )}
+ </div>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/SelectListListContainer.tsx b/server/sonar-ui-common/components/controls/SelectListListContainer.tsx
new file mode 100644
index 00000000000..f0bb64a2ff9
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/SelectListListContainer.tsx
@@ -0,0 +1,129 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import { translate } from '../../helpers/l10n';
+import DeferredSpinner from '../ui/DeferredSpinner';
+import Checkbox from './Checkbox';
+import { SelectListFilter } from './SelectList';
+import SelectListListElement from './SelectListListElement';
+
+interface Props {
+ allowBulkSelection?: boolean;
+ elements: string[];
+ disabledElements: string[];
+ filter: SelectListFilter;
+ onSelect: (element: string) => Promise<void>;
+ onUnselect: (element: string) => Promise<void>;
+ readOnly?: boolean;
+ renderElement: (element: string) => React.ReactNode;
+ selectedElements: string[];
+}
+
+interface State {
+ loading: boolean;
+}
+
+export default class SelectListListContainer extends React.PureComponent<Props, State> {
+ mounted = false;
+ state: State = { loading: false };
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ stopLoading = () => {
+ if (this.mounted) {
+ this.setState({ loading: false });
+ }
+ };
+
+ isDisabled = (element: string): boolean => {
+ return this.props.readOnly || this.props.disabledElements.includes(element);
+ };
+
+ isSelected = (element: string): boolean => {
+ return this.props.selectedElements.includes(element);
+ };
+
+ handleBulkChange = (checked: boolean) => {
+ this.setState({ loading: true });
+ if (checked) {
+ Promise.all(this.props.elements.map((element) => this.props.onSelect(element)))
+ .then(this.stopLoading)
+ .catch(this.stopLoading);
+ } else {
+ Promise.all(this.props.selectedElements.map((element) => this.props.onUnselect(element)))
+ .then(this.stopLoading)
+ .catch(this.stopLoading);
+ }
+ };
+
+ renderBulkSelector() {
+ const { elements, readOnly, selectedElements } = this.props;
+ return (
+ <>
+ <li>
+ <Checkbox
+ checked={selectedElements.length > 0}
+ disabled={this.state.loading || readOnly}
+ onCheck={this.handleBulkChange}
+ thirdState={selectedElements.length > 0 && elements.length !== selectedElements.length}>
+ <span className="big-spacer-left">
+ {translate('bulk_change')}
+ <DeferredSpinner className="spacer-left" loading={this.state.loading} timeout={10} />
+ </span>
+ </Checkbox>
+ </li>
+ <li className="divider" />
+ </>
+ );
+ }
+
+ render() {
+ const { allowBulkSelection, elements, filter } = this.props;
+
+ return (
+ <div className={classNames('select-list-list-container spacer-top')}>
+ <ul className="menu">
+ {allowBulkSelection &&
+ elements.length > 0 &&
+ filter === SelectListFilter.All &&
+ this.renderBulkSelector()}
+ {elements.map((element) => (
+ <SelectListListElement
+ disabled={this.isDisabled(element)}
+ element={element}
+ key={element}
+ onSelect={this.props.onSelect}
+ onUnselect={this.props.onUnselect}
+ renderElement={this.props.renderElement}
+ selected={this.isSelected(element)}
+ />
+ ))}
+ </ul>
+ </div>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/SelectListListElement.tsx b/server/sonar-ui-common/components/controls/SelectListListElement.tsx
new file mode 100644
index 00000000000..bae8dbfea45
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/SelectListListElement.tsx
@@ -0,0 +1,76 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import Checkbox from './Checkbox';
+
+interface Props {
+ active?: boolean;
+ disabled?: boolean;
+ element: string;
+ onSelect: (element: string) => Promise<void>;
+ onUnselect: (element: string) => Promise<void>;
+ renderElement: (element: string) => React.ReactNode;
+ selected: boolean;
+}
+
+interface State {
+ loading: boolean;
+}
+
+export default class SelectListListElement extends React.PureComponent<Props, State> {
+ mounted = false;
+ state: State = { loading: false };
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ stopLoading = () => {
+ if (this.mounted) {
+ this.setState({ loading: false });
+ }
+ };
+
+ handleCheck = (checked: boolean) => {
+ this.setState({ loading: true });
+ const request = checked ? this.props.onSelect : this.props.onUnselect;
+ request(this.props.element).then(this.stopLoading, this.stopLoading);
+ };
+
+ render() {
+ return (
+ <li className={classNames({ 'select-list-list-disabled': this.props.disabled })}>
+ <Checkbox
+ checked={this.props.selected}
+ className={classNames('select-list-list-checkbox', { active: this.props.active })}
+ disabled={this.props.disabled}
+ loading={this.state.loading}
+ onCheck={this.handleCheck}>
+ <span className="little-spacer-left">{this.props.renderElement(this.props.element)}</span>
+ </Checkbox>
+ </li>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/SimpleModal.tsx b/server/sonar-ui-common/components/controls/SimpleModal.tsx
new file mode 100644
index 00000000000..99d368bc02f
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/SimpleModal.tsx
@@ -0,0 +1,101 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 Modal, { ModalProps } from './Modal';
+
+export interface ChildrenProps {
+ onCloseClick: (event?: React.SyntheticEvent<HTMLElement>) => void;
+ onFormSubmit: (event: React.SyntheticEvent<HTMLFormElement>) => void;
+ onSubmitClick: (event?: React.SyntheticEvent<HTMLElement>) => void;
+ submitting: boolean;
+}
+
+interface Props extends ModalProps {
+ children: (props: ChildrenProps) => React.ReactNode;
+ header: string;
+ onClose: () => void;
+ onSubmit: () => void | Promise<void | Response>;
+}
+
+interface State {
+ submitting: boolean;
+}
+
+export default class SimpleModal extends React.Component<Props, State> {
+ mounted = false;
+ state: State = { submitting: false };
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ stopSubmitting = () => {
+ if (this.mounted) {
+ this.setState({ submitting: false });
+ }
+ };
+
+ handleCloseClick = (event?: React.SyntheticEvent<HTMLElement>) => {
+ if (event) {
+ event.preventDefault();
+ event.currentTarget.blur();
+ }
+ this.props.onClose();
+ };
+
+ handleFormSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
+ event.preventDefault();
+ this.submit();
+ };
+
+ handleSubmitClick = (event?: React.SyntheticEvent<HTMLElement>) => {
+ if (event) {
+ event.preventDefault();
+ event.currentTarget.blur();
+ }
+ this.submit();
+ };
+
+ submit = () => {
+ const result = this.props.onSubmit();
+ if (result) {
+ this.setState({ submitting: true });
+ result.then(this.stopSubmitting, this.stopSubmitting);
+ }
+ };
+
+ render() {
+ const { children, header, onClose, onSubmit, ...modalProps } = this.props;
+ return (
+ <Modal contentLabel={header} onRequestClose={onClose} {...modalProps}>
+ {children({
+ onCloseClick: this.handleCloseClick,
+ onFormSubmit: this.handleFormSubmit,
+ onSubmitClick: this.handleSubmitClick,
+ submitting: this.state.submitting,
+ })}
+ </Modal>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/Tabs.css b/server/sonar-ui-common/components/controls/Tabs.css
new file mode 100644
index 00000000000..13dc927ab8d
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Tabs.css
@@ -0,0 +1,60 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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.
+ */
+.flex-tabs {
+ display: flex;
+ clear: left;
+ margin-bottom: calc(3 * var(--gridSize));
+ border-bottom: 1px solid var(--barBorderColor);
+ font-size: var(--mediumFontSize);
+}
+
+.flex-tabs > li > a {
+ position: relative;
+ display: block;
+ top: 1px;
+ height: 100%;
+ width: 100%;
+ box-sizing: border-box;
+ color: var(--secondFontColor);
+ font-weight: 600;
+ cursor: pointer;
+ padding-bottom: calc(1.5 * var(--gridSize));
+ border-bottom: 3px solid transparent;
+ transition: color 0.2s ease;
+}
+
+.flex-tabs > li ~ li {
+ margin-left: calc(4 * var(--gridSize));
+}
+
+.flex-tabs > li > a:hover {
+ color: var(--baseFontColor);
+}
+
+.flex-tabs > li > a.selected {
+ color: var(--blue);
+ border-bottom-color: var(--blue);
+}
+
+.flex-tabs > li > a.disabled {
+ color: var(--disableGrayText) !important;
+ cursor: not-allowed !important;
+ pointer-events: none !important;
+}
diff --git a/server/sonar-ui-common/components/controls/Tabs.tsx b/server/sonar-ui-common/components/controls/Tabs.tsx
new file mode 100644
index 00000000000..7fb2ef73db4
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Tabs.tsx
@@ -0,0 +1,77 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import './Tabs.css';
+
+interface Props<T extends string> {
+ onChange: (tab: T) => void;
+ selected?: T;
+ tabs: Array<{ disabled?: boolean; key: T; node: React.ReactNode }>;
+}
+
+export default function Tabs<T extends string>({ onChange, selected, tabs }: Props<T>) {
+ return (
+ <ul className="flex-tabs">
+ {tabs.map((tab) => (
+ <Tab
+ disabled={tab.disabled}
+ key={tab.key}
+ name={tab.key}
+ onSelect={onChange}
+ selected={selected === tab.key}>
+ {tab.node}
+ </Tab>
+ ))}
+ </ul>
+ );
+}
+
+interface TabProps<T> {
+ children: React.ReactNode;
+ disabled?: boolean;
+ name: T;
+ onSelect: (tab: T) => void;
+ selected: boolean;
+}
+
+export class Tab<T> extends React.PureComponent<TabProps<T>> {
+ handleClick = (event: React.MouseEvent<HTMLAnchorElement>) => {
+ event.preventDefault();
+ event.stopPropagation();
+ if (!this.props.disabled) {
+ this.props.onSelect(this.props.name);
+ }
+ };
+
+ render() {
+ const { children, disabled, name, selected } = this.props;
+ return (
+ <li>
+ <a
+ className={classNames('js-' + name, { disabled, selected })}
+ href="#"
+ onClick={this.handleClick}>
+ {children}
+ </a>
+ </li>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/Toggle.css b/server/sonar-ui-common/components/controls/Toggle.css
new file mode 100644
index 00000000000..1d14e119209
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Toggle.css
@@ -0,0 +1,82 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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.
+ */
+.button.boolean-toggle {
+ display: inline-block;
+ vertical-align: middle;
+ width: 48px;
+ height: var(--controlHeight);
+ padding: 1px;
+ border: 1px solid var(--gray80);
+ border-radius: var(--controlHeight);
+ box-sizing: border-box;
+ background-color: #fff;
+ cursor: pointer;
+ transition: all 0.3s ease;
+}
+
+.button.boolean-toggle:hover {
+ background-color: #fff;
+}
+
+.button.boolean-toggle:focus {
+ border-color: var(--blue);
+ background-color: #f6f6f6;
+}
+
+.boolean-toggle-handle {
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ width: 20px;
+ height: 20px;
+ border: 1px solid var(--gray80);
+ border-radius: 22px;
+ box-sizing: border-box;
+ background-color: #f6f6f6;
+ transition: transform 0.3s cubic-bezier(0.87, -0.41, 0.19, 1.44), border 0.3s ease;
+}
+
+.boolean-toggle-handle > * {
+ opacity: 0;
+ transition: opacity 0.3s ease;
+}
+
+.button.boolean-toggle-on {
+ border-color: var(--darkBlue);
+ background-color: var(--darkBlue);
+ color: var(--darkBlue);
+}
+
+.button.boolean-toggle-on:hover {
+ background-color: var(--darkBlue);
+}
+
+.button.boolean-toggle-on:focus {
+ background-color: var(--darkBlue);
+}
+
+.button.boolean-toggle-on .boolean-toggle-handle {
+ border-color: #f6f6f6;
+ transform: translateX(var(--controlHeight));
+}
+
+.button.boolean-toggle-on .boolean-toggle-handle > * {
+ opacity: 1;
+}
diff --git a/server/sonar-ui-common/components/controls/Toggle.tsx b/server/sonar-ui-common/components/controls/Toggle.tsx
new file mode 100644
index 00000000000..62a7570e759
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Toggle.tsx
@@ -0,0 +1,60 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import { translate } from '../../helpers/l10n';
+import CheckIcon from '../icons/CheckIcon';
+import { Button } from './buttons';
+import './Toggle.css';
+
+interface Props {
+ disabled?: boolean;
+ name?: string;
+ onChange?: (value: boolean) => void;
+ value: boolean | string;
+}
+
+export default class Toggle extends React.PureComponent<Props> {
+ getValue = () => {
+ const { value } = this.props;
+ return typeof value === 'string' ? value === 'true' : value;
+ };
+
+ handleClick = () => {
+ if (this.props.onChange) {
+ const value = this.getValue();
+ this.props.onChange(!value);
+ }
+ };
+
+ render() {
+ const { disabled, name } = this.props;
+ const value = this.getValue();
+ const className = classNames('boolean-toggle', { 'boolean-toggle-on': value });
+
+ return (
+ <Button className={className} disabled={disabled} name={name} onClick={this.handleClick}>
+ <div aria-label={translate(value ? 'on' : 'off')} className="boolean-toggle-handle">
+ <CheckIcon size={12} />
+ </div>
+ </Button>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/Toggler.tsx b/server/sonar-ui-common/components/controls/Toggler.tsx
new file mode 100644
index 00000000000..2545716e8bb
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Toggler.tsx
@@ -0,0 +1,73 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 DocumentClickHandler from './DocumentClickHandler';
+import EscKeydownHandler from './EscKeydownHandler';
+import OutsideClickHandler from './OutsideClickHandler';
+
+interface Props {
+ children?: React.ReactNode;
+ closeOnClick?: boolean;
+ closeOnClickOutside?: boolean;
+ closeOnEscape?: boolean;
+ onRequestClose: () => void;
+ open: boolean;
+ overlay: React.ReactNode;
+}
+
+export default class Toggler extends React.Component<Props> {
+ renderOverlay() {
+ const {
+ closeOnClick = false,
+ closeOnClickOutside = true,
+ closeOnEscape = true,
+ onRequestClose,
+ overlay,
+ } = this.props;
+
+ let renderedOverlay;
+ if (closeOnEscape) {
+ renderedOverlay = <EscKeydownHandler onKeydown={onRequestClose}>{overlay}</EscKeydownHandler>;
+ } else {
+ renderedOverlay = overlay;
+ }
+
+ if (closeOnClick) {
+ return (
+ <DocumentClickHandler onClick={onRequestClose}>{renderedOverlay}</DocumentClickHandler>
+ );
+ } else if (closeOnClickOutside) {
+ return (
+ <OutsideClickHandler onClickOutside={onRequestClose}>{renderedOverlay}</OutsideClickHandler>
+ );
+ } else {
+ return renderedOverlay;
+ }
+ }
+
+ render() {
+ return (
+ <>
+ {this.props.children}
+ {this.props.open && this.renderOverlay()}
+ </>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/Tooltip.css b/server/sonar-ui-common/components/controls/Tooltip.css
new file mode 100644
index 00000000000..40eaf11d69e
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Tooltip.css
@@ -0,0 +1,134 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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.
+ */
+.tooltip {
+ position: absolute;
+ z-index: var(--tooltipZIndex);
+ display: block;
+ height: auto;
+ box-sizing: border-box;
+ font-size: var(--baseFontSize);
+ font-weight: 300;
+ line-height: 1.5;
+ animation: fadeIn 0.3s forwards;
+}
+
+.tooltip.top {
+ padding: 5px 0;
+ margin-top: -3px;
+}
+
+.tooltip.right {
+ padding: 0 5px;
+ margin-left: 3px;
+}
+
+.tooltip.bottom {
+ padding: 5px 0;
+ margin-top: 3px;
+}
+
+.tooltip.left {
+ padding: 0 5px;
+ margin-left: -3px;
+}
+
+.tooltip-inner {
+ max-width: 300px;
+ text-align: left;
+ text-decoration: none;
+ border-radius: 4px;
+ overflow: hidden;
+ word-break: break-word;
+}
+
+.tooltip-inner {
+ padding: 12px 17px;
+ color: #fff;
+ background-color: #475760;
+ letter-spacing: 0.04em;
+}
+
+.tooltip-inner .alert {
+ margin-bottom: 5px;
+ border-radius: 4px;
+}
+
+.tooltip-inner a {
+ border-bottom-color: #8da6b3;
+ color: #a5d0ea;
+}
+
+.tooltip-inner hr {
+ background-color: #5d6d75;
+}
+
+.tooltip-arrow {
+ position: absolute;
+ width: 0;
+ height: 0;
+ border: solid transparent;
+}
+
+.tooltip.top .tooltip-arrow {
+ bottom: 0;
+ left: 50%;
+ border-width: 5px 5px 0;
+ transform: translateX(-5px);
+ border-top-color: #475760;
+}
+
+.tooltip.right .tooltip-arrow {
+ top: 50%;
+ left: 0;
+ transform: translateY(-5px);
+ border-width: 5px 5px 5px 0;
+ border-right-color: #475760;
+}
+
+.tooltip.left .tooltip-arrow {
+ top: 50%;
+ right: 0;
+ transform: translateY(-5px);
+ border-width: 5px 0 5px 5px;
+ border-left-color: #475760;
+}
+
+.tooltip.bottom .tooltip-arrow {
+ top: 0;
+ left: 50%;
+ transform: translateX(-5px);
+ border-width: 0 5px 5px;
+ border-bottom-color: #475760;
+}
+
+/* Workaround for react issue with onMouseLeave in disabled buttons: https://github.com/facebook/react/issues/4251 */
+.tooltip button[disabled] {
+ pointer-events: none;
+}
+
+@keyframes fadeIn {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/Tooltip.tsx b/server/sonar-ui-common/components/controls/Tooltip.tsx
new file mode 100644
index 00000000000..8ac9f8711bb
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/Tooltip.tsx
@@ -0,0 +1,407 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { throttle } from 'lodash';
+import * as React from 'react';
+import { createPortal, findDOMNode } from 'react-dom';
+import ThemeContext from '../theme';
+import ScreenPositionFixer from './ScreenPositionFixer';
+import './Tooltip.css';
+
+export type Placement = 'bottom' | 'right' | 'left' | 'top';
+
+export interface TooltipProps {
+ classNameSpace?: string;
+ children: React.ReactElement<{}>;
+ mouseEnterDelay?: number;
+ mouseLeaveDelay?: number;
+ onShow?: () => void;
+ onHide?: () => void;
+ overlay: React.ReactNode;
+ placement?: Placement;
+ visible?: boolean;
+}
+
+interface Measurements {
+ height: number;
+ left: number;
+ top: number;
+ width: number;
+}
+
+interface OwnState {
+ flipped: boolean;
+ placement?: Placement;
+ visible: boolean;
+}
+
+type State = OwnState & Partial<Measurements>;
+
+const FLIP_MAP: { [key in Placement]: Placement } = {
+ left: 'right',
+ right: 'left',
+ top: 'bottom',
+ bottom: 'top',
+};
+
+function isMeasured(state: State): state is OwnState & Measurements {
+ return state.height !== undefined;
+}
+
+export default function Tooltip(props: TooltipProps) {
+ // overlay is a ReactNode, so it can be `undefined` or `null`
+ // this allows to easily render a tooltip conditionally
+ // more generaly we avoid rendering empty tooltips
+ return props.overlay != null && props.overlay !== '' ? (
+ <TooltipInner {...props} />
+ ) : (
+ props.children
+ );
+}
+
+export class TooltipInner extends React.Component<TooltipProps, State> {
+ throttledPositionTooltip: () => void;
+ mouseEnterTimeout?: number;
+ mouseLeaveTimeout?: number;
+ tooltipNode?: HTMLElement | null;
+ mounted = false;
+ mouseIn = false;
+
+ static defaultProps = {
+ mouseEnterDelay: 0.1,
+ };
+
+ constructor(props: TooltipProps) {
+ super(props);
+ this.state = {
+ flipped: false,
+ placement: props.placement,
+ visible: props.visible !== undefined ? props.visible : false,
+ };
+ this.throttledPositionTooltip = throttle(this.positionTooltip, 10);
+ }
+
+ componentDidMount() {
+ this.mounted = true;
+ if (this.props.visible === true) {
+ this.positionTooltip();
+ this.addEventListeners();
+ }
+ }
+
+ componentDidUpdate(prevProps: TooltipProps, prevState: State) {
+ if (this.props.placement !== prevProps.placement) {
+ this.setState({ placement: this.props.placement });
+ // Break. This will trigger a new componentDidUpdate() call, so the below
+ // positionTooltip() call will be correct. Otherwise, it might not use
+ // the new state.placement value.
+ return;
+ }
+
+ if (
+ // opens
+ (this.props.visible === true && !prevProps.visible) ||
+ (this.props.visible === undefined &&
+ this.state.visible === true &&
+ prevState.visible === false)
+ ) {
+ this.positionTooltip();
+ this.addEventListeners();
+ } else if (
+ // closes
+ (!this.props.visible && prevProps.visible === true) ||
+ (this.props.visible === undefined &&
+ this.state.visible === false &&
+ prevState.visible === true)
+ ) {
+ this.clearPosition();
+ this.removeEventListeners();
+ }
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ this.removeEventListeners();
+ this.clearTimeouts();
+ }
+
+ static contextType = ThemeContext;
+
+ addEventListeners = () => {
+ window.addEventListener('resize', this.throttledPositionTooltip);
+ window.addEventListener('scroll', this.throttledPositionTooltip);
+ };
+
+ removeEventListeners = () => {
+ window.removeEventListener('resize', this.throttledPositionTooltip);
+ window.removeEventListener('scroll', this.throttledPositionTooltip);
+ };
+
+ clearTimeouts = () => {
+ window.clearTimeout(this.mouseEnterTimeout);
+ window.clearTimeout(this.mouseLeaveTimeout);
+ };
+
+ isVisible = () => {
+ return this.props.visible !== undefined ? this.props.visible : this.state.visible;
+ };
+
+ getPlacement = (): Placement => {
+ return this.state.placement || 'bottom';
+ };
+
+ tooltipNodeRef = (node: HTMLElement | null) => {
+ this.tooltipNode = node;
+ };
+
+ adjustArrowPosition = (
+ placement: Placement,
+ { leftFix, topFix }: { leftFix: number; topFix: number }
+ ) => {
+ switch (placement) {
+ case 'left':
+ case 'right':
+ return { marginTop: -topFix };
+ default:
+ return { marginLeft: -leftFix };
+ }
+ };
+
+ positionTooltip = () => {
+ // `findDOMNode(this)` will search for the DOM node for the current component
+ // first it will find a React.Fragment (see `render`),
+ // so it will get the DOM node of the first child, i.e. DOM node of `this.props.children`
+ // docs: https://reactjs.org/docs/refs-and-the-dom.html#exposing-dom-refs-to-parent-components
+
+ // eslint-disable-next-line react/no-find-dom-node
+ const toggleNode = findDOMNode(this);
+
+ if (toggleNode && toggleNode instanceof Element && this.tooltipNode) {
+ const toggleRect = toggleNode.getBoundingClientRect();
+ const tooltipRect = this.tooltipNode.getBoundingClientRect();
+ const { width, height } = tooltipRect;
+
+ let left = 0;
+ let top = 0;
+
+ switch (this.getPlacement()) {
+ case 'bottom':
+ left = toggleRect.left + toggleRect.width / 2 - width / 2;
+ top = toggleRect.top + toggleRect.height;
+ break;
+ case 'top':
+ left = toggleRect.left + toggleRect.width / 2 - width / 2;
+ top = toggleRect.top - height;
+ break;
+ case 'right':
+ left = toggleRect.left + toggleRect.width;
+ top = toggleRect.top + toggleRect.height / 2 - height / 2;
+ break;
+ case 'left':
+ left = toggleRect.left - width;
+ top = toggleRect.top + toggleRect.height / 2 - height / 2;
+ break;
+ }
+
+ // save width and height (and later set in `render`) to avoid resizing the tooltip element,
+ // when it's placed close to the window edge
+ this.setState({
+ left: window.pageXOffset + left,
+ top: window.pageYOffset + top,
+ width,
+ height,
+ });
+ }
+ };
+
+ clearPosition = () => {
+ this.setState({
+ flipped: false,
+ left: undefined,
+ top: undefined,
+ width: undefined,
+ height: undefined,
+ placement: this.props.placement,
+ });
+ };
+
+ handleMouseEnter = () => {
+ this.mouseEnterTimeout = window.setTimeout(() => {
+ // for some reason even after the `this.mouseEnterTimeout` is cleared, it still triggers
+ // to workaround this issue, check that its value is not `undefined`
+ // (if it's `undefined`, it means the timer has been reset)
+ if (
+ this.mounted &&
+ this.props.visible === undefined &&
+ this.mouseEnterTimeout !== undefined
+ ) {
+ this.setState({ visible: true });
+ }
+ }, (this.props.mouseEnterDelay || 0) * 1000);
+
+ if (this.props.onShow) {
+ this.props.onShow();
+ }
+ };
+
+ handleMouseLeave = () => {
+ if (this.mouseEnterTimeout !== undefined) {
+ window.clearTimeout(this.mouseEnterTimeout);
+ this.mouseEnterTimeout = undefined;
+ }
+
+ if (!this.mouseIn) {
+ this.mouseLeaveTimeout = window.setTimeout(() => {
+ if (this.mounted && this.props.visible === undefined && !this.mouseIn) {
+ this.setState({ visible: false });
+ }
+ }, (this.props.mouseLeaveDelay || 0) * 1000);
+
+ if (this.props.onHide) {
+ this.props.onHide();
+ }
+ }
+ };
+
+ handleOverlayMouseEnter = () => {
+ this.mouseIn = true;
+ };
+
+ handleOverlayMouseLeave = () => {
+ this.mouseIn = false;
+ this.handleMouseLeave();
+ };
+
+ needsFlipping = (leftFix: number, topFix: number) => {
+ // We can live with a tooltip that's slightly positioned over the toggle
+ // node. Only trigger if it really starts overlapping, as the re-positioning
+ // is quite expensive, needing 2 re-renders.
+ const threshold = this.context.rawSizes.grid;
+ switch (this.getPlacement()) {
+ case 'left':
+ case 'right':
+ return Math.abs(leftFix) > threshold;
+ case 'top':
+ case 'bottom':
+ return Math.abs(topFix) > threshold;
+ }
+ return false;
+ };
+
+ renderActual = ({ leftFix = 0, topFix = 0 }) => {
+ if (
+ !this.state.flipped &&
+ (leftFix !== 0 || topFix !== 0) &&
+ this.needsFlipping(leftFix, topFix)
+ ) {
+ // Update state in a render function... Not a good idea, but we need to
+ // render in order to know if we need to flip... To prevent React from
+ // complaining, we update the state using a setTimeout() call.
+ setTimeout(() => {
+ this.setState(
+ ({ placement = 'bottom' }) => ({
+ flipped: true,
+ // Set height to undefined to force ScreenPositionFixer to
+ // re-compute our positioning.
+ height: undefined,
+ placement: FLIP_MAP[placement],
+ }),
+ () => {
+ if (this.state.visible) {
+ // Force a re-positioning, as "only" updating the state doesn't
+ // recompute the position, only re-renders with the previous
+ // position (which is no longer correct).
+ this.positionTooltip();
+ }
+ }
+ );
+ }, 1);
+ return null;
+ }
+
+ const { classNameSpace = 'tooltip' } = this.props;
+ const placement = this.getPlacement();
+ const style = isMeasured(this.state)
+ ? {
+ left: this.state.left + leftFix,
+ top: this.state.top + topFix,
+ width: this.state.width,
+ height: this.state.height,
+ }
+ : undefined;
+
+ return (
+ <div
+ className={`${classNameSpace} ${placement}`}
+ onMouseEnter={this.handleOverlayMouseEnter}
+ onMouseLeave={this.handleOverlayMouseLeave}
+ ref={this.tooltipNodeRef}
+ style={style}>
+ <div className={`${classNameSpace}-inner`}>{this.props.overlay}</div>
+ <div
+ className={`${classNameSpace}-arrow`}
+ style={
+ isMeasured(this.state)
+ ? this.adjustArrowPosition(placement, { leftFix, topFix })
+ : undefined
+ }
+ />
+ </div>
+ );
+ };
+
+ render() {
+ return (
+ <>
+ {React.cloneElement(this.props.children, {
+ onMouseEnter: this.handleMouseEnter,
+ onMouseLeave: this.handleMouseLeave,
+ })}
+ {this.isVisible() && (
+ <TooltipPortal>
+ <ScreenPositionFixer ready={isMeasured(this.state)}>
+ {this.renderActual}
+ </ScreenPositionFixer>
+ </TooltipPortal>
+ )}
+ </>
+ );
+ }
+}
+
+class TooltipPortal extends React.Component {
+ el: HTMLElement;
+
+ constructor(props: {}) {
+ super(props);
+ this.el = document.createElement('div');
+ }
+
+ componentDidMount() {
+ document.body.appendChild(this.el);
+ }
+
+ componentWillUnmount() {
+ document.body.removeChild(this.el);
+ }
+
+ render() {
+ return createPortal(this.props.children, this.el);
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/ValidationForm.tsx b/server/sonar-ui-common/components/controls/ValidationForm.tsx
new file mode 100644
index 00000000000..0e2bcd0afa9
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/ValidationForm.tsx
@@ -0,0 +1,72 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { Formik, FormikActions, FormikProps } from 'formik';
+import * as React from 'react';
+
+export type ChildrenProps<V> = T.Omit<FormikProps<V>, 'handleSubmit'>;
+
+interface Props<V> {
+ children: (props: ChildrenProps<V>) => React.ReactNode;
+ initialValues: V;
+ isInitialValid?: boolean;
+ onSubmit: (data: V) => Promise<void>;
+ validate: (data: V) => { [P in keyof V]?: string } | Promise<{ [P in keyof V]?: string }>;
+}
+
+export default class ValidationForm<V> extends React.Component<Props<V>> {
+ mounted = false;
+
+ componentDidMount() {
+ this.mounted = true;
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ }
+
+ handleSubmit = (data: V, { setSubmitting }: FormikActions<V>) => {
+ const result = this.props.onSubmit(data);
+ const stopSubmitting = () => {
+ if (this.mounted) {
+ setSubmitting(false);
+ }
+ };
+
+ if (result) {
+ result.then(stopSubmitting, stopSubmitting);
+ } else {
+ stopSubmitting();
+ }
+ };
+
+ render() {
+ return (
+ <Formik<V>
+ initialValues={this.props.initialValues}
+ isInitialValid={this.props.isInitialValid}
+ onSubmit={this.handleSubmit}
+ validate={this.props.validate}>
+ {({ handleSubmit, ...props }) => (
+ <form onSubmit={handleSubmit}>{this.props.children(props)}</form>
+ )}
+ </Formik>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/ValidationInput.tsx b/server/sonar-ui-common/components/controls/ValidationInput.tsx
new file mode 100644
index 00000000000..a8010ae8d3c
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/ValidationInput.tsx
@@ -0,0 +1,61 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 AlertErrorIcon from '../icons/AlertErrorIcon';
+import AlertSuccessIcon from '../icons/AlertSuccessIcon';
+import MandatoryFieldMarker from '../ui/MandatoryFieldMarker';
+import HelpTooltip from './HelpTooltip';
+
+interface Props {
+ description?: React.ReactNode;
+ children: React.ReactNode;
+ className?: string;
+ error: string | undefined;
+ help?: string;
+ id: string;
+ isInvalid: boolean;
+ isValid: boolean;
+ label: React.ReactNode;
+ required?: boolean;
+}
+
+export default function ValidationInput(props: Props) {
+ const hasError = props.isInvalid && props.error !== undefined;
+ return (
+ <div className={props.className}>
+ <label htmlFor={props.id}>
+ <span className="text-middle">
+ <strong>{props.label}</strong>
+ {props.required && <MandatoryFieldMarker />}
+ </span>
+ {props.help && <HelpTooltip className="spacer-left" overlay={props.help} />}
+ </label>
+ <div className="little-spacer-top spacer-bottom">
+ {props.children}
+ {props.isInvalid && <AlertErrorIcon className="spacer-left text-middle" />}
+ {hasError && (
+ <span className="little-spacer-left text-danger text-middle">{props.error}</span>
+ )}
+ {props.isValid && <AlertSuccessIcon className="spacer-left text-middle" />}
+ </div>
+ {props.description && <div className="note abs-width-400">{props.description}</div>}
+ </div>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/ValidationModal.tsx b/server/sonar-ui-common/components/controls/ValidationModal.tsx
new file mode 100644
index 00000000000..cca7c0f414f
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/ValidationModal.tsx
@@ -0,0 +1,83 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 DeferredSpinner from '../ui/DeferredSpinner';
+import { ResetButtonLink, SubmitButton } from './buttons';
+import Modal, { ModalProps } from './Modal';
+import ValidationForm, { ChildrenProps } from './ValidationForm';
+
+interface Props<V> extends ModalProps {
+ children: (props: ChildrenProps<V>) => React.ReactNode;
+ confirmButtonText: string;
+ header: string;
+ initialValues: V;
+ isDestructive?: boolean;
+ isInitialValid?: boolean;
+ onClose: () => void;
+ onSubmit: (data: V) => Promise<void>;
+ validate: (data: V) => { [P in keyof V]?: string };
+}
+
+export default class ValidationModal<V> extends React.PureComponent<Props<V>> {
+ handleSubmit = (data: V) => {
+ return this.props.onSubmit(data).then(() => {
+ this.props.onClose();
+ });
+ };
+
+ render() {
+ return (
+ <Modal
+ contentLabel={this.props.header}
+ noBackdrop={this.props.noBackdrop}
+ onRequestClose={this.props.onClose}
+ size={this.props.size}>
+ <ValidationForm
+ initialValues={this.props.initialValues}
+ isInitialValid={this.props.isInitialValid}
+ onSubmit={this.handleSubmit}
+ validate={this.props.validate}>
+ {(props) => (
+ <>
+ <header className="modal-head">
+ <h2>{this.props.header}</h2>
+ </header>
+
+ <div className="modal-body">{this.props.children(props)}</div>
+
+ <footer className="modal-foot">
+ <DeferredSpinner className="spacer-right" loading={props.isSubmitting} />
+ <SubmitButton
+ className={this.props.isDestructive ? 'button-red' : undefined}
+ disabled={props.isSubmitting || !props.isValid || !props.dirty}>
+ {this.props.confirmButtonText}
+ </SubmitButton>
+ <ResetButtonLink disabled={props.isSubmitting} onClick={this.props.onClose}>
+ {translate('cancel')}
+ </ResetButtonLink>
+ </footer>
+ </>
+ )}
+ </ValidationForm>
+ </Modal>
+ );
+ }
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/ActionsDropdown-test.tsx b/server/sonar-ui-common/components/controls/__tests__/ActionsDropdown-test.tsx
new file mode 100644
index 00000000000..93c5a8d7d37
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/ActionsDropdown-test.tsx
@@ -0,0 +1,92 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+/* eslint-disable sonarjs/no-duplicate-string */
+import { mount, shallow } from 'enzyme';
+import * as React from 'react';
+import { click } from '../../../helpers/testUtils';
+import { PopupPlacement } from '../../ui/popups';
+import ActionsDropdown, {
+ ActionsDropdownDivider,
+ ActionsDropdownItem,
+ ActionsDropdownProps,
+} from '../ActionsDropdown';
+
+describe('ActionsDropdown', () => {
+ it('should render correctly', () => {
+ expect(shallowRender()).toMatchSnapshot();
+ expect(shallowRender({ small: false })).toMatchSnapshot();
+ });
+
+ function shallowRender(props: Partial<ActionsDropdownProps> = {}) {
+ return shallow(
+ <ActionsDropdown
+ className="foo"
+ onOpen={jest.fn()}
+ overlayPlacement={PopupPlacement.Bottom}
+ small={true}
+ toggleClassName="bar"
+ {...props}>
+ <span>Hello world</span>
+ </ActionsDropdown>
+ );
+ }
+});
+
+describe('ActionsDropdownItem', () => {
+ it('should render correctly', () => {
+ expect(shallowRender()).toMatchSnapshot();
+ expect(shallowRender({ destructive: true, id: 'baz', to: 'path/name' })).toMatchSnapshot();
+ expect(shallowRender({ download: 'foo/bar', to: 'path/name' })).toMatchSnapshot();
+ });
+
+ it('should trigger click', () => {
+ const onClick = jest.fn();
+ const wrapper = shallowRender({ onClick });
+ click(wrapper.find('a'));
+ expect(onClick).toBeCalled();
+ });
+
+ it('should render correctly copy item', () => {
+ const wrapper = mountRender({ copyValue: 'my content to copy to clipboard' });
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ function shallowRender(props: Partial<ActionsDropdownItem['props']> = {}) {
+ return shallow(renderContent(props));
+ }
+
+ function mountRender(props: Partial<ActionsDropdownItem['props']> = {}) {
+ return mount(renderContent(props));
+ }
+
+ function renderContent(props: Partial<ActionsDropdownItem['props']> = {}) {
+ return (
+ <ActionsDropdownItem className="foo" {...props}>
+ <span>Hello world</span>
+ </ActionsDropdownItem>
+ );
+ }
+});
+
+describe('ActionsDropdownDivider', () => {
+ it('should render correctly', () => {
+ expect(shallow(<ActionsDropdownDivider />)).toMatchSnapshot();
+ });
+});
diff --git a/server/sonar-ui-common/components/controls/__tests__/BackButton-test.tsx b/server/sonar-ui-common/components/controls/__tests__/BackButton-test.tsx
new file mode 100644
index 00000000000..77ad09c9f5e
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/BackButton-test.tsx
@@ -0,0 +1,48 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import testTheme from '../../../config/jest/testTheme';
+import { click } from '../../../helpers/testUtils';
+import { ThemeProvider } from '../../theme';
+import BackButton from '../BackButton';
+
+it('should render properly', () => {
+ const wrapper = shallowRender();
+ expect(wrapper).toMatchSnapshot();
+ expect(wrapper.find('ContextConsumer').dive()).toMatchSnapshot();
+});
+
+it('should handle click', () => {
+ const onClick = jest.fn();
+ const wrapper = shallowRender({ onClick });
+ expect(wrapper).toMatchSnapshot();
+ click(wrapper.find('a'));
+ expect(onClick).toBeCalled();
+});
+
+function shallowRender(props: Partial<BackButton['props']> = {}) {
+ return shallow<BackButton>(<BackButton onClick={jest.fn()} {...props} />, {
+ wrappingComponent: ThemeProvider,
+ wrappingComponentProps: {
+ theme: testTheme,
+ },
+ });
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/BoxedGroupAccordion-test.tsx b/server/sonar-ui-common/components/controls/__tests__/BoxedGroupAccordion-test.tsx
new file mode 100644
index 00000000000..7feece1ecdd
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/BoxedGroupAccordion-test.tsx
@@ -0,0 +1,52 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { click } from '../../../helpers/testUtils';
+import BoxedGroupAccordion from '../BoxedGroupAccordion';
+
+it('should render correctly', () => {
+ expect(getWrapper()).toMatchSnapshot();
+});
+
+it('should show the inner content after a click', () => {
+ const onClick = jest.fn();
+ const wrapper = getWrapper({ onClick });
+ click(wrapper.find('.boxed-group-header'));
+
+ expect(onClick).lastCalledWith('foo');
+ wrapper.setProps({ open: true });
+
+ expect(wrapper.find('.boxed-group-inner').exists()).toBe(true);
+});
+
+function getWrapper(props = {}) {
+ return shallow(
+ <BoxedGroupAccordion
+ data="foo"
+ onClick={() => {}}
+ open={false}
+ renderHeader={() => <div>header content</div>}
+ title="Foo"
+ {...props}>
+ <div>inner content</div>
+ </BoxedGroupAccordion>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/BoxedTabs-test.tsx b/server/sonar-ui-common/components/controls/__tests__/BoxedTabs-test.tsx
new file mode 100644
index 00000000000..d597369ea1d
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/BoxedTabs-test.tsx
@@ -0,0 +1,66 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { mount, shallow } from 'enzyme';
+import * as React from 'react';
+import BoxedTabs, { BoxedTabsProps } from '../BoxedTabs';
+
+it('should render correctly', () => {
+ expect(mountRender()).toMatchSnapshot();
+});
+
+it('should call onSelect when a tab is clicked', () => {
+ const onSelect = jest.fn();
+ const wrapper = shallowRender({ onSelect });
+
+ wrapper.find('Styled(button)').get(1).props.onClick();
+
+ expect(onSelect).toHaveBeenCalledWith('b');
+});
+
+function shallowRender(overrides: Partial<BoxedTabsProps<string>> = {}) {
+ return shallow(dom(overrides));
+}
+
+function mountRender(overrides: Partial<BoxedTabsProps<string>> = {}) {
+ return mount(dom(overrides));
+}
+
+function dom(overrides) {
+ return (
+ <BoxedTabs
+ className="boxed-tabs"
+ onSelect={jest.fn()}
+ selected="a"
+ tabs={[
+ { key: 'a', label: 'labela' },
+ { key: 'b', label: 'labelb' },
+ {
+ key: 'c',
+ label: (
+ <span>
+ Complex label <strong>!!!</strong>
+ </span>
+ ),
+ },
+ ]}
+ {...overrides}
+ />
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/Checkbox-test.tsx b/server/sonar-ui-common/components/controls/__tests__/Checkbox-test.tsx
new file mode 100644
index 00000000000..e05e0c87d74
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/Checkbox-test.tsx
@@ -0,0 +1,115 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { click } from '../../../helpers/testUtils';
+import Checkbox from '../Checkbox';
+
+it('should render', () => {
+ const checkbox = shallow(<Checkbox checked={true} onCheck={() => {}} title="Title value" />);
+ expect(checkbox).toMatchSnapshot();
+});
+
+it('should render unchecked', () => {
+ const checkbox = shallow(<Checkbox checked={false} onCheck={() => true} />);
+ expect(checkbox.is('.icon-checkbox-checked')).toBe(false);
+ expect(checkbox.prop('aria-checked')).toBe(false);
+});
+
+it('should render checked', () => {
+ const checkbox = shallow(<Checkbox checked={true} onCheck={() => true} />);
+ expect(checkbox.is('.icon-checkbox-checked')).toBe(true);
+ expect(checkbox.prop('aria-checked')).toBe(true);
+});
+
+it('should render disabled', () => {
+ const checkbox = shallow(<Checkbox checked={true} disabled={true} onCheck={() => true} />);
+ expect(checkbox.is('.icon-checkbox-disabled')).toBe(true);
+});
+
+it('should render unchecked third state', () => {
+ const checkbox = shallow(<Checkbox checked={false} onCheck={() => true} thirdState={true} />);
+ expect(checkbox.is('.icon-checkbox-single')).toBe(true);
+ expect(checkbox.is('.icon-checkbox-checked')).toBe(false);
+});
+
+it('should render checked third state', () => {
+ const checkbox = shallow(<Checkbox checked={true} onCheck={() => true} thirdState={true} />);
+ expect(checkbox.is('.icon-checkbox-single')).toBe(true);
+ expect(checkbox.is('.icon-checkbox-checked')).toBe(true);
+});
+
+it('should render with a spinner', () => {
+ const checkbox = shallow(<Checkbox checked={false} loading={true} onCheck={() => true} />);
+ expect(checkbox.find('DeferredSpinner').exists()).toBe(true);
+});
+
+it('should render children', () => {
+ const checkbox = shallow(
+ <Checkbox checked={false} onCheck={() => true}>
+ <span>foo</span>
+ </Checkbox>
+ );
+ expect(checkbox.hasClass('link-checkbox')).toBe(true);
+ expect(checkbox.find('span').exists()).toBe(true);
+});
+
+it('should render children with a spinner', () => {
+ const checkbox = shallow(
+ <Checkbox checked={false} loading={true} onCheck={() => true}>
+ <span>foo</span>
+ </Checkbox>
+ );
+ expect(checkbox.hasClass('link-checkbox')).toBe(true);
+ expect(checkbox.find('span').exists()).toBe(true);
+ expect(checkbox.find('DeferredSpinner').exists()).toBe(true);
+});
+
+it('should call onCheck', () => {
+ const onCheck = jest.fn();
+ const checkbox = shallow(<Checkbox checked={false} onCheck={onCheck} />);
+ click(checkbox);
+ expect(onCheck).toBeCalledWith(true, undefined);
+});
+
+it('should not call onCheck when disabled', () => {
+ const onCheck = jest.fn();
+ const checkbox = shallow(<Checkbox checked={false} disabled={true} onCheck={onCheck} />);
+ click(checkbox);
+ expect(onCheck).toHaveBeenCalledTimes(0);
+});
+
+it('should call onCheck with id as second parameter', () => {
+ const onCheck = jest.fn();
+ const checkbox = shallow(<Checkbox checked={false} id="foo" onCheck={onCheck} />);
+ click(checkbox);
+ expect(onCheck).toBeCalledWith(true, 'foo');
+});
+
+it('should apply custom class', () => {
+ const checkbox = shallow(
+ <Checkbox checked={true} className="customclass" onCheck={() => true} />
+ );
+ expect(checkbox.is('.customclass')).toBe(true);
+});
+
+it('should render the checkbox on the right', () => {
+ expect(shallow(<Checkbox checked={true} onCheck={() => true} right={true} />)).toMatchSnapshot();
+});
diff --git a/server/sonar-ui-common/components/controls/__tests__/ClickEventBoundary-test.tsx b/server/sonar-ui-common/components/controls/__tests__/ClickEventBoundary-test.tsx
new file mode 100644
index 00000000000..c20dc06a910
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/ClickEventBoundary-test.tsx
@@ -0,0 +1,49 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { mount } from 'enzyme';
+import * as React from 'react';
+import ClickEventBoundary from '../ClickEventBoundary';
+
+it('should render correctly', () => {
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should correctly capture a click event', () => {
+ const parentOnClick = jest.fn();
+ const childOnClick = jest.fn();
+ const wrapper = shallowRender({ onClick: parentOnClick }, { onClick: childOnClick });
+ // Don't use our click() helper, so we make sure the bubbling works correctly.
+ wrapper.find('button').simulate('click');
+ expect(childOnClick).toBeCalled();
+ expect(parentOnClick).not.toBeCalled();
+});
+
+function shallowRender(parentProps = {}, childProps = {}) {
+ // We need to mount in order to support event bubbling.
+ return mount(
+ <div {...parentProps}>
+ <ClickEventBoundary>
+ <button type="button" {...childProps}>
+ Click me
+ </button>
+ </ClickEventBoundary>
+ </div>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/ConfirmButton-test.tsx b/server/sonar-ui-common/components/controls/__tests__/ConfirmButton-test.tsx
new file mode 100644
index 00000000000..abd1ae483e6
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/ConfirmButton-test.tsx
@@ -0,0 +1,44 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import ConfirmButton from '../ConfirmButton';
+
+it('should display a modal button', () => {
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should display a confirm modal', () => {
+ expect(
+ shallowRender().find('ModalButton').prop<Function>('modal')({ onClose: jest.fn() })
+ ).toMatchSnapshot();
+});
+
+function shallowRender() {
+ return shallow(
+ <ConfirmButton
+ confirmButtonText="submit"
+ modalBody={<div />}
+ modalHeader="title"
+ onConfirm={jest.fn()}>
+ {() => 'Confirm button'}
+ </ConfirmButton>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/ConfirmModal-test.tsx b/server/sonar-ui-common/components/controls/__tests__/ConfirmModal-test.tsx
new file mode 100644
index 00000000000..15fd56c0622
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/ConfirmModal-test.tsx
@@ -0,0 +1,60 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { submit, waitAndUpdate } from '../../../helpers/testUtils';
+import ConfirmModal from '../ConfirmModal';
+
+it('should render correctly', () => {
+ const wrapper = shallow(
+ <ConfirmModal
+ confirmButtonText="confirm"
+ confirmData="data"
+ header="title"
+ onClose={jest.fn()}
+ onConfirm={jest.fn()}>
+ <p>My confirm message</p>
+ </ConfirmModal>
+ );
+ expect(wrapper).toMatchSnapshot();
+ expect(wrapper.find('SimpleModal').dive()).toMatchSnapshot();
+});
+
+it('should confirm and close after confirm', async () => {
+ const onClose = jest.fn();
+ const onConfirm = jest.fn(() => Promise.resolve());
+ const wrapper = shallow(
+ <ConfirmModal
+ confirmButtonText="confirm"
+ confirmData="data"
+ header="title"
+ onClose={onClose}
+ onConfirm={onConfirm}>
+ <p>My confirm message</p>
+ </ConfirmModal>
+ );
+ const modalContent = wrapper.find('SimpleModal').dive();
+ submit(modalContent.find('form'));
+ expect(onConfirm).toBeCalledWith('data');
+ expect(modalContent.find('footer')).toMatchSnapshot();
+
+ await waitAndUpdate(wrapper);
+ expect(onClose).toHaveBeenCalled();
+});
diff --git a/server/sonar-ui-common/components/controls/__tests__/Dropdown-test.tsx b/server/sonar-ui-common/components/controls/__tests__/Dropdown-test.tsx
new file mode 100644
index 00000000000..0595d595a27
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/Dropdown-test.tsx
@@ -0,0 +1,111 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { mount, shallow, ShallowWrapper } from 'enzyme';
+import * as React from 'react';
+import { click } from '../../../helpers/testUtils';
+import { Popup, PopupPlacement } from '../../ui/popups';
+import { Button } from '../buttons';
+import Dropdown, { DropdownOverlay } from '../Dropdown';
+import ScreenPositionFixer from '../ScreenPositionFixer';
+
+describe('Dropdown', () => {
+ it('renders', () => {
+ expect(
+ shallow(<Dropdown overlay={<div id="overlay" />}>{() => <div />}</Dropdown>)
+ .find('div')
+ .exists()
+ ).toBe(true);
+ });
+
+ it('toggles with element child', () => {
+ checkToggle(
+ shallow(
+ <Dropdown overlay={<div id="overlay" />}>
+ <Button />
+ </Dropdown>
+ )
+ );
+
+ checkToggle(
+ shallow(
+ <Dropdown overlay={<div id="overlay" />}>
+ <a href="#">click me!</a>
+ </Dropdown>
+ ),
+ 'a'
+ );
+ });
+
+ it('toggles with render prop', () => {
+ checkToggle(
+ shallow(
+ <Dropdown overlay={<div id="overlay" />}>
+ {({ onToggleClick }) => <Button onClick={onToggleClick} />}
+ </Dropdown>
+ )
+ );
+ });
+
+ it('should call onOpen', () => {
+ const onOpen = jest.fn();
+ const wrapper = mount(
+ <Dropdown onOpen={onOpen} overlay={<div id="overlay" />}>
+ <Button />
+ </Dropdown>
+ );
+ expect(onOpen).not.toBeCalled();
+ click(wrapper.find('Button'));
+ expect(onOpen).toBeCalled();
+ });
+
+ function checkToggle(wrapper: ShallowWrapper, selector = 'Button') {
+ expect(wrapper.state()).toEqual({ open: false });
+
+ click(wrapper.find(selector));
+ expect(wrapper.state()).toEqual({ open: true });
+
+ click(wrapper.find(selector));
+ expect(wrapper.state()).toEqual({ open: false });
+ }
+});
+
+describe('DropdownOverlay', () => {
+ it('should render overlay with screen fixer', () => {
+ const wrapper = shallow(
+ <DropdownOverlay>
+ <div />
+ </DropdownOverlay>,
+ // disable ScreenPositionFixer positioning
+ { disableLifecycleMethods: true }
+ );
+
+ expect(wrapper.is(ScreenPositionFixer)).toBe(true);
+ expect(wrapper.dive().dive().dive().is(Popup)).toBe(true);
+ });
+
+ it('should render overlay without screen fixer', () => {
+ const wrapper = shallow(
+ <DropdownOverlay placement={PopupPlacement.BottomRight}>
+ <div />
+ </DropdownOverlay>
+ );
+ expect(wrapper.is('Popup')).toBe(true);
+ });
+});
diff --git a/server/sonar-ui-common/components/controls/__tests__/EscKeydownHandler-test.tsx b/server/sonar-ui-common/components/controls/__tests__/EscKeydownHandler-test.tsx
new file mode 100644
index 00000000000..12641596f46
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/EscKeydownHandler-test.tsx
@@ -0,0 +1,47 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { KeyCodes } from '../../../helpers/keycodes';
+import { keydown } from '../../../helpers/testUtils';
+import EscKeydownHandler from '../EscKeydownHandler';
+
+jest.useFakeTimers();
+
+it('should render correctly', () => {
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should correctly trigger the keydown handler when hitting Esc', () => {
+ const onKeydown = jest.fn();
+ shallowRender({ onKeydown });
+ jest.runAllTimers();
+ keydown(KeyCodes.Escape);
+ expect(onKeydown).toBeCalled();
+});
+
+function shallowRender(props: Partial<EscKeydownHandler['props']> = {}) {
+ return shallow<EscKeydownHandler>(
+ <EscKeydownHandler onKeydown={jest.fn()} {...props}>
+ <span>Hi there</span>
+ </EscKeydownHandler>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/FavoriteButton-test.tsx b/server/sonar-ui-common/components/controls/__tests__/FavoriteButton-test.tsx
new file mode 100644
index 00000000000..61919eb6eb7
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/FavoriteButton-test.tsx
@@ -0,0 +1,54 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { click } from '../../../helpers/testUtils';
+import FavoriteButton, { Props } from '../FavoriteButton';
+
+it('should render favorite', () => {
+ const favorite = renderFavoriteBase({ favorite: true });
+ expect(favorite).toMatchSnapshot();
+});
+
+it('should render not favorite', () => {
+ const favorite = renderFavoriteBase({ favorite: false });
+ expect(favorite).toMatchSnapshot();
+});
+
+it('should update properly', () => {
+ const favorite = renderFavoriteBase({ favorite: false });
+ expect(favorite).toMatchSnapshot();
+
+ favorite.setProps({ favorite: true });
+ expect(favorite).toMatchSnapshot();
+});
+
+it('should toggle favorite', () => {
+ const toggleFavorite = jest.fn();
+ const favorite = renderFavoriteBase({ toggleFavorite });
+ click(favorite.find('ButtonLink'));
+ expect(toggleFavorite).toBeCalled();
+});
+
+function renderFavoriteBase(props: Partial<Props> = {}) {
+ return shallow(
+ <FavoriteButton favorite={true} qualifier="TRK" toggleFavorite={jest.fn()} {...props} />
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/GlobalMessages-test.tsx b/server/sonar-ui-common/components/controls/__tests__/GlobalMessages-test.tsx
new file mode 100644
index 00000000000..3dc4eda5661
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/GlobalMessages-test.tsx
@@ -0,0 +1,64 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import { matchers } from 'jest-emotion';
+import * as React from 'react';
+import testTheme from '../../../config/jest/testTheme';
+import GlobalMessages, { GlobalMessagesProps } from '../GlobalMessages';
+
+expect.extend(matchers);
+
+it('should not render when no message', () => {
+ expect(shallowRender({ messages: [] }).type()).toBeNull();
+});
+
+it('should render correctly with a message', () => {
+ const wrapper = shallowRender();
+ expect(wrapper).toMatchSnapshot();
+ expect(wrapper.find('GlobalMessage').first().dive()).toMatchSnapshot();
+ expect(wrapper.find('GlobalMessage').last().dive()).toMatchSnapshot();
+});
+
+it('should render with correct css', () => {
+ const wrapper = shallowRender();
+ expect(wrapper.render()).toMatchSnapshot();
+ expect(wrapper.find('GlobalMessage').first().render()).toHaveStyleRule(
+ 'background-color',
+ testTheme.colors.red
+ );
+
+ expect(wrapper.find('GlobalMessage').last().render()).toHaveStyleRule(
+ 'background-color',
+ testTheme.colors.green
+ );
+});
+
+function shallowRender(props: Partial<GlobalMessagesProps> = {}) {
+ return shallow(
+ <GlobalMessages
+ closeGlobalMessage={jest.fn()}
+ messages={[
+ { id: '1', level: 'ERROR', message: 'Test' },
+ { id: '2', level: 'SUCCESS', message: 'Test 2' },
+ ]}
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/HelpTooltip-test.tsx b/server/sonar-ui-common/components/controls/__tests__/HelpTooltip-test.tsx
new file mode 100644
index 00000000000..ecd7a50539e
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/HelpTooltip-test.tsx
@@ -0,0 +1,53 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import testTheme from '../../../config/jest/testTheme';
+import { ThemeProvider } from '../../theme';
+import HelpTooltip, { DarkHelpTooltip } from '../HelpTooltip';
+
+it('should render properly', () => {
+ const wrapper = shallow(<HelpTooltip overlay={<div className="my-overlay" />} />, {
+ wrappingComponent: ThemeProvider,
+ wrappingComponentProps: {
+ theme: testTheme,
+ },
+ });
+ expect(wrapper).toMatchSnapshot('default');
+ expect(wrapper.find('ContextConsumer').dive()).toMatchSnapshot('default icon');
+
+ wrapper.setProps({ size: 18 });
+ expect(wrapper.find('ContextConsumer').dive().prop('size')).toBe(18);
+});
+
+it('should render dark helptooltip properly', () => {
+ const wrapper = shallow(<DarkHelpTooltip overlay={<div className="my-overlay" />} size={14} />, {
+ wrappingComponent: ThemeProvider,
+ wrappingComponentProps: {
+ theme: testTheme,
+ },
+ });
+ expect(wrapper).toMatchSnapshot('dark');
+ expect(wrapper.find('ContextConsumer').dive()).toMatchSnapshot('dark icon');
+
+ wrapper.setProps({ size: undefined });
+ expect(wrapper.find('ContextConsumer').dive().prop('size')).toBe(12);
+});
diff --git a/server/sonar-ui-common/components/controls/__tests__/IdentityProviderLink-test.tsx b/server/sonar-ui-common/components/controls/__tests__/IdentityProviderLink-test.tsx
new file mode 100644
index 00000000000..9a8b0a49481
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/IdentityProviderLink-test.tsx
@@ -0,0 +1,43 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import IdentityProviderLink from '../IdentityProviderLink';
+
+const identityProvider = {
+ backgroundColor: '#000',
+ iconPath: '/some/path',
+ key: 'foo',
+ name: 'Foo',
+};
+
+it('should render correctly', () => {
+ expect(
+ shallow(
+ <IdentityProviderLink
+ backgroundColor={identityProvider.backgroundColor}
+ iconPath={identityProvider.iconPath}
+ name={identityProvider.name}
+ url="/url/foo/bar">
+ Link text
+ </IdentityProviderLink>
+ )
+ ).toMatchSnapshot();
+});
diff --git a/server/sonar-ui-common/components/controls/__tests__/InputValidationField-test.tsx b/server/sonar-ui-common/components/controls/__tests__/InputValidationField-test.tsx
new file mode 100644
index 00000000000..363e3d79a8a
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/InputValidationField-test.tsx
@@ -0,0 +1,44 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import InputValidationField from '../InputValidationField';
+
+it('should render correctly', () => {
+ expect(getWrapper()).toMatchSnapshot();
+});
+
+function getWrapper(props = {}) {
+ return shallow(
+ <InputValidationField
+ description="Field description"
+ dirty={true}
+ disabled={false}
+ error="Bad formatting"
+ label="Foo field"
+ name="field"
+ onBlur={jest.fn()}
+ onChange={jest.fn()}
+ touched={true}
+ value="foo"
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/ListFooter-test.tsx b/server/sonar-ui-common/components/controls/__tests__/ListFooter-test.tsx
new file mode 100644
index 00000000000..6ea69c990ae
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/ListFooter-test.tsx
@@ -0,0 +1,57 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { click } from '../../../helpers/testUtils';
+import { Button } from '../buttons';
+import ListFooter, { ListFooterProps } from '../ListFooter';
+
+it('should render correctly', () => {
+ expect(shallowRender()).toMatchSnapshot('default');
+ expect(shallowRender({ loading: true })).toMatchSnapshot('loading');
+ expect(shallowRender({ needReload: true, reload: jest.fn() })).toMatchSnapshot('reload');
+ expect(shallowRender({ loading: true, needReload: true, reload: jest.fn() })).toMatchSnapshot(
+ 'reload, loading'
+ );
+ expect(shallowRender({ loadMore: undefined })).toMatchSnapshot(
+ 'empty if no loadMore nor reload props'
+ );
+ expect(shallowRender({ count: 5 })).toMatchSnapshot('empty if everything is loaded');
+});
+
+it('should properly call loadMore', () => {
+ const loadMore = jest.fn();
+ const wrapper = shallowRender({ loadMore });
+ click(wrapper.find(Button));
+ expect(loadMore).toBeCalled();
+});
+
+it('should properly call reload', () => {
+ const reload = jest.fn();
+ const wrapper = shallowRender({ needReload: true, reload });
+ click(wrapper.find(Button));
+ expect(reload).toBeCalled();
+});
+
+function shallowRender(props: Partial<ListFooterProps> = {}) {
+ return shallow<ListFooterProps>(
+ <ListFooter count={3} loadMore={jest.fn()} total={5} {...props} />
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/ModalButton-test.tsx b/server/sonar-ui-common/components/controls/__tests__/ModalButton-test.tsx
new file mode 100644
index 00000000000..91756df5a55
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/ModalButton-test.tsx
@@ -0,0 +1,38 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { click } from '../../../helpers/testUtils';
+import ModalButton from '../ModalButton';
+
+it('should open/close modal', () => {
+ const wrapper = shallow(
+ <ModalButton modal={({ onClose }) => <button id="js-close" onClick={onClose} type="button" />}>
+ {({ onClick }) => <button id="js-open" onClick={onClick} type="button" />}
+ </ModalButton>
+ );
+
+ expect(wrapper.find('#js-open').exists()).toBe(true);
+ expect(wrapper.find('#js-close').exists()).toBe(false);
+ click(wrapper.find('#js-open'));
+ expect(wrapper.find('#js-close').exists()).toBe(true);
+ click(wrapper.find('#js-close'));
+ expect(wrapper.find('#js-close').exists()).toBe(false);
+});
diff --git a/server/sonar-ui-common/components/controls/__tests__/ModalValidationField-test.tsx b/server/sonar-ui-common/components/controls/__tests__/ModalValidationField-test.tsx
new file mode 100644
index 00000000000..1f5cbb5b7e3
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/ModalValidationField-test.tsx
@@ -0,0 +1,48 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import ModalValidationField from '../ModalValidationField';
+
+it('should display the field without any error/validation', () => {
+ expect(getWrapper({ description: 'Describe Foo.', touched: false })).toMatchSnapshot();
+ expect(getWrapper({ dirty: false })).toMatchSnapshot();
+});
+
+it('should display the field as valid', () => {
+ expect(getWrapper({ error: undefined })).toMatchSnapshot();
+});
+
+it('should display the field with an error', () => {
+ expect(getWrapper()).toMatchSnapshot();
+});
+
+function getWrapper(props = {}) {
+ return shallow(
+ <ModalValidationField
+ dirty={true}
+ error="Is required"
+ label={<label>Foo</label>}
+ touched={true}
+ {...props}>
+ {({ className }) => <input className={className} type="text" />}
+ </ModalValidationField>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/Radio-test.tsx b/server/sonar-ui-common/components/controls/__tests__/Radio-test.tsx
new file mode 100644
index 00000000000..feb1a6e060e
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/Radio-test.tsx
@@ -0,0 +1,52 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { click } from '../../../helpers/testUtils';
+import Radio from '../Radio';
+
+it('should render properly', () => {
+ const wrapper = shallowRender();
+ expect(wrapper).toMatchSnapshot('not checked');
+
+ wrapper.setProps({ checked: true });
+ expect(wrapper).toMatchSnapshot('checked');
+});
+
+it('should invoke callback on click', () => {
+ const onCheck = jest.fn();
+ const value = 'value';
+ const wrapper = shallowRender({ onCheck, value });
+
+ click(wrapper);
+ expect(onCheck).toHaveBeenCalled();
+});
+
+it('should not invoke callback on click when disabled', () => {
+ const onCheck = jest.fn();
+ const wrapper = shallowRender({ disabled: true, onCheck });
+
+ click(wrapper);
+ expect(onCheck).not.toHaveBeenCalled();
+});
+
+function shallowRender(props?: Partial<Radio['props']>) {
+ return shallow<Radio>(<Radio checked={false} onCheck={jest.fn()} value="value" {...props} />);
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/RadioCard-test.tsx b/server/sonar-ui-common/components/controls/__tests__/RadioCard-test.tsx
new file mode 100644
index 00000000000..27ed8f6eadc
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/RadioCard-test.tsx
@@ -0,0 +1,59 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { click } from '../../../helpers/testUtils';
+import RadioCard from '../RadioCard';
+
+it('should render correctly', () => {
+ expect(
+ shallow(
+ <RadioCard recommended="Recommended for you" title="Radio Card" titleInfo="info">
+ <div>content</div>
+ </RadioCard>
+ )
+ ).toMatchSnapshot();
+
+ expect(
+ shallow(
+ <RadioCard
+ recommended="Recommended for you"
+ title="Radio Card Vertical"
+ titleInfo="info"
+ vertical={true}>
+ <div>content</div>
+ </RadioCard>
+ )
+ ).toMatchSnapshot();
+});
+
+it('should be actionable', () => {
+ const onClick = jest.fn();
+ const wrapper = shallow(
+ <RadioCard onClick={onClick} title="Radio Card">
+ <div>content</div>
+ </RadioCard>
+ );
+
+ expect(wrapper).toMatchSnapshot();
+ click(wrapper);
+ wrapper.setProps({ selected: true, titleInfo: 'info' });
+ expect(wrapper).toMatchSnapshot();
+});
diff --git a/server/sonar-ui-common/components/controls/__tests__/RadioToggle-test.tsx b/server/sonar-ui-common/components/controls/__tests__/RadioToggle-test.tsx
new file mode 100644
index 00000000000..6d5aa2e5840
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/RadioToggle-test.tsx
@@ -0,0 +1,94 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { change } from '../../../helpers/testUtils';
+import RadioToggle from '../RadioToggle';
+
+it('renders', () => {
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+it('calls onCheck', () => {
+ const onCheck = jest.fn();
+ const wrapper = shallowRender({ onCheck });
+ change(wrapper.find('input[id="sample__two"]'), '');
+ expect(onCheck).toBeCalledWith('two');
+});
+
+it('handles numeric values', () => {
+ const onCheck = jest.fn();
+ const wrapper = shallowRender({
+ onCheck,
+ options: [
+ { value: 1, label: 'first', tooltip: 'foo' },
+ { value: 2, label: 'second', tooltip: 'bar' },
+ ],
+ value: 1,
+ });
+ change(wrapper.find('input[id="sample__2"]'), '');
+ expect(onCheck).toBeCalledWith(2);
+});
+
+it('handles boolean values', () => {
+ const onCheck = jest.fn();
+ const wrapper = shallowRender({
+ onCheck,
+ options: [
+ { value: true, label: 'yes', tooltip: 'foo' },
+ { value: false, label: 'no', tooltip: 'bar' },
+ ],
+ value: true,
+ });
+ change(wrapper.find('input[id="sample__false"]'), '');
+ expect(onCheck).toBeCalledWith(false);
+});
+
+it('initialize value', () => {
+ const onCheck = jest.fn();
+ const wrapper = shallowRender({
+ onCheck,
+ options: [
+ { value: 1, label: 'first', tooltip: 'foo' },
+ { value: 2, label: 'second', tooltip: 'bar', disabled: true },
+ ],
+ value: 2,
+ });
+ expect(wrapper.find('input[checked=true]').prop('id')).toBe('sample__2');
+});
+
+it('accepts advanced options fields', () => {
+ expect(
+ shallowRender({
+ options: [
+ { value: 'one', label: 'first', tooltip: 'foo' },
+ { value: 'two', label: 'second', tooltip: 'bar', disabled: true },
+ ],
+ })
+ ).toMatchSnapshot();
+});
+
+function shallowRender(props?: Partial<RadioToggle['props']>) {
+ const options = [
+ { value: 'one', label: 'first' },
+ { value: 'two', label: 'second' },
+ ];
+ return shallow(<RadioToggle name="sample" onCheck={() => true} options={options} {...props} />);
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/ReloadButton-test.tsx b/server/sonar-ui-common/components/controls/__tests__/ReloadButton-test.tsx
new file mode 100644
index 00000000000..f6a92859d69
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/ReloadButton-test.tsx
@@ -0,0 +1,49 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import testTheme from '../../../config/jest/testTheme';
+import { click } from '../../../helpers/testUtils';
+import { ThemeProvider } from '../../theme';
+import ReloadButton from '../ReloadButton';
+
+it('should render properly', () => {
+ const wrapper = shallowRender();
+ expect(wrapper).toMatchSnapshot();
+ expect(wrapper.find('ContextConsumer').dive()).toMatchSnapshot();
+});
+
+it('should handle click', () => {
+ const onClick = jest.fn();
+ const wrapper = shallowRender({ onClick });
+ expect(wrapper).toMatchSnapshot();
+ click(wrapper.find('a'));
+ expect(onClick).toBeCalled();
+});
+
+function shallowRender(props: Partial<ReloadButton['props']> = {}) {
+ return shallow<ReloadButton>(<ReloadButton onClick={jest.fn()} {...props} />, {
+ wrappingComponent: ThemeProvider,
+ wrappingComponentProps: {
+ theme: testTheme,
+ },
+ });
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/ScreenPositionFixer-test.tsx b/server/sonar-ui-common/components/controls/__tests__/ScreenPositionFixer-test.tsx
new file mode 100644
index 00000000000..aae3a9e0cc3
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/ScreenPositionFixer-test.tsx
@@ -0,0 +1,93 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { mount } from 'enzyme';
+import * as React from 'react';
+import { resizeWindowTo, setNodeRect } from '../../../helpers/testUtils';
+import ScreenPositionFixer from '../ScreenPositionFixer';
+
+jest.mock('lodash', () => {
+ const lodash = require.requireActual('lodash');
+ lodash.throttle = (fn: any) => () => fn();
+ return lodash;
+});
+
+jest.mock('react-dom', () => ({
+ findDOMNode: jest.fn(),
+}));
+
+beforeEach(() => {
+ setNodeRect({ left: 50, top: 50 });
+ resizeWindowTo(1000, 1000);
+});
+
+it('should fix position', () => {
+ const children = jest.fn(() => <div />);
+ mountRender({ children });
+
+ setNodeRect({ left: 50, top: 50 });
+ resizeWindowTo(75, 1000);
+ expect(children).toHaveBeenLastCalledWith({ leftFix: -29, topFix: 0 });
+
+ resizeWindowTo(1000, 75);
+ expect(children).toHaveBeenLastCalledWith({ leftFix: 0, topFix: -29 });
+
+ setNodeRect({ left: -10, top: 50 });
+ resizeWindowTo(1000, 1000);
+ expect(children).toHaveBeenLastCalledWith({ leftFix: 14, topFix: 0 });
+
+ setNodeRect({ left: 50, top: -10 });
+ resizeWindowTo();
+ expect(children).toHaveBeenLastCalledWith({ leftFix: 0, topFix: 14 });
+});
+
+it('should render two times', () => {
+ const children = jest.fn(() => <div />);
+ mountRender({ children });
+ expect(children).toHaveBeenCalledTimes(2);
+ expect(children).toHaveBeenCalledWith({});
+ expect(children).toHaveBeenLastCalledWith({ leftFix: 0, topFix: 0 });
+});
+
+it('should re-position when `ready` turns to `true`', () => {
+ const children = jest.fn(() => <div />);
+ const wrapper = mountRender({ children, ready: false });
+ expect(children).toHaveBeenCalledTimes(2);
+ wrapper.setProps({ ready: true });
+ // 2 + 1 (props change) + 1 (new measurement)
+ expect(children).toHaveBeenCalledTimes(4);
+});
+
+it('should re-position when window is resized', () => {
+ const children = jest.fn(() => <div />);
+ const wrapper = mountRender({ children });
+ expect(children).toHaveBeenCalledTimes(2);
+
+ resizeWindowTo();
+ // 2 + 1 (new measurement)
+ expect(children).toHaveBeenCalledTimes(3);
+
+ wrapper.unmount();
+ resizeWindowTo();
+ expect(children).toHaveBeenCalledTimes(3);
+});
+
+function mountRender(props: ScreenPositionFixer['props']) {
+ return mount(<ScreenPositionFixer {...props} />);
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/SearchBox-test.tsx b/server/sonar-ui-common/components/controls/__tests__/SearchBox-test.tsx
new file mode 100644
index 00000000000..aaed9ed16c0
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/SearchBox-test.tsx
@@ -0,0 +1,90 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { mount, shallow } from 'enzyme';
+import * as React from 'react';
+import { change, click } from '../../../helpers/testUtils';
+import SearchBox from '../SearchBox';
+
+jest.mock('lodash', () => {
+ const lodash = jest.requireActual('lodash');
+ const debounce = (fn: Function) => {
+ const debounced: any = (...args: any[]) => fn(...args);
+ debounced.cancel = jest.fn();
+ return debounced;
+ };
+ return Object.assign({}, lodash, { debounce });
+});
+
+it('renders', () => {
+ const wrapper = shallow(
+ <SearchBox
+ maxLength={150}
+ minLength={2}
+ onChange={jest.fn()}
+ placeholder="placeholder"
+ value="foo"
+ />
+ );
+ expect(wrapper).toMatchSnapshot();
+});
+
+it('warns when input is too short', () => {
+ const wrapper = shallow(
+ <SearchBox minLength={2} onChange={jest.fn()} placeholder="placeholder" value="f" />
+ );
+ expect(wrapper.find('.search-box-note').exists()).toBe(true);
+});
+
+it('shows clear button only when there is a value', () => {
+ const wrapper = shallow(<SearchBox onChange={jest.fn()} placeholder="placeholder" value="f" />);
+ expect(wrapper.find('.search-box-clear').exists()).toBe(true);
+ wrapper.setProps({ value: '' });
+ expect(wrapper.find('.search-box-clear').exists()).toBe(false);
+});
+
+it('attaches ref', () => {
+ const ref = jest.fn();
+ mount(<SearchBox innerRef={ref} onChange={jest.fn()} placeholder="placeholder" value="f" />);
+ expect(ref).toBeCalled();
+ expect(ref.mock.calls[0][0]).toBeInstanceOf(HTMLInputElement);
+});
+
+it('resets', () => {
+ const onChange = jest.fn();
+ const wrapper = shallow(<SearchBox onChange={onChange} placeholder="placeholder" value="f" />);
+ click(wrapper.find('.search-box-clear'));
+ expect(onChange).toBeCalledWith('');
+});
+
+it('changes', () => {
+ const onChange = jest.fn();
+ const wrapper = shallow(<SearchBox onChange={onChange} placeholder="placeholder" value="f" />);
+ change(wrapper.find('.search-box-input'), 'foo');
+ expect(onChange).toBeCalledWith('foo');
+});
+
+it('does not change when value is too short', () => {
+ const onChange = jest.fn();
+ const wrapper = shallow(
+ <SearchBox minLength={3} onChange={onChange} placeholder="placeholder" value="" />
+ );
+ change(wrapper.find('.search-box-input'), 'fo');
+ expect(onChange).not.toBeCalled();
+});
diff --git a/server/sonar-ui-common/components/controls/__tests__/SearchSelect-test.tsx b/server/sonar-ui-common/components/controls/__tests__/SearchSelect-test.tsx
new file mode 100644
index 00000000000..71ca3e79932
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/SearchSelect-test.tsx
@@ -0,0 +1,50 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import SearchSelect from '../SearchSelect';
+
+jest.mock('lodash', () => {
+ const lodash = require.requireActual('lodash');
+ lodash.debounce = jest.fn((fn) => fn);
+ return lodash;
+});
+
+it('should render Select', () => {
+ expect(shallow(<SearchSelect onSearch={jest.fn()} onSelect={jest.fn()} />)).toMatchSnapshot();
+});
+
+it('should call onSelect', () => {
+ const onSelect = jest.fn();
+ const wrapper = shallow(<SearchSelect onSearch={jest.fn()} onSelect={onSelect} />);
+ wrapper.prop('onChange')({ value: 'foo' });
+ expect(onSelect).lastCalledWith({ value: 'foo' });
+});
+
+it('should call onSearch', () => {
+ const onSearch = jest.fn().mockReturnValue(Promise.resolve([]));
+ const wrapper = shallow(
+ <SearchSelect minimumQueryLength={2} onSearch={onSearch} onSelect={jest.fn()} />
+ );
+ wrapper.prop('onInputChange')('f');
+ expect(onSearch).not.toHaveBeenCalled();
+ wrapper.prop('onInputChange')('foo');
+ expect(onSearch).lastCalledWith('foo');
+});
diff --git a/server/sonar-ui-common/components/controls/__tests__/SelectList-test.tsx b/server/sonar-ui-common/components/controls/__tests__/SelectList-test.tsx
new file mode 100644
index 00000000000..e4a4ed22346
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/SelectList-test.tsx
@@ -0,0 +1,148 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { waitAndUpdate } from '../../../helpers/testUtils';
+import SelectList, { SelectListFilter } from '../SelectList';
+
+const elements = ['foo', 'bar', 'baz'];
+const selectedElements = [elements[0]];
+const disabledElements = [elements[1]];
+
+it('should display properly with basics features', async () => {
+ const wrapper = shallowRender();
+ await waitAndUpdate(wrapper);
+ expect(wrapper.instance().mounted).toBe(true);
+
+ expect(wrapper).toMatchSnapshot();
+
+ wrapper.instance().componentWillUnmount();
+ expect(wrapper.instance().mounted).toBe(false);
+});
+
+it('should display properly with advanced features', async () => {
+ const wrapper = shallowRender({
+ allowBulkSelection: true,
+ elementsTotalCount: 125,
+ pageSize: 10,
+ readOnly: true,
+ withPaging: true,
+ });
+ await waitAndUpdate(wrapper);
+
+ expect(wrapper).toMatchSnapshot();
+});
+
+it('should display a loader when searching', async () => {
+ const wrapper = shallowRender();
+ await waitAndUpdate(wrapper);
+ expect(wrapper.state().loading).toBe(false);
+
+ wrapper.instance().search({});
+ expect(wrapper.state().loading).toBe(true);
+ expect(wrapper).toMatchSnapshot();
+
+ await waitAndUpdate(wrapper);
+ expect(wrapper.state().loading).toBe(false);
+});
+
+it('should cancel filter selection when search is active', async () => {
+ const spy = jest.fn().mockResolvedValue({});
+ const wrapper = shallowRender({ onSearch: spy });
+ wrapper.instance().changeFilter(SelectListFilter.Unselected);
+ await waitAndUpdate(wrapper);
+
+ expect(spy).toHaveBeenCalledWith({
+ query: '',
+ filter: SelectListFilter.Unselected,
+ page: undefined,
+ pageSize: undefined,
+ });
+ expect(wrapper).toMatchSnapshot();
+
+ const query = 'test';
+ wrapper.instance().handleQueryChange(query);
+ expect(spy).toHaveBeenCalledWith({
+ query,
+ filter: SelectListFilter.All,
+ page: undefined,
+ pageSize: undefined,
+ });
+
+ await waitAndUpdate(wrapper);
+ expect(wrapper).toMatchSnapshot();
+
+ wrapper.instance().handleQueryChange('');
+ expect(spy).toHaveBeenCalledWith({
+ query: '',
+ filter: SelectListFilter.Unselected,
+ page: undefined,
+ pageSize: undefined,
+ });
+
+ await waitAndUpdate(wrapper);
+ expect(wrapper).toMatchSnapshot();
+});
+
+it('should display pagination element properly and call search method with correct parameters', () => {
+ const spy = jest.fn().mockResolvedValue({});
+ const wrapper = shallowRender({ elementsTotalCount: 100, onSearch: spy, withPaging: true });
+ expect(wrapper).toMatchSnapshot();
+ expect(spy).toHaveBeenCalledWith({
+ query: '',
+ filter: SelectListFilter.Selected,
+ page: 1,
+ pageSize: 100,
+ }); // Basic default call
+
+ wrapper.instance().onLoadMore();
+ expect(spy).toHaveBeenCalledWith({
+ query: '',
+ filter: SelectListFilter.Selected,
+ page: 2,
+ pageSize: 100,
+ }); // Load more call
+
+ wrapper.instance().onReload();
+ expect(spy).toHaveBeenCalledWith({
+ query: '',
+ filter: SelectListFilter.Selected,
+ page: 1,
+ pageSize: 100,
+ }); // Reload call
+
+ wrapper.setProps({ needToReload: true });
+ expect(wrapper).toMatchSnapshot();
+});
+
+function shallowRender(props: Partial<SelectList['props']> = {}) {
+ return shallow<SelectList>(
+ <SelectList
+ disabledElements={disabledElements}
+ elements={elements}
+ onSearch={jest.fn(() => Promise.resolve())}
+ onSelect={jest.fn(() => Promise.resolve())}
+ onUnselect={jest.fn(() => Promise.resolve())}
+ renderElement={(foo: string) => foo}
+ selectedElements={selectedElements}
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/SelectListListContainer-test.tsx b/server/sonar-ui-common/components/controls/__tests__/SelectListListContainer-test.tsx
new file mode 100644
index 00000000000..61d53f4825f
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/SelectListListContainer-test.tsx
@@ -0,0 +1,44 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { SelectListFilter } from '../SelectList';
+import SelectListListContainer from '../SelectListListContainer';
+
+it('should render correctly', () => {
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+function shallowRender(props: Partial<SelectListListContainer['props']> = {}) {
+ return shallow(
+ <SelectListListContainer
+ allowBulkSelection={true}
+ disabledElements={[]}
+ elements={['foo', 'bar', 'baz']}
+ filter={SelectListFilter.All}
+ onSelect={jest.fn(() => Promise.resolve())}
+ onUnselect={jest.fn(() => Promise.resolve())}
+ readOnly={false}
+ renderElement={(foo: string) => foo}
+ selectedElements={['foo']}
+ {...props}
+ />
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/SelectListListElement-test.tsx b/server/sonar-ui-common/components/controls/__tests__/SelectListListElement-test.tsx
new file mode 100644
index 00000000000..82a1a40455c
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/SelectListListElement-test.tsx
@@ -0,0 +1,47 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { waitAndUpdate } from '../../../helpers/testUtils';
+import SelectListListElement from '../SelectListListElement';
+
+const listElement = (
+ <SelectListListElement
+ element="foo"
+ key="foo"
+ onSelect={jest.fn(() => Promise.resolve())}
+ onUnselect={jest.fn(() => Promise.resolve())}
+ renderElement={(foo: string) => foo}
+ selected={false}
+ />
+);
+
+it('should display a loader when checking', async () => {
+ const wrapper = shallow<SelectListListElement>(listElement);
+ expect(wrapper).toMatchSnapshot();
+ expect(wrapper.state().loading).toBe(false);
+
+ (wrapper.instance() as SelectListListElement).handleCheck(true);
+ expect(wrapper.state().loading).toBe(true);
+ expect(wrapper).toMatchSnapshot();
+
+ await waitAndUpdate(wrapper);
+ expect(wrapper.state().loading).toBe(false);
+});
diff --git a/server/sonar-ui-common/components/controls/__tests__/SimpleModal-test.tsx b/server/sonar-ui-common/components/controls/__tests__/SimpleModal-test.tsx
new file mode 100644
index 00000000000..6b5271450fb
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/SimpleModal-test.tsx
@@ -0,0 +1,65 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { click, waitAndUpdate } from '../../../helpers/testUtils';
+import { Button } from '../buttons';
+import SimpleModal, { ChildrenProps } from '../SimpleModal';
+
+it('renders', () => {
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+it('closes', () => {
+ const onClose = jest.fn();
+ const children = ({ onCloseClick }: ChildrenProps) => (
+ <Button onClick={onCloseClick}>close</Button>
+ );
+ const wrapper = shallowRender({ children, onClose });
+ click(wrapper.find('Button'));
+ expect(onClose).toBeCalled();
+});
+
+it('submits', async () => {
+ const onSubmit = jest.fn(() => Promise.resolve());
+ const children = ({ onSubmitClick, submitting }: ChildrenProps) => (
+ <Button disabled={submitting} onClick={onSubmitClick}>
+ close
+ </Button>
+ );
+ const wrapper = shallowRender({ children, onSubmit });
+ wrapper.instance().mounted = true;
+ expect(wrapper).toMatchSnapshot();
+
+ click(wrapper.find('Button'));
+ expect(onSubmit).toBeCalled();
+ expect(wrapper).toMatchSnapshot();
+
+ await waitAndUpdate(wrapper);
+ expect(wrapper).toMatchSnapshot();
+});
+
+function shallowRender({ children = () => <div />, ...props }: Partial<SimpleModal['props']> = {}) {
+ return shallow<SimpleModal>(
+ <SimpleModal header="" onClose={jest.fn()} onSubmit={jest.fn()} {...props}>
+ {children}
+ </SimpleModal>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/Tabs-test.tsx b/server/sonar-ui-common/components/controls/__tests__/Tabs-test.tsx
new file mode 100644
index 00000000000..7db674a16db
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/Tabs-test.tsx
@@ -0,0 +1,81 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { click } from '../../../helpers/testUtils';
+import Tabs, { Tab } from '../Tabs';
+
+it('should render correctly', () => {
+ const wrapper = shallow(
+ <Tabs
+ onChange={jest.fn()}
+ selected="bar"
+ tabs={[
+ { key: 'foo', node: 'Foo' },
+ { key: 'bar', node: 'Bar' },
+ ]}
+ />
+ );
+
+ expect(wrapper).toMatchSnapshot();
+});
+
+it('should switch tabs', () => {
+ const onChange = jest.fn();
+ const wrapper = shallow(
+ <Tabs
+ onChange={onChange}
+ selected="bar"
+ tabs={[
+ { key: 'foo', node: 'Foo' },
+ { key: 'bar', node: 'Bar' },
+ ]}
+ />
+ );
+
+ click(shallow(wrapper.find('Tab').get(0)).find('.js-foo'));
+ expect(onChange).toBeCalledWith('foo');
+ click(shallow(wrapper.find('Tab').get(1)).find('.js-bar'));
+ expect(onChange).toBeCalledWith('bar');
+});
+
+it('should render single tab correctly', () => {
+ const onSelect = jest.fn();
+ const wrapper = shallow(
+ <Tab name="foo" onSelect={onSelect} selected={true}>
+ <span>Foo</span>
+ </Tab>
+ );
+ expect(wrapper).toMatchSnapshot();
+ click(wrapper.find('a'));
+ expect(onSelect).toBeCalledWith('foo');
+});
+
+it('should disable single tab', () => {
+ const onSelect = jest.fn();
+ const wrapper = shallow(
+ <Tab disabled={true} name="foo" onSelect={onSelect} selected={true}>
+ <span>Foo</span>
+ </Tab>
+ );
+ expect(wrapper).toMatchSnapshot();
+ click(wrapper.find('a'));
+ expect(onSelect).not.toBeCalled();
+});
diff --git a/server/sonar-ui-common/components/controls/__tests__/Toggle-test.tsx b/server/sonar-ui-common/components/controls/__tests__/Toggle-test.tsx
new file mode 100644
index 00000000000..79fc605afb3
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/Toggle-test.tsx
@@ -0,0 +1,44 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { click } from '../../../helpers/testUtils';
+import { Button } from '../buttons';
+import Toggle from '../Toggle';
+
+it('should render correctly', () => {
+ expect(shallowRender()).toMatchSnapshot('on');
+ expect(shallowRender({ value: false })).toMatchSnapshot('off');
+ expect(shallowRender({ disabled: true })).toMatchSnapshot('disabled');
+});
+
+it('should call onChange when clicked', () => {
+ const onChange = jest.fn();
+ const wrapper = shallowRender({ disabled: false, onChange, value: true });
+ click(wrapper.find(Button));
+ expect(onChange).toBeCalledWith(false);
+});
+
+function shallowRender(props?: Partial<Toggle['props']>) {
+ return shallow(
+ <Toggle disabled={true} name="toggle-name" onChange={jest.fn()} value={true} {...props} />
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/Toggler-test.tsx b/server/sonar-ui-common/components/controls/__tests__/Toggler-test.tsx
new file mode 100644
index 00000000000..dfcbe6b0b9a
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/Toggler-test.tsx
@@ -0,0 +1,48 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import Toggler from '../Toggler';
+
+it('should render only children', () => {
+ expect(shallowRender({ open: false })).toMatchSnapshot();
+});
+
+it('should render children and overlay', () => {
+ expect(shallowRender()).toMatchSnapshot();
+});
+
+it('should render when closeOnClick=true', () => {
+ expect(shallowRender({ closeOnClick: true })).toMatchSnapshot();
+});
+
+it('should not render click wrappers', () => {
+ expect(
+ shallowRender({ closeOnClick: false, closeOnClickOutside: false, closeOnEscape: false })
+ ).toMatchSnapshot();
+});
+
+function shallowRender(props?: Partial<Toggler['props']>) {
+ return shallow(
+ <Toggler onRequestClose={jest.fn()} open={true} overlay={<div id="overlay" />} {...props}>
+ <div id="toggle" />
+ </Toggler>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/Tooltip-test.tsx b/server/sonar-ui-common/components/controls/__tests__/Tooltip-test.tsx
new file mode 100644
index 00000000000..d490fd5179c
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/Tooltip-test.tsx
@@ -0,0 +1,112 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import Tooltip, { TooltipInner } from '../Tooltip';
+
+jest.useFakeTimers();
+jest.mock('react-dom', () => {
+ const actual = require.requireActual('react-dom');
+ return Object.assign({}, actual, {
+ findDOMNode: () => undefined,
+ });
+});
+
+it('should render', () => {
+ expect(
+ shallow(
+ <TooltipInner overlay={<span id="overlay" />} visible={false}>
+ <div id="tooltip" />
+ </TooltipInner>
+ )
+ ).toMatchSnapshot();
+ expect(
+ shallow(
+ <TooltipInner overlay={<span id="overlay" />} visible={true}>
+ <div id="tooltip" />
+ </TooltipInner>,
+ { disableLifecycleMethods: true }
+ )
+ ).toMatchSnapshot();
+});
+
+it('should open & close', () => {
+ const onShow = jest.fn();
+ const onHide = jest.fn();
+ const wrapper = shallow(
+ <TooltipInner onHide={onHide} onShow={onShow} overlay={<span id="overlay" />}>
+ <div id="tooltip" />
+ </TooltipInner>
+ );
+ wrapper.find('#tooltip').simulate('mouseenter');
+ jest.runOnlyPendingTimers();
+ wrapper.update();
+ expect(wrapper.find('TooltipPortal').exists()).toBe(true);
+ expect(onShow).toBeCalled();
+
+ wrapper.find('#tooltip').simulate('mouseleave');
+ jest.runOnlyPendingTimers();
+ wrapper.update();
+ expect(wrapper.find('TooltipPortal').exists()).toBe(false);
+ expect(onHide).toBeCalled();
+});
+
+it('should not open when mouse goes away quickly', () => {
+ const onShow = jest.fn();
+ const onHide = jest.fn();
+ const wrapper = shallow(
+ <TooltipInner onHide={onHide} onShow={onShow} overlay={<span id="overlay" />}>
+ <div id="tooltip" />
+ </TooltipInner>
+ );
+
+ wrapper.find('#tooltip').simulate('mouseenter');
+ wrapper.find('#tooltip').simulate('mouseleave');
+ jest.runOnlyPendingTimers();
+ wrapper.update();
+
+ expect(wrapper.find('TooltipPortal').exists()).toBe(false);
+});
+
+it('should not render tooltip without overlay', () => {
+ const wrapper = shallow(
+ <Tooltip overlay={undefined}>
+ <div id="tooltip" />
+ </Tooltip>
+ );
+ expect(wrapper.type()).toBe('div');
+});
+
+it('should not render empty tooltips', () => {
+ expect(
+ shallow(
+ <Tooltip overlay={undefined} visible={true}>
+ <div id="tooltip" />
+ </Tooltip>
+ )
+ ).toMatchSnapshot();
+ expect(
+ shallow(
+ <Tooltip overlay="" visible={true}>
+ <div id="tooltip" />
+ </Tooltip>
+ )
+ ).toMatchSnapshot();
+});
diff --git a/server/sonar-ui-common/components/controls/__tests__/ValidationForm-test.tsx b/server/sonar-ui-common/components/controls/__tests__/ValidationForm-test.tsx
new file mode 100644
index 00000000000..295967f7ed0
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/ValidationForm-test.tsx
@@ -0,0 +1,47 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import ValidationForm from '../ValidationForm';
+
+it('should render and submit', async () => {
+ const render = jest.fn();
+ const onSubmit = jest.fn();
+ const setSubmitting = jest.fn();
+ const wrapper = shallow(
+ <ValidationForm initialValues={{ foo: 'bar' }} onSubmit={onSubmit} validate={jest.fn()}>
+ {render}
+ </ValidationForm>
+ );
+ expect(wrapper).toMatchSnapshot();
+ wrapper.dive();
+ expect(render).toBeCalledWith(
+ expect.objectContaining({ dirty: false, errors: {}, values: { foo: 'bar' } })
+ );
+
+ wrapper.prop<Function>('onSubmit')({ foo: 'bar' }, { setSubmitting });
+ expect(setSubmitting).toBeCalledWith(false);
+
+ onSubmit.mockResolvedValue(undefined).mockClear();
+ setSubmitting.mockClear();
+ wrapper.prop<Function>('onSubmit')({ foo: 'bar' }, { setSubmitting });
+ await new Promise(setImmediate);
+ expect(setSubmitting).toBeCalledWith(false);
+});
diff --git a/server/sonar-ui-common/components/controls/__tests__/ValidationInput-test.tsx b/server/sonar-ui-common/components/controls/__tests__/ValidationInput-test.tsx
new file mode 100644
index 00000000000..716978090da
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/ValidationInput-test.tsx
@@ -0,0 +1,73 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import ValidationInput from '../ValidationInput';
+
+it('should render', () => {
+ expect(
+ shallow(
+ <ValidationInput
+ description="My description"
+ error={undefined}
+ help="Help message"
+ id="field-id"
+ isInvalid={false}
+ isValid={false}
+ label="Field label"
+ required={true}>
+ <div />
+ </ValidationInput>
+ )
+ ).toMatchSnapshot();
+});
+
+it('should render with error', () => {
+ expect(
+ shallow(
+ <ValidationInput
+ description={<div>My description</div>}
+ error="Field error message"
+ id="field-id"
+ isInvalid={true}
+ isValid={false}
+ label="Field label">
+ <div />
+ </ValidationInput>
+ )
+ ).toMatchSnapshot();
+});
+
+it('should render when valid', () => {
+ expect(
+ shallow(
+ <ValidationInput
+ description="My description"
+ error={undefined}
+ id="field-id"
+ isInvalid={false}
+ isValid={true}
+ label="Field label"
+ required={true}>
+ <div />
+ </ValidationInput>
+ )
+ ).toMatchSnapshot();
+});
diff --git a/server/sonar-ui-common/components/controls/__tests__/ValidationModal-test.tsx b/server/sonar-ui-common/components/controls/__tests__/ValidationModal-test.tsx
new file mode 100644
index 00000000000..810b864c8b2
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/ValidationModal-test.tsx
@@ -0,0 +1,68 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { waitAndUpdate } from '../../../helpers/testUtils';
+import ValidationForm from '../ValidationForm';
+import ValidationModal from '../ValidationModal';
+
+it('should render correctly', () => {
+ const wrapper = shallowRender();
+ expect(wrapper).toMatchSnapshot();
+ expect(wrapper.find(ValidationForm).dive().dive()).toMatchSnapshot();
+});
+
+it('should handle submit', async () => {
+ const data = { field: 'foo' };
+ const onSubmit = jest.fn().mockResolvedValue({});
+ const onClose = jest.fn();
+ const wrapper = shallowRender({ onClose, onSubmit });
+
+ wrapper.instance().handleSubmit(data);
+ expect(onSubmit).toBeCalledWith(data);
+
+ await waitAndUpdate(wrapper);
+ expect(onClose).toBeCalled();
+});
+
+function shallowRender(props: Partial<ValidationModal<{ field: string }>['props']> = {}) {
+ return shallow<ValidationModal<{ field: string }>>(
+ <ValidationModal<{ field: string }>
+ confirmButtonText="confirm"
+ header="title"
+ initialValues={{ field: 'foo' }}
+ isDestructive={true}
+ isInitialValid={true}
+ onClose={jest.fn()}
+ onSubmit={jest.fn()}
+ validate={jest.fn()}
+ {...props}>
+ {(props) => (
+ <input
+ name="field"
+ onBlur={props.handleBlur}
+ onChange={props.handleChange}
+ type="text"
+ value={props.values.field}
+ />
+ )}
+ </ValidationModal>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ActionsDropdown-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ActionsDropdown-test.tsx.snap
new file mode 100644
index 00000000000..4bd6a2ea46d
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ActionsDropdown-test.tsx.snap
@@ -0,0 +1,143 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ActionsDropdown should render correctly 1`] = `
+<Dropdown
+ className="foo"
+ onOpen={[MockFunction]}
+ overlay={
+ <ul
+ className="menu"
+ >
+ <span>
+ Hello world
+ </span>
+ </ul>
+ }
+ overlayPlacement="bottom"
+>
+ <Button
+ className="dropdown-toggle bar button-small"
+ >
+ <SettingsIcon
+ size={12}
+ />
+ <DropdownIcon
+ className="little-spacer-left"
+ />
+ </Button>
+</Dropdown>
+`;
+
+exports[`ActionsDropdown should render correctly 2`] = `
+<Dropdown
+ className="foo"
+ onOpen={[MockFunction]}
+ overlay={
+ <ul
+ className="menu"
+ >
+ <span>
+ Hello world
+ </span>
+ </ul>
+ }
+ overlayPlacement="bottom"
+>
+ <Button
+ className="dropdown-toggle bar"
+ >
+ <SettingsIcon
+ size={14}
+ />
+ <DropdownIcon
+ className="little-spacer-left"
+ />
+ </Button>
+</Dropdown>
+`;
+
+exports[`ActionsDropdownDivider should render correctly 1`] = `
+<li
+ className="divider"
+/>
+`;
+
+exports[`ActionsDropdownItem should render correctly 1`] = `
+<li>
+ <a
+ className="foo"
+ href="#"
+ onClick={[Function]}
+ >
+ <span>
+ Hello world
+ </span>
+ </a>
+</li>
+`;
+
+exports[`ActionsDropdownItem should render correctly 2`] = `
+<li>
+ <Link
+ className="foo text-danger"
+ id="baz"
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to="path/name"
+ >
+ <span>
+ Hello world
+ </span>
+ </Link>
+</li>
+`;
+
+exports[`ActionsDropdownItem should render correctly 3`] = `
+<li>
+ <a
+ className="foo"
+ download="foo/bar"
+ href="path/name"
+ >
+ <span>
+ Hello world
+ </span>
+ </a>
+</li>
+`;
+
+exports[`ActionsDropdownItem should render correctly copy item 1`] = `
+<ActionsDropdownItem
+ className="foo"
+ copyValue="my content to copy to clipboard"
+>
+ <ClipboardBase>
+ <Tooltip
+ overlay="copied_action"
+ visible={false}
+ >
+ <TooltipInner
+ mouseEnterDelay={0.1}
+ overlay="copied_action"
+ visible={false}
+ >
+ <li
+ data-clipboard-text="my content to copy to clipboard"
+ onMouseEnter={[Function]}
+ onMouseLeave={[Function]}
+ >
+ <a
+ className="foo"
+ href="#"
+ onClick={[Function]}
+ >
+ <span>
+ Hello world
+ </span>
+ </a>
+ </li>
+ </TooltipInner>
+ </Tooltip>
+ </ClipboardBase>
+</ActionsDropdownItem>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/BackButton-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/BackButton-test.tsx.snap
new file mode 100644
index 00000000000..57a5f2c6135
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/BackButton-test.tsx.snap
@@ -0,0 +1,46 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should handle click 1`] = `
+<Tooltip
+ overlay="issues.return_to_list"
+>
+ <a
+ className="link-no-underline"
+ href="#"
+ onClick={[Function]}
+ >
+ <ContextConsumer>
+ <Component />
+ </ContextConsumer>
+ </a>
+</Tooltip>
+`;
+
+exports[`should render properly 1`] = `
+<Tooltip
+ overlay="issues.return_to_list"
+>
+ <a
+ className="link-no-underline"
+ href="#"
+ onClick={[Function]}
+ >
+ <ContextConsumer>
+ <Component />
+ </ContextConsumer>
+ </a>
+</Tooltip>
+`;
+
+exports[`should render properly 2`] = `
+<svg
+ height="24"
+ viewBox="0 0 21 24"
+ width="21"
+>
+ <path
+ d="M3.845 12.9992l5.993 5.993.052.056c.049.061.093.122.129.191.082.159.121.339.111.518-.006.102-.028.203-.064.298-.149.39-.537.652-.954.644-.102-.002-.204-.019-.301-.052-.148-.05-.273-.135-.387-.241l-8.407-8.407 8.407-8.407.056-.052c.061-.048.121-.092.19-.128.116-.06.237-.091.366-.108.076-.004.075-.004.153-.003.155.015.3.052.437.129.088.051.169.115.239.19.246.266.33.656.214.999-.051.149-.135.273-.241.387l-5.983 5.984c5.287-.044 10.577-.206 15.859.013.073.009.091.009.163.027.187.047.359.15.49.292.075.081.136.175.18.276.044.101.072.209.081.319.032.391-.175.775-.521.962-.097.052-.202.089-.311.107-.073.012-.091.01-.165.013H3.845z"
+ fill="#777"
+ />
+</svg>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/BoxedGroupAccordion-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/BoxedGroupAccordion-test.tsx.snap
new file mode 100644
index 00000000000..0c7b74b79d9
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/BoxedGroupAccordion-test.tsx.snap
@@ -0,0 +1,26 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<div
+ className="boxed-group boxed-group-accordion"
+>
+ <div
+ className="boxed-group-header"
+ onClick={[Function]}
+ role="listitem"
+ >
+ <span
+ className="boxed-group-accordion-title"
+ >
+ <OpenCloseIcon
+ className="little-spacer-right"
+ open={false}
+ />
+ Foo
+ </span>
+ <div>
+ header content
+ </div>
+ </div>
+</div>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/BoxedTabs-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/BoxedTabs-test.tsx.snap
new file mode 100644
index 00000000000..22d8f761533
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/BoxedTabs-test.tsx.snap
@@ -0,0 +1,178 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+.emotion-6 {
+ display: -webkit-box;
+ display: -webkit-flex;
+ display: -ms-flexbox;
+ display: flex;
+ -webkit-flex-direction: row;
+ -ms-flex-direction: row;
+ flex-direction: row;
+}
+
+.emotion-1 {
+ position: relative;
+ background-color: white;
+ border-top: 1px solid #e6e6e6;
+ border-left: 1px solid #e6e6e6;
+ border-right: none;
+ border-bottom: none;
+ margin-bottom: -1px;
+ min-width: 128px;
+ min-height: 56px;
+ outline: 0;
+ padding: calc(2 * 8px);
+}
+
+.emotion-1:last-child {
+ border-right: 1px solid #e6e6e6;
+}
+
+.emotion-0 {
+ display: block;
+ background-color: #4b9fd5;
+ height: 3px;
+ width: 100%;
+ position: absolute;
+ left: 0;
+ top: -1px;
+}
+
+.emotion-3 {
+ position: relative;
+ background-color: #f3f3f3;
+ border-top: 1px solid #e6e6e6;
+ border-left: 1px solid #e6e6e6;
+ border-right: none;
+ border-bottom: none;
+ margin-bottom: -1px;
+ min-width: 128px;
+ min-height: 56px;
+ cursor: pointer;
+ outline: 0;
+ padding: calc(2 * 8px);
+}
+
+.emotion-3:hover {
+ background-color: #f8f8f8;
+}
+
+.emotion-3:last-child {
+ border-right: 1px solid #e6e6e6;
+}
+
+.emotion-2 {
+ display: none;
+ background-color: #4b9fd5;
+ height: 3px;
+ width: 100%;
+ position: absolute;
+ left: 0;
+ top: -1px;
+}
+
+<BoxedTabs
+ className="boxed-tabs"
+ onSelect={[MockFunction]}
+ selected="a"
+ tabs={
+ Array [
+ Object {
+ "key": "a",
+ "label": "labela",
+ },
+ Object {
+ "key": "b",
+ "label": "labelb",
+ },
+ Object {
+ "key": "c",
+ "label": <span>
+ Complex label
+ <strong>
+ !!!
+ </strong>
+ </span>,
+ },
+ ]
+ }
+>
+ <Styled(div)
+ className="boxed-tabs"
+ >
+ <div
+ className="boxed-tabs emotion-6"
+ >
+ <Styled(button)
+ active={true}
+ key="0"
+ onClick={[Function]}
+ type="button"
+ >
+ <button
+ className="emotion-1"
+ onClick={[Function]}
+ type="button"
+ >
+ <Styled(div)
+ active={true}
+ >
+ <div
+ className="emotion-0"
+ />
+ </Styled(div)>
+ labela
+ </button>
+ </Styled(button)>
+ <Styled(button)
+ active={false}
+ key="1"
+ onClick={[Function]}
+ type="button"
+ >
+ <button
+ className="emotion-3"
+ onClick={[Function]}
+ type="button"
+ >
+ <Styled(div)
+ active={false}
+ >
+ <div
+ className="emotion-2"
+ />
+ </Styled(div)>
+ labelb
+ </button>
+ </Styled(button)>
+ <Styled(button)
+ active={false}
+ key="2"
+ onClick={[Function]}
+ type="button"
+ >
+ <button
+ className="emotion-3"
+ onClick={[Function]}
+ type="button"
+ >
+ <Styled(div)
+ active={false}
+ >
+ <div
+ className="emotion-2"
+ />
+ </Styled(div)>
+ <span>
+ Complex label
+ <strong>
+ !!!
+ </strong>
+ </span>
+ </button>
+ </Styled(button)>
+ </div>
+ </Styled(div)>
+</BoxedTabs>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Checkbox-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Checkbox-test.tsx.snap
new file mode 100644
index 00000000000..69c0c60af8c
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Checkbox-test.tsx.snap
@@ -0,0 +1,22 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render 1`] = `
+<a
+ aria-checked={true}
+ className="icon-checkbox icon-checkbox-checked"
+ href="#"
+ onClick={[Function]}
+ role="checkbox"
+ title="Title value"
+/>
+`;
+
+exports[`should render the checkbox on the right 1`] = `
+<a
+ aria-checked={true}
+ className="icon-checkbox icon-checkbox-checked"
+ href="#"
+ onClick={[Function]}
+ role="checkbox"
+/>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ClickEventBoundary-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ClickEventBoundary-test.tsx.snap
new file mode 100644
index 00000000000..62eac278e7f
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ClickEventBoundary-test.tsx.snap
@@ -0,0 +1,14 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<div>
+ <ClickEventBoundary>
+ <button
+ onClick={[Function]}
+ type="button"
+ >
+ Click me
+ </button>
+ </ClickEventBoundary>
+</div>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ConfirmButton-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ConfirmButton-test.tsx.snap
new file mode 100644
index 00000000000..f94c3829503
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ConfirmButton-test.tsx.snap
@@ -0,0 +1,20 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should display a confirm modal 1`] = `
+<ConfirmModal
+ confirmButtonText="submit"
+ header="title"
+ onClose={[MockFunction]}
+ onConfirm={[MockFunction]}
+>
+ <div />
+</ConfirmModal>
+`;
+
+exports[`should display a modal button 1`] = `
+<ModalButton
+ modal={[Function]}
+>
+ <Component />
+</ModalButton>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ConfirmModal-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ConfirmModal-test.tsx.snap
new file mode 100644
index 00000000000..f1367ae048a
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ConfirmModal-test.tsx.snap
@@ -0,0 +1,81 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should confirm and close after confirm 1`] = `
+<footer
+ className="modal-foot"
+>
+ <DeferredSpinner
+ className="spacer-right"
+ loading={true}
+ />
+ <SubmitButton
+ autoFocus={true}
+ disabled={true}
+ >
+ confirm
+ </SubmitButton>
+ <ResetButtonLink
+ disabled={true}
+ onClick={[Function]}
+ >
+ cancel
+ </ResetButtonLink>
+</footer>
+`;
+
+exports[`should render correctly 1`] = `
+<SimpleModal
+ header="title"
+ onClose={[MockFunction]}
+ onSubmit={[Function]}
+>
+ <Component />
+</SimpleModal>
+`;
+
+exports[`should render correctly 2`] = `
+<Modal
+ contentLabel="title"
+ onRequestClose={[MockFunction]}
+>
+ <ClickEventBoundary>
+ <form
+ onSubmit={[Function]}
+ >
+ <header
+ className="modal-head"
+ >
+ <h2>
+ title
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <p>
+ My confirm message
+ </p>
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <DeferredSpinner
+ className="spacer-right"
+ loading={false}
+ />
+ <SubmitButton
+ autoFocus={true}
+ >
+ confirm
+ </SubmitButton>
+ <ResetButtonLink
+ disabled={false}
+ onClick={[Function]}
+ >
+ cancel
+ </ResetButtonLink>
+ </footer>
+ </form>
+ </ClickEventBoundary>
+</Modal>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/EscKeydownHandler-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/EscKeydownHandler-test.tsx.snap
new file mode 100644
index 00000000000..239d6bfe358
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/EscKeydownHandler-test.tsx.snap
@@ -0,0 +1,7 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<span>
+ Hi there
+</span>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/FavoriteButton-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/FavoriteButton-test.tsx.snap
new file mode 100644
index 00000000000..f66e2ddd464
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/FavoriteButton-test.tsx.snap
@@ -0,0 +1,65 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render favorite 1`] = `
+<Tooltip
+ overlay="favorite.current.TRK"
+>
+ <ButtonLink
+ aria-label="favorite.action.remove"
+ className="favorite-link link-no-underline"
+ onClick={[MockFunction]}
+ >
+ <FavoriteIcon
+ favorite={true}
+ />
+ </ButtonLink>
+</Tooltip>
+`;
+
+exports[`should render not favorite 1`] = `
+<Tooltip
+ overlay="favorite.check.TRK"
+>
+ <ButtonLink
+ aria-label="favorite.action.add"
+ className="favorite-link link-no-underline"
+ onClick={[MockFunction]}
+ >
+ <FavoriteIcon
+ favorite={false}
+ />
+ </ButtonLink>
+</Tooltip>
+`;
+
+exports[`should update properly 1`] = `
+<Tooltip
+ overlay="favorite.check.TRK"
+>
+ <ButtonLink
+ aria-label="favorite.action.add"
+ className="favorite-link link-no-underline"
+ onClick={[MockFunction]}
+ >
+ <FavoriteIcon
+ favorite={false}
+ />
+ </ButtonLink>
+</Tooltip>
+`;
+
+exports[`should update properly 2`] = `
+<Tooltip
+ overlay="favorite.current.TRK"
+>
+ <ButtonLink
+ aria-label="favorite.action.remove"
+ className="favorite-link link-no-underline"
+ onClick={[MockFunction]}
+ >
+ <FavoriteIcon
+ favorite={true}
+ />
+ </ButtonLink>
+</Tooltip>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/GlobalMessages-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/GlobalMessages-test.tsx.snap
new file mode 100644
index 00000000000..fdeaf6c28e4
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/GlobalMessages-test.tsx.snap
@@ -0,0 +1,212 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly with a message 1`] = `
+<Styled(div)>
+ <GlobalMessage
+ closeGlobalMessage={[MockFunction]}
+ key="1"
+ message={
+ Object {
+ "id": "1",
+ "level": "ERROR",
+ "message": "Test",
+ }
+ }
+ />
+ <GlobalMessage
+ closeGlobalMessage={[MockFunction]}
+ key="2"
+ message={
+ Object {
+ "id": "2",
+ "level": "SUCCESS",
+ "message": "Test 2",
+ }
+ }
+ />
+</Styled(div)>
+`;
+
+exports[`should render correctly with a message 2`] = `
+<Styled(div)
+ data-test="global-message__ERROR"
+ level="ERROR"
+ role="alert"
+>
+ Test
+ <Styled(ClearButton)
+ className="button-small"
+ color="#fff"
+ level="ERROR"
+ onClick={[Function]}
+ />
+</Styled(div)>
+`;
+
+exports[`should render correctly with a message 3`] = `
+<Styled(div)
+ data-test="global-message__SUCCESS"
+ level="SUCCESS"
+ role="status"
+>
+ Test 2
+ <Styled(ClearButton)
+ className="button-small"
+ color="#fff"
+ level="SUCCESS"
+ onClick={[Function]}
+ />
+</Styled(div)>
+`;
+
+exports[`should render with correct css 1`] = `
+@keyframes animation-0 {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes animation-0 {
+ from {
+ opacity: 0;
+ }
+
+ to {
+ opacity: 1;
+ }
+}
+
+.emotion-4 {
+ position: fixed;
+ z-index: 7000;
+ top: 0;
+ left: 50%;
+ width: 350px;
+ margin-left: -175px;
+}
+
+.emotion-1 {
+ position: relative;
+ padding: 0 30px 0 10px;
+ line-height: 24px;
+ border-radius: 0 0 3px 3px;
+ box-sizing: border-box;
+ color: #ffffff;
+ background-color: #d4333f;
+ text-align: center;
+ opacity: 0;
+ -webkit-animation: animation-0 0.2s ease forwards;
+ animation: animation-0 0.2s ease forwards;
+}
+
+.emotion-1 + .emotion-1 {
+ margin-top: calc(8px / 2);
+ border-radius: 3px;
+}
+
+.emotion-0 {
+ position: absolute;
+ top: calc(8px / 4);
+ right: calc(8px / 4);
+}
+
+.emotion-0:hover svg,
+.emotion-0:focus svg {
+ color: #d4333f;
+}
+
+.emotion-3 {
+ position: relative;
+ padding: 0 30px 0 10px;
+ line-height: 24px;
+ border-radius: 0 0 3px 3px;
+ box-sizing: border-box;
+ color: #ffffff;
+ background-color: #00aa00;
+ text-align: center;
+ opacity: 0;
+ -webkit-animation: animation-0 0.2s ease forwards;
+ animation: animation-0 0.2s ease forwards;
+}
+
+.emotion-3 + .emotion-3 {
+ margin-top: calc(8px / 2);
+ border-radius: 3px;
+}
+
+.emotion-2 {
+ position: absolute;
+ top: calc(8px / 4);
+ right: calc(8px / 4);
+}
+
+.emotion-2:hover svg,
+.emotion-2:focus svg {
+ color: #00aa00;
+}
+
+<div
+ class="emotion-4"
+>
+ <div
+ class="emotion-1"
+ data-test="global-message__ERROR"
+ role="alert"
+ >
+ Test
+ <button
+ class="button button-small emotion-0 button-icon"
+ level="ERROR"
+ style="color:#fff"
+ type="button"
+ >
+ <svg
+ height="16"
+ space="preserve"
+ style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421"
+ version="1.1"
+ viewBox="0 0 16 16"
+ width="16"
+ xlink="http://www.w3.org/1999/xlink"
+ >
+ <path
+ d="M14 4.242L11.758 2l-3.76 3.76L4.242 2 2 4.242l3.756 3.756L2 11.758 4.242 14l3.756-3.76 3.76 3.76L14 11.758l-3.76-3.76L14 4.242z"
+ style="fill:currentColor"
+ />
+ </svg>
+ </button>
+ </div>
+ <div
+ class="emotion-3"
+ data-test="global-message__SUCCESS"
+ role="status"
+ >
+ Test 2
+ <button
+ class="button button-small emotion-2 button-icon"
+ level="SUCCESS"
+ style="color:#fff"
+ type="button"
+ >
+ <svg
+ height="16"
+ space="preserve"
+ style="fill-rule:evenodd;clip-rule:evenodd;stroke-linejoin:round;stroke-miterlimit:1.41421"
+ version="1.1"
+ viewBox="0 0 16 16"
+ width="16"
+ xlink="http://www.w3.org/1999/xlink"
+ >
+ <path
+ d="M14 4.242L11.758 2l-3.76 3.76L4.242 2 2 4.242l3.756 3.756L2 11.758 4.242 14l3.756-3.76 3.76 3.76L14 11.758l-3.76-3.76L14 4.242z"
+ style="fill:currentColor"
+ />
+ </svg>
+ </button>
+ </div>
+</div>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/HelpTooltip-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/HelpTooltip-test.tsx.snap
new file mode 100644
index 00000000000..abf28dfdd32
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/HelpTooltip-test.tsx.snap
@@ -0,0 +1,53 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render dark helptooltip properly: dark 1`] = `
+<HelpTooltip
+ overlay={
+ <div
+ className="my-overlay"
+ />
+ }
+>
+ <ContextConsumer>
+ <Component />
+ </ContextConsumer>
+</HelpTooltip>
+`;
+
+exports[`should render dark helptooltip properly: dark icon 1`] = `
+<HelpIcon
+ fill="rgba(0, 0, 0, 0.25)"
+ fillInner="#ffffff"
+ size={14}
+/>
+`;
+
+exports[`should render properly: default 1`] = `
+<div
+ className="help-tooltip"
+>
+ <Tooltip
+ mouseLeaveDelay={0.25}
+ overlay={
+ <div
+ className="my-overlay"
+ />
+ }
+ >
+ <span
+ className="display-inline-flex-center"
+ >
+ <ContextConsumer>
+ <Component />
+ </ContextConsumer>
+ </span>
+ </Tooltip>
+</div>
+`;
+
+exports[`should render properly: default icon 1`] = `
+<HelpIcon
+ fill="#b4b4b4"
+ size={12}
+/>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/IdentityProviderLink-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/IdentityProviderLink-test.tsx.snap
new file mode 100644
index 00000000000..d3a38a52376
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/IdentityProviderLink-test.tsx.snap
@@ -0,0 +1,21 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<a
+ className="identity-provider-link"
+ href="/url/foo/bar"
+ style={
+ Object {
+ "backgroundColor": "#000",
+ }
+ }
+>
+ <img
+ alt="Foo"
+ height={20}
+ src="/some/path"
+ width={20}
+ />
+ Link text
+</a>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/InputValidationField-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/InputValidationField-test.tsx.snap
new file mode 100644
index 00000000000..16f3e1c2dfa
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/InputValidationField-test.tsx.snap
@@ -0,0 +1,13 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<ModalValidationField
+ description="Field description"
+ dirty={true}
+ error="Bad formatting"
+ label="Foo field"
+ touched={true}
+>
+ <Component />
+</ModalValidationField>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ListFooter-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ListFooter-test.tsx.snap
new file mode 100644
index 00000000000..2d55aa0d03b
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ListFooter-test.tsx.snap
@@ -0,0 +1,85 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: default 1`] = `
+<footer
+ className="spacer-top note text-center"
+>
+ x_of_y_shown.3.5
+ <Button
+ className="spacer-left"
+ data-test="show-more"
+ onClick={[MockFunction]}
+ >
+ show_more
+ </Button>
+</footer>
+`;
+
+exports[`should render correctly: empty if everything is loaded 1`] = `
+<footer
+ className="spacer-top note text-center"
+>
+ x_of_y_shown.5.5
+</footer>
+`;
+
+exports[`should render correctly: empty if no loadMore nor reload props 1`] = `
+<footer
+ className="spacer-top note text-center"
+>
+ x_of_y_shown.3.5
+</footer>
+`;
+
+exports[`should render correctly: loading 1`] = `
+<footer
+ className="spacer-top note text-center"
+>
+ x_of_y_shown.3.5
+ <Button
+ className="spacer-left"
+ data-test="show-more"
+ disabled={true}
+ onClick={[MockFunction]}
+ >
+ show_more
+ </Button>
+ <DeferredSpinner
+ className="text-bottom spacer-left position-absolute"
+ />
+</footer>
+`;
+
+exports[`should render correctly: reload 1`] = `
+<footer
+ className="spacer-top note text-center"
+>
+ x_of_y_shown.3.5
+ <Button
+ className="spacer-left"
+ data-test="reload"
+ onClick={[MockFunction]}
+ >
+ reload
+ </Button>
+</footer>
+`;
+
+exports[`should render correctly: reload, loading 1`] = `
+<footer
+ className="spacer-top note text-center"
+>
+ x_of_y_shown.3.5
+ <Button
+ className="spacer-left"
+ data-test="reload"
+ disabled={true}
+ onClick={[MockFunction]}
+ >
+ reload
+ </Button>
+ <DeferredSpinner
+ className="text-bottom spacer-left position-absolute"
+ />
+</footer>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ModalValidationField-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ModalValidationField-test.tsx.snap
new file mode 100644
index 00000000000..4b4e605c0ad
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ModalValidationField-test.tsx.snap
@@ -0,0 +1,73 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should display the field as valid 1`] = `
+<div
+ className="modal-validation-field"
+>
+ <label>
+ Foo
+ </label>
+ <input
+ className="is-valid"
+ type="text"
+ />
+ <AlertSuccessIcon
+ className="little-spacer-top"
+ />
+</div>
+`;
+
+exports[`should display the field with an error 1`] = `
+<div
+ className="modal-validation-field"
+>
+ <label>
+ Foo
+ </label>
+ <input
+ className="is-invalid"
+ type="text"
+ />
+ <AlertErrorIcon
+ className="little-spacer-top"
+ />
+ <p
+ className="text-danger"
+ >
+ Is required
+ </p>
+</div>
+`;
+
+exports[`should display the field without any error/validation 1`] = `
+<div
+ className="modal-validation-field"
+>
+ <label>
+ Foo
+ </label>
+ <input
+ className=""
+ type="text"
+ />
+ <div
+ className="modal-field-description"
+ >
+ Describe Foo.
+ </div>
+</div>
+`;
+
+exports[`should display the field without any error/validation 2`] = `
+<div
+ className="modal-validation-field"
+>
+ <label>
+ Foo
+ </label>
+ <input
+ className=""
+ type="text"
+ />
+</div>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Radio-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Radio-test.tsx.snap
new file mode 100644
index 00000000000..8649fba79b8
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Radio-test.tsx.snap
@@ -0,0 +1,29 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render properly: checked 1`] = `
+<a
+ aria-checked={true}
+ className="display-inline-flex-center link-radio"
+ href="#"
+ onClick={[Function]}
+ role="radio"
+>
+ <i
+ className="icon-radio spacer-right is-checked"
+ />
+</a>
+`;
+
+exports[`should render properly: not checked 1`] = `
+<a
+ aria-checked={false}
+ className="display-inline-flex-center link-radio"
+ href="#"
+ onClick={[Function]}
+ role="radio"
+>
+ <i
+ className="icon-radio spacer-right"
+ />
+</a>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/RadioCard-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/RadioCard-test.tsx.snap
new file mode 100644
index 00000000000..e58f9d73597
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/RadioCard-test.tsx.snap
@@ -0,0 +1,172 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should be actionable 1`] = `
+<div
+ className="radio-card radio-card-actionable"
+ onClick={[MockFunction]}
+ role="radio"
+ tabIndex={0}
+>
+ <h2
+ className="radio-card-header big-spacer-bottom"
+ >
+ <span
+ className="display-flex-center link-radio"
+ >
+ <i
+ className="icon-radio spacer-right"
+ />
+ Radio Card
+ </span>
+ </h2>
+ <div
+ className="radio-card-body"
+ >
+ <div>
+ content
+ </div>
+ </div>
+</div>
+`;
+
+exports[`should be actionable 2`] = `
+<div
+ aria-checked={true}
+ className="radio-card radio-card-actionable selected"
+ onClick={
+ [MockFunction] {
+ "calls": Array [
+ Array [
+ Object {
+ "currentTarget": Object {
+ "blur": [Function],
+ },
+ "preventDefault": [Function],
+ "stopPropagation": [Function],
+ "target": Object {
+ "blur": [Function],
+ },
+ },
+ ],
+ ],
+ "results": Array [
+ Object {
+ "type": "return",
+ "value": undefined,
+ },
+ ],
+ }
+ }
+ role="radio"
+ tabIndex={0}
+>
+ <h2
+ className="radio-card-header big-spacer-bottom"
+ >
+ <span
+ className="display-flex-center link-radio"
+ >
+ <i
+ className="icon-radio spacer-right is-checked"
+ />
+ Radio Card
+ </span>
+ info
+ </h2>
+ <div
+ className="radio-card-body"
+ >
+ <div>
+ content
+ </div>
+ </div>
+</div>
+`;
+
+exports[`should render correctly 1`] = `
+<div
+ className="radio-card"
+ role="radio"
+ tabIndex={0}
+>
+ <h2
+ className="radio-card-header big-spacer-bottom"
+ >
+ <span
+ className="display-flex-center link-radio"
+ >
+ Radio Card
+ </span>
+ info
+ </h2>
+ <div
+ className="radio-card-body"
+ >
+ <div>
+ content
+ </div>
+ </div>
+ <div
+ className="radio-card-recommended"
+ >
+ <RecommendedIcon
+ className="spacer-right"
+ />
+ <FormattedMessage
+ defaultMessage="Recommended for you"
+ id="Recommended for you"
+ values={
+ Object {
+ "recommended": <strong>
+ recommended
+ </strong>,
+ }
+ }
+ />
+ </div>
+</div>
+`;
+
+exports[`should render correctly 2`] = `
+<div
+ className="radio-card radio-card-vertical"
+ role="radio"
+ tabIndex={0}
+>
+ <h2
+ className="radio-card-header big-spacer-bottom"
+ >
+ <span
+ className="display-flex-center link-radio"
+ >
+ Radio Card Vertical
+ </span>
+ info
+ </h2>
+ <div
+ className="radio-card-body"
+ >
+ <div>
+ content
+ </div>
+ </div>
+ <div
+ className="radio-card-recommended"
+ >
+ <RecommendedIcon
+ className="spacer-right"
+ />
+ <FormattedMessage
+ defaultMessage="Recommended for you"
+ id="Recommended for you"
+ values={
+ Object {
+ "recommended": <strong>
+ recommended
+ </strong>,
+ }
+ }
+ />
+ </div>
+</div>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/RadioToggle-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/RadioToggle-test.tsx.snap
new file mode 100644
index 00000000000..791d312e720
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/RadioToggle-test.tsx.snap
@@ -0,0 +1,92 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`accepts advanced options fields 1`] = `
+<ul
+ className="radio-toggle"
+>
+ <li
+ key="one"
+ >
+ <input
+ checked={false}
+ id="sample__one"
+ name="sample"
+ onChange={[Function]}
+ type="radio"
+ />
+ <Tooltip
+ overlay="foo"
+ >
+ <label
+ htmlFor="sample__one"
+ >
+ first
+ </label>
+ </Tooltip>
+ </li>
+ <li
+ key="two"
+ >
+ <input
+ checked={false}
+ disabled={true}
+ id="sample__two"
+ name="sample"
+ onChange={[Function]}
+ type="radio"
+ />
+ <Tooltip
+ overlay="bar"
+ >
+ <label
+ htmlFor="sample__two"
+ >
+ second
+ </label>
+ </Tooltip>
+ </li>
+</ul>
+`;
+
+exports[`renders 1`] = `
+<ul
+ className="radio-toggle"
+>
+ <li
+ key="one"
+ >
+ <input
+ checked={false}
+ id="sample__one"
+ name="sample"
+ onChange={[Function]}
+ type="radio"
+ />
+ <Tooltip>
+ <label
+ htmlFor="sample__one"
+ >
+ first
+ </label>
+ </Tooltip>
+ </li>
+ <li
+ key="two"
+ >
+ <input
+ checked={false}
+ id="sample__two"
+ name="sample"
+ onChange={[Function]}
+ type="radio"
+ />
+ <Tooltip>
+ <label
+ htmlFor="sample__two"
+ >
+ second
+ </label>
+ </Tooltip>
+ </li>
+</ul>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ReloadButton-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ReloadButton-test.tsx.snap
new file mode 100644
index 00000000000..9510ca69cca
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ReloadButton-test.tsx.snap
@@ -0,0 +1,46 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should handle click 1`] = `
+<Tooltip
+ overlay="reload"
+>
+ <a
+ className="link-no-underline"
+ href="#"
+ onClick={[Function]}
+ >
+ <ContextConsumer>
+ <Component />
+ </ContextConsumer>
+ </a>
+</Tooltip>
+`;
+
+exports[`should render properly 1`] = `
+<Tooltip
+ overlay="reload"
+>
+ <a
+ className="link-no-underline"
+ href="#"
+ onClick={[Function]}
+ >
+ <ContextConsumer>
+ <Component />
+ </ContextConsumer>
+ </a>
+</Tooltip>
+`;
+
+exports[`should render properly 2`] = `
+<svg
+ height="24"
+ viewBox="0 0 18 24"
+ width="18"
+>
+ <path
+ d="M16.6454 8.1084c-.3-.5-.9-.7-1.4-.4-.5.3-.7.9-.4 1.4.9 1.6 1.1 3.4.6 5.1-.5 1.7-1.7 3.2-3.2 4-3.3 1.8-7.4.6-9.1-2.7-1.8-3.1-.8-6.9 2.1-8.8v3.3h2v-7h-7v2h3.9c-3.7 2.5-5 7.5-2.8 11.4 1.6 3 4.6 4.6 7.7 4.6 1.4 0 2.8-.3 4.2-1.1 2-1.1 3.5-3 4.2-5.2.6-2.2.3-4.6-.8-6.6z"
+ fill="#777"
+ />
+</svg>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SearchBox-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SearchBox-test.tsx.snap
new file mode 100644
index 00000000000..4ed69eb6a29
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SearchBox-test.tsx.snap
@@ -0,0 +1,37 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders 1`] = `
+<div
+ className="search-box"
+ title=""
+>
+ <input
+ aria-label="search_verb"
+ autoComplete="off"
+ className="search-box-input"
+ maxLength={150}
+ onChange={[Function]}
+ onKeyDown={[Function]}
+ placeholder="placeholder"
+ type="search"
+ value="foo"
+ />
+ <DeferredSpinner
+ loading={false}
+ >
+ <SearchIcon
+ className="search-box-magnifier"
+ />
+ </DeferredSpinner>
+ <ClearButton
+ aria-label="clear"
+ className="button-tiny search-box-clear"
+ iconProps={
+ Object {
+ "size": 12,
+ }
+ }
+ onClick={[Function]}
+ />
+</div>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SearchSelect-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SearchSelect-test.tsx.snap
new file mode 100644
index 00000000000..792343f1a73
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SearchSelect-test.tsx.snap
@@ -0,0 +1,17 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render Select 1`] = `
+<Select
+ autoFocus={true}
+ escapeClearsValue={false}
+ filterOption={[Function]}
+ isLoading={false}
+ noResultsText="select2.tooShort.2"
+ onBlurResetsInput={true}
+ onChange={[Function]}
+ onInputChange={[Function]}
+ options={Array []}
+ placeholder="search_verb"
+ searchable={true}
+/>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SelectList-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SelectList-test.tsx.snap
new file mode 100644
index 00000000000..14d46bbffc3
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SelectList-test.tsx.snap
@@ -0,0 +1,558 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should cancel filter selection when search is active 1`] = `
+<div
+ className="select-list"
+>
+ <div
+ className="display-flex-center"
+ >
+ <RadioToggle
+ className="select-list-filter spacer-right"
+ disabled={false}
+ name="filter"
+ onCheck={[Function]}
+ options={
+ Array [
+ Object {
+ "disabled": false,
+ "label": "selected",
+ "value": "selected",
+ },
+ Object {
+ "disabled": false,
+ "label": "unselected",
+ "value": "deselected",
+ },
+ Object {
+ "disabled": false,
+ "label": "all",
+ "value": "all",
+ },
+ ]
+ }
+ value="deselected"
+ />
+ <SearchBox
+ autoFocus={true}
+ loading={false}
+ onChange={[Function]}
+ placeholder="search_verb"
+ value=""
+ />
+ </div>
+ <SelectListListContainer
+ disabledElements={
+ Array [
+ "bar",
+ ]
+ }
+ elements={
+ Array [
+ "foo",
+ "bar",
+ "baz",
+ ]
+ }
+ filter="deselected"
+ onSelect={[MockFunction]}
+ onUnselect={[MockFunction]}
+ renderElement={[Function]}
+ selectedElements={
+ Array [
+ "foo",
+ ]
+ }
+ />
+</div>
+`;
+
+exports[`should cancel filter selection when search is active 2`] = `
+<div
+ className="select-list"
+>
+ <div
+ className="display-flex-center"
+ >
+ <RadioToggle
+ className="select-list-filter spacer-right"
+ disabled={false}
+ name="filter"
+ onCheck={[Function]}
+ options={
+ Array [
+ Object {
+ "disabled": true,
+ "label": "selected",
+ "value": "selected",
+ },
+ Object {
+ "disabled": true,
+ "label": "unselected",
+ "value": "deselected",
+ },
+ Object {
+ "disabled": true,
+ "label": "all",
+ "value": "all",
+ },
+ ]
+ }
+ value="deselected"
+ />
+ <SearchBox
+ autoFocus={true}
+ loading={false}
+ onChange={[Function]}
+ placeholder="search_verb"
+ value="test"
+ />
+ </div>
+ <SelectListListContainer
+ disabledElements={
+ Array [
+ "bar",
+ ]
+ }
+ elements={
+ Array [
+ "foo",
+ "bar",
+ "baz",
+ ]
+ }
+ filter="all"
+ onSelect={[MockFunction]}
+ onUnselect={[MockFunction]}
+ renderElement={[Function]}
+ selectedElements={
+ Array [
+ "foo",
+ ]
+ }
+ />
+</div>
+`;
+
+exports[`should cancel filter selection when search is active 3`] = `
+<div
+ className="select-list"
+>
+ <div
+ className="display-flex-center"
+ >
+ <RadioToggle
+ className="select-list-filter spacer-right"
+ disabled={false}
+ name="filter"
+ onCheck={[Function]}
+ options={
+ Array [
+ Object {
+ "disabled": false,
+ "label": "selected",
+ "value": "selected",
+ },
+ Object {
+ "disabled": false,
+ "label": "unselected",
+ "value": "deselected",
+ },
+ Object {
+ "disabled": false,
+ "label": "all",
+ "value": "all",
+ },
+ ]
+ }
+ value="deselected"
+ />
+ <SearchBox
+ autoFocus={true}
+ loading={false}
+ onChange={[Function]}
+ placeholder="search_verb"
+ value=""
+ />
+ </div>
+ <SelectListListContainer
+ disabledElements={
+ Array [
+ "bar",
+ ]
+ }
+ elements={
+ Array [
+ "foo",
+ "bar",
+ "baz",
+ ]
+ }
+ filter="deselected"
+ onSelect={[MockFunction]}
+ onUnselect={[MockFunction]}
+ renderElement={[Function]}
+ selectedElements={
+ Array [
+ "foo",
+ ]
+ }
+ />
+</div>
+`;
+
+exports[`should display a loader when searching 1`] = `
+<div
+ className="select-list"
+>
+ <div
+ className="display-flex-center"
+ >
+ <RadioToggle
+ className="select-list-filter spacer-right"
+ disabled={false}
+ name="filter"
+ onCheck={[Function]}
+ options={
+ Array [
+ Object {
+ "disabled": false,
+ "label": "selected",
+ "value": "selected",
+ },
+ Object {
+ "disabled": false,
+ "label": "unselected",
+ "value": "deselected",
+ },
+ Object {
+ "disabled": false,
+ "label": "all",
+ "value": "all",
+ },
+ ]
+ }
+ value="selected"
+ />
+ <SearchBox
+ autoFocus={true}
+ loading={true}
+ onChange={[Function]}
+ placeholder="search_verb"
+ value=""
+ />
+ </div>
+ <SelectListListContainer
+ disabledElements={
+ Array [
+ "bar",
+ ]
+ }
+ elements={
+ Array [
+ "foo",
+ "bar",
+ "baz",
+ ]
+ }
+ filter="selected"
+ onSelect={[MockFunction]}
+ onUnselect={[MockFunction]}
+ renderElement={[Function]}
+ selectedElements={
+ Array [
+ "foo",
+ ]
+ }
+ />
+</div>
+`;
+
+exports[`should display pagination element properly and call search method with correct parameters 1`] = `
+<div
+ className="select-list"
+>
+ <div
+ className="display-flex-center"
+ >
+ <RadioToggle
+ className="select-list-filter spacer-right"
+ disabled={false}
+ name="filter"
+ onCheck={[Function]}
+ options={
+ Array [
+ Object {
+ "disabled": false,
+ "label": "selected",
+ "value": "selected",
+ },
+ Object {
+ "disabled": false,
+ "label": "unselected",
+ "value": "deselected",
+ },
+ Object {
+ "disabled": false,
+ "label": "all",
+ "value": "all",
+ },
+ ]
+ }
+ value="selected"
+ />
+ <SearchBox
+ autoFocus={true}
+ loading={true}
+ onChange={[Function]}
+ placeholder="search_verb"
+ value=""
+ />
+ </div>
+ <SelectListListContainer
+ disabledElements={
+ Array [
+ "bar",
+ ]
+ }
+ elements={
+ Array [
+ "foo",
+ "bar",
+ "baz",
+ ]
+ }
+ filter="selected"
+ onSelect={[MockFunction]}
+ onUnselect={[MockFunction]}
+ renderElement={[Function]}
+ selectedElements={
+ Array [
+ "foo",
+ ]
+ }
+ />
+ <ListFooter
+ count={3}
+ loadMore={[Function]}
+ reload={[Function]}
+ total={100}
+ />
+</div>
+`;
+
+exports[`should display pagination element properly and call search method with correct parameters 2`] = `
+<div
+ className="select-list"
+>
+ <div
+ className="display-flex-center"
+ >
+ <RadioToggle
+ className="select-list-filter spacer-right"
+ disabled={false}
+ name="filter"
+ onCheck={[Function]}
+ options={
+ Array [
+ Object {
+ "disabled": false,
+ "label": "selected",
+ "value": "selected",
+ },
+ Object {
+ "disabled": false,
+ "label": "unselected",
+ "value": "deselected",
+ },
+ Object {
+ "disabled": false,
+ "label": "all",
+ "value": "all",
+ },
+ ]
+ }
+ value="selected"
+ />
+ <SearchBox
+ autoFocus={true}
+ loading={true}
+ onChange={[Function]}
+ placeholder="search_verb"
+ value=""
+ />
+ </div>
+ <SelectListListContainer
+ disabledElements={
+ Array [
+ "bar",
+ ]
+ }
+ elements={
+ Array [
+ "foo",
+ "bar",
+ "baz",
+ ]
+ }
+ filter="selected"
+ onSelect={[MockFunction]}
+ onUnselect={[MockFunction]}
+ renderElement={[Function]}
+ selectedElements={
+ Array [
+ "foo",
+ ]
+ }
+ />
+ <ListFooter
+ count={3}
+ loadMore={[Function]}
+ needReload={true}
+ reload={[Function]}
+ total={100}
+ />
+</div>
+`;
+
+exports[`should display properly with advanced features 1`] = `
+<div
+ className="select-list"
+>
+ <div
+ className="display-flex-center"
+ >
+ <RadioToggle
+ className="select-list-filter spacer-right"
+ disabled={false}
+ name="filter"
+ onCheck={[Function]}
+ options={
+ Array [
+ Object {
+ "disabled": false,
+ "label": "selected",
+ "value": "selected",
+ },
+ Object {
+ "disabled": false,
+ "label": "unselected",
+ "value": "deselected",
+ },
+ Object {
+ "disabled": false,
+ "label": "all",
+ "value": "all",
+ },
+ ]
+ }
+ value="selected"
+ />
+ <SearchBox
+ autoFocus={true}
+ loading={false}
+ onChange={[Function]}
+ placeholder="search_verb"
+ value=""
+ />
+ </div>
+ <SelectListListContainer
+ allowBulkSelection={true}
+ disabledElements={
+ Array [
+ "bar",
+ ]
+ }
+ elements={
+ Array [
+ "foo",
+ "bar",
+ "baz",
+ ]
+ }
+ filter="selected"
+ onSelect={[MockFunction]}
+ onUnselect={[MockFunction]}
+ readOnly={true}
+ renderElement={[Function]}
+ selectedElements={
+ Array [
+ "foo",
+ ]
+ }
+ />
+ <ListFooter
+ count={3}
+ loadMore={[Function]}
+ reload={[Function]}
+ total={125}
+ />
+</div>
+`;
+
+exports[`should display properly with basics features 1`] = `
+<div
+ className="select-list"
+>
+ <div
+ className="display-flex-center"
+ >
+ <RadioToggle
+ className="select-list-filter spacer-right"
+ disabled={false}
+ name="filter"
+ onCheck={[Function]}
+ options={
+ Array [
+ Object {
+ "disabled": false,
+ "label": "selected",
+ "value": "selected",
+ },
+ Object {
+ "disabled": false,
+ "label": "unselected",
+ "value": "deselected",
+ },
+ Object {
+ "disabled": false,
+ "label": "all",
+ "value": "all",
+ },
+ ]
+ }
+ value="selected"
+ />
+ <SearchBox
+ autoFocus={true}
+ loading={false}
+ onChange={[Function]}
+ placeholder="search_verb"
+ value=""
+ />
+ </div>
+ <SelectListListContainer
+ disabledElements={
+ Array [
+ "bar",
+ ]
+ }
+ elements={
+ Array [
+ "foo",
+ "bar",
+ "baz",
+ ]
+ }
+ filter="selected"
+ onSelect={[MockFunction]}
+ onUnselect={[MockFunction]}
+ renderElement={[Function]}
+ selectedElements={
+ Array [
+ "foo",
+ ]
+ }
+ />
+</div>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap
new file mode 100644
index 00000000000..bf96116e95a
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SelectListListContainer-test.tsx.snap
@@ -0,0 +1,61 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<div
+ className="select-list-list-container spacer-top"
+>
+ <ul
+ className="menu"
+ >
+ <li>
+ <Checkbox
+ checked={true}
+ disabled={false}
+ onCheck={[Function]}
+ thirdState={true}
+ >
+ <span
+ className="big-spacer-left"
+ >
+ bulk_change
+ <DeferredSpinner
+ className="spacer-left"
+ loading={false}
+ timeout={10}
+ />
+ </span>
+ </Checkbox>
+ </li>
+ <li
+ className="divider"
+ />
+ <SelectListListElement
+ disabled={false}
+ element="foo"
+ key="foo"
+ onSelect={[MockFunction]}
+ onUnselect={[MockFunction]}
+ renderElement={[Function]}
+ selected={true}
+ />
+ <SelectListListElement
+ disabled={false}
+ element="bar"
+ key="bar"
+ onSelect={[MockFunction]}
+ onUnselect={[MockFunction]}
+ renderElement={[Function]}
+ selected={false}
+ />
+ <SelectListListElement
+ disabled={false}
+ element="baz"
+ key="baz"
+ onSelect={[MockFunction]}
+ onUnselect={[MockFunction]}
+ renderElement={[Function]}
+ selected={false}
+ />
+ </ul>
+</div>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SelectListListElement-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SelectListListElement-test.tsx.snap
new file mode 100644
index 00000000000..e5d4ba3601f
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SelectListListElement-test.tsx.snap
@@ -0,0 +1,41 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should display a loader when checking 1`] = `
+<li
+ className=""
+>
+ <Checkbox
+ checked={false}
+ className="select-list-list-checkbox"
+ loading={false}
+ onCheck={[Function]}
+ thirdState={false}
+ >
+ <span
+ className="little-spacer-left"
+ >
+ foo
+ </span>
+ </Checkbox>
+</li>
+`;
+
+exports[`should display a loader when checking 2`] = `
+<li
+ className=""
+>
+ <Checkbox
+ checked={false}
+ className="select-list-list-checkbox"
+ loading={true}
+ onCheck={[Function]}
+ thirdState={false}
+ >
+ <span
+ className="little-spacer-left"
+ >
+ foo
+ </span>
+ </Checkbox>
+</li>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SimpleModal-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SimpleModal-test.tsx.snap
new file mode 100644
index 00000000000..49b14a9e20f
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/SimpleModal-test.tsx.snap
@@ -0,0 +1,52 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`renders 1`] = `
+<Modal
+ contentLabel=""
+ onRequestClose={[MockFunction]}
+>
+ <div />
+</Modal>
+`;
+
+exports[`submits 1`] = `
+<Modal
+ contentLabel=""
+ onRequestClose={[MockFunction]}
+>
+ <Button
+ disabled={false}
+ onClick={[Function]}
+ >
+ close
+ </Button>
+</Modal>
+`;
+
+exports[`submits 2`] = `
+<Modal
+ contentLabel=""
+ onRequestClose={[MockFunction]}
+>
+ <Button
+ disabled={true}
+ onClick={[Function]}
+ >
+ close
+ </Button>
+</Modal>
+`;
+
+exports[`submits 3`] = `
+<Modal
+ contentLabel=""
+ onRequestClose={[MockFunction]}
+>
+ <Button
+ disabled={false}
+ onClick={[Function]}
+ >
+ close
+ </Button>
+</Modal>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Tabs-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Tabs-test.tsx.snap
new file mode 100644
index 00000000000..2db4cec05a8
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Tabs-test.tsx.snap
@@ -0,0 +1,52 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should disable single tab 1`] = `
+<li>
+ <a
+ className="js-foo disabled selected"
+ href="#"
+ onClick={[Function]}
+ >
+ <span>
+ Foo
+ </span>
+ </a>
+</li>
+`;
+
+exports[`should render correctly 1`] = `
+<ul
+ className="flex-tabs"
+>
+ <Tab
+ key="foo"
+ name="foo"
+ onSelect={[MockFunction]}
+ selected={false}
+ >
+ Foo
+ </Tab>
+ <Tab
+ key="bar"
+ name="bar"
+ onSelect={[MockFunction]}
+ selected={true}
+ >
+ Bar
+ </Tab>
+</ul>
+`;
+
+exports[`should render single tab correctly 1`] = `
+<li>
+ <a
+ className="js-foo selected"
+ href="#"
+ onClick={[Function]}
+ >
+ <span>
+ Foo
+ </span>
+ </a>
+</li>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Toggle-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Toggle-test.tsx.snap
new file mode 100644
index 00000000000..8862993eb41
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Toggle-test.tsx.snap
@@ -0,0 +1,55 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: disabled 1`] = `
+<Button
+ className="boolean-toggle boolean-toggle-on"
+ disabled={true}
+ name="toggle-name"
+ onClick={[Function]}
+>
+ <div
+ aria-label="on"
+ className="boolean-toggle-handle"
+ >
+ <CheckIcon
+ size={12}
+ />
+ </div>
+</Button>
+`;
+
+exports[`should render correctly: off 1`] = `
+<Button
+ className="boolean-toggle"
+ disabled={true}
+ name="toggle-name"
+ onClick={[Function]}
+>
+ <div
+ aria-label="off"
+ className="boolean-toggle-handle"
+ >
+ <CheckIcon
+ size={12}
+ />
+ </div>
+</Button>
+`;
+
+exports[`should render correctly: on 1`] = `
+<Button
+ className="boolean-toggle boolean-toggle-on"
+ disabled={true}
+ name="toggle-name"
+ onClick={[Function]}
+>
+ <div
+ aria-label="on"
+ className="boolean-toggle-handle"
+ >
+ <CheckIcon
+ size={12}
+ />
+ </div>
+</Button>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Toggler-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Toggler-test.tsx.snap
new file mode 100644
index 00000000000..dfe5d96a394
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Toggler-test.tsx.snap
@@ -0,0 +1,58 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should not render click wrappers 1`] = `
+<Fragment>
+ <div
+ id="toggle"
+ />
+ <div
+ id="overlay"
+ />
+</Fragment>
+`;
+
+exports[`should render children and overlay 1`] = `
+<Fragment>
+ <div
+ id="toggle"
+ />
+ <OutsideClickHandler
+ onClickOutside={[MockFunction]}
+ >
+ <EscKeydownHandler
+ onKeydown={[MockFunction]}
+ >
+ <div
+ id="overlay"
+ />
+ </EscKeydownHandler>
+ </OutsideClickHandler>
+</Fragment>
+`;
+
+exports[`should render only children 1`] = `
+<Fragment>
+ <div
+ id="toggle"
+ />
+</Fragment>
+`;
+
+exports[`should render when closeOnClick=true 1`] = `
+<Fragment>
+ <div
+ id="toggle"
+ />
+ <DocumentClickHandler
+ onClick={[MockFunction]}
+ >
+ <EscKeydownHandler
+ onKeydown={[MockFunction]}
+ >
+ <div
+ id="overlay"
+ />
+ </EscKeydownHandler>
+ </DocumentClickHandler>
+</Fragment>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Tooltip-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Tooltip-test.tsx.snap
new file mode 100644
index 00000000000..786b1bfe0ad
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/Tooltip-test.tsx.snap
@@ -0,0 +1,40 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should not render empty tooltips 1`] = `
+<div
+ id="tooltip"
+/>
+`;
+
+exports[`should not render empty tooltips 2`] = `
+<div
+ id="tooltip"
+/>
+`;
+
+exports[`should render 1`] = `
+<Fragment>
+ <div
+ id="tooltip"
+ onMouseEnter={[Function]}
+ onMouseLeave={[Function]}
+ />
+</Fragment>
+`;
+
+exports[`should render 2`] = `
+<Fragment>
+ <div
+ id="tooltip"
+ onMouseEnter={[Function]}
+ onMouseLeave={[Function]}
+ />
+ <TooltipPortal>
+ <WithTheme(ScreenPositionFixer)
+ ready={false}
+ >
+ <Component />
+ </WithTheme(ScreenPositionFixer)>
+ </TooltipPortal>
+</Fragment>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ValidationForm-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ValidationForm-test.tsx.snap
new file mode 100644
index 00000000000..e00f009ef2f
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ValidationForm-test.tsx.snap
@@ -0,0 +1,19 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render and submit 1`] = `
+<Formik
+ enableReinitialize={false}
+ initialValues={
+ Object {
+ "foo": "bar",
+ }
+ }
+ isInitialValid={false}
+ onSubmit={[Function]}
+ validate={[MockFunction]}
+ validateOnBlur={true}
+ validateOnChange={true}
+>
+ <Component />
+</Formik>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ValidationInput-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ValidationInput-test.tsx.snap
new file mode 100644
index 00000000000..c2d68a1d2bf
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ValidationInput-test.tsx.snap
@@ -0,0 +1,98 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render 1`] = `
+<div>
+ <label
+ htmlFor="field-id"
+ >
+ <span
+ className="text-middle"
+ >
+ <strong>
+ Field label
+ </strong>
+ <MandatoryFieldMarker />
+ </span>
+ <HelpTooltip
+ className="spacer-left"
+ overlay="Help message"
+ />
+ </label>
+ <div
+ className="little-spacer-top spacer-bottom"
+ >
+ <div />
+ </div>
+ <div
+ className="note abs-width-400"
+ >
+ My description
+ </div>
+</div>
+`;
+
+exports[`should render when valid 1`] = `
+<div>
+ <label
+ htmlFor="field-id"
+ >
+ <span
+ className="text-middle"
+ >
+ <strong>
+ Field label
+ </strong>
+ <MandatoryFieldMarker />
+ </span>
+ </label>
+ <div
+ className="little-spacer-top spacer-bottom"
+ >
+ <div />
+ <AlertSuccessIcon
+ className="spacer-left text-middle"
+ />
+ </div>
+ <div
+ className="note abs-width-400"
+ >
+ My description
+ </div>
+</div>
+`;
+
+exports[`should render with error 1`] = `
+<div>
+ <label
+ htmlFor="field-id"
+ >
+ <span
+ className="text-middle"
+ >
+ <strong>
+ Field label
+ </strong>
+ </span>
+ </label>
+ <div
+ className="little-spacer-top spacer-bottom"
+ >
+ <div />
+ <AlertErrorIcon
+ className="spacer-left text-middle"
+ />
+ <span
+ className="little-spacer-left text-danger text-middle"
+ >
+ Field error message
+ </span>
+ </div>
+ <div
+ className="note abs-width-400"
+ >
+ <div>
+ My description
+ </div>
+ </div>
+</div>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ValidationModal-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ValidationModal-test.tsx.snap
new file mode 100644
index 00000000000..67db9979a9e
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/ValidationModal-test.tsx.snap
@@ -0,0 +1,110 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<Modal
+ contentLabel="title"
+ onRequestClose={[MockFunction]}
+>
+ <ValidationForm
+ initialValues={
+ Object {
+ "field": "foo",
+ }
+ }
+ isInitialValid={true}
+ onSubmit={[Function]}
+ validate={[MockFunction]}
+ >
+ <Component />
+ </ValidationForm>
+</Modal>
+`;
+
+exports[`should render correctly 2`] = `
+<ContextProvider
+ value={
+ Object {
+ "dirty": false,
+ "errors": Object {},
+ "handleBlur": [Function],
+ "handleChange": [Function],
+ "handleReset": [Function],
+ "handleSubmit": [Function],
+ "initialValues": Object {
+ "field": "foo",
+ },
+ "isSubmitting": false,
+ "isValid": true,
+ "isValidating": false,
+ "registerField": [Function],
+ "resetForm": [Function],
+ "setError": [Function],
+ "setErrors": [Function],
+ "setFieldError": [Function],
+ "setFieldTouched": [Function],
+ "setFieldValue": [Function],
+ "setFormikState": [Function],
+ "setStatus": [Function],
+ "setSubmitting": [Function],
+ "setTouched": [Function],
+ "setValues": [Function],
+ "submitCount": 0,
+ "submitForm": [Function],
+ "touched": Object {},
+ "unregisterField": [Function],
+ "validate": [MockFunction],
+ "validateField": [Function],
+ "validateForm": [Function],
+ "validateOnBlur": true,
+ "validateOnChange": true,
+ "validationSchema": undefined,
+ "values": Object {
+ "field": "foo",
+ },
+ }
+ }
+>
+ <form
+ onSubmit={[Function]}
+ >
+ <header
+ className="modal-head"
+ >
+ <h2>
+ title
+ </h2>
+ </header>
+ <div
+ className="modal-body"
+ >
+ <input
+ name="field"
+ onBlur={[Function]}
+ onChange={[Function]}
+ type="text"
+ value="foo"
+ />
+ </div>
+ <footer
+ className="modal-foot"
+ >
+ <DeferredSpinner
+ className="spacer-right"
+ loading={false}
+ />
+ <SubmitButton
+ className="button-red"
+ disabled={true}
+ >
+ confirm
+ </SubmitButton>
+ <ResetButtonLink
+ disabled={false}
+ onClick={[MockFunction]}
+ >
+ cancel
+ </ResetButtonLink>
+ </footer>
+ </form>
+</ContextProvider>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/buttons-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/buttons-test.tsx.snap
new file mode 100644
index 00000000000..8d915b89387
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/buttons-test.tsx.snap
@@ -0,0 +1,31 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Button should render correctly 1`] = `
+<button
+ className="button"
+ onClick={[Function]}
+ type="button"
+>
+ My button
+</button>
+`;
+
+exports[`ButtonIcon should render correctly 1`] = `
+<Tooltip
+ mouseEnterDelay={0.4}
+ overlay="my tooltip"
+ visible={true}
+>
+ <Button
+ className="button-icon"
+ stopPropagation={true}
+ style={
+ Object {
+ "color": "#236a97",
+ }
+ }
+ >
+ <i />
+ </Button>
+</Tooltip>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/__snapshots__/clipboard-test.tsx.snap b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/clipboard-test.tsx.snap
new file mode 100644
index 00000000000..de2081cb464
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/__snapshots__/clipboard-test.tsx.snap
@@ -0,0 +1,52 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`ClipboardBase should display correctly 1`] = `
+<Button>
+ copy
+</Button>
+`;
+
+exports[`ClipboardButton should display correctly 1`] = `
+<Tooltip
+ overlay="copied_action"
+ visible={false}
+>
+ <Button
+ className="no-select"
+ data-clipboard-text="foo"
+ innerRef={[Function]}
+ >
+ <CopyIcon
+ className="little-spacer-right"
+ />
+ copy
+ </Button>
+</Tooltip>
+`;
+
+exports[`ClipboardButton should render a custom label if provided 1`] = `
+<Tooltip
+ overlay="copied_action"
+ visible={false}
+>
+ <Button
+ className="no-select"
+ data-clipboard-text="foo"
+ innerRef={[Function]}
+ >
+ Foo Bar
+ </Button>
+</Tooltip>
+`;
+
+exports[`ClipboardIconButton should display correctly 1`] = `
+<ButtonIcon
+ aria-label="copy_to_clipboard"
+ className="no-select"
+ data-clipboard-text="foo"
+ innerRef={[Function]}
+ tooltip="copy_to_clipboard"
+>
+ <CopyIcon />
+</ButtonIcon>
+`;
diff --git a/server/sonar-ui-common/components/controls/__tests__/buttons-test.tsx b/server/sonar-ui-common/components/controls/__tests__/buttons-test.tsx
new file mode 100644
index 00000000000..59e554f972c
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/buttons-test.tsx
@@ -0,0 +1,77 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { click, mockEvent } from '../../../helpers/testUtils';
+import { Button, ButtonIcon, ButtonIconProps } from '../buttons';
+
+describe('Button', () => {
+ it('should render correctly', () => {
+ const onClick = jest.fn();
+ const preventDefault = jest.fn();
+ const stopPropagation = jest.fn();
+ const wrapper = shallowRender({ onClick });
+ expect(wrapper).toMatchSnapshot();
+ click(wrapper.find('button'), mockEvent({ preventDefault, stopPropagation }));
+ expect(onClick).toBeCalled();
+ expect(preventDefault).toBeCalled();
+ expect(stopPropagation).not.toBeCalled();
+ });
+
+ it('should not stop propagation, but prevent default of the click event', () => {
+ const preventDefault = jest.fn();
+ const stopPropagation = jest.fn();
+ const wrapper = shallowRender({ preventDefault: false, stopPropagation: true });
+ click(wrapper.find('button'), mockEvent({ preventDefault, stopPropagation }));
+ expect(preventDefault).not.toBeCalled();
+ expect(stopPropagation).toBeCalled();
+ });
+
+ it('should disable buttons with a class', () => {
+ const preventDefault = jest.fn();
+ const onClick = jest.fn();
+ const button = shallowRender({ disabled: true, onClick, preventDefault: false }).find('button');
+ expect(button.props().disabled).toBeUndefined();
+ expect(button.props().className).toContain('disabled');
+ expect(button.props()['aria-disabled']).toBe(true);
+ click(button, mockEvent({ preventDefault }));
+ expect(onClick).not.toBeCalled();
+ expect(preventDefault).toBeCalled();
+ });
+
+ function shallowRender(props: Partial<Button['props']> = {}) {
+ return shallow<Button>(<Button {...props}>My button</Button>);
+ }
+});
+
+describe('ButtonIcon', () => {
+ it('should render correctly', () => {
+ const wrapper = shallowRender();
+ expect(wrapper).toMatchSnapshot();
+ });
+
+ function shallowRender(props: Partial<ButtonIconProps> = {}) {
+ return shallow(
+ <ButtonIcon tooltip="my tooltip" tooltipProps={{ visible: true }} {...props}>
+ <i />
+ </ButtonIcon>
+ ).dive();
+ }
+});
diff --git a/server/sonar-ui-common/components/controls/__tests__/clipboard-test.tsx b/server/sonar-ui-common/components/controls/__tests__/clipboard-test.tsx
new file mode 100644
index 00000000000..0563651027e
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/__tests__/clipboard-test.tsx
@@ -0,0 +1,102 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 { mount, shallow } from 'enzyme';
+import * as React from 'react';
+import { Button } from '../buttons';
+import { ClipboardBase, ClipboardButton, ClipboardIconButton } from '../clipboard';
+
+const constructor = jest.fn();
+const destroy = jest.fn();
+const on = jest.fn();
+
+jest.mock(
+ 'clipboard',
+ () =>
+ function (...args: any) {
+ constructor(...args);
+ return {
+ destroy,
+ on,
+ };
+ }
+);
+
+jest.useFakeTimers();
+
+describe('ClipboardBase', () => {
+ it('should display correctly', () => {
+ const children = jest.fn().mockReturnValue(<Button>copy</Button>);
+ const wrapper = shallowRender(children);
+ const instance = wrapper.instance();
+ expect(wrapper).toMatchSnapshot();
+ instance.handleSuccessCopy();
+ expect(children).toBeCalledWith({ copySuccess: true, setCopyButton: instance.setCopyButton });
+ jest.runAllTimers();
+ expect(children).toBeCalledWith({ copySuccess: false, setCopyButton: instance.setCopyButton });
+ });
+
+ it('should allow its content to be copied', () => {
+ const wrapper = mountRender(({ setCopyButton }) => (
+ <Button innerRef={setCopyButton}>click</Button>
+ ));
+ const button = wrapper.find('button').getDOMNode();
+ const instance = wrapper.instance();
+
+ expect(constructor).toBeCalledWith(button);
+ expect(on).toBeCalledWith('success', instance.handleSuccessCopy);
+
+ jest.clearAllMocks();
+
+ wrapper.unmount();
+ expect(destroy).toBeCalled();
+ });
+
+ function shallowRender(children?: ClipboardBase['props']['children']) {
+ return shallow<ClipboardBase>(<ClipboardBase>{children}</ClipboardBase>);
+ }
+
+ function mountRender(children?: ClipboardBase['props']['children']) {
+ return mount<ClipboardBase>(<ClipboardBase>{children}</ClipboardBase>);
+ }
+});
+
+describe('ClipboardButton', () => {
+ it('should display correctly', () => {
+ expect(shallowRender()).toMatchSnapshot();
+ });
+
+ it('should render a custom label if provided', () => {
+ expect(shallowRender('Foo Bar')).toMatchSnapshot();
+ });
+
+ function shallowRender(children?: React.ReactNode) {
+ return shallow(<ClipboardButton copyValue="foo">{children}</ClipboardButton>).dive();
+ }
+});
+
+describe('ClipboardIconButton', () => {
+ it('should display correctly', () => {
+ expect(shallowRender()).toMatchSnapshot();
+ });
+
+ function shallowRender() {
+ return shallow(<ClipboardIconButton copyValue="foo" />).dive();
+ }
+});
diff --git a/server/sonar-ui-common/components/controls/buttons.css b/server/sonar-ui-common/components/controls/buttons.css
new file mode 100644
index 00000000000..25a6944a348
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/buttons.css
@@ -0,0 +1,322 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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.
+ */
+.button {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ vertical-align: middle;
+ height: var(--controlHeight);
+ line-height: calc(var(--controlHeight) - 2px);
+ padding: 0 var(--gridSize);
+ border: 1px solid var(--darkBlue);
+ border-radius: 2px;
+ box-sizing: border-box;
+ background: transparent;
+ color: var(--darkBlue);
+ font-weight: 600;
+ font-size: var(--smallFontSize);
+ text-decoration: none;
+ cursor: pointer;
+ outline: none;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease;
+}
+
+.button:hover,
+.button.button-active {
+ background: var(--darkBlue);
+ color: var(--white);
+}
+
+.button:active {
+ box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
+}
+
+.button:focus {
+ box-shadow: 0 0 0 3px rgba(35, 106, 151, 0.25);
+}
+
+.button-primary {
+ background: var(--darkBlue);
+ border-color: var(--darkBlue);
+ color: var(--white);
+}
+
+.button-primary:hover {
+ background: var(--veryDarkBlue);
+ border-color: var(--veryDarkBlue);
+}
+
+.button-primary.button-light {
+ background: var(--blue);
+ border-color: var(--blue);
+ color: var(--white);
+}
+
+.button-primary.button-light:hover {
+ background: var(--darkBlue);
+ border-color: var(--darkBlue);
+}
+
+.button.disabled {
+ color: var(--disableGrayText) !important;
+ border-color: var(--disableGrayBorder) !important;
+ background: var(--disableGrayBg) !important;
+ cursor: not-allowed !important;
+ box-shadow: none !important;
+}
+
+/* #region .button-red */
+.button-red {
+ border-color: var(--red);
+ color: var(--red);
+}
+
+.button-red:hover,
+.button-red.active {
+ background: var(--red);
+ color: var(--white);
+}
+
+.button-red:focus {
+ box-shadow: 0 0 0 3px rgba(212, 51, 63, 0.25);
+}
+
+/* #endregion */
+
+/* #region .button-success */
+.button-success {
+ border-color: var(--green);
+ color: var(--green);
+}
+
+.button-success:hover,
+.button-success.active {
+ background: var(--green);
+ color: var(--white);
+}
+
+.button-success:focus {
+ box-shadow: 0 0 0 3px rgba(0, 170, 0, 0.25);
+}
+
+/* #endregion */
+
+/* #region .button-link */
+.button-link {
+ display: inline-flex;
+ height: auto;
+ /* Keep this to not inherit the height from .button */
+ line-height: 1;
+ margin: 0;
+ padding: 0;
+ border: none;
+ border-radius: 0;
+ background: transparent;
+ color: var(--darkBlue);
+ border-bottom: 1px solid var(--lightBlue);
+ font-weight: 400;
+ font-size: inherit;
+ transition: border-color 0.2s ease, box-shadow 0.2s ease, color 0.2s ease, border-bottom 0.2s ease;
+}
+
+.dropdown .button-link {
+ border-bottom: none;
+}
+
+.button-link:hover {
+ background: transparent;
+ color: var(--blue);
+}
+
+.button-link:active,
+.button-link:focus {
+ box-shadow: none;
+ outline: 1px dotted var(--blue);
+}
+
+.button-link.disabled {
+ color: var(--secondFontColor);
+ background: transparent !important;
+ cursor: default;
+}
+
+/* #endregion */
+
+.button-small {
+ height: var(--smallControlHeight);
+ line-height: 18px;
+ padding: 0 6px;
+ font-size: 11px;
+}
+
+.button-tiny {
+ height: var(--tinyControlHeight);
+ line-height: var(--tinyControlHeight);
+ padding: 0 calc(var(--gridSize) / 2);
+}
+
+.button-large {
+ height: var(--largeControlHeight);
+ padding: 0 16px;
+ font-size: var(--mediumFontSize);
+}
+
+.button-huge {
+ flex-direction: column;
+ padding: calc(2 * var(--gridSize));
+ width: 200px;
+ height: 200px;
+ background-color: var(--white);
+ border: solid 1px var(--white);
+ border-radius: 3px;
+ transition: all 0.2s ease;
+ box-shadow: 0 1px 1px 1px var(--barBorderColor);
+}
+
+.button-huge:hover,
+.button-huge:focus,
+.button-huge:active {
+ background-color: var(--white);
+ color: var(--darkBlue);
+ box-shadow: var(--defaultShadow);
+ transform: translateY(-2px);
+}
+
+/* #region .button-group */
+/* TODO drop usage of this class in SQ (already dropped from SC) */
+.button-group {
+ display: inline-block;
+ vertical-align: middle;
+ font-size: 0;
+ white-space: nowrap;
+}
+
+.button-group > button,
+.button-group > .button {
+ position: relative;
+ z-index: var(--normalZIndex);
+ display: inline-block;
+ vertical-align: middle;
+ margin: 0;
+ cursor: pointer;
+}
+
+.button-group > .button:hover:not(.disabled),
+.button-group > .button:focus:not(.disabled),
+.button-group > .button:active:not(.disabled),
+.button-group > .button.active:not(.disabled) {
+ z-index: var(--aboveNormalZIndex);
+}
+
+.button-group > .button.disabled {
+ z-index: var(--belowNormalZIndex);
+}
+
+.button-group > .button:not(:first-child) {
+ border-top-left-radius: 0;
+ border-bottom-left-radius: 0;
+}
+
+.button-group > .button:not(:last-child):not(.dropdown-toggle) {
+ border-top-right-radius: 0;
+ border-bottom-right-radius: 0;
+}
+
+.button-group > .button + .button {
+ margin-left: -1px;
+}
+
+.button-group > a:not(.button) {
+ vertical-align: middle;
+ margin: 0 8px;
+ font-size: var(--smallFontSize);
+}
+
+/* #endregion */
+
+/* #region .button-icon */
+.button-icon {
+ display: inline-flex;
+ justify-content: center;
+ align-items: center;
+ vertical-align: middle;
+ width: var(--controlHeight);
+ height: var(--controlHeight);
+ padding: 0;
+ border: none;
+ color: inherit;
+}
+
+.button-icon.button-small {
+ width: var(--smallControlHeight);
+ height: var(--smallControlHeight);
+ padding: 0;
+}
+
+.button-icon.button-small svg {
+ margin-top: 0;
+}
+
+.button-icon.button-tiny {
+ width: var(--tinyControlHeight);
+ height: var(--tinyControlHeight);
+ padding: 0;
+}
+
+.button-icon.button-tiny svg {
+ margin-top: 0;
+}
+
+.button-icon:hover,
+.button-icon:focus {
+ background-color: currentColor;
+}
+
+.button-icon:not(.disabled):hover svg,
+.button-icon:not(.disabled):focus svg {
+ color: var(--white);
+}
+
+.button.button-icon.disabled {
+ background: transparent !important;
+}
+
+/* #endregion */
+
+.button-list {
+ display: inline-flex;
+ justify-content: space-between;
+ height: auto;
+ border: 1px solid var(--barBorderColor);
+ padding: var(--gridSize);
+ margin: calc(var(--gridSize) / 2);
+ color: var(--secondFontColor);
+ font-weight: normal;
+}
+
+.button-list:hover {
+ background-color: white;
+ border-color: var(--blue);
+ color: var(--darkBlue);
+}
+
+.no-select {
+ user-select: none !important;
+}
diff --git a/server/sonar-ui-common/components/controls/buttons.tsx b/server/sonar-ui-common/components/controls/buttons.tsx
new file mode 100644
index 00000000000..d0567bebd5d
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/buttons.tsx
@@ -0,0 +1,182 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as React from 'react';
+import ChevronRightIcon from '../icons/ChevronRightIcon';
+import ClearIcon, { ClearIconProps } from '../icons/ClearIcon';
+import DeleteIcon from '../icons/DeleteIcon';
+import EditIcon from '../icons/EditIcon';
+import { IconProps } from '../icons/Icon';
+import { ThemeConsumer } from '../theme';
+import './buttons.css';
+import Tooltip, { TooltipProps } from './Tooltip';
+
+type AllowedButtonAttributes = Pick<
+ React.ButtonHTMLAttributes<HTMLButtonElement>,
+ 'className' | 'disabled' | 'id' | 'style' | 'title'
+>;
+
+interface ButtonProps extends AllowedButtonAttributes {
+ autoFocus?: boolean;
+ children?: React.ReactNode;
+ innerRef?: (node: HTMLElement | null) => void;
+ name?: string;
+ onClick?: () => void;
+ preventDefault?: boolean;
+ stopPropagation?: boolean;
+ type?: 'button' | 'submit' | 'reset' | undefined;
+}
+
+export class Button extends React.PureComponent<ButtonProps> {
+ handleClick = (event: React.MouseEvent<HTMLButtonElement>) => {
+ const { disabled, onClick, preventDefault = true, stopPropagation = false } = this.props;
+
+ event.currentTarget.blur();
+ if (preventDefault || disabled) {
+ event.preventDefault();
+ }
+ if (stopPropagation) {
+ event.stopPropagation();
+ }
+
+ if (onClick && !disabled) {
+ onClick();
+ }
+ };
+
+ render() {
+ const {
+ className,
+ disabled,
+ innerRef,
+ onClick,
+ preventDefault,
+ stopPropagation,
+ type = 'button',
+ ...props
+ } = this.props;
+ return (
+ // eslint-disable-next-line react/button-has-type
+ <button
+ {...props}
+ aria-disabled={disabled}
+ className={classNames('button', className, { disabled })}
+ id={this.props.id}
+ onClick={this.handleClick}
+ ref={this.props.innerRef}
+ type={type}
+ />
+ );
+ }
+}
+
+export function ButtonLink({ className, ...props }: ButtonProps) {
+ return <Button {...props} className={classNames('button-link', className)} />;
+}
+
+export function SubmitButton(props: T.Omit<ButtonProps, 'type'>) {
+ // do not prevent default to actually submit a form
+ return <Button {...props} preventDefault={false} type="submit" />;
+}
+
+export function ResetButtonLink(props: T.Omit<ButtonProps, 'type'>) {
+ return <ButtonLink {...props} type="reset" />;
+}
+
+export interface ButtonIconProps extends ButtonProps {
+ 'aria-label'?: string;
+ 'aria-labelledby'?: string;
+ className?: string;
+ color?: string;
+ onClick?: () => void;
+ tooltip?: React.ReactNode;
+ tooltipProps?: Partial<TooltipProps>;
+}
+
+export function ButtonIcon(props: ButtonIconProps) {
+ const { className, color, tooltip, tooltipProps, ...other } = props;
+ return (
+ <ThemeConsumer>
+ {(theme) => (
+ <Tooltip mouseEnterDelay={0.4} overlay={tooltip} {...tooltipProps}>
+ <Button
+ className={classNames(className, 'button-icon')}
+ stopPropagation={true}
+ style={{ color: color || theme.colors.darkBlue }}
+ {...other}
+ />
+ </Tooltip>
+ )}
+ </ThemeConsumer>
+ );
+}
+
+interface ClearButtonProps extends ButtonIconProps {
+ className?: string;
+ iconProps?: ClearIconProps;
+ onClick?: () => void;
+}
+
+export function ClearButton({ color, iconProps = {}, ...props }: ClearButtonProps) {
+ return (
+ <ThemeConsumer>
+ {(theme) => (
+ <ButtonIcon color={color || theme.colors.gray60} {...props}>
+ <ClearIcon {...iconProps} />
+ </ButtonIcon>
+ )}
+ </ThemeConsumer>
+ );
+}
+
+interface ActionButtonProps extends ButtonIconProps {
+ className?: string;
+ iconProps?: IconProps;
+ onClick?: () => void;
+}
+
+export function DeleteButton({ iconProps = {}, ...props }: ActionButtonProps) {
+ return (
+ <ThemeConsumer>
+ {(theme) => (
+ <ButtonIcon color={theme.colors.red} {...props}>
+ <DeleteIcon {...iconProps} />
+ </ButtonIcon>
+ )}
+ </ThemeConsumer>
+ );
+}
+
+export function EditButton({ iconProps = {}, ...props }: ActionButtonProps) {
+ return (
+ <ButtonIcon {...props}>
+ <EditIcon {...iconProps} />
+ </ButtonIcon>
+ );
+}
+
+export function ListButton({ className, children, ...props }: ButtonProps) {
+ return (
+ <Button className={classNames('button-list', className)} {...props}>
+ {children}
+ <ChevronRightIcon />
+ </Button>
+ );
+}
diff --git a/server/sonar-ui-common/components/controls/clipboard.tsx b/server/sonar-ui-common/components/controls/clipboard.tsx
new file mode 100644
index 00000000000..e4778f46251
--- /dev/null
+++ b/server/sonar-ui-common/components/controls/clipboard.tsx
@@ -0,0 +1,149 @@
+/*
+ * Sonar UI Common
+ * Copyright (C) 2019-2020 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 classNames from 'classnames';
+import * as Clipboard from 'clipboard';
+import * as React from 'react';
+import { translate } from '../../helpers/l10n';
+import CopyIcon from '../icons/CopyIcon';
+import { Button, ButtonIcon } from './buttons';
+import Tooltip from './Tooltip';
+
+export interface State {
+ copySuccess: boolean;
+}
+
+interface RenderProps {
+ setCopyButton: (node: HTMLElement | null) => void;
+ copySuccess: boolean;
+}
+
+interface BaseProps {
+ children: (props: RenderProps) => React.ReactNode;
+}
+
+export class ClipboardBase extends React.PureComponent<BaseProps, State> {
+ private clipboard?: Clipboard;
+ private copyButton?: HTMLElement | null;
+ mounted = false;
+ state: State = { copySuccess: false };
+
+ componentDidMount() {
+ this.mounted = true;
+ if (this.copyButton) {
+ this.clipboard = new Clipboard(this.copyButton);
+ this.clipboard.on('success', this.handleSuccessCopy);
+ }
+ }
+
+ componentDidUpdate() {
+ if (this.clipboard) {
+ this.clipboard.destroy();
+ }
+ if (this.copyButton) {
+ this.clipboard = new Clipboard(this.copyButton);
+ this.clipboard.on('success', this.handleSuccessCopy);
+ }
+ }
+
+ componentWillUnmount() {
+ this.mounted = false;
+ if (this.clipboard) {
+ this.clipboard.destroy();
+ }
+ }
+
+ setCopyButton = (node: HTMLElement | null) => {
+ this.copyButton = node;
+ };
+
+ handleSuccessCopy = () => {
+ if (this.mounted) {
+ this.setState({ copySuccess: true });
+ setTimeout(() => {
+ if (this.mounted) {
+ this.setState({ copySuccess: false });
+ }
+ }, 1000);
+ }
+ };
+
+ render() {
+ return this.props.children({
+ setCopyButton: this.setCopyButton,
+ copySuccess: this.state.copySuccess,
+ });
+ }
+}
+
+interface ButtonProps {
+ className?: string;
+ copyValue: string;
+ children?: React.ReactNode;
+}
+
+export function ClipboardButton({ className, children, copyValue }: ButtonProps) {
+ return (
+ <ClipboardBase>
+ {({ setCopyButton, copySuccess }) => (
+ <Tooltip overlay={translate('copied_action')} visible={copySuccess}>
+ <Button
+ className={classNames('no-select', className)}
+ data-clipboard-text={copyValue}
+ innerRef={setCopyButton}>
+ {children || (
+ <>
+ <CopyIcon className="little-spacer-right" />
+ {translate('copy')}
+ </>
+ )}
+ </Button>
+ </Tooltip>
+ )}
+ </ClipboardBase>
+ );
+}
+
+interface IconButtonProps {
+ 'aria-label'?: string;
+ className?: string;
+ copyValue: string;
+}
+
+export function ClipboardIconButton(props: IconButtonProps) {
+ const { className, copyValue } = props;
+ return (
+ <ClipboardBase>
+ {({ setCopyButton, copySuccess }) => {
+ return (
+ <ButtonIcon
+ aria-label={props['aria-label'] ?? translate('copy_to_clipboard')}
+ className={classNames('no-select', className)}
+ data-clipboard-text={copyValue}
+ innerRef={setCopyButton}
+ tooltip={translate(copySuccess ? 'copied_action' : 'copy_to_clipboard')}
+ tooltipProps={copySuccess ? { visible: copySuccess } : undefined}>
+ <CopyIcon />
+ </ButtonIcon>
+ );
+ }}
+ </ClipboardBase>
+ );
+}