assigneeSelector.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697
  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. // filter out suggested assignees if a suggestion is already selected
  250. return this.getSuggestedAssignees()
  251. .filter(({type, id}) => !(type === assignedTo?.type && id === assignedTo?.id))
  252. .filter(({type}) => type === 'user' || type === 'team')
  253. .map(({type, suggestedReason, assignee}) => {
  254. const reason =
  255. suggestedReason === 'suspectCommit'
  256. ? t('(Suspect Commit)')
  257. : t('(Issue Owner)');
  258. if (type === 'user') {
  259. return this.renderMemberNode(assignee as User, reason);
  260. }
  261. return this.renderTeamNode(assignee as AssignableTeam, reason);
  262. });
  263. }
  264. renderDropdownGroupLabel(label: string) {
  265. return <GroupHeader>{label}</GroupHeader>;
  266. }
  267. renderNewDropdownItems(): ItemsBeforeFilter {
  268. const teams = this.renderNewTeamNodes();
  269. const members = this.renderNewMemberNodes();
  270. const sessionUser = ConfigStore.get('user');
  271. const suggestedAssignees = this.renderSuggestedAssigneeNodes() ?? [];
  272. const filteredSessionUser: ItemsBeforeFilter = members.filter(
  273. member => member.value.assignee.id === sessionUser.id
  274. );
  275. // filter out session user from Suggested
  276. const filteredSuggestedAssignees: ItemsBeforeFilter = suggestedAssignees.filter(
  277. assignee => {
  278. return assignee.value.type === 'member'
  279. ? assignee.value.assignee.id !== sessionUser.id
  280. : assignee;
  281. }
  282. );
  283. const assigneeIds = new Set(
  284. filteredSuggestedAssignees.map(
  285. assignee => `${assignee.value.type}:${assignee.value.assignee.id}`
  286. )
  287. );
  288. // filter out duplicates of Team/Member if also a Suggested Assignee
  289. const filteredTeams: ItemsBeforeFilter = teams.filter(team => {
  290. return !assigneeIds.has(`${team.value.type}:${team.value.assignee.id}`);
  291. });
  292. const filteredMembers: ItemsBeforeFilter = members.filter(member => {
  293. return (
  294. !assigneeIds.has(`${member.value.type}:${member.value.assignee.id}`) &&
  295. member.value.assignee.id !== sessionUser.id
  296. );
  297. });
  298. const dropdownItems: ItemsBeforeFilter = [
  299. {
  300. label: this.renderDropdownGroupLabel(t('Teams')),
  301. id: 'team-header',
  302. items: filteredTeams,
  303. },
  304. {
  305. label: this.renderDropdownGroupLabel(t('People')),
  306. id: 'members-header',
  307. items: filteredMembers,
  308. },
  309. ];
  310. // session user is first on dropdown
  311. if (suggestedAssignees.length || filteredSessionUser.length) {
  312. dropdownItems.unshift(
  313. {
  314. label: this.renderDropdownGroupLabel(t('Suggested')),
  315. id: 'suggested-header',
  316. items: filteredSessionUser,
  317. },
  318. {
  319. hideGroupLabel: true,
  320. id: 'suggested-list',
  321. items: filteredSuggestedAssignees,
  322. }
  323. );
  324. }
  325. return dropdownItems;
  326. }
  327. renderInviteMemberLink() {
  328. const {loading} = this.state;
  329. return (
  330. <InviteMemberLink
  331. to=""
  332. data-test-id="invite-member"
  333. disabled={loading}
  334. onClick={() => openInviteMembersModal({source: 'assignee_selector'})}
  335. >
  336. <MenuItemFooterWrapper>
  337. <IconContainer>
  338. <IconAdd color="purple300" isCircled size="14px" />
  339. </IconContainer>
  340. <Label>{t('Invite Member')}</Label>
  341. </MenuItemFooterWrapper>
  342. </InviteMemberLink>
  343. );
  344. }
  345. getSuggestedAssignees(): SuggestedAssignee[] {
  346. const {suggestedOwners} = this.state;
  347. if (!suggestedOwners) {
  348. return [];
  349. }
  350. const assignableTeams = this.assignableTeams();
  351. const memberList = this.memberList() ?? [];
  352. const suggestedAssignees: Array<SuggestedAssignee | null> = suggestedOwners.map(
  353. owner => {
  354. // converts a backend suggested owner to a suggested assignee
  355. const [ownerType, id] = owner.owner.split(':');
  356. if (ownerType === 'user') {
  357. const member = memberList.find(user => user.id === id);
  358. if (member) {
  359. return {
  360. type: 'user',
  361. id,
  362. name: member.name,
  363. suggestedReason: owner.type,
  364. assignee: member,
  365. };
  366. }
  367. } else if (ownerType === 'team') {
  368. const matchingTeam = assignableTeams.find(
  369. assignableTeam => assignableTeam.id === owner.owner
  370. );
  371. if (matchingTeam) {
  372. return {
  373. type: 'team',
  374. id,
  375. name: matchingTeam.team.name,
  376. suggestedReason: owner.type,
  377. assignee: matchingTeam,
  378. };
  379. }
  380. }
  381. return null;
  382. }
  383. );
  384. return suggestedAssignees.filter(owner => !!owner) as SuggestedAssignee[];
  385. }
  386. render() {
  387. const {disabled, noDropdown} = this.props;
  388. const {loading, assignedTo} = this.state;
  389. const memberList = this.memberList();
  390. const suggestedActors = this.getSuggestedAssignees();
  391. const suggestedReasons: Record<SuggestedOwnerReason, React.ReactNode> = {
  392. suspectCommit: tct('Based on [commit:commit data]', {
  393. commit: (
  394. <TooltipSubExternalLink href="https://docs.sentry.io/product/sentry-basics/integrate-frontend/configure-scms/" />
  395. ),
  396. }),
  397. ownershipRule: t('Matching Issue Owners Rule'),
  398. };
  399. const assignedToSuggestion = suggestedActors.find(
  400. actor => actor.id === assignedTo?.id
  401. );
  402. const avatarElement = assignedTo ? (
  403. <ActorAvatar
  404. actor={assignedTo}
  405. className="avatar"
  406. size={24}
  407. tooltip={
  408. <TooltipWrapper>
  409. {tct('Assigned to [name]', {
  410. name: assignedTo.type === 'team' ? `#${assignedTo.name}` : assignedTo.name,
  411. })}
  412. {assignedToSuggestion && (
  413. <TooltipSubtext>
  414. {suggestedReasons[assignedToSuggestion.suggestedReason]}
  415. </TooltipSubtext>
  416. )}
  417. </TooltipWrapper>
  418. }
  419. />
  420. ) : suggestedActors && suggestedActors.length > 0 ? (
  421. <SuggestedAvatarStack
  422. size={24}
  423. owners={suggestedActors}
  424. tooltipOptions={{isHoverable: true}}
  425. tooltip={
  426. <TooltipWrapper>
  427. <div>
  428. {tct('Suggestion: [name]', {
  429. name:
  430. suggestedActors[0].type === 'team'
  431. ? `#${suggestedActors[0].name}`
  432. : suggestedActors[0].name,
  433. })}
  434. {suggestedActors.length > 1 &&
  435. tn(' + %s other', ' + %s others', suggestedActors.length - 1)}
  436. </div>
  437. <TooltipSubtext>
  438. {suggestedReasons[suggestedActors[0].suggestedReason]}
  439. </TooltipSubtext>
  440. </TooltipWrapper>
  441. }
  442. />
  443. ) : (
  444. <Tooltip
  445. isHoverable
  446. skipWrapper
  447. title={
  448. <TooltipWrapper>
  449. <div>{t('Unassigned')}</div>
  450. <TooltipSubtext>
  451. {tct(
  452. 'You can auto-assign issues by adding [issueOwners:Issue Owner rules].',
  453. {
  454. issueOwners: (
  455. <TooltipSubExternalLink href="https://docs.sentry.io/product/error-monitoring/issue-owners/" />
  456. ),
  457. }
  458. )}
  459. </TooltipSubtext>
  460. </TooltipWrapper>
  461. }
  462. >
  463. <StyledIconUser data-test-id="unassigned" size="20px" color="gray400" />
  464. </Tooltip>
  465. );
  466. return (
  467. <AssigneeWrapper>
  468. {loading && (
  469. <LoadingIndicator mini style={{height: '24px', margin: 0, marginRight: 11}} />
  470. )}
  471. {!loading && !noDropdown && (
  472. <DropdownAutoComplete
  473. disabled={disabled}
  474. maxHeight={400}
  475. onOpen={e => {
  476. // This can be called multiple times and does not always have `event`
  477. e?.stopPropagation();
  478. }}
  479. busy={memberList === undefined}
  480. items={memberList !== undefined ? this.renderNewDropdownItems() : null}
  481. alignMenu="right"
  482. onSelect={this.handleAssign}
  483. itemSize="small"
  484. searchPlaceholder={t('Filter teams and people')}
  485. menuFooter={
  486. assignedTo ? (
  487. <div>
  488. <MenuItemFooterWrapper
  489. role="button"
  490. onClick={this.clearAssignTo}
  491. py={0}
  492. >
  493. <IconContainer>
  494. <IconClose color="purple300" isCircled size="14px" />
  495. </IconContainer>
  496. <Label>{t('Clear Assignee')}</Label>
  497. </MenuItemFooterWrapper>
  498. {this.renderInviteMemberLink()}
  499. </div>
  500. ) : (
  501. this.renderInviteMemberLink()
  502. )
  503. }
  504. disableLabelPadding
  505. emptyHidesInput
  506. >
  507. {({getActorProps, isOpen}) => (
  508. <DropdownButton data-test-id="assignee-selector" {...getActorProps({})}>
  509. {avatarElement}
  510. <StyledChevron direction={isOpen ? 'up' : 'down'} size="xs" />
  511. </DropdownButton>
  512. )}
  513. </DropdownAutoComplete>
  514. )}
  515. {!loading && noDropdown && avatarElement}
  516. </AssigneeWrapper>
  517. );
  518. }
  519. }
  520. export function putSessionUserFirst(members: User[] | undefined): User[] {
  521. // If session user is in the filtered list of members, put them at the top
  522. if (!members) {
  523. return [];
  524. }
  525. const sessionUser = ConfigStore.get('user');
  526. const sessionUserIndex = members.findIndex(member => member.id === sessionUser?.id);
  527. if (sessionUserIndex === -1) {
  528. return members;
  529. }
  530. const arrangedMembers = [members[sessionUserIndex]];
  531. arrangedMembers.push(...members.slice(0, sessionUserIndex));
  532. arrangedMembers.push(...members.slice(sessionUserIndex + 1));
  533. return arrangedMembers;
  534. }
  535. export default AssigneeSelector;
  536. const AssigneeWrapper = styled('div')`
  537. display: flex;
  538. justify-content: flex-end;
  539. /* manually align menu underneath dropdown caret */
  540. ${DropdownBubble} {
  541. right: -14px;
  542. }
  543. `;
  544. const StyledIconUser = styled(IconUser)`
  545. /* We need this to center with Avatar */
  546. margin-right: 2px;
  547. `;
  548. const IconContainer = styled('div')`
  549. display: flex;
  550. align-items: center;
  551. justify-content: center;
  552. width: 24px;
  553. height: 24px;
  554. flex-shrink: 0;
  555. `;
  556. const MenuItemWrapper = styled('div')<{
  557. disabled?: boolean;
  558. py?: number;
  559. }>`
  560. cursor: ${p => (p.disabled ? 'not-allowed' : 'pointer')};
  561. display: flex;
  562. align-items: center;
  563. font-size: 13px;
  564. ${p =>
  565. typeof p.py !== 'undefined' &&
  566. `
  567. padding-top: ${p.py};
  568. padding-bottom: ${p.py};
  569. `};
  570. `;
  571. const MenuItemFooterWrapper = styled(MenuItemWrapper)`
  572. padding: ${space(0.25)} ${space(1)};
  573. border-top: 1px solid ${p => p.theme.innerBorder};
  574. background-color: ${p => p.theme.tag.highlight.background};
  575. color: ${p => p.theme.active};
  576. :hover {
  577. color: ${p => p.theme.activeHover};
  578. svg {
  579. fill: ${p => p.theme.activeHover};
  580. }
  581. }
  582. `;
  583. const InviteMemberLink = styled(Link)`
  584. color: ${p => (p.disabled ? p.theme.disabled : p.theme.textColor)};
  585. `;
  586. const Label = styled(TextOverflow)`
  587. margin-left: 6px;
  588. `;
  589. const StyledChevron = styled(IconChevron)`
  590. margin-left: ${space(1)};
  591. `;
  592. const DropdownButton = styled('div')`
  593. display: flex;
  594. align-items: center;
  595. font-size: 20px;
  596. `;
  597. const GroupHeader = styled('div')`
  598. font-size: ${p => p.theme.fontSizeSmall};
  599. font-weight: 600;
  600. margin: ${space(1)} 0;
  601. color: ${p => p.theme.subText};
  602. line-height: ${p => p.theme.fontSizeSmall};
  603. text-align: left;
  604. `;
  605. const SuggestedReason = styled('span')`
  606. margin-left: ${space(0.5)};
  607. color: ${p => p.theme.textColor};
  608. `;
  609. const TooltipWrapper = styled('div')`
  610. text-align: left;
  611. `;
  612. const TooltipSubtext = styled('div')`
  613. color: ${p => p.theme.subText};
  614. `;
  615. const TooltipSubExternalLink = styled(ExternalLink)`
  616. color: ${p => p.theme.subText};
  617. text-decoration: underline;
  618. :hover {
  619. color: ${p => p.theme.subText};
  620. }
  621. `;