featureTourModal.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243
  1. import {Component, Fragment} from 'react';
  2. import styled from '@emotion/styled';
  3. import {ModalRenderProps, openModal} from 'sentry/actionCreators/modal';
  4. import {Button} from 'sentry/components/button';
  5. import ButtonBar from 'sentry/components/buttonBar';
  6. import {IconClose} from 'sentry/icons';
  7. import {t} from 'sentry/locale';
  8. import {space} from 'sentry/styles/space';
  9. export type TourStep = {
  10. body: React.ReactNode;
  11. title: string;
  12. actions?: React.ReactNode;
  13. image?: React.ReactNode;
  14. };
  15. type ChildProps = {
  16. showModal: () => void;
  17. };
  18. type Props = {
  19. children: (props: ChildProps) => React.ReactNode;
  20. /**
  21. * The list of tour steps.
  22. * The FeatureTourModal will manage state on the active step.
  23. */
  24. steps: TourStep[];
  25. /**
  26. * Customize the text shown on the done button.
  27. */
  28. doneText?: string;
  29. /**
  30. * Provide a URL for the done state to open in a new tab.
  31. */
  32. doneUrl?: string;
  33. /**
  34. * Triggered when the tour is advanced.
  35. */
  36. onAdvance?: (currentIndex: number, durationOpen: number) => void;
  37. /**
  38. * Triggered when the tour is closed by completion or IconClose
  39. */
  40. onCloseModal?: (currentIndex: number, durationOpen: number) => void;
  41. };
  42. type State = {
  43. /**
  44. * The last known step
  45. */
  46. current: number;
  47. /**
  48. * The timestamp when the modal was shown.
  49. * Used to calculate how long the modal was open
  50. */
  51. openedAt: number;
  52. };
  53. const defaultProps = {
  54. doneText: t('Done'),
  55. };
  56. /**
  57. * Provide a showModal action to the child function that lets
  58. * a tour be triggered.
  59. *
  60. * Once active this component will track when the tour was started and keep
  61. * a last known step state. Ideally the state would live entirely in this component.
  62. * However, once the modal has been opened state changes in this component don't
  63. * trigger re-renders in the modal contents. This requires a bit of duplicate state
  64. * to be managed around the current step.
  65. */
  66. class FeatureTourModal extends Component<Props, State> {
  67. static defaultProps = defaultProps;
  68. state: State = {
  69. openedAt: 0,
  70. current: 0,
  71. };
  72. // Record the step change and call the callback this component was given.
  73. handleAdvance = (current: number, duration: number) => {
  74. this.setState({current});
  75. this.props.onAdvance?.(current, duration);
  76. };
  77. handleShow = () => {
  78. this.setState({openedAt: Date.now()}, () => {
  79. const modalProps = {
  80. steps: this.props.steps,
  81. onAdvance: this.handleAdvance,
  82. openedAt: this.state.openedAt,
  83. doneText: this.props.doneText,
  84. doneUrl: this.props.doneUrl,
  85. };
  86. openModal(deps => <ModalContents {...deps} {...modalProps} />, {
  87. onClose: this.handleClose,
  88. });
  89. });
  90. };
  91. handleClose = () => {
  92. // The bootstrap modal and modal store both call this callback.
  93. // We use the state flag to deduplicate actions to upstream components.
  94. if (this.state.openedAt === 0) {
  95. return;
  96. }
  97. const {onCloseModal} = this.props;
  98. const duration = Date.now() - this.state.openedAt;
  99. onCloseModal?.(this.state.current, duration);
  100. // Reset the state now that the modal is closed, used to deduplicate close actions.
  101. this.setState({openedAt: 0, current: 0});
  102. };
  103. render() {
  104. const {children} = this.props;
  105. return <Fragment>{children({showModal: this.handleShow})}</Fragment>;
  106. }
  107. }
  108. export default FeatureTourModal;
  109. type ContentsProps = ModalRenderProps &
  110. Pick<Props, 'steps' | 'doneText' | 'doneUrl' | 'onAdvance'> &
  111. Pick<State, 'openedAt'>;
  112. type ContentsState = {
  113. current: number;
  114. openedAt: number;
  115. };
  116. class ModalContents extends Component<ContentsProps, ContentsState> {
  117. static defaultProps = defaultProps;
  118. state: ContentsState = {
  119. current: 0,
  120. openedAt: Date.now(),
  121. };
  122. handleAdvance = () => {
  123. const {onAdvance, openedAt} = this.props;
  124. this.setState(
  125. prevState => ({current: prevState.current + 1}),
  126. () => {
  127. const duration = Date.now() - openedAt;
  128. onAdvance?.(this.state.current, duration);
  129. }
  130. );
  131. };
  132. render() {
  133. const {Body, steps, doneText, doneUrl, closeModal} = this.props;
  134. const {current} = this.state;
  135. const step = steps[current] !== undefined ? steps[current] : steps[steps.length - 1];
  136. const hasNext = steps[current + 1] !== undefined;
  137. return (
  138. <Body data-test-id="feature-tour">
  139. <CloseButton
  140. borderless
  141. size="zero"
  142. onClick={closeModal}
  143. icon={<IconClose />}
  144. aria-label={t('Close tour')}
  145. />
  146. <TourContent>
  147. {step.image}
  148. <TourHeader>{step.title}</TourHeader>
  149. {step.body}
  150. <TourButtonBar gap={1}>
  151. {step.actions && step.actions}
  152. {hasNext && (
  153. <Button priority="primary" onClick={this.handleAdvance}>
  154. {t('Next')}
  155. </Button>
  156. )}
  157. {!hasNext && (
  158. <Button
  159. external
  160. href={doneUrl}
  161. onClick={closeModal}
  162. priority="primary"
  163. aria-label={t('Complete tour')}
  164. >
  165. {doneText}
  166. </Button>
  167. )}
  168. </TourButtonBar>
  169. <StepCounter>{t('%s of %s', current + 1, steps.length)}</StepCounter>
  170. </TourContent>
  171. </Body>
  172. );
  173. }
  174. }
  175. const CloseButton = styled(Button)`
  176. position: absolute;
  177. top: -${space(2)};
  178. right: -${space(1)};
  179. `;
  180. const TourContent = styled('div')`
  181. display: flex;
  182. flex-direction: column;
  183. align-items: center;
  184. margin: ${space(3)} ${space(4)} ${space(1)} ${space(4)};
  185. `;
  186. const TourHeader = styled('h4')`
  187. margin-bottom: ${space(1)};
  188. `;
  189. const TourButtonBar = styled(ButtonBar)`
  190. margin-bottom: ${space(3)};
  191. `;
  192. const StepCounter = styled('div')`
  193. text-transform: uppercase;
  194. font-size: ${p => p.theme.fontSizeSmall};
  195. font-weight: bold;
  196. color: ${p => p.theme.gray300};
  197. `;
  198. // Styled components that can be used to build tour content.
  199. export const TourText = styled('p')`
  200. text-align: center;
  201. margin-bottom: ${space(4)};
  202. `;
  203. export const TourImage = styled('img')`
  204. height: 200px;
  205. margin-bottom: ${space(4)};
  206. /** override styles in less files */
  207. max-width: 380px !important;
  208. box-shadow: none !important;
  209. border: 0 !important;
  210. border-radius: 0 !important;
  211. `;