diff --git a/packages/app/components/auth/BaseAuthBody.tsx b/packages/app/components/auth/BaseAuthBody.tsx index 6213892..07228ee 100644 --- a/packages/app/components/auth/BaseAuthBody.tsx +++ b/packages/app/components/auth/BaseAuthBody.tsx @@ -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 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; + 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; diff --git a/packages/app/components/auth/Context.tsx b/packages/app/components/auth/Context.tsx new file mode 100644 index 0000000..4b28431 --- /dev/null +++ b/packages/app/components/auth/Context.tsx @@ -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; + clearErrors: () => void; + resolve: (payload: { [key: string]: any } | undefined) => void; + reject: (payload: { [key: string]: any } | undefined) => void; +} + +const Context = React.createContext({ + 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; diff --git a/packages/app/components/auth/PanelTransition.tsx b/packages/app/components/auth/PanelTransition.tsx index c231d9a..e4a6e16 100644 --- a/packages/app/components/auth/PanelTransition.tsx +++ b/packages/app/components/auth/PanelTransition.tsx @@ -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 { - 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 { state: State = { contextHeight: 0, panelId: this.props.Body && (this.props.Body.type as any).panelId, @@ -166,25 +145,6 @@ class PanelTransition extends React.Component { 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 => - 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 { 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 { this.isHeightMeasured = isHeightMeasured || formHeight > 0; return ( - - {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 ( -
- - - {panels.map(config => this.getHeader(config))} - -
- - -
- {panels.map(config => this.getBody(config))} -
-
- - {panels.map(config => this.getFooter(config))} - -
-
-
-
- {panels.map(config => this.getLinks(config))} -
-
- ); + + > + + {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 ( +
+ + + {panels.map(config => this.getHeader(config))} + +
+ + +
+ {panels.map(config => this.getBody(config))} +
+
+ + {panels.map(config => this.getFooter(config))} + +
+
+
+
+ {panels.map(config => this.getLinks(config))} +
+
+ ); + }} +
+
); } @@ -442,8 +423,11 @@ class PanelTransition extends React.Component { } 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 { 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 { transform: `translate${direction}(${value}${unit})`, }; } + + requestRedraw = (): Promise => + new Promise(resolve => + this.setState({ isHeightDirty: true }, () => { + this.setState({ isHeightDirty: false }); + + // wait till transition end + this.timerIds.push(setTimeout(resolve, 200)); + }), + ); } export default connect( diff --git a/packages/app/components/auth/RejectionLink.tsx b/packages/app/components/auth/RejectionLink.tsx index d2413b9..78f1a76 100644 --- a/packages/app/components/auth/RejectionLink.tsx +++ b/packages/app/components/auth/RejectionLink.tsx @@ -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; diff --git a/packages/app/components/auth/forgotPassword/ForgotPasswordBody.js b/packages/app/components/auth/forgotPassword/ForgotPasswordBody.js index 67c2e1b..aa6e481 100644 --- a/packages/app/components/auth/forgotPassword/ForgotPasswordBody.js +++ b/packages/app/components/auth/forgotPassword/ForgotPasswordBody.js @@ -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 (
@@ -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'); }; } diff --git a/packages/app/components/auth/reducer.ts b/packages/app/components/auth/reducer.ts index b9a5313..4b0ae6e 100644 --- a/packages/app/components/auth/reducer.ts +++ b/packages/app/components/auth/reducer.ts @@ -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, +): string | null { return state.auth.credentials.login || null; } diff --git a/packages/app/components/ui/form/FormModel.ts b/packages/app/components/ui/form/FormModel.ts index 682538b..0a076c4 100644 --- a/packages/app/components/ui/form/FormModel.ts +++ b/packages/app/components/ui/form/FormModel.ts @@ -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 * diff --git a/packages/app/components/user/User.ts b/packages/app/components/user/User.ts deleted file mode 100644 index 9614dc2..0000000 --- a/packages/app/components/user/User.ts +++ /dev/null @@ -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, -});