diff options
Diffstat (limited to 'server/sonar-ui-common/components/controls')
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> + ); +} |