diff --git a/packages/client/jest.config.js b/packages/client/jest.config.js index 737dab0..7b62f2d 100644 --- a/packages/client/jest.config.js +++ b/packages/client/jest.config.js @@ -12,5 +12,6 @@ module.exports = { 'js', 'jsx' ], - setupFiles: ['/jest.setup.js'] + setupFiles: ['/jest.setup.js'], + verbose: false } diff --git a/packages/client/src/components/Input.tsx b/packages/client/src/components/Input.tsx index 2413f39..cecae3c 100644 --- a/packages/client/src/components/Input.tsx +++ b/packages/client/src/components/Input.tsx @@ -2,7 +2,7 @@ import React from 'react' export interface IInputProps { name: string - type: 'text' | 'password' | 'hidden' | 'submit' + type: 'text' | 'password' | 'hidden' | 'submit' | 'email' value?: string onChange?: (name: this['name'], value: string) => void placeholder?: string diff --git a/packages/client/src/login/LoginActions.ts b/packages/client/src/login/LoginActions.ts index 9a8c1c3..7f5f68f 100644 --- a/packages/client/src/login/LoginActions.ts +++ b/packages/client/src/login/LoginActions.ts @@ -10,6 +10,10 @@ export enum LoginActionKeys { USER_LOG_OUT = 'USER_LOG_OUT', USER_LOG_OUT_PENDING = 'USER_LOG_OUT_PENDING', USER_LOG_OUT_REJECTED = 'USER_LOG_OUT_REJECTED', + + REGISTER_USER = 'REGISTER_USER', + REGISTER_USER_PENDING = 'REGISTER_USER_PENDING', + REGISTER_USER_REJECTED = 'REGISTER_USER_REJECTED', } export class LoginActions { @@ -37,6 +41,22 @@ export class LoginActions { type: LoginActionKeys.USER_LOG_OUT, } } + + register = (profile: ICredentials): + IAction => { + return { + payload: this.http.post('/auth/register', profile), + type: LoginActionKeys.REGISTER_USER, + } + } + + registerError = (error: Error) + : IErrorAction => { + return { + error, + type: LoginActionKeys.REGISTER_USER_REJECTED, + } + } } // This makes it very easy to write reducer code. diff --git a/packages/client/src/login/LoginConnector.tsx b/packages/client/src/login/LoginConnector.tsx index 3960739..c0fc842 100644 --- a/packages/client/src/login/LoginConnector.tsx +++ b/packages/client/src/login/LoginConnector.tsx @@ -1,9 +1,16 @@ +import {Connector} from '../redux/Connector' +import {ICredentials} from '@rondo/common' import {ILoginState} from './LoginReducer' +import {IStateSelector} from '../redux' import {LoginActions} from './LoginActions' import {LoginForm} from './LoginForm' import {bindActionCreators} from 'redux' -import {IStateSelector} from '../redux' -import {Connector} from '../redux/Connector' +import {withForm} from './withForm' + +const defaultCredentials: ICredentials = { + username: '', + password: '', +} export class LoginConnector extends Connector { @@ -21,7 +28,7 @@ export class LoginConnector extends Connector { dispatch => ({ onSubmit: bindActionCreators(this.loginActions.logIn, dispatch), }), - LoginForm, + withForm(LoginForm, defaultCredentials), ) } } diff --git a/packages/client/src/login/LoginForm.tsx b/packages/client/src/login/LoginForm.tsx index 724f6ff..99466f7 100644 --- a/packages/client/src/login/LoginForm.tsx +++ b/packages/client/src/login/LoginForm.tsx @@ -1,55 +1,33 @@ import React from 'react' -import {Input} from '../components/Input' import {ICredentials} from '@rondo/common' +import {Input} from '../components/Input' export interface ILoginFormProps { error?: string - onSubmit: (credentials: ICredentials) => Promise - onSuccess?: () => void + onSubmit: () => void + onChange: (name: string, value: string) => void + data: ICredentials } -export interface ILoginFormState extends ICredentials {} - // TODO maybe replace this with Formik, which is recommended in React docs // https://jaredpalmer.com/formik/docs/overview -export class LoginForm extends React.PureComponent< - ILoginFormProps, ILoginFormState -> { - constructor(props: ILoginFormProps) { - super(props) - this.state = { - username: '', - password: '', - } - } - handleSubmit = async () => { - const {onSuccess} = this.props - await this.props.onSubmit(this.state) - if (onSuccess) { - onSuccess() - } - } - handleChange = (name: string, value: string) => { - this.setState( - {[name]: value} as Pick, - ) - } +export class LoginForm extends React.PureComponent { render() { return ( -
+

{this.props.error}

