diff --git a/library/package.json b/library/package.json index 7b47ec2..5cf6c9f 100644 --- a/library/package.json +++ b/library/package.json @@ -52,7 +52,8 @@ "entry": { "vue": "./scripts/vue.ts", "main": "./scripts/main.ts", - "react": "./scripts/react.ts" + "react": "./scripts/react.ts", + "plugins": "./scripts/plugins.ts" }, "runInDev": false, "splitChunks": false, diff --git a/library/src/scripts/lib/__mocks__/basx.ts b/library/src/scripts/__mocks__/basx.ts similarity index 100% rename from library/src/scripts/lib/__mocks__/basx.ts rename to library/src/scripts/__mocks__/basx.ts diff --git a/library/src/scripts/lib/__mocks__/diox.ts b/library/src/scripts/__mocks__/diox.ts similarity index 62% rename from library/src/scripts/lib/__mocks__/diox.ts rename to library/src/scripts/__mocks__/diox.ts index 34ae3aa..0baaa7f 100644 --- a/library/src/scripts/lib/__mocks__/diox.ts +++ b/library/src/scripts/__mocks__/diox.ts @@ -9,12 +9,10 @@ /** * diox mock. */ -class Store { - public register = jest.fn(); +const store = { + register: jest.fn(), + mutate: jest.fn(), + subscribe: jest.fn(), +}; - public mutate = jest.fn(); - - public subscribe = jest.fn(); -} - -export default Store; +export default jest.fn(() => store); diff --git a/library/src/scripts/__mocks__/localforage.ts b/library/src/scripts/__mocks__/localforage.ts new file mode 100644 index 0000000..9579819 --- /dev/null +++ b/library/src/scripts/__mocks__/localforage.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) Matthieu Jabbour. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/** + * localforage mock. + */ +export default { + getItem: jest.fn(() => { + if (process.env.CACHE_EXISTING_FORM === 'true') { + return Promise.resolve(JSON.stringify({ + formValues: { test: 'value' }, + steps: [{ + fields: [ + { + id: 'test', + label: undefined, + message: null, + options: {}, + status: 'initial', + type: 'Test', + value: undefined, + }, + { + id: 'last', + label: undefined, + message: null, + options: {}, + status: 'initial', + type: 'Test', + value: undefined, + }, + ], + id: 'test', + status: 'initial', + }], + })); + } + return Promise.resolve(null); + }), + setItem: jest.fn(() => Promise.resolve()), + removeItem: jest.fn(() => Promise.resolve()), +}; diff --git a/library/src/scripts/core/Engine.ts b/library/src/scripts/core/Engine.ts index eac71d3..d62ccfa 100644 --- a/library/src/scripts/core/Engine.ts +++ b/library/src/scripts/core/Engine.ts @@ -6,25 +6,39 @@ * */ -import { - Step, - Hook, - Field, - FormEvent, - UserAction, - FormValues, - Configuration, -} from 'scripts/types'; import Store from 'diox'; import { deepCopy } from 'basx'; +import localforage from 'localforage'; import steps from 'scripts/core/steps'; import userActions from 'scripts/core/userActions'; -import valuesLoader from 'scripts/plugins/valuesLoader'; -import errorHandler from 'scripts/plugins/errorHandler'; -import valuesChecker from 'scripts/plugins/valuesChecker'; -import valuesUpdater from 'scripts/plugins/valuesUpdater'; -import loaderDisplayer from 'scripts/plugins/loaderDisplayer'; -import reCaptchaHandler from 'scripts/plugins/reCaptchaHandler'; +import valuesLoader from 'scripts/core/valuesLoader'; +import errorHandler from 'scripts/core/errorHandler'; +import valuesChecker from 'scripts/core/valuesChecker'; +import valuesUpdater from 'scripts/core/valuesUpdater'; +import { InferProps } from 'prop-types'; +import stepPropTypes from 'scripts/propTypes/step'; +import fieldPropTypes from 'scripts/propTypes/field'; +import configurationPropTypes from 'scripts/propTypes/configuration'; + +export type FormValue = Json; +export type Plugin = (engine: Engine) => void; +export type Step = InferProps; +export type Field = InferProps; +export type Configuration = InferProps; +export type FormEvent = 'loadNextStep' | 'loadedNextStep' | 'userAction' | 'submit' | 'error'; +export type Hook = (data: Type, next: (data?: Type) => Promise) => Promise; + +export interface UserAction { + stepId: string; + fieldId: string; + stepIndex: number; + type: 'input' | 'click'; + value: FormValue; +} + +export interface FormValues { + [fieldId: string]: FormValue; +} /** * Form engine. @@ -33,11 +47,17 @@ export default class Engine { /** Diox store instance. */ private store: Store; + /** Cache name key. */ + private cacheKey: string; + + /** Timeout after which to refresh cache. */ + private cacheTimeout: number | null; + /** Form engine configuration. Contains steps, elements, ... */ private configuration: Configuration; /** Contains all events hooks to trigger when events are fired. */ - private hooks: { [eventName: string]: Hook[]; }; + private hooks: { [eventName: string]: Hook[]; }; /** Contains the actual form steps, as they are currently displayed to end-user. */ private generatedSteps: Step[]; @@ -56,16 +76,26 @@ export default class Engine { * * @throws {Error} If any event hook does not return a Promise. */ + private triggerHooks(eventName: 'submit', data: FormValues | null): Promise; + + private triggerHooks(eventName: 'loadNextStep', data: Step | null): Promise; + + private triggerHooks(eventName: 'loadedNextStep', data: Step | null): Promise; + + private triggerHooks(eventName: 'userAction', data: UserAction | null): Promise; + + private triggerHooks(eventName: 'error', data: Error | null): Promise; + private triggerHooks(eventName: FormEvent, data?: Json): Promise { const hooksChain = this.hooks[eventName].concat([]).reverse().reduce((chain, hook) => ( - (updatedData: Json): Promise => { + (updatedData): Promise => { const hookResult = hook(updatedData, chain as (data: Json) => Promise); if (!(hookResult && typeof hookResult.then === 'function')) { throw new Error(`Event "${eventName}": all your hooks must return a Promise.`); } return hookResult; } - ), (updatedData: Json) => Promise.resolve(updatedData)); + ), (updatedData) => Promise.resolve(updatedData)); // Hooks chain must first be wrapped in a Promise to catch all errors with proper error hooks. return Promise.resolve() .then(() => (hooksChain as (data: Json) => Promise)(data)) @@ -83,7 +113,23 @@ export default class Engine { .catch((error) => ((eventName === 'error') ? this.hooks.error.slice(-1)[0](error, () => Promise.resolve(null)) : this.triggerHooks('error', error).then(() => null))) - .finally(() => this.setCurrentStep(this.getCurrentStep(), true)); + .finally(() => { + const currentStep = this.generatedSteps[this.getCurrentStepIndex()] || null; + this.setCurrentStep(currentStep, true); + window.clearTimeout(this.cacheTimeout as number); + // If cache is enabled, we store current form state, except on form submission, when + // cache must be completely cleared. + if (this.configuration.cache !== false && eventName !== 'submit') { + this.cacheTimeout = window.setTimeout(() => { + localforage.setItem(this.cacheKey, JSON.stringify({ + steps: this.generatedSteps, + formValues: this.formValues, + })); + }, 500); + } else { + localforage.removeItem(this.cacheKey); + } + }); } /** @@ -104,8 +150,7 @@ export default class Engine { // Do not change this `if...else` structure as we must compare lengths before updating steps! if (this.generatedSteps.length < newSteps.length) { this.generatedSteps = newSteps; - const nextStep = newSteps[newSteps.length - 1]; - this.triggerHooks('loadedNextStep', nextStep).then((updatedNextStep: Step) => { + this.triggerHooks('loadedNextStep', newSteps[newSteps.length - 1]).then((updatedNextStep) => { if (updatedNextStep !== null) { this.setCurrentStep(updatedNextStep); } @@ -124,10 +169,9 @@ export default class Engine { */ private loadNextStep(nextStepId?: string | null): void { const nextStep = this.createStep(nextStepId || null); - this.triggerHooks('loadNextStep', nextStep).then((updatedNextStep: Step) => { + this.triggerHooks('loadNextStep', nextStep).then((updatedNextStep) => { if (updatedNextStep !== null) { - const currentStepIndex = this.generatedSteps.length - 1; - this.updateGeneratedSteps(currentStepIndex + 1, updatedNextStep); + this.updateGeneratedSteps(this.getCurrentStepIndex() + 1, updatedNextStep); } }); } @@ -135,28 +179,33 @@ export default class Engine { /** * Handles form submission and next step computation. * - * @param {UserAction} userAction Last user action. + * @param {UserAction | null} userAction Last user action. * * @returns {void} */ - private handleSubmit(userAction: UserAction): void { - this.formValues[userAction.fieldId] = userAction.value; - const currentStep = this.getCurrentStep(); - const stepConfiguration = this.configuration.steps[currentStep.id]; - const shouldLoadNextStep = this.configuration.fields[userAction.fieldId].loadNextStep === true - || this.configuration.steps[currentStep.id].fields.slice(-1)[0] === userAction.fieldId; - if (userAction.type === 'input' && shouldLoadNextStep) { - const submitPromise = (stepConfiguration.submit === true) - ? this.triggerHooks('submit', this.formValues) - : Promise.resolve(this.formValues); - submitPromise.then((updatedFormValues) => { - if (updatedFormValues !== null) { - this.formValues = updatedFormValues; - this.loadNextStep((typeof stepConfiguration.nextStep === 'function') - ? stepConfiguration.nextStep(updatedFormValues) - : stepConfiguration.nextStep); - } - }); + private handleSubmit(userAction: UserAction | null): void { + if (userAction !== null && userAction.type === 'input') { + const currentStep = this.generatedSteps[this.getCurrentStepIndex()]; + const stepConfiguration = this.configuration.steps[currentStep.id]; + const fieldConfiguration = this.configuration.fields[userAction.fieldId]; + const shouldLoadNextStep = ( + fieldConfiguration.loadNextStep === true + || currentStep.fields[currentStep.fields.length - 1].id === userAction.fieldId + ); + if (shouldLoadNextStep) { + const formValues = this.getValues(); + const submitPromise = (stepConfiguration.submit === true) + ? this.triggerHooks('submit', formValues) + : Promise.resolve(formValues); + submitPromise.then((updatedFormValues) => { + if (updatedFormValues !== null) { + this.setValues(updatedFormValues); + this.loadNextStep((typeof stepConfiguration.nextStep === 'function') + ? stepConfiguration.nextStep(updatedFormValues) + : stepConfiguration.nextStep); + } + }); + } } } @@ -168,16 +217,13 @@ export default class Engine { * @returns {void} */ private handleUserAction(userAction: UserAction | null): void { - if (userAction !== null) { - // If user changes a field in a previous step, it may have an impact on next steps to display. - // Thus, it is not necessary to keep any more step than the one containing last user action. + // If user changes a field in a previous step, it may have an impact on next steps to display. + // Thus, it is not necessary to keep any more step than the one containing last user action. + if (userAction !== null && userAction.type === 'input') { + this.formValues[userAction.fieldId] = userAction.value; this.generatedSteps = this.generatedSteps.slice(0, userAction.stepIndex + 1); - this.triggerHooks('userAction', userAction).then((updatedUserAction) => { - if (updatedUserAction !== null) { - this.handleSubmit(updatedUserAction); - } - }); } + this.triggerHooks('userAction', userAction).then(this.handleSubmit.bind(this)); } /** @@ -192,9 +238,11 @@ export default class Engine { store.register('steps', steps); store.register('userActions', userActions); this.store = store; - this.configuration = configuration; - this.generatedSteps = []; this.formValues = {}; + this.cacheTimeout = null; + this.generatedSteps = []; + this.configuration = configuration; + this.cacheKey = `gincko_${configuration.id || 'cache'}`; this.hooks = { error: [], submit: [], @@ -206,21 +254,21 @@ export default class Engine { // Be careful: plugins' order matters! (configuration.plugins || []).concat([ errorHandler(), - reCaptchaHandler(configuration.reCaptchaHandlerOptions || {} as Json), - loaderDisplayer(configuration.loaderDisplayerOptions || {} as Json), valuesUpdater(), - valuesChecker(configuration.valuesCheckerOptions || {} as Json), - valuesLoader(configuration.valuesLoaderOptions || {} as Json), + valuesChecker(), + valuesLoader(), ]).forEach((adapter) => { adapter({ on: this.on.bind(this), getStore: this.getStore.bind(this), getValues: this.getValues.bind(this), - loadValues: this.loadValues.bind(this), + setValues: this.setValues.bind(this), + userAction: this.userAction.bind(this), getConfiguration: this.getConfiguration.bind(this), toggleStepLoader: this.toggleStepLoader.bind(this), getFieldIndex: this.getFieldIndex.bind(this), getCurrentStep: this.getCurrentStep.bind(this), + getCurrentStepIndex: this.getCurrentStepIndex.bind(this), setCurrentStep: this.setCurrentStep.bind(this), createField: this.createField.bind(this), createStep: this.createStep.bind(this), @@ -233,7 +281,23 @@ export default class Engine { // `generatedSteps` MUST stay the single source of truth, and `steps` module must be only used // for unidirectional notification to the view. this.store.subscribe('userActions', this.handleUserAction.bind(this)); - this.loadNextStep(configuration.root); + + // Depending on the configuration, we want either to load the complete form from cache, or just + // its filled values and restart journey from the beginning. + localforage.getItem(this.cacheKey).then((data) => { + const parsedData = JSON.parse(data as string || '{"formValues":{}}'); + if (this.configuration.autoFill !== false) { + this.formValues = parsedData.formValues; + } + if (data !== null && this.configuration.restartOnReload !== true) { + const lastStepIndex = parsedData.steps.length - 1; + const lastStep = parsedData.steps[lastStepIndex]; + this.generatedSteps = parsedData.steps.slice(0, lastStepIndex); + this.updateGeneratedSteps(lastStepIndex, lastStep); + } else { + this.loadNextStep(configuration.root); + } + }); } /** @@ -242,7 +306,7 @@ export default class Engine { * @returns {Configuration} Form's configuration. */ public getConfiguration(): Configuration { - return deepCopy(this.configuration); + return this.configuration; } /** @@ -296,7 +360,7 @@ export default class Engine { } /** - * Retrieves form fields values that have been filled. + * Retrieves current form fields values. * * @returns {FormValues} Form values. */ @@ -305,21 +369,14 @@ export default class Engine { } /** - * Loads the given form fields values into current step. + * Adds or overrides the given form values. * - * @param {FormValues} values Form values to load in form. + * @param {FormValues} values Form values to add. * * @returns {void} */ - public loadValues(values: FormValues): void { - const stepIndex = this.generatedSteps.length - 1; - const currentStep = this.generatedSteps[stepIndex]; - currentStep.fields.forEach((field) => { - if (values[field.id] !== undefined) { - const userAction = { type: 'input', value: values[field.id], fieldId: field.id }; - this.store.mutate('userActions', 'ADD', { stepIndex, ...userAction }); - } - }); + public setValues(values: FormValues): void { + Object.assign(this.formValues, deepCopy(values)); } /** @@ -339,7 +396,7 @@ export default class Engine { * @returns {number} Field's index in current step. */ public getFieldIndex(fieldId: string): number { - const currentStep = this.generatedSteps[this.generatedSteps.length - 1] || { fields: [] }; + const currentStep = this.generatedSteps[this.getCurrentStepIndex()] || { fields: [] }; return currentStep.fields.findIndex((field) => field.id === fieldId); } @@ -349,7 +406,16 @@ export default class Engine { * @returns {Step} Current generated step. */ public getCurrentStep(): Step { - return deepCopy(this.generatedSteps[this.generatedSteps.length - 1]) || null; + return deepCopy(this.generatedSteps[this.getCurrentStepIndex()]) || null; + } + + /** + * Returns current generated step index. + * + * @returns {number} Current generated step index. + */ + public getCurrentStepIndex(): number { + return this.generatedSteps.length - 1; } /** @@ -362,7 +428,7 @@ export default class Engine { * @returns {void} */ public setCurrentStep(updatedStep: Step, notify = false): void { - const stepIndex = this.generatedSteps.length - 1; + const stepIndex = this.getCurrentStepIndex(); this.generatedSteps[stepIndex] = updatedStep; if (notify === true && stepIndex >= 0) { this.updateGeneratedSteps(stepIndex, updatedStep); @@ -374,11 +440,21 @@ export default class Engine { * * @param {FormEvent} eventName Name of the event to register hook for. * - * @param {Hook} hook Hook to register. + * @param {Hook} hook Hook to register. * * @returns {void} */ - public on(eventName: FormEvent, hook: Hook): void { + public on(eventName: 'userAction', hook: Hook): void; + + public on(eventName: 'loadNextStep', hook: Hook): void; + + public on(eventName: 'loadedNextStep', hook: Hook): void; + + public on(eventName: 'error', hook: Hook): void; + + public on(eventName: 'submit', hook: Hook): void; + + public on(eventName: FormEvent, hook: Hook): void { this.hooks[eventName].push(hook); } @@ -392,4 +468,15 @@ export default class Engine { public toggleStepLoader(display: boolean): void { this.store.mutate('steps', 'SET_LOADER', { loadingNextStep: display }); } + + /** + * Triggers the given user action. + * + * @param {UserAction} userAction User action to trigger. + * + * @returns {void} + */ + public userAction(userAction: UserAction): void { + this.store.mutate('userActions', 'ADD', userAction); + } } diff --git a/library/src/scripts/core/__mocks__/Engine.ts b/library/src/scripts/core/__mocks__/Engine.ts index 0d666a7..db06e61 100644 --- a/library/src/scripts/core/__mocks__/Engine.ts +++ b/library/src/scripts/core/__mocks__/Engine.ts @@ -6,12 +6,12 @@ * */ -import { Configuration } from 'scripts/types'; +import { Configuration } from 'scripts/core/Engine'; /** * Engine mock. */ -export default jest.fn((): Json => { +export default jest.fn((configuration = {}) => { const hooks: { [key: string]: Json[] } = { error: [], submit: [], @@ -32,15 +32,19 @@ export default jest.fn((): Json => { unsubscribe: jest.fn(), mutate: jest.fn(), })), - loadValues: jest.fn(), + setValues: jest.fn(), + userAction: jest.fn(), loadNextStep: jest.fn(), handleSubmit: jest.fn(), triggerHooks: jest.fn(), - createStep: jest.fn(), + createStep: jest.fn((stepId) => ((stepId === 'invalid') + ? null + : { id: stepId, fields: [] })), createField: jest.fn(), getConfiguration: jest.fn(() => ({ root: '', steps: {}, + autoFill: configuration.autoFill !== false, fields: { test: { type: 'Test', @@ -48,6 +52,7 @@ export default jest.fn((): Json => { }, new: { type: 'Test', + loadNextStep: true, messages: { validation: (value: string) => ((value !== 'new') ? 'invalid' : null), }, @@ -72,17 +77,24 @@ export default jest.fn((): Json => { toggleStepLoader: jest.fn(), setCurrentStep: jest.fn(), updateGeneratedSteps: jest.fn(), - getCurrentStep: jest.fn(() => ((process.env.ALL_FIELDS_VALID === 'true') - ? ({ - fields: [ - { - id: 'test', - type: 'Message', - value: 'test', - }, - ], - }) - : ({ + getCurrentStepIndex: jest.fn(() => 0), + getCurrentStep: jest.fn(() => { + if (process.env.ALL_FIELDS_VALID === 'true') { + return { + status: 'success', + fields: [ + { + id: 'test', + type: 'Message', + value: 'test', + }, + ], + }; + } + if (process.env.ENGINE_NULL_CURRENT_STEP === 'true') { + return null; + } + return { fields: [ { id: 'test', @@ -102,9 +114,13 @@ export default jest.fn((): Json => { id: 'last', type: 'Message', value: 'last', + options: { + modifiers: 'test', + }, }, ], - }))), + }; + }), on: jest.fn((event: string, callback: Json) => { hooks[event].push(callback); }), diff --git a/library/src/scripts/core/__tests__/Engine.test.ts b/library/src/scripts/core/__tests__/Engine.test.ts index bd05284..f92dd17 100644 --- a/library/src/scripts/core/__tests__/Engine.test.ts +++ b/library/src/scripts/core/__tests__/Engine.test.ts @@ -6,121 +6,167 @@ * */ -import { Plugin } from 'scripts/types'; -import Engine from 'scripts/core/Engine'; - -function flushPromises(): Promise { - return new Promise((resolve) => setImmediate(resolve)); -} +import Store from 'diox'; +import localforage from 'localforage'; +import Engine, { Configuration, Plugin, UserAction } from 'scripts/core/Engine'; jest.mock('diox'); jest.mock('basx'); +jest.mock('localforage'); jest.mock('scripts/core/steps'); jest.mock('scripts/core/userActions'); +jest.useFakeTimers(); // This trick allows to check the calling order of the different plugins. -console.log = jest.fn(); // eslint-disable-line no-console -const consolelog = console.log; // eslint-disable-line no-console -jest.mock('scripts/plugins/errorHandler', jest.fn(() => (options: Json): () => void => (): void => { - consolelog('errorHandler', options); -})); -jest.mock('scripts/plugins/valuesChecker', jest.fn(() => (options: Json): () => void => (): void => { - consolelog('valuesChecker', options); +const call = jest.fn(); +jest.mock('scripts/core/errorHandler', jest.fn(() => () => (): void => { + call('errorHandler'); })); -jest.mock('scripts/plugins/valuesUpdater', jest.fn(() => (options: Json): () => void => (): void => { - consolelog('valuesUpdater', options); +jest.mock('scripts/core/valuesChecker', jest.fn(() => () => (): void => { + call('valuesChecker'); })); -jest.mock('scripts/plugins/loaderDisplayer', jest.fn(() => (options: Json): () => void => (): void => { - consolelog('loaderDisplayer', options); +jest.mock('scripts/core/valuesUpdater', jest.fn(() => () => (): void => { + call('valuesUpdater'); })); -jest.mock('scripts/plugins/valuesLoader', jest.fn(() => (options: Json): () => void => (): void => { - consolelog('valuesLoader', options); +jest.mock('scripts/core/valuesLoader', jest.fn(() => () => (): void => { + call('valuesLoader'); })); describe('core/Engine', () => { + let engine: Engine; + const store = new Store(); + const userAction: UserAction = { + stepId: 'test', + type: 'input', + value: 'test', + fieldId: 'test', + stepIndex: 0, + }; + + function flushPromises(): Promise { + return new Promise((resolve) => setImmediate(resolve)); + } + + async function createEngine(configuration: Configuration): Promise { + engine = new Engine(configuration); + await flushPromises(); + jest.clearAllMocks(); + } + beforeEach(() => { jest.clearAllMocks(); - jest.mock('scripts/plugins/errorHandler', jest.fn(() => (options: Json): () => void => (): void => { - consolelog('errorHandler', options); - })); }); test('constructor - default plugins values and a custom plugin', async () => { - const engine = new Engine({ + engine = new Engine({ root: 'test', steps: { test: { fields: [] } }, fields: {}, plugins: [ - jest.fn((): void => { - console.log('customPlugin'); // eslint-disable-line no-console - }), + jest.fn(() => call('customPlugin')), ], }); await flushPromises(); - expect((engine as Json).store.mutate).toHaveBeenCalledTimes(2); - expect((engine as Json).store.mutate).toHaveBeenCalledWith('steps', 'SET', { steps: [{ fields: [], id: 'test', status: 'initial' }] }); - expect(consolelog).toHaveBeenNthCalledWith(1, 'customPlugin'); - expect(consolelog).toHaveBeenNthCalledWith(2, 'errorHandler', undefined); - expect(consolelog).toHaveBeenNthCalledWith(3, 'loaderDisplayer', {}); - expect(consolelog).toHaveBeenNthCalledWith(4, 'valuesUpdater', undefined); - expect(consolelog).toHaveBeenNthCalledWith(5, 'valuesChecker', {}); - expect(consolelog).toHaveBeenNthCalledWith(6, 'valuesLoader', {}); - expect(consolelog).toHaveBeenNthCalledWith(6, 'valuesLoader', {}); - }); - - test('constructor - custom plugins values and no custom plugin', async () => { - const valuesCheckerOptions = { onSubmit: true }; - const loaderDisplayerOptions = { enabled: false, timeout: 5000 }; - const valuesLoaderOptions = { enabled: false, autoSubmit: true, injectValuesTo: ['Test'] }; - const engine = new Engine({ + expect(call).toHaveBeenNthCalledWith(1, 'customPlugin'); + expect(call).toHaveBeenNthCalledWith(2, 'errorHandler'); + expect(call).toHaveBeenNthCalledWith(3, 'valuesUpdater'); + expect(call).toHaveBeenNthCalledWith(4, 'valuesChecker'); + expect(call).toHaveBeenNthCalledWith(5, 'valuesLoader'); + }); + + test('constructor - `restartOnReload` is true', async () => { + process.env.CACHE_EXISTING_FORM = 'true'; + engine = new Engine({ root: 'test', + restartOnReload: true, steps: { test: { fields: [] } }, fields: {}, - loaderDisplayerOptions, - valuesCheckerOptions, - valuesLoaderOptions, + checkValuesOnSubmit: true, }); await flushPromises(); - expect((engine as Json).store.mutate).toHaveBeenCalledTimes(2); - expect((engine as Json).store.mutate).toHaveBeenCalledWith('steps', 'SET', { steps: [{ fields: [], id: 'test', status: 'initial' }] }); - expect(consolelog).toHaveBeenNthCalledWith(1, 'errorHandler', undefined); - expect(consolelog).toHaveBeenNthCalledWith(2, 'loaderDisplayer', loaderDisplayerOptions); - expect(consolelog).toHaveBeenNthCalledWith(3, 'valuesUpdater', undefined); - expect(consolelog).toHaveBeenNthCalledWith(4, 'valuesChecker', valuesCheckerOptions); - expect(consolelog).toHaveBeenNthCalledWith(5, 'valuesLoader', valuesLoaderOptions); + expect(engine.getValues()).toEqual({ test: 'value' }); + expect(store.mutate).toHaveBeenCalledWith('steps', 'SET', { + steps: [{ + fields: [], + id: 'test', + status: 'initial', + }], + }); + delete process.env.CACHE_EXISTING_FORM; }); - test('handleUserAction - `null` value', async () => { - const engine = new Engine({ + test('constructor - custom plugins values and no custom plugin, `autoFill` is false', async () => { + process.env.CACHE_EXISTING_FORM = 'true'; + engine = new Engine({ root: 'test', + autoFill: false, steps: { test: { fields: [] } }, fields: {}, + checkValuesOnSubmit: true, }); await flushPromises(); - jest.clearAllMocks(); + expect(store.mutate).toHaveBeenCalledTimes(2); + expect(store.mutate).toHaveBeenCalledWith('steps', 'SET', { + steps: [{ + fields: [ + { + id: 'test', + label: undefined, + message: null, + options: {}, + status: 'initial', + type: 'Test', + value: undefined, + }, + { + id: 'last', + label: undefined, + message: null, + options: {}, + status: 'initial', + type: 'Test', + value: undefined, + }, + ], + id: 'test', + status: 'initial', + }], + }); + expect(engine.getValues()).toEqual({}); + expect(call).toHaveBeenNthCalledWith(1, 'errorHandler'); + expect(call).toHaveBeenNthCalledWith(2, 'valuesUpdater'); + expect(call).toHaveBeenNthCalledWith(3, 'valuesChecker'); + expect(call).toHaveBeenNthCalledWith(4, 'valuesLoader'); + delete process.env.CACHE_EXISTING_FORM; + }); + + test('handleUserAction - `null` value', async () => { + await createEngine({ + root: 'test', + steps: { test: { fields: [] } }, + fields: {}, + }); (engine as Json).handleUserAction(null); - expect((engine as Json).store.mutate).toHaveBeenCalledTimes(0); + await flushPromises(); + expect(store.mutate).toHaveBeenCalledTimes(1); }); test('handleUserAction - `null` value from plugins', async () => { - const engine = new Engine({ + await createEngine({ root: 'test', steps: { test: { fields: [] } }, fields: {}, - plugins: [((api): void => { + plugins: [((api) => { api.on('userAction', (_userAction, next) => next(null)); - }) as Plugin], + })], }); + (engine as Json).handleUserAction(userAction); await flushPromises(); - jest.clearAllMocks(); - (engine as Json).handleUserAction({ - type: 'input', value: 'test', fieldId: 'test', stepIndex: 0, - }); - expect((engine as Json).store.mutate).toHaveBeenCalledTimes(0); + expect(store.mutate).toHaveBeenCalledTimes(1); }); test('handleUserAction - non-null value, non-submitting step field', async () => { - const engine = new Engine({ + await createEngine({ root: 'test', steps: { test: { fields: ['test', 'last'] } }, fields: { @@ -132,35 +178,42 @@ describe('core/Engine', () => { }, }, }); + (engine as Json).handleUserAction(userAction); await flushPromises(); - jest.clearAllMocks(); - (engine as Json).handleUserAction({ - type: 'input', value: 'test', fieldId: 'test', stepIndex: 0, - }); - expect((engine as Json).store.mutate).toHaveBeenCalledTimes(0); + jest.runAllTimers(); + expect(localforage.setItem).toHaveBeenCalled(); + expect(store.mutate).toHaveBeenCalledTimes(1); }); test('handleUserAction - non-null value, submitting step field, `submit` not `true`', async () => { - const engine = new Engine({ + await createEngine({ root: 'test', steps: { test: { fields: ['test', 'last'], nextStep: 'last' }, last: { fields: [] } }, fields: { test: { type: 'Test', loadNextStep: true }, last: { type: 'Test' } }, }); + (engine as Json).handleUserAction(userAction); await flushPromises(); - jest.clearAllMocks(); - (engine as Json).handleUserAction({ - type: 'input', value: 'test', fieldId: 'test', stepIndex: 0, - }); - await flushPromises(); - expect((engine as Json).store.mutate).toHaveBeenCalledTimes(4); - expect((engine as Json).store.mutate).toHaveBeenCalledWith('steps', 'SET', { + expect(store.mutate).toHaveBeenCalledTimes(4); + expect(store.mutate).toHaveBeenNthCalledWith(2, 'steps', 'SET', { steps: [{ fields: [ { - id: 'test', label: undefined, message: null, options: {}, status: 'initial', type: 'Test', value: undefined, + id: 'test', + label: undefined, + message: null, + options: {}, + status: 'initial', + type: 'Test', + value: undefined, }, { - id: 'last', label: undefined, message: null, options: {}, status: 'initial', type: 'Test', value: undefined, + id: 'last', + label: undefined, + message: null, + options: {}, + status: 'initial', + type: 'Test', + value: undefined, }, ], id: 'test', @@ -170,26 +223,27 @@ describe('core/Engine', () => { }); test('handleUserAction - non-null value, submitting step field, `submit` is `true`', async () => { - const engine = new Engine({ + await createEngine({ root: 'test', - reCaptchaHandlerOptions: { enabled: false }, steps: { test: { fields: ['test'], submit: true } }, fields: { test: { type: 'Test' } }, plugins: [((api): void => { api.on('submit', (_data, next) => next(null)); }) as Plugin], }); + (engine as Json).handleUserAction(userAction); await flushPromises(); - jest.clearAllMocks(); - (engine as Json).handleUserAction({ - type: 'input', value: 'test', fieldId: 'test', stepIndex: 0, - }); - await flushPromises(); - expect((engine as Json).store.mutate).toHaveBeenCalledTimes(2); - expect((engine as Json).store.mutate).toHaveBeenCalledWith('steps', 'SET', { + expect(store.mutate).toHaveBeenCalledTimes(2); + expect(store.mutate).toHaveBeenCalledWith('steps', 'SET', { steps: [{ fields: [{ - id: 'test', label: undefined, message: null, options: {}, status: 'initial', type: 'Test', value: undefined, + id: 'test', + label: undefined, + message: null, + options: {}, + status: 'initial', + type: 'Test', + value: undefined, }], id: 'test', status: 'initial', @@ -198,7 +252,7 @@ describe('core/Engine', () => { }); test('handleUserAction - non-null value, submitting step field, nextStep is `null`', async () => { - const engine = new Engine({ + await createEngine({ root: 'test', steps: { test: { fields: ['test'], nextStep: null } }, fields: { test: { type: 'Test' } }, @@ -206,17 +260,19 @@ describe('core/Engine', () => { api.on('submit', (_data, next) => next(null)); }) as Plugin], }); + (engine as Json).handleUserAction(userAction); await flushPromises(); - jest.clearAllMocks(); - (engine as Json).handleUserAction({ - type: 'input', value: 'test', fieldId: 'test', stepIndex: 0, - }); - await flushPromises(); - expect((engine as Json).store.mutate).toHaveBeenCalledTimes(2); - expect((engine as Json).store.mutate).toHaveBeenCalledWith('steps', 'SET', { + expect(store.mutate).toHaveBeenCalledTimes(2); + expect(store.mutate).toHaveBeenCalledWith('steps', 'SET', { steps: [{ fields: [{ - id: 'test', label: undefined, message: null, options: {}, status: 'initial', type: 'Test', value: undefined, + id: 'test', + label: undefined, + message: null, + options: {}, + status: 'initial', + type: 'Test', + value: undefined, }], id: 'test', status: 'initial', @@ -225,25 +281,27 @@ describe('core/Engine', () => { }); test('handleUserAction - non-null value, submitting step field, loaded next step is `null`', async () => { - const engine = new Engine({ + await createEngine({ root: 'test', steps: { test: { fields: ['test'], nextStep: null } }, fields: { test: { type: 'Test' } }, - plugins: [((api): void => { + plugins: [((api): void => { api.on('loadedNextStep', (_data, next) => next(null)); - }) as Plugin], - }); - await flushPromises(); - jest.clearAllMocks(); - (engine as Json).handleUserAction({ - type: 'input', value: 'test', fieldId: 'test', stepIndex: 0, + })], }); + (engine as Json).handleUserAction(userAction); await flushPromises(); - expect((engine as Json).store.mutate).toHaveBeenCalledTimes(2); - expect((engine as Json).store.mutate).toHaveBeenCalledWith('steps', 'SET', { + expect(store.mutate).toHaveBeenCalledTimes(2); + expect(store.mutate).toHaveBeenCalledWith('steps', 'SET', { steps: [{ fields: [{ - id: 'test', label: undefined, message: null, options: {}, status: 'initial', type: 'Test', value: undefined, + id: 'test', + label: undefined, + message: null, + options: {}, + status: 'initial', + type: 'Test', + value: undefined, }], id: 'test', status: 'initial', @@ -252,25 +310,27 @@ describe('core/Engine', () => { }); test('handleUserAction - non-null value, `nextStep` is a function', async () => { - const engine = new Engine({ + await createEngine({ root: 'test', steps: { test: { fields: ['test'], nextStep: (): null => null } }, fields: { test: { type: 'Test' } }, - plugins: [((api): void => { + plugins: [((api): void => { api.on('submit', (_data, next) => next(null)); - }) as Plugin], - }); - await flushPromises(); - jest.clearAllMocks(); - (engine as Json).handleUserAction({ - type: 'input', value: 'test', fieldId: 'test', stepIndex: 0, + })], }); + (engine as Json).handleUserAction(userAction); await flushPromises(); - expect((engine as Json).store.mutate).toHaveBeenCalledTimes(2); - expect((engine as Json).store.mutate).toHaveBeenCalledWith('steps', 'SET', { + expect(store.mutate).toHaveBeenCalledTimes(2); + expect(store.mutate).toHaveBeenCalledWith('steps', 'SET', { steps: [{ fields: [{ - id: 'test', label: undefined, message: null, options: {}, status: 'initial', type: 'Test', value: undefined, + id: 'test', + label: undefined, + message: null, + options: {}, + status: 'initial', + type: 'Test', + value: undefined, }], id: 'test', status: 'initial', @@ -283,7 +343,7 @@ describe('core/Engine', () => { expect(error.message).toBe('Event "loadNextStep": all your hooks must return a Promise.'); done(); }; - const engine = new Engine({ + engine = new Engine({ root: 'test', steps: { test: { fields: ['test'] } }, fields: { test: { type: 'Test' } }, @@ -294,7 +354,7 @@ describe('core/Engine', () => { }); return next(error); }); - api.on('loadNextStep', () => { consolelog(engine); }); + api.on('loadNextStep', () => { call(engine); }); })], }); }); @@ -308,7 +368,7 @@ describe('core/Engine', () => { ); done(); }; - const engine = new Engine({ + engine = new Engine({ root: 'test', steps: { test: { fields: ['test'] } }, fields: { test: { type: 'Test' } }, @@ -320,231 +380,181 @@ describe('core/Engine', () => { return next(error); }); api.on('loadNextStep', () => Promise.resolve().then(() => { - consolelog(engine); + call(engine); })); })], }); }); test('triggerHooks - hook throws an error in an error hook', async () => { - const engine = new Engine({ + await createEngine({ root: 'test', - steps: { test: { fields: ['test'] } }, - fields: { test: { type: 'Test' } }, - plugins: [((api): void => { + steps: { test: { fields: ['last'] } }, + fields: { last: { type: 'Radio' } }, + plugins: [((api) => { api.on('error', () => { throw new Error('test'); }); - api.on('error', (error: Error, next: (error: Error) => Promise) => { - consolelog(error); + api.on('error', (error, next) => { + call(error); return next(error); }); - api.on('loadNextStep', () => Promise.resolve().then(() => { - consolelog(engine); - })); + api.on('userAction', () => { + throw new Error('nextStep'); + }); })], }); + (engine as Json).handleUserAction(null); await flushPromises(); - expect(consolelog).toHaveBeenCalledWith(new Error('test')); + expect(call).toHaveBeenCalledWith(new Error('test')); }); - test('getConfiguration', () => { - const configuration = { - root: 'test', - steps: { test: { fields: ['test'] } }, - fields: { test: { type: 'Test' } }, - }; - const engine = new Engine(configuration); - expect(engine.getConfiguration()).toEqual(configuration); - expect(engine.getConfiguration()).not.toBe(configuration); + test('getConfiguration', async () => { + const configuration = { root: 'test', steps: { test: { fields: [] } }, fields: {} }; + await createEngine(configuration); + expect(engine.getConfiguration()).toBe(configuration); }); - test('createField - field exists, non-interactive', () => { - const configuration = { - root: 'test', - steps: { test: { fields: ['test'] } }, - fields: { test: { type: 'Message' } }, - }; - const engine = new Engine(configuration); + test('createField - field exists, non-interactive', async () => { + await createEngine({ root: 'test', steps: { test: { fields: ['test'] } }, fields: { test: { type: 'Radio' } } }); expect(engine.createField('test')).toEqual({ id: 'test', label: undefined, message: null, options: {}, - status: 'success', - type: 'Message', + status: 'initial', + type: 'Radio', value: undefined, }); }); - test('createField - field exists, interactive', () => { - const configuration = { - root: 'test', - steps: { test: { fields: ['test'] } }, - fields: { test: { type: 'Test' } }, - }; - const engine = new Engine(configuration); + test('createField - field exists, interactive', async () => { + await createEngine({ root: 'test', steps: { test: { fields: ['test'] } }, fields: { test: { type: 'Message' } } }); expect(engine.createField('test')).toEqual({ id: 'test', label: undefined, message: null, options: {}, - status: 'initial', - type: 'Test', + status: 'success', + type: 'Message', value: undefined, }); }); - test('createField - field does not exist', () => { - const engine = new Engine({ - root: 'test', - steps: { test: { fields: ['test'] } }, - fields: { test: { type: 'Test' } }, - }); + test('createField - field does not exist', async () => { + await createEngine({ root: 'test', steps: { test: { fields: [] } }, fields: {} }); expect(() => engine.createField('other')).toThrow(new Error('Field "other" does not exist.')); }); - test('createStep - step exists', () => { - const engine = new Engine({ - root: 'test', - steps: { test: { fields: ['test'] } }, - fields: { test: { type: 'Test' } }, - }); + test('createStep - `null` stepId', async () => { + await createEngine({ root: 'test', steps: { test: { fields: [] } }, fields: {} }); + expect(engine.createStep(null)).toBeNull(); + }); + + test('createStep - step exists', async () => { + await createEngine({ root: 'test', steps: { test: { fields: ['last'] } }, fields: { last: { type: 'Message' } } }); expect(engine.createStep('test')).toEqual({ id: 'test', status: 'initial', fields: [{ - id: 'test', + id: 'last', label: undefined, message: null, options: {}, - status: 'initial', - type: 'Test', + status: 'success', + type: 'Message', value: undefined, }], }); }); - test('createStep - step does not exist', () => { - const engine = new Engine({ - root: 'test', - steps: { test: { fields: ['test'] } }, - fields: { test: { type: 'Test' } }, - }); + test('createStep - step does not exist', async () => { + await createEngine({ root: 'test', steps: { test: { fields: [] } }, fields: {} }); expect(() => engine.createStep('other')).toThrow(new Error('Step "other" does not exist.')); }); - test('getValues', async () => { - const engine = new Engine({ - root: 'test', - steps: { test: { fields: ['test'] } }, - fields: { test: { type: 'Test' } }, - }); - (engine as Json).formValues.test = 'testValue'; - await flushPromises(); - jest.clearAllMocks(); - expect(engine.getValues()).toEqual((engine as Json).formValues); - expect(engine.getValues()).not.toBe((engine as Json).formValues); + test('getValues & setValues', async () => { + await createEngine({ root: 'test', steps: { test: { fields: [] } }, fields: {} }); + engine.setValues({ test: 'test', other: 'other' }); + expect(engine.getValues()).toEqual({ test: 'test', other: 'other' }); }); - test('loadValues', async () => { - const engine = new Engine({ - root: 'test', - steps: { test: { fields: ['test', 'last'] } }, - fields: { test: { type: 'Test' }, last: { type: 'Test' } }, - }); - await flushPromises(); - jest.clearAllMocks(); - engine.loadValues({ test: 'test', other: 'other' }); - expect((engine as Json).store.mutate).toHaveBeenCalledTimes(1); - expect((engine as Json).store.mutate).toHaveBeenCalledWith('userActions', 'ADD', { - fieldId: 'test', stepIndex: 0, type: 'input', value: 'test', - }); + test('getStore', async () => { + await createEngine({ root: 'test', steps: { test: { fields: [] } }, fields: {} }); + expect(store).toBe(engine.getStore()); }); - test('getStore', () => { - const engine = new Engine({ - root: 'test', - steps: { test: { fields: ['test', 'last'] } }, - fields: { test: { type: 'Test' }, last: { type: 'Test' } }, - }); - expect((engine as Json).store).toBe(engine.getStore()); + test('getFieldIndex - existing field', async () => { + await createEngine({ root: 'test', steps: { test: { fields: ['last'] } }, fields: { last: { type: 'Message' } } }); + expect(engine.getFieldIndex('last')).toBe(0); }); - test('getFieldIndex - existing field', async () => { - const engine = new Engine({ + test('getFieldIndex - unexisting step', async () => { + let fieldIndex = 0; + await createEngine({ root: 'test', - steps: { test: { fields: ['test', 'last'] } }, - fields: { test: { type: 'Test' }, last: { type: 'Test' } }, + steps: { test: { fields: ['last'] } }, + fields: { last: { type: 'Message' } }, + plugins: [((api) => { + api.on('loadNextStep', (nextStep, next) => { + fieldIndex = engine.getFieldIndex('last'); + return next(nextStep); + }); + })], }); - await flushPromises(); - expect(engine.getFieldIndex('last')).toBe(1); + expect(fieldIndex).toBe(-1); }); - test('getFieldIndex - non-existing field', async () => { - const engine = new Engine({ - root: 'test', - steps: { test: { fields: ['test', 'last'] } }, - fields: { test: { type: 'Test' }, last: { type: 'Test' } }, - }); + test('getFieldIndex - unexisting field', async () => { + await createEngine({ root: 'test', steps: { test: { fields: [] } }, fields: {} }); expect(engine.getFieldIndex('unknown')).toBe(-1); }); test('getCurrentStep', async () => { - const engine = new Engine({ - root: 'test', - steps: { test: { fields: ['test', 'last'] } }, - fields: { test: { type: 'Test' }, last: { type: 'Test' } }, - }); + await createEngine({ root: 'test', steps: { test: { fields: [] } }, fields: {} }); process.env.DEEP_COPY = 'undefined'; expect(engine.getCurrentStep()).toBeNull(); delete process.env.DEEP_COPY; - await flushPromises(); - expect(engine.getCurrentStep()).toEqual((engine as Json).generatedSteps[0]); - expect(engine.getCurrentStep()).not.toBe((engine as Json).generatedSteps[0]); + expect(engine.getCurrentStep()).toEqual({ fields: [], id: 'test', status: 'initial' }); + }); + + test('setCurrentStep - with notification', async () => { + const step = { id: 'test', status: 'progress', fields: [] }; + await createEngine({ root: 'test', steps: { test: { fields: [] } }, fields: {} }); + engine.setCurrentStep(step, true); + expect(engine.getCurrentStep()).toEqual(step); + expect(store.mutate).toHaveBeenCalledTimes(1); + expect(store.mutate).toHaveBeenCalledWith('steps', 'SET', { steps: [step] }); }); test('setCurrentStep - no notification', async () => { - const engine = new Engine({ - root: 'test', - steps: { test: { fields: ['test', 'last'] } }, - fields: { test: { type: 'Test' }, last: { type: 'Test' } }, - }); - await flushPromises(); - jest.clearAllMocks(); - engine.setCurrentStep({ - id: 'test', - status: 'initial', - fields: [], - }); - expect((engine as Json).store.mutate).not.toHaveBeenCalled(); + const step = { id: 'test', status: 'progress', fields: [] }; + await createEngine({ root: 'test', steps: { test: { fields: [] } }, fields: {} }); + engine.setCurrentStep(step); + expect(engine.getCurrentStep()).toEqual(step); + expect(store.mutate).not.toHaveBeenCalled(); }); - test('on', () => { - const engine = new Engine({ - root: 'test', - reCaptchaHandlerOptions: { enabled: false }, - steps: { test: { fields: ['test', 'last'] } }, - fields: { test: { type: 'Test' }, last: { type: 'Test' } }, - }); - engine.on('submit', (data, next) => next(data)); - expect((engine as Json).hooks).toEqual({ - error: [], - loadNextStep: [], - loadedNextStep: [], - submit: [expect.any(Function)], - userAction: [], - }); + test('on', async () => { + const hook = jest.fn((data, next) => next(data)); + await createEngine({ root: 'test', steps: { test: { fields: [] } }, fields: {} }); + engine.on('submit', hook); + await (engine as Json).triggerHooks('submit', { test: 'value' }); + expect(hook).toHaveBeenCalledTimes(1); + expect(hook).toHaveBeenCalledWith({ test: 'value' }, expect.any(Function)); }); - test('toggleStepLoader', () => { - const engine = new Engine({ - root: 'test', - steps: { test: { fields: ['test'] } }, - fields: { test: { type: 'Test' } }, - }); + test('toggleStepLoader', async () => { + await createEngine({ root: 'test', steps: { test: { fields: [] } }, fields: {} }); engine.toggleStepLoader(true); - expect((engine as Json).store.mutate).toHaveBeenCalledTimes(1); - expect((engine as Json).store.mutate).toHaveBeenCalledWith('steps', 'SET_LOADER', { loadingNextStep: true }); + expect(store.mutate).toHaveBeenCalledTimes(1); + expect(store.mutate).toHaveBeenCalledWith('steps', 'SET_LOADER', { loadingNextStep: true }); + }); + + test('userAction', async () => { + await createEngine({ root: 'test', steps: { test: { fields: [] } }, fields: {} }); + engine.userAction(userAction); + expect(store.mutate).toHaveBeenCalledTimes(1); + expect(store.mutate).toHaveBeenCalledWith('userActions', 'ADD', userAction); }); }); diff --git a/library/src/scripts/core/__tests__/errorHandler.test.ts b/library/src/scripts/core/__tests__/errorHandler.test.ts new file mode 100644 index 0000000..47226ef --- /dev/null +++ b/library/src/scripts/core/__tests__/errorHandler.test.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) Matthieu Jabbour. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import Engine from 'scripts/core/Engine'; +import errorHandler from 'scripts/core/errorHandler'; +import MockedEngine from 'scripts/core/__mocks__/Engine'; + +jest.mock('scripts/core/Engine'); +console.error = jest.fn(); // eslint-disable-line no-console + +describe('core/errorHandler', () => { + let engine = MockedEngine(); + + beforeEach(() => { + jest.clearAllMocks(); + engine = MockedEngine(); + }); + + test('logs errors', () => { + const { error } = console; + errorHandler()(engine as unknown as Engine); + engine.trigger('error', 'test'); + expect(error).toHaveBeenCalledTimes(1); + expect(error).toHaveBeenCalledWith('test'); + }); +}); diff --git a/library/src/scripts/plugins/__tests__/valuesChecker.test.ts b/library/src/scripts/core/__tests__/valuesChecker.test.ts similarity index 71% rename from library/src/scripts/plugins/__tests__/valuesChecker.test.ts rename to library/src/scripts/core/__tests__/valuesChecker.test.ts index 94a0ea6..c8633ae 100644 --- a/library/src/scripts/plugins/__tests__/valuesChecker.test.ts +++ b/library/src/scripts/core/__tests__/valuesChecker.test.ts @@ -6,25 +6,25 @@ * */ -import Engine from 'scripts/core/__mocks__/Engine'; -import valuesChecker from 'scripts/plugins/valuesChecker'; +import Engine from 'scripts/core/Engine'; +import valuesChecker from 'scripts/core/valuesChecker'; +import MockedEngine from 'scripts/core/__mocks__/Engine'; -let engine = Engine(); +describe('core/valuesChecker', () => { + let engine = MockedEngine(); -describe('plugins/valuesChecker', () => { beforeEach(() => { jest.clearAllMocks(); - engine = Engine(); + engine = MockedEngine(); + valuesChecker()(engine as unknown as Engine); }); test('userAction hook - null user action', async () => { - valuesChecker({})(engine); await engine.trigger('userAction', null); expect(engine.setCurrentStep).not.toHaveBeenCalled(); }); test('userAction hook - non input user action', async () => { - valuesChecker({})(engine); await engine.trigger('userAction', { type: 'click' }); expect(engine.setCurrentStep).toHaveBeenCalledTimes(1); expect(engine.setCurrentStep).toHaveBeenCalledWith({ @@ -32,13 +32,19 @@ describe('plugins/valuesChecker', () => { { id: 'test', type: 'Message', value: [] }, { id: 'new', type: 'Message', value: 'ok' }, { id: 'other', type: 'Message' }, - { id: 'last', type: 'Message', value: 'last' }, + { + id: 'last', + type: 'Message', + value: 'last', + options: { + modifiers: 'test', + }, + }, ], }); }); test('userAction hook - invalid input on field without validation', async () => { - valuesChecker({})(engine); await engine.trigger('userAction', { type: 'input', fieldId: 'last' }); expect(engine.setCurrentStep).toHaveBeenCalledTimes(1); expect(engine.setCurrentStep).toHaveBeenCalledWith({ @@ -54,37 +60,52 @@ describe('plugins/valuesChecker', () => { id: 'other', type: 'Message', status: 'error', message: undefined, }, { - id: 'last', type: 'Message', value: 'last', message: undefined, status: 'success', + id: 'last', + type: 'Message', + value: 'last', + message: undefined, + status: 'success', + options: { + modifiers: 'test', + }, }, ], }); }); test('userAction hook - invalid input on field, onSubmit is `true`', async () => { - valuesChecker({ onSubmit: true })(engine); await engine.trigger('userAction', { type: 'input', fieldId: 'other' }); expect(engine.setCurrentStep).toHaveBeenCalledTimes(1); expect(engine.setCurrentStep).toHaveBeenCalledWith({ fields: [ { id: 'test', type: 'Message', value: [] }, { id: 'new', type: 'Message', value: 'ok' }, - { id: 'other', type: 'Message' }, - { id: 'last', type: 'Message', value: 'last' }, + { + id: 'other', type: 'Message', message: undefined, status: 'error', + }, + { + id: 'last', + type: 'Message', + value: 'last', + options: { + modifiers: 'test', + }, + }, ], + status: 'error', }); }); test('userAction hook - all fields are valid', async () => { - valuesChecker({})(engine); process.env.ALL_FIELDS_VALID = 'true'; await engine.trigger('userAction', { type: 'input', fieldId: 'test' }); expect(engine.setCurrentStep).toHaveBeenCalledTimes(1); - delete process.env.ALL_FIELDS_VALID; expect(engine.setCurrentStep).toHaveBeenCalledWith({ status: 'success', fields: [{ id: 'test', type: 'Message', value: 'test', status: 'success', }], }); + delete process.env.ALL_FIELDS_VALID; }); }); diff --git a/library/src/scripts/core/__tests__/valuesLoader.test.ts b/library/src/scripts/core/__tests__/valuesLoader.test.ts new file mode 100644 index 0000000..e603ba9 --- /dev/null +++ b/library/src/scripts/core/__tests__/valuesLoader.test.ts @@ -0,0 +1,73 @@ +/** + * Copyright (c) Matthieu Jabbour. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import Engine from 'scripts/core/Engine'; +import valuesLoader from 'scripts/core/valuesLoader'; +import MockedEngine from 'scripts/core/__mocks__/Engine'; + +describe('core/valuesLoader', () => { + let engine = MockedEngine(); + + beforeEach(() => { + jest.clearAllMocks(); + engine = MockedEngine(); + valuesLoader()(engine as unknown as Engine); + }); + + test('loadNextStep hook - null next step', async () => { + await engine.trigger('loadNextStep', null); + expect(engine.next).toHaveBeenCalledTimes(1); + expect(engine.next).toHaveBeenCalledWith(null); + }); + + test('loadNextStep hook - injectTo does not contain field type', async () => { + await engine.trigger('loadNextStep', { fields: [{ id: 'test', type: 'Test' }] }); + expect(engine.next).toHaveBeenCalledTimes(1); + expect(engine.next).toHaveBeenCalledWith({ fields: [{ id: 'test', type: 'Test' }] }); + }); + + test('loadNextStep hook - injectTo contains field type', async () => { + await engine.trigger('loadNextStep', { fields: [{ id: 'test', type: 'Message' }] }); + expect(engine.next).toHaveBeenCalledTimes(1); + expect(engine.next).toHaveBeenCalledWith({ + fields: [{ id: 'test', type: 'Message', options: { formValues: { test: 'value' } } }], + }); + }); + + test('loadedNextStep hook - null next step', async () => { + await engine.trigger('loadedNextStep', null); + expect(engine.next).toHaveBeenCalledWith(null); + }); + + test('loadedNextStep hook - autoFill is `false`', async () => { + jest.clearAllMocks(); + engine = MockedEngine({ autoFill: false }); + valuesLoader()(engine as unknown as Engine); + await engine.trigger('loadedNextStep', { fields: [{ id: 'test' }, { id: 'last', value: 'value' }] }); + expect(engine.userAction).toHaveBeenCalledTimes(1); + expect(engine.userAction).toHaveBeenNthCalledWith(1, { + fieldId: 'last', + value: 'value', + stepId: 'test', + stepIndex: 0, + type: 'input', + }); + }); + + test('loadedNextStep hook - autoFill is `true`', async () => { + await engine.trigger('loadedNextStep', { fields: [{ id: 'test' }, { id: 'last' }] }); + expect(engine.userAction).toHaveBeenCalledTimes(1); + expect(engine.userAction).toHaveBeenNthCalledWith(1, { + fieldId: 'test', + value: 'value', + stepId: 'test', + stepIndex: 0, + type: 'input', + }); + }); +}); diff --git a/library/src/scripts/plugins/__tests__/valuesUpdater.test.ts b/library/src/scripts/core/__tests__/valuesUpdater.test.ts similarity index 69% rename from library/src/scripts/plugins/__tests__/valuesUpdater.test.ts rename to library/src/scripts/core/__tests__/valuesUpdater.test.ts index 9bf85ef..8ecb1a9 100644 --- a/library/src/scripts/plugins/__tests__/valuesUpdater.test.ts +++ b/library/src/scripts/core/__tests__/valuesUpdater.test.ts @@ -6,31 +6,30 @@ * */ -import Engine from 'scripts/core/__mocks__/Engine'; -import valuesUpdater from 'scripts/plugins/valuesUpdater'; +import Engine from 'scripts/core/Engine'; +import valuesUpdater from 'scripts/core/valuesUpdater'; +import MockedEngine from 'scripts/core/__mocks__/Engine'; -let engine = Engine(); +describe('core/valuesUpdater', () => { + let engine = MockedEngine(); -describe('plugins/valuesUpdater', () => { beforeEach(() => { jest.clearAllMocks(); - engine = Engine(); + engine = MockedEngine(); + valuesUpdater()(engine as unknown as Engine); }); test('userAction hook - null user action', async () => { - valuesUpdater()(engine); await engine.trigger('userAction', null); expect(engine.setCurrentStep).not.toHaveBeenCalled(); }); test('userAction hook - non-input user action', async () => { - valuesUpdater()(engine); await engine.trigger('userAction', { type: 'click' }); expect(engine.setCurrentStep).not.toHaveBeenCalled(); }); test('userAction hook - normal user action', async () => { - valuesUpdater()(engine); process.env.LAST_FIELD = 'true'; await engine.trigger('userAction', { fieldId: 'last', type: 'input', value: 'initialValue' }); expect(engine.setCurrentStep).toHaveBeenCalledWith({ @@ -39,7 +38,14 @@ describe('plugins/valuesUpdater', () => { { id: 'new', type: 'Message', value: 'ok' }, { id: 'other', type: 'Message' }, { - id: 'last', message: null, status: 'initial', type: 'Message', value: 'initialValue', + id: 'last', + message: null, + status: 'initial', + type: 'Message', + value: 'initialValue', + options: { + modifiers: 'test', + }, }, ], status: 'progress', diff --git a/library/src/scripts/plugins/errorHandler.ts b/library/src/scripts/core/errorHandler.ts similarity index 91% rename from library/src/scripts/plugins/errorHandler.ts rename to library/src/scripts/core/errorHandler.ts index 443e205..682cc18 100755 --- a/library/src/scripts/plugins/errorHandler.ts +++ b/library/src/scripts/core/errorHandler.ts @@ -6,7 +6,7 @@ * */ -import { Plugin } from 'scripts/types'; +import { Plugin } from 'scripts/core/Engine'; /** * Displays hooks errors in console. diff --git a/library/src/scripts/core/userActions.ts b/library/src/scripts/core/userActions.ts index d09594c..1726e12 100644 --- a/library/src/scripts/core/userActions.ts +++ b/library/src/scripts/core/userActions.ts @@ -7,7 +7,7 @@ */ import { Module } from 'diox'; -import { UserAction } from 'scripts/types'; +import { UserAction } from 'scripts/core/Engine'; /** * Handles all user actions in form. diff --git a/library/src/scripts/plugins/valuesChecker.ts b/library/src/scripts/core/valuesChecker.ts similarity index 89% rename from library/src/scripts/plugins/valuesChecker.ts rename to library/src/scripts/core/valuesChecker.ts index d1ec352..272433c 100755 --- a/library/src/scripts/plugins/valuesChecker.ts +++ b/library/src/scripts/core/valuesChecker.ts @@ -6,11 +6,7 @@ * */ -import { Plugin, FormValue } from 'scripts/types'; - -interface Options { - onSubmit?: boolean; -} +import { Plugin, FormValue } from 'scripts/core/Engine'; const isEmpty = (value: FormValue): boolean => { if (Array.isArray(value)) { @@ -22,15 +18,12 @@ const isEmpty = (value: FormValue): boolean => { /** * Checks that all necessary fields have correctly been filled-in by user. * - * @param {Options} options Plugin's options. - * * @returns {Plugin} The actual plugin. */ -export default function valuesChecker(options: Options): Plugin { - const onSubmit = options.onSubmit || false; - +export default function valuesChecker(): Plugin { return (engine): void => { const configuration = engine.getConfiguration(); + const onSubmit = configuration.checkValuesOnSubmit === true; engine.on('userAction', (userAction, next) => { if (userAction === null) { @@ -44,7 +37,7 @@ export default function valuesChecker(options: Options): Plugin { for (let index = 0; index < currentStep.fields.length; index += 1) { const field = currentStep.fields[index]; const fieldIsRequired = configuration.fields[field.id].required === true; - const shouldLoadNextStep = configuration.fields[field.id].loadNextStep === true + const shouldLoadNextStep = configuration.fields[fieldId].loadNextStep === true || (fieldId === currentStep.fields.slice(-1)[0].id); // If we are about to load next step, we check must all fields to ensure they are all // valid. Otherwise, we just check current one. If `onSubmit` option is set to `true`, diff --git a/library/src/scripts/core/valuesLoader.ts b/library/src/scripts/core/valuesLoader.ts new file mode 100644 index 0000000..1da9344 --- /dev/null +++ b/library/src/scripts/core/valuesLoader.ts @@ -0,0 +1,70 @@ +/** + * Copyright (c) Matthieu Jabbour. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import { Plugin } from 'scripts/core/Engine'; + +/** + * Auto-loads values already filled by user when reloading next steps or page, for better UX. + * + * @returns {Plugin} The actual plugin. + */ +export default function valuesLoader(): Plugin { + return (engine): void => { + const configuration = engine.getConfiguration(); + const autoFill = configuration.autoFill !== false; + const injectValuesTo = configuration.injectValuesTo || ['Message']; + + // Automatically injects form values in specified fields' options to allow dynamic behaviours + // such as using filled values as variables in another step's message field. + engine.on('loadNextStep', (nextStep, next) => { + if (nextStep === null) { + return next(nextStep); + } + const updatedNextStep = { ...nextStep }; + const formValues = engine.getValues(); + updatedNextStep.fields.forEach((field, index) => { + if (injectValuesTo.includes(field.type)) { + updatedNextStep.fields[index].options = { ...field.options, formValues }; + } + }); + return next(updatedNextStep); + }); + + // Loads default values defined in configuration, as well as values already filled, if autofill + // is enabled. + engine.on('loadedNextStep', (nextStep, next) => { + if (nextStep !== null) { + const values = engine.getValues(); + const lastIndex = nextStep.fields.length - 1; + const stepIndex = engine.getCurrentStepIndex(); + nextStep.fields.forEach((field) => { + const shouldLoadNextStep = configuration.fields[field.id].loadNextStep === true + || nextStep.fields[lastIndex] === field; + if (!shouldLoadNextStep && autoFill === true && values[field.id] !== undefined) { + engine.userAction({ + type: 'input', + stepIndex, + stepId: 'test', + fieldId: field.id, + value: values[field.id], + }); + } else if (field.value !== undefined) { + engine.userAction({ + type: 'input', + stepIndex, + stepId: 'test', + fieldId: field.id, + value: field.value, + }); + } + }); + } + return next(nextStep); + }); + }; +} diff --git a/library/src/scripts/plugins/valuesUpdater.ts b/library/src/scripts/core/valuesUpdater.ts similarity index 96% rename from library/src/scripts/plugins/valuesUpdater.ts rename to library/src/scripts/core/valuesUpdater.ts index aa22eef..dd61cab 100755 --- a/library/src/scripts/plugins/valuesUpdater.ts +++ b/library/src/scripts/core/valuesUpdater.ts @@ -6,7 +6,7 @@ * */ -import { Plugin } from 'scripts/types'; +import { Plugin } from 'scripts/core/Engine'; /** * Updates last changed field accordingly with user action. diff --git a/library/src/scripts/lib/__mocks__/localforage.ts b/library/src/scripts/lib/__mocks__/localforage.ts deleted file mode 100644 index 9dd5764..0000000 --- a/library/src/scripts/lib/__mocks__/localforage.ts +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) Matthieu Jabbour. All Rights Reserved. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -/** - * localforage mock. - */ -export default { - getItem: jest.fn(() => Promise.resolve((process.env.CACHE_NOT_EMPTY === 'true') - ? '{"test": "value"}' - : null)), - setItem: jest.fn(() => Promise.resolve()), - removeItem: jest.fn(() => Promise.resolve()), -}; diff --git a/library/src/scripts/plugins.ts b/library/src/scripts/plugins.ts new file mode 100644 index 0000000..50d42b9 --- /dev/null +++ b/library/src/scripts/plugins.ts @@ -0,0 +1,21 @@ +/** + * Copyright (c) Matthieu Jabbour. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +/* istanbul ignore file */ + +import loaderDisplayer from 'scripts/plugins/loaderDisplayer'; +import reCaptchaHandler from 'scripts/plugins/reCaptchaHandler'; +import errorStepDisplayer from 'scripts/plugins/errorStepDisplayer'; +import submittingFieldsManager from 'scripts/plugins/submittingFieldsManager'; + +export { + loaderDisplayer, + reCaptchaHandler, + errorStepDisplayer, + submittingFieldsManager, +}; diff --git a/library/src/scripts/plugins/__tests__/errorHandler.test.ts b/library/src/scripts/plugins/__tests__/errorHandler.test.ts deleted file mode 100644 index e521e4d..0000000 --- a/library/src/scripts/plugins/__tests__/errorHandler.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright (c) Matthieu Jabbour. All Rights Reserved. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import Engine from 'scripts/core/__mocks__/Engine'; -import errorHandler from 'scripts/plugins/errorHandler'; - -console.error = jest.fn(); // eslint-disable-line no-console - -let engine = Engine(); - -describe('plugins/errorHandler', () => { - beforeEach(() => { - jest.clearAllMocks(); - engine = Engine(); - }); - - test('logs errors', () => { - errorHandler()(engine); - engine.trigger('error', 'test'); - expect(console.error).toHaveBeenCalledTimes(1); // eslint-disable-line no-console - expect(console.error).toHaveBeenCalledWith('test'); // eslint-disable-line no-console - }); -}); diff --git a/library/src/scripts/plugins/__tests__/errorStepDisplayer.test.ts b/library/src/scripts/plugins/__tests__/errorStepDisplayer.test.ts new file mode 100644 index 0000000..e4b6663 --- /dev/null +++ b/library/src/scripts/plugins/__tests__/errorStepDisplayer.test.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) Matthieu Jabbour. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import Engine from 'scripts/core/Engine'; +import MockedEngine from 'scripts/core/__mocks__/Engine'; +import errorStepDisplayer from 'scripts/plugins/errorStepDisplayer'; + +describe('plugins/errorStepDisplayer', () => { + let engine = MockedEngine(); + + beforeEach(() => { + jest.clearAllMocks(); + engine = MockedEngine(); + errorStepDisplayer({ stepId: 'error', setActiveStep: () => null })(engine as unknown as Engine); + }); + + test('error hook - step id does not exist', async () => { + jest.clearAllMocks(); + engine = MockedEngine(); + errorStepDisplayer({ stepId: 'invalid', setActiveStep: () => null })(engine as unknown as Engine); + await engine.trigger('error', new Error()); + expect(engine.setCurrentStep).not.toHaveBeenCalled(); + }); + + test('error hook - step id exists', async () => { + await engine.trigger('error', new Error()); + expect(engine.setCurrentStep).toHaveBeenCalledWith({ fields: [], id: 'error' }); + }); +}); diff --git a/library/src/scripts/plugins/__tests__/loaderDisplayer.test.ts b/library/src/scripts/plugins/__tests__/loaderDisplayer.test.ts index 074086a..b79b1ff 100644 --- a/library/src/scripts/plugins/__tests__/loaderDisplayer.test.ts +++ b/library/src/scripts/plugins/__tests__/loaderDisplayer.test.ts @@ -6,27 +6,19 @@ * */ -import Engine from 'scripts/core/__mocks__/Engine'; +import Engine from 'scripts/core/Engine'; +import MockedEngine from 'scripts/core/__mocks__/Engine'; import loaderDisplayer from 'scripts/plugins/loaderDisplayer'; jest.useFakeTimers(); + describe('plugins/loaderDisplayer', () => { - let engine = Engine(); + let engine = MockedEngine(); beforeEach(() => { jest.clearAllMocks(); - engine = Engine(); - loaderDisplayer({})(engine); - }); - - test('initialization - default options', async () => { - expect(engine.toggleStepLoader).not.toHaveBeenCalled(); - }); - - test('initialization - custom options', () => { - loaderDisplayer({ enabled: false })(engine); - expect(engine.toggleStepLoader).toHaveBeenCalledTimes(1); - expect(engine.toggleStepLoader).toHaveBeenCalledWith(false); + engine = MockedEngine(); + loaderDisplayer()(engine as unknown as Engine); }); test('userAction hook - input on non-submitting step field', async () => { diff --git a/library/src/scripts/plugins/__tests__/reCaptchaHandler.test.ts b/library/src/scripts/plugins/__tests__/reCaptchaHandler.test.ts index 99c12e9..acba8b6 100644 --- a/library/src/scripts/plugins/__tests__/reCaptchaHandler.test.ts +++ b/library/src/scripts/plugins/__tests__/reCaptchaHandler.test.ts @@ -6,20 +6,17 @@ * */ -import Engine from 'scripts/core/__mocks__/Engine'; +import Engine from 'scripts/core/Engine'; +import MockedEngine from 'scripts/core/__mocks__/Engine'; import reCaptchaHandler from 'scripts/plugins/reCaptchaHandler'; -let engine = Engine(); - describe('plugins/reCaptchaHandler', () => { + let engine = MockedEngine(); + beforeEach(() => { jest.clearAllMocks(); - engine = Engine(); - }); - - test('initialization - custom options', () => { - reCaptchaHandler({ enabled: false })(engine); - expect(engine.on).not.toHaveBeenCalled(); + engine = MockedEngine(); + reCaptchaHandler({ siteKey: 'testKey' })(engine as unknown as Engine); }); test('submit hook - reCAPTCHA client not loaded', async () => { @@ -30,7 +27,6 @@ describe('plugins/reCaptchaHandler', () => { (document as Json).getElementsByTagName = jest.fn(() => [{ appendChild: jest.fn(), }]); - reCaptchaHandler({})(engine); const promise = engine.trigger('submit', {}, { reCaptchaToken: 'test_token' }); (window as Json).grecaptcha = { ready: jest.fn((callback) => callback()), @@ -45,7 +41,6 @@ describe('plugins/reCaptchaHandler', () => { }); test('submit hook - reCAPTCHA client already loaded', async () => { - reCaptchaHandler({})(engine); (window as Json).grecaptcha = { ready: jest.fn((callback) => callback()), execute: jest.fn(() => Promise.resolve('test_token')), diff --git a/library/src/scripts/plugins/__tests__/submittingFieldsManager.ts b/library/src/scripts/plugins/__tests__/submittingFieldsManager.ts new file mode 100644 index 0000000..8421483 --- /dev/null +++ b/library/src/scripts/plugins/__tests__/submittingFieldsManager.ts @@ -0,0 +1,123 @@ +/** + * Copyright (c) Matthieu Jabbour. All Rights Reserved. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ + +import Engine from 'scripts/core/Engine'; +import MockedEngine from 'scripts/core/__mocks__/Engine'; +import submittingFieldsManager from 'scripts/plugins/submittingFieldsManager'; + +describe('plugins/submittingFieldsManager', () => { + let engine = MockedEngine(); + + beforeEach(() => { + jest.clearAllMocks(); + engine = MockedEngine(); + submittingFieldsManager()(engine as unknown as Engine); + }); + + test('userAction hook - null userAction', async () => { + await engine.trigger('userAction', null); + expect(engine.setCurrentStep).not.toHaveBeenCalled(); + }); + + test('userAction hook - non-null userAction, invalid input', async () => { + const userAction = { type: 'input', fieldId: 'test', value: 'test ' }; + await engine.trigger('userAction', userAction); + expect(engine.setCurrentStep).toHaveBeenCalledWith({ + fields: [ + { id: 'test', type: 'Message', value: [] }, + { + id: 'new', type: 'Message', value: 'ok', options: { modifiers: ' disabled' }, + }, + { id: 'other', type: 'Message' }, + { + id: 'last', + options: { modifiers: 'test disabled' }, + type: 'Message', + value: 'last', + }, + ], + }); + }); + + test('userAction hook - non-null userAction, valid input', async () => { + const userAction = { type: 'input', fieldId: 'new', value: 'other' }; + process.env.ALL_FIELDS_VALID = 'true'; + await engine.trigger('userAction', userAction); + expect(engine.setCurrentStep).toHaveBeenCalledWith({ + status: 'success', + fields: [ + { + id: 'test', + type: 'Message', + value: 'test', + options: { + modifiers: '', + }, + }, + ], + }); + delete process.env.ALL_FIELDS_VALID; + }); + + test('loadNextStep hook - null nextStep', async () => { + process.env.ENGINE_NULL_CURRENT_STEP = 'true'; + await engine.trigger('loadNextStep', null); + expect(engine.setCurrentStep).not.toHaveBeenCalled(); + delete process.env.ENGINE_NULL_CURRENT_STEP; + }); + + test('loadNextStep hook - non-null nextStep', async () => { + await engine.trigger('loadNextStep', null); + expect(engine.setCurrentStep).toHaveBeenCalledTimes(2); + expect(engine.setCurrentStep).toHaveBeenNthCalledWith(1, { + fields: [{ id: 'test', type: 'Message', value: [] }, { + id: 'new', options: { modifiers: ' disabled loading' }, type: 'Message', value: 'ok', + }, { id: 'other', type: 'Message' }, { + id: 'last', options: { modifiers: 'test disabled loading' }, type: 'Message', value: 'last', + }], + }, true); + expect(engine.setCurrentStep).toHaveBeenNthCalledWith(2, { + fields: [{ id: 'test', type: 'Message', value: [] }, { + id: 'new', options: { modifiers: '' }, type: 'Message', value: 'ok', + }, { id: 'other', type: 'Message' }, { + id: 'last', options: { modifiers: 'test' }, type: 'Message', value: 'last', + }], + }); + }); + + test('submit hook - no error', async () => { + await engine.trigger('submit', {}, {}); + expect(engine.setCurrentStep).toHaveBeenCalledTimes(1); + expect(engine.setCurrentStep).toHaveBeenCalledWith({ + fields: [{ id: 'test', type: 'Message', value: [] }, { + id: 'new', options: { modifiers: ' disabled loading' }, type: 'Message', value: 'ok', + }, { id: 'other', type: 'Message' }, { + id: 'last', options: { modifiers: 'test disabled loading' }, type: 'Message', value: 'last', + }], + }, true); + }); + + test('submit hook - error', async () => { + await engine.trigger('submit', {}, null); + expect(engine.setCurrentStep).toHaveBeenCalledTimes(2); + expect(engine.setCurrentStep).toHaveBeenNthCalledWith(1, { + fields: [{ id: 'test', type: 'Message', value: [] }, { + id: 'new', options: { modifiers: ' disabled loading' }, type: 'Message', value: 'ok', + }, { id: 'other', type: 'Message' }, { + id: 'last', options: { modifiers: 'test disabled loading' }, type: 'Message', value: 'last', + }], + }, true); + expect(engine.setCurrentStep).toHaveBeenNthCalledWith(2, { + fields: [{ id: 'test', type: 'Message', value: [] }, { + id: 'new', options: { modifiers: '' }, type: 'Message', value: 'ok', + }, { id: 'other', type: 'Message' }, { + id: 'last', options: { modifiers: 'test' }, type: 'Message', value: 'last', + }], + }); + }); +}); diff --git a/library/src/scripts/plugins/__tests__/valuesLoader.test.ts b/library/src/scripts/plugins/__tests__/valuesLoader.test.ts deleted file mode 100644 index 8fc4f5c..0000000 --- a/library/src/scripts/plugins/__tests__/valuesLoader.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Copyright (c) Matthieu Jabbour. All Rights Reserved. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import localforage from 'localforage'; -import Engine from 'scripts/core/__mocks__/Engine'; -import valuesLoader from 'scripts/plugins/valuesLoader'; - -jest.useFakeTimers(); -jest.mock('localforage'); - -let engine = Engine(); - -describe('plugins/valuesLoader', () => { - beforeEach(() => { - jest.clearAllMocks(); - engine = Engine(); - }); - - test('initialization - default options', () => { - valuesLoader({})(engine); - expect(engine.on).toHaveBeenCalledTimes(4); - expect(engine.on).toHaveBeenNthCalledWith(1, 'userAction', expect.any(Function)); - expect(engine.on).toHaveBeenNthCalledWith(2, 'loadNextStep', expect.any(Function)); - expect(engine.on).toHaveBeenNthCalledWith(3, 'loadedNextStep', expect.any(Function)); - expect(engine.on).toHaveBeenNthCalledWith(4, 'submit', expect.any(Function)); - }); - - test('initialization - custom options', () => { - valuesLoader({ enabled: false })(engine); - expect(engine.on).toHaveBeenCalledTimes(4); - expect(engine.on).toHaveBeenNthCalledWith(1, 'userAction', expect.any(Function)); - expect(engine.on).toHaveBeenNthCalledWith(2, 'loadNextStep', expect.any(Function)); - expect(engine.on).toHaveBeenNthCalledWith(3, 'loadedNextStep', expect.any(Function)); - expect(engine.on).toHaveBeenNthCalledWith(4, 'submit', expect.any(Function)); - }); - - test('userAction hook - null user action', async () => { - valuesLoader({})(engine); - await engine.trigger('userAction', null, null); - expect(localforage.setItem).not.toHaveBeenCalled(); - }); - - test('userAction hook - non-null user action', async () => { - valuesLoader({})(engine); - await engine.trigger('userAction', null, { fieldId: 'new', value: 'test' }); - jest.runAllTimers(); - expect(localforage.setItem).toHaveBeenCalledTimes(1); - expect(localforage.setItem).toHaveBeenCalledWith('gincko_cache', '{"test":"first","new":"test"}'); - }); - - test('loadNextStep hook - null next step', async () => { - valuesLoader({})(engine); - await engine.trigger('loadNextStep', null); - expect(engine.next).toHaveBeenCalledTimes(1); - expect(engine.next).toHaveBeenCalledWith(null); - }); - - test('loadNextStep hook - injectTo does not contain field type', async () => { - valuesLoader({})(engine); - await engine.trigger('loadNextStep', { fields: [{ id: 'test', type: 'Test' }] }); - expect(engine.next).toHaveBeenCalledTimes(1); - expect(engine.next).toHaveBeenCalledWith({ fields: [{ id: 'test', type: 'Test' }] }); - }); - - test('loadNextStep hook - injectTo contains field type', async () => { - valuesLoader({})(engine); - await engine.trigger('loadNextStep', { fields: [{ id: 'test', type: 'Message' }] }); - expect(engine.next).toHaveBeenCalledTimes(1); - expect(engine.next).toHaveBeenCalledWith({ - fields: [{ id: 'test', type: 'Message', options: { formValues: { test: 'first' } } }], - }); - }); - - test('loadedNextStep hook - cache already loaded', async () => { - valuesLoader({ cacheId: 'test' })(engine); - await engine.trigger('loadedNextStep', null); - await engine.trigger('loadedNextStep', null); - expect(localforage.getItem).toHaveBeenCalledTimes(1); - expect(localforage.getItem).toHaveBeenCalledWith('gincko_test'); - }); - - test('loadedNextStep hook - null next step', async () => { - valuesLoader({})(engine); - await engine.trigger('loadedNextStep', null); - expect(engine.next).toHaveBeenCalledTimes(1); - expect(engine.next).toHaveBeenNthCalledWith(1, null); - }); - - test('loadedNextStep hook - next step with empty fields', async () => { - valuesLoader({ autoSubmit: true })(engine); - await engine.trigger('loadedNextStep', { fields: [] }); - expect(engine.loadValues).not.toHaveBeenCalled(); - }); - - test('loadedNextStep hook - cache is not empty', async () => { - process.env.CACHE_NOT_EMPTY = 'true'; - valuesLoader({})(engine); - await engine.trigger('loadedNextStep', { fields: [{ id: 'test' }, { id: 'last', value: 'value' }] }); - expect(engine.loadValues).toHaveBeenCalledTimes(1); - expect(engine.loadValues).toHaveBeenNthCalledWith(1, { test: 'value' }); - delete process.env.CACHE_NOT_EMPTY; - }); - - test('loadedNextStep hook - autoSubmit is `false`', async () => { - valuesLoader({})(engine); - await engine.trigger('loadedNextStep', { fields: [{ id: 'test' }, { id: 'last', value: 'value' }] }); - expect(engine.loadValues).toHaveBeenCalledTimes(1); - expect(engine.loadValues).toHaveBeenNthCalledWith(1, { test: 'first' }); - }); - - test('loadedNextStep hook - autoSubmit is `true`', async () => { - valuesLoader({ autoSubmit: true })(engine); - await engine.trigger('loadedNextStep', { fields: [{ id: 'test' }, { id: 'last', value: 'value' }] }); - expect(engine.loadValues).toHaveBeenCalledTimes(2); - expect(engine.loadValues).toHaveBeenNthCalledWith(1, { test: 'first' }); - expect(engine.loadValues).toHaveBeenNthCalledWith(2, { last: undefined }); - }); - - test('submit hook - error in submission', async () => { - valuesLoader({})(engine); - await engine.trigger('submit', {}, null); - expect(localforage.removeItem).not.toHaveBeenCalled(); - }); - - test('submit hook - successfully submitted', async () => { - valuesLoader({})(engine); - await engine.trigger('submit', {}, {}); - await engine.trigger('userAction', {}, {}); - expect(localforage.setItem).not.toHaveBeenCalled(); - expect(localforage.removeItem).toHaveBeenCalledTimes(1); - expect(localforage.removeItem).toHaveBeenCalledWith('gincko_cache'); - }); -}); diff --git a/library/src/scripts/plugins/errorStepDisplayer.ts b/library/src/scripts/plugins/errorStepDisplayer.ts new file mode 100644 index 0000000..f4f3c1f --- /dev/null +++ b/library/src/scripts/plugins/errorStepDisplayer.ts @@ -0,0 +1,37 @@ +/** + * Copyright (c) KivFinance, Inc. + * All rights reserved. + */ + +import { Plugin } from 'scripts/core/Engine'; + +/** + * Plugin options. + */ +interface Options { + /** Id of the error step in the configuration. */ + stepId: string; + + /** Callback used to set active form step to the error step. */ + setActiveStep: (stepId: string) => void; +} + +/** + * Gracefully handles errors by displaying a generic error step. + * + * @param {Options} options Plugin options. + * + * @returns {Plugin} The actual gincko plugin. + */ +export default function errorStepDisplayer(options: Options): Plugin { + return (engine): void => { + engine.on('error', (error, next) => { + const errorStep = engine.createStep(options.stepId); + if (errorStep !== null) { + engine.setCurrentStep(errorStep); + options.setActiveStep(options.stepId); + } + return next(error); + }); + }; +} diff --git a/library/src/scripts/plugins/loaderDisplayer.ts b/library/src/scripts/plugins/loaderDisplayer.ts index 0feeb07..0d9fda9 100755 --- a/library/src/scripts/plugins/loaderDisplayer.ts +++ b/library/src/scripts/plugins/loaderDisplayer.ts @@ -6,23 +6,25 @@ * */ -import { Plugin } from 'scripts/types'; +import { Plugin } from 'scripts/core/Engine'; +/** + * Plugin options. + */ interface Options { + /** Minimum time during which loader should be displayed. */ timeout?: number; - enabled?: boolean; } /** * Displays a loader each time a new step is being loaded, for better UX. * - * @param {Options} options Plugin's options. + * @param {Options} [options = {}] Plugin's options. * * @returns {Plugin} The actual plugin. */ -export default function loaderDisplayer(options: Options): Plugin { +export default function loaderDisplayer(options: Options = {}): Plugin { return (engine): void => { - const enabled = options.enabled !== false; const timeout = options.timeout || 250; const configuration = engine.getConfiguration(); // This timestamp is used to mesure total time between user action and next step rendering. @@ -32,56 +34,52 @@ export default function loaderDisplayer(options: Options): Plugin { // a minimum time to unify user experience across the form. let startTimestamp = Date.now(); - if (enabled === true) { - // Optimizes number of mutations on store. - let loading = false; + // Optimizes number of mutations on store. + let loading = false; - // Displays loader when next step must be loaded, hides loader if an error occurs in any hook. - engine.on('userAction', (userAction, next) => { - if (userAction !== null) { - const { type, fieldId } = userAction; - const currentStep = engine.getCurrentStep(); - const shouldLoadNextStep = configuration.fields[fieldId].loadNextStep === true - || (fieldId === currentStep.fields.slice(-1)[0].id); - if (shouldLoadNextStep && type === 'input') { - loading = true; - engine.toggleStepLoader(true); - startTimestamp = Date.now(); - } + // Displays loader when next step must be loaded, hides loader if an error occurs in any hook. + engine.on('userAction', (userAction, next) => { + if (userAction !== null) { + const { type, fieldId } = userAction; + const currentStep = engine.getCurrentStep(); + const shouldLoadNextStep = configuration.fields[fieldId].loadNextStep === true + || (fieldId === currentStep.fields.slice(-1)[0].id); + if (shouldLoadNextStep && type === 'input') { + loading = true; + engine.toggleStepLoader(true); + startTimestamp = Date.now(); + } + } + return next(userAction).then((updatedUserAction) => { + if (loading === true && updatedUserAction === null) { + loading = false; + engine.toggleStepLoader(false); } - return next(userAction).then((updatedUserAction) => { - if (loading === true && updatedUserAction === null) { - loading = false; - engine.toggleStepLoader(false); - } - return Promise.resolve(updatedUserAction); - }); + return Promise.resolve(updatedUserAction); }); + }); - // Keeps loader while next step is being loaded, hides loader if an error occurs in any hook. - engine.on('loadNextStep', (nextStep, next) => ( - next(nextStep).then((updatedNextStep) => new Promise((resolve) => { - const elapsedTime = Date.now() - startTimestamp; - setTimeout(() => resolve(updatedNextStep), Math.max(timeout - elapsedTime, 0)); - })).then((updatedNextStep) => { - if (loading === true && updatedNextStep === null) { - loading = false; - engine.toggleStepLoader(false); - } - return Promise.resolve(updatedNextStep); - }) - )); - - // Hides loader once next step is fully loaded. - engine.on('loadedNextStep', (nextStep, next) => { - if (loading === true) { + // Keeps loader while next step is being loaded, hides loader if an error occurs in any hook. + engine.on('loadNextStep', (nextStep, next) => ( + next(nextStep).then((updatedNextStep) => new Promise((resolve) => { + const elapsedTime = Date.now() - startTimestamp; + setTimeout(() => resolve(updatedNextStep), Math.max(timeout - elapsedTime, 0)); + })).then((updatedNextStep) => { + if (loading === true && updatedNextStep === null) { loading = false; engine.toggleStepLoader(false); } - return next(nextStep); - }); - } else { - engine.toggleStepLoader(false); - } + return Promise.resolve(updatedNextStep); + }) + )); + + // Hides loader once next step is fully loaded. + engine.on('loadedNextStep', (nextStep, next) => { + if (loading === true) { + loading = false; + engine.toggleStepLoader(false); + } + return next(nextStep); + }); }; } diff --git a/library/src/scripts/plugins/reCaptchaHandler.ts b/library/src/scripts/plugins/reCaptchaHandler.ts index f9b6276..96e85f4 100644 --- a/library/src/scripts/plugins/reCaptchaHandler.ts +++ b/library/src/scripts/plugins/reCaptchaHandler.ts @@ -6,11 +6,14 @@ * */ -import { Plugin } from 'scripts/types'; +import { Plugin } from 'scripts/core/Engine'; +/** + * Plugin options. + */ interface Options { - enabled?: boolean; - siteKey?: string; + /** Google's reCAPTCHA v3 site key. */ + siteKey: string; } /** @@ -22,26 +25,24 @@ interface Options { */ export default function reCaptcha(options: Options): Plugin { return (engine): void => { - if (options.enabled !== false) { - const { grecaptcha } = (window as Json); - engine.on('submit', (formValues, next) => new Promise((resolve) => { - const submit = (client: Json): void => { - client.ready(() => { - client.execute(options.siteKey, { action: 'submit' }).then((token: string) => { - resolve(token); - }); + const { grecaptcha } = (window as Json); + engine.on('submit', (formValues, next) => new Promise((resolve) => { + const submit = (client: Json): void => { + client.ready(() => { + client.execute(options.siteKey, { action: 'submit' }).then((token: string) => { + resolve(token); }); - }; - if (grecaptcha === undefined) { - const script = document.createElement('script'); - script.onload = (): void => { submit((window as Json).grecaptcha); }; - script.type = 'text/javascript'; - script.src = `https://www.google.com/recaptcha/api.js?render=${options.siteKey}`; - document.getElementsByTagName('head')[0].appendChild(script); - } else { - submit(grecaptcha); - } - }).then((reCaptchaToken) => next({ ...formValues, reCaptchaToken }))); - } + }); + }; + if (grecaptcha === undefined) { + const script = document.createElement('script'); + script.onload = (): void => { submit((window as Json).grecaptcha); }; + script.type = 'text/javascript'; + script.src = `https://www.google.com/recaptcha/api.js?render=${options.siteKey}`; + document.getElementsByTagName('head')[0].appendChild(script); + } else { + submit(grecaptcha); + } + }).then((reCaptchaToken) => next({ ...formValues, reCaptchaToken }))); }; } diff --git a/library/src/scripts/plugins/submittingFieldsManager.ts b/library/src/scripts/plugins/submittingFieldsManager.ts new file mode 100644 index 0000000..eee36ee --- /dev/null +++ b/library/src/scripts/plugins/submittingFieldsManager.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) KivFinance, Inc. + * All rights reserved. + */ + +import { Plugin } from 'scripts/core/Engine'; + +/** + * Handles steps' submitting fields states (disabled, loading, ...) depending on its status. + * + * @returns {Plugin} The actual gincko plugin. + */ +export default function submittingFieldsManager(): Plugin { + return (engine): void => { + const configuration = engine.getConfiguration(); + + // Adds or remove disabled state depending on step status. + engine.on('userAction', (userAction, next) => next(userAction).then((updatedUserAction) => { + if (userAction === null) { + return next(userAction); + } + const currentStep = engine.getCurrentStep(); + const numberOfFields = currentStep.fields.length; + const isSuccess = (currentStep.status === 'success'); + for (let i = 0; i < numberOfFields; i += 1) { + const field = currentStep.fields[i]; + if (configuration.fields[field.id].loadNextStep === true || i === numberOfFields - 1) { + const currentModifiers = (field.options?.modifiers || '').replace(/\s?disabled/g, ''); + if (isSuccess) { + field.options = { ...field.options, modifiers: currentModifiers }; + } else { + field.options = { ...field.options, modifiers: `${currentModifiers} disabled` }; + } + } + } + engine.setCurrentStep(currentStep); + return Promise.resolve(updatedUserAction); + })); + + // Adds/removes loading state on next step loading. + engine.on('loadNextStep', (nextStep, next) => { + let currentStep = engine.getCurrentStep(); + if (currentStep !== null) { + const numberOfFields = currentStep.fields.length; + for (let i = 0; i < numberOfFields; i += 1) { + const field = currentStep.fields[i]; + if (configuration.fields[field.id].loadNextStep === true || i === numberOfFields - 1) { + const currentModifiers = field.options?.modifiers || ''; + field.options = { ...field.options, modifiers: `${currentModifiers} disabled loading` }; + } + } + engine.setCurrentStep(currentStep, true); + return next(nextStep).then((updatedNextStep) => { + currentStep = engine.getCurrentStep(); + for (let i = 0; i < numberOfFields; i += 1) { + const field = currentStep.fields[i]; + if (configuration.fields[field.id].loadNextStep === true || i === numberOfFields - 1) { + const currentModifiers = field.options?.modifiers || ''; + field.options = { ...field.options, modifiers: currentModifiers.replace(/\s?(disabled|loading)/g, '') }; + } + } + engine.setCurrentStep(currentStep); + return Promise.resolve(updatedNextStep); + }); + } + return next(nextStep); + }); + + // Removes disabled/loading state in case of submission error. + engine.on('submit', (formValues, next) => { + let currentStep = engine.getCurrentStep(); + const numberOfFields = currentStep.fields.length; + for (let i = 0; i < numberOfFields; i += 1) { + const field = currentStep.fields[i]; + if (configuration.fields[field.id].loadNextStep === true || i === numberOfFields - 1) { + const currentModifiers = field.options?.modifiers || ''; + field.options = { ...field.options, modifiers: `${currentModifiers} disabled loading` }; + } + } + engine.setCurrentStep(currentStep, true); + return next(formValues).then((updatedFormValues) => { + if (updatedFormValues === null) { + currentStep = engine.getCurrentStep(); + for (let i = 0; i < numberOfFields; i += 1) { + const field = currentStep.fields[i]; + if (configuration.fields[field.id].loadNextStep === true || i === numberOfFields - 1) { + const currentModifiers = field.options?.modifiers || ''; + field.options = { ...field.options, modifiers: currentModifiers.replace(/\s?(disabled|loading)/g, '') }; + } + } + engine.setCurrentStep(currentStep); + } + return Promise.resolve(updatedFormValues); + }); + }); + }; +} diff --git a/library/src/scripts/plugins/valuesLoader.ts b/library/src/scripts/plugins/valuesLoader.ts deleted file mode 100644 index 4ca4aa0..0000000 --- a/library/src/scripts/plugins/valuesLoader.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Copyright (c) Matthieu Jabbour. All Rights Reserved. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -import localforage from 'localforage'; -import { Plugin, Field, FormValues } from 'scripts/types'; - -interface Options { - cacheId?: string; - enabled?: boolean; - autoSubmit?: boolean; - injectValuesTo?: string[]; -} - -/** - * Auto-loads values already filled by user when reloading next steps or page, for better UX. - * - * @param {Options} options Plugin's options. - * - * @returns {Plugin} The actual plugin. - */ -export default function valuesLoader(options: Options): Plugin { - let loadedCache = false; - let timeout: number | null = null; - let enabled = options.enabled !== false; - const autoSubmit = options.autoSubmit === true; - const cacheKey = `gincko_${options.cacheId || 'cache'}`; - const injectValuesTo = options.injectValuesTo || ['Message']; - - return (engine): void => { - const configuration = engine.getConfiguration(); - const formValues = Object.entries(configuration.fields).reduce((values, [id, field]) => ( - (field.value !== undefined && field.value !== null) - ? { ...values, [id]: field.value } - : values - ), {}) as FormValues; - - // Stores new user inputs into cache. - engine.on('userAction', (userAction, next) => next(userAction).then((updatedUserAction) => { - if (enabled === true) { - if (updatedUserAction !== null) { - formValues[updatedUserAction.fieldId] = updatedUserAction.value; - window.clearTimeout(timeout as number); - timeout = window.setTimeout(() => { - localforage.setItem(cacheKey, JSON.stringify(formValues)); - }, 500); - } - } - return Promise.resolve(updatedUserAction); - })); - - // Automatically injects form values in specified fields' options to allow for more dynamic - // behaviours such as using filled values as variables in another step's message field. - engine.on('loadNextStep', (nextStep, next) => next((nextStep === null) ? nextStep : { - ...nextStep, - fields: nextStep.fields.map((field: Field) => ((injectValuesTo.includes(field.type)) - ? { ...field, options: { ...field.options, formValues } } - : field)), - })); - - // Loads default values defined in configuration's fields, as well as values already filled. - engine.on('loadedNextStep', (nextStep, next) => { - // Retrieving stored values from cache... - const cachePromise = (loadedCache === false && enabled === true) - ? localforage.getItem(cacheKey) - : Promise.resolve(null); - - return cachePromise.then((storedValues) => { - loadedCache = true; - if (storedValues !== null) { - Object.assign(formValues, JSON.parse(storedValues as string)); - } - - if (nextStep !== null && nextStep.fields.length > 0) { - const submittingValues: FormValues = {}; - const nonSubmittingValues: FormValues = {}; - const lastIndex = nextStep.fields.length - 1; - nextStep.fields.forEach((field: Field, index: number) => { - if (configuration.fields[field.id].loadNextStep === true || index === lastIndex) { - submittingValues[field.id] = formValues[field.id]; - } else { - nonSubmittingValues[field.id] = formValues[field.id]; - } - }); - - engine.loadValues(nonSubmittingValues); - if (autoSubmit === true) { - engine.loadValues(submittingValues); - } - } - return next(nextStep); - }); - }); - - // Empties values stored in cache if needed, and synchronizes values with engine ones on submit. - engine.on('submit', (submitValues, next) => next(submitValues).then((updatedFormValues) => { - if (updatedFormValues !== null && enabled === true) { - window.clearTimeout(timeout as number); - localforage.removeItem(cacheKey); - Object.assign(formValues, updatedFormValues); - enabled = false; - } - return Promise.resolve(updatedFormValues); - })); - }; -} diff --git a/library/src/scripts/propTypes/configuration.ts b/library/src/scripts/propTypes/configuration.ts index 6ad1043..f27c7e4 100644 --- a/library/src/scripts/propTypes/configuration.ts +++ b/library/src/scripts/propTypes/configuration.ts @@ -14,26 +14,36 @@ import normalizedFieldPropType from 'scripts/propTypes/normalizedField'; * Configuration propType. */ export default { - loaderDisplayerOptions: PropTypes.shape({ - enabled: PropTypes.bool, - timeout: PropTypes.number, - }), - reCaptchaHandlerOptions: PropTypes.shape({ - enabled: PropTypes.bool, - siteKey: PropTypes.string, - }), - valuesCheckerOptions: PropTypes.shape({ - onSubmit: PropTypes.bool, - }), - valuesLoaderOptions: PropTypes.shape({ - enabled: PropTypes.bool, - cacheId: PropTypes.string, - autoSubmit: PropTypes.bool, - injectValuesTo: PropTypes.arrayOf(PropTypes.string.isRequired), - }), + /** Form id, used to name cache key. */ + id: PropTypes.string, + + /** Whether to enable cache. */ + cache: PropTypes.bool, + + /** Whether to enable fields autofill with existing values. */ + autoFill: PropTypes.bool, + + /** Whether to restart form from the beginning on page reload. */ + restartOnReload: PropTypes.bool, + + /** Root step, from which to start the form. */ root: PropTypes.string.isRequired, + + /** Whether to check fields values only on step submit. */ + checkValuesOnSubmit: PropTypes.bool, + + /** Custom plugins registrations. */ plugins: PropTypes.arrayOf(PropTypes.func.isRequired), + + /** List of fields types in which to inject form values in options. */ + injectValuesTo: PropTypes.arrayOf(PropTypes.string.isRequired), + + /** List of non-interactive fields types (message, ...) that will always pass to success state. */ nonInteractiveFields: PropTypes.arrayOf(PropTypes.string.isRequired), + + /** List of form steps. */ steps: PropTypes.objectOf(PropTypes.shape(normalizedStepPropType).isRequired).isRequired, + + /** List of form fields. */ fields: PropTypes.objectOf(PropTypes.shape(normalizedFieldPropType).isRequired).isRequired, }; diff --git a/library/src/scripts/propTypes/normalizedField.ts b/library/src/scripts/propTypes/normalizedField.ts index 5ec07e5..63e0a4f 100644 --- a/library/src/scripts/propTypes/normalizedField.ts +++ b/library/src/scripts/propTypes/normalizedField.ts @@ -12,15 +12,33 @@ import PropTypes from 'prop-types'; * Normalized form field propType. */ export default { + /** Field's type. */ type: PropTypes.string.isRequired, + + /** Whether field is required. */ required: PropTypes.bool, + + /** Field's label. */ label: PropTypes.string, + + /** Field's status-specific messages. */ messages: PropTypes.shape({ + /** Message passed to the field when status is "success". */ success: PropTypes.string, + + /** Message passed to the field when it is empty but required. */ required: PropTypes.string, + + /** Returns a different message depending on validation rule. */ validation: PropTypes.func, }), + + /** Field's default value. */ value: PropTypes.any, + + /** Field's options. */ options: PropTypes.any, + + /** Whether to load next step when performing a user action on this field. */ loadNextStep: PropTypes.bool, }; diff --git a/library/src/scripts/propTypes/normalizedStep.ts b/library/src/scripts/propTypes/normalizedStep.ts index eaf8c40..dd82994 100644 --- a/library/src/scripts/propTypes/normalizedStep.ts +++ b/library/src/scripts/propTypes/normalizedStep.ts @@ -12,8 +12,13 @@ import PropTypes from 'prop-types'; * Normalized form step propType. */ export default { + /** List of step's fields ids. */ fields: PropTypes.arrayOf(PropTypes.string.isRequired).isRequired, + + /** Whether to submit form when step is complete. */ submit: PropTypes.bool, + + /** Determines which step to load next. */ nextStep: PropTypes.oneOfType([ PropTypes.string.isRequired, PropTypes.func.isRequired, diff --git a/library/src/scripts/react/components/Field.tsx b/library/src/scripts/react/components/Field.tsx index 2a690df..6d48986 100644 --- a/library/src/scripts/react/components/Field.tsx +++ b/library/src/scripts/react/components/Field.tsx @@ -20,8 +20,14 @@ import { import * as React from 'react'; import PropTypes, { InferProps } from 'prop-types'; import fieldPropType from 'scripts/propTypes/field'; -import { Components, FormValue } from 'scripts/types'; import Message from 'scripts/react/components/Message'; +import { Field as FormField, FormValue } from 'scripts/core/Engine'; + +export type Component = (field: FormField, onUserAction: (newValue: Json) => void) => JSX.Element; + +export type Components = { + [type: string]: Component; +}; const propTypes = { ...fieldPropType, @@ -50,7 +56,7 @@ const builtInComponents: Components = { icon={field.options.icon} type={field.options.type} iconPosition={field.options.iconPosition} - onClick={(): void => onUserAction(field.id)} + onClick={(): void => onUserAction(true)} modifiers={`${field.status} ${field.options.modifiers || ''}`} /> ), @@ -184,7 +190,7 @@ export default function Field(props: InferProps): JSX.Element }; const onUserAction = (newValue: FormValue): void => { - props.onUserAction(id, { type: 'input', value: newValue }); + props.onUserAction({ type: 'input', value: newValue, fieldId: id }); }; // Unknown field type... diff --git a/library/src/scripts/react/components/Step.tsx b/library/src/scripts/react/components/Step.tsx index 29cb0ba..56766ba 100644 --- a/library/src/scripts/react/components/Step.tsx +++ b/library/src/scripts/react/components/Step.tsx @@ -9,9 +9,9 @@ import * as React from 'react'; import { InferProps } from 'prop-types'; import { buildClass } from 'sonar-ui/react'; +import { UserAction } from 'scripts/core/Engine'; import stepPropType from 'scripts/propTypes/step'; -import Field from 'scripts/react/components/Field'; -import { UserAction, Components } from 'scripts/types'; +import Field, { Components } from 'scripts/react/components/Field'; const defaultProps = {}; @@ -22,8 +22,9 @@ export default function Step(props: InferProps): JSX.Elemen // eslint-disable-next-line object-curly-newline const { id, status, index, fields, customComponents, isActive } = props; - const onUserAction = (fieldId: string, userAction: UserAction): void => { - (props.onUserAction as Json)(index, fieldId, userAction); + const onUserAction = (userAction: UserAction): void => { + const fullUserAction: UserAction = { ...userAction, stepIndex: index as number, stepId: id }; + (props.onUserAction as (userAction: UserAction) => void)(fullUserAction); }; return ( diff --git a/library/src/scripts/react/containers/Form.tsx b/library/src/scripts/react/containers/Form.tsx index 4cdb57b..8570d69 100644 --- a/library/src/scripts/react/containers/Form.tsx +++ b/library/src/scripts/react/containers/Form.tsx @@ -7,12 +7,12 @@ */ import * as React from 'react'; -import Engine from 'scripts/core/Engine'; import useStore from 'diox/connectors/react'; import Step from 'scripts/react/components/Step'; import stepPropType from 'scripts/propTypes/step'; import PropTypes, { InferProps } from 'prop-types'; -import { Components, UserAction } from 'scripts/types'; +import Engine, { UserAction } from 'scripts/core/Engine'; +import { Components } from 'scripts/react/components/Field'; import configurationPropType from 'scripts/propTypes/configuration'; const propTypes = { @@ -35,8 +35,8 @@ export default function Form(props: InferProps): JSX.Element { const [useCombiner, mutate] = useStore(engine.getStore()); const [state] = useCombiner('steps'); - const onUserAction = (stepIndex: number, fieldId: string, userAction: UserAction): void => { - mutate('userActions', 'ADD', { ...userAction, stepIndex, fieldId }); + const onUserAction = (userAction: UserAction): void => { + mutate('userActions', 'ADD', userAction); }; return ( diff --git a/library/src/scripts/types.d.ts b/library/src/scripts/types.d.ts index 222f295..12c25fd 100644 --- a/library/src/scripts/types.d.ts +++ b/library/src/scripts/types.d.ts @@ -15,28 +15,8 @@ type Generic = Record; // eslint-disable-line @typescript-eslint/no export type FormValue = Json; export type Plugin = (engine: Engine) => void; -export type Step = PropTypes.InferProps<{ - index: PropTypes.Requireable; - isActive: PropTypes.Requireable; - onUserAction: PropTypes.Requireable<(...args: Json[]) => Promise>; - id: PropTypes.Validator; - status: PropTypes.Validator; - customComponents: PropTypes.Requireable<{ - [x: string]: (...args: Json[]) => Json; - }>; - fields: PropTypes.Validator; - active: PropTypes.Requireable; - label: PropTypes.Requireable; - message: PropTypes.Requireable; - id: PropTypes.Validator; - type: PropTypes.Validator; - status: PropTypes.Validator; - options: PropTypes.Validator; - }>[]>; -}>; export type Field = PropTypes.InferProps<{ - value: PropTypes.Requireable; + value: PropTypes.Requireable; active: PropTypes.Requireable; label: PropTypes.Requireable; message: PropTypes.Requireable; @@ -45,55 +25,101 @@ export type Field = PropTypes.InferProps<{ status: PropTypes.Validator; options: PropTypes.Validator; }>; +export type Step = PropTypes.InferProps<{ + index: PropTypes.Requireable; + isActive: PropTypes.Requireable; + onUserAction: PropTypes.Requireable<(...args: FormValue[]) => Promise>; + id: PropTypes.Validator; + status: PropTypes.Validator; + customComponents: PropTypes.Requireable<{ + [x: string]: (...args: Json[]) => Json; + }>; + fields: PropTypes.Validator; +}>; + export type Configuration = PropTypes.InferProps<{ - loaderDisplayerOptions: PropTypes.Requireable; - timeout: PropTypes.Requireable; - }>>; - reCaptchaHandlerOptions: PropTypes.Requireable; - siteKey: PropTypes.Requireable; - }>>; - valuesCheckerOptions: PropTypes.Requireable; - }>>; - valuesLoaderOptions: PropTypes.Requireable; - enabled: PropTypes.Requireable; - autoSubmit: PropTypes.Requireable; - injectValuesTo: PropTypes.Requireable; - }>>; + /** Form id, used to name cache key. */ + id: PropTypes.Requireable; + + /** Whether to enable cache. */ + cache: PropTypes.Requireable; + + /** Whether to enable fields autofill with existing values. */ + autoFill: PropTypes.Requireable; + + /** Whether to restart form from the beginning on page reload. */ + restartOnReload: PropTypes.Requireable; + + /** Root step, from which to start the form. */ root: PropTypes.Validator; + + /** Whether to check fields values only on step submit. */ + checkValuesOnSubmit: PropTypes.Requireable; + + /** Custom plugins registrations. */ plugins: PropTypes.Requireable<((...args: Json[]) => void)[]>; + + /** List of fields types in which to inject form values in options. */ + injectValuesTo: PropTypes.Requireable; + + /** List of non-interactive fields types (message, ...) that will always pass to success state. */ nonInteractiveFields: PropTypes.Requireable; + + /** List of form steps. */ steps: PropTypes.Validator<{ [x: string]: PropTypes.InferProps<{ + /** List of step's fields ids. */ fields: PropTypes.Validator; + + /** Whether to submit form when step is complete. */ submit: PropTypes.Requireable; - nextStep: PropTypes.Requireable string | null)>; + + /** Determines which step to load next. */ + nextStep: PropTypes.Requireable string | null)>; }>; }>; + + /** List of form fields. */ fields: PropTypes.Validator<{ [x: string]: PropTypes.InferProps<{ + /** Field's type. */ type: PropTypes.Validator; + + /** Whether field is required. */ required: PropTypes.Requireable; - loadNextStep: PropTypes.Requireable; + + /** Field's label. */ label: PropTypes.Requireable; + + /** Field's status-specific messages. */ messages: PropTypes.Requireable; + + /** Message passed to the field when it is empty but required. */ required: PropTypes.Requireable; - validation: PropTypes.Requireable<(...args: Json[]) => string | null | undefined>; + + /** Returns a different message depending on validation rule. */ + validation: PropTypes.Requireable<(...args: FormValue[]) => string | null | undefined>; }>>; - value: PropTypes.Requireable; + + /** Field's default value. */ + value: PropTypes.Requireable; + + /** Field's options. */ options: PropTypes.Requireable; + + /** Whether to load next step when performing a user action on this field. */ + loadNextStep: PropTypes.Requireable; }>; }>; }>; -export type Hook = (data: Json, next: (data?: Json) => Promise) => Promise; +export type Hook = (data: Type, next: (data?: Type) => Promise) => Promise; export type FormEvent = 'loadNextStep' | 'loadedNextStep' | 'userAction' | 'submit' | 'error'; export interface UserAction { + stepId: string; fieldId: string; stepIndex: number; type: 'input' | 'click'; @@ -111,11 +137,17 @@ export class Engine { /** Diox store instance. */ private store: Store; + /** Cache name key. */ + private cacheKey: string; + + /** Timeout after which to refresh cache. */ + private cacheTimeout: number | null; + /** Form engine configuration. Contains steps, elements, ... */ private configuration: Configuration; /** Contains all events hooks to trigger when events are fired. */ - private hooks: { [eventName: string]: Hook[]; }; + private hooks: { [eventName: string]: Hook[]; }; /** Contains the actual form steps, as they are currently displayed to end-user. */ private generatedSteps: Step[]; @@ -134,7 +166,15 @@ export class Engine { * * @throws {Error} If any event hook does not return a Promise. */ - private triggerHooks(eventName: FormEvent, data?: Json): Promise; + private triggerHooks(eventName: 'submit', data: FormValues | null): Promise; + + private triggerHooks(eventName: 'loadNextStep', data: Step | null): Promise; + + private triggerHooks(eventName: 'loadedNextStep', data: Step | null): Promise; + + private triggerHooks(eventName: 'userAction', data: UserAction | null): Promise; + + private triggerHooks(eventName: 'error', data: Error | null): Promise; /** * Updates list of generated steps. @@ -220,13 +260,13 @@ export class Engine { public getValues(): FormValues; /** - * Loads the given form fields values into current step. + * Adds or overrides the given form values. * - * @param {FormValues} values Form values to load in form. + * @param {FormValues} values Form values to add. * * @returns {void} */ - public loadValues(values: FormValues): void; + public setValues(values: FormValues): void; /** * Returns current store instance. @@ -251,6 +291,13 @@ export class Engine { */ public getCurrentStep(): Step; + /** + * Returns current generated step index. + * + * @returns {number} Current generated step index. + */ + public getCurrentStepIndex(): number; + /** * Updates current generated step with given info. * @@ -267,11 +314,19 @@ export class Engine { * * @param {FormEvent} eventName Name of the event to register hook for. * - * @param {Hook} hook Hook to register. + * @param {Hook} hook Hook to register. * * @returns {void} */ - public on(eventName: FormEvent, hook: Hook): void; + public on(eventName: 'userAction', hook: Hook): void; + + public on(eventName: 'loadNextStep', hook: Hook): void; + + public on(eventName: 'loadedNextStep', hook: Hook): void; + + public on(eventName: 'error', hook: Hook): void; + + public on(eventName: 'submit', hook: Hook): void; /** * Toggles a loader right after current step, indicating next step is/not being generated. @@ -281,6 +336,15 @@ export class Engine { * @returns {void} */ public toggleStepLoader(display: boolean): void; + + /** + * Triggers the given user action. + * + * @param {UserAction} userAction User action to trigger. + * + * @returns {void} + */ + public userAction(userAction: UserAction): void } declare module 'gincko' { diff --git a/library/src/scripts/types.ts b/library/src/scripts/types.ts deleted file mode 100644 index 97de689..0000000 --- a/library/src/scripts/types.ts +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Copyright (c) Matthieu Jabbour. All Rights Reserved. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - * - */ - -declare module '*.vue' { - import Vue from 'vue'; - - export default Vue; -} - -declare module 'scripts/types' { - import { InferProps } from 'prop-types'; - import Engine from 'scripts/core/Engine'; - import stepPropTypes from 'scripts/propTypes/step'; - import fieldPropTypes from 'scripts/propTypes/field'; - import configurationPropTypes from 'scripts/propTypes/configuration'; - - // eslint-disable-next-line @typescript-eslint/no-explicit-any - export type Json = any; - export type FormValue = Json; - export type Plugin = (engine: Engine) => void; - export type Step = InferProps; - export type Field = InferProps; - export type Configuration = InferProps; - export type Hook = (data: Json, next: (data?: Json) => Promise) => Promise; - export type FormEvent = 'loadNextStep' | 'loadedNextStep' | 'userAction' | 'submit' | 'error'; - export type Component = (field: Field, onUserAction: (newValue: Json) => void) => JSX.Element; - export type Listener = (data: Json, hooksChain: (data?: Json) => Promise) => Promise; - export type Generic = Record; - - export interface UserAction { - fieldId: string; - stepIndex: number; - type: 'input' | 'click'; - value: FormValue; - } - - export type Components = { - [type: string]: Component; - }; - - export interface UserActionsState { - actionsPerStep: UserAction[][]; - lastUserAction: UserAction | null; - } - - export interface FormValues { - [fieldId: string]: FormValue; - } -} diff --git a/library/src/scripts/vue.ts b/library/src/scripts/vue.ts index 339c848..0834e08 100644 --- a/library/src/scripts/vue.ts +++ b/library/src/scripts/vue.ts @@ -8,6 +8,14 @@ /* istanbul ignore file */ -import Form from 'scripts/vue/containers/Form.vue'; +declare module '*.vue' { + import Vue from 'vue'; -export default Form; + export default Vue; +} + +declare module 'scripts/vue' { + import Form from 'scripts/vue/containers/Form.vue'; + + export default Form; +} diff --git a/library/src/scripts/vue/components/Field.vue b/library/src/scripts/vue/components/Field.vue index 6e320e2..a1fc9be 100644 --- a/library/src/scripts/vue/components/Field.vue +++ b/library/src/scripts/vue/components/Field.vue @@ -22,12 +22,6 @@ /* eslint-disable vue/one-component-per-file */ -import { - Json, - Field, - Generic, - FormValue, -} from 'scripts/types'; import { UIRadio, UIButton, @@ -41,7 +35,9 @@ import { } from 'sonar-ui/vue'; import Vue from 'vue'; import { ExtendedVue } from 'vue/types/vue.d'; +import { Field, FormValue } from 'scripts/core/Engine'; +type Generic = Record; type Components = { [type: string]: Component; }; type Component = (field: Field, onUserAction: (newValue: FormValue) => void) => Json; @@ -134,7 +130,7 @@ const builtInComponents: Components = { iconPosition: field.options.iconPosition, }, events: { - click: (): void => onUserAction(field.id), + click: (): void => onUserAction(true), }, }), Textfield: (field, onUserAction) => ({ @@ -349,7 +345,7 @@ export default Vue.extend({ }, methods: { onUserAction(newValue: FormValue): void { - this.$emit('userAction', this.id, { type: 'input', value: newValue }); + this.$emit('userAction', { fieldId: this.id, type: 'input', value: newValue }); }, focusField(focusedValue: FormValue): void { this.isActive = true; diff --git a/library/src/scripts/vue/components/Step.vue b/library/src/scripts/vue/components/Step.vue index 3aad84d..b2213e1 100644 --- a/library/src/scripts/vue/components/Step.vue +++ b/library/src/scripts/vue/components/Step.vue @@ -30,16 +30,12 @@ * */ -import { - Json, - Generic, - FormValue, - UserAction, - Field as FormField, -} from 'scripts/types'; import Vue from 'vue'; import { buildClass } from 'sonar-ui/vue'; import Field from 'scripts/vue/components/Field.vue'; +import { FormValue, UserAction, Field as FormField } from 'scripts/core/Engine'; + +type Generic = Record; interface Props { id: string; @@ -91,8 +87,8 @@ export default Vue.extend({ }, }, methods: { - onUserAction(fieldId: string, userAction: UserAction): void { - this.$emit('userAction', this.index, fieldId, userAction); + onUserAction(userAction: UserAction): void { + this.$emit('userAction', { ...userAction, stepIndex: this.index, stepId: this.id }); }, buildClass(baseClass: string, modifiers: string[]) { return buildClass(baseClass, modifiers); diff --git a/library/src/scripts/vue/containers/Form.vue b/library/src/scripts/vue/containers/Form.vue index 351448f..553de21 100644 --- a/library/src/scripts/vue/containers/Form.vue +++ b/library/src/scripts/vue/containers/Form.vue @@ -34,18 +34,17 @@ * */ -import { - Json, +import Vue from 'vue'; +import Engine, { Field, - Generic, FormValue, UserAction, Configuration, -} from 'scripts/types'; -import Vue from 'vue'; -import Engine from 'scripts/core/Engine'; +} from 'scripts/core/Engine'; import Step from 'scripts/vue/components/Step.vue'; +type Generic = Record; + interface Props { activeStep: string; configuration: Configuration; diff --git a/playground/src/scripts/config.ts b/playground/src/scripts/config.ts index 3f43f1b..b44a111 100644 --- a/playground/src/scripts/config.ts +++ b/playground/src/scripts/config.ts @@ -1,20 +1,11 @@ import { Configuration } from 'gincko/react'; -export default { +export default { root: 'start', - valuesLoaderOptions: { - autoSubmit: true, - enabled: false, - }, - loaderDisplayerOptions: { - enabled: false, - }, - reCaptchaHandlerOptions: { - enabled: true, - siteKey: '6LeyjDwbAAAAAMB9r2GmEHa8761Y9b_G7vxWomm-', - }, + autoFill: true, + restartOnReload: true, steps: { - start: { fields: ['email', 'next'], nextStep: 'end' }, + start: { fields: ['email', 'mess', 'next'], nextStep: 'end' }, mid: { fields: ['azd'], nextStep: 'end' }, end: { fields: ['address', 'city', 'submit'], submit: true }, }, @@ -22,14 +13,21 @@ export default { email: { type: 'Textfield', required: true, + loadNextStep: true, + // value: 'test', messages: { validation: (value): string | null => (value.trim() === '' ? 'Please enter a valid email' : null), }, options: { + debounceTimeout: 1000, autocomplete: 'off', // transform: (value: string): string => value.replace(/a/g, 'e'), }, }, + mess: { + type: 'Message', + label: '{{email}} - {{test}}', + }, address: { type: 'Textfield', }, @@ -48,17 +46,6 @@ export default { }, }, plugins: [ - // (engine: Engine): void => { - // engine.on('loadedNextStep', (nextStep, next) => { - // const newStep = engine.generateStep('mid'); - // if (newStep !== null) { - // engine.updateCurrentStep(newStep); - // } - // return next(nextStep); - // }); - // engine.on('error', () => { - // throw new Error('Test'); - // }); - // }, + ], } as Configuration; diff --git a/playground/yarn.lock b/playground/yarn.lock index bb8729b..02baa8a 100644 --- a/playground/yarn.lock +++ b/playground/yarn.lock @@ -3817,12 +3817,12 @@ get-value@^2.0.3, get-value@^2.0.6: integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg= "gincko@file:../../../library/dist": - version "5.7.5" + version "9.3.9" dependencies: basx "^1.3.4" diox "^4.0.1" localforage "^1.9.0" - sonar-ui "^0.0.37" + sonar-ui "^0.0.38" glob-parent@^5.1.0, glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" @@ -6887,10 +6887,10 @@ snapdragon@^0.8.1: source-map-resolve "^0.5.0" use "^3.1.0" -sonar-ui@^0.0.37: - version "0.0.37" - resolved "https://registry.yarnpkg.com/sonar-ui/-/sonar-ui-0.0.37.tgz#ce6140f929626ec29500f3ba7ab2b0fd027fe2be" - integrity sha512-10LBtgVPLfmdvjon5pds+aI8RagZT6PSZvRZrvQdrfmoLG8fENjA47o4WHw3ynqDB4W9kGMFsAJVxHzOaPup+g== +sonar-ui@^0.0.38: + version "0.0.38" + resolved "https://registry.yarnpkg.com/sonar-ui/-/sonar-ui-0.0.38.tgz#22808af17d339be81bef373efe595ec548197da8" + integrity sha512-ue0Wvp7nYzMONnZS/kZikgD/oXYfVH1wNLyua3IiyP81mN3dltL9MbVRYWge/KSRlRZ9D25EpIBI/dVbOmHNgQ== sort-css-media-queries@1.5.4: version "1.5.4"