activitySection.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394
  1. import {Fragment, useCallback, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
  4. import {NoteBody} from 'sentry/components/activity/note/body';
  5. import {NoteInputWithStorage} from 'sentry/components/activity/note/inputWithStorage';
  6. import {LinkButton} from 'sentry/components/button';
  7. import {Flex} from 'sentry/components/container/flex';
  8. import useMutateActivity from 'sentry/components/feedback/useMutateActivity';
  9. import Link from 'sentry/components/links/link';
  10. import Timeline from 'sentry/components/timeline';
  11. import TimeSince from 'sentry/components/timeSince';
  12. import {Tooltip} from 'sentry/components/tooltip';
  13. import {IconChat, IconEllipsis} from 'sentry/icons';
  14. import {t, tct} from 'sentry/locale';
  15. import GroupStore from 'sentry/stores/groupStore';
  16. import {space} from 'sentry/styles/space';
  17. import textStyles from 'sentry/styles/text';
  18. import type {NoteType} from 'sentry/types/alerts';
  19. import type {Group, GroupActivity, GroupActivityNote} from 'sentry/types/group';
  20. import {GroupActivityType} from 'sentry/types/group';
  21. import type {Team} from 'sentry/types/organization';
  22. import type {User} from 'sentry/types/user';
  23. import {trackAnalytics} from 'sentry/utils/analytics';
  24. import {uniqueId} from 'sentry/utils/guid';
  25. import {useLocation} from 'sentry/utils/useLocation';
  26. import useOrganization from 'sentry/utils/useOrganization';
  27. import {useTeamsById} from 'sentry/utils/useTeamsById';
  28. import {useUser} from 'sentry/utils/useUser';
  29. import {groupActivityTypeIconMapping} from 'sentry/views/issueDetails/streamline/sidebar/groupActivityIcons';
  30. import getGroupActivityItem from 'sentry/views/issueDetails/streamline/sidebar/groupActivityItem';
  31. import {NoteDropdown} from 'sentry/views/issueDetails/streamline/sidebar/noteDropdown';
  32. import {SidebarSectionTitle} from 'sentry/views/issueDetails/streamline/sidebar/sidebar';
  33. import {ViewButton} from 'sentry/views/issueDetails/streamline/sidebar/viewButton';
  34. import {Tab, TabPaths} from 'sentry/views/issueDetails/types';
  35. import {useGroupDetailsRoute} from 'sentry/views/issueDetails/useGroupDetailsRoute';
  36. function TimelineItem({
  37. item,
  38. handleDelete,
  39. handleUpdate,
  40. group,
  41. teams,
  42. }: {
  43. group: Group;
  44. handleDelete: (item: GroupActivity) => void;
  45. handleUpdate: (item: GroupActivity, n: NoteType) => void;
  46. item: GroupActivity;
  47. teams: Team[];
  48. }) {
  49. const organization = useOrganization();
  50. const [editing, setEditing] = useState(false);
  51. const authorName = item.user ? item.user.name : 'Sentry';
  52. const {title, message} = getGroupActivityItem(
  53. item,
  54. organization,
  55. group.project,
  56. <strong>{authorName}</strong>,
  57. teams
  58. );
  59. const iconMapping = groupActivityTypeIconMapping[item.type];
  60. const Icon = iconMapping?.componentFunction
  61. ? iconMapping.componentFunction(item.data, item.user)
  62. : (iconMapping?.Component ?? null);
  63. return (
  64. <ActivityTimelineItem
  65. title={
  66. <Flex gap={space(0.5)} align="center" justify="flex-start">
  67. <TitleTooltip title={title} showOnlyOnOverflow>
  68. {title}
  69. </TitleTooltip>
  70. {item.type === GroupActivityType.NOTE && !editing && (
  71. <TitleDropdown
  72. onDelete={() => handleDelete(item)}
  73. onEdit={() => setEditing(true)}
  74. user={item.user}
  75. />
  76. )}
  77. </Flex>
  78. }
  79. timestamp={<Timestamp date={item.dateCreated} tooltipProps={{skipWrapper: true}} />}
  80. icon={
  81. Icon && (
  82. <Icon
  83. {...iconMapping.defaultProps}
  84. {...iconMapping.propsFunction?.(item.data)}
  85. size="xs"
  86. />
  87. )
  88. }
  89. >
  90. {item.type === GroupActivityType.NOTE && editing ? (
  91. <NoteInputWithStorage
  92. itemKey={item.id}
  93. storageKey={`groupinput:${item.id}`}
  94. source="issue-details"
  95. text={item.data.text}
  96. noteId={item.id}
  97. onUpdate={n => {
  98. handleUpdate(item, n);
  99. setEditing(false);
  100. }}
  101. onCancel={() => setEditing(false)}
  102. />
  103. ) : typeof message === 'string' ? (
  104. <NoteWrapper>
  105. <NoteBody text={message} />
  106. </NoteWrapper>
  107. ) : (
  108. message
  109. )}
  110. </ActivityTimelineItem>
  111. );
  112. }
  113. interface StreamlinedActivitySectionProps {
  114. group: Group;
  115. /**
  116. * Whether to filter the activity to only show comments.
  117. */
  118. filterComments?: boolean;
  119. /**
  120. * Whether the activity section is being rendered in the activity drawer.
  121. * Disables collapse feature, and hides headers
  122. */
  123. isDrawer?: boolean;
  124. }
  125. export default function StreamlinedActivitySection({
  126. group,
  127. isDrawer,
  128. filterComments,
  129. }: StreamlinedActivitySectionProps) {
  130. const organization = useOrganization();
  131. const {teams} = useTeamsById();
  132. const {baseUrl} = useGroupDetailsRoute();
  133. const location = useLocation();
  134. const [inputId, setInputId] = useState(() => uniqueId());
  135. const activeUser = useUser();
  136. const projectSlugs = group?.project ? [group.project.slug] : [];
  137. const noteProps = {
  138. minHeight: 140,
  139. group,
  140. projectSlugs,
  141. placeholder: t('Add a comment\u2026'),
  142. };
  143. const mutators = useMutateActivity({
  144. organization,
  145. group,
  146. });
  147. const handleDelete = useCallback(
  148. (item: GroupActivity) => {
  149. const restore = group.activity.find(activity => activity.id === item.id);
  150. const index = GroupStore.removeActivity(group.id, item.id);
  151. if (index === -1 || restore === undefined) {
  152. addErrorMessage(t('Failed to delete comment'));
  153. return;
  154. }
  155. mutators.handleDelete(
  156. item.id,
  157. group.activity.filter(a => a.id !== item.id),
  158. {
  159. onError: () => {
  160. addErrorMessage(t('Failed to delete comment'));
  161. },
  162. onSuccess: () => {
  163. trackAnalytics('issue_details.comment_deleted', {
  164. organization,
  165. streamline: true,
  166. org_streamline_only: organization.streamlineOnly ?? undefined,
  167. });
  168. addSuccessMessage(t('Comment removed'));
  169. },
  170. }
  171. );
  172. },
  173. [group.activity, mutators, group.id, organization]
  174. );
  175. const handleUpdate = useCallback(
  176. (item: GroupActivity, n: NoteType) => {
  177. mutators.handleUpdate(n, item.id, group.activity, {
  178. onError: () => {
  179. addErrorMessage(t('Unable to update comment'));
  180. },
  181. onSuccess: data => {
  182. const d = data as GroupActivityNote;
  183. GroupStore.updateActivity(group.id, data.id, {text: d.data.text});
  184. addSuccessMessage(t('Comment updated'));
  185. trackAnalytics('issue_details.comment_updated', {
  186. organization,
  187. streamline: true,
  188. org_streamline_only: organization.streamlineOnly ?? undefined,
  189. });
  190. },
  191. });
  192. },
  193. [group.activity, mutators, group.id, organization]
  194. );
  195. const handleCreate = useCallback(
  196. (n: NoteType, _me: User) => {
  197. mutators.handleCreate(n, group.activity, {
  198. onError: err => {
  199. const errMessage = err.responseJSON?.detail
  200. ? tct('Error: [msg]', {msg: err.responseJSON?.detail as string})
  201. : t('Unable to post comment');
  202. addErrorMessage(errMessage);
  203. },
  204. onSuccess: data => {
  205. GroupStore.addActivity(group.id, data);
  206. trackAnalytics('issue_details.comment_created', {
  207. organization,
  208. streamline: true,
  209. org_streamline_only: organization.streamlineOnly ?? undefined,
  210. });
  211. addSuccessMessage(t('Comment posted'));
  212. },
  213. });
  214. },
  215. [group.activity, mutators, group.id, organization]
  216. );
  217. const activityLink = {
  218. pathname: `${baseUrl}${TabPaths[Tab.ACTIVITY]}`,
  219. query: {
  220. ...location.query,
  221. cursor: undefined,
  222. },
  223. };
  224. const filteredActivityLink = {
  225. pathname: `${baseUrl}${TabPaths[Tab.ACTIVITY]}`,
  226. query: {
  227. ...location.query,
  228. cursor: undefined,
  229. filter: 'comments',
  230. },
  231. };
  232. return (
  233. <div>
  234. {!isDrawer && (
  235. <Flex justify="space-between" align="center" style={{marginBottom: space(1)}}>
  236. <SidebarSectionTitle style={{gap: space(0.75), margin: 0}}>
  237. {t('Activity')}
  238. {group.numComments > 0 ? (
  239. <CommentsLink
  240. to={filteredActivityLink}
  241. aria-label={t('Number of comments: %s', group.numComments)}
  242. onClick={() => {
  243. trackAnalytics('issue_details.activity_comments_link_clicked', {
  244. organization,
  245. num_comments: group.numComments,
  246. });
  247. }}
  248. >
  249. <IconChat
  250. size="xs"
  251. color={
  252. group.subscriptionDetails?.reason === 'mentioned'
  253. ? 'successText'
  254. : undefined
  255. }
  256. />
  257. <span>{group.numComments}</span>
  258. </CommentsLink>
  259. ) : null}
  260. </SidebarSectionTitle>
  261. <ViewButton
  262. aria-label={t('Open activity drawer')}
  263. to={{
  264. pathname: `${baseUrl}${TabPaths[Tab.ACTIVITY]}`,
  265. query: {
  266. ...location.query,
  267. cursor: undefined,
  268. },
  269. }}
  270. analyticsEventKey="issue_details.activity_drawer_opened"
  271. analyticsEventName="Issue Details: Activity Drawer Opened"
  272. analyticsParams={{
  273. num_activities: group.activity.length,
  274. }}
  275. >
  276. {t('View')}
  277. </ViewButton>
  278. </Flex>
  279. )}
  280. <Timeline.Container>
  281. <NoteInputWithStorage
  282. key={inputId}
  283. storageKey="groupinput:latest"
  284. itemKey={group.id}
  285. onCreate={n => {
  286. handleCreate(n, activeUser);
  287. setInputId(uniqueId());
  288. }}
  289. source="issue-details"
  290. {...noteProps}
  291. />
  292. {(group.activity.length < 5 || isDrawer) &&
  293. group.activity
  294. .filter(item => !filterComments || item.type === GroupActivityType.NOTE)
  295. .map(item => {
  296. return (
  297. <TimelineItem
  298. item={item}
  299. handleDelete={handleDelete}
  300. handleUpdate={handleUpdate}
  301. group={group}
  302. teams={teams}
  303. key={item.id}
  304. />
  305. );
  306. })}
  307. {!isDrawer && group.activity.length >= 5 && (
  308. <Fragment>
  309. {group.activity.slice(0, 3).map(item => {
  310. return (
  311. <TimelineItem
  312. item={item}
  313. handleDelete={handleDelete}
  314. handleUpdate={handleUpdate}
  315. group={group}
  316. teams={teams}
  317. key={item.id}
  318. />
  319. );
  320. })}
  321. <ActivityTimelineItem
  322. title={
  323. <LinkButton
  324. aria-label={t('View all activity')}
  325. to={activityLink}
  326. size="xs"
  327. analyticsEventKey="issue_details.activity_expanded"
  328. analyticsEventName="Issue Details: Activity Expanded"
  329. analyticsParams={{
  330. num_activities_hidden: group.activity.length - 3,
  331. }}
  332. >
  333. {t('View %s more', group.activity.length - 3)}
  334. </LinkButton>
  335. }
  336. icon={<RotatedEllipsisIcon direction={'up'} />}
  337. />
  338. </Fragment>
  339. )}
  340. </Timeline.Container>
  341. </div>
  342. );
  343. }
  344. const TitleTooltip = styled(Tooltip)`
  345. justify-self: start;
  346. overflow: hidden;
  347. text-overflow: ellipsis;
  348. white-space: nowrap;
  349. `;
  350. const TitleDropdown = styled(NoteDropdown)`
  351. font-weight: normal;
  352. `;
  353. const ActivityTimelineItem = styled(Timeline.Item)`
  354. align-items: center;
  355. grid-template-columns: 22px minmax(50px, 1fr) auto;
  356. `;
  357. const Timestamp = styled(TimeSince)`
  358. font-size: ${p => p.theme.fontSizeSmall};
  359. white-space: nowrap;
  360. `;
  361. const RotatedEllipsisIcon = styled(IconEllipsis)`
  362. transform: rotate(90deg) translateY(1px);
  363. `;
  364. const NoteWrapper = styled('div')`
  365. ${textStyles}
  366. `;
  367. const CommentsLink = styled(Link)`
  368. display: flex;
  369. gap: ${space(0.5)};
  370. align-items: center;
  371. color: ${p => p.theme.subText};
  372. `;