activitySection.tsx 6.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214
  1. import {Fragment, useCallback, useMemo, 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 useMutateActivity from 'sentry/components/feedback/useMutateActivity';
  7. import Timeline from 'sentry/components/timeline';
  8. import TimeSince from 'sentry/components/timeSince';
  9. import {t} from 'sentry/locale';
  10. import GroupStore from 'sentry/stores/groupStore';
  11. import {space} from 'sentry/styles/space';
  12. import type {NoteType} from 'sentry/types/alerts';
  13. import type {Group, GroupActivity} from 'sentry/types/group';
  14. import {GroupActivityType} from 'sentry/types/group';
  15. import type {Release} from 'sentry/types/release';
  16. import type {User} from 'sentry/types/user';
  17. import {uniqueId} from 'sentry/utils/guid';
  18. import {useApiQuery} from 'sentry/utils/queryClient';
  19. import useOrganization from 'sentry/utils/useOrganization';
  20. import {useTeamsById} from 'sentry/utils/useTeamsById';
  21. import {useUser} from 'sentry/utils/useUser';
  22. import {groupActivityTypeIconMapping} from 'sentry/views/issueDetails/streamline/groupActivityIcons';
  23. import getGroupActivityItem from 'sentry/views/issueDetails/streamline/groupActivityItem';
  24. import {NoteDropdown} from 'sentry/views/issueDetails/streamline/noteDropdown';
  25. export interface GroupRelease {
  26. firstRelease: Release;
  27. lastRelease: Release;
  28. }
  29. function StreamlinedActivitySection({group}: {group: Group}) {
  30. const organization = useOrganization();
  31. const {teams} = useTeamsById();
  32. const {data: groupReleaseData} = useApiQuery<GroupRelease>(
  33. [`/organizations/${organization.slug}/issues/${group.id}/first-last-release/`],
  34. {
  35. staleTime: 30000,
  36. gcTime: 30000,
  37. }
  38. );
  39. const [inputId, setInputId] = useState(uniqueId());
  40. const activeUser = useUser();
  41. const projectSlugs = group?.project ? [group.project.slug] : [];
  42. const noteProps = {
  43. minHeight: 140,
  44. group,
  45. projectSlugs,
  46. placeholder: t('Add a comment...'),
  47. };
  48. const mutators = useMutateActivity({
  49. organization,
  50. group,
  51. });
  52. const handleDelete = useCallback(
  53. (item: GroupActivity) => {
  54. const restore = group.activity.find(activity => activity.id === item.id);
  55. const index = GroupStore.removeActivity(group.id, item.id);
  56. if (index === -1 || restore === undefined) {
  57. addErrorMessage(t('Failed to delete comment'));
  58. return;
  59. }
  60. mutators.handleDelete(
  61. item.id,
  62. group.activity.filter(a => a.id !== item.id),
  63. {
  64. onError: () => {
  65. addErrorMessage(t('Failed to delete comment'));
  66. },
  67. onSuccess: () => {
  68. addSuccessMessage(t('Comment removed'));
  69. },
  70. }
  71. );
  72. },
  73. [group.activity, mutators, group.id]
  74. );
  75. const handleCreate = useCallback(
  76. (n: NoteType, _me: User) => {
  77. mutators.handleCreate(n, group.activity, {
  78. onError: () => {
  79. addErrorMessage(t('Unable to post comment'));
  80. },
  81. onSuccess: data => {
  82. GroupStore.addActivity(group.id, data);
  83. addSuccessMessage(t('Comment posted'));
  84. },
  85. });
  86. },
  87. [group.activity, mutators, group.id]
  88. );
  89. const activities = useMemo(() => {
  90. const lastSeenActivity: GroupActivity = {
  91. type: GroupActivityType.LAST_SEEN,
  92. id: uniqueId(),
  93. dateCreated: group.lastSeen,
  94. project: group.project,
  95. data: {},
  96. };
  97. const groupActivities = [...group.activity, lastSeenActivity];
  98. return groupActivities.sort((a, b) => {
  99. const dateA = new Date(a.dateCreated).getTime();
  100. const dateB = new Date(b.dateCreated).getTime();
  101. if (
  102. a.type === GroupActivityType.FIRST_SEEN &&
  103. b.type === GroupActivityType.LAST_SEEN
  104. ) {
  105. return 1;
  106. }
  107. if (
  108. a.type === GroupActivityType.LAST_SEEN &&
  109. b.type === GroupActivityType.FIRST_SEEN
  110. ) {
  111. return -1;
  112. }
  113. return dateB - dateA;
  114. });
  115. // eslint-disable-next-line react-hooks/exhaustive-deps
  116. }, [group.activity.length, group.lastSeen, group.project]);
  117. return (
  118. <Fragment>
  119. <Timeline.Container>
  120. <NoteInputWithStorage
  121. key={inputId}
  122. storageKey="groupinput:latest"
  123. itemKey={group.id}
  124. onCreate={n => {
  125. handleCreate(n, activeUser);
  126. setInputId(uniqueId());
  127. }}
  128. source="issue-details"
  129. {...noteProps}
  130. />
  131. {activities.map(item => {
  132. const authorName = item.user ? item.user.name : 'Sentry';
  133. const {title, message} = getGroupActivityItem(
  134. item,
  135. organization,
  136. group.project.id,
  137. <Author>{authorName}</Author>,
  138. teams,
  139. groupReleaseData
  140. );
  141. const Icon = groupActivityTypeIconMapping[item.type]?.Component ?? null;
  142. return (
  143. <ActivityTimelineItem
  144. title={
  145. <TitleWrapper>
  146. {title}
  147. <NoteDropdownWrapper>
  148. {item.type === GroupActivityType.NOTE && (
  149. <NoteDropdown
  150. onDelete={() => handleDelete(item)}
  151. user={item.user}
  152. />
  153. )}
  154. </NoteDropdownWrapper>
  155. </TitleWrapper>
  156. }
  157. timestamp={<SmallTimestamp date={item.dateCreated} />}
  158. icon={
  159. Icon && (
  160. <Icon
  161. {...groupActivityTypeIconMapping[item.type].defaultProps}
  162. size="xs"
  163. />
  164. )
  165. }
  166. key={item.id}
  167. >
  168. {typeof message === 'string' ? <NoteBody text={message} /> : message}
  169. </ActivityTimelineItem>
  170. );
  171. })}
  172. </Timeline.Container>
  173. </Fragment>
  174. );
  175. }
  176. const Author = styled('span')`
  177. font-weight: ${p => p.theme.fontWeightBold};
  178. `;
  179. const NoteDropdownWrapper = styled('span')`
  180. font-weight: normal;
  181. `;
  182. const TitleWrapper = styled('div')`
  183. display: flex;
  184. align-items: center;
  185. gap: ${space(0.5)};
  186. `;
  187. const ActivityTimelineItem = styled(Timeline.Item)`
  188. align-items: center;
  189. `;
  190. const SmallTimestamp = styled(TimeSince)`
  191. font-size: ${p => p.theme.fontSizeSmall};
  192. `;
  193. export default StreamlinedActivitySection;