mirror of
https://github.com/elyby/accounts-frontend.git
synced 2024-12-27 23:40:28 +05:30
584 lines
18 KiB
TypeScript
584 lines
18 KiB
TypeScript
import React, { CSSProperties, MouseEventHandler, ReactElement, ReactNode } from 'react';
|
|
|
|
import { AccountsState } from 'app/components/accounts';
|
|
import { User } from 'app/components/user';
|
|
import { connect } from 'app/functions';
|
|
import { TransitionMotion, spring, PlainStyle, Style, TransitionStyle, TransitionPlainStyle } from 'react-motion';
|
|
import { Panel, PanelBody, PanelFooter, PanelHeader } from 'app/components/ui/Panel';
|
|
import { Form } from 'app/components/ui/form';
|
|
import MeasureHeight from 'app/components/MeasureHeight';
|
|
import panelStyles from 'app/components/ui/panel.scss';
|
|
import icons from 'app/components/ui/icons.scss';
|
|
import authFlow from 'app/services/authFlow';
|
|
|
|
import { Provider as AuthContextProvider } from './Context';
|
|
import { getLogin, State as AuthState } from './reducer';
|
|
import * as actions from './actions';
|
|
import helpLinks from './helpLinks.scss';
|
|
|
|
const opacitySpringConfig = { stiffness: 300, damping: 20 };
|
|
const transformSpringConfig = { stiffness: 500, damping: 50, precision: 0.5 };
|
|
const changeContextSpringConfig = {
|
|
stiffness: 500,
|
|
damping: 20,
|
|
precision: 0.5,
|
|
};
|
|
|
|
const { helpLinks: helpLinksStyles } = helpLinks;
|
|
|
|
type PanelId = string;
|
|
|
|
/**
|
|
* 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: Array<Array<PanelId>> = [
|
|
['login', 'password', 'forgotPassword', 'mfa', 'recoverPassword'],
|
|
['register', 'activation', 'resendActivation'],
|
|
['acceptRules'],
|
|
['chooseAccount', 'permissions'],
|
|
];
|
|
|
|
// eslint-disable-next-line
|
|
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;
|
|
}, {} as Record<string, Array<PanelId>>);
|
|
}
|
|
|
|
type ValidationError =
|
|
| string
|
|
| {
|
|
type: string;
|
|
payload: Record<string, any>;
|
|
};
|
|
|
|
interface AnimationStyle extends PlainStyle {
|
|
opacitySpring: number;
|
|
transformSpring: number;
|
|
}
|
|
|
|
interface AnimationData {
|
|
Title: ReactElement;
|
|
Body: ReactElement;
|
|
Footer: ReactElement;
|
|
Links: ReactNode;
|
|
hasBackButton: boolean | ((props: Props) => boolean);
|
|
}
|
|
|
|
interface AnimationContext extends TransitionPlainStyle {
|
|
key: PanelId;
|
|
style: AnimationStyle;
|
|
data: AnimationData;
|
|
}
|
|
|
|
interface OwnProps {
|
|
Title: ReactElement;
|
|
Body: ReactElement;
|
|
Footer: ReactElement;
|
|
Links: ReactNode;
|
|
}
|
|
|
|
interface Props extends OwnProps {
|
|
// context props
|
|
auth: AuthState;
|
|
user: User;
|
|
accounts: AccountsState;
|
|
clearErrors: () => void;
|
|
resolve: () => void;
|
|
reject: () => void;
|
|
|
|
setErrors: (errors: Record<string, ValidationError>) => void;
|
|
}
|
|
|
|
interface State {
|
|
contextHeight: number;
|
|
panelId: PanelId | void;
|
|
prevPanelId: PanelId | void;
|
|
isHeightDirty: boolean;
|
|
forceHeight: 1 | 0;
|
|
direction: 'X' | 'Y';
|
|
formsHeights: Record<PanelId, number>;
|
|
}
|
|
|
|
// TODO: completely broken for RTL languages
|
|
class PanelTransition extends React.PureComponent<Props, State> {
|
|
state: State = {
|
|
contextHeight: 0,
|
|
panelId: this.props.Body && (this.props.Body.type as any).panelId,
|
|
isHeightDirty: false,
|
|
forceHeight: 0 as const,
|
|
direction: 'X' as const,
|
|
prevPanelId: undefined,
|
|
formsHeights: {},
|
|
};
|
|
|
|
isHeightMeasured: boolean = false;
|
|
wasAutoFocused: boolean = false;
|
|
body: {
|
|
autoFocus: () => void;
|
|
onFormSubmit: () => void;
|
|
} | null = null;
|
|
|
|
timerIds: Array<number> = []; // this is a list of a probably running timeouts to clean on unmount
|
|
|
|
componentDidUpdate(prevProps: Props) {
|
|
const nextPanel: PanelId = this.props.Body && (this.props.Body.type as any).panelId;
|
|
const prevPanel: PanelId = prevProps.Body && (prevProps.Body.type as any).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) {
|
|
this.timerIds.push(
|
|
// https://stackoverflow.com/a/51040768/5184751
|
|
window.setTimeout(() => {
|
|
this.setState({ forceHeight: 0 });
|
|
}, 100),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
componentWillUnmount() {
|
|
this.timerIds.forEach((id) => clearTimeout(id));
|
|
this.timerIds = [];
|
|
}
|
|
|
|
render() {
|
|
const { contextHeight, forceHeight } = this.state;
|
|
|
|
const { Title, Body, Footer, Links, auth, user, clearErrors, resolve, reject } = 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,
|
|
}: {
|
|
panelId: PanelId;
|
|
hasGoBack: boolean;
|
|
} = Body.type as any;
|
|
|
|
const formHeight = this.state.formsHeights[panelId] || 0;
|
|
|
|
// a hack to disable height animation on first render
|
|
const { isHeightMeasured } = this;
|
|
this.isHeightMeasured = isHeightMeasured || formHeight > 0;
|
|
|
|
return (
|
|
<AuthContextProvider
|
|
value={{
|
|
auth,
|
|
user,
|
|
requestRedraw: this.requestRedraw,
|
|
clearErrors,
|
|
resolve,
|
|
reject,
|
|
}}
|
|
>
|
|
<TransitionMotion
|
|
styles={[
|
|
{
|
|
key: panelId,
|
|
data: { Title, Body, Footer, Links, hasBackButton: hasGoBack },
|
|
style: {
|
|
transformSpring: spring(0, transformSpringConfig),
|
|
opacitySpring: spring(1, opacitySpringConfig),
|
|
},
|
|
},
|
|
{
|
|
key: 'common',
|
|
style: {
|
|
heightSpring: isHeightMeasured
|
|
? spring(forceHeight || formHeight, transformSpringConfig)
|
|
: formHeight,
|
|
switchContextHeightSpring: spring(
|
|
forceHeight || contextHeight,
|
|
changeContextSpringConfig,
|
|
),
|
|
},
|
|
},
|
|
]}
|
|
willEnter={this.willEnter}
|
|
willLeave={this.willLeave}
|
|
>
|
|
{(items) => {
|
|
const panels = items.filter(({ key }) => key !== 'common');
|
|
const [common] = items.filter(({ key }) => key === 'common');
|
|
|
|
const contentHeight = {
|
|
overflow: 'hidden',
|
|
height: forceHeight ? common.style.switchContextHeightSpring : 'auto',
|
|
};
|
|
|
|
this.tryToAutoFocus(panels.length);
|
|
|
|
const bodyHeight = {
|
|
position: 'relative' as const,
|
|
height: `${common.style.heightSpring}px`,
|
|
};
|
|
|
|
return (
|
|
<Form
|
|
id={panelId}
|
|
onSubmit={this.onFormSubmit}
|
|
onInvalid={this.onFormInvalid}
|
|
isLoading={this.props.auth.isLoading}
|
|
>
|
|
<Panel>
|
|
<PanelHeader>{panels.map((config) => this.getHeader(config))}</PanelHeader>
|
|
<div style={contentHeight}>
|
|
<MeasureHeight
|
|
state={this.shouldMeasureHeight()}
|
|
onMeasure={this.onUpdateContextHeight}
|
|
>
|
|
<PanelBody>
|
|
<div style={bodyHeight}>
|
|
{panels.map((config) => this.getBody(config))}
|
|
</div>
|
|
</PanelBody>
|
|
<PanelFooter>{panels.map((config) => this.getFooter(config))}</PanelFooter>
|
|
</MeasureHeight>
|
|
</div>
|
|
</Panel>
|
|
<div className={helpLinksStyles} data-testid="auth-controls-secondary">
|
|
{panels.map((config) => this.getLinks(config))}
|
|
</div>
|
|
</Form>
|
|
);
|
|
}}
|
|
</TransitionMotion>
|
|
</AuthContextProvider>
|
|
);
|
|
}
|
|
|
|
onFormSubmit = (): void => {
|
|
this.props.clearErrors();
|
|
|
|
if (this.body) {
|
|
this.body.onFormSubmit();
|
|
}
|
|
};
|
|
|
|
onFormInvalid = (errors: Record<string, ValidationError>): void => this.props.setErrors(errors);
|
|
|
|
willEnter = (config: TransitionStyle): PlainStyle => {
|
|
const transform = this.getTransformForPanel(config.key);
|
|
|
|
return {
|
|
transformSpring: transform,
|
|
opacitySpring: 1,
|
|
};
|
|
};
|
|
|
|
willLeave = (config: TransitionStyle): Style => {
|
|
const transform = this.getTransformForPanel(config.key);
|
|
|
|
return {
|
|
transformSpring: spring(transform, transformSpringConfig),
|
|
opacitySpring: spring(0, opacitySpringConfig),
|
|
};
|
|
};
|
|
|
|
getTransformForPanel(key: PanelId): number {
|
|
const { panelId, prevPanelId } = this.state;
|
|
|
|
const fromLeft = -1;
|
|
const fromRight = 1;
|
|
|
|
const currentContext = contexts.find((context) => context.includes(key));
|
|
|
|
if (!currentContext) {
|
|
throw new Error(`Can not find settings for ${key} panel`);
|
|
}
|
|
|
|
let sign =
|
|
prevPanelId && panelId && currentContext.indexOf(panelId) > currentContext.indexOf(prevPanelId)
|
|
? fromRight
|
|
: fromLeft;
|
|
|
|
if (prevPanelId === key) {
|
|
sign *= -1;
|
|
}
|
|
|
|
return sign * 100;
|
|
}
|
|
|
|
getDirection(next: PanelId, prev: PanelId): 'X' | 'Y' {
|
|
const context = contexts.find((item) => item.includes(prev));
|
|
|
|
if (!context) {
|
|
throw new Error(`Can not find context for transition ${prev} -> ${next}`);
|
|
}
|
|
|
|
return context.includes(next) ? 'X' : 'Y';
|
|
}
|
|
|
|
onUpdateHeight = (height: number, key: PanelId): void => {
|
|
this.setState({
|
|
formsHeights: {
|
|
...this.state.formsHeights,
|
|
[key]: height,
|
|
},
|
|
});
|
|
};
|
|
|
|
onUpdateContextHeight = (height: number): void => {
|
|
this.setState({
|
|
contextHeight: height,
|
|
});
|
|
};
|
|
|
|
onGoBack: MouseEventHandler<HTMLButtonElement> = (event): void => {
|
|
event.preventDefault();
|
|
authFlow.goBack();
|
|
};
|
|
|
|
/**
|
|
* Tries to auto focus form fields after transition end
|
|
*
|
|
* @param {number} length number of panels transitioned
|
|
*/
|
|
tryToAutoFocus(length: number): void {
|
|
if (!this.body) {
|
|
return;
|
|
}
|
|
|
|
if (length === 1) {
|
|
if (!this.wasAutoFocused) {
|
|
this.body.autoFocus();
|
|
}
|
|
|
|
this.wasAutoFocused = true;
|
|
} else if (this.wasAutoFocused) {
|
|
this.wasAutoFocused = false;
|
|
}
|
|
}
|
|
|
|
shouldMeasureHeight(): string {
|
|
const { user, accounts, auth } = this.props;
|
|
const { isHeightDirty } = this.state;
|
|
|
|
const errorString = Object.values(auth.error || {}).reduce((acc, item) => {
|
|
if (typeof item === 'string') {
|
|
return acc + item;
|
|
}
|
|
|
|
return acc + item.type;
|
|
}, '') as string;
|
|
|
|
return [errorString, isHeightDirty, user.lang, accounts.available.length].join('');
|
|
}
|
|
|
|
getHeader({ key, style, data }: TransitionPlainStyle): ReactElement {
|
|
const { Title } = data as AnimationData;
|
|
const { transformSpring } = (style as unknown) as AnimationStyle;
|
|
|
|
let { hasBackButton } = data;
|
|
|
|
if (typeof hasBackButton === 'function') {
|
|
hasBackButton = hasBackButton(this.props);
|
|
}
|
|
|
|
const transitionStyle = {
|
|
...this.getDefaultTransitionStyles(key, (style as unknown) as AnimationStyle),
|
|
opacity: 1, // reset default
|
|
};
|
|
|
|
const scrollStyle = this.translate(transformSpring, 'Y');
|
|
|
|
const sideScrollStyle = {
|
|
position: 'relative' as const,
|
|
zIndex: 2,
|
|
...this.translate(-Math.abs(transformSpring)),
|
|
};
|
|
|
|
const backButton = (
|
|
<button
|
|
style={sideScrollStyle}
|
|
className={panelStyles.headerControl}
|
|
data-e2e-go-back
|
|
type="button"
|
|
onClick={this.onGoBack}
|
|
>
|
|
<span className={icons.arrowLeft} />
|
|
</button>
|
|
);
|
|
|
|
return (
|
|
<div key={`header/${key}`} style={transitionStyle}>
|
|
{hasBackButton ? backButton : null}
|
|
<div style={scrollStyle}>{Title}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
getBody({ key, style, data }: TransitionPlainStyle): ReactElement {
|
|
const { Body } = data as AnimationData;
|
|
const { transformSpring } = (style as unknown) as AnimationStyle;
|
|
const { direction } = this.state;
|
|
|
|
let transform = this.translate(transformSpring, direction);
|
|
let verticalOrigin = 'top';
|
|
|
|
if (direction === 'Y') {
|
|
verticalOrigin = 'bottom';
|
|
transform = {};
|
|
}
|
|
|
|
const transitionStyle: CSSProperties = {
|
|
...this.getDefaultTransitionStyles(key, (style as unknown) as AnimationStyle),
|
|
top: 'auto', // reset default
|
|
[verticalOrigin]: 0,
|
|
...transform,
|
|
};
|
|
|
|
return (
|
|
<MeasureHeight
|
|
key={`body/${key}`}
|
|
style={transitionStyle}
|
|
state={this.shouldMeasureHeight()}
|
|
onMeasure={(height) => this.onUpdateHeight(height, key)}
|
|
>
|
|
{React.cloneElement(Body, {
|
|
// @ts-ignore
|
|
ref: (body) => {
|
|
this.body = body;
|
|
},
|
|
})}
|
|
</MeasureHeight>
|
|
);
|
|
}
|
|
|
|
getFooter({ key, style, data }: TransitionPlainStyle): ReactElement {
|
|
const { Footer } = data as AnimationData;
|
|
|
|
const transitionStyle = this.getDefaultTransitionStyles(key, (style as unknown) as AnimationStyle);
|
|
|
|
return (
|
|
<div key={`footer/${key}`} style={transitionStyle}>
|
|
{Footer}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
getLinks({ key, style, data }: TransitionPlainStyle): ReactElement {
|
|
const { Links } = data as AnimationData;
|
|
|
|
const transitionStyle = this.getDefaultTransitionStyles(key, (style as unknown) as AnimationStyle);
|
|
|
|
return (
|
|
<div key={`links/${key}`} style={transitionStyle}>
|
|
{Links}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
getDefaultTransitionStyles(
|
|
key: string,
|
|
{ opacitySpring }: Readonly<AnimationStyle>,
|
|
): {
|
|
position: 'absolute';
|
|
top: number;
|
|
left: number;
|
|
width: string;
|
|
opacity: number;
|
|
pointerEvents: 'none' | 'auto';
|
|
} {
|
|
return {
|
|
position: 'absolute',
|
|
top: 0,
|
|
left: 0,
|
|
width: '100%',
|
|
opacity: opacitySpring,
|
|
pointerEvents: key === this.state.panelId ? 'auto' : 'none',
|
|
};
|
|
}
|
|
|
|
translate(value: number, direction: 'X' | 'Y' = 'X', unit: '%' | 'px' = '%'): CSSProperties {
|
|
return {
|
|
WebkitTransform: `translate${direction}(${value}${unit})`,
|
|
transform: `translate${direction}(${value}${unit})`,
|
|
};
|
|
}
|
|
|
|
requestRedraw = (): Promise<void> =>
|
|
new Promise((resolve) =>
|
|
this.setState({ isHeightDirty: true }, () => {
|
|
this.setState({ isHeightDirty: false });
|
|
|
|
// wait till transition end
|
|
this.timerIds.push(setTimeout(resolve, 200));
|
|
}),
|
|
);
|
|
}
|
|
|
|
export default connect(
|
|
(state) => {
|
|
const login = getLogin(state);
|
|
let user = {
|
|
...state.user,
|
|
};
|
|
|
|
if (login) {
|
|
user = {
|
|
...user,
|
|
isGuest: true,
|
|
email: '',
|
|
username: '',
|
|
};
|
|
|
|
if (/[@.]/.test(login)) {
|
|
user.email = login;
|
|
} else {
|
|
user.username = login;
|
|
}
|
|
}
|
|
|
|
return {
|
|
user,
|
|
accounts: state.accounts, // need this, to re-render height
|
|
auth: state.auth,
|
|
resolve: authFlow.resolve.bind(authFlow),
|
|
reject: authFlow.reject.bind(authFlow),
|
|
};
|
|
},
|
|
{
|
|
clearErrors: actions.clearErrors,
|
|
setErrors: actions.setErrors,
|
|
},
|
|
)(PanelTransition);
|