From 662192bc9a100749468f40a1beabb038250e14b1 Mon Sep 17 00:00:00 2001 From: Victor Garcia Date: Thu, 12 Dec 2024 17:30:55 +0100 Subject: [PATCH] fix: validator triggering too many times #32, field does not become dirty after touching it #33 --- docs/.vitepress/theme/index.ts | 1 - docs/src/demo/FieldError.vue | 11 - docs/src/demo/SimpleDemo.vue | 144 ----- docs/src/examples/simple.md | 7 - .../createReactiveCollectionRoot.ts | 14 +- .../createReactiveFieldStatus.ts | 6 +- .../createReactiveNestedStatus.ts | 24 +- .../createReactiveRuleStatus.ts | 4 +- playground/vue3/src/App.vue | 4 +- playground/vue3/src/components/Test15.vue | 512 ++++++++++++++++++ postcss.config.mjs | 6 - tailwind.config.mjs | 5 - .../useRegle/collections/collections.spec.ts | 28 +- tests/unit/useRegle/nested/nested.spec.ts | 10 +- .../properties/$extractDirtyFields.spec.ts | 93 ++++ 15 files changed, 658 insertions(+), 211 deletions(-) delete mode 100644 docs/src/demo/FieldError.vue delete mode 100644 docs/src/demo/SimpleDemo.vue create mode 100644 playground/vue3/src/components/Test15.vue delete mode 100644 postcss.config.mjs delete mode 100644 tailwind.config.mjs create mode 100644 tests/unit/useRegle/properties/$extractDirtyFields.spec.ts diff --git a/docs/.vitepress/theme/index.ts b/docs/.vitepress/theme/index.ts index d8d88dd..62ab2ab 100644 --- a/docs/.vitepress/theme/index.ts +++ b/docs/.vitepress/theme/index.ts @@ -8,7 +8,6 @@ import TwoslashFloatingVue from '@shikijs/vitepress-twoslash/client'; import type { EnhanceAppContext } from 'vitepress'; import 'virtual:group-icons.css'; import { createPinia } from 'pinia'; -import './tailwind.postcss'; export default { extends: DefaultTheme, diff --git a/docs/src/demo/FieldError.vue b/docs/src/demo/FieldError.vue deleted file mode 100644 index 10993ce..0000000 --- a/docs/src/demo/FieldError.vue +++ /dev/null @@ -1,11 +0,0 @@ - - - diff --git a/docs/src/demo/SimpleDemo.vue b/docs/src/demo/SimpleDemo.vue deleted file mode 100644 index 7cb998b..0000000 --- a/docs/src/demo/SimpleDemo.vue +++ /dev/null @@ -1,144 +0,0 @@ - - - - - diff --git a/docs/src/examples/simple.md b/docs/src/examples/simple.md index e3aa540..c11f7d2 100644 --- a/docs/src/examples/simple.md +++ b/docs/src/examples/simple.md @@ -2,10 +2,6 @@ title: Simple demo --- - - # Simple demo You can play with the code of this example in the stackblitz sandbox. @@ -20,8 +16,5 @@ Don't forgot to install the `Vue` extension in the online IDE. -## Rendered demo - - diff --git a/packages/core/src/core/useRegle/useStateProperties/collections/createReactiveCollectionRoot.ts b/packages/core/src/core/useRegle/useStateProperties/collections/createReactiveCollectionRoot.ts index cfc3bb2..86fbab8 100644 --- a/packages/core/src/core/useRegle/useStateProperties/collections/createReactiveCollectionRoot.ts +++ b/packages/core/src/core/useRegle/useStateProperties/collections/createReactiveCollectionRoot.ts @@ -443,13 +443,13 @@ export function createReactiveCollectionStatus({ }); if (filterNullishValues) { - dirtyFields = dirtyFields.filter((value) => { - if (isObject(value)) { - return !isEmpty(value); - } else { - return !!value; - } - }); + if ( + dirtyFields.every((value) => { + return isEmpty(value); + }) + ) { + dirtyFields = []; + } } return dirtyFields; } diff --git a/packages/core/src/core/useRegle/useStateProperties/createReactiveFieldStatus.ts b/packages/core/src/core/useRegle/useStateProperties/createReactiveFieldStatus.ts index df33b85..960674e 100644 --- a/packages/core/src/core/useRegle/useStateProperties/createReactiveFieldStatus.ts +++ b/packages/core/src/core/useRegle/useStateProperties/createReactiveFieldStatus.ts @@ -453,10 +453,14 @@ export function createReactiveFieldStatus({ $reset(); } - function $extractDirtyFields(filterNullishValues: boolean = true): any | null { + function $extractDirtyFields(filterNullishValues: boolean = true): unknown | null | { _null: true } { if ($dirty.value) { return state.value; } + if (filterNullishValues) { + // Differenciate untouched empty values from dirty empty ones + return { _null: true }; + } return null; } diff --git a/packages/core/src/core/useRegle/useStateProperties/createReactiveNestedStatus.ts b/packages/core/src/core/useRegle/useStateProperties/createReactiveNestedStatus.ts index 2fcc7bc..63df38b 100644 --- a/packages/core/src/core/useRegle/useStateProperties/createReactiveNestedStatus.ts +++ b/packages/core/src/core/useRegle/useStateProperties/createReactiveNestedStatus.ts @@ -371,20 +371,24 @@ export function createReactiveNestedStatus({ createReactiveFieldsStatus(); } + function filterNullishFields(fields: [string, unknown][]) { + return fields.filter(([key, value]) => { + if (isObject(value)) { + return !(value && typeof value === 'object' && '_null' in value) && !isEmpty(value); + } else if (Array.isArray(value)) { + return value.length; + } else { + return true; + } + }); + } + function $extractDirtyFields(filterNullishValues: boolean = true): Record { - let dirtyFields = Object.entries($fields.value).map(([key, field]) => { + let dirtyFields: [string, unknown][] = Object.entries($fields.value).map(([key, field]) => { return [key, field.$extractDirtyFields(filterNullishValues)]; }); if (filterNullishValues) { - dirtyFields = dirtyFields.filter(([key, value]) => { - if (isObject(value)) { - return !isEmpty(value); - } else if (Array.isArray(value)) { - return value.length; - } else { - return !!value; - } - }); + dirtyFields = filterNullishFields(dirtyFields); } return Object.fromEntries(dirtyFields); } diff --git a/packages/core/src/core/useRegle/useStateProperties/createReactiveRuleStatus.ts b/packages/core/src/core/useRegle/useStateProperties/createReactiveRuleStatus.ts index 86938a8..af37290 100644 --- a/packages/core/src/core/useRegle/useStateProperties/createReactiveRuleStatus.ts +++ b/packages/core/src/core/useRegle/useStateProperties/createReactiveRuleStatus.ts @@ -158,9 +158,7 @@ export function createReactiveRuleStatus({ } satisfies ScopeState; })!; - $unwatchState = watch(scopeState.$params, $validate, { - deep: true, - }); + $unwatchState = watch(scopeState.$params, $validate); } $watch(); diff --git a/playground/vue3/src/App.vue b/playground/vue3/src/App.vue index 796214a..1e39f4c 100644 --- a/playground/vue3/src/App.vue +++ b/playground/vue3/src/App.vue @@ -1,10 +1,10 @@ diff --git a/postcss.config.mjs b/postcss.config.mjs deleted file mode 100644 index 2aa7205..0000000 --- a/postcss.config.mjs +++ /dev/null @@ -1,6 +0,0 @@ -export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, -}; diff --git a/tailwind.config.mjs b/tailwind.config.mjs deleted file mode 100644 index 9a3eca1..0000000 --- a/tailwind.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -/** @type {import('tailwindcss').Config} */ -export default { - content: ['./docs/.vitepress/**/*.{js,ts,vue}', './docs/**/*.{md,vue}'], - plugins: [require('@tailwindcss/forms')], -}; diff --git a/tests/unit/useRegle/collections/collections.spec.ts b/tests/unit/useRegle/collections/collections.spec.ts index 9ee6a6d..4ec265d 100644 --- a/tests/unit/useRegle/collections/collections.spec.ts +++ b/tests/unit/useRegle/collections/collections.spec.ts @@ -2,11 +2,7 @@ import { createRule, useRegle } from '@regle/core'; import { minLength, required, requiredIf, ruleHelpers } from '@regle/rules'; import { nextTick, ref, type Ref } from 'vue'; import { createRegleComponent } from '../../../utils/test.utils'; -import { - shouldBeInvalidField, - shouldBePristineField, - shouldBeValidField, -} from '../../../utils/validations.utils'; +import { shouldBeInvalidField, shouldBePristineField, shouldBeValidField } from '../../../utils/validations.utils'; describe('collections validations', () => { function nestedCollectionRules() { @@ -63,6 +59,17 @@ describe('collections validations', () => { return true; }); + const deepNestedParamSpy = vi.fn((params: Object) => { + return true; + }); + + const deepNestedParamRule = createRule({ + validator(value: unknown, params: Object) { + return deepNestedParamSpy(params); + }, + message: 'This field is required', + }); + const requiredIfMock = createRule({ validator(value: unknown, condition: boolean) { return requiredIfSpy(value, condition); @@ -90,7 +97,10 @@ describe('collections validations', () => { name: { required }, level1: { $each: (value, index) => ({ - name: { required: requiredIfMock(() => parent.value.name === 'required') }, + name: { + required: requiredIfMock(() => parent.value.name === 'required'), + nested: deepNestedParamRule(value), + }, }), }, }), @@ -102,19 +112,23 @@ describe('collections validations', () => { shouldBePristineField(vm.r$.$fields.level0.$each[0].$fields.level1.$each[0].$fields.name); expect(requiredIfSpy).toHaveBeenCalledTimes(1); + expect(deepNestedParamSpy).toHaveBeenCalledTimes(1); vm.r$.$value.level0.push({ name: '', level1: [{ name: '' }] }); await nextTick(); shouldBeInvalidField(vm.r$.$fields.level0.$each[1].$fields.name); expect(requiredIfSpy).toHaveBeenCalledTimes(2); + expect(deepNestedParamSpy).toHaveBeenCalledTimes(2); vm.r$.$value.level0[0].level1.push({ name: '' }); await nextTick(); expect(requiredIfSpy).toHaveBeenCalledTimes(3); + expect(deepNestedParamSpy).toHaveBeenCalledTimes(3); vm.r$.$value.level0[0].name = 'required'; await nextTick(); expect(requiredIfSpy).toHaveBeenCalledTimes(7); + expect(deepNestedParamSpy).toHaveBeenCalledTimes(3); shouldBeInvalidField(vm.r$.$fields.level0.$each[0].$fields.level1.$each[0].$fields.name); }); @@ -137,7 +151,7 @@ describe('collections validations', () => { const cache = vm.r$.$value.level0[0].level1[0]; vm.r$.$value.level0[0].level1[0] = vm.r$.$value.level0[0].level1[1]; vm.r$.$value.level0[0].level1[1] = cache; - await nextTick(); + await vm.$nextTick(); shouldBeInvalidField(vm.r$.$fields.level0.$each[0].$fields.level1.$each[0].$fields.name); shouldBeValidField(vm.r$.$fields.level0.$each[0].$fields.level1.$each[1].$fields.name); diff --git a/tests/unit/useRegle/nested/nested.spec.ts b/tests/unit/useRegle/nested/nested.spec.ts index ac54bcb..38ccb20 100644 --- a/tests/unit/useRegle/nested/nested.spec.ts +++ b/tests/unit/useRegle/nested/nested.spec.ts @@ -1,12 +1,8 @@ import { useRegle } from '@regle/core'; -import { minLength, required } from '@regle/rules'; -import { nextTick, ref } from 'vue'; +import { required } from '@regle/rules'; +import { ref } from 'vue'; import { createRegleComponent } from '../../../utils/test.utils'; -import { - shouldBeInvalidField, - shouldBePristineField, - shouldBeValidField, -} from '../../../utils/validations.utils'; +import { shouldBeInvalidField, shouldBeValidField } from '../../../utils/validations.utils'; describe('nested validations', () => { function nestedCollectionRules() { diff --git a/tests/unit/useRegle/properties/$extractDirtyFields.spec.ts b/tests/unit/useRegle/properties/$extractDirtyFields.spec.ts new file mode 100644 index 0000000..6c0be04 --- /dev/null +++ b/tests/unit/useRegle/properties/$extractDirtyFields.spec.ts @@ -0,0 +1,93 @@ +import { useRegle } from '@regle/core'; +import { email, required } from '@regle/rules'; +import { ref } from 'vue'; +import { createRegleComponent } from '../../../utils/test.utils'; +import { shouldBeInvalidField, shouldBeValidField } from '../../../utils/validations.utils'; + +describe('$extractDirtyFields', () => { + function extractDirtyFieldsRegles() { + const form = ref({ + email: '', + user: { + firstName: '', + lastName: '', + }, + contacts: [{ name: '' }, { name: '' }], + }); + + return useRegle(form, { + email: { required: required, email: email }, + user: { + firstName: { required }, + lastName: { required }, + }, + contacts: { + $each: { + name: { required }, + }, + }, + }); + } + + it('should return empty object if called directly', () => { + const { vm } = createRegleComponent(extractDirtyFieldsRegles); + + let dirtyFields = vm.r$.$extractDirtyFields(); + + expect(dirtyFields).toStrictEqual({}); + }); + + it('should return whole object if called directly without filtering', () => { + const { vm } = createRegleComponent(extractDirtyFieldsRegles); + + let dirtyFields = vm.r$.$extractDirtyFields(false); + + expect(dirtyFields).toStrictEqual({ + email: null, + user: { + firstName: null, + lastName: null, + }, + contacts: [{ name: null }, { name: null }], + }); + }); + + it('should return dirty fields after modifying them', async () => { + const { vm } = createRegleComponent(extractDirtyFieldsRegles); + + vm.r$.$value.email = 'foo'; + vm.r$.$value.user.firstName = 'foo'; + vm.r$.$value.contacts[1].name = 'foo'; + await vm.$nextTick(); + + let dirtyFields = vm.r$.$extractDirtyFields(); + + expect(dirtyFields).toStrictEqual({ + email: 'foo', + user: { + firstName: 'foo', + }, + contacts: [{}, { name: 'foo' }], + }); + }); + + it('should return whole tree after modifying them if filtering is disabled', async () => { + const { vm } = createRegleComponent(extractDirtyFieldsRegles); + + vm.r$.$value.email = 'foo'; + vm.r$.$value.user.firstName = 'foo'; + vm.r$.$value.contacts[1].name = 'foo'; + await vm.$nextTick(); + + let dirtyFields = vm.r$.$extractDirtyFields(false); + + expect(dirtyFields).toStrictEqual({ + email: 'foo', + user: { + firstName: 'foo', + lastName: null, + }, + contacts: [{ name: null }, { name: 'foo' }], + }); + }); +});