Files
2024-02-15 17:01:50 +00:00

245 lines
6.2 KiB
TypeScript

import {Component, Fragment} from 'react';
import styled from '@emotion/styled';
import type {ModalRenderProps} from 'sentry/actionCreators/modal';
import {openModal} from 'sentry/actionCreators/modal';
import {Button} from 'sentry/components/button';
import ButtonBar from 'sentry/components/buttonBar';
import {IconClose} from 'sentry/icons';
import {t} from 'sentry/locale';
import {space} from 'sentry/styles/space';
export type TourStep = {
body: React.ReactNode;
title: string;
actions?: React.ReactNode;
image?: React.ReactNode;
};
type ChildProps = {
showModal: () => void;
};
type Props = {
children: (props: ChildProps) => React.ReactNode;
/**
* The list of tour steps.
* The FeatureTourModal will manage state on the active step.
*/
steps: TourStep[];
/**
* Customize the text shown on the done button.
*/
doneText?: string;
/**
* Provide a URL for the done state to open in a new tab.
*/
doneUrl?: string;
/**
* Triggered when the tour is advanced.
*/
onAdvance?: (currentIndex: number, durationOpen: number) => void;
/**
* Triggered when the tour is closed by completion or IconClose
*/
onCloseModal?: (currentIndex: number, durationOpen: number) => void;
};
type State = {
/**
* The last known step
*/
current: number;
/**
* The timestamp when the modal was shown.
* Used to calculate how long the modal was open
*/
openedAt: number;
};
const defaultProps = {
doneText: t('Done'),
};
/**
* Provide a showModal action to the child function that lets
* a tour be triggered.
*
* Once active this component will track when the tour was started and keep
* a last known step state. Ideally the state would live entirely in this component.
* However, once the modal has been opened state changes in this component don't
* trigger re-renders in the modal contents. This requires a bit of duplicate state
* to be managed around the current step.
*/
class FeatureTourModal extends Component<Props, State> {
static defaultProps = defaultProps;
state: State = {
openedAt: 0,
current: 0,
};
// Record the step change and call the callback this component was given.
handleAdvance = (current: number, duration: number) => {
this.setState({current});
this.props.onAdvance?.(current, duration);
};
handleShow = () => {
this.setState({openedAt: Date.now()}, () => {
const modalProps = {
steps: this.props.steps,
onAdvance: this.handleAdvance,
openedAt: this.state.openedAt,
doneText: this.props.doneText,
doneUrl: this.props.doneUrl,
};
openModal(deps => <ModalContents {...deps} {...modalProps} />, {
onClose: this.handleClose,
});
});
};
handleClose = () => {
// The bootstrap modal and modal store both call this callback.
// We use the state flag to deduplicate actions to upstream components.
if (this.state.openedAt === 0) {
return;
}
const {onCloseModal} = this.props;
const duration = Date.now() - this.state.openedAt;
onCloseModal?.(this.state.current, duration);
// Reset the state now that the modal is closed, used to deduplicate close actions.
this.setState({openedAt: 0, current: 0});
};
render() {
const {children} = this.props;
return <Fragment>{children({showModal: this.handleShow})}</Fragment>;
}
}
export default FeatureTourModal;
type ContentsProps = ModalRenderProps &
Pick<Props, 'steps' | 'doneText' | 'doneUrl' | 'onAdvance'> &
Pick<State, 'openedAt'>;
type ContentsState = {
current: number;
openedAt: number;
};
class ModalContents extends Component<ContentsProps, ContentsState> {
static defaultProps = defaultProps;
state: ContentsState = {
current: 0,
openedAt: Date.now(),
};
handleAdvance = () => {
const {onAdvance, openedAt} = this.props;
this.setState(
prevState => ({current: prevState.current + 1}),
() => {
const duration = Date.now() - openedAt;
onAdvance?.(this.state.current, duration);
}
);
};
render() {
const {Body, steps, doneText, doneUrl, closeModal} = this.props;
const {current} = this.state;
const step = steps[current] !== undefined ? steps[current] : steps[steps.length - 1];
const hasNext = steps[current + 1] !== undefined;
return (
<Body data-test-id="feature-tour">
<CloseButton
borderless
size="zero"
onClick={closeModal}
icon={<IconClose />}
aria-label={t('Close tour')}
/>
<TourContent>
{step.image}
<TourHeader>{step.title}</TourHeader>
{step.body}
<TourButtonBar gap={1}>
{step.actions && step.actions}
{hasNext && (
<Button priority="primary" onClick={this.handleAdvance}>
{t('Next')}
</Button>
)}
{!hasNext && (
<Button
external
href={doneUrl}
onClick={closeModal}
priority="primary"
aria-label={t('Complete tour')}
>
{doneText}
</Button>
)}
</TourButtonBar>
<StepCounter>{t('%s of %s', current + 1, steps.length)}</StepCounter>
</TourContent>
</Body>
);
}
}
const CloseButton = styled(Button)`
position: absolute;
top: -${space(2)};
right: -${space(1)};
`;
const TourContent = styled('div')`
display: flex;
flex-direction: column;
align-items: center;
margin: ${space(3)} ${space(4)} ${space(1)} ${space(4)};
`;
const TourHeader = styled('h4')`
margin-bottom: ${space(1)};
`;
const TourButtonBar = styled(ButtonBar)`
margin-bottom: ${space(3)};
`;
const StepCounter = styled('div')`
text-transform: uppercase;
font-size: ${p => p.theme.fontSizeSmall};
font-weight: bold;
color: ${p => p.theme.gray300};
`;
// Styled components that can be used to build tour content.
export const TourText = styled('p')`
text-align: center;
margin-bottom: ${space(4)};
`;
export const TourImage = styled('img')`
height: 200px;
margin-bottom: ${space(4)};
/** override styles in less files */
max-width: 380px !important;
box-shadow: none !important;
border: 0 !important;
border-radius: 0 !important;
`;