guidedSteps.tsx 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258
  1. import {
  2. createContext,
  3. useCallback,
  4. useContext,
  5. useEffect,
  6. useMemo,
  7. useRef,
  8. useState,
  9. } from 'react';
  10. import styled from '@emotion/styled';
  11. import orderBy from 'lodash/orderBy';
  12. import {type BaseButtonProps, Button} from 'sentry/components/button';
  13. import {IconCheckmark} from 'sentry/icons';
  14. import {t} from 'sentry/locale';
  15. import {space} from 'sentry/styles/space';
  16. type GuidedStepsProps = {
  17. children: React.ReactElement<StepProps> | React.ReactElement<StepProps>[];
  18. className?: string;
  19. onStepChange?: (step: number) => void;
  20. };
  21. interface GuidedStepsContextState {
  22. currentStep: number;
  23. getStepNumber: (stepKey: string) => number;
  24. registerStep: (step: RegisterStepInfo) => void;
  25. setCurrentStep: (step: number) => void;
  26. totalSteps: number;
  27. }
  28. interface StepProps {
  29. children: React.ReactNode;
  30. stepKey: string;
  31. title: string;
  32. isCompleted?: boolean;
  33. }
  34. type RegisterStepInfo = Pick<StepProps, 'stepKey' | 'isCompleted'>;
  35. type RegisteredSteps = {[key: string]: {stepNumber: number; isCompleted?: boolean}};
  36. const GuidedStepsContext = createContext<GuidedStepsContextState>({
  37. currentStep: 0,
  38. setCurrentStep: () => {},
  39. totalSteps: 0,
  40. registerStep: () => 0,
  41. getStepNumber: () => 0,
  42. });
  43. export function useGuidedStepsContext() {
  44. return useContext(GuidedStepsContext);
  45. }
  46. function useGuidedStepsContentValue({
  47. onStepChange,
  48. }: Pick<GuidedStepsProps, 'onStepChange'>): GuidedStepsContextState {
  49. const registeredStepsRef = useRef<RegisteredSteps>({});
  50. const [totalSteps, setTotalSteps] = useState<number>(0);
  51. const [currentStep, setCurrentStep] = useState<number>(1);
  52. // Steps are registered on initial render to determine the step order and which step to start on.
  53. // This allows Steps to be wrapped in other components, but does require that they exist on first
  54. // render and that step order does not change.
  55. const registerStep = useCallback((props: RegisterStepInfo) => {
  56. if (registeredStepsRef.current[props.stepKey]) {
  57. return;
  58. }
  59. const numRegisteredSteps = Object.keys(registeredStepsRef.current).length + 1;
  60. registeredStepsRef.current[props.stepKey] = {
  61. isCompleted: props.isCompleted,
  62. stepNumber: numRegisteredSteps,
  63. };
  64. setTotalSteps(numRegisteredSteps);
  65. }, []);
  66. const getStepNumber = useCallback((stepKey: string) => {
  67. return registeredStepsRef.current[stepKey]?.stepNumber ?? 1;
  68. }, []);
  69. // On initial load, set the current step to the first incomplete step
  70. useEffect(() => {
  71. const firstIncompleteStep = orderBy(
  72. Object.values(registeredStepsRef.current),
  73. 'stepNumber'
  74. ).find(step => step.isCompleted !== true);
  75. setCurrentStep(firstIncompleteStep?.stepNumber ?? 1);
  76. }, []);
  77. const handleSetCurrentStep = useCallback(
  78. (step: number) => {
  79. setCurrentStep(step);
  80. onStepChange?.(step);
  81. },
  82. [onStepChange]
  83. );
  84. return useMemo(
  85. () => ({
  86. currentStep,
  87. setCurrentStep: handleSetCurrentStep,
  88. totalSteps,
  89. registerStep,
  90. getStepNumber,
  91. }),
  92. [currentStep, getStepNumber, handleSetCurrentStep, registerStep, totalSteps]
  93. );
  94. }
  95. function Step(props: StepProps) {
  96. const {currentStep, registerStep, getStepNumber} = useGuidedStepsContext();
  97. const stepNumber = getStepNumber(props.stepKey);
  98. const isActive = currentStep === stepNumber;
  99. const isCompleted = props.isCompleted ?? currentStep > stepNumber;
  100. useEffect(() => {
  101. registerStep({isCompleted: props.isCompleted, stepKey: props.stepKey});
  102. }, [props.isCompleted, props.stepKey, registerStep]);
  103. return (
  104. <StepWrapper data-test-id={`guided-step-${stepNumber}`}>
  105. <StepNumber isActive={isActive}>{stepNumber}</StepNumber>
  106. <div>
  107. <StepHeading isActive={isActive}>
  108. {props.title}
  109. {isCompleted && <StepDoneIcon isActive={isActive} size="sm" />}
  110. </StepHeading>
  111. {isActive && (
  112. <ChildrenWrapper isActive={isActive}>{props.children}</ChildrenWrapper>
  113. )}
  114. </div>
  115. </StepWrapper>
  116. );
  117. }
  118. function BackButton({children, ...props}: BaseButtonProps) {
  119. const {currentStep, setCurrentStep} = useGuidedStepsContext();
  120. if (currentStep === 1) {
  121. return null;
  122. }
  123. return (
  124. <Button size="sm" onClick={() => setCurrentStep(currentStep - 1)} {...props}>
  125. {children ?? t('Back')}
  126. </Button>
  127. );
  128. }
  129. function NextButton({children, ...props}: BaseButtonProps) {
  130. const {currentStep, setCurrentStep, totalSteps} = useGuidedStepsContext();
  131. if (currentStep >= totalSteps) {
  132. return null;
  133. }
  134. return (
  135. <Button size="sm" onClick={() => setCurrentStep(currentStep + 1)} {...props}>
  136. {children ?? t('Next')}
  137. </Button>
  138. );
  139. }
  140. function StepButtons() {
  141. return (
  142. <StepButtonsWrapper>
  143. <BackButton />
  144. <NextButton />
  145. </StepButtonsWrapper>
  146. );
  147. }
  148. export function GuidedSteps({className, children, onStepChange}: GuidedStepsProps) {
  149. const value = useGuidedStepsContentValue({onStepChange});
  150. return (
  151. <GuidedStepsContext.Provider value={value}>
  152. <StepsWrapper className={className}>{children}</StepsWrapper>
  153. </GuidedStepsContext.Provider>
  154. );
  155. }
  156. const StepButtonsWrapper = styled('div')`
  157. display: flex;
  158. flex-wrap: wrap;
  159. gap: ${space(1)};
  160. margin-top: ${space(1.5)};
  161. `;
  162. const StepsWrapper = styled('div')`
  163. background: ${p => p.theme.background};
  164. display: flex;
  165. flex-direction: column;
  166. gap: ${space(2)};
  167. `;
  168. const StepWrapper = styled('div')`
  169. display: grid;
  170. grid-template-columns: 34px 1fr;
  171. gap: ${space(1.5)};
  172. position: relative;
  173. :not(:last-child)::before {
  174. content: '';
  175. position: absolute;
  176. height: calc(100% + ${space(2)});
  177. width: 1px;
  178. background: ${p => p.theme.border};
  179. left: 17px;
  180. }
  181. `;
  182. const StepNumber = styled('div')<{isActive: boolean}>`
  183. position: relative;
  184. z-index: 2;
  185. font-size: ${p => p.theme.fontSizeLarge};
  186. font-weight: bold;
  187. display: flex;
  188. align-items: center;
  189. justify-content: center;
  190. height: 34px;
  191. width: 34px;
  192. line-height: 34px;
  193. border-radius: 50%;
  194. background: ${p => (p.isActive ? p.theme.purple300 : p.theme.gray100)};
  195. color: ${p => (p.isActive ? p.theme.white : p.theme.subText)};
  196. border: 4px solid ${p => p.theme.background};
  197. `;
  198. const StepHeading = styled('h4')<{isActive: boolean}>`
  199. line-height: 34px;
  200. margin: 0;
  201. font-weight: bold;
  202. font-size: ${p => p.theme.fontSizeLarge};
  203. color: ${p => (p.isActive ? p.theme.textColor : p.theme.subText)};
  204. `;
  205. const StepDoneIcon = styled(IconCheckmark, {
  206. shouldForwardProp: prop => prop !== 'isActive',
  207. })<{isActive: boolean}>`
  208. color: ${p => (p.isActive ? p.theme.successText : p.theme.subText)};
  209. margin-left: ${space(1)};
  210. vertical-align: middle;
  211. `;
  212. const ChildrenWrapper = styled('div')<{isActive: boolean}>`
  213. color: ${p => (p.isActive ? p.theme.textColor : p.theme.subText)};
  214. p {
  215. margin-bottom: ${space(1)};
  216. }
  217. `;
  218. GuidedSteps.Step = Step;
  219. GuidedSteps.BackButton = BackButton;
  220. GuidedSteps.NextButton = NextButton;
  221. GuidedSteps.StepButtons = StepButtons;
  222. GuidedSteps.ButtonWrapper = StepButtonsWrapper;