Migrate auth components to new Context api

This commit is contained in:
SleepWalker 2019-12-13 09:26:29 +02:00
parent 08a2158042
commit 59debce051
8 changed files with 212 additions and 211 deletions

View File

@ -1,41 +1,35 @@
/**
* Helps with form fields binding, form serialization and errors rendering
*/
import PropTypes from 'prop-types';
import React from 'react'; import React from 'react';
import AuthError from 'app/components/auth/authError/AuthError'; import AuthError from 'app/components/auth/authError/AuthError';
import { userShape } from 'app/components/user/User';
import { FormModel } from 'app/components/ui/form'; import { FormModel } from 'app/components/ui/form';
import { RouteComponentProps } from 'react-router-dom'; import { RouteComponentProps } from 'react-router-dom';
export default class BaseAuthBody extends React.Component< import Context, { AuthContext } from './Context';
/**
* Helps with form fields binding, form serialization and errors rendering
*/
class BaseAuthBody extends React.Component<
// TODO: this may be converted to generic type RouteComponentProps<T> // TODO: this may be converted to generic type RouteComponentProps<T>
RouteComponentProps<{ [key: string]: any }> RouteComponentProps<{ [key: string]: any }>
> { > {
static contextTypes = { static contextType = Context;
clearErrors: PropTypes.func.isRequired, /* TODO: use declare */ context: React.ContextType<typeof Context>;
resolve: PropTypes.func.isRequired, prevErrors: AuthContext['auth']['error'];
requestRedraw: PropTypes.func.isRequired,
auth: PropTypes.shape({
error: PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
type: PropTypes.string,
payload: PropTypes.object,
}),
]),
scopes: PropTypes.array,
}).isRequired,
user: userShape,
};
autoFocusField: string | null = ''; autoFocusField: string | null = '';
// eslint-disable-next-line react/no-deprecated componentDidMount() {
componentWillReceiveProps(nextProps, nextContext) { this.prevErrors = this.context.auth.error;
if (nextContext.auth.error !== this.context.auth.error) { }
this.form.setErrors(nextContext.auth.error || {});
componentDidUpdate() {
if (this.context.auth.error !== this.prevErrors) {
this.form.setErrors(this.context.auth.error || {});
this.context.requestRedraw();
} }
this.prevErrors = this.context.auth.error;
} }
renderErrors() { renderErrors() {
@ -48,7 +42,7 @@ export default class BaseAuthBody extends React.Component<
this.context.resolve(this.serialize()); this.context.resolve(this.serialize());
} }
onClearErrors = this.context.clearErrors; onClearErrors = () => this.context.clearErrors();
form = new FormModel({ form = new FormModel({
renderErrors: false, renderErrors: false,
@ -65,6 +59,10 @@ export default class BaseAuthBody extends React.Component<
autoFocus() { autoFocus() {
const fieldId = this.autoFocusField; const fieldId = this.autoFocusField;
fieldId && this.form.focus(fieldId); if (fieldId && this.form.hasField(fieldId)) {
this.form.focus(fieldId);
}
} }
} }
export default BaseAuthBody;

View File

@ -0,0 +1,34 @@
import React from 'react';
import { User } from 'app/components/user';
import { State as AuthState } from './reducer';
export interface AuthContext {
auth: AuthState;
user: User;
requestRedraw: () => Promise<void>;
clearErrors: () => void;
resolve: (payload: { [key: string]: any } | undefined) => void;
reject: (payload: { [key: string]: any } | undefined) => void;
}
const Context = React.createContext<AuthContext>({
auth: {
error: null,
login: '',
scopes: [],
} as any,
user: {
id: null,
isGuest: true,
} as any,
async requestRedraw() {},
clearErrors() {},
resolve() {},
reject() {},
});
Context.displayName = 'AuthContext';
export const { Provider, Consumer } = Context;
export default Context;

View File

@ -1,7 +1,6 @@
import React from 'react'; import React from 'react';
import { AccountsState } from 'app/components/accounts'; import { AccountsState } from 'app/components/accounts';
import { User } from 'app/components/user'; import { User } from 'app/components/user';
import PropTypes from 'prop-types';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { TransitionMotion, spring } from 'react-motion'; import { TransitionMotion, spring } from 'react-motion';
import { import {
@ -15,9 +14,9 @@ import MeasureHeight from 'app/components/MeasureHeight';
import panelStyles from 'app/components/ui/panel.scss'; import panelStyles from 'app/components/ui/panel.scss';
import icons from 'app/components/ui/icons.scss'; import icons from 'app/components/ui/icons.scss';
import authFlow from 'app/services/authFlow'; import authFlow from 'app/services/authFlow';
import { userShape } from 'app/components/user/User';
import { RootState } from 'app/reducers'; import { RootState } from 'app/reducers';
import { Provider as AuthContextProvider } from './Context';
import { getLogin, State as AuthState } from './reducer'; import { getLogin, State as AuthState } from './reducer';
import * as actions from './actions'; import * as actions from './actions';
import helpLinks from './helpLinks.scss'; import helpLinks from './helpLinks.scss';
@ -111,10 +110,11 @@ interface Props extends OwnProps {
auth: AuthState; auth: AuthState;
user: User; user: User;
accounts: AccountsState; accounts: AccountsState;
setErrors: (errors: { [key: string]: ValidationError }) => void;
clearErrors: () => void; clearErrors: () => void;
resolve: () => void; resolve: () => void;
reject: () => void; reject: () => void;
setErrors: (errors: { [key: string]: ValidationError }) => void;
} }
type State = { type State = {
@ -126,28 +126,7 @@ type State = {
direction: 'X' | 'Y'; direction: 'X' | 'Y';
}; };
class PanelTransition extends React.Component<Props, State> { class PanelTransition extends React.PureComponent<Props, State> {
static childContextTypes = {
auth: PropTypes.shape({
error: PropTypes.oneOfType([
PropTypes.string,
PropTypes.shape({
type: PropTypes.string,
payload: PropTypes.object,
}),
]),
login: PropTypes.string,
}),
user: userShape,
accounts: PropTypes.shape({
available: PropTypes.array,
}),
requestRedraw: PropTypes.func,
clearErrors: PropTypes.func,
resolve: PropTypes.func,
reject: PropTypes.func,
};
state: State = { state: State = {
contextHeight: 0, contextHeight: 0,
panelId: this.props.Body && (this.props.Body.type as any).panelId, panelId: this.props.Body && (this.props.Body.type as any).panelId,
@ -166,25 +145,6 @@ class PanelTransition extends React.Component<Props, State> {
timerIds: NodeJS.Timeout[] = []; // this is a list of a probably running timeouts to clean on unmount timerIds: NodeJS.Timeout[] = []; // this is a list of a probably running timeouts to clean on unmount
getChildContext() {
return {
auth: this.props.auth,
user: this.props.user,
requestRedraw: (): Promise<void> =>
new Promise(resolve =>
this.setState({ isHeightDirty: true }, () => {
this.setState({ isHeightDirty: false });
// wait till transition end
this.timerIds.push(setTimeout(resolve, 200));
}),
),
clearErrors: this.props.clearErrors,
resolve: this.props.resolve,
reject: this.props.reject,
};
}
componentDidUpdate(prevProps: Props) { componentDidUpdate(prevProps: Props) {
const nextPanel: PanelId = const nextPanel: PanelId =
this.props.Body && (this.props.Body.type as any).panelId; this.props.Body && (this.props.Body.type as any).panelId;
@ -222,7 +182,17 @@ class PanelTransition extends React.Component<Props, State> {
render() { render() {
const { contextHeight, forceHeight } = this.state; const { contextHeight, forceHeight } = this.state;
const { Title, Body, Footer, Links } = this.props; const {
Title,
Body,
Footer,
Links,
auth,
user,
clearErrors,
resolve,
reject,
} = this.props;
if (this.props.children) { if (this.props.children) {
return this.props.children; return this.props.children;
@ -245,84 +215,95 @@ class PanelTransition extends React.Component<Props, State> {
this.isHeightMeasured = isHeightMeasured || formHeight > 0; this.isHeightMeasured = isHeightMeasured || formHeight > 0;
return ( return (
<TransitionMotion <AuthContextProvider
styles={[ value={{
{ auth,
key: panelId, user,
data: { Title, Body, Footer, Links, hasBackButton: hasGoBack }, requestRedraw: this.requestRedraw,
style: { clearErrors,
transformSpring: spring(0, transformSpringConfig), resolve,
opacitySpring: spring(1, opacitySpringConfig), reject,
},
},
{
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}>
{panels.map(config => this.getLinks(config))}
</div>
</Form>
);
}} }}
</TransitionMotion> >
<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}>
{panels.map(config => this.getLinks(config))}
</div>
</Form>
);
}}
</TransitionMotion>
</AuthContextProvider>
); );
} }
@ -442,8 +423,11 @@ class PanelTransition extends React.Component<Props, State> {
} }
shouldMeasureHeight() { shouldMeasureHeight() {
const errorString = Object.values(this.props.auth.error || {}).reduce( const { user, accounts, auth } = this.props;
(acc, item: ValidationError) => { const { isHeightDirty } = this.state;
const errorString = Object.values(auth.error || {}).reduce(
(acc: string, item: ValidationError): string => {
if (typeof item === 'string') { if (typeof item === 'string') {
return acc + item; return acc + item;
} }
@ -451,13 +435,13 @@ class PanelTransition extends React.Component<Props, State> {
return acc + item.type; return acc + item.type;
}, },
'', '',
); ) as string;
return [ return [
errorString, errorString,
this.state.isHeightDirty, isHeightDirty,
this.props.user.lang, user.lang,
this.props.accounts.available.length, accounts.available.length,
].join(''); ].join('');
} }
@ -601,6 +585,16 @@ class PanelTransition extends React.Component<Props, State> {
transform: `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( export default connect(

View File

@ -1,23 +1,19 @@
import PropTypes from 'prop-types'; import React, { useContext } from 'react';
import React from 'react';
import { FormattedMessage as Message, MessageDescriptor } from 'react-intl'; import { FormattedMessage as Message, MessageDescriptor } from 'react-intl';
import { User } from 'app/components/user';
import { userShape } from 'app/components/user/User'; import Context, { AuthContext } from './Context';
interface Props { interface Props {
isAvailable?: (context: Context) => boolean; isAvailable?: (context: AuthContext) => boolean;
payload?: { [key: string]: any }; payload?: { [key: string]: any };
label: MessageDescriptor; label: MessageDescriptor;
} }
export type RejectionLinkProps = Props; export type RejectionLinkProps = Props;
interface Context { function RejectionLink(props: Props) {
reject: (payload: { [key: string]: any } | undefined) => void; const context = useContext(Context);
user: User;
}
function RejectionLink(props: Props, context: Context) {
if (props.isAvailable && !props.isAvailable(context)) { if (props.isAvailable && !props.isAvailable(context)) {
// TODO: if want to properly support multiple links, we should control // TODO: if want to properly support multiple links, we should control
// the dividers ' | ' rendered from factory too // the dividers ' | ' rendered from factory too
@ -38,9 +34,4 @@ function RejectionLink(props: Props, context: Context) {
); );
} }
RejectionLink.contextTypes = {
reject: PropTypes.func.isRequired,
user: userShape,
};
export default RejectionLink; export default RejectionLink;

View File

@ -16,14 +16,16 @@ export default class ForgotPasswordBody extends BaseAuthBody {
static hasGoBack = true; static hasGoBack = true;
state = { state = {
isLoginEdit: !this.getLogin(), isLoginEdit: false,
}; };
autoFocusField = this.state.isLoginEdit ? 'login' : null; autoFocusField = 'login';
render() { render() {
const { isLoginEdit } = this.state;
const login = this.getLogin(); const login = this.getLogin();
const isLoginEditShown = this.state.isLoginEdit; const isLoginEditShown = isLoginEdit || !login;
return ( return (
<div> <div>
@ -79,11 +81,13 @@ export default class ForgotPasswordBody extends BaseAuthBody {
return login || user.username || user.email || ''; return login || user.username || user.email || '';
} }
onClickEdit = () => { onClickEdit = async () => {
this.setState({ this.setState({
isLoginEdit: true, isLoginEdit: true,
}); });
this.context.requestRedraw().then(() => this.form.focus('login')); await this.context.requestRedraw();
this.form.focus('login');
}; };
} }

View File

@ -45,13 +45,14 @@ interface OAuthState {
export interface State { export interface State {
credentials: Credentials; credentials: Credentials;
error: error: null | {
| null [key: string]:
| string | string
| { | {
type: string; type: string;
payload: { [key: string]: any }; payload: { [key: string]: any };
}; };
};
isLoading: boolean; isLoading: boolean;
isSwitcherEnabled: boolean; isSwitcherEnabled: boolean;
client: Client | null; client: Client | null;
@ -198,7 +199,9 @@ function scopes(state = [], { type, payload = [] }): State['scopes'] {
} }
} }
export function getLogin(state: RootState): string | null { export function getLogin(
state: RootState | Pick<RootState, 'auth'>,
): string | null {
return state.auth.credentials.login || null; return state.auth.credentials.login || null;
} }

View File

@ -27,6 +27,10 @@ export default class FormModel {
this.renderErrors = options.renderErrors !== false; this.renderErrors = options.renderErrors !== false;
} }
hasField(fieldId: string) {
return !!this.fields[fieldId];
}
/** /**
* Connects form with React's component * Connects form with React's component
* *

View File

@ -1,27 +0,0 @@
import PropTypes from 'prop-types';
/**
* @typedef {object} User
* @property {number} id
* @property {string} uuid
* @property {string} token
* @property {string} username
* @property {string} email
* @property {string} avatar
* @property {bool} isGuest
* @property {bool} isActive
* @property {number} passwordChangedAt - timestamp
* @property {bool} hasMojangUsernameCollision
*/
export const userShape = PropTypes.shape({
id: PropTypes.number,
uuid: PropTypes.string,
token: PropTypes.string,
username: PropTypes.string,
email: PropTypes.string,
avatar: PropTypes.string,
isGuest: PropTypes.bool.isRequired,
isActive: PropTypes.bool.isRequired,
passwordChangedAt: PropTypes.number,
hasMojangUsernameCollision: PropTypes.bool,
});