diff --git a/.commitlintrc.json b/.commitlintrc.json new file mode 100644 index 0000000..f4fbb7d --- /dev/null +++ b/.commitlintrc.json @@ -0,0 +1,3 @@ +{ + "extends": ["@commitlint/config-conventional"] +} diff --git a/.eslintrc.js b/.eslintrc.js index 0fd398e..17ea4db 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -22,7 +22,9 @@ module.exports = { }, rules: { - '@typescript-eslint/ban-ts-ignore': 'off', // @TODO + '@typescript-eslint/camelcase': ['error', { + allow: ['^__Formulario'], + }], '@typescript-eslint/no-empty-function': 'off', '@typescript-eslint/no-explicit-any': 'off', // @TODO '@typescript-eslint/no-unused-vars': ['error'], // @TODO diff --git a/.github/workflows/github-actions.yml b/.github/workflows/github-actions.yml new file mode 100644 index 0000000..55b376b --- /dev/null +++ b/.github/workflows/github-actions.yml @@ -0,0 +1,16 @@ +name: Tests + +on: [push] + +jobs: + Tests: + runs-on: ubuntu-latest + steps: + - run: echo "Using branch ${{ github.ref }} for repository ${{ github.repository }}." + - name: Check out repository code + uses: actions/checkout@v2 + - run: echo "The ${{ github.repository }} repository has been cloned to the runner." + - run: chmod 777 . + - run: docker-compose pull + - run: docker-compose run --rm node yarn install + - run: docker-compose run --rm node yarn test diff --git a/.npmignore b/.npmignore index 5c3fde1..9769352 100644 --- a/.npmignore +++ b/.npmignore @@ -1,5 +1,9 @@ +.commitlintrc.json +.editorconfig .github .gitignore +.travis.yml +.versionrc.json /storybook /node_modules /build diff --git a/.versionrc.json b/.versionrc.json new file mode 100644 index 0000000..e2dffca --- /dev/null +++ b/.versionrc.json @@ -0,0 +1,12 @@ +{ + "types": [ + {"type": "feat", "section": "Features"}, + {"type": "fix", "section": "Fixes"}, + {"type": "chore", "hidden": true}, + {"type": "docs", "hidden": true}, + {"type": "style", "hidden": true}, + {"type": "refactor", "hidden": true}, + {"type": "perf", "hidden": true}, + {"type": "test", "hidden": true} + ] +} diff --git a/README.md b/README.md index ba3625c..5b20eff 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,11 @@ ## What is Vue Formulario? -Vue Formulario is a library, based on Vue Formulate, that handles the core logic +Vue Formulario is a library, inspired by Vue Formulate, that handles the core logic for working with forms and gives full control on the form presentation. ## Examples -Every form control have to rendered inside FormularioInput component. This component provides `id` and `context` in +Every form control have to rendered inside FormularioField component. This component provides `id` and `context` in v-slot props. Control should use `context.model` as v-model and `context.runValidation` as handler for `blur` event (it is necessary for validation when property `validationBehavior` is `demand`). Errors list for a field can be accessed through `context.allErrors`. @@ -27,7 +27,7 @@ The example below creates the authorization form from data: v-model="formData" name="formName" > - - + - - + - - + @@ -72,10 +72,10 @@ The example below creates the authorization form from data: > - - + + - @@ -83,7 +83,7 @@ The example below creates the authorization form from data: v-model="context.model" type="text" > - + ``` diff --git a/build/rollup.config.js b/build/rollup.config.js index 6c94096..c9f7f52 100644 --- a/build/rollup.config.js +++ b/build/rollup.config.js @@ -1,7 +1,6 @@ import alias from '@rollup/plugin-alias' import autoExternal from 'rollup-plugin-auto-external' import commonjs from '@rollup/plugin-commonjs' -import { terser } from 'rollup-plugin-terser' import typescript from 'rollup-plugin-typescript2' import vue from 'rollup-plugin-vue' @@ -14,19 +13,17 @@ export default { globals: { 'is-plain-object': 'isPlainObject', 'is-url': 'isUrl', - 'nanoid/non-secure': 'nanoid', vue: 'Vue', 'vue-property-decorator': 'vuePropertyDecorator', }, sourcemap: false, }], - external: ['nanoid/non-secure', 'vue', 'vue-property-decorator'], + external: ['vue', 'vue-property-decorator'], plugins: [ - typescript({ check: false, sourceMap: false }), + typescript({ sourceMap: false }), vue({ css: true, compileTemplate: true }), alias({ entries: [{ find: /^@\/(.+)/, replacement: './$1' }] }), commonjs(), autoExternal(), - // terser(), ] } diff --git a/build/rollup.iife.config.js b/build/rollup.iife.config.js index dc3517a..e817a01 100644 --- a/build/rollup.iife.config.js +++ b/build/rollup.iife.config.js @@ -16,7 +16,6 @@ export default { globals: { 'is-plain-object': 'isPlainObject', 'is-url': 'isUrl', - 'nanoid/non-secure': 'nanoid', vue: 'Vue', 'vue-property-decorator': 'vuePropertyDecorator', }, @@ -27,11 +26,11 @@ export default { browser: true, preferBuiltins: false, }), - typescript({ check: false, sourceMap: false }), + typescript({ sourceMap: false }), vue({ css: true, compileTemplate: true }), alias({ entries: [{ find: /^@\/(.+)/, replacement: './$1' }] }), commonjs(), - internal(['is-plain-object', 'nanoid/non-secure', 'is-url', 'vue-property-decorator']), + internal(['is-plain-object', 'is-url', 'vue-property-decorator']), terser(), ] } diff --git a/docker-compose.yml b/docker-compose.yml index 2b95a7a..8b3dee1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,8 @@ version: '3.6' services: node: - image: library/node:12 + image: node:12-alpine user: node volumes: - ./:/var/www/vue-formulario - - "$SSH_AUTH_SOCK:/ssh-auth.sock" working_dir: /var/www/vue-formulario diff --git a/package.json b/package.json index e000e7a..56194d6 100644 --- a/package.json +++ b/package.json @@ -1,20 +1,8 @@ { "name": "@retailcrm/vue-formulario", "version": "0.5.1", + "license": "MIT", "author": "RetailDriverLLC ", - "scripts": { - "build": "npm run build:esm & npm run build:umd & npm run build:iife & wait && echo \"Build complete:\nesm: $(gzip -c dist/formulario.esm.js | wc -c)b gzip\numd: $(gzip -c dist/formulario.umd.js | wc -c)b gzip\nmin: $(gzip -c dist/formulario.min.js | wc -c)b gzip\"", - "build:esm": "rollup --config build/rollup.config.js --format es --file dist/formulario.esm.js", - "build:iife": "rollup --config build/rollup.iife.config.js --format iife --file dist/formulario.min.js", - "build:size": "gzip -c dist/formulario.esm.js | wc -c", - "build:umd": "rollup --config build/rollup.config.js --format umd --file dist/formulario.umd.js", - "lint": "vue-cli-service lint", - "storybook:build": "vue-cli-service storybook:build -c storybook/config", - "storybook:serve": "vue-cli-service storybook:serve -p 6006 -c storybook/config", - "test": "NODE_ENV=test jest --config test/jest.conf.js", - "test:coverage": "NODE_ENV=test jest --config test/jest.conf.js --coverage", - "test:watch": "NODE_ENV=test jest --config test/jest.conf.js --watch" - }, "main": "dist/formulario.umd.js", "module": "dist/formulario.esm.js", "browser": { @@ -24,14 +12,35 @@ "dependencies": { "is-plain-object": "^3.0.0", "is-url": "^1.2.4", - "nanoid": "^2.1.11", "vue-class-component": "^7.2.3", "vue-property-decorator": "^8.4.2" }, + "bugs": { + "url": "https://github.com/retailcrm/vue-formulario/issues" + }, + "scripts": { + "build": "npm run build:esm & npm run build:umd & npm run build:iife & wait && echo \"Build complete:\nesm: $(gzip -c dist/formulario.esm.js | wc -c)b gzip\numd: $(gzip -c dist/formulario.umd.js | wc -c)b gzip\nmin: $(gzip -c dist/formulario.min.js | wc -c)b gzip\"", + "build:esm": "rollup --config build/rollup.config.js --format es --file dist/formulario.esm.js", + "build:iife": "rollup --config build/rollup.iife.config.js --format iife --file dist/formulario.min.js", + "build:size": "gzip -c dist/formulario.esm.js | wc -c", + "build:umd": "rollup --config build/rollup.config.js --format umd --file dist/formulario.umd.js", + "lint": "vue-cli-service lint", + "release": "standard-version", + "release:minor": "standard-version --release-as minor", + "release:patch": "standard-version --release-as patch", + "release:major": "standard-version --release-as major", + "storybook:build": "vue-cli-service storybook:build -c storybook/config", + "storybook:serve": "vue-cli-service storybook:serve -p 6006 -c storybook/config", + "test": "NODE_ENV=test jest --config test/jest.conf.js", + "test:coverage": "NODE_ENV=test jest --config test/jest.conf.js --coverage", + "test:watch": "NODE_ENV=test jest --config test/jest.conf.js --watch" + }, "devDependencies": { "@babel/core": "^7.9.6", "@babel/plugin-transform-modules-commonjs": "^7.9.6", "@babel/preset-env": "^7.9.6", + "@commitlint/cli": "^12.1.4", + "@commitlint/config-conventional": "^12.1.4", "@rollup/plugin-alias": "^3.1.1", "@rollup/plugin-buble": "^0.21.3", "@rollup/plugin-commonjs": "^11.1.0", @@ -43,7 +52,6 @@ "@storybook/vue": "^6.0.26", "@types/is-url": "^1.2.28", "@types/jest": "^26.0.14", - "@types/nanoid": "^2.1.0", "@typescript-eslint/eslint-plugin": "^2.26.0", "@typescript-eslint/parser": "^2.26.0", "@vue/cli-plugin-babel": "^4.3.1", @@ -78,6 +86,7 @@ "rollup-plugin-typescript2": "^0.27.3", "rollup-plugin-vue": "^5.1.7", "sass-loader": "^10.0.3", + "standard-version": "^9.3.0", "ts-jest": "^26.4.1", "typescript": "~3.9.3", "vue": "^2.6.11", @@ -88,9 +97,6 @@ "vue-template-es2015-compiler": "^1.9.1", "watch": "^1.0.2" }, - "bugs": { - "url": "https://github.com/retailcrm/vue-formulario/issues" - }, "contributors": [ "Justin Schroeder " ], @@ -101,7 +107,6 @@ "validation", "validate" ], - "license": "MIT", "publishConfig": { "access": "public" }, diff --git a/src/Formulario.ts b/src/Formulario.ts index 9c41268..8aa49f0 100644 --- a/src/Formulario.ts +++ b/src/Formulario.ts @@ -7,8 +7,11 @@ import { ValidationRuleFn, ValidationMessageFn, ValidationMessageI18NFn, + Violation, } from '@/validation/validator' +import { FormularioForm } from '@/types' + export interface FormularioOptions { validationRules?: Record; validationMessages?: Record; @@ -21,7 +24,11 @@ export default class Formulario { public validationRules: Record = {} public validationMessages: Record = {} - constructor (options?: FormularioOptions) { + private readonly registry: Map + + public constructor (options?: FormularioOptions) { + this.registry = new Map() + this.validationRules = validationRules this.validationMessages = validationMessages @@ -31,26 +38,70 @@ export default class Formulario { /** * Given a set of options, apply them to the pre-existing options. */ - extend (extendWith: FormularioOptions): Formulario { + public extend (extendWith: FormularioOptions): Formulario { if (typeof extendWith === 'object') { this.validationRules = merge(this.validationRules, extendWith.validationRules || {}) this.validationMessages = merge(this.validationMessages, extendWith.validationMessages || {}) return this } - throw new Error(`[Formulario]: Formulario.extend() should be passed an object (was ${typeof extendWith})`) + throw new Error(`[Formulario]: Formulario.extend(): should be passed an object (was ${typeof extendWith})`) + } + + public runValidation (id: string): Promise> { + if (!this.registry.has(id)) { + throw new Error(`[Formulario]: Formulario.runValidation(): no forms with id "${id}"`) + } + + const form = this.registry.get(id) as FormularioForm + + return form.runValidation() + } + + public resetValidation (id: string): void { + if (!this.registry.has(id)) { + return + } + + const form = this.registry.get(id) as FormularioForm + + form.resetValidation() + } + + /** + * Used by forms instances to add themselves into a registry + * @internal + */ + public register (id: string, form: FormularioForm): void { + if (this.registry.has(id)) { + throw new Error(`[Formulario]: Formulario.register(): id "${id}" is already in use`) + } + + this.registry.set(id, form) + } + + /** + * Used by forms instances to remove themselves from a registry + * @internal + */ + public unregister (id: string): void { + if (this.registry.has(id)) { + this.registry.delete(id) + } } /** * Get validation rules by merging any passed in with global rules. + * @internal */ - getRules (extendWith: Record = {}): Record { + public getRules (extendWith: Record = {}): Record { return merge(this.validationRules, extendWith) } /** * Get validation messages by merging any passed in with global messages. + * @internal */ - getMessages (vm: Vue, extendWith: Record): Record { + public getMessages (vm: Vue, extendWith: Record): Record { const raw = merge(this.validationMessages || {}, extendWith) const messages: Record = {} diff --git a/src/FormularioField.vue b/src/FormularioField.vue new file mode 100644 index 0000000..6351007 --- /dev/null +++ b/src/FormularioField.vue @@ -0,0 +1,225 @@ + + + diff --git a/src/FormularioFieldGroup.vue b/src/FormularioFieldGroup.vue new file mode 100644 index 0000000..f805846 --- /dev/null +++ b/src/FormularioFieldGroup.vue @@ -0,0 +1,38 @@ + + + diff --git a/src/FormularioForm.vue b/src/FormularioForm.vue index 4dfc006..5fd9e87 100644 --- a/src/FormularioForm.vue +++ b/src/FormularioForm.vue @@ -1,209 +1,208 @@ diff --git a/src/FormularioInput.vue b/src/FormularioInput.vue deleted file mode 100644 index f9f9e06..0000000 --- a/src/FormularioInput.vue +++ /dev/null @@ -1,243 +0,0 @@ - - - diff --git a/src/form/registry.ts b/src/form/registry.ts deleted file mode 100644 index 07e2ae7..0000000 --- a/src/form/registry.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { shallowEqualObjects, has, getNested } from '@/utils' -import FormularioForm from '@/FormularioForm.vue' -import FormularioInput from '@/FormularioInput.vue' - -/** - * Component registry with inherent depth to handle complex nesting. This is - * important for features such as grouped fields. - */ -export default class Registry { - private ctx: FormularioForm - private registry: Map - - /** - * Create a new registry of components. - * @param {FormularioForm} ctx The host vm context of the registry. - */ - constructor (ctx: FormularioForm) { - this.registry = new Map() - this.ctx = ctx - } - - /** - * Fully register a component. - * @param {string} field name of the field. - * @param {FormularioForm} component the actual component instance. - */ - add (field: string, component: FormularioInput): void { - if (this.registry.has(field)) { - return - } - - this.registry.set(field, component) - - // @ts-ignore - const value = getNested(this.ctx.initialValues, field) - const hasModel = has(component.$options.propsData || {}, 'value') - - // @ts-ignore - if (!hasModel && this.ctx.hasInitialValue && value !== undefined) { - // In the case that the form is carrying an initial value and the - // element is not, set it directly. - // @ts-ignore - component.context.model = value - // @ts-ignore - } else if (hasModel && !shallowEqualObjects(component.proxy, value)) { - // In this case, the field is v-modeled or has an initial value and the - // form has no value or a different value, so use the field value - // @ts-ignore - this.ctx.setFieldValueAndEmit(field, component.proxy) - } - } - - /** - * Remove an item from the registry. - */ - remove (name: string): void { - this.registry.delete(name) - // @ts-ignore - // eslint-disable-next-line @typescript-eslint/no-unused-vars - const { [name]: value, ...newProxy } = this.ctx.proxy - // @ts-ignore - this.ctx.proxy = newProxy - } - - /** - * Check if the registry has the given key. - */ - has (key: string): boolean { - return this.registry.has(key) - } - - /** - * Check if the registry has elements, that equals or nested given key - */ - hasNested (key: string): boolean { - for (const i of this.registry.keys()) { - if (i === key || i.includes(key + '.')) { - return true - } - } - - return false - } - - /** - * Get a particular registry value. - */ - get (key: string): FormularioInput | undefined { - return this.registry.get(key) - } - - /** - * Get registry value for key or nested to given key - */ - getNested (key: string): Map { - const result = new Map() - - for (const i of this.registry.keys()) { - const objectKey = key + '.' - const arrayKey = key + '[' - - if ( - i === key || - i.substring(0, objectKey.length) === objectKey || - i.substring(0, arrayKey.length) === arrayKey - ) { - result.set(i, this.registry.get(i)) - } - } - - return result - } - - /** - * Map over the registry (recursively). - */ - forEach (callback: Function): void { - this.registry.forEach((component, field) => { - callback(component, field) - }) - } - - /** - * Return the keys of the registry. - */ - keys (): string[] { - return Array.from(this.registry.keys()) - } - - /** - * Reduce the registry. - * @param {function} callback - * @param accumulator - */ - reduce (callback: Function, accumulator: U): U { - this.registry.forEach((component, field) => { - accumulator = callback(accumulator, component, field) - }) - return accumulator - } -} diff --git a/src/index.ts b/src/index.ts index a8e34b8..c6c7c6f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,22 @@ -import Formulario, { FormularioOptions } from '@/Formulario.ts' import { VueConstructor } from 'vue' + +import Formulario, { FormularioOptions } from '@/Formulario.ts' + +import FormularioField from '@/FormularioField.vue' +import FormularioFieldGroup from '@/FormularioFieldGroup.vue' import FormularioForm from '@/FormularioForm.vue' -import FormularioGrouping from '@/FormularioGrouping.vue' -import FormularioInput from '@/FormularioInput.vue' export default { + Formulario, install (Vue: VueConstructor, options?: FormularioOptions): void { + Vue.component('FormularioField', FormularioField) + Vue.component('FormularioFieldGroup', FormularioFieldGroup) Vue.component('FormularioForm', FormularioForm) - Vue.component('FormularioGrouping', FormularioGrouping) - Vue.component('FormularioInput', FormularioInput) + + // @deprecated Use FormularioField instead + Vue.component('FormularioInput', FormularioField) + // @deprecated Use FormularioFieldGroup instead + Vue.component('FormularioGrouping', FormularioFieldGroup) Vue.mixin({ beforeCreate () { diff --git a/src/types.ts b/src/types.ts new file mode 100644 index 0000000..de306a6 --- /dev/null +++ b/src/types.ts @@ -0,0 +1,105 @@ +import Vue from 'vue' +import { Violation } from '@/validation/validator' + +export interface FormularioForm extends Vue { + runValidation(): Promise>; + resetValidation(): void; +} + +export interface FormularioField extends Vue { + hasModel: boolean; + proxy: unknown; + setErrors(errors: string[]): void; + runValidation(): Promise; + resetValidation(): void; +} + +export type FormularioFieldContext = { + model: T; + name: string; + runValidation(): Promise; + violations: Violation[]; + errors: string[]; + allErrors: string[]; +} + +export interface FormularioFieldModelGetConverter { + (value: U|Empty): U|T|Empty; +} + +export interface FormularioFieldModelSetConverter { + (curr: U|T, prev: U|Empty): U|T; +} + +export type Empty = undefined | null + +export enum TYPE { + ARRAY = 'ARRAY', + BIGINT = 'BIGINT', + BOOLEAN = 'BOOLEAN', + DATE = 'DATE', + FUNCTION = 'FUNCTION', + NUMBER = 'NUMBER', + RECORD = 'RECORD', + STRING = 'STRING', + SYMBOL = 'SYMBOL', + UNDEFINED = 'UNDEFINED', + NULL = 'NULL', +} + +export function typeOf (value: unknown): string { + switch (typeof value) { + case 'bigint': + return TYPE.BIGINT + case 'boolean': + return TYPE.BOOLEAN + case 'function': + return TYPE.FUNCTION + case 'number': + return TYPE.NUMBER + case 'string': + return TYPE.STRING + case 'symbol': + return TYPE.SYMBOL + case 'undefined': + return TYPE.UNDEFINED + case 'object': + if (value === null) { + return TYPE.NULL + } + + if (value instanceof Date) { + return TYPE.DATE + } + + if (Array.isArray(value)) { + return TYPE.ARRAY + } + + if (value.constructor.name === 'Object') { + return TYPE.RECORD + } + + return 'InstanceOf<' + value.constructor.name + '>' + } + + throw new Error() +} + +export function isRecordLike (value: unknown): boolean { + return typeof value === 'object' && value !== null && ['Array', 'Object'].includes(value.constructor.name) +} + +export function isScalar (value: unknown): boolean { + switch (typeof value) { + case 'bigint': + case 'boolean': + case 'number': + case 'string': + case 'symbol': + case 'undefined': + return true + default: + return value === null + } +} diff --git a/src/utils/access.ts b/src/utils/access.ts new file mode 100644 index 0000000..628569c --- /dev/null +++ b/src/utils/access.ts @@ -0,0 +1,122 @@ +import has from './has' +import { isRecordLike, isScalar } from '@/types' + +const extractIntOrNaN = (value: string): number => { + const numeric = parseInt(value) + + return numeric.toString() === value ? numeric : NaN +} + +const extractPath = (raw: string): string[] => { + const path = [] as string[] + + raw.split('.').forEach(key => { + if (/(.*)\[(\d+)]$/.test(key)) { + path.push(...key.substr(0, key.length - 1).split('[').filter(k => k.length)) + } else { + path.push(key) + } + }) + + return path +} + +export function get (state: unknown, rawOrPath: string|string[], fallback: unknown = undefined): unknown { + const path = typeof rawOrPath === 'string' ? extractPath(rawOrPath) : rawOrPath + + if (isScalar(state) || path.length === 0) { + return fallback + } + + const key = path.shift() as string + const index = extractIntOrNaN(key) + + if (!isNaN(index)) { + if (Array.isArray(state) && index >= 0 && index < state.length) { + return path.length === 0 ? state[index] : get(state[index], path, fallback) + } + + return undefined + } + + if (has(state as Record, key)) { + const values = state as Record + + return path.length === 0 ? values[key] : get(values[key], path, fallback) + } + + return undefined +} + +export function set (state: unknown, rawOrPath: string|string[], value: unknown): unknown { + const path = typeof rawOrPath === 'string' ? extractPath(rawOrPath) : rawOrPath + + if (path.length === 0) { + return value + } + + const key = path.shift() as string + const index = extractIntOrNaN(key) + + if (!isRecordLike(state)) { + return set(!isNaN(index) ? [] : {}, [key, ...path], value) + } + + if (!isNaN(index) && Array.isArray(state)) { + const slice = [...state as unknown[]] + + slice[index] = path.length === 0 ? value : set(slice[index], path, value) + + return slice + } + + const slice = { ...state as Record } + + slice[key] = path.length === 0 ? value : set(slice[key], path, value) + + return slice +} + +const unsetInRecord = (record: Record, prop: string): Record => { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { [prop]: _, ...copy } = record + + return copy +} + +export function unset (state: unknown, rawOrPath: string|string[]): unknown { + if (!isRecordLike(state)) { + return state + } + + const path = typeof rawOrPath === 'string' ? extractPath(rawOrPath) : rawOrPath + + if (path.length === 0) { + return state + } + + const key = path.shift() as string + const index = extractIntOrNaN(key) + + if (!isNaN(index) && Array.isArray(state) && index >= 0 && index < state.length) { + const slice = [...state as unknown[]] + + if (path.length === 0) { + slice.splice(index, 1) + } else { + slice[index] = unset(slice[index], path) + } + + return slice + } + + if (has(state as Record, key)) { + const slice = { ...state as Record } + + return path.length === 0 + ? unsetInRecord(slice, key) + : { ...slice, [key]: unset(slice[key], path) } + } + + return state +} diff --git a/src/utils/arrayify.ts b/src/utils/arrayify.ts deleted file mode 100644 index 94eee43..0000000 --- a/src/utils/arrayify.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Converts to array. - * If given parameter is not string, object ot array, result will be an empty array. - * @param {*} item - */ -export default function arrayify (item: any): any[] { - if (!item) { - return [] - } - if (typeof item === 'string') { - return [item] - } - if (Array.isArray(item)) { - return item - } - if (typeof item === 'object') { - return Object.values(item) - } - return [] -} diff --git a/src/utils/clone.ts b/src/utils/clone.ts index acda4ec..6275f19 100644 --- a/src/utils/clone.ts +++ b/src/utils/clone.ts @@ -1,28 +1,34 @@ -import isScalar from '@/utils/isScalar' -import has from '@/utils/has' +import { isRecordLike, isScalar } from '@/types' + +const cloneInstance = (original: T): T => { + return Object.assign(Object.create(Object.getPrototypeOf(original)), original) +} /** * A simple (somewhat non-comprehensive) clone function, valid for our use * case of needing to unbind reactive watchers. */ -export default function clone (value: any): any { - if (typeof value !== 'object') { +export default function clone (value: T): T { + if (isScalar(value)) { return value } - const copy: any | Record = Array.isArray(value) ? [] : {} - - for (const key in value) { - if (has(value, key)) { - if (isScalar(value[key])) { - copy[key] = value[key] - } else if (value instanceof Date) { - copy[key] = new Date(copy[key]) - } else { - copy[key] = clone(value[key]) - } - } + if (value instanceof Date) { + return new Date(value) as unknown as T } - return copy + if (!isRecordLike(value)) { + return cloneInstance(value) + } + + if (Array.isArray(value)) { + return value.slice().map(clone) as unknown as T + } + + const source: Record = value as Record + + return Object.keys(source).reduce((copy, key) => ({ + ...copy, + [key]: clone(source[key]) + }), {}) as unknown as T } diff --git a/src/utils/compare.ts b/src/utils/compare.ts new file mode 100644 index 0000000..927e26a --- /dev/null +++ b/src/utils/compare.ts @@ -0,0 +1,82 @@ +import has from './has' +import { typeOf, TYPE } from '@/types' + +export interface EqualPredicate { + (a: unknown, b: unknown): boolean; +} + +export function datesEquals (a: Date, b: Date): boolean { + return a.getTime() === b.getTime() +} + +export function arraysEquals ( + a: unknown[], + b: unknown[], + predicate: EqualPredicate +): boolean { + if (a.length !== b.length) { + return false + } + + for (let i = 0; i < a.length; i++) { + if (!predicate(a[i], b[i])) { + return false + } + } + + return true +} + +export function recordsEquals ( + a: Record, + b: Record, + predicate: EqualPredicate +): boolean { + if (Object.keys(a).length !== Object.keys(b).length) { + return false + } + + for (const prop in a as object) { + if (!has(b, prop) || !predicate(a[prop], b[prop])) { + return false + } + } + + return true +} + +export function strictEquals (a: unknown, b: unknown): boolean { + return a === b +} + +export function equals (a: unknown, b: unknown, predicate: EqualPredicate): boolean { + const typeOfA = typeOf(a) + const typeOfB = typeOf(b) + + return typeOfA === typeOfB && ( + (typeOfA === TYPE.ARRAY && arraysEquals( + a as unknown[], + b as unknown[], + predicate + )) || + (typeOfA === TYPE.DATE && datesEquals(a as Date, b as Date)) || + (typeOfA === TYPE.RECORD && recordsEquals( + a as Record, + b as Record, + predicate + )) || + (typeOfA.includes('InstanceOf') && equals( + Object.entries(a as object), + Object.entries(b as object), + predicate, + )) + ) +} + +export function deepEquals (a: unknown, b: unknown): boolean { + return a === b || equals(a, b, deepEquals) +} + +export function shallowEquals (a: unknown, b: unknown): boolean { + return a === b || equals(a, b, strictEquals) +} diff --git a/src/utils/id.ts b/src/utils/id.ts new file mode 100644 index 0000000..72d3c87 --- /dev/null +++ b/src/utils/id.ts @@ -0,0 +1,10 @@ +const registry: Map = new Map() + +export default (prefix: string): string => { + const current = registry.get(prefix) || 0 + const next = current + 1 + + registry.set(prefix, next) + + return `${prefix}-${next}` +} diff --git a/src/utils/index.ts b/src/utils/index.ts index aae4878..c5aae58 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,71 +1,8 @@ -export { default as arrayify } from './arrayify' +export { default as id } from './id' export { default as clone } from './clone' export { default as has } from './has' -export { default as isScalar } from './isScalar' export { default as merge } from './merge' +export { get, set, unset } from './access' export { default as regexForFormat } from './regexForFormat' -export { default as shallowEqualObjects } from './shallowEqualObjects' +export { deepEquals, shallowEquals } from './compare' export { default as snakeToCamel } from './snakeToCamel' - -export function getNested (obj: Record, field: string): any { - const fieldParts = field.split('.') - - let result: Record = obj - - for (const key in fieldParts) { - const matches = fieldParts[key].match(/(.+)\[(\d+)\]$/) - if (result === undefined) { - return null - } - if (matches) { - result = result[matches[1]] - - if (result === undefined) { - return null - } - result = result[matches[2]] - } else { - result = result[fieldParts[key]] - } - } - return result -} - -export function setNested (obj: Record, field: string, value: any): void { - const fieldParts = field.split('.') - - let subProxy: Record = obj - for (let i = 0; i < fieldParts.length; i++) { - const fieldPart = fieldParts[i] - const matches = fieldPart.match(/(.+)\[(\d+)\]$/) - - if (subProxy === undefined) { - break - } - - if (matches) { - if (subProxy[matches[1]] === undefined) { - subProxy[matches[1]] = [] - } - subProxy = subProxy[matches[1]] - - if (i === fieldParts.length - 1) { - subProxy[matches[2]] = value - break - } else { - subProxy = subProxy[matches[2]] - } - } else { - if (i === fieldParts.length - 1) { - subProxy[fieldPart] = value - break - } else { - // eslint-disable-next-line max-depth - if (subProxy[fieldPart] === undefined) { - subProxy[fieldPart] = {} - } - subProxy = subProxy[fieldPart] - } - } - } -} diff --git a/src/utils/isScalar.ts b/src/utils/isScalar.ts deleted file mode 100644 index 572b0c5..0000000 --- a/src/utils/isScalar.ts +++ /dev/null @@ -1,12 +0,0 @@ -export default function isScalar (data: any): boolean { - switch (typeof data) { - case 'symbol': - case 'number': - case 'string': - case 'boolean': - case 'undefined': - return true - default: - return data === null - } -} diff --git a/src/utils/shallowEqualObjects.ts b/src/utils/shallowEqualObjects.ts deleted file mode 100644 index 6f4a207..0000000 --- a/src/utils/shallowEqualObjects.ts +++ /dev/null @@ -1,34 +0,0 @@ -export default function shallowEqualObjects (objA: Record, objB: Record): boolean { - if (objA === objB) { - return true - } - - if (!objA || !objB) { - return false - } - - const aKeys = Object.keys(objA) - const bKeys = Object.keys(objB) - - if (bKeys.length !== aKeys.length) { - return false - } - - if (objA instanceof Date && objB instanceof Date) { - return objA.getTime() === objB.getTime() - } - - if (aKeys.length === 0) { - return objA === objB - } - - for (let i = 0; i < aKeys.length; i++) { - const key = aKeys[i] - - if (objA[key] !== objB[key]) { - return false - } - } - - return true -} diff --git a/src/validation/ErrorObserver.ts b/src/validation/ErrorObserver.ts deleted file mode 100644 index 847a5e6..0000000 --- a/src/validation/ErrorObserver.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { has } from '@/utils' - -export interface ErrorHandler { - (errors: Record | any[]): void; -} - -export interface ErrorObserver { - callback: ErrorHandler; - type: 'form' | 'input'; - field?: string; -} - -export interface ErrorObserverPredicate { - (value: ErrorObserver, index: number, array: ErrorObserver[]): unknown; -} - -export class ErrorObserverRegistry { - private observers: ErrorObserver[] = [] - - constructor (observers: ErrorObserver[] = []) { - this.observers = observers - } - - public add (observer: ErrorObserver): void { - if (!this.observers.some(o => o.callback === observer.callback)) { - this.observers.push(observer) - } - } - - public remove (handler: ErrorHandler): void { - this.observers = this.observers.filter(o => o.callback !== handler) - } - - public filter (predicate: ErrorObserverPredicate): ErrorObserverRegistry { - return new ErrorObserverRegistry(this.observers.filter(predicate)) - } - - public some (predicate: ErrorObserverPredicate): boolean { - return this.observers.some(predicate) - } - - public observe (errors: Record|string[]): void { - this.observers.forEach(observer => { - if (observer.type === 'form') { - observer.callback(errors) - } else if ( - observer.field && - !Array.isArray(errors) - ) { - if (has(errors, observer.field)) { - observer.callback(errors[observer.field]) - } else { - observer.callback([]) - } - } - }) - } -} diff --git a/src/validation/rules.ts b/src/validation/rules.ts index c88f86c..383453a 100644 --- a/src/validation/rules.ts +++ b/src/validation/rules.ts @@ -1,5 +1,5 @@ import isUrl from 'is-url' -import { has, regexForFormat, shallowEqualObjects } from '@/utils' +import { has, regexForFormat, shallowEquals } from '@/utils' import { ValidationContext, ValidationRuleFn, @@ -130,7 +130,7 @@ const rules: Record = { * Rule: Value is in an array (stack). */ in ({ value }: ValidationContext, ...stack: any[]): boolean { - return stack.some(item => typeof item === 'object' ? shallowEqualObjects(item, value) : item === value) + return stack.some(item => shallowEquals(item, value)) }, /** @@ -198,7 +198,7 @@ const rules: Record = { * Rule: Value is not in stack. */ not ({ value }: ValidationContext, ...stack: any[]): boolean { - return !stack.some(item => typeof item === 'object' ? shallowEqualObjects(item, value) : item === value) + return !stack.some(item => shallowEquals(item, value)) }, /** diff --git a/src/validation/validator.ts b/src/validation/validator.ts index 2954a8e..a34b2b5 100644 --- a/src/validation/validator.ts +++ b/src/validation/validator.ts @@ -5,10 +5,10 @@ export interface Validator { } export interface Violation { + message: string; rule: string|null; args: any[]; context: ValidationContext|null; - message: string; } export interface ValidationRuleFn { diff --git a/storybook/stories/ExampleAddressList.tale.vue b/storybook/stories/ExampleAddressList.tale.vue index f010758..34314cb 100644 --- a/storybook/stories/ExampleAddressList.tale.vue +++ b/storybook/stories/ExampleAddressList.tale.vue @@ -1,26 +1,49 @@ @@ -119,4 +160,8 @@ export default { .field { max-width: 250px; } + +.remove-btn-wrapper { + padding-top: 32px; +} diff --git a/storybook/stories/FormularioGrouping.tale.vue b/storybook/stories/FormularioGrouping.tale.vue deleted file mode 100644 index d42333a..0000000 --- a/storybook/stories/FormularioGrouping.tale.vue +++ /dev/null @@ -1,106 +0,0 @@ - - - diff --git a/storybook/stories/index.stories.js b/storybook/stories/index.stories.js index c0c491d..78b2bde 100644 --- a/storybook/stories/index.stories.js +++ b/storybook/stories/index.stories.js @@ -3,10 +3,9 @@ import './bootstrap.scss' import { storiesOf } from '@storybook/vue' import Vue from 'vue' -import VueFormulario from '../../dist/formulario.esm' +import VueFormulario from '@/index.ts' import ExampleAddressList from './ExampleAddressList.tale' -import FormularioGrouping from './FormularioGrouping.tale' Vue.mixin({ methods: { @@ -22,4 +21,3 @@ Vue.use(VueFormulario) storiesOf('Examples', module) .add('Address list', () => ExampleAddressList) - .add('FormularioGrouping', () => FormularioGrouping) diff --git a/test/unit/Formulario.test.js b/test/unit/Formulario.test.js index 4cbb10f..04a1d03 100644 --- a/test/unit/Formulario.test.js +++ b/test/unit/Formulario.test.js @@ -3,60 +3,54 @@ import Formulario from '@/Formulario.ts' import plugin from '@/index.ts' describe('Formulario', () => { - it('Installs on vue instance', () => { + it('installs on vue instance', () => { const localVue = createLocalVue() localVue.use(plugin) + expect(localVue.component('FormularioField')).toBeTruthy() + expect(localVue.component('FormularioFieldGroup')).toBeTruthy() expect(localVue.component('FormularioForm')).toBeTruthy() - expect(localVue.component('FormularioGrouping')).toBeTruthy() - expect(localVue.component('FormularioInput')).toBeTruthy() - const wrapper = mount({ template: '
', }, { localVue }) + const wrapper = mount({ render: h => h('div'), }, { localVue }) expect(wrapper.vm.$formulario).toBeInstanceOf(Formulario) }) - it ('Pushes Formulario instance to child a component', () => { + it ('pushes Formulario instance to child a component', () => { const localVue = createLocalVue() localVue.use(plugin) - localVue.component('TestComponent', { - render (h) { - return h('div') - } + + const ChildComponent = localVue.component('ChildComponent', { + render: h => h('div'), }) - const wrapper = mount({ - render (h) { - return h('div', [h('TestComponent', { ref: 'test' })]) - }, + const parent = mount({ + render: h => h('div', [h('ChildComponent')]), }, { localVue }) - expect(wrapper.vm.$formulario === wrapper.vm.$refs.test.$formulario).toBe(true) + const child = parent.findComponent(ChildComponent) + + expect(parent.vm.$formulario === child.vm.$formulario).toBe(true) }) - it ('Does not pushes Formulario instance to a child component, if it has its own', () => { + it ('does not push Formulario instance to a child component, if it has its own', () => { const localVue = createLocalVue() localVue.use(plugin) // noinspection JSCheckFunctionSignatures - localVue.component('TestComponent', { - formulario () { - return new Formulario() - }, - - render (h) { - return h('div') - }, + const ChildComponent = localVue.component('ChildComponent', { + formulario: () => new Formulario(), + render: h => h('div'), }) - const wrapper = mount({ - render (h) { - return h('div', [h('TestComponent', { ref: 'test' })]) - }, + const parent = mount({ + render: h => h('div', [h('ChildComponent')]), }, { localVue }) - expect(wrapper.vm.$formulario === wrapper.vm.$refs.test.$formulario).toBe(false) + const child = parent.findComponent(ChildComponent) + + expect(parent.vm.$formulario === child.vm.$formulario).toBe(false) }) }) diff --git a/test/unit/FormularioField.test.js b/test/unit/FormularioField.test.js new file mode 100644 index 0000000..6acc99f --- /dev/null +++ b/test/unit/FormularioField.test.js @@ -0,0 +1,289 @@ +import Vue from 'vue' +import flushPromises from 'flush-promises' +import { mount } from '@vue/test-utils' + +import Formulario from '@/index.ts' +import FormularioField from '@/FormularioField.vue' +import FormularioForm from '@/FormularioForm.vue' + +const globalRule = jest.fn(() => false) + +Vue.use(Formulario, { + validationRules: { globalRule }, + validationMessages: { + required: () => 'required', + 'in': () => 'in', + min: () => 'min', + globalRule: () => 'globalRule', + }, +}) + +describe('FormularioField', () => { + const createWrapper = (props = {}) => mount(FormularioField, { + propsData: { + name: 'field', + value: 'initial', + validation: 'required|in:abcdef', + validationMessages: { in: 'the value was different than expected' }, + ...props, + }, + scopedSlots: { + default: ` +
+ + + {{ violation.message }} + +
+ `, + }, + }) + + test('allows custom field-rule level validation strings', async () => { + const wrapper = createWrapper({ + validationBehavior: 'live', + }) + + await flushPromises() + + expect(wrapper.find('[data-violation]').text()).toBe( + 'the value was different than expected' + ) + }) + + test.each([ + ['demand'], + ['submit'], + ])('no validation when validationBehavior is not "live"', async validationBehavior => { + const wrapper = createWrapper({ validationBehavior }) + + await flushPromises() + + expect(wrapper.find('[data-violation]').exists()).toBe(false) + + wrapper.find('input').element['value'] = 'updated' + wrapper.find('input').trigger('change') + + await flushPromises() + + expect(wrapper.find('input').element['value']).toBe('updated') + expect(wrapper.find('[data-violation]').exists()).toBe(false) + }) + + test('allows custom validation rule message', async () => { + const wrapper = createWrapper({ + value: 'other value', + validationMessages: { in: ({ value }) => `the string "${value}" is not correct` }, + validationBehavior: 'live', + }) + + await flushPromises() + + expect(wrapper.find('[data-violation]').text()).toBe( + 'the string "other value" is not correct' + ) + }) + + test.each([ + ['bar', ({ value }) => value === 'foo'], + ['bar', ({ value }) => Promise.resolve(value === 'foo')], + ])('uses local custom validation rules', async (value, rule) => { + const wrapper = createWrapper({ + value, + validation: 'required|custom', + validationRules: { custom: rule }, + validationMessages: { custom: 'failed the custom rule check' }, + validationBehavior: 'live', + }) + + await flushPromises() + + expect(wrapper.find('[data-violation]').text()).toBe('failed the custom rule check') + }) + + test('uses global custom validation rules', async () => { + mount(FormularioField, { + propsData: { + name: 'test', + value: 'bar', + validation: 'required|globalRule', + validationBehavior: 'live', + }, + }) + + await flushPromises() + + expect(globalRule.mock.calls.length).toBe(1) + }) + + test('emits correct validation event', async () => { + const wrapper = mount(FormularioField, { + propsData: { + name: 'field', + value: '', + validation: 'required', + validationBehavior: 'live', + }, + }) + + await flushPromises() + + expect(wrapper.emitted('validation')).toEqual([[{ + path: 'field', + violations: [{ + rule: 'required', + args: expect.any(Array), + context: { + name: 'field', + value: '', + formValues: {}, + }, + message: expect.any(String), + }], + }]]) + }) + + test.each([ + ['bail|required|in:xyz', 1], + ['^required|in:xyz|min:10,length', 1], + ['required|^in:xyz|min:10,length', 2], + ['required|in:xyz|bail', 2], + ])('prevents further validation if not passed a rule with bail modifier', async ( + validation, + expectedViolationsCount + ) => { + const wrapper = createWrapper({ + value: '', + validation, + validationBehavior: 'live', + }) + + await flushPromises() + + expect(wrapper.findAll('[data-violation]').length).toBe(expectedViolationsCount) + }) + + test('proceeds validation if passed a rule with bail modifier', async () => { + const wrapper = createWrapper({ + value: '123', + validation: '^required|in:xyz|min:10,length', + validationBehavior: 'live', + }) + + await flushPromises() + + expect(wrapper.findAll('[data-violation]').length).toBe(2) + }) + + test('displays errors when validation-behavior is submit and form is submitted', async () => { + const wrapper = mount(FormularioForm, { + slots: { + default: ` + + + {{ violation.message }} + + + ` + } + }) + + await flushPromises() + + expect(wrapper.find('[data-violation]').exists()).toBe(false) + + wrapper.trigger('submit') + + await flushPromises() + + expect(wrapper.find('[data-violation]').exists()).toBe(true) + }) + + test('model getter for input', async () => { + const wrapper = mount({ + data: () => ({ state: { date: 'not a date' } }), + template: ` + + + {{ context.model }} + + + `, + methods: { + onGet (source) { + return source instanceof Date ? source.getDate() : 'invalid date' + }, + } + }) + + await flushPromises() + + expect(wrapper.find('[data-output]').text()).toBe('invalid date') + + wrapper.vm.state = { date: new Date('1995-12-17') } + + await flushPromises() + + expect(wrapper.find('[data-output]').text()).toBe('17') + }) + + test('model setter for input', async () => { + const wrapper = mount({ + data: () => ({ state: { date: 'not a date' } }), + template: ` + + + + + + `, + methods: { + onGet (source) { + return source instanceof Date ? source.getDate() : source + }, + + onSet (source) { + if (source instanceof Date) { + return source + } + + if (isNaN(source)) { + return undefined + } + + const result = new Date('2001-05-01') + result.setDate(source) + + return result + }, + } + }) + + await flushPromises() + + wrapper.find('input').setValue('12') + wrapper.find('input').trigger('input') + + await flushPromises() + + const form = wrapper.findComponent(FormularioForm) + + expect(form.emitted('input')).toEqual([ + [{ date: new Date('2001-05-12') }], + ]) + }) +}) diff --git a/test/unit/FormularioFieldGroup.test.js b/test/unit/FormularioFieldGroup.test.js new file mode 100644 index 0000000..be75bab --- /dev/null +++ b/test/unit/FormularioFieldGroup.test.js @@ -0,0 +1,98 @@ +import Vue from 'vue' + +import { mount } from '@vue/test-utils' +import flushPromises from 'flush-promises' + +import Formulario from '@/index.ts' +import FormularioFieldGroup from '@/FormularioFieldGroup.vue' +import FormularioForm from '@/FormularioForm.vue' + +Vue.use(Formulario) + +describe('FormularioFieldGroup', () => { + test('grouped fields to be set', async () => { + const wrapper = mount(FormularioForm, { + slots: { + default: ` + + + + + + `, + }, + }) + + wrapper.find('input').setValue('test') + wrapper.find('form').trigger('submit') + + await flushPromises() + + expect(wrapper.emitted('submit')).toEqual([ + [{ group: { text: 'test' } }], + ]) + }) + + test('grouped fields to be got', async () => { + const wrapper = mount(FormularioForm, { + propsData: { + state: { + group: { text: 'Group text' }, + text: 'Text', + }, + }, + slots: { + default: ` + + + + + + `, + }, + }) + + expect(wrapper.find('input').element['value']).toBe('Group text') + }) + + test('data reactive with grouped fields', async () => { + const wrapper = mount({ + data: () => ({ values: {} }), + template: ` + + + + + {{ values.group.text }} + + + + `, + }) + + expect(wrapper.find('span').text()).toBe('') + + wrapper.find('input').setValue('test') + + await flushPromises() + + expect(wrapper.find('span').text()).toBe('test') + }) + + test('errors are set for grouped fields', async () => { + const wrapper = mount(FormularioForm, { + propsData: { fieldsErrors: { 'address.street': ['Test error'] } }, + slots: { + default: ` + + + {{ error }} + + + `, + }, + }) + + expect(wrapper.findAll('span').length).toBe(1) + }) +}) diff --git a/test/unit/FormularioForm.test.js b/test/unit/FormularioForm.test.js index 9bd6d97..a0fe17f 100644 --- a/test/unit/FormularioForm.test.js +++ b/test/unit/FormularioForm.test.js @@ -1,6 +1,8 @@ import Vue from 'vue' + import { mount } from '@vue/test-utils' import flushPromises from 'flush-promises' + import Formulario from '@/index.ts' import FormularioForm from '@/FormularioForm.vue' @@ -13,442 +15,452 @@ Vue.use(Formulario, { }) describe('FormularioForm', () => { - it('render a form DOM element', () => { + test('renders a form DOM element', () => { const wrapper = mount(FormularioForm) expect(wrapper.find('form').exists()).toBe(true) }) - it('accepts a default slot', () => { + test('accepts a default slot', () => { const wrapper = mount(FormularioForm, { slots: { - default: '
' - } + default: '
' + }, }) - expect(wrapper.find('form div.default-slot-item').exists()).toBe(true) + + expect(wrapper.find('form [data-default]').exists()).toBe(true) }) - it('Intercepts submit event', () => { + test('can set a field’s initial value', async () => { const wrapper = mount(FormularioForm, { - slots: { - default: '