Skip to content

Commit

Permalink
✨ introduce tours api
Browse files Browse the repository at this point in the history
  • Loading branch information
leeandher committed Feb 26, 2025
1 parent 6a6a462 commit 4b89586
Show file tree
Hide file tree
Showing 3 changed files with 419 additions and 0 deletions.
206 changes: 206 additions & 0 deletions static/app/components/tours/components.tsx
Original file line number Diff line number Diff line change
@@ -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<T extends TourEnumType>({
children,
initialState,
orderedStepIds,
tourContext: TourContext,
}: {
children: React.ReactNode;
initialState: Partial<TourState<T>>;
orderedStepIds: T[];
tourContext: React.Context<TourContextType<T>>;
}) {
const tourContext = useTourReducer<T>({initialState, orderedStepIds});
const isTourActive = tourContext.currentStep !== null;
return (
<TourContext.Provider value={tourContext}>
<BlurContainer>
{children}
{isTourActive && <BlurWindow />}
</BlurContainer>
</TourContext.Provider>
);
}

export interface TourElementProps<T extends TourEnumType>
extends Partial<UseOverlayProps> {
/**
* The content being focused during the tour.
*/
children: React.ReactNode;
/**
* The description of the tour step.
*/
description: string;
id: TourStep<T>['id'];
/**
* The title of the tour step.
*/
title: string;
/**
* The tour context.
*/
tourContext: TourContextType<T>;
}

export function TourElement<T extends TourEnumType>({
children,
id,
title,
description,
tourContext,
position,
}: TourElementProps<T>) {
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 (
<Fragment>
<div css={isOpen ? focusStyles : undefined} {...triggerProps}>
{children}
</div>
{isOpen ? (
<FocusScope autoFocus restoreFocus>
<PositionWrapper zIndex={theme.zIndex.tooltip} {...overlayProps}>
<TourOverlay animated>
<TopRow>
<div>
{stepCount}/{stepTotal}
</div>
<TourCloseButton
onClick={() => dispatch({type: 'END_TOUR'})}
icon={<IconClose style={{color: theme.inverted.textColor}} />}
aria-label={t('Close')}
borderless
size="sm"
/>
</TopRow>
<TitleRow>{title}</TitleRow>
<div>{description}</div>
<Flex justify="flex-end" gap={1}>
{hasPreviousStep && (
<ActionButton
size="xs"
onClick={() => dispatch({type: 'PREVIOUS_STEP'})}
>
{t('Previous')}
</ActionButton>
)}
{hasNextStep ? (
<ActionButton size="xs" onClick={() => dispatch({type: 'NEXT_STEP'})}>
{t('Next')}
</ActionButton>
) : (
<ActionButton size="xs" onClick={() => dispatch({type: 'END_TOUR'})}>
{t('Finish tour')}
</ActionButton>
)}
</Flex>
</TourOverlay>
</PositionWrapper>
</FocusScope>
) : null}
</Fragment>
);
}

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;
`;
46 changes: 46 additions & 0 deletions static/app/components/tours/issueDetails.tsx
Original file line number Diff line number Diff line change
@@ -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<TourContextType<IssueDetailsTour>>({
currentStep: null,
isAvailable: false,
orderedStepIds: ORDERED_ISSUE_DETAILS_TOUR_STEP_IDS,
dispatch: () => {},
});

export function useIssueDetailsTour(): TourContextType<IssueDetailsTour> {
return useContext(IssueDetailsTourContext);
}

export function IssueDetailsTourElement(
props: Omit<TourElementProps<IssueDetailsTour>, 'tourContext'>
) {
const tourContext = useIssueDetailsTour();
return <TourElement tourContext={tourContext} {...props} />;
}
Loading

0 comments on commit 4b89586

Please sign in to comment.