diff --git a/global.d.ts b/global.d.ts new file mode 100644 index 00000000..b867229b --- /dev/null +++ b/global.d.ts @@ -0,0 +1 @@ +declare const __DEV__: boolean; diff --git a/packages/core/src/useForm/formContext.ts b/packages/core/src/useForm/formContext.ts index 28477c5e..2bb86f38 100644 --- a/packages/core/src/useForm/formContext.ts +++ b/packages/core/src/useForm/formContext.ts @@ -13,7 +13,7 @@ import { import { cloneDeep, normalizeArrayable } from '../utils/common'; import { escapePath, findLeaf, getFromPath, isPathSet, setInPath, unsetPath as unsetInObject } from '../utils/path'; import { FormSnapshot } from './formSnapshot'; -import { merge } from '../../../shared/src'; +import { isObject, merge } from '../../../shared/src'; export type FormValidationMode = 'native' | 'schema'; @@ -83,7 +83,12 @@ export function createFormContext>(path: TPath) { - return !!getFromPath(touched, path); + const value = getFromPath(touched, path); + if (isObject(value)) { + return !!findLeaf(value, v => !!v); + } + + return !!value; } function isFieldSet>(path: TPath) { diff --git a/packages/core/src/useFormGroup/useFormGroup.spec.ts b/packages/core/src/useFormGroup/useFormGroup.spec.ts new file mode 100644 index 00000000..a2dc0b2b --- /dev/null +++ b/packages/core/src/useFormGroup/useFormGroup.spec.ts @@ -0,0 +1,176 @@ +import { renderSetup } from '@test-utils/renderSetup'; +import { Component } from 'vue'; +import { TypedSchema, useTextField, useForm, useFormGroup } from '@core/index'; +import { fireEvent, render, screen } from '@testing-library/vue'; +import { flush } from '@test-utils/flush'; + +test('warns if no form is present', async () => { + const warnFn = vi.spyOn(console, 'warn'); + + await renderSetup(() => { + return useFormGroup({ name: 'test' }); + }); + + expect(warnFn).toHaveBeenCalledOnce(); + warnFn.mockRestore(); +}); + +function createInputComponent(): Component { + return { + inheritAttrs: false, + setup: (_, { attrs }) => { + const name = (attrs.name || 'test') as string; + const schema = attrs.schema as TypedSchema; + const { errorMessage, inputProps } = useTextField({ name, label: name, schema }); + + return { errorMessage: errorMessage, inputProps, name }; + }, + template: ` + + {{ errorMessage }} + `, + }; +} + +function createGroupComponent(fn?: (fg: ReturnType) => void): Component { + return { + inheritAttrs: false, + setup: (_, { attrs }) => { + const name = (attrs.name || 'test') as string; + const schema = attrs.schema as TypedSchema; + const fg = useFormGroup({ name, label: name, schema }); + fn?.(fg); + + return {}; + }, + template: ` + + `, + }; +} + +test('prefixes path values with its name', async () => { + let form!: ReturnType; + await render({ + components: { TInput: createInputComponent(), TGroup: createGroupComponent() }, + setup() { + form = useForm(); + + return {}; + }, + template: ` + + + + + + + + `, + }); + + await flush(); + await fireEvent.update(screen.getByTestId('field1'), 'test 1'); + await fireEvent.update(screen.getByTestId('field2'), 'test 2'); + await flush(); + + expect(form.values).toEqual({ groupTest: { field1: 'test 1' }, nestedGroup: { deep: { field2: 'test 2' } } }); +}); + +test('tracks its dirty state', async () => { + const groups: ReturnType[] = []; + await render({ + components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) }, + setup() { + useForm(); + + return {}; + }, + template: ` + + + + + + + + `, + }); + + await flush(); + expect(groups[0].isDirty.value).toBe(false); + expect(groups[1].isDirty.value).toBe(false); + await fireEvent.update(screen.getByTestId('field1'), 'test 1'); + await flush(); + expect(groups[0].isDirty.value).toBe(true); + expect(groups[1].isDirty.value).toBe(false); +}); + +test('tracks its touched state', async () => { + const groups: ReturnType[] = []; + await render({ + components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) }, + setup() { + useForm(); + + return {}; + }, + template: ` + + + + + + + + `, + }); + + await flush(); + expect(groups[0].isTouched.value).toBe(false); + expect(groups[1].isTouched.value).toBe(false); + await fireEvent.touch(screen.getByTestId('field1')); + await flush(); + expect(groups[0].isTouched.value).toBe(true); + expect(groups[1].isTouched.value).toBe(false); +}); + +test('tracks its valid state', async () => { + const groups: ReturnType[] = []; + const schema: TypedSchema = { + async parse(value) { + return { + errors: value ? [] : [{ path: 'groupTest.field1', messages: ['error'] }], + }; + }, + }; + + await render({ + components: { TInput: createInputComponent(), TGroup: createGroupComponent(fg => groups.push(fg)) }, + setup() { + useForm(); + + return { + schema, + }; + }, + template: ` + + + + + + + + `, + }); + + await flush(); + expect(groups[0].isValid.value).toBe(false); + expect(groups[1].isValid.value).toBe(true); + await fireEvent.update(screen.getByTestId('field1'), 'test'); + await fireEvent.blur(screen.getByTestId('field1'), 'test'); + await flush(); + expect(groups[0].isValid.value).toBe(true); + expect(groups[1].isValid.value).toBe(true); +}); diff --git a/packages/core/src/useFormGroup/useFormGroup.ts b/packages/core/src/useFormGroup/useFormGroup.ts index 8c09dfc3..8eed7057 100644 --- a/packages/core/src/useFormGroup/useFormGroup.ts +++ b/packages/core/src/useFormGroup/useFormGroup.ts @@ -3,6 +3,7 @@ import { createBlock, defineComponent, Fragment, + inject, InjectionKey, openBlock, provide, @@ -14,10 +15,11 @@ import { import { useLabel } from '../a11y/useLabel'; import { FieldTypePrefixes } from '../constants'; import { AriaLabelableProps, AriaLabelProps, FormObject, Reactivify, TypedSchema } from '../types'; -import { normalizeProps, useUniqId, withRefCapture } from '../utils/common'; +import { isEqual, normalizeProps, useUniqId, warn, withRefCapture } from '../utils/common'; +import { FormKey } from '@core/useForm'; export interface FormGroupProps { - name?: string; + name: string; label?: string; schema?: TypedSchema; } @@ -40,6 +42,10 @@ export function useFormGroup(); + const form = inject(FormKey, null); + if (!form) { + warn('Form groups must have a parent form. Please make sure to call `useForm` at a parent component.'); + } const { labelProps, labelledByProps } = useLabel({ for: id, @@ -63,6 +69,36 @@ export function useFormGroup e.path.startsWith(path)); + } + + const isValid = computed(() => getErrors().length === 0); + const isTouched = computed(() => form?.isFieldTouched(toValue(props.name)) ?? false); + const isDirty = computed(() => { + const path = toValue(props.name); + + return !isEqual(getValues(), form?.getFieldOriginalValue(path) ?? {}); + }); + + function getError(name: string) { + return form?.getFieldErrors(ctx.prefixPath(name) ?? '')?.[0]; + } + + function displayError(name: string) { + const msg = getError(name); + const path = ctx.prefixPath(name) ?? ''; + + return form?.isFieldTouched(path) ? msg : undefined; + } + const ctx: FormGroupContext = { prefixPath(path: string | undefined) { return prefixGroupPath(toValue(props.name), path); @@ -76,6 +112,13 @@ export function useFormGroup Promise, TRe return new Promise(resolve => resolves.push(resolve)); }; } + +export function warn(message: string) { + if (__DEV__) { + console.warn(`[Formwerk]: ${message}`); + } +} diff --git a/packages/playground/src/App.vue b/packages/playground/src/App.vue index 4b47db6a..56e92bbb 100644 --- a/packages/playground/src/App.vue +++ b/packages/playground/src/App.vue @@ -1,32 +1,27 @@ diff --git a/tsconfig.json b/tsconfig.json index 65f84432..2be2dc61 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -26,6 +26,6 @@ "types": ["vitest/globals"], "typeRoots": ["node_modules/@types", "node_modules"] }, - "include": ["packages/*/src", "packages/*/tests", "commitlint.config.ts"], + "include": ["packages/*/src", "packages/*/tests", "commitlint.config.ts", "global.d.ts"], "exclude": ["packages/playground"] }