sidebar.tsx 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. import {useCallback, useEffect, useMemo, useRef} from 'react';
  2. import styled from '@emotion/styled';
  3. import {AnimatePresence, motion} from 'framer-motion';
  4. import HighlightTopRight from 'sentry-images/pattern/highlight-top-right.svg';
  5. import {updateOnboardingTask} from 'sentry/actionCreators/onboardingTasks';
  6. import SidebarPanel from 'sentry/components/sidebar/sidebarPanel';
  7. import {CommonSidebarProps} from 'sentry/components/sidebar/types';
  8. import Tooltip from 'sentry/components/tooltip';
  9. import {t} from 'sentry/locale';
  10. import space from 'sentry/styles/space';
  11. import {OnboardingTask, OnboardingTaskKey, Organization, Project} from 'sentry/types';
  12. import testableTransition from 'sentry/utils/testableTransition';
  13. import useApi from 'sentry/utils/useApi';
  14. import withOrganization from 'sentry/utils/withOrganization';
  15. import withProjects from 'sentry/utils/withProjects';
  16. import {usePersistedOnboardingState} from 'sentry/views/onboarding/targetedOnboarding/utils';
  17. import ProgressHeader from './progressHeader';
  18. import Task from './task';
  19. import {getMergedTasks} from './taskConfig';
  20. import {findActiveTasks, findCompleteTasks, findUpcomingTasks, taskIsDone} from './utils';
  21. type Props = Pick<CommonSidebarProps, 'orientation' | 'collapsed'> & {
  22. onClose: () => void;
  23. organization: Organization;
  24. projects: Project[];
  25. };
  26. /**
  27. * How long (in ms) to delay before beginning to mark tasks complete
  28. */
  29. const INITIAL_MARK_COMPLETE_TIMEOUT = 600;
  30. /**
  31. * How long (in ms) to delay between marking each unseen task as complete.
  32. */
  33. const COMPLETION_SEEN_TIMEOUT = 800;
  34. const Heading = styled(motion.div)`
  35. display: flex;
  36. color: ${p => p.theme.purple300};
  37. font-size: ${p => p.theme.fontSizeExtraSmall};
  38. text-transform: uppercase;
  39. font-weight: 600;
  40. line-height: 1;
  41. margin-top: ${space(3)};
  42. `;
  43. Heading.defaultProps = {
  44. layout: true,
  45. transition: testableTransition(),
  46. };
  47. const customizedTasksHeading = <Heading key="customized">{t('The Basics')}</Heading>;
  48. const completeNowHeading = <Heading key="now">{t('Next Steps')}</Heading>;
  49. const upcomingTasksHeading = (
  50. <Heading key="upcoming">
  51. <Tooltip
  52. containerDisplayMode="block"
  53. title={t('Some tasks should be completed before completing these tasks')}
  54. >
  55. {t('Level Up')}
  56. </Tooltip>
  57. </Heading>
  58. );
  59. const completedTasksHeading = <Heading key="complete">{t('Completed')}</Heading>;
  60. function OnboardingWizardSidebar({
  61. organization,
  62. collapsed,
  63. orientation,
  64. onClose,
  65. projects,
  66. }: Props) {
  67. const api = useApi();
  68. const [onboardingState, setOnboardingState] = usePersistedOnboardingState();
  69. const markCompletionTimeout = useRef<number | undefined>();
  70. const markCompletionSeenTimeout = useRef<number | undefined>();
  71. function completionTimeout(time: number): Promise<void> {
  72. window.clearTimeout(markCompletionTimeout.current);
  73. return new Promise(resolve => {
  74. markCompletionTimeout.current = window.setTimeout(resolve, time);
  75. });
  76. }
  77. function seenTimeout(time: number): Promise<void> {
  78. window.clearTimeout(markCompletionSeenTimeout.current);
  79. return new Promise(resolve => {
  80. markCompletionSeenTimeout.current = window.setTimeout(resolve, time);
  81. });
  82. }
  83. const {allTasks, customTasks, active, upcoming, complete} = useMemo(() => {
  84. const all = getMergedTasks({
  85. organization,
  86. projects,
  87. onboardingState: onboardingState || undefined,
  88. }).filter(task => task.display);
  89. const tasks = all.filter(task => !task.renderCard);
  90. return {
  91. allTasks: all,
  92. customTasks: all.filter(task => task.renderCard),
  93. active: tasks.filter(findActiveTasks),
  94. upcoming: tasks.filter(findUpcomingTasks),
  95. complete: tasks.filter(findCompleteTasks),
  96. };
  97. }, [organization, projects, onboardingState]);
  98. const markTasksAsSeen = useCallback(
  99. async function () {
  100. const unseenTasks = allTasks
  101. .filter(task => taskIsDone(task) && !task.completionSeen)
  102. .map(task => task.task);
  103. // Incrementally mark tasks as seen. This gives the card completion
  104. // animations time before we move each task into the completed section.
  105. for (const task of unseenTasks) {
  106. await seenTimeout(COMPLETION_SEEN_TIMEOUT);
  107. updateOnboardingTask(api, organization, {task, completionSeen: true});
  108. }
  109. },
  110. [api, organization, allTasks]
  111. );
  112. const markSeenOnOpen = useCallback(
  113. async function () {
  114. // Add a minor delay to marking tasks complete to account for the animation
  115. // opening of the sidebar panel
  116. await completionTimeout(INITIAL_MARK_COMPLETE_TIMEOUT);
  117. markTasksAsSeen();
  118. },
  119. [markTasksAsSeen]
  120. );
  121. useEffect(() => {
  122. markSeenOnOpen();
  123. return () => {
  124. window.clearTimeout(markCompletionTimeout.current);
  125. window.clearTimeout(markCompletionSeenTimeout.current);
  126. };
  127. }, [markSeenOnOpen]);
  128. function makeTaskUpdater(status: OnboardingTask['status']) {
  129. return (task: OnboardingTaskKey) =>
  130. updateOnboardingTask(api, organization, {task, status, completionSeen: true});
  131. }
  132. function renderItem(task: OnboardingTask) {
  133. return (
  134. <AnimatedTaskItem
  135. task={task}
  136. key={`${task.task}`}
  137. onSkip={makeTaskUpdater('skipped')}
  138. onMarkComplete={makeTaskUpdater('complete')}
  139. />
  140. );
  141. }
  142. const completeList = (
  143. <CompleteList key="complete-group">
  144. <AnimatePresence initial={false}>{complete.map(renderItem)}</AnimatePresence>
  145. </CompleteList>
  146. );
  147. const customizedCards = customTasks
  148. .map(task =>
  149. task.renderCard?.({
  150. organization,
  151. task,
  152. onboardingState,
  153. setOnboardingState,
  154. projects,
  155. })
  156. )
  157. .filter(card => !!card);
  158. const items = [
  159. customizedCards.length > 0 && customizedTasksHeading,
  160. ...customizedCards,
  161. active.length > 0 && completeNowHeading,
  162. ...active.map(renderItem),
  163. upcoming.length > 0 && upcomingTasksHeading,
  164. ...upcoming.map(renderItem),
  165. complete.length > 0 && completedTasksHeading,
  166. completeList,
  167. ];
  168. return (
  169. <TaskSidebarPanel collapsed={collapsed} hidePanel={onClose} orientation={orientation}>
  170. <TopRight src={HighlightTopRight} />
  171. <ProgressHeader allTasks={allTasks} completedTasks={complete} />
  172. <TaskList>
  173. <AnimatePresence initial={false}>{items}</AnimatePresence>
  174. </TaskList>
  175. </TaskSidebarPanel>
  176. );
  177. }
  178. const TaskSidebarPanel = styled(SidebarPanel)`
  179. width: 450px;
  180. `;
  181. const AnimatedTaskItem = motion(Task);
  182. AnimatedTaskItem.defaultProps = {
  183. initial: 'initial',
  184. animate: 'animate',
  185. exit: 'exit',
  186. layout: true,
  187. variants: {
  188. initial: {
  189. opacity: 0,
  190. y: 40,
  191. },
  192. animate: {
  193. opacity: 1,
  194. y: 0,
  195. transition: testableTransition({
  196. delay: 0.8,
  197. when: 'beforeChildren',
  198. staggerChildren: 0.3,
  199. }),
  200. },
  201. exit: {
  202. y: 20,
  203. z: -10,
  204. opacity: 0,
  205. transition: {duration: 0.2},
  206. },
  207. },
  208. };
  209. const TaskList = styled('div')`
  210. display: grid;
  211. grid-auto-flow: row;
  212. gap: ${space(1)};
  213. margin: ${space(1)} ${space(4)} ${space(4)} ${space(4)};
  214. `;
  215. const CompleteList = styled('div')`
  216. display: grid;
  217. grid-auto-flow: row;
  218. > div {
  219. transition: border-radius 500ms;
  220. }
  221. > div:not(:first-of-type) {
  222. margin-top: -1px;
  223. border-top-left-radius: 0;
  224. border-top-right-radius: 0;
  225. }
  226. > div:not(:last-of-type) {
  227. border-bottom-left-radius: 0;
  228. border-bottom-right-radius: 0;
  229. }
  230. `;
  231. const TopRight = styled('img')`
  232. position: absolute;
  233. top: 0;
  234. right: 0;
  235. width: 60%;
  236. `;
  237. export default withOrganization(withProjects(OnboardingWizardSidebar));