(getLocalState: IStateSelector) { + return this.wrap( + getLocalState, + state => ({ + error: state.error, + }), + dispatch => ({ + onSubmit: bindActionCreators(this.loginActions.register, dispatch), + }), + withForm(RegisterForm, defaultCredentials), + ) + } +} diff --git a/packages/client/src/login/RegisterForm.test.tsx b/packages/client/src/login/RegisterForm.test.tsx new file mode 100644 index 0000000..582f91d --- /dev/null +++ b/packages/client/src/login/RegisterForm.test.tsx @@ -0,0 +1,72 @@ +import * as Feature from './' +import {HTTPClientMock, TestUtils, getError} from '../test-utils' +import {IAPIDef} from '@rondo/common' +import T from 'react-dom/test-utils' + +const test = new TestUtils() + +describe('RegisterForm', () => { + + const http = new HTTPClientMock() + const loginActions = new Feature.LoginActions(http) + + const t = test.withProvider({ + reducers: {Login: Feature.Login}, + state: {Login: {user: {id: 1}}}, + connector: new Feature.RegisterConnector(loginActions), + select: state => state.Login, + }) + + it('should render', () => { + t.render() + }) + + describe('submit', () => { + + const data = {username: 'user', password: 'pass'} + const onSuccess = jest.fn() + let node: Element + beforeEach(() => { + http.mockAdd({ + method: 'post', + url: '/auth/register', + data, + }, {id: 123}) + + node = t.render({onSuccess}).node + T.Simulate.change( + node.querySelector('input[name="username"]')!, + {target: {value: 'user'}} as any, + ) + T.Simulate.change( + node.querySelector('input[name="password"]')!, + {target: {value: 'pass'}} as any, + ) + }) + + it('should submit a form', async () => { + T.Simulate.submit(node) + const {req} = await http.wait() + expect(req).toEqual({ + method: 'post', + url: '/auth/register', + data, + }) + expect(onSuccess.mock.calls.length).toBe(1) + }) + + it('sets the error message on failure', async () => { + http.mockAdd({ + method: 'post', + url: '/auth/register', + data, + }, {error: 'test'}, 500) + T.Simulate.submit(node) + await getError(http.wait()) + expect(node.querySelector('.error')!.textContent) + .toMatch(/HTTP Status: 500/i) + }) + + }) + +}) diff --git a/packages/client/src/login/RegisterForm.tsx b/packages/client/src/login/RegisterForm.tsx new file mode 100644 index 0000000..769aa14 --- /dev/null +++ b/packages/client/src/login/RegisterForm.tsx @@ -0,0 +1,39 @@ +import React from 'react' +import {ICredentials} from '@rondo/common' +import {Input} from '../components/Input' + +export interface IRegisterFormProps { + error?: string + onSubmit: () => void + onChange: (name: string, value: string) => void + data: ICredentials +} + +export class RegisterForm extends React.PureComponent { + render() { + return ( + +

{this.props.error}

+ + + + + ) + } +} diff --git a/packages/client/src/login/index.ts b/packages/client/src/login/index.ts index 9f11045..56563f3 100644 --- a/packages/client/src/login/index.ts +++ b/packages/client/src/login/index.ts @@ -2,3 +2,5 @@ export * from './LoginActions' export * from './LoginConnector' export * from './LoginForm' export * from './LoginReducer' +export * from './RegisterForm' +export * from './RegisterConnector' diff --git a/packages/client/src/login/withForm.tsx b/packages/client/src/login/withForm.tsx new file mode 100644 index 0000000..ea38c88 --- /dev/null +++ b/packages/client/src/login/withForm.tsx @@ -0,0 +1,61 @@ +import React from 'react' + +export interface IComponentProps { + onSubmit: () => void + onChange: (name: string, value: string) => void + // TODO clear data on successful submission. This is important to prevent + // passwords being accidentally rendered in the background. + data: Data +} + +export interface IFormHOCProps { + onSubmit: (props: Data) => Promise + // TODO figure out what would happen if the underlying child component + // would have the same required property as the HOC, like onSuccess? + onSuccess?: () => void +} + +export function withForm>( + Component: React.ComponentType, + initialState: Data, +) { + + type OtherProps = Pick>> + type T = IFormHOCProps & OtherProps + + return class FormHOC extends React.PureComponent { + constructor(props: T) { + super(props) + this.state = initialState + } + handleSubmit = async (e: React.FormEvent) => { + const {onSuccess} = this.props + e.preventDefault() + await this.props.onSubmit(this.state) + if (onSuccess) { + onSuccess() + } + } + handleChange = (name: string, value: string) => { + this.setState( + {[name]: value} as unknown as Pick, + ) + } + render() { + const {children, onSuccess, onSubmit, ...otherProps} = this.props + + // Casting otherProps to any because type inference does not work when + // combinig OtherProps and ChildProps back to Component: + // https://github.com/Microsoft/TypeScript/issues/28938 + return ( + + ) + } + } +} diff --git a/packages/common/src/IAPIDef.ts b/packages/common/src/IAPIDef.ts index a39cda2..ebd1a73 100644 --- a/packages/common/src/IAPIDef.ts +++ b/packages/common/src/IAPIDef.ts @@ -7,6 +7,7 @@ export interface IAPIDef { '/auth/register': { 'post': { body: ICredentials + response: IUser } } '/auth/login': {