activitySection.tsx 8.6 KB

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