activitySection.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  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 Timeline from 'sentry/components/timeline';
  10. import TimeSince from 'sentry/components/timeSince';
  11. import {Tooltip} from 'sentry/components/tooltip';
  12. import {IconEllipsis} from 'sentry/icons';
  13. import {t, tct} from 'sentry/locale';
  14. import GroupStore from 'sentry/stores/groupStore';
  15. import {space} from 'sentry/styles/space';
  16. import textStyles from 'sentry/styles/text';
  17. import type {NoteType} from 'sentry/types/alerts';
  18. import type {Group, GroupActivity} from 'sentry/types/group';
  19. import {GroupActivityType} from 'sentry/types/group';
  20. import type {Team} from 'sentry/types/organization';
  21. import type {User} from 'sentry/types/user';
  22. import {trackAnalytics} from 'sentry/utils/analytics';
  23. import {uniqueId} from 'sentry/utils/guid';
  24. import {useLocation} from 'sentry/utils/useLocation';
  25. import useOrganization from 'sentry/utils/useOrganization';
  26. import {useTeamsById} from 'sentry/utils/useTeamsById';
  27. import {useUser} from 'sentry/utils/useUser';
  28. import {groupActivityTypeIconMapping} from 'sentry/views/issueDetails/streamline/groupActivityIcons';
  29. import getGroupActivityItem from 'sentry/views/issueDetails/streamline/groupActivityItem';
  30. import {NoteDropdown} from 'sentry/views/issueDetails/streamline/noteDropdown';
  31. import {SidebarSectionTitle} from 'sentry/views/issueDetails/streamline/sidebar';
  32. import {Tab, TabPaths} from 'sentry/views/issueDetails/types';
  33. import {useGroupDetailsRoute} from 'sentry/views/issueDetails/useGroupDetailsRoute';
  34. function TimelineItem({
  35. item,
  36. handleDelete,
  37. group,
  38. teams,
  39. }: {
  40. group: Group;
  41. handleDelete: (item: GroupActivity) => void;
  42. item: GroupActivity;
  43. teams: Team[];
  44. }) {
  45. const organization = useOrganization();
  46. const authorName = item.user ? item.user.name : 'Sentry';
  47. const {title, message} = getGroupActivityItem(
  48. item,
  49. organization,
  50. group.project,
  51. <strong>{authorName}</strong>,
  52. teams
  53. );
  54. const iconMapping = groupActivityTypeIconMapping[item.type];
  55. const Icon = iconMapping?.componentFunction
  56. ? iconMapping.componentFunction(item.data)
  57. : iconMapping?.Component ?? null;
  58. return (
  59. <ActivityTimelineItem
  60. title={
  61. <Flex gap={space(0.5)} align="center" justify="flex-start">
  62. <TitleTooltip title={title} showOnlyOnOverflow skipWrapper>
  63. {title}
  64. </TitleTooltip>
  65. {item.type === GroupActivityType.NOTE && (
  66. <TitleDropdown onDelete={() => handleDelete(item)} user={item.user} />
  67. )}
  68. </Flex>
  69. }
  70. timestamp={<Timestamp date={item.dateCreated} tooltipProps={{skipWrapper: true}} />}
  71. icon={
  72. Icon && (
  73. <Icon
  74. {...iconMapping.defaultProps}
  75. {...iconMapping.propsFunction?.(item.data)}
  76. size="xs"
  77. />
  78. )
  79. }
  80. >
  81. {typeof message === 'string' ? (
  82. <NoteWrapper>
  83. <NoteBody text={message} />
  84. </NoteWrapper>
  85. ) : (
  86. message
  87. )}
  88. </ActivityTimelineItem>
  89. );
  90. }
  91. interface StreamlinedActivitySectionProps {
  92. group: Group;
  93. /**
  94. * Whether the activity section is being rendered in the activity drawer.
  95. * Disables collapse feature, and hides headers
  96. */
  97. isDrawer?: boolean;
  98. }
  99. export default function StreamlinedActivitySection({
  100. group,
  101. isDrawer,
  102. }: StreamlinedActivitySectionProps) {
  103. const organization = useOrganization();
  104. const {teams} = useTeamsById();
  105. const {baseUrl} = useGroupDetailsRoute();
  106. const location = useLocation();
  107. const [inputId, setInputId] = useState(() => uniqueId());
  108. const activeUser = useUser();
  109. const projectSlugs = group?.project ? [group.project.slug] : [];
  110. const noteProps = {
  111. minHeight: 140,
  112. group,
  113. projectSlugs,
  114. placeholder: t('Add a comment\u2026'),
  115. };
  116. const mutators = useMutateActivity({
  117. organization,
  118. group,
  119. });
  120. const handleDelete = useCallback(
  121. (item: GroupActivity) => {
  122. const restore = group.activity.find(activity => activity.id === item.id);
  123. const index = GroupStore.removeActivity(group.id, item.id);
  124. if (index === -1 || restore === undefined) {
  125. addErrorMessage(t('Failed to delete comment'));
  126. return;
  127. }
  128. mutators.handleDelete(
  129. item.id,
  130. group.activity.filter(a => a.id !== item.id),
  131. {
  132. onError: () => {
  133. addErrorMessage(t('Failed to delete comment'));
  134. },
  135. onSuccess: () => {
  136. trackAnalytics('issue_details.comment_deleted', {organization});
  137. addSuccessMessage(t('Comment removed'));
  138. },
  139. }
  140. );
  141. },
  142. [group.activity, mutators, group.id, organization]
  143. );
  144. const handleCreate = useCallback(
  145. (n: NoteType, _me: User) => {
  146. mutators.handleCreate(n, group.activity, {
  147. onError: err => {
  148. const errMessage = err.responseJSON?.detail
  149. ? tct('Error: [msg]', {msg: err.responseJSON?.detail as string})
  150. : t('Unable to post comment');
  151. addErrorMessage(errMessage);
  152. },
  153. onSuccess: data => {
  154. GroupStore.addActivity(group.id, data);
  155. trackAnalytics('issue_details.comment_created', {organization});
  156. addSuccessMessage(t('Comment posted'));
  157. },
  158. });
  159. },
  160. [group.activity, mutators, group.id, organization]
  161. );
  162. return (
  163. <div>
  164. {!isDrawer && (
  165. <Flex justify="space-between" align="center">
  166. <SidebarSectionTitle>{t('Activity')}</SidebarSectionTitle>
  167. <TextLinkButton
  168. borderless
  169. size="zero"
  170. aria-label={t('Open activity drawer')}
  171. to={{
  172. pathname: `${baseUrl}${TabPaths[Tab.ACTIVITY]}`,
  173. query: {
  174. ...location.query,
  175. cursor: undefined,
  176. },
  177. }}
  178. analyticsEventKey="issue_details.activity_drawer_opened"
  179. analyticsEventName="Issue Details: Activity Drawer Opened"
  180. analyticsParams={{
  181. num_activities: group.activity.length,
  182. }}
  183. >
  184. {t('View')}
  185. </TextLinkButton>
  186. </Flex>
  187. )}
  188. <Timeline.Container>
  189. <NoteInputWithStorage
  190. key={inputId}
  191. storageKey="groupinput:latest"
  192. itemKey={group.id}
  193. onCreate={n => {
  194. handleCreate(n, activeUser);
  195. setInputId(uniqueId());
  196. }}
  197. source="issue-details"
  198. {...noteProps}
  199. />
  200. {(group.activity.length < 5 || isDrawer) &&
  201. group.activity.map(item => {
  202. return (
  203. <TimelineItem
  204. item={item}
  205. handleDelete={handleDelete}
  206. group={group}
  207. teams={teams}
  208. key={item.id}
  209. />
  210. );
  211. })}
  212. {!isDrawer && group.activity.length >= 5 && (
  213. <Fragment>
  214. {group.activity.slice(0, 3).map(item => {
  215. return (
  216. <TimelineItem
  217. item={item}
  218. handleDelete={handleDelete}
  219. group={group}
  220. teams={teams}
  221. key={item.id}
  222. />
  223. );
  224. })}
  225. <ActivityTimelineItem
  226. title={
  227. <LinkButton
  228. aria-label={t('View all activity')}
  229. to={{
  230. pathname: `${baseUrl}${TabPaths[Tab.ACTIVITY]}`,
  231. query: {
  232. ...location.query,
  233. cursor: undefined,
  234. },
  235. }}
  236. size="xs"
  237. analyticsEventKey="issue_details.activity_expanded"
  238. analyticsEventName="Issue Details: Activity Expanded"
  239. analyticsParams={{
  240. num_activities_hidden: group.activity.length - 3,
  241. }}
  242. >
  243. {t('View %s more', group.activity.length - 3)}
  244. </LinkButton>
  245. }
  246. icon={<RotatedEllipsisIcon direction={'up'} />}
  247. />
  248. </Fragment>
  249. )}
  250. </Timeline.Container>
  251. </div>
  252. );
  253. }
  254. const TitleTooltip = styled(Tooltip)`
  255. justify-self: start;
  256. overflow: hidden;
  257. text-overflow: ellipsis;
  258. white-space: nowrap;
  259. `;
  260. const TitleDropdown = styled(NoteDropdown)`
  261. font-weight: normal;
  262. `;
  263. const ActivityTimelineItem = styled(Timeline.Item)`
  264. align-items: center;
  265. grid-template-columns: 22px minmax(50px, 1fr) auto;
  266. `;
  267. const Timestamp = styled(TimeSince)`
  268. font-size: ${p => p.theme.fontSizeSmall};
  269. white-space: nowrap;
  270. `;
  271. const TextLinkButton = styled(LinkButton)`
  272. font-weight: ${p => p.theme.fontWeightNormal};
  273. font-size: ${p => p.theme.fontSizeSmall};
  274. color: ${p => p.theme.subText};
  275. `;
  276. const RotatedEllipsisIcon = styled(IconEllipsis)`
  277. transform: rotate(90deg) translateY(1px);
  278. `;
  279. const NoteWrapper = styled('div')`
  280. ${textStyles}
  281. `;