projects/node: Add custom redirectTo after successful login

This commit is contained in:
Jerko Steiner 2019-03-18 13:13:52 +05:00
parent 8733dc2c30
commit 003bccc9e8
11 changed files with 102 additions and 45 deletions

View File

@ -3,58 +3,68 @@ import {IAPIDef, ICredentials, IUser} from '@rondo/common'
import {IHTTPClient} from '../http/IHTTPClient' import {IHTTPClient} from '../http/IHTTPClient'
export enum LoginActionKeys { export enum LoginActionKeys {
USER_LOG_IN = 'USER_LOG_IN', LOGIN = 'LOGIN',
USER_LOG_IN_PENDING = 'USER_LOG_IN_PENDING', LOGIN_PENDING = 'LOGIN_PENDING',
USER_LOG_IN_REJECTED = 'USER_LOG_IN_REJECTED', LOGIN_REJECTED = 'LOGIN_REJECTED',
USER_LOG_OUT = 'USER_LOG_OUT', LOGIN_LOG_OUT = 'LOGIN_LOG_OUT',
USER_LOG_OUT_PENDING = 'USER_LOG_OUT_PENDING', LOGIN_LOG_OUT_PENDING = 'LOGIN_LOG_OUT_PENDING',
USER_LOG_OUT_REJECTED = 'USER_LOG_OUT_REJECTED', LOGIN_LOG_OUT_REJECTED = 'LOGIN_LOG_OUT_REJECTED',
REGISTER_USER = 'REGISTER_USER', LOGIN_REGISTER = 'LOGIN_REGISTER',
REGISTER_USER_PENDING = 'REGISTER_USER_PENDING', LOGIN_REGISTER_PENDING = 'LOGIN_REGISTER_PENDING',
REGISTER_USER_REJECTED = 'REGISTER_USER_REJECTED', LOGIN_REGISTER_REJECTED = 'LOGIN_REGISTER_REJECTED',
LOGIN_REDIRECT_SET = 'LOGIN_REDIRECT_SET',
} }
export class LoginActions { export class LoginActions {
constructor(protected readonly http: IHTTPClient<IAPIDef>) {} constructor(protected readonly http: IHTTPClient<IAPIDef>) {}
logIn = (credentials: ICredentials) logIn = (credentials: ICredentials)
: IAction<IUser, LoginActionKeys.USER_LOG_IN> => { : IAction<IUser, LoginActionKeys.LOGIN> => {
return { return {
payload: this.http.post('/auth/login', credentials), payload: this.http.post('/auth/login', credentials),
type: LoginActionKeys.USER_LOG_IN, type: LoginActionKeys.LOGIN,
} }
} }
logInError = (error: Error) logInError = (error: Error)
: IErrorAction<LoginActionKeys.USER_LOG_IN_REJECTED> => { : IErrorAction<LoginActionKeys.LOGIN_REJECTED> => {
return { return {
error, error,
type: LoginActionKeys.USER_LOG_IN_REJECTED, type: LoginActionKeys.LOGIN_REJECTED,
} }
} }
logOut = (): IAction<unknown, LoginActionKeys.USER_LOG_OUT> => { logOut = (): IAction<unknown, LoginActionKeys.LOGIN_LOG_OUT> => {
return { return {
payload: this.http.get('/auth/logout'), payload: this.http.get('/auth/logout'),
type: LoginActionKeys.USER_LOG_OUT, type: LoginActionKeys.LOGIN_LOG_OUT,
} }
} }
register = (profile: ICredentials): register = (profile: ICredentials):
IAction<IUser, LoginActionKeys.REGISTER_USER> => { IAction<IUser, LoginActionKeys.LOGIN_REGISTER> => {
return { return {
payload: this.http.post('/auth/register', profile), payload: this.http.post('/auth/register', profile),
type: LoginActionKeys.REGISTER_USER, type: LoginActionKeys.LOGIN_REGISTER,
} }
} }
registerError = (error: Error) registerError = (error: Error)
: IErrorAction<LoginActionKeys.REGISTER_USER_REJECTED> => { : IErrorAction<LoginActionKeys.LOGIN_REGISTER_REJECTED> => {
return { return {
error, error,
type: LoginActionKeys.REGISTER_USER_REJECTED, type: LoginActionKeys.LOGIN_REGISTER_REJECTED,
}
}
setRedirectTo = (redirectTo: string)
: IAction<{redirectTo: string}, LoginActionKeys.LOGIN_REDIRECT_SET> => {
return {
payload: {redirectTo},
type: LoginActionKeys.LOGIN_REDIRECT_SET,
} }
} }
} }

