+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 styled from '@emotion/styled';
-import { ReactEventHandler, useState } from 'react';
-import tw from 'twin.macro';
-import { themeBorder, themeColor } from '../helpers/theme';
-import { GenericAvatar } from './GenericAvatar';
-
-type Size = 'xs' | 'sm' | 'md' | 'lg';
-
-const sizeMap: Record<Size, number> = {
- xs: 16,
- sm: 24,
- md: 40,
- lg: 64,
-};
-
-interface AvatarProps {
- border?: boolean;
- className?: string;
- enableGravatar?: boolean;
- gravatarServerUrl?: string;
- hash?: string;
- name?: string;
- organizationAvatar?: string;
- organizationName?: string;
- size?: Size;
-}
-
-/**
- * (!) Do not use directly. it requires the gravatar settings to properly fetch the avatars.
- * This is injected by the `Avatar` component in `components/ui` in sonar-web
- */
-export function Avatar({
- className,
- enableGravatar,
- gravatarServerUrl,
- hash,
- name,
- organizationAvatar,
- organizationName,
- size = 'sm',
- border,
-}: AvatarProps) {
- const [imgError, setImgError] = useState(false);
- const numberSize = sizeMap[size];
- const resolvedName = organizationName ?? name;
-
- const handleImgError: ReactEventHandler<HTMLImageElement> = () => {
- setImgError(true);
- };
-
- if (!imgError) {
- if (enableGravatar && gravatarServerUrl && hash) {
- const url = gravatarServerUrl
- .replace('{EMAIL_MD5}', hash)
- .replace('{SIZE}', String(numberSize * 2));
-
- return (
- <StyledAvatar
- alt={resolvedName}
- border={border}
- className={className}
- height={numberSize}
- onError={handleImgError}
- role="img"
- src={url}
- width={numberSize}
- />
- );
- }
-
- if (resolvedName && organizationAvatar) {
- return (
- <StyledAvatar
- alt={resolvedName}
- border={border}
- className={className}
- height={numberSize}
- onError={handleImgError}
- role="img"
- src={organizationAvatar}
- width={numberSize}
- />
- );
- }
- }
-
- if (!resolvedName) {
- return <input className="sw-appearance-none" />;
- }
-
- return <GenericAvatar className={className} name={resolvedName} size={numberSize} />;
-}
-
-const StyledAvatar = styled.img<{ border?: boolean }>`
- ${tw`sw-inline-flex`};
- ${tw`sw-items-center`};
- ${tw`sw-justify-center`};
- ${tw`sw-align-top`};
- ${tw`sw-rounded-1`};
- border: ${({ border }) => (border ? themeBorder('default', 'avatarBorder') : '')};
- background: ${themeColor('avatarBackground')};
-`;
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { useTheme } from '@emotion/react';
-import styled from '@emotion/styled';
-import React from 'react';
-import tw from 'twin.macro';
-import { themeAvatarColor } from '../helpers/theme';
-import { IconProps } from './icons/Icon';
-
-export interface GenericAvatarProps {
- Icon?: React.ComponentType<IconProps>;
- className?: string;
- name: string;
- size?: number;
-}
-
-export function GenericAvatar({ className, Icon, name, size = 24 }: GenericAvatarProps) {
- const theme = useTheme();
- const text = name.length > 0 ? name[0].toUpperCase() : '';
-
- return (
- <StyledGenericAvatar aria-label={name} className={className} name={name} role="img" size={size}>
- {Icon ? <Icon fill={themeAvatarColor(name, true)({ theme })} /> : text}
- </StyledGenericAvatar>
- );
-}
-
-export const StyledGenericAvatar = styled.div<{ name: string; size: number }>`
- ${tw`sw-text-center`};
- ${tw`sw-align-top`};
- ${tw`sw-select-none`};
- ${tw`sw-font-regular`};
- ${tw`sw-rounded-1`};
- ${tw`sw-inline-flex`};
- ${tw`sw-items-center`};
- ${tw`sw-justify-center`};
- height: ${({ size }) => size}px;
- width: ${({ size }) => size}px;
- background-color: ${({ name, theme }) => themeAvatarColor(name)({ theme })};
- color: ${({ name, theme }) => themeAvatarColor(name, true)({ theme })};
- font-size: ${({ size }) => Math.max(Math.floor(size / 2), 8)}px;
- line-height: ${({ size }) => size}px;
-`;
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 import/no-extraneous-dependencies */
-
-import { fireEvent, screen } from '@testing-library/react';
-import { render } from '../../helpers/testUtils';
-import { FCProps } from '../../types/misc';
-import { Avatar } from '../Avatar';
-
-const gravatarServerUrl = 'http://example.com/{EMAIL_MD5}.jpg?s={SIZE}';
-
-it('should render avatar with border', () => {
- setupWithProps({ border: true, hash: '7daf6c79d4802916d83f6266e24850af' });
- expect(screen.getByRole('img')).toHaveStyle('border: 1px solid rgb(225,230,243)');
-});
-
-it('should be able to render with hash only', () => {
- setupWithProps({ hash: '7daf6c79d4802916d83f6266e24850af' });
- expect(screen.getByRole('img')).toHaveAttribute(
- 'src',
- 'http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=48'
- );
-});
-
-it('should fall back to generated on error', () => {
- setupWithProps({ hash: '7daf6c79d4802916d83f6266e24850af' });
- fireEvent(screen.getByRole('img'), new Event('error'));
- expect(screen.getByRole('img')).not.toHaveAttribute('src');
-});
-
-it('should fall back to dummy avatar', () => {
- setupWithProps({ enableGravatar: false });
- expect(screen.getByRole('img')).not.toHaveAttribute('src');
-});
-
-it('should return null if no name is set', () => {
- setupWithProps({ name: undefined });
- expect(screen.queryByRole('img')).not.toBeInTheDocument();
-});
-
-it('should display organization avatar correctly', () => {
- const avatar = 'http://example.com/avatar.png';
- setupWithProps({ organizationAvatar: avatar, organizationName: 'my-org' });
- expect(screen.getByRole('img')).toHaveAttribute('src', avatar);
-});
-
-function setupWithProps(props: Partial<FCProps<typeof Avatar>> = {}) {
- return render(
- <Avatar enableGravatar gravatarServerUrl={gravatarServerUrl} name="foo" {...props} />
- );
-}
+++ /dev/null
-/*
- * SonarQube
- * Copyright (C) 2009-2023 SonarSource SA
- * mailto:info AT sonarsource DOT com
- *
- * This program is free software; you can redistribute it and/or
- * modify it under the terms of the GNU Lesser General Public
- * License as published by the Free Software Foundation; either
- * version 3 of the License, or (at your option) any later version.
- *
- * This program is distributed in the hope that it will be useful,
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
- * Lesser General Public License for more details.
- *
- * You should have received a copy of the GNU Lesser General Public License
- * along with this program; if not, write to the Free Software Foundation,
- * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
- */
-import { screen } from '@testing-library/react';
-import { render } from '../../helpers/testUtils';
-import { GenericAvatar } from '../GenericAvatar';
-import { CustomIcon, IconProps } from '../icons/Icon';
-
-function TestIcon(props: IconProps) {
- return (
- <CustomIcon {...props}>
- <path d="l10 10" />
- </CustomIcon>
- );
-}
-
-it('should render single word and size', () => {
- render(<GenericAvatar name="foo" size={15} />);
- const image = screen.getByRole('img');
- expect(image).toHaveAttribute('size', '15');
- expect(screen.getByText('F')).toBeInTheDocument();
-});
-
-it('should render multiple word with default size', () => {
- render(<GenericAvatar name="foo bar" />);
- const image = screen.getByRole('img');
- expect(image).toHaveAttribute('size', '24');
- expect(screen.getByText('F')).toBeInTheDocument();
-});
-
-it('should render without name', () => {
- render(<GenericAvatar Icon={TestIcon} name="" size={32} />);
- const image = screen.getByRole('img');
- expect(image).toHaveAttribute('size', '32');
-});
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 styled from '@emotion/styled';
+import { ReactEventHandler, useState } from 'react';
+import tw from 'twin.macro';
+import { themeBorder, themeColor } from '../../helpers/theme';
+import { GenericAvatar } from './GenericAvatar';
+import { Size, sizeMap } from './utils';
+
+interface AvatarProps {
+ border?: boolean;
+ className?: string;
+ enableGravatar?: boolean;
+ gravatarServerUrl?: string;
+ hash?: string;
+ name?: string;
+ organizationAvatar?: string;
+ organizationName?: string;
+ size?: Size;
+}
+
+/**
+ * (!) Do not use directly. it requires the gravatar settings to properly fetch the avatars.
+ * This is injected by the `Avatar` component in `components/ui` in sonar-web
+ */
+export function Avatar({
+ className,
+ enableGravatar,
+ gravatarServerUrl,
+ hash,
+ name,
+ organizationAvatar,
+ organizationName,
+ size = 'sm',
+ border,
+}: AvatarProps) {
+ const [imgError, setImgError] = useState(false);
+ const numberSize = sizeMap[size];
+ const resolvedName = organizationName ?? name;
+
+ const handleImgError: ReactEventHandler<HTMLImageElement> = () => {
+ setImgError(true);
+ };
+
+ if (!imgError) {
+ if (enableGravatar && gravatarServerUrl && hash) {
+ const url = gravatarServerUrl
+ .replace('{EMAIL_MD5}', hash)
+ .replace('{SIZE}', String(numberSize * 2));
+
+ return (
+ <StyledAvatar
+ alt={resolvedName}
+ border={border}
+ className={className}
+ height={numberSize}
+ onError={handleImgError}
+ role="img"
+ src={url}
+ width={numberSize}
+ />
+ );
+ }
+
+ if (resolvedName && organizationAvatar) {
+ return (
+ <StyledAvatar
+ alt={resolvedName}
+ border={border}
+ className={className}
+ height={numberSize}
+ onError={handleImgError}
+ role="img"
+ src={organizationAvatar}
+ width={numberSize}
+ />
+ );
+ }
+ }
+
+ if (!resolvedName) {
+ return <input className="sw-appearance-none" />;
+ }
+
+ return <GenericAvatar className={className} name={resolvedName} size={size} />;
+}
+
+const StyledAvatar = styled.img<{ border?: boolean }>`
+ ${tw`sw-inline-flex`};
+ ${tw`sw-items-center`};
+ ${tw`sw-justify-center`};
+ ${tw`sw-align-top`};
+ ${tw`sw-rounded-1`};
+ border: ${({ border }) => (border ? themeBorder('default', 'avatarBorder') : '')};
+ background: ${themeColor('avatarBackground')};
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { useTheme } from '@emotion/react';
+import styled from '@emotion/styled';
+import React from 'react';
+import tw from 'twin.macro';
+import { themeAvatarColor } from '../../helpers/theme';
+import { IconProps } from '../icons/Icon';
+import { Size, iconSizeMap, sizeMap } from './utils';
+
+export interface GenericAvatarProps {
+ Icon?: React.ComponentType<IconProps>;
+ className?: string;
+ name: string;
+ size?: Size;
+}
+
+export function GenericAvatar({ className, Icon, name, size = 'sm' }: GenericAvatarProps) {
+ const theme = useTheme();
+ const text = name.length > 0 ? name[0].toUpperCase() : '';
+
+ const iconSize = iconSizeMap[size];
+
+ return (
+ <StyledGenericAvatar
+ aria-label={name}
+ className={className}
+ name={name}
+ role="img"
+ size={sizeMap[size]}
+ >
+ {Icon ? (
+ <Icon fill={themeAvatarColor(name, true)({ theme })} height={iconSize} width={iconSize} />
+ ) : (
+ text
+ )}
+ </StyledGenericAvatar>
+ );
+}
+
+export const StyledGenericAvatar = styled.div<{ name: string; size: number }>`
+ ${tw`sw-text-center`};
+ ${tw`sw-align-top`};
+ ${tw`sw-select-none`};
+ ${tw`sw-font-regular`};
+ ${tw`sw-rounded-1`};
+ ${tw`sw-inline-flex`};
+ ${tw`sw-items-center`};
+ ${tw`sw-justify-center`};
+ height: ${({ size }) => size}px;
+ width: ${({ size }) => size}px;
+ background-color: ${({ name, theme }) => themeAvatarColor(name)({ theme })};
+ color: ${({ name, theme }) => themeAvatarColor(name, true)({ theme })};
+ font-size: ${({ size }) => Math.max(Math.floor(size / 2), 8)}px;
+ line-height: ${({ size }) => size}px;
+`;
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 import/no-extraneous-dependencies */
+
+import { fireEvent, screen } from '@testing-library/react';
+import { render } from '../../../helpers/testUtils';
+import { FCProps } from '../../../types/misc';
+import { Avatar } from '../Avatar';
+
+const gravatarServerUrl = 'http://example.com/{EMAIL_MD5}.jpg?s={SIZE}';
+
+it('should render avatar with border', () => {
+ setupWithProps({ border: true, hash: '7daf6c79d4802916d83f6266e24850af' });
+ expect(screen.getByRole('img')).toHaveStyle('border: 1px solid rgb(225,230,243)');
+});
+
+it('should be able to render with hash only', () => {
+ setupWithProps({ hash: '7daf6c79d4802916d83f6266e24850af' });
+ expect(screen.getByRole('img')).toHaveAttribute(
+ 'src',
+ 'http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=48'
+ );
+});
+
+it('should fall back to generated on error', () => {
+ setupWithProps({ hash: '7daf6c79d4802916d83f6266e24850af' });
+ fireEvent(screen.getByRole('img'), new Event('error'));
+ expect(screen.getByRole('img')).not.toHaveAttribute('src');
+});
+
+it('should fall back to dummy avatar', () => {
+ setupWithProps({ enableGravatar: false });
+ expect(screen.getByRole('img')).not.toHaveAttribute('src');
+});
+
+it('should return null if no name is set', () => {
+ setupWithProps({ name: undefined });
+ expect(screen.queryByRole('img')).not.toBeInTheDocument();
+});
+
+it('should display organization avatar correctly', () => {
+ const avatar = 'http://example.com/avatar.png';
+ setupWithProps({ organizationAvatar: avatar, organizationName: 'my-org' });
+ expect(screen.getByRole('img')).toHaveAttribute('src', avatar);
+});
+
+function setupWithProps(props: Partial<FCProps<typeof Avatar>> = {}) {
+ return render(
+ <Avatar enableGravatar gravatarServerUrl={gravatarServerUrl} name="foo" {...props} />
+ );
+}
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 SonarSource SA
+ * mailto:info AT sonarsource DOT com
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU Lesser General Public
+ * License as published by the Free Software Foundation; either
+ * version 3 of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
+ * Lesser General Public License for more details.
+ *
+ * You should have received a copy of the GNU Lesser General Public License
+ * along with this program; if not, write to the Free Software Foundation,
+ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ */
+import { screen } from '@testing-library/react';
+import { render } from '../../../helpers/testUtils';
+import { CustomIcon, IconProps } from '../../icons/Icon';
+import { GenericAvatar } from '../GenericAvatar';
+
+function TestIcon(props: IconProps) {
+ return (
+ <CustomIcon {...props}>
+ <path d="l10 10" />
+ </CustomIcon>
+ );
+}
+
+it('should render single word and size', () => {
+ render(<GenericAvatar name="foo" size="xs" />);
+ const image = screen.getByRole('img');
+ expect(image).toHaveAttribute('size', '16');
+ expect(screen.getByText('F')).toBeInTheDocument();
+});
+
+it('should render multiple word with default size', () => {
+ render(<GenericAvatar name="foo bar" />);
+ const image = screen.getByRole('img');
+ expect(image).toHaveAttribute('size', '24');
+ expect(screen.getByText('F')).toBeInTheDocument();
+});
+
+it('should render without name', () => {
+ render(<GenericAvatar Icon={TestIcon} name="" size="md" />);
+ const image = screen.getByRole('img');
+ expect(image).toHaveAttribute('size', '40');
+});
--- /dev/null
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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.
+ */
+export type Size = 'xs' | 'sm' | 'md' | 'lg';
+
+export const sizeMap: Record<Size, number> = {
+ xs: 16,
+ sm: 24,
+ md: 40,
+ lg: 64,
+};
+
+export const iconSizeMap: Record<Size, number> = {
+ xs: 12,
+ sm: 18,
+ md: 24,
+ lg: 24,
+};
function IconWrapper({ fill, ...props }: IconProps) {
const theme = useTheme();
+ const size = props.width ?? props.height ?? 'small';
+
return (
<WrappedOcticon
fill={fill && themeColor(fill)({ theme })}
- size="small"
+ size={size}
verticalAlign="middle"
{...props}
/>
*/
export * from './Accordion';
-export * from './Avatar';
export { Badge } from './Badge';
export * from './Banner';
export { BarChart } from './BarChart';
export * from './FavoriteButton';
export { FlagMessage } from './FlagMessage';
export * from './FlowStep';
-export * from './GenericAvatar';
export * from './HighlightedSection';
export { Histogram } from './Histogram';
export { HotspotRating } from './HotspotRating';
export * from './TreeMapRect';
export * from './TutorialStep';
export * from './TutorialStepList';
+export * from './avatar/Avatar';
+export * from './avatar/GenericAvatar';
export * from './buttons';
export { ClipboardIconButton } from './clipboard';
export * from './code-line/LineCoverage';
tabIndex={disabled ? -1 : 0}
>
<InputValue
- className={classNames('it__js-search-input-value sw-flex sw-justify-between', {
- 'is-disabled': disabled,
- 'is-placeholder': !label,
- })}
+ className={classNames(
+ 'it__js-search-input-value sw-flex sw-justify-between sw-items-center',
+ {
+ 'is-disabled': disabled,
+ 'is-placeholder': !label,
+ }
+ )}
>
<span className="sw-truncate">{label}</span>
<ChevronDownIcon className="sw-ml-1" />
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+import {
+ ContentCell,
+ DestructiveIcon,
+ GenericAvatar,
+ Note,
+ TrashIcon,
+ UserGroupIcon,
+} from 'design-system';
import * as React from 'react';
-import { DeleteButton } from '../../../components/controls/buttons';
-import GroupIcon from '../../../components/icons/GroupIcon';
-import LegacyAvatar from '../../../components/ui/LegacyAvatar';
+import { useIntl } from 'react-intl';
+import Avatar from '../../../components/ui/Avatar';
import { Group, isUser } from '../../../types/quality-gates';
import { UserBase } from '../../../types/users';
export default function PermissionItem(props: PermissionItemProps) {
const { item } = props;
+ const { formatMessage } = useIntl();
return (
- <div className="display-flex-center permission-list-item padded">
- {isUser(item) ? (
- <LegacyAvatar className="spacer-right" hash={item.avatar} name={item.name} size={32} />
- ) : (
- <GroupIcon className="pull-left spacer-right" size={32} />
- )}
+ <>
+ <ContentCell width={0}>
+ {isUser(item) ? (
+ <Avatar hash={item.avatar} name={item.name} size="md" />
+ ) : (
+ <GenericAvatar Icon={UserGroupIcon} name={item.name} size="md" />
+ )}
+ </ContentCell>
- <div className="overflow-hidden flex-1">
- <strong>{item.name}</strong>
- {isUser(item) && <div className="note">{item.login}</div>}
- </div>
+ <ContentCell>
+ <div className="sw-flex sw-flex-col">
+ <strong className="sw-body-sm-highlight">{item.name}</strong>
+ {isUser(item) && <Note>{item.login}</Note>}
+ </div>
+ </ContentCell>
- <DeleteButton
- onClick={() => props.onClickDelete(item)}
- data-testid="permission-delete-button"
- />
- </div>
+ <ContentCell>
+ <DestructiveIcon
+ aria-label={formatMessage({
+ id: isUser(item)
+ ? 'quality_gates.permissions.remove.user'
+ : 'quality_gates.permissions.remove.group',
+ })}
+ Icon={TrashIcon}
+ onClick={() => props.onClickDelete(item)}
+ data-testid="permission-delete-button"
+ />
+ </ContentCell>
+ </>
);
}
searchGroups,
searchUsers,
} from '../../../api/quality-gates';
-import { Group, isUser, SearchPermissionsParameters } from '../../../types/quality-gates';
+import { Group, SearchPermissionsParameters, isUser } from '../../../types/quality-gates';
import { QualityGate } from '../../../types/types';
import { UserBase } from '../../../types/users';
import QualityGatePermissionsRenderer from './QualityGatePermissionsRenderer';
if (isUser(item)) {
this.setState(({ users }) => ({
users: users.filter((u) => u.login !== item.login),
+ permissionToDelete: undefined,
}));
} else {
this.setState(({ groups }) => ({
groups: groups.filter((g) => g.name !== item.name),
+ permissionToDelete: undefined,
}));
}
}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+import { LabelValueSelectOption } from 'design-system';
import { debounce } from 'lodash';
import * as React from 'react';
+import { Options } from 'react-select';
import { searchGroups, searchUsers } from '../../../api/quality-gates';
-import { Group, SearchPermissionsParameters } from '../../../types/quality-gates';
+import { Group, SearchPermissionsParameters, isUser } from '../../../types/quality-gates';
import { QualityGate } from '../../../types/types';
import { UserBase } from '../../../types/users';
import QualityGatePermissionsAddModalRenderer from './QualityGatePermissionsAddModalRenderer';
const DEBOUNCE_DELAY = 250;
export default class QualityGatePermissionsAddModal extends React.Component<Props, State> {
- mounted = false;
state: State = {};
constructor(props: Props) {
this.handleSearch = debounce(this.handleSearch, DEBOUNCE_DELAY);
}
- componentDidMount() {
- this.mounted = true;
- }
-
- componentWillUnmount() {
- this.mounted = false;
- }
-
- handleSearch = (q: string, resolve: (options: OptionWithValue[]) => void) => {
+ handleSearch = (
+ q: string,
+ resolve: (options: Options<LabelValueSelectOption<UserBase | Group>>) => void
+ ) => {
const { qualityGate } = this.props;
const queryParams: SearchPermissionsParameters = {
};
Promise.all([searchUsers(queryParams), searchGroups(queryParams)])
- .then(([usersResponse, groupsResponse]) => [...usersResponse.users, ...groupsResponse.groups])
+ .then(([usersResponse, groupsResponse]) =>
+ [...usersResponse.users, ...groupsResponse.groups].map((o) => ({
+ value: o,
+ label: isUser(o) ? `${o.name} ${o.login}` : o.name,
+ }))
+ )
.then(resolve)
.catch(() => resolve([]));
};
- handleSelection = (selection: UserBase | Group) => {
- this.setState({ selection });
+ handleSelection = ({ value }: LabelValueSelectOption<UserBase | Group>) => {
+ this.setState({ selection: value });
};
handleSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { omit } from 'lodash';
+import {
+ ButtonPrimary,
+ FormField,
+ GenericAvatar,
+ LabelValueSelectOption,
+ Modal,
+ SearchSelectDropdown,
+ UserGroupIcon,
+} from 'design-system';
import * as React from 'react';
-import { components, ControlProps, OptionProps, SingleValueProps } from 'react-select';
-import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
-import Modal from '../../../components/controls/Modal';
-import { SearchSelect } from '../../../components/controls/Select';
-import GroupIcon from '../../../components/icons/GroupIcon';
-import LegacyAvatar from '../../../components/ui/LegacyAvatar';
+import { GroupBase, OptionProps, Options, SingleValue, components } from 'react-select';
+import Avatar from '../../../components/ui/Avatar';
import { translate } from '../../../helpers/l10n';
-import { Group, isUser } from '../../../types/quality-gates';
+import { Group as UserGroup, isUser } from '../../../types/quality-gates';
import { UserBase } from '../../../types/users';
-import { OptionWithValue } from './QualityGatePermissionsAddModal';
export interface QualityGatePermissionsAddModalRendererProps {
onClose: () => void;
- handleSearch: (q: string, resolve: (options: OptionWithValue[]) => void) => void;
- onSelection: (selection: OptionWithValue) => void;
- selection?: UserBase | Group;
+ handleSearch: (
+ q: string,
+ resolve: (options: Options<LabelValueSelectOption<UserBase | UserGroup>>) => void
+ ) => void;
+ onSelection: (selection: SingleValue<LabelValueSelectOption<UserBase | UserGroup>>) => void;
+ selection?: UserBase | UserGroup;
onSubmit: (event: React.SyntheticEvent<HTMLFormElement>) => void;
submitting: boolean;
}
+const FORM_ID = 'quality-gate-permissions-add-modal';
+const USER_SELECT_INPUT_ID = 'quality-gate-permissions-add-modal-select-input';
+
export default function QualityGatePermissionsAddModalRenderer(
props: QualityGatePermissionsAddModalRendererProps,
) {
const { selection, submitting } = props;
- const header = translate('quality_gates.permissions.grant');
-
- const noResultsText = translate('no_results');
+ const renderedSelection = React.useMemo(() => {
+ return <OptionRenderer option={selection} small />;
+ }, [selection]);
return (
- <Modal contentLabel={header} onRequestClose={props.onClose}>
- <header className="modal-head">
- <h2>{header}</h2>
- </header>
- <form onSubmit={props.onSubmit}>
- <div className="modal-body">
- <div className="modal-field">
- <label htmlFor="quality-gate-permissions-add-modal-select-input">
- {translate('quality_gates.permissions.search')}
- </label>
- <SearchSelect
- inputId="quality-gate-permissions-add-modal-select-input"
+ <Modal
+ onClose={props.onClose}
+ headerTitle={translate('quality_gates.permissions.grant')}
+ body={
+ <form onSubmit={props.onSubmit} id={FORM_ID}>
+ <FormField
+ label={translate('quality_gates.permissions.search')}
+ htmlFor={USER_SELECT_INPUT_ID}
+ >
+ <SearchSelectDropdown
+ controlAriaLabel={translate('quality_gates.permissions.search')}
+ inputId={USER_SELECT_INPUT_ID}
autoFocus
isClearable={false}
placeholder=""
defaultOptions
- noOptionsMessage={() => noResultsText}
+ noOptionsMessage={() => translate('no_results')}
onChange={props.onSelection}
loadOptions={props.handleSearch}
- getOptionValue={(opt) => (isUser(opt) ? opt.login : opt.name)}
- large
+ getOptionValue={({ value }) => (isUser(value) ? value.login : value.name)}
+ controlLabel={renderedSelection}
components={{
- Option: optionRenderer,
- SingleValue: singleValueRenderer,
- Control: controlRenderer,
+ Option,
}}
/>
- </div>
- </div>
- <footer className="modal-foot">
- {submitting && <i className="spinner spacer-right" />}
- <SubmitButton disabled={!selection || submitting}>{translate('add_verb')}</SubmitButton>
- <ResetButtonLink onClick={props.onClose}>{translate('cancel')}</ResetButtonLink>
- </footer>
- </form>
- </Modal>
+ </FormField>
+ </form>
+ }
+ primaryButton={
+ <ButtonPrimary disabled={!selection || submitting} type="submit" form={FORM_ID}>
+ {translate('add_verb')}
+ </ButtonPrimary>
+ }
+ secondaryButtonLabel={translate('cancel')}
+ />
);
}
-export function customOptions(option: OptionWithValue) {
+function OptionRenderer({
+ option,
+ small = false,
+}: {
+ option?: UserBase | UserGroup;
+ small?: boolean;
+}) {
+ if (!option) {
+ return null;
+ }
return (
- <span className="display-flex-center" data-testid="qg-add-permission-option">
+ <>
{isUser(option) ? (
- <LegacyAvatar hash={option.avatar} name={option.name} size={16} />
+ <>
+ <Avatar
+ className={small ? 'sw-my-1/2' : ''}
+ hash={option.avatar}
+ name={option.name}
+ size={small ? 'xs' : 'sm'}
+ />
+ <span className="sw-ml-2">
+ <strong className="sw-body-sm-highlight sw-mr-1">{option.name}</strong>
+ {option.login}
+ </span>
+ </>
) : (
- <GroupIcon size={16} />
+ <>
+ <GenericAvatar
+ className={small ? 'sw-my-1/2' : ''}
+ Icon={UserGroupIcon}
+ name={option.name}
+ size={small ? 'xs' : 'sm'}
+ />
+ <strong className="sw-body-sm-highlight sw-ml-2">{option.name}</strong>
+ </>
)}
- <strong className="spacer-left">{option.name}</strong>
- {isUser(option) && <span className="note little-spacer-left">{option.login}</span>}
- </span>
+ </>
);
}
-function optionRenderer(props: OptionProps<OptionWithValue, false>) {
- return <components.Option {...props}>{customOptions(props.data)}</components.Option>;
-}
-
-function singleValueRenderer(props: SingleValueProps<OptionWithValue, false>) {
- return <components.SingleValue {...props}>{customOptions(props.data)}</components.SingleValue>;
-}
+function Option<
+ Option extends LabelValueSelectOption<UserBase | UserGroup>,
+ IsMulti extends boolean = false,
+ Group extends GroupBase<Option> = GroupBase<Option>
+>(props: OptionProps<Option, IsMulti, Group>) {
+ const {
+ data: { value },
+ } = props;
-function controlRenderer(props: ControlProps<OptionWithValue, false>) {
return (
- <components.Control {...omit(props, ['children'])} className="abs-height-100">
- {props.children}
- </components.Control>
+ <components.Option {...props}>
+ <div className="sw-flex sw-items-center">
+ <OptionRenderer option={value} />
+ </div>
+ </components.Option>
);
}
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
+import {
+ ButtonSecondary,
+ DangerButtonPrimary,
+ Modal,
+ Spinner,
+ SubTitle,
+ Table,
+ TableRowInteractive,
+} from 'design-system';
import * as React from 'react';
import { FormattedMessage } from 'react-intl';
-import ConfirmModal from '../../../components/controls/ConfirmModal';
-import { Button } from '../../../components/controls/buttons';
-import Spinner from '../../../components/ui/Spinner';
import { translate } from '../../../helpers/l10n';
import { Group, isUser } from '../../../types/quality-gates';
import { QualityGate } from '../../../types/types';
props;
return (
- <div className="quality-gate-permissions" data-testid="quality-gate-permissions">
- <h3 className="spacer-bottom">{translate('quality_gates.permissions')}</h3>
- <p className="spacer-bottom">{translate('quality_gates.permissions.help')}</p>
+ <div data-testid="quality-gate-permissions">
+ <SubTitle as="h3" className="sw-body-md-highlight">
+ {translate('quality_gates.permissions')}
+ </SubTitle>
+ <p className="sw-body-sm sw-mb-2">{translate('quality_gates.permissions.help')}</p>
<div>
<Spinner loading={loading}>
- <ul>
+ <Table columnCount={3} columnWidths={['40px', 'auto', '1%']} width="100%">
{users.map((user) => (
- <li key={user.login}>
+ <TableRowInteractive key={user.login}>
<PermissionItem onClickDelete={props.onClickDeletePermission} item={user} />
- </li>
+ </TableRowInteractive>
))}
{groups.map((group) => (
- <li key={group.name}>
+ <TableRowInteractive key={group.name}>
<PermissionItem onClickDelete={props.onClickDeletePermission} item={group} />
- </li>
+ </TableRowInteractive>
))}
- </ul>
+ </Table>
</Spinner>
</div>
- <Button className="big-spacer-top" onClick={props.onClickAddPermission}>
+ <ButtonSecondary className="sw-mt-4" onClick={props.onClickAddPermission}>
{translate('quality_gates.permissions.grant')}
- </Button>
+ </ButtonSecondary>
{showAddModal && (
<QualityGatePermissionsAddModal
)}
{permissionToDelete && (
- <ConfirmModal
- header={
+ <Modal
+ headerTitle={
isUser(permissionToDelete)
? translate('quality_gates.permissions.remove.user')
: translate('quality_gates.permissions.remove.group')
}
- confirmButtonText={translate('remove')}
- isDestructive
- confirmData={permissionToDelete}
+ body={
+ <FormattedMessage
+ defaultMessage={
+ isUser(permissionToDelete)
+ ? translate('quality_gates.permissions.remove.user.confirmation')
+ : translate('quality_gates.permissions.remove.group.confirmation')
+ }
+ id="remove.confirmation"
+ values={{
+ user: <strong>{permissionToDelete.name}</strong>,
+ }}
+ />
+ }
+ primaryButton={
+ <DangerButtonPrimary
+ onClick={() => props.onConfirmDeletePermission(permissionToDelete)}
+ >
+ {translate('remove')}
+ </DangerButtonPrimary>
+ }
onClose={props.onCloseDeletePermission}
- onConfirm={props.onConfirmDeletePermission}
- >
- <FormattedMessage
- defaultMessage={
- isUser(permissionToDelete)
- ? translate('quality_gates.permissions.remove.user.confirmation')
- : translate('quality_gates.permissions.remove.group.confirmation')
- }
- id="remove.confirmation"
- values={{
- user: <strong>{permissionToDelete.name}</strong>,
- }}
- />
- </ConfirmModal>
+ />
)}
</div>
);
});
expect(addUserButton).toBeDisabled();
await user.click(searchUserInput);
- expect(screen.getAllByTestId('qg-add-permission-option')).toHaveLength(2);
await user.click(screen.getByText('userlogin'));
expect(addUserButton).toBeEnabled();
await user.click(addUserButton);
await user.click(cancelButton);
const permissionList = within(await screen.findByTestId('quality-gate-permissions'));
- expect(permissionList.getByRole('listitem')).toBeInTheDocument();
+ expect(permissionList.getByRole('row')).toBeInTheDocument();
// Delete the user permission
const deleteButton = screen.getByTestId('permission-delete-button');
const deletePopup = screen.getByRole('dialog');
const dialogDeleteButton = within(deletePopup).getByRole('button', { name: 'remove' });
await user.click(dialogDeleteButton);
- expect(permissionList.queryByRole('listitem')).not.toBeInTheDocument();
+ expect(permissionList.queryByRole('row')).not.toBeInTheDocument();
});
it('should assign permission to a group and delete it later', async () => {
name: 'add_verb',
});
await user.click(searchUserInput);
- expect(screen.getAllByTestId('qg-add-permission-option')).toHaveLength(2);
- await user.click(screen.getAllByTestId('qg-add-permission-option')[1]);
+ await user.click(within(popup).getByLabelText('Foo'));
await user.click(addUserButton);
expect(screen.getByText('Foo')).toBeInTheDocument();