task.tsx 7.9 KB

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