Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 40 additions & 2 deletions static/app/components/selectMembers/index.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ describe('SelectMembers', () => {
MockApiClient.clearMockResponses();
});

it('loads project members as default options', async () => {
it('selects a project member and displays their name', async () => {
const onChange = jest.fn();
const projectMembersRequest = MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/users/`,
Expand All @@ -32,7 +32,7 @@ describe('SelectMembers', () => {
body: [{user: projectUser, role: 'member', projects: ['project-slug']}],
});

render(
const {rerender} = render(
<SelectMembers
aria-label="Member"
onChange={onChange}
Expand Down Expand Up @@ -60,6 +60,44 @@ describe('SelectMembers', () => {
value: projectUser.id,
})
);

rerender(
<SelectMembers
aria-label="Member"
onChange={onChange}
organization={organization}
projectIds={['123']}
value={projectUser.id}
/>
);
expect(screen.getByText('Project Member')).toBeInTheDocument();
expect(screen.queryByText(projectUser.email)).not.toBeInTheDocument();

await selectEvent.openMenu(screen.getByRole('textbox', {name: 'Member'}));
expect(screen.getByText(projectUser.email)).toBeInTheDocument();
await userEvent.keyboard('{Escape}');
expect(screen.getByText('Project Member')).toBeInTheDocument();
expect(screen.queryByText(projectUser.email)).not.toBeInTheDocument();
});

it('displays a saved member', async () => {
MockApiClient.addMockResponse({
url: `/organizations/${organization.slug}/users/`,
body: [{user: projectUser, role: 'member', projects: ['project-slug']}],
});

render(
<SelectMembers
aria-label="Member"
onChange={jest.fn()}
organization={organization}
projectIds={['123']}
value={projectUser.id}
/>,
{organization}
);

expect(await screen.findByText('Project Member')).toBeInTheDocument();
});

it('searches organization members and disables users outside the project', async () => {
Expand Down
195 changes: 99 additions & 96 deletions static/app/components/selectMembers/index.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,19 @@
import {useCallback, useMemo, useState} from 'react';
import {useMemo, useState} from 'react';
import styled from '@emotion/styled';
import {useDebouncedValue} from '@tanstack/react-pacer';
import {useQuery} from '@tanstack/react-query';

import {
Select,
type GeneralSelectValue,
CheckWrap,
components,
type SingleValueProps,
type StylesConfig,
type SelectValue,
} from '@sentry/scraps/select';
import {Tooltip} from '@sentry/scraps/tooltip';

import {IdBadge} from 'sentry/components/idBadge';
import {UserBadge} from 'sentry/components/idBadge/userBadge';
import {t} from 'sentry/locale';
import type {Organization} from 'sentry/types/organization';
import type {User} from 'sentry/types/user';
Expand All @@ -24,8 +26,6 @@ import {
const getSearchKeyForUser = (user: User) =>
`${user.email?.toLowerCase()} ${user.name?.toLowerCase()}`;

const EMPTY_USERS: User[] = [];

type SelectMemberValue = null | number | string | undefined;

interface MentionableUser extends SelectValue<string> {
Expand All @@ -37,6 +37,7 @@ interface MentionableUser extends SelectValue<string> {
};
label: React.ReactElement;
searchKey: string;
user: User;
}

interface Props {
Expand All @@ -52,14 +53,59 @@ interface FilterOption {
data: MentionableUser;
}

function isMentionableUser(option: GeneralSelectValue): option is MentionableUser {
const actor = (option as Partial<MentionableUser>).actor;
function filterMemberOption(option: FilterOption, filterText: string) {
return option.data.searchKey.includes(filterText.toLowerCase());
}

function SelectedMember(props: SingleValueProps<MentionableUser>) {
return (
<components.SingleValue {...props}>
<UserBadge avatarSize={20} user={props.data.user} hideEmail />
</components.SingleValue>
);
}

return typeof option.value === 'string' && actor?.type === 'user';
const memberSelectComponents = {SingleValue: SelectedMember};

function createMentionableUser(user: User): MentionableUser {
return {
value: user.id,
label: (
<UserBadge
avatarSize={20}
user={user}
minHeight="32px"
hideEmail
description={user.name && user.name !== user.email ? user.email : undefined}
/>
),
searchKey: getSearchKeyForUser(user),
user,
actor: {
type: 'user',
email: user.email,
id: user.id,
name: user.name,
},
};
}

function filterMemberOption(option: FilterOption, filterText: string) {
return option?.data?.searchKey?.includes(filterText.toLowerCase());
function createUnmentionableUser(user: User): MentionableUser {
const option = createMentionableUser(user);
return {
...option,
disabled: true,
label: (
<DisabledLabel>
<Tooltip
position="left"
title={t('%s is not a member of project', user.name || user.email)}
>
{option.label}
</Tooltip>
</DisabledLabel>
),
};
}

/**
Expand Down Expand Up @@ -87,112 +133,79 @@ function SelectMembers({
enabled: debouncedSearch !== '',
placeholderData: previousData => (debouncedSearch ? previousData : undefined),
});
const searchedUsers = debouncedSearch
? (searchMembersQuery.data ?? EMPTY_USERS)
: EMPTY_USERS;
const searchLoading = debouncedSearch !== '' && searchMembersQuery.isFetching;

const renderUserBadge = useCallback(
(user: User) => <IdBadge avatarSize={24} user={user} hideEmail disableLink />,
[]
);

const createMentionableUser = useCallback(
(user: User): MentionableUser => ({
value: user.id,
label: renderUserBadge(user),
searchKey: getSearchKeyForUser(user),
actor: {
type: 'user',
email: user.email,
id: user.id,
name: user.name,
},
}),
[renderUserBadge]
);

const createUnmentionableUser = useCallback(
(user: User): MentionableUser => ({
...createMentionableUser(user),
disabled: true,
label: (
<DisabledLabel>
<Tooltip
position="left"
title={t('%s is not a member of project', user.name || user.email)}
>
{renderUserBadge(user)}
</Tooltip>
</DisabledLabel>
),
}),
[createMentionableUser, renderUserBadge]
);

const usersInProjectById = useMemo(() => new Set(users.map(({id}) => id)), [users]);
const mentionableUsers = useMemo(
() => users.map(createMentionableUser),
[createMentionableUser, users]
);
const unmentionableUsers = useMemo(
() =>
searchedUsers
const currentOptions = useMemo(() => {
const searchedUsers = debouncedSearch ? (searchMembersQuery.data ?? []) : [];
const usersInProjectById = new Set(users.map(({id}) => id));
return [
...users.map(createMentionableUser),
...searchedUsers
.filter(user => !usersInProjectById.has(user.id))
.map(createUnmentionableUser),
[createUnmentionableUser, searchedUsers, usersInProjectById]
);
];
}, [users, debouncedSearch, searchMembersQuery.data]);

const currentOptions = useMemo(
() => [...mentionableUsers, ...unmentionableUsers],
[mentionableUsers, unmentionableUsers]
);
const selectedValue = value === null || value === undefined ? undefined : String(value);
const hasSelectedMember = currentOptions.some(option => option.value === selectedValue);

const handleInputChange = (nextInputValue: string) => {
setSearch(nextInputValue);
};
const handleChange = (option: GeneralSelectValue | GeneralSelectValue[] | null) => {
if (!option || Array.isArray(option)) {
return;
}

if (isMentionableUser(option)) {
onChange(option);
}
};
const selectStyles: StylesConfig = useMemo(
() => ({
...styles,
menu: (provided, state) => ({
...provided,
...styles?.menu?.(provided, state),
width: 320,
}),
menuList: (provided, state) => ({
...provided,
...styles?.menuList?.(provided, state),
'.option > div': {
paddingBlock: 4,
},
[String(CheckWrap)]: {
height: 32,
},
}),
input: (provided, state) => ({
...provided,
...styles?.input?.(provided, state),
// Align the caret after the selected member's 20px avatar and 6px gap.
paddingLeft: hasSelectedMember && !search ? 26 : 0,
}),
option: (provided, state) => ({
...provided,
svg: {
color: state.isSelected ? '#fff' : undefined,
},
}),
}),
[styles]
[styles, hasSelectedMember, search]
);

// Keep the select disabled until project-scoped members have loaded so the
// default option set is complete before users can search.
if (memberListLoading) {
return (
<StyledSelectControl aria-label={ariaLabel} isDisabled placeholder={t('Loading')} />
<Select
aria-label={ariaLabel}
isDisabled
placeholder={t('Loading')}
styles={selectStyles}
/>
);
}

const selectedValue = value === null || value === undefined ? undefined : String(value);
const selectedOption = currentOptions.find(option => option.value === selectedValue);

return (
<StyledSelectControl
<Select<MentionableUser>
aria-label={ariaLabel}
options={currentOptions}
components={memberSelectComponents}
filterOption={filterMemberOption}
isLoading={searchLoading}
onInputChange={handleInputChange}
onChange={handleChange}
value={selectedOption}
onInputChange={setSearch}
onChange={option => onChange(option)}
value={selectedValue}
styles={selectStyles}
/>
);
Expand All @@ -204,15 +217,5 @@ const DisabledLabel = styled('div')`
overflow: hidden; /* Needed so that "Add to team" button can fit */
`;

const StyledSelectControl = styled(Select)`
.Select-value {
display: flex;
align-items: center;
}
.Select-input {
margin-left: 32px;
}
`;

// eslint-disable-next-line @sentry/no-default-exports
export default SelectMembers;
Loading