diff --git a/README.md b/README.md index 6687e37..6d835aa 100644 --- a/README.md +++ b/README.md @@ -21,13 +21,14 @@ - [@bedrockstreaming/form-builder](libs/form-builder/README.md) :construction_worker: - [@bedrockstreaming/form-validation-rule-list](libs/form-validation-rule-list/README.md) 🧑‍⚖️ - [@bedrockstreaming/form-redux](libs/form-redux/README.md) :globe_with_meridians: +- [@bedrockstreaming/form-context](libs/form-context/README.md) :globe_with_meridians: ## Why The idea of this library came from the variety of requests our customers had in terms of forms. Thus, we wanted to be able to generate any form by simply passing some config and a dictionary of inputs to go with. As we were eager to keep some control over the process, but not willing to control the form state ourselves, we went with [react-hook-form](https://react-hook-form.com/) which has great capabilities. Unfortunately we were missing some features that we had to implement ourselves. -- Complex validation with multiple visuals feedback +- Complex validation with multiple visuals feedback (at the same time) - Steps handling We believe that anyone using react could use our libraries to create and manage forms the way we do. We are still exposing - what we think are - the relevant parts of `react-hook-form` API so we think of the FormBuilder as an opinionated solution to industrialize forms across your application. diff --git a/apps/demo/src/app/app.tsx b/apps/demo/src/app/app.tsx index f7608d9..d058ee3 100644 --- a/apps/demo/src/app/app.tsx +++ b/apps/demo/src/app/app.tsx @@ -1,7 +1,8 @@ import { Switch, Route } from 'react-router-dom'; import { Layout } from './components/app/layout.component'; -import MUIForm from './examples/with-material-ui/form.component'; +import { FormContainer } from './examples/with-material-ui/login/form.container'; +import MUIRegisterForm from './examples/with-material-ui/register/form.component'; import { Generator as SchemaBuilder } from '@bedrockstreaming/form-editor'; import StyledForm from './examples/with-styled-components/form.component'; import { dictionary } from './examples/with-material-ui/dictionary'; @@ -13,7 +14,7 @@ export function App() {
- + - + +
diff --git a/apps/demo/src/app/examples/with-material-ui/login/form.component.tsx b/apps/demo/src/app/examples/with-material-ui/login/form.component.tsx new file mode 100644 index 0000000..8eaafc3 --- /dev/null +++ b/apps/demo/src/app/examples/with-material-ui/login/form.component.tsx @@ -0,0 +1,131 @@ +import { useEffect } from 'react'; +import _ from 'lodash'; + +import { FieldValues } from 'react-hook-form'; +import { FormBuilder } from '@bedrockstreaming/form-builder'; +import { + useFormsDispatch, + useFormsState, + getCurrentStepIndex, + isLastStep as isLastStepSelector, + initForm, + setNextStep, + updateFormData, + getFormData, + setPreviousStep +} from '@bedrockstreaming/form-context'; +import { + Divider, + Paper, + Typography, + Stepper, + Step, + StepLabel, + Box +} from '@mui/material'; +import { makeStyles } from '@mui/styles'; + +import { config } from '../../../login.config'; +import { dictionary } from '../dictionary'; +import { useSubmit } from '../../../hooks/useLoginSubmit.hook'; +import { extraValidation } from '../../../extraValidation'; + +const formId = 'login'; +const defaultValues = { + email: '', + password: '' +}; + +const { + schemas: { login: schema } +} = config; + +const useStyles = makeStyles({ + root: { + margin: '0 auto', + + '& .validation-rule-ul': { + display: 'flex', + padding: 0, + listStyle: 'none' + }, + + '& .validation-rule-ul li': { + margin: '4px', + fontSize: 'smaller' + }, + + '& .complete-li': { + color: '#4ed569' + }, + '& .incomplete-li,.idle-li': { + color: '#da3b2b' + }, + '& .step-fields-actions': { + width: '100%', + display: 'flex', + justifyContent: 'center' + } + } +}); + +const Form = () => { + const classes = useStyles(); + const dispatch = useFormsDispatch(); + const state = useFormsState(); + const currentStepIndex = getCurrentStepIndex(formId)(state); + const isLastStep = isLastStepSelector(formId)(state); + const previousValues = getFormData(formId)(state); + + useEffect(() => { + dispatch(initForm(formId, schema)); + }, [dispatch]); + + const handleSubmit = useSubmit(formId); + + const handleNextStep = (fieldsValues: FieldValues) => { + dispatch(updateFormData(formId, fieldsValues)); + dispatch(setNextStep(formId)); + }; + + const handlePreviousStep = () => { + dispatch(setPreviousStep(formId)); + }; + + return ( + + + {formId} with React Context API + + + + + + {Object.keys(schema.steps).map((label, index) => { + return ( + + {label} + + ); + })} + + + + + + ); +}; + +export default Form; diff --git a/apps/demo/src/app/examples/with-material-ui/login/form.container.tsx b/apps/demo/src/app/examples/with-material-ui/login/form.container.tsx new file mode 100644 index 0000000..b46e120 --- /dev/null +++ b/apps/demo/src/app/examples/with-material-ui/login/form.container.tsx @@ -0,0 +1,10 @@ +import { FormsProvider } from '@bedrockstreaming/form-context'; +import Form from '../login/form.component'; + +export const FormContainer = () => { + return ( + +
+ + ); +}; diff --git a/apps/demo/src/app/examples/with-material-ui/form.component.tsx b/apps/demo/src/app/examples/with-material-ui/register/form.component.tsx similarity index 90% rename from apps/demo/src/app/examples/with-material-ui/form.component.tsx rename to apps/demo/src/app/examples/with-material-ui/register/form.component.tsx index 2f401e2..67f9571 100644 --- a/apps/demo/src/app/examples/with-material-ui/form.component.tsx +++ b/apps/demo/src/app/examples/with-material-ui/register/form.component.tsx @@ -25,10 +25,10 @@ import { } from '@mui/material'; import { makeStyles } from '@mui/styles'; -import { config } from '../../config'; -import { dictionary } from './dictionary'; -import { useSubmit } from '../../hooks/useSubmit.hook'; -import { extraValidation } from '../../extraValidation'; +import { config } from '../../../register.config'; +import { dictionary } from '../dictionary'; +import { useSubmit } from '../../../hooks/useRegisterSubmit.hook'; +import { extraValidation } from '../../../extraValidation'; const formId = 'register'; const defaultValues = { @@ -104,9 +104,9 @@ const Form = () => { ); return ( - + - {formId} + {formId} with React Redux diff --git a/apps/demo/src/app/examples/with-styled-components/form.component.tsx b/apps/demo/src/app/examples/with-styled-components/form.component.tsx index b111979..333de07 100644 --- a/apps/demo/src/app/examples/with-styled-components/form.component.tsx +++ b/apps/demo/src/app/examples/with-styled-components/form.component.tsx @@ -14,9 +14,9 @@ import { } from '@bedrockstreaming/form-redux'; import _ from 'lodash'; -import { config } from '../../config'; +import { config } from '../../register.config'; import { dictionary } from './dictionary'; -import { useSubmit } from '../../hooks/useSubmit.hook'; +import { useSubmit } from '../../hooks/useRegisterSubmit.hook'; import { extraValidation } from '../../extraValidation'; const formId = 'register'; diff --git a/apps/demo/src/app/hooks/useLoginSubmit.hook.js b/apps/demo/src/app/hooks/useLoginSubmit.hook.js new file mode 100644 index 0000000..933a992 --- /dev/null +++ b/apps/demo/src/app/hooks/useLoginSubmit.hook.js @@ -0,0 +1,25 @@ +import { useCallback } from 'react'; +import { updateFormData, useFormsDispatch } from '@bedrockstreaming/form-context'; + +const transformFields = (x) => x; +const formSubmit = (processedFields) => ({ + type: 'some_scope/SUBMIT', + payload: processedFields +}); + +export const useSubmit = (formId) => { + const dispatch = useFormsDispatch(); + + const callback = useCallback( + async (fieldsValues) => { + dispatch(updateFormData(formId, fieldsValues)); + + const processedFields = transformFields(fieldsValues); + + return console.log(formSubmit(processedFields)); + }, + [dispatch, formId] + ); + + return callback; +}; diff --git a/apps/demo/src/app/hooks/useSubmit.hook.js b/apps/demo/src/app/hooks/useRegisterSubmit.hook.js similarity index 100% rename from apps/demo/src/app/hooks/useSubmit.hook.js rename to apps/demo/src/app/hooks/useRegisterSubmit.hook.js diff --git a/apps/demo/src/app/login.config.ts b/apps/demo/src/app/login.config.ts new file mode 100644 index 0000000..76ba18a --- /dev/null +++ b/apps/demo/src/app/login.config.ts @@ -0,0 +1,85 @@ +export const config = { + formIds: { + login: 'login' + }, + schemas: { + login: { + fields: { + email: { + id: 'email', + meta: { + errorMessage: 'Invalid Email', + label: 'Email', + name: 'email' + }, + title: 'Email', + type: 'text', + validation: { + checkPattern: { + key: 'checkPattern', + message: 'Email format', + value: + '^(([^<>()[\\]\\\\.,;:\\s@"]+(\\.[^<>()[\\]\\\\.,;:\\s@"]+)*)|(".+"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$' + }, + required: { + key: 'required', + message: 'Required field', + value: true + } + } + }, + password: { + id: 'password', + meta: { + errorMessage: 'Invalid Password', + label: 'Password', + name: 'password' + }, + title: 'password', + type: 'password', + validation: { + checkForLower: { + key: 'checkForLower', + message: 'Lowercase expected' + }, + checkForNumber: { + key: 'checkForNumber', + message: 'Number expected' + }, + checkForUpper: { + key: 'checkForUpper', + message: 'Uppercase expected' + }, + checkMinLength: { + key: 'checkMinLength', + message: 'Minimum chars expected', + value: 8 + }, + required: { + key: 'required', + message: 'Required field', + value: true + } + } + } + }, + steps: { + 'login-step-0': { + fieldsById: ['email'], + id: 'login-step-0', + submit: { + label: 'Next' + } + }, + 'login-step-1': { + fieldsById: ['password'], + id: 'login-step-1', + submit: { + label: 'Next' + } + } + }, + stepsById: ['login-step-0', 'login-step-1'] + } + } +}; diff --git a/apps/demo/src/app/config.ts b/apps/demo/src/app/register.config.ts similarity index 100% rename from apps/demo/src/app/config.ts rename to apps/demo/src/app/register.config.ts diff --git a/apps/docsite/docs/form-context.md b/apps/docsite/docs/form-context.md new file mode 100644 index 0000000..98f80a5 --- /dev/null +++ b/apps/docsite/docs/form-context.md @@ -0,0 +1,88 @@ +--- +id: form-context +title: 🌐 Form Context +--- + +`form-context` is a React reducer solution to manage steps. + +## :question: Why + +Since we are using `react-hook-form` to persist data locally, we are only storing the form data and step information on each step submission. + +:::note + +You can avoid to store any form data if you wish + +::: + +## :building_construction: Install + +```bash +npm install @bedrockstreaming/form-context +``` + +## :rocket: Usage + +```js +import { useEffect } from 'react'; +import { FormBuilder } from '@bedrockstreaming/form-builder'; +import { + getCurrentStepIndex, + isLastStep, + resetForm, + initForm, + setNextStep, + useFormsDispatch, + useFormsState, + FormProvider, +} from '@bedrockstreaming/form-context'; + +import { schema, formId } from './path/to/my/config'; +import { fooSubmitAction } from ''; + +const FooForm = () => { + const dispatch = useFormsDispatch(); + const state = useFormsState(); + const currentStepIndex = getCurrentStepIndex(formId)(state); + const shouldSubmit = isLastStepSelector(formId)(state); + const previousValues = getFormData(formId)(state); + + useEffect(() => { + dispatch(initForm(formId, schema)); + }, [dispatch]); + + const handleSubmit = useSubmit(formId); + + const handleNextStep = (fieldsValues: FieldValues) => { + dispatch(updateFormData(formId, fieldsValues)); + dispatch(setNextStep(formId)); + }; + + const handleSubmit = (fieldValues: FieldValues) => { + if (shouldSubmit) { + fooSubmit(fieldValues); + dispatch(resetForm(formId)); + } else { + dispatch(setNextStep(formId)); + } + }; + + return ( + + ); +}; + +export const FormContainer = () => { + return ( + + + + ); +}; +``` diff --git a/apps/docsite/docs/form-redux.md b/apps/docsite/docs/form-redux.md index 065593c..55a411e 100644 --- a/apps/docsite/docs/form-redux.md +++ b/apps/docsite/docs/form-redux.md @@ -21,7 +21,7 @@ Import and subscribe the reducer. ```js // reducers -import { reducer as forms } from '@bedrockstreaming/forms'; +import { reducer as forms } from '@bedrockstreaming/form-redux'; combineReducers({ forms, ... }); ``` diff --git a/apps/docsite/docs/install.md b/apps/docsite/docs/install.md index 5e7350f..71d4dd0 100644 --- a/apps/docsite/docs/install.md +++ b/apps/docsite/docs/install.md @@ -23,7 +23,13 @@ You only need state to handle the steps, you can use a solution as simple as **u ::: -Using redux +Using React's Context API + +```sh +npm install @bedrockstreaming/form-context +``` + +Using react-redux ```sh npm install @bedrockstreaming/form-redux diff --git a/apps/docsite/docs/overview.md b/apps/docsite/docs/overview.md index cf4f732..c6df587 100644 --- a/apps/docsite/docs/overview.md +++ b/apps/docsite/docs/overview.md @@ -9,6 +9,7 @@ This documentation presents a set of libraries BedrockStreaming uses to handle d - :package: [@bedrockstreaming/form-builder](https://github.com/BedrockStreaming/forms/tree/master/libs/form-builder/README.md) - :package: [@bedrockstreaming/form-validation-rule-list](https://github.com/BedrockStreaming/forms/tree/master/libs/form-validation-rule-list/README.md) - :package: [@bedrockstreaming/form-redux](https://github.com/BedrockStreaming/forms/tree/master/libs/form-redux/README.md) +- :package: [@bedrockstreaming/form-context](https://github.com/BedrockStreaming/forms/tree/master/libs/form-context/README.md) ## Why would you use these libraries ? diff --git a/apps/docsite/sidebars.js b/apps/docsite/sidebars.js index 7ac852b..dae8b4d 100644 --- a/apps/docsite/sidebars.js +++ b/apps/docsite/sidebars.js @@ -11,7 +11,7 @@ module.exports = { type: 'category', label: 'Packages', collapsed: false, - items: ['form-builder', 'form-redux', 'form-validation-rule-list'] + items: ['form-builder', 'form-redux', 'form-validation-rule-list', 'form-context'] }, { type: 'category', diff --git a/libs/form-context/.babelrc b/libs/form-context/.babelrc new file mode 100644 index 0000000..ccae900 --- /dev/null +++ b/libs/form-context/.babelrc @@ -0,0 +1,12 @@ +{ + "presets": [ + [ + "@nrwl/react/babel", + { + "runtime": "automatic", + "useBuiltIns": "usage" + } + ] + ], + "plugins": [] +} diff --git a/libs/form-context/.eslintrc.json b/libs/form-context/.eslintrc.json new file mode 100644 index 0000000..734ddac --- /dev/null +++ b/libs/form-context/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["plugin:@nrwl/nx/react", "../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/form-context/README.md b/libs/form-context/README.md new file mode 100644 index 0000000..56c7d3e --- /dev/null +++ b/libs/form-context/README.md @@ -0,0 +1,89 @@ +# form-context + +`form-context` is a React reducer solution to manage steps. + +## :question: Why + +Since we are using `react-hook-form` to persist data locally, we are only storing the form data and step information on each step submission. + +:::note + +You can avoid to store any form data if you wish + +::: + +## :building_construction: Install + +```bash +npm install @bedrockstreaming/form-context +``` + +## :rocket: Usage + +```js +import { useEffect } from 'react'; +import { FormBuilder } from '@bedrockstreaming/form-builder'; +import { + getCurrentStepIndex, + isLastStep, + resetForm, + initForm, + setNextStep, + useFormsDispatch, + useFormsState, + FormProvider, +} from '@bedrockstreaming/form-context'; + +import { schema, formId } from './path/to/my/config'; +import { fooSubmitAction } from ''; + +const FooForm = () => { + const dispatch = useFormsDispatch(); + const state = useFormsState(); + const currentStepIndex = getCurrentStepIndex(formId)(state); + const shouldSubmit = isLastStepSelector(formId)(state); + const previousValues = getFormData(formId)(state); + + useEffect(() => { + dispatch(initForm(formId, schema)); + }, [dispatch]); + + const handleSubmit = useSubmit(formId); + + const handleNextStep = (fieldsValues: FieldValues) => { + dispatch(updateFormData(formId, fieldsValues)); + dispatch(setNextStep(formId)); + }; + + const handleSubmit = (fieldValues: FieldValues) => { + if (shouldSubmit) { + fooSubmit(fieldValues); + dispatch(resetForm(formId)); + } else { + dispatch(setNextStep(formId)); + } + }; + + return ( + + ); +}; + +export const FormContainer = () => { + return ( + + + + ); +}; +``` + +## Running unit tests + +Run `nx test form-context` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/form-context/jest.config.js b/libs/form-context/jest.config.js new file mode 100644 index 0000000..dba52b6 --- /dev/null +++ b/libs/form-context/jest.config.js @@ -0,0 +1,9 @@ +module.exports = { + displayName: 'form-context', + preset: '../../jest.preset.js', + transform: { + '^.+\\.[tj]sx?$': 'babel-jest', + }, + moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx'], + coverageDirectory: '../../coverage/libs/form-context', +}; diff --git a/libs/form-context/package.json b/libs/form-context/package.json new file mode 100644 index 0000000..0bfdea2 --- /dev/null +++ b/libs/form-context/package.json @@ -0,0 +1,4 @@ +{ + "name": "@bedrockstreaming/form-context", + "version": "0.0.1" +} diff --git a/libs/form-context/project.json b/libs/form-context/project.json new file mode 100644 index 0000000..77b9320 --- /dev/null +++ b/libs/form-context/project.json @@ -0,0 +1,49 @@ +{ + "root": "libs/form-context", + "sourceRoot": "libs/form-context/src", + "projectType": "library", + "targets": { + "build": { + "executor": "@nrwl/web:package", + "outputs": ["{options.outputPath}"], + "options": { + "outputPath": "dist/libs/form-context", + "tsConfig": "libs/form-context/tsconfig.lib.json", + "project": "libs/form-context/package.json", + "entryFile": "libs/form-context/src/index.ts", + "external": ["react/jsx-runtime"], + "rollupConfig": "@nrwl/react/plugins/bundle-rollup", + "assets": [ + { + "glob": "libs/form-context/README.md", + "input": ".", + "output": "." + } + ] + } + }, + "lint": { + "executor": "@nrwl/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/form-context/**/*.{ts,tsx,js,jsx}"] + } + }, + "test": { + "executor": "@nrwl/jest:jest", + "outputs": ["coverage/libs/form-context"], + "options": { + "jestConfig": "libs/form-context/jest.config.js", + "passWithNoTests": true + } + }, + "publish": { + "executor": "ngx-deploy-npm:deploy", + "options": { + "baseBranch": "master", + "access": "public" + } + } + }, + "tags": [] +} diff --git a/libs/form-context/src/index.ts b/libs/form-context/src/index.ts new file mode 100644 index 0000000..97a7df1 --- /dev/null +++ b/libs/form-context/src/index.ts @@ -0,0 +1,4 @@ +export * from './lib/form-context'; +export * from './lib/forms.actions'; +export * from './lib/forms.reducer'; +export * from './lib/forms.selectors'; diff --git a/libs/form-context/src/lib/__tests__/forms.actions.spec.ts b/libs/form-context/src/lib/__tests__/forms.actions.spec.ts new file mode 100644 index 0000000..903bdb7 --- /dev/null +++ b/libs/form-context/src/lib/__tests__/forms.actions.spec.ts @@ -0,0 +1,93 @@ +import { + INIT_FORM, + initForm, + NEXT_STEP, + setNextStep, + PREVIOUS_STEP, + setPreviousStep, + UPDATE_FORM_DATA, + updateFormData, + RESET_FORM, + resetForm, + setStep, + SET_STEP +} from '../forms.actions'; + +const formId = 'foo'; +const CORRECT_SCHEMA = { + fields: { + foo: { + id: 'foo', + title: 'Input text 1', + type: 'text' + }, + bar: { + id: 'bar', + title: 'Input Checkbox 2', + type: 'checkbox' + } + }, + fieldsById: ['foo', 'bar'], + steps: { + one: { + fieldsById: ['foo', 'bar'], + id: 'one', + submit: { + label: 'label' + } + } + }, + stepsById: ['one'] +}; + +describe('forms.actions', () => { + describe('initForm', () => { + it('should return type and formId', () => { + expect(initForm(formId, CORRECT_SCHEMA)).toEqual({ + type: INIT_FORM, + formId, + schema: CORRECT_SCHEMA + }); + }); + }); + + describe('resetForm', () => { + it('should return type and formId', () => { + expect(resetForm(formId)).toEqual({ type: RESET_FORM, formId }); + }); + }); + + describe('setNextStep', () => { + it('should return type and formId', () => { + expect(setNextStep(formId)).toEqual({ type: NEXT_STEP, formId }); + }); + }); + + describe('setPreviousStep', () => { + it('should return type and formId', () => { + expect(setPreviousStep(formId)).toEqual({ type: PREVIOUS_STEP, formId }); + }); + }); + + describe('updateFormData', () => { + it('should return type, formId and data', () => { + const data = { bar: 'baz' }; + expect(updateFormData(formId, data)).toEqual({ + type: UPDATE_FORM_DATA, + formId, + data + }); + }); + }); + + describe('setStep', () => { + it('should return type, formId and stepIndex', () => { + const stepIndex = 666; + expect(setStep(formId, stepIndex)).toEqual({ + type: SET_STEP, + formId, + stepIndex + }); + }); + }); +}); diff --git a/libs/form-context/src/lib/__tests__/forms.reducer.spec.ts b/libs/form-context/src/lib/__tests__/forms.reducer.spec.ts new file mode 100644 index 0000000..0745634 --- /dev/null +++ b/libs/form-context/src/lib/__tests__/forms.reducer.spec.ts @@ -0,0 +1,227 @@ +import deepFreeze from 'deep-freeze'; +import { + reducer, + initialState, + DefaultFormState, + FormAction +} from '../forms.reducer'; +import { + PREVIOUS_STEP, + NEXT_STEP, + UPDATE_FORM_DATA, + INIT_FORM, + RESET_FORM, + SET_STEP +} from '../forms.actions'; + +const formId = 'foo'; +const emptyObject = {}; +const zero = 0; +const one = 1; +const defaultState = { + [formId]: { + data: emptyObject, + currentStepIndex: zero, + isLastStep: true, + stepsCount: 1 + } +}; + +describe('forms.reducer', () => { + const freezedReducer = (state: DefaultFormState, action: FormAction) => + reducer(deepFreeze(state), action); + + it('should init store', () => { + expect(reducer(undefined, { type: '@@redux/INIT' })).toEqual(initialState); + }); + + describe(INIT_FORM, () => { + it('should set a default form state under its formId', () => { + const action = { + type: INIT_FORM, + formId, + schema: { stepsById: ['foo'] } + }; + expect(freezedReducer(initialState, action)).toEqual(defaultState); + }); + }); + + describe(PREVIOUS_STEP, () => { + it('should decrement currentStepIndex', () => { + const action = { + type: PREVIOUS_STEP, + formId + }; + + expect( + freezedReducer( + { + ...defaultState, + [formId]: { ...defaultState[formId], currentStepIndex: one } + }, + action + ) + ).toEqual(defaultState); + }); + + it('should not decrement currentStepIndex when it does not exist', () => { + const action = { + type: PREVIOUS_STEP, + formId + }; + + expect(freezedReducer(initialState, action)).toBe(initialState); + }); + + it('should not decrement currentStepIndex when it is already 0', () => { + const action = { + type: PREVIOUS_STEP, + formId + }; + + expect(freezedReducer(defaultState, action)).toStrictEqual(defaultState); + }); + }); + + describe(NEXT_STEP, () => { + it('should increment currentStepIndex on multi steps', () => { + const action = { + type: NEXT_STEP, + formId + }; + + const defaultStateWithHigherCount = { + ...defaultState, + [formId]: { ...defaultState[formId], stepsCount: 2 } + }; + + expect(freezedReducer(defaultStateWithHigherCount, action)).toEqual({ + ...defaultStateWithHigherCount, + [formId]: { + ...defaultStateWithHigherCount[formId], + currentStepIndex: one, + isLastStep: true + } + }); + }); + + it('should not increment currentStepIndex on single step', () => { + const action = { + type: NEXT_STEP, + formId + }; + + expect(freezedReducer(defaultState, action)).toStrictEqual(defaultState); + }); + }); + + describe(RESET_FORM, () => { + it('should remove the form from the formId', () => { + const action = { + type: RESET_FORM, + formId + }; + + expect( + freezedReducer( + { + ...defaultState, + [formId]: { ...defaultState[formId], currentStepIndex: one } + }, + action + ) + ).toEqual(initialState); + }); + }); + + describe(UPDATE_FORM_DATA, () => { + it('should set data under formId when it is empty yet', () => { + const action = { + type: UPDATE_FORM_DATA, + formId, + data: { foo: 'bar' } + }; + + expect(freezedReducer(defaultState, action)).toEqual({ + ...defaultState, + [formId]: { ...defaultState[formId], data: action.data } + }); + }); + + it('should update data under formId', () => { + const action = { + type: UPDATE_FORM_DATA, + formId, + data: { bar: 'baz' } + }; + + const state = { + ...defaultState, + [formId]: { ...defaultState[formId], data: { foo: 'bar' } } + }; + + expect(freezedReducer(state, action)).toEqual({ + ...defaultState, + [formId]: { + ...defaultState[formId], + data: { ...state[formId].data, ...action.data } + } + }); + }); + }); + + describe(SET_STEP, () => { + it('should return state when passed index is above stepsCount', () => { + const action = { + type: SET_STEP, + formId, + stepIndex: 666 + }; + + expect(freezedReducer(defaultState, action)).toEqual(defaultState); + }); + + describe('on single step', () => { + it('should set step to given stepIndex and redefine isLastStep', () => { + const action = { + type: SET_STEP, + formId, + stepIndex: 0 + }; + + expect(freezedReducer(defaultState, action)).toEqual({ + ...defaultState, + [formId]: { + ...defaultState[formId], + currentStepIndex: action.stepIndex, + isLastStep: true + } + }); + }); + }); + + describe('on multi steps', () => { + it('should set step to given stepIndex and redefine isLastStep', () => { + const action = { + type: SET_STEP, + formId, + stepIndex: 0 + }; + + const defaultStateWithHigherCount = { + ...defaultState, + [formId]: { ...defaultState[formId], stepsCount: 2 } + }; + + expect(freezedReducer(defaultStateWithHigherCount, action)).toEqual({ + ...defaultStateWithHigherCount, + [formId]: { + ...defaultStateWithHigherCount[formId], + currentStepIndex: action.stepIndex, + isLastStep: false + } + }); + }); + }); + }); +}); diff --git a/libs/form-context/src/lib/__tests__/forms.selectors.spec.ts b/libs/form-context/src/lib/__tests__/forms.selectors.spec.ts new file mode 100644 index 0000000..5bbb166 --- /dev/null +++ b/libs/form-context/src/lib/__tests__/forms.selectors.spec.ts @@ -0,0 +1,39 @@ +import { + getFormData, + getCurrentStepIndex, + isLastStep +} from '../forms.selectors'; + +const formId = 'foo'; +const state = { + foo: { + currentStepIndex: 0, + data: { + bar: 'baz' + }, + isLastStep: false, + stepsCount: 666 + } +}; + +describe('forms.selectors', () => { + describe('getFormData', () => { + it('should retrieve data from state input', () => { + expect(getFormData(formId)(state)).toBe(state.foo.data); + }); + }); + + describe('getCurrentStepIndex', () => { + it('should retrieve currentStepIndex from state input', () => { + expect(getCurrentStepIndex(formId)(state)).toBe( + state.foo.currentStepIndex + ); + }); + }); + + describe('isLastStep', () => { + it('should retrieve isLastStep property', () => { + expect(isLastStep(formId)(state)).toBe(state.foo.isLastStep); + }); + }); +}); diff --git a/libs/form-context/src/lib/form-context.tsx b/libs/form-context/src/lib/form-context.tsx new file mode 100644 index 0000000..faac37b --- /dev/null +++ b/libs/form-context/src/lib/form-context.tsx @@ -0,0 +1,53 @@ +import React, { ReactChildren, useEffect, useRef } from 'react'; + +import { reducer, initialState } from './forms.reducer'; + +const FormsStateContext = React.createContext({}); +const FormsDispatchContext = React.createContext(() => void null); + +const FormsProvider = ({ + children +}: { + children: ReactChildren | JSX.Element; +}) => { + const isInitialized = useRef(false); + const [state, dispatch] = React.useReducer(reducer, initialState); + + useEffect(() => { + if (isInitialized && isInitialized.current) { + return; + } + + isInitialized.current = true; + }); + + return ( + + + {children} + + + ); +}; + +function useFormsState() { + const context = React.useContext(FormsStateContext); + if (context === undefined) { + throw new Error('useFormsState must be used within a FormsProvider'); + } + return context; +} + +function useFormsDispatch() { + const context = React.useContext(FormsDispatchContext); + if (context === undefined) { + throw new Error('useFormsDispatch must be used within a FormsProvider'); + } + return context; +} + +function useForms() { + return [useFormsState(), useFormsDispatch()]; +} + +export { FormsProvider, useForms, useFormsState, useFormsDispatch }; diff --git a/libs/form-context/src/lib/forms.actions.ts b/libs/form-context/src/lib/forms.actions.ts new file mode 100644 index 0000000..7fff57b --- /dev/null +++ b/libs/form-context/src/lib/forms.actions.ts @@ -0,0 +1,34 @@ +import { FormSchema } from '@bedrockstreaming/form-builder'; + +export const INIT_FORM = 'forms/INIT_FORM'; +export const initForm = (formId: string, schema: FormSchema) => ({ + type: INIT_FORM, + formId, + schema +}); + +export const NEXT_STEP = 'forms/NEXT_STEP'; +export const setNextStep = (formId: string) => ({ type: NEXT_STEP, formId }); + +export const PREVIOUS_STEP = 'forms/PREVIOUS_STEP'; +export const setPreviousStep = (formId: string) => ({ + type: PREVIOUS_STEP, + formId +}); + +export const UPDATE_FORM_DATA = 'forms/UPDATE_FORM_DATA'; +export const updateFormData = (formId: string, data: T) => ({ + type: UPDATE_FORM_DATA, + formId, + data +}); + +export const RESET_FORM = 'forms/RESET_FORM'; +export const resetForm = (formId: string) => ({ type: RESET_FORM, formId }); + +export const SET_STEP = 'forms/SET_STEP'; +export const setStep = (formId: string, stepIndex: number) => ({ + type: SET_STEP, + formId, + stepIndex +}); diff --git a/libs/form-context/src/lib/forms.reducer.ts b/libs/form-context/src/lib/forms.reducer.ts new file mode 100644 index 0000000..b91a882 --- /dev/null +++ b/libs/form-context/src/lib/forms.reducer.ts @@ -0,0 +1,188 @@ +import _ from 'lodash'; +import { FieldValues } from 'react-hook-form'; + +import { + PREVIOUS_STEP, + NEXT_STEP, + UPDATE_FORM_DATA, + INIT_FORM, + RESET_FORM, + SET_STEP +} from './forms.actions'; + +export interface DefaultFormState { + [key: string]: { + stepsCount: number; + isLastStep: boolean; + currentStepIndex: number; + data: FieldValues; + }; +} + +export interface FormAction { + type: string; + [key: string]: any; +} + +const defaultFormState = { + stepsCount: 1, + isLastStep: true, + currentStepIndex: 0, + data: {} +}; + +const DEFAULT_OBJECT = {}; +export const initialState = {} as any; + +const checkFormId = ({ formId }: FormAction) => !!formId; +const checkFormExist = ({ formId }: FormAction, state: DefaultFormState) => + !!state[formId]; +const checkStepExist = (stepsCount: number, newStepIndex: number) => + newStepIndex + 1 <= stepsCount; + +export const reducer = (state = initialState, action: FormAction) => { + switch (action.type) { + case INIT_FORM: { + if (!checkFormId(action) || checkFormExist(action, state)) { + return state; + } + + const stepsById = _.get(action, ['schema', 'stepsById'], [] as string[]); + const currentFormState = { + ...defaultFormState, + stepsCount: stepsById.length, + isLastStep: stepsById.length === 1 + }; + + return { + ...state, + [action.formId]: currentFormState + }; + } + + case PREVIOUS_STEP: { + if (!checkFormId(action)) { + return state; + } + if (!checkFormExist(action, state)) { + return state; + } + + const formState = _.get(state, action.formId, DEFAULT_OBJECT); + const currentStepIndex = _.get( + state, + [action.formId, 'currentStepIndex'], + 0 + ); + const stepsCount = _.get(state, [action.formId, 'stepsCount'], 1); + + // Can't go under 0 + const newStepIndex = currentStepIndex <= 0 ? 0 : currentStepIndex - 1; + + return { + ...state, + [action.formId]: { + ...formState, + isLastStep: stepsCount === newStepIndex + 1, + currentStepIndex: newStepIndex + } + }; + } + + case NEXT_STEP: { + if (!checkFormId(action)) { + return state; + } + if (!checkFormExist(action, state)) { + return state; + } + + const formState = _.get(state, action.formId, DEFAULT_OBJECT); + const currentStepIndex = _.get( + state, + [action.formId, 'currentStepIndex'], + 0 + ); + const stepsCount = _.get(state, [action.formId, 'stepsCount'], 1); + + // Can't go above steps count + const newStepIndex = + currentStepIndex >= stepsCount - 1 + ? stepsCount - 1 + : currentStepIndex + 1; + + return { + ...state, + [action.formId]: { + ...formState, + isLastStep: stepsCount === newStepIndex + 1, + currentStepIndex: newStepIndex + } + }; + } + + case UPDATE_FORM_DATA: { + if (!checkFormId(action)) { + return state; + } + if (!checkFormExist(action, state)) { + return state; + } + + const formState = _.get(state, action.formId); + + if (!formState) return state; + + return { + ...state, + [action.formId]: { + ...formState, + data: { + ...formState.data, + ...action.data + } + } + }; + } + + case RESET_FORM: { + if (!checkFormId(action)) { + return state; + } + if (!checkFormExist(action, state)) { + return state; + } + + return _.omit(state, [action.formId]); + } + + case SET_STEP: { + if (!checkFormId(action)) { + return state; + } + if (!checkFormExist(action, state)) { + return state; + } + + const newStepIndex = _.isNumber(action.stepIndex) ? action.stepIndex : 0; + const stepsCount = _.get(state, [action.formId, 'stepsCount'], 1); + + if (!checkStepExist(stepsCount, newStepIndex)) { + return state; + } + + const formState = _.get(state, action.formId, DEFAULT_OBJECT); + + return { + ...state, + [action.formId]: { + ...formState, + isLastStep: stepsCount === newStepIndex + 1, + currentStepIndex: newStepIndex % stepsCount + } + }; + } + default: + return state; + } +}; diff --git a/libs/form-context/src/lib/forms.selectors.ts b/libs/form-context/src/lib/forms.selectors.ts new file mode 100644 index 0000000..da92e1e --- /dev/null +++ b/libs/form-context/src/lib/forms.selectors.ts @@ -0,0 +1,15 @@ +import _ from 'lodash'; +import { DefaultFormState } from './forms.reducer'; +import { FieldValues } from 'react-hook-form'; + +const defaultData = {} as FieldValues; + +export const getFormData = (formId: string) => (state: DefaultFormState) => + _.get(state, [formId, 'data'], defaultData); + +export const getCurrentStepIndex = + (formId: string) => (state: DefaultFormState) => + _.get(state, [formId, 'currentStepIndex'], 0); + +export const isLastStep = (formId: string) => (state: DefaultFormState) => + _.get(state, [formId, 'isLastStep'], true); diff --git a/libs/form-context/tsconfig.json b/libs/form-context/tsconfig.json new file mode 100644 index 0000000..3230750 --- /dev/null +++ b/libs/form-context/tsconfig.json @@ -0,0 +1,23 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "jsx": "react-jsx", + "allowJs": true, + "esModuleInterop": true, + "allowSyntheticDefaultImports": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/form-context/tsconfig.lib.json b/libs/form-context/tsconfig.lib.json new file mode 100644 index 0000000..71adee6 --- /dev/null +++ b/libs/form-context/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "types": ["node"] + }, + "files": [ + "../../node_modules/@nrwl/react/typings/cssmodule.d.ts", + "../../node_modules/@nrwl/react/typings/image.d.ts" + ], + "exclude": ["**/*.spec.ts", "**/*.spec.tsx"], + "include": ["**/*.js", "**/*.jsx", "**/*.ts", "**/*.tsx"] +} diff --git a/libs/form-context/tsconfig.spec.json b/libs/form-context/tsconfig.spec.json new file mode 100644 index 0000000..559410b --- /dev/null +++ b/libs/form-context/tsconfig.spec.json @@ -0,0 +1,15 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "**/*.spec.ts", + "**/*.spec.tsx", + "**/*.spec.js", + "**/*.spec.jsx", + "**/*.d.ts" + ] +} diff --git a/project.json b/project.json new file mode 100644 index 0000000..aab13a3 --- /dev/null +++ b/project.json @@ -0,0 +1,30 @@ +{ + "root": ".", + "targets": { + "version": { + "executor": "@jscutlery/semver:version", + "options": { + "baseBranch": "master", + "push": true, + "noVerify": true, + "commitMessageFormat": "release(${projectName}): publish version ${version}", + "postTargets": [ + "workspace:github", + "form-builder:publish", + "form-validation-rule-list:publish", + "form-redux:publish", + "form-editor:publish", + "form-context:publish" + ] + } + }, + "github": { + "executor": "@jscutlery/semver:github", + "options": { + "target": "master", + "tag": "${tag}", + "notes": "${notes}" + } + } + } +} diff --git a/tsconfig.base.json b/tsconfig.base.json index bfdb4d7..8a98b27 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -16,6 +16,7 @@ "baseUrl": ".", "paths": { "@bedrockstreaming/form-builder": ["libs/form-builder/src/index.ts"], + "@bedrockstreaming/form-context": ["libs/form-context/src/index.ts"], "@bedrockstreaming/form-editor": ["libs/form-editor/src/index.ts"], "@bedrockstreaming/form-redux": ["libs/form-redux/src/index.ts"], "@bedrockstreaming/form-validation-rule-list": [ diff --git a/workspace.json b/workspace.json index 707b531..856f2c6 100644 --- a/workspace.json +++ b/workspace.json @@ -1,44 +1,17 @@ { "version": 2, "projects": { - "workspace": { - "root": ".", - "targets": { - "version": { - "executor": "@jscutlery/semver:version", - "options": { - "baseBranch": "master", - "push": true, - "noVerify": true, - "commitMessageFormat": "release(${projectName}): publish version ${version}", - "postTargets": [ - "workspace:github", - "form-builder:publish", - "form-validation-rule-list:publish", - "form-redux:publish", - "form-editor:publish" - ] - } - }, - "github": { - "executor": "@jscutlery/semver:github", - "options": { - "target": "master", - "tag": "${tag}", - "notes": "${notes}" - } - } - } - }, "demo": "apps/demo", "demo-e2e": "apps/demo-e2e", "docsite": "apps/docsite", "examples-birthdate": "libs/examples/birthdate", "examples-styled-inputs": "libs/examples/styled-inputs", "form-builder": "libs/form-builder", + "form-context": "libs/form-context", "form-editor": "libs/form-editor", "form-redux": "libs/form-redux", - "form-validation-rule-list": "libs/form-validation-rule-list" + "form-validation-rule-list": "libs/form-validation-rule-list", + "workspace": "." }, "cli": { "defaultCollection": "@nrwl/react"