diff --git a/static/app/components/tours/components.tsx b/static/app/components/tours/components.tsx new file mode 100644 index 00000000000000..8af4e10ce93a0d --- /dev/null +++ b/static/app/components/tours/components.tsx @@ -0,0 +1,206 @@ +import {Fragment, useEffect} from 'react'; +import {css, useTheme} from '@emotion/react'; +import styled from '@emotion/styled'; +import {FocusScope} from '@react-aria/focus'; + +import {Button} from 'sentry/components/button'; +import {Flex} from 'sentry/components/container/flex'; +import {Overlay, PositionWrapper} from 'sentry/components/overlay'; +import { + type TourContextType, + type TourEnumType, + type TourState, + type TourStep, + useTourReducer, +} from 'sentry/components/tours/tourContext'; +import {IconClose} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import useOverlay, {type UseOverlayProps} from 'sentry/utils/useOverlay'; + +export function TourContextProvider({ + children, + initialState, + orderedStepIds, + tourContext: TourContext, +}: { + children: React.ReactNode; + initialState: Partial>; + orderedStepIds: T[]; + tourContext: React.Context>; +}) { + const tourContext = useTourReducer({initialState, orderedStepIds}); + const isTourActive = tourContext.currentStep !== null; + return ( + + + {children} + {isTourActive && } + + + ); +} + +export interface TourElementProps + extends Partial { + /** + * The content being focused during the tour. + */ + children: React.ReactNode; + /** + * The description of the tour step. + */ + description: string; + id: TourStep['id']; + /** + * The title of the tour step. + */ + title: string; + /** + * The tour context. + */ + tourContext: TourContextType; +} + +export function TourElement({ + children, + id, + title, + description, + tourContext, + position, +}: TourElementProps) { + const theme = useTheme(); + + const {dispatch, currentStep, orderedStepIds} = tourContext; + const stepCount = currentStep ? orderedStepIds.indexOf(id) + 1 : 0; + const stepTotal = orderedStepIds.length; + const hasPreviousStep = stepCount > 1; + const hasNextStep = stepCount < stepTotal; + const isOpen = currentStep?.id === id; + + const {triggerProps, triggerRef, overlayProps} = useOverlay({isOpen, position}); + const {current: element} = triggerRef; + + useEffect(() => { + dispatch({ + type: 'REGISTER_STEP', + step: {id, element}, + }); + }, [id, element, dispatch]); + + const focusStyles = css` + position: relative; + z-index: ${theme.zIndex.toast}; + &:after { + content: ''; + position: absolute; + inset: 0; + border-radius: ${theme.borderRadius}; + box-shadow: inset 0 0 0 3px ${theme.subText}; + } + `; + + return ( + +
+ {children} +
+ {isOpen ? ( + + + + +
+ {stepCount}/{stepTotal} +
+ dispatch({type: 'END_TOUR'})} + icon={} + aria-label={t('Close')} + borderless + size="sm" + /> +
+ {title} +
{description}
+ + {hasPreviousStep && ( + dispatch({type: 'PREVIOUS_STEP'})} + > + {t('Previous')} + + )} + {hasNextStep ? ( + dispatch({type: 'NEXT_STEP'})}> + {t('Next')} + + ) : ( + dispatch({type: 'END_TOUR'})}> + {t('Finish tour')} + + )} + +
+
+
+ ) : null} +
+ ); +} + +const BlurContainer = styled('div')` + position: relative; +`; + +const BlurWindow = styled('div')` + position: absolute; + inset: 0; + content: ''; + z-index: ${p => p.theme.zIndex.modal}; + user-select: none; + backdrop-filter: blur(3px); + overscroll-behavior: none; +`; + +const TourOverlay = styled(Overlay)` + display: flex; + flex-direction: column; + gap: ${space(0.75)}; + background: ${p => p.theme.inverted.surface400}; + padding: ${space(1.5)} ${space(2)}; + color: ${p => p.theme.inverted.textColor}; + border-radius: ${p => p.theme.borderRadius}; + max-width: 360px; +`; + +const TourCloseButton = styled(Button)` + display: block; + padding: 0; + height: 14px; +`; + +const TopRow = styled('div')` + display: flex; + height: 18px; + justify-content: space-between; + align-items: center; + color: ${p => p.theme.inverted.textColor}; + font-size: ${p => p.theme.fontSizeSmall}; + font-weight: ${p => p.theme.fontWeightBold}; + opacity: 0.6; +`; + +const TitleRow = styled('div')` + font-size: ${p => p.theme.fontSizeExtraLarge}; + font-weight: ${p => p.theme.fontWeightBold}; +`; + +const ActionButton = styled(Button)` + font-size: ${p => p.theme.fontSizeSmall}; + color: ${p => p.theme.textColor}; + background: ${p => p.theme.surface400}; + border: 0; +`; diff --git a/static/app/components/tours/issueDetails.tsx b/static/app/components/tours/issueDetails.tsx new file mode 100644 index 00000000000000..8e8c95affdcc0d --- /dev/null +++ b/static/app/components/tours/issueDetails.tsx @@ -0,0 +1,46 @@ +import {createContext, useContext} from 'react'; + +import {TourElement, type TourElementProps} from 'sentry/components/tours/components'; +import type {TourContextType} from 'sentry/components/tours/tourContext'; + +export const enum IssueDetailsTour { + /** Onboarding for trends and aggregates, the graph, and tag distributions */ + ISSUE_DETAILS_AGGREGATES = 'issue-details-aggregates', + /** Onboarding for date/time/environment filters */ + ISSUE_DETAILS_FILTERS = 'issue-details-filters', + /** Onboarding for event details, event navigation, main page content */ + ISSUE_DETAILS_EVENT_DETAILS = 'issue-details-event-details', + /** Onboarding for event navigation; next/previous, first/last/recommended events */ + ISSUE_DETAILS_NAVIGATION = 'issue-details-navigation', + /** Onboarding for workflow actions; resolution, archival, assignment, priority, etc. */ + ISSUE_DETAILS_WORKFLOWS = 'issue-details-workflows', + /** Onboarding for activity log, issue tracking, solutions hub area */ + ISSUE_DETAILS_SIDEBAR = 'issue-details-sidebar', +} + +export const ORDERED_ISSUE_DETAILS_TOUR_STEP_IDS = [ + IssueDetailsTour.ISSUE_DETAILS_AGGREGATES, + IssueDetailsTour.ISSUE_DETAILS_FILTERS, + IssueDetailsTour.ISSUE_DETAILS_EVENT_DETAILS, + IssueDetailsTour.ISSUE_DETAILS_NAVIGATION, + IssueDetailsTour.ISSUE_DETAILS_WORKFLOWS, + IssueDetailsTour.ISSUE_DETAILS_SIDEBAR, +]; + +export const IssueDetailsTourContext = createContext>({ + currentStep: null, + isAvailable: false, + orderedStepIds: ORDERED_ISSUE_DETAILS_TOUR_STEP_IDS, + dispatch: () => {}, +}); + +export function useIssueDetailsTour(): TourContextType { + return useContext(IssueDetailsTourContext); +} + +export function IssueDetailsTourElement( + props: Omit, 'tourContext'> +) { + const tourContext = useIssueDetailsTour(); + return ; +} diff --git a/static/app/components/tours/tourContext.tsx b/static/app/components/tours/tourContext.tsx new file mode 100644 index 00000000000000..4b061b8aa67f05 --- /dev/null +++ b/static/app/components/tours/tourContext.tsx @@ -0,0 +1,167 @@ +import {type Dispatch, type Reducer, useCallback, useState} from 'react'; +import {useReducer} from 'react'; + +export type TourEnumType = string | number; + +export interface TourStep { + /** + * The element to focus on when the tour step is active. + * This is usually set within the TourElement component, which wraps the focused element. + */ + element: HTMLElement | null; + /** + * Unique ID for the tour step. + */ + id: T; +} + +type TourStartAction = { + type: 'START_TOUR'; + stepId?: T; +}; +type TourNextStepAction = { + type: 'NEXT_STEP'; +}; +type TourPreviousStepAction = { + type: 'PREVIOUS_STEP'; +}; +type TourEndAction = { + type: 'END_TOUR'; +}; +type TourRegisterStepAction = { + step: TourStep; + type: 'REGISTER_STEP'; +}; + +export type TourAction = + | TourStartAction + | TourNextStepAction + | TourPreviousStepAction + | TourEndAction + | TourRegisterStepAction; + +export interface TourState { + /** + * The current active tour step. If this is null, the tour is not active. + */ + currentStep: TourStep | null; + /** + * Whether the tour is available to the user. Should be set by flags or other conditions. + */ + isAvailable: boolean; + /** + * The ordered step IDs. Declared once when the provider is initialized. + */ + orderedStepIds: readonly T[]; +} + +type TourRegistry = { + [key in T]: TourStep | null; +}; + +export function useTourReducer({ + initialState, + orderedStepIds, +}: { + initialState: Partial>; + orderedStepIds: T[]; +}): TourContextType { + const initState = { + orderedStepIds, + currentStep: null, + isAvailable: false, + ...initialState, + }; + const [registry, setRegistry] = useState>( + orderedStepIds.reduce((reg, stepId) => { + reg[stepId] = null; + return reg; + }, {} as TourRegistry) + ); + + const isCompletelyRegistered = Object.values(registry).every(Boolean); + const reducer: Reducer, TourAction> = useCallback( + (state, action) => { + const completeTourState = { + ...state, + isActive: false, + currentStep: null, + }; + switch (action.type) { + case 'REGISTER_STEP': { + setRegistry(prev => ({...prev, [action.step.id]: action.step})); + return state; + } + case 'START_TOUR': { + // If the tour is not available, or not all steps are registered, do nothing + if (!state.isAvailable || !isCompletelyRegistered) { + return state; + } + + // If the stepId is provided, set the current step to the stepId + const startStepIndex = action.stepId + ? orderedStepIds.indexOf(action.stepId) + : -1; + if (action.stepId && startStepIndex !== -1) { + return { + ...state, + currentStep: registry[action.stepId] ?? null, + }; + } + // If no stepId is provided, set the current step to the first step + if (orderedStepIds[0]) { + return { + ...state, + currentStep: registry[orderedStepIds[0]] ?? null, + }; + } + + return state; + } + case 'NEXT_STEP': { + if (!state.currentStep) { + return state; + } + const nextStepIndex = orderedStepIds.indexOf(state.currentStep.id) + 1; + const nextStepId = orderedStepIds[nextStepIndex]; + if (nextStepId) { + return { + ...state, + currentStep: registry[nextStepId] ?? null, + }; + } + // If there is no next step, complete the tour + return completeTourState; + } + case 'PREVIOUS_STEP': { + if (!state.currentStep) { + return state; + } + const prevStepIndex = orderedStepIds.indexOf(state.currentStep.id) - 1; + const prevStepId = orderedStepIds[prevStepIndex]; + if (prevStepId) { + return { + ...state, + currentStep: registry[prevStepId] ?? null, + }; + } + // If there is no previous step, do nothing + return state; + } + case 'END_TOUR': + return completeTourState; + default: + return state; + } + }, + [registry, setRegistry, orderedStepIds, isCompletelyRegistered] + ); + + const [tour, dispatch] = useReducer(reducer, initState); + + return {...tour, dispatch}; +} + +export interface TourContextType extends TourState { + dispatch: Dispatch>; +}