featureTourModal.tsx 6.3 KB

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