activitySection.tsx 11 KB

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