From dfa10eb060996f4bf022c1c2f6d8e06eae435878 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Tue, 7 Jan 2025 08:23:13 +0100 Subject: [PATCH] ref(quick-start): Add task completion animation (#82523) --- .../onboardingWizard/newSidebar.spec.tsx | 13 +- .../onboardingWizard/newSidebar.tsx | 128 ++++++++++++++---- .../sidebar/newOnboardingStatus.spec.tsx | 12 +- .../sidebar/newOnboardingStatus.tsx | 39 +----- 4 files changed, 113 insertions(+), 79 deletions(-) diff --git a/static/app/components/onboardingWizard/newSidebar.spec.tsx b/static/app/components/onboardingWizard/newSidebar.spec.tsx index 937f13dfefdac0..72dab3db33133f 100644 --- a/static/app/components/onboardingWizard/newSidebar.spec.tsx +++ b/static/app/components/onboardingWizard/newSidebar.spec.tsx @@ -89,6 +89,7 @@ describe('NewSidebar', function () { collapsed={false} gettingStartedTasks={gettingStartedTasks.map(task => ({ ...task, + completionSeen: true, status: 'complete', }))} beyondBasicsTasks={beyondBasicsTasks} @@ -130,6 +131,7 @@ describe('NewSidebar', function () { // Tasks from the second group should be visible expect(await screen.findByText(beyondBasicsTasks[0]!.title)).toBeInTheDocument(); + // Click skip task await userEvent.click(screen.getByRole('button', {name: 'Skip Task'})); // Confirmation to skip should be visible @@ -146,6 +148,13 @@ describe('NewSidebar', function () { expect(screen.getByRole('link', {name: 'Join our Discord'})).toBeInTheDocument(); expect(screen.getByRole('link', {name: 'Visit Help Center'})).toBeInTheDocument(); + // Dismiss skip confirmation + await userEvent.click(screen.getByRole('button', {name: 'Dismiss Skip'})); + expect(screen.queryByText(/Not sure what to do/)).not.toBeInTheDocument(); + + // Click skip task again + await userEvent.click(screen.getByRole('button', {name: 'Skip Task'})); + // Click 'Just Skip' await userEvent.click(screen.getByRole('button', {name: 'Just Skip'})); await waitFor(() => { @@ -159,9 +168,5 @@ describe('NewSidebar', function () { }) ); }); - - // Dismiss skip confirmation - await userEvent.click(screen.getByRole('button', {name: 'Dismiss Skip'})); - expect(screen.queryByText(/Not sure what to do/)).not.toBeInTheDocument(); }); }); diff --git a/static/app/components/onboardingWizard/newSidebar.tsx b/static/app/components/onboardingWizard/newSidebar.tsx index 719d245a30ed65..5909e9e6cd9ceb 100644 --- a/static/app/components/onboardingWizard/newSidebar.tsx +++ b/static/app/components/onboardingWizard/newSidebar.tsx @@ -1,7 +1,7 @@ import {Fragment, useCallback, useEffect, useMemo, useState} from 'react'; import {css, useTheme} from '@emotion/react'; import styled from '@emotion/styled'; -import {motion} from 'framer-motion'; +import {AnimatePresence, motion} from 'framer-motion'; import partition from 'lodash/partition'; import HighlightTopRight from 'sentry-images/pattern/highlight-top-right.svg'; @@ -14,7 +14,7 @@ import {Chevron} from 'sentry/components/chevron'; import {DropdownMenu} from 'sentry/components/dropdownMenu'; import InteractionStateLayer from 'sentry/components/interactionStateLayer'; import type {useOnboardingTasks} from 'sentry/components/onboardingWizard/useOnboardingTasks'; -import {taskIsDone} from 'sentry/components/onboardingWizard/utils'; +import {findCompleteTasks, taskIsDone} from 'sentry/components/onboardingWizard/utils'; import ProgressRing from 'sentry/components/progressRing'; import SidebarPanel from 'sentry/components/sidebar/sidebarPanel'; import type {CommonSidebarProps} from 'sentry/components/sidebar/types'; @@ -34,6 +34,7 @@ import {space} from 'sentry/styles/space'; import {type OnboardingTask, OnboardingTaskKey} from 'sentry/types/onboarding'; import {trackAnalytics} from 'sentry/utils/analytics'; import {isDemoModeEnabled} from 'sentry/utils/demoMode'; +import testableTransition from 'sentry/utils/testableTransition'; import useApi from 'sentry/utils/useApi'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; import useOrganization from 'sentry/utils/useOrganization'; @@ -57,7 +58,9 @@ const orderedBeyondBasicsTasks = [ ]; function groupTasksByCompletion(tasks: OnboardingTask[]) { - const [completedTasks, incompletedTasks] = partition(tasks, task => taskIsDone(task)); + const [completedTasks, incompletedTasks] = partition(tasks, task => + findCompleteTasks(task) + ); return { completedTasks, incompletedTasks, @@ -129,7 +132,7 @@ function TaskStatusIcon({status, tooltipText, progress}: TaskStatusIconProps) { `} /> ) : status === 'pending' ? ( - + {showSkipConfirmation && ( handleMarkSkipped(task.task)} + onConfirm={() => { + handleMarkSkipped(task.task); + setShowSkipConfirmation(false); + }} onDismiss={() => setShowSkipConfirmation(false)} /> )} @@ -373,6 +379,56 @@ function Task({task, hidePanel, showWaitingIndicator}: TaskProps) { ); } +interface ExpandedTaskGroupProps { + hidePanel: () => void; + sortedTasks: OnboardingTask[]; + taskKeyForWaitingIndicator: OnboardingTaskKey | undefined; +} + +function ExpandedTaskGroup({ + sortedTasks, + hidePanel, + taskKeyForWaitingIndicator, +}: ExpandedTaskGroupProps) { + const api = useApi(); + const organization = useOrganization(); + + const markTasksAsSeen = useCallback(() => { + const unseenDoneTasks = sortedTasks + .filter(task => taskIsDone(task) && !task.completionSeen) + .map(task => task.task); + + for (const unseenDoneTask of unseenDoneTasks) { + updateOnboardingTask(api, organization, { + task: unseenDoneTask, + completionSeen: true, + }); + } + }, [api, organization, sortedTasks]); + + useEffect(() => { + markTasksAsSeen(); + }, []); // eslint-disable-line react-hooks/exhaustive-deps + + return ( + +
+ + + {sortedTasks.map(sortedTask => ( + + ))} + + +
+ ); +} + interface TaskGroupProps { /** * Used for analytics @@ -398,11 +454,16 @@ function TaskGroup({ const organization = useOrganization(); const [isExpanded, setIsExpanded] = useState(expanded); const {completedTasks, incompletedTasks} = groupTasksByCompletion(tasks); + const [taskGroupComplete, setTaskGroupComplete] = useLocalStorageState( `quick-start:${organization.slug}:${group}-completed`, false ); + const doneTasks = useMemo(() => { + return tasks.filter(task => taskIsDone(task)); + }, [tasks]); + useEffect(() => { setIsExpanded(expanded); }, [expanded]); @@ -434,20 +495,20 @@ function TaskGroup({ description={ tasks.length > 1 ? tct('[totalCompletedTasks] out of [totalTasks] tasks completed', { - totalCompletedTasks: completedTasks.length, + totalCompletedTasks: doneTasks.length, totalTasks: tasks.length, }) : tct('[totalCompletedTasks] out of [totalTasks] task completed', { - totalCompletedTasks: completedTasks.length, + totalCompletedTasks: doneTasks.length, totalTasks: tasks.length, }) } - hasProgress={completedTasks.length > 0} + hasProgress={doneTasks.length > 0} onClick={toggleable ? () => setIsExpanded(!isExpanded) : undefined} icon={ } actions={ @@ -461,22 +522,11 @@ function TaskGroup({ } /> {isExpanded && ( - -
- - {incompletedTasks.map(task => ( - - ))} - {completedTasks.map(task => ( - - ))} - -
+ )} ); @@ -592,7 +642,7 @@ const TaskGroupHeader = styled(TaskCard)<{hasProgress: boolean}>` } `; -const TaskGroupBody = styled(motion.ul)` +const TaskGroupBody = styled('ul')` border-radius: ${p => p.theme.borderRadius}; list-style-type: none; padding: 0; @@ -601,8 +651,30 @@ const TaskGroupBody = styled(motion.ul)` const TaskWrapper = styled(motion.li)` gap: ${space(1)}; + background-color: ${p => p.theme.background}; `; +TaskWrapper.defaultProps = { + initial: false, + animate: 'animate', + layout: true, + variants: { + initial: { + opacity: 0, + y: 40, + }, + animate: { + opacity: 1, + y: 0, + transition: testableTransition({ + delay: 0.8, + when: 'beforeChildren', + staggerChildren: 0.3, + }), + }, + }, +}; + const TaskActions = styled('div')` display: flex; flex-direction: column; diff --git a/static/app/components/sidebar/newOnboardingStatus.spec.tsx b/static/app/components/sidebar/newOnboardingStatus.spec.tsx index 466f0dd7bff180..a0e7f8df3718a5 100644 --- a/static/app/components/sidebar/newOnboardingStatus.spec.tsx +++ b/static/app/components/sidebar/newOnboardingStatus.spec.tsx @@ -17,13 +17,7 @@ function renderMockRequests(organization: Organization) { }, }); - const postOnboardingTasksMock = MockApiClient.addMockResponse({ - url: `/organizations/${organization.slug}/onboarding-tasks/`, - method: 'POST', - body: {task: OnboardingTaskKey.FIRST_PROJECT, completionSeen: true}, - }); - - return {getOnboardingTasksMock, postOnboardingTasksMock}; + return {getOnboardingTasksMock}; } describe('Onboarding Status', function () { @@ -41,8 +35,7 @@ describe('Onboarding Status', function () { ], }); - const {getOnboardingTasksMock, postOnboardingTasksMock} = - renderMockRequests(organization); + const {getOnboardingTasksMock} = renderMockRequests(organization); const handleShowPanel = jest.fn(); @@ -69,7 +62,6 @@ describe('Onboarding Status', function () { // Open the panel await userEvent.click(screen.getByRole('button', {name: 'Onboarding'})); await waitFor(() => expect(getOnboardingTasksMock).toHaveBeenCalled()); - await waitFor(() => expect(postOnboardingTasksMock).toHaveBeenCalled()); expect(handleShowPanel).toHaveBeenCalled(); }); diff --git a/static/app/components/sidebar/newOnboardingStatus.tsx b/static/app/components/sidebar/newOnboardingStatus.tsx index 6241c333812957..cf81ada3a85296 100644 --- a/static/app/components/sidebar/newOnboardingStatus.tsx +++ b/static/app/components/sidebar/newOnboardingStatus.tsx @@ -1,9 +1,8 @@ -import {Fragment, useCallback, useContext, useEffect, useMemo, useRef} from 'react'; +import {Fragment, useCallback, useContext, useEffect} from 'react'; import type {Theme} from '@emotion/react'; import {css} from '@emotion/react'; import styled from '@emotion/styled'; -import {updateOnboardingTask} from 'sentry/actionCreators/onboardingTasks'; import {OnboardingContext} from 'sentry/components/onboarding/onboardingContext'; import {DeprecatedNewOnboardingSidebar} from 'sentry/components/onboardingWizard/deprecatedNewSidebar'; import {NewOnboardingSidebar} from 'sentry/components/onboardingWizard/newSidebar'; @@ -12,7 +11,6 @@ import {useOnboardingTasks} from 'sentry/components/onboardingWizard/useOnboardi import { findCompleteTasks, hasQuickStartUpdatesFeatureGA, - taskIsDone, } from 'sentry/components/onboardingWizard/utils'; import ProgressRing, { RingBackground, @@ -25,7 +23,6 @@ import {space} from 'sentry/styles/space'; import {trackAnalytics} from 'sentry/utils/analytics'; import {isDemoModeEnabled} from 'sentry/utils/demoMode'; import theme from 'sentry/utils/theme'; -import useApi from 'sentry/utils/useApi'; import {useLocalStorageState} from 'sentry/utils/useLocalStorageState'; import useOrganization from 'sentry/utils/useOrganization'; import useProjects from 'sentry/utils/useProjects'; @@ -42,12 +39,10 @@ export function NewOnboardingStatus({ hidePanel, onShowPanel, }: NewOnboardingStatusProps) { - const api = useApi(); const organization = useOrganization(); const onboardingContext = useContext(OnboardingContext); const {projects} = useProjects(); const {shouldAccordionFloat} = useContext(ExpandedContext); - const hasMarkedUnseenTasksAsComplete = useRef(false); const [quickStartCompleted, setQuickStartCompleted] = useLocalStorageState( `quick-start:${organization.slug}:completed`, false @@ -84,23 +79,6 @@ export function NewOnboardingStatus({ const skipQuickStart = !organization.features?.includes('onboarding') || (allTasksCompleted && !isActive); - const unseenDoneTasks = useMemo( - () => - allTasks - .filter(task => taskIsDone(task) && !task.completionSeen) - .map(task => task.task), - [allTasks] - ); - - const markDoneTaskAsComplete = useCallback(() => { - for (const unseenDoneTask of unseenDoneTasks) { - updateOnboardingTask(api, organization, { - task: unseenDoneTask, - completionSeen: true, - }); - } - }, [api, organization, unseenDoneTasks]); - const handleShowPanel = useCallback(() => { if (!walkthrough && !isActive === true) { trackAnalytics('quick_start.opened', { @@ -109,10 +87,8 @@ export function NewOnboardingStatus({ }); } - markDoneTaskAsComplete(); - onShowPanel(); - }, [onShowPanel, isActive, walkthrough, markDoneTaskAsComplete, organization]); + }, [onShowPanel, isActive, walkthrough, organization]); useEffect(() => { if (!allTasksCompleted || skipQuickStart || quickStartCompleted) { @@ -134,17 +110,6 @@ export function NewOnboardingStatus({ allTasksCompleted, ]); - useEffect(() => { - if (pendingCompletionSeen && isActive && !hasMarkedUnseenTasksAsComplete.current) { - markDoneTaskAsComplete(); - hasMarkedUnseenTasksAsComplete.current = true; - } - - if (!pendingCompletionSeen || !isActive) { - hasMarkedUnseenTasksAsComplete.current = false; - } - }, [isActive, pendingCompletionSeen, markDoneTaskAsComplete]); - if (skipQuickStart) { return null; }