task.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293
  1. import {forwardRef} from 'react';
  2. import {withRouter, WithRouterProps} from 'react-router';
  3. import styled from '@emotion/styled';
  4. import {motion} from 'framer-motion';
  5. import moment from 'moment';
  6. import {navigateTo} from 'sentry/actionCreators/navigation';
  7. import Avatar from 'sentry/components/avatar';
  8. import Button from 'sentry/components/button';
  9. import Card from 'sentry/components/card';
  10. import LetterAvatar from 'sentry/components/letterAvatar';
  11. import Tooltip from 'sentry/components/tooltip';
  12. import {IconCheckmark, IconClose, IconLock, IconSync} from 'sentry/icons';
  13. import {t, tct} from 'sentry/locale';
  14. import space from 'sentry/styles/space';
  15. import {AvatarUser, OnboardingTask, OnboardingTaskKey, Organization} from 'sentry/types';
  16. import trackAdvancedAnalyticsEvent from 'sentry/utils/analytics/trackAdvancedAnalyticsEvent';
  17. import testableTransition from 'sentry/utils/testableTransition';
  18. import withOrganization from 'sentry/utils/withOrganization';
  19. import SkipConfirm from './skipConfirm';
  20. import {taskIsDone} from './utils';
  21. const recordAnalytics = (
  22. task: OnboardingTask,
  23. organization: Organization,
  24. action: string
  25. ) =>
  26. trackAdvancedAnalyticsEvent('onboarding.wizard_clicked', {
  27. organization,
  28. todo_id: task.task,
  29. todo_title: task.title,
  30. action,
  31. });
  32. type Props = WithRouterProps & {
  33. forwardedRef: React.Ref<HTMLDivElement>;
  34. /**
  35. * Fired when a task is completed. This will typically happen if there is a
  36. * supplemental component with the ability to complete a task
  37. */
  38. onMarkComplete: (taskKey: OnboardingTaskKey) => void;
  39. /**
  40. * Fired when the task has been skipped
  41. */
  42. onSkip: (taskKey: OnboardingTaskKey) => void;
  43. organization: Organization;
  44. /**
  45. * Task to render
  46. */
  47. task: OnboardingTask;
  48. };
  49. function Task({router, task, onSkip, onMarkComplete, forwardedRef, organization}: Props) {
  50. const handleSkip = () => {
  51. recordAnalytics(task, organization, 'skipped');
  52. onSkip(task.task);
  53. };
  54. const handleClick = (e: React.MouseEvent) => {
  55. recordAnalytics(task, organization, 'clickthrough');
  56. e.stopPropagation();
  57. if (task.actionType === 'external') {
  58. window.open(task.location, '_blank');
  59. }
  60. if (task.actionType === 'action') {
  61. task.action();
  62. }
  63. if (task.actionType === 'app') {
  64. const url = new URL(task.location, window.location.origin);
  65. url.searchParams.append('referrer', 'onboarding_task');
  66. navigateTo(url.toString(), router);
  67. }
  68. };
  69. if (taskIsDone(task) && task.completionSeen) {
  70. const completedOn = moment(task.dateCompleted);
  71. return (
  72. <TaskCard ref={forwardedRef} onClick={handleClick}>
  73. <CompleteTitle>
  74. <StatusIndicator>
  75. {task.status === 'complete' && <CompleteIndicator />}
  76. {task.status === 'skipped' && <SkippedIndicator />}
  77. </StatusIndicator>
  78. {task.title}
  79. <DateCompleted title={completedOn.toString()}>
  80. {completedOn.fromNow()}
  81. </DateCompleted>
  82. {task.user ? (
  83. <TaskUserAvatar hasTooltip user={task.user} />
  84. ) : (
  85. <Tooltip
  86. containerDisplayMode="inherit"
  87. title={t('No user was associated with completing this task')}
  88. >
  89. <TaskBlankAvatar round />
  90. </Tooltip>
  91. )}
  92. </CompleteTitle>
  93. </TaskCard>
  94. );
  95. }
  96. const IncompleteMarker = task.requisiteTasks.length > 0 && (
  97. <Tooltip
  98. containerDisplayMode="block"
  99. title={tct('[requisite] before completing this task', {
  100. requisite: task.requisiteTasks[0].title,
  101. })}
  102. >
  103. <IconLock color="pink300" isSolid />
  104. </Tooltip>
  105. );
  106. const {SupplementComponent} = task;
  107. const supplement = SupplementComponent && (
  108. <SupplementComponent task={task} onCompleteTask={() => onMarkComplete(task.task)} />
  109. );
  110. const skipAction = task.skippable && (
  111. <SkipConfirm onSkip={handleSkip}>
  112. {({skip}) => (
  113. <CloseButton
  114. borderless
  115. size="zero"
  116. aria-label={t('Close')}
  117. icon={<IconClose size="xs" />}
  118. onClick={skip}
  119. />
  120. )}
  121. </SkipConfirm>
  122. );
  123. return (
  124. <TaskCard
  125. interactive
  126. ref={forwardedRef}
  127. onClick={handleClick}
  128. data-test-id={task.task}
  129. >
  130. <IncompleteTitle>
  131. {IncompleteMarker}
  132. {task.title}
  133. </IncompleteTitle>
  134. <Description>{`${task.description}`}</Description>
  135. {task.requisiteTasks.length === 0 && (
  136. <ActionBar>
  137. {skipAction}
  138. {supplement}
  139. {task.status === 'pending' ? (
  140. <InProgressIndicator user={task.user} />
  141. ) : (
  142. <Button priority="primary" size="small">
  143. {t('Start')}
  144. </Button>
  145. )}
  146. </ActionBar>
  147. )}
  148. </TaskCard>
  149. );
  150. }
  151. const TaskCard = styled(Card)`
  152. position: relative;
  153. padding: ${space(2)} ${space(3)};
  154. `;
  155. const IncompleteTitle = styled('div')`
  156. display: grid;
  157. grid-template-columns: max-content 1fr;
  158. gap: ${space(1)};
  159. align-items: center;
  160. font-weight: 600;
  161. `;
  162. const CompleteTitle = styled(IncompleteTitle)`
  163. grid-template-columns: min-content 1fr max-content min-content;
  164. `;
  165. const Description = styled('p')`
  166. font-size: ${p => p.theme.fontSizeSmall};
  167. color: ${p => p.theme.subText};
  168. margin: ${space(0.5)} 0 0 0;
  169. `;
  170. const ActionBar = styled('div')`
  171. display: flex;
  172. justify-content: flex-end;
  173. align-items: flex-end;
  174. margin-top: ${space(1.5)};
  175. `;
  176. type InProgressIndicatorProps = React.HTMLAttributes<HTMLDivElement> & {
  177. user?: AvatarUser | null;
  178. };
  179. const InProgressIndicator = styled(({user, ...props}: InProgressIndicatorProps) => (
  180. <div {...props}>
  181. <Tooltip
  182. disabled={!user}
  183. containerDisplayMode="flex"
  184. title={tct('This task has been started by [user]', {
  185. user: user?.name,
  186. })}
  187. >
  188. <IconSync />
  189. </Tooltip>
  190. {t('Task in progress...')}
  191. </div>
  192. ))`
  193. font-size: ${p => p.theme.fontSizeMedium};
  194. font-weight: bold;
  195. color: ${p => p.theme.pink300};
  196. display: grid;
  197. grid-template-columns: max-content max-content;
  198. align-items: center;
  199. gap: ${space(1)};
  200. `;
  201. const CloseButton = styled(Button)`
  202. position: absolute;
  203. right: ${space(1.5)};
  204. top: ${space(1.5)};
  205. color: ${p => p.theme.gray300};
  206. `;
  207. const transition = testableTransition();
  208. const StatusIndicator = styled(motion.div)`
  209. display: flex;
  210. `;
  211. StatusIndicator.defaultProps = {
  212. variants: {
  213. initial: {opacity: 0, x: 10},
  214. animate: {opacity: 1, x: 0},
  215. },
  216. transition,
  217. };
  218. const CompleteIndicator = styled(IconCheckmark)``;
  219. CompleteIndicator.defaultProps = {
  220. isCircled: true,
  221. color: 'green300',
  222. };
  223. const SkippedIndicator = styled(IconClose)``;
  224. SkippedIndicator.defaultProps = {
  225. isCircled: true,
  226. color: 'pink300',
  227. };
  228. const completedItemAnimation = {
  229. initial: {opacity: 0, x: -10},
  230. animate: {opacity: 1, x: 0},
  231. };
  232. const DateCompleted = styled(motion.div)`
  233. color: ${p => p.theme.subText};
  234. font-size: ${p => p.theme.fontSizeSmall};
  235. font-weight: 300;
  236. `;
  237. DateCompleted.defaultProps = {
  238. variants: completedItemAnimation,
  239. transition,
  240. };
  241. const TaskUserAvatar = motion(Avatar);
  242. TaskUserAvatar.defaultProps = {
  243. variants: completedItemAnimation,
  244. transition,
  245. };
  246. const TaskBlankAvatar = styled(motion(LetterAvatar))`
  247. position: unset;
  248. `;
  249. TaskBlankAvatar.defaultProps = {
  250. variants: completedItemAnimation,
  251. transition,
  252. };
  253. const WrappedTask = withOrganization(withRouter(Task));
  254. export default forwardRef<
  255. HTMLDivElement,
  256. Omit<React.ComponentProps<typeof WrappedTask>, 'forwardedRef'>
  257. >((props, ref) => <WrappedTask forwardedRef={ref} {...props} />);