activitySection.tsx 11 KB

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