assigneeSelector.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697
  1. import * as React 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 React.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. return {
  191. value: {type: 'member', assignee: member},
  192. searchKey: `${member.email} ${member.name}`,
  193. label: ({inputValue}) => (
  194. <MenuItemWrapper
  195. data-test-id="assignee-option"
  196. key={buildUserId(member.id)}
  197. onSelect={this.assignToUser.bind(this, member)}
  198. >
  199. <IconContainer>
  200. <UserAvatar user={member} size={size} />
  201. </IconContainer>
  202. <Label>
  203. <Highlight text={inputValue}>
  204. {sessionUser.id === member.id
  205. ? `${member.name || member.email} ${t('(You)')}`
  206. : member.name || member.email}
  207. </Highlight>
  208. {suggestedReason && <SuggestedReason>{suggestedReason}</SuggestedReason>}
  209. </Label>
  210. </MenuItemWrapper>
  211. ),
  212. };
  213. }
  214. renderNewMemberNodes(): ItemsBeforeFilter {
  215. const members = putSessionUserFirst(this.memberList());
  216. return members.map(member => this.renderMemberNode(member));
  217. }
  218. renderTeamNode(
  219. assignableTeam: AssignableTeam,
  220. suggestedReason?: string
  221. ): ItemsBeforeFilter[0] {
  222. const {size} = this.props;
  223. const {id, display, team} = assignableTeam;
  224. return {
  225. value: {type: 'team', assignee: team},
  226. searchKey: team.slug,
  227. label: ({inputValue}) => (
  228. <MenuItemWrapper
  229. data-test-id="assignee-option"
  230. key={id}
  231. onSelect={this.assignToTeam.bind(this, team)}
  232. >
  233. <IconContainer>
  234. <TeamAvatar team={team} size={size} />
  235. </IconContainer>
  236. <Label>
  237. <Highlight text={inputValue}>{display}</Highlight>
  238. {suggestedReason && <SuggestedReason>{suggestedReason}</SuggestedReason>}
  239. </Label>
  240. </MenuItemWrapper>
  241. ),
  242. };
  243. }
  244. renderNewTeamNodes(): ItemsBeforeFilter {
  245. return this.assignableTeams().map(team => this.renderTeamNode(team));
  246. }
  247. renderSuggestedAssigneeNodes(): React.ComponentProps<
  248. typeof DropdownAutoComplete
  249. >['items'] {
  250. const {assignedTo} = this.state;
  251. // filter out suggested assignees if a suggestion is already selected
  252. return this.getSuggestedAssignees()
  253. .filter(({type, id}) => !(type === assignedTo?.type && id === assignedTo?.id))
  254. .filter(({type}) => type === 'user' || type === 'team')
  255. .map(({type, suggestedReason, assignee}) => {
  256. const reason =
  257. suggestedReason === 'suspectCommit'
  258. ? t('(Suspect Commit)')
  259. : t('(Issue Owner)');
  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. ownershipRule: t('Matching Issue Owners Rule'),
  400. };
  401. const assignedToSuggestion = suggestedActors.find(
  402. actor => actor.id === assignedTo?.id
  403. );
  404. const avatarElement = assignedTo ? (
  405. <ActorAvatar
  406. actor={assignedTo}
  407. className="avatar"
  408. size={24}
  409. tooltip={
  410. <TooltipWrapper>
  411. {tct('Assigned to [name]', {
  412. name: assignedTo.type === 'team' ? `#${assignedTo.name}` : assignedTo.name,
  413. })}
  414. {assignedToSuggestion && (
  415. <TooltipSubtext>
  416. {suggestedReasons[assignedToSuggestion.suggestedReason]}
  417. </TooltipSubtext>
  418. )}
  419. </TooltipWrapper>
  420. }
  421. />
  422. ) : suggestedActors && suggestedActors.length > 0 ? (
  423. <SuggestedAvatarStack
  424. size={24}
  425. owners={suggestedActors}
  426. tooltipOptions={{isHoverable: true}}
  427. tooltip={
  428. <TooltipWrapper>
  429. <div>
  430. {tct('Suggestion: [name]', {
  431. name:
  432. suggestedActors[0].type === 'team'
  433. ? `#${suggestedActors[0].name}`
  434. : suggestedActors[0].name,
  435. })}
  436. {suggestedActors.length > 1 &&
  437. tn(' + %s other', ' + %s others', suggestedActors.length - 1)}
  438. </div>
  439. <TooltipSubtext>
  440. {suggestedReasons[suggestedActors[0].suggestedReason]}
  441. </TooltipSubtext>
  442. </TooltipWrapper>
  443. }
  444. />
  445. ) : (
  446. <Tooltip
  447. isHoverable
  448. skipWrapper
  449. title={
  450. <TooltipWrapper>
  451. <div>{t('Unassigned')}</div>
  452. <TooltipSubtext>
  453. {tct(
  454. 'You can auto-assign issues by adding [issueOwners:Issue Owner rules].',
  455. {
  456. issueOwners: (
  457. <TooltipSubExternalLink href="https://docs.sentry.io/product/error-monitoring/issue-owners/" />
  458. ),
  459. }
  460. )}
  461. </TooltipSubtext>
  462. </TooltipWrapper>
  463. }
  464. >
  465. <StyledIconUser size="20px" color="gray400" />
  466. </Tooltip>
  467. );
  468. return (
  469. <AssigneeWrapper>
  470. {loading && (
  471. <LoadingIndicator mini style={{height: '24px', margin: 0, marginRight: 11}} />
  472. )}
  473. {!loading && !noDropdown && (
  474. <DropdownAutoComplete
  475. disabled={disabled}
  476. maxHeight={400}
  477. onOpen={e => {
  478. // This can be called multiple times and does not always have `event`
  479. e?.stopPropagation();
  480. }}
  481. busy={memberList === undefined}
  482. items={memberList !== undefined ? this.renderNewDropdownItems() : null}
  483. alignMenu="right"
  484. onSelect={this.handleAssign}
  485. itemSize="small"
  486. searchPlaceholder={t('Filter teams and people')}
  487. menuFooter={
  488. assignedTo ? (
  489. <div>
  490. <MenuItemFooterWrapper
  491. data-test-id="clear-assignee"
  492. onClick={this.clearAssignTo}
  493. py={0}
  494. >
  495. <IconContainer>
  496. <IconClose color="purple300" isCircled size="14px" />
  497. </IconContainer>
  498. <Label>{t('Clear Assignee')}</Label>
  499. </MenuItemFooterWrapper>
  500. {this.renderInviteMemberLink()}
  501. </div>
  502. ) : (
  503. this.renderInviteMemberLink()
  504. )
  505. }
  506. disableLabelPadding
  507. menuWithArrow
  508. emptyHidesInput
  509. >
  510. {({getActorProps, isOpen}) => (
  511. <DropdownButton {...getActorProps({})}>
  512. {avatarElement}
  513. <StyledChevron direction={isOpen ? 'up' : 'down'} size="xs" />
  514. </DropdownButton>
  515. )}
  516. </DropdownAutoComplete>
  517. )}
  518. {!loading && noDropdown && avatarElement}
  519. </AssigneeWrapper>
  520. );
  521. }
  522. }
  523. export function putSessionUserFirst(members: User[] | undefined): User[] {
  524. // If session user is in the filtered list of members, put them at the top
  525. if (!members) {
  526. return [];
  527. }
  528. const sessionUser = ConfigStore.get('user');
  529. const sessionUserIndex = members.findIndex(member => member.id === sessionUser?.id);
  530. if (sessionUserIndex === -1) {
  531. return members;
  532. }
  533. const arrangedMembers = [members[sessionUserIndex]];
  534. arrangedMembers.push(...members.slice(0, sessionUserIndex));
  535. arrangedMembers.push(...members.slice(sessionUserIndex + 1));
  536. return arrangedMembers;
  537. }
  538. export default AssigneeSelector;
  539. const AssigneeWrapper = styled('div')`
  540. display: flex;
  541. justify-content: flex-end;
  542. /* manually align menu underneath dropdown caret */
  543. ${DropdownBubble} {
  544. right: -14px;
  545. }
  546. `;
  547. const StyledIconUser = styled(IconUser)`
  548. /* We need this to center with Avatar */
  549. margin-right: 2px;
  550. `;
  551. const IconContainer = styled('div')`
  552. display: flex;
  553. align-items: center;
  554. justify-content: center;
  555. width: 24px;
  556. height: 24px;
  557. flex-shrink: 0;
  558. `;
  559. const MenuItemWrapper = styled('div')<{
  560. disabled?: boolean;
  561. py?: number;
  562. }>`
  563. cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
  564. display: flex;
  565. align-items: center;
  566. font-size: 13px;
  567. ${p =>
  568. typeof p.py !== 'undefined' &&
  569. `
  570. padding-top: ${p.py};
  571. padding-bottom: ${p.py};
  572. `};
  573. `;
  574. const MenuItemFooterWrapper = styled(MenuItemWrapper)`
  575. padding: ${space(0.25)} ${space(1)};
  576. border-top: 1px solid ${p => p.theme.innerBorder};
  577. background-color: ${p => p.theme.tag.highlight.background};
  578. color: ${p => p.theme.active};
  579. :hover {
  580. color: ${p => p.theme.activeHover};
  581. svg {
  582. fill: ${p => p.theme.activeHover};
  583. }
  584. }
  585. `;
  586. const InviteMemberLink = styled(Link)`
  587. color: ${p => (p.disabled ? p.theme.disabled : p.theme.textColor)};
  588. `;
  589. const Label = styled(TextOverflow)`
  590. margin-left: 6px;
  591. `;
  592. const StyledChevron = styled(IconChevron)`
  593. margin-left: ${space(1)};
  594. `;
  595. const DropdownButton = styled('div')`
  596. display: flex;
  597. align-items: center;
  598. font-size: 20px;
  599. `;
  600. const GroupHeader = styled('div')`
  601. font-size: ${p => p.theme.fontSizeSmall};
  602. font-weight: 600;
  603. margin: ${space(1)} 0;
  604. color: ${p => p.theme.subText};
  605. line-height: ${p => p.theme.fontSizeSmall};
  606. text-align: left;
  607. `;
  608. const SuggestedReason = styled('span')`
  609. margin-left: ${space(0.5)};
  610. color: ${p => p.theme.textColor};
  611. `;
  612. const TooltipWrapper = styled('div')`
  613. text-align: left;
  614. `;
  615. const TooltipSubtext = styled('div')`
  616. color: ${p => p.theme.subText};
  617. `;
  618. const TooltipSubExternalLink = styled(ExternalLink)`
  619. color: ${p => p.theme.subText};
  620. text-decoration: underline;
  621. :hover {
  622. color: ${p => p.theme.subText};
  623. }
  624. `;