import React, { Component, PropTypes } from 'react'; import { connect } from 'react-redux'; import { TransitionMotion, spring } from 'react-motion'; import { Panel, PanelBody, PanelFooter, PanelHeader } from 'components/ui/Panel'; import { Form } from 'components/ui/form'; import MeasureHeight from 'components/MeasureHeight'; import { helpLinks as helpLinksStyles } from 'components/auth/helpLinks.scss'; import panelStyles from 'components/ui/panel.scss'; import icons from 'components/ui/icons.scss'; import authFlow from 'services/authFlow'; import { userShape } from 'components/user/User'; import * as actions from './actions'; const opacitySpringConfig = {stiffness: 300, damping: 20}; const transformSpringConfig = {stiffness: 500, damping: 50, precision: 0.5}; const changeContextSpringConfig = {stiffness: 500, damping: 20, precision: 0.5}; /** * Definition of relation between contexts and panels * * Each sub-array is context. Each sub-array item is panel * * This definition declares animations between panels: * - The animation between panels from different contexts will be along Y axe (height toggling) * - The animation between panels from the same context will be along X axe (sliding) * - Panel index defines the direction of X transition of both panels * (e.g. the panel with lower index will slide from left side, and with greater from right side) */ const contexts = [ ['login', 'password', 'forgotPassword', 'recoverPassword'], ['register', 'activation'], ['changePassword'], ['permissions'] ]; if (process.env.NODE_ENV !== 'production') { // test panel uniquenes between contexts // TODO: it may be moved to tests in future contexts.reduce((acc, context) => { context.forEach((panel) => { if (acc[panel]) { throw new Error(`Panel ${panel} is already exists in context ${JSON.stringify(acc[panel])}`); } acc[panel] = context; }); return acc; }, {}); } class PanelTransition extends Component { static displayName = 'PanelTransition'; static propTypes = { // context props auth: PropTypes.shape({ error: PropTypes.string, isLoading: PropTypes.bool, login: PropTypes.shape({ login: PropTypes.string, password: PropTypes.string }) }).isRequired, user: userShape.isRequired, setError: PropTypes.func.isRequired, clearErrors: PropTypes.func.isRequired, resolve: PropTypes.func.isRequired, reject: PropTypes.func.isRequired, // local props Title: PropTypes.element, Body: PropTypes.element, Footer: PropTypes.element, Links: PropTypes.element, children: PropTypes.element }; static childContextTypes = { auth: PropTypes.shape({ error: PropTypes.string, login: PropTypes.shape({ login: PropTypes.string, password: PropTypes.string }) }), user: userShape, clearErrors: PropTypes.func, resolve: PropTypes.func, reject: PropTypes.func }; state = { contextHeight: 0, panelId: this.props.Body && this.props.Body.type.panelId }; getChildContext() { return { auth: this.props.auth, user: this.props.user, clearErrors: this.props.clearErrors, resolve: this.props.resolve, reject: this.props.reject }; } componentWillReceiveProps(nextProps) { const nextPanel = nextProps.Body && nextProps.Body.type.panelId; const prevPanel = this.props.Body && this.props.Body.type.panelId; if (nextPanel !== prevPanel) { const direction = this.getDirection(nextPanel, prevPanel); const forceHeight = direction === 'Y' && nextPanel !== prevPanel ? 1 : 0; this.props.clearErrors(); this.setState({ direction, panelId: nextPanel, prevPanelId: prevPanel, forceHeight }); if (forceHeight) { setTimeout(() => { this.setState({forceHeight: 0}); }, 100); } } } render() { const {contextHeight, forceHeight} = this.state; const {Title, Body, Footer, Links} = this.props; if (this.props.children) { return this.props.children; } else if (!Title || !Body || !Footer || !Links) { throw new Error('Title, Body, Footer and Links are required'); } const {panelId, hasGoBack} = Body.type; const formHeight = this.state[`formHeight${panelId}`] || 0; // a hack to disable height animation on first render const isHeightMeasured = this.isHeightMeasured; this.isHeightMeasured = isHeightMeasured || formHeight > 0; return ( {(items) => { const panels = items.filter(({key}) => key !== 'common'); const common = items.filter(({key}) => key === 'common')[0]; const contentHeight = { overflow: 'hidden', height: forceHeight ? common.style.switchContextHeightSpring : 'auto' }; this.tryToAutoFocus(panels.length); const bodyHeight = { position: 'relative', height: `${common.style.heightSpring}px` }; return (
{panels.map((config) => this.getHeader(config))}
{panels.map((config) => this.getBody(config))}
{panels.map((config) => this.getFooter(config))}
{panels.map((config) => this.getLinks(config))}
); }}
); } onFormSubmit = () => { this.props.clearErrors(); this.body.onFormSubmit(); }; onFormInvalid = (errors) => { this.props.setError(Object.values(errors).shift()); }; willEnter = (config) => this.getTransitionStyles(config); willLeave = (config) => this.getTransitionStyles(config, {isLeave: true}); /** * @param {Object} config * @param {string} config.key * @param {Object} [options] * @param {Object} [options.isLeave=false] - true, if this is a leave transition * * @return {Object} */ getTransitionStyles({key}, options = {}) { const {isLeave = false} = options; const {panelId, prevPanelId} = this.state; const fromLeft = -1; const fromRight = 1; const currentContext = contexts.find((context) => context.includes(key)); let sign = currentContext.indexOf(panelId) > currentContext.indexOf(prevPanelId) ? fromRight : fromLeft; if (prevPanelId === key) { sign *= -1; } const transform = sign * 100; return { transformSpring: isLeave ? spring(transform, transformSpringConfig) : transform, opacitySpring: isLeave ? spring(0, opacitySpringConfig) : 1 }; } getDirection(next, prev) { return contexts.find((context) => context.includes(prev)).includes(next) ? 'X' : 'Y'; } onUpdateHeight = (height, key) => { const heightKey = `formHeight${key}`; this.setState({ [heightKey]: height }); }; onUpdateContextHeight = (height) => { this.setState({ contextHeight: height }); }; onGoBack = (event) => { event.preventDefault(); authFlow.goBack(); }; /** * Tries to auto focus form fields after transition end * * @param {number} length number of panels transitioned */ tryToAutoFocus(length) { if (!this.body) { return; } if (length === 1) { if (!this.wasAutoFocused) { this.body.autoFocus(); } this.wasAutoFocused = true; } else if (this.wasAutoFocused) { this.wasAutoFocused = false; } } getHeader({key, style, data}) { const {Title, hasBackButton} = data; const {transformSpring} = style; style = { ...this.getDefaultTransitionStyles(key, style), opacity: 1 // reset default }; const scrollStyle = this.translate(transformSpring, 'Y'); const sideScrollStyle = { position: 'relative', zIndex: 2, ...this.translate(-Math.abs(transformSpring)) }; const backButton = ( ); return (
{hasBackButton ? backButton : null}
{Title}
); } getBody({key, style, data}) { const {Body} = data; const {transformSpring} = style; const {direction} = this.state; let transform = this.translate(transformSpring, direction); let verticalOrigin = 'top'; if (direction === 'Y') { verticalOrigin = 'bottom'; transform = {}; } style = { ...this.getDefaultTransitionStyles(key, style), top: 'auto', // reset default [verticalOrigin]: 0, ...transform }; return ( this.onUpdateHeight(height, key)} > {React.cloneElement(Body, { ref: (body) => { this.body = body; } })} ); } getFooter({key, style, data}) { const {Footer} = data; style = this.getDefaultTransitionStyles(key, style); return (
{Footer}
); } getLinks({key, style, data}) { const {Links} = data; style = this.getDefaultTransitionStyles(key, style); return (
{Links}
); } /** * @param {string} key * @param {Object} style * @param {number} style.opacitySpring * * @return {Object} */ getDefaultTransitionStyles(key, {opacitySpring}) { return { position: 'absolute', top: 0, left: 0, width: '100%', opacity: opacitySpring, pointerEvents: key === this.state.panelId ? 'auto' : 'none' }; } /** * @param {number} value * @param {string} direction='X' - X|Y * @param {string} unit='%' - %|px etc * * @return {Object} */ translate(value, direction = 'X', unit = '%') { return { WebkitTransform: `translate${direction}(${value}${unit})`, transform: `translate${direction}(${value}${unit})` }; } } export default connect((state) => ({ user: state.user, auth: state.auth, resolve: authFlow.resolve.bind(authFlow), reject: authFlow.reject.bind(authFlow) }), { clearErrors: actions.clearErrors, setError: actions.setError })(PanelTransition);