├── src ├── index.ts ├── binary │ ├── common │ │ ├── index.ts │ │ ├── binary.states.ts │ │ └── binary.events.ts │ ├── binary.machine.ts │ ├── index.ts │ └── two-steps-binary.machine.ts └── login │ ├── common │ ├── auth.error-messages.ts │ ├── auth.factory-options.ts │ ├── auth.context.ts │ ├── auth.guards.ts │ ├── index.ts │ ├── auth.states.ts │ ├── auth.actions.ts │ └── auth.events.ts │ ├── index.ts │ ├── email-and-password-login.machine.ts │ ├── username-and-password-login.machine.ts │ └── phone-number-and-password-login.machine.ts ├── .gitignore ├── test ├── binary.machine.test.ts ├── two-steps-binary.machine.test.ts ├── email-and-password-login.machine.test.ts ├── username-and-password-login.machine.test.ts └── phone-number-and-password-login.machine.test.ts ├── tsconfig.json ├── LICENSE ├── package.json └── README.md /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './binary'; 2 | export * from './login'; 3 | -------------------------------------------------------------------------------- /src/binary/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './binary.events'; 2 | export * from './binary.states'; 3 | -------------------------------------------------------------------------------- /src/login/common/auth.error-messages.ts: -------------------------------------------------------------------------------- 1 | export const INVALID_PASSWORD_MESSAGE = 'Password is not valid'; 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | .DS_Store 3 | node_modules 4 | .rts2_cache_cjs 5 | .rts2_cache_esm 6 | .rts2_cache_umd 7 | .rts2_cache_system 8 | dist 9 | -------------------------------------------------------------------------------- /src/login/common/auth.factory-options.ts: -------------------------------------------------------------------------------- 1 | export interface IAuthFactoryOptions { 2 | onLoggedIn: VoidFunction; 3 | invalidPasswordMessage?: string; 4 | } 5 | -------------------------------------------------------------------------------- /src/login/common/auth.context.ts: -------------------------------------------------------------------------------- 1 | export interface IAuthMachineBaseContext { 2 | formErrors: Array | null; 3 | loginError: Error | null; 4 | password: string; 5 | } 6 | -------------------------------------------------------------------------------- /src/binary/common/binary.states.ts: -------------------------------------------------------------------------------- 1 | export const StateA = 'A'; 2 | export const StateB = 'B'; 3 | export const FromStateAToStateB = 'A -> B'; 4 | export const FromStateBToStateA = 'A <- B'; 5 | -------------------------------------------------------------------------------- /src/login/common/auth.guards.ts: -------------------------------------------------------------------------------- 1 | import { Validator } from 'class-validator'; 2 | 3 | export const formIsValidGuard = 'formIsValidGuard'; 4 | 5 | export function validPassword(pwd: string) { 6 | return new Validator().isNotEmpty(pwd); 7 | } 8 | -------------------------------------------------------------------------------- /src/login/common/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.actions'; 2 | export * from './auth.context'; 3 | export * from './auth.events'; 4 | export * from './auth.guards'; 5 | export * from './auth.states'; 6 | export * from './auth.factory-options'; 7 | export * from './auth.error-messages'; 8 | -------------------------------------------------------------------------------- /src/login/common/auth.states.ts: -------------------------------------------------------------------------------- 1 | export const IdleState = 'Idle'; 2 | export const LoggingInState = 'Logging In'; 3 | export const LoggedInState = 'Logged In'; 4 | 5 | export interface IAuthMachineStates { 6 | states: { 7 | [IdleState]: {}; 8 | [LoggingInState]: {}; 9 | [LoggedInState]: {}; 10 | }; 11 | } 12 | -------------------------------------------------------------------------------- /src/binary/common/binary.events.ts: -------------------------------------------------------------------------------- 1 | export const TransitionToBEvent = 'TRANSITION_TO_B'; 2 | interface TransitionToBEvent { 3 | type: typeof TransitionToBEvent; 4 | } 5 | 6 | export const TransitionToAEvent = 'TRANSITION_TO_A'; 7 | interface TransitionToAEvent { 8 | type: typeof TransitionToAEvent; 9 | } 10 | 11 | export type IBinaryEvent = TransitionToBEvent | TransitionToAEvent; 12 | -------------------------------------------------------------------------------- /src/login/common/auth.actions.ts: -------------------------------------------------------------------------------- 1 | export const setPasswordAction = 'setPasswordAction'; 2 | export const setFormErrorAction = 'setFormErrorAction'; 3 | export const deleteFormErrorAction = 'deleteFormErrorAction'; 4 | export const setLoginErrorAction = 'setLoginErrorAction'; 5 | export const deleteLoginErrorAction = 'deleteLoginErrorAction'; 6 | export const whenLoggedInAction = 'whenLoggedInAction'; 7 | -------------------------------------------------------------------------------- /src/login/common/auth.events.ts: -------------------------------------------------------------------------------- 1 | export const TypePasswordEvent = 'TYPE_PASSWORD'; 2 | export interface TypePasswordEvent { 3 | type: typeof TypePasswordEvent; 4 | value: string; 5 | } 6 | 7 | export const LoginEvent = 'LOG_IN'; 8 | export interface LoginEvent { 9 | type: typeof LoginEvent; 10 | loginFn: (opts: LoginOpts) => Promise; 11 | } 12 | 13 | export interface LoginFailureEvent { 14 | type: 'error.execution'; 15 | data: { 16 | reason: Error; 17 | }; 18 | } 19 | 20 | export type IAuthBaseEvent = 21 | | TypePasswordEvent 22 | | LoginEvent 23 | | LoginFailureEvent; 24 | -------------------------------------------------------------------------------- /test/binary.machine.test.ts: -------------------------------------------------------------------------------- 1 | import { interpret } from 'xstate'; 2 | 3 | import { binaryMachine, BinaryStates, BinaryEvents } from '../src'; 4 | 5 | describe('Binary Machine', () => { 6 | it('"A" -> "B" -> "A"', done => { 7 | const service = interpret(binaryMachine) 8 | .start() 9 | .onTransition(current => { 10 | if (current.matches(BinaryStates.B)) { 11 | service.send(BinaryEvents.ToA); 12 | } 13 | 14 | if ( 15 | current.matches(BinaryStates.A) && 16 | current.event.type === BinaryEvents.ToA 17 | ) { 18 | done(); 19 | } 20 | }); 21 | 22 | service.send(BinaryEvents.ToB); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /src/binary/binary.machine.ts: -------------------------------------------------------------------------------- 1 | import { Machine } from 'xstate'; 2 | import { 3 | StateA, 4 | StateB, 5 | TransitionToAEvent, 6 | TransitionToBEvent, 7 | IBinaryEvent, 8 | } from './common'; 9 | 10 | interface IBinaryMachineStates { 11 | states: { 12 | [StateA]: {}; 13 | [StateB]: {}; 14 | }; 15 | } 16 | 17 | export const binaryMachine = Machine<{}, IBinaryMachineStates, IBinaryEvent>({ 18 | id: 'Binary Machine', 19 | initial: StateA, 20 | states: { 21 | [StateA]: { 22 | on: { 23 | [TransitionToBEvent]: StateB, 24 | }, 25 | }, 26 | [StateB]: { 27 | on: { 28 | [TransitionToAEvent]: StateA, 29 | }, 30 | }, 31 | }, 32 | }); 33 | -------------------------------------------------------------------------------- /src/binary/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | StateA, 3 | StateB, 4 | FromStateAToStateB, 5 | FromStateBToStateA, 6 | } from './common/binary.states'; 7 | import { TransitionToAEvent, TransitionToBEvent } from './common/binary.events'; 8 | 9 | export const BinaryStates: { 10 | A: typeof StateA; 11 | B: typeof StateB; 12 | 'A -> B': typeof FromStateAToStateB; 13 | 'A <- B': typeof FromStateBToStateA; 14 | } = { 15 | A: StateA, 16 | B: StateB, 17 | 'A -> B': FromStateAToStateB, 18 | 'A <- B': FromStateBToStateA, 19 | }; 20 | 21 | export const BinaryEvents: { 22 | ToA: typeof TransitionToAEvent; 23 | ToB: typeof TransitionToBEvent; 24 | } = { 25 | ToA: TransitionToAEvent, 26 | ToB: TransitionToBEvent, 27 | }; 28 | 29 | export * from './binary.machine'; 30 | export * from './two-steps-binary.machine'; 31 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["src", "types", "test"], 3 | "compilerOptions": { 4 | "target": "es5", 5 | "module": "esnext", 6 | "lib": ["dom", "esnext"], 7 | "importHelpers": true, 8 | "declaration": true, 9 | "sourceMap": true, 10 | "rootDir": "./", 11 | "strict": true, 12 | "noImplicitAny": true, 13 | "strictNullChecks": true, 14 | "strictFunctionTypes": true, 15 | "strictPropertyInitialization": true, 16 | "noImplicitThis": true, 17 | "alwaysStrict": true, 18 | "noUnusedLocals": true, 19 | "noUnusedParameters": true, 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | "moduleResolution": "node", 23 | "baseUrl": "./", 24 | "paths": { 25 | "*": ["src/*", "node_modules/*"] 26 | }, 27 | "jsx": "react", 28 | "esModuleInterop": true 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /test/two-steps-binary.machine.test.ts: -------------------------------------------------------------------------------- 1 | import { interpret } from 'xstate'; 2 | 3 | import { TwoStepsBinaryMachine, BinaryStates, BinaryEvents } from '../src'; 4 | 5 | const twoStepsBinaryMachine = TwoStepsBinaryMachine(100); 6 | 7 | describe('Two Steps Binary Machine', () => { 8 | it('"A" -> "A -> B" -> "B" -> "A <- B" -> "A"', done => { 9 | const service = interpret(twoStepsBinaryMachine) 10 | .start() 11 | .onTransition(current => { 12 | if (current.matches(BinaryStates['A -> B'])) { 13 | expect(current.event.type === BinaryEvents.ToB); 14 | } else if (current.matches(BinaryStates['A <- B'])) { 15 | expect(current.event.type === BinaryEvents.ToA); 16 | } else if (current.matches(BinaryStates.B)) { 17 | service.send(BinaryEvents.ToA); 18 | } else if (current.matches(BinaryStates.A)) { 19 | done(); 20 | } 21 | }); 22 | 23 | service.send(BinaryEvents.ToB); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 Carlos Camilo 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "ui-machines", 3 | "description": "XState machines for common UI patterns", 4 | "version": "0.0.15", 5 | "license": "MIT", 6 | "author": "Carlos Camilo Lobo Ulloque", 7 | "main": "dist/index.js", 8 | "module": "dist/ui-machines.esm.js", 9 | "repository": { 10 | "url": "https://github.com/Platekun/ui-machines" 11 | }, 12 | "typings": "dist/index.d.ts", 13 | "files": [ 14 | "dist" 15 | ], 16 | "scripts": { 17 | "start": "tsdx watch", 18 | "build": "tsdx build", 19 | "test": "tsdx test", 20 | "lint": "tsdx lint" 21 | }, 22 | "peerDependencies": {}, 23 | "husky": { 24 | "hooks": { 25 | "pre-commit": "tsdx lint" 26 | } 27 | }, 28 | "prettier": { 29 | "printWidth": 80, 30 | "semi": true, 31 | "singleQuote": true, 32 | "trailingComma": "es5" 33 | }, 34 | "devDependencies": { 35 | "@types/jest": "^24.0.22", 36 | "husky": "^3.0.9", 37 | "tsdx": "^0.11.0", 38 | "tslib": "^1.10.0", 39 | "typescript": "^3.7.2" 40 | }, 41 | "dependencies": { 42 | "class-validator": "^0.11.0", 43 | "titlecase": "^1.1.3", 44 | "xstate": "^4.6.7" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # TSDX Bootstrap 2 | 3 | This project was bootstrapped with [TSDX](https://github.com/jaredpalmer/tsdx). 4 | 5 | ## Local Development 6 | 7 | Below is a list of commands you will probably find useful. 8 | 9 | ### `npm start` or `yarn start` 10 | 11 | Runs the project in development/watch mode. Your project will be rebuilt upon changes. TSDX has a special logger for you convenience. Error messages are pretty printed and formatted for compatibility VS Code's Problems tab. 12 | 13 | 14 | 15 | Your library will be rebuilt if you make edits. 16 | 17 | ### `npm run build` or `yarn build` 18 | 19 | Bundles the package to the `dist` folder. 20 | The package is optimized and bundled with Rollup into multiple formats (CommonJS, UMD, and ES Module). 21 | 22 | 23 | 24 | ### `npm test` or `yarn test` 25 | 26 | Runs the test watcher (Jest) in an interactive mode. 27 | By default, runs tests related to files changed since the last commit. 28 | -------------------------------------------------------------------------------- /src/binary/two-steps-binary.machine.ts: -------------------------------------------------------------------------------- 1 | import { Machine } from 'xstate'; 2 | 3 | import { 4 | StateA, 5 | StateB, 6 | FromStateBToStateA, 7 | FromStateAToStateB, 8 | } from './common'; 9 | import { 10 | IBinaryEvent, 11 | TransitionToAEvent, 12 | TransitionToBEvent, 13 | } from './common/binary.events'; 14 | 15 | interface IDialogMachineStates { 16 | states: { 17 | [StateA]: {}; 18 | [StateB]: {}; 19 | [FromStateBToStateA]: {}; 20 | [FromStateAToStateB]: {}; 21 | }; 22 | } 23 | 24 | export function TwoStepsBinaryMachine(delayTimeInMs: number) { 25 | return Machine<{}, IDialogMachineStates, IBinaryEvent>({ 26 | id: 'Two Steps Binary Machine', 27 | initial: StateB, 28 | states: { 29 | [StateA]: { 30 | on: { 31 | [TransitionToBEvent]: FromStateAToStateB, 32 | }, 33 | }, 34 | [StateB]: { 35 | on: { 36 | [TransitionToAEvent]: FromStateBToStateA, 37 | }, 38 | }, 39 | [FromStateBToStateA]: { 40 | after: { 41 | [delayTimeInMs]: StateA, 42 | }, 43 | }, 44 | [FromStateAToStateB]: { 45 | after: { 46 | [delayTimeInMs]: StateB, 47 | }, 48 | }, 49 | }, 50 | }); 51 | } 52 | -------------------------------------------------------------------------------- /src/login/index.ts: -------------------------------------------------------------------------------- 1 | import { IdleState, LoggingInState, LoggedInState } from './common/auth.states'; 2 | import { LoginEvent, TypePasswordEvent } from './common/auth.events'; 3 | import { TypeUsernameEvent } from './username-and-password-login.machine'; 4 | import { TypePhoneNumber } from './phone-number-and-password-login.machine'; 5 | import { TypeEmailEvent } from './email-and-password-login.machine'; 6 | 7 | export const AuthEvents: { 8 | Login: typeof LoginEvent; 9 | TypePassword: typeof TypePasswordEvent; 10 | TypeUsername: typeof TypeUsernameEvent; 11 | TypePhoneNumber: typeof TypePhoneNumber; 12 | TypeEmail: typeof TypeEmailEvent; 13 | } = { 14 | Login: LoginEvent, 15 | TypePassword: TypePasswordEvent, 16 | TypeUsername: TypeUsernameEvent, 17 | TypePhoneNumber: TypePhoneNumber, 18 | TypeEmail: TypeEmailEvent, 19 | }; 20 | 21 | export const AuthStates: { 22 | Idle: typeof IdleState; 23 | LoggingIn: typeof LoggingInState; 24 | LoggedIn: typeof LoggedInState; 25 | } = { 26 | Idle: IdleState, 27 | LoggingIn: LoggingInState, 28 | LoggedIn: LoggedInState, 29 | }; 30 | 31 | export { 32 | EmailAndPasswordLoginMachine, 33 | } from './email-and-password-login.machine'; 34 | export { 35 | PhoneNumberPasswordLoginMachine, 36 | } from './phone-number-and-password-login.machine'; 37 | export { 38 | UserNameAndPasswordLoginMachine, 39 | } from './username-and-password-login.machine'; 40 | -------------------------------------------------------------------------------- /src/login/email-and-password-login.machine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, assign } from 'xstate'; 2 | import { Validator } from 'class-validator'; 3 | 4 | import { 5 | IAuthMachineBaseContext, 6 | IAuthMachineStates, 7 | IdleState, 8 | LoggingInState, 9 | LoggedInState, 10 | TypePasswordEvent, 11 | deleteFormErrorAction, 12 | deleteLoginErrorAction, 13 | setFormErrorAction, 14 | setLoginErrorAction, 15 | IAuthBaseEvent, 16 | LoginFailureEvent, 17 | formIsValidGuard, 18 | setPasswordAction, 19 | LoginEvent, 20 | validPassword, 21 | IAuthFactoryOptions, 22 | whenLoggedInAction, 23 | INVALID_PASSWORD_MESSAGE, 24 | } from './common'; 25 | 26 | interface IAuthMachineContext extends IAuthMachineBaseContext { 27 | email: string; 28 | } 29 | 30 | export const TypeEmailEvent = 'TYPE_EMAIL'; 31 | interface TypeEmailEvent { 32 | type: typeof TypeEmailEvent; 33 | value: string; 34 | } 35 | 36 | type LoginOpts = Pick; 37 | const setEmailAction = 'setEmail'; 38 | 39 | type IAuthMachineEvent = TypeEmailEvent | IAuthBaseEvent; 40 | 41 | function validEmail(email: string) { 42 | return new Validator().isEmail(email); 43 | } 44 | 45 | export const INVALID_EMAIL_MESSAGE = 'Email is not valid'; 46 | 47 | interface IEmailAndPasswordLoginFactoryOptions extends IAuthFactoryOptions { 48 | invalidEmailMessage?: string; 49 | } 50 | 51 | export function EmailAndPasswordLoginMachine( 52 | opts: IEmailAndPasswordLoginFactoryOptions 53 | ) { 54 | return Machine( 55 | { 56 | id: 'Login machine with username and password', 57 | initial: IdleState, 58 | context: { 59 | email: '', 60 | password: '', 61 | formErrors: null, 62 | loginError: null, 63 | }, 64 | states: { 65 | [IdleState]: { 66 | on: { 67 | [TypeEmailEvent]: { 68 | actions: [setEmailAction], 69 | }, 70 | [TypePasswordEvent]: { 71 | actions: [setPasswordAction], 72 | }, 73 | [LoginEvent]: [ 74 | { 75 | target: LoggingInState, 76 | actions: [deleteFormErrorAction, deleteLoginErrorAction], 77 | cond: formIsValidGuard, 78 | }, 79 | { 80 | actions: [setFormErrorAction], 81 | }, 82 | ], 83 | }, 84 | }, 85 | [LoggingInState]: { 86 | invoke: { 87 | id: 'Login', 88 | src: (ctx: IAuthMachineContext, e) => 89 | (e as LoginEvent).loginFn({ 90 | email: ctx.email, 91 | password: ctx.password, 92 | }), 93 | onDone: { 94 | target: LoggedInState, 95 | actions: [whenLoggedInAction], 96 | }, 97 | onError: { 98 | target: IdleState, 99 | actions: [setLoginErrorAction], 100 | }, 101 | }, 102 | }, 103 | [LoggedInState]: { 104 | type: 'final', 105 | }, 106 | }, 107 | }, 108 | { 109 | actions: { 110 | [setEmailAction]: assign({ 111 | email: (_ctx, e) => (e as TypeEmailEvent).value, 112 | }), 113 | [setPasswordAction]: assign({ 114 | password: (_ctx, e) => (e as TypePasswordEvent).value, 115 | }), 116 | [setFormErrorAction]: assign({ 117 | formErrors: (ctx: IAuthMachineContext) => { 118 | let errors: Array = []; 119 | 120 | if (!validEmail(ctx.email)) { 121 | errors.push( 122 | new Error(opts.invalidEmailMessage || INVALID_EMAIL_MESSAGE) 123 | ); 124 | } 125 | 126 | if (!validPassword(ctx.password)) { 127 | errors.push( 128 | new Error( 129 | opts.invalidPasswordMessage || INVALID_PASSWORD_MESSAGE 130 | ) 131 | ); 132 | } 133 | 134 | return errors; 135 | }, 136 | }), 137 | [deleteFormErrorAction]: assign( 138 | { 139 | formErrors: null, 140 | } 141 | ), 142 | [setLoginErrorAction]: assign({ 143 | loginError: (_ctx, e) => (e as LoginFailureEvent).data.reason, 144 | }), 145 | [deleteLoginErrorAction]: assign< 146 | IAuthMachineContext, 147 | IAuthMachineEvent 148 | >({ 149 | loginError: null, 150 | }), 151 | [whenLoggedInAction]: opts.onLoggedIn, 152 | }, 153 | guards: { 154 | [formIsValidGuard]: (ctx: IAuthMachineContext) => 155 | validEmail(ctx.email) && validPassword(ctx.password), 156 | }, 157 | } 158 | ); 159 | } 160 | -------------------------------------------------------------------------------- /src/login/username-and-password-login.machine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, assign } from 'xstate'; 2 | import { Validator } from 'class-validator'; 3 | 4 | import { 5 | IAuthMachineBaseContext, 6 | IAuthMachineStates, 7 | IdleState, 8 | LoggingInState, 9 | LoggedInState, 10 | TypePasswordEvent, 11 | deleteFormErrorAction, 12 | deleteLoginErrorAction, 13 | setFormErrorAction, 14 | setLoginErrorAction, 15 | IAuthBaseEvent, 16 | LoginFailureEvent, 17 | formIsValidGuard, 18 | setPasswordAction, 19 | LoginEvent, 20 | validPassword, 21 | whenLoggedInAction, 22 | IAuthFactoryOptions, 23 | INVALID_PASSWORD_MESSAGE, 24 | } from './common'; 25 | 26 | interface IAuthMachineContext extends IAuthMachineBaseContext { 27 | username: string; 28 | } 29 | 30 | export const TypeUsernameEvent = 'TYPE_USERNAME'; 31 | interface TypeUsernameEvent { 32 | type: typeof TypeUsernameEvent; 33 | value: string; 34 | } 35 | 36 | type LoginOpts = Pick; 37 | const setUsernameAction = 'setUsername'; 38 | 39 | type IAuthMachineEvent = TypeUsernameEvent | IAuthBaseEvent; 40 | 41 | function validUsername(username: string) { 42 | return new Validator().isNotEmpty(username); 43 | } 44 | 45 | export const INVALID_USERNAME_MESSAGE = 'Username is not valid'; 46 | 47 | interface IUsernameAndPasswordLoginFactoryOptions extends IAuthFactoryOptions { 48 | invalidUsernameMessage?: string; 49 | } 50 | 51 | export function UserNameAndPasswordLoginMachine( 52 | opts: IUsernameAndPasswordLoginFactoryOptions 53 | ) { 54 | return Machine( 55 | { 56 | id: 'Login machine with username and password', 57 | initial: IdleState, 58 | context: { 59 | username: '', 60 | password: '', 61 | formErrors: null, 62 | loginError: null, 63 | }, 64 | states: { 65 | [IdleState]: { 66 | on: { 67 | [TypeUsernameEvent]: { 68 | actions: [setUsernameAction], 69 | }, 70 | [TypePasswordEvent]: { 71 | actions: [setPasswordAction], 72 | }, 73 | [LoginEvent]: [ 74 | { 75 | target: LoggingInState, 76 | actions: [deleteFormErrorAction, deleteLoginErrorAction], 77 | cond: formIsValidGuard, 78 | }, 79 | { 80 | actions: [setFormErrorAction], 81 | }, 82 | ], 83 | }, 84 | }, 85 | [LoggingInState]: { 86 | invoke: { 87 | id: 'Login', 88 | src: (ctx: IAuthMachineContext, e) => 89 | (e as LoginEvent).loginFn({ 90 | username: ctx.username, 91 | password: ctx.password, 92 | }), 93 | onDone: { 94 | target: LoggedInState, 95 | actions: [whenLoggedInAction], 96 | }, 97 | onError: { 98 | target: IdleState, 99 | actions: [setLoginErrorAction], 100 | }, 101 | }, 102 | }, 103 | [LoggedInState]: { 104 | type: 'final', 105 | }, 106 | }, 107 | }, 108 | { 109 | actions: { 110 | [setUsernameAction]: assign({ 111 | username: (_ctx, e) => (e as TypeUsernameEvent).value, 112 | }), 113 | [setPasswordAction]: assign({ 114 | password: (_ctx, e) => (e as TypePasswordEvent).value, 115 | }), 116 | [setFormErrorAction]: assign({ 117 | formErrors: (ctx: IAuthMachineContext) => { 118 | let errors: Array = []; 119 | 120 | if (!validUsername(ctx.username)) { 121 | errors.push( 122 | new Error( 123 | opts.invalidUsernameMessage || INVALID_USERNAME_MESSAGE 124 | ) 125 | ); 126 | } 127 | 128 | if (!validPassword(ctx.password)) { 129 | errors.push( 130 | new Error( 131 | opts.invalidPasswordMessage || INVALID_PASSWORD_MESSAGE 132 | ) 133 | ); 134 | } 135 | 136 | return errors; 137 | }, 138 | }), 139 | [deleteFormErrorAction]: assign( 140 | { 141 | formErrors: null, 142 | } 143 | ), 144 | [setLoginErrorAction]: assign({ 145 | loginError: (_ctx, e) => (e as LoginFailureEvent).data.reason, 146 | }), 147 | [deleteLoginErrorAction]: assign< 148 | IAuthMachineContext, 149 | IAuthMachineEvent 150 | >({ 151 | loginError: null, 152 | }), 153 | [whenLoggedInAction]: opts.onLoggedIn, 154 | }, 155 | guards: { 156 | [formIsValidGuard]: (ctx: IAuthMachineContext) => 157 | validUsername(ctx.username) && validPassword(ctx.password), 158 | }, 159 | } 160 | ); 161 | } 162 | -------------------------------------------------------------------------------- /src/login/phone-number-and-password-login.machine.ts: -------------------------------------------------------------------------------- 1 | import { Machine, assign } from 'xstate'; 2 | import { Validator } from 'class-validator'; 3 | 4 | import { 5 | IAuthMachineBaseContext, 6 | IAuthMachineStates, 7 | IdleState, 8 | LoggingInState, 9 | LoggedInState, 10 | TypePasswordEvent, 11 | deleteFormErrorAction, 12 | deleteLoginErrorAction, 13 | setFormErrorAction, 14 | setLoginErrorAction, 15 | IAuthBaseEvent, 16 | LoginFailureEvent, 17 | formIsValidGuard, 18 | setPasswordAction, 19 | LoginEvent, 20 | validPassword, 21 | whenLoggedInAction, 22 | IAuthFactoryOptions, 23 | INVALID_PASSWORD_MESSAGE, 24 | } from './common'; 25 | 26 | interface IAuthMachineContext extends IAuthMachineBaseContext { 27 | phoneNumber: string; 28 | } 29 | 30 | export const TypePhoneNumber = 'TYPE_PHONE_NUMBER'; 31 | interface TypePhoneNumber { 32 | type: typeof TypePhoneNumber; 33 | value: string; 34 | } 35 | 36 | type LoginOpts = Pick; 37 | const setPhoneNumber = 'setPhoneNumber'; 38 | 39 | type IAuthMachineEvent = TypePhoneNumber | IAuthBaseEvent; 40 | 41 | function validPhoneNumber(phoneNumber: string, region: string) { 42 | return new Validator().isPhoneNumber(phoneNumber, region); 43 | } 44 | 45 | export const INVALID_PHONE_NUMBER_MESSAGE = 'Phone number is not valid'; 46 | 47 | interface IPhoneNumberAndPasswordLoginFactoryOptions 48 | extends IAuthFactoryOptions { 49 | /** 50 | * Country code of the Phone Number to be used 51 | */ 52 | region: string; 53 | invalidPhoneNumberMessage?: string; 54 | } 55 | 56 | export function PhoneNumberPasswordLoginMachine( 57 | opts: IPhoneNumberAndPasswordLoginFactoryOptions 58 | ) { 59 | return Machine( 60 | { 61 | id: 'Login machine with username and password', 62 | initial: IdleState, 63 | context: { 64 | phoneNumber: '', 65 | password: '', 66 | formErrors: null, 67 | loginError: null, 68 | }, 69 | states: { 70 | [IdleState]: { 71 | on: { 72 | [TypePhoneNumber]: { 73 | actions: [setPhoneNumber], 74 | }, 75 | [TypePasswordEvent]: { 76 | actions: [setPasswordAction], 77 | }, 78 | [LoginEvent]: [ 79 | { 80 | target: LoggingInState, 81 | actions: [deleteFormErrorAction, deleteLoginErrorAction], 82 | cond: formIsValidGuard, 83 | }, 84 | { 85 | actions: [setFormErrorAction], 86 | }, 87 | ], 88 | }, 89 | }, 90 | [LoggingInState]: { 91 | invoke: { 92 | id: 'Login', 93 | src: (ctx: IAuthMachineContext, e) => 94 | (e as LoginEvent).loginFn({ 95 | phoneNumber: ctx.phoneNumber, 96 | password: ctx.password, 97 | }), 98 | onDone: { 99 | target: LoggedInState, 100 | actions: [whenLoggedInAction], 101 | }, 102 | onError: { 103 | target: IdleState, 104 | actions: [setLoginErrorAction], 105 | }, 106 | }, 107 | }, 108 | [LoggedInState]: { 109 | type: 'final', 110 | }, 111 | }, 112 | }, 113 | { 114 | actions: { 115 | [setPhoneNumber]: assign({ 116 | phoneNumber: (_ctx, e) => (e as TypePhoneNumber).value, 117 | }), 118 | [setPasswordAction]: assign({ 119 | password: (_ctx, e) => (e as TypePasswordEvent).value, 120 | }), 121 | [setFormErrorAction]: assign({ 122 | formErrors: (ctx: IAuthMachineContext) => { 123 | let errors: Array = []; 124 | 125 | if (!validPhoneNumber(ctx.phoneNumber, opts.region)) { 126 | errors.push( 127 | new Error( 128 | opts.invalidPhoneNumberMessage || INVALID_PHONE_NUMBER_MESSAGE 129 | ) 130 | ); 131 | } 132 | 133 | if (!validPassword(ctx.password)) { 134 | errors.push( 135 | new Error( 136 | opts.invalidPasswordMessage || INVALID_PASSWORD_MESSAGE 137 | ) 138 | ); 139 | } 140 | 141 | return errors; 142 | }, 143 | }), 144 | [deleteFormErrorAction]: assign( 145 | { 146 | formErrors: null, 147 | } 148 | ), 149 | [setLoginErrorAction]: assign({ 150 | loginError: (_ctx, e) => (e as LoginFailureEvent).data.reason, 151 | }), 152 | [deleteLoginErrorAction]: assign< 153 | IAuthMachineContext, 154 | IAuthMachineEvent 155 | >({ 156 | loginError: null, 157 | }), 158 | [whenLoggedInAction]: opts.onLoggedIn, 159 | }, 160 | guards: { 161 | [formIsValidGuard]: (ctx: IAuthMachineContext) => 162 | validPhoneNumber(ctx.phoneNumber, opts.region) && 163 | validPassword(ctx.password), 164 | }, 165 | } 166 | ); 167 | } 168 | -------------------------------------------------------------------------------- /test/email-and-password-login.machine.test.ts: -------------------------------------------------------------------------------- 1 | import { interpret } from 'xstate'; 2 | 3 | import { EmailAndPasswordLoginMachine, AuthEvents, AuthStates } from '../src'; 4 | import { INVALID_PASSWORD_MESSAGE } from '../src/login/common'; 5 | import { INVALID_EMAIL_MESSAGE } from '../src/login/email-and-password-login.machine'; 6 | 7 | const testEmail = 'johndoe@test.com'; 8 | const testPassword = 'I Love Cookies'; 9 | const invalidEmailMessage = 'You cannot use that email name'; 10 | const invalidPasswordMessage = 'You cannot use that password'; 11 | const loginFailureMessage = 'Woops. Login Failed for some reason'; 12 | 13 | describe('Email and Password Login Machine', () => { 14 | it('types email, types password and logs in', done => { 15 | const onLoggedIn = jest.fn().mockImplementation(); 16 | const loginFn = jest.fn().mockImplementation(({ email, password }) => { 17 | expect(email).toBe(testEmail); 18 | expect(password).toBe(testPassword); 19 | 20 | return Promise.resolve(); 21 | }); 22 | 23 | const emailAndPasswordMachine = EmailAndPasswordLoginMachine({ 24 | onLoggedIn, 25 | }); 26 | 27 | const service = interpret(emailAndPasswordMachine) 28 | .start() 29 | .onTransition(current => { 30 | if (current.event.type === AuthEvents.TypeEmail) { 31 | service.send({ type: AuthEvents.TypePassword, value: testPassword }); 32 | } 33 | 34 | if (current.event.type === AuthEvents.TypePassword) { 35 | service.send({ type: AuthEvents.Login, loginFn }); 36 | } 37 | 38 | if (current.matches(AuthStates.LoggedIn)) { 39 | expect(onLoggedIn).toHaveBeenCalledTimes(1); 40 | expect(loginFn).toHaveBeenCalledTimes(1); 41 | } 42 | }) 43 | .onDone(() => { 44 | done(); 45 | }); 46 | 47 | service.send({ type: AuthEvents.TypeEmail, value: testEmail }); 48 | }); 49 | 50 | it('prevents from logging in with invalid parameters', done => { 51 | const onLoggedIn = jest.fn().mockImplementation(); 52 | const loginFn = jest.fn(); 53 | 54 | const emailAndPasswordMachine = EmailAndPasswordLoginMachine({ 55 | onLoggedIn, 56 | }); 57 | 58 | const service = interpret(emailAndPasswordMachine) 59 | .start() 60 | .onTransition(current => { 61 | if ( 62 | current.matches(AuthStates.Idle) && 63 | current.event.type === AuthEvents.Login 64 | ) { 65 | expect(onLoggedIn).not.toHaveBeenCalled(); 66 | expect(loginFn).not.toHaveBeenCalled(); 67 | expect(current.context.formErrors).toHaveLength(2); 68 | expect( 69 | (current.context.formErrors as Array)[0].message 70 | ).toEqual(INVALID_EMAIL_MESSAGE); 71 | expect( 72 | (current.context.formErrors as Array)[1].message 73 | ).toEqual(INVALID_PASSWORD_MESSAGE); 74 | done(); 75 | } 76 | }); 77 | 78 | service.send({ type: AuthEvents.Login, loginFn }); 79 | }); 80 | 81 | it('allows custom error messages for invalid email and password', done => { 82 | const onLoggedIn = jest.fn().mockImplementation(); 83 | const loginFn = jest.fn(); 84 | 85 | const emailAndPasswordMachine = EmailAndPasswordLoginMachine({ 86 | onLoggedIn, 87 | invalidEmailMessage, 88 | invalidPasswordMessage, 89 | }); 90 | 91 | const service = interpret(emailAndPasswordMachine) 92 | .start() 93 | .onTransition(current => { 94 | if ( 95 | current.matches(AuthStates.Idle) && 96 | current.event.type === AuthEvents.Login 97 | ) { 98 | expect(loginFn).not.toHaveBeenCalled(); 99 | expect(onLoggedIn).not.toHaveBeenCalled(); 100 | expect(current.context.formErrors).toHaveLength(2); 101 | expect( 102 | (current.context.formErrors as Array)[0].message 103 | ).toEqual(invalidEmailMessage); 104 | expect( 105 | (current.context.formErrors as Array)[1].message 106 | ).toEqual(invalidPasswordMessage); 107 | done(); 108 | } 109 | }); 110 | 111 | service.send({ type: AuthEvents.Login, loginFn }); 112 | }); 113 | 114 | it('types email, types password, fails to log in', done => { 115 | const onLoggedIn = jest.fn().mockImplementation(); 116 | const loginFn = jest 117 | .fn() 118 | .mockRejectedValue({ reason: new Error(loginFailureMessage) }); 119 | 120 | const emailAndPasswordMachine = EmailAndPasswordLoginMachine({ 121 | onLoggedIn, 122 | }); 123 | 124 | const service = interpret(emailAndPasswordMachine) 125 | .start() 126 | .onTransition(current => { 127 | if (current.event.type === AuthEvents.TypeEmail) { 128 | service.send({ type: AuthEvents.TypePassword, value: testPassword }); 129 | } else if (current.event.type === AuthEvents.TypePassword) { 130 | service.send({ type: AuthEvents.Login, loginFn }); 131 | } else if (current.matches(AuthStates.Idle)) { 132 | expect(loginFn).toHaveBeenCalledTimes(1); 133 | expect(onLoggedIn).not.toHaveBeenCalled(); 134 | expect((current.context.loginError as Error).message).toBe( 135 | loginFailureMessage 136 | ); 137 | done(); 138 | } 139 | }); 140 | 141 | service.send({ type: AuthEvents.TypeEmail, value: testEmail }); 142 | }); 143 | }); 144 | -------------------------------------------------------------------------------- /test/username-and-password-login.machine.test.ts: -------------------------------------------------------------------------------- 1 | import { interpret } from 'xstate'; 2 | 3 | import { 4 | UserNameAndPasswordLoginMachine, 5 | AuthEvents, 6 | AuthStates, 7 | } from '../src'; 8 | import { INVALID_PASSWORD_MESSAGE } from '../src/login/common'; 9 | import { INVALID_USERNAME_MESSAGE } from '../src/login/username-and-password-login.machine'; 10 | 11 | const testUsername = 'JohnDoe'; 12 | const testPassword = 'I Love Cookies'; 13 | const invalidUsernameMessage = 'You cannot use that user name'; 14 | const invalidPasswordMessage = 'You cannot use that password'; 15 | const loginFailureMessage = 'Woops. Login Failed for some reason'; 16 | 17 | describe('Username and Password Login Machine', () => { 18 | it('types username, types password and logs in', done => { 19 | const onLoggedIn = jest.fn().mockImplementation(); 20 | const loginFn = jest.fn().mockImplementation(({ username, password }) => { 21 | expect(username).toBe(testUsername); 22 | expect(password).toBe(testPassword); 23 | 24 | return Promise.resolve(); 25 | }); 26 | 27 | const userNameAndPasswordLoginMachine = UserNameAndPasswordLoginMachine({ 28 | onLoggedIn, 29 | }); 30 | 31 | const service = interpret(userNameAndPasswordLoginMachine) 32 | .start() 33 | .onTransition(current => { 34 | if (current.event.type === AuthEvents.TypeUsername) { 35 | service.send({ type: AuthEvents.TypePassword, value: testPassword }); 36 | } 37 | 38 | if (current.event.type === AuthEvents.TypePassword) { 39 | service.send({ type: AuthEvents.Login, loginFn }); 40 | } 41 | 42 | if (current.matches(AuthStates.LoggedIn)) { 43 | expect(onLoggedIn).toHaveBeenCalledTimes(1); 44 | expect(loginFn).toHaveBeenCalledTimes(1); 45 | } 46 | }) 47 | .onDone(() => { 48 | done(); 49 | }); 50 | 51 | service.send({ type: AuthEvents.TypeUsername, value: testUsername }); 52 | }); 53 | 54 | it('prevents from logging in with invalid parameters', done => { 55 | const onLoggedIn = jest.fn().mockImplementation(); 56 | const loginFn = jest.fn(); 57 | 58 | const userNameAndPasswordLoginMachine = UserNameAndPasswordLoginMachine({ 59 | onLoggedIn, 60 | }); 61 | 62 | const service = interpret(userNameAndPasswordLoginMachine) 63 | .start() 64 | .onTransition(current => { 65 | if ( 66 | current.matches(AuthStates.Idle) && 67 | current.event.type === AuthEvents.Login 68 | ) { 69 | expect(onLoggedIn).not.toHaveBeenCalled(); 70 | expect(loginFn).not.toHaveBeenCalled(); 71 | expect(current.context.formErrors).toHaveLength(2); 72 | expect( 73 | (current.context.formErrors as Array)[0].message 74 | ).toEqual(INVALID_USERNAME_MESSAGE); 75 | expect( 76 | (current.context.formErrors as Array)[1].message 77 | ).toEqual(INVALID_PASSWORD_MESSAGE); 78 | done(); 79 | } 80 | }); 81 | 82 | service.send({ type: AuthEvents.Login, loginFn }); 83 | }); 84 | 85 | it('allows custom error messages for invalid username and password', done => { 86 | const onLoggedIn = jest.fn().mockImplementation(); 87 | const loginFn = jest.fn(); 88 | 89 | const userNameAndPasswordLoginMachine = UserNameAndPasswordLoginMachine({ 90 | onLoggedIn, 91 | invalidUsernameMessage, 92 | invalidPasswordMessage, 93 | }); 94 | 95 | const service = interpret(userNameAndPasswordLoginMachine) 96 | .start() 97 | .onTransition(current => { 98 | if ( 99 | current.matches(AuthStates.Idle) && 100 | current.event.type === AuthEvents.Login 101 | ) { 102 | expect(loginFn).not.toHaveBeenCalled(); 103 | expect(onLoggedIn).not.toHaveBeenCalled(); 104 | expect(current.context.formErrors).toHaveLength(2); 105 | expect( 106 | (current.context.formErrors as Array)[0].message 107 | ).toEqual(invalidUsernameMessage); 108 | expect( 109 | (current.context.formErrors as Array)[1].message 110 | ).toEqual(invalidPasswordMessage); 111 | done(); 112 | } 113 | }); 114 | 115 | service.send({ type: AuthEvents.Login, loginFn }); 116 | }); 117 | 118 | it('types username, types password, fails to log in', done => { 119 | const onLoggedIn = jest.fn().mockImplementation(); 120 | const loginFn = jest 121 | .fn() 122 | .mockRejectedValue({ reason: new Error(loginFailureMessage) }); 123 | 124 | const userNameAndPasswordLoginMachine = UserNameAndPasswordLoginMachine({ 125 | onLoggedIn, 126 | }); 127 | 128 | const service = interpret(userNameAndPasswordLoginMachine) 129 | .start() 130 | .onTransition(current => { 131 | if (current.event.type === AuthEvents.TypeUsername) { 132 | service.send({ type: AuthEvents.TypePassword, value: testPassword }); 133 | } else if (current.event.type === AuthEvents.TypePassword) { 134 | service.send({ type: AuthEvents.Login, loginFn }); 135 | } else if (current.matches(AuthStates.Idle)) { 136 | expect(loginFn).toHaveBeenCalledTimes(1); 137 | expect(onLoggedIn).not.toHaveBeenCalled(); 138 | expect((current.context.loginError as Error).message).toBe( 139 | loginFailureMessage 140 | ); 141 | done(); 142 | } 143 | }); 144 | 145 | service.send({ type: AuthEvents.TypeUsername, value: testUsername }); 146 | }); 147 | }); 148 | -------------------------------------------------------------------------------- /test/phone-number-and-password-login.machine.test.ts: -------------------------------------------------------------------------------- 1 | import { interpret } from 'xstate'; 2 | 3 | import { 4 | PhoneNumberPasswordLoginMachine, 5 | AuthEvents, 6 | AuthStates, 7 | } from '../src'; 8 | import { INVALID_PASSWORD_MESSAGE } from '../src/login/common'; 9 | import { INVALID_PHONE_NUMBER_MESSAGE } from '../src/login/phone-number-and-password-login.machine'; 10 | 11 | const region = 'CO'; 12 | /** 13 | * ? Valid Colombian phone number 14 | */ 15 | const testPhoneNumber = '+573034042344'; 16 | const testPassword = 'I Love Cookies'; 17 | const invalidPhoneNumberMessage = 'You cannot use that phone number'; 18 | const invalidPasswordMessage = 'You cannot use that password'; 19 | const loginFailureMessage = 'Woops. Login Failed for some reason'; 20 | 21 | describe('Phone Number and Password Login Machine', () => { 22 | it('types phone number, types password and logs in', done => { 23 | const onLoggedIn = jest.fn().mockImplementation(); 24 | const loginFn = jest 25 | .fn() 26 | .mockImplementation(({ phoneNumber, password }) => { 27 | expect(phoneNumber).toBe(testPhoneNumber); 28 | expect(password).toBe(testPassword); 29 | 30 | return Promise.resolve(); 31 | }); 32 | 33 | const phoneNumberandPasswordLoginMachine = PhoneNumberPasswordLoginMachine({ 34 | region, 35 | onLoggedIn, 36 | }); 37 | 38 | const service = interpret(phoneNumberandPasswordLoginMachine) 39 | .start() 40 | .onTransition(current => { 41 | if (current.event.type === AuthEvents.TypePhoneNumber) { 42 | service.send({ type: AuthEvents.TypePassword, value: testPassword }); 43 | } 44 | 45 | if (current.event.type === AuthEvents.TypePassword) { 46 | service.send({ type: AuthEvents.Login, loginFn }); 47 | } 48 | 49 | if (current.matches(AuthStates.LoggedIn)) { 50 | expect(onLoggedIn).toHaveBeenCalledTimes(1); 51 | expect(loginFn).toHaveBeenCalledTimes(1); 52 | } 53 | }) 54 | .onDone(() => { 55 | done(); 56 | }); 57 | 58 | service.send({ type: AuthEvents.TypePhoneNumber, value: testPhoneNumber }); 59 | }); 60 | 61 | it('prevents from logging in with invalid parameters', done => { 62 | const onLoggedIn = jest.fn().mockImplementation(); 63 | const loginFn = jest.fn(); 64 | 65 | const phoneNumberandPasswordLoginMachine = PhoneNumberPasswordLoginMachine({ 66 | region, 67 | onLoggedIn, 68 | }); 69 | 70 | const service = interpret(phoneNumberandPasswordLoginMachine) 71 | .start() 72 | .onTransition(current => { 73 | if ( 74 | current.matches(AuthStates.Idle) && 75 | current.event.type === AuthEvents.Login 76 | ) { 77 | expect(onLoggedIn).not.toHaveBeenCalled(); 78 | expect(loginFn).not.toHaveBeenCalled(); 79 | expect(current.context.formErrors).toHaveLength(2); 80 | expect( 81 | (current.context.formErrors as Array)[0].message 82 | ).toEqual(INVALID_PHONE_NUMBER_MESSAGE); 83 | expect( 84 | (current.context.formErrors as Array)[1].message 85 | ).toEqual(INVALID_PASSWORD_MESSAGE); 86 | done(); 87 | } 88 | }); 89 | 90 | service.send({ type: AuthEvents.Login, loginFn }); 91 | }); 92 | 93 | it('allows custom error messages for invalid phone number and password', done => { 94 | const onLoggedIn = jest.fn().mockImplementation(); 95 | const loginFn = jest.fn(); 96 | 97 | const phoneNumberandPasswordLoginMachine = PhoneNumberPasswordLoginMachine({ 98 | region, 99 | onLoggedIn, 100 | invalidPhoneNumberMessage, 101 | invalidPasswordMessage, 102 | }); 103 | 104 | const service = interpret(phoneNumberandPasswordLoginMachine) 105 | .start() 106 | .onTransition(current => { 107 | if ( 108 | current.matches(AuthStates.Idle) && 109 | current.event.type === AuthEvents.Login 110 | ) { 111 | expect(loginFn).not.toHaveBeenCalled(); 112 | expect(onLoggedIn).not.toHaveBeenCalled(); 113 | expect(current.context.formErrors).toHaveLength(2); 114 | expect( 115 | (current.context.formErrors as Array)[0].message 116 | ).toEqual(invalidPhoneNumberMessage); 117 | expect( 118 | (current.context.formErrors as Array)[1].message 119 | ).toEqual(invalidPasswordMessage); 120 | done(); 121 | } 122 | }); 123 | 124 | service.send({ type: AuthEvents.Login, loginFn }); 125 | }); 126 | 127 | it('types phone number, types password, fails to log in', done => { 128 | const onLoggedIn = jest.fn().mockImplementation(); 129 | const loginFn = jest 130 | .fn() 131 | .mockRejectedValue({ reason: new Error(loginFailureMessage) }); 132 | 133 | const phoneNumberandPasswordLoginMachine = PhoneNumberPasswordLoginMachine({ 134 | region, 135 | onLoggedIn, 136 | }); 137 | 138 | const service = interpret(phoneNumberandPasswordLoginMachine) 139 | .start() 140 | .onTransition(current => { 141 | if (current.event.type === AuthEvents.TypePhoneNumber) { 142 | service.send({ type: AuthEvents.TypePassword, value: testPassword }); 143 | } else if (current.event.type === AuthEvents.TypePassword) { 144 | service.send({ type: AuthEvents.Login, loginFn }); 145 | } else if (current.matches(AuthStates.Idle)) { 146 | expect(loginFn).toHaveBeenCalledTimes(1); 147 | expect(onLoggedIn).not.toHaveBeenCalled(); 148 | expect((current.context.loginError as Error).message).toBe( 149 | loginFailureMessage 150 | ); 151 | done(); 152 | } 153 | }); 154 | 155 | service.send({ type: AuthEvents.TypePhoneNumber, value: testPhoneNumber }); 156 | }); 157 | }); 158 | --------------------------------------------------------------------------------