task.tsx 7.6 KB

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