sidebar.tsx 8.2 KB

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