diff --git a/static/app/components/guidedSteps/guidedSteps.spec.tsx b/static/app/components/guidedSteps/guidedSteps.spec.tsx new file mode 100644 index 00000000000000..4d3d629b6dcc91 --- /dev/null +++ b/static/app/components/guidedSteps/guidedSteps.spec.tsx @@ -0,0 +1,68 @@ +import {render, screen, userEvent, within} from 'sentry-test/reactTestingLibrary'; + +import {GuidedSteps} from 'sentry/components/guidedSteps/guidedSteps'; + +describe('GuidedSteps', function () { + it('can navigate through steps and shows previous ones as completed', async function () { + render( + + + This is the first step. + + + + This is the second step. + + + + This is the third step. + + + + ); + + expect(screen.getByText('This is the first step.')).toBeInTheDocument(); + expect(screen.queryByText('This is the second step.')).not.toBeInTheDocument(); + expect(screen.queryByText('This is the third step.')).not.toBeInTheDocument(); + + await userEvent.click(screen.getByText('Next')); + + expect(screen.queryByText('This is the first step.')).not.toBeInTheDocument(); + expect(screen.getByText('This is the second step.')).toBeInTheDocument(); + expect(screen.queryByText('This is the third step.')).not.toBeInTheDocument(); + + // First step is shown as completed + expect( + within(screen.getByTestId('guided-step-1')).getByTestId('icon-check-mark') + ).toBeInTheDocument(); + }); + + it('starts at the first incomplete step', function () { + render( + + + This is the first step. + + + + This is the second step. + + + + This is the third step. + + + + ); + + // First step is shown as completed + expect( + within(screen.getByTestId('guided-step-1')).getByTestId('icon-check-mark') + ).toBeInTheDocument(); + + // Second step is shown as active + expect(screen.queryByText('This is the first step.')).not.toBeInTheDocument(); + expect(screen.getByText('This is the second step.')).toBeInTheDocument(); + expect(screen.queryByText('This is the third step.')).not.toBeInTheDocument(); + }); +}); diff --git a/static/app/components/guidedSteps/guidedSteps.stories.tsx b/static/app/components/guidedSteps/guidedSteps.stories.tsx new file mode 100644 index 00000000000000..0e4600a9539a65 --- /dev/null +++ b/static/app/components/guidedSteps/guidedSteps.stories.tsx @@ -0,0 +1,114 @@ +import {Fragment} from 'react'; + +import {Button} from 'sentry/components/button'; +import { + GuidedSteps, + useGuidedStepsContext, +} from 'sentry/components/guidedSteps/guidedSteps'; +import JSXNode from 'sentry/components/stories/jsxNode'; +import SizingWindow from 'sentry/components/stories/sizingWindow'; +import storyBook from 'sentry/stories/storyBook'; + +export default storyBook(GuidedSteps, story => { + story('Default', () => ( + +

+ To create a GuideStep component, you should use as + the container and as direct children. +

+

+ You have complete control over what to render in the step titles and step content. + You may use to render the default + back/next buttons, but can also render your own. +

+ + + + This is the first step. + + + + This is the second step. + + + + This is the third step. + + + + +
+ )); + + story('Custom button behavior', () => { + function SkipToLastButton() { + const {setCurrentStep, totalSteps} = useGuidedStepsContext(); + return ( + + + + ); + } + + return ( + +

+ A hook is provided to access and control the step state:{' '} + useGuidedStepsContext(). This can be used to create step buttons + with custom behavior. +

+ + + + This is the first step. + + + + This is the second step. + + + + This is the third step. + + + + +
+ ); + }); + + story('Controlling completed state', () => { + return ( + +

+ By default, previous steps are considered completed. However, if the completed + state is known it can be controlled using the isCompleted property + on . The GuidedStep component will begin on + the first incomplete step. +

+ + + + Congrats, you finished the first step! + + + + You haven't completed the second step yet, here's how you do it. + + + + + + You haven't completed the third step yet, here's how you do it. + + + + + + +
+ ); + }); +}); diff --git a/static/app/components/guidedSteps/guidedSteps.tsx b/static/app/components/guidedSteps/guidedSteps.tsx new file mode 100644 index 00000000000000..fe3845c7b7a4c3 --- /dev/null +++ b/static/app/components/guidedSteps/guidedSteps.tsx @@ -0,0 +1,221 @@ +import { + Children, + createContext, + isValidElement, + useCallback, + useContext, + useMemo, + useState, +} from 'react'; +import styled from '@emotion/styled'; + +import {type BaseButtonProps, Button} from 'sentry/components/button'; +import {IconCheckmark} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; + +type GuidedStepsProps = { + children: React.ReactElement | React.ReactElement[]; + className?: string; + onStepChange?: (step: number) => void; +}; + +interface GuidedStepsContextState { + currentStep: number; + setCurrentStep: (step: number) => void; + totalSteps: number; +} + +interface StepProps { + children: React.ReactNode; + title: string; + isCompleted?: boolean; + stepNumber?: number; +} + +const GuidedStepsContext = createContext({ + currentStep: 0, + setCurrentStep: () => {}, + totalSteps: 0, +}); + +export function useGuidedStepsContext() { + return useContext(GuidedStepsContext); +} + +function Step({ + stepNumber = 1, + title, + children, + isCompleted: completedOverride, +}: StepProps) { + const {currentStep} = useGuidedStepsContext(); + const isActive = currentStep === stepNumber; + const isCompleted = completedOverride ?? currentStep > stepNumber; + + return ( + + {stepNumber} +
+ + {title} + {isCompleted && } + + {isActive && {children}} +
+
+ ); +} + +function BackButton({children, ...props}: BaseButtonProps) { + const {currentStep, setCurrentStep} = useGuidedStepsContext(); + + if (currentStep === 1) { + return null; + } + + return ( + + ); +} + +function NextButton({children, ...props}: BaseButtonProps) { + const {currentStep, setCurrentStep, totalSteps} = useGuidedStepsContext(); + + if (currentStep >= totalSteps) { + return null; + } + + return ( + + ); +} + +function StepButtons() { + return ( + + + + + ); +} + +export function GuidedSteps({className, children, onStepChange}: GuidedStepsProps) { + const [currentStep, setCurrentStep] = useState(() => { + // If `isCompleted` has been passed in, we should start at the first incomplete step + const firstIncompleteStepIndex = Children.toArray(children).findIndex(child => + isValidElement(child) ? child.props.isCompleted !== true : false + ); + + return Math.max(1, firstIncompleteStepIndex + 1); + }); + + const totalSteps = Children.count(children); + const handleSetCurrentStep = useCallback( + (step: number) => { + setCurrentStep(step); + onStepChange?.(step); + }, + [onStepChange] + ); + + const value = useMemo( + () => ({ + currentStep, + setCurrentStep: handleSetCurrentStep, + totalSteps, + }), + [currentStep, handleSetCurrentStep, totalSteps] + ); + + return ( + + + {Children.map(children, (child, index) => { + if (!child) { + return null; + } + + return ; + })} + + + ); +} + +const StepButtonsWrapper = styled('div')` + display: flex; + flex-wrap: wrap; + gap: ${space(1)}; + margin-top: ${space(1.5)}; +`; + +const StepsWrapper = styled('div')` + background: ${p => p.theme.background}; + display: flex; + flex-direction: column; + gap: ${space(2)}; +`; + +const StepWrapper = styled('div')` + display: grid; + grid-template-columns: 34px 1fr; + gap: ${space(1.5)}; + position: relative; + + :not(:last-child)::before { + content: ''; + position: absolute; + height: calc(100% + ${space(2)}); + width: 1px; + background: ${p => p.theme.border}; + left: 17px; + } +`; + +const StepNumber = styled('div')<{isActive: boolean}>` + position: relative; + z-index: 2; + font-size: ${p => p.theme.fontSizeLarge}; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; + height: 34px; + width: 34px; + line-height: 34px; + border-radius: 50%; + background: ${p => (p.isActive ? p.theme.purple300 : p.theme.gray100)}; + color: ${p => (p.isActive ? p.theme.white : p.theme.subText)}; + border: 4px solid ${p => p.theme.background}; +`; + +const StepHeading = styled('h4')<{isActive: boolean}>` + line-height: 34px; + margin: 0; + font-weight: bold; + font-size: ${p => p.theme.fontSizeLarge}; + color: ${p => (p.isActive ? p.theme.textColor : p.theme.subText)}; +`; + +const StepDoneIcon = styled(IconCheckmark, { + shouldForwardProp: prop => prop !== 'isActive', +})<{isActive: boolean}>` + color: ${p => (p.isActive ? p.theme.successText : p.theme.subText)}; + margin-left: ${space(1)}; + vertical-align: middle; +`; + +const ChildrenWrapper = styled('div')<{isActive: boolean}>` + color: ${p => (p.isActive ? p.theme.textColor : p.theme.subText)}; +`; + +GuidedSteps.Step = Step; +GuidedSteps.BackButton = BackButton; +GuidedSteps.NextButton = NextButton; +GuidedSteps.StepButtons = StepButtons; +GuidedSteps.ButtonWrapper = StepButtonsWrapper;