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 AuthError from 'app/components/auth/authError/AuthError';
import { userShape } from 'app/components/user/User';
import { FormModel } from 'app/components/ui/form';
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>
RouteComponentProps<{ [key: string]: any }>
> {
static contextTypes = {
clearErrors: PropTypes.func.isRequired,
resolve: PropTypes.func.isRequired,
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,
};
static contextType = Context;
/* TODO: use declare */ context: React.ContextType<typeof Context>;
prevErrors: AuthContext['auth']['error'];
autoFocusField: string | null = '';
// eslint-disable-next-line react/no-deprecated
componentWillReceiveProps(nextProps, nextContext) {
if (nextContext.auth.error !== this.context.auth.error) {
this.form.setErrors(nextContext.auth.error || {});
componentDidMount() {
this.prevErrors = this.context.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() {
@ -48,7 +42,7 @@ export default class BaseAuthBody extends React.Component<
this.context.resolve(this.serialize());
}
onClearErrors = this.context.clearErrors;
onClearErrors = () => this.context.clearErrors();
form = new FormModel({
renderErrors: false,
@ -65,6 +59,10 @@ export default class BaseAuthBody extends React.Component<
autoFocus() {
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 { AccountsState } from 'app/components/accounts';
import { User } from 'app/components/user';
import PropTypes from 'prop-types';
import { connect } from 'react-redux';
import { TransitionMotion, spring } from 'react-motion';
import {
@ -15,9 +14,9 @@ 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 { userShape } from 'app/components/user/User';
import { RootState } from 'app/reducers';
import { Provider as AuthContextProvider } from './Context';
import { getLogin, State as AuthState } from './reducer';
import * as actions from './actions';
import helpLinks from './helpLinks.scss';
@ -111,10 +110,11 @@ interface Props extends OwnProps {
auth: AuthState;
user: User;
accounts: AccountsState;
setErrors: (errors: { [key: string]: ValidationError }) => void;
clearErrors: () => void;
resolve: () => void;
reject: () => void;
setErrors: (errors: { [key: string]: ValidationError }) => void;
}
type State = {
@ -126,28 +126,7 @@ type State = {
direction: 'X' | 'Y';
};
class PanelTransition extends React.Component<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,
};
class PanelTransition extends React.PureComponent<Props, State> {
state: State = {
contextHeight: 0,
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
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) {
const nextPanel: PanelId =
this.props.Body && (this.props.Body.type as any).panelId;
@ -222,7 +182,17 @@ class PanelTransition extends React.Component<Props, State> {
render() {
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) {
return this.props.children;
@ -245,84 +215,95 @@ class PanelTransition extends React.Component<Props, State> {
this.isHeightMeasured = isHeightMeasured || formHeight > 0;
return (
<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>
);
<AuthContextProvider
value={{
auth,
user,
requestRedraw: this.requestRedraw,
clearErrors,
resolve,
reject,
}}
</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() {
const errorString = Object.values(this.props.auth.error || {}).reduce(
(acc, item: ValidationError) => {
const { user, accounts, auth } = this.props;
const { isHeightDirty } = this.state;
const errorString = Object.values(auth.error || {}).reduce(
(acc: string, item: ValidationError): string => {
if (typeof item === 'string') {
return acc + item;
}
@ -451,13 +435,13 @@ class PanelTransition extends React.Component<Props, State> {
return acc + item.type;
},
'',
);
) as string;
return [
errorString,
this.state.isHeightDirty,
this.props.user.lang,
this.props.accounts.available.length,
isHeightDirty,
user.lang,
accounts.available.length,
].join('');
}
@ -601,6 +585,16 @@ class PanelTransition extends React.Component<Props, State> {
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(

View File

@ -1,23 +1,19 @@
import PropTypes from 'prop-types';
import React from 'react';
import React, { useContext } from 'react';
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 {
isAvailable?: (context: Context) => boolean;
isAvailable?: (context: AuthContext) => boolean;
payload?: { [key: string]: any };
label: MessageDescriptor;
}
export type RejectionLinkProps = Props;
interface Context {
reject: (payload: { [key: string]: any } | undefined) => void;
user: User;
}
function RejectionLink(props: Props) {
const context = useContext(Context);
function RejectionLink(props: Props, context: Context) {
if (props.isAvailable && !props.isAvailable(context)) {
// TODO: if want to properly support multiple links, we should control
// 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;

View File

@ -16,14 +16,16 @@ export default class ForgotPasswordBody extends BaseAuthBody {
static hasGoBack = true;
state = {
isLoginEdit: !this.getLogin(),
isLoginEdit: false,
};
autoFocusField = this.state.isLoginEdit ? 'login' : null;
autoFocusField = 'login';
render() {
const { isLoginEdit } = this.state;
const login = this.getLogin();
const isLoginEditShown = this.state.isLoginEdit;
const isLoginEditShown = isLoginEdit || !login;
return (
<div>
@ -79,11 +81,13 @@ export default class ForgotPasswordBody extends BaseAuthBody {
return login || user.username || user.email || '';
}
onClickEdit = () => {
onClickEdit = async () => {
this.setState({
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 {
credentials: Credentials;
error:
| null
| string
| {
type: string;
payload: { [key: string]: any };
};
error: null | {
[key: string]:
| string
| {
type: string;
payload: { [key: string]: any };
};
};
isLoading: boolean;
isSwitcherEnabled: boolean;
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;
}

View File

@ -27,6 +27,10 @@ export default class FormModel {
this.renderErrors = options.renderErrors !== false;
}
hasField(fieldId: string) {
return !!this.fields[fieldId];
}
/**
* 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,
});