assigneeSelector.tsx 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701
  1. import {Component} from 'react';
  2. import styled from '@emotion/styled';
  3. import {assignToActor, assignToUser, clearAssignment} from 'sentry/actionCreators/group';
  4. import {openInviteMembersModal} from 'sentry/actionCreators/modal';
  5. import ActorAvatar from 'sentry/components/avatar/actorAvatar';
  6. import SuggestedAvatarStack from 'sentry/components/avatar/suggestedAvatarStack';
  7. import TeamAvatar from 'sentry/components/avatar/teamAvatar';
  8. import UserAvatar from 'sentry/components/avatar/userAvatar';
  9. import DropdownAutoComplete from 'sentry/components/dropdownAutoComplete';
  10. import {ItemsBeforeFilter} from 'sentry/components/dropdownAutoComplete/types';
  11. import DropdownBubble from 'sentry/components/dropdownBubble';
  12. import Highlight from 'sentry/components/highlight';
  13. import ExternalLink from 'sentry/components/links/externalLink';
  14. import Link from 'sentry/components/links/link';
  15. import LoadingIndicator from 'sentry/components/loadingIndicator';
  16. import TextOverflow from 'sentry/components/textOverflow';
  17. import Tooltip from 'sentry/components/tooltip';
  18. import {IconAdd, IconChevron, IconClose, IconUser} from 'sentry/icons';
  19. import {t, tct, tn} from 'sentry/locale';
  20. import ConfigStore from 'sentry/stores/configStore';
  21. import GroupStore from 'sentry/stores/groupStore';
  22. import MemberListStore from 'sentry/stores/memberListStore';
  23. import ProjectsStore from 'sentry/stores/projectsStore';
  24. import space from 'sentry/styles/space';
  25. import {Actor, SuggestedOwner, SuggestedOwnerReason, Team, User} from 'sentry/types';
  26. import {buildTeamId, buildUserId, valueIsEqual} from 'sentry/utils';
  27. type SuggestedAssignee = Actor & {
  28. assignee: AssignableTeam | User;
  29. suggestedReason: SuggestedOwnerReason;
  30. };
  31. type AssignableTeam = {
  32. display: string;
  33. email: string;
  34. id: string;
  35. team: Team;
  36. };
  37. type Props = {
  38. id: string;
  39. disabled?: boolean;
  40. memberList?: User[];
  41. noDropdown?: boolean;
  42. onAssign?: (
  43. type: Actor['type'],
  44. assignee: User | Actor,
  45. suggestedAssignee?: SuggestedAssignee
  46. ) => void;
  47. size?: number;
  48. };
  49. type State = {
  50. loading: boolean;
  51. assignedTo?: Actor;
  52. memberList?: User[];
  53. suggestedOwners?: SuggestedOwner[] | null;
  54. };
  55. class AssigneeSelector extends Component<Props, State> {
  56. static defaultProps = {
  57. size: 20,
  58. };
  59. state = this.getInitialState();
  60. getInitialState() {
  61. const group = GroupStore.get(this.props.id);
  62. const memberList = MemberListStore.loaded ? MemberListStore.getAll() : undefined;
  63. const loading = GroupStore.hasStatus(this.props.id, 'assignTo');
  64. const suggestedOwners = group?.owners;
  65. return {
  66. assignedTo: group?.assignedTo,
  67. memberList,
  68. loading,
  69. suggestedOwners,
  70. };
  71. }
  72. componentWillReceiveProps(nextProps: Props) {
  73. const loading = GroupStore.hasStatus(nextProps.id, 'assignTo');
  74. if (nextProps.id !== this.props.id || loading !== this.state.loading) {
  75. const group = GroupStore.get(this.props.id);
  76. this.setState({
  77. loading,
  78. assignedTo: group?.assignedTo,
  79. suggestedOwners: group?.owners,
  80. });
  81. }
  82. }
  83. shouldComponentUpdate(nextProps: Props, nextState: State) {
  84. if (nextState.loading !== this.state.loading) {
  85. return true;
  86. }
  87. // If the memberList in props has changed, re-render as
  88. // props have updated, and we won't use internal state anyways.
  89. if (
  90. nextProps.memberList &&
  91. !valueIsEqual(this.props.memberList, nextProps.memberList)
  92. ) {
  93. return true;
  94. }
  95. const currentMembers = this.memberList();
  96. // XXX(billyvg): this means that once `memberList` is not-null, this component will never update due to `memberList` changes
  97. // Note: this allows us to show a "loading" state for memberList, but only before `MemberListStore.loadInitialData`
  98. // is called
  99. if (currentMembers === undefined && nextState.memberList !== currentMembers) {
  100. return true;
  101. }
  102. return !valueIsEqual(nextState.assignedTo, this.state.assignedTo, true);
  103. }
  104. componentWillUnmount() {
  105. this.unlisteners.forEach(unlistener => unlistener?.());
  106. }
  107. unlisteners = [
  108. GroupStore.listen(itemIds => this.onGroupChange(itemIds), undefined),
  109. MemberListStore.listen((users: User[]) => {
  110. this.handleMemberListUpdate(users);
  111. }, undefined),
  112. ];
  113. handleMemberListUpdate = (members: User[]) => {
  114. if (members === this.state.memberList) {
  115. return;
  116. }
  117. this.setState({memberList: members});
  118. };
  119. memberList(): User[] | undefined {
  120. return this.props.memberList ? this.props.memberList : this.state.memberList;
  121. }
  122. onGroupChange(itemIds: Set<string>) {
  123. if (!itemIds.has(this.props.id)) {
  124. return;
  125. }
  126. const group = GroupStore.get(this.props.id);
  127. this.setState({
  128. assignedTo: group?.assignedTo,
  129. suggestedOwners: group?.owners,
  130. loading: GroupStore.hasStatus(this.props.id, 'assignTo'),
  131. });
  132. }
  133. assignableTeams(): AssignableTeam[] {
  134. const group = GroupStore.get(this.props.id);
  135. if (!group) {
  136. return [];
  137. }
  138. const teams = ProjectsStore.getBySlug(group.project.slug)?.teams ?? [];
  139. return teams
  140. .sort((a, b) => a.slug.localeCompare(b.slug))
  141. .map(team => ({
  142. id: buildTeamId(team.id),
  143. display: `#${team.slug}`,
  144. email: team.id,
  145. team,
  146. }));
  147. }
  148. assignToUser(user: User | Actor) {
  149. assignToUser({id: this.props.id, user, assignedBy: 'assignee_selector'});
  150. this.setState({loading: true});
  151. }
  152. assignToTeam(team: Team) {
  153. assignToActor({
  154. actor: {id: team.id, type: 'team'},
  155. id: this.props.id,
  156. assignedBy: 'assignee_selector',
  157. });
  158. this.setState({loading: true});
  159. }
  160. handleAssign: React.ComponentProps<typeof DropdownAutoComplete>['onSelect'] = (
  161. {value: {type, assignee}},
  162. _state,
  163. e
  164. ) => {
  165. if (type === 'member') {
  166. this.assignToUser(assignee);
  167. }
  168. if (type === 'team') {
  169. this.assignToTeam(assignee);
  170. }
  171. e?.stopPropagation();
  172. const {onAssign} = this.props;
  173. if (onAssign) {
  174. const suggestionType = type === 'member' ? 'user' : type;
  175. const suggestion = this.getSuggestedAssignees().find(
  176. actor => actor.type === suggestionType && actor.id === assignee.id
  177. );
  178. onAssign?.(type, assignee, suggestion);
  179. }
  180. };
  181. clearAssignTo = (e: React.MouseEvent<HTMLDivElement>) => {
  182. // clears assignment
  183. clearAssignment(this.props.id, 'assignee_selector');
  184. this.setState({loading: true});
  185. e.stopPropagation();
  186. };
  187. renderMemberNode(member: User, suggestedReason?: string): ItemsBeforeFilter[0] {
  188. const {size} = this.props;
  189. const sessionUser = ConfigStore.get('user');
  190. const handleSelect = () => this.assignToUser(member);
  191. return {
  192. value: {type: 'member', assignee: member},
  193. searchKey: `${member.email} ${member.name}`,
  194. label: ({inputValue}) => (
  195. <MenuItemWrapper
  196. data-test-id="assignee-option"
  197. key={buildUserId(member.id)}
  198. onSelect={handleSelect}
  199. >
  200. <IconContainer>
  201. <UserAvatar user={member} size={size} />
  202. </IconContainer>
  203. <Label>
  204. <Highlight text={inputValue}>
  205. {sessionUser.id === member.id
  206. ? `${member.name || member.email} ${t('(You)')}`
  207. : member.name || member.email}
  208. </Highlight>
  209. {suggestedReason && <SuggestedReason>({suggestedReason})</SuggestedReason>}
  210. </Label>
  211. </MenuItemWrapper>
  212. ),
  213. };
  214. }
  215. renderNewMemberNodes(): ItemsBeforeFilter {
  216. const members = putSessionUserFirst(this.memberList());
  217. return members.map(member => this.renderMemberNode(member));
  218. }
  219. renderTeamNode(
  220. assignableTeam: AssignableTeam,
  221. suggestedReason?: string
  222. ): ItemsBeforeFilter[0] {
  223. const {size} = this.props;
  224. const {id, display, team} = assignableTeam;
  225. const handleSelect = () => this.assignToTeam(team);
  226. return {
  227. value: {type: 'team', assignee: team},
  228. searchKey: team.slug,
  229. label: ({inputValue}) => (
  230. <MenuItemWrapper data-test-id="assignee-option" key={id} onSelect={handleSelect}>
  231. <IconContainer>
  232. <TeamAvatar team={team} size={size} />
  233. </IconContainer>
  234. <Label>
  235. <Highlight text={inputValue}>{display}</Highlight>
  236. {suggestedReason && <SuggestedReason>({suggestedReason})</SuggestedReason>}
  237. </Label>
  238. </MenuItemWrapper>
  239. ),
  240. };
  241. }
  242. renderNewTeamNodes(): ItemsBeforeFilter {
  243. return this.assignableTeams().map(team => this.renderTeamNode(team));
  244. }
  245. renderSuggestedAssigneeNodes(): React.ComponentProps<
  246. typeof DropdownAutoComplete
  247. >['items'] {
  248. const {assignedTo} = this.state;
  249. const textReason: Record<SuggestedOwnerReason, string> = {
  250. suspectCommit: t('Suspect Commit'),
  251. releaseCommit: t('Suspect Release'),
  252. ownershipRule: t('Ownership Rule'),
  253. };
  254. // filter out suggested assignees if a suggestion is already selected
  255. return this.getSuggestedAssignees()
  256. .filter(({type, id}) => !(type === assignedTo?.type && id === assignedTo?.id))
  257. .filter(({type}) => type === 'user' || type === 'team')
  258. .map(({type, suggestedReason, assignee}) => {
  259. const reason = textReason[suggestedReason];
  260. if (type === 'user') {
  261. return this.renderMemberNode(assignee as User, reason);
  262. }
  263. return this.renderTeamNode(assignee as AssignableTeam, reason);
  264. });
  265. }
  266. renderDropdownGroupLabel(label: string) {
  267. return <GroupHeader>{label}</GroupHeader>;
  268. }
  269. renderNewDropdownItems(): ItemsBeforeFilter {
  270. const teams = this.renderNewTeamNodes();
  271. const members = this.renderNewMemberNodes();
  272. const sessionUser = ConfigStore.get('user');
  273. const suggestedAssignees = this.renderSuggestedAssigneeNodes() ?? [];
  274. const filteredSessionUser: ItemsBeforeFilter = members.filter(
  275. member => member.value.assignee.id === sessionUser.id
  276. );
  277. // filter out session user from Suggested
  278. const filteredSuggestedAssignees: ItemsBeforeFilter = suggestedAssignees.filter(
  279. assignee => {
  280. return assignee.value.type === 'member'
  281. ? assignee.value.assignee.id !== sessionUser.id
  282. : assignee;
  283. }
  284. );
  285. const assigneeIds = new Set(
  286. filteredSuggestedAssignees.map(
  287. assignee => `${assignee.value.type}:${assignee.value.assignee.id}`
  288. )
  289. );
  290. // filter out duplicates of Team/Member if also a Suggested Assignee
  291. const filteredTeams: ItemsBeforeFilter = teams.filter(team => {
  292. return !assigneeIds.has(`${team.value.type}:${team.value.assignee.id}`);
  293. });
  294. const filteredMembers: ItemsBeforeFilter = members.filter(member => {
  295. return (
  296. !assigneeIds.has(`${member.value.type}:${member.value.assignee.id}`) &&
  297. member.value.assignee.id !== sessionUser.id
  298. );
  299. });
  300. const dropdownItems: ItemsBeforeFilter = [
  301. {
  302. label: this.renderDropdownGroupLabel(t('Teams')),
  303. id: 'team-header',
  304. items: filteredTeams,
  305. },
  306. {
  307. label: this.renderDropdownGroupLabel(t('People')),
  308. id: 'members-header',
  309. items: filteredMembers,
  310. },
  311. ];
  312. // session user is first on dropdown
  313. if (suggestedAssignees.length || filteredSessionUser.length) {
  314. dropdownItems.unshift(
  315. {
  316. label: this.renderDropdownGroupLabel(t('Suggested')),
  317. id: 'suggested-header',
  318. items: filteredSessionUser,
  319. },
  320. {
  321. hideGroupLabel: true,
  322. id: 'suggested-list',
  323. items: filteredSuggestedAssignees,
  324. }
  325. );
  326. }
  327. return dropdownItems;
  328. }
  329. renderInviteMemberLink() {
  330. const {loading} = this.state;
  331. return (
  332. <InviteMemberLink
  333. to=""
  334. data-test-id="invite-member"
  335. disabled={loading}
  336. onClick={() => openInviteMembersModal({source: 'assignee_selector'})}
  337. >
  338. <MenuItemFooterWrapper>
  339. <IconContainer>
  340. <IconAdd color="purple300" isCircled size="14px" />
  341. </IconContainer>
  342. <Label>{t('Invite Member')}</Label>
  343. </MenuItemFooterWrapper>
  344. </InviteMemberLink>
  345. );
  346. }
  347. getSuggestedAssignees(): SuggestedAssignee[] {
  348. const {suggestedOwners} = this.state;
  349. if (!suggestedOwners) {
  350. return [];
  351. }
  352. const assignableTeams = this.assignableTeams();
  353. const memberList = this.memberList() ?? [];
  354. const suggestedAssignees: Array<SuggestedAssignee | null> = suggestedOwners.map(
  355. owner => {
  356. // converts a backend suggested owner to a suggested assignee
  357. const [ownerType, id] = owner.owner.split(':');
  358. if (ownerType === 'user') {
  359. const member = memberList.find(user => user.id === id);
  360. if (member) {
  361. return {
  362. type: 'user',
  363. id,
  364. name: member.name,
  365. suggestedReason: owner.type,
  366. assignee: member,
  367. };
  368. }
  369. } else if (ownerType === 'team') {
  370. const matchingTeam = assignableTeams.find(
  371. assignableTeam => assignableTeam.id === owner.owner
  372. );
  373. if (matchingTeam) {
  374. return {
  375. type: 'team',
  376. id,
  377. name: matchingTeam.team.name,
  378. suggestedReason: owner.type,
  379. assignee: matchingTeam,
  380. };
  381. }
  382. }
  383. return null;
  384. }
  385. );
  386. return suggestedAssignees.filter(owner => !!owner) as SuggestedAssignee[];
  387. }
  388. render() {
  389. const {disabled, noDropdown} = this.props;
  390. const {loading, assignedTo} = this.state;
  391. const memberList = this.memberList();
  392. const suggestedActors = this.getSuggestedAssignees();
  393. const suggestedReasons: Record<SuggestedOwnerReason, React.ReactNode> = {
  394. suspectCommit: tct('Based on [commit:commit data]', {
  395. commit: (
  396. <TooltipSubExternalLink href="https://docs.sentry.io/product/sentry-basics/integrate-frontend/configure-scms/" />
  397. ),
  398. }),
  399. releaseCommit: '',
  400. ownershipRule: t('Matching Issue Owners Rule'),
  401. };
  402. const assignedToSuggestion = suggestedActors.find(
  403. actor => actor.id === assignedTo?.id
  404. );
  405. const avatarElement = assignedTo ? (
  406. <ActorAvatar
  407. actor={assignedTo}
  408. className="avatar"
  409. size={24}
  410. tooltip={
  411. <TooltipWrapper>
  412. {tct('Assigned to [name]', {
  413. name: assignedTo.type === 'team' ? `#${assignedTo.name}` : assignedTo.name,
  414. })}
  415. {assignedToSuggestion &&
  416. suggestedReasons[assignedToSuggestion.suggestedReason] && (
  417. <TooltipSubtext>
  418. {suggestedReasons[assignedToSuggestion.suggestedReason]}
  419. </TooltipSubtext>
  420. )}
  421. </TooltipWrapper>
  422. }
  423. />
  424. ) : suggestedActors && suggestedActors.length > 0 ? (
  425. <SuggestedAvatarStack
  426. size={24}
  427. owners={suggestedActors}
  428. tooltipOptions={{isHoverable: true}}
  429. tooltip={
  430. <TooltipWrapper>
  431. <div>
  432. {tct('Suggestion: [name]', {
  433. name:
  434. suggestedActors[0].type === 'team'
  435. ? `#${suggestedActors[0].name}`
  436. : suggestedActors[0].name,
  437. })}
  438. {suggestedActors.length > 1 &&
  439. tn(' + %s other', ' + %s others', suggestedActors.length - 1)}
  440. </div>
  441. <TooltipSubtext>
  442. {suggestedReasons[suggestedActors[0].suggestedReason]}
  443. </TooltipSubtext>
  444. </TooltipWrapper>
  445. }
  446. />
  447. ) : (
  448. <Tooltip
  449. isHoverable
  450. skipWrapper
  451. title={
  452. <TooltipWrapper>
  453. <div>{t('Unassigned')}</div>
  454. <TooltipSubtext>
  455. {tct(
  456. 'You can auto-assign issues by adding [issueOwners:Issue Owner rules].',
  457. {
  458. issueOwners: (
  459. <TooltipSubExternalLink href="https://docs.sentry.io/product/error-monitoring/issue-owners/" />
  460. ),
  461. }
  462. )}
  463. </TooltipSubtext>
  464. </TooltipWrapper>
  465. }
  466. >
  467. <StyledIconUser data-test-id="unassigned" size="20px" color="gray400" />
  468. </Tooltip>
  469. );
  470. return (
  471. <AssigneeWrapper>
  472. {loading && (
  473. <LoadingIndicator mini style={{height: '24px', margin: 0, marginRight: 11}} />
  474. )}
  475. {!loading && !noDropdown && (
  476. <DropdownAutoComplete
  477. disabled={disabled}
  478. maxHeight={400}
  479. onOpen={e => {
  480. // This can be called multiple times and does not always have `event`
  481. e?.stopPropagation();
  482. }}
  483. busy={memberList === undefined}
  484. items={memberList !== undefined ? this.renderNewDropdownItems() : null}
  485. alignMenu="right"
  486. onSelect={this.handleAssign}
  487. itemSize="small"
  488. searchPlaceholder={t('Filter teams and people')}
  489. menuFooter={
  490. assignedTo ? (
  491. <div>
  492. <MenuItemFooterWrapper
  493. role="button"
  494. onClick={this.clearAssignTo}
  495. py={0}
  496. >
  497. <IconContainer>
  498. <IconClose color="purple300" isCircled size="14px" />
  499. </IconContainer>
  500. <Label>{t('Clear Assignee')}</Label>
  501. </MenuItemFooterWrapper>
  502. {this.renderInviteMemberLink()}
  503. </div>
  504. ) : (
  505. this.renderInviteMemberLink()
  506. )
  507. }
  508. disableLabelPadding
  509. emptyHidesInput
  510. >
  511. {({getActorProps, isOpen}) => (
  512. <DropdownButton data-test-id="assignee-selector" {...getActorProps({})}>
  513. {avatarElement}
  514. <StyledChevron direction={isOpen ? 'up' : 'down'} size="xs" />
  515. </DropdownButton>
  516. )}
  517. </DropdownAutoComplete>
  518. )}
  519. {!loading && noDropdown && avatarElement}
  520. </AssigneeWrapper>
  521. );
  522. }
  523. }
  524. export function putSessionUserFirst(members: User[] | undefined): User[] {
  525. // If session user is in the filtered list of members, put them at the top
  526. if (!members) {
  527. return [];
  528. }
  529. const sessionUser = ConfigStore.get('user');
  530. const sessionUserIndex = members.findIndex(member => member.id === sessionUser?.id);
  531. if (sessionUserIndex === -1) {
  532. return members;
  533. }
  534. const arrangedMembers = [members[sessionUserIndex]];
  535. arrangedMembers.push(...members.slice(0, sessionUserIndex));
  536. arrangedMembers.push(...members.slice(sessionUserIndex + 1));
  537. return arrangedMembers;
  538. }
  539. export default AssigneeSelector;
  540. const AssigneeWrapper = styled('div')`
  541. display: flex;
  542. justify-content: flex-end;
  543. /* manually align menu underneath dropdown caret */
  544. ${DropdownBubble} {
  545. right: -14px;
  546. }
  547. `;
  548. const StyledIconUser = styled(IconUser)`
  549. /* We need this to center with Avatar */
  550. margin-right: 2px;
  551. `;
  552. const IconContainer = styled('div')`
  553. display: flex;
  554. align-items: center;
  555. justify-content: center;
  556. width: 24px;
  557. height: 24px;
  558. flex-shrink: 0;
  559. `;
  560. const MenuItemWrapper = styled('div')<{
  561. disabled?: boolean;
  562. py?: number;
  563. }>`
  564. cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
  565. display: flex;
  566. align-items: center;
  567. font-size: 13px;
  568. ${p =>
  569. typeof p.py !== 'undefined' &&
  570. `
  571. padding-top: ${p.py};
  572. padding-bottom: ${p.py};
  573. `};
  574. `;
  575. const MenuItemFooterWrapper = styled(MenuItemWrapper)`
  576. padding: ${space(0.25)} ${space(1)};
  577. border-top: 1px solid ${p => p.theme.innerBorder};
  578. background-color: ${p => p.theme.tag.highlight.background};
  579. color: ${p => p.theme.active};
  580. :hover {
  581. color: ${p => p.theme.activeHover};
  582. svg {
  583. fill: ${p => p.theme.activeHover};
  584. }
  585. }
  586. `;
  587. const InviteMemberLink = styled(Link)`
  588. color: ${p => (p.disabled ? p.theme.disabled : p.theme.textColor)};
  589. `;
  590. const Label = styled(TextOverflow)`
  591. margin-left: 6px;
  592. `;
  593. const StyledChevron = styled(IconChevron)`
  594. margin-left: ${space(1)};
  595. `;
  596. const DropdownButton = styled('div')`
  597. display: flex;
  598. align-items: center;
  599. font-size: 20px;
  600. `;
  601. const GroupHeader = styled('div')`
  602. font-size: ${p => p.theme.fontSizeSmall};
  603. font-weight: 600;
  604. margin: ${space(1)} 0;
  605. color: ${p => p.theme.subText};
  606. line-height: ${p => p.theme.fontSizeSmall};
  607. text-align: left;
  608. `;
  609. const SuggestedReason = styled('span')`
  610. margin-left: ${space(0.5)};
  611. color: ${p => p.theme.textColor};
  612. `;
  613. const TooltipWrapper = styled('div')`
  614. text-align: left;
  615. `;
  616. const TooltipSubtext = styled('div')`
  617. color: ${p => p.theme.subText};
  618. `;
  619. const TooltipSubExternalLink = styled(ExternalLink)`
  620. color: ${p => p.theme.subText};
  621. text-decoration: underline;
  622. :hover {
  623. color: ${p => p.theme.subText};
  624. }
  625. `;