View File

@ -12,7 +12,7 @@ const defaultCredentials: ICredentials = {
password: '', password: '',
} }
export class LoginConnector extends Connector { export class LoginConnector extends Connector<ILoginState> {
constructor(protected readonly loginActions: LoginActions) { constructor(protected readonly loginActions: LoginActions) {
super() super()
@ -24,6 +24,7 @@ export class LoginConnector extends Connector {
state => ({ state => ({
error: state.error, error: state.error,
user: state.user, user: state.user,
redirectTo: state.redirectTo,
}), }),
dispatch => ({ dispatch => ({
onSubmit: bindActionCreators(this.loginActions.logIn, dispatch), onSubmit: bindActionCreators(this.loginActions.logIn, dispatch),

View File

@ -1,7 +1,8 @@
import * as Feature from './' import * as Feature from './'
import ReactDOM from 'react-dom'
import T from 'react-dom/test-utils'
import {HTTPClientMock, TestUtils, getError} from '../test-utils' import {HTTPClientMock, TestUtils, getError} from '../test-utils'
import {IAPIDef} from '@rondo/common' import {IAPIDef} from '@rondo/common'
import T from 'react-dom/test-utils'
const test = new TestUtils() const test = new TestUtils()
@ -10,15 +11,18 @@ describe('LoginForm', () => {
const http = new HTTPClientMock<IAPIDef>() const http = new HTTPClientMock<IAPIDef>()
const loginActions = new Feature.LoginActions(http) const loginActions = new Feature.LoginActions(http)
const t = test.withProvider({ const createTestProvider = () => test.withProvider({
reducers: {Login: Feature.Login}, reducers: {Login: Feature.Login},
state: {Login: {user: {id: 1}}},
connector: new Feature.LoginConnector(loginActions), connector: new Feature.LoginConnector(loginActions),
select: state => state.Login, select: state => state.Login,
}) })
beforeAll(() => {
(window as any).__MOCK_SERVER_SIDE__ = true
})
it('should render', () => { it('should render', () => {
t.render() createTestProvider().render()
}) })
describe('submit', () => { describe('submit', () => {
@ -26,6 +30,7 @@ describe('LoginForm', () => {
const data = {username: 'user', password: 'pass'} const data = {username: 'user', password: 'pass'}
const onSuccess = jest.fn() const onSuccess = jest.fn()
let node: Element let node: Element
let component: React.Component
beforeEach(() => { beforeEach(() => {
http.mockAdd({ http.mockAdd({
method: 'post', method: 'post',
@ -33,8 +38,10 @@ describe('LoginForm', () => {
data, data,
}, {id: 123}) }, {id: 123})
const t = createTestProvider()
const r = t.render({onSuccess}) const r = t.render({onSuccess})
node = r.node node = r.node
component = r.component
T.Simulate.change( T.Simulate.change(
node.querySelector('input[name="username"]')!, node.querySelector('input[name="username"]')!,
{target: {value: 'user'}} as any, {target: {value: 'user'}} as any,
@ -45,7 +52,7 @@ describe('LoginForm', () => {
) )
}) })
it('should submit a form and clear it', async () => { it('should submit a form, clear it and redirect it', async () => {
T.Simulate.submit(node) T.Simulate.submit(node)
const {req} = await http.wait() const {req} = await http.wait()
expect(req).toEqual({ expect(req).toEqual({
@ -64,6 +71,8 @@ describe('LoginForm', () => {
.value, .value,
) )
.toEqual('') .toEqual('')
node = ReactDOM.findDOMNode(component) as Element
expect(node.innerHTML).toMatch(/<a href="\/">/)
}) })
it('sets the error message on failure', async () => { it('sets the error message on failure', async () => {

View File

@ -1,18 +1,24 @@
import React from 'react' import React from 'react'
import {ICredentials} from '@rondo/common' import {ICredentials, IUser} from '@rondo/common'
import {Input} from '../components/Input' import {Input} from '../components/Input'
import {Redirect} from '../components/Redirect'
export interface ILoginFormProps { export interface ILoginFormProps {
error?: string error?: string
onSubmit: () => void onSubmit: () => void
onChange: (name: string, value: string) => void onChange: (name: string, value: string) => void
data: ICredentials data: ICredentials
user?: IUser
redirectTo: string
} }
// TODO maybe replace this with Formik, which is recommended in React docs // TODO maybe replace this with Formik, which is recommended in React docs
// https://jaredpalmer.com/formik/docs/overview // https://jaredpalmer.com/formik/docs/overview
export class LoginForm extends React.PureComponent<ILoginFormProps> { export class LoginForm extends React.PureComponent<ILoginFormProps> {
render() { render() {
if (this.props.user) {
return <Redirect to={this.props.redirectTo} />
}
return ( return (
<form onSubmit={this.props.onSubmit}> <form onSubmit={this.props.onSubmit}>
<p className='error'>{this.props.error}</p> <p className='error'>{this.props.error}</p>

View File

@ -2,13 +2,15 @@ import {IUser} from '@rondo/common'
import {LoginActionKeys, LoginActionType} from './LoginActions' import {LoginActionKeys, LoginActionType} from './LoginActions'
export interface ILoginState { export interface ILoginState {
readonly error?: string, readonly error?: string
readonly user?: IUser readonly user?: IUser
readonly redirectTo: string
} }
const defaultState: ILoginState = { const defaultState: ILoginState = {
error: undefined, error: undefined,
user: undefined, user: undefined,
redirectTo: '/',
} }
export function Login( export function Login(
@ -16,16 +18,18 @@ export function Login(
action: LoginActionType, action: LoginActionType,
): ILoginState { ): ILoginState {
switch (action.type) { switch (action.type) {
case LoginActionKeys.USER_LOG_IN: case LoginActionKeys.LOGIN:
return {...state, user: action.payload, error: ''} return {...state, user: action.payload, error: ''}
case LoginActionKeys.USER_LOG_OUT: case LoginActionKeys.LOGIN_LOG_OUT:
return {...state, user: undefined} return {...state, user: undefined}
case LoginActionKeys.USER_LOG_IN_REJECTED: case LoginActionKeys.LOGIN_REJECTED:
return {...state, error: action.error.message} return {...state, error: action.error.message}
case LoginActionKeys.REGISTER_USER: case LoginActionKeys.LOGIN_REGISTER:
return {...state, user: action.payload, error: ''} return {...state, user: action.payload, error: ''}
case LoginActionKeys.REGISTER_USER_REJECTED: case LoginActionKeys.LOGIN_REGISTER_REJECTED:
return {...state, error: action.error.message} return {...state, error: action.error.message}
case LoginActionKeys.LOGIN_REDIRECT_SET:
return {...state, redirectTo: action.payload.redirectTo}
default: default:
return state return state
} }

View File

@ -12,7 +12,7 @@ const defaultCredentials: ICredentials = {
password: '', password: '',
} }
export class RegisterConnector extends Connector { export class RegisterConnector extends Connector<ILoginState> {
constructor(protected readonly loginActions: LoginActions) { constructor(protected readonly loginActions: LoginActions) {
super() super()
@ -23,6 +23,8 @@ export class RegisterConnector extends Connector {
getLocalState, getLocalState,
state => ({ state => ({
error: state.error, error: state.error,
user: state.user,
redirectTo: state.redirectTo,
}), }),
dispatch => ({ dispatch => ({
onSubmit: bindActionCreators(this.loginActions.register, dispatch), onSubmit: bindActionCreators(this.loginActions.register, dispatch),

View File

@ -1,7 +1,8 @@
import * as Feature from './' import * as Feature from './'
import ReactDOM from 'react-dom'
import T from 'react-dom/test-utils'
import {HTTPClientMock, TestUtils, getError} from '../test-utils' import {HTTPClientMock, TestUtils, getError} from '../test-utils'
import {IAPIDef} from '@rondo/common' import {IAPIDef} from '@rondo/common'
import T from 'react-dom/test-utils'
const test = new TestUtils() const test = new TestUtils()
@ -10,15 +11,18 @@ describe('RegisterForm', () => {
const http = new HTTPClientMock<IAPIDef>() const http = new HTTPClientMock<IAPIDef>()
const loginActions = new Feature.LoginActions(http) const loginActions = new Feature.LoginActions(http)
const t = test.withProvider({ const createTestProvider = () => test.withProvider({
reducers: {Login: Feature.Login}, reducers: {Login: Feature.Login},
state: {Login: {user: {id: 1}}},
connector: new Feature.RegisterConnector(loginActions), connector: new Feature.RegisterConnector(loginActions),
select: state => state.Login, select: state => state.Login,
}) })
beforeAll(() => {
(window as any).__MOCK_SERVER_SIDE__ = true
})
it('should render', () => { it('should render', () => {
t.render() createTestProvider().render()
}) })
describe('submit', () => { describe('submit', () => {
@ -26,6 +30,7 @@ describe('RegisterForm', () => {
const data = {username: 'user', password: 'pass'} const data = {username: 'user', password: 'pass'}
const onSuccess = jest.fn() const onSuccess = jest.fn()
let node: Element let node: Element
let component: React.Component
beforeEach(() => { beforeEach(() => {
http.mockAdd({ http.mockAdd({
method: 'post', method: 'post',
@ -33,7 +38,9 @@ describe('RegisterForm', () => {
data, data,
}, {id: 123}) }, {id: 123})
node = t.render({onSuccess}).node const r = createTestProvider().render({onSuccess})
node = r.node
component = r.component
T.Simulate.change( T.Simulate.change(
node.querySelector('input[name="username"]')!, node.querySelector('input[name="username"]')!,
{target: {value: 'user'}} as any, {target: {value: 'user'}} as any,
@ -44,7 +51,7 @@ describe('RegisterForm', () => {
) )
}) })
it('should submit a form', async () => { it('should submit a form, clear it, and redirect', async () => {
T.Simulate.submit(node) T.Simulate.submit(node)
const {req} = await http.wait() const {req} = await http.wait()
expect(req).toEqual({ expect(req).toEqual({
@ -53,6 +60,18 @@ describe('RegisterForm', () => {
data, data,
}) })
expect(onSuccess.mock.calls.length).toBe(1) expect(onSuccess.mock.calls.length).toBe(1)
expect(
(node.querySelector('input[name="username"]') as HTMLInputElement)
.value,
)
.toEqual('')
expect(
(node.querySelector('input[name="password"]') as HTMLInputElement)
.value,
)
.toEqual('')
node = ReactDOM.findDOMNode(component) as Element
expect(node.innerHTML).toMatch(/<a href="\/">/)
}) })
it('sets the error message on failure', async () => { it('sets the error message on failure', async () => {

View File

@ -1,16 +1,22 @@
import React from 'react' import React from 'react'
import {ICredentials} from '@rondo/common' import {ICredentials, IUser} from '@rondo/common'
import {Input} from '../components/Input' import {Input} from '../components/Input'
import {Redirect} from '../components/Redirect'
export interface IRegisterFormProps { export interface IRegisterFormProps {
error?: string error?: string
onSubmit: () => void onSubmit: () => void
onChange: (name: string, value: string) => void onChange: (name: string, value: string) => void
data: ICredentials data: ICredentials
user?: IUser
redirectTo: string
} }
export class RegisterForm extends React.PureComponent<IRegisterFormProps> { export class RegisterForm extends React.PureComponent<IRegisterFormProps> {
render() { render() {
if (this.props.user) {
return <Redirect to={this.props.redirectTo} />
}
return ( return (
<form onSubmit={this.props.onSubmit}> <form onSubmit={this.props.onSubmit}>
<p className='error'>{this.props.error}</p> <p className='error'>{this.props.error}</p>

View File

@ -18,7 +18,7 @@ import {ComponentType} from 'react'
* easy to mock it during tests, or swap out different dependencies for * easy to mock it during tests, or swap out different dependencies for
* different applications. * different applications.
*/ */
export abstract class Connector { export abstract class Connector<LocalState> {
/** /**
* Connects a component using redux. The `selectState` method is used to * Connects a component using redux. The `selectState` method is used to
@ -29,13 +29,12 @@ export abstract class Connector {
* *
* https://stackoverflow.com/questions/54277411 * https://stackoverflow.com/questions/54277411
*/ */
abstract connect<State, LocalState>( abstract connect<State>(
selectState: IStateSelector<State, LocalState>, selectState: IStateSelector<State, LocalState>,
): ComponentType<any> ): ComponentType<any>
protected wrap< protected wrap<
State, State,
LocalState,
StateProps, StateProps,
DispatchProps, DispatchProps,
Props Props

View File

@ -1,5 +1,6 @@
export function isClientSide() { export function isClientSide() {
return typeof window !== 'undefined' && return typeof window !== 'undefined' &&
typeof window.document !== 'undefined' && typeof window.document !== 'undefined' &&
typeof window.document.createElement === 'function' typeof window.document.createElement === 'function' &&
typeof (window as any).__MOCK_SERVER_SIDE__ === 'undefined'
} }

View File

@ -16,7 +16,7 @@ import {
interface IRenderParams<State> { interface IRenderParams<State> {
reducers: ReducersMapObject<State, any> reducers: ReducersMapObject<State, any>
state?: DeepPartial<State> state?: DeepPartial<State>
connector: Connector connector: Connector<any>
select: IStateSelector<State, any> select: IStateSelector<State, any>
} }