activitySection.tsx 7.9 KB

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